Merge pull request #688 from langchain-ai/vb/move-langgraph-libs

libs: add separate langgraph cli, sdk-py, sdk-js libraries and move core langgraph
This commit is contained in:
Nuno Campos
2024-06-18 17:42:26 -07:00
committed by GitHub
132 changed files with 10711 additions and 2477 deletions
@@ -1,9 +1,12 @@
name: lint
on:
push:
branches: [main]
pull_request:
workflow_call:
inputs:
working-directory:
required: true
type: string
description: "From which folder this pipeline executes"
env:
POETRY_VERSION: "1.7.1"
@@ -26,6 +29,7 @@ jobs:
python-version:
- "3.9"
- "3.11"
name: "lint #${{ matrix.python-version }}"
steps:
- uses: actions/checkout@v4
@@ -34,14 +38,17 @@ jobs:
with:
python-version: ${{ matrix.python-version }}
poetry-version: ${{ env.POETRY_VERSION }}
working-directory: ${{ inputs.working-directory }}
cache-key: lint-with-extras
- name: Check Poetry File
shell: bash
working-directory: ${{ inputs.working-directory }}
run: poetry check
- name: Check lock file
shell: bash
working-directory: ${{ inputs.working-directory }}
run: poetry lock --check
- name: Install dependencies
@@ -53,7 +60,8 @@ jobs:
# If you change this configuration, make sure to change the `cache-key`
# in the `poetry_setup` action above to stop using the old cache.
# It doesn't matter how you change it, any change will cause a cache-bust.
run: poetry install --with test
working-directory: ${{ inputs.working-directory }}
run: poetry install --with dev
- name: Get .mypy_cache to speed up mypy
uses: actions/cache@v3
@@ -61,12 +69,18 @@ jobs:
SEGMENT_DOWNLOAD_TIMEOUT_MIN: "2"
with:
path: |
./.mypy_cache
key: mypy-lint-${{ runner.os }}-${{ runner.arch }}-py${{ matrix.python-version }}-${{ hashFiles('./poetry.lock') }}
${{ inputs.working-directory }}/.mypy_cache
key: mypy-lint-${{ runner.os }}-${{ runner.arch }}-py${{ matrix.python-version }}-${{ inputs.working-directory }}-${{ hashFiles(format('{0}/poetry.lock', inputs.working-directory)) }}
- name: Analysing the code with our lint
- name: Analysing package code with our lint
working-directory: ${{ inputs.working-directory }}
run: |
make lint_package
if make lint_package > /dev/null 2>&1; then
make lint_package
else
echo "lint_package command not found, using lint instead"
make lint
fi
- name: Install test dependencies
# Also installs dev/lint/test/typing dependencies, to ensure we have
@@ -77,17 +91,24 @@ jobs:
# If you change this configuration, make sure to change the `cache-key`
# in the `poetry_setup` action above to stop using the old cache.
# It doesn't matter how you change it, any change will cause a cache-bust.
working-directory: ${{ inputs.working-directory }}
run: |
poetry install --with test
poetry install --with dev
- name: Get .mypy_cache_test to speed up mypy
uses: actions/cache@v3
env:
SEGMENT_DOWNLOAD_TIMEOUT_MIN: "2"
with:
path: ./.mypy_cache_test
key: mypy-test-${{ runner.os }}-${{ runner.arch }}-py${{ matrix.python-version }}-${{ hashFiles('./poetry.lock') }}
path: |
${{ inputs.working-directory }}/.mypy_cache_test
key: mypy-test-${{ runner.os }}-${{ runner.arch }}-py${{ matrix.python-version }}-${{ inputs.working-directory }}-${{ hashFiles(format('{0}/poetry.lock', inputs.working-directory)) }}
- name: Analysing the code with our lint
- name: Analysing tests with our lint
working-directory: ${{ inputs.working-directory }}
run: |
make lint_tests
if make lint_tests > /dev/null 2>&1; then
make lint_tests
else
echo "lint_tests command not found, skipping step"
fi
@@ -1,9 +1,12 @@
name: test
on:
push:
branches: [main]
pull_request:
workflow_call:
inputs:
working-directory:
required: true
type: string
description: "From which folder this pipeline executes"
env:
POETRY_VERSION: "1.7.1"
@@ -18,7 +21,7 @@ jobs:
- "3.10"
- "3.11"
- "3.12"
name: Python ${{ matrix.python-version }}
name: "test #${{ matrix.python-version }}"
steps:
- uses: actions/checkout@v4
@@ -27,19 +30,23 @@ jobs:
with:
python-version: ${{ matrix.python-version }}
poetry-version: ${{ env.POETRY_VERSION }}
working-directory: ${{ inputs.working-directory }}
cache-key: core
- name: Install dependencies
shell: bash
run: poetry install --with test
working-directory: ${{ inputs.working-directory }}
run: poetry install --with dev
- name: Run core tests
shell: bash
working-directory: ${{ inputs.working-directory }}
run: |
make test
- name: Ensure the tests did not create any additional files
shell: bash
working-directory: ${{ inputs.working-directory }}
run: |
set -eu
+101
View File
@@ -0,0 +1,101 @@
---
name: CI
on:
push:
branches: [main]
pull_request:
# If another push to the same PR or branch happens while this workflow is still running,
# cancel the earlier run in favor of the next run.
#
# There's no point in testing an outdated version of the code. GitHub only allows
# a limited number of job runners to be active at the same time, so it's better to cancel
# pointless jobs early so that more useful jobs can run sooner.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
POETRY_VERSION: "1.7.1"
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.10'
lint:
name: cd ${{ matrix.working-directory }}
needs: [ build ]
strategy:
matrix:
working-directory: [
"libs/langgraph",
"libs/sdk-py",
"libs/cli"
]
uses: ./.github/workflows/_lint.yml
with:
working-directory: ${{ matrix.working-directory }}
secrets: inherit
test:
name: cd ${{ matrix.working-directory }}
needs: [ build ]
strategy:
matrix:
working-directory: [
"libs/langgraph",
"libs/cli"
]
uses: ./.github/workflows/_test.yml
with:
working-directory: ${{ matrix.working-directory }}
secrets: inherit
lint-js:
runs-on: ubuntu-latest
strategy:
matrix:
working-directory:
- "libs/sdk-js"
defaults:
run:
working-directory: ${{ matrix.working-directory }}
steps:
- uses: actions/checkout@v3
- name: Setup Node.js (LTS)
uses: actions/setup-node@v3
with:
node-version: "20"
cache: "yarn"
cache-dependency-path: ${{ matrix.working-directory }}/yarn.lock
- name: Install dependencies
run: yarn install
- name: Run lint
run: yarn lint
- name: Build
run: yarn build
ci_success:
name: "CI Success"
needs: [build, lint, lint-js, test]
if: |
always()
runs-on: ubuntu-latest
env:
JOBS_JSON: ${{ toJSON(needs) }}
RESULTS_JSON: ${{ toJSON(needs.*.result) }}
EXIT_CODE: ${{!contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') && '0' || '1'}}
steps:
- name: "CI Success"
run: |
echo $JOBS_JSON
echo $RESULTS_JSON
echo "Exiting with $EXIT_CODE"
exit $EXIT_CODE
+11 -13
View File
@@ -9,6 +9,9 @@ on:
- main
workflow_dispatch:
env:
POETRY_VERSION: "1.7.1"
permissions:
contents: read
pages: write
@@ -26,22 +29,17 @@ jobs:
with:
fetch-depth: 0
- name: Set up Python + Poetry ${{ env.POETRY_VERSION }}
uses: "./.github/actions/poetry_setup"
with:
python-version: "3.12"
poetry-version: ${{ env.POETRY_VERSION }}
cache-key: docs
- name: Install dependencies
run: |
pip install poetry poethepoet
poetry install --with docs
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: 3.12
cache: poetry
cache-dependency-path: "poetry.lock"
- name: Poetry install
run: |
poetry install
poetry run pip install -r docs/docs-requirements.txt
- name: Build site
run: make build-docs
env:
@@ -1,6 +1,6 @@
import toml
pyproject_toml = toml.load("pyproject.toml")
pyproject_toml = toml.load("libs/langgraph/pyproject.toml")
# Extract the ignore words list (adjust the key as per your TOML structure)
ignore_words_list = (
+1 -1
View File
@@ -46,7 +46,7 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
poetry install --with test
poetry install --with docs
poetry run pip install -U pytest pytest-check-links langsmith langchain GitPython
# - name: Check links in notebooks
+2 -2
View File
@@ -119,7 +119,7 @@ jobs:
poetry run python -c "import $IMPORT_NAME; print(dir($IMPORT_NAME))"
- name: Import test dependencies
run: poetry install --with test
run: poetry install --with dev
# Overwrite the local version of the package with the test PyPI version.
- name: Import published package (again)
@@ -133,7 +133,7 @@ jobs:
"$PKG_NAME==$VERSION"
- name: Run unit tests
run: make tests
run: make test
publish:
needs:
-12
View File
@@ -1,12 +0,0 @@
FROM python:3.9
# Set the working directory to /app
WORKDIR /app
# Copy the current directory contents into the container at /app
COPY . .
# Install any needed packages specified in requirements.txt
RUN pip install poetry && poetry config virtualenvs.create false && poetry install --with test
RUN poetry run pytest
+1 -1
View File
@@ -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
+5 -78
View File
@@ -1,90 +1,17 @@
.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
spell_fix:
poetry run codespell --toml pyproject.toml -w
.PHONY: build-docs serve-docs serve-clean-docs clean-docs
build-docs:
poetry run python docs/_scripts/copy_notebooks.py
poetry run mkdocs build --clean -f docs/mkdocs.yml --strict
poetry run python -m mkdocs build --clean -f docs/mkdocs.yml --strict
serve-clean-docs: clean-docs
poetry run python docs/_scripts/copy_notebooks.py
poetry run python -m mkdocs serve -c -f docs/mkdocs.yml --strict -w ./langgraph
poetry run python -m mkdocs serve -c -f docs/mkdocs.yml --strict -w ./libs/langgraph
serve-docs:
poetry run python docs/_scripts/copy_notebooks.py
poetry run python -m mkdocs serve -f docs/mkdocs.yml -w ./langgraph --dirty
poetry run python -m mkdocs serve -f docs/mkdocs.yml -w ./libs/langgraph --dirty
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=<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
-9
View File
@@ -1,9 +0,0 @@
mkdocs
mkdocstrings
mkdocstrings-python
mkdocs-jupyter
mkdocs-redirects
mkdocs-minify-plugin
mkdocs-rss-plugin
mkdocs-git-committers-plugin-2
mkdocs-material[imaging]
+21
View File
@@ -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.
+31
View File
@@ -0,0 +1,31 @@
.PHONY: test lint format
######################
# TESTING AND COVERAGE
######################
test:
poetry run pytest tests
######################
# 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 --relative --diff-filter=d main . | grep -E '\.py$$|\.ipynb$$')
lint_package: PYTHON_FILES=langgraph_cli
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)
+3
View File
@@ -0,0 +1,3 @@
# langchain-cli
This package implements the official CLI for LangGraph API.
+562
View File
@@ -0,0 +1,562 @@
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
from langgraph_cli.util import clean_empty_lines
OPT_DOCKER_COMPOSE = 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_CONFIG = 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"
- "<package_name>
- "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_WATCH = click.option("--watch", is_flag=True, help="Restart on file changes")
OPT_LANGGRAPH_API_PATH = click.option(
"--langgraph-api-path",
type=click.Path(exists=True, file_okay=False, dir_okay=True, resolve_path=True),
hidden=True,
)
OPT_DEBUGGER_PORT = click.option(
"--debugger-port",
type=int,
help="Pull the debugger image locally and serve the UI on specified port",
)
OPT_POSTGRES_URI = click.option(
"--postgres-uri",
help="Postgres URI to use for the database. Defaults to launching a local database",
)
@click.group()
def cli():
pass
@OPT_RECREATE
@OPT_PULL
@OPT_PORT
@OPT_DOCKER_COMPOSE
@OPT_CONFIG
@OPT_VERBOSE
@OPT_DEBUGGER_PORT
@OPT_WATCH
@OPT_LANGGRAPH_API_PATH
@OPT_POSTGRES_URI
@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],
postgres_uri: Optional[str],
):
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,
postgres_uri=postgres_uri,
)
# 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")
else:
args.append("--abort-on-container-exit")
# 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://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_DOCKER_COMPOSE
@OPT_CONFIG
@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_DOCKER_COMPOSE
@OPT_CONFIG
@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_CONFIG
@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
)
)
@cli.group(help="Export langgraph compose files")
def export():
pass
@click.option(
"--output",
"-o",
help="Output path to write the docker compose file to",
type=click.Path(
exists=False,
file_okay=True,
dir_okay=False,
resolve_path=True,
path_type=pathlib.Path,
),
required=True,
)
@OPT_CONFIG
@OPT_PORT
@OPT_WATCH
@OPT_LANGGRAPH_API_PATH
@export.command(name="compose", help="Export docker compose file")
def export_compose(
output: pathlib.Path,
config: pathlib.Path,
port: int,
watch: bool,
langgraph_api_path: Optional[pathlib.Path],
):
with Runner() as runner:
capabilities = langgraph_cli.docker.check_capabilities(runner)
_, stdin = prepare(
runner,
capabilities=capabilities,
config_path=config,
docker_compose=None,
pull=False,
watch=watch,
langgraph_api_path=langgraph_api_path,
port=port,
verbose=False,
)
with open(output, "w") as f:
f.write(clean_empty_lines(stdin))
@click.option(
"--output",
"-o",
help="Output path (directory) to write the helm chart to",
type=click.Path(
exists=False,
file_okay=False,
dir_okay=True,
resolve_path=True,
path_type=pathlib.Path,
),
required=True,
)
@OPT_PORT
@OPT_DOCKER_COMPOSE
@OPT_CONFIG
@export.command(
name="helm",
help="Build and export a helm chart to deploy to a Kubernetes cluster",
hidden=True,
)
def export_helm(
output: pathlib.Path,
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 = [
"convert",
"--chart",
"-o",
str(output),
"-v",
]
# 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, 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,
postgres_uri: Optional[str] = None,
):
# prepare args
stdin = langgraph_cli.docker.compose(
capabilities,
port=port,
debugger_port=debugger_port,
postgres_uri=postgres_uri,
)
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,
postgres_uri: Optional[str] = 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,
postgres_uri=postgres_uri,
)
return args, stdin
+306
View File
@@ -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 "<module>:<attribute>".'
)
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 <<EOF /deps/__outer_{fullpath.name}/pyproject.toml
[project]
name = "{fullpath.name}"
version = "0.1"
[tool.setuptools.package-data]
"*" = ["**/*"]
EOF"""
for fullpath, (relpath, destpath) in local_deps.faux_pkgs.items()
)
local_pkgs_str = os.linesep.join(
f"ADD {relpath} /deps/{fullpath.name}"
for fullpath, relpath in local_deps.real_pkgs.items()
)
return f"""FROM langchain/langgraph-api:{config['python_version']}
{os.linesep.join(config["dockerfile_lines"])}
{pip_config_file_str}
{pip_pkgs_str}
{pip_reqs_str}
{local_pkgs_str}
{faux_pkgs_str}
RUN {pip_install} -e /deps/*
ENV LANGSERVE_GRAPHS='{json.dumps(config["graphs"])}'
{f"WORKDIR {local_deps.working_dir}" if local_deps.working_dir else ""}"""
def config_to_compose(
config_path: pathlib.Path,
config: Config,
watch: bool = False,
langgraph_api_path: Optional[pathlib.Path] = None,
):
env_vars = config["env"].items() if isinstance(config["env"], dict) else {}
env_vars_str = "\n".join(f" {k}: {v}" for k, v in env_vars)
env_file_str = (
f"env_file: {config['env']}" if isinstance(config["env"], str) else ""
)
if watch:
watch_paths = [config_path] + [
config_path.parent / dep
for dep in config["dependencies"]
if dep.startswith(".")
]
watch_actions = "\n".join(
f"""- path: {path}
action: rebuild
ignore:
- .langgraph-data"""
for path in watch_paths
)
if langgraph_api_path:
watch_actions += f"""\n- path: {langgraph_api_path}
action: sync+restart
target: /api/langgraph_api"""
watch_str = f"""
develop:
watch:
{textwrap.indent(watch_actions, " ")}
"""
else:
watch_str = ""
return f"""
{textwrap.indent(env_vars_str, " ")}
{env_file_str}
pull_policy: build
build:
context: .
dockerfile_inline: |
{textwrap.indent(config_to_docker(config_path, config), " ")}
{watch_str}
"""
+170
View File
@@ -0,0 +1,170 @@
import json
import pathlib
import shutil
from typing import Literal, NamedTuple, Optional
import click.exceptions
from langgraph_cli.exec import subp_exec
ROOT = pathlib.Path(__file__).parent.resolve()
DEFAULT_POSTGRES_URI = (
"postgres://postgres:postgres@langgraph-postgres:5432/postgres?sslmode=disable"
)
DB = """
langgraph-postgres:
image: postgres:16
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
"""
DEBUGGER = """
langgraph-debugger:
image: langchain/langgraph-debugger
restart: on-failure
ports:
- "{debugger_port}:80"
depends_on:
langgraph-postgres:
condition: service_healthy
"""
class Version(NamedTuple):
major: int
minor: int
patch: int
DockerComposeType = Literal["plugin", "standalone"]
class DockerCapabilities(NamedTuple):
version_docker: Version
version_compose: Version
healthcheck_start_interval: bool
compose_type: DockerComposeType = "plugin"
def _parse_version(version: str) -> 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
if shutil.which("docker") is None:
raise click.UsageError("Docker not installed") from None
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
if not info["ServerVersion"]:
raise click.UsageError("Docker 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:
ports:
- "{port}:8000\""""
if include_db:
compose_str += """
depends_on:
langgraph-postgres:
condition: service_healthy"""
compose_str += f"""
environment:
POSTGRES_URI: {postgres_uri}
"""
if capabilities.healthcheck_start_interval:
compose_str += """ healthcheck:
test: python /api/healthcheck.py
interval: 60s
start_interval: 1s
start_period: 10s"""
return compose_str
+140
View File
@@ -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
+64
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
def clean_empty_lines(input_str: str):
return "\n".join(filter(None, input_str.splitlines()))
+327
View File
@@ -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"
+53
View File
@@ -0,0 +1,53 @@
[tool.poetry]
name = "langgraph-cli"
version = "0.1.37"
description = "CLI for interacting with LangGraph API"
authors = ["Nuno Campos <nuno@langchain.dev>"]
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" ]
+2
View File
@@ -0,0 +1,2 @@
def clean_empty_lines(input_str: str):
return "\n".join(filter(None, input_str.splitlines()))
+111
View File
@@ -0,0 +1,111 @@
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 langgraph_cli.util 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
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:
ports:
- "8000:8000"
depends_on:
langgraph-postgres:
condition: service_healthy
environment:
POSTGRES_URI: {DEFAULT_POSTGRES_URI}
healthcheck:
test: python /api/healthcheck.py
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
@@ -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"
}
+395
View File
@@ -0,0 +1,395 @@
import os
import pathlib
import click
import pytest
from langgraph_cli.config import config_to_compose, config_to_docker, validate_config
from langgraph_cli.util 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 <<EOF /deps/__outer_unit_tests/pyproject.toml
[project]
name = "unit_tests"
version = "0.1"
[tool.setuptools.package-data]
"*" = ["**/*"]
EOF
RUN pip install -c /api/constraints.txt -e /deps/*
ENV LANGSERVE_GRAPHS='{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}'
WORKDIR /deps/__outer_unit_tests/unit_tests\
"""
assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin
def test_config_to_docker_pipconfig():
graphs = {"agent": "./agent.py:graph"}
actual_docker_stdin = config_to_docker(
PATH_TO_CONFIG,
validate_config(
{
"dependencies": ["."],
"graphs": graphs,
"pip_config_file": "pipconfig.txt",
}
),
)
expected_docker_stdin = """\
FROM langchain/langgraph-api:3.11
ADD pipconfig.txt /pipconfig.txt
ADD . /deps/__outer_unit_tests/unit_tests
COPY <<EOF /deps/__outer_unit_tests/pyproject.toml
[project]
name = "unit_tests"
version = "0.1"
[tool.setuptools.package-data]
"*" = ["**/*"]
EOF
RUN PIP_CONFIG_FILE=/pipconfig.txt pip install -c /api/constraints.txt -e /deps/*
ENV LANGSERVE_GRAPHS='{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}'
WORKDIR /deps/__outer_unit_tests/unit_tests\
"""
assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin
def test_config_to_docker_invalid_inputs():
# test missing local dependencies
with pytest.raises(FileNotFoundError):
graphs = {"agent": "tests/unit_tests/agent.py:graph"}
config_to_docker(
PATH_TO_CONFIG,
validate_config({"dependencies": ["./missing"], "graphs": graphs}),
)
# test missing local module
with pytest.raises(FileNotFoundError):
graphs = {"agent": "./missing_agent.py:graph"}
config_to_docker(
PATH_TO_CONFIG, validate_config({"dependencies": ["."], "graphs": graphs})
)
def test_config_to_docker_local_deps():
graphs = {"agent": "./graphs/agent.py:graph"}
actual_docker_stdin = config_to_docker(
PATH_TO_CONFIG,
validate_config(
{
"dependencies": ["./graphs"],
"graphs": graphs,
}
),
)
expected_docker_stdin = """\
FROM langchain/langgraph-api:3.11
ADD ./graphs /deps/__outer_graphs/src
COPY <<EOF /deps/__outer_graphs/pyproject.toml
[project]
name = "graphs"
version = "0.1"
[tool.setuptools.package-data]
"*" = ["**/*"]
EOF
RUN pip install -c /api/constraints.txt -e /deps/*
ENV LANGSERVE_GRAPHS='{"agent": "/deps/__outer_graphs/src/agent.py:graph"}'\
"""
assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin
def test_config_to_docker_pyproject():
pyproject_str = """[project]
name = "custom"
version = "0.1"
dependencies = ["langchain"]"""
pyproject_path = "tests/unit_tests/pyproject.toml"
with open(pyproject_path, "w") as f:
f.write(pyproject_str)
graphs = {"agent": "./graphs/agent.py:graph"}
actual_docker_stdin = config_to_docker(
PATH_TO_CONFIG,
validate_config(
{
"dependencies": ["."],
"graphs": graphs,
}
),
)
os.remove(pyproject_path)
expected_docker_stdin = """FROM langchain/langgraph-api:3.11
ADD . /deps/unit_tests
RUN pip install -c /api/constraints.txt -e /deps/*
ENV LANGSERVE_GRAPHS='{"agent": "/deps/unit_tests/graphs/agent.py:graph"}'
WORKDIR /deps/unit_tests"""
assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin
def test_config_to_docker_end_to_end():
graphs = {"agent": "./graphs/agent.py:graph"}
actual_docker_stdin = config_to_docker(
PATH_TO_CONFIG,
validate_config(
{
"python_version": "3.12",
"dependencies": ["./graphs/", "langchain", "langchain_openai"],
"graphs": graphs,
"pip_config_file": "pipconfig.txt",
"dockerfile_lines": ["ARG meow", "ARG foo"],
}
),
)
expected_docker_stdin = """FROM langchain/langgraph-api:3.12
ARG meow
ARG foo
ADD pipconfig.txt /pipconfig.txt
RUN PIP_CONFIG_FILE=/pipconfig.txt pip install -c /api/constraints.txt langchain langchain_openai
ADD ./graphs/ /deps/__outer_graphs/src
COPY <<EOF /deps/__outer_graphs/pyproject.toml
[project]
name = "graphs"
version = "0.1"
[tool.setuptools.package-data]
"*" = ["**/*"]
EOF
RUN PIP_CONFIG_FILE=/pipconfig.txt pip install -c /api/constraints.txt -e /deps/*
ENV LANGSERVE_GRAPHS='{"agent": "/deps/__outer_graphs/src/agent.py:graph"}'"""
assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin
# config_to_compose
def test_config_to_compose_simple_config():
graphs = {"agent": "./agent.py:graph"}
expected_compose_stdin = """\
pull_policy: build
build:
context: .
dockerfile_inline: |
FROM langchain/langgraph-api:3.11
ADD . /deps/__outer_unit_tests/unit_tests
COPY <<EOF /deps/__outer_unit_tests/pyproject.toml
[project]
name = "unit_tests"
version = "0.1"
[tool.setuptools.package-data]
"*" = ["**/*"]
EOF
RUN pip install -c /api/constraints.txt -e /deps/*
ENV LANGSERVE_GRAPHS='{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}'
WORKDIR /deps/__outer_unit_tests/unit_tests
"""
actual_compose_stdin = config_to_compose(
PATH_TO_CONFIG, validate_config({"dependencies": ["."], "graphs": graphs})
)
assert clean_empty_lines(actual_compose_stdin) == expected_compose_stdin
def test_config_to_compose_env_vars():
graphs = {"agent": "./agent.py:graph"}
expected_compose_stdin = """ OPENAI_API_KEY: key
pull_policy: build
build:
context: .
dockerfile_inline: |
FROM langchain/langgraph-api:3.11
ADD . /deps/__outer_unit_tests/unit_tests
COPY <<EOF /deps/__outer_unit_tests/pyproject.toml
[project]
name = "unit_tests"
version = "0.1"
[tool.setuptools.package-data]
"*" = ["**/*"]
EOF
RUN pip install -c /api/constraints.txt -e /deps/*
ENV LANGSERVE_GRAPHS='{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}'
WORKDIR /deps/__outer_unit_tests/unit_tests
"""
openai_api_key = "key"
actual_compose_stdin = config_to_compose(
PATH_TO_CONFIG,
validate_config(
{
"dependencies": ["."],
"graphs": graphs,
"env": {"OPENAI_API_KEY": openai_api_key},
}
),
)
assert clean_empty_lines(actual_compose_stdin) == expected_compose_stdin
def test_config_to_compose_env_file():
graphs = {"agent": "./agent.py:graph"}
expected_compose_stdin = """\
env_file: .env
pull_policy: build
build:
context: .
dockerfile_inline: |
FROM langchain/langgraph-api:3.11
ADD . /deps/__outer_unit_tests/unit_tests
COPY <<EOF /deps/__outer_unit_tests/pyproject.toml
[project]
name = "unit_tests"
version = "0.1"
[tool.setuptools.package-data]
"*" = ["**/*"]
EOF
RUN pip install -c /api/constraints.txt -e /deps/*
ENV LANGSERVE_GRAPHS='{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}'
WORKDIR /deps/__outer_unit_tests/unit_tests
"""
actual_compose_stdin = config_to_compose(
PATH_TO_CONFIG,
validate_config({"dependencies": ["."], "graphs": graphs, "env": ".env"}),
)
assert clean_empty_lines(actual_compose_stdin) == expected_compose_stdin
def test_config_to_compose_watch():
graphs = {"agent": "./agent.py:graph"}
expected_compose_stdin = """\
pull_policy: build
build:
context: .
dockerfile_inline: |
FROM langchain/langgraph-api:3.11
ADD . /deps/__outer_unit_tests/unit_tests
COPY <<EOF /deps/__outer_unit_tests/pyproject.toml
[project]
name = "unit_tests"
version = "0.1"
[tool.setuptools.package-data]
"*" = ["**/*"]
EOF
RUN pip install -c /api/constraints.txt -e /deps/*
ENV LANGSERVE_GRAPHS='{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}'
WORKDIR /deps/__outer_unit_tests/unit_tests
develop:
watch:
- path: tests/unit_tests/test_config.json
action: rebuild
ignore:
- .langgraph-data
- path: tests/unit_tests
action: rebuild
ignore:
- .langgraph-data\
"""
actual_compose_stdin = config_to_compose(
PATH_TO_CONFIG,
validate_config({"dependencies": ["."], "graphs": graphs}),
watch=True,
)
assert clean_empty_lines(actual_compose_stdin) == expected_compose_stdin
def test_config_to_compose_end_to_end():
# test all of the above + langgraph API path
graphs = {"agent": "./agent.py:graph"}
expected_compose_stdin = """\
env_file: .env
pull_policy: build
build:
context: .
dockerfile_inline: |
FROM langchain/langgraph-api:3.11
ADD . /deps/__outer_unit_tests/unit_tests
COPY <<EOF /deps/__outer_unit_tests/pyproject.toml
[project]
name = "unit_tests"
version = "0.1"
[tool.setuptools.package-data]
"*" = ["**/*"]
EOF
RUN pip install -c /api/constraints.txt -e /deps/*
ENV LANGSERVE_GRAPHS='{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}'
WORKDIR /deps/__outer_unit_tests/unit_tests
develop:
watch:
- path: tests/unit_tests/test_config.json
action: rebuild
ignore:
- .langgraph-data
- path: tests/unit_tests
action: rebuild
ignore:
- .langgraph-data
- path: path/to/langgraph/api
action: sync+restart
target: /api/langgraph_api\
"""
actual_compose_stdin = config_to_compose(
PATH_TO_CONFIG,
validate_config({"dependencies": ["."], "graphs": graphs, "env": ".env"}),
watch=True,
langgraph_api_path="path/to/langgraph/api",
)
assert clean_empty_lines(actual_compose_stdin) == expected_compose_stdin
+101
View File
@@ -0,0 +1,101 @@
from langgraph_cli.docker import (
DEFAULT_POSTGRES_URI,
DockerCapabilities,
Version,
compose,
)
from langgraph_cli.util import clean_empty_lines
DEFAULT_DOCKER_CAPABILITIES = DockerCapabilities(
version_docker=Version(26, 1, 1),
version_compose=Version(2, 27, 0),
healthcheck_start_interval=False,
)
def test_compose_with_no_debugger_and_custom_db():
port = 8123
custom_postgres_uri = "custom_postgres_uri"
actual_compose_str = compose(
DEFAULT_DOCKER_CAPABILITIES, port=port, postgres_uri=custom_postgres_uri
)
expected_compose_str = f"""services:
langgraph-api:
ports:
- "{port}:8000"
environment:
POSTGRES_URI: {custom_postgres_uri}"""
assert clean_empty_lines(actual_compose_str) == expected_compose_str
def test_compose_with_no_debugger_and_custom_db_with_healthcheck():
port = 8123
custom_postgres_uri = "custom_postgres_uri"
actual_compose_str = compose(
DEFAULT_DOCKER_CAPABILITIES._replace(healthcheck_start_interval=True),
port=port,
postgres_uri=custom_postgres_uri,
)
expected_compose_str = f"""services:
langgraph-api:
ports:
- "{port}:8000"
environment:
POSTGRES_URI: {custom_postgres_uri}
healthcheck:
test: python /api/healthcheck.py
interval: 60s
start_interval: 1s
start_period: 10s"""
assert clean_empty_lines(actual_compose_str) == expected_compose_str
def test_compose_with_debugger_and_custom_db():
port = 8123
custom_postgres_uri = "custom_postgres_uri"
actual_compose_str = compose(
DEFAULT_DOCKER_CAPABILITIES,
port=port,
postgres_uri=custom_postgres_uri,
)
expected_compose_str = f"""services:
langgraph-api:
ports:
- "{port}:8000"
environment:
POSTGRES_URI: {custom_postgres_uri}"""
assert clean_empty_lines(actual_compose_str) == expected_compose_str
def test_compose_with_debugger_and_default_db():
port = 8123
actual_compose_str = compose(DEFAULT_DOCKER_CAPABILITIES, port=port)
expected_compose_str = f"""volumes:
langgraph-data:
driver: local
services:
langgraph-postgres:
image: postgres:16
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: 5s
langgraph-api:
ports:
- "{port}:8000"
depends_on:
langgraph-postgres:
condition: service_healthy
environment:
POSTGRES_URI: {DEFAULT_POSTGRES_URI}"""
assert clean_empty_lines(actual_compose_str) == expected_compose_str
+21
View File
@@ -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.
+70
View File
@@ -0,0 +1,70 @@
.PHONY: all format lint test test_watch integration_tests spell_check spell_fix
# 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 --relative --diff-filter=d main . | 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
spell_fix:
poetry run codespell --toml pyproject.toml -w
######################
# 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 'test TEST_FILE=<test_file> - run all tests in file'
@echo 'test_watch - run unit tests in watch mode'
+207
View File
@@ -0,0 +1,207 @@
# 🦜🕸️LangGraph
![Version](https://img.shields.io/pypi/v/langgraph)
[![Downloads](https://static.pepy.tech/badge/langgraph/month)](https://pepy.tech/project/langgraph)
[![Open Issues](https://img.shields.io/github/issues-raw/langchain-ai/langgraph)](https://github.com/langchain-ai/langgraph/issues)
[![](https://dcbadge.vercel.app/api/server/6adMQxSpJS?compact=true&style=flat)](https://discord.com/channels/1038097195422978059/1170024642245832774)
[![Docs](https://img.shields.io/badge/docs-latest-blue)](https://langchain-ai.github.io/langgraph/)
⚡ Building language agents as graphs ⚡
## Overview
[LangGraph](https://langchain-ai.github.io/langgraph/) is a library for building stateful, multi-actor applications with LLMs, used to create agent and multi-agent workflows. Compared to other LLM frameworks, it offers these core benefits: cycles, controllability, and persistence. LangGraph allows you to define flows that involve cycles, essential for most agentic architectures, differentiating it from DAG-based solutions. As a very low-level framework, it provides fine-grained control over both the flow and state of your application, crucial for creating reliable agents. Additionally, LangGraph includes built-in persistence, enabling advanced human-in-the-loop and memory features.
LangGraph is inspired by [Pregel](https://research.google/pubs/pub37252/) and [Apache Beam](https://beam.apache.org/). The public interface draws inspiration from [NetworkX](https://networkx.org/documentation/latest/). LangGraph is built by LangChain Inc, the creators of LangChain, but can be used without LangChain.
### Key Features
- **Cycles and Branching**: Implement loops and conditionals in your apps.
- **Persistence**: Automatically save state after each step in the graph. Pause and resume the graph execution at any point to support error recovery, human-in-the-loop workflows, time travel and more.
- **Human-in-the-Loop**: Interrupt graph execution to approve or edit next action planned by the agent.
- **Streaming Support**: Stream outputs as they are produced by each node (including token streaming).
- **Integration with LangChain**: LangGraph integrates seamlessly with [LangChain](https://github.com/langchain-ai/langchain/) and [LangSmith](https://docs.smith.langchain.com/) (but does not require them).
## Installation
```shell
pip install -U langgraph
```
## Example
One of the central concepts of LangGraph is state. Each graph execution creates a state that is passed between nodes in the graph as they execute, and each node updates this internal state with its return value after it executes. The way that the graph updates its internal state is defined by either the type of graph chosen or a custom function.
Let's take a look at a simple example of an agent that can search the web using [Tavily Search API](https://tavily.com/).
```shell
pip install langchain_openai langchain_community
```
```shell
export OPENAI_API_KEY=sk-...
export TAVILY_API_KEY=tvly-...
```
Optionally, we can set up [LangSmith](https://docs.smith.langchain.com/) for best-in-class observability.
```shell
export LANGCHAIN_TRACING_V2="true"
export LANGCHAIN_API_KEY=ls__...
```
```python
from typing import Annotated, Literal, TypedDict
from langchain_core.messages import HumanMessage
from langchain_community.tools.tavily_search import TavilySearchResults
from langchain_openai import ChatOpenAI
from langgraph.checkpoint import MemorySaver
from langgraph.graph import END, StateGraph, MessagesState
from langgraph.prebuilt import ToolNode
# Define the tools for the agent to use
tools = [TavilySearchResults(max_results=1)]
tool_node = ToolNode(tools)
model = ChatOpenAI(temperature=0).bind_tools(tools)
# Define the function that determines whether to continue or not
def should_continue(state: AgentState) -> Literal["tools", END]:
messages = state['messages']
last_message = messages[-1]
# If the LLM makes a tool call, then we route to the "tools" node
if last_message.tool_calls:
return "tools"
# Otherwise, we stop (reply to the user)
return END
# Define the function that calls the model
def call_model(state: AgentState):
messages = state['messages']
response = model.invoke(messages)
# We return a list, because this will get added to the existing list
return {"messages": [response]}
# Define a new graph
workflow = StateGraph(MessagesState)
# Define the two nodes we will cycle between
workflow.add_node("agent", call_model)
workflow.add_node("tools", tool_node)
# Set the entrypoint as `agent`
# This means that this node is the first one called
workflow.set_entry_point("agent")
# We now add a conditional edge
workflow.add_conditional_edges(
# First, we define the start node. We use `agent`.
# This means these are the edges taken after the `agent` node is called.
"agent",
# Next, we pass in the function that will determine which node is called next.
should_continue,
)
# We now add a normal edge from `tools` to `agent`.
# This means that after `tools` is called, `agent` node is called next.
workflow.add_edge("tools", 'agent')
# Initialize memory to persist state between graph runs
checkpointer = MemorySaver()
# Finally, we compile it!
# This compiles it into a LangChain Runnable,
# meaning you can use it as you would any other runnable.
# Note that we're (optionally) passing the memory when compiling the graph
app = workflow.compile(checkpointer=checkpointer)
# Use the Runnable
final_state = app.invoke(
{"messages": [HumanMessage(content="what is the weather in sf")]},
config={"configurable": {"thread_id": 42}}
)
final_state["messages"][-1].content
```
```
'The current weather in San Francisco is as follows:\n- Temperature: 60.1°F (15.6°C)\n- Condition: Partly cloudy\n- Wind: 5.6 mph (9.0 kph) from SSW\n- Humidity: 83%\n- Visibility: 9.0 miles (16.0 km)\n- UV Index: 4.0\n\nFor more details, you can visit [Weather API](https://www.weatherapi.com/).'
```
Now when we pass the same `"thread_id"`, the conversation context is retained via the saved state (i.e. stored list of messages)
```python
final_state = app.invoke(
{"messages": [HumanMessage(content="what about ny")]},
config={"configurable": {"thread_id": 42}}
)
final_state["messages"][-1].content
```
```
'The current weather in New York is as follows:\n- Temperature: 20.3°C (68.5°F)\n- Condition: Overcast\n- Wind: 2.2 mph from the north\n- Humidity: 65%\n- Cloud Cover: 100%\n- UV Index: 5.0\n\nFor more details, you can visit [Weather API](https://www.weatherapi.com/).'
```
### Step-by-step Breakdown:
1. <details>
<summary>Initialize the model and tools.</summary>
- we use `ChatOpenAI` as our LLM. **NOTE:** we need make sure the model knows that it has these tools available to call. We can do this by converting the LangChain tools into the format for OpenAI tool calling using the `.bind_tools()` method.
- we define the tools we want to use -- a web search tool in our case. It is really easy to create your own tools - see documentation here on how to do that [here](https://python.langchain.com/docs/modules/agents/tools/custom_tools).
</details>
2. <details>
<summary>Initialize graph with state.</summary>
- we initialize graph (`StateGraph`) by passing state schema (in our case `MessagesState`)
- `MessagesState` is a prebuilt state schema that has one attribute -- a list of LangChain `Message` objects, as well as logic for merging the updates from each node into the state
</details>
3. <details>
<summary>Define graph nodes.</summary>
There are two main nodes we need:
- The `agent` node: responsible for deciding what (if any) actions to take.
- The `tools` node that invokes tools: if the agent decides to take an action, this node will then execute that action.
</details>
4. <details>
<summary>Define entry point and graph edges.</summary>
First, we need to set the entry point for graph execution - `agent` node.
Then we define one normal and one conditional edge. Conditional edge means that the destination depends on the contents of the graph's state (`MessageState`). In our case, the destination is not known until the agent (LLM) decides.
- Conditional edge: after the agent is called, we should either:
- a. Run tools if the agent said to take an action, OR
- b. Finish (respond to the user) if the agent did not ask to run tools
- Normal edge: after the tools are invoked, the graph should always return to the agent to decide what to do next
</details>
5. <details>
<summary>Compile the graph.</summary>
- When we compile the graph, we turn it into a LangChain [Runnable](https://python.langchain.com/v0.2/docs/concepts/#runnable-interface), which automatically enables calling `.invoke()`, `.stream()` and `.batch()` with your inputs
- We can also optionally pass checkpointer object for persisting state between graph runs, and enabling memory, human-in-the-loop workflows, time travel and more. In our case we use `MemorySaver` - a simple in-memory checkpointer
</details>
6. <details>
<summary>Execute the graph.</summary>
1. LangGraph adds the input message to the internal state, then passes the state to the entrypoint node, `"agent"`.
2. The `"agent"` node executes, invoking the chat model.
3. The chat model returns an `AIMessage`. LangGraph adds this to the state.
4. Graph cycles the following steps until there are no more `tool_calls` on `AIMessage`:
- If `AIMessage` has `tool_calls`, `"tools"` node executes
- The `"agent"` node executes again and returns `AIMessage`
5. Execution progresses to the special `END` value and outputs the final state.
And as a result, we get a list of all our chat messages as output.
</details>
## Documentation
* [Tutorials](https://langchain-ai.github.io/langgraph/tutorials/): Learn to build with LangGraph through guided examples.
* [How-to Guides](https://langchain-ai.github.io/langgraph/how-tos/): Accomplish specific things within LangGraph, from streaming, to adding memory & persistence, to common design patterns (branching, subgraphs, etc.), these are the place to go if you want to copy and run a specific code snippet.
* [Conceptual Guides](https://langchain-ai.github.io/langgraph/concepts/): In-depth explanations of the key concepts and principles behind LangGraph, such as nodes, edges, state and more.
* [API Reference](https://langchain-ai.github.io/langgraph/reference/graphs/): Review important classes and methods, simple examples of how to use the graph and checkpointing APIs, higher-level prebuilt components and more.
View File
+4130
View File
File diff suppressed because it is too large Load Diff
+84
View File
@@ -0,0 +1,84 @@
[tool.poetry]
name = "langgraph"
version = "0.0.69"
description = "langgraph"
authors = []
license = "MIT"
readme = "README.md"
repository = "https://www.github.com/langchain-ai/langgraph"
[tool.poetry.dependencies]
python = ">=3.9.0,<4.0"
langchain-core = ">=0.2,<0.3"
[tool.poetry.group.dev.dependencies]
pytest = "^7.3.0"
pytest-cov = "^4.0.0"
pytest-dotenv = "^0.5.2"
pytest-asyncio = "^0.20.3"
pytest-mock = "^3.10.0"
syrupy = "^4.0.2"
httpx = "^0.26.0"
pytest-watcher = "^0.4.1"
langchain = ">=0.1.0"
aiosqlite = "^0.19.0"
grandalf = "^0.8"
mypy = "^1.6.0"
ruff = "^0.1.4"
jupyter = "^1.0.0"
langchainhub = "^0.1.14"
langchain-openai = ">=0.1.2"
langchain-anthropic = ">=0.1.8"
dataclasses-json = "^0.6.7"
[tool.poetry.group.dev]
optional = true
[tool.ruff]
lint.select = [ "E", "F", "I" ]
lint.ignore = [ "E501" ]
line-length = 88
indent-width = 4
extend-include = ["*.ipynb"]
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
skip-magic-trailing-comma = false
line-ending = "auto"
docstring-code-format = false
docstring-code-line-length = "dynamic"
[tool.mypy]
ignore_missing_imports = "True"
disallow_untyped_defs = "True"
exclude = ["notebooks", "examples", "example_data"]
[tool.coverage.run]
omit = ["tests/*"]
[tool.pytest-watcher]
now = true
delay = 0.1
runner_args = ["-x", "--ff", "-vv", "--snapshot-update"]
patterns = ["*.py"]
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
[tool.pytest.ini_options]
asyncio_mode = "auto"
# --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.
#
# https://github.com/tophat/syrupy
# --snapshot-warn-unused Prints a warning on unused snapshots rather than fail the test suite.
addopts = "--full-trace --strict-markers --strict-config --durations=5 --snapshot-warn-unused"
# Registering custom markers.
# https://docs.pytest.org/en/7.1.x/example/markers.html#registering-markers
View File

Some files were not shown because too many files have changed in this diff Show More