From b2e23d47b3193a30ae6c374a5f6f5b1e8e770c7e Mon Sep 17 00:00:00 2001 From: kagami Date: Wed, 26 Jun 2024 17:34:26 +0800 Subject: [PATCH 01/26] Init devchat service --- devchat/_service/Makefile | 7 ++ devchat/_service/README.md | 0 devchat/_service/__init__.py | 0 devchat/_service/main.py | 30 ++++++ devchat/_service/route/__init__.py | 10 ++ devchat/_service/route/log.py | 46 ++++++++++ devchat/_service/route/message.py | 142 +++++++++++++++++++++++++++++ devchat/_service/route/workflow.py | 132 +++++++++++++++++++++++++++ 8 files changed, 367 insertions(+) create mode 100644 devchat/_service/Makefile create mode 100644 devchat/_service/README.md create mode 100644 devchat/_service/__init__.py create mode 100644 devchat/_service/main.py create mode 100644 devchat/_service/route/__init__.py create mode 100644 devchat/_service/route/log.py create mode 100644 devchat/_service/route/message.py create mode 100644 devchat/_service/route/workflow.py diff --git a/devchat/_service/Makefile b/devchat/_service/Makefile new file mode 100644 index 00000000..d6e428b3 --- /dev/null +++ b/devchat/_service/Makefile @@ -0,0 +1,7 @@ +.PHONY: run-dev + +# @uvicorn main:app --reload --host 0.0.0.0 --port 22222 +run-dev: + @echo "Running dev server..." + @python main.py + diff --git a/devchat/_service/README.md b/devchat/_service/README.md new file mode 100644 index 00000000..e69de29b diff --git a/devchat/_service/__init__.py b/devchat/_service/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/devchat/_service/main.py b/devchat/_service/main.py new file mode 100644 index 00000000..6c1d48ce --- /dev/null +++ b/devchat/_service/main.py @@ -0,0 +1,30 @@ +from fastapi import FastAPI + +from devchat._service.route import router +from dotenv import load_dotenv +# import socketio +import uvicorn + +# from devchat._service.route.async_socket import sio_app + +# TODO: manage env variables +load_dotenv() + + +fa_app = FastAPI( + title="DevChat Local Service", +) +fa_app.mount("/devchat", router) +fa_app.include_router(router) + +# app = socketio.ASGIApp(sio_app, fa_app, socketio_path="devchat.socket") + +if __name__ == '__main__': + # uvicorn.run("devchat._service.main:app", host='127.0.0.1', port=22222, reload=True) + uvicorn.run("devchat._service.main:fa_app", host='127.0.0.1', port=22222, reload=True) + + +# https://www.reddit.com/r/FastAPI/comments/170awhx/mount_socketio_to_fastapi/ +# https://github.com/miguelgrinberg/python-socketio/blob/main/examples/server/asgi/fastapi-fiddle.py +# not work: https://github.com/tiangolo/fastapi/discussions/8781 + diff --git a/devchat/_service/route/__init__.py b/devchat/_service/route/__init__.py new file mode 100644 index 00000000..0a26ccf3 --- /dev/null +++ b/devchat/_service/route/__init__.py @@ -0,0 +1,10 @@ +from fastapi import APIRouter +from .workflow import router as workflow_router +from .message import router as message_router +from .log import router as log_router + +router = APIRouter() + +router.include_router(workflow_router, prefix="/workflow", tags=["Workflow"]) +router.include_router(message_router, prefix="/message", tags=["Message"]) +router.include_router(log_router, prefix="/log", tags=["LogManager"]) \ No newline at end of file diff --git a/devchat/_service/route/log.py b/devchat/_service/route/log.py new file mode 100644 index 00000000..99b5af40 --- /dev/null +++ b/devchat/_service/route/log.py @@ -0,0 +1,46 @@ +import time +import json +from concurrent.futures import ProcessPoolExecutor + +from typing import Dict, Optional, List, Iterator +from fastapi import APIRouter +from fastapi.responses import StreamingResponse + +from pydantic import BaseModel, Field + +from devchat.msg.logm import gen_log_prompt, insert_log_promt + +router = APIRouter() + + +@router.get("/hello") +async def hello(): + return {"hello": "devchat log"} + + +class InsertLogRequest(BaseModel): + jsondata: Optional[str] = Field(None, description="data to insert in json format") + filepath: Optional[str] = Field(None, description="file path to insert data in json format") + +class InsertLogResponse(BaseModel): + hash: Optional[str] = Field(None, description="hash of the inserted data") + error: Optional[str] = Field(None, description="error message") + +@router.post("/insert", response_model=InsertLogResponse) +async def insert( + request: InsertLogRequest, +): + print(f"\n\ncheck insert log request: \n{request}") + # TODO: handle error + error_msg = None + prompt = gen_log_prompt(request.jsondata, request.filepath) + + insert_log_promt(prompt) + # # execute the insert in a separate process? + # with ProcessPoolExecutor() as executor: + # executor.submit(insert_log_promt, prompt) + + return InsertLogResponse(hash=prompt.hash, error=error_msg) + + + diff --git a/devchat/_service/route/message.py b/devchat/_service/route/message.py new file mode 100644 index 00000000..6b7d2744 --- /dev/null +++ b/devchat/_service/route/message.py @@ -0,0 +1,142 @@ +import time +import json + +from typing import Dict, Optional, List, Iterator +from fastapi import APIRouter +from fastapi.responses import StreamingResponse + + +router = APIRouter() + + +@router.get("/hello") +async def hello(): + return {"hello": "devchat message"} + + +sample_txt = """ +金樽清酒斗十千,玉盘珍羞直万钱。 +停杯投箸不能食,拔剑四顾心茫然。 +欲渡黄河冰塞川,将登太行雪满山。 +闲来垂钓碧溪上,忽复乘舟梦日边。 +行路难!行路难!多歧路,今安在? +长风破浪会有时,直挂云帆济沧海。 +""" + + +@router.get("/ssample") +async def sample(): + + def _gen(): + # for char in sample_txt: + # time.sleep(0.1) + # yield char + lines = sample_txt.split("\n") + for line in lines: + time.sleep(0.5) + yield line + "\n" + + return StreamingResponse(_gen(), media_type="text/plain") + + +from devchat.msg.util import mk_meta, route_message, MessageType +from devchat.msg.schema import MessageRequest, MessageResponseChunk +from devchat.msg.chatting import chatting +from devchat.workflow.workflow import Workflow + + +def _mock_gen( + content: str, user_str: str, date_str: str +) -> Iterator[MessageResponseChunk]: + yield MessageResponseChunk( + user=user_str, date=date_str, content="\n\n# mock response\n\n" + ).json() + + count = 0 + chunk = [] + for c in content: + count += 1 + chunk.append(c) + if count % 5 == 0: + time.sleep(0.2) + res_chunk = MessageResponseChunk( + user=user_str, date=date_str, content="".join(chunk) + ) + a = res_chunk.json() + yield res_chunk.json() + chunk = [] + if chunk: + res_chunk = MessageResponseChunk( + user=user_str, date=date_str, content="".join(chunk) + ) + yield res_chunk.json() + + +@router.post("/msg") +async def msg( + request: MessageRequest, +): + print(f"check request: \n{request}") + user_str, date_str = mk_meta() + + message_type, extra = route_message(request) + print(f"message type: {message_type}") + + if message_type == MessageType.CHATTING: + + def gen_chat_response() -> Iterator[MessageResponseChunk]: + for res in chatting(request): + chunk = MessageResponseChunk(user=user_str, date=date_str, content=res) + yield chunk.json() + + return StreamingResponse(gen_chat_response(), media_type="application/json") + + elif message_type == MessageType.WORKFLOW: + workflow: Workflow + wf_name: str + wf_input: Optional[str] + workflow, wf_name, wf_input = extra + + if workflow.should_show_help(wf_input): + + doc = workflow.get_help_doc(wf_input) + + def _gen_res_help() -> Iterator[MessageResponseChunk]: + yield MessageResponseChunk( + user=user_str, date=date_str, content=doc + ).json() + + return StreamingResponse(_gen_res_help(), media_type="application/json") + else: + # # run the workflow + + # workflow.setup( + # model_name=request.model_name, + # user_input=wf_input, + # history_messages=[], # not in use, will be assistant.prompt.messages if needed + # parent_hash=request.parent, + # ) + # return_code = workflow.run_steps() + # ret_val = [ + # f"- the requst content is: \n\n{request.content}\n\n", + # f"- workflow return_code: \n\n{return_code}\n\n", + # ] + def _gen_res_run_workflow() -> Iterator[MessageResponseChunk]: + yield MessageResponseChunk( + user=user_str, + date=date_str, + content="", + finish_reason="should_run_workflow", + extra={"workflow_name": wf_name, "workflow_input": wf_input}, + ).json() + + return StreamingResponse( + _gen_res_run_workflow(), + media_type="application/json", + ) + + else: + content = request.content + return StreamingResponse( + _mock_gen(content, user_str, date_str), media_type="application/json" + ) diff --git a/devchat/_service/route/workflow.py b/devchat/_service/route/workflow.py new file mode 100644 index 00000000..1cde7540 --- /dev/null +++ b/devchat/_service/route/workflow.py @@ -0,0 +1,132 @@ +from dataclasses import asdict, dataclass, field +from pathlib import Path +from typing import Dict, List, Set, Tuple + +import oyaml as yaml +import yaml as pyyaml + + +from fastapi import APIRouter +from fastapi.responses import JSONResponse + +from pydantic import BaseModel, Field + +from devchat.workflow.namespace import get_prioritized_namespace_path +from devchat.workflow.path import ( + COMMAND_FILENAMES, + WORKFLOWS_BASE, + WORKFLOWS_CONFIG_FILENAME, +) +from devchat.workflow.command.update import update_by_git, update_by_zip, copy_workflows_usr + +HAS_GIT = False +try: + from git import GitCommandError, InvalidGitRepositoryError, Repo +except ImportError: + pass +else: + HAS_GIT = True + +router = APIRouter() + + +class WorkflowMeta(BaseModel): + name: str = Field(..., description="workflow name") + namespace: str = Field(..., description="workflow namespace") + active: bool = Field(..., description="active flag") + command_conf: Dict = Field(description="command configuration", default_factory=dict) + + +@router.get("/") +async def hello(): + return {"hello": "devchat workflow"} + + +def iter_namespace( + ns_path: str, existing_names: Set[str] +) -> Tuple[List[WorkflowMeta], Set[str]]: + """ + Get all workflows under the namespace path. + + Args: + ns_path: the namespace path + existing_names: the existing workflow names to check if the workflow is the first priority + + Returns: + List[WorkflowMeta]: the workflows + Set[str]: the updated existing workflow names + """ + root = Path(ns_path) + interest_files = set(COMMAND_FILENAMES) + result = [] + unique_names = set(existing_names) + for file in root.rglob("*"): + try: + if file.is_file() and file.name in interest_files: + rel_path = file.relative_to(root) + parts = rel_path.parts + workflow_name = ".".join(parts[:-1]) + is_first = workflow_name not in unique_names + + # load the config content from file + with open(file, "r", encoding="utf-8") as file_handle: + yaml_content = file_handle.read() + command_conf = yaml.safe_load(yaml_content) + # pop the "steps" field + command_conf.pop("steps", None) + + workflow = WorkflowMeta( + name=workflow_name, + namespace=root.name, + active=is_first, + command_conf=command_conf, + ) + unique_names.add(workflow_name) + result.append(workflow) + except pyyaml.scanner.ScannerError as err: + # TODO: log the error + # logger.error("Failed to load %s: %s", rel_path, err) + print("Failed to load %s: %s", rel_path, err) + except Exception as err: + # TODO: log the error + # logger.error("Unknown error when loading %s: %s", rel_path, err) + print("Unknown error when loading %s: %s", rel_path, err) + + return result, unique_names + + +# TODO: handle errors +@router.get("/list", response_model=List[WorkflowMeta]) +async def list_workflow(): + namespace_paths = get_prioritized_namespace_path() + + workflows: List[WorkflowMeta] = [] + visited_names = set() + for ns_path in namespace_paths: + ws_names, visited_names = iter_namespace(ns_path, visited_names) + workflows.extend(ws_names) + + return workflows + + +@router.get("/config") +async def get_config(): + config_path = Path(WORKFLOWS_BASE) / WORKFLOWS_CONFIG_FILENAME + config_content = {} + if config_path.exists(): + with open(config_path, "r", encoding="utf-8") as file: + config_content = yaml.safe_load(file.read()) + + return JSONResponse(content=config_content) + + +# @router.get("/update") +# async def update_workflows(): +# base_path = Path(WORKFLOWS_BASE) + +# if HAS_GIT: +# update_by_git(base_path) +# else: +# update_by_zip(base_path) + +# copy_workflows_usr() From f7b534f3d17a86c6cba586b4c4c973b894f2a01c Mon Sep 17 00:00:00 2001 From: kagami Date: Wed, 26 Jun 2024 22:18:00 +0800 Subject: [PATCH 02/26] Make workflow operations available for both _cli and _service --- devchat/_service/route/workflow.py | 105 ++---- devchat/workflow/command/list.py | 71 +--- devchat/workflow/command/update.py | 296 +---------------- devchat/workflow/namespace.py | 69 +++- .../{command => }/update_flowchart.md | 0 devchat/workflow/update_util.py | 310 ++++++++++++++++++ 6 files changed, 425 insertions(+), 426 deletions(-) rename devchat/workflow/{command => }/update_flowchart.md (100%) create mode 100644 devchat/workflow/update_util.py diff --git a/devchat/_service/route/workflow.py b/devchat/_service/route/workflow.py index 1cde7540..d4c09ec3 100644 --- a/devchat/_service/route/workflow.py +++ b/devchat/_service/route/workflow.py @@ -3,7 +3,6 @@ from typing import Dict, List, Set, Tuple import oyaml as yaml -import yaml as pyyaml from fastapi import APIRouter @@ -11,90 +10,32 @@ from pydantic import BaseModel, Field -from devchat.workflow.namespace import get_prioritized_namespace_path +from devchat.workflow.namespace import ( + get_prioritized_namespace_path, + iter_namespace, + WorkflowMeta, +) from devchat.workflow.path import ( COMMAND_FILENAMES, WORKFLOWS_BASE, WORKFLOWS_CONFIG_FILENAME, ) -from devchat.workflow.command.update import update_by_git, update_by_zip, copy_workflows_usr +from devchat.workflow.update_util import ( + update_by_git, + update_by_zip, + HAS_GIT, + copy_workflows_usr, +) -HAS_GIT = False -try: - from git import GitCommandError, InvalidGitRepositoryError, Repo -except ImportError: - pass -else: - HAS_GIT = True router = APIRouter() -class WorkflowMeta(BaseModel): - name: str = Field(..., description="workflow name") - namespace: str = Field(..., description="workflow namespace") - active: bool = Field(..., description="active flag") - command_conf: Dict = Field(description="command configuration", default_factory=dict) - - @router.get("/") async def hello(): return {"hello": "devchat workflow"} -def iter_namespace( - ns_path: str, existing_names: Set[str] -) -> Tuple[List[WorkflowMeta], Set[str]]: - """ - Get all workflows under the namespace path. - - Args: - ns_path: the namespace path - existing_names: the existing workflow names to check if the workflow is the first priority - - Returns: - List[WorkflowMeta]: the workflows - Set[str]: the updated existing workflow names - """ - root = Path(ns_path) - interest_files = set(COMMAND_FILENAMES) - result = [] - unique_names = set(existing_names) - for file in root.rglob("*"): - try: - if file.is_file() and file.name in interest_files: - rel_path = file.relative_to(root) - parts = rel_path.parts - workflow_name = ".".join(parts[:-1]) - is_first = workflow_name not in unique_names - - # load the config content from file - with open(file, "r", encoding="utf-8") as file_handle: - yaml_content = file_handle.read() - command_conf = yaml.safe_load(yaml_content) - # pop the "steps" field - command_conf.pop("steps", None) - - workflow = WorkflowMeta( - name=workflow_name, - namespace=root.name, - active=is_first, - command_conf=command_conf, - ) - unique_names.add(workflow_name) - result.append(workflow) - except pyyaml.scanner.ScannerError as err: - # TODO: log the error - # logger.error("Failed to load %s: %s", rel_path, err) - print("Failed to load %s: %s", rel_path, err) - except Exception as err: - # TODO: log the error - # logger.error("Unknown error when loading %s: %s", rel_path, err) - print("Unknown error when loading %s: %s", rel_path, err) - - return result, unique_names - - # TODO: handle errors @router.get("/list", response_model=List[WorkflowMeta]) async def list_workflow(): @@ -120,13 +61,21 @@ async def get_config(): return JSONResponse(content=config_content) -# @router.get("/update") -# async def update_workflows(): -# base_path = Path(WORKFLOWS_BASE) +class UpdateWorkflows(BaseModel): + updated: bool = Field(..., description="Whether the workflows are updated.") + message: str = Field(..., description="The message of the update.") + + +# TODO: set time out? what if the update takes too long due to user's network? +@router.post("/update", response_model=UpdateWorkflows) +async def update_workflows(): + base_path = Path(WORKFLOWS_BASE) + + if HAS_GIT: + updated, message = update_by_git(base_path) + else: + updated, message = update_by_zip(base_path) -# if HAS_GIT: -# update_by_git(base_path) -# else: -# update_by_zip(base_path) + copy_workflows_usr() -# copy_workflows_usr() + return UpdateWorkflows(updated=updated, message=message) diff --git a/devchat/workflow/command/list.py b/devchat/workflow/command/list.py index e6fd664c..7c802af0 100644 --- a/devchat/workflow/command/list.py +++ b/devchat/workflow/command/list.py @@ -4,76 +4,20 @@ from typing import Dict, List, Set, Tuple import click + import oyaml as yaml import yaml as pyyaml from devchat.utils import get_logger -from devchat.workflow.namespace import get_prioritized_namespace_path -from devchat.workflow.path import COMMAND_FILENAMES +from devchat.workflow.namespace import ( + get_prioritized_namespace_path, + iter_namespace, + WorkflowMeta, +) logger = get_logger(__name__) -@dataclass -class WorkflowMeta: - name: str - namespace: str - active: bool - command_conf: Dict = field( - default_factory=dict - ) # content of command.yml, excluding "steps" field - - def __str__(self): - return f"{'*' if self.active else ' '} {self.name} ({self.namespace})" - - -def iter_namespace(ns_path: str, existing_names: Set[str]) -> Tuple[List[WorkflowMeta], Set[str]]: - """ - Get all workflows under the namespace path. - - Args: - ns_path: the namespace path - existing_names: the existing workflow names to check if the workflow is the first priority - - Returns: - List[WorkflowMeta]: the workflows - Set[str]: the updated existing workflow names - """ - root = Path(ns_path) - interest_files = set(COMMAND_FILENAMES) - result = [] - unique_names = set(existing_names) - for file in root.rglob("*"): - try: - if file.is_file() and file.name in interest_files: - rel_path = file.relative_to(root) - parts = rel_path.parts - workflow_name = ".".join(parts[:-1]) - is_first = workflow_name not in unique_names - - # load the config content from file - with open(file, "r", encoding="utf-8") as file_handle: - yaml_content = file_handle.read() - command_conf = yaml.safe_load(yaml_content) - # pop the "steps" field - command_conf.pop("steps", None) - - workflow = WorkflowMeta( - name=workflow_name, - namespace=root.name, - active=is_first, - command_conf=command_conf, - ) - unique_names.add(workflow_name) - result.append(workflow) - except pyyaml.scanner.ScannerError as err: - logger.error("Failed to load %s: %s", rel_path, err) - except Exception as err: - logger.error("Unknown error when loading %s: %s", rel_path, err) - - return result, unique_names - - @click.command(help="List all local workflows.", name="list") @click.option("--json", "in_json", is_flag=True, help="Output in json format.") def list_cmd(in_json: bool): @@ -95,6 +39,7 @@ def list_cmd(in_json: bool): else: # convert workflows to json - data = [asdict(workflow) for workflow in workflows] + # data = [asdict(workflow) for workflow in workflows] + data = [workflow.dict() for workflow in workflows] json_format = json.dumps(data) click.echo(json_format) diff --git a/devchat/workflow/command/update.py b/devchat/workflow/command/update.py index fba3da51..3bd22615 100644 --- a/devchat/workflow/command/update.py +++ b/devchat/workflow/command/update.py @@ -7,300 +7,30 @@ from typing import List, Optional, Tuple import click -import requests - -from devchat.utils import get_logger from devchat.workflow.path import ( - CHAT_DIR, - CUSTOM_BASE, WORKFLOWS_BASE, - WORKFLOWS_BASE_NAME, ) - -HAS_GIT = False -try: - from git import GitCommandError, InvalidGitRepositoryError, Repo -except ImportError: - pass -else: - HAS_GIT = True - - -REPO_NAME = "workflows" -DEFAULT_BRANCH = "scripts" -REPO_URLS = [ - # url, branch - ("https://gitlab.com/devchat-ai/workflows.git", DEFAULT_BRANCH), - ("git@github.com:devchat-ai/workflows.git", DEFAULT_BRANCH), - ("https://github.com/devchat-ai/workflows.git", DEFAULT_BRANCH), -] -ZIP_URLS = [ - "https://gitlab.com/devchat-ai/workflows/-/archive/scripts/workflows-scripts.zip", - "https://codeload.github.com/devchat-ai/workflows/zip/refs/heads/scripts", -] - - -logger = get_logger(__name__) - - -def _backup(workflow_base: Path, n: int = 5) -> Optional[Path]: - """ - Backup the current workflow base dir to zip with timestamp under .backup. - - Args: - n: the number of backups to keep, default 3 - - Returns: - Path: the backup zip path - """ - - if not workflow_base.exists(): - return None - - backup_dir = workflow_base.parent / ".backup" - backup_dir.mkdir(exist_ok=True) - - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - backup_zip = backup_dir / f"{WORKFLOWS_BASE_NAME}_{timestamp}" - shutil.make_archive(backup_zip, "zip", workflow_base) - click.echo(f"Backup {workflow_base} to {backup_zip}") - - # keep the last n backups - backups = sorted(backup_dir.glob(f"{WORKFLOWS_BASE_NAME}_*"), reverse=True) - for backup in backups[n:]: - backup.unlink() - click.echo(f"Remove old backup {backup}") - - return backup_zip - - -def _download_zip_to_dir(candidate_urls: List[str], dst_dir: Path) -> bool: - """ - Download the zip file with the first successful url - in the candidate_urls to the target_dir. - - Args: - candidate_urls: the list of candidate urls - dst_dir: the dst dir of the extracted zip file, should not exist - - Returns: - bool: True if success else False - """ - assert not dst_dir.exists() - - download_ok = False - with tempfile.TemporaryDirectory() as tmp_dir: - tmp_dir_path = Path(tmp_dir) - - for url in candidate_urls: - try: - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - zip_path = tmp_dir_path / f"{WORKFLOWS_BASE_NAME}_{timestamp}.zip" - click.echo(f"Downloading workflow from {url} to {zip_path}") - - # Download the workflow zip file - response = requests.get(url, stream=True, timeout=10) - with open(zip_path, "wb") as zip_f: - for chunk in response.iter_content(chunk_size=8192): - if chunk: - zip_f.write(chunk) - - # Extract the zip file - with zipfile.ZipFile(zip_path, "r") as zip_f: - zip_f.extractall(tmp_dir_path) - - # move the extracted dir to target dir - extracted_dir = tmp_dir_path / f"{REPO_NAME}-{DEFAULT_BRANCH}" - shutil.move(extracted_dir, dst_dir) - click.echo(f"Extracted to {dst_dir}") - - download_ok = True - break - - except Exception as e: - click.echo(f"Failed to download from {url}: {e}") - - return download_ok - - -def update_by_zip(workflow_base: Path): - click.echo("Updating by zip file...") - parent = workflow_base.parent - - if not workflow_base.exists(): - # No previous workflows, download to the workflow_base directly - download_ok = _download_zip_to_dir(ZIP_URLS, workflow_base) - if not download_ok: - click.echo("Failed to download from all zip urls. Please Try again later.") - return - - click.echo(f"Updated {workflow_base} successfully by zip.") - - else: - # Has previous workflows, download as tmp_new - tmp_new = parent / f"{WORKFLOWS_BASE_NAME}_new" - if tmp_new.exists(): - shutil.rmtree(tmp_new) - # TODO: handle error? - # shutil.rmtree(tmp_new, onerror=__onerror) - - download_ok = _download_zip_to_dir(ZIP_URLS, tmp_new) - - if not download_ok: - click.echo("Failed to download from all zip urls. Skip update.") - return - - # backup the current workflows - backup_zip = _backup(workflow_base) - - # rename the new dir to the workflow_base - shutil.rmtree(workflow_base) - shutil.move(tmp_new, workflow_base) - - click.echo(f"Updated {workflow_base} by zip. (backup: {backup_zip})") - - -def _clone_repo_to_dir(candidates: List[Tuple[str, str]], dst_dir: Path) -> bool: - """ - Clone the git repo with the first successful url - in the candidates to the dst_dir. - - Args: - candidates: the list of candidate git url and branch pairs. - dst_dir: the dst dir of the cloned repo, should not exist - - Returns: - bool: True if success else False - """ - - assert not dst_dir.exists() - - clone_ok = False - for url, branch in candidates: - try: - Repo.clone_from(url, to_path=dst_dir, branch=branch) - click.echo(f"Cloned from {url}|{branch} to {dst_dir}") - clone_ok = True - break - except GitCommandError as e: - click.echo(f"Failed to clone from {url}|{branch}: {e}") - - return clone_ok - - -def update_by_git(workflow_base: Path): - click.echo("Updating by git...") - parent = workflow_base.parent - - if not workflow_base.exists(): - # No previous workflows, clone to the workflow_base directly - clone_ok = _clone_repo_to_dir(REPO_URLS, workflow_base) - if not clone_ok: - click.echo("Failed to clone from all git urls. Please Try again later.") - return - - click.echo(f"Updated {workflow_base} by git.") - - else: - # Has previous workflows - repo = None - try: - # check if the workflow base dir is a valid git repo - repo = Repo(workflow_base) - head_name = repo.head.reference.name - except InvalidGitRepositoryError: - pass - except Exception: - repo = None - - if repo is None: - # current workflow base dir is not a valid git repo - # try to clone the new repo to tmp_new - tmp_new = parent / f"{WORKFLOWS_BASE_NAME}_new" - if tmp_new.exists(): - shutil.rmtree(tmp_new) - - clone_ok = _clone_repo_to_dir(REPO_URLS, tmp_new) - if not clone_ok: - click.echo("Failed to clone from all git urls. Skip update.") - return - - # backup the current workflows - backup_zip = _backup(workflow_base) - - # rename the new dir to the workflow_base - shutil.rmtree(workflow_base) - shutil.move(tmp_new, workflow_base) - click.echo(f"Updated {workflow_base} by git. (backup: {backup_zip})") - return - - # current workflow base dir is a valid git repo - if head_name != DEFAULT_BRANCH: - click.echo( - f"Current workflow branch is not the default one[{DEFAULT_BRANCH}]: " - f"<{head_name}>. Skip update." - ) - return - - try: - repo.git.fetch("origin") - except GitCommandError as e: - click.echo(f"Failed to fetch from origin. Skip update. {e}") - return - - local_main_hash = repo.head.commit.hexsha - remote_main_hash = repo.commit(f"origin/{DEFAULT_BRANCH}").hexsha - - if local_main_hash == remote_main_hash: - click.echo(f"Local branch is up-to-date with remote {DEFAULT_BRANCH}. Skip update.") - return - - try: - # backup the current workflows - backup_zip = _backup(workflow_base) - - # discard the local changes and force update to the latest main - repo.git.reset("--hard", "HEAD") - repo.git.clean("-df") - repo.git.fetch("origin") - repo.git.reset("--hard", f"origin/{DEFAULT_BRANCH}") - click.echo( - f"Updated {workflow_base} from <{local_main_hash[:8]}> to" - f" <{remote_main_hash[:8]}>. (backup: {backup_zip})" - ) - except GitCommandError as e: - click.echo(f"Failed to update to the latest main: {e}. Skip update.") - - -def copy_workflows_usr(): - """ - Copy workflows/usr to scripts/custom/usr for engine migration. - """ - old_usr_dir = os.path.join(CHAT_DIR, "workflows", "usr") - new_usr_dir = os.path.join(CUSTOM_BASE, "usr") - - old_exists = os.path.exists(old_usr_dir) - new_exists = os.path.exists(new_usr_dir) - - if old_exists and not new_exists: - shutil.copytree(old_usr_dir, new_usr_dir) - click.echo(f"Copied {old_usr_dir} to {new_usr_dir} successfully.") - else: - click.echo(f"Skip copying usr dir. old exists: {old_exists}, new exists: {new_exists}.") - +from devchat.workflow.update_util import ( + update_by_git, + update_by_zip, + HAS_GIT, + copy_workflows_usr, +) @click.command(help="Update the workflow_base dir.") -@click.option("-f", "--force", is_flag=True, help="Force update the workflows to the latest main.") +@click.option( + "-f", "--force", is_flag=True, help="Force update the workflows to the latest main." +) def update(force: bool): click.echo(f"Updating wf repo... force: {force}") click.echo(f"WORKFLOWS_BASE: {WORKFLOWS_BASE}") base_path = Path(WORKFLOWS_BASE) - # # backup(base_path) if HAS_GIT: - update_by_git(base_path) + updated, message = update_by_git(base_path) else: - update_by_zip(base_path) - + updated, message = update_by_zip(base_path) + + click.echo(f"- Updated: {updated}\n- Message: {message}") copy_workflows_usr() diff --git a/devchat/workflow/namespace.py b/devchat/workflow/namespace.py index 306ca04c..6d9d4ba9 100644 --- a/devchat/workflow/namespace.py +++ b/devchat/workflow/namespace.py @@ -3,10 +3,13 @@ """ import os -from typing import List +from typing import List, Set, Tuple, Dict +from pathlib import Path import oyaml as yaml -from pydantic import BaseModel, Extra, ValidationError +import yaml as pyyaml + +from pydantic import BaseModel, Extra, ValidationError, Field from devchat.utils import get_logger @@ -15,6 +18,7 @@ CUSTOM_BASE, CUSTOM_CONFIG_FILE, MERICO_WORKFLOWS, + COMMAND_FILENAMES, ) logger = get_logger(__name__) @@ -27,6 +31,18 @@ class Config: extra = Extra.ignore +class WorkflowMeta(BaseModel): + name: str = Field(..., description="workflow name") + namespace: str = Field(..., description="workflow namespace") + active: bool = Field(..., description="active flag") + command_conf: Dict = Field( + description="command configuration", default_factory=dict + ) + + def __str__(self): + return f"{'*' if self.active else ' '} {self.name} ({self.namespace})" + + def _load_custom_config() -> CustomConfig: """ Load the custom config file. @@ -66,6 +82,55 @@ def get_prioritized_namespace_path() -> List[str]: return namespace_paths +def iter_namespace( + ns_path: str, existing_names: Set[str] +) -> Tuple[List[WorkflowMeta], Set[str]]: + """ + Get all workflows under the namespace path. + + Args: + ns_path: the namespace path + existing_names: the existing workflow names to check if the workflow is the first priority + + Returns: + List[WorkflowMeta]: the workflows + Set[str]: the updated existing workflow names + """ + root = Path(ns_path) + interest_files = set(COMMAND_FILENAMES) + result = [] + unique_names = set(existing_names) + for file in root.rglob("*"): + try: + if file.is_file() and file.name in interest_files: + rel_path = file.relative_to(root) + parts = rel_path.parts + workflow_name = ".".join(parts[:-1]) + is_first = workflow_name not in unique_names + + # load the config content from file + with open(file, "r", encoding="utf-8") as file_handle: + yaml_content = file_handle.read() + command_conf = yaml.safe_load(yaml_content) + # pop the "steps" field + command_conf.pop("steps", None) + + workflow = WorkflowMeta( + name=workflow_name, + namespace=root.name, + active=is_first, + command_conf=command_conf, + ) + unique_names.add(workflow_name) + result.append(workflow) + except pyyaml.scanner.ScannerError as err: + logger.error("Failed to load %s: %s", rel_path, err) + except Exception as err: + logger.error("Unknown error when loading %s: %s", rel_path, err) + + return result, unique_names + + def main(): paths = get_prioritized_namespace_path() for pathv in paths: diff --git a/devchat/workflow/command/update_flowchart.md b/devchat/workflow/update_flowchart.md similarity index 100% rename from devchat/workflow/command/update_flowchart.md rename to devchat/workflow/update_flowchart.md diff --git a/devchat/workflow/update_util.py b/devchat/workflow/update_util.py new file mode 100644 index 00000000..d9c661a9 --- /dev/null +++ b/devchat/workflow/update_util.py @@ -0,0 +1,310 @@ +import os +import shutil +import tempfile +import zipfile +from datetime import datetime +from pathlib import Path +from typing import List, Optional, Tuple + +import requests + +from devchat.utils import get_logger +from devchat.workflow.path import ( + CHAT_DIR, + CUSTOM_BASE, + WORKFLOWS_BASE, + WORKFLOWS_BASE_NAME, +) + +HAS_GIT = False +try: + from git import GitCommandError, InvalidGitRepositoryError, Repo +except ImportError: + pass +else: + HAS_GIT = True + + +REPO_NAME = "workflows" +DEFAULT_BRANCH = "scripts" +REPO_URLS = [ + # url, branch + ("https://gitlab.com/devchat-ai/workflows.git", DEFAULT_BRANCH), + ("git@github.com:devchat-ai/workflows.git", DEFAULT_BRANCH), + ("https://github.com/devchat-ai/workflows.git", DEFAULT_BRANCH), +] +ZIP_URLS = [ + "https://gitlab.com/devchat-ai/workflows/-/archive/scripts/workflows-scripts.zip", + "https://codeload.github.com/devchat-ai/workflows/zip/refs/heads/scripts", +] + +# TODO: logger setting +logger = get_logger(__name__) + + +def _backup(workflow_base: Path, n: int = 5) -> Optional[Path]: + """ + Backup the current workflow base dir to zip with timestamp under .backup. + + Args: + n: the number of backups to keep, default 3 + + Returns: + Path: the backup zip path + """ + + if not workflow_base.exists(): + return None + + backup_dir = workflow_base.parent / ".backup" + backup_dir.mkdir(exist_ok=True) + + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + backup_zip = backup_dir / f"{WORKFLOWS_BASE_NAME}_{timestamp}" + shutil.make_archive(backup_zip, "zip", workflow_base) + logger.info(f"Backup {workflow_base} to {backup_zip}") + + # keep the last n backups + backups = sorted(backup_dir.glob(f"{WORKFLOWS_BASE_NAME}_*"), reverse=True) + for backup in backups[n:]: + backup.unlink() + logger.info(f"Remove old backup {backup}") + + return backup_zip + + +def _download_zip_to_dir(candidate_urls: List[str], dst_dir: Path) -> bool: + """ + Download the zip file with the first successful url + in the candidate_urls to the target_dir. + + Args: + candidate_urls: the list of candidate urls + dst_dir: the dst dir of the extracted zip file, should not exist + + Returns: + bool: True if success else False + """ + assert not dst_dir.exists() + + download_ok = False + with tempfile.TemporaryDirectory() as tmp_dir: + tmp_dir_path = Path(tmp_dir) + + for url in candidate_urls: + try: + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + zip_path = tmp_dir_path / f"{WORKFLOWS_BASE_NAME}_{timestamp}.zip" + logger.info(f"Downloading workflow from {url} to {zip_path}") + + # Download the workflow zip file + response = requests.get(url, stream=True, timeout=10) + with open(zip_path, "wb") as zip_f: + for chunk in response.iter_content(chunk_size=8192): + if chunk: + zip_f.write(chunk) + + # Extract the zip file + with zipfile.ZipFile(zip_path, "r") as zip_f: + zip_f.extractall(tmp_dir_path) + + # move the extracted dir to target dir + extracted_dir = tmp_dir_path / f"{REPO_NAME}-{DEFAULT_BRANCH}" + shutil.move(extracted_dir, dst_dir) + logger.info(f"Extracted to {dst_dir}") + + download_ok = True + break + + except Exception as e: + logger.info(f"Failed to download from {url}: {e}") + + return download_ok + + +def _clone_repo_to_dir(candidates: List[Tuple[str, str]], dst_dir: Path) -> bool: + """ + Clone the git repo with the first successful url + in the candidates to the dst_dir. + + Args: + candidates: the list of candidate git url and branch pairs. + dst_dir: the dst dir of the cloned repo, should not exist + + Returns: + bool: True if success else False + """ + + assert not dst_dir.exists() + + clone_ok = False + for url, branch in candidates: + try: + Repo.clone_from(url, to_path=dst_dir, branch=branch) + logger.info(f"Cloned from {url}|{branch} to {dst_dir}") + clone_ok = True + break + except GitCommandError as e: + logger.info(f"Failed to clone from {url}|{branch}: {e}") + + return clone_ok + + +def update_by_zip(workflow_base: Path) -> Tuple[bool, str]: + logger.info("Updating by zip file...") + parent = workflow_base.parent + + if not workflow_base.exists(): + # No previous workflows, download to the workflow_base directly + download_ok = _download_zip_to_dir(ZIP_URLS, workflow_base) + if not download_ok: + msg = "Failed to download from all zip urls. Please Try again later." + logger.info(msg) + return False, msg + + logger.info(f"Updated {workflow_base} successfully by zip.") + + else: + # Has previous workflows, download as tmp_new + tmp_new = parent / f"{WORKFLOWS_BASE_NAME}_new" + if tmp_new.exists(): + shutil.rmtree(tmp_new) + # TODO: handle error? + # shutil.rmtree(tmp_new, onerror=__onerror) + + download_ok = _download_zip_to_dir(ZIP_URLS, tmp_new) + + if not download_ok: + msg = "Failed to download from all zip urls. Skip update." + logger.info(msg) + return False, msg + + # backup the current workflows + backup_zip = _backup(workflow_base) + + # rename the new dir to the workflow_base + shutil.rmtree(workflow_base) + shutil.move(tmp_new, workflow_base) + + msg = f"Updated {workflow_base} by zip. (backup: {backup_zip})" + logger.info(msg) + return True, msg + + +def update_by_git(workflow_base: Path) -> Tuple[bool, str]: + logger.info("Updating by git...") + parent = workflow_base.parent + + if not workflow_base.exists(): + # No previous workflows, clone to the workflow_base directly + clone_ok = _clone_repo_to_dir(REPO_URLS, workflow_base) + if not clone_ok: + msg = "Failed to clone from all git urls. Please Try again later." + logger.info(msg) + return False, msg + + logger.info(f"Updated {workflow_base} by git.") + + else: + # Has previous workflows + repo = None + try: + # check if the workflow base dir is a valid git repo + repo = Repo(workflow_base) + head_name = repo.head.reference.name + except InvalidGitRepositoryError: + pass + except Exception: + repo = None + + if repo is None: + # current workflow base dir is not a valid git repo + # try to clone the new repo to tmp_new + tmp_new = parent / f"{WORKFLOWS_BASE_NAME}_new" + if tmp_new.exists(): + shutil.rmtree(tmp_new) + + clone_ok = _clone_repo_to_dir(REPO_URLS, tmp_new) + if not clone_ok: + msg = "Failed to clone from all git urls. Skip update." + logger.info(msg) + return False, msg + + # backup the current workflows + backup_zip = _backup(workflow_base) + + # rename the new dir to the workflow_base + shutil.rmtree(workflow_base) + shutil.move(tmp_new, workflow_base) + + msg = f"Updated {workflow_base} by git. (backup: {backup_zip})" + logger.info(msg) + return True, msg + + # current workflow base dir is a valid git repo + if head_name != DEFAULT_BRANCH: + msg = ( + f"Current workflow branch is not the default one[{DEFAULT_BRANCH}]: " + f"<{head_name}>. Skip update." + ) + logger.info(msg) + return False, msg + + try: + repo.git.fetch("origin") + except GitCommandError as e: + msg = f"Failed to fetch from origin. Skip update. {e}" + logger.info(msg) + return False, msg + + local_main_hash = repo.head.commit.hexsha + remote_main_hash = repo.commit(f"origin/{DEFAULT_BRANCH}").hexsha + + if local_main_hash == remote_main_hash: + msg = ( + f"Local branch is up-to-date with remote {DEFAULT_BRANCH}. Skip update." + ) + logger.info(msg) + return False, msg + + try: + # backup the current workflows + backup_zip = _backup(workflow_base) + + # discard the local changes and force update to the latest main + repo.git.reset("--hard", "HEAD") + repo.git.clean("-df") + repo.git.fetch("origin") + repo.git.reset("--hard", f"origin/{DEFAULT_BRANCH}") + + msg = ( + f"Updated {workflow_base} from <{local_main_hash[:8]}> to" + f" <{remote_main_hash[:8]}>. (backup: {backup_zip})" + ) + logger.info(msg) + + return True, msg + + except GitCommandError as e: + msg = f"Failed to update to the latest main: {e}. Skip update." + logger.info(msg) + return False, msg + + +def copy_workflows_usr(): + """ + Copy workflows/usr to scripts/custom/usr for engine migration. + """ + old_usr_dir = os.path.join(CHAT_DIR, "workflows", "usr") + new_usr_dir = os.path.join(CUSTOM_BASE, "usr") + + old_exists = os.path.exists(old_usr_dir) + new_exists = os.path.exists(new_usr_dir) + + if old_exists and not new_exists: + shutil.copytree(old_usr_dir, new_usr_dir) + logger.info(f"Copied {old_usr_dir} to {new_usr_dir} successfully.") + else: + logger.info( + f"Skip copying usr dir. old exists: {old_exists}, new exists: {new_exists}." + ) From a1829699afe37fe15ce3cf86aa46839f90117c54 Mon Sep 17 00:00:00 2001 From: kagami Date: Thu, 4 Jul 2024 16:31:22 +0800 Subject: [PATCH 03/26] Add API for message chatting --- devchat/_service/route/message.py | 79 +++++-------------------------- devchat/msg/chatting.py | 66 ++++++++++++++++++++++++++ devchat/msg/schema.py | 31 ++++++++++++ 3 files changed, 108 insertions(+), 68 deletions(-) create mode 100644 devchat/msg/chatting.py create mode 100644 devchat/msg/schema.py diff --git a/devchat/_service/route/message.py b/devchat/_service/route/message.py index 6b7d2744..4b87af3d 100644 --- a/devchat/_service/route/message.py +++ b/devchat/_service/route/message.py @@ -1,5 +1,6 @@ import time import json +import os from typing import Dict, Optional, List, Iterator from fastapi import APIRouter @@ -14,69 +15,21 @@ async def hello(): return {"hello": "devchat message"} -sample_txt = """ -金樽清酒斗十千,玉盘珍羞直万钱。 -停杯投箸不能食,拔剑四顾心茫然。 -欲渡黄河冰塞川,将登太行雪满山。 -闲来垂钓碧溪上,忽复乘舟梦日边。 -行路难!行路难!多歧路,今安在? -长风破浪会有时,直挂云帆济沧海。 -""" - - -@router.get("/ssample") -async def sample(): - - def _gen(): - # for char in sample_txt: - # time.sleep(0.1) - # yield char - lines = sample_txt.split("\n") - for line in lines: - time.sleep(0.5) - yield line + "\n" - - return StreamingResponse(_gen(), media_type="text/plain") - - from devchat.msg.util import mk_meta, route_message, MessageType from devchat.msg.schema import MessageRequest, MessageResponseChunk from devchat.msg.chatting import chatting from devchat.workflow.workflow import Workflow -def _mock_gen( - content: str, user_str: str, date_str: str -) -> Iterator[MessageResponseChunk]: - yield MessageResponseChunk( - user=user_str, date=date_str, content="\n\n# mock response\n\n" - ).json() - - count = 0 - chunk = [] - for c in content: - count += 1 - chunk.append(c) - if count % 5 == 0: - time.sleep(0.2) - res_chunk = MessageResponseChunk( - user=user_str, date=date_str, content="".join(chunk) - ) - a = res_chunk.json() - yield res_chunk.json() - chunk = [] - if chunk: - res_chunk = MessageResponseChunk( - user=user_str, date=date_str, content="".join(chunk) - ) - yield res_chunk.json() - - @router.post("/msg") async def msg( request: MessageRequest, ): - print(f"check request: \n{request}") + if request.api_key: + os.environ["OPENAI_API_KEY"] = request.api_key + if request.api_base: + os.environ["OPENAI_API_BASE"] = request.api_base + user_str, date_str = mk_meta() message_type, extra = route_message(request) @@ -108,19 +61,8 @@ def _gen_res_help() -> Iterator[MessageResponseChunk]: return StreamingResponse(_gen_res_help(), media_type="application/json") else: - # # run the workflow - - # workflow.setup( - # model_name=request.model_name, - # user_input=wf_input, - # history_messages=[], # not in use, will be assistant.prompt.messages if needed - # parent_hash=request.parent, - # ) - # return_code = workflow.run_steps() - # ret_val = [ - # f"- the requst content is: \n\n{request.content}\n\n", - # f"- workflow return_code: \n\n{return_code}\n\n", - # ] + # return "should run workflow" response + # then the client will trigger the workflow by devchat cli def _gen_res_run_workflow() -> Iterator[MessageResponseChunk]: yield MessageResponseChunk( user=user_str, @@ -136,7 +78,8 @@ def _gen_res_run_workflow() -> Iterator[MessageResponseChunk]: ) else: - content = request.content + # TODO: Should not reach here + chunk = MessageResponseChunk(user=user_str, date=date_str, content="") return StreamingResponse( - _mock_gen(content, user_str, date_str), media_type="application/json" + (chunk.json() for _ in [1]), media_type="application/json" ) diff --git a/devchat/msg/chatting.py b/devchat/msg/chatting.py new file mode 100644 index 00000000..c7b12504 --- /dev/null +++ b/devchat/msg/chatting.py @@ -0,0 +1,66 @@ +from typing import Optional, Iterator +import json +import os + +from devchat._cli.utils import init_dir +from devchat.assistant import Assistant +from devchat.openai.openai_chat import OpenAIChat, OpenAIChatConfig +from devchat.store import Store +from devchat.utils import parse_files + +from .schema import MessageRequest +from .path import USER_CHAT_DIR +from .util import get_workspace_chat_dir + +from devchat._cli.utils import get_model_config + + +def _get_model_and_config(model: Optional[str], config_str: Optional[str]): + + model, config = get_model_config(USER_CHAT_DIR, model) + + parameters_data = config.dict(exclude_unset=True) + if config_str: + config_data = json.loads(config_str) + parameters_data.update(config_data) + return model, parameters_data + + +def chatting(msg: MessageRequest) -> Iterator[str]: + context = msg.context + content = msg.content + parent = msg.parent + model = msg.model_name + + workspace_chat_dir = get_workspace_chat_dir(msg.workspace) + + context_contents = parse_files(context) + + model, parameters_data = _get_model_and_config(model, None) + max_input_tokens = parameters_data.get("max_input_tokens", 4000) + + openai_config = OpenAIChatConfig(model=model, **parameters_data) + chat = OpenAIChat(openai_config) + chat_store = Store(workspace_chat_dir, chat) + + # assistant = Assistant(chat, chat_store, max_input_tokens, not not_store) + assistant = Assistant( + chat=chat, + store=chat_store, + max_prompt_tokens=max_input_tokens, + need_store=False, + ) + assistant.make_prompt( + request=content, + instruct_contents=None, + context_contents=context_contents, + functions=None, + parent=parent, + references=None, + function_name=None, + ) + + for res in assistant.iterate_response(): + # for res in _mock_txt_res(): + print(res, end="", flush=True) + yield res diff --git a/devchat/msg/schema.py b/devchat/msg/schema.py new file mode 100644 index 00000000..84c3dd29 --- /dev/null +++ b/devchat/msg/schema.py @@ -0,0 +1,31 @@ +from typing import Dict, Optional, List, Iterator + +from pydantic import BaseModel, Field + + +class MessageRequest(BaseModel): + content: str = Field(..., description="message content") + model_name: str = Field(..., description="LLM model name") + workspace: Optional[str] = Field(None, description="absolute path to the workspace/repository") + api_key: Optional[str] = Field(None, description="API key (OpenAI API key or DevChat Key)") + api_base: Optional[str] = Field(None, description="API base url") + parent: Optional[str] = Field(None, description="parent message hash in a thread") + context: Optional[List[str]] = Field(None, description="paths to context files") + + + +class MessageResponseChunk(BaseModel): + # TODO: add response hash + # response_hash: str = Field( + # ..., + # description="response hash, all chunks in a response should have the same hash", + # ) + user: str = Field(..., description="user info") + date: str = Field(..., description="date time") + content: str = Field(..., description="chunk content") + finish_reason: str = Field(default="", description="finish reason") + # TODO: should handle isError in another way? + isError: bool = Field(default=False, description="is error") + extra: Dict = Field(default_factory=dict, description="extra data") + + From c5f523c64f266ca1eb7c19aacf369c880cb4fa7a Mon Sep 17 00:00:00 2001 From: kagami Date: Thu, 4 Jul 2024 16:34:45 +0800 Subject: [PATCH 04/26] Add API for log management --- devchat/_service/route/__init__.py | 4 +- devchat/_service/route/{log.py => logs.py} | 30 +++++- devchat/msg/logm.py | 113 +++++++++++++++++++++ devchat/msg/path.py | 8 ++ 4 files changed, 151 insertions(+), 4 deletions(-) rename devchat/_service/route/{log.py => logs.py} (53%) create mode 100644 devchat/msg/logm.py create mode 100644 devchat/msg/path.py diff --git a/devchat/_service/route/__init__.py b/devchat/_service/route/__init__.py index 0a26ccf3..7b2ddb32 100644 --- a/devchat/_service/route/__init__.py +++ b/devchat/_service/route/__init__.py @@ -1,10 +1,12 @@ from fastapi import APIRouter from .workflow import router as workflow_router from .message import router as message_router -from .log import router as log_router +from .logs import router as log_router +from .topics import router as topic_router router = APIRouter() router.include_router(workflow_router, prefix="/workflow", tags=["Workflow"]) router.include_router(message_router, prefix="/message", tags=["Message"]) +router.include_router(log_router, prefix="/logs", tags=["LogManagement"]) router.include_router(log_router, prefix="/log", tags=["LogManager"]) \ No newline at end of file diff --git a/devchat/_service/route/log.py b/devchat/_service/route/logs.py similarity index 53% rename from devchat/_service/route/log.py rename to devchat/_service/route/logs.py index 99b5af40..51c85874 100644 --- a/devchat/_service/route/log.py +++ b/devchat/_service/route/logs.py @@ -8,7 +8,7 @@ from pydantic import BaseModel, Field -from devchat.msg.logm import gen_log_prompt, insert_log_promt +from devchat.msg.logm import gen_log_prompt, insert_log_prompt, delete_log_prompt router = APIRouter() @@ -19,13 +19,18 @@ async def hello(): class InsertLogRequest(BaseModel): + workspace: Optional[str] = Field(None, description="absolute path to the workspace/repository") jsondata: Optional[str] = Field(None, description="data to insert in json format") - filepath: Optional[str] = Field(None, description="file path to insert data in json format") + filepath: Optional[str] = Field( + None, description="file path to insert data in json format" + ) + class InsertLogResponse(BaseModel): hash: Optional[str] = Field(None, description="hash of the inserted data") error: Optional[str] = Field(None, description="error message") + @router.post("/insert", response_model=InsertLogResponse) async def insert( request: InsertLogRequest, @@ -35,7 +40,8 @@ async def insert( error_msg = None prompt = gen_log_prompt(request.jsondata, request.filepath) - insert_log_promt(prompt) + insert_log_prompt(prompt, request.workspace) + # # execute the insert in a separate process? # with ProcessPoolExecutor() as executor: # executor.submit(insert_log_promt, prompt) @@ -43,4 +49,22 @@ async def insert( return InsertLogResponse(hash=prompt.hash, error=error_msg) +class DeleteLogRequest(BaseModel): + hash: str = Field(..., description="hash of the prompt to delete") + workspace: Optional[str] = Field(None, description="absolute path to the workspace/repository") + + +class DeleteLogResponse(BaseModel): + success: bool = Field(..., description="success status") + error: Optional[str] = Field(None, description="error message") + + +@router.post("/delete") +async def delete( + request: DeleteLogRequest, +): + print(f"\n\ncheck delete log request: \n{request}") + + success, error_msg = delete_log_prompt(request.hash, request.workspace) + return DeleteLogResponse(success=success, error=error_msg) diff --git a/devchat/msg/logm.py b/devchat/msg/logm.py new file mode 100644 index 00000000..407424fb --- /dev/null +++ b/devchat/msg/logm.py @@ -0,0 +1,113 @@ +from typing import Optional, List, Dict, Tuple +from dataclasses import dataclass, field +import json +import time +import os + +from devchat.openai.openai_chat import OpenAIPrompt, OpenAIChat, OpenAIChatConfig +from devchat._cli.utils import get_model_config +from devchat.store import Store + +from .util import get_workspace_chat_dir, USER_CHAT_DIR +from .user_info import user_info +from .schema import MessageRequest + + +@dataclass +class PromptData: + model: str = "none" + messages: Optional[List[Dict]] = field(default_factory=list) + parent: Optional[str] = None + references: Optional[List[str]] = field(default_factory=list) + timestamp: int = time.time() + request_tokens: int = 0 + response_tokens: int = 0 + + +def gen_log_prompt(jsondata: Optional[str], filepath: Optional[str]) -> OpenAIPrompt: + """ + Generate a hash for a chat record + """ + t0 = time.time() + prompt_data = PromptData(**json.loads(jsondata)) + name = user_info.name + email = user_info.email + prompt = OpenAIPrompt(prompt_data.model, name, email) + + prompt.model = prompt_data.model + prompt.input_messages(prompt_data.messages) + prompt.parent = prompt_data.parent + prompt.references = prompt_data.references + prompt.timestamp = prompt_data.timestamp + prompt.request_tokens = prompt_data.request_tokens + prompt.response_tokens = prompt_data.response_tokens + + prompt.finalize_hash() + + return prompt + + +def insert_log_prompt( + prompt: OpenAIPrompt, workspace_path: Optional[str] +) -> Tuple[Optional[str], Optional[str]]: + """ + Insert a chat record + + return: + inserted_hash: insert hash + error: error message if any + """ + inserted_hash = None + error_msg = None + + try: + + user_chat_dir = USER_CHAT_DIR + workspace_chat_dir = get_workspace_chat_dir(workspace_path) + + model, config = get_model_config(user_chat_dir) + openai_config = OpenAIChatConfig(model=model, **config.dict(exclude_unset=True)) + + chat = OpenAIChat(openai_config) + store = Store(workspace_chat_dir, chat) + _ = store.store_prompt(prompt) + + inserted_hash = prompt.hash + except Exception as e: + error_msg = str(e) + + return inserted_hash, error_msg + + +def delete_log_prompt( + hash: str, workspace_path: Optional[str] +) -> Tuple[bool, Optional[str]]: + """ + Delete a chat record + + return: + success: True if the prompt is deleted successfully, False otherwise + error: error message if any + """ + success = False + error_msg = None + + try: + user_chat_dir = USER_CHAT_DIR + workspace_chat_dir = get_workspace_chat_dir(workspace_path) + + model, config = get_model_config(user_chat_dir) + openai_config = OpenAIChatConfig(model=model, **config.dict(exclude_unset=True)) + + chat = OpenAIChat(openai_config) + store = Store(workspace_chat_dir, chat) + + success = store.delete_prompt(hash) + + if not success: + error_msg = f"Failed to delete prompt {hash}." + except Exception as e: + error_msg = str(e) + + return success, error_msg + diff --git a/devchat/msg/path.py b/devchat/msg/path.py new file mode 100644 index 00000000..de8ece3e --- /dev/null +++ b/devchat/msg/path.py @@ -0,0 +1,8 @@ +import os + +# ------------------------------- +# devchat basic paths +# ------------------------------- +USE_DIR = os.path.expanduser("~") +USER_CHAT_DIR = os.path.join(USE_DIR, ".chat") + From 8d90c80487464f07b417be085641b0ea1521bc69 Mon Sep 17 00:00:00 2001 From: kagami Date: Thu, 4 Jul 2024 16:36:14 +0800 Subject: [PATCH 05/26] Add API for topic management --- devchat/_service/route/__init__.py | 2 +- devchat/_service/route/topics.py | 115 +++++++++++++++++++++++++++++ devchat/msg/logm.py | 73 ++++++++++++++++++ 3 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 devchat/_service/route/topics.py diff --git a/devchat/_service/route/__init__.py b/devchat/_service/route/__init__.py index 7b2ddb32..6dbf6180 100644 --- a/devchat/_service/route/__init__.py +++ b/devchat/_service/route/__init__.py @@ -9,4 +9,4 @@ router.include_router(workflow_router, prefix="/workflow", tags=["Workflow"]) router.include_router(message_router, prefix="/message", tags=["Message"]) router.include_router(log_router, prefix="/logs", tags=["LogManagement"]) -router.include_router(log_router, prefix="/log", tags=["LogManager"]) \ No newline at end of file +router.include_router(topic_router, prefix="/topics", tags=["TopicManagement"]) diff --git a/devchat/_service/route/topics.py b/devchat/_service/route/topics.py new file mode 100644 index 00000000..97efb77c --- /dev/null +++ b/devchat/_service/route/topics.py @@ -0,0 +1,115 @@ +import time +import json + +from typing import Dict, Optional, List, Iterator +from fastapi import APIRouter, Query + +from pydantic import BaseModel, Field +from devchat.msg.logm import get_topic_shortlogs, get_topics, delete_topic as del_topic +from pprint import pprint + +router = APIRouter() + + +@router.get("/hello") +async def hello(): + return {"hello": "devchat topic"} + + +class ShortLog(BaseModel): + user: str = Field(..., description="user id (name and email)") + date: int = Field(..., description="timestamp") + context: List[Dict] = Field(..., description="context data") + request: str = Field(..., description="request content(message)") + responses: List[str] = Field(..., description="response contents(messages)") + request_tokens: int = Field(..., description="number of tokens in the request") + response_tokens: int = Field(..., description="number of tokens in the response") + hash: str = Field(..., description="hash of the log record") + parent: Optional[str] = Field(None, description="hash of the parent log record") + + +@router.get("/{topic_root_hash}/logs", response_model=List[ShortLog]) +async def get_topic_logs( + topic_root_hash: str, + limit: int = Query(1, gt=0, description="maximum number of records to return"), + offset: int = Query(0, ge=0, description="offset of the first record to return"), + workspace: Optional[str] = Query( + None, description="absolute path to the workspace/repository" + ), +): + # TODO: handle error in the http way + records, error = get_topic_shortlogs(topic_root_hash, limit, offset, workspace) + + logs = [ShortLog.parse_obj(record) for record in records] + return logs + + +class TopicSummary(BaseModel): + latest_time: int = Field(..., description="timestamp of the latest log") + hidden: bool = Field(..., description="hidden status of the topic") + # root prompt info + root_prompt_hash: str = Field(..., description="hash of the log summary") + root_prompt_user: str = Field(..., description="root hash of the log") + root_prompt_date: int = Field(..., description="timestamp") + root_prompt_request: str = Field( + ..., description="truncated request content(message)" + ) + root_prompt_response: str = Field( + ..., description="truncated response content(message)" + ) + title: Optional[str] = Field(None, description="title of the topic") + + +@router.get("", response_model=List[TopicSummary]) +def list_topics( + limit: int = Query(1, gt=0, description="maximum number of records to return"), + offset: int = Query(0, ge=0, description="offset of the first record to return"), + workspace: Optional[str] = Query( + None, description="absolute path to the workspace/repository" + ), +): + topics = get_topics( + limit=limit, offset=offset, workspace_path=workspace, with_deleted=False + ) + + summaries = [ + TopicSummary( + latest_time=topic["latest_time"], + title=topic["title"], + hidden=topic["hidden"], + root_prompt_hash=topic["root_prompt"]["hash"], + root_prompt_user=topic["root_prompt"]["user"], + root_prompt_date=topic["root_prompt"]["date"], + root_prompt_request=topic["root_prompt"]["request"], + root_prompt_response=topic["root_prompt"]["responses"][0], + ) + for topic in topics + ] + return summaries + + +class DeleteTopicResquest(BaseModel): + topic_hash: str = Field(..., description="hash of the topic to delete") + workspace: Optional[str] = Field( + None, description="absolute path to the workspace/repository" + ) + + +class DeleteTopicResponse(BaseModel): + topic_hash: str = Field(..., description="hash of the deleted topic") + success: bool = Field(..., description="success status") + error: Optional[str] = Field(None, description="error message") + + +@router.post("/delete", response_model=DeleteTopicResponse) +def delete_topic( + request: DeleteTopicResquest, +): + print(f"check delete topic request: \n{request}") + try: + del_topic(request.topic_hash, request.workspace) + return DeleteTopicResponse(topic_hash=request.topic_hash, success=True) + except Exception as e: + return DeleteTopicResponse( + topic_hash=request.topic_hash, success=False, error=str(e) + ) diff --git a/devchat/msg/logm.py b/devchat/msg/logm.py index 407424fb..bc7ee809 100644 --- a/devchat/msg/logm.py +++ b/devchat/msg/logm.py @@ -111,3 +111,76 @@ def delete_log_prompt( return success, error_msg + +def get_topic_shortlogs( + topic_root_hash: str, limit: int, offset: int, workspace_path: Optional[str] +) -> Tuple[List[Dict], Optional[str]]: + short_logs = [] + error_msg = None + try: + user_chat_dir = USER_CHAT_DIR + workspace_chat_dir = get_workspace_chat_dir(workspace_path) + + model, config = get_model_config(user_chat_dir) + openai_config = OpenAIChatConfig(model=model, **config.dict(exclude_unset=True)) + + chat = OpenAIChat(openai_config) + store = Store(workspace_chat_dir, chat) + + logs = store.select_prompts(offset, offset + limit, topic_root_hash) + for l in logs: + try: + short_logs.append(l.shortlog()) + except Exception as e: + # TODO: log the error + continue + + except Exception as e: + error_msg = str(e) + + return short_logs, error_msg + + +def get_topics( + limit: int, offset: int, workspace_path: Optional[str], with_deleted: bool = False +) -> List[Dict]: + topics = [] + + user_chat_dir = USER_CHAT_DIR + workspace_chat_dir = get_workspace_chat_dir(workspace_path) + + model, config = get_model_config(user_chat_dir) + openai_config = OpenAIChatConfig(model=model, **config.dict(exclude_unset=True)) + + chat = OpenAIChat(openai_config) + store = Store(workspace_chat_dir, chat) + + topics = store.select_topics(offset, offset + limit) + + if not with_deleted: + record_file = os.path.join(workspace_chat_dir, ".deletedTopics") + if os.path.exists(record_file): + with open(record_file, "r") as f: + deleted_topics = f.read().split("\n") + topics = [ + t for t in topics if t["root_prompt"]["hash"] not in deleted_topics + ] + + return topics + + +def delete_topic(topic_hash: str, workspace_path: Optional[str]): + """ + Logicalily delete a topic + """ + workspace_chat_dir = get_workspace_chat_dir(workspace_path) + + record_file = os.path.join(workspace_chat_dir, ".deletedTopics") + if not os.path.exists(record_file): + with open(record_file, "w") as f: + f.write(topic_hash) + else: + with open(record_file, "a") as f: + f.write(f"\n{topic_hash}") + + return From 05f452b8ccdad94b880b57d071f7f4139a9d5a54 Mon Sep 17 00:00:00 2001 From: kagami Date: Thu, 4 Jul 2024 16:38:46 +0800 Subject: [PATCH 06/26] Add utils for user info and workspace management --- devchat/msg/__init__.py | 0 devchat/msg/user_info.py | 84 ++++++++++++++++++++++++++++++++++++++++ devchat/msg/util.py | 68 ++++++++++++++++++++++++++++++++ 3 files changed, 152 insertions(+) create mode 100644 devchat/msg/__init__.py create mode 100644 devchat/msg/user_info.py create mode 100644 devchat/msg/util.py diff --git a/devchat/msg/__init__.py b/devchat/msg/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/devchat/msg/user_info.py b/devchat/msg/user_info.py new file mode 100644 index 00000000..0dc9f76f --- /dev/null +++ b/devchat/msg/user_info.py @@ -0,0 +1,84 @@ +from typing import Tuple, Optional +import subprocess +import getpass +import socket +import os + + +class UserInfo: + + def __init__(self): + self._name = None + self._email = None + + self._load_user_info() + + @property + def name(self) -> str: + if not self._name: + self._load_user_info() + + return self._name + + @property + def email(self) -> str: + if not self._email: + self._load_user_info() + + return self._email + + def _load_user_info(self): + """ + Load user info + """ + git_name, git_email = self.__get_git_user_info() + + if git_name and git_email: + self._name = git_name + self._email = git_email + return + + sys_name = self.__get_sys_user_name() + name = git_name or sys_name + + mock_email = name + "@" + socket.gethostname() + email = git_email or mock_email + + self._name = name + self._email = email + return + + def __get_git_user_info(self) -> Tuple[Optional[str], Optional[str]]: + """ + Load user info from git + """ + name, email = None, None + try: + cmd = ["git", "config", "user.name"] + name = subprocess.check_output(cmd, encoding="utf-8").strip() + except Exception: + pass + + try: + cmd = ["git", "config", "user.email"] + email = subprocess.check_output(cmd, encoding="utf-8").strip() + except Exception: + pass + + return name, email + + def __get_sys_user_name(self) -> str: + """ + Get user name from system + """ + name = "devchat_anonymous" + try: + name = getpass.getuser() + except Exception: + user_dir = os.path.expanduser("~") + name = user_dir.split(os.sep)[-1] + + return name + + +user_info = UserInfo() diff --git a/devchat/msg/util.py b/devchat/msg/util.py new file mode 100644 index 00000000..c0b236b6 --- /dev/null +++ b/devchat/msg/util.py @@ -0,0 +1,68 @@ +from typing import Tuple, Any, Optional +from enum import Enum +from devchat.utils import user_id, unix_to_local_datetime +from datetime import datetime +import os + +from .schema import MessageRequest +from .path import USER_CHAT_DIR + +from devchat.workflow.workflow import Workflow +from .user_info import user_info + + +class MessageType(Enum): + """ + Enum for message types + """ + + CHATTING = "chatting" # chat with LLM directly + WORKFLOW = "workflow" # trigger a workflow + + +def mk_meta() -> Tuple[str, str]: + """ + Make metadata for a response + """ + name = user_info.name + email = user_info.email + user_str, _ = user_id(name, email) + + _timestamp = datetime.timestamp(datetime.now()) + _local_time = unix_to_local_datetime(_timestamp) + date_str = _local_time.strftime("%a %b %d %H:%M:%S %Y %z") + + return user_str, date_str + + +def get_workspace_chat_dir(workspace_path: Optional[str]) -> str: + """ + Get the chat directory for a workspace + Return user chat directory if workspace is None + """ + workspace_chat_dir = USER_CHAT_DIR + if workspace_path: + workspace_chat_dir = os.path.join(workspace_path, ".chat") + + return workspace_chat_dir + + +def route_message(msg_req: MessageRequest) -> Tuple[MessageType, Any]: + """ + Route the message to the correct handler + 1. trigger a workflow + 2. chat with LLM directly + """ + content = msg_req.content + print(f"check content: {content}") + + wf_name, wf_input = Workflow.parse_trigger(content) + workflow = Workflow.load(wf_name) if wf_name else None + + if workflow: + # TODO: the message should be handled by the workflow engine + return MessageType.WORKFLOW, (workflow, wf_name, wf_input) + + else: + # chat with LLM directly + return MessageType.CHATTING, None From 06c199bfeee81555edeb356ae42fb18a688d4329 Mon Sep 17 00:00:00 2001 From: kagami Date: Thu, 4 Jul 2024 16:51:59 +0800 Subject: [PATCH 07/26] Mark run_workflow command as TODO --- devchat/workflow/command/run.py | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 devchat/workflow/command/run.py diff --git a/devchat/workflow/command/run.py b/devchat/workflow/command/run.py new file mode 100644 index 00000000..f2ce6248 --- /dev/null +++ b/devchat/workflow/command/run.py @@ -0,0 +1,7 @@ +import click + + +@click.command(help="Run a workflow.", name="run") +def run_workflow(workflow_name: str, user_input: str): + # TODO: Replace `devchat route` with this command(`devchat workflow run`) later + pass From 668f6f805cfa7444d0aef79416da4de696a696f4 Mon Sep 17 00:00:00 2001 From: kagami Date: Thu, 4 Jul 2024 17:34:23 +0800 Subject: [PATCH 08/26] Improve module structures --- devchat/_service/route/__init__.py | 5 +- devchat/_service/route/logs.py | 24 +------- devchat/_service/route/message.py | 36 +++++------ devchat/_service/route/topics.py | 38 ++++-------- devchat/_service/route/workflow.py | 14 ++--- devchat/msg/chatting.py | 30 ++++------ devchat/msg/{logm.py => log_util.py} | 90 ++-------------------------- devchat/msg/path.py | 1 - devchat/msg/schema.py | 5 +- devchat/msg/topic_util.py | 80 +++++++++++++++++++++++++ devchat/msg/user_info.py | 7 +-- devchat/msg/util.py | 14 ++--- 12 files changed, 143 insertions(+), 201 deletions(-) rename devchat/msg/{logm.py => log_util.py} (51%) create mode 100644 devchat/msg/topic_util.py diff --git a/devchat/_service/route/__init__.py b/devchat/_service/route/__init__.py index 6dbf6180..45bf594f 100644 --- a/devchat/_service/route/__init__.py +++ b/devchat/_service/route/__init__.py @@ -1,8 +1,9 @@ from fastapi import APIRouter -from .workflow import router as workflow_router -from .message import router as message_router + from .logs import router as log_router +from .message import router as message_router from .topics import router as topic_router +from .workflow import router as workflow_router router = APIRouter() diff --git a/devchat/_service/route/logs.py b/devchat/_service/route/logs.py index 51c85874..d6d44c28 100644 --- a/devchat/_service/route/logs.py +++ b/devchat/_service/route/logs.py @@ -1,29 +1,17 @@ -import time -import json -from concurrent.futures import ProcessPoolExecutor +from typing import Optional -from typing import Dict, Optional, List, Iterator from fastapi import APIRouter -from fastapi.responses import StreamingResponse - from pydantic import BaseModel, Field -from devchat.msg.logm import gen_log_prompt, insert_log_prompt, delete_log_prompt +from devchat.msg.log_util import delete_log_prompt, gen_log_prompt, insert_log_prompt router = APIRouter() -@router.get("/hello") -async def hello(): - return {"hello": "devchat log"} - - class InsertLogRequest(BaseModel): workspace: Optional[str] = Field(None, description="absolute path to the workspace/repository") jsondata: Optional[str] = Field(None, description="data to insert in json format") - filepath: Optional[str] = Field( - None, description="file path to insert data in json format" - ) + filepath: Optional[str] = Field(None, description="file path to insert data in json format") class InsertLogResponse(BaseModel): @@ -35,17 +23,11 @@ class InsertLogResponse(BaseModel): async def insert( request: InsertLogRequest, ): - print(f"\n\ncheck insert log request: \n{request}") # TODO: handle error error_msg = None prompt = gen_log_prompt(request.jsondata, request.filepath) insert_log_prompt(prompt, request.workspace) - - # # execute the insert in a separate process? - # with ProcessPoolExecutor() as executor: - # executor.submit(insert_log_promt, prompt) - return InsertLogResponse(hash=prompt.hash, error=error_msg) diff --git a/devchat/_service/route/message.py b/devchat/_service/route/message.py index 4b87af3d..57bfa77d 100644 --- a/devchat/_service/route/message.py +++ b/devchat/_service/route/message.py @@ -1,25 +1,16 @@ -import time -import json import os +from typing import Iterator, Optional -from typing import Dict, Optional, List, Iterator from fastapi import APIRouter from fastapi.responses import StreamingResponse - -router = APIRouter() - - -@router.get("/hello") -async def hello(): - return {"hello": "devchat message"} - - -from devchat.msg.util import mk_meta, route_message, MessageType -from devchat.msg.schema import MessageRequest, MessageResponseChunk from devchat.msg.chatting import chatting +from devchat.msg.schema import MessageRequest, MessageResponseChunk +from devchat.msg.util import MessageType, mk_meta, route_message from devchat.workflow.workflow import Workflow +router = APIRouter() + @router.post("/msg") async def msg( @@ -38,7 +29,13 @@ async def msg( if message_type == MessageType.CHATTING: def gen_chat_response() -> Iterator[MessageResponseChunk]: - for res in chatting(request): + for res in chatting( + content=request.content, + model_name=request.model_name, + parent=request.parent, + workspace=request.workspace, + context_files=request.context, + ): chunk = MessageResponseChunk(user=user_str, date=date_str, content=res) yield chunk.json() @@ -51,13 +48,10 @@ def gen_chat_response() -> Iterator[MessageResponseChunk]: workflow, wf_name, wf_input = extra if workflow.should_show_help(wf_input): - doc = workflow.get_help_doc(wf_input) def _gen_res_help() -> Iterator[MessageResponseChunk]: - yield MessageResponseChunk( - user=user_str, date=date_str, content=doc - ).json() + yield MessageResponseChunk(user=user_str, date=date_str, content=doc).json() return StreamingResponse(_gen_res_help(), media_type="application/json") else: @@ -80,6 +74,4 @@ def _gen_res_run_workflow() -> Iterator[MessageResponseChunk]: else: # TODO: Should not reach here chunk = MessageResponseChunk(user=user_str, date=date_str, content="") - return StreamingResponse( - (chunk.json() for _ in [1]), media_type="application/json" - ) + return StreamingResponse((chunk.json() for _ in [1]), media_type="application/json") diff --git a/devchat/_service/route/topics.py b/devchat/_service/route/topics.py index 97efb77c..4bbc4043 100644 --- a/devchat/_service/route/topics.py +++ b/devchat/_service/route/topics.py @@ -1,12 +1,10 @@ -import time -import json +from typing import Dict, List, Optional -from typing import Dict, Optional, List, Iterator from fastapi import APIRouter, Query - from pydantic import BaseModel, Field -from devchat.msg.logm import get_topic_shortlogs, get_topics, delete_topic as del_topic -from pprint import pprint + +from devchat.msg.topic_util import delete_topic as del_topic +from devchat.msg.topic_util import get_topic_shortlogs, get_topics router = APIRouter() @@ -33,9 +31,7 @@ async def get_topic_logs( topic_root_hash: str, limit: int = Query(1, gt=0, description="maximum number of records to return"), offset: int = Query(0, ge=0, description="offset of the first record to return"), - workspace: Optional[str] = Query( - None, description="absolute path to the workspace/repository" - ), + workspace: Optional[str] = Query(None, description="absolute path to the workspace/repository"), ): # TODO: handle error in the http way records, error = get_topic_shortlogs(topic_root_hash, limit, offset, workspace) @@ -51,12 +47,8 @@ class TopicSummary(BaseModel): root_prompt_hash: str = Field(..., description="hash of the log summary") root_prompt_user: str = Field(..., description="root hash of the log") root_prompt_date: int = Field(..., description="timestamp") - root_prompt_request: str = Field( - ..., description="truncated request content(message)" - ) - root_prompt_response: str = Field( - ..., description="truncated response content(message)" - ) + root_prompt_request: str = Field(..., description="truncated request content(message)") + root_prompt_response: str = Field(..., description="truncated response content(message)") title: Optional[str] = Field(None, description="title of the topic") @@ -64,13 +56,9 @@ class TopicSummary(BaseModel): def list_topics( limit: int = Query(1, gt=0, description="maximum number of records to return"), offset: int = Query(0, ge=0, description="offset of the first record to return"), - workspace: Optional[str] = Query( - None, description="absolute path to the workspace/repository" - ), + workspace: Optional[str] = Query(None, description="absolute path to the workspace/repository"), ): - topics = get_topics( - limit=limit, offset=offset, workspace_path=workspace, with_deleted=False - ) + topics = get_topics(limit=limit, offset=offset, workspace_path=workspace, with_deleted=False) summaries = [ TopicSummary( @@ -90,9 +78,7 @@ def list_topics( class DeleteTopicResquest(BaseModel): topic_hash: str = Field(..., description="hash of the topic to delete") - workspace: Optional[str] = Field( - None, description="absolute path to the workspace/repository" - ) + workspace: Optional[str] = Field(None, description="absolute path to the workspace/repository") class DeleteTopicResponse(BaseModel): @@ -110,6 +96,4 @@ def delete_topic( del_topic(request.topic_hash, request.workspace) return DeleteTopicResponse(topic_hash=request.topic_hash, success=True) except Exception as e: - return DeleteTopicResponse( - topic_hash=request.topic_hash, success=False, error=str(e) - ) + return DeleteTopicResponse(topic_hash=request.topic_hash, success=False, error=str(e)) diff --git a/devchat/_service/route/workflow.py b/devchat/_service/route/workflow.py index d4c09ec3..6bfa3870 100644 --- a/devchat/_service/route/workflow.py +++ b/devchat/_service/route/workflow.py @@ -1,33 +1,27 @@ -from dataclasses import asdict, dataclass, field from pathlib import Path -from typing import Dict, List, Set, Tuple +from typing import List import oyaml as yaml - - from fastapi import APIRouter from fastapi.responses import JSONResponse - from pydantic import BaseModel, Field from devchat.workflow.namespace import ( + WorkflowMeta, get_prioritized_namespace_path, iter_namespace, - WorkflowMeta, ) from devchat.workflow.path import ( - COMMAND_FILENAMES, WORKFLOWS_BASE, WORKFLOWS_CONFIG_FILENAME, ) from devchat.workflow.update_util import ( - update_by_git, - update_by_zip, HAS_GIT, copy_workflows_usr, + update_by_git, + update_by_zip, ) - router = APIRouter() diff --git a/devchat/msg/chatting.py b/devchat/msg/chatting.py index c7b12504..7ad5e334 100644 --- a/devchat/msg/chatting.py +++ b/devchat/msg/chatting.py @@ -1,22 +1,17 @@ -from typing import Optional, Iterator import json -import os +from typing import Iterator, List, Optional -from devchat._cli.utils import init_dir +from devchat._cli.utils import get_model_config from devchat.assistant import Assistant from devchat.openai.openai_chat import OpenAIChat, OpenAIChatConfig from devchat.store import Store from devchat.utils import parse_files -from .schema import MessageRequest from .path import USER_CHAT_DIR from .util import get_workspace_chat_dir -from devchat._cli.utils import get_model_config - def _get_model_and_config(model: Optional[str], config_str: Optional[str]): - model, config = get_model_config(USER_CHAT_DIR, model) parameters_data = config.dict(exclude_unset=True) @@ -26,24 +21,24 @@ def _get_model_and_config(model: Optional[str], config_str: Optional[str]): return model, parameters_data -def chatting(msg: MessageRequest) -> Iterator[str]: - context = msg.context - content = msg.content - parent = msg.parent - model = msg.model_name - - workspace_chat_dir = get_workspace_chat_dir(msg.workspace) +def chatting( + content: str, + model_name: str, + parent: Optional[str], + workspace: Optional[str], + context_files: Optional[List[str]], +) -> Iterator[str]: + workspace_chat_dir = get_workspace_chat_dir(workspace) - context_contents = parse_files(context) + context_contents = parse_files(context_files) - model, parameters_data = _get_model_and_config(model, None) + model, parameters_data = _get_model_and_config(model_name, None) max_input_tokens = parameters_data.get("max_input_tokens", 4000) openai_config = OpenAIChatConfig(model=model, **parameters_data) chat = OpenAIChat(openai_config) chat_store = Store(workspace_chat_dir, chat) - # assistant = Assistant(chat, chat_store, max_input_tokens, not not_store) assistant = Assistant( chat=chat, store=chat_store, @@ -61,6 +56,5 @@ def chatting(msg: MessageRequest) -> Iterator[str]: ) for res in assistant.iterate_response(): - # for res in _mock_txt_res(): print(res, end="", flush=True) yield res diff --git a/devchat/msg/logm.py b/devchat/msg/log_util.py similarity index 51% rename from devchat/msg/logm.py rename to devchat/msg/log_util.py index bc7ee809..4786e2e0 100644 --- a/devchat/msg/logm.py +++ b/devchat/msg/log_util.py @@ -1,16 +1,14 @@ -from typing import Optional, List, Dict, Tuple -from dataclasses import dataclass, field import json import time -import os +from dataclasses import dataclass, field +from typing import Dict, List, Optional, Tuple -from devchat.openai.openai_chat import OpenAIPrompt, OpenAIChat, OpenAIChatConfig from devchat._cli.utils import get_model_config +from devchat.openai.openai_chat import OpenAIChat, OpenAIChatConfig, OpenAIPrompt from devchat.store import Store -from .util import get_workspace_chat_dir, USER_CHAT_DIR from .user_info import user_info -from .schema import MessageRequest +from .util import USER_CHAT_DIR, get_workspace_chat_dir @dataclass @@ -28,7 +26,6 @@ def gen_log_prompt(jsondata: Optional[str], filepath: Optional[str]) -> OpenAIPr """ Generate a hash for a chat record """ - t0 = time.time() prompt_data = PromptData(**json.loads(jsondata)) name = user_info.name email = user_info.email @@ -61,7 +58,6 @@ def insert_log_prompt( error_msg = None try: - user_chat_dir = USER_CHAT_DIR workspace_chat_dir = get_workspace_chat_dir(workspace_path) @@ -79,9 +75,7 @@ def insert_log_prompt( return inserted_hash, error_msg -def delete_log_prompt( - hash: str, workspace_path: Optional[str] -) -> Tuple[bool, Optional[str]]: +def delete_log_prompt(hash: str, workspace_path: Optional[str]) -> Tuple[bool, Optional[str]]: """ Delete a chat record @@ -110,77 +104,3 @@ def delete_log_prompt( error_msg = str(e) return success, error_msg - - -def get_topic_shortlogs( - topic_root_hash: str, limit: int, offset: int, workspace_path: Optional[str] -) -> Tuple[List[Dict], Optional[str]]: - short_logs = [] - error_msg = None - try: - user_chat_dir = USER_CHAT_DIR - workspace_chat_dir = get_workspace_chat_dir(workspace_path) - - model, config = get_model_config(user_chat_dir) - openai_config = OpenAIChatConfig(model=model, **config.dict(exclude_unset=True)) - - chat = OpenAIChat(openai_config) - store = Store(workspace_chat_dir, chat) - - logs = store.select_prompts(offset, offset + limit, topic_root_hash) - for l in logs: - try: - short_logs.append(l.shortlog()) - except Exception as e: - # TODO: log the error - continue - - except Exception as e: - error_msg = str(e) - - return short_logs, error_msg - - -def get_topics( - limit: int, offset: int, workspace_path: Optional[str], with_deleted: bool = False -) -> List[Dict]: - topics = [] - - user_chat_dir = USER_CHAT_DIR - workspace_chat_dir = get_workspace_chat_dir(workspace_path) - - model, config = get_model_config(user_chat_dir) - openai_config = OpenAIChatConfig(model=model, **config.dict(exclude_unset=True)) - - chat = OpenAIChat(openai_config) - store = Store(workspace_chat_dir, chat) - - topics = store.select_topics(offset, offset + limit) - - if not with_deleted: - record_file = os.path.join(workspace_chat_dir, ".deletedTopics") - if os.path.exists(record_file): - with open(record_file, "r") as f: - deleted_topics = f.read().split("\n") - topics = [ - t for t in topics if t["root_prompt"]["hash"] not in deleted_topics - ] - - return topics - - -def delete_topic(topic_hash: str, workspace_path: Optional[str]): - """ - Logicalily delete a topic - """ - workspace_chat_dir = get_workspace_chat_dir(workspace_path) - - record_file = os.path.join(workspace_chat_dir, ".deletedTopics") - if not os.path.exists(record_file): - with open(record_file, "w") as f: - f.write(topic_hash) - else: - with open(record_file, "a") as f: - f.write(f"\n{topic_hash}") - - return diff --git a/devchat/msg/path.py b/devchat/msg/path.py index de8ece3e..7d1abc13 100644 --- a/devchat/msg/path.py +++ b/devchat/msg/path.py @@ -5,4 +5,3 @@ # ------------------------------- USE_DIR = os.path.expanduser("~") USER_CHAT_DIR = os.path.join(USE_DIR, ".chat") - diff --git a/devchat/msg/schema.py b/devchat/msg/schema.py index 84c3dd29..b8fde058 100644 --- a/devchat/msg/schema.py +++ b/devchat/msg/schema.py @@ -1,4 +1,4 @@ -from typing import Dict, Optional, List, Iterator +from typing import Dict, List, Optional from pydantic import BaseModel, Field @@ -13,7 +13,6 @@ class MessageRequest(BaseModel): context: Optional[List[str]] = Field(None, description="paths to context files") - class MessageResponseChunk(BaseModel): # TODO: add response hash # response_hash: str = Field( @@ -27,5 +26,3 @@ class MessageResponseChunk(BaseModel): # TODO: should handle isError in another way? isError: bool = Field(default=False, description="is error") extra: Dict = Field(default_factory=dict, description="extra data") - - diff --git a/devchat/msg/topic_util.py b/devchat/msg/topic_util.py new file mode 100644 index 00000000..bf93bd36 --- /dev/null +++ b/devchat/msg/topic_util.py @@ -0,0 +1,80 @@ +import os +from typing import Dict, List, Optional, Tuple + +from devchat._cli.utils import get_model_config +from devchat.openai.openai_chat import OpenAIChat, OpenAIChatConfig +from devchat.store import Store + +from .util import USER_CHAT_DIR, get_workspace_chat_dir + + +def get_topic_shortlogs( + topic_root_hash: str, limit: int, offset: int, workspace_path: Optional[str] +) -> Tuple[List[Dict], Optional[str]]: + short_logs = [] + error_msg = None + try: + user_chat_dir = USER_CHAT_DIR + workspace_chat_dir = get_workspace_chat_dir(workspace_path) + + model, config = get_model_config(user_chat_dir) + openai_config = OpenAIChatConfig(model=model, **config.dict(exclude_unset=True)) + + chat = OpenAIChat(openai_config) + store = Store(workspace_chat_dir, chat) + + logs = store.select_prompts(offset, offset + limit, topic_root_hash) + for log in logs: + try: + short_logs.append(log.shortlog()) + except Exception: + # TODO: log the error + continue + + except Exception as e: + error_msg = str(e) + + return short_logs, error_msg + + +def get_topics( + limit: int, offset: int, workspace_path: Optional[str], with_deleted: bool = False +) -> List[Dict]: + topics = [] + + user_chat_dir = USER_CHAT_DIR + workspace_chat_dir = get_workspace_chat_dir(workspace_path) + + model, config = get_model_config(user_chat_dir) + openai_config = OpenAIChatConfig(model=model, **config.dict(exclude_unset=True)) + + chat = OpenAIChat(openai_config) + store = Store(workspace_chat_dir, chat) + + topics = store.select_topics(offset, offset + limit) + + if not with_deleted: + record_file = os.path.join(workspace_chat_dir, ".deletedTopics") + if os.path.exists(record_file): + with open(record_file, "r") as f: + deleted_topics = f.read().split("\n") + topics = [t for t in topics if t["root_prompt"]["hash"] not in deleted_topics] + + return topics + + +def delete_topic(topic_hash: str, workspace_path: Optional[str]): + """ + Logicalily delete a topic + """ + workspace_chat_dir = get_workspace_chat_dir(workspace_path) + + record_file = os.path.join(workspace_chat_dir, ".deletedTopics") + if not os.path.exists(record_file): + with open(record_file, "w") as f: + f.write(topic_hash) + else: + with open(record_file, "a") as f: + f.write(f"\n{topic_hash}") + + return diff --git a/devchat/msg/user_info.py b/devchat/msg/user_info.py index 0dc9f76f..a98c3db3 100644 --- a/devchat/msg/user_info.py +++ b/devchat/msg/user_info.py @@ -1,12 +1,11 @@ -from typing import Tuple, Optional -import subprocess import getpass -import socket import os +import socket +import subprocess +from typing import Optional, Tuple class UserInfo: - def __init__(self): self._name = None self._email = None diff --git a/devchat/msg/util.py b/devchat/msg/util.py index c0b236b6..eaa6b6e7 100644 --- a/devchat/msg/util.py +++ b/devchat/msg/util.py @@ -1,13 +1,13 @@ -from typing import Tuple, Any, Optional -from enum import Enum -from devchat.utils import user_id, unix_to_local_datetime -from datetime import datetime import os +from datetime import datetime +from enum import Enum +from typing import Any, Optional, Tuple -from .schema import MessageRequest -from .path import USER_CHAT_DIR - +from devchat.utils import unix_to_local_datetime, user_id from devchat.workflow.workflow import Workflow + +from .path import USER_CHAT_DIR +from .schema import MessageRequest from .user_info import user_info From 92f3e0a2783711e8712230f316d81563b44c4b9b Mon Sep 17 00:00:00 2001 From: kagami Date: Thu, 4 Jul 2024 21:20:44 +0800 Subject: [PATCH 09/26] Bette manage API models --- devchat/_service/route/logs.py | 43 +++++------------- devchat/_service/route/message.py | 50 ++++++++++++--------- devchat/_service/route/topics.py | 70 +++++++++-------------------- devchat/_service/route/workflow.py | 11 ++--- devchat/_service/schema/__init__.py | 0 devchat/_service/schema/request.py | 29 ++++++++++++ devchat/_service/schema/response.py | 63 ++++++++++++++++++++++++++ devchat/msg/util.py | 4 +- 8 files changed, 159 insertions(+), 111 deletions(-) create mode 100644 devchat/_service/schema/__init__.py create mode 100644 devchat/_service/schema/request.py create mode 100644 devchat/_service/schema/response.py diff --git a/devchat/_service/route/logs.py b/devchat/_service/route/logs.py index d6d44c28..8d71df88 100644 --- a/devchat/_service/route/logs.py +++ b/devchat/_service/route/logs.py @@ -1,52 +1,29 @@ -from typing import Optional - from fastapi import APIRouter -from pydantic import BaseModel, Field +from devchat._service.schema import request, response from devchat.msg.log_util import delete_log_prompt, gen_log_prompt, insert_log_prompt router = APIRouter() -class InsertLogRequest(BaseModel): - workspace: Optional[str] = Field(None, description="absolute path to the workspace/repository") - jsondata: Optional[str] = Field(None, description="data to insert in json format") - filepath: Optional[str] = Field(None, description="file path to insert data in json format") - - -class InsertLogResponse(BaseModel): - hash: Optional[str] = Field(None, description="hash of the inserted data") - error: Optional[str] = Field(None, description="error message") - - -@router.post("/insert", response_model=InsertLogResponse) +@router.post("/insert", response_model=response.InsertLog) async def insert( - request: InsertLogRequest, + item: request.InsertLog, ): # TODO: handle error error_msg = None - prompt = gen_log_prompt(request.jsondata, request.filepath) - - insert_log_prompt(prompt, request.workspace) - return InsertLogResponse(hash=prompt.hash, error=error_msg) - - -class DeleteLogRequest(BaseModel): - hash: str = Field(..., description="hash of the prompt to delete") - workspace: Optional[str] = Field(None, description="absolute path to the workspace/repository") - + prompt = gen_log_prompt(item.jsondata, item.filepath) -class DeleteLogResponse(BaseModel): - success: bool = Field(..., description="success status") - error: Optional[str] = Field(None, description="error message") + insert_log_prompt(prompt, item.workspace) + return response.InsertLog(hash=prompt.hash, error=error_msg) @router.post("/delete") async def delete( - request: DeleteLogRequest, + item: request.DeleteLog, ): - print(f"\n\ncheck delete log request: \n{request}") + print(f"\n\ncheck delete log request: \n{item}") - success, error_msg = delete_log_prompt(request.hash, request.workspace) + success, error_msg = delete_log_prompt(item.hash, item.workspace) - return DeleteLogResponse(success=success, error=error_msg) + return response.DeleteLog(success=success, error=error_msg) diff --git a/devchat/_service/route/message.py b/devchat/_service/route/message.py index 57bfa77d..859896f0 100644 --- a/devchat/_service/route/message.py +++ b/devchat/_service/route/message.py @@ -4,9 +4,9 @@ from fastapi import APIRouter from fastapi.responses import StreamingResponse +from devchat._service.schema import request, response from devchat.msg.chatting import chatting -from devchat.msg.schema import MessageRequest, MessageResponseChunk -from devchat.msg.util import MessageType, mk_meta, route_message +from devchat.msg.util import MessageType, mk_meta, route_message_by_content from devchat.workflow.workflow import Workflow router = APIRouter() @@ -14,29 +14,33 @@ @router.post("/msg") async def msg( - request: MessageRequest, + message: request.UserMessage, ): - if request.api_key: - os.environ["OPENAI_API_KEY"] = request.api_key - if request.api_base: - os.environ["OPENAI_API_BASE"] = request.api_base + if message.api_key: + os.environ["OPENAI_API_KEY"] = message.api_key + if message.api_base: + os.environ["OPENAI_API_BASE"] = message.api_base user_str, date_str = mk_meta() - message_type, extra = route_message(request) + message_type, extra = route_message_by_content(message.content) print(f"message type: {message_type}") if message_type == MessageType.CHATTING: - def gen_chat_response() -> Iterator[MessageResponseChunk]: + def gen_chat_response() -> Iterator[response.MessageCompletionChunk]: for res in chatting( - content=request.content, - model_name=request.model_name, - parent=request.parent, - workspace=request.workspace, - context_files=request.context, + content=message.content, + model_name=message.model_name, + parent=message.parent, + workspace=message.workspace, + context_files=message.context, ): - chunk = MessageResponseChunk(user=user_str, date=date_str, content=res) + chunk = response.MessageCompletionChunk( + user=user_str, + date=date_str, + content=res, + ) yield chunk.json() return StreamingResponse(gen_chat_response(), media_type="application/json") @@ -50,15 +54,17 @@ def gen_chat_response() -> Iterator[MessageResponseChunk]: if workflow.should_show_help(wf_input): doc = workflow.get_help_doc(wf_input) - def _gen_res_help() -> Iterator[MessageResponseChunk]: - yield MessageResponseChunk(user=user_str, date=date_str, content=doc).json() + def _gen_res_help() -> Iterator[response.MessageCompletionChunk]: + yield response.MessageCompletionChunk( + user=user_str, date=date_str, content=doc + ).json() return StreamingResponse(_gen_res_help(), media_type="application/json") else: # return "should run workflow" response # then the client will trigger the workflow by devchat cli - def _gen_res_run_workflow() -> Iterator[MessageResponseChunk]: - yield MessageResponseChunk( + def _gen_res_run_workflow() -> Iterator[response.MessageCompletionChunk]: + yield response.MessageCompletionChunk( user=user_str, date=date_str, content="", @@ -73,5 +79,9 @@ def _gen_res_run_workflow() -> Iterator[MessageResponseChunk]: else: # TODO: Should not reach here - chunk = MessageResponseChunk(user=user_str, date=date_str, content="") + chunk = response.MessageCompletionChunk( + user=user_str, + date=date_str, + content="", + ) return StreamingResponse((chunk.json() for _ in [1]), media_type="application/json") diff --git a/devchat/_service/route/topics.py b/devchat/_service/route/topics.py index 4bbc4043..3fce8dc1 100644 --- a/devchat/_service/route/topics.py +++ b/devchat/_service/route/topics.py @@ -1,8 +1,8 @@ -from typing import Dict, List, Optional +from typing import List, Optional from fastapi import APIRouter, Query -from pydantic import BaseModel, Field +from devchat._service.schema import request, response from devchat.msg.topic_util import delete_topic as del_topic from devchat.msg.topic_util import get_topic_shortlogs, get_topics @@ -14,19 +14,7 @@ async def hello(): return {"hello": "devchat topic"} -class ShortLog(BaseModel): - user: str = Field(..., description="user id (name and email)") - date: int = Field(..., description="timestamp") - context: List[Dict] = Field(..., description="context data") - request: str = Field(..., description="request content(message)") - responses: List[str] = Field(..., description="response contents(messages)") - request_tokens: int = Field(..., description="number of tokens in the request") - response_tokens: int = Field(..., description="number of tokens in the response") - hash: str = Field(..., description="hash of the log record") - parent: Optional[str] = Field(None, description="hash of the parent log record") - - -@router.get("/{topic_root_hash}/logs", response_model=List[ShortLog]) +@router.get("/{topic_root_hash}/logs", response_model=List[response.ShortLog]) async def get_topic_logs( topic_root_hash: str, limit: int = Query(1, gt=0, description="maximum number of records to return"), @@ -36,32 +24,25 @@ async def get_topic_logs( # TODO: handle error in the http way records, error = get_topic_shortlogs(topic_root_hash, limit, offset, workspace) - logs = [ShortLog.parse_obj(record) for record in records] + logs = [response.ShortLog.parse_obj(record) for record in records] return logs -class TopicSummary(BaseModel): - latest_time: int = Field(..., description="timestamp of the latest log") - hidden: bool = Field(..., description="hidden status of the topic") - # root prompt info - root_prompt_hash: str = Field(..., description="hash of the log summary") - root_prompt_user: str = Field(..., description="root hash of the log") - root_prompt_date: int = Field(..., description="timestamp") - root_prompt_request: str = Field(..., description="truncated request content(message)") - root_prompt_response: str = Field(..., description="truncated response content(message)") - title: Optional[str] = Field(None, description="title of the topic") - - -@router.get("", response_model=List[TopicSummary]) +@router.get("", response_model=List[response.TopicSummary]) def list_topics( limit: int = Query(1, gt=0, description="maximum number of records to return"), offset: int = Query(0, ge=0, description="offset of the first record to return"), workspace: Optional[str] = Query(None, description="absolute path to the workspace/repository"), ): - topics = get_topics(limit=limit, offset=offset, workspace_path=workspace, with_deleted=False) + topics = get_topics( + limit=limit, + offset=offset, + workspace_path=workspace, + with_deleted=False, + ) summaries = [ - TopicSummary( + response.TopicSummary( latest_time=topic["latest_time"], title=topic["title"], hidden=topic["hidden"], @@ -76,24 +57,17 @@ def list_topics( return summaries -class DeleteTopicResquest(BaseModel): - topic_hash: str = Field(..., description="hash of the topic to delete") - workspace: Optional[str] = Field(None, description="absolute path to the workspace/repository") - - -class DeleteTopicResponse(BaseModel): - topic_hash: str = Field(..., description="hash of the deleted topic") - success: bool = Field(..., description="success status") - error: Optional[str] = Field(None, description="error message") - - -@router.post("/delete", response_model=DeleteTopicResponse) +@router.post("/delete", response_model=response.DeleteTopic) def delete_topic( - request: DeleteTopicResquest, + item: request.DeleteTopic, ): - print(f"check delete topic request: \n{request}") + print(f"check delete topic request: \n{item}") try: - del_topic(request.topic_hash, request.workspace) - return DeleteTopicResponse(topic_hash=request.topic_hash, success=True) + del_topic(item.topic_hash, item.workspace) + return response.DeleteTopic(topic_hash=item.topic_hash, success=True) except Exception as e: - return DeleteTopicResponse(topic_hash=request.topic_hash, success=False, error=str(e)) + return response.DeleteTopic( + topic_hash=item.topic_hash, + success=False, + error=str(e), + ) diff --git a/devchat/_service/route/workflow.py b/devchat/_service/route/workflow.py index 6bfa3870..8c2ca155 100644 --- a/devchat/_service/route/workflow.py +++ b/devchat/_service/route/workflow.py @@ -4,8 +4,8 @@ import oyaml as yaml from fastapi import APIRouter from fastapi.responses import JSONResponse -from pydantic import BaseModel, Field +from devchat._service.schema import response from devchat.workflow.namespace import ( WorkflowMeta, get_prioritized_namespace_path, @@ -55,13 +55,8 @@ async def get_config(): return JSONResponse(content=config_content) -class UpdateWorkflows(BaseModel): - updated: bool = Field(..., description="Whether the workflows are updated.") - message: str = Field(..., description="The message of the update.") - - # TODO: set time out? what if the update takes too long due to user's network? -@router.post("/update", response_model=UpdateWorkflows) +@router.post("/update", response_model=response.UpdateWorkflows) async def update_workflows(): base_path = Path(WORKFLOWS_BASE) @@ -72,4 +67,4 @@ async def update_workflows(): copy_workflows_usr() - return UpdateWorkflows(updated=updated, message=message) + return response.UpdateWorkflows(updated=updated, message=message) diff --git a/devchat/_service/schema/__init__.py b/devchat/_service/schema/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/devchat/_service/schema/request.py b/devchat/_service/schema/request.py new file mode 100644 index 00000000..92bf4490 --- /dev/null +++ b/devchat/_service/schema/request.py @@ -0,0 +1,29 @@ +from typing import List, Optional + +from pydantic import BaseModel, Field + + +class UserMessage(BaseModel): + content: str = Field(..., description="message content") + model_name: str = Field(..., description="LLM model name") + workspace: Optional[str] = Field(None, description="absolute path to the workspace/repository") + api_key: Optional[str] = Field(None, description="API key (OpenAI API key or DevChat Key)") + api_base: Optional[str] = Field(None, description="API base url") + parent: Optional[str] = Field(None, description="parent message hash in a thread") + context: Optional[List[str]] = Field(None, description="paths to context files") + + +class InsertLog(BaseModel): + workspace: Optional[str] = Field(None, description="absolute path to the workspace/repository") + jsondata: Optional[str] = Field(None, description="data to insert in json format") + filepath: Optional[str] = Field(None, description="file path to insert data in json format") + + +class DeleteLog(BaseModel): + hash: str = Field(..., description="hash of the prompt to delete") + workspace: Optional[str] = Field(None, description="absolute path to the workspace/repository") + + +class DeleteTopic(BaseModel): + topic_hash: str = Field(..., description="hash of the topic to delete") + workspace: Optional[str] = Field(None, description="absolute path to the workspace/repository") diff --git a/devchat/_service/schema/response.py b/devchat/_service/schema/response.py new file mode 100644 index 00000000..c65f228e --- /dev/null +++ b/devchat/_service/schema/response.py @@ -0,0 +1,63 @@ +from typing import Dict, List, Optional + +from pydantic import BaseModel, Field + + +class MessageCompletionChunk(BaseModel): + # TODO: add response hash + # response_hash: str = Field( + # ..., + # description="response hash, all chunks in a response should have the same hash", + # ) + user: str = Field(..., description="user info") + date: str = Field(..., description="date time") + content: str = Field(..., description="chunk content") + finish_reason: str = Field(default="", description="finish reason") + # TODO: should handle isError in another way? + isError: bool = Field(default=False, description="is error") + extra: Dict = Field(default_factory=dict, description="extra data") + + +class InsertLog(BaseModel): + hash: Optional[str] = Field(None, description="hash of the inserted data") + error: Optional[str] = Field(None, description="error message") + + +class DeleteLog(BaseModel): + success: bool = Field(..., description="success status") + error: Optional[str] = Field(None, description="error message") + + +class ShortLog(BaseModel): + user: str = Field(..., description="user id (name and email)") + date: int = Field(..., description="timestamp") + context: List[Dict] = Field(..., description="context data") + request: str = Field(..., description="request content(message)") + responses: List[str] = Field(..., description="response contents(messages)") + request_tokens: int = Field(..., description="number of tokens in the request") + response_tokens: int = Field(..., description="number of tokens in the response") + hash: str = Field(..., description="hash of the log record") + parent: Optional[str] = Field(None, description="hash of the parent log record") + + +class TopicSummary(BaseModel): + latest_time: int = Field(..., description="timestamp of the latest log") + hidden: bool = Field(..., description="hidden status of the topic") + # root prompt info + root_prompt_hash: str = Field(..., description="hash of the log summary") + root_prompt_user: str = Field(..., description="root hash of the log") + root_prompt_date: int = Field(..., description="timestamp") + root_prompt_request: str = Field(..., description="truncated request content(message)") + root_prompt_response: str = Field(..., description="truncated response content(message)") + title: Optional[str] = Field(None, description="title of the topic") + + +class DeleteTopic(BaseModel): + topic_hash: str = Field(..., description="hash of the deleted topic") + success: bool = Field(..., description="success status") + error: Optional[str] = Field(None, description="error message") + + +class UpdateWorkflows(BaseModel): + updated: bool = Field(..., description="Whether the workflows are updated.") + message: str = Field(..., description="The message of the update.") diff --git a/devchat/msg/util.py b/devchat/msg/util.py index eaa6b6e7..da41b49c 100644 --- a/devchat/msg/util.py +++ b/devchat/msg/util.py @@ -47,13 +47,13 @@ def get_workspace_chat_dir(workspace_path: Optional[str]) -> str: return workspace_chat_dir -def route_message(msg_req: MessageRequest) -> Tuple[MessageType, Any]: +def route_message_by_content(message_content: str) -> Tuple[MessageType, Any]: """ Route the message to the correct handler 1. trigger a workflow 2. chat with LLM directly """ - content = msg_req.content + content = message_content print(f"check content: {content}") wf_name, wf_input = Workflow.parse_trigger(content) From d6242ffa4470cbe86880974b19b1124fb67c4029 Mon Sep 17 00:00:00 2001 From: kagami Date: Thu, 4 Jul 2024 21:47:07 +0800 Subject: [PATCH 10/26] Clean the entrypoint --- Makefile | 9 +++++++++ devchat/_service/Makefile | 7 ------- devchat/_service/main.py | 23 +++++------------------ 3 files changed, 14 insertions(+), 25 deletions(-) delete mode 100644 devchat/_service/Makefile diff --git a/Makefile b/Makefile index c9e33c47..8c455798 100644 --- a/Makefile +++ b/Makefile @@ -15,3 +15,12 @@ fix: @echo ${div} poetry run ruff check $(DIR) --fix @echo "Done!" + + +run-dev-svc: + @echo "Running dev service on port 22222..." + @uvicorn devchat._service.main:api_app --reload --port 22222 + +run-svc: + @echo "Running service on port 22222..." + @gunicorn devchat._service.main:api_app --workers 4 --worker-class uvicorn.workers.UvicornWorker --bind 0.0.0.0:22222 diff --git a/devchat/_service/Makefile b/devchat/_service/Makefile deleted file mode 100644 index d6e428b3..00000000 --- a/devchat/_service/Makefile +++ /dev/null @@ -1,7 +0,0 @@ -.PHONY: run-dev - -# @uvicorn main:app --reload --host 0.0.0.0 --port 22222 -run-dev: - @echo "Running dev server..." - @python main.py - diff --git a/devchat/_service/main.py b/devchat/_service/main.py index 6c1d48ce..41f73e0f 100644 --- a/devchat/_service/main.py +++ b/devchat/_service/main.py @@ -1,30 +1,17 @@ from fastapi import FastAPI from devchat._service.route import router -from dotenv import load_dotenv -# import socketio -import uvicorn -# from devchat._service.route.async_socket import sio_app - -# TODO: manage env variables -load_dotenv() - - -fa_app = FastAPI( +api_app = FastAPI( title="DevChat Local Service", ) -fa_app.mount("/devchat", router) -fa_app.include_router(router) +api_app.mount("/devchat", router) +api_app.include_router(router) -# app = socketio.ASGIApp(sio_app, fa_app, socketio_path="devchat.socket") -if __name__ == '__main__': - # uvicorn.run("devchat._service.main:app", host='127.0.0.1', port=22222, reload=True) - uvicorn.run("devchat._service.main:fa_app", host='127.0.0.1', port=22222, reload=True) +# app = socketio.ASGIApp(sio_app, api_app, socketio_path="devchat.socket") +# NOTE: some references if we want to use socketio with FastAPI in the future # https://www.reddit.com/r/FastAPI/comments/170awhx/mount_socketio_to_fastapi/ # https://github.com/miguelgrinberg/python-socketio/blob/main/examples/server/asgi/fastapi-fiddle.py -# not work: https://github.com/tiangolo/fastapi/discussions/8781 - From 2faf75e1295e50bf3fc2326276cb7acad74c387c Mon Sep 17 00:00:00 2001 From: kagami Date: Mon, 8 Jul 2024 22:21:37 +0800 Subject: [PATCH 11/26] Handle logs in file --- devchat/msg/chatting.py | 2 +- devchat/msg/log_util.py | 16 ++++++++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/devchat/msg/chatting.py b/devchat/msg/chatting.py index 7ad5e334..a767645a 100644 --- a/devchat/msg/chatting.py +++ b/devchat/msg/chatting.py @@ -56,5 +56,5 @@ def chatting( ) for res in assistant.iterate_response(): - print(res, end="", flush=True) + # print(res, end="", flush=True) yield res diff --git a/devchat/msg/log_util.py b/devchat/msg/log_util.py index 4786e2e0..14455a06 100644 --- a/devchat/msg/log_util.py +++ b/devchat/msg/log_util.py @@ -22,10 +22,20 @@ class PromptData: response_tokens: int = 0 -def gen_log_prompt(jsondata: Optional[str], filepath: Optional[str]) -> OpenAIPrompt: +def gen_log_prompt( + jsondata: Optional[str] = None, filepath: Optional[str] = None +) -> OpenAIPrompt: """ Generate a hash for a chat record """ + assert ( + jsondata is not None or filepath is not None + ), "Either jsondata or filepath is required." + + if jsondata is None: + with open(filepath, "r", encoding="utf-8") as f: + jsondata = f.read() + prompt_data = PromptData(**json.loads(jsondata)) name = user_info.name email = user_info.email @@ -75,7 +85,9 @@ def insert_log_prompt( return inserted_hash, error_msg -def delete_log_prompt(hash: str, workspace_path: Optional[str]) -> Tuple[bool, Optional[str]]: +def delete_log_prompt( + hash: str, workspace_path: Optional[str] +) -> Tuple[bool, Optional[str]]: """ Delete a chat record From 237be7e540db41547c7aa298965ead9f4bddbc35 Mon Sep 17 00:00:00 2001 From: kagami Date: Tue, 9 Jul 2024 08:40:56 +0800 Subject: [PATCH 12/26] Clean routes --- devchat/_service/route/__init__.py | 6 ++++++ devchat/_service/route/topics.py | 6 ------ devchat/_service/route/workflow.py | 7 ------- 3 files changed, 6 insertions(+), 13 deletions(-) diff --git a/devchat/_service/route/__init__.py b/devchat/_service/route/__init__.py index 45bf594f..7ce87d04 100644 --- a/devchat/_service/route/__init__.py +++ b/devchat/_service/route/__init__.py @@ -7,6 +7,12 @@ router = APIRouter() + +@router.get("/ping") +def ping(): + return {"message": "pong"} + + router.include_router(workflow_router, prefix="/workflow", tags=["Workflow"]) router.include_router(message_router, prefix="/message", tags=["Message"]) router.include_router(log_router, prefix="/logs", tags=["LogManagement"]) diff --git a/devchat/_service/route/topics.py b/devchat/_service/route/topics.py index 3fce8dc1..a03de1df 100644 --- a/devchat/_service/route/topics.py +++ b/devchat/_service/route/topics.py @@ -8,12 +8,6 @@ router = APIRouter() - -@router.get("/hello") -async def hello(): - return {"hello": "devchat topic"} - - @router.get("/{topic_root_hash}/logs", response_model=List[response.ShortLog]) async def get_topic_logs( topic_root_hash: str, diff --git a/devchat/_service/route/workflow.py b/devchat/_service/route/workflow.py index 8c2ca155..dd529314 100644 --- a/devchat/_service/route/workflow.py +++ b/devchat/_service/route/workflow.py @@ -24,13 +24,6 @@ router = APIRouter() - -@router.get("/") -async def hello(): - return {"hello": "devchat workflow"} - - -# TODO: handle errors @router.get("/list", response_model=List[WorkflowMeta]) async def list_workflow(): namespace_paths = get_prioritized_namespace_path() From 07ad4629302a9979e07fd1798cd46cac36a00bca Mon Sep 17 00:00:00 2001 From: kagami Date: Tue, 9 Jul 2024 10:09:24 +0800 Subject: [PATCH 13/26] Add service dependencies --- poetry.lock | 700 ++++++++++++++++++++++++++++++++++++++++++++++++- pyproject.toml | 3 + 2 files changed, 692 insertions(+), 11 deletions(-) diff --git a/poetry.lock b/poetry.lock index ae83b3b2..947e2494 100644 --- a/poetry.lock +++ b/poetry.lock @@ -168,6 +168,41 @@ files = [ {file = "distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed"}, ] +[[package]] +name = "dnspython" +version = "2.6.1" +description = "DNS toolkit" +optional = false +python-versions = ">=3.8" +files = [ + {file = "dnspython-2.6.1-py3-none-any.whl", hash = "sha256:5ef3b9680161f6fa89daf8ad451b5f1a33b18ae8a1c6778cdf4b43f08c0a6e50"}, + {file = "dnspython-2.6.1.tar.gz", hash = "sha256:e8f0f9c23a7b7cb99ded64e6c3a6f3e701d78f50c55e002b839dea7225cff7cc"}, +] + +[package.extras] +dev = ["black (>=23.1.0)", "coverage (>=7.0)", "flake8 (>=7)", "mypy (>=1.8)", "pylint (>=3)", "pytest (>=7.4)", "pytest-cov (>=4.1.0)", "sphinx (>=7.2.0)", "twine (>=4.0.0)", "wheel (>=0.42.0)"] +dnssec = ["cryptography (>=41)"] +doh = ["h2 (>=4.1.0)", "httpcore (>=1.0.0)", "httpx (>=0.26.0)"] +doq = ["aioquic (>=0.9.25)"] +idna = ["idna (>=3.6)"] +trio = ["trio (>=0.23)"] +wmi = ["wmi (>=1.5.1)"] + +[[package]] +name = "email-validator" +version = "2.1.1" +description = "A robust email address syntax and deliverability validation library." +optional = false +python-versions = ">=3.8" +files = [ + {file = "email_validator-2.1.1-py3-none-any.whl", hash = "sha256:97d882d174e2a65732fb43bfce81a3a834cbc1bde8bf419e30ef5ea976370a05"}, + {file = "email_validator-2.1.1.tar.gz", hash = "sha256:200a70680ba08904be6d1eef729205cc0d687634399a5924d842533efb824b84"}, +] + +[package.dependencies] +dnspython = ">=2.0.0" +idna = ">=2.0.0" + [[package]] name = "exceptiongroup" version = "1.2.0" @@ -182,6 +217,50 @@ files = [ [package.extras] test = ["pytest (>=6)"] +[[package]] +name = "fastapi" +version = "0.111.0" +description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" +optional = false +python-versions = ">=3.8" +files = [ + {file = "fastapi-0.111.0-py3-none-any.whl", hash = "sha256:97ecbf994be0bcbdadedf88c3150252bed7b2087075ac99735403b1b76cc8fc0"}, + {file = "fastapi-0.111.0.tar.gz", hash = "sha256:b9db9dd147c91cb8b769f7183535773d8741dd46f9dc6676cd82eab510228cd7"}, +] + +[package.dependencies] +email_validator = ">=2.0.0" +fastapi-cli = ">=0.0.2" +httpx = ">=0.23.0" +jinja2 = ">=2.11.2" +orjson = ">=3.2.1" +pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.1.0 || >2.1.0,<3.0.0" +python-multipart = ">=0.0.7" +starlette = ">=0.37.2,<0.38.0" +typing-extensions = ">=4.8.0" +ujson = ">=4.0.1,<4.0.2 || >4.0.2,<4.1.0 || >4.1.0,<4.2.0 || >4.2.0,<4.3.0 || >4.3.0,<5.0.0 || >5.0.0,<5.1.0 || >5.1.0" +uvicorn = {version = ">=0.12.0", extras = ["standard"]} + +[package.extras] +all = ["email_validator (>=2.0.0)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=2.11.2)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.7)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] + +[[package]] +name = "fastapi-cli" +version = "0.0.4" +description = "Run and manage FastAPI apps from the command line with FastAPI CLI. 🚀" +optional = false +python-versions = ">=3.8" +files = [ + {file = "fastapi_cli-0.0.4-py3-none-any.whl", hash = "sha256:a2552f3a7ae64058cdbb530be6fa6dbfc975dc165e4fa66d224c3d396e25e809"}, + {file = "fastapi_cli-0.0.4.tar.gz", hash = "sha256:e2e9ffaffc1f7767f488d6da34b6f5a377751c996f397902eb6abb99a67bde32"}, +] + +[package.dependencies] +typer = ">=0.12.3" + +[package.extras] +standard = ["fastapi", "uvicorn[standard] (>=0.15.0)"] + [[package]] name = "gitdb" version = "4.0.11" @@ -213,6 +292,27 @@ gitdb = ">=4.0.1,<5" [package.extras] test = ["black", "coverage[toml]", "ddt (>=1.1.1,!=1.4.3)", "mock", "mypy", "pre-commit", "pytest (>=7.3.1)", "pytest-cov", "pytest-instafail", "pytest-mock", "pytest-sugar"] +[[package]] +name = "gunicorn" +version = "22.0.0" +description = "WSGI HTTP Server for UNIX" +optional = false +python-versions = ">=3.7" +files = [ + {file = "gunicorn-22.0.0-py3-none-any.whl", hash = "sha256:350679f91b24062c86e386e198a15438d53a7a8207235a78ba1b53df4c4378d9"}, + {file = "gunicorn-22.0.0.tar.gz", hash = "sha256:4a0b436239ff76fb33f11c07a16482c521a7e09c1ce3cc293c2330afe01bec63"}, +] + +[package.dependencies] +packaging = "*" + +[package.extras] +eventlet = ["eventlet (>=0.24.1,!=0.36.0)"] +gevent = ["gevent (>=1.4.0)"] +setproctitle = ["setproctitle"] +testing = ["coverage", "eventlet", "gevent", "pytest", "pytest-cov"] +tornado = ["tornado (>=0.2)"] + [[package]] name = "h11" version = "0.14.0" @@ -245,6 +345,54 @@ http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] trio = ["trio (>=0.22.0,<0.25.0)"] +[[package]] +name = "httptools" +version = "0.6.1" +description = "A collection of framework independent HTTP protocol utils." +optional = false +python-versions = ">=3.8.0" +files = [ + {file = "httptools-0.6.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d2f6c3c4cb1948d912538217838f6e9960bc4a521d7f9b323b3da579cd14532f"}, + {file = "httptools-0.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:00d5d4b68a717765b1fabfd9ca755bd12bf44105eeb806c03d1962acd9b8e563"}, + {file = "httptools-0.6.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:639dc4f381a870c9ec860ce5c45921db50205a37cc3334e756269736ff0aac58"}, + {file = "httptools-0.6.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e57997ac7fb7ee43140cc03664de5f268813a481dff6245e0075925adc6aa185"}, + {file = "httptools-0.6.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0ac5a0ae3d9f4fe004318d64b8a854edd85ab76cffbf7ef5e32920faef62f142"}, + {file = "httptools-0.6.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:3f30d3ce413088a98b9db71c60a6ada2001a08945cb42dd65a9a9fe228627658"}, + {file = "httptools-0.6.1-cp310-cp310-win_amd64.whl", hash = "sha256:1ed99a373e327f0107cb513b61820102ee4f3675656a37a50083eda05dc9541b"}, + {file = "httptools-0.6.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7a7ea483c1a4485c71cb5f38be9db078f8b0e8b4c4dc0210f531cdd2ddac1ef1"}, + {file = "httptools-0.6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:85ed077c995e942b6f1b07583e4eb0a8d324d418954fc6af913d36db7c05a5a0"}, + {file = "httptools-0.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b0bb634338334385351a1600a73e558ce619af390c2b38386206ac6a27fecfc"}, + {file = "httptools-0.6.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d9ceb2c957320def533671fc9c715a80c47025139c8d1f3797477decbc6edd2"}, + {file = "httptools-0.6.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4f0f8271c0a4db459f9dc807acd0eadd4839934a4b9b892f6f160e94da309837"}, + {file = "httptools-0.6.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:6a4f5ccead6d18ec072ac0b84420e95d27c1cdf5c9f1bc8fbd8daf86bd94f43d"}, + {file = "httptools-0.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:5cceac09f164bcba55c0500a18fe3c47df29b62353198e4f37bbcc5d591172c3"}, + {file = "httptools-0.6.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:75c8022dca7935cba14741a42744eee13ba05db00b27a4b940f0d646bd4d56d0"}, + {file = "httptools-0.6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:48ed8129cd9a0d62cf4d1575fcf90fb37e3ff7d5654d3a5814eb3d55f36478c2"}, + {file = "httptools-0.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f58e335a1402fb5a650e271e8c2d03cfa7cea46ae124649346d17bd30d59c90"}, + {file = "httptools-0.6.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93ad80d7176aa5788902f207a4e79885f0576134695dfb0fefc15b7a4648d503"}, + {file = "httptools-0.6.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:9bb68d3a085c2174c2477eb3ffe84ae9fb4fde8792edb7bcd09a1d8467e30a84"}, + {file = "httptools-0.6.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:b512aa728bc02354e5ac086ce76c3ce635b62f5fbc32ab7082b5e582d27867bb"}, + {file = "httptools-0.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:97662ce7fb196c785344d00d638fc9ad69e18ee4bfb4000b35a52efe5adcc949"}, + {file = "httptools-0.6.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:8e216a038d2d52ea13fdd9b9c9c7459fb80d78302b257828285eca1c773b99b3"}, + {file = "httptools-0.6.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:3e802e0b2378ade99cd666b5bffb8b2a7cc8f3d28988685dc300469ea8dd86cb"}, + {file = "httptools-0.6.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4bd3e488b447046e386a30f07af05f9b38d3d368d1f7b4d8f7e10af85393db97"}, + {file = "httptools-0.6.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe467eb086d80217b7584e61313ebadc8d187a4d95bb62031b7bab4b205c3ba3"}, + {file = "httptools-0.6.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:3c3b214ce057c54675b00108ac42bacf2ab8f85c58e3f324a4e963bbc46424f4"}, + {file = "httptools-0.6.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8ae5b97f690badd2ca27cbf668494ee1b6d34cf1c464271ef7bfa9ca6b83ffaf"}, + {file = "httptools-0.6.1-cp38-cp38-win_amd64.whl", hash = "sha256:405784577ba6540fa7d6ff49e37daf104e04f4b4ff2d1ac0469eaa6a20fde084"}, + {file = "httptools-0.6.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:95fb92dd3649f9cb139e9c56604cc2d7c7bf0fc2e7c8d7fbd58f96e35eddd2a3"}, + {file = "httptools-0.6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:dcbab042cc3ef272adc11220517278519adf8f53fd3056d0e68f0a6f891ba94e"}, + {file = "httptools-0.6.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cf2372e98406efb42e93bfe10f2948e467edfd792b015f1b4ecd897903d3e8d"}, + {file = "httptools-0.6.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:678fcbae74477a17d103b7cae78b74800d795d702083867ce160fc202104d0da"}, + {file = "httptools-0.6.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:e0b281cf5a125c35f7f6722b65d8542d2e57331be573e9e88bc8b0115c4a7a81"}, + {file = "httptools-0.6.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:95658c342529bba4e1d3d2b1a874db16c7cca435e8827422154c9da76ac4e13a"}, + {file = "httptools-0.6.1-cp39-cp39-win_amd64.whl", hash = "sha256:7ebaec1bf683e4bf5e9fbb49b8cc36da482033596a415b3e4ebab5a4c0d7ec5e"}, + {file = "httptools-0.6.1.tar.gz", hash = "sha256:c6e26c30455600b95d94b1b836085138e82f177351454ee841c148f93a9bad5a"}, +] + +[package.extras] +test = ["Cython (>=0.29.24,<0.30.0)"] + [[package]] name = "httpx" version = "0.27.0" @@ -328,6 +476,23 @@ files = [ {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, ] +[[package]] +name = "jinja2" +version = "3.1.4" +description = "A very fast and expressive template engine." +optional = false +python-versions = ">=3.7" +files = [ + {file = "jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d"}, + {file = "jinja2-3.1.4.tar.gz", hash = "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369"}, +] + +[package.dependencies] +MarkupSafe = ">=2.0" + +[package.extras] +i18n = ["Babel (>=2.7)"] + [[package]] name = "markdown-it-py" version = "3.0.0" @@ -352,6 +517,75 @@ profiling = ["gprof2dot"] rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] +[[package]] +name = "markupsafe" +version = "2.1.5" +description = "Safely add untrusted strings to HTML/XML markup." +optional = false +python-versions = ">=3.7" +files = [ + {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-win32.whl", hash = "sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-win_amd64.whl", hash = "sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-win32.whl", hash = "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-win_amd64.whl", hash = "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-win32.whl", hash = "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-win_amd64.whl", hash = "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c8b29db45f8fe46ad280a7294f5c3ec36dbac9491f2d1c17345be8e69cc5928f"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec6a563cff360b50eed26f13adc43e61bc0c04d94b8be985e6fb24b81f6dcfdf"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a549b9c31bec33820e885335b451286e2969a2d9e24879f83fe904a5ce59d70a"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f11aa001c540f62c6166c7726f71f7573b52c68c31f014c25cc7901deea0b52"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:7b2e5a267c855eea6b4283940daa6e88a285f5f2a67f2220203786dfa59b37e9"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:2d2d793e36e230fd32babe143b04cec8a8b3eb8a3122d2aceb4a371e6b09b8df"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:ce409136744f6521e39fd8e2a24c53fa18ad67aa5bc7c2cf83645cce5b5c4e50"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-win32.whl", hash = "sha256:4096e9de5c6fdf43fb4f04c26fb114f61ef0bf2e5604b6ee3019d51b69e8c371"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-win_amd64.whl", hash = "sha256:4275d846e41ecefa46e2015117a9f491e57a71ddd59bbead77e904dc02b1bed2"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:656f7526c69fac7f600bd1f400991cc282b417d17539a1b228617081106feb4a"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:97cafb1f3cbcd3fd2b6fbfb99ae11cdb14deea0736fc2b0952ee177f2b813a46"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f3fbcb7ef1f16e48246f704ab79d79da8a46891e2da03f8783a5b6fa41a9532"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa9db3f79de01457b03d4f01b34cf91bc0048eb2c3846ff26f66687c2f6d16ab"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffee1f21e5ef0d712f9033568f8344d5da8cc2869dbd08d87c84656e6a2d2f68"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5dedb4db619ba5a2787a94d877bc8ffc0566f92a01c0ef214865e54ecc9ee5e0"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:30b600cf0a7ac9234b2638fbc0fb6158ba5bdcdf46aeb631ead21248b9affbc4"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8dd717634f5a044f860435c1d8c16a270ddf0ef8588d4887037c5028b859b0c3"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-win32.whl", hash = "sha256:daa4ee5a243f0f20d528d939d06670a298dd39b1ad5f8a72a4275124a7819eff"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-win_amd64.whl", hash = "sha256:619bc166c4f2de5caa5a633b8b7326fbe98e0ccbfacabd87268a2b15ff73a029"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7a68b554d356a91cce1236aa7682dc01df0edba8d043fd1ce607c49dd3c1edcf"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:db0b55e0f3cc0be60c1f19efdde9a637c32740486004f20d1cff53c3c0ece4d2"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e53af139f8579a6d5f7b76549125f0d94d7e630761a2111bc431fd820e163b8"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17b950fccb810b3293638215058e432159d2b71005c74371d784862b7e4683f3"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c31f53cdae6ecfa91a77820e8b151dba54ab528ba65dfd235c80b086d68a465"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:bff1b4290a66b490a2f4719358c0cdcd9bafb6b8f061e45c7a2460866bf50c2e"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:bc1667f8b83f48511b94671e0e441401371dfd0f0a795c7daa4a3cd1dde55bea"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5049256f536511ee3f7e1b3f87d1d1209d327e818e6ae1365e8653d7e3abb6a6"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-win32.whl", hash = "sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-win_amd64.whl", hash = "sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5"}, + {file = "MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b"}, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -404,6 +638,61 @@ typing-extensions = ">=4.7,<5" [package.extras] datalib = ["numpy (>=1)", "pandas (>=1.2.3)", "pandas-stubs (>=1.1.0.11)"] +[[package]] +name = "orjson" +version = "3.10.3" +description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy" +optional = false +python-versions = ">=3.8" +files = [ + {file = "orjson-3.10.3-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9fb6c3f9f5490a3eb4ddd46fc1b6eadb0d6fc16fb3f07320149c3286a1409dd8"}, + {file = "orjson-3.10.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:252124b198662eee80428f1af8c63f7ff077c88723fe206a25df8dc57a57b1fa"}, + {file = "orjson-3.10.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9f3e87733823089a338ef9bbf363ef4de45e5c599a9bf50a7a9b82e86d0228da"}, + {file = "orjson-3.10.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c8334c0d87103bb9fbbe59b78129f1f40d1d1e8355bbed2ca71853af15fa4ed3"}, + {file = "orjson-3.10.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1952c03439e4dce23482ac846e7961f9d4ec62086eb98ae76d97bd41d72644d7"}, + {file = "orjson-3.10.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c0403ed9c706dcd2809f1600ed18f4aae50be263bd7112e54b50e2c2bc3ebd6d"}, + {file = "orjson-3.10.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:382e52aa4270a037d41f325e7d1dfa395b7de0c367800b6f337d8157367bf3a7"}, + {file = "orjson-3.10.3-cp310-none-win32.whl", hash = "sha256:be2aab54313752c04f2cbaab4515291ef5af8c2256ce22abc007f89f42f49109"}, + {file = "orjson-3.10.3-cp310-none-win_amd64.whl", hash = "sha256:416b195f78ae461601893f482287cee1e3059ec49b4f99479aedf22a20b1098b"}, + {file = "orjson-3.10.3-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:73100d9abbbe730331f2242c1fc0bcb46a3ea3b4ae3348847e5a141265479700"}, + {file = "orjson-3.10.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:544a12eee96e3ab828dbfcb4d5a0023aa971b27143a1d35dc214c176fdfb29b3"}, + {file = "orjson-3.10.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:520de5e2ef0b4ae546bea25129d6c7c74edb43fc6cf5213f511a927f2b28148b"}, + {file = "orjson-3.10.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ccaa0a401fc02e8828a5bedfd80f8cd389d24f65e5ca3954d72c6582495b4bcf"}, + {file = "orjson-3.10.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a7bc9e8bc11bac40f905640acd41cbeaa87209e7e1f57ade386da658092dc16"}, + {file = "orjson-3.10.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3582b34b70543a1ed6944aca75e219e1192661a63da4d039d088a09c67543b08"}, + {file = "orjson-3.10.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c23dfa91481de880890d17aa7b91d586a4746a4c2aa9a145bebdbaf233768d5"}, + {file = "orjson-3.10.3-cp311-none-win32.whl", hash = "sha256:1770e2a0eae728b050705206d84eda8b074b65ee835e7f85c919f5705b006c9b"}, + {file = "orjson-3.10.3-cp311-none-win_amd64.whl", hash = "sha256:93433b3c1f852660eb5abdc1f4dd0ced2be031ba30900433223b28ee0140cde5"}, + {file = "orjson-3.10.3-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:a39aa73e53bec8d410875683bfa3a8edf61e5a1c7bb4014f65f81d36467ea098"}, + {file = "orjson-3.10.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0943a96b3fa09bee1afdfccc2cb236c9c64715afa375b2af296c73d91c23eab2"}, + {file = "orjson-3.10.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e852baafceff8da3c9defae29414cc8513a1586ad93e45f27b89a639c68e8176"}, + {file = "orjson-3.10.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18566beb5acd76f3769c1d1a7ec06cdb81edc4d55d2765fb677e3eaa10fa99e0"}, + {file = "orjson-3.10.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bd2218d5a3aa43060efe649ec564ebedec8ce6ae0a43654b81376216d5ebd42"}, + {file = "orjson-3.10.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cf20465e74c6e17a104ecf01bf8cd3b7b252565b4ccee4548f18b012ff2f8069"}, + {file = "orjson-3.10.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba7f67aa7f983c4345eeda16054a4677289011a478ca947cd69c0a86ea45e534"}, + {file = "orjson-3.10.3-cp312-none-win32.whl", hash = "sha256:17e0713fc159abc261eea0f4feda611d32eabc35708b74bef6ad44f6c78d5ea0"}, + {file = "orjson-3.10.3-cp312-none-win_amd64.whl", hash = "sha256:4c895383b1ec42b017dd2c75ae8a5b862fc489006afde06f14afbdd0309b2af0"}, + {file = "orjson-3.10.3-cp38-cp38-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:be2719e5041e9fb76c8c2c06b9600fe8e8584e6980061ff88dcbc2691a16d20d"}, + {file = "orjson-3.10.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb0175a5798bdc878956099f5c54b9837cb62cfbf5d0b86ba6d77e43861bcec2"}, + {file = "orjson-3.10.3-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:978be58a68ade24f1af7758626806e13cff7748a677faf95fbb298359aa1e20d"}, + {file = "orjson-3.10.3-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:16bda83b5c61586f6f788333d3cf3ed19015e3b9019188c56983b5a299210eb5"}, + {file = "orjson-3.10.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ad1f26bea425041e0a1adad34630c4825a9e3adec49079b1fb6ac8d36f8b754"}, + {file = "orjson-3.10.3-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:9e253498bee561fe85d6325ba55ff2ff08fb5e7184cd6a4d7754133bd19c9195"}, + {file = "orjson-3.10.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0a62f9968bab8a676a164263e485f30a0b748255ee2f4ae49a0224be95f4532b"}, + {file = "orjson-3.10.3-cp38-none-win32.whl", hash = "sha256:8d0b84403d287d4bfa9bf7d1dc298d5c1c5d9f444f3737929a66f2fe4fb8f134"}, + {file = "orjson-3.10.3-cp38-none-win_amd64.whl", hash = "sha256:8bc7a4df90da5d535e18157220d7915780d07198b54f4de0110eca6b6c11e290"}, + {file = "orjson-3.10.3-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9059d15c30e675a58fdcd6f95465c1522b8426e092de9fff20edebfdc15e1cb0"}, + {file = "orjson-3.10.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8d40c7f7938c9c2b934b297412c067936d0b54e4b8ab916fd1a9eb8f54c02294"}, + {file = "orjson-3.10.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d4a654ec1de8fdaae1d80d55cee65893cb06494e124681ab335218be6a0691e7"}, + {file = "orjson-3.10.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:831c6ef73f9aa53c5f40ae8f949ff7681b38eaddb6904aab89dca4d85099cb78"}, + {file = "orjson-3.10.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:99b880d7e34542db89f48d14ddecbd26f06838b12427d5a25d71baceb5ba119d"}, + {file = "orjson-3.10.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2e5e176c994ce4bd434d7aafb9ecc893c15f347d3d2bbd8e7ce0b63071c52e25"}, + {file = "orjson-3.10.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b69a58a37dab856491bf2d3bbf259775fdce262b727f96aafbda359cb1d114d8"}, + {file = "orjson-3.10.3-cp39-none-win32.whl", hash = "sha256:b8d4d1a6868cde356f1402c8faeb50d62cee765a1f7ffcfd6de732ab0581e063"}, + {file = "orjson-3.10.3-cp39-none-win_amd64.whl", hash = "sha256:5102f50c5fc46d94f2033fe00d392588564378260d64377aec702f21a7a22912"}, + {file = "orjson-3.10.3.tar.gz", hash = "sha256:2b166507acae7ba2f7c315dcf185a9111ad5e992ac81f2d507aac39193c2c818"}, +] + [[package]] name = "oyaml" version = "1.0" @@ -544,6 +833,34 @@ tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} [package.extras] testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] +[[package]] +name = "python-dotenv" +version = "1.0.1" +description = "Read key-value pairs from a .env file and set them as environment variables" +optional = false +python-versions = ">=3.8" +files = [ + {file = "python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca"}, + {file = "python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a"}, +] + +[package.extras] +cli = ["click (>=5.0)"] + +[[package]] +name = "python-multipart" +version = "0.0.9" +description = "A streaming multipart parser for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "python_multipart-0.0.9-py3-none-any.whl", hash = "sha256:97ca7b8ea7b05f977dc3849c3ba99d51689822fab725c3703af7c866a0c2b215"}, + {file = "python_multipart-0.0.9.tar.gz", hash = "sha256:03f54688c663f1b7977105f021043b0793151e4cb1c1a9d4a11fc13d622c4026"}, +] + +[package.extras] +dev = ["atomicwrites (==1.4.1)", "attrs (==23.2.0)", "coverage (==7.4.1)", "hatch", "invoke (==2.2.0)", "more-itertools (==10.2.0)", "pbr (==6.0.0)", "pluggy (==1.4.0)", "py (==1.11.0)", "pytest (==8.0.0)", "pytest-cov (==4.1.0)", "pytest-timeout (==2.2.0)", "pyyaml (==6.0.1)", "ruff (==0.2.1)"] + [[package]] name = "pyyaml" version = "6.0.1" @@ -556,7 +873,6 @@ files = [ {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, - {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, @@ -564,15 +880,8 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, - {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, - {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, - {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, - {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, - {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, - {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, - {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, @@ -589,7 +898,6 @@ files = [ {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, - {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, @@ -597,7 +905,6 @@ files = [ {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, - {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, @@ -790,6 +1097,17 @@ files = [ {file = "ruff-0.4.4.tar.gz", hash = "sha256:f87ea42d5cdebdc6a69761a9d0bc83ae9b3b30d0ad78952005ba6568d6c022af"}, ] +[[package]] +name = "shellingham" +version = "1.5.4" +description = "Tool to Detect Surrounding Shell" +optional = false +python-versions = ">=3.7" +files = [ + {file = "shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686"}, + {file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"}, +] + [[package]] name = "smmap" version = "5.0.1" @@ -812,6 +1130,24 @@ files = [ {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, ] +[[package]] +name = "starlette" +version = "0.37.2" +description = "The little ASGI library that shines." +optional = false +python-versions = ">=3.8" +files = [ + {file = "starlette-0.37.2-py3-none-any.whl", hash = "sha256:6fe59f29268538e5d0d182f2791a479a0c64638e6935d1c6989e63fb2699c6ee"}, + {file = "starlette-0.37.2.tar.gz", hash = "sha256:9af890290133b79fc3db55474ade20f6220a364a0402e0b556e7cd5e1e093823"}, +] + +[package.dependencies] +anyio = ">=3.4.0,<5" +typing-extensions = {version = ">=3.10.0", markers = "python_version < \"3.10\""} + +[package.extras] +full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.7)", "pyyaml"] + [[package]] name = "tenacity" version = "8.2.3" @@ -920,6 +1256,23 @@ notebook = ["ipywidgets (>=6)"] slack = ["slack-sdk"] telegram = ["requests"] +[[package]] +name = "typer" +version = "0.12.3" +description = "Typer, build great CLIs. Easy to code. Based on Python type hints." +optional = false +python-versions = ">=3.7" +files = [ + {file = "typer-0.12.3-py3-none-any.whl", hash = "sha256:070d7ca53f785acbccba8e7d28b08dcd88f79f1fbda035ade0aecec71ca5c914"}, + {file = "typer-0.12.3.tar.gz", hash = "sha256:49e73131481d804288ef62598d97a1ceef3058905aa536a1134f90891ba35482"}, +] + +[package.dependencies] +click = ">=8.0.0" +rich = ">=10.11.0" +shellingham = ">=1.3.0" +typing-extensions = ">=3.7.4.3" + [[package]] name = "typing-extensions" version = "4.10.0" @@ -931,6 +1284,93 @@ files = [ {file = "typing_extensions-4.10.0.tar.gz", hash = "sha256:b0abd7c89e8fb96f98db18d86106ff1d90ab692004eb746cf6eda2682f91b3cb"}, ] +[[package]] +name = "ujson" +version = "5.10.0" +description = "Ultra fast JSON encoder and decoder for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "ujson-5.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2601aa9ecdbee1118a1c2065323bda35e2c5a2cf0797ef4522d485f9d3ef65bd"}, + {file = "ujson-5.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:348898dd702fc1c4f1051bc3aacbf894caa0927fe2c53e68679c073375f732cf"}, + {file = "ujson-5.10.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22cffecf73391e8abd65ef5f4e4dd523162a3399d5e84faa6aebbf9583df86d6"}, + {file = "ujson-5.10.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26b0e2d2366543c1bb4fbd457446f00b0187a2bddf93148ac2da07a53fe51569"}, + {file = "ujson-5.10.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:caf270c6dba1be7a41125cd1e4fc7ba384bf564650beef0df2dd21a00b7f5770"}, + {file = "ujson-5.10.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a245d59f2ffe750446292b0094244df163c3dc96b3ce152a2c837a44e7cda9d1"}, + {file = "ujson-5.10.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:94a87f6e151c5f483d7d54ceef83b45d3a9cca7a9cb453dbdbb3f5a6f64033f5"}, + {file = "ujson-5.10.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:29b443c4c0a113bcbb792c88bea67b675c7ca3ca80c3474784e08bba01c18d51"}, + {file = "ujson-5.10.0-cp310-cp310-win32.whl", hash = "sha256:c18610b9ccd2874950faf474692deee4223a994251bc0a083c114671b64e6518"}, + {file = "ujson-5.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:924f7318c31874d6bb44d9ee1900167ca32aa9b69389b98ecbde34c1698a250f"}, + {file = "ujson-5.10.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a5b366812c90e69d0f379a53648be10a5db38f9d4ad212b60af00bd4048d0f00"}, + {file = "ujson-5.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:502bf475781e8167f0f9d0e41cd32879d120a524b22358e7f205294224c71126"}, + {file = "ujson-5.10.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b91b5d0d9d283e085e821651184a647699430705b15bf274c7896f23fe9c9d8"}, + {file = "ujson-5.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:129e39af3a6d85b9c26d5577169c21d53821d8cf68e079060602e861c6e5da1b"}, + {file = "ujson-5.10.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f77b74475c462cb8b88680471193064d3e715c7c6074b1c8c412cb526466efe9"}, + {file = "ujson-5.10.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7ec0ca8c415e81aa4123501fee7f761abf4b7f386aad348501a26940beb1860f"}, + {file = "ujson-5.10.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ab13a2a9e0b2865a6c6db9271f4b46af1c7476bfd51af1f64585e919b7c07fd4"}, + {file = "ujson-5.10.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:57aaf98b92d72fc70886b5a0e1a1ca52c2320377360341715dd3933a18e827b1"}, + {file = "ujson-5.10.0-cp311-cp311-win32.whl", hash = "sha256:2987713a490ceb27edff77fb184ed09acdc565db700ee852823c3dc3cffe455f"}, + {file = "ujson-5.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:f00ea7e00447918ee0eff2422c4add4c5752b1b60e88fcb3c067d4a21049a720"}, + {file = "ujson-5.10.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:98ba15d8cbc481ce55695beee9f063189dce91a4b08bc1d03e7f0152cd4bbdd5"}, + {file = "ujson-5.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a9d2edbf1556e4f56e50fab7d8ff993dbad7f54bac68eacdd27a8f55f433578e"}, + {file = "ujson-5.10.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6627029ae4f52d0e1a2451768c2c37c0c814ffc04f796eb36244cf16b8e57043"}, + {file = "ujson-5.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f8ccb77b3e40b151e20519c6ae6d89bfe3f4c14e8e210d910287f778368bb3d1"}, + {file = "ujson-5.10.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3caf9cd64abfeb11a3b661329085c5e167abbe15256b3b68cb5d914ba7396f3"}, + {file = "ujson-5.10.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6e32abdce572e3a8c3d02c886c704a38a1b015a1fb858004e03d20ca7cecbb21"}, + {file = "ujson-5.10.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a65b6af4d903103ee7b6f4f5b85f1bfd0c90ba4eeac6421aae436c9988aa64a2"}, + {file = "ujson-5.10.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:604a046d966457b6cdcacc5aa2ec5314f0e8c42bae52842c1e6fa02ea4bda42e"}, + {file = "ujson-5.10.0-cp312-cp312-win32.whl", hash = "sha256:6dea1c8b4fc921bf78a8ff00bbd2bfe166345f5536c510671bccececb187c80e"}, + {file = "ujson-5.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:38665e7d8290188b1e0d57d584eb8110951a9591363316dd41cf8686ab1d0abc"}, + {file = "ujson-5.10.0-cp313-cp313-macosx_10_9_x86_64.whl", hash = "sha256:618efd84dc1acbd6bff8eaa736bb6c074bfa8b8a98f55b61c38d4ca2c1f7f287"}, + {file = "ujson-5.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:38d5d36b4aedfe81dfe251f76c0467399d575d1395a1755de391e58985ab1c2e"}, + {file = "ujson-5.10.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67079b1f9fb29ed9a2914acf4ef6c02844b3153913eb735d4bf287ee1db6e557"}, + {file = "ujson-5.10.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d7d0e0ceeb8fe2468c70ec0c37b439dd554e2aa539a8a56365fd761edb418988"}, + {file = "ujson-5.10.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:59e02cd37bc7c44d587a0ba45347cc815fb7a5fe48de16bf05caa5f7d0d2e816"}, + {file = "ujson-5.10.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2a890b706b64e0065f02577bf6d8ca3b66c11a5e81fb75d757233a38c07a1f20"}, + {file = "ujson-5.10.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:621e34b4632c740ecb491efc7f1fcb4f74b48ddb55e65221995e74e2d00bbff0"}, + {file = "ujson-5.10.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b9500e61fce0cfc86168b248104e954fead61f9be213087153d272e817ec7b4f"}, + {file = "ujson-5.10.0-cp313-cp313-win32.whl", hash = "sha256:4c4fc16f11ac1612f05b6f5781b384716719547e142cfd67b65d035bd85af165"}, + {file = "ujson-5.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:4573fd1695932d4f619928fd09d5d03d917274381649ade4328091ceca175539"}, + {file = "ujson-5.10.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:a984a3131da7f07563057db1c3020b1350a3e27a8ec46ccbfbf21e5928a43050"}, + {file = "ujson-5.10.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:73814cd1b9db6fc3270e9d8fe3b19f9f89e78ee9d71e8bd6c9a626aeaeaf16bd"}, + {file = "ujson-5.10.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:61e1591ed9376e5eddda202ec229eddc56c612b61ac6ad07f96b91460bb6c2fb"}, + {file = "ujson-5.10.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2c75269f8205b2690db4572a4a36fe47cd1338e4368bc73a7a0e48789e2e35a"}, + {file = "ujson-5.10.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7223f41e5bf1f919cd8d073e35b229295aa8e0f7b5de07ed1c8fddac63a6bc5d"}, + {file = "ujson-5.10.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc2fd6b3067c0782e7002ac3b38cf48608ee6366ff176bbd02cf969c9c20fe"}, + {file = "ujson-5.10.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:232cc85f8ee3c454c115455195a205074a56ff42608fd6b942aa4c378ac14dd7"}, + {file = "ujson-5.10.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:cc6139531f13148055d691e442e4bc6601f6dba1e6d521b1585d4788ab0bfad4"}, + {file = "ujson-5.10.0-cp38-cp38-win32.whl", hash = "sha256:e7ce306a42b6b93ca47ac4a3b96683ca554f6d35dd8adc5acfcd55096c8dfcb8"}, + {file = "ujson-5.10.0-cp38-cp38-win_amd64.whl", hash = "sha256:e82d4bb2138ab05e18f089a83b6564fee28048771eb63cdecf4b9b549de8a2cc"}, + {file = "ujson-5.10.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:dfef2814c6b3291c3c5f10065f745a1307d86019dbd7ea50e83504950136ed5b"}, + {file = "ujson-5.10.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4734ee0745d5928d0ba3a213647f1c4a74a2a28edc6d27b2d6d5bd9fa4319e27"}, + {file = "ujson-5.10.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d47ebb01bd865fdea43da56254a3930a413f0c5590372a1241514abae8aa7c76"}, + {file = "ujson-5.10.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dee5e97c2496874acbf1d3e37b521dd1f307349ed955e62d1d2f05382bc36dd5"}, + {file = "ujson-5.10.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7490655a2272a2d0b072ef16b0b58ee462f4973a8f6bbe64917ce5e0a256f9c0"}, + {file = "ujson-5.10.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:ba17799fcddaddf5c1f75a4ba3fd6441f6a4f1e9173f8a786b42450851bd74f1"}, + {file = "ujson-5.10.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:2aff2985cef314f21d0fecc56027505804bc78802c0121343874741650a4d3d1"}, + {file = "ujson-5.10.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:ad88ac75c432674d05b61184178635d44901eb749786c8eb08c102330e6e8996"}, + {file = "ujson-5.10.0-cp39-cp39-win32.whl", hash = "sha256:2544912a71da4ff8c4f7ab5606f947d7299971bdd25a45e008e467ca638d13c9"}, + {file = "ujson-5.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:3ff201d62b1b177a46f113bb43ad300b424b7847f9c5d38b1b4ad8f75d4a282a"}, + {file = "ujson-5.10.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:5b6fee72fa77dc172a28f21693f64d93166534c263adb3f96c413ccc85ef6e64"}, + {file = "ujson-5.10.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:61d0af13a9af01d9f26d2331ce49bb5ac1fb9c814964018ac8df605b5422dcb3"}, + {file = "ujson-5.10.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ecb24f0bdd899d368b715c9e6664166cf694d1e57be73f17759573a6986dd95a"}, + {file = "ujson-5.10.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fbd8fd427f57a03cff3ad6574b5e299131585d9727c8c366da4624a9069ed746"}, + {file = "ujson-5.10.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:beeaf1c48e32f07d8820c705ff8e645f8afa690cca1544adba4ebfa067efdc88"}, + {file = "ujson-5.10.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:baed37ea46d756aca2955e99525cc02d9181de67f25515c468856c38d52b5f3b"}, + {file = "ujson-5.10.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7663960f08cd5a2bb152f5ee3992e1af7690a64c0e26d31ba7b3ff5b2ee66337"}, + {file = "ujson-5.10.0-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:d8640fb4072d36b08e95a3a380ba65779d356b2fee8696afeb7794cf0902d0a1"}, + {file = "ujson-5.10.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78778a3aa7aafb11e7ddca4e29f46bc5139131037ad628cc10936764282d6753"}, + {file = "ujson-5.10.0-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0111b27f2d5c820e7f2dbad7d48e3338c824e7ac4d2a12da3dc6061cc39c8e6"}, + {file = "ujson-5.10.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:c66962ca7565605b355a9ed478292da628b8f18c0f2793021ca4425abf8b01e5"}, + {file = "ujson-5.10.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ba43cc34cce49cf2d4bc76401a754a81202d8aa926d0e2b79f0ee258cb15d3a4"}, + {file = "ujson-5.10.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:ac56eb983edce27e7f51d05bc8dd820586c6e6be1c5216a6809b0c668bb312b8"}, + {file = "ujson-5.10.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f44bd4b23a0e723bf8b10628288c2c7c335161d6840013d4d5de20e48551773b"}, + {file = "ujson-5.10.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7c10f4654e5326ec14a46bcdeb2b685d4ada6911050aa8baaf3501e57024b804"}, + {file = "ujson-5.10.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0de4971a89a762398006e844ae394bd46991f7c385d7a6a3b93ba229e6dac17e"}, + {file = "ujson-5.10.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:e1402f0564a97d2a52310ae10a64d25bcef94f8dd643fcf5d310219d915484f7"}, + {file = "ujson-5.10.0.tar.gz", hash = "sha256:b3cd8f3c5d8c7738257f1018880444f7b7d9b66232c64649f562d7ba86ad4bc1"}, +] + [[package]] name = "urllib3" version = "1.26.18" @@ -947,6 +1387,244 @@ brotli = ["brotli (==1.0.9)", "brotli (>=1.0.9)", "brotlicffi (>=0.8.0)", "brotl secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress", "pyOpenSSL (>=0.14)", "urllib3-secure-extra"] socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] +[[package]] +name = "uvicorn" +version = "0.30.1" +description = "The lightning-fast ASGI server." +optional = false +python-versions = ">=3.8" +files = [ + {file = "uvicorn-0.30.1-py3-none-any.whl", hash = "sha256:cd17daa7f3b9d7a24de3617820e634d0933b69eed8e33a516071174427238c81"}, + {file = "uvicorn-0.30.1.tar.gz", hash = "sha256:d46cd8e0fd80240baffbcd9ec1012a712938754afcf81bce56c024c1656aece8"}, +] + +[package.dependencies] +click = ">=7.0" +colorama = {version = ">=0.4", optional = true, markers = "sys_platform == \"win32\" and extra == \"standard\""} +h11 = ">=0.8" +httptools = {version = ">=0.5.0", optional = true, markers = "extra == \"standard\""} +python-dotenv = {version = ">=0.13", optional = true, markers = "extra == \"standard\""} +pyyaml = {version = ">=5.1", optional = true, markers = "extra == \"standard\""} +typing-extensions = {version = ">=4.0", markers = "python_version < \"3.11\""} +uvloop = {version = ">=0.14.0,<0.15.0 || >0.15.0,<0.15.1 || >0.15.1", optional = true, markers = "(sys_platform != \"win32\" and sys_platform != \"cygwin\") and platform_python_implementation != \"PyPy\" and extra == \"standard\""} +watchfiles = {version = ">=0.13", optional = true, markers = "extra == \"standard\""} +websockets = {version = ">=10.4", optional = true, markers = "extra == \"standard\""} + +[package.extras] +standard = ["colorama (>=0.4)", "httptools (>=0.5.0)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "watchfiles (>=0.13)", "websockets (>=10.4)"] + +[[package]] +name = "uvloop" +version = "0.19.0" +description = "Fast implementation of asyncio event loop on top of libuv" +optional = false +python-versions = ">=3.8.0" +files = [ + {file = "uvloop-0.19.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:de4313d7f575474c8f5a12e163f6d89c0a878bc49219641d49e6f1444369a90e"}, + {file = "uvloop-0.19.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5588bd21cf1fcf06bded085f37e43ce0e00424197e7c10e77afd4bbefffef428"}, + {file = "uvloop-0.19.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b1fd71c3843327f3bbc3237bedcdb6504fd50368ab3e04d0410e52ec293f5b8"}, + {file = "uvloop-0.19.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a05128d315e2912791de6088c34136bfcdd0c7cbc1cf85fd6fd1bb321b7c849"}, + {file = "uvloop-0.19.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:cd81bdc2b8219cb4b2556eea39d2e36bfa375a2dd021404f90a62e44efaaf957"}, + {file = "uvloop-0.19.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:5f17766fb6da94135526273080f3455a112f82570b2ee5daa64d682387fe0dcd"}, + {file = "uvloop-0.19.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4ce6b0af8f2729a02a5d1575feacb2a94fc7b2e983868b009d51c9a9d2149bef"}, + {file = "uvloop-0.19.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:31e672bb38b45abc4f26e273be83b72a0d28d074d5b370fc4dcf4c4eb15417d2"}, + {file = "uvloop-0.19.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:570fc0ed613883d8d30ee40397b79207eedd2624891692471808a95069a007c1"}, + {file = "uvloop-0.19.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5138821e40b0c3e6c9478643b4660bd44372ae1e16a322b8fc07478f92684e24"}, + {file = "uvloop-0.19.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:91ab01c6cd00e39cde50173ba4ec68a1e578fee9279ba64f5221810a9e786533"}, + {file = "uvloop-0.19.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:47bf3e9312f63684efe283f7342afb414eea4d3011542155c7e625cd799c3b12"}, + {file = "uvloop-0.19.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:da8435a3bd498419ee8c13c34b89b5005130a476bda1d6ca8cfdde3de35cd650"}, + {file = "uvloop-0.19.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:02506dc23a5d90e04d4f65c7791e65cf44bd91b37f24cfc3ef6cf2aff05dc7ec"}, + {file = "uvloop-0.19.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2693049be9d36fef81741fddb3f441673ba12a34a704e7b4361efb75cf30befc"}, + {file = "uvloop-0.19.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7010271303961c6f0fe37731004335401eb9075a12680738731e9c92ddd96ad6"}, + {file = "uvloop-0.19.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:5daa304d2161d2918fa9a17d5635099a2f78ae5b5960e742b2fcfbb7aefaa593"}, + {file = "uvloop-0.19.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:7207272c9520203fea9b93843bb775d03e1cf88a80a936ce760f60bb5add92f3"}, + {file = "uvloop-0.19.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:78ab247f0b5671cc887c31d33f9b3abfb88d2614b84e4303f1a63b46c046c8bd"}, + {file = "uvloop-0.19.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:472d61143059c84947aa8bb74eabbace30d577a03a1805b77933d6bd13ddebbd"}, + {file = "uvloop-0.19.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45bf4c24c19fb8a50902ae37c5de50da81de4922af65baf760f7c0c42e1088be"}, + {file = "uvloop-0.19.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:271718e26b3e17906b28b67314c45d19106112067205119dddbd834c2b7ce797"}, + {file = "uvloop-0.19.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:34175c9fd2a4bc3adc1380e1261f60306344e3407c20a4d684fd5f3be010fa3d"}, + {file = "uvloop-0.19.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:e27f100e1ff17f6feeb1f33968bc185bf8ce41ca557deee9d9bbbffeb72030b7"}, + {file = "uvloop-0.19.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:13dfdf492af0aa0a0edf66807d2b465607d11c4fa48f4a1fd41cbea5b18e8e8b"}, + {file = "uvloop-0.19.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6e3d4e85ac060e2342ff85e90d0c04157acb210b9ce508e784a944f852a40e67"}, + {file = "uvloop-0.19.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8ca4956c9ab567d87d59d49fa3704cf29e37109ad348f2d5223c9bf761a332e7"}, + {file = "uvloop-0.19.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f467a5fd23b4fc43ed86342641f3936a68ded707f4627622fa3f82a120e18256"}, + {file = "uvloop-0.19.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:492e2c32c2af3f971473bc22f086513cedfc66a130756145a931a90c3958cb17"}, + {file = "uvloop-0.19.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:2df95fca285a9f5bfe730e51945ffe2fa71ccbfdde3b0da5772b4ee4f2e770d5"}, + {file = "uvloop-0.19.0.tar.gz", hash = "sha256:0246f4fd1bf2bf702e06b0d45ee91677ee5c31242f39aab4ea6fe0c51aedd0fd"}, +] + +[package.extras] +docs = ["Sphinx (>=4.1.2,<4.2.0)", "sphinx-rtd-theme (>=0.5.2,<0.6.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)"] +test = ["Cython (>=0.29.36,<0.30.0)", "aiohttp (==3.9.0b0)", "aiohttp (>=3.8.1)", "flake8 (>=5.0,<6.0)", "mypy (>=0.800)", "psutil", "pyOpenSSL (>=23.0.0,<23.1.0)", "pycodestyle (>=2.9.0,<2.10.0)"] + +[[package]] +name = "watchfiles" +version = "0.22.0" +description = "Simple, modern and high performance file watching and code reload in python." +optional = false +python-versions = ">=3.8" +files = [ + {file = "watchfiles-0.22.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:da1e0a8caebf17976e2ffd00fa15f258e14749db5e014660f53114b676e68538"}, + {file = "watchfiles-0.22.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:61af9efa0733dc4ca462347becb82e8ef4945aba5135b1638bfc20fad64d4f0e"}, + {file = "watchfiles-0.22.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d9188979a58a096b6f8090e816ccc3f255f137a009dd4bbec628e27696d67c1"}, + {file = "watchfiles-0.22.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2bdadf6b90c099ca079d468f976fd50062905d61fae183f769637cb0f68ba59a"}, + {file = "watchfiles-0.22.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:067dea90c43bf837d41e72e546196e674f68c23702d3ef80e4e816937b0a3ffd"}, + {file = "watchfiles-0.22.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bbf8a20266136507abf88b0df2328e6a9a7c7309e8daff124dda3803306a9fdb"}, + {file = "watchfiles-0.22.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1235c11510ea557fe21be5d0e354bae2c655a8ee6519c94617fe63e05bca4171"}, + {file = "watchfiles-0.22.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c2444dc7cb9d8cc5ab88ebe792a8d75709d96eeef47f4c8fccb6df7c7bc5be71"}, + {file = "watchfiles-0.22.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:c5af2347d17ab0bd59366db8752d9e037982e259cacb2ba06f2c41c08af02c39"}, + {file = "watchfiles-0.22.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:9624a68b96c878c10437199d9a8b7d7e542feddda8d5ecff58fdc8e67b460848"}, + {file = "watchfiles-0.22.0-cp310-none-win32.whl", hash = "sha256:4b9f2a128a32a2c273d63eb1fdbf49ad64852fc38d15b34eaa3f7ca2f0d2b797"}, + {file = "watchfiles-0.22.0-cp310-none-win_amd64.whl", hash = "sha256:2627a91e8110b8de2406d8b2474427c86f5a62bf7d9ab3654f541f319ef22bcb"}, + {file = "watchfiles-0.22.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:8c39987a1397a877217be1ac0fb1d8b9f662c6077b90ff3de2c05f235e6a8f96"}, + {file = "watchfiles-0.22.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a927b3034d0672f62fb2ef7ea3c9fc76d063c4b15ea852d1db2dc75fe2c09696"}, + {file = "watchfiles-0.22.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:052d668a167e9fc345c24203b104c313c86654dd6c0feb4b8a6dfc2462239249"}, + {file = "watchfiles-0.22.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5e45fb0d70dda1623a7045bd00c9e036e6f1f6a85e4ef2c8ae602b1dfadf7550"}, + {file = "watchfiles-0.22.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c49b76a78c156979759d759339fb62eb0549515acfe4fd18bb151cc07366629c"}, + {file = "watchfiles-0.22.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c4a65474fd2b4c63e2c18ac67a0c6c66b82f4e73e2e4d940f837ed3d2fd9d4da"}, + {file = "watchfiles-0.22.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc0cba54f47c660d9fa3218158b8963c517ed23bd9f45fe463f08262a4adae1"}, + {file = "watchfiles-0.22.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94ebe84a035993bb7668f58a0ebf998174fb723a39e4ef9fce95baabb42b787f"}, + {file = "watchfiles-0.22.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e0f0a874231e2839abbf473256efffe577d6ee2e3bfa5b540479e892e47c172d"}, + {file = "watchfiles-0.22.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:213792c2cd3150b903e6e7884d40660e0bcec4465e00563a5fc03f30ea9c166c"}, + {file = "watchfiles-0.22.0-cp311-none-win32.whl", hash = "sha256:b44b70850f0073b5fcc0b31ede8b4e736860d70e2dbf55701e05d3227a154a67"}, + {file = "watchfiles-0.22.0-cp311-none-win_amd64.whl", hash = "sha256:00f39592cdd124b4ec5ed0b1edfae091567c72c7da1487ae645426d1b0ffcad1"}, + {file = "watchfiles-0.22.0-cp311-none-win_arm64.whl", hash = "sha256:3218a6f908f6a276941422b035b511b6d0d8328edd89a53ae8c65be139073f84"}, + {file = "watchfiles-0.22.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:c7b978c384e29d6c7372209cbf421d82286a807bbcdeb315427687f8371c340a"}, + {file = "watchfiles-0.22.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd4c06100bce70a20c4b81e599e5886cf504c9532951df65ad1133e508bf20be"}, + {file = "watchfiles-0.22.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:425440e55cd735386ec7925f64d5dde392e69979d4c8459f6bb4e920210407f2"}, + {file = "watchfiles-0.22.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:68fe0c4d22332d7ce53ad094622b27e67440dacefbaedd29e0794d26e247280c"}, + {file = "watchfiles-0.22.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a8a31bfd98f846c3c284ba694c6365620b637debdd36e46e1859c897123aa232"}, + {file = "watchfiles-0.22.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc2e8fe41f3cac0660197d95216c42910c2b7e9c70d48e6d84e22f577d106fc1"}, + {file = "watchfiles-0.22.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:55b7cc10261c2786c41d9207193a85c1db1b725cf87936df40972aab466179b6"}, + {file = "watchfiles-0.22.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28585744c931576e535860eaf3f2c0ec7deb68e3b9c5a85ca566d69d36d8dd27"}, + {file = "watchfiles-0.22.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:00095dd368f73f8f1c3a7982a9801190cc88a2f3582dd395b289294f8975172b"}, + {file = "watchfiles-0.22.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:52fc9b0dbf54d43301a19b236b4a4614e610605f95e8c3f0f65c3a456ffd7d35"}, + {file = "watchfiles-0.22.0-cp312-none-win32.whl", hash = "sha256:581f0a051ba7bafd03e17127735d92f4d286af941dacf94bcf823b101366249e"}, + {file = "watchfiles-0.22.0-cp312-none-win_amd64.whl", hash = "sha256:aec83c3ba24c723eac14225194b862af176d52292d271c98820199110e31141e"}, + {file = "watchfiles-0.22.0-cp312-none-win_arm64.whl", hash = "sha256:c668228833c5619f6618699a2c12be057711b0ea6396aeaece4ded94184304ea"}, + {file = "watchfiles-0.22.0-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:d47e9ef1a94cc7a536039e46738e17cce058ac1593b2eccdede8bf72e45f372a"}, + {file = "watchfiles-0.22.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:28f393c1194b6eaadcdd8f941307fc9bbd7eb567995232c830f6aef38e8a6e88"}, + {file = "watchfiles-0.22.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd64f3a4db121bc161644c9e10a9acdb836853155a108c2446db2f5ae1778c3d"}, + {file = "watchfiles-0.22.0-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2abeb79209630da981f8ebca30a2c84b4c3516a214451bfc5f106723c5f45843"}, + {file = "watchfiles-0.22.0-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4cc382083afba7918e32d5ef12321421ef43d685b9a67cc452a6e6e18920890e"}, + {file = "watchfiles-0.22.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d048ad5d25b363ba1d19f92dcf29023988524bee6f9d952130b316c5802069cb"}, + {file = "watchfiles-0.22.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:103622865599f8082f03af4214eaff90e2426edff5e8522c8f9e93dc17caee13"}, + {file = "watchfiles-0.22.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d3e1f3cf81f1f823e7874ae563457828e940d75573c8fbf0ee66818c8b6a9099"}, + {file = "watchfiles-0.22.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:8597b6f9dc410bdafc8bb362dac1cbc9b4684a8310e16b1ff5eee8725d13dcd6"}, + {file = "watchfiles-0.22.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:0b04a2cbc30e110303baa6d3ddce8ca3664bc3403be0f0ad513d1843a41c97d1"}, + {file = "watchfiles-0.22.0-cp38-none-win32.whl", hash = "sha256:b610fb5e27825b570554d01cec427b6620ce9bd21ff8ab775fc3a32f28bba63e"}, + {file = "watchfiles-0.22.0-cp38-none-win_amd64.whl", hash = "sha256:fe82d13461418ca5e5a808a9e40f79c1879351fcaeddbede094028e74d836e86"}, + {file = "watchfiles-0.22.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:3973145235a38f73c61474d56ad6199124e7488822f3a4fc97c72009751ae3b0"}, + {file = "watchfiles-0.22.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:280a4afbc607cdfc9571b9904b03a478fc9f08bbeec382d648181c695648202f"}, + {file = "watchfiles-0.22.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3a0d883351a34c01bd53cfa75cd0292e3f7e268bacf2f9e33af4ecede7e21d1d"}, + {file = "watchfiles-0.22.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9165bcab15f2b6d90eedc5c20a7f8a03156b3773e5fb06a790b54ccecdb73385"}, + {file = "watchfiles-0.22.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dc1b9b56f051209be458b87edb6856a449ad3f803315d87b2da4c93b43a6fe72"}, + {file = "watchfiles-0.22.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8dc1fc25a1dedf2dd952909c8e5cb210791e5f2d9bc5e0e8ebc28dd42fed7562"}, + {file = "watchfiles-0.22.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dc92d2d2706d2b862ce0568b24987eba51e17e14b79a1abcd2edc39e48e743c8"}, + {file = "watchfiles-0.22.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:97b94e14b88409c58cdf4a8eaf0e67dfd3ece7e9ce7140ea6ff48b0407a593ec"}, + {file = "watchfiles-0.22.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:96eec15e5ea7c0b6eb5bfffe990fc7c6bd833acf7e26704eb18387fb2f5fd087"}, + {file = "watchfiles-0.22.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:28324d6b28bcb8d7c1041648d7b63be07a16db5510bea923fc80b91a2a6cbed6"}, + {file = "watchfiles-0.22.0-cp39-none-win32.whl", hash = "sha256:8c3e3675e6e39dc59b8fe5c914a19d30029e36e9f99468dddffd432d8a7b1c93"}, + {file = "watchfiles-0.22.0-cp39-none-win_amd64.whl", hash = "sha256:25c817ff2a86bc3de3ed2df1703e3d24ce03479b27bb4527c57e722f8554d971"}, + {file = "watchfiles-0.22.0-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b810a2c7878cbdecca12feae2c2ae8af59bea016a78bc353c184fa1e09f76b68"}, + {file = "watchfiles-0.22.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:f7e1f9c5d1160d03b93fc4b68a0aeb82fe25563e12fbcdc8507f8434ab6f823c"}, + {file = "watchfiles-0.22.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:030bc4e68d14bcad2294ff68c1ed87215fbd9a10d9dea74e7cfe8a17869785ab"}, + {file = "watchfiles-0.22.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ace7d060432acde5532e26863e897ee684780337afb775107c0a90ae8dbccfd2"}, + {file = "watchfiles-0.22.0-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5834e1f8b71476a26df97d121c0c0ed3549d869124ed2433e02491553cb468c2"}, + {file = "watchfiles-0.22.0-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:0bc3b2f93a140df6806c8467c7f51ed5e55a931b031b5c2d7ff6132292e803d6"}, + {file = "watchfiles-0.22.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8fdebb655bb1ba0122402352b0a4254812717a017d2dc49372a1d47e24073795"}, + {file = "watchfiles-0.22.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c8e0aa0e8cc2a43561e0184c0513e291ca891db13a269d8d47cb9841ced7c71"}, + {file = "watchfiles-0.22.0-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:2f350cbaa4bb812314af5dab0eb8d538481e2e2279472890864547f3fe2281ed"}, + {file = "watchfiles-0.22.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7a74436c415843af2a769b36bf043b6ccbc0f8d784814ba3d42fc961cdb0a9dc"}, + {file = "watchfiles-0.22.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:00ad0bcd399503a84cc688590cdffbe7a991691314dde5b57b3ed50a41319a31"}, + {file = "watchfiles-0.22.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:72a44e9481afc7a5ee3291b09c419abab93b7e9c306c9ef9108cb76728ca58d2"}, + {file = "watchfiles-0.22.0.tar.gz", hash = "sha256:988e981aaab4f3955209e7e28c7794acdb690be1efa7f16f8ea5aba7ffdadacb"}, +] + +[package.dependencies] +anyio = ">=3.0.0" + +[[package]] +name = "websockets" +version = "12.0" +description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" +optional = false +python-versions = ">=3.8" +files = [ + {file = "websockets-12.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d554236b2a2006e0ce16315c16eaa0d628dab009c33b63ea03f41c6107958374"}, + {file = "websockets-12.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2d225bb6886591b1746b17c0573e29804619c8f755b5598d875bb4235ea639be"}, + {file = "websockets-12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:eb809e816916a3b210bed3c82fb88eaf16e8afcf9c115ebb2bacede1797d2547"}, + {file = "websockets-12.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c588f6abc13f78a67044c6b1273a99e1cf31038ad51815b3b016ce699f0d75c2"}, + {file = "websockets-12.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5aa9348186d79a5f232115ed3fa9020eab66d6c3437d72f9d2c8ac0c6858c558"}, + {file = "websockets-12.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6350b14a40c95ddd53e775dbdbbbc59b124a5c8ecd6fbb09c2e52029f7a9f480"}, + {file = "websockets-12.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:70ec754cc2a769bcd218ed8d7209055667b30860ffecb8633a834dde27d6307c"}, + {file = "websockets-12.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6e96f5ed1b83a8ddb07909b45bd94833b0710f738115751cdaa9da1fb0cb66e8"}, + {file = "websockets-12.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4d87be612cbef86f994178d5186add3d94e9f31cc3cb499a0482b866ec477603"}, + {file = "websockets-12.0-cp310-cp310-win32.whl", hash = "sha256:befe90632d66caaf72e8b2ed4d7f02b348913813c8b0a32fae1cc5fe3730902f"}, + {file = "websockets-12.0-cp310-cp310-win_amd64.whl", hash = "sha256:363f57ca8bc8576195d0540c648aa58ac18cf85b76ad5202b9f976918f4219cf"}, + {file = "websockets-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5d873c7de42dea355d73f170be0f23788cf3fa9f7bed718fd2830eefedce01b4"}, + {file = "websockets-12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3f61726cae9f65b872502ff3c1496abc93ffbe31b278455c418492016e2afc8f"}, + {file = "websockets-12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ed2fcf7a07334c77fc8a230755c2209223a7cc44fc27597729b8ef5425aa61a3"}, + {file = "websockets-12.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e332c210b14b57904869ca9f9bf4ca32f5427a03eeb625da9b616c85a3a506c"}, + {file = "websockets-12.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5693ef74233122f8ebab026817b1b37fe25c411ecfca084b29bc7d6efc548f45"}, + {file = "websockets-12.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e9e7db18b4539a29cc5ad8c8b252738a30e2b13f033c2d6e9d0549b45841c04"}, + {file = "websockets-12.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:6e2df67b8014767d0f785baa98393725739287684b9f8d8a1001eb2839031447"}, + {file = "websockets-12.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:bea88d71630c5900690fcb03161ab18f8f244805c59e2e0dc4ffadae0a7ee0ca"}, + {file = "websockets-12.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:dff6cdf35e31d1315790149fee351f9e52978130cef6c87c4b6c9b3baf78bc53"}, + {file = "websockets-12.0-cp311-cp311-win32.whl", hash = "sha256:3e3aa8c468af01d70332a382350ee95f6986db479ce7af14d5e81ec52aa2b402"}, + {file = "websockets-12.0-cp311-cp311-win_amd64.whl", hash = "sha256:25eb766c8ad27da0f79420b2af4b85d29914ba0edf69f547cc4f06ca6f1d403b"}, + {file = "websockets-12.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0e6e2711d5a8e6e482cacb927a49a3d432345dfe7dea8ace7b5790df5932e4df"}, + {file = "websockets-12.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:dbcf72a37f0b3316e993e13ecf32f10c0e1259c28ffd0a85cee26e8549595fbc"}, + {file = "websockets-12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:12743ab88ab2af1d17dd4acb4645677cb7063ef4db93abffbf164218a5d54c6b"}, + {file = "websockets-12.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b645f491f3c48d3f8a00d1fce07445fab7347fec54a3e65f0725d730d5b99cb"}, + {file = "websockets-12.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9893d1aa45a7f8b3bc4510f6ccf8db8c3b62120917af15e3de247f0780294b92"}, + {file = "websockets-12.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f38a7b376117ef7aff996e737583172bdf535932c9ca021746573bce40165ed"}, + {file = "websockets-12.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:f764ba54e33daf20e167915edc443b6f88956f37fb606449b4a5b10ba42235a5"}, + {file = "websockets-12.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:1e4b3f8ea6a9cfa8be8484c9221ec0257508e3a1ec43c36acdefb2a9c3b00aa2"}, + {file = "websockets-12.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:9fdf06fd06c32205a07e47328ab49c40fc1407cdec801d698a7c41167ea45113"}, + {file = "websockets-12.0-cp312-cp312-win32.whl", hash = "sha256:baa386875b70cbd81798fa9f71be689c1bf484f65fd6fb08d051a0ee4e79924d"}, + {file = "websockets-12.0-cp312-cp312-win_amd64.whl", hash = "sha256:ae0a5da8f35a5be197f328d4727dbcfafa53d1824fac3d96cdd3a642fe09394f"}, + {file = "websockets-12.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5f6ffe2c6598f7f7207eef9a1228b6f5c818f9f4d53ee920aacd35cec8110438"}, + {file = "websockets-12.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9edf3fc590cc2ec20dc9d7a45108b5bbaf21c0d89f9fd3fd1685e223771dc0b2"}, + {file = "websockets-12.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:8572132c7be52632201a35f5e08348137f658e5ffd21f51f94572ca6c05ea81d"}, + {file = "websockets-12.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:604428d1b87edbf02b233e2c207d7d528460fa978f9e391bd8aaf9c8311de137"}, + {file = "websockets-12.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1a9d160fd080c6285e202327aba140fc9a0d910b09e423afff4ae5cbbf1c7205"}, + {file = "websockets-12.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87b4aafed34653e465eb77b7c93ef058516cb5acf3eb21e42f33928616172def"}, + {file = "websockets-12.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b2ee7288b85959797970114deae81ab41b731f19ebcd3bd499ae9ca0e3f1d2c8"}, + {file = "websockets-12.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:7fa3d25e81bfe6a89718e9791128398a50dec6d57faf23770787ff441d851967"}, + {file = "websockets-12.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:a571f035a47212288e3b3519944f6bf4ac7bc7553243e41eac50dd48552b6df7"}, + {file = "websockets-12.0-cp38-cp38-win32.whl", hash = "sha256:3c6cc1360c10c17463aadd29dd3af332d4a1adaa8796f6b0e9f9df1fdb0bad62"}, + {file = "websockets-12.0-cp38-cp38-win_amd64.whl", hash = "sha256:1bf386089178ea69d720f8db6199a0504a406209a0fc23e603b27b300fdd6892"}, + {file = "websockets-12.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:ab3d732ad50a4fbd04a4490ef08acd0517b6ae6b77eb967251f4c263011a990d"}, + {file = "websockets-12.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a1d9697f3337a89691e3bd8dc56dea45a6f6d975f92e7d5f773bc715c15dde28"}, + {file = "websockets-12.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1df2fbd2c8a98d38a66f5238484405b8d1d16f929bb7a33ed73e4801222a6f53"}, + {file = "websockets-12.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23509452b3bc38e3a057382c2e941d5ac2e01e251acce7adc74011d7d8de434c"}, + {file = "websockets-12.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2e5fc14ec6ea568200ea4ef46545073da81900a2b67b3e666f04adf53ad452ec"}, + {file = "websockets-12.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46e71dbbd12850224243f5d2aeec90f0aaa0f2dde5aeeb8fc8df21e04d99eff9"}, + {file = "websockets-12.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b81f90dcc6c85a9b7f29873beb56c94c85d6f0dac2ea8b60d995bd18bf3e2aae"}, + {file = "websockets-12.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:a02413bc474feda2849c59ed2dfb2cddb4cd3d2f03a2fedec51d6e959d9b608b"}, + {file = "websockets-12.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:bbe6013f9f791944ed31ca08b077e26249309639313fff132bfbf3ba105673b9"}, + {file = "websockets-12.0-cp39-cp39-win32.whl", hash = "sha256:cbe83a6bbdf207ff0541de01e11904827540aa069293696dd528a6640bd6a5f6"}, + {file = "websockets-12.0-cp39-cp39-win_amd64.whl", hash = "sha256:fc4e7fa5414512b481a2483775a8e8be7803a35b30ca805afa4998a84f9fd9e8"}, + {file = "websockets-12.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:248d8e2446e13c1d4326e0a6a4e9629cb13a11195051a73acf414812700badbd"}, + {file = "websockets-12.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f44069528d45a933997a6fef143030d8ca8042f0dfaad753e2906398290e2870"}, + {file = "websockets-12.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c4e37d36f0d19f0a4413d3e18c0d03d0c268ada2061868c1e6f5ab1a6d575077"}, + {file = "websockets-12.0-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d829f975fc2e527a3ef2f9c8f25e553eb7bc779c6665e8e1d52aa22800bb38b"}, + {file = "websockets-12.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:2c71bd45a777433dd9113847af751aae36e448bc6b8c361a566cb043eda6ec30"}, + {file = "websockets-12.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:0bee75f400895aef54157b36ed6d3b308fcab62e5260703add87f44cee9c82a6"}, + {file = "websockets-12.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:423fc1ed29f7512fceb727e2d2aecb952c46aa34895e9ed96071821309951123"}, + {file = "websockets-12.0-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:27a5e9964ef509016759f2ef3f2c1e13f403725a5e6a1775555994966a66e931"}, + {file = "websockets-12.0-pp38-pypy38_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c3181df4583c4d3994d31fb235dc681d2aaad744fbdbf94c4802485ececdecf2"}, + {file = "websockets-12.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:b067cb952ce8bf40115f6c19f478dc71c5e719b7fbaa511359795dfd9d1a6468"}, + {file = "websockets-12.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:00700340c6c7ab788f176d118775202aadea7602c5cc6be6ae127761c16d6b0b"}, + {file = "websockets-12.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e469d01137942849cff40517c97a30a93ae79917752b34029f0ec72df6b46399"}, + {file = "websockets-12.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffefa1374cd508d633646d51a8e9277763a9b78ae71324183693959cf94635a7"}, + {file = "websockets-12.0-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba0cab91b3956dfa9f512147860783a1829a8d905ee218a9837c18f683239611"}, + {file = "websockets-12.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2cb388a5bfb56df4d9a406783b7f9dbefb888c09b71629351cc6b036e9259370"}, + {file = "websockets-12.0-py3-none-any.whl", hash = "sha256:dc284bbc8d7c78a6c69e0c7325ab46ee5e40bb4d50e494d8131a07ef47500e9e"}, + {file = "websockets-12.0.tar.gz", hash = "sha256:81df9cbcbb6c260de1e007e58c011bfebe2dafc8435107b0537f393dd38c8b1b"}, +] + [[package]] name = "zipp" version = "3.18.1" @@ -965,4 +1643,4 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "p [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "cba504bfe893b9840d504ff77b55431a1a815fa0ea1fc94f8f36b639415135fd" +content-hash = "c49b4b822933f5815e46f39ebc94fdf6e8f077b01cc21544be6548a7ff398430" diff --git a/pyproject.toml b/pyproject.toml index da581467..eceb707d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,9 @@ colorama = "^0.4.6" tenacity = "^8.2.3" pathspec = "^0.12.1" importlib-resources = "^6.1.1" +fastapi = "^0.111.0" +uvicorn = {extras = ["standard"], version = "^0.30.1"} +gunicorn = "^22.0.0" [tool.poetry.scripts] devchat = "devchat._cli.main:main" From ad9fce71e7385f010ebd52a94021115c2e90c964 Mon Sep 17 00:00:00 2001 From: kagami Date: Tue, 9 Jul 2024 16:19:44 +0800 Subject: [PATCH 14/26] Ensure workspace chat dir before using it --- devchat/msg/util.py | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/devchat/msg/util.py b/devchat/msg/util.py index da41b49c..fb9364a4 100644 --- a/devchat/msg/util.py +++ b/devchat/msg/util.py @@ -35,6 +35,31 @@ def mk_meta() -> Tuple[str, str]: return user_str, date_str +def _ensure_workspace_chat_dir(workspace_path: str) -> str: + """ + Ensure the workspace chat directory exists and is ignored by git + + return the chat directory path + """ + assert workspace_path, "workspace path is required to create .chat directory" + chat_dir = os.path.join(workspace_path, ".chat") + + if not os.path.exists(chat_dir): + try: + os.makedirs(chat_dir, exist_ok=True) + except FileExistsError: + pass + + # ignore .chat dir in user's workspace + ignore_file = os.path.join(chat_dir, ".gitignore") + ignore_content = "*\n" + if not os.path.exists(ignore_file): + with open(ignore_file, "w") as f: + f.write(ignore_content) + + return chat_dir + + def get_workspace_chat_dir(workspace_path: Optional[str]) -> str: """ Get the chat directory for a workspace @@ -42,7 +67,7 @@ def get_workspace_chat_dir(workspace_path: Optional[str]) -> str: """ workspace_chat_dir = USER_CHAT_DIR if workspace_path: - workspace_chat_dir = os.path.join(workspace_path, ".chat") + workspace_chat_dir = _ensure_workspace_chat_dir(workspace_path) return workspace_chat_dir From bd355b474cf7eacb78bb0c40c46470001443d205 Mon Sep 17 00:00:00 2001 From: kagami Date: Tue, 9 Jul 2024 17:16:17 +0800 Subject: [PATCH 15/26] Improve error handling for logs apis --- devchat/_service/route/logs.py | 35 ++++++++++++------ devchat/_service/schema/response.py | 4 --- devchat/msg/log_util.py | 56 ++++++++++------------------- 3 files changed, 43 insertions(+), 52 deletions(-) diff --git a/devchat/_service/route/logs.py b/devchat/_service/route/logs.py index 8d71df88..8b23b090 100644 --- a/devchat/_service/route/logs.py +++ b/devchat/_service/route/logs.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter +from fastapi import APIRouter, HTTPException, status from devchat._service.schema import request, response from devchat.msg.log_util import delete_log_prompt, gen_log_prompt, insert_log_prompt @@ -10,20 +10,35 @@ async def insert( item: request.InsertLog, ): - # TODO: handle error - error_msg = None - prompt = gen_log_prompt(item.jsondata, item.filepath) - insert_log_prompt(prompt, item.workspace) - return response.InsertLog(hash=prompt.hash, error=error_msg) + try: + prompt = gen_log_prompt(item.jsondata, item.filepath) + prompt_hash = insert_log_prompt(prompt, item.workspace) + except Exception as e: + detail = f"Failed to insert log: {str(e)}" + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=detail + ) + return response.InsertLog(hash=prompt_hash) @router.post("/delete") async def delete( item: request.DeleteLog, ): - print(f"\n\ncheck delete log request: \n{item}") - success, error_msg = delete_log_prompt(item.hash, item.workspace) - - return response.DeleteLog(success=success, error=error_msg) + try: + success = delete_log_prompt(item.hash, item.workspace) + if not success: + detail = ( + f"Failed to delete log <{item.hash}>. Log not found or is not a leaf." + ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=detail) + + except Exception as e: + detail = f"Failed to delete log <{item.hash}>: {str(e)}" + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=detail + ) + + return response.DeleteLog(success=success) diff --git a/devchat/_service/schema/response.py b/devchat/_service/schema/response.py index c65f228e..b7c78c4d 100644 --- a/devchat/_service/schema/response.py +++ b/devchat/_service/schema/response.py @@ -20,12 +20,10 @@ class MessageCompletionChunk(BaseModel): class InsertLog(BaseModel): hash: Optional[str] = Field(None, description="hash of the inserted data") - error: Optional[str] = Field(None, description="error message") class DeleteLog(BaseModel): success: bool = Field(..., description="success status") - error: Optional[str] = Field(None, description="error message") class ShortLog(BaseModel): @@ -54,8 +52,6 @@ class TopicSummary(BaseModel): class DeleteTopic(BaseModel): topic_hash: str = Field(..., description="hash of the deleted topic") - success: bool = Field(..., description="success status") - error: Optional[str] = Field(None, description="error message") class UpdateWorkflows(BaseModel): diff --git a/devchat/msg/log_util.py b/devchat/msg/log_util.py index 14455a06..84d819d6 100644 --- a/devchat/msg/log_util.py +++ b/devchat/msg/log_util.py @@ -56,33 +56,23 @@ def gen_log_prompt( def insert_log_prompt( prompt: OpenAIPrompt, workspace_path: Optional[str] -) -> Tuple[Optional[str], Optional[str]]: +) -> str: """ Insert a chat record - return: - inserted_hash: insert hash - error: error message if any + return the hash of the inserted chat record (prompt) """ - inserted_hash = None - error_msg = None - - try: - user_chat_dir = USER_CHAT_DIR - workspace_chat_dir = get_workspace_chat_dir(workspace_path) - - model, config = get_model_config(user_chat_dir) - openai_config = OpenAIChatConfig(model=model, **config.dict(exclude_unset=True)) + user_chat_dir = USER_CHAT_DIR + workspace_chat_dir = get_workspace_chat_dir(workspace_path) - chat = OpenAIChat(openai_config) - store = Store(workspace_chat_dir, chat) - _ = store.store_prompt(prompt) + model, config = get_model_config(user_chat_dir) + openai_config = OpenAIChatConfig(model=model, **config.dict(exclude_unset=True)) - inserted_hash = prompt.hash - except Exception as e: - error_msg = str(e) + chat = OpenAIChat(openai_config) + store = Store(workspace_chat_dir, chat) + _ = store.store_prompt(prompt) - return inserted_hash, error_msg + return prompt.hash def delete_log_prompt( @@ -93,26 +83,16 @@ def delete_log_prompt( return: success: True if the prompt is deleted successfully, False otherwise - error: error message if any """ - success = False - error_msg = None - - try: - user_chat_dir = USER_CHAT_DIR - workspace_chat_dir = get_workspace_chat_dir(workspace_path) - - model, config = get_model_config(user_chat_dir) - openai_config = OpenAIChatConfig(model=model, **config.dict(exclude_unset=True)) + user_chat_dir = USER_CHAT_DIR + workspace_chat_dir = get_workspace_chat_dir(workspace_path) - chat = OpenAIChat(openai_config) - store = Store(workspace_chat_dir, chat) + model, config = get_model_config(user_chat_dir) + openai_config = OpenAIChatConfig(model=model, **config.dict(exclude_unset=True)) - success = store.delete_prompt(hash) + chat = OpenAIChat(openai_config) + store = Store(workspace_chat_dir, chat) - if not success: - error_msg = f"Failed to delete prompt {hash}." - except Exception as e: - error_msg = str(e) + success = store.delete_prompt(hash) - return success, error_msg + return success From b9ba215b6be1ea77e6d6c374af35b56f11732c9d Mon Sep 17 00:00:00 2001 From: kagami Date: Tue, 9 Jul 2024 17:39:22 +0800 Subject: [PATCH 16/26] Improve error handling for topics apis --- devchat/_service/route/topics.py | 26 ++++++++++++--------- devchat/msg/topic_util.py | 40 ++++++++++++++++---------------- 2 files changed, 35 insertions(+), 31 deletions(-) diff --git a/devchat/_service/route/topics.py b/devchat/_service/route/topics.py index a03de1df..b8c89300 100644 --- a/devchat/_service/route/topics.py +++ b/devchat/_service/route/topics.py @@ -1,6 +1,6 @@ from typing import List, Optional -from fastapi import APIRouter, Query +from fastapi import APIRouter, Query, HTTPException, status from devchat._service.schema import request, response from devchat.msg.topic_util import delete_topic as del_topic @@ -8,15 +8,17 @@ router = APIRouter() + @router.get("/{topic_root_hash}/logs", response_model=List[response.ShortLog]) async def get_topic_logs( topic_root_hash: str, limit: int = Query(1, gt=0, description="maximum number of records to return"), offset: int = Query(0, ge=0, description="offset of the first record to return"), - workspace: Optional[str] = Query(None, description="absolute path to the workspace/repository"), + workspace: Optional[str] = Query( + None, description="absolute path to the workspace/repository" + ), ): - # TODO: handle error in the http way - records, error = get_topic_shortlogs(topic_root_hash, limit, offset, workspace) + records = get_topic_shortlogs(topic_root_hash, limit, offset, workspace) logs = [response.ShortLog.parse_obj(record) for record in records] return logs @@ -26,7 +28,9 @@ async def get_topic_logs( def list_topics( limit: int = Query(1, gt=0, description="maximum number of records to return"), offset: int = Query(0, ge=0, description="offset of the first record to return"), - workspace: Optional[str] = Query(None, description="absolute path to the workspace/repository"), + workspace: Optional[str] = Query( + None, description="absolute path to the workspace/repository" + ), ): topics = get_topics( limit=limit, @@ -55,13 +59,13 @@ def list_topics( def delete_topic( item: request.DeleteTopic, ): - print(f"check delete topic request: \n{item}") try: del_topic(item.topic_hash, item.workspace) - return response.DeleteTopic(topic_hash=item.topic_hash, success=True) + except Exception as e: - return response.DeleteTopic( - topic_hash=item.topic_hash, - success=False, - error=str(e), + detail = f"Failed to delete topic <{item.topic_hash}>: {str(e)}" + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=detail ) + + return response.DeleteTopic(topic_hash=item.topic_hash) diff --git a/devchat/msg/topic_util.py b/devchat/msg/topic_util.py index bf93bd36..32f17f0f 100644 --- a/devchat/msg/topic_util.py +++ b/devchat/msg/topic_util.py @@ -10,31 +10,27 @@ def get_topic_shortlogs( topic_root_hash: str, limit: int, offset: int, workspace_path: Optional[str] -) -> Tuple[List[Dict], Optional[str]]: +) -> List[Dict]: short_logs = [] - error_msg = None - try: - user_chat_dir = USER_CHAT_DIR - workspace_chat_dir = get_workspace_chat_dir(workspace_path) - model, config = get_model_config(user_chat_dir) - openai_config = OpenAIChatConfig(model=model, **config.dict(exclude_unset=True)) + user_chat_dir = USER_CHAT_DIR + workspace_chat_dir = get_workspace_chat_dir(workspace_path) - chat = OpenAIChat(openai_config) - store = Store(workspace_chat_dir, chat) + model, config = get_model_config(user_chat_dir) + openai_config = OpenAIChatConfig(model=model, **config.dict(exclude_unset=True)) - logs = store.select_prompts(offset, offset + limit, topic_root_hash) - for log in logs: - try: - short_logs.append(log.shortlog()) - except Exception: - # TODO: log the error - continue + chat = OpenAIChat(openai_config) + store = Store(workspace_chat_dir, chat) - except Exception as e: - error_msg = str(e) + logs = store.select_prompts(offset, offset + limit, topic_root_hash) + for log in logs: + try: + short_logs.append(log.shortlog()) + except Exception: + # TODO: log the error + continue - return short_logs, error_msg + return short_logs def get_topics( @@ -54,11 +50,15 @@ def get_topics( topics = store.select_topics(offset, offset + limit) if not with_deleted: + # filter out deleted topics record_file = os.path.join(workspace_chat_dir, ".deletedTopics") if os.path.exists(record_file): with open(record_file, "r") as f: deleted_topics = f.read().split("\n") - topics = [t for t in topics if t["root_prompt"]["hash"] not in deleted_topics] + + topics = [ + t for t in topics if t["root_prompt"]["hash"] not in deleted_topics + ] return topics From 1328fc316b7ef8faeab018e9f821664c5df712ac Mon Sep 17 00:00:00 2001 From: kagami Date: Tue, 9 Jul 2024 17:58:17 +0800 Subject: [PATCH 17/26] Update the route of workflow management --- devchat/_service/route/__init__.py | 4 ++-- devchat/_service/route/{workflow.py => workflows.py} | 1 - devchat/msg/chatting.py | 1 - 3 files changed, 2 insertions(+), 4 deletions(-) rename devchat/_service/route/{workflow.py => workflows.py} (95%) diff --git a/devchat/_service/route/__init__.py b/devchat/_service/route/__init__.py index 7ce87d04..b30c69bf 100644 --- a/devchat/_service/route/__init__.py +++ b/devchat/_service/route/__init__.py @@ -3,7 +3,7 @@ from .logs import router as log_router from .message import router as message_router from .topics import router as topic_router -from .workflow import router as workflow_router +from .workflows import router as workflow_router router = APIRouter() @@ -13,7 +13,7 @@ def ping(): return {"message": "pong"} -router.include_router(workflow_router, prefix="/workflow", tags=["Workflow"]) +router.include_router(workflow_router, prefix="/workflows", tags=["WorkflowManagement"]) router.include_router(message_router, prefix="/message", tags=["Message"]) router.include_router(log_router, prefix="/logs", tags=["LogManagement"]) router.include_router(topic_router, prefix="/topics", tags=["TopicManagement"]) diff --git a/devchat/_service/route/workflow.py b/devchat/_service/route/workflows.py similarity index 95% rename from devchat/_service/route/workflow.py rename to devchat/_service/route/workflows.py index dd529314..427b67af 100644 --- a/devchat/_service/route/workflow.py +++ b/devchat/_service/route/workflows.py @@ -48,7 +48,6 @@ async def get_config(): return JSONResponse(content=config_content) -# TODO: set time out? what if the update takes too long due to user's network? @router.post("/update", response_model=response.UpdateWorkflows) async def update_workflows(): base_path = Path(WORKFLOWS_BASE) diff --git a/devchat/msg/chatting.py b/devchat/msg/chatting.py index a767645a..48beae82 100644 --- a/devchat/msg/chatting.py +++ b/devchat/msg/chatting.py @@ -56,5 +56,4 @@ def chatting( ) for res in assistant.iterate_response(): - # print(res, end="", flush=True) yield res From e1c61e489d82fe4deceb09a81cbbefafec94549d Mon Sep 17 00:00:00 2001 From: kagami Date: Thu, 11 Jul 2024 10:54:13 +0800 Subject: [PATCH 18/26] Add the missing response_model annotation --- devchat/_service/route/logs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/devchat/_service/route/logs.py b/devchat/_service/route/logs.py index 8b23b090..8e7e2001 100644 --- a/devchat/_service/route/logs.py +++ b/devchat/_service/route/logs.py @@ -22,7 +22,7 @@ async def insert( return response.InsertLog(hash=prompt_hash) -@router.post("/delete") +@router.post("/delete", response_model=response.DeleteLog) async def delete( item: request.DeleteLog, ): From c42ee1a9a74ab2234a5ecb46d1459f0b73464047 Mon Sep 17 00:00:00 2001 From: kagami Date: Thu, 11 Jul 2024 11:11:30 +0800 Subject: [PATCH 19/26] Fix linter issues --- devchat/_service/route/logs.py | 14 +++----------- devchat/_service/route/topics.py | 14 ++++---------- devchat/_service/route/workflows.py | 1 + devchat/msg/log_util.py | 16 ++++------------ devchat/msg/topic_util.py | 6 ++---- devchat/msg/util.py | 3 +-- devchat/workflow/command/list.py | 9 ++------- devchat/workflow/command/update.py | 18 ++++++------------ devchat/workflow/namespace.py | 15 +++++---------- devchat/workflow/update_util.py | 9 ++------- 10 files changed, 30 insertions(+), 75 deletions(-) diff --git a/devchat/_service/route/logs.py b/devchat/_service/route/logs.py index 8e7e2001..d08f53a2 100644 --- a/devchat/_service/route/logs.py +++ b/devchat/_service/route/logs.py @@ -10,15 +10,12 @@ async def insert( item: request.InsertLog, ): - try: prompt = gen_log_prompt(item.jsondata, item.filepath) prompt_hash = insert_log_prompt(prompt, item.workspace) except Exception as e: detail = f"Failed to insert log: {str(e)}" - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=detail - ) + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=detail) return response.InsertLog(hash=prompt_hash) @@ -26,19 +23,14 @@ async def insert( async def delete( item: request.DeleteLog, ): - try: success = delete_log_prompt(item.hash, item.workspace) if not success: - detail = ( - f"Failed to delete log <{item.hash}>. Log not found or is not a leaf." - ) + detail = f"Failed to delete log <{item.hash}>. Log not found or is not a leaf." raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=detail) except Exception as e: detail = f"Failed to delete log <{item.hash}>: {str(e)}" - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=detail - ) + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=detail) return response.DeleteLog(success=success) diff --git a/devchat/_service/route/topics.py b/devchat/_service/route/topics.py index b8c89300..65e66050 100644 --- a/devchat/_service/route/topics.py +++ b/devchat/_service/route/topics.py @@ -1,6 +1,6 @@ from typing import List, Optional -from fastapi import APIRouter, Query, HTTPException, status +from fastapi import APIRouter, HTTPException, Query, status from devchat._service.schema import request, response from devchat.msg.topic_util import delete_topic as del_topic @@ -14,9 +14,7 @@ async def get_topic_logs( topic_root_hash: str, limit: int = Query(1, gt=0, description="maximum number of records to return"), offset: int = Query(0, ge=0, description="offset of the first record to return"), - workspace: Optional[str] = Query( - None, description="absolute path to the workspace/repository" - ), + workspace: Optional[str] = Query(None, description="absolute path to the workspace/repository"), ): records = get_topic_shortlogs(topic_root_hash, limit, offset, workspace) @@ -28,9 +26,7 @@ async def get_topic_logs( def list_topics( limit: int = Query(1, gt=0, description="maximum number of records to return"), offset: int = Query(0, ge=0, description="offset of the first record to return"), - workspace: Optional[str] = Query( - None, description="absolute path to the workspace/repository" - ), + workspace: Optional[str] = Query(None, description="absolute path to the workspace/repository"), ): topics = get_topics( limit=limit, @@ -64,8 +60,6 @@ def delete_topic( except Exception as e: detail = f"Failed to delete topic <{item.topic_hash}>: {str(e)}" - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=detail - ) + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=detail) return response.DeleteTopic(topic_hash=item.topic_hash) diff --git a/devchat/_service/route/workflows.py b/devchat/_service/route/workflows.py index 427b67af..73d842c0 100644 --- a/devchat/_service/route/workflows.py +++ b/devchat/_service/route/workflows.py @@ -24,6 +24,7 @@ router = APIRouter() + @router.get("/list", response_model=List[WorkflowMeta]) async def list_workflow(): namespace_paths = get_prioritized_namespace_path() diff --git a/devchat/msg/log_util.py b/devchat/msg/log_util.py index 84d819d6..509f77d7 100644 --- a/devchat/msg/log_util.py +++ b/devchat/msg/log_util.py @@ -22,15 +22,11 @@ class PromptData: response_tokens: int = 0 -def gen_log_prompt( - jsondata: Optional[str] = None, filepath: Optional[str] = None -) -> OpenAIPrompt: +def gen_log_prompt(jsondata: Optional[str] = None, filepath: Optional[str] = None) -> OpenAIPrompt: """ Generate a hash for a chat record """ - assert ( - jsondata is not None or filepath is not None - ), "Either jsondata or filepath is required." + assert jsondata is not None or filepath is not None, "Either jsondata or filepath is required." if jsondata is None: with open(filepath, "r", encoding="utf-8") as f: @@ -54,9 +50,7 @@ def gen_log_prompt( return prompt -def insert_log_prompt( - prompt: OpenAIPrompt, workspace_path: Optional[str] -) -> str: +def insert_log_prompt(prompt: OpenAIPrompt, workspace_path: Optional[str]) -> str: """ Insert a chat record @@ -75,9 +69,7 @@ def insert_log_prompt( return prompt.hash -def delete_log_prompt( - hash: str, workspace_path: Optional[str] -) -> Tuple[bool, Optional[str]]: +def delete_log_prompt(hash: str, workspace_path: Optional[str]) -> Tuple[bool, Optional[str]]: """ Delete a chat record diff --git a/devchat/msg/topic_util.py b/devchat/msg/topic_util.py index 32f17f0f..6f8c7e9e 100644 --- a/devchat/msg/topic_util.py +++ b/devchat/msg/topic_util.py @@ -1,5 +1,5 @@ import os -from typing import Dict, List, Optional, Tuple +from typing import Dict, List, Optional from devchat._cli.utils import get_model_config from devchat.openai.openai_chat import OpenAIChat, OpenAIChatConfig @@ -56,9 +56,7 @@ def get_topics( with open(record_file, "r") as f: deleted_topics = f.read().split("\n") - topics = [ - t for t in topics if t["root_prompt"]["hash"] not in deleted_topics - ] + topics = [t for t in topics if t["root_prompt"]["hash"] not in deleted_topics] return topics diff --git a/devchat/msg/util.py b/devchat/msg/util.py index fb9364a4..2e3240cb 100644 --- a/devchat/msg/util.py +++ b/devchat/msg/util.py @@ -7,7 +7,6 @@ from devchat.workflow.workflow import Workflow from .path import USER_CHAT_DIR -from .schema import MessageRequest from .user_info import user_info @@ -85,7 +84,7 @@ def route_message_by_content(message_content: str) -> Tuple[MessageType, Any]: workflow = Workflow.load(wf_name) if wf_name else None if workflow: - # TODO: the message should be handled by the workflow engine + # the message should be handled by the workflow engine return MessageType.WORKFLOW, (workflow, wf_name, wf_input) else: diff --git a/devchat/workflow/command/list.py b/devchat/workflow/command/list.py index 7c802af0..5232c889 100644 --- a/devchat/workflow/command/list.py +++ b/devchat/workflow/command/list.py @@ -1,18 +1,13 @@ import json -from dataclasses import asdict, dataclass, field -from pathlib import Path -from typing import Dict, List, Set, Tuple +from typing import List import click -import oyaml as yaml -import yaml as pyyaml - from devchat.utils import get_logger from devchat.workflow.namespace import ( + WorkflowMeta, get_prioritized_namespace_path, iter_namespace, - WorkflowMeta, ) logger = get_logger(__name__) diff --git a/devchat/workflow/command/update.py b/devchat/workflow/command/update.py index 3bd22615..6bac4f77 100644 --- a/devchat/workflow/command/update.py +++ b/devchat/workflow/command/update.py @@ -1,26 +1,20 @@ -import os -import shutil -import tempfile -import zipfile -from datetime import datetime from pathlib import Path -from typing import List, Optional, Tuple import click + from devchat.workflow.path import ( WORKFLOWS_BASE, ) from devchat.workflow.update_util import ( - update_by_git, - update_by_zip, HAS_GIT, copy_workflows_usr, + update_by_git, + update_by_zip, ) + @click.command(help="Update the workflow_base dir.") -@click.option( - "-f", "--force", is_flag=True, help="Force update the workflows to the latest main." -) +@click.option("-f", "--force", is_flag=True, help="Force update the workflows to the latest main.") def update(force: bool): click.echo(f"Updating wf repo... force: {force}") click.echo(f"WORKFLOWS_BASE: {WORKFLOWS_BASE}") @@ -31,6 +25,6 @@ def update(force: bool): updated, message = update_by_git(base_path) else: updated, message = update_by_zip(base_path) - + click.echo(f"- Updated: {updated}\n- Message: {message}") copy_workflows_usr() diff --git a/devchat/workflow/namespace.py b/devchat/workflow/namespace.py index 6d9d4ba9..9b371d1c 100644 --- a/devchat/workflow/namespace.py +++ b/devchat/workflow/namespace.py @@ -3,22 +3,21 @@ """ import os -from typing import List, Set, Tuple, Dict from pathlib import Path +from typing import Dict, List, Set, Tuple import oyaml as yaml import yaml as pyyaml - -from pydantic import BaseModel, Extra, ValidationError, Field +from pydantic import BaseModel, Extra, Field, ValidationError from devchat.utils import get_logger from .path import ( + COMMAND_FILENAMES, COMMUNITY_WORKFLOWS, CUSTOM_BASE, CUSTOM_CONFIG_FILE, MERICO_WORKFLOWS, - COMMAND_FILENAMES, ) logger = get_logger(__name__) @@ -35,9 +34,7 @@ class WorkflowMeta(BaseModel): name: str = Field(..., description="workflow name") namespace: str = Field(..., description="workflow namespace") active: bool = Field(..., description="active flag") - command_conf: Dict = Field( - description="command configuration", default_factory=dict - ) + command_conf: Dict = Field(description="command configuration", default_factory=dict) def __str__(self): return f"{'*' if self.active else ' '} {self.name} ({self.namespace})" @@ -82,9 +79,7 @@ def get_prioritized_namespace_path() -> List[str]: return namespace_paths -def iter_namespace( - ns_path: str, existing_names: Set[str] -) -> Tuple[List[WorkflowMeta], Set[str]]: +def iter_namespace(ns_path: str, existing_names: Set[str]) -> Tuple[List[WorkflowMeta], Set[str]]: """ Get all workflows under the namespace path. diff --git a/devchat/workflow/update_util.py b/devchat/workflow/update_util.py index d9c661a9..3082abb0 100644 --- a/devchat/workflow/update_util.py +++ b/devchat/workflow/update_util.py @@ -12,7 +12,6 @@ from devchat.workflow.path import ( CHAT_DIR, CUSTOM_BASE, - WORKFLOWS_BASE, WORKFLOWS_BASE_NAME, ) @@ -261,9 +260,7 @@ def update_by_git(workflow_base: Path) -> Tuple[bool, str]: remote_main_hash = repo.commit(f"origin/{DEFAULT_BRANCH}").hexsha if local_main_hash == remote_main_hash: - msg = ( - f"Local branch is up-to-date with remote {DEFAULT_BRANCH}. Skip update." - ) + msg = f"Local branch is up-to-date with remote {DEFAULT_BRANCH}. Skip update." logger.info(msg) return False, msg @@ -305,6 +302,4 @@ def copy_workflows_usr(): shutil.copytree(old_usr_dir, new_usr_dir) logger.info(f"Copied {old_usr_dir} to {new_usr_dir} successfully.") else: - logger.info( - f"Skip copying usr dir. old exists: {old_exists}, new exists: {new_exists}." - ) + logger.info(f"Skip copying usr dir. old exists: {old_exists}, new exists: {new_exists}.") From c52424c80745002dd4843029e5c5e2a6b8a0a951 Mon Sep 17 00:00:00 2001 From: kagami Date: Thu, 11 Jul 2024 13:36:31 +0800 Subject: [PATCH 20/26] Improve error handling for chatting --- devchat/_service/route/message.py | 29 +++++++++++++++++++---------- devchat/assistant.py | 2 +- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/devchat/_service/route/message.py b/devchat/_service/route/message.py index 859896f0..accdc206 100644 --- a/devchat/_service/route/message.py +++ b/devchat/_service/route/message.py @@ -24,24 +24,33 @@ async def msg( user_str, date_str = mk_meta() message_type, extra = route_message_by_content(message.content) - print(f"message type: {message_type}") if message_type == MessageType.CHATTING: def gen_chat_response() -> Iterator[response.MessageCompletionChunk]: - for res in chatting( - content=message.content, - model_name=message.model_name, - parent=message.parent, - workspace=message.workspace, - context_files=message.context, - ): + try: + for res in chatting( + content=message.content, + model_name=message.model_name, + parent=message.parent, + workspace=message.workspace, + context_files=message.context, + ): + chunk = response.MessageCompletionChunk( + user=user_str, + date=date_str, + content=res, + ) + yield chunk.json() + except Exception as e: chunk = response.MessageCompletionChunk( user=user_str, date=date_str, - content=res, + content=str(e), + isError=True, ) yield chunk.json() + raise e return StreamingResponse(gen_chat_response(), media_type="application/json") @@ -78,7 +87,7 @@ def _gen_res_run_workflow() -> Iterator[response.MessageCompletionChunk]: ) else: - # TODO: Should not reach here + # Should not reach here chunk = response.MessageCompletionChunk( user=user_str, date=date_str, diff --git a/devchat/assistant.py b/devchat/assistant.py index fd76f2c8..df4092c9 100755 --- a/devchat/assistant.py +++ b/devchat/assistant.py @@ -139,7 +139,7 @@ def iterate_response(self) -> Iterator[str]: except Exception as err: print("receive:", chunk, file=sys.stderr, end="\n\n") logger.error("Error while iterating response: %s, %s", err, str(chunk)) - raise err + raise RuntimeError(f"Error while iterating response, {err}, {str(chunk)}") if not self._prompt.responses: raise RuntimeError("No responses returned from the chat API") if self._need_store: From adc96eee018ec0b4a5201394b16acf4fe0312cbc Mon Sep 17 00:00:00 2001 From: kagami Date: Thu, 11 Jul 2024 10:37:21 +0800 Subject: [PATCH 21/26] Unify logging settings --- devchat/_service/logger_util.py | 64 +++++++++++++++++++++++++++++++++ devchat/_service/main.py | 47 ++++++++++++++++++++++++ poetry.lock | 34 +++++++++++++++++- pyproject.toml | 1 + 4 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 devchat/_service/logger_util.py diff --git a/devchat/_service/logger_util.py b/devchat/_service/logger_util.py new file mode 100644 index 00000000..1aad1552 --- /dev/null +++ b/devchat/_service/logger_util.py @@ -0,0 +1,64 @@ +import os +import logging +import sys + +from gunicorn.app.base import BaseApplication +from gunicorn.glogging import Logger +from loguru import logger + +LOG_LEVEL = logging.getLevelName(os.environ.get("LOG_LEVEL", "DEBUG")) +JSON_LOGS = True if os.environ.get("JSON_LOGS", "0") == "1" else False +WORKERS = int(os.environ.get("GUNICORN_WORKERS", "5")) + + +class InterceptHandler(logging.Handler): + def emit(self, record): + # get corresponding Loguru level if it exists + try: + level = logger.level(record.levelname).name + except ValueError: + level = record.levelno + + # find caller from where originated the logged message + frame, depth = sys._getframe(6), 6 + while frame and frame.f_code.co_filename == logging.__file__: + frame = frame.f_back + depth += 1 + + logger.opt(depth=depth, exception=record.exc_info).log( + level, record.getMessage() + ) + + +class StubbedGunicornLogger(Logger): + def setup(self, cfg): + handler = logging.NullHandler() + self.error_logger = logging.getLogger("gunicorn.error") + self.error_logger.addHandler(handler) + self.access_logger = logging.getLogger("gunicorn.access") + self.access_logger.addHandler(handler) + self.error_logger.setLevel(LOG_LEVEL) + self.access_logger.setLevel(LOG_LEVEL) + + +class StandaloneApplication(BaseApplication): + """Our Gunicorn application.""" + + def __init__(self, app, options=None): + self.options = options or {} + self.application = app + super().__init__() + + def load_config(self): + config = { + key: value + for key, value in self.options.items() + if key in self.cfg.settings and value is not None + } + for key, value in config.items(): + self.cfg.set(key.lower(), value) + + def load(self): + return self.application + + diff --git a/devchat/_service/main.py b/devchat/_service/main.py index 41f73e0f..397fc2d1 100644 --- a/devchat/_service/main.py +++ b/devchat/_service/main.py @@ -2,6 +2,19 @@ from devchat._service.route import router +import logging +import sys +from loguru import logger + +from devchat._service.logger_util import ( + InterceptHandler, + JSON_LOGS, + LOG_LEVEL, + WORKERS, + StandaloneApplication, + StubbedGunicornLogger, +) + api_app = FastAPI( title="DevChat Local Service", ) @@ -15,3 +28,37 @@ # https://www.reddit.com/r/FastAPI/comments/170awhx/mount_socketio_to_fastapi/ # https://github.com/miguelgrinberg/python-socketio/blob/main/examples/server/asgi/fastapi-fiddle.py + + +if __name__ == "__main__": + intercept_handler = InterceptHandler() + # logging.basicConfig(handlers=[intercept_handler], level=LOG_LEVEL) + # logging.root.handlers = [intercept_handler] + logging.root.setLevel(LOG_LEVEL) + + seen = set() + for name in [ + *logging.root.manager.loggerDict.keys(), + "gunicorn", + "gunicorn.access", + "gunicorn.error", + "uvicorn", + "uvicorn.access", + "uvicorn.error", + ]: + if name not in seen: + seen.add(name.split(".")[0]) + logging.getLogger(name).handlers = [intercept_handler] + + logger.configure(handlers=[{"sink": sys.stdout, "serialize": JSON_LOGS}]) + + options = { + "bind": "0.0.0.0:22222", + "workers": WORKERS, + "accesslog": "-", + "errorlog": "-", + "worker_class": "uvicorn.workers.UvicornWorker", + "logger_class": StubbedGunicornLogger, + } + + StandaloneApplication(api_app, options).run() diff --git a/poetry.lock b/poetry.lock index 947e2494..091bed14 100644 --- a/poetry.lock +++ b/poetry.lock @@ -493,6 +493,24 @@ MarkupSafe = ">=2.0" [package.extras] i18n = ["Babel (>=2.7)"] +[[package]] +name = "loguru" +version = "0.7.2" +description = "Python logging made (stupidly) simple" +optional = false +python-versions = ">=3.5" +files = [ + {file = "loguru-0.7.2-py3-none-any.whl", hash = "sha256:003d71e3d3ed35f0f8984898359d65b79e5b21943f78af86aa5491210429b8eb"}, + {file = "loguru-0.7.2.tar.gz", hash = "sha256:e671a53522515f34fd406340ee968cb9ecafbc4b36c679da03c18fd8d0bd51ac"}, +] + +[package.dependencies] +colorama = {version = ">=0.3.4", markers = "sys_platform == \"win32\""} +win32-setctime = {version = ">=1.0.0", markers = "sys_platform == \"win32\""} + +[package.extras] +dev = ["Sphinx (==7.2.5)", "colorama (==0.4.5)", "colorama (==0.4.6)", "exceptiongroup (==1.1.3)", "freezegun (==1.1.0)", "freezegun (==1.2.2)", "mypy (==v0.910)", "mypy (==v0.971)", "mypy (==v1.4.1)", "mypy (==v1.5.1)", "pre-commit (==3.4.0)", "pytest (==6.1.2)", "pytest (==7.4.0)", "pytest-cov (==2.12.1)", "pytest-cov (==4.1.0)", "pytest-mypy-plugins (==1.9.3)", "pytest-mypy-plugins (==3.0.0)", "sphinx-autobuild (==2021.3.14)", "sphinx-rtd-theme (==1.3.0)", "tox (==3.27.1)", "tox (==4.11.0)"] + [[package]] name = "markdown-it-py" version = "3.0.0" @@ -1625,6 +1643,20 @@ files = [ {file = "websockets-12.0.tar.gz", hash = "sha256:81df9cbcbb6c260de1e007e58c011bfebe2dafc8435107b0537f393dd38c8b1b"}, ] +[[package]] +name = "win32-setctime" +version = "1.1.0" +description = "A small Python utility to set file creation time on Windows" +optional = false +python-versions = ">=3.5" +files = [ + {file = "win32_setctime-1.1.0-py3-none-any.whl", hash = "sha256:231db239e959c2fe7eb1d7dc129f11172354f98361c4fa2d6d2d7e278baa8aad"}, + {file = "win32_setctime-1.1.0.tar.gz", hash = "sha256:15cf5750465118d6929ae4de4eb46e8edae9a5634350c01ba582df868e932cb2"}, +] + +[package.extras] +dev = ["black (>=19.3b0)", "pytest (>=4.6.2)"] + [[package]] name = "zipp" version = "3.18.1" @@ -1643,4 +1675,4 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "p [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "c49b4b822933f5815e46f39ebc94fdf6e8f077b01cc21544be6548a7ff398430" +content-hash = "dd2bf3c1c5d6e8b7a84edad42b60b1b8daff44318ca80c84bd477962457bd5b1" diff --git a/pyproject.toml b/pyproject.toml index eceb707d..50bcd94e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,7 @@ importlib-resources = "^6.1.1" fastapi = "^0.111.0" uvicorn = {extras = ["standard"], version = "^0.30.1"} gunicorn = "^22.0.0" +loguru = "^0.7.2" [tool.poetry.scripts] devchat = "devchat._cli.main:main" From 43d8f95ba88c133f0ec4a6a84bd841c03a21910d Mon Sep 17 00:00:00 2001 From: kagami Date: Thu, 11 Jul 2024 16:00:49 +0800 Subject: [PATCH 22/26] Extract workspace util --- devchat/msg/chatting.py | 5 ++--- devchat/msg/log_util.py | 2 +- devchat/msg/topic_util.py | 3 +-- devchat/msg/util.py | 41 +-------------------------------------- devchat/{msg => }/path.py | 0 devchat/workspace_util.py | 41 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 46 insertions(+), 46 deletions(-) rename devchat/{msg => }/path.py (100%) create mode 100644 devchat/workspace_util.py diff --git a/devchat/msg/chatting.py b/devchat/msg/chatting.py index 48beae82..3b2a971a 100644 --- a/devchat/msg/chatting.py +++ b/devchat/msg/chatting.py @@ -4,11 +4,10 @@ from devchat._cli.utils import get_model_config from devchat.assistant import Assistant from devchat.openai.openai_chat import OpenAIChat, OpenAIChatConfig +from devchat.path import USER_CHAT_DIR from devchat.store import Store from devchat.utils import parse_files - -from .path import USER_CHAT_DIR -from .util import get_workspace_chat_dir +from devchat.workspace_util import get_workspace_chat_dir def _get_model_and_config(model: Optional[str], config_str: Optional[str]): diff --git a/devchat/msg/log_util.py b/devchat/msg/log_util.py index 509f77d7..e3e1625c 100644 --- a/devchat/msg/log_util.py +++ b/devchat/msg/log_util.py @@ -6,9 +6,9 @@ from devchat._cli.utils import get_model_config from devchat.openai.openai_chat import OpenAIChat, OpenAIChatConfig, OpenAIPrompt from devchat.store import Store +from devchat.workspace_util import USER_CHAT_DIR, get_workspace_chat_dir from .user_info import user_info -from .util import USER_CHAT_DIR, get_workspace_chat_dir @dataclass diff --git a/devchat/msg/topic_util.py b/devchat/msg/topic_util.py index 6f8c7e9e..89609568 100644 --- a/devchat/msg/topic_util.py +++ b/devchat/msg/topic_util.py @@ -4,8 +4,7 @@ from devchat._cli.utils import get_model_config from devchat.openai.openai_chat import OpenAIChat, OpenAIChatConfig from devchat.store import Store - -from .util import USER_CHAT_DIR, get_workspace_chat_dir +from devchat.workspace_util import USER_CHAT_DIR, get_workspace_chat_dir def get_topic_shortlogs( diff --git a/devchat/msg/util.py b/devchat/msg/util.py index 2e3240cb..1e349b82 100644 --- a/devchat/msg/util.py +++ b/devchat/msg/util.py @@ -1,12 +1,10 @@ -import os from datetime import datetime from enum import Enum -from typing import Any, Optional, Tuple +from typing import Any, Tuple from devchat.utils import unix_to_local_datetime, user_id from devchat.workflow.workflow import Workflow -from .path import USER_CHAT_DIR from .user_info import user_info @@ -34,43 +32,6 @@ def mk_meta() -> Tuple[str, str]: return user_str, date_str -def _ensure_workspace_chat_dir(workspace_path: str) -> str: - """ - Ensure the workspace chat directory exists and is ignored by git - - return the chat directory path - """ - assert workspace_path, "workspace path is required to create .chat directory" - chat_dir = os.path.join(workspace_path, ".chat") - - if not os.path.exists(chat_dir): - try: - os.makedirs(chat_dir, exist_ok=True) - except FileExistsError: - pass - - # ignore .chat dir in user's workspace - ignore_file = os.path.join(chat_dir, ".gitignore") - ignore_content = "*\n" - if not os.path.exists(ignore_file): - with open(ignore_file, "w") as f: - f.write(ignore_content) - - return chat_dir - - -def get_workspace_chat_dir(workspace_path: Optional[str]) -> str: - """ - Get the chat directory for a workspace - Return user chat directory if workspace is None - """ - workspace_chat_dir = USER_CHAT_DIR - if workspace_path: - workspace_chat_dir = _ensure_workspace_chat_dir(workspace_path) - - return workspace_chat_dir - - def route_message_by_content(message_content: str) -> Tuple[MessageType, Any]: """ Route the message to the correct handler diff --git a/devchat/msg/path.py b/devchat/path.py similarity index 100% rename from devchat/msg/path.py rename to devchat/path.py diff --git a/devchat/workspace_util.py b/devchat/workspace_util.py new file mode 100644 index 00000000..302de3b7 --- /dev/null +++ b/devchat/workspace_util.py @@ -0,0 +1,41 @@ +import os +from typing import Optional + +from .path import USER_CHAT_DIR + + +def _ensure_workspace_chat_dir(workspace_path: str) -> str: + """ + Ensure the workspace chat directory exists and is ignored by git + + return the chat directory path + """ + assert workspace_path, "workspace path is required to create .chat directory" + chat_dir = os.path.join(workspace_path, ".chat") + + if not os.path.exists(chat_dir): + try: + os.makedirs(chat_dir, exist_ok=True) + except FileExistsError: + pass + + # ignore .chat dir in user's workspace + ignore_file = os.path.join(chat_dir, ".gitignore") + ignore_content = "*\n" + if not os.path.exists(ignore_file): + with open(ignore_file, "w") as f: + f.write(ignore_content) + + return chat_dir + + +def get_workspace_chat_dir(workspace_path: Optional[str]) -> str: + """ + Get the chat directory for a workspace + Return user chat directory if workspace is None + """ + workspace_chat_dir = USER_CHAT_DIR + if workspace_path: + workspace_chat_dir = _ensure_workspace_chat_dir(workspace_path) + + return workspace_chat_dir From 280405736a416319de662384ab4f27518552cee2 Mon Sep 17 00:00:00 2001 From: kagami Date: Thu, 11 Jul 2024 16:04:12 +0800 Subject: [PATCH 23/26] Config service app from env vars --- Makefile | 4 +- devchat/_service/config.py | 19 ++++++++ .../{logger_util.py => custom_logging.py} | 14 +++--- devchat/_service/main.py | 43 +++++++++++++------ devchat/msg/util.py | 1 - 5 files changed, 56 insertions(+), 25 deletions(-) create mode 100644 devchat/_service/config.py rename devchat/_service/{logger_util.py => custom_logging.py} (78%) diff --git a/Makefile b/Makefile index 8c455798..733f885d 100644 --- a/Makefile +++ b/Makefile @@ -22,5 +22,5 @@ run-dev-svc: @uvicorn devchat._service.main:api_app --reload --port 22222 run-svc: - @echo "Running service on port 22222..." - @gunicorn devchat._service.main:api_app --workers 4 --worker-class uvicorn.workers.UvicornWorker --bind 0.0.0.0:22222 + @echo "Running service..." + @python devchat/_service/main.py diff --git a/devchat/_service/config.py b/devchat/_service/config.py new file mode 100644 index 00000000..bec6da2e --- /dev/null +++ b/devchat/_service/config.py @@ -0,0 +1,19 @@ +from typing import Optional + +from pydantic import BaseSettings + + +class Settings(BaseSettings): + PORT: int = 22222 + WORKERS: int = 2 + WORKSPACE: Optional[str] = None + LOG_LEVEL: str = "INFO" + LOG_FILE: Optional[str] = "dc_svc.log" + JSON_LOGS: bool = False + + class Config: + env_prefix = "DC_SVC_" + case_sensitive = True + + +config = Settings() diff --git a/devchat/_service/logger_util.py b/devchat/_service/custom_logging.py similarity index 78% rename from devchat/_service/logger_util.py rename to devchat/_service/custom_logging.py index 1aad1552..4c78e277 100644 --- a/devchat/_service/logger_util.py +++ b/devchat/_service/custom_logging.py @@ -1,4 +1,3 @@ -import os import logging import sys @@ -6,9 +5,7 @@ from gunicorn.glogging import Logger from loguru import logger -LOG_LEVEL = logging.getLevelName(os.environ.get("LOG_LEVEL", "DEBUG")) -JSON_LOGS = True if os.environ.get("JSON_LOGS", "0") == "1" else False -WORKERS = int(os.environ.get("GUNICORN_WORKERS", "5")) +from devchat._service.config import config class InterceptHandler(logging.Handler): @@ -25,9 +22,7 @@ def emit(self, record): frame = frame.f_back depth += 1 - logger.opt(depth=depth, exception=record.exc_info).log( - level, record.getMessage() - ) + logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage()) class StubbedGunicornLogger(Logger): @@ -37,8 +32,8 @@ def setup(self, cfg): self.error_logger.addHandler(handler) self.access_logger = logging.getLogger("gunicorn.access") self.access_logger.addHandler(handler) - self.error_logger.setLevel(LOG_LEVEL) - self.access_logger.setLevel(LOG_LEVEL) + self.error_logger.setLevel(config.LOG_LEVEL) + self.access_logger.setLevel(config.LOG_LEVEL) class StandaloneApplication(BaseApplication): @@ -62,3 +57,4 @@ def load(self): return self.application +# https://pawamoy.github.io/posts/unify-logging-for-a-gunicorn-uvicorn-app/ diff --git a/devchat/_service/main.py b/devchat/_service/main.py index 397fc2d1..5f148bc7 100644 --- a/devchat/_service/main.py +++ b/devchat/_service/main.py @@ -1,19 +1,18 @@ -from fastapi import FastAPI - -from devchat._service.route import router - import logging +import os import sys + +from fastapi import FastAPI from loguru import logger -from devchat._service.logger_util import ( +from devchat._service.config import config +from devchat._service.custom_logging import ( InterceptHandler, - JSON_LOGS, - LOG_LEVEL, - WORKERS, StandaloneApplication, StubbedGunicornLogger, ) +from devchat._service.route import router +from devchat.workspace_util import get_workspace_chat_dir api_app = FastAPI( title="DevChat Local Service", @@ -30,11 +29,11 @@ # https://github.com/miguelgrinberg/python-socketio/blob/main/examples/server/asgi/fastapi-fiddle.py -if __name__ == "__main__": +def main(): intercept_handler = InterceptHandler() # logging.basicConfig(handlers=[intercept_handler], level=LOG_LEVEL) # logging.root.handlers = [intercept_handler] - logging.root.setLevel(LOG_LEVEL) + logging.root.setLevel(config.LOG_LEVEL) seen = set() for name in [ @@ -50,11 +49,25 @@ seen.add(name.split(".")[0]) logging.getLogger(name).handlers = [intercept_handler] - logger.configure(handlers=[{"sink": sys.stdout, "serialize": JSON_LOGS}]) + workspace_chat_dir = get_workspace_chat_dir(config.WORKSPACE) + log_file = os.path.join(workspace_chat_dir, config.LOG_FILE) + + logger.configure( + handlers=[ + {"sink": sys.stdout, "serialize": config.JSON_LOGS}, + { + "sink": log_file, + "serialize": config.JSON_LOGS, + "rotation": "10 days", + "retention": "30 days", + "enqueue": True, + }, + ] + ) options = { - "bind": "0.0.0.0:22222", - "workers": WORKERS, + "bind": f"0.0.0.0:{config.PORT}", + "workers": config.WORKERS, "accesslog": "-", "errorlog": "-", "worker_class": "uvicorn.workers.UvicornWorker", @@ -62,3 +75,7 @@ } StandaloneApplication(api_app, options).run() + + +if __name__ == "__main__": + main() diff --git a/devchat/msg/util.py b/devchat/msg/util.py index 1e349b82..60942b47 100644 --- a/devchat/msg/util.py +++ b/devchat/msg/util.py @@ -39,7 +39,6 @@ def route_message_by_content(message_content: str) -> Tuple[MessageType, Any]: 2. chat with LLM directly """ content = message_content - print(f"check content: {content}") wf_name, wf_input = Workflow.parse_trigger(content) workflow = Workflow.load(wf_name) if wf_name else None From 3d8bae80248484cb32c59bddcce67875351934fb Mon Sep 17 00:00:00 2001 From: kagami Date: Mon, 15 Jul 2024 09:53:47 +0800 Subject: [PATCH 24/26] Adjust path function types --- devchat/_service/route/__init__.py | 2 +- devchat/_service/route/logs.py | 4 ++-- devchat/_service/route/message.py | 2 +- devchat/_service/route/topics.py | 2 +- devchat/_service/route/workflows.py | 6 +++--- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/devchat/_service/route/__init__.py b/devchat/_service/route/__init__.py index b30c69bf..2b2f2d3c 100644 --- a/devchat/_service/route/__init__.py +++ b/devchat/_service/route/__init__.py @@ -9,7 +9,7 @@ @router.get("/ping") -def ping(): +async def ping(): return {"message": "pong"} diff --git a/devchat/_service/route/logs.py b/devchat/_service/route/logs.py index d08f53a2..aff80af7 100644 --- a/devchat/_service/route/logs.py +++ b/devchat/_service/route/logs.py @@ -7,7 +7,7 @@ @router.post("/insert", response_model=response.InsertLog) -async def insert( +def insert( item: request.InsertLog, ): try: @@ -20,7 +20,7 @@ async def insert( @router.post("/delete", response_model=response.DeleteLog) -async def delete( +def delete( item: request.DeleteLog, ): try: diff --git a/devchat/_service/route/message.py b/devchat/_service/route/message.py index accdc206..69533d22 100644 --- a/devchat/_service/route/message.py +++ b/devchat/_service/route/message.py @@ -13,7 +13,7 @@ @router.post("/msg") -async def msg( +def msg( message: request.UserMessage, ): if message.api_key: diff --git a/devchat/_service/route/topics.py b/devchat/_service/route/topics.py index 65e66050..2fd32ec1 100644 --- a/devchat/_service/route/topics.py +++ b/devchat/_service/route/topics.py @@ -10,7 +10,7 @@ @router.get("/{topic_root_hash}/logs", response_model=List[response.ShortLog]) -async def get_topic_logs( +def get_topic_logs( topic_root_hash: str, limit: int = Query(1, gt=0, description="maximum number of records to return"), offset: int = Query(0, ge=0, description="offset of the first record to return"), diff --git a/devchat/_service/route/workflows.py b/devchat/_service/route/workflows.py index 73d842c0..fa67a1dd 100644 --- a/devchat/_service/route/workflows.py +++ b/devchat/_service/route/workflows.py @@ -26,7 +26,7 @@ @router.get("/list", response_model=List[WorkflowMeta]) -async def list_workflow(): +def list_workflow(): namespace_paths = get_prioritized_namespace_path() workflows: List[WorkflowMeta] = [] @@ -39,7 +39,7 @@ async def list_workflow(): @router.get("/config") -async def get_config(): +def get_config(): config_path = Path(WORKFLOWS_BASE) / WORKFLOWS_CONFIG_FILENAME config_content = {} if config_path.exists(): @@ -50,7 +50,7 @@ async def get_config(): @router.post("/update", response_model=response.UpdateWorkflows) -async def update_workflows(): +def update_workflows(): base_path = Path(WORKFLOWS_BASE) if HAS_GIT: From 957f479508c13603d01e3ef93435b31340413f96 Mon Sep 17 00:00:00 2001 From: kagami Date: Tue, 16 Jul 2024 15:11:43 +0800 Subject: [PATCH 25/26] Run with uvicorn because gunicorn doesn't support Windows --- ...{custom_logging.py => gunicorn_logging.py} | 50 ++++++++++++++ devchat/_service/main.py | 68 ++++--------------- devchat/_service/uvicorn_logging.py | 54 +++++++++++++++ 3 files changed, 118 insertions(+), 54 deletions(-) rename devchat/_service/{custom_logging.py => gunicorn_logging.py} (55%) create mode 100644 devchat/_service/uvicorn_logging.py diff --git a/devchat/_service/custom_logging.py b/devchat/_service/gunicorn_logging.py similarity index 55% rename from devchat/_service/custom_logging.py rename to devchat/_service/gunicorn_logging.py index 4c78e277..5a04f9dc 100644 --- a/devchat/_service/custom_logging.py +++ b/devchat/_service/gunicorn_logging.py @@ -1,4 +1,5 @@ import logging +import os import sys from gunicorn.app.base import BaseApplication @@ -6,6 +7,7 @@ from loguru import logger from devchat._service.config import config +from devchat.workspace_util import get_workspace_chat_dir class InterceptHandler(logging.Handler): @@ -57,4 +59,52 @@ def load(self): return self.application +def run_with_gunicorn(app): + intercept_handler = InterceptHandler() + # logging.basicConfig(handlers=[intercept_handler], level=LOG_LEVEL) + # logging.root.handlers = [intercept_handler] + logging.root.setLevel(config.LOG_LEVEL) + + seen = set() + for name in [ + *logging.root.manager.loggerDict.keys(), + "gunicorn", + "gunicorn.access", + "gunicorn.error", + "uvicorn", + "uvicorn.access", + "uvicorn.error", + ]: + if name not in seen: + seen.add(name.split(".")[0]) + logging.getLogger(name).handlers = [intercept_handler] + + workspace_chat_dir = get_workspace_chat_dir(config.WORKSPACE) + log_file = os.path.join(workspace_chat_dir, config.LOG_FILE) + + logger.configure( + handlers=[ + {"sink": sys.stdout, "serialize": config.JSON_LOGS}, + { + "sink": log_file, + "serialize": config.JSON_LOGS, + "rotation": "10 days", + "retention": "30 days", + "enqueue": True, + }, + ] + ) + + options = { + "bind": f"0.0.0.0:{config.PORT}", + "workers": config.WORKERS, + "accesslog": "-", + "errorlog": "-", + "worker_class": "uvicorn.workers.UvicornWorker", + "logger_class": StubbedGunicornLogger, + } + + StandaloneApplication(app, options).run() + + # https://pawamoy.github.io/posts/unify-logging-for-a-gunicorn-uvicorn-app/ diff --git a/devchat/_service/main.py b/devchat/_service/main.py index 5f148bc7..456f51db 100644 --- a/devchat/_service/main.py +++ b/devchat/_service/main.py @@ -1,18 +1,8 @@ -import logging -import os -import sys - from fastapi import FastAPI -from loguru import logger from devchat._service.config import config -from devchat._service.custom_logging import ( - InterceptHandler, - StandaloneApplication, - StubbedGunicornLogger, -) from devchat._service.route import router -from devchat.workspace_util import get_workspace_chat_dir +from devchat._service.uvicorn_logging import setup_logging api_app = FastAPI( title="DevChat Local Service", @@ -30,51 +20,21 @@ def main(): - intercept_handler = InterceptHandler() - # logging.basicConfig(handlers=[intercept_handler], level=LOG_LEVEL) - # logging.root.handlers = [intercept_handler] - logging.root.setLevel(config.LOG_LEVEL) - - seen = set() - for name in [ - *logging.root.manager.loggerDict.keys(), - "gunicorn", - "gunicorn.access", - "gunicorn.error", - "uvicorn", - "uvicorn.access", - "uvicorn.error", - ]: - if name not in seen: - seen.add(name.split(".")[0]) - logging.getLogger(name).handlers = [intercept_handler] - - workspace_chat_dir = get_workspace_chat_dir(config.WORKSPACE) - log_file = os.path.join(workspace_chat_dir, config.LOG_FILE) - - logger.configure( - handlers=[ - {"sink": sys.stdout, "serialize": config.JSON_LOGS}, - { - "sink": log_file, - "serialize": config.JSON_LOGS, - "rotation": "10 days", - "retention": "30 days", - "enqueue": True, - }, - ] + # Use uvicorn to run the app because gunicorn doesn't support Windows + from uvicorn import Config, Server + + server = Server( + Config( + api_app, + host="0.0.0.0", + port=config.PORT, + ), ) - options = { - "bind": f"0.0.0.0:{config.PORT}", - "workers": config.WORKERS, - "accesslog": "-", - "errorlog": "-", - "worker_class": "uvicorn.workers.UvicornWorker", - "logger_class": StubbedGunicornLogger, - } - - StandaloneApplication(api_app, options).run() + # setup logging last, to make sure no library overwrites it + # (they shouldn't, but it happens) + setup_logging() + server.run() if __name__ == "__main__": diff --git a/devchat/_service/uvicorn_logging.py b/devchat/_service/uvicorn_logging.py new file mode 100644 index 00000000..64de00a9 --- /dev/null +++ b/devchat/_service/uvicorn_logging.py @@ -0,0 +1,54 @@ +import logging +import os +import sys + +from loguru import logger + +from devchat._service.config import config +from devchat.workspace_util import get_workspace_chat_dir + + +class InterceptHandler(logging.Handler): + def emit(self, record): + # get corresponding Loguru level if it exists + try: + level = logger.level(record.levelname).name + except ValueError: + level = record.levelno + + # find caller from where originated the logged message + frame, depth = sys._getframe(6), 6 + while frame and frame.f_code.co_filename == logging.__file__: + frame = frame.f_back + depth += 1 + + logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage()) + + +def setup_logging(): + # intercept everything at the root logger + logging.root.handlers = [InterceptHandler()] + logging.root.setLevel(config.LOG_LEVEL) + + # remove every other logger's handlers + # and propagate to root logger + for name in logging.root.manager.loggerDict.keys(): + logging.getLogger(name).handlers = [] + logging.getLogger(name).propagate = True + + workspace_chat_dir = get_workspace_chat_dir(config.WORKSPACE) + log_file = os.path.join(workspace_chat_dir, config.LOG_FILE) + + # configure loguru + logger.configure( + handlers=[ + {"sink": sys.stdout, "serialize": config.JSON_LOGS}, + { + "sink": log_file, + "serialize": config.JSON_LOGS, + "rotation": "10 days", + "retention": "30 days", + "enqueue": True, + }, + ] + ) From ebbc9062edece24327968e0eb0936056d6d48f2c Mon Sep 17 00:00:00 2001 From: kagami Date: Tue, 16 Jul 2024 15:14:33 +0800 Subject: [PATCH 26/26] Add win32-setctime explicitly for packaging for win --- poetry.lock | 2 +- pyproject.toml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/poetry.lock b/poetry.lock index 091bed14..e3995e3a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1675,4 +1675,4 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "p [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "dd2bf3c1c5d6e8b7a84edad42b60b1b8daff44318ca80c84bd477962457bd5b1" +content-hash = "fc56cb7d16a2fb8376443404528d6c90570219f2cb547cde1226cf4ef0fd0e52" diff --git a/pyproject.toml b/pyproject.toml index 50bcd94e..cd862bdd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,7 @@ fastapi = "^0.111.0" uvicorn = {extras = ["standard"], version = "^0.30.1"} gunicorn = "^22.0.0" loguru = "^0.7.2" +win32-setctime = "^1.1.0" [tool.poetry.scripts] devchat = "devchat._cli.main:main"