From c625ff8bd1b0abeae17e920c607d6cf935a5dc69 Mon Sep 17 00:00:00 2001 From: manish-wekan Date: Wed, 28 Oct 2026 16:02:18 +0530 Subject: [PATCH] Revert "Issue #41 : fix(tools): keep null branch in registered outputSchema" --- .github/workflows/bump-version.yml | 85 --------------- .github/workflows/publish.yml | 64 ------------ nitrostack/core/app.py | 4 +- pyproject.toml | 2 +- scripts/bump_version.py | 159 ----------------------------- tests/test_bump_version.py | 68 ------------ tests/test_tool_output_schema.py | 49 --------- 7 files changed, 3 insertions(+), 428 deletions(-) delete mode 100644 .github/workflows/bump-version.yml delete mode 100644 .github/workflows/publish.yml delete mode 100644 scripts/bump_version.py delete mode 100644 tests/test_bump_version.py delete mode 100644 tests/test_tool_output_schema.py diff --git a/.github/workflows/bump-version.yml b/.github/workflows/bump-version.yml deleted file mode 100644 index 3f7ff55..0000000 --- a/.github/workflows/bump-version.yml +++ /dev/null @@ -1,85 +0,0 @@ -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/nitrostack/core/app.py b/nitrostack/core/app.py index 894d939..53777ae 100644 --- a/nitrostack/core/app.py +++ b/nitrostack/core/app.py @@ -317,10 +317,10 @@ def tool_json_schema(schema_spec: Any) -> Optional[Dict[str, Any]]: if schema_spec is None: return None if isinstance(schema_spec, dict): - return normalize_output_schema(schema_spec) + return normalize_output_schema(inspector_friendly_schema(schema_spec)) model = get_pydantic_model(schema_spec) if model is not None: - return normalize_output_schema(model.model_json_schema()) + return normalize_output_schema(inspector_friendly_schema(model.model_json_schema())) return None diff --git a/pyproject.toml b/pyproject.toml index b46607f..6f480c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "nitrostack" -version = "0.4.0" +version = "0.3.2" description = "NitroStack Python SDK — A Python-idiomatic port of the NitroStack MCP framework" readme = "README.md" requires-python = ">=3.10" diff --git a/scripts/bump_version.py b/scripts/bump_version.py deleted file mode 100644 index 4b466ae..0000000 --- a/scripts/bump_version.py +++ /dev/null @@ -1,159 +0,0 @@ -#!/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/tests/test_bump_version.py b/tests/test_bump_version.py deleted file mode 100644 index ffa7bb7..0000000 --- a/tests/test_bump_version.py +++ /dev/null @@ -1,68 +0,0 @@ -from __future__ import annotations - -import importlib.util -import os - -import pytest - -ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) -SCRIPT = os.path.join(ROOT, "scripts", "bump_version.py") - - -def _load(): - spec = importlib.util.spec_from_file_location("bump_version", SCRIPT) - assert spec is not None and spec.loader is not None - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -bump = _load() - - -def test_bump_x_y_z(): - assert bump.bump_x_y_z("0.3.5", "patch") == "0.3.6" - assert bump.bump_x_y_z("0.3.5", "minor") == "0.4.0" - assert bump.bump_x_y_z("0.3.5", "major") == "1.0.0" - - -def test_plan_bump_skips_unpublished_git_version(): - assert bump.plan_bump("0.3.6", "0.3.5", {"0.3.5"}, "patch") is None - - -def test_plan_bump_from_pypi_when_git_is_stale(): - released = {"0.3.2", "0.3.4", "0.3.5"} - assert bump.plan_bump("0.3.2", "0.3.5", released, "patch") == "0.3.6" - - -def test_plan_bump_patch_when_git_matches_pypi(): - assert bump.plan_bump("0.3.5", "0.3.5", {"0.3.5"}, "patch") == "0.3.6" - - -def test_should_publish(): - assert bump.should_publish("0.3.6", {"0.3.5"}) is True - assert bump.should_publish("0.3.5", {"0.3.5"}) is False - - -def test_apply_version_syncs_pyproject_and_lock(tmp_path): - (tmp_path / "pyproject.toml").write_text( - '[project]\nname = "nitrostack"\nversion = "0.3.2"\n', - encoding="utf-8", - ) - (tmp_path / "uv.lock").write_text( - '[[package]]\nname = "annotated-types"\nversion = "0.8.0"\n\n' - '[[package]]\nname = "nitrostack"\nversion = "0.3.2"\n' - 'source = { editable = "." }\n', - encoding="utf-8", - ) - bump.apply_version(str(tmp_path), "0.3.6") - pyproject = (tmp_path / "pyproject.toml").read_text(encoding="utf-8") - lock = (tmp_path / "uv.lock").read_text(encoding="utf-8") - assert 'version = "0.3.6"' in pyproject - assert 'name = "nitrostack"\nversion = "0.3.6"\nsource = { editable = "." }' in lock - assert 'name = "annotated-types"\nversion = "0.8.0"' in lock - - -def test_set_project_version_only_first_field(): - text = '[project]\nversion = "0.3.2"\n\n[tool.ruff]\ntarget-version = "py310"\n' - assert bump.read_project_version(bump.set_project_version(text, "0.3.6")) == "0.3.6" diff --git a/tests/test_tool_output_schema.py b/tests/test_tool_output_schema.py deleted file mode 100644 index 669d77c..0000000 --- a/tests/test_tool_output_schema.py +++ /dev/null @@ -1,49 +0,0 @@ -from typing import Any, Dict, Optional - -import jsonschema -from pydantic import BaseModel - -from nitrostack.core.app import inspector_friendly_schema, tool_json_schema - - -class Out(BaseModel): - maybe: Optional[str] = None - n: Optional[int] = None - required_str: str = "x" - - -def _accepts_null(prop: Dict[str, Any]) -> bool: - for key in ("anyOf", "oneOf"): - if any(v.get("type") == "null" for v in prop.get(key, [])): - return True - types = prop.get("type") - return isinstance(types, list) and "null" in types - - -def test_output_schema_keeps_null_for_model(): - props = tool_json_schema(Out)["properties"] - assert _accepts_null(props["maybe"]) - assert _accepts_null(props["n"]) - - -def test_output_schema_keeps_null_for_dict(): - props = tool_json_schema(Out.model_json_schema())["properties"] - assert _accepts_null(props["maybe"]) - assert _accepts_null(props["n"]) - - -def test_output_schema_does_not_loosen_required_fields(): - props = tool_json_schema(Out)["properties"] - assert not _accepts_null(props["required_str"]) - assert props["required_str"]["type"] == "string" - - -def test_output_schema_validates_null_payload(): - jsonschema.validate({"maybe": None, "n": None, "required_str": "x"}, tool_json_schema(Out)) - - -def test_input_schema_still_unwraps_optional_for_inspector(): - props = inspector_friendly_schema(Out.model_json_schema())["properties"] - assert not _accepts_null(props["maybe"]) - assert props["maybe"]["type"] == "string" - assert props["n"]["type"] == "integer" \ No newline at end of file