BoilerWorks

Initial commit — Boilerworks CLI v0.1.0

anonymous 2026-03-30 03:09 trunk
Commit 0cb4a5e6ba26b36ed54ace7bf4f3cc00aa4e141090a85272a24c52c8cb850248
--- a/.github/ISSUE_TEMPLATE/bug_report.md
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,35 @@
1
+---
2
+name: Bug report
3
+about: Something isn't working
4
+title: 'bug: '
5
+labels: bug
6
+assignees: ''
7
+---
8
+
9
+## Describe the bug
10
+
11
+A clear description of what the bug is.
12
+
13
+## To reproduce
14
+
15
+Steps to reproduce the behavior:
16
+
17
+1. Run `boilerworks ...`
18
+2. See error
19
+
20
+## Expected behavior
21
+
22
+What you expected to happen.
23
+
24
+## Environment
25
+
26
+- OS:
27
+- Python version:
28
+- Boilerworks version: (run `boilerworks --version`)
29
+- Template:
30
+
31
+## Error output
32
+
33
+```
34
+paste error output here
35
+```
--- a/.github/ISSUE_TEMPLATE/bug_report.md
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/.github/ISSUE_TEMPLATE/bug_report.md
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,35 @@
1 ---
2 name: Bug report
3 about: Something isn't working
4 title: 'bug: '
5 labels: bug
6 assignees: ''
7 ---
8
9 ## Describe the bug
10
11 A clear description of what the bug is.
12
13 ## To reproduce
14
15 Steps to reproduce the behavior:
16
17 1. Run `boilerworks ...`
18 2. See error
19
20 ## Expected behavior
21
22 What you expected to happen.
23
24 ## Environment
25
26 - OS:
27 - Python version:
28 - Boilerworks version: (run `boilerworks --version`)
29 - Template:
30
31 ## Error output
32
33 ```
34 paste error output here
35 ```
--- a/.github/ISSUE_TEMPLATE/config.yml
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,8 @@
1
+blank_issues_enabled: false
2
+contact_links:
3
+ - name: Documentation
4
+ url: https://boilerworks.dev
5
+ about: Read the docs
6
+ - name: Discussions
7
+ url: https://github.com/ConflictHQ/boilerworks/discussions
8
+ about: Ask questions and share ideas
--- a/.github/ISSUE_TEMPLATE/config.yml
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
--- a/.github/ISSUE_TEMPLATE/config.yml
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,8 @@
1 blank_issues_enabled: false
2 contact_links:
3 - name: Documentation
4 url: https://boilerworks.dev
5 about: Read the docs
6 - name: Discussions
7 url: https://github.com/ConflictHQ/boilerworks/discussions
8 about: Ask questions and share ideas
--- a/.github/ISSUE_TEMPLATE/feature_request.md
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,23 @@
1
+---
2
+name: Feature request
3
+about: Suggest an improvement or new template
4
+title: 'feat: '
5
+labels: enhancement
6
+assignees: ''
7
+---
8
+
9
+## What problem does this solve?
10
+
11
+A clear description of the problem or gap.
12
+
13
+## Proposed solution
14
+
15
+What you'd like to see. Be specific.
16
+
17
+## Alternatives considered
18
+
19
+Other approaches you've thought about.
20
+
21
+## Additional context
22
+
23
+Any other context, screenshots, or references.
--- a/.github/ISSUE_TEMPLATE/feature_request.md
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/.github/ISSUE_TEMPLATE/feature_request.md
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,23 @@
1 ---
2 name: Feature request
3 about: Suggest an improvement or new template
4 title: 'feat: '
5 labels: enhancement
6 assignees: ''
7 ---
8
9 ## What problem does this solve?
10
11 A clear description of the problem or gap.
12
13 ## Proposed solution
14
15 What you'd like to see. Be specific.
16
17 ## Alternatives considered
18
19 Other approaches you've thought about.
20
21 ## Additional context
22
23 Any other context, screenshots, or references.
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -0,0 +1,13 @@
1
+version: 2
2
+updates:
3
+ - package-ecosystem: "pip"
4
+ directory: "/"
5
+ schedule:
6
+ interval: "weekly"
7
+ open-pull-requests-limit: 10
8
+
9
+ - package-ecosystem: "github-actions"
10
+ directory: "/"
11
+ schedule:
12
+ interval: "weekly"
13
+ open-pull-requests-limit: 5
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -0,0 +1,13 @@
1 version: 2
2 updates:
3 - package-ecosystem: "pip"
4 directory: "/"
5 schedule:
6 interval: "weekly"
7 open-pull-requests-limit: 10
8
9 - package-ecosystem: "github-actions"
10 directory: "/"
11 schedule:
12 interval: "weekly"
13 open-pull-requests-limit: 5
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -0,0 +1,23 @@
1
+## Summary
2
+
3
+<!-- What does this PR do? 1-3 bullet points. -->
4
+
5
+-
6
+-
7
+
8
+## Related issue
9
+
10
+Closes #
11
+
12
+## Test plan
13
+
14
+<!-- How was this tested? -->
15
+
16
+- [ ] `make lint` passes
17
+- [ ] `make test` passes with ≥ 80% coverage
18
+- [ ] `uv run boilerworks --help` works
19
+- [ ] Manually tested the changed command(s)
20
+
21
+## Notes
22
+
23
+<!-- Anything reviewers should know? Breaking changes? Migration steps? -->
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -0,0 +1,23 @@
1 ## Summary
2
3 <!-- What does this PR do? 1-3 bullet points. -->
4
5 -
6 -
7
8 ## Related issue
9
10 Closes #
11
12 ## Test plan
13
14 <!-- How was this tested? -->
15
16 - [ ] `make lint` passes
17 - [ ] `make test` passes with ≥ 80% coverage
18 - [ ] `uv run boilerworks --help` works
19 - [ ] Manually tested the changed command(s)
20
21 ## Notes
22
23 <!-- Anything reviewers should know? Breaking changes? Migration steps? -->
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,40 @@
1
+name: CI
2
+
3
+on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+jobs:
10
+ lint:
11
+ name: Lint
12
+ runs-on: ubuntu-latest
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+ -4uses: astral-sh/setup-uv@version: "latest"
16
+ - name: Install dependencies
17
+ run: uv sync
18
+ Ruff check
19
+ Y@4Y,N:name: Ruff format check7@8f,1: l@53,A:name: Test1g@9F,1:4P@Aw,2M:version: "latest"
20
+ - name: Install dependencies
21
+ run: uv sync
22
+ - name: Run tests
23
+ run: uv run pytest
24
+
25
+ build:
26
+ name: Build1g@9F,1:4P@Aw,j:version: "latest"
27
+ - name: Build package
28
+8@BB,2v:run: uv build
29
+ - name: Verify package
30
+ run: |
31
+ ls -la dist/
32
+ uv run python -c "import boilerworks; print(boilerworks.__version__)"
33
+
34
+ audit:
35
+ name: Audit1g@9F,1:4P@Aw,2N:version: "latest"
36
+ - name: Install pip-audit
37
+ run: uv tool install pip-audit
38
+ - name: Run audit
39
+ run: uv run pip-audit || true
40
+2W2YhL;
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,40 @@
1 name: CI
2
3 on:
4 push:
5 branches: [main]
6 pull_request:
7 branches: [main]
8
9 jobs:
10 lint:
11 name: Lint
12 runs-on: ubuntu-latest
13 steps:
14 - uses: actions/checkout@v4
15 -4uses: astral-sh/setup-uv@version: "latest"
16 - name: Install dependencies
17 run: uv sync
18 Ruff check
19 Y@4Y,N:name: Ruff format check7@8f,1: l@53,A:name: Test1g@9F,1:4P@Aw,2M:version: "latest"
20 - name: Install dependencies
21 run: uv sync
22 - name: Run tests
23 run: uv run pytest
24
25 build:
26 name: Build1g@9F,1:4P@Aw,j:version: "latest"
27 - name: Build package
28 8@BB,2v:run: uv build
29 - name: Verify package
30 run: |
31 ls -la dist/
32 uv run python -c "import boilerworks; print(boilerworks.__version__)"
33
34 audit:
35 name: Audit1g@9F,1:4P@Aw,2N:version: "latest"
36 - name: Install pip-audit
37 run: uv tool install pip-audit
38 - name: Run audit
39 run: uv run pip-audit || true
40 2W2YhL;
+2
--- a/.gitignore
+++ b/.gitignore
@@ -0,0 +1,2 @@
1
+.idea/
2
+backend/backend.iml
--- a/.gitignore
+++ b/.gitignore
@@ -0,0 +1,2 @@
 
 
--- a/.gitignore
+++ b/.gitignore
@@ -0,0 +1,2 @@
1 .idea/
2 backend/backend.iml
+26
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -0,0 +1,26 @@
1
+# Agents — Boilerworks CLI
2
+
3
+This file is for Cursor, Windsurf, and generic AI coding agents.
4
+
5
+**Read `CLAUDE.md` for the full stack summary and conventions.**
6
+
7
+## Quick orientation
8
+
9
+- This is the `boilerworks` Python CLI package, not a web app
10
+- Entry point: `boilerworks/cli.py` → `main()` Click group
11
+- Template data: `data/templates.yaml` (26 templates)
12
+- Manifest model: `boilerworks/manifest.py` → `BoilerworksManifest`
13
+- Registry: `boilerworks/registry.py` → `Registry`
14
+- Renderer: `boilerworks/renderer.py` → `render_directory`, `build_replacements`
15
+
16
+## Before writing code
17
+
18
+1. Run `uv run boilerworks --help` to verify the CLI works
19
+2. Run `make lint` to check for style issues
20
+3. Run `make test` to ensure tests pass
21
+
22
+## After writing code
23
+
24
+1. `make format` — fix style issues
25
+2. `make lint` — verify zero violations
26
+3. `make test` — verify tests pass and coverage ≥ 80%
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -0,0 +1,26 @@
1 # Agents — Boilerworks CLI
2
3 This file is for Cursor, Windsurf, and generic AI coding agents.
4
5 **Read `CLAUDE.md` for the full stack summary and conventions.**
6
7 ## Quick orientation
8
9 - This is the `boilerworks` Python CLI package, not a web app
10 - Entry point: `boilerworks/cli.py` → `main()` Click group
11 - Template data: `data/templates.yaml` (26 templates)
12 - Manifest model: `boilerworks/manifest.py` → `BoilerworksManifest`
13 - Registry: `boilerworks/registry.py` → `Registry`
14 - Renderer: `boilerworks/renderer.py` → `render_directory`, `build_replacements`
15
16 ## Before writing code
17
18 1. Run `uv run boilerworks --help` to verify the CLI works
19 2. Run `make lint` to check for style issues
20 3. Run `make test` to ensure tests pass
21
22 ## After writing code
23
24 1. `make format` — fix style issues
25 2. `make lint` — verify zero violations
26 3. `make test` — verify tests pass and coverage ≥ 80%
+82
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -0,0 +1,82 @@
1
+# Claude — Boilerworks CLI
2
+
3
+Primary conventions doc: read this file, then the source code.
4
+
5
+This repo is the **Boilerworks CLI** — a Python package published to PyPI as `boilerworks`.
6
+It is NOT a web application. It is a command-line tool built with Click, Questionary, and Rich.
7
+
8
+---
9
+
10
+## Stack
11
+
12
+- **Language**: Python 3.12+
13
+- **CLI framework**: Click 8+
14
+- **Interactive prompts**: Questionary 2+
15
+- **Output**: Rich 13+ (tables, panels, progress bars)
16
+- **Manifest validation**: Pydantic v2
17
+- **Template rendering**: string replacement (not Jinja2)
18
+- **Git operations**: subprocess (git CLI) + GitPython
19
+- **Config**: PyYAML 6+
20
+- **Package manager**: uv (not pip)
21
+- **Lint + format**: Ruff (not flake8/black/isort)
22
+- **Tests**: pytest with coverage
23
+
24
+## Package layout
25
+
26
+```
27
+boilerworks/ # Python package
28
+ __init__.py # __version__ = "0.1.0"
29
+ cli.py # Click group: setup, init, bootstrap, list
30
+ wizard.py # Questionary prompts → boilerworks.yaml
31
+ generator.py # Clone → render → wire → git init
32
+ bootstrap.py # Terraform stub (v2)
33
+ manifest.py # Pydantic models for boilerworks.yaml
34
+ registry.py # Load + query templates.yaml
35
+ renderer.py # String replacement in cloned files
36
+ console.py # Rich output helpers
37
+data/
38
+ templates.yaml # All 26 templates with metadata
39
+tests/
40
+ conftest.py
41
+ test_cli.py
42
+ test_manifest.py
43
+ test_registry.py
44
+ test_renderer.py
45
+ test_generator.py
46
+ test_console.py
47
+ test_wizard.py
48
+```
49
+
50
+## Running locally
51
+
52
+```bash
53
+uv sync # install deps
54
+uv run boilerworks --help # verify install
55
+make lint # ruff check + format --check
56
+make test # pytest with coverage
57
+make format # ruff fix + format
58
+```
59
+
60
+## Adding a template
61
+
62
+Edit `data/templates.yaml`. Add an entry following the existing schema.
63
+Run `make test` — `test_registry.py` will catch count mismatches.
64
+
65
+## Coding standards
66
+
67
+- Fully typed: all function signatures have type hints
68
+- Line length: 120 (ruff config in pyproject.toml)
69
+- `ruff check . && ruff format .` after every change
70
+- pytest coverage ≥ 80%
71
+- No TODOs, no stubs (bootstrap is intentionally a v2 stub — document it clearly)
72
+- No co-authorship messages in commits
73
+
74
+## Common patterns
75
+
76
+**Adding a CLI option**: edit `boilerworks/cli.py`, add `@click.option(...)` decorator
77
+
78
+**Adding a manifest field**: edit `boilerworks/manifest.py` (BoilerworksManifest model)
79
+
80
+**Adding a renderer rule**: edit `boilerworks/renderer.py` (`build_replacements` or `_SKIP_*`)
81
+
82
+**Adding a template to the catalogue**: edit `data/templates.yaml`
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -0,0 +1,82 @@
1 # Claude — Boilerworks CLI
2
3 Primary conventions doc: read this file, then the source code.
4
5 This repo is the **Boilerworks CLI** — a Python package published to PyPI as `boilerworks`.
6 It is NOT a web application. It is a command-line tool built with Click, Questionary, and Rich.
7
8 ---
9
10 ## Stack
11
12 - **Language**: Python 3.12+
13 - **CLI framework**: Click 8+
14 - **Interactive prompts**: Questionary 2+
15 - **Output**: Rich 13+ (tables, panels, progress bars)
16 - **Manifest validation**: Pydantic v2
17 - **Template rendering**: string replacement (not Jinja2)
18 - **Git operations**: subprocess (git CLI) + GitPython
19 - **Config**: PyYAML 6+
20 - **Package manager**: uv (not pip)
21 - **Lint + format**: Ruff (not flake8/black/isort)
22 - **Tests**: pytest with coverage
23
24 ## Package layout
25
26 ```
27 boilerworks/ # Python package
28 __init__.py # __version__ = "0.1.0"
29 cli.py # Click group: setup, init, bootstrap, list
30 wizard.py # Questionary prompts → boilerworks.yaml
31 generator.py # Clone → render → wire → git init
32 bootstrap.py # Terraform stub (v2)
33 manifest.py # Pydantic models for boilerworks.yaml
34 registry.py # Load + query templates.yaml
35 renderer.py # String replacement in cloned files
36 console.py # Rich output helpers
37 data/
38 templates.yaml # All 26 templates with metadata
39 tests/
40 conftest.py
41 test_cli.py
42 test_manifest.py
43 test_registry.py
44 test_renderer.py
45 test_generator.py
46 test_console.py
47 test_wizard.py
48 ```
49
50 ## Running locally
51
52 ```bash
53 uv sync # install deps
54 uv run boilerworks --help # verify install
55 make lint # ruff check + format --check
56 make test # pytest with coverage
57 make format # ruff fix + format
58 ```
59
60 ## Adding a template
61
62 Edit `data/templates.yaml`. Add an entry following the existing schema.
63 Run `make test` — `test_registry.py` will catch count mismatches.
64
65 ## Coding standards
66
67 - Fully typed: all function signatures have type hints
68 - Line length: 120 (ruff config in pyproject.toml)
69 - `ruff check . && ruff format .` after every change
70 - pytest coverage ≥ 80%
71 - No TODOs, no stubs (bootstrap is intentionally a v2 stub — document it clearly)
72 - No co-authorship messages in commits
73
74 ## Common patterns
75
76 **Adding a CLI option**: edit `boilerworks/cli.py`, add `@click.option(...)` decorator
77
78 **Adding a manifest field**: edit `boilerworks/manifest.py` (BoilerworksManifest model)
79
80 **Adding a renderer rule**: edit `boilerworks/renderer.py` (`build_replacements` or `_SKIP_*`)
81
82 **Adding a template to the catalogue**: edit `data/templates.yaml`
+26
--- a/Makefile
+++ b/Makefile
@@ -0,0 +1,26 @@
1
+.PHONY: lint format test build install clean
2
+
3
+lint:
4
+ uv run ruff check .
5
+ uv run ruff format --check .
6
+
7
+format:
8
+ uv run ruff check --fix .
9
+ uv run ruff format .
10
+
11
+test:
12
+ uv run pytest
13
+
14
+test-fast:
15
+ uv run pytest -x --no-cov
16
+
17
+build:
18
+ uv build
19
+
20
+install:
21
+ uv sync
22
+
23
+clean:
24
+ rm -rf dist/ .pytest_cache/ .coverage htmlcov/ __pycache__
25
+ find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
26
+ find . -type f -name "*.pyc" -delete
--- a/Makefile
+++ b/Makefile
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/Makefile
+++ b/Makefile
@@ -0,0 +1,26 @@
1 .PHONY: lint format test build install clean
2
3 lint:
4 uv run ruff check .
5 uv run ruff format --check .
6
7 format:
8 uv run ruff check --fix .
9 uv run ruff format .
10
11 test:
12 uv run pytest
13
14 test-fast:
15 uv run pytest -x --no-cov
16
17 build:
18 uv build
19
20 install:
21 uv sync
22
23 clean:
24 rm -rf dist/ .pytest_cache/ .coverage htmlcov/ __pycache__
25 find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
26 find . -type f -name "*.pyc" -delete
+160
--- a/README.md
+++ b/README.md
@@ -0,0 +1,160 @@
1
+# Boilerworks
2
+
3
+**Production-ready project templates — assembled in seconds.**
4
+
5
+Boilerworks is a CLI that clones and configures any of 26 opinionated, best-of-breed project templates. Stop re-solving auth, permissions, Docker, CI, and admin panels from scratch. Pick a stack, run `boilerworks init`, and get straight to your business logic.
6
+
7
+```
8
+pip install boilerworks
9
+boilerworks setup # interactive wizard → writes boilerworks.yaml
10
+boilerworks init # clone + configure the template
11
+cd my-project
12
+docker compose up -d # full stack running in seconds
13
+```
14
+
15
+---
16
+
17
+## Installation
18
+
19
+```bash
20
+pip install boilerworks
21
+# or with uv:
22
+uv tool install boilerworks
23
+```
24
+
25
+Requires Python 3.12+.
26
+
27
+---
28
+
29
+## Quick Start
30
+
31
+### 1. Run the setup wizard
32
+
33
+```bash
34
+boilerworks setup
35
+```
36
+
37
+Answer 13 questions about your project (name, template, cloud, compliance, etc.) and a `boilerworks.yaml` manifest is written to the current directory.
38
+
39
+### 2. Generate the project
40
+
41
+```bash
42
+boilerworks init
43
+```
44
+
45
+Boilerworks clones the selected template, replaces all `boilerworks` references with your project name, and runs `git init` to give you a clean starting commit.
46
+
47
+### 3. Start developing
48
+
49
+```bash
50
+cd my-project
51
+docker compose up -d
52
+# Visit http://localhost:3000
53
+```
54
+
55
+One command. Full stack. No manual setup.
56
+
57
+---
58
+
59
+## Template Catalogue
60
+
61
+26 templates across three sizes:
62
+
63
+| Size | Description | Auth | Deploy Target |
64
+|------|-------------|------|---------------|
65
+| **Full** | Apps with users | Session auth, permissions, org management | VPS, containers, Kubernetes |
66
+| **Micro** | API services | API-key auth | VPS, containers |
67
+| **Edge** | Serverless apps | Flexible | Cloudflare Workers / Pages |
68
+
69
+### Full Templates (15)
70
+
71
+| Statusular](https://github|---------/boilerworks-django-nextjs) | Django 5 | Next.js 16 |
72
+| [nestjs-nextjs](https://github.com/ConflictHQ/boilerworks-nestjs-nextjs) | NestJS 11 | Next.js 16 |
73
+| [rails-hotwire](https://github.com/ConflictHQ/boilerworks-rails-hotwire) | Rails 8 | Hotwire + Tailwind |
74
+| [rails-nextjs](https://github.com/ConflictHQ/boilerworks-rails-nextjs) | Rails 8 | Next.js 16 |
75
+| [spring-angular](https://github.com/ConflictHQ/boilerworks-spring-angular) | Spring Boot 3 | Angular 19 |
76
+| [go-nextjs](https://github.com/ConflictHQ/boilerworks-go-nextjs) | Go + Chi | Next.js 16 |
77
+| [phoenix-liveview](https://github.com/ConflictHQ/boilerworks-phoenix-liveview) | Phoenix 1.7 | LiveView |
78
+| [laravel-vue](https://github.com/ConflictHQ/boilerworks-laravel-vue) | Laravel 12 | Inertia + Vue 3 |
79
+| [django-htmx](https://github.com/ConflictHQ/boilerworks-django-htmx) | Django 5 | HTMX + Alpine.js |
80
+| [fastapi-nextjs](https://github.com/ConflictHQ/boilerworks-fastapi-nextjs) | FastAPI | Next.js 16 |
81
+| [spring-nextjs](https://github.com/ConflictHQ/boilerworks-spring-nextjs) | Spring Boot 3 | Next.js 16 |
82
+| [laravel-livewire](https://github.com/ConflictHQ/boilerworks-laravel-livewire) | Laravel 12 | Livewire 3 |
83
+| [go-htmx](https://github.com/ConflictHQ/boilerworks-go-htmx) | Go + Chi | HTMX + Templ |
84
+| [fastapi-htmx](https://github.com/ConflictHQ/boilerworks-fastapi-htmx) | FastAPI | HTMX + Alpine.js |
85
+| [saleor-nextjs](https://github.com/ConflictHQ/boilerworks-saleor-nextjs) | Saleor (Django) | Next.js 16 |
86
+
87
+### Micro Templates (6)
88
+
89
+| Name | Backend |
90
+|------|---------|
91
+| [django-micro](https://github.com/ConflictHQ/boilerworks-django-micro) | Django 5 (DRF/Ninja) |
92
+| [fastapi-micro](https://github.com/ConflictHQ/boilerworks-fastapi-micro) | FastAPI |
93
+| [nestjs-micro](https://github.com/ConflictHQ/boilerworks-nestjs-micro) | NestJS 11 |
94
+| [go-micro](https://github.com/ConflictHQ/boilerworks-go-micro) | Go + Chi |
95
+| [rust-micro](https://github.com/ConflictHQ/boilerworks-rust-micro) | Axum (Rust) |
96
+| [cherrypy-micro](https://github.com/ConflictHQ/boilerworks-cherrypy-micro) | CherryPy |
97
+
98
+### Edge Templates (5)
99
+
100
+| Name | Framework |
101
+|------|-----------|
102
+| [sveltekit-full](https://github.com/ConflictHQ/boilerworks-sveltekit-full) | SvelteKit |
103
+| [remix-full](https://github.com/ConflictHQ/boilerworks-remix-full) | Remix |
104
+| [hono-micro](https://github.com/ConflictHQ/boilerworks-hono-micro) | Hono (Cloudflare Workers) |
105
+| [nuxt-full](https://github.com/ConflictHQ/boilerworks-nuxt-full) | Nuxt 4 |
106
+| [astro-site](https://github.com/ConflictHQ/boilerworks-astro-site) | Astro |
107
+
108
+---
109
+
110
+## Commands
111
+
112
+```bash
113
+boilerworks --help # show all commands
114
+boilerworks list # show all 26 templates
115
+boilerworks list --size micro # filter by size
116
+boilerworks list --language python # filter by language
117
+boilerworks setup # interactive wizard
118
+boilerworks init # generate project from boilerworks.yaml
119
+boilerworks init --dry-run # preview what would happen
120
+boilerworks init --manifest ./path/to/boilerworks.yaml
121
+boilerworks init --output /path/to/output
122
+```
123
+
124
+---
125
+
126
+## boilerworks.yaml
127
+
128
+The manifest file describes your project. Generated by `boilerworks setup`, editable by hand.
129
+
130
+```yaml
131
+project: my-app
132
+family: django-nextjs
133
+size: full
134
+topology: standard
135
+cloud: aws
136
+ops: true
137
+region: us-east-1
138
+domain: myapp.com
139
+mobile: false
140
+web_presence: false
141
+compliance:
142
+ - soc2
143
+servicesonflictil: ses
144
+ cache: redis
145
+data:
146
+ database: postgres
147
+ migrations: true
148
+ seedonflict LLC.
149
+ ✅ Done |
150
+| nestjs-nextjs | NestJS 11 | Next.js 16 | ✅ Done |
151
+| django-htmxrrent directory.
152
+
153
+### 2. Generat 🔨 Building |
154
+| saleor-nextjs**Micro** | API services | API-ke 🔨 Building |
155
+| rails-hotwire | Rails 8 | Hotwire | 🔨 Building |
156
+| laravel-vue | Laravel 11ce, etc.) and a `boi 🔨 Building |
157
+| fastapi-nextjslerworks` references with 🔨 Building |
158
+| fastapi-htmx-|------|---------------|
159
+| **F 📋 Planned |
160
+| spring-ang
--- a/README.md
+++ b/README.md
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/README.md
+++ b/README.md
@@ -0,0 +1,160 @@
1 # Boilerworks
2
3 **Production-ready project templates — assembled in seconds.**
4
5 Boilerworks is a CLI that clones and configures any of 26 opinionated, best-of-breed project templates. Stop re-solving auth, permissions, Docker, CI, and admin panels from scratch. Pick a stack, run `boilerworks init`, and get straight to your business logic.
6
7 ```
8 pip install boilerworks
9 boilerworks setup # interactive wizard → writes boilerworks.yaml
10 boilerworks init # clone + configure the template
11 cd my-project
12 docker compose up -d # full stack running in seconds
13 ```
14
15 ---
16
17 ## Installation
18
19 ```bash
20 pip install boilerworks
21 # or with uv:
22 uv tool install boilerworks
23 ```
24
25 Requires Python 3.12+.
26
27 ---
28
29 ## Quick Start
30
31 ### 1. Run the setup wizard
32
33 ```bash
34 boilerworks setup
35 ```
36
37 Answer 13 questions about your project (name, template, cloud, compliance, etc.) and a `boilerworks.yaml` manifest is written to the current directory.
38
39 ### 2. Generate the project
40
41 ```bash
42 boilerworks init
43 ```
44
45 Boilerworks clones the selected template, replaces all `boilerworks` references with your project name, and runs `git init` to give you a clean starting commit.
46
47 ### 3. Start developing
48
49 ```bash
50 cd my-project
51 docker compose up -d
52 # Visit http://localhost:3000
53 ```
54
55 One command. Full stack. No manual setup.
56
57 ---
58
59 ## Template Catalogue
60
61 26 templates across three sizes:
62
63 | Size | Description | Auth | Deploy Target |
64 |------|-------------|------|---------------|
65 | **Full** | Apps with users | Session auth, permissions, org management | VPS, containers, Kubernetes |
66 | **Micro** | API services | API-key auth | VPS, containers |
67 | **Edge** | Serverless apps | Flexible | Cloudflare Workers / Pages |
68
69 ### Full Templates (15)
70
71 | Statusular](https://github|---------/boilerworks-django-nextjs) | Django 5 | Next.js 16 |
72 | [nestjs-nextjs](https://github.com/ConflictHQ/boilerworks-nestjs-nextjs) | NestJS 11 | Next.js 16 |
73 | [rails-hotwire](https://github.com/ConflictHQ/boilerworks-rails-hotwire) | Rails 8 | Hotwire + Tailwind |
74 | [rails-nextjs](https://github.com/ConflictHQ/boilerworks-rails-nextjs) | Rails 8 | Next.js 16 |
75 | [spring-angular](https://github.com/ConflictHQ/boilerworks-spring-angular) | Spring Boot 3 | Angular 19 |
76 | [go-nextjs](https://github.com/ConflictHQ/boilerworks-go-nextjs) | Go + Chi | Next.js 16 |
77 | [phoenix-liveview](https://github.com/ConflictHQ/boilerworks-phoenix-liveview) | Phoenix 1.7 | LiveView |
78 | [laravel-vue](https://github.com/ConflictHQ/boilerworks-laravel-vue) | Laravel 12 | Inertia + Vue 3 |
79 | [django-htmx](https://github.com/ConflictHQ/boilerworks-django-htmx) | Django 5 | HTMX + Alpine.js |
80 | [fastapi-nextjs](https://github.com/ConflictHQ/boilerworks-fastapi-nextjs) | FastAPI | Next.js 16 |
81 | [spring-nextjs](https://github.com/ConflictHQ/boilerworks-spring-nextjs) | Spring Boot 3 | Next.js 16 |
82 | [laravel-livewire](https://github.com/ConflictHQ/boilerworks-laravel-livewire) | Laravel 12 | Livewire 3 |
83 | [go-htmx](https://github.com/ConflictHQ/boilerworks-go-htmx) | Go + Chi | HTMX + Templ |
84 | [fastapi-htmx](https://github.com/ConflictHQ/boilerworks-fastapi-htmx) | FastAPI | HTMX + Alpine.js |
85 | [saleor-nextjs](https://github.com/ConflictHQ/boilerworks-saleor-nextjs) | Saleor (Django) | Next.js 16 |
86
87 ### Micro Templates (6)
88
89 | Name | Backend |
90 |------|---------|
91 | [django-micro](https://github.com/ConflictHQ/boilerworks-django-micro) | Django 5 (DRF/Ninja) |
92 | [fastapi-micro](https://github.com/ConflictHQ/boilerworks-fastapi-micro) | FastAPI |
93 | [nestjs-micro](https://github.com/ConflictHQ/boilerworks-nestjs-micro) | NestJS 11 |
94 | [go-micro](https://github.com/ConflictHQ/boilerworks-go-micro) | Go + Chi |
95 | [rust-micro](https://github.com/ConflictHQ/boilerworks-rust-micro) | Axum (Rust) |
96 | [cherrypy-micro](https://github.com/ConflictHQ/boilerworks-cherrypy-micro) | CherryPy |
97
98 ### Edge Templates (5)
99
100 | Name | Framework |
101 |------|-----------|
102 | [sveltekit-full](https://github.com/ConflictHQ/boilerworks-sveltekit-full) | SvelteKit |
103 | [remix-full](https://github.com/ConflictHQ/boilerworks-remix-full) | Remix |
104 | [hono-micro](https://github.com/ConflictHQ/boilerworks-hono-micro) | Hono (Cloudflare Workers) |
105 | [nuxt-full](https://github.com/ConflictHQ/boilerworks-nuxt-full) | Nuxt 4 |
106 | [astro-site](https://github.com/ConflictHQ/boilerworks-astro-site) | Astro |
107
108 ---
109
110 ## Commands
111
112 ```bash
113 boilerworks --help # show all commands
114 boilerworks list # show all 26 templates
115 boilerworks list --size micro # filter by size
116 boilerworks list --language python # filter by language
117 boilerworks setup # interactive wizard
118 boilerworks init # generate project from boilerworks.yaml
119 boilerworks init --dry-run # preview what would happen
120 boilerworks init --manifest ./path/to/boilerworks.yaml
121 boilerworks init --output /path/to/output
122 ```
123
124 ---
125
126 ## boilerworks.yaml
127
128 The manifest file describes your project. Generated by `boilerworks setup`, editable by hand.
129
130 ```yaml
131 project: my-app
132 family: django-nextjs
133 size: full
134 topology: standard
135 cloud: aws
136 ops: true
137 region: us-east-1
138 domain: myapp.com
139 mobile: false
140 web_presence: false
141 compliance:
142 - soc2
143 servicesonflictil: ses
144 cache: redis
145 data:
146 database: postgres
147 migrations: true
148 seedonflict LLC.
149 ✅ Done |
150 | nestjs-nextjs | NestJS 11 | Next.js 16 | ✅ Done |
151 | django-htmxrrent directory.
152
153 ### 2. Generat 🔨 Building |
154 | saleor-nextjs**Micro** | API services | API-ke 🔨 Building |
155 | rails-hotwire | Rails 8 | Hotwire | 🔨 Building |
156 | laravel-vue | Laravel 11ce, etc.) and a `boi 🔨 Building |
157 | fastapi-nextjslerworks` references with 🔨 Building |
158 | fastapi-htmx-|------|---------------|
159 | **F 📋 Planned |
160 | spring-ang
--- a/boilerworks.yaml.example
+++ b/boilerworks.yaml.example
@@ -0,0 +1,93 @@
1
+# boilerworks.yaml — project manifest
2
+#
3
+# Generated by `boilerworks setup`. Edit by hand or regenerate at any time.
4
+# All fields are validated against the registry and manifest schema.
5
+# Run `boilerworks init` to generate the project from this file.
6
+
7
+# ── Required ──────────────────────────────────────────────────────────────────
8
+
9
+# Slug format: lowercase, start with a letter, letters/digits/hyphens only
10
+project: my-app
11
+
12
+# Template family name. Run `boilerworks list` to see all options.
13
+family: django-nextjs
14
+
15
+# Template size: full | micro | edge
16
+size: full
17
+
18
+# ── Topology ──────────────────────────────────────────────────────────────────
19
+
20
+# standard: monorepo with web app and ops (default)
21
+# api-only: backend only, no frontend
22
+# omni: standard + mobile (v2)
23
+topology: standard
24
+
25
+# ── Cloud ─────────────────────────────────────────────────────────────────────
26
+
27
+# Cloud provider: aws | gcp | azure | null
28
+cloud: aws
29
+
30
+# Cloud region (required if cloud is set)
31
+region: us-east-1
32
+
33
+# ── Domain ────────────────────────────────────────────────────────────────────
34
+
35
+# Your production domain (optional)
36
+domain: myapp.com
37
+
38
+# ── Optional add-ons ──────────────────────────────────────────────────────────
39
+
40
+# Include a mobile app template (Full size only)
41
+mobile: false
42
+
43
+# Include a web presence / marketing site template (Full size only)
44
+web_presence: false
45
+
46
+# ── Compliance ────────────────────────────────────────────────────────────────
47
+
48
+# One or more: soc2 | hipaa | pci-dss | gdpr
49
+compliance:
50
+ - soc2
51
+
52
+# ── Services ──────────────────────────────────────────────────────────────────
53
+
54
+services:
55
+ # Transactional email: ses | sendgrid | mailgun | null
56
+ email: ses
57
+
58
+ # File storage: s3 | gcs | azure-blob | null
59
+ storage: s3
60
+
61
+ # Full-text search: opensearch | meilisearch | null
62
+ search: opensearch
63
+
64
+ # Cache / session store: redis | memcached | null
65
+ cache: redis
66
+
67
+# ── Data ──────────────────────────────────────────────────────────────────────
68
+
69
+data:
70
+ # Primary database: postgres | mysql | sqlite
71
+ database: postgres
72
+
73
+ # Run migrations on first boot
74
+ migrations: true
75
+
76
+ # Load seed data on first boot
77
+ seed_data: true
78
+
79
+# ── Testing ───────────────────────────────────────────────────────────────────
80
+
81
+testing:
82
+ # End-to-end framework: playwright | cypress | null
83
+ e2e: playwright
84
+
85
+ # Unit tests
86
+ unit: true
87
+
88
+ # Integration tests
89
+ integration: true
90
+
91
+# ── Template versions (auto-managed, do not edit manually) ────────────────────
92
+
93
+template_versions: {}
--- a/boilerworks.yaml.example
+++ b/boilerworks.yaml.example
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/boilerworks.yaml.example
+++ b/boilerworks.yaml.example
@@ -0,0 +1,93 @@
1 # boilerworks.yaml — project manifest
2 #
3 # Generated by `boilerworks setup`. Edit by hand or regenerate at any time.
4 # All fields are validated against the registry and manifest schema.
5 # Run `boilerworks init` to generate the project from this file.
6
7 # ── Required ──────────────────────────────────────────────────────────────────
8
9 # Slug format: lowercase, start with a letter, letters/digits/hyphens only
10 project: my-app
11
12 # Template family name. Run `boilerworks list` to see all options.
13 family: django-nextjs
14
15 # Template size: full | micro | edge
16 size: full
17
18 # ── Topology ──────────────────────────────────────────────────────────────────
19
20 # standard: monorepo with web app and ops (default)
21 # api-only: backend only, no frontend
22 # omni: standard + mobile (v2)
23 topology: standard
24
25 # ── Cloud ─────────────────────────────────────────────────────────────────────
26
27 # Cloud provider: aws | gcp | azure | null
28 cloud: aws
29
30 # Cloud region (required if cloud is set)
31 region: us-east-1
32
33 # ── Domain ────────────────────────────────────────────────────────────────────
34
35 # Your production domain (optional)
36 domain: myapp.com
37
38 # ── Optional add-ons ──────────────────────────────────────────────────────────
39
40 # Include a mobile app template (Full size only)
41 mobile: false
42
43 # Include a web presence / marketing site template (Full size only)
44 web_presence: false
45
46 # ── Compliance ────────────────────────────────────────────────────────────────
47
48 # One or more: soc2 | hipaa | pci-dss | gdpr
49 compliance:
50 - soc2
51
52 # ── Services ──────────────────────────────────────────────────────────────────
53
54 services:
55 # Transactional email: ses | sendgrid | mailgun | null
56 email: ses
57
58 # File storage: s3 | gcs | azure-blob | null
59 storage: s3
60
61 # Full-text search: opensearch | meilisearch | null
62 search: opensearch
63
64 # Cache / session store: redis | memcached | null
65 cache: redis
66
67 # ── Data ──────────────────────────────────────────────────────────────────────
68
69 data:
70 # Primary database: postgres | mysql | sqlite
71 database: postgres
72
73 # Run migrations on first boot
74 migrations: true
75
76 # Load seed data on first boot
77 seed_data: true
78
79 # ── Testing ───────────────────────────────────────────────────────────────────
80
81 testing:
82 # End-to-end framework: playwright | cypress | null
83 e2e: playwright
84
85 # Unit tests
86 unit: true
87
88 # Integration tests
89 integration: true
90
91 # ── Template versions (auto-managed, do not edit manually) ────────────────────
92
93 template_versions: {}
--- a/boilerworks/__init__.py
+++ b/boilerworks/__init__.py
@@ -0,0 +1,3 @@
1
+"""Boilerworks — production-ready project templates."""
2
+
3
+__version__ = "0.1.0"
--- a/boilerworks/__init__.py
+++ b/boilerworks/__init__.py
@@ -0,0 +1,3 @@
 
 
 
--- a/boilerworks/__init__.py
+++ b/boilerworks/__init__.py
@@ -0,0 +1,3 @@
1 """Boilerworks — production-ready project templates."""
2
3 __version__ = "0.1.0"
--- a/boilerworks/bootstrap.py
+++ b/boilerworks/bootstrap.py
@@ -0,0 +1,43 @@
1
+"""Bootstrap command — infrastructure layer orchestration (v2 stub)."""
2
+
3
+from __future__ import annotations
4
+
5
+from pathlib import Path
6
+
7
+from rich.panel import Panel
8
+
9
+from boilerworks.console import console, print_info, print_warning
10
+
11
+_LAYERS = [
12
+ ("1", "Foundation", "VPC, subnets, security groups, IAM roles"),
13
+ ("2", "Data", "RDS (Postgres), ElastiCache (Redis), S3 buckets"),
14
+ ("3", "Compute", "ECS/GKE cluster, service definitions, autoscaling"),
15
+ ("4", "Delivery", "Load balancer, TLS certificates, CDN, DNS"),
16
+ ("5", "Observability", "Metrics, logs, alerts, dashboards"),
17
+]
18
+
19
+_V2_NOTICE = (
20
+ "Infrastructure bootstrapping is coming in v2.\n"
21
+ "For now, follow the ops template README to provision infrastructure manually.\n\n"
22
+ "Ops template: [bold]https://github.com/ConflictHQ/boilerworks-opscode[/bold]"
23
+)
24
+
25
+
26
+def run_bootstrap(ops_dir: str | None = None, dry_run: bool = True) -> None:
27
+ """Show the infrastructure bootstrap execution plan."""
28
+ if ops_dir:
29
+ ops_path = Path(ops_dir)
30
+ if not ops_path.exists():
31
+ print_warning(f"Ops directory not found: {ops_path}")
32
+ else:
33
+ print_info("No --ops-dir specified; showing generic plan")
34
+
35
+ # Build the plan panel
36
+ plan_lines = ["[bold]Execution plan — 5 layers[/bold]\n"]
37
+ for layer_num, layer_name, layer_desc in _LAYERS:
38
+ plan_lines.append(f" [cyan]{layer_num}.[/cyan] [bold]{layer_name}[/bold]")
39
+ plan_lines.append(f" {layer_desc}")
40
+ plan_lines.append("")
41
+
42
+ console.print(Panel("\n".join(plan_lines).rstrip(), title="[bold]Bootstrap Plan[/bold]", border_style="cyan"))
43
+ console.print(Panel(_V2_NOTICE, title="[bold yellow]v1 — Stub[/bold yellow]", border_style="yellow"))
--- a/boilerworks/bootstrap.py
+++ b/boilerworks/bootstrap.py
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/boilerworks/bootstrap.py
+++ b/boilerworks/bootstrap.py
@@ -0,0 +1,43 @@
1 """Bootstrap command — infrastructure layer orchestration (v2 stub)."""
2
3 from __future__ import annotations
4
5 from pathlib import Path
6
7 from rich.panel import Panel
8
9 from boilerworks.console import console, print_info, print_warning
10
11 _LAYERS = [
12 ("1", "Foundation", "VPC, subnets, security groups, IAM roles"),
13 ("2", "Data", "RDS (Postgres), ElastiCache (Redis), S3 buckets"),
14 ("3", "Compute", "ECS/GKE cluster, service definitions, autoscaling"),
15 ("4", "Delivery", "Load balancer, TLS certificates, CDN, DNS"),
16 ("5", "Observability", "Metrics, logs, alerts, dashboards"),
17 ]
18
19 _V2_NOTICE = (
20 "Infrastructure bootstrapping is coming in v2.\n"
21 "For now, follow the ops template README to provision infrastructure manually.\n\n"
22 "Ops template: [bold]https://github.com/ConflictHQ/boilerworks-opscode[/bold]"
23 )
24
25
26 def run_bootstrap(ops_dir: str | None = None, dry_run: bool = True) -> None:
27 """Show the infrastructure bootstrap execution plan."""
28 if ops_dir:
29 ops_path = Path(ops_dir)
30 if not ops_path.exists():
31 print_warning(f"Ops directory not found: {ops_path}")
32 else:
33 print_info("No --ops-dir specified; showing generic plan")
34
35 # Build the plan panel
36 plan_lines = ["[bold]Execution plan — 5 layers[/bold]\n"]
37 for layer_num, layer_name, layer_desc in _LAYERS:
38 plan_lines.append(f" [cyan]{layer_num}.[/cyan] [bold]{layer_name}[/bold]")
39 plan_lines.append(f" {layer_desc}")
40 plan_lines.append("")
41
42 console.print(Panel("\n".join(plan_lines).rstrip(), title="[bold]Bootstrap Plan[/bold]", border_style="cyan"))
43 console.print(Panel(_V2_NOTICE, title="[bold yellow]v1 — Stub[/bold yellow]", border_style="yellow"))
--- a/boilerworks/cli.py
+++ b/boilerworks/cli.py
@@ -0,0 +1,69 @@
1
+"""Boilerworks CLI — main entry point."""
2
+
3
+from __future__ import annotations
4
+
5
+import click
6
+
7
+from boilerworks import __version__
8
+
9
+
10
+@click.group()
11
+@click.version_option(__version__, prog_name="boilerworks")
12
+def main() -> None:
13
+ """Boilerworks — production-ready project templates."""
14
+
15
+
16
+@main.command()
17
+def setup() -> None:
18
+ """Interactive wizard → writes boilerworks.yaml."""
19
+ from boilerworks.wizard import run_wizard
20
+
21
+ run_wizard()
22
+
23
+
24
+@main.command(name="init")
25
+@click.option("--manifest", "manifest_path", default=None, help="Path to boilerworks.yaml")
26
+@click.option("--output", "output_dir", default=".", show_default=True, help="Output directory for generated project")
27
+@click.option("--dry-run", is_flag=True, default=False, help="Print what would happen without doing it")
28
+def init_command(manifest_path: str | None, output_dir: str, dry_run: bool) -> None:
29
+ """Clone a template and configure it from boilerworks.yaml."""
30
+ from boilerworks.generator import generate_from_manifest
31
+
32
+ generate_from_manifest(manifest_path=manifest_path, output_dir=output_dir, dry_run=dry_run)
33
+
34
+
35
+@main.command()
36
+@click.option("--ops-dir", default=None, help="Path to ops directory (default: ../{project}-ops)")
37
+@click.option("--dry-run", is_flag=True, default=True, help="Show execution plan (v1 only supports dry-run)")
38
+def bootstrap(ops_dir: str | None, dry_run: bool) -> None:
39
+ """Show infrastructure bootstrap plan (Terraform — v2)."""
40
+ from boilerworks.bootstrap import run_bootstrap
41
+
42
+ run_bootstrap(ops_dir=ops_dir, dry_run=dry_run)
43
+
44
+
45
+@main.command(name="list")
46
+@click.option("--size", type=click.Choice(["full", "micro", "edge"]), default=None, help="Filter by template size")
47
+@click.option(
48
+ "--language",
49
+ type=click.Choice(["python", "typescript", "ruby", "php", "java", "go", "elixir", "rust", "svelte"]),
50
+ default=None,
51
+ help="Filter by primary language",
52
+)
53
+@click.option("--status", type=click.Choice(["done", "building", "planned"]), default=None, help="Filter by status")
54
+def list_command(size: str | None, language: str | None, status: str | None) -> None:
55
+ """List all available templates."""
56
+ from boilerworks.console import print_template_table
57
+ from boilerworks.registry import Registry
58
+
59
+ registry = Registry()
60
+ templates = registry.list_all()
61
+
62
+ if size:
63
+ templates = [t for t in templates if t.size == size]
64
+ if language:
65
+ templates = [t for t in templates if t.language == language]
66
+ if status:
67
+ templates = [t for t in templates if t.status == status]
68
+
69
+ print_template_table(templates)
--- a/boilerworks/cli.py
+++ b/boilerworks/cli.py
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/boilerworks/cli.py
+++ b/boilerworks/cli.py
@@ -0,0 +1,69 @@
1 """Boilerworks CLI — main entry point."""
2
3 from __future__ import annotations
4
5 import click
6
7 from boilerworks import __version__
8
9
10 @click.group()
11 @click.version_option(__version__, prog_name="boilerworks")
12 def main() -> None:
13 """Boilerworks — production-ready project templates."""
14
15
16 @main.command()
17 def setup() -> None:
18 """Interactive wizard → writes boilerworks.yaml."""
19 from boilerworks.wizard import run_wizard
20
21 run_wizard()
22
23
24 @main.command(name="init")
25 @click.option("--manifest", "manifest_path", default=None, help="Path to boilerworks.yaml")
26 @click.option("--output", "output_dir", default=".", show_default=True, help="Output directory for generated project")
27 @click.option("--dry-run", is_flag=True, default=False, help="Print what would happen without doing it")
28 def init_command(manifest_path: str | None, output_dir: str, dry_run: bool) -> None:
29 """Clone a template and configure it from boilerworks.yaml."""
30 from boilerworks.generator import generate_from_manifest
31
32 generate_from_manifest(manifest_path=manifest_path, output_dir=output_dir, dry_run=dry_run)
33
34
35 @main.command()
36 @click.option("--ops-dir", default=None, help="Path to ops directory (default: ../{project}-ops)")
37 @click.option("--dry-run", is_flag=True, default=True, help="Show execution plan (v1 only supports dry-run)")
38 def bootstrap(ops_dir: str | None, dry_run: bool) -> None:
39 """Show infrastructure bootstrap plan (Terraform — v2)."""
40 from boilerworks.bootstrap import run_bootstrap
41
42 run_bootstrap(ops_dir=ops_dir, dry_run=dry_run)
43
44
45 @main.command(name="list")
46 @click.option("--size", type=click.Choice(["full", "micro", "edge"]), default=None, help="Filter by template size")
47 @click.option(
48 "--language",
49 type=click.Choice(["python", "typescript", "ruby", "php", "java", "go", "elixir", "rust", "svelte"]),
50 default=None,
51 help="Filter by primary language",
52 )
53 @click.option("--status", type=click.Choice(["done", "building", "planned"]), default=None, help="Filter by status")
54 def list_command(size: str | None, language: str | None, status: str | None) -> None:
55 """List all available templates."""
56 from boilerworks.console import print_template_table
57 from boilerworks.registry import Registry
58
59 registry = Registry()
60 templates = registry.list_all()
61
62 if size:
63 templates = [t for t in templates if t.size == size]
64 if language:
65 templates = [t for t in templates if t.language == language]
66 if status:
67 templates = [t for t in templates if t.status == status]
68
69 print_template_table(templates)
--- a/boilerworks/console.py
+++ b/boilerworks/console.py
@@ -0,0 +1,106 @@
1
+"""Rich output helpers for the Boilerworks CLI."""
2
+
3
+from __future__ import annotations
4
+
5
+from rich.console import Console
6
+from rich.panel import Panel
7
+from rich.table import Table
8
+from rich.text import Text
9
+
10
+from boilerworks.registry import TemplateInfo
11
+
12
+console = Console()
13
+
14
+
15
+_STATUS_STYLE: dict[str, str] = {
16
+ "done": "bold green",
17
+ "building": "bold yellow",
18
+ "planned": "dim",
19
+}
20
+
21
+_SIZE_STYLE: dict[str, str] = {
22
+ "full": "cyan",
23
+ "micro": "magenta",
24
+ "edge": "blue",
25
+}
26
+
27
+
28
+def _status_badge(status: str) -> Text:
29
+ style = _STATUS_STYLE.get(status, "")
30
+ labels = {"done": "● done", "building": "◐ building", "planned": "○ planned"}
31
+ return Text(labels.get(status, status), style=style)
32
+
33
+
34
+def print_template_table(templates: list[TemplateInfo]) -> None:
35
+ """Print a Rich table of templates."""
36
+ if not templates:
37
+ console.print("[yellow]No templates match your filters.[/yellow]")
38
+ return
39
+
40
+ table = Table(
41
+ title=f"Boilerworks Templates ({len(templates)})",
42
+ show_lines=False,
43
+ header_style="bold white",
44
+ border_style="dim",
45
+ expand=False,
46
+ )
47
+
48
+ table.add_column("Name", style="bold", min_width=20)
49
+ table.add_column("Size", min_width=6)
50
+ table.add_column("Language", min_width=11)
51
+ table.add_column("Backend", min_width=16)
52
+ table.add_column("Frontend", min_width=16)
53
+ table.add_column("Status", min_width=13)
54
+ table.add_column("Description", min_width=30)
55
+
56
+ for t in templates:
57
+ size_text = Text(t.size, style=_SIZE_STYLE.get(t.size, ""))
58
+ table.add_row(
59
+ t.name,
60
+ size_text,
61
+ t.language,
62
+ t.backend,
63
+ t.frontend if t.frontend else "—",
64
+ _status_badge(t.status),
65
+ t.description,
66
+ )
67
+
68
+ console.print(table)
69
+
70
+
71
+def print_template_detail(template: TemplateInfo) -> None:
72
+ """Print a Rich panel with full template details."""
73
+ lines = [
74
+ f"[bold]Name:[/bold] {template.name}",
75
+ f"[bold]Repo:[/bold] {template.repo}",
76
+ f"[bold]Size:[/bold] {template.size}",
77
+ f"[bold]Language:[/bold] {template.language}",
78
+ f"[bold]Backend:[/bold] {template.backend}",
79
+ f"[bold]Frontend:[/bold] {template.frontend or '—'}",
80
+ f"[bold]Status:[/bold] {template.status}",
81
+ f"[bold]Topologies:[/bold] {', '.join(template.topologies)}",
82
+ "",
83
+ f"[bold]Best for:[/bold] {template.best_for}",
84
+ f"[bold]Description:[/bold] {template.description}",
85
+ ]
86
+ console.print(Panel("\n".join(lines), title=f"[bold cyan]{template.name}[/bold cyan]", border_style="cyan"))
87
+
88
+
89
+def print_success(message: str) -> None:
90
+ """Print a success message."""
91
+ console.print(f"[bold green]✓[/bold green] {message}")
92
+
93
+
94
+def print_error(message: str) -> None:
95
+ """Print an error message."""
96
+ console.print(f"[bold red]✗[/bold red] {message}")
97
+
98
+
99
+def print_info(message: str) -> None:
100
+ """Print an informational message."""
101
+ console.print(f"[dim]→[/dim] {message}")
102
+
103
+
104
+def print_warning(message: str) -> None:
105
+ """Print a warning message."""
106
+ console.print(f"[bold yellow]![/bold yellow] {message}")
--- a/boilerworks/console.py
+++ b/boilerworks/console.py
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/boilerworks/console.py
+++ b/boilerworks/console.py
@@ -0,0 +1,106 @@
1 """Rich output helpers for the Boilerworks CLI."""
2
3 from __future__ import annotations
4
5 from rich.console import Console
6 from rich.panel import Panel
7 from rich.table import Table
8 from rich.text import Text
9
10 from boilerworks.registry import TemplateInfo
11
12 console = Console()
13
14
15 _STATUS_STYLE: dict[str, str] = {
16 "done": "bold green",
17 "building": "bold yellow",
18 "planned": "dim",
19 }
20
21 _SIZE_STYLE: dict[str, str] = {
22 "full": "cyan",
23 "micro": "magenta",
24 "edge": "blue",
25 }
26
27
28 def _status_badge(status: str) -> Text:
29 style = _STATUS_STYLE.get(status, "")
30 labels = {"done": "● done", "building": "◐ building", "planned": "○ planned"}
31 return Text(labels.get(status, status), style=style)
32
33
34 def print_template_table(templates: list[TemplateInfo]) -> None:
35 """Print a Rich table of templates."""
36 if not templates:
37 console.print("[yellow]No templates match your filters.[/yellow]")
38 return
39
40 table = Table(
41 title=f"Boilerworks Templates ({len(templates)})",
42 show_lines=False,
43 header_style="bold white",
44 border_style="dim",
45 expand=False,
46 )
47
48 table.add_column("Name", style="bold", min_width=20)
49 table.add_column("Size", min_width=6)
50 table.add_column("Language", min_width=11)
51 table.add_column("Backend", min_width=16)
52 table.add_column("Frontend", min_width=16)
53 table.add_column("Status", min_width=13)
54 table.add_column("Description", min_width=30)
55
56 for t in templates:
57 size_text = Text(t.size, style=_SIZE_STYLE.get(t.size, ""))
58 table.add_row(
59 t.name,
60 size_text,
61 t.language,
62 t.backend,
63 t.frontend if t.frontend else "—",
64 _status_badge(t.status),
65 t.description,
66 )
67
68 console.print(table)
69
70
71 def print_template_detail(template: TemplateInfo) -> None:
72 """Print a Rich panel with full template details."""
73 lines = [
74 f"[bold]Name:[/bold] {template.name}",
75 f"[bold]Repo:[/bold] {template.repo}",
76 f"[bold]Size:[/bold] {template.size}",
77 f"[bold]Language:[/bold] {template.language}",
78 f"[bold]Backend:[/bold] {template.backend}",
79 f"[bold]Frontend:[/bold] {template.frontend or '—'}",
80 f"[bold]Status:[/bold] {template.status}",
81 f"[bold]Topologies:[/bold] {', '.join(template.topologies)}",
82 "",
83 f"[bold]Best for:[/bold] {template.best_for}",
84 f"[bold]Description:[/bold] {template.description}",
85 ]
86 console.print(Panel("\n".join(lines), title=f"[bold cyan]{template.name}[/bold cyan]", border_style="cyan"))
87
88
89 def print_success(message: str) -> None:
90 """Print a success message."""
91 console.print(f"[bold green]✓[/bold green] {message}")
92
93
94 def print_error(message: str) -> None:
95 """Print an error message."""
96 console.print(f"[bold red]✗[/bold red] {message}")
97
98
99 def print_info(message: str) -> None:
100 """Print an informational message."""
101 console.print(f"[dim]→[/dim] {message}")
102
103
104 def print_warning(message: str) -> None:
105 """Print a warning message."""
106 console.print(f"[bold yellow]![/bold yellow] {message}")
--- a/boilerworks/generator.py
+++ b/boilerworks/generator.py
@@ -0,0 +1,354 @@
1
+"""Project generator — clone → render → rename → git init."""
2
+
3
+from __future__ import annotations
4
+
5
+import shutil
6
+import subprocess
7
+import sys
8
+from pathlib import Path
9
+
10
+from rich.progress import Progress, SpinnerColumn, TextColumn
11
+
12
+from boilerworks.console import console, print_error, print_info, print_success
13
+from boilerworks.manifest import BoilerworksManifest
14
+from boilerworks.registry import Registry
15
+from boilerworks.renderer import build_replacements, rename_boilerworks_paths, render_directory
16
+
17
+_OPS_REPO = "ConflictHQ/boilerworks-opscode"
18
+
19
+_NEXT_STEPS_TEMPLATE = """[bold]Project created at:[/bold] {project_dir}
20
+
21
+[bold]Next steps:[/bold]
22
+ cd {project}
23
+ docker compose up -d
24
+ # Visit http://localhost:3000
25
+
26
+[bold]Documentation:[/bold]
27
+ bootstrap.md — conventions
28
+ CLAUDE.md — AI agent guide
29
+"""
30
+
31
+_NEXT_STEPS_OPS_STANDARD = """[bold]Infrastructure repo:[/bold] {ops_dir}
32
+
33
+[bold]Infrastructure next steps:[/bold]
34
+ cd {ops_name}
35
+ # Edit {cloud}/config.env to review settings
36
+ ./run.sh bootstrap {cloud} dev
37
+ ./run.sh plan {cloud} dev
38
+
39
+[bold]Documentation:[/bold]
40
+ bootstrap.md — Terraform conventions and setup
41
+"""
42
+
43
+_NEXT_STEPS_OPS_OMNI = """[bold]Infrastructure (ops/) is inside the app repo[/bold]
44
+
45
+[bold]Infrastructure next steps:[/bold]
46
+ cd {project}/ops
47
+ # Edit {cloud}/config.env to review settings
48
+ ./run.sh bootstrap {cloud} dev
49
+ ./run.sh plan {cloud} dev
50
+
51
+[bold]Documentation:[/bold]
52
+ ops/bootstrap.md — Terraform conventions and setup
53
+"""
54
+
55
+
56
+def _clone_repo(repo: str, dest: Path) -> None:
57
+ """Clone repo to dest. Tries SSH first, falls back to HTTPS."""
58
+ ssh_url = f"[email protected]:{repo}.git"
59
+ https_url = f"https://github.com/{repo}.git"
60
+
61
+ result = subprocess.run(
62
+ ["git", "clone", "--depth", "1", ssh_url, str(dest)],
63
+ capture_output=True,
64
+ text=True,
65
+ )
66
+ if result.returncode != 0:
67
+ # SSH failed — try HTTPS
68
+ result = subprocess.run(
69
+ ["git", "clone", "--depth", "1", https_url, str(dest)],
70
+ capture_output=True,
71
+ text=True,
72
+ )
73
+ if result.returncode != 0:
74
+ raise RuntimeError(
75
+ f"Failed to clone {repo}.\n"
76
+ f"SSH error: {result.stderr.strip()}\n"
77
+ "Ensure you have GitHub access (SSH key or gh auth login)."
78
+ )
79
+
80
+
81
+def _remove_git_dir(project_dir: Path) -> None:
82
+ git_dir = project_dir / ".git"
83
+ if git_dir.exists():
84
+ shutil.rmtree(git_dir)
85
+
86
+
87
+def _git_init(project_dir: Path, family: str) -> None:
88
+ """Initialise a fresh git repo and make the initial commit."""
89
+ subprocess.run(["git", "init"], cwd=project_dir, capture_output=True)
90
+ subprocess.run(["git", "add", "."], cwd=project_dir, capture_output=True)
91
+ subprocess.run(
92
+ ["git", "commit", "-m", f"Initial project from boilerworks-{family}"],
93
+ cwd=project_dir,
94
+ capture_output=True,
95
+ )
96
+
97
+
98
+def _git_add_commit(project_dir: Path, message: str) -> None:
99
+ """Stage all and commit in an existing repo."""
100
+ subprocess.run(["git", "add", "."], cwd=project_dir, capture_output=True)
101
+ subprocess.run(
102
+ ["git", "commit", "-m", message],
103
+ cwd=project_dir,
104
+ capture_output=True,
105
+ )
106
+
107
+
108
+def _write_ops_config(ops_dir: Path, cloud: str, project: str, region: str | None, domain: str | None) -> None:
109
+ """Write {cloud}/config.env with project-specific values."""
110
+ config_path = ops_dir / cloud / "config.env"
111
+ if not config_path.exists():
112
+ return
113
+
114
+ region_default = {"aws": "us-east-1", "gcp": "us-central1", "azure": "eastus"}.get(cloud, "us-east-1")
115
+ effective_region = region or region_default
116
+
117
+ lines = [
118
+ "# -----------------------------------------------------------------------------",
119
+ f"# {project} — {cloud.upper()} Configuration",
120
+ "#",
121
+ "# Shared configuration for all environments. Sourced by run.sh and bootstrap.sh.",
122
+ "# Review and update before running bootstrap.",
123
+ "# -----------------------------------------------------------------------------",
124
+ "",
125
+ "# Project slug — used in all resource naming",
126
+ f'PROJECT="{project}"',
127
+ "",
128
+ f"# {cloud.upper()} region",
129
+ ]
130
+
131
+ if cloud == "aws":
132
+ lines.append(f'AWS_REGION="{effective_region}"')
133
+ elif cloud == "gcp":
134
+ lines.append(f'GCP_REGION="{effective_region}"')
135
+ elif cloud == "azure":
136
+ lines.append(f'AZURE_REGION="{effective_region}"')
137
+
138
+ lines += [
139
+ "",
140
+ "# Owner tag",
141
+ f'OWNER="{project}"',
142
+ ]
143
+
144
+ if domain:
145
+ lines += [
146
+ "",
147
+ "# Domain",
148
+ f'DOMAIN="{domain}"',
149
+ ]
150
+
151
+ config_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
152
+
153
+
154
+def _clone_and_render_ops(
155
+ project: str,
156
+ cloud: str,
157
+ region: str | None,
158
+ domain: str | None,
159
+ dest: Path,
160
+ progress: Progress,
161
+) -> None:
162
+ """Clone boilerworks-opscode, render, rename, and configure for this project."""
163
+ task = progress.add_task(f"Cloning {_OPS_REPO}…", total=None)
164
+ try:
165
+ _clone_repo(_OPS_REPO, dest)
166
+ except RuntimeError as exc:
167
+ progress.stop()
168
+ print_error(str(exc))
169
+ sys.exit(1)
170
+ progress.remove_task(task)
171
+
172
+ task = progress.add_task("Removing .git/ from ops…", total=None)
173
+ _remove_git_dir(dest)
174
+ progress.remove_task(task)
175
+
176
+ task = progress.add_task("Applying ops substitutions…", total=None)
177
+ replacements = build_replacements(project)
178
+ render_directory(dest, replacements)
179
+ progress.remove_task(task)
180
+
181
+ task = progress.add_task("Renaming ops paths…", total=None)
182
+ rename_boilerworks_paths(dest, project)
183
+ progress.remove_task(task)
184
+
185
+ task = progress.add_task("Configuring ops for your project…", total=None)
186
+ _write_ops_config(dest, cloud, project, region, domain)
187
+ progress.remove_task(task)
188
+
189
+
190
+def _dry_run_plan(manifest: BoilerworksManifest, output_dir: Path) -> None:
191
+ """Print what would happen without doing it."""
192
+ registry = Registry()
193
+ template = registry.get_by_name(manifest.family)
194
+ repo = template.repo if template else f"ConflictHQ/boilerworks-{manifest.family}"
195
+ project_dir = output_dir / manifest.project
196
+
197
+ console.print("\n[bold]Dry run — no files will be written[/bold]\n")
198
+ steps = [
199
+ f"[dim]1.[/dim] Clone [cyan]{repo}[/cyan]",
200
+ "[dim]2.[/dim] Remove .git/ from cloned directory",
201
+ f"[dim]3.[/dim] Replace all 'boilerworks' → '[bold]{manifest.project}[/bold]' (case-variant)",
202
+ "[dim]4.[/dim] Rename files/dirs containing 'boilerworks'",
203
+ "[dim]5.[/dim] Update CLAUDE.md and README.md headers",
204
+ f"[dim]6.[/dim] git init + initial commit in [bold]{project_dir}[/bold]",
205
+ ]
206
+ if manifest.ops and manifest.cloud:
207
+ ops_dest = f"{project_dir}/ops/" if manifest.topology == "omni" else str(output_dir / f"{manifest.project}-ops")
208
+ steps += [
209
+ f"[dim]7.[/dim] Clone [cyan]{_OPS_REPO}[/cyan] → [bold]{ops_dest}[/bold]",
210
+ f"[dim]8.[/dim] Render + rename ops files (boilerworks → {manifest.project})",
211
+ f"[dim]9.[/dim] Write [cyan]{manifest.cloud}/config.env[/cyan] (project, region, domain)",
212
+ ]
213
+ if manifest.topology == "omni":
214
+ steps.append("[dim]10.[/dim] Recommit app repo to include ops/")
215
+ else:
216
+ steps.append(f"[dim]10.[/dim] git init ops repo in [bold]{ops_dest}[/bold]")
217
+
218
+ if manifest.mobile:
219
+ steps.append(f"[dim]{len(steps) + 1}.[/dim] Clone mobile template")
220
+
221
+ for step in steps:
222
+ console.print(f" {step}")
223
+ console.print(f"\n[dim]Output directory:[/dim] {project_dir}")
224
+
225
+
226
+def generate_from_manifest(
227
+ manifest_path: str | None,
228
+ output_dir: str = ".",
229
+ dry_run: bool = False,
230
+) -> None:
231
+ """Entry point called from the CLI."""
232
+ manifest_file = Path(manifest_path) if manifest_path else Path("boilerworks.yaml")
233
+
234
+ if not manifest_file.exists():
235
+ print_error(f"Manifest not found: {manifest_file}")
236
+ print_info("Run [bold]boilerworks setup[/bold] first to create boilerworks.yaml")
237
+ sys.exit(1)
238
+
239
+ try:
240
+ manifest = BoilerworksManifest.from_file(manifest_file)
241
+ except Exception as exc:
242
+ print_error(f"Invalid manifest: {exc}")
243
+ sys.exit(1)
244
+
245
+ out = Path(output_dir).resolve()
246
+
247
+ if dry_run:
248
+ _dry_run_plan(manifest, out)
249
+ return
250
+
251
+ _generate(manifest, out)
252
+
253
+
254
+def _generate(manifest: BoilerworksManifest, output_dir: Path) -> None:
255
+ registry = Registry()
256
+ template = registry.get_by_name(manifest.family)
257
+ if template is None:
258
+ print_error(f"Unknown template family: {manifest.family}")
259
+ sys.exit(1)
260
+
261
+ project_dir = output_dir / manifest.project
262
+
263
+ if project_dir.exists():
264
+ print_error(f"Directory already exists: {project_dir}")
265
+ print_info("Delete it first or choose a different output directory.")
266
+ sys.exit(1)
267
+
268
+ replacements = build_replacements(manifest.project)
269
+
270
+ with Progress(SpinnerColumn(), TextColumn("[progress.description]{task.description}"), transient=True) as progress:
271
+ # ── App template ──────────────────────────────────────────────────────
272
+ task = progress.add_task(f"Cloning {template.repo}…", total=None)
273
+ try:
274
+ _clone_repo(template.repo, project_dir)
275
+ except RuntimeError as exc:
276
+ progress.stop()
277
+ print_error(str(exc))
278
+ sys.exit(1)
279
+ progress.remove_task(task)
280
+
281
+ task = progress.add_task("Removing .git/…", total=None)
282
+ _remove_git_dir(project_dir)
283
+ progress.remove_task(task)
284
+
285
+ task = progress.add_task("Applying template substitutions…", total=None)
286
+ render_directory(project_dir, replacements)
287
+ progress.remove_task(task)
288
+
289
+ task = progress.add_task("Renaming paths…", total=None)
290
+ rename_boilerworks_paths(project_dir, manifest.project)
291
+ progress.remove_task(task)
292
+
293
+ # ── Ops (infra-as-code) ───────────────────────────────────────────────
294
+ ops_dir: Path | None = None
295
+ if manifest.ops and manifest.cloud:
296
+ if manifest.topology == "omni":
297
+ # Ops lives inside the app repo
298
+ ops_dir = project_dir / "ops"
299
+ _clone_and_render_ops(
300
+ manifest.project, manifest.cloud, manifest.region, manifest.domain, ops_dir, progress
301
+ )
302
+ # git init + commit includes ops/
303
+ task = progress.add_task("Initialising git repository (app + ops)…", total=None)
304
+ _git_init(project_dir, manifest.family)
305
+ progress.remove_task(task)
306
+ else:
307
+ # Standard: app and ops are sibling repos
308
+ task = progress.add_task("Initialising app git repository…", total=None)
309
+ _git_init(project_dir, manifest.family)
310
+ progress.remove_task(task)
311
+
312
+ ops_dir = output_dir / f"{manifest.project}-ops"
313
+ if ops_dir.exists():
314
+ print_error(f"Ops directory already exists: {ops_dir}")
315
+ print_info("Delete it first or choose a different output directory.")
316
+ sys.exit(1)
317
+ _clone_and_render_ops(
318
+ manifest.project, manifest.cloud, manifest.region, manifest.domain, ops_dir, progress
319
+ )
320
+ task = progress.add_task("Initialising ops git repository…", total=None)
321
+ _git_init(ops_dir, "opscode")
322
+ progress.remove_task(task)
323
+ else:
324
+ # No ops — just init app
325
+ task = progress.add_task("Initialising git repository…", total=None)
326
+ _git_init(project_dir, manifest.family)
327
+ progress.remove_task(task)
328
+
329
+ print_success(f"Project [bold]{manifest.project}[/bold] created at [bold]{project_dir}[/bold]")
330
+
331
+ from rich.panel import Panel
332
+
333
+ next_steps = _NEXT_STEPS_TEMPLATE.format(project=manifest.project, project_dir=project_dir)
334
+
335
+ if manifest.ops and manifest.cloud and ops_dir is not None:
336
+ if manifest.topology == "omni":
337
+ next_steps += _NEXT_STEPS_OPS_OMNI.format(
338
+ project=manifest.project,
339
+ cloud=manifest.cloud,
340
+ )
341
+ else:
342
+ next_steps += _NEXT_STEPS_OPS_STANDARD.format(
343
+ ops_dir=ops_dir,
344
+ ops_name=f"{manifest.project}-ops",
345
+ cloud=manifest.cloud,
346
+ )
347
+
348
+ console.print(
349
+ Panel(
350
+ next_steps.strip(),
351
+ title="[bold green]Done![/bold green]",
352
+ border_style="green",
353
+ )
354
+ )
--- a/boilerworks/generator.py
+++ b/boilerworks/generator.py
@@ -0,0 +1,354 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/boilerworks/generator.py
+++ b/boilerworks/generator.py
@@ -0,0 +1,354 @@
1 """Project generator — clone → render → rename → git init."""
2
3 from __future__ import annotations
4
5 import shutil
6 import subprocess
7 import sys
8 from pathlib import Path
9
10 from rich.progress import Progress, SpinnerColumn, TextColumn
11
12 from boilerworks.console import console, print_error, print_info, print_success
13 from boilerworks.manifest import BoilerworksManifest
14 from boilerworks.registry import Registry
15 from boilerworks.renderer import build_replacements, rename_boilerworks_paths, render_directory
16
17 _OPS_REPO = "ConflictHQ/boilerworks-opscode"
18
19 _NEXT_STEPS_TEMPLATE = """[bold]Project created at:[/bold] {project_dir}
20
21 [bold]Next steps:[/bold]
22 cd {project}
23 docker compose up -d
24 # Visit http://localhost:3000
25
26 [bold]Documentation:[/bold]
27 bootstrap.md — conventions
28 CLAUDE.md — AI agent guide
29 """
30
31 _NEXT_STEPS_OPS_STANDARD = """[bold]Infrastructure repo:[/bold] {ops_dir}
32
33 [bold]Infrastructure next steps:[/bold]
34 cd {ops_name}
35 # Edit {cloud}/config.env to review settings
36 ./run.sh bootstrap {cloud} dev
37 ./run.sh plan {cloud} dev
38
39 [bold]Documentation:[/bold]
40 bootstrap.md — Terraform conventions and setup
41 """
42
43 _NEXT_STEPS_OPS_OMNI = """[bold]Infrastructure (ops/) is inside the app repo[/bold]
44
45 [bold]Infrastructure next steps:[/bold]
46 cd {project}/ops
47 # Edit {cloud}/config.env to review settings
48 ./run.sh bootstrap {cloud} dev
49 ./run.sh plan {cloud} dev
50
51 [bold]Documentation:[/bold]
52 ops/bootstrap.md — Terraform conventions and setup
53 """
54
55
56 def _clone_repo(repo: str, dest: Path) -> None:
57 """Clone repo to dest. Tries SSH first, falls back to HTTPS."""
58 ssh_url = f"[email protected]:{repo}.git"
59 https_url = f"https://github.com/{repo}.git"
60
61 result = subprocess.run(
62 ["git", "clone", "--depth", "1", ssh_url, str(dest)],
63 capture_output=True,
64 text=True,
65 )
66 if result.returncode != 0:
67 # SSH failed — try HTTPS
68 result = subprocess.run(
69 ["git", "clone", "--depth", "1", https_url, str(dest)],
70 capture_output=True,
71 text=True,
72 )
73 if result.returncode != 0:
74 raise RuntimeError(
75 f"Failed to clone {repo}.\n"
76 f"SSH error: {result.stderr.strip()}\n"
77 "Ensure you have GitHub access (SSH key or gh auth login)."
78 )
79
80
81 def _remove_git_dir(project_dir: Path) -> None:
82 git_dir = project_dir / ".git"
83 if git_dir.exists():
84 shutil.rmtree(git_dir)
85
86
87 def _git_init(project_dir: Path, family: str) -> None:
88 """Initialise a fresh git repo and make the initial commit."""
89 subprocess.run(["git", "init"], cwd=project_dir, capture_output=True)
90 subprocess.run(["git", "add", "."], cwd=project_dir, capture_output=True)
91 subprocess.run(
92 ["git", "commit", "-m", f"Initial project from boilerworks-{family}"],
93 cwd=project_dir,
94 capture_output=True,
95 )
96
97
98 def _git_add_commit(project_dir: Path, message: str) -> None:
99 """Stage all and commit in an existing repo."""
100 subprocess.run(["git", "add", "."], cwd=project_dir, capture_output=True)
101 subprocess.run(
102 ["git", "commit", "-m", message],
103 cwd=project_dir,
104 capture_output=True,
105 )
106
107
108 def _write_ops_config(ops_dir: Path, cloud: str, project: str, region: str | None, domain: str | None) -> None:
109 """Write {cloud}/config.env with project-specific values."""
110 config_path = ops_dir / cloud / "config.env"
111 if not config_path.exists():
112 return
113
114 region_default = {"aws": "us-east-1", "gcp": "us-central1", "azure": "eastus"}.get(cloud, "us-east-1")
115 effective_region = region or region_default
116
117 lines = [
118 "# -----------------------------------------------------------------------------",
119 f"# {project} — {cloud.upper()} Configuration",
120 "#",
121 "# Shared configuration for all environments. Sourced by run.sh and bootstrap.sh.",
122 "# Review and update before running bootstrap.",
123 "# -----------------------------------------------------------------------------",
124 "",
125 "# Project slug — used in all resource naming",
126 f'PROJECT="{project}"',
127 "",
128 f"# {cloud.upper()} region",
129 ]
130
131 if cloud == "aws":
132 lines.append(f'AWS_REGION="{effective_region}"')
133 elif cloud == "gcp":
134 lines.append(f'GCP_REGION="{effective_region}"')
135 elif cloud == "azure":
136 lines.append(f'AZURE_REGION="{effective_region}"')
137
138 lines += [
139 "",
140 "# Owner tag",
141 f'OWNER="{project}"',
142 ]
143
144 if domain:
145 lines += [
146 "",
147 "# Domain",
148 f'DOMAIN="{domain}"',
149 ]
150
151 config_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
152
153
154 def _clone_and_render_ops(
155 project: str,
156 cloud: str,
157 region: str | None,
158 domain: str | None,
159 dest: Path,
160 progress: Progress,
161 ) -> None:
162 """Clone boilerworks-opscode, render, rename, and configure for this project."""
163 task = progress.add_task(f"Cloning {_OPS_REPO}…", total=None)
164 try:
165 _clone_repo(_OPS_REPO, dest)
166 except RuntimeError as exc:
167 progress.stop()
168 print_error(str(exc))
169 sys.exit(1)
170 progress.remove_task(task)
171
172 task = progress.add_task("Removing .git/ from ops…", total=None)
173 _remove_git_dir(dest)
174 progress.remove_task(task)
175
176 task = progress.add_task("Applying ops substitutions…", total=None)
177 replacements = build_replacements(project)
178 render_directory(dest, replacements)
179 progress.remove_task(task)
180
181 task = progress.add_task("Renaming ops paths…", total=None)
182 rename_boilerworks_paths(dest, project)
183 progress.remove_task(task)
184
185 task = progress.add_task("Configuring ops for your project…", total=None)
186 _write_ops_config(dest, cloud, project, region, domain)
187 progress.remove_task(task)
188
189
190 def _dry_run_plan(manifest: BoilerworksManifest, output_dir: Path) -> None:
191 """Print what would happen without doing it."""
192 registry = Registry()
193 template = registry.get_by_name(manifest.family)
194 repo = template.repo if template else f"ConflictHQ/boilerworks-{manifest.family}"
195 project_dir = output_dir / manifest.project
196
197 console.print("\n[bold]Dry run — no files will be written[/bold]\n")
198 steps = [
199 f"[dim]1.[/dim] Clone [cyan]{repo}[/cyan]",
200 "[dim]2.[/dim] Remove .git/ from cloned directory",
201 f"[dim]3.[/dim] Replace all 'boilerworks' → '[bold]{manifest.project}[/bold]' (case-variant)",
202 "[dim]4.[/dim] Rename files/dirs containing 'boilerworks'",
203 "[dim]5.[/dim] Update CLAUDE.md and README.md headers",
204 f"[dim]6.[/dim] git init + initial commit in [bold]{project_dir}[/bold]",
205 ]
206 if manifest.ops and manifest.cloud:
207 ops_dest = f"{project_dir}/ops/" if manifest.topology == "omni" else str(output_dir / f"{manifest.project}-ops")
208 steps += [
209 f"[dim]7.[/dim] Clone [cyan]{_OPS_REPO}[/cyan] → [bold]{ops_dest}[/bold]",
210 f"[dim]8.[/dim] Render + rename ops files (boilerworks → {manifest.project})",
211 f"[dim]9.[/dim] Write [cyan]{manifest.cloud}/config.env[/cyan] (project, region, domain)",
212 ]
213 if manifest.topology == "omni":
214 steps.append("[dim]10.[/dim] Recommit app repo to include ops/")
215 else:
216 steps.append(f"[dim]10.[/dim] git init ops repo in [bold]{ops_dest}[/bold]")
217
218 if manifest.mobile:
219 steps.append(f"[dim]{len(steps) + 1}.[/dim] Clone mobile template")
220
221 for step in steps:
222 console.print(f" {step}")
223 console.print(f"\n[dim]Output directory:[/dim] {project_dir}")
224
225
226 def generate_from_manifest(
227 manifest_path: str | None,
228 output_dir: str = ".",
229 dry_run: bool = False,
230 ) -> None:
231 """Entry point called from the CLI."""
232 manifest_file = Path(manifest_path) if manifest_path else Path("boilerworks.yaml")
233
234 if not manifest_file.exists():
235 print_error(f"Manifest not found: {manifest_file}")
236 print_info("Run [bold]boilerworks setup[/bold] first to create boilerworks.yaml")
237 sys.exit(1)
238
239 try:
240 manifest = BoilerworksManifest.from_file(manifest_file)
241 except Exception as exc:
242 print_error(f"Invalid manifest: {exc}")
243 sys.exit(1)
244
245 out = Path(output_dir).resolve()
246
247 if dry_run:
248 _dry_run_plan(manifest, out)
249 return
250
251 _generate(manifest, out)
252
253
254 def _generate(manifest: BoilerworksManifest, output_dir: Path) -> None:
255 registry = Registry()
256 template = registry.get_by_name(manifest.family)
257 if template is None:
258 print_error(f"Unknown template family: {manifest.family}")
259 sys.exit(1)
260
261 project_dir = output_dir / manifest.project
262
263 if project_dir.exists():
264 print_error(f"Directory already exists: {project_dir}")
265 print_info("Delete it first or choose a different output directory.")
266 sys.exit(1)
267
268 replacements = build_replacements(manifest.project)
269
270 with Progress(SpinnerColumn(), TextColumn("[progress.description]{task.description}"), transient=True) as progress:
271 # ── App template ──────────────────────────────────────────────────────
272 task = progress.add_task(f"Cloning {template.repo}…", total=None)
273 try:
274 _clone_repo(template.repo, project_dir)
275 except RuntimeError as exc:
276 progress.stop()
277 print_error(str(exc))
278 sys.exit(1)
279 progress.remove_task(task)
280
281 task = progress.add_task("Removing .git/…", total=None)
282 _remove_git_dir(project_dir)
283 progress.remove_task(task)
284
285 task = progress.add_task("Applying template substitutions…", total=None)
286 render_directory(project_dir, replacements)
287 progress.remove_task(task)
288
289 task = progress.add_task("Renaming paths…", total=None)
290 rename_boilerworks_paths(project_dir, manifest.project)
291 progress.remove_task(task)
292
293 # ── Ops (infra-as-code) ───────────────────────────────────────────────
294 ops_dir: Path | None = None
295 if manifest.ops and manifest.cloud:
296 if manifest.topology == "omni":
297 # Ops lives inside the app repo
298 ops_dir = project_dir / "ops"
299 _clone_and_render_ops(
300 manifest.project, manifest.cloud, manifest.region, manifest.domain, ops_dir, progress
301 )
302 # git init + commit includes ops/
303 task = progress.add_task("Initialising git repository (app + ops)…", total=None)
304 _git_init(project_dir, manifest.family)
305 progress.remove_task(task)
306 else:
307 # Standard: app and ops are sibling repos
308 task = progress.add_task("Initialising app git repository…", total=None)
309 _git_init(project_dir, manifest.family)
310 progress.remove_task(task)
311
312 ops_dir = output_dir / f"{manifest.project}-ops"
313 if ops_dir.exists():
314 print_error(f"Ops directory already exists: {ops_dir}")
315 print_info("Delete it first or choose a different output directory.")
316 sys.exit(1)
317 _clone_and_render_ops(
318 manifest.project, manifest.cloud, manifest.region, manifest.domain, ops_dir, progress
319 )
320 task = progress.add_task("Initialising ops git repository…", total=None)
321 _git_init(ops_dir, "opscode")
322 progress.remove_task(task)
323 else:
324 # No ops — just init app
325 task = progress.add_task("Initialising git repository…", total=None)
326 _git_init(project_dir, manifest.family)
327 progress.remove_task(task)
328
329 print_success(f"Project [bold]{manifest.project}[/bold] created at [bold]{project_dir}[/bold]")
330
331 from rich.panel import Panel
332
333 next_steps = _NEXT_STEPS_TEMPLATE.format(project=manifest.project, project_dir=project_dir)
334
335 if manifest.ops and manifest.cloud and ops_dir is not None:
336 if manifest.topology == "omni":
337 next_steps += _NEXT_STEPS_OPS_OMNI.format(
338 project=manifest.project,
339 cloud=manifest.cloud,
340 )
341 else:
342 next_steps += _NEXT_STEPS_OPS_STANDARD.format(
343 ops_dir=ops_dir,
344 ops_name=f"{manifest.project}-ops",
345 cloud=manifest.cloud,
346 )
347
348 console.print(
349 Panel(
350 next_steps.strip(),
351 title="[bold green]Done![/bold green]",
352 border_style="green",
353 )
354 )
--- a/boilerworks/manifest.py
+++ b/boilerworks/manifest.py
@@ -0,0 +1,89 @@
1
+"""Pydantic models for boilerworks.yaml manifest."""
2
+
3
+from __future__ import annotations
4
+
5
+import re
6
+from pathlib import Path
7
+from typing import Literal
8
+
9
+import yaml
10
+from pydantic import BaseModel, Field, field_validator, model_validator
11
+
12
+_SLUG_RE = re.compile(r"^[a-z][a-z0-9-]*$")
13
+
14
+
15
+class ServicesConfig(BaseModel):
16
+ email: Literal["ses", "sendgrid", "mailgun", "none"] | None = None
17
+ storage: Literal["s3", "gcs", "azure-blob", "none"] | None = None
18
+ search: Literal["opensearch", "meilisearch", "none"] | None = None
19
+ cache: Literal["redis", "memcached", "none"] | None = "redis"
20
+
21
+
22
+class DataConfig(BaseModel):
23
+ database: Literal["postgres", "mysql", "sqlite"] = "postgres"
24
+ migrations: bool = True
25
+ seed_data: bool = True
26
+
27
+
28
+class TestingConfig(BaseModel):
29
+ e2e: Literal["playwright", "cypress", "none"] | None = None
30
+ unit: bool = True
31
+ integration: bool = True
32
+
33
+
34
+class BoilerworksManifest(BaseModel):
35
+ project: str
36
+ family: str
37
+ size: Literal["full", "micro", "edge"]
38
+ topology: Literal["standard", "omni", "api-only"] = "standard"
39
+ cloud: Literal["aws", "gcp", "azure"] | None = None
40
+ ops: bool = False
41
+ region: str | None = None
42
+ domain: str | None = None
43
+ mobile: bool = False
44
+ web_presence: bool = False
45
+ compliance: list[str] = Field(default_factory=list)
46
+ services: ServicesConfig = Field(default_factory=ServicesConfig)
47
+ data: DataConfig = Field(default_factory=DataConfig)
48
+ testing: TestingConfig = Field(default_factory=TestingConfig)
49
+ template_versions: dict[str, str] = Field(default_factory=dict)
50
+
51
+ @field_validator("project")
52
+ @classmethod
53
+ def validate_project_slug(cls, v: str) -> str:
54
+ if not _SLUG_RE.match(v):
55
+ raise ValueError(
56
+ f"project name '{v}' must be lowercase, start with a letter, "
57
+ "and contain only letters, digits, and hyphens"
58
+ )
59
+ return v
60
+
61
+ @model_validator(mode="after")
62
+ def validate_family_in_registry(self) -> BoilerworksManifest:
63
+ from boilerworks.registry import Registry
64
+
65
+ registry = Registry()
66
+ if registry.get_by_name(self.family) is None:
67
+ valid = ", ".join(sorted(registry.names()))
68
+ raise ValueError(f"unknown template family '{self.family}'. Valid families: {valid}")
69
+ return self
70
+
71
+ def to_yaml(self) -> str:
72
+ """Serialise the manifest to a YAML string."""
73
+ data = self.model_dump(exclude_none=False)
74
+ return yaml.dump(data, default_flow_style=False, sort_keys=False, allow_unicode=True)
75
+
76
+ @classmethod
77
+ def from_yaml(cls, text: str) -> BoilerworksManifest:
78
+ """Parse a manifest from a YAML string."""
79
+ data = yaml.safe_load(text)
80
+ return cls(**data)
81
+
82
+ @classmethod
83
+ def from_file(cls, path: str | Path) -> BoilerworksManifest:
84
+ """Load a manifest from a file path."""
85
+ return cls.from_yaml(Path(path).read_text())
86
+
87
+ def to_file(self, path: str | Path) -> None:
88
+ """Write the manifest to a file."""
89
+ Path(path).write_text(self.to_yaml())
--- a/boilerworks/manifest.py
+++ b/boilerworks/manifest.py
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/boilerworks/manifest.py
+++ b/boilerworks/manifest.py
@@ -0,0 +1,89 @@
1 """Pydantic models for boilerworks.yaml manifest."""
2
3 from __future__ import annotations
4
5 import re
6 from pathlib import Path
7 from typing import Literal
8
9 import yaml
10 from pydantic import BaseModel, Field, field_validator, model_validator
11
12 _SLUG_RE = re.compile(r"^[a-z][a-z0-9-]*$")
13
14
15 class ServicesConfig(BaseModel):
16 email: Literal["ses", "sendgrid", "mailgun", "none"] | None = None
17 storage: Literal["s3", "gcs", "azure-blob", "none"] | None = None
18 search: Literal["opensearch", "meilisearch", "none"] | None = None
19 cache: Literal["redis", "memcached", "none"] | None = "redis"
20
21
22 class DataConfig(BaseModel):
23 database: Literal["postgres", "mysql", "sqlite"] = "postgres"
24 migrations: bool = True
25 seed_data: bool = True
26
27
28 class TestingConfig(BaseModel):
29 e2e: Literal["playwright", "cypress", "none"] | None = None
30 unit: bool = True
31 integration: bool = True
32
33
34 class BoilerworksManifest(BaseModel):
35 project: str
36 family: str
37 size: Literal["full", "micro", "edge"]
38 topology: Literal["standard", "omni", "api-only"] = "standard"
39 cloud: Literal["aws", "gcp", "azure"] | None = None
40 ops: bool = False
41 region: str | None = None
42 domain: str | None = None
43 mobile: bool = False
44 web_presence: bool = False
45 compliance: list[str] = Field(default_factory=list)
46 services: ServicesConfig = Field(default_factory=ServicesConfig)
47 data: DataConfig = Field(default_factory=DataConfig)
48 testing: TestingConfig = Field(default_factory=TestingConfig)
49 template_versions: dict[str, str] = Field(default_factory=dict)
50
51 @field_validator("project")
52 @classmethod
53 def validate_project_slug(cls, v: str) -> str:
54 if not _SLUG_RE.match(v):
55 raise ValueError(
56 f"project name '{v}' must be lowercase, start with a letter, "
57 "and contain only letters, digits, and hyphens"
58 )
59 return v
60
61 @model_validator(mode="after")
62 def validate_family_in_registry(self) -> BoilerworksManifest:
63 from boilerworks.registry import Registry
64
65 registry = Registry()
66 if registry.get_by_name(self.family) is None:
67 valid = ", ".join(sorted(registry.names()))
68 raise ValueError(f"unknown template family '{self.family}'. Valid families: {valid}")
69 return self
70
71 def to_yaml(self) -> str:
72 """Serialise the manifest to a YAML string."""
73 data = self.model_dump(exclude_none=False)
74 return yaml.dump(data, default_flow_style=False, sort_keys=False, allow_unicode=True)
75
76 @classmethod
77 def from_yaml(cls, text: str) -> BoilerworksManifest:
78 """Parse a manifest from a YAML string."""
79 data = yaml.safe_load(text)
80 return cls(**data)
81
82 @classmethod
83 def from_file(cls, path: str | Path) -> BoilerworksManifest:
84 """Load a manifest from a file path."""
85 return cls.from_yaml(Path(path).read_text())
86
87 def to_file(self, path: str | Path) -> None:
88 """Write the manifest to a file."""
89 Path(path).write_text(self.to_yaml())
--- a/boilerworks/registry.py
+++ b/boilerworks/registry.py
@@ -0,0 +1,69 @@
1
+"""Template registry — loads and queries templates.yaml."""
2
+
3
+from __future__ import annotations
4
+
5
+from pathlib import Path
6
+from typing import Literal
7
+
8
+import yaml
9
+from pydantic import BaseModel, Field
10
+
11
+_DATA_DIR = Path(__file__).parent / "data"
12
+
13
+
14
+class TemplateInfo(BaseModel):
15
+ """A single template entry from templates.yaml."""
16
+
17
+ name: str
18
+ repo: str
19
+ size: Literal["full", "micro", "edge"]
20
+ language: str
21
+ backend: str
22
+ frontend: str
23
+ status: Literal["done", "building", "planned"]
24
+ description: str
25
+ topologies: list[str] = Field(default_factory=list)
26
+ best_for: str = ""
27
+
28
+
29
+class Registry:
30
+ """Loads templates.yaml and provides query methods."""
31
+
32
+ def __init__(self, yaml_path: Path | None = None) -> None:
33
+ path = yaml_path or (_DATA_DIR / "templates.yaml")
34
+ raw: list[dict] = yaml.safe_load(path.read_text())
35
+ self._templates: list[TemplateInfo] = [TemplateInfo(**entry) for entry in raw]
36
+
37
+ def list_all(self) -> list[TemplateInfo]:
38
+ """Return all templates."""
39
+ return list(self._templates)
40
+
41
+ def filter_by_size(self, size: str) -> list[TemplateInfo]:
42
+ """Return templates matching the given size."""
43
+ return [t for t in self._templates if t.size == size]
44
+
45
+ def filter_by_language(self, language: str) -> list[TemplateInfo]:
46
+ """Return templates matching the given language."""
47
+ return [t for t in self._templates if t.language == language]
48
+
49
+ def filter_by_status(self, status: str) -> list[TemplateInfo]:
50
+ """Return templates matching the given status."""
51
+ return [t for t in self._templates if t.status == status]
52
+
53
+ def get_by_name(self, name: str) -> TemplateInfo | None:
54
+ """Return the template with the given name, or None."""
55
+ for t in self._templates:
56
+ if t.name == name:
57
+ return t
58
+ return None
59
+
60
+ def search(self, query: str) -> list[TemplateInfo]:
61
+ """Return templates whose name, description, or best_for contains the query (case-insensitive)."""
62
+ q = query.lower()
63
+ return [
64
+ t for t in self._templates if q in t.name.lower() or q in t.description.lower() or q in t.best_for.lower()
65
+ ]
66
+
67
+ def names(self) -> list[str]:
68
+ """Return all template names."""
69
+ return [t.name for t in self._templates]
--- a/boilerworks/registry.py
+++ b/boilerworks/registry.py
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/boilerworks/registry.py
+++ b/boilerworks/registry.py
@@ -0,0 +1,69 @@
1 """Template registry — loads and queries templates.yaml."""
2
3 from __future__ import annotations
4
5 from pathlib import Path
6 from typing import Literal
7
8 import yaml
9 from pydantic import BaseModel, Field
10
11 _DATA_DIR = Path(__file__).parent / "data"
12
13
14 class TemplateInfo(BaseModel):
15 """A single template entry from templates.yaml."""
16
17 name: str
18 repo: str
19 size: Literal["full", "micro", "edge"]
20 language: str
21 backend: str
22 frontend: str
23 status: Literal["done", "building", "planned"]
24 description: str
25 topologies: list[str] = Field(default_factory=list)
26 best_for: str = ""
27
28
29 class Registry:
30 """Loads templates.yaml and provides query methods."""
31
32 def __init__(self, yaml_path: Path | None = None) -> None:
33 path = yaml_path or (_DATA_DIR / "templates.yaml")
34 raw: list[dict] = yaml.safe_load(path.read_text())
35 self._templates: list[TemplateInfo] = [TemplateInfo(**entry) for entry in raw]
36
37 def list_all(self) -> list[TemplateInfo]:
38 """Return all templates."""
39 return list(self._templates)
40
41 def filter_by_size(self, size: str) -> list[TemplateInfo]:
42 """Return templates matching the given size."""
43 return [t for t in self._templates if t.size == size]
44
45 def filter_by_language(self, language: str) -> list[TemplateInfo]:
46 """Return templates matching the given language."""
47 return [t for t in self._templates if t.language == language]
48
49 def filter_by_status(self, status: str) -> list[TemplateInfo]:
50 """Return templates matching the given status."""
51 return [t for t in self._templates if t.status == status]
52
53 def get_by_name(self, name: str) -> TemplateInfo | None:
54 """Return the template with the given name, or None."""
55 for t in self._templates:
56 if t.name == name:
57 return t
58 return None
59
60 def search(self, query: str) -> list[TemplateInfo]:
61 """Return templates whose name, description, or best_for contains the query (case-insensitive)."""
62 q = query.lower()
63 return [
64 t for t in self._templates if q in t.name.lower() or q in t.description.lower() or q in t.best_for.lower()
65 ]
66
67 def names(self) -> list[str]:
68 """Return all template names."""
69 return [t.name for t in self._templates]
--- a/boilerworks/renderer.py
+++ b/boilerworks/renderer.py
@@ -0,0 +1,164 @@
1
+"""File renderer — string replacement across a cloned template directory."""
2
+
3
+from __future__ import annotations
4
+
5
+import os
6
+from pathlib import Path
7
+
8
+# Directories to skip entirely during rendering
9
+_SKIP_DIRS: frozenset[str] = frozenset(
10
+ [".git", "node_modules", "vendor", "__pycache__", "_build", "deps", "target", ".venv"]
11
+)
12
+
13
+# Binary/lock file extensions to skip (don't attempt text replacement)
14
+_SKIP_EXTENSIONS: frozenset[str] = frozenset(
15
+ [
16
+ ".png",
17
+ ".jpg",
18
+ ".jpeg",
19
+ ".gif",
20
+ ".ico",
21
+ ".woff",
22
+ ".woff2",
23
+ ".ttf",
24
+ ".eot",
25
+ ".lock",
26
+ ".pyc",
27
+ ".pdf",
28
+ ".zip",
29
+ ".tar",
30
+ ".gz",
31
+ ".bz2",
32
+ ".7z",
33
+ ".exe",
34
+ ".bin",
35
+ ".so",
36
+ ".dylib",
37
+ ".dll",
38
+ ".wasm",
39
+ ".db",
40
+ ".sqlite",
41
+ ".sqlite3",
42
+ ]
43
+)
44
+
45
+
46
+def build_replacements(project: str) -> dict[str, str]:
47
+ """Build the standard case-variant replacement map from a project name.
48
+
49
+ E.g. project='my-app' produces:
50
+ 'boilerworks' → 'my-app'
51
+ 'Boilerworks' → 'My-App'
52
+ 'BOILERWORKS' → 'MY-APP'
53
+ 'boilerworks_' → 'my_app_' (underscore variant for Python identifiers)
54
+ """
55
+ # underscore variant (Python module names)
56
+ project_under = project.replace("-", "_")
57
+ project_title = project.replace("-", " ").title().replace(" ", "-")
58
+ project_upper = project.upper()
59
+
60
+ return {
61
+ "boilerworks_": f"{project_under}_",
62
+ "_boilerworks": f"_{project_under}",
63
+ "BOILERWORKS": project_upper,
64
+ "Boilerworks": project_title,
65
+ "boilerworks": project,
66
+ }
67
+
68
+
69
+def render_file(path: Path, replacements: dict[str, str]) -> bool:
70
+ """Apply string replacements to a single file in-place.
71
+
72
+ Returns True if the file was modified, False if skipped or unchanged.
73
+ """
74
+ if path.suffix.lower() in _SKIP_EXTENSIONS:
75
+ return False
76
+
77
+ try:
78
+ original = path.read_text(encoding="utf-8", errors="strict")
79
+ except (UnicodeDecodeError, PermissionError):
80
+ # Binary or unreadable file — skip
81
+ return False
82
+
83
+ modified = original
84
+ for old, new in replacements.items():
85
+ modified = modified.replace(old, new)
86
+
87
+ if modified == original:
88
+ return False
89
+
90
+ path.write_text(modified, encoding="utf-8")
91
+ return True
92
+
93
+
94
+def render_directory(
95
+ root: Path,
96
+ replacements: dict[str, str],
97
+ skip_dirs: frozenset[str] | None = None,
98
+ skip_extensions: frozenset[str] | None = None,
99
+) -> list[Path]:
100
+ """Apply replacements to all eligible files under root.
101
+
102
+ Returns the list of files that were modified.
103
+ """
104
+ skip_d = skip_dirs if skip_dirs is not None else _SKIP_DIRS
105
+ skip_e = skip_extensions if skip_extensions is not None else _SKIP_EXTENSIONS
106
+
107
+ modified: list[Path] = []
108
+
109
+ for dirpath, dirnames, filenames in os.walk(root):
110
+ # Prune excluded dirs in-place so os.walk doesn't recurse into them
111
+ dirnames[:] = [d for d in dirnames if d not in skip_d]
112
+
113
+ for filename in filenames:
114
+ filepath = Path(dirpath) / filename
115
+ if filepath.suffix.lower() in skip_e:
116
+ continue
117
+ try:
118
+ was_changed = render_file(filepath, replacements)
119
+ except OSError:
120
+ continue
121
+ if was_changed:
122
+ modified.append(filepath)
123
+
124
+ return modified
125
+
126
+
127
+def rename_boilerworks_paths(root: Path, project: str) -> list[tuple[Path, Path]]:
128
+ """Rename any files or directories containing 'boilerworks' in their name.
129
+
130
+ Walks the tree bottom-up so child renames happen before parents.
131
+ Returns list of (old_path, new_path) pairs.
132
+ """
133
+ project_under = project.replace("-", "_")
134
+ renames: list[tuple[Path, Path]] = []
135
+
136
+ for dirpath, dirnames, filenames in os.walk(root, topdown=False):
137
+ current_dir = Path(dirpath)
138
+
139
+ # Rename files
140
+ for filename in filenames:
141
+ if "boilerworks" in filename.lower():
142
+ old = current_dir / filename
143
+ new_name = filename.replace("boilerworks", project).replace("BOILERWORKS", project.upper())
144
+ new_name = new_name.replace("Boilerworks", project.replace("-", " ").title().replace(" ", "-"))
145
+ # Also handle underscore variant
146
+ new_name = new_name.replace("boilerworks_", f"{project_under}_")
147
+ new = current_dir / new_name
148
+ if old != new:
149
+ old.rename(new)
150
+ renames.append((old, new))
151
+
152
+ # Rename subdirectories
153
+ for dirname in dirnames:
154
+ if "boilerworks" in dirname.lower():
155
+ old = current_dir / dirname
156
+ new_dirname = dirname.replace("boilerworks", project).replace("BOILERWORKS", project.upper())
157
+ new_dirname = new_dirname.replace("Boilerworks", project.replace("-", " ").title().replace(" ", "-"))
158
+ new_dirname = new_dirname.replace("boilerworks_", f"{project_under}_")
159
+ new = current_dir / new_dirname
160
+ if old != new and old.exists():
161
+ old.rename(new)
162
+ renames.append((old, new))
163
+
164
+ return renames
--- a/boilerworks/renderer.py
+++ b/boilerworks/renderer.py
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/boilerworks/renderer.py
+++ b/boilerworks/renderer.py
@@ -0,0 +1,164 @@
1 """File renderer — string replacement across a cloned template directory."""
2
3 from __future__ import annotations
4
5 import os
6 from pathlib import Path
7
8 # Directories to skip entirely during rendering
9 _SKIP_DIRS: frozenset[str] = frozenset(
10 [".git", "node_modules", "vendor", "__pycache__", "_build", "deps", "target", ".venv"]
11 )
12
13 # Binary/lock file extensions to skip (don't attempt text replacement)
14 _SKIP_EXTENSIONS: frozenset[str] = frozenset(
15 [
16 ".png",
17 ".jpg",
18 ".jpeg",
19 ".gif",
20 ".ico",
21 ".woff",
22 ".woff2",
23 ".ttf",
24 ".eot",
25 ".lock",
26 ".pyc",
27 ".pdf",
28 ".zip",
29 ".tar",
30 ".gz",
31 ".bz2",
32 ".7z",
33 ".exe",
34 ".bin",
35 ".so",
36 ".dylib",
37 ".dll",
38 ".wasm",
39 ".db",
40 ".sqlite",
41 ".sqlite3",
42 ]
43 )
44
45
46 def build_replacements(project: str) -> dict[str, str]:
47 """Build the standard case-variant replacement map from a project name.
48
49 E.g. project='my-app' produces:
50 'boilerworks' → 'my-app'
51 'Boilerworks' → 'My-App'
52 'BOILERWORKS' → 'MY-APP'
53 'boilerworks_' → 'my_app_' (underscore variant for Python identifiers)
54 """
55 # underscore variant (Python module names)
56 project_under = project.replace("-", "_")
57 project_title = project.replace("-", " ").title().replace(" ", "-")
58 project_upper = project.upper()
59
60 return {
61 "boilerworks_": f"{project_under}_",
62 "_boilerworks": f"_{project_under}",
63 "BOILERWORKS": project_upper,
64 "Boilerworks": project_title,
65 "boilerworks": project,
66 }
67
68
69 def render_file(path: Path, replacements: dict[str, str]) -> bool:
70 """Apply string replacements to a single file in-place.
71
72 Returns True if the file was modified, False if skipped or unchanged.
73 """
74 if path.suffix.lower() in _SKIP_EXTENSIONS:
75 return False
76
77 try:
78 original = path.read_text(encoding="utf-8", errors="strict")
79 except (UnicodeDecodeError, PermissionError):
80 # Binary or unreadable file — skip
81 return False
82
83 modified = original
84 for old, new in replacements.items():
85 modified = modified.replace(old, new)
86
87 if modified == original:
88 return False
89
90 path.write_text(modified, encoding="utf-8")
91 return True
92
93
94 def render_directory(
95 root: Path,
96 replacements: dict[str, str],
97 skip_dirs: frozenset[str] | None = None,
98 skip_extensions: frozenset[str] | None = None,
99 ) -> list[Path]:
100 """Apply replacements to all eligible files under root.
101
102 Returns the list of files that were modified.
103 """
104 skip_d = skip_dirs if skip_dirs is not None else _SKIP_DIRS
105 skip_e = skip_extensions if skip_extensions is not None else _SKIP_EXTENSIONS
106
107 modified: list[Path] = []
108
109 for dirpath, dirnames, filenames in os.walk(root):
110 # Prune excluded dirs in-place so os.walk doesn't recurse into them
111 dirnames[:] = [d for d in dirnames if d not in skip_d]
112
113 for filename in filenames:
114 filepath = Path(dirpath) / filename
115 if filepath.suffix.lower() in skip_e:
116 continue
117 try:
118 was_changed = render_file(filepath, replacements)
119 except OSError:
120 continue
121 if was_changed:
122 modified.append(filepath)
123
124 return modified
125
126
127 def rename_boilerworks_paths(root: Path, project: str) -> list[tuple[Path, Path]]:
128 """Rename any files or directories containing 'boilerworks' in their name.
129
130 Walks the tree bottom-up so child renames happen before parents.
131 Returns list of (old_path, new_path) pairs.
132 """
133 project_under = project.replace("-", "_")
134 renames: list[tuple[Path, Path]] = []
135
136 for dirpath, dirnames, filenames in os.walk(root, topdown=False):
137 current_dir = Path(dirpath)
138
139 # Rename files
140 for filename in filenames:
141 if "boilerworks" in filename.lower():
142 old = current_dir / filename
143 new_name = filename.replace("boilerworks", project).replace("BOILERWORKS", project.upper())
144 new_name = new_name.replace("Boilerworks", project.replace("-", " ").title().replace(" ", "-"))
145 # Also handle underscore variant
146 new_name = new_name.replace("boilerworks_", f"{project_under}_")
147 new = current_dir / new_name
148 if old != new:
149 old.rename(new)
150 renames.append((old, new))
151
152 # Rename subdirectories
153 for dirname in dirnames:
154 if "boilerworks" in dirname.lower():
155 old = current_dir / dirname
156 new_dirname = dirname.replace("boilerworks", project).replace("BOILERWORKS", project.upper())
157 new_dirname = new_dirname.replace("Boilerworks", project.replace("-", " ").title().replace(" ", "-"))
158 new_dirname = new_dirname.replace("boilerworks_", f"{project_under}_")
159 new = current_dir / new_dirname
160 if old != new and old.exists():
161 old.rename(new)
162 renames.append((old, new))
163
164 return renames
--- a/boilerworks/wizard.py
+++ b/boilerworks/wizard.py
@@ -0,0 +1,212 @@
1
+"""Interactive setup wizard — collects answers, writes boilerworks.yaml."""
2
+
3
+from __future__ import annotations
4
+
5
+import re
6
+import sys
7
+from pathlib import Path
8
+
9
+import questionary
10
+from rich.panel import Panel
11
+
12
+from boilerworks.console import console, print_success
13
+from boilerworks.manifest import BoilerworksManifest, DataConfig, ServicesConfig, TestingConfig
14
+from boilerworks.registry import Registry, TemplateInfo
15
+
16
+_SLUG_RE = re.compile(r"^[a-z][a-z0-9-]*$")
17
+
18
+_SELECTION_GUIDE = """[bold]How to pick a template size:[/bold]
19
+
20
+ [cyan]Full[/cyan] — Apps with users (login, permissions, org management)
21
+ [magenta]Micro[/magenta] — API-key services, microservices, workers
22
+ [blue]Edge[/blue] — Cloudflare Workers / Pages, globally distributed
23
+
24
+[bold]When to choose Full vs Micro:[/bold]
25
+ If it has users logging in → Full
26
+ If it's a service with API keys → Micro
27
+ If it needs global edge scale → Edge
28
+"""
29
+
30
+
31
+def _validate_slug(text: str) -> bool | str:
32
+ if not text:
33
+ return "Project name is required"
34
+ if not _SLUG_RE.match(text):
35
+ return "Must be lowercase, start with a letter, letters/digits/hyphens only (e.g. my-project)"
36
+ return True
37
+
38
+
39
+def _template_choices(templates: list[TemplateInfo]) -> list[questionary.Choice]:
40
+ choices = []
41
+ current_lang = None
42
+ for t in templates:
43
+ if t.language != current_lang:
44
+ current_lang = t.language
45
+ choices.append(questionary.Separator(f"── {t.language} ──"))
46
+ status_icon = {"done": "●", "building": "◐", "planned": "○"}.get(t.status, "?")
47
+ label = f"{status_icon} {t.name:<24} {t.description}"
48
+ choices.append(questionary.Choice(title=label, value=t.name))
49
+ return choices
50
+
51
+
52
+def run_wizard(output_path: str | Path = "boilerworks.yaml") -> None:
53
+ """Walk through the 13-step wizard and write boilerworks.yaml."""
54
+ registry = Registry()
55
+ console.print(
56
+ Panel(
57
+ "[bold cyan]Boilerworks Setup Wizard[/bold cyan]\nAnswers are saved to [bold]boilerworks.yaml[/bold]",
58
+ border_style="cyan",
59
+ )
60
+ )
61
+
62
+ # ── Step 1: Project name ─────────────────────────────────────────────────
63
+ project = questionary.text(
64
+ "Project name (slug format, e.g. my-app):",
65
+ validate=_validate_slug,
66
+ ).ask()
67
+ if project is None:
68
+ console.print("[yellow]Cancelled.[/yellow]")
69
+ sys.exit(0)
70
+
71
+ # ── Step 2: Template size ─────────────────────────────────────────────────
72
+ console.print(Panel(_SELECTION_GUIDE, title="Template Guide", border_style="dim"))
73
+ size = questionary.select(
74
+ "Template size:",
75
+ choices=["full", "micro", "edge"],
76
+ ).ask()
77
+ if size is None:
78
+ console.print("[yellow]Cancelled.[/yellow]")
79
+ sys.exit(0)
80
+
81
+ # ── Step 3: Template family ───────────────────────────────────────────────
82
+ filtered = registry.filter_by_size(size)
83
+ family = questionary.select(
84
+ "Template family:",
85
+ choices=_template_choices(filtered),
86
+ ).ask()
87
+ if family is None:
88
+ console.print("[yellow]Cancelled.[/yellow]")
89
+ sys.exit(0)
90
+
91
+ template = registry.get_by_name(family)
92
+
93
+ # ── Step 4: Topology ──────────────────────────────────────────────────────
94
+ available_topologies = template.topologies if template else ["standard"]
95
+ if len(available_topologies) > 1:
96
+ topology = questionary.select(
97
+ "Deployment topology:",
98
+ choices=available_topologies,
99
+ ).ask()
100
+ else:
101
+ topology = available_topologies[0]
102
+ console.print(f" [dim]Topology: {topology} (only option for this template)[/dim]")
103
+ if topology is None:
104
+ topology = "standard"
105
+
106
+ # ── Step 5: Cloud provider ────────────────────────────────────────────────
107
+ cloud_answer = questionary.select(
108
+ "Cloud provider:",
109
+ choices=["aws", "gcp", "azure", "none"],
110
+ ).ask()
111
+ cloud = cloud_answer if cloud_answer != "none" else None
112
+
113
+ # ── Step 5b: Ops (infra-as-code) ─────────────────────────────────────────
114
+ ops = False
115
+ if cloud:
116
+ ops = (
117
+ questionary.confirm(
118
+ "Include infrastructure-as-code (boilerworks-opscode)?",
119
+ default=True,
120
+ ).ask()
121
+ or False
122
+ )
123
+
124
+ # ── Step 6: Region ────────────────────────────────────────────────────────
125
+ region: str | None = None
126
+ if cloud:
127
+ region = questionary.text(
128
+ f"Region (e.g. {'us-east-1' if cloud == 'aws' else 'us-central1' if cloud == 'gcp' else 'eastus'}):",
129
+ ).ask()
130
+ if not region:
131
+ region = None
132
+
133
+ # ── Step 7: Domain ────────────────────────────────────────────────────────
134
+ domain_answer = questionary.text("Domain (optional, e.g. myapp.com):").ask()
135
+ domain = domain_answer.strip() if domain_answer and domain_answer.strip() else None
136
+
137
+ # ── Step 8 & 9: Mobile / Web presence (Full only) ────────────────────────
138
+ mobile = False
139
+ web_presence = False
140
+ if size == "full":
141
+ mobile = questionary.confirm("Include mobile app template?", default=False).ask() or False
142
+ web_presence = questionary.confirm("Include web presence / marketing site?", default=False).ask() or False
143
+
144
+ # ── Step 10: Compliance ───────────────────────────────────────────────────
145
+ compliance_answer = questionary.checkbox(
146
+ "Compliance requirements:",
147
+ choices=["soc2", "hipaa", "pci-dss", "gdpr", "none"],
148
+ ).ask()
149
+ compliance = [c for c in (compliance_answer or []) if c != "none"]
150
+
151
+ # ── Step 11: Email provider ───────────────────────────────────────────────
152
+ email_answer = questionary.select(
153
+ "Email provider:",
154
+ choices=["ses", "sendgrid", "mailgun", "none"],
155
+ ).ask()
156
+ email = email_answer if email_answer != "none" else None
157
+
158
+ # ── Step 12: E2E testing ──────────────────────────────────────────────────
159
+ e2e_answer = questionary.select(
160
+ "End-to-end testing framework:",
161
+ choices=["playwright", "cypress", "none"],
162
+ ).ask()
163
+ e2e = e2e_answer if e2e_answer != "none" else None
164
+
165
+ # ── Step 13: Summary + confirm ────────────────────────────────────────────
166
+ summary_lines = [
167
+ f" [bold]Project:[/bold] {project}",
168
+ f" [bold]Template:[/bold] {family} ({size})",
169
+ f" [bold]Topology:[/bold] {topology}",
170
+ f" [bold]Cloud:[/bold] {cloud or 'none'}",
171
+ f" [bold]Ops:[/bold] {'yes' if ops else 'no'}",
172
+ f" [bold]Region:[/bold] {region or '—'}",
173
+ f" [bold]Domain:[/bold] {domain or '—'}",
174
+ ]
175
+ if size == "full":
176
+ summary_lines += [
177
+ f" [bold]Mobile:[/bold] {'yes' if mobile else 'no'}",
178
+ f" [bold]Web:[/bold] {'yes' if web_presence else 'no'}",
179
+ ]
180
+ summary_lines += [
181
+ f" [bold]Compliance:[/bold] {', '.join(compliance) if compliance else 'none'}",
182
+ f" [bold]Email:[/bold] {email or 'none'}",
183
+ f" [bold]E2E:[/bold] {e2e or 'none'}",
184
+ ]
185
+ console.print(Panel("\n".join(summary_lines), title="[bold]Summary[/bold]", border_style="green"))
186
+
187
+ confirmed = questionary.confirm("Write boilerworks.yaml?", default=True).ask()
188
+ if not confirmed:
189
+ console.print("[yellow]Cancelled — nothing written.[/yellow]")
190
+ sys.exit(0)
191
+
192
+ manifest = BoilerworksManifest(
193
+ project=project,
194
+ family=family,
195
+ size=size,
196
+ topology=topology,
197
+ cloud=cloud,
198
+ ops=ops,
199
+ region=region,
200
+ domain=domain,
201
+ mobile=mobile,
202
+ web_presence=web_presence,
203
+ compliance=compliance,
204
+ services=ServicesConfig(email=email),
205
+ data=DataConfig(),
206
+ testing=TestingConfig(e2e=e2e),
207
+ )
208
+
209
+ out = Path(output_path)
210
+ manifest.to_file(out)
211
+ print_success(f"Written to [bold]{out}[/bold]")
212
+ console.print("\n[dim]Next:[/dim] [bold]boilerworks init[/bold]")
--- a/boilerworks/wizard.py
+++ b/boilerworks/wizard.py
@@ -0,0 +1,212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/boilerworks/wizard.py
+++ b/boilerworks/wizard.py
@@ -0,0 +1,212 @@
1 """Interactive setup wizard — collects answers, writes boilerworks.yaml."""
2
3 from __future__ import annotations
4
5 import re
6 import sys
7 from pathlib import Path
8
9 import questionary
10 from rich.panel import Panel
11
12 from boilerworks.console import console, print_success
13 from boilerworks.manifest import BoilerworksManifest, DataConfig, ServicesConfig, TestingConfig
14 from boilerworks.registry import Registry, TemplateInfo
15
16 _SLUG_RE = re.compile(r"^[a-z][a-z0-9-]*$")
17
18 _SELECTION_GUIDE = """[bold]How to pick a template size:[/bold]
19
20 [cyan]Full[/cyan] — Apps with users (login, permissions, org management)
21 [magenta]Micro[/magenta] — API-key services, microservices, workers
22 [blue]Edge[/blue] — Cloudflare Workers / Pages, globally distributed
23
24 [bold]When to choose Full vs Micro:[/bold]
25 If it has users logging in → Full
26 If it's a service with API keys → Micro
27 If it needs global edge scale → Edge
28 """
29
30
31 def _validate_slug(text: str) -> bool | str:
32 if not text:
33 return "Project name is required"
34 if not _SLUG_RE.match(text):
35 return "Must be lowercase, start with a letter, letters/digits/hyphens only (e.g. my-project)"
36 return True
37
38
39 def _template_choices(templates: list[TemplateInfo]) -> list[questionary.Choice]:
40 choices = []
41 current_lang = None
42 for t in templates:
43 if t.language != current_lang:
44 current_lang = t.language
45 choices.append(questionary.Separator(f"── {t.language} ──"))
46 status_icon = {"done": "●", "building": "◐", "planned": "○"}.get(t.status, "?")
47 label = f"{status_icon} {t.name:<24} {t.description}"
48 choices.append(questionary.Choice(title=label, value=t.name))
49 return choices
50
51
52 def run_wizard(output_path: str | Path = "boilerworks.yaml") -> None:
53 """Walk through the 13-step wizard and write boilerworks.yaml."""
54 registry = Registry()
55 console.print(
56 Panel(
57 "[bold cyan]Boilerworks Setup Wizard[/bold cyan]\nAnswers are saved to [bold]boilerworks.yaml[/bold]",
58 border_style="cyan",
59 )
60 )
61
62 # ── Step 1: Project name ─────────────────────────────────────────────────
63 project = questionary.text(
64 "Project name (slug format, e.g. my-app):",
65 validate=_validate_slug,
66 ).ask()
67 if project is None:
68 console.print("[yellow]Cancelled.[/yellow]")
69 sys.exit(0)
70
71 # ── Step 2: Template size ─────────────────────────────────────────────────
72 console.print(Panel(_SELECTION_GUIDE, title="Template Guide", border_style="dim"))
73 size = questionary.select(
74 "Template size:",
75 choices=["full", "micro", "edge"],
76 ).ask()
77 if size is None:
78 console.print("[yellow]Cancelled.[/yellow]")
79 sys.exit(0)
80
81 # ── Step 3: Template family ───────────────────────────────────────────────
82 filtered = registry.filter_by_size(size)
83 family = questionary.select(
84 "Template family:",
85 choices=_template_choices(filtered),
86 ).ask()
87 if family is None:
88 console.print("[yellow]Cancelled.[/yellow]")
89 sys.exit(0)
90
91 template = registry.get_by_name(family)
92
93 # ── Step 4: Topology ──────────────────────────────────────────────────────
94 available_topologies = template.topologies if template else ["standard"]
95 if len(available_topologies) > 1:
96 topology = questionary.select(
97 "Deployment topology:",
98 choices=available_topologies,
99 ).ask()
100 else:
101 topology = available_topologies[0]
102 console.print(f" [dim]Topology: {topology} (only option for this template)[/dim]")
103 if topology is None:
104 topology = "standard"
105
106 # ── Step 5: Cloud provider ────────────────────────────────────────────────
107 cloud_answer = questionary.select(
108 "Cloud provider:",
109 choices=["aws", "gcp", "azure", "none"],
110 ).ask()
111 cloud = cloud_answer if cloud_answer != "none" else None
112
113 # ── Step 5b: Ops (infra-as-code) ─────────────────────────────────────────
114 ops = False
115 if cloud:
116 ops = (
117 questionary.confirm(
118 "Include infrastructure-as-code (boilerworks-opscode)?",
119 default=True,
120 ).ask()
121 or False
122 )
123
124 # ── Step 6: Region ────────────────────────────────────────────────────────
125 region: str | None = None
126 if cloud:
127 region = questionary.text(
128 f"Region (e.g. {'us-east-1' if cloud == 'aws' else 'us-central1' if cloud == 'gcp' else 'eastus'}):",
129 ).ask()
130 if not region:
131 region = None
132
133 # ── Step 7: Domain ────────────────────────────────────────────────────────
134 domain_answer = questionary.text("Domain (optional, e.g. myapp.com):").ask()
135 domain = domain_answer.strip() if domain_answer and domain_answer.strip() else None
136
137 # ── Step 8 & 9: Mobile / Web presence (Full only) ────────────────────────
138 mobile = False
139 web_presence = False
140 if size == "full":
141 mobile = questionary.confirm("Include mobile app template?", default=False).ask() or False
142 web_presence = questionary.confirm("Include web presence / marketing site?", default=False).ask() or False
143
144 # ── Step 10: Compliance ───────────────────────────────────────────────────
145 compliance_answer = questionary.checkbox(
146 "Compliance requirements:",
147 choices=["soc2", "hipaa", "pci-dss", "gdpr", "none"],
148 ).ask()
149 compliance = [c for c in (compliance_answer or []) if c != "none"]
150
151 # ── Step 11: Email provider ───────────────────────────────────────────────
152 email_answer = questionary.select(
153 "Email provider:",
154 choices=["ses", "sendgrid", "mailgun", "none"],
155 ).ask()
156 email = email_answer if email_answer != "none" else None
157
158 # ── Step 12: E2E testing ──────────────────────────────────────────────────
159 e2e_answer = questionary.select(
160 "End-to-end testing framework:",
161 choices=["playwright", "cypress", "none"],
162 ).ask()
163 e2e = e2e_answer if e2e_answer != "none" else None
164
165 # ── Step 13: Summary + confirm ────────────────────────────────────────────
166 summary_lines = [
167 f" [bold]Project:[/bold] {project}",
168 f" [bold]Template:[/bold] {family} ({size})",
169 f" [bold]Topology:[/bold] {topology}",
170 f" [bold]Cloud:[/bold] {cloud or 'none'}",
171 f" [bold]Ops:[/bold] {'yes' if ops else 'no'}",
172 f" [bold]Region:[/bold] {region or '—'}",
173 f" [bold]Domain:[/bold] {domain or '—'}",
174 ]
175 if size == "full":
176 summary_lines += [
177 f" [bold]Mobile:[/bold] {'yes' if mobile else 'no'}",
178 f" [bold]Web:[/bold] {'yes' if web_presence else 'no'}",
179 ]
180 summary_lines += [
181 f" [bold]Compliance:[/bold] {', '.join(compliance) if compliance else 'none'}",
182 f" [bold]Email:[/bold] {email or 'none'}",
183 f" [bold]E2E:[/bold] {e2e or 'none'}",
184 ]
185 console.print(Panel("\n".join(summary_lines), title="[bold]Summary[/bold]", border_style="green"))
186
187 confirmed = questionary.confirm("Write boilerworks.yaml?", default=True).ask()
188 if not confirmed:
189 console.print("[yellow]Cancelled — nothing written.[/yellow]")
190 sys.exit(0)
191
192 manifest = BoilerworksManifest(
193 project=project,
194 family=family,
195 size=size,
196 topology=topology,
197 cloud=cloud,
198 ops=ops,
199 region=region,
200 domain=domain,
201 mobile=mobile,
202 web_presence=web_presence,
203 compliance=compliance,
204 services=ServicesConfig(email=email),
205 data=DataConfig(),
206 testing=TestingConfig(e2e=e2e),
207 )
208
209 out = Path(output_path)
210 manifest.to_file(out)
211 print_success(f"Written to [bold]{out}[/bold]")
212 console.print("\n[dim]Next:[/dim] [bold]boilerworks init[/bold]")
--- a/data/templates.yaml
+++ b/data/templates.yaml
@@ -0,0 +1,306 @@
1
+# Boilerworks Template Catalogue
2
+# 26 templates across Full / Micro / Edge sizes
3
+
4
+# ─── Full Templates (15) ──────────────────────────────────────────────────────
5
+
6
+- name: django-nextjs
7
+ repo: ConflictHQ/boilerworks-django-nextjs
8
+ size: full
9
+ language: python
10
+ backend: "Django 5"
11
+ frontend: "Next.js 16"
12
+ status: done
13
+ description: "Data-heavy backends, admin-rich, rapid prototyping"
14
+ topologies: [standard, omni, api-only]
15
+ best_for: "Python teams wanting batteries-included backend with rich SPA frontend"
16
+
17
+- name: nestjs-nextjs
18
+ repo: ConflictHQ/boilerworks-nestjs-nextjs
19
+ size: full
20
+ language: typescript
21
+ backend: "NestJS 11"
22
+ frontend: "Next.js 16"
23
+ status: done
24
+ description: "Full TypeScript, enterprise-ish"
25
+ topologies: [standard, omni, api-only]
26
+ best_for: "TypeSbuildingt-first teams building enterprise applications"
27
+
28
+- nall
29
+ language: ruby
30
+ backend: "Rails 8"
31
+ frontend: "Hotwire (Turbo + Stimulus)"
32
+ status: done
33
+ description: "Marketplace, social, content, CMS"
34
+ building
35
+ description: "anting Rails-native reactive UI"
36
+
37
+- name: rails-nextjs
38
+ repo: ConflictHQ/boilerworks-rails-nextjs
39
+ size: full
40
+ language: ruby
41
+ backend: "Rails 8"
42
+ frontend: "Next.js 16"
43
+ status: done
44
+ description: "Rails backend, richer frontend than Hotwire"
45
+ topologies: [standard, abuilding
46
+ description: "needs exceed Hotwire"
47
+
48
+- name: spring-angular
49
+ repo: ConflictHQ/boilerworks-spring-angular
50
+ size: full
51
+ language: java
52
+ backenme: rails-hotwire
53
+ repo: ConflictHQ/boilerworks-rails-hotwire
54
+ size: full
55
+ language: ruby
56
+ backend: "Rails 8"
57
+ frontend: "Hotwire (Turbo + Stimulus)"
58
+ statking, or fintech"
59
+
60
+- name:: "Marketplace, social, content, CMS"
61
+ building
62
+ description: "anting Rails-native reactive UI"
63
+
64
+ipt
65
+ backend: "NestJS 11"
66
+ frontend: "Next.js 16"
67
+ status: done
68
+ descripti1andard, omni, api-only]
69
+ best_for: "TypeSbuildingt-first teams building enterprise applications"
70
+
71
+- name: rails-hotwire
72
+ repo: ConflictHQ/boilerworks-rails-hotwire
73
+ size: full
74
+ language: ruby
75
+ backend: "Angular 19"
76
+ status: done
77
+ description: "Enterprise, banking, fintech"
78
+ topologies: [standard]
79
+ best_for: "Java tbuilding
80
+ description: "anking, or fintech"
81
+
82
+- name: go-nextjs
83
+ repo: ConflictHQ/boilerworks-go-nextjs
84
+ size: full
85
+ language: go
86
+ backend: "Go + Chi"
87
+ frontend: "Next.js 16"
88
+ status: done
89
+ descre
90
+ description: "High-performance Rust microservice"
91
+ topologies: [standard]
92
+ best_for: "Maximum performance microservices in Rust"
93
+
94
+- name: cherrypy-micro
95
+ repo: ConflictHQ/boilerworks-cherrypy-micro
96
+ size: micro
97
+ language: python
98
+ backend: "CherryPy"
99
+ frontend:angular
100
+ repo: ConflictHQ/boilerworks-spring-angular
101
+ size: full
102
+ language: java
103
+ backend: "Spring Boot 3"
104
+ frontend: "Angular 19"
105
+ status: done
106
+ descriptze: micro
107
+ language: pyting, fintech"
108
+ topologies: [standard]
109
+ best_for: "Java tbuilding
110
+ description: "anking, or finte# Boilerworks Template Catalogue
111
+d: "Go + Chi"
112
+ fronten/Echoteams buil an API-first approach with rich SPA"
113
+
114
+- name: phoenix-liveview
115
+ repo: ConflictHQ/boilerworks-phoenix-liveview
116
+ size: full
117
+ language: elixir
118
+ backend: "Phoenix 1.7"
119
+ frontend: "Live- name: rails-nextjs
120
+ repo: ConflictHQ/boilerworks-rails-nextjs
121
+ size: full
122
+ language: ruby
123
+ backend: "Rails 8"
124
+ frontend: "Next.js 16"
125
+ ze: micro
126
+ language: pyer frontend than Hotwire"
127
+ topologies: [standard, abuilding
128
+ description: "needs exceed Hotwire"
129
+
130
+- name: spring-angular
131
+ repo: ConflictHQ/boilerwoliveview
132
+ repo: ConflictHQ/boilerworks-phoenix-liveview
133
+ size: full
134
+ language: elixir
135
+ backend: "Phoenix 1.7"
136
+ frontend: "LiveView"
137
+ status: done
138
+ dze: micro
139
+ language: py# Boilerworks Template Catalogue
140
+# 26 templates across Full / Micro / Edge sizes
141
+
142
+# ─── Full
143
+# 26 templates across Full / Micro / Edge sizes
144
+
145
+# ─── Full Templates (15) ──────────────────────────────────────────────────────
146
+
147
+- name: django-nextjs
148
+ repo: ConTegular 19"
149
+ status: done
150
+ description: "Enterprise, banking, fintech"
151
+ topologies: [standard]
152
+ best_for: "Java tbuilding
153
+ description: "anking, or fintech"
154
+
155
+- name: go-nextjs
156
+ repo: ConflictHQ/boilerworks-go-nextjs
157
+ size: full
158
+ language: go
159
+ backend: "Go + Chi"
160
+ frontend: "Next.js 16"
161
+ status: done
162
+ description: "API-first Go, rich frontend"
163
+ topologies: [standard, api-only]
164
+ best_for: "Go teams wanting an Aplannedt-first teams buil an API-first approach with rich SPA"
165
+
166
+- name: phoenix-liveview
167
+ repo: ConflictHQ/boilerworks-phoenix-liveview
168
+ size: full
169
+ language: elixir
170
+ backend: "Phoenix 1.7"
171
+ frontend: "LiveView"
172
+ status: done
173
+ description: "Real-time, collaborative"
174
+ topologies: [standard]
175
+ best_foplanned
176
+ description: "atus: done
177
+ description: "Modern enterprise, non-Angular"
178
+ topologies: [standard, api-only]
179
+ best_for: "Java teams preferring React over Angular"
180
+
181
+- name: laravel-livewire
182
+ repo: ConflictHQ/boilerworks-laravel-livewire
183
+ size: full
184
+ language: php
185
+ backend: "Laravel 12"
186
+ fronteplanned
187
+ description: "St_for: "PHP teams wantn: "Server-rendered reactive PHP"
188
+ topologies: [standard]
189
+ best_for: "PHP teams wanting server-rendered reactivity without JS"
190
+
191
+- name: fastapi-htmx
192
+ repo: ConflictHQ/boilerworks-fastapi-htmx
193
+ size: full
194
+ language: python
195
+ backend: "FastAPI"
196
+ frontend: "Hplannedt-first teams builightweight Python, server-rendered"
197
+ topologies: [standard]
198
+ best_for: "FastAPI teams preferring server-rendered HTML"
199
+
200
+# ─── Micro Templates (6) ──────────────────────────────────────────────────────
201
+
202
+- name: django-micro
203
+ repo: ConflictHQ/boilerworks-django-micro
204
+ size: micro
205
+ language: python
206
+ backend: "Django 5 (DRF/Ninja)"
207
+ frontend: ""
208
+ status: done
209
+ description: "API-only Python service"
210
+ topologies: [standard]
211
+ best_for: "Python microservices with Django's ORM and admin"
212
+
213
+- name: fastapi-micro
214
+ repo: ConflictHQ/boilerworks-fastapi-micro
215
+ size: micro
216
+ language: python
217
+ backend: "FastAPI"
218
+ frontend: ""
219
+ status: done
220
+ description: "Lightweight async Python API"
221
+ topologies: [standard]
222
+ best_for: "High-performance async Python microservices"
223
+
224
+- name: nestjs-micro
225
+ repo: ConflictHQ/boilerworks-nestjs-micro
226
+ size: micro
227
+ language: typescript
228
+ backend: "NestJS 11"
229
+ frontend: ""
230
+ status: done
231
+ description: "API-only TypeScript service"
232
+ topologies: [standard]
233
+ best_for: "TypeScript microservices with NestJS DI and modules"
234
+
235
+- name: go-micro
236
+ repo: ConflictHQ/boilerworks-go-micro
237
+ size: micro
238
+ language: go
239
+ backend: "Go + Chi"
240
+ frontend: ""
241
+ status: done
242
+ description: "Lightweight Go service"
243
+ topologies: [standard]
244
+ best_for: "High-throughput Go microservices"
245
+
246
+- name: rust-micro
247
+ repo: ConflictHQ/boilerworks-rust-micro
248
+ size: micro
249
+ language: rust
250
+ backend: "Axum (Rust)"
251
+ frontend: ""
252
+ status: done
253
+ description: "High-performance Rust microservice"
254
+ topologies: [standard]
255
+ best_for: "Maximum performance microservices in Rust"
256
+
257
+- name: cherrypy-micro
258
+ repo: ConflictHQ/boilerworks-cherrypy-micro
259
+ size: micro
260
+ language: python
261
+ backend: "CherryPy"
262
+ frontend: ""
263
+ status: planned
264
+ description: "Pureor: "High-performanc─────────────────────────────────────────────
265
+
266
+- name: django-micro
267
+ repo: ConflictHQ/boilerworks-django-micro
268
+ size: micro
269
+ language: python
270
+ backend: "Django 5 (DRF/Ninja)"
271
+ frontend: ""
272
+ status: done
273
+ description: "API-only Python service"
274
+ topologies: [standard]
275
+ best_for: "Python microservices with Django's ORM and admin"
276
+
277
+- name: fastapi-micrplanned
278
+ description: "boilerworks-fastapi-micro
279
+ size: micro
280
+ language: python
281
+ backend: "FastAPI"
282
+ frontend: ""
283
+ status: done
284
+ description: "Lightweight async Python API"
285
+ topologies: [standard]
286
+ best_for: "High-performance async Python microservices"
287
+
288
+- name: nestjs-micro
289
+ repo: ConflictHQ/boilerworks-nestjs-micro
290
+ size: micro
291
+ language: typescript
292
+ backend: "NestJS 11"
293
+ frontend: ""
294
+ status: done
295
+ description: "API-only TypeScript service"
296
+ topologies: [standard]
297
+ best_for: "TypeScript microservices with NestJS DI and modules"
298
+
299
+- name: go-mplanned
300
+ description: "HQ/boilerworks-go-micro
301
+ size: micro
302
+ language: go
303
+ backend: "Go + Chi"
304
+ frontend: ""
305
+ status: done
306
+ description: "
--- a/data/templates.yaml
+++ b/data/templates.yaml
@@ -0,0 +1,306 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/data/templates.yaml
+++ b/data/templates.yaml
@@ -0,0 +1,306 @@
1 # Boilerworks Template Catalogue
2 # 26 templates across Full / Micro / Edge sizes
3
4 # ─── Full Templates (15) ──────────────────────────────────────────────────────
5
6 - name: django-nextjs
7 repo: ConflictHQ/boilerworks-django-nextjs
8 size: full
9 language: python
10 backend: "Django 5"
11 frontend: "Next.js 16"
12 status: done
13 description: "Data-heavy backends, admin-rich, rapid prototyping"
14 topologies: [standard, omni, api-only]
15 best_for: "Python teams wanting batteries-included backend with rich SPA frontend"
16
17 - name: nestjs-nextjs
18 repo: ConflictHQ/boilerworks-nestjs-nextjs
19 size: full
20 language: typescript
21 backend: "NestJS 11"
22 frontend: "Next.js 16"
23 status: done
24 description: "Full TypeScript, enterprise-ish"
25 topologies: [standard, omni, api-only]
26 best_for: "TypeSbuildingt-first teams building enterprise applications"
27
28 - nall
29 language: ruby
30 backend: "Rails 8"
31 frontend: "Hotwire (Turbo + Stimulus)"
32 status: done
33 description: "Marketplace, social, content, CMS"
34 building
35 description: "anting Rails-native reactive UI"
36
37 - name: rails-nextjs
38 repo: ConflictHQ/boilerworks-rails-nextjs
39 size: full
40 language: ruby
41 backend: "Rails 8"
42 frontend: "Next.js 16"
43 status: done
44 description: "Rails backend, richer frontend than Hotwire"
45 topologies: [standard, abuilding
46 description: "needs exceed Hotwire"
47
48 - name: spring-angular
49 repo: ConflictHQ/boilerworks-spring-angular
50 size: full
51 language: java
52 backenme: rails-hotwire
53 repo: ConflictHQ/boilerworks-rails-hotwire
54 size: full
55 language: ruby
56 backend: "Rails 8"
57 frontend: "Hotwire (Turbo + Stimulus)"
58 statking, or fintech"
59
60 - name:: "Marketplace, social, content, CMS"
61 building
62 description: "anting Rails-native reactive UI"
63
64 ipt
65 backend: "NestJS 11"
66 frontend: "Next.js 16"
67 status: done
68 descripti1andard, omni, api-only]
69 best_for: "TypeSbuildingt-first teams building enterprise applications"
70
71 - name: rails-hotwire
72 repo: ConflictHQ/boilerworks-rails-hotwire
73 size: full
74 language: ruby
75 backend: "Angular 19"
76 status: done
77 description: "Enterprise, banking, fintech"
78 topologies: [standard]
79 best_for: "Java tbuilding
80 description: "anking, or fintech"
81
82 - name: go-nextjs
83 repo: ConflictHQ/boilerworks-go-nextjs
84 size: full
85 language: go
86 backend: "Go + Chi"
87 frontend: "Next.js 16"
88 status: done
89 descre
90 description: "High-performance Rust microservice"
91 topologies: [standard]
92 best_for: "Maximum performance microservices in Rust"
93
94 - name: cherrypy-micro
95 repo: ConflictHQ/boilerworks-cherrypy-micro
96 size: micro
97 language: python
98 backend: "CherryPy"
99 frontend:angular
100 repo: ConflictHQ/boilerworks-spring-angular
101 size: full
102 language: java
103 backend: "Spring Boot 3"
104 frontend: "Angular 19"
105 status: done
106 descriptze: micro
107 language: pyting, fintech"
108 topologies: [standard]
109 best_for: "Java tbuilding
110 description: "anking, or finte# Boilerworks Template Catalogue
111 d: "Go + Chi"
112 fronten/Echoteams buil an API-first approach with rich SPA"
113
114 - name: phoenix-liveview
115 repo: ConflictHQ/boilerworks-phoenix-liveview
116 size: full
117 language: elixir
118 backend: "Phoenix 1.7"
119 frontend: "Live- name: rails-nextjs
120 repo: ConflictHQ/boilerworks-rails-nextjs
121 size: full
122 language: ruby
123 backend: "Rails 8"
124 frontend: "Next.js 16"
125 ze: micro
126 language: pyer frontend than Hotwire"
127 topologies: [standard, abuilding
128 description: "needs exceed Hotwire"
129
130 - name: spring-angular
131 repo: ConflictHQ/boilerwoliveview
132 repo: ConflictHQ/boilerworks-phoenix-liveview
133 size: full
134 language: elixir
135 backend: "Phoenix 1.7"
136 frontend: "LiveView"
137 status: done
138 dze: micro
139 language: py# Boilerworks Template Catalogue
140 # 26 templates across Full / Micro / Edge sizes
141
142 # ─── Full
143 # 26 templates across Full / Micro / Edge sizes
144
145 # ─── Full Templates (15) ──────────────────────────────────────────────────────
146
147 - name: django-nextjs
148 repo: ConTegular 19"
149 status: done
150 description: "Enterprise, banking, fintech"
151 topologies: [standard]
152 best_for: "Java tbuilding
153 description: "anking, or fintech"
154
155 - name: go-nextjs
156 repo: ConflictHQ/boilerworks-go-nextjs
157 size: full
158 language: go
159 backend: "Go + Chi"
160 frontend: "Next.js 16"
161 status: done
162 description: "API-first Go, rich frontend"
163 topologies: [standard, api-only]
164 best_for: "Go teams wanting an Aplannedt-first teams buil an API-first approach with rich SPA"
165
166 - name: phoenix-liveview
167 repo: ConflictHQ/boilerworks-phoenix-liveview
168 size: full
169 language: elixir
170 backend: "Phoenix 1.7"
171 frontend: "LiveView"
172 status: done
173 description: "Real-time, collaborative"
174 topologies: [standard]
175 best_foplanned
176 description: "atus: done
177 description: "Modern enterprise, non-Angular"
178 topologies: [standard, api-only]
179 best_for: "Java teams preferring React over Angular"
180
181 - name: laravel-livewire
182 repo: ConflictHQ/boilerworks-laravel-livewire
183 size: full
184 language: php
185 backend: "Laravel 12"
186 fronteplanned
187 description: "St_for: "PHP teams wantn: "Server-rendered reactive PHP"
188 topologies: [standard]
189 best_for: "PHP teams wanting server-rendered reactivity without JS"
190
191 - name: fastapi-htmx
192 repo: ConflictHQ/boilerworks-fastapi-htmx
193 size: full
194 language: python
195 backend: "FastAPI"
196 frontend: "Hplannedt-first teams builightweight Python, server-rendered"
197 topologies: [standard]
198 best_for: "FastAPI teams preferring server-rendered HTML"
199
200 # ─── Micro Templates (6) ──────────────────────────────────────────────────────
201
202 - name: django-micro
203 repo: ConflictHQ/boilerworks-django-micro
204 size: micro
205 language: python
206 backend: "Django 5 (DRF/Ninja)"
207 frontend: ""
208 status: done
209 description: "API-only Python service"
210 topologies: [standard]
211 best_for: "Python microservices with Django's ORM and admin"
212
213 - name: fastapi-micro
214 repo: ConflictHQ/boilerworks-fastapi-micro
215 size: micro
216 language: python
217 backend: "FastAPI"
218 frontend: ""
219 status: done
220 description: "Lightweight async Python API"
221 topologies: [standard]
222 best_for: "High-performance async Python microservices"
223
224 - name: nestjs-micro
225 repo: ConflictHQ/boilerworks-nestjs-micro
226 size: micro
227 language: typescript
228 backend: "NestJS 11"
229 frontend: ""
230 status: done
231 description: "API-only TypeScript service"
232 topologies: [standard]
233 best_for: "TypeScript microservices with NestJS DI and modules"
234
235 - name: go-micro
236 repo: ConflictHQ/boilerworks-go-micro
237 size: micro
238 language: go
239 backend: "Go + Chi"
240 frontend: ""
241 status: done
242 description: "Lightweight Go service"
243 topologies: [standard]
244 best_for: "High-throughput Go microservices"
245
246 - name: rust-micro
247 repo: ConflictHQ/boilerworks-rust-micro
248 size: micro
249 language: rust
250 backend: "Axum (Rust)"
251 frontend: ""
252 status: done
253 description: "High-performance Rust microservice"
254 topologies: [standard]
255 best_for: "Maximum performance microservices in Rust"
256
257 - name: cherrypy-micro
258 repo: ConflictHQ/boilerworks-cherrypy-micro
259 size: micro
260 language: python
261 backend: "CherryPy"
262 frontend: ""
263 status: planned
264 description: "Pureor: "High-performanc─────────────────────────────────────────────
265
266 - name: django-micro
267 repo: ConflictHQ/boilerworks-django-micro
268 size: micro
269 language: python
270 backend: "Django 5 (DRF/Ninja)"
271 frontend: ""
272 status: done
273 description: "API-only Python service"
274 topologies: [standard]
275 best_for: "Python microservices with Django's ORM and admin"
276
277 - name: fastapi-micrplanned
278 description: "boilerworks-fastapi-micro
279 size: micro
280 language: python
281 backend: "FastAPI"
282 frontend: ""
283 status: done
284 description: "Lightweight async Python API"
285 topologies: [standard]
286 best_for: "High-performance async Python microservices"
287
288 - name: nestjs-micro
289 repo: ConflictHQ/boilerworks-nestjs-micro
290 size: micro
291 language: typescript
292 backend: "NestJS 11"
293 frontend: ""
294 status: done
295 description: "API-only TypeScript service"
296 topologies: [standard]
297 best_for: "TypeScript microservices with NestJS DI and modules"
298
299 - name: go-mplanned
300 description: "HQ/boilerworks-go-micro
301 size: micro
302 language: go
303 backend: "Go + Chi"
304 frontend: ""
305 status: done
306 description: "
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -0,0 +1,62 @@
1
+[build-system]
2
+requires = ["hatchling"]
3
+build-backend = "hatchling.build"
4
+
5
+[project]
6
+name = "boilerworks"
7
+version = "0.1.0"
8
+description = "Production-ready project templates — assembled in seconds."
9
+readme = "README.md"
10
+requires-python = ">=3.12"
11
+license = { text = "MIT" }
12
+keywords = ["boilerplate", "templates", "scaffolding", "cli"]
13
+classifiers = [
14
+ "Development Status :: 3 - Alpha",
15
+ "Environment :: Console",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Topic :: Software Development :: Code Generators",
21
+]
22
+dependencies = [
23
+ "click>=8.1",
24
+ "questionary>=2.0",
25
+ "rich>=13.0",
26
+ "pydantic>=2.0",
27
+ "jinja2>=3.0",
28
+ "gitpython>=3.1",
29
+ "mcp[cli]>=1.0"]
30
+
31
+[
32
+[project.urls]
33
+Homepage = "https://boilerworks.dev"
34
+Repository = "https://github.com/ConflictHQ/boilerworks"
35
+Issues = "https://github.com/ConflictHQ/boilerworks/issues"
36
+
37
+[tool.hatch.build.targets.wheel]
38
+packages = ["boilerworks"]
39
+
40
+[tool.hatch.build.targets.sdist]
41
+include = ["boilerworks/", "tests/"]
42
+
43
+[dependency-groups]
44
+dev = [
45
+ "pytest>=8.0",
46
+ "pytest-cov>=5.0",
47
+ "ruff>=0.9",
48
+]
49
+
50
+[tool.ruff]
51
+target-version = "py312"
52
+line-length = 120
53
+
54
+[tool.ruff.lint]
55
+select = ["E", "F", "I", "W", "UP", "B", "SIM", "N"]
56
+
57
+[tool.pytest.ini_options]
58
+testpaths = ["tests"]
59
+addopts = "--cov=boilerworks --cov-report=term-missing --cov-fail-under=80"
60
+filterwarnings = ["ignore::pytest.PytestCollectionWarning"]
61
+
62
+[tool.covera
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -0,0 +1,62 @@
1 [build-system]
2 requires = ["hatchling"]
3 build-backend = "hatchling.build"
4
5 [project]
6 name = "boilerworks"
7 version = "0.1.0"
8 description = "Production-ready project templates — assembled in seconds."
9 readme = "README.md"
10 requires-python = ">=3.12"
11 license = { text = "MIT" }
12 keywords = ["boilerplate", "templates", "scaffolding", "cli"]
13 classifiers = [
14 "Development Status :: 3 - Alpha",
15 "Environment :: Console",
16 "Intended Audience :: Developers",
17 "License :: OSI Approved :: MIT License",
18 "Programming Language :: Python :: 3",
19 "Programming Language :: Python :: 3.12",
20 "Topic :: Software Development :: Code Generators",
21 ]
22 dependencies = [
23 "click>=8.1",
24 "questionary>=2.0",
25 "rich>=13.0",
26 "pydantic>=2.0",
27 "jinja2>=3.0",
28 "gitpython>=3.1",
29 "mcp[cli]>=1.0"]
30
31 [
32 [project.urls]
33 Homepage = "https://boilerworks.dev"
34 Repository = "https://github.com/ConflictHQ/boilerworks"
35 Issues = "https://github.com/ConflictHQ/boilerworks/issues"
36
37 [tool.hatch.build.targets.wheel]
38 packages = ["boilerworks"]
39
40 [tool.hatch.build.targets.sdist]
41 include = ["boilerworks/", "tests/"]
42
43 [dependency-groups]
44 dev = [
45 "pytest>=8.0",
46 "pytest-cov>=5.0",
47 "ruff>=0.9",
48 ]
49
50 [tool.ruff]
51 target-version = "py312"
52 line-length = 120
53
54 [tool.ruff.lint]
55 select = ["E", "F", "I", "W", "UP", "B", "SIM", "N"]
56
57 [tool.pytest.ini_options]
58 testpaths = ["tests"]
59 addopts = "--cov=boilerworks --cov-report=term-missing --cov-fail-under=80"
60 filterwarnings = ["ignore::pytest.PytestCollectionWarning"]
61
62 [tool.covera
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -0,0 +1,27 @@
1
+"""Shared pytest fixtures."""
2
+
3
+from __future__ import annotations
4
+
5
+import pytest
6
+
7
+from boilerworks.manifest import BoilerworksManifest, DataConfig, ServicesConfig
8
+from boilerworks.manifest import TestingConfig as TestingCfg
9
+
10
+
11
+@pytest.fixture()
12
+def valid_manifest() -> BoilerworksManifest:
13
+ return BoilerworksManifest(
14
+ project="my-app",
15
+ family="django-nextjs",
16
+ size="full",
17
+ topology="standard",
18
+ cloud="aws",
19
+ region="us-east-1",
20
+ domain="myapp.com",
21
+ mobile=False,
22
+ web_presence=False,
23
+ compliance=["soc2"],
24
+ services=ServicesConfig(email="ses"),
25
+ data=DataConfig(),
26
+ testing=TestingCfg(e2e="playwright"),
27
+ )
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -0,0 +1,27 @@
1 """Shared pytest fixtures."""
2
3 from __future__ import annotations
4
5 import pytest
6
7 from boilerworks.manifest import BoilerworksManifest, DataConfig, ServicesConfig
8 from boilerworks.manifest import TestingConfig as TestingCfg
9
10
11 @pytest.fixture()
12 def valid_manifest() -> BoilerworksManifest:
13 return BoilerworksManifest(
14 project="my-app",
15 family="django-nextjs",
16 size="full",
17 topology="standard",
18 cloud="aws",
19 region="us-east-1",
20 domain="myapp.com",
21 mobile=False,
22 web_presence=False,
23 compliance=["soc2"],
24 services=ServicesConfig(email="ses"),
25 data=DataConfig(),
26 testing=TestingCfg(e2e="playwright"),
27 )
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -0,0 +1,104 @@
1
+"""Tests for boilerworks.cli — Click command interface."""
2
+
3
+from __future__ import annotations
4
+
5
+from click.testing import CliRunner
6
+
7
+from boilerworks.cli import main
8
+
9
+
10
+class TestHelpOutput:
11
+ def test_main_help(self) -> None:
12
+ runner = CliRunner()
13
+ result = runner.invoke(main, ["--help"])
14
+ assert result.exit_code == 0
15
+ assert "setup" in result.output
16
+ assert "init" in result.output
17
+ assert "bootstrap" in result.output
18
+ assert "list" in result.output
19
+
20
+ def test_list_help(self) -> None:
21
+ runner = CliRunner()
22
+ result = runner.invoke(main, ["list", "--help"])
23
+ assert result.exit_code == 0
24
+ assert "--size" in result.output
25
+ assert "--language" in result.output
26
+ assert "--status" in result.output
27
+
28
+ def test_init_help(self) -> None:
29
+ runner = CliRunner()
30
+ result = runner.invoke(main, ["init", "--help"])
31
+ assert result.exit_code == 0
32
+ assert "--manifest" in result.output
33
+ assert "--output" in result.output
34
+ assert "--dry-run" in result.output
35
+
36
+ def test_bootstrap_help(self) -> None:
37
+ runner = CliRunner()
38
+ result = runner.invoke(main, ["bootstrap", "--help"])
39
+ assert result.exit_code == 0
40
+
41
+ def test_version(self) -> None:
42
+ runner = CliRunner()
43
+ result = runner.invoke(main, ["--version"])
44
+ assert result.exit_code == 0
45
+ assert "0.1.0" in result.output
46
+
47
+
48
+class TestListCommand:
49
+ def test_list_all(self) -> None:
50
+ runner = CliRunner()
51
+ result = runner.invoke(main, ["list"])
52
+ assert result.exit_code == 0
53
+ assert "django-nextjs" in result.output
54
+ assert "fastapi-micro" in result.output
55
+ assert "astro-site" in result.output
56
+
57
+ def test_list_filter_size_micro(self) -> None:
58
+ runner = CliRunner()
59
+ result = runner.invoke(main, ["list", "--size", "micro"])
60
+ assert result.exit_code == 0
61
+ assert "fastapi-micro" in result.output
62
+ assert "django-nextjs" not in result.output
63
+
64
+ def test_list_filter_size_edge(self) -> None:
65
+ runner = CliRunner()
66
+ result = runner.invoke(main, ["list", "--size", "edge"])
67
+ assert result.exit_code == 0
68
+ assert "astro-site" in result.output
69
+ assert "django-nextjs" not in result.output
70
+
71
+ def test_list_filter_language_python(self) -> None:
72
+ runner = CliRunner()
73
+ result = runner.invoke(main, ["list", "--language", "python"])
74
+ assert result.exit_code == 0
75
+ assert "django-nextjs" in result.output
76
+ assert "nestjs-nextjs" not in result.output
77
+
78
+ def test_list_filter_size_and_language(self) -> None:
79
+ runner = CliRunner()
80
+ result = runner.invoke(main, ["list", "--size", "micro", "--language", "python"])
81
+ assert result.exit_code == 0
82
+ assert "fastapi-micro" in result.output
83
+ assert "nestjs-micro" not in result.output
84
+
85
+ def test_list_filter_status_done(self) -> None:
86
+ runner = CliRunner()
87
+ result = runner.invoke(main, ["list", "--status", "done"])
88
+ assert result.exit_code == 0
89
+ assert "django-nextjs" in result.output
90
+
91
+ def test_list_no_results_shows_message(self) -> None:
92
+ runner = CliRunner()
93
+ # rust + full → no results
94
+ result = runner.invoke(main, ["list", "--size", "full", "--language", "rust"])
95
+ assert result.exit_code == 0
96
+ assert "No templates match" in result.output
97
+
98
+
99
+class TestBootstrapCommand:
100
+ def test_bootstrap_runs(self) -> None:
101
+ runner = CliRunner()
102
+ result = runner.invoke(main, ["bootstrap"])
103
+ assert result.exit_code == 0
104
+ assert "Bootstrap Plan" in result.output or "v1" in result.output or "v2" in result.output
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -0,0 +1,104 @@
1 """Tests for boilerworks.cli — Click command interface."""
2
3 from __future__ import annotations
4
5 from click.testing import CliRunner
6
7 from boilerworks.cli import main
8
9
10 class TestHelpOutput:
11 def test_main_help(self) -> None:
12 runner = CliRunner()
13 result = runner.invoke(main, ["--help"])
14 assert result.exit_code == 0
15 assert "setup" in result.output
16 assert "init" in result.output
17 assert "bootstrap" in result.output
18 assert "list" in result.output
19
20 def test_list_help(self) -> None:
21 runner = CliRunner()
22 result = runner.invoke(main, ["list", "--help"])
23 assert result.exit_code == 0
24 assert "--size" in result.output
25 assert "--language" in result.output
26 assert "--status" in result.output
27
28 def test_init_help(self) -> None:
29 runner = CliRunner()
30 result = runner.invoke(main, ["init", "--help"])
31 assert result.exit_code == 0
32 assert "--manifest" in result.output
33 assert "--output" in result.output
34 assert "--dry-run" in result.output
35
36 def test_bootstrap_help(self) -> None:
37 runner = CliRunner()
38 result = runner.invoke(main, ["bootstrap", "--help"])
39 assert result.exit_code == 0
40
41 def test_version(self) -> None:
42 runner = CliRunner()
43 result = runner.invoke(main, ["--version"])
44 assert result.exit_code == 0
45 assert "0.1.0" in result.output
46
47
48 class TestListCommand:
49 def test_list_all(self) -> None:
50 runner = CliRunner()
51 result = runner.invoke(main, ["list"])
52 assert result.exit_code == 0
53 assert "django-nextjs" in result.output
54 assert "fastapi-micro" in result.output
55 assert "astro-site" in result.output
56
57 def test_list_filter_size_micro(self) -> None:
58 runner = CliRunner()
59 result = runner.invoke(main, ["list", "--size", "micro"])
60 assert result.exit_code == 0
61 assert "fastapi-micro" in result.output
62 assert "django-nextjs" not in result.output
63
64 def test_list_filter_size_edge(self) -> None:
65 runner = CliRunner()
66 result = runner.invoke(main, ["list", "--size", "edge"])
67 assert result.exit_code == 0
68 assert "astro-site" in result.output
69 assert "django-nextjs" not in result.output
70
71 def test_list_filter_language_python(self) -> None:
72 runner = CliRunner()
73 result = runner.invoke(main, ["list", "--language", "python"])
74 assert result.exit_code == 0
75 assert "django-nextjs" in result.output
76 assert "nestjs-nextjs" not in result.output
77
78 def test_list_filter_size_and_language(self) -> None:
79 runner = CliRunner()
80 result = runner.invoke(main, ["list", "--size", "micro", "--language", "python"])
81 assert result.exit_code == 0
82 assert "fastapi-micro" in result.output
83 assert "nestjs-micro" not in result.output
84
85 def test_list_filter_status_done(self) -> None:
86 runner = CliRunner()
87 result = runner.invoke(main, ["list", "--status", "done"])
88 assert result.exit_code == 0
89 assert "django-nextjs" in result.output
90
91 def test_list_no_results_shows_message(self) -> None:
92 runner = CliRunner()
93 # rust + full → no results
94 result = runner.invoke(main, ["list", "--size", "full", "--language", "rust"])
95 assert result.exit_code == 0
96 assert "No templates match" in result.output
97
98
99 class TestBootstrapCommand:
100 def test_bootstrap_runs(self) -> None:
101 runner = CliRunner()
102 result = runner.invoke(main, ["bootstrap"])
103 assert result.exit_code == 0
104 assert "Bootstrap Plan" in result.output or "v1" in result.output or "v2" in result.output
--- a/tests/test_console.py
+++ b/tests/test_console.py
@@ -0,0 +1,103 @@
1
+"""Tests for boilerworks.console."""
2
+
3
+from __future__ import annotations
4
+
5
+from io import StringIO
6
+
7
+from rich.console import Console
8
+
9
+from boilerworks.console import (
10
+ print_error,
11
+ print_info,
12
+ print_success,
13
+ print_template_detail,
14
+ print_template_table,
15
+ print_warning,
16
+)
17
+from boilerworks.registry import Registry, TemplateInfo
18
+
19
+
20
+def _capture(fn, *args, **kwargs) -> str:
21
+ """Call fn with a capturing console and return the output."""
22
+ buf = StringIO()
23
+ cap = Console(file=buf, highlight=False, markup=True)
24
+ # Temporarily override the module-level console
25
+ import boilerworks.console as _mod
26
+
27
+ original = _mod.console
28
+ _mod.console = cap
29
+ try:
30
+ fn(*args, **kwargs)
31
+ finally:
32
+ _mod.console = original
33
+ return buf.getvalue()
34
+
35
+
36
+class TestPrintTemplateTable:
37
+ def test_shows_all_templates(self) -> None:
38
+ registry = Registry()
39
+ templates = registry.list_all()
40
+ output = _capture(print_template_table, templates)
41
+ assert "django-nextjs" in output
42
+ assert "astro-site" in output
43
+
44
+ def test_shows_26_count(self) -> None:
45
+ registry = Registry()
46
+ templates = registry.list_all()
47
+ output = _capture(print_template_table, templates)
48
+ assert "26" in output
49
+
50
+ def test_empty_list_shows_message(self) -> None:
51
+ output = _capture(print_template_table, [])
52
+ assert "No templates match" in output
53
+
54
+ def test_filtered_table(self) -> None:
55
+ registry = Registry()
56
+ templates = registry.filter_by_size("micro")
57
+ output = _capture(print_template_table, templates)
58
+ assert "fastapi-micro" in output
59
+ assert "django-nextjs" not in output
60
+
61
+
62
+class TestPrintTemplateDetail:
63
+ def test_shows_template_fields(self) -> None:
64
+ registry = Registry()
65
+ template = registry.get_by_name("django-nextjs")
66
+ assert template is not None
67
+ output = _capture(print_template_detail, template)
68
+ assert "django-nextjs" in output
69
+ assert "python" in output.lower()
70
+ assert "Django" in output
71
+
72
+ def test_template_without_frontend(self) -> None:
73
+ # Micro templates have no frontend
74
+ t = TemplateInfo(
75
+ name="test-micro",
76
+ repo="ConflictHQ/test",
77
+ size="micro",
78
+ language="python",
79
+ backend="FastAPI",
80
+ frontend="",
81
+ status="planned",
82
+ description="Test",
83
+ )
84
+ output = _capture(print_template_detail, t)
85
+ assert "test-micro" in output
86
+
87
+
88
+class TestPrintHelpers:
89
+ def test_print_success(self) -> None:
90
+ output = _capture(print_success, "All good")
91
+ assert "All good" in output
92
+
93
+ def test_print_error(self) -> None:
94
+ output = _capture(print_error, "Something failed")
95
+ assert "Something failed" in output
96
+
97
+ def test_print_info(self) -> None:
98
+ output = _capture(print_info, "Just FYI")
99
+ assert "Just FYI" in output
100
+
101
+ def test_print_warning(self) -> None:
102
+ output = _capture(print_warning, "Be careful")
103
+ assert "Be careful" in output
--- a/tests/test_console.py
+++ b/tests/test_console.py
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/tests/test_console.py
+++ b/tests/test_console.py
@@ -0,0 +1,103 @@
1 """Tests for boilerworks.console."""
2
3 from __future__ import annotations
4
5 from io import StringIO
6
7 from rich.console import Console
8
9 from boilerworks.console import (
10 print_error,
11 print_info,
12 print_success,
13 print_template_detail,
14 print_template_table,
15 print_warning,
16 )
17 from boilerworks.registry import Registry, TemplateInfo
18
19
20 def _capture(fn, *args, **kwargs) -> str:
21 """Call fn with a capturing console and return the output."""
22 buf = StringIO()
23 cap = Console(file=buf, highlight=False, markup=True)
24 # Temporarily override the module-level console
25 import boilerworks.console as _mod
26
27 original = _mod.console
28 _mod.console = cap
29 try:
30 fn(*args, **kwargs)
31 finally:
32 _mod.console = original
33 return buf.getvalue()
34
35
36 class TestPrintTemplateTable:
37 def test_shows_all_templates(self) -> None:
38 registry = Registry()
39 templates = registry.list_all()
40 output = _capture(print_template_table, templates)
41 assert "django-nextjs" in output
42 assert "astro-site" in output
43
44 def test_shows_26_count(self) -> None:
45 registry = Registry()
46 templates = registry.list_all()
47 output = _capture(print_template_table, templates)
48 assert "26" in output
49
50 def test_empty_list_shows_message(self) -> None:
51 output = _capture(print_template_table, [])
52 assert "No templates match" in output
53
54 def test_filtered_table(self) -> None:
55 registry = Registry()
56 templates = registry.filter_by_size("micro")
57 output = _capture(print_template_table, templates)
58 assert "fastapi-micro" in output
59 assert "django-nextjs" not in output
60
61
62 class TestPrintTemplateDetail:
63 def test_shows_template_fields(self) -> None:
64 registry = Registry()
65 template = registry.get_by_name("django-nextjs")
66 assert template is not None
67 output = _capture(print_template_detail, template)
68 assert "django-nextjs" in output
69 assert "python" in output.lower()
70 assert "Django" in output
71
72 def test_template_without_frontend(self) -> None:
73 # Micro templates have no frontend
74 t = TemplateInfo(
75 name="test-micro",
76 repo="ConflictHQ/test",
77 size="micro",
78 language="python",
79 backend="FastAPI",
80 frontend="",
81 status="planned",
82 description="Test",
83 )
84 output = _capture(print_template_detail, t)
85 assert "test-micro" in output
86
87
88 class TestPrintHelpers:
89 def test_print_success(self) -> None:
90 output = _capture(print_success, "All good")
91 assert "All good" in output
92
93 def test_print_error(self) -> None:
94 output = _capture(print_error, "Something failed")
95 assert "Something failed" in output
96
97 def test_print_info(self) -> None:
98 output = _capture(print_info, "Just FYI")
99 assert "Just FYI" in output
100
101 def test_print_warning(self) -> None:
102 output = _capture(print_warning, "Be careful")
103 assert "Be careful" in output
--- a/tests/test_generator.py
+++ b/tests/test_generator.py
@@ -0,0 +1,306 @@
1
+"""Tests for boilerworks.generator."""
2
+
3
+from __future__ import annotations
4
+
5
+import shutil
6
+from pathlib import Path
7
+from unittest.mock import MagicMock, patch
8
+
9
+import pytest
10
+
11
+from boilerworks.generator import (
12
+ _clone_and_render_ops,
13
+ _dry_run_plan,
14
+ _write_ops_config,
15
+ generate_from_manifest,
16
+)
17
+from boilerworks.manifest import BoilerworksManifest
18
+
19
+
20
+class TestDryRun:
21
+ def test_dry_run_no_files_created(self, tmp_path: Path, valid_manifest: BoilerworksManifest) -> None:
22
+ """Dry-run should print the plan without touching the filesystem."""
23
+ project_dir = tmp_path / valid_manifest.project
24
+ _dry_run_plan(valid_manifest, tmp_path)
25
+ assert not project_dir.exists()
26
+
27
+ def test_generate_from_manifest_dry_run(self, tmp_path: Path, valid_manifest: BoilerworksManifest) -> None:
28
+ """generate_from_manifest with dry_run=True must not create any files."""
29
+ manifest_file = tmp_path / "boilerworks.yaml"
30
+ valid_manifest.to_file(manifest_file)
31
+
32
+ project_dir = tmp_path / valid_manifest.project
33
+ generate_from_manifest(
34
+ manifest_path=str(manifest_file),
35
+ output_dir=str(tmp_path),
36
+ dry_run=True,
37
+ )
38
+ assert not project_dir.exists()
39
+
40
+ def test_dry_run_with_ops_standard(self, tmp_path: Path) -> None:
41
+ """Dry-run with cloud + ops shows ops clone step (standard topology)."""
42
+ manifest = BoilerworksManifest(
43
+ project="test-app",
44
+ family="django-nextjs",
45
+ size="full",
46
+ cloud="aws",
47
+ ops=True,
48
+ topology="standard",
49
+ )
50
+ _dry_run_plan(manifest, tmp_path) # should not raise
51
+
52
+ def test_dry_run_with_ops_omni(self, tmp_path: Path) -> None:
53
+ """Dry-run with cloud + ops shows ops clone step (omni topology)."""
54
+ manifest = BoilerworksManifest(
55
+ project="test-app",
56
+ family="django-nextjs",
57
+ size="full",
58
+ cloud="gcp",
59
+ ops=True,
60
+ topology="omni",
61
+ )
62
+ _dry_run_plan(manifest, tmp_path) # should not raise
63
+
64
+ def test_dry_run_no_ops_when_flag_false(self, tmp_path: Path) -> None:
65
+ """Dry-run with cloud set but ops=False does not include ops steps."""
66
+ manifest = BoilerworksManifest(
67
+ project="test-app",
68
+ family="django-nextjs",
69
+ size="full",
70
+ cloud="aws",
71
+ ops=False,
72
+ )
73
+ _dry_run_plan(manifest, tmp_path) # should not raise
74
+
75
+ def test_dry_run_shows_mobile_step(self, tmp_path: Path) -> None:
76
+ manifest = BoilerworksManifest(
77
+ project="test-app",
78
+ family="django-nextjs",
79
+ size="full",
80
+ mobile=True,
81
+ )
82
+ _dry_run_plan(manifest, tmp_path)
83
+
84
+
85
+class TestWriteOpsConfig:
86
+ def _make_ops_dir(self, tmp_path: Path, cloud: str) -> Path:
87
+ ops_dir = tmp_path / "ops"
88
+ cloud_dir = ops_dir / cloud
89
+ cloud_dir.mkdir(parents=True)
90
+ config = cloud_dir / "config.env"
91
+ config.write_text('PROJECT="boilerworks"\nAWS_REGION="us-west-2"\nOWNER="conflict"\n')
92
+ return ops_dir
93
+
94
+ def test_aws_config_written(self, tmp_path: Path) -> None:
95
+ ops_dir = self._make_ops_dir(tmp_path, "aws")
96
+ _write_ops_config(ops_dir, "aws", "myproject", "eu-west-1", "myproject.com")
97
+
98
+ content = (ops_dir / "aws" / "config.env").read_text()
99
+ assert 'PROJECT="myproject"' in content
100
+ assert 'AWS_REGION="eu-west-1"' in content
101
+ assert 'OWNER="myproject"' in content
102
+ assert 'DOMAIN="myproject.com"' in content
103
+
104
+ def test_gcp_config_written(self, tmp_path: Path) -> None:
105
+ ops_dir = self._make_ops_dir(tmp_path, "gcp")
106
+ _write_ops_config(ops_dir, "gcp", "myproject", "us-central1", None)
107
+
108
+ content = (ops_dir / "gcp" / "config.env").read_text()
109
+ assert 'PROJECT="myproject"' in content
110
+ assert 'GCP_REGION="us-central1"' in content
111
+ assert "DOMAIN" not in content
112
+
113
+ def test_azure_config_default_region(self, tmp_path: Path) -> None:
114
+ ops_dir = self._make_ops_dir(tmp_path, "azure")
115
+ _write_ops_config(ops_dir, "azure", "myproject", None, None)
116
+
117
+ content = (ops_dir / "azure" / "config.env").read_text()
118
+ assert 'AZURE_REGION="eastus"' in content
119
+
120
+ def test_missing_config_file_is_noop(self, tmp_path: Path) -> None:
121
+ """If config.env doesn't exist yet, write_ops_config should not raise."""
122
+ ops_dir = tmp_path / "ops"
123
+ ops_dir.mkdir()
124
+ (ops_dir / "aws").mkdir()
125
+ # No config.env file
126
+ _write_ops_config(ops_dir, "aws", "myproject", "us-east-1", None)
127
+
128
+
129
+class TestCloneAndRenderOps:
130
+ def _fake_clone(self, src: Path) -> None:
131
+ """Create a fake clone that looks like a minimal boilerworks-opscode."""
132
+ src.mkdir(parents=True, exist_ok=True)
133
+ (src / ".git").mkdir()
134
+ (src / "aws").mkdir()
135
+ (src / "aws" / "config.env").write_text('PROJECT="boilerworks"\nAWS_REGION="us-west-2"\nOWNER="conflict"\n')
136
+ (src / "README.md").write_text("# Boilerworks Opscode\nBoilerworks infrastructure.\n")
137
+
138
+ def test_ops_clone_and_render_standard(self, tmp_path: Path) -> None:
139
+ """_clone_and_render_ops populates dest and renders project name."""
140
+ ops_dest = tmp_path / "myproject-ops"
141
+
142
+ def fake_clone(repo: str, dest: Path) -> None:
143
+ self._fake_clone(dest)
144
+
145
+ progress = MagicMock()
146
+ progress.add_task.return_value = "task-id"
147
+
148
+ with patch("boilerworks.generator._clone_repo", side_effect=fake_clone):
149
+ _clone_and_render_ops("myproject", "aws", "us-east-1", "myproject.com", ops_dest, progress)
150
+
151
+ assert ops_dest.exists()
152
+ assert not (ops_dest / ".git").exists()
153
+ config_content = (ops_dest / "aws" / "config.env").read_text()
154
+ assert 'PROJECT="myproject"' in config_content
155
+
156
+ def test_ops_clone_failure_exits(self, tmp_path: Path) -> None:
157
+ """When cloning ops fails, process exits."""
158
+ ops_dest = tmp_path / "myproject-ops"
159
+ progress = MagicMock()
160
+ progress.add_task.return_valueself, tmp_path: Path) -> Non._clone_repo", side_effect=Ru:
161
+ f, tmp_path: Path) -> Non _clone_and_render_ops("myproNone, ops_dest, progress)
162
+
163
+="myproject"' in co""Tests for boilerworks.generator."""
164
+
165
+from __future__ import annotations
166
+
167
+import shutil
168
+from pathlib import Path
169
+from unittest.mock import MagicMock, patch
170
+
171
+import pytest
172
+
173
+from boilerworks.generator import (
174
+ _clone_and_render_ops,
175
+ _dry_run_plan,
176
+ _write_ops_config,
177
+ generate_from_manifest,
178
+)
179
+from boilerworks.manifest import BoilerworksManifest
180
+
181
+
182
+class TestDryRun:
183
+ def test_dry_run_no_files_created(self, tmp_path: Path, valid_manifest: BoilerworksManifest) -> None:
184
+ """Dry-run should print the plan without touching the filesystem."""
185
+ project_dir = tmp_path / valid_manifest.project
186
+ _dry_run_plan(valid_manifest, tmp_path)
187
+ assert not project_dir.exists()
188
+
189
+ def test_generate_from_manifest_dry_run(self, tmp_path: Path, valid_manifest: BoilerworksManifest) -> None:
190
+ """generate_from_manifest with dry_run=True must not create any files."""
191
+ manifest_file = tmp_path / "boilerworks.yaml"
192
+ valid_manifest.to_file(manifest_file)
193
+
194
+ project_dir = tmp_path / valid_manifest.projec"""Tests for boilerworks.generator."""
195
+
196
+from __future__ import ann:
197
+ "Tests foilerworkssubprocess.run"):
198
+ il
199
+from """Tests for boilerworks.generator."""
200
+
201
+from __future__ import annotations
202
+
203
+import shutil
204
+fr"ath / "myapp-ops").exists()
205
+ assert not (tmp_path / "myapp-ops" / ".git").exists()
206
+
207
+ def test_generate_omni_with_ops(self, tmp_path: PMagicMock, patch
208
+
209
+i"""Tests for boilerworks.g(tmp_path / "myapp-ops").exists()
210
+ assert not (tmp_path / "myapp-ops" / ".git").exists()
211
+
212
+ def test_generate_omni_with_ops(self, tmp_path: Path) -> None:
213
+ """Omni topology: ops/ lives inside the app directory."""
214
+ manifest = BoilerworksManifest(
215
+ project="myapp",
216
+ family="django-nextjs",
217
+ size="full",
218
+ topology="omni",
219
+ cloud="aws",
220
+ ops=True,
221
+ region="us-east-1",
222
+ )
223
+ manifest_file = tmp_path / "boilerworks.yaml"
224
+ manifest.to_file(manifest_file)
225
+
226
+ call_count = 0
227
+
228
+ def fake_clone(repo: str, dest: Path) -> None:
229
+ nonlocal call_count
230
+ call_count += 1
231
+ if "opscode" in repo:
232
+ self._seed_opscode(dest)
233
+ else:
234
+ self._seed_template(dest)
235
+
236
+ with self, tmp_path: Path) -> None:
237
+ """Omni topology: ops/ live:
238
+ "Tests foilerworkssubprocess.run"):
239
+ il
240
+from """Tests for boilerworks.generator."""
241
+
242
+from __future__ import annotations
243
+
244
+import shutil
245
+fr"ath / "myapp-ops").exists()
246
+ assert not (tmp_path / "myapp-ops" / ".git").exists()
247
+
248
+ def test_generate_omni_with_ops(self, tmp_path: PMagicMock, patch
249
+
250
+i"""Tests for boilerworks.gMagicMock, patch
251
+
252
+import pytest
253
+
254
+from boilerworks.generator import (
255
+ _clone_and_render_ops,
256
+ _dry_run_plan,
257
+ _write_ops_config,
258
+ generate_from_manifest,
259
+)
260
+from boilerworks.manifest import BoilerworksManifest
261
+
262
+
263
+class TestDryRun:
264
+ def test_dry_run_no_files_created(self, tmp_path: Path, valid_manifest: BoilerworksManifest) -> None:
265
+ """Dry-run should print the plan without touching the filesystem."""
266
+ project_dir = tmp_path / valid_manifest.project
267
+ _dry_run_plan(valid_manifest, tmp_path)
268
+ assert not project_dir.exists()
269
+
270
+ def test_generate_from_manifest_dry_run(self, tmp_path: Path, valid_manifest: BoilerworksManifest) ->"""Tests for boilerworks.generator."""
271
+
272
+from __future__ import ann:
273
+ "Tests foilerworkssubprocess.run"):
274
+ il
275
+from """Tests for boilerworks.generator."""
276
+
277
+from __future__ import annotations
278
+
279
+import shutil
280
+from pathlib import Path
281
+from unittest.mock import MagicMock, patch
282
+
283
+import pytest
284
+
285
+from boilerworks.generator import (
286
+ _clone_and_render_ops,
287
+ _dry_run_plan,
288
+ _write_ops_config,
289
+ generate_from_manifest,
290
+)
291
+from boilerworks.manifest import BoilerworksManifest
292
+
293
+
294
+class TestDryRun:
295
+ def test_dry_run_no_files_created(self, tmp_path: Path, valid_manifest: BoilerworksManifest) -> None:
296
+ """Dry-run should print the plan without touching the filesystem."""
297
+ project_dir = tmp_path / valid_manifest.project
298
+ _dry_run_plan(valid_manifest, tmp_path)
299
+ assert not project_dir.exists()
300
+
301
+ def test_generate_from_manifest_dry_run(self, tmp_path: Path, valid_manifest: BoilerworksManifest) -> None:
302
+ """generate_from_manifest with dry"""Tests for boilerworks.generator."""
303
+
304
+from __future__ import ann:
305
+ "Tests foilerworkssubprocess.run"):
306
+ st.to_fi, tmp_path: Path) -> Nonshutil.rmtree(tmp_path / "m
--- a/tests/test_generator.py
+++ b/tests/test_generator.py
@@ -0,0 +1,306 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/tests/test_generator.py
+++ b/tests/test_generator.py
@@ -0,0 +1,306 @@
1 """Tests for boilerworks.generator."""
2
3 from __future__ import annotations
4
5 import shutil
6 from pathlib import Path
7 from unittest.mock import MagicMock, patch
8
9 import pytest
10
11 from boilerworks.generator import (
12 _clone_and_render_ops,
13 _dry_run_plan,
14 _write_ops_config,
15 generate_from_manifest,
16 )
17 from boilerworks.manifest import BoilerworksManifest
18
19
20 class TestDryRun:
21 def test_dry_run_no_files_created(self, tmp_path: Path, valid_manifest: BoilerworksManifest) -> None:
22 """Dry-run should print the plan without touching the filesystem."""
23 project_dir = tmp_path / valid_manifest.project
24 _dry_run_plan(valid_manifest, tmp_path)
25 assert not project_dir.exists()
26
27 def test_generate_from_manifest_dry_run(self, tmp_path: Path, valid_manifest: BoilerworksManifest) -> None:
28 """generate_from_manifest with dry_run=True must not create any files."""
29 manifest_file = tmp_path / "boilerworks.yaml"
30 valid_manifest.to_file(manifest_file)
31
32 project_dir = tmp_path / valid_manifest.project
33 generate_from_manifest(
34 manifest_path=str(manifest_file),
35 output_dir=str(tmp_path),
36 dry_run=True,
37 )
38 assert not project_dir.exists()
39
40 def test_dry_run_with_ops_standard(self, tmp_path: Path) -> None:
41 """Dry-run with cloud + ops shows ops clone step (standard topology)."""
42 manifest = BoilerworksManifest(
43 project="test-app",
44 family="django-nextjs",
45 size="full",
46 cloud="aws",
47 ops=True,
48 topology="standard",
49 )
50 _dry_run_plan(manifest, tmp_path) # should not raise
51
52 def test_dry_run_with_ops_omni(self, tmp_path: Path) -> None:
53 """Dry-run with cloud + ops shows ops clone step (omni topology)."""
54 manifest = BoilerworksManifest(
55 project="test-app",
56 family="django-nextjs",
57 size="full",
58 cloud="gcp",
59 ops=True,
60 topology="omni",
61 )
62 _dry_run_plan(manifest, tmp_path) # should not raise
63
64 def test_dry_run_no_ops_when_flag_false(self, tmp_path: Path) -> None:
65 """Dry-run with cloud set but ops=False does not include ops steps."""
66 manifest = BoilerworksManifest(
67 project="test-app",
68 family="django-nextjs",
69 size="full",
70 cloud="aws",
71 ops=False,
72 )
73 _dry_run_plan(manifest, tmp_path) # should not raise
74
75 def test_dry_run_shows_mobile_step(self, tmp_path: Path) -> None:
76 manifest = BoilerworksManifest(
77 project="test-app",
78 family="django-nextjs",
79 size="full",
80 mobile=True,
81 )
82 _dry_run_plan(manifest, tmp_path)
83
84
85 class TestWriteOpsConfig:
86 def _make_ops_dir(self, tmp_path: Path, cloud: str) -> Path:
87 ops_dir = tmp_path / "ops"
88 cloud_dir = ops_dir / cloud
89 cloud_dir.mkdir(parents=True)
90 config = cloud_dir / "config.env"
91 config.write_text('PROJECT="boilerworks"\nAWS_REGION="us-west-2"\nOWNER="conflict"\n')
92 return ops_dir
93
94 def test_aws_config_written(self, tmp_path: Path) -> None:
95 ops_dir = self._make_ops_dir(tmp_path, "aws")
96 _write_ops_config(ops_dir, "aws", "myproject", "eu-west-1", "myproject.com")
97
98 content = (ops_dir / "aws" / "config.env").read_text()
99 assert 'PROJECT="myproject"' in content
100 assert 'AWS_REGION="eu-west-1"' in content
101 assert 'OWNER="myproject"' in content
102 assert 'DOMAIN="myproject.com"' in content
103
104 def test_gcp_config_written(self, tmp_path: Path) -> None:
105 ops_dir = self._make_ops_dir(tmp_path, "gcp")
106 _write_ops_config(ops_dir, "gcp", "myproject", "us-central1", None)
107
108 content = (ops_dir / "gcp" / "config.env").read_text()
109 assert 'PROJECT="myproject"' in content
110 assert 'GCP_REGION="us-central1"' in content
111 assert "DOMAIN" not in content
112
113 def test_azure_config_default_region(self, tmp_path: Path) -> None:
114 ops_dir = self._make_ops_dir(tmp_path, "azure")
115 _write_ops_config(ops_dir, "azure", "myproject", None, None)
116
117 content = (ops_dir / "azure" / "config.env").read_text()
118 assert 'AZURE_REGION="eastus"' in content
119
120 def test_missing_config_file_is_noop(self, tmp_path: Path) -> None:
121 """If config.env doesn't exist yet, write_ops_config should not raise."""
122 ops_dir = tmp_path / "ops"
123 ops_dir.mkdir()
124 (ops_dir / "aws").mkdir()
125 # No config.env file
126 _write_ops_config(ops_dir, "aws", "myproject", "us-east-1", None)
127
128
129 class TestCloneAndRenderOps:
130 def _fake_clone(self, src: Path) -> None:
131 """Create a fake clone that looks like a minimal boilerworks-opscode."""
132 src.mkdir(parents=True, exist_ok=True)
133 (src / ".git").mkdir()
134 (src / "aws").mkdir()
135 (src / "aws" / "config.env").write_text('PROJECT="boilerworks"\nAWS_REGION="us-west-2"\nOWNER="conflict"\n')
136 (src / "README.md").write_text("# Boilerworks Opscode\nBoilerworks infrastructure.\n")
137
138 def test_ops_clone_and_render_standard(self, tmp_path: Path) -> None:
139 """_clone_and_render_ops populates dest and renders project name."""
140 ops_dest = tmp_path / "myproject-ops"
141
142 def fake_clone(repo: str, dest: Path) -> None:
143 self._fake_clone(dest)
144
145 progress = MagicMock()
146 progress.add_task.return_value = "task-id"
147
148 with patch("boilerworks.generator._clone_repo", side_effect=fake_clone):
149 _clone_and_render_ops("myproject", "aws", "us-east-1", "myproject.com", ops_dest, progress)
150
151 assert ops_dest.exists()
152 assert not (ops_dest / ".git").exists()
153 config_content = (ops_dest / "aws" / "config.env").read_text()
154 assert 'PROJECT="myproject"' in config_content
155
156 def test_ops_clone_failure_exits(self, tmp_path: Path) -> None:
157 """When cloning ops fails, process exits."""
158 ops_dest = tmp_path / "myproject-ops"
159 progress = MagicMock()
160 progress.add_task.return_valueself, tmp_path: Path) -> Non._clone_repo", side_effect=Ru:
161 f, tmp_path: Path) -> Non _clone_and_render_ops("myproNone, ops_dest, progress)
162
163 ="myproject"' in co""Tests for boilerworks.generator."""
164
165 from __future__ import annotations
166
167 import shutil
168 from pathlib import Path
169 from unittest.mock import MagicMock, patch
170
171 import pytest
172
173 from boilerworks.generator import (
174 _clone_and_render_ops,
175 _dry_run_plan,
176 _write_ops_config,
177 generate_from_manifest,
178 )
179 from boilerworks.manifest import BoilerworksManifest
180
181
182 class TestDryRun:
183 def test_dry_run_no_files_created(self, tmp_path: Path, valid_manifest: BoilerworksManifest) -> None:
184 """Dry-run should print the plan without touching the filesystem."""
185 project_dir = tmp_path / valid_manifest.project
186 _dry_run_plan(valid_manifest, tmp_path)
187 assert not project_dir.exists()
188
189 def test_generate_from_manifest_dry_run(self, tmp_path: Path, valid_manifest: BoilerworksManifest) -> None:
190 """generate_from_manifest with dry_run=True must not create any files."""
191 manifest_file = tmp_path / "boilerworks.yaml"
192 valid_manifest.to_file(manifest_file)
193
194 project_dir = tmp_path / valid_manifest.projec"""Tests for boilerworks.generator."""
195
196 from __future__ import ann:
197 "Tests foilerworkssubprocess.run"):
198 il
199 from """Tests for boilerworks.generator."""
200
201 from __future__ import annotations
202
203 import shutil
204 fr"ath / "myapp-ops").exists()
205 assert not (tmp_path / "myapp-ops" / ".git").exists()
206
207 def test_generate_omni_with_ops(self, tmp_path: PMagicMock, patch
208
209 i"""Tests for boilerworks.g(tmp_path / "myapp-ops").exists()
210 assert not (tmp_path / "myapp-ops" / ".git").exists()
211
212 def test_generate_omni_with_ops(self, tmp_path: Path) -> None:
213 """Omni topology: ops/ lives inside the app directory."""
214 manifest = BoilerworksManifest(
215 project="myapp",
216 family="django-nextjs",
217 size="full",
218 topology="omni",
219 cloud="aws",
220 ops=True,
221 region="us-east-1",
222 )
223 manifest_file = tmp_path / "boilerworks.yaml"
224 manifest.to_file(manifest_file)
225
226 call_count = 0
227
228 def fake_clone(repo: str, dest: Path) -> None:
229 nonlocal call_count
230 call_count += 1
231 if "opscode" in repo:
232 self._seed_opscode(dest)
233 else:
234 self._seed_template(dest)
235
236 with self, tmp_path: Path) -> None:
237 """Omni topology: ops/ live:
238 "Tests foilerworkssubprocess.run"):
239 il
240 from """Tests for boilerworks.generator."""
241
242 from __future__ import annotations
243
244 import shutil
245 fr"ath / "myapp-ops").exists()
246 assert not (tmp_path / "myapp-ops" / ".git").exists()
247
248 def test_generate_omni_with_ops(self, tmp_path: PMagicMock, patch
249
250 i"""Tests for boilerworks.gMagicMock, patch
251
252 import pytest
253
254 from boilerworks.generator import (
255 _clone_and_render_ops,
256 _dry_run_plan,
257 _write_ops_config,
258 generate_from_manifest,
259 )
260 from boilerworks.manifest import BoilerworksManifest
261
262
263 class TestDryRun:
264 def test_dry_run_no_files_created(self, tmp_path: Path, valid_manifest: BoilerworksManifest) -> None:
265 """Dry-run should print the plan without touching the filesystem."""
266 project_dir = tmp_path / valid_manifest.project
267 _dry_run_plan(valid_manifest, tmp_path)
268 assert not project_dir.exists()
269
270 def test_generate_from_manifest_dry_run(self, tmp_path: Path, valid_manifest: BoilerworksManifest) ->"""Tests for boilerworks.generator."""
271
272 from __future__ import ann:
273 "Tests foilerworkssubprocess.run"):
274 il
275 from """Tests for boilerworks.generator."""
276
277 from __future__ import annotations
278
279 import shutil
280 from pathlib import Path
281 from unittest.mock import MagicMock, patch
282
283 import pytest
284
285 from boilerworks.generator import (
286 _clone_and_render_ops,
287 _dry_run_plan,
288 _write_ops_config,
289 generate_from_manifest,
290 )
291 from boilerworks.manifest import BoilerworksManifest
292
293
294 class TestDryRun:
295 def test_dry_run_no_files_created(self, tmp_path: Path, valid_manifest: BoilerworksManifest) -> None:
296 """Dry-run should print the plan without touching the filesystem."""
297 project_dir = tmp_path / valid_manifest.project
298 _dry_run_plan(valid_manifest, tmp_path)
299 assert not project_dir.exists()
300
301 def test_generate_from_manifest_dry_run(self, tmp_path: Path, valid_manifest: BoilerworksManifest) -> None:
302 """generate_from_manifest with dry"""Tests for boilerworks.generator."""
303
304 from __future__ import ann:
305 "Tests foilerworkssubprocess.run"):
306 st.to_fi, tmp_path: Path) -> Nonshutil.rmtree(tmp_path / "m
--- a/tests/test_manifest.py
+++ b/tests/test_manifest.py
@@ -0,0 +1,146 @@
1
+"""Tests for boilerworks.manifest."""
2
+
3
+from __future__ import annotations
4
+
5
+import pytest
6
+from pydantic import ValidationError
7
+
8
+from boilerworks.manifest import BoilerworksManifest, DataConfig, ServicesConfig
9
+from boilerworks.manifest import TestingConfig as TestingCfg
10
+
11
+
12
+def _make_manifest(**kwargs) -> BoilerworksManifest:
13
+ defaults = {
14
+ "project": "my-app",
15
+ "family": "django-nextjs",
16
+ "size": "full",
17
+ }
18
+ defaults.update(kwargs)
19
+ return BoilerworksManifest(**defaults)
20
+
21
+
22
+class TestValidManifest:
23
+ def test_minimal_manifest_passes(self) -> None:
24
+ m = _make_manifest()
25
+ assert m.project == "my-app"
26
+ assert m.family == "django-nextjs"
27
+ assert m.size == "full"
28
+
29
+ def test_defaults_are_correct(self) -> None:
30
+ m = _make_manifest()
31
+ assert m.topology == "standard"
32
+ assert m.cloud is None
33
+ assert m.region is None
34
+ assert m.domain is None
35
+ assert m.mobile is False
36
+ assert m.web_presence is False
37
+ assert m.compliance == []
38
+ assert m.template_versions == {}
39
+
40
+ def test_full_manifest_passes(self, valid_manifest: BoilerworksManifest) -> None:
41
+ assert valid_manifest.project == "my-app"
42
+ assert valid_manifest.cloud == "aws"
43
+ assert valid_manifest.region == "us-east-1"
44
+
45
+ def test_micro_template(self) -> None:
46
+ m = _make_manifest(family="fastapi-micro", size="micro")
47
+ assert m.size == "micro"
48
+
49
+ def test_edge_template(self) -> None:
50
+ m = _make_manifest(family="astro-site", size="edge")
51
+ assert m.size == "edge"
52
+
53
+
54
+class TestProjectSlugValidation:
55
+ def test_valid_slugs(self) -> None:
56
+ for slug in ["my-app", "app", "my-app-v2", "a1b2c3"]:
57
+ m = _make_manifest(project=slug)
58
+ assert m.project == slug
59
+
60
+ def test_spaces_fail(self) -> None:
61
+ with pytest.raises(ValidationError, match="project name"):
62
+ _make_manifest(project="my app")
63
+
64
+ def test_uppercase_fails(self) -> None:
65
+ with pytest.raises(ValidationError, match="project name"):
66
+ _make_manifest(project="MyApp")
67
+
68
+ def test_leading_digit_fails(self) -> None:
69
+ with pytest.raises(ValidationError, match="project name"):
70
+ _make_manifest(project="1app")
71
+
72
+ def test_underscore_fails(self) -> None:
73
+ with pytest.raises(ValidationError, match="project name"):
74
+ _make_manifest(project="my_app")
75
+
76
+ def test_empty_fails(self) -> None:
77
+ with pytest.raises(ValidationError):
78
+ _make_manifest(project="")
79
+
80
+
81
+class TestFamilyValidation:
82
+ def test_unknown_family_fails(self) -> None:
83
+ with pytest.raises(ValidationError, match="unknown template family"):
84
+ _make_manifest(family="not-a-real-template")
85
+
86
+ def test_all_known_families_pass(self) -> None:
87
+ from boilerworks.registry import Registry
88
+
89
+ registry = Registry()
90
+ for t in registry.list_all():
91
+ m = _make_manifest(family=t.name, size=t.size)
92
+ assert m.family == t.name
93
+
94
+
95
+class TestYamlRoundtrip:
96
+ def test_to_yaml_and_back(self, valid_manifest: BoilerworksManifest) -> None:
97
+ yaml_str = valid_manifest.to_yaml()
98
+ assert "my-app" in yaml_str
99
+ assert "django-nextjs" in yaml_str
100
+
101
+ restored = BoilerworksManifest.from_yaml(yaml_str)
102
+ assert restored.project == valid_manifest.project
103
+ assert restored.family == valid_manifest.family
104
+ assert restored.size == valid_manifest.size
105
+ assert restored.cloud == valid_manifest.cloud
106
+ assert restored.region == valid_manifest.region
107
+
108
+ def test_to_yaml_is_valid_yaml(self, valid_manifest: BoilerworksManifest) -> None:
109
+ import yaml
110
+
111
+ yaml_str = valid_manifest.to_yaml()
112
+ parsed = yaml.safe_load(yaml_str)
113
+ assert isinstance(parsed, dict)
114
+ assert parsed["project"] == "my-app"
115
+
116
+ def test_from_file(self, valid_manifest: BoilerworksManifest, tmp_path) -> None:
117
+ yaml_file = tmp_path / "boilerworks.yaml"
118
+ valid_manifest.to_file(yaml_file)
119
+
120
+ loaded = BoilerworksManifest.from_file(yaml_file)
121
+ assert loaded.project == valid_manifest.project
122
+ assert loaded.family == valid_manifest.family
123
+
124
+
125
+class TestNestedModels:
126
+ def test_services_config_defaults(self) -> None:
127
+ s = ServicesConfig()
128
+ assert s.email is None
129
+ assert s.cache == "redis"
130
+
131
+ def test_data_config_defaults(self) -> None:
132
+ d = DataConfig()
133
+ assert d.database == "postgres"
134
+ assert d.migrations is True
135
+ assert d.seed_data is True
136
+
137
+ def test_testing_config_defaults(self) -> None:
138
+ t = TestingCfg()
139
+ assert t.e2e is None
140
+ assert t.unit is True
141
+ assert t.integration is True
142
+
143
+ def test_services_email_options(self) -> None:
144
+ for provider in ("ses", "sendgrid", "mailgun"):
145
+ s = ServicesConfig(email=provider)
146
+ assert s.email == provider
--- a/tests/test_manifest.py
+++ b/tests/test_manifest.py
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/tests/test_manifest.py
+++ b/tests/test_manifest.py
@@ -0,0 +1,146 @@
1 """Tests for boilerworks.manifest."""
2
3 from __future__ import annotations
4
5 import pytest
6 from pydantic import ValidationError
7
8 from boilerworks.manifest import BoilerworksManifest, DataConfig, ServicesConfig
9 from boilerworks.manifest import TestingConfig as TestingCfg
10
11
12 def _make_manifest(**kwargs) -> BoilerworksManifest:
13 defaults = {
14 "project": "my-app",
15 "family": "django-nextjs",
16 "size": "full",
17 }
18 defaults.update(kwargs)
19 return BoilerworksManifest(**defaults)
20
21
22 class TestValidManifest:
23 def test_minimal_manifest_passes(self) -> None:
24 m = _make_manifest()
25 assert m.project == "my-app"
26 assert m.family == "django-nextjs"
27 assert m.size == "full"
28
29 def test_defaults_are_correct(self) -> None:
30 m = _make_manifest()
31 assert m.topology == "standard"
32 assert m.cloud is None
33 assert m.region is None
34 assert m.domain is None
35 assert m.mobile is False
36 assert m.web_presence is False
37 assert m.compliance == []
38 assert m.template_versions == {}
39
40 def test_full_manifest_passes(self, valid_manifest: BoilerworksManifest) -> None:
41 assert valid_manifest.project == "my-app"
42 assert valid_manifest.cloud == "aws"
43 assert valid_manifest.region == "us-east-1"
44
45 def test_micro_template(self) -> None:
46 m = _make_manifest(family="fastapi-micro", size="micro")
47 assert m.size == "micro"
48
49 def test_edge_template(self) -> None:
50 m = _make_manifest(family="astro-site", size="edge")
51 assert m.size == "edge"
52
53
54 class TestProjectSlugValidation:
55 def test_valid_slugs(self) -> None:
56 for slug in ["my-app", "app", "my-app-v2", "a1b2c3"]:
57 m = _make_manifest(project=slug)
58 assert m.project == slug
59
60 def test_spaces_fail(self) -> None:
61 with pytest.raises(ValidationError, match="project name"):
62 _make_manifest(project="my app")
63
64 def test_uppercase_fails(self) -> None:
65 with pytest.raises(ValidationError, match="project name"):
66 _make_manifest(project="MyApp")
67
68 def test_leading_digit_fails(self) -> None:
69 with pytest.raises(ValidationError, match="project name"):
70 _make_manifest(project="1app")
71
72 def test_underscore_fails(self) -> None:
73 with pytest.raises(ValidationError, match="project name"):
74 _make_manifest(project="my_app")
75
76 def test_empty_fails(self) -> None:
77 with pytest.raises(ValidationError):
78 _make_manifest(project="")
79
80
81 class TestFamilyValidation:
82 def test_unknown_family_fails(self) -> None:
83 with pytest.raises(ValidationError, match="unknown template family"):
84 _make_manifest(family="not-a-real-template")
85
86 def test_all_known_families_pass(self) -> None:
87 from boilerworks.registry import Registry
88
89 registry = Registry()
90 for t in registry.list_all():
91 m = _make_manifest(family=t.name, size=t.size)
92 assert m.family == t.name
93
94
95 class TestYamlRoundtrip:
96 def test_to_yaml_and_back(self, valid_manifest: BoilerworksManifest) -> None:
97 yaml_str = valid_manifest.to_yaml()
98 assert "my-app" in yaml_str
99 assert "django-nextjs" in yaml_str
100
101 restored = BoilerworksManifest.from_yaml(yaml_str)
102 assert restored.project == valid_manifest.project
103 assert restored.family == valid_manifest.family
104 assert restored.size == valid_manifest.size
105 assert restored.cloud == valid_manifest.cloud
106 assert restored.region == valid_manifest.region
107
108 def test_to_yaml_is_valid_yaml(self, valid_manifest: BoilerworksManifest) -> None:
109 import yaml
110
111 yaml_str = valid_manifest.to_yaml()
112 parsed = yaml.safe_load(yaml_str)
113 assert isinstance(parsed, dict)
114 assert parsed["project"] == "my-app"
115
116 def test_from_file(self, valid_manifest: BoilerworksManifest, tmp_path) -> None:
117 yaml_file = tmp_path / "boilerworks.yaml"
118 valid_manifest.to_file(yaml_file)
119
120 loaded = BoilerworksManifest.from_file(yaml_file)
121 assert loaded.project == valid_manifest.project
122 assert loaded.family == valid_manifest.family
123
124
125 class TestNestedModels:
126 def test_services_config_defaults(self) -> None:
127 s = ServicesConfig()
128 assert s.email is None
129 assert s.cache == "redis"
130
131 def test_data_config_defaults(self) -> None:
132 d = DataConfig()
133 assert d.database == "postgres"
134 assert d.migrations is True
135 assert d.seed_data is True
136
137 def test_testing_config_defaults(self) -> None:
138 t = TestingCfg()
139 assert t.e2e is None
140 assert t.unit is True
141 assert t.integration is True
142
143 def test_services_email_options(self) -> None:
144 for provider in ("ses", "sendgrid", "mailgun"):
145 s = ServicesConfig(email=provider)
146 assert s.email == provider
--- a/tests/test_registry.py
+++ b/tests/test_registry.py
@@ -0,0 +1,70 @@
1
+"""Tests for boilerworks.registry."""
2
+
3
+from __future__ import annotations
4
+
5
+import pytest
6
+
7
+from boilerworks.registry import Registry, TemplateInfo
8
+
9
+
10
+@pytest.fixture(scope="module")
11
+def registry() -> Registry:
12
+ return Registry()
13
+
14
+
15
+def test_yaml_loads_without_error(registry: Registry) -> None:
16
+ templates = registry.list_all()
17
+ assert isinstance(templates, list)
18
+ assert len(templates) > 0
19
+
20
+
21
+def test_all_26_templates_present(registry: Registry) -> None:
22
+ assert len(registry.list_all()) == 26
23
+
24
+
25
+def test_templates_are_template_info_instances(registry: Registry) -> None:
26
+ for t in registry.list_all():
27
+ assert isinstance(t, TemplateInfo)
28
+
29
+
30
+def test_filter_by_size_full(registry: Registry) -> None:
31
+ full = registry.filter_by_size("full")
32
+ assert len(full) == 15
33
+ assert all(t.size == "full" for t in full)
34
+
35
+
36
+def test_filter_by_size_micro(registry: Registry) -> None:
37
+ micro = registry.filter_by_size("micro")
38
+ assert len(micro) == 6
39
+ assert all(t.size == "micro" for t in micro)
40
+
41
+
42
+def test_filter_by_size_edge(registry: Registry) -> None:
43
+ edge = registry.filter_by_size("edge")
44
+ assert len(edge) == 5
45
+ assert all(t.siz== "edge" for t in edge)
46
+
47
+
48
+def test_filter_by_language_python(registry: Registry) -> None:
49
+ python = registry.filter_by_language("python")
50
+ names = {t.name for t in python}
51
+ assert "django-nextjs" in names
52
+ assert "fastapi-micro" in names
53
+ assert "nestjs-nextjs" not in names
54
+
55
+
56
+def test_filter_by_language_typescript(registry: Registry) -> None:
57
+ ts = registry.filter_by_language("typescript")
58
+ names = {t.name for t in ts}
59
+ assert "nestjs-nextjs" in names
60
+ assert "django-nextjs" not in names
61
+
62
+
63
+def test_filter_by_language_go(registry: Registry) -> None:
64
+ go = registry.filter_by_language("go")
65
+ assert all(t.language == "go" for t in go)
66
+ assert len(go) >= 2
67
+
68
+
69
+def test_get_by_name_returns_correct_template(registry: Registry) -> None:
70
+ t = reg
--- a/tests/test_registry.py
+++ b/tests/test_registry.py
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/tests/test_registry.py
+++ b/tests/test_registry.py
@@ -0,0 +1,70 @@
1 """Tests for boilerworks.registry."""
2
3 from __future__ import annotations
4
5 import pytest
6
7 from boilerworks.registry import Registry, TemplateInfo
8
9
10 @pytest.fixture(scope="module")
11 def registry() -> Registry:
12 return Registry()
13
14
15 def test_yaml_loads_without_error(registry: Registry) -> None:
16 templates = registry.list_all()
17 assert isinstance(templates, list)
18 assert len(templates) > 0
19
20
21 def test_all_26_templates_present(registry: Registry) -> None:
22 assert len(registry.list_all()) == 26
23
24
25 def test_templates_are_template_info_instances(registry: Registry) -> None:
26 for t in registry.list_all():
27 assert isinstance(t, TemplateInfo)
28
29
30 def test_filter_by_size_full(registry: Registry) -> None:
31 full = registry.filter_by_size("full")
32 assert len(full) == 15
33 assert all(t.size == "full" for t in full)
34
35
36 def test_filter_by_size_micro(registry: Registry) -> None:
37 micro = registry.filter_by_size("micro")
38 assert len(micro) == 6
39 assert all(t.size == "micro" for t in micro)
40
41
42 def test_filter_by_size_edge(registry: Registry) -> None:
43 edge = registry.filter_by_size("edge")
44 assert len(edge) == 5
45 assert all(t.siz== "edge" for t in edge)
46
47
48 def test_filter_by_language_python(registry: Registry) -> None:
49 python = registry.filter_by_language("python")
50 names = {t.name for t in python}
51 assert "django-nextjs" in names
52 assert "fastapi-micro" in names
53 assert "nestjs-nextjs" not in names
54
55
56 def test_filter_by_language_typescript(registry: Registry) -> None:
57 ts = registry.filter_by_language("typescript")
58 names = {t.name for t in ts}
59 assert "nestjs-nextjs" in names
60 assert "django-nextjs" not in names
61
62
63 def test_filter_by_language_go(registry: Registry) -> None:
64 go = registry.filter_by_language("go")
65 assert all(t.language == "go" for t in go)
66 assert len(go) >= 2
67
68
69 def test_get_by_name_returns_correct_template(registry: Registry) -> None:
70 t = reg
--- a/tests/test_renderer.py
+++ b/tests/test_renderer.py
@@ -0,0 +1,183 @@
1
+"""Tests for boilerworks.renderer."""
2
+
3
+from __future__ import annotations
4
+
5
+from pathlib import Path
6
+
7
+from boilerworks.renderer import (
8
+ _SKIP_DIRS,
9
+ _SKIP_EXTENSIONS,
10
+ build_replacements,
11
+ rename_boilerworks_paths,
12
+ render_directory,
13
+ render_file,
14
+)
15
+
16
+
17
+class TestBuildReplacements:
18
+ def test_lowercase_replacement(self) -> None:
19
+ r = build_replacements("my-app")
20
+ assert r["boilerworks"] == "my-app"
21
+
22
+ def test_uppercase_replacement(self) -> None:
23
+ r = build_replacements("my-app")
24
+ assert r["BOILERWORKS"] == "MY-APP"
25
+
26
+ def test_title_replacement(self) -> None:
27
+ r = build_replacements("my-app")
28
+ assert r["Boilerworks"] == "My-App"
29
+
30
+ def test_underscore_prefix_replacement(self) -> None:
31
+ r = build_replacements("my-app")
32
+ assert r["boilerworks_"] == "my_app_"
33
+
34
+ def test_underscore_suffix_replacement(self) -> None:
35
+ r = build_replacements("my-app")
36
+ assert r["_boilerworks"] == "_my_app"
37
+
38
+ def test_single_word_project(self) -> None:
39
+ r = build_replacements("myapp")
40
+ assert r["boilerworks"] == "myapp"
41
+ assert r["BOILERWORKS"] == "MYAPP"
42
+
43
+
44
+class TestRenderFile:
45
+ def test_replaces_lowercase(self, tmp_path: Path) -> None:
46
+ f = tmp_path / "test.txt"
47
+ f.write_text("APP_NAME=boilerworks")
48
+ replacements = build_replacements("my-app")
49
+ changed = render_file(f, replacements)
50
+ assert changed is True
51
+ assert f.read_text() == "APP_NAME=my-app"
52
+
53
+ def test_replaces_uppercase(self, tmp_path: Path) -> None:
54
+ f = tmp_path / "test.env"
55
+ f.write_text("DB_NAME=BOILERWORKS_DB")
56
+ replacements = build_replacements("my-app")
57
+ render_file(f, replacements)
58
+ assert f.read_text() == "DB_NAME=MY-APP_DB"
59
+
60
+ def test_no_match_returns_false(self, tmp_path: Path) -> None:
61
+ f = tmp_path / "test.txt"
62
+ f.write_text("hello world")
63
+ changed = render_file(f, build_replacements("my-app"))
64
+ assert changed is False
65
+ assert f.read_text() == "hello world"
66
+
67
+ def test_empty_file_unchanged(self, tmp_path: Path) -> None:
68
+ f = tmp_path / "empty.txt"
69
+ f.write_text("")
70
+ changed = render_file(f, build_replacements("my-app"))
71
+ assert changed is False
72
+ assert f.read_text() == ""
73
+
74
+ def test_binary_extension_skipped(self, tmp_path: Path) -> None:
75
+ f = tmp_path / "image.png"
76
+ f.write_bytes(b"\x89PNG boilerworks")
77
+ changed = render_file(f, build_replacements("my-app"))
78
+ assert changed is False
79
+ # File content unchanged
80
+ assert b"boilerworks" in f.read_bytes()
81
+
82
+ def test_lock_extension_skipped(self, tmp_path: Path) -> None:
83
+ f = tmp_path / "uv.lock"
84
+ f.write_text("boilerworks = 1.0")
85
+ changed = render_file(f, build_replacements("my-app"))
86
+ assert changed is False
87
+
88
+ def test_multiple_replacements_in_one_file(self, tmp_path: Path) -> None:
89
+ f = tmp_path / "settings.py"
90
+ content = "APP = 'boilerworks'\nNAME = 'Boilerworks'\nKEY = 'BOILERWORKS'"
91
+ f.write_text(content)
92
+ render_file(f, build_replacements("cool-app"))
93
+ result = f.read_text()
94
+ assert "cool-app" in result
95
+ assert "Cool-App" in result
96
+ assert "COOL-APP" in result
97
+ assert "boilerworks" not in result
98
+
99
+ def test_case_variant_python_module(self, tmp_path: Path) -> None:
100
+ f = tmp_path / "settings.py"
101
+ f.write_text("from boilerworks_config import settings")
102
+ replacements = build_replacements("myproject")
103
+ render_file(f, replacements)
104
+ assert f.read_text() == "from myproject_config import settings"
105
+
106
+
107
+class TestRenderDirectory:
108
+ def test_renders_text_files(self, tmp_path: Path) -> None:
109
+ (tmp_path / "app.py").write_text("APP = 'boilerworks'")
110
+ (tmp_path / "README.md").write_text("# boilerworks\n")
111
+ modified = render_directory(tmp_path, build_replacements("newapp"))
112
+ assert len(modified) == 2
113
+
114
+ def test_skips_excluded_dirs(self, tmp_path: Path) -> None:
115
+ skip_dir = tmp_path / "node_modules"
116
+ skip_dir.mkdir()
117
+ (skip_dir / "file.js").write_text("module.exports = 'boilerworks'")
118
+ modified = render_directory(tmp_path, build_replacements("newapp"))
119
+ assert not any("node_modules" in str(p) for p in modified)
120
+ # Original file unchanged
121
+ assert "boilerworks" in (skip_dir / "file.js").read_text()
122
+
123
+ def test_skips_git_dir(self, tmp_path: Path) -> None:
124
+ git_dir = tmp_path / ".git"
125
+ git_dir.mkdir()
126
+ (git_dir / "config").write_text("url = boilerworks")
127
+ modified = render_directory(tmp_path, build_replacements("newapp"))
128
+ assert not any(".git" in str(p) for p in modified)
129
+
130
+ def test_skips_binary_extensions(self, tmp_path: Path) -> None:
131
+ (tmp_path / "logo.png").write_bytes(b"\x89PNG boilerworks")
132
+ (tmp_path / "app.py").write_text("# boilerworks")
133
+ modified = render_directory(tmp_path, build_replacements("newapp"))
134
+ modified_names = [p.name for p in modified]
135
+ assert "logo.png" not in modified_names
136
+ assert "app.py" in modified_names
137
+
138
+ def test_nested_directories(self, tmp_path: Path) -> None:
139
+ subdir = tmp_path / "src" / "config"
140
+ subdir.mkdir(parents=True)
141
+ (subdir / "settings.py").write_text("APP = 'boilerworks'")
142
+ modified = render_directory(tmp_path, build_replacements("newapp"))
143
+ assert len(modified) == 1
144
+ assert (subdir / "settings.py").read_text() == "APP = 'newapp'"
145
+
146
+
147
+class TestRenameBoilerworksPaths:
148
+ def test_renames_file_with_boilerworks(self, tmp_path: Path) -> None:
149
+ f = tmp_path / "boilerworks.iml"
150
+ f.write_text("")
151
+ renames = rename_boilerworks_paths(tmp_path, "my-project")
152
+ assert len(renames) == 1
153
+ assert not (tmp_path / "boilerworks.iml").exists()
154
+ assert (tmp_path / "my-project.iml").exists()
155
+
156
+ def test_renames_directory_with_boilerworks(self, tmp_path: Path) -> None:
157
+ subdir = tmp_path / "boilerworks_config"
158
+ subdir.mkdir()
159
+ (subdir / "settings.py").write_text("")
160
+ rename_boilerworks_paths(tmp_path, "myproject")
161
+ assert not (tmp_path / "boilerworks_config").exists()
162
+ assert (tmp_path / "myproject_config").exists()
163
+
164
+ def test_no_boilerworks_paths_unchanged(self, tmp_path: Path) -> None:
165
+ f = tmp_path / "other_file.txt"
166
+ f.write_text("content")
167
+ renames = rename_boilerworks_paths(tmp_path, "myproject")
168
+ assert len(renames) == 0
169
+ assert f.exists()
170
+
171
+
172
+class TestSkipSets:
173
+ def test_skip_dirs_contains_expected(self) -> None:
174
+ assert ".git" in _SKIP_DIRS
175
+ assert "node_modules" in _SKIP_DIRS
176
+ assert "__pycache__" in _SKIP_DIRS
177
+ assert ".venv" in _SKIP_DIRS
178
+
179
+ def test_skip_extensions_contains_expected(self) -> None:
180
+ assert ".png" in _SKIP_EXTENSIONS
181
+ assert ".lock" in _SKIP_EXTENSIONS
182
+ assert ".pyc" in _SKIP_EXTENSIONS
183
+ assert ".woff" in _SKIP_EXTENSIONS
--- a/tests/test_renderer.py
+++ b/tests/test_renderer.py
@@ -0,0 +1,183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/tests/test_renderer.py
+++ b/tests/test_renderer.py
@@ -0,0 +1,183 @@
1 """Tests for boilerworks.renderer."""
2
3 from __future__ import annotations
4
5 from pathlib import Path
6
7 from boilerworks.renderer import (
8 _SKIP_DIRS,
9 _SKIP_EXTENSIONS,
10 build_replacements,
11 rename_boilerworks_paths,
12 render_directory,
13 render_file,
14 )
15
16
17 class TestBuildReplacements:
18 def test_lowercase_replacement(self) -> None:
19 r = build_replacements("my-app")
20 assert r["boilerworks"] == "my-app"
21
22 def test_uppercase_replacement(self) -> None:
23 r = build_replacements("my-app")
24 assert r["BOILERWORKS"] == "MY-APP"
25
26 def test_title_replacement(self) -> None:
27 r = build_replacements("my-app")
28 assert r["Boilerworks"] == "My-App"
29
30 def test_underscore_prefix_replacement(self) -> None:
31 r = build_replacements("my-app")
32 assert r["boilerworks_"] == "my_app_"
33
34 def test_underscore_suffix_replacement(self) -> None:
35 r = build_replacements("my-app")
36 assert r["_boilerworks"] == "_my_app"
37
38 def test_single_word_project(self) -> None:
39 r = build_replacements("myapp")
40 assert r["boilerworks"] == "myapp"
41 assert r["BOILERWORKS"] == "MYAPP"
42
43
44 class TestRenderFile:
45 def test_replaces_lowercase(self, tmp_path: Path) -> None:
46 f = tmp_path / "test.txt"
47 f.write_text("APP_NAME=boilerworks")
48 replacements = build_replacements("my-app")
49 changed = render_file(f, replacements)
50 assert changed is True
51 assert f.read_text() == "APP_NAME=my-app"
52
53 def test_replaces_uppercase(self, tmp_path: Path) -> None:
54 f = tmp_path / "test.env"
55 f.write_text("DB_NAME=BOILERWORKS_DB")
56 replacements = build_replacements("my-app")
57 render_file(f, replacements)
58 assert f.read_text() == "DB_NAME=MY-APP_DB"
59
60 def test_no_match_returns_false(self, tmp_path: Path) -> None:
61 f = tmp_path / "test.txt"
62 f.write_text("hello world")
63 changed = render_file(f, build_replacements("my-app"))
64 assert changed is False
65 assert f.read_text() == "hello world"
66
67 def test_empty_file_unchanged(self, tmp_path: Path) -> None:
68 f = tmp_path / "empty.txt"
69 f.write_text("")
70 changed = render_file(f, build_replacements("my-app"))
71 assert changed is False
72 assert f.read_text() == ""
73
74 def test_binary_extension_skipped(self, tmp_path: Path) -> None:
75 f = tmp_path / "image.png"
76 f.write_bytes(b"\x89PNG boilerworks")
77 changed = render_file(f, build_replacements("my-app"))
78 assert changed is False
79 # File content unchanged
80 assert b"boilerworks" in f.read_bytes()
81
82 def test_lock_extension_skipped(self, tmp_path: Path) -> None:
83 f = tmp_path / "uv.lock"
84 f.write_text("boilerworks = 1.0")
85 changed = render_file(f, build_replacements("my-app"))
86 assert changed is False
87
88 def test_multiple_replacements_in_one_file(self, tmp_path: Path) -> None:
89 f = tmp_path / "settings.py"
90 content = "APP = 'boilerworks'\nNAME = 'Boilerworks'\nKEY = 'BOILERWORKS'"
91 f.write_text(content)
92 render_file(f, build_replacements("cool-app"))
93 result = f.read_text()
94 assert "cool-app" in result
95 assert "Cool-App" in result
96 assert "COOL-APP" in result
97 assert "boilerworks" not in result
98
99 def test_case_variant_python_module(self, tmp_path: Path) -> None:
100 f = tmp_path / "settings.py"
101 f.write_text("from boilerworks_config import settings")
102 replacements = build_replacements("myproject")
103 render_file(f, replacements)
104 assert f.read_text() == "from myproject_config import settings"
105
106
107 class TestRenderDirectory:
108 def test_renders_text_files(self, tmp_path: Path) -> None:
109 (tmp_path / "app.py").write_text("APP = 'boilerworks'")
110 (tmp_path / "README.md").write_text("# boilerworks\n")
111 modified = render_directory(tmp_path, build_replacements("newapp"))
112 assert len(modified) == 2
113
114 def test_skips_excluded_dirs(self, tmp_path: Path) -> None:
115 skip_dir = tmp_path / "node_modules"
116 skip_dir.mkdir()
117 (skip_dir / "file.js").write_text("module.exports = 'boilerworks'")
118 modified = render_directory(tmp_path, build_replacements("newapp"))
119 assert not any("node_modules" in str(p) for p in modified)
120 # Original file unchanged
121 assert "boilerworks" in (skip_dir / "file.js").read_text()
122
123 def test_skips_git_dir(self, tmp_path: Path) -> None:
124 git_dir = tmp_path / ".git"
125 git_dir.mkdir()
126 (git_dir / "config").write_text("url = boilerworks")
127 modified = render_directory(tmp_path, build_replacements("newapp"))
128 assert not any(".git" in str(p) for p in modified)
129
130 def test_skips_binary_extensions(self, tmp_path: Path) -> None:
131 (tmp_path / "logo.png").write_bytes(b"\x89PNG boilerworks")
132 (tmp_path / "app.py").write_text("# boilerworks")
133 modified = render_directory(tmp_path, build_replacements("newapp"))
134 modified_names = [p.name for p in modified]
135 assert "logo.png" not in modified_names
136 assert "app.py" in modified_names
137
138 def test_nested_directories(self, tmp_path: Path) -> None:
139 subdir = tmp_path / "src" / "config"
140 subdir.mkdir(parents=True)
141 (subdir / "settings.py").write_text("APP = 'boilerworks'")
142 modified = render_directory(tmp_path, build_replacements("newapp"))
143 assert len(modified) == 1
144 assert (subdir / "settings.py").read_text() == "APP = 'newapp'"
145
146
147 class TestRenameBoilerworksPaths:
148 def test_renames_file_with_boilerworks(self, tmp_path: Path) -> None:
149 f = tmp_path / "boilerworks.iml"
150 f.write_text("")
151 renames = rename_boilerworks_paths(tmp_path, "my-project")
152 assert len(renames) == 1
153 assert not (tmp_path / "boilerworks.iml").exists()
154 assert (tmp_path / "my-project.iml").exists()
155
156 def test_renames_directory_with_boilerworks(self, tmp_path: Path) -> None:
157 subdir = tmp_path / "boilerworks_config"
158 subdir.mkdir()
159 (subdir / "settings.py").write_text("")
160 rename_boilerworks_paths(tmp_path, "myproject")
161 assert not (tmp_path / "boilerworks_config").exists()
162 assert (tmp_path / "myproject_config").exists()
163
164 def test_no_boilerworks_paths_unchanged(self, tmp_path: Path) -> None:
165 f = tmp_path / "other_file.txt"
166 f.write_text("content")
167 renames = rename_boilerworks_paths(tmp_path, "myproject")
168 assert len(renames) == 0
169 assert f.exists()
170
171
172 class TestSkipSets:
173 def test_skip_dirs_contains_expected(self) -> None:
174 assert ".git" in _SKIP_DIRS
175 assert "node_modules" in _SKIP_DIRS
176 assert "__pycache__" in _SKIP_DIRS
177 assert ".venv" in _SKIP_DIRS
178
179 def test_skip_extensions_contains_expected(self) -> None:
180 assert ".png" in _SKIP_EXTENSIONS
181 assert ".lock" in _SKIP_EXTENSIONS
182 assert ".pyc" in _SKIP_EXTENSIONS
183 assert ".woff" in _SKIP_EXTENSIONS
--- a/tests/test_wizard.py
+++ b/tests/test_wizard.py
@@ -0,0 +1,177 @@
1
+"""Tests for boilerworks.wizard."""
2
+
3
+from __future__ import annotations
4
+
5
+from pathlib import Path
6
+from unittest.mock import patch
7
+
8
+import pytest
9
+
10
+from boilerworks.wizard import _template_choices, _validate_slug
11
+
12
+
13
+class TestValidateSlug:
14
+ def test_valid_slugs(self) -> None:
15
+ for slug in ["my-app", "app", "a1b2", "my-project-v2"]:
16
+ assert _validate_slug(slug) is True
17
+
18
+ def test_empty_returns_message(self) -> None:
19
+ result = _validate_slug("")
20
+ assert isinstance(result, str)
21
+ assert "required" in result.lower()
22
+
23
+ def test_spaces_returns_message(self) -> None:
24
+ result = _validate_slug("my app")
25
+ assert isinstance(result, str)
26
+
27
+ def test_uppercase_returns_message(self) -> None:
28
+ result = _validate_slug("MyApp")
29
+ assert isinstance(result, str)
30
+
31
+ def test_leading_digit_returns_message(self) -> None:
32
+ result = _validate_slug("1app")
33
+ assert isinstance(result, str)
34
+
35
+
36
+class TestTemplateChoices:
37
+ def test_returns_choices_list(self) -> None:
38
+ from boilerworks.registry import Registry
39
+
40
+ registry = Registry()
41
+ templates = registry.filter_by_size("full")
42
+ choices = _template_choices(templates)
43
+ assert len(choices) > 0
44
+
45
+ def test_separators_per_language(self) -> None:
46
+ import questionary
47
+
48
+ from boilerworks.registry import Registry
49
+
50
+ registry = Registry()
51
+ templates = registry.list_all()
52
+ choices = _template_choices(templates)
53
+ separators = [c for c in choices if isinstance(c, questionary.Separator)]
54
+ # Should have at least one separator per language group
55
+ assert len(separators) >= 5
56
+
57
+ def test_choice_values_are_template_names(self) -> None:
58
+ import questionary
59
+
60
+ from boilerworks.registry import Registry
61
+
62
+ registry = Registry()
63
+ templates = registry.filter_by_size("micro")
64
+ choices = _template_choices(templates)
65
+ # Separator is a subclass of Choice in questionary, so exclude them explicitly
66
+ real_choices = [
67
+ c for c in choices if isinstance(c, questionary.Choice) and not isinstance(c, questionary.Separator)
68
+ ]
69
+ names = {c.value for c in real_choices}
70
+ expected = {t.name for t in templates}
71
+ assert names == expected
72
+
73
+
74
+class TestRunWizard:
75
+ """Integration tests for run_wizard using mocked questionary."""
76
+
77
+ def test_wizard_writes_manifest(self, tmp_path: Path) -> None:
78
+ output_file = tmp_path / "boilerworks.yaml"
79
+
80
+ with (
81
+ patch("questionary.text") as mock_text,
82
+ patch("questionary.select") as mock_select,
83
+ patch("questionary.confirm") as mock_confirm,
84
+ patch("questionary.checkbox") as mock_checkbox,
85
+ ):
86
+ mock_text.return_value.ask.side_effect = [
87
+ "my-test-app", # project name
88
+ "", # region (empty → None)
89
+ "", # domain (empty → None)
90
+ ]
91
+ mock_select.return_value.ask.side_effect = [
92
+ "full", # size
93
+ "django-nextjs", # family
94
+ "standard", # topology
95
+ "none", # cloud
96
+ "none", # email
97
+ "none", # e2e
98
+ ]
99
+ mock_confirm.return_value.ask.side_effect = [
100
+ False, # mobile
101
+ False, # web_presence
102
+ True, # confirm write
103
+ ]
104
+ mock_checkbox.return_value.ask.return_value = [] # compliance
105
+
106
+ from boilerworks.wizard import run_wizard
107
+
108
+ run_wizard(output_path=output_file)
109
+
110
+ assert output_file.exists()
111
+ content = output_file.read_text()
112
+ assert "my-test-app" in content
113
+ assert "django-nextjs" in content
114
+
115
+ def test_wizard_cancelled_on_project_name(self, tmp_path: Path) -> None:
116
+ output_file = tmp_path / "boilerworks.yaml"
117
+ with patch("questionary.text") as mock_text:
118
+ mock_text.return_value.ask.return_value = None # user hit Ctrl+C
119
+ from boilerworks.wizard import run_wizard
120
+
121
+ with pytest.raises(SystemExit):
122
+ run_wizard(output_path=output_file)
123
+
124
+ assert not output_file.exists()
125
+
126
+ def test_wizard_cancelled_on_confirm(self, tmp_path: Path) -> None:
127
+ output_file = tmp_path / "boilerworks.yaml"
128
+ with (
129
+ patch("questionary.text") as mock_text,
130
+ patch("questionary.select") as mock_select,
131
+ patch("questionary.confirm") as mock_confirm,
132
+ patch("questionary.checkbox") as mock_checkbox,
133
+ ):
134
+ mock_text.return_value.ask.side_effect = ["my-app", "", ""]
135
+ mock_select.return_value.ask.side_effect = ["full", "django-nextjs", "standard", "none", "none", "none"]
136
+ mock_confirm.return_value.ask.side_effect = [False, False, False] # all confirms → False
137
+ mock_checkbox.return_value.ask.return_value = []
138
+
139
+ from boilerworks.wizard import run_wizard
140
+
141
+ with pytest.raises(SystemExit):
142
+ run_wizard(output_path=output_file)
143
+
144
+ assert not output_file.exists()
145
+
146
+ def test_wizard_with_cloud_and_region(self, tmp_path: Path) -> None:
147
+ output_file = tmp_path / "boilerworks.yaml"
148
+ with (
149
+ patch("questionary.text") as mock_text,
150
+ patch("questionary.select") as mock_select,
151
+ patch("questionary.confirm") as mock_confirm,
152
+ patch("questionary.checkbox") as mock_checkbox,
153
+ ):
154
+ mock_text.return_value.ask.side_effect = [
155
+ "cloud-app", # project name
156
+ "us-east-1", # region
157
+ "myapp.com", # domain
158
+ ]
159
+ # fastapi-micro has only one topology, so topology select is skipped
160
+ mock_select.return_value.ask.side_effect = [
161
+ "micro", # size
162
+ "fastapi-micro", # family
163
+ "aws", # cloud (topology prompt skipped — single option)
164
+ "ses", # email
165
+ "playwright", # e2e
166
+ ]
167
+ mock_confirm.return_value.ask.return_value = True
168
+ mock_checkbox.return_value.ask.return_value = ["soc2"]
169
+
170
+ from boilerworks.wizard import run_wizard
171
+
172
+ run_wizard(output_path=output_file)
173
+
174
+ assert output_file.exists()
175
+ content = output_file.read_text()
176
+ assert "cloud-app" in content
177
+ assert "fastapi-micro" in content
--- a/tests/test_wizard.py
+++ b/tests/test_wizard.py
@@ -0,0 +1,177 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/tests/test_wizard.py
+++ b/tests/test_wizard.py
@@ -0,0 +1,177 @@
1 """Tests for boilerworks.wizard."""
2
3 from __future__ import annotations
4
5 from pathlib import Path
6 from unittest.mock import patch
7
8 import pytest
9
10 from boilerworks.wizard import _template_choices, _validate_slug
11
12
13 class TestValidateSlug:
14 def test_valid_slugs(self) -> None:
15 for slug in ["my-app", "app", "a1b2", "my-project-v2"]:
16 assert _validate_slug(slug) is True
17
18 def test_empty_returns_message(self) -> None:
19 result = _validate_slug("")
20 assert isinstance(result, str)
21 assert "required" in result.lower()
22
23 def test_spaces_returns_message(self) -> None:
24 result = _validate_slug("my app")
25 assert isinstance(result, str)
26
27 def test_uppercase_returns_message(self) -> None:
28 result = _validate_slug("MyApp")
29 assert isinstance(result, str)
30
31 def test_leading_digit_returns_message(self) -> None:
32 result = _validate_slug("1app")
33 assert isinstance(result, str)
34
35
36 class TestTemplateChoices:
37 def test_returns_choices_list(self) -> None:
38 from boilerworks.registry import Registry
39
40 registry = Registry()
41 templates = registry.filter_by_size("full")
42 choices = _template_choices(templates)
43 assert len(choices) > 0
44
45 def test_separators_per_language(self) -> None:
46 import questionary
47
48 from boilerworks.registry import Registry
49
50 registry = Registry()
51 templates = registry.list_all()
52 choices = _template_choices(templates)
53 separators = [c for c in choices if isinstance(c, questionary.Separator)]
54 # Should have at least one separator per language group
55 assert len(separators) >= 5
56
57 def test_choice_values_are_template_names(self) -> None:
58 import questionary
59
60 from boilerworks.registry import Registry
61
62 registry = Registry()
63 templates = registry.filter_by_size("micro")
64 choices = _template_choices(templates)
65 # Separator is a subclass of Choice in questionary, so exclude them explicitly
66 real_choices = [
67 c for c in choices if isinstance(c, questionary.Choice) and not isinstance(c, questionary.Separator)
68 ]
69 names = {c.value for c in real_choices}
70 expected = {t.name for t in templates}
71 assert names == expected
72
73
74 class TestRunWizard:
75 """Integration tests for run_wizard using mocked questionary."""
76
77 def test_wizard_writes_manifest(self, tmp_path: Path) -> None:
78 output_file = tmp_path / "boilerworks.yaml"
79
80 with (
81 patch("questionary.text") as mock_text,
82 patch("questionary.select") as mock_select,
83 patch("questionary.confirm") as mock_confirm,
84 patch("questionary.checkbox") as mock_checkbox,
85 ):
86 mock_text.return_value.ask.side_effect = [
87 "my-test-app", # project name
88 "", # region (empty → None)
89 "", # domain (empty → None)
90 ]
91 mock_select.return_value.ask.side_effect = [
92 "full", # size
93 "django-nextjs", # family
94 "standard", # topology
95 "none", # cloud
96 "none", # email
97 "none", # e2e
98 ]
99 mock_confirm.return_value.ask.side_effect = [
100 False, # mobile
101 False, # web_presence
102 True, # confirm write
103 ]
104 mock_checkbox.return_value.ask.return_value = [] # compliance
105
106 from boilerworks.wizard import run_wizard
107
108 run_wizard(output_path=output_file)
109
110 assert output_file.exists()
111 content = output_file.read_text()
112 assert "my-test-app" in content
113 assert "django-nextjs" in content
114
115 def test_wizard_cancelled_on_project_name(self, tmp_path: Path) -> None:
116 output_file = tmp_path / "boilerworks.yaml"
117 with patch("questionary.text") as mock_text:
118 mock_text.return_value.ask.return_value = None # user hit Ctrl+C
119 from boilerworks.wizard import run_wizard
120
121 with pytest.raises(SystemExit):
122 run_wizard(output_path=output_file)
123
124 assert not output_file.exists()
125
126 def test_wizard_cancelled_on_confirm(self, tmp_path: Path) -> None:
127 output_file = tmp_path / "boilerworks.yaml"
128 with (
129 patch("questionary.text") as mock_text,
130 patch("questionary.select") as mock_select,
131 patch("questionary.confirm") as mock_confirm,
132 patch("questionary.checkbox") as mock_checkbox,
133 ):
134 mock_text.return_value.ask.side_effect = ["my-app", "", ""]
135 mock_select.return_value.ask.side_effect = ["full", "django-nextjs", "standard", "none", "none", "none"]
136 mock_confirm.return_value.ask.side_effect = [False, False, False] # all confirms → False
137 mock_checkbox.return_value.ask.return_value = []
138
139 from boilerworks.wizard import run_wizard
140
141 with pytest.raises(SystemExit):
142 run_wizard(output_path=output_file)
143
144 assert not output_file.exists()
145
146 def test_wizard_with_cloud_and_region(self, tmp_path: Path) -> None:
147 output_file = tmp_path / "boilerworks.yaml"
148 with (
149 patch("questionary.text") as mock_text,
150 patch("questionary.select") as mock_select,
151 patch("questionary.confirm") as mock_confirm,
152 patch("questionary.checkbox") as mock_checkbox,
153 ):
154 mock_text.return_value.ask.side_effect = [
155 "cloud-app", # project name
156 "us-east-1", # region
157 "myapp.com", # domain
158 ]
159 # fastapi-micro has only one topology, so topology select is skipped
160 mock_select.return_value.ask.side_effect = [
161 "micro", # size
162 "fastapi-micro", # family
163 "aws", # cloud (topology prompt skipped — single option)
164 "ses", # email
165 "playwright", # e2e
166 ]
167 mock_confirm.return_value.ask.return_value = True
168 mock_checkbox.return_value.ask.return_value = ["soc2"]
169
170 from boilerworks.wizard import run_wizard
171
172 run_wizard(output_path=output_file)
173
174 assert output_file.exists()
175 content = output_file.read_text()
176 assert "cloud-app" in content
177 assert "fastapi-micro" in content
+575
--- a/uv.lock
+++ b/uv.lock
@@ -0,0 +1,575 @@
1
+version = 1
2
+revision = 3
3
+requires-python = ">=3.12"
4
+
5
+[[package]]
6
+name = "annotated-types"
7
+version = "0.7.0"
8
+source = { registry = "https://pypi.org/simple" }
9
+sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
10
+wheels = [
11
+ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
12
+]
13
+
14
+[[package]]
15
+name = "boilerworks"
16
+version = "0.1.0"
17
+source = { editable = "." }
18
+dependencies = [
19
+ { name = "click" },
20
+ { name = "gitpython" },
21
+ { name = "jinja2" },
22
+ { name = "pydantic" },
23
+ { name = "pyyaml" },
24
+ { name = "questionary" },
25
+ { name = "rich" },
26
+]
27
+
28
+[package.dev-dependencies]
29
+dev = [
30
+ { name = "pytest" },
31
+ { name = "pytest-cov" },
32
+ { name = "ruff" },
33
+]
34
+
35
+[package.metadata]
36
+requires-dist = [
37
+ { name = "click", specifier = ">=8.1" },
38
+ { name = "gitpython", specifier = ">=3.1" },
39
+ { name = "jinja2", specifier = ">=3.0" },
40
+ { name = "pydantic", specifier = ">=2.0" },
41
+ { name = "pyyaml", specifier = ">=6.0" },
42
+ { name = "questionary", specifier = ">=2.0" },
43
+ { name = "rich", specifier = ">=13.0" },
44
+]
45
+
46
+[package.metadata.requires-dev]
47
+dev = [
48
+ { name = "pytest", specifier = ">=8.0" },
49
+ { name = "pytest-cov", specifier = ">=5.0" },
50
+ { name = "ruff", specifier = ">=0.9" },
51
+]
52
+
53
+[[package]]
54
+name = "click"
55
+version = "8.3.1"
56
+source = { registry = "https://pypi.org/simple" }
57
+dependencies = [
58
+ { name = "colorama", marker = "sys_platform == 'win32'" },
59
+]
60
+sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
61
+wheels = [
62
+ { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
63
+]
64
+
65
+[[package]]
66
+name = "colorama"
67
+version = "0.4.6"
68
+source = { registry = "https://pypi.org/simple" }
69
+sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
70
+wheels = [
71
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
72
+]
73
+
74
+[[package]]
75
+name = "coverage"
76
+version = "7.13.5"
77
+source = { registry = "https://pypi.org/simple" }
78
+sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" }
79
+wheels = [
80
+ { url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" },
81
+ { url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" },
82
+ { url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" },
83
+ { url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" },
84
+ { url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" },
85
+ { url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" },
86
+ { url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" },
87
+ { url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" },
88
+ { url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" },
89
+ { url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" },
90
+ { url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" },
91
+ { url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" },
92
+ { url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" },
93
+ { url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" },
94
+ { url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" },
95
+ { url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" },
96
+ { url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" },
97
+ { url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" },
98
+ { url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" },
99
+ { url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" },
100
+ { url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" },
101
+ { url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" },
102
+ { url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" },
103
+ { url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" },
104
+ { url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" },
105
+ { url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" },
106
+ { url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" },
107
+ { url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" },
108
+ { url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" },
109
+ { url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" },
110
+ { url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" },
111
+ { url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" },
112
+ { url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" },
113
+ { url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" },
114
+ { url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" },
115
+ { url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" },
116
+ { url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" },
117
+ { url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" },
118
+ { url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" },
119
+ { url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" },
120
+ { url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" },
121
+ { url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" },
122
+ { url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" },
123
+ { url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" },
124
+ { url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" },
125
+ { url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" },
126
+ { url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" },
127
+ { url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" },
128
+ { url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" },
129
+ { url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" },
130
+ { url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" },
131
+ { url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" },
132
+ { url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" },
133
+ { url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" },
134
+ { url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" },
135
+ { url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" },
136
+ { url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" },
137
+ { url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" },
138
+ { url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" },
139
+ { url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" },
140
+ { url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" },
141
+ { url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" },
142
+ { url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" },
143
+ { url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" },
144
+ { url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" },
145
+ { url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" },
146
+ { url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" },
147
+ { url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" },
148
+ { url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" },
149
+ { url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" },
150
+ { url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" },
151
+ { url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" },
152
+ { url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" },
153
+ { url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" },
154
+ { url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" },
155
+ { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" },
156
+]
157
+
158
+[[package]]
159
+name = "gitdb"
160
+version = "4.0.12"
161
+source = { registry = "https://pypi.org/simple" }
162
+dependencies = [
163
+ { name = "smmap" },
164
+]
165
+sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" }
166
+wheels = [
167
+ { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" },
168
+]
169
+
170
+[[package]]
171
+name = "gitpython"
172
+version = "3.1.46"
173
+source = { registry = "https://pypi.org/simple" }
174
+dependencies = [
175
+ { name = "gitdb" },
176
+]
177
+sdist = { url = "https://files.pythonhosted.org/packages/df/b5/59d16470a1f0dfe8c793f9ef56fd3826093fc52b3bd96d6b9d6c26c7e27b/gitpython-3.1.46.tar.gz", hash = "sha256:400124c7d0ef4ea03f7310ac2fbf7151e09ff97f2a3288d64a440c584a29c37f", size = 215371, upload-time = "2026-01-01T15:37:32.073Z" }
178
+wheels = [
179
+ { url = "https://files.pythonhosted.org/packages/6a/09/e21df6aef1e1ffc0c816f0522ddc3f6dcded766c3261813131c78a704470/gitpython-3.1.46-py3-none-any.whl", hash = "sha256:79812ed143d9d25b6d176a10bb511de0f9c67b1fa641d82097b0ab90398a2058", size = 208620, upload-time = "2026-01-01T15:37:30.574Z" },
180
+]
181
+
182
+[[package]]
183
+name = "iniconfig"
184
+version = "2.3.0"
185
+source = { registry = "https://pypi.org/simple" }
186
+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" }
187
+wheels = [
188
+ { 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" },
189
+]
190
+
191
+[[package]]
192
+name = "jinja2"
193
+version = "3.1.6"
194
+source = { registry = "https://pypi.org/simple" }
195
+dependencies = [
196
+ { name = "markupsafe" },
197
+]
198
+sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
199
+wheels = [
200
+ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
201
+]
202
+
203
+[[package]]
204
+name = "markdown-it-py"
205
+version = "4.0.0"
206
+source = { registry = "https://pypi.org/simple" }
207
+dependencies = [
208
+ { name = "mdurl" },
209
+]
210
+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" }
211
+wheels = [
212
+ { 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" },
213
+]
214
+
215
+[[package]]
216
+name = "markupsafe"
217
+version = "3.0.3"
218
+source = { registry = "https://pypi.org/simple" }
219
+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" }
220
+wheels = [
221
+ { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" },
222
+ { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" },
223
+ { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" },
224
+ { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" },
225
+ { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" },
226
+ { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" },
227
+ { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" },
228
+ { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" },
229
+ { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" },
230
+ { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" },
231
+ { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" },
232
+ { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
233
+ { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
234
+ { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
235
+ { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
236
+ { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
237
+ { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
238
+ { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
239
+ { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
240
+ { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
241
+ { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
242
+ { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
243
+ { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
244
+ { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
245
+ { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
246
+ { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
247
+ { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
248
+ { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
249
+ { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
250
+ { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
251
+ { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
252
+ { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
253
+ { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
254
+ { 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" },
255
+ { 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" },
256
+ { 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" },
257
+ { 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" },
258
+ { 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" },
259
+ { 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" },
260
+ { 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" },
261
+ { 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" },
262
+ { 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" },
263
+ { 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" },
264
+ { 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" },
265
+ { 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" },
266
+ { 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" },
267
+ { 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" },
268
+ { 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" },
269
+ { 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" },
270
+ { 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" },
271
+ { 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" },
272
+ { 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" },
273
+ { 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" },
274
+ { 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" },
275
+ { 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" },
276
+]
277
+
278
+[[package]]
279
+name = "mdurl"
280
+version = "0.1.2"
281
+source = { registry = "https://pypi.org/simple" }
282
+sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
283
+wheels = [
284
+ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
285
+]
286
+
287
+[[package]]
288
+name = "packaging"
289
+version = "26.0"
290
+source = { registry = "https://pypi.org/simple" }
291
+sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
292
+wheels = [
293
+ { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
294
+]
295
+
296
+[[package]]
297
+name = "pluggy"
298
+version = "1.6.0"
299
+source = { registry = "https://pypi.org/simple" }
300
+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" }
301
+wheels = [
302
+ { 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" },
303
+]
304
+
305
+[[package]]
306
+name = "prompt-toolkit"
307
+version = "3.0.52"
308
+source = { registry = "https://pypi.org/simple" }
309
+dependencies = [
310
+ { name = "wcwidth" },
311
+]
312
+sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" }
313
+wheels = [
314
+ { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" },
315
+]
316
+
317
+[[package]]
318
+name = "pydantic"
319
+version = "2.12.5"
320
+source = { registry = "https://pypi.org/simple" }
321
+dependencies = [
322
+ { name = "annotated-types" },
323
+ { name = "pydantic-core" },
324
+ { name = "typing-extensions" },
325
+ { name = "typing-inspection" },
326
+]
327
+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" }
328
+wheels = [
329
+ { 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" },
330
+]
331
+
332
+[[package]]
333
+name = "pydantic-core"
334
+version = "2.41.5"
335
+source = { registry = "https://pypi.org/simple" }
336
+dependencies = [
337
+ { name = "typing-extensions" },
338
+]
339
+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" }
340
+wheels = [
341
+ { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" },
342
+ { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" },
343
+ { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" },
344
+ { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" },
345
+ { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" },
346
+ { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" },
347
+ { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" },
348
+ { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" },
349
+ { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" },
350
+ { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" },
351
+ { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" },
352
+ { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" },
353
+ { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" },
354
+ { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" },
355
+ { 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" },
356
+ { 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" },
357
+ { 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" },
358
+ { 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" },
359
+ { 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" },
360
+ { 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" },
361
+ { 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" },
362
+ { 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" },
363
+ { 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" },
364
+ { 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" },
365
+ { 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" },
366
+ { 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" },
367
+ { 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" },
368
+ { 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" },
369
+ { 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" },
370
+ { 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" },
371
+ { 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" },
372
+ { 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" },
373
+ { 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" },
374
+ { 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" },
375
+ { 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" },
376
+ { 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" },
377
+ { 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" },
378
+ { 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" },
379
+ { 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" },
380
+ { 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" },
381
+ { 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" },
382
+ { 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" },
383
+ { 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" },
384
+ { 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" },
385
+ { 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" },
386
+ { 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" },
387
+ { 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" },
388
+ { 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" },
389
+ { 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" },
390
+ { 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" },
391
+ { 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" },
392
+ { 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" },
393
+ { 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" },
394
+ { 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" },
395
+ { 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" },
396
+ { 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" },
397
+ { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" },
398
+ { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" },
399
+ { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" },
400
+ { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" },
401
+]
402
+
403
+[[package]]
404
+name = "pygments"
405
+version = "2.20.0"
406
+source = { registry = "https://pypi.org/simple" }
407
+sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
408
+wheels = [
409
+ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
410
+]
411
+
412
+[[package]]
413
+name = "pytest"
414
+version = "9.0.2"
415
+source = { registry = "https://pypi.org/simple" }
416
+dependencies = [
417
+ { name = "colorama", marker = "sys_platform == 'win32'" },
418
+ { name = "iniconfig" },
419
+ { name = "packaging" },
420
+ { name = "pluggy" },
421
+ { name = "pygments" },
422
+]
423
+sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
424
+wheels = [
425
+ { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
426
+]
427
+
428
+[[package]]
429
+name = "pytest-cov"
430
+version = "7.1.0"
431
+source = { registry = "https://pypi.org/simple" }
432
+dependencies = [
433
+ { name = "coverage" },
434
+ { name = "pluggy" },
435
+ { name = "pytest" },
436
+]
437
+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" }
438
+wheels = [
439
+ { 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" },
440
+]
441
+
442
+[[package]]
443
+name = "pyyaml"
444
+version = "6.0.3"
445
+source = { registry = "https://pypi.org/simple" }
446
+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" }
447
+wheels = [
448
+ { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
449
+ { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
450
+ { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
451
+ { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
452
+ { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
453
+ { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
454
+ { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
455
+ { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
456
+ { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
457
+ { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
458
+ { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
459
+ { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
460
+ { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
461
+ { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
462
+ { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
463
+ { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
464
+ { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
465
+ { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
466
+ { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
467
+ { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
468
+ { 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" },
469
+ { 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" },
470
+ { 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" },
471
+ { 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" },
472
+ { 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" },
473
+ { 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" },
474
+ { 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" },
475
+ { 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" },
476
+ { 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" },
477
+ { 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" },
478
+ { 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" },
479
+ { 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" },
480
+ { 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" },
481
+ { 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" },
482
+ { 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" },
483
+ { 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" },
484
+ { 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" },
485
+ { 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" },
486
+]
487
+
488
+[[package]]
489
+name = "questionary"
490
+version = "2.1.1"
491
+source = { registry = "https://pypi.org/simple" }
492
+dependencies = [
493
+ { name = "prompt-toolkit" },
494
+]
495
+sdist = { url = "https://files.pythonhosted.org/packages/f6/45/eafb0bba0f9988f6a2520f9ca2df2c82ddfa8d67c95d6625452e97b204a5/questionary-2.1.1.tar.gz", hash = "sha256:3d7e980292bb0107abaa79c68dd3eee3c561b83a0f89ae482860b181c8bd412d", size = 25845, upload-time = "2025-08-28T19:00:20.851Z" }
496
+wheels = [
497
+ { url = "https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59", size = 36753, upload-time = "2025-08-28T19:00:19.56Z" },
498
+]
499
+
500
+[[package]]
501
+name = "rich"
502
+version = "14.3.3"
503
+source = { registry = "https://pypi.org/simple" }
504
+dependencies = [
505
+ { name = "markdown-it-py" },
506
+ { name = "pygments" },
507
+]
508
+sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" }
509
+wheels = [
510
+ { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" },
511
+]
512
+
513
+[[package]]
514
+name = "ruff"
515
+version = "0.15.8"
516
+source = { registry = "https://pypi.org/simple" }
517
+sdist = { url = "https://files.pythonhosted.org/packages/14/b0/73cf7550861e2b4824950b8b52eebdcc5adc792a00c514406556c5b80817/ruff-0.15.8.tar.gz", hash = "sha256:995f11f63597ee362130d1d5a327a87cb6f3f5eae3094c620bcc632329a4d26e", size = 4610921, upload-time = "2026-03-26T18:39:38.675Z" }
518
+wheels = [
519
+ { url = "https://files.pythonhosted.org/packages/4a/92/c445b0cd6da6e7ae51e954939cb69f97e008dbe750cfca89b8cedc081be7/ruff-0.15.8-py3-none-linux_armv6l.whl", hash = "sha256:cbe05adeba76d58162762d6b239c9056f1a15a55bd4b346cfd21e26cd6ad7bc7", size = 10527394, upload-time = "2026-03-26T18:39:41.566Z" },
520
+ { url = "https://files.pythonhosted.org/packages/eb/92/f1c662784d149ad1414cae450b082cf736430c12ca78367f20f5ed569d65/ruff-0.15.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d3e3d0b6ba8dca1b7ef9ab80a28e840a20070c4b62e56d675c24f366ef330570", size = 10905693, upload-time = "2026-03-26T18:39:30.364Z" },
521
+ { url = "https://files.pythonhosted.org/packages/ca/f2/7a631a8af6d88bcef997eb1bf87cc3da158294c57044aafd3e17030613de/ruff-0.15.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6ee3ae5c65a42f273f126686353f2e08ff29927b7b7e203b711514370d500de3", size = 10323044, upload-time = "2026-03-26T18:39:33.37Z" },
522
+ { url = "https://files.pythonhosted.org/packages/67/18/1bf38e20914a05e72ef3b9569b1d5c70a7ef26cd188d69e9ca8ef588d5bf/ruff-0.15.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdce027ada77baa448077ccc6ebb2fa9c3c62fd110d8659d601cf2f475858d94", size = 10629135, upload-time = "2026-03-26T18:39:44.142Z" },
523
+ { url = "https://files.pythonhosted.org/packages/d2/e9/138c150ff9af60556121623d41aba18b7b57d95ac032e177b6a53789d279/ruff-0.15.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12e617fc01a95e5821648a6df341d80456bd627bfab8a829f7cfc26a14a4b4a3", size = 10348041, upload-time = "2026-03-26T18:39:52.178Z" },
524
+ { url = "https://files.pythonhosted.org/packages/02/f1/5bfb9298d9c323f842c5ddeb85f1f10ef51516ac7a34ba446c9347d898df/ruff-0.15.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:432701303b26416d22ba696c39f2c6f12499b89093b61360abc34bcc9bf07762", size = 11121987, upload-time = "2026-03-26T18:39:55.195Z" },
525
+ { url = "https://files.pythonhosted.org/packages/10/11/6da2e538704e753c04e8d86b1fc55712fdbdcc266af1a1ece7a51fff0d10/ruff-0.15.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d910ae974b7a06a33a057cb87d2a10792a3b2b3b35e33d2699fdf63ec8f6b17a", size = 11951057, upload-time = "2026-03-26T18:39:19.18Z" },
526
+ { url = "https://files.pythonhosted.org/packages/83/f0/c9208c5fd5101bf87002fed774ff25a96eea313d305f1e5d5744698dc314/ruff-0.15.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2033f963c43949d51e6fdccd3946633c6b37c484f5f98c3035f49c27395a8ab8", size = 11464613, upload-time = "2026-03-26T18:40:06.301Z" },
527
+ { url = "https://files.pythonhosted.org/packages/f8/22/d7f2fabdba4fae9f3b570e5605d5eb4500dcb7b770d3217dca4428484b17/ruff-0.15.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f29b989a55572fb885b77464cf24af05500806ab4edf9a0fd8977f9759d85b1", size = 11257557, upload-time = "2026-03-26T18:39:57.972Z" },
528
+ { url = "https://files.pythonhosted.org/packages/71/8c/382a9620038cf6906446b23ce8632ab8c0811b8f9d3e764f58bedd0c9a6f/ruff-0.15.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:ac51d486bf457cdc985a412fb1801b2dfd1bd8838372fc55de64b1510eff4bec", size = 11169440, upload-time = "2026-03-26T18:39:22.205Z" },
529
+ { url = "https://files.pythonhosted.org/packages/4d/0d/0994c802a7eaaf99380085e4e40c845f8e32a562e20a38ec06174b52ef24/ruff-0.15.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c9861eb959edab053c10ad62c278835ee69ca527b6dcd72b47d5c1e5648964f6", size = 10605963, upload-time = "2026-03-26T18:39:46.682Z" },
530
+ { url = "https://files.pythonhosted.org/packages/19/aa/d624b86f5b0aad7cef6bbf9cd47a6a02dfdc4f72c92a337d724e39c9d14b/ruff-0.15.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8d9a5b8ea13f26ae90838afc33f91b547e61b794865374f114f349e9036835fb", size = 10357484, upload-time = "2026-03-26T18:39:49.176Z" },
531
+ { url = "https://files.pythonhosted.org/packages/35/c3/e0b7835d23001f7d999f3895c6b569927c4d39912286897f625736e1fd04/ruff-0.15.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c2a33a529fb3cbc23a7124b5c6ff121e4d6228029cba374777bd7649cc8598b8", size = 10830426, upload-time = "2026-03-26T18:40:03.702Z" },
532
+ { url = "https://files.pythonhosted.org/packages/f0/51/ab20b322f637b369383adc341d761eaaa0f0203d6b9a7421cd6e783d81b9/ruff-0.15.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:75e5cd06b1cf3f47a3996cfc999226b19aa92e7cce682dcd62f80d7035f98f49", size = 11345125, upload-time = "2026-03-26T18:39:27.799Z" },
533
+ { url = "https://files.pythonhosted.org/packages/37/e6/90b2b33419f59d0f2c4c8a48a4b74b460709a557e8e0064cf33ad894f983/ruff-0.15.8-py3-none-win32.whl", hash = "sha256:bc1f0a51254ba21767bfa9a8b5013ca8149dcf38092e6a9eb704d876de94dc34", size = 10571959, upload-time = "2026-03-26T18:39:36.117Z" },
534
+ { url = "https://files.pythonhosted.org/packages/1f/a2/ef467cb77099062317154c63f234b8a7baf7cb690b99af760c5b68b9ee7f/ruff-0.15.8-py3-none-win_amd64.whl", hash = "sha256:04f79eff02a72db209d47d665ba7ebcad609d8918a134f86cb13dd132159fc89", size = 11743893, upload-time = "2026-03-26T18:39:25.01Z" },
535
+ { url = "https://files.pythonhosted.org/packages/15/e2/77be4fff062fa78d9b2a4dea85d14785dac5f1d0c1fb58ed52331f0ebe28/ruff-0.15.8-py3-none-win_arm64.whl", hash = "sha256:cf891fa8e3bb430c0e7fac93851a5978fc99c8fa2c053b57b118972866f8e5f2", size = 11048175, upload-time = "2026-03-26T18:40:01.06Z" },
536
+]
537
+
538
+[[package]]
539
+name = "smmap"
540
+version = "5.0.3"
541
+source = { registry = "https://pypi.org/simple" }
542
+sdist = { url = "https://files.pythonhosted.org/packages/1f/ea/49c993d6dfdd7338c9b1000a0f36817ed7ec84577ae2e52f890d1a4ff909/smmap-5.0.3.tar.gz", hash = "sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c", size = 22506, upload-time = "2026-03-09T03:43:26.1Z" }
543
+wheels = [
544
+ { url = "https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f", size = 24390, upload-time = "2026-03-09T03:43:24.361Z" },
545
+]
546
+
547
+[[package]]
548
+name = "typing-extensions"
549
+version = "4.15.0"
550
+source = { registry = "https://pypi.org/simple" }
551
+sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
552
+wheels = [
553
+ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
554
+]
555
+
556
+[[package]]
557
+name = "typing-inspection"
558
+version = "0.4.2"
559
+source = { registry = "https://pypi.org/simple" }
560
+dependencies = [
561
+ { name = "typing-extensions" },
562
+]
563
+sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
564
+wheels = [
565
+ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
566
+]
567
+
568
+[[package]]
569
+name = "wcwidth"
570
+version = "0.6.0"
571
+source = { registry = "https://pypi.org/simple" }
572
+sdist = { url = "https://files.pythonhosted.org/packages/35/a2/8e3becb46433538a38726c948d3399905a4c7cabd0df578ede5dc51f0ec2/wcwidth-0.6.0.tar.gz", hash = "sha256:cdc4e4262d6ef9a1a57e018384cbeb1208d8abbc64176027e2c2455c81313159", size = 159684, upload-time = "2026-02-06T19:19:40.919Z" }
573
+wheels = [
574
+ { url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189, upload-time = "2026-02-06T19:19:39.646Z" },
575
+]
--- a/uv.lock
+++ b/uv.lock
@@ -0,0 +1,575 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/uv.lock
+++ b/uv.lock
@@ -0,0 +1,575 @@
1 version = 1
2 revision = 3
3 requires-python = ">=3.12"
4
5 [[package]]
6 name = "annotated-types"
7 version = "0.7.0"
8 source = { registry = "https://pypi.org/simple" }
9 sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
10 wheels = [
11 { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
12 ]
13
14 [[package]]
15 name = "boilerworks"
16 version = "0.1.0"
17 source = { editable = "." }
18 dependencies = [
19 { name = "click" },
20 { name = "gitpython" },
21 { name = "jinja2" },
22 { name = "pydantic" },
23 { name = "pyyaml" },
24 { name = "questionary" },
25 { name = "rich" },
26 ]
27
28 [package.dev-dependencies]
29 dev = [
30 { name = "pytest" },
31 { name = "pytest-cov" },
32 { name = "ruff" },
33 ]
34
35 [package.metadata]
36 requires-dist = [
37 { name = "click", specifier = ">=8.1" },
38 { name = "gitpython", specifier = ">=3.1" },
39 { name = "jinja2", specifier = ">=3.0" },
40 { name = "pydantic", specifier = ">=2.0" },
41 { name = "pyyaml", specifier = ">=6.0" },
42 { name = "questionary", specifier = ">=2.0" },
43 { name = "rich", specifier = ">=13.0" },
44 ]
45
46 [package.metadata.requires-dev]
47 dev = [
48 { name = "pytest", specifier = ">=8.0" },
49 { name = "pytest-cov", specifier = ">=5.0" },
50 { name = "ruff", specifier = ">=0.9" },
51 ]
52
53 [[package]]
54 name = "click"
55 version = "8.3.1"
56 source = { registry = "https://pypi.org/simple" }
57 dependencies = [
58 { name = "colorama", marker = "sys_platform == 'win32'" },
59 ]
60 sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
61 wheels = [
62 { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
63 ]
64
65 [[package]]
66 name = "colorama"
67 version = "0.4.6"
68 source = { registry = "https://pypi.org/simple" }
69 sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
70 wheels = [
71 { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
72 ]
73
74 [[package]]
75 name = "coverage"
76 version = "7.13.5"
77 source = { registry = "https://pypi.org/simple" }
78 sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" }
79 wheels = [
80 { url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" },
81 { url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" },
82 { url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" },
83 { url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" },
84 { url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" },
85 { url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" },
86 { url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" },
87 { url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" },
88 { url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" },
89 { url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" },
90 { url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" },
91 { url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" },
92 { url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" },
93 { url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" },
94 { url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" },
95 { url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" },
96 { url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" },
97 { url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" },
98 { url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" },
99 { url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" },
100 { url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" },
101 { url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" },
102 { url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" },
103 { url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" },
104 { url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" },
105 { url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" },
106 { url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" },
107 { url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" },
108 { url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" },
109 { url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" },
110 { url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" },
111 { url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" },
112 { url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" },
113 { url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" },
114 { url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" },
115 { url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" },
116 { url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" },
117 { url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" },
118 { url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" },
119 { url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" },
120 { url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" },
121 { url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" },
122 { url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" },
123 { url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" },
124 { url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" },
125 { url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" },
126 { url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" },
127 { url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" },
128 { url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" },
129 { url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" },
130 { url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" },
131 { url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" },
132 { url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" },
133 { url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" },
134 { url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" },
135 { url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" },
136 { url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" },
137 { url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" },
138 { url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" },
139 { url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" },
140 { url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" },
141 { url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" },
142 { url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" },
143 { url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" },
144 { url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" },
145 { url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" },
146 { url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" },
147 { url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" },
148 { url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" },
149 { url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" },
150 { url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" },
151 { url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" },
152 { url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" },
153 { url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" },
154 { url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" },
155 { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" },
156 ]
157
158 [[package]]
159 name = "gitdb"
160 version = "4.0.12"
161 source = { registry = "https://pypi.org/simple" }
162 dependencies = [
163 { name = "smmap" },
164 ]
165 sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" }
166 wheels = [
167 { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" },
168 ]
169
170 [[package]]
171 name = "gitpython"
172 version = "3.1.46"
173 source = { registry = "https://pypi.org/simple" }
174 dependencies = [
175 { name = "gitdb" },
176 ]
177 sdist = { url = "https://files.pythonhosted.org/packages/df/b5/59d16470a1f0dfe8c793f9ef56fd3826093fc52b3bd96d6b9d6c26c7e27b/gitpython-3.1.46.tar.gz", hash = "sha256:400124c7d0ef4ea03f7310ac2fbf7151e09ff97f2a3288d64a440c584a29c37f", size = 215371, upload-time = "2026-01-01T15:37:32.073Z" }
178 wheels = [
179 { url = "https://files.pythonhosted.org/packages/6a/09/e21df6aef1e1ffc0c816f0522ddc3f6dcded766c3261813131c78a704470/gitpython-3.1.46-py3-none-any.whl", hash = "sha256:79812ed143d9d25b6d176a10bb511de0f9c67b1fa641d82097b0ab90398a2058", size = 208620, upload-time = "2026-01-01T15:37:30.574Z" },
180 ]
181
182 [[package]]
183 name = "iniconfig"
184 version = "2.3.0"
185 source = { registry = "https://pypi.org/simple" }
186 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" }
187 wheels = [
188 { 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" },
189 ]
190
191 [[package]]
192 name = "jinja2"
193 version = "3.1.6"
194 source = { registry = "https://pypi.org/simple" }
195 dependencies = [
196 { name = "markupsafe" },
197 ]
198 sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
199 wheels = [
200 { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
201 ]
202
203 [[package]]
204 name = "markdown-it-py"
205 version = "4.0.0"
206 source = { registry = "https://pypi.org/simple" }
207 dependencies = [
208 { name = "mdurl" },
209 ]
210 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" }
211 wheels = [
212 { 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" },
213 ]
214
215 [[package]]
216 name = "markupsafe"
217 version = "3.0.3"
218 source = { registry = "https://pypi.org/simple" }
219 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" }
220 wheels = [
221 { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" },
222 { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" },
223 { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" },
224 { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" },
225 { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" },
226 { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" },
227 { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" },
228 { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" },
229 { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" },
230 { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" },
231 { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" },
232 { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
233 { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
234 { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
235 { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
236 { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
237 { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
238 { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
239 { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
240 { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
241 { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
242 { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
243 { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
244 { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
245 { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
246 { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
247 { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
248 { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
249 { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
250 { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
251 { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
252 { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
253 { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
254 { 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" },
255 { 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" },
256 { 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" },
257 { 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" },
258 { 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" },
259 { 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" },
260 { 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" },
261 { 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" },
262 { 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" },
263 { 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" },
264 { 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" },
265 { 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" },
266 { 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" },
267 { 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" },
268 { 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" },
269 { 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" },
270 { 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" },
271 { 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" },
272 { 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" },
273 { 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" },
274 { 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" },
275 { 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" },
276 ]
277
278 [[package]]
279 name = "mdurl"
280 version = "0.1.2"
281 source = { registry = "https://pypi.org/simple" }
282 sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
283 wheels = [
284 { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
285 ]
286
287 [[package]]
288 name = "packaging"
289 version = "26.0"
290 source = { registry = "https://pypi.org/simple" }
291 sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
292 wheels = [
293 { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
294 ]
295
296 [[package]]
297 name = "pluggy"
298 version = "1.6.0"
299 source = { registry = "https://pypi.org/simple" }
300 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" }
301 wheels = [
302 { 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" },
303 ]
304
305 [[package]]
306 name = "prompt-toolkit"
307 version = "3.0.52"
308 source = { registry = "https://pypi.org/simple" }
309 dependencies = [
310 { name = "wcwidth" },
311 ]
312 sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" }
313 wheels = [
314 { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" },
315 ]
316
317 [[package]]
318 name = "pydantic"
319 version = "2.12.5"
320 source = { registry = "https://pypi.org/simple" }
321 dependencies = [
322 { name = "annotated-types" },
323 { name = "pydantic-core" },
324 { name = "typing-extensions" },
325 { name = "typing-inspection" },
326 ]
327 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" }
328 wheels = [
329 { 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" },
330 ]
331
332 [[package]]
333 name = "pydantic-core"
334 version = "2.41.5"
335 source = { registry = "https://pypi.org/simple" }
336 dependencies = [
337 { name = "typing-extensions" },
338 ]
339 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" }
340 wheels = [
341 { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" },
342 { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" },
343 { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" },
344 { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" },
345 { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" },
346 { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" },
347 { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" },
348 { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" },
349 { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" },
350 { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" },
351 { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" },
352 { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" },
353 { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" },
354 { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" },
355 { 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" },
356 { 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" },
357 { 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" },
358 { 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" },
359 { 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" },
360 { 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" },
361 { 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" },
362 { 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" },
363 { 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" },
364 { 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" },
365 { 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" },
366 { 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" },
367 { 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" },
368 { 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" },
369 { 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" },
370 { 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" },
371 { 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" },
372 { 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" },
373 { 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" },
374 { 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" },
375 { 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" },
376 { 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" },
377 { 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" },
378 { 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" },
379 { 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" },
380 { 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" },
381 { 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" },
382 { 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" },
383 { 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" },
384 { 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" },
385 { 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" },
386 { 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" },
387 { 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" },
388 { 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" },
389 { 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" },
390 { 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" },
391 { 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" },
392 { 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" },
393 { 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" },
394 { 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" },
395 { 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" },
396 { 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" },
397 { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" },
398 { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" },
399 { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" },
400 { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" },
401 ]
402
403 [[package]]
404 name = "pygments"
405 version = "2.20.0"
406 source = { registry = "https://pypi.org/simple" }
407 sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
408 wheels = [
409 { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
410 ]
411
412 [[package]]
413 name = "pytest"
414 version = "9.0.2"
415 source = { registry = "https://pypi.org/simple" }
416 dependencies = [
417 { name = "colorama", marker = "sys_platform == 'win32'" },
418 { name = "iniconfig" },
419 { name = "packaging" },
420 { name = "pluggy" },
421 { name = "pygments" },
422 ]
423 sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
424 wheels = [
425 { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
426 ]
427
428 [[package]]
429 name = "pytest-cov"
430 version = "7.1.0"
431 source = { registry = "https://pypi.org/simple" }
432 dependencies = [
433 { name = "coverage" },
434 { name = "pluggy" },
435 { name = "pytest" },
436 ]
437 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" }
438 wheels = [
439 { 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" },
440 ]
441
442 [[package]]
443 name = "pyyaml"
444 version = "6.0.3"
445 source = { registry = "https://pypi.org/simple" }
446 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" }
447 wheels = [
448 { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
449 { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
450 { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
451 { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
452 { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
453 { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
454 { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
455 { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
456 { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
457 { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
458 { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
459 { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
460 { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
461 { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
462 { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
463 { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
464 { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
465 { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
466 { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
467 { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
468 { 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" },
469 { 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" },
470 { 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" },
471 { 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" },
472 { 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" },
473 { 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" },
474 { 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" },
475 { 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" },
476 { 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" },
477 { 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" },
478 { 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" },
479 { 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" },
480 { 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" },
481 { 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" },
482 { 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" },
483 { 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" },
484 { 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" },
485 { 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" },
486 ]
487
488 [[package]]
489 name = "questionary"
490 version = "2.1.1"
491 source = { registry = "https://pypi.org/simple" }
492 dependencies = [
493 { name = "prompt-toolkit" },
494 ]
495 sdist = { url = "https://files.pythonhosted.org/packages/f6/45/eafb0bba0f9988f6a2520f9ca2df2c82ddfa8d67c95d6625452e97b204a5/questionary-2.1.1.tar.gz", hash = "sha256:3d7e980292bb0107abaa79c68dd3eee3c561b83a0f89ae482860b181c8bd412d", size = 25845, upload-time = "2025-08-28T19:00:20.851Z" }
496 wheels = [
497 { url = "https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59", size = 36753, upload-time = "2025-08-28T19:00:19.56Z" },
498 ]
499
500 [[package]]
501 name = "rich"
502 version = "14.3.3"
503 source = { registry = "https://pypi.org/simple" }
504 dependencies = [
505 { name = "markdown-it-py" },
506 { name = "pygments" },
507 ]
508 sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" }
509 wheels = [
510 { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" },
511 ]
512
513 [[package]]
514 name = "ruff"
515 version = "0.15.8"
516 source = { registry = "https://pypi.org/simple" }
517 sdist = { url = "https://files.pythonhosted.org/packages/14/b0/73cf7550861e2b4824950b8b52eebdcc5adc792a00c514406556c5b80817/ruff-0.15.8.tar.gz", hash = "sha256:995f11f63597ee362130d1d5a327a87cb6f3f5eae3094c620bcc632329a4d26e", size = 4610921, upload-time = "2026-03-26T18:39:38.675Z" }
518 wheels = [
519 { url = "https://files.pythonhosted.org/packages/4a/92/c445b0cd6da6e7ae51e954939cb69f97e008dbe750cfca89b8cedc081be7/ruff-0.15.8-py3-none-linux_armv6l.whl", hash = "sha256:cbe05adeba76d58162762d6b239c9056f1a15a55bd4b346cfd21e26cd6ad7bc7", size = 10527394, upload-time = "2026-03-26T18:39:41.566Z" },
520 { url = "https://files.pythonhosted.org/packages/eb/92/f1c662784d149ad1414cae450b082cf736430c12ca78367f20f5ed569d65/ruff-0.15.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d3e3d0b6ba8dca1b7ef9ab80a28e840a20070c4b62e56d675c24f366ef330570", size = 10905693, upload-time = "2026-03-26T18:39:30.364Z" },
521 { url = "https://files.pythonhosted.org/packages/ca/f2/7a631a8af6d88bcef997eb1bf87cc3da158294c57044aafd3e17030613de/ruff-0.15.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6ee3ae5c65a42f273f126686353f2e08ff29927b7b7e203b711514370d500de3", size = 10323044, upload-time = "2026-03-26T18:39:33.37Z" },
522 { url = "https://files.pythonhosted.org/packages/67/18/1bf38e20914a05e72ef3b9569b1d5c70a7ef26cd188d69e9ca8ef588d5bf/ruff-0.15.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdce027ada77baa448077ccc6ebb2fa9c3c62fd110d8659d601cf2f475858d94", size = 10629135, upload-time = "2026-03-26T18:39:44.142Z" },
523 { url = "https://files.pythonhosted.org/packages/d2/e9/138c150ff9af60556121623d41aba18b7b57d95ac032e177b6a53789d279/ruff-0.15.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12e617fc01a95e5821648a6df341d80456bd627bfab8a829f7cfc26a14a4b4a3", size = 10348041, upload-time = "2026-03-26T18:39:52.178Z" },
524 { url = "https://files.pythonhosted.org/packages/02/f1/5bfb9298d9c323f842c5ddeb85f1f10ef51516ac7a34ba446c9347d898df/ruff-0.15.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:432701303b26416d22ba696c39f2c6f12499b89093b61360abc34bcc9bf07762", size = 11121987, upload-time = "2026-03-26T18:39:55.195Z" },
525 { url = "https://files.pythonhosted.org/packages/10/11/6da2e538704e753c04e8d86b1fc55712fdbdcc266af1a1ece7a51fff0d10/ruff-0.15.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d910ae974b7a06a33a057cb87d2a10792a3b2b3b35e33d2699fdf63ec8f6b17a", size = 11951057, upload-time = "2026-03-26T18:39:19.18Z" },
526 { url = "https://files.pythonhosted.org/packages/83/f0/c9208c5fd5101bf87002fed774ff25a96eea313d305f1e5d5744698dc314/ruff-0.15.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2033f963c43949d51e6fdccd3946633c6b37c484f5f98c3035f49c27395a8ab8", size = 11464613, upload-time = "2026-03-26T18:40:06.301Z" },
527 { url = "https://files.pythonhosted.org/packages/f8/22/d7f2fabdba4fae9f3b570e5605d5eb4500dcb7b770d3217dca4428484b17/ruff-0.15.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f29b989a55572fb885b77464cf24af05500806ab4edf9a0fd8977f9759d85b1", size = 11257557, upload-time = "2026-03-26T18:39:57.972Z" },
528 { url = "https://files.pythonhosted.org/packages/71/8c/382a9620038cf6906446b23ce8632ab8c0811b8f9d3e764f58bedd0c9a6f/ruff-0.15.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:ac51d486bf457cdc985a412fb1801b2dfd1bd8838372fc55de64b1510eff4bec", size = 11169440, upload-time = "2026-03-26T18:39:22.205Z" },
529 { url = "https://files.pythonhosted.org/packages/4d/0d/0994c802a7eaaf99380085e4e40c845f8e32a562e20a38ec06174b52ef24/ruff-0.15.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c9861eb959edab053c10ad62c278835ee69ca527b6dcd72b47d5c1e5648964f6", size = 10605963, upload-time = "2026-03-26T18:39:46.682Z" },
530 { url = "https://files.pythonhosted.org/packages/19/aa/d624b86f5b0aad7cef6bbf9cd47a6a02dfdc4f72c92a337d724e39c9d14b/ruff-0.15.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8d9a5b8ea13f26ae90838afc33f91b547e61b794865374f114f349e9036835fb", size = 10357484, upload-time = "2026-03-26T18:39:49.176Z" },
531 { url = "https://files.pythonhosted.org/packages/35/c3/e0b7835d23001f7d999f3895c6b569927c4d39912286897f625736e1fd04/ruff-0.15.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c2a33a529fb3cbc23a7124b5c6ff121e4d6228029cba374777bd7649cc8598b8", size = 10830426, upload-time = "2026-03-26T18:40:03.702Z" },
532 { url = "https://files.pythonhosted.org/packages/f0/51/ab20b322f637b369383adc341d761eaaa0f0203d6b9a7421cd6e783d81b9/ruff-0.15.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:75e5cd06b1cf3f47a3996cfc999226b19aa92e7cce682dcd62f80d7035f98f49", size = 11345125, upload-time = "2026-03-26T18:39:27.799Z" },
533 { url = "https://files.pythonhosted.org/packages/37/e6/90b2b33419f59d0f2c4c8a48a4b74b460709a557e8e0064cf33ad894f983/ruff-0.15.8-py3-none-win32.whl", hash = "sha256:bc1f0a51254ba21767bfa9a8b5013ca8149dcf38092e6a9eb704d876de94dc34", size = 10571959, upload-time = "2026-03-26T18:39:36.117Z" },
534 { url = "https://files.pythonhosted.org/packages/1f/a2/ef467cb77099062317154c63f234b8a7baf7cb690b99af760c5b68b9ee7f/ruff-0.15.8-py3-none-win_amd64.whl", hash = "sha256:04f79eff02a72db209d47d665ba7ebcad609d8918a134f86cb13dd132159fc89", size = 11743893, upload-time = "2026-03-26T18:39:25.01Z" },
535 { url = "https://files.pythonhosted.org/packages/15/e2/77be4fff062fa78d9b2a4dea85d14785dac5f1d0c1fb58ed52331f0ebe28/ruff-0.15.8-py3-none-win_arm64.whl", hash = "sha256:cf891fa8e3bb430c0e7fac93851a5978fc99c8fa2c053b57b118972866f8e5f2", size = 11048175, upload-time = "2026-03-26T18:40:01.06Z" },
536 ]
537
538 [[package]]
539 name = "smmap"
540 version = "5.0.3"
541 source = { registry = "https://pypi.org/simple" }
542 sdist = { url = "https://files.pythonhosted.org/packages/1f/ea/49c993d6dfdd7338c9b1000a0f36817ed7ec84577ae2e52f890d1a4ff909/smmap-5.0.3.tar.gz", hash = "sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c", size = 22506, upload-time = "2026-03-09T03:43:26.1Z" }
543 wheels = [
544 { url = "https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f", size = 24390, upload-time = "2026-03-09T03:43:24.361Z" },
545 ]
546
547 [[package]]
548 name = "typing-extensions"
549 version = "4.15.0"
550 source = { registry = "https://pypi.org/simple" }
551 sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
552 wheels = [
553 { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
554 ]
555
556 [[package]]
557 name = "typing-inspection"
558 version = "0.4.2"
559 source = { registry = "https://pypi.org/simple" }
560 dependencies = [
561 { name = "typing-extensions" },
562 ]
563 sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
564 wheels = [
565 { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
566 ]
567
568 [[package]]
569 name = "wcwidth"
570 version = "0.6.0"
571 source = { registry = "https://pypi.org/simple" }
572 sdist = { url = "https://files.pythonhosted.org/packages/35/a2/8e3becb46433538a38726c948d3399905a4c7cabd0df578ede5dc51f0ec2/wcwidth-0.6.0.tar.gz", hash = "sha256:cdc4e4262d6ef9a1a57e018384cbeb1208d8abbc64176027e2c2455c81313159", size = 159684, upload-time = "2026-02-06T19:19:40.919Z" }
573 wheels = [
574 { url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189, upload-time = "2026-02-06T19:19:39.646Z" },
575 ]

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button