From 143b9f0e09ad9a62d8c327272b718818cafa4317 Mon Sep 17 00:00:00 2001 From: Jimisola Laursen Date: Thu, 25 Jun 2026 09:03:50 +0200 Subject: [PATCH 1/2] feat(reqstool): dogfood OpenSpec <-> reqstool traceability Bootstraps the OpenSpec spec layer and derives the reqstool requirement traceability SSOT for this repo, mirroring reqstool-client#407 and reqstool-python-decorators#84: - docs/reqstool/{requirements,software_verification_cases,reqstool_config}.yml with 2 capability-prefixed requirements (HATCH_PLUGIN_001/002) covering annotation generation during build and sdist bundling. Only 2 capabilities vs. poetry-plugin's 5: Hatch's build-hook model doesn't need a separate pyproject.toml include-registration step or a root-file-cleanup step -- it appends directly into the already-built sdist tarball - openspec/specs/{annotation-generation,sdist-bundling} thin ID-reference spec files - Tagged ReqstoolBuildHook.initialize/.finalize with @Requirements; tagged the existing e2e test (test_build_e2e.py, the only test that meaningfully exercises the hook against a real hatchling build of a fixture project) with @SVCs for both capabilities - scripts/generate_annotations.py self-applies the decorator processor to this repo's own src/tests/unit/tests/e2e -- no chicken-and-egg problem, same as reqstool-python-decorators#84. tests/fixtures is excluded (its own self-contained fixture project has unrelated decorated REQ_001/SVC_001); tests/integration is empty stub packages, omitted until real tests land - CI: new [pypi, main] matrix on the build job running the existing wheel build + test + full build steps, then self-apply, reqstool-status (required gate), and validate-reqstool (main leg only, supplementary); new validate-openspec job - .claude/settings.json + .mcp.json wire up the reqstool-ai plugins/MCP server; .gitignore carve-out for .claude/settings.json Validated: 1 passed/1 skipped (pre-existing skip, unrelated to this PR) test run, reqstool validate --strict and reqstool status --check-all-reqs-met both pass (2/2 complete), CLI and MCP (get_requirements_status) agree exactly, openspec validate --specs --strict 2/2 pass. Signed-off-by: Jimisola Laursen --- .claude/settings.json | 15 +++ .github/workflows/build.yml | 29 +++++ .gitignore | 3 +- .mcp.json | 8 ++ .reqstool-ai.yaml | 32 ++++++ CONTRIBUTING.md | 25 ++++ docs/reqstool/reqstool_config.yml | 11 ++ docs/reqstool/requirements.yml | 31 +++++ docs/reqstool/software_verification_cases.yml | 14 +++ openspec/openspecui.hooks.ts | 108 ++++++++++++++++++ openspec/specs/annotation-generation/spec.md | 15 +++ openspec/specs/sdist-bundling/spec.md | 15 +++ scripts/generate_annotations.py | 17 +++ .../build_hooks/reqstool.py | 3 + .../test_build_e2e.py | 2 + 15 files changed, 327 insertions(+), 1 deletion(-) create mode 100644 .claude/settings.json create mode 100644 .mcp.json create mode 100644 .reqstool-ai.yaml create mode 100644 docs/reqstool/reqstool_config.yml create mode 100644 docs/reqstool/requirements.yml create mode 100644 docs/reqstool/software_verification_cases.yml create mode 100644 openspec/openspecui.hooks.ts create mode 100644 openspec/specs/annotation-generation/spec.md create mode 100644 openspec/specs/sdist-bundling/spec.md create mode 100644 scripts/generate_annotations.py diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..876c06b --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,15 @@ +{ + "extraKnownMarketplaces": { + "reqstool-ai": { + "source": { + "source": "github", + "repo": "reqstool/reqstool-ai" + }, + "autoUpdate": true + } + }, + "enabledPlugins": { + "reqstool@reqstool-ai": true, + "reqstool-openspec@reqstool-ai": true + } +} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 742f6f8..d54be99 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -11,6 +11,9 @@ on: - reopened - synchronize +permissions: + contents: read + jobs: linting: name: Reuse linting job @@ -19,6 +22,10 @@ jobs: build: needs: linting runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + reqstool-source: [pypi, main] steps: - name: Check out source repository uses: actions/checkout@v6 @@ -36,9 +43,31 @@ jobs: run: hatch run dev:pytest --junitxml=build/junit.xml --cov=reqstool_python_hatch_plugin --cov-report=xml:build/coverage.xml - name: Build project run: hatch build + - name: Self-apply own decorators to own src/tests + run: hatch run dev:python scripts/generate_annotations.py + - name: Install reqstool + uses: reqstool/.github/.github/actions/install-reqstool@74cc3ac55a476258898db82c69a78eeaabb2fcbc # main 2026-06-24 + with: + reqstool-source: ${{ matrix.reqstool-source }} + - name: Validate reqstool spec completeness + # not yet available in the latest PyPI release; supplementary to (not a + # replacement for) the `reqstool status --fail-if-incomplete` gate below, which + # is the actual required check on the pypi leg + if: matrix.reqstool-source == 'main' + uses: reqstool/.github/.github/actions/validate-reqstool@74cc3ac55a476258898db82c69a78eeaabb2fcbc # main 2026-06-24 + - name: Run reqstool status + # Required gate on both matrix legs -- fails the build unless every + # requirement is implemented and verified. + uses: reqstool/.github/.github/actions/reqstool-status@74cc3ac55a476258898db82c69a78eeaabb2fcbc # main 2026-06-24 + with: + fail-if-incomplete: "true" # Upload artifacts for later use - name: Upload Artifacts + if: matrix.reqstool-source == 'pypi' uses: actions/upload-artifact@v7 with: name: dist path: dist/ + + validate-openspec: + uses: reqstool/.github/.github/workflows/common-validate-openspec.yml@74cc3ac55a476258898db82c69a78eeaabb2fcbc # main 2026-06-24 diff --git a/.gitignore b/.gitignore index f0277f4..2d83c08 100644 --- a/.gitignore +++ b/.gitignore @@ -283,4 +283,5 @@ pyrightconfig.json # End of https://www.toptal.com/developers/gitignore/api/python,intellij+all,visualstudiocode # Claude Code -.claude/ +.claude/* +!.claude/settings.json diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..a14c1b1 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "reqstool": { + "command": "reqstool", + "args": ["mcp"] + } + } +} diff --git a/.reqstool-ai.yaml b/.reqstool-ai.yaml new file mode 100644 index 0000000..3e4a1fb --- /dev/null +++ b/.reqstool-ai.yaml @@ -0,0 +1,32 @@ +# reqstool-ai configuration +# +# This file tells reqstool-ai skills where to find your reqstool files +# and how to generate IDs for new requirements and SVCs. +# +# Place this file at: .reqstool-ai.yaml (project root) + +# Project URN — matches the urn in your reqstool YAML files +urn: reqstool-python-hatch-plugin + +# Revision string for new requirements and SVCs +revision: "0.1.0" + +# System-level reqstool directory (contains the SSOT requirements and SVCs) +system: + path: docs/reqstool + +# Subproject modules — each module imports a subset of requirements/SVCs via filters +# +# Required fields per module: +# path — path to the module's reqstool directory (contains filter files) +# req_prefix — prefix for requirement IDs belonging to this module (e.g., CORE_) +# svc_prefix — prefix for SVC IDs belonging to this module (e.g., SVC_CORE_) +# +# Add as many modules as your project has. The module name (key) is used in +# commands like `/reqstool:status core` and `/reqstool:add-req core`. +modules: + # Matches the existing HATCH_PLUGIN_NNN / SVC_HATCH_PLUGIN_NNN ID convention. + plugin: + path: docs/reqstool + req_prefix: "HATCH_PLUGIN_" + svc_prefix: "SVC_HATCH_PLUGIN_" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2e2341c..2257c13 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,6 +8,8 @@ For DCO sign-off, commit conventions, and code review process, see the organizat - Python 3.13+ - [Hatch](https://hatch.pypa.io/) (`pip install hatch`) +- [reqstool](https://github.com/reqstool/reqstool-client) (`pipx install reqstool`) +- [OpenSpec](https://github.com/Fission-AI/OpenSpec) (`npm install -g @fission-ai/openspec`) ## Setup @@ -17,9 +19,32 @@ cd reqstool-python-hatch-plugin hatch env create ``` +If using Claude Code, opening this repo will prompt you to confirm adding the `reqstool-ai` +marketplace and enabling the `reqstool`/`reqstool-openspec` plugins (configured in +`.claude/settings.json`) — accept the prompt. + +Then regenerate the `opsx` slash commands and OpenSpec skills +(`.claude/commands/opsx/`, `.claude/skills/openspec-*`) — they're CLI-generated tool scaffolding, +not committed to the repo: + +```bash +openspec update # or: openspec init --tools claude --force +``` + ## Build & Test ```bash hatch build hatch run test ``` + +## Self-applied traceability (`docs/reqstool/`) + +This project dogfoods itself: `hatch run dev:python scripts/generate_annotations.py` scans its +own `src`/`tests/unit`/`tests/e2e` for `@Requirements`/`@SVCs` decorators and writes +`build/reqstool/annotations.yml`. Check status with: + +```bash +hatch build --target wheel && hatch run dev:pytest && hatch run dev:python scripts/generate_annotations.py +reqstool status --check-all-reqs-met local -p docs/reqstool +``` diff --git a/docs/reqstool/reqstool_config.yml b/docs/reqstool/reqstool_config.yml new file mode 100644 index 0000000..33271ba --- /dev/null +++ b/docs/reqstool/reqstool_config.yml @@ -0,0 +1,11 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/reqstool/reqstool-client/main/src/reqstool/resources/schemas/v1/reqstool_config.schema.json + +language: python +build: hatch +resources: + requirements: requirements.yml + software_verification_cases: software_verification_cases.yml + manual_verification_results: manual_verification_results.yml + annotations: ../../build/reqstool/annotations.yml + test_results: + - ../../build/**/*.xml diff --git a/docs/reqstool/requirements.yml b/docs/reqstool/requirements.yml new file mode 100644 index 0000000..be9ed91 --- /dev/null +++ b/docs/reqstool/requirements.yml @@ -0,0 +1,31 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/reqstool/reqstool-client/main/src/reqstool/resources/schemas/v1/requirements.schema.json + +metadata: + urn: reqstool-python-hatch-plugin + variant: microservice + title: Reqstool Python Hatch Plugin + url: https://github.com/reqstool/reqstool-python-hatch-plugin + +requirements: + - id: HATCH_PLUGIN_001 + title: Generate annotations.yml from reqstool decorators during build + significance: shall + description: >- + On `hatch build`, the plugin shall process the configured source + directories for `@Requirements`/`@SVCs` decorators and write the + result to annotations.yml in the configured output directory, before + any build artifact is produced. + categories: [functional-suitability] + revision: "0.1.0" + - id: HATCH_PLUGIN_002 + title: Bundle reqstool dataset and annotations into the built sdist + significance: shall + description: >- + After a sdist artifact is built, the plugin shall append the + project's reqstool dataset (requirements.yml, + software_verification_cases.yml, manual_verification_results.yml, + when present), the generated annotations.yml, any configured test + result files, and a generated reqstool_config.yml directly into the + sdist tarball. Non-sdist artifacts (e.g. wheel) are left untouched. + categories: [functional-suitability] + revision: "0.1.0" diff --git a/docs/reqstool/software_verification_cases.yml b/docs/reqstool/software_verification_cases.yml new file mode 100644 index 0000000..fb146c6 --- /dev/null +++ b/docs/reqstool/software_verification_cases.yml @@ -0,0 +1,14 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/reqstool/reqstool-client/main/src/reqstool/resources/schemas/v1/software_verification_cases.schema.json + +cases: + - id: SVC_HATCH_PLUGIN_001 + requirement_ids: ["HATCH_PLUGIN_001"] + title: "Verify annotations.yml is generated from decorated source during hatch build" + verification: automated-test + revision: "0.1.0" + + - id: SVC_HATCH_PLUGIN_002 + requirement_ids: ["HATCH_PLUGIN_002"] + title: "Verify the built sdist tarball contains the reqstool dataset, annotations, and config" + verification: automated-test + revision: "0.1.0" diff --git a/openspec/openspecui.hooks.ts b/openspec/openspecui.hooks.ts new file mode 100644 index 0000000..49a96ee --- /dev/null +++ b/openspec/openspecui.hooks.ts @@ -0,0 +1,108 @@ +// @reqstool-openspec-hooks: 0.1.1 +import { spawn, ChildProcess } from "child_process"; +import type { OnReadDocumentHookV1 } from "openspecui/hooks"; + +// Minimal MCP client over stdio (JSON-RPC 2.0, newline-delimited). +// Uses only Node.js built-ins — no npm packages required. +class McpStdioClient { + private proc: ChildProcess; + private buf = ""; + private pending = new Map< + number, + { resolve: (v: unknown) => void; reject: (e: Error) => void } + >(); + private id = 1; + readonly ready: Promise; + + constructor(cwd: string) { + this.proc = spawn("reqstool", ["mcp"], { + cwd, + stdio: ["pipe", "pipe", "pipe"], + }); + this.proc.stdout!.on("data", (chunk: Buffer) => { + this.buf += chunk.toString(); + let nl: number; + while ((nl = this.buf.indexOf("\n")) !== -1) { + const line = this.buf.slice(0, nl).trim(); + this.buf = this.buf.slice(nl + 1); + if (line) this.handle(line); + } + }); + this.ready = this.init(); + } + + private handle(line: string) { + try { + const msg = JSON.parse(line) as { id?: number; result?: unknown; error?: { message: string } }; + if (msg.id !== undefined) { + const p = this.pending.get(msg.id); + if (p) { + this.pending.delete(msg.id); + msg.error ? p.reject(new Error(msg.error.message)) : p.resolve(msg.result); + } + } + } catch (e) { + console.warn("[reqstool-openspec] Skipping non-JSON line from reqstool mcp:", e instanceof Error ? e.message : e); + } + } + + private send(method: string, params: unknown, expectReply = true): Promise { + if (!expectReply) { + this.proc.stdin!.write(JSON.stringify({ jsonrpc: "2.0", method, params }) + "\n"); + return Promise.resolve(); + } + const id = this.id++; + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }); + this.proc.stdin!.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n"); + }); + } + + private async init(): Promise { + await this.send("initialize", { + protocolVersion: "2024-11-05", + capabilities: { tools: {} }, + clientInfo: { name: "openspecui", version: "1.0" }, + }); + this.send("notifications/initialized", {}, false); + } + + async enrich(content: string, preset: string): Promise { + await this.ready; + const result = (await this.send("tools/call", { + name: "enrich_document", + arguments: { content, preset }, + })) as { content: { text: string }[] }; + return result.content[0].text; + } + + close() { + this.proc.stdin?.end(); + this.proc.kill(); + } +} + +let client: McpStdioClient | null = null; + +export const onReadDocument: OnReadDocumentHookV1 = async (ctx, read) => { + if (!client) { + client = new McpStdioClient(ctx.projectDir); + ctx.lifecycle.onDispose(() => { + client?.close(); + client = null; + }); + } + + const result = await read(); + const preset = `openspec:${ctx.document.kind}`; + + try { + const enriched = await client.enrich(result.markdown, preset); + return { ...result, markdown: enriched, sourceLabel: `reqstool ${preset}` }; + } catch (e) { + return { + ...result, + diagnostics: [{ level: "warning", message: `reqstool enrich failed: ${e}` }], + }; + } +}; diff --git a/openspec/specs/annotation-generation/spec.md b/openspec/specs/annotation-generation/spec.md new file mode 100644 index 0000000..424072e --- /dev/null +++ b/openspec/specs/annotation-generation/spec.md @@ -0,0 +1,15 @@ +# Annotation Generation Specification + +## Purpose + +Requirement and SVC content is owned by reqstool (single source of truth). This spec references +reqstool requirement and SVC IDs only; titles and descriptions are injected at read time via +`reqstool enrich` (or the openspecui hook). See `docs/reqstool/`. + +## Requirements + +### Requirement: HATCH_PLUGIN_001 +The system SHALL implement HATCH_PLUGIN_001. + +#### Scenario: SVC_HATCH_PLUGIN_001 +The system SHALL pass SVC_HATCH_PLUGIN_001. diff --git a/openspec/specs/sdist-bundling/spec.md b/openspec/specs/sdist-bundling/spec.md new file mode 100644 index 0000000..a579119 --- /dev/null +++ b/openspec/specs/sdist-bundling/spec.md @@ -0,0 +1,15 @@ +# Sdist Bundling Specification + +## Purpose + +Requirement and SVC content is owned by reqstool (single source of truth). This spec references +reqstool requirement and SVC IDs only; titles and descriptions are injected at read time via +`reqstool enrich` (or the openspecui hook). See `docs/reqstool/`. + +## Requirements + +### Requirement: HATCH_PLUGIN_002 +The system SHALL implement HATCH_PLUGIN_002. + +#### Scenario: SVC_HATCH_PLUGIN_002 +The system SHALL pass SVC_HATCH_PLUGIN_002. diff --git a/scripts/generate_annotations.py b/scripts/generate_annotations.py new file mode 100644 index 0000000..497a562 --- /dev/null +++ b/scripts/generate_annotations.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 +# Copyright © LFV +"""Self-apply reqstool-python-decorators' processor to this repo's own src/tests, +producing build/reqstool/annotations.yml for `reqstool status` to consume.""" + +from reqstool_python_decorators.processors.decorator_processor import DecoratorProcessor + +if __name__ == "__main__": + DecoratorProcessor().process_decorated_data( + # tests/fixtures holds a self-contained fixture project with its own decorated + # REQ_001/SVC_001, unrelated to this plugin's own requirements -- excluded so it + # doesn't pollute this repo's own traceability data. + # tests/integration is omitted entirely: it's currently just empty __init__.py + # stubs, add it back once real integration tests land there. + path_to_python_files=["src", "tests/unit", "tests/e2e"], + output_file="build/reqstool/annotations.yml", + ) diff --git a/src/reqstool_python_hatch_plugin/build_hooks/reqstool.py b/src/reqstool_python_hatch_plugin/build_hooks/reqstool.py index 2f4116f..b4466b9 100644 --- a/src/reqstool_python_hatch_plugin/build_hooks/reqstool.py +++ b/src/reqstool_python_hatch_plugin/build_hooks/reqstool.py @@ -11,6 +11,7 @@ from hatchling.builders.hooks.plugin.interface import BuildHookInterface from hatchling.builders.plugin.interface import BuilderInterface +from reqstool_python_decorators.decorators.decorators import Requirements from reqstool_python_decorators.processors.decorator_processor import DecoratorProcessor from ruamel.yaml import YAML @@ -51,6 +52,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) self.__config_path: Optional[str] = None + @Requirements("HATCH_PLUGIN_001") def initialize(self, version: str, build_data: Dict[str, Any]) -> None: """ Executes custom actions during the build process. @@ -63,6 +65,7 @@ def initialize(self, version: str, build_data: Dict[str, Any]) -> None: self._create_annotations_file() + @Requirements("HATCH_PLUGIN_002") def finalize(self, version: str, build_data: dict[str, Any], artifact_path: str) -> None: if artifact_path.endswith(".tar.gz"): diff --git a/tests/e2e/reqstool_python_hatch_plugin/test_build_e2e.py b/tests/e2e/reqstool_python_hatch_plugin/test_build_e2e.py index 4e24512..1575906 100644 --- a/tests/e2e/reqstool_python_hatch_plugin/test_build_e2e.py +++ b/tests/e2e/reqstool_python_hatch_plugin/test_build_e2e.py @@ -7,6 +7,7 @@ from pathlib import Path import pytest +from reqstool_python_decorators.decorators.decorators import SVCs FIXTURE_DIR = Path(__file__).parents[2] / "fixtures" / "test_project" DIST_DIR = Path(__file__).parents[3] / "dist" @@ -22,6 +23,7 @@ @pytest.mark.e2e +@SVCs("SVC_HATCH_PLUGIN_001", "SVC_HATCH_PLUGIN_002") def test_hatch_build_sdist_contains_reqstool_artifacts(): """hatch build (sdist) triggers the reqstool hook and bundles all artifacts. From 3ab88d0166451c582e581dc0ec96f5f592006f87 Mon Sep 17 00:00:00 2001 From: Jimisola Laursen Date: Thu, 25 Jun 2026 15:26:23 +0200 Subject: [PATCH 2/2] fix(reqstool): address full-pr-review findings on #93 - Split the single e2e test into test_hatch_build_generates_annotations_yml (@SVCs SVC_HATCH_PLUGIN_001) and test_hatch_build_sdist_bundles_reqstool_dataset (@SVCs SVC_HATCH_PLUGIN_002), so a regression in one hook method no longer conflates both SVCs' pass/fail status in the traceability dataset - Extended the sdist-bundling test to cover the previously-untested manual_verification_results.yml and test_results branches of _append_to_sdist_tar_gz: added a real manual_verification_results.yml fixture, carved out a .gitignore exception so the fixture's build/test-results/junit.xml can be committed, and asserted reqstool_config.yml records the configured test_results glob (the hook only records the glob pattern as metadata, it doesn't bundle the actual XML into the tarball -- confirmed by re-reading _append_to_sdist_tar_gz) - Fixed a misleading build.yml comment implying reqstool-status only runs on the pypi leg; it runs unconditionally on both - Added a one-line note flagging the 4 SHA-pinned reqstool/.github refs that need to stay in sync when bumped Re-validated: 2 passed/1 skipped (pre-existing skip, unrelated), reqstool validate --strict and reqstool status --check-all-reqs-met both pass (2/2 complete, now mapped to 2 independent tests instead of 1 shared one), openspec validate --specs --strict 2/2, black clean. Signed-off-by: Jimisola Laursen --- .github/workflows/build.yml | 5 +- .gitignore | 1 + .../test_build_e2e.py | 111 +++++++++++------- .../test_project/build/test-results/junit.xml | 1 + .../reqstool/manual_verification_results.yml | 7 ++ 5 files changed, 83 insertions(+), 42 deletions(-) create mode 100644 tests/fixtures/test_project/build/test-results/junit.xml create mode 100644 tests/fixtures/test_project/docs/reqstool/manual_verification_results.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d54be99..7c9d90e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -45,6 +45,9 @@ jobs: run: hatch build - name: Self-apply own decorators to own src/tests run: hatch run dev:python scripts/generate_annotations.py + # NOTE: all reqstool/.github refs below are pinned to the same commit -- keep + # these 4 in sync when bumping (install-reqstool, validate-reqstool, + # reqstool-status, and the validate-openspec job's workflow ref further down). - name: Install reqstool uses: reqstool/.github/.github/actions/install-reqstool@74cc3ac55a476258898db82c69a78eeaabb2fcbc # main 2026-06-24 with: @@ -52,7 +55,7 @@ jobs: - name: Validate reqstool spec completeness # not yet available in the latest PyPI release; supplementary to (not a # replacement for) the `reqstool status --fail-if-incomplete` gate below, which - # is the actual required check on the pypi leg + # is the actual required check, run unconditionally on both matrix legs if: matrix.reqstool-source == 'main' uses: reqstool/.github/.github/actions/validate-reqstool@74cc3ac55a476258898db82c69a78eeaabb2fcbc # main 2026-06-24 - name: Run reqstool status diff --git a/.gitignore b/.gitignore index 2d83c08..8047d51 100644 --- a/.gitignore +++ b/.gitignore @@ -101,6 +101,7 @@ __pycache__/ # Distribution / packaging .Python build/ +!tests/fixtures/test_project/build/ develop-eggs/ dist/ downloads/ diff --git a/tests/e2e/reqstool_python_hatch_plugin/test_build_e2e.py b/tests/e2e/reqstool_python_hatch_plugin/test_build_e2e.py index 1575906..7ab2cbe 100644 --- a/tests/e2e/reqstool_python_hatch_plugin/test_build_e2e.py +++ b/tests/e2e/reqstool_python_hatch_plugin/test_build_e2e.py @@ -13,64 +13,93 @@ DIST_DIR = Path(__file__).parents[3] / "dist" # The hatch plugin appends reqstool_config.yml to the tar.gz and generates -# annotations.yml on disk. requirements.yml and software_verification_cases.yml -# are included via the sdist include config (docs/reqstool/**). +# annotations.yml on disk. requirements.yml, software_verification_cases.yml, and +# manual_verification_results.yml are all included via the sdist include config +# (docs/reqstool/**). The hook does NOT bundle the actual test-result XML files into +# the tarball -- it only records the configured glob pattern as a reqstool_config.yml +# resource (test results are transient CI artifacts, not shipped in the package). EXPECTED_IN_TARBALL = [ "reqstool_config.yml", "requirements.yml", "software_verification_cases.yml", + "manual_verification_results.yml", ] -@pytest.mark.e2e -@SVCs("SVC_HATCH_PLUGIN_001", "SVC_HATCH_PLUGIN_002") -def test_hatch_build_sdist_contains_reqstool_artifacts(): - """hatch build (sdist) triggers the reqstool hook and bundles all artifacts. +def _build_fixture_sdist(tmpdir: str) -> tuple[Path, Path]: + """Builds the fixture project's sdist via an isolated venv with the local plugin + wheel pre-installed (bypassing hatch's own build-env management, which can't + resolve `@ file://` hook dependencies reliably across pip/uv versions). - Runs hatchling directly inside an isolated venv that has the local plugin wheel - pre-installed, bypassing hatch's own build-env management (which can't resolve - @ file:// hook dependencies reliably across pip/uv versions). + Returns (tarball_path, project_dir). """ + tmp_project = Path(tmpdir) / "test_project" + # Keep build/test-results/junit.xml (the committed test_results fixture the hook + # bundles into the sdist) but drop dist/__pycache__. ignore_patterns matches by + # basename anywhere in the tree, so "build/reqstool" can't be excluded this way + # without also matching docs/reqstool -- removed separately below instead. + shutil.copytree(FIXTURE_DIR, tmp_project, ignore=shutil.ignore_patterns("dist", "__pycache__")) + shutil.rmtree(tmp_project / "build" / "reqstool", ignore_errors=True) + + venv_dir = Path(tmpdir) / "build-venv" + venv.create(str(venv_dir), with_pip=True) + python = str(venv_dir / "bin" / "python") + wheels = sorted(DIST_DIR.glob("reqstool_python_hatch_plugin-*.whl")) if not wheels: pytest.skip("No local wheel found — run `hatch build --target wheel` first") + subprocess.run( + [python, "-m", "pip", "install", "--quiet", "hatchling", str(wheels[-1])], + check=True, + ) + + result = subprocess.run( + [python, "-m", "hatchling", "build", "--target", "sdist"], + cwd=tmp_project, + capture_output=True, + text=True, + ) + assert result.returncode == 0, f"hatchling build failed:\n{result.stderr}" + + tarballs = sorted((tmp_project / "dist").glob("mypackage-*.tar.gz")) + assert tarballs, "No tarball found in dist/" + + return tarballs[-1], tmp_project + + +@pytest.mark.e2e +@SVCs("SVC_HATCH_PLUGIN_001") +def test_hatch_build_generates_annotations_yml(): + """hatch build's `initialize` hook generates annotations.yml from decorated source, + independently of whether the sdist-bundling (`finalize`) hook also succeeds.""" with tempfile.TemporaryDirectory() as tmpdir: - tmp_project = Path(tmpdir) / "test_project" - shutil.copytree(FIXTURE_DIR, tmp_project, ignore=shutil.ignore_patterns("dist", "build", "__pycache__")) - - # Build an isolated venv with hatchling + the local plugin wheel. - # We call hatchling directly so we fully control what's installed. - venv_dir = Path(tmpdir) / "build-venv" - venv.create(str(venv_dir), with_pip=True) - python = str(venv_dir / "bin" / "python") - - subprocess.run( - [python, "-m", "pip", "install", "--quiet", "hatchling", str(wheels[-1])], - check=True, - ) - - result = subprocess.run( - [python, "-m", "hatchling", "build", "--target", "sdist"], - cwd=tmp_project, - capture_output=True, - text=True, - ) - assert result.returncode == 0, f"hatchling build failed:\n{result.stderr}" - - tarballs = sorted((tmp_project / "dist").glob("mypackage-*.tar.gz")) - assert tarballs, "No tarball found in dist/" - - with tarfile.open(tarballs[-1]) as tf: - names = tf.getnames() - for expected in EXPECTED_IN_TARBALL: - assert any( - expected in n for n in names - ), f"{expected!r} missing from {tarballs[-1].name};\ngot: {names}" + _, tmp_project = _build_fixture_sdist(tmpdir) - # annotations.yml is generated on disk (not bundled in the tarball) annotations_file = tmp_project / "build" / "reqstool" / "annotations.yml" assert annotations_file.exists(), f"annotations.yml not generated at {annotations_file}" annotations_content = annotations_file.read_text() assert "REQ_001" in annotations_content, "annotations.yml missing REQ_001" assert "SVC_001" in annotations_content, "annotations.yml missing SVC_001" + + +@pytest.mark.e2e +@SVCs("SVC_HATCH_PLUGIN_002") +def test_hatch_build_sdist_bundles_reqstool_dataset(): + """hatch build's `finalize` hook bundles the full reqstool dataset -- including the + optional manual_verification_results.yml and configured test_results glob -- into + the sdist tarball, independently of whether annotation generation also succeeds.""" + with tempfile.TemporaryDirectory() as tmpdir: + tarball, _ = _build_fixture_sdist(tmpdir) + + with tarfile.open(tarball) as tf: + names = tf.getnames() + for expected in EXPECTED_IN_TARBALL: + assert any(expected in n for n in names), f"{expected!r} missing from {tarball.name};\ngot: {names}" + + config_member = next(n for n in names if n.endswith("reqstool_config.yml")) + config_fileobj = tf.extractfile(config_member) + assert config_fileobj is not None, f"{config_member!r} is not a regular file" + config_content = config_fileobj.read().decode() + assert "test_results" in config_content, "reqstool_config.yml missing test_results" + assert "junit.xml" in config_content, "reqstool_config.yml missing the configured test_results glob" diff --git a/tests/fixtures/test_project/build/test-results/junit.xml b/tests/fixtures/test_project/build/test-results/junit.xml new file mode 100644 index 0000000..959bfc8 --- /dev/null +++ b/tests/fixtures/test_project/build/test-results/junit.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/fixtures/test_project/docs/reqstool/manual_verification_results.yml b/tests/fixtures/test_project/docs/reqstool/manual_verification_results.yml new file mode 100644 index 0000000..73a5a16 --- /dev/null +++ b/tests/fixtures/test_project/docs/reqstool/manual_verification_results.yml @@ -0,0 +1,7 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/reqstool/reqstool-client/main/src/reqstool/resources/schemas/v1/manual_verification_results.schema.json + +results: + - id: MVR_001 + svc_ids: ["SVC_001"] + comment: Manually verified hello function returns hello. + pass: true