Compare commits

...
Author SHA1 Message Date
Christian Bromann 741c6f8d50 cr 2026-03-18 15:46:35 -07:00
Christian Bromann 37a5504433 move to stream mode 2026-03-12 15:08:26 -07:00
Christian Bromann f6286dce38 feat(langgraph): add protocol improvements for better streaming 2026-03-12 15:08:25 -07:00
hari-dhanushkodiandGitHub 93a0dfec08 fix(cli): cleanup client creation code (#7140)
make following fixes for deploy cli:
- allow logs command to read in `_DEPLOYMENT_NAME_ENV`
- change prompt for api key to be more user friendly
- refactor prompt for org scoped keys
- make sure --config option is documented in deploy help
2026-03-12 11:40:49 -07:00
Sydney RunkleandGitHub 56834787eb release(langgraph): 1.1.2 (#7135) 2026-03-12 13:07:16 -04:00
96199c4fee feat(cli): add langgraph deploy logs subcommand (#7100)
Add a `langgraph deploy logs` subcommand to fetch build and
deploy/server logs from LangSmith deployments.

## Changes

- **`host_backend.py`**: Add `get_build_logs()` and `get_deploy_logs()`
methods, plus `langsmith_url` support.
- **`helpers.py`** (new): Log formatting (`format_log_entry`,
`format_timestamp`, `level_fg`) and resolution helpers
(`resolve_deployment_id`).

## Usage

```bash
# Deploy logs (latest 10)
langgraph deploy logs --deployment-id <id> --limit 10

# Error logs by name
langgraph deploy logs --name gtm-agent --level ERROR

# Build logs
langgraph deploy logs --deployment-id <id> --type build

# Tail logs (Ctrl+C to stop)
langgraph deploy logs --name gtm-agent --follow
```

## Tests

56 unit tests passing covering host backend methods, log formatting, and
helper functions.

---------

Co-authored-by: hari-dhanushkodi <hari@langchain.dev>
2026-03-12 09:01:44 -07:00
Sydney RunkleandGitHub 682814e944 fix: stream part generic order (#7134)
this is technically breaking but is a fix given dependency of output t
on state t
2026-03-12 11:58:04 -04:00
Sydney RunkleandGitHub 210c4b3877 feat: add context for remote graph api (#7132)
underlying sdk already supports, just exposing for remote graph
2026-03-12 11:57:06 -04:00
Mason DaughertyandGitHub 81489ab858 chore: fix typo (#7131) 2026-03-12 14:44:24 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
b7b052e66c chore(deps): bump tornado from 6.5.4 to 6.5.5 in /libs/langgraph (#7122)
Bumps [tornado](https://github.com/tornadoweb/tornado) from 6.5.4 to
6.5.5.
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/tornadoweb/tornado/blob/master/docs/releases.rst">tornado's
changelog</a>.</em></p>
<blockquote>
<h1>Release notes</h1>
<p>.. toctree::
:maxdepth: 2</p>
<p>releases/v6.5.5
releases/v6.5.4
releases/v6.5.3
releases/v6.5.2
releases/v6.5.1
releases/v6.5.0
releases/v6.4.2
releases/v6.4.1
releases/v6.4.0
releases/v6.3.3
releases/v6.3.2
releases/v6.3.1
releases/v6.3.0
releases/v6.2.0
releases/v6.1.0
releases/v6.0.4
releases/v6.0.3
releases/v6.0.2
releases/v6.0.1
releases/v6.0.0
releases/v5.1.1
releases/v5.1.0
releases/v5.0.2
releases/v5.0.1
releases/v5.0.0
releases/v4.5.3
releases/v4.5.2
releases/v4.5.1
releases/v4.5.0
releases/v4.4.3
releases/v4.4.2
releases/v4.4.1
releases/v4.4.0
releases/v4.3.0
releases/v4.2.1
releases/v4.2.0
releases/v4.1.0
releases/v4.0.2
releases/v4.0.1
releases/v4.0.0
releases/v3.2.2
releases/v3.2.1
releases/v3.2.0
releases/v3.1.1</p>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/tornadoweb/tornado/commit/7d6465056ceb7a054b3f64cf1c18271753b10482"><code>7d64650</code></a>
Merge pull request <a
href="https://redirect.github.com/tornadoweb/tornado/issues/3586">#3586</a>
from bdarnell/update-cibw</li>
<li><a
href="https://github.com/tornadoweb/tornado/commit/d05d59b8080a0d5d6a260994c7aad7049209d345"><code>d05d59b</code></a>
build: Bump cibuildwheel to 3.4.0</li>
<li><a
href="https://github.com/tornadoweb/tornado/commit/c2f46732b0ad14bf0db4219c96a945f4b60205f5"><code>c2f4673</code></a>
Merge pull request <a
href="https://redirect.github.com/tornadoweb/tornado/issues/3585">#3585</a>
from bdarnell/release-655</li>
<li><a
href="https://github.com/tornadoweb/tornado/commit/e5f1aa4b6fa2c16b29024830227838fcb0c79b6f"><code>e5f1aa4</code></a>
Release notes and version bump for v6.5.5</li>
<li><a
href="https://github.com/tornadoweb/tornado/commit/78a046f99f89977dfc8ff5a1fe16d298afbeeaca"><code>78a046f</code></a>
httputil: Add CRLF to _FORBIDDEN_HEADER_CHARS_RE</li>
<li><a
href="https://github.com/tornadoweb/tornado/commit/24a2d96ea115f663b223887deb0060f13974c104"><code>24a2d96</code></a>
web: Validate characters in all cookie attributes.</li>
<li><a
href="https://github.com/tornadoweb/tornado/commit/119a195e290c43ad2d63a2cf012c29d43d6ed839"><code>119a195</code></a>
httputil: Add limits on multipart form data parsing</li>
<li>See full diff in <a
href="https://github.com/tornadoweb/tornado/compare/v6.5.4...v6.5.5">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=tornado&package-manager=uv&previous-version=6.5.4&new-version=6.5.5)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/langchain-ai/langgraph/network/alerts).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-11 21:32:42 -07:00
hari-dhanushkodiandGitHub d595f11b43 feat(cli): add deploy list and deploy delete subcommands (#7118)
### Summary
This PR introduces a subcommand implementation that allows `langgraph
deploy list` and `langgraph deploy delete` subcommands.

#### `langgraph deploy list`
```bash
(env) andrewnguonly@Andrew-Nguonly-KC23X90J02 langgraph % langgraph deploy list --help                                                                              ⎈ gke_langchain-test-387119_us-west1_langgraph-cloud-us-west1
Usage: langgraph deploy list [OPTIONS]

  [Beta] List LangSmith Deployments.

Options:
  --name-contains TEXT  Only show deployments whose names contain this value.
  --api-key TEXT        API key. Can also be set via LANGGRAPH_HOST_API_KEY,
                        LANGSMITH_API_KEY, or LANGCHAIN_API_KEY environment
                        variable or .env file.
  --help                Show this message and exit.
```

Output example:
```bash
(env) andrewnguonly@Andrew-Nguonly-KC23X90J02 cli % langgraph deploy list
Deployment ID                         Deployment Name             Deployment URL                                                                      
------------------------------------  --------------------------  ------------------------------------------------------------------------------------
a40d6567-87c0-485a-a23d-94309a7d4519  ht-andrew-test-04           -                                                                                   
9da26acb-d0c9-4af0-af9e-f3fe8dfe85bc  ht-anirudh-deployment-test  https://ht-anirudh-deployment-test-428af4737f8a533cb2b107587eb8f38f.us.langgraph.app/
```

#### `langgraph deploy delete`
```bash
(env) andrewnguonly@Andrew-Nguonly-KC23X90J02 langgraph % langgraph deploy delete --help                                                                            ⎈ gke_langchain-test-387119_us-west1_langgraph-cloud-us-west1
Usage: langgraph deploy delete [OPTIONS] DEPLOYMENT_ID

  [Beta] Delete a LangSmith Deployment.

Options:
  --force         Delete without prompting for confirmation.
  --api-key TEXT  API key. Can also be set via LANGGRAPH_HOST_API_KEY,
                  LANGSMITH_API_KEY, or LANGCHAIN_API_KEY environment variable
                  or .env file.
  --help          Show this message and exit.
```

Output example:
```bash
(env) andrewnguonly@Andrew-Nguonly-KC23X90J02 cli % langgraph deploy delete a40d6567-87c0-485a-a23d-94309a7d4519
Are you sure you want to delete deployment ID a40d6567-87c0-485a-a23d-94309a7d4519? (Y/n): Y
Host API key: 
Deleted deployment a40d6567-87c0-485a-a23d-94309a7d4519.
```

```bash
(env) andrewnguonly@Andrew-Nguonly-KC23X90J02 cli % langgraph deploy delete a40d6567-87c0-485a-a23d-94309a7d4519
Are you sure you want to delete deployment ID a40d6567-87c0-485a-a23d-94309a7d4519? (Y/n): n
Aborted!
```

```bash
(env) andrewnguonly@Andrew-Nguonly-KC23X90J02 cli % langgraph deploy delete 9da26acb-d0c9-4af0-af9e-f3fe8dfe85bc --force
Host API key: 
Deleted deployment 9da26acb-d0c9-4af0-af9e-f3fe8dfe85bc.
```
2026-03-11 15:33:32 -07:00
Sydney RunkleandGitHub ed540155e3 release(langgraph): 1.1.1 (#7120) 2026-03-11 18:10:09 -04:00
Sydney RunkleandGitHub f78892d462 fix: replay bug, direct to subgraphs (#7115)
## Summary

Fixes two bugs when time-traveling to a subgraph checkpoint
(`graph.invoke(None, sub_config)`):

1. **Wrong checkpoint loaded:** The old `__enter__` checked
`replay_state` before explicit `checkpoint_id` for nested graphs. When
time-traveling, `checkpoint_map` resolves a specific checkpoint_id for
the target subgraph, but `replay_state.get_checkpoint()` would find an
*earlier* checkpoint (via `list(before=parent_ckpt_id)`), causing the
subgraph to re-execute from the beginning (step_a, ask_1, etc. all
re-ran). Fix: check for explicit `checkpoint_id` first.

2. **Stale RESUME writes kept:** The parent sets `RESUMING=True` on
subgraph configs — it can't distinguish time-travel from normal resume.
The subgraph sees `RESUMING=True` and preserves old RESUME writes, so
`interrupt()` returns cached answers instead of re-firing. Fix: check
whether the subgraph's own namespace appears in `checkpoint_map` (it
only does during time-travel — normally the map only has ancestor
entries). When present, force-strip RESUME writes.

## Test plan

Tests cover time travel (replay + fork) at different interrupt points
for both single-nested and double-nested subgraphs, including cases
where the middle subgraph itself has interrupts. All tests have sync and
async variants.

### Single nested (2 levels: parent → executor subgraph)
- `test_subgraph_time_travel_to_first_interrupt` — time travel to 1st
interrupt checkpoint; verifies step_a doesn't re-run, ask_1 re-fires
(replay + fork)
- `test_subgraph_time_travel_to_second_interrupt` — time travel to 2nd
interrupt checkpoint; verifies step_a and ask_1 don't re-run, ask_2
re-fires (replay + fork)
- `test_subgraph_time_travel_after_completion` — replay from final
parent checkpoint after full completion; verifies no nodes re-run and
all state values preserved

### Double nested (3 levels: parent → outer → inner subgraph)
- `test_3_levels_deep_time_travel_to_first_interrupt` — time travel to
innermost checkpoint at 1st interrupt (replay + fork)
- `test_3_levels_deep_time_travel_to_second_interrupt` — time travel to
innermost checkpoint at 2nd interrupt (replay + fork)
- `test_3_levels_deep_time_travel_to_middle_subgraph` — time travel to
middle-level subgraph checkpoint (replay + fork)
- `test_3_levels_deep_middle_has_interrupts` — middle subgraph has its
own `interrupt()` calls (in `pre` node) plus an inner subgraph with
interrupts; time travel to middle checkpoint at each interrupt point
(replay + fork at both the middle's own interrupt and the inner's
interrupt)
2026-03-11 21:59:45 +00:00
Andrew NguonlyandGitHub 7488cf2448 fix: Revert "feat(cli): Add deploy list and deploy delete subcommands" (#7116)
Reverts langchain-ai/langgraph#7106

This PR broke the `langgraph deploy` command flags.
2026-03-11 10:56:31 -07:00
Andrew NguonlyandGitHub e77201cbb1 feat(cli): Add deploy list and deploy delete subcommands (#7106)
### Summary
This PR introduces a subcommand implementation that allows `langgraph
deploy list` and `langgraph deploy delete` subcommands.

#### `langgraph deploy list`
```bash
(env) andrewnguonly@Andrew-Nguonly-KC23X90J02 langgraph % langgraph deploy list --help                                                                              ⎈ gke_langchain-test-387119_us-west1_langgraph-cloud-us-west1
Usage: langgraph deploy list [OPTIONS]

  [Beta] List LangSmith Deployments.

Options:
  --name-contains TEXT  Only show deployments whose names contain this value.
  --api-key TEXT        API key. Can also be set via LANGGRAPH_HOST_API_KEY,
                        LANGSMITH_API_KEY, or LANGCHAIN_API_KEY environment
                        variable or .env file.
  --help                Show this message and exit.
```

Output example:
```bash
(env) andrewnguonly@Andrew-Nguonly-KC23X90J02 cli % langgraph deploy list
Deployment ID                         Deployment Name             Deployment URL                                                                      
------------------------------------  --------------------------  ------------------------------------------------------------------------------------
a40d6567-87c0-485a-a23d-94309a7d4519  ht-andrew-test-04           -                                                                                   
9da26acb-d0c9-4af0-af9e-f3fe8dfe85bc  ht-anirudh-deployment-test  https://ht-anirudh-deployment-test-428af4737f8a533cb2b107587eb8f38f.us.langgraph.app
```

#### `langgraph deploy delete`
```bash
(env) andrewnguonly@Andrew-Nguonly-KC23X90J02 langgraph % langgraph deploy delete --help                                                                            ⎈ gke_langchain-test-387119_us-west1_langgraph-cloud-us-west1
Usage: langgraph deploy delete [OPTIONS] DEPLOYMENT_ID

  [Beta] Delete a LangSmith Deployment.

Options:
  --force         Delete without prompting for confirmation.
  --api-key TEXT  API key. Can also be set via LANGGRAPH_HOST_API_KEY,
                  LANGSMITH_API_KEY, or LANGCHAIN_API_KEY environment variable
                  or .env file.
  --help          Show this message and exit.
```

Output example:
```bash
(env) andrewnguonly@Andrew-Nguonly-KC23X90J02 cli % langgraph deploy delete a40d6567-87c0-485a-a23d-94309a7d4519
Are you sure you want to delete deployment ID a40d6567-87c0-485a-a23d-94309a7d4519? (Y/n): Y
Host API key: 
Deleted deployment a40d6567-87c0-485a-a23d-94309a7d4519.
```

```bash
(env) andrewnguonly@Andrew-Nguonly-KC23X90J02 cli % langgraph deploy delete a40d6567-87c0-485a-a23d-94309a7d4519
Are you sure you want to delete deployment ID a40d6567-87c0-485a-a23d-94309a7d4519? (Y/n): n
Aborted!
```

```bash
(env) andrewnguonly@Andrew-Nguonly-KC23X90J02 cli % langgraph deploy delete 9da26acb-d0c9-4af0-af9e-f3fe8dfe85bc --force
Host API key: 
Deleted deployment 9da26acb-d0c9-4af0-af9e-f3fe8dfe85bc.
```
2026-03-11 09:12:17 -07:00
William FHandGitHub acae5e23b0 chore(sdk-py): cron tz support (#7108) 2026-03-10 17:38:02 -07:00
Sydney RunkleandGitHub 14ce607111 chore: remove md notes (#7103) 2026-03-10 08:44:55 -04:00
Sydney RunkleandGitHub 3330ccdea4 release(langgraph): 1.1 (#7102)
exciting!

relnotes preview:

# LangGraph 1.1.0 Release Notes

## Type-Safe Streaming & Invoke

LangGraph 1.1 introduces `version="v2"` — a new opt-in streaming format
that brings full type safety to `stream()`, `astream()`, `invoke()`, and
`ainvoke()`.

### What's changing

**v1 (default, unchanged):** `stream()` yields bare tuples like
`(stream_mode, data)` or just `data`. `invoke()` returns a plain `dict`.
Interrupts are mixed into the output dict under `"__interrupt__"`.

**v2 (opt-in):** `stream()` yields strongly-typed `StreamPart` dicts
with `type`, `ns`, `data`, and (for values) `interrupts` fields.
`invoke()` returns a `GraphOutput` object with `.value` and
`.interrupts` attributes. When your state schema is a Pydantic model or
dataclass, outputs are automatically coerced to the correct type.

### `invoke()` / `ainvoke()` with `version="v2"`

```python
from langgraph.types import GraphOutput

result = graph.invoke({"input": "hello"}, version="v2")

# result is a GraphOutput, not a dict
assert isinstance(result, GraphOutput)
result.value       # your output — dict, Pydantic model, or dataclass
result.interrupts  # tuple[Interrupt, ...], empty if none occurred
```

With a non-`"values"` stream mode, `invoke(..., stream_mode="updates",
version="v2")` returns `list[StreamPart]` instead of `list[tuple]`.

### `stream()` / `astream()` with `version="v2"`

```python
for part in graph.stream({"input": "hello"}, version="v2"):
    if part["type"] == "values":
        part["data"]        # OutputT — full state
        part["interrupts"]  # tuple[Interrupt, ...]
    elif part["type"] == "updates":
        part["data"]        # dict[str, Any]
    elif part["type"] == "messages":
        part["data"]        # tuple[BaseMessage, dict]
    elif part["type"] == "custom":
        part["data"]        # Any
    elif part["type"] == "tasks":
        part["data"]        # TaskPayload | TaskResultPayload
    elif part["type"] == "debug":
        part["data"]        # DebugPayload
```

Each stream mode has its own `TypedDict` — `ValuesStreamPart`,
`UpdatesStreamPart`, `MessagesStreamPart`, `CustomStreamPart`,
`CheckpointStreamPart`, `TasksStreamPart`, `DebugStreamPart` — all
importable from `langgraph.types`. The union type `StreamPart` is a
discriminated union on `part["type"]`, enabling full type narrowing in
editors and type checkers.

### Pydantic & dataclass output coercion

When your graph's state schema is a Pydantic model or dataclass,
`version="v2"` automatically coerces outputs to the declared type:

```python
from pydantic import BaseModel

class MyState(BaseModel):
    answer: str
    count: int

graph = StateGraph(MyState)
# ... build graph ...
compiled = graph.compile()

result = compiled.invoke({"answer": "", "count": 0}, version="v2")
assert isinstance(result.value, MyState)  # not a dict!
```

### Backward compatibility

- **Default is still `version="v1"`** — existing code works without
changes.
- To make migration easier, `GraphOutput` supports old-style best-effort
access to graph values and interrupts. Dict-style access
(`result["key"]`, `"key" in result`, `result["__interrupt__"]`) still
works and delegates to `result.value` / `result.interrupts` under the
hood. However, this is **deprecated** and emits a
`LangGraphDeprecatedSinceV11` warning. It will be removed in v3.0 —
migrate to `result.value` and `result.interrupts` at your convenience.

```python
result = graph.invoke({"input": "hello"}, version="v2")

# Old style — still works, but deprecated
result["input"]          # delegates to result.value["input"]
result["__interrupt__"]  # delegates to result.interrupts
"input" in result        # delegates to "input" in result.value

# New style — preferred
result.value["input"]
result.interrupts
```

## Migration Guide

1. **No action required** — `version="v1"` remains the default. All
existing code continues to work.
2. **Adopt v2 incrementally** — Add `version="v2"` to individual
`invoke()`/`stream()` calls to get typed outputs.
3. **Use typed imports** — Import `GraphOutput`, `StreamPart`, and
individual part types from `langgraph.types` for type-safe code.
2026-03-10 12:41:30 +00:00
Quanzheng LongandGitHub 4ef61690c6 feat(cli): add distributed runtime support to langgraph cli (#7096) 2026-03-09 20:43:45 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>John KennedyClaude Sonnet 4.6
27da1d35ef chore(deps): bump the all-dependencies group across 1 directory with 3 updates (#7072)
Bumps the all-dependencies group with 3 updates in the /libs/sdk-py
directory: [ruff](https://github.com/astral-sh/ruff),
[codespell](https://github.com/codespell-project/codespell) and
[ty](https://github.com/astral-sh/ty).

Updates `ruff` from 0.15.1 to 0.15.5
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/astral-sh/ruff/releases">ruff's
releases</a>.</em></p>
<blockquote>
<h2>0.15.5</h2>
<h2>Release Notes</h2>
<p>Released on 2026-03-05.</p>
<h3>Preview features</h3>
<ul>
<li>Discover Markdown files by default in preview mode (<a
href="https://redirect.github.com/astral-sh/ruff/pull/23434">#23434</a>)</li>
<li>[<code>perflint</code>] Extend <code>PERF102</code> to
comprehensions and generators (<a
href="https://redirect.github.com/astral-sh/ruff/pull/23473">#23473</a>)</li>
<li>[<code>refurb</code>] Fix <code>FURB101</code> and
<code>FURB103</code> false positives when I/O variable is used later (<a
href="https://redirect.github.com/astral-sh/ruff/pull/23542">#23542</a>)</li>
<li>[<code>ruff</code>] Add fix for
<code>none-not-at-end-of-union</code> (<code>RUF036</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/22829">#22829</a>)</li>
<li>[<code>ruff</code>] Fix false positive for <code>re.split</code>
with empty string pattern (<code>RUF055</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/23634">#23634</a>)</li>
</ul>
<h3>Bug fixes</h3>
<ul>
<li>[<code>fastapi</code>] Handle callable class dependencies with
<code>__call__</code> method (<code>FAST003</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/23553">#23553</a>)</li>
<li>[<code>pydocstyle</code>] Fix numpy section ordering
(<code>D420</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/23685">#23685</a>)</li>
<li>[<code>pyflakes</code>] Fix false positive for names shadowing
re-exports (<code>F811</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/23356">#23356</a>)</li>
<li>[<code>pyupgrade</code>] Avoid inserting redundant <code>None</code>
elements in <code>UP045</code> (<a
href="https://redirect.github.com/astral-sh/ruff/pull/23459">#23459</a>)</li>
</ul>
<h3>Documentation</h3>
<ul>
<li>Document extension mapping for Markdown code formatting (<a
href="https://redirect.github.com/astral-sh/ruff/pull/23574">#23574</a>)</li>
<li>Update default Python version examples (<a
href="https://redirect.github.com/astral-sh/ruff/pull/23605">#23605</a>)</li>
</ul>
<h3>Other changes</h3>
<ul>
<li>Publish releases to Astral mirror (<a
href="https://redirect.github.com/astral-sh/ruff/pull/23616">#23616</a>)</li>
</ul>
<h3>Contributors</h3>
<ul>
<li><a
href="https://github.com/amyreese"><code>@​amyreese</code></a></li>
<li><a
href="https://github.com/stakeswky"><code>@​stakeswky</code></a></li>
<li><a
href="https://github.com/chirizxc"><code>@​chirizxc</code></a></li>
<li><a
href="https://github.com/anishgirianish"><code>@​anishgirianish</code></a></li>
<li><a href="https://github.com/bxff"><code>@​bxff</code></a></li>
<li><a href="https://github.com/zsol"><code>@​zsol</code></a></li>
<li><a
href="https://github.com/charliermarsh"><code>@​charliermarsh</code></a></li>
<li><a href="https://github.com/ntBre"><code>@​ntBre</code></a></li>
<li><a
href="https://github.com/kar-ganap"><code>@​kar-ganap</code></a></li>
</ul>
<h2>Install ruff 0.15.5</h2>
<h3>Install prebuilt binaries via shell script</h3>
<pre lang="sh"><code>curl --proto '=https' --tlsv1.2 -LsSf
https://github.com/astral-sh/ruff/releases/download/0.15.5/ruff-installer.sh
| sh
</code></pre>
<h3>Install prebuilt binaries via powershell script</h3>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md">ruff's
changelog</a>.</em></p>
<blockquote>
<h2>0.15.5</h2>
<p>Released on 2026-03-05.</p>
<h3>Preview features</h3>
<ul>
<li>Discover Markdown files by default in preview mode (<a
href="https://redirect.github.com/astral-sh/ruff/pull/23434">#23434</a>)</li>
<li>[<code>perflint</code>] Extend <code>PERF102</code> to
comprehensions and generators (<a
href="https://redirect.github.com/astral-sh/ruff/pull/23473">#23473</a>)</li>
<li>[<code>refurb</code>] Fix <code>FURB101</code> and
<code>FURB103</code> false positives when I/O variable is used later (<a
href="https://redirect.github.com/astral-sh/ruff/pull/23542">#23542</a>)</li>
<li>[<code>ruff</code>] Add fix for
<code>none-not-at-end-of-union</code> (<code>RUF036</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/22829">#22829</a>)</li>
<li>[<code>ruff</code>] Fix false positive for <code>re.split</code>
with empty string pattern (<code>RUF055</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/23634">#23634</a>)</li>
</ul>
<h3>Bug fixes</h3>
<ul>
<li>[<code>fastapi</code>] Handle callable class dependencies with
<code>__call__</code> method (<code>FAST003</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/23553">#23553</a>)</li>
<li>[<code>pydocstyle</code>] Fix numpy section ordering
(<code>D420</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/23685">#23685</a>)</li>
<li>[<code>pyflakes</code>] Fix false positive for names shadowing
re-exports (<code>F811</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/23356">#23356</a>)</li>
<li>[<code>pyupgrade</code>] Avoid inserting redundant <code>None</code>
elements in <code>UP045</code> (<a
href="https://redirect.github.com/astral-sh/ruff/pull/23459">#23459</a>)</li>
</ul>
<h3>Documentation</h3>
<ul>
<li>Document extension mapping for Markdown code formatting (<a
href="https://redirect.github.com/astral-sh/ruff/pull/23574">#23574</a>)</li>
<li>Update default Python version examples (<a
href="https://redirect.github.com/astral-sh/ruff/pull/23605">#23605</a>)</li>
</ul>
<h3>Other changes</h3>
<ul>
<li>Publish releases to Astral mirror (<a
href="https://redirect.github.com/astral-sh/ruff/pull/23616">#23616</a>)</li>
</ul>
<h3>Contributors</h3>
<ul>
<li><a
href="https://github.com/amyreese"><code>@​amyreese</code></a></li>
<li><a
href="https://github.com/stakeswky"><code>@​stakeswky</code></a></li>
<li><a
href="https://github.com/chirizxc"><code>@​chirizxc</code></a></li>
<li><a
href="https://github.com/anishgirianish"><code>@​anishgirianish</code></a></li>
<li><a href="https://github.com/bxff"><code>@​bxff</code></a></li>
<li><a href="https://github.com/zsol"><code>@​zsol</code></a></li>
<li><a
href="https://github.com/charliermarsh"><code>@​charliermarsh</code></a></li>
<li><a href="https://github.com/ntBre"><code>@​ntBre</code></a></li>
<li><a
href="https://github.com/kar-ganap"><code>@​kar-ganap</code></a></li>
</ul>
<h2>0.15.4</h2>
<p>Released on 2026-02-26.</p>
<p>This is a follow-up release to 0.15.3 that resolves a panic when the
new rule <code>PLR1712</code> was enabled with any rule that analyzes
definitions, such as many of the <code>ANN</code> or <code>D</code>
rules.</p>
<h3>Bug fixes</h3>
<ul>
<li>Fix panic on access to definitions after analyzing definitions (<a
href="https://redirect.github.com/astral-sh/ruff/pull/23588">#23588</a>)</li>
<li>[<code>pyflakes</code>] Suppress false positive in <code>F821</code>
for names used before <code>del</code> in stub files (<a
href="https://redirect.github.com/astral-sh/ruff/pull/23550">#23550</a>)</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/astral-sh/ruff/commit/5e4a3d9c3b381df20f6a52caef0f56ed0ebc74be"><code>5e4a3d9</code></a>
Bump 0.15.5 (<a
href="https://redirect.github.com/astral-sh/ruff/issues/23743">#23743</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/69c23cc5a3a6cb08d81b01c7d1c2ba0482c3a3b1"><code>69c23cc</code></a>
[ty] Render all changed diagnostics in conformance.py (<a
href="https://redirect.github.com/astral-sh/ruff/issues/23613">#23613</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/4926bd58204839cb75a8ed1397e824bbc8f644ca"><code>4926bd5</code></a>
[ty] Split deferred checks out of <code>types/infer/builder.rs</code>
(<a
href="https://redirect.github.com/astral-sh/ruff/issues/23740">#23740</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/9a70f5eb2fb0180953418cd6ac037cb3d531e77b"><code>9a70f5e</code></a>
Discover markdown files by default in preview mode (<a
href="https://redirect.github.com/astral-sh/ruff/issues/23434">#23434</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/3dc78b0a84ee231afb1c3329e11bfc912c236366"><code>3dc78b0</code></a>
[ty] Use <code>HasOptionalDefinition</code> for <code>except</code>
handlers (<a
href="https://redirect.github.com/astral-sh/ruff/issues/23739">#23739</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/a6a5e8d10b8a5185049827be0a304db522b91c9a"><code>a6a5e8d</code></a>
[ty] Fix precedence of <code>all</code> selector in TOML configurations
(<a
href="https://redirect.github.com/astral-sh/ruff/issues/23723">#23723</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/2a5384b0b6e22ab511aec6f8dbb11648befda887"><code>2a5384b</code></a>
[ty] Make <code>all</code> selector case sensitive (<a
href="https://redirect.github.com/astral-sh/ruff/issues/23713">#23713</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/db77d7b2ae3da8deed64d8889a5cbcea287b52a6"><code>db77d7b</code></a>
[ty] Add a diagnostic if a <code>TypeVar</code> is used to specialize a
<code>ParamSpec</code>, or ...</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/db2849068f7d6a1f42cdafec46a7c2c83d39ece3"><code>db28490</code></a>
[ty] Override home directory in ty tests (<a
href="https://redirect.github.com/astral-sh/ruff/issues/23724">#23724</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/5f0fd91a230972bb9d1e4545ebaed2b7d09158a2"><code>5f0fd91</code></a>
[ty] More type-variable default validation (<a
href="https://redirect.github.com/astral-sh/ruff/issues/23639">#23639</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/astral-sh/ruff/compare/0.15.1...0.15.5">compare
view</a></li>
</ul>
</details>
<br />

Updates `codespell` from 2.4.1 to 2.4.2
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/codespell-project/codespell/releases">codespell's
releases</a>.</em></p>
<blockquote>
<h2>v2.4.2</h2>
<!-- raw HTML omitted -->
<h2>Highlights</h2>
<ul>
<li>Fixed compatibility with chardet 7+</li>
</ul>
<h2>What's Changed</h2>
<ul>
<li>Fix and clarify cases in ignore patterns by <a
href="https://github.com/DanielYang59"><code>@​DanielYang59</code></a>
in <a
href="https://redirect.github.com/codespell-project/codespell/pull/3583">codespell-project/codespell#3583</a></li>
<li>codespell-private.yml: Do not codespell digital signature files by
<a href="https://github.com/cclauss"><code>@​cclauss</code></a> in <a
href="https://redirect.github.com/codespell-project/codespell/pull/3623">codespell-project/codespell#3623</a></li>
<li>[pre-commit.ci] pre-commit autoupdate by <a
href="https://github.com/pre-commit-ci"><code>@​pre-commit-ci</code></a>[bot]
in <a
href="https://redirect.github.com/codespell-project/codespell/pull/3634">codespell-project/codespell#3634</a></li>
<li>numbes-&gt;numbers and numbesr-&gt;numbers by <a
href="https://github.com/skshetry"><code>@​skshetry</code></a> in <a
href="https://redirect.github.com/codespell-project/codespell/pull/3635">codespell-project/codespell#3635</a></li>
<li>Add spelling corrections for disclose and variables. by <a
href="https://github.com/cfi-gb"><code>@​cfi-gb</code></a> in <a
href="https://redirect.github.com/codespell-project/codespell/pull/3622">codespell-project/codespell#3622</a></li>
<li>Add spelling correction for Vulnererability and variants. by <a
href="https://github.com/cfi-gb"><code>@​cfi-gb</code></a> in <a
href="https://redirect.github.com/codespell-project/codespell/pull/3625">codespell-project/codespell#3625</a></li>
<li>Remove lets-&gt;let's by <a
href="https://github.com/Piedone"><code>@​Piedone</code></a> in <a
href="https://redirect.github.com/codespell-project/codespell/pull/3633">codespell-project/codespell#3633</a></li>
<li>Add corrections for &quot;dictate&quot; by <a
href="https://github.com/jdufresne"><code>@​jdufresne</code></a> in <a
href="https://redirect.github.com/codespell-project/codespell/pull/3636">codespell-project/codespell#3636</a></li>
<li>Add specicification (and pl) typo by <a
href="https://github.com/yarikoptic"><code>@​yarikoptic</code></a> in <a
href="https://redirect.github.com/codespell-project/codespell/pull/3639">codespell-project/codespell#3639</a></li>
<li>Remove &quot;blueish&quot; correction by <a
href="https://github.com/hadess"><code>@​hadess</code></a> in <a
href="https://redirect.github.com/codespell-project/codespell/pull/3510">codespell-project/codespell#3510</a></li>
<li>Add &quot;lighting&quot; as an option to fix &quot;lighning&quot; by
<a href="https://github.com/yarikoptic"><code>@​yarikoptic</code></a> in
<a
href="https://redirect.github.com/codespell-project/codespell/pull/3648">codespell-project/codespell#3648</a></li>
<li>Revert adding <code>lien</code> to the rare dictionary by <a
href="https://github.com/nikolaik"><code>@​nikolaik</code></a> in <a
href="https://redirect.github.com/codespell-project/codespell/pull/3631">codespell-project/codespell#3631</a></li>
<li>&quot;ane&quot; could have been &quot;one&quot; by <a
href="https://github.com/yarikoptic"><code>@​yarikoptic</code></a> in <a
href="https://redirect.github.com/codespell-project/codespell/pull/3645">codespell-project/codespell#3645</a></li>
<li>Add spelling correction for &quot;priort&quot; by <a
href="https://github.com/cfi-gb"><code>@​cfi-gb</code></a> in <a
href="https://redirect.github.com/codespell-project/codespell/pull/3647">codespell-project/codespell#3647</a></li>
<li>Remove &quot;fix&quot; of &quot;deques&quot; - it is quite legit by
<a href="https://github.com/yarikoptic"><code>@​yarikoptic</code></a> in
<a
href="https://redirect.github.com/codespell-project/codespell/pull/3649">codespell-project/codespell#3649</a></li>
<li>Several new suggestions by <a
href="https://github.com/mdeweerd"><code>@​mdeweerd</code></a> in <a
href="https://redirect.github.com/codespell-project/codespell/pull/3621">codespell-project/codespell#3621</a></li>
<li>Add proposal constraints to containts by <a
href="https://github.com/mdeweerd"><code>@​mdeweerd</code></a> in <a
href="https://redirect.github.com/codespell-project/codespell/pull/3652">codespell-project/codespell#3652</a></li>
<li>Additions dleay,infp,practive,utiliy by <a
href="https://github.com/mdeweerd"><code>@​mdeweerd</code></a> in <a
href="https://redirect.github.com/codespell-project/codespell/pull/3643">codespell-project/codespell#3643</a></li>
<li>Add calncelled and its variations by <a
href="https://github.com/mdeweerd"><code>@​mdeweerd</code></a> in <a
href="https://redirect.github.com/codespell-project/codespell/pull/3650">codespell-project/codespell#3650</a></li>
<li>Use raw strings for regex by <a
href="https://github.com/DimitriPapadopoulos"><code>@​DimitriPapadopoulos</code></a>
in <a
href="https://redirect.github.com/codespell-project/codespell/pull/3654">codespell-project/codespell#3654</a></li>
<li>Allow multiple spaces before codespell:ignore by <a
href="https://github.com/DimitriPapadopoulos"><code>@​DimitriPapadopoulos</code></a>
in <a
href="https://redirect.github.com/codespell-project/codespell/pull/3653">codespell-project/codespell#3653</a></li>
<li>Added correction from <code>timeour</code> to <code>timeout</code>
by <a href="https://github.com/jamesbraza"><code>@​jamesbraza</code></a>
in <a
href="https://redirect.github.com/codespell-project/codespell/pull/3656">codespell-project/codespell#3656</a></li>
<li>Add typos found in various software projects by <a
href="https://github.com/luzpaz"><code>@​luzpaz</code></a> in <a
href="https://redirect.github.com/codespell-project/codespell/pull/3640">codespell-project/codespell#3640</a></li>
<li>[pre-commit.ci] pre-commit autoupdate by <a
href="https://github.com/pre-commit-ci"><code>@​pre-commit-ci</code></a>[bot]
in <a
href="https://redirect.github.com/codespell-project/codespell/pull/3659">codespell-project/codespell#3659</a></li>
<li>Add codespell suggestions for enabke and friends by <a
href="https://github.com/mdeweerd"><code>@​mdeweerd</code></a> in <a
href="https://redirect.github.com/codespell-project/codespell/pull/3657">codespell-project/codespell#3657</a></li>
<li>END: add &quot;queues&quot; (plural from queue) as possible fix for
ques by <a
href="https://github.com/yarikoptic"><code>@​yarikoptic</code></a> in <a
href="https://redirect.github.com/codespell-project/codespell/pull/3591">codespell-project/codespell#3591</a></li>
<li>agreegate, lesda, realod, colouer by <a
href="https://github.com/mdeweerd"><code>@​mdeweerd</code></a> in <a
href="https://redirect.github.com/codespell-project/codespell/pull/3665">codespell-project/codespell#3665</a></li>
<li>Update pre-commit version in documentation by <a
href="https://github.com/prchoward"><code>@​prchoward</code></a> in <a
href="https://redirect.github.com/codespell-project/codespell/pull/3666">codespell-project/codespell#3666</a></li>
<li>MAINT: Rename CI file and run name by <a
href="https://github.com/larsoner"><code>@​larsoner</code></a> in <a
href="https://redirect.github.com/codespell-project/codespell/pull/3667">codespell-project/codespell#3667</a></li>
<li>preoccuption-&gt;preoccupation; occuption-&gt;occupation by <a
href="https://github.com/TheGiraffe3"><code>@​TheGiraffe3</code></a> in
<a
href="https://redirect.github.com/codespell-project/codespell/pull/3668">codespell-project/codespell#3668</a></li>
<li>Suggestions for: checkto, diminsion, waitfor by <a
href="https://github.com/mdeweerd"><code>@​mdeweerd</code></a> in <a
href="https://redirect.github.com/codespell-project/codespell/pull/3670">codespell-project/codespell#3670</a></li>
<li>Typos found in sigstore-python by <a
href="https://github.com/DimitriPapadopoulos"><code>@​DimitriPapadopoulos</code></a>
in <a
href="https://redirect.github.com/codespell-project/codespell/pull/3664">codespell-project/codespell#3664</a></li>
<li>usgin-&gt;using by <a
href="https://github.com/ydah"><code>@​ydah</code></a> in <a
href="https://redirect.github.com/codespell-project/codespell/pull/3672">codespell-project/codespell#3672</a></li>
<li>Add typos found in various software projects by <a
href="https://github.com/luzpaz"><code>@​luzpaz</code></a> in <a
href="https://redirect.github.com/codespell-project/codespell/pull/3669">codespell-project/codespell#3669</a></li>
<li>Add coered -&gt; coerced by <a
href="https://github.com/effigies"><code>@​effigies</code></a> in <a
href="https://redirect.github.com/codespell-project/codespell/pull/3680">codespell-project/codespell#3680</a></li>
<li>backwward(s)-&gt;backward(s), onwward(s)-&gt;onward(s) by <a
href="https://github.com/cjwatson"><code>@​cjwatson</code></a> in <a
href="https://redirect.github.com/codespell-project/codespell/pull/3682">codespell-project/codespell#3682</a></li>
<li>[pre-commit.ci] pre-commit autoupdate by <a
href="https://github.com/pre-commit-ci"><code>@​pre-commit-ci</code></a>[bot]
in <a
href="https://redirect.github.com/codespell-project/codespell/pull/3685">codespell-project/codespell#3685</a></li>
<li>telemetery-&gt;telemetry by <a
href="https://github.com/august-soderberg"><code>@​august-soderberg</code></a>
in <a
href="https://redirect.github.com/codespell-project/codespell/pull/3686">codespell-project/codespell#3686</a></li>
<li>Add hexedacimal and similar typos by <a
href="https://github.com/Akuli"><code>@​Akuli</code></a> in <a
href="https://redirect.github.com/codespell-project/codespell/pull/3692">codespell-project/codespell#3692</a></li>
<li>Add rounted-&gt;routed, rounded and friends by <a
href="https://github.com/peternewman"><code>@​peternewman</code></a> in
<a
href="https://redirect.github.com/codespell-project/codespell/pull/3693">codespell-project/codespell#3693</a></li>
<li>Add symmectric and similar typos by <a
href="https://github.com/Akuli"><code>@​Akuli</code></a> in <a
href="https://redirect.github.com/codespell-project/codespell/pull/3694">codespell-project/codespell#3694</a></li>
<li>Fix CI on Windows: pip upgrade pip by <a
href="https://github.com/DimitriPapadopoulos"><code>@​DimitriPapadopoulos</code></a>
in <a
href="https://redirect.github.com/codespell-project/codespell/pull/3698">codespell-project/codespell#3698</a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/codespell-project/codespell/commit/2ccb47ff45ad361a21071a7eedda4c37e6ae8c5a"><code>2ccb47f</code></a>
Compat with chardet 7 (<a
href="https://redirect.github.com/codespell-project/codespell/issues/3886">#3886</a>)</li>
<li><a
href="https://github.com/codespell-project/codespell/commit/4ec53bf6a3e510c64900d5ee838abd99d49b2910"><code>4ec53bf</code></a>
[pre-commit.ci] pre-commit autoupdate</li>
<li><a
href="https://github.com/codespell-project/codespell/commit/2a4acba3f282f1b5ccb7ad8b57bc991810663a44"><code>2a4acba</code></a>
Bump actions/download-artifact from 7 to 8</li>
<li><a
href="https://github.com/codespell-project/codespell/commit/be17cacc96a5ee3f014e048f5962cfdb7145e096"><code>be17cac</code></a>
Bump actions/upload-artifact from 6 to 7</li>
<li><a
href="https://github.com/codespell-project/codespell/commit/04a071280d56148cab14249ccc8d4181c0066b3c"><code>04a0712</code></a>
Bump ruff (<a
href="https://redirect.github.com/codespell-project/codespell/issues/3879">#3879</a>)</li>
<li><a
href="https://github.com/codespell-project/codespell/commit/583d8796d92eb58e15072db03e5b756be45f638a"><code>583d879</code></a>
avoide-&gt;avoid, avoided, avoids,</li>
<li><a
href="https://github.com/codespell-project/codespell/commit/1f59f34d7c6d1642fdb325d9dfa49cf9eb5f692a"><code>1f59f34</code></a>
Add correction for 'foudation' to 'foundation'</li>
<li><a
href="https://github.com/codespell-project/codespell/commit/e047fdafb8620b08a86349014487886bcd9c2205"><code>e047fda</code></a>
Add spelling correction for gather and variants.</li>
<li><a
href="https://github.com/codespell-project/codespell/commit/b5cd66de14b8f65b0f45fabbe1c89bd69ea60939"><code>b5cd66d</code></a>
respondant-&gt;respondent</li>
<li><a
href="https://github.com/codespell-project/codespell/commit/92125a3814fa6e86cd2055385916ce5186d3e5df"><code>92125a3</code></a>
Add detection of ivoice and variants.</li>
<li>Additional commits viewable in <a
href="https://github.com/codespell-project/codespell/compare/v2.4.1...v2.4.2">compare
view</a></li>
</ul>
</details>
<br />

Updates `ty` from 0.0.17 to 0.0.21
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/astral-sh/ty/releases">ty's
releases</a>.</em></p>
<blockquote>
<h2>0.0.21</h2>
<h2>Release Notes</h2>
<p>Released on 2026-03-05.</p>
<h3>Bug fixes</h3>
<ul>
<li>Avoid stack overflow with recursive typevar (<a
href="https://redirect.github.com/astral-sh/ruff/pull/23652">#23652</a>)</li>
<li>Fix panic on incomplete except handlers (<a
href="https://redirect.github.com/astral-sh/ruff/pull/23708">#23708</a>)</li>
<li>Allow unions of different-length iterables in <code>*args</code>
unpacking into optional positional parameters (<a
href="https://redirect.github.com/astral-sh/ruff/pull/23124">#23124</a>)</li>
<li>Don't replace <code>Any</code> attributes with <code>object</code>
after <code>isinstance</code> narrowing (<a
href="https://redirect.github.com/astral-sh/ruff/pull/23725">#23725</a>)</li>
</ul>
<h3>LSP server</h3>
<ul>
<li>Exclude decorators from class/def code folding (<a
href="https://redirect.github.com/astral-sh/ruff/pull/23543">#23543</a>)</li>
<li>Fix handling of non-Python text documents (<a
href="https://redirect.github.com/astral-sh/ruff/pull/23704">#23704</a>)</li>
</ul>
<h3>Configuration</h3>
<ul>
<li>Add <code>all</code> selector to <code>ty.json</code> schema (<a
href="https://redirect.github.com/astral-sh/ruff/pull/23721">#23721</a>)</li>
<li>Fix precedence of <code>all</code> selector in TOML configurations
(<a
href="https://redirect.github.com/astral-sh/ruff/pull/23723">#23723</a>)</li>
<li>Make <code>all</code> selector case-sensitive (<a
href="https://redirect.github.com/astral-sh/ruff/pull/23713">#23713</a>)</li>
</ul>
<h3>Type checking</h3>
<ul>
<li>Add <code>invalid-enum-member-annotation</code> lint rule (<a
href="https://redirect.github.com/astral-sh/ruff/pull/23648">#23648</a>)</li>
<li>Add a diagnostic for an unused awaitable (<a
href="https://redirect.github.com/astral-sh/ruff/pull/23650">#23650</a>)</li>
<li>Add a diagnostic if a <code>TypeVar</code> is used to specialize a
<code>ParamSpec</code>, or vice versa (<a
href="https://redirect.github.com/astral-sh/ruff/pull/23738">#23738</a>)</li>
<li>Add more type-variable default validation (<a
href="https://redirect.github.com/astral-sh/ruff/pull/23639">#23639</a>)</li>
<li>Add unbound type variable detection in annotations (<a
href="https://redirect.github.com/astral-sh/ruff/pull/23641">#23641</a>)</li>
<li>Apply narrowing to walrus values (<a
href="https://redirect.github.com/astral-sh/ruff/pull/23687">#23687</a>)</li>
<li>Do not union <code>Unknown</code> into unannotated container types
(<a
href="https://redirect.github.com/astral-sh/ruff/pull/23718">#23718</a>)</li>
<li>Avoid inferring generics with negative intersections (<a
href="https://redirect.github.com/astral-sh/ruff/pull/23750">#23750</a>)</li>
<li>More precise types for <code>name</code> and <code>value</code> of
an enum (<a
href="https://redirect.github.com/astral-sh/ruff/pull/23683">#23683</a>)</li>
<li>Better preserve user-provided union order in inferred
specializations (<a
href="https://redirect.github.com/astral-sh/ruff/pull/23715">#23715</a>)</li>
<li>Support narrowing in ternary expressions (<a
href="https://redirect.github.com/astral-sh/ruff/pull/23726">#23726</a>)</li>
<li>Validate bare ParamSpec usage in type annotations, and support
stringified ParamSpecs as the first argument to <code>Callable</code>
(<a
href="https://redirect.github.com/astral-sh/ruff/pull/23625">#23625</a>)</li>
</ul>
<h3>Contributors</h3>
<ul>
<li><a
href="https://github.com/charliermarsh"><code>@​charliermarsh</code></a></li>
<li><a href="https://github.com/zsol"><code>@​zsol</code></a></li>
<li><a href="https://github.com/Gankra"><code>@​Gankra</code></a></li>
<li><a
href="https://github.com/MichaReiser"><code>@​MichaReiser</code></a></li>
<li><a
href="https://github.com/AlexWaygood"><code>@​AlexWaygood</code></a></li>
<li><a href="https://github.com/rizzip"><code>@​rizzip</code></a></li>
<li><a
href="https://github.com/iksuddle"><code>@​iksuddle</code></a></li>
<li><a
href="https://github.com/dcreager"><code>@​dcreager</code></a></li>
<li><a
href="https://github.com/BurntSushi"><code>@​BurntSushi</code></a></li>
<li><a href="https://github.com/carljm"><code>@​carljm</code></a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/astral-sh/ty/blob/main/CHANGELOG.md">ty's
changelog</a>.</em></p>
<blockquote>
<h2>0.0.21</h2>
<p>Released on 2026-03-05.</p>
<h3>Bug fixes</h3>
<ul>
<li>Avoid stack overflow with recursive typevar (<a
href="https://redirect.github.com/astral-sh/ruff/pull/23652">#23652</a>)</li>
<li>Fix panic on incomplete except handlers (<a
href="https://redirect.github.com/astral-sh/ruff/pull/23708">#23708</a>)</li>
<li>Allow unions of different-length iterables in <code>*args</code>
unpacking into optional positional parameters (<a
href="https://redirect.github.com/astral-sh/ruff/pull/23124">#23124</a>)</li>
<li>Don't replace <code>Any</code> attributes with <code>object</code>
after <code>isinstance</code> narrowing (<a
href="https://redirect.github.com/astral-sh/ruff/pull/23725">#23725</a>)</li>
</ul>
<h3>LSP server</h3>
<ul>
<li>Exclude decorators from class/def code folding (<a
href="https://redirect.github.com/astral-sh/ruff/pull/23543">#23543</a>)</li>
<li>Fix handling of non-Python text documents (<a
href="https://redirect.github.com/astral-sh/ruff/pull/23704">#23704</a>)</li>
</ul>
<h3>Configuration</h3>
<ul>
<li>Add <code>all</code> selector to <code>ty.json</code> schema (<a
href="https://redirect.github.com/astral-sh/ruff/pull/23721">#23721</a>)</li>
<li>Fix precedence of <code>all</code> selector in TOML configurations
(<a
href="https://redirect.github.com/astral-sh/ruff/pull/23723">#23723</a>)</li>
<li>Make <code>all</code> selector case-sensitive (<a
href="https://redirect.github.com/astral-sh/ruff/pull/23713">#23713</a>)</li>
</ul>
<h3>Type checking</h3>
<ul>
<li>Add <code>invalid-enum-member-annotation</code> lint rule (<a
href="https://redirect.github.com/astral-sh/ruff/pull/23648">#23648</a>)</li>
<li>Add a diagnostic for an unused awaitable (<a
href="https://redirect.github.com/astral-sh/ruff/pull/23650">#23650</a>)</li>
<li>Add a diagnostic if a <code>TypeVar</code> is used to specialize a
<code>ParamSpec</code>, or vice versa (<a
href="https://redirect.github.com/astral-sh/ruff/pull/23738">#23738</a>)</li>
<li>Add more type-variable default validation (<a
href="https://redirect.github.com/astral-sh/ruff/pull/23639">#23639</a>)</li>
<li>Add unbound type variable detection in annotations (<a
href="https://redirect.github.com/astral-sh/ruff/pull/23641">#23641</a>)</li>
<li>Apply narrowing to walrus values (<a
href="https://redirect.github.com/astral-sh/ruff/pull/23687">#23687</a>)</li>
<li>Do not union <code>Unknown</code> into unannotated container types
(<a
href="https://redirect.github.com/astral-sh/ruff/pull/23718">#23718</a>)</li>
<li>Avoid inferring generics with negative intersections (<a
href="https://redirect.github.com/astral-sh/ruff/pull/23750">#23750</a>)</li>
<li>More precise types for <code>name</code> and <code>value</code> of
an enum (<a
href="https://redirect.github.com/astral-sh/ruff/pull/23683">#23683</a>)</li>
<li>Better preserve user-provided union order in inferred
specializations (<a
href="https://redirect.github.com/astral-sh/ruff/pull/23715">#23715</a>)</li>
<li>Support narrowing in ternary expressions (<a
href="https://redirect.github.com/astral-sh/ruff/pull/23726">#23726</a>)</li>
<li>Validate bare ParamSpec usage in type annotations, and support
stringified ParamSpecs as the first argument to <code>Callable</code>
(<a
href="https://redirect.github.com/astral-sh/ruff/pull/23625">#23625</a>)</li>
</ul>
<h3>Contributors</h3>
<ul>
<li><a
href="https://github.com/charliermarsh"><code>@​charliermarsh</code></a></li>
<li><a href="https://github.com/zsol"><code>@​zsol</code></a></li>
<li><a href="https://github.com/Gankra"><code>@​Gankra</code></a></li>
<li><a
href="https://github.com/MichaReiser"><code>@​MichaReiser</code></a></li>
<li><a
href="https://github.com/AlexWaygood"><code>@​AlexWaygood</code></a></li>
<li><a href="https://github.com/rizzip"><code>@​rizzip</code></a></li>
<li><a
href="https://github.com/iksuddle"><code>@​iksuddle</code></a></li>
<li><a
href="https://github.com/dcreager"><code>@​dcreager</code></a></li>
<li><a
href="https://github.com/BurntSushi"><code>@​BurntSushi</code></a></li>
<li><a href="https://github.com/carljm"><code>@​carljm</code></a></li>
<li><a
href="https://github.com/oconnor663"><code>@​oconnor663</code></a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/astral-sh/ty/commit/c1ad9f28136df1678348359dcb859117d5a21b37"><code>c1ad9f2</code></a>
Bump version to 0.0.21 (<a
href="https://redirect.github.com/astral-sh/ty/issues/2973">#2973</a>)</li>
<li><a
href="https://github.com/astral-sh/ty/commit/065338a1fcc24ac5b974724dbb5d8a3ab740c5b7"><code>065338a</code></a>
Add typeshed diff link to changelog during release (<a
href="https://redirect.github.com/astral-sh/ty/issues/2957">#2957</a>)</li>
<li><a
href="https://github.com/astral-sh/ty/commit/fba59f6af85e9f49d58bcc24601b915a4c9ebc65"><code>fba59f6</code></a>
Fix renovate <code>actions/*-artifact</code> updates (<a
href="https://redirect.github.com/astral-sh/ty/issues/2956">#2956</a>)</li>
<li><a
href="https://github.com/astral-sh/ty/commit/bad4a9771fcbe172411e18813926d182ac80ba4f"><code>bad4a97</code></a>
Update actions/download-artifact digest to 70fc10c (<a
href="https://redirect.github.com/astral-sh/ty/issues/2933">#2933</a>)</li>
<li><a
href="https://github.com/astral-sh/ty/commit/04a5216ada0638640ce9c0e57986ee9ce2492c45"><code>04a5216</code></a>
Update actions/upload-artifact digest to bbbca2d (<a
href="https://redirect.github.com/astral-sh/ty/issues/2934">#2934</a>)</li>
<li><a
href="https://github.com/astral-sh/ty/commit/c8bc09cda6604613320eae9b4fb184b1fec7273d"><code>c8bc09c</code></a>
Hook up publish job to release pipeline (<a
href="https://redirect.github.com/astral-sh/ty/issues/2953">#2953</a>)</li>
<li><a
href="https://github.com/astral-sh/ty/commit/2bd0252435a1ad19b91863f37beab60bb8e68a14"><code>2bd0252</code></a>
Bump version to 0.0.20 (<a
href="https://redirect.github.com/astral-sh/ty/issues/2939">#2939</a>)</li>
<li><a
href="https://github.com/astral-sh/ty/commit/08f4e9f152431bbacd956a62c5145255d0241a25"><code>08f4e9f</code></a>
Publish releases to Astral mirror (<a
href="https://redirect.github.com/astral-sh/ty/issues/2921">#2921</a>)</li>
<li><a
href="https://github.com/astral-sh/ty/commit/983105f032ab355347a66a107e53f3fe75b6a616"><code>983105f</code></a>
Update prek dependencies (<a
href="https://redirect.github.com/astral-sh/ty/issues/2936">#2936</a>)</li>
<li><a
href="https://github.com/astral-sh/ty/commit/ece21b6c8eeab4bc889ed88ea0f86cbc504757ff"><code>ece21b6</code></a>
Update astral-sh/setup-uv action to v7.3.1 (<a
href="https://redirect.github.com/astral-sh/ty/issues/2935">#2935</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/astral-sh/ty/compare/0.0.17...0.0.21">compare
view</a></li>
</ul>
</details>
<br />


Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions


</details>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: John Kennedy <65985482+jkennedyvz@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 21:42:29 -04:00
Sydney RunkleandGitHub 9c2deacb28 fix: replay behavior for parent + subgraphs! (#7038)
## Summary

Fix time travel (replay and fork) for graphs with interrupts and
subgraphs.

## Problem

Two issues with replaying/forking from earlier checkpoints:

1. **Stale interrupt values during replay** — Replays incorrectly reused
cached `RESUME` values from prior `interrupt()` calls, so interrupts
silently returned stale answers instead of re-firing.

2. **Wrong subgraph state during time travel** — Subgraphs always loaded
their **latest** checkpoint instead of the one corresponding to the
parent's historical state. This caused subgraphs to skip execution or
produce incorrect results during replay/fork.

## Changes

Code changes span `libs/langgraph/langgraph/pregel/_loop.py`,
`libs/langgraph/langgraph/_internal/_constants.py`, and a new
`libs/langgraph/langgraph/_internal/_replay.py` module:

- **Strip stale `RESUME` writes on replay** — During replays, cached
`RESUME` writes are filtered out so `interrupt()` re-fires instead of
returning old values. Genuine resumes (`Command(resume=...)`) preserve
these writes.

- **Rename `skip_done_tasks` → `is_replaying`** — Clearer naming for the
flag that tracks whether the current run is replaying from a specific
checkpoint.

- **New `ReplayState` class (`_replay.py`)** — Encapsulates subgraph
checkpoint loading during time-travel. Tracks a parent checkpoint ID
upper bound and which subgraph namespaces have already loaded their
pre-replay checkpoint. On the first visit to a subgraph namespace, it
loads the latest checkpoint created *before* the replay point (via
`checkpointer.list(..., before=...)` with `limit=1`). On subsequent
visits (e.g. the same subgraph in a later loop iteration), it falls back
to normal latest-checkpoint loading. The task-id suffix is stripped from
namespaces so the same logical subgraph is recognized across loop
iterations.

- **New `CONFIG_KEY_REPLAY_STATE` config key** — The parent graph
creates a `ReplayState` instance and passes it to subgraphs via config.
For forks (`source=update`), the replay state uses the fork's parent
checkpoint ID since the fork was created after the subgraph's original
checkpoints. The single `ReplayState` instance is shared by reference
across all derived configs within one parent execution.

- **Subgraph checkpoint loading in `__enter__`/`__aenter__`** — When a
subgraph detects a `ReplayState` in its config, it delegates checkpoint
loading to `ReplayState.get_checkpoint`/`aget_checkpoint` instead of
using the default `get_tuple`. It also clears `CONFIG_KEY_RESUMING` so
`_first` re-applies input and recreates ephemeral routing channels.

## Tests

New test files `test_time_travel.py` (~2500 lines) and
`test_time_travel_async.py` (~2200 lines) covering:
- Replay and fork with interrupts (single and multiple)
- Replay and fork for graphs with and without subgraphs
- Correct subgraph checkpoint restoration during parent time travel
- `get_state` with subgraph state during replay
2026-03-09 21:21:26 -04:00
37 changed files with 8499 additions and 273 deletions
+529 -124
View File
@@ -13,6 +13,7 @@ import tempfile
import time
from collections.abc import Callable, Sequence
from contextlib import contextmanager
from datetime import datetime, timezone
import click
import click.exceptions
@@ -26,10 +27,11 @@ from langgraph_cli.config import Config
from langgraph_cli.constants import DEFAULT_CONFIG, DEFAULT_PORT
from langgraph_cli.docker import DockerCapabilities
from langgraph_cli.exec import Runner, subp_exec
from langgraph_cli.helpers import format_log_entry, level_fg, resolve_deployment_id
from langgraph_cli.host_backend import HostBackendClient, HostBackendError
from langgraph_cli.progress import Progress
from langgraph_cli.templates import TEMPLATE_HELP_STRING, create_new
from langgraph_cli.util import warn_non_wolfi_distro
from langgraph_cli.util import format_deployments_table, warn_non_wolfi_distro
from langgraph_cli.version import __version__
RESERVED_ENV_VARS = frozenset(
@@ -287,8 +289,102 @@ OPT_API_VERSION = click.option(
help="API server version to use for the base image. If unspecified, the latest version will be used.",
)
OPT_HOST_API_KEY = click.option(
"--api-key",
envvar="LANGGRAPH_HOST_API_KEY",
help=(
"API key. Can also be set via LANGGRAPH_HOST_API_KEY, "
"LANGSMITH_API_KEY, or LANGCHAIN_API_KEY environment variable or .env file."
),
)
@click.group()
OPT_HOST_DEPLOYMENT_NAME = click.option(
"--name",
envvar=_DEPLOYMENT_NAME_ENV,
help=(
"Deployment name. Can also be set via LANGSMITH_DEPLOYMENT_NAME "
"environment variable or .env file. Defaults to current directory name "
"if --deployment-id is not provided."
),
)
OPT_HOST_URL = click.option(
"--host-url",
envvar="LANGGRAPH_HOST_URL",
default="https://api.host.langchain.com",
hidden=True,
)
OPT_ENGINE_RUNTIME_MODE = click.option(
"--engine-runtime-mode",
type=click.Choice(["combined_queue_worker", "distributed"]),
default="combined_queue_worker",
help="Runtime mode. 'distributed' uses separate executor and orchestrator containers.",
)
class NestedHelpGroup(click.Group):
"""Click group that shows one level of nested subcommands in top-level help."""
def format_commands(
self, ctx: click.Context, formatter: click.HelpFormatter
) -> None:
command_entries: list[tuple[str, click.Command]] = []
# Collect the top-level commands first, then append one level of nested
# subcommands using names like "deploy list" so they show up in the
# top-level help output.
for command_name in self.list_commands(ctx):
command = self.get_command(ctx, command_name)
if command is None or command.hidden:
continue
command_entries.append((command_name, command))
if isinstance(command, click.Group):
# Build a child context so Click resolves the subcommands the same
# way it would for the nested group itself.
sub_ctx = click.Context(command, info_name=command_name, parent=ctx)
for subcommand_name in command.list_commands(sub_ctx):
subcommand = command.get_command(sub_ctx, subcommand_name)
if subcommand is None or subcommand.hidden:
continue
command_entries.append(
(f"{command_name} {subcommand_name}", subcommand)
)
# Compute the available width for help text up front so we can truncate
# descriptions before handing them to Click. That keeps each command on
# a single line instead of allowing wrapped descriptions.
command_width = max((len(name) for name, _ in command_entries), default=0)
help_width = max(formatter.width - command_width - 6, 10)
rows = [
(name, command.get_short_help_str(help_width))
for name, command in command_entries
]
if rows:
# Render the flattened command list using Click's standard
# definition-list formatter so alignment stays consistent with the
# rest of the CLI help output.
with formatter.section("Commands"):
formatter.write_dl(rows)
class DeployGroup(NestedHelpGroup):
"""Group that treats leading '-' args as passthrough docker flags."""
def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]:
result = super().parse_args(ctx, args)
if ctx._protected_args and ctx._protected_args[0].startswith("-"):
# Click stores the would-be subcommand in _protected_args; if it looks
# like an option (e.g. --build-arg) treat it as passthrough docker
# args instead of insisting on a nested command.
ctx.args = [*ctx._protected_args, *ctx.args]
ctx._protected_args = []
return ctx.args
return result
@click.group(cls=NestedHelpGroup)
@click.version_option(version=__version__, prog_name="LangGraph CLI")
def cli():
pass
@@ -305,6 +401,7 @@ def cli():
@OPT_WATCH
@OPT_POSTGRES_URI
@OPT_API_VERSION
@OPT_ENGINE_RUNTIME_MODE
@click.option(
"--image",
type=str,
@@ -339,6 +436,7 @@ def up(
debugger_base_url: str | None,
postgres_uri: str | None,
api_version: str | None,
engine_runtime_mode: str,
image: str | None,
base_image: str | None,
):
@@ -362,6 +460,7 @@ For production use, requires a license key in env var LANGGRAPH_CLOUD_LICENSE_KE
debugger_base_url=debugger_base_url,
postgres_uri=postgres_uri,
api_version=api_version,
engine_runtime_mode=engine_runtime_mode,
image=image,
base_image=base_image,
)
@@ -518,6 +617,7 @@ def _build(
"\n --base-image langchain/langgraph-server:0.2 # Pin to a minor version (Python)",
)
@OPT_API_VERSION
@OPT_ENGINE_RUNTIME_MODE
@click.option(
"--install-command",
help="Custom install command to run from the build context root. If not provided, auto-detects based on package manager files.",
@@ -539,6 +639,7 @@ def build(
docker_build_args: Sequence[str],
base_image: str | None,
api_version: str | None,
engine_runtime_mode: str,
pull: bool,
tag: str,
install_command: str | None,
@@ -561,12 +662,17 @@ def build(
raise click.UsageError("Docker not installed") from None
config_json = langgraph_cli.config.validate_config_file(config)
warn_non_wolfi_distro(config_json)
effective_base_image = base_image
if engine_runtime_mode == "distributed" and not base_image:
effective_base_image = langgraph_cli.config.default_base_image(
config_json, engine_runtime_mode=engine_runtime_mode
)
_build(
runner,
set,
config,
config_json,
base_image,
effective_base_image,
api_version,
pull,
tag,
@@ -576,84 +682,109 @@ def build(
)
@click.option(
"--api-key",
envvar="LANGGRAPH_HOST_API_KEY",
help=(
"API key. Can also be set via LANGGRAPH_HOST_API_KEY, "
"LANGSMITH_API_KEY, or LANGCHAIN_API_KEY environment variable or .env file."
),
)
@click.option(
"--name",
envvar="LANGSMITH_DEPLOYMENT_NAME",
help=(
"Deployment name. Can also be set via LANGSMITH_DEPLOYMENT_NAME "
"environment variable or .env file. Defaults to current directory name "
"if --deployment-id is not provided."
),
)
@click.option(
"--deployment-id",
help=(
"ID of an existing deployment to update. If omitted, "
"--name is used to find or create the deployment."
),
)
@click.option(
"--deployment-type",
type=click.Choice(["dev", "prod"]),
default="dev",
show_default=True,
help="Deployment type (used when creating a new deployment).",
)
@click.option(
"--no-wait",
is_flag=True,
default=False,
help="Skip waiting for deployment status.",
)
@OPT_VERBOSE
@click.option(
"--host-url",
envvar="LANGGRAPH_HOST_URL",
default="https://api.host.langchain.com",
hidden=True,
)
@click.option("--image-name", hidden=True)
@click.option("--image-tag", default="latest", hidden=True)
@click.option(
"--config",
"-c",
default=DEFAULT_CONFIG,
hidden=True,
type=click.Path(
exists=True,
file_okay=True,
dir_okay=False,
resolve_path=True,
path_type=pathlib.Path,
),
)
@click.option("--pull/--no-pull", default=True, hidden=True)
@click.option("--base-image", hidden=True)
@click.option("--install-command", hidden=True)
@click.option("--build-command", hidden=True)
@click.option("--api-version", type=str, hidden=True)
@click.argument("docker_build_args", nargs=-1, type=click.UNPROCESSED)
@cli.command(
def _deploy_base_options(
func: Callable | None = None,
*,
include_docker_args: bool = True,
validate_config_path: bool = True,
):
"""Apply shared deploy flags.
The group shares most options but should not consume subcommands, so the
docker build args are only attached when requested.
"""
def _apply(target: Callable) -> Callable:
decorators = [
OPT_HOST_API_KEY,
OPT_HOST_DEPLOYMENT_NAME,
click.option(
"--deployment-id",
help=(
"ID of an existing deployment to update. If omitted, "
"--name is used to find or create the deployment."
),
),
click.option(
"--deployment-type",
type=click.Choice(["dev", "prod"]),
default="dev",
show_default=True,
help="Deployment type (used when creating a new deployment).",
),
click.option(
"--no-wait",
is_flag=True,
default=False,
help="Skip waiting for deployment status.",
),
OPT_VERBOSE,
OPT_HOST_URL,
click.option("--image-name", hidden=True),
click.option("--image-tag", default="latest", hidden=True),
click.option(
"--config",
"-c",
default=DEFAULT_CONFIG,
hidden=True,
type=click.Path(
exists=validate_config_path,
file_okay=True,
dir_okay=False,
resolve_path=True,
path_type=pathlib.Path,
),
),
click.option("--pull/--no-pull", default=True, hidden=True),
click.option("--base-image", hidden=True),
click.option("--install-command", hidden=True),
click.option("--build-command", hidden=True),
click.option("--api-version", type=str, hidden=True),
]
if include_docker_args:
# Only attach build args to the default command; on the group they
# would capture subcommand names like `list` before Click resolves
# them, making those subcommands unreachable.
decorators.append(
click.argument("docker_build_args", nargs=-1, type=click.UNPROCESSED)
)
for decorator in reversed(decorators):
target = decorator(target)
return target
return _apply(func) if func is not None else _apply
@cli.group(
cls=DeployGroup,
help=(
"[Beta] Build and deploy a LangGraph image to LangSmith Deployments.\n\n"
"This command is in beta and under active development. "
"Expect frequent updates and improvements.\n\n"
"Run from the root of your LangGraph project (where langgraph.json "
"is located). This command also accepts build flags (--base-image, "
"--pull, etc.). See 'langgraph build --help' for details."
"--config, --pull, etc.). See 'langgraph build --help' for details."
),
context_settings=dict(ignore_unknown_options=True),
context_settings=dict(ignore_unknown_options=True, allow_extra_args=True),
invoke_without_command=True, # allow `deploy` click group to execute without command
)
@_deploy_base_options(include_docker_args=False, validate_config_path=False)
@click.pass_context
@log_command
def deploy(
def deploy(ctx: click.Context, **_: object):
# We register deploy as both a group and a command here.
# if we detect no subcommand, we run _deploy (basically run langgraph deploy as a top level command)
# otherwise, we return None here and click will proceed to actually run the subcommand (list or delete)
if ctx.invoked_subcommand is not None:
return
docker_build_args = tuple(ctx.args)
ctx.args = [] # Prevent Click from re-processing passthrough args later.
return ctx.forward(_deploy, docker_build_args=docker_build_args)
@_deploy_base_options()
@click.command(context_settings=dict(ignore_unknown_options=True))
def _deploy(
config: pathlib.Path,
pull: bool,
verbose: bool,
@@ -681,15 +812,6 @@ def deploy(
env_vars = _parse_env_from_config(config_json, config)
if not api_key:
for key_name in _API_KEY_ENV_NAMES:
val = env_vars.get(key_name) or os.environ.get(key_name)
if val:
api_key = val
break
if not api_key:
api_key = click.prompt("Host API key", hide_input=True)
if not deployment_id and not name:
name = env_vars.get(_DEPLOYMENT_NAME_ENV)
if not deployment_id and not name:
@@ -726,55 +848,21 @@ def deploy(
def log_step(message: str) -> None:
click.secho(message, fg="cyan")
client = HostBackendClient(host_url, api_key)
client = _create_host_backend_client(host_url, api_key, env_vars=env_vars)
step = 1
needs_creation = False
if deployment_id:
log_step(f"{step}. Using deployment {deployment_id}")
try:
client.get_deployment(deployment_id)
except HostBackendError as err:
if (
err.status_code == 403
and "requires workspace specification" in err.message
):
click.secho(
"Your API key is org-scoped and requires a workspace ID.",
fg="yellow",
)
click.secho(
"Find your workspace ID in LangSmith under Settings > Workspaces.",
fg="yellow",
)
tenant_id = click.prompt("Workspace ID")
client = HostBackendClient(host_url, api_key, tenant_id=tenant_id)
client.get_deployment(deployment_id)
else:
raise
_call_host_backend_with_optional_tenant(
client, lambda c: c.get_deployment(deployment_id)
)
step += 1
else:
log_step(f"{step}. Looking up deployment '{name}'")
try:
existing = client.list_deployments(name_contains=name)
except HostBackendError as err:
if (
err.status_code == 403
and "requires workspace specification" in err.message
):
click.secho(
"Your API key is org-scoped and requires a workspace ID.",
fg="yellow",
)
click.secho(
"Find your workspace ID in LangSmith under Settings > Workspaces.",
fg="yellow",
)
tenant_id = click.prompt("Workspace ID")
client = HostBackendClient(host_url, api_key, tenant_id=tenant_id)
existing = client.list_deployments(name_contains=name)
else:
raise
existing = _call_host_backend_with_optional_tenant(
client, lambda c: c.list_deployments(name_contains=name)
)
found_id = None
if isinstance(existing, dict):
for dep in existing.get("resources", []):
@@ -1033,6 +1121,122 @@ def deploy(
)
def _create_host_backend_client(
host_url: str | None,
api_key: str | None,
env_vars: dict[str, str] | None = None,
) -> HostBackendClient:
if env_vars is None:
env_vars = _parse_env_from_config({}, pathlib.Path.cwd() / DEFAULT_CONFIG)
resolved_api_key = api_key
if not resolved_api_key:
for key_name in _API_KEY_ENV_NAMES:
val = env_vars.get(key_name)
if val:
resolved_api_key = val
break
val = os.environ.get(key_name)
if val:
resolved_api_key = val
break
if not resolved_api_key:
click.secho(
"No LangSmith API key found. Create one at Settings > API Keys in LangSmith.",
fg="yellow",
)
resolved_api_key = click.prompt("Enter LangSmith API key", hide_input=True)
return HostBackendClient(host_url, resolved_api_key)
def _call_host_backend_with_optional_tenant(
client: HostBackendClient,
operation: Callable[[HostBackendClient], object],
) -> object:
"""Run *operation*, prompting for a workspace ID on org-scoped 403s.
On success the original *client* is returned as-is. If the user is
prompted for a workspace ID, the tenant header is set on *client*
in-place so all subsequent calls through the same instance are
tenant-aware.
"""
try:
return operation(client)
except HostBackendError as err:
if err.status_code == 403 and "requires workspace specification" in err.message:
click.secho(
"Your API key is org-scoped and requires a workspace ID.",
fg="yellow",
)
click.secho(
"Find your workspace ID in LangSmith under Settings > Workspaces.",
fg="yellow",
)
tenant_id = click.prompt("Workspace ID")
client._client.headers["X-Tenant-ID"] = tenant_id
return operation(client)
raise
@OPT_HOST_API_KEY
@OPT_HOST_URL
@click.option(
"--name-contains",
default="",
help="Only show deployments whose names contain this value.",
)
@deploy.command("list", help="[Beta] List LangSmith Deployments.")
def deploy_list(api_key: str | None, host_url: str | None, name_contains: str) -> None:
client = _create_host_backend_client(host_url, api_key)
response = _call_host_backend_with_optional_tenant(
client,
lambda c: c.list_deployments(name_contains=name_contains),
)
resources = response.get("resources", []) if isinstance(response, dict) else []
deployments = [item for item in resources if isinstance(item, dict)]
if not deployments:
click.echo("No deployments found.")
return
click.echo(format_deployments_table(deployments))
@OPT_HOST_API_KEY
@OPT_HOST_URL
@click.option(
"--force",
is_flag=True,
default=False,
help="Delete without prompting for confirmation.",
)
@click.argument("deployment_id")
@deploy.command(
"delete",
help=(
"[Beta] Delete a LangSmith Deployment.\n\n"
"Use the `deploy list` command to list deployment IDs."
),
)
def deploy_delete(
api_key: str | None, host_url: str | None, force: bool, deployment_id: str
) -> None:
if not force:
response = click.prompt(
click.style(
f"Are you sure you want to delete deployment ID {deployment_id}? (Y/n)",
fg="yellow",
),
default="Y",
show_default=False,
)
if response.strip().lower() not in {"y", "yes"}:
raise click.Abort()
client = _create_host_backend_client(host_url, api_key)
_call_host_backend_with_optional_tenant(
client,
lambda c: c.delete_deployment(deployment_id),
)
click.secho(f"Deleted deployment {deployment_id}.", fg="green")
def _normalize_image_name(value: str | None) -> str:
"""Sanitize a deployment/directory name into a valid Docker repository name.
@@ -1059,6 +1263,180 @@ def _normalize_image_tag(value: str) -> str:
return value
@OPT_HOST_API_KEY
@OPT_HOST_DEPLOYMENT_NAME
@click.option(
"--deployment-id",
help="Deployment ID. If omitted, --name is used to find the deployment.",
)
@click.option(
"--type",
"log_type",
type=click.Choice(["deploy", "build"]),
default="deploy",
show_default=True,
help=(
"Log stream to fetch: 'deploy' shows agent server runtime logs; "
"'build' shows build logs (for deployments built remotely)."
),
)
@click.option(
"--revision-id",
help="Specific revision ID. For build logs, defaults to latest revision.",
)
@click.option(
"--level",
type=click.Choice(
["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], case_sensitive=False
),
help="Filter by log level.",
)
@click.option(
"--limit",
type=int,
default=100,
show_default=True,
help="Max log entries to fetch.",
)
@click.option(
"--query",
"-q",
help="Search string filter.",
)
@click.option(
"--start-time",
help="ISO8601 start time (e.g. 2026-03-08T00:00:00Z).",
)
@click.option(
"--end-time",
help="ISO8601 end time. (e.g. 2026-03-08T00:00:00Z)",
)
@click.option(
"--follow",
"-f",
is_flag=True,
default=False,
help="Continuously poll for new logs.",
)
@OPT_HOST_URL
@deploy.command(
"logs",
help=(
"[Beta] Fetch LangSmith Deployment logs. Use 'deploy' for agent runtime "
"logs, or 'build' for remote build logs."
),
)
@log_command
def deploy_logs(
api_key: str | None,
name: str | None,
deployment_id: str | None,
log_type: str,
revision_id: str | None,
level: str | None,
limit: int,
query: str | None,
start_time: str | None,
end_time: str | None,
follow: bool,
host_url: str,
):
env_vars = _parse_env_from_config({}, pathlib.Path.cwd() / DEFAULT_CONFIG)
client = _create_host_backend_client(host_url, api_key, env_vars=env_vars)
if not deployment_id and not name:
name = env_vars.get(_DEPLOYMENT_NAME_ENV)
dep_id = _call_host_backend_with_optional_tenant(
client, lambda c: resolve_deployment_id(c, deployment_id, name)
)
if log_type == "build" and not revision_id:
revisions_resp = client.list_revisions(dep_id, limit=1)
resources = (
revisions_resp.get("resources", [])
if isinstance(revisions_resp, dict)
else []
)
if not resources:
raise click.ClickException(
"No revisions found for this deployment. Cannot fetch build logs."
)
revision_id = str(resources[0]["id"])
click.secho(f"Using latest revision: {revision_id}", fg="cyan")
payload: dict = {"limit": limit, "order": "desc"}
if level:
payload["level"] = level.upper()
if query:
payload["query"] = query
if start_time:
payload["start_time"] = start_time
if end_time:
payload["end_time"] = end_time
def _fetch(request_payload: dict) -> list[dict]:
if log_type == "build":
resp = client.get_build_logs(dep_id, revision_id, request_payload)
else:
resp = client.get_deploy_logs(dep_id, request_payload, revision_id)
if isinstance(resp, dict):
return resp.get("logs", [])
return []
def _print_entries(entries: list[dict], *, reverse: bool = False) -> None:
iterable = reversed(entries) if reverse else entries
for entry in iterable:
line = format_log_entry(entry)
fg = level_fg(entry.get("level", ""))
click.secho(line, fg=fg)
def _fetch_and_print(request_payload: dict, *, reverse: bool = False) -> list[dict]:
entries = _fetch(request_payload)
_print_entries(entries, reverse=reverse)
return entries
def _fetch_and_print_new(request_payload: dict, seen_ids: set[str]) -> list[dict]:
entries = _fetch(request_payload)
new = [e for e in entries if e.get("id", "") not in seen_ids]
if new:
_print_entries(new)
seen_ids.update(e.get("id", "") for e in new)
return new
# initial log fetch will be newest -> oldest, so we need to reverse
entries = _fetch_and_print(payload, reverse=True)
if not follow:
if not entries:
click.secho("No log entries found.", fg="yellow")
return
payload["order"] = "asc"
seen_ids: set[str] = {e.get("id", "") for e in entries if e.get("id")}
def _update_start_time(ts) -> None:
if ts is None:
return
if isinstance(ts, (int, float)):
dt = datetime.fromtimestamp(ts / 1000, tz=timezone.utc)
payload["start_time"] = dt.isoformat()
else:
payload["start_time"] = str(ts)
if entries:
# entries are in descending order here, so index 0 is the newest log
_update_start_time(entries[0].get("timestamp"))
try:
while True:
time.sleep(2)
new_entries = _fetch_and_print_new(payload, seen_ids)
if new_entries:
_update_start_time(new_entries[-1].get("timestamp"))
except KeyboardInterrupt:
click.echo("\nStopped.")
def _get_docker_ignore_content() -> str:
"""Return the content of a .dockerignore file.
@@ -1136,6 +1514,7 @@ tests
"\n --base-image langchain/langgraph-server:0.2 # Pin to a minor version (Python)",
)
@OPT_API_VERSION
@OPT_ENGINE_RUNTIME_MODE
@log_command
def dockerfile(
save_path: str,
@@ -1143,6 +1522,7 @@ def dockerfile(
add_docker_compose: bool,
base_image: str | None = None,
api_version: str | None = None,
engine_runtime_mode: str = "combined_queue_worker",
) -> None:
save_path = pathlib.Path(save_path).absolute()
secho(f"🔍 Validating configuration at path: {config}", fg="yellow")
@@ -1150,11 +1530,17 @@ def dockerfile(
warn_non_wolfi_distro(config_json)
secho("✅ Configuration validated!", fg="green")
effective_base_image = base_image
if engine_runtime_mode == "distributed" and not base_image:
effective_base_image = langgraph_cli.config.default_base_image(
config_json, engine_runtime_mode=engine_runtime_mode
)
secho(f"📝 Generating Dockerfile at {save_path}", fg="yellow")
dockerfile, additional_contexts = langgraph_cli.config.config_to_docker(
config_path=config,
config=config_json,
base_image=base_image,
base_image=effective_base_image,
api_version=api_version,
)
with open(str(save_path), "w", encoding="utf-8") as f:
@@ -1425,6 +1811,7 @@ def prepare_args_and_stdin(
debugger_base_url: str | None = None,
postgres_uri: str | None = None,
api_version: str | None = None,
engine_runtime_mode: str = "combined_queue_worker",
# Like "my-tag" (if you already built it locally)
image: str | None = None,
# Like "langchain/langgraphjs-api" or "langchain/langgraph-api
@@ -1438,9 +1825,10 @@ def prepare_args_and_stdin(
debugger_port=debugger_port,
debugger_base_url=debugger_base_url,
postgres_uri=postgres_uri,
image=image, # Pass image to compose YAML generator
image=image,
base_image=base_image,
api_version=api_version,
engine_runtime_mode=engine_runtime_mode,
)
args = [
"--project-directory",
@@ -1458,6 +1846,7 @@ def prepare_args_and_stdin(
base_image=langgraph_cli.config.default_base_image(config),
api_version=api_version,
image=image,
engine_runtime_mode=engine_runtime_mode,
)
return args, stdin
@@ -1476,6 +1865,7 @@ def prepare(
debugger_base_url: str | None = None,
postgres_uri: str | None = None,
api_version: str | None = None,
engine_runtime_mode: str = "combined_queue_worker",
image: str | None = None,
base_image: str | None = None,
) -> tuple[list[str], str]:
@@ -1492,6 +1882,20 @@ def prepare(
verbose=verbose,
)
)
if engine_runtime_mode == "distributed":
executor_base = langgraph_cli.config.default_base_image(
config_json, engine_runtime_mode="distributed"
)
runner.run(
subp_exec(
"docker",
"pull",
langgraph_cli.config.docker_tag(
config_json, executor_base, api_version
),
verbose=verbose,
)
)
args, stdin = prepare_args_and_stdin(
capabilities=capabilities,
@@ -1504,6 +1908,7 @@ def prepare(
debugger_base_url=debugger_base_url or f"http://127.0.0.1:{port}",
postgres_uri=postgres_uri,
api_version=api_version,
engine_runtime_mode=engine_runtime_mode,
image=image,
base_image=base_image,
)
+70 -2
View File
@@ -1,3 +1,4 @@
import copy
import json
import os
import pathlib
@@ -1232,11 +1233,15 @@ def node_config_to_docker(
return os.linesep.join(docker_file_contents), {}
def default_base_image(config: Config) -> str:
def default_base_image(
config: Config, engine_runtime_mode: str = "combined_queue_worker"
) -> str:
if config.get("base_image"):
return config["base_image"]
if config.get("node_version") and not config.get("python_version"):
return "langchain/langgraphjs-api"
if engine_runtime_mode == "distributed":
return "langchain/langgraph-executor"
return "langchain/langgraph-api"
@@ -1329,6 +1334,7 @@ def config_to_compose(
api_version: str | None = None,
image: str | None = None,
watch: bool = False,
engine_runtime_mode: str = "combined_queue_worker",
) -> str:
base_image = base_image or default_base_image(config)
@@ -1362,6 +1368,11 @@ def config_to_compose(
"""
else:
# Save a pristine copy before config_to_docker mutates graph paths
config_snapshot = (
copy.deepcopy(config) if engine_runtime_mode == "distributed" else None
)
dockerfile, additional_contexts = config_to_docker(
config_path=config_path,
config=config,
@@ -1379,7 +1390,7 @@ def config_to_compose(
additional_contexts:
{additional_contexts_str}"""
return f"""
result = f"""
{textwrap.indent(env_vars_str, " ")}
{env_file_str}
pull_policy: build
@@ -1389,3 +1400,60 @@ def config_to_compose(
{textwrap.indent(dockerfile, " ")}
{watch_str}
"""
if engine_runtime_mode == "distributed":
executor_base_image = default_base_image(
config_snapshot, engine_runtime_mode="distributed"
)
executor_dockerfile, executor_additional_contexts = config_to_docker(
config_path=config_path,
config=config_snapshot,
base_image=executor_base_image,
api_version=api_version,
escape_variables=True,
)
executor_additional_contexts_str = "\n".join(
f" - {name}: {path}"
for name, path in executor_additional_contexts.items()
)
if executor_additional_contexts_str:
executor_additional_contexts_str = f"""
additional_contexts:
{executor_additional_contexts_str}"""
postgres_uri = "postgres://postgres:postgres@langgraph-postgres:5432/postgres?sslmode=disable"
result += f""" langgraph-orchestrator:
image: langchain/langgraph-orchestrator-licensed:latest
depends_on:
langgraph-api:
condition: service_healthy
langgraph-postgres:
condition: service_healthy
environment:
DATABASE_URI: {postgres_uri}
EXECUTOR_TARGET: langgraph-executor:8188
{env_file_str}
langgraph-executor:
depends_on:
langgraph-postgres:
condition: service_healthy
langgraph-api:
condition: service_healthy
entrypoint: ["sh", "/storage/executor_entrypoint.sh"]
environment:
DATABASE_URI: {postgres_uri}
REDIS_URI: redis://langgraph-redis:6379
EXECUTOR_GRPC_PORT: "8188"
ENGINE_GRPC_ADDRESS: "langgraph-orchestrator:50054"
LSD_GRPC_SERVER_ADDRESS: "localhost:50050"
LANGGRAPH_HTTP: ""
{env_file_str}
pull_policy: build
build:
context: .{executor_additional_contexts_str}
dockerfile_inline: |
{textwrap.indent(executor_dockerfile, " ")}
"""
return result
+11 -4
View File
@@ -149,6 +149,7 @@ def compose_as_dict(
base_image: str | None = None,
# API version of the base image
api_version: str | None = None,
engine_runtime_mode: str = "combined_queue_worker",
) -> dict:
"""Create a docker compose file as a dictionary in YML style."""
if postgres_uri is None:
@@ -207,15 +208,19 @@ def compose_as_dict(
)["langgraph-debugger"]
# Add langgraph-api service
api_environment = {
"REDIS_URI": "redis://langgraph-redis:6379",
"POSTGRES_URI": postgres_uri,
}
if engine_runtime_mode == "distributed":
api_environment["N_JOBS_PER_WORKER"] = '"0"'
services["langgraph-api"] = {
"ports": [f'"{port}:8000"'],
"depends_on": {
"langgraph-redis": {"condition": "service_healthy"},
},
"environment": {
"REDIS_URI": "redis://langgraph-redis:6379",
"POSTGRES_URI": postgres_uri,
},
"environment": api_environment,
}
if image:
services["langgraph-api"]["image"] = image
@@ -255,6 +260,7 @@ def compose(
image: str | None = None,
base_image: str | None = None,
api_version: str | None = None,
engine_runtime_mode: str = "combined_queue_worker",
) -> str:
"""Create a docker compose file as a string."""
compose_content = compose_as_dict(
@@ -266,6 +272,7 @@ def compose(
image=image,
base_image=base_image,
api_version=api_version,
engine_runtime_mode=engine_runtime_mode,
)
compose_str = dict_to_yaml(compose_content)
return compose_str
+59
View File
@@ -0,0 +1,59 @@
"""Helpers for the ``langgraph logs`` CLI command."""
from __future__ import annotations
from datetime import datetime, timezone
import click
from langgraph_cli.host_backend import HostBackendClient
def resolve_deployment_id(
client: HostBackendClient,
deployment_id: str | None,
name: str | None,
) -> str:
"""Resolve a deployment ID from --deployment-id or --name."""
if deployment_id:
return deployment_id
if not name:
raise click.UsageError("Either --deployment-id or --name is required.")
existing = client.list_deployments(name_contains=name)
if isinstance(existing, dict):
for dep in existing.get("resources", []):
if isinstance(dep, dict) and dep.get("name") == name:
found_id = dep.get("id")
if found_id:
return str(found_id)
raise click.ClickException(f"Deployment '{name}' not found.")
def format_timestamp(ts) -> str:
"""Convert a timestamp (epoch ms or string) to a readable string."""
if isinstance(ts, (int, float)):
dt = datetime.fromtimestamp(ts / 1000, tz=timezone.utc)
return dt.strftime("%Y-%m-%d %H:%M:%S")
return str(ts) if ts else ""
def format_log_entry(entry: dict) -> str:
"""Format a single log entry for display."""
ts = format_timestamp(entry.get("timestamp", ""))
level = entry.get("level", "")
message = entry.get("message", "")
if ts and level:
return f"[{ts}] [{level}] {message}"
elif ts:
return f"[{ts}] {message}"
return message
def level_fg(level: str) -> str | None:
"""Return click color for a log level."""
level_upper = level.upper() if level else ""
if level_upper in {"ERROR", "CRITICAL"}:
return "red"
if level_upper == "WARNING":
return "yellow"
return None
+42 -6
View File
@@ -19,7 +19,12 @@ class HostBackendError(click.ClickException):
class HostBackendClient:
"""Minimal JSON HTTP client for the host backend deployment service."""
def __init__(self, base_url: str, api_key: str, tenant_id: str | None = None):
def __init__(
self,
base_url: str,
api_key: str,
tenant_id: str | None = None,
):
if not base_url:
raise click.UsageError("Host backend URL is required")
transport = httpx.HTTPTransport(retries=3)
@@ -30,7 +35,6 @@ class HostBackendClient:
if tenant_id:
headers["X-Tenant-ID"] = tenant_id
self._base_url = base_url.rstrip("/")
self._api_key = api_key
self._client = httpx.Client(
base_url=self._base_url,
headers=headers,
@@ -39,10 +43,14 @@ class HostBackendClient:
)
def _request(
self, method: str, path: str, payload: dict[str, Any] | None = None
self,
method: str,
path: str,
payload: dict[str, Any] | None = None,
params: dict[str, Any] | None = None,
) -> Any:
try:
resp = self._client.request(method, path, json=payload)
resp = self._client.request(method, path, json=payload, params=params)
resp.raise_for_status()
except httpx.HTTPStatusError as err:
detail = err.response.text or str(err.response.status_code)
@@ -65,12 +73,19 @@ class HostBackendClient:
def create_deployment(self, payload: dict[str, Any]) -> dict[str, Any]:
return self._request("POST", "/v2/deployments", payload)
def list_deployments(self, name_contains: str) -> dict[str, Any]:
return self._request("GET", f"/v2/deployments?name_contains={name_contains}")
def list_deployments(self, name_contains: str = "") -> dict[str, Any]:
return self._request(
"GET",
"/v2/deployments",
params={"name_contains": name_contains},
)
def get_deployment(self, deployment_id: str) -> dict[str, Any]:
return self._request("GET", f"/v2/deployments/{deployment_id}")
def delete_deployment(self, deployment_id: str) -> None:
return self._request("DELETE", f"/v2/deployments/{deployment_id}")
def request_push_token(self, deployment_id: str) -> dict[str, Any]:
return self._request(
"POST",
@@ -105,3 +120,24 @@ class HostBackendClient:
"GET",
f"/v2/deployments/{deployment_id}/revisions/{revision_id}",
)
def get_build_logs(
self, project_id: str, revision_id: str, payload: dict[str, Any]
) -> Any:
return self._request(
"POST",
f"/v1/projects/{project_id}/revisions/{revision_id}/build_logs",
payload,
)
def get_deploy_logs(
self,
project_id: str,
payload: dict[str, Any],
revision_id: str | None = None,
) -> Any:
if revision_id:
path = f"/v1/projects/{project_id}/revisions/{revision_id}/deploy_logs"
else:
path = f"/v1/projects/{project_id}/deploy_logs"
return self._request("POST", path, payload)
+34
View File
@@ -1,3 +1,5 @@
from collections.abc import Sequence
import click
@@ -23,3 +25,35 @@ def warn_non_wolfi_distro(config_json: dict) -> None:
fg="yellow",
)
click.secho("") # Empty line for better readability
def _extract_deployment_url(deployment: dict[str, object]) -> str:
source_config = deployment.get("source_config")
if isinstance(source_config, dict):
custom_url = source_config.get("custom_url")
if isinstance(custom_url, str) and custom_url:
return custom_url
return "-"
def format_deployments_table(deployments: Sequence[dict[str, object]]) -> str:
headers = ("Deployment ID", "Deployment Name", "Deployment URL")
rows = [
(
str(deployment.get("id", "-") or "-"),
str(deployment.get("name", "-") or "-"),
_extract_deployment_url(deployment),
)
for deployment in deployments
]
widths = [
max(len(headers[index]), *(len(row[index]) for row in rows))
for index in range(len(headers))
]
def format_row(row: Sequence[str]) -> str:
return " ".join(value.ljust(widths[index]) for index, value in enumerate(row))
lines = [format_row(headers), format_row(tuple("-" * width for width in widths))]
lines.extend(format_row(row) for row in rows)
return "\n".join(lines)
+369
View File
@@ -9,6 +9,7 @@ from pathlib import Path
from click.testing import CliRunner
import langgraph_cli.cli as cli_module
from langgraph_cli.cli import cli, prepare_args_and_stdin
from langgraph_cli.config import Config, _get_pip_cleanup_lines, validate_config
from langgraph_cli.docker import DEFAULT_POSTGRES_URI, DockerCapabilities, Version
@@ -287,6 +288,238 @@ def test_version_option() -> None:
)
def test_top_level_help_shows_deploy_subcommands() -> None:
runner = CliRunner()
result = runner.invoke(cli, ["--help"])
assert result.exit_code == 0, result.output
assert "deploy" in result.output
assert "deploy list" in result.output
assert "deploy delete" in result.output
assert "[Beta] List LangSmith Deployments." in result.output
def test_top_level_help_truncates_command_descriptions_to_single_line() -> None:
runner = CliRunner()
result = runner.invoke(cli, ["--help"])
assert result.exit_code == 0, result.output
lines = result.output.splitlines()
deploy_line = next(line for line in lines if line.strip().startswith("deploy"))
deploy_list_line = next(
line for line in lines if line.strip().startswith("deploy list")
)
assert not lines[lines.index(deploy_line) + 1].startswith(" ")
assert "..." in deploy_line
assert "[Beta] List LangSmith Deployments." in deploy_list_line
def test_deploy_list_command(monkeypatch) -> None:
runner = CliRunner()
captured: dict[str, str] = {}
class FakeClient:
def __init__(self, host_url: str, api_key: str, tenant_id: str | None = None):
captured["host_url"] = host_url
captured["api_key"] = api_key
captured["tenant_id"] = tenant_id or ""
def list_deployments(self, name_contains: str = ""):
captured["name_contains"] = name_contains
return {
"resources": [
{
"id": "dep-123",
"name": "alpha",
"source_config": {"custom_url": "https://alpha.example.com"},
},
{
"id": "dep-456",
"name": "beta",
"source_config": {"custom_url": "https://beta.example.com"},
},
]
}
monkeypatch.setattr(cli_module, "HostBackendClient", FakeClient)
result = runner.invoke(
cli,
[
"deploy",
"list",
"--api-key",
"test-key",
"--host-url",
"https://api.example.com",
"--name-contains",
"alp",
],
)
assert result.exit_code == 0, result.output
assert captured == {
"host_url": "https://api.example.com",
"api_key": "test-key",
"tenant_id": "",
"name_contains": "alp",
}
assert "Deployment ID" in result.output
assert "Deployment Name" in result.output
assert "Deployment URL" in result.output
assert "dep-123" in result.output
assert "https://beta.example.com" in result.output
def test_deploy_list_command_no_results(monkeypatch) -> None:
runner = CliRunner()
class FakeClient:
def __init__(self, host_url: str, api_key: str, tenant_id: str | None = None):
pass
def list_deployments(self, name_contains: str = ""):
return {"resources": []}
monkeypatch.setattr(cli_module, "HostBackendClient", FakeClient)
result = runner.invoke(
cli,
[
"deploy",
"list",
"--api-key",
"test-key",
"--host-url",
"https://api.example.com",
],
)
assert result.exit_code == 0, result.output
assert result.output.strip() == "No deployments found."
def test_deploy_delete_command(monkeypatch) -> None:
runner = CliRunner()
captured: dict[str, str] = {}
class FakeClient:
def __init__(self, host_url: str, api_key: str, tenant_id: str | None = None):
captured["host_url"] = host_url
captured["api_key"] = api_key
captured["tenant_id"] = tenant_id or ""
def delete_deployment(self, deployment_id: str):
captured["deployment_id"] = deployment_id
return None
monkeypatch.setattr(cli_module, "HostBackendClient", FakeClient)
result = runner.invoke(
cli,
[
"deploy",
"delete",
"--api-key",
"test-key",
"--host-url",
"https://api.example.com",
"dep-123",
],
input="y\n",
)
assert result.exit_code == 0, result.output
assert captured == {
"host_url": "https://api.example.com",
"api_key": "test-key",
"tenant_id": "",
"deployment_id": "dep-123",
}
assert (
"Are you sure you want to delete deployment ID dep-123? (Y/n):" in result.output
)
assert result.output.strip().endswith("Deleted deployment dep-123.")
def test_deploy_delete_command_cancelled(monkeypatch) -> None:
runner = CliRunner()
deleted = False
class FakeClient:
def __init__(self, host_url: str, api_key: str, tenant_id: str | None = None):
pass
def delete_deployment(self, deployment_id: str):
nonlocal deleted
deleted = True
return None
monkeypatch.setattr(cli_module, "HostBackendClient", FakeClient)
result = runner.invoke(
cli,
[
"deploy",
"delete",
"--api-key",
"test-key",
"--host-url",
"https://api.example.com",
"dep-123",
],
input="n\n",
)
assert result.exit_code == 1, result.output
assert not deleted
assert "Aborted!" in result.output
def test_deploy_delete_command_force(monkeypatch) -> None:
runner = CliRunner()
captured: dict[str, str] = {}
class FakeClient:
def __init__(self, host_url: str, api_key: str, tenant_id: str | None = None):
captured["host_url"] = host_url
captured["api_key"] = api_key
captured["tenant_id"] = tenant_id or ""
def delete_deployment(self, deployment_id: str):
captured["deployment_id"] = deployment_id
return None
monkeypatch.setattr(cli_module, "HostBackendClient", FakeClient)
result = runner.invoke(
cli,
[
"deploy",
"delete",
"--force",
"--api-key",
"test-key",
"--host-url",
"https://api.example.com",
"dep-123",
],
)
assert result.exit_code == 0, result.output
assert "Are you sure you want to delete deployment ID dep-123?" not in result.output
assert captured == {
"host_url": "https://api.example.com",
"api_key": "test-key",
"tenant_id": "",
"deployment_id": "dep-123",
}
assert result.output.strip() == "Deleted deployment dep-123."
def test_dockerfile_command_basic() -> None:
"""Test the 'dockerfile' command with basic configuration."""
runner = CliRunner()
@@ -822,3 +1055,139 @@ def test_prepare_args_and_stdin_with_api_version_and_image() -> None:
# When image is provided, api_version should be ignored for the image
# but the stdin should not contain a build section (since image is provided)
assert "pull_policy: build" not in actual_stdin
def test_dockerfile_command_distributed_mode() -> None:
"""Test the 'dockerfile' command with --engine-runtime-mode distributed."""
runner = CliRunner()
config_content = {
"python_version": "3.11",
"graphs": {"agent": "agent.py:graph"},
"dependencies": ["."],
}
with temporary_config_folder(config_content) as temp_dir:
save_path = temp_dir / "Dockerfile"
agent_path = temp_dir / "agent.py"
agent_path.touch()
result = runner.invoke(
cli,
[
"dockerfile",
str(save_path),
"--config",
str(temp_dir / "config.json"),
"--engine-runtime-mode",
"distributed",
],
)
assert result.exit_code == 0, result.output
assert "✅ Created: Dockerfile" in result.output
assert save_path.exists()
with open(save_path) as f:
dockerfile = f.read()
assert "FROM langchain/langgraph-executor:3.11" in dockerfile
def test_dockerfile_command_combined_mode() -> None:
"""Test the 'dockerfile' command with --engine-runtime-mode combined_queue_worker."""
runner = CliRunner()
config_content = {
"python_version": "3.11",
"graphs": {"agent": "agent.py:graph"},
"dependencies": ["."],
}
with temporary_config_folder(config_content) as temp_dir:
save_path = temp_dir / "Dockerfile"
agent_path = temp_dir / "agent.py"
agent_path.touch()
result = runner.invoke(
cli,
[
"dockerfile",
str(save_path),
"--config",
str(temp_dir / "config.json"),
"--engine-runtime-mode",
"combined_queue_worker",
],
)
assert result.exit_code == 0, result.output
assert save_path.exists()
with open(save_path) as f:
dockerfile = f.read()
assert "FROM langchain/langgraph-api:3.11" in dockerfile
def test_dockerfile_command_distributed_with_explicit_base_image() -> None:
"""Test distributed mode with explicit --base-image overrides executor default."""
runner = CliRunner()
config_content = {
"python_version": "3.11",
"graphs": {"agent": "agent.py:graph"},
"dependencies": ["."],
}
with temporary_config_folder(config_content) as temp_dir:
save_path = temp_dir / "Dockerfile"
agent_path = temp_dir / "agent.py"
agent_path.touch()
result = runner.invoke(
cli,
[
"dockerfile",
str(save_path),
"--config",
str(temp_dir / "config.json"),
"--engine-runtime-mode",
"distributed",
"--base-image",
"my-custom-executor:latest",
],
)
assert result.exit_code == 0, result.output
assert save_path.exists()
with open(save_path) as f:
dockerfile = f.read()
assert "FROM my-custom-executor:latest" in dockerfile
def test_prepare_args_and_stdin_distributed_mode() -> None:
"""Test prepare_args_and_stdin with distributed mode includes all services."""
config_path = pathlib.Path(__file__).parent / "langgraph.json"
config = validate_config(
Config(dependencies=["."], graphs={"agent": "agent.py:graph"})
)
port = 8000
actual_args, actual_stdin = prepare_args_and_stdin(
capabilities=DEFAULT_DOCKER_CAPABILITIES,
config_path=config_path,
config=config,
docker_compose=None,
port=port,
watch=False,
engine_runtime_mode="distributed",
)
# API service should use langgraph-api base image
assert "FROM langchain/langgraph-api:" in actual_stdin
# Distributed mode sets N_JOBS_PER_WORKER=0 on the API service
assert 'N_JOBS_PER_WORKER: "0"' in actual_stdin
# Orchestrator service present
assert "langgraph-orchestrator:" in actual_stdin
# Executor service present with correct base image
assert "langgraph-executor:" in actual_stdin
assert "FROM langchain/langgraph-executor:" in actual_stdin
assert "executor_entrypoint.sh" in actual_stdin
+191
View File
@@ -13,6 +13,7 @@ from langgraph_cli.config import (
_get_pip_cleanup_lines,
config_to_compose,
config_to_docker,
default_base_image,
docker_tag,
has_disallowed_build_command_content,
validate_config,
@@ -1695,6 +1696,196 @@ def test_config_to_compose_with_api_version():
assert "FROM langchain/langgraphjs-api:0.2.74-node20" in actual_compose_str
def test_default_base_image_combined_mode():
"""Test default_base_image returns langgraph-api for combined_queue_worker mode."""
config = validate_config(
{
"dependencies": ["."],
"graphs": {"agent": "./agent.py:graph"},
}
)
assert default_base_image(config) == "langchain/langgraph-api"
assert (
default_base_image(config, engine_runtime_mode="combined_queue_worker")
== "langchain/langgraph-api"
)
def test_default_base_image_distributed_mode():
"""Test default_base_image returns langgraph-executor for distributed mode."""
config = validate_config(
{
"dependencies": ["."],
"graphs": {"agent": "./agent.py:graph"},
}
)
assert (
default_base_image(config, engine_runtime_mode="distributed")
== "langchain/langgraph-executor"
)
def test_default_base_image_distributed_with_explicit_base():
"""Test default_base_image returns explicit base_image even in distributed mode."""
config = validate_config(
{
"dependencies": ["."],
"graphs": {"agent": "./agent.py:graph"},
"base_image": "my-custom-image:latest",
}
)
assert (
default_base_image(config, engine_runtime_mode="distributed")
== "my-custom-image:latest"
)
def test_default_base_image_nodejs():
"""Test default_base_image returns langgraphjs-api for Node.js config."""
config = validate_config(
{
"node_version": "20",
"graphs": {"agent": "./agent.js:graph"},
}
)
assert default_base_image(config) == "langchain/langgraphjs-api"
def test_config_to_docker_executor_base_image():
"""Test config_to_docker with executor base image for distributed mode."""
graphs = {"agent": "./agent.py:graph"}
config = validate_config({"dependencies": ["."], "graphs": graphs})
actual_docker_stdin, _ = config_to_docker(
PATH_TO_CONFIG,
config,
base_image="langchain/langgraph-executor",
)
assert "FROM langchain/langgraph-executor:3.11" in actual_docker_stdin
assert "LANGSERVE_GRAPHS=" in actual_docker_stdin
def test_config_to_compose_distributed_mode():
"""Test config_to_compose with engine_runtime_mode='distributed'."""
graphs = {"agent": "./agent.py:graph"}
actual_compose_stdin = config_to_compose(
PATH_TO_CONFIG,
validate_config({"dependencies": ["."], "graphs": graphs}),
"langchain/langgraph-api",
engine_runtime_mode="distributed",
)
# API service uses langchain/langgraph-api base image
assert "FROM langchain/langgraph-api:3.11" in actual_compose_stdin
# Orchestrator service is present
assert "langgraph-orchestrator:" in actual_compose_stdin
assert "EXECUTOR_TARGET: langgraph-executor:8188" in actual_compose_stdin
# Executor service is present with correct base image
assert "langgraph-executor:" in actual_compose_stdin
assert "FROM langchain/langgraph-executor:3.11" in actual_compose_stdin
assert (
'entrypoint: ["sh", "/storage/executor_entrypoint.sh"]' in actual_compose_stdin
)
# Executor has required environment variables
assert "EXECUTOR_GRPC_PORT:" in actual_compose_stdin
assert "ENGINE_GRPC_ADDRESS:" in actual_compose_stdin
assert "LSD_GRPC_SERVER_ADDRESS:" in actual_compose_stdin
assert 'LANGGRAPH_HTTP: ""' in actual_compose_stdin
assert "REDIS_URI: redis://langgraph-redis:6379" in actual_compose_stdin
def test_config_to_compose_distributed_mode_with_env_file():
"""Test config_to_compose distributed mode propagates env_file to all services."""
graphs = {"agent": "./agent.py:graph"}
actual_compose_stdin = config_to_compose(
PATH_TO_CONFIG,
validate_config({"dependencies": ["."], "graphs": graphs, "env": ".env"}),
"langchain/langgraph-api",
engine_runtime_mode="distributed",
)
# env_file should appear multiple times: API, orchestrator, executor
env_file_count = actual_compose_stdin.count("env_file: .env")
assert env_file_count == 3, (
f"Expected env_file to appear 3 times (api, orchestrator, executor), "
f"got {env_file_count}"
)
def test_config_to_compose_distributed_mode_generates_two_dockerfiles():
"""Test that distributed mode generates separate Dockerfiles for API and executor."""
graphs = {"agent": "./agent.py:graph"}
actual_compose_stdin = config_to_compose(
PATH_TO_CONFIG,
validate_config({"dependencies": ["."], "graphs": graphs}),
"langchain/langgraph-api",
engine_runtime_mode="distributed",
)
# Should contain two different FROM lines
from_lines = [
line.strip()
for line in actual_compose_stdin.splitlines()
if line.strip().startswith("FROM ")
]
assert len(from_lines) == 2
assert "FROM langchain/langgraph-api:3.11" in from_lines[0]
assert "FROM langchain/langgraph-executor:3.11" in from_lines[1]
def test_config_to_compose_combined_mode_no_orchestrator():
"""Test that combined_queue_worker mode does NOT generate orchestrator/executor."""
graphs = {"agent": "./agent.py:graph"}
actual_compose_stdin = config_to_compose(
PATH_TO_CONFIG,
validate_config({"dependencies": ["."], "graphs": graphs}),
"langchain/langgraph-api",
engine_runtime_mode="combined_queue_worker",
)
assert "langgraph-orchestrator:" not in actual_compose_stdin
assert "langgraph-executor:" not in actual_compose_stdin
def test_config_to_compose_default_mode_no_orchestrator():
"""Test that default mode (no engine_runtime_mode) has no orchestrator/executor."""
graphs = {"agent": "./agent.py:graph"}
actual_compose_stdin = config_to_compose(
PATH_TO_CONFIG,
validate_config({"dependencies": ["."], "graphs": graphs}),
"langchain/langgraph-api",
)
assert "langgraph-orchestrator:" not in actual_compose_stdin
assert "langgraph-executor:" not in actual_compose_stdin
def test_config_to_compose_distributed_executor_gets_correct_paths():
"""Test that executor Dockerfile gets correct host paths despite API Dockerfile
mutation. This validates the deep copy fix in config_to_compose -- without it,
the executor's config_to_docker call would see already-mutated container paths
from the API's config_to_docker call, causing FileNotFoundError."""
graphs = {"agent": "./agent.py:graph"}
actual_compose_stdin = config_to_compose(
PATH_TO_CONFIG,
validate_config({"dependencies": ["."], "graphs": graphs}),
"langchain/langgraph-api",
engine_runtime_mode="distributed",
)
# Both API and executor Dockerfiles should contain valid LANGSERVE_GRAPHS
# referencing container paths (not host paths). If the deep copy was missing,
# the executor Dockerfile would fail to generate or have wrong paths.
from_lines = [
line.strip()
for line in actual_compose_stdin.splitlines()
if "LANGSERVE_GRAPHS=" in line.strip()
]
assert len(from_lines) == 2, (
f"Expected 2 LANGSERVE_GRAPHS lines (api + executor), got {len(from_lines)}"
)
class TestHasDisallowedBuildCommandContent:
"""Tests for has_disallowed_build_command_content."""
+55
View File
@@ -368,6 +368,61 @@ services:
assert clean_empty_lines(actual_compose_str) == expected_compose_str
def test_compose_distributed_mode_with_custom_db():
"""Test compose with engine_runtime_mode='distributed' adds N_JOBS_PER_WORKER=0."""
port = 8123
custom_postgres_uri = "custom_postgres_uri"
actual_compose_str = compose(
DEFAULT_DOCKER_CAPABILITIES,
port=port,
postgres_uri=custom_postgres_uri,
engine_runtime_mode="distributed",
)
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}
N_JOBS_PER_WORKER: "0\""""
assert clean_empty_lines(actual_compose_str) == expected_compose_str
def test_compose_distributed_mode_with_default_db():
"""Test compose distributed mode with default DB includes N_JOBS_PER_WORKER=0."""
port = 8123
actual_compose_str = compose(
DEFAULT_DOCKER_CAPABILITIES,
port=port,
engine_runtime_mode="distributed",
)
assert 'N_JOBS_PER_WORKER: "0"' in actual_compose_str
assert "langgraph-postgres:" in actual_compose_str
assert "langgraph-redis:" in actual_compose_str
def test_compose_combined_mode_has_no_n_jobs():
"""Test compose with default combined_queue_worker mode does NOT set N_JOBS_PER_WORKER."""
port = 8123
actual_compose_str = compose(
DEFAULT_DOCKER_CAPABILITIES,
port=port,
engine_runtime_mode="combined_queue_worker",
)
assert "N_JOBS_PER_WORKER" not in actual_compose_str
@pytest.mark.parametrize(
"input_str,expected",
[
@@ -135,6 +135,28 @@ def test_list_deployments(client):
assert result == {"ok": True}
def test_list_deployments_sends_query_params():
def handler(req: httpx.Request) -> httpx.Response:
assert req.url.path == "/v2/deployments"
assert req.url.params["name_contains"] == "my app"
return httpx.Response(200, json={"ok": True})
c = HostBackendClient("https://api.example.com", "test-key")
c._client = httpx.Client(
base_url="https://api.example.com",
transport=httpx.MockTransport(handler),
headers={"X-Api-Key": "test-key", "Accept": "application/json"},
timeout=30,
)
result = c.list_deployments("my app")
assert result == {"ok": True}
def test_delete_deployment(client):
result = client.delete_deployment("dep-123")
assert result == {"ok": True}
def test_request_push_token(client):
result = client.request_push_token("dep-123")
assert result == {"ok": True}
@@ -160,3 +182,41 @@ def test_list_revisions(client):
def test_get_revision(client):
result = client.get_revision("dep-123", "rev-456")
assert result == {"ok": True}
def test_get_build_logs(client):
result = client.get_build_logs("proj-1", "rev-1", {"limit": 10})
assert result == {"ok": True}
def test_get_deploy_logs_all_revisions():
def handler(req: httpx.Request) -> httpx.Response:
assert "/v1/projects/proj-1/deploy_logs" in str(req.url)
assert "/revisions/" not in str(req.url)
return httpx.Response(200, json={"logs": [{"message": "running"}]})
c = HostBackendClient("https://api.example.com", "key")
c._client = httpx.Client(
base_url="https://api.example.com",
transport=httpx.MockTransport(handler),
headers={"X-Api-Key": "key", "Accept": "application/json"},
timeout=30,
)
result = c.get_deploy_logs("proj-1", {"limit": 10})
assert result == {"logs": [{"message": "running"}]}
def test_get_deploy_logs_specific_revision():
def handler(req: httpx.Request) -> httpx.Response:
assert "/v1/projects/proj-1/revisions/rev-2/deploy_logs" in str(req.url)
return httpx.Response(200, json={"logs": []})
c = HostBackendClient("https://api.example.com", "key")
c._client = httpx.Client(
base_url="https://api.example.com",
transport=httpx.MockTransport(handler),
headers={"X-Api-Key": "key", "Accept": "application/json"},
timeout=30,
)
result = c.get_deploy_logs("proj-1", {"limit": 10}, revision_id="rev-2")
assert result == {"logs": []}
@@ -0,0 +1,58 @@
from langgraph_cli.helpers import format_log_entry, format_timestamp, level_fg
class TestFormatTimestamp:
def test_epoch_ms(self):
assert format_timestamp(1773119644012) == "2026-03-10 05:14:04"
def test_string_passthrough(self):
assert format_timestamp("2026-03-08T00:00:00Z") == "2026-03-08T00:00:00Z"
def test_empty(self):
assert format_timestamp("") == ""
def test_none(self):
assert format_timestamp(None) == ""
class TestFormatLogEntry:
def test_full_entry_epoch(self):
entry = {"timestamp": 1773119644012, "level": "ERROR", "message": "boom"}
result = format_log_entry(entry)
assert result == "[2026-03-10 05:14:04] [ERROR] boom"
def test_full_entry_string(self):
entry = {
"timestamp": "2026-03-08T12:00:00Z",
"level": "ERROR",
"message": "boom",
}
assert format_log_entry(entry) == "[2026-03-08T12:00:00Z] [ERROR] boom"
def test_no_level(self):
entry = {"timestamp": "2026-03-08T12:00:00Z", "message": "hello"}
assert format_log_entry(entry) == "[2026-03-08T12:00:00Z] hello"
def test_no_timestamp(self):
entry = {"message": "bare message"}
assert format_log_entry(entry) == "bare message"
def test_empty_entry(self):
assert format_log_entry({}) == ""
class TestLevelFg:
def test_error(self):
assert level_fg("ERROR") == "red"
def test_error_lowercase(self):
assert level_fg("error") == "red"
def test_warning(self):
assert level_fg("WARNING") == "yellow"
def test_info_returns_none(self):
assert level_fg("INFO") is None
def test_empty_returns_none(self):
assert level_fg("") is None
+39 -1
View File
@@ -1,6 +1,11 @@
from unittest.mock import patch
from langgraph_cli.util import clean_empty_lines, warn_non_wolfi_distro
from langgraph_cli.util import (
_extract_deployment_url,
clean_empty_lines,
format_deployments_table,
warn_non_wolfi_distro,
)
def test_clean_empty_lines():
@@ -186,3 +191,36 @@ def test_warn_non_wolfi_distro_does_not_modify_config():
warn_non_wolfi_distro(config_copy)
assert config_copy == original_config # Config should remain unchanged
def test_extract_deployment_url_uses_custom_url():
deployment = {"source_config": {"custom_url": "https://example.com/custom"}}
assert _extract_deployment_url(deployment) == "https://example.com/custom"
def test_extract_deployment_url_defaults_to_dash():
assert _extract_deployment_url({"id": "dep-123"}) == "-"
def test_format_deployments_table():
output = format_deployments_table(
[
{
"id": "dep-123",
"name": "alpha",
"source_config": {"custom_url": "https://alpha.example.com"},
},
{
"id": "dep-456",
"name": "beta",
"url": "https://beta.example.com",
},
]
)
assert "Deployment ID" in output
assert "Deployment Name" in output
assert "Deployment URL" in output
assert "dep-123" in output
assert "alpha" in output
assert "https://alpha.example.com" in output
assert "dep-456" in output
@@ -41,6 +41,9 @@ CONFIG_KEY_CACHE = sys.intern("__pregel_cache")
# holds a `BaseCache` made available to subgraphs
CONFIG_KEY_RESUMING = sys.intern("__pregel_resuming")
# holds a boolean indicating if subgraphs should resume from a previous checkpoint
CONFIG_KEY_REPLAY_STATE = sys.intern("__pregel_replay_state")
# holds a ReplayState tracking the parent checkpoint_id upper bound and which
# subgraph namespaces have already loaded their pre-replay checkpoint
CONFIG_KEY_TASK_ID = sys.intern("__pregel_task_id")
# holds the task ID for the current task
CONFIG_KEY_THREAD_ID = sys.intern("thread_id")
@@ -98,6 +101,7 @@ RESERVED = {
CONFIG_KEY_STREAM,
CONFIG_KEY_CHECKPOINT_MAP,
CONFIG_KEY_RESUMING,
CONFIG_KEY_REPLAY_STATE,
CONFIG_KEY_TASK_ID,
CONFIG_KEY_CHECKPOINT_MAP,
CONFIG_KEY_CHECKPOINT_ID,
@@ -0,0 +1,90 @@
"""Replay state for subgraph checkpoint loading during time-travel."""
from __future__ import annotations
from typing import TYPE_CHECKING
from langgraph._internal._constants import NS_END
if TYPE_CHECKING:
from langchain_core.runnables import RunnableConfig
from langgraph.checkpoint.base import BaseCheckpointSaver, CheckpointTuple
class ReplayState:
"""Tracks which subgraphs have already loaded their pre-replay checkpoint.
During a parent replay, each subgraph's first invocation should restore the
checkpoint from before the replay point. Subsequent invocations of the same
subgraph (e.g. in a loop) should use normal checkpoint loading so they pick
up freshly created checkpoints.
The single `ReplayState` instance is shared by reference across all derived
configs within one parent execution.
"""
__slots__ = ("checkpoint_id", "_visited_ns")
def __init__(self, checkpoint_id: str) -> None:
self.checkpoint_id = checkpoint_id
# DO NOT CHANGE THIS VARIABLE – it may need to be rehydrated
# in other runtimes
self._visited_ns: set[str] = set()
def _is_first_visit(self, checkpoint_ns: str) -> bool:
"""Return True the first time a subgraph namespace is seen.
The task-id suffix is stripped so that the same logical subgraph
(e.g. ``"sub_node"``) is recognized across loop iterations even
though each iteration has a different task id.
"""
# "sub_node:task_id" -> "sub_node"
stable_ns = (
checkpoint_ns.rsplit(NS_END, 1)[0]
if NS_END in checkpoint_ns
else checkpoint_ns
)
if stable_ns in self._visited_ns:
return False
self._visited_ns.add(stable_ns)
return True
def get_checkpoint(
self,
checkpoint_ns: str,
checkpointer: BaseCheckpointSaver,
checkpoint_config: RunnableConfig,
) -> CheckpointTuple | None:
"""Load the right checkpoint for a subgraph during replay.
On the first call for a given subgraph namespace, returns the latest
checkpoint created *before* the replay point. On subsequent calls
(e.g. the same subgraph in a later loop iteration), falls back to
normal latest-checkpoint loading.
"""
if self._is_first_visit(checkpoint_ns):
for saved in checkpointer.list(
checkpoint_config,
before={"configurable": {"checkpoint_id": self.checkpoint_id}},
limit=1,
):
return saved
return None
return checkpointer.get_tuple(checkpoint_config)
async def aget_checkpoint(
self,
checkpoint_ns: str,
checkpointer: BaseCheckpointSaver,
checkpoint_config: RunnableConfig,
) -> CheckpointTuple | None:
"""Async version of `get_checkpoint`."""
if self._is_first_visit(checkpoint_ns):
async for saved in checkpointer.alist(
checkpoint_config,
before={"configurable": {"checkpoint_id": self.checkpoint_id}},
limit=1,
):
return saved
return None
return await checkpointer.aget_tuple(checkpoint_config)
+118 -23
View File
@@ -42,6 +42,7 @@ from langgraph._internal._constants import (
CONFIG_KEY_CHECKPOINT_ID,
CONFIG_KEY_CHECKPOINT_MAP,
CONFIG_KEY_CHECKPOINT_NS,
CONFIG_KEY_REPLAY_STATE,
CONFIG_KEY_RESUME_MAP,
CONFIG_KEY_RESUMING,
CONFIG_KEY_SCRATCHPAD,
@@ -58,6 +59,7 @@ from langgraph._internal._constants import (
RESUME,
TASKS,
)
from langgraph._internal._replay import ReplayState
from langgraph._internal._scratchpad import PregelScratchpad
from langgraph._internal._typing import EMPTY_SEQ, MISSING
from langgraph.channels.base import BaseChannel
@@ -152,7 +154,7 @@ class PregelLoop:
input_keys: str | Sequence[str]
output_keys: str | Sequence[str]
stream_keys: str | Sequence[str]
skip_done_tasks: bool
is_replaying: bool
is_nested: bool
manager: None | AsyncParentRunManager | ParentRunManager
interrupt_after: All | Sequence[str]
@@ -244,7 +246,7 @@ class PregelLoop:
self.interrupt_before = interrupt_before
self.manager = manager
self.is_nested = CONFIG_KEY_TASK_ID in self.config.get(CONF, {})
self.skip_done_tasks = CONFIG_KEY_CHECKPOINT_ID not in config[CONF]
self.is_replaying = CONFIG_KEY_CHECKPOINT_ID in config[CONF]
self._migrate_checkpoint = migrate_checkpoint
self.trigger_to_nodes = trigger_to_nodes
self.retry_policy = retry_policy
@@ -451,7 +453,7 @@ class PregelLoop:
# save the new task
self.tasks[pushed.id] = pushed
# match any pending writes to the new task
if self.skip_done_tasks:
if not self.is_replaying:
self._match_writes({pushed.id: pushed})
# return the new task, to be started if not run before
return pushed
@@ -515,7 +517,7 @@ class PregelLoop:
return False
# if there are pending writes from a previous loop, apply them
if self.skip_done_tasks and self.checkpoint_pending_writes:
if not self.is_replaying and self.checkpoint_pending_writes:
self._match_writes(self.tasks)
# before execution, check if we should interrupt
@@ -557,8 +559,8 @@ class PregelLoop:
)
# clear pending writes
self.checkpoint_pending_writes.clear()
# "not skip_done_tasks" only applies to first tick after resuming
self.skip_done_tasks = True
# only replay (re-execute) done tasks on the first tick
self.is_replaying = False
# save checkpoint
self._put_checkpoint({"source": "loop"})
# after execution, check if we should interrupt
@@ -618,15 +620,21 @@ class PregelLoop:
def _first(
self, *, input_keys: str | Sequence[str], updated_channels: set[str] | None
) -> set[str] | None:
# resuming from previous checkpoint requires
# - finding a previous checkpoint
# - receiving None input (outer graph) or RESUMING flag (subgraph)
# Resuming from a previous checkpoint requires two things:
# 1. A prior checkpoint exists (channel_versions is non-empty)
# 2. The input signals continuation (not a fresh run with new input)
# For subgraphs, the parent explicitly sets CONFIG_KEY_RESUMING.
# For the outer graph, we infer from the input:
# - None input: resume after interrupt (invoke(None, config))
# - Command input: any Command operates on existing state
# - Same run_id: re-entry into an ongoing run (e.g. stream reconnect)
configurable = self.config.get(CONF, {})
input_is_command = isinstance(self.input, Command)
is_resuming = bool(self.checkpoint["channel_versions"]) and bool(
configurable.get(
CONFIG_KEY_RESUMING,
self.input is None
or isinstance(self.input, Command)
or input_is_command
or (
not self.is_nested
and self.config.get("metadata", {}).get("run_id")
@@ -635,9 +643,36 @@ class PregelLoop:
)
)
# When replaying from a specific checkpoint, drop cached RESUME
# writes so that interrupt() calls re-fire instead of returning
# stale values. But if we're actively resuming, keep them —
# multi-interrupt scenarios need previously resolved values preserved.
if self.is_replaying and (
# Time-travel to a subgraph checkpoint: the parent sets
# RESUMING=True (it can't distinguish time-travel from resume),
# so we check if this subgraph's own ns is in checkpoint_map.
# Normally the map only has ancestor entries (_algo.py); the
# subgraph's own entry only appears via get_state(subgraphs=True).
(
self.is_nested
and configurable.get(CONFIG_KEY_CHECKPOINT_NS, "")
in configurable.get(CONFIG_KEY_CHECKPOINT_MAP, {})
)
or not (
# Outer graph: resume arrives as Command(resume=...)
(input_is_command and cast(Command, self.input).resume is not None)
# Subgraphs: resume arrives via config flag from parent
# (subgraph input is a Send arg, not a Command)
or configurable.get(CONFIG_KEY_RESUMING, False)
)
):
self.checkpoint_pending_writes = [
w for w in self.checkpoint_pending_writes if w[1] != RESUME
]
# map command to writes
if isinstance(self.input, Command):
if (resume := self.input.resume) is not None:
if input_is_command:
if (resume := cast(Command, self.input).resume) is not None:
if not self.checkpointer:
raise RuntimeError(
"Cannot use Command(resume=...) without checkpointer"
@@ -657,7 +692,7 @@ class PregelLoop:
writes: defaultdict[str, list[tuple[str, Any]]] = defaultdict(list)
# group writes by task ID
for tid, c, v in map_command(cmd=self.input):
for tid, c, v in map_command(cmd=cast(Command, self.input)):
if not (c == RESUME and resume_is_map):
writes[tid].append((c, v))
if not writes and not resume_is_map:
@@ -723,10 +758,30 @@ class PregelLoop:
self._put_checkpoint({"source": "input"})
elif CONFIG_KEY_RESUMING not in configurable:
raise EmptyInputError(f"Received no input for {input_keys}")
# update config
# Propagate resuming and replaying flags to subgraphs.
if not self.is_nested:
# Pass the resolved before-bound checkpoint ID so subgraphs can
# find their corresponding checkpoint without re-fetching the
# parent. For forks (source=update), use the fork's parent
# checkpoint ID since the fork was created after the subgraph's
# checkpoints from the original execution.
replay_state: ReplayState | None = None
if self.is_replaying:
replay_checkpoint_id = self.checkpoint["id"]
if (
self.checkpoint_metadata.get("source") == "update"
and self.prev_checkpoint_config
):
replay_checkpoint_id = self.prev_checkpoint_config[CONF].get(
CONFIG_KEY_CHECKPOINT_ID, replay_checkpoint_id
)
replay_state = ReplayState(replay_checkpoint_id)
self.config = patch_configurable(
self.config, {CONFIG_KEY_RESUMING: is_resuming}
self.config,
{
CONFIG_KEY_RESUMING: is_resuming,
CONFIG_KEY_REPLAY_STATE: replay_state,
},
)
# set flag
self.status = "pending"
@@ -1081,10 +1136,31 @@ class SyncPregelLoop(PregelLoop, AbstractContextManager):
# context manager
def __enter__(self) -> Self:
if self.checkpointer:
saved = self.checkpointer.get_tuple(self.checkpoint_config)
else:
if not self.checkpointer:
saved = None
elif self.checkpoint_config[CONF].get(CONFIG_KEY_CHECKPOINT_ID):
# Explicit checkpoint_id requested — fetch that exact checkpoint.
# This covers both normal replay and subgraphs resolved via
# checkpoint_map during time-travel.
saved = self.checkpointer.get_tuple(self.checkpoint_config)
elif replay_state := self.config[CONF].get(CONFIG_KEY_REPLAY_STATE):
# Subgraph replay: the parent is replaying and passed us a
# replay_state with its checkpoint_id. Look up our checkpoint
# from the parent's checkpoint_map instead of fetching latest.
saved = replay_state.get_checkpoint(
self.config[CONF].get(CONFIG_KEY_CHECKPOINT_NS, ""),
self.checkpointer,
self.checkpoint_config,
)
# Clear RESUMING so _first re-applies input instead of resuming.
# This recreates ephemeral routing channels so nodes trigger
# naturally via version comparison.
self.config[CONF].pop(CONFIG_KEY_RESUMING, None)
else:
# Normal case: fetch the most recent checkpoint for this
# graph/thread. Returns None on first invocation.
saved = self.checkpointer.get_tuple(self.checkpoint_config)
if saved is None:
saved = CheckpointTuple(
self.checkpoint_config, empty_checkpoint(), {"step": -2}, None, []
@@ -1109,7 +1185,6 @@ class SyncPregelLoop(PregelLoop, AbstractContextManager):
if saved.pending_writes is not None
else []
)
self.submit = self.stack.enter_context(BackgroundExecutor(self.config))
self.channels, self.managed = channels_from_checkpoint(
self.specs, self.checkpoint
@@ -1260,10 +1335,31 @@ class AsyncPregelLoop(PregelLoop, AbstractAsyncContextManager):
# context manager
async def __aenter__(self) -> Self:
if self.checkpointer:
saved = await self.checkpointer.aget_tuple(self.checkpoint_config)
else:
if not self.checkpointer:
saved = None
elif self.checkpoint_config[CONF].get(CONFIG_KEY_CHECKPOINT_ID):
# Explicit checkpoint_id requested — fetch that exact checkpoint.
# This covers both normal replay and subgraphs resolved via
# checkpoint_map during time-travel.
saved = await self.checkpointer.aget_tuple(self.checkpoint_config)
elif replay_state := self.config[CONF].get(CONFIG_KEY_REPLAY_STATE):
# Subgraph replay: the parent is replaying and passed us a
# replay_state with its checkpoint_id. Look up our checkpoint
# from the parent's checkpoint_map instead of fetching latest.
saved = await replay_state.aget_checkpoint(
self.config[CONF].get(CONFIG_KEY_CHECKPOINT_NS, ""),
self.checkpointer,
self.checkpoint_config,
)
# Clear RESUMING so _first re-applies input instead of resuming.
# This recreates ephemeral routing channels so nodes trigger
# naturally via version comparison.
self.config[CONF].pop(CONFIG_KEY_RESUMING, None)
else:
# Normal case: fetch the most recent checkpoint for this
# graph/thread. Returns None on first invocation.
saved = await self.checkpointer.aget_tuple(self.checkpoint_config)
if saved is None:
saved = CheckpointTuple(
self.checkpoint_config, empty_checkpoint(), {"step": -2}, None, []
@@ -1288,7 +1384,6 @@ class AsyncPregelLoop(PregelLoop, AbstractAsyncContextManager):
if saved.pending_writes is not None
else []
)
self.submit = await self.stack.enter_async_context(
AsyncBackgroundExecutor(self.config)
)
+10 -1
View File
@@ -25,7 +25,7 @@ except ImportError:
_StreamingCallbackHandler = object # type: ignore
T = TypeVar("T")
Meta = tuple[tuple[str, ...], dict[str, Any]]
Meta = tuple[tuple[str, ...], dict[str, Any] | None]
def _state_values(obj: Any) -> Sequence[Any]:
@@ -56,6 +56,7 @@ class StreamMessagesHandler(BaseCallbackHandler, _StreamingCallbackHandler):
subgraphs: bool,
*,
parent_ns: tuple[str, ...] | None = None,
dedupe_metadata: bool = False,
) -> None:
"""Configure the handler to stream messages from LLMs and nodes.
@@ -84,8 +85,10 @@ class StreamMessagesHandler(BaseCallbackHandler, _StreamingCallbackHandler):
self.stream = stream
self.subgraphs = subgraphs
self.metadata: dict[UUID, Meta] = {}
self.emitted_metadata: set[UUID] = set()
self.seen: set[int | str] = set()
self.parent_ns = parent_ns
self.dedupe_metadata = dedupe_metadata
def _emit(self, meta: Meta, message: BaseMessage, *, dedupe: bool = False) -> None:
if dedupe and message.id in self.seen:
@@ -155,6 +158,10 @@ class StreamMessagesHandler(BaseCallbackHandler, _StreamingCallbackHandler):
if not isinstance(chunk, ChatGenerationChunk):
return
if meta := self.metadata.get(run_id):
if self.dedupe_metadata and run_id in self.emitted_metadata:
meta = (meta[0], None)
else:
self.emitted_metadata.add(run_id)
self._emit(meta, chunk.message)
def on_llm_end(
@@ -170,6 +177,7 @@ class StreamMessagesHandler(BaseCallbackHandler, _StreamingCallbackHandler):
gen = response.generations[0][0]
if isinstance(gen, ChatGeneration):
self._emit(meta, gen.message, dedupe=True)
self.emitted_metadata.discard(run_id)
self.metadata.pop(run_id, None)
def on_llm_error(
@@ -180,6 +188,7 @@ class StreamMessagesHandler(BaseCallbackHandler, _StreamingCallbackHandler):
parent_run_id: UUID | None = None,
**kwargs: Any,
) -> Any:
self.emitted_metadata.discard(run_id)
self.metadata.pop(run_id, None)
def on_chain_start(
+6 -4
View File
@@ -2456,7 +2456,7 @@ class Pregel(
debug: bool | None = None,
version: Literal["v2"],
**kwargs: Unpack[DeprecatedKwargs],
) -> Iterator[StreamPart[OutputT, StateT]]: ...
) -> Iterator[StreamPart[StateT, OutputT]]: ...
@overload
def stream(
@@ -2614,6 +2614,7 @@ class Pregel(
stream.put,
subgraphs,
parent_ns=tuple(ns_.split(NS_SEP)) if ns_ else None,
dedupe_metadata="compact" in stream_modes,
)
)
@@ -2787,7 +2788,7 @@ class Pregel(
debug: bool | None = None,
version: Literal["v2"],
**kwargs: Unpack[DeprecatedKwargs],
) -> AsyncIterator[StreamPart[OutputT, StateT]]: ...
) -> AsyncIterator[StreamPart[StateT, OutputT]]: ...
@overload
def astream(
@@ -2965,6 +2966,7 @@ class Pregel(
stream_put,
subgraphs,
parent_ns=tuple(ns_.split(NS_SEP)) if ns_ else None,
dedupe_metadata="compact" in stream_modes,
)
)
@@ -3194,7 +3196,7 @@ class Pregel(
durability: Durability | None = None,
version: Literal["v2"],
**kwargs: Any,
) -> list[StreamPart[OutputT, StateT]]: ...
) -> list[StreamPart[StateT, OutputT]]: ...
@overload
def invoke(
@@ -3364,7 +3366,7 @@ class Pregel(
durability: Durability | None = None,
version: Literal["v2"],
**kwargs: Any,
) -> list[StreamPart[OutputT, StateT]]: ...
) -> list[StreamPart[StateT, OutputT]]: ...
@overload
async def ainvoke(
+2 -2
View File
@@ -117,7 +117,7 @@ class PregelProtocol(Runnable[InputT, Any], Generic[StateT, ContextT, InputT, Ou
interrupt_after: All | Sequence[str] | None = None,
subgraphs: bool = False,
version: Literal["v2"],
) -> Iterator[StreamPart[OutputT, StateT]]: ...
) -> Iterator[StreamPart[StateT, OutputT]]: ...
@overload
@abstractmethod
@@ -161,7 +161,7 @@ class PregelProtocol(Runnable[InputT, Any], Generic[StateT, ContextT, InputT, Ou
interrupt_after: All | Sequence[str] | None = None,
subgraphs: bool = False,
version: Literal["v2"],
) -> AsyncIterator[StreamPart[OutputT, StateT]]: ...
) -> AsyncIterator[StreamPart[StateT, OutputT]]: ...
@overload
@abstractmethod
+78 -14
View File
@@ -31,6 +31,7 @@ from langgraph_sdk.client import (
)
from langgraph_sdk.schema import (
Checkpoint,
Context,
QueryParamTypes,
ThreadState,
)
@@ -108,6 +109,45 @@ class RemoteException(Exception):
pass
def _restore_message_metadata(
data: Any, metadata_by_message_id: dict[str, dict[str, Any]]
) -> Any:
"""Restore deduplicated message metadata using the message id as cache key."""
if not (isinstance(data, list) and len(data) == 2):
return data
message, metadata = data
if not isinstance(message, dict):
return data
message_id = message.get("id")
if isinstance(message_id, str):
if isinstance(metadata, dict):
metadata_by_message_id[message_id] = metadata
else:
metadata = metadata_by_message_id.get(message_id)
return (message, metadata)
def _merge_values_patch(
ns: tuple[str, ...],
mode: str,
data: Any,
values_by_ns: dict[tuple[str, ...], dict[str, Any]],
) -> tuple[str, Any]:
"""Merge `values-patch` events back into full values snapshots."""
if mode != "values-patch" or not isinstance(data, dict):
return mode, data
values = data.get("values")
if not isinstance(values, dict):
return "values", values if values is not None else {}
merged = dict(values_by_ns.get(ns, {}))
merged.update(values)
for key in data.get("deleted_keys", ()):
if isinstance(key, str):
merged.pop(key, None)
values_by_ns[ns] = merged
return "values", merged
class RemoteGraph(PregelProtocol):
"""The `RemoteGraph` class is a client implementation for calling remote
APIs that implement the LangGraph Server API specification.
@@ -691,6 +731,7 @@ class RemoteGraph(PregelProtocol):
input: dict[str, Any] | Any,
config: RunnableConfig | None = None,
*,
context: Context | None = None,
stream_mode: StreamMode | list[StreamMode] | None = None,
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
@@ -707,6 +748,7 @@ class RemoteGraph(PregelProtocol):
input: dict[str, Any] | Any,
config: RunnableConfig | None = None,
*,
context: Context | None = None,
stream_mode: StreamMode | list[StreamMode] | None = None,
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
@@ -722,6 +764,7 @@ class RemoteGraph(PregelProtocol):
input: dict[str, Any] | Any,
config: RunnableConfig | None = None,
*,
context: Context | None = None,
stream_mode: StreamMode | list[StreamMode] | None = None,
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
@@ -734,7 +777,7 @@ class RemoteGraph(PregelProtocol):
"""Create a run and stream the results.
This method calls `POST /threads/{thread_id}/runs/stream` if a `thread_id`
is speciffed in the `configurable` field of the config or
is specified in the `configurable` field of the config or
`POST /runs/stream` otherwise.
Args:
@@ -762,6 +805,8 @@ class RemoteGraph(PregelProtocol):
else:
command = None
thread_id = sanitized_config.get("configurable", {}).pop("thread_id", None)
message_metadata_by_id: dict[str, dict[str, Any]] = {}
values_by_ns: dict[tuple[str, ...], dict[str, Any]] = {}
for chunk in sync_client.runs.stream(
thread_id=thread_id,
@@ -769,6 +814,7 @@ class RemoteGraph(PregelProtocol):
input=input,
command=command,
config=sanitized_config,
context=context,
stream_mode=stream_modes,
interrupt_before=interrupt_before,
interrupt_after=interrupt_after,
@@ -793,6 +839,11 @@ class RemoteGraph(PregelProtocol):
if caller_ns := (config or {}).get(CONF, {}).get(CONFIG_KEY_CHECKPOINT_NS):
caller_ns = tuple(caller_ns.split(NS_SEP))
ns = caller_ns + ns
mode, data = _merge_values_patch(ns, mode, chunk.data, values_by_ns)
if mode != chunk.event:
chunk = chunk._replace(data=data)
elif data is not chunk.data:
chunk = chunk._replace(data=data)
# stream to parent stream
if stream is not None and mode in stream.modes:
stream((ns, mode, chunk.data))
@@ -810,7 +861,9 @@ class RemoteGraph(PregelProtocol):
continue
if chunk.event.startswith("messages"):
chunk = chunk._replace(data=tuple(chunk.data))
chunk = chunk._replace(
data=_restore_message_metadata(chunk.data, message_metadata_by_id)
)
# emit chunk
if version == "v2":
@@ -822,11 +875,6 @@ class RemoteGraph(PregelProtocol):
)
yield {"type": mode, "ns": ns, "data": chunk.data, "interrupts": ints}
elif subgraphs:
if NS_SEP in chunk.event:
mode, ns_ = chunk.event.split(NS_SEP, 1)
ns = tuple(ns_.split(NS_SEP))
else:
mode, ns = chunk.event, ()
if req_single:
yield ns, chunk.data
else:
@@ -842,6 +890,7 @@ class RemoteGraph(PregelProtocol):
input: dict[str, Any] | Any,
config: RunnableConfig | None = None,
*,
context: Context | None = None,
stream_mode: StreamMode | list[StreamMode] | None = None,
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
@@ -858,6 +907,7 @@ class RemoteGraph(PregelProtocol):
input: dict[str, Any] | Any,
config: RunnableConfig | None = None,
*,
context: Context | None = None,
stream_mode: StreamMode | list[StreamMode] | None = None,
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
@@ -873,6 +923,7 @@ class RemoteGraph(PregelProtocol):
input: dict[str, Any] | Any,
config: RunnableConfig | None = None,
*,
context: Context | None = None,
stream_mode: StreamMode | list[StreamMode] | None = None,
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
@@ -885,7 +936,7 @@ class RemoteGraph(PregelProtocol):
"""Create a run and stream the results.
This method calls `POST /threads/{thread_id}/runs/stream` if a `thread_id`
is speciffed in the `configurable` field of the config or
is specified in the `configurable` field of the config or
`POST /runs/stream` otherwise.
Args:
@@ -913,6 +964,8 @@ class RemoteGraph(PregelProtocol):
else:
command = None
thread_id = sanitized_config.get("configurable", {}).pop("thread_id", None)
message_metadata_by_id: dict[str, dict[str, Any]] = {}
values_by_ns: dict[tuple[str, ...], dict[str, Any]] = {}
async for chunk in client.runs.stream(
thread_id=thread_id,
@@ -920,6 +973,7 @@ class RemoteGraph(PregelProtocol):
input=input,
command=command,
config=sanitized_config,
context=context,
stream_mode=stream_modes,
interrupt_before=interrupt_before,
interrupt_after=interrupt_after,
@@ -944,6 +998,11 @@ class RemoteGraph(PregelProtocol):
if caller_ns := (config or {}).get(CONF, {}).get(CONFIG_KEY_CHECKPOINT_NS):
caller_ns = tuple(caller_ns.split(NS_SEP))
ns = caller_ns + ns
mode, data = _merge_values_patch(ns, mode, chunk.data, values_by_ns)
if mode != chunk.event:
chunk = chunk._replace(data=data)
elif data is not chunk.data:
chunk = chunk._replace(data=data)
# stream to parent stream
if stream is not None and mode in stream.modes:
stream((ns, mode, chunk.data))
@@ -961,7 +1020,9 @@ class RemoteGraph(PregelProtocol):
continue
if chunk.event.startswith("messages"):
chunk = chunk._replace(data=tuple(chunk.data))
chunk = chunk._replace(
data=_restore_message_metadata(chunk.data, message_metadata_by_id)
)
# emit chunk
if version == "v2":
@@ -973,11 +1034,6 @@ class RemoteGraph(PregelProtocol):
)
yield {"type": mode, "ns": ns, "data": chunk.data, "interrupts": ints}
elif subgraphs:
if NS_SEP in chunk.event:
mode, ns_ = chunk.event.split(NS_SEP, 1)
ns = tuple(ns_.split(NS_SEP))
else:
mode, ns = chunk.event, ()
if req_single:
yield ns, chunk.data
else:
@@ -1009,6 +1065,7 @@ class RemoteGraph(PregelProtocol):
input: dict[str, Any] | Any,
config: RunnableConfig | None = None,
*,
context: Context | None = None,
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
headers: dict[str, str] | None = None,
@@ -1023,6 +1080,7 @@ class RemoteGraph(PregelProtocol):
input: dict[str, Any] | Any,
config: RunnableConfig | None = None,
*,
context: Context | None = None,
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
headers: dict[str, str] | None = None,
@@ -1036,6 +1094,7 @@ class RemoteGraph(PregelProtocol):
input: dict[str, Any] | Any,
config: RunnableConfig | None = None,
*,
context: Context | None = None,
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
headers: dict[str, str] | None = None,
@@ -1061,6 +1120,7 @@ class RemoteGraph(PregelProtocol):
for chunk in self.stream( # type: ignore[misc, call-overload]
input,
config=config,
context=context,
interrupt_before=interrupt_before,
interrupt_after=interrupt_after,
headers=headers,
@@ -1087,6 +1147,7 @@ class RemoteGraph(PregelProtocol):
input: dict[str, Any] | Any,
config: RunnableConfig | None = None,
*,
context: Context | None = None,
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
headers: dict[str, str] | None = None,
@@ -1101,6 +1162,7 @@ class RemoteGraph(PregelProtocol):
input: dict[str, Any] | Any,
config: RunnableConfig | None = None,
*,
context: Context | None = None,
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
headers: dict[str, str] | None = None,
@@ -1114,6 +1176,7 @@ class RemoteGraph(PregelProtocol):
input: dict[str, Any] | Any,
config: RunnableConfig | None = None,
*,
context: Context | None = None,
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
headers: dict[str, str] | None = None,
@@ -1139,6 +1202,7 @@ class RemoteGraph(PregelProtocol):
async for chunk in self.astream( # type: ignore[misc, call-overload]
input,
config=config,
context=context,
interrupt_before=interrupt_before,
interrupt_after=interrupt_after,
headers=headers,
+14 -6
View File
@@ -116,7 +116,14 @@ def ensure_valid_checkpointer(checkpointer: Checkpointer) -> Checkpointer:
StreamMode = Literal[
"values", "updates", "checkpoints", "tasks", "debug", "messages", "custom"
"values",
"updates",
"checkpoints",
"tasks",
"debug",
"messages",
"custom",
"compact",
]
"""How the stream method should emit outputs.
@@ -275,13 +282,14 @@ class MessagesStreamPart(TypedDict):
"""Stream part emitted for `stream_mode="messages"`.
`data` is a 2-tuple of `(message, metadata)` where `message` is a
`BaseMessage` (e.g. `AIMessageChunk`) and `metadata` is a dict containing
keys like `langgraph_step`, `langgraph_node`, `langgraph_triggers`, etc.
`BaseMessage` (e.g. `AIMessageChunk`) and `metadata` is either a dict containing
keys like `langgraph_step`, `langgraph_node`, `langgraph_triggers`, etc. or
`None` for deduplicated follow-up chunks when `stream_mode` includes `"compact"`.
"""
type: Literal["messages"]
ns: tuple[str, ...]
data: tuple[AnyMessage, dict[str, Any]]
data: tuple[AnyMessage, dict[str, Any] | None]
class CustomStreamPart(TypedDict):
@@ -335,7 +343,7 @@ StreamPart = TypeAliasType(
| CheckpointStreamPart[StateT]
| TasksStreamPart
| DebugStreamPart[StateT],
type_params=(OutputT, StateT),
type_params=(StateT, OutputT),
)
"""A discriminated union of all v2 stream part types.
@@ -346,7 +354,7 @@ async for part in graph.astream(input, version="v2"):
if part["type"] == "values":
part["data"] # OutputT — full state (pydantic/dataclass/dict)
elif part["type"] == "messages":
part["data"] # tuple[BaseMessage, dict] — (message, metadata)
part["data"] # tuple[BaseMessage, dict | None] — (message, metadata)
elif part["type"] == "custom":
part["data"] # Any — user-defined
```
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "langgraph"
version = "1.0.10"
version = "1.1.2"
description = "Building stateful, multi-actor applications with LLMs"
authors = []
requires-python = ">=3.10"
+335
View File
@@ -1,5 +1,6 @@
import re
import sys
from dataclasses import dataclass
from typing import Annotated
from unittest.mock import AsyncMock, MagicMock
@@ -10,6 +11,7 @@ from langchain_core.runnables import RunnableConfig
from langchain_core.runnables.graph import Edge as DrawableEdge
from langchain_core.runnables.graph import Node as DrawableNode
from langgraph_sdk.schema import StreamPart
from pydantic import BaseModel
from typing_extensions import TypedDict
from langgraph.errors import GraphInterrupt
@@ -880,6 +882,80 @@ def test_stream_sanitizes_thread_id():
assert not passed_config["configurable"]
def test_stream_restores_messages_and_merges_values_patch():
mock_sync_client = MagicMock()
mock_sync_client.runs.stream.return_value = [
StreamPart(
event="messages|tools:call_1",
data=[
{"id": "msg-1", "type": "AIMessageChunk", "content": "hel"},
{
"langgraph_checkpoint_ns": "tools:call_1",
"langgraph_node": "agent",
},
],
),
StreamPart(
event="messages|tools:call_1",
data=[
{"id": "msg-1", "type": "AIMessageChunk", "content": "lo"},
None,
],
),
StreamPart(
event="values|tools:call_1",
data={"messages": [{"type": "human", "content": "hi"}], "count": 1},
),
StreamPart(
event="values-patch|tools:call_1",
data={"values": {"count": 2}, "deleted_keys": ["messages"]},
),
]
remote_pregel = RemoteGraph("test_graph_id", sync_client=mock_sync_client)
parts = list(
remote_pregel.stream(
{"input": "data"},
config={"configurable": {"thread_id": "thread_1"}},
stream_mode=["messages", "values", "compact"],
subgraphs=True,
version="v2",
)
)
message_parts = [part for part in parts if part["type"] == "messages"]
assert message_parts[0]["data"][1] == {
"langgraph_checkpoint_ns": "tools:call_1",
"langgraph_node": "agent",
}
assert message_parts[1]["data"][1] == {
"langgraph_checkpoint_ns": "tools:call_1",
"langgraph_node": "agent",
}
value_parts = [part for part in parts if part["type"] == "values"]
assert value_parts == [
{
"type": "values",
"ns": ("tools:call_1",),
"data": {"messages": [{"type": "human", "content": "hi"}], "count": 1},
"interrupts": (),
},
{
"type": "values",
"ns": ("tools:call_1",),
"data": {"count": 2},
"interrupts": (),
},
]
_, kwargs = mock_sync_client.runs.stream.call_args
assert set(kwargs["stream_mode"]) == {
"messages-tuple",
"values",
"compact",
"updates",
}
@pytest.mark.anyio
async def test_ainvoke():
# set up test
@@ -908,6 +984,265 @@ async def test_ainvoke():
assert result == {"messages": [{"type": "human", "content": "world"}]}
def test_stream_context():
"""Test that context is passed through to the SDK client in stream."""
mock_sync_client = MagicMock()
mock_sync_client.runs.stream.return_value = [
StreamPart(event="values", data={"chunk": "data1"}),
]
remote_pregel = RemoteGraph(
"test_graph_id",
sync_client=mock_sync_client,
)
config = {"configurable": {"thread_id": "thread_1"}}
context = {"model_name": "anthropic", "user_id": "123"}
stream_parts = list(
remote_pregel.stream(
{"input": "data"},
config,
context=context,
stream_mode="values",
)
)
assert stream_parts == [{"chunk": "data1"}]
_, kwargs = mock_sync_client.runs.stream.call_args
assert kwargs["context"] == {"model_name": "anthropic", "user_id": "123"}
def test_stream_context_none():
"""Test that context defaults to None when not provided."""
mock_sync_client = MagicMock()
mock_sync_client.runs.stream.return_value = [
StreamPart(event="values", data={"chunk": "data1"}),
]
remote_pregel = RemoteGraph(
"test_graph_id",
sync_client=mock_sync_client,
)
config = {"configurable": {"thread_id": "thread_1"}}
list(remote_pregel.stream({"input": "data"}, config, stream_mode="values"))
_, kwargs = mock_sync_client.runs.stream.call_args
assert kwargs["context"] is None
@pytest.mark.anyio
async def test_astream_context():
"""Test that context is passed through to the SDK client in astream."""
mock_async_client = MagicMock()
async_iter = MagicMock()
async_iter.__aiter__.return_value = [
StreamPart(event="values", data={"chunk": "data1"}),
]
mock_async_client.runs.stream.return_value = async_iter
remote_pregel = RemoteGraph(
"test_graph_id",
client=mock_async_client,
)
config = {"configurable": {"thread_id": "thread_1"}}
context = {"model_name": "anthropic"}
chunks = []
async for chunk in remote_pregel.astream(
{"input": "data"},
config,
context=context,
stream_mode="values",
):
chunks.append(chunk)
assert chunks == [{"chunk": "data1"}]
_, kwargs = mock_async_client.runs.stream.call_args
assert kwargs["context"] == {"model_name": "anthropic"}
def test_invoke_context():
"""Test that context is passed through to the SDK client in invoke."""
mock_sync_client = MagicMock()
mock_sync_client.runs.stream.return_value = [
StreamPart(event="values", data={"result": "done"}),
]
remote_pregel = RemoteGraph(
"test_graph_id",
sync_client=mock_sync_client,
)
config = {"configurable": {"thread_id": "thread_1"}}
context = {"model_name": "openai"}
result = remote_pregel.invoke({"input": "data"}, config, context=context)
assert result == {"result": "done"}
_, kwargs = mock_sync_client.runs.stream.call_args
assert kwargs["context"] == {"model_name": "openai"}
@pytest.mark.anyio
async def test_ainvoke_context():
"""Test that context is passed through to the SDK client in ainvoke."""
mock_async_client = MagicMock()
async_iter = MagicMock()
async_iter.__aiter__.return_value = [
StreamPart(event="values", data={"result": "done"}),
]
mock_async_client.runs.stream.return_value = async_iter
remote_pregel = RemoteGraph(
"test_graph_id",
client=mock_async_client,
)
config = {"configurable": {"thread_id": "thread_1"}}
context = {"user_id": "456"}
result = await remote_pregel.ainvoke({"input": "data"}, config, context=context)
assert result == {"result": "done"}
_, kwargs = mock_async_client.runs.stream.call_args
assert kwargs["context"] == {"user_id": "456"}
def test_stream_context_dataclass():
"""Test that a dataclass context is passed through to the SDK client."""
@dataclass
class MyContext:
model_name: str
user_id: str
mock_sync_client = MagicMock()
mock_sync_client.runs.stream.return_value = [
StreamPart(event="values", data={"chunk": "data1"}),
]
remote_pregel = RemoteGraph(
"test_graph_id",
sync_client=mock_sync_client,
)
config = {"configurable": {"thread_id": "thread_1"}}
ctx = MyContext(model_name="anthropic", user_id="123")
list(
remote_pregel.stream(
{"input": "data"}, config, context=ctx, stream_mode="values"
)
)
_, kwargs = mock_sync_client.runs.stream.call_args
assert kwargs["context"] == ctx
def test_stream_context_base_model():
"""Test that a BaseModel context is passed through to the SDK client."""
class MyContext(BaseModel):
model_name: str
user_id: str
mock_sync_client = MagicMock()
mock_sync_client.runs.stream.return_value = [
StreamPart(event="values", data={"chunk": "data1"}),
]
remote_pregel = RemoteGraph(
"test_graph_id",
sync_client=mock_sync_client,
)
config = {"configurable": {"thread_id": "thread_1"}}
ctx = MyContext(model_name="anthropic", user_id="123")
list(
remote_pregel.stream(
{"input": "data"}, config, context=ctx, stream_mode="values"
)
)
_, kwargs = mock_sync_client.runs.stream.call_args
assert kwargs["context"] == ctx
@pytest.mark.anyio
async def test_astream_restores_messages_and_merges_values_patch():
mock_async_client = MagicMock()
async_iter = MagicMock()
async_iter.__aiter__.return_value = [
StreamPart(
event="messages|tools:call_1",
data=[
{"id": "msg-1", "type": "AIMessageChunk", "content": "hel"},
{
"langgraph_checkpoint_ns": "tools:call_1",
"langgraph_node": "agent",
},
],
),
StreamPart(
event="messages|tools:call_1",
data=[
{"id": "msg-1", "type": "AIMessageChunk", "content": "lo"},
None,
],
),
StreamPart(
event="values|tools:call_1",
data={"messages": [{"type": "human", "content": "hi"}], "count": 1},
),
StreamPart(
event="values-patch|tools:call_1",
data={"values": {"count": 2}, "deleted_keys": ["messages"]},
),
]
mock_async_client.runs.stream.return_value = async_iter
remote_pregel = RemoteGraph("test_graph_id", client=mock_async_client)
parts = []
async for part in remote_pregel.astream(
{"input": "data"},
config={"configurable": {"thread_id": "thread_1"}},
stream_mode=["messages", "values", "compact"],
subgraphs=True,
version="v2",
):
parts.append(part)
message_parts = [part for part in parts if part["type"] == "messages"]
assert message_parts[0]["data"][1] == {
"langgraph_checkpoint_ns": "tools:call_1",
"langgraph_node": "agent",
}
assert message_parts[1]["data"][1] == {
"langgraph_checkpoint_ns": "tools:call_1",
"langgraph_node": "agent",
}
value_parts = [part for part in parts if part["type"] == "values"]
assert value_parts == [
{
"type": "values",
"ns": ("tools:call_1",),
"data": {"messages": [{"type": "human", "content": "hi"}], "count": 1},
"interrupts": (),
},
{
"type": "values",
"ns": ("tools:call_1",),
"data": {"count": 2},
"interrupts": (),
},
]
_, kwargs = mock_async_client.runs.stream.call_args
assert set(kwargs["stream_mode"]) == {
"messages-tuple",
"values",
"compact",
"updates",
}
@pytest.mark.skip(
"Unskip this test to manually test the LangSmith Deployment integration"
)
+83 -1
View File
@@ -90,6 +90,25 @@ def _make_messages_graph() -> StateGraph[
return builder
def _make_streaming_messages_graph() -> StateGraph[
MessagesState, None, MessagesState, MessagesState
]:
model = FakeChatModel(messages=[AIMessage(content="hello world", id="ai-1")])
def call_model(state: MessagesState) -> dict[str, Any]:
streamed = model.stream(state["messages"])
message = next(streamed)
for chunk in streamed:
message += chunk
return {"messages": message}
builder = StateGraph(MessagesState, input_schema=MessagesState)
builder.add_node("call_model", call_model)
builder.add_edge(START, "call_model")
builder.add_edge("call_model", END)
return builder
def _make_custom_graph() -> Any:
@entrypoint()
def graph(inputs: Any, *, writer: StreamWriter) -> Any:
@@ -164,6 +183,26 @@ class TestV1BackwardsCompat:
ns, _data = chunk
assert isinstance(ns, tuple)
def test_stream_v1_messages_keep_metadata_on_every_chunk(self) -> None:
graph = _make_streaming_messages_graph().compile()
chunks = list(graph.stream(_MSG_INPUT, stream_mode="messages"))
metadata = [meta for _message, meta in chunks]
assert len(metadata) >= 3
assert all(isinstance(meta, dict) for meta in metadata)
def test_stream_v1_messages_compact_dedupes_metadata(self) -> None:
graph = _make_streaming_messages_graph().compile()
chunks = list(graph.stream(_MSG_INPUT, stream_mode=["messages", "compact"]))
metadata = [
meta
for mode, payload in chunks
if mode == "messages"
for _message, meta in [payload]
]
assert len(metadata) >= 3
assert isinstance(metadata[0], dict)
assert all(meta is None for meta in metadata[1:])
# --- v2 sync stream ---
@@ -205,6 +244,26 @@ class TestV2Stream:
assert isinstance(metadata, dict)
assert "langgraph_node" in metadata
def test_messages_streaming_compact_dedupes_metadata(self) -> None:
graph = _make_streaming_messages_graph().compile()
chunks = list(
graph.stream(
_MSG_INPUT,
stream_mode=["messages", "compact"],
version="v2",
)
)
msg_chunks = [c for c in chunks if c["type"] == "messages"]
assert len(msg_chunks) >= 3
first_message, first_metadata = msg_chunks[0]["data"]
assert isinstance(first_message, BaseMessage)
assert isinstance(first_metadata, dict)
assert "langgraph_node" in first_metadata
for chunk in msg_chunks[1:]:
message, metadata = chunk["data"]
assert isinstance(message, BaseMessage)
assert metadata is None
def test_custom(self) -> None:
graph = _make_custom_graph()
chunks = list(graph.stream({"key": "val"}, stream_mode="custom", version="v2"))
@@ -544,6 +603,29 @@ class TestV2StreamAsync:
assert isinstance(metadata, dict)
assert "langgraph_node" in metadata
@NEEDS_CONTEXTVARS
@pytest.mark.anyio
async def test_messages_streaming_compact_dedupes_metadata(self) -> None:
graph = _make_streaming_messages_graph().compile()
chunks = [
c
async for c in graph.astream(
_MSG_INPUT,
stream_mode=["messages", "compact"],
version="v2",
)
]
msg_chunks = [c for c in chunks if c["type"] == "messages"]
assert len(msg_chunks) >= 3
first_message, first_metadata = msg_chunks[0]["data"]
assert isinstance(first_message, BaseMessage)
assert isinstance(first_metadata, dict)
assert "langgraph_node" in first_metadata
for chunk in msg_chunks[1:]:
message, metadata = chunk["data"]
assert isinstance(message, BaseMessage)
assert metadata is None
@NEEDS_CONTEXTVARS
@pytest.mark.anyio
async def test_custom(self) -> None:
@@ -1129,7 +1211,7 @@ _OutputT = TypeVar("_OutputT")
_StateT = TypeVar("_StateT")
def _check_type_narrowing(part: StreamPart[_OutputT, _StateT]) -> None:
def _check_type_narrowing(part: StreamPart[_StateT, _OutputT]) -> None:
"""Compile-time type narrowing checks — never called at runtime."""
if part["type"] == "values":
assert_type(part, ValuesStreamPart[_OutputT])
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+16 -18
View File
@@ -1367,7 +1367,7 @@ wheels = [
[[package]]
name = "langgraph"
version = "1.0.10"
version = "1.1.2"
source = { editable = "." }
dependencies = [
{ name = "langchain-core" },
@@ -1828,16 +1828,16 @@ dev = [
{ name = "pytest-asyncio" },
{ name = "pytest-mock" },
{ name = "pytest-watch" },
{ name = "ruff", specifier = "==0.15.1" },
{ name = "ruff", specifier = "==0.15.5" },
{ name = "starlette" },
{ name = "ty", specifier = "==0.0.17" },
{ name = "ty", specifier = "==0.0.21" },
]
lint = [
{ name = "codespell" },
{ name = "mypy", specifier = "==1.19.1" },
{ name = "ruff", specifier = "==0.15.1" },
{ name = "ruff", specifier = "==0.15.5" },
{ name = "starlette" },
{ name = "ty", specifier = "==0.0.17" },
{ name = "ty", specifier = "==0.0.21" },
]
test = [
{ name = "pytest" },
@@ -3615,21 +3615,19 @@ wheels = [
[[package]]
name = "tornado"
version = "6.5.4"
version = "6.5.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/37/1d/0a336abf618272d53f62ebe274f712e213f5a03c0b2339575430b8362ef2/tornado-6.5.4.tar.gz", hash = "sha256:a22fa9047405d03260b483980635f0b041989d8bcc9a313f8fe18b411d84b1d7", size = 513632, upload-time = "2025-12-15T19:21:03.836Z" }
sdist = { url = "https://files.pythonhosted.org/packages/f8/f1/3173dfa4a18db4a9b03e5d55325559dab51ee653763bb8745a75af491286/tornado-6.5.5.tar.gz", hash = "sha256:192b8f3ea91bd7f1f50c06955416ed76c6b72f96779b962f07f911b91e8d30e9", size = 516006, upload-time = "2026-03-10T21:31:02.067Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ab/a9/e94a9d5224107d7ce3cc1fab8d5dc97f5ea351ccc6322ee4fb661da94e35/tornado-6.5.4-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:d6241c1a16b1c9e4cc28148b1cda97dd1c6cb4fb7068ac1bedc610768dff0ba9", size = 443909, upload-time = "2025-12-15T19:20:48.382Z" },
{ url = "https://files.pythonhosted.org/packages/db/7e/f7b8d8c4453f305a51f80dbb49014257bb7d28ccb4bbb8dd328ea995ecad/tornado-6.5.4-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2d50f63dda1d2cac3ae1fa23d254e16b5e38153758470e9956cbc3d813d40843", size = 442163, upload-time = "2025-12-15T19:20:49.791Z" },
{ url = "https://files.pythonhosted.org/packages/ba/b5/206f82d51e1bfa940ba366a8d2f83904b15942c45a78dd978b599870ab44/tornado-6.5.4-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1cf66105dc6acb5af613c054955b8137e34a03698aa53272dbda4afe252be17", size = 445746, upload-time = "2025-12-15T19:20:51.491Z" },
{ url = "https://files.pythonhosted.org/packages/8e/9d/1a3338e0bd30ada6ad4356c13a0a6c35fbc859063fa7eddb309183364ac1/tornado-6.5.4-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:50ff0a58b0dc97939d29da29cd624da010e7f804746621c78d14b80238669335", size = 445083, upload-time = "2025-12-15T19:20:52.778Z" },
{ url = "https://files.pythonhosted.org/packages/50/d4/e51d52047e7eb9a582da59f32125d17c0482d065afd5d3bc435ff2120dc5/tornado-6.5.4-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5fb5e04efa54cf0baabdd10061eb4148e0be137166146fff835745f59ab9f7f", size = 445315, upload-time = "2025-12-15T19:20:53.996Z" },
{ url = "https://files.pythonhosted.org/packages/27/07/2273972f69ca63dbc139694a3fc4684edec3ea3f9efabf77ed32483b875c/tornado-6.5.4-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9c86b1643b33a4cd415f8d0fe53045f913bf07b4a3ef646b735a6a86047dda84", size = 446003, upload-time = "2025-12-15T19:20:56.101Z" },
{ url = "https://files.pythonhosted.org/packages/d1/83/41c52e47502bf7260044413b6770d1a48dda2f0246f95ee1384a3cd9c44a/tornado-6.5.4-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:6eb82872335a53dd063a4f10917b3efd28270b56a33db69009606a0312660a6f", size = 445412, upload-time = "2025-12-15T19:20:57.398Z" },
{ url = "https://files.pythonhosted.org/packages/10/c7/bc96917f06cbee182d44735d4ecde9c432e25b84f4c2086143013e7b9e52/tornado-6.5.4-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6076d5dda368c9328ff41ab5d9dd3608e695e8225d1cd0fd1e006f05da3635a8", size = 445392, upload-time = "2025-12-15T19:20:58.692Z" },
{ url = "https://files.pythonhosted.org/packages/0c/1a/d7592328d037d36f2d2462f4bc1fbb383eec9278bc786c1b111cbbd44cfa/tornado-6.5.4-cp39-abi3-win32.whl", hash = "sha256:1768110f2411d5cd281bac0a090f707223ce77fd110424361092859e089b38d1", size = 446481, upload-time = "2025-12-15T19:21:00.008Z" },
{ url = "https://files.pythonhosted.org/packages/d6/6d/c69be695a0a64fd37a97db12355a035a6d90f79067a3cf936ec2b1dc38cd/tornado-6.5.4-cp39-abi3-win_amd64.whl", hash = "sha256:fa07d31e0cd85c60713f2b995da613588aa03e1303d75705dca6af8babc18ddc", size = 446886, upload-time = "2025-12-15T19:21:01.287Z" },
{ url = "https://files.pythonhosted.org/packages/50/49/8dc3fd90902f70084bd2cd059d576ddb4f8bb44c2c7c0e33a11422acb17e/tornado-6.5.4-cp39-abi3-win_arm64.whl", hash = "sha256:053e6e16701eb6cbe641f308f4c1a9541f91b6261991160391bfc342e8a551a1", size = 445910, upload-time = "2025-12-15T19:21:02.571Z" },
{ url = "https://files.pythonhosted.org/packages/59/8c/77f5097695f4dd8255ecbd08b2a1ed8ba8b953d337804dd7080f199e12bf/tornado-6.5.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:487dc9cc380e29f58c7ab88f9e27cdeef04b2140862e5076a66fb6bb68bb1bfa", size = 445983, upload-time = "2026-03-10T21:30:44.28Z" },
{ url = "https://files.pythonhosted.org/packages/ab/5e/7625b76cd10f98f1516c36ce0346de62061156352353ef2da44e5c21523c/tornado-6.5.5-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:65a7f1d46d4bb41df1ac99f5fcb685fb25c7e61613742d5108b010975a9a6521", size = 444246, upload-time = "2026-03-10T21:30:46.571Z" },
{ url = "https://files.pythonhosted.org/packages/b2/04/7b5705d5b3c0fab088f434f9c83edac1573830ca49ccf29fb83bf7178eec/tornado-6.5.5-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e74c92e8e65086b338fd56333fb9a68b9f6f2fe7ad532645a290a464bcf46be5", size = 447229, upload-time = "2026-03-10T21:30:48.273Z" },
{ url = "https://files.pythonhosted.org/packages/34/01/74e034a30ef59afb4097ef8659515e96a39d910b712a89af76f5e4e1f93c/tornado-6.5.5-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:435319e9e340276428bbdb4e7fa732c2d399386d1de5686cb331ec8eee754f07", size = 448192, upload-time = "2026-03-10T21:30:51.22Z" },
{ url = "https://files.pythonhosted.org/packages/be/00/fe9e02c5a96429fce1a1d15a517f5d8444f9c412e0bb9eadfbe3b0fc55bf/tornado-6.5.5-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3f54aa540bdbfee7b9eb268ead60e7d199de5021facd276819c193c0fb28ea4e", size = 448039, upload-time = "2026-03-10T21:30:53.52Z" },
{ url = "https://files.pythonhosted.org/packages/82/9e/656ee4cec0398b1d18d0f1eb6372c41c6b889722641d84948351ae19556d/tornado-6.5.5-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:36abed1754faeb80fbd6e64db2758091e1320f6bba74a4cf8c09cd18ccce8aca", size = 447445, upload-time = "2026-03-10T21:30:55.541Z" },
{ url = "https://files.pythonhosted.org/packages/5a/76/4921c00511f88af86a33de770d64141170f1cfd9c00311aea689949e274e/tornado-6.5.5-cp39-abi3-win32.whl", hash = "sha256:dd3eafaaeec1c7f2f8fdcd5f964e8907ad788fe8a5a32c4426fbbdda621223b7", size = 448582, upload-time = "2026-03-10T21:30:57.142Z" },
{ url = "https://files.pythonhosted.org/packages/2c/23/f6c6112a04d28eed765e374435fb1a9198f73e1ec4b4024184f21faeb1ad/tornado-6.5.5-cp39-abi3-win_amd64.whl", hash = "sha256:6443a794ba961a9f619b1ae926a2e900ac20c34483eea67be4ed8f1e58d3ef7b", size = 448990, upload-time = "2026-03-10T21:30:58.857Z" },
{ url = "https://files.pythonhosted.org/packages/b7/c8/876602cbc96469911f0939f703453c1157b0c826ecb05bdd32e023397d4e/tornado-6.5.5-cp39-abi3-win_arm64.whl", hash = "sha256:2c9a876e094109333f888539ddb2de4361743e5d21eece20688e3e351e4990a6", size = 448016, upload-time = "2026-03-10T21:31:00.43Z" },
]
[[package]]
+5 -5
View File
@@ -268,7 +268,7 @@ wheels = [
[[package]]
name = "langgraph"
version = "1.0.10"
version = "1.1.2"
source = { editable = "../langgraph" }
dependencies = [
{ name = "langchain-core" },
@@ -599,16 +599,16 @@ dev = [
{ name = "pytest-asyncio" },
{ name = "pytest-mock" },
{ name = "pytest-watch" },
{ name = "ruff", specifier = "==0.15.1" },
{ name = "ruff", specifier = "==0.15.5" },
{ name = "starlette" },
{ name = "ty", specifier = "==0.0.17" },
{ name = "ty", specifier = "==0.0.21" },
]
lint = [
{ name = "codespell" },
{ name = "mypy", specifier = "==1.19.1" },
{ name = "ruff", specifier = "==0.15.1" },
{ name = "ruff", specifier = "==0.15.5" },
{ name = "starlette" },
{ name = "ty", specifier = "==0.0.17" },
{ name = "ty", specifier = "==0.0.21" },
]
test = [
{ name = "pytest" },
+1 -1
View File
@@ -3,6 +3,6 @@ from langgraph_sdk.client import get_client, get_sync_client
from langgraph_sdk.encryption import Encryption
from langgraph_sdk.encryption.types import EncryptionContext
__version__ = "0.3.10"
__version__ = "0.3.11"
__all__ = ["Auth", "Encryption", "EncryptionContext", "get_client", "get_sync_client"]
+15 -5
View File
@@ -4,10 +4,11 @@ from __future__ import annotations
import warnings
from collections.abc import Mapping, Sequence
from datetime import datetime
from datetime import datetime, tzinfo
from typing import Any
from langgraph_sdk._async.http import HttpClient
from langgraph_sdk._shared.utilities import _resolve_timezone
from langgraph_sdk.schema import (
All,
Config,
@@ -70,6 +71,7 @@ class CronClient:
multitask_strategy: str | None = None,
end_time: datetime | None = None,
enabled: bool | None = None,
timezone: str | tzinfo | None = None,
stream_mode: StreamMode | Sequence[StreamMode] | None = None,
stream_subgraphs: bool | None = None,
stream_resumable: bool | None = None,
@@ -84,7 +86,7 @@ class CronClient:
assistant_id: The assistant ID or graph name to use for the cron job.
If using graph name, will default to first assistant created from that graph.
schedule: The cron schedule to execute this job on.
Schedules are interpreted in UTC.
Schedules are interpreted in UTC unless a timezone is specified.
input: The input to the graph.
metadata: Metadata to assign to the cron job runs.
config: The configuration for the assistant.
@@ -100,6 +102,7 @@ class CronClient:
Must be one of 'reject', 'interrupt', 'rollback', or 'enqueue'.
end_time: The time to stop running the cron job. If not provided, the cron job will run indefinitely.
enabled: Whether the cron job is enabled or not.
timezone: IANA timezone for the cron schedule. Accepts a string (e.g. 'America/New_York') or a ``datetime.tzinfo`` instance (e.g. ``ZoneInfo("America/New_York")``).
stream_mode: The stream mode(s) to use.
stream_subgraphs: Whether to stream output from subgraphs.
stream_resumable: Whether to persist the stream chunks in order to resume the stream later.
@@ -152,6 +155,7 @@ class CronClient:
"webhook": webhook,
"end_time": end_time.isoformat() if end_time else None,
"enabled": enabled,
"timezone": _resolve_timezone(timezone),
"stream_mode": stream_mode,
"stream_subgraphs": stream_subgraphs,
"stream_resumable": stream_resumable,
@@ -184,6 +188,7 @@ class CronClient:
multitask_strategy: str | None = None,
end_time: datetime | None = None,
enabled: bool | None = None,
timezone: str | tzinfo | None = None,
stream_mode: StreamMode | Sequence[StreamMode] | None = None,
stream_subgraphs: bool | None = None,
stream_resumable: bool | None = None,
@@ -197,7 +202,7 @@ class CronClient:
assistant_id: The assistant ID or graph name to use for the cron job.
If using graph name, will default to first assistant created from that graph.
schedule: The cron schedule to execute this job on.
Schedules are interpreted in UTC.
Schedules are interpreted in UTC unless a timezone is specified.
input: The input to the graph.
metadata: Metadata to assign to the cron job runs.
config: The configuration for the assistant.
@@ -215,6 +220,7 @@ class CronClient:
Must be one of 'reject', 'interrupt', 'rollback', or 'enqueue'.
end_time: The time to stop running the cron job. If not provided, the cron job will run indefinitely.
enabled: Whether the cron job is enabled or not.
timezone: IANA timezone for the cron schedule. Accepts a string (e.g. 'America/New_York') or a ``datetime.tzinfo`` instance (e.g. ``ZoneInfo("America/New_York")``).
stream_mode: The stream mode(s) to use.
stream_subgraphs: Whether to stream output from subgraphs.
stream_resumable: Whether to persist the stream chunks in order to resume the stream later.
@@ -268,6 +274,7 @@ class CronClient:
"on_run_completed": on_run_completed,
"end_time": end_time.isoformat() if end_time else None,
"enabled": enabled,
"timezone": _resolve_timezone(timezone),
"stream_mode": stream_mode,
"stream_subgraphs": stream_subgraphs,
"stream_resumable": stream_resumable,
@@ -324,6 +331,7 @@ class CronClient:
interrupt_after: All | list[str] | None = None,
on_run_completed: OnCompletionBehavior | None = None,
enabled: bool | None = None,
timezone: str | tzinfo | None = None,
stream_mode: StreamMode | Sequence[StreamMode] | None = None,
stream_subgraphs: bool | None = None,
stream_resumable: bool | None = None,
@@ -336,7 +344,7 @@ class CronClient:
Args:
cron_id: The cron ID to update.
schedule: The cron schedule to execute this job on.
Schedules are interpreted in UTC.
Schedules are interpreted in UTC unless a timezone is specified.
end_time: The end date to stop running the cron.
input: The input to the graph.
metadata: Metadata to assign to the cron job runs.
@@ -350,6 +358,7 @@ class CronClient:
after execution. 'keep' creates a new thread for each execution but does not
clean them up.
enabled: Enable or disable the cron job.
timezone: IANA timezone for the cron schedule. Accepts a string (e.g. 'America/New_York') or a ``datetime.tzinfo`` instance (e.g. ``ZoneInfo("America/New_York")``).
stream_mode: The stream mode(s) to use.
stream_subgraphs: Whether to stream output from subgraphs.
stream_resumable: Whether to persist the stream chunks in order to resume the stream later.
@@ -384,6 +393,7 @@ class CronClient:
"interrupt_after": interrupt_after,
"on_run_completed": on_run_completed,
"enabled": enabled,
"timezone": _resolve_timezone(timezone),
"stream_mode": stream_mode,
"stream_subgraphs": stream_subgraphs,
"stream_resumable": stream_resumable,
@@ -464,7 +474,7 @@ class CronClient:
```
"""
payload = {
payload: dict[str, Any] = {
"assistant_id": assistant_id,
"thread_id": thread_id,
"enabled": enabled,
+1 -1
View File
@@ -134,7 +134,7 @@ class StoreClient:
raise ValueError(
f"Invalid namespace label '{label}'. Namespace labels cannot contain periods ('.')."
)
get_params = {"namespace": ".".join(namespace), "key": key}
get_params: dict[str, Any] = {"namespace": ".".join(namespace), "key": key}
if refresh_ttl is not None:
get_params["refresh_ttl"] = refresh_ttl
if params:
+34 -1
View File
@@ -6,13 +6,17 @@ import functools
import os
import re
from collections.abc import Mapping
from typing import Any, cast
from datetime import tzinfo
from typing import TYPE_CHECKING, Any, cast
import httpx
import langgraph_sdk
from langgraph_sdk.schema import RunCreateMetadata
if TYPE_CHECKING:
from zoneinfo import ZoneInfo
RESERVED_HEADERS = ("x-api-key",)
NOT_PROVIDED = cast(None, object())
@@ -125,6 +129,35 @@ def _sse_to_v2_dict(event: str, data: Any) -> dict[str, Any] | None:
return result
def _resolve_timezone(tz: str | tzinfo | ZoneInfo | None) -> str | None:
"""Convert a timezone argument to an IANA timezone string.
Accepts:
- A string (returned as-is, assumed to be an IANA timezone name)
- A ``datetime.tzinfo`` instance (e.g. ``zoneinfo.ZoneInfo("America/New_York")``,
``datetime.timezone.utc``). The ``key`` attribute is used if available,
otherwise ``tzname(None)`` is used.
- ``None`` (returned as ``None``)
"""
if tz is None or isinstance(tz, str):
return tz
if isinstance(tz, tzinfo):
# ZoneInfo objects have a .key attribute with the IANA name
if hasattr(tz, "key"):
return tz.key # type: ignore[union-attr]
# Fall back to tzname for fixed-offset timezones like datetime.timezone.utc
name = tz.tzname(None)
if name is not None:
return name
raise ValueError(
f"Cannot determine timezone name from {tz!r}. "
"Use a zoneinfo.ZoneInfo instance or pass a string like 'America/New_York'."
)
raise TypeError(
f"Expected str, datetime.tzinfo, or None for timezone, got {type(tz).__name__}"
)
def _provided_vals(d: Mapping[str, Any]) -> dict[str, Any]:
return {k: v for k, v in d.items() if v is not None}
+15 -5
View File
@@ -4,9 +4,10 @@ from __future__ import annotations
import warnings
from collections.abc import Mapping, Sequence
from datetime import datetime
from datetime import datetime, tzinfo
from typing import Any
from langgraph_sdk._shared.utilities import _resolve_timezone
from langgraph_sdk._sync.http import SyncHttpClient
from langgraph_sdk.schema import (
All,
@@ -64,6 +65,7 @@ class SyncCronClient:
multitask_strategy: str | None = None,
end_time: datetime | None = None,
enabled: bool | None = None,
timezone: str | tzinfo | None = None,
stream_mode: StreamMode | Sequence[StreamMode] | None = None,
stream_subgraphs: bool | None = None,
stream_resumable: bool | None = None,
@@ -78,7 +80,7 @@ class SyncCronClient:
assistant_id: The assistant ID or graph name to use for the cron job.
If using graph name, will default to first assistant created from that graph.
schedule: The cron schedule to execute this job on.
Schedules are interpreted in UTC.
Schedules are interpreted in UTC unless a timezone is specified.
input: The input to the graph.
metadata: Metadata to assign to the cron job runs.
config: The configuration for the assistant.
@@ -92,6 +94,7 @@ class SyncCronClient:
Must be one of 'reject', 'interrupt', 'rollback', or 'enqueue'.
end_time: The time to stop running the cron job. If not provided, the cron job will run indefinitely.
enabled: Whether the cron job is enabled. By default, it is considered enabled.
timezone: IANA timezone for the cron schedule. Accepts a string (e.g. 'America/New_York') or a ``datetime.tzinfo`` instance (e.g. ``ZoneInfo("America/New_York")``).
stream_mode: The stream mode(s) to use.
stream_subgraphs: Whether to stream output from subgraphs.
stream_resumable: Whether to persist the stream chunks in order to resume the stream later.
@@ -144,6 +147,7 @@ class SyncCronClient:
"multitask_strategy": multitask_strategy,
"end_time": end_time.isoformat() if end_time else None,
"enabled": enabled,
"timezone": _resolve_timezone(timezone),
"stream_mode": stream_mode,
"stream_subgraphs": stream_subgraphs,
"stream_resumable": stream_resumable,
@@ -174,6 +178,7 @@ class SyncCronClient:
multitask_strategy: str | None = None,
end_time: datetime | None = None,
enabled: bool | None = None,
timezone: str | tzinfo | None = None,
stream_mode: StreamMode | Sequence[StreamMode] | None = None,
stream_subgraphs: bool | None = None,
stream_resumable: bool | None = None,
@@ -187,7 +192,7 @@ class SyncCronClient:
assistant_id: The assistant ID or graph name to use for the cron job.
If using graph name, will default to first assistant created from that graph.
schedule: The cron schedule to execute this job on.
Schedules are interpreted in UTC.
Schedules are interpreted in UTC unless a timezone is specified.
input: The input to the graph.
metadata: Metadata to assign to the cron job runs.
config: The configuration for the assistant.
@@ -205,6 +210,7 @@ class SyncCronClient:
Must be one of 'reject', 'interrupt', 'rollback', or 'enqueue'.
end_time: The time to stop running the cron job. If not provided, the cron job will run indefinitely.
enabled: Whether the cron job is enabled. By default, it is considered enabled.
timezone: IANA timezone for the cron schedule. Accepts a string (e.g. 'America/New_York') or a ``datetime.tzinfo`` instance (e.g. ``ZoneInfo("America/New_York")``).
stream_mode: The stream mode(s) to use.
stream_subgraphs: Whether to stream output from subgraphs.
stream_resumable: Whether to persist the stream chunks in order to resume the stream later.
@@ -259,6 +265,7 @@ class SyncCronClient:
"multitask_strategy": multitask_strategy,
"end_time": end_time.isoformat() if end_time else None,
"enabled": enabled,
"timezone": _resolve_timezone(timezone),
"stream_mode": stream_mode,
"stream_subgraphs": stream_subgraphs,
"stream_resumable": stream_resumable,
@@ -313,6 +320,7 @@ class SyncCronClient:
interrupt_after: All | list[str] | None = None,
on_run_completed: OnCompletionBehavior | None = None,
enabled: bool | None = None,
timezone: str | tzinfo | None = None,
stream_mode: StreamMode | Sequence[StreamMode] | None = None,
stream_subgraphs: bool | None = None,
stream_resumable: bool | None = None,
@@ -325,7 +333,7 @@ class SyncCronClient:
Args:
cron_id: The cron ID to update.
schedule: The cron schedule to execute this job on.
Schedules are interpreted in UTC.
Schedules are interpreted in UTC unless a timezone is specified.
end_time: The end date to stop running the cron.
input: The input to the graph.
metadata: Metadata to assign to the cron job runs.
@@ -339,6 +347,7 @@ class SyncCronClient:
after execution. 'keep' creates a new thread for each execution but does not
clean them up.
enabled: Enable or disable the cron job.
timezone: IANA timezone for the cron schedule. Accepts a string (e.g. 'America/New_York') or a ``datetime.tzinfo`` instance (e.g. ``ZoneInfo("America/New_York")``).
stream_mode: The stream mode(s) to use.
stream_subgraphs: Whether to stream output from subgraphs.
stream_resumable: Whether to persist the stream chunks in order to resume the stream later.
@@ -373,6 +382,7 @@ class SyncCronClient:
"interrupt_after": interrupt_after,
"on_run_completed": on_run_completed,
"enabled": enabled,
"timezone": _resolve_timezone(timezone),
"stream_mode": stream_mode,
"stream_subgraphs": stream_subgraphs,
"stream_resumable": stream_resumable,
@@ -451,7 +461,7 @@ class SyncCronClient:
]
```
"""
payload = {
payload: dict[str, Any] = {
"assistant_id": assistant_id,
"thread_id": thread_id,
"enabled": enabled,
+1 -1
View File
@@ -134,7 +134,7 @@ class SyncStoreClient:
f"Invalid namespace label '{label}'. Namespace labels cannot contain periods ('.')."
)
query_params = {"key": key, "namespace": ".".join(namespace)}
query_params: dict[str, Any] = {"key": key, "namespace": ".".join(namespace)}
if refresh_ttl is not None:
query_params["refresh_ttl"] = refresh_ttl
if params:
+28
View File
@@ -58,6 +58,7 @@ StreamMode = Literal[
"debug",
"custom",
"messages-tuple",
"compact",
]
"""
Defines the mode of streaming:
@@ -69,6 +70,7 @@ Defines the mode of streaming:
- "tasks": Stream task start and finish events.
- "debug": Stream detailed debug information.
- "custom": Stream custom events.
- "compact": Enable compact streaming payloads for other selected modes.
"""
DisconnectMode = Literal["cancel", "continue"]
@@ -385,6 +387,8 @@ class Cron(TypedDict):
"""The end date to stop running the cron."""
schedule: str
"""The schedule to run, cron format."""
timezone: str | None
"""IANA timezone for the cron schedule (e.g. 'America/New_York'). Defaults to null, which is treated as UTC."""
created_at: datetime
"""The time the cron was created."""
updated_at: datetime
@@ -406,6 +410,8 @@ class CronUpdate(TypedDict, total=False):
schedule: str
"""The cron schedule to execute this job on."""
timezone: str
"""IANA timezone for the cron schedule (e.g. 'America/New_York')."""
end_time: datetime
"""The end date to stop running the cron."""
input: Input
@@ -482,6 +488,7 @@ CronSelectField = Literal[
"thread_id",
"end_time",
"schedule",
"timezone",
"created_at",
"updated_at",
"user_id",
@@ -728,6 +735,26 @@ class ValuesStreamPart(TypedDict):
"""List of interrupts that occurred during this step."""
class ValuesPatchPayload(TypedDict):
"""Incremental patch payload for subgraph `values` updates."""
values: dict[str, Any]
"""Only the changed fields since the previous `values` event for this namespace."""
deleted_keys: NotRequired[list[str]]
"""Optional list of keys that were removed from the previous values snapshot."""
class ValuesPatchStreamPart(TypedDict):
"""Stream part emitted for incremental subgraph value patches (`values-patch`)."""
type: Literal["values-patch"]
"""Stream part type discriminator."""
ns: list[str]
"""Namespace path of the emitting node (empty for root graph)."""
data: ValuesPatchPayload
"""Incremental state patch for the namespace."""
class UpdatesStreamPart(TypedDict):
"""Stream part emitted for `stream_mode="updates"`."""
@@ -840,6 +867,7 @@ class MetadataStreamPart(TypedDict):
StreamPartV2 = (
ValuesStreamPart
| ValuesPatchStreamPart
| UpdatesStreamPart
| MessagesPartialStreamPart
| MessagesCompleteStreamPart
+2 -2
View File
@@ -30,10 +30,10 @@ test = [
"pytest-watch",
]
lint = [
"ruff==0.15.1",
"ruff==0.15.5",
"codespell",
"mypy==1.19.1",
"ty==0.0.17",
"ty==0.0.21",
"starlette",
]
dev = [
+81
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import json
from collections.abc import Iterator, Sequence
from pathlib import Path
from typing import Any
@@ -8,7 +9,9 @@ import httpx
import pytest
from typing_extensions import assert_type
from langgraph_sdk._async.runs import RunsClient
from langgraph_sdk._shared.utilities import _sse_to_v2_dict
from langgraph_sdk._sync.runs import SyncRunsClient
from langgraph_sdk.client import HttpClient, SyncHttpClient
from langgraph_sdk.schema import (
CheckpointPayload,
@@ -24,6 +27,7 @@ from langgraph_sdk.schema import (
TaskResultPayload,
TasksStreamPart,
UpdatesStreamPart,
ValuesPatchStreamPart,
ValuesStreamPart,
)
from langgraph_sdk.sse import BytesLike, BytesLineDecoder, SSEDecoder
@@ -375,6 +379,81 @@ def test_sse_to_v2_dict_values_with_interrupts() -> None:
assert "__interrupt__" not in result["data"]
def test_sse_to_v2_dict_values_patch() -> None:
payload = {"values": {"count": 2}, "deleted_keys": ["stale"]}
result = _sse_to_v2_dict("values-patch|tools:call_1", payload)
assert result is not None
_assert_v2_shape(result)
assert result == {
"type": "values-patch",
"ns": ["tools:call_1"],
"data": {"values": {"count": 2}, "deleted_keys": ["stale"]},
"interrupts": [],
}
@pytest.mark.asyncio
async def test_async_runs_stream_includes_compact_mode():
async def handler(request: httpx.Request) -> httpx.Response:
assert request.method == "POST"
assert request.url.path == "/runs/stream"
body = json.loads(request.content)
assert body["stream_mode"] == ["values", "compact"]
return httpx.Response(
200,
headers={"Content-Type": "text/event-stream"},
content=b"event: end\ndata: null\n\n",
)
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(
transport=transport, base_url="https://example.com"
) as client:
runs_client = RunsClient(HttpClient(client))
parts = [
part
async for part in runs_client.stream(
thread_id=None,
assistant_id="agent",
input={"messages": []},
stream_mode=["values", "compact"],
)
]
assert len(parts) == 1
assert parts[0].event == "end"
assert parts[0].data is None
def test_sync_runs_stream_includes_compact_mode():
def handler(request: httpx.Request) -> httpx.Response:
assert request.method == "POST"
assert request.url.path == "/runs/stream"
body = json.loads(request.content)
assert body["stream_mode"] == ["values", "compact"]
return httpx.Response(
200,
headers={"Content-Type": "text/event-stream"},
content=b"event: end\ndata: null\n\n",
)
transport = httpx.MockTransport(handler)
with httpx.Client(transport=transport, base_url="https://example.com") as client:
runs_client = SyncRunsClient(SyncHttpClient(client))
parts = list(
runs_client.stream(
thread_id=None,
assistant_id="agent",
input={"messages": []},
stream_mode=["values", "compact"],
)
)
assert len(parts) == 1
assert parts[0].event == "end"
assert parts[0].data is None
# --- client-side v2 stream wrapping ---
@@ -448,6 +527,8 @@ def _check_v2_type_narrowing(part: StreamPartV2) -> None:
if part["type"] == "values":
assert_type(part, ValuesStreamPart)
assert_type(part["data"], dict[str, Any])
elif part["type"] == "values-patch":
assert_type(part, ValuesPatchStreamPart)
elif part["type"] == "updates":
assert_type(part, UpdatesStreamPart)
assert_type(part["data"], dict[str, Any])
+45 -45
View File
@@ -134,11 +134,11 @@ wheels = [
[[package]]
name = "codespell"
version = "2.4.1"
version = "2.4.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/15/e0/709453393c0ea77d007d907dd436b3ee262e28b30995ea1aa36c6ffbccaf/codespell-2.4.1.tar.gz", hash = "sha256:299fcdcb09d23e81e35a671bbe746d5ad7e8385972e65dbb833a2eaac33c01e5", size = 344740, upload-time = "2025-01-28T18:52:39.411Z" }
sdist = { url = "https://files.pythonhosted.org/packages/2d/9d/1d0903dff693160f893ca6abcabad545088e7a2ee0a6deae7c24e958be69/codespell-2.4.2.tar.gz", hash = "sha256:3c33be9ae34543807f088aeb4832dfad8cb2dae38da61cac0a7045dd376cfdf3", size = 352058, upload-time = "2026-03-05T18:10:42.936Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/20/01/b394922252051e97aab231d416c86da3d8a6d781eeadcdca1082867de64e/codespell-2.4.1-py3-none-any.whl", hash = "sha256:3dadafa67df7e4a3dbf51e0d7315061b80d265f9552ebd699b3dd6834b47e425", size = 344501, upload-time = "2025-01-28T18:52:37.057Z" },
{ url = "https://files.pythonhosted.org/packages/42/a1/52fa05533e95fe45bcc09bcf8a503874b1c08f221a4e35608017e0938f55/codespell-2.4.2-py3-none-any.whl", hash = "sha256:97e0c1060cf46bd1d5db89a936c98db8c2b804e1fdd4b5c645e82a1ec6b1f886", size = 353715, upload-time = "2026-03-05T18:10:41.398Z" },
]
[[package]]
@@ -265,7 +265,7 @@ wheels = [
[[package]]
name = "langgraph"
version = "1.0.10"
version = "1.1.2"
source = { editable = "../langgraph" }
dependencies = [
{ name = "langchain-core" },
@@ -498,16 +498,16 @@ dev = [
{ name = "pytest-asyncio" },
{ name = "pytest-mock" },
{ name = "pytest-watch" },
{ name = "ruff", specifier = "==0.15.1" },
{ name = "ruff", specifier = "==0.15.5" },
{ name = "starlette" },
{ name = "ty", specifier = "==0.0.17" },
{ name = "ty", specifier = "==0.0.21" },
]
lint = [
{ name = "codespell" },
{ name = "mypy", specifier = "==1.19.1" },
{ name = "ruff", specifier = "==0.15.1" },
{ name = "ruff", specifier = "==0.15.5" },
{ name = "starlette" },
{ name = "ty", specifier = "==0.0.17" },
{ name = "ty", specifier = "==0.0.21" },
]
test = [
{ name = "pytest" },
@@ -1119,27 +1119,27 @@ wheels = [
[[package]]
name = "ruff"
version = "0.15.1"
version = "0.15.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/04/dc/4e6ac71b511b141cf626357a3946679abeba4cf67bc7cc5a17920f31e10d/ruff-0.15.1.tar.gz", hash = "sha256:c590fe13fb57c97141ae975c03a1aedb3d3156030cabd740d6ff0b0d601e203f", size = 4540855, upload-time = "2026-02-12T23:09:09.998Z" }
sdist = { url = "https://files.pythonhosted.org/packages/77/9b/840e0039e65fcf12758adf684d2289024d6140cde9268cc59887dc55189c/ruff-0.15.5.tar.gz", hash = "sha256:7c3601d3b6d76dce18c5c824fc8d06f4eef33d6df0c21ec7799510cde0f159a2", size = 4574214, upload-time = "2026-03-05T20:06:34.946Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/23/bf/e6e4324238c17f9d9120a9d60aa99a7daaa21204c07fcd84e2ef03bb5fd1/ruff-0.15.1-py3-none-linux_armv6l.whl", hash = "sha256:b101ed7cf4615bda6ffe65bdb59f964e9f4a0d3f85cbf0e54f0ab76d7b90228a", size = 10367819, upload-time = "2026-02-12T23:09:03.598Z" },
{ url = "https://files.pythonhosted.org/packages/b3/ea/c8f89d32e7912269d38c58f3649e453ac32c528f93bb7f4219258be2e7ed/ruff-0.15.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:939c995e9277e63ea632cc8d3fae17aa758526f49a9a850d2e7e758bfef46602", size = 10798618, upload-time = "2026-02-12T23:09:22.928Z" },
{ url = "https://files.pythonhosted.org/packages/5e/0f/1d0d88bc862624247d82c20c10d4c0f6bb2f346559d8af281674cf327f15/ruff-0.15.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1d83466455fdefe60b8d9c8df81d3c1bbb2115cede53549d3b522ce2bc703899", size = 10148518, upload-time = "2026-02-12T23:08:58.339Z" },
{ url = "https://files.pythonhosted.org/packages/f5/c8/291c49cefaa4a9248e986256df2ade7add79388fe179e0691be06fae6f37/ruff-0.15.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9457e3c3291024866222b96108ab2d8265b477e5b1534c7ddb1810904858d16", size = 10518811, upload-time = "2026-02-12T23:09:31.865Z" },
{ url = "https://files.pythonhosted.org/packages/c3/1a/f5707440e5ae43ffa5365cac8bbb91e9665f4a883f560893829cf16a606b/ruff-0.15.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:92c92b003e9d4f7fbd33b1867bb15a1b785b1735069108dfc23821ba045b29bc", size = 10196169, upload-time = "2026-02-12T23:09:17.306Z" },
{ url = "https://files.pythonhosted.org/packages/2a/ff/26ddc8c4da04c8fd3ee65a89c9fb99eaa5c30394269d424461467be2271f/ruff-0.15.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fe5c41ab43e3a06778844c586251eb5a510f67125427625f9eb2b9526535779", size = 10990491, upload-time = "2026-02-12T23:09:25.503Z" },
{ url = "https://files.pythonhosted.org/packages/fc/00/50920cb385b89413f7cdb4bb9bc8fc59c1b0f30028d8bccc294189a54955/ruff-0.15.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66a6dd6df4d80dc382c6484f8ce1bcceb55c32e9f27a8b94c32f6c7331bf14fb", size = 11843280, upload-time = "2026-02-12T23:09:19.88Z" },
{ url = "https://files.pythonhosted.org/packages/5d/6d/2f5cad8380caf5632a15460c323ae326f1e1a2b5b90a6ee7519017a017ca/ruff-0.15.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6a4a42cbb8af0bda9bcd7606b064d7c0bc311a88d141d02f78920be6acb5aa83", size = 11274336, upload-time = "2026-02-12T23:09:14.907Z" },
{ url = "https://files.pythonhosted.org/packages/a3/1d/5f56cae1d6c40b8a318513599b35ea4b075d7dc1cd1d04449578c29d1d75/ruff-0.15.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ab064052c31dddada35079901592dfba2e05f5b1e43af3954aafcbc1096a5b2", size = 11137288, upload-time = "2026-02-12T23:09:07.475Z" },
{ url = "https://files.pythonhosted.org/packages/cd/20/6f8d7d8f768c93b0382b33b9306b3b999918816da46537d5a61635514635/ruff-0.15.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5631c940fe9fe91f817a4c2ea4e81f47bee3ca4aa646134a24374f3c19ad9454", size = 11070681, upload-time = "2026-02-12T23:08:55.43Z" },
{ url = "https://files.pythonhosted.org/packages/9a/67/d640ac76069f64cdea59dba02af2e00b1fa30e2103c7f8d049c0cff4cafd/ruff-0.15.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:68138a4ba184b4691ccdc39f7795c66b3c68160c586519e7e8444cf5a53e1b4c", size = 10486401, upload-time = "2026-02-12T23:09:27.927Z" },
{ url = "https://files.pythonhosted.org/packages/65/3d/e1429f64a3ff89297497916b88c32a5cc88eeca7e9c787072d0e7f1d3e1e/ruff-0.15.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:518f9af03bfc33c03bdb4cb63fabc935341bb7f54af500f92ac309ecfbba6330", size = 10197452, upload-time = "2026-02-12T23:09:12.147Z" },
{ url = "https://files.pythonhosted.org/packages/78/83/e2c3bade17dad63bf1e1c2ffaf11490603b760be149e1419b07049b36ef2/ruff-0.15.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:da79f4d6a826caaea95de0237a67e33b81e6ec2e25fc7e1993a4015dffca7c61", size = 10693900, upload-time = "2026-02-12T23:09:34.418Z" },
{ url = "https://files.pythonhosted.org/packages/a1/27/fdc0e11a813e6338e0706e8b39bb7a1d61ea5b36873b351acee7e524a72a/ruff-0.15.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3dd86dccb83cd7d4dcfac303ffc277e6048600dfc22e38158afa208e8bf94a1f", size = 11227302, upload-time = "2026-02-12T23:09:36.536Z" },
{ url = "https://files.pythonhosted.org/packages/f6/58/ac864a75067dcbd3b95be5ab4eb2b601d7fbc3d3d736a27e391a4f92a5c1/ruff-0.15.1-py3-none-win32.whl", hash = "sha256:660975d9cb49b5d5278b12b03bb9951d554543a90b74ed5d366b20e2c57c2098", size = 10462555, upload-time = "2026-02-12T23:09:29.899Z" },
{ url = "https://files.pythonhosted.org/packages/e0/5e/d4ccc8a27ecdb78116feac4935dfc39d1304536f4296168f91ed3ec00cd2/ruff-0.15.1-py3-none-win_amd64.whl", hash = "sha256:c820fef9dd5d4172a6570e5721704a96c6679b80cf7be41659ed439653f62336", size = 11599956, upload-time = "2026-02-12T23:09:01.157Z" },
{ url = "https://files.pythonhosted.org/packages/2a/07/5bda6a85b220c64c65686bc85bd0bbb23b29c62b3a9f9433fa55f17cda93/ruff-0.15.1-py3-none-win_arm64.whl", hash = "sha256:5ff7d5f0f88567850f45081fac8f4ec212be8d0b963e385c3f7d0d2eb4899416", size = 10874604, upload-time = "2026-02-12T23:09:05.515Z" },
{ url = "https://files.pythonhosted.org/packages/47/20/5369c3ce21588c708bcbe517a8fbe1a8dfdb5dfd5137e14790b1da71612c/ruff-0.15.5-py3-none-linux_armv6l.whl", hash = "sha256:4ae44c42281f42e3b06b988e442d344a5b9b72450ff3c892e30d11b29a96a57c", size = 10478185, upload-time = "2026-03-05T20:06:29.093Z" },
{ url = "https://files.pythonhosted.org/packages/44/ed/e81dd668547da281e5dce710cf0bc60193f8d3d43833e8241d006720e42b/ruff-0.15.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6edd3792d408ebcf61adabc01822da687579a1a023f297618ac27a5b51ef0080", size = 10859201, upload-time = "2026-03-05T20:06:32.632Z" },
{ url = "https://files.pythonhosted.org/packages/c4/8f/533075f00aaf19b07c5cd6aa6e5d89424b06b3b3f4583bfa9c640a079059/ruff-0.15.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:89f463f7c8205a9f8dea9d658d59eff49db05f88f89cc3047fb1a02d9f344010", size = 10184752, upload-time = "2026-03-05T20:06:40.312Z" },
{ url = "https://files.pythonhosted.org/packages/66/0e/ba49e2c3fa0395b3152bad634c7432f7edfc509c133b8f4529053ff024fb/ruff-0.15.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba786a8295c6574c1116704cf0b9e6563de3432ac888d8f83685654fe528fd65", size = 10534857, upload-time = "2026-03-05T20:06:19.581Z" },
{ url = "https://files.pythonhosted.org/packages/59/71/39234440f27a226475a0659561adb0d784b4d247dfe7f43ffc12dd02e288/ruff-0.15.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd4b801e57955fe9f02b31d20375ab3a5c4415f2e5105b79fb94cf2642c91440", size = 10309120, upload-time = "2026-03-05T20:06:00.435Z" },
{ url = "https://files.pythonhosted.org/packages/f5/87/4140aa86a93df032156982b726f4952aaec4a883bb98cb6ef73c347da253/ruff-0.15.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:391f7c73388f3d8c11b794dbbc2959a5b5afe66642c142a6effa90b45f6f5204", size = 11047428, upload-time = "2026-03-05T20:05:51.867Z" },
{ url = "https://files.pythonhosted.org/packages/5a/f7/4953e7e3287676f78fbe85e3a0ca414c5ca81237b7575bdadc00229ac240/ruff-0.15.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8dc18f30302e379fe1e998548b0f5e9f4dff907f52f73ad6da419ea9c19d66c8", size = 11914251, upload-time = "2026-03-05T20:06:22.887Z" },
{ url = "https://files.pythonhosted.org/packages/77/46/0f7c865c10cf896ccf5a939c3e84e1cfaeed608ff5249584799a74d33835/ruff-0.15.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc6e7f90087e2d27f98dc34ed1b3ab7c8f0d273cc5431415454e22c0bd2a681", size = 11333801, upload-time = "2026-03-05T20:05:57.168Z" },
{ url = "https://files.pythonhosted.org/packages/d3/01/a10fe54b653061585e655f5286c2662ebddb68831ed3eaebfb0eb08c0a16/ruff-0.15.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1cb7169f53c1ddb06e71a9aebd7e98fc0fea936b39afb36d8e86d36ecc2636a", size = 11206821, upload-time = "2026-03-05T20:06:03.441Z" },
{ url = "https://files.pythonhosted.org/packages/7a/0d/2132ceaf20c5e8699aa83da2706ecb5c5dcdf78b453f77edca7fb70f8a93/ruff-0.15.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9b037924500a31ee17389b5c8c4d88874cc6ea8e42f12e9c61a3d754ff72f1ca", size = 11133326, upload-time = "2026-03-05T20:06:25.655Z" },
{ url = "https://files.pythonhosted.org/packages/72/cb/2e5259a7eb2a0f87c08c0fe5bf5825a1e4b90883a52685524596bfc93072/ruff-0.15.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:65bb414e5b4eadd95a8c1e4804f6772bbe8995889f203a01f77ddf2d790929dd", size = 10510820, upload-time = "2026-03-05T20:06:37.79Z" },
{ url = "https://files.pythonhosted.org/packages/ff/20/b67ce78f9e6c59ffbdb5b4503d0090e749b5f2d31b599b554698a80d861c/ruff-0.15.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d20aa469ae3b57033519c559e9bc9cd9e782842e39be05b50e852c7c981fa01d", size = 10302395, upload-time = "2026-03-05T20:05:54.504Z" },
{ url = "https://files.pythonhosted.org/packages/5f/e5/719f1acccd31b720d477751558ed74e9c88134adcc377e5e886af89d3072/ruff-0.15.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:15388dd28c9161cdb8eda68993533acc870aa4e646a0a277aa166de9ad5a8752", size = 10754069, upload-time = "2026-03-05T20:06:06.422Z" },
{ url = "https://files.pythonhosted.org/packages/c3/9c/d1db14469e32d98f3ca27079dbd30b7b44dbb5317d06ab36718dee3baf03/ruff-0.15.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b30da330cbd03bed0c21420b6b953158f60c74c54c5f4c1dabbdf3a57bf355d2", size = 11304315, upload-time = "2026-03-05T20:06:10.867Z" },
{ url = "https://files.pythonhosted.org/packages/28/3a/950367aee7c69027f4f422059227b290ed780366b6aecee5de5039d50fa8/ruff-0.15.5-py3-none-win32.whl", hash = "sha256:732e5ee1f98ba5b3679029989a06ca39a950cced52143a0ea82a2102cb592b74", size = 10551676, upload-time = "2026-03-05T20:06:13.705Z" },
{ url = "https://files.pythonhosted.org/packages/b8/00/bf077a505b4e649bdd3c47ff8ec967735ce2544c8e4a43aba42ee9bf935d/ruff-0.15.5-py3-none-win_amd64.whl", hash = "sha256:821d41c5fa9e19117616c35eaa3f4b75046ec76c65e7ae20a333e9a8696bc7fe", size = 11678972, upload-time = "2026-03-05T20:06:45.379Z" },
{ url = "https://files.pythonhosted.org/packages/fe/4e/cd76eca6db6115604b7626668e891c9dd03330384082e33662fb0f113614/ruff-0.15.5-py3-none-win_arm64.whl", hash = "sha256:b498d1c60d2fe5c10c45ec3f698901065772730b411f164ae270bb6bfcc4740b", size = 10965572, upload-time = "2026-03-05T20:06:16.984Z" },
]
[[package]]
@@ -1220,26 +1220,26 @@ wheels = [
[[package]]
name = "ty"
version = "0.0.17"
version = "0.0.21"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/66/c3/41ae6346443eedb65b96761abfab890a48ce2aa5a8a27af69c5c5d99064d/ty-0.0.17.tar.gz", hash = "sha256:847ed6c120913e280bf9b54d8eaa7a1049708acb8824ad234e71498e8ad09f97", size = 5167209, upload-time = "2026-02-13T13:26:36.835Z" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/20/2ba8fd9493c89c41dfe9dbb73bc70a28b28028463bc0d2897ba8be36230a/ty-0.0.21.tar.gz", hash = "sha256:a4c2ba5d67d64df8fcdefd8b280ac1149d24a73dbda82fa953a0dff9d21400ed", size = 5297967, upload-time = "2026-03-06T01:57:13.809Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c0/01/0ef15c22a1c54b0f728ceff3f62d478dbf8b0dcf8ff7b80b954f79584f3e/ty-0.0.17-py3-none-linux_armv6l.whl", hash = "sha256:64a9a16555cc8867d35c2647c2f1afbd3cae55f68fd95283a574d1bb04fe93e0", size = 10192793, upload-time = "2026-02-13T13:27:13.943Z" },
{ url = "https://files.pythonhosted.org/packages/0f/2c/f4c322d9cded56edc016b1092c14b95cf58c8a33b4787316ea752bb9418e/ty-0.0.17-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:eb2dbd8acd5c5a55f4af0d479523e7c7265a88542efe73ed3d696eb1ba7b6454", size = 10051977, upload-time = "2026-02-13T13:26:57.741Z" },
{ url = "https://files.pythonhosted.org/packages/4c/a5/43746c1ff81e784f5fc303afc61fe5bcd85d0fcf3ef65cb2cef78c7486c7/ty-0.0.17-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f18f5fd927bc628deb9ea2df40f06b5f79c5ccf355db732025a3e8e7152801f6", size = 9564639, upload-time = "2026-02-13T13:26:42.781Z" },
{ url = "https://files.pythonhosted.org/packages/d6/b8/280b04e14a9c0474af574f929fba2398b5e1c123c1e7735893b4cd73d13c/ty-0.0.17-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5383814d1d7a5cc53b3b07661856bab04bb2aac7a677c8d33c55169acdaa83df", size = 10061204, upload-time = "2026-02-13T13:27:00.152Z" },
{ url = "https://files.pythonhosted.org/packages/2a/d7/493e1607d8dfe48288d8a768a2adc38ee27ef50e57f0af41ff273987cda0/ty-0.0.17-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9c20423b8744b484f93e7bf2ef8a9724bca2657873593f9f41d08bd9f83444c9", size = 10013116, upload-time = "2026-02-13T13:26:34.543Z" },
{ url = "https://files.pythonhosted.org/packages/80/ef/22f3ed401520afac90dbdf1f9b8b7755d85b0d5c35c1cb35cf5bd11b59c2/ty-0.0.17-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e6f5b1aba97db9af86517b911674b02f5bc310750485dc47603a105bd0e83ddd", size = 10533623, upload-time = "2026-02-13T13:26:31.449Z" },
{ url = "https://files.pythonhosted.org/packages/75/ce/744b15279a11ac7138832e3a55595706b4a8a209c9f878e3ab8e571d9032/ty-0.0.17-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:488bce1a9bea80b851a97cd34c4d2ffcd69593d6c3f54a72ae02e5c6e47f3d0c", size = 11069750, upload-time = "2026-02-13T13:26:48.638Z" },
{ url = "https://files.pythonhosted.org/packages/f2/be/1133c91f15a0e00d466c24f80df486d630d95d1b2af63296941f7473812f/ty-0.0.17-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8df66b91ec84239420985ec215e7f7549bfda2ac036a3b3c065f119d1c06825a", size = 10870862, upload-time = "2026-02-13T13:26:54.715Z" },
{ url = "https://files.pythonhosted.org/packages/3e/4a/a2ed209ef215b62b2d3246e07e833081e07d913adf7e0448fc204be443d6/ty-0.0.17-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:002139e807c53002790dfefe6e2f45ab0e04012e76db3d7c8286f96ec121af8f", size = 10628118, upload-time = "2026-02-13T13:26:45.439Z" },
{ url = "https://files.pythonhosted.org/packages/b3/0c/87476004cb5228e9719b98afffad82c3ef1f84334bde8527bcacba7b18cb/ty-0.0.17-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6c4e01f05ce82e5d489ab3900ca0899a56c4ccb52659453780c83e5b19e2b64c", size = 10038185, upload-time = "2026-02-13T13:27:02.693Z" },
{ url = "https://files.pythonhosted.org/packages/46/4b/98f0b3ba9aef53c1f0305519536967a4aa793a69ed72677b0a625c5313ac/ty-0.0.17-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:2b226dd1e99c0d2152d218c7e440150d1a47ce3c431871f0efa073bbf899e881", size = 10047644, upload-time = "2026-02-13T13:27:05.474Z" },
{ url = "https://files.pythonhosted.org/packages/93/e0/06737bb80aa1a9103b8651d2eb691a7e53f1ed54111152be25f4a02745db/ty-0.0.17-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8b11f1da7859e0ad69e84b3c5ef9a7b055ceed376a432fad44231bdfc48061c2", size = 10231140, upload-time = "2026-02-13T13:27:10.844Z" },
{ url = "https://files.pythonhosted.org/packages/7c/79/e2a606bd8852383ba9abfdd578f4a227bd18504145381a10a5f886b4e751/ty-0.0.17-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:c04e196809ff570559054d3e011425fd7c04161529eb551b3625654e5f2434cb", size = 10718344, upload-time = "2026-02-13T13:26:51.66Z" },
{ url = "https://files.pythonhosted.org/packages/c5/2d/2663984ac11de6d78f74432b8b14ba64d170b45194312852b7543cf7fd56/ty-0.0.17-py3-none-win32.whl", hash = "sha256:305b6ed150b2740d00a817b193373d21f0767e10f94ac47abfc3b2e5a5aec809", size = 9672932, upload-time = "2026-02-13T13:27:08.522Z" },
{ url = "https://files.pythonhosted.org/packages/de/b5/39be78f30b31ee9f5a585969930c7248354db90494ff5e3d0756560fb731/ty-0.0.17-py3-none-win_amd64.whl", hash = "sha256:531828267527aee7a63e972f54e5eee21d9281b72baf18e5c2850c6b862add83", size = 10542138, upload-time = "2026-02-13T13:27:17.084Z" },
{ url = "https://files.pythonhosted.org/packages/40/b7/f875c729c5d0079640c75bad2c7e5d43edc90f16ba242f28a11966df8f65/ty-0.0.17-py3-none-win_arm64.whl", hash = "sha256:de9810234c0c8d75073457e10a84825b9cd72e6629826b7f01c7a0b266ae25b1", size = 10023068, upload-time = "2026-02-13T13:26:39.637Z" },
{ url = "https://files.pythonhosted.org/packages/36/70/edf38bb37517531681d1c37f5df64744e5ad02673c02eb48447eae4bea08/ty-0.0.21-py3-none-linux_armv6l.whl", hash = "sha256:7bdf2f572378de78e1f388d24691c89db51b7caf07cf90f2bfcc1d6b18b70a76", size = 10299222, upload-time = "2026-03-06T01:57:16.64Z" },
{ url = "https://files.pythonhosted.org/packages/72/62/0047b0bd19afeefbc7286f20a5f78a2aa39f92b4d89853f0d7185ab89edc/ty-0.0.21-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7e9613994610431ab8625025bd2880dbcb77c5c9fabdd21134cda12d840a529d", size = 10130513, upload-time = "2026-03-06T01:57:29.93Z" },
{ url = "https://files.pythonhosted.org/packages/a2/20/0b93a9e91aaed23155780258cdfdb4726ef68b6985378ac069bc427291a0/ty-0.0.21-py3-none-macosx_11_0_arm64.whl", hash = "sha256:56d3b198b64dd0a19b2b66e257deaed2ecea568e722ae5352f3c6fb62027f89d", size = 9605425, upload-time = "2026-03-06T01:57:27.115Z" },
{ url = "https://files.pythonhosted.org/packages/ea/fd/9945e2fa2996a1287b1e1d7ce050e97e1f420233b271e770934bfa0880a0/ty-0.0.21-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d23d2c34f7a77d974bb08f0860ef700addc8a683d81a0319f71c08f87506cfd0", size = 10108298, upload-time = "2026-03-06T01:57:35.429Z" },
{ url = "https://files.pythonhosted.org/packages/52/e7/4ec52fcb15f3200826c9f048472c062549a05b0d1ef0b51f32d527b513c4/ty-0.0.21-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56b01fd2519637a4ca88344f61c96225f540c98ff18bca321d4eaa7bb0f7aa2f", size = 10121556, upload-time = "2026-03-06T01:57:03.242Z" },
{ url = "https://files.pythonhosted.org/packages/ee/c0/ad457be2a8abea0f25549598bd098554540ced66229488daa0d558dad3c8/ty-0.0.21-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e9de7e11c63c6afc40f3e9ba716374add171aee7fabc70b5146a510705c6d41b", size = 10603264, upload-time = "2026-03-06T01:56:52.134Z" },
{ url = "https://files.pythonhosted.org/packages/f8/5b/2ecc7a2175243a4bcb72f5298ae41feabbb93b764bb0dc45722f3752c2c2/ty-0.0.21-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:62f7f5b235c4f7876db305c36997aea07b7af29b1a068f373d0e2547e25f32ff", size = 11196428, upload-time = "2026-03-06T01:57:32.94Z" },
{ url = "https://files.pythonhosted.org/packages/37/f5/aff507d6a901f328ef96a298032b0c11aaaf950a146ed7dd3b5bf2cd3acf/ty-0.0.21-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ee8399f7c453a425291e6688efe430cfae7ab0ac4ffd50eba9f872bf878b54f6", size = 10866355, upload-time = "2026-03-06T01:56:57.831Z" },
{ url = "https://files.pythonhosted.org/packages/be/30/822bbcb92d55b65989aa7ed06d9585f28ade9c9447369194ed4b0fb3b5b9/ty-0.0.21-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:210e7568c9f886c4d01308d751949ee714ad7ad9d7d928d2ba90d329dd880367", size = 10738177, upload-time = "2026-03-06T01:57:11.256Z" },
{ url = "https://files.pythonhosted.org/packages/57/cc/46e7991b6469e93ac2c7e533a028983e402485580150ac864c56352a3a82/ty-0.0.21-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:53508e345b11569f78b21ba8e2b4e61df38a9754947fb3cd9f2ef574367338fb", size = 10079158, upload-time = "2026-03-06T01:57:00.516Z" },
{ url = "https://files.pythonhosted.org/packages/15/c2/0bbdadfbd008240f8f1a87dc877433cb3884436097926107ccf06e618199/ty-0.0.21-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:553e43571f4a35604c36cfd07d8b61a5eb7a714e3c67f8c4ff2cf674fefbaef9", size = 10150535, upload-time = "2026-03-06T01:57:08.815Z" },
{ url = "https://files.pythonhosted.org/packages/c5/b5/2dbdb7b57b5362200ef0a39738ebd31331726328336def0143ac097ee59d/ty-0.0.21-py3-none-musllinux_1_2_i686.whl", hash = "sha256:666f6822e3b9200abfa7e95eb0ddd576460adb8d66b550c0ad2c70abc84a2048", size = 10319803, upload-time = "2026-03-06T01:57:19.106Z" },
{ url = "https://files.pythonhosted.org/packages/72/84/70e52c0b7abc7c2086f9876ef454a73b161d3125315536d8d7e911c94ca4/ty-0.0.21-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a0854d008347ce4a5fb351af132f660a390ab2a1163444d075251d43e6f74b9b", size = 10826239, upload-time = "2026-03-06T01:57:21.727Z" },
{ url = "https://files.pythonhosted.org/packages/a1/8a/1f72480fd013bbc6cd1929002abbbcde9a0b08ead6a15154de9d7f7fa37e/ty-0.0.21-py3-none-win32.whl", hash = "sha256:bef3ab4c7b966bcc276a8ac6c11b63ba222d21355b48d471ea782c4104eee4e0", size = 9693196, upload-time = "2026-03-06T01:57:24.126Z" },
{ url = "https://files.pythonhosted.org/packages/8d/f8/1104808b875c26c640e536945753a78562d606bef4e241d9dbf3d92477f6/ty-0.0.21-py3-none-win_amd64.whl", hash = "sha256:a709d576e5bea84b745d43058d8b9cd4f27f74a0b24acb4b0cbb7d3d41e0d050", size = 10668660, upload-time = "2026-03-06T01:56:55.06Z" },
{ url = "https://files.pythonhosted.org/packages/1b/b8/25e0adc404bbf986977657b25318991f93097b49f8aea640d93c0b0db68e/ty-0.0.21-py3-none-win_arm64.whl", hash = "sha256:f72047996598ac20553fb7e21ba5741e3c82dee4e9eadf10d954551a5fe09391", size = 10104161, upload-time = "2026-03-06T01:57:06.072Z" },
]
[[package]]