From 890147681dd48a20b7ae139a423a9a74b9a9e2c3 Mon Sep 17 00:00:00 2001 From: William FH <13333726+hinthornw@users.noreply.github.com> Date: Tue, 7 Apr 2026 18:29:24 -0700 Subject: [PATCH 1/2] chore(cli): add validate command (#7438) add validate command --------- Co-authored-by: Will Fu-Hinthorn --- libs/cli/langgraph_cli/__init__.py | 2 +- libs/cli/langgraph_cli/cli.py | 42 +++++++++++ libs/cli/langgraph_cli/config.py | 94 +++++++++++++++++++----- libs/cli/tests/unit_tests/test_config.py | 4 +- 4 files changed, 122 insertions(+), 20 deletions(-) diff --git a/libs/cli/langgraph_cli/__init__.py b/libs/cli/langgraph_cli/__init__.py index b4ed79e09..e427a5547 100644 --- a/libs/cli/langgraph_cli/__init__.py +++ b/libs/cli/langgraph_cli/__init__.py @@ -1 +1 @@ -__version__ = "0.4.20" +__version__ = "0.4.21" diff --git a/libs/cli/langgraph_cli/cli.py b/libs/cli/langgraph_cli/cli.py index a9b5405f6..6e6950bd4 100644 --- a/libs/cli/langgraph_cli/cli.py +++ b/libs/cli/langgraph_cli/cli.py @@ -817,6 +817,48 @@ def dev( ) +# --------------------------------------------------------------------------- +# validate command +# --------------------------------------------------------------------------- + + +@OPT_CONFIG +@cli.command(help="✅ Validate the LangGraph configuration file.") +@log_command +def validate(config: pathlib.Path): + import json + + try: + with open(config) as f: + raw_config = json.load(f) + except json.JSONDecodeError as e: + raise click.UsageError(f"Invalid JSON in {config}: {e.args[0]}") from None + + # Check for unknown keys before validation so they show alongside any error. + unknown_warnings = langgraph_cli.config.get_unknown_keys(raw_config) + + try: + config_json = langgraph_cli.config.validate_config_file(config) + except (click.UsageError, ValueError) as e: + click.secho(f"Error: {e}", fg="red", err=True) + if unknown_warnings: + click.echo(err=True) + for warning in unknown_warnings: + click.secho(f" warning: {warning}", fg="yellow", err=True) + raise SystemExit(1) from None + + num_graphs = len(config_json.get("graphs", {})) + click.secho( + f"Configuration file {config} is valid. " + f"({num_graphs} graph{'s' if num_graphs != 1 else ''} found)", + fg="green", + ) + if unknown_warnings: + click.echo() + for warning in unknown_warnings: + click.secho(f" warning: {warning}", fg="yellow") + + # --------------------------------------------------------------------------- # new command # --------------------------------------------------------------------------- diff --git a/libs/cli/langgraph_cli/config.py b/libs/cli/langgraph_cli/config.py index 2928a0aa1..ac66470d7 100644 --- a/libs/cli/langgraph_cli/config.py +++ b/libs/cli/langgraph_cli/config.py @@ -182,7 +182,11 @@ def validate_config(config: Config) -> Config: "Version must be major or major.minor or major.minor.patch." ) except TypeError: - raise click.UsageError(f"Invalid version format: {api_version}") from None + raise click.UsageError( + f"Invalid version format: {api_version}.\n\n" + "Pin to a minor version, e.g.:\n" + ' "api_version": "0.8"' + ) from None config = { "node_version": node_version, @@ -220,45 +224,51 @@ def validate_config(config: Config) -> Config: if major < min_major: raise click.UsageError( f"Node.js version {node_version} is not supported. " - f"Minimum required version is {MIN_NODE_VERSION}." + f"Minimum required version is {MIN_NODE_VERSION}.\n\n" + f"Set node_version to {MIN_NODE_VERSION} or higher:\n" + f' "node_version": "{MIN_NODE_VERSION}"' ) except ValueError as e: raise click.UsageError(str(e)) from None if pip_installer := config.get("pip_installer"): - if pip_installer == "uv_lock": - raise click.UsageError( - "pip_installer 'uv_lock' has been replaced. Use " - '`source: {"kind": "uv", "root": "..", ' - '"package": "my-agent"}`.' - ) if pip_installer not in ["auto", "pip", "uv"]: raise click.UsageError( f"Invalid pip_installer: '{pip_installer}'. " - "Must be 'auto', 'pip', or 'uv'." + "Consider using uv-based source management instead:\n\n" + ' "source": {"kind": "uv", "root": ".."}' ) source = config.get("source") source_kind = _get_source_kind(config) if source is not None and not isinstance(source, dict): - raise click.UsageError("`source` must be an object.") + raise click.UsageError( + "`source` must be an object, e.g.:\n" + ' "source": {"kind": "uv", "root": ".."}' + ) if source is not None and source_kind != "uv": - raise click.UsageError("Invalid source.kind. Supported values: 'uv'.") + raise click.UsageError( + "Invalid source.kind. The only supported value is 'uv':\n" + ' "source": {"kind": "uv", "root": ".."}' + ) if config.get("python_version"): pyversion = config["python_version"] if not pyversion.count(".") == 1 or not all( part.isdigit() for part in pyversion.split("-")[0].split(".") ): + parts = pyversion.split("-")[0].split(".") + fix = f"{parts[0]}.{parts[1]}" if len(parts) >= 2 else MIN_PYTHON_VERSION raise click.UsageError( f"Invalid Python version format: {pyversion}. " - "Use 'major.minor' format (e.g., '3.11'). " - "Patch version cannot be specified." + "Use 'major.minor' format — patch version cannot be specified.\n\n" + f' "python_version": "{fix}"' ) if _parse_version(pyversion) < _parse_version(MIN_PYTHON_VERSION): raise click.UsageError( f"Python version {pyversion} is not supported. " - f"Minimum required version is {MIN_PYTHON_VERSION}." + f"Minimum required version is {MIN_PYTHON_VERSION}.\n\n" + f' "python_version": "{MIN_PYTHON_VERSION}"' ) if "bullseye" in pyversion: raise click.UsageError( @@ -269,12 +279,16 @@ def validate_config(config: Config) -> Config: if source_kind != "uv" and not config["dependencies"]: raise click.UsageError( "No dependencies found in config. " - "Add at least one dependency to 'dependencies' list." + "Consider using uv-based source management:\n\n" + ' "source": {"kind": "uv", "root": ".."}' ) if not config.get("graphs"): raise click.UsageError( - "No graphs found in config. Add at least one graph to 'graphs' dictionary." + "No graphs found in config. Add at least one graph, e.g.:\n" + ' "graphs": {\n' + ' "agent": "./my_agent/graph.py:graph"\n' + " }" ) # Validate image_distro config @@ -287,7 +301,8 @@ def validate_config(config: Config) -> Config: if image_distro not in Distros.__args__: raise click.UsageError( f"Invalid image_distro: '{image_distro}'. " - "Must be one of 'debian', 'wolfi', or 'bookworm'." + f"Must be one of: {', '.join(repr(d) for d in Distros.__args__)}.\n\n" + ' "image_distro": "wolfi" (recommended)' ) if source_kind == "uv": @@ -369,6 +384,51 @@ def validate_config(config: Config) -> Config: return config +# Keys recognized by validate_config (used to detect unknown fields). +_KNOWN_CONFIG_KEYS = { + "python_version", + "node_version", + "api_version", + "base_image", + "image_distro", + "pip_config_file", + "pip_installer", + "source", + "dependencies", + "dockerfile_lines", + "graphs", + "env", + "store", + "auth", + "encryption", + "http", + "webhooks", + "checkpointer", + "ui", + "ui_config", + "keep_pkg_tools", + # Internal / legacy (still recognized, may error separately) + "_INTERNAL_docker_tag", + "project_root", + "package", +} + + +def get_unknown_keys(raw_config: dict) -> list[str]: + """Return warnings for unrecognized top-level keys (typos, etc.).""" + import difflib + + unknown = set(raw_config) - _KNOWN_CONFIG_KEYS + warnings: list[str] = [] + for key in sorted(unknown): + close = difflib.get_close_matches(key, _KNOWN_CONFIG_KEYS, n=1) + if close: + warnings.append(f"Unknown key '{key}' — did you mean '{close[0]}'?") + else: + warnings.append(f"Unknown key '{key}' is not a recognized config field.") + return warnings + + def validate_config_file(config_path: pathlib.Path) -> Config: """Load and validate a configuration file.""" with open(config_path) as f: diff --git a/libs/cli/tests/unit_tests/test_config.py b/libs/cli/tests/unit_tests/test_config.py index 20180bbc2..50fac53d8 100644 --- a/libs/cli/tests/unit_tests/test_config.py +++ b/libs/cli/tests/unit_tests/test_config.py @@ -404,7 +404,7 @@ def test_validate_config_pip_installer(): } ) assert "Invalid pip_installer: 'conda'" in str(exc_info.value) - assert "Must be 'auto', 'pip', or 'uv'" in str(exc_info.value) + assert "uv-based source management" in str(exc_info.value) with pytest.raises(click.UsageError) as exc_info: validate_config( @@ -417,7 +417,7 @@ def test_validate_config_pip_installer(): ) assert "Invalid pip_installer: 'invalid'" in str(exc_info.value) - with pytest.raises(click.UsageError, match="has been replaced"): + with pytest.raises(click.UsageError, match="Invalid pip_installer: 'uv_lock'"): validate_config( { "python_version": "3.11", From 6242b99e061a9de9f702ea903e141380f5041b2a Mon Sep 17 00:00:00 2001 From: William FH <13333726+hinthornw@users.noreply.github.com> Date: Wed, 8 Apr 2026 05:54:37 -0700 Subject: [PATCH 2/2] chore(checkpoint-conformance): remove test_list_global_search, bump to 0.0.2 (#7444) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Remove `test_list_global_search` from the conformance test suite. This test required cross-thread `alist(None, filter=...)` support that not all checkpointer implementations provide. - Remove the corresponding entry from `ALL_LIST_TESTS`. - Bump `langgraph-checkpoint-conformance` version from 0.0.1 to 0.0.2. ## Test plan - [x] Verify `test_list_global_search` function definition is fully removed - [x] Verify `test_list_global_search` is removed from `ALL_LIST_TESTS` - [x] Verify version bumped to 0.0.2 in pyproject.toml 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Will Fu-Hinthorn Co-authored-by: Claude Opus 4.6 (1M context) --- .../checkpoint/conformance/spec/test_list.py | 31 ------------------- libs/checkpoint-conformance/pyproject.toml | 2 +- 2 files changed, 1 insertion(+), 32 deletions(-) diff --git a/libs/checkpoint-conformance/langgraph/checkpoint/conformance/spec/test_list.py b/libs/checkpoint-conformance/langgraph/checkpoint/conformance/spec/test_list.py index e8e8ae941..fd98ba652 100644 --- a/libs/checkpoint-conformance/langgraph/checkpoint/conformance/spec/test_list.py +++ b/libs/checkpoint-conformance/langgraph/checkpoint/conformance/spec/test_list.py @@ -339,36 +339,6 @@ async def test_list_metadata_custom_keys( assert results[0].metadata["run_id"] == "run-abc" -async def test_list_global_search( - saver: BaseCheckpointSaver, -) -> None: - """alist(None, filter=...) searches across all threads.""" - tid1, tid2 = str(uuid4()), str(uuid4()) - - # Use a unique marker so we don't collide with other tests' data - marker = str(uuid4()) - - cfg1 = generate_config(tid1) - cp1 = generate_checkpoint() - await saver.aput(cfg1, cp1, generate_metadata(source="input", marker=marker), {}) - - cfg2 = generate_config(tid2) - cp2 = generate_checkpoint() - await saver.aput(cfg2, cp2, generate_metadata(source="loop", marker=marker), {}) - - # Search across all threads with filter - results = [] - async for tup in saver.alist(None, filter={"source": "input", "marker": marker}): - results.append(tup) - assert len(results) == 1 - assert results[0].config["configurable"]["thread_id"] == tid1 - - # Search with marker only — should find both - results = [] - async for tup in saver.alist(None, filter={"marker": marker}): - results.append(tup) - assert len(results) == 2 - ALL_LIST_TESTS = [ test_list_all, @@ -380,7 +350,6 @@ ALL_LIST_TESTS = [ test_list_metadata_filter_multiple_keys, test_list_metadata_filter_no_match, test_list_metadata_custom_keys, - test_list_global_search, test_list_before, test_list_limit, test_list_limit_plus_before, diff --git a/libs/checkpoint-conformance/pyproject.toml b/libs/checkpoint-conformance/pyproject.toml index c9033061c..dac2f550c 100644 --- a/libs/checkpoint-conformance/pyproject.toml +++ b/libs/checkpoint-conformance/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "langgraph-checkpoint-conformance" -version = "0.0.1" +version = "0.0.2" description = "Conformance test suite for LangGraph checkpointer implementations." authors = [{name = "William FH", email = "13333726+hinthornw@users.noreply.github.com"}] requires-python = ">=3.10"