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")