Skip to content

Commit 8901476

Browse files
chore(cli): add validate command (#7438)
add validate command --------- Co-authored-by: Will Fu-Hinthorn <will@langchain.dev>
1 parent 336ad12 commit 8901476

4 files changed

Lines changed: 122 additions & 20 deletions

File tree

libs/cli/langgraph_cli/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "0.4.20"
1+
__version__ = "0.4.21"

libs/cli/langgraph_cli/cli.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -817,6 +817,48 @@ def dev(
817817
)
818818

819819

820+
# ---------------------------------------------------------------------------
821+
# validate command
822+
# ---------------------------------------------------------------------------
823+
824+
825+
@OPT_CONFIG
826+
@cli.command(help="✅ Validate the LangGraph configuration file.")
827+
@log_command
828+
def validate(config: pathlib.Path):
829+
import json
830+
831+
try:
832+
with open(config) as f:
833+
raw_config = json.load(f)
834+
except json.JSONDecodeError as e:
835+
raise click.UsageError(f"Invalid JSON in {config}: {e.args[0]}") from None
836+
837+
# Check for unknown keys before validation so they show alongside any error.
838+
unknown_warnings = langgraph_cli.config.get_unknown_keys(raw_config)
839+
840+
try:
841+
config_json = langgraph_cli.config.validate_config_file(config)
842+
except (click.UsageError, ValueError) as e:
843+
click.secho(f"Error: {e}", fg="red", err=True)
844+
if unknown_warnings:
845+
click.echo(err=True)
846+
for warning in unknown_warnings:
847+
click.secho(f" warning: {warning}", fg="yellow", err=True)
848+
raise SystemExit(1) from None
849+
850+
num_graphs = len(config_json.get("graphs", {}))
851+
click.secho(
852+
f"Configuration file {config} is valid. "
853+
f"({num_graphs} graph{'s' if num_graphs != 1 else ''} found)",
854+
fg="green",
855+
)
856+
if unknown_warnings:
857+
click.echo()
858+
for warning in unknown_warnings:
859+
click.secho(f" warning: {warning}", fg="yellow")
860+
861+
820862
# ---------------------------------------------------------------------------
821863
# new command
822864
# ---------------------------------------------------------------------------

libs/cli/langgraph_cli/config.py

Lines changed: 77 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,11 @@ def validate_config(config: Config) -> Config:
182182
"Version must be major or major.minor or major.minor.patch."
183183
)
184184
except TypeError:
185-
raise click.UsageError(f"Invalid version format: {api_version}") from None
185+
raise click.UsageError(
186+
f"Invalid version format: {api_version}.\n\n"
187+
"Pin to a minor version, e.g.:\n"
188+
' "api_version": "0.8"'
189+
) from None
186190

187191
config = {
188192
"node_version": node_version,
@@ -220,45 +224,51 @@ def validate_config(config: Config) -> Config:
220224
if major < min_major:
221225
raise click.UsageError(
222226
f"Node.js version {node_version} is not supported. "
223-
f"Minimum required version is {MIN_NODE_VERSION}."
227+
f"Minimum required version is {MIN_NODE_VERSION}.\n\n"
228+
f"Set node_version to {MIN_NODE_VERSION} or higher:\n"
229+
f' "node_version": "{MIN_NODE_VERSION}"'
224230
)
225231
except ValueError as e:
226232
raise click.UsageError(str(e)) from None
227233

228234
if pip_installer := config.get("pip_installer"):
229-
if pip_installer == "uv_lock":
230-
raise click.UsageError(
231-
"pip_installer 'uv_lock' has been replaced. Use "
232-
'`source: {"kind": "uv", "root": "..", '
233-
'"package": "my-agent"}`.'
234-
)
235235
if pip_installer not in ["auto", "pip", "uv"]:
236236
raise click.UsageError(
237237
f"Invalid pip_installer: '{pip_installer}'. "
238-
"Must be 'auto', 'pip', or 'uv'."
238+
"Consider using uv-based source management instead:\n\n"
239+
' "source": {"kind": "uv", "root": ".."}'
239240
)
240241

241242
source = config.get("source")
242243
source_kind = _get_source_kind(config)
243244
if source is not None and not isinstance(source, dict):
244-
raise click.UsageError("`source` must be an object.")
245+
raise click.UsageError(
246+
"`source` must be an object, e.g.:\n"
247+
' "source": {"kind": "uv", "root": ".."}'
248+
)
245249
if source is not None and source_kind != "uv":
246-
raise click.UsageError("Invalid source.kind. Supported values: 'uv'.")
250+
raise click.UsageError(
251+
"Invalid source.kind. The only supported value is 'uv':\n"
252+
' "source": {"kind": "uv", "root": ".."}'
253+
)
247254

248255
if config.get("python_version"):
249256
pyversion = config["python_version"]
250257
if not pyversion.count(".") == 1 or not all(
251258
part.isdigit() for part in pyversion.split("-")[0].split(".")
252259
):
260+
parts = pyversion.split("-")[0].split(".")
261+
fix = f"{parts[0]}.{parts[1]}" if len(parts) >= 2 else MIN_PYTHON_VERSION
253262
raise click.UsageError(
254263
f"Invalid Python version format: {pyversion}. "
255-
"Use 'major.minor' format (e.g., '3.11'). "
256-
"Patch version cannot be specified."
264+
"Use 'major.minor' format — patch version cannot be specified.\n\n"
265+
f' "python_version": "{fix}"'
257266
)
258267
if _parse_version(pyversion) < _parse_version(MIN_PYTHON_VERSION):
259268
raise click.UsageError(
260269
f"Python version {pyversion} is not supported. "
261-
f"Minimum required version is {MIN_PYTHON_VERSION}."
270+
f"Minimum required version is {MIN_PYTHON_VERSION}.\n\n"
271+
f' "python_version": "{MIN_PYTHON_VERSION}"'
262272
)
263273
if "bullseye" in pyversion:
264274
raise click.UsageError(
@@ -269,12 +279,16 @@ def validate_config(config: Config) -> Config:
269279
if source_kind != "uv" and not config["dependencies"]:
270280
raise click.UsageError(
271281
"No dependencies found in config. "
272-
"Add at least one dependency to 'dependencies' list."
282+
"Consider using uv-based source management:\n\n"
283+
' "source": {"kind": "uv", "root": ".."}'
273284
)
274285

275286
if not config.get("graphs"):
276287
raise click.UsageError(
277-
"No graphs found in config. Add at least one graph to 'graphs' dictionary."
288+
"No graphs found in config. Add at least one graph, e.g.:\n"
289+
' "graphs": {\n'
290+
' "agent": "./my_agent/graph.py:graph"\n'
291+
" }"
278292
)
279293

280294
# Validate image_distro config
@@ -287,7 +301,8 @@ def validate_config(config: Config) -> Config:
287301
if image_distro not in Distros.__args__:
288302
raise click.UsageError(
289303
f"Invalid image_distro: '{image_distro}'. "
290-
"Must be one of 'debian', 'wolfi', or 'bookworm'."
304+
f"Must be one of: {', '.join(repr(d) for d in Distros.__args__)}.\n\n"
305+
' "image_distro": "wolfi" (recommended)'
291306
)
292307

293308
if source_kind == "uv":
@@ -369,6 +384,51 @@ def validate_config(config: Config) -> Config:
369384
return config
370385

371386

387+
# Keys recognized by validate_config (used to detect unknown fields).
388+
_KNOWN_CONFIG_KEYS = {
389+
"python_version",
390+
"node_version",
391+
"api_version",
392+
"base_image",
393+
"image_distro",
394+
"pip_config_file",
395+
"pip_installer",
396+
"source",
397+
"dependencies",
398+
"dockerfile_lines",
399+
"graphs",
400+
"env",
401+
"store",
402+
"auth",
403+
"encryption",
404+
"http",
405+
"webhooks",
406+
"checkpointer",
407+
"ui",
408+
"ui_config",
409+
"keep_pkg_tools",
410+
# Internal / legacy (still recognized, may error separately)
411+
"_INTERNAL_docker_tag",
412+
"project_root",
413+
"package",
414+
}
415+
416+
417+
def get_unknown_keys(raw_config: dict) -> list[str]:
418+
"""Return warnings for unrecognized top-level keys (typos, etc.)."""
419+
import difflib
420+
421+
unknown = set(raw_config) - _KNOWN_CONFIG_KEYS
422+
warnings: list[str] = []
423+
for key in sorted(unknown):
424+
close = difflib.get_close_matches(key, _KNOWN_CONFIG_KEYS, n=1)
425+
if close:
426+
warnings.append(f"Unknown key '{key}' — did you mean '{close[0]}'?")
427+
else:
428+
warnings.append(f"Unknown key '{key}' is not a recognized config field.")
429+
return warnings
430+
431+
372432
def validate_config_file(config_path: pathlib.Path) -> Config:
373433
"""Load and validate a configuration file."""
374434
with open(config_path) as f:

libs/cli/tests/unit_tests/test_config.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -404,7 +404,7 @@ def test_validate_config_pip_installer():
404404
}
405405
)
406406
assert "Invalid pip_installer: 'conda'" in str(exc_info.value)
407-
assert "Must be 'auto', 'pip', or 'uv'" in str(exc_info.value)
407+
assert "uv-based source management" in str(exc_info.value)
408408

409409
with pytest.raises(click.UsageError) as exc_info:
410410
validate_config(
@@ -417,7 +417,7 @@ def test_validate_config_pip_installer():
417417
)
418418
assert "Invalid pip_installer: 'invalid'" in str(exc_info.value)
419419

420-
with pytest.raises(click.UsageError, match="has been replaced"):
420+
with pytest.raises(click.UsageError, match="Invalid pip_installer: 'uv_lock'"):
421421
validate_config(
422422
{
423423
"python_version": "3.11",

0 commit comments

Comments
 (0)