diff --git a/.github/workflows/bump-version.yml b/.github/workflows/bump-version.yml new file mode 100644 index 0000000..3f7ff55 --- /dev/null +++ b/.github/workflows/bump-version.yml @@ -0,0 +1,85 @@ +name: Bump version + +# After a non-release PR merges to main, open (or refresh) a release PR that +# bumps pyproject.toml + uv.lock ahead of PyPI. Merging that PR runs publish.yml. +on: + pull_request: + types: [closed] + branches: [main] + workflow_dispatch: + inputs: + bump: + description: Semver component to increment + type: choice + options: [patch, minor, major] + default: patch + +concurrency: + group: bump-version + cancel-in-progress: true + +jobs: + bump: + if: > + github.event_name == 'workflow_dispatch' || + (github.event.pull_request.merged == true && + !contains(github.event.pull_request.labels.*.name, 'release')) + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.event_name == 'workflow_dispatch' && 'main' || github.event.pull_request.merge_commit_sha }} + token: ${{ secrets.GITHUB_TOKEN }} + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Plan and write bump + id: bump + run: | + set -euo pipefail + KIND="${{ github.event_name == 'workflow_dispatch' && inputs.bump || 'patch' }}" + VERSION=$(python scripts/bump_version.py --bump "$KIND" --write | tail -n 1) + if [ -z "$VERSION" ]; then + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "skip=false" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Open or update release PR + if: steps.bump.outputs.skip != 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ steps.bump.outputs.version }} + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + BRANCH="release/${VERSION}" + git checkout -B "$BRANCH" + git add pyproject.toml uv.lock + git diff --staged --quiet && { echo "No version files changed"; exit 1; } + git commit -m "chore(release): ${VERSION}" + git push --force-with-lease origin "HEAD:refs/heads/${BRANCH}" + gh label create release --description "Version bump / PyPI release" --color 0E8A16 --force || true + EXISTING=$(gh pr list --base main --head "$BRANCH" --json number --jq '.[0].number // empty') + if [ -n "$EXISTING" ]; then + echo "Updated PR #${EXISTING}" + exit 0 + fi + gh pr create --base main --head "$BRANCH" --title "chore(release): ${VERSION}" --label release --body "$(cat <> "$GITHUB_OUTPUT" + echo "publish=true" >> "$GITHUB_OUTPUT" + else + echo "publish=false" >> "$GITHUB_OUTPUT" + echo "pyproject version is already on PyPI; skipping upload" + fi + + publish: + needs: check + if: needs.check.outputs.publish == 'true' + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/project/nitrostack/ + permissions: + id-token: write + contents: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: python -m pip install -U build && python -m build + - uses: pypa/gh-action-pypi-publish@release/v1 + - name: Tag release + env: + VERSION: ${{ needs.check.outputs.version }} + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + TAG="v${VERSION}" + if git rev-parse "$TAG" >/dev/null 2>&1; then + echo "Tag $TAG already exists" + exit 0 + fi + git tag -a "$TAG" -m "nitrostack ${VERSION}" + git push origin "$TAG" diff --git a/README.md b/README.md index 6074a49..a122b1a 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,28 @@ # NitroStack Python SDK -A Python-idiomatic port of the **NitroStack** Model Context Protocol (MCP) framework, enabling NestJS-like modular architecture, dependency injection, execution pipelines, background task processing, built-in authentication modules, and a diagnostic testing harness. +NitroStack - Build, test, and deploy MCP servers in one place | Product Hunt + +Build Model Context Protocol (MCP) servers in Python with a NestJS-style structure: modules, dependency injection, decorators for tools, resources and prompts, and a pipeline of guards, middleware, interceptors, pipes and exception filters. It's the Python port of the [NitroStack](https://docs.nitrostack.ai/) TypeScript framework. + +- **Docs:** [docs.nitrostack.ai/python](https://docs.nitrostack.ai/python) +- **Source and issues:** [github.com/nitrocloudofficial/nitrostack-python-sdk](https://github.com/nitrocloudofficial/nitrostack-python-sdk) +- **Requires:** Python 3.10+ --- ## Features -- **Nested Modular Architecture**: Group components cleanly with `@module`. -- **Dependency Injection**: Explicit class constructor DI with `DIContainer` and `@injectable(deps=[...])`. -- **Pipeline Interceptors**: Build guards, middleware, interceptors, pipes, and exception filters for tool execution. -- **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 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`). +- **Modules and dependency injection**: group code with `@module`, inject services with `@injectable(deps=[...])`. +- **Tools, resources and prompts**: plain decorated methods, with Pydantic input/output schemas, tool annotations and examples. +- **Execution pipeline**: guards, middleware, interceptors, pipes and exception filters per tool. +- **Auth**: API keys, JWT, and OAuth 2.1 (protected resource metadata, token introspection or JWKS, PKCE, CIMD, scopes). +- **Long-running tasks**: tools that report progress and can be cancelled, over the MCP tasks API. +- **Widgets**: attach an HTML UI to a tool for ChatGPT Apps and MCP Apps clients. +- **Transports**: stdio, Streamable HTTP (plus legacy SSE), or both at once; 2025 and 2026 protocol versions. +- **Caching, rate limits, events and health checks** as decorators. +- **Testing harness**: call tools, resources and prompts in-process, without a transport. +- **CLI (`nitrostack-py`)**: scaffold, generate code, run with hot reload, validate, pack wheels, upgrade. +- **NitroStudio**: open a project folder in [NitroStudio](https://nitrostack.ai/) to run, chat with and debug it. --- @@ -20,41 +30,43 @@ A Python-idiomatic port of the **NitroStack** Model Context Protocol (MCP) frame ```bash pip install nitrostack +# or +uv add nitrostack ``` -To install local developer or test dependencies: +To work on the SDK itself (tests and tooling): + ```bash -pip install -e . +pip install -e ".[dev]" ``` --- ## Scaffolding a New Project (Recommended) -You can quickly scaffold a new project template using the interactive CLI tool: - ```bash -nitrostack-py init +nitrostack-py init my-server --template python-starter ``` *(Or via Python: `python -m nitrostack.cli.main init`)* -The project name is optional on the command line. If omitted, the CLI asks for it next: +Both arguments are optional; without them `init` asks: -```bash -nitrostack-py init my-server --template python-starter -``` +1. **Project name**. Default: `my-mcp-server`. +2. **Template**: -This launches an interactive prompt where you can: -1. **Project name** (if not passed as an argument). Default: `my-mcp-server`. -2. **Choose a template** by explicit name: - - **python-starter**: A simple calculator server. - - **python-pizzaz**: A pizza shop finder with maps and widgets. - - **python-oauth**: A flight booking server demonstrating OAuth 2.1 authentication and guarded routes. -3. **Provide metadata**: Specify a custom description and author name. -4. **Install dependencies**: `Install dependencies: (Y/n)` — Enter or `Y` runs `npm install` in `src/widgets`; `n` skips it. `--skip-install` skips the prompt. + | Template | What it shows | + |---|---| + | `python-starter` | A calculator: tools, a widget, a health check. | + | `python-pizzaz` | A pizza shop finder with map and list widgets. | + | `python-oauth` | Flight booking with OAuth 2.1 and scope-guarded tools (see `OAUTH_SETUP.md` in the project). | -Optional port flags override the defaults (**3000** MCP, **3001** widgets): +3. **Description and author**. +4. **Install dependencies (Y/n)**: installs the Python dependencies (`uv sync` when `uv` is on PATH, otherwise `.venv` + `pip`), then runs `npm install` in `src/widgets` if the template has one. `--skip-install` skips this. + +`init` also installs agent skills for this SDK (from [skills-python-sdk](https://github.com/nitrocloudofficial/skills-python-sdk)) into the project's coding-agent folders. `--force` overwrites existing skill folders. + +Port flags override the defaults (**3000** for MCP, **3001** for widgets): ```bash nitrostack-py init my-server --template python-starter --port 4000 --widget 4001 @@ -62,86 +74,9 @@ 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. - -Once scaffolded, follow the next steps printed by the CLI to run your server, configure environment variables, and try it out. - ---- - -## 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 (writes nitrostack>=latest) -nitrostack-py upgrade --version 0.3.2 # pin exactly this version (writes nitrostack==0.3.2) -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 - -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 -``` - -`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`. +`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. -`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. +Once scaffolded, follow the next steps the CLI prints. --- @@ -231,9 +166,13 @@ if __name__ == "__main__": asyncio.run(main()) ``` +Run it with `python app.py` (stdio), or `MCP_TRANSPORT_TYPE=http python app.py` for Streamable HTTP on `http://127.0.0.1:3000/mcp`. + +Scaffolded projects split the same pieces into files: `main.py` calls `McpApplicationFactory.create(AppModule)`, `app_module.py` holds the root module, and each feature lives in `modules//`. When the server's name is generic (`app` or `mcp-server`, as with a root `@module(name="app")` and no `@mcp_app`), the name and description reported to clients come from `SERVER_NAME` / `SERVER_DESC`. + ### 2. Configure Environment Variables -The SDK reads standard settings from the environment or `.env` files: +The SDK reads settings from the process environment and the project's `.env` file. **The process environment wins:** a variable already set when the server starts (for example `PORT=3001` from NitroStudio or your shell) is not overwritten by `.env`. `.env` fills in what isn't set, and `ConfigModule.for_root(defaults=...)` fills in the rest. (Before 0.4.1, `.env` overwrote the environment.) | Environment Variable | Description | |---|---| @@ -241,6 +180,7 @@ The SDK reads standard settings from the environment or `.env` files: | `TRUSTED_PROXIES` / `MCP_TRUSTED_PROXIES` | Comma-separated IPs, CIDRs, or hostnames allowed to send `X-Forwarded-Host` / `X-Forwarded-Proto`. Unset means those headers are ignored. `X-Forwarded-For` is never used to decide trust. | | `PORT` / `MCP_SERVER_PORT` | The port to bind for HTTP/SSE transport (default: `3000`). Overridden by `nitrostack-py --port`. | | `WIDGETS_DEV_PORT` | Widget Next.js port (default: `3001`). Overridden by `nitrostack-py --widget`. | +| `SERVER_NAME` / `SERVER_DESC` | Server name and description reported to clients when the configured name is generic (`app`, `mcp-server`). `init` sets `SERVER_NAME` to the project folder name. | | `MCP_TRANSPORT_TYPE` | Transport selection: `stdio`, `http`, or `dual` (combining stdio + HTTP/SSE). | | `NODE_ENV` | If set to `production`, defaults to `dual` transport. Otherwise defaults to `stdio`. | | `MCP_MAX_SESSIONS` | Cap on concurrent Streamable HTTP sessions; new sessions beyond the cap get an HTTP `429`. Unset = unlimited. | @@ -249,11 +189,241 @@ The SDK reads standard settings from the environment or `.env` files: | `NITRO_MCP_PROTOCOL_VERSION` | Protocol era (case-insensitive): `auto` / `both` / `dual` / `dual-spec` (default when unset or unknown), `modern` / `latest` / `2026` / `2026-07-28`, or `legacy` / `2025` / `2025-06-18` / `2025-11-25`. `auto` is not the same as `modern`. Wins over `ServerConfig.protocol_era`. | | `MCP_STATELESS` | Explicit override: `true` forces `modern` (stateless HTTP), `false` forces `legacy` (sessionful). Wins over `NITRO_MCP_PROTOCOL_VERSION` and `ServerConfig.protocol_era`. | | `MCP_ALLOWED_HOSTS` / `MCP_ALLOWED_ORIGINS` | Comma-separated allow-lists for DNS-rebinding protection, used only when CORS is disabled. | +| `NITROSTACK_APP_MODE` | Widget metadata flavour: `universal` (default), `openai`, or `mcp-app`. See [Widgets](#widgets-ui-tools). | +| `OAUTH_REQUIRED` | With `OAuthGuard`: `true` rejects calls without a valid token. Unset lets token-less calls through, for local development. | | `NITROSTACK_LOG_FILE` | Destination file for logs (default: `nitrostack.log`). | | `NITROSTACK_LOG_LEVEL` | Log level (`DEBUG`, `INFO`, `WARNING`, `ERROR`). | | `NITROSTACK_LOG_TO_STDOUT` | Set to `true` to allow logging to stdout under stdio transport (Caution: may corrupt protocol stream). | | `NITROSTACK_HTTP_DEBUG` | Set to `1` to log every HTTP request (method, path, headers, body) and response status to stderr. Use when diagnosing a client that fails to connect: uvicorn's access log shows neither headers nor timestamps. | +Read your own settings through `ConfigService`: + +```python +from nitrostack import ConfigModule, ConfigService, injectable, module + +@injectable(deps=[ConfigService]) +class WeatherService: + def __init__(self, config: ConfigService): + self.units = config.get("WEATHER_UNITS", "metric") + self.api_key = config.get_or_throw("WEATHER_API_KEY") # KeyError if unset + +@module( + name="app", + imports=[ConfigModule.for_root(env_file_path=".env", defaults={"WEATHER_UNITS": "metric"})], + providers=[WeatherService], +) +class AppModule: + pass +``` + +--- + +## Building Blocks + +The examples below build on each other (later ones reuse `LookupInput` and `NoInput`) and are methods on a controller (an `@injectable` class listed in a module's `controllers`). Every tool takes `(self, input, context)`, where `input` is an instance of its `input_schema` model and `context` is the `ExecutionContext` (logger, auth, task, request metadata). + +### Tools + +```python +from pydantic import BaseModel, Field +from nitrostack import tool, initial_tool, ToolAnnotations, ToolExamples, ExecutionContext + +class LookupInput(BaseModel): + city: str = Field(description="City name") + +class Weather(BaseModel): + city: str + temp: float + units: str + +class NoInput(BaseModel): + pass + +class WeatherController: + @tool( + name="get_weather", + title="Get Weather", + description="Current weather for a city", + input_schema=LookupInput, + output_schema=Weather, # optional; adds structuredContent + annotations=ToolAnnotations(read_only_hint=True, destructive_hint=False, idempotent_hint=True), + examples=ToolExamples(input={"city": "Paris"}, output={"city": "Paris", "temp": 21, "units": "metric"}), + ) + async def get_weather(self, input: LookupInput, context: ExecutionContext) -> dict: + return {"city": input.city, "temp": 21, "units": "metric"} + + @tool(name="whats_new", description="Shown when a client connects", input_schema=NoInput) + @initial_tool # clients that support it call this on connect + async def whats_new(self, input: NoInput, context: ExecutionContext) -> str: + return "Try get_weather." +``` + +A tool with no arguments still needs an input model; use an empty `BaseModel` subclass like `NoInput` (not `BaseModel` itself). `visibility="hidden"` keeps a tool callable but out of `tools/list`. Raise an exception to return an MCP error to the client. + +### Resources and prompts + +```python +from typing import List +from nitrostack import resource, prompt, PromptArgument, PromptMessage, ExecutionContext + +class WeatherController: + @resource(uri="weather://units", name="Units", description="Units in use", mime_type="text/plain") + async def units(self, context: ExecutionContext) -> str: + return "metric" + + @prompt( + name="forecast_brief", + description="Write a short forecast", + arguments=[PromptArgument(name="city", description="City", required=True)], + ) + async def forecast_brief(self, args: dict, context: ExecutionContext) -> List[PromptMessage]: + return [PromptMessage(role="user", content=f"Write a short forecast for {args['city']}.")] +``` + +### Guards, interceptors, pipes, middleware and filters + +Each is a small class with one async method; attach it with a decorator below `@tool`. Generate stubs with `nitrostack-py generate guard|pipe|interceptor|filter `. + +| Stage | Method | Attach with | Runs | +|---|---|---|---| +| Guard | `can_activate(context) -> bool` | `@use_guards(...)` | first; `False` rejects the call ("Access denied by guard") | +| Pipe | `transform(value, metadata)` | `@use_pipes(...)` | on the tool input, after guards | +| Middleware | `use(context, next_fn)` | `@use_middleware(...)` | around interceptors and the tool | +| Interceptor | `intercept(context, next_fn)` | `@use_interceptors(...)` | around the tool; can change its result | +| Exception filter | `catch(error, context)` | `@use_filters(...)` | on errors; its return value becomes the result | + +```python +from nitrostack import tool, use_guards, use_interceptors, use_filters, ExecutionContext + +class OfficeHoursGuard: + async def can_activate(self, context: ExecutionContext) -> bool: + return context.metadata.get("office_open", True) + +class TimingInterceptor: + async def intercept(self, context: ExecutionContext, next_fn): + result = await next_fn() + context.logger.info(f"{context.tool_name} finished") + return result + +class FriendlyErrors: + async def catch(self, error: Exception, context: ExecutionContext): + return {"error": str(error)} + +class WeatherController: + @tool(name="get_forecast", description="Forecast for a city", input_schema=LookupInput) + @use_guards(OfficeHoursGuard) + @use_interceptors(TimingInterceptor) + @use_filters(FriendlyErrors) + async def get_forecast(self, input: LookupInput, context: ExecutionContext) -> dict: + return {"city": input.city, "forecast": "sunny"} +``` + +### Authentication + +Register an auth module in the root module, then guard tools. Guards fill `context.auth` (`subject`, `scopes`, `client_id`, `claims`, ...). + +| Module | Guard | Configured by | +|---|---|---| +| `ApiKeyModule.for_root(keys_env_prefix="API_KEY", hashed=False)` | `ApiKeyGuard` | `x-api-key` header checked against `API_KEY`, `API_KEY_1`, `API_KEY_2`, ... (SHA-256 hashes when `hashed=True`) | +| `JWTModule.for_root(secret_env_var="JWT_SECRET", audience=..., issuer=...)` | `JwtGuard` | `Authorization: Bearer ` | +| `OAuthModule.for_root(resource_uri=..., authorization_servers=[...], scopes_supported=[...], token_introspection_endpoint=... or jwks_uri=...)` | `OAuthGuard` | OAuth 2.1 access tokens, audience-bound; `OAUTH_REQUIRED=true` to require one | + +```python +from nitrostack import ( + ApiKeyGuard, ApiKeyModule, ExecutionContext, OAuthGuard, module, require_scopes, tool, use_guards, +) + +class AccountController: + @tool(name="get_balance", description="Account balance", input_schema=NoInput) + @use_guards(ApiKeyGuard) + async def get_balance(self, input: NoInput, context: ExecutionContext) -> dict: + return {"balance": 42} + + @tool(name="close_account", description="Close the account", input_schema=NoInput) + @use_guards(OAuthGuard) + @require_scopes("admin") # needs context.auth with every listed scope + async def close_account(self, input: NoInput, context: ExecutionContext) -> dict: + return {"closed": True} + +@module(name="app", imports=[ApiKeyModule.for_root()], controllers=[AccountController]) +class AppModule: + pass +``` + +Register `ApiKeyModule` when you use `ApiKeyGuard`: without it, the guard falls back to the `API_KEY` variable, and with neither set it lets every call through. + +`has_scope`, `has_any_scope` and `has_all_scopes` check `context.auth` inside a tool. For OAuth, start from the `python-oauth` template and its `OAUTH_SETUP.md`; PKCE (`generate_pkce_params`, `verify_pkce`), client ID metadata documents (`resolve_cimd`) and `generate_www_authenticate_header` are exported for custom flows. + +### Caching and rate limits + +```python +from nitrostack import cache, injectable, rate_limit, tool + +@injectable() +class WeatherService: + @cache(ttl=60) # seconds; keyed on the arguments + async def lookup(self, city: str) -> dict: + return {"city": city, "temp": 21} + +class WeatherController: + @tool(name="ping", description="Ping", input_schema=NoInput) + @rate_limit(max=10, window=60) # 10 calls per 60 s, across all callers + async def ping(self, input: NoInput, context: ExecutionContext) -> str: + return "pong" +``` + +Put `@cache` on a service method with plain, hashable arguments (strings, numbers, tuples). It can't key on a tool's Pydantic input object. + +### Long-running tasks + +Set `task_support="optional"` (or `"required"`) and the client can run the tool as an MCP task: it polls `tasks/get`, can cancel it, and gets live `notifications/progress` when it sends a `progressToken`. `context.task` is `None` when the tool is called normally. + +```python +import asyncio +from pydantic import BaseModel +from nitrostack import tool, ExecutionContext + +class ReportInput(BaseModel): + steps: int = 5 + +class ReportController: + @tool(name="build_report", description="Build a report", input_schema=ReportInput, task_support="optional") + async def build_report(self, input: ReportInput, context: ExecutionContext) -> dict: + for step in range(1, input.steps + 1): + if context.task: + context.task.update_progress(f"Step {step}/{input.steps}") + context.task.throw_if_cancelled() + await asyncio.sleep(1) + return {"steps": input.steps} +``` + +### Events and health checks + +```python +from typing import List +from nitrostack import EventEmitter, health_check, injectable, on_event + +@injectable() +class OrderService: + async def place(self, order_id: str) -> None: + await EventEmitter.get_instance().emit("order.placed", {"id": order_id}) + +@injectable() +class AuditLog: + def __init__(self): + self.entries: List[str] = [] + + @on_event("order.placed") + def record(self, payload: dict) -> None: + self.entries.append(payload["id"]) + + @health_check("audit") + def healthy(self) -> bool: + return True +``` + +Listeners and health checks are found on any provider or controller in a module. When any health check exists, the server exposes the `health://status` resource (`{"audit": "healthy"}`). Keep health checks synchronous. + --- ## Transport Options @@ -279,28 +449,94 @@ server = ServerConfig(name="my-server", transport_type="http", max_sessions=100, --- -## Developing & Testing +## 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 -### Auto-Registering with Claude Desktop -To automatically configure your server script with Claude Desktop without any manual editing: ```bash -nitrostack-py register --name my-mcp-server --file app.py +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 main.py ``` -*(If your scripts folder is not in PATH, use: `python -m nitrostack.cli.main register --name my-mcp-server --file app.py`)* -This detects all standard and Windows Store installation directories, sets up virtualenv executables, and writes the JSON configuration. Once registered, simply restart Claude Desktop. +### Generate components -### Widgets (UI tools) +```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 +``` -Bind a static HTML template to a tool with `@widget` and return domain JSON from the handler: +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 +nitrostack-py pack --include-env # also pack .env files (use with caution!) +``` + +`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`. `.env`, `.env.local`, and `.env.production` are excluded unless you pass `--include-env`. Temporary build directories are deleted afterwards. + +### Upgrade, install, validate + +```bash +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 + +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 +``` + +`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` 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. + +--- + +## Widgets (UI tools) + +Bind an HTML template to a tool with `@widget` and return domain JSON from the handler: ```python from nitrostack import tool, widget, ExecutionContext -@tool(name="show_card", description="Product card", input_schema=CardInput) -@widget("card") -async def show_card(self, input: CardInput, context: ExecutionContext) -> dict: - return {"name": "Widget", "price": 9.99} +class ShopController: + @tool(name="show_card", description="Product card", input_schema=CardInput) + @widget("card") + async def show_card(self, input: CardInput, context: ExecutionContext) -> dict: + return {"name": "Widget", "price": 9.99} ``` Place HTML at `widgets/out/{route}.html` (e.g. `widgets/out/card.html`). The SDK registers @@ -325,16 +561,69 @@ from nitrostack import WidgetOptions, WidgetCsp, widget prefers_border=True, csp=WidgetCsp(connect_domains=["https://api.example.com"]), )) +async def show_chart(self, input, context): + ... ``` `nitrostack-py init` copies `widgets/out/{route}.html` for every `@widget`. Widget HTML is generated in Python from the tool's `structuredContent` (one iframe, N cards). +#### Custom widget HTML: data and host bridge + +The tool result is passed to the widget as a JSON-compatible object. The shared host bridge +normalizes the following sources, in order: + +1. `window.openai.toolOutput.structuredContent` (or `toolResult`). +2. A JSON `application/json` content block, or a JSON object in a `text/plain` content block. +3. The `#nitrostack-tool-data` embedded JSON script used by Python-rendered pages. + +The normalized object is passed to `window.__nitroWidgetRender(data)`. Keep the tool return +shape stable and render from that object. `NITROSTACK_APP_MODE` changes the advertised +metadata and resource MIME type, not this data shape: + +- `openai` uses OpenAI output-template metadata and the OpenAI host data channel. +- `mcp-app` uses MCP Apps `_meta.ui` metadata; tool-result notifications are normalized by the bridge. +- `universal` (the default) publishes both forms. + +A hand-written file found at `widgets/out/{route}.html` is loaded as-is. The loader does +not inject the host bridge into that file. The built-in route templates created by `init` +do include the bridge, so custom HTML must include the equivalent bridge/render hook itself. +The generated files under `examples/widgets/out/` are the reference implementation. + +Minimal render hook (used after the bridge has been included): + +```html +
Waiting for tool data…
+ +``` + +#### Widget CSP metadata + +`WidgetCsp` describes the origins a widget needs; NitroStack publishes this as +metadata for the host to enforce. The SDK does not build or attach a response +`Content-Security-Policy` header itself. + +| Field | Meaning | MCP Apps metadata | OpenAI metadata | +|---|---|---|---| +| `connect_domains` | Network connections made by the widget | `connectDomains` | `connect_domains` | +| `resource_domains` | External resources such as images, scripts, or styles | `resourceDomains` | `resource_domains` | +| `frame_domains` | Origins the widget may embed in frames | `frameDomains` | `frame_domains` | + +Whether a scheme such as `data:` is accepted is host-dependent. Listing `data:` +in `resource_domains` requests/advertises that resource origin; it is not a guarantee +that every host will allow data URIs. + MCP Inspector: use **HTTP + stateless**, then the **Apps** tab. `tools/call` also embeds the data-filled HTML. Do not use `widgets/preview.html` as the live result — that file is a static helper. Live preview: `http://localhost:3000/widgets/preview`. -Turn **Authentication off** in Inspector. Pizzaz/starter have no OAuth. If Auth is on, +Turn **Authentication off** in Inspector for the starter and pizzaz templates (they have no OAuth). If Auth is on, Inspector POSTs `/register` and you will see `Cannot POST /register` / `Unexpected token '<'`. Connect Streamable HTTP to `http://localhost:3000/mcp` (no trailing slash). @@ -342,11 +631,6 @@ For **open pizza shops only**, call `show_pizza_list` with `{"openNow": true}` o `show_pizza_map` with `{"filter": "open_now"}`. Omitting those fields returns every shop, including closed ones (Pizzeria Delfina). -**NitroStudio:** folder-connect looks for a TypeScript project (`package.json` with -`@nitrostack/core` and `src/index.ts`). A Python server will not detect. Keep using -MCP Inspector over HTTP, or point Studio at a custom Streamable HTTP URL if the build -supports it. Do not enable OAuth against this server. - Example server: `examples/widgets_example.py` with templates in `examples/widgets/out/`. For MCP Inspector over HTTP, use stateless mode: @@ -355,24 +639,32 @@ cd examples MCP_TRANSPORT_TYPE=http MCP_STATELESS=true NITROSTACK_APP_MODE=universal python widgets_example.py ``` -### Running Tests -To run the automated test suite, execute: +--- + +## Using with Clients + +### NitroStudio + +Open the project folder in NitroStudio. It recognises a Python NitroStack project by `main.py` plus `nitrostack` in `requirements.txt` or `pyproject.toml`, starts it, and lets you call tools, preview widgets, chat with it, and run OAuth. Studio passes the port it picks through `PORT`, which wins over `.env` (0.4.1 and later), so several projects can run side by side. + +### Claude Desktop + ```bash -python tests/test_basic.py -python tests/test_tasks.py -python tests/test_initial_tool.py -python tests/test_transports.py -python tests/test_widgets.py -python tests/test_widget_metadata.py -python tests/test_pizzaz_widgets.py -python tests/test_template_widgets.py -python tests/test_cli.py -pytest tests/test_cli.py -v -python tests/test_tool_input_schema.py +nitrostack-py register --name my-mcp-server --file main.py ``` +*(If your scripts folder is not in PATH, use: `python -m nitrostack.cli.main register --name my-mcp-server --file main.py`)* + +This finds the Claude Desktop config (standard and Windows Store installs), points it at your virtualenv's Python, and writes the server entry. Restart Claude Desktop afterwards. + +--- + +## Testing + ### Testing Harness -Write in-process unit tests using the harness: + +Call tools, resources and prompts in-process with `NitroTestingModule`; no transport, no subprocess: + ```python import asyncio from nitrostack.testing import NitroTestingModule @@ -380,10 +672,18 @@ from app import AppModule async def test_add(): harness = await NitroTestingModule.create(AppModule) - result = await harness.call_tool("add", {"input": {"a": 5, "b": 10}}) - assert result == 15.0 - print("Test passed!") + assert await harness.call_tool("add", {"a": 5, "b": 10}) == 15.0 + assert await harness.read_resource("calc://info") == "Simple Add Calculator v1.0.0" if __name__ == "__main__": asyncio.run(test_add()) ``` + +`call_tool` takes the tool's arguments as an MCP client sends them and returns the decoded result (JSON is parsed; errors come back as their message). `get_prompt(name, args)` returns the prompt messages. + +### Running the SDK's tests + +```bash +pip install -e ".[dev]" +pytest tests +``` diff --git a/nitrostack/auth/config.py b/nitrostack/auth/config.py index f97c56d..7b4cd4c 100644 --- a/nitrostack/auth/config.py +++ b/nitrostack/auth/config.py @@ -19,7 +19,10 @@ def __init__( # Merge system environment variables first self._config.update(os.environ) - # Parse .env manually (no stdout prints allowed) + # Parse .env manually (no stdout prints allowed). The process + # environment wins, as with python-dotenv and Node's dotenv: a host + # that starts the server with PORT=3001 must not be overridden by the + # PORT=3000 that `nitrostack init` writes into .env. if not ignore_env_file and os.path.exists(env_file_path): try: with open(env_file_path, "r", encoding="utf-8") as f: @@ -31,6 +34,8 @@ def __init__( k, v = line.split("=", 1) k = k.strip() v = v.strip().strip("'\"") # Remove wrapping quotes + if k in os.environ: + continue self._config[k] = v os.environ[k] = v except Exception as e: diff --git a/nitrostack/cli/main.py b/nitrostack/cli/main.py index 1ab03b3..e570c03 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): @@ -1795,13 +1795,18 @@ def main(): # pack command pack_parser = subparsers.add_parser( "pack", - help="Build a deployable wheel of the current project (never includes .env/secrets)", + help="Build a deployable wheel of the current project (excludes .env by default)", ) pack_parser.add_argument( "--dry-run", action="store_true", help="Show files that would be packed without creating the artifact", ) + pack_parser.add_argument( + "--include-env", + action="store_true", + help="Include .env files in the wheel (use with caution!)", + ) # upgrade command upgrade_parser = subparsers.add_parser( @@ -1881,7 +1886,7 @@ def _run_command(fn, *fn_args, **fn_kwargs): else: generate_component(args.generator, args.name) elif args.command == "pack": - _run_command(pack_project, dry_run=args.dry_run) + _run_command(pack_project, dry_run=args.dry_run, include_env=args.include_env) elif args.command == "upgrade": _run_command( upgrade_project, diff --git a/nitrostack/cli/pack.py b/nitrostack/cli/pack.py index b2a805c..221a6bc 100644 --- a/nitrostack/cli/pack.py +++ b/nitrostack/cli/pack.py @@ -1,8 +1,9 @@ """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. +already declared in NitroStack's packaging config. Secrets (``.env``) are excluded +by default; pass ``include_env`` to pack them. Temporary build artifacts are +cleaned up after packing. """ from __future__ import annotations @@ -110,10 +111,17 @@ def _matches_gitignore(rel_posix: str, patterns: Sequence[Tuple[str, bool]]) -> return ignored -def _should_exclude(rel_posix: str, name: str, is_dir: bool, gitignore: Sequence[Tuple[str, bool]]) -> bool: +def _should_exclude( + rel_posix: str, + name: str, + is_dir: bool, + gitignore: Sequence[Tuple[str, bool]], + *, + include_env: bool = False, +) -> bool: if name == ".env.example": return False - if _is_secret_env(name): + if _is_secret_env(name) and not include_env: return True if name in _EXCLUDE_DIR_NAMES and is_dir: return True @@ -128,7 +136,7 @@ def _should_exclude(rel_posix: str, name: str, is_dir: bool, gitignore: Sequence return False -def collect_pack_files(root: str) -> List[str]: +def collect_pack_files(root: str, *, include_env: bool = False) -> List[str]: """Return posix-relative paths that would be included in a pack.""" gitignore = _load_gitignore_patterns(root) included: List[str] = [] @@ -141,14 +149,14 @@ def collect_pack_files(root: str) -> List[str]: 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): + if _should_exclude(child_rel, dirname, True, gitignore, include_env=include_env): 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): + if _should_exclude(child_rel, filename, False, gitignore, include_env=include_env): continue included.append(child_rel) @@ -348,14 +356,14 @@ def _is_valid_wheel(path: str) -> bool: return "Wheel-Version:" in body -def _collect_staging_bytes(src_root: str) -> dict: +def _collect_staging_bytes(src_root: str, *, include_env: bool = False) -> 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): + if _is_secret_env(filename) and not include_env: continue full = os.path.join(dirpath, filename) rel = os.path.relpath(full, src_root).replace("\\", "/") @@ -370,6 +378,8 @@ def _write_purelib_wheel( name: str, version: str, deps: Sequence[str], + *, + include_env: bool = False, ) -> str: """Write a PEP 427 py3-none-any wheel from the staged project files.""" os.makedirs(wheel_dir, exist_ok=True) @@ -379,7 +389,7 @@ def _write_purelib_wheel( wheel_path = os.path.join(wheel_dir, wheel_filename) dist_info = f"{dist_name}-{version}.dist-info" - contents = _collect_staging_bytes(src_root) + contents = _collect_staging_bytes(src_root, include_env=include_env) metadata_lines = [ "Metadata-Version: 2.1", f"Name: {display_name}", @@ -441,18 +451,34 @@ def _build_wheel( name: str, version: str, deps: Sequence[str], + *, + include_env: bool = False, ) -> 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) + return _write_purelib_wheel( + src_root, wheel_dir, name, version, deps, include_env=include_env + ) + + +def _secret_env_paths_in_pack(files: Sequence[str]) -> List[str]: + return sorted(rel for rel in files if _is_secret_env(rel.rsplit("/", 1)[-1])) + + +def _warn_including_env_files(env_files: Sequence[str]) -> None: + if not env_files: + return + listed = ", ".join(env_files) + print(f"Warning: Including .env files in the wheel (use with caution!): {listed}") def pack_project( root: Optional[str] = None, *, dry_run: bool = False, + include_env: bool = False, output_dir: Optional[str] = None, ) -> dict: """Pack the project at ``root``. @@ -462,12 +488,15 @@ def pack_project( root = os.path.abspath(root or os.getcwd()) from nitrostack.cli.vendor import ensure_vendored_nitrostack ensure_vendored_nitrostack(root) - files = collect_pack_files(root) + files = collect_pack_files(root, include_env=include_env) 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")) + if include_env: + _warn_including_env_files(_secret_env_paths_in_pack(files)) + print("Files that would be packed:" if dry_run else "Packing files:") for rel in files: print(f" {rel}") @@ -486,12 +515,17 @@ def pack_project( write_requirements_txt(staging, deps) _ensure_pyproject(staging, name, version, deps) - wheel_path = _build_wheel(staging, wheel_tmp, name, version, deps) + wheel_path = _build_wheel( + staging, wheel_tmp, name, version, deps, include_env=include_env + ) 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)) + if include_env: + for rel in _secret_env_paths_in_pack(files): + extra.append((rel, os.path.join(staging, rel.replace("/", os.sep)))) _inject_into_wheel(wheel_path, extra) if not _is_valid_wheel(wheel_path): 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/validators.py b/nitrostack/cli/validators.py index 990b154..75f604b 100644 --- a/nitrostack/cli/validators.py +++ b/nitrostack/cli/validators.py @@ -21,6 +21,7 @@ read_text, split_requirement, ) +from nitrostack.cli.vendor import VENDOR_REL @dataclass @@ -39,10 +40,14 @@ def format(self) -> str: def _iter_python_files(root: str) -> Iterable[str]: + # The vendored SDK is not project code; its testing helpers apply @mcp_app + # inside functions, which reads as an app with no decorated class. + vendored_sdk = os.path.join(os.path.abspath(root), VENDOR_REL) 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") + and os.path.join(os.path.abspath(dirpath), d) != vendored_sdk ] for filename in filenames: if filename.endswith(".py"): @@ -372,6 +377,56 @@ def validate_module_references(root: str) -> List[ValidationIssue]: return issues +_WIDGET_DECORATOR_RE = re.compile(r"""@widget\(\s*["']([^"']+)["']""") +_WIDGET_OPTIONS_RE = re.compile(r"""WidgetOptions\(\s*route\s*=\s*["']([^"']+)["']""") +_WIDGET_ROUTE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]*$") + + +def _collect_widget_routes(root: str) -> List[Tuple[str, str]]: + found: List[Tuple[str, str]] = [] + seen: Set[str] = set() + for path in sorted(_iter_python_files(root)): + try: + text = _read(path) + except OSError: + continue + for route in _WIDGET_DECORATOR_RE.findall(text) + _WIDGET_OPTIONS_RE.findall(text): + if route not in seen: + seen.add(route) + found.append((route, os.path.relpath(path, root))) + return found + + +def validate_widgets(root: str) -> List[ValidationIssue]: + issues: List[ValidationIssue] = [] + for route, source in _collect_widget_routes(root): + if not _WIDGET_ROUTE_RE.fullmatch(route): + issues.append(ValidationIssue( + "warning", + f"@widget route {route!r} is not a single alphanumeric path segment", + "Use letters, digits, '-' or '_' only; the route maps to widgets/out/.html.", + source, + )) + continue + rel = os.path.join("widgets", "out", f"{route}.html") + uri = f"ui://widget/{route}.html" + html_path = os.path.join(root, rel) + if not os.path.isfile(html_path): + issues.append(ValidationIssue( + "warning", + f"widget route {route!r} -> {rel} -> {uri}: file is missing", + f"Create {rel} with the widget HTML.", + source, + )) + elif not _read(html_path).strip(): + issues.append(ValidationIssue( + "warning", + f"widget route {route!r} -> {rel} -> {uri}: file is empty", + f"Add widget HTML to {rel}.", + 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: @@ -380,6 +435,7 @@ def validate_project(root: Optional[str] = None) -> List[ValidationIssue]: issues.extend(validate_dependencies(root)) issues.extend(validate_mcp_app_imports(root)) issues.extend(validate_module_references(root)) + issues.extend(validate_widgets(root)) return issues 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/pyproject.toml b/pyproject.toml index 5b4a3b3..59850ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "nitrostack" -version = "0.3.2" +version = "0.4.2" description = "NitroStack Python SDK — A Python-idiomatic port of the NitroStack MCP framework" readme = "README.md" requires-python = ">=3.10" @@ -28,6 +28,12 @@ dev = [ "tox>=4.0.0", ] +[project.urls] +Homepage = "https://nitrostack.ai/" +Documentation = "https://docs.nitrostack.ai/python" +Repository = "https://github.com/nitrocloudofficial/nitrostack-python-sdk" +Issues = "https://github.com/nitrocloudofficial/nitrostack-python-sdk/issues" + [project.scripts] nitrostack-py = "nitrostack.cli.main:main" diff --git a/scripts/bump_version.py b/scripts/bump_version.py new file mode 100644 index 0000000..4b466ae --- /dev/null +++ b/scripts/bump_version.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +"""Bump and sync the SDK version in pyproject.toml and uv.lock. + +Version sources of truth: + - pyproject.toml [project].version + - uv.lock [[package]] name = \"nitrostack\" (editable .) + +Template app versions (0.1.0) and README CLI examples are not the SDK release. +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +import urllib.request +from typing import Optional, Sequence, Tuple + +PYPI_JSON = "https://pypi.org/pypi/nitrostack/json" +_PROJECT_VERSION = re.compile(r'(?m)^(version = ")([^"]+)(")') +_LOCK_NITROSTACK = re.compile( + r'(name = "nitrostack"\nversion = ")([^"]+)("\nsource = \{ editable = "\." \})', +) + + +def parse_x_y_z(raw: str) -> Tuple[int, int, int]: + parts = (raw or "").strip().split(".") + if len(parts) != 3 or not all(p.isdigit() for p in parts): + raise ValueError(f"Expected X.Y.Z version, got {raw!r}") + return int(parts[0]), int(parts[1]), int(parts[2]) + + +def bump_x_y_z(raw: str, kind: str) -> str: + major, minor, patch = parse_x_y_z(raw) + if kind == "major": + return f"{major + 1}.0.0" + if kind == "minor": + return f"{major}.{minor + 1}.0" + if kind == "patch": + return f"{major}.{minor}.{patch + 1}" + raise ValueError(f"Unknown bump kind {kind!r}") + + +def version_key(raw: str) -> Tuple[int, int, int]: + return parse_x_y_z(raw) + + +def read_project_version(pyproject_text: str) -> str: + match = _PROJECT_VERSION.search(pyproject_text) + if not match: + raise ValueError("pyproject.toml has no version = \"...\" field") + return match.group(2) + + +def set_project_version(pyproject_text: str, version: str) -> str: + updated, n = _PROJECT_VERSION.subn(rf"\g<1>{version}\3", pyproject_text, count=1) + if n != 1: + raise ValueError("Could not replace [project].version in pyproject.toml") + return updated + + +def set_lock_nitrostack_version(lock_text: str, version: str) -> str: + updated, n = _LOCK_NITROSTACK.subn(rf"\g<1>{version}\3", lock_text, count=1) + if n != 1: + raise ValueError("Could not replace editable nitrostack version in uv.lock") + return updated + + +def fetch_pypi(url: str = PYPI_JSON) -> dict: + with urllib.request.urlopen(url, timeout=30) as response: + return json.load(response) + + +def pypi_latest_and_released(payload: dict) -> Tuple[Optional[str], set]: + info = payload.get("info") or {} + latest = info.get("version") + released = set((payload.get("releases") or {}).keys()) + return latest, released + + +def plan_bump(git_ver: str, latest: Optional[str], released: set, kind: str) -> Optional[str]: + """Return the next version to write, or None if a bump PR is not needed. + + Unpublished git versions are left for the publish workflow (git not on PyPI). + Otherwise bump from max(git, PyPI latest) so a stale git version cannot + collide with a manual upload (e.g. git 0.3.2, PyPI 0.3.5 → 0.3.6). + """ + if git_ver not in released: + return None + base = git_ver + if latest: + base = git_ver if version_key(git_ver) >= version_key(latest) else latest + return bump_x_y_z(base, kind) + + +def should_publish(git_ver: str, released: set) -> bool: + return git_ver not in released + + +def _read(path: str) -> str: + with open(path, "r", encoding="utf-8") as handle: + return handle.read() + + +def _write(path: str, text: str) -> None: + with open(path, "w", encoding="utf-8") as handle: + handle.write(text) + + +def apply_version(root: str, version: str) -> None: + pyproject = f"{root}/pyproject.toml" + lock = f"{root}/uv.lock" + _write(pyproject, set_project_version(_read(pyproject), version)) + _write(lock, set_lock_nitrostack_version(_read(lock), version)) + + +def main(argv: Optional[Sequence[str]] = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", default=".") + parser.add_argument("--bump", choices=("patch", "minor", "major"), default="patch") + parser.add_argument( + "--write", + action="store_true", + help="Write pyproject.toml and uv.lock. Prints the new version.", + ) + parser.add_argument( + "--if-unpublished", + action="store_true", + help="Exit 0 if the git version is not on PyPI (publish it); else exit 1.", + ) + args = parser.parse_args(argv) + + git_ver = read_project_version(_read(f"{args.root}/pyproject.toml")) + payload = fetch_pypi() + latest, released = pypi_latest_and_released(payload) + + if args.if_unpublished: + if should_publish(git_ver, released): + print(git_ver) + return 0 + print(f"skip: {git_ver} is already on PyPI", file=sys.stderr) + return 1 + + planned = plan_bump(git_ver, latest, released, args.bump) + if planned is None: + print( + f"skip: {git_ver} is not on PyPI yet (publish this version first)", + file=sys.stderr, + ) + return 0 + if args.write: + apply_version(args.root, planned) + print(planned) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100644 index 0000000..8042f2f --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,263 @@ +#!/usr/bin/env bash +# Release the SDK to PyPI from this machine. +# +# publish.yml uploads only through PyPI trusted publishing, and the Bump +# version workflow can't open its release PR (the org doesn't let GitHub +# Actions create pull requests). This script does both by hand. +# +# 1. Get a release branch, either way: +# - the Bump version workflow already pushed `release/X.Y.Z`: +# git switch release/X.Y.Z && git pull +# - or have this script make one from origin/main: +# scripts/release.sh --bump patch (or minor / major) +# 2. On that branch: scripts/release.sh +# checks, tests, builds, uploads to PyPI (asks first), verifies the +# install, tags the uploaded commit vX.Y.Z, and opens the release PR. +# 3. Merge the release PR (publish.yml sees the version on PyPI and skips). +# +# Options: +# --bump KIND patch|minor|major: create release/ from origin/main +# --skip-tests don't run the test suite before building +# --yes don't ask before uploading +# --dry-run stop before anything leaves this machine (no push, upload, +# PR or tag); --bump still creates the branch locally +# --tag only tag a version that's already on PyPI but untagged +# (the head of origin/release/X.Y.Z if it still exists, +# else origin/main) +# +# Needs uv, gh (for the PR) and a PyPI token, in ~/.pypirc ([pypi] with +# username __token__) or in TWINE_USERNAME=__token__ / TWINE_PASSWORD. +set -euo pipefail + +# bash reads a script while running it, and --bump switches branches, which +# can rewrite this file. Run from a copy. +if [ -z "${NITRO_RELEASE_ROOT:-}" ]; then + NITRO_RELEASE_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + copy="$(mktemp)" + cp "${BASH_SOURCE[0]}" "$copy" + NITRO_RELEASE_ROOT="$NITRO_RELEASE_ROOT" NITRO_RELEASE_COPY="$copy" exec bash "$copy" "$@" +fi +trap 'rm -f "${NITRO_RELEASE_COPY:-}"' EXIT +ROOT="$NITRO_RELEASE_ROOT" +cd "$ROOT" + +BUMP="" +SKIP_TESTS=0 +YES=0 +DRY_RUN=0 +TAG_ONLY=0 +while [ $# -gt 0 ]; do + case "$1" in + --bump) + BUMP="${2:-}" + shift + ;; + --skip-tests) SKIP_TESTS=1 ;; + --yes) YES=1 ;; + --dry-run) DRY_RUN=1 ;; + --tag) TAG_ONLY=1 ;; + -h | --help) + sed -n '2,/^set -euo/p' "$0" | sed '$d' | sed 's/^# \{0,1\}//' + exit 0 + ;; + *) + echo "Unknown option: $1 (see --help)" >&2 + exit 2 + ;; + esac + shift +done +case "$BUMP" in "" | patch | minor | major) ;; *) + echo "--bump takes patch, minor or major" >&2 + exit 2 + ;; +esac + +step() { printf '\n==> %s\n' "$*"; } +fail() { + printf '\nerror: %s\n' "$*" >&2 + exit 1 +} + +# uv: a pyenv shim fails inside this repo when the pinned .python-version +# isn't installed in pyenv, so fall back to known install locations. +find_uv() { + local candidate + for candidate in "${UV:-}" "$(command -v uv 2>/dev/null || true)" \ + "$HOME/.local/bin/uv" "$HOME/.cargo/bin/uv" /opt/homebrew/bin/uv /usr/local/bin/uv \ + "$HOME"/.pyenv/versions/*/bin/uv; do + if [ -n "$candidate" ] && [ -x "$candidate" ] && "$candidate" --version >/dev/null 2>&1; then + echo "$candidate" + return 0 + fi + done + return 1 +} +UV="$(find_uv)" || fail "uv not found. Install it (https://docs.astral.sh/uv/) or set UV=/path/to/uv." +UVX="$(dirname "$UV")/uvx" +[ -x "$UVX" ] || UVX="$UV tool run" +PY=("$UV" run --no-project --quiet python) + +project_version() { "${PY[@]}" -c 'import re,sys; print(re.search(r"(?m)^version = \"([^\"]+)\"", sys.stdin.read()).group(1))'; } +# Exit 0 when VERSION is on PyPI, 1 when it isn't; stop on any other answer +# (offline, PyPI down) rather than guess. +on_pypi() { + local code + code="$(curl -s -o /dev/null -w '%{http_code}' "https://pypi.org/pypi/nitrostack/$1/json" || true)" + case "$code" in + 200) return 0 ;; + 404) return 1 ;; + *) fail "Couldn't check PyPI for $1 (HTTP ${code:-none}). Check your connection and try again." ;; + esac +} +tag_exists() { git rev-parse -q --verify "refs/tags/v$1" >/dev/null || [ -n "$(git ls-remote --tags origin "refs/tags/v$1")" ]; } + +step "Fetching origin" +git fetch -q --tags origin + +# --- Step 4: tag the merged release ------------------------------------- +if [ "$TAG_ONLY" = 1 ]; then + VERSION="$(git show origin/main:pyproject.toml | project_version)" + echo "origin/main is at $VERSION" + on_pypi "$VERSION" || fail "$VERSION isn't on PyPI. Release it first (scripts/release.sh on its release branch)." + tag_exists "$VERSION" && fail "Tag v$VERSION already exists." + # Tag what was uploaded: the release branch head, when it's still around. + TARGET=origin/main + if git rev-parse -q --verify "refs/remotes/origin/release/$VERSION" >/dev/null && + [ "$(git show "origin/release/$VERSION:pyproject.toml" | project_version)" = "$VERSION" ]; then + TARGET="origin/release/$VERSION" + fi + if [ "$DRY_RUN" = 1 ]; then + echo "Dry run: would tag $TARGET ($(git rev-parse --short "$TARGET")) as v$VERSION and push it." + exit 0 + fi + git tag -a "v$VERSION" "$TARGET" -m "nitrostack $VERSION" + git push -q origin "v$VERSION" + echo "Tagged and pushed v$VERSION." + exit 0 +fi + +[ -z "$(git status --porcelain --untracked-files=no)" ] || fail "Uncommitted changes. Commit or stash them first." + +# --- Step 1 (optional): make the release branch ------------------------- +if [ -n "$BUMP" ]; then + step "Creating a $BUMP release branch from origin/main" + MAIN_VERSION="$(git show origin/main:pyproject.toml | project_version)" + on_pypi "$MAIN_VERSION" || fail "origin/main's version $MAIN_VERSION isn't on PyPI yet. Release it as is (no --bump)." + # Plan from origin/main's files (and its bump_version.py) in a scratch + # folder, so nothing changes here until every check has passed. + PLAN="$(mktemp -d)" + for f in pyproject.toml uv.lock scripts/bump_version.py; do + mkdir -p "$PLAN/$(dirname "$f")" + git show "origin/main:$f" >"$PLAN/$f" + done + NEXT="$("${PY[@]}" "$PLAN/scripts/bump_version.py" --root "$PLAN" --bump "$BUMP" | tail -n 1)" + [ -n "$NEXT" ] || fail "PyPI's project index doesn't list $MAIN_VERSION yet (it's cached for up to 15 minutes after a release). Try again shortly." + git rev-parse -q --verify "refs/heads/release/$NEXT" >/dev/null && fail "Branch release/$NEXT already exists locally." + [ -z "$(git ls-remote --heads origin "release/$NEXT")" ] || fail "release/$NEXT already exists on origin. Use it: git switch release/$NEXT" + "${PY[@]}" "$PLAN/scripts/bump_version.py" --root "$PLAN" --bump "$BUMP" --write >/dev/null + git switch -q -c "release/$NEXT" origin/main + cp "$PLAN/pyproject.toml" "$PLAN/uv.lock" . + rm -rf "$PLAN" + git add pyproject.toml uv.lock + git commit -q -m "chore(release): $NEXT" + echo "Created release/$NEXT with the version bump." + if [ "$DRY_RUN" = 0 ]; then + git push -q -u origin "release/$NEXT" + fi +fi + +# --- Step 2: check, test, build, upload --------------------------------- +BRANCH="$(git branch --show-current)" +[ -n "$BRANCH" ] || fail "Not on a branch. Switch to the release branch first." +VERSION="$(project_version &2 + +step "Checking the release on PyPI" +for _ in $(seq 1 30); do + on_pypi "$VERSION" && break + sleep 10 +done +on_pypi "$VERSION" || fail "PyPI doesn't list $VERSION after 5 minutes. Check https://pypi.org/project/nitrostack/" +VENV="$(mktemp -d)/venv" +"$UV" venv --quiet "$VENV" +installed="" +for _ in $(seq 1 12); do + if "$UV" pip install --quiet --no-cache --python "$VENV" "nitrostack==$VERSION" 2>/dev/null; then + installed="$("$VENV/bin/python" -c 'import importlib.metadata as m, nitrostack; print(m.version("nitrostack"))')" + break + fi + sleep 10 +done +rm -rf "$(dirname "$VENV")" +[ "$installed" = "$VERSION" ] || fail "Couldn't install and import nitrostack==$VERSION from PyPI." +echo "OK: pip install nitrostack==$VERSION works and imports." + +# --- Release PR --------------------------------------------------------- +step "Release PR" +if ! command -v gh >/dev/null; then + echo "gh not found. Open a PR from $BRANCH to main, labelled 'release'." +else + PR="$(gh pr list --base main --head "$BRANCH" --json url --jq '.[0].url // empty')" + if [ -z "$PR" ]; then + PR="$(gh pr create --base main --head "$BRANCH" --title "chore(release): $VERSION" --label release --body "$( + cat < 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") diff --git a/tests/test_readme_examples.py b/tests/test_readme_examples.py new file mode 100644 index 0000000..0e004fd --- /dev/null +++ b/tests/test_readme_examples.py @@ -0,0 +1,148 @@ +"""README examples must stay true to the SDK: PyPI shows this README. + +Every ```python block must compile and import only names the SDK has. The +Quick Start and Building Blocks examples are also run through the testing +harness. +""" +from __future__ import annotations + +import asyncio +import importlib +import os +import re +import sys +import types + +import pytest + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +sys.path.insert(0, ROOT) + +from nitrostack import DIContainer, NitroTestingModule, module # noqa: E402 +from nitrostack.core.additional_decorators import HealthCheckRegistry # noqa: E402 +from nitrostack.events.event_emitter import EventEmitter # noqa: E402 + +README = open(os.path.join(ROOT, "README.md"), encoding="utf-8").read() +FENCE = re.compile(r"^```python\n(.*?)^```", re.S | re.M) + + +def _section(title: str) -> str: + match = re.search(rf"^## {re.escape(title)}\n(.*?)(?=^## |\Z)", README, re.S | re.M) + assert match, f"README has no '## {title}' section" + return match.group(1) + + +def _blocks(text: str): + return FENCE.findall(text) + + +@pytest.fixture(autouse=True) +def fresh_globals(): + DIContainer.reset() + EventEmitter.reset() + HealthCheckRegistry._bound_checks.clear() + yield + DIContainer.reset() + EventEmitter.reset() + HealthCheckRegistry._bound_checks.clear() + + +def test_every_python_block_compiles(): + blocks = _blocks(README) + assert len(blocks) >= 10 + for i, code in enumerate(blocks): + compile(code, f"README.md python block {i + 1}", "exec") + + +def test_every_nitrostack_import_exists(): + imports = re.compile(r"^from (nitrostack[\w.]*) import (\([^)]*\)|[^\n]*)", re.M) + checked = 0 + for code in _blocks(README): + for mod_name, names in imports.findall(code): + mod = importlib.import_module(mod_name) + for name in re.split(r"[,\s()]+", names): + if not name or name.startswith("#"): + continue + assert hasattr(mod, name), f"README imports {name!r} from {mod_name}, which doesn't have it" + checked += 1 + assert checked >= 30 + + +def test_quick_start_runs(): + code = _blocks(_section("Quick Start"))[0] + app = types.ModuleType("readme_quick_start_app") + exec(compile(code, "README quick start", "exec"), app.__dict__) + + async def run(): + harness = await NitroTestingModule.create(app.AppModule) + assert await harness.call_tool("add", {"a": 5, "b": 10}) == 15.0 + assert await harness.read_resource("calc://info") == "Simple Add Calculator v1.0.0" + + asyncio.run(run()) + + +def test_building_blocks_run(monkeypatch): + monkeypatch.setenv("WEATHER_API_KEY", "test") + monkeypatch.setenv("API_KEY", "secret") + monkeypatch.delenv("OAUTH_REQUIRED", raising=False) + + # The section's blocks build on each other; keep every controller and + # service each block defines (several reuse the name WeatherController). + namespace: dict = {"__name__": "readme_building_blocks"} + controllers, providers = [], [] + for i, code in enumerate(_blocks(_section("Building Blocks"))): + before = dict(namespace) + exec(compile(code, f"README building block {i + 1}", "exec"), namespace) + for name, value in namespace.items(): + if not isinstance(value, type) or before.get(name) is value: + continue + if name.endswith("Controller"): + controllers.append(value) + elif name in ("WeatherService", "OrderService", "AuditLog"): + providers.append(value) + + @module(name="app", imports=[namespace["ApiKeyModule"].for_root()], controllers=controllers, providers=providers) + class AppModule: + pass + + async def run(): + h = await NitroTestingModule.create(AppModule) + assert await h.call_tool("get_weather", {"city": "Paris"}) == {"city": "Paris", "temp": 21, "units": "metric"} + assert await h.call_tool("whats_new", {}) == "Try get_weather." + assert await h.read_resource("weather://units") == "metric" + messages = await h.get_prompt("forecast_brief", {"city": "Oslo"}) + assert "Oslo" in str(messages) + + assert await h.call_tool("get_forecast", {"city": "Rome"}) == {"city": "Rome", "forecast": "sunny"} + assert "Access denied" in str(await h.call_tool("get_balance", {})) + assert "Not authenticated" in str(await h.call_tool("close_account", {})) + + assert [await h.call_tool("ping", {}) for _ in range(10)] == ["pong"] * 10 + assert "Rate limit exceeded" in str(await h.call_tool("ping", {})) + + service = DIContainer.get_instance().resolve(namespace["WeatherService"]) + assert await service.lookup("Paris") is await service.lookup("Paris") + + assert await h.call_tool("build_report", {"steps": 1}) == {"steps": 1} + + await DIContainer.get_instance().resolve(namespace["OrderService"]).place("A1") + assert DIContainer.get_instance().resolve(namespace["AuditLog"]).entries == ["A1"] + assert await h.read_resource("health://status") == {"audit": "healthy"} + + asyncio.run(run()) + + +def test_config_service_example(tmp_path, monkeypatch): + code = _blocks(_section("Quick Start"))[1] + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("WEATHER_API_KEY", "k") + monkeypatch.delenv("WEATHER_UNITS", raising=False) + namespace: dict = {"__name__": "readme_config"} + exec(compile(code, "README config example", "exec"), namespace) + + async def run(): + await NitroTestingModule.create(namespace["AppModule"]) + service = DIContainer.get_instance().resolve(namespace["WeatherService"]) + assert (service.units, service.api_key) == ("metric", "k") + + asyncio.run(run()) diff --git a/tests/test_validate_widgets.py b/tests/test_validate_widgets.py new file mode 100644 index 0000000..1595e60 --- /dev/null +++ b/tests/test_validate_widgets.py @@ -0,0 +1,85 @@ +import sys +from pathlib import Path + +from nitrostack.cli.validators import validate_project, validate_widgets + +TOOL = ( + "from nitrostack import tool, widget\n\n" + "@tool(name='show')\n" + "@widget('card')\n" + "async def show(self, input, context):\n" + " return {}\n" +) + + +def _project(tmp_path: Path, source: str = TOOL) -> Path: + (tmp_path / "tools.py").write_text(source, encoding="utf-8") + return tmp_path + + +def _write_html(root: Path, route: str, html: str) -> None: + out = root / "widgets" / "out" + out.mkdir(parents=True, exist_ok=True) + (out / f"{route}.html").write_text(html, encoding="utf-8") + + +def test_missing_widget_html_is_a_warning(tmp_path: Path): + issues = validate_widgets(str(_project(tmp_path))) + assert len(issues) == 1 + assert issues[0].severity == "warning" + assert "file is missing" in issues[0].message + assert "card.html" in issues[0].message + assert "ui://widget/card.html" in issues[0].message + assert issues[0].path == "tools.py" + + +def test_empty_widget_html_is_a_warning(tmp_path: Path): + _write_html(_project(tmp_path), "card", " \n") + issues = validate_widgets(str(tmp_path)) + assert len(issues) == 1 + assert issues[0].severity == "warning" + assert "file is empty" in issues[0].message + + +def test_valid_widget_html_passes(tmp_path: Path): + _write_html(_project(tmp_path), "card", "") + assert validate_widgets(str(tmp_path)) == [] + + +def test_widget_options_route_is_collected(tmp_path: Path): + src = "from nitrostack import WidgetOptions, widget\n\n@widget(WidgetOptions(route='chart'))\ndef f(): ...\n" + issues = validate_widgets(str(_project(tmp_path, src))) + assert len(issues) == 1 + assert "'chart'" in issues[0].message + + +def test_invalid_route_is_reported_not_resolved(tmp_path: Path): + issues = validate_widgets(str(_project(tmp_path, "@widget('../evil')\ndef f(): ...\n"))) + assert len(issues) == 1 + assert "not a single alphanumeric path segment" in issues[0].message + + +def test_duplicate_routes_are_reported_once(tmp_path: Path): + (tmp_path / "a.py").write_text(TOOL, encoding="utf-8") + (tmp_path / "b.py").write_text(TOOL, encoding="utf-8") + assert len(validate_widgets(str(tmp_path))) == 1 + + +def test_vendored_sdk_is_ignored(tmp_path: Path): + vendored = tmp_path / "vendor" / "nitrostack" + vendored.mkdir(parents=True) + (vendored / "x.py").write_text(TOOL, encoding="utf-8") + assert validate_widgets(str(tmp_path)) == [] + + +def test_project_without_widgets_has_no_widget_issues(tmp_path: Path): + (tmp_path / "plain.py").write_text("x = 1\n", encoding="utf-8") + assert validate_widgets(str(tmp_path)) == [] + + +def test_validate_project_reports_widget_warnings_only(tmp_path: Path, monkeypatch): + monkeypatch.setattr(sys, "path", list(sys.path)) + _project(tmp_path) + issues = [i for i in validate_project(str(tmp_path)) if "widget route" in i.message] + assert len(issues) == 1 + assert issues[0].severity == "warning" \ No newline at end of file diff --git a/uv.lock b/uv.lock index ed009ff..b4eb511 100644 --- a/uv.lock +++ b/uv.lock @@ -568,7 +568,7 @@ wheels = [ [[package]] name = "nitrostack" -version = "0.3.2" +version = "0.4.2" source = { editable = "." } dependencies = [ { name = "anyio" },