diff --git a/libs/cli/Makefile b/libs/cli/Makefile index 424946fdf..22506684d 100644 --- a/libs/cli/Makefile +++ b/libs/cli/Makefile @@ -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 diff --git a/libs/cli/langgraph_cli/cli.py b/libs/cli/langgraph_cli/cli.py index e581c5eab..66073a8cf 100644 --- a/libs/cli/langgraph_cli/cli.py +++ b/libs/cli/langgraph_cli/cli.py @@ -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, diff --git a/libs/cli/langgraph_cli/templates.py b/libs/cli/langgraph_cli/templates.py new file mode 100644 index 000000000..20d6efe8d --- /dev/null +++ b/libs/cli/langgraph_cli/templates.py @@ -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) diff --git a/libs/cli/tests/integration_tests/__init__.py b/libs/cli/tests/integration_tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/libs/cli/tests/integration_tests/test_cli.py b/libs/cli/tests/integration_tests/test_cli.py new file mode 100644 index 000000000..7cb41b47b --- /dev/null +++ b/libs/cli/tests/integration_tests/test_cli.py @@ -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." diff --git a/libs/cli/tests/unit_tests/cli/__init__.py b/libs/cli/tests/unit_tests/cli/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/libs/cli/tests/unit_tests/test_cli.py b/libs/cli/tests/unit_tests/cli/test_cli.py similarity index 100% rename from libs/cli/tests/unit_tests/test_cli.py rename to libs/cli/tests/unit_tests/cli/test_cli.py diff --git a/libs/cli/tests/unit_tests/cli/test_templates.py b/libs/cli/tests/unit_tests/cli/test_templates.py new file mode 100644 index 000000000..acd7651d0 --- /dev/null +++ b/libs/cli/tests/unit_tests/cli/test_templates.py @@ -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." diff --git a/libs/cli/tests/unit_tests/conftest.py b/libs/cli/tests/unit_tests/conftest.py new file mode 100644 index 000000000..5caaa6c81 --- /dev/null +++ b/libs/cli/tests/unit_tests/conftest.py @@ -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