Compare commits

..
Author SHA1 Message Date
John KennedyandGitHub 428ebe1a01 Merge branch 'main' into langster/remove-stale-debugger-pull 2026-09-25 21:38:30 -07:00
John Kennedyandopen-swe[bot] <open-swe@users.noreply.github.com> 8e05912899 fix(cli): extend API healthcheck startup window
Keep one-second health probes active for slower graph imports so Compose does not stall for the steady-state interval.

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
2026-08-20 07:24:09 +00:00
John KennedyandGitHub f899af1c73 Merge branch 'main' into langster/remove-stale-debugger-pull 2026-08-20 00:07:35 -07:00
John Kennedy 5f479f1af5 fix(cli): preserve hosted Studio URL overrides 2026-08-11 09:39:33 -07:00
John Kennedy 33c3edfde2 fix(cli): drop unrelated uv export change 2026-08-11 09:39:24 -07:00
John Kennedyandopen-swe[bot] <open-swe@users.noreply.github.com> 292fd5787c fix(cli): include workspace metadata in uv exports
Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
2026-08-10 23:03:38 +00:00
John Kennedyandopen-swe[bot] <open-swe@users.noreply.github.com> b904db211c fix(cli): update integration runner arguments
Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
2026-08-10 22:53:15 +00:00
langsmith-fleet[bot] ecee22fa16 fix(cli): remove discontinued local debugger image 2026-08-06 00:01:58 +00:00
34 changed files with 338 additions and 1043 deletions
+1 -3
View File
@@ -32,8 +32,6 @@ def test(config: pathlib.Path, port: int, tag: str, verbose: bool):
docker_compose=None, docker_compose=None,
port=port, port=port,
watch=False, watch=False,
debugger_port=None,
debugger_base_url=f"http://127.0.0.1:{port}",
postgres_uri=None, postgres_uri=None,
api_version=None, api_version=None,
image=tag, image=tag,
@@ -173,5 +171,5 @@ if __name__ == "__main__":
except BaseException: except BaseException:
logger.exception("Test failed") logger.exception("Test failed")
raise raise
logger.info("Test execution finished") logger.info("Test execution finished")
+36 -23
View File
@@ -139,10 +139,23 @@ jobs:
echo EOF echo EOF
} >> "$GITHUB_OUTPUT" } >> "$GITHUB_OUTPUT"
test-pypi-publish:
needs:
- build
- release-notes
permissions:
contents: read
id-token: write
uses: ./.github/workflows/_test_release.yml
with:
working-directory: ${{ inputs.working-directory }}
secrets: inherit
pre-release-checks: pre-release-checks:
needs: needs:
- build - build
- release-notes - release-notes
- test-pypi-publish
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -167,20 +180,31 @@ jobs:
enable-cache: false enable-cache: false
working-directory: ${{ inputs.working-directory }} working-directory: ${{ inputs.working-directory }}
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - name: Import published package
with:
name: dist
path: ${{ inputs.working-directory }}/dist/
- name: Import dist package
shell: bash shell: bash
working-directory: ${{ inputs.working-directory }} working-directory: ${{ inputs.working-directory }}
env: env:
PKG_NAME: ${{ needs.build.outputs.pkg-name }} PKG_NAME: ${{ needs.build.outputs.pkg-name }}
VERSION: ${{ needs.build.outputs.version }} VERSION: ${{ needs.build.outputs.version }}
# Install directly from the locally-built wheel (no index resolution needed). # Here we use:
# - The default regular PyPI index as the *primary* index, meaning
# that it takes priority (https://pypi.org/simple)
# - The test PyPI index as an extra index, so that any dependencies that
# are not found on test PyPI can be resolved and installed anyway.
# (https://test.pypi.org/simple). This will include the PKG_NAME==VERSION
# package because VERSION will not have been uploaded to regular PyPI yet.
# - attempt install again after 5 seconds if it fails because there is
# sometimes a delay in availability on test pypi
run: | run: |
uv run pip install dist/*.whl uv run pip install \
--extra-index-url https://test.pypi.org/simple/ \
"$PKG_NAME==$VERSION" || \
( \
sleep 5 && \
uv run pip install \
--extra-index-url https://test.pypi.org/simple/ \
"$PKG_NAME==$VERSION" \
)
if [[ "$PKG_NAME" == *prebuilt* ]]; then if [[ "$PKG_NAME" == *prebuilt* ]]; then
uv run pip install langgraph uv run pip install langgraph
@@ -202,7 +226,7 @@ jobs:
run: uv sync --group test run: uv sync --group test
working-directory: ${{ inputs.working-directory }} working-directory: ${{ inputs.working-directory }}
# Overwrite the local version of the package with the built version # Overwrite the local version of the package with the test PyPI version.
- name: Import published package (again) - name: Import published package (again)
working-directory: ${{ inputs.working-directory }} working-directory: ${{ inputs.working-directory }}
shell: bash shell: bash
@@ -210,25 +234,14 @@ jobs:
PKG_NAME: ${{ needs.build.outputs.pkg-name }} PKG_NAME: ${{ needs.build.outputs.pkg-name }}
VERSION: ${{ needs.build.outputs.version }} VERSION: ${{ needs.build.outputs.version }}
run: | run: |
uv run pip install dist/*.whl uv run pip install \
--extra-index-url https://test.pypi.org/simple/ \
"$PKG_NAME==$VERSION"
- name: Run unit tests - name: Run unit tests
run: make test run: make test
working-directory: ${{ inputs.working-directory }} working-directory: ${{ inputs.working-directory }}
test-pypi-publish:
needs:
- build
- release-notes
- pre-release-checks
permissions:
contents: read
id-token: write
uses: ./.github/workflows/_test_release.yml
with:
working-directory: ${{ inputs.working-directory }}
secrets: inherit
publish: publish:
needs: needs:
- build - build
+3 -3
View File
@@ -943,11 +943,11 @@ wheels = [
[[package]] [[package]]
name = "urllib3" name = "urllib3"
version = "2.8.0" version = "2.7.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" } sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
] ]
[[package]] [[package]]
@@ -448,12 +448,11 @@ class PostgresSaver(BasePostgresSaver):
Two-stage query, both stages cover ALL requested channels: Two-stage query, both stages cover ALL requested channels:
* Stage 1 (paged): dynamic SELECT over `checkpoints` with three * Stage 1 (paged): dynamic SELECT over `checkpoints` with K parallel
columns per channel: its version, an `EXISTS` probe for a stored JSONB key lookups (one column pair per channel) — no subquery, no
blob at that version, and its inline value. Pages newest-first by aggregation. Pages newest-first by `checkpoint_id` with a cursor;
`checkpoint_id` with a cursor; page size is `_DELTA_PAGE_SIZE`. page size is `_DELTA_PAGE_SIZE`. Stops paging when every channel
Stops paging when every channel has found its seed or a page comes has found its seed or the chain is exhausted.
back short.
* Stage 2 (per-channel UNION ALL): one branch per channel reading * Stage 2 (per-channel UNION ALL): one branch per channel reading
`checkpoint_writes` filtered to that channel's specific `checkpoint_writes` filtered to that channel's specific
@@ -172,8 +172,30 @@ class _DeltaStage2Row(TypedDict, total=False):
version: str | None # "b" rows only version: str | None # "b" rows only
# Delta history is rebuilt in two queries; `_build_delta_stage1_sql` and # Multi-channel two-stage DeltaChannel reconstruction.
# `_build_delta_stage2_sql` document their shapes. #
# Stage 1 scans checkpoint metadata (no blob bytes) and emits one row per
# checkpoint with K parallel JSONB key lookups (one column pair per
# requested delta channel: ver_i / hs_i). No subqueries, no aggregation.
# Python walks the parent chain once across all channels.
#
# Stage 2 fetches all writes and the seed blobs for ALL channels in a
# single roundtrip via `channel = ANY(%s)` and chain/seed-version
# filtering.
#
# Empirical comparison vs an alternative "ship full channel_versions /
# channel_values JSONB and let Python pick" form (1000 checkpoints,
# 8 total channels in graph, 3 delta channels requested):
#
# Postgres execution: A=0.24ms vs B=0.38ms (both negligible)
# End-to-end latency: A=6.83ms vs B=2.28ms (B is 3.0x faster)
# Wire payload: A=836KB vs B=330KB (61% smaller)
# Buffer hits: identical (167 blocks)
#
# B (this dynamic-columns design) wins because it avoids JSONB
# serialization on the wire and JSONB-to-dict deserialization in
# psycopg. Even at K=8 (8 delta channels = 16 dynamic columns), B
# still beats A end-to-end (4.2ms vs 6.8ms).
def _build_delta_stage1_sql(channels: Sequence[str], *, paged: bool) -> str: def _build_delta_stage1_sql(channels: Sequence[str], *, paged: bool) -> str:
@@ -313,8 +335,10 @@ def _build_delta_stage2_sql(
return " UNION ALL ".join(branches) return " UNION ALL ".join(branches)
# Stage 1 rows are dicts keyed by the per-channel aliases # Stage 1 rows are dynamic-shape dicts: {checkpoint_id, parent_checkpoint_id,
# `_build_delta_stage1_sql` emits, so there is no static TypedDict. # ver_0, hs_0, ver_1, hs_1, ...}. Walking is parameterized by the channel
# list to map indices back to channel names — no static TypedDict here.
# `dict[str, Any]` is the practical signature.
class BasePostgresSaver(BaseCheckpointSaver[str]): class BasePostgresSaver(BaseCheckpointSaver[str]):
@@ -407,11 +431,9 @@ class BasePostgresSaver(BaseCheckpointSaver[str]):
(a) it found a stored value for its channel — a blob or an inline (a) it found a stored value for its channel — a blob or an inline
primitive (channel becomes seeded), primitive (channel becomes seeded),
(b) it reached a real root (parent_of[cid] is None — fully (b) it reached a real root (parent_of[cid] is None — fully
materialized at this point), materialized at this point), or
(c) the next ancestor cid isn't in `parent_of` yet (waiting for (c) the next ancestor cid isn't in `parent_of` yet (waiting for
a later page; the cursor stays put), or a later page; the cursor stays put).
(d) the target's own row isn't in `parent_of` yet (the walk has
not started; no cursor is set, so a later page retries).
Mutates `chain_by_ch`, `seed_ver_by_ch`, `seed_inline_by_ch`, Mutates `chain_by_ch`, `seed_ver_by_ch`, `seed_inline_by_ch`,
`walk_cursor_by_ch`, and `seeded` in place. `walk_cursor_by_ch`, and `seeded` in place.
@@ -419,12 +441,9 @@ class BasePostgresSaver(BaseCheckpointSaver[str]):
for i, ch in enumerate(channels): for i, ch in enumerate(channels):
if ch in seeded: if ch in seeded:
continue continue
# Pages start at the thread head, so the target may not have # First-time entry: cursor starts at the target's parent.
# loaded yet; a `None` cursor would read as "target is a root".
if ch not in walk_cursor_by_ch: if ch not in walk_cursor_by_ch:
if target_id not in parent_of: walk_cursor_by_ch[ch] = parent_of.get(target_id)
continue
walk_cursor_by_ch[ch] = parent_of[target_id]
cur_cid = walk_cursor_by_ch[ch] cur_cid = walk_cursor_by_ch[ch]
ch_chain = chain_by_ch[ch] ch_chain = chain_by_ch[ch]
hb_i = hb_by_i_by_cid[i] hb_i = hb_by_i_by_cid[i]
@@ -1,132 +0,0 @@
from __future__ import annotations
from typing import Any
from uuid import uuid4
import pytest
from langgraph.checkpoint.base import (
Checkpoint,
DeltaChannelHistory,
empty_checkpoint,
)
from langgraph.checkpoint.base.id import uuid6
from langgraph.checkpoint.serde.types import _DeltaSnapshot
from langgraph.checkpoint.postgres import PostgresSaver
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
from langgraph.checkpoint.postgres.base import _DELTA_PAGE_SIZE
from tests.conftest import DEFAULT_URI
CHANNEL = "items"
STEPS = 8
SEED_STEP = 1
SEED_VALUE = [10, 20]
TARGET_STEP = 4
# The real page size is the control; the rest leave the target off the first
# page (three checkpoints are newer than it).
PAGE_SIZES = [_DELTA_PAGE_SIZE, 3, 2, 1]
def _step_args(
thread_id: str, step: int, parent: dict | None
) -> tuple[dict, Checkpoint, dict[str, Any]]:
config: dict = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
if parent is not None:
config["configurable"]["checkpoint_id"] = parent["configurable"][
"checkpoint_id"
]
checkpoint: Checkpoint = empty_checkpoint()
checkpoint["id"] = str(uuid6(clock_seq=step))
checkpoint["channel_versions"][CHANNEL] = f"v{step}"
if step == SEED_STEP:
checkpoint["channel_values"][CHANNEL] = _DeltaSnapshot(list(SEED_VALUE))
return config, checkpoint, {CHANNEL: f"v{step}"}
return config, checkpoint, {}
async def _abuild_chain(saver: AsyncPostgresSaver) -> list[dict]:
thread_id = str(uuid4())
parent: dict | None = None
configs: list[dict] = []
for step in range(STEPS):
config, checkpoint, new_versions = _step_args(thread_id, step, parent)
parent = await saver.aput(
config,
checkpoint,
{"source": "loop", "step": step, "parents": {}},
new_versions,
)
await saver.aput_writes(parent, [(CHANNEL, f"w{step}")], str(uuid4()))
configs.append(parent)
return configs
def _build_chain(saver: PostgresSaver) -> list[dict]:
thread_id = str(uuid4())
parent: dict | None = None
configs: list[dict] = []
for step in range(STEPS):
config, checkpoint, new_versions = _step_args(thread_id, step, parent)
parent = saver.put(
config,
checkpoint,
{"source": "loop", "step": step, "parents": {}},
new_versions,
)
saver.put_writes(parent, [(CHANNEL, f"w{step}")], str(uuid4()))
configs.append(parent)
return configs
def _assert_history(entry: DeltaChannelHistory, page_size: int) -> None:
seed = entry.get("seed")
assert isinstance(seed, _DeltaSnapshot), (
f"page_size={page_size}: expected a snapshot seed, "
f"got {entry.get('seed', '<missing>')!r}"
)
assert seed.value == SEED_VALUE
assert [w[2] for w in entry["writes"]] == ["w1", "w2", "w3"], (
f"page_size={page_size}: got {[w[2] for w in entry['writes']]}"
)
@pytest.mark.parametrize("page_size", PAGE_SIZES)
async def test_async_target_older_than_the_first_page(
page_size: int, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr("langgraph.checkpoint.postgres.aio._DELTA_PAGE_SIZE", page_size)
async with AsyncPostgresSaver.from_conn_string(DEFAULT_URI) as saver:
await saver.setup()
configs = await _abuild_chain(saver)
result = await saver.aget_delta_channel_history(
config=configs[TARGET_STEP], channels=[CHANNEL]
)
_assert_history(result[CHANNEL], page_size)
@pytest.mark.parametrize("page_size", PAGE_SIZES)
def test_sync_target_older_than_the_first_page(
page_size: int, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr("langgraph.checkpoint.postgres._DELTA_PAGE_SIZE", page_size)
with PostgresSaver.from_conn_string(DEFAULT_URI) as saver:
saver.setup()
configs = _build_chain(saver)
result = saver.get_delta_channel_history(
config=configs[TARGET_STEP], channels=[CHANNEL]
)
_assert_history(result[CHANNEL], page_size)
async def test_root_target_has_no_history_and_still_terminates(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr("langgraph.checkpoint.postgres.aio._DELTA_PAGE_SIZE", 1)
async with AsyncPostgresSaver.from_conn_string(DEFAULT_URI) as saver:
await saver.setup()
configs = await _abuild_chain(saver)
result = await saver.aget_delta_channel_history(
config=configs[0], channels=[CHANNEL]
)
assert result[CHANNEL] == {"writes": []}
+3 -3
View File
@@ -1122,11 +1122,11 @@ wheels = [
[[package]] [[package]]
name = "urllib3" name = "urllib3"
version = "2.8.0" version = "2.7.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" } sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
] ]
[[package]] [[package]]
@@ -507,12 +507,13 @@ class SqliteSaver(BaseCheckpointSaver[str]):
Two-stage query: Two-stage query:
* Stage 1 (streamed): recursive CTE over `checkpoints` following * Stage 1 (paged): newest-first slice of `checkpoints` returning
`parent_checkpoint_id` from the target, returning `(checkpoint_id, parent_checkpoint_id, type, checkpoint)` per
`(checkpoint_id, type, checkpoint)` per ancestor. Sqlite has no ancestor. Sqlite has no JSONB, so we ship the full serialized
JSONB, so we ship the full serialized checkpoint blob and inspect checkpoint blob and inspect `channel_values` in Python. Pages
`channel_values` in Python. Stops reading when every channel has newest-first by `checkpoint_id` with a `< cursor` predicate;
found its seed or the chain is exhausted. page size is `DELTA_PAGE_SIZE`. Stops paging when every channel
has found its seed or the chain is exhausted.
* Stage 2 (per-channel UNION ALL): one branch per channel reading * Stage 2 (per-channel UNION ALL): one branch per channel reading
`writes` filtered to that channel's specific `chain_cids`. No `writes` filtered to that channel's specific `chain_cids`. No
@@ -537,14 +538,12 @@ class SqliteSaver(BaseCheckpointSaver[str]):
seeded: set[str] = set() seeded: set[str] = set()
with self.cursor(transaction=False) as cur: with self.cursor(transaction=False) as cur:
cur.execute( cur.execute(DELTA_STAGE1_SQL, (thread_id, checkpoint_ns, checkpoint_id))
DELTA_STAGE1_SQL,
(thread_id, checkpoint_ns, checkpoint_id, thread_id, checkpoint_ns),
)
for row in cur: for row in cur:
cid, type_tag, blob = row cid, parent_cid, type_tag, blob = row
if step_walk_with_row( if step_walk_with_row(
cid=cid, cid=cid,
parent_cid=parent_cid,
type_tag=type_tag, type_tag=type_tag,
blob=blob, blob=blob,
target_id=checkpoint_id, target_id=checkpoint_id,
@@ -26,33 +26,16 @@ from typing import Any
from langgraph.checkpoint.base import DeltaChannelHistory, PendingWrite from langgraph.checkpoint.base import DeltaChannelHistory, PendingWrite
# Stage 1 streams target, then its ancestors nearest-first, by following # Stage 1 streams ancestors of `target_cid` newest-first. The `<=`
# `parent_checkpoint_id` rather than id order: ids are only monotonic within # predicate keeps target itself in the stream so we can read its
# one process, so a range scan by id can miss a parent whose id sorts above # `parent_checkpoint_id` from the first row without a separate lookup;
# its child's. Target is the anchor row; its own writes/seed are skipped # the caller skips target's own writes/seed (matches the
# (matches the `BaseCheckpointSaver` contract). # `BaseCheckpointSaver` contract).
#
# `put` is `INSERT OR REPLACE`, so re-putting an existing id under a
# descendant's config makes the chain a loop. `step_walk_with_row` stops on a
# repeated id; sqlite yields recursive rows lazily, so abandoning the cursor
# ends the recursion.
#
# `CROSS JOIN` pins `ancestors` as the outer loop, so each step is one primary
# key lookup. With a plain `JOIN` and no `ANALYZE` stats, sqlite can put
# `checkpoints` outside and scan the whole thread per step.
DELTA_STAGE1_SQL = ( DELTA_STAGE1_SQL = (
"WITH RECURSIVE ancestors(checkpoint_id, parent_checkpoint_id, type, "
"checkpoint) AS ("
"SELECT checkpoint_id, parent_checkpoint_id, type, checkpoint " "SELECT checkpoint_id, parent_checkpoint_id, type, checkpoint "
"FROM checkpoints " "FROM checkpoints "
"WHERE thread_id = ? AND checkpoint_ns = ? AND checkpoint_id = ? " "WHERE thread_id = ? AND checkpoint_ns = ? AND checkpoint_id <= ? "
"UNION ALL " "ORDER BY checkpoint_id DESC"
"SELECT c.checkpoint_id, c.parent_checkpoint_id, c.type, c.checkpoint "
"FROM ancestors a CROSS JOIN checkpoints c "
"ON c.checkpoint_id = a.parent_checkpoint_id "
"WHERE c.thread_id = ? AND c.checkpoint_ns = ?"
") "
"SELECT checkpoint_id, type, checkpoint FROM ancestors"
) )
@@ -85,6 +68,7 @@ def build_delta_stage2_sql(*, chain_lens: Sequence[int]) -> str:
def step_walk_with_row( def step_walk_with_row(
*, *,
cid: str, cid: str,
parent_cid: str | None,
type_tag: str, type_tag: str,
blob: bytes, blob: bytes,
target_id: str, target_id: str,
@@ -97,32 +81,36 @@ def step_walk_with_row(
) -> bool: ) -> bool:
"""Process one streamed stage-1 row in the merged ancestor walk. """Process one streamed stage-1 row in the merged ancestor walk.
The cursor returns (cid, type, blob) rows in walk order starting at The cursor returns (cid, parent_cid, type, blob) rows in
target. The first row is target itself and is skipped (target's own `checkpoint_id` DESC order starting at target. The first row is
writes/seed are not part of the contract). target itself; we read its parent_cid to seed the walk and otherwise
skip it (target's own writes/seed are not part of the contract).
For each subsequent row we deserialize the blob, append the cid to For each subsequent row, if `cid` matches the walk's current
every not-yet-seeded channel's chain, and check `channel_values` for position, we deserialize the blob, append the cid to every
not-yet-seeded channel's chain, and check `channel_values` for
seeds. The deserialized checkpoint is dropped before advancing — no seeds. The deserialized checkpoint is dropped before advancing — no
cross-row cache, so peak in-flight is one deserialized checkpoint. cross-row cache, so peak in-flight is one deserialized checkpoint.
Returns True when the caller can stop iterating and close the cursor: Off-path rows (different branch on the same thread) advance the
every requested channel is seeded, or the chain revisited a checkpoint. cursor without doing any work.
Returns True when every requested channel is seeded — the caller
can stop iterating and close the cursor.
""" """
if "started" not in walk_state: if "started" not in walk_state:
if cid == target_id: if cid == target_id:
walk_state["started"] = True walk_state["started"] = True
walk_state["cur_cid"] = parent_cid
walk_state["active"] = {ch for ch in channels if ch not in seeded} walk_state["active"] = {ch for ch in channels if ch not in seeded}
walk_state["walked"] = {cid}
# Not target yet (or target not present): keep streaming. # Not target yet (or target not present): keep streaming.
return False return False
active: set[str] = walk_state["active"] active: set[str] = walk_state["active"]
if not active: if not active:
return True return True
walked: set[str] = walk_state["walked"] if cid != walk_state["cur_cid"]:
if cid in walked: # Off-path row from a sibling branch — skip without deserializing.
return True return False
walked.add(cid)
for ch in active: for ch in active:
chain_by_ch[ch].append(cid) chain_by_ch[ch].append(cid)
ckpt = serde.loads_typed((type_tag, blob)) ckpt = serde.loads_typed((type_tag, blob))
@@ -132,6 +120,7 @@ def step_walk_with_row(
seeded.add(ch) seeded.add(ch)
active.discard(ch) active.discard(ch)
del ckpt, channel_values del ckpt, channel_values
walk_state["cur_cid"] = parent_cid
return not active return not active
@@ -625,8 +625,8 @@ class AsyncSqliteSaver(BaseCheckpointSaver[str]):
"""Fast-path override of `BaseCheckpointSaver.aget_delta_channel_history`. """Fast-path override of `BaseCheckpointSaver.aget_delta_channel_history`.
See `SqliteSaver.get_delta_channel_history` for design notes; this See `SqliteSaver.get_delta_channel_history` for design notes; this
is the async equivalent using `aiosqlite` cursors. Stage 1 streams is the async equivalent using `aiosqlite` cursors. Stage 1 pages
the parent chain from the target and Python-deserializes each the parent chain newest-first and Python-deserializes each
checkpoint blob to find per-channel snapshots; stage 2 fetches checkpoint blob to find per-channel snapshots; stage 2 fetches
only the relevant writes via per-channel UNION ALL. only the relevant writes via per-channel UNION ALL.
""" """
@@ -650,13 +650,13 @@ class AsyncSqliteSaver(BaseCheckpointSaver[str]):
async with self.lock, self.conn.cursor() as cur: async with self.lock, self.conn.cursor() as cur:
await cur.execute( await cur.execute(
DELTA_STAGE1_SQL, DELTA_STAGE1_SQL, (thread_id, checkpoint_ns, checkpoint_id)
(thread_id, checkpoint_ns, checkpoint_id, thread_id, checkpoint_ns),
) )
async for row in cur: async for row in cur:
cid, type_tag, blob = row cid, parent_cid, type_tag, blob = row
if step_walk_with_row( if step_walk_with_row(
cid=cid, cid=cid,
parent_cid=parent_cid,
type_tag=type_tag, type_tag=type_tag,
blob=blob, blob=blob,
target_id=checkpoint_id, target_id=checkpoint_id,
@@ -1,104 +0,0 @@
from __future__ import annotations
from typing import Any
import pytest
from langgraph.checkpoint.base import (
BaseCheckpointSaver,
Checkpoint,
DeltaChannelHistory,
empty_checkpoint,
)
from langgraph.checkpoint.sqlite import SqliteSaver
from langgraph.checkpoint.sqlite._delta import DELTA_STAGE1_SQL
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
CHANNEL = "ch"
CONFIG: dict[str, Any] = {"configurable": {"thread_id": "t", "checkpoint_ns": ""}}
EXPECTED: DeltaChannelHistory = {
"writes": [("task", CHANNEL, "write-root")],
"seed": "seed",
}
def _checkpoint(checkpoint_id: str, values: dict[str, Any]) -> Checkpoint:
value = empty_checkpoint()
value["id"] = checkpoint_id
value["channel_values"] = values
return value
PARENT_ID_ORDERS = [
pytest.param("z-older", "a-newer", id="parent_id_sorts_above_child"),
pytest.param("a-older", "z-newer", id="parent_id_sorts_below_child"),
]
@pytest.mark.parametrize(("root_id", "child_id"), PARENT_ID_ORDERS)
def test_sync_walk_reaches_parent_whatever_the_id_order(
root_id: str, child_id: str
) -> None:
with SqliteSaver.from_conn_string(":memory:") as saver:
root = saver.put(CONFIG, _checkpoint(root_id, {CHANNEL: "seed"}), {}, {})
saver.put_writes(root, [(CHANNEL, "write-root")], "task")
child = saver.put(root, _checkpoint(child_id, {}), {}, {})
got = saver.get_delta_channel_history(config=child, channels=[CHANNEL])
reference = BaseCheckpointSaver.get_delta_channel_history(
saver, config=child, channels=[CHANNEL]
)
assert got[CHANNEL] == EXPECTED
assert got[CHANNEL] == reference[CHANNEL], "fast path disagrees with base"
@pytest.mark.parametrize(("root_id", "child_id"), PARENT_ID_ORDERS)
async def test_async_walk_reaches_parent_whatever_the_id_order(
root_id: str, child_id: str
) -> None:
async with AsyncSqliteSaver.from_conn_string(":memory:") as saver:
root = await saver.aput(CONFIG, _checkpoint(root_id, {CHANNEL: "seed"}), {}, {})
await saver.aput_writes(root, [(CHANNEL, "write-root")], "task")
child = await saver.aput(root, _checkpoint(child_id, {}), {}, {})
got = await saver.aget_delta_channel_history(config=child, channels=[CHANNEL])
assert got[CHANNEL] == EXPECTED
def test_walk_reaches_root_of_long_chain_with_descending_ids() -> None:
steps = 40
with SqliteSaver.from_conn_string(":memory:") as saver:
parent = saver.put(
CONFIG, _checkpoint(f"id-{steps:03d}", {CHANNEL: "seed"}), {}, {}
)
saver.put_writes(parent, [(CHANNEL, "write-root")], "task")
for step in range(steps - 1, 0, -1):
parent = saver.put(parent, _checkpoint(f"id-{step:03d}", {}), {}, {})
got = saver.get_delta_channel_history(config=parent, channels=[CHANNEL])
assert got[CHANNEL] == EXPECTED
def test_walk_terminates_when_put_makes_the_parent_chain_cycle() -> None:
with SqliteSaver.from_conn_string(":memory:") as saver:
a = saver.put(CONFIG, _checkpoint("cid-a", {}), {}, {})
b = saver.put(a, _checkpoint("cid-b", {}), {}, {})
repoint_a_under_b = _checkpoint("cid-a", {})
saver.put(b, repoint_a_under_b, {}, {})
got = saver.get_delta_channel_history(config=b, channels=[CHANNEL])
assert got[CHANNEL] == {"writes": []}
def test_walk_step_looks_up_the_parent_by_primary_key() -> None:
with SqliteSaver.from_conn_string(":memory:") as saver:
saver.setup()
plan = [
row[3]
for row in saver.conn.execute(
f"EXPLAIN QUERY PLAN {DELTA_STAGE1_SQL}", ("t", "", "id", "t", "")
)
]
assert any(
step.startswith("SEARCH c ") and "checkpoint_id=?" in step for step in plan
), f"recursive step should look up the parent by key, got {plan}"
+3 -3
View File
@@ -1057,11 +1057,11 @@ wheels = [
[[package]] [[package]]
name = "urllib3" name = "urllib3"
version = "2.8.0" version = "2.7.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" } sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
] ]
[[package]] [[package]]
+3 -3
View File
@@ -1338,11 +1338,11 @@ wheels = [
[[package]] [[package]]
name = "urllib3" name = "urllib3"
version = "2.8.0" version = "2.7.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" } sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
] ]
[[package]] [[package]]
-2
View File
@@ -103,8 +103,6 @@ The CLI uses a `langgraph.json` configuration file with these key settings:
} }
``` ```
Git dependencies should use credential-free URLs. The CLI conservatively scans direct `langgraph.json` dependencies, common Python package files, uv project and lock files, and common Node.js package and lock files for HTTP Git URLs with userinfo. This check is not exhaustive: generated Docker builds can copy other files, including nested requirement or constraint files, into image layers without scanning them. For private dependencies, provide short-lived credentials through your build environment's secret-backed Git credential helper. Do not store credentials in copied files such as `langgraph.json` or `pip_config_file`.
See the [full documentation](https://reference.langchain.com/python/langgraph-cli) for detailed configuration options. See the [full documentation](https://reference.langchain.com/python/langgraph-cli) for detailed configuration options.
## Development ## Development
-3
View File
@@ -48,9 +48,6 @@ def get_anonymized_params(
if kwargs.get("docker_compose"): if kwargs.get("docker_compose"):
params["docker_compose"] = True params["docker_compose"] = True
if kwargs.get("debugger_port"):
params["debugger_port"] = True
if kwargs.get("postgres_uri"): if kwargs.get("postgres_uri"):
params["postgres_uri"] = True params["postgres_uri"] = True
+89 -34
View File
@@ -5,6 +5,7 @@ import pathlib
import shutil import shutil
import sys import sys
from collections.abc import Sequence from collections.abc import Sequence
from urllib.parse import SplitResult, urlencode, urlsplit, urlunsplit
import click import click
import click.exceptions import click.exceptions
@@ -140,17 +141,6 @@ OPT_VERBOSE = click.option(
help="Show more output from the server logs", help="Show more output from the server logs",
) )
OPT_WATCH = click.option("--watch", is_flag=True, help="Restart on file changes") OPT_WATCH = click.option("--watch", is_flag=True, help="Restart on file changes")
OPT_DEBUGGER_PORT = click.option(
"--debugger-port",
type=int,
help="Pull the debugger image locally and serve the UI on specified port",
)
OPT_DEBUGGER_BASE_URL = click.option(
"--debugger-base-url",
type=str,
help="URL used by the debugger to access LangGraph API. Defaults to http://127.0.0.1:[PORT]",
)
OPT_POSTGRES_URI = click.option( OPT_POSTGRES_URI = click.option(
"--postgres-uri", "--postgres-uri",
help="Postgres URI to use for the database. Defaults to launching a local database", help="Postgres URI to use for the database. Defaults to launching a local database",
@@ -242,18 +232,94 @@ cli.add_command(deploy)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _validated_http_url(value: str, option_name: str) -> SplitResult:
try:
parsed = urlsplit(value)
hostname = parsed.hostname
_ = parsed.port
except ValueError as exc:
raise click.UsageError(
f"{option_name} must be a valid HTTP(S) URL without credentials."
) from exc
if (
value != value.strip()
or parsed.scheme not in {"http", "https"}
or not parsed.netloc
or not hostname
or parsed.username is not None
or parsed.password is not None
):
raise click.UsageError(
f"{option_name} must be a valid HTTP(S) URL without credentials."
)
return parsed
def _studio_link(
*,
port: int,
studio_url: str | None,
api_url: str | None,
debugger_base_url: str | None,
) -> str:
if debugger_base_url is not None:
if api_url is not None and api_url != debugger_base_url:
raise click.UsageError(
"--api-url and --debugger-base-url cannot specify different URLs."
)
click.echo(
"Warning: --debugger-base-url is deprecated; use --api-url instead.",
err=True,
)
api_url = debugger_base_url
studio_url = "https://smith.langchain.com" if studio_url is None else studio_url
api_url = f"http://127.0.0.1:{port}" if api_url is None else api_url
studio_parts = _validated_http_url(studio_url, "--studio-url")
_validated_http_url(api_url, "--api-url")
if studio_parts.query or studio_parts.fragment:
raise click.UsageError(
"--studio-url must not include a query string or fragment."
)
studio_path = f"{studio_parts.path.rstrip('/')}/studio/"
return urlunsplit(
studio_parts._replace(
path=studio_path,
query=urlencode({"baseUrl": api_url}),
)
)
@OPT_RECREATE @OPT_RECREATE
@OPT_PULL @OPT_PULL
@OPT_PORT @OPT_PORT
@OPT_DOCKER_COMPOSE @OPT_DOCKER_COMPOSE
@OPT_CONFIG @OPT_CONFIG
@OPT_VERBOSE @OPT_VERBOSE
@OPT_DEBUGGER_PORT
@OPT_DEBUGGER_BASE_URL
@OPT_WATCH @OPT_WATCH
@OPT_POSTGRES_URI @OPT_POSTGRES_URI
@OPT_API_VERSION @OPT_API_VERSION
@OPT_ENGINE_RUNTIME_MODE @OPT_ENGINE_RUNTIME_MODE
@click.option(
"--studio-url",
type=str,
default=None,
help="URL of the LangGraph Studio instance. Defaults to https://smith.langchain.com",
)
@click.option(
"--api-url",
type=str,
default=None,
help="URL that LangGraph Studio uses to access the API. Defaults to http://127.0.0.1:[PORT]",
)
@click.option(
"--debugger-base-url",
type=str,
default=None,
hidden=True,
)
@click.option( @click.option(
"--image", "--image",
type=str, type=str,
@@ -284,14 +350,21 @@ def up(
watch: bool, watch: bool,
wait: bool, wait: bool,
verbose: bool, verbose: bool,
debugger_port: int | None,
debugger_base_url: str | None,
postgres_uri: str | None, postgres_uri: str | None,
api_version: str | None, api_version: str | None,
engine_runtime_mode: str, engine_runtime_mode: str,
studio_url: str | None,
api_url: str | None,
debugger_base_url: str | None,
image: str | None, image: str | None,
base_image: str | None, base_image: str | None,
): ):
studio_link = _studio_link(
port=port,
studio_url=studio_url,
api_url=api_url,
debugger_base_url=debugger_base_url,
)
click.secho("Starting LangGraph API server...", fg="green") click.secho("Starting LangGraph API server...", fg="green")
click.secho( click.secho(
"""For local dev, requires env var LANGSMITH_API_KEY with access to LangSmith Deployment. """For local dev, requires env var LANGSMITH_API_KEY with access to LangSmith Deployment.
@@ -308,8 +381,6 @@ For production use, requires a license key in env var LANGGRAPH_CLOUD_LICENSE_KE
pull=pull, pull=pull,
watch=watch, watch=watch,
verbose=verbose, verbose=verbose,
debugger_port=debugger_port,
debugger_base_url=debugger_base_url,
postgres_uri=postgres_uri, postgres_uri=postgres_uri,
api_version=api_version, api_version=api_version,
engine_runtime_mode=engine_runtime_mode, engine_runtime_mode=engine_runtime_mode,
@@ -337,20 +408,12 @@ For production use, requires a license key in env var LANGGRAPH_CLOUD_LICENSE_KE
if "unpacking to docker.io" in line: if "unpacking to docker.io" in line:
set("Starting...") set("Starting...")
elif "Application startup complete" in line: elif "Application startup complete" in line:
debugger_origin = (
f"http://localhost:{debugger_port}"
if debugger_port
else "https://smith.langchain.com"
)
debugger_base_url_query = (
debugger_base_url or f"http://127.0.0.1:{port}"
)
set("") set("")
sys.stdout.write( sys.stdout.write(
f"""Ready! f"""Ready!
- API: http://localhost:{port} - API: http://localhost:{port}
- Docs: http://localhost:{port}/docs - Docs: http://localhost:{port}/docs
- LangGraph Studio: {debugger_origin}/studio/?baseUrl={debugger_base_url_query} - LangGraph Studio: {studio_link}
""" """
) )
sys.stdout.flush() sys.stdout.flush()
@@ -935,8 +998,6 @@ def prepare_args_and_stdin(
docker_compose: pathlib.Path | None, docker_compose: pathlib.Path | None,
port: int, port: int,
watch: bool, watch: bool,
debugger_port: int | None = None,
debugger_base_url: str | None = None,
postgres_uri: str | None = None, postgres_uri: str | None = None,
api_version: str | None = None, api_version: str | None = None,
engine_runtime_mode: str = "combined_queue_worker", engine_runtime_mode: str = "combined_queue_worker",
@@ -950,8 +1011,6 @@ def prepare_args_and_stdin(
stdin = langgraph_cli.docker.compose( stdin = langgraph_cli.docker.compose(
capabilities, capabilities,
port=port, port=port,
debugger_port=debugger_port,
debugger_base_url=debugger_base_url,
postgres_uri=postgres_uri, postgres_uri=postgres_uri,
image=image, image=image,
base_image=base_image, base_image=base_image,
@@ -989,8 +1048,6 @@ def prepare(
pull: bool, pull: bool,
watch: bool, watch: bool,
verbose: bool, verbose: bool,
debugger_port: int | None = None,
debugger_base_url: str | None = None,
postgres_uri: str | None = None, postgres_uri: str | None = None,
api_version: str | None = None, api_version: str | None = None,
engine_runtime_mode: str = "combined_queue_worker", engine_runtime_mode: str = "combined_queue_worker",
@@ -1032,8 +1089,6 @@ def prepare(
docker_compose=docker_compose, docker_compose=docker_compose,
port=port, port=port,
watch=watch, watch=watch,
debugger_port=debugger_port,
debugger_base_url=debugger_base_url or f"http://127.0.0.1:{port}",
postgres_uri=postgres_uri, postgres_uri=postgres_uri,
api_version=api_version, api_version=api_version,
engine_runtime_mode=engine_runtime_mode, engine_runtime_mode=engine_runtime_mode,
+3 -87
View File
@@ -6,7 +6,6 @@ import re
import shlex import shlex
import textwrap import textwrap
from collections import Counter from collections import Counter
from collections.abc import Iterable
from typing import Literal, NamedTuple from typing import Literal, NamedTuple
import click import click
@@ -37,10 +36,6 @@ DISALLOWED_BUILD_COMMAND_CHARS = [
# This blocks background execution (cmd &) while allowing command # This blocks background execution (cmd &) while allowing command
# chaining (cmd1 && cmd2) which is common in build commands. # chaining (cmd1 && cmd2) which is common in build commands.
_SINGLE_AMPERSAND_RE = re.compile(r"(?<!&)&(?:&&)*(?!&)") _SINGLE_AMPERSAND_RE = re.compile(r"(?<!&)&(?:&&)*(?!&)")
_GIT_HTTP_AUTHORITY_RES = (
re.compile(r"git\+https?://(?P<authority>[^/\s\"']+)", re.I),
re.compile(r"\bgit\s*=\s*[\"']https?://(?P<authority>[^/\s\"']+)", re.I),
)
_API_VERSION_PATTERN = re.compile( _API_VERSION_PATTERN = re.compile(
r"^(?P<major>\d+)" r"^(?P<major>\d+)"
r"(?:\.(?P<minor>\d+))?" r"(?:\.(?P<minor>\d+))?"
@@ -83,62 +78,6 @@ def has_disallowed_build_command_content(command: str) -> bool:
return False return False
def _has_git_http_url_userinfo(dependency: str) -> bool:
"""Check whether a Git HTTP URL contains userinfo."""
return any(
"@" in match.group("authority")
for pattern in _GIT_HTTP_AUTHORITY_RES
for match in pattern.finditer(dependency)
)
def _validate_git_http_url_userinfo(
values: Iterable[str], *, source: pathlib.Path | None = None
) -> None:
"""Reject credential-bearing Git HTTP URLs without echoing their values."""
if not any(_has_git_http_url_userinfo(value) for value in values):
return
message = (
"Git dependency URLs must not contain credentials or other URL "
"userinfo because generated Dockerfiles and image layers can retain "
"them. Use a credential-free Git URL and provide short-lived "
"credentials through your build environment's secret-backed Git "
"credential helper."
)
if source is not None:
message += f" Found in: {source}"
raise click.UsageError(message)
def _validate_git_http_url_userinfo_files(paths: Iterable[pathlib.Path]) -> None:
"""Reject credential-bearing Git HTTP URLs in dependency files."""
for path in paths:
path = path.resolve()
if not path.is_file():
continue
try:
contents = path.read_text(encoding="utf-8", errors="replace")
except OSError:
raise click.UsageError(
f"Could not inspect dependency file for embedded credentials: {path}"
) from None
_validate_git_http_url_userinfo([contents], source=path)
def _validate_local_dependency_files(config_path: pathlib.Path, config: Config) -> None:
"""Validate dependency files copied into a non-uv Python image."""
paths: list[pathlib.Path] = []
for dependency in config["dependencies"]:
if not isinstance(dependency, str) or not dependency.startswith("."):
continue
root = (config_path.parent / dependency).resolve()
paths.extend(
root / name
for name in ("requirements.txt", "pyproject.toml", "setup.py", "setup.cfg")
)
_validate_git_http_url_userinfo_files(paths)
MIN_PYTHON_VERSION = "3.11" MIN_PYTHON_VERSION = "3.11"
DEFAULT_PYTHON_VERSION = "3.11" DEFAULT_PYTHON_VERSION = "3.11"
@@ -381,9 +320,7 @@ def _get_source_kind(config: Config) -> str | None:
return kind if isinstance(kind, str) else None return kind if isinstance(kind, str) else None
def validate_config( def validate_config(config: Config) -> Config:
config: Config, *, source_path: pathlib.Path | None = None
) -> Config:
"""Validate a configuration dictionary.""" """Validate a configuration dictionary."""
graphs = config.get("graphs", {}) graphs = config.get("graphs", {})
@@ -478,15 +415,6 @@ def validate_config(
' "source": {"kind": "uv", "root": ".."}' ' "source": {"kind": "uv", "root": ".."}'
) )
_validate_git_http_url_userinfo(
(
dependency
for dependency in config["dependencies"]
if isinstance(dependency, str)
),
source=source_path,
)
source = config.get("source") source = config.get("source")
source_kind = _get_source_kind(config) source_kind = _get_source_kind(config)
if source is not None and not isinstance(source, dict): if source is not None and not isinstance(source, dict):
@@ -681,7 +609,7 @@ def validate_config_file(config_path: pathlib.Path) -> Config:
"""Load and validate a configuration file.""" """Load and validate a configuration file."""
with open(config_path) as f: with open(config_path) as f:
config = json.load(f) config = json.load(f)
validated = validate_config(config, source_path=config_path.resolve()) validated = validate_config(config)
# Enforce the package.json doesn't enforce an # Enforce the package.json doesn't enforce an
# incompatible Node.js version # incompatible Node.js version
if validated.get("node_version"): if validated.get("node_version"):
@@ -1352,7 +1280,6 @@ def python_config_to_docker(
api_version=api_version, api_version=api_version,
build_tools_to_uninstall=build_tools_to_uninstall, build_tools_to_uninstall=build_tools_to_uninstall,
) )
_validate_local_dependency_files(config_path, config)
if pip_installer == "auto": if pip_installer == "auto":
if _image_supports_uv(base_image): if _image_supports_uv(base_image):
pip_installer = "uv" pip_installer = "uv"
@@ -1563,18 +1490,7 @@ def node_config_to_docker(
) -> tuple[str, dict[str, str]]: ) -> tuple[str, dict[str, str]]:
# Calculate paths for monorepo support # Calculate paths for monorepo support
install_root = ( install_root = (
pathlib.Path(build_context).resolve() pathlib.Path(build_context).resolve() if build_context else config_path.parent
if build_context
else config_path.parent.resolve()
)
config_root = config_path.parent.resolve()
dependency_roots = (
(install_root, config_root) if install_root != config_root else (install_root,)
)
_validate_git_http_url_userinfo_files(
root / name
for root in dependency_roots
for name in ("package.json", "package-lock.json", "yarn.lock", "pnpm-lock.yaml")
) )
install_cmd = install_command or _get_node_pm_install_cmd(install_root) install_cmd = install_command or _get_node_pm_install_cmd(install_root)
if build_context: if build_context:
+1 -36
View File
@@ -142,29 +142,6 @@ def check_capabilities(runner) -> DockerCapabilities:
) )
def debugger_compose(*, port: int | None = None, base_url: str | None = None) -> dict:
if port is None:
return ""
config = {
"langgraph-debugger": {
"image": "langchain/langgraph-debugger",
"restart": "on-failure",
"depends_on": {
"langgraph-postgres": {"condition": "service_healthy"},
},
"ports": [f'"{port}:3968"'],
}
}
if base_url:
config["langgraph-debugger"]["environment"] = {
"VITE_STUDIO_LOCAL_GRAPH_URL": base_url
}
return config
# Function to convert dictionary to YAML # Function to convert dictionary to YAML
def dict_to_yaml(d: dict, *, indent: int = 0) -> str: def dict_to_yaml(d: dict, *, indent: int = 0) -> str:
"""Convert a dictionary to a YAML string.""" """Convert a dictionary to a YAML string."""
@@ -191,8 +168,6 @@ def compose_as_dict(
capabilities: DockerCapabilities, capabilities: DockerCapabilities,
*, *,
port: int, port: int,
debugger_port: int | None = None,
debugger_base_url: str | None = None,
# postgres://user:password@host:port/database?option=value # postgres://user:password@host:port/database?option=value
postgres_uri: str | None = None, postgres_uri: str | None = None,
# If you are running against an already-built image, you can pass it here # If you are running against an already-built image, you can pass it here
@@ -253,12 +228,6 @@ def compose_as_dict(
else: else:
services["langgraph-postgres"]["healthcheck"]["interval"] = "5s" services["langgraph-postgres"]["healthcheck"]["interval"] = "5s"
# Add optional debugger service if debugger_port is specified
if debugger_port:
services["langgraph-debugger"] = debugger_compose(
port=debugger_port, base_url=debugger_base_url
)["langgraph-debugger"]
# Add langgraph-api service # Add langgraph-api service
api_environment = { api_environment = {
"REDIS_URI": "redis://langgraph-redis:6379", "REDIS_URI": "redis://langgraph-redis:6379",
@@ -289,7 +258,7 @@ def compose_as_dict(
"test": "python /api/healthcheck.py", "test": "python /api/healthcheck.py",
"interval": "60s", "interval": "60s",
"start_interval": "1s", "start_interval": "1s",
"start_period": "10s", "start_period": "60s",
} }
# Final compose dictionary with volumes included if needed # Final compose dictionary with volumes included if needed
@@ -305,8 +274,6 @@ def compose(
capabilities: DockerCapabilities, capabilities: DockerCapabilities,
*, *,
port: int, port: int,
debugger_port: int | None = None,
debugger_base_url: str | None = None,
# postgres://user:password@host:port/database?option=value # postgres://user:password@host:port/database?option=value
postgres_uri: str | None = None, postgres_uri: str | None = None,
image: str | None = None, image: str | None = None,
@@ -318,8 +285,6 @@ def compose(
compose_content = compose_as_dict( compose_content = compose_as_dict(
capabilities, capabilities,
port=port, port=port,
debugger_port=debugger_port,
debugger_base_url=debugger_base_url,
postgres_uri=postgres_uri, postgres_uri=postgres_uri,
image=image, image=image,
base_image=base_image, base_image=base_image,
+1 -5
View File
@@ -650,8 +650,7 @@ class Config(TypedDict, total=False):
pip_config_file: str | None pip_config_file: str | None
"""Optional. Path to a pip config file (e.g., "/etc/pip.conf" or "pip.ini") for controlling """Optional. Path to a pip config file (e.g., "/etc/pip.conf" or "pip.ini") for controlling
package installation (custom indices, timeouts, etc.). The file is copied into the package installation (custom indices, credentials, etc.).
generated image, so it must not contain credentials or other secrets.
Only relevant if Python dependencies are installed via pip. If omitted, default pip settings are used. Only relevant if Python dependencies are installed via pip. If omitted, default pip settings are used.
""" """
@@ -690,9 +689,6 @@ class Config(TypedDict, total=False):
- "." or "./src" if you have a local Python package - "." or "./src" if you have a local Python package
- str (aka "anthropic") for a PyPI package - str (aka "anthropic") for a PyPI package
- "git+https://github.com/org/repo.git@main" for a Git-based package - "git+https://github.com/org/repo.git@main" for a Git-based package
Git HTTP URLs must not contain userinfo such as a username or token. For private
dependencies, provide short-lived credentials through the build environment's
secret-backed Git credential helper.
Defaults to an empty list, meaning no additional packages installed beyond your base environment. Defaults to an empty list, meaning no additional packages installed beyond your base environment.
This field is not supported when `source.kind` is `uv`. This field is not supported when `source.kind` is `uv`.
-10
View File
@@ -880,7 +880,6 @@ def python_config_to_docker_uv_lock(
_get_node_pm_install_cmd, _get_node_pm_install_cmd,
_get_pip_cleanup_lines, _get_pip_cleanup_lines,
_image_supports_uv, _image_supports_uv,
_validate_git_http_url_userinfo_files,
docker_tag, docker_tag,
) )
@@ -891,20 +890,11 @@ def python_config_to_docker_uv_lock(
) )
config_root = config_path.parent.resolve() config_root = config_path.parent.resolve()
source_root = config["source"].get("root", ".")
project_root = (config_root / source_root).resolve()
_validate_git_http_url_userinfo_files(
[project_root / "pyproject.toml", project_root / "uv.lock"]
)
install_cmd = "uv pip install --system" install_cmd = "uv pip install --system"
_, global_reqs_pip_install, pip_config_file_str = _build_python_install_commands( _, global_reqs_pip_install, pip_config_file_str = _build_python_install_commands(
config, install_cmd config, install_cmd
) )
plan = _plan_uv_lock_workspace(config_path, config) plan = _plan_uv_lock_workspace(config_path, config)
_validate_git_http_url_userinfo_files(
package.pyproject_path for package in plan.install_order
)
_update_uv_lock_graph_paths(config_path, config, plan) _update_uv_lock_graph_paths(config_path, config, plan)
for section, key in [ for section, key in [
+2 -2
View File
@@ -28,7 +28,7 @@
"type": "null" "type": "null"
} }
], ],
"description": "Optional. Path to a pip config file (e.g., \"/etc/pip.conf\" or \"pip.ini\") for controlling\npackage installation (custom indices, timeouts, etc.). The file is copied into the\ngenerated image, so it must not contain credentials or other secrets.\n\nOnly relevant if Python dependencies are installed via pip. If omitted, default pip settings are used.\n" "description": "Optional. Path to a pip config file (e.g., \"/etc/pip.conf\" or \"pip.ini\") for controlling\npackage installation (custom indices, credentials, etc.).\n\nOnly relevant if Python dependencies are installed via pip. If omitted, default pip settings are used.\n"
}, },
"_INTERNAL_docker_tag": { "_INTERNAL_docker_tag": {
"anyOf": [ "anyOf": [
@@ -270,7 +270,7 @@
"type": "null" "type": "null"
} }
], ],
"description": "Optional. Path to a pip config file (e.g., \"/etc/pip.conf\" or \"pip.ini\") for controlling\npackage installation (custom indices, timeouts, etc.). The file is copied into the\ngenerated image, so it must not contain credentials or other secrets.\n\nOnly relevant if Python dependencies are installed via pip. If omitted, default pip settings are used.\n" "description": "Optional. Path to a pip config file (e.g., \"/etc/pip.conf\" or \"pip.ini\") for controlling\npackage installation (custom indices, credentials, etc.).\n\nOnly relevant if Python dependencies are installed via pip. If omitted, default pip settings are used.\n"
}, },
"_INTERNAL_docker_tag": { "_INTERNAL_docker_tag": {
"anyOf": [ "anyOf": [
+2 -2
View File
@@ -28,7 +28,7 @@
"type": "null" "type": "null"
} }
], ],
"description": "Optional. Path to a pip config file (e.g., \"/etc/pip.conf\" or \"pip.ini\") for controlling\npackage installation (custom indices, timeouts, etc.). The file is copied into the\ngenerated image, so it must not contain credentials or other secrets.\n\nOnly relevant if Python dependencies are installed via pip. If omitted, default pip settings are used.\n" "description": "Optional. Path to a pip config file (e.g., \"/etc/pip.conf\" or \"pip.ini\") for controlling\npackage installation (custom indices, credentials, etc.).\n\nOnly relevant if Python dependencies are installed via pip. If omitted, default pip settings are used.\n"
}, },
"_INTERNAL_docker_tag": { "_INTERNAL_docker_tag": {
"anyOf": [ "anyOf": [
@@ -270,7 +270,7 @@
"type": "null" "type": "null"
} }
], ],
"description": "Optional. Path to a pip config file (e.g., \"/etc/pip.conf\" or \"pip.ini\") for controlling\npackage installation (custom indices, timeouts, etc.). The file is copied into the\ngenerated image, so it must not contain credentials or other secrets.\n\nOnly relevant if Python dependencies are installed via pip. If omitted, default pip settings are used.\n" "description": "Optional. Path to a pip config file (e.g., \"/etc/pip.conf\" or \"pip.ini\") for controlling\npackage installation (custom indices, credentials, etc.).\n\nOnly relevant if Python dependencies are installed via pip. If omitted, default pip settings are used.\n"
}, },
"_INTERNAL_docker_tag": { "_INTERNAL_docker_tag": {
"anyOf": [ "anyOf": [
+80 -31
View File
@@ -8,10 +8,11 @@ from contextlib import contextmanager
from pathlib import Path from pathlib import Path
import click import click
import pytest
from click.testing import CliRunner from click.testing import CliRunner
import langgraph_cli.deploy as deploy_module import langgraph_cli.deploy as deploy_module
from langgraph_cli.cli import cli, prepare_args_and_stdin from langgraph_cli.cli import _studio_link, cli, prepare_args_and_stdin
from langgraph_cli.config import Config, _get_pip_cleanup_lines, validate_config from langgraph_cli.config import Config, _get_pip_cleanup_lines, validate_config
from langgraph_cli.docker import DEFAULT_POSTGRES_URI, DockerCapabilities, Version from langgraph_cli.docker import DEFAULT_POSTGRES_URI, DockerCapabilities, Version
from langgraph_cli.util import clean_empty_lines from langgraph_cli.util import clean_empty_lines
@@ -56,8 +57,6 @@ def test_prepare_args_and_stdin() -> None:
Config(dependencies=[".", "../../.."], graphs={"agent": "agent.py:graph"}) Config(dependencies=[".", "../../.."], graphs={"agent": "agent.py:graph"})
) )
port = 8000 port = 8000
debugger_port = 8001
debugger_graph_url = f"http://127.0.0.1:{port}"
actual_args, actual_stdin = prepare_args_and_stdin( actual_args, actual_stdin = prepare_args_and_stdin(
capabilities=DEFAULT_DOCKER_CAPABILITIES, capabilities=DEFAULT_DOCKER_CAPABILITIES,
@@ -65,8 +64,6 @@ def test_prepare_args_and_stdin() -> None:
config=config, config=config,
docker_compose=pathlib.Path("custom-docker-compose.yml"), docker_compose=pathlib.Path("custom-docker-compose.yml"),
port=port, port=port,
debugger_port=debugger_port,
debugger_base_url=debugger_graph_url,
watch=True, watch=True,
) )
@@ -110,16 +107,6 @@ services:
retries: 5 retries: 5
interval: 60s interval: 60s
start_interval: 1s start_interval: 1s
langgraph-debugger:
image: langchain/langgraph-debugger
restart: on-failure
depends_on:
langgraph-postgres:
condition: service_healthy
ports:
- "{debugger_port}:3968"
environment:
VITE_STUDIO_LOCAL_GRAPH_URL: {debugger_graph_url}
langgraph-api: langgraph-api:
ports: ports:
- "8000:8000" - "8000:8000"
@@ -135,7 +122,7 @@ services:
test: python /api/healthcheck.py test: python /api/healthcheck.py
interval: 60s interval: 60s
start_interval: 1s start_interval: 1s
start_period: 10s start_period: 60s
pull_policy: build pull_policy: build
build: build:
@@ -178,8 +165,6 @@ def test_prepare_args_and_stdin_with_image() -> None:
Config(dependencies=[".", "../../.."], graphs={"agent": "agent.py:graph"}) Config(dependencies=[".", "../../.."], graphs={"agent": "agent.py:graph"})
) )
port = 8000 port = 8000
debugger_port = 8001
debugger_graph_url = f"http://127.0.0.1:{port}"
actual_args, actual_stdin = prepare_args_and_stdin( actual_args, actual_stdin = prepare_args_and_stdin(
capabilities=DEFAULT_DOCKER_CAPABILITIES, capabilities=DEFAULT_DOCKER_CAPABILITIES,
@@ -187,8 +172,6 @@ def test_prepare_args_and_stdin_with_image() -> None:
config=config, config=config,
docker_compose=pathlib.Path("custom-docker-compose.yml"), docker_compose=pathlib.Path("custom-docker-compose.yml"),
port=port, port=port,
debugger_port=debugger_port,
debugger_base_url=debugger_graph_url,
watch=True, watch=True,
image="my-cool-image", image="my-cool-image",
) )
@@ -233,16 +216,6 @@ services:
retries: 5 retries: 5
interval: 60s interval: 60s
start_interval: 1s start_interval: 1s
langgraph-debugger:
image: langchain/langgraph-debugger
restart: on-failure
depends_on:
langgraph-postgres:
condition: service_healthy
ports:
- "{debugger_port}:3968"
environment:
VITE_STUDIO_LOCAL_GRAPH_URL: {debugger_graph_url}
langgraph-api: langgraph-api:
ports: ports:
- "8000:8000" - "8000:8000"
@@ -259,7 +232,7 @@ services:
test: python /api/healthcheck.py test: python /api/healthcheck.py
interval: 60s interval: 60s
start_interval: 1s start_interval: 1s
start_period: 10s start_period: 60s
develop: develop:
@@ -289,6 +262,82 @@ def test_version_option() -> None:
) )
def test_up_help_shows_hosted_studio_options() -> None:
result = CliRunner().invoke(cli, ["up", "--help"])
assert result.exit_code == 0, result.output
assert "--studio-url" in result.output
assert "--api-url" in result.output
assert "--debugger-port" not in result.output
assert "--debugger-base-url" not in result.output
def test_studio_link_defaults_to_hosted_studio() -> None:
assert _studio_link(
port=8123,
studio_url=None,
api_url=None,
debugger_base_url=None,
) == ("https://smith.langchain.com/studio/?baseUrl=http%3A%2F%2F127.0.0.1%3A8123")
def test_studio_link_supports_self_hosted_and_remote_urls() -> None:
assert _studio_link(
port=8123,
studio_url="https://langsmith.example.com/prefix/",
api_url="https://api.example.com/graph?tenant=a&region=eu",
debugger_base_url=None,
) == (
"https://langsmith.example.com/prefix/studio/"
"?baseUrl=https%3A%2F%2Fapi.example.com%2Fgraph%3Ftenant%3Da%26region%3Deu"
)
def test_studio_link_supports_deprecated_debugger_base_url(capsys) -> None:
assert _studio_link(
port=8123,
studio_url=None,
api_url=None,
debugger_base_url="https://api.example.com",
).endswith("?baseUrl=https%3A%2F%2Fapi.example.com")
assert "--debugger-base-url is deprecated; use --api-url" in capsys.readouterr().err
@pytest.mark.parametrize(
("studio_url", "api_url"),
[
("javascript:alert(1)", None),
("https://user:password@example.com", None),
("https://smith.langchain.com?workspace=test", None),
(None, "file:///tmp/langgraph.sock"),
(None, "https://user:password@example.com"),
],
)
def test_studio_link_rejects_unsafe_urls(
studio_url: str | None, api_url: str | None
) -> None:
with pytest.raises(click.UsageError):
_studio_link(
port=8123,
studio_url=studio_url,
api_url=api_url,
debugger_base_url=None,
)
def test_studio_link_rejects_conflicting_api_url_aliases() -> None:
with pytest.raises(
click.UsageError,
match="cannot specify different URLs",
):
_studio_link(
port=8123,
studio_url=None,
api_url="https://api.example.com",
debugger_base_url="https://other.example.com",
)
def test_top_level_help_shows_deploy_subcommands() -> None: def test_top_level_help_shows_deploy_subcommands() -> None:
runner = CliRunner() runner = CliRunner()
-237
View File
@@ -255,243 +255,6 @@ def test_validate_config():
) )
@pytest.mark.parametrize(
"dependency",
[
"git+https://user:secret-token@github.com/org/private.git@main",
"private-package @ git+http://token@github.com/org/private.git",
"git+HTTPS://user%40example.com:secret%2Ftoken@github.com/org/private.git",
"git+https://${GIT_TOKEN}@github.com/org/private.git",
],
)
def test_validate_config_rejects_git_http_url_userinfo(dependency: str):
with pytest.raises(click.UsageError) as exc_info:
validate_config(
{
"python_version": "3.11",
"dependencies": [dependency],
"graphs": {"agent": "./agent.py:graph"},
}
)
message = str(exc_info.value)
assert "must not contain credentials or other URL userinfo" in message
assert "secret-token" not in message
assert "secret%2Ftoken" not in message
def test_validate_config_file_reports_source_for_git_http_url_userinfo(
tmp_path: pathlib.Path,
):
config_path = tmp_path / "langgraph.json"
config_path.write_text(
json.dumps(
{
"python_version": "3.11",
"dependencies": ["git+https://secret-token@github.com/org/private.git"],
"graphs": {"agent": "./agent.py:graph"},
}
)
)
with pytest.raises(click.UsageError) as exc_info:
validate_config_file(config_path)
message = str(exc_info.value)
assert "secret-token" not in message
assert f"Found in: {config_path.resolve()}" in message
@pytest.mark.parametrize(
"manifest", ["package.json", "package-lock.json", "yarn.lock", "pnpm-lock.yaml"]
)
def test_config_to_docker_rejects_git_http_url_userinfo_in_node_files(
tmp_path: pathlib.Path, manifest: str
):
config_path = tmp_path / "langgraph.json"
config_path.write_text("{}\n")
(tmp_path / "agent.js").write_text("export const graph = {};\n")
(tmp_path / "package.json").write_text('{"name":"agent"}\n')
(tmp_path / manifest).write_text(
'"priv": "git+https://user:secret-token@github.com/org/private.git"\n'
)
config = validate_config(
{
"node_version": "20",
"graphs": {"agent": "./agent.js:graph"},
}
)
with pytest.raises(click.UsageError) as exc_info:
config_to_docker(
config_path,
config,
base_image="langchain/langgraphjs-api",
)
message = str(exc_info.value)
assert "must not contain credentials or other URL userinfo" in message
assert "secret-token" not in message
assert f"Found in: {(tmp_path / manifest).resolve()}" in message
def test_config_to_docker_allows_node_git_urls_without_http_userinfo(
tmp_path: pathlib.Path,
):
config_path = tmp_path / "langgraph.json"
config_path.write_text("{}\n")
(tmp_path / "agent.js").write_text("export const graph = {};\n")
(tmp_path / "package.json").write_text(
'{"dependencies":{"public":"git+https://github.com/org/public.git"}}\n'
)
config = validate_config(
{
"node_version": "20",
"graphs": {"agent": "./agent.js:graph"},
}
)
docker, _ = config_to_docker(
config_path,
config,
base_image="langchain/langgraphjs-api",
)
assert f"ADD . /deps/{tmp_path.name}" in docker
def test_config_to_docker_rejects_git_http_url_userinfo_in_node_workspace(
tmp_path: pathlib.Path,
):
config_root = tmp_path / "apps" / "agent"
config_root.mkdir(parents=True)
config_path = config_root / "langgraph.json"
config_path.write_text("{}\n")
(config_root / "agent.js").write_text("export const graph = {};\n")
(config_root / "package.json").write_text(
'{"dependencies":{"priv":"git+https://secret-token@github.com/org/private.git"}}\n'
)
(tmp_path / "package.json").write_text('{"name":"workspace"}\n')
config = validate_config(
{
"node_version": "20",
"graphs": {"agent": "./agent.js:graph"},
}
)
with pytest.raises(click.UsageError) as exc_info:
config_to_docker(
config_path,
config,
base_image="langchain/langgraphjs-api",
build_context=str(tmp_path),
)
message = str(exc_info.value)
assert "secret-token" not in message
assert f"Found in: {(config_root / 'package.json').resolve()}" in message
@pytest.mark.parametrize(
"dependency",
[
"git+https://github.com/org/public.git@main",
"private-package @ git+https://github.com/org/private.git@main",
"git+ssh://git@github.com/org/private.git@main",
],
)
def test_validate_config_allows_git_urls_without_http_userinfo(dependency: str):
config = validate_config(
{
"python_version": "3.11",
"dependencies": [dependency],
"graphs": {"agent": "./agent.py:graph"},
}
)
assert config["dependencies"] == [dependency]
def test_config_to_docker_rejects_git_http_url_userinfo_in_requirements(
tmp_path: pathlib.Path,
):
config_path = tmp_path / "langgraph.json"
config_path.write_text("{}\n")
(tmp_path / "agent.py").write_text("graph = object()\n")
(tmp_path / "requirements.txt").write_text(
"private @ git+https://secret-token@github.com/org/private.git\n"
)
config = validate_config(
{
"python_version": "3.11",
"dependencies": ["."],
"graphs": {"agent": "./agent.py:graph"},
}
)
with pytest.raises(click.UsageError) as exc_info:
config_to_docker(
config_path,
config,
base_image="langchain/langgraph-api:0.2.47",
)
message = str(exc_info.value)
assert "must not contain credentials or other URL userinfo" in message
assert "secret-token" not in message
assert f"Found in: {(tmp_path / 'requirements.txt').resolve()}" in message
@pytest.mark.parametrize("manifest", ["pyproject.toml", "uv.lock"])
def test_config_to_docker_rejects_git_http_url_userinfo_in_uv_files(
tmp_path: pathlib.Path, manifest: str
):
config_path = tmp_path / "langgraph.json"
config_path.write_text("{}\n")
(tmp_path / "src").mkdir()
(tmp_path / "src" / "agent.py").write_text("graph = object()\n")
pyproject = textwrap.dedent(
"""
[project]
name = "agent"
version = "0.1.0"
dependencies = ["private"]
[tool.uv.sources]
private = { git = "https://github.com/org/private.git" }
"""
).strip()
uv_lock = "# uv lock file\n"
if manifest == "pyproject.toml":
pyproject = pyproject.replace(
"https://github.com", "https://secret-token@github.com"
)
else:
uv_lock += (
'source = { git = "https://secret-token@github.com/org/private.git" }\n'
)
(tmp_path / "pyproject.toml").write_text(pyproject + "\n")
(tmp_path / "uv.lock").write_text(uv_lock)
config = validate_config(
{
"python_version": "3.11",
"graphs": {"agent": "./src/agent.py:graph"},
"source": {"kind": "uv"},
}
)
with pytest.raises(click.UsageError) as exc_info:
config_to_docker(
config_path,
config,
base_image="langchain/langgraph-api:0.2.47",
)
message = str(exc_info.value)
assert "must not contain credentials or other URL userinfo" in message
assert "secret-token" not in message
def test_validate_config_image_distro(): def test_validate_config_image_distro():
"""Test validation of image_distro field.""" """Test validation of image_distro field."""
# Valid image_distro values should work # Valid image_distro values should work
+4 -98
View File
@@ -16,7 +16,7 @@ DEFAULT_DOCKER_CAPABILITIES = DockerCapabilities(
) )
def test_compose_with_no_debugger_and_custom_db(): def test_compose_with_custom_db():
port = 8123 port = 8123
custom_postgres_uri = "custom_postgres_uri" custom_postgres_uri = "custom_postgres_uri"
actual_compose_str = compose( actual_compose_str = compose(
@@ -42,7 +42,7 @@ def test_compose_with_no_debugger_and_custom_db():
assert clean_empty_lines(actual_compose_str) == expected_compose_str assert clean_empty_lines(actual_compose_str) == expected_compose_str
def test_compose_with_no_debugger_and_custom_db_with_healthcheck(): def test_compose_with_custom_db_and_healthcheck():
port = 8123 port = 8123
custom_postgres_uri = "custom_postgres_uri" custom_postgres_uri = "custom_postgres_uri"
actual_compose_str = compose( actual_compose_str = compose(
@@ -71,39 +71,11 @@ def test_compose_with_no_debugger_and_custom_db_with_healthcheck():
test: python /api/healthcheck.py test: python /api/healthcheck.py
interval: 60s interval: 60s
start_interval: 1s start_interval: 1s
start_period: 10s""" start_period: 60s"""
assert clean_empty_lines(actual_compose_str) == expected_compose_str assert clean_empty_lines(actual_compose_str) == expected_compose_str
def test_compose_with_debugger_and_custom_db(): def test_compose_with_default_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-redis:
image: redis:6
healthcheck:
test: redis-cli ping
interval: 5s
timeout: 1s
retries: 5
langgraph-api:
ports:
- "{port}:8000"
depends_on:
langgraph-redis:
condition: service_healthy
environment:
REDIS_URI: redis://langgraph-redis:6379
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 port = 8123
actual_compose_str = compose(DEFAULT_DOCKER_CAPABILITIES, port=port) actual_compose_str = compose(DEFAULT_DOCKER_CAPABILITIES, port=port)
expected_compose_str = f"""volumes: expected_compose_str = f"""volumes:
@@ -302,72 +274,6 @@ def test_compose_with_api_version_and_custom_postgres():
assert clean_empty_lines(actual_compose_str) == expected_compose_str assert clean_empty_lines(actual_compose_str) == expected_compose_str
def test_compose_with_api_version_and_debugger():
"""Test compose function with api_version and debugger port."""
port = 8123
debugger_port = 8001
api_version = "0.2.74"
actual_compose_str = compose(
DEFAULT_DOCKER_CAPABILITIES,
port=port,
api_version=api_version,
debugger_port=debugger_port,
)
expected_compose_str = f"""volumes:
langgraph-data:
driver: local
services:
langgraph-redis:
image: redis:6
healthcheck:
test: redis-cli ping
interval: 5s
timeout: 1s
retries: 5
langgraph-postgres:
image: pgvector/pgvector:pg16
ports:
- "5433:5432"
environment:
POSTGRES_DB: postgres
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
command:
- postgres
- -c
- shared_preload_libraries=vector
volumes:
- langgraph-data:/var/lib/postgresql/data
healthcheck:
test: pg_isready -U postgres
start_period: 10s
timeout: 1s
retries: 5
interval: 5s
langgraph-debugger:
image: langchain/langgraph-debugger
restart: on-failure
depends_on:
langgraph-postgres:
condition: service_healthy
ports:
- "{debugger_port}:3968"
langgraph-api:
ports:
- "{port}:8000"
depends_on:
langgraph-redis:
condition: service_healthy
langgraph-postgres:
condition: service_healthy
environment:
REDIS_URI: redis://langgraph-redis:6379
POSTGRES_URI: {DEFAULT_POSTGRES_URI}"""
assert clean_empty_lines(actual_compose_str) == expected_compose_str
def test_compose_distributed_mode_with_custom_db(): def test_compose_distributed_mode_with_custom_db():
"""Test compose with engine_runtime_mode='distributed' adds N_JOBS_PER_WORKER=0.""" """Test compose with engine_runtime_mode='distributed' adds N_JOBS_PER_WORKER=0."""
port = 8123 port = 8123
+3 -3
View File
@@ -732,11 +732,11 @@ wheels = [
[[package]] [[package]]
name = "urllib3" name = "urllib3"
version = "2.8.0" version = "2.7.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" } sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
] ]
[[package]] [[package]]
+3 -3
View File
@@ -672,11 +672,11 @@ wheels = [
[[package]] [[package]]
name = "urllib3" name = "urllib3"
version = "2.8.0" version = "2.7.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" } sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
] ]
[[package]] [[package]]
+6 -6
View File
@@ -1777,11 +1777,11 @@ wheels = [
[[package]] [[package]]
name = "pyjwt" name = "pyjwt"
version = "2.15.0" version = "2.13.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/02/a5/5197bfd06417837ac079921c66fa6393f1dea3557272a263cebfef69e432/pyjwt-2.15.0.tar.gz", hash = "sha256:b11c5f9791d7bf51c2b39a81ed669f6b2dbbd669df2942f6c60167e9e3d1abe4", size = 120513, upload-time = "2026-09-23T16:56:00.689Z" } sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/e8/55/40e45bf052ee8ee12a4dfd785519660f8effa7b065442b91646ec6828619/pyjwt-2.15.0-py3-none-any.whl", hash = "sha256:7a3742debf6b879e912dbb9819ceec1594be812452b78c5f2e2dfc56564954f8", size = 33680, upload-time = "2026-09-23T16:55:59.241Z" }, { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" },
] ]
[[package]] [[package]]
@@ -2243,11 +2243,11 @@ wheels = [
[[package]] [[package]]
name = "urllib3" name = "urllib3"
version = "2.8.0" version = "2.7.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" } sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
] ]
[[package]] [[package]]
@@ -114,26 +114,6 @@ def create_metadata_for_update_state_api(
return new_counters return new_counters
def advance_delta_counters(
channels: Mapping[str, BaseChannel],
updated_channels: set[str],
*,
prev_metadata: Mapping[str, Any] | None,
) -> dict[str, Any]:
"""The `counters_since_delta_snapshot` entry for an update_state
checkpoint saved one superstep after `prev_metadata`'s, for the paths
that skip `create_checkpoint_plan_for_update_state_api`.
Without it, the next checkpoint restarts every delta channel's snapshot
cadence from zero.
"""
counters = create_metadata_for_update_state_api(
channels, updated_channels, prev_metadata=prev_metadata
)
non_zero = {k: v for k, v in counters.items() if v != (0, 0)}
return {"counters_since_delta_snapshot": non_zero} if non_zero else {}
def create_checkpoint_plan_for_update_state_api( def create_checkpoint_plan_for_update_state_api(
channels: Mapping[str, BaseChannel], channels: Mapping[str, BaseChannel],
updated_channels: set[str], updated_channels: set[str],
+6 -53
View File
@@ -129,7 +129,6 @@ from langgraph.pregel._algo import (
from langgraph.pregel._call import identifier from langgraph.pregel._call import identifier
from langgraph.pregel._checkpoint import ( from langgraph.pregel._checkpoint import (
achannels_from_checkpoint, achannels_from_checkpoint,
advance_delta_counters,
channels_from_checkpoint, channels_from_checkpoint,
copy_checkpoint, copy_checkpoint,
create_checkpoint, create_checkpoint,
@@ -1682,7 +1681,6 @@ class Pregel(
"Cannot apply multiple updates when clearing state" "Cannot apply multiple updates when clearing state"
) )
updated_channels: set[str] = set()
if saved is not None: if saved is not None:
# tasks for this checkpoint # tasks for this checkpoint
next_tasks = prepare_next_tasks( next_tasks = prepare_next_tasks(
@@ -1705,7 +1703,7 @@ class Pregel(
for w in saved.pending_writes or [] for w in saved.pending_writes or []
if w[0] == NULL_TASK_ID if w[0] == NULL_TASK_ID
]: ]:
updated_channels |= apply_writes( apply_writes(
checkpoint, checkpoint,
channels, channels,
[PregelTaskWrites((), INPUT, null_writes, [])], [PregelTaskWrites((), INPUT, null_writes, [])],
@@ -1720,7 +1718,7 @@ class Pregel(
continue continue
next_tasks[tid].writes.append((k, v)) next_tasks[tid].writes.append((k, v))
# clear all current tasks # clear all current tasks
updated_channels |= apply_writes( apply_writes(
checkpoint, checkpoint,
channels, channels,
next_tasks.values(), next_tasks.values(),
@@ -1735,13 +1733,6 @@ class Pregel(
"source": "update", "source": "update",
"step": step + 1, "step": step + 1,
"parents": saved.metadata.get("parents", {}) if saved else {}, "parents": saved.metadata.get("parents", {}) if saved else {},
**(
advance_delta_counters(
channels, updated_channels, prev_metadata=saved.metadata
)
if saved
else {}
),
}, },
get_new_channel_versions( get_new_channel_versions(
checkpoint_previous_versions, checkpoint_previous_versions,
@@ -1760,7 +1751,7 @@ class Pregel(
) )
if input_writes := deque(map_input(self.input_channels, values)): if input_writes := deque(map_input(self.input_channels, values)):
updated_channels = apply_writes( apply_writes(
checkpoint, checkpoint,
channels, channels,
[PregelTaskWrites((), INPUT, input_writes, [])], [PregelTaskWrites((), INPUT, input_writes, [])],
@@ -1783,15 +1774,6 @@ class Pregel(
"parents": saved.metadata.get("parents", {}) "parents": saved.metadata.get("parents", {})
if saved if saved
else {}, else {},
**(
advance_delta_counters(
channels,
updated_channels,
prev_metadata=saved.metadata,
)
if saved
else {}
),
}, },
get_new_channel_versions( get_new_channel_versions(
checkpoint_previous_versions, checkpoint_previous_versions,
@@ -1837,12 +1819,6 @@ class Pregel(
"source": "fork", "source": "fork",
"step": step + 1, "step": step + 1,
"parents": saved.metadata.get("parents", {}), "parents": saved.metadata.get("parents", {}),
# The copy has the same values and the same parent.
**{
k: v
for k, v in saved.metadata.items()
if k == "counters_since_delta_snapshot"
},
}, },
{}, {},
) )
@@ -2169,7 +2145,6 @@ class Pregel(
raise InvalidUpdateError( raise InvalidUpdateError(
"Cannot apply multiple updates when clearing state" "Cannot apply multiple updates when clearing state"
) )
updated_channels: set[str] = set()
if saved is not None: if saved is not None:
# tasks for this checkpoint # tasks for this checkpoint
next_tasks = prepare_next_tasks( next_tasks = prepare_next_tasks(
@@ -2192,7 +2167,7 @@ class Pregel(
for w in saved.pending_writes or [] for w in saved.pending_writes or []
if w[0] == NULL_TASK_ID if w[0] == NULL_TASK_ID
]: ]:
updated_channels |= apply_writes( apply_writes(
checkpoint, checkpoint,
channels, channels,
[PregelTaskWrites((), INPUT, null_writes, [])], [PregelTaskWrites((), INPUT, null_writes, [])],
@@ -2207,7 +2182,7 @@ class Pregel(
continue continue
next_tasks[tid].writes.append((k, v)) next_tasks[tid].writes.append((k, v))
# clear all current tasks # clear all current tasks
updated_channels |= apply_writes( apply_writes(
checkpoint, checkpoint,
channels, channels,
next_tasks.values(), next_tasks.values(),
@@ -2222,13 +2197,6 @@ class Pregel(
"source": "update", "source": "update",
"step": step + 1, "step": step + 1,
"parents": saved.metadata.get("parents", {}) if saved else {}, "parents": saved.metadata.get("parents", {}) if saved else {},
**(
advance_delta_counters(
channels, updated_channels, prev_metadata=saved.metadata
)
if saved
else {}
),
}, },
get_new_channel_versions( get_new_channel_versions(
checkpoint_previous_versions, checkpoint["channel_versions"] checkpoint_previous_versions, checkpoint["channel_versions"]
@@ -2246,7 +2214,7 @@ class Pregel(
) )
if input_writes := deque(map_input(self.input_channels, values)): if input_writes := deque(map_input(self.input_channels, values)):
updated_channels = apply_writes( apply_writes(
checkpoint, checkpoint,
channels, channels,
[PregelTaskWrites((), INPUT, input_writes, [])], [PregelTaskWrites((), INPUT, input_writes, [])],
@@ -2269,15 +2237,6 @@ class Pregel(
"parents": saved.metadata.get("parents", {}) "parents": saved.metadata.get("parents", {})
if saved if saved
else {}, else {},
**(
advance_delta_counters(
channels,
updated_channels,
prev_metadata=saved.metadata,
)
if saved
else {}
),
}, },
get_new_channel_versions( get_new_channel_versions(
checkpoint_previous_versions, checkpoint_previous_versions,
@@ -2323,12 +2282,6 @@ class Pregel(
"source": "fork", "source": "fork",
"step": step + 1, "step": step + 1,
"parents": saved.metadata.get("parents", {}), "parents": saved.metadata.get("parents", {}),
# The copy has the same values and the same parent.
**{
k: v
for k, v in saved.metadata.items()
if k == "counters_since_delta_snapshot"
},
}, },
{}, {},
) )
@@ -147,60 +147,6 @@ async def test_predicate_fires_on_supersteps_overflow() -> None:
assert "x" not in result2 assert "x" not in result2
def _delta_counters(saver: InMemorySaver, config: Any) -> dict[str, list[int]]:
tup = saver.get_tuple(config)
assert tup is not None
counters = tup.metadata.get("counters_since_delta_snapshot") or {}
return {ch: list(c) for ch, c in counters.items()}
_UPDATE_PATHS_WITHOUT_THE_SNAPSHOT_PLAN = pytest.mark.parametrize(
("values", "as_node", "supersteps"),
[
(None, END, 1),
(None, "__copy__", 0),
({"a": []}, "__input__", 1),
],
ids=["clear as END", "copy", "update as input"],
)
@_UPDATE_PATHS_WITHOUT_THE_SNAPSHOT_PLAN
def test_update_state_path_keeps_delta_counters(
values: Any, as_node: str, supersteps: int
) -> None:
saver = InMemorySaver()
graph = _build_two_channel_graph(saver)
config = {"configurable": {"thread_id": "counters"}}
graph.invoke({"a": ["seed-a"], "b": ["seed-b"]}, config)
before = _delta_counters(saver, config)
assert set(before) == {"a", "b"}, f"both channels need live counters: {before}"
updated = graph.update_state(config, values, as_node=as_node)
assert _delta_counters(saver, updated) == {
ch: [u, s + supersteps] for ch, (u, s) in before.items()
}
@_UPDATE_PATHS_WITHOUT_THE_SNAPSHOT_PLAN
async def test_aupdate_state_path_keeps_delta_counters(
values: Any, as_node: str, supersteps: int
) -> None:
saver = InMemorySaver()
graph = _build_two_channel_graph(saver)
config = {"configurable": {"thread_id": "counters"}}
await graph.ainvoke({"a": ["seed-a"], "b": ["seed-b"]}, config)
before = _delta_counters(saver, config)
assert set(before) == {"a", "b"}, f"both channels need live counters: {before}"
updated = await graph.aupdate_state(config, values, as_node=as_node)
assert _delta_counters(saver, updated) == {
ch: [u, s + supersteps] for ch, (u, s) in before.items()
}
async def test_counter_reset_after_supersteps_snapshot() -> None: async def test_counter_reset_after_supersteps_snapshot() -> None:
"""After the supersteps bound triggers a snapshot, the counters for """After the supersteps bound triggers a snapshot, the counters for
that channel reset. Verify by using a bound higher than one run's that channel reset. Verify by using a bound higher than one run's
+6 -6
View File
@@ -2841,11 +2841,11 @@ wheels = [
[[package]] [[package]]
name = "pyjwt" name = "pyjwt"
version = "2.15.1" version = "2.13.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/43/ea/5194e52748b0da83d71e082d75496eaec6e58f419f5e184786ded517e6a9/pyjwt-2.15.1.tar.gz", hash = "sha256:4f259e80cdfb6b3fc18a7de51fd1ef9ec79652f25019bae68975ca2468a34df8", size = 121252, upload-time = "2026-09-28T18:40:42.598Z" } sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/50/ca/44de4e75f8aadc457f0634be3b542815078ded46dca30efb960edeecad6e/pyjwt-2.15.1-py3-none-any.whl", hash = "sha256:42d59d631f7768a1028a64c7ff581a9bf7519804daf91fc5b6c56e30eec5e193", size = 33860, upload-time = "2026-09-28T18:40:41.429Z" }, { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" },
] ]
[[package]] [[package]]
@@ -3687,11 +3687,11 @@ wheels = [
[[package]] [[package]]
name = "urllib3" name = "urllib3"
version = "2.8.0" version = "2.7.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" } sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
] ]
[[package]] [[package]]
+3 -3
View File
@@ -1363,11 +1363,11 @@ wheels = [
[[package]] [[package]]
name = "urllib3" name = "urllib3"
version = "2.8.0" version = "2.7.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" } sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
] ]
[[package]] [[package]]
+3 -3
View File
@@ -1175,11 +1175,11 @@ wheels = [
[[package]] [[package]]
name = "urllib3" name = "urllib3"
version = "2.8.0" version = "2.7.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" } sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
] ]
[[package]] [[package]]