From dab8453245ff75599f21fb94a188f82092a919a4 Mon Sep 17 00:00:00 2001 From: Bhavani Devi Date: Thu, 13 Aug 2026 16:09:30 +0530 Subject: [PATCH] Implement Phase 5 CLI tooling --- .github/workflows/test.yml | 1 + README.md | 70 ++- nitrostack/cli/__init__.py | 1 + nitrostack/cli/generate.py | 109 +++++ nitrostack/cli/install.py | 85 ++++ nitrostack/cli/main.py | 123 +++++- nitrostack/cli/pack.py | 537 ++++++++++++++++++++++++ nitrostack/cli/templates/__init__.py | 1 + nitrostack/cli/templates/filter.py | 19 + nitrostack/cli/templates/guard.py | 12 + nitrostack/cli/templates/interceptor.py | 18 + nitrostack/cli/templates/module.py | 12 + nitrostack/cli/templates/pipe.py | 13 + nitrostack/cli/templates/service.py | 10 + nitrostack/cli/upgrade.py | 197 +++++++++ nitrostack/cli/validators.py | 376 +++++++++++++++++ pyproject.toml | 2 +- tests/test_cli.py | 221 ++++++++++ 18 files changed, 1789 insertions(+), 18 deletions(-) create mode 100644 nitrostack/cli/__init__.py create mode 100644 nitrostack/cli/generate.py create mode 100644 nitrostack/cli/install.py create mode 100644 nitrostack/cli/pack.py create mode 100644 nitrostack/cli/templates/__init__.py create mode 100644 nitrostack/cli/templates/filter.py create mode 100644 nitrostack/cli/templates/guard.py create mode 100644 nitrostack/cli/templates/interceptor.py create mode 100644 nitrostack/cli/templates/module.py create mode 100644 nitrostack/cli/templates/pipe.py create mode 100644 nitrostack/cli/templates/service.py create mode 100644 nitrostack/cli/upgrade.py create mode 100644 nitrostack/cli/validators.py create mode 100644 tests/test_cli.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f7afb3e..20ee067 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -36,3 +36,4 @@ jobs: python tests/test_production.py python tests/test_widget_metadata.py python tests/test_transports.py + pytest tests/test_cli.py -v diff --git a/README.md b/README.md index 1ead877..5d271f0 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ A Python-idiomatic port of the **NitroStack** Model Context Protocol (MCP) frame - **Asynchronous Background Tasks**: Spawn background workers automatically for long-running tools. - **Built-in Authentication**: Modules for API Keys, JWT verification, and OAuth 2.1 (featuring Protected Resource Metadata discovery servers). - **In-Process Testing Harness**: Run unit and integration tests against modules without managing subprocesses or real network transports. -- **CLI Tooling (`nitrostack-py`)**: Scaffold new apps (`init`), generate boilerplates (`generate`), auto-register servers with Claude (`register`), and run hot-reload development servers (`dev`). +- **CLI Tooling (`nitrostack-py`)**: Scaffold apps (`init`), generate components (`generate`), pack deployable wheels (`pack`), upgrade/install dependencies, validate projects, auto-register servers with Claude (`register`), and run hot-reload development servers (`dev`). --- @@ -50,6 +50,73 @@ Once scaffolded, follow the next steps printed by the CLI to run your server, co --- +## CLI (`nitrostack-py`) + +The CLI is installed with the SDK (`nitrostack-py`, or `python -m nitrostack.cli.main`). Run `nitrostack-py --help` to list commands. + +### Project lifecycle + +```bash +nitrostack-py init my-server +nitrostack-py dev # hot-reload development server +nitrostack-py start # production server (no reload) +nitrostack-py register --name my-mcp-server --file app.py +``` + +### Generate components + +Existing `tool` and `module` generators are unchanged. Additional generators create pipeline and service stubs that follow the current Python decorator/protocol APIs: + +```bash +nitrostack-py generate tool add_numbers +nitrostack-py generate module payments +nitrostack-py generate guard MyGuard +nitrostack-py generate pipe Validation +nitrostack-py generate interceptor Transform +nitrostack-py generate filter HttpException +nitrostack-py generate service Email +``` + +Generated files: + +| Command | Output | +|---|---| +| `generate tool ` | `{name}_tool.py` in the current directory | +| `generate module ` | `{name}_module.py` in the current directory | +| `generate guard ` | `guards/.py` | +| `generate pipe ` | `pipes/.py` | +| `generate interceptor ` | `interceptors/.py` | +| `generate filter ` | `filters/.py` | +| `generate service ` | `services/.py` | + +Attach generated pipeline classes with `@use_guards`, `@use_pipes`, `@use_interceptors`, or `@use_filters`. Register services in a module's `providers` list. + +### Pack a deployable wheel + +```bash +nitrostack-py pack --dry-run # list files; does not write an artifact +nitrostack-py pack # write dist/*.whl +``` + +`pack` builds a wheel with setuptools (the same backend as this SDK), refreshes `requirements.txt` from `pyproject.toml` when possible, and always includes `.env.example`. The real `.env` file and other secrets are never packed. Temporary build directories are deleted afterwards. + +### Upgrade, install, validate + +```bash +nitrostack-py upgrade # latest nitrostack on PyPI +nitrostack-py upgrade --version 0.3.2 # pin a specific version +nitrostack-py upgrade --dry-run # print the change; do not edit files + +nitrostack-py install # install project + development dependencies +nitrostack-py install --production # skip optional extras and requirements-dev.txt + +nitrostack-py validate # lint deps, @mcp_app imports, and @module() refs +``` + +`upgrade` updates the `nitrostack` dependency spec in `pyproject.toml` in place (and `requirements.txt` when it already pins nitrostack). `validate` reports missing/conflicting dependencies, `@mcp_app` modules that fail to import, and `@module()` `imports`/`exports` that are not real classes. + +--- + ## NitroStudio Dashboard NitroStudio is an interactive visual developer dashboard for inspecting, graphing, and testing your MCP servers. @@ -210,6 +277,7 @@ python tests/test_basic.py python tests/test_tasks.py python tests/test_initial_tool.py python tests/test_transports.py +pytest tests/test_cli.py -v ``` ### Testing Harness diff --git a/nitrostack/cli/__init__.py b/nitrostack/cli/__init__.py new file mode 100644 index 0000000..e4a06e2 --- /dev/null +++ b/nitrostack/cli/__init__.py @@ -0,0 +1 @@ +"""NitroStack Python CLI package.""" diff --git a/nitrostack/cli/generate.py b/nitrostack/cli/generate.py new file mode 100644 index 0000000..9d0107d --- /dev/null +++ b/nitrostack/cli/generate.py @@ -0,0 +1,109 @@ +"""Code generation for `nitrostack-py generate`.""" + +from __future__ import annotations + +import os +import re +import sys +from typing import Dict, Optional + +TEMPLATES_DIR = os.path.join(os.path.dirname(__file__), "templates") + +COMPONENT_KINDS = ("guard", "pipe", "interceptor", "filter", "service") + +_KIND_DIR: Dict[str, str] = { + "guard": "guards", + "pipe": "pipes", + "interceptor": "interceptors", + "filter": "filters", + "service": "services", +} + +_KIND_SUFFIX: Dict[str, str] = { + "guard": "Guard", + "pipe": "Pipe", + "interceptor": "Interceptor", + "filter": "Filter", + "service": "Service", +} + + +def to_pascal_case(name: str) -> str: + cleaned = name.replace("-", "_") + if "_" in cleaned: + return "".join(part.capitalize() for part in cleaned.split("_") if part) + if cleaned and cleaned[0].isupper(): + return cleaned + return cleaned[:1].upper() + cleaned[1:] if cleaned else cleaned + + +def to_snake_case(name: str) -> str: + cleaned = name.replace("-", "_") + if "_" in cleaned: + return re.sub(r"_+", "_", cleaned).strip("_").lower() + stepped = re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", cleaned) + stepped = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", stepped) + return stepped.lower() + + +def class_name_for(kind: str, name: str) -> str: + pascal = to_pascal_case(name) + suffix = _KIND_SUFFIX[kind] + if pascal.endswith(suffix): + return pascal + return f"{pascal}{suffix}" + + +def _load_template(filename: str) -> str: + path = os.path.join(TEMPLATES_DIR, filename) + if not os.path.exists(path): + print(f"Error: template '{filename}' not found at '{path}'.") + sys.exit(1) + with open(path, "r", encoding="utf-8") as handle: + return handle.read() + + +def _write_file(path: str, content: str) -> None: + if os.path.exists(path): + print(f"Error: File '{path}' already exists.") + sys.exit(1) + parent = os.path.dirname(path) + if parent: + os.makedirs(parent, exist_ok=True) + with open(path, "w", encoding="utf-8") as handle: + handle.write(content) + if not content.endswith("\n"): + handle.write("\n") + + +def generate_component(kind: str, name: str, cwd: Optional[str] = None) -> str: + """Render a component template and write it under the project's expected directory.""" + if kind not in COMPONENT_KINDS: + print(f"Error: unknown generate target '{kind}'.") + sys.exit(1) + if not name or not re.match(r"^[A-Za-z_][A-Za-z0-9_-]*$", name): + print("Error: name must be a valid identifier (letters, numbers, '_' or '-').") + sys.exit(1) + + root = cwd or os.getcwd() + class_name = class_name_for(kind, name) + snake = to_snake_case(name) + rel_path = os.path.join(_KIND_DIR[kind], f"{snake}.py") + dest = os.path.join(root, rel_path) + + content = _load_template(f"{kind}.py").replace("CLASS_NAME", class_name) + _write_file(dest, content) + print(f"Generated {kind} boilerplate in '{rel_path}'") + return dest + + +def generate_module(name: str, cwd: Optional[str] = None) -> str: + """Preserve existing `generate module` behavior: `{name}_module.py` in CWD.""" + root = cwd or os.getcwd() + filename = f"{name}_module.py" + dest = os.path.join(root, filename) + camel_name = "".join(part.capitalize() for part in name.split("_")) + content = _load_template("module.py").format(name=name, camel_name=camel_name) + _write_file(dest, content) + print(f"Generated module boilerplate in '{filename}'") + return dest diff --git a/nitrostack/cli/install.py b/nitrostack/cli/install.py new file mode 100644 index 0000000..aeb19dc --- /dev/null +++ b/nitrostack/cli/install.py @@ -0,0 +1,85 @@ +"""Dependency installation wrapper for `nitrostack-py install`.""" + +from __future__ import annotations + +import os +import re +import subprocess +import sys +from typing import List, Optional + + +def _read(path: str) -> str: + with open(path, "r", encoding="utf-8") as handle: + return handle.read() + + +def _optional_extra_names(pyproject_text: str) -> List[str]: + """Return optional-dependency extra names (e.g. dev, test).""" + match = re.search( + r"^\[project\.optional-dependencies\](.*?)(?=^\[|\Z)", + pyproject_text, + re.MULTILINE | re.DOTALL, + ) + if not match: + return [] + names = re.findall(r"^([A-Za-z0-9._-]+)\s*=", match.group(1), re.MULTILINE) + return names + + +def _run_pip(args: List[str], cwd: str) -> None: + cmd = [sys.executable, "-m", "pip", "install", *args] + print(f"Running: {' '.join(cmd)}") + result = subprocess.run(cmd, cwd=cwd) + if result.returncode != 0: + raise RuntimeError( + f"`pip install` failed with exit code {result.returncode}.\n" + "Fix the reported dependency error and retry `nitrostack-py install`." + ) + + +def install_dependencies( + *, + production: bool = False, + cwd: Optional[str] = None, +) -> None: + root = os.path.abspath(cwd or os.getcwd()) + pyproject = os.path.join(root, "pyproject.toml") + requirements = os.path.join(root, "requirements.txt") + dev_requirement_files = [ + os.path.join(root, "requirements-dev.txt"), + os.path.join(root, "requirements.dev.txt"), + os.path.join(root, "dev-requirements.txt"), + ] + + if not os.path.isfile(pyproject) and not os.path.isfile(requirements): + raise RuntimeError( + "No pyproject.toml or requirements.txt found in the current directory.\n" + "Run this command from a NitroStack project." + ) + + print("NITROSTACK — Install" + (" (production)" if production else "")) + + if os.path.isfile(pyproject): + extras: List[str] = [] + if not production: + extras = _optional_extra_names(_read(pyproject)) + if extras: + extra_spec = ",".join(extras) + _run_pip(["-e", f".[{extra_spec}]"], cwd=root) + else: + _run_pip(["-e", "."], cwd=root) + print("Installed pyproject.toml dependencies" + + (" (skipped optional/dev extras)" if production else "")) + elif os.path.isfile(requirements): + _run_pip(["-r", requirements], cwd=root) + print("Installed requirements.txt") + + if production: + print("Skipping development dependency files (--production).") + return + + for path in dev_requirement_files: + if os.path.isfile(path): + _run_pip(["-r", path], cwd=root) + print(f"Installed {os.path.basename(path)}") diff --git a/nitrostack/cli/main.py b/nitrostack/cli/main.py index d673dfe..5ee4e99 100644 --- a/nitrostack/cli/main.py +++ b/nitrostack/cli/main.py @@ -4,6 +4,12 @@ import subprocess import time +from nitrostack.cli.generate import generate_component, generate_module as generate_module_from_template +from nitrostack.cli.install import install_dependencies +from nitrostack.cli.pack import pack_project +from nitrostack.cli.upgrade import UpgradeError, upgrade_project +from nitrostack.cli.validators import format_report, validate_project + MAIN_TEMPLATE = """import asyncio from nitrostack import McpApplicationFactory from app_module import AppModule @@ -1127,15 +1133,7 @@ def generate_tool(name: str): print(f"Generated tool boilerplate in '{filename}'") def generate_module(name: str): - filename = f"{name}_module.py" - if os.path.exists(filename): - print(f"Error: File '{filename}' already exists.") - sys.exit(1) - camel_name = "".join(part.capitalize() for part in name.split("_")) - content = MODULE_TEMPLATE.format(name=name, camel_name=camel_name) - with open(filename, "w", encoding="utf-8") as f: - f.write(content) - print(f"Generated module boilerplate in '{filename}'") + generate_module_from_template(name) def get_claude_config_paths(): paths = [] @@ -1231,10 +1229,20 @@ def main(): pass parser = argparse.ArgumentParser( - description="nitrostack-py CLI — Scaffold, develop, and run NitroStack Python MCP servers", - prog="nitrostack-py" + prog="nitrostack-py", + description="nitrostack-py CLI — Scaffold, develop, pack, and run NitroStack Python MCP servers", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=( + "examples:\n" + " nitrostack-py init my-server\n" + " nitrostack-py generate guard MyGuard\n" + " nitrostack-py pack --dry-run\n" + " nitrostack-py upgrade --dry-run\n" + " nitrostack-py install --production\n" + " nitrostack-py validate\n" + ), ) - subparsers = parser.add_subparsers(dest="command") + subparsers = parser.add_subparsers(dest="command", metavar="command") # init command init_parser = subparsers.add_parser("init", help="Initialize a new NitroStack MCP server project") @@ -1253,15 +1261,73 @@ def main(): reg_parser.add_argument("--file", default="main.py", help="Python script to register (defaults to main.py)") # generate command - gen_parser = subparsers.add_parser("generate", help="Generate boilerplate code") - gen_subparsers = gen_parser.add_subparsers(dest="generator") - + gen_parser = subparsers.add_parser( + "generate", + help="Generate boilerplate code (tool, module, guard, pipe, interceptor, filter, service)", + ) + gen_subparsers = gen_parser.add_subparsers(dest="generator", metavar="type") + tool_parser = gen_subparsers.add_parser("tool", help="Generate a new tool boilerplate") tool_parser.add_argument("name", help="Name of the tool") mod_parser = gen_subparsers.add_parser("module", help="Generate a new module boilerplate") mod_parser.add_argument("name", help="Name of the module") + for kind, kind_help in ( + ("guard", "Generate an authorization guard"), + ("pipe", "Generate a validation/transform pipe"), + ("interceptor", "Generate an execution interceptor"), + ("filter", "Generate an exception filter"), + ("service", "Generate an injectable service"), + ): + kind_parser = gen_subparsers.add_parser(kind, help=kind_help) + kind_parser.add_argument("name", help=f"Name of the {kind}") + + # pack command + pack_parser = subparsers.add_parser( + "pack", + help="Build a deployable wheel of the current project (never includes .env/secrets)", + ) + pack_parser.add_argument( + "--dry-run", + action="store_true", + help="Show files that would be packed without creating the artifact", + ) + + # upgrade command + upgrade_parser = subparsers.add_parser( + "upgrade", + help="Update the nitrostack dependency to the latest (or a specific) PyPI version", + ) + upgrade_parser.add_argument( + "--version", + dest="target_version", + metavar="X.Y.Z", + help="Pin nitrostack to this version instead of the latest PyPI release", + ) + upgrade_parser.add_argument( + "--dry-run", + action="store_true", + help="Show the version change without modifying pyproject.toml", + ) + + # install command + install_parser = subparsers.add_parser( + "install", + help="Install project dependencies from pyproject.toml / requirements.txt", + ) + install_parser.add_argument( + "--production", + action="store_true", + help="Skip development dependencies (optional extras and requirements-dev.txt)", + ) + + # validate command + subparsers.add_parser( + "validate", + help="Lint project config, @mcp_app imports, and @module() class references", + ) + args = parser.parse_args() if not args.command: @@ -1278,12 +1344,37 @@ def main(): register_server(args.name, args.file) elif args.command == "generate": if not args.generator: - parser.parse_args(["generate", "--help"]) + gen_parser.print_help() sys.exit(1) if args.generator == "tool": generate_tool(args.name) elif args.generator == "module": generate_module(args.name) + else: + generate_component(args.generator, args.name) + elif args.command == "pack": + try: + pack_project(dry_run=args.dry_run) + except Exception as exc: + print(f"Error: {exc}") + sys.exit(1) + elif args.command == "upgrade": + try: + upgrade_project(version=args.target_version, dry_run=args.dry_run) + except UpgradeError as exc: + print(f"Error: {exc}") + sys.exit(1) + elif args.command == "install": + try: + install_dependencies(production=args.production) + except Exception as exc: + print(f"Error: {exc}") + sys.exit(1) + elif args.command == "validate": + issues = validate_project() + print(format_report(issues)) + if any(issue.severity == "error" for issue in issues): + sys.exit(1) if __name__ == "__main__": main() diff --git a/nitrostack/cli/pack.py b/nitrostack/cli/pack.py new file mode 100644 index 0000000..3a057a7 --- /dev/null +++ b/nitrostack/cli/pack.py @@ -0,0 +1,537 @@ +"""Project packing for `nitrostack-py pack`. + +Builds a deployable wheel of the current project using the setuptools backend +already declared in NitroStack's packaging config. Secrets (``.env``) are never +included. Temporary build artifacts are cleaned up after packing. +""" + +from __future__ import annotations + +import os +import re +import shutil +import stat +import tempfile +import zipfile +from fnmatch import fnmatch +from typing import Iterable, List, Optional, Sequence, Set, Tuple + +# Directories that must never ship in a pack artifact. +_EXCLUDE_DIR_NAMES = { + ".git", + ".hg", + ".svn", + ".venv", + "venv", + "env", + ".env", + "__pycache__", + ".pytest_cache", + ".mypy_cache", + ".ruff_cache", + ".tox", + ".nox", + ".cache", + "node_modules", + ".next", + "dist", + "build", + ".eggs", + ".idea", + ".vscode", +} + +_EXCLUDE_SUFFIXES = { + ".pyc", + ".pyo", + ".pyd", + ".so", + ".dll", + ".egg", +} + +_EXCLUDE_NAME_GLOBS = { + "*.egg-info", +} + +ENV_EXAMPLE_STUB = """PORT=8000 +NODE_ENV=development +""" + +_PYPROJECT_TEMPLATE = """[build-system] +requires = ["setuptools>=61.0.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "{name}" +version = "{version}" +description = "Packed NitroStack MCP server" +requires-python = ">=3.10" +dependencies = {deps} + +[tool.setuptools] +py-modules = {py_modules} + +[tool.setuptools.packages.find] +where = ["."] +namespaces = true +exclude = ["tests*", "venv*", "node_modules*", "src.widgets*"] + +[tool.setuptools.package-data] +"*" = [".env.example", "requirements.txt", "*.md"] +""" + + +def _is_secret_env(name: str) -> bool: + if name == ".env.example": + return False + return name == ".env" or name.startswith(".env.") + + +def _load_gitignore_patterns(root: str) -> List[Tuple[str, bool]]: + """Return (pattern, is_negation) pairs from .gitignore if present.""" + path = os.path.join(root, ".gitignore") + patterns: List[Tuple[str, bool]] = [] + if not os.path.isfile(path): + return patterns + try: + with open(path, "r", encoding="utf-8") as handle: + for raw in handle: + line = raw.strip() + if not line or line.startswith("#"): + continue + negated = line.startswith("!") + if negated: + line = line[1:] + patterns.append((line.rstrip("/"), negated)) + except OSError: + return [] + return patterns + + +def _matches_gitignore(rel_posix: str, patterns: Sequence[Tuple[str, bool]]) -> bool: + ignored = False + basename = rel_posix.rsplit("/", 1)[-1] + for pattern, negated in patterns: + pat = pattern[2:] if pattern.startswith("./") else pattern + hit = ( + fnmatch(rel_posix, pat) + or fnmatch(rel_posix, pat.rstrip("/") + "/*") + or fnmatch(basename, pat) + or (pat.endswith("/") and (rel_posix.startswith(pat) or rel_posix.startswith(pat.rstrip("/")))) + ) + if hit: + ignored = not negated + return ignored + + +def _should_exclude(rel_posix: str, name: str, is_dir: bool, gitignore: Sequence[Tuple[str, bool]]) -> bool: + if _is_secret_env(name): + return True + if name in _EXCLUDE_DIR_NAMES and is_dir: + return True + if name in _EXCLUDE_DIR_NAMES and not is_dir and name in {".git"}: + return True + if any(fnmatch(name, glob) for glob in _EXCLUDE_NAME_GLOBS): + return True + if any(name.endswith(suffix) for suffix in _EXCLUDE_SUFFIXES): + return True + if name.endswith(".egg-info") or ".egg-info/" in rel_posix: + return True + if _matches_gitignore(rel_posix, gitignore) and name != ".env.example": + # gitignore may ignore .env.* — still keep .env.example explicitly. + if name == ".env.example": + return False + return True + return False + + +def collect_pack_files(root: str) -> List[str]: + """Return posix-relative paths that would be included in a pack.""" + gitignore = _load_gitignore_patterns(root) + included: List[str] = [] + root = os.path.abspath(root) + + for dirpath, dirnames, filenames in os.walk(root): + rel_dir = os.path.relpath(dirpath, root) + rel_dir_posix = "" if rel_dir == "." else rel_dir.replace("\\", "/") + + kept_dirs = [] + for dirname in dirnames: + child_rel = f"{rel_dir_posix}/{dirname}" if rel_dir_posix else dirname + if _should_exclude(child_rel, dirname, True, gitignore): + continue + kept_dirs.append(dirname) + dirnames[:] = kept_dirs + + for filename in filenames: + child_rel = f"{rel_dir_posix}/{filename}" if rel_dir_posix else filename + if _should_exclude(child_rel, filename, False, gitignore): + continue + included.append(child_rel) + + if ".env.example" not in included: + included.append(".env.example") + included.sort() + return included + + +def _read_text(path: str) -> str: + with open(path, "r", encoding="utf-8-sig") as handle: + return handle.read() + + +def _parse_pyproject_field(text: str, field: str) -> Optional[str]: + match = re.search(rf'^{field}\s*=\s*["\']([^"\']+)["\']', text, re.MULTILINE) + return match.group(1) if match else None + + +def _parse_pyproject_dependencies(text: str) -> List[str]: + match = re.search(r"^dependencies\s*=\s*\[(.*?)\]", text, re.MULTILINE | re.DOTALL) + if not match: + return [] + deps: List[str] = [] + for raw in match.group(1).split(","): + item = raw.strip().strip(",").strip() + if not item: + continue + if item.startswith("#"): + continue + deps.append(item.strip("\"'").lstrip("\ufeff").strip()) + return deps + + +def _parse_requirements(path: str) -> List[str]: + deps: List[str] = [] + with open(path, "r", encoding="utf-8-sig") as handle: + for raw in handle: + line = raw.strip().lstrip("\ufeff").strip() + if not line or line.startswith("#") or line.startswith("-"): + continue + deps.append(line) + return deps + + +def _project_name_and_version(root: str) -> Tuple[str, str]: + pyproject = os.path.join(root, "pyproject.toml") + if os.path.isfile(pyproject): + text = _read_text(pyproject) + name = _parse_pyproject_field(text, "name") + version = _parse_pyproject_field(text, "version") + if name and version: + return name, version + if name: + return name, version or "0.1.0" + return os.path.basename(os.path.abspath(root)) or "nitrostack-project", "0.1.0" + + +def _normalize_dist_name(name: str) -> str: + normalized = re.sub(r"[-_.]+", "-", name).strip("-").lower() + return normalized or "nitrostack-project" + + +def _pep503_wheel_name(name: str) -> str: + return re.sub(r"[-_.]+", "_", name) + + +def _requirement_name(req: str) -> str: + return re.split(r"[=<>!~\[]", req.lstrip("\ufeff"), maxsplit=1)[0].strip().lower().replace("_", "-") + + +def requirements_from_project(root: str) -> List[str]: + pyproject = os.path.join(root, "pyproject.toml") + req_path = os.path.join(root, "requirements.txt") + deps: List[str] = [] + name, _ = _project_name_and_version(root) + if os.path.isfile(pyproject): + deps = _parse_pyproject_dependencies(_read_text(pyproject)) + if not deps and os.path.isfile(req_path): + deps = _parse_requirements(req_path) + deps = [item.lstrip("\ufeff").strip() for item in deps if item.lstrip("\ufeff").strip()] + has_nitrostack = any(_requirement_name(item) == "nitrostack" for item in deps) + if not has_nitrostack and name.lower() != "nitrostack": + deps = ["nitrostack", *deps] + return deps + + +def write_requirements_txt(root: str, deps: Sequence[str]) -> str: + path = os.path.join(root, "requirements.txt") + body = "".join(f"{dep}\n" for dep in deps) if deps else "nitrostack\n" + with open(path, "w", encoding="utf-8") as handle: + handle.write(body) + return path + + +def _ensure_env_example(root: str) -> str: + path = os.path.join(root, ".env.example") + if not os.path.isfile(path): + with open(path, "w", encoding="utf-8") as handle: + handle.write(ENV_EXAMPLE_STUB) + return path + + +def _discover_py_modules_and_packages(root: str) -> Tuple[List[str], List[str]]: + py_modules: List[str] = [] + packages: Set[str] = set() + for dirpath, dirnames, filenames in os.walk(root): + dirnames[:] = [d for d in dirnames if d not in _EXCLUDE_DIR_NAMES and not d.endswith(".egg-info")] + rel_dir = os.path.relpath(dirpath, root) + if rel_dir == ".": + for filename in filenames: + if filename.endswith(".py") and filename != "setup.py": + py_modules.append(os.path.splitext(filename)[0]) + continue + parts = rel_dir.replace("\\", "/").split("/") + if any(part in _EXCLUDE_DIR_NAMES for part in parts): + continue + if any(filename.endswith(".py") for filename in filenames): + packages.add(".".join(parts)) + return sorted(py_modules), sorted(packages) + + +def _toml_list(values: Iterable[str]) -> str: + items = ", ".join(f'"{v}"' for v in values) + return f"[{items}]" + + +def _ensure_pyproject(root: str, name: str, version: str, deps: Sequence[str]) -> None: + path = os.path.join(root, "pyproject.toml") + if os.path.isfile(path): + return + py_modules, _packages = _discover_py_modules_and_packages(root) + content = _PYPROJECT_TEMPLATE.format( + name=_normalize_dist_name(name), + version=version, + deps=_toml_list(deps), + py_modules=_toml_list(py_modules), + ) + with open(path, "w", encoding="utf-8") as handle: + handle.write(content) + + +def _rmtree(path: str) -> None: + def _onerror(func, p, _exc): + try: + os.chmod(p, stat.S_IWRITE) + func(p) + except OSError: + pass + + shutil.rmtree(path, onerror=_onerror) + + +def _copy_selected(src_root: str, dest_root: str, files: Sequence[str]) -> None: + for rel in files: + if rel == ".env.example": + continue # handled separately so a stub can be created + src = os.path.join(src_root, rel.replace("/", os.sep)) + dest = os.path.join(dest_root, rel.replace("/", os.sep)) + if not os.path.isfile(src): + continue + os.makedirs(os.path.dirname(dest) or dest_root, exist_ok=True) + shutil.copy2(src, dest) + + +def _sha256_record(data: bytes) -> str: + import base64 + import hashlib + + digest = hashlib.sha256(data).digest() + return "sha256=" + base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii") + + +def _inject_into_wheel(wheel_path: str, files: Sequence[Tuple[str, str]]) -> None: + """Add extra files to a built wheel and rewrite RECORD so the wheel stays valid.""" + with zipfile.ZipFile(wheel_path, "r") as src: + contents = {name: src.read(name) for name in src.namelist()} + + for arcname, source in files: + posix = arcname.replace("\\", "/") + if posix in contents or not os.path.isfile(source): + continue + with open(source, "rb") as handle: + contents[posix] = handle.read() + + record_name = next((n for n in contents if n.endswith(".dist-info/RECORD")), None) + if record_name: + rows = [] + for name in sorted(contents): + if name == record_name: + continue + data = contents[name] + rows.append(f"{name},{_sha256_record(data)},{len(data)}") + rows.append(f"{record_name},,") + contents[record_name] = ("\n".join(rows) + "\n").encode("utf-8") + + tmp_path = wheel_path + ".tmp" + with zipfile.ZipFile(tmp_path, "w", compression=zipfile.ZIP_DEFLATED) as dest: + for name, data in contents.items(): + dest.writestr(name, data) + os.replace(tmp_path, wheel_path) + + +def _is_valid_wheel(path: str) -> bool: + if not zipfile.is_zipfile(path): + return False + with zipfile.ZipFile(path, "r") as zf: + names = zf.namelist() + has_wheel = any(n.endswith(".dist-info/WHEEL") for n in names) + has_meta = any(n.endswith(".dist-info/METADATA") for n in names) + has_record = any(n.endswith(".dist-info/RECORD") for n in names) + if not (has_wheel and has_meta and has_record): + return False + wheel_name = next(n for n in names if n.endswith(".dist-info/WHEEL")) + body = zf.read(wheel_name).decode("utf-8", errors="replace") + return "Wheel-Version:" in body + + +def _collect_staging_bytes(src_root: str) -> dict: + contents = {} + for dirpath, dirnames, filenames in os.walk(src_root): + dirnames[:] = [d for d in dirnames if d not in _EXCLUDE_DIR_NAMES and not d.endswith(".egg-info")] + for filename in filenames: + if any(filename.endswith(suffix) for suffix in _EXCLUDE_SUFFIXES): + continue + if _is_secret_env(filename): + continue + full = os.path.join(dirpath, filename) + rel = os.path.relpath(full, src_root).replace("\\", "/") + with open(full, "rb") as handle: + contents[rel] = handle.read() + return contents + + +def _write_purelib_wheel( + src_root: str, + wheel_dir: str, + name: str, + version: str, + deps: Sequence[str], +) -> str: + """Write a PEP 427 py3-none-any wheel from the staged project files.""" + os.makedirs(wheel_dir, exist_ok=True) + dist_name = _pep503_wheel_name(_normalize_dist_name(name)) + display_name = _normalize_dist_name(name) + wheel_filename = f"{dist_name}-{version}-py3-none-any.whl" + wheel_path = os.path.join(wheel_dir, wheel_filename) + dist_info = f"{dist_name}-{version}.dist-info" + + contents = _collect_staging_bytes(src_root) + metadata_lines = [ + "Metadata-Version: 2.1", + f"Name: {display_name}", + f"Version: {version}", + "Summary: Packed NitroStack MCP server", + "Requires-Python: >=3.10", + ] + for dep in deps: + metadata_lines.append(f"Requires-Dist: {dep}") + contents[f"{dist_info}/METADATA"] = ("\n".join(metadata_lines) + "\n").encode("utf-8") + contents[f"{dist_info}/WHEEL"] = ( + "Wheel-Version: 1.0\n" + "Generator: nitrostack-py pack\n" + "Root-Is-Purelib: true\n" + "Tag: py3-none-any\n" + ).encode("utf-8") + + record_name = f"{dist_info}/RECORD" + rows = [] + for arcname in sorted(contents): + data = contents[arcname] + rows.append(f"{arcname},{_sha256_record(data)},{len(data)}") + rows.append(f"{record_name},,") + contents[record_name] = ("\n".join(rows) + "\n").encode("utf-8") + + with zipfile.ZipFile(wheel_path, "w", compression=zipfile.ZIP_DEFLATED) as zf: + for arcname, data in contents.items(): + zf.writestr(arcname, data) + return wheel_path + + +def _build_wheel_with_setuptools(src_root: str, wheel_dir: str) -> Optional[str]: + """Use the setuptools PEP 517 backend when it is importable.""" + try: + from setuptools.build_meta import build_wheel as setuptools_build_wheel + except ImportError: + return None + os.makedirs(wheel_dir, exist_ok=True) + previous = os.getcwd() + try: + os.chdir(src_root) + filename = setuptools_build_wheel(wheel_dir) + except Exception: + return None + finally: + os.chdir(previous) + path = os.path.join(wheel_dir, filename) + return path if os.path.isfile(path) else None + + +def _build_wheel( + src_root: str, + wheel_dir: str, + name: str, + version: str, + deps: Sequence[str], +) -> str: + os.makedirs(wheel_dir, exist_ok=True) + built = _build_wheel_with_setuptools(src_root, wheel_dir) + if built: + return built + return _write_purelib_wheel(src_root, wheel_dir, name, version, deps) + + +def pack_project( + root: Optional[str] = None, + *, + dry_run: bool = False, + output_dir: Optional[str] = None, +) -> dict: + """Pack the project at ``root``. + + Returns a dict with ``files``, ``wheel`` (path or planned name), and ``dry_run``. + """ + root = os.path.abspath(root or os.getcwd()) + files = collect_pack_files(root) + name, version = _project_name_and_version(root) + dist_name = _pep503_wheel_name(_normalize_dist_name(name)) + planned_wheel = f"{dist_name}-{version}-py3-none-any.whl" + dest_dir = os.path.abspath(output_dir or os.path.join(root, "dist")) + + print("Files that would be packed:" if dry_run else "Packing files:") + for rel in files: + print(f" {rel}") + + if dry_run: + print(f"\nWould write: {os.path.join('dist', planned_wheel)}") + print("Dry run — no artifact created.") + return {"files": files, "wheel": os.path.join(dest_dir, planned_wheel), "dry_run": True} + + staging = tempfile.mkdtemp(prefix="nitrostack-pack-") + wheel_tmp = tempfile.mkdtemp(prefix="nitrostack-wheel-") + try: + _copy_selected(root, staging, files) + _ensure_env_example(staging) + deps = requirements_from_project(root) + write_requirements_txt(staging, deps) + _ensure_pyproject(staging, name, version, deps) + + wheel_path = _build_wheel(staging, wheel_tmp, name, version, deps) + extra = [] + env_example = os.path.join(staging, ".env.example") + reqs = os.path.join(staging, "requirements.txt") + extra.append((".env.example", env_example)) + extra.append(("requirements.txt", reqs)) + _inject_into_wheel(wheel_path, extra) + + if not _is_valid_wheel(wheel_path): + raise RuntimeError(f"Built file is not a valid wheel: {wheel_path}") + + os.makedirs(dest_dir, exist_ok=True) + final_path = os.path.join(dest_dir, os.path.basename(wheel_path)) + shutil.copy2(wheel_path, final_path) + print(f"\nCreated wheel: {final_path}") + return {"files": files, "wheel": final_path, "dry_run": False} + finally: + _rmtree(staging) + _rmtree(wheel_tmp) diff --git a/nitrostack/cli/templates/__init__.py b/nitrostack/cli/templates/__init__.py new file mode 100644 index 0000000..8e34b95 --- /dev/null +++ b/nitrostack/cli/templates/__init__.py @@ -0,0 +1 @@ +"""Boilerplate templates for `nitrostack-py generate`.""" diff --git a/nitrostack/cli/templates/filter.py b/nitrostack/cli/templates/filter.py new file mode 100644 index 0000000..c5c786a --- /dev/null +++ b/nitrostack/cli/templates/filter.py @@ -0,0 +1,19 @@ +from datetime import datetime, timezone +from typing import Any + +from nitrostack import injectable, ExecutionContext + + +@injectable(deps=[]) +class CLASS_NAME: + """Exception filter. Attach with ``@use_filters(CLASS_NAME)`` on a tool.""" + + async def catch(self, error: Exception, context: ExecutionContext) -> Any: + context.logger.error(f"{type(error).__name__}: {error}") + return { + "statusCode": getattr(error, "status", 500), + "error": type(error).__name__, + "message": str(error) or "Internal server error", + "timestamp": datetime.now(timezone.utc).isoformat(), + "tool": context.tool_name, + } diff --git a/nitrostack/cli/templates/guard.py b/nitrostack/cli/templates/guard.py new file mode 100644 index 0000000..22db599 --- /dev/null +++ b/nitrostack/cli/templates/guard.py @@ -0,0 +1,12 @@ +from nitrostack import injectable, ExecutionContext + + +@injectable(deps=[]) +class CLASS_NAME: + """Authorization guard. Attach with ``@use_guards(CLASS_NAME)`` on a tool.""" + + async def can_activate(self, context: ExecutionContext) -> bool: + # TODO: implement authorization logic (scopes, roles, API keys, ...) + if context.auth is None: + return False + return True diff --git a/nitrostack/cli/templates/interceptor.py b/nitrostack/cli/templates/interceptor.py new file mode 100644 index 0000000..77a3ece --- /dev/null +++ b/nitrostack/cli/templates/interceptor.py @@ -0,0 +1,18 @@ +from datetime import datetime, timezone +from typing import Any, Callable + +from nitrostack import injectable, ExecutionContext + + +@injectable(deps=[]) +class CLASS_NAME: + """Execution interceptor. Attach with ``@use_interceptors(CLASS_NAME)`` on a tool.""" + + async def intercept(self, context: ExecutionContext, next_fn: Callable[[], Any]) -> Any: + result = await next_fn() + return { + "success": True, + "data": result, + "tool": context.tool_name, + "timestamp": datetime.now(timezone.utc).isoformat(), + } diff --git a/nitrostack/cli/templates/module.py b/nitrostack/cli/templates/module.py new file mode 100644 index 0000000..d721d9f --- /dev/null +++ b/nitrostack/cli/templates/module.py @@ -0,0 +1,12 @@ +from nitrostack import module + + +@module( + name="{name}", + imports=[], + controllers=[], + providers=[], + exports=[] +) +class {camel_name}Module: + pass diff --git a/nitrostack/cli/templates/pipe.py b/nitrostack/cli/templates/pipe.py new file mode 100644 index 0000000..a8e979b --- /dev/null +++ b/nitrostack/cli/templates/pipe.py @@ -0,0 +1,13 @@ +from typing import Any + +from nitrostack import injectable +from nitrostack.core.pipeline import PipeMetadata + + +@injectable(deps=[]) +class CLASS_NAME: + """Validation/transform pipe. Attach with ``@use_pipes(CLASS_NAME)`` on a tool.""" + + async def transform(self, value: Any, metadata: PipeMetadata) -> Any: + # TODO: validate or transform ``value`` (param: metadata.param_name) + return value diff --git a/nitrostack/cli/templates/service.py b/nitrostack/cli/templates/service.py new file mode 100644 index 0000000..347c5ee --- /dev/null +++ b/nitrostack/cli/templates/service.py @@ -0,0 +1,10 @@ +from nitrostack import injectable + + +@injectable(deps=[]) +class CLASS_NAME: + """Injectable service. Register it in a module's ``providers`` list.""" + + def __init__(self): + # TODO: add constructor dependencies via @injectable(deps=[...]) + pass diff --git a/nitrostack/cli/upgrade.py b/nitrostack/cli/upgrade.py new file mode 100644 index 0000000..aa10015 --- /dev/null +++ b/nitrostack/cli/upgrade.py @@ -0,0 +1,197 @@ +"""In-place nitrostack version upgrades for `nitrostack-py upgrade`.""" + +from __future__ import annotations + +import json +import os +import re +import urllib.error +import urllib.request +from typing import Optional, Tuple + +PYPI_JSON = "https://pypi.org/pypi/nitrostack/json" +PYPI_VERSION_JSON = "https://pypi.org/pypi/nitrostack/{version}/json" + +_DEP_RE = re.compile( + r'(["\']?)(nitrostack)((?:\s*(?:===|==|!=|~=|>=|<=|>|<)\s*[^"\'\s,#]+)?)(\1)', + re.IGNORECASE, +) + + +class UpgradeError(RuntimeError): + pass + + +def fetch_latest_nitrostack_version(timeout: float = 15.0) -> str: + req = urllib.request.Request( + PYPI_JSON, + headers={"Accept": "application/json", "User-Agent": "nitrostack-py"}, + ) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + payload = json.loads(resp.read().decode("utf-8")) + except urllib.error.URLError as exc: + raise UpgradeError( + f"Could not reach PyPI to determine the latest nitrostack version: {exc}\n" + "Check your network connection, then retry." + ) from exc + version = (payload.get("info") or {}).get("version") + if not version: + raise UpgradeError("PyPI response did not include a version for nitrostack.") + return str(version) + + +def verify_nitrostack_version(version: str, timeout: float = 15.0) -> None: + req = urllib.request.Request( + PYPI_VERSION_JSON.format(version=version), + headers={"Accept": "application/json", "User-Agent": "nitrostack-py"}, + ) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + if getattr(resp, "status", 200) >= 400: + raise UpgradeError(f"nitrostack=={version} was not found on PyPI.") + except urllib.error.HTTPError as exc: + if exc.code == 404: + raise UpgradeError( + f"nitrostack=={version} was not found on PyPI. " + "Check the version string and retry." + ) from exc + raise UpgradeError(f"PyPI lookup for nitrostack=={version} failed: {exc}") from exc + except urllib.error.URLError as exc: + raise UpgradeError(f"Could not reach PyPI to verify nitrostack=={version}: {exc}") from exc + + +def find_current_spec(text: str) -> Optional[str]: + match = _DEP_RE.search(text) + if not match: + return None + spec = (match.group(2) + (match.group(3) or "")).strip() + return spec + + +def replace_nitrostack_spec(text: str, version: str) -> Tuple[str, int]: + replacement = f"nitrostack>={version}" + + def _sub(match: re.Match) -> str: + quote = match.group(1) or "" + return f"{quote}{replacement}{quote}" + + return _DEP_RE.subn(_sub, text) + + +def _add_to_pyproject_dependencies(text: str, spec: str) -> str: + match = re.search(r"(^dependencies\s*=\s*\[)(.*?)(\])", text, re.MULTILINE | re.DOTALL) + if not match: + # Insert a dependencies array under [project] if possible. + project = re.search(r"^\[project\][^\[]*", text, re.MULTILINE | re.DOTALL) + if not project: + raise UpgradeError( + "pyproject.toml has no [project] table. Add one (or add nitrostack to " + "requirements.txt) before running upgrade." + ) + insert_at = project.end() + block = f'\ndependencies = [\n "{spec}",\n]\n' + return text[:insert_at] + block + text[insert_at:] + inner = match.group(2).rstrip() + indent = " " + addition = f'\n{indent}"{spec}",\n' + if inner.strip(): + if not inner.rstrip().endswith(","): + # keep existing formatting; append comma + new item + addition = f',\n{indent}"{spec}",\n' + else: + addition = f'{indent}"{spec}",\n' + new_inner = inner + ("" if inner.endswith("\n") else "\n") + addition + else: + new_inner = addition + return text[: match.start(2)] + new_inner + text[match.end(2) :] + + +def upgrade_project( + root: Optional[str] = None, + *, + version: Optional[str] = None, + dry_run: bool = False, + verify: bool = True, +) -> dict: + """Update the nitrostack dependency spec in pyproject.toml (and requirements.txt if present).""" + root = os.path.abspath(root or os.getcwd()) + pyproject = os.path.join(root, "pyproject.toml") + requirements = os.path.join(root, "requirements.txt") + + if not os.path.isfile(pyproject) and not os.path.isfile(requirements): + raise UpgradeError( + "No pyproject.toml or requirements.txt found in the current directory.\n" + "Run this command from a NitroStack project, or create pyproject.toml first." + ) + + target = version or fetch_latest_nitrostack_version() + if version and verify: + verify_nitrostack_version(target) + + new_spec = f"nitrostack>={target}" + changes = [] + original_pyproject = _read(pyproject) if os.path.isfile(pyproject) else None + original_reqs = _read(requirements) if os.path.isfile(requirements) else None + + if original_pyproject is not None: + current = find_current_spec(original_pyproject) + updated, n = replace_nitrostack_spec(original_pyproject, target) + if n == 0: + updated = _add_to_pyproject_dependencies(original_pyproject, new_spec) + current = current or "(missing)" + changes.append( + { + "file": "pyproject.toml", + "from": current or "(missing)", + "to": new_spec, + "text": updated, + } + ) + + # Phase 5 requires pyproject.toml in-place updates. Also keep requirements.txt + # in sync when it already pins nitrostack, so install/pack stay consistent. + if original_reqs is not None and find_current_spec(original_reqs): + current = find_current_spec(original_reqs) + updated, n = replace_nitrostack_spec(original_reqs, target) + if n: + changes.append( + { + "file": "requirements.txt", + "from": current, + "to": new_spec, + "text": updated, + } + ) + + if not changes: + raise UpgradeError( + "Could not find a nitrostack dependency to update.\n" + "Add `nitrostack` to [project].dependencies in pyproject.toml and retry." + ) + + print("NITROSTACK — Upgrade" + (" (dry run)" if dry_run else "")) + print(f"Target version: {target}") + for change in changes: + print(f" {change['file']}: {change['from']} → {change['to']}") + + if dry_run: + print("\nDry run — no files modified.") + return {"version": target, "changes": changes, "dry_run": True, "written": []} + + written = [] + for change in changes: + path = os.path.join(root, change["file"]) + with open(path, "w", encoding="utf-8") as handle: + handle.write(change["text"]) + written.append(change["file"]) + print(f"Updated {change['file']}") + + print(f"\nUpgrade complete. nitrostack dependency is now {new_spec}.") + print("Run `nitrostack-py install` to install the new version.") + return {"version": target, "changes": changes, "dry_run": False, "written": written} + + +def _read(path: str) -> str: + with open(path, "r", encoding="utf-8") as handle: + return handle.read() diff --git a/nitrostack/cli/validators.py b/nitrostack/cli/validators.py new file mode 100644 index 0000000..f25c607 --- /dev/null +++ b/nitrostack/cli/validators.py @@ -0,0 +1,376 @@ +"""Project validation for `nitrostack-py validate`.""" + +from __future__ import annotations + +import ast +import importlib.util +import os +import re +import sys +import traceback +from dataclasses import dataclass +from typing import Any, Iterable, List, Optional, Sequence, Set, Tuple + +_EXCLUDE_DIR_NAMES = { + ".git", + ".venv", + "venv", + "env", + "__pycache__", + ".pytest_cache", + ".mypy_cache", + ".ruff_cache", + "node_modules", + ".next", + "dist", + "build", + ".eggs", + ".tox", + ".nox", +} + + +@dataclass +class ValidationIssue: + severity: str # "error" or "warning" + message: str + hint: str = "" + path: str = "" + + def format(self) -> str: + location = f"{self.path}: " if self.path else "" + text = f"[{self.severity.upper()}] {location}{self.message}" + if self.hint: + text += f"\n → {self.hint}" + return text + + +def _iter_python_files(root: str) -> Iterable[str]: + for dirpath, dirnames, filenames in os.walk(root): + dirnames[:] = [ + d for d in dirnames + if d not in _EXCLUDE_DIR_NAMES and not d.endswith(".egg-info") + ] + for filename in filenames: + if filename.endswith(".py"): + yield os.path.join(dirpath, filename) + + +def _read(path: str) -> str: + with open(path, "r", encoding="utf-8") as handle: + return handle.read() + + +def _parse_pyproject_dependencies(text: str) -> List[str]: + match = re.search(r"^dependencies\s*=\s*\[(.*?)\]", text, re.MULTILINE | re.DOTALL) + if not match: + return [] + deps: List[str] = [] + for raw in match.group(1).split(","): + item = raw.strip().strip(",").strip() + if not item or item.startswith("#"): + continue + deps.append(item.strip("\"'")) + return deps + + +def _parse_requirements(path: str) -> List[str]: + deps: List[str] = [] + with open(path, "r", encoding="utf-8") as handle: + for raw in handle: + line = raw.strip() + if not line or line.startswith("#") or line.startswith("-"): + continue + deps.append(line) + return deps + + +def _split_req(req: str) -> Tuple[str, str]: + match = re.match(r"^\s*([A-Za-z0-9_.-]+)\s*(.*)$", req) + if not match: + return req.strip().lower(), "" + return match.group(1).lower().replace("_", "-"), match.group(2).strip() + + +def _parse_version_constraint(spec: str) -> Optional[Tuple[str, str]]: + match = re.match(r"^(===|==|!=|~=|>=|<=|>|<)\s*([0-9A-Za-z][0-9A-Za-z._-]*)", spec.strip()) + if not match: + return None + return match.group(1), match.group(2) + + +def _version_tuple(version: str) -> Tuple[int, ...]: + parts = [] + for chunk in re.split(r"[._-]", version): + if chunk.isdigit(): + parts.append(int(chunk)) + else: + break + return tuple(parts) or (0,) + + +def _constraints_conflict(a: str, b: str) -> bool: + """Conservative conflict check for simple numeric pins (e.g. ==1 vs ==2, >=3 vs <3).""" + ca = _parse_version_constraint(a) + cb = _parse_version_constraint(b) + if not ca or not cb: + return False + op_a, ver_a = ca + op_b, ver_b = cb + va, vb = _version_tuple(ver_a), _version_tuple(ver_b) + if op_a in {"==", "==="} and op_b in {"==", "==="}: + return va != vb + pairs = [(op_a, va, op_b, vb), (op_b, vb, op_a, va)] + for op_x, vx, op_y, vy in pairs: + if op_x in {">=", ">"} and op_y in {"==", "==="} and ( + vy < vx or (op_x == ">" and vy <= vx) + ): + return True + if op_x in {"<=", "<"} and op_y in {"==", "==="} and ( + vy > vx or (op_x == "<" and vy >= vx) + ): + return True + if op_x in {">=", ">"} and op_y in {"<=", "<"}: + if vx > vy or (vx == vy and (op_x == ">" or op_y == "<")): + return True + return False + + +def validate_dependencies(root: str) -> List[ValidationIssue]: + issues: List[ValidationIssue] = [] + pyproject = os.path.join(root, "pyproject.toml") + requirements = os.path.join(root, "requirements.txt") + has_pyproject = os.path.isfile(pyproject) + has_requirements = os.path.isfile(requirements) + + if not has_pyproject and not has_requirements: + issues.append(ValidationIssue( + "error", + "Neither pyproject.toml nor requirements.txt was found.", + "Create one of these files and declare a `nitrostack` dependency.", + )) + return issues + + py_deps = _parse_pyproject_dependencies(_read(pyproject)) if has_pyproject else [] + req_deps = _parse_requirements(requirements) if has_requirements else [] + + if has_pyproject and not py_deps: + issues.append(ValidationIssue( + "warning", + "[project].dependencies is missing or empty in pyproject.toml.", + "Add your runtime packages, including nitrostack, to [project].dependencies.", + "pyproject.toml", + )) + if has_requirements and not req_deps: + issues.append(ValidationIssue( + "warning", + "requirements.txt is empty.", + "List runtime packages (at least `nitrostack`) so install/pack can reproduce the environment.", + "requirements.txt", + )) + + py_map = dict(_split_req(dep) for dep in py_deps) + req_map = dict(_split_req(dep) for dep in req_deps) + + if has_pyproject and has_requirements: + for name in sorted(set(py_map) & set(req_map)): + if _constraints_conflict(py_map[name], req_map[name]): + issues.append(ValidationIssue( + "error", + f"Conflicting version specs for '{name}': " + f"pyproject.toml has '{name}{py_map[name] or ''}' but " + f"requirements.txt has '{name}{req_map[name] or ''}'.", + "Make the two files agree on one version range, then re-run validate.", + )) + + declared = {_split_req(d)[0] for d in py_deps + req_deps} + project_name = "" + if has_pyproject: + match = re.search(r'^name\s*=\s*["\']([^"\']+)["\']', _read(pyproject), re.MULTILINE) + project_name = (match.group(1) if match else "").lower() + if "nitrostack" not in declared and project_name != "nitrostack": + issues.append(ValidationIssue( + "error", + "The `nitrostack` package is not declared in pyproject.toml or requirements.txt.", + "Add `nitrostack` to [project].dependencies (or requirements.txt) so the server can be installed.", + )) + + return issues + + +def _decorator_names(node: ast.AST) -> Set[str]: + names: Set[str] = set() + if isinstance(node, ast.Name): + names.add(node.id) + elif isinstance(node, ast.Attribute): + names.add(node.attr) + elif isinstance(node, ast.Call): + names.update(_decorator_names(node.func)) + return names + + +def _module_name_from_path(root: str, path: str) -> str: + rel = os.path.relpath(path, root) + no_ext = os.path.splitext(rel)[0] + parts = no_ext.replace("\\", "/").split("/") + if parts[-1] == "__init__": + parts = parts[:-1] + return ".".join(parts) + + +def _load_module(path: str, module_name: str): + spec = importlib.util.spec_from_file_location(module_name, path) + if spec is None or spec.loader is None: + raise ImportError(f"Could not create an import spec for '{path}'.") + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + return module + + +def _file_has_decorator(path: str, decorator: str) -> bool: + try: + tree = ast.parse(_read(path), filename=path) + except SyntaxError: + return False + for node in ast.walk(tree): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + for dec in node.decorator_list: + if decorator in _decorator_names(dec): + return True + return False + + +def validate_mcp_app_imports(root: str) -> List[ValidationIssue]: + issues: List[ValidationIssue] = [] + found = False + for path in _iter_python_files(root): + if not _file_has_decorator(path, "mcp_app"): + continue + found = True + rel = os.path.relpath(path, root) + module_name = f"nitrostack_validate_{_module_name_from_path(root, path).replace('.', '_')}" + try: + module = _load_module(path, module_name) + except Exception as exc: + tb = "".join(traceback.format_exception_only(type(exc), exc)).strip() + issues.append(ValidationIssue( + "error", + f"Failed to import @mcp_app module '{rel}': {tb}", + "Fix the import/name error in this file (missing package, typo, or circular import) " + "and re-run `nitrostack-py validate`.", + rel, + )) + continue + app_classes = [ + obj for obj in vars(module).values() + if isinstance(obj, type) and hasattr(obj, "_mcp_app_module") + ] + if not app_classes: + issues.append(ValidationIssue( + "error", + f"'{rel}' uses @mcp_app but no decorated application class was found after import.", + "Ensure the @mcp_app decorator is applied to a class in this file.", + rel, + )) + continue + for cls in app_classes: + app_module = getattr(cls, "_mcp_app_module", None) + if app_module is None: + issues.append(ValidationIssue( + "error", + f"{cls.__name__} is decorated with @mcp_app but has no root module.", + "Pass a real module class to @mcp_app(module=..., server=...).", + rel, + )) + elif not isinstance(app_module, type): + issues.append(ValidationIssue( + "error", + f"{cls.__name__} @mcp_app(module=...) does not reference a class " + f"(got {type(app_module).__name__}).", + "Pass the AppModule class itself, not an instance or string.", + rel, + )) + if not found: + issues.append(ValidationIssue( + "warning", + "No @mcp_app-decorated class was found in this project.", + "If this is an MCP server, decorate your application class with " + "@mcp_app(module=AppModule, server=ServerConfig(...)).", + )) + return issues + + +def _is_real_class(value: Any) -> bool: + return isinstance(value, type) + + +def validate_module_references(root: str) -> List[ValidationIssue]: + issues: List[ValidationIssue] = [] + for path in _iter_python_files(root): + if not _file_has_decorator(path, "module"): + continue + rel = os.path.relpath(path, root) + module_name = f"nitrostack_validate_mod_{_module_name_from_path(root, path).replace('.', '_')}" + try: + module = _load_module(path, module_name) + except Exception as exc: + tb = "".join(traceback.format_exception_only(type(exc), exc)).strip() + issues.append(ValidationIssue( + "error", + f"Failed to import @module file '{rel}': {tb}", + "Resolve the import error (typo, missing file, or broken relative import) and retry.", + rel, + )) + continue + + for obj in vars(module).values(): + if not isinstance(obj, type) or not hasattr(obj, "_mcp_module_config"): + continue + config = getattr(obj, "_mcp_module_config") + for field in ("imports", "exports", "controllers", "providers"): + entries = getattr(config, field, []) or [] + for index, entry in enumerate(entries): + if not _is_real_class(entry): + issues.append(ValidationIssue( + "error", + f"{obj.__name__}.{field}[{index}] is {entry!r} " + f"({type(entry).__name__}), not a class.", + f"Use the class object (e.g. {field[:-1].rstrip('e').title()}Class), " + "not a string or instance. Check for typos in the @module() lists.", + rel, + )) + continue + if field == "imports" and not hasattr(entry, "_mcp_module_config"): + issues.append(ValidationIssue( + "error", + f"{obj.__name__}.imports[{index}] references {entry.__name__}, " + "which is not decorated with @module().", + "Import a real NitroStack module class, or remove this entry from imports=[].", + rel, + )) + return issues + + +def validate_project(root: Optional[str] = None) -> List[ValidationIssue]: + root = os.path.abspath(root or os.getcwd()) + if root not in sys.path: + sys.path.insert(0, root) + issues: List[ValidationIssue] = [] + issues.extend(validate_dependencies(root)) + issues.extend(validate_mcp_app_imports(root)) + issues.extend(validate_module_references(root)) + return issues + + +def format_report(issues: Sequence[ValidationIssue]) -> str: + if not issues: + return "Validation passed. No issues found." + lines = ["Validation found the following issues:", ""] + for issue in issues: + lines.append(issue.format()) + errors = sum(1 for i in issues if i.severity == "error") + warnings = sum(1 for i in issues if i.severity == "warning") + lines.append("") + lines.append(f"{errors} error(s), {warnings} warning(s).") + return "\n".join(lines) diff --git a/pyproject.toml b/pyproject.toml index 64d08f7..3ef14ee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,7 @@ nitrostack-studio = "nitrostack.studio:start_server_cli" include = ["nitrostack*"] [tool.setuptools.package-data] -nitrostack = ["static/*", "templates/**/*"] +nitrostack = ["static/*", "templates/**/*", "cli/templates/*"] [tool.ruff] line-length = 100 diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..03d4472 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,221 @@ +"""Phase 5 CLI tests: generate, pack, upgrade, validate.""" + +from __future__ import annotations + +import ast +import asyncio +import importlib.util +import io +import os +import subprocess +import sys +import zipfile +from pathlib import Path +from unittest.mock import patch + +import pytest + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +sys.path.insert(0, REPO_ROOT) + +from nitrostack import ExecutionContext +from nitrostack.cli.generate import generate_component, generate_module +from nitrostack.cli.main import main +from nitrostack.cli.pack import _is_valid_wheel, pack_project +from nitrostack.cli.upgrade import upgrade_project +from nitrostack.cli.validators import validate_project + + +GENERATE_CASES = [ + ("guard", "MyGuard", Path("guards") / "my_guard.py", "MyGuard"), + ("pipe", "Validation", Path("pipes") / "validation.py", "ValidationPipe"), + ("interceptor", "Transform", Path("interceptors") / "transform.py", "TransformInterceptor"), + ("filter", "HttpException", Path("filters") / "http_exception.py", "HttpExceptionFilter"), + ("service", "Email", Path("services") / "email.py", "EmailService"), + ("module", "payments", Path("payments_module.py"), "PaymentsModule"), +] + + +def _load_module(path: Path, module_name: str): + spec = importlib.util.spec_from_file_location(module_name, path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _invoke_cli(args, cwd: Path) -> tuple[int, str]: + old_cwd = os.getcwd() + old_argv = sys.argv + stdout = io.StringIO() + stderr = io.StringIO() + try: + os.chdir(cwd) + sys.argv = ["nitrostack-py", *args] + try: + with patch("sys.stdout", stdout), patch("sys.stderr", stderr): + main() + code = 0 + except SystemExit as exc: + code = int(exc.code or 0) + finally: + os.chdir(old_cwd) + sys.argv = old_argv + return code, stdout.getvalue() + stderr.getvalue() + + +def test_cli_help_lists_new_commands(): + env = os.environ.copy() + env["PYTHONPATH"] = REPO_ROOT + os.pathsep + env.get("PYTHONPATH", "") + result = subprocess.run( + [sys.executable, "-m", "nitrostack.cli.main", "--help"], + cwd=REPO_ROOT, + capture_output=True, + text=True, + env=env, + ) + assert result.returncode == 0 + help_text = result.stdout + for command in ("init", "dev", "start", "register", "generate", "pack", "upgrade", "install", "validate"): + assert command in help_text, f"expected {command!r} in --help output" + + +def test_generate_guard_myguard_importable(tmp_path: Path): + generate_component("guard", "MyGuard", cwd=str(tmp_path)) + path = tmp_path / "guards" / "my_guard.py" + assert path.is_file() + ast.parse(path.read_text(encoding="utf-8")) + module = _load_module(path, "generated_my_guard") + instance = module.MyGuard() + ctx = ExecutionContext(request_id="cli-test") + assert asyncio.run(instance.can_activate(ctx)) is False + + +@pytest.mark.parametrize("kind,name,rel_path,class_name", GENERATE_CASES) +def test_generate_targets_valid_python(tmp_path: Path, kind: str, name: str, rel_path: Path, class_name: str): + if kind == "module": + generate_module(name, cwd=str(tmp_path)) + else: + generate_component(kind, name, cwd=str(tmp_path)) + path = tmp_path / rel_path + assert path.is_file(), f"expected generated file at {rel_path}" + source = path.read_text(encoding="utf-8") + ast.parse(source) + compile(source, str(path), "exec") + module = _load_module(path, f"generated_{kind}_{class_name}") + cls = getattr(module, class_name) + instance = cls() + assert instance is not None + + +def _mini_project(tmp_path: Path) -> Path: + (tmp_path / "main.py").write_text("VALUE = 1\n", encoding="utf-8") + (tmp_path / "app_module.py").write_text("NAME = 'demo'\n", encoding="utf-8") + (tmp_path / "requirements.txt").write_text("nitrostack\n", encoding="utf-8") + (tmp_path / ".env.example").write_text("PORT=8000\n", encoding="utf-8") + (tmp_path / ".env").write_text("SECRET=should-never-pack\n", encoding="utf-8") + (tmp_path / "notes.txt").write_text("keep me\n", encoding="utf-8") + return tmp_path + + +def test_pack_dry_run_lists_files_without_writing(tmp_path: Path): + project = _mini_project(tmp_path) + result = pack_project(str(project), dry_run=True) + assert result["dry_run"] is True + files = result["files"] + assert "main.py" in files + assert "app_module.py" in files + assert "requirements.txt" in files + assert ".env.example" in files + assert ".env" not in files + assert not list(project.rglob("*.whl")) + assert not (project / "dist").exists() + + +def test_pack_creates_valid_wheel(tmp_path: Path): + project = _mini_project(tmp_path) + result = pack_project(str(project), dry_run=False) + wheel = Path(result["wheel"]) + assert wheel.is_file() + assert wheel.suffix == ".whl" + assert zipfile.is_zipfile(wheel) + assert _is_valid_wheel(str(wheel)) + + with zipfile.ZipFile(wheel) as zf: + names = zf.namelist() + assert any(n.endswith(".dist-info/WHEEL") for n in names) + assert any(n.endswith(".dist-info/METADATA") for n in names) + assert any(n.endswith(".dist-info/RECORD") for n in names) + wheel_entry = next(n for n in names if n.endswith(".dist-info/WHEEL")) + wheel_body = zf.read(wheel_entry).decode("utf-8") + assert "Wheel-Version:" in wheel_body + assert any(n == ".env.example" or n.endswith("/.env.example") for n in names) + assert not any(n == ".env" or n.endswith("/.env") for n in names) + assert "SECRET=should-never-pack" not in "\n".join( + zf.read(n).decode("utf-8", errors="ignore") for n in names if not n.endswith("/") + ) + + +def test_upgrade_dry_run_does_not_modify_pyproject(tmp_path: Path): + original = ( + "[project]\n" + 'name = "demo"\n' + 'version = "0.1.0"\n' + "dependencies = [\n" + ' "nitrostack>=0.1.0",\n' + "]\n" + ) + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text(original, encoding="utf-8") + with patch("nitrostack.cli.upgrade.fetch_latest_nitrostack_version", return_value="9.9.9"): + result = upgrade_project(str(tmp_path), dry_run=True, verify=False) + assert result["dry_run"] is True + assert result["version"] == "9.9.9" + assert pyproject.read_text(encoding="utf-8") == original + assert any(change["to"] == "nitrostack>=9.9.9" for change in result["changes"]) + + +def test_validate_catches_broken_imports_and_module_refs(tmp_path: Path): + (tmp_path / "pyproject.toml").write_text( + "[project]\n" + 'name = "broken-demo"\n' + 'version = "0.1.0"\n' + "dependencies = [\n" + ' "nitrostack==1.0.0",\n' + "]\n", + encoding="utf-8", + ) + (tmp_path / "requirements.txt").write_text("nitrostack==2.0.0\n", encoding="utf-8") + (tmp_path / "broken_app.py").write_text( + "from nitrostack import mcp_app, module, ServerConfig\n" + "from definitely_missing_nitrostack_pkg import Missing\n\n" + "@module(name='root')\n" + "class RootModule:\n" + " pass\n\n" + "@mcp_app(module=RootModule, server=ServerConfig(name='broken'))\n" + "class App:\n" + " pass\n", + encoding="utf-8", + ) + (tmp_path / "bad_module.py").write_text( + "from nitrostack import module\n\n" + "@module(name='bad', imports=['CalculatorModule'], exports=['Nope'], controllers=[123])\n" + "class BadModule:\n" + " pass\n", + encoding="utf-8", + ) + + issues = validate_project(str(tmp_path)) + messages = "\n".join(issue.format() for issue in issues) + assert any(issue.severity == "error" for issue in issues) + assert "conflicting version" in messages.lower() or "Conflicting version" in messages + assert "definitely_missing_nitrostack_pkg" in messages + assert "not a class" in messages.lower() or "not a class" in messages + assert "→" in messages or "Fix the import" in messages + + +def test_generate_guard_via_cli(tmp_path: Path): + code, output = _invoke_cli(["generate", "guard", "TestGuard"], tmp_path) + assert code == 0 + assert (tmp_path / "guards" / "test_guard.py").is_file() + assert "Generated guard" in output