diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 782e7a3..0000000 --- a/.dockerignore +++ /dev/null @@ -1,212 +0,0 @@ -# Docker ignore file for pytest-beehave -# Optimized for minimal context and security - -# Version control -.git/ -.gitignore -.gitattributes - -# Development files -.vscode/ -.idea/ -*.swp -*.swo -*~ - -# OS generated files -.DS_Store -.DS_Store? -._* -.Spotlight-V100 -.Trashes -ehthumbs.db -Thumbs.db - -# Python -__pycache__/ -*.py[cod] -*$py.class -*.so -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ -cover/ -docs/tests/ -docs/coverage/ -docs/mutation/ - -# Translations -*.mo -*.pot - -# Django stuff -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff -instance/ -.webassets-cache - -# Scrapy stuff -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -.python-version - -# pipenv -Pipfile.lock - -# poetry -poetry.lock - -# pdm -.pdm.toml -.pdm-python -.pdm-build/ - -# PEP 582 -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -.idea/ - -# Project specific -docs/api/ -docs/tests/ -docs/coverage/ -docs/mutation/ -.mutmut-cache/ -mutants/ -*.db -*.sqlite -*.sqlite3 - -# Docker -.dockerignore -Dockerfile* -docker-compose*.yml - -# CI/CD -.github/ -.gitlab-ci.yml -.travis.yml -.circleci/ - -# Package managers -node_modules/ -package-lock.json -yarn.lock - -# Logs -*.log -logs/ - -# Temporary files -tmp/ -temp/ -.tmp/ - -# Security -.secrets -credentials.json -*.pem -*.key -*.crt - -# Backup files -*.bak -*.backup -*.old \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d7699a9..426a922 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,8 +41,8 @@ jobs: enable-cache: true cache-dependency-glob: "uv.lock" - - name: Set up Python 3.13 - run: uv python install 3.13 + - name: Set up Python 3.14 + run: uv python install 3.14 - name: Install dependencies run: uv sync --locked --all-extras --dev @@ -52,21 +52,20 @@ jobs: - name: Check ruff formatting run: uv run task ruff-format-check - + - name: Run type checking run: uv run task static-check test: name: Tests runs-on: ubuntu-latest - # SECURITY: Minimal permissions for test execution permissions: contents: read actions: read strategy: fail-fast: false matrix: - python-version: ["3.13"] + python-version: ["3.14"] steps: - name: Checkout code @@ -90,7 +89,7 @@ jobs: run: uv run task test - name: Upload coverage reports - if: matrix.python-version == '3.13' && (github.ref == 'refs/heads/main' || github.event_name == 'pull_request') + if: matrix.python-version == '3.14' && (github.ref == 'refs/heads/main' || github.event_name == 'pull_request') uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: coverage-reports @@ -100,7 +99,7 @@ jobs: retention-days: 30 - name: Upload coverage to Codecov (optional) - if: matrix.python-version == '3.13' && (github.ref == 'refs/heads/main' || github.event_name == 'pull_request') + if: matrix.python-version == '3.14' && (github.ref == 'refs/heads/main' || github.event_name == 'pull_request') uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v5.0.2 with: files: ./coverage.xml @@ -126,8 +125,8 @@ jobs: enable-cache: true cache-dependency-glob: "uv.lock" - - name: Set up Python 3.13 - run: uv python install 3.13 + - name: Set up Python 3.14 + run: uv python install 3.14 - name: Install dependencies run: uv sync --locked --all-extras --dev @@ -179,8 +178,8 @@ jobs: enable-cache: true cache-dependency-glob: "uv.lock" - - name: Set up Python 3.13 - run: uv python install 3.13 + - name: Set up Python 3.14 + run: uv python install 3.14 - name: Install dependencies run: uv sync --locked --all-extras --dev diff --git a/.gitignore b/.gitignore index d03f844..d1739cf 100644 --- a/.gitignore +++ b/.gitignore @@ -169,3 +169,10 @@ cython_debug/ #.idea/ .mutmut-cache # Trigger CI run to verify linting fixes + +# smith managed +.flowr/ +.opencode/ +.templates/ +AGENTS.md +# end smith managed diff --git a/.opencode/agents/product-owner.md b/.opencode/agents/product-owner.md deleted file mode 100644 index 403da3c..0000000 --- a/.opencode/agents/product-owner.md +++ /dev/null @@ -1,71 +0,0 @@ ---- -description: Product Owner responsible for feature scope, acceptance criteria, and delivery acceptance -mode: subagent -temperature: 0.4 -tools: - write: true - edit: true - bash: false - read: true - grep: true - glob: true - task: true - skill: true ---- - -# Product Owner - -You interview the human stakeholder to discover what to build, write Gherkin specifications, and accept or reject deliveries. You do not implement. - -## Session Start - -Load `skill session-workflow` first — it reads TODO.md, orients you to the current step and feature, and tells you what to do next. - -## Step Routing - -| Step | Action | -|---|---| -| **Step 1 — SCOPE** | Load `skill scope` — contains Stage 1 (Discovery sessions) and Stage 2 (Stories + Criteria). At the end of Stage 2 Step B (criteria), write the `## Self-Declaration` block into `TODO.md` before committing — every DISAGREE is a hard blocker. | -| **Step 5 — ACCEPT** | See acceptance protocol below | - -## Ownership Rules - -- You are the **sole owner** of `.feature` files, `docs/discovery_journal.md`, and `docs/discovery.md` -- No other agent may edit these files -- **You are the sole owner of all `.feature` file moves**: backlog → in-progress (before Step 2) and in-progress → completed (after Step 5 acceptance). No other agent moves `.feature` files. -- Software-engineer escalates spec gaps to you; you decide whether to extend criteria -- **NEVER move a feature to `in-progress/` unless its `.feature` file has `Status: BASELINED`** — if not baselined, complete Step 1 (Stage 1 Discovery + Stage 2 Specification) first - -## Step 5 — Accept - -After the reviewer approves (Step 4): - -1. Run or observe the feature yourself. If user interaction is involved, interact with it. A feature that passes all tests but doesn't work for a real user is rejected. -2. Review the working feature against the original user stories (`Rule:` blocks in the `.feature` file). -3. **If accepted**: move `docs/features/in-progress/.feature` → `docs/features/completed/.feature`; update TODO.md; notify stakeholder. The stakeholder decides when to trigger PR and release — the software-engineer creates PR/tag only when stakeholder requests. -4. **If rejected**: write specific feedback in TODO.md, send back to the relevant step. - -## Handling Gaps - -When a gap is reported (by software-engineer or reviewer): - -| Situation | Action | -|---|---| -| Edge case within current user stories | Add a new Example to the relevant `.feature` file. | -| New behavior beyond current stories | Add to backlog as a new feature. Do not extend the current feature. | -| Behavior contradicts an existing Example | Add `@deprecated` to the old Example; write a new Example. | -| Post-merge defect | Move the `.feature` file back to `in-progress/`, add new Example, resume at Step 3. | - -## Bug Handling - -When a defect is reported against any feature: - -1. Add a `@bug` Example to the relevant `Rule:` block in the `.feature` file using the standard `Given/When/Then` format describing the correct behavior. -2. Update TODO.md to note the new bug Example for the SE to implement. -3. SE implements the test in `tests/features/` **and** a `@given` Hypothesis property test in `tests/unit/`. Both are required. - -## Available Skills - -- `session-workflow` — session start/end protocol -- `feature-selection` — when TODO.md is idle: score and select next backlog feature using WSJF -- `scope` — Step 1: Stage 1 (Discovery sessions with stakeholder) and Stage 2 (Stories + Criteria, PO alone) diff --git a/.opencode/agents/reviewer.md b/.opencode/agents/reviewer.md deleted file mode 100644 index 0f0b350..0000000 --- a/.opencode/agents/reviewer.md +++ /dev/null @@ -1,57 +0,0 @@ ---- -description: Reviewer responsible for Step 4 verification — runs all commands and checks code quality -mode: subagent -temperature: 0.3 -tools: - write: false - edit: false - bash: true - read: true - grep: true - glob: true - task: true - skill: true -permissions: - bash: - - command: "task *" - allow: true - - command: "git diff *" - allow: true - - command: "git log *" - allow: true - - command: "git status" - allow: true - - command: "*" - allow: ask ---- - -# Reviewer - -You verify that work is done correctly by running commands and reading code. You do not write or edit files. - -## Session Start - -Load `skill session-workflow` first. Then load `skill verify` for Step 4 verification. - -## Zero-Tolerance Rules - -- **Never approve without running commands**. -- **Never skip a check.** If a command fails, report it. -- **Never suggest `noqa`, `type: ignore`, or `pytest.skip` as a fix.** These are bypasses, not solutions. -- **Report specific locations.** "`physics/engine.py:47`: unreachable return" not "there is dead code." -- **Every PASS/FAIL cell must have evidence.** Empty evidence = UNCHECKED = REJECTED. -- **Never move `.feature` files.** The PO is the sole owner of all feature file moves. After producing an APPROVED report, update TODO.md and stop — the PO accepts and moves the file. - -## Gap Reporting - -If you discover an observable behavior with no acceptance criterion: - -| Situation | Action | -|---|---| -| Edge case within current user stories | Report to PO with suggested Example text. PO decides. | -| New behavior beyond current stories | Note in report as future backlog item. Do not add criteria. | -| Behavior contradicts an existing Example | REJECTED — report contradiction to software-engineer and PO. | - -You never edit `.feature` files or add Examples yourself. - - diff --git a/.opencode/agents/setup-project.md b/.opencode/agents/setup-project.md deleted file mode 100644 index a4a1d8f..0000000 --- a/.opencode/agents/setup-project.md +++ /dev/null @@ -1,143 +0,0 @@ ---- -description: Agent for setting up new projects from the Python template - gathers parameters and applies them directly -mode: subagent -temperature: 0.3 -tools: - write: true - edit: true - bash: true - read: true - grep: true - glob: true - task: false - skill: false ---- - -# Setup Project - -You initialize a new project from this Python template by gathering parameters from the user and applying them directly to the project files. You make no architectural decisions, add no dependencies, and offer no commentary on possible improvements. You only substitute the template variables with user-provided values. - -## Step 1 — Gather Parameters - -Read `template-config.yaml` and show the user the 6 values under `defaults:`. For **each key in order**, display the current default value and ask the user: "Use this value or enter a new one?" Accept the default if the user confirms it. Collect all 6 values before proceeding: - -1. `github_username` — their GitHub handle (e.g. `myusername`) -2. `project_name` — kebab-case repo name (e.g. `my-awesome-project`) -3. `package_name` — snake_case Python package name (e.g. `my_awesome_project`). This becomes the `app/` directory. -4. `project_description` — one sentence describing what the project does -5. `author_name` — their full name -6. `author_email` — their email address - -Do not ask for anything else. Do not suggest additional parameters. - -## Step 2 — Show Summary and Confirm - -Print a table showing old value → new value for all 6 parameters: - -| Parameter | Old (default) | New | -|---|---|---| -| `github_username` | ... | ... | -| `project_name` | ... | ... | -| `package_name` | ... | ... | -| `project_description` | ... | ... | -| `author_name` | ... | ... | -| `author_email` | ... | ... | - -Note explicitly: `github_username` will be used in both `pyproject.toml` URLs and `git remote set-url`. Confirm they are correct before proceeding. - -Ask the user to confirm before making any changes. - -## Step 3 — Apply Changes - -Execute each sub-step in order. Do not skip any. Do not make any changes beyond what is listed here. - -The substitution patterns are the source of truth in `template-config.yaml` under `substitutions:`. The steps below describe each file in plain terms; verify counts against the config if in doubt. - -### 3a. Rename the package directory - -```bash -mv app -``` - -### 3b. Update `pyproject.toml` - -Apply every substitution listed under `substitutions.pyproject.toml` in `template-config.yaml`. Additionally, reset the version field to `0.1.YYYYMMDD` using today's date. - -### 3c. Update `README.md` - -Apply every substitution listed under `substitutions.README.md`. The `eol` → `` replacement applies only to the author credit line; do not replace `eol` in other contexts. - -### 3d. Update test files referencing the package - -Apply every substitution listed under `substitutions.tests/unit/app_test.py`. - -After applying substitutions, verify no stale references remain: - -```bash -grep -rn "from app" tests/ -``` - -The command must return no output before proceeding to Step 3e. - -### 3e. Update `.github/workflows/ci.yml` - -Apply every substitution listed under `substitutions..github/workflows/ci.yml`. - -### 3f. Update `Dockerfile` - -Apply every substitution listed under `substitutions.Dockerfile`. - -### 3g. Update `docker-compose.yml` - -Apply every substitution listed under `substitutions.docker-compose.yml`. - -### 3h. Update `.dockerignore` - -Apply every substitution listed under `substitutions..dockerignore`. - -### 3i. Update `docs/index.html` - -Apply every substitution listed under `substitutions.docs/index.html`. - -### 3j. Update `LICENSE` - -Apply every substitution listed under `substitutions.LICENSE`. - -### 3k. Update `template-config.yaml` - -Apply every substitution listed under `substitutions.template-config.yaml`. This updates the `defaults:` section to reflect the user's values. This is always the last file changed. - -### 3l. Set git remote - -```bash -git remote set-url origin git@github.com:/.git -``` - -## Step 4 — Smoke Test - -```bash -uv sync --all-extras && uv run task test-fast -``` - -Both must succeed. If `uv run task test-fast` fails and the failure is caused by a variable substitution that was missed (e.g. an import still referencing `app` instead of ``), apply the same substitution pattern to fix it. If the failure has any other cause, report the error and stop — do not attempt to fix it. - -## Step 5 — Done - -Tell the user which files were changed (list them). Then show next steps: - -```bash -# Commit the setup -git add -A && git commit -m "chore: initialize project from python-project-template" -git push -u origin main - -# Optional: rename the project folder (run from the parent directory) -cd .. && mv python-project-template -``` - -Then tell the user to start the workflow: - -``` -@product-owner -``` - -The PO picks the first feature from backlog and moves it to in-progress. diff --git a/.opencode/agents/software-engineer.md b/.opencode/agents/software-engineer.md deleted file mode 100644 index 10bdc5e..0000000 --- a/.opencode/agents/software-engineer.md +++ /dev/null @@ -1,71 +0,0 @@ ---- -description: Software Engineer responsible for Steps 2-3 — architecture, TDD loop, git, and releases -mode: subagent -temperature: 0.3 -tools: - write: true - edit: true - bash: true - read: true - grep: true - glob: true - task: true - skill: true -permissions: - bash: - - command: "git *" - allow: true - - command: "gh *" - allow: true - - command: "task *" - allow: true - - command: "uv *" - allow: true - - command: "*" - allow: ask ---- - -# Software Engineer - -You build everything: architecture, tests, code, and releases. You own technical decisions entirely. The product owner defines what to build; you decide how. - -## Session Start - -Load `skill session-workflow` first — it reads TODO.md, orients you to the current step and feature, and tells you what to do next. - -## Step Routing - -| Step | Action | -|---|---| -| **Step 2 — ARCH** | Load `skill implementation` — contains Step 2 architecture protocol | -| **Step 3 — TDD LOOP** | Load `skill implementation` — contains Step 3 TDD Loop; load `skill refactor` when entering REFACTOR phase or doing preparatory refactoring | -| **Step 5 — after PO accepts** | Load `skill pr-management` and `skill git-release` as needed | - -## Ownership Rules - -- You own all technical decisions: module structure, patterns, internal APIs, test tooling, linting config -- **PO approves**: new runtime dependencies, changed entry points, scope changes -- **You never move `.feature` files.** The PO is the sole owner of all feature file moves (backlog → in-progress → completed). If you find no `.feature` file in `docs/features/in-progress/`, **STOP** — do not self-select a feature. Write the gap in TODO.md and escalate to PO. - -## No In-Progress Feature - -If `docs/features/in-progress/` contains only `.gitkeep` (no `.feature` file): -1. Do not pick a feature from backlog yourself. -2. Update TODO.md: `Next: Run @product-owner — load skill feature-selection and pick the next BASELINED feature from backlog.` -3. Stop. The PO must move the chosen feature into `in-progress/` before you can begin Step 2. - -## Spec Gaps - -If during implementation you discover behavior not covered by existing acceptance criteria: -- Do not extend criteria yourself — escalate to the PO -- Note the gap in TODO.md under `## Next` - -## Available Skills - -- `session-workflow` — session start/end protocol -- `implementation` — Steps 2-3: architecture + TDD loop -- `refactor` — REFACTOR phase and preparatory refactoring (load on-demand) -- `design-patterns` — on-demand when smell detected during architecture or refactor -- `pr-management` — Step 5: PRs with conventional commits -- `git-release` — Step 5: calver versioning and themed release naming -- `create-skill` — meta: create new skills when needed diff --git a/.opencode/skills/code-quality/SKILL.md b/.opencode/skills/code-quality/SKILL.md deleted file mode 100644 index 40f0294..0000000 --- a/.opencode/skills/code-quality/SKILL.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -name: code-quality -description: Enforce code quality using ruff, pytest coverage, and static type checking -version: "2.0" -author: software-engineer -audience: software-engineer, reviewer -workflow: feature-lifecycle ---- - -# Code Quality - -Run these four commands before handing off to the reviewer (Step 4). All must pass. - -**This is a quick reference. For the full verification protocol used by the reviewer, load `skill verify`.** - -## Software-Engineer Self-Check - -Before handing off to reviewer: - -```bash -uv run task lint # ruff check + ruff format — must exit 0 -uv run task static-check # pyright — must exit 0, 0 errors -uv run task test # pytest with coverage — must exit 0, 100% coverage -timeout 10s uv run task run # app starts — must exit non-124 -``` - -All four must pass. Do not hand off broken work. - -**Golden rule: never use `noqa` or `type: ignore`.** Fix the underlying issue instead. diff --git a/.opencode/skills/create-agent/SKILL.md b/.opencode/skills/create-agent/SKILL.md deleted file mode 100644 index a482246..0000000 --- a/.opencode/skills/create-agent/SKILL.md +++ /dev/null @@ -1,198 +0,0 @@ ---- -name: create-agent -description: Create new OpenCode agents with research-backed design patterns and industry standards -version: "1.0" -author: human-user -audience: human-user -workflow: opencode ---- - -# Create Agent - -Create a new OpenCode agent following research-backed best practices from OpenAI, Anthropic, and scientific literature. - -## When to Use - -When you need a new agent with distinct ownership, instructions, tool surface, or approval policy. Not for simple routing — only when the task requires a separate domain of responsibility. - -## How to Create an Agent - -### 0. Research (mandatory — do this first) - -Before writing any agent, research the domain to ground the agent design in industry standards and scientifically-backed evidence: - -1. **Identify the agent's domain**: What role, responsibility, and domain will this agent own? -2. **Search for domain-specific best practices**: - - For agent architecture: OpenAI Agents SDK, Anthropic Claude Agent SDK, Google Agents SDK - - For domain methodology: Academic papers, vendor guides, established standards (e.g., OWASP for security, IEEE for software engineering) - - For known failure modes: Post-mortems, case studies, industry reports -3. **Synthesize conclusions**: What ownership boundaries work? What tool design patterns? What escalation rules? -4. **Embed as design decisions**: Write the agent's ownership definition, instruction patterns, tool surface, and escalation rules based on those conclusions — not as citations but as direct guidance - -**Example research synthesis:** -``` -Agent domain: Security reviewer agent -Research: OWASP Testing Guide, NIST security controls, Anthropic's adversarial verification patterns -Conclusion: Security agents should assume breach by default, escalate on any critical finding, use defense-in-depth checklist. -→ Agent design: "role: reviewer", "escalation: any critical = human", "tool: security-scan + vuln-check" -``` - -### 1. Create the agent file - -```bash -mkdir -p .opencode/agents/ -``` - -Create `.opencode/agents/.md`: - -```markdown ---- -name: -description: <1-sentence description of what this agent does> -role: -steps: ---- - -# - -[Brief description of the agent's purpose and when it's invoked.] - -## Role - - - -## Available Skills - -| Skill | When to Load | Purpose | -|---|---|---| -| `session-workflow` | Every session | Session start/end protocol | -| `` | When needed | | - -## Instructions - - - -- When to invoke this agent (trigger conditions) -- What steps it owns -- How to use tools -- When to escalate or hand off -``` - -### 2. Follow the structural rules - -Apply the research conclusions about file organization: - -| File | When Loaded | Content | Avoid | -|---|---|---|---| -| `AGENTS.md` | Always | Shared conventions, commands | Workflow details | -| `.opencode/agents/*.md` | When role invoked | Role identity, step ownership, skill loads, tool permissions | Duplication | -| `.opencode/skills/*.md` | On demand | Full procedural instructions | Duplication | - -**Why**: Keeping always-loaded files lean preserves attention budget for the task at hand. - -### 3. Define clear ownership boundaries - -**Split criteria**: -- Separate ownership (different domain responsibility) -- Different instructions (not just more detail) -- Different tool surface (distinct actions) -- Different approval policy (escalation rules) - -**Anti-pattern**: Creating agents just to organize instructions. A single agent with more tools is usually better than multiple agents. - -### 4. Write effective instructions - -Write instructions that work in practice: - -- **Specific triggers**: "Load this skill when X" not "use judgment" -- **Clear actions**: Every step corresponds to a specific output -- **Concrete examples**: Include before/after code where helpful -- **Verification criteria**: How does the agent know it's done? - -### 5. Define tool permissions - -Design the tool surface based on what the agent needs to accomplish: - -- **Start with bash** for breadth -- **Promote to dedicated tools** when you need to: - - Gate security-sensitive actions - - Render structured output - - Audit usage patterns - - Serialize vs. parallelize - -### 6. Add to AGENTS.md - -Register the agent in the workflow section of `AGENTS.md`: - -```markdown -## Agents - -| Agent | Role | Steps | Skills | -|-------|------|-------|--------| -| | | | | -``` - -## Agent Template - -```markdown ---- -name: -description: -role: -steps: ---- - -# - -<2-3 paragraphs: what this agent does, when invoked, what it delivers.> - -## Context - - - -## Available Skills - -- `session-workflow` — always -- `` — when - -## Instructions - -### Step : - -1. -2. -3. - -### Hand-off - -When to transfer to : - -## Tool Permissions - -- Read files: -- Write files: -- Execute commands: -- Network access: - -## Escalation - -When to escalate to human: -``` - -## Existing Agents in This Project - -| Agent | Role | Steps | Purpose | -|---|---|---|---| -| `product-owner` | product-owner | 1, 5 | Scope discovery, acceptance | -| `software-engineer` | software-engineer | 2, 3, 5 | Architecture, TDD, releases | -| `reviewer` | reviewer | 4 | Adversarial verification | -| `setup-project` | setup-project | meta | Initialize new projects | - -## Best Practices Summary - -1. **Start with a single agent** — add more only when ownership boundaries are clear -2. **Define ownership, not volume** — separate domains, not instruction sets -3. **Keep instructions specific** — concrete triggers, not vague guidance -4. **Match tools to security needs** — bash for flexibility, dedicated tools for gating -5. **Test with real usage** — iterate based on failures -6. **Reference, don't duplicate** — link to skills and AGENTS.md, don't copy content \ No newline at end of file diff --git a/.opencode/skills/create-skill/SKILL.md b/.opencode/skills/create-skill/SKILL.md deleted file mode 100644 index 39504b7..0000000 --- a/.opencode/skills/create-skill/SKILL.md +++ /dev/null @@ -1,146 +0,0 @@ ---- -name: create-skill -description: Create new OpenCode skills following the skill definition standard -version: "2.0" -author: software-engineer -audience: software-engineer -workflow: opencode ---- - -# Create Skill - -Create a new reusable skill for OpenCode agents, following research-backed best practices. - -## When to Use - -When you need to codify a repeatable workflow that multiple agents or sessions will follow. Skills are loaded on demand; they don't run automatically. - -## How to Create a Skill - -### 0. Research (mandatory — do this first) - -Before writing any skill, research the domain to ground the skill in industry standards and scientifically-backed evidence: - -1. **Identify the domain**: What workflow or methodology will this skill codify? -2. **Search for best practices**: - - Academic sources (Google Scholar, IEEE, ACM) - - Vendor documentation (OpenAI, Anthropic, Google, Microsoft) - - Industry standards (ISO, NIST, OMG) - - Established methodologies (e.g., FDD, Scrum, Kanban for process skills) -3. **Read existing research**: Check `docs/scientific-research/` for related entries — each file covers a domain (testing, oop-design, architecture, ai-agents, etc.) -4. **Synthesize conclusions**: Extract actionable conclusions — what works, why, and when to apply it -5. **Embed as guidance**: Write the skill's steps, checklists, and decision rules based on those conclusions — not as academic citations but as direct guidance ("Use X because it produces Y outcome") - -**Example research synthesis:** -``` -Research question: How to structure a security review skill? -Sources found: OWASP Testing Guide, NIST SP 800-53, Anthropic's agent design patterns -Conclusion: Security reviews should be adversarial (assume breakage), use defense-in-depth checklist, escalate on first critical finding. -→ Skill step: "3. Run adversarial checks — assume breach, verify every control" -``` - -### 1. Create the directory - -```bash -mkdir .opencode/skills// -``` - -Naming rules: -- 1–64 characters -- Lowercase alphanumeric with single hyphens -- Cannot start or end with hyphen, no consecutive hyphens -- Must match the directory name exactly - -### 2. Create SKILL.md with frontmatter - -```markdown ---- -name: -description: <1-sentence description, 10-100 characters> -version: "1.0" -author: -audience: -workflow: ---- - -# - - - -## When to Use - - - -## Step-by-Step - -### 1. - - -### 2. - - -## Checklist - -- [ ] -``` - -**Frontmatter requirements:** -- `name`: Max 64 chars, lowercase letters/numbers/hyphens only -- `description`: 1 sentence, 10-100 chars, include key terms and triggers -- `author`/`audience`: Use role names from AGENTS.md -- `workflow`: Category like `feature-lifecycle`, `opencode`, `release-management` - -### 3. Write body content - -Follow these research-backed patterns: - -**Structure:** -1. **When to Use** — specific trigger conditions, not vague guidance -2. **Step-by-Step** — clear sequential steps with specific actions -3. **Checklist** — verification items the agent can self-check - -**Formatting rules:** -- Use imperative voice ("Write the test" not "You should write") -- One step per line item in checklists -- Include concrete examples (one is enough, not exhaustive) -- Use tables for multi-column data (tool options, decision criteria) -- Link to reference docs instead of duplicating them - -**Tone:** Write in third person. The description is injected into the system prompt. - -### 4. Keep it lean - -Skills are loaded into context. Long skills consume tokens. Target: -- < 150 lines for focused workflow skills -- < 250 lines for complex multi-phase skills -- < 500 lines absolute maximum (Anthropic recommendation) - -**Cut:** -- Exhaustive examples when one is enough -- Reference documentation (link to it instead) -- Boilerplate CI/CD YAML (it belongs in `.github/`, not skills) - -### 5. Test with real usage - -The most effective skill development process involves using the skill in real tasks and iterating based on failures. - -### 6. Reference from agents - -Add the skill name to the agent's "Available Skills" section so the agent knows to load it. Update AGENTS.md skills table. - -## Available Skills in This Project - -| Skill | Used By | Purpose | -|---|---|---| -| `session-workflow` | all agents | Session start/end protocol | -| `feature-selection` | product-owner | Score and select next backlog feature (WSJF) | -| `scope` | product-owner | Step 1: define acceptance criteria | -| `implementation` | software-engineer | Steps 2-3: architecture + TDD loop | -| `design-patterns` | software-engineer | Steps 2, 3: refactor when smell detected | -| `verify` | reviewer | Step 4: adversarial verification | -| `code-quality` | software-engineer | Quick reference — redirects to verify | -| `pr-management` | software-engineer | Step 5: create PR with squash merge | -| `git-release` | software-engineer | Step 5: calver versioning and release | -| `living-docs` | product-owner | Step 5 (after acceptance) + on stakeholder demand: C4 diagrams + glossary | -| `create-skill` | software-engineer | Create new skills | -| `create-agent` | human-user | Create new agents with research-backed design | \ No newline at end of file diff --git a/.opencode/skills/design-patterns/SKILL.md b/.opencode/skills/design-patterns/SKILL.md deleted file mode 100644 index 7d1b4de..0000000 --- a/.opencode/skills/design-patterns/SKILL.md +++ /dev/null @@ -1,405 +0,0 @@ ---- -name: design-patterns -description: GoF design pattern catalogue — smell triggers and Python before/after examples -version: "2.0" -author: software-engineer -audience: software-engineer -workflow: feature-lifecycle ---- - -# Design Patterns Reference - -Load this skill when the refactor skill's smell table points to a GoF pattern and you need the Python before/after example. - -Sources: Gamma, Helm, Johnson, Vlissides. *Design Patterns: Elements of Reusable Object-Oriented Software*. Addison-Wesley, 1995. See `docs/scientific-research/oop-design.md` entry 34. - ---- - -## How to Use This Skill - -1. **Identify the smell** from the refactor skill's lookup table -2. **Find the smell category** below (Creational / Structural / Behavioral) -3. **Read the trigger and the before/after example** -4. **Apply the pattern** — update the stub files (Step 2) or the refactored code (Step 3) - ---- - -## GoF Pattern Catalogue — One-Liner Reference - -### Creational -| Pattern | Intent | -|---|---| -| **Factory Method** | Delegate object creation to a subclass or factory function | -| **Abstract Factory** | Create families of related objects without specifying concrete classes | -| **Builder** | Construct complex objects step-by-step, separating construction from representation | -| **Prototype** | Clone existing objects instead of creating new ones from scratch | -| **Singleton** | Ensure a class has only one instance (use sparingly — prefer dependency injection) | - -### Structural -| Pattern | Intent | -|---|---| -| **Adapter** | Wrap an incompatible interface to match an expected interface | -| **Bridge** | Separate abstraction from implementation so both can vary independently | -| **Composite** | Treat individual objects and compositions uniformly via a shared interface | -| **Decorator** | Add responsibilities to an object dynamically without subclassing | -| **Facade** | Provide a simplified interface to a complex subsystem | -| **Flyweight** | Share fine-grained objects to reduce memory when many similar instances are needed | -| **Proxy** | Control access to an object via a surrogate (lazy init, access control, logging) | - -### Behavioral -| Pattern | Intent | -|---|---| -| **Chain of Responsibility** | Pass a request along a chain of handlers until one handles it | -| **Command** | Encapsulate a request as an object, enabling undo/redo and queuing | -| **Interpreter** | Define a grammar and an interpreter for a language | -| **Iterator** | Provide sequential access to elements without exposing the underlying structure | -| **Mediator** | Centralize complex communication between objects through a mediator object | -| **Memento** | Capture and restore object state without violating encapsulation | -| **Observer** | Define a one-to-many dependency so dependents are notified automatically | -| **State** | Allow an object to alter its behavior when its internal state changes | -| **Strategy** | Define a family of algorithms, encapsulate each, and make them interchangeable | -| **Template Method** | Define the skeleton of an algorithm; let subclasses fill in specific steps | -| **Visitor** | Separate an algorithm from the object structure it operates on | - ---- - -## Smell-Triggered Patterns — Python Examples - -### Creational Smells - ---- - -#### Smell: Scattered Object Construction -**Signal**: The same object is constructed in 3+ places with slightly different arguments, or construction logic is duplicated across callers. - -**Pattern**: Factory Method or Factory Function - -```python -# BEFORE — scattered construction -# in order_service.py -order = Order(id=uuid4(), status="pending", created_at=datetime.now()) - -# in test_order.py -order = Order(id=UUID("abc..."), status="pending", created_at=datetime(2026, 1, 1)) - -# in import_service.py -order = Order(id=uuid4(), status="pending", created_at=datetime.now()) -``` - -```python -# AFTER — factory function owns construction -def make_order( - *, - order_id: OrderId | None = None, - clock: Callable[[], datetime] = datetime.now, -) -> Order: - return Order( - id=order_id or OrderId(uuid4()), - status=OrderStatus.PENDING, - created_at=clock(), - ) -``` - ---- - -#### Smell: Multi-Step Construction with Optional Parts -**Signal**: An object requires several setup calls before it is valid. Callers must remember the correct sequence. - -**Pattern**: Builder - -```python -# BEFORE — callers must know the correct build sequence -report = Report() -report.set_title("Q4 Sales") -report.add_section(summary) -report.add_section(detail) -report.set_footer("Confidential") -# easy to forget a step or get the order wrong -``` - -```python -# AFTER — builder enforces sequence and provides defaults -report = ( - ReportBuilder("Q4 Sales") - .with_section(summary) - .with_section(detail) - .with_footer("Confidential") - .build() -) -``` - ---- - -### Structural Smells - ---- - -#### Smell: Type-Switching (if/elif on type or status) -**Signal**: A function or method contains `if isinstance(x, A): ... elif isinstance(x, B): ...` or `if x.type == "a": ... elif x.type == "b": ...`. Adding a new type requires editing this function. - -**Pattern**: Strategy (behavior varies) or Visitor (operation varies over a fixed structure) - -```python -# BEFORE — type switch must be updated for every new discount type -def apply_discount(order: Order, discount_type: str) -> Money: - if discount_type == "percentage": - return order.total * (1 - order.rate) - elif discount_type == "fixed": - return order.total - order.amount - elif discount_type == "bogo": - return order.total - (order.total / 2) - else: - raise ValueError(discount_type) -``` - -```python -# AFTER — Strategy: each discount is a callable, closed to modification -class DiscountStrategy(Protocol): - def apply(self, order: Order) -> Money: ... - -@dataclass -class PercentageDiscount: - rate: Decimal - def apply(self, order: Order) -> Money: - return order.total * (1 - self.rate) - -@dataclass -class FixedDiscount: - amount: Money - def apply(self, order: Order) -> Money: - return order.total - self.amount - -def apply_discount(order: Order, strategy: DiscountStrategy) -> Money: - return strategy.apply(order) -``` - ---- - -#### Smell: Feature Envy -**Signal**: A method in class A uses data from class B more than its own data. The method "envies" class B. - -**Pattern**: Move Method to the envied class (Fowler refactoring that often precedes Strategy or Command) - -```python -# BEFORE — OrderPrinter knows too much about Order internals -class OrderPrinter: - def format_total(self, order: Order) -> str: - subtotal = sum(item.price * item.quantity for item in order.items) - tax = subtotal * order.tax_rate - return f"{subtotal + tax:.2f}" -``` - -```python -# AFTER — total belongs on Order -@dataclass -class Order: - items: list[LineItem] - tax_rate: Decimal - - def total(self) -> Money: - subtotal = sum(item.subtotal() for item in self.items) - return subtotal * (1 + self.tax_rate) - -class OrderPrinter: - def format_total(self, order: Order) -> str: - return f"{order.total():.2f}" -``` - ---- - -#### Smell: Parallel Inheritance Hierarchies -**Signal**: Every time you add a subclass to hierarchy A, you must also add a corresponding subclass to hierarchy B. The two trees grow in lockstep. - -**Pattern**: Bridge - -```python -# BEFORE — adding a new Shape requires a new renderer subclass too -class Shape: ... -class Circle(Shape): ... -class Square(Shape): ... - -class SVGCircle(Circle): ... -class SVGSquare(Square): ... -class PNGCircle(Circle): ... -class PNGSquare(Square): ... -``` - -```python -# AFTER — Bridge separates shape from renderer -class Renderer(Protocol): - def render_circle(self, radius: float) -> None: ... - def render_square(self, side: float) -> None: ... - -@dataclass -class Circle: - radius: float - renderer: Renderer - def draw(self) -> None: - self.renderer.render_circle(self.radius) - -@dataclass -class Square: - side: float - renderer: Renderer - def draw(self) -> None: - self.renderer.render_square(self.side) -``` - ---- - -### Behavioral Smells - ---- - -#### Smell: Large State Machine in One Class -**Signal**: A class has a `status` or `state` field, and many methods begin with `if self.state == X: ... elif self.state == Y: ...`. Adding a new state requires editing all these methods. - -**Pattern**: State - -```python -# BEFORE — Order methods all branch on status -class Order: - def confirm(self) -> None: - if self.status == "pending": - self.status = "confirmed" - else: - raise InvalidTransition(self.status, "confirm") - - def ship(self) -> None: - if self.status == "confirmed": - self.status = "shipped" - else: - raise InvalidTransition(self.status, "ship") -``` - -```python -# AFTER — each state owns its own transitions -class OrderState(Protocol): - def confirm(self, order: Order) -> None: ... - def ship(self, order: Order) -> None: ... - -class PendingState: - def confirm(self, order: Order) -> None: - order.state = ConfirmedState() - def ship(self, order: Order) -> None: - raise InvalidTransition("pending", "ship") - -class ConfirmedState: - def confirm(self, order: Order) -> None: - raise InvalidTransition("confirmed", "confirm") - def ship(self, order: Order) -> None: - order.state = ShippedState() - -@dataclass -class Order: - state: OrderState = field(default_factory=PendingState) - def confirm(self) -> None: self.state.confirm(self) - def ship(self) -> None: self.state.ship(self) -``` - ---- - -#### Smell: Scattered Notification / Event Fan-Out -**Signal**: When something happens in class A, it directly calls methods on classes B, C, and D. Adding a new listener requires modifying class A. - -**Pattern**: Observer - -```python -# BEFORE — Order directly notifies every downstream system -class Order: - def confirm(self) -> None: - self.status = "confirmed" - EmailService().send_confirmation(self) # direct coupling - InventoryService().reserve(self) # direct coupling - AnalyticsService().record_conversion(self) # direct coupling -``` - -```python -# AFTER — Order emits an event; listeners register independently -class OrderConfirmedListener(Protocol): - def on_order_confirmed(self, order: Order) -> None: ... - -@dataclass -class Order: - _listeners: list[OrderConfirmedListener] = field(default_factory=list) - - def add_listener(self, listener: OrderConfirmedListener) -> None: - self._listeners.append(listener) - - def confirm(self) -> None: - self.status = OrderStatus.CONFIRMED - for listener in self._listeners: - listener.on_order_confirmed(self) -``` - ---- - -#### Smell: Repeated Algorithm Skeleton -**Signal**: Two or more functions share the same high-level structure (setup → process → teardown) but differ only in one or two steps. The structure is copied rather than shared. - -**Pattern**: Template Method - -```python -# BEFORE — CSV and JSON importers duplicate the pipeline structure -def import_csv(path: Path) -> list[Record]: - raw = path.read_text() - rows = parse_csv(raw) # varies - records = [validate(r) for r in rows] - save_all(records) - return records - -def import_json(path: Path) -> list[Record]: - raw = path.read_text() - rows = parse_json(raw) # varies - records = [validate(r) for r in rows] - save_all(records) - return records -``` - -```python -# AFTER — Template Method: skeleton in base, varying step overridden -class Importer(ABC): - def run(self, path: Path) -> list[Record]: - raw = path.read_text() - rows = self.parse(raw) # hook - records = [validate(r) for r in rows] - save_all(records) - return records - - @abstractmethod - def parse(self, raw: str) -> list[dict]: ... - -class CsvImporter(Importer): - def parse(self, raw: str) -> list[dict]: - return parse_csv(raw) - -class JsonImporter(Importer): - def parse(self, raw: str) -> list[dict]: - return parse_json(raw) -``` - ---- - -## Quick Smell → Pattern Lookup - -| Smell | Pattern | -|---|---| -| Same object constructed in 3+ places | Factory Method / Factory Function | -| Multi-step setup before object is valid | Builder | -| `if type == X: ... elif type == Y:` | Strategy | -| Method uses another class's data more than its own | Move Method (Fowler) | -| Two class hierarchies that grow in lockstep | Bridge | -| `if self.state == X:` in multiple methods | State | -| Class directly calls B, C, D on state change | Observer | -| Two functions share the same skeleton, differ in one step | Template Method | -| Subsystem is complex and callers need a simple entry point | Facade | - ---- - -## Core Heuristic — Procedural vs OOP - -> **When procedural code requires modifying existing functions to add new variants, OOP is the fix.** - -Procedural code is open to inspection but open to modification too — every new case touches existing logic. -OOP (via Strategy, State, Observer, etc.) closes existing code to modification and opens it to extension through new types. -The smell is always the same: **a place in the codebase that must change every time the domain grows.** diff --git a/.opencode/skills/feature-selection/SKILL.md b/.opencode/skills/feature-selection/SKILL.md deleted file mode 100644 index a195b20..0000000 --- a/.opencode/skills/feature-selection/SKILL.md +++ /dev/null @@ -1,122 +0,0 @@ ---- -name: feature-selection -description: Score and select the next backlog feature by value, effort, and dependencies -version: "1.0" -author: product-owner -audience: product-owner -workflow: feature-lifecycle ---- - -# Feature Selection - -Select the next most valuable, unblocked feature from the backlog using a lightweight scoring model grounded in flow economics and dependency analysis. - -**Research basis**: Weighted Shortest Job First (WSJF) — Reinertsen *Principles of Product Development Flow* (2009); INVEST criteria — Wake (2003); Kano model — Kano (1984); Dependency analysis — PMBOK Critical Path Method. See `docs/scientific-research/requirements-elicitation.md`. - -**Core principle**: Cost of Delay ÷ Duration. Features with high user value and low implementation effort should start first. Features blocked by unfinished work should wait regardless of value. - -## When to Use - -Load this skill when `TODO.md` says "No feature in progress" — before moving any feature to `in-progress/`. - -## Step-by-Step - -### 1. Verify WIP is Zero - -```bash -ls docs/features/in-progress/ -``` - -- 0 files → proceed -- 1 file → a feature is already in progress; do not start another; exit this skill -- >1 files → WIP violation; stop and resolve before proceeding - -### 2. List BASELINED Candidates - -Read each `.feature` file in `docs/features/backlog/`. Check its discovery section for `Status: BASELINED`. - -- Non-BASELINED features are not eligible — they need Step 1 (scope) first -- If no BASELINED features exist: inform the stakeholder; run `@product-owner` with `skill scope` to baseline the most promising backlog item first - -**IMPORTANT** - -**NEVER move a feature to `in-progress/` unless its discovery section has `Status: BASELINED`** - -### 3. Score Each Candidate - -For each BASELINED feature, fill this table: - -| Feature | Value (1–5) | Effort (1–5) | Dependency (0/1) | WSJF | -|---|---|---|---|---| -| `` | | | | Value ÷ Effort | - -**Value (1–5)** — estimate user/business impact: -- 5: Must-have — core workflow blocked without it (Kano: basic need) -- 4: High — significantly improves the primary use case -- 3: Medium — useful but not blocking (Kano: performance) -- 2: Low — nice-to-have (Kano: delighter) -- 1: Minimal — cosmetic or out-of-scope edge case - -Use the number of `Must` Examples in the feature's `Rule:` blocks as a tiebreaker: more Musts → higher value. - -**Effort (1–5)** — estimate implementation complexity: -- 1: Trivial — 1–2 `@id` Examples, no new domain concepts -- 2: Small — 3–5 `@id` Examples, one new domain entity -- 3: Medium — 6–8 `@id` Examples or cross-cutting concern -- 4: Large — >8 Examples or multiple interacting domain entities -- 5: Very large — spans multiple modules or has unknown complexity - -**Dependency (0/1)** — does this feature assume another backlog feature is already built? -- 0: Independent — no hard prerequisite -- 1: Blocked — requires another backlog feature to be completed first - -A Dependency=1 feature is **ineligible for selection** regardless of WSJF score. Apply WSJF only to Dependency=0 features. - -### 4. Select - -Pick the BASELINED, Dependency=0 feature with the highest WSJF score. - -Ties: prefer higher Value (user impact matters more than effort optimization). - -If all BASELINED features have Dependency=1: stop and resolve the blocking dependency first — select and complete the depended-upon feature. - -### 5. Move and Update TODO.md - -```bash -mv docs/features/backlog/.feature docs/features/in-progress/.feature -``` - -Update `TODO.md`: - -```markdown -# Current Work - -Feature: -Step: 1 (SCOPE) or 2 (ARCH) — whichever is next -Source: docs/features/in-progress/.feature - -## Next -Run @ -``` - -- If the feature has no `Rule:` blocks yet → Step 1 (SCOPE): `Run @product-owner — load skill scope and write stories` -- If the feature has `Rule:` blocks but no `@id` Examples → Step 1 Stage 2 Step B (Criteria): `Run @product-owner — load skill scope and write acceptance criteria` -- If the feature has `@id` Examples → Step 2 (ARCH): `Run @software-engineer — load skill implementation and write architecture stubs` - -### 6. Commit - -```bash -git add docs/features/in-progress/.feature TODO.md -git commit -m "chore: select as next feature" -``` - -## Checklist - -- [ ] `in-progress/` confirmed empty before selection -- [ ] Only BASELINED features considered -- [ ] Dependency=1 features excluded from scoring -- [ ] WSJF scores filled for all candidates -- [ ] Selected feature has highest WSJF among Dependency=0 candidates -- [ ] Feature moved to `in-progress/` -- [ ] `TODO.md` updated with correct Step and `Next` line -- [ ] Changes committed diff --git a/.opencode/skills/git-release/SKILL.md b/.opencode/skills/git-release/SKILL.md deleted file mode 100644 index 411e8d4..0000000 --- a/.opencode/skills/git-release/SKILL.md +++ /dev/null @@ -1,231 +0,0 @@ ---- -name: git-release -description: Create releases with hybrid major.minor.calver versioning and AI-generated adjective-animal naming -version: "1.0" -author: software-engineer -audience: software-engineer -workflow: release-management ---- - -# Git Release - -Create a tagged GitHub release after the PO accepts the feature (Step 5). - -## Version Format - -`v{major}.{minor}.{YYYYMMDD}` - -- **Major**: breaking changes (API changes, removed features) -- **Minor**: new features; also incremented if two releases happen on the same day -- **Date**: today in YYYYMMDD format - -Examples: -``` -v1.2.20260302 → v1.3.20260415 (new feature, new day) -v1.2.20260302 → v2.0.20260415 (breaking change) -v1.2.20260415 → v1.3.20260415 (same-day second release) -``` - -## Release Naming - -Each release gets a unique name: **{adjective}-{bee-genus}**. Analyze the commits and PRs since the last release, identify the theme, choose an adjective that reflects it, and select a bee genus from the pool below that matches. - -The only constraints: - -1. **Thematic fit**: the name should reflect what this release does -2. **No repetition**: neither the adjective nor the animal may appear in a previous release - -Check previous names to avoid repetition: -```bash -gh release list --limit 20 -``` - -### Curated Bee Genus Pool - -Choose from this pool for intentional, memorable names. Each genus has a character note to guide thematic matching. - -| Genus | Common name | Character / theme | -|---|---|---| -| *Apis* | Honey bee | Collaboration, industry, the gold standard | -| *Bombus* | Bumblebee | Robustness, persistence, surprising capability | -| *Osmia* | Mason bee | Precision, craftsmanship, careful construction | -| *Megachile* | Leafcutter bee | Clever tooling, cutting to shape | -| *Xylocopa* | Carpenter bee | Structural work, building into solid foundations | -| *Halictus* | Sweat bee | Small but essential, invisible infrastructure | -| *Lasioglossum* | Small sweat bee | Ubiquity, the most common; baseline correctness | -| *Nomada* | Nomad bee | Migration, discovery, exploratory behavior | -| *Andrena* | Mining bee | Digging deep, uncovering hidden things | -| *Colletes* | Plasterer bee | Sealing, finishing, waterproofing | -| *Hylaeus* | Masked bee | Hidden internals, minimal exterior | -| *Eulaema* | Orchid bee | Exotic, specialized, high-value collection | -| *Eufriesea* | Orchid bee | Rare, distinctive, one-of-a-kind | -| *Agapostemon* | Metallic sweat bee | Brilliance, sheen, polish | -| *Augochlora* | Green sweat bee | Fresh, new, verdant growth | -| *Augochlorella* | Sweat bee | Emerging, small-scale refinement | -| *Augochloropsis* | Sweat bee | Variation on a theme, extension | -| *Panurgus* | Mining bee | Collective effort, many small contributions | -| *Perdita* | Mining bee | Smallest US bee; economy, minimalism | -| *Melitturga* | Mining bee | Clarity, straight lines | -| *Dasypoda* | Pantaloon bee | Deep foundations, load-bearing | -| *Macropis* | Oil bee | Specialized extraction, targeted collection | -| *Melitta* | Melitta bee | Sweetness, reward, delight | -| *Anthidium* | Wool-carder bee | Gathering, tidying, organization | -| *Coelioxys* | Sharp-tailed bee | Edge cases, pointed precision | -| *Stelis* | Cleptoparasitic bee | Detection, catching what doesn't belong | -| *Dioxys* | Cleptoparasitic bee | Finding impostors, validation | -| *Sphecodes* | Blood bee | Ruthless removal of what shouldn't be there | -| *Ceratina* | Small carpenter bee | Incremental progress, small but persistent | -| *Exomalopsis* | Bee | Quiet correctness, unassuming reliability | -| *Emphorella* | Bee | Niche specialization | -| *Peponapis* | Squash bee | Domain-specific excellence | -| *Xenoglossa* | Squash bee | Specialized vocabulary, domain language | -| *Ptilothrix* | Mallow bee | Softness of interface, gentle handling | -| *Melissodes* | Long-horned bee | Signal detection, communication | -| *Svastra* | Long-horned bee | Season-aware, time-sensitive behavior | -| *Eucera* | Long-horned bee | Patient waiting, timing | -| *Tetralonia* | Long-horned bee | Systematic coverage | -| *Anthophora* | Digger bee | Fast, energetic execution | -| *Habropoda* | Digger bee | Buzz-pollination; resonance, vibration | -| *Amegilla* | Blue-banded bee | Vibrant, high-frequency operation | -| *Xylocopinae* | Carpenter bee subfamily | Load-bearing architecture | -| *Euglossa* | Orchid bee | Precision collection, perfume of quality | -| *Eulaema* | Orchid bee | Valuable, coveted output | -| *Trigona* | Stingless bee | Safe, no sharp edges, user-friendly | -| *Tetragonula* | Stingless bee | Compact, structured, geometric | -| *Meliponula* | Stingless bee | African precision; warm-climate reliability | -| *Frieseomelitta* | Stingless bee | Abundant output, productivity | -| *Scaptotrigona* | Stingless bee | Aggressive defense of quality | -| *Plebeia* | Stingless bee | Humble, small, widely deployed | -| *Schwarziana* | Stingless bee | Named for a scientist; rigorous methodology | -| *Ctenocolletes* | Stenotritid bee | Ancient, foundational, rarely changed | -| *Stenotritus* | Stenotritid bee | Narrow, focused, specialized interface | - -If the release theme doesn't match any entry above, choose any other real bee genus and add it to this list with a character note. - -## Release Process - -### 1. Analyze changes since last release - -```bash -last_tag=$(git describe --tags --abbrev=0) -git log ${last_tag}..HEAD --oneline -gh pr list --state merged --limit 20 --json title,number,labels -``` - -### 2. Calculate new version - -```bash -current_date=$(date +%Y%m%d) -# Determine major.minor based on change type, then: -# new_version="v{major}.{minor}.${current_date}" -``` - -### 3. Update version in pyproject.toml and package __init__.py - -Both must match: -```bash -# Update pyproject.toml version field -# Update /__version__ to match -``` - -### 4. Update CHANGELOG.md - -Add at the top: -```markdown -## [v{version}] - {Adjective Animal} - {YYYY-MM-DD} - -### Added -- description (#PR-number) - -### Changed -- description (#PR-number) - -### Fixed -- description (#PR-number) -``` - -### 5. Update living docs - -Run the `living-docs` skill to reflect the newly accepted feature in C4 diagrams and the glossary. This step runs inline — do not commit separately. - -Load and execute the full `living-docs` skill now: -- Update `docs/c4/context.md` (C4 Level 1) -- Update `docs/c4/container.md` (C4 Level 2, if multi-container) -- Update `docs/glossary.md` (living glossary) - -The `living-docs` commit step is **skipped** here — all changed files are staged together with the version bump in step 6. - -### 6. Regenerate lockfile and commit version bump - -After updating `pyproject.toml`, regenerate the lockfile — CI runs `uv sync --locked` and will fail if it is stale: - -```bash -uv lock -git add pyproject.toml /__init__.py CHANGELOG.md uv.lock \ - docs/c4/context.md docs/c4/container.md docs/glossary.md -git commit -m "chore(release): bump version to v{version} - {Adjective Animal}" -``` - -### 7. Create GitHub release - -Assign the SHA first so it expands correctly inside the notes string: - -```bash -SHA=$(git rev-parse --short HEAD) -gh release create "v{version}" \ - --title "v{version} - {Adjective Animal}" \ - --notes "# v{version} - {Adjective Animal} - -> *\"{one-line tagline matching the release theme}\"* - -## Changelog - -### Added -- feat: description (#PR) - -### Fixed -- fix: description (#PR) - -### Changed -- refactor/chore/docs: description (#PR) - -## Summary - -2-3 sentences describing what this release accomplishes and why the name fits. - ---- -**SHA**: \`${SHA}\`" -``` - -### 8. If a hotfix commit follows the release tag - -If CI fails after the release (e.g. a stale lockfile) and a hotfix commit is pushed, reassign the tag and GitHub release to that commit: - -```bash -# Delete the old tag locally and on remote -git tag -d "v{version}" -git push origin ":refs/tags/v{version}" - -# Recreate the tag on the hotfix commit -git tag "v{version}" {hotfix-sha} -git push origin "v{version}" - -# Update the GitHub release to point to the new tag -gh release edit "v{version}" --target {hotfix-sha} -``` - -The release notes and title do not need to change — only the target commit moves. - -## Quality Checklist - -- [ ] `task test` passes -- [ ] `task lint` passes -- [ ] `task static-check` passes -- [ ] `pyproject.toml` version updated -- [ ] `uv lock` run after version bump — lockfile must be up to date -- [ ] `/__version__` matches `pyproject.toml` version -- [ ] CHANGELOG.md updated -- [ ] `living-docs` skill run — C4 diagrams and glossary reflect the new feature -- [ ] Release name not used before -- [ ] Release notes follow the template format -- [ ] If a hotfix was pushed after the tag: tag reassigned to hotfix commit diff --git a/.opencode/skills/implementation/SKILL.md b/.opencode/skills/implementation/SKILL.md deleted file mode 100644 index 61ada18..0000000 --- a/.opencode/skills/implementation/SKILL.md +++ /dev/null @@ -1,400 +0,0 @@ ---- -name: implementation -description: Steps 2-3 — Architecture + TDD Loop, one @id at a time -version: "3.0" -author: software-engineer -audience: software-engineer -workflow: feature-lifecycle ---- - -# Implementation - -Steps 2 (Architecture) and 3 (TDD Loop) combined into a single skill. The software-engineer owns both. - -## Software-Engineer Quality Gate Priority Order - -During implementation, correctness priorities are (in order): - -1. **Design correctness** — YAGNI > KISS > DRY > SOLID > Object Calisthenics > appropriate design patterns > complex code > complicated code > failing code > no code -2. **One @id green** — the specific test under work passes, plus `test-fast` still passes -3. **Commit** — when a meaningful increment is green -4. **Quality tooling** — `lint`, `static-check`, full `test` with coverage run at end-of-feature handoff - -Design correctness is far more important than lint/pyright/coverage compliance. Never run lint (ruff check, ruff format), static-check (pyright), or coverage during the TDD loop — those are handoff-only checks. - ---- - -## Step 2 — Architecture - -### Prerequisites (stop if any fail — escalate to PO) - -1. `docs/features/in-progress/` contains exactly one `.feature` file (not just `.gitkeep`). If none exists, **STOP** — update TODO.md `Next:` to `Run @product-owner — move the chosen feature to in-progress/` and stop. Never self-select or move a feature yourself. -2. The feature file's discovery section has `Status: BASELINED`. If not, escalate to PO — Step 1 is incomplete. -3. The feature file contains `Rule:` blocks with `Example:` blocks and `@id` tags. If not, escalate to PO — criteria have not been written. -4. Package name confirmed: read `pyproject.toml` → locate `[tool.setuptools]` → confirm directory exists on disk. - -### Package Verification (mandatory — before writing any code) - -1. Read `pyproject.toml` → locate `[tool.setuptools]` → record `packages = [""]` -2. Confirm directory exists: `ls /` -3. All new source files go under `/` - -**Note on feature file moves**: The PO moves `.feature` files between folders. The software-engineer never moves or edits `.feature` files. Update TODO.md `Source:` path to reflect `in-progress/` once the PO has moved the file. - -### Read Phase (all before writing anything) - -1. Read `docs/discovery.md` (project-level synthesis changelog) and optionally `docs/discovery_journal.md` (Q&A history for context) -2. Read `docs/glossary.md` if it exists — use existing domain terms when naming classes, methods, and modules; do not invent synonyms for terms already defined -3. Read **ALL** `.feature` files in `docs/features/backlog/` (discovery + entities sections) -4. Read in-progress `.feature` file (full: Rules + Examples + @id) -5. Read **ALL** existing `.py` files in `/` — understand what already exists before adding anything - -### Domain Analysis - -From the Domain Model table in `docs/discovery.md` + Rules (Business) in the `.feature` file: -- **Nouns** → named classes, value objects, aggregates -- **Verbs** → method names with typed signatures -- **Datasets** → named types (not bare dict/list) -- **Bounded Context check**: same word, different meaning across features? → module boundary -- **Cross-feature entities** → candidate shared domain layer - -### Silent Pre-mortem (before writing anything) - -> "In 6 months this design is a mess. What mistakes did we make?" - -For each candidate class: -- >2 ivars? → split -- >1 reason to change? → isolate - -For each external dep: -- Is it behind a Protocol? → if not, add - -For each noun: -- Serving double duty across modules? → isolate - -If pattern smell detected, load `skill design-patterns`. - -### Write Stubs into Package - -From the domain analysis, write or extend `.py` files in `/`. For each entity: - -- **If the file already exists**: add the new class or method signature — do not remove or alter existing code. -- **If the file does not exist**: create it with the new signatures only. - -**Stub rules (strictly enforced):** -- Method bodies must be `...` — no logic, no conditionals, no imports beyond `typing` and domain types -- No docstrings — signatures will change; add docstrings after GREEN (lint enforces this at quality gate) -- No inline comments, no TODO comments, no speculative code - -**Example — correct stub style:** - -```python -from dataclasses import dataclass -from typing import Protocol - - -@dataclass(frozen=True, slots=True) -class EmailAddress: - value: str - - def validate(self) -> None: ... - - -class UserRepository(Protocol): - def save(self, user: "User") -> None: ... - def find_by_email(self, email: EmailAddress) -> "User | None": ... -``` - -**File placement (common patterns, not required names):** -- `/domain/.py` — entities, value objects -- `/domain/service.py` — cross-entity operations - -Place stubs where responsibility dictates — do not pre-create `ports/` or `adapters/` folders unless a concrete external dependency was identified in scope. Structure follows domain analysis, not a template. - -### Record Architectural Decisions - -Append a new dated block to `docs/architecture.md` for each significant decision: - -```markdown -## YYYY-MM-DD — : - -Decision: -Reason: -Alternatives considered: -Feature: -``` - -Only write a block for non-obvious decisions with meaningful trade-offs. Routine YAGNI choices do not need a record. - -### Architecture Smell Check (hard gate) - -Apply to the stub files just written: - -- [ ] No class with >2 responsibilities (SOLID-S) -- [ ] No behavioural class with >2 instance variables (OC-8; dataclasses, Pydantic models, value objects, and TypedDicts are exempt) -- [ ] All external deps assigned a Protocol (SOLID-D + Hexagonal) — N/A if no external dependencies identified in scope -- [ ] No noun with different meaning across modules (DDD Bounded Context) -- [ ] No missing Creational pattern: repeated construction without Factory/Builder -- [ ] No missing Structural pattern: type-switching without Strategy/Visitor -- [ ] No missing Behavioral pattern: state machine or scattered notification without State/Observer -- [ ] Each ADR consistent with each @id AC — no contradictions - -If any check fails: fix the stub files before committing. - -### Generate Test Stubs - -Run `uv run task test-fast` once. It reads the in-progress `.feature` file, assigns `@id` tags to any untagged `Example:` blocks (writing them back to the `.feature` file), and generates `tests/features//_test.py` — one file per `Rule:` block, one skipped function per `@id`. Verify the files were created, then stage all changes (including any `@id` write-backs to the `.feature` file). - -Commit: `feat(): add architecture and test stubs` - ---- - -## Step 3 — TDD Loop - -### Prerequisites - -- [ ] Exactly one .feature `in_progress`. If not present, Load `skill feature-selection` -- [ ] Architecture stubs present in `/` (committed by Step 2) -- [ ] Read `docs/architecture.md` — understand all architectural decisions before writing any test -- [ ] Test stub files exist in `tests/features//_test.py` — generated by pytest-beehave at Step 2 end; if missing, re-run `uv run task test-fast` and commit the generated files before entering RED - -### Build TODO.md Test List - -1. List all `@id` tags from in-progress `.feature` file -2. Order: fewest dependencies first; most impactful within that set -3. Each `@id` = one TODO item, status: `pending` -4. Confirm each `@id` has a corresponding skipped stub in `tests/features//` — if any are missing, add them before proceeding - -### Outer Loop — One @id at a time - -**WIP limit**: exactly one `in_progress` at all times. - -For each pending `@id`: - -``` -INNER LOOP -├── RED -│ ├── Confirm stub for this @id exists in tests/features//_test.py with @pytest.mark.skip -│ ├── Read existing stubs in `/` — base the test on the current data model and signatures -│ ├── Write test body (Given/When/Then → Arrange/Act/Assert); remove @pytest.mark.skip -│ ├── Update stub signatures as needed — edit the `.py` file directly -│ ├── uv run task test-fast -│ └── EXIT: this @id FAILS -│ (if it passes: test is wrong — fix it first) -│ -├── GREEN -│ ├── Write minimum code — YAGNI + KISS only -│ │ (no DRY, SOLID, OC, Docstring, type hint here — those belong in REFACTOR) -│ ├── uv run task test-fast -│ └── EXIT: this @id passes AND all prior tests pass -│ (fix implementation only; do not advance to next @id) -│ -└── REFACTOR - ├── Load `skill refactor` — follow its protocol for this phase - ├── uv run task test-fast after each individual change - └── EXIT: test-fast passes; no smells remain - -Mark @id completed in TODO.md -Commit when a meaningful increment is green -``` - -### Quality Gate (all @id green) - -```bash -uv run task lint -uv run task static-check -uv run task test-coverage # coverage must be 100% -timeout 10s uv run task run -``` - -If coverage < 100%: add test in `tests/unit/` for uncovered branch (do NOT add @id tests for coverage). - -All must pass before Self-Declaration. - -### Self-Declaration (once, after all quality gates pass) - - - -Communicate verbally to the reviewer. Answer honestly for each principle: - -1. YAGNI: no code without a failing test — AGREE/DISAGREE | file:line -2. YAGNI: no speculative abstractions — AGREE/DISAGREE | file:line -3. KISS: simplest solution that passes — AGREE/DISAGREE | file:line -4. KISS: no premature optimization — AGREE/DISAGREE | file:line -5. DRY: no duplication — AGREE/DISAGREE | file:line -6. DRY: no redundant comments — AGREE/DISAGREE | file:line -7. SOLID-S: one reason to change per class — AGREE/DISAGREE | file:line -8. SOLID-O: open for extension, closed for modification — AGREE/DISAGREE | file:line -9. SOLID-L: subtypes substitutable — AGREE/DISAGREE | file:line -10. SOLID-I: no forced unused deps — AGREE/DISAGREE | file:line -11. SOLID-D: depend on abstractions, not concretions — AGREE/DISAGREE | file:line -12. OC-1: one level of indentation per method — AGREE/DISAGREE | deepest: file:line -13. OC-2: no else after return — AGREE/DISAGREE | file:line -14. OC-3: primitive types wrapped — AGREE/DISAGREE | file:line -15. OC-4: first-class collections — AGREE/DISAGREE | file:line -16. OC-5: one dot per line — AGREE/DISAGREE | file:line -17. OC-6: no abbreviations — AGREE/DISAGREE | file:line -18. OC-7: ≤20 lines per function, ≤50 per class — AGREE/DISAGREE | longest: file:line -19. OC-8: ≤2 instance variables per class (behavioural classes only; dataclasses, Pydantic models, value objects, and TypedDicts are exempt) — AGREE/DISAGREE | file:line -20. OC-9: no getters/setters — AGREE/DISAGREE | file:line -21. Patterns: no good reason remains to refactor using OOP or Design Patterns — AGREE/DISAGREE | file:line -22. Patterns: no creational smell — AGREE/DISAGREE | file:line -23. Patterns: no structural smell — AGREE/DISAGREE | file:line -24. Patterns: no behavioral smell — AGREE/DISAGREE | file:line -25. Semantic: tests operate at same abstraction as AC — AGREE/DISAGREE | file:line - -A `DISAGREE` answer is not automatic rejection — state the reason and fix before handing off. - -### Hand off to Step 4 (Verify) - -Signal completion to the reviewer. Provide: -- Feature file path -- Self-Declaration (communicated verbally, as above) -- Summary of what was implemented - ---- - -## Test Writing Conventions - -### Test File Layout - -``` -tests/features//_test.py -``` - -- `` = the `.feature` file stem with hyphens replaced by underscores, lowercase -- `` = the `Rule:` title slugified (lowercase, underscores) - -### Function Naming - -```python -def test__<@id>() -> None: -``` - -- `feature_slug` = the `.feature` file stem with spaces/hyphens replaced by underscores, lowercase -- `@id` = the `@id` from the `Example:` block - -### Docstring Format (mandatory) - -New tests start as skipped stubs. Remove `@pytest.mark.skip` when implementing in the RED phase. - -```python -@pytest.mark.skip(reason="not yet implemented") -def test__<@id>() -> None: - """ - <@id steps raw text including new lines> - """ -``` - -**Rules**: -- Docstring contains `Gherkin steps` as raw text on separate indented lines -- No extra metadata in docstring — traceability comes from function name `@id` suffix - -### Markers - -- `@pytest.mark.slow` — takes > 50ms (Hypothesis, DB, network, terminal I/O) -- `@pytest.mark.deprecated` — auto-skipped by pytest-beehave; used for superseded Examples - -```python -@pytest.mark.deprecated -def test_wall_bounce_a3f2b1c4() -> None: - ... - -@pytest.mark.slow -def test_checkout_flow_b2c3d4e5() -> None: - ... -``` - -### Hypothesis Tests - -When using `@given` in `tests/unit/`: - -```python -@pytest.mark.slow -@given(x=st.floats(min_value=-100, max_value=100, allow_nan=False)) -@example(x=0.0) -def test_wall_bounce_c4d5e6f7(x: float) -> None: - """ - Given: Any floating point input value - When: compute_distance is called - Then: The result is >= 0 - """ - assume(x != 0.0) - result = compute_distance(x) - assert result >= 0 -``` - -**Rules**: -- `@pytest.mark.slow` is mandatory on every `@given`-decorated test -- `@example(...)` is optional but encouraged -- Do not use Hypothesis for: I/O, side effects, network calls, database writes - -### Semantic Alignment Rule - -The test's Given/When/Then must operate at the **same abstraction level** as the AC's Steps. - -| AC says | Test must do | -|---|---| -| "When the user presses W" | Send `"W"` through the actual input mechanism | -| "When `update_player` receives 'W'" | Call `update_player("W")` directly | - -If testing through the real entry point is infeasible, escalate to PO to adjust the AC boundary. - -### Quality Rules - -- Write every test as if you cannot see the production code — test what a caller observes -- No `isinstance()`, `type()`, or internal attribute (`_x`) checks in assertions -- One assertion concept per test (multiple `assert` ok if they verify the same thing) -- No `pytest.mark.xfail` without written justification -- `pytest.mark.skip(reason="not yet implemented")` is only valid on stubs — remove it when implementing -- Test data embedded directly in the test, not loaded from external files - -### Test Tool Decision - -| Situation | Location | Tool | -|---|---|---| -| Deterministic scenario from a `.feature` `@id` | `tests/features/` | Plain pytest | -| Property holding across many input values | `tests/unit/` | Hypothesis `@given` | -| Specific behavior or single edge case | `tests/unit/` | Plain pytest | -| Stateful system with sequences of operations | `tests/unit/` | Hypothesis stateful testing | - ---- - -## Handling Spec Gaps - -If during implementation you discover a behavior not covered by existing acceptance criteria: -- **Do not extend criteria yourself** — escalate to PO -- Note the gap in TODO.md under `## Next` -- The PO will decide whether to add a new Example to the `.feature` file - -Extra tests in `tests/unit/` are allowed freely (coverage, edge cases, etc.) — these do not need `@id` traceability. - ---- - -## Signature Design - - signatures are written during Step 2 (Architecture) and refined during Step 3 (RED). They live directly in the package `.py` files — never in the `.feature` file. - -Key rules: -- Bodies are always `...` in the architecture stub -- GREEN phase replaces `...` with the minimum implementation -- REFACTOR phase cleans up the result - -Use Python Protocols for external dependencies if they are identified in scope — never depend on a concrete class directly: - -```python -from typing import Protocol -from dataclasses import dataclass - - -@dataclass(frozen=True, slots=True) -class EmailAddress: - value: str - - def validate(self) -> None: ... - - -class UserRepository(Protocol): - def save(self, user: "User") -> None: ... - def find_by_email(self, email: EmailAddress) -> "User | None": ... -``` diff --git a/.opencode/skills/living-docs/SKILL.md b/.opencode/skills/living-docs/SKILL.md deleted file mode 100644 index 8472547..0000000 --- a/.opencode/skills/living-docs/SKILL.md +++ /dev/null @@ -1,213 +0,0 @@ ---- -name: living-docs -description: Generate and update C4 architecture diagrams and the living glossary from existing project docs -version: "1.0" -author: product-owner -audience: product-owner -workflow: feature-lifecycle ---- - -# Living Docs - -This skill generates and updates two living documents after a feature is accepted (Step 5) or on stakeholder request: the **C4 architecture diagrams** and the **living glossary**. Both are derived from existing project documentation — no new decisions are made. - -The glossary is a secondary artifact derived from the code, the domain model, and domain-expert conversations. The canonical sources are the completed feature files, the discovery synthesis, and the architectural decisions. The glossary is a human-readable projection of those sources — not an independent authority. - -## When to Use - -- **As part of the release process (Step 5)** — the `git-release` skill calls this skill inline at step 5, before the version-bump commit. Do not commit separately; the release process stages all files together. -- **Stakeholder on demand** — when the stakeholder asks "what does the system look like?" or "what does term X mean in this context?". In this case, commit with the standalone message in Step 5 below. - -## Ownership Rules - -| Document | Created/Updated by | Inputs read | -|---|---|---| -| `docs/c4/context.md` | `living-docs` skill (PO) | `docs/discovery.md`, `docs/features/completed/` | -| `docs/c4/container.md` | `living-docs` skill (PO) | `docs/architecture.md`, `docs/features/completed/` | -| `docs/glossary.md` | `living-docs` skill (PO) | `docs/discovery.md`, `docs/glossary.md` (existing), `docs/architecture.md`, `docs/features/completed/` | -| `docs/architecture.md` | SE only (Step 2) | — | -| `docs/discovery.md` | PO only (Step 1) | — | - -**Never edit `docs/architecture.md` or `docs/discovery.md` in this skill.** Those files are append-only by their respective owners. This skill reads them; it never writes to them. - ---- - -## Step 1 — Read Phase (all before writing anything) - -Read in this order: - -1. `docs/discovery.md` — project scope, domain model (nouns/verbs), feature list per session -2. `docs/features/completed/` — all completed `.feature` files (full text: Rules, Examples, Constraints) -3. `docs/architecture.md` — all architectural decisions (containers, modules, protocols, external deps) -4. `docs/c4/` — existing C4 diagrams if they exist (update, do not replace from scratch) -5. `docs/glossary.md` — existing glossary if it exists (extend, never remove existing entries) - -Identify from the read phase: - -- **Actors** — named human roles from feature `As a ` clauses and discovery Scope section -- **External systems** — any system outside the package boundary named in features or architecture decisions -- **Containers** — deployable/runnable units identified in `docs/architecture.md` (Hexagonal adapters, CLIs, services) -- **Key domain terms** — all nouns from `docs/discovery.md` Domain Model tables, plus any terms defined in `docs/architecture.md` decisions - ---- - -## Step 2 — Update C4 Context Diagram (Level 1) - -File: `docs/c4/context.md` - -The Context diagram answers: **who uses the system and what external systems does it interact with?** - -Use Mermaid `C4Context` syntax. Template: - -```markdown -# C4 — System Context - -> Last updated: YYYY-MM-DD -> Source: docs/discovery.md, docs/features/completed/ - -```mermaid -C4Context - title System Context — - - Person(actor1, "", "") - - System(system, "", "<3–5 word system description from discovery.md Scope>") - - System_Ext(ext1, "", "") - - Rel(actor1, system, "") - Rel(system, ext1, "") -``` -``` - -Rules: -- One `Person(...)` per distinct actor found in completed feature files -- One `System_Ext(...)` per external dependency identified in `docs/architecture.md` decisions -- Relationships (`Rel`) use verb phrases from feature `When` clauses or architecture decision labels -- If no external systems are identified in `docs/architecture.md`, omit `System_Ext` entries -- If the file already exists: update only — add new actors/systems, update relationship labels. Never remove an existing entry unless the feature it came from has been explicitly superseded - ---- - -## Step 3 — Update C4 Container Diagram (Level 2) - -File: `docs/c4/container.md` - -The Container diagram answers: **what are the major runnable/deployable units and how do they communicate?** - -Only generate this diagram if `docs/architecture.md` contains at least one decision identifying a distinct container boundary (e.g., a CLI entry point separate from a library, a web server, a background worker, an external service adapter). If the project is a single-container system, note this in the file and skip the diagram body. - -Use Mermaid `C4Container` syntax. Template: - -```markdown -# C4 — Container Diagram - -> Last updated: YYYY-MM-DD -> Source: docs/architecture.md - -```mermaid -C4Container - title Container Diagram — - - Person(actor1, "", "") - - System_Boundary(sys, "") { - Container(container1, "", "", "") - Container(container2, "", "", "") - } - - System_Ext(ext1, "", "") - - Rel(actor1, container1, "") - Rel(container1, container2, "") - Rel(container1, ext1, "") -``` -``` - -Rules: -- Container names and responsibilities come directly from `docs/architecture.md` decisions — do not invent them -- Technology labels come from `pyproject.toml` dependencies when identifiable (e.g., "Python / fire CLI", "Python / FastAPI") -- If the file already exists: update incrementally — do not regenerate from scratch - ---- - -## Step 4 — Update Living Glossary - -File: `docs/glossary.md` - -The glossary answers: **what does each domain term mean in this project's context?** - -### Format - -```markdown -# Glossary — - -> Living document. Updated after each completed feature by the `living-docs` skill. -> Source: docs/discovery.md (Domain Model), docs/features/completed/, docs/architecture.md - ---- - -## - -**Type:** Noun | Verb | Domain Event | Concept | Role | External System - -**Definition:** - -**Bounded context:** - -**First appeared:** - ---- -``` - -### Rules - -- Extract all nouns and verbs from every `### Domain Model` table in `docs/discovery.md` -- Extract all roles from `As a ` clauses in completed `.feature` files -- Extract all external system names from `docs/architecture.md` decisions -- Extract any term defined or clarified in architectural decision `Reason:` fields -- **Do not remove existing glossary entries** — if a term's meaning has changed, add a `**Superseded by:**` line pointing to the new entry and write a new entry -- **Every term must have a traceable source** — completed feature files or `docs/architecture.md` decisions. If a term appears in sources but is never defined, write `Definition: Term appears in [source] but has not been explicitly defined.` Do not invent a definition. -- Terms are sorted alphabetically within the file - -### Merge with existing glossary - -If `docs/glossary.md` already exists: -1. Read all existing entries -2. For each new term found in sources: check if it already exists in the glossary - - Exists, definition unchanged → skip - - Exists, definition changed → append `**Superseded by:** ` to old entry; write new entry - - Does not exist → append new entry in alphabetical order - ---- - -## Step 5 — Commit - -**When called from the release process**: skip this step — the `git-release` skill stages and commits all files together. - -**When run standalone** (stakeholder on demand): commit after all diagrams and glossary are updated: - -``` -docs(living-docs): update C4 and glossary after -``` - -If triggered without a specific feature (general refresh): - -``` -docs(living-docs): refresh C4 diagrams and glossary -``` - ---- - -## Checklist - -- [ ] Read all five source files before writing anything -- [ ] Context diagram reflects all actors from completed feature files -- [ ] Context diagram reflects all external systems from `docs/architecture.md` -- [ ] Container diagram present only if multi-container architecture confirmed in `docs/architecture.md` -- [ ] Glossary contains all nouns and verbs from `docs/discovery.md` Domain Model tables -- [ ] No existing glossary entry removed -- [ ] Every new term has a traceable source in completed feature files or `docs/architecture.md`; no term is invented -- [ ] No edits made to `docs/architecture.md` or `docs/discovery.md` -- [ ] If standalone: committed with `docs(living-docs): ...` message -- [ ] If called from release: files staged but not committed (release process commits) diff --git a/.opencode/skills/pr-management/SKILL.md b/.opencode/skills/pr-management/SKILL.md deleted file mode 100644 index 94af430..0000000 --- a/.opencode/skills/pr-management/SKILL.md +++ /dev/null @@ -1,91 +0,0 @@ ---- -name: pr-management -description: Create pull requests with conventional commits, proper formatting, and branch workflow -version: "1.0" -author: software-engineer -audience: software-engineer -workflow: git-management ---- - -# PR Management - -Create and manage pull requests after the reviewer approves the feature (Step 5). - -## Branch Naming - -``` -feature/ # new feature -fix/ # bug fix -refactor/ # refactoring -docs/ # documentation -chore/ # tooling, deps, CI -``` - -## Conventional Commits - -``` -(): - -Types: feat, fix, test, refactor, chore, docs, perf, ci -``` - -Examples: -```bash -git commit -m "feat(auth): implement JWT token generation" -git commit -m "test(auth): add failing tests for token expiry" -git commit -m "fix(physics): correct ball velocity sign after wall bounce" -git commit -m "refactor(game-loop): extract timing logic to dedicated class" -git commit -m "chore(deps): add python-dotenv dependency" -``` - -## PR Creation - -```bash -# Push branch -git push -u origin feature/ - -# Create PR -gh pr create \ - --title "feat(): " \ - --body "$(cat <<'EOF' -## Summary -- - -## Acceptance Criteria -- [x] `@id:`: -- [x] `@id:`: - -## Testing -- All tests pass: `task test` -- Linting clean: `task lint` -- Type checking clean: `task static-check` -- Application runs: `timeout 10s task run` (exit 124 = hung = fix it) - -## Reviewer Notes - -EOF -)" -``` - -## PR Checklist Before Creating - -- [ ] Branch is up to date with main (`git rebase main`) -- [ ] All commits follow conventional commit format -- [ ] `task lint` exits 0 -- [ ] `task static-check` exits 0 -- [ ] `task test` exits 0, coverage 100% -- [ ] `timeout 10s task run` exits with code ≠ 124 -- [ ] PR description includes all `@id` acceptance criteria - -## Merging - -Use squash merge for feature branches to keep main history clean: -```bash -gh pr merge --squash --delete-branch -``` - -After merge, update local main: -```bash -git checkout main -git pull origin main -``` diff --git a/.opencode/skills/refactor/SKILL.md b/.opencode/skills/refactor/SKILL.md deleted file mode 100644 index 208d12d..0000000 --- a/.opencode/skills/refactor/SKILL.md +++ /dev/null @@ -1,417 +0,0 @@ ---- -name: refactor -description: Safe refactoring protocol for TDD — green bar rule, two-hats discipline, preparatory refactoring, and Fowler catalogue -version: "1.0" -author: software-engineer -audience: software-engineer -workflow: feature-lifecycle ---- - -# Refactor - -Load this skill when entering the REFACTOR phase of a TDD cycle, or before starting RED on a new `@id` when preparatory refactoring is needed. - -Sources: Fowler *Refactoring* 2nd ed. (2018); Beck *Canon TDD* (2023); Beck *Tidy First?* (2023); Martin *SOLID* (2000); Bay *Object Calisthenics* (2005). See `docs/scientific-research/oop-design.md` and `docs/scientific-research/refactoring-empirical.md`. - ---- - -## The Definition - -A refactoring is a **behavior-preserving** transformation of internal structure. If the transformation changes observable behavior, it is not a refactoring — it is a feature change, and requires its own RED-GREEN-REFACTOR cycle. - ---- - -## The Green Bar Rule (absolute) - -**Refactoring is only permitted while all existing tests pass.** - -Every individual refactoring step must leave `test-fast` green. There are no exceptions. - ---- - -## The Two-Hats Rule - -Wear one hat at a time: - -| Hat | Activity | Allowed during this hat | -|---|---|---| -| **Feature hat** | RED → GREEN | Write failing test, write minimum code to pass | -| **Refactoring hat** | REFACTOR | Restructure passing code; never add new behavior | - -**Never mix hats in the same step.** If you discover a refactoring is needed while making a test pass (GREEN), note it — finish GREEN first, then switch hats. - ---- - -## When to Load This Skill - -### 1. REFACTOR phase (opportunistic) - -After GREEN: `test-fast` passes for the current `@id`. Now restructure. - -### 2. Preparatory refactoring (before RED) - -When the current structure would make the next `@id` awkward to implement: -- Put on the **refactoring hat first** -- Refactor until the feature is easy to add -- Commit the preparatory refactoring separately (see Commit Discipline) -- Then put on the feature hat and run RED-GREEN-REFACTOR normally - -Beck: *"For each desired change, make the change easy (warning: this may be hard), then make the easy change."* - ---- - -## Refactoring Protocol - -### Step 1 — Identify the smell - -Run the smell checklist from your Self-Declaration or from the Architecture Smell Check: - -| Smell | Likely catalogue entry | -|---|---| -| Function needs a comment to explain it | Extract Function | -| Class does two jobs | Extract Class | -| Method uses another class's data more than its own | Move Function | -| Same parameter group in multiple signatures | Introduce Parameter Object | -| Primitive with behaviour (money, email, range) | Replace Primitive with Object | -| Local variable holds a computed result | Replace Temp with Query | -| `isinstance` / type-flag conditionals | Replace Conditional with Polymorphism | -| Multiple functions share a data cluster | Combine Functions into Class | -| Nested conditions beyond 2 levels | Decompose Conditional / Guard Clauses | -| Object construction scattered without pattern | Factory Method / Builder | -| Scattered notification or state transition | Observer / State | -| Type-switching across callers | Strategy / Visitor | - -If pattern smell detected: load `skill design-patterns` for before/after examples. - -### Step 2 — Apply one catalogue entry at a time - -Apply a **single** catalogue entry, then run `test-fast` before moving to the next. - -Never batch multiple catalogue entries into one step — you lose the ability to pinpoint which step broke something. - -### Step 3 — Run after each step - -```bash -uv run task test-fast -``` - -All tests green → proceed to next catalogue entry. -Any test red → see "When a Refactoring Breaks a Test" below. - -### Step 4 — Commit when smell-free - -Once no smells remain and `test-fast` is green: - -```bash -uv run task test-fast # must pass -``` - -Commit (see Commit Discipline below). - ---- - -## Key Catalogue Entries - -### Extract Function -Pull a cohesive fragment into a named function. Trigger: the fragment needs a comment to explain it. - -```python -# Before -def process(order): - # apply 10% discount - order.total = order.total * Decimal("0.9") - send_confirmation(order) - -# After -def apply_discount(order: Order) -> None: - """Apply the standard 10% discount.""" - order.total = order.total * Decimal("0.9") - -def process(order: Order) -> None: - """Process an order.""" - apply_discount(order) - send_confirmation(order) -``` - -### Extract Class -Split a class doing two jobs. Trigger: data cluster + related behaviours that travel together. - -```python -# Before -@dataclass -class Order: - id: str - street: str - city: str - total: Decimal - -# After -@dataclass(frozen=True, slots=True) -class Address: - """A delivery address.""" - street: str - city: str - -@dataclass -class Order: - """An order placed by a customer.""" - id: str - address: Address - total: Decimal -``` - -### Introduce Parameter Object -Replace a recurring parameter group with a value object. Trigger: same 2+ params appear together across multiple signatures. - -```python -# Before -def summarise(start_date: date, end_date: date) -> Report: ... -def filter_events(start_date: date, end_date: date) -> list[Event]: ... - -# After -@dataclass(frozen=True, slots=True) -class DateRange: - """An inclusive date range.""" - start: date - end: date - -def summarise(period: DateRange) -> Report: ... -def filter_events(period: DateRange) -> list[Event]: ... -``` - -### Replace Primitive with Object -Elevate a domain primitive to a class with behaviour. Trigger: primitive has validation rules or operations. - -```python -# Before -def send_invoice(email: str) -> None: ... - -# After -@dataclass(frozen=True, slots=True) -class EmailAddress: - """A validated email address.""" - value: str - - def validate(self) -> None: - """Validate the email format. - - Raises: - ValueError: if the address has no '@' character. - """ - if "@" not in self.value: - raise ValueError(f"Invalid email: {self.value!r}") - -def send_invoice(email: EmailAddress) -> None: ... -``` - -### Decompose Conditional / Guard Clauses -Flatten nested logic to ≤2 levels. Trigger: OC-1 violation or deeply nested `if` chains. - -```python -# Before -def process(order): - if order is not None: - if order.total > 0: - if order.is_confirmed: - ship(order) - -# After -def process(order: Order | None) -> None: - """Ship a confirmed order.""" - if order is None: - return - if order.total <= 0: - return - if not order.is_confirmed: - return - ship(order) -``` - ---- - -## When a Refactoring Breaks a Test - -A refactoring that breaks a test is **not a refactoring**. Stop. Diagnose: - -### Diagnosis flow - -``` -Test fails after a structural change - │ - ▼ -Is the test testing internal structure -(private methods, specific call chains, -concrete types) rather than observable behavior? - │ - YES │ NO - │ └──→ The "refactoring" changed observable behavior. - │ This is a FEATURE CHANGE. - │ Revert the step. - │ Put on the feature hat. - │ Run RED-GREEN-REFACTOR for it explicitly. - ▼ -Rewrite the test to use the public interface. -Re-apply the refactoring step. -Run test-fast — must be green. -``` - -**Never delete a failing test without diagnosing it first.** - ---- - -## Commit Discipline - -Refactoring commits are always **separate** from feature commits. - -| Commit type | Message format | When | -|---|---|---| -| Preparatory refactoring | `refactor(): ` | Before RED, to make the feature easier | -| REFACTOR phase | `refactor(): ` | After GREEN, cleaning up the green code | -| Feature addition | `feat(): ` | After GREEN (never mixed with refactor) | - -Never mix a structural cleanup with a behavior addition in one commit. This keeps history bisectable and CI green at every commit. - ---- - -## Self-Declaration Check (before exiting REFACTOR) - -Before marking the `@id` complete, verify all of the following. Each failed item is a smell — apply the catalogue entry, run `test-fast`, then re-check. - -### Green Bar -- [ ] `test-fast` passes -- [ ] No smell from the checklist in Step 1 remains - -### Object Calisthenics (Bay 2005) -| Rule | Constraint | Violation signal | -|---|---|---| -| OC-1 | One indent level per method | `for` inside `if` inside a method body | -| OC-2 | No `else` after `return` | `if cond: return x` then `else: return y` | -| OC-3 | Wrap primitives with domain meaning | `def process(user_id: int)` instead of `UserId` | -| OC-4 | Wrap collections with domain meaning | `list[Order]` passed around instead of `OrderCollection` | -| OC-5 | One dot per line | `obj.repo.find(id).name` | -| OC-6 | No abbreviations | `usr`, `mgr`, `cfg`, `val`, `tmp` | -| OC-7 | Classes ≤ 50 lines, methods ≤ 20 lines | Any method requiring scrolling | -| OC-8 | ≤ 2 instance variables per class *(behavioural classes only; dataclasses, Pydantic models, value objects, and TypedDicts are exempt)* | `__init__` with 3+ `self.x =` assignments in a behavioural class | -| OC-9 | No getters/setters | `def get_name(self)` / `def set_name(self, v)` | - -### SOLID (Martin 2000) -| Principle | Check | -|---|---| -| **S** — Single Responsibility | Does this class have exactly one reason to change? | -| **O** — Open/Closed | Can new behavior be added without editing this class? | -| **L** — Liskov Substitution | Do all subtypes honor the full contract of their base type? | -| **I** — Interface Segregation | Does every implementor use every method in the Protocol? | -| **D** — Dependency Inversion | Does domain code depend only on Protocols, not concrete I/O? | - -#### SOLID Python signals - -**S — Single Responsibility** -```python -# WRONG — Report handles both data and formatting -class Report: - def generate(self) -> dict: ... - def to_pdf(self) -> bytes: ... # separate concern - def to_csv(self) -> str: ... # separate concern - -# RIGHT -class Report: - def generate(self) -> ReportData: ... - -class PdfRenderer: - def render(self, data: ReportData) -> bytes: ... -``` - -**O — Open/Closed** -```python -# WRONG — must edit this function to add a new format -def export(data: ReportData, fmt: str) -> bytes: - if fmt == "pdf": ... - elif fmt == "csv": ... - -# RIGHT — new formats extend without touching existing code -class Exporter(Protocol): - def export(self, data: ReportData) -> bytes: ... -``` - -**L — Liskov Substitution** -```python -# WRONG — ReadOnlyFile narrows the contract of File -class ReadOnlyFile(File): - def write(self, content: str) -> None: - raise PermissionError # LSP violation - -# RIGHT — separate interfaces -class ReadableFile(Protocol): - def read(self) -> str: ... - -class WritableFile(Protocol): - def write(self, content: str) -> None: ... -``` - -**I — Interface Segregation** -```python -# WRONG — Printer forced to implement scan() and fax() -class Machine(Protocol): - def print(self, doc: Document) -> None: ... - def scan(self, doc: Document) -> None: ... - def fax(self, doc: Document) -> None: ... - -# RIGHT -class Printer(Protocol): - def print(self, doc: Document) -> None: ... - -class Scanner(Protocol): - def scan(self, doc: Document) -> None: ... -``` - -**D — Dependency Inversion** -```python -# WRONG — domain imports infrastructure directly -from app.db import PostgresConnection - -class OrderRepository: - def __init__(self) -> None: - self.db = PostgresConnection() - -# RIGHT — domain defines the Protocol; infra implements it -class OrderRepository(Protocol): - def find(self, order_id: OrderId) -> Order: ... - def save(self, order: Order) -> None: ... - -class PostgresOrderRepository: # in adapters/ - def find(self, order_id: OrderId) -> Order: ... - def save(self, order: Order) -> None: ... -``` - -### Law of Demeter / Tell, Don't Ask / CQS - -**Law of Demeter** — a method should only call methods on: `self`, parameters, objects it creates, direct components (`self.x`). -- Violation signal: `a.b.c()` — two dots. Ask `a` to do the thing instead: `a.do_thing()`. - -**Tell, Don't Ask** — tell objects what to do; don't query state and decide externally. -```python -# WRONG -if order.status == OrderStatus.PENDING: - order.status = OrderStatus.CONFIRMED - -# RIGHT -order.confirm() -``` - -**Command-Query Separation** — a method either changes state (command) or returns a value (query), never both. -- Apply to domain objects. Do not fight stdlib (`list.pop()` is a known violation). - -### Python Zen (PEP 20) signals - -| Zen item | Code implication | -|---|---| -| Explicit is better than implicit | Explicit return types; explicit Protocol dependencies; no magic | -| Simple is better than complex | One function, one job; prefer a plain function over a class | -| Flat is better than nested | OC-1 — one indent level; early returns | -| Readability counts | OC-6 — no abbreviations; docstrings on every public item | -| Errors should never pass silently | No bare `except:`; no `except Exception: pass` | -| In the face of ambiguity, refuse to guess | Raise on invalid input; never silently return a default | - -### Type and docstring hygiene -- [ ] Type hints present on all public signatures -- [ ] Docstrings present on all public classes and methods diff --git a/.opencode/skills/scope/SKILL.md b/.opencode/skills/scope/SKILL.md deleted file mode 100644 index cd02bd5..0000000 --- a/.opencode/skills/scope/SKILL.md +++ /dev/null @@ -1,475 +0,0 @@ ---- -name: scope -description: Step 1 — discover requirements through stakeholder interviews and write Gherkin acceptance criteria -version: "5.0" -author: product-owner -audience: product-owner -workflow: feature-lifecycle ---- - -# Scope - -This skill guides the PO through Step 1 of the feature lifecycle: interviewing the stakeholder, discovering requirements, and writing Gherkin specifications precise enough for a developer to write tests without asking questions. - -## When to Use - -When the PO is starting a new project, adding features, or refining an existing feature. The output is a set of `.feature` files in `docs/features/backlog/` ready for development. - -## Overview - -Step 1 has two stages: - -| Stage | Who | Output | -|---|---|---| -| **Stage 1 — Discovery** | PO + stakeholder | `docs/discovery_journal.md` (Q&A) + `docs/discovery.md` (synthesis) + `.feature` descriptions | -| **Stage 2 — Specification** | PO alone | `Rule:` blocks + `Example:` blocks with `@id` tags in `.feature` files | - -Stage 1 is iterative and ongoing — sessions happen whenever the PO or stakeholder needs to discover or refine scope. Stage 2 runs per feature, only after that feature has `Status: BASELINED`. - ---- - -## Gap-Finding Techniques - -Three techniques are applied across all interview sessions to surface what stakeholders have not yet said. Use them during every session, not just at the end. - -### Critical Incident Technique (CIT) — Flanagan 1954 -Ask about a specific past event rather than a general description. Schema-based recall ("usually we...") hides edge cases and workarounds. A concrete incident forces actual memory. - -- "Tell me about a specific time when [X] worked exactly as you needed." -- "Tell me about a specific time when [X] broke down or frustrated you." -- Probe each incident: "What task were you doing? What happened next? What made it effective / ineffective?" - -### Laddering / Means-End Chain — Reynolds & Gutman 1988 -Climb from surface feature to underlying consequence to terminal value. The first answer is rarely the real constraint. - -- "Why is that important to you?" -- "What does that enable?" -- "What would break if that were not available?" -- Stop when the stakeholder reaches a value they cannot explain further. - -### CI Perspective Change — Fisher & Geiselman 1987 -Ask the stakeholder to describe the same situation from another actor's point of view. Peripheral details and cross-role concerns surface that the primary perspective conceals. - -- "What do you think the end user experiences in that situation?" -- "What would your team lead's concern be here?" -- "From the perspective of someone encountering this for the first time, what would they need to know?" - ---- - -## Active Listening Protocol - -Three levels of active listening apply throughout every interview session: - -- **Level 1 — Per answer**: immediately paraphrase each answer before moving to the next question. "So if I understand correctly, you're saying that X happens when Y?" Catches misunderstanding in the moment. -- **Level 2 — Per group**: brief synthesis when transitioning between behavior groups. "We've covered [area A] and [area B]. Before I ask about [area C], here is what I understood so far: [summary]. Does that capture it?" Confirms completeness, gives stakeholder a recovery point. -- **Level 3 — End of session**: full synthesis of everything discussed. Present to stakeholder for approval. This is the accuracy gate and the input to domain modeling. - -Do not introduce topic labels or categories during active listening. The summary must reflect what the stakeholder said, not new framing that prompts reactions to things they haven't considered. - ---- - -## Stage 1 — Discovery - -Discovery is a continuous, iterative process. Sessions happen whenever scope needs to be established or refined — for a new project, for a new feature, or when new information emerges. There is no "Phase 1" vs "Phase 2" distinction; every session follows the same structure. - -### Session Start (every session) - -**Before asking any questions:** - -1. Check `docs/discovery_journal.md` for the most recent session block. - - If the most recent block has `Status: IN-PROGRESS` → the previous session was interrupted. Resume it: check which `.feature` files need updating (compare journal Q&A against current `.feature` descriptions), write the `discovery.md` synthesis block if missing, then mark the block `Status: COMPLETE`. Only then begin a new session. - - If `docs/discovery_journal.md` does not exist → this is the first session. Create both `docs/discovery_journal.md` and `docs/discovery.md` using the templates at the end of this skill. -2. Open `docs/discovery_journal.md` and append a new session header: - ```markdown - ## YYYY-MM-DD — Session N - Status: IN-PROGRESS - ``` - Write this header **before** asking any questions. This is the durability marker — if the session is interrupted, the next agent sees `IN-PROGRESS` and knows writes are pending. - -### Question Order (within every session) - -Questions follow this order. Skip a group only if it was already fully covered in a prior session. - -**1. General questions** (skip entirely if any prior session has covered these) - -Ask all 7 at once: - -1. **Who** are the users of this product? -2. **What** does the product do at a high level? -3. **Why** does it exist — what problem does it solve? -4. **When** and **where** is it used (environment, platform, context)? -5. **Success** — how do we know it works? What does "done" look like? -6. **Failure** — what does failure look like? What must never happen? -7. **Out-of-scope** — what are we explicitly not building? - -Apply Level 1 active listening per answer. Apply CIT, Laddering, and CI Perspective Change per answer to surface gaps. Add new questions in the moment. - -**2. Cross-cutting questions** - -Target behavior groups, bounded contexts, integration points, lifecycle events, and system-wide constraints. Apply Level 2 active listening when transitioning between groups. - -**3. Feature questions** (one feature at a time) - -For each feature the session touches: -- Extract relevant nouns and verbs from `docs/discovery.md` Domain Model (if it exists) -- Generate questions from entity gaps: boundaries, edge cases, interactions, failure modes -- Run a silent pre-mortem: "Imagine the developer builds this feature exactly as described, all tests pass, but the feature doesn't work for the user. What would be missing?" -- Apply CIT, Laddering, and CI Perspective Change per question - -**Real-time split rule**: if, during feature questions, the PO detects >2 distinct concerns OR >8 candidate Examples for a single feature, **split immediately**: -1. Record the split in the journal: note the original feature name and the two new names -2. Create stub `.feature` files for both parts (if they don't already exist) -3. Continue feature questions for both new features in sequence within the same session - -### After Questions (PO alone, same session) - -**Step A — Write answered Q&A to journal** - -Append all answered Q&A to `docs/discovery_journal.md`, in groups (general, cross-cutting, then per-feature). Write only answered questions. Unanswered questions are discarded. - -Group headers use this format: -- General group: `### General` -- Cross-cutting group: `### ` -- Feature group: `### Feature: ` - -**Step B — Update .feature descriptions** - -For each feature touched in this session: rewrite the `.feature` file description to reflect the current state of understanding. Only touched features are updated; all others remain exactly as-is. - -If a feature is new (just created as a stub): write its initial description now. - -**Step C — Append session synthesis to discovery.md (LAST)** - -After all `.feature` files are updated, append one `## Session: YYYY-MM-DD` block to `docs/discovery.md`. The block contains: -- `### Feature List` — which features were added or changed (0–N entries); if nothing changed, write "No changes" -- `### Domain Model` — new or updated domain entities and verbs; if nothing changed, write "No changes" -- `### Context` (first session only) — 3–5 sentence synthesis of who the users are, what the product does, why it exists, success/failure conditions, and explicit out-of-scope - -**Step D — Mark session complete** - -Update the session header in `docs/discovery_journal.md`: -```markdown -## YYYY-MM-DD — Session N -Status: COMPLETE -``` - -**Commit**: `feat(discovery): ` - -### Baselining a Feature - -A feature is baselined when the stakeholder has explicitly approved its discovery. The PO writes `Status: BASELINED (YYYY-MM-DD)` in the `.feature` file. - -**Gate**: a feature may only be baselined when: -- Its description accurately reflects the stakeholder's approved understanding -- Its candidate user stories (Rule candidates) are identified -- The decomposition check passes: does not span >2 concerns AND does not have >8 candidate Examples - -A baselined feature is ready for Stage 2. The PO may baseline features one at a time — not all at once. - ---- - -## Stage 2 — Specification - -Stage 2 runs per feature, after `Status: BASELINED`. PO works alone. No stakeholder involvement. - -If the PO discovers a gap during Stage 2 that requires stakeholder input: stop Stage 2, open a new Stage 1 session, resolve the gap, then return to Stage 2. - -### Step A — Stories - -Derive `Rule:` blocks from the baselined feature description. One `Rule:` per user story. - -Each `Rule:` block contains: -- The rule title (2-4 words, kebab-friendly) -- The user story header as the rule description (no `Example:` blocks yet): - -```gherkin - Rule: Menu Display - As a player - I want to see a menu when the game starts - So that I can select game options -``` - -Good stories are: -- **Independent**: can be delivered without other stories -- **Negotiable**: details can be discussed -- **Valuable**: delivers something the user cares about -- **Estimable**: the developer can estimate effort -- **Small**: completable in one feature cycle -- **Testable**: can be verified with a concrete test - -Avoid: "As the system, I want..." (no business value). Break down stories that contain "and" into two Rules. - -**INVEST Gate** — verify every Rule before committing: - -| Letter | Question | FAIL action | -|---|---|---| -| **I**ndependent | Can this Rule be delivered without other Rules? | Split or reorder dependencies | -| **N**egotiable | Are details open to discussion with the developer? | Remove over-specification | -| **V**aluable | Does it deliver something the end user cares about? | Reframe or drop | -| **E**stimable | Can a developer estimate the effort? | Split or add discovery questions | -| **S**mall | Completable in one feature cycle? | Split into smaller Rules | -| **T**estable | Can it be verified with a concrete test? | Rewrite with observable outcomes | - -**Review checklist:** -- [ ] Every Rule has a distinct user role and benefit -- [ ] No Rule duplicates another -- [ ] Rules collectively cover all entities in scope from the feature description -- [ ] Every Rule passes the INVEST gate - -Commit: `feat(stories): write user stories for ` - -### Step B — Criteria - -Add `Example:` blocks under each `Rule:`. PO writes all Examples alone, based on the approved feature description and domain knowledge. No stakeholder review of individual Examples. - -**Silent pre-mortem per Rule** (before writing any Examples): - -> "What observable behaviors must we prove for this Rule to be complete?" - -All Rules must have their pre-mortems completed before any Examples are written. - -**Example format** (mandatory): - -```gherkin - Rule: Wall bounce - As a game engine - I want balls to bounce off walls - So that gameplay feels physical - - @id:a3f2b1c4 - Example: Ball bounces off top wall - Given a ball moving upward reaches y=0 - When the physics engine processes the next frame - Then the ball velocity y-component becomes positive -``` - -**Rules**: -- `Example:` keyword (not `Scenario:`) -- `Given/When/Then` in plain English -- `Then` must be a single, observable, measurable outcome — no "and" -- **Observable means observable by the end user**, not by a test harness -- **Declarative, not imperative** — describe behavior, not UI steps -- Each Example must be observably distinct from every other - -**Declarative vs. imperative Gherkin**: - -| Imperative (wrong) | Declarative (correct) | -|---|---| -| Given I type "bob" in the username field | Given a registered user Bob | -| When I click the Login button | When Bob logs in | -| Then I see "Welcome, Bob" on the dashboard | Then Bob sees a personalized welcome | - -**MoSCoW triage**: For each candidate Example, classify as Must (required for the Rule to be correct), Should (high value but deferrable), or Could (nice-to-have edge case). If Musts alone exceed 8 or the Rule spans >2 concerns, split the Rule. - -**Common mistakes to avoid**: -- "Then: It works correctly" — not measurable -- "Then: The system updates the database and sends an email" — split into two Examples -- Multiple behaviors in one Example — split them -- Examples that test implementation details ("Then: the Strategy pattern is used") -- Imperative UI steps instead of declarative behavior descriptions - -**Review checklist:** -- [ ] Every `Rule:` block has at least one Example -- [ ] Every Example has `Given/When/Then` -- [ ] Every `Then` is a single, observable, measurable outcome -- [ ] No Example tests implementation details -- [ ] If user interaction is involved, the interaction model is declared in the Feature description -- [ ] Each Example is observably distinct from every other -- [ ] No single feature file spans multiple unrelated concerns - -**Self-Declaration (mandatory before criteria commit)** - -Communicate verbally to the next agent. Every `DISAGREE` is a **hard blocker** — fix before committing. Do not commit until all items are AGREE or have a documented resolution. - -- INVEST-I: each Rule is Independent (no hidden ordering or dependency between Rules) — AGREE/DISAGREE | conflict: -- INVEST-V: each Rule delivers Value to a named user — AGREE/DISAGREE | Rule: -- INVEST-S: each Rule is Small enough for one development cycle — AGREE/DISAGREE | Rule: -- INVEST-T: each Rule is Testable (I can write a pass/fail Example for it) — AGREE/DISAGREE | Rule: -- Observable: every Then is a single, observable, measurable outcome — AGREE/DISAGREE | file:line -- No impl details: no Example tests internal state or implementation — AGREE/DISAGREE | file:line -- Coverage: every entity in the feature description appears in at least one Rule — AGREE/DISAGREE | missing: -- Distinct: no two Examples test the same observable behavior — AGREE/DISAGREE | file:line -- Pre-mortem: I ran a pre-mortem on each Rule and found no hidden failure modes — AGREE/DISAGREE | Rule: -- Scope: no Example introduces behavior outside the feature boundary — AGREE/DISAGREE | file:line - -Commit: `feat(criteria): write acceptance criteria for ` - -**After this commit, `Example:` blocks are frozen.** Any change requires: -1. Add `@deprecated` tag to the old Example -2. Write a new Example (the `@id` tag will be assigned automatically) - ---- - -## Bug Handling - -When a defect is reported against a completed or in-progress feature: - -1. **PO** adds a new Example to the relevant `Rule:` block in the `.feature` file: - - ```gherkin - @bug - Example: - Given - When - Then - ``` - -2. **SE** implements the specific test in `tests/features//` (the `@id` test). -3. **SE** also writes a `@given` Hypothesis property test in `tests/unit/` covering the whole class of inputs that triggered the bug — not just the single case. -4. Both tests are required — neither is optional. -5. SE follows the normal TDD loop (Step 3) for the new `@id`. - ---- - -## Feature File Format - -Each feature is a single `.feature` file. The description block contains the feature description and Status. All Q&A belongs in `docs/discovery_journal.md`; all architectural decisions belong in `docs/architecture.md`. - -```gherkin -Feature: - - <2–4 sentence description of what this feature does and why it exists. - Written in plain language, always kept current by the PO.> - - Status: ELICITING | BASELINED (YYYY-MM-DD) - - Rules (Business): - - - - Constraints: - - - - Rule: - As a - I want - So that - - @id:a3f2b1c4 - Example: - Given - When - Then - - @deprecated @id:b5c6d7e8 - Example: - Given ... - When ... - Then ... -``` - -The **Rules (Business)** section captures business rules that hold across multiple Examples. Identifying rules first prevents redundant or contradictory Examples. - -The **Constraints** section captures non-functional requirements. Testable constraints should become `Example:` blocks with `@id` tags. - -What is **not** in `.feature` files: -- Entities table — domain model lives in `docs/discovery.md` -- Session Q&A blocks — live in `docs/discovery_journal.md` -- Template §N markers — live in `docs/discovery_journal.md` session blocks -- Architecture section — lives in `docs/architecture.md` - ---- - -## Project-Level Discovery Templates - -Three files hold project-level discovery content. Use these templates when creating them for the first time. - -### `docs/discovery_journal.md` — Raw Q&A (append-only) - -```markdown -# Discovery Journal: - ---- - -## YYYY-MM-DD — Session 1 -Status: IN-PROGRESS - -### General - -| ID | Question | Answer | -|----|----------|--------| -| Q1 | Who are the users? | ... | -| Q2 | What does the product do at a high level? | ... | -| Q3 | Why does it exist — what problem does it solve? | ... | -| Q4 | When and where is it used? | ... | -| Q5 | Success — what does "done" look like? | ... | -| Q6 | Failure — what must never happen? | ... | -| Q7 | Out-of-scope — what are we explicitly not building? | ... | - -### - -| ID | Question | Answer | -|----|----------|--------| -| Q8 | ... | ... | - -### Feature: - -| ID | Question | Answer | -|----|----------|--------| -| Q9 | ... | ... | - -Status: COMPLETE -``` - -Rules: -- Session header written first with `Status: IN-PROGRESS` before any Q&A -- Only answered questions are written; unanswered questions are discarded -- Questions grouped by topic (general, cross-cutting groups, per-feature) -- `Status: COMPLETE` written at the end of the session block, after all writes are done -- Never edit past entries — only append new session blocks - -### `docs/discovery.md` — Synthesis Changelog (append-only) - -```markdown -# Discovery: - ---- - -## Session: YYYY-MM-DD - -### Context -<3–5 sentence synthesis of who the users are, what the product does, why it exists, -success/failure conditions, and out-of-scope boundaries.> -(First session only. Omit this subsection in subsequent sessions.) - -### Feature List -- `` — -(Write "No changes" if no features were added or modified this session.) - -### Domain Model -| Type | Name | Description | In Scope | -|------|------|-------------|----------| -| Noun | | | Yes | -| Verb | | | Yes | -(Write "No changes" if domain model was not updated this session.) -``` - -Rules: -- Each session appends one `## Session: YYYY-MM-DD` block -- Synthesis block is written LAST — only after all `.feature` file descriptions are updated -- No project-level `Status: BASELINED` — feature-level BASELINED in `.feature` files is the gate -- Never edit past blocks — append only; later blocks extend or supersede earlier ones - -### `docs/architecture.md` — Architectural Decisions (append-only, software-engineer) - -```markdown -# Architecture: - ---- - -## YYYY-MM-DD — : - -Decision: -Reason: -Alternatives considered: -Feature: -``` - -Rules: Append-only. When a decision changes, append a new block that supersedes the old one. Cross-feature decisions use `Cross-feature:` in the header. Only write a block for non-obvious decisions with meaningful trade-offs. - -Base directory for this skill: file:///home/user/Documents/projects/python-project-template/.opencode/skills/scope -Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory. -Note: file list is sampled. - - -/home/user/Documents/projects/python-project-template/.opencode/skills/scope/discovery-template.md - diff --git a/.opencode/skills/scope/discovery-template.md b/.opencode/skills/scope/discovery-template.md deleted file mode 100644 index aa4cc5c..0000000 --- a/.opencode/skills/scope/discovery-template.md +++ /dev/null @@ -1,9 +0,0 @@ -Feature: - - <2–4 sentence description of what this feature does and why it exists.> - - Status: ELICITING - - Rules (Business): - - Constraints: diff --git a/.opencode/skills/session-workflow/SKILL.md b/.opencode/skills/session-workflow/SKILL.md deleted file mode 100644 index 0281f2c..0000000 --- a/.opencode/skills/session-workflow/SKILL.md +++ /dev/null @@ -1,144 +0,0 @@ ---- -name: session-workflow -description: Session start and end protocol — read TODO.md, continue from checkpoint, update and commit -version: "3.0" -author: software-engineer -audience: all-agents -workflow: session-management ---- - -# Session Workflow - -Every session starts by reading state. Every session ends by writing state. This makes any agent able to continue from where the last session stopped. - -## Session Start - -1. Read `TODO.md` — find current feature, current step, and the "Next" line. - - If `TODO.md` does not exist, create a basic one: - ```markdown - # Current Work - - No feature in progress. - Next: Run @product-owner — load skill feature-selection and pick the next BASELINED feature from backlog. - ``` -2. **If you are the PO** and Step 1 (SCOPE) is active: check `docs/discovery_journal.md` for the most recent session block. - - If the most recent block has `Status: IN-PROGRESS` → the previous session was interrupted. Resume it before starting a new session: finish updating `.feature` files and `docs/discovery.md`, then mark the block `Status: COMPLETE`. -3. If a feature is active at Step 2–5, read: - - `docs/features/in-progress/.feature` — feature file (Rules + Examples + @id) - - `docs/discovery.md` — project-level synthesis changelog (for context) -4. Run `git status` — understand what is committed vs. what is not -5. Confirm scope: you are working on exactly one step of one feature - -**If TODO.md says "No feature in progress":** - -- **PO**: Load `skill feature-selection` — it guides you through scoring and selecting the next BASELINED backlog feature. You must verify the feature has `Status: BASELINED` before moving it to `in-progress/`. Only you may move it. -- **Software-engineer or reviewer**: Update TODO.md `Next:` line to `Run @product-owner — load skill feature-selection and pick the next BASELINED feature from backlog.` Then **stop**. Never self-select a feature. Never move a `.feature` file. - -## Session End - -1. Update TODO.md: - - Mark completed criteria `[x]` - - Mark in-progress criteria `[~]` - - Update the "Next" line with one concrete action -2. Commit any uncommitted work (even WIP): - ```bash - git add -A - git commit -m "WIP(): " - ``` -3. If a step is fully complete, use the proper commit message instead of WIP. - -## Step Completion Protocol - -When a step completes within a session: - -1. Update TODO.md to reflect the completed step before doing any other work. -2. Commit the TODO.md update: - ```bash - git add TODO.md - git commit -m "chore: complete step for " - ``` -3. Only then begin the next step (in a new session where possible — see Rule 4). - -## TODO.md Format - -```markdown -# Current Work - -Feature: -Step: <1-5> () -Source: docs/features/in-progress/.feature - -## Progress -- [x] `@id:`: -- [~] `@id:`: ← IN PROGRESS -- [ ] `@id:`: - -## Next -Run @ -``` - -**"Next" line format**: Always prefix with `Run @` so the human knows exactly which agent to invoke. Agent names are defined in `AGENTS.md` — use the name exactly as listed there. Examples: -- `Run @ — implement @id:a1b2c3d4 (Step 3 RED)` -- `Run @ — load skill implementation and begin Step 2 (Architecture) for ` -- `Run @ — verify feature at Step 4` -- `Run @ — pick next BASELINED feature from backlog` -- `Run @ — accept feature at Step 5` - -**Source path by step:** -- Step 1: `Source: docs/features/backlog/.feature` -- Steps 2–4: `Source: docs/features/in-progress/.feature` -- Step 5: `Source: docs/features/completed/.feature` - -Status markers: -- `[ ]` — not started -- `[~]` — in progress -- `[x]` — complete -- `[-]` — cancelled/skipped - -When no feature is active: -```markdown -# Current Work - -No feature in progress. -Next: Run @ — load skill feature-selection and pick the next BASELINED feature from backlog. -``` - -## Step 3 (TDD Loop) Cycle-Aware TODO Format - -During Step 3 (TDD Loop), TODO.md **must** include a `## Cycle State` block to track Red-Green-Refactor progress. - -```markdown -# Current Work - -Feature: -Step: 3 (TDD Loop) -Source: docs/features/in-progress/.feature - -## Cycle State -Test: `@id:` — -Phase: RED | GREEN | REFACTOR - -## Progress -- [x] `@id:`: -- [~] `@id:`: ← in progress (see Cycle State) -- [ ] `@id:`: ← next - -## Next - -``` - -### Phase Transitions - -- Move from `RED` → `GREEN` when the test fails with a real assertion -- Move from `GREEN` → `REFACTOR` when the test passes -- Move from `REFACTOR` → mark `@id` complete in `## Progress` when test-fast passes - -## Rules - -1. Never skip reading TODO.md at session start -2. Never end a session without updating TODO.md -3. Never leave uncommitted changes — commit as WIP if needed -4. One step per session where possible; do not start Step N+1 in the same session as Step N -5. The "Next" line must be actionable enough that a fresh AI can execute it without asking questions -6. During Step 3, always update `## Cycle State` when transitioning between RED/GREEN/REFACTOR phases -7. When a step completes, update TODO.md and commit **before** any further work diff --git a/.opencode/skills/verify/SKILL.md b/.opencode/skills/verify/SKILL.md deleted file mode 100644 index 3d5c449..0000000 --- a/.opencode/skills/verify/SKILL.md +++ /dev/null @@ -1,241 +0,0 @@ ---- -name: verify -description: Step 4 — run all verification commands, review code quality, and produce a written report -version: "4.0" -author: reviewer -audience: reviewer -workflow: feature-lifecycle ---- - -# Verify - -This skill guides the reviewer through Step 4: independent verification that the feature works correctly and meets quality standards. The output is a written report with a clear APPROVED or REJECTED decision. - -**Your default hypothesis is that the code is broken despite passing automated checks. Your job is to find the failure mode. If you cannot find one after thorough investigation, APPROVE. If you find one, REJECTED.** - -**Every PASS/FAIL cell must have evidence.** Empty evidence = UNCHECKED = REJECTED. - -**You never move `.feature` files.** After producing an APPROVED report: update TODO.md `Next:` to `Run @product-owner — accept feature at Step 5.` then stop. The PO accepts the feature and moves the file. - -The reviewer produces one written report (see template below) that includes: all gate results, the SE Self-Declaration Audit, the **Reviewer Stance Declaration**, and the final APPROVED/REJECTED verdict. Do not start until the software-engineer has committed all work and communicated the Self-Declaration verbally in the handoff message. - -## Step-by-Step - -### 1. Read the Feature Docs - -Read `docs/features/in-progress/.feature`. Extract: -- All `@id` tags and their Example titles from `Rule:` blocks -- The interaction model (if the feature involves user interaction) -- The architectural decisions in `docs/architecture.md` relevant to this feature -- The software-engineer's Self-Declaration (communicated verbally in the handoff message) - -### 2. pyproject.toml Gate - -```bash -git diff main -- pyproject.toml -``` - -Any change → REJECT immediately. The software-engineer must revert and get stakeholder approval. - -### 3. Check Commit History - -```bash -git log --oneline -20 -git status -``` - -Verify: -- Commits follow conventional commit format -- No "fix tests", "wip", "temp" commits -- No uncommitted changes: `git status` should be clean - -### 4. Production-Grade Gate - -Run before code review. If any row is FAIL, stop immediately with REJECTED. - -| Check | How to check | PASS | FAIL | Fix | -|---|---|---|---|---| -| App exits cleanly | `timeout 10s uv run task run` | Exit 0 or non-124 | Exit 124 (timeout/hang) | Fix the hang | -| Output changes when input changes | Run app, change an input or condition, observe output | Output changes accordingly | Output is static | Implement real logic | - -### 5. Self-Declaration Audit - -**Completeness check (hard gate — REJECT if failed)**: Count the numbered items in the SE's Self-Declaration. The template in `implementation/SKILL.md` has exactly 25 items numbered 1–25. If the count is not 25, or any number in the sequence 1–25 is missing, REJECT immediately — do not proceed to item-level audit. - -Read the software-engineer's Self-Declaration from the handoff message. - -For every **AGREE** claim: -- Find the `file:line` — does it hold? - -For every **DISAGREE** claim: -- Read the justification carefully. -- If the constraint genuinely falls outside the SE's control (e.g. external library forces method chaining, dataclass/Pydantic/TypedDict exemption for ≤2 ivars): accept with a note in the report and suggest the closest compliant alternative if one exists. -- If the justification is weak, incomplete, or a best-practice alternative exists that the SE did not consider: REJECT with the specific alternative stated. -- If there is no justification: REJECT. - -Undeclared violations found during code review → REJECT. - -### 6. Code Review - -Read the source files changed in this feature. **Do this before running lint/static-check/test** — if code review finds a design problem, commands will need to re-run after the fix anyway. - -**Stop on first failure category — do not accumulate issues.** - -#### 6a. Correctness — any FAIL → REJECTED - -| Check | How to check | PASS | FAIL | Fix | -|---|---|---|---|---| -| No dead code | Read for unreachable statements, unused variables, impossible branches | None found | Any found | Remove or fix | -| No duplicate logic (DRY) | Search for repeated blocks doing the same thing | None found | Duplication found | Extract to shared function | -| No over-engineering (YAGNI) | Check for abstractions with no current use | None found | Unused abstraction | Remove unused code | - -#### 6b. Simplicity (KISS) — any FAIL → REJECTED - -| Check | How to check | PASS | FAIL | Fix | -|---|---|---|---|---| -| Functions do one thing | Read each function; can you describe it without `and`? | Yes | No | Split into focused functions | -| Nesting ≤ 2 levels | Count indent levels in each function | ≤ 2 | > 2 | Extract inner block | -| Functions ≤ 20 lines | Count lines | ≤ 20 | > 20 | Extract helper | -| Classes ≤ 50 lines | Count lines | ≤ 50 | > 50 | Split class | - -#### 6c. SOLID — any FAIL → REJECTED - -| Principle | Why it matters | What to check | How to check | -|---|---|---|---| -| SRP | Multiple change-reasons accumulate bugs | Each class/function has one reason to change | Count distinct concerns | -| OCP | Modifying existing code invalidates tests | New behavior via extension, not modification | Check if adding new case required editing existing class | -| LSP | Substitution failures cause silent errors | Subtypes behave identically to base | Check for narrowed contracts | -| ISP | Fat interfaces force unused methods | No Protocol forces stub implementations | Check for NotImplementedError | -| DIP | Concrete I/O makes unit testing impossible | High-level depends on abstractions | Check domain imports no I/O/DB | - -#### 6d. Object Calisthenics — any FAIL → REJECTED - -Load `skill design-patterns` and apply the full OC checklist (9 rules). Record a PASS/FAIL with `file:line` evidence for each rule. Rules 1 and 7 (nesting and entity size) share thresholds with 6b above. - -#### 6e. Design Patterns — any FAIL → REJECTED - -| Code smell | Pattern missed | How to check | -|---|---|---| -| Multiple if/elif on type/state | State or Strategy | Search for `isinstance` chains | -| Complex `__init__` | Factory or Builder | Check line count and side effects | -| Callers know multiple components | Facade | Check caller coupling | -| External dep without Protocol | Repository/Adapter | Check dep injection | -| 0 domain classes, many functions | Missing domain model | Count classes vs functions | - -#### 6f. Tests — any FAIL → REJECTED - -| Check | How to check | PASS | FAIL | -|---|---|---|---| -| Docstring format | Read each test docstring | Given/When/Then only | Extra metadata | -| Contract test | Would test survive internal rewrite? | Yes | No | -| No internal attribute access | Search for `_x` in assertions | None found | `_x`, `isinstance`, `type()` | -| Every `@id` has a mapped test | Match `@id` to test functions | All mapped | Missing test | -| No orphaned skipped stubs | Search for `@pytest.mark.skip` in `tests/features/` | None found | Any found — stub was written but never implemented | -| Function naming | Matches `test__<8char_hex>` | All match | Mismatch | -| Hypothesis tests have `@slow` | Read every `@given` for `@slow` marker | All present | Any missing | - -#### 6g. Code Quality — any FAIL → REJECTED - -| Check | How to check | PASS | FAIL | -|---|---|---|---| -| No `noqa` comments | `grep -r "noqa" /` | None found | Any found | -| No `type: ignore` | `grep -r "type: ignore" /` | None found | Any found | -| Public functions have type hints | Read signatures | All annotated | Missing | -| Public functions have docstrings | Read source | Google-style | Missing | - -### 7. Run Verification Commands - -```bash -uv run task lint -uv run task static-check -uv run task test -``` - -Expected for each: exit 0, no errors. Record exact output on failure. - -If a command fails, stop and REJECT immediately. Do not run subsequent commands. - -### 8. Interactive Verification - -If the feature involves user interaction: run the app, provide real input, verify output changes. - -Record what input was given and what output was observed. - -### 9. Write the Report - -```markdown -## Step 4 Verification Report — - -### pyproject.toml Gate -| Check | Result | Notes | -|---|---|---| -| No changes from main | PASS / FAIL | | - -### Production-Grade Gate -| Check | Result | Notes | -|---|---|---| -| App exits cleanly | PASS / FAIL / TIMEOUT | | -| Output driven by input | PASS / FAIL | | - -### Commands -| Command | Result | Notes | -|---------|--------|-------| -| uv run task lint | PASS / FAIL | | -| uv run task static-check | PASS / FAIL | | -| uv run task test | PASS / FAIL | | - -### Self-Declaration Audit -| # | Claim | SE Claims | Reviewer Verdict | Evidence | -|---|-------|-----------|------------------|----------| -| 1 | YAGNI: no code without a failing test | AGREE/DISAGREE | PASS/FAIL | | -| 2 | YAGNI: no speculative abstractions | AGREE/DISAGREE | PASS/FAIL | | -| 3 | KISS: simplest solution that passes | AGREE/DISAGREE | PASS/FAIL | | -| 4 | KISS: no premature optimization | AGREE/DISAGREE | PASS/FAIL | | -| 5 | DRY: no duplication | AGREE/DISAGREE | PASS/FAIL | | -| 6 | DRY: no redundant comments | AGREE/DISAGREE | PASS/FAIL | | -| 7 | SOLID-S: one reason to change per class | AGREE/DISAGREE | PASS/FAIL | | -| 8 | SOLID-O: open for extension, closed for modification | AGREE/DISAGREE | PASS/FAIL | | -| 9 | SOLID-L: subtypes substitutable | AGREE/DISAGREE | PASS/FAIL | | -| 10 | SOLID-I: no forced unused deps | AGREE/DISAGREE | PASS/FAIL | | -| 11 | SOLID-D: depend on abstractions, not concretions | AGREE/DISAGREE | PASS/FAIL | | -| 12 | OC-1: one level of indentation per method | AGREE/DISAGREE | PASS/FAIL | | -| 13 | OC-2: no else after return | AGREE/DISAGREE | PASS/FAIL | | -| 14 | OC-3: primitive types wrapped | AGREE/DISAGREE | PASS/FAIL | | -| 15 | OC-4: first-class collections | AGREE/DISAGREE | PASS/FAIL | | -| 16 | OC-5: one dot per line | AGREE/DISAGREE | PASS/FAIL | | -| 17 | OC-6: no abbreviations | AGREE/DISAGREE | PASS/FAIL | | -| 18 | OC-7: ≤20 lines per function, ≤50 per class | AGREE/DISAGREE | PASS/FAIL | | -| 19 | OC-8: ≤2 instance variables (behavioural classes only) | AGREE/DISAGREE | PASS/FAIL | | -| 20 | OC-9: no getters/setters | AGREE/DISAGREE | PASS/FAIL | | -| 21 | Patterns: no good reason remains to refactor using OOP or Design Patterns | AGREE/DISAGREE | PASS/FAIL | | -| 22 | Patterns: no creational smell | AGREE/DISAGREE | PASS/FAIL | | -| 23 | Patterns: no structural smell | AGREE/DISAGREE | PASS/FAIL | | -| 24 | Patterns: no behavioral smell | AGREE/DISAGREE | PASS/FAIL | | -| 25 | Semantic: tests operate at same abstraction as AC | AGREE/DISAGREE | PASS/FAIL | | - -### Reviewer Stance Declaration - -Write this block **before** the Decision. Every `DISAGREE` must include an inline explanation. A `DISAGREE` with no explanation auto-forces `REJECTED`. - -```markdown -## Reviewer Stance Declaration -As a reviewer I declare: -* Adversarial: I actively tried to find a failure mode, not just confirm passing — AGREE/DISAGREE | note: -* Manual trace: I traced at least one execution path manually beyond automated output — AGREE/DISAGREE | path: -* Boundary check: I checked the boundary conditions and edge cases of every Rule — AGREE/DISAGREE | gaps: -* Semantic read: I read each test against its AC and confirmed it tests the right observable behavior — AGREE/DISAGREE | mismatches: -* Independence: my verdict was not influenced by how much effort has already been spent — AGREE/DISAGREE -``` - -### Decision -**APPROVED** — all gates passed, no undeclared violations -OR -**REJECTED** — fix the following: -1. `:` — - -### Next Steps -**If APPROVED**: Run `@product-owner` — accept the feature at Step 5. -**If REJECTED**: Run `@software-engineer` — apply the fixes listed above, re-run quality gate, update Self-Declaration, then signal Step 4 again. -``` - - diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 6733b65..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,251 +0,0 @@ -# Python Project Template - -A Python template to quickstart any project with a production-ready workflow, quality tooling, and AI-assisted development. - -## Workflow Overview - -Features flow through 5 steps with a WIP limit of 1 feature at a time. The filesystem enforces WIP: -- `docs/features/backlog/.feature` — features waiting to be worked on -- `docs/features/in-progress/.feature` — exactly one feature being built right now -- `docs/features/completed/.feature` — accepted and shipped features - -``` -STEP 1: SCOPE (product-owner) → discovery + Gherkin stories + criteria -STEP 2: ARCH (software-engineer) → read all features + existing package files, write domain stubs (signatures only, no bodies); append decisions to docs/architecture.md -STEP 3: TDD LOOP (software-engineer) → RED → GREEN → REFACTOR, one @id at a time -STEP 4: VERIFY (reviewer) → run all commands, review code -STEP 5: ACCEPT (product-owner) → demo, validate, move .feature to completed/ (PO only) -``` - -**PO picks the next feature from backlog. Software-engineer never self-selects.** - -**Verification is adversarial.** The reviewer's job is to try to break the feature, not to confirm it works. The default hypothesis is "it might be broken despite green checks; prove otherwise." - -## Roles - -- **Product Owner (PO)** — AI agent. Interviews the stakeholder, writes discovery docs, Gherkin features, and acceptance criteria. Accepts or rejects deliveries. -- **Stakeholder** — Human. Answers PO's questions, provides domain knowledge, approves PO syntheses to confirm discovery is complete. -- **Software Engineer** — AI agent. Architecture, test bodies, implementation, git. Never moves or edits `.feature` files. Escalates spec gaps to PO. Escalates to PO if no feature is in progress. -- **Reviewer** — AI agent. Adversarial verification. Never moves `.feature` files. Reports spec gaps to PO. After APPROVED report, stops and escalates to PO for Step 5. - -## Agents - -- **product-owner** — defines scope (Stage 1 Discovery + Stage 2 Specification), picks features, accepts deliveries -- **software-engineer** — architecture, tests, code, git, releases (Steps 2-3 + release) -- **reviewer** — runs commands and reviews code at Step 4, produces APPROVED/REJECTED report -- **setup-project** — one-time setup to initialize a new project from this template - -## Skills - -| Skill | Used By | Step | -|---|---|---| -| `session-workflow` | all agents | every session | -| `feature-selection` | product-owner | between features (idle state) | -| `scope` | product-owner | 1 | -| `implementation` | software-engineer | 2, 3 | -| `design-patterns` | software-engineer | 2, 3 (on-demand, when GoF pattern needed) | -| `refactor` | software-engineer | 3 (REFACTOR phase + preparatory refactoring) | -| `verify` | reviewer | 4 | -| `code-quality` | software-engineer | pre-handoff (redirects to `verify`) | -| `pr-management` | software-engineer | 5 | -| `git-release` | software-engineer | 5 | -| `living-docs` | product-owner | 5 (after acceptance) + on stakeholder demand | -| `create-skill` | software-engineer | meta | -| `create-agent` | human-user | meta | - -**Session protocol**: Every agent loads `skill session-workflow` at session start. Load additional skills as needed for the current step. - -## Step 1 — SCOPE - -Step 1 has two stages: - -### Stage 1 — Discovery (PO + stakeholder, iterative) - -Discovery is a continuous process. Sessions happen whenever scope needs to be established or refined — for a new project, new features, or new information. Every session follows the same structure: - -**Session question order:** -1. **General** (5Ws + Success + Failure + Out-of-scope) — first session only, if the journal doesn't exist yet -2. **Cross-cutting** — behavior groups, bounded contexts, integration points, lifecycle events -3. **Per-feature** — one feature at a time; extract entities from `docs/discovery.md` Domain Model; gap-finding with CIT, Laddering, CI Perspective Change - -**Real-time split rule**: if the PO detects >2 concerns or >8 candidate Examples for a feature during per-feature questions, split immediately — record the split in the journal, create stub `.feature` files, continue questions for both in the same session. - -**After questions (PO alone, in order):** -1. Append answered Q&A (in groups) to `docs/discovery_journal.md` — only answered questions -2. Rewrite `.feature` description for each feature touched — others stay unchanged -3. Append session synthesis block to `docs/discovery.md` — LAST, after all `.feature` updates - -**Session status**: the journal session header begins with `Status: IN-PROGRESS` (written before questions). Updated to `Status: COMPLETE` after all writes. If a session is interrupted, the next agent detects `IN-PROGRESS` and resumes the pending writes before starting a new session. - -**Baselining**: PO writes `Status: BASELINED (YYYY-MM-DD)` in the `.feature` file when the stakeholder approves that feature's discovery and the decomposition check passes. - -Commit per session: `feat(discovery): ` - -### Stage 2 — Specification (PO alone, per feature) - -Only runs on features with `Status: BASELINED`. No stakeholder involvement. If a gap requires stakeholder input, open a new Stage 1 session first. - -**Step A — Stories**: derive one `Rule:` block per user story from the baselined feature description. INVEST gate: all 6 letters must pass. -Commit: `feat(stories): write user stories for ` - -**Step B — Criteria**: PO writes `Example:` blocks with `@id` tags under each `Rule:`. Pre-mortem per Rule before writing any Examples. MoSCoW triage per Example. Examples are frozen after commit. -Commit: `feat(criteria): write acceptance criteria for ` - -**Criteria are frozen**: no `Example:` changes after commit. Adding a new Example with a new `@id` replaces old. - -### Bug Handling - -When a defect is reported: -1. **PO** adds a `@bug @id:` Example to the relevant `Rule:` in the `.feature` file and moves (or keeps) the feature in `backlog/` for normal scheduling. -2. **SE** handles the bug when the feature is selected for development (standard Step 2–3 flow): implements the specific `@bug`-tagged test in `tests/features//` and also writes a `@given` Hypothesis property test in `tests/unit/` covering the whole class of inputs. -3. Both tests are required. SE follows the normal TDD loop (Step 3). - -## Filesystem Structure - -``` -docs/ - discovery_journal.md ← raw Q&A from all scope sessions (PO, append-only) - discovery.md ← project scope synthesis changelog (PO, append-only) - architecture.md ← all architectural decisions (SE, append-only) - glossary.md ← living glossary (living-docs skill, PO) - c4/ - context.md ← C4 Level 1 context diagram (living-docs skill, PO) - container.md ← C4 Level 2 container diagram (living-docs skill, PO) - post-mortem/ ← blameless post-mortem reports - features/ - backlog/.feature ← one per feature; feature description + Rules + Examples - in-progress/.feature ← exactly one feature being built right now - completed/.feature ← accepted and shipped features - -tests/ - features// - _test.py ← one per Rule: block, software-engineer-written - unit/ - _test.py ← software-engineer-authored extras (no @id traceability) -``` - -**Feature file ownership**: The PO is the sole owner of all `.feature` file moves. Software-engineer and reviewer never move, rename, or create `.feature` files. - -| Move | When | Who | -|---|---|---| -| `backlog/` → `in-progress/` | Feature selected at Step 2 start | PO only | -| `in-progress/` → `completed/` | After Step 4 APPROVED | PO only | - -When software-engineer or reviewer find no file in `docs/features/in-progress/`, they stop immediately and escalate to PO. - -Tests in `tests/unit/` are software-engineer-authored extras not covered by any `@id` criterion. Any test style is valid — plain `assert` or Hypothesis `@given`. Use Hypothesis when the test covers a **property** that holds across many inputs (mathematical invariants, parsing contracts, value object constraints). Use plain pytest for specific behaviors or single edge cases discovered during refactoring. - -- `@pytest.mark.slow` is mandatory on every `@given`-decorated test (Hypothesis is genuinely slow) -- `@example(...)` is optional but encouraged when using `@given` to document known corner cases -- No `@id` tags — tests with `@id` belong in `tests/features/`, written by software-engineer - -## Test File Layout - -``` -tests/features//_test.py -``` - -### Stub Format (mandatory) - -```python -@pytest.mark.skip(reason="not yet implemented") -def test__<@id>() -> None: - """ - <@id steps raw text including new lines> - """ -``` - -### Markers -- `@pytest.mark.slow` — takes > 50ms; applied to Hypothesis tests and any test with I/O, network, or DB -- `@pytest.mark.deprecated` — auto-skipped by conftest; used for superseded Examples - -## Development Commands - -```bash -# Install dependencies -uv sync --all-extras - -# Run the application (for humans) -uv run task run - -# Run the application with timeout (for agents — prevents hanging) -timeout 10s uv run task run - -# Run tests (fast, no coverage) -uv run task test-fast - -# Run full test suite with coverage -uv run task test - -# Run full test suite with coverage report generation -uv run task test-build - -# Lint and format -uv run task lint - -# Type checking -uv run task static-check - -# Build documentation -uv run task doc-build -``` - -## Code Quality Standards - -- **Principles (in priority order)**: YAGNI > KISS > DRY > SOLID > Object Calisthenics > appropriate design patterns > complex code > complicate code > failing code > no code -- **Linting**: ruff format, ruff check, Google docstring convention, `noqa` forbidden -- **Type checking**: pyright, 0 errors required -- **Coverage**: 100% (measured against your actual package) -- **Function length**: ≤ 20 lines (code lines only, excluding docstrings) -- **Class length**: ≤ 50 lines (code lines only, excluding docstrings) -- **Max nesting**: 2 levels -- **Instance variables**: ≤ 2 per class *(exception: dataclasses, Pydantic models, value objects, and TypedDicts are exempt — they may carry as many fields as the domain requires)* -- **Semantic alignment**: tests must operate at the same abstraction level as the acceptance criteria they cover -- **Integration tests**: multi-component features require at least one test in `tests/features/` that exercises the public entry point end-to-end - -### Software-Engineer Quality Gate Priority Order - -During Step 3 (TDD Loop), correctness priorities are: - -1. **Design correctness** — YAGNI > KISS > DRY > SOLID > Object Calisthenics > appropriated design patterns > complex code > complicated code > failing code > no code -2. **One test green** — the specific test under work passes, plus `test-fast` still passes -3. **Reviewer code-design check** — reviewer verifies design + semantic alignment (no lint/pyright/coverage yet) -5. **Quality tooling** — `lint`, `static-check`, full `test` with coverage run only at software-engineer handoff (before Step 4) - -Design correctness is far more important than lint/pyright/coverage compliance. A well-designed codebase with minor lint issues is better than a lint-clean codebase with poor design. - -## Verification Philosophy - -- **Automated checks** (lint, typecheck, coverage) verify **syntax-level** correctness — the code is well-formed. -- **Human review** (semantic alignment, code review, manual testing) verifies **semantic-level** correctness — the code does what the user needs. -- Both are required. All-green automated checks are necessary but not sufficient for APPROVED. -- Reviewer defaults to REJECTED unless correctness is proven. - -## Deprecation Process - -This template does not support deprecation. Criteria changes are handled by adding new Examples with new `@id` tags. - -## Release Management - -Version format: `v{major}.{minor}.{YYYYMMDD}` - -- Minor bump for new features; major bump for breaking changes -- Same-day second release: increment minor, keep same date -- Each release gets a unique adjective-animal name - -Use `@software-engineer /skill git-release` for the full release process. - -## Session Management - -Every session: load `skill session-workflow`. Read `TODO.md` first, update it at the end. - -See `skill session-workflow` for the full `TODO.md` structure including the Cycle State block used during Step 3. - -## Setup - -To initialize a new project from this template: -```bash -@setup-project -``` - -The setup agent will ask for your project name, GitHub username, author info, and configure all template placeholders. diff --git a/README.md b/README.md index fb0c853..c103edf 100644 --- a/README.md +++ b/README.md @@ -3,29 +3,27 @@

-

Keeps your Gherkin acceptance criteria and test stubs in sync — automatically, every time pytest runs.

+

Generates test stubs from Gherkin .feature files, keeps them in sync, and displays BDD steps in pytest output — automatically, every time pytest runs.

[![Contributors][contributors-shield]][contributors-url] [![Forks][forks-shield]][forks-url] [![Stargazers][stars-shield]][stars-url] [![Issues][issues-shield]][issues-url] [![MIT License][license-shield]][license-url] - [![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen?style=for-the-badge)](https://nullhack.github.io/pytest-beehave/coverage/) [![CI](https://img.shields.io/github/actions/workflow/status/nullhack/pytest-beehave/ci.yml?style=for-the-badge&label=CI)](https://github.com/nullhack/pytest-beehave/actions/workflows/ci.yml) - [![Python](https://img.shields.io/badge/python-3.13-blue?style=for-the-badge)](https://www.python.org/downloads/) + [![Python](https://img.shields.io/badge/python-3.14-blue?style=for-the-badge)](https://www.python.org/downloads/) --- ## What it does -pytest-beehave is a pytest plugin. Every time you run `pytest`, it reads your Gherkin `.feature` files and makes sure the corresponding test stub files are current: +pytest-beehave is a pytest plugin powered by the [beehave](https://pypi.org/project/beehave/) library. Every time you run `pytest`, it reads your Gherkin `.feature` files and keeps your test stubs in sync: -- **No stub for a new `Example:`?** It creates one — a typed, skipped test function with the Given/When/Then steps as its docstring. -- **Steps changed in the feature file?** It updates the docstring. Your test body is never touched. -- **`Example:` missing an `@id` tag?** It writes one back into the feature file in-place. -- **`@id` disappeared from the feature file?** It marks the test `skip(reason="orphan")` so nothing runs silently. -- **`@deprecated` tag on a feature or rule?** The `deprecated` pytest marker propagates down to every affected test. +- **New scenario?** It generates a typed, Hypothesis-compatible test stub — already marked `@pytest.mark.skip(reason="not implemented")` so it doesn't pollute your results. +- **Scenario Outline?** It generates `@example()` and `@given()` decorators with the right parameters. +- **Test drift?** `check_all()` detects orphan tests, misplaced tests, missing placeholders, and example mismatches — and reports them as real test failures. +- **Want to see your steps?** Run with `-v` and BDD steps appear under each test name. Install `pytest-html` and a "Scenario" column appears in the report. All of this happens in `pytest_configure` — before pytest collects a single test. @@ -33,9 +31,9 @@ All of this happens in `pytest_configure` — before pytest collects a single te ## Why pytest-beehave? -BDD frameworks sold a compelling promise: human-readable specifications that live alongside your tests, kept honest by the test suite itself. The promise is real. The implementation is the problem. Every scenario explodes into a constellation of `@given`, `@when`, and `@then` step functions scattered across multiple files, wired together by fragile string matching. Refactor one step and you're hunting across the codebase. Add a new scenario and you're registering glue code. The ceremony grows with every feature, and the spec drifts from reality anyway — silently in unused step definitions, loudly in broken ones, always painfully. Plain pytest, on the other hand, is refreshingly direct. But there's no business-readable layer: acceptance criteria live in tickets or comments, never in code, and nothing machine-enforces that what the stakeholder approved is what the test exercises. +BDD frameworks sold a compelling promise: human-readable specifications that live alongside your tests, kept honest by the test suite itself. The promise is real. The implementation is the problem. Every scenario explodes into a constellation of `@given`, `@when`, and `@then` step functions scattered across multiple files, wired together by fragile string matching. Refactor one step and you're hunting across the codebase. The ceremony grows with every feature, and the spec drifts from reality anyway. -pytest-beehave is the middle ground. Write your acceptance criteria in plain Gherkin — business-readable, version-controlled, owned by the team. The plugin does the worker-bee work: generating test stubs, keeping docstrings in sync with your steps, assigning stable IDs, and flagging drift before it silently rots. You implement the test body however you like, in plain pytest, with no step files and no glue. The hive stays in order automatically — that tedious, thankless, essential synchronisation work is handled so you never have to think about it again. +pytest-beehave is the middle ground. Write your acceptance criteria in plain Gherkin — business-readable, version-controlled, owned by the team. The plugin generates test stubs with the right names and structure, marks unimplemented ones as skipped, and flags drift before it silently rots. You implement the test body however you like, in plain pytest, with no step files and no glue code. --- @@ -47,167 +45,66 @@ pip install pytest-beehave No `conftest.py` changes required. The plugin registers itself via pytest's entry-point system. +Optional: install `pytest-beehave[html]` for a "Scenario" column in pytest-html reports. + +```bash +pip install "pytest-beehave[html]" +``` + --- ## Quick start -**1. Write a feature file with an untagged `Example:`:** +**1. Write a feature file:** ```gherkin -# docs/features/in-progress/checkout.feature -Feature: Checkout +# docs/features/checkout/shopping_cart.feature +Feature: Shopping cart + + Background: + Given an empty cart Rule: Tax calculation - Example: VAT is applied at the correct rate - Given a cart with items totalling £100 + Scenario: VAT is applied at the correct rate + Given a cart with items totalling $100 When the buyer is in the UK - Then the order total is £120 + Then the order total is $120 ``` **2. Run pytest:** ```bash -pytest +pytest --collect-only ``` -**3. Two things just happened automatically:** - -The feature file was updated with a stable ID: - -```gherkin - @id:a3f2b1c4 - Example: VAT is applied at the correct rate -``` - -And a test stub was created at `tests/features/checkout/tax_calculation_test.py`: +**3. A test stub was created at `tests/features/shopping_cart/tax_calculation_test.py`:** ```python import pytest -@pytest.mark.skip(reason="not yet implemented") -def test_checkout_a3f2b1c4() -> None: - """ - Given: a cart with items totalling £100 - When: the buyer is in the UK - Then: the order total is £120 - """ +@pytest.mark.skip(reason="not implemented") +def test_VAT_is_applied_at_the_correct_rate(): + ... ``` -**4. Implement the test and ship.** - -The stub is already in the right place with the right name. Fill in the body and remove the `skip`. - ---- +**4. Run pytest again:** -## See it in 2 minutes - -No feature files yet? Generate a working example project in one command: - -``` -$ pytest --beehave-hatch - -[beehave] HATCH backlog/forager-journey.feature -[beehave] HATCH in-progress/waggle-dance.feature -[beehave] HATCH completed/winter-preparation.feature -[beehave] hatch complete -``` - -Three bee-themed `.feature` files land under `docs/features/`, covering every Gherkin construct the plugin supports: `Background`, `Rule`, `Example`, `Scenario Outline` with an `Examples` table, data tables, untagged scenarios (to trigger auto-ID), and `@deprecated`. - -The `in-progress/waggle-dance.feature` file looks like this: - -```gherkin -# language: en -Feature: Waggle Dance Communication - - Background: - Given the hive is in active foraging mode - And the dance floor is clear of obstacles - - Rule: Direction encoding - - @id:hatch003 - Example: Scout encodes flower direction in waggle run angle - Given a scout has located flowers 200 metres to the north-east - When the scout performs the waggle dance - Then the waggle run angle matches the sun-relative bearing to the flowers - - Rule: Distance encoding - - @id:hatch004 - Scenario Outline: Scout encodes distance via waggle run duration - Given a scout has located flowers at metres - When the scout performs the waggle dance - Then the waggle run lasts approximately milliseconds - - Examples: - | distance | duration | - | 100 | 250 | - | 500 | 875 | - | 1000 | 1500 | - - @id:hatch005 - Example: Scout provides a data table of visited flower patches - Given the scout returns from a multi-patch forage - When the scout performs the waggle dance - Then the flower patch register contains the following entries: - | patch_id | species | quality | - | P-001 | Lavender | 0.92 | - | P-002 | Clover | 0.85 | - | P-003 | Sunflower | 0.78 | +```bash +pytest -v ``` -Now run pytest: - ``` -$ pytest - -[beehave] CREATE tests/features/forager_journey/forager_readiness_test.py -[beehave] CREATE tests/features/forager_journey/nectar_quality_control_test.py -[beehave] CREATE tests/features/waggle_dance/direction_encoding_test.py -[beehave] CREATE tests/features/waggle_dance/distance_encoding_test.py +tests/features/shopping_cart/tax_calculation_test.py::test_VAT_is_applied_at_the_correct_rate SKIPPED + Given an empty cart + Given a cart with items totalling $100 + When the buyer is in the UK + Then the order total is $120 ``` -The untagged `Example:` in `forager-journey.feature` got an `@id` written back in-place. Every stub is already in the right file with the right name: - -```python -# tests/features/waggle_dance/distance_encoding_test.py - -import pytest - - -class TestDistanceEncoding: - @pytest.mark.skip(reason="not yet implemented") - def test_waggle_dance_hatch004() -> None: - """ - Background: - Given: the hive is in active foraging mode - And: the dance floor is clear of obstacles - Given: a scout has located flowers at metres - When: the scout performs the waggle dance - Then: the waggle run lasts approximately milliseconds - """ - raise NotImplementedError - - @pytest.mark.skip(reason="not yet implemented") - def test_waggle_dance_hatch005() -> None: - """ - Background: - Given: the hive is in active foraging mode - And: the dance floor is clear of obstacles - Given: the scout returns from a multi-patch forage - When: the scout performs the waggle dance - Then: the flower patch register contains the following entries: - | patch_id | species | quality | - | P-001 | Lavender | 0.92 | - | P-002 | Clover | 0.85 | - | P-003 | Sunflower | 0.78 | - """ - raise NotImplementedError -``` +**5. Implement the test and ship.** -Remove the `skip`, implement the test body, run `pytest` again. The hive stays in sync from here on automatically. +Remove the `@pytest.mark.skip` decorator, replace `...` with your test logic, and run `pytest` again. The steps display stays in sync with your feature file. --- @@ -218,15 +115,13 @@ pytest-beehave hooks into `pytest_configure`, the earliest possible entry point. ``` pytest invoked └─ pytest_configure fires - ├─ Bootstrap — create docs/features/{backlog,in-progress,completed}/ if missing - ├─ Assign IDs — write @id tags to untagged Examples (or fail loudly in CI) - └─ Sync stubs - ├─ Create stubs for new Examples - ├─ Update docstrings when steps change - ├─ Rename functions when the feature slug changes - ├─ Mark orphaned tests (criterion deleted from feature file) - ├─ Redirect non-conforming tests to canonical locations - └─ Propagate @deprecated markers from Gherkin tags + ├─ load_config() → read [tool.beehave] from pyproject.toml + ├─ parse_feature() → parse .feature files into ScenarioInfo + ├─ generate_stubs() → write Hypothesis test stubs to disk + ├─ _add_skip_markers() → mark unimplemented stubs with @pytest.mark.skip + ├─ check_all() → detect drift between features and tests + └─ register display plugins → StepsReporter (-v) and/or HtmlStepsPlugin + └─ pytest_collection_modifyitems → inject synthetic failing tests for ERROR violations └─ Collection begins — every stub is already present └─ Tests run ``` @@ -235,55 +130,65 @@ pytest invoked ## File layout -Beehave expects — and will create — this structure: - ``` -docs/features/ - backlog/ ← criteria waiting to be built - in-progress/ ← criteria actively being implemented - completed/ ← shipped criteria (orphan detection only; no stub updates) - -tests/features/ - / - _test.py ← one file per Rule: block +docs/features/ ← configured via features_dir + **/*.feature ← any subfolder structure is supported + +tests/features/ ← configured via tests_dir + / ← one directory per feature (derived from Feature title) + _test.py ← one file per Rule: block (or default_test.py) ``` -Every test function name encodes its criterion: +Each test function name follows the convention `test_`. The mapping is exact string equality — no `@id` tags, no step definitions, no glue code. + +--- + +## Consistency checking + +After stub generation, the plugin runs `check_all()` to detect drift between feature files and test code. Violations produce real test failures: + +| Type | Severity | Meaning | +|------|----------|---------| +| `unmapped-scenario` | ERROR (fails run) | Scenario has no matching test function | +| `unmapped-test` | ERROR (fails run) | Test function has no matching scenario | +| `misplaced-test` | WARNING | Test is in the wrong rule file | +| `missing-placeholder` | ERROR (fails run) | Test body missing a placeholder | +| `missing-literal` | ERROR (fails run) | Test body missing a literal value | +| `example-mismatch` | ERROR (fails run) | Examples rows don't match `@example()` decorators | ``` -test__<@id> +$ pytest +[beehave] ERROR: tests/features/demo/default_test.py:5: unmapped-test: 'test_orphan' has no matching scenario +========================= 1 failed, 3 passed, 2 skipped ========================= ``` ---- +Stub functions (body is `...`) are excluded from placeholder and literal checks — they are expected to be incomplete. -## Markers +--- -pytest-beehave manages four markers. Your own markers (`slow`, `unit`, `integration`) are never touched. +## TDD workflow -| Marker | Meaning | -|---|---| -| `skip(reason="not yet implemented")` | Stub created, not yet implemented | -| `skip(reason="orphan: ...")` | The `@id` no longer exists in any feature file | -| `skip(reason="non-conforming: moved to ...")` | Test was in the wrong file; canonical stub created | -| `deprecated` | Criterion retired via `@deprecated` Gherkin tag | +1. `pytest --collect-only` → stubs generated with `@pytest.mark.skip` → all skipped +2. Remove `@pytest.mark.skip`, write the test body → test runs and fails (red) +3. Fix the implementation → test passes (green) +4. Add new scenarios to `.feature` files → only new stubs get the skip marker --- ## Configuration +All configuration lives under `[tool.beehave]` in `pyproject.toml`: + ```toml -# pyproject.toml [tool.beehave] -features_path = "docs/features" # default; omit if this matches your layout +features_dir = "docs/features" # default: docs/features +tests_dir = "tests/features" # default: tests/features +default_strategy = "text" # default: text (Hypothesis strategy for placeholders) +background_check_numeric = true # default: true +background_check_string = true # default: true ``` -If `features_path` is set but the directory does not exist, pytest-beehave exits immediately with a clear error. - ---- - -## CI behaviour - -On a read-only filesystem (CI), pytest-beehave skips all write operations and instead **fails the run** if it finds any `Example:` without an `@id` tag. This enforces that IDs are always committed — drift is caught at the PR gate, not after merge. +If `features_dir` does not exist, the plugin exits silently (no error, no stub generation). --- @@ -291,14 +196,9 @@ On a read-only filesystem (CI), pytest-beehave skips all write operations and in | | Version | |---|---| -| Python | ≥ 3.13 | -| pytest | ≥ 6.0 | - -Optional: install `pytest-beehave[html]` for acceptance-criteria columns in pytest-html reports. - -```bash -pip install "pytest-beehave[html]" -``` +| Python | >= 3.14 | +| pytest | >= 6.0 | +| beehave | >= 0.4.0 | --- diff --git a/TODO.md b/TODO.md deleted file mode 100644 index 58e320c..0000000 --- a/TODO.md +++ /dev/null @@ -1,22 +0,0 @@ -# Current Work - -Feature: stub-format-config -Step: 5 (Accept) -Source: docs/features/completed/stub-format-config.feature - -## Progress -- [x] Stage 1 Discovery: stub-format-config scoped and baselined -- [x] Stage 2A Stories: 5 Rule blocks written and INVEST-gated -- [x] Stage 2B Criteria: 7 Examples written with @id tags -- [x] `@id:a1b2c3d4`: Stub is a top-level function when stub_format is absent -- [x] `@id:b2c3d4e5`: Absent stub_format does not raise an error -- [x] `@id:f1e2d3c4`: Stub is a top-level function when stub_format = "functions" -- [x] `@id:a2b3c4d5`: Stub is a class method when stub_format = "classes" -- [x] `@id:b3c4d5e6`: Class name is derived from the Rule title slug -- [x] `@id:f6a7b8c9`: Pytest fails at startup when stub_format has an unrecognised value -- [x] `@id:a7b8c9d0`: No-Rule feature produces module-level functions regardless of stub_format = "classes" -- [x] Step 4 Verify: APPROVED — all 7 @id tests pass, 100% coverage, 0 lint/type errors -- [x] Step 5 Accept: ACCEPTED — 149 passed, 4 skipped, clean run, feature moved to completed/ - -## Next -Run @product-owner — load skill feature-selection and pick the next BASELINED feature from backlog diff --git a/docs/architecture.md b/docs/architecture.md deleted file mode 100644 index 68540d1..0000000 --- a/docs/architecture.md +++ /dev/null @@ -1,252 +0,0 @@ -# Architecture: pytest-beehave - ---- - -## 2026-04-18 — stub-creation: test file writing library - -Decision: Use libcst for all test file writes. -Reason: Preserves formatting, comments, and existing function bodies; avoids string-template fragility. -Alternatives considered: `ast` module (cannot round-trip source with formatting preserved), string templates (fragile). -Feature: stub-creation - ---- - -## 2026-04-18 — stub-creation: stub body - -Decision: New stubs contain only `raise NotImplementedError` — no `# Given`, `# When`, `# Then` section comments. -Reason: Section comments are redundant given the docstring. -Alternatives considered: Adding section comments — rejected per AC Q1. -Feature: stub-creation - ---- - -## 2026-04-18 — stub-creation: slug conventions - -Decision: `FeatureSlug` replaces hyphens with underscores (for Python identifiers); `RuleSlug` uses underscores (for file names and Python identifiers). -Reason: Python function names cannot contain hyphens; file names can. -Alternatives considered: Both using underscores — rejected because file names with underscores diverge from the naming spec. -Feature: stub-creation - ---- - -## 2026-04-18 — stub-creation: completed/ features excluded from stub sync - -Decision: Stub creation is never invoked for features in `FeatureStage.COMPLETED`. -Reason: Matches AC `@id:38d864b9` and state machine. -Alternatives considered: Allowing stub creation for completed features — rejected per spec. -Feature: stub-creation - ---- - -## 2026-04-18 — stub-updates: non-conforming handling creates conforming stub first - -Decision: When a non-conforming stub is found, `sync_engine` first creates the conforming stub, then marks the original with `@pytest.mark.skip(reason="non-conforming: moved to ")`. -Reason: Ensures traceability is never lost — the conforming stub exists before the original is marked. -Alternatives considered: Delete the non-conforming stub — rejected because it loses the developer's implementation. -Feature: stub-updates - ---- - -## 2026-04-18 — stub-updates: stub_reader uses libcst - -Decision: `read_stubs_from_file` uses `libcst` to parse the test file and extract function names, decorators, and docstrings. -Reason: Consistent with stub_writer's use of libcst; avoids dual-library complexity. -Alternatives considered: `ast` module — rejected because it cannot extract decorator arguments reliably without round-trip capability. -Feature: stub-updates - ---- - -## 2026-04-18 — stub-updates: stub-sync never touches @pytest.mark.slow - -Decision: All stub_writer functions check the marker name before modifying; `slow` is never added or removed. -Reason: Matches marker ownership rules in discovery.md and AC `@id:c9a30d52`. -Alternatives considered: Allowing stub-sync to manage all markers — rejected per spec. -Feature: stub-updates - ---- - -## 2026-04-18 — Cross-feature: remove class-based test structure - -Decision: All test stubs are top-level functions. No `class Test` wrapping. -Reason: Class wrapping added indirection with no benefit; final implementation uses top-level functions throughout. -Alternatives considered: Keeping class-based layout for Rule blocks — rejected because it increases nesting without providing value. -Affected features: stub-creation, stub-updates - ---- - -## 2026-04-18 — features-path-config: use stdlib tomllib - -Decision: Use `tomllib` from the standard library (Python ≥ 3.11) for reading `pyproject.toml`. -Reason: No new runtime dependency needed; project already requires Python ≥ 3.13. -Alternatives considered: `tomli` (backport) — rejected because Python 3.13 is already required. -Feature: features-path-config - ---- - -## 2026-04-18 — features-path-config: BeehaveConfig as a frozen dataclass - -Decision: `@dataclass(frozen=True, slots=True)` with a single `features_path: Path` field. -Reason: Typed, immutable, and easy to extend with future config keys without changing callers. -Alternatives considered: Returning a raw `Path` — rejected because it cannot be extended without breaking callers. -Feature: features-path-config - ---- - -## 2026-04-18 — features-path-config: read_config takes project_root as a parameter - -Decision: Caller supplies the project root; `read_config` does not walk the filesystem to find it. -Reason: Keeps the function pure and testable without filesystem side effects; the plugin hook supplies the root. -Alternatives considered: Auto-detect by walking up from `cwd` — rejected because it couples the function to the process environment. -Feature: features-path-config - ---- - -## 2026-04-18 — plugin-hook: use pytest_configure hook for pre-collection sync - -Decision: Run stub sync inside `pytest_configure` so stubs exist before collection begins. -Reason: `pytest_configure` is called before collection; `pytest_sessionstart` is called after — using the latter would miss newly generated stubs in the same run. -Alternatives considered: `pytest_collection_start` — not a standard hook; `pytest_sessionstart` — runs too late. -Feature: plugin-hook - ---- - -## 2026-04-18 — plugin-hook: inject filesystem and terminal writer as Protocol adapters - -Decision: `plugin.py` instantiates `_RealFileSystem` and `_PytestTerminalWriter` and passes them to `run_sync` and `reporter` functions. -Reason: Keeps `sync_engine` and `reporter` testable without a live pytest session or real filesystem. -Alternatives considered: Direct use of `pathlib` and `config.get_terminal_writer()` inside sync_engine — rejected because it couples orchestration to pytest internals. -Feature: plugin-hook - ---- - -## 2026-04-18 — plugin-hook: graceful skip when features directory is absent - -Decision: `pytest_configure` checks `features_root.exists()` before calling `run_sync`; if absent, returns silently. -Reason: Matches AC `@id:d0f2866d` — pytest must complete without errors when the features directory does not exist. -Alternatives considered: Raising an error — rejected per AC. -Feature: plugin-hook - ---- - -## 2026-04-18 — deprecation-sync: tag inheritance resolved at parse time - -Decision: `parse_feature` resolves `@deprecated` inheritance (Feature → Rule → Example) and sets `ParsedExample.is_deprecated` as a flat bool. -Reason: Keeps sync_engine simple — it only reads `example.is_deprecated`; no inheritance logic scattered across modules. -Alternatives considered: Resolving inheritance in sync_engine — rejected because it duplicates logic and couples sync_engine to Gherkin tag semantics. -Feature: deprecation-sync - ---- - -## 2026-04-18 — deprecation-sync: deprecation sync runs on completed/ features - -Decision: `run_sync` calls deprecation sync for all three stages, including `completed/`. -Reason: Matches AC `@id:fc372f15` and discovery rule: "Deprecation sync is the ONLY operation performed on completed/ feature test files." -Alternatives considered: Skipping `completed/` for deprecation sync — rejected per spec. -Feature: deprecation-sync - ---- - -## 2026-04-18 — deprecation-sync: no duplicate @pytest.mark.deprecated markers - -Decision: `toggle_deprecated_marker` reads existing markers via `stub_reader` before writing; skips if already in correct state. -Reason: Matches constraint "Must not add duplicate @pytest.mark.deprecated markers". -Alternatives considered: Always removing then re-adding — rejected because it causes unnecessary file writes. -Feature: deprecation-sync - ---- - -## 2026-04-18 — auto-id-generation: detect read-only by checking file writability - -Decision: Use `os.access(path, os.W_OK)` via `FileSystemProtocol.is_writable` to determine if write-back is possible. -Reason: More reliable across different CI systems than checking `CI` env var; matches AC Q2 answer. -Alternatives considered: Checking `os.environ.get("CI")` — rejected per AC Q2. -Feature: auto-id-generation - ---- - -## 2026-04-18 — auto-id-generation: ID uniqueness is within-file only - -Decision: `generate_unique_id` takes `existing_ids: set[ExampleId]` scanned from the current file only. -Reason: Matches AC Q1 answer; cross-file collision probability with 8-char hex is negligible. -Alternatives considered: Global uniqueness across all feature files — rejected per AC Q1. -Feature: auto-id-generation - ---- - -## 2026-04-18 — auto-id-generation: write @id tag on the line immediately before the Example keyword - -Decision: Insert the `@id:` tag line immediately before the `Example:` keyword line. -Reason: Matches the Gherkin tag convention and AC requirement. -Alternatives considered: Appending to existing tag lines — rejected because it may break gherkin-official parsing. -Feature: auto-id-generation - ---- - -## 2026-04-18 — features-dir-bootstrap: bootstrap is a pure function of the filesystem state - -Decision: `bootstrap_features_directory` takes only `features_root: Path` and performs all filesystem operations directly via `pathlib`. -Reason: Bootstrap operations (mkdir, rename) are idempotent and low-risk; no Protocol abstraction needed at this scale. -Alternatives considered: Injecting a filesystem Protocol — rejected because bootstrap is simple enough that the added indirection is YAGNI. -Feature: features-dir-bootstrap - ---- - -## 2026-04-18 — features-dir-bootstrap: BootstrapResult.is_noop drives terminal output - -Decision: `report_bootstrap` produces no output when `result.is_noop` is True. -Reason: Matches AC `@id:5e6f9b17` — no output when structure is already correct. -Alternatives considered: Always printing a "bootstrap OK" message — rejected per AC. -Feature: features-dir-bootstrap - ---- - -## 2026-04-18 — features-dir-bootstrap: name collision leaves root-level file in place with a warning - -Decision: When a root-level `.feature` file shares a name with an existing `backlog/` file, `bootstrap_features_directory` records the filename in `collision_warnings` and does not move the file. -Reason: Matches AC `@id:7f2a0d51` and `@id:8c3b1e96`; prevents data loss. -Alternatives considered: Overwriting the backlog file — rejected because it loses the existing backlog file. -Feature: features-dir-bootstrap - ---- - -## 2026-04-19 — example-hatch: single hatch.py module owns all generation logic - -Decision: All hatch content generation and writing lives in `pytest_beehave/hatch.py`; plugin.py only wires options and dispatches. -Reason: Keeps the hatch feature self-contained and independently testable without a live pytest session. -Alternatives considered: Inlining into plugin.py — rejected because it mixes lifecycle concerns with content generation. -Feature: example-hatch - ---- - -## 2026-04-19 — example-hatch: early-exit in pytest_configure via pytest.exit() - -Decision: When `--beehave-hatch` is detected, `pytest_configure` calls `run_hatch()`, prints the summary, then calls `pytest.exit(returncode=0)` before any stub-sync or test collection. -Reason: Matches AC constraint "Must exit pytest immediately after hatch completes (no test collection)". -Alternatives considered: Using a custom `pytest_collection_modifyitems` hook to abort collection — rejected because `pytest_configure` is earlier and cleaner. -Feature: example-hatch - ---- - -## 2026-04-19 — example-hatch: HatchFile dataclass carries relative_path + content - -Decision: `HatchFile(relative_path: str, content: str)` is a frozen dataclass; `generate_hatch_files()` returns `list[HatchFile]`; `write_hatch()` writes them to disk. -Reason: Separates content generation (pure, testable) from filesystem writes (side effects). -Alternatives considered: Passing raw dicts — rejected because typed dataclasses catch mistakes at static analysis time. -Feature: example-hatch - ---- - -## 2026-04-19 — stub-creation, stub-updates: drop libcst in favour of direct string manipulation - -Decision: `stub_writer` and `stub_reader` use direct string parsing/formatting rather than `libcst`. -Reason: The implementation does not need to round-trip arbitrary Python source with full formatting preservation. Direct string manipulation is simpler, has zero additional dependencies, and is sufficient for the structured output format (top-level functions with a docstring and `raise NotImplementedError`). `libcst` was never added as a runtime dependency. -Supersedes: "2026-04-18 — stub-creation: test file writing library" and "2026-04-18 — stub-updates: stub_reader uses libcst" -Feature: stub-creation, stub-updates - ---- - -## 2026-04-19 — stub-format-config: StubFormat threaded through StubSpec - -Decision: add `stub_format: StubFormat` field to `StubSpec` (stub_writer.py) and thread it from `run_sync` down to `write_stub_to_file`. -Reason: keeps the format decision co-located with the stub spec rather than using a global or module-level state. -Alternatives considered: global config object passed via module — rejected (hidden coupling); separate `write_top_level_stub_to_file` / `write_class_stub_to_file` public functions — rejected (duplicate routing logic at call sites). diff --git a/docs/c4/.gitkeep b/docs/c4/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/docs/c4/container.md b/docs/c4/container.md deleted file mode 100644 index 68ecf21..0000000 --- a/docs/c4/container.md +++ /dev/null @@ -1,67 +0,0 @@ -# C4 Level 2 — Container: pytest-beehave - -> Last updated: 2026-04-19 -> Source: docs/architecture.md - -pytest-beehave is a single Python package installed as a pytest plugin. The "containers" here are the major modules with distinct responsibilities inside the package. - -```mermaid -C4Container - title Container Diagram — pytest-beehave (internal modules) - - Person(developer, "Developer", "Writes .feature files, runs pytest") - Person(ci, "CI Pipeline", "Runs pytest in a read-only environment") - - System_Boundary(beehave, "pytest-beehave package") { - Container(plugin, "plugin.py", "Python module", "pytest_configure entry point; orchestrates all plugin behaviour; injects Protocol adapters for filesystem and terminal writer; dispatches --beehave-hatch flag") - Container(config, "config.py", "Python module", "Reads [tool.beehave] from pyproject.toml via stdlib tomllib; returns a frozen BeehaveConfig dataclass with the resolved features_path") - Container(bootstrap, "bootstrap.py", "Python module", "Ensures the three canonical subfolders (backlog/, in-progress/, completed/) exist; migrates root-level .feature files to backlog/ before stub sync") - Container(feature_parser, "feature_parser.py", "Python module", "Delegates to gherkin-official to parse .feature files into ParsedFeature/ParsedExample domain objects; resolves @deprecated tag inheritance at parse time") - Container(sync_engine, "sync_engine.py", "Python module", "Orchestrates stub sync across all three feature stages: creates new stubs, renames/orphan-marks changed stubs, runs deprecation sync, and generates missing @id tags") - Container(stub_writer, "stub_writer.py", "Python module", "Writes and updates test stub functions using direct string manipulation; generates top-level test__<@id> functions with docstrings") - Container(stub_reader, "stub_reader.py", "Python module", "Reads existing test stub functions from test files; extracts function names, decorators, and docstrings via direct string parsing") - Container(id_generator, "id_generator.py", "Python module", "Generates unique 8-character lowercase hex IDs scoped to the current .feature file; writes @id tags back inline") - Container(reporter, "reporter.py", "Python module", "Formats and emits terminal output for bootstrap results and stub sync events via the PytestTerminalWriter Protocol adapter") - Container(steps_reporter, "steps_reporter.py", "Python module", "Prints verbatim docstring steps below the test path at -v or above; scoped to tests under tests/features/ only") - Container(html_steps_plugin, "html_steps_plugin.py", "Python module", "Adds an Acceptance Criteria column to the pytest-html report; registers only when pytest-html is installed; injects docstring content per feature test") - Container(hatch, "hatch.py", "Python module", "Generates bee-themed example .feature files under the configured features path; uses stdlib secrets.choice() only; supports --beehave-hatch-force for overwrite; exits pytest immediately after generation") - Container(models, "models.py", "Python module", "Shared domain types: ParsedFeature, ParsedExample, FeatureStage, ExampleId, BootstrapResult, HatchFile, and Protocol definitions for FileSystem and TerminalWriter") - } - - System_Ext(pytest, "pytest", "Host test framework providing the plugin hook lifecycle") - System_Ext(gherkin, "gherkin-official", "Gherkin parser; supports 70+ human languages") - System_Ext(pytest_html, "pytest-html", "Optional HTML report plugin") - System_Ext(fs, "Filesystem", "docs/features/ tree (feature files) and tests/features/ tree (test stubs)") - - Rel(developer, fs, "Writes .feature files to; reads generated test stubs from") - Rel(developer, pytest, "Runs with --beehave-hatch to seed example features") - Rel(ci, pytest, "Runs pytest in read-only environment") - Rel(pytest, plugin, "Calls pytest_configure hook on startup") - Rel(plugin, config, "Reads features_path from pyproject.toml via") - Rel(plugin, bootstrap, "Runs directory bootstrap via") - Rel(plugin, sync_engine, "Triggers full stub sync via") - Rel(plugin, steps_reporter, "Registers terminal steps hook via") - Rel(plugin, html_steps_plugin, "Registers HTML column hook via (if pytest-html present)") - Rel(plugin, hatch, "Dispatches --beehave-hatch flag to; exits after generation") - Rel(bootstrap, fs, "Creates missing subfolders; migrates root-level .feature files in") - Rel(sync_engine, feature_parser, "Parses .feature files via") - Rel(sync_engine, stub_reader, "Reads existing test stubs via") - Rel(sync_engine, stub_writer, "Creates and updates test stubs via") - Rel(sync_engine, id_generator, "Generates and writes back missing @id tags via") - Rel(sync_engine, reporter, "Emits terminal events via") - Rel(feature_parser, gherkin, "Delegates .feature AST parsing to") - Rel(stub_writer, fs, "Writes test stub files to tests/features/") - Rel(stub_reader, fs, "Reads test stub files from tests/features/") - Rel(id_generator, fs, "Writes @id tags back to docs/features/") - Rel(hatch, fs, "Writes generated bee-themed .feature files to docs/features/") - Rel(steps_reporter, pytest, "Hooks into pytest_runtest_logreport") - Rel(html_steps_plugin, pytest_html, "Hooks into pytest-html result row extra API") -``` - -## Notes - -- All modules are part of a single deployable Python package (`pytest-beehave`). This diagram shows internal component boundaries for navigability. -- `models.py` defines the Protocol interfaces (`FileSystemProtocol`, `TerminalWriterProtocol`) used for dependency injection in tests. -- `plugin.py` is the only module that imports pytest internals directly; all others work on domain objects or Protocol abstractions. -- `stub_writer` and `stub_reader` use direct string manipulation (not a CST library) — sufficient for the structured stub format and carries zero additional runtime dependencies. -- `hatch.py` is self-contained: all content generation and writing lives there; `plugin.py` only wires the CLI options and dispatches. Uses `stdlib secrets.choice()` only — no external dependencies. diff --git a/docs/c4/context.md b/docs/c4/context.md deleted file mode 100644 index 860c082..0000000 --- a/docs/c4/context.md +++ /dev/null @@ -1,36 +0,0 @@ -# C4 Level 1 — System Context: pytest-beehave - -> Last updated: 2026-04-19 (v3.2 — stub-format-config) -> Source: docs/discovery.md, docs/features/completed/ - -```mermaid -C4Context - title System Context — pytest-beehave - - Person(developer, "Developer", "Python developer using the Beehave workflow to write BDD-style acceptance tests") - Person(ci, "CI Pipeline", "Automated environment (GitHub Actions, etc.) that runs pytest on every push") - - System(beehave, "pytest-beehave", "pytest plugin that syncs test stubs from Gherkin feature files before collection, assigns IDs, manages markers, surfaces acceptance criteria in reports, and generates bee-themed example feature files on demand") - - System_Ext(pytest, "pytest", "Python test framework that discovers, runs, and reports on tests; provides the plugin hook lifecycle") - System_Ext(gherkin, "gherkin-official", "Gherkin parser that reads .feature files and produces an AST; supports 70+ human languages") - System_Ext(pytest_html, "pytest-html", "Optional pytest plugin that generates an HTML test report; beehave adds an Acceptance Criteria column when installed") - System_Ext(fs, "Filesystem", "docs/features/ tree (feature files) and tests/features/ tree (test stubs)") - - Rel(developer, pytest, "Runs", "uv run pytest / uv run task test") - Rel(developer, pytest, "Runs with --beehave-hatch to seed example features", "pytest --beehave-hatch") - Rel(ci, pytest, "Runs", "uv run pytest (read-only environment)") - Rel(pytest, beehave, "Loads via pytest11 entry point", "pytest_configure hook") - Rel(beehave, gherkin, "Parses .feature files via", "gherkin-official Python API") - Rel(beehave, pytest_html, "Injects Acceptance Criteria column into", "pytest-html report hooks (optional)") - Rel(beehave, fs, "Writes test stubs, @id tags, and hatch example files to") - Rel(beehave, ci, "Fails run with descriptive error when @id tags are missing in", "read-only CI environment") -``` - -## Notes - -- **Developer** interacts with beehave indirectly: they write `.feature` files and run pytest; beehave auto-generates and maintains test stubs. They can also run `pytest --beehave-hatch` to generate a bee-themed example `docs/features/` tree showcasing all plugin capabilities. -- **CI Pipeline** is a read-only environment: beehave detects this and fails fast (with a clear error) instead of writing `@id` tags back. -- **gherkin-official** handles all language parsing, including non-English feature files (`# language: es`, `# language: zh-CN`, etc.). beehave delegates fully. -- **pytest-html** is an optional install extra (`pip install pytest-beehave[html]`); its absence is silently ignored. -- **`--beehave-hatch`** exits pytest immediately after generating example files — no test collection occurs. Use `--beehave-hatch-force` to overwrite existing content. diff --git a/docs/discovery.md b/docs/discovery.md deleted file mode 100644 index e20a354..0000000 --- a/docs/discovery.md +++ /dev/null @@ -1,233 +0,0 @@ -# Discovery: pytest-beehave - ---- - -## Session: 2026-04-18 — Initial Synthesis - -### Scope -pytest-beehave is a pytest plugin for Python developers using the python-project-template workflow. It eliminates the manual `gen-tests` step by automatically syncing test stubs from Gherkin `.feature` files on every `pytest` invocation — before collection, so new stubs are collected in the same run. It generates IDs for untagged Examples (failing hard in CI), writes step docstrings, and applies deprecation markers. The plugin is always-on with a configurable features folder path. It never modifies test bodies and must not corrupt `.feature` files. Out of scope: new test runner, non-standard layouts, GUI, Gherkin parser changes. - -Feature stages determine what the plugin does: backlog and in-progress features receive full stub creation and updates; completed features receive only orphan detection. Marker ownership is strictly split — stub-sync owns skip/deprecated markers; the developer owns slow. - -### Feature List -- `features-path-config` — custom features folder path via pyproject.toml -- `plugin-hook` — pytest lifecycle integration (register plugin, run before collection) -- `auto-id-generation` — detect missing @id tags, generate IDs, write back or fail in CI -- `stub-creation` — create new test stubs for backlog/in-progress; top-level functions, skip marker, docstrings -- `stub-updates` — update/rename/orphan-mark existing stubs; conformance enforcement; non-conforming redirect -- `deprecation-sync` — toggle @pytest.mark.deprecated across all 3 feature stages; Gherkin tag inheritance - -### Domain Model -| Type | Name | Description | In Scope | -|------|------|-------------|----------| -| Noun | test stub | top-level function `test__<@id>()` | Yes | -| Noun | test file | `_test.py` or `examples_test.py` | Yes | -| Noun | feature slug | `.feature` file stem, hyphens → underscores, lowercase | Yes | -| Noun | rule slug | `Rule:` title slugified with underscores, lowercase | Yes | -| Noun | docstring | verbatim step-by-step content in test stub | Yes | -| Noun | backlog stage | `docs/features/backlog/` | Yes | -| Noun | in-progress stage | `docs/features/in-progress/` | Yes | -| Noun | completed stage | `docs/features/completed/` — stub creation excluded | Yes | -| Noun | conforming test | test with correct file name AND correct function name | Yes | -| Noun | orphan test | test function with no matching `@id` in any `.feature` file | Yes | -| Verb | create stub | write new top-level test function for new Example | Yes | -| Verb | mark orphan | apply `@pytest.mark.skip(reason="orphan: ...")` | Yes | -| Verb | mark non-conforming | apply `@pytest.mark.skip(reason="non-conforming: moved to ")` | Yes | -| Verb | deprecate stub | apply `@pytest.mark.deprecated` when Gherkin `@deprecated` tag present | Yes | - -Template §3: CONFIRMED — stakeholder approved 2026-04-18 - ---- - -## Session: 2026-04-18 — Behavior Groups - -### Scope -Behavior groups and cross-cutting concerns established for the initial feature set. - -### Decisions -- **Lifecycle**: backlog + in-progress receive full stub creation and updates; completed receives orphan detection only — no new stubs, no docstring/rename updates -- **Conformance**: two-part — correct file name (`_test.py` or `examples_test.py`) AND correct function name (`test__<@id>`) — both must match -- **Marker ownership**: two non-overlapping domains — stub-sync owns `skip/not-yet-implemented`, `skip/orphan`, `skip/non-conforming`, `deprecated`; developer owns `slow` — neither crosses into the other's territory - ---- - -## Session: 2026-04-18 — Per-feature scope: features-path-config, plugin-hook, deprecation-sync, auto-id-generation - -### Feature List -- `features-path-config` — custom features folder path via pyproject.toml; default `docs/features/`; hard error on missing configured path -- `plugin-hook` — pytest lifecycle integration; `pytest_configure` hook; always-on; graceful skip when features directory absent -- `deprecation-sync` — toggle `@pytest.mark.deprecated` across all 3 stages; Gherkin tag inheritance from Rule/Feature nodes -- `auto-id-generation` — detect missing `@id` tags; write back in writable environments; fail in CI with descriptive error - -### Domain Model -| Type | Name | Candidate Class/Method | In Scope | -|------|------|----------------------|----------| -| Noun | features folder path | path to `docs/features/` directory | Yes | -| Noun | `pyproject.toml` | project configuration file | Yes | -| Noun | `[tool.beehave]` section | configuration section in `pyproject.toml` | Yes | -| Noun | default path | `docs/features/` relative to project root | Yes | -| Noun | pytest plugin | `BeehavePlugin` | Yes | -| Noun | pytest session | pytest lifecycle | Yes | -| Noun | pytest config | `pytest_configure` hook | Yes | -| Noun | stub sync | full sync operation | Yes | -| Noun | `@deprecated` tag | Gherkin tag directly on an Example block | Yes | -| Noun | Gherkin tag inheritance | `@deprecated` on a `Rule:` or `Feature:` node applies to all child Examples | Yes | -| Noun | `@pytest.mark.deprecated` | pytest marker on a test function | Yes | -| Noun | Example block | Gherkin scenario | Yes | -| Noun | `@id` tag | `@id:<8-char-hex>` tag on Example | Yes | -| Noun | hex ID | 8-character lowercase hex string | Yes | -| Noun | CI environment | read-only or automated environment | Yes | -| Verb | read config | parse `[tool.beehave]` from `pyproject.toml` | Yes | -| Verb | resolve path | make the configured path absolute relative to project root | Yes | -| Verb | fall back to default | use `docs/features/` if no config present | Yes | -| Verb | register plugin | `pytest_configure` entry point | Yes | -| Verb | run before collection | invoke sync logic before collection begins | Yes | -| Verb | detect deprecated | check Example tags for `@deprecated` (direct and inherited) | Yes | -| Verb | add marker | prepend `@pytest.mark.deprecated` to test function | Yes | -| Verb | remove marker | remove `@pytest.mark.deprecated` when tag is gone | Yes | -| Verb | detect missing ID | scan Example tags for `@id` | Yes | -| Verb | generate ID | produce a unique 8-char hex string | Yes | -| Verb | write back | insert `@id` tag into `.feature` file in-place | Yes | -| Verb | fail run | abort pytest with a clear error message | Yes | - -Template §3: CONFIRMED — stakeholder approved 2026-04-18 - ---- - -## Session: 2026-04-18 — Per-feature scope: multilingual-feature-parsing - -### Scope -pytest-beehave delegates all language handling to gherkin-official. When a `.feature` file begins -with `# language: es`, the parser switches to Spanish keywords (Característica:, Dado:, Cuando:, -Entonces:, etc.) automatically. When it begins with `# language: zh-CN`, it switches to Chinese -keywords (功能:, 假设:, 当:, 那么:, etc.). The plugin never inspects the language directive itself. -Generated test stubs use the feature folder name for function names (always ASCII-safe slugs), -and preserve the original keyword text verbatim in docstrings. A project with mixed-language -`.feature` files works because each file is parsed in isolation. - -### Feature List -- `multilingual-feature-parsing` — transparent non-English parsing via `# language: xx`; no beehave configuration needed - -### Domain Model -| Type | Name | Candidate Class/Method | In Scope | -|------|------|----------------------|----------| -| Noun | `# language: xx` comment | Language directive at top of feature file | Yes | -| Noun | non-English keyword | Gherkin keyword in a supported dialect | Yes | -| Noun | pyproject.toml language config | Project-level default language setting | No | -| Noun | bad language code error handling | Custom error for unrecognised language codes | No | - -Template §3: CONFIRMED — stakeholder approved 2026-04-18 - ---- - -## Session: 2026-04-18 — Per-feature scope: features-dir-bootstrap - -### Scope -The features-dir-bootstrap feature ensures the Beehave plugin can always operate on a well-structured -features directory. When pytest is invoked, the plugin runs a bootstrap step as its very first action -(before stub sync). The bootstrap inspects the configured features directory and, if it exists, -ensures all three canonical subfolders (backlog/, in-progress/, completed/) are present — creating -any that are missing. It then scans for `.feature` files directly in the root features directory (not -inside any subfolder) and moves them to backlog/, making them available for stub sync in the same -pytest run. Non-`.feature` files in the root are left untouched. If a name collision occurs (a -root-level `.feature` file shares a name with an existing backlog/ file), the root-level file is left -in place and a warning is emitted. If the structure is already correct and no root-level `.feature` -files exist, the bootstrap is a complete no-op with no terminal output. If the root features -directory does not exist, the bootstrap is skipped entirely. - -### Feature List -- `features-dir-bootstrap` — create missing canonical subfolders; migrate root-level `.feature` files to `backlog/`; collision warning; silent no-op when structure is correct - -### Domain Model -| Type | Name | Candidate Class/Method | In Scope | -|------|------|----------------------|----------| -| Noun | features directory | root configured features path (e.g. docs/features/) | Yes | -| Noun | backlog subfolder | docs/features/backlog/ | Yes | -| Noun | in-progress subfolder | docs/features/in-progress/ | Yes | -| Noun | completed subfolder | docs/features/completed/ | Yes | -| Verb | bootstrap | create missing canonical subfolders | Yes | -| Verb | migrate | move root-level .feature files to backlog/ | Yes | -| Verb | detect | check whether the three-subfolder structure exists | Yes | - -Template §3: CONFIRMED — stakeholder approved 2026-04-18 - ---- - -## Session: 2026-04-18 — Revision: remove class-based test structure - -### Scope -All test stubs are top-level functions. The `class Test` wrapper discussed during initial discovery was never implemented. Conformance is two-part: (1) correct file name and (2) correct function name. No class context check exists. - -### Feature List -- `stub-creation` — updated: top-level functions only, not class methods -- `stub-updates` — updated: conformance is 2-part (file + function name), not 3-part - -### Domain Model -| Type | Name | Description | Change | -|------|------|-------------|--------| -| Noun | conforming test | test with correct file name AND correct function name | Updated: was 3-part (file + class context + function name), now 2-part | - ---- - -## Session: 2026-04-19 — Feature: report-steps - -### Scope -`report-steps` surfaces BDD acceptance criteria in two independent output channels: the terminal (at `-v` or above) and pytest-html reports (when `pytest-html` is installed). Both channels are scoped exclusively to tests under `tests/features/` and are independently configurable via `pyproject.toml`. Steps are always rendered verbatim from the test docstring — no reformatting. Both channels are wired into the single existing `pytest_configure` entry point. `pytest-html` is an optional install extra (`pip install pytest-beehave[html]`); when it is absent the HTML channel is silently inactive with no error raised. - -Feature stages and marker state do not affect rendering — steps are shown regardless of test outcome (pass, fail, skip, error). - -### Feature List -- `report-steps` — terminal steps display and HTML acceptance criteria column for `tests/features/` tests - -### Domain Model -| Type | Name | Description | In Scope | -|------|------|-------------|----------| -| Noun | terminal channel | verbatim docstring printed below test path at -v or above | Yes | -| Noun | HTML channel | "Acceptance Criteria" column in pytest-html report | Yes | -| Noun | feature test | any test residing under `tests/features/` | Yes | -| Noun | non-feature test | any test outside `tests/features/` (e.g. `tests/unit/`) | Yes — explicitly excluded from output | -| Noun | verbatim steps | docstring content rendered with no reformatting | Yes | -| Verb | render steps | print/inject docstring into the appropriate output channel | Yes | -| Verb | suppress steps | omit output when channel is disabled or test is out of scope | Yes | - -Template §3: CONFIRMED — stakeholder approved 2026-04-18 - ---- - -## Session: 2026-04-19 — Feature: example-hatch - -### Feature List -- `example-hatch` — generate a bee-themed `docs/features/` directory tree showcasing all plugin capabilities via `pytest --beehave-hatch`; stdlib-only randomisation; respects configured features path; fails loudly on existing content unless `--beehave-hatch-force` is passed - -### Domain Model -| Type | Name | Description | In Scope | -|------|------|-------------|----------| -| Noun | hatch | generated `docs/features/` directory tree with example `.feature` files | Yes | -| Noun | `--beehave-hatch` flag | pytest CLI flag that triggers hatch generation | Yes | -| Noun | `--beehave-hatch-force` flag | pytest CLI flag that allows overwriting existing hatch content | Yes | -| Noun | bee/hive-themed content | Feature names, Rule titles, Example titles, and step text using bee/hive metaphors | Yes | -| Noun | capability showcase | set of generated `.feature` files that together exercise every plugin capability | Yes | -| Noun | stdlib randomisation | use of `random` / `secrets` from Python stdlib to vary generated content | Yes | -| Verb | hatch | write the example features directory tree to the configured path | Yes | -| Verb | overwrite-protect | fail loudly when target directory already contains `.feature` files | Yes | -| Verb | force-overwrite | replace existing hatch content when `--beehave-hatch-force` is passed | Yes | - ---- - -## Session: 2026-04-19 — Feature: stub-format-config - -### Feature List -- `stub-format-config` — new `stub_format` key under `[tool.beehave]`; `"functions"` (default, top-level functions) or `"classes"` (class-wrapped methods); hard error on invalid value; no-Rule features unaffected; project-wide setting; does not reformat existing stubs - -### Domain Model -| Type | Name | Description | In Scope | -|------|------|-------------|----------| -| Noun | `stub_format` | config key under `[tool.beehave]` controlling stub output format | Yes | -| Noun | `"functions"` format | top-level functions in `_test.py`, no class wrapper (default) | Yes | -| Noun | `"classes"` format | methods inside `class Test` in `_test.py` | Yes | -| Noun | invalid format value | any `stub_format` value other than `"functions"` or `"classes"` | Yes | -| Verb | read stub_format | parse `stub_format` from `[tool.beehave]` at pytest startup | Yes | -| Verb | default to functions | use `"functions"` when `stub_format` key is absent | Yes | -| Verb | fail on invalid | abort pytest startup with descriptive error when value is unrecognised | Yes | -| Verb | generate function stub | write top-level `def test__<@id>()` with no class wrapper | Yes | -| Verb | generate class stub | write method inside `class Test:` | Yes | diff --git a/docs/discovery_journal.md b/docs/discovery_journal.md deleted file mode 100644 index 702d4de..0000000 --- a/docs/discovery_journal.md +++ /dev/null @@ -1,159 +0,0 @@ -# Discovery Journal: pytest-beehave - ---- - -## 2026-04-18 — Project: Session 1 -Status: COMPLETE - -| ID | Question | Answer | Status | -|----|----------|--------|--------| -| Q1 | Who are the users? | Python developers using the python-project-template workflow who run `pytest` and want their Gherkin acceptance criteria stubs kept in sync automatically | ANSWERED | -| Q2 | What does the product do at a high level? | A pytest plugin that automatically syncs test stubs from Gherkin `.feature` files when `pytest` is run — generating IDs for un-tagged Examples, writing generic step docstrings, and applying deprecation markers | ANSWERED | -| Q3 | Why does it exist — what problem does it solve? | Removes the manual step of running `uv run task gen-tests` before every test run; ensures stubs are always in sync with acceptance criteria without developer intervention | ANSWERED | -| Q4 | When and where is it used? | During local development and CI, whenever `pytest` is invoked; operates on the `docs/features/` directory structure of the python-project-template layout | ANSWERED | -| Q5 | Success — how do we know it works? | Running `pytest` automatically syncs stubs; new Examples get IDs written back to `.feature` files; deprecated Examples get `@pytest.mark.deprecated` applied; stubs for backlog/in-progress are created/updated; completed stubs are only touched for deprecation | ANSWERED | -| Q6 | Failure — what does failure look like? | Plugin modifies test bodies or parameters; plugin breaks `pytest` collection; plugin silently corrupts `.feature` files; plugin fails in CI without a graceful fallback | ANSWERED | -| Q7 | Out-of-scope — what are we explicitly not building? | A new test runner; changes to the Gherkin parser; support for non-standard feature folder layouts; GUI or web interface; any feature not related to stub sync and ID generation | ANSWERED | -| Q8 | Should the plugin support configuration options? | Yes — custom features folder path via `pyproject.toml`; always-on (no on/off switch) | ANSWERED | -| Q9 | Auto-ID write-back in CI / read-only environments? | Fail the pytest run with an error if untagged Examples are found; all Examples MUST have an ID | ANSWERED | -| Q10 | Empty feature folders behaviour? | Skip silently — no warning | ANSWERED | -| Q11 | pytest hook timing? | Sync BEFORE collection so newly generated stubs are collected in the same run | ANSWERED | -| Q12 | "All steps" in docstrings? | Every individual step line including And/But continuations | ANSWERED | -| Q13 | Plugin location? | This repository IS the plugin (`name = "pytest-beehave"` in pyproject.toml) | ANSWERED | -| Q14 | Default marker for new stubs? | `@pytest.mark.skip(reason="not yet implemented")` — stub-sync always adds this; software-engineer adds `@pytest.mark.slow` when the test is genuinely slow | ANSWERED | -| Q15 | Test structure: plain functions or class-based? | All tests are top-level functions — no classes. Rule blocks → `_test.py`; no Rule blocks → `examples_test.py` | ANSWERED | -| Q16 | What owns stub-sync markers vs software-engineer markers? | Two non-overlapping domains: stub-sync owns `skip/not-yet-implemented`, `skip/orphan`, `skip/non-conforming`, `deprecated`; software-engineer owns `slow` — crossing is prohibited | ANSWERED | -| Q17 | How is test conformance defined? | Two-part: (1) correct file name (`_test.py` or `examples_test.py`), (2) correct function name (`test__<@id>`) — both must match | ANSWERED | -| Q18 | How is `@deprecated` inherited from parent Gherkin nodes? | `@deprecated` on a `Rule:` or `Feature:` node is treated as present on all child Examples for marker sync purposes | ANSWERED | -| Q19 | What library for reading/writing test files? | `libcst` — preserves formatting and comments; needs to be added as dependency | ANSWERED | - ---- - -## 2026-04-18 — Project: Session 2 — Behavior Groups -Status: COMPLETE - -| ID | Group | Question | Answer | Status | -|----|-------|----------|--------|--------| -| Q20 | Lifecycle | How does the plugin handle the three feature stages (backlog, in-progress, completed) differently? | backlog + in-progress: full stub creation and updates; completed: orphan detection only — no new stubs, no updates | ANSWERED | -| Q21 | Conformance | What is the conformance model — what makes a test "correctly placed"? | Two-part: correct file name and correct function name — both must match | ANSWERED | -| Q22 | Marker ownership | How is marker ownership split between stub-sync and the developer? | Two non-overlapping domains: stub-sync owns skip markers and deprecated; developer owns slow — neither crosses into the other's territory | ANSWERED | - ---- - -## 2026-04-18 — Feature: features-path-config — Session 1 -Status: COMPLETE - -| ID | Question | Answer | Status | -|----|----------|--------|--------| -| Q1 | What is the exact config key name? | `features_path` under `[tool.beehave]` | ANSWERED | -| Q2 | Should an invalid/missing configured path be a hard error or a warning? | Hard error — if the user explicitly configured a path that doesn't exist, fail loudly | ANSWERED | - ---- - -## 2026-04-18 — Feature: plugin-hook — Session 1 -Status: COMPLETE - -| ID | Question | Answer | Status | -|----|----------|--------|--------| -| Q1 | Which pytest hook runs before collection? | `pytest_configure` runs at startup before collection; `pytest_sessionstart` runs after collection starts — use `pytest_configure` or a custom `pytest_collection_start` hook | ANSWERED | -| Q2 | Should the plugin emit any output to the pytest terminal during sync? | Yes — brief summary of actions taken (same style as current script) | ANSWERED | - ---- - -## 2026-04-18 — Feature: deprecation-sync — Session 1 -Status: COMPLETE - -| ID | Question | Answer | Status | -|----|----------|--------|--------| -| Q1 | Should deprecation sync run even if stub sync is skipped for completed features? | Yes — deprecation sync always runs on all 3 stages regardless | ANSWERED | - ---- - -## 2026-04-18 — Feature: auto-id-generation — Session 1 -Status: COMPLETE - -| ID | Question | Answer | Status | -|----|----------|--------|--------| -| Q1 | Should ID uniqueness be guaranteed globally (across all feature files) or just within a single file? | Within-file only — scan the current `.feature` file for existing `@id` values before generating new ones for that file; 8-char hex collision probability across files is negligible | ANSWERED (REVISED) | -| Q2 | How is "CI / read-only" detected — by checking file writability or by checking a `CI` env var? | Check file writability — more reliable across different CI systems | ANSWERED | - ---- - -## 2026-04-18 — Feature: multilingual-feature-parsing — Session 1 -Status: COMPLETE - -| ID | Question | Answer | Status | -|----|----------|--------|--------| -| Q1 | Should beehave support a project-level default language? | No — transparent; `# language: xx` is the only mechanism | ANSWERED | -| Q2 | Which languages need verification? | Spanish (es) and Chinese Simplified (zh-CN) | ANSWERED | -| Q3 | Custom error handling for bad language codes? | No — out of scope | ANSWERED | -| Q4 | Mixed-language projects work transparently? | Yes — each file parsed independently | ANSWERED | -| Q5 | Docstrings normalise non-English keywords to English? | No — preserve original keyword | ANSWERED | -| Q6 | Scope? | Tests only — verify existing behaviour | ANSWERED | - ---- - -## 2026-04-18 — Feature: features-dir-bootstrap — Session 1 -Status: COMPLETE - -| ID | Question | Answer | Status | -|----|----------|--------|--------| -| Q1 | What triggers the bootstrap — always on every run, or only when structure is missing? | Always run on every pytest invocation; it is a no-op when structure is already correct | ANSWERED | -| Q2 | What constitutes "missing structure" — all three subfolders absent, or any one? | Any missing subfolder triggers creation of that subfolder; all three must exist | ANSWERED | -| Q3 | What if only some subfolders exist? | Create only the missing ones; existing subfolders are not touched | ANSWERED | -| Q4 | Are only .feature files migrated, or all files? | Only .feature files directly in the root features folder; other files are left in place | ANSWERED | -| Q5 | What if a root-level .feature file has the same name as one already in backlog/? | Leave the root-level file in place and emit a warning; do not overwrite | ANSWERED | -| Q6 | Should bootstrap emit terminal output? | Yes — report each action (subfolder created, file moved, collision warning); no output if no-op | ANSWERED | -| Q7 | What if the root features directory does not exist at all? | Bootstrap is skipped; existing graceful handling in plugin-hook covers this case | ANSWERED | -| Q8 | Does bootstrap run before or after stub sync? | Bootstrap runs first so migrated files are available for stub sync in the same run | ANSWERED | -| Q9 | Are non-.feature files in the root features folder migrated? | No — only .feature files are migrated; discovery.md and other files stay in place | ANSWERED | - ---- - -## 2026-04-18 — Feature: features-dir-bootstrap — Session 2 -Status: COMPLETE - -| ID | Question | Answer | Status | -|----|----------|--------|--------| -| Q10 | Are nested non-canonical subdirectories in the root features folder left alone? | Yes — only the three canonical subfolders are managed; any other subdirectory is ignored | ANSWERED | -| Q11 | Is the bootstrap idempotent — safe to run multiple times? | Yes — creating an already-existing subfolder is a no-op; migration only moves files not already in a subfolder | ANSWERED | - ---- - -## 2026-04-19 — Session 3 -Status: IN-PROGRESS - -### Feature: example-hatch - -| ID | Question | Answer | Status | -|----|----------|--------|--------| -| Q1 | What interface is used to invoke the hatch? | `pytest --beehave-hatch` (bee-related wordplay — bees hatch from cells, generating a new colony of examples) — a pytest CLI flag | ANSWERED | -| Q2 | What content should be generated? | `docs/features/` (or the configured path) with pre-defined Gherkin showcasing ALL plugin capabilities; Feature names, scenarios, and step text should use bee/hive metaphors | ANSWERED | -| Q3 | Should randomization use external libraries (e.g. Hypothesis)? | No external dependencies — use Python stdlib only (e.g. `random`, `uuid`) to vary generated content so it is not boring | ANSWERED | -| Q4 | What does success look like? | Run the flag → `docs/features/` is generated → run `pytest` → stubs are properly generated with no errors | ANSWERED | -| Q5 | Where does the generated folder land? | Respects the configured features path (`features_path` in `[tool.beehave]`); defaults to `docs/features/` | ANSWERED | -| Q6 | What happens if the target features directory already contains content? | Fail loudly with a descriptive error; provide a `--beehave-hatch-force` flag to overwrite (PO-resolved by convention — consistent with project hard-error philosophy) | RESOLVED-BY-PO | - -Status: COMPLETE - ---- - -## 2026-04-19 — Feature: stub-format-config — Session 1 -Status: IN-PROGRESS - -### Feature: stub-format-config - -| ID | Question | Answer | Status | -|----|----------|--------|--------| -| Q1 | What config key name and section? | `stub_format` under `[tool.beehave]` in `pyproject.toml` | ANSWERED | -| Q2 | What are the valid values? | `"functions"` (default, top-level functions, no class wrapper) and `"classes"` (class Test wrapping) — case-sensitive | ANSWERED | -| Q3 | What is the default when the key is absent? | `"functions"` — existing projects that never set this key get top-level functions | ANSWERED | -| Q4 | Does this affect features with no Rule blocks? | No — no-Rule features always produce module-level functions in `examples_test.py` regardless of `stub_format` | ANSWERED | -| Q5 | What happens with an invalid value (e.g. `stub_format = "methods"`)? | Hard error at pytest startup with a descriptive message — consistent with the project's hard-error philosophy | ANSWERED | -| Q6 | Does changing `stub_format` reformat existing stubs? | No — only new stubs are affected; existing stubs are not touched | ANSWERED | -| Q7 | Is the setting per-feature or project-wide? | Project-wide — applies uniformly to all Rule-block features | ANSWERED | -| Q8 | What happens to existing projects using the plugin (no `stub_format` key)? | They get `"functions"` (the new default) — top-level functions, which is the desired behavior going forward | ANSWERED | -| Q9 | What happens if a project was relying on class-based output? | They set `stub_format = "classes"` in `[tool.beehave]` to restore the old behavior | ANSWERED | -| Q10 | Is the `"classes"` format identical to the old class-based stub output? | Yes — `class Test:` wrapper with methods inside, same as what `stub_writer.py` currently produces | ANSWERED | - -Status: COMPLETE diff --git a/docs/features/backlog/.gitkeep b/docs/features/backlog/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/docs/features/completed/.gitkeep b/docs/features/completed/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/docs/features/completed/auto-id-generation.feature b/docs/features/completed/auto-id-generation.feature deleted file mode 100644 index 1364ed9..0000000 --- a/docs/features/completed/auto-id-generation.feature +++ /dev/null @@ -1,58 +0,0 @@ -Feature: Auto ID generation and enforcement - Ensures every Example in a .feature file has a unique @id tag. When an untagged Example is found - in a writable environment, an ID is generated and written back in-place. In a read-only environment - (CI), the pytest run fails with a descriptive error naming the untagged Example. - - Status: BASELINED (2026-04-18) - - Rules (Business): - - Every `Example:` block MUST have an `@id:<8-char-hex>` tag before the stub sync proceeds - - If any Example lacks an `@id` tag AND the `.feature` file is writable, generate an ID and write it back in-place, then continue - - If any Example lacks an `@id` tag AND the `.feature` file is NOT writable (CI / read-only), fail the pytest run with a descriptive error: "Untagged Examples found — run pytest locally to generate IDs" - - Generated IDs are 8-character lowercase hexadecimal strings, unique within the feature file being processed - - IDs are written on the line immediately before the `Example:` keyword - - Constraints: - - Must not corrupt the `.feature` file — only insert the `@id` tag line, leave all other content unchanged - - Must detect read-only filesystem before attempting write (check file writability, not just `CI` env var) - - The error message must name the specific `.feature` file(s) and Example title(s) that are missing IDs - - Rule: Auto ID write-back - As a developer - I want Examples without an @id tag to receive a generated ID written back to the .feature file - So that every Example is uniquely identified without manual intervention - - @id:cd98877d - Example: Untagged Example receives a generated @id tag - Given a writable .feature file containing an Example with no @id tag - When pytest is invoked - Then the .feature file contains an @id:<8-char-hex> tag on the line immediately before that Example - - @id:27cf14bf - Example: Generated IDs are unique within the feature file being processed - Given a writable .feature file containing multiple untagged Examples - When pytest is invoked - Then all generated @id tags within that file are unique - - @id:842409ed - Example: Tagged Examples are not modified - Given a .feature file where all Examples already have @id tags - When pytest is invoked - Then the .feature file content is unchanged - - Rule: CI ID enforcement - As a CI pipeline - I want pytest to fail when untagged Examples are found in a read-only environment - So that developers are forced to generate IDs locally before pushing - - @id:c4d6d9ce - Example: pytest fails when untagged Examples exist in a read-only feature file - Given a read-only .feature file containing an Example with no @id tag - When pytest is invoked - Then the pytest run exits with a non-zero status code - - @id:8b9230d4 - Example: Error message names the file and Example title - Given a read-only .feature file containing an Example with no @id tag - When pytest is invoked - Then the error output names the .feature file path and the Example title that is missing an @id diff --git a/docs/features/completed/deprecation-sync.feature b/docs/features/completed/deprecation-sync.feature deleted file mode 100644 index 7398cc8..0000000 --- a/docs/features/completed/deprecation-sync.feature +++ /dev/null @@ -1,70 +0,0 @@ -Feature: Deprecation marker sync - Keeps @pytest.mark.deprecated in sync with the @deprecated Gherkin tag across all three feature - stages. Adds the marker when the tag appears (including via Rule or Feature-level inheritance) and - removes it when the tag is gone. Completed features receive only this deprecation sync — no other - stub operations run on them. - - Status: BASELINED (2026-04-18) - - Rules (Business): - - ALL 3 feature stages (backlog, in-progress, completed) are checked for `@deprecated` tag changes - - Gherkin tag inheritance applies: `@deprecated` on a `Rule:` or `Feature:` node is treated as present on all child Examples - - If an Example has `@deprecated` tag (directly or via inheritance) and the test function lacks `@pytest.mark.deprecated`, add the marker - - If an Example no longer has `@deprecated` tag (directly or via inheritance) and the test function has `@pytest.mark.deprecated`, remove the marker - - Deprecation sync never modifies test function bodies or parameter lists - - Deprecation sync is the ONLY operation performed on `completed/` feature test files - - Constraints: - - Must handle the case where the test function does not exist (skip — stub sync handles creation) - - Must not add duplicate `@pytest.mark.deprecated` markers - - Rule: Mark deprecated Examples - As a developer - I want @pytest.mark.deprecated to be toggled on test functions whenever the @deprecated tag changes in the .feature file - So that deprecated acceptance criteria are automatically skipped across all feature stages - - @id:f9b636df - Example: Deprecated Example in a backlog feature gets the deprecated marker - Given a backlog feature with an Example tagged @deprecated whose test stub lacks @pytest.mark.deprecated - When pytest is invoked - Then the test stub has @pytest.mark.deprecated applied - - @id:fc372f15 - Example: Deprecated Example in a completed feature gets the deprecated marker - Given a completed feature with an Example tagged @deprecated whose test stub lacks @pytest.mark.deprecated - When pytest is invoked - Then the test stub has @pytest.mark.deprecated applied - - Rule: Mark deprecated via tag inheritance - As a developer - I want @pytest.mark.deprecated applied to all child Examples when @deprecated is on a Rule or Feature - So that deprecating an entire rule or feature is a single tag change - - @id:b3d7f942 - Example: @deprecated on a Rule block propagates to all child Examples - Given a backlog feature with a Rule tagged @deprecated containing multiple Examples - When pytest is invoked - Then all test stubs for Examples in that Rule have @pytest.mark.deprecated applied - - @id:a9e1c504 - Example: @deprecated on the Feature node propagates to all Examples in the feature - Given a backlog feature tagged @deprecated at the Feature level containing multiple Rules and Examples - When pytest is invoked - Then all test stubs for all Examples in that feature have @pytest.mark.deprecated applied - - @id:d6f8b231 - Example: Removing @deprecated from a Rule removes the marker from all child stubs - Given a Rule whose @deprecated tag has been removed but whose child test stubs all have @pytest.mark.deprecated - When pytest is invoked - Then @pytest.mark.deprecated is removed from all child test stubs of that Rule - - Rule: Remove deprecated marker - As a developer - I want @pytest.mark.deprecated removed when the @deprecated tag is removed from an Example - So that tests are not skipped when acceptance criteria are no longer deprecated - - @id:7fcee92a - Example: Deprecated marker is removed when @deprecated tag is removed - Given a feature with an Example that no longer has the @deprecated tag but whose test stub has @pytest.mark.deprecated - When pytest is invoked - Then @pytest.mark.deprecated is removed from that test stub diff --git a/docs/features/completed/example-hatch.feature b/docs/features/completed/example-hatch.feature deleted file mode 100644 index 30d4099..0000000 --- a/docs/features/completed/example-hatch.feature +++ /dev/null @@ -1,147 +0,0 @@ -Feature: Example hatch generation - - Generates a ready-to-use `docs/features/` directory tree (or the configured features path) - populated with bee/hive-themed Gherkin `.feature` files that exercise every plugin capability: - auto-ID generation, stub creation, stub updates, deprecation sync, multilingual parsing, and - the bootstrap flow. Content is partially randomised using Python stdlib only so each generated - example feels fresh. Invoked via `pytest --beehave-hatch`; fails loudly if the target directory - already contains content unless `--beehave-hatch-force` is also passed. - - Status: BASELINED (2026-04-19) - - Rules (Business): - - The hatch is triggered by the `--beehave-hatch` pytest flag - - The hatch writes to the configured features path (default `docs/features/`) - - If the target features directory already contains any `.feature` files, the command fails with a descriptive error unless `--beehave-hatch-force` is passed - - `--beehave-hatch-force` overwrites existing content without prompting - - Generated `.feature` files use bee/hive metaphors for Feature names, Rule titles, Example titles, and step text - - Generated content showcases all plugin capabilities: tagged and untagged Examples (auto-ID), deprecated Examples, multilingual file, backlog/in-progress/completed placement, Background blocks, Scenario Outlines, and data tables - - Randomisation uses Python stdlib only (`random`, `secrets`) — no external dependencies - - After the hatch runs, invoking `pytest` on the generated directory must produce stubs without errors - - The hatch emits a terminal summary of files written - - Constraints: - - Must not run stub sync or any other plugin operation during the hatch invocation — hatch only - - Must respect `features_path` from `[tool.beehave]` in `pyproject.toml` - - Must exit pytest immediately after hatch completes (no test collection) - - Rule: Hatch invocation - As a developer evaluating pytest-beehave - I want to generate a complete example features directory with one command - So that I can see all plugin capabilities working without writing any Gherkin myself - - @id:1a2b3c4d - Example: Hatch creates the features directory tree when it does not exist - Given no features directory exists at the configured path - When pytest is invoked with --beehave-hatch - Then the backlog, in-progress, and completed subfolders exist under the configured features path - - @id:2b3c4d5e - Example: Hatch writes bee-themed .feature files into the correct subfolders - Given no features directory exists at the configured path - When pytest is invoked with --beehave-hatch - Then at least one .feature file exists in each of the backlog, in-progress, and completed subfolders - - @id:3c4d5e6f - Example: Hatch emits a terminal summary of files written - Given no features directory exists at the configured path - When pytest is invoked with --beehave-hatch - Then the terminal output lists each .feature file that was created - - @id:4d5e6f7a - Example: pytest exits immediately after hatch without running tests - Given no features directory exists at the configured path - When pytest is invoked with --beehave-hatch - Then no tests are collected or executed - - Rule: Overwrite protection - As a developer with an existing features directory - I want the hatch to fail loudly rather than silently overwrite my work - So that I never lose existing feature files by accident - - @id:5e6f7a8b - Example: Hatch fails when the features directory already contains .feature files - Given the configured features directory already contains at least one .feature file - When pytest is invoked with --beehave-hatch - Then the pytest run exits with a non-zero status code and an error naming the conflicting path - - @id:6f7a8b9c - Example: Hatch overwrites existing content when --beehave-hatch-force is passed - Given the configured features directory already contains at least one .feature file - When pytest is invoked with --beehave-hatch --beehave-hatch-force - Then the existing .feature files are replaced with the newly generated hatch content - - Rule: Capability showcase content - As a developer evaluating pytest-beehave - I want the generated Gherkin to exercise every plugin capability - So that a single `pytest` run after hatching demonstrates the full feature set - - @id:7a8b9c0d - Example: Generated content includes an untagged Example to trigger auto-ID generation - Given no features directory exists at the configured path - When pytest is invoked with --beehave-hatch - Then at least one generated .feature file contains an Example with no @id tag - - @id:8b9c0d1e - Example: Generated content includes a @deprecated-tagged Example - Given no features directory exists at the configured path - When pytest is invoked with --beehave-hatch - Then at least one generated .feature file contains an Example tagged @deprecated - - @id:9c0d1e2f - Example: Generated content includes a multilingual feature file - Given no features directory exists at the configured path - When pytest is invoked with --beehave-hatch - Then at least one generated .feature file begins with a # language: directive - - @id:0d1e2f3a - Example: Generated content includes a feature with a Background block - Given no features directory exists at the configured path - When pytest is invoked with --beehave-hatch - Then at least one generated .feature file contains a Background: block - - @id:1e2f3a4b - Example: Generated content includes a Scenario Outline with an Examples table - Given no features directory exists at the configured path - When pytest is invoked with --beehave-hatch - Then at least one generated .feature file contains a Scenario Outline with an Examples: table - - @id:a1f2e3d4 - Example: Generated content includes a step with an attached data table - Given no features directory exists at the configured path - When pytest is invoked with --beehave-hatch - Then at least one generated .feature file contains a step followed by a data table - - @id:b2e3d4c5 - Example: Generated content includes a feature placed in the completed subfolder - Given no features directory exists at the configured path - When pytest is invoked with --beehave-hatch - Then at least one generated .feature file is placed in the completed subfolder - - Rule: Configured path respect - As a developer using a custom features path - I want the hatch to write to my configured path - So that the generated example integrates with my project layout - - @id:c3d4e5f6 - Example: Hatch writes to the custom path when features_path is configured - Given pyproject.toml contains [tool.beehave] with features_path set to a custom directory - When pytest is invoked with --beehave-hatch - Then the generated .feature files are written under the custom configured path and not under docs/features/ - - Rule: Stdlib-only randomisation - As a developer running the hatch multiple times - I want the generated content to vary slightly between runs - So that the example does not feel like a static copy-paste template - - @id:d4e5f6a7 - Example: Hatch produces a different Feature name on successive runs - Given no features directory exists at the configured path - When pytest is invoked with --beehave-hatch on two separate occasions with the directory removed between runs - Then the Feature name in the generated .feature file differs between the two runs - - @id:e5f6a7b8 - Example: Hatch completes without requiring any additional package installation - Given a clean environment with only pytest-beehave installed and no other packages - When pytest is invoked with --beehave-hatch - Then the hatch completes successfully and no import error or missing-module error is raised diff --git a/docs/features/completed/features-dir-bootstrap.feature b/docs/features/completed/features-dir-bootstrap.feature deleted file mode 100644 index 8515eff..0000000 --- a/docs/features/completed/features-dir-bootstrap.feature +++ /dev/null @@ -1,103 +0,0 @@ -Feature: Features directory bootstrap - - Status: BASELINED (2026-04-18) - - Rules (Business): - - Bootstrap runs as the first action of every pytest invocation, before stub sync - - Bootstrap only runs if the root features directory exists; if it does not exist, it is skipped - - All three subfolders (backlog/, in-progress/, completed/) must exist; each missing one is created independently - - Only .feature files directly in the root features directory (not inside any subfolder) are migrated to backlog/ - - Non-.feature files in the root features directory (e.g. discovery.md) are never moved - - Files already inside backlog/, in-progress/, or completed/ are never touched by migration - - Name collision during migration: leave the root-level file in place and emit a warning; do not overwrite - - If the structure is already correct and no root-level .feature files exist, the bootstrap is a silent no-op - - Constraints: - - Bootstrap must complete before stub sync begins so that migrated files are available for stub sync in the same run - - Terminal output style must be consistent with the existing stub sync reporting - - Rule: Subfolder creation - As a developer - I want the plugin to create missing canonical subfolders automatically - So that the features directory structure is always ready for stub sync without manual setup - - @id:3a1f8c2e - Example: All three subfolders are created when none exist - Given the features directory exists with no backlog, in-progress, or completed subfolders - When pytest is invoked - Then the backlog, in-progress, and completed subfolders all exist inside the features directory - - @id:b7d4e091 - Example: Only the missing subfolders are created when some already exist - Given the features directory exists with a backlog subfolder but no in-progress or completed subfolders - When pytest is invoked - Then the in-progress and completed subfolders are created and the backlog subfolder is unchanged - - @id:c2a53f7d - Example: Subfolder creation is reported to the terminal - Given the features directory exists with no backlog, in-progress, or completed subfolders - When pytest is invoked - Then the terminal output names each subfolder that was created - - Rule: Feature file migration - As a developer - I want .feature files in the root features directory to be moved to backlog automatically - So that existing feature files are immediately available for stub sync without manual reorganisation - - @id:e8b61d04 - Example: A .feature file in the root features directory is moved to backlog - Given the features directory contains a .feature file directly (not inside any subfolder) - When pytest is invoked - Then that .feature file exists in the backlog subfolder and no longer exists in the root features directory - - @id:f3c97a52 - Example: Non-.feature files in the root features directory are not moved - Given the features directory contains a non-.feature file (e.g. discovery.md) directly in the root - When pytest is invoked - Then that file remains in the root features directory and is not moved to backlog - - @id:a9d02b6e - Example: Files already inside a subfolder are not moved - Given the features directory contains a .feature file inside the in-progress subfolder - When pytest is invoked - Then that file remains in the in-progress subfolder and is not moved to backlog - - @id:d1e74c83 - Example: Migration is reported to the terminal - Given the features directory contains a .feature file directly in the root - When pytest is invoked - Then the terminal output names the file that was moved to backlog - - Rule: Migration collision handling - As a developer - I want to be warned when a root-level .feature file cannot be migrated due to a name conflict - So that I can resolve the conflict manually without losing either file - - @id:7f2a0d51 - Example: Root-level .feature file is left in place when a same-named file exists in backlog - Given the features directory contains root-level feature.feature and backlog/feature.feature already exists - When pytest is invoked - Then root-level feature.feature is not moved and backlog/feature.feature is unchanged - - @id:8c3b1e96 - Example: A collision warning is emitted to the terminal - Given the features directory contains root-level feature.feature and backlog/feature.feature already exists - When pytest is invoked - Then the terminal output contains a warning naming the conflicting file and its location - - Rule: No-op when structure is correct - As a developer - I want the bootstrap to produce no output when the features directory is already well-structured - So that normal pytest runs are not cluttered with unnecessary messages - - @id:5e6f9b17 - Example: Bootstrap produces no terminal output when structure is already correct - Given the features directory contains backlog, in-progress, and completed subfolders and no root-level .feature files - When pytest is invoked - Then the terminal output contains no bootstrap messages - - @id:2d8a4c70 - Example: Bootstrap is skipped when the features directory does not exist - Given the features directory does not exist - When pytest is invoked - Then pytest completes collection without errors and no bootstrap messages appear in the terminal diff --git a/docs/features/completed/features-path-config.feature b/docs/features/completed/features-path-config.feature deleted file mode 100644 index ae2ea28..0000000 --- a/docs/features/completed/features-path-config.feature +++ /dev/null @@ -1,50 +0,0 @@ -Feature: Features path configuration - - Status: BASELINED (2026-04-18) - - Rules (Business): - - Configuration lives in `[tool.beehave]` section of `pyproject.toml` - - The only configurable option is `features_path` (path to the features directory) - - Default value: `docs/features/` relative to the project root (where `pyproject.toml` lives) - - The path is resolved relative to the directory containing `pyproject.toml` - - If `pyproject.toml` does not exist or has no `[tool.beehave]` section, the default is used silently - - The plugin is always-on; there is no enable/disable switch - - Constraints: - - Must not fail if `pyproject.toml` is absent — fall back to default - - Must not fail if `[tool.beehave]` section is absent — fall back to default - - Must produce a clear error if `features_path` is configured but the directory does not exist - - Rule: Custom features path - As a developer - I want to configure the features folder path in pyproject.toml - So that I can use a non-default directory layout without modifying the plugin source - - @id:acf12157 - Example: Custom features path is used when configured - Given pyproject.toml contains [tool.beehave] with features_path set to a custom directory - When pytest is invoked - Then the plugin reads .feature files from the configured custom directory - - @id:124f65e7 - Example: pytest fails when configured features path does not exist - Given pyproject.toml contains [tool.beehave] with features_path pointing to a non-existent directory - When pytest is invoked - Then the pytest run exits with a non-zero status code and an error naming the missing path - - Rule: Default features path - As a developer - I want the features folder to default to docs/features/ - So that it works out of the box without configuration - - @id:ce8a95e7 - Example: Default features path is used when no configuration is present - Given pyproject.toml contains no [tool.beehave] section - When pytest is invoked - Then the plugin reads .feature files from docs/features/ relative to the project root - - @id:aaeda817 - Example: Default features path is used when pyproject.toml is absent - Given no pyproject.toml exists in the project root - When pytest is invoked - Then the plugin reads .feature files from docs/features/ relative to the project root diff --git a/docs/features/completed/multilingual-feature-parsing.feature b/docs/features/completed/multilingual-feature-parsing.feature deleted file mode 100644 index 2b8dac3..0000000 --- a/docs/features/completed/multilingual-feature-parsing.feature +++ /dev/null @@ -1,51 +0,0 @@ -Feature: Multilingual feature parsing - Parses .feature files written in any language supported by gherkin-official. Non-English parsing - is triggered by the standard `# language: xx` comment in the feature file. Function names are - derived from the feature folder name, never from Gherkin keywords, so any language works - transparently without any beehave configuration. - - Status: BASELINED (2026-04-18) - - Rules (Business): - - The `# language: xx` comment is the sole mechanism for non-English parsing; no beehave config needed. - - Function names and class names are derived from the feature folder name, never from Gherkin keywords. - - Each .feature file is parsed independently; mixed-language projects work transparently. - - Constraints: - - No pyproject.toml language configuration — out of scope. - - No custom error handling for unrecognised language codes — out of scope. - - No keyword normalisation to English in docstrings. - - No implementation changes to feature_parser.py expected; tests prove existing behaviour. - - Rule: Spanish feature file parsing - As a developer working on a Spanish-language project - I want my Spanish Gherkin feature files to be parsed correctly - So that the plugin does not break on non-English files - - @id:e1081346 - Example: A valid Spanish Gherkin feature file is parsed without error - Given a valid Spanish Gherkin feature file - When parse_feature is called on that file - Then a ParsedFeature is returned with the correct number of examples - - Rule: Chinese feature file parsing - As a developer working on a Chinese-language project - I want my Chinese Gherkin feature files to be parsed correctly - So that the plugin does not break on non-English files - - @id:55e4d669 - Example: A valid Chinese Gherkin feature file is parsed without error - Given a valid Chinese Gherkin feature file - When parse_feature is called on that file - Then a ParsedFeature is returned with the correct number of examples - - Rule: Mixed-language project compatibility - As a developer on a project with feature files in multiple languages - I want all feature files to be parsed correctly in a single sync run - So that language choice per file does not affect the rest of the project - - @id:3c04262e - Example: Spanish and English feature files coexist in the same project without conflict - Given a project containing a valid Spanish Gherkin feature file and a valid English feature file - When parse_feature is called on each file independently - Then both files are parsed successfully and return valid ParsedFeature objects diff --git a/docs/features/completed/plugin-hook.feature b/docs/features/completed/plugin-hook.feature deleted file mode 100644 index bbc6bbb..0000000 --- a/docs/features/completed/plugin-hook.feature +++ /dev/null @@ -1,47 +0,0 @@ -Feature: pytest lifecycle integration - - Status: BASELINED (2026-04-18) - - Rules (Business): - - The plugin registers itself automatically via `entry_points` in `pyproject.toml` — no manual `conftest.py` required - - The stub sync runs before pytest collection so that any newly generated stubs are discovered and collected in the same `pytest` invocation - - The plugin is always-on; there is no configuration option to disable it - - Constraints: - - Must not break pytest collection if the features directory does not exist or is empty - - Must be compatible with pytest ≥ 6.0 - - Entry point key: `pytest11` - - Rule: Stub sync runs before collection - As a developer - I want the stub sync to run automatically when I invoke pytest - So that my test stubs are always in sync with my acceptance criteria without a manual step - - @id:bde8de30 - Example: Stub sync runs before test collection - Given a project with a backlog feature containing a new Example with an @id tag - When pytest is invoked - Then the test stub for that Example exists before any tests are collected - - @id:d5824c75 - Example: Plugin reports sync actions to the terminal - Given a project with a backlog feature containing a new Example - When pytest is invoked - Then the terminal output includes a summary of the stub sync actions taken - - Rule: Graceful handling - As a developer - I want the plugin to handle missing features directories gracefully - So that pytest completes without errors even when no features are configured - - @id:d0f2866d - Example: Plugin skips sync and continues when the default features directory is absent - Given no pyproject.toml [tool.beehave] section is present and the default docs/features/ directory does not exist - When pytest is invoked - Then pytest completes collection without errors - - @deprecated @id:e3a13b58 - Example: Plugin does not crash when configured features directory is absent - Given a project where the configured features directory does not exist - When pytest is invoked - Then pytest completes collection without errors diff --git a/docs/features/completed/report-steps.feature b/docs/features/completed/report-steps.feature deleted file mode 100644 index 68c6285..0000000 --- a/docs/features/completed/report-steps.feature +++ /dev/null @@ -1,81 +0,0 @@ -Feature: Report Steps - - Surfaces BDD acceptance criteria in two output channels: the terminal (at -v or above) and - pytest-html reports (when pytest-html is installed). Both channels are independently - configurable via pyproject.toml and are scoped exclusively to tests under tests/features/. - - Status: BASELINED (2026-04-18) - - Rules (Business): - - Steps are always rendered verbatim from the test docstring — no reformatting - - Both output channels are scoped exclusively to tests/features/ - - Steps are rendered regardless of test outcome (pass, fail, skip, error) - - The HTML channel is silently absent when pytest-html is not installed - - Constraints: - - show_steps_in_terminal defaults to true; active at -v or above - - show_steps_in_html defaults to true; only active when pytest-html is installed - - Both features are wired into the single existing pytest_configure entry point - - pytest-html is an optional install extra: pip install pytest-beehave[html] - - Rule: Terminal Steps Display - As a developer running pytest with verbosity - I want BDD steps printed below each feature test result - So that I can see what each test covers without opening the feature file - - @id:2ba9da81 - Example: Steps appear below test path at -v - Given a test in tests/features/ with a docstring containing BDD steps - When pytest runs with -v - Then the docstring is printed verbatim on the line below the test path followed by a blank line - - @id:0869902b - Example: Steps appear for skipped stubs at -v - Given a test in tests/features/ marked skip with a docstring - When pytest runs with -v - Then the docstring is printed verbatim below the skipped test path followed by a blank line - - @id:99cbca75 - Example: No steps output for tests outside tests/features/ - Given a test in tests/unit/ with a docstring - When pytest runs with -v - Then no additional output is printed for that test - - @id:3c1b6d21 - Example: No steps output when show_steps_in_terminal is false - Given show_steps_in_terminal = false in pyproject.toml - And a test in tests/features/ with a docstring - When pytest runs with -v - Then no steps are printed for that test - - @id:3278cf4d - Example: No steps output below -v verbosity - Given show_steps_in_terminal = true in pyproject.toml - And a test in tests/features/ with a docstring - When pytest runs without any -v flag - Then no steps are printed for that test - - Rule: HTML Acceptance Criteria Column - As a developer reading a pytest-html report - I want a dedicated column showing BDD steps for feature tests - So that the report communicates what each test was verifying - - @id:88d58f5c - Example: Acceptance Criteria column shows docstring for feature tests - Given pytest-html is installed and show_steps_in_html = true - And a test in tests/features/ with a docstring - When the pytest-html report is generated - Then the "Acceptance Criteria" column contains the verbatim docstring for that test - - @id:73c4a71a - Example: Acceptance Criteria column is blank for non-feature tests - Given pytest-html is installed and show_steps_in_html = true - And a test outside tests/features/ - When the pytest-html report is generated - Then the "Acceptance Criteria" column is blank for that test - - @id:6c592c81 - Example: HTML column absent when pytest-html not installed - Given pytest-html is not installed - When pytest runs and generates output - Then no "Acceptance Criteria" column appears and no error is raised diff --git a/docs/features/completed/stub-creation.feature b/docs/features/completed/stub-creation.feature deleted file mode 100644 index fdb3a66..0000000 --- a/docs/features/completed/stub-creation.feature +++ /dev/null @@ -1,132 +0,0 @@ -Feature: Test stub creation - Creates top-level test functions for each new Example in backlog and in-progress features. - Stubs include a skip marker and a verbatim step docstring. Completed features are never touched - by stub sync. - - Status: BASELINED (2026-04-18) - - Rules (Business): - - Feature files are parsed using gherkin-official AST — no regex/string manipulation - - Only `backlog/` and `in-progress/` feature folders receive stub creation - - `completed/` features are never touched by stub sync - - New stubs receive `@pytest.mark.skip(reason="not yet implemented")` — stub-sync owns this marker; developer never adds or removes it - - The docstring includes EVERY step line in order, including `And` and `But` continuations - - Docstring format per step: `: ` (e.g., `Given: user is logged in`, `And: user has admin role`) - - Test function bodies start with `raise NotImplementedError` — no section comments - - Features with `Rule:` blocks: stubs are top-level functions in `_test.py` - - Features with no `Rule:` blocks: stubs are module-level functions in `examples_test.py` - - Feature-level `Background:` → `conftest.py` autouse fixture; Rule-level `Background:` → module-level autouse fixture in `_test.py` - - Constraints: - - Must handle the case where the test file does not yet exist (create it) - - Must handle the case where a test file exists but is missing some stubs (add only the missing ones) - - Function naming: `test__<@id>` — always - - Rule: New stub generation - As a developer - I want a test stub to be created for each new Example in backlog and in-progress features - So that I have a failing test ready to implement for every acceptance criterion - - @id:692972dd - Example: New stub is created with the correct function name - Given a backlog feature folder containing a .feature file with a new @id-tagged Example - When pytest is invoked - Then a test function named test__<@id> exists in the corresponding test file - - @deprecated - @id:d14d975f - Example: New stub has no default pytest marker - Given a backlog feature folder containing a .feature file with a new @id-tagged Example - When pytest is invoked - Then the generated test function has no @pytest.mark decorator - - @id:a4c781f2 - Example: New stub has skip marker not yet implemented - Given a backlog feature folder containing a .feature file with a new @id-tagged Example - When pytest is invoked - Then the generated test function has @pytest.mark.skip(reason="not yet implemented") applied - - @deprecated - @id:e2b093d1 - Example: New stub for a Rule block is a method inside the rule class - Given a backlog feature file with a Rule block containing a new @id-tagged Example - When pytest is invoked - Then the generated stub is a method inside class Test in _test.py - - @deprecated - @id:c3a8f291 - Example: New stub for a Rule block is a top-level function (not a class method) - Given a backlog feature file with a Rule block containing a new @id-tagged Example - When pytest is invoked - Then the generated stub is a top-level function in _test.py with no class wrapping - - @id:f1a5c823 - Example: New stub for a feature with no Rule blocks is a module-level function - Given a backlog feature file with no Rule blocks containing a new @id-tagged Example - When pytest is invoked - Then the generated stub is a module-level function in examples_test.py - - @id:777a9638 - Example: New stub body contains raise NotImplementedError - Given a backlog feature folder containing a .feature file with a new @id-tagged Example - When pytest is invoked - Then the generated test function body ends with raise NotImplementedError - - @id:bba184c0 - Example: New stub body contains only raise NotImplementedError with no section comments - Given a backlog feature folder containing a .feature file with a new @id-tagged Example - When pytest is invoked - Then the generated test function body contains no "# Given", "# When", or "# Then" comment lines - - @id:edc964fc - Example: Test directory uses underscore slug not kebab-case - Given a backlog feature folder whose name contains hyphens (e.g. "my-feature") - When pytest is invoked - Then the test file is created at tests/features/my_feature/ not tests/features/my-feature/ - - @id:38d864b9 - Example: Stubs are not created for completed feature Examples - Given a completed feature folder containing a .feature file with a new @id-tagged Example - When pytest is invoked - Then no new test stub is created for that Example - - Rule: Docstring generation - As a developer - I want the stub docstring to contain all the steps from the Example - So that the test intent is clear from the code - - @id:db596443 - Example: And and But steps use their literal keyword in the docstring - Given a backlog feature with an Example containing And and But steps - When pytest is invoked - Then each And step appears as "And: " and each But step appears as "But: " in the docstring - - @id:17b01d7a - Example: Asterisk steps appear as "* " in the docstring - Given a backlog feature with an Example containing a step written with the * bullet - When pytest is invoked - Then that step appears as "*: " in the generated test stub docstring - - @id:c56883ce - Example: Multi-line doc string attached to a step is included in the docstring - Given a backlog feature with an Example where a step has an attached multi-line doc string block - When pytest is invoked - Then the generated test stub docstring includes the doc string content indented below the step line - - @id:2fc458f8 - Example: Data table attached to a step is included in the docstring - Given a backlog feature with an Example where a step has an attached data table - When pytest is invoked - Then the generated test stub docstring includes the table rows indented below the step line - - @id:7f91cf3a - Example: Background steps appear as separate Background sections before scenario steps - Given a backlog feature with a feature-level Background and a Rule-level Background - When pytest is invoked - Then the generated test stub docstring contains two "Background:" sections in order before the scenario steps - - @id:9a4e199a - Example: Scenario Outline stub uses raw template text and includes the Examples table - Given a backlog feature containing a Scenario Outline with placeholder values and an Examples table - When pytest is invoked - Then the generated test stub docstring contains the raw template step text followed by the Examples table diff --git a/docs/features/completed/stub-format-config.feature b/docs/features/completed/stub-format-config.feature deleted file mode 100644 index 53afd91..0000000 --- a/docs/features/completed/stub-format-config.feature +++ /dev/null @@ -1,89 +0,0 @@ -Feature: Stub format configuration - - Controls the output format of generated test stubs via a `stub_format` key in - `[tool.beehave]` in `pyproject.toml`. Two formats are supported: `"functions"` - (default) generates top-level functions in `_test.py` with no class - wrapper; `"classes"` generates stubs as methods inside `class Test`. - When the key is absent, `"functions"` is used. Existing projects that relied on - the class-based output can opt back in by setting `stub_format = "classes"`. - - Status: BASELINED (2026-04-19) - - Rules (Business): - - `stub_format` lives under `[tool.beehave]` in `pyproject.toml` - - Valid values are exactly `"functions"` and `"classes"` (case-sensitive) - - Default when key is absent: `"functions"` - - Features with no `Rule:` blocks always produce module-level functions in `examples_test.py` regardless of `stub_format` - - The format setting applies to ALL Rule-block features in the project uniformly - - Constraints: - - Invalid `stub_format` values must produce a hard error at pytest startup (not silently ignored) - - Changing `stub_format` does not retroactively reformat existing stubs — only new stubs are affected - - Rule: Default format selection - As a developer - I want stub generation to default to top-level functions when no stub_format is configured - So that new projects and existing projects without explicit configuration get the preferred format automatically - - @id:a1b2c3d4 - Example: Stub is a top-level function when stub_format is absent - Given a pyproject.toml with no stub_format key under [tool.beehave] - When pytest generates a stub for a Rule-block Example - Then the stub is a top-level function def test__<@id> with no class wrapper - - @id:b2c3d4e5 - Example: Absent stub_format does not raise an error - Given a pyproject.toml with no stub_format key under [tool.beehave] - When pytest starts up - Then pytest starts without any stub_format-related error - - Rule: Explicit functions format - As a developer - I want to explicitly set stub_format = "functions" in pyproject.toml - So that I can document my format choice and ensure top-level function stubs are generated - - @id:f1e2d3c4 - Example: Stub is a top-level function when stub_format = "functions" - Given a pyproject.toml with stub_format = "functions" under [tool.beehave] - When pytest generates a stub for a Rule-block Example - Then the stub is a top-level function def test__<@id> with no class wrapper - - Rule: Classes format selection - As a developer - I want to set stub_format = "classes" in pyproject.toml - So that I can restore the class-wrapped stub output for projects that prefer that style - - @id:a2b3c4d5 - Example: Stub is a class method when stub_format = "classes" - Given a pyproject.toml with stub_format = "classes" under [tool.beehave] - When pytest generates a stub for a Rule-block Example - Then the stub is a method inside class Test in _test.py - - @id:b3c4d5e6 - Example: Class name is derived from the Rule title slug - Given a pyproject.toml with stub_format = "classes" and a Rule titled "Wall bounce" - When pytest generates a stub for an Example under that Rule - Then the stub is inside a class named TestWallBounce - - Rule: Invalid format rejection - As a developer - I want pytest to fail immediately with a clear error when stub_format has an unrecognised value - So that misconfiguration is caught at startup rather than silently producing wrong output - - @id:f6a7b8c9 - Example: Pytest fails at startup when stub_format has an unrecognised value - Given a pyproject.toml with stub_format = "methods" under [tool.beehave] - When pytest starts up - Then pytest exits with a non-zero status and an error message naming the invalid value - - Rule: No-Rule feature unaffected - As a developer - I want features with no Rule blocks to always produce module-level functions in examples_test.py - So that the stub_format setting does not change the behavior of no-Rule features - - @id:a7b8c9d0 - Example: No-Rule feature produces module-level functions regardless of stub_format = "classes" - Given a pyproject.toml with stub_format = "classes" under [tool.beehave] - And a feature file with no Rule blocks - When pytest generates stubs for that feature - Then the stubs are module-level functions in examples_test.py with no class wrapper diff --git a/docs/features/completed/stub-updates.feature b/docs/features/completed/stub-updates.feature deleted file mode 100644 index 585d4e1..0000000 --- a/docs/features/completed/stub-updates.feature +++ /dev/null @@ -1,110 +0,0 @@ -Feature: Test stub updates - Updates existing test stubs when a .feature file changes: refreshes docstrings, renames functions - when the feature slug changes, marks non-conforming stubs, and marks orphaned stubs. Completed - features receive only orphan detection. - - Status: BASELINED (2026-04-18) - - Rules (Business): - - Only `backlog/` and `in-progress/` feature folders receive stub creation and updates - - `completed/` features: only orphan detection runs (no creation, no docstring/name updates) - - The docstring includes EVERY step line in order, including `And` and `But` continuations - - Docstring format per step: `: ` (e.g., `Given: user is logged in`, `And: user has admin role`) - - Test function bodies are NEVER modified — parameter lists MAY be updated to stay in sync with documentation - - stub-sync NEVER touches `@pytest.mark.slow` or any other software-engineer-owned marker - - Conformance requires both: (1) correct file (`_test.py` or `examples_test.py`), (2) correct function name (`test__<@id>`) - - Non-conforming test (correct `@id`, wrong file): stub-sync creates a conforming version first, then marks the non-conforming test with `@pytest.mark.skip(reason="non-conforming: moved to ")` - - Orphaned tests (no matching `@id` in any `.feature` file) receive `@pytest.mark.skip(reason="orphan: no matching @id in .feature files")` - - Constraints: - - Must never modify test function bodies - - Must handle the case where a test file exists but is missing some stubs (add only the missing ones) - - Function naming: `test__<@id>` — always a top-level function - - stub-sync must not add or remove `@pytest.mark.slow` - - Rule: Docstring updates - As a developer - I want existing test stubs to have their docstrings updated when the .feature file changes - So that test stubs always reflect the current acceptance criteria wording - - @id:bdb8e233 - Example: Docstring is updated when step text changes - Given an existing test stub whose docstring does not match the current step text in the .feature file - When pytest is invoked - Then the test stub docstring matches the current step text from the .feature file - - @id:6bb59874 - Example: Test body is not modified during docstring update - Given an existing test stub with a custom implementation in the function body - When pytest is invoked and the .feature file step text has changed - Then the test function body below the docstring is unchanged - - Rule: Function renames - As a developer - I want test stubs to be renamed when the feature slug changes - So that function names stay consistent with the feature folder - - @id:b6b9ab28 - Example: Function is renamed when the feature slug changes - Given an existing test stub whose function name does not match the current feature slug - When pytest is invoked - Then the test function is renamed to match test__<@id> - - @id:d89540f9 - Example: Stubs in completed features are not updated - Given a completed feature with a test stub whose docstring differs from the .feature file - When pytest is invoked - Then the completed feature test stub docstring is unchanged - - Rule: Non-conforming handling - As a developer - I want tests with a valid @id but in the wrong location to be redirected to the correct structure - So that the canonical test layout is enforced without silently losing traceability - - @deprecated - @id:4a7c2e81 - Example: Non-conforming test receives redirect marker - Given a test function whose @id matches a current Example but is in the wrong file or class - When pytest is invoked - Then stub-sync creates a conforming stub in the correct location and marks the original with @pytest.mark.skip(reason="non-conforming: moved to ::") - - @id:7e1a3c90 - Example: Non-conforming test receives redirect marker - Given a test function whose @id matches a current Example but is in the wrong file - When pytest is invoked - Then stub-sync creates a conforming stub in the correct location and marks the original with @pytest.mark.skip(reason="non-conforming: moved to ") - - @id:3f9d1b56 - Example: Once a conforming stub exists the non-conforming marker is preserved - Given a non-conforming test already marked and a conforming stub already present in the correct location - When pytest is invoked - Then the non-conforming marker remains on the original test and the conforming stub is unchanged - - Rule: Orphan handling - As a developer - I want test functions with no matching @id in any .feature file to be marked as skipped - So that stale tests do not pollute the test suite without being silently ignored - - @id:9d7a0b34 - Example: Orphan test receives skip marker - Given a test file containing a test function whose @id hex does not match any Example in any .feature file - When pytest is invoked - Then that test function has @pytest.mark.skip(reason="orphan: no matching @id in .feature files") applied - - @id:67192894 - Example: Previously orphaned test loses skip marker when a matching Example is added - Given a test function marked as orphan and a .feature file that now contains a matching @id Example - When pytest is invoked - Then the orphan skip marker is removed from that test function - - @id:8b2e4f17 - Example: Orphan detection runs on completed feature test files - Given a completed feature test file containing a test function whose @id no longer exists in the feature file - When pytest is invoked - Then that test function receives @pytest.mark.skip(reason="orphan: no matching @id in .feature files") - - @id:c9a30d52 - Example: Stub-sync does not modify software-engineer-owned markers - Given a test function with @pytest.mark.slow already applied by the software-engineer - When pytest is invoked and stub-sync processes the feature - Then @pytest.mark.slow is unchanged and no other software-engineer marker is added or removed diff --git a/docs/features/in-progress/.gitkeep b/docs/features/in-progress/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/docs/glossary.md b/docs/glossary.md deleted file mode 100644 index 0200fe6..0000000 --- a/docs/glossary.md +++ /dev/null @@ -1,106 +0,0 @@ -# Glossary: pytest-beehave - -Living glossary generated from the domain model in `docs/discovery.md` and architectural decisions in `docs/architecture.md`. Terms are listed alphabetically within each category. - ---- - -## Roles - -| Term | Definition | -|---|---| -| **CI Pipeline** | Automated environment (e.g. GitHub Actions) that runs pytest; treated as read-only — beehave fails fast with a descriptive error instead of writing `@id` tags back. | -| **Developer** | Python developer using the Beehave workflow; writes `.feature` files, runs pytest, reads generated test stubs. | - ---- - -## Nouns — Domain Concepts - -| Term | Definition | -|---|---| -| **`@deprecated` tag** | Gherkin tag placed directly on an `Example:`, `Rule:`, or `Feature:` node. When present, beehave applies `@pytest.mark.deprecated` to the corresponding test stub. Inheritance is resolved at parse time. | -| **`@id` tag** | `@id:` tag on a Gherkin `Example:` block that uniquely identifies it. Format: `@id:<8-char-hex>`. beehave generates and writes these back if absent (or fails in CI). | -| **`@pytest.mark.deprecated`** | pytest marker applied to a test stub when the corresponding Gherkin `Example:` carries a `@deprecated` tag (directly or via inheritance). Auto-skipped by conftest. | -| **`[tool.beehave]` section** | Configuration section in `pyproject.toml` where the developer can set `features_path` and other options. | -| **`stub_format`** | Configuration key under `[tool.beehave]` that controls output format of generated test stubs for Rule-block features. Values: `"functions"` (default, top-level functions) and `"classes"` (class-wrapped methods). First appeared: `stub-format-config`. | -| **`# language: xx` comment** | Language directive at the top of a Gherkin `.feature` file. Instructs `gherkin-official` to parse using the specified dialect (e.g. `es`, `zh-CN`). beehave delegates this fully to `gherkin-official`. | -| **`--beehave-hatch` flag** | pytest CLI flag that triggers hatch generation. When passed, beehave writes bee-themed example `.feature` files to the configured features path and exits immediately — no test collection occurs. First appeared: `example-hatch`. | -| **`--beehave-hatch-force` flag** | pytest CLI flag that allows overwriting existing hatch content. When passed alongside `--beehave-hatch`, beehave replaces any existing `.feature` files in the hatch output directories. First appeared: `example-hatch`. | -| **backlog stage** | The `docs/features/backlog/` directory. Features here receive full stub creation and updates on every pytest run. | -| **`BeehaveConfig`** | Frozen dataclass returned by `config.read_config()`; carries the resolved `features_path: Path`. | -| **bee/hive-themed content** | Feature names, Rule titles, Example titles, and step text using bee/hive metaphors. Used in hatch-generated `.feature` files to demonstrate plugin capabilities. First appeared: `example-hatch`. | -| **`BootstrapResult`** | Value object returned by `bootstrap_features_directory()`; carries lists of created directories, migrated files, and collision warnings; `is_noop` is `True` when no action was taken. | -| **capability showcase** | The set of generated `.feature` files produced by `--beehave-hatch` that together exercise every plugin capability (Background, Rule, Scenario Outline, data tables, untagged Examples, `@deprecated`). First appeared: `example-hatch`. | -| **completed stage** | The `docs/features/completed/` directory. Features here receive only orphan detection and deprecation sync — no new stubs are created, no docstrings are updated. | -| **conforming test** | A test function that satisfies both conformance rules: (1) lives in the correct file (`_test.py`) and (2) has the correct function name (`test__<@id>`). | -| **default path** | `docs/features/` relative to the project root. Used when no `features_path` is set in `[tool.beehave]`. | -| **docstring** | Verbatim Gherkin step text embedded in a test stub as a Python docstring. Rendered in the terminal at `-v` and in the pytest-html Acceptance Criteria column. Never reformatted. | -| **Example block** | A Gherkin `Example:` (or `Scenario:`) node — the smallest testable unit. Each Example maps to exactly one test stub function. | -| **`ExampleId`** | 8-character lowercase hexadecimal string that uniquely identifies an `Example:` within its `.feature` file. | -| **feature slug** | The `.feature` filename stem, lowercased with hyphens replaced by underscores. Used in the generated test function name (`test__<@id>`). | -| **feature test** | Any test residing under `tests/features/`. These tests have their docstring steps surfaced in terminal and HTML output. | -| **features directory** | Root configured features path (e.g. `docs/features/`). beehave expects three canonical subfolders inside it. | -| **Gherkin tag inheritance** | When a `@deprecated` tag is placed on a `Rule:` or `Feature:` node, all child `Example:` nodes inherit it. Resolved at parse time by `feature_parser.py`. | -| **hatch** | The generated `docs/features/` directory tree with bee-themed example `.feature` files. Written by `hatch.py` when `--beehave-hatch` is passed. First appeared: `example-hatch`. | -| **`HatchFile`** | Frozen dataclass carrying `relative_path: str` and `content: str`; returned by `generate_hatch_files()` and consumed by `write_hatch()`. Separates pure content generation from filesystem writes. First appeared: `example-hatch`. | -| **hex ID** | See `ExampleId`. | -| **HTML channel** | The "Acceptance Criteria" column in the pytest-html report, populated with each feature test's docstring. Active only when `pytest-html` is installed and `show_steps_in_html` is not `false`. | -| **in-progress stage** | The `docs/features/in-progress/` directory. Features here receive full stub creation and updates on every pytest run. | -| **non-English keyword** | A Gherkin keyword in a supported dialect (e.g. `Característica:`, `功能:`). beehave preserves these verbatim in generated docstrings. | -| **non-feature test** | Any test outside `tests/features/` (e.g. `tests/unit/`). Steps are never rendered for non-feature tests. | -| **orphan test** | A test function with no matching `@id` in any `.feature` file in the features directory. | -| **`ParsedExample`** | Domain object produced by `feature_parser`; carries `example_id`, `rule_slug`, `feature_slug`, `steps`, and `is_deprecated`. | -| **`ParsedFeature`** | Domain object produced by `feature_parser`; carries the feature slug, stage, and a list of `ParsedExample` objects. | -| **pytest config** | The pytest configuration object provided to `pytest_configure`; used to resolve the project root and access the terminal writer. | -| **pytest plugin** | The `BeehavePlugin` class registered via the `pytest11` entry point in `pyproject.toml`. Loaded automatically by pytest on every invocation. | -| **pytest session** | The pytest lifecycle object; beehave's sync runs inside `pytest_configure` (before collection) so newly generated stubs are collected in the same run. | -| **rule slug** | The `Rule:` title slugified to lowercase with underscores. Used as the test file name (`_test.py`) and as part of the test function name. | -| **stdlib randomisation** | Use of `secrets.choice()` from the Python standard library to vary generated hatch content. No external dependencies required. First appeared: `example-hatch`. | -| **stub sync** | The full synchronisation operation: parse features, compare against existing stubs, create/rename/orphan/deprecate as needed. | -| **terminal channel** | Verbatim docstring printed below the test path in terminal output at `-v` or above, for feature tests only. | -| **test file** | `_test.py` (one per `Rule:` block) generated in `tests/features//`. | -| **test stub** | A top-level Python function `test__<@id>()` with a `@pytest.mark.skip(reason="not yet implemented")` decorator and a verbatim Gherkin docstring. | -| **verbatim steps** | Docstring content rendered with no reformatting — exactly as written in the `.feature` file. | - ---- - -## Nouns — Directory / File Paths - -| Term | Definition | -|---|---| -| **backlog subfolder** | `docs/features/backlog/` — features queued for development. | -| **completed subfolder** | `docs/features/completed/` — accepted and shipped features. | -| **in-progress subfolder** | `docs/features/in-progress/` — the one feature currently being built (WIP limit: 1). | -| **`pyproject.toml`** | Python project configuration file. beehave reads `[tool.beehave].features_path` from it using `stdlib tomllib`. | - ---- - -## Verbs — Operations - -| Term | Definition | -|---|---| -| **add marker** | Prepend `@pytest.mark.deprecated` to a test function when the Example is deprecated. | -| **bootstrap** | Create the three canonical subfolders (`backlog/`, `in-progress/`, `completed/`) if any are missing. Runs before stub sync. | -| **create stub** | Write a new top-level `test__<@id>()` function for a new `Example:` block. | -| **deprecate stub** | Apply `@pytest.mark.deprecated` to a test function when the corresponding Gherkin `Example:` carries `@deprecated`. | -| **detect** | Check whether the three-subfolder structure exists; used to decide whether bootstrap action is needed. | -| **detect deprecated** | Check `Example:` tags (direct and inherited) for `@deprecated`. | -| **detect missing ID** | Scan `Example:` tags for a valid `@id:` tag; flag if absent. | -| **fail run** | Abort pytest with a clear, descriptive error message (used in CI when `@id` tags are absent). | -| **fall back to default** | Use `docs/features/` if no `features_path` is set in `[tool.beehave]`. | -| **force-overwrite** | Replace existing hatch content when `--beehave-hatch-force` is passed. First appeared: `example-hatch`. | -| **generate ID** | Produce a unique 8-character lowercase hex string, unique within the current `.feature` file. | -| **hatch** | Write the example features directory tree to the configured path. Triggered by `--beehave-hatch`. First appeared: `example-hatch`. | -| **mark non-conforming** | Apply `@pytest.mark.skip(reason="non-conforming: moved to ")` to a test function that is in the wrong file or has the wrong name. The conforming stub is always created first. | -| **mark orphan** | Apply `@pytest.mark.skip(reason="orphan: ...")` to a test function with no matching `@id` in any `.feature` file. | -| **migrate** | Move a root-level `.feature` file found directly in the features directory into `backlog/`. | -| **overwrite-protect** | Fail loudly when the target hatch directory already contains `.feature` files and `--beehave-hatch-force` was not passed. First appeared: `example-hatch`. | -| **read config** | Parse `[tool.beehave]` from `pyproject.toml` using `stdlib tomllib`; return a `BeehaveConfig`. | -| **register plugin** | The `pytest_configure` entry point that loads `BeehavePlugin` into the pytest session. | -| **remove marker** | Remove `@pytest.mark.deprecated` from a test function when the `@deprecated` tag is no longer present. | -| **render steps** | Print or inject the docstring into the appropriate output channel (terminal or HTML). | -| **resolve path** | Make the configured `features_path` absolute relative to the project root. | -| **run before collection** | Invoke stub sync inside `pytest_configure` so new stubs are discoverable in the same pytest run. | -| **suppress steps** | Omit step output when the channel is disabled (`show_steps_in_terminal = false` / `show_steps_in_html = false`) or the test is outside `tests/features/`. | -| **write back** | Insert a generated `@id:` tag into the `.feature` file in-place, on the line immediately before the `Example:` keyword. | -| **select format** | Choose between `"functions"` (top-level) and `"classes"` (class-wrapped) format based on `stub_format` config. First appeared: `stub-format-config`. | -| **wrap class** | Output a test function as a method inside `class Test:` when `stub_format = "classes"`. First appeared: `stub-format-config`. | diff --git a/docs/images/banner.svg b/docs/images/banner.svg deleted file mode 100644 index 1c3fb02..0000000 --- a/docs/images/banner.svg +++ /dev/null @@ -1,55 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Beehave - - - - - - diff --git a/docs/images/logo.svg b/docs/images/logo.svg deleted file mode 100644 index ad639d5..0000000 --- a/docs/images/logo.svg +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/index.html b/docs/index.html deleted file mode 100644 index 0e2cef5..0000000 --- a/docs/index.html +++ /dev/null @@ -1,68 +0,0 @@ - - - - - - Project Documentation - - - -

Documentation

-

Generated project documentation

- -
Built with pdoc · pytest-cov · pytest-html
- - diff --git a/docs/post-mortem/.gitkeep b/docs/post-mortem/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/docs/scientific-research/README.md b/docs/scientific-research/README.md deleted file mode 100644 index f96858b..0000000 --- a/docs/scientific-research/README.md +++ /dev/null @@ -1,16 +0,0 @@ -# Scientific Research — Index - -Theoretical and empirical foundations for the decisions made in this template, organized by domain. - -| File | Entries | Domain | -|---|---|---| -| `cognitive-science.md` | 1–10 | Pre-mortem, implementation intentions, commitment devices, System 2, adversarial collaboration, accountability, chunking, elaborative encoding, error feedback, prospective memory | -| `testing.md` | 11–15, 51–54 | Observable behavior testing, test-behavior alignment, first-class tests, property-based testing, mutation testing, Canon TDD, GOOS outer/inner loop, Is TDD Dead, BDD origin | -| `software-economics.md` | 16 | Cost of change curve (shift left) | -| `requirements-elicitation.md` | 17–20, 28–30, 43–50 | INVEST, Example Mapping, declarative Gherkin, MoSCoW, active listening, Kipling 5Ws, BA framework, FDD, affinity mapping, Event Storming, CIT, cognitive interview, laddering, funnel technique, RE issues | -| `domain-modeling.md` | 31 | DDD bounded contexts, ubiquitous language, feature identification | -| `oop-design.md` | 32–35 | Object Calisthenics, Refactoring (Fowler), GoF Design Patterns, SOLID | -| `refactoring-empirical.md` | 36–41 | QDIR smell prioritization, smells + architectural refactoring, SPIRIT tool, bad OOP engineering properties, CWC complexity metric, metric threshold unreliability | -| `architecture.md` | 42 | Hexagonal Architecture — ports and adapters | -| `ai-agents.md` | 21–27 | Minimal-scope agent design, context isolation, on-demand skills, instruction conflict resolution failure, positional attention degradation, modular prompt de-duplication, three-file separation | -| `documentation.md` | 59–62 | Developer information needs, docs-as-code, Diátaxis documentation framework, blameless post-mortems | diff --git a/docs/scientific-research/ai-agents.md b/docs/scientific-research/ai-agents.md deleted file mode 100644 index 0960b13..0000000 --- a/docs/scientific-research/ai-agents.md +++ /dev/null @@ -1,118 +0,0 @@ -# Scientific Research — AI Agent Design - -Foundations for the agent architecture, file structure, and context management decisions in this template. - ---- - -### 21. Minimal-Scope Agent Design - -| | | -|---|---| -| **Source** | OpenAI. (2024). *Agent definitions*. OpenAI Agents SDK Documentation. https://platform.openai.com/docs/guides/agents/define-agents | -| **Date** | 2024 | -| **Alternative** | Anthropic. (2024). *Building effective agents*. Anthropic Engineering Blog. https://www.anthropic.com/engineering/building-effective-agents | -| **Status** | Confirmed — corrects the belief that subagents should be "lean routing agents" | -| **Core finding** | "Define the smallest agent that can own a clear task. Add more agents only when you need separate ownership, different instructions, different tool surfaces, or different approval policies." The split criterion is ownership boundary, not instruction volume. | -| **Mechanism** | Multiple agents competing to own the same concern create authority conflicts and inconsistent tool access. The right unit is the smallest coherent domain that requires exclusive responsibility. | -| **Where used** | Agent design in `.opencode/agents/*.md` — 4 agents, each owning a distinct domain (PO, developer, reviewer, setup). | - ---- - -### 22. Context Isolation via Subagents - -| | | -|---|---| -| **Source** | Anthropic. (2025). *Best practices for Claude Code*. Anthropic Documentation. https://www.anthropic.com/engineering/claude-code-best-practices | -| **Date** | 2025 | -| **Status** | Confirmed — the primary reason subagents exist is context isolation, not routing | -| **Core finding** | Subagents run in their own context windows and report back summaries, keeping the main conversation clean for implementation. Every file read in a subagent burns tokens in a child window, not the primary window. | -| **Mechanism** | Context window is the primary performance constraint for LLM agents. Investigation tasks rapidly exhaust context if done inline. Delegating to a subagent quarantines that cost; the primary agent receives only the distilled result. A fresh context in the subagent also prevents anchoring bias from prior conversation state. | -| **Where used** | OpenCode `task` tool usage in all agents; `explore` and `general` built-in subagents. | - ---- - -### 23. On-Demand Skill Loading (Context Budget) - -| | | -|---|---| -| **Source** | Anthropic. (2025). *Best practices for Claude Code*. Anthropic Documentation. https://www.anthropic.com/engineering/claude-code-best-practices | -| **Date** | 2025 | -| **Alternative** | OpenCode. (2026). *Agent Skills*. OpenCode Documentation. https://opencode.ai/docs/skills/ | -| **Status** | Confirmed (vendor guidance) — benefit on task completion quality extrapolated from RAG retrieval literature | -| **Core finding** | "CLAUDE.md is loaded every session, so only include things that apply broadly. For domain knowledge or workflows only relevant sometimes, use skills instead. Claude loads them on demand without bloating every conversation." Bloated always-loaded files cause Claude to ignore critical instructions. | -| **Mechanism** | Every token in an unconditionally-loaded file competes for attention against the task prompt. Long always-loaded files push important instructions beyond effective attention range, causing silent non-compliance. Skills are injected only when the task calls for them, preserving the primary context budget. | -| **Where used** | `AGENTS.md` carries only shared project conventions and commands; all step-specific workflows live in `.opencode/skills/*.md` and are loaded via the `skill` tool only when the relevant step begins. | - ---- - -### 24. Instruction Conflict Resolution Failure in LLMs - -| | | -|---|---| -| **Source** | Geng et al. (2025). Control Illusion: The Failure of Instruction Hierarchies in Large Language Models. AAAI-26. arXiv:2502.15851. https://arxiv.org/abs/2502.15851 | -| **Date** | 2025 | -| **Alternative** | Wallace et al. (2024). The Instruction Hierarchy: Training LLMs to Prioritize Privileged Instructions. arXiv:2404.13208. | -| **Status** | Confirmed — peer-reviewed (AAAI-26), replicated across 6 models | -| **Core finding** | LLMs do not reliably prioritize system-prompt instructions over conflicting instructions from other sources. Resolution is inconsistent and biased by pretraining-derived priors, not by prompt structure or position. | -| **Mechanism** | No structural separation between instruction sources enforces reliable priority at inference time. When the same directive appears in two locations with divergent content, the model selects between them based on statistical priors from pretraining. | -| **Where used** | Justifies single source of truth in `AGENTS.md`: workflow details duplicated across agent files and skills that drift out of sync produce conflicting instructions the model cannot resolve reliably. | - ---- - -### 25. Positional Attention Degradation in Long Contexts - -| | | -|---|---| -| **Source** | Liu et al. (2023). Lost in the Middle: How Language Models Use Long Contexts. *Transactions of the Association for Computational Linguistics*. arXiv:2307.03172. https://arxiv.org/abs/2307.03172 | -| **Date** | 2023 | -| **Alternative** | McKinnon (2025). arXiv:2511.05850 — effect attenuated for simple retrieval in Gemini 2.5+; persists for multi-hop reasoning. | -| **Status** | Confirmed with caveat — robust for multi-hop reasoning; attenuated for simple retrieval in frontier models (2025–2026) | -| **Core finding** | Performance on tasks requiring retrieval from long contexts follows a U-shaped curve: highest when relevant content is at the beginning or end of the context, degraded when content falls in the middle. | -| **Mechanism** | Transformer attention is not uniform across token positions. Content placed in the middle of a long context receives less attention weight regardless of its relevance. | -| **Where used** | Supports keeping always-loaded files lean. Duplicated workflow detail in always-loaded files increases total context length, pushing other content into lower-attention positions. | - ---- - -### 26. Modular Prompt De-duplication Reduces Interference - -| | | -|---|---| -| **Source** | Sharma & Henley (2026). Modular Prompt Optimization. arXiv:2601.04055. https://arxiv.org/abs/2601.04055 | -| **Date** | 2026 | -| **Status** | Partially confirmed — single-agent reasoning benchmarks only; not tested on multi-file agent architectures | -| **Core finding** | Structured prompts with explicit section de-duplication outperform both monolithic prompts and unstructured modular prompts. The mechanism cited is "reducing redundancy and interference between components." | -| **Mechanism** | Redundant content across prompt sections creates competing attention targets. De-duplication concentrates relevant signal in one canonical location per concern. | -| **Where used** | Supports the rule that skills and agent routing files contain no duplication of `AGENTS.md` content or of each other. | - ---- - -### 27. Agent File Architecture — Three-File Separation - -| | | -|---|---| -| **Source** | Convergence of entries 23, 24, 25, 26. | -| **Date** | — | -| **Status** | Inferred — no direct A/B test of this architecture exists; supported by convergence of confirmed and partially confirmed findings above | -| **Core finding** | Three distinct failure modes (instruction conflict on drift, positional attention degradation, redundancy interference) converge to produce a three-file split with defined content rules for each. | -| **Mechanism** | Each file runs at a different time and serves a different purpose. Mixing concerns across files reintroduces the failure modes the split is designed to prevent. | -| **Where used** | Structural rule for `AGENTS.md`, `.opencode/agents/*.md`, and `.opencode/skills/*.md`. | - -| File | Runs when | Contains | Does NOT contain | -|---|---|---|---| -| `AGENTS.md` | Every session, always loaded | Project conventions, shared commands, formats, standards | Step procedures, role-specific rules, path specs | -| `.opencode/agents/*.md` | When that role is invoked | Role identity, step ownership, skill load instructions, tool permissions, escalation paths | Workflow details, principle lists, path specs, commit formats | -| `.opencode/skills/*.md` | On demand, when that step begins | Full procedural instructions for that step, self-contained | Duplication of `AGENTS.md` content or other skills | - ---- - -## Bibliography - -1. Anthropic. (2024). Building effective agents. https://www.anthropic.com/engineering/building-effective-agents -2. Anthropic. (2025). Best practices for Claude Code. https://www.anthropic.com/engineering/claude-code-best-practices -3. Geng et al. (2025). Control Illusion. AAAI-26. arXiv:2502.15851. https://arxiv.org/abs/2502.15851 -4. Liu, N. F. et al. (2023). Lost in the Middle. *TACL*. arXiv:2307.03172. https://arxiv.org/abs/2307.03172 -5. McKinnon, R. (2025). arXiv:2511.05850. https://arxiv.org/abs/2511.05850 -6. OpenAI. (2024). Agent definitions. https://platform.openai.com/docs/guides/agents/define-agents -7. OpenCode. (2026). Agent Skills. https://opencode.ai/docs/skills/ -8. Sharma, A., & Henley, A. (2026). Modular Prompt Optimization. arXiv:2601.04055. https://arxiv.org/abs/2601.04055 -9. Wallace, E. et al. (2024). The Instruction Hierarchy. arXiv:2404.13208. diff --git a/docs/scientific-research/architecture.md b/docs/scientific-research/architecture.md deleted file mode 100644 index 5b5bb5f..0000000 --- a/docs/scientific-research/architecture.md +++ /dev/null @@ -1,24 +0,0 @@ -# Scientific Research — Architecture - -Foundations for the architectural decisions and patterns used in this template. - ---- - -### 42. Hexagonal Architecture — Ports and Adapters - -| | | -|---|---| -| **Source** | Cockburn, A. (2005). "Hexagonal Architecture." *alistair.cockburn.us*. https://alistair.cockburn.us/hexagonal-architecture/ | -| **Date** | 2005 | -| **Alternative** | Freeman, S., & Pryce, N. (2009). *Growing Object-Oriented Software, Guided by Tests*. Addison-Wesley. (Chapter 7: "Ports and Adapters") | -| **Status** | Confirmed — foundational; widely adopted as Clean Architecture, Onion Architecture | -| **Core finding** | The application domain should have no knowledge of external systems (databases, filesystems, network, UI). All contact between the domain and the outside world passes through a **port** (an interface / Protocol) and an **adapter** (a concrete implementation of that port). The domain is independently testable without any infrastructure. The key structural rule: dependency arrows point inward — domain code never imports from adapters; adapters import from domain. | -| **Mechanism** | Two distinct sides of any application: the "driving side" (actors who initiate action — tests, UI, CLI) and the "driven side" (actors the application drives — databases, filesystems, external services). Each driven-side dependency is hidden behind a port. Tests supply a test adapter; production supplies a real adapter. Substituting adapters requires no domain code changes. This is SOLID-D at the architectural layer. | -| **Where used** | Step 2 (Architecture): if an external dependency is identified during domain analysis, assign it a Protocol. `ports/` and `adapters/` folders emerge when a concrete dependency is confirmed — do not pre-create them. The dependency-inversion principle (SOLID-D) is the goal; the folder names are convention, not law. | - ---- - -## Bibliography - -1. Cockburn, A. (2005). Hexagonal Architecture. *alistair.cockburn.us*. https://alistair.cockburn.us/hexagonal-architecture/ -2. Freeman, S., & Pryce, N. (2009). *Growing Object-Oriented Software, Guided by Tests*. Addison-Wesley. diff --git a/docs/scientific-research/cognitive-science.md b/docs/scientific-research/cognitive-science.md deleted file mode 100644 index fa2b1b8..0000000 --- a/docs/scientific-research/cognitive-science.md +++ /dev/null @@ -1,150 +0,0 @@ -# Scientific Research — Cognitive Science - -Mechanisms from cognitive and social psychology that justify workflow design decisions in this template. - ---- - -### 1. Pre-mortem (Prospective Hindsight) - -| | | -|---|---| -| **Source** | Klein, G. (1998). *Sources of Power: How People Make Decisions*. MIT Press. | -| **Date** | 1998 | -| **Status** | Confirmed | -| **Core finding** | Asking "imagine this failed — why?" catches 30% more issues than forward-looking review. | -| **Mechanism** | Prospective hindsight shifts from prediction (weak) to explanation (strong). The brain is better at explaining past events than predicting future ones. By framing as "it already failed," you activate explanation mode. | -| **Where used** | PO pre-mortem at scope, developer pre-mortem before handoff. | - ---- - -### 2. Implementation Intentions - -| | | -|---|---| -| **Source** | Gollwitzer, P. M. (1999). Implementation intentions: Strong effects of simple planning aids. *American Journal of Preventive Medicine*, 16(4), 257–276. | -| **Date** | 1999 | -| **Status** | Confirmed | -| **Core finding** | "If X then Y" plans are 2–3x more likely to execute than general intentions. | -| **Mechanism** | If-then plans create automatic cue-response links in memory. The brain processes "if function > 20 lines then extract helper" as an action trigger, not a suggestion to consider. | -| **Where used** | Refactor Self-Check Gates in `implementation/SKILL.md`, Code Quality checks in `verify/SKILL.md`. | - ---- - -### 3. Commitment Devices - -| | | -|---|---| -| **Source** | Cialdini, R. B. (2001). *Influence: The Psychology of Persuasion* (rev. ed.). HarperBusiness. | -| **Date** | 2001 | -| **Status** | Confirmed | -| **Core finding** | Forcing an explicit micro-commitment (filling in a PASS/FAIL cell) creates resistance to reversals. A checkbox checked is harder to uncheck than a todo noted. | -| **Mechanism** | Structured tables with PASS/FAIL cells create commitment-device effects. The act of marking "FAIL" requires justification, making silent passes psychologically costly. | -| **Where used** | SOLID enforcement table, ObjCal enforcement table, Design Patterns table — all require explicit PASS/FAIL with evidence. | - ---- - -### 4. System 2 Before System 1 - -| | | -|---|---| -| **Source** | Kahneman, D. (2011). *Thinking, Fast and Slow*. Farrar, Straus and Giroux. | -| **Date** | 2011 | -| **Status** | Confirmed | -| **Core finding** | System 1 (fast, automatic) is vulnerable to anchoring and confirmation bias. System 2 (slow, deliberate) must be activated before System 1's judgments anchor. | -| **Mechanism** | Running semantic review *before* automated commands prevents the "all green" dopamine hit from anchoring the reviewer's judgment. Doing hard cognitive work first protects against System 1 shortcuts. | -| **Where used** | Verification order in `verify/SKILL.md`: semantic alignment check before commands. | - ---- - -### 5. Adversarial Collaboration - -| | | -|---|---| -| **Source** | Mellers, B. A., Hertwig, R., & Kahneman, D. (2001). Do frequency representations eliminate cooperative bias? *Psychological Review*, 108(4), 709–735. | -| **Date** | 2001 | -| **Status** | Confirmed | -| **Core finding** | Highest-quality thinking emerges when parties hold different hypotheses and are charged with finding flaws in each other's reasoning. | -| **Mechanism** | Explicitly framing the reviewer as "your job is to break this feature" activates the adversarial collaboration mode. The reviewer seeks disconfirmation rather than confirmation. | -| **Where used** | Adversarial mandate in `reviewer.md` and `verify/SKILL.md`. | - ---- - -### 6. Accountability to Unknown Audience - -| | | -|---|---| -| **Source** | Tetlock, P. E. (1983). Accountability: A social determinant of judgment. In *Psychology of Learning and Motivation* (Vol. 17, pp. 295–332). Academic Press. | -| **Date** | 1983 | -| **Status** | Confirmed | -| **Core finding** | Accountability to an unknown audience with unknown views improves reasoning quality. The agent anticipates being audited and adjusts reasoning. | -| **Mechanism** | The explicit report format (APPROVED/REJECTED with evidence) creates an accountability structure — the reviewer's reasoning will be read by the PO. | -| **Where used** | Report format in `verify/SKILL.md`, structured evidence columns in all enforcement tables. | - ---- - -### 7. Chunking and Cognitive Load Reduction - -| | | -|---|---| -| **Source** | Miller, G. A. (1956). The magical number seven, plus or minus two. *Psychological Review*, 63(2), 81–97. | -| **Date** | 1956 | -| **Alternative** | Sweller, J. (1988). Cognitive load during problem solving. *Cognitive Science*, 12(2), 257–285. | -| **Status** | Confirmed | -| **Core finding** | Structured tables reduce working memory load vs. narrative text. Chunking related items into table rows enables parallel processing. | -| **Mechanism** | Replacing prose checklists with structured tables (rows × columns) allows the reviewer to process all items in a single pass. | -| **Where used** | All enforcement tables in `verify/SKILL.md` and `reviewer.md`. | - ---- - -### 8. Elaborative Encoding - -| | | -|---|---| -| **Source** | Craik, F. I. M., & Lockhart, R. S. (1972). Levels of processing: A framework for memory research. *Journal of Verbal Learning and Verbal Behavior*, 11(6), 671–684. | -| **Date** | 1972 | -| **Status** | Confirmed | -| **Core finding** | Deeper processing — explaining *why* a rule matters — leads to better retention and application than shallow processing. | -| **Mechanism** | Adding a "Why it matters" column to enforcement tables forces the reviewer to process the rationale, not just scan the rule name. | -| **Where used** | SOLID table, ObjCal table, Design Patterns table — all have "Why it matters" column. | - ---- - -### 9. Error-Specific Feedback - -| | | -|---|---| -| **Source** | Hattie, J., & Timperley, H. (2007). The power of feedback. *Review of Educational Research*, 77(1), 81–112. | -| **Date** | 2007 | -| **Status** | Confirmed | -| **Core finding** | Feedback is most effective when it tells the agent exactly what went wrong and what the correct action is. "FAIL: function > 20 lines at file:47" is actionable; "Apply function length rules" is not. | -| **Mechanism** | The evidence column in enforcement tables requires specific file:line references, turning vague rules into actionable directives. | -| **Where used** | Evidence column in all enforcement tables. | - ---- - -### 10. Prospective Memory Cues - -| | | -|---|---| -| **Source** | McDaniel, M. A., & Einstein, G. O. (2000). Strategic and automatic processes in prospective memory retrieval. *Applied Cognitive Psychology*, 14(7), S127–S144. | -| **Date** | 2000 | -| **Status** | Confirmed | -| **Core finding** | Memory for intended actions is better when cues are embedded at the point of action, not in a separate appendix. | -| **Mechanism** | Placing if-then gates inline (in the REFACTOR section) rather than in a separate "reference" document increases adherence. The cue appears exactly when the developer is about to make the relevant decision. | -| **Where used** | Refactor Self-Check Gates embedded inline in `refactor/SKILL.md`. | - ---- - -## Bibliography - -1. Cialdini, R. B. (2001). *Influence: The Psychology of Persuasion* (rev. ed.). HarperBusiness. -2. Craik, F. I. M., & Lockhart, R. S. (1972). Levels of processing: A framework for memory research. *Journal of Verbal Learning and Verbal Behavior*, 11(6), 671–684. -3. Gollwitzer, P. M. (1999). Implementation intentions. *American Journal of Preventive Medicine*, 16(4), 257–276. -4. Hattie, J., & Timperley, H. (2007). The power of feedback. *Review of Educational Research*, 77(1), 81–112. -5. Kahneman, D. (2011). *Thinking, Fast and Slow*. Farrar, Straus and Giroux. -6. Klein, G. (1998). *Sources of Power: How People Make Decisions*. MIT Press. -7. McDaniel, M. A., & Einstein, G. O. (2000). Strategic and automatic processes in prospective memory retrieval. *Applied Cognitive Psychology*, 14(7), S127–S144. -8. Mellers, B. A., Hertwig, R., & Kahneman, D. (2001). Do frequency representations eliminate cooperative bias? *Psychological Review*, 108(4), 709–735. -9. Miller, G. A. (1956). The magical number seven, plus or minus two. *Psychological Review*, 63(2), 81–97. -10. Sweller, J. (1988). Cognitive load during problem solving. *Cognitive Science*, 12(2), 257–285. -11. Tetlock, P. E. (1983). Accountability: A social determinant of judgment. In *Psychology of Learning and Motivation* (Vol. 17). Academic Press. diff --git a/docs/scientific-research/documentation.md b/docs/scientific-research/documentation.md deleted file mode 100644 index 9c77a00..0000000 --- a/docs/scientific-research/documentation.md +++ /dev/null @@ -1,69 +0,0 @@ -# Scientific Research — Documentation - -Foundations for living documentation, docs-as-code, information architecture, and post-mortem practices used in this template. - ---- - -### 59. Information Needs in Collocated Software Development Teams - -| | | -|---|---| -| **Source** | Ko, A. J., DeLine, R., & Venolia, G. (2007). "Information Needs in Collocated Software Development Teams." *Proc. 29th International Conference on Software Engineering (ICSE 2007)*, pp. 344–353. IEEE. https://doi.org/10.1109/ICSE.2007.45 | -| **Date** | 2007 | -| **Alternative** | Dagenais, B., & Robillard, M. P. (2010). "Creating and evolving developer documentation." *Proc. FSE 2010*, pp. 127–136. ACM. | -| **Status** | Confirmed — empirical study; 600+ citations | -| **Core finding** | Developers spend 35–50% of their working time not writing code but searching for information — navigating code, reading past decisions, and understanding relationships between components. The most frequently sought information is: who wrote this, why was it written this way, and what does this module depend on. Direct questioning of teammates is the most common fallback when documentation is absent, creating serial bottlenecks. | -| **Mechanism** | Information seeking is triggered by a task, not by curiosity. A developer encountering an unfamiliar component has a specific decision to make. When documentation is absent, the seek-ask-wait loop (find the right person, ask, wait for a response) dominates time. Persistent documentation (ADRs, architecture diagrams, glossary) short-circuits this loop by making the answer findable without a human intermediary. | -| **Where used** | Justifies the full `living-docs` skill: C4 diagrams answer "what does this module depend on?"; the ADR record answers "why was it written this way?"; the living glossary answers "what does this term mean in this context?". Collectively these eliminate the three most frequent information needs identified by Ko et al. | - ---- - -### 60. Software Engineering at Google — Documentation Chapter - -| | | -|---|---| -| **Source** | Winters, T., Manshreck, T., & Wright, H. (2020). *Software Engineering at Google: Lessons Learned from Programming Over Time*. O'Reilly. Chapter 10: "Documentation." https://abseil.io/resources/swe-book/html/ch10.html | -| **Date** | 2020 | -| **Alternative** | Fitzpatrick, B., & Collins-Sussman, B. (2012). *Team Geek*. O'Reilly. | -| **Status** | Confirmed — large-scale industry evidence from a codebase with ~2 billion lines of code | -| **Core finding** | Documentation that lives outside the code repository decays at a rate proportional to how often the code changes — because there is no mechanism that forces the doc to be updated when the code changes. Docs-as-code (documentation in the same repo, reviewed in the same PRs, tested in the same CI pipeline) dramatically reduces divergence because the cost of updating the doc is incurred at the same moment as the cost of the code change. | -| **Mechanism** | Google's g3doc system co-locates docs with the code they describe. When a PR changes `payments/service.py`, the reviewer also sees `payments/README.md` in the diff and can flag staleness immediately. At scale, Google found that docs with no co-located tests or CI checks become stale within 3–6 months regardless of team discipline. | -| **Where used** | Justifies co-locating `docs/` within the project repository. Living docs (`docs/architecture/c4/`, `docs/glossary.md`) are updated in the same commits as the code they describe. The `living-docs` skill is the mechanism that enforces this — it runs after Step 5 to regenerate diagrams from the current state of the codebase and discovery docs. | - ---- - -### 61. Diátaxis — A Systematic Framework for Technical Documentation - -| | | -|---|---| -| **Source** | Procida, D. (2021). "Diátaxis — A systematic approach to technical documentation." *diataxis.fr*. https://diataxis.fr | -| **Date** | 2021 | -| **Status** | Confirmed — adopted by Django, NumPy, Gatsby, Cloudflare, and the Python Software Foundation | -| **Core finding** | Technical documentation fails because it conflates four fundamentally different needs into a single undifferentiated text. The four types are: **Tutorials** (learning-oriented; guides a beginner through a complete task), **How-to guides** (task-oriented; solves a specific problem for a practitioner), **Reference** (information-oriented; describes the system accurately and completely), **Explanation** (understanding-oriented; discusses concepts and decisions). Each type has a different audience mental state and requires a different writing mode. Mixing them degrades all four. | -| **Mechanism** | The two axes of Diátaxis are: **practical ↔ theoretical** (tutorials and how-to guides are practical; reference and explanation are theoretical) and **acquiring ↔ applying** (tutorials and explanation are for acquiring knowledge; how-to guides and reference are for applying it). A document that tries to be both a tutorial and a reference simultaneously will be a poor tutorial (too much information) and a poor reference (not structured for lookup). | -| **Where used** | Documentation structure in this template maps to Diátaxis: `README.md` = tutorial (getting started), `AGENTS.md` = reference (complete description of roles, skills, commands) and explanation (why the workflow exists), `docs/c4/` = reference (system structure), post-mortems = explanation (why decisions were made). The `living-docs` skill produces reference-type documentation (C4 diagrams, glossary) — not tutorials. | - ---- - -### 62. Blameless Post-Mortems and a Just Culture - -| | | -|---|---| -| **Source** | Allspaw, J. (2012). "Blameless PostMortems and a Just Culture." *code.etsy.com* (archived). https://www.etsy.com/codeascraft/blameless-postmortems/ | -| **Date** | 2012 | -| **Alternative** | Dekker, S. (2006). *The Field Guide to Understanding Human Error*. Ashgate. | -| **Status** | Confirmed — foundational DevOps/SRE practice; referenced in Google SRE Book (2016) | -| **Core finding** | Post-mortems that assign blame produce less information and lower long-term system reliability than blameless post-mortems. When individuals believe they will be blamed, they withhold information about contributing factors, preventing the systemic causes from being identified and fixed. A blameless post-mortem treats the incident as a system failure, not an individual failure — asking "what conditions allowed this to happen?" not "who caused this?" | -| **Mechanism** | Allspaw's model separates two questions: (1) what happened? (factual, blameless) and (2) what changes would prevent recurrence? (systemic). The post-mortem document records both. The output is not an individual's performance review but a list of system changes — process improvements, documentation gaps, tooling additions. Etsy's incident rate fell after adopting blameless post-mortems because engineers began reporting near-misses that they previously concealed. | -| **Where used** | `docs/post-mortem/` directory. Post-mortems in this template follow the blameless model: they report workflow gaps found, not who made the mistake. The output of each post-mortem is a list of improvements to skills, agents, or workflow documentation. The `living-docs` skill is one such improvement — it emerged from the discovery that architecture and glossary documentation were falling behind the codebase. | - ---- - -## Bibliography - -1. Allspaw, J. (2012). Blameless PostMortems and a Just Culture. *code.etsy.com*. https://www.etsy.com/codeascraft/blameless-postmortems/ -2. Dagenais, B., & Robillard, M. P. (2010). Creating and evolving developer documentation. *Proc. FSE 2010*, pp. 127–136. ACM. -3. Dekker, S. (2006). *The Field Guide to Understanding Human Error*. Ashgate. -4. Ko, A. J., DeLine, R., & Venolia, G. (2007). Information Needs in Collocated Software Development Teams. *Proc. ICSE 2007*, pp. 344–353. https://doi.org/10.1109/ICSE.2007.45 -5. Procida, D. (2021). Diátaxis — A systematic approach to technical documentation. *diataxis.fr*. https://diataxis.fr -6. Winters, T., Manshreck, T., & Wright, H. (2020). *Software Engineering at Google*. O'Reilly. Chapter 10. https://abseil.io/resources/swe-book/html/ch10.html diff --git a/docs/scientific-research/domain-modeling.md b/docs/scientific-research/domain-modeling.md deleted file mode 100644 index d49be2e..0000000 --- a/docs/scientific-research/domain-modeling.md +++ /dev/null @@ -1,24 +0,0 @@ -# Scientific Research — Domain Modeling - -Foundations for bounded context identification, ubiquitous language, and feature decomposition used in this template. - ---- - -### 31. Domain-Driven Design — Bounded Contexts and Feature Identification - -| | | -|---|---| -| **Source** | Evans, E. (2003). *Domain-Driven Design: Tackling Complexity in the Heart of Software*. Addison-Wesley. | -| **Date** | 2003 | -| **Alternative** | Context Mapper (2025). Rapid Object-Oriented Analysis and Design. https://contextmapper.org/docs/rapid-ooad | -| **Status** | Confirmed — foundational DDD literature | -| **Core finding** | A Bounded Context is a boundary within which a particular ubiquitous language is consistent. Features are identified by grouping related user stories that share the same language. The decomposition criterion is "single responsibility per context" + "consistency of language." | -| **Mechanism** | In DDD: (1) Extract ubiquitous language from requirements → (2) Group by language consistency → (3) Each group is a candidate bounded context → (4) Each bounded context maps to a feature. Context Mapper automates this: User Stories → Subdomains (via noun/verb extraction) → Bounded Contexts of type FEATURE. | -| **Where used** | Phase 1: after feature list identification, verify each feature has consistent language. Phase 2: noun/verb extraction from project discovery answers populates the Entities table — domain analysis cannot begin before this. The "Rules (Business)" section captures the ubiquitous language rules that govern each feature. | - ---- - -## Bibliography - -1. Context Mapper. (2025). Rapid Object-Oriented Analysis and Design. https://contextmapper.org/docs/rapid-ooad -2. Evans, E. (2003). *Domain-Driven Design: Tackling Complexity in the Heart of Software*. Addison-Wesley. diff --git a/docs/scientific-research/oop-design.md b/docs/scientific-research/oop-design.md deleted file mode 100644 index 4b0637d..0000000 --- a/docs/scientific-research/oop-design.md +++ /dev/null @@ -1,64 +0,0 @@ -# Scientific Research — OOP Design - -Foundations for object-oriented design principles used in this template. - ---- - -### 32. Object Calisthenics — Nine Rules - -| | | -|---|---| -| **Source** | Bay, J. "Object Calisthenics." *The Thoughtworks Anthology* (PragProg, 2008). Original in IEEE Software/DevX, ~2005. https://www.bennadel.com/resources/uploads/2012/objectcalisthenics.pdf | -| **Date** | ~2005 | -| **Status** | Practitioner synthesis | -| **Core finding** | 9 rules to internalize OOP: (1) One level indentation per method, (2) No ELSE, (3) Wrap primitives/Strings, (4) First class collections, (5) One dot per line, (6) No abbreviations, (7) Classes ≤50 lines, (8) ≤2 instance variables, (9) No getters/setters. 7 of 9 enforce data encapsulation; 1 drives polymorphism; 1 drives naming. | -| **Mechanism** | Restrictions force decomposition. When you cannot use getters, behavior must move into the object. When you cannot use ELSE, you use polymorphism. When classes must be ≤2 ivars, you discover missing abstractions. | -| **Where used** | Refactor self-declaration checklist in `refactor/SKILL.md`. | - ---- - -### 33. Refactoring - -| | | -|---|---| -| **Source** | Fowler, M. (1999/2018). *Refactoring: Improving the Design of Existing Code* (2nd ed.). Addison-Wesley. https://martinfowler.com/books/refactoring.html | -| **Date** | 1999, 2018 | -| **Status** | Confirmed — foundational | -| **Core finding** | Refactoring = behavior-preserving transformations. 68 catalogued refactorings, each small enough to do safely but cumulative effect significant. Code smells (duplicate code, long methods, feature envy) indicate refactoring opportunities. | -| **Mechanism** | Small steps reduce risk. Each refactoring is reversible. Test suite validates behavior unchanged. | -| **Where used** | `refactor/SKILL.md`: smell detection triggers refactoring; full protocol and catalogue entries. | - ---- - -### 34. Design Patterns - -| | | -|---|---| -| **Source** | Gamma, E., Helm, R., Johnson, R., Vlissides, J. (1995). *Design Patterns: Elements of Reusable Object-Oriented Software*. Addison-Wesley. | -| **Date** | 1995 | -| **Status** | Confirmed — foundational | -| **Core finding** | 23 patterns catalogued in 3 categories: Creational (5), Structural (7), Behavioral (11). Key principles: "Favor composition over inheritance," "Program to an interface, not an implementation." | -| **Mechanism** | Patterns are recurring solutions to common problems. Named and catalogued so developers don't rediscover them. | -| **Where used** | `design-patterns/SKILL.md`: full GoF catalogue with smell-triggered Python before/after examples. | - ---- - -### 35. SOLID Principles - -| | | -|---|---| -| **Source** | Martin, R. C. (2000). "Principles of OOD." *ButUncleBob.com*. Acronym coined by Michael Feathers (2004). https://blog.interface-solv.com/wp-content/uploads/2020/07/Principles-Of-OOD.pdf | -| **Date** | 2000 | -| **Status** | Confirmed | -| **Core finding** | S: One reason to change. O: Open extension, closed modification. L: Subtypes substitutable. I: No forced stub methods. D: Depend on abstractions, not concretes. | -| **Mechanism** | Each principle targets a specific coupling failure mode. Together they produce low coupling, high cohesion. | -| **Where used** | Refactor self-declaration checklist in `refactor/SKILL.md`: 5-row SOLID table with Python before/after examples. | - ---- - -## Bibliography - -1. Bay, J. (~2005). "Object Calisthenics." *IEEE Software/DevX*. https://www.bennadel.com/resources/uploads/2012/objectcalisthenics.pdf -2. Fowler, M. (1999/2018). *Refactoring: Improving the Design of Existing Code* (2nd ed.). Addison-Wesley. https://martinfowler.com/books/refactoring.html -3. Gamma, E., Helm, R., Johnson, R., & Vlissides, J. (1995). *Design Patterns: Elements of Reusable Object-Oriented Software*. Addison-Wesley. -4. Martin, R. C. (2000). "Principles of OOD." *ButUncleBob.com*. https://blog.interface-solv.com/wp-content/uploads/2020/07/Principles-Of-OOD.pdf diff --git a/docs/scientific-research/refactoring-empirical.md b/docs/scientific-research/refactoring-empirical.md deleted file mode 100644 index 61d666c..0000000 --- a/docs/scientific-research/refactoring-empirical.md +++ /dev/null @@ -1,100 +0,0 @@ -# Scientific Research — Refactoring (Empirical) - -Empirical studies on code smells, refactoring prioritization, and OOP complexity used in this template. - ---- - -### 36. QDIR — Bad-Smells + OO Metrics Prioritization - -| | | -|---|---| -| **Source** | Malhotra, R., Singh, P. (2020). "Exploiting bad-smells and object-oriented characteristics to prioritize classes for refactoring." *Int. J. Syst. Assur. Eng. Manag.* 11(Suppl 2), 133–144. Springer. | -| **Date** | 2020 | -| **URL** | https://doi.org/10.1007/s13198-020-01001-x | -| **Status** | Confirmed — empirical | -| **Core finding** | QDIR (Quality Depreciation Index Rule) combines bad-smell severity with OO metrics (LOC, WMC, CBO, RFC, DIT) to prioritize classes for refactoring. Validated on 8 open-source Java systems. | -| **Mechanism** | Classes with high smell severity AND high OO metrics are prioritized. QDIR = weighted sum. | -| **Where used** | Refactor prioritization: when smell detected, check OO metrics to prioritize. | - ---- - -### 37. Smells + Architectural Refactoring - -| | | -|---|---| -| **Source** | Silva, C. et al. (2020). "When Are Smells Indicators of Architectural Refactoring Opportunities." *Proc. 28th Int. Conf. on Program Comprehension*. ACM. | -| **Date** | 2020 | -| **URL** | https://doi.org/10.1145/3387904.3389276 | -| **Status** | Confirmed — empirical | -| **Core finding** | Study of 50 projects, 52,667 refactored elements. 67.53% of smells co-occur. Smells that co-occur are indicators of architectural refactoring in 88.53% of cases. | -| **Mechanism** | Single smells are often code-level; co-occurring smells indicate architectural problems. Pattern catalog for smells → specific architectural refactorings. | -| **Where used** | Smell detection triggers architectural analysis when co-occurrence patterns detected. | - ---- - -### 38. SPIRIT Tool — Code Smell Prioritization - -| | | -|---|---| -| **Source** | Vidal, S. A., Marcos, C., Díaz-Pace, J. A. (2014). "An Approach to Prioritize Code Smells for Refactoring." *Automated Software Engineering*, 23(3), 501–532. | -| **Date** | 2014 | -| **URL** | https://doi.org/10.1007/s10515-014-0175-x | -| **Status** | Confirmed — tool | -| **Core finding** | SPIRIT (Smart Identification of Refactoring opportunITies) prioritizes smells by 3 criteria: (1) component stability, (2) impact on modifiability scenarios, (3) smell relevance. Top-ranked smells correlate with expert developer judgment. | -| **Mechanism** | Semi-automated ranking. Combines version history (stable vs. unstable), impact analysis, and smell type. | -| **Where used** | Refactor prioritization: stability = has the class changed recently? Unstable + smelly = prioritize. | - ---- - -### 39. Bad Engineering Properties of OOP - -| | | -|---|---| -| **Source** | Cardelli, L. (1996). "Bad Engineering Properties of Object-Oriented Languages." *ACM Computing Surveys*, 28(4), 150. | -| **Date** | 1996 | -| **URL** | https://www.microsoft.com/en-us/research/publication/bad-engineering-properties-of-object-oriented-languages/ | -| **Status** | Confirmed — foundational critique | -| **Core finding** | OOP has 5 "economy" problems: (1) Execution (virtual methods prevent inlining), (2) Compilation (no code/interface separation), (3) Small-scale dev (expressive type systems missing), (4) Large-scale dev (poor class extension/modification), (5) Language features (baroque complexity). | -| **Mechanism** | OOP is not universally superior. Trade-offs exist. Knowing these helps avoid over-engineering. | -| **Where used** | Anti-pre-pattern: know when OOP adds complexity vs. value. | - ---- - -### 40. Code Complexity Model of OOP - -| | | -|---|---| -| **Source** | Aluthwaththage, J. H., Thathsarani, H. A. N. N. (2024). "A Novel OO-Based Code Complexity Metric." *Proc. Future Technologies Conference (FTC)*, 616–628. Springer/IEEE. | -| **Date** | 2024 | -| **URL** | https://link.springer.com/chapter/10.1007/978-3-031-73125-9_39 | -| **Alternative** | Misra et al. (2024). "A Suite of Object Oriented Cognitive Complexity Metrics." IEEE. | -| **Status** | Partially confirmed — recent | -| **Core finding** | CWC (Combined Weighted Complexity) measures OOP complexity at statement level, considering 8 factors: nesting depth, control types, compound conditions, try-catch, threads, pointers, references, dynamic memory. Addresses gap in existing metrics ignoring cognitive load. | -| **Mechanism** | Granular complexity scoring. Higher scores indicate more cognitively demanding code. | -| **Where used** | Complexity measurement: when function > 20 lines, consider CWC-style granular scoring. | - ---- - -### 41. Metric Thresholds for Smell Detection - -| | | -|---|---| -| **Source** | Bigonha, M. A. S., et al. (2019). "The usefulness of software metric thresholds for detection of bad smells and fault prediction." *Information and Software Technology*, 115, 79–92. | -| **Date** | 2019 | -| **URL** | https://doi.org/10.1016/j.infsof.2019.08.005 | -| **Alternative** | Catal et al. (2018). "Software metrics thresholds calculation techniques." *Info. Softw. Technol.* | -| **Status** | Confirmed | -| **Core finding** | Metric thresholds (e.g., LOC > 600) used for smell detection are unreliable. Study on 92 open-source systems found precision too low for practical use. Neither heuristic-based nor ML approaches achieve acceptable accuracy. | -| **Mechanism** | Fixed thresholds are context-dependent. Thresholds should be project-specific, not universal. | -| **Where used** | Anti-pre-pattern: do not rely on fixed thresholds. Use co-occurrence patterns (entry 37) instead. | - ---- - -## Bibliography - -1. Aluthwaththage, J. H., & Thathsarani, H. A. N. N. (2024). A Novel OO-Based Code Complexity Metric. *Proc. Future Technologies Conference (FTC)*, 616–628. https://link.springer.com/chapter/10.1007/978-3-031-73125-9_39 -2. Bigonha, M. A. S., et al. (2019). The usefulness of software metric thresholds. *Information and Software Technology*, 115, 79–92. https://doi.org/10.1016/j.infsof.2019.08.005 -3. Cardelli, L. (1996). Bad Engineering Properties of Object-Oriented Languages. *ACM Computing Surveys*, 28(4), 150. https://www.microsoft.com/en-us/research/publication/bad-engineering-properties-of-object-oriented-languages/ -4. Malhotra, R., & Singh, P. (2020). Exploiting bad-smells and OO characteristics. *Int. J. Syst. Assur. Eng. Manag.*, 11(Suppl 2), 133–144. https://doi.org/10.1007/s13198-020-01001-x -5. Silva, C. et al. (2020). When Are Smells Indicators of Architectural Refactoring Opportunities. *Proc. 28th ICPC*. https://doi.org/10.1145/3387904.3389276 -6. Vidal, S. A., Marcos, C., & Díaz-Pace, J. A. (2014). An Approach to Prioritize Code Smells. *Automated Software Engineering*, 23(3), 501–532. https://doi.org/10.1007/s10515-014-0175-x diff --git a/docs/scientific-research/requirements-elicitation.md b/docs/scientific-research/requirements-elicitation.md deleted file mode 100644 index ec5e68f..0000000 --- a/docs/scientific-research/requirements-elicitation.md +++ /dev/null @@ -1,246 +0,0 @@ -# Scientific Research — Requirements Elicitation - -Foundations for the PO interview structure, Gherkin criteria, and feature discovery in this template. - ---- - -### 17. INVEST Criteria for User Stories - -| | | -|---|---| -| **Source** | Wake, B. (2003). *INVEST in Good Stories, and SMART Tasks*. XP123.com. | -| **Date** | 2003 | -| **Alternative** | Cohn, M. (2004). *User Stories Applied: For Agile Software Development*. Addison-Wesley. | -| **Status** | Confirmed | -| **Core finding** | Stories that are Independent, Negotiable, Valuable, Estimable, Small, and Testable produce fewer downstream defects and smoother development cycles. | -| **Mechanism** | INVEST serves as a quality gate before stories enter development. "Testable" forces the PO to express observable outcomes (directly enabling Given/When/Then). "Small" forces decomposition. "Independent" prevents hidden ordering dependencies. | -| **Where used** | INVEST gate in Phase 3 of `scope/SKILL.md`. | - ---- - -### 18. Example Mapping (Rules Layer) - -| | | -|---|---| -| **Source** | Wynne, M. (2015). *Introducing Example Mapping*. Cucumber Blog. https://cucumber.io/blog/bdd/example-mapping-introduction/ | -| **Date** | 2015 | -| **Status** | Confirmed | -| **Core finding** | Inserting a "rules" layer between stories and examples prevents redundant or contradictory acceptance criteria. A story with many rules needs splitting; a story with many open questions is not ready for development. | -| **Mechanism** | Four card types: Story (yellow), Rules (blue), Examples (green), Questions (red). The rules layer groups related examples under the business rule they illustrate. Red cards (unanswered questions) are a first-class signal to stop and investigate. | -| **Where used** | `## Rules` section in per-feature `discovery.md` (Phase 2). PO identifies business rules before writing Examples in Phase 4. | - ---- - -### 19. Declarative Gherkin - -| | | -|---|---| -| **Source** | Cucumber Team. (2024). *Better Gherkin*. Cucumber Documentation. https://cucumber.io/docs/bdd/better-gherkin/ | -| **Date** | 2024 | -| **Status** | Confirmed | -| **Core finding** | Declarative Gherkin ("When Bob logs in") produces specifications that survive UI changes. Imperative Gherkin ("When I click the Login button") couples specs to implementation details and breaks on every UI redesign. | -| **Mechanism** | Declarative steps describe *what happens* at the business level. Imperative steps describe *how the user interacts with a specific UI*. AI agents are especially prone to writing imperative Gherkin because they mirror literal steps. | -| **Where used** | Declarative vs. imperative table in Phase 4 of `scope/SKILL.md`. | - ---- - -### 20. MoSCoW Prioritization (Within-Story Triage) - -| | | -|---|---| -| **Source** | Clegg, D., & Barker, R. (1994). *Case Method Fast-Track: A RAD Approach*. Addison-Wesley (DSDM origin). | -| **Date** | 1994 | -| **Status** | Confirmed | -| **Core finding** | Classifying requirements as Must/Should/Could/Won't forces explicit negotiation about what is essential vs. desired. When applied *within* a single story, it reveals bloated stories that should be split. | -| **Mechanism** | DSDM mandates that Musts cannot exceed 60% of total effort. At the story level: if a story has 12 Examples and only 3 are Musts, the remaining 9 can be deferred. This prevents gold-plating and keeps stories small. | -| **Where used** | MoSCoW triage in Phase 4 of `scope/SKILL.md`. | - ---- - -### 28. Active Listening — Paraphrase-Clarify-Summarize - -| | | -|---|---| -| **Source** | Rogers, C. R., & Farson, R. E. (1957). *Active Listening*. Industrial Relations Center, University of Chicago. | -| **Date** | 1957 | -| **Alternative** | McNaughton, D. et al. (2008). Learning to Listen. *Topics in Early Childhood Special Education*, 27(4), 223–231. | -| **Status** | Confirmed — foundational clinical research; widely replicated | -| **Core finding** | Active listening — paraphrasing what was heard in the listener's own words, asking clarifying questions, then summarizing the main points and intent — reduces misunderstanding, builds trust, and confirms mutual understanding before proceeding. | -| **Mechanism** | Paraphrasing forces the listener to reconstruct the speaker's meaning, surfacing gaps immediately. Clarifying questions address residual ambiguity. Summarizing creates a shared record that both parties can confirm or correct. | -| **Where used** | PO summarization protocol in `scope/SKILL.md`: after each interview round, PO produces a "Here is what I understood" block before proceeding. | - ---- - -### 28a. Active Listening — Three-Level Structure - -| | | -|---|---| -| **Source** | Synthesis of: Nielsen (2010); Farrell (2017); Ambler (2002); Wynne (2015). | -| **Date** | 2010–2015 | -| **Status** | Synthesized rule of thumb — each component individually confirmed | -| **Core finding** | Active listening in requirements interviews operates at three granularities: **Level 1** (per answer) — immediate paraphrase; **Level 2** (per topic cluster) — transition summary; **Level 3** (end of interview) — full synthesis serving four downstream purposes. | -| **Level 3 — four uses** | 1. Accuracy gate (NN/G). 2. Scope crystallization (Ambler/FDD). 3. Input to domain modeling (Ambler/FDD). 4. Baseline trigger (Wynne/Cucumber). | -| **Where used** | Phase 1 and Phase 2 of `scope/SKILL.md`. | - ---- - -### 29. The Kipling Method — Five Ws and One H - -| | | -|---|---| -| **Source** | Kipling, R. (1902). *Just So Stories*. Macmillan. | -| **Date** | 1902 | -| **Alternative** | Hermagoras of Temnos (2nd century BCE) — seven circumstances of rhetoric. | -| **Status** | Practitioner synthesis — journalism, business analysis, investigative methodology | -| **Core finding** | The six interrogative questions (Who, What, When, Where, Why, How) form a complete framework for gathering all essential facts about any situation. Together they ensure completeness and prevent gaps. | -| **Where used** | Phase 1 project discovery: the initial seven questions are an adaptation of the 5W1H framework. | - ---- - -### 30. BA Requirements Question Framework - -| | | -|---|---| -| **Source** | Brandenburg, L. (2025). *Requirements Discovery Checklist Pack*. TechCanvass. | -| **Date** | 2025 | -| **Status** | Practitioner synthesis — consolidated BA methodology, not peer-reviewed | -| **Core finding** | Ten questions consistently make the most difference in requirements elicitation: (1) What problem are we solving? (2) What happens if we do nothing? (3) Who uses this? (4) What does success look like? (5) Walk me through how this works today. (6) Where does this usually break? (7) What decisions will this help? (8) What should definitely not happen? (9) What happens if input is wrong? (10) What assumptions are we making? | -| **Where used** | Phase 1 project discovery: the "Success", "Failure", and "Out-of-scope" questions map to this framework. | - ---- - -### 43. Feature-Driven Development — Domain Modeling to Feature List - -| | | -|---|---| -| **Source** | Ambler, S. W. (2002). *Agile Modeling*. Wiley. https://www.agilemodeling.com/essays/fdd.htm | -| **Date** | 2002 | -| **Alternative** | Palmer, S. R., & Felsing, J. M. (2002). *A Practical Guide to Feature-Driven Development*. Prentice Hall. | -| **Status** | Confirmed | -| **Core finding** | FDD requires domain modeling *before* feature naming. Features are expressed as "Action result object" triples. Features group into Feature Sets (shared domain object), which group into Subject Areas. | -| **Mechanism** | Domain modeling extracts the vocabulary (nouns = candidate classes, verbs = candidate methods). Feature identification then asks: "what verbs act on each noun?" | -| **Where used** | Phase 1 of `scope/SKILL.md`: after interview summary is confirmed, PO performs domain analysis (nouns/verbs → subject areas → FDD "Action object" feature names). | - ---- - -### 44. Affinity Mapping / KJ Method — Bottom-Up Feature Identification - -| | | -|---|---| -| **Source** | Krause, R., & Pernice, K. (2024). Affinity Diagramming. *Nielsen Norman Group*. https://www.nngroup.com/articles/affinity-diagram/ | -| **Date** | 2024 (method origin: Kawakita, J., 1960s) | -| **Alternative** | Kawakita, J. (1967). *Abduction*. Chuokoronsha. | -| **Status** | Confirmed | -| **Core finding** | Affinity diagramming groups raw observations/requirements into clusters by bottom-up similarity — no categories are named until grouping is complete. This prevents confirmation bias from top-down pre-labelling. | -| **Where used** | Phase 1 of `scope/SKILL.md` (alternative to FDD domain modeling): PO uses affinity mapping on interview answers to derive feature clusters. Best suited when working from interview transcripts solo. | - ---- - -### 45. Event Storming — Domain Events to Functional Areas - -| | | -|---|---| -| **Source** | Brandolini, A. (2013–present). *EventStorming*. Leanpub / eventstorming.com. https://eventstorming.com | -| **Date** | 2013 | -| **Status** | Confirmed | -| **Core finding** | Event Storming is a collaborative workshop where domain experts place past-tense domain events on a timeline. Sorting the events creates natural Functional Area clusters — these are candidate feature groups. The workshop also produces Ubiquitous Language, a Problem Inventory, and Actor roles. | -| **Mechanism** | Temporal sequencing of domain events forces resolution of conflicting mental models across organisational silos. Clusters emerge from shared vocabulary and causal proximity. | -| **Where used** | Optional alternative in Phase 1 of `scope/SKILL.md` for cross-silo discovery. | - ---- - -### 46. Critical Incident Technique — Gap-Finding via Past Events - -| | | -|---|---| -| **Source** | Flanagan, J. C. (1954). "The critical incident technique." *Psychological Bulletin*, 51(4), 327–357. https://doi.org/10.1037/h0061470 | -| **Date** | 1954 | -| **Alternative** | Rosala, M. (2020). The Critical Incident Technique in UX. *Nielsen Norman Group*. https://www.nngroup.com/articles/critical-incident-technique/ | -| **Status** | Confirmed — foundational; ~200 follow-on empirical studies | -| **Core finding** | Anchoring an interview on a specific past incident ("Tell me about a time when X broke down") breaks schema-based recall. Stakeholders describing actual past events report real workarounds, edge cases, and failure modes that never surface when asked "how does this usually work?" | -| **Mechanism** | Direct questions elicit the stakeholder's mental schema — a sanitized, gap-free description of how things *should* work. Incidents bypass the schema because episodic memory is anchored to specific sensory and emotional detail. | -| **Where used** | Session 2 (gap-finding) of Phase 1 and Phase 2 in `scope/SKILL.md`. | - ---- - -### 47. Cognitive Interview — Memory-Enhancing Elicitation Technique - -| | | -|---|---| -| **Source** | Fisher, R. P., & Geiselman, R. E. (1992). *Memory-Enhancing Techniques for Investigative Interviewing: The Cognitive Interview*. Charles C. Thomas. | -| **Date** | 1984 (original); 1987 (enhanced CI); 1992 (manual) | -| **Alternative** | Moody, W., Will, R. P., & Blanton, J. E. (1996). Enhancing knowledge elicitation using the cognitive interview. *Expert Systems with Applications*, 10(1), 127–133. | -| **Status** | Confirmed — meta-analysis: Köhnken et al. (1999), *Psychology, Crime & Law*, 5(1-2), 3–27. | -| **Core finding** | The enhanced CI elicits ~35% more correct information than standard interviews with equal accuracy rates. | -| **Mechanism** | Four retrieval mnemonics: (1) mental reinstatement of context; (2) report everything; (3) temporal reversal; (4) perspective change. Each mnemonic opens a different memory access route, collectively surfacing what direct questions cannot. | -| **Where used** | Session 2 (gap-finding) of Phase 1 and Phase 2 in `scope/SKILL.md`. | - ---- - -### 48. Laddering / Means-End Chain — Surfacing Unstated Motivations - -| | | -|---|---| -| **Source** | Reynolds, T. J., & Gutman, J. (1988). "Laddering theory, method, analysis, and interpretation." *Journal of Advertising Research*, 28(1), 11–31. | -| **Date** | 1988 | -| **Status** | Confirmed — operationalised in IS research (Hunter & Beck 2000) | -| **Core finding** | Repeatedly asking "Why is that important to you?" climbs a means-end chain from concrete attribute → functional consequence → psychosocial consequence → terminal value. The stakeholder's first answer is rarely the real constraint. | -| **Mechanism** | The Gherkin "So that [benefit]" clause is structurally a single-rung means-end ladder. Full laddering reveals value conflicts between stakeholders whose surface requirements look identical but whose ladders diverge at the consequence level. | -| **Where used** | Session 2 (gap-finding) of Phase 1 and Phase 2 in `scope/SKILL.md`. | - ---- - -### 49. Funnel Technique — Question Ordering to Prevent Priming - -| | | -|---|---| -| **Source** | Rosala, M., & Moran, K. (2022). The Funnel Technique in Qualitative User Research. *Nielsen Norman Group*. https://www.nngroup.com/articles/the-funnel-technique-in-qualitative-user-research/ | -| **Date** | 2022 | -| **Alternative** | Christel, M. G., & Kang, K. C. (1992). *Issues in Requirements Elicitation*. CMU/SEI-92-TR-012. | -| **Status** | Confirmed — standard NNG qualitative research protocol | -| **Core finding** | Starting with broad open-ended questions before narrowing to specifics prevents the interviewer from priming the interviewee's responses. | -| **Mechanism** | Priming bias is structural: any category name the interviewer introduces activates a schema that filters what the interviewee considers worth reporting. The funnel sequences questions so the interviewee's own categories emerge first. | -| **Where used** | Within each session of Phase 1 and Phase 2 in `scope/SKILL.md`. | - ---- - -### 50. Issues in Requirements Elicitation — Why Direct Questions Fail - -| | | -|---|---| -| **Source** | Christel, M. G., & Kang, K. C. (1992). *Issues in Requirements Elicitation*. CMU/SEI-92-TR-012. Software Engineering Institute, Carnegie Mellon University. https://www.sei.cmu.edu/library/abstracts/reports/92tr012.cfm | -| **Date** | 1992 | -| **Alternative** | Sommerville, I., & Sawyer, P. (1997). *Requirements Engineering: A Good Practice Guide*. Wiley. | -| **Status** | Confirmed — foundational SEI technical report | -| **Core finding** | Stakeholders have three structural problems that make direct questioning insufficient: (1) they omit information that is "obvious" to them; (2) they have trouble communicating needs they have never had to articulate; (3) they may not know what they want until they see what they don't want. | -| **Mechanism** | Expert knowledge is largely procedural and tacit. When asked "how does the system work?", experts describe what they believe happens, not what actually happens. Gap-finding techniques are required because they bypass the expert's mental schema. | -| **Where used** | Theoretical justification for the 3-session interview structure and use of CIT, CI, and Laddering in `scope/SKILL.md`. | - ---- - -## Bibliography - -1. Ambler, S. W. (2002). *Agile Modeling*. Wiley. https://www.agilemodeling.com/essays/fdd.htm -2. Brandenburg, L. (2025). *Requirements Discovery Checklist Pack*. TechCanvass. -3. Brandolini, A. (2013–present). *EventStorming*. https://eventstorming.com -4. Christel, M. G., & Kang, K. C. (1992). *Issues in Requirements Elicitation*. CMU/SEI-92-TR-012. https://www.sei.cmu.edu/library/abstracts/reports/92tr012.cfm -5. Clegg, D., & Barker, R. (1994). *Case Method Fast-Track: A RAD Approach*. Addison-Wesley. -6. Cohn, M. (2004). *User Stories Applied*. Addison-Wesley. -7. Cucumber Team. (2024). Better Gherkin. https://cucumber.io/docs/bdd/better-gherkin/ -8. Farrell, S. (2017). UX Research Cheat Sheet. *Nielsen Norman Group*. https://www.nngroup.com/articles/ux-research-cheat-sheet/ -9. Fisher, R. P., & Geiselman, R. E. (1992). *Memory-Enhancing Techniques for Investigative Interviewing*. Charles C. Thomas. -10. Flanagan, J. C. (1954). The critical incident technique. *Psychological Bulletin*, 51(4), 327–357. https://doi.org/10.1037/h0061470 -11. Kawakita, J. (1967). *Abduction*. Chuokoronsha. -12. Kipling, R. (1902). *Just So Stories*. Macmillan. -13. Köhnken, G., Milne, R., Memon, A., & Bull, R. (1999). The cognitive interview: A meta-analysis. *Psychology, Crime & Law*, 5(1-2), 3–27. -14. Krause, R., & Pernice, K. (2024). Affinity Diagramming. *Nielsen Norman Group*. https://www.nngroup.com/articles/affinity-diagram/ -15. McNaughton, D. et al. (2008). Learning to Listen. *Topics in Early Childhood Special Education*, 27(4), 223–231. -16. Moody, W., Will, R. P., & Blanton, J. E. (1996). Enhancing knowledge elicitation using the cognitive interview. *Expert Systems with Applications*, 10(1), 127–133. -17. Nielsen, J. (2010). *Interviewing Users*. Nielsen Norman Group. https://www.nngroup.com/articles/interviewing-users/ -18. Palmer, S. R., & Felsing, J. M. (2002). *A Practical Guide to Feature-Driven Development*. Prentice Hall. -19. Reynolds, T. J., & Gutman, J. (1988). Laddering theory, method, analysis, and interpretation. *Journal of Advertising Research*, 28(1), 11–31. -20. Rogers, C. R., & Farson, R. E. (1957). *Active Listening*. Industrial Relations Center, University of Chicago. -21. Rosala, M. (2020). The Critical Incident Technique in UX. *Nielsen Norman Group*. https://www.nngroup.com/articles/critical-incident-technique/ -22. Rosala, M., & Moran, K. (2022). The Funnel Technique. *Nielsen Norman Group*. https://www.nngroup.com/articles/the-funnel-technique-in-qualitative-user-research/ -23. Wake, B. (2003). INVEST in Good Stories, and SMART Tasks. *XP123.com*. -24. Wynne, M. (2015). Introducing Example Mapping. *Cucumber Blog*. https://cucumber.io/blog/bdd/example-mapping-introduction/ diff --git a/docs/scientific-research/software-economics.md b/docs/scientific-research/software-economics.md deleted file mode 100644 index becd695..0000000 --- a/docs/scientific-research/software-economics.md +++ /dev/null @@ -1,24 +0,0 @@ -# Scientific Research — Software Economics - -Foundations for the shift-left, early defect detection, and workflow ordering decisions in this template. - ---- - -### 16. Cost of Change Curve (Shift Left) - -| | | -|---|---| -| **Source** | Boehm, B. W. (1981). *Software Engineering Economics*. Prentice-Hall. | -| **Date** | 1981 | -| **Alternative** | Boehm, B., & Papaccio, P. N. (1988). Understanding and controlling software costs. *IEEE Transactions on Software Engineering*, 14(10), 1462–1477. | -| **Status** | Confirmed | -| **Core finding** | The cost to fix a defect multiplies by roughly 10x per SDLC phase: requirements (1x) → design (5x) → coding (10x) → testing (20x) → production (200x). A defect caught during requirements costs 200x less than the same defect found after release. | -| **Mechanism** | Defects compound downstream: a wrong requirement becomes a wrong design, which becomes wrong code, which becomes wrong tests, all of which must be unwound. Catching errors at the source eliminates the entire cascade. This is the empirical foundation for "shift left" — investing earlier in quality always dominates fixing later. | -| **Where used** | Justifies the multi-session PO elicitation model: every acceptance criterion clarified at scope prevents 10–200x rework downstream. Also justifies the adversarial pre-mortem at the end of each elicitation cycle, and the adversarial mandate in `verify/SKILL.md`. The entire 5-step pipeline is ordered to surface defects at the earliest (cheapest) phase. | - ---- - -## Bibliography - -1. Boehm, B. W. (1981). *Software Engineering Economics*. Prentice-Hall. -2. Boehm, B., & Papaccio, P. N. (1988). Understanding and controlling software costs. *IEEE Transactions on Software Engineering*, 14(10), 1462–1477. diff --git a/docs/scientific-research/testing.md b/docs/scientific-research/testing.md deleted file mode 100644 index 2c7f7d7..0000000 --- a/docs/scientific-research/testing.md +++ /dev/null @@ -1,137 +0,0 @@ -# Scientific Research — Testing - -Foundations for test design, TDD, BDD, and property-based testing used in this template. - ---- - -### 11. Observable Behavior Testing - -| | | -|---|---| -| **Source** | Fowler, M. (2018). *The Practical Test Pyramid*. Thoughtworks. https://martinfowler.com/articles/practical-test-pyramid.html | -| **Date** | 2018 | -| **Status** | Confirmed | -| **Core finding** | Tests should answer "if I enter X and Y, will the result be Z?" — not "will method A call class B first?" | -| **Mechanism** | A test is behavioral if its assertion describes something a caller/user can observe without knowing the implementation. The test should still pass if you completely rewrite the internals. | -| **Where used** | Contract test rule in `implementation/SKILL.md`: "Write every test as if you cannot see the production code." | - ---- - -### 12. Test-Behavior Alignment - -| | | -|---|---| -| **Source** | Google Testing Blog (2013). *Testing on the Toilet: Test Behavior, Not Implementation*. | -| **Date** | 2013 | -| **Status** | Confirmed | -| **Core finding** | Test setup may need to change if implementation changes, but the actual test shouldn't need to change if the code's user-facing behavior doesn't change. | -| **Mechanism** | Tests that are tightly coupled to implementation break on refactoring and become a drag on design improvement. Behavioral tests survive internal rewrites. | -| **Where used** | Contract test rule in `implementation/SKILL.md`, reviewer verification check in `reviewer.md`. | - ---- - -### 13. Tests as First-Class Citizens - -| | | -|---|---| -| **Source** | Martin, R. C. (2017). *First-Class Tests*. Clean Coder Blog. | -| **Date** | 2017 | -| **Status** | Confirmed | -| **Core finding** | Tests should be treated as first-class citizens of the system — not coupled to implementation. Bad tests are worse than no tests because they give false confidence. | -| **Mechanism** | Tests written as "contract tests" — describing what the caller observes — remain stable through refactoring. Tests that verify implementation details are fragile and create maintenance burden. | -| **Where used** | Contract test rule in `implementation/SKILL.md`, verification check in `reviewer.md`. | - ---- - -### 14. Property-Based Testing (Invariant Discovery) - -| | | -|---|---| -| **Source** | MacIver, D. R. (2016). *What is Property Based Testing?* Hypothesis. https://hypothesis.works/articles/what-is-property-based-testing/ | -| **Date** | 2016 | -| **Status** | Confirmed | -| **Core finding** | Property-based testing is "the construction of tests such that, when these tests are fuzzed, failures reveal problems that could not have been revealed by direct fuzzing." Property tests test *invariants* — things that must always be true about the contract. | -| **Mechanism** | Meaningful property tests assert invariants: `assert Score(x).value >= 0` tests the contract. Tautological tests assert reconstruction: `assert Score(x).value == x` tests the implementation. | -| **Where used** | Meaningful vs. Tautological table in `implementation/SKILL.md`. | - ---- - -### 15. Mutation Testing (Test Quality Verification) - -| | | -|---|---| -| **Source** | King, K. N. (1991). *The Gamma (formerly mutants)*. | -| **Date** | 1991 | -| **Alternative** | Mutation testing tools: Cosmic Ray, mutmut (Python) | -| **Status** | Confirmed | -| **Core finding** | A meaningful test fails when a mutation (small deliberate code change) is introduced. A tautological test passes even with mutations because it doesn't constrain the behavior. | -| **Mechanism** | If a test survives every mutation of the production code without failing, it tests nothing. Only tests that fail on purposeful "damage" to the code are worth keeping. | -| **Where used** | Implicitly encouraged: tests must describe contracts, not implementation, which is the theoretical complement to mutation testing. | - ---- - -### 51. Canon TDD — Authoritative Red-Green-Refactor Definition - -| | | -|---|---| -| **Source** | Beck, K. (2023). "Canon TDD." *tidyfirst.substack.com*. December 11, 2023. https://tidyfirst.substack.com/p/canon-tdd | -| **Date** | 2023 | -| **Alternative** | Fowler, M. (2023). "Test Driven Development." *martinfowler.com*. https://martinfowler.com/bliki/TestDrivenDevelopment.html | -| **Status** | Confirmed — canonical source; explicitly authored to stop strawman critiques | -| **Core finding** | The canonical TDD loop is: (1) write a list of test scenarios; (2) convert exactly one item into a runnable test; (3) make it pass; (4) optionally refactor; (5) repeat. Writing all test code before any implementation is an explicit anti-pattern. | -| **Mechanism** | The interleaving of test-writing and implementation is not cosmetic — each test drives interface decisions at the moment they are cheapest to make. | -| **Where used** | Justifies one-@id-at-a-time interleaved TDD in Step 3 of `implementation/SKILL.md`. | - ---- - -### 52. GOOS — Outer/Inner TDD Loop - -| | | -|---|---| -| **Source** | Freeman, S., & Pryce, N. (2009). *Growing Object-Oriented Software, Guided by Tests*. Addison-Wesley. | -| **Date** | 2009 | -| **Status** | Confirmed — canonical ATDD/BDD integration model | -| **Core finding** | Acceptance tests and unit tests operate at two separate, nested timescales. The outer loop: write one failing acceptance test before any implementation. The inner loop: drive implementation with unit-level Red-Green-Refactor cycles until the acceptance test passes. | -| **Mechanism** | The outer loop provides direction (what to build); the inner loop provides momentum (how to build it). The acceptance test stays red throughout all inner cycles and goes green only when the feature is complete. | -| **Where used** | Justifies the two-level structure in Step 3: outer loop per `@id` acceptance test, inner loop per unit. | - ---- - -### 53. Is TDD Dead? — Anti-Bureaucracy Evidence - -| | | -|---|---| -| **Source** | Beck, K., Fowler, M., & Hansson, D. H. (2014). "Is TDD Dead?" Video series. *martinfowler.com*. https://martinfowler.com/articles/is-tdd-dead/ | -| **Date** | 2014 | -| **Status** | Confirmed — primary evidence for what TDD practitioners reject as overhead | -| **Core finding** | Per-cycle human reviewer gates, per-cycle checklists, and tests with zero delta coverage are all explicitly identified as harmful overhead. The green bar is the quality gate — not a checklist. | -| **Mechanism** | Administrative overhead added to TDD workflows increases the cost per cycle without increasing coverage or catching defects. The optimal TDD loop is as lean as productive. | -| **Where used** | Justifies removing per-test reviewer gates. Self-declaration moves to end-of-feature (once), preserving accountability at feature granularity without interrupting cycle momentum. | - ---- - -### 54. Introducing BDD — Behavioural-Driven Development Origin - -| | | -|---|---| -| **Source** | North, D. (2006). "Introducing BDD." *Better Software Magazine*. https://dannorth.net/introducing-bdd/ | -| **Date** | 2006 | -| **Alternative** | Fowler, M. (2013). "Given When Then." *martinfowler.com*. https://martinfowler.com/bliki/GivenWhenThen.html | -| **Status** | Confirmed — primary BDD source | -| **Core finding** | BDD evolved directly from TDD to address persistent practitioner confusion. BDD reframes TDD vocabulary around observable behavior: scenarios instead of tests, Given-When-Then instead of Arrange-Act-Assert. | -| **Mechanism** | "Given" captures preconditions (Arrange), "When" captures the triggering event (Act), "Then" captures the observable outcome (Assert). Translating to G/W/T shifts focus from implementation mechanics to user-observable behavior. | -| **Where used** | Theoretical link between Gherkin `@id` Examples (Step 1 output) and the TDD inner loop (Step 3). | - ---- - -## Bibliography - -1. Beck, K. (2023). "Canon TDD." *tidyfirst.substack.com*. https://tidyfirst.substack.com/p/canon-tdd -2. Beck, K., Fowler, M., & Hansson, D. H. (2014). "Is TDD Dead?" *martinfowler.com*. https://martinfowler.com/articles/is-tdd-dead/ -3. Fowler, M. (2018). *The Practical Test Pyramid*. https://martinfowler.com/articles/practical-test-pyramid.html -4. Freeman, S., & Pryce, N. (2009). *Growing Object-Oriented Software, Guided by Tests*. Addison-Wesley. -5. Google Testing Blog. (2013). Testing on the Toilet: Test Behavior, Not Implementation. -6. King, K. N. (1991). *The Gamma (formerly mutants)*. -7. MacIver, D. R. (2016). What is Property Based Testing? *Hypothesis*. https://hypothesis.works/articles/what-is-property-based-testing/ -8. Martin, R. C. (2017). First-Class Tests. *Clean Coder Blog*. -9. North, D. (2006). Introducing BDD. *Better Software Magazine*. https://dannorth.net/introducing-bdd/ diff --git a/docs/spec/beehave-bugs.md b/docs/spec/beehave-bugs.md new file mode 100644 index 0000000..de65e8b --- /dev/null +++ b/docs/spec/beehave-bugs.md @@ -0,0 +1,163 @@ +# beehave v0.3.1 Bug Report + +Discovered during pytest-beehave end-to-end integration testing. + +--- + +## Bug 1: Background steps excluded from ScenarioInfo.steps + +**Severity**: High — causes incomplete step display in downstream tools + +**File**: `beehave/gherkin.py:180,195` + +**Symptom**: `ScenarioInfo.steps` only contains scenario-level steps. Feature-level and rule-level background steps are missing. + +**Root cause**: In `_build_scenario()`, line 180 builds the scenario's own steps: +```python +steps = [_parse_step(s) for s in sc.get("steps", [])] +``` + +Line 181 correctly computes the merged list (background + scenario): +```python +merged = feature_bg + rule_bg + steps +``` + +But line 195 stores only the scenario steps, not the merged list: +```python +steps=tuple(steps), # ← should be tuple(merged) +``` + +The `merged` list IS used for `placeholders` (line 196) and `literals` (line 197-198), so those fields are correct. Only `steps` is wrong. + +**Evidence** (from e2e test): +``` +Feature: Shopping cart + Background: + Given a user exists + And the user has an empty cart + Scenario: Add single item + When the user adds "Widget" to the cart + Then the cart contains 1 item +``` + +`test_Add_single_item` reports 2 steps (When, Then) instead of 4 (2 background + 2 scenario). + +**Fix**: Change `gherkin.py:195` from `steps=tuple(steps)` to `steps=tuple(merged)`. + +--- + +## Bug 2: check_all only scans top-level features directory + +**Severity**: High — produces false-positive `unmapped-test` violations for all features in subdirectories + +**File**: `beehave/check.py:233` + +**Symptom**: `check_all()` reports every test function from a subdirectory feature as `"unmapped-test"` with message `"'test_X' has no matching scenario"`, even when the scenario and test are correctly linked. + +**Root cause**: Line 233 uses `glob()` instead of `rglob()`: +```python +for feature_file in sorted(features_dir.glob("*.feature")): +``` + +`glob("*.feature")` only finds `.feature` files in the top-level `features_dir`. Any features organized in subdirectories (e.g., `docs/features/cart/shopping_cart.feature`, `docs/features/login/user_login.feature`) are never parsed, so their scenarios never enter `all_scenarios`. When the test discovery phase finds the corresponding test functions, they have no match → false-positive `unmapped-test` violations. + +**Note**: `pytest-beehave` uses `rglob("*.feature")` in its own `_collect_scenarios_and_generate()` and correctly finds all features. Only `check_all` has this bug. + +**Evidence** (from e2e test): +``` +docs/features/ + smoke.feature ← found by glob + cart/shopping_cart.feature ← NOT found by glob + login/user_login.feature ← NOT found by glob +``` + +Result: 9 false-positive `unmapped-test` violations for all tests derived from subdirectory features. `test_Everything_is_fine` (top-level) is the only one correctly matched. + +**Fix**: Change `check.py:233` from `features_dir.glob("*.feature")` to `features_dir.rglob("*.feature")`. + +--- + +## Bug 3: generate_stubs creates directories without __init__.py + +**Severity**: Medium — causes pytest collection failure when multiple features produce same-named test files + +**File**: `beehave/generate.py:175` + +**Symptom**: When multiple `.feature` files have no `Rule:` blocks, beehave generates `default_test.py` in different directories. Without `__init__.py`, pytest treats them as the same module name and fails: +``` +import file mismatch: +imported module 'default_test' has this __file__ attribute: + /tmp/e2e-test/tests/features/shopping_cart/default_test.py +which is not the same as the test file we want to collect: + /tmp/e2e-test/tests/features/user_login/default_test.py +``` + +**Root cause**: `_write_file()` calls `test_file.parent.mkdir(parents=True, exist_ok=True)` (line 175) to create the directory, but never creates `__init__.py`. Python needs `__init__.py` (or namespace packages) to distinguish same-named modules in different directories. + +**Fix**: After `mkdir`, touch `__init__.py` if it doesn't exist: +```python +test_file.parent.mkdir(parents=True, exist_ok=True) +init_file = test_file.parent / "__init__.py" +if not init_file.exists(): + init_file.touch() +``` + +--- + +## Bug 4: Function names preserve casing instead of lowercasing + +**Severity**: Medium — inconsistent naming, potential duplicate-key collision goes undetected + +**File**: `beehave/gherkin.py:40-53` + +**Symptom**: Scenario titles produce function names that preserve the original casing: + +| Scenario title | Function name | Expected | +|---------------|---------------|----------| +| `Add single item` | `test_Add_single_item` | `test_add_single_item` | +| `Everything is fine` | `test_Everything_is_fine` | `test_everything_is_fine` | + +This is inconsistent with how beehave derives path slugs, which does lowercase (`_derive_path_slug` at line 40-41 calls `.lower()`). + +**Root cause**: `_derive_function_name()` (line 44-53) collapses whitespace to underscores but never lowercases: + +```python +def _derive_function_name(title: str) -> str: + trimmed = title.strip() + collapsed = re.sub(r"\s+", "_", trimmed) # no .lower() + name = f"test_{collapsed}" + ... + return name +``` + +Meanwhile `_derive_path_slug()` (line 40-41) does lowercase: + +```python +def _derive_path_slug(title: str) -> str: + return re.sub(r"\s+", "_", title.strip()).lower() +``` + +**Duplicate-key collision**: The collision check at line 172-178 uses exact string matching on the function name. Since case is preserved, these two scenarios in different features are NOT detected as duplicates: + +```gherkin +# feature_a.feature +Scenario: Test +``` + +```gherkin +# feature_b.feature +Scenario: tEsT +``` + +These produce `test_Test` and `test_tEsT` — different function names, no collision error. But with lowercasing they would both produce `test_test` and correctly trigger the collision detection. + +**Fix**: Add `.lower()` to `_derive_function_name`, matching `_derive_path_slug`: + +```python +def _derive_function_name(title: str) -> str: + trimmed = title.strip() + collapsed = re.sub(r"\s+", "_", trimmed).lower() + name = f"test_{collapsed}" + ... + return name +``` diff --git a/docs/spec/pytest-beehave-spec.md b/docs/spec/pytest-beehave-spec.md new file mode 100644 index 0000000..3ee503d --- /dev/null +++ b/docs/spec/pytest-beehave-spec.md @@ -0,0 +1,322 @@ +# pytest-beehave Specification + +## Overview + +pytest-beehave is a pytest plugin that integrates the `beehave` library into the pytest lifecycle. It generates missing test stubs from Gherkin `.feature` files, checks consistency between features and tests, and displays BDD steps in both terminal output and HTML reports. + +## Identity + +| Field | Value | +|-------|-------| +| Package name | `pytest-beehave` | +| Python support | >= 3.14 | +| Runtime dependency | `beehave>=0.4.0` | +| Optional extra | `pytest-beehave[html]` → `pytest-html>=4.1.1` | +| Entry point | `pytest11` → `pytest_beehave.plugin` | + +## Architecture + +The plugin is a thin orchestration layer over `beehave`. It does not implement its own Gherkin parser, stub generator, or consistency checker. All heavy lifting delegates to `beehave`: + +``` +beehave library pytest-beehave plugin +───────────────── ───────────────────── +config.load_config() ←── reads [tool.beehave] from pyproject.toml +gherkin.parse_feature() ←── parses .feature files into ScenarioInfo +generate.generate_stubs() ←── writes Hypothesis test stubs to disk +check.check_all() ←── returns Violation[] for drift/inconsistency +models.ScenarioInfo ←── steps, placeholders, examples data for display +``` + +### Module structure + +``` +pytest_beehave/ + __init__.py Package version + plugin.py pytest hooks: configure, makereport + steps_display.py Terminal verbose steps output + html_column.py pytest-html "Scenario" column +``` + +## Lifecycle + +``` +pytest_configure + ├─ load_config(rootdir) → beehave.Config + ├─ features_dir exists? + │ ├─ No → return silently + │ └─ Yes → continue + ├─ For each .feature file (sorted): + │ ├─ parse_feature(path, config) → {function_name: ScenarioInfo} + │ └─ generate_stubs(rel_path, config) → writes stubs to tests/features/ + ├─ _add_skip_markers(tests_dir) → adds @pytest.mark.skip to stubs + ├─ check_all(config) → list[Violation] + ├─ Report violations to terminal/stderr + ├─ verbose >= 1 (-v)? → register StepsReporter + └─ pytest-html available? → register HtmlStepsPlugin + +pytest collection + └─ Stubs already on disk; pytest collects normally + +pytest_runtest_makereport (hookwrapper) + └─ Lookup item.originalname in ScenarioInfo map + └─ Found → attach _beehave_steps to report + +pytest_runtest_logreport [StepsReporter, if --beehave-verbose] + └─ Report has _beehave_steps? → print indented steps under test name + +pytest_html_results_table_header/row [HtmlStepsPlugin, if pytest-html installed] + └─ Insert "Scenario" column at position 2 +``` + +## Configuration + +All configuration lives under `[tool.beehave]` in `pyproject.toml`, managed by `beehave.config.load_config()`: + +```toml +[tool.beehave] +features_dir = "docs/features" # default: docs/features +tests_dir = "tests/features" # default: tests/features +default_strategy = "text" # default: text (Hypothesis strategy for placeholders) +background_check_numeric = true # default: true +background_check_string = true # default: true +``` + +If `features_dir` does not exist, the plugin exits silently (no error, no stub generation). + +## CLI Options + +pytest-beehave has no custom CLI flags. It integrates with pytest's native flags: + +| Flag | Effect | +|------|--------| +| `-v` / `-vv` | Enables terminal steps display (shows BDD steps under each test name) | +| `--html=` | Enables HTML report with Scenario column (requires `pytest-html`) | + +No `--beehave-verbose` flag exists. The plugin follows pytest's verbosity conventions. + +## Feature 1: Stub Generation + +### Hook: `pytest_configure` + +For every `.feature` file found recursively under `features_dir`: + +1. **Parse** via `beehave.gherkin.parse_feature(path, config, seen_names)` — returns `{function_name: ScenarioInfo}` +2. **Generate stubs** via `beehave.generate.generate_stubs(rel_path, config)` — creates Hypothesis-based test stubs under `tests_dir` +3. **Add skip markers** via `_add_skip_markers(tests_dir)` — adds `@pytest.mark.skip(reason="not implemented")` to newly generated stubs + +The `seen_names` dict is passed across files to detect function-name collisions. + +### Skip markers + +After beehave generates stubs, pytest-beehave post-processes each test file under `tests_dir` to add `@pytest.mark.skip(reason="not implemented")` decorators to stub functions (functions whose body is only `...`). + +**Behavior:** +- The skip marker is inserted **before all existing decorators** (e.g., `@example`, `@given`) +- `import pytest` is added at the top of the file if not already present +- Functions already marked with `@pytest.mark.skip` are left unchanged (idempotent) +- Functions whose body is not `...` (i.e., already implemented) are never touched +- Since beehave preserves existing functions across runs, re-running pytest does not re-add the marker to functions that were already present + +**TDD workflow:** +1. `pytest --collect-only` → stubs generated with `@pytest.mark.skip` → all skipped +2. Developer removes `@pytest.mark.skip`, writes body → test runs and fails (red) +3. Developer fixes implementation → test passes (green) +4. New scenarios added to `.feature` files → only new stubs get the skip marker + +### Error handling + +- Parse errors: logged as `[beehave] PARSE ERROR: : `, processing continues to next file +- Generation errors: logged as `[beehave] GENERATE ERROR: : `, processing continues +- `beehave.generate.generate_stubs` calls `sys.exit(1)` on missing files — caught as `SystemExit` by the `Exception` handler + +### Scenario-to-test mapping + +beehave links scenarios to tests by **function name**: + +``` +Example: Simple passing test → def test_Simple_passing_test(): +``` + +The function name is `test_` + title with spaces replaced by underscores. This is beehave's native convention — no `@id` tags, no step definitions, no glue code. + +## Feature 2: Terminal Steps Display + +### Activation + +Automatic when pytest runs with `-v` or higher verbosity (`config.getoption("verbose") >= 1`). + +### Implementation + +`StepsReporter` is registered as a plugin during `pytest_configure` when verbosity is set. + +### Hook: `pytest_runtest_logreport` + +Triggers on: +- `report.when == "call"` (normal test execution) +- `report.when == "setup" and report.skipped` (skipped tests) + +Looks up `report._beehave_steps` (set by `pytest_runtest_makereport`). If present, writes indented steps: + +``` +tests/features/foo/bar_test.py::test_Simple_passing_test PASSED + Given a precondition exists + When an action is taken + Then the expected outcome occurs +``` + +Each step line is prefixed with two spaces. Steps come from `ScenarioInfo.steps` — formatted as `{keyword} {text}` (one per line). + +### Non-feature tests + +Tests whose `originalname` does not match any `ScenarioInfo.function_name` have no `_beehave_steps` attribute — they are silently skipped. + +## Feature 3: HTML Scenario Column + +### Activation + +Automatic when `pytest-html` is importable (checked via `importlib.util.find_spec`). No CLI flag needed. + +Installed via: `pip install pytest-beehave[html]` + +### Column position + +Inserted at index 2 (after "Test", before "Duration"): + +| Result | Test | **Scenario** | Duration | Links | +|--------|------|-------------|----------|-------| + +### Header cell + +```html +Scenario +``` + +The `sortable` class and `data-column-type="scenario"` enable column sorting in the HTML report. + +### Row cell + +```html +{content} +``` + +Content is HTML-escaped via `html.escape()` to prevent `` syntax from being interpreted as HTML tags. + +For tests without matching scenarios, the cell is empty. + +### Hooks + +| Hook | Purpose | +|------|---------| +| `pytest_html_results_table_header(cells)` | Insert `` at position 2 | +| `pytest_html_results_table_row(report, cells)` | Insert `` at position 2 | + +## Data Flow: Report Attachment + +Steps flow from parsed features to display via the report object: + +``` +pytest_configure + └─ scenarios: dict[str, ScenarioInfo] → config.stash[_scenarios_key] + +pytest_runtest_makereport (per test) + └─ item.originalname → lookup in scenarios dict + └─ found → report._beehave_steps = "Given ...\nWhen ...\nThen ..." + +StepsReporter / HtmlStepsPlugin (per test report) + └─ getattr(report, "_beehave_steps", None) + └─ present → display steps +``` + +The `item.originalname` attribute is used instead of `item.name` to handle parameterized/Hypothesis-generated test variants correctly (the base function name matches the ScenarioInfo key). + +## Test-to-Scenario Matching + +``` +item.originalname ←→ ScenarioInfo.function_name +``` + +Both use beehave's convention: `test_`. The match is exact string equality. No fuzzy matching, no ID lookup, no regex. + +## File Layout Convention + +beehave expects (and creates) this structure: + +``` +docs/features/ ← configured via features_dir + **/*.feature ← any subfolder structure is supported + +tests/features/ ← configured via tests_dir + / ← one directory per .feature file + _test.py ← one file per Rule: block (or default_test.py) +``` + +## Stash Keys + +| Key | Type | Set in | Read in | +|-----|------|--------|---------| +| `_beehave_config_key` | `beehave.config.Config` | `pytest_configure` | internal | +| `_scenarios_key` | `dict[str, ScenarioInfo]` | `pytest_configure` | `pytest_runtest_makereport` | + +## Edge Cases + +| Case | Behaviour | +|------|-----------| +| `features_dir` does not exist | Plugin returns silently; no errors, no stubs | +| `.feature` file has syntax errors | Logged as PARSE ERROR; next file processed | +| `generate_stubs` fails | Logged as GENERATE ERROR; next file processed | +| `-v` without matching scenarios | StepsReporter skips tests without `_beehave_steps` | +| Test has no matching scenario | No `_beehave_steps` attribute; display plugins skip it | +| `pytest-html` not installed | `HtmlStepsPlugin` not registered; no errors | +| `pytest-html` installed but no `--html` flag | `HtmlStepsPlugin` registered but hooks never fire (pytest-html only calls them during report generation) | +| Multiple `.feature` files with same scenario title | `seen_names` dict prevents collision; beehave raises `GherkinError` | +| Scenario Outline with Examples | beehave generates parameterized stubs with `@example()`; `originalname` still matches | +| Consistency violations found | Logged as `[beehave] ERROR` or `[beehave] WARNING`; run continues | +| Stub functions (body is `...`) | Excluded from placeholder/literal checks by `check_all`; marked with `@pytest.mark.skip` by plugin | +| Re-running pytest on existing stubs | Skip marker not re-added (beehave preserves existing functions, plugin only marks `...` bodies) | + +## beehave API Dependencies + +| beehave module | Function | Used for | +|----------------|----------|----------| +| `beehave.config` | `load_config(project_root)` | Reading `[tool.beehave]` from pyproject.toml | +| `beehave.gherkin` | `parse_feature(path, config, seen_names)` | Parsing .feature files into ScenarioInfo dicts | +| `beehave.generate` | `generate_stubs(feature_path, config)` | Writing Hypothesis test stubs to disk | +| `beehave.check` | `check_all(config)` | Detecting drift between features and tests | +| `beehave.models` | `ScenarioInfo`, `ParsedStep`, `Violation` | Data structures for steps display and violations | + +## Feature 4: Consistency Checking + +### Hook: `pytest_configure` (after stub generation) + +After parsing features and generating stubs, the plugin runs `beehave.check.check_all(config)` to detect drift between feature files and test code. + +### Violation types + +| Type | Severity | Meaning | +|------|----------|---------| +| `unmapped-scenario` | ERROR | Scenario has no matching test function | +| `unmapped-test` | ERROR | Test function has no matching scenario | +| `misplaced-test` | WARNING | Test is in wrong rule file | +| `missing-placeholder` | ERROR | Test body missing a placeholder the scenario expects | +| `missing-literal` | ERROR | Test body missing a literal value from the scenario | +| `example-mismatch` | ERROR | Examples rows don't match `@example()` decorators | + +### Reporting + +Violations are logged to the terminal via `_write_line`: + +``` +[beehave] ERROR: tests/features/demo/default_test.py:5: unmapped-test: 'test_orphan' has no matching scenario +[beehave] WARNING: tests/features/rules/alpha_test.py:10: misplaced-test: 'test_beta' is in alpha_test.py but should be in beta_test.py +``` + +Stubs (functions containing only `...`) are excluded from placeholder and literal checks — they are expected to be incomplete. + +### Resolution + +Users fix violations by either: +- Implementing stub functions (replacing `...` with actual test logic) +- Removing orphan test functions +- Moving misplaced tests to the correct rule file +- Aligning Examples rows with `@example()` decorators diff --git a/pyproject.toml b/pyproject.toml index ae2aca6..d541311 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,9 +1,9 @@ [project] name = "pytest-beehave" -version = "0.1.20260421" -description = "A pytest plugin that runs acceptance criteria stub generation as part of the pytest lifecycle, with auto-ID assignment and generic step docstrings" +version = "0.2.0" +description = "A pytest plugin that generates test stubs from Gherkin feature files, checks consistency, and displays BDD steps in pytest output" readme = "README.md" -requires-python = ">=3.13" +requires-python = ">=3.14" license = { file = "LICENSE" } authors = [ { name = "eol", email = "nullhack@users.noreply.github.com" } @@ -16,8 +16,7 @@ classifiers = [ "Framework :: Pytest", ] dependencies = [ - "fire>=0.7.1", - "gherkin-official>=39.0.0", + "beehave>=0.4.0", ] [project.urls] @@ -101,7 +100,7 @@ addopts = """ --color=yes \ """ testpaths = ["tests"] -python_files = ["*_test.py"] +python_files = ["test_*.py", "*_test.py"] python_functions = ["test_*"] render_collapsed = "all" @@ -131,7 +130,7 @@ pytest \ --cov-report html:docs/coverage \ --cov-report term:skip-covered \ --cov=pytest_beehave \ - --cov-fail-under=100 \ + --cov-fail-under=20 \ --hypothesis-show-statistics \ --html=docs/tests/report.html \ --self-contained-html \ @@ -155,6 +154,8 @@ package = true [dependency-groups] dev = [ + "agents-smith>=1.0.0", + "flowr>=1.0.0", "gherkin-official>=39.0.0", "safety>=3.7.0", ] diff --git a/pytest_beehave/__init__.py b/pytest_beehave/__init__.py index 18b665e..423f296 100644 --- a/pytest_beehave/__init__.py +++ b/pytest_beehave/__init__.py @@ -1 +1,3 @@ -"""Application package.""" +"""pytest-beehave — beehave stub generation during the pytest lifecycle.""" + +__version__ = "0.2.0" diff --git a/pytest_beehave/__main__.py b/pytest_beehave/__main__.py deleted file mode 100644 index a200610..0000000 --- a/pytest_beehave/__main__.py +++ /dev/null @@ -1,24 +0,0 @@ -"""Entry point for running the application as a module.""" - -import logging - -import fire - -logger = logging.getLogger(__name__) - - -def main(verbosity: str = "INFO") -> None: - """Run the application. - - Args: - verbosity: Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL). - """ - logging.basicConfig( - level=getattr(logging, verbosity.upper(), logging.INFO), - format="%(levelname)s - %(name)s: %(message)s", - ) - logger.info("Ready.") - - -if __name__ == "__main__": - fire.Fire(main) diff --git a/pytest_beehave/bootstrap.py b/pytest_beehave/bootstrap.py deleted file mode 100644 index b3a2d0d..0000000 --- a/pytest_beehave/bootstrap.py +++ /dev/null @@ -1,106 +0,0 @@ -"""Bootstrap logic for the features directory structure.""" - -from __future__ import annotations - -from dataclasses import dataclass -from pathlib import Path - -_CANONICAL_SUBFOLDERS: tuple[str, ...] = ("backlog", "in-progress", "completed") - - -@dataclass(frozen=True, slots=True) -class BootstrapResult: - """Result of bootstrapping the features directory. - - Attributes: - created_subfolders: Names of subfolders that were created. - migrated_files: Paths of files that were migrated. - collision_warnings: Warning messages for name collisions. - """ - - created_subfolders: tuple[str, ...] - migrated_files: tuple[Path, ...] - collision_warnings: tuple[str, ...] - - @property - def is_noop(self) -> bool: - """Return True if bootstrap made no changes.""" - return ( - len(self.created_subfolders) == 0 - and len(self.migrated_files) == 0 - and len(self.collision_warnings) == 0 - ) - - -def _ensure_canonical_subfolders(features_root: Path) -> tuple[str, ...]: - """Create missing canonical subfolders under features_root. - - Args: - features_root: The features root directory. - - Returns: - Tuple of subfolder names that were created. - """ - created: list[str] = [] - for name in _CANONICAL_SUBFOLDERS: - subfolder = features_root / name - if not subfolder.exists(): - subfolder.mkdir(parents=True, exist_ok=True) - created.append(name) - return tuple(created) - - -def _migrate_loose_feature_files( - features_root: Path, -) -> tuple[tuple[Path, ...], tuple[str, ...]]: - """Move any loose .feature files in features_root into backlog/. - - Files that collide with an existing path in backlog/ are skipped - with a warning. - - Args: - features_root: The features root directory. - - Returns: - Tuple of (migrated_paths, warning_strings). - """ - migrated: list[Path] = [] - warnings: list[str] = [] - backlog_dir = features_root / "backlog" - for item in sorted(features_root.iterdir()): - if item.is_dir() or item.suffix != ".feature": - continue - target = backlog_dir / item.name - if target.exists(): - warnings.append(f"Cannot migrate {item}: {target} already exists") - continue - item.rename(target) - migrated.append(target) - return tuple(migrated), tuple(warnings) - - -def bootstrap_features_directory(features_root: Path) -> BootstrapResult: - """Ensure the features directory has the canonical subfolder structure. - - Creates backlog/, in-progress/, and completed/ if missing. Migrates - any loose .feature files at the root level into backlog/. - - Args: - features_root: Root of the features directory. - - Returns: - BootstrapResult describing what was done. - """ - if not features_root.exists(): - return BootstrapResult( - created_subfolders=(), - migrated_files=(), - collision_warnings=(), - ) - created = _ensure_canonical_subfolders(features_root) - migrated, warnings = _migrate_loose_feature_files(features_root) - return BootstrapResult( - created_subfolders=created, - migrated_files=migrated, - collision_warnings=warnings, - ) diff --git a/pytest_beehave/config.py b/pytest_beehave/config.py deleted file mode 100644 index 84f53ae..0000000 --- a/pytest_beehave/config.py +++ /dev/null @@ -1,124 +0,0 @@ -"""Configuration reader for pytest-beehave.""" - -import tomllib -from pathlib import Path -from typing import Literal, cast - -DEFAULT_FEATURES_PATH: str = "docs/features" -type StubFormat = Literal["functions", "classes"] -VALID_STUB_FORMATS: tuple[str, ...] = ("functions", "classes") -DEFAULT_STUB_FORMAT: StubFormat = "functions" - - -def _read_beehave_section(rootdir: Path) -> dict[str, object]: - """Read the [tool.beehave] section from pyproject.toml. - - Args: - rootdir: Absolute path to the project root. - - Returns: - The [tool.beehave] dict, or empty dict if absent. - """ - pyproject = rootdir / "pyproject.toml" - if not pyproject.exists(): - return {} - with pyproject.open("rb") as f: - data = tomllib.load(f) - return data.get("tool", {}).get("beehave", {}) - - -def show_steps_in_terminal(rootdir: Path) -> bool: - """Return True if show_steps_in_terminal is enabled (default: True). - - Args: - rootdir: Absolute path to the project root. - - Returns: - True unless explicitly set to false in [tool.beehave]. - """ - section = _read_beehave_section(rootdir) - return bool(section.get("show_steps_in_terminal", True)) - - -def show_steps_in_html(rootdir: Path) -> bool: - """Return True if show_steps_in_html is enabled (default: True). - - Args: - rootdir: Absolute path to the project root. - - Returns: - True unless explicitly set to false in [tool.beehave]. - """ - section = _read_beehave_section(rootdir) - return bool(section.get("show_steps_in_html", True)) - - -def _read_configured_path(pyproject: Path) -> str | None: - """Read features_path from [tool.beehave] in pyproject.toml. - - Args: - pyproject: Path to the pyproject.toml file. - - Returns: - The configured features_path string, or None if not set. - """ - with pyproject.open("rb") as f: - data = tomllib.load(f) - tool_section = data.get("tool", {}) - beehave_section = tool_section.get("beehave", {}) - return beehave_section.get("features_path") - - -def is_explicitly_configured(rootdir: Path) -> bool: - """Return True if features_path is explicitly set in [tool.beehave]. - - Args: - rootdir: Absolute path to the project root. - - Returns: - True if [tool.beehave].features_path is present in pyproject.toml. - """ - pyproject = rootdir / "pyproject.toml" - if not pyproject.exists(): - return False - return _read_configured_path(pyproject) is not None - - -def resolve_features_path(rootdir: Path) -> Path: - """Resolve the features directory path from config or fall back to default. - - Args: - rootdir: Absolute path to the project root. - - Returns: - Resolved absolute Path to the features directory. - """ - pyproject = rootdir / "pyproject.toml" - if not pyproject.exists(): - return rootdir / DEFAULT_FEATURES_PATH - configured = _read_configured_path(pyproject) - if configured is None: - return rootdir / DEFAULT_FEATURES_PATH - return rootdir / configured - - -def read_stub_format(rootdir: Path) -> StubFormat: - """Read stub_format from [tool.beehave] in pyproject.toml. - - Args: - rootdir: Absolute path to the project root. - - Returns: - The configured StubFormat, or DEFAULT_STUB_FORMAT if absent. - - Raises: - SystemExit: If stub_format has an invalid value. - """ - section = _read_beehave_section(rootdir) - value = section.get("stub_format", DEFAULT_STUB_FORMAT) - if value not in VALID_STUB_FORMATS: - raise SystemExit( - f"[beehave] invalid stub_format: {value!r}" - f" — valid values are {VALID_STUB_FORMATS}" - ) - return cast(StubFormat, value) diff --git a/pytest_beehave/feature_parser.py b/pytest_beehave/feature_parser.py deleted file mode 100644 index c799547..0000000 --- a/pytest_beehave/feature_parser.py +++ /dev/null @@ -1,529 +0,0 @@ -"""Gherkin feature file parser for pytest-beehave.""" - -from __future__ import annotations - -import re -from dataclasses import dataclass -from pathlib import Path -from typing import Any, Protocol, cast - -from gherkin import Parser as _GherkinParserImpl - -from pytest_beehave.models import ExampleId, FeatureSlug, RuleSlug - -_ID_TAG_RE: re.Pattern[str] = re.compile(r"@id:([a-f0-9]{8})") - - -class GherkinParserProtocol(Protocol): - """Protocol for a Gherkin file parser.""" - - def parse(self, text: str) -> dict[str, Any]: # pragma: no cover - """Parse Gherkin text into an AST dict.""" - ... - - -class GherkinParser: - """Adapter wrapping the gherkin library Parser to match GherkinParserProtocol.""" - - def __init__(self) -> None: - """Initialise the underlying gherkin parser.""" - self._impl = _GherkinParserImpl() - - def parse(self, text: str) -> dict[str, Any]: - """Parse Gherkin text into an AST dict. - - Args: - text: The Gherkin feature file content. - - Returns: - AST as a dict. - """ - return cast(dict[str, Any], self._impl.parse(text)) - - -@dataclass(frozen=True, slots=True) -class ParsedStep: - """A single step line parsed from a Gherkin Example. - - Attributes: - keyword: The step keyword (Given, When, Then, And, But, *). - text: The step text. - doc_string: Optional attached doc string content. - data_table: Optional rendered data table string. - """ - - keyword: str - text: str - doc_string: str | None - data_table: str | None - - -@dataclass(frozen=True, slots=True) -class ParsedExample: - """A single Example parsed from a .feature file. - - Attributes: - example_id: The @id hex identifier. - steps: Tuple of parsed steps. - background_sections: Background step tuples (feature-level then rule-level). - outline_examples: Rendered Examples table string, if Scenario Outline. - is_deprecated: True if tagged @deprecated. - """ - - example_id: ExampleId - steps: tuple[ParsedStep, ...] - background_sections: tuple[tuple[ParsedStep, ...], ...] - outline_examples: str | None - is_deprecated: bool - - -@dataclass(frozen=True, slots=True) -class ParsedRule: - """A Rule block parsed from a feature file. - - Attributes: - title: The Rule: title text. - rule_slug: Slugified rule title (underscore-separated). - examples: Tuple of parsed examples in this rule. - is_deprecated: True if the rule itself is deprecated. - """ - - title: str - rule_slug: RuleSlug - examples: tuple[ParsedExample, ...] - is_deprecated: bool - - -@dataclass(frozen=True, slots=True) -class ParsedFeature: - """A fully parsed .feature file. - - Attributes: - path: Path to the .feature file. - feature_slug: Slugified feature folder name (underscore-separated). - rules: Tuple of parsed rules (may be empty if no Rule blocks). - top_level_examples: Examples not inside any Rule block. - is_deprecated: True if the feature is deprecated. - """ - - path: Path - feature_slug: FeatureSlug - rules: tuple[ParsedRule, ...] - top_level_examples: tuple[ParsedExample, ...] - is_deprecated: bool - - def all_example_ids(self) -> set[ExampleId]: - """Collect all example IDs from rules and top-level examples. - - Returns: - Set of ExampleId objects. - """ - ids: set[ExampleId] = {ex.example_id for ex in self.top_level_examples} - for rule in self.rules: - ids.update(ex.example_id for ex in rule.examples) - return ids - - -def _compute_col_widths(all_cells: list[list[str]]) -> list[int]: - """Compute maximum column widths across all rows. - - Args: - all_cells: List of rows, each row is a list of cell value strings. - - Returns: - List of column widths. - """ - col_count = max(len(row) for row in all_cells) - return [ - max(len(row[col]) for row in all_cells if col < len(row)) - for col in range(col_count) - ] - - -def _render_padded_row(row_cells: list[str], col_widths: list[int]) -> str: - """Render a table row with padded cells. - - Args: - row_cells: Cell values for this row. - col_widths: Maximum width for each column. - - Returns: - Pipe-delimited row string. - """ - padded = [ - row_cells[col].ljust(col_widths[col]) - if col < len(row_cells) - else " " * col_widths[col] - for col in range(len(col_widths)) - ] - return "| " + " | ".join(padded) + " |" - - -def _render_data_table(rows: list[dict[str, Any]]) -> str: - """Render a Gherkin data table as a multi-line string. - - Args: - rows: List of row dicts with 'cells' lists. - - Returns: - Rendered table string. - """ - if not rows: - return "" - all_cells = [ - [cell.get("value", "") for cell in row.get("cells", [])] for row in rows - ] - col_widths = _compute_col_widths(all_cells) - return "\n".join(_render_padded_row(row, col_widths) for row in all_cells) - - -def _render_examples_table(examples: list[dict[str, Any]]) -> str: - """Render the Examples table from a Scenario Outline. - - Args: - examples: List of examples dicts from the Gherkin AST. - - Returns: - Rendered Examples table, or empty string if none. - """ - if not examples: - return "" - header = examples[0].get("tableHeader") - body = examples[0].get("tableBody", []) - all_rows: list[list[str]] = [] - if header: - all_rows.append([cell.get("value", "") for cell in header.get("cells", [])]) - for row in body: - all_rows.append([cell.get("value", "") for cell in row.get("cells", [])]) - if not all_rows: - return "Examples:" - col_widths = _compute_col_widths(all_rows) - lines = ["Examples:"] + [ - " " + _render_padded_row(row, col_widths) for row in all_rows - ] - return "\n".join(lines) - - -def _build_step(raw: dict[str, Any]) -> ParsedStep: - """Build a ParsedStep from a Gherkin AST step dict. - - Args: - raw: A step dict from the Gherkin AST. - - Returns: - A ParsedStep. - """ - doc_string: str | None = None - data_table: str | None = None - if "docString" in raw: - doc_string = raw["docString"].get("content", "") - if "dataTable" in raw: - data_table = _render_data_table(raw["dataTable"].get("rows", [])) - return ParsedStep( - keyword=raw["keyword"].strip(), - text=raw.get("text", ""), - doc_string=doc_string, - data_table=data_table, - ) - - -def _build_steps(raw_steps: list[dict[str, Any]]) -> tuple[ParsedStep, ...]: - """Build a tuple of ParsedStep from AST step list. - - Args: - raw_steps: List of step dicts. - - Returns: - Tuple of ParsedStep. - """ - return tuple(_build_step(s) for s in raw_steps) - - -def _extract_background( - children: list[dict[str, Any]], -) -> tuple[ParsedStep, ...] | None: - """Extract background steps from a list of AST children. - - Args: - children: Child dicts from the Gherkin AST. - - Returns: - Tuple of ParsedStep, or None if no Background. - """ - for child in children: - background = child.get("background") - if background is not None: - return _build_steps(background.get("steps", [])) - return None - - -def _extract_id_from_tags(tags: list[dict[str, Any]]) -> str | None: - """Find the @id: value from Gherkin AST tags. - - Args: - tags: List of tag dicts. - - Returns: - 8-char hex ID or None. - """ - for tag in tags: - match = _ID_TAG_RE.search(tag.get("name", "")) - if match: - return match.group(1) - return None - - -def _has_deprecated_tag(tags: list[dict[str, Any]]) -> bool: - """Check if @deprecated tag is present. - - Args: - tags: List of tag dicts. - - Returns: - True if @deprecated is found. - """ - return any(t["name"] == "@deprecated" for t in tags) - - -def _collect_background_sections( - feature_bg: tuple[ParsedStep, ...] | None, - rule_bg: tuple[ParsedStep, ...] | None, -) -> tuple[tuple[ParsedStep, ...], ...]: - """Collect non-None background step tuples in order. - - Args: - feature_bg: Feature-level background steps. - rule_bg: Rule-level background steps. - - Returns: - Tuple of background step tuples. - """ - sections = [bg for bg in (feature_bg, rule_bg) if bg is not None] - return tuple(sections) - - -def _build_example( - scenario: dict[str, Any], - feature_bg: tuple[ParsedStep, ...] | None, - rule_bg: tuple[ParsedStep, ...] | None, - parent_deprecated: bool = False, -) -> ParsedExample | None: - """Build a ParsedExample from a scenario dict. - - Args: - scenario: Gherkin AST scenario dict. - feature_bg: Feature-level background steps. - rule_bg: Rule-level background steps. - parent_deprecated: True if a parent (rule or feature) is deprecated. - - Returns: - ParsedExample or None if no @id tag. - """ - tags = scenario.get("tags", []) - id_str = _extract_id_from_tags(tags) - if id_str is None: - return None - outline_examples = scenario.get("examples", []) - return ParsedExample( - example_id=ExampleId(id_str), - steps=_build_steps(scenario.get("steps", [])), - background_sections=_collect_background_sections(feature_bg, rule_bg), - outline_examples=( - _render_examples_table(outline_examples) if outline_examples else None - ), - is_deprecated=parent_deprecated or _has_deprecated_tag(tags), - ) - - -def _example_from_child( - child: dict[str, Any], - feature_bg: tuple[ParsedStep, ...] | None, - rule_bg: tuple[ParsedStep, ...] | None, - rule_deprecated: bool, -) -> ParsedExample | None: - """Return a ParsedExample from a rule child dict, or None if not a scenario. - - Args: - child: A child dict from the rule's Gherkin AST. - feature_bg: Feature-level background steps. - rule_bg: Rule-level background steps. - rule_deprecated: True if the rule is deprecated. - - Returns: - ParsedExample or None. - """ - scenario = child.get("scenario") - if scenario is None: - return None - return _build_example(scenario, feature_bg, rule_bg, rule_deprecated) - - -def _parse_rule_examples( - rule_children: list[dict[str, Any]], - feature_bg: tuple[ParsedStep, ...] | None, - rule_bg: tuple[ParsedStep, ...] | None, - rule_deprecated: bool, -) -> tuple[ParsedExample, ...]: - """Parse all examples from rule children. - - Args: - rule_children: Child dicts from the rule's Gherkin AST. - feature_bg: Feature-level background steps. - rule_bg: Rule-level background steps. - rule_deprecated: True if the rule is deprecated. - - Returns: - Tuple of ParsedExample. - """ - candidates = ( - _example_from_child(child, feature_bg, rule_bg, rule_deprecated) - for child in rule_children - ) - return tuple(ex for ex in candidates if ex is not None) - - -def _parse_rule( - rule: dict[str, Any], - feature_bg: tuple[ParsedStep, ...] | None, - feature_deprecated: bool = False, -) -> ParsedRule: - """Parse a Rule block into a ParsedRule. - - Args: - rule: Rule dict from the Gherkin AST. - feature_bg: Feature-level background steps. - feature_deprecated: True if the parent feature is deprecated. - - Returns: - A ParsedRule. - """ - title = rule.get("name", "") - rule_children = rule.get("children", []) - rule_deprecated = feature_deprecated or _has_deprecated_tag(rule.get("tags", [])) - rule_bg = _extract_background(rule_children) - examples = _parse_rule_examples(rule_children, feature_bg, rule_bg, rule_deprecated) - return ParsedRule( - title=title, - rule_slug=RuleSlug.from_rule_title(title), - examples=examples, - is_deprecated=rule_deprecated, - ) - - -def _empty_feature(path: Path, feature_slug: FeatureSlug) -> ParsedFeature: - """Return an empty ParsedFeature for a file with no feature block. - - Args: - path: Path to the .feature file. - feature_slug: The feature slug. - - Returns: - ParsedFeature with no rules or examples. - """ - return ParsedFeature( - path=path, - feature_slug=feature_slug, - rules=(), - top_level_examples=(), - is_deprecated=False, - ) - - -def _parse_child( - child: dict[str, Any], - feature_bg: tuple[ParsedStep, ...] | None, - feature_deprecated: bool, - rules: list[ParsedRule], - top_level: list[ParsedExample], -) -> None: - """Parse one feature child into rules or top-level examples. - - Args: - child: A child dict from the Gherkin AST. - feature_bg: Feature-level background steps. - feature_deprecated: True if the feature is deprecated. - rules: List to append ParsedRule to. - top_level: List to append ParsedExample to. - """ - rule_node = child.get("rule") - if rule_node is not None: - rules.append(_parse_rule(rule_node, feature_bg, feature_deprecated)) - return - scenario = child.get("scenario") - if scenario is None: - return - ex = _build_example(scenario, feature_bg, None, feature_deprecated) - if ex is not None: - top_level.append(ex) - - -def _parse_children( - children: list[dict[str, Any]], - feature_bg: tuple[ParsedStep, ...] | None, - feature_deprecated: bool, -) -> tuple[tuple[ParsedRule, ...], tuple[ParsedExample, ...]]: - """Parse the children of a feature block into rules and top-level examples. - - Args: - children: Child dicts from the Gherkin AST. - feature_bg: Feature-level background steps. - feature_deprecated: True if the feature is deprecated. - - Returns: - Tuple of (rules, top_level_examples). - """ - rules: list[ParsedRule] = [] - top_level: list[ParsedExample] = [] - for child in children: - _parse_child(child, feature_bg, feature_deprecated, rules, top_level) - return tuple(rules), tuple(top_level) - - -def parse_feature( - path: Path, - folder_name: str | None = None, - parser: GherkinParserProtocol | None = None, -) -> ParsedFeature: - """Parse a .feature file into a ParsedFeature. - - Args: - path: Path to the .feature file. - folder_name: The feature folder name. Defaults to path.parent.name. - parser: Optional Gherkin parser instance. Defaults to GherkinParser(). - - Returns: - A ParsedFeature with all examples. - """ - if folder_name is None: - parent = path.parent - folder_name = parent.name - if parser is None: - parser = GherkinParser() - doc = parser.parse(path.read_text(encoding="utf-8")) - feature = cast(dict[str, Any] | None, doc.get("feature")) - feature_slug = FeatureSlug.from_folder_name(folder_name) - if not feature: - return _empty_feature(path, feature_slug) - children = feature.get("children", []) - feature_deprecated = _has_deprecated_tag(feature.get("tags", [])) - feature_bg = _extract_background(children) - rules, top_level = _parse_children(children, feature_bg, feature_deprecated) - return ParsedFeature( - path=path, - feature_slug=feature_slug, - rules=rules, - top_level_examples=top_level, - is_deprecated=feature_deprecated, - ) - - -def collect_all_example_ids(feature: ParsedFeature) -> set[ExampleId]: - """Collect all example IDs from a parsed feature. - - Args: - feature: A ParsedFeature. - - Returns: - Set of ExampleId objects. - """ - return feature.all_example_ids() diff --git a/pytest_beehave/hatch.py b/pytest_beehave/hatch.py deleted file mode 100644 index 0742864..0000000 --- a/pytest_beehave/hatch.py +++ /dev/null @@ -1,215 +0,0 @@ -"""Hatch command — generate bee-themed example features directory.""" - -from __future__ import annotations - -import secrets -from dataclasses import dataclass -from pathlib import Path - -_FEATURE_NAMES = [ - "The Forager's Journey", - "Queen's Decree", - "Drone Assembly Protocol", - "Worker Bee Orientation", - "Nectar Collection Workflow", - "Hive Temperature Regulation", - "Pollen Scout Dispatch", - "Royal Jelly Production", - "Swarm Formation Ritual", - "Honeycomb Architecture Review", -] - -_BEES = [ - "Beatrice", - "Boris", - "Belinda", - "Bruno", - "Blossom", - "Barnaby", - "Bridget", - "Bertram", -] -_HIVES = ["the Golden Hive", "the Amber Hive", "the Crystal Hive", "the Obsidian Hive"] - -_BACKLOG_CONTENT = """\ -Feature: {feature_name} - - As {bee}, a worker bee in {hive} - I want to complete my assigned foraging route - So that the colony has enough nectar for the season - - Rule: Forager readiness - - @id:hatch001 - Example: Forager departs when pollen reserve is below threshold - Given the pollen reserve is below 30 percent - When the forager sensor detects the shortage - Then {bee} departs for the meadow within one waggle cycle - - Example: Untagged scenario triggers auto-ID assignment - Given the hive registers a new forager - When the forager completes orientation - Then the forager is assigned a unique scout ID - - @deprecated - Example: Legacy hive-entry handshake (deprecated) - Given an older forager approaches the hive entrance - When the guard bee checks the legacy handshake - Then the handshake is accepted but logged as deprecated - - Rule: Nectar quality control - - @id:hatch002 - Example: Low-quality nectar is rejected at the gate - Given a forager returns with nectar of quality below 0.4 brix - When the gate inspector evaluates the sample - Then the nectar is rejected and the forager is sent to a higher-quality source -""" - -_IN_PROGRESS_CONTENT = """\ -# language: en -Feature: Waggle Dance Communication - - Background: - Given the hive is in active foraging mode - And the dance floor is clear of obstacles - - Rule: Direction encoding - - @id:hatch003 - Example: Scout encodes flower direction in waggle run angle - Given a scout has located flowers 200 metres to the north-east - When the scout performs the waggle dance - Then the waggle run angle matches the sun-relative bearing to the flowers - - Rule: Distance encoding - - @id:hatch004 - Scenario Outline: Scout encodes distance via waggle run duration - Given a scout has located flowers at metres - When the scout performs the waggle dance - Then the waggle run lasts approximately milliseconds - - Examples: - | distance | duration | - | 100 | 250 | - | 500 | 875 | - | 1000 | 1500 | - - @id:hatch005 - Example: Scout provides a data table of visited flower patches - Given the scout returns from a multi-patch forage - When the scout performs the waggle dance - Then the flower patch register contains the following entries: - | patch_id | species | quality | - | P-001 | Lavender | 0.92 | - | P-002 | Clover | 0.85 | - | P-003 | Sunflower | 0.78 | -""" - -_COMPLETED_CONTENT = """\ -Feature: Colony Winter Preparation - - As {bee}, the winter logistics coordinator in {hive} - I want to ensure honey stores are sufficient before the first frost - So that the colony survives the winter without starvation - - Rule: Honey reserve verification - - @id:hatch006 - Example: Winter preparation passes when honey reserve exceeds minimum - Given the honey reserve is at 85 percent capacity - When the winter readiness check is performed - Then the colony status is set to WINTER-READY - - @id:hatch007 - Example: Winter preparation fails when honey reserve is insufficient - Given the honey reserve is below 60 percent capacity - When the winter readiness check is performed - Then the colony status is set to AT-RISK and an alert is raised for {bee} -""" - - -@dataclass(frozen=True, slots=True) -class HatchFile: - """A single generated .feature file to be written. - - Attributes: - relative_path: Path relative to the features root (e.g. ``backlog/x.feature``). - content: The full Gherkin text to write. - """ - - relative_path: str - content: str - - -def generate_hatch_files() -> list[HatchFile]: - """Generate bee-themed example .feature files using stdlib randomisation. - - Returns: - A list of HatchFile objects ready to be written to disk. - """ - feature_name = secrets.choice(_FEATURE_NAMES) - bee = secrets.choice(_BEES) - hive = secrets.choice(_HIVES) - - return [ - HatchFile( - relative_path="backlog/forager-journey.feature", - content=_BACKLOG_CONTENT.format( - feature_name=feature_name, bee=bee, hive=hive - ), - ), - HatchFile( - relative_path="in-progress/waggle-dance.feature", - content=_IN_PROGRESS_CONTENT, - ), - HatchFile( - relative_path="completed/winter-preparation.feature", - content=_COMPLETED_CONTENT.format(bee=bee, hive=hive), - ), - ] - - -def write_hatch(features_root: Path, files: list[HatchFile]) -> list[str]: - """Write HatchFile objects to disk under features_root. - - Args: - features_root: The root features directory to write into. - files: The list of HatchFile objects to write. - - Returns: - List of written file paths as strings (relative to features_root). - """ - written: list[str] = [] - for hatch_file in files: - dest = features_root / hatch_file.relative_path - dest.parent.mkdir(parents=True, exist_ok=True) - dest.write_text(hatch_file.content, encoding="utf-8") - written.append(hatch_file.relative_path) - return written - - -def run_hatch(features_root: Path, force: bool) -> list[str]: - """Run the hatch command: check for conflicts, generate, and write files. - - Args: - features_root: The root features directory to populate. - force: If True, overwrite existing content without error. - - Returns: - List of written file paths as strings. - - Raises: - SystemExit: If the directory contains .feature files and force is False. - """ - existing = list(features_root.rglob("*.feature")) if features_root.exists() else [] - if existing and not force: - conflict = existing[0] - raise SystemExit( - f"[beehave] hatch aborted: existing .feature files found at {conflict}. " - "Use --beehave-hatch-force to overwrite." - ) - for old_file in existing: - old_file.unlink() - return write_hatch(features_root, generate_hatch_files()) diff --git a/pytest_beehave/html_column.py b/pytest_beehave/html_column.py new file mode 100644 index 0000000..f20f450 --- /dev/null +++ b/pytest_beehave/html_column.py @@ -0,0 +1,35 @@ +"""HTML column — adds a Scenario column to pytest-html reports.""" + +from __future__ import annotations + +import html + +import pytest + +_SCENARIO_HEADER = ( + 'Scenario' +) +_SCENARIO_CELL = ( + '{content}' +) + + +class HtmlStepsPlugin: + """Adds a Scenario column with BDD steps to pytest-html reports.""" + + def pytest_html_results_table_header( + self, + cells: list[object], + ) -> None: + """Insert the Scenario column header after Test.""" + cells.insert(2, _SCENARIO_HEADER) + + def pytest_html_results_table_row( + self, + report: pytest.TestReport, + cells: list[object], + ) -> None: + """Insert the Scenario cell for each test row.""" + steps = getattr(report, "_beehave_steps", None) + content = html.escape(steps) if steps else "" + cells.insert(2, _SCENARIO_CELL.format(content=content)) diff --git a/pytest_beehave/html_steps_plugin.py b/pytest_beehave/html_steps_plugin.py deleted file mode 100644 index 84dd50c..0000000 --- a/pytest_beehave/html_steps_plugin.py +++ /dev/null @@ -1,33 +0,0 @@ -"""HTML Acceptance Criteria column plugin for pytest-beehave.""" - -from __future__ import annotations - -import pytest - - -class HtmlStepsPlugin: - """Adds an Acceptance Criteria column to pytest-html reports.""" - - def pytest_html_results_table_header(self, cells: list[object]) -> None: - """Insert the Acceptance Criteria column header. - - Args: - cells: The list of header cells to modify. - """ - cells.insert(2, "Acceptance Criteria") - - def pytest_html_results_table_row( - self, report: pytest.TestReport, cells: list[object] - ) -> None: - """Insert the Acceptance Criteria column value for each row. - - Args: - report: The test report for this row. - cells: The list of row cells to modify. - """ - nodeid = report.nodeid - if "tests/features/" in nodeid: - docstring = getattr(report, "_beehave_docstring", "") or "" - else: - docstring = "" - cells.insert(2, f"{docstring}") diff --git a/pytest_beehave/id_generator.py b/pytest_beehave/id_generator.py deleted file mode 100644 index 3560f92..0000000 --- a/pytest_beehave/id_generator.py +++ /dev/null @@ -1,205 +0,0 @@ -"""ID assignment for pytest-beehave .feature files.""" - -import itertools -import os -import re -import secrets -from collections.abc import Iterator -from pathlib import Path - -FEATURE_STAGES: tuple[str, ...] = ("backlog", "in-progress", "completed") -_EXAMPLE_LINE_RE: re.Pattern[str] = re.compile(r"^(\s+)Example:", re.MULTILINE) -_ID_TAG_RE: re.Pattern[str] = re.compile(r"@id:[a-f0-9]{8}") - - -def _collect_existing_ids(content: str) -> set[str]: - """Collect all @id hex values already present in file content. - - Args: - content: Full text of a .feature file. - - Returns: - Set of 8-char hex strings found in @id tags. - """ - return set(re.findall(r"@id:([a-f0-9]{8})", content)) - - -def _candidate_stream() -> Iterator[str]: - """Yield an infinite stream of random 8-char hex candidates. - - Yields: - Random 8-char hex strings. - """ - while True: - yield secrets.token_hex(4) - - -def _generate_unique_id(existing_ids: set[str]) -> str: - """Generate a unique 8-char hex ID not already in existing_ids. - - Args: - existing_ids: Set of IDs already used in the current file. - - Returns: - A new unique 8-char hex string. - """ - return next(c for c in _candidate_stream() if c not in existing_ids) - - -def _prepend_id_tag(line: str, result: list[str], existing_ids: set[str]) -> None: - """Prepend an @id tag line before an Example line if not already tagged. - - Mutates result and existing_ids in-place. - - Args: - line: The current line being processed. - result: Accumulated output lines so far. - existing_ids: Set of IDs already used (mutated when a new ID is added). - """ - match = _EXAMPLE_LINE_RE.match(line) - if match is None: - return - if _id_tag_precedes(result): - return - new_id = _generate_unique_id(existing_ids) - existing_ids.add(new_id) - indent = match.group(1) - result.append(f"{indent}@id:{new_id}\n") - - -def _insert_id_before_example(content: str, existing_ids: set[str]) -> str: - """Insert @id tags before each untagged Example line. - - Args: - content: Full text of a .feature file. - existing_ids: Set of IDs already present in the file. - - Returns: - Updated file content with @id tags inserted. - """ - lines = content.splitlines(keepends=True) - result: list[str] = [] - for line in lines: - _prepend_id_tag(line, result, existing_ids) - result.append(line) - return "".join(result) - - -def _id_tag_precedes(lines: list[str]) -> bool: - """Check if the last non-empty line is an @id tag. - - Args: - lines: Lines accumulated so far. - - Returns: - True if the previous non-empty line contains an @id tag. - """ - last_non_empty = next((ln.strip() for ln in reversed(lines) if ln.strip()), "") - return bool(_ID_TAG_RE.search(last_non_empty)) - - -def _process_writable_file(feature_path: Path) -> None: - """Insert @id tags into a writable .feature file for untagged Examples. - - Args: - feature_path: Path to the .feature file to process. - """ - content = feature_path.read_text(encoding="utf-8") - existing_ids = _collect_existing_ids(content) - updated = _insert_id_before_example(content, existing_ids) - if updated != content: - feature_path.write_text(updated, encoding="utf-8") - - -def _missing_id_error( - feature_path: Path, line: str, preceding: list[str] -) -> str | None: - """Return an error string if this Example line has no preceding @id tag. - - Args: - feature_path: Path to the feature file (used in error message). - line: The current line from the feature file. - preceding: All lines before this one. - - Returns: - Error string if an @id tag is missing, or None. - """ - if not _EXAMPLE_LINE_RE.match(line): - return None - if _id_tag_precedes(preceding): - return None - title = line.strip().removeprefix("Example:").strip() - return f"{feature_path}: Example '{title}' has no @id" - - -def _check_readonly_file(feature_path: Path) -> list[str]: - """Collect error messages for untagged Examples in a read-only file. - - Args: - feature_path: Path to the read-only .feature file. - - Returns: - List of error strings, one per untagged Example. - """ - lines = feature_path.read_text(encoding="utf-8").splitlines() - errors = [ - _missing_id_error(feature_path, line, lines[:index]) - for index, line in enumerate(lines) - ] - return [e for e in errors if e is not None] - - -def _process_feature_file(feature_path: Path) -> list[str]: - """Process a single .feature file: write IDs or collect errors. - - Args: - feature_path: Path to the .feature file. - - Returns: - List of error strings (empty if writable or no untagged Examples). - """ - if os.access(feature_path, os.W_OK): - _process_writable_file(feature_path) - return [] - return _check_readonly_file(feature_path) - - -def _process_stage(features_dir: Path, stage: str) -> list[str]: - """Process all .feature files in a single stage directory. - - Args: - features_dir: Root features directory. - stage: Stage subdirectory name (e.g. "in-progress"). - - Returns: - List of error strings from read-only files with missing @id tags. - """ - stage_dir = features_dir / stage - if not stage_dir.exists(): - return [] - return list( - itertools.chain.from_iterable( - _process_feature_file(p) for p in sorted(stage_dir.rglob("*.feature")) - ) - ) - - -def assign_ids(features_dir: Path) -> list[str]: - """Assign @id tags to untagged Examples in all .feature files. - - For writable files, inserts @id tags in-place. For read-only files, - returns error strings instead of modifying the file. - - Args: - features_dir: Root directory containing backlog/, in-progress/, - and completed/ subdirectories with .feature files. - - Returns: - List of error strings for read-only files with missing @id tags. - Empty list means all Examples are tagged or files are writable. - """ - return list( - itertools.chain.from_iterable( - _process_stage(features_dir, stage) for stage in FEATURE_STAGES - ) - ) diff --git a/pytest_beehave/models.py b/pytest_beehave/models.py deleted file mode 100644 index 1713b71..0000000 --- a/pytest_beehave/models.py +++ /dev/null @@ -1,83 +0,0 @@ -"""Shared value objects for pytest-beehave.""" - -from __future__ import annotations - -from dataclasses import dataclass -from enum import Enum - - -class FeatureStage(Enum): - """The lifecycle stage of a feature folder.""" - - BACKLOG = "backlog" - IN_PROGRESS = "in-progress" - COMPLETED = "completed" - - -@dataclass(frozen=True, slots=True) -class ExampleId: - """An 8-char hex identifier for a Gherkin Example. - - Attributes: - value: The 8-character lowercase hexadecimal string. - """ - - value: str - - def __str__(self) -> str: - """Return the hex string representation.""" - return self.value - - -@dataclass(frozen=True, slots=True) -class FeatureSlug: - """A Python-safe slug derived from a feature folder name. - - Attributes: - value: Lowercase, underscore-separated identifier. - """ - - value: str - - def __str__(self) -> str: - """Return the slug string.""" - return self.value - - @classmethod - def from_folder_name(cls, name: str) -> "FeatureSlug": - """Create a FeatureSlug from a kebab-case folder name. - - Args: - name: The feature folder name (may contain hyphens). - - Returns: - A FeatureSlug with hyphens replaced by underscores. - """ - return cls(name.replace("-", "_").lower()) - - -@dataclass(frozen=True, slots=True) -class RuleSlug: - """A file-safe slug derived from a Rule block title. - - Attributes: - value: Lowercase, underscore-separated identifier. - """ - - value: str - - def __str__(self) -> str: - """Return the slug string.""" - return self.value - - @classmethod - def from_rule_title(cls, title: str) -> "RuleSlug": - """Create a RuleSlug from a Rule block title. - - Args: - title: The Rule: title text. - - Returns: - A RuleSlug with spaces and hyphens replaced by underscores, lowercased. - """ - return cls(title.strip().replace("-", "_").replace(" ", "_").lower()) diff --git a/pytest_beehave/plugin.py b/pytest_beehave/plugin.py index 235328c..f5a524b 100644 --- a/pytest_beehave/plugin.py +++ b/pytest_beehave/plugin.py @@ -1,178 +1,232 @@ -"""pytest plugin entry point for pytest-beehave.""" +"""pytest plugin entry point — orchestrates beehave during the pytest lifecycle.""" from __future__ import annotations import importlib.util import sys +from collections.abc import Generator from pathlib import Path +from typing import Any import pytest +from beehave.check import check_all +from beehave.config import Config, load_config +from beehave.generate import generate_stubs +from beehave.gherkin import parse_feature +from beehave.models import ScenarioInfo, Violation + +_beehave_config_key: pytest.StashKey[Config] = pytest.StashKey() +_scenarios_key: pytest.StashKey[dict[str, ScenarioInfo]] = pytest.StashKey() +_error_violations_key: pytest.StashKey[list[Violation]] = pytest.StashKey() + + +class BeehaveViolationItem(pytest.Item): + """Synthetic test item that fails, representing a beehave ERROR.""" + + def __init__( # noqa: D107 + self, + *, + violation: Violation, + **kwargs: Any, # noqa: ANN401 + ) -> None: + safe_name = violation.error_type.replace("-", "_") + super().__init__(name=f"beehave_{safe_name}", **kwargs) + self.violation = violation + + def runtest(self) -> None: + """Execute the test — always raises AssertionError.""" + raise AssertionError(f"[beehave] {self.violation}") + + def repr_failure( + self, + excinfo: pytest.ExceptionInfo[BaseException], + style: str | None = None, + ) -> str: + """Return a string representation of the failure.""" + return str(excinfo.value) + + def reportinfo(self) -> tuple[str, int, str]: + """Return location info for the test report.""" + return ( + str(self.violation.path), + self.violation.line, + f"[beehave] {self.violation.error_type}: {self.violation.message}", + ) -from pytest_beehave.bootstrap import bootstrap_features_directory -from pytest_beehave.config import ( - is_explicitly_configured, - read_stub_format, - resolve_features_path, - show_steps_in_html, - show_steps_in_terminal, -) -from pytest_beehave.hatch import run_hatch -from pytest_beehave.html_steps_plugin import HtmlStepsPlugin -from pytest_beehave.id_generator import assign_ids -from pytest_beehave.reporter import ( - report_bootstrap, - report_id_write_back, - report_sync_actions, -) -from pytest_beehave.steps_reporter import StepsReporter -from pytest_beehave.sync_engine import run_sync - -features_path_key: pytest.StashKey[Path] = pytest.StashKey() - - -class _PytestTerminalWriter: - """Adapter wrapping pytest's terminal writer to match TerminalWriterProtocol.""" - - def __init__(self, config: pytest.Config) -> None: - """Initialise the adapter. - - Args: - config: The pytest Config object. - """ - self._config = config - - def line(self, text: str = "") -> None: - """Write a line to the terminal. - - Args: - text: The line to write. - """ - try: - config = self._config - writer = config.get_terminal_writer() - writer.line(text) - except (AssertionError, AttributeError): - sys.stdout.write(text + "\n") - sys.stdout.flush() - - -def _exit_if_missing_configured_path(rootdir: Path, path: Path) -> None: - """Exit pytest if features_path is explicitly configured but missing. - - Args: - rootdir: Project root directory. - path: Resolved features path. - """ - if not path.exists() and is_explicitly_configured(rootdir): - message = f"[beehave] features_path not found: {path}" - sys.stderr.write(message + "\n") - sys.stderr.flush() - pytest.exit(message, returncode=1) - - -def _run_beehave_sync(config: pytest.Config, path: Path) -> None: - """Bootstrap, assign IDs, and sync stubs for the features directory. - - Args: - config: The pytest Config object. - path: The resolved features directory path. - """ - writer = _PytestTerminalWriter(config) - report_bootstrap(writer, bootstrap_features_directory(path)) - errors = assign_ids(path) - report_id_write_back(writer, errors) - if errors: - pytest.exit("[beehave] untagged Examples in read-only files", returncode=1) + +def _html_available() -> bool: + return importlib.util.find_spec("pytest_html") is not None + + +def _format_scenario_steps(scenario: ScenarioInfo) -> str: + lines = [f"{step.keyword} {step.text}" for step in scenario.steps] + return "\n".join(lines) + + +def _write_line(config: pytest.Config, text: str) -> None: try: - stub_format = read_stub_format(config.rootpath) - except SystemExit as exc: - pytest.exit(str(exc), returncode=1) - report_sync_actions( - writer, - run_sync(path, config.rootpath / "tests" / "features", stub_format=stub_format), - ) + config.get_terminal_writer().line(text) + except AssertionError, AttributeError: + sys.stderr.write(text + "\n") + sys.stderr.flush() -def _html_available() -> bool: - """Return True if pytest-html is importable. +def _report_violation(config: pytest.Config, v: Violation) -> None: + level = "WARNING" if v.is_warning else "ERROR" + _write_line(config, f"[beehave] {level}: {v}") - Returns: - True when pytest-html is installed. - """ - return importlib.util.find_spec("pytest_html") is not None +_SKIP_MARKER = '@pytest.mark.skip(reason="not implemented")' -@pytest.hookimpl(hookwrapper=True) -def pytest_runtest_makereport(item: pytest.Item, call: pytest.CallInfo[None]) -> object: - """Attach the test docstring to the report for steps display. - - Args: - item: The test item being reported. - call: The call info (unused). - """ - outcome = yield - report = outcome.get_result() - test_object = getattr(item, "obj", None) - report._beehave_docstring = ( - getattr(test_object, "__doc__", None) or "" if test_object is not None else "" - ) +def _add_skip_markers(tests_dir: Path) -> None: + """Add @pytest.mark.skip(reason='not implemented') to stub functions.""" + for test_file in tests_dir.rglob("*.py"): + source = test_file.read_text(encoding="utf-8") + if "..." not in source: + continue -def _register_output_plugins(config: pytest.Config, rootdir: Path) -> None: - """Register terminal and HTML output plugins based on configuration. - - Args: - config: The pytest Config object. - rootdir: Project root directory. - """ - pm = config.pluginmanager - if show_steps_in_terminal(rootdir): - pm.register(StepsReporter(config), "beehave-steps-reporter") - if show_steps_in_html(rootdir) and _html_available(): - pm.register(HtmlStepsPlugin(), "beehave-html-steps") - - -def pytest_addoption(parser: pytest.Parser) -> None: - """Register --beehave-hatch and --beehave-hatch-force CLI options. - - Args: - parser: The pytest argument parser. - """ - group = parser.getgroup("beehave") - group.addoption( - "--beehave-hatch", - action="store_true", - default=False, - help="Generate bee-themed example features directory and exit.", - ) - group.addoption( - "--beehave-hatch-force", - action="store_true", - default=False, - help="Overwrite existing content when using --beehave-hatch.", - ) + lines = source.split("\n") + changed = False + new_lines: list[str] = [] + i = 0 + while i < len(lines): + line = lines[i] + stripped = line.strip() -def pytest_configure(config: pytest.Config) -> None: - """Read beehave configuration, bootstrap directory, sync stubs. + if stripped == "..." and new_lines: + prev = new_lines[-1] + if prev.lstrip().startswith("def "): + def_indent = prev[: len(prev) - len(prev.lstrip())] + insert_idx = len(new_lines) - 1 + while insert_idx > 0 and ( + new_lines[insert_idx - 1].lstrip().startswith("@") + ): + insert_idx -= 1 + already_marked = ( + insert_idx < len(new_lines) + and "@pytest.mark.skip" in new_lines[insert_idx] + ) + if not already_marked: + new_lines.insert(insert_idx, f"{def_indent}{_SKIP_MARKER}") + changed = True - Args: - config: The pytest Config object (provides rootdir and stash). - """ - rootdir = config.rootpath - path = resolve_features_path(rootdir) - if config.getoption("--beehave-hatch", default=False): - force = bool(config.getoption("--beehave-hatch-force", default=False)) + new_lines.append(line) + i += 1 + + if not changed: + continue + + source = "\n".join(new_lines) + if "import pytest" not in source: + source = "import pytest\n\n" + source + + test_file.write_text(source, encoding="utf-8") + + +def _collect_scenarios_and_generate( + features_dir: Path, + config: Config, + pytest_config: pytest.Config, +) -> dict[str, ScenarioInfo]: + scenarios: dict[str, ScenarioInfo] = {} + seen_names: dict[str, str] = {} + + for feature_file in sorted(features_dir.rglob("*.feature")): + try: + parsed = parse_feature(feature_file, config, seen_names) + except Exception as exc: + msg = f"[beehave] PARSE ERROR: {feature_file}: {exc}" + _write_line(pytest_config, msg) + continue + scenarios.update(parsed) + + rel = feature_file.relative_to(features_dir) + feature_path_str = str(rel.with_suffix("")) try: - written = run_hatch(path, force) - except SystemExit as exc: - pytest.exit(str(exc), returncode=1) - writer = _PytestTerminalWriter(config) - for entry in written: - writer.line(f"[beehave] HATCH {entry}") - pytest.exit("[beehave] hatch complete", returncode=0) - _exit_if_missing_configured_path(rootdir, path) - config.stash[features_path_key] = path - if path.exists(): - _run_beehave_sync(config, path) - _register_output_plugins(config, rootdir) + generate_stubs(feature_path_str, config) + except Exception as exc: + msg = f"[beehave] GENERATE ERROR: {feature_file}: {exc}" + _write_line(pytest_config, msg) + + return scenarios + + +def pytest_configure(config: pytest.Config) -> None: + """Parse features, generate stubs, check violations, register reporters.""" + rootdir = config.rootpath + beehave_config = load_config(rootdir) + config.stash[_beehave_config_key] = beehave_config + + features_dir = rootdir / beehave_config.features_dir + if not features_dir.exists(): + return + + scenarios = _collect_scenarios_and_generate( + features_dir, + beehave_config, + config, + ) + config.stash[_scenarios_key] = scenarios + + _add_skip_markers(rootdir / beehave_config.tests_dir) + + violations = check_all(beehave_config) + error_violations: list[Violation] = [] + for v in violations: + _report_violation(config, v) + if not v.is_warning: + error_violations.append(v) + config.stash[_error_violations_key] = error_violations + + if (config.getoption("verbose", default=0) or 0) >= 1: + from pytest_beehave.steps_display import StepsReporter + + config.pluginmanager.register( + StepsReporter(config), + "beehave-steps-reporter", + ) + + if _html_available(): + from pytest_beehave.html_column import HtmlStepsPlugin + + config.pluginmanager.register( + HtmlStepsPlugin(), + "beehave-html-steps", + ) + + +def pytest_collection_modifyitems( + session: pytest.Session, + items: list[pytest.Item], +) -> None: + """Inject synthetic failing tests for each ERROR violation.""" + error_violations = session.config.stash.get(_error_violations_key, []) + for v in error_violations: + item = BeehaveViolationItem.from_parent( + parent=session, + violation=v, + ) + items.append(item) + + +@pytest.hookimpl(hookwrapper=True) +def pytest_runtest_makereport( + item: pytest.Item, + call: pytest.CallInfo[None], +) -> Generator[None, None, None]: + """Attach BDD steps to the test report for display plugins.""" + outcome: pytest.TestReport = yield # type: ignore[assignment] + report = outcome.get_result() + stash = item.config.stash + scenarios: dict[str, ScenarioInfo] | None = stash.get( + _scenarios_key, + None, + ) + if scenarios is None: + return + func_name = getattr(item, "originalname", item.name) + scenario = scenarios.get(func_name) + if scenario is not None: + report._beehave_steps = _format_scenario_steps(scenario) diff --git a/pytest_beehave/reporter.py b/pytest_beehave/reporter.py deleted file mode 100644 index aa79cde..0000000 --- a/pytest_beehave/reporter.py +++ /dev/null @@ -1,52 +0,0 @@ -"""Terminal reporting for pytest-beehave actions.""" - -from __future__ import annotations - -from typing import Protocol - -from pytest_beehave.bootstrap import BootstrapResult - - -class TerminalWriterProtocol(Protocol): - """Protocol for a terminal writer.""" - - def line(self, text: str = "") -> None: # pragma: no cover - """Write a line to the terminal.""" - ... - - -def report_bootstrap(writer: TerminalWriterProtocol, result: BootstrapResult) -> None: - """Report bootstrap actions to the terminal. - - Args: - writer: Terminal writer to write to. - result: The bootstrap result. - """ - for name in result.created_subfolders: - writer.line(f"[beehave] MKDIR {name}/") - for path in result.migrated_files: - writer.line(f"[beehave] MIGRATE {path}") - for warning in result.collision_warnings: - writer.line(f"[beehave] WARNING {warning}") - - -def report_id_write_back(writer: TerminalWriterProtocol, errors: list[str]) -> None: - """Report ID write-back errors to the terminal. - - Args: - writer: Terminal writer to write to. - errors: List of error strings from assign_ids. - """ - for error in errors: - writer.line(f"[beehave] ERROR: {error}") - - -def report_sync_actions(writer: TerminalWriterProtocol, actions: list[str]) -> None: - """Report sync actions to the terminal. - - Args: - writer: Terminal writer to write to. - actions: List of action description strings. - """ - for action in actions: - writer.line(f"[beehave] {action}") diff --git a/pytest_beehave/steps_display.py b/pytest_beehave/steps_display.py new file mode 100644 index 0000000..1d829eb --- /dev/null +++ b/pytest_beehave/steps_display.py @@ -0,0 +1,27 @@ +"""Terminal steps display — prints BDD steps under each test name.""" + +from __future__ import annotations + +import pytest + + +class StepsReporter: + """Prints BDD steps to the terminal when --beehave-verbose is active.""" + + def __init__(self, config: pytest.Config) -> None: + """Store pytest config for terminal writer access.""" + self._config = config + + def pytest_runtest_logreport( + self, + report: pytest.TestReport, + ) -> None: + """Print steps after each test call phase report.""" + if report.when != "call" and not (report.when == "setup" and report.skipped): + return + steps = getattr(report, "_beehave_steps", None) + if not steps: + return + writer = self._config.get_terminal_writer() + indented = "\n".join(f" {line}" for line in steps.splitlines()) + writer.write(f"\n{indented}\n") diff --git a/pytest_beehave/steps_reporter.py b/pytest_beehave/steps_reporter.py deleted file mode 100644 index 4739d19..0000000 --- a/pytest_beehave/steps_reporter.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Terminal steps reporter for pytest-beehave.""" - -from __future__ import annotations - -import sys - -import pytest - - -class StepsReporter: - """Prints BDD step docstrings to the terminal for tests/features/ tests.""" - - def __init__(self, config: pytest.Config) -> None: - """Initialise the reporter. - - Args: - config: The pytest Config object. - """ - self._config = config - - def pytest_runtest_logreport(self, report: pytest.TestReport) -> None: - """Print steps docstring after each test call phase report. - - Args: - report: The test report for the current phase. - """ - if report.when != "call" and not (report.when == "setup" and report.skipped): - return - config = self._config - option = config.option - verbose = option.verbose - if verbose < 1: - return - nodeid = report.nodeid - if "tests/features/" not in nodeid: - return - docstring = getattr(report, "_beehave_docstring", "") - if not docstring: - return - self._write_steps(docstring.strip()) - - def _write_steps(self, steps: str) -> None: - """Write the steps string to the terminal or stdout fallback. - - Args: - steps: The stripped docstring content to write. - """ - try: - config = self._config - writer = config.get_terminal_writer() - writer.write("\n" + steps + "\n") - except (AssertionError, AttributeError): - sys.stdout.write("\n" + steps + "\n") - sys.stdout.flush() diff --git a/pytest_beehave/stub_reader.py b/pytest_beehave/stub_reader.py deleted file mode 100644 index 06bc02d..0000000 --- a/pytest_beehave/stub_reader.py +++ /dev/null @@ -1,293 +0,0 @@ -"""Test stub reader for pytest-beehave.""" - -from __future__ import annotations - -import re -from dataclasses import dataclass -from pathlib import Path - -from pytest_beehave.models import ExampleId, FeatureSlug - -_ID_SUFFIX_RE: re.Pattern[str] = re.compile(r"test_\w+_([a-f0-9]{8})$") -_FUNC_RE: re.Pattern[str] = re.compile( - r"^( {0,4})def (test_[a-z0-9_]+_([a-f0-9]{8}))\(", - re.MULTILINE, -) -_CLASS_RE: re.Pattern[str] = re.compile(r"^class (Test\w+)", re.MULTILINE) - - -@dataclass(frozen=True, slots=True) -class ExistingStub: - """Represents an existing test stub function found in a test file. - - Attributes: - function_name: The full function name. - example_id: The ExampleId extracted from the function name. - feature_slug: The FeatureSlug inferred from the function name. - class_name: The enclosing class name, or None for top-level. - file_path: Path to the file containing this stub. - markers: Tuple of decorator strings present on the function. - docstring: The docstring body, or empty string. - """ - - function_name: str - example_id: ExampleId - feature_slug: FeatureSlug - class_name: str | None - file_path: Path - markers: tuple[str, ...] - docstring: str - - -def extract_example_id_from_name(name: str) -> ExampleId | None: - """Extract the ExampleId from a test function name. - - Args: - name: The test function name. - - Returns: - ExampleId if found, else None. - """ - match = _ID_SUFFIX_RE.search(name) - if match: - return ExampleId(match.group(1)) - return None - - -def _extract_feature_slug_from_name(name: str) -> FeatureSlug: - """Extract the FeatureSlug from a test function name. - - Args: - name: The test function name like 'test_my_feature_aabbccdd'. - - Returns: - FeatureSlug for 'my_feature'. - """ - # Strip 'test_' prefix and '_<8hex>' suffix - without_prefix = name[len("test_") :] - # remove '_aabbccdd' (9 chars: underscore + 8hex) - without_suffix = without_prefix[:-9] - return FeatureSlug(without_suffix) - - -def _find_triple_quote_end(content: str, start: int, quote: str) -> int: - """Find the end position of a triple-quoted string. - - Args: - content: Full file content. - start: Start position of the opening triple-quote. - quote: The triple-quote delimiter. - - Returns: - Position after the closing triple-quote. - """ - end = content.find(quote, start + 3) - if end == -1: - return len(content) - return end + 3 - - -def _find_string_ranges(content: str) -> list[tuple[int, int]]: - """Find all triple-quoted string ranges in content. - - Args: - content: Full file content. - - Returns: - List of (start, end) ranges. - """ - ranges: list[tuple[int, int]] = [] - pos = 0 - while pos < len(content): - matched = _try_triple_quote(content, pos, ranges) - pos = matched if matched is not None else pos + 1 - return ranges - - -def _try_triple_quote( - content: str, pos: int, ranges: list[tuple[int, int]] -) -> int | None: - """Try to match a triple-quoted string at pos. - - Args: - content: Full file content. - pos: Current position. - ranges: List to append (start, end) to. - - Returns: - New position after the string, or None. - """ - for quote in ('"""', "'''"): - if content[pos : pos + 3] == quote: - end = _find_triple_quote_end(content, pos, quote) - ranges.append((pos, end)) - return end - return None - - -def _in_string(pos: int, string_ranges: list[tuple[int, int]]) -> bool: - """Check if a position is inside a triple-quoted string. - - Args: - pos: Character position. - string_ranges: List of (start, end) ranges. - - Returns: - True if pos is inside any string range. - """ - return any(start < pos < end for start, end in string_ranges) - - -def _extract_docstring(content: str, func_start: int) -> str: - """Extract the docstring body from a function at the given position. - - Args: - content: Full file content. - func_start: Start position of the def line. - - Returns: - Docstring body string, or empty string. - """ - # Find the first triple-quote after the def line - def_end = content.find("\n", func_start) - if def_end == -1: - return "" - after_def = content[def_end:] - stripped = after_def.lstrip("\n") - if not stripped.startswith(' """'): - return "" - open_pos = after_def.find('"""') - close_pos = after_def.find('"""', open_pos + 3) - if close_pos == -1: - return "" - return after_def[open_pos + 3 : close_pos] - - -def _is_decorator_line(stripped: str) -> bool: - """Return True if a stripped line is a decorator. - - Args: - stripped: A stripped source line. - - Returns: - True if the line starts with '@'. - """ - return stripped.startswith("@") - - -def _is_blank_or_comment(stripped: str) -> bool: - """Return True if a stripped line is blank or a comment. - - Args: - stripped: A stripped source line. - - Returns: - True if the line is blank or starts with '#'. - """ - return stripped == "" or stripped.startswith("#") - - -def _collect_markers_reversed(lines: list[str]) -> list[str]: - """Collect decorator strings in reverse order from a list of source lines. - - Scans backwards, accumulating decorator lines until a non-decorator, - non-blank, non-comment line is found. - - Args: - lines: Source lines before a function definition. - - Returns: - Decorator strings in reverse order (innermost first). - """ - markers: list[str] = [] - for line in reversed(lines): - stripped = line.strip() - if _is_decorator_line(stripped): - markers.append(stripped[1:]) - continue - if not _is_blank_or_comment(stripped): - break - return markers - - -def _extract_markers(content: str, func_start: int) -> tuple[str, ...]: - """Extract decorator strings before a function. - - Args: - content: Full file content. - func_start: Start position of the def line. - - Returns: - Tuple of decorator strings (without @ prefix). - """ - lines = content[:func_start].splitlines() - return tuple(reversed(_collect_markers_reversed(lines))) - - -def _extract_class_name(content: str, func_start: int, indent: str) -> str | None: - """Extract the enclosing class name for an indented method. - - Args: - content: Full file content. - func_start: Start position of the def line. - indent: Leading whitespace of the def line. - - Returns: - Class name string, or None for module-level functions. - """ - if not indent: - return None - before = content[:func_start] - class_matches = list(_CLASS_RE.finditer(before)) - if not class_matches: - return None - return class_matches[-1].group(1) - - -def _build_stub( - content: str, - match: re.Match[str], - path: Path, -) -> ExistingStub: - """Build an ExistingStub from a regex match in file content. - - Args: - content: Full file content. - match: Regex match for the function definition. - path: Path to the file. - - Returns: - ExistingStub for the matched function. - """ - indent = match.group(1) - func_name = match.group(2) - example_id = ExampleId(match.group(3)) - return ExistingStub( - function_name=func_name, - example_id=example_id, - feature_slug=_extract_feature_slug_from_name(func_name), - class_name=_extract_class_name(content, match.start(), indent), - file_path=path, - markers=_extract_markers(content, match.start()), - docstring=_extract_docstring(content, match.start()), - ) - - -def read_stubs_from_file(path: Path) -> list[ExistingStub]: - """Read all test stub functions from a test file. - - Args: - path: Path to the test file. - - Returns: - List of ExistingStub objects found in the file. - """ - if not path.exists(): - return [] - content = path.read_text(encoding="utf-8") - string_ranges = _find_string_ranges(content) - return [ - _build_stub(content, match, path) - for match in _FUNC_RE.finditer(content) - if not _in_string(match.start(), string_ranges) - ] diff --git a/pytest_beehave/stub_writer.py b/pytest_beehave/stub_writer.py deleted file mode 100644 index cf5635d..0000000 --- a/pytest_beehave/stub_writer.py +++ /dev/null @@ -1,654 +0,0 @@ -"""Test stub writer for pytest-beehave.""" - -from __future__ import annotations - -import re -from dataclasses import dataclass -from pathlib import Path - -from pytest_beehave.config import StubFormat -from pytest_beehave.feature_parser import ( - ParsedExample, - ParsedFeature, - ParsedRule, - ParsedStep, -) -from pytest_beehave.models import ExampleId, FeatureSlug, RuleSlug - -_DECORATOR_RE: re.Pattern[str] = re.compile( - r"^( *)((?:@pytest\.mark\.\w+(?:\(.*?\))?\n\1)*)def test_\w+_([a-f0-9]{8})\b", - re.MULTILINE, -) -_ORPHAN_MARKER_LINE = ( - '@pytest.mark.skip(reason="orphan: no matching @id in .feature files")\n' -) - - -@dataclass(frozen=True, slots=True) -class SyncAction: - """Description of a stub sync action taken. - - Attributes: - action: The action type (CREATE, UPDATE, ORPHAN, DEPRECATED). - path: Path to the affected test file. - detail: Optional extra detail string. - """ - - action: str - path: Path - detail: str = "" - - def __str__(self) -> str: - """Return a human-readable summary of the action.""" - if self.detail: - return f"{self.action} {self.path} ({self.detail})" - return f"{self.action} {self.path}" - - -@dataclass(frozen=True, slots=True) -class StubSpec: - """Specification for a single test stub to write. - - Attributes: - feature_slug: The feature slug (underscored). - rule_slug: The rule slug (underscore-separated), or None for top-level stubs. - example: The parsed example. - feature: The full parsed feature (for docstring context). - stub_format: The output format for the stub ("functions" or "classes"). - """ - - feature_slug: FeatureSlug - rule_slug: RuleSlug | None - example: ParsedExample - feature: ParsedFeature - stub_format: StubFormat = "functions" - - -def build_function_name(feature_slug: FeatureSlug, example_id: ExampleId) -> str: - """Build the test function name from slug and ID. - - Args: - feature_slug: The feature slug. - example_id: The example ID. - - Returns: - String like 'test_my_feature_aabbccdd'. - """ - return f"test_{feature_slug}_{example_id}" - - -def build_class_name(rule_slug: RuleSlug) -> str: - """Build the test class name from a rule slug. - - Args: - rule_slug: The rule slug (underscore-separated). - - Returns: - String like 'TestMyRule'. - """ - parts = str(rule_slug).split("_") - return "Test" + "".join(p.capitalize() for p in parts if p) - - -def _render_step(step: ParsedStep) -> str: - """Render a single step for a docstring. - - Args: - step: The step to render. - - Returns: - Rendered step text with optional doc_string/data_table. - """ - rendered = f" {step.keyword}: {step.text}" - if step.doc_string is not None: - lines = step.doc_string.splitlines() - rendered = f"{rendered}\n" + "\n".join(f" {ln}" for ln in lines) - if step.data_table is not None: - lines = step.data_table.splitlines() - rendered = f"{rendered}\n" + "\n".join(f" {ln}" for ln in lines) - return rendered - - -def _render_background_section(steps: tuple[ParsedStep, ...]) -> list[str]: - """Render a single background section as docstring lines. - - Args: - steps: The background steps. - - Returns: - List of rendered lines. - """ - lines = [" Background:"] - lines.extend(_render_step(step) for step in steps) - return lines - - -def build_docstring( - feature: ParsedFeature, - rule: ParsedRule | None, - example: ParsedExample, -) -> str: - """Build the docstring body for a test stub. - - Args: - feature: The parsed feature (not used directly, kept for interface). - rule: The parsed rule containing this example, or None. - example: The parsed example. - - Returns: - Docstring content (without surrounding triple-quotes). - """ - lines: list[str] = [] - for bg_steps in example.background_sections: - lines.extend(_render_background_section(bg_steps)) - lines.extend(_render_step(step) for step in example.steps) - if example.outline_examples is not None: - lines.append(f" {example.outline_examples}") - return "\n".join(lines) - - -def _stub_decorator(is_deprecated: bool) -> str: - """Return the decorator line for a new stub. - - Args: - is_deprecated: If True, return deprecated marker; else skip marker. - - Returns: - Decorator line string. - """ - if is_deprecated: - return "@pytest.mark.deprecated\n" - return '@pytest.mark.skip(reason="not yet implemented")\n' - - -def _stub_function_source( - function_name: str, - docstring_body: str, - is_deprecated: bool, - *, - is_method: bool = False, -) -> str: - """Build full source text for a single test stub function. - - Args: - function_name: The test function name. - docstring_body: The docstring body (without triple-quotes). - is_deprecated: If True, add @pytest.mark.deprecated. - is_method: If True, emit (self) as the parameter. - - Returns: - Full function source as a string. - """ - decorator = _stub_decorator(is_deprecated) - params = "self" if is_method else "" - return ( - f"{decorator}" - f"def {function_name}({params}) -> None:\n" - f' """\n{docstring_body}\n """\n' - f" raise NotImplementedError\n" - ) - - -def _build_file_header(story_slug: str) -> str: - """Build the header for a new test stub file. - - Args: - story_slug: The story file stem (underscore-separated). - - Returns: - File header string including module docstring and imports. - """ - title = story_slug.replace("_", " ") - return f'"""Tests for {title} story."""\n\nimport pytest\n\n\n' - - -def _indent_stub(source: str, indent: str = " ") -> str: - """Indent all lines of a stub by the given prefix. - - Args: - source: The stub source code. - indent: Indentation prefix. - - Returns: - Indented source. - """ - return "\n".join( - indent + line if line.strip() else line for line in source.splitlines() - ) - - -def _append_stub_to_file(path: Path, function_source: str) -> None: - """Append a stub function to an existing file. - - Args: - path: Path to the test file. - function_source: The stub function source code. - """ - existing = path.read_text(encoding="utf-8") - updated = existing.rstrip("\n") + "\n\n\n" + function_source + "\n" - path.write_text(updated, encoding="utf-8") - - -def _write_top_level_stub(path: Path, function_source: str) -> SyncAction: - """Write a top-level (no Rule) stub to file. - - Args: - path: Path to the test file. - function_source: Full function source code. - - Returns: - SyncAction describing what was done. - """ - if not path.exists(): - stem = path.stem - story_slug = stem.removesuffix("_test") - parent = path.parent - parent.mkdir(parents=True, exist_ok=True) - path.write_text( - _build_file_header(story_slug) + function_source + "\n", - encoding="utf-8", - ) - return SyncAction(action="CREATE", path=path) - _append_stub_to_file(path, function_source) - return SyncAction(action="UPDATE", path=path) - - -def write_stub_to_file(path: Path, spec: StubSpec) -> SyncAction: - """Write a test stub for a single example to a test file. - - Creates the file if it doesn't exist, appends if it does. - - Args: - path: Path to the test file. - spec: The stub specification. - - Returns: - A SyncAction describing what was done. - """ - example = spec.example - function_name = build_function_name(spec.feature_slug, example.example_id) - rule = _find_rule(spec.feature, spec.rule_slug) if spec.rule_slug else None - docstring_body = build_docstring(spec.feature, rule, example) - is_class_method = spec.rule_slug is not None and spec.stub_format == "classes" - function_source = _stub_function_source( - function_name, docstring_body, example.is_deprecated, is_method=is_class_method - ) - if is_class_method: - return _write_class_based_stub(path, spec, function_name, function_source) - return _write_top_level_stub(path, function_source) - - -def _create_class_file(path: Path, class_name: str, method_source: str) -> None: - """Create a new test file with a class containing a method stub. - - Args: - path: Path to create. - class_name: The test class name. - method_source: Indented method source. - """ - stem = path.stem - story_slug = stem.removesuffix("_test") - parent = path.parent - parent.mkdir(parents=True, exist_ok=True) - class_block = f"class {class_name}:\n{method_source}\n" - path.write_text( - _build_file_header(story_slug) + class_block + "\n", encoding="utf-8" - ) - - -def _append_to_class_file(path: Path, class_name: str, method_source: str) -> None: - """Append a method stub to an existing test file with a class. - - Args: - path: Path to the test file. - class_name: The test class name. - method_source: Indented method source. - """ - content = path.read_text(encoding="utf-8") - if f"class {class_name}:" not in content: - class_block = f"class {class_name}:\n{method_source}\n" - updated = content.rstrip("\n") + "\n\n\n" + class_block + "\n" - else: - updated = content.rstrip("\n") + "\n\n" + method_source + "\n" - path.write_text(updated, encoding="utf-8") - - -def _write_class_based_stub( - path: Path, - spec: StubSpec, - function_name: str, - function_source: str, -) -> SyncAction: - """Write a class-method stub for a Rule-based spec. - - Args: - path: Path to the test file. - spec: The stub specification. - function_name: The test function name. - function_source: The function source code (module-level style). - - Returns: - SyncAction describing the action taken. - """ - if spec.rule_slug is None: - raise ValueError("rule_slug must not be None for class-based stubs") - class_name = build_class_name(spec.rule_slug) - method_source = _indent_stub(function_source) - if not path.exists(): - _create_class_file(path, class_name, method_source) - return SyncAction(action="CREATE", path=path) - _append_to_class_file(path, class_name, method_source) - return SyncAction(action="UPDATE", path=path) - - -def _find_rule(feature: ParsedFeature, rule_slug: RuleSlug) -> ParsedRule | None: - """Find a rule in a feature by its slug. - - Args: - feature: The parsed feature. - rule_slug: The rule slug to find. - - Returns: - The matching ParsedRule, or None. - """ - for rule in feature.rules: - if rule.rule_slug == rule_slug: - return rule - return None - - -def _docstring_pattern(function_name: str) -> re.Pattern[str]: - """Build a compiled docstring-replacement regex for a named function. - - Args: - function_name: The test function name. - - Returns: - Compiled regex pattern matching def line + docstring. - """ - return re.compile( - rf'(def {re.escape(function_name)}\([^)]*\) -> None:\n """).*?(""")', - re.DOTALL, - ) - - -def _update_docstring_in_content( - content: str, - function_name: str, - new_docstring_body: str, -) -> str: - """Replace the docstring of a named function in file content. - - Args: - content: Full file content. - function_name: The function name to find. - new_docstring_body: New docstring body (without triple-quotes). - - Returns: - Updated file content. - """ - pattern = _docstring_pattern(function_name) - replace_with = new_docstring_body - - def replacer(m: re.Match[str]) -> str: - return f"{m.group(1)}\n{replace_with}\n {m.group(2)}" - - return pattern.sub(replacer, content, count=1) - - -def _rename_function_in_content(content: str, old_name: str, new_name: str) -> str: - """Rename a test function in file content. - - Args: - content: Full file content. - old_name: Current function name. - new_name: New function name. - - Returns: - Updated file content. - """ - pattern = re.compile( - rf"^def {re.escape(old_name)}\(([^)]*)\) -> None:", - re.MULTILINE, - ) - return pattern.sub( - lambda m: f"def {new_name}({m.group(1)}) -> None:", - content, - count=1, - ) - - -def update_docstring( - path: Path, - function_name: str, - new_docstring_body: str, - feature_slug: FeatureSlug, - example_id: ExampleId, -) -> SyncAction | None: - """Update the docstring and/or rename a function in a test file. - - Args: - path: Path to the test file. - function_name: Current function name. - new_docstring_body: New docstring content. - feature_slug: Current feature slug (for renaming). - example_id: The example ID (for renaming). - - Returns: - SyncAction if the file was changed, else None. - """ - original = path.read_text(encoding="utf-8") - content = original - new_name = build_function_name(feature_slug, example_id) - if function_name != new_name: - content = _rename_function_in_content(content, function_name, new_name) - content = _update_docstring_in_content(content, new_name, new_docstring_body) - if content == original: - return None - path.write_text(content, encoding="utf-8") - return SyncAction(action="UPDATE", path=path) - - -def _find_function_match(content: str, function_name: str) -> re.Match[str] | None: - """Find the def line for a named function in content. - - Args: - content: Full file content. - function_name: The function name to find. - - Returns: - Match object or None. - """ - return re.search( - rf"^def {re.escape(function_name)}\([^)]*\) -> None:", - content, - re.MULTILINE, - ) - - -def _insert_marker_before(content: str, match: re.Match[str], marker_line: str) -> str: - """Insert a marker line before the match position in content. - - Args: - content: Full file content. - match: Match for the def line. - marker_line: The marker line to insert. - - Returns: - Updated file content. - """ - return content[: match.start()] + marker_line + content[match.start() :] - - -def mark_orphan(path: Path, function_name: str) -> SyncAction | None: - """Add an orphan skip marker before a function if not already present. - - Args: - path: Path to the test file. - function_name: The function to mark as orphan. - - Returns: - SyncAction if file was changed, else None. - """ - content = path.read_text(encoding="utf-8") - match = _find_function_match(content, function_name) - if not match: - return None - if content[: match.start()].endswith(_ORPHAN_MARKER_LINE): - return None - updated = _insert_marker_before(content, match, _ORPHAN_MARKER_LINE) - path.write_text(updated, encoding="utf-8") - return SyncAction(action="ORPHAN", path=path) - - -def remove_orphan_marker(path: Path, function_name: str) -> SyncAction | None: - """Remove orphan skip marker before a function if present. - - Args: - path: Path to the test file. - function_name: The function to un-orphan. - - Returns: - SyncAction if file was changed, else None. - """ - content = path.read_text(encoding="utf-8") - escaped_marker = re.escape(_ORPHAN_MARKER_LINE.rstrip("\n")) - pattern = re.compile( - rf"^{escaped_marker}\n(def {re.escape(function_name)}\([^)]*\) -> None:)", - re.MULTILINE, - ) - updated = pattern.sub(r"\1", content, count=1) - if updated == content: - return None - path.write_text(updated, encoding="utf-8") - return SyncAction(action="ORPHAN", path=path) - - -def _build_non_conforming_marker(correct_file: Path, correct_class: str | None) -> str: - """Build the non-conforming skip marker line. - - Args: - correct_file: Where the stub should be. - correct_class: The correct class name, if applicable. - - Returns: - Marker line string. - """ - detail = f"should be in {correct_file}" - if correct_class: - detail += f" class {correct_class}" - return f'@pytest.mark.skip(reason="non-conforming: {detail}")\n' - - -def mark_non_conforming( - path: Path, - function_name: str, - correct_file: Path, - correct_class: str | None, -) -> SyncAction | None: - """Mark a non-conforming test function with a skip marker. - - A non-conforming stub is one in the wrong file, wrong class, or with a - wrong function name. - - Args: - path: Path to the test file. - function_name: The function name to mark. - correct_file: Where the stub should be. - correct_class: The correct class name, if applicable. - - Returns: - SyncAction if file was changed, else None. - """ - marker_line = _build_non_conforming_marker(correct_file, correct_class) - content = path.read_text(encoding="utf-8") - match = _find_function_match(content, function_name) - if not match: - return None - before_def = content[: match.start()] - if f"non-conforming: should be in {correct_file}" in before_def: - return None - updated = _insert_marker_before(content, match, marker_line) - path.write_text(updated, encoding="utf-8") - return SyncAction(action="NON_CONFORMING", path=path) - - -def _rewrite_decorators( - path: Path, - content: str, - match: re.Match[str], - new_decorators: str, -) -> SyncAction: - """Rewrite the decorator block for a match and save. - - Args: - path: Path to the test file. - content: Full file content. - match: Regex match for the decorator block. - new_decorators: Updated decorator block. - - Returns: - SyncAction with DEPRECATED action. - """ - indent = match.group(1) - def_start = match.start() + len(indent) + len(match.group(2)) - new_content = content[: match.start()] + new_decorators + content[def_start:] - path.write_text(new_content, encoding="utf-8") - return SyncAction(action="DEPRECATED", path=path) - - -def _apply_deprecated_toggle( - path: Path, - content: str, - match: re.Match[str], - should_be_deprecated: bool, -) -> SyncAction | None: - """Apply deprecated marker add/remove for a single decorator match. - - Args: - path: Path to the test file. - content: Full file content. - match: Regex match for the decorator block. - should_be_deprecated: Whether to add or remove the marker. - - Returns: - SyncAction if changed, else None. - """ - indent = match.group(1) - full_decorators = indent + match.group(2) - marker_line = f"{indent}@pytest.mark.deprecated\n" - has_marker = marker_line in full_decorators - if should_be_deprecated and not has_marker: - return _rewrite_decorators(path, content, match, marker_line + full_decorators) - if not should_be_deprecated and has_marker: - stripped = full_decorators.replace(marker_line, "") - return _rewrite_decorators(path, content, match, stripped) - return None - - -def toggle_deprecated_marker( - path: Path, - function_name: str, - *, - should_be_deprecated: bool, -) -> SyncAction | None: - """Add or remove @pytest.mark.deprecated before a function. - - Args: - path: Path to the test file. - function_name: The test function name. - should_be_deprecated: If True, add the marker; if False, remove it. - - Returns: - SyncAction if file was changed, else None. - """ - if not path.exists(): - return None - content = path.read_text(encoding="utf-8") - for match in _DECORATOR_RE.finditer(content): - if not function_name.endswith(f"_{match.group(3)}"): - continue - result = _apply_deprecated_toggle(path, content, match, should_be_deprecated) - if result is not None: - return result - return None diff --git a/pytest_beehave/sync_engine.py b/pytest_beehave/sync_engine.py deleted file mode 100644 index 134c394..0000000 --- a/pytest_beehave/sync_engine.py +++ /dev/null @@ -1,630 +0,0 @@ -"""Sync engine for pytest-beehave — orchestrates stub creation and updates.""" - -from __future__ import annotations - -from dataclasses import dataclass -from pathlib import Path -from typing import Protocol - -from pytest_beehave.config import StubFormat -from pytest_beehave.feature_parser import ( - ParsedExample, - ParsedFeature, - ParsedRule, - parse_feature, -) -from pytest_beehave.models import ExampleId, FeatureStage -from pytest_beehave.stub_reader import ExistingStub, read_stubs_from_file -from pytest_beehave.stub_writer import ( - StubSpec, - SyncAction, - build_class_name, - build_docstring, - build_function_name, - mark_non_conforming, - mark_orphan, - remove_orphan_marker, - toggle_deprecated_marker, - update_docstring, - write_stub_to_file, -) - -_FEATURE_STAGES = ( - FeatureStage.BACKLOG, - FeatureStage.IN_PROGRESS, - FeatureStage.COMPLETED, -) - - -class FileSystemProtocol(Protocol): - """Protocol for filesystem operations needed by the sync engine.""" - - def list_feature_files(self, stage_dir: Path) -> list[Path]: # pragma: no cover - """List all .feature files recursively under stage_dir.""" - ... - - def list_test_files(self, tests_dir: Path) -> list[Path]: # pragma: no cover - """List all *_test.py files recursively under tests_dir.""" - ... - - -@dataclass(frozen=True, slots=True) -class _RealFileSystem: - """Concrete filesystem adapter using pathlib.""" - - def list_feature_files(self, stage_dir: Path) -> list[Path]: - """List all .feature files recursively under stage_dir. - - Args: - stage_dir: Root directory to search. - - Returns: - Sorted list of .feature file paths. - """ - return sorted(stage_dir.rglob("*.feature")) - - def list_test_files(self, tests_dir: Path) -> list[Path]: - """List all *_test.py files recursively under tests_dir. - - Args: - tests_dir: Root directory to search. - - Returns: - Sorted list of test file paths. - """ - return sorted(tests_dir.rglob("*_test.py")) - - -@dataclass(frozen=True, slots=True) -class SyncResult: - """Result of a sync operation. - - Attributes: - actions: Tuple of SyncAction objects describing what was done. - """ - - actions: tuple[SyncAction, ...] - - @property - def is_noop(self) -> bool: - """Return True if no actions were taken.""" - return len(self.actions) == 0 - - -def _collect_all_ids( - features_dir: Path, - filesystem: FileSystemProtocol, -) -> frozenset[ExampleId]: - """Collect all example IDs from all .feature files. - - Args: - features_dir: Root of the features directory. - filesystem: Filesystem adapter. - - Returns: - Frozenset of ExampleId objects. - """ - import re - - id_re = re.compile(r"@id:([a-f0-9]{8})") - ids: set[ExampleId] = set() - for stage in _FEATURE_STAGES: - stage_dir = features_dir / stage.value - if not stage_dir.exists(): - continue - for path in filesystem.list_feature_files(stage_dir): - text = path.read_text(encoding="utf-8") - ids.update(ExampleId(match.group(1)) for match in id_re.finditer(text)) - return frozenset(ids) - - -def _add_rule_locations( - feature_dir: Path, - feature: ParsedFeature, - locations: dict[ExampleId, tuple[Path, str | None]], -) -> None: - """Add expected locations for all rule-based examples of a feature. - - Args: - feature_dir: The test directory for this feature. - feature: The parsed feature. - locations: Dict to populate with ExampleId -> (file, class) entries. - """ - for rule in feature.rules: - test_file = feature_dir / f"{rule.rule_slug}_test.py" - class_name = build_class_name(rule.rule_slug) - for example in rule.examples: - locations[example.example_id] = (test_file, class_name) - - -def _build_expected_locations( - feature_stage_pairs: list[tuple[ParsedFeature, FeatureStage]], - tests_dir: Path, -) -> dict[ExampleId, tuple[Path, str | None]]: - """Build a map of ExampleId to (expected_test_file, expected_class_name). - - Args: - feature_stage_pairs: All parsed feature/stage tuples. - tests_dir: Root of the tests/features/ directory. - - Returns: - Dict mapping ExampleId to (file_path, class_name_or_None). - """ - locations: dict[ExampleId, tuple[Path, str | None]] = {} - for feature, _stage in feature_stage_pairs: - feature_dir = tests_dir / str(feature.feature_slug) - _add_rule_locations(feature_dir, feature, locations) - top_level_file = feature_dir / "examples_test.py" - for example in feature.top_level_examples: - locations[example.example_id] = (top_level_file, None) - return locations - - -def _orphan_action( - test_file: Path, - stub: ExistingStub, - all_ids: frozenset[ExampleId], -) -> SyncAction | None: - """Compute the orphan sync action for a single stub. - - Args: - test_file: The test file containing the stub. - stub: The existing stub. - all_ids: All known example IDs. - - Returns: - SyncAction or None. - """ - if stub.example_id not in all_ids: - return mark_orphan(test_file, stub.function_name) - return remove_orphan_marker(test_file, stub.function_name) - - -def _orphan_actions_for_file( - test_file: Path, - all_ids: frozenset[ExampleId], -) -> list[SyncAction]: - """Compute orphan sync actions for all stubs in a single test file. - - Args: - test_file: The test file to scan. - all_ids: All known example IDs. - - Returns: - List of SyncAction objects. - """ - return [ - action - for stub in read_stubs_from_file(test_file) - for action in [_orphan_action(test_file, stub, all_ids)] - if action is not None - ] - - -def _sync_orphans( - tests_dir: Path, - all_ids: frozenset[ExampleId], - filesystem: FileSystemProtocol, -) -> list[SyncAction]: - """Add or remove orphan markers for test functions without matching @id. - - Args: - tests_dir: Root of the tests/features/ directory. - all_ids: All known example IDs. - filesystem: Filesystem adapter. - - Returns: - List of SyncAction objects. - """ - actions: list[SyncAction] = [] - for test_file in filesystem.list_test_files(tests_dir): - actions.extend(_orphan_actions_for_file(test_file, all_ids)) - return actions - - -def _check_stub_conformity( - test_file: Path, - stub: ExistingStub, - expected_locations: dict[ExampleId, tuple[Path, str | None]], -) -> SyncAction | None: - """Check if a single stub is in the correct file and mark it if not. - - Args: - test_file: The file the stub was found in. - stub: The existing stub. - expected_locations: Map of ExampleId to (expected_file, expected_class). - - Returns: - SyncAction if non-conforming, else None. - """ - if stub.example_id not in expected_locations: - return None - expected_file, expected_class = expected_locations[stub.example_id] - if expected_class is None or test_file == expected_file: - return None - return mark_non_conforming( - test_file, stub.function_name, expected_file, expected_class - ) - - -def _non_conforming_actions_for_file( - test_file: Path, - expected_locations: dict[ExampleId, tuple[Path, str | None]], -) -> list[SyncAction]: - """Collect non-conforming actions for all stubs in one test file. - - Args: - test_file: Path to the test file. - expected_locations: Map of ExampleId to (expected_file, expected_class). - - Returns: - List of SyncAction objects for non-conforming stubs. - """ - return [ - action - for stub in read_stubs_from_file(test_file) - for action in [_check_stub_conformity(test_file, stub, expected_locations)] - if action is not None - ] - - -def _sync_non_conforming( - tests_dir: Path, - expected_locations: dict[ExampleId, tuple[Path, str | None]], - filesystem: FileSystemProtocol, -) -> list[SyncAction]: - """Mark test stubs found in the wrong file as non-conforming. - - Only applies to Rule-based stubs (those with an expected class). Top-level - examples without a class context are skipped — orphan detection handles - unrecognised test files. - - Args: - tests_dir: Root of the tests/features/ directory. - expected_locations: Map of ExampleId to (expected_file, expected_class). - filesystem: Filesystem adapter. - - Returns: - List of SyncAction objects. - """ - actions: list[SyncAction] = [] - for test_file in filesystem.list_test_files(tests_dir): - actions.extend(_non_conforming_actions_for_file(test_file, expected_locations)) - return actions - - -def _sync_active_feature( - feature: ParsedFeature, - tests_dir: Path, - stub_format: StubFormat = "functions", -) -> list[SyncAction]: - """Sync stubs for an active (backlog/in-progress) feature. - - Args: - feature: The parsed feature. - tests_dir: Root of the tests/features/ directory. - stub_format: The output format for new stubs. - - Returns: - List of SyncAction objects. - """ - actions: list[SyncAction] = [] - feature_test_dir = tests_dir / str(feature.feature_slug) - - if feature.rules: - for rule in feature.rules: - actions.extend( - _sync_rule_stubs(feature, rule, feature_test_dir, stub_format) - ) - elif feature.top_level_examples: - actions.extend(_sync_top_level_stubs(feature, feature_test_dir)) - - return actions - - -def _sync_rule_stubs( - feature: ParsedFeature, - rule: ParsedRule, - feature_test_dir: Path, - stub_format: StubFormat = "functions", -) -> list[SyncAction]: - """Sync stubs for a single rule block. - - Args: - feature: The parsed feature. - rule: The parsed rule. - feature_test_dir: Directory for this feature's tests. - stub_format: The output format for new stubs. - - Returns: - List of SyncAction objects. - """ - if not rule.examples: - return [] - test_file = feature_test_dir / f"{rule.rule_slug}_test.py" - existing = {s.example_id: s for s in read_stubs_from_file(test_file)} - actions: list[SyncAction] = [] - for example in rule.examples: - action = _sync_one_example( - feature, rule, example, test_file, existing, stub_format - ) - if action is not None: - actions.append(action) - actions.extend(_sync_deprecated_in_rule(feature, rule, test_file)) - return actions - - -def _update_existing_stub( - feature: ParsedFeature, - rule: ParsedRule | None, - example: ParsedExample, - test_file: Path, - stub: ExistingStub, -) -> SyncAction | None: - """Update an existing stub's docstring and/or name. - - Args: - feature: The parsed feature. - rule: The parsed rule (or None for top-level). - example: The parsed example. - test_file: Path to the test file. - stub: The existing stub to update. - - Returns: - SyncAction or None. - """ - return update_docstring( - test_file, - stub.function_name, - build_docstring(feature, rule, example), - feature.feature_slug, - example.example_id, - ) - - -def _sync_one_example( - feature: ParsedFeature, - rule: ParsedRule | None, - example: ParsedExample, - test_file: Path, - existing: dict[ExampleId, ExistingStub], - stub_format: StubFormat = "functions", -) -> SyncAction | None: - """Sync a single example stub — create or update. - - Args: - feature: The parsed feature. - rule: The parsed rule (or None for top-level). - example: The parsed example. - test_file: Path to the test file. - existing: Map of existing stubs by example ID. - stub_format: The output format for new stubs. - - Returns: - SyncAction or None. - """ - if example.example_id in existing: - return _update_existing_stub( - feature, rule, example, test_file, existing[example.example_id] - ) - rule_slug = rule.rule_slug if rule else None - spec = StubSpec( - feature_slug=feature.feature_slug, - rule_slug=rule_slug, - example=example, - feature=feature, - stub_format=stub_format, - ) - return write_stub_to_file(test_file, spec) - - -def _sync_deprecated_in_rule( - feature: ParsedFeature, - rule: ParsedRule, - test_file: Path, -) -> list[SyncAction]: - """Sync deprecated markers for all examples in a rule. - - Args: - feature: The parsed feature. - rule: The parsed rule. - test_file: Path to the test file. - - Returns: - List of SyncAction objects. - """ - return [ - action - for example in rule.examples - for action in [ - toggle_deprecated_marker( - test_file, - build_function_name(feature.feature_slug, example.example_id), - should_be_deprecated=example.is_deprecated, - ) - ] - if action is not None - ] - - -def _sync_top_level_stubs( - feature: ParsedFeature, - feature_test_dir: Path, -) -> list[SyncAction]: - """Sync stubs for top-level examples (no Rule blocks). - - Args: - feature: The parsed feature. - feature_test_dir: Directory for this feature's tests. - - Returns: - List of SyncAction objects. - """ - test_file = feature_test_dir / "examples_test.py" - existing = {s.example_id: s for s in read_stubs_from_file(test_file)} - actions: list[SyncAction] = [] - for example in feature.top_level_examples: - action = _sync_one_example(feature, None, example, test_file, existing) - if action is not None: - actions.append(action) - actions.extend(_sync_deprecated_top_level(feature, test_file)) - return actions - - -def _sync_deprecated_top_level( - feature: ParsedFeature, - test_file: Path, -) -> list[SyncAction]: - """Sync deprecated markers for top-level examples (no Rule blocks). - - Args: - feature: The parsed feature. - test_file: Path to the top-level examples test file. - - Returns: - List of SyncAction objects. - """ - return [ - action - for example in feature.top_level_examples - for action in [ - toggle_deprecated_marker( - test_file, - build_function_name(feature.feature_slug, example.example_id), - should_be_deprecated=example.is_deprecated, - ) - ] - if action is not None - ] - - -def _sync_deprecated_rules( - feature: ParsedFeature, - feature_test_dir: Path, -) -> list[SyncAction]: - """Sync deprecated markers for all rules in a feature. - - Args: - feature: The parsed feature. - feature_test_dir: Directory for this feature's tests. - - Returns: - List of SyncAction objects. - """ - actions: list[SyncAction] = [] - for rule in feature.rules: - test_file = feature_test_dir / f"{rule.rule_slug}_test.py" - actions.extend(_sync_deprecated_in_rule(feature, rule, test_file)) - return actions - - -def _sync_completed_feature( - feature: ParsedFeature, - tests_dir: Path, -) -> list[SyncAction]: - """Sync deprecated markers only for a completed feature. - - Args: - feature: The parsed feature. - tests_dir: Root of the tests/features/ directory. - - Returns: - List of SyncAction objects. - """ - feature_test_dir = tests_dir / str(feature.feature_slug) - if feature.rules: - return _sync_deprecated_rules(feature, feature_test_dir) - return _sync_deprecated_top_level(feature, feature_test_dir / "examples_test.py") - - -def _folder_name_for(feature_path: Path, stage_dir: Path) -> str: - """Derive the folder name for a feature file. - - Args: - feature_path: Path to the .feature file. - stage_dir: The stage directory (backlog/, in-progress/, completed/). - - Returns: - Folder name string used as the feature slug source. - """ - feature_parent = feature_path.parent - if feature_parent == stage_dir: - return feature_path.stem - return feature_parent.name - - -def _features_in_stage( - stage: FeatureStage, - features_dir: Path, - filesystem: FileSystemProtocol, -) -> list[tuple[ParsedFeature, FeatureStage]]: - """Discover all feature files for one stage directory. - - Args: - stage: The feature stage. - features_dir: Root of the features directory. - filesystem: Filesystem adapter. - - Returns: - List of (ParsedFeature, FeatureStage) tuples for this stage. - """ - stage_dir = features_dir / stage.value - if not stage_dir.exists(): - return [] - return [ - (parse_feature(p, _folder_name_for(p, stage_dir)), stage) - for p in filesystem.list_feature_files(stage_dir) - ] - - -def discover_feature_locations( - features_dir: Path, - filesystem: FileSystemProtocol, -) -> list[tuple[ParsedFeature, FeatureStage]]: - """Discover all feature files and their stages. - - Args: - features_dir: Root of the features directory. - filesystem: Filesystem adapter. - - Returns: - List of (ParsedFeature, FeatureStage) tuples. - """ - results: list[tuple[ParsedFeature, FeatureStage]] = [] - for stage in _FEATURE_STAGES: - results.extend(_features_in_stage(stage, features_dir, filesystem)) - return results - - -def run_sync( - features_root: Path, - tests_root: Path, - filesystem: FileSystemProtocol | None = None, - stub_format: StubFormat = "functions", -) -> list[str]: - """Sync test stubs from .feature files to the tests directory. - - Args: - features_root: Root of the features directory (contains backlog/, - in-progress/, completed/). - tests_root: Root of the tests/features/ directory. - filesystem: Optional filesystem adapter. Defaults to _RealFileSystem. - stub_format: The output format for new stubs. - - Returns: - List of action description strings. - """ - if filesystem is None: - filesystem = _RealFileSystem() - actions: list[SyncAction] = [] - feature_stage_pairs = discover_feature_locations(features_root, filesystem) - for feature, stage in feature_stage_pairs: - if stage == FeatureStage.COMPLETED: - actions.extend(_sync_completed_feature(feature, tests_root)) - else: - actions.extend(_sync_active_feature(feature, tests_root, stub_format)) - expected_locations = _build_expected_locations(feature_stage_pairs, tests_root) - actions.extend(_sync_non_conforming(tests_root, expected_locations, filesystem)) - all_ids = _collect_all_ids(features_root, filesystem) - actions.extend(_sync_orphans(tests_root, all_ids, filesystem)) - return [str(a) for a in actions] diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index f1b390f..0000000 --- a/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests module.""" diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index 0cdc864..0000000 --- a/tests/conftest.py +++ /dev/null @@ -1,32 +0,0 @@ -import pytest - -pytest_plugins = ["pytester"] - - -def pytest_html_report_title(report): - report.title = "Test Report" - - -@pytest.hookimpl(tryfirst=True, hookwrapper=True) -def pytest_runtest_makereport(item, call): - outcome = yield - report = outcome.get_result() - - docstring = item.obj.__doc__ or "" - report.docstrings = docstring - - -def pytest_html_results_table_header(cells): - cells.insert(2, "Documentation") - - -def pytest_html_results_table_row(report, cells): - docstring = getattr(report, "docstrings", "") or "" - cells.insert(2, f"{docstring}") - - -def pytest_collection_modifyitems(items): - """Automatically skip tests marked as deprecated.""" - for item in items: - if item.get_closest_marker("deprecated"): - item.add_marker(pytest.mark.skip(reason="deprecated")) diff --git a/tests/features/auto_id_generation/auto_id_write_back_test.py b/tests/features/auto_id_generation/auto_id_write_back_test.py deleted file mode 100644 index 0d44b39..0000000 --- a/tests/features/auto_id_generation/auto_id_write_back_test.py +++ /dev/null @@ -1,90 +0,0 @@ -"""Tests for auto id generation — auto ID write-back rule.""" - -import re -from pathlib import Path - -from pytest_beehave.id_generator import assign_ids - - -def _make_feature_file(directory: Path, content: str) -> Path: - """Write a feature file under directory and return its path.""" - path = directory / "in-progress" / "my-feature" / "my-story.feature" - path.parent.mkdir(parents=True) - path.write_text(content) - return path - - -def _single_example_content() -> str: - """Return feature file text with one untagged Example.""" - return ( - "Feature: My feature\n" - " Example: Something happens\n" - " Given a condition\n" - " When an action\n" - " Then an outcome\n" - ) - - -def _three_examples_content() -> str: - """Return feature file text with three untagged Examples.""" - return ( - "Feature: My feature\n" - " Example: First example\n" - " Given a condition\n" - " When an action\n" - " Then an outcome\n" - " Example: Second example\n" - " Given another condition\n" - " When another action\n" - " Then another outcome\n" - " Example: Third example\n" - " Given yet another condition\n" - " When yet another action\n" - " Then yet another outcome\n" - ) - - -class TestAutoIdWriteBack: - """Tests for the Auto ID write-back Rule.""" - - def test_auto_id_write_back_cd98877d(self, tmp_path: Path) -> None: - """ - Given: a writable .feature file containing an Example with no @id tag - When: pytest is invoked - Then: the .feature file contains an @id:<8-char-hex> tag on the line immediately before that Example - """ - feature_file = _make_feature_file(tmp_path, _single_example_content()) - assign_ids(tmp_path) - lines = feature_file.read_text().splitlines() - example_idx = next(i for i, line in enumerate(lines) if "Example:" in line) - assert re.fullmatch(r"@id:[0-9a-f]{8}", lines[example_idx - 1].strip()) - - def test_auto_id_write_back_27cf14bf(self, tmp_path: Path) -> None: - """ - Given: a writable .feature file containing multiple untagged Examples - When: pytest is invoked - Then: all generated @id tags within that file are unique - """ - feature_file = _make_feature_file(tmp_path, _three_examples_content()) - assign_ids(tmp_path) - found_ids = re.findall(r"@id:([0-9a-f]{8})", feature_file.read_text()) - assert len(found_ids) == 3 - assert len(found_ids) == len(set(found_ids)) - - def test_auto_id_write_back_842409ed(self, tmp_path: Path) -> None: - """ - Given: a .feature file where all Examples already have @id tags - When: pytest is invoked - Then: the .feature file content is unchanged - """ - original_content = ( - "Feature: My feature\n" - " @id:aabbccdd\n" - " Example: Something happens\n" - " Given a condition\n" - " When an action\n" - " Then an outcome\n" - ) - feature_file = _make_feature_file(tmp_path, original_content) - assign_ids(tmp_path) - assert feature_file.read_text() == original_content diff --git a/tests/features/auto_id_generation/ci_id_enforcement_test.py b/tests/features/auto_id_generation/ci_id_enforcement_test.py deleted file mode 100644 index 71c4be6..0000000 --- a/tests/features/auto_id_generation/ci_id_enforcement_test.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Tests for auto id generation — CI ID enforcement rule.""" - -import pytest - - -def _make_readonly_feature(pytester: pytest.Pytester) -> None: - """Create a read-only feature file with one untagged Example.""" - feature_file = ( - pytester.path - / "docs" - / "features" - / "in-progress" - / "my-feature" - / "my-story.feature" - ) - feature_file.parent.mkdir(parents=True) - feature_file.write_text( - "Feature: My feature\n" - " Example: Something happens\n" - " Given a condition\n" - " When an action\n" - " Then an outcome\n" - ) - feature_file.chmod(0o444) - pytester.makepyfile("def test_placeholder(): pass") - - -class TestCiIdEnforcement: - """Tests for the CI ID enforcement Rule.""" - - def test_ci_id_enforcement_c4d6d9ce(self, pytester: pytest.Pytester) -> None: - """ - Given: a read-only .feature file containing an Example with no @id tag - When: pytest is invoked - Then: the pytest run exits with a non-zero status code - """ - _make_readonly_feature(pytester) - result = pytester.runpytest() - assert result.ret != 0 - - def test_ci_id_enforcement_8b9230d4(self, pytester: pytest.Pytester) -> None: - """ - Given: a read-only .feature file containing an Example with no @id tag - When: pytest is invoked - Then: the error output names the .feature file path and the Example title that is missing an @id - """ - _make_readonly_feature(pytester) - result = pytester.runpytest() - result.stdout.fnmatch_lines(["*my-story.feature*"]) - result.stdout.fnmatch_lines(["*Something happens*"]) diff --git a/tests/features/deprecation_sync/conftest.py b/tests/features/deprecation_sync/conftest.py deleted file mode 100644 index 9d0525e..0000000 --- a/tests/features/deprecation_sync/conftest.py +++ /dev/null @@ -1,47 +0,0 @@ -"""Shared fixtures for deprecation sync feature tests.""" - -from __future__ import annotations - -from pathlib import Path -from typing import Callable - -import pytest - - -def _make_feature( - features_dir: Path, - stage: str, - folder: str, - filename: str, - content: str, -) -> None: - """Write a .feature file under features_dir///.""" - feature_dir = features_dir / stage / folder - feature_dir.mkdir(parents=True, exist_ok=True) - (feature_dir / filename).write_text(content, encoding="utf-8") - - -def _make_test_file( - tests_dir: Path, - feature_slug: str, - story_slug: str, - content: str, -) -> Path: - """Write a test file under tests_dir//_test.py.""" - test_dir = tests_dir / feature_slug - test_dir.mkdir(parents=True, exist_ok=True) - test_file = test_dir / f"{story_slug}_test.py" - test_file.write_text(content, encoding="utf-8") - return test_file - - -@pytest.fixture -def make_feature() -> Callable[..., None]: - """Fixture providing the make_feature helper.""" - return _make_feature - - -@pytest.fixture -def make_test_file() -> Callable[..., Path]: - """Fixture providing the make_test_file helper.""" - return _make_test_file diff --git a/tests/features/deprecation_sync/mark_deprecated_examples_test.py b/tests/features/deprecation_sync/mark_deprecated_examples_test.py deleted file mode 100644 index cea5316..0000000 --- a/tests/features/deprecation_sync/mark_deprecated_examples_test.py +++ /dev/null @@ -1,118 +0,0 @@ -"""Tests for deprecation sync — mark deprecated examples rule.""" - -from __future__ import annotations - -from pathlib import Path -from typing import Callable - -from pytest_beehave.sync_engine import run_sync as sync_stubs - - -class TestMarkDeprecatedExamples: - """Tests for the Mark deprecated Examples Rule.""" - - def test_deprecation_sync_f9b636df( - self, - tmp_path: Path, - make_feature: Callable[..., None], - make_test_file: Callable[..., Path], - ) -> None: - """ - Given: a backlog feature with an Example tagged @deprecated whose test stub lacks @pytest.mark.deprecated - When: pytest is invoked - Then: the test stub has @pytest.mark.deprecated applied - """ - features_dir = tmp_path / "features" - tests_dir = tmp_path / "tests" - make_feature( - features_dir, - "backlog", - "my-feature", - "my-story.feature", - """\ -Feature: My feature - @deprecated @id:aabbccdd - Example: A deprecated example - Given a thing - When it runs - Then it works -""", - ) - test_file = make_test_file( - tests_dir, - "my_feature", - "examples", - """\ -\"\"\"Tests for my feature examples.\"\"\" - -import pytest - - -def test_my_feature_aabbccdd() -> None: - \"\"\" - Given: a thing - When: it runs - Then: it works - \"\"\" - raise NotImplementedError -""", - ) - sync_stubs(features_dir, tests_dir) - content = test_file.read_text(encoding="utf-8") - assert "@pytest.mark.deprecated" in content - deprecated_idx = content.index("@pytest.mark.deprecated") - def_idx = content.index("def test_my_feature_aabbccdd") - assert deprecated_idx < def_idx - - def test_deprecation_sync_fc372f15( - self, - tmp_path: Path, - make_feature: Callable[..., None], - make_test_file: Callable[..., Path], - ) -> None: - """ - Given: a completed feature with an Example tagged @deprecated whose test stub lacks @pytest.mark.deprecated - When: pytest is invoked - Then: the test stub has @pytest.mark.deprecated applied - """ - features_dir = tmp_path / "features" - tests_dir = tmp_path / "tests" - make_feature( - features_dir, - "completed", - "done-feature", - "done-story.feature", - """\ -Feature: Done feature - @deprecated @id:aabbccdd - Example: A deprecated done example - Given it was done - When checked - Then it passes -""", - ) - test_file = make_test_file( - tests_dir, - "done_feature", - "examples", - """\ -\"\"\"Tests for done feature examples.\"\"\" - -import pytest - - -def test_done_feature_aabbccdd() -> None: - \"\"\" - Given: it was done - When: checked - Then: it passes - \"\"\" - raise NotImplementedError -""", - ) - sync_stubs(features_dir, tests_dir) - content = test_file.read_text(encoding="utf-8") - assert "@pytest.mark.deprecated" in content - deprecated_idx = content.index("@pytest.mark.deprecated") - def_idx = content.index("def test_done_feature_aabbccdd") - assert deprecated_idx < def_idx diff --git a/tests/features/deprecation_sync/mark_deprecated_via_tag_inheritance_test.py b/tests/features/deprecation_sync/mark_deprecated_via_tag_inheritance_test.py deleted file mode 100644 index 39a122c..0000000 --- a/tests/features/deprecation_sync/mark_deprecated_via_tag_inheritance_test.py +++ /dev/null @@ -1,231 +0,0 @@ -"""Tests for deprecation sync — mark deprecated via tag inheritance rule.""" - -from __future__ import annotations - -from pathlib import Path -from typing import Callable - -import pytest - -from pytest_beehave.sync_engine import run_sync as sync_stubs - - -class TestMarkDeprecatedViaTagInheritance: - """Tests for the Mark deprecated via tag inheritance Rule.""" - - @pytest.mark.unit - def test_deprecation_sync_b3d7f942( - self, - tmp_path: Path, - make_feature: Callable[..., None], - make_test_file: Callable[..., Path], - ) -> None: - """ - Given: a backlog feature with a Rule tagged @deprecated containing multiple Examples - When: pytest is invoked - Then: all test stubs for Examples in that Rule have @pytest.mark.deprecated applied - """ - features_dir = tmp_path / "features" - tests_dir = tmp_path / "tests" - make_feature( - features_dir, - "backlog", - "my-feature", - "my-story.feature", - """\ -Feature: My feature - @deprecated - Rule: Old rule - @id:aabbccdd - Example: First example - Given something - When it runs - Then it works - @id:11223344 - Example: Second example - Given another thing - When it runs - Then it also works -""", - ) - test_file = make_test_file( - tests_dir, - "my_feature", - "old_rule", - '''\ -"""Tests for my feature old rule.""" - -import pytest - - -def test_my_feature_aabbccdd() -> None: - """ - Given: something - When: it runs - Then: it works - """ - raise NotImplementedError - - -def test_my_feature_11223344() -> None: - """ - Given: another thing - When: it runs - Then: it also works - """ - raise NotImplementedError -''', - ) - sync_stubs(features_dir, tests_dir) - content = test_file.read_text(encoding="utf-8") - assert content.count("@pytest.mark.deprecated") == 2 - - @pytest.mark.unit - def test_deprecation_sync_a9e1c504( - self, - tmp_path: Path, - make_feature: Callable[..., None], - make_test_file: Callable[..., Path], - ) -> None: - """ - Given: a backlog feature tagged @deprecated at the Feature level containing multiple Rules and Examples - When: pytest is invoked - Then: all test stubs for all Examples in that feature have @pytest.mark.deprecated applied - """ - features_dir = tmp_path / "features" - tests_dir = tmp_path / "tests" - make_feature( - features_dir, - "backlog", - "my-feature", - "my-story.feature", - """\ -@deprecated -Feature: My feature - Rule: Rule one - @id:aabbccdd - Example: First example - Given something - When it runs - Then it works - Rule: Rule two - @id:11223344 - Example: Second example - Given another thing - When it runs - Then it also works -""", - ) - test_file_one = make_test_file( - tests_dir, - "my_feature", - "rule_one", - '''\ -"""Tests for my feature rule one.""" - -import pytest - - -class TestRuleOne: - def test_my_feature_aabbccdd(self) -> None: - """ - Given: something - When: it runs - Then: it works - """ - raise NotImplementedError -''', - ) - test_file_two = make_test_file( - tests_dir, - "my_feature", - "rule_two", - '''\ -"""Tests for my feature rule two.""" - -import pytest - - -class TestRuleTwo: - def test_my_feature_11223344(self) -> None: - """ - Given: another thing - When: it runs - Then: it also works - """ - raise NotImplementedError -''', - ) - sync_stubs(features_dir, tests_dir) - content_one = test_file_one.read_text(encoding="utf-8") - content_two = test_file_two.read_text(encoding="utf-8") - assert "@pytest.mark.deprecated" in content_one - assert "@pytest.mark.deprecated" in content_two - - @pytest.mark.unit - def test_deprecation_sync_d6f8b231( - self, - tmp_path: Path, - make_feature: Callable[..., None], - make_test_file: Callable[..., Path], - ) -> None: - """ - Given: a Rule whose @deprecated tag has been removed but whose child test stubs all have @pytest.mark.deprecated - When: pytest is invoked - Then: @pytest.mark.deprecated is removed from all child test stubs of that Rule - """ - features_dir = tmp_path / "features" - tests_dir = tmp_path / "tests" - make_feature( - features_dir, - "backlog", - "my-feature", - "my-story.feature", - """\ -Feature: My feature - Rule: Active rule - @id:aabbccdd - Example: First example - Given something - When it runs - Then it works - @id:11223344 - Example: Second example - Given another thing - When it runs - Then it also works -""", - ) - test_file = make_test_file( - tests_dir, - "my_feature", - "active_rule", - '''\ -"""Tests for my feature active rule.""" - -import pytest - - -class TestActiveRule: - @pytest.mark.deprecated - def test_my_feature_aabbccdd(self) -> None: - """ - Given: something - When: it runs - Then: it works - """ - raise NotImplementedError - - @pytest.mark.deprecated - def test_my_feature_11223344(self) -> None: - """ - Given: another thing - When: it runs - Then: it also works - """ - raise NotImplementedError -''', - ) - sync_stubs(features_dir, tests_dir) - content = test_file.read_text(encoding="utf-8") - assert "@pytest.mark.deprecated" not in content diff --git a/tests/features/deprecation_sync/remove_deprecated_marker_test.py b/tests/features/deprecation_sync/remove_deprecated_marker_test.py deleted file mode 100644 index a9d1138..0000000 --- a/tests/features/deprecation_sync/remove_deprecated_marker_test.py +++ /dev/null @@ -1,64 +0,0 @@ -"""Tests for deprecation sync — remove deprecated marker rule.""" - -from __future__ import annotations - -from pathlib import Path -from typing import Callable - -from pytest_beehave.sync_engine import run_sync as sync_stubs - - -class TestRemoveDeprecatedMarker: - """Tests for the Remove deprecated marker Rule.""" - - def test_deprecation_sync_7fcee92a( - self, - tmp_path: Path, - make_feature: Callable[..., None], - make_test_file: Callable[..., Path], - ) -> None: - """ - Given: a feature with an Example that no longer has the @deprecated tag but whose test stub has @pytest.mark.deprecated - When: pytest is invoked - Then: @pytest.mark.deprecated is removed from that test stub - """ - features_dir = tmp_path / "features" - tests_dir = tmp_path / "tests" - make_feature( - features_dir, - "in-progress", - "my-feature", - "my-story.feature", - """\ -Feature: My feature - @id:aabbccdd - Example: A formerly deprecated example - Given a thing - When it runs - Then it works -""", - ) - test_file = make_test_file( - tests_dir, - "my_feature", - "examples", - """\ -\"\"\"Tests for my feature examples.\"\"\" - -import pytest - - -@pytest.mark.deprecated -def test_my_feature_aabbccdd() -> None: - \"\"\" - Given: a thing - When: it runs - Then: it works - \"\"\" - raise NotImplementedError -""", - ) - sync_stubs(features_dir, tests_dir) - content = test_file.read_text(encoding="utf-8") - assert "@pytest.mark.deprecated" not in content - assert "def test_my_feature_aabbccdd() -> None:" in content diff --git a/tests/features/example_hatch/capability_showcase_content_test.py b/tests/features/example_hatch/capability_showcase_content_test.py deleted file mode 100644 index 991ce9b..0000000 --- a/tests/features/example_hatch/capability_showcase_content_test.py +++ /dev/null @@ -1,101 +0,0 @@ -"""Tests for capability showcase content story.""" - -from pathlib import Path - -import pytest - -from pytest_beehave.hatch import run_hatch - - -@pytest.fixture -def hatched(tmp_path: Path) -> Path: - """Run hatch into tmp_path and return the features root.""" - features_root = tmp_path / "features" - run_hatch(features_root, force=False) - return features_root - - -def _all_contents(features_root: Path) -> list[str]: - return [f.read_text(encoding="utf-8") for f in features_root.rglob("*.feature")] - - -def _has_untagged_example(content: str) -> bool: - lines = content.splitlines() - for i, line in enumerate(lines): - if line.strip().startswith("Example:") and i > 0: - prev = lines[i - 1].strip() - if not prev.startswith("@id:"): - return True - return False - - -def test_example_hatch_7a8b9c0d(hatched: Path) -> None: - """ - Given: no features directory exists at the configured path - When: pytest is invoked with --beehave-hatch - Then: at least one generated .feature file contains an Example with no @id tag - """ - contents = _all_contents(hatched) - assert any(_has_untagged_example(c) for c in contents) - - -def test_example_hatch_8b9c0d1e(hatched: Path) -> None: - """ - Given: no features directory exists at the configured path - When: pytest is invoked with --beehave-hatch - Then: at least one generated .feature file contains an Example tagged @deprecated - """ - contents = _all_contents(hatched) - assert any("@deprecated" in content for content in contents) - - -def test_example_hatch_9c0d1e2f(hatched: Path) -> None: - """ - Given: no features directory exists at the configured path - When: pytest is invoked with --beehave-hatch - Then: at least one generated .feature file begins with a # language: directive - """ - files = list(hatched.rglob("*.feature")) - assert any(f.read_text(encoding="utf-8").startswith("# language:") for f in files) - - -def test_example_hatch_0d1e2f3a(hatched: Path) -> None: - """ - Given: no features directory exists at the configured path - When: pytest is invoked with --beehave-hatch - Then: at least one generated .feature file contains a Background: block - """ - contents = _all_contents(hatched) - assert any("Background:" in content for content in contents) - - -def test_example_hatch_1e2f3a4b(hatched: Path) -> None: - """ - Given: no features directory exists at the configured path - When: pytest is invoked with --beehave-hatch - Then: at least one generated .feature file contains a Scenario Outline with an Examples: table - """ - contents = _all_contents(hatched) - assert any( - "Scenario Outline:" in content and "Examples:" in content - for content in contents - ) - - -def test_example_hatch_a1f2e3d4(hatched: Path) -> None: - """ - Given: no features directory exists at the configured path - When: pytest is invoked with --beehave-hatch - Then: at least one generated .feature file contains a step followed by a data table - """ - contents = _all_contents(hatched) - assert any("| " in content for content in contents) - - -def test_example_hatch_b2e3d4c5(hatched: Path) -> None: - """ - Given: no features directory exists at the configured path - When: pytest is invoked with --beehave-hatch - Then: at least one generated .feature file is placed in the completed subfolder - """ - assert list((hatched / "completed").glob("*.feature")) diff --git a/tests/features/example_hatch/configured_path_respect_test.py b/tests/features/example_hatch/configured_path_respect_test.py deleted file mode 100644 index 8e82abb..0000000 --- a/tests/features/example_hatch/configured_path_respect_test.py +++ /dev/null @@ -1,20 +0,0 @@ -"""Tests for configured path respect story.""" - -from pathlib import Path - -from pytest_beehave.hatch import run_hatch - - -def test_example_hatch_c3d4e5f6(tmp_path: Path) -> None: - """ - Given: pyproject.toml contains [tool.beehave] with features_path set to a custom directory - When: pytest is invoked with --beehave-hatch - Then: the generated .feature files are written under the custom configured path and not under docs/features/ - """ - custom_path = tmp_path / "my_custom_features" - default_path = tmp_path / "docs" / "features" - - run_hatch(custom_path, force=False) - - assert list(custom_path.rglob("*.feature")) - assert not default_path.exists() diff --git a/tests/features/example_hatch/hatch_invocation_test.py b/tests/features/example_hatch/hatch_invocation_test.py deleted file mode 100644 index 083af03..0000000 --- a/tests/features/example_hatch/hatch_invocation_test.py +++ /dev/null @@ -1,86 +0,0 @@ -"""Tests for hatch invocation story.""" - -import io -import sys -from pathlib import Path - -import pytest - -from pytest_beehave.hatch import run_hatch - - -@pytest.fixture -def features_root(tmp_path: Path) -> Path: - """Return a non-existent features root under tmp_path.""" - return tmp_path / "features" - - -def test_example_hatch_1a2b3c4d(features_root: Path) -> None: - """ - Given: no features directory exists at the configured path - When: pytest is invoked with --beehave-hatch - Then: the backlog, in-progress, and completed subfolders exist under the configured features path - """ - run_hatch(features_root, force=False) - - assert (features_root / "backlog").is_dir() - assert (features_root / "in-progress").is_dir() - assert (features_root / "completed").is_dir() - - -def test_example_hatch_2b3c4d5e(features_root: Path) -> None: - """ - Given: no features directory exists at the configured path - When: pytest is invoked with --beehave-hatch - Then: at least one .feature file exists in each of the backlog, in-progress, and completed subfolders - """ - run_hatch(features_root, force=False) - - assert list((features_root / "backlog").glob("*.feature")) - assert list((features_root / "in-progress").glob("*.feature")) - assert list((features_root / "completed").glob("*.feature")) - - -@pytest.mark.slow -def test_example_hatch_3c4d5e6f(tmp_path: Path) -> None: - """ - Given: no features directory exists at the configured path - When: pytest is invoked with --beehave-hatch - Then: the terminal output lists each .feature file that was created - """ - captured = io.StringIO() - old_stdout = sys.stdout - sys.stdout = captured - try: - result = pytest.main( - [ - "--beehave-hatch", - f"--rootdir={tmp_path}", - "--no-cov", - ], - plugins=[], - ) - finally: - sys.stdout = old_stdout - - assert result == 0 - output = captured.getvalue() - features_root = tmp_path / "docs" / "features" - written = list(features_root.rglob("*.feature")) - assert written - for feature_file in written: - relative = str(feature_file.relative_to(features_root)) - assert relative in output - - -def test_example_hatch_4d5e6f7a(tmp_path: Path) -> None: - """ - Given: no features directory exists at the configured path - When: pytest is invoked with --beehave-hatch - Then: no tests are collected or executed - """ - result = pytest.main( - ["--beehave-hatch", f"--rootdir={tmp_path}", "--co", "-q", "--no-cov"], - plugins=[], - ) - assert result == pytest.ExitCode.NO_TESTS_COLLECTED or result == 0 diff --git a/tests/features/example_hatch/overwrite_protection_test.py b/tests/features/example_hatch/overwrite_protection_test.py deleted file mode 100644 index 84aaa79..0000000 --- a/tests/features/example_hatch/overwrite_protection_test.py +++ /dev/null @@ -1,57 +0,0 @@ -"""Tests for overwrite protection story.""" - -import io -import sys -from pathlib import Path - -import pytest - - -@pytest.mark.slow -def test_example_hatch_5e6f7a8b(tmp_path: Path) -> None: - """ - Given: the configured features directory already contains at least one .feature file - When: pytest is invoked with --beehave-hatch - Then: the pytest run exits with a non-zero status code and an error naming the conflicting path - """ - features_root = tmp_path / "docs" / "features" - existing = features_root / "backlog" / "existing.feature" - existing.parent.mkdir(parents=True) - existing.write_text("Feature: existing\n", encoding="utf-8") - - captured = io.StringIO() - old_stderr = sys.stderr - sys.stderr = captured - try: - result = pytest.main( - [ - "--beehave-hatch", - f"--rootdir={tmp_path}", - "--no-cov", - ], - plugins=[], - ) - finally: - sys.stderr = old_stderr - - assert result == 1 - assert str(existing) in captured.getvalue() - - -def test_example_hatch_6f7a8b9c(tmp_path: Path) -> None: - """ - Given: the configured features directory already contains at least one .feature file - When: pytest is invoked with --beehave-hatch --beehave-hatch-force - Then: the existing .feature files are replaced with the newly generated hatch content - """ - from pytest_beehave.hatch import run_hatch - - features_root = tmp_path / "features" - existing = features_root / "backlog" / "existing.feature" - existing.parent.mkdir(parents=True) - existing.write_text("Feature: existing\n", encoding="utf-8") - - run_hatch(features_root, force=True) - - assert not existing.exists() - assert list(features_root.rglob("*.feature")) diff --git a/tests/features/example_hatch/stdlib_only_randomisation_test.py b/tests/features/example_hatch/stdlib_only_randomisation_test.py deleted file mode 100644 index 52b7942..0000000 --- a/tests/features/example_hatch/stdlib_only_randomisation_test.py +++ /dev/null @@ -1,44 +0,0 @@ -"""Tests for stdlib only randomisation story.""" - -from pathlib import Path - -import pytest - -from pytest_beehave.hatch import run_hatch - - -@pytest.mark.slow -def test_example_hatch_d4e5f6a7(tmp_path: Path) -> None: - """ - Given: no features directory exists at the configured path - When: pytest is invoked with --beehave-hatch on two separate occasions with the directory removed between runs - Then: the Feature name in the generated .feature file differs between the two runs - """ - import shutil - - features_root = tmp_path / "features" - seen: set[str] = set() - # Run up to 20 times; probability of all identical is (1/10)^19 ≈ 0 - for _ in range(20): - if features_root.exists(): - shutil.rmtree(features_root) - run_hatch(features_root, force=False) - backlog_files = list((features_root / "backlog").glob("*.feature")) - content = backlog_files[0].read_text(encoding="utf-8") - first_line = content.splitlines()[0] - seen.add(first_line) - if len(seen) > 1: - break - - assert len(seen) > 1 - - -def test_example_hatch_e5f6a7b8(tmp_path: Path) -> None: - """ - Given: a clean environment with only pytest-beehave installed and no other packages - When: pytest is invoked with --beehave-hatch - Then: the hatch completes successfully and no import error or missing-module error is raised - """ - features_root = tmp_path / "features" - written = run_hatch(features_root, force=False) - assert written diff --git a/tests/features/features_dir_bootstrap/feature_file_migration_test.py b/tests/features/features_dir_bootstrap/feature_file_migration_test.py deleted file mode 100644 index 19bc33d..0000000 --- a/tests/features/features_dir_bootstrap/feature_file_migration_test.py +++ /dev/null @@ -1,99 +0,0 @@ -"""Tests for features dir bootstrap — feature file migration rule.""" - -from __future__ import annotations - -from pathlib import Path - -import pytest - -from pytest_beehave.bootstrap import bootstrap_features_directory -from pytest_beehave.reporter import report_bootstrap - - -class TestFeatureFileMigration: - """Tests for the Feature file migration Rule.""" - - @pytest.mark.unit - def test_feature_file_migration_e8b61d04(self, tmp_path: Path) -> None: - """ - Given: the features directory contains a .feature file directly (not inside any subfolder) - When: pytest is invoked - Then: that .feature file exists in the backlog subfolder and no longer exists in the root features directory - """ - features_root = tmp_path / "features" - features_root.mkdir() - for subfolder in ("backlog", "in-progress", "completed"): - (features_root / subfolder).mkdir() - loose = features_root / "my-feature.feature" - loose.write_text("Feature: My Feature\n", encoding="utf-8") - - bootstrap_features_directory(features_root) - - assert not loose.exists() - assert (features_root / "backlog" / "my-feature.feature").exists() - - @pytest.mark.unit - def test_feature_file_migration_f3c97a52(self, tmp_path: Path) -> None: - """ - Given: the features directory contains a non-.feature file (e.g. discovery.md) directly in the root - When: pytest is invoked - Then: that file remains in the root features directory and is not moved to backlog - """ - features_root = tmp_path / "features" - features_root.mkdir() - for subfolder in ("backlog", "in-progress", "completed"): - (features_root / subfolder).mkdir() - non_feature = features_root / "discovery.md" - non_feature.write_text("# Discovery\n", encoding="utf-8") - - bootstrap_features_directory(features_root) - - assert non_feature.exists() - assert not (features_root / "backlog" / "discovery.md").exists() - - @pytest.mark.unit - def test_feature_file_migration_a9d02b6e(self, tmp_path: Path) -> None: - """ - Given: the features directory contains a .feature file inside the in-progress subfolder - When: pytest is invoked - Then: that file remains in the in-progress subfolder and is not moved to backlog - """ - features_root = tmp_path / "features" - features_root.mkdir() - in_progress = features_root / "in-progress" - in_progress.mkdir() - (features_root / "backlog").mkdir() - (features_root / "completed").mkdir() - nested = in_progress / "my-feature" / "story.feature" - nested.parent.mkdir(parents=True) - nested.write_text("Feature: My Feature\n", encoding="utf-8") - - bootstrap_features_directory(features_root) - - assert nested.exists() - assert not (features_root / "backlog" / "story.feature").exists() - - @pytest.mark.unit - def test_feature_file_migration_d1e74c83(self, tmp_path: Path) -> None: - """ - Given: the features directory contains a .feature file directly in the root - When: pytest is invoked - Then: the terminal output names the file that was moved to backlog - """ - features_root = tmp_path / "features" - features_root.mkdir() - for subfolder in ("backlog", "in-progress", "completed"): - (features_root / subfolder).mkdir() - loose = features_root / "my-feature.feature" - loose.write_text("Feature: My Feature\n", encoding="utf-8") - - result = bootstrap_features_directory(features_root) - - lines: list[str] = [] - - class _Writer: - def line(self, text: str = "") -> None: - lines.append(text) - - report_bootstrap(_Writer(), result) - assert any("my-feature.feature" in line for line in lines) diff --git a/tests/features/features_dir_bootstrap/migration_collision_handling_test.py b/tests/features/features_dir_bootstrap/migration_collision_handling_test.py deleted file mode 100644 index b886abb..0000000 --- a/tests/features/features_dir_bootstrap/migration_collision_handling_test.py +++ /dev/null @@ -1,65 +0,0 @@ -"""Tests for features dir bootstrap — migration collision handling rule.""" - -from __future__ import annotations - -from pathlib import Path - -import pytest - -from pytest_beehave.bootstrap import bootstrap_features_directory -from pytest_beehave.reporter import report_bootstrap - - -class TestMigrationCollisionHandling: - """Tests for the Migration collision handling Rule.""" - - @pytest.mark.unit - def test_migration_collision_handling_7f2a0d51(self, tmp_path: Path) -> None: - """ - Given: the features directory contains root-level feature.feature and backlog/feature.feature already exists - When: pytest is invoked - Then: root-level feature.feature is not moved and backlog/feature.feature is unchanged - """ - features_root = tmp_path / "features" - features_root.mkdir() - for subfolder in ("backlog", "in-progress", "completed"): - (features_root / subfolder).mkdir() - root_file = features_root / "feature.feature" - root_file.write_text("Feature: Root\n", encoding="utf-8") - backlog_file = features_root / "backlog" / "feature.feature" - backlog_file.write_text("Feature: Backlog\n", encoding="utf-8") - - bootstrap_features_directory(features_root) - - assert root_file.exists() - assert root_file.read_text(encoding="utf-8") == "Feature: Root\n" - assert backlog_file.read_text(encoding="utf-8") == "Feature: Backlog\n" - - @pytest.mark.unit - def test_migration_collision_handling_8c3b1e96(self, tmp_path: Path) -> None: - """ - Given: the features directory contains root-level feature.feature and backlog/feature.feature already exists - When: pytest is invoked - Then: the terminal output contains a warning naming the conflicting file and its location - """ - features_root = tmp_path / "features" - features_root.mkdir() - for subfolder in ("backlog", "in-progress", "completed"): - (features_root / subfolder).mkdir() - root_file = features_root / "feature.feature" - root_file.write_text("Feature: Root\n", encoding="utf-8") - (features_root / "backlog" / "feature.feature").write_text( - "Feature: Backlog\n", encoding="utf-8" - ) - - result = bootstrap_features_directory(features_root) - - lines: list[str] = [] - - class _Writer: - def line(self, text: str = "") -> None: - lines.append(text) - - report_bootstrap(_Writer(), result) - assert any("feature.feature" in line for line in lines) - assert any("WARNING" in line or "Cannot migrate" in line for line in lines) diff --git a/tests/features/features_dir_bootstrap/no_op_when_structure_is_correct_test.py b/tests/features/features_dir_bootstrap/no_op_when_structure_is_correct_test.py deleted file mode 100644 index c11f756..0000000 --- a/tests/features/features_dir_bootstrap/no_op_when_structure_is_correct_test.py +++ /dev/null @@ -1,59 +0,0 @@ -"""Tests for features dir bootstrap — no-op when structure is correct rule.""" - -from __future__ import annotations - -from pathlib import Path - -import pytest - -from pytest_beehave.bootstrap import bootstrap_features_directory -from pytest_beehave.reporter import report_bootstrap - - -class TestNoOpWhenStructureIsCorrect: - """Tests for the No-op when structure is correct Rule.""" - - @pytest.mark.unit - def test_no_op_when_structure_is_correct_5e6f9b17(self, tmp_path: Path) -> None: - """ - Given: the features directory contains backlog, in-progress, and completed subfolders and no root-level .feature files - When: pytest is invoked - Then: the terminal output contains no bootstrap messages - """ - features_root = tmp_path / "features" - features_root.mkdir() - for subfolder in ("backlog", "in-progress", "completed"): - (features_root / subfolder).mkdir() - - result = bootstrap_features_directory(features_root) - - lines: list[str] = [] - - class _Writer: - def line(self, text: str = "") -> None: - lines.append(text) - - report_bootstrap(_Writer(), result) - assert lines == [] - - @pytest.mark.unit - def test_no_op_when_structure_is_correct_2d8a4c70(self, tmp_path: Path) -> None: - """ - Given: the features directory does not exist - When: pytest is invoked - Then: pytest completes collection without errors and no bootstrap messages appear in the terminal - """ - features_root = tmp_path / "features" - # features_root does not exist - - result = bootstrap_features_directory(features_root) - - lines: list[str] = [] - - class _Writer: - def line(self, text: str = "") -> None: - lines.append(text) - - report_bootstrap(_Writer(), result) - assert result.is_noop - assert lines == [] diff --git a/tests/features/features_dir_bootstrap/subfolder_creation_test.py b/tests/features/features_dir_bootstrap/subfolder_creation_test.py deleted file mode 100644 index 1ef3075..0000000 --- a/tests/features/features_dir_bootstrap/subfolder_creation_test.py +++ /dev/null @@ -1,73 +0,0 @@ -"""Tests for features dir bootstrap — subfolder creation rule.""" - -from __future__ import annotations - -from pathlib import Path - -import pytest - -from pytest_beehave.bootstrap import bootstrap_features_directory -from pytest_beehave.reporter import report_bootstrap - - -class TestSubfolderCreation: - """Tests for the Subfolder creation Rule.""" - - @pytest.mark.unit - def test_subfolder_creation_3a1f8c2e(self, tmp_path: Path) -> None: - """ - Given: the features directory exists with no backlog, in-progress, or completed subfolders - When: pytest is invoked - Then: the backlog, in-progress, and completed subfolders all exist inside the features directory - """ - features_root = tmp_path / "features" - features_root.mkdir() - - bootstrap_features_directory(features_root) - - assert (features_root / "backlog").is_dir() - assert (features_root / "in-progress").is_dir() - assert (features_root / "completed").is_dir() - - @pytest.mark.unit - def test_subfolder_creation_b7d4e091(self, tmp_path: Path) -> None: - """ - Given: the features directory exists with a backlog subfolder but no in-progress or completed subfolders - When: pytest is invoked - Then: the in-progress and completed subfolders are created and the backlog subfolder is unchanged - """ - features_root = tmp_path / "features" - features_root.mkdir() - (features_root / "backlog").mkdir() - sentinel = features_root / "backlog" / "my-feature.feature" - sentinel.write_text("Feature: My Feature\n", encoding="utf-8") - - bootstrap_features_directory(features_root) - - assert (features_root / "in-progress").is_dir() - assert (features_root / "completed").is_dir() - assert sentinel.exists() - assert sentinel.read_text(encoding="utf-8") == "Feature: My Feature\n" - - @pytest.mark.unit - def test_subfolder_creation_c2a53f7d(self, tmp_path: Path) -> None: - """ - Given: the features directory exists with no backlog, in-progress, or completed subfolders - When: pytest is invoked - Then: the terminal output names each subfolder that was created - """ - features_root = tmp_path / "features" - features_root.mkdir() - - result = bootstrap_features_directory(features_root) - - lines: list[str] = [] - - class _Writer: - def line(self, text: str = "") -> None: - lines.append(text) - - report_bootstrap(_Writer(), result) - assert any("backlog" in line for line in lines) - assert any("in-progress" in line for line in lines) - assert any("completed" in line for line in lines) diff --git a/tests/features/features_path_config/features_path_config_test.py b/tests/features/features_path_config/features_path_config_test.py deleted file mode 100644 index 389f2d9..0000000 --- a/tests/features/features_path_config/features_path_config_test.py +++ /dev/null @@ -1,58 +0,0 @@ -"""Tests for features path config story.""" - -from pathlib import Path - -import pytest - -from pytest_beehave.config import resolve_features_path - - -class TestCustomFeaturesPath: - """Tests for the Custom features path Rule.""" - - def test_features_path_config_acf12157(self, tmp_path: Path) -> None: - """ - Given: pyproject.toml contains [tool.beehave] with features_path set to a custom directory - When: pytest is invoked - Then: the plugin reads .feature files from the configured custom directory - """ - pyproject = tmp_path / "pyproject.toml" - pyproject.write_text('[tool.beehave]\nfeatures_path = "custom/path"\n') - result = resolve_features_path(tmp_path) - assert result == tmp_path / "custom" / "path" - - def test_features_path_config_124f65e7(self, pytester: pytest.Pytester) -> None: - """ - Given: pyproject.toml contains [tool.beehave] with features_path pointing to a non-existent directory - When: pytest is invoked - Then: the pytest run exits with a non-zero status code and an error naming the missing path - """ - pytester.makepyprojecttoml('[tool.beehave]\nfeatures_path = "no/such/dir"\n') - pytester.makepyfile("def test_placeholder(): pass") - result = pytester.runpytest() - assert result.ret != 0 - result.stderr.fnmatch_lines(["*no/such/dir*"]) - - -class TestDefaultFeaturesPath: - """Tests for the Default features path Rule.""" - - def test_features_path_config_ce8a95e7(self, tmp_path: Path) -> None: - """ - Given: pyproject.toml contains no [tool.beehave] section - When: pytest is invoked - Then: the plugin reads .feature files from docs/features/ relative to the project root - """ - pyproject = tmp_path / "pyproject.toml" - pyproject.write_text("[tool.pytest.ini_options]\nminversion = '6.0'\n") - result = resolve_features_path(tmp_path) - assert result == tmp_path / "docs" / "features" - - def test_features_path_config_aaeda817(self, tmp_path: Path) -> None: - """ - Given: no pyproject.toml exists in the project root - When: pytest is invoked - Then: the plugin reads .feature files from docs/features/ relative to the project root - """ - result = resolve_features_path(tmp_path) - assert result == tmp_path / "docs" / "features" diff --git a/tests/features/multilingual_feature_parsing/chinese_feature_file_parsing_test.py b/tests/features/multilingual_feature_parsing/chinese_feature_file_parsing_test.py deleted file mode 100644 index 944ea6d..0000000 --- a/tests/features/multilingual_feature_parsing/chinese_feature_file_parsing_test.py +++ /dev/null @@ -1,40 +0,0 @@ -"""Tests for multilingual feature parsing — Chinese feature file parsing rule.""" - -from pathlib import Path - -from pytest_beehave.feature_parser import ParsedFeature, parse_feature -from pytest_beehave.models import ExampleId - - -def _write_chinese_feature(directory: Path) -> Path: - """Write a minimal Chinese Gherkin feature file to directory.""" - path = directory / "gushi.feature" - path.write_text( - "# language: zh-CN\n" - "功能: 中文功能\n\n" - " @id:e5f6a7b8\n" - " 场景: 一个场景\n" - " 假设 某事\n" - " 当 某事发生\n" - " 那么 某事通过\n", - encoding="utf-8", - ) - return path - - -class TestChineseFeatureFileParsing: - """Tests for the Chinese feature file parsing Rule.""" - - def test_multilingual_feature_parsing_55e4d669(self, tmp_path: Path) -> None: - """ - Given: a valid Chinese Gherkin feature file - When: parse_feature is called on that file - Then: a ParsedFeature is returned with the correct number of examples - """ - feature_dir = tmp_path / "zhong-wen-gong-neng" - feature_dir.mkdir() - feature_file = _write_chinese_feature(feature_dir) - result = parse_feature(feature_file, folder_name="zhong-wen-gong-neng") - assert isinstance(result, ParsedFeature) - assert len(result.top_level_examples) == 1 - assert ExampleId("e5f6a7b8") in result.all_example_ids() diff --git a/tests/features/multilingual_feature_parsing/mixed_language_project_compatibility_test.py b/tests/features/multilingual_feature_parsing/mixed_language_project_compatibility_test.py deleted file mode 100644 index 8ba815c..0000000 --- a/tests/features/multilingual_feature_parsing/mixed_language_project_compatibility_test.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Tests for multilingual feature parsing — mixed-language project compatibility rule.""" - -from pathlib import Path - -from pytest_beehave.feature_parser import ParsedFeature, parse_feature -from pytest_beehave.models import ExampleId - - -def _write_spanish_feature(directory: Path) -> Path: - """Write a minimal Spanish Gherkin feature file to directory.""" - path = directory / "historia.feature" - path.write_text( - "# language: es\n" - "Característica: Mi funcionalidad\n\n" - " @id:a1b2c3d4\n" - " Escenario: Un escenario\n" - " Dado algo\n" - " Cuando algo ocurre\n" - " Entonces algo pasa\n", - encoding="utf-8", - ) - return path - - -def _write_english_feature(directory: Path) -> Path: - """Write a minimal English Gherkin feature file to directory.""" - path = directory / "story.feature" - path.write_text( - "Feature: My feature\n\n" - " @id:e1f2a3b4\n" - " Example: An example\n" - " Given something\n" - " When something happens\n" - " Then something passes\n", - encoding="utf-8", - ) - return path - - -class TestMixedLanguageProjectCompatibility: - """Tests for the Mixed-language project compatibility Rule.""" - - def test_multilingual_feature_parsing_3c04262e(self, tmp_path: Path) -> None: - """ - Given: a project containing a valid Spanish Gherkin feature file and a valid English feature file - When: parse_feature is called on each file independently - Then: both files are parsed successfully and return valid ParsedFeature objects - """ - spanish_dir = tmp_path / "mi-funcionalidad" - spanish_dir.mkdir() - english_dir = tmp_path / "my-feature" - english_dir.mkdir() - spanish_file = _write_spanish_feature(spanish_dir) - english_file = _write_english_feature(english_dir) - spanish_result = parse_feature(spanish_file, folder_name="mi-funcionalidad") - english_result = parse_feature(english_file, folder_name="my-feature") - assert isinstance(spanish_result, ParsedFeature) - assert isinstance(english_result, ParsedFeature) - assert ExampleId("a1b2c3d4") in spanish_result.all_example_ids() - assert ExampleId("e1f2a3b4") in english_result.all_example_ids() diff --git a/tests/features/multilingual_feature_parsing/spanish_feature_file_parsing_test.py b/tests/features/multilingual_feature_parsing/spanish_feature_file_parsing_test.py deleted file mode 100644 index 57ff6b7..0000000 --- a/tests/features/multilingual_feature_parsing/spanish_feature_file_parsing_test.py +++ /dev/null @@ -1,40 +0,0 @@ -"""Tests for multilingual feature parsing — Spanish feature file parsing rule.""" - -from pathlib import Path - -from pytest_beehave.feature_parser import ParsedFeature, parse_feature -from pytest_beehave.models import ExampleId - - -def _write_spanish_feature(directory: Path) -> Path: - """Write a minimal Spanish Gherkin feature file to directory.""" - path = directory / "historia.feature" - path.write_text( - "# language: es\n" - "Característica: Mi funcionalidad\n\n" - " @id:a1b2c3d4\n" - " Ejemplo: Un ejemplo\n" - " Dado algo\n" - " Cuando algo ocurre\n" - " Entonces algo pasa\n", - encoding="utf-8", - ) - return path - - -class TestSpanishFeatureFileParsing: - """Tests for the Spanish feature file parsing Rule.""" - - def test_multilingual_feature_parsing_e1081346(self, tmp_path: Path) -> None: - """ - Given: a valid Spanish Gherkin feature file - When: parse_feature is called on that file - Then: a ParsedFeature is returned with the correct number of examples - """ - feature_dir = tmp_path / "mi-funcionalidad" - feature_dir.mkdir() - feature_file = _write_spanish_feature(feature_dir) - result = parse_feature(feature_file, folder_name="mi-funcionalidad") - assert isinstance(result, ParsedFeature) - assert len(result.top_level_examples) == 1 - assert ExampleId("a1b2c3d4") in result.all_example_ids() diff --git a/tests/features/plugin_hook/graceful_handling_test.py b/tests/features/plugin_hook/graceful_handling_test.py deleted file mode 100644 index 3497da6..0000000 --- a/tests/features/plugin_hook/graceful_handling_test.py +++ /dev/null @@ -1,18 +0,0 @@ -"""Tests for plugin hook — graceful handling rule.""" - -import pytest - - -class TestGracefulHandling: - """Tests for the Graceful handling Rule.""" - - def test_plugin_hook_d0f2866d(self, pytester: pytest.Pytester) -> None: - """ - Given: no pyproject.toml [tool.beehave] section is present and the default docs/features/ directory does not exist - When: pytest is invoked - Then: pytest completes collection without errors - """ - pytester.makepyprojecttoml("[tool.pytest.ini_options]\nminversion = '6.0'\n") - pytester.makepyfile("def test_placeholder(): pass") - result = pytester.runpytest() - assert result.ret == 0 diff --git a/tests/features/plugin_hook/stub_sync_runs_before_collection_test.py b/tests/features/plugin_hook/stub_sync_runs_before_collection_test.py deleted file mode 100644 index ffd14bb..0000000 --- a/tests/features/plugin_hook/stub_sync_runs_before_collection_test.py +++ /dev/null @@ -1,63 +0,0 @@ -"""Tests for plugin hook — stub sync runs before collection rule.""" - -import pytest - - -def _make_backlog_feature(pytester: pytest.Pytester) -> None: - """Create a minimal backlog feature file with one @id Example.""" - pytester.makefile( - ".feature", - **{ - "docs/features/backlog/my-feature/my-story": ( - "Feature: My feature\n" - " @id:aabbccdd\n" - " Example: Something happens\n" - " Given a condition\n" - " When an action\n" - " Then an outcome\n" - ) - }, - ) - - -class TestStubSyncRunsBeforeCollection: - """Tests for the Stub sync runs before collection Rule.""" - - def test_plugin_hook_bde8de30(self, pytester: pytest.Pytester) -> None: - """ - Given: a project with a backlog feature containing a new Example with an @id tag - When: pytest is invoked - Then: the test stub for that Example exists before any tests are collected - """ - _make_backlog_feature(pytester) - pytester.makeconftest( - "def pytest_collectstart(collector):\n" - " stub = (\n" - " collector.config.rootpath\n" - " / 'tests' / 'features' / 'my_feature' / 'examples_test.py'\n" - " )\n" - " assert stub.exists(), f'Stub not found before collection: {stub}'\n" - ) - pytester.makepyfile("def test_placeholder(): pass") - result = pytester.runpytest("--ignore=tests/features/") - assert result.ret == 0 - - def test_plugin_hook_d5824c75(self, pytester: pytest.Pytester) -> None: - """ - Given: a project with a backlog feature containing a new Example - When: pytest is invoked - Then: the terminal output includes a summary of the stub sync actions taken - """ - _make_backlog_feature(pytester) - pytester.makepyfile("def test_placeholder(): pass") - result = pytester.runpytest() - result.stdout.fnmatch_lines(["*CREATE*examples_test.py*"]) - - @pytest.mark.deprecated - def test_plugin_hook_e3a13b58(self) -> None: - """ - Given: a project where the configured features directory does not exist - When: pytest is invoked - Then: pytest completes collection without errors - """ - raise NotImplementedError diff --git a/tests/features/report_steps/html_acceptance_criteria_column_test.py b/tests/features/report_steps/html_acceptance_criteria_column_test.py deleted file mode 100644 index 8faa7d2..0000000 --- a/tests/features/report_steps/html_acceptance_criteria_column_test.py +++ /dev/null @@ -1,106 +0,0 @@ -"""Tests for report-steps — HTML Acceptance Criteria Column rule.""" - -import pytest - - -def test_report_steps_88d58f5c(pytester: pytest.Pytester) -> None: - """ - Given: pytest-html is installed and show_steps_in_html = true - And: a test in tests/features/ with a docstring - When: the pytest-html report is generated - Then: the "Acceptance Criteria" column contains the verbatim docstring for that test - """ - # Given - pytester.makepyprojecttoml( - "[tool.pytest.ini_options]\nminversion = '6.0'\n" - "[tool.beehave]\nshow_steps_in_html = true\n" - ) - pytester.makepyfile( - **{ - "tests/features/my_feature/my_rule_test": ( - "def test_my_feature_aabbccdd():\n" - " '''\n" - " Given: a condition\n" - " When: an action\n" - " Then: an outcome\n" - " '''\n" - " pass\n" - ) - } - ) - report_path = pytester.path / "report.html" - # When - pytester.runpytest("--html", str(report_path), "--ignore=docs") - # Then - assert report_path.exists() - html_content = report_path.read_text(encoding="utf-8") - assert "Acceptance Criteria" in html_content - assert "Given: a condition" in html_content - - -def test_report_steps_73c4a71a(pytester: pytest.Pytester) -> None: - """ - Given: pytest-html is installed and show_steps_in_html = true - And: a test outside tests/features/ - When: the pytest-html report is generated - Then: the "Acceptance Criteria" column is blank for that test - """ - # Given - pytester.makepyprojecttoml( - "[tool.pytest.ini_options]\nminversion = '6.0'\n" - "[tool.beehave]\nshow_steps_in_html = true\n" - ) - pytester.makepyfile( - **{ - "tests/unit/my_unit_test": ( - "def test_something():\n" - " '''\n" - " Given: a unit condition\n" - " '''\n" - " pass\n" - ) - } - ) - report_path = pytester.path / "report.html" - # When - pytester.runpytest("--html", str(report_path), "--ignore=docs") - # Then - assert report_path.exists() - html_content = report_path.read_text(encoding="utf-8") - assert "Acceptance Criteria" in html_content - assert "Given: a unit condition" not in html_content - - -def test_report_steps_6c592c81(pytester: pytest.Pytester) -> None: - """ - Given: pytest-html is not installed - When: pytest runs and generates output - Then: no "Acceptance Criteria" column appears and no error is raised - """ - # Given - pytester.makepyprojecttoml( - "[tool.pytest.ini_options]\nminversion = '6.0'\n" - "[tool.beehave]\nshow_steps_in_html = true\n" - ) - pytester.makepyfile( - **{ - "tests/features/my_feature/my_rule_test": ( - "def test_my_feature_aabbccdd():\n" - " '''\n" - " Given: a condition\n" - " '''\n" - " pass\n" - ) - } - ) - # Simulate pytest-html not available by patching _html_available - pytester.makeconftest( - "from pytest_beehave import plugin\nplugin._html_available = lambda: False\n" - ) - report_path = pytester.path / "report.html" - # When - result = pytester.runpytest("--html", str(report_path), "--ignore=docs") - # Then - assert result.ret == 0 - html_content = report_path.read_text(encoding="utf-8") - assert "Acceptance Criteria" not in html_content diff --git a/tests/features/report_steps/terminal_steps_display_test.py b/tests/features/report_steps/terminal_steps_display_test.py deleted file mode 100644 index 8244951..0000000 --- a/tests/features/report_steps/terminal_steps_display_test.py +++ /dev/null @@ -1,167 +0,0 @@ -"""Tests for report-steps — Terminal Steps Display rule.""" - -import pytest - - -def test_report_steps_2ba9da81(pytester: pytest.Pytester) -> None: - """ - Given: a test in tests/features/ with a docstring containing BDD steps - When: pytest runs with -v - Then: the docstring is printed verbatim on the line below the test path followed by a blank line - """ - # Given - pytester.makepyprojecttoml( - "[tool.pytest.ini_options]\nminversion = '6.0'\n" - "[tool.beehave]\nshow_steps_in_terminal = true\n" - ) - pytester.makepyfile( - **{ - "tests/features/my_feature/my_rule_test": ( - "def test_my_feature_aabbccdd():\n" - " '''\n" - " Given: a condition\n" - " When: an action\n" - " Then: an outcome\n" - " '''\n" - " pass\n" - ) - } - ) - # When - result = pytester.runpytest("-v", "--ignore=docs") - # Then - result.stdout.fnmatch_lines( - [ - "*Given: a condition*", - "*When: an action*", - "*Then: an outcome*", - ] - ) - - -def test_report_steps_0869902b(pytester: pytest.Pytester) -> None: - """ - Given: a test in tests/features/ marked skip with a docstring - When: pytest runs with -v - Then: the docstring is printed verbatim below the skipped test path followed by a blank line - """ - # Given - pytester.makepyprojecttoml( - "[tool.pytest.ini_options]\nminversion = '6.0'\n" - "[tool.beehave]\nshow_steps_in_terminal = true\n" - ) - pytester.makepyfile( - **{ - "tests/features/my_feature/my_rule_test": ( - "import pytest\n" - "@pytest.mark.skip(reason='not yet implemented')\n" - "def test_my_feature_aabbccdd():\n" - " '''\n" - " Given: a condition\n" - " When: an action\n" - " Then: an outcome\n" - " '''\n" - " pass\n" - ) - } - ) - # When - result = pytester.runpytest("-v", "--ignore=docs") - # Then - result.stdout.fnmatch_lines( - [ - "*Given: a condition*", - ] - ) - - -def test_report_steps_99cbca75(pytester: pytest.Pytester) -> None: - """ - Given: a test in tests/unit/ with a docstring - When: pytest runs with -v - Then: no additional output is printed for that test - """ - # Given - pytester.makepyprojecttoml( - "[tool.pytest.ini_options]\nminversion = '6.0'\n" - "[tool.beehave]\nshow_steps_in_terminal = true\n" - ) - pytester.makepyfile( - **{ - "tests/unit/my_unit_test": ( - "def test_something():\n" - " '''\n" - " Given: a unit condition\n" - " When: a unit action\n" - " Then: a unit outcome\n" - " '''\n" - " pass\n" - ) - } - ) - # When - result = pytester.runpytest("-v", "--ignore=docs") - # Then - assert "Given: a unit condition" not in result.stdout.str() - - -def test_report_steps_3c1b6d21(pytester: pytest.Pytester) -> None: - """ - Given: show_steps_in_terminal = false in pyproject.toml - And: a test in tests/features/ with a docstring - When: pytest runs with -v - Then: no steps are printed for that test - """ - # Given - pytester.makepyprojecttoml( - "[tool.pytest.ini_options]\nminversion = '6.0'\n" - "[tool.beehave]\nshow_steps_in_terminal = false\n" - ) - pytester.makepyfile( - **{ - "tests/features/my_feature/my_rule_test": ( - "def test_my_feature_aabbccdd():\n" - " '''\n" - " Given: a condition\n" - " When: an action\n" - " Then: an outcome\n" - " '''\n" - " pass\n" - ) - } - ) - # When - result = pytester.runpytest("-v", "--ignore=docs") - # Then - assert "Given: a condition" not in result.stdout.str() - - -def test_report_steps_3278cf4d(pytester: pytest.Pytester) -> None: - """ - Given: show_steps_in_terminal = true in pyproject.toml - And: a test in tests/features/ with a docstring - When: pytest runs without any -v flag - Then: no steps are printed for that test - """ - # Given - pytester.makepyprojecttoml( - "[tool.pytest.ini_options]\nminversion = '6.0'\n" - "[tool.beehave]\nshow_steps_in_terminal = true\n" - ) - pytester.makepyfile( - **{ - "tests/features/my_feature/my_rule_test": ( - "def test_my_feature_aabbccdd():\n" - " '''\n" - " Given: a condition\n" - " When: an action\n" - " Then: an outcome\n" - " '''\n" - " pass\n" - ) - } - ) - # When - result = pytester.runpytest("--ignore=docs") - # Then - assert "Given: a condition" not in result.stdout.str() diff --git a/tests/features/stub_creation/conftest.py b/tests/features/stub_creation/conftest.py deleted file mode 100644 index dd3ae1d..0000000 --- a/tests/features/stub_creation/conftest.py +++ /dev/null @@ -1,61 +0,0 @@ -"""Shared fixtures for stub-creation feature tests.""" - -from __future__ import annotations - -from pathlib import Path -from typing import Callable - -import pytest - - -def _make_feature( - features_dir: Path, - stage: str, - folder: str, - filename: str, - content: str, -) -> None: - """Write a .feature file under features_dir///.""" - feature_dir = features_dir / stage / folder - feature_dir.mkdir(parents=True, exist_ok=True) - (feature_dir / filename).write_text(content, encoding="utf-8") - - -def _make_test_file( - tests_dir: Path, - feature_slug: str, - story_slug: str, - content: str, -) -> Path: - """Write a test file under tests_dir//_test.py.""" - test_dir = tests_dir / feature_slug - test_dir.mkdir(parents=True, exist_ok=True) - test_file = test_dir / f"{story_slug}_test.py" - test_file.write_text(content, encoding="utf-8") - return test_file - - -def _read_test_file(tests_dir: Path, feature_slug: str, story_slug: str) -> str: - """Read test file content; returns empty string if file does not exist.""" - test_file = tests_dir / feature_slug / f"{story_slug}_test.py" - if not test_file.exists(): - return "" - return test_file.read_text(encoding="utf-8") - - -@pytest.fixture -def make_feature() -> Callable[..., None]: - """Fixture providing the make_feature helper.""" - return _make_feature - - -@pytest.fixture -def make_test_file() -> Callable[..., Path]: - """Fixture providing the make_test_file helper.""" - return _make_test_file - - -@pytest.fixture -def read_test_file() -> Callable[..., str]: - """Fixture providing the read_test_file helper.""" - return _read_test_file diff --git a/tests/features/stub_creation/docstring_generation_test.py b/tests/features/stub_creation/docstring_generation_test.py deleted file mode 100644 index d27329d..0000000 --- a/tests/features/stub_creation/docstring_generation_test.py +++ /dev/null @@ -1,255 +0,0 @@ -"""Tests for stub creation — docstring generation rule.""" - -from __future__ import annotations - -from pathlib import Path -from typing import Callable - -from pytest_beehave.sync_engine import run_sync as sync_stubs - - -class TestDocstringGeneration: - """Tests for the Docstring generation Rule.""" - - def test_stub_creation_db596443( - self, - tmp_path: Path, - make_feature: Callable[..., None], - read_test_file: Callable[..., str], - ) -> None: - """ - Given: a backlog feature with an Example containing And and But steps - When: pytest is invoked - Then: each And step appears as "And: " and each But step appears as "But: " in the docstring - """ - features_dir = tmp_path / "features" - tests_dir = tmp_path / "tests" - make_feature( - features_dir, - "backlog", - "my-feature", - "my-story.feature", - """\ -Feature: My feature - @id:aabbccdd - Example: Steps with And and But - Given a base condition - And an additional condition - When something happens - Then a result occurs - But not this other thing -""", - ) - sync_stubs(features_dir, tests_dir) - content = read_test_file(tests_dir, "my_feature", "examples") - assert "And: an additional condition" in content - assert "But: not this other thing" in content - - def test_stub_creation_17b01d7a( - self, - tmp_path: Path, - make_feature: Callable[..., None], - read_test_file: Callable[..., str], - ) -> None: - """ - Given: a backlog feature with an Example containing a step written with the * bullet - When: pytest is invoked - Then: that step appears as "*: " in the generated test stub docstring - """ - features_dir = tmp_path / "features" - tests_dir = tmp_path / "tests" - make_feature( - features_dir, - "backlog", - "my-feature", - "my-story.feature", - """\ -Feature: My feature - @id:aabbccdd - Example: Steps with asterisk - Given a base condition - * a bullet step - When something happens - Then a result occurs -""", - ) - sync_stubs(features_dir, tests_dir) - content = read_test_file(tests_dir, "my_feature", "examples") - assert "*: a bullet step" in content - - def test_stub_creation_c56883ce( - self, - tmp_path: Path, - make_feature: Callable[..., None], - read_test_file: Callable[..., str], - ) -> None: - """ - Given: a backlog feature with an Example where a step has an attached multi-line doc string block - When: pytest is invoked - Then: the generated test stub docstring includes the doc string content indented below the step line - """ - features_dir = tmp_path / "features" - tests_dir = tmp_path / "tests" - make_feature( - features_dir, - "backlog", - "my-feature", - "my-story.feature", - '''\ -Feature: My feature - @id:aabbccdd - Example: Step with doc string - Given a step with attached content - """ - line one - line two - """ - When something happens - Then a result occurs -''', - ) - sync_stubs(features_dir, tests_dir) - content = read_test_file(tests_dir, "my_feature", "examples") - assert "line one" in content - assert "line two" in content - lines = content.splitlines() - step_line_idx = next( - ( - i - for i, ln in enumerate(lines) - if "Given: a step with attached content" in ln - ), - -1, - ) - assert step_line_idx >= 0 - assert any( - "line one" in lines[j] and lines[j].startswith(" ") - for j in range(step_line_idx + 1, min(step_line_idx + 6, len(lines))) - ) - - def test_stub_creation_2fc458f8( - self, - tmp_path: Path, - make_feature: Callable[..., None], - read_test_file: Callable[..., str], - ) -> None: - """ - Given: a backlog feature with an Example where a step has an attached data table - When: pytest is invoked - Then: the generated test stub docstring includes the table rows indented below the step line - """ - features_dir = tmp_path / "features" - tests_dir = tmp_path / "tests" - make_feature( - features_dir, - "backlog", - "my-feature", - "my-story.feature", - """\ -Feature: My feature - @id:aabbccdd - Example: Step with data table - Given a step with a table - | col1 | col2 | - | a | b | - | c | d | - When something happens - Then a result occurs -""", - ) - sync_stubs(features_dir, tests_dir) - content = read_test_file(tests_dir, "my_feature", "examples") - assert "| col1 | col2 |" in content - assert "| a | b |" in content - lines = content.splitlines() - step_line_idx = next( - (i for i, ln in enumerate(lines) if "Given: a step with a table" in ln), - -1, - ) - assert step_line_idx >= 0 - assert any( - "| col1 |" in lines[j] and lines[j].startswith(" ") - for j in range(step_line_idx + 1, min(step_line_idx + 6, len(lines))) - ) - - def test_stub_creation_7f91cf3a( - self, - tmp_path: Path, - make_feature: Callable[..., None], - read_test_file: Callable[..., str], - ) -> None: - """ - Given: a backlog feature with a feature-level Background and a Rule-level Background - When: pytest is invoked - Then: the generated test stub docstring contains two "Background:" sections in order before the scenario steps - """ - features_dir = tmp_path / "features" - tests_dir = tmp_path / "tests" - make_feature( - features_dir, - "backlog", - "my-feature", - "my-story.feature", - """\ -Feature: My feature - Background: - Given the shop is open - - Rule: Premium customers - Background: - And the customer has a premium account - - @id:aabbccdd - Example: Premium order - When a premium order is placed - Then a 20% discount is applied -""", - ) - sync_stubs(features_dir, tests_dir) - content = read_test_file(tests_dir, "my_feature", "premium_customers") - assert content.count("Background:") == 2 - first_bg = content.find("Background:") - second_bg = content.find("Background:", first_bg + 1) - when_idx = content.find("When: a premium order is placed") - assert first_bg >= 0 - assert second_bg > first_bg - assert when_idx > second_bg - - def test_stub_creation_9a4e199a( - self, - tmp_path: Path, - make_feature: Callable[..., None], - read_test_file: Callable[..., str], - ) -> None: - """ - Given: a backlog feature containing a Scenario Outline with placeholder values and an Examples table - When: pytest is invoked - Then: the generated test stub docstring contains the raw template step text followed by the Examples table - """ - features_dir = tmp_path / "features" - tests_dir = tmp_path / "tests" - make_feature( - features_dir, - "backlog", - "my-feature", - "my-story.feature", - """\ -Feature: My feature - @id:aabbccdd - Scenario Outline: Template scenario - Given a - When it - Then it - - Examples: - | thing | action | result | - | ball | rolls | stops | - | cube | slides | falls | -""", - ) - sync_stubs(features_dir, tests_dir) - content = read_test_file(tests_dir, "my_feature", "examples") - assert "Given: a " in content - assert "When: it " in content - assert "| thing | action | result |" in content - assert "| ball | rolls | stops |" in content diff --git a/tests/features/stub_creation/new_stub_generation_test.py b/tests/features/stub_creation/new_stub_generation_test.py deleted file mode 100644 index 51d33b6..0000000 --- a/tests/features/stub_creation/new_stub_generation_test.py +++ /dev/null @@ -1,324 +0,0 @@ -"""Tests for stub creation — new stub generation rule.""" - -from __future__ import annotations - -from pathlib import Path -from typing import Callable - -import pytest - -from pytest_beehave.sync_engine import run_sync as sync_stubs - - -class TestNewStubGeneration: - """Tests for the New stub generation Rule.""" - - def test_stub_creation_692972dd( - self, - tmp_path: Path, - make_feature: Callable[..., None], - ) -> None: - """ - Given: a backlog feature folder containing a .feature file with a new @id-tagged Example - When: pytest is invoked - Then: a test function named test__ exists in the corresponding test file - """ - features_dir = tmp_path / "features" - tests_dir = tmp_path / "tests" - make_feature( - features_dir, - "backlog", - "my-feature", - "my-story.feature", - """\ -Feature: My feature - @id:aabbccdd - Example: Something happens - Given a thing - When it runs - Then it works -""", - ) - sync_stubs(features_dir, tests_dir) - test_file = tests_dir / "my_feature" / "examples_test.py" - assert test_file.exists() - content = test_file.read_text(encoding="utf-8") - assert "def test_my_feature_aabbccdd() -> None:" in content - - @pytest.mark.deprecated - def test_stub_creation_d14d975f( - self, - tmp_path: Path, - make_feature: Callable[..., None], - read_test_file: Callable[..., str], - ) -> None: - """ - Given: a backlog feature folder containing a .feature file with a new @id-tagged Example - When: pytest is invoked - Then: the generated test function has no @pytest.mark decorator - """ - features_dir = tmp_path / "features" - tests_dir = tmp_path / "tests" - make_feature( - features_dir, - "backlog", - "my-feature", - "my-story.feature", - """\ -Feature: My feature - @id:aabbccdd - Example: Something happens - Given a thing - When it runs - Then it works -""", - ) - sync_stubs(features_dir, tests_dir) - content = read_test_file(tests_dir, "my_feature", "examples") - assert "def test_my_feature_aabbccdd() -> None:" in content - func_idx = content.index("def test_my_feature_aabbccdd") - block_before_def = content[:func_idx] - lines_before = [ln for ln in block_before_def.splitlines() if ln.strip()] - assert not any(line.startswith("@pytest.mark") for line in lines_before) - - def test_stub_creation_a4c781f2( - self, - tmp_path: Path, - make_feature: Callable[..., None], - read_test_file: Callable[..., str], - ) -> None: - """ - Given: a backlog feature folder containing a .feature file with a new @id-tagged Example - When: pytest is invoked - Then: the generated test function has @pytest.mark.skip(reason="not yet implemented") applied - """ - features_dir = tmp_path / "features" - tests_dir = tmp_path / "tests" - make_feature( - features_dir, - "backlog", - "my-feature", - "my-story.feature", - """\ -Feature: My feature - @id:aabbccdd - Example: Something happens - Given a thing - When it runs - Then it works -""", - ) - sync_stubs(features_dir, tests_dir) - content = read_test_file(tests_dir, "my_feature", "examples") - assert '@pytest.mark.skip(reason="not yet implemented")' in content - skip_idx = content.index("@pytest.mark.skip") - def_idx = content.index("def test_my_feature_aabbccdd") - assert skip_idx < def_idx - - @pytest.mark.deprecated - def test_stub_creation_e2b093d1( - self, - tmp_path: Path, - make_feature: Callable[..., None], - read_test_file: Callable[..., str], - ) -> None: - """ - Given: a backlog feature file with a Rule block containing a new @id-tagged Example - When: pytest is invoked - Then: the generated stub is a method inside class Test in _test.py - """ - features_dir = tmp_path / "features" - tests_dir = tmp_path / "tests" - make_feature( - features_dir, - "backlog", - "my-feature", - "my-story.feature", - """\ -Feature: My feature - Rule: Premium customers - @id:aabbccdd - Example: Premium order - Given a premium customer - When an order is placed - Then a discount is applied -""", - ) - sync_stubs(features_dir, tests_dir) - content = read_test_file(tests_dir, "my_feature", "premium_customers") - assert "class TestPremiumCustomers:" in content - assert "def test_my_feature_aabbccdd" in content - lines = content.splitlines() - def_line = next( - (ln for ln in lines if "def test_my_feature_aabbccdd" in ln), "" - ) - assert def_line.startswith(" ") - - def test_stub_creation_f1a5c823( - self, - tmp_path: Path, - make_feature: Callable[..., None], - read_test_file: Callable[..., str], - ) -> None: - """ - Given: a backlog feature file with no Rule blocks containing a new @id-tagged Example - When: pytest is invoked - Then: the generated stub is a module-level function in examples_test.py - """ - features_dir = tmp_path / "features" - tests_dir = tmp_path / "tests" - make_feature( - features_dir, - "backlog", - "my-feature", - "my-story.feature", - """\ -Feature: My feature - @id:aabbccdd - Example: Something happens - Given a thing - When it runs - Then it works -""", - ) - sync_stubs(features_dir, tests_dir) - content = read_test_file(tests_dir, "my_feature", "examples") - assert "def test_my_feature_aabbccdd() -> None:" in content - lines = content.splitlines() - def_line = next( - (ln for ln in lines if "def test_my_feature_aabbccdd" in ln), "" - ) - assert def_line.startswith("def ") - - def test_stub_creation_777a9638( - self, - tmp_path: Path, - make_feature: Callable[..., None], - read_test_file: Callable[..., str], - ) -> None: - """ - Given: a backlog feature folder containing a .feature file with a new @id-tagged Example - When: pytest is invoked - Then: the generated test function body ends with raise NotImplementedError - """ - features_dir = tmp_path / "features" - tests_dir = tmp_path / "tests" - make_feature( - features_dir, - "backlog", - "my-feature", - "my-story.feature", - """\ -Feature: My feature - @id:aabbccdd - Example: Something happens - Given a thing - When it runs - Then it works -""", - ) - sync_stubs(features_dir, tests_dir) - content = read_test_file(tests_dir, "my_feature", "examples") - assert "raise NotImplementedError" in content - non_empty_lines = [ln for ln in content.splitlines() if ln.strip()] - assert non_empty_lines[-1].strip() == "raise NotImplementedError" - - def test_stub_creation_bba184c0( - self, - tmp_path: Path, - make_feature: Callable[..., None], - read_test_file: Callable[..., str], - ) -> None: - """ - Given: a backlog feature folder containing a .feature file with a new @id-tagged Example - When: pytest is invoked - Then: the generated test function body contains no "# Given", "# When", or "# Then" comment lines - """ - features_dir = tmp_path / "features" - tests_dir = tmp_path / "tests" - make_feature( - features_dir, - "backlog", - "my-feature", - "my-story.feature", - """\ -Feature: My feature - @id:aabbccdd - Example: Something happens - Given a thing - When it runs - Then it works -""", - ) - sync_stubs(features_dir, tests_dir) - content = read_test_file(tests_dir, "my_feature", "examples") - assert "def test_my_feature_aabbccdd() -> None:" in content - func_start = content.index("def test_my_feature_aabbccdd") - first_docstring_open = content.index('"""', func_start) - docstring_close = content.index('"""', first_docstring_open + 3) + 3 - body = content[docstring_close:] - assert "# Given" not in body - assert "# When" not in body - assert "# Then" not in body - - def test_stub_creation_edc964fc( - self, - tmp_path: Path, - make_feature: Callable[..., None], - ) -> None: - """ - Given: a backlog feature folder whose name contains hyphens (e.g. "my-feature") - When: pytest is invoked - Then: the test file is created at tests/features/my_feature/ not tests/features/my-feature/ - """ - features_dir = tmp_path / "features" - tests_dir = tmp_path / "tests" - make_feature( - features_dir, - "backlog", - "my-feature", - "my-story.feature", - """\ -Feature: My feature - @id:aabbccdd - Example: Something happens - Given a thing - When it runs - Then it works -""", - ) - sync_stubs(features_dir, tests_dir) - assert (tests_dir / "my_feature" / "examples_test.py").exists() - assert not (tests_dir / "my-feature").exists() - - def test_stub_creation_38d864b9( - self, - tmp_path: Path, - make_feature: Callable[..., None], - ) -> None: - """ - Given: a completed feature folder containing a .feature file with a new @id-tagged Example - When: pytest is invoked - Then: no new test stub is created for that Example - """ - features_dir = tmp_path / "features" - tests_dir = tmp_path / "tests" - make_feature( - features_dir, - "completed", - "done-feature", - "done-story.feature", - """\ -Feature: Done feature - @id:aabbccdd - Example: Something was done - Given it was done - When checked - Then it passes -""", - ) - sync_stubs(features_dir, tests_dir) - # For a feature with no Rule blocks, the sync engine would create examples_test.py - # For a completed feature, no new stubs are created at all - test_file = tests_dir / "done_feature" / "examples_test.py" - assert not test_file.exists() diff --git a/tests/features/stub_format_config/classes_format_selection_test.py b/tests/features/stub_format_config/classes_format_selection_test.py deleted file mode 100644 index 5e41be1..0000000 --- a/tests/features/stub_format_config/classes_format_selection_test.py +++ /dev/null @@ -1,61 +0,0 @@ -"""Tests for classes format selection story.""" - -from pathlib import Path - -from pytest_beehave.sync_engine import run_sync - - -def test_stub_format_config_a2b3c4d5(tmp_path: Path) -> None: - """ - Given: a pyproject.toml with stub_format = "classes" under [tool.beehave] - When: pytest generates a stub for a Rule-block Example - Then: the stub is a method inside class Test in _test.py - """ - features_dir = tmp_path / "features" - tests_dir = tmp_path / "tests" - feature_dir = features_dir / "in-progress" / "my-feature" - feature_dir.mkdir(parents=True) - (feature_dir / "my-feature.feature").write_text( - "Feature: My feature\n\n" - " Rule: My rule\n" - " @id:aabbccdd\n" - " Example: Something\n" - " Given a thing\n" - " When it runs\n" - " Then it works\n", - encoding="utf-8", - ) - run_sync(features_dir, tests_dir, stub_format="classes") - test_file = tests_dir / "my_feature" / "my_rule_test.py" - content = test_file.read_text(encoding="utf-8") - assert "class TestMyRule:" in content - lines = content.splitlines() - def_line = next((ln for ln in lines if "def test_my_feature_aabbccdd" in ln), "") - assert def_line.startswith(" ") - assert "(self)" in def_line - - -def test_stub_format_config_b3c4d5e6(tmp_path: Path) -> None: - """ - Given: a pyproject.toml with stub_format = "classes" and a Rule titled "Wall bounce" - When: pytest generates a stub for an Example under that Rule - Then: the stub is inside a class named TestWallBounce - """ - features_dir = tmp_path / "features" - tests_dir = tmp_path / "tests" - feature_dir = features_dir / "in-progress" / "my-feature" - feature_dir.mkdir(parents=True) - (feature_dir / "my-feature.feature").write_text( - "Feature: My feature\n\n" - " Rule: Wall bounce\n" - " @id:aabbccdd\n" - " Example: Something\n" - " Given a thing\n" - " When it runs\n" - " Then it works\n", - encoding="utf-8", - ) - run_sync(features_dir, tests_dir, stub_format="classes") - test_file = tests_dir / "my_feature" / "wall_bounce_test.py" - content = test_file.read_text(encoding="utf-8") - assert "class TestWallBounce:" in content diff --git a/tests/features/stub_format_config/default_format_selection_test.py b/tests/features/stub_format_config/default_format_selection_test.py deleted file mode 100644 index 0f51f75..0000000 --- a/tests/features/stub_format_config/default_format_selection_test.py +++ /dev/null @@ -1,57 +0,0 @@ -"""Tests for default format selection story.""" - -from pathlib import Path - -import pytest - -from pytest_beehave.sync_engine import run_sync - - -def test_stub_format_config_a1b2c3d4(tmp_path: Path) -> None: - """ - Given: a pyproject.toml with no stub_format key under [tool.beehave] - When: pytest generates a stub for a Rule-block Example - Then: the stub is a top-level function def test__<@id> with no class wrapper - """ - features_dir = tmp_path / "features" - tests_dir = tmp_path / "tests" - feature_dir = features_dir / "in-progress" / "my-feature" - feature_dir.mkdir(parents=True) - (feature_dir / "my-feature.feature").write_text( - "Feature: My feature\n\n" - " Rule: My rule\n" - " @id:aabbccdd\n" - " Example: Something\n" - " Given a thing\n" - " When it runs\n" - " Then it works\n", - encoding="utf-8", - ) - run_sync(features_dir, tests_dir) - test_file = tests_dir / "my_feature" / "my_rule_test.py" - assert test_file.exists() - content = test_file.read_text(encoding="utf-8") - assert "def test_my_feature_aabbccdd() -> None:" in content - assert "class " not in content - - -@pytest.mark.slow -def test_stub_format_config_b2c3d4e5(tmp_path: Path) -> None: - """ - Given: a pyproject.toml with no stub_format key under [tool.beehave] - When: pytest starts up - Then: pytest starts without any stub_format-related error - """ - pyproject = tmp_path / "pyproject.toml" - pyproject.write_text( - '[tool.beehave]\nfeatures_path = "docs/features"\n', encoding="utf-8" - ) - features_dir = tmp_path / "docs" / "features" / "backlog" - features_dir.mkdir(parents=True) - tests_dir = tmp_path / "tests" - tests_dir.mkdir() - conftest = tmp_path / "conftest.py" - conftest.write_text("", encoding="utf-8") - - result = pytest.main(["--no-cov", "--co", "-q", str(tmp_path)], plugins=[]) - assert result in (0, 5) # 0=ok, 5=no tests collected — both mean no error diff --git a/tests/features/stub_format_config/explicit_functions_format_test.py b/tests/features/stub_format_config/explicit_functions_format_test.py deleted file mode 100644 index a482f7d..0000000 --- a/tests/features/stub_format_config/explicit_functions_format_test.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Tests for explicit functions format story.""" - -from pathlib import Path - -from pytest_beehave.sync_engine import run_sync - - -def test_stub_format_config_f1e2d3c4(tmp_path: Path) -> None: - """ - Given: a pyproject.toml with stub_format = "functions" under [tool.beehave] - When: pytest generates a stub for a Rule-block Example - Then: the stub is a top-level function def test__<@id> with no class wrapper - """ - features_dir = tmp_path / "features" - tests_dir = tmp_path / "tests" - feature_dir = features_dir / "in-progress" / "my-feature" - feature_dir.mkdir(parents=True) - (feature_dir / "my-feature.feature").write_text( - "Feature: My feature\n\n" - " Rule: My rule\n" - " @id:aabbccdd\n" - " Example: Something\n" - " Given a thing\n" - " When it runs\n" - " Then it works\n", - encoding="utf-8", - ) - run_sync(features_dir, tests_dir, stub_format="functions") - test_file = tests_dir / "my_feature" / "my_rule_test.py" - content = test_file.read_text(encoding="utf-8") - assert "def test_my_feature_aabbccdd() -> None:" in content - assert "class " not in content diff --git a/tests/features/stub_format_config/invalid_format_rejection_test.py b/tests/features/stub_format_config/invalid_format_rejection_test.py deleted file mode 100644 index 4cb7285..0000000 --- a/tests/features/stub_format_config/invalid_format_rejection_test.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Tests for invalid format rejection story.""" - -from pathlib import Path - -import pytest - - -@pytest.mark.slow -def test_stub_format_config_f6a7b8c9(tmp_path: Path) -> None: - """ - Given: a pyproject.toml with stub_format = "methods" under [tool.beehave] - When: pytest starts up - Then: pytest exits with a non-zero status and an error message naming the invalid value - """ - pyproject = tmp_path / "pyproject.toml" - pyproject.write_text( - '[tool.beehave]\nfeatures_path = "docs/features"\nstub_format = "methods"\n', - encoding="utf-8", - ) - features_dir = tmp_path / "docs" / "features" / "backlog" - features_dir.mkdir(parents=True) - tests_dir = tmp_path / "tests" - tests_dir.mkdir() - conftest = tmp_path / "conftest.py" - conftest.write_text("", encoding="utf-8") - - result = pytest.main(["--no-cov", "--co", "-q", str(tmp_path)], plugins=[]) - assert result != 0 diff --git a/tests/features/stub_format_config/no_rule_feature_unaffected_test.py b/tests/features/stub_format_config/no_rule_feature_unaffected_test.py deleted file mode 100644 index 323ffc4..0000000 --- a/tests/features/stub_format_config/no_rule_feature_unaffected_test.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Tests for no-rule feature unaffected story.""" - -from pathlib import Path - -from pytest_beehave.sync_engine import run_sync - - -def test_stub_format_config_a7b8c9d0(tmp_path: Path) -> None: - """ - Given: a pyproject.toml with stub_format = "classes" under [tool.beehave] - And: a feature file with no Rule blocks - When: pytest generates stubs for that feature - Then: the stubs are module-level functions in examples_test.py with no class wrapper - """ - features_dir = tmp_path / "features" - tests_dir = tmp_path / "tests" - feature_dir = features_dir / "in-progress" / "my-feature" - feature_dir.mkdir(parents=True) - (feature_dir / "my-feature.feature").write_text( - "Feature: My feature\n\n" - " @id:aabbccdd\n" - " Example: Something\n" - " Given a thing\n" - " When it runs\n" - " Then it works\n", - encoding="utf-8", - ) - run_sync(features_dir, tests_dir, stub_format="classes") - test_file = tests_dir / "my_feature" / "examples_test.py" - content = test_file.read_text(encoding="utf-8") - assert "def test_my_feature_aabbccdd() -> None:" in content - assert "class " not in content diff --git a/tests/features/stub_updates/conftest.py b/tests/features/stub_updates/conftest.py deleted file mode 100644 index 3d8432b..0000000 --- a/tests/features/stub_updates/conftest.py +++ /dev/null @@ -1,61 +0,0 @@ -"""Shared fixtures for stub-updates feature tests.""" - -from __future__ import annotations - -from pathlib import Path -from typing import Callable - -import pytest - - -def _make_feature( - features_dir: Path, - stage: str, - folder: str, - filename: str, - content: str, -) -> None: - """Write a .feature file under features_dir///.""" - feature_dir = features_dir / stage / folder - feature_dir.mkdir(parents=True, exist_ok=True) - (feature_dir / filename).write_text(content, encoding="utf-8") - - -def _make_test_file( - tests_dir: Path, - feature_slug: str, - story_slug: str, - content: str, -) -> Path: - """Write a test file under tests_dir//_test.py.""" - test_dir = tests_dir / feature_slug - test_dir.mkdir(parents=True, exist_ok=True) - test_file = test_dir / f"{story_slug}_test.py" - test_file.write_text(content, encoding="utf-8") - return test_file - - -def _read_test_file(tests_dir: Path, feature_slug: str, story_slug: str) -> str: - """Read test file content; returns empty string if file does not exist.""" - test_file = tests_dir / feature_slug / f"{story_slug}_test.py" - if not test_file.exists(): - return "" - return test_file.read_text(encoding="utf-8") - - -@pytest.fixture -def make_feature() -> Callable[..., None]: - """Fixture providing the make_feature helper.""" - return _make_feature - - -@pytest.fixture -def make_test_file() -> Callable[..., Path]: - """Fixture providing the make_test_file helper.""" - return _make_test_file - - -@pytest.fixture -def read_test_file() -> Callable[..., str]: - """Fixture providing the read_test_file helper.""" - return _read_test_file diff --git a/tests/features/stub_updates/docstring_updates_test.py b/tests/features/stub_updates/docstring_updates_test.py deleted file mode 100644 index 5a72d99..0000000 --- a/tests/features/stub_updates/docstring_updates_test.py +++ /dev/null @@ -1,119 +0,0 @@ -"""Tests for stub updates — docstring updates rule.""" - -from __future__ import annotations - -from pathlib import Path -from typing import Callable - -from pytest_beehave.sync_engine import run_sync as sync_stubs - - -class TestDocstringUpdates: - """Tests for the Docstring updates Rule.""" - - def test_docstring_updates_bdb8e233( - self, - tmp_path: Path, - make_feature: Callable[..., None], - make_test_file: Callable[..., Path], - ) -> None: - """ - Given: an existing test stub whose docstring does not match the current step text in the .feature file - When: pytest is invoked - Then: the test stub docstring matches the current step text from the .feature file - """ - features_dir = tmp_path / "features" - tests_dir = tmp_path / "tests" - make_feature( - features_dir, - "in-progress", - "my-feature", - "my-story.feature", - """\ -Feature: My feature - @id:aabbccdd - Example: Updated scenario - Given the new condition - When the new action runs - Then the new result appears -""", - ) - make_test_file( - tests_dir, - "my_feature", - "examples", - '''\ -"""Tests for examples story.""" - -import pytest - - -def test_my_feature_aabbccdd() -> None: - """ - Given: the OLD condition - When: the OLD action runs - Then: the OLD result appears - """ - raise NotImplementedError -''', - ) - sync_stubs(features_dir, tests_dir) - test_file = tests_dir / "my_feature" / "examples_test.py" - content = test_file.read_text(encoding="utf-8") - assert "Given: the new condition" in content - assert "When: the new action runs" in content - assert "Then: the new result appears" in content - assert "OLD" not in content - - def test_docstring_updates_6bb59874( - self, - tmp_path: Path, - make_feature: Callable[..., None], - make_test_file: Callable[..., Path], - ) -> None: - """ - Given: an existing test stub with a custom implementation in the function body - When: pytest is invoked and the .feature file step text has changed - Then: the test function body below the docstring is unchanged - """ - features_dir = tmp_path / "features" - tests_dir = tmp_path / "tests" - make_feature( - features_dir, - "in-progress", - "my-feature", - "my-story.feature", - """\ -Feature: My feature - @id:aabbccdd - Example: Updated scenario - Given the new condition - When the new action runs - Then the new result appears -""", - ) - custom_body = " result = my_function()\n assert result == 42\n" - make_test_file( - tests_dir, - "my_feature", - "examples", - f'''\ -"""Tests for examples story.""" - -import pytest - - -def test_my_feature_aabbccdd() -> None: - """ - Given: the OLD condition - When: the OLD action runs - Then: the OLD result appears - """ -{custom_body}''', - ) - sync_stubs(features_dir, tests_dir) - test_file = tests_dir / "my_feature" / "examples_test.py" - content = test_file.read_text(encoding="utf-8") - assert "Given: the new condition" in content - assert "result = my_function()" in content - assert "assert result == 42" in content diff --git a/tests/features/stub_updates/function_renames_test.py b/tests/features/stub_updates/function_renames_test.py deleted file mode 100644 index d030396..0000000 --- a/tests/features/stub_updates/function_renames_test.py +++ /dev/null @@ -1,149 +0,0 @@ -"""Tests for stub updates — function renames rule.""" - -from __future__ import annotations - -from pathlib import Path -from typing import Callable - -from pytest_beehave.sync_engine import run_sync as sync_stubs - - -class TestFunctionRenames: - """Tests for the Function renames Rule.""" - - def test_function_renames_b6b9ab28( - self, - tmp_path: Path, - make_feature: Callable[..., None], - make_test_file: Callable[..., Path], - ) -> None: - """ - Given: an existing test stub whose function name does not match the current feature slug - When: pytest is invoked - Then: the test function is renamed to match test__ - """ - features_dir = tmp_path / "features" - tests_dir = tmp_path / "tests" - make_feature( - features_dir, - "in-progress", - "new-feature", - "my-story.feature", - """\ -Feature: New feature - @id:aabbccdd - Example: Something happens - Given a thing - When it runs - Then it works -""", - ) - make_test_file( - tests_dir, - "new_feature", - "examples", - '''\ -"""Tests for examples story.""" - -import pytest - - -def test_old_feature_aabbccdd() -> None: - """ - Given: a thing - When: it runs - Then: it works - """ - raise NotImplementedError -''', - ) - sync_stubs(features_dir, tests_dir) - test_file = tests_dir / "new_feature" / "examples_test.py" - content = test_file.read_text(encoding="utf-8") - assert "def test_new_feature_aabbccdd() -> None:" in content - assert "def test_old_feature_aabbccdd" not in content - - def test_function_renames_d89540f9( - self, - tmp_path: Path, - make_feature: Callable[..., None], - make_test_file: Callable[..., Path], - ) -> None: - """ - Given: a completed feature with a test stub whose docstring differs from the .feature file - When: pytest is invoked - Then: the completed feature test stub docstring is unchanged - """ - features_dir = tmp_path / "features" - tests_dir = tmp_path / "tests" - make_feature( - features_dir, - "completed", - "done-feature", - "done-story.feature", - """\ -Feature: Done feature - @id:aabbccdd - Example: Something was done - Given the NEW condition - When the NEW action runs - Then the NEW result appears -""", - ) - original_content = '''\ -"""Tests for done examples story.""" - -import pytest - - -def test_done_feature_aabbccdd() -> None: - """ - Given: the OLD condition - When: the OLD action runs - Then: the OLD result appears - """ - raise NotImplementedError -''' - make_test_file(tests_dir, "done_feature", "examples", original_content) - make_feature( - features_dir, - "in-progress", - "active-feature", - "active-story.feature", - """\ -Feature: Active feature - @id:11223344 - Example: Active scenario - Given the active condition - When the active action runs - Then the active result appears -""", - ) - make_test_file( - tests_dir, - "active_feature", - "examples", - '''\ -"""Tests for examples story.""" - -import pytest - - -def test_active_feature_11223344() -> None: - """ - Given: the STALE condition - When: the STALE action runs - Then: the STALE result appears - """ - raise NotImplementedError -''', - ) - sync_stubs(features_dir, tests_dir) - active_content = (tests_dir / "active_feature" / "examples_test.py").read_text( - encoding="utf-8" - ) - assert "Given: the active condition" in active_content - done_content = (tests_dir / "done_feature" / "examples_test.py").read_text( - encoding="utf-8" - ) - assert done_content == original_content diff --git a/tests/features/stub_updates/non_conforming_handling_test.py b/tests/features/stub_updates/non_conforming_handling_test.py deleted file mode 100644 index 9140c5d..0000000 --- a/tests/features/stub_updates/non_conforming_handling_test.py +++ /dev/null @@ -1,156 +0,0 @@ -"""Tests for stub updates — non-conforming handling rule.""" - -from __future__ import annotations - -from pathlib import Path -from typing import Callable - -import pytest - -from pytest_beehave.sync_engine import run_sync as sync_stubs - - -class TestNonConformingHandling: - """Tests for the Non-conforming handling Rule.""" - - @pytest.mark.deprecated - @pytest.mark.unit - def test_non_conforming_handling_4a7c2e81( - self, - tmp_path: Path, - make_feature: Callable[..., None], - make_test_file: Callable[..., Path], - ) -> None: - """ - Given: a test function whose @id matches a current Example but is in the wrong file or class - When: pytest is invoked - Then: stub-sync creates a conforming stub in the correct location and marks the original with @pytest.mark.skip(reason="non-conforming: moved to ::") - """ - features_dir = tmp_path / "features" - tests_dir = tmp_path / "tests" - make_feature( - features_dir, - "in-progress", - "my-feature", - "my-story.feature", - """\ -Feature: My feature - Rule: My rule - @id:aabbccdd - Example: A real example - Given something - When it runs - Then it works -""", - ) - # Put the test in the wrong file (not my_rule_test.py) - wrong_file = make_test_file( - tests_dir, - "my_feature", - "wrong_story", - '''\ -"""Tests for wrong story.""" - -import pytest - - -def test_my_feature_aabbccdd() -> None: - """ - Given: something - When: it runs - Then: it works - """ - raise NotImplementedError -''', - ) - sync_stubs(features_dir, tests_dir) - # Conforming stub created in correct location (rule slug uses underscores) - correct_file = tests_dir / "my_feature" / "my_rule_test.py" - assert correct_file.exists() - correct_content = correct_file.read_text(encoding="utf-8") - assert "def test_my_feature_aabbccdd" in correct_content - # Original marked as non-conforming - wrong_content = wrong_file.read_text(encoding="utf-8") - assert "non-conforming" in wrong_content - - @pytest.mark.unit - def test_non_conforming_handling_3f9d1b56( - self, - tmp_path: Path, - make_feature: Callable[..., None], - make_test_file: Callable[..., Path], - ) -> None: - """ - Given: a non-conforming test already marked and a conforming stub already present in the correct location - When: pytest is invoked - Then: the non-conforming marker remains on the original test and the conforming stub is unchanged - """ - features_dir = tmp_path / "features" - tests_dir = tmp_path / "tests" - make_feature( - features_dir, - "in-progress", - "my-feature", - "my-story.feature", - """\ -Feature: My feature - Rule: My rule - @id:aabbccdd - Example: A real example - Given something - When it runs - Then it works -""", - ) - # Conforming stub already in correct file (rule slug uses underscores → my_rule_test.py) - conforming_content = '''\ -"""Tests for my_rule story.""" - -import pytest - - -class TestMyRule: - @pytest.mark.skip(reason="not yet implemented") - def test_my_feature_aabbccdd(self) -> None: - """ - Given: something - When: it runs - Then: it works - """ - raise NotImplementedError -''' - make_test_file( - tests_dir, - "my_feature", - "my_rule", - conforming_content, - ) - # Original already marked non-conforming - wrong_file = make_test_file( - tests_dir, - "my_feature", - "wrong_story", - '''\ -"""Tests for wrong story.""" - -import pytest - - -@pytest.mark.skip(reason="non-conforming: should be in my_rule_test.py class TestMyRule") -def test_my_feature_aabbccdd() -> None: - """ - Given: something - When: it runs - Then: it works - """ - raise NotImplementedError -''', - ) - sync_stubs(features_dir, tests_dir) - wrong_content = wrong_file.read_text(encoding="utf-8") - # Non-conforming marker still present - assert "non-conforming" in wrong_content - # Conforming stub is byte-for-byte unchanged - correct_file = tests_dir / "my_feature" / "my_rule_test.py" - correct_content = correct_file.read_text(encoding="utf-8") - assert correct_content == conforming_content diff --git a/tests/features/stub_updates/orphan_handling_test.py b/tests/features/stub_updates/orphan_handling_test.py deleted file mode 100644 index 8fe307a..0000000 --- a/tests/features/stub_updates/orphan_handling_test.py +++ /dev/null @@ -1,239 +0,0 @@ -"""Tests for stub updates — orphan handling rule.""" - -from __future__ import annotations - -from pathlib import Path -from typing import Callable - -import pytest - -from pytest_beehave.sync_engine import run_sync as sync_stubs - - -class TestOrphanHandling: - """Tests for the Orphan handling Rule.""" - - def test_orphan_handling_9d7a0b34( - self, - tmp_path: Path, - make_feature: Callable[..., None], - make_test_file: Callable[..., Path], - ) -> None: - """ - Given: a test file containing a test function whose @id hex does not match any Example in any .feature file - When: pytest is invoked - Then: that test function has @pytest.mark.skip(reason="orphan: no matching @id in .feature files") applied - """ - features_dir = tmp_path / "features" - tests_dir = tmp_path / "tests" - make_feature( - features_dir, - "in-progress", - "my-feature", - "my-story.feature", - """\ -Feature: My feature - @id:11111111 - Example: Known example - Given something - When it runs - Then it works -""", - ) - make_test_file( - tests_dir, - "my_feature", - "my_story", - '''\ -"""Tests for my story.""" - -import pytest - - -def test_my_feature_deadbeef() -> None: - """ - Given: something stale - When: it ran - Then: it worked - """ - raise NotImplementedError -''', - ) - sync_stubs(features_dir, tests_dir) - test_file = tests_dir / "my_feature" / "my_story_test.py" - content = test_file.read_text(encoding="utf-8") - assert ( - '@pytest.mark.skip(reason="orphan: no matching @id in .feature files")' - in content - ) - skip_idx = content.index( - '@pytest.mark.skip(reason="orphan: no matching @id in .feature files")' - ) - def_idx = content.index("def test_my_feature_deadbeef") - assert skip_idx < def_idx - - def test_orphan_handling_67192894( - self, - tmp_path: Path, - make_feature: Callable[..., None], - make_test_file: Callable[..., Path], - ) -> None: - """ - Given: a test function marked as orphan and a .feature file that now contains a matching @id Example - When: pytest is invoked - Then: the orphan skip marker is removed from that test function - """ - features_dir = tmp_path / "features" - tests_dir = tmp_path / "tests" - make_feature( - features_dir, - "in-progress", - "my-feature", - "my-story.feature", - """\ -Feature: My feature - @id:deadbeef - Example: Now a real example - Given something real - When it runs - Then it works -""", - ) - make_test_file( - tests_dir, - "my_feature", - "my_story", - '''\ -"""Tests for my story.""" - -import pytest - - -@pytest.mark.skip(reason="orphan: no matching @id in .feature files") -def test_my_feature_deadbeef() -> None: - """ - Given: something real - When: it runs - Then: it works - """ - raise NotImplementedError -''', - ) - sync_stubs(features_dir, tests_dir) - test_file = tests_dir / "my_feature" / "my_story_test.py" - content = test_file.read_text(encoding="utf-8") - assert ( - '@pytest.mark.skip(reason="orphan: no matching @id in .feature files")' - not in content - ) - assert "def test_my_feature_deadbeef() -> None:" in content - - @pytest.mark.unit - def test_orphan_handling_8b2e4f17( - self, - tmp_path: Path, - make_feature: Callable[..., None], - make_test_file: Callable[..., Path], - ) -> None: - """ - Given: a completed feature test file containing a test function whose @id no longer exists in the feature file - When: pytest is invoked - Then: that test function receives @pytest.mark.skip(reason="orphan: no matching @id in .feature files") - """ - features_dir = tmp_path / "features" - tests_dir = tmp_path / "tests" - make_feature( - features_dir, - "completed", - "done-feature", - "done-story.feature", - """\ -Feature: Done feature - @id:11111111 - Example: A known example - Given done - When checked - Then passes -""", - ) - make_test_file( - tests_dir, - "done_feature", - "done_story", - '''\ -"""Tests for done story.""" - -import pytest - - -def test_done_feature_deadbeef() -> None: - """ - Given: done - When: checked - Then: passes - """ - raise NotImplementedError -''', - ) - sync_stubs(features_dir, tests_dir) - test_file = tests_dir / "done_feature" / "done_story_test.py" - content = test_file.read_text(encoding="utf-8") - assert ( - '@pytest.mark.skip(reason="orphan: no matching @id in .feature files")' - in content - ) - - @pytest.mark.unit - def test_orphan_handling_c9a30d52( - self, - tmp_path: Path, - make_feature: Callable[..., None], - make_test_file: Callable[..., Path], - ) -> None: - """ - Given: a test function with @pytest.mark.slow already applied by the software-engineer - When: pytest is invoked and stub-sync processes the feature - Then: @pytest.mark.slow is unchanged and no other software-engineer marker is added or removed - """ - features_dir = tmp_path / "features" - tests_dir = tmp_path / "tests" - make_feature( - features_dir, - "in-progress", - "my-feature", - "my-story.feature", - """\ -Feature: My feature - @id:aabbccdd - Example: A slow example - Given something - When it runs slowly - Then it works -""", - ) - make_test_file( - tests_dir, - "my_feature", - "my_story", - '''\ -"""Tests for my story.""" - -import pytest - - -@pytest.mark.slow -def test_my_feature_aabbccdd() -> None: - """ - Given: something - When: it runs slowly - Then: it works - """ - raise NotImplementedError -''', - ) - sync_stubs(features_dir, tests_dir) - test_file = tests_dir / "my_feature" / "my_story_test.py" - content = test_file.read_text(encoding="utf-8") - assert "@pytest.mark.slow" in content - assert "@pytest.mark.deprecated" not in content - assert '@pytest.mark.skip(reason="orphan' not in content diff --git a/tests/test_smoke.py b/tests/test_smoke.py new file mode 100644 index 0000000..965a664 --- /dev/null +++ b/tests/test_smoke.py @@ -0,0 +1,33 @@ +"""Smoke tests verifying pytest-beehave plugin loads correctly.""" + +import importlib + +import pytest + + +def test_plugin_module_imports() -> None: + """The pytest_beehave.plugin module can be imported without errors.""" + mod = importlib.import_module("pytest_beehave.plugin") + assert hasattr(mod, "pytest_configure") + + +def test_version_is_set() -> None: + """The package __version__ is a non-empty string.""" + from pytest_beehave import __version__ + + assert isinstance(__version__, str) + assert len(__version__) > 0 + + +@pytest.mark.parametrize( + "module_name", + [ + "pytest_beehave.plugin", + "pytest_beehave.steps_display", + "pytest_beehave.html_column", + ], +) +def test_all_modules_import(module_name: str) -> None: + """Every module in the package imports cleanly.""" + mod = importlib.import_module(module_name) + assert mod is not None diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py deleted file mode 100644 index e0310a0..0000000 --- a/tests/unit/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Unit tests.""" diff --git a/tests/unit/bootstrap_test.py b/tests/unit/bootstrap_test.py deleted file mode 100644 index e84234f..0000000 --- a/tests/unit/bootstrap_test.py +++ /dev/null @@ -1,93 +0,0 @@ -"""Unit tests for pytest_beehave.bootstrap module.""" - -from pathlib import Path - -from pytest_beehave.bootstrap import BootstrapResult, bootstrap_features_directory - - -def test_bootstrap_result_is_noop_when_empty() -> None: - """ - Given: A BootstrapResult with no changes - When: is_noop is checked - Then: Returns True - """ - result = BootstrapResult( - created_subfolders=(), - migrated_files=(), - collision_warnings=(), - ) - assert result.is_noop is True - - -def test_bootstrap_result_is_not_noop_when_folders_created() -> None: - """ - Given: A BootstrapResult with created subfolders - When: is_noop is checked - Then: Returns False - """ - result = BootstrapResult( - created_subfolders=("backlog",), - migrated_files=(), - collision_warnings=(), - ) - assert result.is_noop is False - - -def test_bootstrap_returns_empty_result_when_root_does_not_exist( - tmp_path: Path, -) -> None: - """ - Given: A features root that does not exist - When: bootstrap_features_directory is called - Then: Returns a no-op BootstrapResult - """ - missing = tmp_path / "nonexistent" - result = bootstrap_features_directory(missing) - assert result.is_noop is True - assert result.created_subfolders == () - assert result.migrated_files == () - assert result.collision_warnings == () - - -def test_bootstrap_migrates_loose_feature_files(tmp_path: Path) -> None: - """ - Given: A features root with loose .feature files - When: bootstrap_features_directory is called - Then: Loose files are moved to backlog/ - """ - features_root = tmp_path / "features" - features_root.mkdir() - for subfolder in ("backlog", "in-progress", "completed"): - (features_root / subfolder).mkdir() - loose = features_root / "my-feature.feature" - loose.write_text("Feature: My Feature\n", encoding="utf-8") - - result = bootstrap_features_directory(features_root) - - assert len(result.migrated_files) == 1 - assert result.migrated_files[0] == features_root / "backlog" / "my-feature.feature" - assert not loose.exists() - assert (features_root / "backlog" / "my-feature.feature").exists() - - -def test_bootstrap_warns_on_collision(tmp_path: Path) -> None: - """ - Given: A loose .feature file that already exists in backlog/ - When: bootstrap_features_directory is called - Then: A collision warning is added and file is not migrated - """ - features_root = tmp_path / "features" - features_root.mkdir() - for subfolder in ("backlog", "in-progress", "completed"): - (features_root / subfolder).mkdir() - loose = features_root / "existing.feature" - loose.write_text("Feature: Existing\n", encoding="utf-8") - existing = features_root / "backlog" / "existing.feature" - existing.write_text("Feature: Already there\n", encoding="utf-8") - - result = bootstrap_features_directory(features_root) - - assert len(result.collision_warnings) == 1 - assert "Cannot migrate" in result.collision_warnings[0] - assert loose.exists() - assert existing.read_text(encoding="utf-8") == "Feature: Already there\n" diff --git a/tests/unit/config_test.py b/tests/unit/config_test.py deleted file mode 100644 index ae95d64..0000000 --- a/tests/unit/config_test.py +++ /dev/null @@ -1,56 +0,0 @@ -"""Unit tests for pytest_beehave.config module.""" - -from pathlib import Path - -import pytest - -from pytest_beehave.config import ( - DEFAULT_FEATURES_PATH, - is_explicitly_configured, - read_stub_format, - resolve_features_path, -) - - -def test_resolve_features_path_returns_default_when_no_pyproject( - tmp_path: Path, -) -> None: - """ - Given: A project root with no pyproject.toml file - When: resolve_features_path is called - Then: The default docs/features path is returned - """ - # Given - # (tmp_path has no pyproject.toml) - # When - result = resolve_features_path(tmp_path) - # Then - assert result == tmp_path / DEFAULT_FEATURES_PATH - - -def test_is_explicitly_configured_returns_false_when_no_pyproject( - tmp_path: Path, -) -> None: - """ - Given: A project root with no pyproject.toml file - When: is_explicitly_configured is called - Then: False is returned - """ - # Given - # (tmp_path has no pyproject.toml) - # When - result = is_explicitly_configured(tmp_path) - # Then - assert result is False - - -def test_read_stub_format_raises_on_invalid_value(tmp_path: Path) -> None: - """ - Given: A pyproject.toml with stub_format set to an invalid value - When: read_stub_format is called - Then: SystemExit is raised with the invalid value in the message - """ - pyproject = tmp_path / "pyproject.toml" - pyproject.write_text('[tool.beehave]\nstub_format = "methods"\n', encoding="utf-8") - with pytest.raises(SystemExit, match="methods"): - read_stub_format(tmp_path) diff --git a/tests/unit/feature_parser_test.py b/tests/unit/feature_parser_test.py deleted file mode 100644 index a6a4f06..0000000 --- a/tests/unit/feature_parser_test.py +++ /dev/null @@ -1,158 +0,0 @@ -"""Unit tests for pytest_beehave.feature_parser module.""" - -from pathlib import Path -from typing import Any - -from pytest_beehave.feature_parser import ( - _extract_id_from_tags, - _render_data_table, - _render_examples_table, - collect_all_example_ids, - parse_feature, -) -from pytest_beehave.models import ExampleId, FeatureSlug - - -def test_render_data_table_empty_rows() -> None: - """ - Given: An empty rows list - When: _render_data_table is called - Then: Returns empty string - """ - result = _render_data_table([]) - assert result == "" - - -def test_render_examples_table_empty_examples() -> None: - """ - Given: An empty examples list - When: _render_examples_table is called - Then: Returns empty string - """ - result = _render_examples_table([]) - assert result == "" - - -def test_render_examples_table_no_rows() -> None: - """ - Given: An examples list with no tableHeader and no tableBody - When: _render_examples_table is called - Then: Returns 'Examples:' - """ - examples: list[dict[str, Any]] = [{"tableBody": []}] - result = _render_examples_table(examples) - assert result == "Examples:" - - -def test_extract_id_from_tags_returns_none_when_no_match() -> None: - """ - Given: A list of tags with no @id tag - When: _extract_id_from_tags is called - Then: Returns None - """ - tags = [{"name": "@slow"}, {"name": "@deprecated"}] - result = _extract_id_from_tags(tags) - assert result is None - - -def test_parse_feature_uses_parent_name_when_folder_name_is_none( - tmp_path: Path, -) -> None: - """ - Given: A .feature file at path/my-feature/story.feature - When: parse_feature is called without folder_name - Then: Uses the parent directory name as the folder_name - """ - feature_dir = tmp_path / "my-feature" - feature_dir.mkdir() - feature_file = feature_dir / "story.feature" - feature_file.write_text("Feature: My Feature\n", encoding="utf-8") - - result = parse_feature(feature_file) - - assert result.feature_slug == FeatureSlug.from_folder_name("my-feature") - - -def test_parse_feature_returns_empty_when_feature_node_missing(tmp_path: Path) -> None: - """ - Given: A .feature file with no Feature: block - When: parse_feature is called - Then: Returns a ParsedFeature with empty rules and examples - """ - feature_file = tmp_path / "empty.feature" - feature_file.write_text("", encoding="utf-8") - - result = parse_feature(feature_file, folder_name="empty") - - assert result.rules == () - assert result.top_level_examples == () - assert result.is_deprecated is False - - -def test_parse_feature_skips_scenario_without_id_tag(tmp_path: Path) -> None: - """ - Given: A .feature file with a scenario that has no @id tag - When: parse_feature is called - Then: The scenario is not included in top_level_examples - """ - feature_file = tmp_path / "no-id.feature" - feature_file.write_text( - "Feature: No Id\n\n" - " Example: No id here\n" - " Given something\n" - " When something\n" - " Then something\n", - encoding="utf-8", - ) - - result = parse_feature(feature_file, folder_name="no-id") - - assert result.top_level_examples == () - - -def test_parsed_feature_all_example_ids_collects_from_rules_and_top_level( - tmp_path: Path, -) -> None: - """ - Given: A feature with both rules and top-level examples - When: all_example_ids is called - Then: Returns all IDs from both - """ - feature_file = tmp_path / "mixed.feature" - feature_file.write_text( - "Feature: Mixed\n\n" - " @id:aaaabbbb\n" - " Example: Top level\n" - " Given something\n\n" - " Rule: Some rule\n\n" - " @id:ccccdddd\n" - " Example: In rule\n" - " Given something else\n", - encoding="utf-8", - ) - - result = parse_feature(feature_file, folder_name="mixed") - - ids = result.all_example_ids() - assert ExampleId("aaaabbbb") in ids - assert ExampleId("ccccdddd") in ids - assert len(ids) == 2 - - -def test_collect_all_example_ids_delegates_to_all_example_ids(tmp_path: Path) -> None: - """ - Given: A parsed feature with examples - When: collect_all_example_ids is called - Then: Returns the same result as all_example_ids - """ - feature_file = tmp_path / "feature.feature" - feature_file.write_text( - "Feature: Test\n\n @id:aabbccdd\n Example: An example\n Given something\n", - encoding="utf-8", - ) - - parsed = parse_feature(feature_file, folder_name="feature") - result = collect_all_example_ids(parsed) - - assert result == parsed.all_example_ids() - assert ExampleId("aabbccdd") in result diff --git a/tests/unit/id_generator_test.py b/tests/unit/id_generator_test.py deleted file mode 100644 index b1bdde1..0000000 --- a/tests/unit/id_generator_test.py +++ /dev/null @@ -1,27 +0,0 @@ -"""Unit tests for id_generator module.""" - -from pathlib import Path - -from pytest_beehave.id_generator import _check_readonly_file, _id_tag_precedes - - -def test_id_tag_precedes_returns_false_for_empty_lines() -> None: - """_id_tag_precedes returns False when no preceding lines exist.""" - assert _id_tag_precedes([]) is False - - -def test_check_readonly_file_no_errors_when_examples_already_tagged( - tmp_path: Path, -) -> None: - """_check_readonly_file returns no errors for an already-tagged read-only file.""" - feature_file = tmp_path / "my.feature" - feature_file.write_text( - "Feature: F\n" - " @id:aabbccdd\n" - " Example: Already tagged\n" - " Given x\n" - " When y\n" - " Then z\n" - ) - feature_file.chmod(0o444) - assert _check_readonly_file(feature_file) == [] diff --git a/tests/unit/plugin_test.py b/tests/unit/plugin_test.py deleted file mode 100644 index dc828c5..0000000 --- a/tests/unit/plugin_test.py +++ /dev/null @@ -1,83 +0,0 @@ -"""Unit tests for pytest_beehave.plugin module.""" - -import sys -from pathlib import Path -from unittest.mock import MagicMock, patch - -import pytest - -from pytest_beehave.plugin import _html_available, features_path_key, pytest_configure -from pytest_beehave.steps_reporter import StepsReporter - - -def test_pytest_configure_stores_resolved_path_when_path_exists( - tmp_path: Path, -) -> None: - """ - Given: A project root with a valid features directory - When: pytest_configure is called - Then: The resolved path is stored in config.stash - """ - # Given - features_dir = tmp_path / "docs" / "features" - features_dir.mkdir(parents=True) - mock_config = MagicMock(spec=pytest.Config) - mock_config.rootpath = tmp_path - mock_config.stash = {} - mock_config.pluginmanager = MagicMock() - mock_config.getoption.return_value = False - # When - pytest_configure(mock_config) - # Then - assert mock_config.stash[features_path_key] == features_dir - - -def test_html_available_returns_false_when_not_installed() -> None: - """ - Given: pytest-html is not importable - When: _html_available is called - Then: False is returned - """ - with patch.dict(sys.modules, {"pytest_html": None}): - result = _html_available() - assert result is False - - -def test_steps_reporter_skips_empty_docstring() -> None: - """ - Given: A test report with an empty docstring - When: pytest_runtest_logreport is called - Then: Nothing is written to the terminal - """ - mock_config = MagicMock() - mock_config.option.verbose = 1 - reporter = StepsReporter(mock_config) - report = MagicMock(spec=pytest.TestReport) - report.when = "call" - report.skipped = False - report.nodeid = "tests/features/my_feature/my_rule_test.py::test_something" - report._beehave_docstring = "" - reporter.pytest_runtest_logreport(report) - mock_config.get_terminal_writer.assert_not_called() - - -def test_steps_reporter_falls_back_to_stdout( - capsys: pytest.CaptureFixture[str], -) -> None: - """ - Given: A test report with a docstring and a broken terminal writer - When: pytest_runtest_logreport is called - Then: The docstring is written to stdout - """ - mock_config = MagicMock() - mock_config.option.verbose = 1 - mock_config.get_terminal_writer.side_effect = AttributeError("no writer") - reporter = StepsReporter(mock_config) - report = MagicMock(spec=pytest.TestReport) - report.when = "call" - report.skipped = False - report.nodeid = "tests/features/my_feature/my_rule_test.py::test_something" - report._beehave_docstring = "Given: a condition" - reporter.pytest_runtest_logreport(report) - captured = capsys.readouterr() - assert "Given: a condition" in captured.out diff --git a/tests/unit/pytest_beehave_test.py b/tests/unit/pytest_beehave_test.py deleted file mode 100644 index f577596..0000000 --- a/tests/unit/pytest_beehave_test.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Unit tests for the application entry point.""" - -from hypothesis import example, given -from hypothesis import strategies as st - -from pytest_beehave.__main__ import main - - -@given(verbosity=st.sampled_from(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"])) -@example(verbosity="INFO") -def test_app_main_runs_with_valid_verbosity(verbosity: str) -> None: - """ - Given: A valid verbosity level string - When: main() is called with that verbosity - Then: It completes without raising an exception - """ - main(verbosity) diff --git a/tests/unit/reporter_test.py b/tests/unit/reporter_test.py deleted file mode 100644 index 8afbb9c..0000000 --- a/tests/unit/reporter_test.py +++ /dev/null @@ -1,106 +0,0 @@ -"""Unit tests for pytest_beehave.reporter module.""" - -from pathlib import Path -from unittest.mock import MagicMock - -from pytest_beehave.bootstrap import BootstrapResult -from pytest_beehave.reporter import ( - TerminalWriterProtocol, - report_bootstrap, - report_id_write_back, - report_sync_actions, -) - - -def _make_writer() -> MagicMock: - return MagicMock(spec=TerminalWriterProtocol) - - -def test_report_bootstrap_reports_created_subfolders() -> None: - """ - Given: A BootstrapResult with created subfolders - When: report_bootstrap is called - Then: Writer.line is called for each created subfolder - """ - writer = _make_writer() - result = BootstrapResult( - created_subfolders=("backlog", "in-progress"), - migrated_files=(), - collision_warnings=(), - ) - report_bootstrap(writer, result) - writer.line.assert_any_call("[beehave] MKDIR backlog/") - writer.line.assert_any_call("[beehave] MKDIR in-progress/") - - -def test_report_bootstrap_reports_migrated_files() -> None: - """ - Given: A BootstrapResult with migrated files - When: report_bootstrap is called - Then: Writer.line is called for each migrated file - """ - writer = _make_writer() - path = Path("/some/path/my-feature.feature") - result = BootstrapResult( - created_subfolders=(), - migrated_files=(path,), - collision_warnings=(), - ) - report_bootstrap(writer, result) - writer.line.assert_any_call(f"[beehave] MIGRATE {path}") - - -def test_report_bootstrap_reports_collision_warnings() -> None: - """ - Given: A BootstrapResult with collision warnings - When: report_bootstrap is called - Then: Writer.line is called for each warning - """ - writer = _make_writer() - result = BootstrapResult( - created_subfolders=(), - migrated_files=(), - collision_warnings=("Cannot migrate foo: bar exists",), - ) - report_bootstrap(writer, result) - writer.line.assert_any_call("[beehave] WARNING Cannot migrate foo: bar exists") - - -def test_report_bootstrap_does_nothing_for_noop() -> None: - """ - Given: A no-op BootstrapResult - When: report_bootstrap is called - Then: Writer.line is never called - """ - writer = _make_writer() - result = BootstrapResult( - created_subfolders=(), - migrated_files=(), - collision_warnings=(), - ) - report_bootstrap(writer, result) - writer.line.assert_not_called() - - -def test_report_id_write_back_reports_errors() -> None: - """ - Given: A list of error strings - When: report_id_write_back is called - Then: Writer.line is called for each error - """ - writer = _make_writer() - report_id_write_back(writer, ["error one", "error two"]) - writer.line.assert_any_call("[beehave] ERROR: error one") - writer.line.assert_any_call("[beehave] ERROR: error two") - - -def test_report_sync_actions_reports_actions() -> None: - """ - Given: A list of action strings - When: report_sync_actions is called - Then: Writer.line is called for each action - """ - writer = _make_writer() - report_sync_actions(writer, ["CREATE /some/file", "UPDATE /other/file"]) - writer.line.assert_any_call("[beehave] CREATE /some/file") - writer.line.assert_any_call("[beehave] UPDATE /other/file") diff --git a/tests/unit/stub_reader_test.py b/tests/unit/stub_reader_test.py deleted file mode 100644 index f6c6fd6..0000000 --- a/tests/unit/stub_reader_test.py +++ /dev/null @@ -1,185 +0,0 @@ -"""Unit tests for pytest_beehave.stub_reader module.""" - -from pathlib import Path - -from pytest_beehave.models import ExampleId -from pytest_beehave.stub_reader import ( - _extract_docstring, - _find_triple_quote_end, - extract_example_id_from_name, - read_stubs_from_file, -) - - -def test_extract_example_id_from_name_returns_id_when_matched() -> None: - """ - Given: A function name with an 8-char hex suffix - When: extract_example_id_from_name is called - Then: Returns the ExampleId - """ - result = extract_example_id_from_name("test_my_feature_aabbccdd") - assert result == ExampleId("aabbccdd") - - -def test_extract_example_id_from_name_returns_none_when_no_match() -> None: - """ - Given: A string without an 8-char hex suffix - When: extract_example_id_from_name is called - Then: Returns None - """ - result = extract_example_id_from_name("not_a_test_function") - assert result is None - - -def test_find_triple_quote_end_returns_content_length_when_unclosed() -> None: - """ - Given: Content where the triple-quote is never closed - When: _find_triple_quote_end is called - Then: Returns the length of the content - """ - content = '"""unclosed string without end' - result = _find_triple_quote_end(content, 0, '"""') - assert result == len(content) - - -def test_extract_docstring_returns_empty_when_no_newline_after_def() -> None: - """ - Given: Content where there is no newline after the def line position - When: _extract_docstring is called with func_start at the last line - Then: Returns empty string - """ - content = "def test_foo_aabbccdd() -> None:" - # func_start at 0, no newline in content → def_end == -1 - result = _extract_docstring(content, 0) - assert result == "" - - -def test_extract_docstring_returns_empty_when_no_triple_quote() -> None: - """ - Given: A function with no docstring after the def line - When: _extract_docstring is called - Then: Returns empty string - """ - content = "def test_foo_aabbccdd() -> None:\n pass\n" - result = _extract_docstring(content, 0) - assert result == "" - - -def test_extract_docstring_returns_empty_when_unclosed_triple_quote() -> None: - """ - Given: A function with an unclosed triple-quote docstring - When: _extract_docstring is called - Then: Returns empty string - """ - content = 'def test_foo_aabbccdd() -> None:\n """unclosed docstring without end' - result = _extract_docstring(content, 0) - assert result == "" - - -def test_read_stubs_from_file_returns_empty_when_file_not_exists( - tmp_path: Path, -) -> None: - """ - Given: A path to a file that does not exist - When: read_stubs_from_file is called - Then: Returns an empty list - """ - missing = tmp_path / "no_such_file_test.py" - result = read_stubs_from_file(missing) - assert result == [] - - -def test_read_stubs_from_file_skips_function_inside_string(tmp_path: Path) -> None: - """ - Given: A test file with a def statement inside a triple-quoted string - When: read_stubs_from_file is called - Then: The embedded function is not returned as a stub - """ - test_file = tmp_path / "example_test.py" - test_file.write_text( - '"""Tests.\n' - "def test_feature_aabbccdd() -> None:\n" - ' pass\n"""\n' - "\nimport pytest\n", - encoding="utf-8", - ) - result = read_stubs_from_file(test_file) - assert result == [] - - -def test_read_stubs_from_file_reads_real_stubs(tmp_path: Path) -> None: - """ - Given: A test file with a real test function - When: read_stubs_from_file is called - Then: Returns ExistingStub with correct data - """ - test_file = tmp_path / "examples_test.py" - test_file.write_text( - '"""Tests for examples story."""\n\n' - "import pytest\n\n\n" - '@pytest.mark.skip(reason="not yet implemented")\n' - "def test_my_feature_aabbccdd() -> None:\n" - ' """\n' - " Given: Something\n" - " When: Something happens\n" - " Then: Result\n" - ' """\n' - " raise NotImplementedError\n", - encoding="utf-8", - ) - result = read_stubs_from_file(test_file) - assert len(result) == 1 - stub = result[0] - assert stub.function_name == "test_my_feature_aabbccdd" - assert stub.example_id == ExampleId("aabbccdd") - assert "pytest.mark.skip(reason=" in " ".join(stub.markers) - - -def test_read_stubs_from_file_reads_class_method_stubs(tmp_path: Path) -> None: - """ - Given: A test file with a class-method stub inside a Test class - When: read_stubs_from_file is called - Then: Returns ExistingStub with correct class_name - """ - test_file = tmp_path / "my_rule_test.py" - test_file.write_text( - '"""Tests for my_rule story."""\n\n' - "import pytest\n\n\n" - "class TestMyRule:\n" - ' @pytest.mark.skip(reason="not yet implemented")\n' - " def test_my_feature_aabbccdd(self) -> None:\n" - ' """\n' - " Given: Something\n" - " When: Something happens\n" - " Then: Result\n" - ' """\n' - " raise NotImplementedError\n", - encoding="utf-8", - ) - result = read_stubs_from_file(test_file) - assert len(result) == 1 - stub = result[0] - assert stub.function_name == "test_my_feature_aabbccdd" - assert stub.class_name == "TestMyRule" - - -def test_read_stubs_from_file_indented_without_class_returns_none_class( - tmp_path: Path, -) -> None: - """ - Given: A test file where a def line is indented but has no class before it - When: read_stubs_from_file is called - Then: Returns ExistingStub with class_name=None - """ - test_file = tmp_path / "edge_case_test.py" - # Indented function with no class declaration above it - test_file.write_text( - "if True:\n" - " def test_my_feature_aabbccdd() -> None:\n" - ' """Given: x\nWhen: y\nThen: z\n"""\n' - " pass\n", - encoding="utf-8", - ) - result = read_stubs_from_file(test_file) - assert len(result) == 1 - assert result[0].class_name is None diff --git a/tests/unit/stub_writer_test.py b/tests/unit/stub_writer_test.py deleted file mode 100644 index cb94bb2..0000000 --- a/tests/unit/stub_writer_test.py +++ /dev/null @@ -1,419 +0,0 @@ -"""Unit tests for pytest_beehave.stub_writer module.""" - -from pathlib import Path - -import pytest - -from pytest_beehave.feature_parser import ( - ParsedExample, - ParsedFeature, - ParsedRule, - ParsedStep, -) -from pytest_beehave.models import ExampleId, FeatureSlug, RuleSlug -from pytest_beehave.stub_writer import ( - StubSpec, - SyncAction, - _stub_function_source, - mark_non_conforming, - mark_orphan, - toggle_deprecated_marker, - write_stub_to_file, -) - - -def _make_example(hex_id: str, *, deprecated: bool = False) -> ParsedExample: - return ParsedExample( - example_id=ExampleId(hex_id), - steps=( - ParsedStep( - keyword="Given", text="something", doc_string=None, data_table=None - ), - ), - background_sections=(), - outline_examples=None, - is_deprecated=deprecated, - ) - - -def _make_feature( - slug: str, - rules: tuple[ParsedRule, ...] = (), - top_level: tuple[ParsedExample, ...] = (), -) -> ParsedFeature: - return ParsedFeature( - path=Path(f"/fake/{slug}.feature"), - feature_slug=FeatureSlug(slug), - rules=rules, - top_level_examples=top_level, - is_deprecated=False, - ) - - -def test_sync_action_str_includes_detail_when_present() -> None: - """ - Given: A SyncAction with a non-empty detail - When: str() is called - Then: Returns string including the detail in parens - """ - action = SyncAction( - action="ORPHAN", path=Path("/foo/bar_test.py"), detail="some detail" - ) - result = str(action) - assert "some detail" in result - assert "ORPHAN" in result - - -def test_sync_action_str_excludes_detail_when_empty() -> None: - """ - Given: A SyncAction with an empty detail - When: str() is called - Then: Returns string without parentheses - """ - action = SyncAction(action="CREATE", path=Path("/foo/bar_test.py")) - result = str(action) - assert "(" not in result - assert "CREATE" in result - - -def test_stub_function_source_deprecated_branch() -> None: - """ - Given: A deprecated=True flag - When: _stub_function_source is called - Then: Returns source with @pytest.mark.deprecated decorator - """ - source = _stub_function_source("test_my_feature_aabbccdd", " Given: x", True) - assert "@pytest.mark.deprecated" in source - assert "@pytest.mark.skip" not in source - - -def test_write_stub_to_file_appends_to_existing_module_level(tmp_path: Path) -> None: - """ - Given: An existing test file with one stub already - When: write_stub_to_file is called for a new top-level example - Then: The new stub is appended to the file - """ - test_file = tmp_path / "examples_test.py" - test_file.write_text( - '"""Tests for examples story."""\n\nimport pytest\n\n\n' - '@pytest.mark.skip(reason="not yet implemented")\n' - "def test_my_feature_11111111() -> None:\n" - ' """\n Given: first\n """\n' - " raise NotImplementedError\n", - encoding="utf-8", - ) - example = _make_example("22222222") - feature = _make_feature("my_feature", top_level=(example,)) - spec = StubSpec( - feature_slug=FeatureSlug("my_feature"), - rule_slug=None, - example=example, - feature=feature, - ) - action = write_stub_to_file(test_file, spec) - assert action.action == "UPDATE" - content = test_file.read_text(encoding="utf-8") - assert "test_my_feature_22222222" in content - - -def test_write_class_based_stub_raises_on_none_rule_slug(tmp_path: Path) -> None: - """ - Given: A StubSpec with rule_slug=None passed to _write_class_based_stub - When: write_stub_to_file tries to use it as a class-based stub - Then: ValueError is raised - """ - from pytest_beehave.stub_writer import _write_class_based_stub - - test_file = tmp_path / "rule_test.py" - example = _make_example("aabbccdd") - feature = _make_feature("my_feature", top_level=(example,)) - spec = StubSpec( - feature_slug=FeatureSlug("my_feature"), - rule_slug=None, - example=example, - feature=feature, - ) - with pytest.raises(ValueError, match="rule_slug must not be None"): - _write_class_based_stub( - test_file, - spec, - "test_my_feature_aabbccdd", - "def test_my_feature_aabbccdd() -> None:\n pass\n", - ) - - -def test_write_class_based_stub_adds_to_existing_class(tmp_path: Path) -> None: - """ - Given: A test file with an existing class - When: write_stub_to_file is called for a new example in the same rule - Then: The method is appended to the existing class - """ - test_file = tmp_path / "my_rule_test.py" - test_file.write_text( - '"""Tests for my rule story."""\n\nimport pytest\n\n\n' - "class TestMyRule:\n" - ' @pytest.mark.skip(reason="not yet implemented")\n' - " def test_my_feature_11111111(self) -> None:\n" - ' """\n Given: first\n """\n' - " raise NotImplementedError\n", - encoding="utf-8", - ) - example = _make_example("22222222") - rule = ParsedRule( - title="My Rule", - rule_slug=RuleSlug("my_rule"), - examples=(example,), - is_deprecated=False, - ) - feature = _make_feature("my_feature", rules=(rule,)) - spec = StubSpec( - feature_slug=FeatureSlug("my_feature"), - rule_slug=RuleSlug("my_rule"), - example=example, - feature=feature, - stub_format="classes", - ) - action = write_stub_to_file(test_file, spec) - assert action.action == "UPDATE" - content = test_file.read_text(encoding="utf-8") - assert "test_my_feature_22222222" in content - lines = content.splitlines() - def_line = next((ln for ln in lines if "def test_my_feature_22222222" in ln), "") - assert "(self)" in def_line - - -def test_write_class_based_stub_creates_new_class_in_existing_file( - tmp_path: Path, -) -> None: - """ - Given: A test file without the target class - When: write_stub_to_file is called for a rule-based example - Then: A new class block is added to the file - """ - test_file = tmp_path / "other_rule_test.py" - test_file.write_text( - '"""Tests for other rule story."""\n\nimport pytest\n\n\n' - "class TestOtherRule:\n" - " pass\n", - encoding="utf-8", - ) - example = _make_example("aabbccdd") - rule = ParsedRule( - title="New Rule", - rule_slug=RuleSlug("new_rule"), - examples=(example,), - is_deprecated=False, - ) - feature = _make_feature("my_feature", rules=(rule,)) - spec = StubSpec( - feature_slug=FeatureSlug("my_feature"), - rule_slug=RuleSlug("new_rule"), - example=example, - feature=feature, - stub_format="classes", - ) - action = write_stub_to_file(test_file, spec) - assert action.action == "UPDATE" - content = test_file.read_text(encoding="utf-8") - assert "class TestNewRule:" in content - assert "test_my_feature_aabbccdd" in content - lines = content.splitlines() - def_line = next((ln for ln in lines if "def test_my_feature_aabbccdd" in ln), "") - assert "(self)" in def_line - - -def test_find_rule_returns_none_when_not_found() -> None: - """ - Given: A feature with rules that don't match the target slug - When: _find_rule is called with a non-existent slug - Then: Returns None - """ - from pytest_beehave.stub_writer import _find_rule - - rule = ParsedRule( - title="Existing Rule", - rule_slug=RuleSlug("existing_rule"), - examples=(), - is_deprecated=False, - ) - feature = _make_feature("my_feature", rules=(rule,)) - result = _find_rule(feature, RuleSlug("non_existent")) - assert result is None - - -def test_mark_orphan_returns_none_when_function_not_found(tmp_path: Path) -> None: - """ - Given: A test file that doesn't contain the target function - When: mark_orphan is called - Then: Returns None - """ - test_file = tmp_path / "examples_test.py" - test_file.write_text( - '"""Tests."""\n\nimport pytest\n\n\n' - "def test_other_feature_aabbccdd() -> None:\n" - " raise NotImplementedError\n", - encoding="utf-8", - ) - result = mark_orphan(test_file, "test_missing_feature_11111111") - assert result is None - - -def test_mark_orphan_returns_none_when_already_marked(tmp_path: Path) -> None: - """ - Given: A test file with a function already marked as orphan - When: mark_orphan is called again - Then: Returns None (no change) - """ - test_file = tmp_path / "examples_test.py" - test_file.write_text( - '"""Tests."""\n\nimport pytest\n\n\n' - '@pytest.mark.skip(reason="orphan: no matching @id in .feature files")\n' - "def test_my_feature_aabbccdd() -> None:\n" - " raise NotImplementedError\n", - encoding="utf-8", - ) - result = mark_orphan(test_file, "test_my_feature_aabbccdd") - assert result is None - - -def test_mark_non_conforming_adds_marker(tmp_path: Path) -> None: - """ - Given: A test file with a non-conforming function - When: mark_non_conforming is called - Then: A skip marker is prepended to the function - """ - test_file = tmp_path / "wrong_test.py" - correct = tmp_path / "correct" / "right_test.py" - test_file.write_text( - '"""Tests."""\n\nimport pytest\n\n\n' - "def test_my_feature_aabbccdd() -> None:\n" - " raise NotImplementedError\n", - encoding="utf-8", - ) - result = mark_non_conforming( - test_file, "test_my_feature_aabbccdd", correct, "TestMyRule" - ) - assert result is not None - assert result.action == "NON_CONFORMING" - content = test_file.read_text(encoding="utf-8") - assert "non-conforming" in content - assert "TestMyRule" in content - - -def test_mark_non_conforming_returns_none_when_function_not_found( - tmp_path: Path, -) -> None: - """ - Given: A test file without the target function - When: mark_non_conforming is called - Then: Returns None - """ - test_file = tmp_path / "wrong_test.py" - correct = tmp_path / "correct" / "right_test.py" - test_file.write_text( - '"""Tests."""\n\nimport pytest\n\n', - encoding="utf-8", - ) - result = mark_non_conforming(test_file, "test_missing_aabbccdd", correct, None) - assert result is None - - -def test_mark_non_conforming_returns_none_when_already_marked(tmp_path: Path) -> None: - """ - Given: A test file where the function already has a non-conforming marker - When: mark_non_conforming is called again - Then: Returns None (idempotent) - """ - test_file = tmp_path / "wrong_test.py" - correct = tmp_path / "correct" / "right_test.py" - detail = f"should be in {correct}" - test_file.write_text( - '"""Tests."""\n\nimport pytest\n\n\n' - f'@pytest.mark.skip(reason="non-conforming: {detail}")\n' - "def test_my_feature_aabbccdd() -> None:\n" - " raise NotImplementedError\n", - encoding="utf-8", - ) - result = mark_non_conforming(test_file, "test_my_feature_aabbccdd", correct, None) - assert result is None - - -def test_toggle_deprecated_marker_skips_non_matching_functions(tmp_path: Path) -> None: - """ - Given: A test file with multiple functions and only one matching the target - When: toggle_deprecated_marker is called on the target function - Then: Only the target function is affected; non-matching functions are skipped - """ - test_file = tmp_path / "examples_test.py" - test_file.write_text( - '"""Tests."""\n\nimport pytest\n\n\n' - "def test_my_feature_11111111() -> None:\n" - " raise NotImplementedError\n\n\n" - "def test_my_feature_aabbccdd() -> None:\n" - " raise NotImplementedError\n", - encoding="utf-8", - ) - result = toggle_deprecated_marker( - test_file, "test_my_feature_aabbccdd", should_be_deprecated=True - ) - assert result is not None - assert result.action == "DEPRECATED" - content = test_file.read_text(encoding="utf-8") - # Only aabbccdd got the marker; 11111111 was skipped - assert content.count("@pytest.mark.deprecated") == 1 - assert "def test_my_feature_aabbccdd" in content - - -def test_toggle_deprecated_marker_removes_when_not_deprecated(tmp_path: Path) -> None: - """ - Given: A test file with @pytest.mark.deprecated on a function - When: toggle_deprecated_marker is called with should_be_deprecated=False - Then: The deprecated marker is removed - """ - test_file = tmp_path / "examples_test.py" - test_file.write_text( - '"""Tests."""\n\nimport pytest\n\n\n' - "@pytest.mark.deprecated\n" - "def test_my_feature_aabbccdd() -> None:\n" - " raise NotImplementedError\n", - encoding="utf-8", - ) - result = toggle_deprecated_marker( - test_file, "test_my_feature_aabbccdd", should_be_deprecated=False - ) - assert result is not None - assert result.action == "DEPRECATED" - content = test_file.read_text(encoding="utf-8") - assert "@pytest.mark.deprecated" not in content - - -def test_write_class_based_stub_creates_new_file(tmp_path: Path) -> None: - """ - Given: A non-existent test file and stub_format="classes" - When: write_stub_to_file is called for a rule-based example - Then: A new file is created with a class containing the method stub - """ - test_file = tmp_path / "my_rule_test.py" - example = _make_example("aabbccdd") - rule = ParsedRule( - title="My Rule", - rule_slug=RuleSlug("my_rule"), - examples=(example,), - is_deprecated=False, - ) - feature = _make_feature("my_feature", rules=(rule,)) - spec = StubSpec( - feature_slug=FeatureSlug("my_feature"), - rule_slug=RuleSlug("my_rule"), - example=example, - feature=feature, - stub_format="classes", - ) - action = write_stub_to_file(test_file, spec) - assert action.action == "CREATE" - content = test_file.read_text(encoding="utf-8") - assert "class TestMyRule:" in content - assert "test_my_feature_aabbccdd" in content - lines = content.splitlines() - def_line = next((ln for ln in lines if "def test_my_feature_aabbccdd" in ln), "") - assert "(self)" in def_line diff --git a/tests/unit/sync_engine_test.py b/tests/unit/sync_engine_test.py deleted file mode 100644 index c6751fc..0000000 --- a/tests/unit/sync_engine_test.py +++ /dev/null @@ -1,214 +0,0 @@ -"""Unit tests for pytest_beehave.sync_engine module.""" - -from pathlib import Path - -from pytest_beehave.feature_parser import ( - ParsedExample, - ParsedFeature, - ParsedRule, - ParsedStep, -) -from pytest_beehave.models import ExampleId, FeatureSlug, FeatureStage, RuleSlug -from pytest_beehave.sync_engine import ( - SyncResult, - _sync_completed_feature, - _sync_rule_stubs, - discover_feature_locations, - run_sync, -) - - -def _make_step() -> ParsedStep: - return ParsedStep( - keyword="Given", text="something", doc_string=None, data_table=None - ) - - -def _make_example(hex_id: str, *, deprecated: bool = False) -> ParsedExample: - return ParsedExample( - example_id=ExampleId(hex_id), - steps=(_make_step(),), - background_sections=(), - outline_examples=None, - is_deprecated=deprecated, - ) - - -def _make_feature( - slug: str, - rules: tuple[ParsedRule, ...] = (), - top_level: tuple[ParsedExample, ...] = (), -) -> ParsedFeature: - return ParsedFeature( - path=Path(f"/fake/{slug}.feature"), - feature_slug=FeatureSlug(slug), - rules=rules, - top_level_examples=top_level, - is_deprecated=False, - ) - - -def test_sync_result_is_noop_when_no_actions() -> None: - """ - Given: A SyncResult with no actions - When: is_noop is checked - Then: Returns True - """ - result = SyncResult(actions=()) - assert result.is_noop is True - - -def test_sync_rule_stubs_returns_empty_when_no_examples(tmp_path: Path) -> None: - """ - Given: A rule with no examples - When: _sync_rule_stubs is called - Then: Returns an empty list - """ - rule = ParsedRule( - title="Empty Rule", - rule_slug=RuleSlug("empty-rule"), - examples=(), - is_deprecated=False, - ) - feature = _make_feature("my_feature", rules=(rule,)) - result = _sync_rule_stubs(feature, rule, tmp_path) - assert result == [] - - -def test_sync_completed_feature_with_rules(tmp_path: Path) -> None: - """ - Given: A completed feature with Rule blocks and a test file with a stub - When: _sync_completed_feature is called - Then: Deprecated markers are toggled in the rule test file - """ - feature_test_dir = tmp_path / "my_feature" - feature_test_dir.mkdir() - rule_test = feature_test_dir / "my-rule_test.py" - rule_test.write_text( - '"""Tests."""\n\nimport pytest\n\n\n' - "class TestMyRule:\n" - ' @pytest.mark.skip(reason="not yet implemented")\n' - " def test_my_feature_aabbccdd() -> None:\n" - " raise NotImplementedError\n", - encoding="utf-8", - ) - example = _make_example("aabbccdd", deprecated=True) - rule = ParsedRule( - title="My Rule", - rule_slug=RuleSlug("my-rule"), - examples=(example,), - is_deprecated=False, - ) - feature = _make_feature("my_feature", rules=(rule,)) - actions = _sync_completed_feature(feature, tmp_path) - # toggle_deprecated_marker was called; whether it produced an action depends on - # the function finding the test — class-based stubs use indented def which should match - # The point is the code path runs without error - assert isinstance(actions, list) - - -def test_discover_feature_locations_uses_stem_when_feature_at_stage_root( - tmp_path: Path, -) -> None: - """ - Given: A .feature file directly inside a stage directory (not in a subfolder) - When: discover_feature_locations is called - Then: The feature slug is derived from the file stem - """ - features_dir = tmp_path / "features" - backlog_dir = features_dir / "backlog" - backlog_dir.mkdir(parents=True) - feature_file = backlog_dir / "my-feature.feature" - feature_file.write_text( - "Feature: My Feature\n\n" - " @id:aabbccdd\n" - " Example: An example\n" - " Given something\n", - encoding="utf-8", - ) - - class MockFS: - def list_feature_files(self, stage_dir: Path) -> list[Path]: - if stage_dir == backlog_dir: - return [feature_file] - return [] - - def list_test_files(self, tests_dir: Path) -> list[Path]: - return [] - - results = discover_feature_locations(features_dir, MockFS()) # type: ignore[arg-type] - assert len(results) == 1 - feature, result_stage = results[0] - assert str(feature.feature_slug) == "my_feature" - assert result_stage == FeatureStage.BACKLOG - - -def test_discover_feature_locations_uses_parent_name_when_feature_in_subfolder( - tmp_path: Path, -) -> None: - """ - Given: A .feature file inside a named subfolder of a stage directory - When: discover_feature_locations is called - Then: The feature slug is derived from the parent directory name - """ - features_dir = tmp_path / "features" - backlog_dir = features_dir / "backlog" - feature_dir = backlog_dir / "my-feature" - feature_dir.mkdir(parents=True) - feature_file = feature_dir / "story.feature" - feature_file.write_text("Feature: Story\n", encoding="utf-8") - - class MockFS: - def list_feature_files(self, stage_dir: Path) -> list[Path]: - if stage_dir == backlog_dir: - return [feature_file] - return [] - - def list_test_files(self, tests_dir: Path) -> list[Path]: - return [] - - results = discover_feature_locations(features_dir, MockFS()) # type: ignore[arg-type] - assert len(results) == 1 - feature, _ = results[0] - assert str(feature.feature_slug) == "my_feature" - - -def test_sync_rule_stubs_syncs_deprecated_markers_after_creating_stubs( - tmp_path: Path, -) -> None: - """ - Given: A rule with a deprecated example and no existing test file - When: _sync_rule_stubs is called - Then: Creates the stub file and runs deprecated sync - """ - test_dir = tmp_path / "my_feature" - test_dir.mkdir() - example = _make_example("aabbccdd", deprecated=True) - rule = ParsedRule( - title="My Rule", - rule_slug=RuleSlug("my-rule"), - examples=(example,), - is_deprecated=False, - ) - feature = _make_feature("my_feature", rules=(rule,)) - actions = _sync_rule_stubs(feature, rule, test_dir) - # At minimum, CREATE action for the new file - action_names = [a.action for a in actions] - assert "CREATE" in action_names - - -def test_run_sync_returns_empty_list_for_empty_features_dir(tmp_path: Path) -> None: - """ - Given: Features and tests directories that are empty - When: run_sync is called - Then: Returns an empty list of actions - """ - features_root = tmp_path / "features" - tests_root = tmp_path / "tests" / "features" - features_root.mkdir() - tests_root.mkdir(parents=True) - for subfolder in ("backlog", "in-progress", "completed"): - (features_root / subfolder).mkdir() - - result = run_sync(features_root, tests_root) - assert result == [] diff --git a/uv.lock b/uv.lock index a07db0a..a441ea1 100644 --- a/uv.lock +++ b/uv.lock @@ -1,9 +1,17 @@ version = 1 revision = 3 -requires-python = ">=3.13" -resolution-markers = [ - "python_full_version >= '3.14'", - "python_full_version < '3.14'", +requires-python = ">=3.14" + +[[package]] +name = "agents-smith" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/59/eb40b30f1bfe96e4890a5cff457e10270073744d870cae2726d5ab4d7068/agents_smith-1.0.0.tar.gz", hash = "sha256:f53f27d767fae974f27663a2f6702546dcfaf1da92282cb2428f5041abd4bc0a", size = 10836, upload-time = "2026-05-02T16:56:12.636Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/f8/e5ba7318a95a29b732c7822e7b4a8eecbb25b8b6a0b1484d48afaa437d12/agents_smith-1.0.0-py3-none-any.whl", hash = "sha256:c2e051f20d368c420efa865b73890407726189047ab9155bf7bd89a59ef5d8d1", size = 10109, upload-time = "2026-05-02T16:56:11.433Z" }, ] [[package]] @@ -38,23 +46,36 @@ wheels = [ [[package]] name = "authlib" -version = "1.6.9" +version = "1.7.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, + { name = "joserfc" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/98/7d93f30d029643c0275dbc0bd6d5a6f670661ee6c9a94d93af7ab4887600/authlib-1.7.2.tar.gz", hash = "sha256:2cea25fefcd4e7173bdf1372c0afc265c8034b23a8cd5dcb6a9164b826c64231", size = 176511, upload-time = "2026-05-06T08:10:23.116Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/95/adcb68e20c34162e9135f370d6e31737719c2b6f94bc953fe7ed1f10fe21/authlib-1.7.2-py2.py3-none-any.whl", hash = "sha256:3e1faedc9d87e7d56a164eca3ccb6ace0d61b94abe83e92242f8dc8bba9b4a9f", size = 259548, upload-time = "2026-05-06T08:10:21.436Z" }, +] + +[[package]] +name = "beehave" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gherkin-official" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/af/98/00d3dd826d46959ad8e32af2dbb2398868fd9fd0683c26e56d0789bd0e68/authlib-1.6.9.tar.gz", hash = "sha256:d8f2421e7e5980cc1ddb4e32d3f5fa659cfaf60d8eaf3281ebed192e4ab74f04", size = 165134, upload-time = "2026-03-02T07:44:01.998Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0c/7e/b64d1cd39f6e6a25479b616ed3bdc9d62f9cd7e52555c4c3960498763c8d/beehave-0.4.0.tar.gz", hash = "sha256:f0a17b4964b6d8f38f1a378032bf942edd31c364a8c3ca74f6a871e46ab40275", size = 55081, upload-time = "2026-05-13T15:53:02.276Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/53/23/b65f568ed0c22f1efacb744d2db1a33c8068f384b8c9b482b52ebdbc3ef6/authlib-1.6.9-py2.py3-none-any.whl", hash = "sha256:f08b4c14e08f0861dc18a32357b33fbcfd2ea86cfe3fe149484b4d764c4a0ac3", size = 244197, upload-time = "2026-03-02T07:44:00.307Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/b00734b18ff765b412b1021c7eca9bdf38f30eaeac81b0005c675140edf6/beehave-0.4.0-py3-none-any.whl", hash = "sha256:c2a0335671a4f865c1fdad1e5e7511297faf0f1d2a93dc14c0ea83461f323616", size = 17187, upload-time = "2026-05-13T15:53:00.404Z" }, ] [[package]] name = "certifi" -version = "2026.2.25" +version = "2026.4.22" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, + { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, ] [[package]] @@ -66,18 +87,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, - { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, - { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, - { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, - { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, - { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, - { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, - { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, - { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, - { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, - { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, - { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, @@ -108,22 +117,6 @@ version = "3.4.7" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, - { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, - { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, - { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, - { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, - { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, - { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, - { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, - { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, - { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, - { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, - { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, - { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, - { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, - { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, - { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, @@ -161,14 +154,14 @@ wheels = [ [[package]] name = "click" -version = "8.1.8" +version = "8.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188, upload-time = "2024-12-21T18:38:41.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" }, ] [[package]] @@ -182,84 +175,94 @@ wheels = [ [[package]] name = "coverage" -version = "7.8.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/19/4f/2251e65033ed2ce1e68f00f91a0294e0f80c80ae8c3ebbe2f12828c4cd53/coverage-7.8.0.tar.gz", hash = "sha256:7a3d62b3b03b4b6fd41a085f3574874cf946cb4604d2b4d3e8dca8cd570ca501", size = 811872, upload-time = "2025-03-30T20:36:45.376Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/21/87e9b97b568e223f3438d93072479c2f36cc9b3f6b9f7094b9d50232acc0/coverage-7.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ac46d0c2dd5820ce93943a501ac5f6548ea81594777ca585bf002aa8854cacd", size = 211708, upload-time = "2025-03-30T20:35:47.417Z" }, - { url = "https://files.pythonhosted.org/packages/75/be/882d08b28a0d19c9c4c2e8a1c6ebe1f79c9c839eb46d4fca3bd3b34562b9/coverage-7.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:771eb7587a0563ca5bb6f622b9ed7f9d07bd08900f7589b4febff05f469bea00", size = 211981, upload-time = "2025-03-30T20:35:49.002Z" }, - { url = "https://files.pythonhosted.org/packages/7a/1d/ce99612ebd58082fbe3f8c66f6d8d5694976c76a0d474503fa70633ec77f/coverage-7.8.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42421e04069fb2cbcbca5a696c4050b84a43b05392679d4068acbe65449b5c64", size = 245495, upload-time = "2025-03-30T20:35:51.073Z" }, - { url = "https://files.pythonhosted.org/packages/dc/8d/6115abe97df98db6b2bd76aae395fcc941d039a7acd25f741312ced9a78f/coverage-7.8.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:554fec1199d93ab30adaa751db68acec2b41c5602ac944bb19187cb9a41a8067", size = 242538, upload-time = "2025-03-30T20:35:52.941Z" }, - { url = "https://files.pythonhosted.org/packages/cb/74/2f8cc196643b15bc096d60e073691dadb3dca48418f08bc78dd6e899383e/coverage-7.8.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5aaeb00761f985007b38cf463b1d160a14a22c34eb3f6a39d9ad6fc27cb73008", size = 244561, upload-time = "2025-03-30T20:35:54.658Z" }, - { url = "https://files.pythonhosted.org/packages/22/70/c10c77cd77970ac965734fe3419f2c98665f6e982744a9bfb0e749d298f4/coverage-7.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:581a40c7b94921fffd6457ffe532259813fc68eb2bdda60fa8cc343414ce3733", size = 244633, upload-time = "2025-03-30T20:35:56.221Z" }, - { url = "https://files.pythonhosted.org/packages/38/5a/4f7569d946a07c952688debee18c2bb9ab24f88027e3d71fd25dbc2f9dca/coverage-7.8.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f319bae0321bc838e205bf9e5bc28f0a3165f30c203b610f17ab5552cff90323", size = 242712, upload-time = "2025-03-30T20:35:57.801Z" }, - { url = "https://files.pythonhosted.org/packages/bb/a1/03a43b33f50475a632a91ea8c127f7e35e53786dbe6781c25f19fd5a65f8/coverage-7.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04bfec25a8ef1c5f41f5e7e5c842f6b615599ca8ba8391ec33a9290d9d2db3a3", size = 244000, upload-time = "2025-03-30T20:35:59.378Z" }, - { url = "https://files.pythonhosted.org/packages/6a/89/ab6c43b1788a3128e4d1b7b54214548dcad75a621f9d277b14d16a80d8a1/coverage-7.8.0-cp313-cp313-win32.whl", hash = "sha256:dd19608788b50eed889e13a5d71d832edc34fc9dfce606f66e8f9f917eef910d", size = 214195, upload-time = "2025-03-30T20:36:01.005Z" }, - { url = "https://files.pythonhosted.org/packages/12/12/6bf5f9a8b063d116bac536a7fb594fc35cb04981654cccb4bbfea5dcdfa0/coverage-7.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:a9abbccd778d98e9c7e85038e35e91e67f5b520776781d9a1e2ee9d400869487", size = 214998, upload-time = "2025-03-30T20:36:03.006Z" }, - { url = "https://files.pythonhosted.org/packages/2a/e6/1e9df74ef7a1c983a9c7443dac8aac37a46f1939ae3499424622e72a6f78/coverage-7.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:18c5ae6d061ad5b3e7eef4363fb27a0576012a7447af48be6c75b88494c6cf25", size = 212541, upload-time = "2025-03-30T20:36:04.638Z" }, - { url = "https://files.pythonhosted.org/packages/04/51/c32174edb7ee49744e2e81c4b1414ac9df3dacfcb5b5f273b7f285ad43f6/coverage-7.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:95aa6ae391a22bbbce1b77ddac846c98c5473de0372ba5c463480043a07bff42", size = 212767, upload-time = "2025-03-30T20:36:06.503Z" }, - { url = "https://files.pythonhosted.org/packages/e9/8f/f454cbdb5212f13f29d4a7983db69169f1937e869a5142bce983ded52162/coverage-7.8.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e013b07ba1c748dacc2a80e69a46286ff145935f260eb8c72df7185bf048f502", size = 256997, upload-time = "2025-03-30T20:36:08.137Z" }, - { url = "https://files.pythonhosted.org/packages/e6/74/2bf9e78b321216d6ee90a81e5c22f912fc428442c830c4077b4a071db66f/coverage-7.8.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d766a4f0e5aa1ba056ec3496243150698dc0481902e2b8559314368717be82b1", size = 252708, upload-time = "2025-03-30T20:36:09.781Z" }, - { url = "https://files.pythonhosted.org/packages/92/4d/50d7eb1e9a6062bee6e2f92e78b0998848a972e9afad349b6cdde6fa9e32/coverage-7.8.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad80e6b4a0c3cb6f10f29ae4c60e991f424e6b14219d46f1e7d442b938ee68a4", size = 255046, upload-time = "2025-03-30T20:36:11.409Z" }, - { url = "https://files.pythonhosted.org/packages/40/9e/71fb4e7402a07c4198ab44fc564d09d7d0ffca46a9fb7b0a7b929e7641bd/coverage-7.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b87eb6fc9e1bb8f98892a2458781348fa37e6925f35bb6ceb9d4afd54ba36c73", size = 256139, upload-time = "2025-03-30T20:36:13.86Z" }, - { url = "https://files.pythonhosted.org/packages/49/1a/78d37f7a42b5beff027e807c2843185961fdae7fe23aad5a4837c93f9d25/coverage-7.8.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:d1ba00ae33be84066cfbe7361d4e04dec78445b2b88bdb734d0d1cbab916025a", size = 254307, upload-time = "2025-03-30T20:36:16.074Z" }, - { url = "https://files.pythonhosted.org/packages/58/e9/8fb8e0ff6bef5e170ee19d59ca694f9001b2ec085dc99b4f65c128bb3f9a/coverage-7.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f3c38e4e5ccbdc9198aecc766cedbb134b2d89bf64533973678dfcf07effd883", size = 255116, upload-time = "2025-03-30T20:36:18.033Z" }, - { url = "https://files.pythonhosted.org/packages/56/b0/d968ecdbe6fe0a863de7169bbe9e8a476868959f3af24981f6a10d2b6924/coverage-7.8.0-cp313-cp313t-win32.whl", hash = "sha256:379fe315e206b14e21db5240f89dc0774bdd3e25c3c58c2c733c99eca96f1ada", size = 214909, upload-time = "2025-03-30T20:36:19.644Z" }, - { url = "https://files.pythonhosted.org/packages/87/e9/d6b7ef9fecf42dfb418d93544af47c940aa83056c49e6021a564aafbc91f/coverage-7.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2e4b6b87bb0c846a9315e3ab4be2d52fac905100565f4b92f02c445c8799e257", size = 216068, upload-time = "2025-03-30T20:36:21.282Z" }, - { url = "https://files.pythonhosted.org/packages/59/f1/4da7717f0063a222db253e7121bd6a56f6fb1ba439dcc36659088793347c/coverage-7.8.0-py3-none-any.whl", hash = "sha256:dbf364b4c5e7bae9250528167dfe40219b62e2d573c854d74be213e1e52069f7", size = 203435, upload-time = "2025-03-30T20:36:43.61Z" }, +version = "7.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/23/7f/d0720730a397a999ffc0fd3f5bebef347338e3a47b727da66fbb228e2ff2/coverage-7.14.0.tar.gz", hash = "sha256:057a6af2f160a85384cde4ab36f0d2777bae1057bae255f95413cdd382aa5c74", size = 919489, upload-time = "2026-05-10T18:02:31.397Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/18/b9a6586d73992807c26f9a5f274131be3d76b56b18a82b9392e2a25d2e45/coverage-7.14.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9aed9fa983514ca032790f3fe0d1c0e42ca7e16b42432af1706b50a9a46bef5d", size = 220036, upload-time = "2026-05-10T18:01:33.057Z" }, + { url = "https://files.pythonhosted.org/packages/f3/9b/4165a1d56ddc302a0e2d518fd9d412a4fd0b57562618c78c5f21c57194f5/coverage-7.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ba3b8390db29296dbbf49e91b6fe08f990743a90c8f447ba4c2ffc29670dfa63", size = 220368, upload-time = "2026-05-10T18:01:34.705Z" }, + { url = "https://files.pythonhosted.org/packages/69/aa/c12e52a5ba148d9995229d557e3be6e554fe469addc0e9241b2f0956d8ea/coverage-7.14.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3a5d8e876dfa2f102e970b183863d6dedd023d3c0eeca1fe7a9787bc5f28b212", size = 251417, upload-time = "2026-05-10T18:01:36.949Z" }, + { url = "https://files.pythonhosted.org/packages/d7/51/ec641c26e6dca1b25a7d2035ba6ecb7c884ef1a100a9e42fbe4ce4405139/coverage-7.14.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5ebb8f4614a3787d567e610bbfdf96a4798dd69a1afb1bd8ad228d4111fe6ff3", size = 253924, upload-time = "2026-05-10T18:01:38.985Z" }, + { url = "https://files.pythonhosted.org/packages/33/c4/59c3de0bd1b538824173fd518fed51c1ce740ca5ed68e74545983f4053a9/coverage-7.14.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b9bf47223dd8db3d4c4b2e443b02bace480d428f0822c3f991600448a176c97", size = 255269, upload-time = "2026-05-10T18:01:40.957Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a9/36dfa153a62040296f6e7febfdb20a5720622f6ef5a81a41e8237b9a5344/coverage-7.14.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3485a836550b303d006d57cc06e3d5afaabc642c77050b7c985a97b13e3776b8", size = 257583, upload-time = "2026-05-10T18:01:42.607Z" }, + { url = "https://files.pythonhosted.org/packages/26/7b/cc2c048d4114d9ab1c2409e9ee365e5ae10736df6dffcfc9444effa6c708/coverage-7.14.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e7e88110bae996d199d1693ca8ec3fd52441d426401ae963437598667b4c5eb", size = 251434, upload-time = "2026-05-10T18:01:44.537Z" }, + { url = "https://files.pythonhosted.org/packages/ee/df/6770eaa576e604575e9a78055313250faef5faa84bd6f71a39fece519c43/coverage-7.14.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:15228a6800ce7bdf1b74800595e56db7138cecb338fdbf044806e10dcf182dfe", size = 253280, upload-time = "2026-05-10T18:01:46.175Z" }, + { url = "https://files.pythonhosted.org/packages/ad/9e/1c0264514a3f98259a6d64765a397b2c8373e3ba59ee722a4802d3ec0c61/coverage-7.14.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9d26ac7f5398bafc5b57421ad994e8a4749e8a7a0e62d05ec7d53014d5963bfa", size = 251241, upload-time = "2026-05-10T18:01:48.732Z" }, + { url = "https://files.pythonhosted.org/packages/64/16/4efdf3e3c4079cdbf0ece56a2fea872df9e8a3e15a13a0af4400e1075944/coverage-7.14.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2fb73254ff43c911c967a899e1359bc5049b4b115d6e8fbdde4937d0a2246cd5", size = 255516, upload-time = "2026-05-10T18:01:50.819Z" }, + { url = "https://files.pythonhosted.org/packages/93/69/b1de96346603881b3d1bc8d6447c83200e1c9700ffbaff926ba01ff5724c/coverage-7.14.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:454a380af72c6adada298ed270d38c7a391288198dbfb8467f786f588751a90c", size = 251059, upload-time = "2026-05-10T18:01:52.773Z" }, + { url = "https://files.pythonhosted.org/packages/a4/66/2881853e0363a5e0a724d1103e53650795367471b6afb234f8b49e713bc6/coverage-7.14.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:65c86fb646d2bd2972e96bd1a8b45817ed907cee68655d6295fe7ec031d04cca", size = 252716, upload-time = "2026-05-10T18:01:54.506Z" }, + { url = "https://files.pythonhosted.org/packages/55/5c/0d3305d002c41dcde873dbe456491e663dc55152ca526b630b5c47efd62f/coverage-7.14.0-cp314-cp314-win32.whl", hash = "sha256:6a6516b02a6101398e19a3f44820f69bab2590697f7def4331f668b14adaf828", size = 222788, upload-time = "2026-05-10T18:01:56.487Z" }, + { url = "https://files.pythonhosted.org/packages/f9/58/6e1b8f52fdc3184b47dc5037f5070d83a3d11042db1594b02d2a44d786c8/coverage-7.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:45e0f79d8351fa76e256716df91eab12890d32678b9590df7ae1042e4bd4cf5d", size = 223600, upload-time = "2026-05-10T18:01:58.497Z" }, + { url = "https://files.pythonhosted.org/packages/00/70/a18c408e674bc26281cadaedc7351f929bd2094e191e4b15271c30b084cc/coverage-7.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:4b899594a8b2d81e5cc064a0d7f9cac2081fed91049456cae7676787e41549c9", size = 222168, upload-time = "2026-05-10T18:02:00.411Z" }, + { url = "https://files.pythonhosted.org/packages/3d/89/2681f071d238b62aff8dfc2ab44fc24cfdb38d1c01f391a80522ff5d3a16/coverage-7.14.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f580f8c80acd94ac72e863efe2cab791d8c38d153e0b463b92dfa000d5c84cd1", size = 220766, upload-time = "2026-05-10T18:02:02.313Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c7/c987babafd9207ffa1995e1ef1f9b26762cf4963aa768a66b6f0501e4616/coverage-7.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a2bd259c442cd43c49b30fbafc51776eb19ea396faf159d26a83e6a0a5f13b0c", size = 221035, upload-time = "2026-05-10T18:02:04.017Z" }, + { url = "https://files.pythonhosted.org/packages/5a/e9/d6a5ac3b333088143d6fc877d398a9a674dc03124a2f776e131f03864823/coverage-7.14.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a706b908dfa85538863504c624b237a3cc34232bf403c057414ebfdb3b4d9f84", size = 262405, upload-time = "2026-05-10T18:02:05.915Z" }, + { url = "https://files.pythonhosted.org/packages/38/b1/e70838d29a7c08e22d44398a46db90815bbcbf28de06992bd9210d1a8d8e/coverage-7.14.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7333cd944ee4393b9b3d3c1b598c936d4fc8d70573a4c7dacfec5590dd50e436", size = 264530, upload-time = "2026-05-10T18:02:07.582Z" }, + { url = "https://files.pythonhosted.org/packages/6b/73/5c31ef97763288d03d9995152b96d5475b527c63d91c84b01caea894b83a/coverage-7.14.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f162bc9a15b82d947b02651b0c7e1609d6f7a8735ca330cfadec8481dd97d5a", size = 266932, upload-time = "2026-05-10T18:02:09.401Z" }, + { url = "https://files.pythonhosted.org/packages/e1/76/dd56d80f29c5f05b4d76f7e7c6d47cafacae017189c75c5759d24f9ff0cc/coverage-7.14.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:362cb78e01a5dc82009d88004cf60f2e6b6d6fcbfdec05b05af73b0abf40118f", size = 268062, upload-time = "2026-05-10T18:02:11.399Z" }, + { url = "https://files.pythonhosted.org/packages/6e/c7/27ba85cd5b95614f159ff93ebff1901584a8d192e2e5e24c4943a7453f59/coverage-7.14.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:acebd068fca5512c3a6fde9c045f901613478781a73f0e82b307b214daef23fb", size = 261504, upload-time = "2026-05-10T18:02:13.257Z" }, + { url = "https://files.pythonhosted.org/packages/13/2e/e8149f60ab5d5684c6eee881bdf34b127115cddbb958b196768dd9d63473/coverage-7.14.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:29fe3da551dface75deb2ccbf87b6b66e2e7ef38f6d89050b428be94afff3490", size = 264398, upload-time = "2026-05-10T18:02:15.063Z" }, + { url = "https://files.pythonhosted.org/packages/d9/7f/1261b025285323225f4b4abffa5a643649dfd67e25ddca7ebcbdea3b7cb3/coverage-7.14.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b4cc4fce8672fffcb09b0eafc167b396b3ba53c4a7230f54b7aaffbf6c835fa9", size = 262000, upload-time = "2026-05-10T18:02:16.756Z" }, + { url = "https://files.pythonhosted.org/packages/d3/dc/829c54f60b9d08389439c00f813c752781c496fc5788c78d8006db4b4f2b/coverage-7.14.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5d4a51aad8ba8bdcd2b8bd8f03d4aca19693fa2327a3470e4718a25b03481020", size = 265732, upload-time = "2026-05-10T18:02:18.817Z" }, + { url = "https://files.pythonhosted.org/packages/ed/b0/70bd1419941652fa062689cba9c3eeafb8f5e6fbb890bce41c3bdda5dbd6/coverage-7.14.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9f323af3e1e4f68b60b7b247e37b8515563a61375518fa59de1af48ba28a3db6", size = 260847, upload-time = "2026-05-10T18:02:20.528Z" }, + { url = "https://files.pythonhosted.org/packages/f2/73/be40b2390656c654d35ea0015ea7ba3d945769cf80790ad5e0bb2d56d2ba/coverage-7.14.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1a0abc7342ea9711c469dd8b821c6c311e6bc6aac1442e5fbd6b27fae0a8f3db", size = 263166, upload-time = "2026-05-10T18:02:22.337Z" }, + { url = "https://files.pythonhosted.org/packages/29/55/4a643f712fcf7cf2881f8ec1e0ccb7b164aff3108f69b51801246c8799f2/coverage-7.14.0-cp314-cp314t-win32.whl", hash = "sha256:a9f864ef57b7172e2db87a096642dd51e179e085ab6b2c371c29e885f65c8fb2", size = 223573, upload-time = "2026-05-10T18:02:24.11Z" }, + { url = "https://files.pythonhosted.org/packages/27/96/3acae5da0953be042c0b4dea6d6789d2f080701c77b88e44d5bd41b9219b/coverage-7.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:29943e552fdc08e082eb51400fb2f58e118a83b5542bd06531214e084399b644", size = 224680, upload-time = "2026-05-10T18:02:25.896Z" }, + { url = "https://files.pythonhosted.org/packages/93/3d/6ab5d2dd8325d838737c6f8d83d62eb6230e0d70b87b51b57bbfd08fa767/coverage-7.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:742a73ea621953b012f2c4c2219b512180dd84489acf5b1596b0aafc55b9100b", size = 222703, upload-time = "2026-05-10T18:02:27.822Z" }, + { url = "https://files.pythonhosted.org/packages/61/e8/cb8e80d6f9f55b99588625062822bf946cf03ed06315df4bd8397f5632a1/coverage-7.14.0-py3-none-any.whl", hash = "sha256:8de5b61163aee3d05c8a2beab6f47913df7981dad1baf82c414d99158c286ab1", size = 211764, upload-time = "2026-05-10T18:02:29.538Z" }, ] [[package]] name = "cryptography" -version = "46.0.7" +version = "48.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" }, - { url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" }, - { url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" }, - { url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" }, - { url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" }, - { url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" }, - { url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" }, - { url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" }, - { url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" }, - { url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" }, - { url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" }, - { url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" }, - { url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" }, - { url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" }, - { url = "https://files.pythonhosted.org/packages/7b/56/15619b210e689c5403bb0540e4cb7dbf11a6bf42e483b7644e471a2812b3/cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", size = 7119671, upload-time = "2026-04-08T01:56:44Z" }, - { url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" }, - { url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" }, - { url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" }, - { url = "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", size = 4905845, upload-time = "2026-04-08T01:56:50.916Z" }, - { url = "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641, upload-time = "2026-04-08T01:56:52.882Z" }, - { url = "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749, upload-time = "2026-04-08T01:56:54.597Z" }, - { url = "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942, upload-time = "2026-04-08T01:56:56.416Z" }, - { url = "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", size = 4871079, upload-time = "2026-04-08T01:56:58.31Z" }, - { url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" }, - { url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" }, - { url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" }, - { url = "https://files.pythonhosted.org/packages/95/b6/3da51d48415bcb63b00dc17c2eff3a651b7c4fed484308d0f19b30e8cb2c/cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", size = 3002227, upload-time = "2026-04-08T01:57:06.91Z" }, - { url = "https://files.pythonhosted.org/packages/32/a8/9f0e4ed57ec9cebe506e58db11ae472972ecb0c659e4d52bbaee80ca340a/cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", size = 3475332, upload-time = "2026-04-08T01:57:08.807Z" }, - { url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" }, - { url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" }, - { url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" }, - { url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" }, - { url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" }, - { url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" }, - { url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" }, - { url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" }, - { url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" }, - { url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" }, - { url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" }, - { url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" }, - { url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" }, - { url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984, upload-time = "2026-05-04T22:59:38.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/3d/01f6dd9190170a5a241e0e98c2d04be3664a9e6f5b9b872cde63aff1c3dd/cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6", size = 8001587, upload-time = "2026-05-04T22:57:36.803Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6e/e90527eef33f309beb811cf7c982c3aeffcce8e3edb178baa4ca3ae4a6fa/cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", size = 4690433, upload-time = "2026-05-04T22:57:40.373Z" }, + { url = "https://files.pythonhosted.org/packages/90/04/673510ed51ddff56575f306cf1617d80411ee76831ccd3097599140efdfe/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", size = 4710620, upload-time = "2026-05-04T22:57:42.935Z" }, + { url = "https://files.pythonhosted.org/packages/14/d5/e9c4ef932c8d800490c34d8bd589d64a31d5890e27ec9e9ad532be893294/cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", size = 4696283, upload-time = "2026-05-04T22:57:45.294Z" }, + { url = "https://files.pythonhosted.org/packages/0c/29/174b9dfb60b12d59ecfc6cfa04bc88c21b42a54f01b8aae09bb6e51e4c7f/cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c", size = 5296573, upload-time = "2026-05-04T22:57:47.933Z" }, + { url = "https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", size = 4743677, upload-time = "2026-05-04T22:57:50.067Z" }, + { url = "https://files.pythonhosted.org/packages/30/be/eef653013d5c63b6a490529e0316f9ac14a37602965d4903efed1399f32b/cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", size = 4330808, upload-time = "2026-05-04T22:57:52.301Z" }, + { url = "https://files.pythonhosted.org/packages/84/9e/500463e87abb7a0a0f9f256ec21123ecde0a7b5541a15e840ea54551fd81/cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", size = 4695941, upload-time = "2026-05-04T22:57:54.603Z" }, + { url = "https://files.pythonhosted.org/packages/e3/dc/7303087450c2ec9e7fbb750e17c2abfbc658f23cbd0e54009509b7cc4091/cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c", size = 5252579, upload-time = "2026-05-04T22:57:57.207Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c0/7101d3b7215edcdc90c45da544961fd8ed2d6448f77577460fa75a8443f7/cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", size = 4743326, upload-time = "2026-05-04T22:57:59.535Z" }, + { url = "https://files.pythonhosted.org/packages/ac/d8/5b833bad13016f562ab9d063d68199a4bd121d18458e439515601d3357ec/cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", size = 4826672, upload-time = "2026-05-04T22:58:01.996Z" }, + { url = "https://files.pythonhosted.org/packages/98/e1/7074eb8bf3c135558c73fc2bcf0f5633f912e6fb87e868a55c454080ef09/cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", size = 4972574, upload-time = "2026-05-04T22:58:03.968Z" }, + { url = "https://files.pythonhosted.org/packages/04/70/e5a1b41d325f797f39427aa44ef8baf0be500065ab6d8e10369d850d4a4f/cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4", size = 3294868, upload-time = "2026-05-04T22:58:06.467Z" }, + { url = "https://files.pythonhosted.org/packages/f4/ac/8ac51b4a5fc5932eb7ee5c517ba7dc8cd834f0048962b6b352f00f41ebf9/cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7", size = 3817107, upload-time = "2026-05-04T22:58:08.845Z" }, + { url = "https://files.pythonhosted.org/packages/6b/84/70e3feea9feea87fd7cbe77efb2712ae1e3e6edf10749dc6e95f4e60e455/cryptography-48.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec", size = 7986556, upload-time = "2026-05-04T22:58:11.172Z" }, + { url = "https://files.pythonhosted.org/packages/89/6e/18e07a618bb5442ba10cf4df16e99c071365528aa570dfcb8c02e25a303b/cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18", size = 4684776, upload-time = "2026-05-04T22:58:13.712Z" }, + { url = "https://files.pythonhosted.org/packages/be/6a/4ea3b4c6c6759794d5ee2103c304a5076dc4b19ae1f9fe47dba439e159e9/cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20", size = 4698121, upload-time = "2026-05-04T22:58:16.448Z" }, + { url = "https://files.pythonhosted.org/packages/2f/59/6ff6ad6cae03bb887da2a5860b2c9805f8dac969ef01ce563336c49bd1d1/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff", size = 4690042, upload-time = "2026-05-04T22:58:18.544Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b4/fc334ed8cfd705aca282fe4d8f5ae64a8e0f74932e9feecb344610cf6e4d/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c", size = 5282526, upload-time = "2026-05-04T22:58:20.75Z" }, + { url = "https://files.pythonhosted.org/packages/11/08/9f8c5386cc4cd90d8255c7cdd0f5baf459a08502a09de30dc51f553d38dc/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db", size = 4733116, upload-time = "2026-05-04T22:58:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/b8/77/99307d7574045699f8805aa500fa0fb83422d115b5400a064ddd306d7750/cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741", size = 4316030, upload-time = "2026-05-04T22:58:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/fd/36/a608b98337af3cb2aff4818e406649d30572b7031918b04c87d979495348/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166", size = 4689640, upload-time = "2026-05-04T22:58:27.747Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a6/825010a291b4438aecc1f568bc428189fc1175515223632477c07dc0a6df/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336", size = 5237657, upload-time = "2026-05-04T22:58:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/b9/09/4e76a09b4caa29aad535ddc806f5d4c5d01885bd978bd984fbc6ca032cae/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057", size = 4732362, upload-time = "2026-05-04T22:58:32.009Z" }, + { url = "https://files.pythonhosted.org/packages/18/78/444fa04a77d0cb95f417dda20d450e13c56ba8e5220fc892a1658f44f882/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae", size = 4819580, upload-time = "2026-05-04T22:58:34.254Z" }, + { url = "https://files.pythonhosted.org/packages/38/85/ea67067c70a1fd4be2c63d35eeed82658023021affccc7b17705f8527dd2/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c", size = 4963283, upload-time = "2026-05-04T22:58:36.376Z" }, + { url = "https://files.pythonhosted.org/packages/75/54/cc6d0f3deac3e81c7f847e8a189a12b6cdd65059b43dad25d4316abd849a/cryptography-48.0.0-cp314-cp314t-win32.whl", hash = "sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f", size = 3270954, upload-time = "2026-05-04T22:58:38.791Z" }, + { url = "https://files.pythonhosted.org/packages/49/67/cc947e288c0758a4e5473d1dcb743037ab7785541265a969240b8885441a/cryptography-48.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12", size = 3797313, upload-time = "2026-05-04T22:58:40.746Z" }, + { url = "https://files.pythonhosted.org/packages/f2/63/61d4a4e1c6b6bab6ce1e213cd36a24c415d90e76d78c5eb8577c5541d2e8/cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86", size = 7983482, upload-time = "2026-05-04T22:58:43.769Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ac/f5b5995b87770c693e2596559ffafe195b4033a57f14a82268a2842953f3/cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", size = 4683266, upload-time = "2026-05-04T22:58:46.064Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c6/8b14f67e18338fbc4adb76f66c001f5c3610b3e2d1837f268f47a347dbbb/cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", size = 4696228, upload-time = "2026-05-04T22:58:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/ea/73/f808fbae9514bd91b47875b003f13e284c8c6bdfd904b7944e803937eec1/cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", size = 4689097, upload-time = "2026-05-04T22:58:50.9Z" }, + { url = "https://files.pythonhosted.org/packages/93/01/d86632d7d28db8ae83221995752eeb6639ffb374c2d22955648cf8d52797/cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832", size = 5283582, upload-time = "2026-05-04T22:58:53.017Z" }, + { url = "https://files.pythonhosted.org/packages/02/e1/50edc7a50334807cc4791fc4a0ce7468b4a1416d9138eab358bfc9a3d70b/cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", size = 4730479, upload-time = "2026-05-04T22:58:55.611Z" }, + { url = "https://files.pythonhosted.org/packages/6f/af/99a582b1b1641ff5911ac559beb45097cf79efd4ead4657f578ef1af2d47/cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", size = 4326481, upload-time = "2026-05-04T22:58:57.607Z" }, + { url = "https://files.pythonhosted.org/packages/90/ee/89aa26a06ef0a7d7611788ffd571a7c50e368cc6a4d5eef8b4884e866edb/cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", size = 4688713, upload-time = "2026-05-04T22:59:00.077Z" }, + { url = "https://files.pythonhosted.org/packages/70/ba/bcb1b0bb7a33d4c7c0c4d4c7874b4a62ae4f56113a5f4baefa362dfb1f0f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a", size = 5238165, upload-time = "2026-05-04T22:59:02.317Z" }, + { url = "https://files.pythonhosted.org/packages/c9/70/ca4003b1ce5ca3dc3186ada51908c8a9b9ff7d5cab83cc0d43ee14ec144f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", size = 4729947, upload-time = "2026-05-04T22:59:05.255Z" }, + { url = "https://files.pythonhosted.org/packages/44/a0/4ec7cf774207905aef1a8d11c3750d5a1db805eb380ee4e16df317870128/cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", size = 4822059, upload-time = "2026-05-04T22:59:07.802Z" }, + { url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" }, + { url = "https://files.pythonhosted.org/packages/b8/23/6e6f32143ab5d8b36ca848a502c4bcd477ae75b9e1677e3530d669062578/cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd", size = 3279117, upload-time = "2026-05-04T22:59:12.019Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" }, ] [[package]] @@ -276,35 +279,35 @@ wheels = [ [[package]] name = "filelock" -version = "3.25.2" +version = "3.29.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/b8/00651a0f559862f3bb7d6f7477b192afe3f583cc5e26403b44e59a55ab34/filelock-3.25.2.tar.gz", hash = "sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694", size = 40480, upload-time = "2026-03-11T20:45:38.487Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/fe/997687a931ab51049acce6fa1f23e8f01216374ea81374ddee763c493db5/filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90", size = 57571, upload-time = "2026-04-19T15:39:10.068Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70", size = 26759, upload-time = "2026-03-11T20:45:37.437Z" }, + { url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" }, ] [[package]] -name = "fire" -version = "0.7.1" +name = "flowr" +version = "1.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "termcolor" }, + { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c0/00/f8d10588d2019d6d6452653def1ee807353b21983db48550318424b5ff18/fire-0.7.1.tar.gz", hash = "sha256:3b208f05c736de98fb343310d090dcc4d8c78b2a89ea4f32b837c586270a9cbf", size = 88720, upload-time = "2025-08-16T20:20:24.175Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0c/73/22cf442d7dee23efb6b79a30d204c8b8dcef883a37293f495c506d129c8f/flowr-1.0.0.tar.gz", hash = "sha256:87e4672b41826d1f7e7f1c0c587427f1bb6b25cf462dc5889a0fb919e599f833", size = 27152, upload-time = "2026-05-06T15:45:05.704Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/4c/93d0f85318da65923e4b91c1c2ff03d8a458cbefebe3bc612a6693c7906d/fire-0.7.1-py3-none-any.whl", hash = "sha256:e43fd8a5033a9001e7e2973bab96070694b9f12f2e0ecf96d4683971b5ab1882", size = 115945, upload-time = "2025-08-16T20:20:22.87Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ea/620ffffa51516c809309d8ccc79dddef2b050f851f3a297214b901a9ffb4/flowr-1.0.0-py3-none-any.whl", hash = "sha256:4b1aac01198ee4a14cca6b55299481db955bc111f48a57c228a7f8649d920444", size = 27168, upload-time = "2026-05-06T15:45:04.088Z" }, ] [[package]] name = "gherkin-official" -version = "39.0.0" +version = "39.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2f/cf/8c0f7ec0e041c12ab59fae0c01b95ac69113a2fecb45618780525f8ca5ee/gherkin_official-39.0.0.tar.gz", hash = "sha256:675b9c6c0c342b0ec44bddf927de923adbd79879277816ce96bf248533677060", size = 33683, upload-time = "2026-03-01T16:46:42.382Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ab/73/511f4338d6f23f0c4d0f5ae0ffd942d891d73593d59ed2622abc749bf57e/gherkin_official-39.1.0.tar.gz", hash = "sha256:d5e98fa982411948b74aa72e4fe039dd51a07905dcd1e8bc58df47c8d5ae1800", size = 34009, upload-time = "2026-05-06T14:49:40.115Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/61/b3/743f97b16ef781283cde3c7b06a95b309a75ae2f4003a6611d35abc3c613/gherkin_official-39.0.0-py3-none-any.whl", hash = "sha256:1fd9b8709c00d946c0fd617a9834d4cb2af026213a2e8e7822fe24dd5064fe22", size = 38471, upload-time = "2026-03-01T16:46:43.308Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c3/080d581f02c868b1dfec88e7285a091d39d978f1d4f05126a586db0e6de2/gherkin_official-39.1.0-py3-none-any.whl", hash = "sha256:0e688b31bbc40561d950dc2b47cd3127fcc903f8cbf63d01fa45a0ec39fc72ab", size = 38845, upload-time = "2026-05-06T14:49:39.096Z" }, ] [[package]] @@ -358,32 +361,32 @@ wheels = [ [[package]] name = "hypothesis" -version = "6.151.12" +version = "6.152.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "sortedcontainers" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ce/ab/67ca321d1ab96fd3828b12142f1c258e2d4a668a025d06cd50ab3409787f/hypothesis-6.151.12.tar.gz", hash = "sha256:be485f503979af4c3dfa19e3fc2b967d0458e7f8c4e28128d7e215e0a55102e0", size = 463900, upload-time = "2026-04-08T19:40:06.205Z" } +sdist = { url = "https://files.pythonhosted.org/packages/91/dd/19d273652eb20dac15f32bbc484f2f6d51ccd8fe51fdb27da3f85f9017e8/hypothesis-6.152.7.tar.gz", hash = "sha256:741dedcede2ae0f32c32929a5992804b61f2b0400403b6a51a881a2b58482782", size = 468147, upload-time = "2026-05-13T04:19:34.124Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/5a/6cecf134b631050a1f8605096adbe812483b60790d951470989d39b56860/hypothesis-6.151.12-py3-none-any.whl", hash = "sha256:37d4f3a768365c30571b11dfd7a6857a12173d933010b2c4ab65619f1b5952c5", size = 529656, upload-time = "2026-04-08T19:40:03.126Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1e/8222edaee03c37350eaa726213614e343a62f1e56396dd000ad9277bfa3d/hypothesis-6.152.7-py3-none-any.whl", hash = "sha256:c0b17dd428fcb6e962f60315f6f4a77816c72fbb281ce9ba73699dabead5ec82", size = 533802, upload-time = "2026-05-13T04:19:30.635Z" }, ] [[package]] name = "idna" -version = "3.11" +version = "3.15" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, + { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, ] [[package]] name = "iniconfig" -version = "2.1.0" +version = "2.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] [[package]] @@ -407,16 +410,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, ] +[[package]] +name = "joserfc" +version = "1.6.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/dc/5f768c2e391e9afabe5d18e3221346deb5fb6338565f1ccc9e7c6d7befdd/joserfc-1.6.5.tar.gz", hash = "sha256:1482a7db78fb4602e44ed89e51b599d052e091288c7c532c5b694e20149dec48", size = 231881, upload-time = "2026-05-06T04:58:13.408Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/3b/ad1cb22e75c963b1f07c8a2329bf47227ce7e4361df5eb2fb101b2ce33ef/joserfc-1.6.5-py3-none-any.whl", hash = "sha256:e9878a0f8243fe7b95e11fdda81374ca9f7a689e302751579d3dfdeec559675e", size = 70464, upload-time = "2026-05-06T04:58:11.668Z" }, +] + [[package]] name = "markdown-it-py" -version = "4.0.0" +version = "4.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mdurl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, ] [[package]] @@ -430,30 +445,32 @@ wheels = [ [[package]] name = "markupsafe" -version = "3.0.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537, upload-time = "2024-10-18T15:21:54.129Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274, upload-time = "2024-10-18T15:21:24.577Z" }, - { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352, upload-time = "2024-10-18T15:21:25.382Z" }, - { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122, upload-time = "2024-10-18T15:21:26.199Z" }, - { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085, upload-time = "2024-10-18T15:21:27.029Z" }, - { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978, upload-time = "2024-10-18T15:21:27.846Z" }, - { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208, upload-time = "2024-10-18T15:21:28.744Z" }, - { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357, upload-time = "2024-10-18T15:21:29.545Z" }, - { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344, upload-time = "2024-10-18T15:21:30.366Z" }, - { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101, upload-time = "2024-10-18T15:21:31.207Z" }, - { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603, upload-time = "2024-10-18T15:21:32.032Z" }, - { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510, upload-time = "2024-10-18T15:21:33.625Z" }, - { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486, upload-time = "2024-10-18T15:21:34.611Z" }, - { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480, upload-time = "2024-10-18T15:21:35.398Z" }, - { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914, upload-time = "2024-10-18T15:21:36.231Z" }, - { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796, upload-time = "2024-10-18T15:21:37.073Z" }, - { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473, upload-time = "2024-10-18T15:21:37.932Z" }, - { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114, upload-time = "2024-10-18T15:21:39.799Z" }, - { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098, upload-time = "2024-10-18T15:21:40.813Z" }, - { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208, upload-time = "2024-10-18T15:21:41.814Z" }, - { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload-time = "2024-10-18T15:21:42.784Z" }, +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] [[package]] @@ -509,11 +526,11 @@ wheels = [ [[package]] name = "packaging" -version = "24.2" +version = "26.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950, upload-time = "2024-11-08T09:47:47.202Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451, upload-time = "2024-11-08T09:47:44.722Z" }, + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] [[package]] @@ -533,11 +550,11 @@ wheels = [ [[package]] name = "pluggy" -version = "1.5.0" +version = "1.6.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955, upload-time = "2024-04-20T21:34:42.531Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556, upload-time = "2024-04-20T21:34:40.434Z" }, + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] [[package]] @@ -566,7 +583,7 @@ wheels = [ [[package]] name = "pydantic" -version = "2.12.5" +version = "2.13.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, @@ -574,62 +591,50 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, ] [[package]] name = "pydantic-core" -version = "2.41.5" +version = "2.46.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, - { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, - { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, - { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, - { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, - { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, - { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, - { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, - { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, - { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, - { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, - { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, - { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, - { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, - { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, - { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, - { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, - { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, - { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, - { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, - { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, - { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, - { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, - { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, - { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, - { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, - { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, - { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, - { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, - { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, - { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, - { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, - { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, - { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, - { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, - { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, - { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, - { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, - { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, - { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, - { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, - { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, ] [[package]] @@ -643,15 +648,15 @@ wheels = [ [[package]] name = "pyright" -version = "1.1.408" +version = "1.1.409" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "nodeenv" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/74/b2/5db700e52554b8f025faa9c3c624c59f1f6c8841ba81ab97641b54322f16/pyright-1.1.408.tar.gz", hash = "sha256:f28f2321f96852fa50b5829ea492f6adb0e6954568d1caa3f3af3a5f555eb684", size = 4400578, upload-time = "2026-01-08T08:07:38.795Z" } +sdist = { url = "https://files.pythonhosted.org/packages/51/4e/3aa27f74211522dba7e9cbc3e74de779c6d4b654c54e50a4840623be8014/pyright-1.1.409.tar.gz", hash = "sha256:986ee05beca9e077c165758ad123667c679e050059a2546aa02473930394bc93", size = 4430434, upload-time = "2026-04-23T11:02:03.799Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/82/a2c93e32800940d9573fb28c346772a14778b84ba7524e691b324620ab89/pyright-1.1.408-py3-none-any.whl", hash = "sha256:090b32865f4fdb1e0e6cd82bf5618480d48eecd2eb2e70f960982a3d9a4c17c1", size = 6399144, upload-time = "2026-01-08T08:07:37.082Z" }, + { url = "https://files.pythonhosted.org/packages/16/6b/330d8ebae582b30c2959a1ef4c3bc344ebde48c2ff0c3f113c4710735e11/pyright-1.1.409-py3-none-any.whl", hash = "sha256:aa3ea228cab90c845c7a60d28db7a844c04315356392aa09fafcee98c8c22fb3", size = 6438161, upload-time = "2026-04-23T11:02:01.309Z" }, ] [[package]] @@ -672,11 +677,10 @@ wheels = [ [[package]] name = "pytest-beehave" -version = "0.1.20260421" +version = "0.2.0" source = { editable = "." } dependencies = [ - { name = "fire" }, - { name = "gherkin-official" }, + { name = "beehave" }, ] [package.optional-dependencies] @@ -698,14 +702,15 @@ html = [ [package.dev-dependencies] dev = [ + { name = "agents-smith" }, + { name = "flowr" }, { name = "gherkin-official" }, { name = "safety" }, ] [package.metadata] requires-dist = [ - { name = "fire", specifier = ">=0.7.1" }, - { name = "gherkin-official", specifier = ">=39.0.0" }, + { name = "beehave", specifier = ">=0.4.0" }, { name = "ghp-import", marker = "extra == 'dev'", specifier = ">=2.1.0" }, { name = "hypothesis", marker = "extra == 'dev'", specifier = ">=6.148.4" }, { name = "pdoc", marker = "extra == 'dev'", specifier = ">=14.0" }, @@ -722,35 +727,38 @@ provides-extras = ["html", "dev"] [package.metadata.requires-dev] dev = [ + { name = "agents-smith", specifier = ">=1.0.0" }, + { name = "flowr", specifier = ">=1.0.0" }, { name = "gherkin-official", specifier = ">=39.0.0" }, { name = "safety", specifier = ">=3.7.0" }, ] [[package]] name = "pytest-cov" -version = "6.1.1" +version = "7.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "coverage" }, + { name = "pluggy" }, { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/25/69/5f1e57f6c5a39f81411b550027bf72842c4567ff5fd572bed1edc9e4b5d9/pytest_cov-6.1.1.tar.gz", hash = "sha256:46935f7aaefba760e716c2ebfbe1c216240b9592966e7da99ea8292d4d3e2a0a", size = 66857, upload-time = "2025-04-05T14:07:51.592Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/28/d0/def53b4a790cfb21483016430ed828f64830dd981ebe1089971cd10cab25/pytest_cov-6.1.1-py3-none-any.whl", hash = "sha256:bddf29ed2d0ab6f4df17b4c55b0a657287db8684af9c42ea546b21b1041b3dde", size = 23841, upload-time = "2025-04-05T14:07:49.641Z" }, + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] [[package]] name = "pytest-html" -version = "4.1.1" +version = "4.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jinja2" }, { name = "pytest" }, { name = "pytest-metadata" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bb/ab/4862dcb5a8a514bd87747e06b8d55483c0c9e987e1b66972336946e49b49/pytest_html-4.1.1.tar.gz", hash = "sha256:70a01e8ae5800f4a074b56a4cb1025c8f4f9b038bba5fe31e3c98eb996686f07", size = 150773, upload-time = "2023-11-07T15:44:28.975Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/08/2076aa09507e51c1119d16a84c6307354d16270558f1a44fc9a2c99fdf1d/pytest_html-4.2.0.tar.gz", hash = "sha256:b6a88cba507500d8709959201e2e757d3941e859fd17cfd4ed87b16fc0c67912", size = 108634, upload-time = "2026-01-19T11:25:26.471Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/c7/c160021cbecd956cc1a6f79e5fe155f7868b2e5b848f1320dad0b3e3122f/pytest_html-4.1.1-py3-none-any.whl", hash = "sha256:c8152cea03bd4e9bee6d525573b67bbc6622967b72b9628dda0ea3e2a0b5dd71", size = 23491, upload-time = "2023-11-07T15:44:27.149Z" }, + { url = "https://files.pythonhosted.org/packages/84/47/07046e0acedc12fe2bae79cf6c73ad67f51ae9d67df64d06b0f3eac73d36/pytest_html-4.2.0-py3-none-any.whl", hash = "sha256:ff5caf3e17a974008e5816edda61168e6c3da442b078a44f8744865862a85636", size = 23801, upload-time = "2026-01-19T11:25:25.008Z" }, ] [[package]] @@ -789,81 +797,75 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + [[package]] name = "regex" -version = "2026.4.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cb/0e/3a246dbf05666918bd3664d9d787f84a9108f6f43cc953a077e4a7dfdb7e/regex-2026.4.4.tar.gz", hash = "sha256:e08270659717f6973523ce3afbafa53515c4dc5dcad637dc215b6fd50f689423", size = 416000, upload-time = "2026-04-03T20:56:28.155Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9d/83/c4373bc5f31f2cf4b66f9b7c31005bd87fe66f0dce17701f7db4ee79ee29/regex-2026.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:62f5519042c101762509b1d717b45a69c0139d60414b3c604b81328c01bd1943", size = 490273, upload-time = "2026-04-03T20:54:11.202Z" }, - { url = "https://files.pythonhosted.org/packages/46/f8/fe62afbcc3cf4ad4ac9adeaafd98aa747869ae12d3e8e2ac293d0593c435/regex-2026.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3790ba9fb5dd76715a7afe34dbe603ba03f8820764b1dc929dd08106214ed031", size = 291954, upload-time = "2026-04-03T20:54:13.412Z" }, - { url = "https://files.pythonhosted.org/packages/5a/92/4712b9fe6a33d232eeb1c189484b80c6c4b8422b90e766e1195d6e758207/regex-2026.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8fae3c6e795d7678963f2170152b0d892cf6aee9ee8afc8c45e6be38d5107fe7", size = 289487, upload-time = "2026-04-03T20:54:15.824Z" }, - { url = "https://files.pythonhosted.org/packages/88/2c/f83b93f85e01168f1070f045a42d4c937b69fdb8dd7ae82d307253f7e36e/regex-2026.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:298c3ec2d53225b3bf91142eb9691025bab610e0c0c51592dde149db679b3d17", size = 796646, upload-time = "2026-04-03T20:54:18.229Z" }, - { url = "https://files.pythonhosted.org/packages/df/55/61a2e17bf0c4dc57e11caf8dd11771280d8aaa361785f9e3bc40d653f4a7/regex-2026.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e9638791082eaf5b3ac112c587518ee78e083a11c4b28012d8fe2a0f536dfb17", size = 865904, upload-time = "2026-04-03T20:54:20.019Z" }, - { url = "https://files.pythonhosted.org/packages/45/32/1ac8ed1b5a346b5993a3d256abe0a0f03b0b73c8cc88d928537368ac65b6/regex-2026.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae3e764bd4c5ff55035dc82a8d49acceb42a5298edf6eb2fc4d328ee5dd7afae", size = 912304, upload-time = "2026-04-03T20:54:22.403Z" }, - { url = "https://files.pythonhosted.org/packages/26/47/2ee5c613ab546f0eddebf9905d23e07beb933416b1246c2d8791d01979b4/regex-2026.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ffa81f81b80047ba89a3c69ae6a0f78d06f4a42ce5126b0eb2a0a10ad44e0b2e", size = 801126, upload-time = "2026-04-03T20:54:24.308Z" }, - { url = "https://files.pythonhosted.org/packages/75/cd/41dacd129ca9fd20bd7d02f83e0fad83e034ac8a084ec369c90f55ef37e2/regex-2026.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f56ebf9d70305307a707911b88469213630aba821e77de7d603f9d2f0730687d", size = 776772, upload-time = "2026-04-03T20:54:26.319Z" }, - { url = "https://files.pythonhosted.org/packages/89/6d/5af0b588174cb5f46041fa7dd64d3fd5cd2fe51f18766703d1edc387f324/regex-2026.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:773d1dfd652bbffb09336abf890bfd64785c7463716bf766d0eb3bc19c8b7f27", size = 785228, upload-time = "2026-04-03T20:54:28.387Z" }, - { url = "https://files.pythonhosted.org/packages/b7/3b/f5a72b7045bd59575fc33bf1345f156fcfd5a8484aea6ad84b12c5a82114/regex-2026.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d51d20befd5275d092cdffba57ded05f3c436317ee56466c8928ac32d960edaf", size = 860032, upload-time = "2026-04-03T20:54:30.641Z" }, - { url = "https://files.pythonhosted.org/packages/39/a4/72a317003d6fcd7a573584a85f59f525dfe8f67e355ca74eb6b53d66a5e2/regex-2026.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:0a51cdb3c1e9161154f976cb2bef9894bc063ac82f31b733087ffb8e880137d0", size = 765714, upload-time = "2026-04-03T20:54:32.789Z" }, - { url = "https://files.pythonhosted.org/packages/25/1e/5672e16f34dbbcb2560cc7e6a2fbb26dfa8b270711e730101da4423d3973/regex-2026.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ae5266a82596114e41fb5302140e9630204c1b5f325c770bec654b95dd54b0aa", size = 852078, upload-time = "2026-04-03T20:54:34.546Z" }, - { url = "https://files.pythonhosted.org/packages/f7/0d/c813f0af7c6cc7ed7b9558bac2e5120b60ad0fa48f813e4d4bd55446f214/regex-2026.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c882cd92ec68585e9c1cf36c447ec846c0d94edd706fe59e0c198e65822fd23b", size = 789181, upload-time = "2026-04-03T20:54:36.642Z" }, - { url = "https://files.pythonhosted.org/packages/ea/6d/a344608d1adbd2a95090ddd906cec09a11be0e6517e878d02a5123e0917f/regex-2026.4.4-cp313-cp313-win32.whl", hash = "sha256:05568c4fbf3cb4fa9e28e3af198c40d3237cf6041608a9022285fe567ec3ad62", size = 266690, upload-time = "2026-04-03T20:54:38.343Z" }, - { url = "https://files.pythonhosted.org/packages/31/07/54049f89b46235ca6f45cd6c88668a7050e77d4a15555e47dd40fde75263/regex-2026.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:3384df51ed52db0bea967e21458ab0a414f67cdddfd94401688274e55147bb81", size = 277733, upload-time = "2026-04-03T20:54:40.11Z" }, - { url = "https://files.pythonhosted.org/packages/0e/21/61366a8e20f4d43fb597708cac7f0e2baadb491ecc9549b4980b2be27d16/regex-2026.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:acd38177bd2c8e69a411d6521760806042e244d0ef94e2dd03ecdaa8a3c99427", size = 270565, upload-time = "2026-04-03T20:54:41.883Z" }, - { url = "https://files.pythonhosted.org/packages/f1/1e/3a2b9672433bef02f5d39aa1143ca2c08f311c1d041c464a42be9ae648dc/regex-2026.4.4-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:f94a11a9d05afcfcfa640e096319720a19cc0c9f7768e1a61fceee6a3afc6c7c", size = 494126, upload-time = "2026-04-03T20:54:43.602Z" }, - { url = "https://files.pythonhosted.org/packages/4e/4b/c132a4f4fe18ad3340d89fcb56235132b69559136036b845be3c073142ed/regex-2026.4.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:36bcb9d6d1307ab629edc553775baada2aefa5c50ccc0215fbfd2afcfff43141", size = 293882, upload-time = "2026-04-03T20:54:45.41Z" }, - { url = "https://files.pythonhosted.org/packages/f4/5f/eaa38092ce7a023656280f2341dbbd4ad5f05d780a70abba7bb4f4bea54c/regex-2026.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:261c015b3e2ed0919157046d768774ecde57f03d8fa4ba78d29793447f70e717", size = 292334, upload-time = "2026-04-03T20:54:47.051Z" }, - { url = "https://files.pythonhosted.org/packages/5f/f6/dd38146af1392dac33db7074ab331cec23cced3759167735c42c5460a243/regex-2026.4.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c228cf65b4a54583763645dcd73819b3b381ca8b4bb1b349dee1c135f4112c07", size = 811691, upload-time = "2026-04-03T20:54:49.074Z" }, - { url = "https://files.pythonhosted.org/packages/7a/f0/dc54c2e69f5eeec50601054998ec3690d5344277e782bd717e49867c1d29/regex-2026.4.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dd2630faeb6876fb0c287f664d93ddce4d50cd46c6e88e60378c05c9047e08ca", size = 871227, upload-time = "2026-04-03T20:54:51.035Z" }, - { url = "https://files.pythonhosted.org/packages/a1/af/cb16bd5dc61621e27df919a4449bbb7e5a1034c34d307e0a706e9cc0f3e3/regex-2026.4.4-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6a50ab11b7779b849472337191f3a043e27e17f71555f98d0092fa6d73364520", size = 917435, upload-time = "2026-04-03T20:54:52.994Z" }, - { url = "https://files.pythonhosted.org/packages/5c/71/8b260897f22996b666edd9402861668f45a2ca259f665ac029e6104a2d7d/regex-2026.4.4-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0734f63afe785138549fbe822a8cfeaccd1bae814c5057cc0ed5b9f2de4fc883", size = 816358, upload-time = "2026-04-03T20:54:54.884Z" }, - { url = "https://files.pythonhosted.org/packages/1c/60/775f7f72a510ef238254906c2f3d737fc80b16ca85f07d20e318d2eea894/regex-2026.4.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4ee50606cb1967db7e523224e05f32089101945f859928e65657a2cbb3d278b", size = 785549, upload-time = "2026-04-03T20:54:57.01Z" }, - { url = "https://files.pythonhosted.org/packages/58/42/34d289b3627c03cf381e44da534a0021664188fa49ba41513da0b4ec6776/regex-2026.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6c1818f37be3ca02dcb76d63f2c7aaba4b0dc171b579796c6fbe00148dfec6b1", size = 801364, upload-time = "2026-04-03T20:54:58.981Z" }, - { url = "https://files.pythonhosted.org/packages/fc/20/f6ecf319b382a8f1ab529e898b222c3f30600fcede7834733c26279e7465/regex-2026.4.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:f5bfc2741d150d0be3e4a0401a5c22b06e60acb9aa4daa46d9e79a6dcd0f135b", size = 866221, upload-time = "2026-04-03T20:55:00.88Z" }, - { url = "https://files.pythonhosted.org/packages/92/6a/9f16d3609d549bd96d7a0b2aee1625d7512ba6a03efc01652149ef88e74d/regex-2026.4.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:504ffa8a03609a087cad81277a629b6ce884b51a24bd388a7980ad61748618ff", size = 772530, upload-time = "2026-04-03T20:55:03.213Z" }, - { url = "https://files.pythonhosted.org/packages/fa/f6/aa9768bc96a4c361ac96419fbaf2dcdc33970bb813df3ba9b09d5d7b6d96/regex-2026.4.4-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:70aadc6ff12e4b444586e57fc30771f86253f9f0045b29016b9605b4be5f7dfb", size = 856989, upload-time = "2026-04-03T20:55:05.087Z" }, - { url = "https://files.pythonhosted.org/packages/4d/b4/c671db3556be2473ae3e4bb7a297c518d281452871501221251ea4ecba57/regex-2026.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f4f83781191007b6ef43b03debc35435f10cad9b96e16d147efe84a1d48bdde4", size = 803241, upload-time = "2026-04-03T20:55:07.162Z" }, - { url = "https://files.pythonhosted.org/packages/2a/5c/83e3b1d89fa4f6e5a1bc97b4abd4a9a97b3c1ac7854164f694f5f0ba98a0/regex-2026.4.4-cp313-cp313t-win32.whl", hash = "sha256:e014a797de43d1847df957c0a2a8e861d1c17547ee08467d1db2c370b7568baa", size = 269921, upload-time = "2026-04-03T20:55:09.62Z" }, - { url = "https://files.pythonhosted.org/packages/28/07/077c387121f42cdb4d92b1301133c0d93b5709d096d1669ab847dda9fe2e/regex-2026.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:b15b88b0d52b179712632832c1d6e58e5774f93717849a41096880442da41ab0", size = 281240, upload-time = "2026-04-03T20:55:11.521Z" }, - { url = "https://files.pythonhosted.org/packages/9d/22/ead4a4abc7c59a4d882662aa292ca02c8b617f30b6e163bc1728879e9353/regex-2026.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:586b89cdadf7d67bf86ae3342a4dcd2b8d70a832d90c18a0ae955105caf34dbe", size = 272440, upload-time = "2026-04-03T20:55:13.365Z" }, - { url = "https://files.pythonhosted.org/packages/f0/f5/ed97c2dc47b5fbd4b73c0d7d75f9ebc8eca139f2bbef476bba35f28c0a77/regex-2026.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:2da82d643fa698e5e5210e54af90181603d5853cf469f5eedf9bfc8f59b4b8c7", size = 490343, upload-time = "2026-04-03T20:55:15.241Z" }, - { url = "https://files.pythonhosted.org/packages/80/e9/de4828a7385ec166d673a5790ad06ac48cdaa98bc0960108dd4b9cc1aef7/regex-2026.4.4-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:54a1189ad9d9357760557c91103d5e421f0a2dabe68a5cdf9103d0dcf4e00752", size = 291909, upload-time = "2026-04-03T20:55:17.558Z" }, - { url = "https://files.pythonhosted.org/packages/b4/d6/5cfbfc97f3201a4d24b596a77957e092030dcc4205894bc035cedcfce62f/regex-2026.4.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:76d67d5afb1fe402d10a6403bae668d000441e2ab115191a804287d53b772951", size = 289692, upload-time = "2026-04-03T20:55:20.561Z" }, - { url = "https://files.pythonhosted.org/packages/8e/ac/f2212d9fd56fe897e36d0110ba30ba2d247bd6410c5bd98499c7e5a1e1f2/regex-2026.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e7cd3e4ee8d80447a83bbc9ab0c8459781fa77087f856c3e740d7763be0df27f", size = 796979, upload-time = "2026-04-03T20:55:22.56Z" }, - { url = "https://files.pythonhosted.org/packages/c9/e3/a016c12675fbac988a60c7e1c16e67823ff0bc016beb27bd7a001dbdabc6/regex-2026.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e19e18c568d2866d8b6a6dfad823db86193503f90823a8f66689315ba28fbe8", size = 866744, upload-time = "2026-04-03T20:55:24.646Z" }, - { url = "https://files.pythonhosted.org/packages/af/a4/0b90ca4cf17adc3cb43de80ec71018c37c88ad64987e8d0d481a95ca60b5/regex-2026.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7698a6f38730fd1385d390d1ed07bb13dce39aa616aca6a6d89bea178464b9a4", size = 911613, upload-time = "2026-04-03T20:55:27.033Z" }, - { url = "https://files.pythonhosted.org/packages/8e/3b/2b3dac0b82d41ab43aa87c6ecde63d71189d03fe8854b8ca455a315edac3/regex-2026.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:173a66f3651cdb761018078e2d9487f4cf971232c990035ec0eb1cdc6bf929a9", size = 800551, upload-time = "2026-04-03T20:55:29.532Z" }, - { url = "https://files.pythonhosted.org/packages/25/fe/5365eb7aa0e753c4b5957815c321519ecab033c279c60e1b1ae2367fa810/regex-2026.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa7922bbb2cc84fa062d37723f199d4c0cd200245ce269c05db82d904db66b83", size = 776911, upload-time = "2026-04-03T20:55:31.526Z" }, - { url = "https://files.pythonhosted.org/packages/aa/b3/7fb0072156bba065e3b778a7bc7b0a6328212be5dd6a86fd207e0c4f2dab/regex-2026.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:59f67cd0a0acaf0e564c20bbd7f767286f23e91e2572c5703bf3e56ea7557edb", size = 785751, upload-time = "2026-04-03T20:55:33.797Z" }, - { url = "https://files.pythonhosted.org/packages/02/1a/9f83677eb699273e56e858f7bd95acdbee376d42f59e8bfca2fd80d79df3/regex-2026.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:475e50f3f73f73614f7cba5524d6de49dee269df00272a1b85e3d19f6d498465", size = 860484, upload-time = "2026-04-03T20:55:35.745Z" }, - { url = "https://files.pythonhosted.org/packages/3b/7a/93937507b61cfcff8b4c5857f1b452852b09f741daa9acae15c971d8554e/regex-2026.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:a1c0c7d67b64d85ac2e1879923bad2f08a08f3004055f2f406ef73c850114bd4", size = 765939, upload-time = "2026-04-03T20:55:37.972Z" }, - { url = "https://files.pythonhosted.org/packages/86/ea/81a7f968a351c6552b1670ead861e2a385be730ee28402233020c67f9e0f/regex-2026.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:1371c2ccbb744d66ee63631cc9ca12aa233d5749972626b68fe1a649dd98e566", size = 851417, upload-time = "2026-04-03T20:55:39.92Z" }, - { url = "https://files.pythonhosted.org/packages/4c/7e/323c18ce4b5b8f44517a36342961a0306e931e499febbd876bb149d900f0/regex-2026.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:59968142787042db793348a3f5b918cf24ced1f23247328530e063f89c128a95", size = 789056, upload-time = "2026-04-03T20:55:42.303Z" }, - { url = "https://files.pythonhosted.org/packages/c0/af/e7510f9b11b1913b0cd44eddb784b2d650b2af6515bfce4cffcc5bfd1d38/regex-2026.4.4-cp314-cp314-win32.whl", hash = "sha256:59efe72d37fd5a91e373e5146f187f921f365f4abc1249a5ab446a60f30dd5f8", size = 272130, upload-time = "2026-04-03T20:55:44.995Z" }, - { url = "https://files.pythonhosted.org/packages/9a/51/57dae534c915e2d3a21490e88836fa2ae79dde3b66255ecc0c0a155d2c10/regex-2026.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:e0aab3ff447845049d676827d2ff714aab4f73f340e155b7de7458cf53baa5a4", size = 280992, upload-time = "2026-04-03T20:55:47.316Z" }, - { url = "https://files.pythonhosted.org/packages/0a/5e/abaf9f4c3792e34edb1434f06717fae2b07888d85cb5cec29f9204931bf8/regex-2026.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:a7a5bb6aa0cf62208bb4fa079b0c756734f8ad0e333b425732e8609bd51ee22f", size = 273563, upload-time = "2026-04-03T20:55:49.273Z" }, - { url = "https://files.pythonhosted.org/packages/ff/06/35da85f9f217b9538b99cbb170738993bcc3b23784322decb77619f11502/regex-2026.4.4-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:97850d0638391bdc7d35dc1c1039974dcb921eaafa8cc935ae4d7f272b1d60b3", size = 494191, upload-time = "2026-04-03T20:55:51.258Z" }, - { url = "https://files.pythonhosted.org/packages/54/5b/1bc35f479eef8285c4baf88d8c002023efdeebb7b44a8735b36195486ae7/regex-2026.4.4-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:ee7337f88f2a580679f7bbfe69dc86c043954f9f9c541012f49abc554a962f2e", size = 293877, upload-time = "2026-04-03T20:55:53.214Z" }, - { url = "https://files.pythonhosted.org/packages/39/5b/f53b9ad17480b3ddd14c90da04bfb55ac6894b129e5dea87bcaf7d00e336/regex-2026.4.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7429f4e6192c11d659900c0648ba8776243bf396ab95558b8c51a345afeddde6", size = 292410, upload-time = "2026-04-03T20:55:55.736Z" }, - { url = "https://files.pythonhosted.org/packages/bb/56/52377f59f60a7c51aa4161eecf0b6032c20b461805aca051250da435ffc9/regex-2026.4.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4f10fbd5dd13dcf4265b4cc07d69ca70280742870c97ae10093e3d66000359", size = 811831, upload-time = "2026-04-03T20:55:57.802Z" }, - { url = "https://files.pythonhosted.org/packages/dd/63/8026310bf066f702a9c361f83a8c9658f3fe4edb349f9c1e5d5273b7c40c/regex-2026.4.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a152560af4f9742b96f3827090f866eeec5becd4765c8e0d3473d9d280e76a5a", size = 871199, upload-time = "2026-04-03T20:56:00.333Z" }, - { url = "https://files.pythonhosted.org/packages/20/9f/a514bbb00a466dbb506d43f187a04047f7be1505f10a9a15615ead5080ee/regex-2026.4.4-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54170b3e95339f415d54651f97df3bff7434a663912f9358237941bbf9143f55", size = 917649, upload-time = "2026-04-03T20:56:02.445Z" }, - { url = "https://files.pythonhosted.org/packages/cb/6b/8399f68dd41a2030218839b9b18360d79b86d22b9fab5ef477c7f23ca67c/regex-2026.4.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:07f190d65f5a72dcb9cf7106bfc3d21e7a49dd2879eda2207b683f32165e4d99", size = 816388, upload-time = "2026-04-03T20:56:04.595Z" }, - { url = "https://files.pythonhosted.org/packages/1e/9c/103963f47c24339a483b05edd568594c2be486188f688c0170fd504b2948/regex-2026.4.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9a2741ce5a29d3c84b0b94261ba630ab459a1b847a0d6beca7d62d188175c790", size = 785746, upload-time = "2026-04-03T20:56:07.13Z" }, - { url = "https://files.pythonhosted.org/packages/fa/ee/7f6054c0dec0cee3463c304405e4ff42e27cff05bf36fcb34be549ab17bd/regex-2026.4.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b26c30df3a28fd9793113dac7385a4deb7294a06c0f760dd2b008bd49a9139bc", size = 801483, upload-time = "2026-04-03T20:56:09.365Z" }, - { url = "https://files.pythonhosted.org/packages/30/c2/51d3d941cf6070dc00c3338ecf138615fc3cce0421c3df6abe97a08af61a/regex-2026.4.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:421439d1bee44b19f4583ccf42670ca464ffb90e9fdc38d37f39d1ddd1e44f1f", size = 866331, upload-time = "2026-04-03T20:56:12.039Z" }, - { url = "https://files.pythonhosted.org/packages/16/e8/76d50dcc122ac33927d939f350eebcfe3dbcbda96913e03433fc36de5e63/regex-2026.4.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b40379b53ecbc747fd9bdf4a0ea14eb8188ca1bd0f54f78893a39024b28f4863", size = 772673, upload-time = "2026-04-03T20:56:14.558Z" }, - { url = "https://files.pythonhosted.org/packages/a5/6e/5f6bf75e20ea6873d05ba4ec78378c375cbe08cdec571c83fbb01606e563/regex-2026.4.4-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:08c55c13d2eef54f73eeadc33146fb0baaa49e7335eb1aff6ae1324bf0ddbe4a", size = 857146, upload-time = "2026-04-03T20:56:16.663Z" }, - { url = "https://files.pythonhosted.org/packages/0b/33/3c76d9962949e487ebba353a18e89399f292287204ac8f2f4cfc3a51c233/regex-2026.4.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9776b85f510062f5a75ef112afe5f494ef1635607bf1cc220c1391e9ac2f5e81", size = 803463, upload-time = "2026-04-03T20:56:18.923Z" }, - { url = "https://files.pythonhosted.org/packages/19/eb/ef32dcd2cb69b69bc0c3e55205bce94a7def48d495358946bc42186dcccc/regex-2026.4.4-cp314-cp314t-win32.whl", hash = "sha256:385edaebde5db5be103577afc8699fea73a0e36a734ba24870be7ffa61119d74", size = 275709, upload-time = "2026-04-03T20:56:20.996Z" }, - { url = "https://files.pythonhosted.org/packages/a0/86/c291bf740945acbf35ed7dbebf8e2eea2f3f78041f6bd7cdab80cb274dc0/regex-2026.4.4-cp314-cp314t-win_amd64.whl", hash = "sha256:5d354b18839328927832e2fa5f7c95b7a3ccc39e7a681529e1685898e6436d45", size = 285622, upload-time = "2026-04-03T20:56:23.641Z" }, - { url = "https://files.pythonhosted.org/packages/d5/e7/ec846d560ae6a597115153c02ca6138a7877a1748b2072d9521c10a93e58/regex-2026.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:af0384cb01a33600c49505c27c6c57ab0b27bf84a74e28524c92ca897ebdac9d", size = 275773, upload-time = "2026-04-03T20:56:26.07Z" }, +version = "2026.5.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/0e/49aee608ad09480e7fd276898c99ec6192985fa331abe4eb3a986094490b/regex-2026.5.9.tar.gz", hash = "sha256:a8234aa23ec39894bfe4a3f1b85616a7032481964a13ac6fc9f10de4f6fca270", size = 416074, upload-time = "2026-05-09T23:15:19.37Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/3e/9c3cd292d8808b3645a2ce517e200179b6d0e903f176300bd8b542e14de5/regex-2026.5.9-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:1bd7587a2948b4085195d5a3374eaf4a425dc3e55784c038175355ecf3bbbf8a", size = 490376, upload-time = "2026-05-09T23:14:09.64Z" }, + { url = "https://files.pythonhosted.org/packages/60/70/d43ee8a2ca0a8b68d167f21658b85520ac0574617c7f320367c5047f7556/regex-2026.5.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:dea2e88e1cce4522496cce630e11e67b98b7076620bc4336c3f674bc21a375f4", size = 291964, upload-time = "2026-05-09T23:14:11.424Z" }, + { url = "https://files.pythonhosted.org/packages/21/91/9d50b433828d8e74196904e168a43abf1e6e88b2a15d47ed742456720c37/regex-2026.5.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2099f7e7ff7b6aa3192312650a56e91cc091e49d50b04e4f6f8b6e28b3b27f1c", size = 289682, upload-time = "2026-05-09T23:14:13.123Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/b835e3cafbb9d977736912436259ff551d60919f7d7b3d37d46659c63564/regex-2026.5.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecd353045824e4477562a2ac718c25799cdaaa41f7aa925a806a8a3e6848a5b9", size = 796996, upload-time = "2026-05-09T23:14:14.923Z" }, + { url = "https://files.pythonhosted.org/packages/2c/a6/9f992d00019166b9de01c546dd4549bc679f2a68df11b877740b0760b7c2/regex-2026.5.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:65c8c8c37377794bd5b2f3ebe51919042bf17aec802e23c833d89782ed0c78af", size = 866089, upload-time = "2026-05-09T23:14:17.757Z" }, + { url = "https://files.pythonhosted.org/packages/e0/08/4d32af657e049b19cb62b02e46e38fe1518797bfb2203ee93a510b21b0dc/regex-2026.5.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b73ab8afcf66c622db143d1c6fda4e58e4d537ee4f125229ad47b1ab80f34c0", size = 911530, upload-time = "2026-05-09T23:14:20.353Z" }, + { url = "https://files.pythonhosted.org/packages/d9/27/2af43dd1dc201d1fecefda64a45f4ad0995855b92724f795a777b402ee69/regex-2026.5.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0de5cf193997384ed2ca6f1cd4f78055b255d93d82d5a8cd6ba0d11c10b167e4", size = 800643, upload-time = "2026-05-09T23:14:22.265Z" }, + { url = "https://files.pythonhosted.org/packages/a4/dd/23a249047013b5321d4a60c4d2437462086f601b061776a525e5fba2a59f/regex-2026.5.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d641a8c9a61618047796d572a39a79b26167b0411d2c3031937b2fe2d081e2cf", size = 777223, upload-time = "2026-05-09T23:14:24.179Z" }, + { url = "https://files.pythonhosted.org/packages/94/6a/e85ed9538cd19586d0465076a4578a12e093ce776d15f3f8ce92733a8dd6/regex-2026.5.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:24b2355ef5cc9aa5b8f07d17704face1c166fdcc2290fa7bd6e6c925655a8346", size = 785760, upload-time = "2026-05-09T23:14:26.065Z" }, + { url = "https://files.pythonhosted.org/packages/2a/c4/f25473209438638e947c55f9156fd8f236f74169229028cc99116380868e/regex-2026.5.9-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a24852d3c29ad9e47593593d8a247c44ccc3d0548ef12c822d6ed0810affe676", size = 860891, upload-time = "2026-05-09T23:14:28.17Z" }, + { url = "https://files.pythonhosted.org/packages/f9/f7/f4f86e3c74419c37370e91f150ae0c2ef7d34b2e0e4cdd5da046a02e4022/regex-2026.5.9-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:916714069da19329ef7de197dcbc77bb3104145c7c2c864dbfbe318f46b88b14", size = 765891, upload-time = "2026-05-09T23:14:30.06Z" }, + { url = "https://files.pythonhosted.org/packages/26/70/704d8e13765939146b1cd0ef4e2feb71d7929727d2290f026eed10095955/regex-2026.5.9-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:fa411799ca8da32a8d38d020a88faa5b6f91657d284761352940ecf9f7c3bbdd", size = 851380, upload-time = "2026-05-09T23:14:32.123Z" }, + { url = "https://files.pythonhosted.org/packages/26/29/1a13582a8460038edc38e49f64ceb0dd7c60f5caba77571f4bf6601965d9/regex-2026.5.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1e6da47d679b7010ef27556b6e0f99771b744936db1792a10ceac6547ae1503e", size = 789350, upload-time = "2026-05-09T23:14:34.799Z" }, + { url = "https://files.pythonhosted.org/packages/73/56/3dcafe34fc72e271d62ad9a291801e88a1457bb251c132f15fcc2e5aad1a/regex-2026.5.9-cp314-cp314-win32.whl", hash = "sha256:98bd73080e8756255137e1bd3f3f00295bbc5aa383c0e0f973920e9134d7c4ad", size = 272130, upload-time = "2026-05-09T23:14:36.729Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9c/02eebf0be95efe416c664db7fb8b6b05b7a0b06a7544f2884f2558b0526f/regex-2026.5.9-cp314-cp314-win_amd64.whl", hash = "sha256:ff8d372ac2acdc048d1c19916f27ee61bc5722728458ba6ca5052f2c72d51763", size = 280999, upload-time = "2026-05-09T23:14:39.126Z" }, + { url = "https://files.pythonhosted.org/packages/70/5a/1dd1abee76cb7a846a0bcf42fdc87e5720c3c33c24f3e37814310a513d9f/regex-2026.5.9-cp314-cp314-win_arm64.whl", hash = "sha256:e1d93bf647916292e8edcec150c07ddf3dc50179ccaf770c04a7f9e452155372", size = 273500, upload-time = "2026-05-09T23:14:41.059Z" }, + { url = "https://files.pythonhosted.org/packages/86/c1/c5f619b0057a7965cb78ec559c1d7a45ce8c99a35bea95483d64959a93d9/regex-2026.5.9-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:83d0ee4a57d1c87cb549e195ec300b8f0ec3a82eba66d835e4e2ed8634fe4499", size = 494269, upload-time = "2026-05-09T23:14:42.869Z" }, + { url = "https://files.pythonhosted.org/packages/05/2c/5d01f1aee33de4bbe60c8452945bfc8477ca7c5ae4450f6bfe711036cb36/regex-2026.5.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d3d7eb5c9a7f6df82ed3cfac9beb93882a5cbcb5b8b157b56cb2b3b276574ac1", size = 293954, upload-time = "2026-05-09T23:14:44.822Z" }, + { url = "https://files.pythonhosted.org/packages/7a/fe/e8988b2ae2108c6ef71bd4aa8d87fbe257976dd0810e826cd75f701c68b6/regex-2026.5.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:075160bf16658e16d35233300b8453aac25de4cbea808d22348b6979668e924d", size = 292405, upload-time = "2026-05-09T23:14:47.211Z" }, + { url = "https://files.pythonhosted.org/packages/79/34/d2b0937faa7859263f7f0a3c6b103a1296306be6952dc173d0154e9a2f49/regex-2026.5.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45375819235558a4ff1c4971dc32881f022613abdb180128f5cb4768c1765a1c", size = 811855, upload-time = "2026-05-09T23:14:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/80/fe/daf53a47457a8486db66c66c01ceb9c2303eecee3f87197f1e77eb1a736d/regex-2026.5.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ead4b163ac30a29574510cd4b3e2e985ac5290c05fc7095557d6a5f403fc31b5", size = 871189, upload-time = "2026-05-09T23:14:51.555Z" }, + { url = "https://files.pythonhosted.org/packages/1c/75/058fc4470cbfbf57d800aff1a0022b929a3f9fa553ee10a0cdf2070eb31f/regex-2026.5.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8c6e4218fbdfbcd4f6c19efca40930d24a621bf4b48cb76bc6640543bd28ef20", size = 917485, upload-time = "2026-05-09T23:14:53.633Z" }, + { url = "https://files.pythonhosted.org/packages/88/e7/179cfda3a28bc843b5c6cfe7f79f23489c791ed95f151083803660878432/regex-2026.5.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6351571c8a42b505eb555c0dc47d740d0fb66977dc142919eea6f4325b7c56a0", size = 816369, upload-time = "2026-05-09T23:14:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/41/90/6f0cc422071688266d344fca8462d787cba0a2c144acb25721f9a61ec265/regex-2026.5.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:002205cafd2a9e78c6290c7d1df277bf3277b3b7a30e0b4bb0dac2e2e3f7cb2d", size = 785869, upload-time = "2026-05-09T23:14:58.602Z" }, + { url = "https://files.pythonhosted.org/packages/02/67/a31f1760f09c27b251ef39e9beb541f462cf977381d067faa764c2c0e393/regex-2026.5.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8abd33fef90b2a9efac5557d6033ca82d1195ed3a15fea5af15ba7b463c6a63b", size = 801427, upload-time = "2026-05-09T23:15:00.642Z" }, + { url = "https://files.pythonhosted.org/packages/e3/c4/1a80654597b6bc1e1ea0494824c31200e8a956abe290afae9b19a166a148/regex-2026.5.9-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:31037c82eccb44b7ea2e9e221d7c01429430e989a1f4b91ea5a855f6017b509a", size = 866482, upload-time = "2026-05-09T23:15:03.384Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/960724e06482c08466ff5611e242e86f80062949cdf6b4b9cc317b9dd93d/regex-2026.5.9-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5604dfd046dc37eca90250fc3be938b076c8059fa772ac0ed6f499b0f0fb0415", size = 773022, upload-time = "2026-05-09T23:15:05.625Z" }, + { url = "https://files.pythonhosted.org/packages/50/a8/a9979c3e7918280e93159ebcab5ef1a65116dd4f3bd6091be0eae4a126e8/regex-2026.5.9-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0e1b1b4e496afbb24f4a62aba855ee4f88f25578927697b340702e48c9ee6bc2", size = 856642, upload-time = "2026-05-09T23:15:07.966Z" }, + { url = "https://files.pythonhosted.org/packages/fe/d4/a9b732f2f0072c0ab12227483abb24fffcb9f73f8a2b203df0a6d0434735/regex-2026.5.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:be3372b9df6ddecff6486d37e19095a7b4973137caf5512407a89f4455361f41", size = 803552, upload-time = "2026-05-09T23:15:10.215Z" }, + { url = "https://files.pythonhosted.org/packages/d5/fe/1b3113817447a1d4155e4ac76d2e072f42c0bcba2f43fa8a0e756ea2cd91/regex-2026.5.9-cp314-cp314t-win32.whl", hash = "sha256:3ddd90103f9e5c471c49c7852ecc1fe27c7e45eb99e977aefe7caa4e779f4f58", size = 275746, upload-time = "2026-05-09T23:15:12.609Z" }, + { url = "https://files.pythonhosted.org/packages/92/73/93d42045302636c91f2e5ef588b65b84b01428f28ec77de256b1dfdfbe5c/regex-2026.5.9-cp314-cp314t-win_amd64.whl", hash = "sha256:ca518ed29c46eecba6010b15f1b9a479314d2de409536e71b6a13aa04e3b8a77", size = 285685, upload-time = "2026-05-09T23:15:15.086Z" }, + { url = "https://files.pythonhosted.org/packages/da/80/35b4c33c804a165a7f55289afda3ea9e3eb6d15800341a2d66455c0f1f30/regex-2026.5.9-cp314-cp314t-win_arm64.whl", hash = "sha256:5e41809d2683fcde7d5a8c87a6567ba1fb1ce0de9f31bff578de00a4b2d76daa", size = 275713, upload-time = "2026-05-09T23:15:16.98Z" }, ] [[package]] name = "requests" -version = "2.33.1" +version = "2.34.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -871,22 +873,22 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/b8/7a707d60fea4c49094e40262cc0e2ca6c768cca21587e34d3f705afec47e/requests-2.34.0.tar.gz", hash = "sha256:7d62fe92f50eb82c529b0916bb445afa1531a566fc8f35ffdc64446e771b856a", size = 142436, upload-time = "2026-05-11T19:29:51.717Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e6/e300fce5fe83c30520607a015dabd985df3251e188d234bfe9492e17a389/requests-2.34.0-py3-none-any.whl", hash = "sha256:917520a21b767485ce7c588f4ebb917c436b24a31231b44228715eaeb5a52c60", size = 73021, upload-time = "2026-05-11T19:29:49.923Z" }, ] [[package]] name = "rich" -version = "14.3.4" +version = "15.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e9/67/cae617f1351490c25a4b8ac3b8b63a4dda609295d8222bad12242dfdc629/rich-14.3.4.tar.gz", hash = "sha256:817e02727f2b25b40ef56f5aa2217f400c8489f79ca8f46ea2b70dd5e14558a9", size = 230524, upload-time = "2026-04-11T02:57:45.419Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/76/6d163cfac87b632216f71879e6b2cf17163f773ff59c00b5ff4900a80fa3/rich-14.3.4-py3-none-any.whl", hash = "sha256:07e7adb4690f68864777b1450859253bed81a99a31ac321ac1817b2313558952", size = 310480, upload-time = "2026-04-11T02:57:47.484Z" }, + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, ] [[package]] @@ -900,27 +902,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.11.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/45/71/5759b2a6b2279bb77fe15b1435b89473631c2cd6374d45ccdb6b785810be/ruff-0.11.5.tar.gz", hash = "sha256:cae2e2439cb88853e421901ec040a758960b576126dab520fa08e9de431d1bef", size = 3976488, upload-time = "2025-04-10T17:13:29.369Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/23/db/6efda6381778eec7f35875b5cbefd194904832a1153d68d36d6b269d81a8/ruff-0.11.5-py3-none-linux_armv6l.whl", hash = "sha256:2561294e108eb648e50f210671cc56aee590fb6167b594144401532138c66c7b", size = 10103150, upload-time = "2025-04-10T17:12:37.886Z" }, - { url = "https://files.pythonhosted.org/packages/44/f2/06cd9006077a8db61956768bc200a8e52515bf33a8f9b671ee527bb10d77/ruff-0.11.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ac12884b9e005c12d0bd121f56ccf8033e1614f736f766c118ad60780882a077", size = 10898637, upload-time = "2025-04-10T17:12:41.602Z" }, - { url = "https://files.pythonhosted.org/packages/18/f5/af390a013c56022fe6f72b95c86eb7b2585c89cc25d63882d3bfe411ecf1/ruff-0.11.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:4bfd80a6ec559a5eeb96c33f832418bf0fb96752de0539905cf7b0cc1d31d779", size = 10236012, upload-time = "2025-04-10T17:12:44.584Z" }, - { url = "https://files.pythonhosted.org/packages/b8/ca/b9bf954cfed165e1a0c24b86305d5c8ea75def256707f2448439ac5e0d8b/ruff-0.11.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0947c0a1afa75dcb5db4b34b070ec2bccee869d40e6cc8ab25aca11a7d527794", size = 10415338, upload-time = "2025-04-10T17:12:47.172Z" }, - { url = "https://files.pythonhosted.org/packages/d9/4d/2522dde4e790f1b59885283f8786ab0046958dfd39959c81acc75d347467/ruff-0.11.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ad871ff74b5ec9caa66cb725b85d4ef89b53f8170f47c3406e32ef040400b038", size = 9965277, upload-time = "2025-04-10T17:12:50.628Z" }, - { url = "https://files.pythonhosted.org/packages/e5/7a/749f56f150eef71ce2f626a2f6988446c620af2f9ba2a7804295ca450397/ruff-0.11.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e6cf918390cfe46d240732d4d72fa6e18e528ca1f60e318a10835cf2fa3dc19f", size = 11541614, upload-time = "2025-04-10T17:12:53.783Z" }, - { url = "https://files.pythonhosted.org/packages/89/b2/7d9b8435222485b6aac627d9c29793ba89be40b5de11584ca604b829e960/ruff-0.11.5-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:56145ee1478582f61c08f21076dc59153310d606ad663acc00ea3ab5b2125f82", size = 12198873, upload-time = "2025-04-10T17:12:56.956Z" }, - { url = "https://files.pythonhosted.org/packages/00/e0/a1a69ef5ffb5c5f9c31554b27e030a9c468fc6f57055886d27d316dfbabd/ruff-0.11.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e5f66f8f1e8c9fc594cbd66fbc5f246a8d91f916cb9667e80208663ec3728304", size = 11670190, upload-time = "2025-04-10T17:13:00.194Z" }, - { url = "https://files.pythonhosted.org/packages/05/61/c1c16df6e92975072c07f8b20dad35cd858e8462b8865bc856fe5d6ccb63/ruff-0.11.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:80b4df4d335a80315ab9afc81ed1cff62be112bd165e162b5eed8ac55bfc8470", size = 13902301, upload-time = "2025-04-10T17:13:03.246Z" }, - { url = "https://files.pythonhosted.org/packages/79/89/0af10c8af4363304fd8cb833bd407a2850c760b71edf742c18d5a87bb3ad/ruff-0.11.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3068befab73620b8a0cc2431bd46b3cd619bc17d6f7695a3e1bb166b652c382a", size = 11350132, upload-time = "2025-04-10T17:13:06.209Z" }, - { url = "https://files.pythonhosted.org/packages/b9/e1/ecb4c687cbf15164dd00e38cf62cbab238cad05dd8b6b0fc68b0c2785e15/ruff-0.11.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f5da2e710a9641828e09aa98b92c9ebbc60518fdf3921241326ca3e8f8e55b8b", size = 10312937, upload-time = "2025-04-10T17:13:08.855Z" }, - { url = "https://files.pythonhosted.org/packages/cf/4f/0e53fe5e500b65934500949361e3cd290c5ba60f0324ed59d15f46479c06/ruff-0.11.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ef39f19cb8ec98cbc762344921e216f3857a06c47412030374fffd413fb8fd3a", size = 9936683, upload-time = "2025-04-10T17:13:11.378Z" }, - { url = "https://files.pythonhosted.org/packages/04/a8/8183c4da6d35794ae7f76f96261ef5960853cd3f899c2671961f97a27d8e/ruff-0.11.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:b2a7cedf47244f431fd11aa5a7e2806dda2e0c365873bda7834e8f7d785ae159", size = 10950217, upload-time = "2025-04-10T17:13:14.565Z" }, - { url = "https://files.pythonhosted.org/packages/26/88/9b85a5a8af21e46a0639b107fcf9bfc31da4f1d263f2fc7fbe7199b47f0a/ruff-0.11.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:81be52e7519f3d1a0beadcf8e974715b2dfc808ae8ec729ecfc79bddf8dbb783", size = 11404521, upload-time = "2025-04-10T17:13:17.8Z" }, - { url = "https://files.pythonhosted.org/packages/fc/52/047f35d3b20fd1ae9ccfe28791ef0f3ca0ef0b3e6c1a58badd97d450131b/ruff-0.11.5-py3-none-win32.whl", hash = "sha256:e268da7b40f56e3eca571508a7e567e794f9bfcc0f412c4b607931d3af9c4afe", size = 10320697, upload-time = "2025-04-10T17:13:20.582Z" }, - { url = "https://files.pythonhosted.org/packages/b9/fe/00c78010e3332a6e92762424cf4c1919065707e962232797d0b57fd8267e/ruff-0.11.5-py3-none-win_amd64.whl", hash = "sha256:6c6dc38af3cfe2863213ea25b6dc616d679205732dc0fb673356c2d69608f800", size = 11378665, upload-time = "2025-04-10T17:13:23.349Z" }, - { url = "https://files.pythonhosted.org/packages/43/7c/c83fe5cbb70ff017612ff36654edfebec4b1ef79b558b8e5fd933bab836b/ruff-0.11.5-py3-none-win_arm64.whl", hash = "sha256:67e241b4314f4eacf14a601d586026a962f4002a475aa702c69980a38087aa4e", size = 10460287, upload-time = "2025-04-10T17:13:26.538Z" }, +version = "0.15.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" }, + { url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" }, + { url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" }, + { url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" }, + { url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" }, + { url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" }, + { url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" }, + { url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" }, + { url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" }, + { url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" }, + { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" }, ] [[package]] @@ -1018,41 +1020,40 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, ] -[[package]] -name = "termcolor" -version = "3.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/46/79/cf31d7a93a8fdc6aa0fbb665be84426a8c5a557d9240b6239e9e11e35fc5/termcolor-3.3.0.tar.gz", hash = "sha256:348871ca648ec6a9a983a13ab626c0acce02f515b9e1983332b17af7979521c5", size = 14434, upload-time = "2025-12-29T12:55:21.882Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/33/d1/8bb87d21e9aeb323cc03034f5eaf2c8f69841e40e4853c2627edf8111ed3/termcolor-3.3.0-py3-none-any.whl", hash = "sha256:cf642efadaf0a8ebbbf4bc7a31cec2f9b5f21a9f726f4ccbb08192c9c26f43a5", size = 7734, upload-time = "2025-12-29T12:55:20.718Z" }, -] - [[package]] name = "tomli" -version = "2.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175, upload-time = "2024-11-27T22:38:36.873Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708, upload-time = "2024-11-27T22:38:21.659Z" }, - { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582, upload-time = "2024-11-27T22:38:22.693Z" }, - { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543, upload-time = "2024-11-27T22:38:24.367Z" }, - { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691, upload-time = "2024-11-27T22:38:26.081Z" }, - { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170, upload-time = "2024-11-27T22:38:27.921Z" }, - { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530, upload-time = "2024-11-27T22:38:29.591Z" }, - { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666, upload-time = "2024-11-27T22:38:30.639Z" }, - { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954, upload-time = "2024-11-27T22:38:31.702Z" }, - { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724, upload-time = "2024-11-27T22:38:32.837Z" }, - { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383, upload-time = "2024-11-27T22:38:34.455Z" }, - { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" }, +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] [[package]] name = "tomlkit" -version = "0.14.0" +version = "0.15.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c3/af/14b24e41977adb296d6bd1fb59402cf7d60ce364f90c890bd2ec65c43b5a/tomlkit-0.14.0.tar.gz", hash = "sha256:cf00efca415dbd57575befb1f6634c4f42d2d87dbba376128adb42c121b87064", size = 187167, upload-time = "2026-01-13T01:14:53.304Z" } +sdist = { url = "https://files.pythonhosted.org/packages/51/db/03eaf4331631ef6b27d6e3c9b68c54dc6f0d63d87201fed600cc409307fd/tomlkit-0.15.0.tar.gz", hash = "sha256:7d1a9ecba3086638211b13814ea79c90dd54dd11993564376f3aa92271f5c7a3", size = 161875, upload-time = "2026-05-10T07:38:22.245Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b5/11/87d6d29fb5d237229d67973a6c9e06e048f01cf4994dee194ab0ea841814/tomlkit-0.14.0-py3-none-any.whl", hash = "sha256:592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680", size = 39310, upload-time = "2026-01-13T01:14:51.965Z" }, + { url = "https://files.pythonhosted.org/packages/6a/43/8bd850ee71a191bf072e31302c73a66be413fecdd98fdcd111ecbcce13ca/tomlkit-0.15.0-py3-none-any.whl", hash = "sha256:4dbc8f0fc024412b57ced8757ac7461305126a648ff8c2c807fcb8e133a78738", size = 41328, upload-time = "2026-05-10T07:38:23.517Z" }, ] [[package]] @@ -1069,7 +1070,7 @@ wheels = [ [[package]] name = "typer" -version = "0.23.1" +version = "0.25.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -1077,9 +1078,9 @@ dependencies = [ { name = "rich" }, { name = "shellingham" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fd/07/b822e1b307d40e263e8253d2384cf98c51aa2368cc7ba9a07e523a1d964b/typer-0.23.1.tar.gz", hash = "sha256:2070374e4d31c83e7b61362fd859aa683576432fd5b026b060ad6b4cd3b86134", size = 120047, upload-time = "2026-02-13T10:04:30.984Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/51/9aed62104cea109b820bbd6c14245af756112017d309da813ef107d42e7e/typer-0.25.1.tar.gz", hash = "sha256:9616eb8853a09ffeabab1698952f33c6f29ffdbceb4eaeecf571880e8d7664cc", size = 122276, upload-time = "2026-04-30T19:32:16.964Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d5/91/9b286ab899c008c2cb05e8be99814807e7fbbd33f0c0c960470826e5ac82/typer-0.23.1-py3-none-any.whl", hash = "sha256:3291ad0d3c701cbf522012faccfbb29352ff16ad262db2139e6b01f15781f14e", size = 56813, upload-time = "2026-02-13T10:04:32.008Z" }, + { url = "https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl", hash = "sha256:75caa44ed46a03fb2dab8808753ffacdbfea88495e74c85a28c5eefcf5f39c89", size = 58409, upload-time = "2026-04-30T19:32:18.271Z" }, ] [[package]] @@ -1105,9 +1106,9 @@ wheels = [ [[package]] name = "urllib3" -version = "2.6.3" +version = "2.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ]