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 e99df5c..dae705c 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@v7 @@ -34,9 +41,35 @@ jobs: run: hatch run dev:pytest --junitxml=build/junit.xml --cov=reqstool_python_decorators --cov-report=xml:build/coverage.xml - name: Build project run: hatch build + - name: Self-apply own decorator processor to own src/tests + # Relies on `hatch build` (above) not cleaning build/ -- annotations.yml lands + # alongside the junit/coverage output from the "Run tests" step, and + # reqstool_config.yml expects to find both there. If hatch build ever starts + # cleaning build/, move the pytest output dir outside of it. + run: hatch run dev:python scripts/generate_annotations.py + - name: Install reqstool + uses: reqstool/.github/.github/actions/install-reqstool@9c6feaab046f4782f430dd2527fdb82c2a5cd926 # main 2026-06-22 + 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@9c6feaab046f4782f430dd2527fdb82c2a5cd926 # main 2026-06-22 + - 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@9c6feaab046f4782f430dd2527fdb82c2a5cd926 # main 2026-06-22 + 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@9c6feaab046f4782f430dd2527fdb82c2a5cd926 # main 2026-06-22 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..f2be33d --- /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-decorators + +# 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 DECORATORS_NNN / SVC_DECORATORS_NNN ID convention. + decorators: + path: docs/reqstool + req_prefix: "DECORATORS_" + svc_prefix: "SVC_DECORATORS_" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2a1ab2b..3d1e5f0 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,6 +19,18 @@ cd reqstool-python-decorators 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 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..fca4fee --- /dev/null +++ b/docs/reqstool/requirements.yml @@ -0,0 +1,45 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/reqstool/reqstool-client/main/src/reqstool/resources/schemas/v1/requirements.schema.json +--- +metadata: + urn: reqstool-python-decorators + variant: microservice + title: Reqstool Python Decorators + url: https://github.com/reqstool/reqstool-python-decorators + +requirements: + - id: DECORATORS_001 + title: Decorator definitions + significance: shall + description: >- + The library shall provide Requirements and SVCs decorator factories that attach a + tuple of string IDs (requirements or svc_ids respectively) to the decorated function, + async function, or class as an attribute, without altering the decorated callable's + identity or name. + categories: + - functional-suitability + revision: "0.1.0" + + - id: DECORATORS_002 + title: Decorator processing + significance: shall + description: >- + The processor shall discover Python source files under a given set of directories, + parse each file's AST to find functions, async functions, and classes decorated with + Requirements or SVCs, and group the collected IDs into an implementations/tests + structure keyed by requirement or SVC ID, with each entry recording the fully + qualified name and a normalized element kind (FUNCTION/ASYNCFUNCTION mapped to + METHOD). + categories: + - functional-suitability + revision: "0.1.0" + + - id: DECORATORS_003 + title: YAML export + significance: shall + description: >- + The processor shall serialize the processed implementations/tests structure to a + YAML file at the given output path, prefixed with a JSON schema header comment, + creating any missing parent directories first. + 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..d95d73f --- /dev/null +++ b/docs/reqstool/software_verification_cases.yml @@ -0,0 +1,20 @@ +# 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_DECORATORS_001 + requirement_ids: ["DECORATORS_001"] + title: "Verify Requirements and SVCs attach ID tuples to functions, async functions, and classes" + verification: automated-test + revision: "0.1.0" + + - id: SVC_DECORATORS_002 + requirement_ids: ["DECORATORS_002"] + title: "Verify file discovery, AST extraction, element-kind mapping, and ID grouping into implementations/tests" + verification: automated-test + revision: "0.1.0" + + - id: SVC_DECORATORS_003 + requirement_ids: ["DECORATORS_003"] + title: "Verify the exported YAML matches the expected schema-tagged implementations/tests structure" + 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/decorator-definitions/spec.md b/openspec/specs/decorator-definitions/spec.md new file mode 100644 index 0000000..a1ae487 --- /dev/null +++ b/openspec/specs/decorator-definitions/spec.md @@ -0,0 +1,15 @@ +# Decorator Definitions 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: DECORATORS_001 +The system SHALL implement DECORATORS_001. + +#### Scenario: SVC_DECORATORS_001 +The system SHALL pass SVC_DECORATORS_001. diff --git a/openspec/specs/decorator-processing/spec.md b/openspec/specs/decorator-processing/spec.md new file mode 100644 index 0000000..9d6cb85 --- /dev/null +++ b/openspec/specs/decorator-processing/spec.md @@ -0,0 +1,15 @@ +# Decorator Processing 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: DECORATORS_002 +The system SHALL implement DECORATORS_002. + +#### Scenario: SVC_DECORATORS_002 +The system SHALL pass SVC_DECORATORS_002. diff --git a/openspec/specs/yaml-export/spec.md b/openspec/specs/yaml-export/spec.md new file mode 100644 index 0000000..8276fab --- /dev/null +++ b/openspec/specs/yaml-export/spec.md @@ -0,0 +1,15 @@ +# YAML Export 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: DECORATORS_003 +The system SHALL implement DECORATORS_003. + +#### Scenario: SVC_DECORATORS_003 +The system SHALL pass SVC_DECORATORS_003. diff --git a/scripts/generate_annotations.py b/scripts/generate_annotations.py new file mode 100644 index 0000000..6fe8baa --- /dev/null +++ b/scripts/generate_annotations.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 +# Copyright © LFV +"""Self-apply the decorator 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/resources holds fixture files with intentionally-fake REQ_*/SVC_* IDs + # (used by tests/e2e to exercise the processor against real decorator syntax) -- + # excluded here so they don'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_decorators/decorators/decorators.py b/src/reqstool_python_decorators/decorators/decorators.py index 7a9ce0c..aeaa9f2 100644 --- a/src/reqstool_python_decorators/decorators/decorators.py +++ b/src/reqstool_python_decorators/decorators/decorators.py @@ -11,6 +11,12 @@ def decorator(func: T) -> T: return decorator +# Tagged here rather than on Requirements: by the time SVCs is defined, Requirements +# is already bound in module scope, so it can reference it as a decorator. Requirements +# can't self-tag this way (its own name isn't bound yet at its own def statement), but +# both factories jointly implement DECORATORS_001 -- this tag location is just where +# that fact gets recorded. +@Requirements("DECORATORS_001") def SVCs[T: Callable](*svc_ids: str) -> Callable[[T], T]: def decorator(func: T) -> T: func.svc_ids = svc_ids # type: ignore[attr-defined] diff --git a/src/reqstool_python_decorators/processors/decorator_processor.py b/src/reqstool_python_decorators/processors/decorator_processor.py index 895eda8..9165941 100644 --- a/src/reqstool_python_decorators/processors/decorator_processor.py +++ b/src/reqstool_python_decorators/processors/decorator_processor.py @@ -6,6 +6,8 @@ from ruamel.yaml import YAML import ast +from reqstool_python_decorators.decorators.decorators import Requirements + @unique class DECORATOR_TYPES(Enum): @@ -79,6 +81,7 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.req_svc_results: Results = [] + @Requirements("DECORATORS_002") def find_python_files(self, directory: str | os.PathLike) -> list[str]: """ Find Python files in the given directory. @@ -96,6 +99,7 @@ def find_python_files(self, directory: str | os.PathLike) -> list[str]: python_files.append(os.path.join(root, file)) return python_files + @Requirements("DECORATORS_002") def get_functions_and_classes(self, file_path: str | os.PathLike, decorator_names: list[str]) -> None: """ Get information about functions and classes, if annotated with "Requirements" or "SVCs": @@ -133,6 +137,7 @@ def get_functions_and_classes(self, file_path: str | os.PathLike, decorator_name } ) + @Requirements("DECORATORS_003") def write_to_yaml(self, output_file: str | os.PathLike, formatted_data) -> None: """ Write formatted data to a YAML file. @@ -158,10 +163,12 @@ def write_to_yaml(self, output_file: str | os.PathLike, formatted_data) -> None: yaml_file.write(self.yaml_language_server) yaml.dump(formatted_data, yaml_file) + @Requirements("DECORATORS_002") def map_type(self, input_str) -> str: mapping = {item.from_value: item.to_value for item in DECORATOR_TYPES} return mapping.get(input_str, input_str) + @Requirements("DECORATORS_002") def format_results(self, results: Results) -> FormattedData: """ Format the collected results into a structured data format for YAML. @@ -201,6 +208,7 @@ def format_results(self, results: Results) -> FormattedData: return formatted_data + @Requirements("DECORATORS_003") def create_dir_from_path(self, filepath: str | os.PathLike) -> None: """ Creates directory of provided filepath if it does not exists @@ -212,6 +220,7 @@ def create_dir_from_path(self, filepath: str | os.PathLike) -> None: if not os.path.exists(directory): os.makedirs(directory) + @Requirements("DECORATORS_002", "DECORATORS_003") def process_decorated_data( self, path_to_python_files: list[str | os.PathLike], diff --git a/tests/e2e/reqstool_decorators/test_process_e2e.py b/tests/e2e/reqstool_decorators/test_process_e2e.py new file mode 100644 index 0000000..4088ec3 --- /dev/null +++ b/tests/e2e/reqstool_decorators/test_process_e2e.py @@ -0,0 +1,46 @@ +# Copyright © LFV + +import os + +import pytest +from reqstool_python_decorators.decorators.decorators import SVCs +from reqstool_python_decorators.processors.decorator_processor import DecoratorProcessor +from ruamel.yaml import YAML + + +@pytest.mark.e2e +@SVCs("SVC_DECORATORS_002", "SVC_DECORATORS_003") +def test_process_decorated_data_against_fixture_files(tmp_path): + """Runs the full pipeline against tests/resources/test_decorators, real + @Requirements/@SVCs-decorated fixture files rather than synthetic ASTs.""" + + tests_rootdir = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) + fixtures_dir = os.path.join(tests_rootdir, "resources", "test_decorators") + output_file = tmp_path / "annotations.yml" + + DecoratorProcessor().process_decorated_data(path_to_python_files=[fixtures_dir], output_file=output_file) + + yaml_language_server = "# yaml-language-server: $schema=https://raw.githubusercontent.com/reqstool/reqstool-client/main/src/reqstool/resources/schemas/v1/annotations.schema.json\n" # noqa: E501 + + with open(output_file) as f: + raw = f.read() + assert raw.startswith(yaml_language_server) + + yaml = YAML() + data = yaml.load(raw) + + implementations = data["requirement_annotations"]["implementations"] + tests = data["requirement_annotations"]["tests"] + + assert {"REQ_001", "REQ_222", "REQ_333", "REQ_444"} <= implementations.keys() + assert {"SVC_999", "SVC_123"} <= tests.keys() + + assert implementations["REQ_001"][0]["elementKind"] == "CLASS" + assert implementations["REQ_333"][0]["elementKind"] == "METHOD" + assert implementations["REQ_444"][0]["elementKind"] == "METHOD" + + # fullyQualifiedName is built from the real on-disk file path, not a synthetic AST + assert implementations["REQ_333"][0]["fullyQualifiedName"].endswith("requirements_decorators.requirements_function") + + # SVC_999 is declared on both test functions in svc_decorators.py + assert len(tests["SVC_999"]) == 2 diff --git a/tests/unit/reqstool_decorators/processors/test_processors.py b/tests/unit/reqstool_decorators/processors/test_processors.py index 1f52b82..def8265 100644 --- a/tests/unit/reqstool_decorators/processors/test_processors.py +++ b/tests/unit/reqstool_decorators/processors/test_processors.py @@ -1,4 +1,5 @@ import pytest +from reqstool_python_decorators.decorators.decorators import SVCs from reqstool_python_decorators.processors.decorator_processor import DecoratorProcessor from ruamel.yaml import YAML @@ -13,17 +14,20 @@ def process_decorator_instance(): # --------------------------------------------------------------------------- +@SVCs("SVC_DECORATORS_002") def test_find_python_files(process_decorator_instance: DecoratorProcessor, tmp_path): (tmp_path / "pythonfile.py").write_text("content") result = process_decorator_instance.find_python_files(tmp_path) assert result == [str(tmp_path / "pythonfile.py")] +@SVCs("SVC_DECORATORS_002") def test_find_python_files_empty_dir(process_decorator_instance: DecoratorProcessor, tmp_path): result = process_decorator_instance.find_python_files(tmp_path) assert result == [] +@SVCs("SVC_DECORATORS_002") def test_find_python_files_nested(process_decorator_instance: DecoratorProcessor, tmp_path): sub = tmp_path / "sub" sub.mkdir() @@ -32,6 +36,7 @@ def test_find_python_files_nested(process_decorator_instance: DecoratorProcessor assert str(sub / "nested.py") in result +@SVCs("SVC_DECORATORS_002") def test_find_python_files_ignores_non_py(process_decorator_instance: DecoratorProcessor, tmp_path): (tmp_path / "readme.txt").write_text("content") result = process_decorator_instance.find_python_files(tmp_path) @@ -43,6 +48,7 @@ def test_find_python_files_ignores_non_py(process_decorator_instance: DecoratorP # --------------------------------------------------------------------------- +@SVCs("SVC_DECORATORS_002") def test_get_functions_and_classes(process_decorator_instance: DecoratorProcessor, tmp_path): file_path = tmp_path / "test_file.py" file_path.write_text('@SVCs("SVC_001")\nclass Test:\n pass') @@ -53,6 +59,7 @@ def test_get_functions_and_classes(process_decorator_instance: DecoratorProcesso assert process_decorator_instance.req_svc_results[0]["elementKind"] == "CLASS" +@SVCs("SVC_DECORATORS_002") def test_get_functions_and_classes_function_def(process_decorator_instance: DecoratorProcessor, tmp_path): file_path = tmp_path / "f.py" file_path.write_text('@Requirements("REQ_001")\ndef my_func():\n pass') @@ -63,6 +70,7 @@ def test_get_functions_and_classes_function_def(process_decorator_instance: Deco assert result["elementKind"] == "FUNCTION" +@SVCs("SVC_DECORATORS_002") def test_get_functions_and_classes_async_function_def(process_decorator_instance: DecoratorProcessor, tmp_path): file_path = tmp_path / "af.py" file_path.write_text('@Requirements("REQ_001")\nasync def my_async():\n pass') @@ -73,6 +81,7 @@ def test_get_functions_and_classes_async_function_def(process_decorator_instance assert result["elementKind"] == "ASYNCFUNCTION" +@SVCs("SVC_DECORATORS_002") def test_get_functions_and_classes_multiple_args(process_decorator_instance: DecoratorProcessor, tmp_path): file_path = tmp_path / "m.py" file_path.write_text('@Requirements("A", "B")\ndef func():\n pass') @@ -81,6 +90,7 @@ def test_get_functions_and_classes_multiple_args(process_decorator_instance: Dec assert args == ["A", "B"] +@SVCs("SVC_DECORATORS_002") @pytest.mark.parametrize( "code,expected_kind", [ @@ -99,6 +109,7 @@ def test_get_functions_and_classes_element_kind( assert process_decorator_instance.req_svc_results[0]["elementKind"] == expected_kind +@SVCs("SVC_DECORATORS_002") def test_get_functions_and_classes_no_match(process_decorator_instance: DecoratorProcessor, tmp_path): file_path = tmp_path / "n.py" file_path.write_text('@OtherDecorator("X")\ndef func():\n pass') @@ -106,6 +117,7 @@ def test_get_functions_and_classes_no_match(process_decorator_instance: Decorato assert process_decorator_instance.req_svc_results == [] +@SVCs("SVC_DECORATORS_002") def test_get_functions_and_classes_multiple_decorators_on_func( process_decorator_instance: DecoratorProcessor, tmp_path ): @@ -124,6 +136,7 @@ def test_get_functions_and_classes_multiple_decorators_on_func( # --------------------------------------------------------------------------- +@SVCs("SVC_DECORATORS_002") def test_map_type_known_type(process_decorator_instance: DecoratorProcessor): map_funcion = process_decorator_instance.map_type("FUNCTION") map_asyncfunction = process_decorator_instance.map_type("ASYNCFUNCTION") @@ -131,6 +144,7 @@ def test_map_type_known_type(process_decorator_instance: DecoratorProcessor): assert map_asyncfunction == "METHOD" +@SVCs("SVC_DECORATORS_002") def test_map_type_unknown_type(process_decorator_instance: DecoratorProcessor): result = process_decorator_instance.map_type("CLASS") assert result == "CLASS" @@ -141,6 +155,7 @@ def test_map_type_unknown_type(process_decorator_instance: DecoratorProcessor): # --------------------------------------------------------------------------- +@SVCs("SVC_DECORATORS_002") def test_format_results_implementations(process_decorator_instance: DecoratorProcessor): results = [ { @@ -154,6 +169,7 @@ def test_format_results_implementations(process_decorator_instance: DecoratorPro assert "REQ_001" in data["requirement_annotations"]["implementations"] +@SVCs("SVC_DECORATORS_002") def test_format_results_tests(process_decorator_instance: DecoratorProcessor): results = [ { @@ -167,6 +183,7 @@ def test_format_results_tests(process_decorator_instance: DecoratorProcessor): assert "SVC_001" in data["requirement_annotations"]["tests"] +@SVCs("SVC_DECORATORS_002") def test_format_results_multiple_ids(process_decorator_instance: DecoratorProcessor): results = [ { @@ -182,6 +199,7 @@ def test_format_results_multiple_ids(process_decorator_instance: DecoratorProces assert "REQ_002" in impls +@SVCs("SVC_DECORATORS_002") def test_format_results_fully_qualified_name(process_decorator_instance: DecoratorProcessor): results = [ { @@ -201,6 +219,7 @@ def test_format_results_fully_qualified_name(process_decorator_instance: Decorat # --------------------------------------------------------------------------- +@SVCs("SVC_DECORATORS_003") def test_write_to_yaml(process_decorator_instance: DecoratorProcessor, tmp_path): yaml_language_server = "# yaml-language-server: $schema=https://raw.githubusercontent.com/reqstool/reqstool-client/main/src/reqstool/resources/schemas/v1/annotations.schema.json\n" # noqa: E501 @@ -229,6 +248,7 @@ def test_write_to_yaml(process_decorator_instance: DecoratorProcessor, tmp_path) # --------------------------------------------------------------------------- +@SVCs("SVC_DECORATORS_003") def test_create_dir_from_path_creates_dir(process_decorator_instance: DecoratorProcessor, tmp_path): new_dir = tmp_path / "new_subdir" filepath = str(new_dir / "output.yml") @@ -236,6 +256,7 @@ def test_create_dir_from_path_creates_dir(process_decorator_instance: DecoratorP assert new_dir.exists() +@SVCs("SVC_DECORATORS_003") def test_create_dir_from_path_existing_dir(process_decorator_instance: DecoratorProcessor, tmp_path): filepath = str(tmp_path / "output.yml") # tmp_path already exists — should not raise @@ -248,6 +269,7 @@ def test_create_dir_from_path_existing_dir(process_decorator_instance: Decorator # --------------------------------------------------------------------------- +@SVCs("SVC_DECORATORS_002", "SVC_DECORATORS_003") def test_process_decorated_data_produces_yaml(process_decorator_instance: DecoratorProcessor, tmp_path): src_file = tmp_path / "src" / "app.py" src_file.parent.mkdir() @@ -261,6 +283,7 @@ def test_process_decorated_data_produces_yaml(process_decorator_instance: Decora assert output_file.exists() +@SVCs("SVC_DECORATORS_002", "SVC_DECORATORS_003") def test_process_decorated_data_correct_structure(process_decorator_instance: DecoratorProcessor, tmp_path): src_file = tmp_path / "src" / "app.py" src_file.parent.mkdir() @@ -279,6 +302,7 @@ def test_process_decorated_data_correct_structure(process_decorator_instance: De assert "REQ_001" in data["requirement_annotations"]["implementations"] +@SVCs("SVC_DECORATORS_002", "SVC_DECORATORS_003") def test_process_decorated_data_no_state_accumulation(process_decorator_instance: DecoratorProcessor, tmp_path): src_file = tmp_path / "src" / "app.py" src_file.parent.mkdir() diff --git a/tests/unit/reqstool_decorators/test_decorators.py b/tests/unit/reqstool_decorators/test_decorators.py index b0432cc..777bd5f 100644 --- a/tests/unit/reqstool_decorators/test_decorators.py +++ b/tests/unit/reqstool_decorators/test_decorators.py @@ -1,59 +1,68 @@ # Copyright © LFV +import pytest from reqstool_python_decorators.decorators.decorators import Requirements, SVCs +# NOTE: these tests apply Requirements/SVCs via direct call (`Requirements(...)(func)`) +# rather than `@Requirements(...)` decorator syntax. Decorator syntax would make the +# nested function/class defs below show up as real, AST-discoverable annotations when +# this repo self-applies its own processor (see scripts/generate_annotations.py) -- +# polluting the project's own requirement/SVC traceability data with these fake IDs. -def test_requirements_sets_attribute(): - @Requirements("REQ_001") - def func(): - pass - assert func.requirements == ("REQ_001",) +def _function(): + pass -def test_requirements_multiple_ids(): - @Requirements("A", "B") - def func(): - pass +async def _async_function(): + pass - assert func.requirements == ("A", "B") +class _Class: + pass -def test_requirements_preserves_function_name(): - @Requirements("REQ_001") - def my_function(): - pass - assert my_function.__name__ == "my_function" +@SVCs("SVC_DECORATORS_001") +@pytest.mark.parametrize( + "decorator_factory,attr_name,target", + [ + (Requirements, "requirements", _function), + (Requirements, "requirements", _async_function), + (Requirements, "requirements", _Class), + (SVCs, "svc_ids", _function), + (SVCs, "svc_ids", _async_function), + (SVCs, "svc_ids", _Class), + ], + ids=[ + "requirements-on-function", + "requirements-on-async-function", + "requirements-on-class", + "svcs-on-function", + "svcs-on-async-function", + "svcs-on-class", + ], +) +def test_sets_attribute_on_target(decorator_factory, attr_name, target): + decorated = decorator_factory("REQ_001")(target) + assert getattr(decorated, attr_name) == ("REQ_001",) -def test_svcs_sets_attribute(): - @SVCs("SVC_001") - def func(): - pass - assert func.svc_ids == ("SVC_001",) +@SVCs("SVC_DECORATORS_001") +@pytest.mark.parametrize( + "decorator_factory,attr_name", + [(Requirements, "requirements"), (SVCs, "svc_ids")], + ids=["requirements", "svcs"], +) +def test_multiple_ids(decorator_factory, attr_name): + target = decorator_factory("A", "B")(_function) + assert getattr(target, attr_name) == ("A", "B") -def test_svcs_multiple_ids(): - @SVCs("A", "B") - def func(): - pass - assert func.svc_ids == ("A", "B") +@SVCs("SVC_DECORATORS_001") +@pytest.mark.parametrize("decorator_factory", [Requirements, SVCs], ids=["requirements", "svcs"]) +def test_preserves_function_name(decorator_factory): + target = decorator_factory("REQ_001")(_function) - -def test_svcs_preserves_function_name(): - @SVCs("SVC_001") - def my_function(): - pass - - assert my_function.__name__ == "my_function" - - -def test_requirements_on_class(): - @Requirements("REQ_001") - class MyClass: - pass - - assert MyClass.requirements == ("REQ_001",) + assert target.__name__ == "_function"