Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.12
11 changes: 11 additions & 0 deletions MANIFEST.in
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
recursive-include nitrostack/templates *
recursive-include nitrostack/cli/templates *
include nitrostack/templates/starter/.python-version
include nitrostack/templates/starter/.gitignore
include nitrostack/templates/starter/uv.toml
include nitrostack/templates/pizzaz/.python-version
include nitrostack/templates/pizzaz/.gitignore
include nitrostack/templates/pizzaz/uv.toml
include nitrostack/templates/flight-booking/.python-version
include nitrostack/templates/flight-booking/.gitignore
include nitrostack/templates/flight-booking/uv.toml
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,15 @@ nitrostack-py install --production # skip optional extras and requirements-de
nitrostack-py validate # lint deps, @mcp_app imports, and @module() refs
```

`init` writes `pyproject.toml`, `.python-version`, and `uv.toml`, then runs `uv lock` when `uv` is on PATH. `install` prefers `uv sync` in that case. If `requirements.txt` pins nitrostack as a local path (`-e /path/to/nitrostack-python-sdk`), install uses `pip install -r requirements.txt` instead so unpublished SDK testing still works. The uv equivalent is:

```toml
[tool.uv.sources]
nitrostack = { path = "/path/to/nitrostack-python-sdk", editable = true }
```

then `uv lock` / `uv sync`. Without `uv`, install falls back to `.venv` + `pip`.

`upgrade` updates the `nitrostack` dependency spec in `pyproject.toml` in place (and `requirements.txt` when it already pins nitrostack). `--version X` writes `nitrostack==X`. Without `--version`, the latest PyPI release is written as `nitrostack>=latest`. A target older than the currently declared version is rejected unless `--allow-downgrade` is passed. `validate` reports missing/conflicting dependencies, `@mcp_app` modules that fail to import, and `@module()` `imports`/`exports` that are not real classes.

---
Expand Down
117 changes: 116 additions & 1 deletion nitrostack/cli/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,78 @@

import os
import re
import shutil
import subprocess
import sys
from typing import List, Optional

from nitrostack.cli._shared import write_text_atomic

_TRUE_TOKENS = frozenset({"1", "true", "yes", "on"})
_PEP503_KEEP = re.compile(r"[^A-Za-z0-9._-]+")
_PEP503_DASH = re.compile(r"[-_.]+")


def _read(path: str) -> str:
with open(path, "r", encoding="utf-8") as handle:
return handle.read()


def pep503_name(raw: str) -> str:
"""Normalize a folder or display name to a PEP 503 distribution name."""
value = _PEP503_KEEP.sub("-", (raw or "").strip())
value = value.strip("-._").lower()
value = _PEP503_DASH.sub("-", value)
return value or "nitrostack-app"


def rewrite_pyproject_identity(
path: str,
*,
name: str,
description: Optional[str] = None,
) -> None:
"""Set ``[project] name`` (and optional description) in a template pyproject."""
if not os.path.isfile(path):
return
text = _read(path)
dist = pep503_name(name)
text, _ = re.subn(r'(?m)^(name\s*=\s*")[^"]*(")', rf"\g<1>{dist}\2", text, count=1)
if description is not None:
escaped = description.replace("\\", "\\\\").replace('"', '\\"')
text, _ = re.subn(
r'(?m)^(description\s*=\s*")[^"]*(")',
rf"\g<1>{escaped}\2",
text,
count=1,
)
write_text_atomic(path, text)


def requirements_uses_local_nitrostack(path: str) -> bool:
"""True when requirements.txt pins nitrostack (or the SDK) as an editable/path."""
if not os.path.isfile(path):
return False
for raw in _read(path).splitlines():
line = raw.strip()
if not line or line.startswith("#"):
continue
lower = line.lower()
if lower.startswith("-e ") or lower.startswith("--editable"):
return True
if lower.startswith("file:") or " @ file:" in lower:
return True
if lower.startswith("./") or lower.startswith("../") or os.path.isabs(line.split()[0]):
return True
if "nitrostack" in lower and ("/" in line or "\\" in line):
return True
return False


def _uv_bin() -> Optional[str]:
return shutil.which("uv")


def _optional_extra_names(pyproject_text: str) -> List[str]:
"""Return optional-dependency extra names (e.g. dev, test)."""
match = re.search(
Expand Down Expand Up @@ -65,6 +127,44 @@ def _run_pip(args: List[str], cwd: str) -> None:
)


def _run_uv(args: List[str], cwd: str) -> None:
uv = _uv_bin()
if not uv:
raise RuntimeError("uv is not installed")
cmd = [uv, *args]
print(f"Running: {' '.join(cmd)}")
result = subprocess.run(cmd, cwd=cwd)
if result.returncode != 0:
raise RuntimeError(
f"`uv {' '.join(args)}` failed with exit code {result.returncode}.\n"
"Fix the reported dependency error and retry `nitrostack-py install`."
)


def _skip_uv_lock() -> bool:
return os.environ.get("NITROSTACK_SKIP_UV_LOCK", "").strip().lower() in _TRUE_TOKENS


def lock_with_uv(cwd: str) -> bool:
"""Run ``uv lock`` when uv is available. Missing uv is a skip, not a failure."""
if _skip_uv_lock():
return False
root = os.path.abspath(cwd)
if not os.path.isfile(os.path.join(root, "pyproject.toml")):
return False
if not _uv_bin():
print("uv not found; skip lock. Run `uv lock` in this directory.")
return False
try:
_run_uv(["lock"], cwd=root)
except RuntimeError as exc:
print(f"Warning: {exc}")
print("Run `uv lock` in the project directory.")
return False
print("Wrote uv.lock")
return True


def install_dependencies(
*,
production: bool = False,
Expand All @@ -86,9 +186,24 @@ def install_dependencies(
)

print("NITROSTACK — Install" + (" (production)" if production else ""))
local_pin = requirements_uses_local_nitrostack(requirements)
use_uv = bool(_uv_bin() and os.path.isfile(pyproject) and not local_pin)
print(f"Target: {venv_dir(root)}")
if use_uv:
args = ["sync"]
if production:
args.append("--no-dev")
_run_uv(args, cwd=root)
print("Installed with uv sync"
+ (" (skipped optional/dev extras)" if production else ""))
if production:
print("Skipping development dependency files (--production).")
return

if os.path.isfile(pyproject):
if local_pin and os.path.isfile(requirements):
_run_pip(["-r", requirements], cwd=root)
print("Installed requirements.txt")
elif os.path.isfile(pyproject):
extras: List[str] = []
if not production:
extras = _optional_extra_names(_read(pyproject))
Expand Down
8 changes: 7 additions & 1 deletion nitrostack/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from pathlib import Path

from nitrostack.cli.generate import generate_component, generate_module as generate_module_from_template
from nitrostack.cli.install import install_dependencies
from nitrostack.cli.install import install_dependencies, lock_with_uv, rewrite_pyproject_identity
from nitrostack.cli.pack import pack_project
from nitrostack.cli.skills import run_skills_flow
from nitrostack.cli.upgrade import upgrade_project
Expand Down Expand Up @@ -1294,6 +1294,12 @@ def init_project(name: str = None, template: str = None, skip_install: bool = Fa
sys.exit(1)

shutil.copytree(template_src_dir, name)
rewrite_pyproject_identity(
os.path.join(name, "pyproject.toml"),
name=os.path.basename(os.path.abspath(name)),
description=description,
)
lock_with_uv(name)
widget_routes = ensure_python_widgets(name)
print("\n\033[32m✓\033[0m Project created")
if widget_routes:
Expand Down
7 changes: 7 additions & 0 deletions nitrostack/templates/flight-booking/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
.venv/
venv/
__pycache__/
*.py[cod]
.env
!.env.example
.DS_Store
1 change: 1 addition & 0 deletions nitrostack/templates/flight-booking/.python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.12
17 changes: 17 additions & 0 deletions nitrostack/templates/flight-booking/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
[build-system]
requires = ["setuptools>=61.0.0"]
build-backend = "setuptools.build_meta"

[project]
name = "nitrostack-flight-booking"
version = "0.1.0"
description = "NitroStack flight-booking MCP server"
requires-python = ">=3.10"
dependencies = [
"nitrostack",
]

[tool.setuptools.packages.find]
where = ["."]
namespaces = true
exclude = ["tests*", ".venv*", "venv*", "node_modules*", "widgets*"]
1 change: 1 addition & 0 deletions nitrostack/templates/flight-booking/uv.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
python-preference = "managed"
7 changes: 7 additions & 0 deletions nitrostack/templates/pizzaz/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
.venv/
venv/
__pycache__/
*.py[cod]
.env
!.env.example
.DS_Store
1 change: 1 addition & 0 deletions nitrostack/templates/pizzaz/.python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.12
17 changes: 17 additions & 0 deletions nitrostack/templates/pizzaz/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
[build-system]
requires = ["setuptools>=61.0.0"]
build-backend = "setuptools.build_meta"

[project]
name = "nitrostack-pizzaz"
version = "0.1.0"
description = "NitroStack pizzaz MCP server"
requires-python = ">=3.10"
dependencies = [
"nitrostack",
]

[tool.setuptools.packages.find]
where = ["."]
namespaces = true
exclude = ["tests*", ".venv*", "venv*", "node_modules*", "widgets*"]
1 change: 1 addition & 0 deletions nitrostack/templates/pizzaz/uv.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
python-preference = "managed"
7 changes: 7 additions & 0 deletions nitrostack/templates/starter/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
.venv/
venv/
__pycache__/
*.py[cod]
.env
!.env.example
.DS_Store
1 change: 1 addition & 0 deletions nitrostack/templates/starter/.python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.12
17 changes: 17 additions & 0 deletions nitrostack/templates/starter/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
[build-system]
requires = ["setuptools>=61.0.0"]
build-backend = "setuptools.build_meta"

[project]
name = "nitrostack-starter"
version = "0.1.0"
description = "NitroStack starter MCP server"
requires-python = ">=3.10"
dependencies = [
"nitrostack",
]

[tool.setuptools.packages.find]
where = ["."]
namespaces = true
exclude = ["tests*", ".venv*", "venv*", "node_modules*", "widgets*"]
1 change: 1 addition & 0 deletions nitrostack/templates/starter/uv.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
python-preference = "managed"
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,11 @@ nitrostack-py = "nitrostack.cli.main:main"
[tool.setuptools.packages.find]
include = ["nitrostack*"]

[tool.setuptools]
include-package-data = true

[tool.setuptools.package-data]
nitrostack = ["templates/**/*", "cli/templates/*"]
nitrostack = ["templates/**/*", "templates/**/.*", "cli/templates/*"]

[tool.ruff]
line-length = 100
Expand Down
6 changes: 6 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@
from nitrostack.cli.skills import SkillsCloneError


@pytest.fixture(autouse=True)
def skip_uv_lock_in_tests(monkeypatch):
"""Init must not hit PyPI via ``uv lock`` during the suite."""
monkeypatch.setenv("NITROSTACK_SKIP_UV_LOCK", "1")


@pytest.fixture(autouse=True)
def disable_live_skills_clone(monkeypatch):
"""Keep CLI tests off GitHub; test_cli_skills.py overrides this fixture."""
Expand Down
Loading
Loading