diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5bb9dace2..1ce24c0df 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -133,7 +133,7 @@ jobs: "$PKG_NAME==$VERSION" - name: Run unit tests - run: make tests + run: make test publish: needs: diff --git a/LICENSE b/LICENSE index 395773867..fc0602fee 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) LangChain, Inc. +Copyright (c) 2024 LangChain, Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/Makefile b/Makefile index 3b2ae1774..f2216a7ce 100644 --- a/Makefile +++ b/Makefile @@ -1,48 +1,5 @@ .PHONY: all clean format lint test tests test_watch integration_tests docker_tests help extended_tests coverage spell_check spell_fix build-docs serve-docs serve-clean-docs clean-docs -# Default target executed when no arguments are given to make. -all: help - -###################### -# TESTING AND COVERAGE -###################### - -# Run unit tests and generate a coverage report. -coverage: - poetry run pytest --cov \ - --cov-config=.coveragerc \ - --cov-report xml \ - --cov-report term-missing:skip-covered - -test: - poetry run pytest - -test_watch: - poetry run ptw . - -###################### -# LINTING AND FORMATTING -###################### - -# Define a variable for Python and notebook files. -PYTHON_FILES=. -MYPY_CACHE=.mypy_cache -lint format: PYTHON_FILES=. -lint_diff format_diff: PYTHON_FILES=$(shell git diff --name-only --diff-filter=d master | grep -E '\.py$$|\.ipynb$$') -lint_package: PYTHON_FILES=langgraph -lint_tests: PYTHON_FILES=tests -lint_tests: MYPY_CACHE=.mypy_cache_test - -lint lint_diff lint_package lint_tests: - poetry run ruff . - [ "$(PYTHON_FILES)" = "" ] || poetry run ruff format $(PYTHON_FILES) --diff - [ "$(PYTHON_FILES)" = "" ] || poetry run ruff --select I $(PYTHON_FILES) - [ "$(PYTHON_FILES)" = "" ] || mkdir -p $(MYPY_CACHE) || poetry run mypy $(PYTHON_FILES) --cache-dir $(MYPY_CACHE) - -format format_diff: - poetry run ruff format $(PYTHON_FILES) - poetry run ruff --select I --fix $(PYTHON_FILES) - spell_check: poetry run codespell --toml pyproject.toml @@ -63,28 +20,4 @@ serve-docs: clean-docs: find ./docs/docs -name "*.ipynb" -type f -delete - rm -rf docs/site - - -###################### -# HELP -###################### - -help: - @echo '====================' - @echo '-- DOCUMENTATION --' - - @echo '-- LINTING --' - @echo 'format - run code formatters' - @echo 'lint - run linters' - @echo 'spell_check - run codespell on the project' - @echo 'spell_fix - run codespell on the project and fix the errors' - @echo '-- TESTS --' - @echo 'coverage - run unit tests and generate coverage report' - @echo 'test - run unit tests' - @echo 'tests - run unit tests (alias for "make test")' - @echo 'test TEST_FILE= - run all tests in file' - @echo 'extended_tests - run only extended unit tests' - @echo 'test_watch - run unit tests in watch mode' - @echo 'integration_tests - run integration tests' - @echo 'docker_tests - run unit tests in docker' + rm -rf docs/site \ No newline at end of file diff --git a/libs/cli/LICENSE b/libs/cli/LICENSE new file mode 100644 index 000000000..fc0602fee --- /dev/null +++ b/libs/cli/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 LangChain, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/libs/cli/Makefile b/libs/cli/Makefile new file mode 100644 index 000000000..0bb0d74da --- /dev/null +++ b/libs/cli/Makefile @@ -0,0 +1,13 @@ +.PHONY: lint format + +lint: + poetry run ruff . + poetry run ruff format . --diff + poetry run mypy . + +format: + poetry run ruff format . + poetry run ruff --select I --fix . + +test: + poetry run pytest tests \ No newline at end of file diff --git a/libs/cli/README.md b/libs/cli/README.md new file mode 100644 index 000000000..b65ad64ac --- /dev/null +++ b/libs/cli/README.md @@ -0,0 +1,3 @@ +# langchain-cli + +This package implements the official CLI for LangGraph API. \ No newline at end of file diff --git a/langgraph/_api/__init__.py b/libs/cli/langgraph_cli/__init__.py similarity index 100% rename from langgraph/_api/__init__.py rename to libs/cli/langgraph_cli/__init__.py diff --git a/libs/cli/langgraph_cli/cli.py b/libs/cli/langgraph_cli/cli.py new file mode 100644 index 000000000..69d66c75d --- /dev/null +++ b/libs/cli/langgraph_cli/cli.py @@ -0,0 +1,476 @@ +import json +import pathlib +import shutil +import sys +from typing import Optional + +import click +import click.exceptions + +import langgraph_cli.config +import langgraph_cli.docker +from langgraph_cli.config import Config +from langgraph_cli.docker import DockerCapabilities +from langgraph_cli.exec import Runner, subp_exec +from langgraph_cli.progress import Progress + +OPT_O = click.option( + "--docker-compose", + "-d", + help="Advanced: Path to docker-compose.yml file with additional services to launch", + type=click.Path( + exists=True, + file_okay=True, + dir_okay=False, + resolve_path=True, + path_type=pathlib.Path, + ), +) +OPT_C = click.option( + "--config", + "-c", + help="""Path to configuration file declaring dependencies, graphs and environment variables. + + \b + Config file must be a JSON file that has the following keys: + - "dependencies": array of dependencies for langgraph API server. Dependencies can be one of the following: + - ".", which would look for local python packages, as well as pyproject.toml, setup.py or requirements.txt in the app directory + - "./local_package" + - " + - "graphs": mapping from graph ID to path where the compiled graph is defined, i.e. ./your_package/your_file.py:variable, where + "variable" is an instance of langgraph.graph.graph.CompiledGraph + - "env": (optional) path to .env file or a mapping from environment variable to its value + - "python_version": (optional) 3.11 or 3.12. Defaults to 3.11 + - "pip_config_file": (optional) path to pip config file + - "dockerfile_lines": (optional) array of additional lines to add to Dockerfile following the import from parent image + + \b + Example: + langgraph up -c langgraph.json + + \b + Example: + { + "dependencies": [ + "langchain_openai", + "./your_package" + ], + "graphs": { + "my_graph_id": "./your_package/your_file.py:variable" + }, + "env": "./.env" + } + + \b + Example: + { + "python_version": "3.11", + "dependencies": [ + "langchain_openai", + "." + ], + "graphs": { + "my_graph_id": "./your_package/your_file.py:variable" + }, + "env": { + "OPENAI_API_KEY": "secret-key" + } + } + + Defaults to looking for langgraph.json in the current directory.""", + default="langgraph.json", + type=click.Path( + exists=True, + file_okay=True, + dir_okay=False, + resolve_path=True, + path_type=pathlib.Path, + ), +) +OPT_PORT = click.option( + "--port", + "-p", + type=int, + default=8123, + show_default=True, + help=""" + Port to expose. + + \b + Example: + langgraph up --port 8000 + \b + """, +) +OPT_RECREATE = click.option( + "--recreate/--no-recreate", + default=False, + show_default=True, + help="Recreate containers even if their configuration and image haven't changed", +) +OPT_PULL = click.option( + "--pull/--no-pull", + default=True, + show_default=True, + help=""" + Pull latest images. Use --no-pull for running the server with locally-built images. + + \b + Example: + langgraph up --no-pull + \b + """, +) +OPT_VERBOSE = click.option( + "--verbose", + is_flag=True, + default=False, + help="Show more output from the server logs", +) +OPT_DEBUGGER_PORT = click.option( + "--debugger-port", + type=int, + help="Pull the debugger image locally and serve the UI on specified port", +) + + +@click.group() +def cli(): + pass + + +@OPT_RECREATE +@OPT_PULL +@OPT_PORT +@OPT_O +@OPT_C +@OPT_VERBOSE +@OPT_DEBUGGER_PORT +@click.option("--watch", is_flag=True, help="Restart on file changes") +@click.option( + "--langgraph-api-path", + type=click.Path(exists=True, file_okay=False, dir_okay=True, resolve_path=True), + hidden=True, +) +@click.option( + "--wait", + is_flag=True, + help="Wait for services to start before returning. Implies --detach", +) +@cli.command(help="Start langgraph API server") +def up( + config: pathlib.Path, + docker_compose: Optional[pathlib.Path], + port: int, + recreate: bool, + pull: bool, + watch: bool, + langgraph_api_path: Optional[pathlib.Path], + wait: bool, + verbose: bool, + debugger_port: Optional[int], +): + with Runner() as runner, Progress(message="Pulling...") as set: + capabilities = langgraph_cli.docker.check_capabilities(runner) + args, stdin = prepare( + runner, + capabilities=capabilities, + config_path=config, + docker_compose=docker_compose, + port=port, + pull=pull, + watch=watch, + langgraph_api_path=langgraph_api_path, + verbose=verbose, + debugger_port=debugger_port, + ) + # add up + options + args.extend(["up", "--remove-orphans"]) + if recreate: + args.extend(["--force-recreate", "--renew-anon-volumes"]) + shutil.rmtree(config.parent / ".langgraph-data", ignore_errors=True) + try: + runner.run(subp_exec("docker", "volume", "rm", "langgraph-data")) + except click.exceptions.Exit: + pass + if watch: + args.append("--watch") + if wait: + args.append("--wait") + # run docker compose + set("Building...") + + def on_stdout(line: str): + if "unpacking to docker.io" in line: + set("Starting...") + elif "GET /ok" in line: + debugger_origin = ( + f"http://localhost:{debugger_port}" + if debugger_port + else "https://dev.smith.langchain.com" + ) + set("") + sys.stdout.write( + f"""Ready! +- API: http://localhost:{port} +- Docs: http://localhost:{port}/docs +- Debugger: {debugger_origin}/studio/?baseUrl=http://127.0.0.1:{port} +""" + ) + sys.stdout.flush() + return True + + if capabilities.compose_type == "plugin": + compose_cmd = ["docker", "compose"] + elif capabilities.compose_type == "standalone": + compose_cmd = ["docker-compose"] + + runner.run( + subp_exec( + *compose_cmd, + *args, + input=stdin, + verbose=verbose, + on_stdout=on_stdout, + ) + ) + + +@OPT_PORT +@OPT_O +@OPT_C +@OPT_VERBOSE +@OPT_DEBUGGER_PORT +@cli.command(help="Stop langgraph API server") +def down( + config: pathlib.Path, + docker_compose: Optional[pathlib.Path], + port: int, + verbose: bool, + debugger_port: Optional[int], +): + with Runner() as runner: + capabilities = langgraph_cli.docker.check_capabilities(runner) + args, stdin = prepare( + runner, + capabilities=capabilities, + config_path=config, + docker_compose=docker_compose, + port=port, + pull=False, + watch=False, + langgraph_api_path=None, + verbose=verbose, + debugger_port=debugger_port, + ) + # add down + options + args.append("down") + # run docker compose + if capabilities.compose_type == "plugin": + compose_cmd = ["docker", "compose"] + elif capabilities.compose_type == "standalone": + compose_cmd = ["docker-compose"] + + runner.run(subp_exec(*compose_cmd, *args, input=stdin, verbose=verbose)) + + +@OPT_O +@OPT_C +@click.option("--follow", "-f", is_flag=True, help="Follow logs") +@cli.command(help="Show langgraph API server logs") +def logs( + config: pathlib.Path, + docker_compose: Optional[pathlib.Path], + follow: bool, +): + with Runner() as runner: + capabilities = langgraph_cli.docker.check_capabilities(runner) + args, stdin = prepare( + runner, + capabilities=capabilities, + config_path=config, + docker_compose=docker_compose, + port=8123, + pull=False, + watch=False, + verbose=False, + langgraph_api_path=None, + ) + # add logs + options + args.append("logs") + if follow: + args.extend(["-f"]) + # run docker compose + if capabilities.compose_type == "plugin": + compose_cmd = ["docker", "compose"] + elif capabilities.compose_type == "standalone": + compose_cmd = ["docker-compose"] + + runner.run(subp_exec(*compose_cmd, *args, input=stdin, verbose=True)) + + +@OPT_C +@OPT_PULL +@click.option( + "--tag", + "-t", + help="""Tag for the docker image. + + \b + Example: + langgraph build -t my-image + + \b + """, + required=True, +) +@click.option( + "--platform", + help="""Target platform(s) to build the docker image for. + + \b + Example: + langgraph build --platform linux/amd64,linux/arm64 + \b + """, +) +@cli.command(help="Build langgraph API server docker image") +def build( + config: pathlib.Path, + platform: Optional[str], + pull: bool, + tag: str, +): + with open(config) as f: + config_json = langgraph_cli.config.validate_config(json.load(f)) + with Runner() as runner: + # check docker available + langgraph_cli.docker.check_capabilities(runner) + # pull latest images + if pull: + runner.run( + subp_exec( + "docker", + "pull", + f"langchain/langgraph-api:{config_json['python_version']}", + ) + ) + # apply options + args = [ + "-f", + "-", # stdin + "-t", + tag, + ] + if platform: + args.extend(["--platform", platform]) + # apply config + stdin = langgraph_cli.config.config_to_docker(config, config_json) + # run docker build + runner.run( + subp_exec( + "docker", "build", *args, str(config.parent), input=stdin, verbose=True + ) + ) + + +@OPT_PORT +@OPT_O +@OPT_C +@cli.command(help="Build a helm chart to deploy to a Kubernetes cluster", hidden=True) +def helm( + config: pathlib.Path, + docker_compose: Optional[pathlib.Path], + port: int, +): + with open(config) as f: + config_json = langgraph_cli.config.validate_config(json.load(f)) + with Runner() as runner: + # check docker available + capabilities = langgraph_cli.docker.check_capabilities(runner) + # prepare args + stdin = langgraph_cli.docker.compose(capabilities, port=port) + args = [ + "--chart", + "-o=./helm", + "-v", + "-f", + "-", # stdin + ] + # apply options + if docker_compose: + args.extend(["-f", str(docker_compose)]) + args.append("convert") + # apply config + stdin += langgraph_cli.config.config_to_compose(config, config_json) + # run kompose convert + runner.run(subp_exec("kompose", *args, input=stdin)) + + +def prepare_args_and_stdin( + *, + capabilities: DockerCapabilities, + config_path: pathlib.Path, + config: Config, + docker_compose: Optional[pathlib.Path], + port: int, + watch: bool, + langgraph_api_path: Optional[pathlib.Path], + debugger_port: Optional[int] = None, +): + # prepare args + stdin = langgraph_cli.docker.compose( + capabilities, port=port, debugger_port=debugger_port + ) + args = [ + "--project-directory", + str(config_path.parent), + ] + # apply options + if docker_compose: + args.extend(["-f", str(docker_compose)]) + args.extend(["-f", "-"]) # stdin + # apply config + stdin += langgraph_cli.config.config_to_compose( + config_path, config, watch=watch, langgraph_api_path=langgraph_api_path + ) + return args, stdin + + +def prepare( + runner, + *, + capabilities: DockerCapabilities, + config_path: pathlib.Path, + docker_compose: Optional[pathlib.Path], + port: int, + pull: bool, + watch: bool, + langgraph_api_path: Optional[pathlib.Path], + verbose: bool, + debugger_port: Optional[int] = None, +): + with open(config_path) as f: + config = langgraph_cli.config.validate_config(json.load(f)) + # pull latest images + if pull: + runner.run( + subp_exec( + "docker", + "pull", + f"langchain/langgraph-api:{config['python_version']}", + verbose=verbose, + ) + ) + + args, stdin = prepare_args_and_stdin( + capabilities=capabilities, + config_path=config_path, + config=config, + docker_compose=docker_compose, + port=port, + watch=watch, + langgraph_api_path=langgraph_api_path, + debugger_port=debugger_port, + ) + return args, stdin diff --git a/libs/cli/langgraph_cli/config.py b/libs/cli/langgraph_cli/config.py new file mode 100644 index 000000000..47ba78756 --- /dev/null +++ b/libs/cli/langgraph_cli/config.py @@ -0,0 +1,306 @@ +import json +import os +import pathlib +import textwrap +from typing import NamedTuple, Optional, TypedDict, Union + +import click + + +class Config(TypedDict): + python_version: str + pip_config_file: Optional[str] + dockerfile_lines: list[str] + dependencies: list[str] + graphs: dict[str, str] + env: Union[dict[str, str], str] + + +def validate_config(config: Config) -> Config: + config = { + "python_version": config.get("python_version", "3.11"), + "pip_config_file": config.get("pip_config_file"), + "dockerfile_lines": config.get("dockerfile_lines", []), + "dependencies": config.get("dependencies", []), + "graphs": config.get("graphs", {}), + "env": config.get("env", {}), + } + if config["python_version"] not in ( + "3.11", + "3.12", + ): + raise click.UsageError( + f"Unsupported Python version: {config['python_version']}. " + "Supported versions are 3.11 and 3.12." + ) + if not config["dependencies"]: + raise click.UsageError( + "No dependencies found in config. " + "Add at least one dependency to 'dependencies' list." + ) + if not config["graphs"]: + raise click.UsageError( + "No graphs found in config. " + "Add at least one graph to 'graphs' dictionary." + ) + return config + + +class LocalDeps(NamedTuple): + pip_reqs: list[tuple[pathlib.Path, str]] + real_pkgs: dict[pathlib.Path, str] + faux_pkgs: dict[pathlib.Path, tuple[str, str]] + # if . is in dependencies, use it as working_dir + working_dir: Optional[str] = None + + +def _assemble_local_deps(config_path: pathlib.Path, config: Config) -> LocalDeps: + # ensure reserved package names are not used + reserved = { + "src", + "langgraph-api", + "langgraph_api", + "langgraph", + "langchain-core", + "langchain_core", + "pydantic", + "orjson", + "fastapi", + "uvicorn", + "psycopg", + "httpx", + "langsmith", + } + + def check_reserved(name: str, ref: str): + if name in reserved: + raise ValueError( + f"Package name '{name}' used in local dep '{ref}' is reserved. " + "Rename the directory." + ) + reserved.add(name) + + pip_reqs = [] + real_pkgs = {} + faux_pkgs = {} + working_dir = None + + for local_dep in config["dependencies"]: + if not local_dep.startswith("."): + continue + + resolved = config_path.parent / local_dep + + # validate local dependency + if not resolved.exists(): + raise FileNotFoundError(f"Could not find local dependency: {resolved}") + elif not resolved.is_dir(): + raise NotADirectoryError( + f"Local dependency must be a directory: {resolved}" + ) + elif not resolved.is_relative_to(config_path.parent): + raise ValueError( + f"Local dependency '{resolved}' must be a subdirectory of '{config_path.parent}'" + ) + + # if it's installable, add it to local_pkgs + # otherwise, add it to faux_pkgs, and create a pyproject.toml + files = os.listdir(resolved) + if "pyproject.toml" in files: + real_pkgs[resolved] = local_dep + if local_dep == ".": + working_dir = f"/deps/{resolved.name}" + elif "setup.py" in files: + real_pkgs[resolved] = local_dep + if local_dep == ".": + working_dir = f"/deps/{resolved.name}" + else: + if any(file == "__init__.py" for file in files): + # flat layout + if "-" in resolved.name: + raise ValueError( + f"Package name '{resolved.name}' contains a hyphen. " + "Rename the directory to use it as flat-layout package." + ) + check_reserved(resolved.name, local_dep) + container_path = f"/deps/__outer_{resolved.name}/{resolved.name}" + else: + # src layout + container_path = f"/deps/__outer_{resolved.name}/src" + for file in files: + rfile = resolved / file + if ( + rfile.is_dir() + and file != "__pycache__" + and not file.startswith(".") + ): + try: + for subfile in os.listdir(rfile): + if subfile.endswith(".py"): + check_reserved(file, local_dep) + break + except PermissionError: + pass + faux_pkgs[resolved] = (local_dep, container_path) + if local_dep == ".": + working_dir = container_path + if "requirements.txt" in files: + rfile = resolved / "requirements.txt" + pip_reqs.append( + ( + rfile.relative_to(config_path.parent), + f"{container_path}/requirements.txt", + ) + ) + + return LocalDeps(pip_reqs, real_pkgs, faux_pkgs, working_dir) + + +def _update_graph_paths( + config_path: pathlib.Path, config: Config, local_deps: LocalDeps +) -> None: + for graph_id, import_str in config["graphs"].items(): + module_str, _, attr_str = import_str.partition(":") + if not module_str or not attr_str: + message = ( + 'Import string "{import_str}" must be in format ":".' + ) + raise ValueError(message.format(import_str=import_str)) + if "/" in module_str: + resolved = config_path.parent / module_str + if not resolved.exists(): + raise FileNotFoundError(f"Could not find local module: {resolved}") + elif not resolved.is_file(): + raise IsADirectoryError(f"Local module must be a file: {resolved}") + else: + for path in local_deps.real_pkgs: + if resolved.is_relative_to(path): + module_str = f"/deps/{path.name}/{resolved.relative_to(path)}" + break + else: + for faux_pkg, (_, destpath) in local_deps.faux_pkgs.items(): + if resolved.is_relative_to(faux_pkg): + module_str = f"{destpath}/{resolved.relative_to(faux_pkg)}" + break + else: + raise ValueError( + f"Module '{import_str}' not found in 'dependencies' list. " + "Add its containing package to 'dependencies' list." + ) + # update the config + config["graphs"][graph_id] = f"{module_str}:{attr_str}" + + +def config_to_docker(config_path: pathlib.Path, config: Config): + # configure pip + pip_install = "pip install -c /api/constraints.txt" + if config.get("pip_config_file"): + pip_install = f"PIP_CONFIG_FILE=/pipconfig.txt {pip_install}" + pip_config_file_str = ( + f"ADD {config['pip_config_file']} /pipconfig.txt" + if config.get("pip_config_file") + else "" + ) + + # collect dependencies + pypi_deps = [dep for dep in config["dependencies"] if not dep.startswith(".")] + local_deps = _assemble_local_deps(config_path, config) + + # rewrite graph paths + _update_graph_paths(config_path, config, local_deps) + + pip_pkgs_str = f"RUN {pip_install} {' '.join(pypi_deps)}" if pypi_deps else "" + if local_deps.pip_reqs: + pip_reqs_str = os.linesep.join( + f"ADD {reqpath} {destpath}" for reqpath, destpath in local_deps.pip_reqs + ) + pip_reqs_str += f'{os.linesep}RUN {pip_install} {" ".join("-r " + r for _,r in local_deps.pip_reqs)}' + + else: + pip_reqs_str = "" + + # https://setuptools.pypa.io/en/latest/userguide/datafiles.html#package-data + # https://til.simonwillison.net/python/pyproject + faux_pkgs_str = f"{os.linesep}{os.linesep}".join( + f"""ADD {relpath} {destpath} +COPY < Version: + parts = version.split(".", 2) + if len(parts) == 1: + major = parts[0] + minor = "0" + patch = "0" + elif len(parts) == 2: + major, minor = parts + patch = "0" + else: + major, minor, patch = parts + return Version(int(major.lstrip("v")), int(minor), int(patch.split("-")[0])) + + +def check_capabilities(runner) -> DockerCapabilities: + # check docker available + try: + stdout, _ = runner.run(subp_exec("docker", "info", "-f", "json", collect=True)) + info = json.loads(stdout) + except (click.exceptions.Exit, json.JSONDecodeError): + raise click.UsageError("Docker not installed or not running") from None + + compose_type: DockerComposeType + try: + compose = next( + p for p in info["ClientInfo"]["Plugins"] if p["Name"] == "compose" + ) + compose_type = "plugin" + except (KeyError, StopIteration): + if shutil.which("docker-compose") is None: + raise click.UsageError("Docker Compose not installed") from None + + compose_type = "standalone" + + # parse versions + docker_version = _parse_version(info["ServerVersion"]) + compose_version = _parse_version(compose["Version"]) + + # check capabilities + return DockerCapabilities( + version_docker=docker_version, + version_compose=compose_version, + healthcheck_start_interval=docker_version >= Version(25, 0, 0), + compose_type=compose_type, + ) + + +def compose( + capabilities: DockerCapabilities, + *, + port: int, + debugger_port: Optional[int] = None, + # postgres://user:password@host:port/database?option=value + postgres_uri: Optional[str] = None, +) -> str: + if postgres_uri is None: + include_db = True + postgres_uri = DEFAULT_POSTGRES_URI + else: + include_db = False + + db = DB.format() if include_db else "" + volumes = ( + """volumes: + langgraph-data: + driver: local +""" + if include_db + else "" + ) + if db: + if capabilities.healthcheck_start_interval: + db += """ + interval: 60s + start_interval: 1s""" + else: + db += """ + interval: 5s""" + + compose_str = f"""{volumes}services: +{db} +{DEBUGGER.format(debugger_port=debugger_port) if debugger_port else ""} + langgraph-api: + restart: on-failure + ports: + - "{port}:8000" + depends_on: + langgraph-postgres: + condition: service_healthy + environment: + POSTGRES_URI: {postgres_uri} +""" + if capabilities.healthcheck_start_interval: + compose_str += """ healthcheck: + interval: 60s + start_interval: 1s + start_period: 10s""" + + return compose_str diff --git a/libs/cli/langgraph_cli/exec.py b/libs/cli/langgraph_cli/exec.py new file mode 100644 index 000000000..e165b9e86 --- /dev/null +++ b/libs/cli/langgraph_cli/exec.py @@ -0,0 +1,140 @@ +import asyncio +import os +import signal +import sys +from contextlib import contextmanager +from typing import Callable, Optional, cast + +import click.exceptions + + +@contextmanager +def Runner(): + if hasattr(asyncio, "Runner"): + with asyncio.Runner() as runner: + yield runner + else: + + class _Runner: + def __enter__(self): + return self + + def __exit__(self, *args): + pass + + def run(self, coro): + asyncio.run(coro) + + yield _Runner() + + +async def subp_exec( + cmd: str, + *args: str, + input: Optional[str] = None, + wait: Optional[float] = None, + verbose: bool = False, + collect: bool = False, + on_stdout: Optional[Callable[[str], Optional[bool]]] = None, +) -> tuple[Optional[str], Optional[str]]: + if verbose: + cmd_str = f"+ {cmd} {' '.join(map(str, args))}" + if input: + print(cmd_str, " <\n", "\n".join(filter(None, input.splitlines())), sep="") + else: + print(cmd_str) + if wait: + await asyncio.sleep(wait) + + try: + proc = await asyncio.create_subprocess_exec( + cmd, + *args, + stdin=asyncio.subprocess.PIPE if input else None, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + + def signal_handler(): + # make sure process exists, then terminate it + if proc.returncode is None: + proc.terminate() + + loop = asyncio.get_event_loop() + loop.add_signal_handler(signal.SIGINT, signal_handler) + loop.add_signal_handler(signal.SIGTERM, signal_handler) + + empty_fut: asyncio.Future = asyncio.Future() + empty_fut.set_result(None) + stdout, stderr, _ = await asyncio.gather( + monitor_stream( + cast(asyncio.StreamReader, proc.stdout), + collect=True, + display=verbose, + on_line=on_stdout, + ), + monitor_stream( + cast(asyncio.StreamReader, proc.stderr), + collect=True, + display=verbose, + ), + proc._feed_stdin(input.encode()) if input else empty_fut, # type: ignore[attr-defined] + ) + returncode = await proc.wait() + if ( + returncode is not None + and returncode != 0 # success + and returncode != 130 # user interrupt + ): + sys.stdout.write(stdout.decode() if stdout else "") + sys.stderr.write(stderr.decode() if stderr else "") + raise click.exceptions.Exit(returncode) + if collect: + return ( + stdout.decode() if stdout else None, + stderr.decode() if stderr else None, + ) + else: + return None, None + finally: + try: + if proc.returncode is None: + try: + os.killpg(os.getpgid(proc.pid), signal.SIGINT) + except (ProcessLookupError, KeyboardInterrupt): + pass + + loop.remove_signal_handler(signal.SIGINT) + loop.remove_signal_handler(signal.SIGTERM) + except UnboundLocalError: + pass + + +async def monitor_stream( + stream: asyncio.StreamReader, + collect: bool = False, + display: bool = False, + on_line: Optional[Callable[[str], Optional[bool]]] = None, +) -> Optional[bytearray]: + if collect: + ba = bytearray() + + def handle(line: bytes): + nonlocal on_line + nonlocal display + + if collect: + ba.extend(line) + if display: + sys.stdout.write(line.decode()) + if on_line: + if on_line(line.decode()): + on_line = None + display = True + + async for line in stream: + await asyncio.to_thread(handle, line) + if collect: + return ba + else: + return None diff --git a/libs/cli/langgraph_cli/progress.py b/libs/cli/langgraph_cli/progress.py new file mode 100644 index 000000000..38154f1b9 --- /dev/null +++ b/libs/cli/langgraph_cli/progress.py @@ -0,0 +1,64 @@ +import sys +import threading +import time +from typing import Callable + + +class Progress: + delay: float = 0.1 + + @staticmethod + def spinning_cursor(): + while True: + yield from "|/-\\" + + def __init__(self, *, message=""): + self.message = message + self.spinner_generator = self.spinning_cursor() + + def spinner_iteration(self): + message = self.message + sys.stdout.write(next(self.spinner_generator) + " " + message) + sys.stdout.flush() + time.sleep(self.delay) + # clear the spinner and message + sys.stdout.write( + "\b" * (len(message) + 2) + + " " * (len(message) + 2) + + "\b" * (len(message) + 2) + ) + sys.stdout.flush() + + def spinner_task(self): + while self.message: + message = self.message + sys.stdout.write(next(self.spinner_generator) + " " + message) + sys.stdout.flush() + time.sleep(self.delay) + # clear the spinner and message + sys.stdout.write( + "\b" * (len(message) + 2) + + " " * (len(message) + 2) + + "\b" * (len(message) + 2) + ) + sys.stdout.flush() + + def __enter__(self) -> Callable[[str], None]: + self.thread = threading.Thread(target=self.spinner_task) + self.thread.start() + + def set_message(message): + self.message = message + if not message: + self.thread.join() + + return set_message + + def __exit__(self, exception, value, tb): + self.message = "" + try: + self.thread.join() + finally: + del self.thread + if exception is not None: + return False diff --git a/libs/cli/poetry.lock b/libs/cli/poetry.lock new file mode 100644 index 000000000..4f6c66396 --- /dev/null +++ b/libs/cli/poetry.lock @@ -0,0 +1,327 @@ +# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. + +[[package]] +name = "click" +version = "8.1.7" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.7" +files = [ + {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, + {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "codespell" +version = "2.2.6" +description = "Codespell" +optional = false +python-versions = ">=3.8" +files = [ + {file = "codespell-2.2.6-py3-none-any.whl", hash = "sha256:9ee9a3e5df0990604013ac2a9f22fa8e57669c827124a2e961fe8a1da4cacc07"}, + {file = "codespell-2.2.6.tar.gz", hash = "sha256:a8c65d8eb3faa03deabab6b3bbe798bea72e1799c7e9e955d57eca4096abcff9"}, +] + +[package.extras] +dev = ["Pygments", "build", "chardet", "pre-commit", "pytest", "pytest-cov", "pytest-dependency", "ruff", "tomli", "twine"] +hard-encoding-detection = ["chardet"] +toml = ["tomli"] +types = ["chardet (>=5.1.0)", "mypy", "pytest", "pytest-cov", "pytest-dependency"] + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "docopt" +version = "0.6.2" +description = "Pythonic argument parser, that will make you smile" +optional = false +python-versions = "*" +files = [ + {file = "docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"}, +] + +[[package]] +name = "exceptiongroup" +version = "1.2.0" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +files = [ + {file = "exceptiongroup-1.2.0-py3-none-any.whl", hash = "sha256:4bfd3996ac73b41e9b9628b04e079f193850720ea5945fc96a08633c66912f14"}, + {file = "exceptiongroup-1.2.0.tar.gz", hash = "sha256:91f5c769735f051a4290d52edd0858999b57e5876e9f85937691bd4c9fa3ed68"}, +] + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "iniconfig" +version = "2.0.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.7" +files = [ + {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, + {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, +] + +[[package]] +name = "mypy" +version = "1.10.0" +description = "Optional static typing for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "mypy-1.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:da1cbf08fb3b851ab3b9523a884c232774008267b1f83371ace57f412fe308c2"}, + {file = "mypy-1.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:12b6bfc1b1a66095ab413160a6e520e1dc076a28f3e22f7fb25ba3b000b4ef99"}, + {file = "mypy-1.10.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e36fb078cce9904c7989b9693e41cb9711e0600139ce3970c6ef814b6ebc2b2"}, + {file = "mypy-1.10.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:2b0695d605ddcd3eb2f736cd8b4e388288c21e7de85001e9f85df9187f2b50f9"}, + {file = "mypy-1.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:cd777b780312ddb135bceb9bc8722a73ec95e042f911cc279e2ec3c667076051"}, + {file = "mypy-1.10.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3be66771aa5c97602f382230165b856c231d1277c511c9a8dd058be4784472e1"}, + {file = "mypy-1.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8b2cbaca148d0754a54d44121b5825ae71868c7592a53b7292eeb0f3fdae95ee"}, + {file = "mypy-1.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ec404a7cbe9fc0e92cb0e67f55ce0c025014e26d33e54d9e506a0f2d07fe5de"}, + {file = "mypy-1.10.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e22e1527dc3d4aa94311d246b59e47f6455b8729f4968765ac1eacf9a4760bc7"}, + {file = "mypy-1.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:a87dbfa85971e8d59c9cc1fcf534efe664d8949e4c0b6b44e8ca548e746a8d53"}, + {file = "mypy-1.10.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:a781f6ad4bab20eef8b65174a57e5203f4be627b46291f4589879bf4e257b97b"}, + {file = "mypy-1.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b808e12113505b97d9023b0b5e0c0705a90571c6feefc6f215c1df9381256e30"}, + {file = "mypy-1.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f55583b12156c399dce2df7d16f8a5095291354f1e839c252ec6c0611e86e2e"}, + {file = "mypy-1.10.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4cf18f9d0efa1b16478c4c129eabec36148032575391095f73cae2e722fcf9d5"}, + {file = "mypy-1.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:bc6ac273b23c6b82da3bb25f4136c4fd42665f17f2cd850771cb600bdd2ebeda"}, + {file = "mypy-1.10.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9fd50226364cd2737351c79807775136b0abe084433b55b2e29181a4c3c878c0"}, + {file = "mypy-1.10.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f90cff89eea89273727d8783fef5d4a934be2fdca11b47def50cf5d311aff727"}, + {file = "mypy-1.10.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fcfc70599efde5c67862a07a1aaf50e55bce629ace26bb19dc17cece5dd31ca4"}, + {file = "mypy-1.10.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:075cbf81f3e134eadaf247de187bd604748171d6b79736fa9b6c9685b4083061"}, + {file = "mypy-1.10.0-cp38-cp38-win_amd64.whl", hash = "sha256:3f298531bca95ff615b6e9f2fc0333aae27fa48052903a0ac90215021cdcfa4f"}, + {file = "mypy-1.10.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fa7ef5244615a2523b56c034becde4e9e3f9b034854c93639adb667ec9ec2976"}, + {file = "mypy-1.10.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3236a4c8f535a0631f85f5fcdffba71c7feeef76a6002fcba7c1a8e57c8be1ec"}, + {file = "mypy-1.10.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a2b5cdbb5dd35aa08ea9114436e0d79aceb2f38e32c21684dcf8e24e1e92821"}, + {file = "mypy-1.10.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:92f93b21c0fe73dc00abf91022234c79d793318b8a96faac147cd579c1671746"}, + {file = "mypy-1.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:28d0e038361b45f099cc086d9dd99c15ff14d0188f44ac883010e172ce86c38a"}, + {file = "mypy-1.10.0-py3-none-any.whl", hash = "sha256:f8c083976eb530019175aabadb60921e73b4f45736760826aa1689dda8208aee"}, + {file = "mypy-1.10.0.tar.gz", hash = "sha256:3d087fcbec056c4ee34974da493a826ce316947485cef3901f511848e687c131"}, +] + +[package.dependencies] +mypy-extensions = ">=1.0.0" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +typing-extensions = ">=4.1.0" + +[package.extras] +dmypy = ["psutil (>=4.0)"] +install-types = ["pip"] +mypyc = ["setuptools (>=50)"] +reports = ["lxml"] + +[[package]] +name = "mypy-extensions" +version = "1.0.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = false +python-versions = ">=3.5" +files = [ + {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, + {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, +] + +[[package]] +name = "packaging" +version = "23.2" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.7" +files = [ + {file = "packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7"}, + {file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"}, +] + +[[package]] +name = "pluggy" +version = "1.3.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pluggy-1.3.0-py3-none-any.whl", hash = "sha256:d89c696a773f8bd377d18e5ecda92b7a3793cbe66c87060a6fb58c7b6e1061f7"}, + {file = "pluggy-1.3.0.tar.gz", hash = "sha256:cf61ae8f126ac6f7c451172cf30e3e43d3ca77615509771b3a984a0730651e12"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "pytest" +version = "7.4.3" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pytest-7.4.3-py3-none-any.whl", hash = "sha256:0d009c083ea859a71b76adf7c1d502e4bc170b80a8ef002da5806527b9591fac"}, + {file = "pytest-7.4.3.tar.gz", hash = "sha256:d989d136982de4e3b29dabcc838ad581c64e8ed52c11fbe86ddebd9da0818cd5"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=0.12,<2.0" +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 = "pytest-asyncio" +version = "0.21.1" +description = "Pytest support for asyncio" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pytest-asyncio-0.21.1.tar.gz", hash = "sha256:40a7eae6dded22c7b604986855ea48400ab15b069ae38116e8c01238e9eeb64d"}, + {file = "pytest_asyncio-0.21.1-py3-none-any.whl", hash = "sha256:8666c1c8ac02631d7c51ba282e0c69a8a452b211ffedf2599099845da5c5c37b"}, +] + +[package.dependencies] +pytest = ">=7.0.0" + +[package.extras] +docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] +testing = ["coverage (>=6.2)", "flaky (>=3.5.0)", "hypothesis (>=5.7.1)", "mypy (>=0.931)", "pytest-trio (>=0.7.0)"] + +[[package]] +name = "pytest-mock" +version = "3.12.0" +description = "Thin-wrapper around the mock package for easier use with pytest" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest-mock-3.12.0.tar.gz", hash = "sha256:31a40f038c22cad32287bb43932054451ff5583ff094bca6f675df2f8bc1a6e9"}, + {file = "pytest_mock-3.12.0-py3-none-any.whl", hash = "sha256:0972719a7263072da3a21c7f4773069bcc7486027d7e8e1f81d98a47e701bc4f"}, +] + +[package.dependencies] +pytest = ">=5.0" + +[package.extras] +dev = ["pre-commit", "pytest-asyncio", "tox"] + +[[package]] +name = "pytest-watch" +version = "4.2.0" +description = "Local continuous test runner with pytest and watchdog." +optional = false +python-versions = "*" +files = [ + {file = "pytest-watch-4.2.0.tar.gz", hash = "sha256:06136f03d5b361718b8d0d234042f7b2f203910d8568f63df2f866b547b3d4b9"}, +] + +[package.dependencies] +colorama = ">=0.3.3" +docopt = ">=0.4.0" +pytest = ">=2.6.4" +watchdog = ">=0.6.0" + +[[package]] +name = "ruff" +version = "0.1.6" +description = "An extremely fast Python linter and code formatter, written in Rust." +optional = false +python-versions = ">=3.7" +files = [ + {file = "ruff-0.1.6-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:88b8cdf6abf98130991cbc9f6438f35f6e8d41a02622cc5ee130a02a0ed28703"}, + {file = "ruff-0.1.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5c549ed437680b6105a1299d2cd30e4964211606eeb48a0ff7a93ef70b902248"}, + {file = "ruff-0.1.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1cf5f701062e294f2167e66d11b092bba7af6a057668ed618a9253e1e90cfd76"}, + {file = "ruff-0.1.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:05991ee20d4ac4bb78385360c684e4b417edd971030ab12a4fbd075ff535050e"}, + {file = "ruff-0.1.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:87455a0c1f739b3c069e2f4c43b66479a54dea0276dd5d4d67b091265f6fd1dc"}, + {file = "ruff-0.1.6-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:683aa5bdda5a48cb8266fcde8eea2a6af4e5700a392c56ea5fb5f0d4bfdc0240"}, + {file = "ruff-0.1.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:137852105586dcbf80c1717facb6781555c4e99f520c9c827bd414fac67ddfb6"}, + {file = "ruff-0.1.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd98138a98d48a1c36c394fd6b84cd943ac92a08278aa8ac8c0fdefcf7138f35"}, + {file = "ruff-0.1.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a0cd909d25f227ac5c36d4e7e681577275fb74ba3b11d288aff7ec47e3ae745"}, + {file = "ruff-0.1.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e8fd1c62a47aa88a02707b5dd20c5ff20d035d634aa74826b42a1da77861b5ff"}, + {file = "ruff-0.1.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:fd89b45d374935829134a082617954120d7a1470a9f0ec0e7f3ead983edc48cc"}, + {file = "ruff-0.1.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:491262006e92f825b145cd1e52948073c56560243b55fb3b4ecb142f6f0e9543"}, + {file = "ruff-0.1.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:ea284789861b8b5ca9d5443591a92a397ac183d4351882ab52f6296b4fdd5462"}, + {file = "ruff-0.1.6-py3-none-win32.whl", hash = "sha256:1610e14750826dfc207ccbcdd7331b6bd285607d4181df9c1c6ae26646d6848a"}, + {file = "ruff-0.1.6-py3-none-win_amd64.whl", hash = "sha256:4558b3e178145491e9bc3b2ee3c4b42f19d19384eaa5c59d10acf6e8f8b57e33"}, + {file = "ruff-0.1.6-py3-none-win_arm64.whl", hash = "sha256:03910e81df0d8db0e30050725a5802441c2022ea3ae4fe0609b76081731accbc"}, + {file = "ruff-0.1.6.tar.gz", hash = "sha256:1b09f29b16c6ead5ea6b097ef2764b42372aebe363722f1605ecbcd2b9207184"}, +] + +[[package]] +name = "tomli" +version = "2.0.1" +description = "A lil' TOML parser" +optional = false +python-versions = ">=3.7" +files = [ + {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, + {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, +] + +[[package]] +name = "typing-extensions" +version = "4.12.0" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +files = [ + {file = "typing_extensions-4.12.0-py3-none-any.whl", hash = "sha256:b349c66bea9016ac22978d800cfff206d5f9816951f12a7d0ec5578b0a819594"}, + {file = "typing_extensions-4.12.0.tar.gz", hash = "sha256:8cbcdc8606ebcb0d95453ad7dc5065e6237b6aa230a31e81d0f440c30fed5fd8"}, +] + +[[package]] +name = "watchdog" +version = "3.0.0" +description = "Filesystem events monitoring" +optional = false +python-versions = ">=3.7" +files = [ + {file = "watchdog-3.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:336adfc6f5cc4e037d52db31194f7581ff744b67382eb6021c868322e32eef41"}, + {file = "watchdog-3.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a70a8dcde91be523c35b2bf96196edc5730edb347e374c7de7cd20c43ed95397"}, + {file = "watchdog-3.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:adfdeab2da79ea2f76f87eb42a3ab1966a5313e5a69a0213a3cc06ef692b0e96"}, + {file = "watchdog-3.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2b57a1e730af3156d13b7fdddfc23dea6487fceca29fc75c5a868beed29177ae"}, + {file = "watchdog-3.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7ade88d0d778b1b222adebcc0927428f883db07017618a5e684fd03b83342bd9"}, + {file = "watchdog-3.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7e447d172af52ad204d19982739aa2346245cc5ba6f579d16dac4bfec226d2e7"}, + {file = "watchdog-3.0.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:9fac43a7466eb73e64a9940ac9ed6369baa39b3bf221ae23493a9ec4d0022674"}, + {file = "watchdog-3.0.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:8ae9cda41fa114e28faf86cb137d751a17ffd0316d1c34ccf2235e8a84365c7f"}, + {file = "watchdog-3.0.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:25f70b4aa53bd743729c7475d7ec41093a580528b100e9a8c5b5efe8899592fc"}, + {file = "watchdog-3.0.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4f94069eb16657d2c6faada4624c39464f65c05606af50bb7902e036e3219be3"}, + {file = "watchdog-3.0.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7c5f84b5194c24dd573fa6472685b2a27cc5a17fe5f7b6fd40345378ca6812e3"}, + {file = "watchdog-3.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3aa7f6a12e831ddfe78cdd4f8996af9cf334fd6346531b16cec61c3b3c0d8da0"}, + {file = "watchdog-3.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:233b5817932685d39a7896b1090353fc8efc1ef99c9c054e46c8002561252fb8"}, + {file = "watchdog-3.0.0-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:13bbbb462ee42ec3c5723e1205be8ced776f05b100e4737518c67c8325cf6100"}, + {file = "watchdog-3.0.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:8f3ceecd20d71067c7fd4c9e832d4e22584318983cabc013dbf3f70ea95de346"}, + {file = "watchdog-3.0.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:c9d8c8ec7efb887333cf71e328e39cffbf771d8f8f95d308ea4125bf5f90ba64"}, + {file = "watchdog-3.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:0e06ab8858a76e1219e68c7573dfeba9dd1c0219476c5a44d5333b01d7e1743a"}, + {file = "watchdog-3.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:d00e6be486affb5781468457b21a6cbe848c33ef43f9ea4a73b4882e5f188a44"}, + {file = "watchdog-3.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:c07253088265c363d1ddf4b3cdb808d59a0468ecd017770ed716991620b8f77a"}, + {file = "watchdog-3.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:5113334cf8cf0ac8cd45e1f8309a603291b614191c9add34d33075727a967709"}, + {file = "watchdog-3.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:51f90f73b4697bac9c9a78394c3acbbd331ccd3655c11be1a15ae6fe289a8c83"}, + {file = "watchdog-3.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:ba07e92756c97e3aca0912b5cbc4e5ad802f4557212788e72a72a47ff376950d"}, + {file = "watchdog-3.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:d429c2430c93b7903914e4db9a966c7f2b068dd2ebdd2fa9b9ce094c7d459f33"}, + {file = "watchdog-3.0.0-py3-none-win32.whl", hash = "sha256:3ed7c71a9dccfe838c2f0b6314ed0d9b22e77d268c67e015450a29036a81f60f"}, + {file = "watchdog-3.0.0-py3-none-win_amd64.whl", hash = "sha256:4c9956d27be0bb08fc5f30d9d0179a855436e655f046d288e2bcc11adfae893c"}, + {file = "watchdog-3.0.0-py3-none-win_ia64.whl", hash = "sha256:5d9f3a10e02d7371cd929b5d8f11e87d4bad890212ed3901f9b4d68767bee759"}, + {file = "watchdog-3.0.0.tar.gz", hash = "sha256:4d98a320595da7a7c5a18fc48cb633c2e73cda78f93cac2ef42d42bf609a33f9"}, +] + +[package.extras] +watchmedo = ["PyYAML (>=3.10)"] + +[metadata] +lock-version = "2.0" +python-versions = "^3.9.0,<4.0" +content-hash = "5efa2f1ed4bd611a45e5d43d7c3fb907a8fa4447e2d1c30ce26b830411e189dd" diff --git a/libs/cli/pyproject.toml b/libs/cli/pyproject.toml new file mode 100644 index 000000000..dca1782cb --- /dev/null +++ b/libs/cli/pyproject.toml @@ -0,0 +1,53 @@ +[tool.poetry] +name = "langgraph-cli" +version = "0.1.35" +description = "CLI for interacting with LangGraph API" +authors = ["Nuno Campos "] +readme = "README.md" +packages = [{include = "langgraph_cli"}] + +[tool.poetry.scripts] +langgraph = "langgraph_cli.cli:cli" + +[tool.poetry.dependencies] +python = "^3.9.0,<4.0" +click = "^8.1.7" + +[tool.poetry.group.dev.dependencies] +ruff = "^0.1.4" +codespell = "^2.2.0" +pytest = "^7.2.1" +pytest-asyncio = "^0.21.1" +pytest-mock = "^3.11.1" +pytest-watch = "^4.2.0" +mypy = "^1.10.0" + +[tool.pytest.ini_options] +# --strict-markers will raise errors on unknown marks. +# https://docs.pytest.org/en/7.1.x/how-to/mark.html#raising-errors-on-unknown-marks +# +# https://docs.pytest.org/en/7.1.x/reference/reference.html +# --strict-config any warnings encountered while parsing the `pytest` +# section of the configuration file raise errors. +addopts = "--strict-markers --strict-config --durations=5 -vv" +asyncio_mode = "auto" + + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" + +[tool.ruff] +select = [ + # pycodestyle + "E", + # Pyflakes + "F", + # pyupgrade + "UP", + # flake8-bugbear + "B", + # isort + "I", +] +ignore = [ "E501", "B008" ] diff --git a/langgraph/serde/__init__.py b/libs/cli/tests/__init__.py similarity index 100% rename from langgraph/serde/__init__.py rename to libs/cli/tests/__init__.py diff --git a/tests/__init__.py b/libs/cli/tests/unit_tests/__init__.py similarity index 100% rename from tests/__init__.py rename to libs/cli/tests/unit_tests/__init__.py diff --git a/langgraph/py.typed b/libs/cli/tests/unit_tests/agent.py similarity index 100% rename from langgraph/py.typed rename to libs/cli/tests/unit_tests/agent.py diff --git a/tests/checkpoint/__init__.py b/libs/cli/tests/unit_tests/graphs/agent.py similarity index 100% rename from tests/checkpoint/__init__.py rename to libs/cli/tests/unit_tests/graphs/agent.py diff --git a/libs/cli/tests/unit_tests/helpers.py b/libs/cli/tests/unit_tests/helpers.py new file mode 100644 index 000000000..79b67a2c9 --- /dev/null +++ b/libs/cli/tests/unit_tests/helpers.py @@ -0,0 +1,2 @@ +def clean_empty_lines(input_str: str): + return "\n".join(filter(None, input_str.splitlines())) diff --git a/libs/cli/tests/unit_tests/test_cli.py b/libs/cli/tests/unit_tests/test_cli.py new file mode 100644 index 000000000..44f63bb2a --- /dev/null +++ b/libs/cli/tests/unit_tests/test_cli.py @@ -0,0 +1,113 @@ +import pathlib + +from langgraph_cli.cli import prepare_args_and_stdin +from langgraph_cli.config import Config, validate_config +from langgraph_cli.docker import DEFAULT_POSTGRES_URI, DockerCapabilities, Version + +from .helpers import clean_empty_lines + +DEFAULT_DOCKER_CAPABILITIES = DockerCapabilities( + version_docker=Version(26, 1, 1), + version_compose=Version(2, 27, 0), + healthcheck_start_interval=True, +) + + +def test_prepare_args_and_stdin(): + # this basically serves as an end-to-end test for using config and docker helpers + config_path = pathlib.Path("./langgraph.json") + config = validate_config( + Config(dependencies=["."], graphs={"agent": "agent.py:graph"}) + ) + port = 8000 + debugger_port = 8001 + + actual_args, actual_stdin = prepare_args_and_stdin( + capabilities=DEFAULT_DOCKER_CAPABILITIES, + config_path=config_path, + config=config, + docker_compose="custom-docker-compose.yml", + port=port, + debugger_port=debugger_port, + watch=True, + langgraph_api_path="path/to/langgraph-api", + ) + + expected_args = [ + "--project-directory", + ".", + "-f", + "custom-docker-compose.yml", + "-f", + "-", + ] + expected_stdin = f"""volumes: + langgraph-data: + driver: local +services: + langgraph-postgres: + image: postgres:16 + restart: on-failure + ports: + - "5433:5432" + environment: + POSTGRES_DB: postgres + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + volumes: + - langgraph-data:/var/lib/postgresql/data + healthcheck: + test: pg_isready -U postgres + start_period: 10s + timeout: 1s + retries: 5 + interval: 60s + start_interval: 1s + langgraph-debugger: + image: langchain/langgraph-debugger + restart: on-failure + ports: + - "{debugger_port}:80" + depends_on: + langgraph-postgres: + condition: service_healthy + langgraph-api: + restart: on-failure + ports: + - "8000:8000" + depends_on: + langgraph-postgres: + condition: service_healthy + environment: + POSTGRES_URI: {DEFAULT_POSTGRES_URI} + healthcheck: + interval: 60s + start_interval: 1s + start_period: 10s + + pull_policy: build + build: + context: . + dockerfile_inline: | + FROM langchain/langgraph-api:3.11 + ADD . /deps/ + RUN pip install -c /api/constraints.txt -e /deps/* + ENV LANGSERVE_GRAPHS='{{"agent": "agent.py:graph"}}' + WORKDIR /deps/ + + develop: + watch: + - path: langgraph.json + action: rebuild + ignore: + - .langgraph-data + - path: . + action: rebuild + ignore: + - .langgraph-data + - path: path/to/langgraph-api + action: sync+restart + target: /api/langgraph_api\ +""" + assert actual_args == expected_args + assert clean_empty_lines(actual_stdin) == expected_stdin diff --git a/libs/cli/tests/unit_tests/test_config.json b/libs/cli/tests/unit_tests/test_config.json new file mode 100644 index 000000000..b75d747c4 --- /dev/null +++ b/libs/cli/tests/unit_tests/test_config.json @@ -0,0 +1,13 @@ +{ + "python_version": "3.12", + "pip_config_file": "pipconfig.txt", + "dockerfile_lines": ["ARG meow"], + "dependencies": [ + "langchain_openai", + "." + ], + "graphs": { + "agent": "tests/unit_tests/agent.py:graph" + }, + "env": ".env" +} \ No newline at end of file diff --git a/libs/cli/tests/unit_tests/test_config.py b/libs/cli/tests/unit_tests/test_config.py new file mode 100644 index 000000000..9adcbd071 --- /dev/null +++ b/libs/cli/tests/unit_tests/test_config.py @@ -0,0 +1,396 @@ +import os +import pathlib + +import click +import pytest + +from langgraph_cli.config import config_to_compose, config_to_docker, validate_config + +from .helpers import clean_empty_lines + +PATH_TO_CONFIG = pathlib.Path("tests/unit_tests/test_config.json") + + +def test_validate_config(): + # minimal config + expected_config = { + "dependencies": ["."], + "graphs": { + "agent": "./agent.py:graph", + }, + } + expected_config = { + "python_version": "3.11", + "pip_config_file": None, + "dockerfile_lines": [], + "env": {}, + **expected_config, + } + actual_config = validate_config(expected_config) + assert actual_config == expected_config + + # full config + env = ".env" + expected_config = { + "python_version": "3.12", + "pip_config_file": "pipconfig.txt", + "dockerfile_lines": ["ARG meow"], + "dependencies": [".", "langchain"], + "graphs": { + "agent": "./agent.py:graph", + }, + "env": env, + } + actual_config = validate_config(expected_config) + assert actual_config == expected_config + + # check wrong python version raises + with pytest.raises(click.UsageError): + validate_config( + { + "python_version": "3.9", + } + ) + + # check missing dependencies key raises + with pytest.raises(click.UsageError): + validate_config( + {"python_version": "3.9", "graphs": {"agent": "./agent.py:graph"}}, + ) + + # check missing graphs key raises + with pytest.raises(click.UsageError): + validate_config({"python_version": "3.9", "dependencies": ["."]}) + + +# config_to_docker +def test_config_to_docker_simple(): + graphs = {"agent": "./agent.py:graph"} + actual_docker_stdin = config_to_docker( + PATH_TO_CONFIG, validate_config({"dependencies": ["."], "graphs": graphs}) + ) + expected_docker_stdin = """\ +FROM langchain/langgraph-api:3.11 +ADD . /deps/__outer_unit_tests/unit_tests +COPY < - run all tests in file' + @echo 'test_watch - run unit tests in watch mode' diff --git a/langgraph/__init__.py b/libs/langgraph/langgraph/__init__.py similarity index 100% rename from langgraph/__init__.py rename to libs/langgraph/langgraph/__init__.py diff --git a/libs/langgraph/langgraph/_api/__init__.py b/libs/langgraph/langgraph/_api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/langgraph/_api/deprecation.py b/libs/langgraph/langgraph/_api/deprecation.py similarity index 100% rename from langgraph/_api/deprecation.py rename to libs/langgraph/langgraph/_api/deprecation.py diff --git a/langgraph/channels/__init__.py b/libs/langgraph/langgraph/channels/__init__.py similarity index 100% rename from langgraph/channels/__init__.py rename to libs/langgraph/langgraph/channels/__init__.py diff --git a/langgraph/channels/any_value.py b/libs/langgraph/langgraph/channels/any_value.py similarity index 100% rename from langgraph/channels/any_value.py rename to libs/langgraph/langgraph/channels/any_value.py diff --git a/langgraph/channels/base.py b/libs/langgraph/langgraph/channels/base.py similarity index 100% rename from langgraph/channels/base.py rename to libs/langgraph/langgraph/channels/base.py diff --git a/langgraph/channels/binop.py b/libs/langgraph/langgraph/channels/binop.py similarity index 100% rename from langgraph/channels/binop.py rename to libs/langgraph/langgraph/channels/binop.py diff --git a/langgraph/channels/context.py b/libs/langgraph/langgraph/channels/context.py similarity index 100% rename from langgraph/channels/context.py rename to libs/langgraph/langgraph/channels/context.py diff --git a/langgraph/channels/dynamic_barrier_value.py b/libs/langgraph/langgraph/channels/dynamic_barrier_value.py similarity index 100% rename from langgraph/channels/dynamic_barrier_value.py rename to libs/langgraph/langgraph/channels/dynamic_barrier_value.py diff --git a/langgraph/channels/ephemeral_value.py b/libs/langgraph/langgraph/channels/ephemeral_value.py similarity index 100% rename from langgraph/channels/ephemeral_value.py rename to libs/langgraph/langgraph/channels/ephemeral_value.py diff --git a/langgraph/channels/last_value.py b/libs/langgraph/langgraph/channels/last_value.py similarity index 100% rename from langgraph/channels/last_value.py rename to libs/langgraph/langgraph/channels/last_value.py diff --git a/langgraph/channels/manager.py b/libs/langgraph/langgraph/channels/manager.py similarity index 100% rename from langgraph/channels/manager.py rename to libs/langgraph/langgraph/channels/manager.py diff --git a/langgraph/channels/named_barrier_value.py b/libs/langgraph/langgraph/channels/named_barrier_value.py similarity index 100% rename from langgraph/channels/named_barrier_value.py rename to libs/langgraph/langgraph/channels/named_barrier_value.py diff --git a/langgraph/channels/topic.py b/libs/langgraph/langgraph/channels/topic.py similarity index 100% rename from langgraph/channels/topic.py rename to libs/langgraph/langgraph/channels/topic.py diff --git a/langgraph/checkpoint/__init__.py b/libs/langgraph/langgraph/checkpoint/__init__.py similarity index 100% rename from langgraph/checkpoint/__init__.py rename to libs/langgraph/langgraph/checkpoint/__init__.py diff --git a/langgraph/checkpoint/aiosqlite.py b/libs/langgraph/langgraph/checkpoint/aiosqlite.py similarity index 100% rename from langgraph/checkpoint/aiosqlite.py rename to libs/langgraph/langgraph/checkpoint/aiosqlite.py diff --git a/langgraph/checkpoint/base.py b/libs/langgraph/langgraph/checkpoint/base.py similarity index 100% rename from langgraph/checkpoint/base.py rename to libs/langgraph/langgraph/checkpoint/base.py diff --git a/langgraph/checkpoint/id.py b/libs/langgraph/langgraph/checkpoint/id.py similarity index 100% rename from langgraph/checkpoint/id.py rename to libs/langgraph/langgraph/checkpoint/id.py diff --git a/langgraph/checkpoint/memory.py b/libs/langgraph/langgraph/checkpoint/memory.py similarity index 100% rename from langgraph/checkpoint/memory.py rename to libs/langgraph/langgraph/checkpoint/memory.py diff --git a/langgraph/checkpoint/sqlite.py b/libs/langgraph/langgraph/checkpoint/sqlite.py similarity index 100% rename from langgraph/checkpoint/sqlite.py rename to libs/langgraph/langgraph/checkpoint/sqlite.py diff --git a/langgraph/constants.py b/libs/langgraph/langgraph/constants.py similarity index 100% rename from langgraph/constants.py rename to libs/langgraph/langgraph/constants.py diff --git a/langgraph/errors.py b/libs/langgraph/langgraph/errors.py similarity index 100% rename from langgraph/errors.py rename to libs/langgraph/langgraph/errors.py diff --git a/langgraph/graph/__init__.py b/libs/langgraph/langgraph/graph/__init__.py similarity index 100% rename from langgraph/graph/__init__.py rename to libs/langgraph/langgraph/graph/__init__.py diff --git a/langgraph/graph/graph.py b/libs/langgraph/langgraph/graph/graph.py similarity index 100% rename from langgraph/graph/graph.py rename to libs/langgraph/langgraph/graph/graph.py diff --git a/langgraph/graph/message.py b/libs/langgraph/langgraph/graph/message.py similarity index 100% rename from langgraph/graph/message.py rename to libs/langgraph/langgraph/graph/message.py diff --git a/langgraph/graph/state.py b/libs/langgraph/langgraph/graph/state.py similarity index 100% rename from langgraph/graph/state.py rename to libs/langgraph/langgraph/graph/state.py diff --git a/langgraph/managed/__init__.py b/libs/langgraph/langgraph/managed/__init__.py similarity index 100% rename from langgraph/managed/__init__.py rename to libs/langgraph/langgraph/managed/__init__.py diff --git a/langgraph/managed/base.py b/libs/langgraph/langgraph/managed/base.py similarity index 100% rename from langgraph/managed/base.py rename to libs/langgraph/langgraph/managed/base.py diff --git a/langgraph/managed/few_shot.py b/libs/langgraph/langgraph/managed/few_shot.py similarity index 100% rename from langgraph/managed/few_shot.py rename to libs/langgraph/langgraph/managed/few_shot.py diff --git a/langgraph/managed/is_last_step.py b/libs/langgraph/langgraph/managed/is_last_step.py similarity index 100% rename from langgraph/managed/is_last_step.py rename to libs/langgraph/langgraph/managed/is_last_step.py diff --git a/langgraph/prebuilt/__init__.py b/libs/langgraph/langgraph/prebuilt/__init__.py similarity index 100% rename from langgraph/prebuilt/__init__.py rename to libs/langgraph/langgraph/prebuilt/__init__.py diff --git a/langgraph/prebuilt/agent_executor.py b/libs/langgraph/langgraph/prebuilt/agent_executor.py similarity index 100% rename from langgraph/prebuilt/agent_executor.py rename to libs/langgraph/langgraph/prebuilt/agent_executor.py diff --git a/langgraph/prebuilt/chat_agent_executor.py b/libs/langgraph/langgraph/prebuilt/chat_agent_executor.py similarity index 100% rename from langgraph/prebuilt/chat_agent_executor.py rename to libs/langgraph/langgraph/prebuilt/chat_agent_executor.py diff --git a/langgraph/prebuilt/tool_executor.py b/libs/langgraph/langgraph/prebuilt/tool_executor.py similarity index 100% rename from langgraph/prebuilt/tool_executor.py rename to libs/langgraph/langgraph/prebuilt/tool_executor.py diff --git a/langgraph/prebuilt/tool_node.py b/libs/langgraph/langgraph/prebuilt/tool_node.py similarity index 100% rename from langgraph/prebuilt/tool_node.py rename to libs/langgraph/langgraph/prebuilt/tool_node.py diff --git a/langgraph/prebuilt/tool_validator.py b/libs/langgraph/langgraph/prebuilt/tool_validator.py similarity index 100% rename from langgraph/prebuilt/tool_validator.py rename to libs/langgraph/langgraph/prebuilt/tool_validator.py diff --git a/langgraph/pregel/__init__.py b/libs/langgraph/langgraph/pregel/__init__.py similarity index 100% rename from langgraph/pregel/__init__.py rename to libs/langgraph/langgraph/pregel/__init__.py diff --git a/langgraph/pregel/debug.py b/libs/langgraph/langgraph/pregel/debug.py similarity index 100% rename from langgraph/pregel/debug.py rename to libs/langgraph/langgraph/pregel/debug.py diff --git a/langgraph/pregel/io.py b/libs/langgraph/langgraph/pregel/io.py similarity index 100% rename from langgraph/pregel/io.py rename to libs/langgraph/langgraph/pregel/io.py diff --git a/langgraph/pregel/log.py b/libs/langgraph/langgraph/pregel/log.py similarity index 100% rename from langgraph/pregel/log.py rename to libs/langgraph/langgraph/pregel/log.py diff --git a/langgraph/pregel/read.py b/libs/langgraph/langgraph/pregel/read.py similarity index 100% rename from langgraph/pregel/read.py rename to libs/langgraph/langgraph/pregel/read.py diff --git a/langgraph/pregel/retry.py b/libs/langgraph/langgraph/pregel/retry.py similarity index 100% rename from langgraph/pregel/retry.py rename to libs/langgraph/langgraph/pregel/retry.py diff --git a/langgraph/pregel/types.py b/libs/langgraph/langgraph/pregel/types.py similarity index 100% rename from langgraph/pregel/types.py rename to libs/langgraph/langgraph/pregel/types.py diff --git a/langgraph/pregel/validate.py b/libs/langgraph/langgraph/pregel/validate.py similarity index 100% rename from langgraph/pregel/validate.py rename to libs/langgraph/langgraph/pregel/validate.py diff --git a/langgraph/pregel/write.py b/libs/langgraph/langgraph/pregel/write.py similarity index 100% rename from langgraph/pregel/write.py rename to libs/langgraph/langgraph/pregel/write.py diff --git a/libs/langgraph/langgraph/py.typed b/libs/langgraph/langgraph/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/libs/langgraph/langgraph/serde/__init__.py b/libs/langgraph/langgraph/serde/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/langgraph/serde/base.py b/libs/langgraph/langgraph/serde/base.py similarity index 100% rename from langgraph/serde/base.py rename to libs/langgraph/langgraph/serde/base.py diff --git a/langgraph/serde/jsonplus.py b/libs/langgraph/langgraph/serde/jsonplus.py similarity index 100% rename from langgraph/serde/jsonplus.py rename to libs/langgraph/langgraph/serde/jsonplus.py diff --git a/langgraph/utils.py b/libs/langgraph/langgraph/utils.py similarity index 100% rename from langgraph/utils.py rename to libs/langgraph/langgraph/utils.py diff --git a/langgraph/version.py b/libs/langgraph/langgraph/version.py similarity index 100% rename from langgraph/version.py rename to libs/langgraph/langgraph/version.py diff --git a/poetry.lock b/libs/langgraph/poetry.lock similarity index 100% rename from poetry.lock rename to libs/langgraph/poetry.lock diff --git a/poetry.toml b/libs/langgraph/poetry.toml similarity index 100% rename from poetry.toml rename to libs/langgraph/poetry.toml diff --git a/pyproject.toml b/libs/langgraph/pyproject.toml similarity index 100% rename from pyproject.toml rename to libs/langgraph/pyproject.toml diff --git a/libs/langgraph/tests/__init__.py b/libs/langgraph/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/__snapshots__/test_pregel.ambr b/libs/langgraph/tests/__snapshots__/test_pregel.ambr similarity index 100% rename from tests/__snapshots__/test_pregel.ambr rename to libs/langgraph/tests/__snapshots__/test_pregel.ambr diff --git a/tests/__snapshots__/test_pregel_async.ambr b/libs/langgraph/tests/__snapshots__/test_pregel_async.ambr similarity index 100% rename from tests/__snapshots__/test_pregel_async.ambr rename to libs/langgraph/tests/__snapshots__/test_pregel_async.ambr diff --git a/tests/any_str.py b/libs/langgraph/tests/any_str.py similarity index 100% rename from tests/any_str.py rename to libs/langgraph/tests/any_str.py diff --git a/libs/langgraph/tests/checkpoint/__init__.py b/libs/langgraph/tests/checkpoint/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/checkpoint/test_aiosqlite.py b/libs/langgraph/tests/checkpoint/test_aiosqlite.py similarity index 100% rename from tests/checkpoint/test_aiosqlite.py rename to libs/langgraph/tests/checkpoint/test_aiosqlite.py diff --git a/tests/checkpoint/test_memory.py b/libs/langgraph/tests/checkpoint/test_memory.py similarity index 100% rename from tests/checkpoint/test_memory.py rename to libs/langgraph/tests/checkpoint/test_memory.py diff --git a/tests/checkpoint/test_sqlite.py b/libs/langgraph/tests/checkpoint/test_sqlite.py similarity index 100% rename from tests/checkpoint/test_sqlite.py rename to libs/langgraph/tests/checkpoint/test_sqlite.py diff --git a/tests/conftest.py b/libs/langgraph/tests/conftest.py similarity index 100% rename from tests/conftest.py rename to libs/langgraph/tests/conftest.py diff --git a/tests/memory_assert.py b/libs/langgraph/tests/memory_assert.py similarity index 100% rename from tests/memory_assert.py rename to libs/langgraph/tests/memory_assert.py diff --git a/tests/test_channels.py b/libs/langgraph/tests/test_channels.py similarity index 100% rename from tests/test_channels.py rename to libs/langgraph/tests/test_channels.py diff --git a/tests/test_io.py b/libs/langgraph/tests/test_io.py similarity index 100% rename from tests/test_io.py rename to libs/langgraph/tests/test_io.py diff --git a/tests/test_jsonplus.py b/libs/langgraph/tests/test_jsonplus.py similarity index 100% rename from tests/test_jsonplus.py rename to libs/langgraph/tests/test_jsonplus.py diff --git a/tests/test_prebuilt.py b/libs/langgraph/tests/test_prebuilt.py similarity index 100% rename from tests/test_prebuilt.py rename to libs/langgraph/tests/test_prebuilt.py diff --git a/tests/test_pregel.py b/libs/langgraph/tests/test_pregel.py similarity index 100% rename from tests/test_pregel.py rename to libs/langgraph/tests/test_pregel.py diff --git a/tests/test_pregel_async.py b/libs/langgraph/tests/test_pregel_async.py similarity index 100% rename from tests/test_pregel_async.py rename to libs/langgraph/tests/test_pregel_async.py diff --git a/tests/test_state.py b/libs/langgraph/tests/test_state.py similarity index 100% rename from tests/test_state.py rename to libs/langgraph/tests/test_state.py diff --git a/tests/test_utils.py b/libs/langgraph/tests/test_utils.py similarity index 100% rename from tests/test_utils.py rename to libs/langgraph/tests/test_utils.py diff --git a/libs/sdk-js/LICENSE b/libs/sdk-js/LICENSE new file mode 100644 index 000000000..fc0602fee --- /dev/null +++ b/libs/sdk-js/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 LangChain, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/libs/sdk-js/client.cjs b/libs/sdk-js/client.cjs new file mode 100644 index 000000000..680e5bd38 --- /dev/null +++ b/libs/sdk-js/client.cjs @@ -0,0 +1 @@ +module.exports = require('./dist/client.cjs'); \ No newline at end of file diff --git a/libs/sdk-js/client.d.cts b/libs/sdk-js/client.d.cts new file mode 100644 index 000000000..c90177f3b --- /dev/null +++ b/libs/sdk-js/client.d.cts @@ -0,0 +1 @@ +export * from './dist/client.cjs' \ No newline at end of file diff --git a/libs/sdk-js/client.d.ts b/libs/sdk-js/client.d.ts new file mode 100644 index 000000000..ca6f7c421 --- /dev/null +++ b/libs/sdk-js/client.d.ts @@ -0,0 +1 @@ +export * from './dist/client.mjs' \ No newline at end of file diff --git a/libs/sdk-js/client.js b/libs/sdk-js/client.js new file mode 100644 index 000000000..ca6f7c421 --- /dev/null +++ b/libs/sdk-js/client.js @@ -0,0 +1 @@ +export * from './dist/client.mjs' \ No newline at end of file diff --git a/libs/sdk-js/index.cjs b/libs/sdk-js/index.cjs new file mode 100644 index 000000000..c84256f7a --- /dev/null +++ b/libs/sdk-js/index.cjs @@ -0,0 +1 @@ +module.exports = require('./dist/index.cjs'); \ No newline at end of file diff --git a/libs/sdk-js/index.d.cts b/libs/sdk-js/index.d.cts new file mode 100644 index 000000000..4adf1c804 --- /dev/null +++ b/libs/sdk-js/index.d.cts @@ -0,0 +1 @@ +export * from './dist/index.cjs' \ No newline at end of file diff --git a/libs/sdk-js/index.d.ts b/libs/sdk-js/index.d.ts new file mode 100644 index 000000000..307cab014 --- /dev/null +++ b/libs/sdk-js/index.d.ts @@ -0,0 +1 @@ +export * from './dist/index.mjs' \ No newline at end of file diff --git a/libs/sdk-js/index.js b/libs/sdk-js/index.js new file mode 100644 index 000000000..307cab014 --- /dev/null +++ b/libs/sdk-js/index.js @@ -0,0 +1 @@ +export * from './dist/index.mjs' \ No newline at end of file diff --git a/libs/sdk-js/package.json b/libs/sdk-js/package.json new file mode 100644 index 000000000..fd7ee23e3 --- /dev/null +++ b/libs/sdk-js/package.json @@ -0,0 +1,64 @@ +{ + "name": "@langchain/langgraph-sdk", + "version": "0.0.1-rc.11", + "description": "Client library for interacting with the LangGraph API", + "type": "module", + "packageManager": "yarn@1.22.19", + "scripts": { + "clean": "rm -rf dist/ && node scripts/create-entrypoints.js clean", + "build": "yarn clean && yarn build:esm && yarn build:cjs && node scripts/create-entrypoints.js", + "build:esm": "rm -f src/package.json && tsc --outDir dist/ && rm -rf dist/tests dist/**/tests", + "build:cjs": "echo '{}' > src/package.json && tsc --outDir dist-cjs/ -p tsconfig.cjs.json && node scripts/move-cjs-to-dist.js && rm -r dist-cjs src/package.json", + "prepublish": "yarn run build", + "format": "prettier --write src", + "lint": "prettier --check src && tsc --noEmit" + }, + "main": "index.js", + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.15", + "eventsource-parser": "^1.1.2", + "p-queue": "^6.6.2", + "p-retry": "4", + "uuid": "^9.0.0" + }, + "devDependencies": { + "@tsconfig/recommended": "^1.0.2", + "@types/node": "^20.12.12", + "@types/uuid": "^9.0.1", + "prettier": "^3.2.5", + "typescript": "^5.4.5" + }, + "exports": { + ".": { + "types": { + "import": "./index.d.ts", + "require": "./index.d.cts", + "default": "./index.d.ts" + }, + "import": "./index.js", + "require": "./index.cjs" + }, + "./client": { + "types": { + "import": "./client.d.ts", + "require": "./client.d.cts", + "default": "./client.d.ts" + }, + "import": "./client.js", + "require": "./client.cjs" + }, + "./package.json": "./package.json" + }, + "files": [ + "dist/", + "client.cjs", + "client.js", + "client.d.ts", + "client.d.cts", + "index.cjs", + "index.js", + "index.d.ts", + "index.d.cts" + ] +} diff --git a/libs/sdk-js/scripts/create-entrypoints.js b/libs/sdk-js/scripts/create-entrypoints.js new file mode 100644 index 000000000..da14512f9 --- /dev/null +++ b/libs/sdk-js/scripts/create-entrypoints.js @@ -0,0 +1,115 @@ +import * as fs from "fs"; +import * as path from "path"; + +// This lists all the entrypoints for the library. Each key corresponds to an +// importable path, eg. `import { Foo } from "langgraph-sdk/client"`. +// The value is the path to the file in `src/` that exports the entrypoint. +// This is used to generate the `exports` field in package.json. +// Order is not important. +const entrypoints = { client: "client" }; + +const updateJsonFile = (relativePath, updateFunction) => { + const contents = fs.readFileSync(relativePath).toString(); + const res = updateFunction(JSON.parse(contents)); + fs.writeFileSync(relativePath, JSON.stringify(res, null, 2) + "\n"); +}; + +const generateFiles = () => { + const files = [...Object.entries(entrypoints), ["index", "index"]].flatMap( + ([key, value]) => { + const nrOfDots = key.split("/").length - 1; + const relativePath = "../".repeat(nrOfDots) || "./"; + const compiledPath = `${relativePath}dist/${value}`; + return [ + [`${key}.cjs`, `module.exports = require('${compiledPath}.cjs');`], + [`${key}.js`, `export * from '${compiledPath}.mjs'`], + [`${key}.d.ts`, `export * from '${compiledPath}.mjs'`], + [`${key}.d.cts`, `export * from '${compiledPath}.cjs'`], + ]; + }, + ); + + return Object.fromEntries(files); +}; + +const updateConfig = () => { + // Update tsconfig.json `typedocOptions.entryPoints` field + updateJsonFile("./tsconfig.json", (json) => ({ + ...json, + typedocOptions: { + ...json.typedocOptions, + entryPoints: [...Object.keys(entrypoints)].map( + (key) => `src/${entrypoints[key]}.ts`, + ), + }, + })); + + const generatedFiles = generateFiles(); + const filenames = Object.keys(generatedFiles); + + // Update package.json `exports` and `files` fields + updateJsonFile("./package.json", (json) => ({ + ...json, + exports: Object.assign( + Object.fromEntries( + ["index", ...Object.keys(entrypoints)].map((key) => { + let entryPoint = { + types: { + import: `./${key}.d.ts`, + require: `./${key}.d.cts`, + default: `./${key}.d.ts`, + }, + import: `./${key}.js`, + require: `./${key}.cjs`, + }; + + return [key === "index" ? "." : `./${key}`, entryPoint]; + }), + ), + { + "./package.json": "./package.json", + }, + ), + files: ["dist/", ...filenames], + })); + + // Write generated files + Object.entries(generatedFiles).forEach(([filename, content]) => { + fs.mkdirSync(path.dirname(filename), { + recursive: true, + }); + fs.writeFileSync(filename, content); + }); + + const gitignore = fs.readFileSync("./.gitignore").toString(); + const lines = gitignore.split("\n"); + const startMarker = "## GENERATED create-entrypoints.js"; + const endMarker = "## END GENERATED create-entrypoints.js"; + const startIdx = lines.findIndex((line) => line.includes(startMarker)); + const endIdx = lines.findIndex((line) => line.includes(endMarker)); + const newLines = [ + ...lines.slice(0, startIdx + 1), + ...filenames.map((fname) => `/${fname}`), + ...lines.slice(endIdx), + ]; + fs.writeFileSync("./.gitignore", newLines.join("\n")); +}; + +const cleanGenerated = () => { + const filenames = Object.keys(generateFiles()); + filenames.forEach((fname) => { + try { + fs.unlinkSync(fname); + } catch { + // ignore error + } + }); +}; + +const command = process.argv[2]; + +if (command === "clean") { + cleanGenerated(); +} else { + updateConfig(); +} diff --git a/libs/sdk-js/scripts/move-cjs-to-dist.js b/libs/sdk-js/scripts/move-cjs-to-dist.js new file mode 100644 index 000000000..1e89ccca8 --- /dev/null +++ b/libs/sdk-js/scripts/move-cjs-to-dist.js @@ -0,0 +1,38 @@ +import { resolve, dirname, parse, format } from "node:path"; +import { readdir, readFile, writeFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; + +function abs(relativePath) { + return resolve(dirname(fileURLToPath(import.meta.url)), relativePath); +} + +async function moveAndRename(source, dest) { + for (const file of await readdir(abs(source), { withFileTypes: true })) { + if (file.isDirectory()) { + await moveAndRename(`${source}/${file.name}`, `${dest}/${file.name}`); + } else if (file.isFile()) { + const parsed = parse(file.name); + + // Ignore anything that's not a .js file + if (parsed.ext !== ".js") { + continue; + } + + // Rewrite any require statements to use .cjs + const content = await readFile(abs(`${source}/${file.name}`), "utf8"); + const rewritten = content.replace(/require\("(\..+?).js"\)/g, (_, p1) => { + return `require("${p1}.cjs")`; + }); + + // Rename the file to .cjs + const renamed = format({ name: parsed.name, ext: ".cjs" }); + + await writeFile(abs(`${dest}/${renamed}`), rewritten, "utf8"); + } + } +} + +moveAndRename("../dist-cjs", "../dist").catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/libs/sdk-js/src/client.mts b/libs/sdk-js/src/client.mts new file mode 100644 index 000000000..3311daba5 --- /dev/null +++ b/libs/sdk-js/src/client.mts @@ -0,0 +1,713 @@ +import { + Assistant, + AssistantGraph, + Config, + DefaultValues, + GraphSchema, + Metadata, + Run, + RunEvent, + Thread, + ThreadState, +} from "./schema.js"; +import { AsyncCaller, AsyncCallerParams } from "./utils/async_caller.mjs"; +import { EventSourceParser, createParser } from "eventsource-parser"; +import { IterableReadableStream } from "./utils/stream.mjs"; +import { + RunsCreatePayload, + RunsStreamPayload, + RunsWaitPayload, + StreamEvent, +} from "./types.mjs"; + +interface ClientConfig { + apiUrl?: string; + callerOptions?: AsyncCallerParams; + timeoutMs?: number; + defaultHeaders?: Record; +} + +class BaseClient { + protected asyncCaller: AsyncCaller; + + protected timeoutMs: number; + + protected apiUrl: string; + + protected defaultHeaders: Record; + + constructor(config?: ClientConfig) { + this.asyncCaller = new AsyncCaller({ + maxRetries: 4, + maxConcurrency: 4, + ...config?.callerOptions, + }); + + this.timeoutMs = config?.timeoutMs || 12_000; + this.apiUrl = config?.apiUrl || "http://localhost:8123"; + this.defaultHeaders = config?.defaultHeaders || {}; + } + + protected prepareFetchOptions( + path: string, + options?: RequestInit & { + json?: unknown; + params?: Record; + }, + ): [url: URL, init: RequestInit] { + const mutatedOptions = { + ...options, + headers: { ...this.defaultHeaders, ...options?.headers }, + }; + + if (mutatedOptions.json) { + mutatedOptions.body = JSON.stringify(mutatedOptions.json); + mutatedOptions.headers = { + ...mutatedOptions.headers, + "Content-Type": "application/json", + }; + delete mutatedOptions.json; + } + + const targetUrl = new URL(`${this.apiUrl}${path}`); + + if (mutatedOptions.params) { + for (const [key, value] of Object.entries(mutatedOptions.params)) { + if (value == null) continue; + + let strValue = + typeof value === "string" || typeof value === "number" + ? value.toString() + : JSON.stringify(value); + + targetUrl.searchParams.append(key, strValue); + } + delete mutatedOptions.params; + } + + return [targetUrl, mutatedOptions]; + } + + protected async fetch( + path: string, + options?: RequestInit & { + json?: unknown; + params?: Record; + }, + ): Promise { + const response = await this.asyncCaller.fetch( + ...this.prepareFetchOptions(path, options), + ); + if (response.status == 202) { + return undefined as T; + } + return response.json() as T; + } +} + +class AssistantsClient extends BaseClient { + /** + * Get an assistant by ID. + * + * @param assistantId The ID of the assistant. + * @returns Assistant + */ + async get(assistantId: string): Promise { + return this.fetch(`/assistants/${assistantId}`); + } + + /** + * Get the JSON representation of the graph assigned to a runnable + * @param assistantId The ID of the assistant. + * @returns Serialized graph + */ + async getGraph(assistantId: string): Promise { + return this.fetch(`/assistants/${assistantId}/graph`); + } + + /** + * Get the state and config schema of the graph assigned to a runnable + * @param assistantId The ID of the assistant. + * @returns Graph schema + */ + async getSchemas(assistantId: string): Promise { + return this.fetch(`/assistants/${assistantId}/schemas`); + } + + /** + * Create a new assistant. + * @param payload Payload for creating an assistant. + * @returns The created assistant. + */ + async create(payload: { + graphId: string; + config?: Config; + metadata?: Metadata; + }): Promise { + return this.fetch("/assistants", { + method: "POST", + json: { + graph_id: payload.graphId, + config: payload.config, + metadata: payload.metadata, + }, + }); + } + + /** + * Update an assistant. + * @param assistantId ID of the assistant. + * @param payload Payload for updating the assistant. + * @returns The updated assistant. + */ + async update( + assistantId: string, + payload: { + graphId: string; + config?: Config; + metadata?: Metadata; + }, + ): Promise { + return this.fetch(`/assistants/${assistantId}`, { + method: "PATCH", + json: { + graph_id: payload.graphId, + config: payload.config, + metadata: payload.metadata, + }, + }); + } + + /** + * Delete an assistant. + * + * @param assistantId ID of the assistant. + */ + async delete(assistantId: string): Promise { + return this.fetch(`/assistants/${assistantId}`, { + method: "DELETE", + }); + } + + /** + * List assistants. + * @param query Query options. + * @returns List of assistants. + */ + async search(query?: { + metadata?: Metadata; + limit?: number; + offset?: number; + }): Promise { + return this.fetch("/assistants/search", { + method: "POST", + json: { + metadata: query?.metadata ?? undefined, + limit: query?.limit ?? 10, + offset: query?.offset ?? 0, + }, + }); + } +} + +class ThreadsClient extends BaseClient { + /** + * Get a thread by ID. + * + * @param threadId ID of the thread. + * @returns The thread. + */ + async get(threadId: string): Promise { + return this.fetch(`/threads/${threadId}`); + } + + /** + * Create a new thread. + * + * @param payload Payload for creating a thread. + * @returns The created thread. + */ + async create(payload?: { + /** + * Metadata for the thread. + */ + metadata?: Metadata; + }): Promise { + return this.fetch(`/threads`, { + method: "POST", + json: { metadata: payload?.metadata }, + }); + } + + /** + * Update a thread. + * + * @param threadId ID of the thread. + * @param payload Payload for updating the thread. + * @returns The updated thread. + */ + async update( + threadId: string, + payload?: { + /** + * Metadata for the thread. + */ + metadata?: Metadata; + }, + ): Promise { + return this.fetch(`/threads/${threadId}`, { + method: "PATCH", + json: { metadata: payload?.metadata }, + }); + } + + /** + * Delete a thread. + * + * @param threadId ID of the thread. + */ + async delete(threadId: string): Promise { + return this.fetch(`/threads/${threadId}`, { + method: "DELETE", + }); + } + + /** + * List threads + * + * @param query Query options + * @returns List of threads + */ + async search(query?: { + /** + * Metadata to filter threads by. + */ + metadata?: Metadata; + /** + * Maximum number of threads to return. + * Defaults to 10 + */ + limit?: number; + /** + * Offset to start from. + */ + offset?: number; + }): Promise { + return this.fetch("/threads/search", { + method: "POST", + json: { + metadata: query?.metadata ?? undefined, + limit: query?.limit ?? 10, + offset: query?.offset ?? 0, + }, + }); + } + + /** + * Get state for a thread. + * + * @param threadId ID of the thread. + * @returns Thread state. + */ + async getState( + threadId: string, + checkpointId?: string, + ): Promise> { + return this.fetch>( + checkpointId != null + ? `/threads/${threadId}/state/${checkpointId}` + : `/threads/${threadId}/state`, + ); + } + + /** + * Add state to a thread. + * + * @param threadId The ID of the thread. + * @returns + */ + async updateState( + threadId: string, + options: { values: ValuesType; checkpointId?: string; asNode?: string }, + ): Promise { + return this.fetch(`/threads/${threadId}/state`, { + method: "POST", + json: { + values: options.values, + checkpoint_id: options.checkpointId, + as_node: options?.asNode, + }, + }); + } + + /** + * Patch the metadata of a thread. + * + * @param threadIdOrConfig Thread ID or config to patch the state of. + * @param metadata Metadata to patch the state with. + */ + async patchState( + threadIdOrConfig: string | Config, + metadata: Metadata, + ): Promise { + let threadId: string; + + if (typeof threadIdOrConfig !== "string") { + if (typeof threadIdOrConfig.configurable.thread_id !== "string") { + throw new Error( + "Thread ID is required when updating state with a config.", + ); + } + threadId = threadIdOrConfig.configurable.thread_id; + } else { + threadId = threadIdOrConfig; + } + + return this.fetch(`/threads/${threadId}/state`, { + method: "PATCH", + json: { metadata: metadata }, + }); + } + + /** + * Get all past states for a thread. + * + * @param threadId ID of the thread. + * @param options Additional options. + * @returns List of thread states. + */ + async getHistory( + threadId: string, + options?: { + limit?: number; + before?: Config; + metadata?: Metadata; + }, + ): Promise[]> { + return this.fetch[]>( + `/threads/${threadId}/history`, + { + method: "POST", + json: { + limit: options?.limit ?? 10, + before: options?.before, + metadata: options?.metadata, + }, + }, + ); + } +} + +class RunsClient extends BaseClient { + stream( + threadId: null, + assistantId: string, + payload?: Omit, + ): AsyncGenerator<{ + event: StreamEvent; + data: any; + }>; + + stream( + threadId: string, + assistantId: string, + payload?: RunsStreamPayload, + ): AsyncGenerator<{ + event: StreamEvent; + data: any; + }>; + + /** + * Create a run and stream the results. + * + * @param threadId The ID of the thread. + * @param assistantId Assistant ID to use for this run. + * @param payload Payload for creating a run. + */ + async *stream( + threadId: string | null, + assistantId: string, + payload?: RunsStreamPayload, + ): AsyncGenerator<{ + event: StreamEvent; + // TODO: figure out a better way to + // type this without any + data: any; + }> { + const json: Record = { + input: payload?.input, + config: payload?.config, + metadata: payload?.metadata, + stream_mode: payload?.streamMode, + feedback_keys: payload?.feedbackKeys, + assistant_id: assistantId, + interrupt_before: payload?.interruptBefore, + interrupt_after: payload?.interruptAfter, + }; + if (payload?.multitaskStrategy != null) { + json["multitask_strategy"] = payload?.multitaskStrategy; + } + + const endpoint = + threadId == null ? `/runs/stream` : `/threads/${threadId}/runs/stream`; + const response = await this.asyncCaller.fetch( + ...this.prepareFetchOptions(endpoint, { + method: "POST", + json, + signal: payload?.signal, + }), + ); + + let parser: EventSourceParser; + const textDecoder = new TextDecoder(); + + const stream: ReadableStream<{ event: string; data: any }> = ( + response.body || new ReadableStream({ start: (ctrl) => ctrl.close() }) + ).pipeThrough( + new TransformStream({ + async start(ctrl) { + parser = createParser((event) => { + if ( + (payload?.signal && payload.signal.aborted) || + (event.type === "event" && event.data === "[DONE]") + ) { + ctrl.terminate(); + return; + } + + if ("data" in event) { + ctrl.enqueue({ + event: event.event ?? "message", + data: JSON.parse(event.data), + }); + } + }); + }, + async transform(chunk) { + parser.feed(textDecoder.decode(chunk)); + }, + }), + ); + + yield* IterableReadableStream.fromReadableStream(stream); + } + + /** + * Create a run. + * + * @param threadId The ID of the thread. + * @param assistantId Assistant ID to use for this run. + * @param payload Payload for creating a run. + * @returns The created run. + */ + async create( + threadId: string, + assistantId: string, + payload?: RunsCreatePayload, + ): Promise { + const json: Record = { + input: payload?.input, + config: payload?.config, + metadata: payload?.metadata, + assistant_id: assistantId, + interrupt_before: payload?.interruptBefore, + interrupt_after: payload?.interruptAfter, + webhook: payload?.webhook, + }; + if (payload?.multitaskStrategy != null) { + json["multitask_strategy"] = payload?.multitaskStrategy; + } + return this.fetch(`/threads/${threadId}/runs`, { + method: "POST", + json, + signal: payload?.signal, + }); + } + + async wait( + threadId: null, + assistantId: string, + payload?: Omit, + ): Promise; + + async wait( + threadId: string, + assistantId: string, + payload?: RunsWaitPayload, + ): Promise; + + /** + * Create a run and wait for it to complete. + * + * @param threadId The ID of the thread. + * @param assistantId Assistant ID to use for this run. + * @param payload Payload for creating a run. + * @returns The last values chunk of the thread. + */ + async wait( + threadId: string | null, + assistantId: string, + payload?: RunsWaitPayload, + ): Promise { + const json: Record = { + input: payload?.input, + config: payload?.config, + metadata: payload?.metadata, + assistant_id: assistantId, + interrupt_before: payload?.interruptBefore, + interrupt_after: payload?.interruptAfter, + }; + if (payload?.multitaskStrategy != null) { + json["multitask_strategy"] = payload?.multitaskStrategy; + } + const endpoint = + threadId == null ? `/runs/wait` : `/threads/${threadId}/runs/wait`; + return this.fetch(endpoint, { + method: "POST", + json, + signal: payload?.signal, + }); + } + + /** + * List all runs for a thread. + * + * @param threadId The ID of the thread. + * @param options Filtering and pagination options. + * @returns List of runs. + */ + async list( + threadId: string, + options?: { + /** + * Maximum number of runs to return. + * Defaults to 10 + */ + limit?: number; + + /** + * Offset to start from. + * Defaults to 0. + */ + offset?: number; + }, + ): Promise { + return this.fetch(`/threads/${threadId}/runs`, { + params: { + limit: options?.limit ?? 10, + offset: options?.offset ?? 0, + }, + }); + } + + /** + * Get a run by ID. + * + * @param threadId The ID of the thread. + * @param runId The ID of the run. + * @returns The run. + */ + async get(threadId: string, runId: string): Promise { + return this.fetch(`/threads/${threadId}/runs/${runId}`); + } + + /** + * Cancel a run. + * + * @param threadId The ID of the thread. + * @param runId The ID of the run. + * @param wait Whether to block when canceling + * @returns + */ + async cancel( + threadId: string, + runId: string, + wait: boolean = false, + ): Promise { + return this.fetch(`/threads/${threadId}/runs/${runId}/cancel`, { + method: "POST", + params: { + wait: wait ? "1" : "0", + }, + }); + } + + /** + * Block until a run is done. + * + * @param threadId The ID of the thread. + * @param runId The ID of the run. + * @returns + */ + async join(threadId: string, runId: string): Promise { + return this.fetch(`/threads/${threadId}/runs/${runId}/join`); + } + + /** + * List all events for a run. + * + * @param threadId The ID of the thread. + * @param runId The ID of the run. + * @param options Filtering and pagination options. + * @returns List of events. + */ + async listEvents( + threadId: string, + runId: string, + options?: { + /** + * Maximum number of events to return. + * Defaults to 10 + */ + limit?: number; + /** + * Offset to start from. + * Defaults to 0. + */ + offset?: number; + }, + ): Promise { + return this.fetch(`/threads/${threadId}/runs/${runId}/events`, { + params: { + limit: options?.limit ?? 10, + offset: options?.offset ?? 0, + }, + }); + } + + /** + * Delete a run. + * + * @param threadId The ID of the thread. + * @param runId The ID of the run. + * @returns + */ + async delete(threadId: string, runId: string): Promise { + return this.fetch(`/threads/${threadId}/runs/${runId}`, { + method: "DELETE", + }); + } +} + +export class Client { + /** + * The client for interacting with assistants. + */ + public assistants: AssistantsClient; + + /** + * The client for interacting with threads. + */ + public threads: ThreadsClient; + + /** + * The client for interacting with runs. + */ + public runs: RunsClient; + + constructor(config?: ClientConfig) { + this.assistants = new AssistantsClient(config); + this.threads = new ThreadsClient(config); + this.runs = new RunsClient(config); + } +} diff --git a/libs/sdk-js/src/index.mts b/libs/sdk-js/src/index.mts new file mode 100644 index 000000000..b420f7df8 --- /dev/null +++ b/libs/sdk-js/src/index.mts @@ -0,0 +1,14 @@ +export { Client } from "./client.mjs"; + +export type { + Assistant, + AssistantGraph, + Config, + DefaultValues, + GraphSchema, + Metadata, + Run, + RunEvent, + Thread, + ThreadState, +} from "./schema.js"; diff --git a/libs/sdk-js/src/schema.ts b/libs/sdk-js/src/schema.ts new file mode 100644 index 000000000..b8d16cd2a --- /dev/null +++ b/libs/sdk-js/src/schema.ts @@ -0,0 +1,108 @@ +import type { JSONSchema7 } from "json-schema"; + +type Optional = T | null | undefined; + +export interface Config { + /** + * Tags for this call and any sub-calls (eg. a Chain calling an LLM). + * You can use these to filter calls. + */ + tags?: string[]; + + /** + * Maximum number of times a call can recurse. + * If not provided, defaults to 25. + */ + recursion_limit?: number; + + /** + * Runtime values for attributes previously made configurable on this Runnable. + */ + configurable: { + /** + * ID of the thread + */ + thread_id?: string; + + /** + * Timestamp of the state checkpoint + */ + thread_ts?: string; + [key: string]: unknown; + }; +} + +export interface GraphSchema { + /** + * The ID of the graph. + */ + graph_id: string; + + /** + * The schema for the graph state + */ + state_schema: JSONSchema7; + + /** + * The schema for the graph config + */ + config_schema: JSONSchema7; +} + +export type Metadata = Optional>; + +export interface Assistant { + assistant_id: string; + graph_id: string; + config: Config; + created_at: string; + updated_at: string; + metadata: Metadata; +} +export type AssistantGraph = Record>>; + +export interface Thread { + thread_id: string; + created_at: string; + updated_at: string; + metadata: Metadata; +} + +export type DefaultValues = Record[] | Record; + +export interface ThreadState { + values: ValuesType; + next: string[]; + checkpoint_id: string; + metadata: Metadata; + created_at: Optional; + parent_checkpoint_id: Optional; +} + +export interface Run { + run_id: string; + thread_id: string; + assistant_id: string; + created_at: string; + updated_at: string; + status: + | "pending" + | "running" + | "error" + | "success" + | "timeout" + | "interrupted"; + metadata: Metadata; +} + +export interface RunEvent { + event_id: string; + run_id: string; + received_at: string; + span_id: string; + event: string; + name: string; + data: Record; + metadata: Record; + tags: string[]; +} diff --git a/libs/sdk-js/src/types.mts b/libs/sdk-js/src/types.mts new file mode 100644 index 000000000..c5ddaa5b4 --- /dev/null +++ b/libs/sdk-js/src/types.mts @@ -0,0 +1,86 @@ +import { Config, Metadata } from "./schema.js"; + +export type StreamMode = "values" | "messages" | "updates" | "events" | "debug"; +export type MultitaskStrategy = "reject" | "interrupt" | "rollback" | "enqueue"; +export type StreamEvent = + | "events" + | "metadata" + | "debug" + | "updates" + | "values" + | "messages/partial" + | "messages/metadata" + | "messages/complete" + | (string & {}); + +interface RunsInvokePayload { + /** + * Input to the run. Pass `null` to resume from the current state of the thread. + */ + input?: Record | null; + + /** + * Metadata for the run. + */ + metadata?: Metadata; + + /** + * Additional configuration for the run. + */ + config?: Config; + + /** + * Interrupt execution before entering these nodes. + */ + interruptBefore?: string[]; + + /** + * Interrupt execution after leaving these nodes. + */ + interruptAfter?: string[]; + + /** + * Strategy to handle concurrent runs on the same thread. Only relevant if + * there is a pending/inflight run on the same thread. One of: + * - "reject": Reject the new run. + * - "interrupt": Interrupt the current run, keeping steps completed until now, + and start a new one. + * - "rollback": Cancel and delete the existing run, rolling back the thread to + the state before it had started, then start the new run. + * - "enqueue": Queue up the new run to start after the current run finishes. + */ + multitaskStrategy?: MultitaskStrategy; + + /** + * Abort controller signal to cancel the run. + */ + signal?: AbortController["signal"]; +} + +export interface RunsStreamPayload extends RunsInvokePayload { + /** + * One of `"values"`, `"messages"`, `"updates"` or `"events"`. + * - `"values"`: Stream the thread state any time it changes. + * - `"messages"`: Stream chat messages from thread state and calls to chat models, + * token-by-token where possible. + * - `"updates"`: Stream the state updates returned by each node. + * - `"events"`: Stream all events produced by the run. You can also access these + * afterwards using the `client.runs.listEvents()` method. + */ + streamMode?: StreamMode | Array; + + /** + * Pass one or more feedbackKeys if you want to request short-lived signed URLs + * for submitting feedback to LangSmith with this key for this run. + */ + feedbackKeys?: string[]; +} + +export interface RunsCreatePayload extends RunsInvokePayload { + /** + * Webhook to call when the run is complete. + */ + webhook?: string; +} + +export type RunsWaitPayload = RunsStreamPayload; diff --git a/libs/sdk-js/src/utils/async_caller.mts b/libs/sdk-js/src/utils/async_caller.mts new file mode 100644 index 000000000..1774b9609 --- /dev/null +++ b/libs/sdk-js/src/utils/async_caller.mts @@ -0,0 +1,206 @@ +import pRetry from "p-retry"; +import PQueueMod from "p-queue"; + +const STATUS_NO_RETRY = [ + 400, // Bad Request + 401, // Unauthorized + 403, // Forbidden + 404, // Not Found + 405, // Method Not Allowed + 406, // Not Acceptable + 407, // Proxy Authentication Required + 408, // Request Timeout + 422, // Unprocessable Entity +]; +const STATUS_IGNORE = [ + 409, // Conflict +]; + +type ResponseCallback = (response?: Response) => Promise; + +export interface AsyncCallerParams { + /** + * The maximum number of concurrent calls that can be made. + * Defaults to `Infinity`, which means no limit. + */ + maxConcurrency?: number; + /** + * The maximum number of retries that can be made for a single call, + * with an exponential backoff between each attempt. Defaults to 6. + */ + maxRetries?: number; + + onFailedResponseHook?: ResponseCallback; +} + +export interface AsyncCallerCallOptions { + signal?: AbortSignal; +} + +/** + * Do not rely on globalThis.Response, rather just + * do duck typing + */ +function isResponse(x: unknown): x is Response { + if (x == null || typeof x !== "object") return false; + return "status" in x && "statusText" in x && "text" in x; +} + +/** + * Utility error to properly handle failed requests + */ +class HTTPError extends Error { + status: number; + text: string; + + response?: Response; + + constructor(status: number, message: string, response?: Response) { + super(`HTTP ${status}: ${message}`); + this.status = status; + this.text = message; + this.response = response; + } + + static async fromResponse( + response: Response, + options?: { includeResponse?: boolean }, + ): Promise { + try { + return new HTTPError( + response.status, + await response.text(), + options?.includeResponse ? response : undefined, + ); + } catch { + return new HTTPError( + response.status, + response.statusText, + options?.includeResponse ? response : undefined, + ); + } + } +} + +/** + * A class that can be used to make async calls with concurrency and retry logic. + * + * This is useful for making calls to any kind of "expensive" external resource, + * be it because it's rate-limited, subject to network issues, etc. + * + * Concurrent calls are limited by the `maxConcurrency` parameter, which defaults + * to `Infinity`. This means that by default, all calls will be made in parallel. + * + * Retries are limited by the `maxRetries` parameter, which defaults to 5. This + * means that by default, each call will be retried up to 5 times, with an + * exponential backoff between each attempt. + */ +export class AsyncCaller { + protected maxConcurrency: AsyncCallerParams["maxConcurrency"]; + + protected maxRetries: AsyncCallerParams["maxRetries"]; + + private queue: (typeof import("p-queue"))["default"]["prototype"]; + + private onFailedResponseHook?: ResponseCallback; + + constructor(params: AsyncCallerParams) { + this.maxConcurrency = params.maxConcurrency ?? Infinity; + this.maxRetries = params.maxRetries ?? 4; + + if ("default" in PQueueMod) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + this.queue = new (PQueueMod.default as any)({ + concurrency: this.maxConcurrency, + }); + } else { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + this.queue = new (PQueueMod as any)({ concurrency: this.maxConcurrency }); + } + this.onFailedResponseHook = params?.onFailedResponseHook; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + call Promise>( + callable: T, + ...args: Parameters + ): Promise>> { + const onFailedResponseHook = this.onFailedResponseHook; + return this.queue.add( + () => + pRetry( + () => + callable(...(args as Parameters)).catch(async (error) => { + // eslint-disable-next-line no-instanceof/no-instanceof + if (error instanceof Error) { + throw error; + } else if (isResponse(error)) { + throw await HTTPError.fromResponse(error, { + includeResponse: !!onFailedResponseHook, + }); + } else { + throw new Error(error); + } + }), + { + async onFailedAttempt(error) { + if ( + error.message.startsWith("Cancel") || + error.message.startsWith("TimeoutError") || + error.message.startsWith("AbortError") + ) { + throw error; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if ((error as any)?.code === "ECONNABORTED") { + throw error; + } + + if (error instanceof HTTPError) { + if (STATUS_NO_RETRY.includes(error.status)) { + throw error; + } else if (STATUS_IGNORE.includes(error.status)) { + return; + } + if (onFailedResponseHook && error.response) { + await onFailedResponseHook(error.response); + } + } + }, + // If needed we can change some of the defaults here, + // but they're quite sensible. + retries: this.maxRetries, + randomize: true, + }, + ), + { throwOnTimeout: true }, + ); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + callWithOptions Promise>( + options: AsyncCallerCallOptions, + callable: T, + ...args: Parameters + ): Promise>> { + // Note this doesn't cancel the underlying request, + // when available prefer to use the signal option of the underlying call + if (options.signal) { + return Promise.race([ + this.call(callable, ...args), + new Promise((_, reject) => { + options.signal?.addEventListener("abort", () => { + reject(new Error("AbortError")); + }); + }), + ]); + } + return this.call(callable, ...args); + } + + fetch(...args: Parameters): ReturnType { + return this.call(() => + fetch(...args).then((res) => (res.ok ? res : Promise.reject(res))), + ); + } +} diff --git a/libs/sdk-js/src/utils/stream.mts b/libs/sdk-js/src/utils/stream.mts new file mode 100644 index 000000000..c921ddf51 --- /dev/null +++ b/libs/sdk-js/src/utils/stream.mts @@ -0,0 +1,109 @@ +// in this case don't quite match. +type IterableReadableStreamInterface = ReadableStream & AsyncIterable; + +/* + * Support async iterator syntax for ReadableStreams in all environments. + * Source: https://github.com/MattiasBuelens/web-streams-polyfill/pull/122#issuecomment-1627354490 + */ +export class IterableReadableStream + extends ReadableStream + implements IterableReadableStreamInterface +{ + public reader: ReadableStreamDefaultReader; + + ensureReader() { + if (!this.reader) { + this.reader = this.getReader(); + } + } + + async next(): Promise> { + this.ensureReader(); + try { + const result = await this.reader.read(); + if (result.done) { + this.reader.releaseLock(); // release lock when stream becomes closed + return { + done: true, + value: undefined, + }; + } else { + return { + done: false, + value: result.value, + }; + } + } catch (e) { + this.reader.releaseLock(); // release lock when stream becomes errored + throw e; + } + } + + async return(): Promise> { + this.ensureReader(); + // If wrapped in a Node stream, cancel is already called. + if (this.locked) { + const cancelPromise = this.reader.cancel(); // cancel first, but don't await yet + this.reader.releaseLock(); // release lock first + await cancelPromise; // now await it + } + return { done: true, value: undefined }; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async throw(e: any): Promise> { + this.ensureReader(); + if (this.locked) { + const cancelPromise = this.reader.cancel(); // cancel first, but don't await yet + this.reader.releaseLock(); // release lock first + await cancelPromise; // now await it + } + throw e; + } + + [Symbol.asyncIterator]() { + return this; + } + + static fromReadableStream(stream: ReadableStream) { + // From https://developer.mozilla.org/en-US/docs/Web/API/Streams_API/Using_readable_streams#reading_the_stream + const reader = stream.getReader(); + return new IterableReadableStream({ + start(controller) { + return pump(); + function pump(): Promise { + return reader.read().then(({ done, value }) => { + // When no more data needs to be consumed, close the stream + if (done) { + controller.close(); + return; + } + // Enqueue the next data chunk into our target stream + controller.enqueue(value); + return pump(); + }); + } + }, + cancel() { + reader.releaseLock(); + }, + }); + } + + static fromAsyncGenerator(generator: AsyncGenerator) { + return new IterableReadableStream({ + async pull(controller) { + const { value, done } = await generator.next(); + // When no more data needs to be consumed, close the stream + if (done) { + controller.close(); + } + // Fix: `else if (value)` will hang the streaming when nullish value (e.g. empty string) is pulled + controller.enqueue(value); + }, + async cancel(reason) { + await generator.return(reason); + }, + }); + } +} diff --git a/libs/sdk-js/tsconfig.cjs.json b/libs/sdk-js/tsconfig.cjs.json new file mode 100644 index 000000000..7e091b100 --- /dev/null +++ b/libs/sdk-js/tsconfig.cjs.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": false + }, + "exclude": ["node_modules", "dist", "**/tests"] +} diff --git a/libs/sdk-js/tsconfig.json b/libs/sdk-js/tsconfig.json new file mode 100644 index 000000000..355f38ad2 --- /dev/null +++ b/libs/sdk-js/tsconfig.json @@ -0,0 +1,26 @@ +{ + "extends": "@tsconfig/recommended", + "compilerOptions": { + "target": "ES2021", + "lib": ["ES2021", "ES2022.Object", "DOM"], + "module": "NodeNext", + "moduleResolution": "nodenext", + "esModuleInterop": true, + "declaration": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "useDefineForClassFields": true, + "strictPropertyInitialization": false, + "allowJs": true, + "strict": true, + "outDir": "dist" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "coverage"], + "includeVersion": true, + "typedocOptions": { + "entryPoints": ["src/client.ts"] + } +} diff --git a/libs/sdk-js/yarn.lock b/libs/sdk-js/yarn.lock new file mode 100644 index 000000000..b49c759a3 --- /dev/null +++ b/libs/sdk-js/yarn.lock @@ -0,0 +1,93 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@tsconfig/recommended@^1.0.2": + version "1.0.6" + resolved "https://registry.yarnpkg.com/@tsconfig/recommended/-/recommended-1.0.6.tgz#217b78f9601215939d566a79d202a760ae185114" + integrity sha512-0IKu9GHYF1NGTJiYgfWwqnOQSlnE9V9R7YohHNNf0/fj/SyOZWzdd06JFr0fLpg1Mqw0kGbYg8w5xdkSqLKM9g== + +"@types/json-schema@^7.0.15": + version "7.0.15" + resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" + integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== + +"@types/node@^20.12.12": + version "20.12.12" + resolved "https://registry.yarnpkg.com/@types/node/-/node-20.12.12.tgz#7cbecdf902085cec634fdb362172dfe12b8f2050" + integrity sha512-eWLDGF/FOSPtAvEqeRAQ4C8LSA7M1I7i0ky1I8U7kD1J5ITyW3AsRhQrKVoWf5pFKZ2kILsEGJhsI9r93PYnOw== + dependencies: + undici-types "~5.26.4" + +"@types/retry@0.12.0": + version "0.12.0" + resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.0.tgz#2b35eccfcee7d38cd72ad99232fbd58bffb3c84d" + integrity sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA== + +"@types/uuid@^9.0.1": + version "9.0.8" + resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-9.0.8.tgz#7545ba4fc3c003d6c756f651f3bf163d8f0f29ba" + integrity sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA== + +eventemitter3@^4.0.4: + version "4.0.7" + resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f" + integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw== + +eventsource-parser@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/eventsource-parser/-/eventsource-parser-1.1.2.tgz#ed6154a4e3dbe7cda9278e5e35d2ffc58b309f89" + integrity sha512-v0eOBUbiaFojBu2s2NPBfYUoRR9GjcDNvCXVaqEf5vVfpIAh9f8RCo4vXTP8c63QRKCFwoLpMpTdPwwhEKVgzA== + +p-finally@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae" + integrity sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow== + +p-queue@^6.6.2: + version "6.6.2" + resolved "https://registry.yarnpkg.com/p-queue/-/p-queue-6.6.2.tgz#2068a9dcf8e67dd0ec3e7a2bcb76810faa85e426" + integrity sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ== + dependencies: + eventemitter3 "^4.0.4" + p-timeout "^3.2.0" + +p-retry@4: + version "4.6.2" + resolved "https://registry.yarnpkg.com/p-retry/-/p-retry-4.6.2.tgz#9baae7184057edd4e17231cee04264106e092a16" + integrity sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ== + dependencies: + "@types/retry" "0.12.0" + retry "^0.13.1" + +p-timeout@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/p-timeout/-/p-timeout-3.2.0.tgz#c7e17abc971d2a7962ef83626b35d635acf23dfe" + integrity sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg== + dependencies: + p-finally "^1.0.0" + +prettier@^3.2.5: + version "3.2.5" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.2.5.tgz#e52bc3090586e824964a8813b09aba6233b28368" + integrity sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A== + +retry@^0.13.1: + version "0.13.1" + resolved "https://registry.yarnpkg.com/retry/-/retry-0.13.1.tgz#185b1587acf67919d63b357349e03537b2484658" + integrity sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg== + +typescript@^5.4.5: + version "5.4.5" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.4.5.tgz#42ccef2c571fdbd0f6718b1d1f5e6e5ef006f611" + integrity sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ== + +undici-types@~5.26.4: + version "5.26.5" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617" + integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA== + +uuid@^9.0.0: + version "9.0.1" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.1.tgz#e188d4c8853cc722220392c424cd637f32293f30" + integrity sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA== diff --git a/libs/sdk-py/LICENSE b/libs/sdk-py/LICENSE new file mode 100644 index 000000000..fc0602fee --- /dev/null +++ b/libs/sdk-py/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 LangChain, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/libs/sdk-py/Makefile b/libs/sdk-py/Makefile new file mode 100644 index 000000000..d3707c2f8 --- /dev/null +++ b/libs/sdk-py/Makefile @@ -0,0 +1,10 @@ +.PHONY: lint format + +lint: + poetry run ruff check . + poetry run ruff format . --diff + poetry run mypy . + +format: + poetry run ruff format . + poetry run ruff check --select I --fix . diff --git a/libs/sdk-py/README.md b/libs/sdk-py/README.md new file mode 100644 index 000000000..66351bf01 --- /dev/null +++ b/libs/sdk-py/README.md @@ -0,0 +1 @@ +# langgraph diff --git a/libs/sdk-py/langgraph_sdk/__init__.py b/libs/sdk-py/langgraph_sdk/__init__.py new file mode 100644 index 000000000..4af615310 --- /dev/null +++ b/libs/sdk-py/langgraph_sdk/__init__.py @@ -0,0 +1,10 @@ +from langgraph_sdk.client import get_client + +try: + from importlib import metadata + + __version__ = metadata.version(__package__) +except metadata.PackageNotFoundError: + __version__ = "unknown" + +__all__ = ["get_client"] diff --git a/libs/sdk-py/langgraph_sdk/client.py b/libs/sdk-py/langgraph_sdk/client.py new file mode 100644 index 000000000..d07094c61 --- /dev/null +++ b/libs/sdk-py/langgraph_sdk/client.py @@ -0,0 +1,610 @@ +from __future__ import annotations + +import asyncio +import logging +import os +import sys +from typing import Any, AsyncIterator, Dict, List, NamedTuple, Optional, Union, overload + +import httpx +import httpx_sse +import orjson +from httpx._types import QueryParamTypes + +import langgraph_sdk +from langgraph_sdk.schema import ( + Assistant, + Config, + GraphSchema, + Metadata, + MultitaskStrategy, + Run, + RunEvent, + StreamMode, + Thread, + ThreadState, +) + +logger = logging.getLogger(__name__) + + +def get_client( + *, url: str = "http://localhost:8123", api_key: Optional[str] = None +) -> LangGraphClient: + """Get a LangGraphClient instance. + + Args: + url (str, optional): The URL of the LangGraph API. Defaults to "http://localhost:8123". + api_key (str, optional): The API key. If not provided, it will be read from the environment. + Precedence: + 1. explicit argument + 2. LANGGRAPH_API_KEY + 3. LANGSMITH_API_KEY + 4. LANGCHAIN_API_KEY + """ + headers = { + "User-Agent": f"langgraph-sdk-py/{langgraph_sdk.__version__}", + } + api_key = _get_api_key(api_key) + if api_key: + headers["x-api-key"] = api_key + client = httpx.AsyncClient( + base_url=url, + transport=httpx.AsyncHTTPTransport(retries=5), + timeout=httpx.Timeout(connect=5, read=60, write=60, pool=5), + headers=headers, + ) + return LangGraphClient(client) + + +class StreamPart(NamedTuple): + event: str + data: dict + + +class LangGraphClient: + def __init__(self, client: httpx.AsyncClient) -> None: + self.http = HttpClient(client) + self.assistants = AssistantsClient(self.http) + self.threads = ThreadsClient(self.http) + self.runs = RunsClient(self.http) + + +class HttpClient: + def __init__(self, client: httpx.AsyncClient) -> None: + self.client = client + + async def get(self, path: str, *, params: Optional[QueryParamTypes] = None) -> Any: + """Make a GET request.""" + r = await self.client.get(path, params=params) + try: + r.raise_for_status() + except httpx.HTTPStatusError as e: + body = (await r.aread()).decode() + if sys.version_info >= (3, 11): + e.add_note(body) + else: + logger.error(f"Error from langgraph-api: {body}", exc_info=e) + raise e + return await decode_json(r) + + async def post(self, path: str, *, json: Optional[dict]) -> Any: + """Make a POST request.""" + if json is not None: + headers, content = await encode_json(json) + else: + headers, content = {}, b"" + r = await self.client.post(path, headers=headers, content=content) + try: + r.raise_for_status() + except httpx.HTTPStatusError as e: + body = (await r.aread()).decode() + if sys.version_info >= (3, 11): + e.add_note(body) + else: + logger.error(f"Error from langgraph-api: {body}", exc_info=e) + raise e + return await decode_json(r) + + async def put(self, path: str, *, json: dict) -> Any: + """Make a PUT request.""" + headers, content = await encode_json(json) + r = await self.client.put(path, headers=headers, content=content) + try: + r.raise_for_status() + except httpx.HTTPStatusError as e: + body = (await r.aread()).decode() + if sys.version_info >= (3, 11): + e.add_note(body) + else: + logger.error(f"Error from langgraph-api: {body}", exc_info=e) + raise e + return await decode_json(r) + + async def patch(self, path: str, *, json: dict) -> Any: + """Make a PATCH request.""" + headers, content = await encode_json(json) + r = await self.client.patch(path, headers=headers, content=content) + try: + r.raise_for_status() + except httpx.HTTPStatusError as e: + body = (await r.aread()).decode() + if sys.version_info >= (3, 11): + e.add_note(body) + else: + logger.error(f"Error from langgraph-api: {body}", exc_info=e) + raise e + return await decode_json(r) + + async def delete(self, path: str) -> None: + """Make a DELETE request.""" + r = await self.client.delete(path) + try: + r.raise_for_status() + except httpx.HTTPStatusError as e: + body = (await r.aread()).decode() + if sys.version_info >= (3, 11): + e.add_note(body) + else: + logger.error(f"Error from langgraph-api: {body}", exc_info=e) + raise e + + async def stream( + self, path: str, method: str, *, json: Optional[dict] = None + ) -> AsyncIterator[StreamPart]: + """Stream the results of a request using SSE.""" + headers, content = await encode_json(json) + async with httpx_sse.aconnect_sse( + self.client, method, path, headers=headers, content=content + ) as sse: + try: + sse.response.raise_for_status() + except httpx.HTTPStatusError as e: + body = (await sse.response.aread()).decode() + if sys.version_info >= (3, 11): + e.add_note(body) + else: + logger.error(f"Error from langgraph-api: {body}", exc_info=e) + raise e + async for event in sse.aiter_sse(): + yield StreamPart( + event.event, orjson.loads(event.data) if event.data else None + ) + + +def _orjson_default(obj: Any) -> Any: + if hasattr(obj, "model_dump") and callable(obj.model_dump): + return obj.model_dump() + elif hasattr(obj, "dict") and callable(obj.dict): + return obj.dict() + elif isinstance(obj, (set, frozenset)): + return list(obj) + else: + raise TypeError(f"Object of type {type(obj)} is not JSON serializable") + + +async def encode_json(json: Any) -> tuple[dict[str, str], bytes]: + body = await asyncio.get_running_loop().run_in_executor( + None, + orjson.dumps, + json, + _orjson_default, + orjson.OPT_SERIALIZE_NUMPY | orjson.OPT_NON_STR_KEYS, + ) + content_length = str(len(body)) + content_type = "application/json" + headers = {"Content-Length": content_length, "Content-Type": content_type} + return headers, body + + +async def decode_json(r: httpx.Response) -> Any: + body = await r.aread() + return ( + await asyncio.get_running_loop().run_in_executor(None, orjson.loads, body) + if body + else None + ) + + +class AssistantsClient: + def __init__(self, http: HttpClient) -> None: + self.http = http + + async def get(self, assistant_id: str) -> Assistant: + """Get an assistant by ID.""" + return await self.http.get(f"/assistants/{assistant_id}") + + async def get_graph(self, assistant_id: str) -> dict[str, list[dict[str, Any]]]: + """Get the graph of an assistant by ID.""" + return await self.http.get(f"/assistants/{assistant_id}/graph") + + async def get_schemas(self, assistant_id: str) -> GraphSchema: + """Get the schemas of an assistant by ID.""" + return await self.http.get(f"/assistants/{assistant_id}/schemas") + + async def create( + self, + graph_id: Optional[str], + config: Optional[Config] = None, + *, + metadata: Metadata = None, + assistant_id: Optional[str] = None, + ) -> Assistant: + """Create a new assistant.""" + payload = { + "metadata": metadata, + "graph_id": graph_id, + "config": config or {}, + } + if assistant_id: + payload["assistant_id"] = assistant_id + return await self.http.post("/assistants", json=payload) + + async def update( + self, + assistant_id: str, + *, + graph_id: Optional[str] = None, + config: Optional[Config] = None, + metadata: Metadata = None, + ) -> Assistant: + """Update an assistant.""" + return await self.http.patch( + f"/assistants/{assistant_id}", + json={"metadata": metadata, "graph_id": graph_id, "config": config}, + ) + + async def delete( + self, + assistant_id: str, + ) -> None: + """Delete an assistant.""" + await self.http.delete(f"/assistants/{assistant_id}") + + async def search( + self, *, metadata: Metadata = None, limit: int = 10, offset: int = 0 + ) -> list[Assistant]: + """Search for assistants.""" + return await self.http.post( + "/assistants/search", + json={"metadata": metadata, "limit": limit, "offset": offset}, + ) + + +class ThreadsClient: + def __init__(self, http: HttpClient) -> None: + self.http = http + + async def get(self, thread_id: str) -> Thread: + """Get a thread by ID.""" + return await self.http.get(f"/threads/{thread_id}") + + async def create( + self, + *, + metadata: Metadata = None, + thread_id: Optional[str] = None, + ) -> Thread: + """Create a new thread.""" + payload: Dict[str, Any] = {"metadata": metadata} + if thread_id: + payload["thread_id"] = thread_id + return await self.http.post("/threads", json=payload) + + async def update(self, thread_id: str, *, metadata: Metadata = None) -> Thread: + """Update a thread.""" + return await self.http.patch( + f"/threads/{thread_id}", json={"metadata": metadata} + ) + + async def delete(self, thread_id: str) -> None: + """Delete a thread.""" + await self.http.delete(f"/threads/{thread_id}") + + async def search( + self, *, metadata: Metadata = None, limit: int = 10, offset: int = 0 + ) -> list[Thread]: + """Search for threads.""" + return await self.http.post( + "/threads/search", + json={"metadata": metadata, "limit": limit, "offset": offset}, + ) + + async def get_state( + self, thread_id: str, checkpoint_id: Optional[str] = None + ) -> ThreadState: + """Get the state of a thread.""" + if checkpoint_id: + return await self.http.get(f"/threads/{thread_id}/state/{checkpoint_id}") + else: + return await self.http.get(f"/threads/{thread_id}/state") + + async def update_state( + self, + thread_id: str, + values: dict, + *, + as_node: Optional[str] = None, + checkpoint_id: Optional[str] = None, + ) -> None: + """Update the state of a thread.""" + return await self.http.post( + f"/threads/{thread_id}/state", + json={"values": values, "checkpoint_id": checkpoint_id, "as_node": as_node}, + ) + + async def patch_state( + self, + thread_id: Union[str, Config], + metadata: dict, + ) -> None: + """Patch the state of a thread.""" + if isinstance(thread_id, dict): + thread_id_: str = thread_id["configurable"]["thread_id"] + else: + thread_id_ = thread_id + return await self.http.patch( + f"/threads/{thread_id_}/state", + json={"metadata": metadata}, + ) + + async def get_history( + self, + thread_id: str, + limit: int = 10, + before: Optional[str] = None, + metadata: Optional[dict] = None, + ) -> list[ThreadState]: + """Get the history of a thread.""" + return await self.http.post( + f"/threads/{thread_id}/history", + json={"limit": limit, "before": before, "metadata": metadata}, + ) + + +class RunsClient: + def __init__(self, http: HttpClient) -> None: + self.http = http + + @overload + def stream( + self, + thread_id: str, + assistant_id: str, + *, + input: Optional[dict] = None, + stream_mode: Union[StreamMode, list[StreamMode]] = "values", + metadata: Optional[dict] = None, + config: Optional[Config] = None, + checkpoint_id: Optional[str] = None, + interrupt_before: Optional[list[str]] = None, + interrupt_after: Optional[list[str]] = None, + feedback_keys: Optional[list[str]] = None, + multitask_strategy: Optional[MultitaskStrategy] = None, + ) -> AsyncIterator[StreamPart]: + ... + + @overload + def stream( + self, + thread_id: None, + assistant_id: str, + *, + input: Optional[dict] = None, + stream_mode: Union[StreamMode, list[StreamMode]] = "values", + metadata: Optional[dict] = None, + config: Optional[Config] = None, + interrupt_before: Optional[list[str]] = None, + interrupt_after: Optional[list[str]] = None, + feedback_keys: Optional[list[str]] = None, + ) -> AsyncIterator[StreamPart]: + ... + + def stream( + self, + thread_id: Optional[str], + assistant_id: str, + *, + input: Optional[dict] = None, + stream_mode: Union[StreamMode, list[StreamMode]] = "values", + metadata: Optional[dict] = None, + config: Optional[Config] = None, + checkpoint_id: Optional[str] = None, + interrupt_before: Optional[list[str]] = None, + interrupt_after: Optional[list[str]] = None, + feedback_keys: Optional[list[str]] = None, + multitask_strategy: Optional[MultitaskStrategy] = None, + ) -> AsyncIterator[StreamPart]: + """Create a run and stream the results.""" + payload = { + "input": input, + "config": config, + "metadata": metadata, + "stream_mode": stream_mode, + "assistant_id": assistant_id, + "interrupt_before": interrupt_before, + "interrupt_after": interrupt_after, + "feedback_keys": feedback_keys, + "checkpoint_id": checkpoint_id, + } + if multitask_strategy: + payload["multitask_strategy"] = multitask_strategy + + endpoint = ( + f"/threads/{thread_id}/runs/stream" + if thread_id is not None + else "/runs/stream" + ) + return self.http.stream(endpoint, "POST", json=payload) + + @overload + async def create( + self, + thread_id: None, + assistant_id: str, + *, + input: Optional[dict] = None, + metadata: Optional[dict] = None, + config: Optional[Config] = None, + interrupt_before: Optional[list[str]] = None, + interrupt_after: Optional[list[str]] = None, + webhook: Optional[str] = None, + ) -> Run: + ... + + @overload + async def create( + self, + thread_id: str, + assistant_id: str, + *, + input: Optional[dict] = None, + metadata: Optional[dict] = None, + config: Optional[Config] = None, + checkpoint_id: Optional[str] = None, + interrupt_before: Optional[list[str]] = None, + interrupt_after: Optional[list[str]] = None, + webhook: Optional[str] = None, + multitask_strategy: Optional[MultitaskStrategy] = None, + ) -> Run: + ... + + async def create( + self, + thread_id: Optional[str], + assistant_id: str, + *, + input: Optional[dict] = None, + metadata: Optional[dict] = None, + config: Optional[Config] = None, + checkpoint_id: Optional[str] = None, + interrupt_before: Optional[list[str]] = None, + interrupt_after: Optional[list[str]] = None, + webhook: Optional[str] = None, + multitask_strategy: Optional[MultitaskStrategy] = None, + ) -> Run: + """Create a background run.""" + payload = { + "input": input, + "config": config, + "metadata": metadata, + "assistant_id": assistant_id, + "interrupt_before": interrupt_before, + "interrupt_after": interrupt_after, + "webhook": webhook, + "checkpoint_id": checkpoint_id, + } + if multitask_strategy: + payload["multitask_strategy"] = multitask_strategy + if thread_id: + return await self.http.post(f"/threads/{thread_id}/runs", json=payload) + else: + return await self.http.post("/runs", json=payload) + + @overload + async def wait( + self, + thread_id: str, + assistant_id: str, + *, + input: Optional[dict] = None, + metadata: Optional[dict] = None, + config: Optional[Config] = None, + checkpoint_id: Optional[str] = None, + interrupt_before: Optional[list[str]] = None, + interrupt_after: Optional[list[str]] = None, + multitask_strategy: Optional[MultitaskStrategy] = None, + ) -> Union[list[dict], dict[str, Any]]: + ... + + @overload + async def wait( + self, + thread_id: None, + assistant_id: str, + *, + input: Optional[dict] = None, + metadata: Optional[dict] = None, + config: Optional[Config] = None, + interrupt_before: Optional[list[str]] = None, + interrupt_after: Optional[list[str]] = None, + ) -> Union[list[dict], dict[str, Any]]: + ... + + async def wait( + self, + thread_id: Optional[str], + assistant_id: str, + *, + input: Optional[dict] = None, + metadata: Optional[dict] = None, + config: Optional[Config] = None, + checkpoint_id: Optional[str] = None, + interrupt_before: Optional[list[str]] = None, + interrupt_after: Optional[list[str]] = None, + multitask_strategy: Optional[MultitaskStrategy] = None, + ) -> Union[list[dict], dict[str, Any]]: + """Create a run, wait for and return the final state.""" + payload = { + "input": input, + "config": config, + "metadata": metadata, + "assistant_id": assistant_id, + "interrupt_before": interrupt_before, + "interrupt_after": interrupt_after, + "checkpoint_id": checkpoint_id, + } + if multitask_strategy: + payload["multitask_strategy"] = multitask_strategy + + endpoint = ( + f"/threads/{thread_id}/runs/wait" if thread_id is not None else "/runs/wait" + ) + return await self.http.post(endpoint, json=payload) + + async def list( + self, thread_id: str, *, limit: int = 10, offset: int = 0 + ) -> List[Run]: + """List runs.""" + return await self.http.get(f"/threads/{thread_id}/runs") + + async def get(self, thread_id: str, run_id: str) -> Run: + """Get a run.""" + return await self.http.get(f"/threads/{thread_id}/runs/{run_id}") + + async def cancel(self, thread_id: str, run_id: str, *, wait: bool = False) -> None: + """Get a run.""" + return await self.http.post( + f"/threads/{thread_id}/runs/{run_id}/cancel?wait={1 if wait else 0}", + json=None, + ) + + async def join(self, thread_id: str, run_id: str) -> None: + """Block until a run is done.""" + return await self.http.get(f"/threads/{thread_id}/runs/{run_id}/join") + + async def list_events( + self, thread_id: str, run_id: str, *, limit: int = 10, offset: int = 0 + ) -> List[RunEvent]: + """List run events.""" + return await self.http.get(f"/threads/{thread_id}/runs/{run_id}/events") + + async def delete(self, thread_id: str, run_id: str) -> None: + """Delete a run.""" + await self.http.delete(f"/threads/{thread_id}/runs/{run_id}") + + +def _get_api_key(api_key: Optional[str] = None) -> Optional[str]: + """Get the API key from the environment. + Precedence: + 1. explicit argument + 2. LANGGRAPH_API_KEY + 3. LANGSMITH_API_KEY + 4. LANGCHAIN_API_KEY + """ + if api_key: + return api_key + for prefix in ["LANGGRAPH", "LANGSMITH", "LANGCHAIN"]: + if env := os.getenv(f"{prefix}_API_KEY"): + return env.strip().strip('"').strip("'") + return None # type: ignore diff --git a/libs/sdk-py/langgraph_sdk/schema.py b/libs/sdk-py/langgraph_sdk/schema.py new file mode 100644 index 000000000..dc1097183 --- /dev/null +++ b/libs/sdk-py/langgraph_sdk/schema.py @@ -0,0 +1,128 @@ +from datetime import datetime +from typing import Any, Literal, Optional, Sequence, TypedDict, Union + +Metadata = Optional[dict[str, Any]] + +RunStatus = Literal["pending", "running", "error", "success", "timeout", "interrupted"] + +StreamMode = Literal["values", "messages", "updates", "events", "debug"] + +MultitaskStrategy = Literal["reject", "interrupt", "rollback", "enqueue"] + +All = Literal["*"] + + +class Config(TypedDict, total=False): + tags: list[str] + """ + Tags for this call and any sub-calls (eg. a Chain calling an LLM). + You can use these to filter calls. + """ + + recursion_limit: int + """ + Maximum number of times a call can recurse. If not provided, defaults to 25. + """ + + configurable: dict[str, Any] + """ + Runtime values for attributes previously made configurable on this Runnable, + or sub-Runnables, through .configurable_fields() or .configurable_alternatives(). + Check .output_schema() for a description of the attributes that have been made + configurable. + """ + + +class GraphSchema(TypedDict): + """Graph model.""" + + graph_id: str + """The ID of the graph.""" + state_schema: dict + """The schema for the graph state.""" + config_schema: dict + """The schema for the graph config.""" + + +class Assistant(TypedDict): + """Assistant model.""" + + assistant_id: str + """The ID of the assistant.""" + graph_id: str + """The ID of the graph.""" + config: Config + """The assistant config.""" + created_at: datetime + """The time the assistant was created.""" + updated_at: datetime + """The last time the assistant was updated.""" + metadata: Metadata + """The assistant metadata.""" + + +class Thread(TypedDict): + thread_id: str + """The ID of the thread.""" + created_at: datetime + """The time the thread was created.""" + updated_at: datetime + """The last time the thread was updated.""" + metadata: Metadata + """The thread metadata.""" + multitask_strategy: MultitaskStrategy + """The multitask strategy for this thread.""" + + +class ThreadState(TypedDict): + values: Union[list[dict], dict[str, Any]] + """The state values.""" + next: Sequence[str] + """The next nodes to execute. If empty, the thread is done until new input is + received.""" + checkpoint_id: str + """The ID of the checkpoint.""" + metadata: Metadata + """Metadata for this state""" + created_at: Optional[str] + """Timestamp of state creation""" + parent_checkpoint_id: Optional[str] + """The ID of the parent checkpoint. If missing, this is the root checkpoint.""" + + +class Run(TypedDict): + run_id: str + """The ID of the run.""" + thread_id: str + """The ID of the thread.""" + assistant_id: str + """The assistant that was used for this run.""" + created_at: datetime + """The time the run was created.""" + updated_at: datetime + """The last time the run was updated.""" + status: RunStatus + """The status of the run. One of 'pending', 'running', 'error', 'success'.""" + metadata: Metadata + """The run metadata.""" + + +class RunEvent(TypedDict): + event_id: str + """The ID of the event.""" + run_id: str + """The ID of the run.""" + received_at: datetime + """The time the event was received.""" + span_id: str + """The ID of the span.""" + event: str + """The event type.""" + name: str + """The event name.""" + data: dict + """The event data.""" + metadata: dict + """The event metadata.""" + tags: list[str] + """The event tags.""" diff --git a/libs/sdk-py/poetry.lock b/libs/sdk-py/poetry.lock new file mode 100644 index 000000000..9f714f5de --- /dev/null +++ b/libs/sdk-py/poetry.lock @@ -0,0 +1,492 @@ +# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. + +[[package]] +name = "anyio" +version = "4.3.0" +description = "High level compatibility layer for multiple asynchronous event loop implementations" +optional = false +python-versions = ">=3.8" +files = [ + {file = "anyio-4.3.0-py3-none-any.whl", hash = "sha256:048e05d0f6caeed70d731f3db756d35dcc1f35747c8c403364a8332c630441b8"}, + {file = "anyio-4.3.0.tar.gz", hash = "sha256:f75253795a87df48568485fd18cdd2a3fa5c4f7c5be8e5e36637733fce06fed6"}, +] + +[package.dependencies] +exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} +idna = ">=2.8" +sniffio = ">=1.1" +typing-extensions = {version = ">=4.1", markers = "python_version < \"3.11\""} + +[package.extras] +doc = ["Sphinx (>=7)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] +test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"] +trio = ["trio (>=0.23)"] + +[[package]] +name = "certifi" +version = "2024.2.2" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.6" +files = [ + {file = "certifi-2024.2.2-py3-none-any.whl", hash = "sha256:dc383c07b76109f368f6106eee2b593b04a011ea4d55f652c6ca24a754d1cdd1"}, + {file = "certifi-2024.2.2.tar.gz", hash = "sha256:0569859f95fc761b18b45ef421b1290a0f65f147e92a1e5eb3e635f9a5e4e66f"}, +] + +[[package]] +name = "codespell" +version = "2.2.6" +description = "Codespell" +optional = false +python-versions = ">=3.8" +files = [ + {file = "codespell-2.2.6-py3-none-any.whl", hash = "sha256:9ee9a3e5df0990604013ac2a9f22fa8e57669c827124a2e961fe8a1da4cacc07"}, + {file = "codespell-2.2.6.tar.gz", hash = "sha256:a8c65d8eb3faa03deabab6b3bbe798bea72e1799c7e9e955d57eca4096abcff9"}, +] + +[package.extras] +dev = ["Pygments", "build", "chardet", "pre-commit", "pytest", "pytest-cov", "pytest-dependency", "ruff", "tomli", "twine"] +hard-encoding-detection = ["chardet"] +toml = ["tomli"] +types = ["chardet (>=5.1.0)", "mypy", "pytest", "pytest-cov", "pytest-dependency"] + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "docopt" +version = "0.6.2" +description = "Pythonic argument parser, that will make you smile" +optional = false +python-versions = "*" +files = [ + {file = "docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"}, +] + +[[package]] +name = "exceptiongroup" +version = "1.2.1" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +files = [ + {file = "exceptiongroup-1.2.1-py3-none-any.whl", hash = "sha256:5258b9ed329c5bbdd31a309f53cbfb0b155341807f6ff7606a1e801a891b29ad"}, + {file = "exceptiongroup-1.2.1.tar.gz", hash = "sha256:a4785e48b045528f5bfe627b6ad554ff32def154f42372786903b7abcfe1aa16"}, +] + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "h11" +version = "0.14.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +optional = false +python-versions = ">=3.7" +files = [ + {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, + {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, +] + +[[package]] +name = "httpcore" +version = "1.0.5" +description = "A minimal low-level HTTP client." +optional = false +python-versions = ">=3.8" +files = [ + {file = "httpcore-1.0.5-py3-none-any.whl", hash = "sha256:421f18bac248b25d310f3cacd198d55b8e6125c107797b609ff9b7a6ba7991b5"}, + {file = "httpcore-1.0.5.tar.gz", hash = "sha256:34a38e2f9291467ee3b44e89dd52615370e152954ba21721378a87b2960f7a61"}, +] + +[package.dependencies] +certifi = "*" +h11 = ">=0.13,<0.15" + +[package.extras] +asyncio = ["anyio (>=4.0,<5.0)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +trio = ["trio (>=0.22.0,<0.26.0)"] + +[[package]] +name = "httpx" +version = "0.27.0" +description = "The next generation HTTP client." +optional = false +python-versions = ">=3.8" +files = [ + {file = "httpx-0.27.0-py3-none-any.whl", hash = "sha256:71d5465162c13681bff01ad59b2cc68dd838ea1f10e51574bac27103f00c91a5"}, + {file = "httpx-0.27.0.tar.gz", hash = "sha256:a0cb88a46f32dc874e04ee956e4c2764aba2aa228f650b06788ba6bda2962ab5"}, +] + +[package.dependencies] +anyio = "*" +certifi = "*" +httpcore = "==1.*" +idna = "*" +sniffio = "*" + +[package.extras] +brotli = ["brotli", "brotlicffi"] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] + +[[package]] +name = "httpx-sse" +version = "0.4.0" +description = "Consume Server-Sent Event (SSE) messages with HTTPX." +optional = false +python-versions = ">=3.8" +files = [ + {file = "httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721"}, + {file = "httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f"}, +] + +[[package]] +name = "idna" +version = "3.7" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.5" +files = [ + {file = "idna-3.7-py3-none-any.whl", hash = "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0"}, + {file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"}, +] + +[[package]] +name = "iniconfig" +version = "2.0.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.7" +files = [ + {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, + {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, +] + +[[package]] +name = "mypy" +version = "1.10.0" +description = "Optional static typing for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "mypy-1.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:da1cbf08fb3b851ab3b9523a884c232774008267b1f83371ace57f412fe308c2"}, + {file = "mypy-1.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:12b6bfc1b1a66095ab413160a6e520e1dc076a28f3e22f7fb25ba3b000b4ef99"}, + {file = "mypy-1.10.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e36fb078cce9904c7989b9693e41cb9711e0600139ce3970c6ef814b6ebc2b2"}, + {file = "mypy-1.10.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:2b0695d605ddcd3eb2f736cd8b4e388288c21e7de85001e9f85df9187f2b50f9"}, + {file = "mypy-1.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:cd777b780312ddb135bceb9bc8722a73ec95e042f911cc279e2ec3c667076051"}, + {file = "mypy-1.10.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3be66771aa5c97602f382230165b856c231d1277c511c9a8dd058be4784472e1"}, + {file = "mypy-1.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8b2cbaca148d0754a54d44121b5825ae71868c7592a53b7292eeb0f3fdae95ee"}, + {file = "mypy-1.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ec404a7cbe9fc0e92cb0e67f55ce0c025014e26d33e54d9e506a0f2d07fe5de"}, + {file = "mypy-1.10.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e22e1527dc3d4aa94311d246b59e47f6455b8729f4968765ac1eacf9a4760bc7"}, + {file = "mypy-1.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:a87dbfa85971e8d59c9cc1fcf534efe664d8949e4c0b6b44e8ca548e746a8d53"}, + {file = "mypy-1.10.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:a781f6ad4bab20eef8b65174a57e5203f4be627b46291f4589879bf4e257b97b"}, + {file = "mypy-1.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b808e12113505b97d9023b0b5e0c0705a90571c6feefc6f215c1df9381256e30"}, + {file = "mypy-1.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f55583b12156c399dce2df7d16f8a5095291354f1e839c252ec6c0611e86e2e"}, + {file = "mypy-1.10.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4cf18f9d0efa1b16478c4c129eabec36148032575391095f73cae2e722fcf9d5"}, + {file = "mypy-1.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:bc6ac273b23c6b82da3bb25f4136c4fd42665f17f2cd850771cb600bdd2ebeda"}, + {file = "mypy-1.10.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9fd50226364cd2737351c79807775136b0abe084433b55b2e29181a4c3c878c0"}, + {file = "mypy-1.10.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f90cff89eea89273727d8783fef5d4a934be2fdca11b47def50cf5d311aff727"}, + {file = "mypy-1.10.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fcfc70599efde5c67862a07a1aaf50e55bce629ace26bb19dc17cece5dd31ca4"}, + {file = "mypy-1.10.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:075cbf81f3e134eadaf247de187bd604748171d6b79736fa9b6c9685b4083061"}, + {file = "mypy-1.10.0-cp38-cp38-win_amd64.whl", hash = "sha256:3f298531bca95ff615b6e9f2fc0333aae27fa48052903a0ac90215021cdcfa4f"}, + {file = "mypy-1.10.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fa7ef5244615a2523b56c034becde4e9e3f9b034854c93639adb667ec9ec2976"}, + {file = "mypy-1.10.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3236a4c8f535a0631f85f5fcdffba71c7feeef76a6002fcba7c1a8e57c8be1ec"}, + {file = "mypy-1.10.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a2b5cdbb5dd35aa08ea9114436e0d79aceb2f38e32c21684dcf8e24e1e92821"}, + {file = "mypy-1.10.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:92f93b21c0fe73dc00abf91022234c79d793318b8a96faac147cd579c1671746"}, + {file = "mypy-1.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:28d0e038361b45f099cc086d9dd99c15ff14d0188f44ac883010e172ce86c38a"}, + {file = "mypy-1.10.0-py3-none-any.whl", hash = "sha256:f8c083976eb530019175aabadb60921e73b4f45736760826aa1689dda8208aee"}, + {file = "mypy-1.10.0.tar.gz", hash = "sha256:3d087fcbec056c4ee34974da493a826ce316947485cef3901f511848e687c131"}, +] + +[package.dependencies] +mypy-extensions = ">=1.0.0" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +typing-extensions = ">=4.1.0" + +[package.extras] +dmypy = ["psutil (>=4.0)"] +install-types = ["pip"] +mypyc = ["setuptools (>=50)"] +reports = ["lxml"] + +[[package]] +name = "mypy-extensions" +version = "1.0.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = false +python-versions = ">=3.5" +files = [ + {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, + {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, +] + +[[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 = "packaging" +version = "24.0" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.7" +files = [ + {file = "packaging-24.0-py3-none-any.whl", hash = "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5"}, + {file = "packaging-24.0.tar.gz", hash = "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9"}, +] + +[[package]] +name = "pluggy" +version = "1.5.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, + {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "pytest" +version = "7.4.4" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8"}, + {file = "pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=0.12,<2.0" +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 = "pytest-asyncio" +version = "0.21.2" +description = "Pytest support for asyncio" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pytest_asyncio-0.21.2-py3-none-any.whl", hash = "sha256:ab664c88bb7998f711d8039cacd4884da6430886ae8bbd4eded552ed2004f16b"}, + {file = "pytest_asyncio-0.21.2.tar.gz", hash = "sha256:d67738fc232b94b326b9d060750beb16e0074210b98dd8b58a5239fa2a154f45"}, +] + +[package.dependencies] +pytest = ">=7.0.0" + +[package.extras] +docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] +testing = ["coverage (>=6.2)", "flaky (>=3.5.0)", "hypothesis (>=5.7.1)", "mypy (>=0.931)", "pytest-trio (>=0.7.0)"] + +[[package]] +name = "pytest-mock" +version = "3.14.0" +description = "Thin-wrapper around the mock package for easier use with pytest" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest-mock-3.14.0.tar.gz", hash = "sha256:2719255a1efeceadbc056d6bf3df3d1c5015530fb40cf347c0f9afac88410bd0"}, + {file = "pytest_mock-3.14.0-py3-none-any.whl", hash = "sha256:0b72c38033392a5f4621342fe11e9219ac11ec9d375f8e2a0c164539e0d70f6f"}, +] + +[package.dependencies] +pytest = ">=6.2.5" + +[package.extras] +dev = ["pre-commit", "pytest-asyncio", "tox"] + +[[package]] +name = "pytest-watch" +version = "4.2.0" +description = "Local continuous test runner with pytest and watchdog." +optional = false +python-versions = "*" +files = [ + {file = "pytest-watch-4.2.0.tar.gz", hash = "sha256:06136f03d5b361718b8d0d234042f7b2f203910d8568f63df2f866b547b3d4b9"}, +] + +[package.dependencies] +colorama = ">=0.3.3" +docopt = ">=0.4.0" +pytest = ">=2.6.4" +watchdog = ">=0.6.0" + +[[package]] +name = "ruff" +version = "0.1.15" +description = "An extremely fast Python linter and code formatter, written in Rust." +optional = false +python-versions = ">=3.7" +files = [ + {file = "ruff-0.1.15-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:5fe8d54df166ecc24106db7dd6a68d44852d14eb0729ea4672bb4d96c320b7df"}, + {file = "ruff-0.1.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6f0bfbb53c4b4de117ac4d6ddfd33aa5fc31beeaa21d23c45c6dd249faf9126f"}, + {file = "ruff-0.1.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e0d432aec35bfc0d800d4f70eba26e23a352386be3a6cf157083d18f6f5881c8"}, + {file = "ruff-0.1.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9405fa9ac0e97f35aaddf185a1be194a589424b8713e3b97b762336ec79ff807"}, + {file = "ruff-0.1.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c66ec24fe36841636e814b8f90f572a8c0cb0e54d8b5c2d0e300d28a0d7bffec"}, + {file = "ruff-0.1.15-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:6f8ad828f01e8dd32cc58bc28375150171d198491fc901f6f98d2a39ba8e3ff5"}, + {file = "ruff-0.1.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:86811954eec63e9ea162af0ffa9f8d09088bab51b7438e8b6488b9401863c25e"}, + {file = "ruff-0.1.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fd4025ac5e87d9b80e1f300207eb2fd099ff8200fa2320d7dc066a3f4622dc6b"}, + {file = "ruff-0.1.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b17b93c02cdb6aeb696effecea1095ac93f3884a49a554a9afa76bb125c114c1"}, + {file = "ruff-0.1.15-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:ddb87643be40f034e97e97f5bc2ef7ce39de20e34608f3f829db727a93fb82c5"}, + {file = "ruff-0.1.15-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:abf4822129ed3a5ce54383d5f0e964e7fef74a41e48eb1dfad404151efc130a2"}, + {file = "ruff-0.1.15-py3-none-musllinux_1_2_i686.whl", hash = "sha256:6c629cf64bacfd136c07c78ac10a54578ec9d1bd2a9d395efbee0935868bf852"}, + {file = "ruff-0.1.15-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1bab866aafb53da39c2cadfb8e1c4550ac5340bb40300083eb8967ba25481447"}, + {file = "ruff-0.1.15-py3-none-win32.whl", hash = "sha256:2417e1cb6e2068389b07e6fa74c306b2810fe3ee3476d5b8a96616633f40d14f"}, + {file = "ruff-0.1.15-py3-none-win_amd64.whl", hash = "sha256:3837ac73d869efc4182d9036b1405ef4c73d9b1f88da2413875e34e0d6919587"}, + {file = "ruff-0.1.15-py3-none-win_arm64.whl", hash = "sha256:9a933dfb1c14ec7a33cceb1e49ec4a16b51ce3c20fd42663198746efc0427360"}, + {file = "ruff-0.1.15.tar.gz", hash = "sha256:f6dfa8c1b21c913c326919056c390966648b680966febcb796cc9d1aaab8564e"}, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +description = "Sniff out which async library your code is running under" +optional = false +python-versions = ">=3.7" +files = [ + {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, + {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, +] + +[[package]] +name = "tomli" +version = "2.0.1" +description = "A lil' TOML parser" +optional = false +python-versions = ">=3.7" +files = [ + {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, + {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, +] + +[[package]] +name = "typing-extensions" +version = "4.11.0" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +files = [ + {file = "typing_extensions-4.11.0-py3-none-any.whl", hash = "sha256:c1f94d72897edaf4ce775bb7558d5b79d8126906a14ea5ed1635921406c0387a"}, + {file = "typing_extensions-4.11.0.tar.gz", hash = "sha256:83f085bd5ca59c80295fc2a82ab5dac679cbe02b9f33f7d83af68e241bea51b0"}, +] + +[[package]] +name = "watchdog" +version = "4.0.0" +description = "Filesystem events monitoring" +optional = false +python-versions = ">=3.8" +files = [ + {file = "watchdog-4.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:39cb34b1f1afbf23e9562501673e7146777efe95da24fab5707b88f7fb11649b"}, + {file = "watchdog-4.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c522392acc5e962bcac3b22b9592493ffd06d1fc5d755954e6be9f4990de932b"}, + {file = "watchdog-4.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6c47bdd680009b11c9ac382163e05ca43baf4127954c5f6d0250e7d772d2b80c"}, + {file = "watchdog-4.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8350d4055505412a426b6ad8c521bc7d367d1637a762c70fdd93a3a0d595990b"}, + {file = "watchdog-4.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c17d98799f32e3f55f181f19dd2021d762eb38fdd381b4a748b9f5a36738e935"}, + {file = "watchdog-4.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4986db5e8880b0e6b7cd52ba36255d4793bf5cdc95bd6264806c233173b1ec0b"}, + {file = "watchdog-4.0.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:11e12fafb13372e18ca1bbf12d50f593e7280646687463dd47730fd4f4d5d257"}, + {file = "watchdog-4.0.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:5369136a6474678e02426bd984466343924d1df8e2fd94a9b443cb7e3aa20d19"}, + {file = "watchdog-4.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:76ad8484379695f3fe46228962017a7e1337e9acadafed67eb20aabb175df98b"}, + {file = "watchdog-4.0.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:45cc09cc4c3b43fb10b59ef4d07318d9a3ecdbff03abd2e36e77b6dd9f9a5c85"}, + {file = "watchdog-4.0.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:eed82cdf79cd7f0232e2fdc1ad05b06a5e102a43e331f7d041e5f0e0a34a51c4"}, + {file = "watchdog-4.0.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:ba30a896166f0fee83183cec913298151b73164160d965af2e93a20bbd2ab605"}, + {file = "watchdog-4.0.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:d18d7f18a47de6863cd480734613502904611730f8def45fc52a5d97503e5101"}, + {file = "watchdog-4.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2895bf0518361a9728773083908801a376743bcc37dfa252b801af8fd281b1ca"}, + {file = "watchdog-4.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:87e9df830022488e235dd601478c15ad73a0389628588ba0b028cb74eb72fed8"}, + {file = "watchdog-4.0.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:6e949a8a94186bced05b6508faa61b7adacc911115664ccb1923b9ad1f1ccf7b"}, + {file = "watchdog-4.0.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:6a4db54edea37d1058b08947c789a2354ee02972ed5d1e0dca9b0b820f4c7f92"}, + {file = "watchdog-4.0.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:d31481ccf4694a8416b681544c23bd271f5a123162ab603c7d7d2dd7dd901a07"}, + {file = "watchdog-4.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:8fec441f5adcf81dd240a5fe78e3d83767999771630b5ddfc5867827a34fa3d3"}, + {file = "watchdog-4.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:6a9c71a0b02985b4b0b6d14b875a6c86ddea2fdbebd0c9a720a806a8bbffc69f"}, + {file = "watchdog-4.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:557ba04c816d23ce98a06e70af6abaa0485f6d94994ec78a42b05d1c03dcbd50"}, + {file = "watchdog-4.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:d0f9bd1fd919134d459d8abf954f63886745f4660ef66480b9d753a7c9d40927"}, + {file = "watchdog-4.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:f9b2fdca47dc855516b2d66eef3c39f2672cbf7e7a42e7e67ad2cbfcd6ba107d"}, + {file = "watchdog-4.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:73c7a935e62033bd5e8f0da33a4dcb763da2361921a69a5a95aaf6c93aa03a87"}, + {file = "watchdog-4.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:6a80d5cae8c265842c7419c560b9961561556c4361b297b4c431903f8c33b269"}, + {file = "watchdog-4.0.0-py3-none-win32.whl", hash = "sha256:8f9a542c979df62098ae9c58b19e03ad3df1c9d8c6895d96c0d51da17b243b1c"}, + {file = "watchdog-4.0.0-py3-none-win_amd64.whl", hash = "sha256:f970663fa4f7e80401a7b0cbeec00fa801bf0287d93d48368fc3e6fa32716245"}, + {file = "watchdog-4.0.0-py3-none-win_ia64.whl", hash = "sha256:9a03e16e55465177d416699331b0f3564138f1807ecc5f2de9d55d8f188d08c7"}, + {file = "watchdog-4.0.0.tar.gz", hash = "sha256:e3e7065cbdabe6183ab82199d7a4f6b3ba0a438c5a512a68559846ccb76a78ec"}, +] + +[package.extras] +watchmedo = ["PyYAML (>=3.10)"] + +[metadata] +lock-version = "2.0" +python-versions = "^3.9.0,<4.0" +content-hash = "dcd64c96c58d777998a9a0fee6e8f78153870796e294989a9b19d0e7ac10b2b7" diff --git a/libs/sdk-py/pyproject.toml b/libs/sdk-py/pyproject.toml new file mode 100644 index 000000000..f6fb8ae94 --- /dev/null +++ b/libs/sdk-py/pyproject.toml @@ -0,0 +1,47 @@ +[tool.poetry] +name = "langgraph-sdk" +version = "0.1.20" +description = "" +authors = ["Nuno Campos "] +readme = "README.md" +packages = [{ include = "langgraph_sdk" }] + +[tool.poetry.dependencies] +python = "^3.9.0,<4.0" +httpx = ">=0.25.2" +httpx-sse = ">=0.4.0" +orjson = ">=3.10.1" + +[tool.poetry.group.dev.dependencies] +ruff = "^0.1.4" +codespell = "^2.2.0" +pytest = "^7.2.1" +pytest-asyncio = "^0.21.1" +pytest-mock = "^3.11.1" +pytest-watch = "^4.2.0" +mypy = "^1.10.0" + +[tool.pytest.ini_options] +# --strict-markers will raise errors on unknown marks. +# https://docs.pytest.org/en/7.1.x/how-to/mark.html#raising-errors-on-unknown-marks +# +# https://docs.pytest.org/en/7.1.x/reference/reference.html +# --strict-config any warnings encountered while parsing the `pytest` +# section of the configuration file raise errors. +addopts = "--strict-markers --strict-config --durations=5 -vv" +asyncio_mode = "auto" + + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" + +[tool.ruff] +lint.select = [ + "E", # pycodestyle + "F", # Pyflakes + "UP", # pyupgrade + "B", # flake8-bugbear + "I", # isort +] +lint.ignore = ["E501", "B008", "UP007", "UP006"]