langgraph-cli 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,16 @@
1
+ Metadata-Version: 2.1
2
+ Name: langgraph-cli
3
+ Version: 0.1.0
4
+ Summary:
5
+ Author: Nuno Campos
6
+ Author-email: nuno@langchain.dev
7
+ Requires-Python: >=3.9.0,<3.12
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.9
10
+ Classifier: Programming Language :: Python :: 3.10
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Requires-Dist: click (>=8.1.7,<9.0.0)
13
+ Description-Content-Type: text/markdown
14
+
15
+ # langserve
16
+
@@ -0,0 +1 @@
1
+ # langserve
File without changes
@@ -0,0 +1,202 @@
1
+ import asyncio
2
+ import json
3
+ import os
4
+ import pathlib
5
+ import shutil
6
+ import signal
7
+ from datetime import datetime, timezone
8
+ from typing import Coroutine, Optional
9
+
10
+ import click
11
+
12
+ import langgraph_cli.config
13
+ import langgraph_cli.docker
14
+
15
+
16
+ async def exec(cmd: str, *args: str, input: str = None, wait: float = None):
17
+ if wait:
18
+ await asyncio.sleep(wait)
19
+ try:
20
+ proc = await asyncio.create_subprocess_exec(
21
+ cmd, *args, stdin=asyncio.subprocess.PIPE if input else None
22
+ )
23
+ await proc.communicate(input.encode() if input else None)
24
+ if (
25
+ proc.returncode != 0 # success
26
+ and proc.returncode != 130 # user interrupt
27
+ ):
28
+ raise click.exceptions.Exit(proc.returncode)
29
+ finally:
30
+ try:
31
+ if proc.returncode is None:
32
+ try:
33
+ os.killpg(os.getpgid(proc.pid), signal.SIGINT)
34
+ except (ProcessLookupError, KeyboardInterrupt):
35
+ pass
36
+ except UnboundLocalError:
37
+ pass
38
+
39
+
40
+ PLACEHOLDER_NOW = object()
41
+
42
+
43
+ async def exec_loop(cmd: str, *args: str, input: str = None):
44
+ now = datetime.now(timezone.utc).isoformat()
45
+ while True:
46
+ try:
47
+ await exec(
48
+ cmd, *(now if a is PLACEHOLDER_NOW else a for a in args), input=input
49
+ )
50
+ now = datetime.now(timezone.utc).isoformat()
51
+ await asyncio.sleep(1)
52
+ except Exception as e:
53
+ print(e)
54
+ pass
55
+
56
+
57
+ async def gather(*coros: Coroutine):
58
+ tasks = [asyncio.create_task(coro) for coro in coros]
59
+ exceptions = []
60
+ while tasks:
61
+ done, tasks = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
62
+ for t in tasks:
63
+ t.cancel()
64
+ for d in done:
65
+ if exc := d.exception():
66
+ exceptions.append(exc)
67
+
68
+
69
+ OPT_O = click.option(
70
+ "--override",
71
+ "-o",
72
+ type=click.Path(
73
+ exists=True,
74
+ file_okay=True,
75
+ dir_okay=False,
76
+ resolve_path=True,
77
+ path_type=pathlib.Path,
78
+ ),
79
+ )
80
+ OPT_C = click.option(
81
+ "--config",
82
+ "-c",
83
+ type=click.Path(
84
+ exists=True,
85
+ file_okay=True,
86
+ dir_okay=False,
87
+ resolve_path=True,
88
+ path_type=pathlib.Path,
89
+ ),
90
+ )
91
+ OPT_PORT = click.option("--port", "-p", type=int, default=8123)
92
+
93
+
94
+ @click.group()
95
+ def cli():
96
+ pass
97
+
98
+
99
+ @OPT_O
100
+ @OPT_C
101
+ @OPT_PORT
102
+ @click.option("--recreate", is_flag=True, default=False)
103
+ @click.option("--pull", is_flag=True, default=False)
104
+ @cli.command()
105
+ def up(
106
+ override: Optional[pathlib.Path],
107
+ config: Optional[pathlib.Path],
108
+ port: int,
109
+ recreate: bool,
110
+ pull: bool,
111
+ ):
112
+ if not override and not config:
113
+ raise click.UsageError("Must provide either --override or --config")
114
+ with asyncio.Runner() as runner:
115
+ # check docker available
116
+ try:
117
+ runner.run(exec("docker", "--version"))
118
+ runner.run(exec("docker", "compose", "version"))
119
+ except click.exceptions.Exit:
120
+ click.echo("Docker not installed or not running")
121
+ return
122
+ # pull latest images
123
+ if pull:
124
+ runner.run(exec("docker", "pull", "langchain/langserve"))
125
+ # prepare args
126
+ stdin = langgraph_cli.docker.compose(port=port)
127
+ args = [
128
+ "--project-directory",
129
+ override.parent if override else config.parent,
130
+ "-f",
131
+ "-", # stdin
132
+ ]
133
+ # apply options
134
+ if override:
135
+ args.extend(["-f", str(override)])
136
+ args.append("up")
137
+ if config:
138
+ with open(config) as f:
139
+ stdin += langgraph_cli.config.config_to_compose(config, json.load(f))
140
+ if recreate:
141
+ args.extend(["--force-recreate", "--remove-orphans"])
142
+ shutil.rmtree(".langserve-data", ignore_errors=True)
143
+ # run docker compose
144
+ runner.run(exec("docker", "compose", *args, input=stdin))
145
+
146
+
147
+ @OPT_O
148
+ @OPT_PORT
149
+ @cli.command()
150
+ def watch(override: pathlib.Path, port: int):
151
+ compose = langgraph_cli.docker.compose(port=port)
152
+
153
+ with asyncio.Runner() as runner:
154
+ try:
155
+ runner.run(
156
+ gather(
157
+ exec(
158
+ "docker",
159
+ "compose",
160
+ "--project-directory",
161
+ override.parent,
162
+ "-f",
163
+ "-",
164
+ "-f",
165
+ str(override),
166
+ "watch",
167
+ input=compose,
168
+ ),
169
+ exec_loop(
170
+ "docker",
171
+ "compose",
172
+ "--project-directory",
173
+ override.parent,
174
+ "-f",
175
+ "-",
176
+ "-f",
177
+ str(override),
178
+ "logs",
179
+ "--follow",
180
+ "--since",
181
+ PLACEHOLDER_NOW,
182
+ "langserve",
183
+ input=compose,
184
+ ),
185
+ )
186
+ )
187
+ finally:
188
+ # docker compose watch doesn't clean up on exit, so we need to do it
189
+ runner.run(
190
+ exec(
191
+ "docker",
192
+ "compose",
193
+ "--project-directory",
194
+ override.parent,
195
+ "-f",
196
+ "-",
197
+ "-f",
198
+ str(override),
199
+ "kill",
200
+ input=compose,
201
+ )
202
+ )
@@ -0,0 +1,81 @@
1
+ import json
2
+ import os
3
+ import pathlib
4
+ from typing import TypedDict, Union
5
+
6
+
7
+ class Config(TypedDict):
8
+ dependencies: list[str]
9
+ graphs: dict[str, str]
10
+ env: Union[dict[str, str], str]
11
+
12
+
13
+ def config_to_compose(config_path: pathlib.Path, config: Config):
14
+ pypi_deps = [dep for dep in config["dependencies"] if not dep.startswith(".")]
15
+ local_pkgs = []
16
+ faux_pkgs = {}
17
+ locals_set = set()
18
+
19
+ for local_dep in config["dependencies"]:
20
+ if local_dep.startswith("."):
21
+ resolved = config_path.parent / local_dep
22
+
23
+ # validate local dependency
24
+ if not resolved.exists():
25
+ raise FileNotFoundError(f"Could not find local dependency: {resolved}")
26
+ elif not resolved.is_dir():
27
+ raise NotADirectoryError(
28
+ f"Local dependency must be a directory: {resolved}"
29
+ )
30
+ elif resolved.name in locals_set:
31
+ raise ValueError(f"Duplicate local dependency: {resolved}")
32
+ else:
33
+ locals_set.add(resolved.name)
34
+
35
+ # if it's installable, add it to local_pkgs
36
+ # otherwise, add it to faux_pkgs, and create a pyproject.toml
37
+ files = os.listdir(resolved)
38
+ if "pyproject.toml" in files:
39
+ local_pkgs.append(local_dep)
40
+ elif "setup.py" in files:
41
+ local_pkgs.append(local_dep)
42
+ else:
43
+ faux_pkgs[resolved.name] = local_dep
44
+
45
+ for _, import_str in config["graphs"].items():
46
+ module_str, _, attrs_str = import_str.partition(":")
47
+ if not module_str or not attrs_str:
48
+ message = (
49
+ 'Import string "{import_str}" must be in format "<module>:<attribute>".'
50
+ )
51
+ raise ValueError(message.format(import_str=import_str))
52
+
53
+ faux_pkgs_str = "\n\n".join(
54
+ f"ADD {path} /tmp/{name}/{name}\n RUN touch /tmp/{name}/pyproject.toml"
55
+ for name, path in faux_pkgs.items()
56
+ )
57
+ local_pkgs_str = f"ADD {' '.join(local_pkgs)} /tmp/" if local_pkgs else ""
58
+ env_vars_str = (
59
+ "\n".join(f" {k}: {v}" for k, v in config["env"].items())
60
+ if isinstance(config["env"], dict)
61
+ else ""
62
+ )
63
+ env_file_str = (
64
+ f"env_file: {config['env']}" if isinstance(config["env"], str) else ""
65
+ )
66
+
67
+ return f"""
68
+ LANGSERVE_GRAPHS: '{json.dumps(config["graphs"])}'
69
+ {env_vars_str}
70
+ {env_file_str}
71
+ pull_policy: build
72
+ build:
73
+ dockerfile_inline: |
74
+ FROM langchain/langserve
75
+
76
+ {local_pkgs_str}
77
+
78
+ {faux_pkgs_str}
79
+
80
+ RUN pip install {' '.join(pypi_deps)} /tmp/*
81
+ """
@@ -0,0 +1,63 @@
1
+ import pathlib
2
+ from typing import Optional
3
+
4
+
5
+ ROOT = pathlib.Path(__file__).parent.parent.resolve()
6
+
7
+
8
+ DB = f"""
9
+ postgres:
10
+ image: postgres:16
11
+ restart: on-failure
12
+ healthcheck:
13
+ test: pg_isready -U postgres
14
+ start_interval: 1s
15
+ start_period: 5s
16
+ interval: 5s
17
+ retries: 5
18
+ ports:
19
+ - "5433:5432"
20
+ environment:
21
+ POSTGRES_DB: postgres
22
+ POSTGRES_USER: postgres
23
+ POSTGRES_PASSWORD: postgres
24
+ volumes:
25
+ - ./.langserve-data:/var/lib/postgresql/data
26
+ - {ROOT}/initdb:/docker-entrypoint-initdb.d
27
+ """
28
+
29
+
30
+ def compose(
31
+ *,
32
+ # postgres://user:password@host:port/database?option=value
33
+ postgres_uri: Optional[str] = None,
34
+ port: int,
35
+ ) -> str:
36
+ if postgres_uri is None:
37
+ include_db = True
38
+ postgres_uri = "postgres://postgres:postgres@postgres:5432/postgres?sslmode=disable&search_path=langserve"
39
+ else:
40
+ include_db = False
41
+
42
+ return f"""
43
+ services:
44
+ {DB if include_db else ""}
45
+ migrate:
46
+ image: langchain/langserve-migrate
47
+ pull_policy: always
48
+ {'''depends_on:
49
+ postgres:
50
+ condition: service_healthy''' if include_db else ""}
51
+ environment:
52
+ POSTGRES_URI: {postgres_uri}
53
+ langserve:
54
+ image: langchain/langserve
55
+ restart: on-failure:3
56
+ ports:
57
+ - "{port}:8000"
58
+ depends_on:
59
+ migrate:
60
+ condition: service_completed_successfully
61
+ environment:
62
+ POSTGRES_URI: {postgres_uri}
63
+ """
@@ -0,0 +1,37 @@
1
+ [tool.poetry]
2
+ name = "langgraph-cli"
3
+ version = "0.1.0"
4
+ description = ""
5
+ authors = ["Nuno Campos <nuno@langchain.dev>"]
6
+ readme = "README.md"
7
+ packages = [{include = "langgraph_cli"}]
8
+
9
+ [tool.poetry.scripts]
10
+ langgraph = "langgraph_cli.cli:cli"
11
+
12
+ [tool.poetry.dependencies]
13
+ python = "^3.9.0,<3.12"
14
+ click = "^8.1.7"
15
+
16
+ [tool.poetry.group.dev.dependencies]
17
+ ruff = "^0.1.4"
18
+ codespell = "^2.2.0"
19
+ pytest = "^7.2.1"
20
+ pytest-asyncio = "^0.21.1"
21
+ pytest-mock = "^3.11.1"
22
+ pytest-watch = "^4.2.0"
23
+
24
+ [tool.pytest.ini_options]
25
+ # --strict-markers will raise errors on unknown marks.
26
+ # https://docs.pytest.org/en/7.1.x/how-to/mark.html#raising-errors-on-unknown-marks
27
+ #
28
+ # https://docs.pytest.org/en/7.1.x/reference/reference.html
29
+ # --strict-config any warnings encountered while parsing the `pytest`
30
+ # section of the configuration file raise errors.
31
+ addopts = "--strict-markers --strict-config --durations=5 -vv"
32
+ asyncio_mode = "auto"
33
+
34
+
35
+ [build-system]
36
+ requires = ["poetry-core"]
37
+ build-backend = "poetry.core.masonry.api"