Compare commits

...
Author SHA1 Message Date
Saad Farooq fd2d92c31a perf(cli): merge cleanup steps and drop repeated WORKDIRs in generated Dockerfiles
Each Dockerfile instruction is a layer, and copy-based snapshotters pay a
full filesystem copy per layer. Emit the langgraph-api restore and build
tool removal as one RUN, and skip WORKDIRs that repeat the current one.
2026-11-04 08:41:33 -05:00
ataller-lc 5db97b9cca release(sdk-py): 0.4.12 (#9569)
Bumps the Python SDK version from 0.4.11 to 0.4.12.

Includes:

- Remove `fairness_weight` from `PrioritySettings` and document that -5
runs first (#9441)
- Rejoin a run stream that closes before the run ends (#9554)
2026-11-03 17:21:55 +00:00
ataller-lc 3c916d7505 feat(sdk-py): remove fairness_weight from PrioritySettings (#9441)
`PrioritySettings` no longer has `fairness_weight`, since a group's
weight belongs to the group rather than to each thread. The docstrings
now match what the server does with the other two keys.

- The server stores `priority` and `fairness_group` on the thread.
langgraph-api 0.18.0, the first stable release with the priority
callback, ignores a `fairness_weight` and logs a warning, so callbacks
written against 0.4.9 to 0.4.11 keep working. A callback annotated `->
PrioritySettings` that still returns the key gets a type-checker error.
- `PrioritySettings.priority`'s docstring and examples now say -5 runs
first and 5 runs last, matching the server. 0.4.9 to 0.4.11 say the
reverse, and their example gives premium users 3, which runs them after
everyone at the default 0.
- `fairness_group` is currently disabled: it has no effect on run order
for now, and its docstring says so. It also gives the limit: at most 256
characters, and an empty string takes the default, like a missing key.
- The changelog gets a `0.4.12` section, and the old `Unreleased`
heading becomes `0.4.11 and earlier`, since everything under it has
shipped.

## Test plan

- [x] `test_every_settings_key_is_optional` expects only `priority` and
`fairness_group`, and fails with the key put back.
- [x] `make test` in `libs/sdk-py`: 639 passed. `make lint` clean.
2026-11-03 01:03:26 +00:00
8 changed files with 69 additions and 45 deletions

No files matched your search

+19 -18
View File
@@ -154,12 +154,9 @@ def _get_pip_cleanup_lines(
pip_installer: Literal["uv", "pip"],
) -> str:
commands = [
f"""# -- Ensure user deps didn't inadvertently overwrite langgraph-api
RUN mkdir -p /api/langgraph_api /api/langgraph_runtime /api/langgraph_license && \
touch /api/langgraph_api/__init__.py /api/langgraph_runtime/__init__.py /api/langgraph_license/__init__.py
RUN PYTHONDONTWRITEBYTECODE=1 {install_cmd} --no-cache-dir --no-deps -e /api
# -- End of ensuring user deps didn't inadvertently overwrite langgraph-api --
# -- Removing build deps from the final image ~<:===~~~ --"""
"mkdir -p /api/langgraph_api /api/langgraph_runtime /api/langgraph_license && "
"touch /api/langgraph_api/__init__.py /api/langgraph_runtime/__init__.py /api/langgraph_license/__init__.py",
f"PYTHONDONTWRITEBYTECODE=1 {install_cmd} --no-cache-dir --no-deps -e /api",
]
if to_uninstall:
for pack in to_uninstall:
@@ -168,30 +165,31 @@ RUN PYTHONDONTWRITEBYTECODE=1 {install_cmd} --no-cache-dir --no-deps -e /api
f"Invalid build tool: {pack}; must be one of {', '.join(_BUILD_TOOLS)}"
)
packs_str = " ".join(sorted(to_uninstall))
commands.append(f"RUN pip uninstall -y {packs_str}")
commands.append(f"pip uninstall -y {packs_str}")
# Ensure the directories are removed entirely
packages_rm = " ".join(
f"/usr/local/lib/python*/site-packages/{pack}*" for pack in to_uninstall
)
if "pip" in to_uninstall:
packages_rm += ' && find /usr/local/bin -name "pip*" -delete || true'
commands.append(f"RUN rm -rf {packages_rm}")
commands.append(f"rm -rf {packages_rm}")
wolfi_packages_rm = " ".join(
f"/usr/lib/python*/site-packages/{pack}*" for pack in to_uninstall
)
if "pip" in to_uninstall:
wolfi_packages_rm += ' && find /usr/bin -name "pip*" -delete || true'
commands.append(f"RUN rm -rf {wolfi_packages_rm}")
commands.append(f"rm -rf {wolfi_packages_rm}")
if pip_installer == "uv":
commands.append(
f"RUN uv pip uninstall --system {packs_str} && rm /usr/bin/uv /usr/bin/uvx"
f"uv pip uninstall --system {packs_str} && rm /usr/bin/uv /usr/bin/uvx"
)
else:
if pip_installer == "uv":
commands.append(
"RUN rm /usr/bin/uv /usr/bin/uvx\n# -- End of build deps removal --"
)
return "\n".join(commands)
elif pip_installer == "uv":
commands.append("rm /usr/bin/uv /usr/bin/uvx")
# One RUN, since each layer is costly on copy-based snapshotters; subshells scope each `|| true`.
return (
"# -- Restore langgraph-api over user deps, then remove build deps --\n"
"RUN " + " && \\\n ".join(f"({command})" for command in commands)
)
def _parse_version(version_str: str) -> tuple[int, int]:
@@ -1608,7 +1606,10 @@ ADD {relpath} /deps/{name}
pip_installer=pip_installer,
),
"",
f"WORKDIR {local_deps.working_dir}" if local_deps.working_dir else "",
# The JS install step already set this WORKDIR.
f"WORKDIR {local_deps.working_dir}"
if local_deps.working_dir and not js_inst_str
else "",
]
)
@@ -1690,7 +1691,7 @@ def node_config_to_docker(
"",
os.linesep.join(env_vars),
"",
f"WORKDIR {build_workdir}",
f"WORKDIR {build_workdir}" if build_workdir != install_workdir else "",
"",
build_step,
]
+6
View File
@@ -65,6 +65,7 @@ class UvLockPlan:
@dataclass(slots=True)
class DockerBuildPlan:
lines: list[str]
workdir: str | None = None
def add_blank(self) -> None:
self.lines.append("")
@@ -73,6 +74,11 @@ class DockerBuildPlan:
self.lines.append(line)
def add_instruction(self, opcode: str, value: str | None = None) -> None:
# Repeating the current WORKDIR changes nothing but still costs a build step.
if opcode == "WORKDIR":
if value == self.workdir:
return
self.workdir = value
if value:
self.lines.append(f"{opcode} {value}")
else:
+15 -11
View File
@@ -1231,7 +1231,6 @@ ENV LANGGRAPH_AUTH='{"path": "./graphs/auth.mts:auth"}'
ENV LANGGRAPH_UI='{"agent": "./graphs/agent.ui.jsx"}'
ENV LANGGRAPH_UI_CONFIG='{"shared": ["nuqs"]}'
ENV LANGSERVE_GRAPHS='{"agent": "./graphs/agent.js:graph"}'
WORKDIR /deps/unit_tests
RUN (test ! -f /api/langgraph_api/js/build.mts && echo "Prebuild script not found, skipping") || tsx /api/langgraph_api/js/build.mts"""
assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin
@@ -1422,7 +1421,6 @@ ENV LANGGRAPH_AUTH='{"path": "./graphs/auth.mts:auth"}'
ENV LANGGRAPH_UI='{"agent": "./graphs/agent.ui.jsx"}'
ENV LANGGRAPH_UI_CONFIG='{"shared": ["nuqs"]}'
ENV LANGSERVE_GRAPHS='{"agent": "./graphs/agent.js:graph"}'
WORKDIR /deps/unit_tests
RUN (test ! -f /api/langgraph_api/js/build.mts && echo "Prebuild script not found, skipping") || tsx /api/langgraph_api/js/build.mts"""
assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin
@@ -1550,8 +1548,7 @@ ENV NODE_VERSION=20
WORKDIR /deps/outer-unit_tests/unit_tests
RUN npm i && tsx /api/langgraph_api/js/build.mts
# -- End of JS dependencies install --
{FORMATTED_CLEANUP_LINES}
WORKDIR /deps/outer-unit_tests/unit_tests"""
{FORMATTED_CLEANUP_LINES}"""
assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin
assert additional_contexts == {}
@@ -1595,8 +1592,7 @@ ENV NODE_VERSION=22
WORKDIR /deps/outer-unit_tests/unit_tests
RUN npm i && tsx /api/langgraph_api/js/build.mts
# -- End of JS dependencies install --
{FORMATTED_CLEANUP_LINES}
WORKDIR /deps/outer-unit_tests/unit_tests"""
{FORMATTED_CLEANUP_LINES}"""
assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin
assert additional_contexts == {}
@@ -2152,7 +2148,8 @@ def test_config_to_docker_uv_lock_detects_js_pm_from_target_package_root():
config_path, config, base_image="langchain/langgraph-api:0.2.47"
)
assert "WORKDIR /deps/workspace/apps/agent" in docker
# Package install, JS install and runtime all share this dir: one WORKDIR.
assert docker.splitlines().count("WORKDIR /deps/workspace/apps/agent") == 1
assert (
"RUN pnpm i --frozen-lockfile && tsx /api/langgraph_api/js/build.mts"
in docker
@@ -2803,7 +2800,7 @@ def test_config_retain_build_tools():
"/usr/local/lib/python*/site-packages/" + pckg + "*" in docker_true
for pckg in _BUILD_TOOLS
)
assert "RUN pip uninstall -y pip setuptools wheel" not in docker_true
assert "(pip uninstall -y pip setuptools wheel)" not in docker_true
config_false = validate_config(
{**copy.deepcopy(base_config), "keep_pkg_tools": False}
)
@@ -2814,7 +2811,7 @@ def test_config_retain_build_tools():
"/usr/local/lib/python*/site-packages/" + pckg + "*" in docker_false
for pckg in _BUILD_TOOLS
)
assert "RUN pip uninstall -y pip setuptools wheel" in docker_false
assert "(pip uninstall -y pip setuptools wheel)" in docker_false
config_list = validate_config(
{**copy.deepcopy(base_config), "keep_pkg_tools": ["pip", "setuptools"]}
)
@@ -2829,8 +2826,15 @@ def test_config_retain_build_tools():
"/usr/local/lib/python*/site-packages/" + pckg + "*" in docker_list
for pckg in ("pip", "setuptools")
)
assert "RUN pip uninstall -y wheel" in docker_list
assert "RUN pip uninstall -y pip setuptools" not in docker_list
assert "(pip uninstall -y wheel)" in docker_list
assert "(pip uninstall -y pip setuptools)" not in docker_list
def test_pip_cleanup_is_a_single_run_with_scoped_fallbacks():
cleanup = _get_pip_cleanup_lines("uv pip install --system", _BUILD_TOOLS, "uv")
assert cleanup.count("RUN ") == 1
# Each `|| true` must close its own subshell so it can't mask later steps.
assert cleanup.count("|| true)") == cleanup.count("|| true") == 2
# config_to_compose
+17 -2
View File
@@ -1,6 +1,21 @@
# Changelog
## Unreleased
## 0.4.12
### Removed
- **`PrioritySettings.fairness_weight`** — removed. The server stores only
`priority` and `fairness_group` on the thread; it ignores a
`fairness_weight` and logs a warning. Remove it from your callback.
### Changed
- **`PrioritySettings.priority`** — now says -5 runs first and 5 runs last,
matching how the server orders runs. 0.4.9 to 0.4.11 said the reverse, and
their example gave premium users 3, which runs them after everyone at the
default 0. Check any callback written from those docs.
## 0.4.11 and earlier
### Added
@@ -38,7 +53,7 @@
and name the object in the `priority` section of `langgraph.json`. The
server calls the function with a `PriorityContext` at the first run on a
thread with no settings and stores the `PrioritySettings` it returns
(`priority`, `fairness_group`, `fairness_weight`) on the thread. Requires
(`priority`, `fairness_group`) on the thread. Requires
`langgraph-api` 0.18.0 or later and a `langgraph-cli` that supports the
section; older versions of either ignore it.
+1 -1
View File
@@ -5,7 +5,7 @@ from langgraph_sdk.encryption.types import DecryptResult, EncryptionContext
from langgraph_sdk.priority import Priority
from langgraph_sdk.priority.types import PriorityContext, PrioritySettings
__version__ = "0.4.11"
__version__ = "0.4.12"
__all__ = [
"Auth",
@@ -102,7 +102,7 @@ class Priority:
@priority.assign
async def assign(ctx: PriorityContext) -> PrioritySettings | None:
return {
"priority": 3 if "premium" in ctx.permissions else 0,
"priority": -3 if "premium" in ctx.permissions else 0,
# Stored unencrypted, so an opaque ID rather than personal data.
"fairness_group": ctx.user["org_id"],
}
@@ -134,7 +134,7 @@ class Priority:
@priority.assign
async def assign(ctx: PriorityContext) -> PrioritySettings | None:
if ctx.cron_id is not None:
return {"priority": -2}
return {"priority": 2}
return None
```
+8 -6
View File
@@ -50,18 +50,20 @@ class PrioritySettings(typing.TypedDict, total=False):
Every key is optional: a missing key or a key set to `None` takes its
default, and returning `None` instead of a dict gives every key its default.
A value outside its range, or an empty `fairness_group`, is an error.
A `priority` outside -5 to 5, or a `fairness_group` longer than 256
characters, is an error.
"""
priority: int | None
"""An integer from -5 to 5, where 5 runs first and -5 runs last. Defaults to 0."""
"""An integer from -5 to 5, where -5 runs first and 5 runs last. Defaults to 0."""
fairness_group: str | None
"""The thread's fairness group, a non-empty string. Defaults to `"default"`.
"""Currently disabled.
The thread's fairness group, a string of at most 256 characters.
Defaults to `"default"`, as does an empty string. It has no effect on run
order for now.
The server stores it unencrypted, even when the deployment uses custom
encryption, so use an opaque ID rather than personal data.
"""
fairness_weight: int | None
"""The weight of the thread's fairness group, an integer from 1 to 100. Defaults to 1."""
+1 -5
View File
@@ -120,9 +120,5 @@ def test_context_is_frozen():
def test_every_settings_key_is_optional():
assert PrioritySettings.__optional_keys__ == {
"priority",
"fairness_group",
"fairness_weight",
}
assert PrioritySettings.__optional_keys__ == {"priority", "fairness_group"}
assert PrioritySettings.__required_keys__ == frozenset()