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):
"""Configuration for langgraph-cli."""
@@ -124,6 +151,9 @@ class Config(TypedDict, total=False):
auth: Optional[AuthConfig]
"""Configuration for authentication."""
http: Optional[HttpConfig]
"""Configuration for HTTP server."""
def _parse_version(version_str: str) -> tuple[int, int]:
"""Parse a version string into a tuple of (major, minor)."""
@@ -158,6 +188,7 @@ def validate_config(config: Config) -> Config:
"env": config.get("env", {}),
"store": config.get("store"),
"auth": config.get("auth"),
"http": config.get("http"),
}
if config.get("node_version")
else {
@@ -169,6 +200,7 @@ def validate_config(config: Config) -> Config:
"env": config.get("env", {}),
"store": config.get("store"),
"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']}'. "
"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
@@ -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)
@@ -560,13 +639,80 @@ def _update_auth_path(
auth_conf["path"] = new_path
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(
f"Auth file '{resolved}' not covered by dependencies.\n"
"Add its parent directory to the 'dependencies' array in your config.\n"
f"Auth file '{resolved}' not covered by dependencies or additional contexts.\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']}"
)
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(
config_path: pathlib.Path, config: Config, base_image: str
) -> tuple[str, dict[str, str]]:
@@ -590,13 +736,17 @@ def python_config_to_docker(
_update_graph_paths(config_path, config, local_deps)
# Rewrite auth path, so it points to the correct location in the Docker container
_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 ""
if local_deps.pip_reqs:
pip_reqs_str = os.linesep.join(
f"COPY --from=__outer_{reqpath.name} requirements.txt {destpath}"
if reqpath.parent in local_deps.additional_contexts
else f"ADD {reqpath.relative_to(config_path.parent)} {destpath}"
(
f"COPY --from=__outer_{reqpath.name} requirements.txt {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
)
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(
f"""# -- Adding local package {relpath} --
(
f"""# -- Adding local package {relpath} --
COPY --from={name} . /deps/{name}
# -- End of local package {relpath} --"""
if fullpath in local_deps.additional_contexts
else f"""# -- Adding local package {relpath} --
if fullpath in local_deps.additional_contexts
else f"""# -- Adding local package {relpath} --
ADD {relpath} /deps/{name}
# -- End of local package {relpath} --"""
)
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:
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"]
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:
env_additional_config += f"""
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 (
+5 -1
View File
@@ -6,10 +6,14 @@
],
"dependencies": [
"langchain_openai",
"starlette",
"."
],
"graphs": {
"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": {},
"store": None,
"auth": None,
"http": None,
**expected_config,
}
actual_config = validate_config(expected_config)
@@ -50,6 +51,7 @@ def test_validate_config():
"env": env,
"store": None,
"auth": None,
"http": None,
}
actual_config = validate_config(expected_config)
assert actual_config == expected_config
@@ -108,6 +110,18 @@ def test_validate_config():
}
)
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():
@@ -180,7 +194,11 @@ def test_config_to_docker_simple():
actual_docker_stdin, additional_contexts = config_to_docker(
PATH_TO_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",
)
@@ -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
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 --
# -- Adding local package ../../examples --
COPY --from=examples . /deps/examples
# -- End of local package ../../examples --
# -- Adding non-package dependency unit_tests --
ADD . /deps/__outer_unit_tests/unit_tests
RUN set -ex && \\
@@ -215,6 +236,7 @@ RUN set -ex && \\
# -- Installing all local dependencies --
RUN PYTHONDONTWRITEBYTECODE=1 pip install --no-cache-dir -c /api/constraints.txt -e /deps/*
# -- 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"}'
WORKDIR /deps/__outer_unit_tests/unit_tests\
"""
@@ -223,7 +245,8 @@ WORKDIR /deps/__outer_unit_tests/unit_tests\
assert additional_contexts == {
"__outer_graphs_reqs_a": str(
(pathlib.Path(__file__).parent / "../../examples/graphs_reqs_a").resolve()
)
),
"examples": str((pathlib.Path(__file__).parent / "../../examples").resolve()),
}