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
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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

Expand All @@ -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.

---

Expand Down
6 changes: 3 additions & 3 deletions nitrostack/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
155 changes: 149 additions & 6 deletions nitrostack/cli/upgrade.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import hashlib
import json
import os
import re
Expand All @@ -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'(?<![\w./-])(["\']?)(nitrostack)(?![\w.-])((?:\s*(?:===|==|!=|~=|>=|<=|>|<)\s*[^"\'\s,#]+)?)(\1)',
re.IGNORECASE,
)

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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,
*,
Expand All @@ -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")
Expand All @@ -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)
Expand All @@ -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))

Expand All @@ -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)"
Expand Down
Loading
Loading