mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-08-31 20:29:46 +02:00
cli[minor]: Add langgraph new command (#2369)
Adds a "new" command to create langgraph application from a template.
This commit is contained in:
+4
-2
@@ -1,11 +1,13 @@
|
||||
.PHONY: test lint format
|
||||
.PHONY: test lint format test-integration
|
||||
|
||||
######################
|
||||
# TESTING AND COVERAGE
|
||||
######################
|
||||
|
||||
test:
|
||||
poetry run pytest tests
|
||||
poetry run pytest tests/unit_tests
|
||||
test-integration:
|
||||
poetry run pytest tests/integration_tests
|
||||
|
||||
######################
|
||||
# LINTING AND FORMATTING
|
||||
|
||||
@@ -15,6 +15,7 @@ from langgraph_cli.constants import DEFAULT_CONFIG, DEFAULT_PORT
|
||||
from langgraph_cli.docker import DockerCapabilities
|
||||
from langgraph_cli.exec import Runner, subp_exec
|
||||
from langgraph_cli.progress import Progress
|
||||
from langgraph_cli.templates import TEMPLATE_HELP_STRING, create_new
|
||||
from langgraph_cli.version import __version__
|
||||
|
||||
OPT_DOCKER_COMPOSE = click.option(
|
||||
@@ -378,6 +379,19 @@ def dockerfile(save_path: pathlib.Path, config: pathlib.Path):
|
||||
)
|
||||
|
||||
|
||||
@click.argument("path", required=False)
|
||||
@click.option(
|
||||
"--template",
|
||||
type=str,
|
||||
help=TEMPLATE_HELP_STRING,
|
||||
)
|
||||
@cli.command("new", help="Create a new LangGraph project from a template.")
|
||||
@log_command
|
||||
def new(path: Optional[str], template: Optional[str]) -> None:
|
||||
"""Create a new LangGraph project from a template."""
|
||||
return create_new(path, template)
|
||||
|
||||
|
||||
def prepare_args_and_stdin(
|
||||
*,
|
||||
capabilities: DockerCapabilities,
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from io import BytesIO
|
||||
from typing import Dict, Optional
|
||||
from urllib import error, request
|
||||
from zipfile import ZipFile
|
||||
|
||||
import click
|
||||
|
||||
TEMPLATES: Dict[str, Dict[str, str]] = {
|
||||
"New LangGraph Project": {
|
||||
"description": "A simple, minimal chatbot with memory.",
|
||||
"python": "https://github.com/langchain-ai/new-langgraph-project/archive/refs/heads/main.zip",
|
||||
"js": "https://github.com/langchain-ai/new-langgraphjs-project/archive/refs/heads/main.zip",
|
||||
},
|
||||
"ReAct Agent": {
|
||||
"description": "A simple agent that can be flexibly extended to many tools.",
|
||||
"python": "https://github.com/langchain-ai/react-agent/archive/refs/heads/main.zip",
|
||||
"js": "https://github.com/langchain-ai/react-agent-js/archive/refs/heads/main.zip",
|
||||
},
|
||||
"Memory Agent": {
|
||||
"description": "A ReAct-style agent with an additional tool to store memories for use across conversational threads.",
|
||||
"python": "https://github.com/langchain-ai/memory-agent/archive/refs/heads/main.zip",
|
||||
"js": "https://github.com/langchain-ai/memory-agent-js/archive/refs/heads/main.zip",
|
||||
},
|
||||
"Retrieval Agent": {
|
||||
"description": "An agent that includes a retrieval-based question-answering system.",
|
||||
"python": "https://github.com/langchain-ai/retrieval-agent-template/archive/refs/heads/main.zip",
|
||||
"js": "https://github.com/langchain-ai/retrieval-agent-template-js/archive/refs/heads/main.zip",
|
||||
},
|
||||
"Data-enrichment Agent": {
|
||||
"description": "An agent that performs web searches and organizes its findings into a structured format.",
|
||||
"python": "https://github.com/langchain-ai/data-enrichment/archive/refs/heads/main.zip",
|
||||
"js": "https://github.com/langchain-ai/data-enrichment-js/archive/refs/heads/main.zip",
|
||||
},
|
||||
}
|
||||
|
||||
# Generate TEMPLATE_IDS programmatically
|
||||
TEMPLATE_ID_TO_CONFIG = {
|
||||
f"{name.lower().replace(' ', '-')}-{lang}": (name, lang, url)
|
||||
for name, versions in TEMPLATES.items()
|
||||
for lang, url in versions.items()
|
||||
if lang in {"python", "js"}
|
||||
}
|
||||
|
||||
TEMPLATE_IDS = list(TEMPLATE_ID_TO_CONFIG.keys())
|
||||
|
||||
TEMPLATE_HELP_STRING = (
|
||||
"The name of the template to use. Available options:\n"
|
||||
+ "\n".join(f"{id_}" for id_ in TEMPLATE_ID_TO_CONFIG)
|
||||
)
|
||||
|
||||
|
||||
def _choose_template() -> str:
|
||||
"""Presents a list of templates to the user and prompts them to select one.
|
||||
|
||||
Returns:
|
||||
str: The URL of the selected template.
|
||||
"""
|
||||
click.secho("🌟 Please select a template:", bold=True, fg="yellow")
|
||||
for idx, (template_name, template_info) in enumerate(TEMPLATES.items(), 1):
|
||||
click.secho(f"{idx}. ", nl=False, fg="cyan")
|
||||
click.secho(template_name, fg="cyan", nl=False)
|
||||
click.secho(f" - {template_info['description']}", fg="white")
|
||||
|
||||
# Get the template choice from user
|
||||
template_choice: int = click.prompt(
|
||||
"Enter the number of your template choice", type=int
|
||||
)
|
||||
|
||||
template_keys = list(TEMPLATES.keys())
|
||||
if 1 <= template_choice <= len(template_keys):
|
||||
selected_template: str = template_keys[template_choice - 1]
|
||||
else:
|
||||
click.secho("❌ Invalid choice. Please try again.", fg="red")
|
||||
return _choose_template()
|
||||
|
||||
# Prompt the user to choose between Python or JS/TS version
|
||||
click.secho(
|
||||
f"\nYou selected: {selected_template} - {TEMPLATES[selected_template]['description']}",
|
||||
fg="green",
|
||||
)
|
||||
version_choice: int = click.prompt(
|
||||
"Choose version (1 for Python 🐍, 2 for JS/TS 🌐)", type=int
|
||||
)
|
||||
|
||||
if version_choice == 1:
|
||||
return TEMPLATES[selected_template]["python"]
|
||||
elif version_choice == 2:
|
||||
return TEMPLATES[selected_template]["js"]
|
||||
else:
|
||||
click.secho("❌ Invalid choice. Please try again.", fg="red")
|
||||
return _choose_template()
|
||||
|
||||
|
||||
def _download_repo_with_requests(repo_url: str, path: str) -> None:
|
||||
"""Download a ZIP archive from the given URL and extracts it to the specified path.
|
||||
|
||||
Args:
|
||||
repo_url (str): The URL of the repository to download.
|
||||
path (str): The path where the repository should be extracted.
|
||||
"""
|
||||
click.secho("📥 Attempting to download repository as a ZIP archive...", fg="yellow")
|
||||
click.secho(f"URL: {repo_url}", fg="yellow")
|
||||
try:
|
||||
with request.urlopen(repo_url) as response:
|
||||
if response.status == 200:
|
||||
with ZipFile(BytesIO(response.read())) as zip_file:
|
||||
zip_file.extractall(path)
|
||||
# Move extracted contents to path
|
||||
for item in os.listdir(path):
|
||||
if item.endswith("-main"):
|
||||
extracted_dir = os.path.join(path, item)
|
||||
for filename in os.listdir(extracted_dir):
|
||||
shutil.move(os.path.join(extracted_dir, filename), path)
|
||||
shutil.rmtree(extracted_dir)
|
||||
click.secho(
|
||||
f"✅ Downloaded and extracted repository to {path}", fg="green"
|
||||
)
|
||||
except error.HTTPError as e:
|
||||
click.secho(
|
||||
f"❌ Error: Failed to download repository.\n" f"Details: {e}\n",
|
||||
fg="red",
|
||||
bold=True,
|
||||
err=True,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _get_template_url(template_name: str) -> Optional[str]:
|
||||
"""
|
||||
Retrieves the template URL based on the provided template name.
|
||||
|
||||
Args:
|
||||
template_name (str): The name of the template.
|
||||
|
||||
Returns:
|
||||
Optional[str]: The URL of the template if found, else None.
|
||||
"""
|
||||
if template_name in TEMPLATES:
|
||||
click.secho(f"Template selected: {template_name}", fg="green")
|
||||
version_choice: int = click.prompt(
|
||||
"Choose version (1 for Python 🐍, 2 for JS/TS 🌐)", type=int
|
||||
)
|
||||
|
||||
if version_choice == 1:
|
||||
return TEMPLATES[template_name]["python"]
|
||||
elif version_choice == 2:
|
||||
return TEMPLATES[template_name]["js"]
|
||||
else:
|
||||
click.secho("❌ Invalid choice. Please try again.", fg="red")
|
||||
return None
|
||||
else:
|
||||
click.secho(
|
||||
f"Template '{template_name}' not found. Please select from the available options.",
|
||||
fg="red",
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def create_new(path: Optional[str], template: Optional[str]) -> None:
|
||||
"""Create a new LangGraph project at the specified PATH using the chosen TEMPLATE.
|
||||
|
||||
Args:
|
||||
path (Optional[str]): The path where the new project will be created.
|
||||
template (Optional[str]): The name of the template to use.
|
||||
"""
|
||||
# Prompt for path if not provided
|
||||
if not path:
|
||||
path = click.prompt(
|
||||
"📂 Please specify the path to create the application", default="."
|
||||
)
|
||||
|
||||
path = os.path.abspath(path) # Ensure path is absolute
|
||||
|
||||
# Check if path exists and is not empty
|
||||
if os.path.exists(path) and os.listdir(path):
|
||||
click.secho(
|
||||
"❌ The specified directory already exists and is not empty. "
|
||||
"Aborting to prevent overwriting files.",
|
||||
fg="red",
|
||||
bold=True,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
# Get template URL either from command-line argument or
|
||||
# through interactive selection
|
||||
if template:
|
||||
if template not in TEMPLATE_ID_TO_CONFIG:
|
||||
# Format available options in a readable way with descriptions
|
||||
template_options = ""
|
||||
for id_ in TEMPLATE_IDS:
|
||||
name, lang, _ = TEMPLATE_ID_TO_CONFIG[id_]
|
||||
description = TEMPLATES[name]["description"]
|
||||
|
||||
# Add each template option with color formatting
|
||||
template_options += (
|
||||
click.style("- ", fg="yellow", bold=True)
|
||||
+ click.style(f"{id_}", fg="cyan")
|
||||
+ click.style(f": {description}", fg="white")
|
||||
+ "\n"
|
||||
)
|
||||
|
||||
# Display error message with colors and formatting
|
||||
click.secho("❌ Error:", fg="red", bold=True, nl=False)
|
||||
click.secho(f" Template '{template}' not found.", fg="red")
|
||||
click.secho(
|
||||
"Please select from the available options:\n", fg="yellow", bold=True
|
||||
)
|
||||
click.secho(template_options, fg="cyan")
|
||||
sys.exit(1)
|
||||
_, _, template_url = TEMPLATE_ID_TO_CONFIG[template]
|
||||
else:
|
||||
template_url = _choose_template()
|
||||
|
||||
# Download and extract the template
|
||||
_download_repo_with_requests(template_url, path)
|
||||
|
||||
click.secho(f"🎉 New project created at {path}", fg="green", bold=True)
|
||||
@@ -0,0 +1,13 @@
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from langgraph_cli.templates import TEMPLATE_ID_TO_CONFIG
|
||||
|
||||
|
||||
@pytest.mark.parametrize("template_key", TEMPLATE_ID_TO_CONFIG.keys())
|
||||
def test_template_urls_work(template_key: str) -> None:
|
||||
"""Integration test to verify that all template URLs are reachable."""
|
||||
_, _, template_url = TEMPLATE_ID_TO_CONFIG[template_key]
|
||||
response = requests.head(template_url)
|
||||
# Returns 302 on a successful HEAD request
|
||||
assert response.status_code == 302, f"URL {template_url} is not reachable."
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Unit tests for the 'new' CLI command.
|
||||
|
||||
This command creates a new LangGraph project using a specified template.
|
||||
"""
|
||||
|
||||
import os
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from unittest.mock import MagicMock, patch
|
||||
from urllib import request
|
||||
from zipfile import ZipFile
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from langgraph_cli.cli import cli
|
||||
from langgraph_cli.templates import TEMPLATE_ID_TO_CONFIG
|
||||
|
||||
|
||||
@patch.object(request, "urlopen")
|
||||
def test_create_new_with_mocked_download(mock_urlopen: MagicMock) -> None:
|
||||
"""Test the 'new' CLI command with a mocked download response using urllib."""
|
||||
# Mock the response content to simulate a ZIP file
|
||||
mock_zip_content = BytesIO()
|
||||
with ZipFile(mock_zip_content, "w") as mock_zip:
|
||||
mock_zip.writestr("test-file.txt", "Test content.")
|
||||
|
||||
# Create a mock response that behaves like a context manager
|
||||
mock_response = MagicMock()
|
||||
mock_response.read.return_value = mock_zip_content.getvalue()
|
||||
mock_response.__enter__.return_value = mock_response # Setup enter context
|
||||
mock_response.status = 200
|
||||
|
||||
mock_urlopen.return_value = mock_response
|
||||
|
||||
with TemporaryDirectory() as temp_dir:
|
||||
runner = CliRunner()
|
||||
template = next(
|
||||
iter(TEMPLATE_ID_TO_CONFIG)
|
||||
) # Select the first template for the test
|
||||
result = runner.invoke(cli, ["new", temp_dir, "--template", template])
|
||||
|
||||
# Verify CLI command execution and success
|
||||
assert result.exit_code == 0, result.output
|
||||
assert (
|
||||
"New project created" in result.output
|
||||
), "Expected success message in output."
|
||||
|
||||
# Verify that the directory is not empty
|
||||
assert os.listdir(temp_dir), "Expected files to be created in temp directory."
|
||||
|
||||
# Check for a known file in the extracted content
|
||||
extracted_files = [f.name for f in Path(temp_dir).glob("*")]
|
||||
assert (
|
||||
"test-file.txt" in extracted_files
|
||||
), "Expected 'test-file.txt' in the extracted content."
|
||||
|
||||
|
||||
def test_invalid_template_id() -> None:
|
||||
"""Test that an invalid template ID passed via CLI results in a graceful error."""
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
cli, ["new", "dummy_path", "--template", "invalid-template-id"]
|
||||
)
|
||||
|
||||
# Verify the command failed and proper message is displayed
|
||||
assert result.exit_code != 0, "Expected non-zero exit code for invalid template."
|
||||
assert (
|
||||
"Template 'invalid-template-id' not found" in result.output
|
||||
), "Expected error message in output."
|
||||
@@ -0,0 +1,16 @@
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def disable_analytics_env() -> None:
|
||||
"""Disable analytics for unit tests LANGGRAPH_CLI_NO_ANALYTICS."""
|
||||
# First check if the environment variable is already set, if so, log a warning prior
|
||||
# to overriding it.
|
||||
if "LANGGRAPH_CLI_NO_ANALYTICS" in os.environ:
|
||||
print("⚠️ LANGGRAPH_CLI_NO_ANALYTICS is set. Overriding it for the test.")
|
||||
|
||||
with patch.dict(os.environ, {"LANGGRAPH_CLI_NO_ANALYTICS": "0"}):
|
||||
yield
|
||||
Reference in New Issue
Block a user