Compare commits

...
Author SHA1 Message Date
William Fu-Hinthorn 3d263a20ae Add to local faux packages 2025-02-19 06:09:39 -08:00
William Fu-Hinthorn 10d9721aed Support http config 2025-02-19 05:46:05 -08:00
4 changed files with 259 additions and 12 deletions
+61
View File
@@ -0,0 +1,61 @@
from contextlib import asynccontextmanager
from contextvars import ContextVar
from typing import Any
from starlette import Starlette
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse
from starlette.routing import Route
my_context_var: ContextVar[str] = ContextVar("my_context_var", default="")
LIFESPAN_VAL = ""
other_context_var = ContextVar("other_context_var", default="")
@asynccontextmanager
@asynccontextmanager
async def my_lifespan(app):
global LIFESPAN_VAL
LIFESPAN_VAL = "foobar-lifespan"
yield
assert LIFESPAN_VAL == "foobar-lifespan"
LIFESPAN_VAL = ""
class MyContextMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Any, call_next: Any) -> Any:
token = my_context_var.set("Foobar")
try:
response = await call_next(request)
return response
finally:
my_context_var.reset(token)
async def custom_my_route(request):
assert my_context_var.get() == "Foobar"
assert LIFESPAN_VAL == "foobar-lifespan"
return JSONResponse({"foo": "bar"})
async def runs_afakeroute(request):
assert my_context_var.get() == "Foobar"
assert LIFESPAN_VAL == "foobar-lifespan"
return JSONResponse({"foo": "afakeroute"})
async def other_middleware(request: Any, call_next: Any) -> Any:
other_context_var.set("foobar")
response = await call_next(request)
other_context_var.reset()
return response
app = Starlette(
middleware=[MyContextMiddleware],
routes=[
Route("/custom/my-route", custom_my_route),
Route("/runs/afakeroute", runs_afakeroute),
],
lifespan=my_lifespan,
)
+168 -9
View File
@@ -86,6 +86,33 @@ class AuthConfig(TypedDict, total=False):
""" """
class CorsConfig(TypedDict, total=False):
allow_origins: list[str]
allow_methods: list[str]
allow_headers: list[str]
allow_credentials: bool
allow_origin_regex: str
expose_headers: list[str]
max_age: int
class HttpConfig(TypedDict, total=False):
app: str
"""Import path for a custom Starlette/FastAPI app to mount"""
disable_assistants: bool
"""Disable /assistants routes"""
disable_threads: bool
"""Disable /threads routes"""
disable_runs: bool
"""Disable /runs routes"""
disable_store: bool
"""Disable /store routes"""
disable_meta: bool
"""Disable /ok, /info, /metrics, and /docs routes"""
cors: Optional[CorsConfig]
"""Cross-Origin Resource Sharing (CORS) configuration"""
class Config(TypedDict, total=False): class Config(TypedDict, total=False):
"""Configuration for langgraph-cli.""" """Configuration for langgraph-cli."""
@@ -124,6 +151,9 @@ class Config(TypedDict, total=False):
auth: Optional[AuthConfig] auth: Optional[AuthConfig]
"""Configuration for authentication.""" """Configuration for authentication."""
http: Optional[HttpConfig]
"""Configuration for HTTP server."""
def _parse_version(version_str: str) -> tuple[int, int]: def _parse_version(version_str: str) -> tuple[int, int]:
"""Parse a version string into a tuple of (major, minor).""" """Parse a version string into a tuple of (major, minor)."""
@@ -158,6 +188,7 @@ def validate_config(config: Config) -> Config:
"env": config.get("env", {}), "env": config.get("env", {}),
"store": config.get("store"), "store": config.get("store"),
"auth": config.get("auth"), "auth": config.get("auth"),
"http": config.get("http"),
} }
if config.get("node_version") if config.get("node_version")
else { else {
@@ -169,6 +200,7 @@ def validate_config(config: Config) -> Config:
"env": config.get("env", {}), "env": config.get("env", {}),
"store": config.get("store"), "store": config.get("store"),
"auth": config.get("auth"), "auth": config.get("auth"),
"http": config.get("http"),
} }
) )
@@ -221,7 +253,13 @@ def validate_config(config: Config) -> Config:
f"Invalid auth.path format: '{auth_conf['path']}'. " f"Invalid auth.path format: '{auth_conf['path']}'. "
"Must be in format './path/to/file.py:attribute_name'" "Must be in format './path/to/file.py:attribute_name'"
) )
if http_conf := config.get("http"):
if "app" in http_conf:
if ":" not in http_conf["app"]:
raise ValueError(
f"Invalid http.app format: '{http_conf['app']}'. "
"Must be in format './path/to/file.py:attribute_name'"
)
return config return config
@@ -441,6 +479,47 @@ def _assemble_local_deps(config_path: pathlib.Path, config: Config) -> LocalDeps
) )
) )
if auth_conf := config.get("auth"):
if auth_path := auth_conf.get("path"):
module_str, _, _ = auth_path.partition(":")
if module_str.startswith("."):
auth_file = (config_path.parent / module_str).resolve()
auth_dir = auth_file.parent
if (config_path.parent not in auth_dir.parents) and (
auth_dir not in additional_contexts
):
additional_contexts.append(auth_dir)
# Also add auth_dir to faux_pkgs if not already added.
if auth_dir not in real_pkgs and auth_dir not in faux_pkgs:
files = os.listdir(auth_dir)
if "__init__.py" in files:
container_path = (
f"/deps/__outer_{auth_dir.name}/{auth_dir.name}"
)
else:
container_path = f"/deps/__outer_{auth_dir.name}/src"
faux_pkgs[auth_dir] = (str(auth_dir), container_path)
if http_conf := config.get("http"):
if http_path := http_conf.get("app"):
module_str, _, _ = http_path.partition(":")
if module_str.startswith("."):
http_file = (config_path.parent / module_str).resolve()
http_dir = http_file.parent
if (config_path.parent not in http_dir.parents) and (
http_dir not in additional_contexts
):
additional_contexts.append(http_dir)
# Also add http_dir to faux_pkgs if not already added.
if http_dir not in real_pkgs and http_dir not in faux_pkgs:
files = os.listdir(http_dir)
if "__init__.py" in files:
container_path = (
f"/deps/__outer_{http_dir.name}/{http_dir.name}"
)
else:
container_path = f"/deps/__outer_{http_dir.name}/src"
faux_pkgs[http_dir] = (str(http_dir), container_path)
return LocalDeps(pip_reqs, real_pkgs, faux_pkgs, working_dir, additional_contexts) return LocalDeps(pip_reqs, real_pkgs, faux_pkgs, working_dir, additional_contexts)
@@ -560,13 +639,80 @@ def _update_auth_path(
auth_conf["path"] = new_path auth_conf["path"] = new_path
return return
# -- New: Check additional contexts for auth --
for add_ctx in local_deps.additional_contexts:
if resolved.is_relative_to(add_ctx):
new_path = f"/deps/__outer_{add_ctx.name}/{resolved.relative_to(add_ctx)}:{attr_str}"
auth_conf["path"] = new_path
return
# ------------------------------------------------
raise ValueError( raise ValueError(
f"Auth file '{resolved}' not covered by dependencies.\n" f"Auth file '{resolved}' not covered by dependencies or additional contexts.\n"
"Add its parent directory to the 'dependencies' array in your config.\n" "Add its parent directory to the 'dependencies' array in your config, or let the auto-include logic add it.\n"
f"Current dependencies: {config['dependencies']}" f"Current dependencies: {config['dependencies']}"
) )
def _update_http_app_path(
config_path: pathlib.Path, config: Config, local_deps: LocalDeps
) -> None:
"""Update the HTTP app path to point to the correct location in the Docker container.
Similar to _update_graph_paths, this ensures that if a custom app is specified via
a local file path, that file is included in the Docker build context and its path
is updated to point to the correct location in the container.
"""
if not (http_config := config.get("http")) or not (
app_str := http_config.get("app")
):
return
module_str, _, attr_str = app_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=app_str))
# Check if it's a file path
if "/" in module_str or "\\" in module_str:
# Resolve the local path properly on the current OS
resolved = (config_path.parent / module_str).resolve()
if not resolved.exists():
raise FileNotFoundError(f"Could not find HTTP app module: {resolved}")
elif not resolved.is_file():
raise IsADirectoryError(f"HTTP app module must be a file: {resolved}")
else:
for path in local_deps.real_pkgs:
if resolved.is_relative_to(path):
container_path = (
pathlib.Path("/deps") / path.name / resolved.relative_to(path)
)
module_str = container_path.as_posix()
break
else:
for faux_pkg, (_, destpath) in local_deps.faux_pkgs.items():
if resolved.is_relative_to(faux_pkg):
container_subpath = resolved.relative_to(faux_pkg)
# Construct the final path, ensuring POSIX style
module_str = f"{destpath}/{container_subpath.as_posix()}"
break
else:
# -- New: Check additional contexts for HTTP app --
for add_ctx in local_deps.additional_contexts:
if resolved.is_relative_to(add_ctx):
module_str = f"/deps/__outer_{add_ctx.name}/{resolved.relative_to(add_ctx)}"
break
else:
raise ValueError(
f"HTTP app module '{app_str}' not found in 'dependencies' or additional contexts. "
"Add its containing package to 'dependencies' list."
)
# update the config
http_config["app"] = f"{module_str}:{attr_str}"
def python_config_to_docker( def python_config_to_docker(
config_path: pathlib.Path, config: Config, base_image: str config_path: pathlib.Path, config: Config, base_image: str
) -> tuple[str, dict[str, str]]: ) -> tuple[str, dict[str, str]]:
@@ -590,13 +736,17 @@ def python_config_to_docker(
_update_graph_paths(config_path, config, local_deps) _update_graph_paths(config_path, config, local_deps)
# Rewrite auth path, so it points to the correct location in the Docker container # Rewrite auth path, so it points to the correct location in the Docker container
_update_auth_path(config_path, config, local_deps) _update_auth_path(config_path, config, local_deps)
# Rewrite HTTP app path, so it points to the correct location in the Docker container
_update_http_app_path(config_path, config, local_deps)
pip_pkgs_str = f"RUN {pip_install} {' '.join(pypi_deps)}" if pypi_deps else "" pip_pkgs_str = f"RUN {pip_install} {' '.join(pypi_deps)}" if pypi_deps else ""
if local_deps.pip_reqs: if local_deps.pip_reqs:
pip_reqs_str = os.linesep.join( pip_reqs_str = os.linesep.join(
f"COPY --from=__outer_{reqpath.name} requirements.txt {destpath}" (
if reqpath.parent in local_deps.additional_contexts f"COPY --from=__outer_{reqpath.name} requirements.txt {destpath}"
else f"ADD {reqpath.relative_to(config_path.parent)} {destpath}" if reqpath.parent in local_deps.additional_contexts
else f"ADD {reqpath.relative_to(config_path.parent)} {destpath}"
)
for reqpath, destpath in local_deps.pip_reqs 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)}' pip_reqs_str += f'{os.linesep}RUN {pip_install} {" ".join("-r " + r for _,r in local_deps.pip_reqs)}'
@@ -631,13 +781,15 @@ RUN set -ex && \\
) )
local_pkgs_str = os.linesep.join( local_pkgs_str = os.linesep.join(
f"""# -- Adding local package {relpath} -- (
f"""# -- Adding local package {relpath} --
COPY --from={name} . /deps/{name} COPY --from={name} . /deps/{name}
# -- End of local package {relpath} --""" # -- End of local package {relpath} --"""
if fullpath in local_deps.additional_contexts if fullpath in local_deps.additional_contexts
else f"""# -- Adding local package {relpath} -- else f"""# -- Adding local package {relpath} --
ADD {relpath} /deps/{name} ADD {relpath} /deps/{name}
# -- End of local package {relpath} --""" # -- End of local package {relpath} --"""
)
for fullpath, (relpath, name) in local_deps.real_pkgs.items() for fullpath, (relpath, name) in local_deps.real_pkgs.items()
) )
@@ -662,6 +814,9 @@ ADD {relpath} /deps/{name}
if (auth_config := config.get("auth")) is not None: if (auth_config := config.get("auth")) is not None:
env_vars.append(f"ENV LANGGRAPH_AUTH='{json.dumps(auth_config)}'") env_vars.append(f"ENV LANGGRAPH_AUTH='{json.dumps(auth_config)}'")
if (http_config := config.get("http")) is not None:
env_vars.append(f"ENV LANGGRAPH_HTTP='{json.dumps(http_config)}'")
graphs = config["graphs"] graphs = config["graphs"]
env_vars.append(f"ENV LANGSERVE_GRAPHS='{json.dumps(graphs)}'") env_vars.append(f"ENV LANGSERVE_GRAPHS='{json.dumps(graphs)}'")
@@ -733,6 +888,10 @@ ENV LANGGRAPH_STORE='{json.dumps(store_config)}'
if (auth_config := config.get("auth")) is not None: if (auth_config := config.get("auth")) is not None:
env_additional_config += f""" env_additional_config += f"""
ENV LANGGRAPH_AUTH='{json.dumps(auth_config)}' ENV LANGGRAPH_AUTH='{json.dumps(auth_config)}'
"""
if (http_config := config.get("http")) is not None:
env_additional_config += f"""
ENV LANGGRAPH_HTTP='{json.dumps(http_config)}'
""" """
return ( return (
+5 -1
View File
@@ -6,10 +6,14 @@
], ],
"dependencies": [ "dependencies": [
"langchain_openai", "langchain_openai",
"starlette",
"." "."
], ],
"graphs": { "graphs": {
"agent": "graphs/agent.py:graph" "agent": "graphs/agent.py:graph"
}, },
"env": ".env" "env": ".env",
"http": {
"app": "../../examples/my_app.py:app"
}
} }
+25 -2
View File
@@ -32,6 +32,7 @@ def test_validate_config():
"env": {}, "env": {},
"store": None, "store": None,
"auth": None, "auth": None,
"http": None,
**expected_config, **expected_config,
} }
actual_config = validate_config(expected_config) actual_config = validate_config(expected_config)
@@ -50,6 +51,7 @@ def test_validate_config():
"env": env, "env": env,
"store": None, "store": None,
"auth": None, "auth": None,
"http": None,
} }
actual_config = validate_config(expected_config) actual_config = validate_config(expected_config)
assert actual_config == expected_config assert actual_config == expected_config
@@ -108,6 +110,18 @@ def test_validate_config():
} }
) )
assert config["python_version"] == "3.12-slim" assert config["python_version"] == "3.12-slim"
with pytest.raises(
ValueError,
match="Invalid http.app format",
):
validate_config(
{
"python_version": "3.12",
"dependencies": ["."],
"graphs": {"agent": "./agent.py:graph"},
"http": {"app": "../../examples/my_app.py"},
}
)
def test_validate_config_file(): def test_validate_config_file():
@@ -180,7 +194,11 @@ def test_config_to_docker_simple():
actual_docker_stdin, additional_contexts = config_to_docker( actual_docker_stdin, additional_contexts = config_to_docker(
PATH_TO_CONFIG, PATH_TO_CONFIG,
validate_config( validate_config(
{"dependencies": [".", "../../examples/graphs_reqs_a"], "graphs": graphs} {
"dependencies": [".", "../../examples/graphs_reqs_a"],
"graphs": graphs,
"http": {"app": "../../examples/my_app.py:app"},
}
), ),
"langchain/langgraph-api", "langchain/langgraph-api",
) )
@@ -190,6 +208,9 @@ FROM langchain/langgraph-api:3.11
COPY --from=__outer_requirements.txt requirements.txt /deps/__outer_graphs_reqs_a/graphs_reqs_a/requirements.txt COPY --from=__outer_requirements.txt requirements.txt /deps/__outer_graphs_reqs_a/graphs_reqs_a/requirements.txt
RUN PYTHONDONTWRITEBYTECODE=1 pip install --no-cache-dir -c /api/constraints.txt -r /deps/__outer_graphs_reqs_a/graphs_reqs_a/requirements.txt RUN PYTHONDONTWRITEBYTECODE=1 pip install --no-cache-dir -c /api/constraints.txt -r /deps/__outer_graphs_reqs_a/graphs_reqs_a/requirements.txt
# -- End of local requirements install -- # -- End of local requirements install --
# -- Adding local package ../../examples --
COPY --from=examples . /deps/examples
# -- End of local package ../../examples --
# -- Adding non-package dependency unit_tests -- # -- Adding non-package dependency unit_tests --
ADD . /deps/__outer_unit_tests/unit_tests ADD . /deps/__outer_unit_tests/unit_tests
RUN set -ex && \\ RUN set -ex && \\
@@ -215,6 +236,7 @@ RUN set -ex && \\
# -- Installing all local dependencies -- # -- Installing all local dependencies --
RUN PYTHONDONTWRITEBYTECODE=1 pip install --no-cache-dir -c /api/constraints.txt -e /deps/* RUN PYTHONDONTWRITEBYTECODE=1 pip install --no-cache-dir -c /api/constraints.txt -e /deps/*
# -- End of local dependencies install -- # -- End of local dependencies install --
ENV LANGGRAPH_HTTP='{"app": "/deps/examples/my_app.py:app"}'
ENV LANGSERVE_GRAPHS='{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}' ENV LANGSERVE_GRAPHS='{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}'
WORKDIR /deps/__outer_unit_tests/unit_tests\ WORKDIR /deps/__outer_unit_tests/unit_tests\
""" """
@@ -223,7 +245,8 @@ WORKDIR /deps/__outer_unit_tests/unit_tests\
assert additional_contexts == { assert additional_contexts == {
"__outer_graphs_reqs_a": str( "__outer_graphs_reqs_a": str(
(pathlib.Path(__file__).parent / "../../examples/graphs_reqs_a").resolve() (pathlib.Path(__file__).parent / "../../examples/graphs_reqs_a").resolve()
) ),
"examples": str((pathlib.Path(__file__).parent / "../../examples").resolve()),
} }