Compare commits

..
Author SHA1 Message Date
Quanzheng Long 58c23b2d83 done 2026-03-10 11:21:13 -07:00
Quanzheng Long 28cb872ca4 rm 2026-03-10 10:33:08 -07:00
Quanzheng Long 6bd3e8b1e5 version-done 2026-03-10 10:24:46 -07:00
Quanzheng LongandGitHub db40389fbd WIP: chore(cli): support DR for deploy command (#7098)
- [ ] **Add tests and docs**: If you're adding a new integration, you
must include:
1. A test for the integration, preferably unit tests that do not rely on
network access,
2. An example notebook showing its use. It lives in
`docs/docs/integrations` directory.

- [ ] **Lint and test**: Run `make format`, `make lint` and `make test`
from the root of the package(s) you've modified. We will not consider a
PR unless these three are passing in CI. See [contribution
guidelines](https://docs.langchain.com/oss/python/contributing/overview)
for more.

Additional guidelines:

- Make sure optional dependencies are imported within a function.
- Please do not add dependencies to `pyproject.toml` files (even
optional ones) unless they are **required** for unit tests.
- Most PRs should not touch more than one package.
- Changes should be backwards compatible.
2026-03-10 10:03:32 -07:00
Quanzheng Long 4b4b91c7e7 fixtest 2026-03-09 22:04:29 -07:00
Quanzheng Long 5c4af31372 version 2026-03-09 21:47:11 -07: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
William FHandGitHub 2638ff715a release(cli): 0.4.15 (#7095) 2026-03-09 18:13:45 -07:00
65117979b4 feat(cli): add langgraph deploy (#7004)
Allow users to deploy to langsmith deployments from the langgraph-cli.
This PR makes the following changes:
1. Add a simple host backend client with httpx
2. Adjust `progress.py` to show elapsed time for commands, and also use
threading.Event to stop the spinner
3. Adjust `_build` to allow arbitrary command so we can pass `docker
buildx build` and default to `docker build`
4. Add new `deploy` command, this re-uses a lot of the `langgraph build`
functionality, and then uses the new host-backend client to push the
built image to langsmith deployments.

---------

Co-authored-by: David Asamu <david.asamu@langchain.dev>
2026-03-09 18:11:34 -07:00
William FHandGitHub 6a19a5a7b2 chore: Add cache (#7092) 2026-03-09 16:44:36 -07:00
35 changed files with 7361 additions and 186 deletions
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.4.14"
__version__ = "0.4.15"
+95
View File
@@ -0,0 +1,95 @@
"""Resolve the LangGraph API version from CLI flags and langgraph.json."""
import json
import pathlib
import re
import urllib.request
import click
VERSION_MARKER_REPO = "langchain/langgraph-published-version-marker"
_PATCH_VERSION_RE = re.compile(r"^\d+\.\d+\.\d+$")
_SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+")
def resolve_langgraph_api_version(
config_path: pathlib.Path,
api_version_cli_param: str | None,
) -> str:
"""Resolve the API version from the CLI flag and/or langgraph.json.
Returns the resolved patch-level version string. When neither source
provides a version, the latest published version is fetched from Docker Hub.
Raises `click.ClickException` when both sources specify a version, or
when a version cannot be resolved via Docker Hub.
"""
api_version_langgraph_json = _read_api_version_from_config(config_path)
if api_version_cli_param and api_version_langgraph_json:
raise click.ClickException(
"API version specified in both --api-version CLI flag "
f"({api_version_cli_param!r}) and langgraph.json "
f"({api_version_langgraph_json!r}). Please use only one."
)
preferred_api_version = api_version_cli_param or api_version_langgraph_json
if preferred_api_version and _PATCH_VERSION_RE.match(preferred_api_version):
return preferred_api_version
version_prefix = preferred_api_version or ""
if version_prefix:
click.secho(
f"Resolving API version matching {version_prefix!r} from Docker Hub...",
fg="cyan",
)
else:
click.secho(
"Resolving latest API version from Docker Hub...",
fg="cyan",
)
resolved = _fetch_matching_version(version_prefix)
click.secho(f"Resolved API version: {resolved}", fg="cyan")
return resolved
def _read_api_version_from_config(config_path: pathlib.Path) -> str | None:
"""Read the `api_version` field from langgraph.json (if present)."""
try:
with open(config_path) as f:
raw_config = json.load(f)
except (OSError, json.JSONDecodeError):
return None
return raw_config.get("api_version")
def _fetch_matching_version(version_prefix: str = "") -> str:
"""Query Docker Hub for the latest patch version matching *version_prefix*.
When *version_prefix* is empty, returns the latest published version.
"""
url = f"https://hub.docker.com/v2/repositories/{VERSION_MARKER_REPO}/tags/?page_size=10"
if version_prefix:
url += f"&name={version_prefix}"
try:
with urllib.request.urlopen(url, timeout=10) as resp:
data = json.loads(resp.read())
except Exception as exc:
raise click.ClickException(
f"Failed to fetch API version from {VERSION_MARKER_REPO}: {exc}\n"
"You can specify an exact version with --api-version (e.g. 0.7.67)."
) from exc
for tag in data.get("results", []):
name = tag.get("name", "")
if _SEMVER_RE.match(name):
return name
if version_prefix:
msg = f"Could not find a version matching {version_prefix!r} in {VERSION_MARKER_REPO}."
else:
msg = f"Could not find a published version in {VERSION_MARKER_REPO}."
raise click.ClickException(
f"{msg}\nYou can specify an exact version with --api-version (e.g. 0.7.67)."
)
+705 -8
View File
@@ -1,27 +1,159 @@
"""CLI entrypoint for LangGraph API server."""
import base64
import copy
import json as json_mod
import os
import pathlib
import platform
import re
import shutil
import sys
import tempfile
import time
from collections.abc import Callable, Sequence
from contextlib import contextmanager
from typing import Any
import click
import click.exceptions
from click import secho
from dotenv import dotenv_values
import langgraph_cli.config
import langgraph_cli.docker
from langgraph_cli.analytics import log_command
from langgraph_cli.api_version import resolve_langgraph_api_version
from langgraph_cli.config import Config
from langgraph_cli.constants import DEFAULT_CONFIG, DEFAULT_PORT
from langgraph_cli.docker import DockerCapabilities
from langgraph_cli.engine_runtime_mode import resolve_engine_runtime_mode
from langgraph_cli.exec import Runner, subp_exec
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.version import __version__
RESERVED_ENV_VARS = frozenset(
[
# LANGCHAIN_RESERVED_ENV_VARS from host-backend
"LANGCHAIN_TRACING_V2",
"LANGSMITH_TRACING_V2",
"LANGCHAIN_ENDPOINT",
"LANGCHAIN_PROJECT",
"LANGSMITH_PROJECT",
"LANGSMITH_LANGGRAPH_GIT_REPO",
"LANGGRAPH_GIT_REPO_PATH",
"LANGCHAIN_API_KEY",
"LANGSMITH_CONTROL_PLANE_API_KEY",
"POSTGRES_URI",
"POSTGRES_PASSWORD",
"DATABASE_URI",
"LANGSMITH_LANGGRAPH_GIT_REF",
"LANGSMITH_LANGGRAPH_GIT_REF_SHA",
"LANGGRAPH_AUTH_TYPE",
"LANGSMITH_AUTH_ENDPOINT",
"LANGSMITH_TENANT_ID",
"LANGSMITH_AUTH_VERIFY_TENANT_ID",
"LANGSMITH_HOST_PROJECT_ID",
"LANGSMITH_HOST_PROJECT_NAME",
"LANGSMITH_HOST_REVISION_ID",
"LOG_JSON",
"LOG_DICT_TRACEBACKS",
"REDIS_URI",
"LANGCHAIN_CALLBACKS_BACKGROUND",
"DD_TRACE_PSYCOPG_ENABLED",
"DD_TRACE_REDIS_ENABLED",
"LANGSMITH_DEPLOYMENT_NAME",
"LANGGRAPH_CLOUD_LICENSE_KEY",
# ALLOWED_SELF_HOSTED_ENV_VARS (rejected for non-self-hosted)
"LANGSMITH_API_KEY",
"LANGSMITH_ENDPOINT",
"POSTGRES_URI_CUSTOM",
"REDIS_URI_CUSTOM",
"PATH",
"PORT",
"MOUNT_PREFIX",
"LSD_ENV",
"LSD_DD_API_KEY",
"LSD_DD_ENDPOINT",
"LSD_DEPLOYMENT_TYPE",
]
)
_API_KEY_ENV_NAMES = (
"LANGGRAPH_HOST_API_KEY",
"LANGSMITH_API_KEY",
"LANGCHAIN_API_KEY",
)
_DEPLOYMENT_NAME_ENV = "LANGSMITH_DEPLOYMENT_NAME"
def _parse_env_from_config(
config_json: dict, config_path: pathlib.Path
) -> dict[str, str]:
"""Resolve env vars from langgraph.json 'env' field or a .env fallback."""
env_field = config_json.get("env")
# validate_config_file will default env to {}
if isinstance(env_field, dict) and env_field:
return {str(k): str(v) for k, v in env_field.items()}
if isinstance(env_field, str):
env_path = (config_path.parent / env_field).resolve()
if not env_path.exists():
click.secho(
f"Warning: env file '{env_field}' specified in langgraph.json not found.",
fg="yellow",
)
return {}
else:
env_path = pathlib.Path.cwd() / ".env"
return {k: v for k, v in dotenv_values(env_path).items() if v is not None}
def _secrets_from_env(
env_vars: dict[str, str],
) -> list[dict[str, str]]:
"""Convert env dict to secrets list, filtering reserved vars with warnings."""
secrets: list[dict[str, str]] = []
for name, value in env_vars.items():
if name in RESERVED_ENV_VARS:
click.secho(f" Skipping reserved env var: {name}", fg="yellow")
continue
if not value:
continue
secrets.append({"name": name, "value": value})
return secrets
_TERMINAL_STATUSES = frozenset(
[
"DEPLOYED",
"CREATE_FAILED",
"BUILD_FAILED",
"DEPLOY_FAILED",
"SKIPPED",
]
)
@contextmanager
def _docker_config_for_token(registry_host: str, token: str):
"""Create a temporary Docker config with only the push token.
Yields the path to a temporary config directory that can be passed
to ``docker --config <path>`` so that system credential helpers
(e.g. gcloud) don't interfere with the push token.
"""
auth_b64 = base64.b64encode(f"oauth2accesstoken:{token}".encode()).decode()
config_data = {"auths": {registry_host: {"auth": auth_b64}}}
with tempfile.TemporaryDirectory() as tmpdir:
with open(os.path.join(tmpdir, "config.json"), "w") as f:
json_mod.dump(config_data, f)
yield tmpdir
OPT_DOCKER_COMPOSE = click.option(
"--docker-compose",
"-d",
@@ -158,6 +290,13 @@ OPT_API_VERSION = click.option(
help="API server version to use for the base image. If unspecified, the latest version will be used.",
)
OPT_ENGINE_RUNTIME_MODE = click.option(
"--engine-runtime-mode",
type=click.Choice(["combined_queue_worker", "distributed"]),
default=None,
help="Runtime mode. 'distributed' uses separate executor and orchestrator containers. Defaults to distributed.",
)
@click.group()
@click.version_option(version=__version__, prog_name="LangGraph CLI")
@@ -176,6 +315,7 @@ def cli():
@OPT_WATCH
@OPT_POSTGRES_URI
@OPT_API_VERSION
@OPT_ENGINE_RUNTIME_MODE
@click.option(
"--image",
type=str,
@@ -210,9 +350,14 @@ def up(
debugger_base_url: str | None,
postgres_uri: str | None,
api_version: str | None,
engine_runtime_mode: str | None,
image: str | None,
base_image: str | None,
):
api_version = resolve_langgraph_api_version(config, api_version)
engine_runtime_mode = resolve_engine_runtime_mode(
config, api_version, engine_runtime_mode
)
click.secho("Starting LangGraph API server...", fg="green")
click.secho(
"""For local dev, requires env var LANGSMITH_API_KEY with access to LangSmith Deployment.
@@ -233,6 +378,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,
)
@@ -304,6 +450,9 @@ def _build(
passthrough: Sequence[str] = (),
install_command: str | None = None,
build_command: str | None = None,
docker_command: Sequence[str] | None = None,
extra_flags: Sequence[str] = (),
verbose: bool = True,
):
# pull latest images
if pull:
@@ -312,7 +461,7 @@ def _build(
"docker",
"pull",
langgraph_cli.config.docker_tag(config_json, base_image, api_version),
verbose=True,
verbose=verbose,
)
)
set("Building...")
@@ -334,7 +483,9 @@ def _build(
else:
build_context = str(config.parent)
# apply config
# Deep copy to avoid mutating the caller's config (config_to_docker
# rewrites graph paths to container-internal paths in place).
config_json = copy.deepcopy(config_json)
stdin, additional_contexts = langgraph_cli.config.config_to_docker(
config_path=config,
config=config_json,
@@ -348,15 +499,16 @@ def _build(
if additional_contexts:
for k, v in additional_contexts.items():
args.extend(["--build-context", f"{k}={v}"])
cmd = tuple(docker_command) if docker_command else ("docker", "build")
runner.run(
subp_exec(
"docker",
"build",
*cmd,
*args,
*extra_flags,
*passthrough,
build_context,
input=stdin,
verbose=True,
verbose=verbose,
)
)
@@ -383,6 +535,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.",
@@ -404,11 +557,16 @@ def build(
docker_build_args: Sequence[str],
base_image: str | None,
api_version: str | None,
engine_runtime_mode: str | None,
pull: bool,
tag: str,
install_command: str | None,
build_command: str | None,
):
api_version = resolve_langgraph_api_version(config, api_version)
engine_runtime_mode = resolve_engine_runtime_mode(
config, api_version, engine_runtime_mode
)
if install_command and langgraph_cli.config.has_disallowed_build_command_content(
install_command
):
@@ -426,12 +584,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,
@@ -441,6 +604,500 @@ 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(
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."
),
context_settings=dict(ignore_unknown_options=True),
)
@log_command
def deploy(
config: pathlib.Path,
pull: bool,
verbose: bool,
api_version: str | None,
host_url: str | None,
api_key: str | None,
deployment_id: str | None,
deployment_type: str,
name: str | None,
image_name: str | None,
image_tag: str,
base_image: str | None,
install_command: str | None,
build_command: str | None,
no_wait: bool,
docker_build_args: Sequence[str],
):
api_version = resolve_langgraph_api_version(config, api_version)
engine_runtime_mode = resolve_engine_runtime_mode(config, api_version, None)
click.secho(
"Note: 'langgraph deploy' is in beta. Expect frequent updates and improvements.",
fg="yellow",
)
click.echo()
config_json = langgraph_cli.config.validate_config_file(config)
warn_non_wolfi_distro(config_json)
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:
default_name = _normalize_image_name(pathlib.Path.cwd().name)
name = click.prompt("Deployment name", default=default_name)
secrets = _secrets_from_env(env_vars)
# Use buildx to cross-compile for amd64 when running on a non-x86_64 host
# (e.g. Apple Silicon). On amd64 hosts, plain docker build is sufficient.
needs_buildx = platform.machine() != "x86_64"
local_tag = f"langgraph-deploy-tmp:{int(time.time())}"
with Runner() as runner:
if shutil.which("docker") is None:
raise click.UsageError(
"Docker is required but not installed.\n"
"Install Docker Desktop: https://docs.docker.com/get-docker/\n\n"
"Remote builds (no Docker required) are coming in a future update."
)
if needs_buildx:
try:
runner.run(subp_exec("docker", "buildx", "version", collect=True))
except click.exceptions.Exit:
raise click.UsageError(
"Docker Buildx is required but not installed.\n"
"Your machine architecture ("
+ platform.machine()
+ ") requires Buildx to cross-compile images for linux/amd64.\n"
"Install Buildx: https://docs.docker.com/build/install-buildx/\n\n"
"Remote builds (no Docker required) are coming in a future update."
) from None
def log_step(message: str) -> None:
click.secho(message, fg="cyan")
client = HostBackendClient(host_url, api_key)
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
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
found_id = None
if isinstance(existing, dict):
for dep in existing.get("resources", []):
if isinstance(dep, dict) and dep.get("name") == name:
found_id = dep.get("id")
break
if found_id:
deployment_id = str(found_id)
click.secho(
f" Found existing deployment (ID: {deployment_id})",
fg="green",
)
else:
needs_creation = True
click.secho(
" No deployment found. Will create after build.", fg="yellow"
)
step += 1
# -- Step: Build image --
log_step(f"{step}. Building image")
if needs_buildx:
build_flags: list[str] = [
"--platform",
"linux/amd64",
"--load",
]
if not verbose:
build_flags.append("--progress=quiet")
with Progress(message="Building...", elapsed=not verbose):
_build(
runner,
lambda _msg: None,
config,
config_json,
base_image,
api_version,
pull,
local_tag,
docker_build_args,
install_command,
build_command,
docker_command=("docker", "buildx", "build"),
extra_flags=build_flags,
verbose=verbose,
)
else:
with Progress(message="Building...", elapsed=not verbose):
_build(
runner,
lambda _msg: None,
config,
config_json,
base_image,
api_version,
pull,
local_tag,
docker_build_args,
install_command,
build_command,
verbose=verbose,
)
step += 1
if needs_creation:
log_step(f"{step}. Creating deployment '{name}'")
payload: dict[str, Any] = {
"name": name,
"source": "internal_docker",
"source_config": {"deployment_type": deployment_type},
"source_revision_config": {},
"secrets": secrets,
"engine_runtime_mode": engine_runtime_mode,
}
if api_version:
payload["deployed_api_version"] = api_version
created = client.create_deployment(payload)
created_id = created.get("id") if isinstance(created, dict) else None
if not isinstance(created_id, str) or not created_id:
raise HostBackendError(
"POST /v2/deployments succeeded but response missing a valid 'id'"
)
deployment_id = created_id
click.secho(f" Deployment ID: {deployment_id}", fg="green")
step += 1
# -- Step: Get push token and authenticate --
log_step(f"{step}. Requesting push token")
try:
push_data = client.request_push_token(deployment_id)
except HostBackendError as err:
if (
err.status_code == 400
and "only available for 'internal_docker' source deployments"
in err.message
):
raise click.ClickException(
f"Deployment '{deployment_id}' was not created by 'langgraph deploy' "
"and cannot be updated with this command.\n"
"Please create a new deployment by running 'langgraph deploy' "
"without --deployment-id, or use a different --name."
) from None
raise
deployment_token = push_data.get("token")
registry_url = push_data.get("registry_url")
if not deployment_token or not registry_url:
raise click.ClickException(
"Push token response missing token or registry_url"
)
step += 1
normalized_registry = registry_url.rstrip("/")
if "://" in normalized_registry:
normalized_registry = normalized_registry.split("//", 1)[1]
repo_seed = image_name or name or config.parent.name
repo_name = _normalize_image_name(repo_seed)
tag_value = _normalize_image_tag(image_tag)
remote_image = f"{normalized_registry}/{repo_name}:{tag_value}"
registry_host = normalized_registry.split("/")[0]
# Use a clean Docker config with only the push token so that
# system credential helpers (e.g. gcloud) don't interfere.
with _docker_config_for_token(registry_host, deployment_token) as cfg:
log_step(f"{step}. Logging into {registry_host}")
token_input = (
deployment_token
if deployment_token.endswith("\n")
else f"{deployment_token}\n"
)
runner.run(
subp_exec(
"docker",
"--config",
cfg,
"login",
"-u",
"oauth2accesstoken",
"--password-stdin",
registry_host,
input=token_input,
verbose=verbose,
)
)
step += 1
# -- Step: Tag and push --
log_step(f"{step}. Pushing image {remote_image}")
runner.run(
subp_exec(
"docker",
"tag",
local_tag,
remote_image,
verbose=verbose,
)
)
max_push_retries = 3
for attempt in range(max_push_retries):
try:
with Progress(message="Pushing...", elapsed=not verbose):
runner.run(
subp_exec(
"docker",
"--config",
cfg,
"push",
remote_image,
verbose=verbose,
)
)
break
except click.exceptions.Exit:
if attempt < max_push_retries - 1:
click.secho(
f" Push failed, retrying (attempt {attempt + 2} of {max_push_retries})...",
fg="yellow",
)
else:
raise
step += 1
# -- Step: Update deployment --
log_step(f"{step}. Updating deployment {deployment_id}")
updated = client.update_deployment(
deployment_id,
remote_image,
secrets=secrets,
engine_runtime_mode=engine_runtime_mode,
deployed_api_version=api_version,
)
tenant_id = updated.get("tenant_id") if isinstance(updated, dict) else None
if tenant_id:
status_url = (
f"https://smith.langchain.com/o/{tenant_id}"
f"/host/deployments/{deployment_id}"
)
click.secho(f" View status: {status_url}", fg="cyan")
if no_wait:
click.secho(" Deployment updated", fg="green")
return
# -- Poll revision status --
revisions_resp = client.list_revisions(deployment_id, limit=1)
resources = (
revisions_resp.get("resources", [])
if isinstance(revisions_resp, dict)
else []
)
if not resources:
click.secho(" Deployment updated", fg="green")
return
revision_id = str(resources[0]["id"])
last_status = ""
deadline = time.time() + 300
with Progress(message="Deploying...", elapsed=True) as set_progress:
while time.time() < deadline:
rev = client.get_revision(deployment_id, revision_id)
status = (
rev.get("status", "UNKNOWN") if isinstance(rev, dict) else "UNKNOWN"
)
if status != last_status:
last_status = status
# pause spinner so we can avoid conflict when writing status
set_progress("")
click.secho(f" Status: {status}", fg="cyan")
if status in _TERMINAL_STATUSES:
break
set_progress(f"{status}...")
time.sleep(1)
else:
set_progress("")
dep_info = client.get_deployment(deployment_id)
custom_url = None
if isinstance(dep_info, dict):
sc = dep_info.get("source_config")
if isinstance(sc, dict):
custom_url = sc.get("custom_url")
if last_status == "DEPLOYED":
click.secho(" Deployment successful!", fg="green")
if custom_url:
click.secho(f" URL: {custom_url}", fg="green")
elif last_status in ("BUILD_FAILED", "DEPLOY_FAILED", "CREATE_FAILED"):
click.secho(f" Deployment failed: {last_status}", fg="red")
raise click.exceptions.Exit(1)
else:
click.secho(
f" Timed out waiting for deployment (last status: {last_status}).",
fg="yellow",
)
if custom_url:
click.secho(
f" Check status at: {custom_url}",
fg="yellow",
)
else:
click.secho(
" Check status in the LangSmith Deployments dashboard.",
fg="yellow",
)
def _normalize_image_name(value: str | None) -> str:
"""Sanitize a deployment/directory name into a valid Docker repository name.
Docker repository names must be lowercase and may only contain
[a-z0-9._-]. Invalid characters are replaced with hyphens.
"""
if not value:
return "app"
slug = re.sub(r"[^a-z0-9._-]+", "-", value.lower()).strip("-.")
return slug or "app"
def _normalize_image_tag(value: str) -> str:
"""Validate and return a Docker image tag.
Tags may only contain [A-Za-z0-9_.-]. Defaults to "latest" when empty.
"""
if not value:
value = "latest"
if not re.fullmatch(r"[A-Za-z0-9_.-]+", value):
raise click.UsageError(
"Image tag may only contain characters A-Z, a-z, 0-9, '_', '-', '.'"
)
return value
def _get_docker_ignore_content() -> str:
"""Return the content of a .dockerignore file.
@@ -518,6 +1175,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,
@@ -525,18 +1183,29 @@ def dockerfile(
add_docker_compose: bool,
base_image: str | None = None,
api_version: str | None = None,
engine_runtime_mode: str | None = None,
) -> None:
api_version = resolve_langgraph_api_version(config, api_version)
engine_runtime_mode = resolve_engine_runtime_mode(
config, api_version, engine_runtime_mode
)
save_path = pathlib.Path(save_path).absolute()
secho(f"🔍 Validating configuration at path: {config}", fg="yellow")
config_json = langgraph_cli.config.validate_config_file(config)
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:
@@ -807,6 +1476,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
@@ -820,9 +1490,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",
@@ -840,6 +1511,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
@@ -858,12 +1530,14 @@ 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]:
"""Prepare the arguments and stdin for running the LangGraph API server."""
config_json = langgraph_cli.config.validate_config_file(config_path)
warn_non_wolfi_distro(config_json)
# pull latest images
if pull:
runner.run(
@@ -874,6 +1548,28 @@ 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,
)
)
runner.run(
subp_exec(
"docker",
"pull",
f"langchain/langgraph-orchestrator-licensed:{api_version}",
verbose=verbose,
)
)
args, stdin = prepare_args_and_stdin(
capabilities=capabilities,
@@ -886,6 +1582,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,
)
+75 -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"
@@ -1264,6 +1269,11 @@ def docker_tag(
version_distro_tag = f"{version}{distro_tag}"
# Prepend API version if provided
# Strip an existing tag from base_image so we don't produce two colons
# (e.g. "langchain/langgraph-server:0.2" → "langchain/langgraph-server").
if ":" in base_image:
base_image = base_image.rsplit(":", 1)[0]
if api_version:
full_tag = f"{api_version}-{language}{version_distro_tag}"
elif "/langgraph-server" in base_image and version_distro_tag not in base_image:
@@ -1329,6 +1339,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 +1373,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 +1395,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 +1405,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:{api_version}
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
@@ -0,0 +1,69 @@
"""Resolve the LangGraph engine runtime mode."""
import json
import pathlib
import click
_DISTRIBUTED_MIN_VERSION = (0, 7, 68)
def resolve_engine_runtime_mode(
config_path: pathlib.Path,
api_version: str,
engine_runtime_mode_cli_param: str | None,
) -> str:
"""Resolve the engine runtime mode.
*api_version* must already be resolved to a patch-level semver string.
Returns ``"distributed"`` or ``"combined_queue_worker"``.
Raises `click.ClickException` when distributed mode is requested but
not supported (JavaScript project or api_version <= 0.7.67).
"""
requires_combined = _requires_combined(config_path, api_version)
if engine_runtime_mode_cli_param == "distributed":
if requires_combined:
reasons = _constraint_reasons(config_path, api_version)
raise click.ClickException(
f"Distributed runtime is not supported for {' and '.join(reasons)}."
)
return "distributed"
if engine_runtime_mode_cli_param == "combined_queue_worker":
return "combined_queue_worker"
# No explicit choice → default to distributed
return "distributed"
def _requires_combined(config_path: pathlib.Path, api_version: str) -> bool:
return _is_javascript_project(config_path) or _version_too_old(api_version)
def _version_too_old(api_version: str) -> bool:
try:
parts = tuple(int(x) for x in api_version.split("."))
except (ValueError, AttributeError):
return True
return parts < _DISTRIBUTED_MIN_VERSION
def _is_javascript_project(config_path: pathlib.Path) -> bool:
try:
with open(config_path) as f:
cfg = json.load(f)
except (OSError, json.JSONDecodeError):
return False
return bool(cfg.get("node_version")) and not cfg.get("python_version")
def _constraint_reasons(config_path: pathlib.Path, api_version: str) -> list[str]:
reasons: list[str] = []
if _is_javascript_project(config_path):
reasons.append("JavaScript projects")
if _version_too_old(api_version):
reasons.append(f"API version {api_version} (<= 0.7.67)")
return reasons
+113
View File
@@ -0,0 +1,113 @@
"""HTTP client for LangGraph host backend deployments."""
from __future__ import annotations
from typing import Any
import click
import httpx
class HostBackendError(click.ClickException):
"""Raised when the host backend returns an error response."""
def __init__(self, message: str, status_code: int | None = None):
super().__init__(message)
self.status_code = status_code
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):
if not base_url:
raise click.UsageError("Host backend URL is required")
transport = httpx.HTTPTransport(retries=3)
headers: dict[str, str] = {
"X-Api-Key": api_key,
"Accept": "application/json",
}
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,
transport=transport,
timeout=30,
)
def _request(
self, method: str, path: str, payload: dict[str, Any] | None = None
) -> Any:
try:
resp = self._client.request(method, path, json=payload)
resp.raise_for_status()
except httpx.HTTPStatusError as err:
detail = err.response.text or str(err.response.status_code)
raise HostBackendError(
f"{method} {path} failed with status {err.response.status_code}: {detail}",
status_code=err.response.status_code,
) from None
except httpx.TransportError as err:
raise HostBackendError(str(err)) from None
if not resp.content:
return None
try:
return resp.json()
except ValueError as err:
raise HostBackendError(
f"Failed to decode response from {path}: {err}"
) from None
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 get_deployment(self, deployment_id: str) -> dict[str, Any]:
return self._request("GET", f"/v2/deployments/{deployment_id}")
def request_push_token(self, deployment_id: str) -> dict[str, Any]:
return self._request(
"POST",
f"/v2/deployments/{deployment_id}/push-token",
)
def update_deployment(
self,
deployment_id: str,
image_uri: str,
secrets: list[dict[str, str]] | None = None,
engine_runtime_mode: str | None = None,
deployed_api_version: str | None = None,
) -> dict[str, Any]:
payload: dict[str, Any] = {
"source_revision_config": {"image_uri": image_uri},
}
if secrets is not None:
payload["secrets"] = secrets
if engine_runtime_mode is not None:
payload["engine_runtime_mode"] = engine_runtime_mode
if deployed_api_version is not None:
payload["deployed_api_version"] = deployed_api_version
return self._request(
"PATCH",
f"/v2/deployments/{deployment_id}",
payload,
)
def list_revisions(self, deployment_id: str, limit: int = 1) -> dict[str, Any]:
return self._request(
"GET",
f"/v2/deployments/{deployment_id}/revisions?limit={limit}",
)
def get_revision(self, deployment_id: str, revision_id: str) -> dict[str, Any]:
return self._request(
"GET",
f"/v2/deployments/{deployment_id}/revisions/{revision_id}",
)
+25 -6
View File
@@ -12,8 +12,12 @@ class Progress:
while True:
yield from "|/-\\"
def __init__(self, *, message=""):
def __init__(self, *, message="", elapsed: bool = False):
self.message = message
self._base_message = message
self._show_elapsed = elapsed
# use this to make sure we don't kill thread when we set msg to ""
self._stop = threading.Event()
self.spinner_generator = self.spinning_cursor()
def spinner_iteration(self):
@@ -29,9 +33,23 @@ class Progress:
)
sys.stdout.flush()
def _format_elapsed(self, seconds: float) -> str:
mins, secs = divmod(int(seconds), 60)
if mins:
return f"{self._base_message} ({mins}m {secs:02d}s)"
return f"{self._base_message} ({secs}s)"
def spinner_task(self):
while self.message:
start = time.monotonic()
while not self._stop.is_set():
if not self.message:
time.sleep(self.delay)
continue
if self._show_elapsed:
self.message = self._format_elapsed(time.monotonic() - start)
message = self.message
if not message:
continue
sys.stdout.write(next(self.spinner_generator) + " " + message)
sys.stdout.flush()
time.sleep(self.delay)
@@ -50,21 +68,22 @@ class Progress:
def set_message(message):
self.message = message
if not message:
self.thread.join()
self._base_message = message or self._base_message
return set_message
else:
def set_message(message):
sys.stderr.write(message + "\n")
sys.stderr.flush()
if message:
sys.stderr.write(message + "\n")
sys.stderr.flush()
return set_message
def __exit__(self, exception, value, tb):
if sys.stdout.isatty():
self.message = ""
self._stop.set()
try:
self.thread.join()
finally:
+2 -1
View File
@@ -13,7 +13,9 @@ license = "MIT"
license-files = ['LICENSE']
dependencies = [
"click>=8.1.7",
"httpx>=0.24.0",
"langgraph-sdk>=0.1.0 ; python_version >= '3.11'",
"python-dotenv>=0.8.0",
]
[tool.hatch.version]
path = "langgraph_cli/__init__.py"
@@ -21,7 +23,6 @@ path = "langgraph_cli/__init__.py"
inmem = [
"langgraph-api>=0.5.35,<0.8.0 ; python_version >= '3.11'",
"langgraph-runtime-inmem>=0.7 ; python_version >= '3.11'",
"python-dotenv>=0.8.0",
]
[project.urls]
+179 -3
View File
@@ -382,9 +382,10 @@ def test_dockerfile_command_with_base_image() -> None:
assert save_path.exists()
with open(save_path) as f:
dockerfile = f.read()
assert re.match("FROM langchain/langgraph-server:0.2-py3.*", dockerfile), (
"\n".join(dockerfile.splitlines()[:3])
)
assert re.match(
r"FROM langchain/langgraph-server:\d+\.\d+\.\d+-py3\..*",
dockerfile,
), "\n".join(dockerfile.splitlines()[:3])
def test_dockerfile_command_with_docker_compose() -> None:
@@ -567,6 +568,8 @@ def test_build_generate_proper_build_context():
"test-image",
"--config",
str(temp_dir / "config.json"),
"--engine-runtime-mode",
"combined_queue_worker",
],
catch_exceptions=True,
)
@@ -602,6 +605,8 @@ def test_dockerfile_command_with_api_version() -> None:
str(temp_dir / "config.json"),
"--api-version",
"0.2.74",
"--engine-runtime-mode",
"combined_queue_worker",
],
)
@@ -718,6 +723,8 @@ def test_build_command_with_api_version() -> None:
str(temp_dir / "config.json"),
"--api-version",
"0.2.74",
"--engine-runtime-mode",
"combined_queue_worker",
"--no-pull", # Avoid pulling non-existent images
],
catch_exceptions=True,
@@ -822,3 +829,172 @@ 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 re.search(
r"FROM langchain/langgraph-executor:\d+\.\d+\.\d+-py3\.11",
dockerfile,
), dockerfile.splitlines()[0]
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 re.search(
r"FROM langchain/langgraph-api:\d+\.\d+\.\d+-py3\.11",
dockerfile,
), dockerfile.splitlines()[0]
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 re.search(
r"FROM my-custom-executor:\d+\.\d+\.\d+-py3\.11",
dockerfile,
), dockerfile.splitlines()[0]
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_version="0.7.67",
)
# API service should use langgraph-api base image with pinned version
assert "FROM langchain/langgraph-api:0.7.67-py3.11" 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 with pinned version
assert "langgraph-orchestrator:" in actual_stdin
assert "langchain/langgraph-orchestrator-licensed:0.7.67" in actual_stdin
# Executor service present with correct base image
assert "langgraph-executor:" in actual_stdin
assert "FROM langchain/langgraph-executor:0.7.67-py3.11" in actual_stdin
assert "executor_entrypoint.sh" in actual_stdin
def test_prepare_args_and_stdin_distributed_with_api_version() -> None:
"""All 3 images should use the same api_version in distributed mode."""
config_path = pathlib.Path(__file__).parent / "langgraph.json"
config = validate_config(
Config(dependencies=["."], graphs={"agent": "agent.py:graph"})
)
actual_args, actual_stdin = prepare_args_and_stdin(
capabilities=DEFAULT_DOCKER_CAPABILITIES,
config_path=config_path,
config=config,
docker_compose=None,
port=8000,
watch=False,
engine_runtime_mode="distributed",
api_version="0.7.67",
)
assert "FROM langchain/langgraph-api:0.7.67-py3.11" in actual_stdin
assert "FROM langchain/langgraph-executor:0.7.67-py3.11" in actual_stdin
assert "langchain/langgraph-orchestrator-licensed:0.7.67" in actual_stdin
@@ -0,0 +1,156 @@
import io
import json
import pathlib
import urllib.error
import urllib.request
import click
import pytest
from langgraph_cli.api_version import (
_fetch_matching_version,
resolve_langgraph_api_version,
)
@pytest.fixture()
def config_dir(tmp_path: pathlib.Path) -> pathlib.Path:
return tmp_path
def _write_config(
config_dir: pathlib.Path, api_version: str | None = None
) -> pathlib.Path:
cfg: dict = {"dependencies": ["."], "graphs": {"agent": "agent.py:graph"}}
if api_version is not None:
cfg["api_version"] = api_version
path = config_dir / "langgraph.json"
path.write_text(json.dumps(cfg))
return path
class TestResolveLanggraphApiVersion:
def test_exact_patch_from_cli(self, config_dir: pathlib.Path) -> None:
path = _write_config(config_dir)
assert resolve_langgraph_api_version(path, "0.7.67") == "0.7.67"
def test_exact_patch_from_json(self, config_dir: pathlib.Path) -> None:
path = _write_config(config_dir, api_version="0.7.67")
assert resolve_langgraph_api_version(path, None) == "0.7.67"
def test_neither_source_fetches_latest(
self, config_dir: pathlib.Path, monkeypatch: pytest.MonkeyPatch
) -> None:
path = _write_config(config_dir)
fake_body = json.dumps(
{"results": [{"name": "latest"}, {"name": "0.9.2"}]}
).encode()
def mock_urlopen(url, *, timeout=None):
assert "name=" not in url
return io.BytesIO(fake_body)
monkeypatch.setattr(urllib.request, "urlopen", mock_urlopen)
assert resolve_langgraph_api_version(path, None) == "0.9.2"
def test_both_sources_raises(self, config_dir: pathlib.Path) -> None:
path = _write_config(config_dir, api_version="0.7.67")
with pytest.raises(click.ClickException, match="both"):
resolve_langgraph_api_version(path, "0.8.0")
def test_partial_version_resolves_from_dockerhub(
self, config_dir: pathlib.Path, monkeypatch: pytest.MonkeyPatch
) -> None:
path = _write_config(config_dir, api_version="0.7")
fake_body = json.dumps(
{"results": [{"name": "latest"}, {"name": "0.7.67"}]}
).encode()
def mock_urlopen(url, *, timeout=None):
assert "name=0.7" in url
return io.BytesIO(fake_body)
monkeypatch.setattr(urllib.request, "urlopen", mock_urlopen)
assert resolve_langgraph_api_version(path, None) == "0.7.67"
def test_partial_cli_version_resolves_from_dockerhub(
self, config_dir: pathlib.Path, monkeypatch: pytest.MonkeyPatch
) -> None:
path = _write_config(config_dir)
fake_body = json.dumps(
{"results": [{"name": "0.8.1"}, {"name": "0.8.0"}]}
).encode()
def mock_urlopen(url, *, timeout=None):
assert "name=0.8" in url
return io.BytesIO(fake_body)
monkeypatch.setattr(urllib.request, "urlopen", mock_urlopen)
assert resolve_langgraph_api_version(path, "0.8") == "0.8.1"
def test_missing_config_file_fetches_latest(
self, config_dir: pathlib.Path, monkeypatch: pytest.MonkeyPatch
) -> None:
path = config_dir / "nonexistent.json"
fake_body = json.dumps({"results": [{"name": "0.9.2"}]}).encode()
def mock_urlopen(url, *, timeout=None):
return io.BytesIO(fake_body)
monkeypatch.setattr(urllib.request, "urlopen", mock_urlopen)
assert resolve_langgraph_api_version(path, None) == "0.9.2"
def test_missing_config_file_with_cli_version(
self, config_dir: pathlib.Path
) -> None:
path = config_dir / "nonexistent.json"
assert resolve_langgraph_api_version(path, "0.7.67") == "0.7.67"
class TestFetchMatchingVersion:
def test_empty_prefix_returns_latest(self, monkeypatch: pytest.MonkeyPatch) -> None:
fake_body = json.dumps(
{"results": [{"name": "latest"}, {"name": "0.9.2"}, {"name": "abc123"}]}
).encode()
def mock_urlopen(url, *, timeout=None):
assert "name=" not in url
return io.BytesIO(fake_body)
monkeypatch.setattr(urllib.request, "urlopen", mock_urlopen)
assert _fetch_matching_version() == "0.9.2"
def test_returns_first_semver(self, monkeypatch: pytest.MonkeyPatch) -> None:
fake_body = json.dumps(
{"results": [{"name": "latest"}, {"name": "abc1234"}, {"name": "0.7.67"}]}
).encode()
def mock_urlopen(url, *, timeout=None):
return io.BytesIO(fake_body)
monkeypatch.setattr(urllib.request, "urlopen", mock_urlopen)
assert _fetch_matching_version("0.7") == "0.7.67"
def test_no_semver_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
fake_body = json.dumps(
{"results": [{"name": "latest"}, {"name": "abc1234"}]}
).encode()
def mock_urlopen(url, *, timeout=None):
return io.BytesIO(fake_body)
monkeypatch.setattr(urllib.request, "urlopen", mock_urlopen)
with pytest.raises(click.ClickException, match="Could not find a version"):
_fetch_matching_version("0.7")
def test_network_error_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
def mock_urlopen(url, *, timeout=None):
raise urllib.error.URLError("connection refused")
monkeypatch.setattr(urllib.request, "urlopen", mock_urlopen)
with pytest.raises(click.ClickException, match="Failed to fetch"):
_fetch_matching_version("0.7")
+224
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,229 @@ 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_version="0.7.67",
)
# API service uses langchain/langgraph-api base image
assert "FROM langchain/langgraph-api:0.7.67-py3.11" in actual_compose_stdin
# Orchestrator service is present with pinned version
assert "langgraph-orchestrator:" in actual_compose_stdin
assert "langchain/langgraph-orchestrator-licensed:0.7.67" 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:0.7.67-py3.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",
api_version="0.7.67",
)
# 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",
api_version="0.7.67",
)
# 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:0.7.67-py3.11" in from_lines[0]
assert "FROM langchain/langgraph-executor:0.7.67-py3.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",
api_version="0.7.67",
)
# 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)}"
)
def test_config_to_compose_distributed_orchestrator_uses_api_version():
"""Orchestrator image tag should use the api_version when provided."""
graphs = {"agent": "./agent.py:graph"}
actual = config_to_compose(
PATH_TO_CONFIG,
validate_config({"dependencies": ["."], "graphs": graphs}),
"langchain/langgraph-api",
engine_runtime_mode="distributed",
api_version="0.7.67",
)
assert "langchain/langgraph-orchestrator-licensed:0.7.67" in actual
def test_config_to_compose_distributed_all_images_same_version():
"""All 3 images (api, executor, orchestrator) should use the same api_version."""
graphs = {"agent": "./agent.py:graph"}
actual = config_to_compose(
PATH_TO_CONFIG,
validate_config({"dependencies": ["."], "graphs": graphs}),
"langchain/langgraph-api",
engine_runtime_mode="distributed",
api_version="0.7.67",
)
assert "FROM langchain/langgraph-api:0.7.67-py3.11" in actual
assert "FROM langchain/langgraph-executor:0.7.67-py3.11" in actual
assert "langchain/langgraph-orchestrator-licensed:0.7.67" in actual
class TestHasDisallowedBuildCommandContent:
"""Tests for has_disallowed_build_command_content."""
@@ -0,0 +1,134 @@
import base64
import json
import os
import click
import pytest
from langgraph_cli.cli import (
_docker_config_for_token,
_normalize_image_name,
_normalize_image_tag,
_parse_env_from_config,
)
class TestDockerConfigForToken:
def test_creates_config_json(self):
with _docker_config_for_token("us-docker.pkg.dev", "my-token") as cfg:
config_path = os.path.join(cfg, "config.json")
assert os.path.isfile(config_path)
with open(config_path) as f:
data = json.load(f)
expected_auth = base64.b64encode(b"oauth2accesstoken:my-token").decode()
assert data == {"auths": {"us-docker.pkg.dev": {"auth": expected_auth}}}
def test_tempdir_cleaned_up(self):
with _docker_config_for_token("registry.example.com", "tok") as cfg:
assert os.path.isdir(cfg)
assert not os.path.exists(cfg)
def test_different_registries(self):
with _docker_config_for_token("gcr.io", "token123") as cfg:
with open(os.path.join(cfg, "config.json")) as f:
data = json.load(f)
assert "gcr.io" in data["auths"]
class TestNormalizeImageName:
def test_simple_name(self):
assert _normalize_image_name("myapp") == "myapp"
def test_uppercase_lowered(self):
assert _normalize_image_name("MyApp") == "myapp"
def test_special_chars_replaced(self):
assert _normalize_image_name("my app!@#v2") == "my-app-v2"
def test_dots_and_hyphens_kept(self):
assert _normalize_image_name("my-app.v2") == "my-app.v2"
def test_leading_trailing_stripped(self):
assert _normalize_image_name("--my-app..") == "my-app"
def test_empty_string_returns_app(self):
assert _normalize_image_name("") == "app"
def test_none_returns_app(self):
assert _normalize_image_name(None) == "app"
def test_all_invalid_chars_returns_app(self):
assert _normalize_image_name("!!!") == "app"
class TestNormalizeImageTag:
def test_valid_tag(self):
assert _normalize_image_tag("v1.2.3") == "v1.2.3"
def test_empty_defaults_to_latest(self):
assert _normalize_image_tag("") == "latest"
def test_alphanumeric_and_special(self):
assert _normalize_image_tag("my_tag-1.0") == "my_tag-1.0"
def test_invalid_chars_raises(self):
with pytest.raises(click.UsageError, match="Image tag may only contain"):
_normalize_image_tag("v1.0:bad")
def test_spaces_raises(self):
with pytest.raises(click.UsageError, match="Image tag may only contain"):
_normalize_image_tag("has space")
class TestParseEnvFromConfig:
def test_env_dict(self, tmp_path):
config_path = tmp_path / "langgraph.json"
config_path.touch()
result = _parse_env_from_config({"env": {"FOO": "bar", "NUM": 42}}, config_path)
assert result == {"FOO": "bar", "NUM": "42"}
def test_env_string_dotenv_file(self, tmp_path):
env_file = tmp_path / "my.env"
env_file.write_text("KEY1=val1\nKEY2=val2\n")
config_path = tmp_path / "langgraph.json"
config_path.touch()
result = _parse_env_from_config({"env": "my.env"}, config_path)
assert result == {"KEY1": "val1", "KEY2": "val2"}
def test_env_missing_falls_back_to_dotenv(self, tmp_path, monkeypatch):
env_file = tmp_path / ".env"
env_file.write_text("DEFAULT_KEY=default_val\n")
monkeypatch.chdir(tmp_path)
config_path = tmp_path / "langgraph.json"
config_path.touch()
result = _parse_env_from_config({}, config_path)
assert result == {"DEFAULT_KEY": "default_val"}
def test_env_empty_dict_falls_back_to_dotenv(self, tmp_path, monkeypatch):
"""validate_config defaults env to {}, should still fall back to .env."""
env_file = tmp_path / ".env"
env_file.write_text("MY_KEY=my_val\n")
monkeypatch.chdir(tmp_path)
config_path = tmp_path / "langgraph.json"
config_path.touch()
result = _parse_env_from_config({"env": {}}, config_path)
assert result == {"MY_KEY": "my_val"}
def test_env_missing_no_dotenv_returns_empty(self, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
config_path = tmp_path / "langgraph.json"
config_path.touch()
result = _parse_env_from_config({}, config_path)
assert result == {}
def test_env_dotenv_filters_none_values(self, tmp_path):
# Lines like "KEY=" produce empty string, lines like "KEY" produce None
env_file = tmp_path / "test.env"
env_file.write_text("GOOD=value\nEMPTY=\n")
config_path = tmp_path / "langgraph.json"
config_path.touch()
result = _parse_env_from_config({"env": "test.env"}, config_path)
assert "GOOD" in result
assert result["GOOD"] == "value"
# EMPTY= gives empty string, not None, so it should be present
assert result["EMPTY"] == ""
+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",
[
@@ -0,0 +1,99 @@
import json
import pathlib
import click
import pytest
from langgraph_cli.engine_runtime_mode import resolve_engine_runtime_mode
def _write_config(
tmp_path: pathlib.Path,
*,
python_version: str | None = "3.11",
node_version: str | None = None,
) -> pathlib.Path:
cfg: dict = {"dependencies": ["."], "graphs": {"agent": "agent.py:graph"}}
if python_version is not None:
cfg["python_version"] = python_version
if node_version is not None:
cfg["node_version"] = node_version
path = tmp_path / "langgraph.json"
path.write_text(json.dumps(cfg))
return path
class TestResolveEngineRuntimeMode:
# -- cli_param == "distributed" -------------------------------------------
def test_distributed_explicit_new_version(self, tmp_path: pathlib.Path) -> None:
path = _write_config(tmp_path)
assert (
resolve_engine_runtime_mode(path, "0.7.68", "distributed") == "distributed"
)
def test_distributed_explicit_old_version_raises(
self, tmp_path: pathlib.Path
) -> None:
path = _write_config(tmp_path)
with pytest.raises(click.ClickException, match="0.7.67"):
resolve_engine_runtime_mode(path, "0.7.67", "distributed")
def test_distributed_explicit_js_raises(self, tmp_path: pathlib.Path) -> None:
path = _write_config(tmp_path, python_version=None, node_version="20")
with pytest.raises(click.ClickException, match="JavaScript"):
resolve_engine_runtime_mode(path, "0.8.0", "distributed")
def test_distributed_explicit_js_and_old_version_raises(
self, tmp_path: pathlib.Path
) -> None:
path = _write_config(tmp_path, python_version=None, node_version="20")
with pytest.raises(click.ClickException, match="JavaScript.*0.7.60"):
resolve_engine_runtime_mode(path, "0.7.60", "distributed")
# -- cli_param == "combined_queue_worker" ----------------------------------
def test_combined_explicit(self, tmp_path: pathlib.Path) -> None:
path = _write_config(tmp_path)
assert (
resolve_engine_runtime_mode(path, "0.8.0", "combined_queue_worker")
== "combined_queue_worker"
)
def test_combined_explicit_old_version(self, tmp_path: pathlib.Path) -> None:
path = _write_config(tmp_path)
assert (
resolve_engine_runtime_mode(path, "0.7.67", "combined_queue_worker")
== "combined_queue_worker"
)
# -- cli_param is None (default) -------------------------------------------
def test_default_is_distributed(self, tmp_path: pathlib.Path) -> None:
path = _write_config(tmp_path)
assert resolve_engine_runtime_mode(path, "0.8.0", None) == "distributed"
def test_default_old_version(self, tmp_path: pathlib.Path) -> None:
path = _write_config(tmp_path)
assert resolve_engine_runtime_mode(path, "0.7.67", None) == "distributed"
def test_default_js(self, tmp_path: pathlib.Path) -> None:
path = _write_config(tmp_path, python_version=None, node_version="20")
assert resolve_engine_runtime_mode(path, "0.8.0", None) == "distributed"
# -- edge: version boundary ------------------------------------------------
def test_version_boundary_0_7_67_blocks_distributed(
self, tmp_path: pathlib.Path
) -> None:
path = _write_config(tmp_path)
with pytest.raises(click.ClickException):
resolve_engine_runtime_mode(path, "0.7.67", "distributed")
def test_version_boundary_0_7_68_allows_distributed(
self, tmp_path: pathlib.Path
) -> None:
path = _write_config(tmp_path)
assert (
resolve_engine_runtime_mode(path, "0.7.68", "distributed") == "distributed"
)
@@ -0,0 +1,233 @@
import httpx
import pytest
from langgraph_cli.host_backend import HostBackendClient, HostBackendError
@pytest.fixture
def mock_transport():
return httpx.MockTransport(lambda req: httpx.Response(200, json={"ok": True}))
@pytest.fixture
def client(mock_transport):
c = HostBackendClient("https://api.example.com", "test-key")
c._client = httpx.Client(
base_url="https://api.example.com",
transport=mock_transport,
headers={"X-Api-Key": "test-key", "Accept": "application/json"},
timeout=30,
)
return c
def test_constructor_strips_trailing_slash():
c = HostBackendClient("https://api.example.com/", "key")
assert str(c._client.base_url) == "https://api.example.com"
def test_constructor_empty_url_raises():
with pytest.raises(Exception, match="Host backend URL is required"):
HostBackendClient("", "key")
def test_request_sends_headers():
def handler(req: httpx.Request) -> httpx.Response:
assert req.headers["x-api-key"] == "test-key"
assert req.headers["accept"] == "application/json"
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._request("GET", "/test")
assert result == {"ok": True}
def test_request_sends_json_payload():
def handler(req: httpx.Request) -> httpx.Response:
assert req.headers["content-type"] == "application/json"
assert req.content == b'{"key":"value"}'
return httpx.Response(200, json={"created": 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._request("POST", "/test", {"key": "value"})
assert result == {"created": True}
def test_request_empty_body_returns_none():
transport = httpx.MockTransport(lambda req: httpx.Response(200, content=b""))
c = HostBackendClient("https://api.example.com", "test-key")
c._client = httpx.Client(
base_url="https://api.example.com",
transport=transport,
headers={"X-Api-Key": "test-key", "Accept": "application/json"},
timeout=30,
)
assert c._request("DELETE", "/test") is None
def test_request_http_error_raises():
transport = httpx.MockTransport(lambda req: httpx.Response(404, text="not found"))
c = HostBackendClient("https://api.example.com", "test-key")
c._client = httpx.Client(
base_url="https://api.example.com",
transport=transport,
headers={"X-Api-Key": "test-key", "Accept": "application/json"},
timeout=30,
)
with pytest.raises(HostBackendError, match="404"):
c._request("GET", "/missing")
def test_request_invalid_json_raises():
transport = httpx.MockTransport(
lambda req: httpx.Response(200, content=b"not json")
)
c = HostBackendClient("https://api.example.com", "test-key")
c._client = httpx.Client(
base_url="https://api.example.com",
transport=transport,
headers={"X-Api-Key": "test-key", "Accept": "application/json"},
timeout=30,
)
with pytest.raises(HostBackendError, match="Failed to decode"):
c._request("GET", "/bad-json")
def test_request_transport_error_raises():
def handler(req: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("connection refused")
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,
)
with pytest.raises(HostBackendError, match="connection refused"):
c._request("GET", "/test")
def test_create_deployment(client):
result = client.create_deployment({"name": "my-deploy"})
assert result == {"ok": True}
def test_get_deployment(client):
result = client.get_deployment("dep-123")
assert result == {"ok": True}
def test_list_deployments(client):
result = client.list_deployments("my-app")
assert result == {"ok": True}
def test_request_push_token(client):
result = client.request_push_token("dep-123")
assert result == {"ok": True}
def test_update_deployment(client):
result = client.update_deployment(
"dep-123", "image:latest", secrets=[{"name": "KEY", "value": "val"}]
)
assert result == {"ok": True}
def test_update_deployment_no_secrets(client):
result = client.update_deployment("dep-123", "image:latest")
assert result == {"ok": True}
def test_update_deployment_with_engine_runtime_mode():
"""Verify engine_runtime_mode is included in the PATCH payload."""
import json
def handler(req: httpx.Request) -> httpx.Response:
body = json.loads(req.content)
assert body["engine_runtime_mode"] == "distributed"
assert body["source_revision_config"]["image_uri"] == "img:v1"
return httpx.Response(200, json={"id": "dep-1"})
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.update_deployment("dep-1", "img:v1", engine_runtime_mode="distributed")
assert result == {"id": "dep-1"}
def test_update_deployment_with_deployed_api_version():
"""Verify deployed_api_version is included in the PATCH payload."""
import json
def handler(req: httpx.Request) -> httpx.Response:
body = json.loads(req.content)
assert body["deployed_api_version"] == "0.3.5"
assert body["engine_runtime_mode"] == "distributed"
assert body["source_revision_config"]["image_uri"] == "img:v2"
assert body["secrets"] == [{"name": "K", "value": "V"}]
return httpx.Response(200, json={"id": "dep-2"})
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.update_deployment(
"dep-2",
"img:v2",
secrets=[{"name": "K", "value": "V"}],
engine_runtime_mode="distributed",
deployed_api_version="0.3.5",
)
assert result == {"id": "dep-2"}
def test_update_deployment_omits_none_fields():
"""Verify None values for optional fields are not sent in payload."""
import json
def handler(req: httpx.Request) -> httpx.Response:
body = json.loads(req.content)
assert "engine_runtime_mode" not in body
assert "deployed_api_version" not in body
assert "secrets" not in body
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,
)
c.update_deployment("dep-3", "img:v1")
def test_list_revisions(client):
result = client.list_revisions("dep-123", limit=5)
assert result == {"ok": True}
def test_get_revision(client):
result = client.get_revision("dep-123", "rev-456")
assert result == {"ok": True}
+4 -1
View File
@@ -983,7 +983,9 @@ name = "langgraph-cli"
source = { editable = "." }
dependencies = [
{ name = "click" },
{ name = "httpx" },
{ name = "langgraph-sdk", marker = "python_full_version >= '3.11'" },
{ name = "python-dotenv" },
]
[package.optional-dependencies]
@@ -1021,10 +1023,11 @@ test = [
[package.metadata]
requires-dist = [
{ name = "click", specifier = ">=8.1.7" },
{ name = "httpx", specifier = ">=0.24.0" },
{ name = "langgraph-api", marker = "python_full_version >= '3.11' and extra == 'inmem'", specifier = ">=0.5.35,<0.8.0" },
{ name = "langgraph-runtime-inmem", marker = "python_full_version >= '3.11' and extra == 'inmem'", specifier = ">=0.7" },
{ name = "langgraph-sdk", marker = "python_full_version >= '3.11'", specifier = ">=0.1.0" },
{ name = "python-dotenv", marker = "extra == 'inmem'", specifier = ">=0.8.0" },
{ name = "python-dotenv", specifier = ">=0.8.0" },
]
provides-extras = ["inmem"]
@@ -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)
+8 -2
View File
@@ -191,8 +191,14 @@ def add_messages(
if not isinstance(right, list):
right = [right] # type: ignore[assignment]
# coerce to message
left = [message_chunk_to_message(m) for m in convert_to_messages(left)]
right = [message_chunk_to_message(m) for m in convert_to_messages(right)]
left = [
message_chunk_to_message(cast(BaseMessageChunk, m))
for m in convert_to_messages(left)
]
right = [
message_chunk_to_message(cast(BaseMessageChunk, m))
for m in convert_to_messages(right)
]
# assign missing ids
for m in left:
if m.id is None:
+24 -55
View File
@@ -220,7 +220,6 @@ def apply_writes(
tasks: Iterable[WritesProtocol],
get_next_version: GetNextVersion | None,
trigger_to_nodes: Mapping[str, Sequence[str]],
available_channels: set[str] | None = None,
) -> set[str]:
"""Apply writes from a set of tasks (usually the tasks from a Pregel step)
to the checkpoint and channels, and return managed values writes to be applied
@@ -267,18 +266,6 @@ def apply_writes(
None,
)
# Sync available_channels with channel's actual availability state.
# Returns True if the channel is available (for callers that also need
# to update updated_channels).
def _track(chan: str) -> bool:
avail = channels[chan].is_available()
if available_channels is not None:
if avail:
available_channels.add(chan)
else:
available_channels.discard(chan)
return avail
# Consume all channels that were read
for chan in {
chan
@@ -288,7 +275,6 @@ def apply_writes(
}:
if channels[chan].consume() and next_version is not None:
checkpoint["channel_versions"][chan] = next_version
_track(chan)
# Group writes by channel
pending_writes_by_channel: dict[str, list[Any]] = defaultdict(list)
@@ -310,28 +296,18 @@ def apply_writes(
if channels[chan].update(vals) and next_version is not None:
checkpoint["channel_versions"][chan] = next_version
# unavailable channels can't trigger tasks, so don't add them
if _track(chan):
if channels[chan].is_available():
updated_channels.add(chan)
else:
_track(chan)
# Channels that weren't updated in this step are notified of a new step
if bump_step:
candidates = (
available_channels - updated_channels
if available_channels is not None
else (
chan
for chan in channels
if channels[chan].is_available() and chan not in updated_channels
)
)
for chan in candidates:
if channels[chan].update(EMPTY_SEQ) and next_version is not None:
checkpoint["channel_versions"][chan] = next_version
# unavailable channels can't trigger tasks, so don't add them
if _track(chan):
updated_channels.add(chan)
for chan in channels:
if channels[chan].is_available() and chan not in updated_channels:
if channels[chan].update(EMPTY_SEQ) and next_version is not None:
checkpoint["channel_versions"][chan] = next_version
# unavailable channels can't trigger tasks, so don't add them
if channels[chan].is_available():
updated_channels.add(chan)
# If this is (tentatively) the last superstep, notify all channels of finish
if bump_step and updated_channels.isdisjoint(trigger_to_nodes):
@@ -339,10 +315,8 @@ def apply_writes(
if channels[chan].finish() and next_version is not None:
checkpoint["channel_versions"][chan] = next_version
# unavailable channels can't trigger tasks, so don't add them
if _track(chan):
if channels[chan].is_available():
updated_channels.add(chan)
else:
_track(chan)
# Return managed values writes to be applied externally
return updated_channels
@@ -520,7 +494,7 @@ PUSH_TRIGGER = (PUSH,)
class _TaskIDFn(Protocol):
def __call__(self, namespace: bytes, *parts: str) -> str:
def __call__(self, namespace: bytes, *parts: str | bytes) -> str:
pass
@@ -1191,37 +1165,32 @@ def _proc_input(
return val
def _uuid5_str(namespace: bytes, *parts: str) -> str:
def _uuid5_str(namespace: bytes, *parts: str | bytes) -> str:
"""Generate a UUID from the SHA-1 hash of a namespace and str parts."""
sha = sha1(namespace, usedforsecurity=False)
sha.update(b"".join(p.encode() for p in parts))
sha.update(b"".join(p.encode() if isinstance(p, str) else p for p in parts))
hex = sha.hexdigest()
return f"{hex[:8]}-{hex[8:12]}-{hex[12:16]}-{hex[16:20]}-{hex[20:32]}"
def _xxhash_str(namespace: bytes, *parts: str) -> str:
def _xxhash_str(namespace: bytes, *parts: str | bytes) -> str:
"""Generate a UUID from the XXH3 hash of a namespace and str parts."""
hex = xxh3_128_hexdigest(namespace + b"".join(p.encode() for p in parts))
hex = xxh3_128_hexdigest(
namespace + b"".join(p.encode() if isinstance(p, str) else p for p in parts)
)
return f"{hex[:8]}-{hex[8:12]}-{hex[12:16]}-{hex[16:20]}-{hex[20:32]}"
def task_path_str(tup: str | int | tuple | list) -> str:
def task_path_str(tup: str | int | tuple) -> str:
"""Generate a string representation of the task path."""
if isinstance(tup, (tuple, list)):
parts: list[str] = []
for x in tup:
if isinstance(x, int):
parts.append(f"{x:010d}")
elif isinstance(x, (tuple, list)):
parts.append(task_path_str(x))
else:
parts.append(str(x))
return f"~{', '.join(parts)}"
elif isinstance(tup, int):
return f"{tup:010d}"
else:
return str(tup)
return (
f"~{', '.join(task_path_str(x) for x in tup)}"
if isinstance(tup, (tuple, list))
else f"{tup:010d}"
if isinstance(tup, int)
else str(tup)
)
LAZY_ATOMIC_COUNTER_LOCK = threading.Lock()
+99 -34
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]
@@ -180,7 +182,6 @@ class PregelLoop:
_migrate_checkpoint: Callable[[Checkpoint], None] | None
submit: Submit
channels: Mapping[str, BaseChannel]
_available_channels: set[str]
managed: ManagedValueMapping
checkpoint: Checkpoint
checkpoint_id_saved: str
@@ -245,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
@@ -452,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
@@ -516,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
@@ -546,7 +547,6 @@ class PregelLoop:
self.tasks.values(),
self.checkpointer_get_next_version,
self.trigger_to_nodes,
available_channels=self._available_channels,
)
# produce values output
if not self.updated_channels.isdisjoint(
@@ -559,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
@@ -620,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")
@@ -637,9 +643,25 @@ 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.
# We check two conditions because resume signals arrive differently:
# - Command(resume=...): the outer graph receives resume via input
# - CONFIG_KEY_RESUMING: child subgraphs receive it via config from
# the parent (their input is a Send arg, not a Command)
if self.is_replaying and not (
(input_is_command and cast(Command, self.input).resume is not None)
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"
@@ -659,7 +681,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:
@@ -677,7 +699,6 @@ class PregelLoop:
[PregelTaskWrites((), INPUT, null_writes, [])],
self.checkpointer_get_next_version,
self.trigger_to_nodes,
available_channels=self._available_channels,
)
if updated_channels is not None:
updated_channels.update(null_updated_channels)
@@ -720,17 +741,36 @@ class PregelLoop:
],
self.checkpointer_get_next_version,
self.trigger_to_nodes,
available_channels=self._available_channels,
)
# save input checkpoint
self.updated_channels = updated_channels
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"
@@ -848,7 +888,6 @@ class PregelLoop:
self.tasks.values(),
self.checkpointer_get_next_version,
self.trigger_to_nodes,
available_channels=self._available_channels,
)
if not updated_channels.isdisjoint(
(self.output_keys,)
@@ -1086,10 +1125,27 @@ 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.is_nested and (
replay_state := self.config[CONF].get(CONFIG_KEY_REPLAY_STATE)
):
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. If a specific checkpoint_id is in the config,
# fetch that exact checkpoint; otherwise fetch the latest one.
# Returns None on first invocation (no checkpoints exist yet).
saved = self.checkpointer.get_tuple(self.checkpoint_config)
if saved is None:
saved = CheckpointTuple(
self.checkpoint_config, empty_checkpoint(), {"step": -2}, None, []
@@ -1114,14 +1170,10 @@ 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
)
self._available_channels: set[str] = {
k for k, v in self.channels.items() if v.is_available()
}
self.stack.push(self._suppress_interrupt)
self.status = "input"
self.step = self.checkpoint_metadata["step"] + 1
@@ -1268,10 +1320,27 @@ 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.is_nested and (
replay_state := self.config[CONF].get(CONFIG_KEY_REPLAY_STATE)
):
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. If a specific checkpoint_id is in the config,
# fetch that exact checkpoint; otherwise fetch the latest one.
# Returns None on first invocation (no checkpoints exist yet).
saved = await self.checkpointer.aget_tuple(self.checkpoint_config)
if saved is None:
saved = CheckpointTuple(
self.checkpoint_config, empty_checkpoint(), {"step": -2}, None, []
@@ -1296,16 +1365,12 @@ class AsyncPregelLoop(PregelLoop, AbstractAsyncContextManager):
if saved.pending_writes is not None
else []
)
self.submit = await self.stack.enter_async_context(
AsyncBackgroundExecutor(self.config)
)
self.channels, self.managed = channels_from_checkpoint(
self.specs, self.checkpoint
)
self._available_channels: set[str] = {
k for k, v in self.channels.items() if v.is_available()
}
self.stack.push(self._suppress_interrupt)
self.status = "input"
self.step = self.checkpoint_metadata["step"] + 1
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+8 -6
View File
@@ -1689,23 +1689,25 @@ name = "langgraph-cli"
source = { editable = "../cli" }
dependencies = [
{ name = "click", marker = "python_full_version < '3.14'" },
{ name = "httpx", marker = "python_full_version < '3.14'" },
{ name = "langgraph-sdk", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
{ name = "python-dotenv", marker = "python_full_version < '3.14'" },
]
[package.optional-dependencies]
inmem = [
{ name = "langgraph-api", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
{ name = "langgraph-runtime-inmem", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
{ name = "python-dotenv", marker = "python_full_version < '3.14'" },
]
[package.metadata]
requires-dist = [
{ name = "click", specifier = ">=8.1.7" },
{ name = "httpx", specifier = ">=0.24.0" },
{ name = "langgraph-api", marker = "python_full_version >= '3.11' and extra == 'inmem'", specifier = ">=0.5.35,<0.8.0" },
{ name = "langgraph-runtime-inmem", marker = "python_full_version >= '3.11' and extra == 'inmem'", specifier = ">=0.7" },
{ name = "langgraph-sdk", marker = "python_full_version >= '3.11'", specifier = ">=0.1.0" },
{ name = "python-dotenv", marker = "extra == 'inmem'", specifier = ">=0.8.0" },
{ name = "python-dotenv", specifier = ">=0.8.0" },
]
provides-extras = ["inmem"]
@@ -1826,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" },
@@ -638,18 +638,15 @@ def create_react_agent(
messages = (
_get_state_value(state, "llm_input_messages")
) or _get_state_value(state, "messages")
error_msg = f"Expected input to call_model to have 'llm_input_messages' or 'messages' key, but got {state}"
else:
messages = _get_state_value(state, "messages")
error_msg = (
f"Expected input to call_model to have 'messages' key, but got {state}"
)
if messages is None:
if pre_model_hook is not None:
raise ValueError(
f"Expected input to call_model to have 'llm_input_messages' or 'messages' key, but got {state}"
)
else:
raise ValueError(
f"Expected input to call_model to have 'messages' key, but got {state}"
)
raise ValueError(error_msg)
_validate_chat_history(messages)
# we're passing messages under `messages` key, as this is expected by the prompt
+4 -4
View File
@@ -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.9"
__version__ = "0.3.10"
__all__ = ["Auth", "Encryption", "EncryptionContext", "get_client", "get_sync_client"]
+1 -1
View File
@@ -464,7 +464,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:
+1 -1
View File
@@ -451,7 +451,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:
+62
View File
@@ -0,0 +1,62 @@
"""Key/value cache for use inside LangGraph deployments.
Thin wrapper around ``langgraph_api.cache``.
Values must be JSON-serializable (dicts, lists, strings, numbers, booleans,
``None``).
"""
from __future__ import annotations
from datetime import timedelta
from typing import Any
try:
from langgraph_api.cache import ( # type: ignore[unresolved-import]
cache_get as _cache_get,
)
from langgraph_api.cache import ( # type: ignore[unresolved-import]
cache_set as _cache_set,
)
except ImportError:
_cache_get = None
_cache_set = None
__all__ = [
"cache_get",
"cache_set",
]
async def cache_get(key: str) -> Any | None:
"""Get a value from the cache.
Returns the deserialized value, or ``None`` if the key is missing or expired.
Requires Agent Server runtime version 0.7.29 or later.
"""
if _cache_get is None:
raise RuntimeError(
"Cache is only available server-side within the LangGraph Agent Server "
"(https://docs.langchain.com/langsmith/deployments)."
)
return await _cache_get(key)
async def cache_set(key: str, value: Any, *, ttl: timedelta | None = None) -> None:
"""Set a value in the cache.
Args:
key: The cache key.
value: The value to cache (must be JSON-serializable).
ttl: Optional time-to-live. Capped at 1 day; ``None`` or zero
defaults to 1 day.
Requires Agent Server runtime version 0.7.29 or later.
"""
if _cache_set is None:
raise RuntimeError(
"Cache is only available server-side within the LangGraph Agent Server "
"(https://docs.langchain.com/langsmith/deployments)."
)
await _cache_set(key, value, ttl)
+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 = [
+44 -44
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]]
@@ -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]]