From f29cff07aadb15d0ab47e6f4e5aa31e64bb6259f Mon Sep 17 00:00:00 2001 From: manish-wekan Date: Fri, 16 Oct 2026 06:55:28 +0530 Subject: [PATCH] Fix upgrade on vendored projects; correct init's NitroStudio note `nitrostack-py upgrade` failed on every project `init` creates: the dependency pattern also matched `nitrostack = { path = "vendor/nitrostack" }` in [tool.uv.sources] and produced an invalid pyproject.toml. Spec rewrites are now limited to [project].dependencies, and the pattern skips paths ending in /nitrostack. Fixing that alone isn't enough: a vendored project runs vendor/nitrostack whatever spec it declares, and uv accepts a spec the vendored copy doesn't satisfy. For vendored projects, upgrade now replaces vendor/nitrostack with the target release (copied from the running CLI when it is that release, otherwise the PyPI wheel, checked against its sha256). The new tree is built beside the old one and swapped in, so a failed download leaves the project as it was. init's next steps said NitroStudio can't open Python projects; it can. The pizzaz open-shops hint is printed only for the pizzaz template. Co-Authored-By: Claude Opus 5.5 --- README.md | 8 +- nitrostack/cli/main.py | 6 +- nitrostack/cli/upgrade.py | 155 ++++++++++++++++++- nitrostack/cli/vendor.py | 130 ++++++++++++++-- tests/test_cli_upgrade_vendored.py | 234 +++++++++++++++++++++++++++++ 5 files changed, 508 insertions(+), 25 deletions(-) create mode 100644 tests/test_cli_upgrade_vendored.py diff --git a/README.md b/README.md index daca9bf..ff05e8d 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ nitrostack-py dev --port 4000 --widget 4001 nitrostack-py start --port 4000 --widget 4001 ``` -`init` and `pack` vendor this SDK into `vendor/nitrostack` and pin it with `[tool.uv.sources]`. The dependency name stays `nitrostack` in `pyproject.toml` and `requirements.txt`, so `nitrostack-py install` and NitroCloud still run `uv sync`. Do not put a `file:` URL in `[project].dependencies`; that breaks the cloud wheel metadata. The project runs that vendored copy, whatever the version spec says. +`init` and `pack` vendor this SDK into `vendor/nitrostack` and pin it with `[tool.uv.sources]`. The dependency name stays `nitrostack` in `pyproject.toml` and `requirements.txt`, so `nitrostack-py install` and NitroCloud still run `uv sync`. Do not put a `file:` URL in `[project].dependencies`; that breaks the cloud wheel metadata. The project runs that vendored copy, whatever the version spec says; `nitrostack-py upgrade` replaces it with another release. Once scaffolded, follow the next steps the CLI prints. @@ -498,8 +498,8 @@ nitrostack-py pack # write dist/*.whl ### Upgrade, install, validate ```bash -nitrostack-py upgrade # latest nitrostack on PyPI (writes nitrostack>=latest) -nitrostack-py upgrade --version 0.4.1 # pin exactly this version (writes nitrostack==0.4.1) +nitrostack-py upgrade # move the project to the latest nitrostack on PyPI +nitrostack-py upgrade --version 0.4.1 # move it to exactly this version nitrostack-py upgrade --dry-run # print the change; do not edit files nitrostack-py upgrade --allow-downgrade --version 0.1.0 # required to pin an older version @@ -518,7 +518,7 @@ 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. +`upgrade` in a project with `vendor/nitrostack` (every project `init` creates) replaces the vendored copy with the target release: the wheel is downloaded from PyPI and checked against its published sha256, or copied from the running CLI when it is that release. The dependency stays the plain name `nitrostack`. In other projects, `upgrade` updates the `nitrostack` spec in `pyproject.toml`'s `dependencies` (and `requirements.txt` when it already pins nitrostack): `--version X` writes `nitrostack==X`, and without `--version` the latest release is written as `nitrostack>=latest`. Either way, run `nitrostack-py install` afterwards, and a target older than the current 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. --- diff --git a/nitrostack/cli/main.py b/nitrostack/cli/main.py index 1ab03b3..6e5553c 100644 --- a/nitrostack/cli/main.py +++ b/nitrostack/cli/main.py @@ -1409,9 +1409,9 @@ def init_project(name: str = None, template: str = None, skip_install: bool = Fa print(" 5. Inspector (HTTP): \033[34mMCP_TRANSPORT_TYPE=http MCP_STATELESS=true NITROSTACK_APP_MODE=universal python main.py\033[0m") print(" Connect Streamable HTTP to \033[34mhttp://localhost:3000/mcp\033[0m with \033[34mAuthentication = Off\033[0m") print(" Apps tab renders the widget from live \033[34mstructuredContent\033[0m (not widgets/preview.html)") - print(" For open shops only set \033[34mopenNow=true\033[0m (list) or \033[34mfilter=open_now\033[0m (map)") - print(" 6. NitroStudio cannot folder-connect a Python project (it looks for @nitrostack/core).") - print(" Use Inspector HTTP as above, or Studio's custom MCP URL if it offers Streamable HTTP.") + if template == "python-pizzaz": + print(" For open shops only set \033[34mopenNow=true\033[0m (list) or \033[34mfilter=open_now\033[0m (map)") + print(" 6. NitroStudio: open this folder as a project; Studio starts and connects it.") print("\nHappy coding! 🚀\n") def run_dev(port=None, widget=None): diff --git a/nitrostack/cli/upgrade.py b/nitrostack/cli/upgrade.py index 12a312a..64e600a 100644 --- a/nitrostack/cli/upgrade.py +++ b/nitrostack/cli/upgrade.py @@ -2,6 +2,7 @@ from __future__ import annotations +import hashlib import json import os import re @@ -19,13 +20,20 @@ write_text_atomic, ) from nitrostack.cli.skills import upgrade_agent_skills +from nitrostack.cli.vendor import ( + VENDOR_REL, + _distribution_version, + replace_vendored_nitrostack, + vendored_version, +) PYPI_JSON = "https://pypi.org/pypi/nitrostack/json" PYPI_VERSION_JSON = "https://pypi.org/pypi/nitrostack/{version}/json" -# Name must not be a prefix of nitrostack-studio / nitrostack_extras / nitrostack.contrib. +# Name must not be a prefix of nitrostack-studio / nitrostack_extras / nitrostack.contrib, +# nor the tail of a path such as ./vendor/nitrostack. _DEP_RE = re.compile( - r'(["\']?)(nitrostack)(?![\w.-])((?:\s*(?:===|==|!=|~=|>=|<=|>|<)\s*[^"\'\s,#]+)?)(\1)', + r'(?=|<=|>|<)\s*[^"\'\s,#]+)?)(\1)', re.IGNORECASE, ) @@ -75,6 +83,63 @@ def verify_nitrostack_version(version: str, timeout: float = 15.0) -> None: raise UpgradeError(f"Could not reach PyPI to verify nitrostack=={version}: {exc}") from exc +def fetch_nitrostack_wheel(version: str, timeout: float = 60.0) -> bytes: + """Download the nitrostack wheel for ``version`` from PyPI and check its sha256.""" + 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: + payload = json.loads(resp.read().decode("utf-8")) + wheels = [ + f for f in payload.get("urls") or [] + if f.get("packagetype") == "bdist_wheel" + and str(f.get("filename", "")).endswith("-none-any.whl") + ] + if not wheels: + raise UpgradeError(f"PyPI has no wheel for nitrostack=={version}.") + wheel = wheels[0] + expected = (wheel.get("digests") or {}).get("sha256") + if not expected: + raise UpgradeError( + f"PyPI lists no sha256 for {wheel.get('filename')}; not installing it." + ) + download = urllib.request.Request(wheel["url"], headers={"User-Agent": "nitrostack-py"}) + with urllib.request.urlopen(download, timeout=timeout) as resp: + data = resp.read() + except urllib.error.HTTPError as exc: + if exc.code == 404: + raise UpgradeError(f"nitrostack=={version} was not found on PyPI.") from exc + raise UpgradeError(f"Downloading nitrostack=={version} failed: {exc}") from exc + except urllib.error.URLError as exc: + raise UpgradeError( + f"Could not reach PyPI to download nitrostack=={version}: {exc}" + ) from exc + except json.JSONDecodeError as exc: + raise UpgradeError(f"PyPI returned invalid JSON: {exc}") from exc + if hashlib.sha256(data).hexdigest() != expected: + raise UpgradeError(f"{wheel.get('filename')} failed its sha256 check; not installing it.") + return data + + +def _pyproject_dependencies(text: str) -> Tuple[int, int]: + """Span of `[project].dependencies`, or (0, 0). Specs elsewhere (e.g. a + `[tool.uv.sources]` entry named nitrostack) must not be rewritten.""" + return find_toml_array_inner(text, "dependencies") or (0, 0) + + +def _find_pyproject_spec(text: str) -> Optional[str]: + start, end = _pyproject_dependencies(text) + return find_current_spec(text[start:end]) + + +def _replace_pyproject_spec(text: str, spec: str) -> Tuple[str, int]: + start, end = _pyproject_dependencies(text) + inner, count = replace_nitrostack_spec(text[start:end], spec) + return text[:start] + inner + text[end:], count + + def find_current_spec(text: str) -> Optional[str]: match = _DEP_RE.search(text) if not match: @@ -145,6 +210,68 @@ def _commit_file_changes(changes: list) -> list: raise +def _upgrade_vendored( + root: str, + current: str, + *, + version: Optional[str], + dry_run: bool, + verify: bool, + allow_downgrade: bool, +) -> dict: + """Replace `vendor/nitrostack` with the target release. + + `init` vendors the SDK and points uv at it with `[tool.uv.sources]`, so the + project runs that copy whatever version spec it declares. The dependency + stays the plain name `nitrostack` (see `vendor.py`). + """ + target = version or fetch_latest_nitrostack_version() + if version and verify: + verify_nitrostack_version(target) + try: + target_ver = Version(target) + except InvalidVersion as exc: + raise UpgradeError(f"Invalid target version '{target}': {exc}") from exc + try: + current_ver: Optional[Version] = Version(current) + except InvalidVersion: + current_ver = None + if not allow_downgrade and current_ver is not None and target_ver < current_ver: + raise UpgradeError( + f"Target {target} is older than the vendored nitrostack {current}. " + "Re-run with --allow-downgrade if you intend to downgrade." + ) + + vendor_dir = VENDOR_REL.replace(os.sep, "/") + change = {"file": vendor_dir, "from": current, "to": target} + up_to_date = current_ver == target_ver + print("NITROSTACK — Upgrade" + (" (dry run)" if dry_run else "")) + print(f"Target version: {target}") + print(f" {vendor_dir}: {current} → {target}" + (" (already installed)" if up_to_date else "")) + + written: list = [] + if dry_run: + print("\nDry run — no files modified.") + elif not up_to_date: + # The running CLI is the target release: copy it rather than download it. + wheel = None if _distribution_version() == target else fetch_nitrostack_wheel(target) + replace_vendored_nitrostack(root, wheel) + written = [vendor_dir] + print(f"Updated {vendor_dir}") + print(f"\nUpgrade complete. The project now runs nitrostack {target}.") + print("Run `nitrostack-py install` to install it.") + + skills = upgrade_agent_skills(root, dry_run=dry_run) + return { + "version": target, + "spec": "nitrostack", + "changes": [] if up_to_date else [change], + "dry_run": dry_run, + "written": written, + "skills": skills, + } + + def upgrade_project( root: Optional[str] = None, *, @@ -153,7 +280,12 @@ def upgrade_project( verify: bool = True, allow_downgrade: bool = False, ) -> dict: - """Update the nitrostack dependency spec in pyproject.toml (and requirements.txt if present).""" + """Move the project to another nitrostack release. + + Vendored projects (`vendor/nitrostack`, as `init` creates) get the vendored copy + replaced. Others get the dependency spec in pyproject.toml (and requirements.txt + if present) rewritten. + """ root = os.path.abspath(root or os.getcwd()) pyproject = os.path.join(root, "pyproject.toml") requirements = os.path.join(root, "requirements.txt") @@ -164,6 +296,17 @@ def upgrade_project( "Run this command from a NitroStack project, or create pyproject.toml first." ) + vendored = vendored_version(root) + if vendored is not None: + return _upgrade_vendored( + root, + vendored, + version=version, + dry_run=dry_run, + verify=verify, + allow_downgrade=allow_downgrade, + ) + target = version or fetch_latest_nitrostack_version() if version and verify: verify_nitrostack_version(target) @@ -175,7 +318,7 @@ def upgrade_project( current_specs = [] if original_pyproject is not None: - current_specs.append(find_current_spec(original_pyproject)) + current_specs.append(_find_pyproject_spec(original_pyproject)) if original_reqs is not None: current_specs.append(find_current_spec(original_reqs)) @@ -194,8 +337,8 @@ def upgrade_project( ) if original_pyproject is not None: - current = find_current_spec(original_pyproject) - updated, n = replace_nitrostack_spec(original_pyproject, new_spec) + current = _find_pyproject_spec(original_pyproject) + updated, n = _replace_pyproject_spec(original_pyproject, new_spec) if n == 0: updated = _add_to_pyproject_dependencies(original_pyproject, new_spec) current = current or "(missing)" diff --git a/nitrostack/cli/vendor.py b/nitrostack/cli/vendor.py index af35dd7..08abbea 100644 --- a/nitrostack/cli/vendor.py +++ b/nitrostack/cli/vendor.py @@ -8,13 +8,18 @@ """ from __future__ import annotations +import email.parser +import io +import json import os import re import shutil +import tempfile +import zipfile from pathlib import Path -from typing import Optional +from typing import List, Optional -from nitrostack.cli._shared import write_text_atomic +from nitrostack.cli._shared import loads_toml, write_text_atomic VENDOR_REL = os.path.join("vendor", "nitrostack") _PLAIN_REQUIREMENT = "nitrostack" @@ -87,8 +92,25 @@ def _distribution_version() -> str: ) -def _write_minimal_pyproject(dest: Path) -> None: - version = _distribution_version() +_DEFAULT_DEPENDENCIES = ( + "mcp>=2,<3", + "pydantic>=2.0.0", + "starlette>=0.30.0", + "uvicorn>=0.20.0", + "watchfiles>=0.18.0", + "anyio>=4.0.0", + "packaging>=23.0.0", + "tomli>=2.0.0; python_version < '3.11'", +) + + +def _write_minimal_pyproject( + dest: Path, + version: Optional[str] = None, + dependencies: Optional[List[str]] = None, +) -> None: + version = version or _distribution_version() + deps = "".join(f" {json.dumps(dep)},\n" for dep in (dependencies or _DEFAULT_DEPENDENCIES)) dest.mkdir(parents=True, exist_ok=True) (dest / "pyproject.toml").write_text( "[build-system]\n" @@ -100,14 +122,7 @@ def _write_minimal_pyproject(dest: Path) -> None: 'description = "NitroStack Python SDK (vendored)"\n' 'requires-python = ">=3.10"\n' "dependencies = [\n" - ' "mcp>=2,<3",\n' - ' "pydantic>=2.0.0",\n' - ' "starlette>=0.30.0",\n' - ' "uvicorn>=0.20.0",\n' - ' "watchfiles>=0.18.0",\n' - ' "anyio>=4.0.0",\n' - ' "packaging>=23.0.0",\n' - ' "tomli>=2.0.0; python_version < \'3.11\'",\n' + f"{deps}" "]\n\n" "[project.scripts]\n" 'nitrostack-py = "nitrostack.cli.main:main"\n\n' @@ -142,6 +157,10 @@ def copy_running_nitrostack(dest: Path) -> None: else: _write_minimal_pyproject(dest) + _check_vendored_tree(dest) + + +def _check_vendored_tree(dest: Path) -> None: assets = dest / "nitrostack" / "transports" / "assets" / "landing.html" if not assets.is_file(): raise RuntimeError( @@ -150,6 +169,93 @@ def copy_running_nitrostack(dest: Path) -> None: ) +def extract_nitrostack_wheel(wheel: bytes, dest: Path) -> str: + """Unpack a nitrostack wheel into ``dest`` as a vendored tree; return its version. + + Skips the same directories as `copy_running_nitrostack` (templates, caches). + """ + with zipfile.ZipFile(io.BytesIO(wheel)) as archive: + metadata_name = next( + ( + n for n in archive.namelist() + if re.fullmatch(r"nitrostack-[^/]+\.dist-info/METADATA", n) + ), + None, + ) + if metadata_name is None: + raise RuntimeError("The downloaded file is not a nitrostack wheel (no METADATA).") + metadata = email.parser.Parser().parsestr(archive.read(metadata_name).decode("utf-8")) + version = metadata.get("Version") + if not version: + raise RuntimeError("The nitrostack wheel has no version in its METADATA.") + requires = [ + req for req in metadata.get_all("Requires-Dist") or [] + if not re.search(r"\bextra\s*==", req) + ] + + root = dest.resolve() + for name in archive.namelist(): + parts = name.split("/") + if parts[0] != "nitrostack" or name.endswith("/"): + continue + if any(part in _SKIP_DIR_NAMES or part.endswith(".egg-info") for part in parts[:-1]): + continue + target = (dest / name).resolve() + if root not in target.parents: + raise RuntimeError(f"Refusing to extract {name!r} outside {dest}.") + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(archive.read(name)) + + _write_minimal_pyproject(dest, version=version, dependencies=requires) + _check_vendored_tree(dest) + return version + + +def vendored_version(project: str) -> Optional[str]: + """Version of the SDK in ``project/vendor/nitrostack``, or None if not vendored.""" + pyproject = Path(project) / VENDOR_REL / "pyproject.toml" + if not pyproject.is_file(): + return None + try: + data = loads_toml(pyproject.read_text(encoding="utf-8")) + version = data.get("project", {}).get("version") + except Exception: + return None + return str(version) if version else None + + +def replace_vendored_nitrostack(project: str, wheel: Optional[bytes] = None) -> str: + """Swap ``vendor/nitrostack`` for the running SDK, or for ``wheel`` when given. + + The new tree is built next to the old one and swapped in afterwards, so a + failed download or extraction leaves the project's current SDK in place. + Returns the vendored version. + """ + dest = Path(os.path.abspath(project)) / VENDOR_REL + dest.parent.mkdir(parents=True, exist_ok=True) + staging = Path(tempfile.mkdtemp(prefix=".nitrostack-new-", dir=dest.parent)) + backup = dest.parent / f".nitrostack-old-{os.getpid()}" + try: + if wheel is None: + copy_running_nitrostack(staging) + version = _distribution_version() + else: + version = extract_nitrostack_wheel(wheel, staging) + if dest.exists(): + os.replace(dest, backup) + try: + os.replace(staging, dest) + except OSError: + if backup.exists(): + os.replace(backup, dest) + raise + finally: + shutil.rmtree(staging, ignore_errors=True) + shutil.rmtree(backup, ignore_errors=True) + pin_project_to_vendor(Path(os.path.abspath(project))) + return version + + def _pin_text(text: str) -> str: """Keep `[project].dependencies` as a plain name (valid wheel Requires-Dist).""" return _QUOTED_NITROSTACK.sub(lambda match: f"{match.group(1)}nitrostack{match.group(1)}", text) diff --git a/tests/test_cli_upgrade_vendored.py b/tests/test_cli_upgrade_vendored.py new file mode 100644 index 0000000..8cc09c8 --- /dev/null +++ b/tests/test_cli_upgrade_vendored.py @@ -0,0 +1,234 @@ +"""`nitrostack-py upgrade` on projects shaped like `init` creates them. + +`init` vendors the SDK into vendor/nitrostack and points uv at it with a +`[tool.uv.sources]` entry, so upgrade must replace that copy and must not +touch the source entry. +""" + +from __future__ import annotations + +import hashlib +import io +import json +import zipfile +from pathlib import Path +from unittest.mock import patch + +import pytest + +from nitrostack.cli import vendor +from nitrostack.cli._shared import loads_toml +from nitrostack.cli.upgrade import ( + UpgradeError, + fetch_nitrostack_wheel, + replace_nitrostack_spec, + upgrade_project, +) +from nitrostack.cli.vendor import ( + ensure_vendored_nitrostack, + extract_nitrostack_wheel, + vendored_version, +) + +INIT_PYPROJECT = ( + "[project]\n" + 'name = "demo"\n' + 'version = "0.1.0"\n' + 'requires-python = ">=3.10"\n' + "dependencies = [\n" + ' "nitrostack",\n' + "]\n\n" + "[tool.uv.sources]\n" + 'nitrostack = { path = "vendor/nitrostack" }\n' +) + + +def _wheel(version: str, extra_files: dict | None = None) -> bytes: + files = { + "nitrostack/__init__.py": f"VERSION = {version!r}\n", + "nitrostack/transports/assets/landing.html": "", + "nitrostack/templates/starter/main.py": "print('template')\n", + f"nitrostack-{version}.dist-info/METADATA": ( + "Metadata-Version: 2.1\n" + "Name: nitrostack\n" + f"Version: {version}\n" + "Requires-Dist: mcp<3,>=2\n" + "Requires-Dist: pydantic>=2.0.0\n" + 'Requires-Dist: tomli>=2.0.0; python_version < "3.11"\n' + 'Requires-Dist: pytest>=7.4.0; extra == "dev"\n' + ), + } + files.update(extra_files or {}) + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + for name, content in files.items(): + archive.writestr(name, content) + return buffer.getvalue() + + +@pytest.fixture +def no_skills(): + with patch( + "nitrostack.cli.upgrade.upgrade_agent_skills", return_value={"skipped": True} + ) as mock: + yield mock + + +@pytest.fixture +def project(tmp_path: Path) -> Path: + (tmp_path / "pyproject.toml").write_text(INIT_PYPROJECT, encoding="utf-8") + (tmp_path / "requirements.txt").write_text("nitrostack\n", encoding="utf-8") + assert ensure_vendored_nitrostack(str(tmp_path), quiet=True) + return tmp_path + + +def test_spec_rewrite_skips_the_uv_source_entry(tmp_path: Path, no_skills): + # Not vendored, but the pyproject still names nitrostack in [tool.uv.sources]. + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text(INIT_PYPROJECT, encoding="utf-8") + with patch("nitrostack.cli.upgrade.verify_nitrostack_version"): + upgrade_project(str(tmp_path), version="1.2.3", verify=False, allow_downgrade=True) + parsed = loads_toml(pyproject.read_text(encoding="utf-8")) + assert parsed["project"]["dependencies"] == ["nitrostack==1.2.3"] + assert parsed["tool"]["uv"]["sources"]["nitrostack"] == {"path": "vendor/nitrostack"} + + +def test_spec_rewrite_skips_vendor_paths(): + text = "-e ./vendor/nitrostack\nnitrostack>=0.3.0\n" + updated, count = replace_nitrostack_spec(text, "nitrostack>=9.9.9") + assert count == 1 + assert updated == "-e ./vendor/nitrostack\nnitrostack>=9.9.9\n" + + +def test_upgrade_replaces_the_vendored_sdk_from_the_wheel(project: Path, no_skills): + pyproject_before = (project / "pyproject.toml").read_text(encoding="utf-8") + with ( + patch("nitrostack.cli.upgrade.fetch_latest_nitrostack_version", return_value="9.9.9"), + patch( + "nitrostack.cli.upgrade.fetch_nitrostack_wheel", return_value=_wheel("9.9.9") + ) as fetch, + ): + result = upgrade_project(str(project)) + + fetch.assert_called_once_with("9.9.9") + assert result["written"] == ["vendor/nitrostack"] + assert result["changes"][0]["to"] == "9.9.9" + assert vendored_version(str(project)) == "9.9.9" + sdk = project / "vendor" / "nitrostack" + assert (sdk / "nitrostack" / "__init__.py").read_text(encoding="utf-8") == "VERSION = '9.9.9'\n" + assert not (sdk / "nitrostack" / "templates").exists() + deps = loads_toml((sdk / "pyproject.toml").read_text(encoding="utf-8"))["project"][ + "dependencies" + ] + assert deps == ["mcp<3,>=2", "pydantic>=2.0.0", 'tomli>=2.0.0; python_version < "3.11"'] + # The project keeps the plain name; the vendored copy decides the version. + assert (project / "pyproject.toml").read_text(encoding="utf-8") == pyproject_before + assert (project / "requirements.txt").read_text(encoding="utf-8") == "nitrostack\n" + assert not [p for p in (project / "vendor").iterdir() if p.name.startswith(".nitrostack-")] + + +def test_upgrade_to_the_running_version_copies_it(project: Path, no_skills): + running = vendor._distribution_version() + (project / "vendor" / "nitrostack" / "pyproject.toml").write_text( + '[project]\nname = "nitrostack"\nversion = "0.0.1"\n', encoding="utf-8" + ) + with ( + patch("nitrostack.cli.upgrade.fetch_latest_nitrostack_version", return_value=running), + patch("nitrostack.cli.upgrade.fetch_nitrostack_wheel") as fetch, + ): + upgrade_project(str(project)) + fetch.assert_not_called() + assert vendored_version(str(project)) == running + + +def test_upgrade_vendored_dry_run_same_version_and_downgrade(project: Path, no_skills): + current = vendored_version(str(project)) + snapshot = sorted(p.relative_to(project) for p in project.rglob("*")) + + with ( + patch("nitrostack.cli.upgrade.fetch_latest_nitrostack_version", return_value="9.9.9"), + patch("nitrostack.cli.upgrade.fetch_nitrostack_wheel") as fetch, + ): + dry = upgrade_project(str(project), dry_run=True) + fetch.assert_not_called() + assert dry["changes"] == [{"file": "vendor/nitrostack", "from": current, "to": "9.9.9"}] + assert dry["written"] == [] + + with ( + patch("nitrostack.cli.upgrade.verify_nitrostack_version"), + patch("nitrostack.cli.upgrade.fetch_nitrostack_wheel") as fetch, + ): + same = upgrade_project(str(project), version=current) + with pytest.raises(UpgradeError, match="allow-downgrade"): + upgrade_project(str(project), version="0.0.1") + fetch.assert_not_called() + assert same["changes"] == [] and same["written"] == [] + assert sorted(p.relative_to(project) for p in project.rglob("*")) == snapshot + + +def test_failed_download_or_bad_wheel_keeps_the_current_sdk(project: Path, no_skills): + current = vendored_version(str(project)) + with patch("nitrostack.cli.upgrade.fetch_latest_nitrostack_version", return_value="9.9.9"): + with patch( + "nitrostack.cli.upgrade.fetch_nitrostack_wheel", side_effect=UpgradeError("offline") + ): + with pytest.raises(UpgradeError, match="offline"): + upgrade_project(str(project)) + with patch("nitrostack.cli.upgrade.fetch_nitrostack_wheel", return_value=b"not a zip"): + with pytest.raises(zipfile.BadZipFile): + upgrade_project(str(project)) + assert vendored_version(str(project)) == current + assert ( + project / "vendor" / "nitrostack" / "nitrostack" / "transports" / "assets" / "landing.html" + ).is_file() + assert not [p for p in (project / "vendor").iterdir() if p.name.startswith(".nitrostack-")] + + +def test_extract_refuses_paths_outside_the_vendor_dir(tmp_path: Path): + evil = _wheel("9.9.9", {"nitrostack/../../escaped.py": "boom"}) + with pytest.raises(RuntimeError, match="outside"): + extract_nitrostack_wheel(evil, tmp_path / "sdk") + assert not (tmp_path / "escaped.py").exists() + + +class _Response(io.BytesIO): + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + +def _pypi(wheel: bytes, digest: str): + payload = { + "urls": [ + { + "packagetype": "sdist", + "filename": "nitrostack-9.9.9.tar.gz", + "url": "https://x/sdist", + "digests": {}, + }, + { + "packagetype": "bdist_wheel", + "filename": "nitrostack-9.9.9-py3-none-any.whl", + "url": "https://x/wheel", + "digests": {"sha256": digest}, + }, + ] + } + + def urlopen(request, timeout=None): + url = request.full_url + return _Response(json.dumps(payload).encode() if url.endswith("/json") else wheel) + + return urlopen + + +def test_fetch_wheel_checks_sha256(): + wheel = _wheel("9.9.9") + good = hashlib.sha256(wheel).hexdigest() + with patch("urllib.request.urlopen", side_effect=_pypi(wheel, good)): + assert fetch_nitrostack_wheel("9.9.9") == wheel + with patch("urllib.request.urlopen", side_effect=_pypi(wheel, "0" * 64)): + with pytest.raises(UpgradeError, match="sha256"): + fetch_nitrostack_wheel("9.9.9")