Compare commits

...
108 Commits
Author SHA1 Message Date
sankalp 70e9068082 fix: None crash in providers, _merge_deepest loop, lock-file false positives, stage() race, cache TTL, config merge, changelog indent, template check 2026-06-06 21:33:51 +05:45
sankalp fb87bfa146 fix: handle deleted files in stage() — use git rm for missing paths 2026-06-06 21:21:10 +05:45
sankalp 69bd79e0fa bump version to 0.2.6 2026-06-06 20:57:00 +05:45
sankalp a86dcc93ee update readme 2026-06-06 17:43:22 +05:45
sankalp 456040ed2b feat(root): revamp README and add gitignore/license commands
Updated project overview, removed emojis, and introduced new CLI subcommands for managing .gitignore and license templates. Added architecture and custom template documentation.

New Features
- Added `devgen gitignore list` and `devgen gitignore add <name> …` commands.
- Added `devgen license list` and `devgen license add <spdx>` commands.
- Introduced support for custom commit templates via `.tpl` files.

Enhancements
- Rewrote README with clearer description and updated badges.
- Removed emojis and streamlined formatting throughout documentation.
- Added sections for custom templates, architecture overview, and contribution guide.
- Clarified dependency installation and CI workflow references.

Bug Fixes
- Fixed formatting issues in badge links and stray hyphens.
- Resolved truncated documentation sections and improved readability.
2026-06-06 17:39:25 +05:45
sankalp df1d1765d1 ci: drop pyright typecheck job in favour of ruff
The project uses ruff for lint+format, and pyright was redundant and
slow. The previous pyright run surfaced ~30 false positives from running
outside the venv plus 5 real-but-minor typing issues that don't justify
the extra CI minutes on a 0.2.x CLI.

CI now runs only ruff check, ruff format --check, and the import/CLI
smoke matrix on 3.10/3.11/3.12/3.13.
2026-06-06 17:38:01 +05:45
sankalp 09ba5e9644 ci: add CodeQL, lint/typecheck/smoke CI, and dependency review
- ci.yml: ruff check + format check, pyright typecheck, and import/CLI
  smoke test on Python 3.10/3.11/3.12/3.13 matrix. Uses uv like the
  existing pre-commit workflow for consistency.
- codeql.yml: weekly + push/PR Python security-and-quality analysis.
- dependency-review.yml: fail PRs adding high+ severity CVEs or copyleft
  licenses that would taint GPL-3.0 (AGPL, SSPL, BUSL).
2026-06-06 17:31:31 +05:45
sankalp 850e88972e ci(dependabot): merge directly via squash instead of use-github-auto-merge
The previous config required branch protection with required status checks,
but main has no protection rules configured, so auto-merge never fired and
PRs had to be merged manually.

Switch to direct squash merge in the action itself, which works regardless
of branch protection settings.
2026-06-06 17:24:10 +05:45
sankalp 96e776f7cc chore(root):ignore .tpl files in Linguist language stats to prevent inflated language statistics. 2026-06-06 17:10:34 +05:45
sankalp f70b5208ec refactor(devgen/providers): shared provider base class
Abstract out generate validation and error handling into BaseProvider, eliminating duplicated logic across Anthropic, OpenRouter, and OpenAI providers.
2026-06-06 17:10:30 +05:45
sankalp e2ec269f16 refactor(modules): restructure changelog and release note generation
Refactor to improve readability and separate concerns,
use Section enum and ordered traversal for consistent output.
2026-06-06 17:10:30 +05:45
sankalp 90ca0c0f59 refactor(devgen/modules): rename commit_message template from .j2 to .tpl
Bug Fixes:
- Updated template reference to use .tpl extension, resolving missing template error.
2026-06-06 16:56:14 +05:45
sankalp 9781523e8e refactor(devgen/modules): streamline manifest context generation
Enhancements:
- Replaced verbose scanning of many manifest files with targeted parsing of key project manifests (pyproject.toml, package.json, requirements.txt).
- Generated a compact one‑line‑per‑file summary, reducing token usage.
- Improved clarity and readability of the AI‑provided context.
2026-06-06 16:48:28 +05:45
sankalp 96b2499ecf chore(devgen/templates/commit): streamline commit template to Conventional Commits format
Clarify output format and remove redundant guidance.

New Features:
- Added explicit format and type list

Enhancements:
- Simplified instructions, removed unnecessary guidance

Bug Fixes:
- None
2026-06-06 16:48:21 +05:45
sankalp 4f8e358a60 feat(devgen/modules): expose max_groups and max_diff_size as CLI override flags
- Add CLI flags to override `max_groups` and `max_diff_size` limits
- Update file grouping logic to respect override values
- Improve truncation logging with configurable size and guidance
- Remove unused imports and streamline rich console setup
2026-06-06 16:45:18 +05:45
sankalp 72c57f0d33 feat(devgen):add token limit error detection and friendly formatting utilities 2026-06-06 16:45:13 +05:45
sankalp 0b023482d7 feat(devgen/cli): introduce --max-groups and --max-diff-size options for commit command
Add limits to prevent token‑limit errors during commit generation.
- New `--max-groups` option to cap the number of commit groups.
- New `--max-diff-size` option to set maximum diff size per group (default 8000 chars).
Update logging to reflect the new options and pass them to the engine.
2026-06-06 16:45:07 +05:45
sankalp 8069895bc7 feat(devgen/providers): normalize token limit error handling
Add token limit error detection and formatting across all provider modules to surface clear diagnostics when context windows are exceeded, improving error messages and preventing silent failures.

New Features
- Introduce `is_token_limit_error` checks in Anthropic, Gemini, Hugging Face, Ollama, OpenAI, and OpenRouter providers.
- Use `format_token_limit_error` to generate user‑friendly error messages for token limit overs.

Enhancements
- Centralize token limit error handling for consistent diagnostics across providers.
- Explicitly re‑raise `RuntimeError` in Hugging Face to preserve original behavior.

Bug Fixes
- None
2026-06-06 16:45:05 +05:45
sankalp d991af9c47 feat(devgen/cli): introduce Ollamaprovider support and interactive host configuration
- Add "ollama" to AI provider choices in config prompts
- Prompt user for Ollama server URL when selecting the Ollama provider
- Store the configured Ollama host in the config under the "ollama_host" key
- Update config saving logic in both edit_config and setup_config functions
2026-06-06 16:31:09 +05:45
sankalp d8fcf0849a feat(root): add local Ollama AI model support
- New Features
  - Added Ollama as a supported AI model source.
  - Updated README and project manifests to include Ollama.

- Enhancements
  - Expanded AI model selection to handle local Ollama endpoints.
  - Improved dependency resolution for Ollama packages.

- Bug Fixes
  - None
2026-06-06 16:31:06 +05:45
sankalp aad3703ea3 feat(devgen/modules): add configurable Ollama host- New Features
- Added `ollama_host` configuration option.
- Enhancements
  - Use the configured host when calling the Ollama provider.
2026-06-06 16:31:02 +05:45
sankalp a8b48e9f07 feat(devgen/providers): add OllamaProvider for local Ollama integration
Add a new provider that wraps the local Ollama server using an OpenAI‑compatible client, enabling generation via `ollama_host` configuration. This introduces the first provider for local LLM inference.

New Features
- Implement OllamaProvider with a `generate()` method, supporting custom host, model selection, and robust error handling.
2026-06-06 16:30:56 +05:45
sankalp 7016cff3a7 feat(devgen): add Ollama provider and host configuration
Add support for the Ollama provider in the AI generation pipeline and expose a configurable host address.

New Features:
- Introduce `ollama` as a selectable provider.
- Add `ollama_host` configuration option with default `http://localhost:11434`.

Enhancements:
- Extend provider list in error message to include Ollama.
2026-06-06 16:30:52 +05:45
sankalp 55ebbaef15 feat(devgen/cli): improve version callback error messages
- Enhanced error messages when version cannot be determined, providing clearer guidance.
- Added explicit handling for missing pyproject.toml and TOML parsing errors.
- Improved user feedback for installation corruption issues.
2026-06-06 16:27:20 +05:45
sankalp 80f68a6ccd feat(devgen): enhance provider import error handling
Enhancements:
- Split ModuleNotFoundError and AttributeError handling to deliver clearer diagnostics
- Explicitly list available providers and flag missing provider classes as packaging bugs

Bug Fixes:
- Provide specific guidance when a provider module is not a built‑in module
- Clarify that a missing provider class indicates a packaging issue rather than user error
2026-06-06 16:27:17 +05:45
sankalp fd3ce03484 feat(devgen/modules): categorize committypes and improve changelog/release note formattingAdd separate categories for Performance, Refactor, Tests, Style, and Chore; update ordering and emojis; improve remote push detection and fetch strategy; enhance error logging.
- New Features  - Introduced distinct sections for Performance, Refactor, Tests, Style, and Chore in changelog generation.
  - Added dedicated emojis for each new category.
  - Updated section ordering to reflect the new categories.
- Enhancements
  - Enhanced push logic to fetch upstream before push, avoiding non‑fast‑forward rejections.
  - Refactored `is_ahead_of_remote` with a fetch flag to reduce network calls and TOCTOU windows.
  - Improved error logging with full traceback for group processing failures.
- Bug Fixes
  - Fixed emoji mappings for "Style" and "Other Changes".
  - Corrected handling of empty upstream ref to prevent errors.
  - Ensured proper exception handling when remote branch is missing.
2026-06-06 16:27:12 +05:45
sankalp 51d2b7549f feat(devgen/providers):improve provider error handling and response parsing 2026-06-06 16:27:03 +05:45
sankalp 5660e40eb4 chore(root): tidy README and bump dependencies
New Features
- None

Enhancements
- Updated dependency versions; fixed pre‑commit typo.
- Normalized Rich version across config files.
- Cleaned up README formatting and spacing.

Bug Fixes
- Fixed misspelling "pre-comit" → "pre-commit".
- Removed redundant whitespace in README badges and title.
2026-06-06 16:26:58 +05:45
sankalp c6914e2139 Merge pull request #53 from S4NKALP/dependabot/pip/main/openai-2.41.0
build(deps): bump openai from 2.29.0 to 2.41.0
2026-06-06 16:11:32 +05:45
dependabot[bot] 4f0a055216 build(deps): bump openai from 2.29.0 to 2.41.0
Bumps [openai](https://github.com/openai/openai-python) from 2.29.0 to 2.41.0.
- [Release notes](https://github.com/openai/openai-python/releases)
- [Changelog](https://github.com/openai/openai-python/blob/main/CHANGELOG.md)
- [Commits](https://github.com/openai/openai-python/compare/v2.29.0...v2.41.0)

---
updated-dependencies:
- dependency-name: openai
  dependency-version: 2.41.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-06 10:26:23 +00:00
sankalp 16c6f70057 Merge pull request #56 from S4NKALP/dependabot/pip/main/typer-0.26.7
build(deps): bump typer from 0.24.1 to 0.26.7
2026-06-06 16:09:48 +05:45
sankalp 3daf798dcd Merge pull request #54 from S4NKALP/dependabot/pip/main/ruff-0.15.16
build(deps): bump ruff from 0.15.7 to 0.15.16
2026-06-06 16:09:33 +05:45
sankalp d6f2452d21 Merge pull request #55 from S4NKALP/dependabot/pip/main/google-genai-2.8.0
build(deps): bump google-genai from 1.68.0 to 2.8.0
2026-06-06 16:09:14 +05:45
dependabot[bot] 59aa40bdcf build(deps): bump google-genai from 1.68.0 to 2.8.0
Bumps [google-genai](https://github.com/googleapis/python-genai) from 1.68.0 to 2.8.0.
- [Release notes](https://github.com/googleapis/python-genai/releases)
- [Changelog](https://github.com/googleapis/python-genai/blob/main/CHANGELOG.md)
- [Commits](https://github.com/googleapis/python-genai/compare/v1.68.0...v2.8.0)

---
updated-dependencies:
- dependency-name: google-genai
  dependency-version: 2.8.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-06 10:22:19 +00:00
sankalp 8fdc346913 Merge pull request #50 from S4NKALP/dependabot/pip/main/anthropic-0.105.2
build(deps): bump anthropic from 0.86.0 to 0.105.2
2026-06-06 16:05:29 +05:45
dependabot[bot] cdca283cd2 build(deps): bump typer from 0.24.1 to 0.26.7
Bumps [typer](https://github.com/fastapi/typer) from 0.24.1 to 0.26.7.
- [Release notes](https://github.com/fastapi/typer/releases)
- [Changelog](https://github.com/fastapi/typer/blob/master/docs/release-notes.md)
- [Commits](https://github.com/fastapi/typer/compare/0.24.1...0.26.7)

---
updated-dependencies:
- dependency-name: typer
  dependency-version: 0.26.7
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-06 10:20:21 +00:00
dependabot[bot] e30d2d854d build(deps): bump ruff from 0.15.7 to 0.15.16
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.15.7 to 0.15.16.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.15.7...0.15.16)

---
updated-dependencies:
- dependency-name: ruff
  dependency-version: 0.15.16
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-06 10:20:20 +00:00
sankalp e77472fb0f Merge pull request #44 from S4NKALP/dependabot/pip/main/requests-2.34.2
build(deps): bump requests from 2.32.5 to 2.34.2
2026-06-06 16:03:59 +05:45
sankalp e3dc6729dc Merge branch 'main' into dependabot/pip/main/requests-2.34.2 2026-06-06 16:03:44 +05:45
sankalp c7c1da8459 Merge pull request #28 from S4NKALP/dependabot/pip/main/rich-15.0.0
build(deps): bump rich from 14.3.3 to 15.0.0
2026-06-06 16:03:14 +05:45
dependabot[bot] b0ad273f87 build(deps): bump anthropic from 0.86.0 to 0.105.2
Bumps [anthropic](https://github.com/anthropics/anthropic-sdk-python) from 0.86.0 to 0.105.2.
- [Release notes](https://github.com/anthropics/anthropic-sdk-python/releases)
- [Changelog](https://github.com/anthropics/anthropic-sdk-python/blob/main/CHANGELOG.md)
- [Commits](https://github.com/anthropics/anthropic-sdk-python/compare/v0.86.0...v0.105.2)

---
updated-dependencies:
- dependency-name: anthropic
  dependency-version: 0.105.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-29 01:19:02 +00:00
dependabot[bot] dcecfec258 build(deps): bump requests from 2.32.5 to 2.34.2
Bumps [requests](https://github.com/psf/requests) from 2.32.5 to 2.34.2.
- [Release notes](https://github.com/psf/requests/releases)
- [Changelog](https://github.com/psf/requests/blob/main/HISTORY.md)
- [Commits](https://github.com/psf/requests/compare/v2.32.5...v2.34.2)

---
updated-dependencies:
- dependency-name: requests
  dependency-version: 2.34.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-15 01:17:54 +00:00
dependabot[bot] f21ba55d6d build(deps): bump rich from 14.3.3 to 15.0.0
Bumps [rich](https://github.com/Textualize/rich) from 14.3.3 to 15.0.0.
- [Release notes](https://github.com/Textualize/rich/releases)
- [Changelog](https://github.com/Textualize/rich/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Textualize/rich/compare/v14.3.3...v15.0.0)

---
updated-dependencies:
- dependency-name: rich
  dependency-version: 15.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-17 01:15:55 +00:00
sankalp 48ae3be0d4 chore: auto-update README [skip ci] 2026-03-27 07:01:11 +05:45
sankalp 5682a51d97 chore: auto-update README [skip ci] 2026-03-20 08:38:34 +05:45
sankalp 3007623f4c docs(root): update README withproject description and badges
Added a concise AI‑focused description and a centered badge section to the README, improving the project overview.

New Features- Introduced an AI‑centric tagline that highlights automation of commits, changelogs, and project management tasks.

Enhancements
- Centered and styled the title block for consistent visual presentation.
- Integrated project badges for PyPI version, Python compatibility, license, build status, and pre‑commit.

Bug Fixes
- None.
2026-03-20 08:38:24 +05:45
sankalp f2e5097e7f chore: auto-update README [skip ci] 2026-03-20 08:34:44 +05:45
sankalp d95c88b240 Merge pull request #14 from S4NKALP/dependabot/pip/main/openai-2.29.0 2026-03-20 08:34:37 +05:45
sankalp dc2b00651f chore: auto-update README [skip ci] 2026-03-20 08:34:18 +05:45
sankalp 2c9c5ed22d Merge pull request #15 from S4NKALP/dependabot/pip/main/ruff-0.15.7 2026-03-20 08:34:11 +05:45
dependabot[bot] 8a42b547ec build(deps): bump openai from 2.26.0 to 2.29.0
Bumps [openai](https://github.com/openai/openai-python) from 2.26.0 to 2.29.0.
- [Release notes](https://github.com/openai/openai-python/releases)
- [Changelog](https://github.com/openai/openai-python/blob/main/CHANGELOG.md)
- [Commits](https://github.com/openai/openai-python/compare/v2.26.0...v2.29.0)

---
updated-dependencies:
- dependency-name: openai
  dependency-version: 2.29.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-20 02:49:10 +00:00
sankalp d7fd658b98 Merge pull request #13 from S4NKALP/dependabot/pip/main/anthropic-0.86.0 2026-03-20 08:33:57 +05:45
sankalp 34a7635a6d chore: auto-update README [skip ci] 2026-03-20 08:33:53 +05:45
sankalp fdd38d5a49 Merge branch 'main' into dependabot/pip/main/anthropic-0.86.0 2026-03-20 08:33:44 +05:45
sankalp aa4fc8018e chore: auto-update README [skip ci] 2026-03-20 08:33:44 +05:45
sankalp 763537a324 Merge pull request #12 from S4NKALP/dependabot/pip/main/google-genai-1.68.0 2026-03-20 08:33:05 +05:45
sankalp 867156b65a chore: auto-update README [skip ci] 2026-03-20 07:01:09 +05:45
dependabot[bot] 0e50e5f5b0 build(deps): bump ruff from 0.15.5 to 0.15.7
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.15.5 to 0.15.7.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.15.5...0.15.7)

---
updated-dependencies:
- dependency-name: ruff
  dependency-version: 0.15.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-20 01:15:20 +00:00
dependabot[bot] 3fde0941eb build(deps): bump anthropic from 0.84.0 to 0.86.0
Bumps [anthropic](https://github.com/anthropics/anthropic-sdk-python) from 0.84.0 to 0.86.0.
- [Release notes](https://github.com/anthropics/anthropic-sdk-python/releases)
- [Changelog](https://github.com/anthropics/anthropic-sdk-python/blob/main/CHANGELOG.md)
- [Commits](https://github.com/anthropics/anthropic-sdk-python/compare/v0.84.0...v0.86.0)

---
updated-dependencies:
- dependency-name: anthropic
  dependency-version: 0.86.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-20 01:15:04 +00:00
dependabot[bot] 5b7303c2a6 build(deps): bump google-genai from 1.66.0 to 1.68.0
Bumps [google-genai](https://github.com/googleapis/python-genai) from 1.66.0 to 1.68.0.
- [Release notes](https://github.com/googleapis/python-genai/releases)
- [Changelog](https://github.com/googleapis/python-genai/blob/main/CHANGELOG.md)
- [Commits](https://github.com/googleapis/python-genai/compare/v1.66.0...v1.68.0)

---
updated-dependencies:
- dependency-name: google-genai
  dependency-version: 1.68.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-20 01:15:01 +00:00
sankalp 6a8e92b4fc chore: auto-update README [skip ci] 2026-03-13 07:01:35 +05:45
sankalp 89bfe85002 docs(root): enrich README with detailed description and badges
Add expanded project overview and formatting updates.

- New Features: Expanded overview and richer description of AI-powered commits and customizable templates.
- Enhancements: Added extra badge images and improved formatting across sections.
- Bug Fixes: None.
2026-03-06 20:38:21 +05:45
sankalp 5515ca5633 Merge pull request #5 from S4NKALP/dependabot/pip/main/google-genai-1.66.0
build(deps): bump google-genai from 0.1.0 to 1.66.0
2026-03-06 20:36:10 +05:45
sankalp e7385c55fb chore: auto-update README [skip ci] 2026-03-06 20:36:03 +05:45
dependabot[bot] 52cf0d4a7b build(deps): bump google-genai from 0.1.0 to 1.66.0
Bumps [google-genai](https://github.com/googleapis/python-genai) from 0.1.0 to 1.66.0.
- [Release notes](https://github.com/googleapis/python-genai/releases)
- [Changelog](https://github.com/googleapis/python-genai/blob/main/CHANGELOG.md)
- [Commits](https://github.com/googleapis/python-genai/commits/v1.66.0)

---
updated-dependencies:
- dependency-name: google-genai
  dependency-version: 1.66.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-06 14:50:55 +00:00
sankalp ceebf728ca Merge pull request #8 from S4NKALP/dependabot/pip/main/rich-14.3.3
build(deps): bump rich from 14.2.0 to 14.3.3
2026-03-06 20:35:44 +05:45
dependabot[bot] 277cb324ba build(deps): bump rich from 14.2.0 to 14.3.3
Bumps [rich](https://github.com/Textualize/rich) from 14.2.0 to 14.3.3.
- [Release notes](https://github.com/Textualize/rich/releases)
- [Changelog](https://github.com/Textualize/rich/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Textualize/rich/compare/v14.2.0...v14.3.3)

---
updated-dependencies:
- dependency-name: rich
  dependency-version: 14.3.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-06 14:50:01 +00:00
sankalp 8c98b64495 chore: auto-update README [skip ci] 2026-03-06 20:34:33 +05:45
sankalp b9899a774f Merge pull request #7 from S4NKALP/dependabot/pip/main/typer-0.24.1
build(deps): bump typer from 0.21.1 to 0.24.1
2026-03-06 20:34:32 +05:45
sankalp 96c8dd83b1 Merge pull request #6 from S4NKALP/dependabot/pip/main/ruff-0.15.5
build(deps): bump ruff from 0.14.13 to 0.15.5
2026-03-06 20:34:24 +05:45
sankalp 92e9f5ac19 chore: auto-update README [skip ci] 2026-03-06 20:34:08 +05:45
sankalp 75eb20fab3 Merge pull request #4 from S4NKALP/dependabot/pip/main/anthropic-0.84.0
build(deps): bump anthropic from 0.76.0 to 0.84.0
2026-03-06 20:34:00 +05:45
sankalp 5b7bd55ccd chore: auto-update README [skip ci] 2026-03-06 20:33:55 +05:45
sankalp 386e0ee72d Merge pull request #9 from S4NKALP/dependabot/pip/main/openai-2.26.0
build(deps): bump openai from 2.15.0 to 2.26.0
2026-03-06 20:33:48 +05:45
sankalp b31b06eb11 chore: auto-update README [skip ci] 2026-03-06 20:33:03 +05:45
sankalp cf637f34e8 Merge pull request #3 from S4NKALP/dependabot/github_actions/main/actions/checkout-6
build(deps): bump actions/checkout from 4 to 6
2026-03-06 20:32:30 +05:45
sankalp cb99c2fa56 Merge pull request #2 from S4NKALP/dependabot/github_actions/main/astral-sh/setup-uv-7
build(deps): bump astral-sh/setup-uv from 4 to 7
2026-03-06 20:32:19 +05:45
sankalp a67220cfb7 chore: auto-update README [skip ci] 2026-03-06 19:45:40 +05:45
dependabot[bot] f9914fa482 build(deps): bump openai from 2.15.0 to 2.26.0
Bumps [openai](https://github.com/openai/openai-python) from 2.15.0 to 2.26.0.
- [Release notes](https://github.com/openai/openai-python/releases)
- [Changelog](https://github.com/openai/openai-python/blob/main/CHANGELOG.md)
- [Commits](https://github.com/openai/openai-python/compare/v2.15.0...v2.26.0)

---
updated-dependencies:
- dependency-name: openai
  dependency-version: 2.26.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-06 14:00:39 +00:00
dependabot[bot] fab6465c69 build(deps): bump typer from 0.21.1 to 0.24.1
Bumps [typer](https://github.com/fastapi/typer) from 0.21.1 to 0.24.1.
- [Release notes](https://github.com/fastapi/typer/releases)
- [Changelog](https://github.com/fastapi/typer/blob/master/docs/release-notes.md)
- [Commits](https://github.com/fastapi/typer/compare/0.21.1...0.24.1)

---
updated-dependencies:
- dependency-name: typer
  dependency-version: 0.24.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-06 14:00:33 +00:00
dependabot[bot] b94e8502c8 build(deps): bump ruff from 0.14.13 to 0.15.5
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.14.13 to 0.15.5.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.14.13...0.15.5)

---
updated-dependencies:
- dependency-name: ruff
  dependency-version: 0.15.5
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-06 14:00:24 +00:00
dependabot[bot] 4fe5ef0d34 build(deps): bump anthropic from 0.76.0 to 0.84.0
Bumps [anthropic](https://github.com/anthropics/anthropic-sdk-python) from 0.76.0 to 0.84.0.
- [Release notes](https://github.com/anthropics/anthropic-sdk-python/releases)
- [Changelog](https://github.com/anthropics/anthropic-sdk-python/blob/main/CHANGELOG.md)
- [Commits](https://github.com/anthropics/anthropic-sdk-python/compare/v0.76.0...v0.84.0)

---
updated-dependencies:
- dependency-name: anthropic
  dependency-version: 0.84.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-06 14:00:13 +00:00
sankalp 9e9c42ced7 chore: auto-update README [skip ci] 2026-03-06 19:45:12 +05:45
dependabot[bot] 81da7f1846 build(deps): bump actions/checkout from 4 to 6
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-06 14:00:04 +00:00
dependabot[bot] a01bdac09f build(deps): bump astral-sh/setup-uv from 4 to 7
Bumps [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) from 4 to 7.
- [Release notes](https://github.com/astral-sh/setup-uv/releases)
- [Commits](https://github.com/astral-sh/setup-uv/compare/v4...v7)

---
updated-dependencies:
- dependency-name: astral-sh/setup-uv
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-06 13:59:59 +00:00
sankalp b74fb8071a chore(.github/workflows): add dependabot auto-merge workflow
New Features:
- Add dependabot auto-merge workflow (dependabot-auto-merge.yml)
2026-03-06 19:44:27 +05:45
sankalp d135381ad5 chore(.github): add dependabot.yml for weekly dependency updates
Add dependabot configuration to automate weekly updates of GitHub Actions and pip packages.

New Features
- Add Dependabot configuration for GitHub Actions and pip ecosystems with weekly schedule.

Enhancements
-

Bug Fixes
-
2026-03-06 19:44:23 +05:45
sankalp daa6f203c3 refactor(devgen/modules): reformat time.sleep block for readability 2026-03-06 19:43:19 +05:45
sankalp 1b36c3f2d7 feat(devgen/providers): improve error detection for rate‑limiting and quota exceeded responses
Enhancements:
- Extend error message parsing to capture additional throttling indicators such as "RESOURCE_EXHAUSTED", "QUOTA_EXCEEDED", and "THROTTLED".
- Refactor the conditional logic for better readability and maintainability.
- Keep existing retry behavior while increasing resilience to API rate‑limit errors.
2026-03-06 19:43:16 +05:45
sankalp 9554f7af2a feat(devgen/providers): bump default Gemini model to gemini-2.5-flash and add retry handling for quota errors
The update changes the default model, refactors imports, and implements robust retry logic with exponential back‑off and clearer error messages. This improves resilience and clarifies usage.

New Features
- Exponential back‑off retry on 429/quota exhausted responses.
- Configurable max_retries and retry_delay parameters.

Enhancements
- Default model set to gemini-2.5-flash.
- Improved error output with actionable troubleshooting steps.
2026-01-30 22:36:31 +05:45
sankalp 06f25af35f chore(deps): update all python dependencies 2026-01-16 23:30:08 +05:45
sankalp cc94b53d48 chore(root): bump version to 0.2.4
- Updated version in `pyproject.toml` and `setup.py` to `0.2.4`
- Lockfile (`uv.lock`) updated to reflect dependency changes
2025-12-30 14:40:57 +05:45
sankalp b09de402cc feat(devgen): add max_groups config option 2025-12-25 09:36:05 +05:45
sankalp c121a7fa59 feat(devgen/modules): add interactive commit review and smart file grouping
- New Features:
  - Added `check` parameter to enable interactive commit message review
  - Implemented user confirmation/editing/abort workflow using questionary
  - Added smart file grouping with configurable max_groups limit
  - Files are now merged into parent directories when group count exceeds limit

- Enhancements:
  - Improved `group_files()` method with hierarchical merging logic
  - Added graceful KeyboardInterrupt handling during user review
  - Enhanced logging for group merging operations
```
2025-12-25 09:35:33 +05:45
sankalp 525aebadf4 feat(devgen/modules): enhance commit generation with manifest context and custom templates
- New Features:
  - Added `_get_manifest_context()` to extract project metadata from manifest files (pyproject.toml, package.json, etc.) for richer commit message context.
  - Added support for custom commit message templates via `custom_template` config.
  - Added emoji instruction toggle based on global `use_emoji` setting.

- Enhancements:
  - Improved error handling for git push failures with specific hints for "no upstream branch" scenarios.
  - Optimized manifest context extraction by truncating large files to avoid token bloat.
  - Refactored prompt generation to dynamically include manifest context and custom templates.
```
2025-12-25 00:04:54 +05:45
sankalp 1219c1ad81 feat(devgen/templates/commit): add context to commit message template
- New Features:
  - Added `{{ context }}` variable to the commit message template to provide additional context.
- Enhancements:
  - Removed the emoji usage line from the template to simplify the output.
```
2025-12-25 00:04:50 +05:45
sankalp cfa891dcde feat(devgen/cli): add config info command and improve undo commit message formatting 2025-12-25 00:04:48 +05:45
sankalp 286c46d2b5 feat(root): enhance commit generation with context-aware features
- Add shell completion support for bash/zsh/fish via `devgen --install-completion`
- Introduce `devgen commit undo` to revert the last AI-generated commit while preserving staged changes
- Implement context-aware commit generation by analyzing project manifests (`package.json`, `pyproject.toml`, etc.) and lock files for accurate dependency updates
- Add support for custom Jinja2 templates in `~/.devgen.yaml` with `custom_template` configuration
- Update documentation to reflect new features and provide template variable reference

This release significantly improves commit accuracy by leveraging project context and offers greater customization through templates and shell integration.
```
2025-12-25 00:04:44 +05:45
sankalp f2ff3327c3 feat(devgen): add custom template rendering function
- New Features:
  - Introduced `render_custom_template()` function to render Jinja2 templates from strings.
  - Enables dynamic template rendering without requiring file-based templates.
```
2025-12-25 00:04:40 +05:45
sankalp 37e5d94373 feat(devgen): enhance sanitize_ai_commit_message to handle bolding and labels
- Improve header detection by stripping bolding and cleaning labels like "1. Title:", "### Message:", etc.
- Stop processing lines when encountering a sponsor section
```
2025-12-24 23:49:37 +05:45
sankalp 40c9b3ca57 refactor(devgen/templates/commit): update commit message template guidelines
- Simplify and clarify the structure of commit message generation guidelines.
- Replace "Format" with "Guidelines" for better readability.
- Remove redundant section numbering.
- Streamline emoji usage instruction into a single line.
- Clarify that the output should exclude labels like "Title:" or "Format:".

**Enhancements:**
- Improved template structure for better maintainability.
- More concise and direct instructions for users.
2025-12-24 23:49:34 +05:45
sankalp 4d309c313d 1. Title: feat(root): add TODO.md for project roadmap and tasks 2025-12-24 23:44:23 +05:45
sankalp ba747c727c feat(devgen): improve sanitize_ai_commit_message function
The `sanitize_ai_commit_message` function in `devgen/utils.py` has been enhanced for better readability and maintainability. The changes include minor formatting improvements and a more consistent regex pattern definition.

**Enhancements & Refinements:**
- Improved code formatting by removing trailing whitespace and adding a comma after `re.IGNORECASE` in the regex pattern for better readability.
- Enhanced comment clarity by fixing a minor typo in the regex examples section.
```
2025-12-24 23:34:35 +05:45
sankalp 3635887a85 feat(devgen/modules): enhance changelog and license generation with improved file handling
This update introduces improvements to the changelog generator's file handling logic, including proper prepending of new entries and automatic header management. It also refines the license generator's template directory structure and cleans up minor formatting issues in the commit generator.

**New Features**
- Added intelligent changelog prepending logic that preserves existing headers and maintains proper formatting when updating files
- Implemented automatic `# CHANGELOG` header insertion for new changelog files

**Enhancements & Refinements**
- Changed changelog version headers from `#` to `##` for better markdown hierarchy
- Updated license generator to use `templates/licenses` directory instead of `prompt_templates/licenses`
- Removed unnecessary whitespace and improved code formatting in commit generator
- Enhanced changelog file writing logic to handle both new and existing files more robustly
```
2025-12-24 23:34:32 +05:45
sankalp a7507255c7 feat(root): update devgen-cli to version 0.2.3
The dependency `devgen-cli` has been upgraded from version 0.2.2 to 0.2.3 in the project's lockfile.

- **Enhancements & Refinements**
  - Updated `devgen-cli` dependency to version 0.2.3 in `uv.lock`
```
2025-12-24 23:29:47 +05:45
sankalp d373f95388 refactor(devgen): enhance AI commit message sanitization
The `sanitize_ai_commit_message` function has been significantly improved to better handle AI-generated commit messages, including support for bolded headers, more robust header detection, and a fallback mechanism for non-conventional messages.

**Enhancements & Refinements:**
- Added comprehensive docstring explaining the function's purpose and behavior
- Improved regex pattern to match conventional commit headers with optional bolding (e.g., **feat: ...**)
- Added case-insensitive matching for commit types
- Implemented a state-based approach with `found_header` flag for clearer logic
- Added automatic removal of bold markers (**) from headers
- Introduced fallback behavior to return the first non-empty line if no conventional header is found
- Improved code structure with better variable naming and comments
- Added early return for empty input strings

**Bug Fixes:**
- Fixed potential issue where the function could return empty strings for non-conventional messages
- Resolved edge case handling for messages without proper headers
```
2025-12-24 23:29:45 +05:45
sankalp fe7da77ed6 chore(root): Bump version to 0.2.3 2025-12-14 07:58:09 +05:45
sankalp 407458149b fix: remove invalid debug argument from OpenAI and OpenRouter providers
The debug argument is used internally by devgen but is not accepted by the OpenAI API client. This change filters it out from kwargs before the API call to prevent TypeError.
2025-12-14 07:57:16 +05:45
35 changed files with 1917 additions and 1016 deletions

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
# Tell GitHub Linguist to skip *.tpl files entirely so they don't
# inflate language stats (Linguist otherwise maps .tpl -> Go).
*.tpl linguist-vendored
+16
View File
@@ -0,0 +1,16 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
target-branch: "main"
rebase-strategy: "auto"
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "weekly"
target-branch: "main"
rebase-strategy: "auto"
open-pull-requests-limit: 10
+66
View File
@@ -0,0 +1,66 @@
name: CI
on:
push:
branches: [main]
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
lint:
name: Lint & format
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
cache-dependency-glob: "uv.lock"
- name: Set up Python
run: uv python install 3.12
- name: Sync dependencies
run: uv sync --all-extras --dev
- name: ruff check
run: uv run ruff check .
- name: ruff format --check
run: uv run ruff format --check .
smoke:
name: Import & CLI smoke (${{ matrix.python-version }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v6
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
cache-dependency-glob: "uv.lock"
- name: Set up Python ${{ matrix.python-version }}
run: uv python install ${{ matrix.python-version }}
- name: Sync dependencies
run: uv sync --all-extras --dev
- name: Import devgen
run: uv run python -c "import devgen, devgen.ai, devgen.utils, devgen.cli.main; print('imports OK')"
- name: CLI --help
run: uv run devgen --help
- name: CLI commit --help
run: uv run devgen commit --help
+49
View File
@@ -0,0 +1,49 @@
name: CodeQL
on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: "17 6 * * 1"
permissions:
actions: read
contents: read
security-events: write
jobs:
analyze:
name: Analyze (${{ matrix.language }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
language: [python]
steps:
- uses: actions/checkout@v6
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
cache-dependency-glob: "uv.lock"
- name: Set up Python
run: uv python install 3.12
- name: Sync dependencies
run: uv sync --all-extras --dev
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
queries: security-and-quality
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
with:
category: "/language:${{ matrix.language }}"
@@ -0,0 +1,22 @@
name: Dependabot Auto-Merge
on:
pull_request_target:
types: [opened, synchronize, reopened]
permissions:
contents: write
pull-requests: write
jobs:
dependabot:
runs-on: ubuntu-latest
# Check that it's actually dependabot
if: github.event.pull_request.user.login == 'dependabot[bot]'
steps:
- name: Merge PR from Dependabot
uses: fastify/github-action-merge-dependabot@v3
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
target: minor
merge-method: squash
+27
View File
@@ -0,0 +1,27 @@
name: Dependency Review
on:
pull_request:
branches: [main]
permissions:
contents: read
pull-requests: write
jobs:
dependency-review:
name: Audit dependencies
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Dependency Review
uses: actions/dependency-review-action@v4
with:
fail-on-severity: high
fail-on-scopes: changes
license-check: true
# Project is GPL-3.0-or-later; flag licenses that would taint it.
deny-licenses: AGPL-1.0-only, AGPL-1.0-or-later, AGPL-3.0-only, AGPL-3.0-or-later, SSPL-1.0, BUSL-1.1
comment-summary-in-pr: always
vuln-check-non-vulnerable-refs: true
+2 -2
View File
@@ -6,12 +6,12 @@ jobs:
pre-commit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Install uv
uses: astral-sh/setup-uv@v4
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
cache-dependency-glob: "uv.lock"
+93 -83
View File
@@ -2,138 +2,148 @@
<div align="center">
> **Your AI Powerhouse for Git & Project Management.**
> Stop wasting time on repetitive tasks. Automate your commits, changelogs, and project essentials with a single CLI.
>
**AI-Powered Git & Project Workflows in One CLI**
Stop wasting time on repetitive tasks. DevGen automates commits, changelogs, `.gitignore`, and license files using AI — from cloud providers or a local Ollama model.
> PyPI didn't allow the original name, so you'll find it as **devgen-cli** on PyPI
<a href="https://pypi.org/project/devgen-cli"><img src="https://img.shields.io/pypi/v/devgen-cli?color=blue&label=PyPI&logo=pypi&logoColor=white" alt="PyPI"></a>
<img src="https://img.shields.io/badge/Python-3.10%2B-3776AB?logo=python&logoColor=white" alt="Python">
<a href="https://github.com/S4NKALP/DevGen/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-GPL--3.0--or--later-blue.svg" alt="License"></a>
> <a href="https://pypi.org/project/devgen-cli"><img src="https://img.shields.io/pypi/v/devgen-cli?color=blue&label=PyPI&logo=pypi&logoColor=white" alt="PyPI"></a>
> <img src="https://img.shields.io/badge/Python-3.10%2B-3776AB?logo=python&logoColor=white" alt="Python">
> <a href="https://github.com/S4NKALP/DevGen/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-GPL--3.0--or--later-blue.svg" alt="License"></a>
> <a href="https://github.com/S4NKALP/DevGen/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/S4NKALP/DevGen/ci.yml?branch=main&label=CI" alt="CI"></a>
> <a href="https://github.com/S4NKALP/DevGen/actions/workflows/pre-commit.yml"><img src="https://img.shields.io/github/actions/workflow/status/S4NKALP/DevGen/pre-commit.yml?branch=main&label=Pre-Commit" alt="Pre-Commit"></a>
</div>
---
## Why DevGen
**DevGen** isn't just another CLI tool it's your development workflow on steroids. By leveraging state of the art AI models, DevGen turns tedious manual tasks into one click magic. From semantic commit messages to comprehensive release notes, DevGen handles the boring stuff so you can focus on building.
DevGen is a CLI for the parts of development that should be invisible. It writes Conventional Commits from your diff, drafts SemVer changelogs, fetches `.gitignore` and license templates, and routes everything through whichever AI provider you prefer — including a fully local Ollama model if you don't want to send code to the cloud.
## ⚡ Why DevGen?
## Features
**🧠 AI Brain**
Semantic commit messages powered by Gemini, OpenAI, Claude, HuggingFace, and OpenRouter. It reads your diffs and understands your code.
- **AI-Powered Commits** — Conventional Commit messages generated from your staged diff, grouped by directory, with optional emoji prefixes.
- **Multiple Providers** — Google Gemini, OpenAI, Anthropic, OpenRouter, HuggingFace, and local Ollama, all behind one CLI.
- **Smart Caching** — `.gitignore` and license templates are cached for offline use; AI responses are de-duplicated.
- **Conventional Changelogs** — `feat`, `fix`, `refactor`, `perf`, `docs`, `test`, `build`, `ci`, `chore`, `style`, and a `BREAKING CHANGES` section.
- **Project Scaffolding** — Pull `.gitignore` from GitHub's collection and drop in SPDX license files (MIT, Apache-2.0, GPL-3.0, AGPL-3.0, BSD, MPL-2.0, …).
- **Custom Templates** — Override the commit-message prompt via a `.tpl` file in your config.
- **Interactive Setup** — `devgen setup` walks you through provider + API key + options.
- **Undo Support** — `devgen commit undo` rolls back the last AI commit while keeping changes staged.
- **Token-Limit Aware** — When a diff exceeds a model's context window, you get a single actionable error with `--max-groups` and `--max-diff-size` hints.
**🚀 Battle Tested**
Generates **Conventional Commits** and **Semantic Versioning** compliant changelogs that actually make sense.
## Supported AI Providers
**⚡ Lightning Fast**
Smart caching and async operations mean you never wait longer than necessary.
| Provider | Notes |
| ----------------- | ---------------------------------------------------- |
| **Google Gemini** | Default-friendly, generous free tier |
| **OpenAI** | GPT-4o, GPT-4.1, o-series |
| **Anthropic** | Claude 3.5 / 3.7 / 4 |
| **OpenRouter** | Single key, many models |
| **HuggingFace** | Inference API |
| **Ollama** | Fully local, no API key, no data leaves your machine |
**Project Essentials**
Quickly add `.gitignore` and license files to your existing projects. Access cached templates instantly, even without internet.
**🛠️ Zero Friction**
Interactive setup gets you running in seconds.
## 📦 Installation
Get started in seconds.
## Installation
```bash
# Recommended: Install via pipx for an isolated environment
# Recommended: isolated environment
pipx install devgen-cli
# Or use uv for blazing speed
# Or use uv for speed
uv tool install devgen-cli
# Standard pip install
# Or plain pip
pip install devgen-cli
# Shell completion (bash/zsh/fish)
devgen --install-completion
```
## 🚀 Quick Start
Requires **Python 3.10 or newer**.
**1. Initialize & Configure**
Tell DevGen which AI provider to use.
## Quick Start
```bash
# 1. Configure a provider and API key
devgen setup config
```
**2. Stage & Commit**
Stage your files and let AI write the message.
```bash
# 2. Stage your work as usual
git add .
# 3. Let DevGen write the commit message
devgen commit run
```
_Boom. Done._
## 💡 Feature Deep Dive
### 🤖 AI Powered Commits
Stop writing "fix bug" or "wip". DevGen analyzes your staged changes, groups them by component, and generates meaningful, semantic commit messages.
```bash
# Preview what DevGen will generate
# 4. Or preview first, then commit
devgen commit run --dry-run
# Commit and push in one go
devgen commit run --push
```
### 📝 Changelogs & Release Notes
## Commands
Turn your git history into beautiful, readable documentation.
| Command | Description |
| ------------------------------- | ----------------------------------------------------------------------------- |
| `devgen setup config` | Interactive provider / API key / options wizard |
| `devgen commit run` | Stage, generate message, commit (optionally `--push`, `--check`, `--dry-run`) |
| `devgen commit undo` | Undo the last AI commit, keep changes staged |
| `devgen changelog` | Generate a Conventional Commits changelog from recent history |
| `devgen release-notes` | Generate release notes for a version range |
| `devgen gitignore list` | List available GitHub `.gitignore` templates |
| `devgen gitignore add <name> …` | Add `.gitignore` entries to the current project |
| `devgen license list` | List available SPDX licenses |
| `devgen license add <spdx>` | Add a LICENSE file to the current project |
```bash
# Generate a changelog from the last tag
devgen changelog generate
Run `devgen <command> --help` for full options on any subcommand.
# Create release notes for v2.0.0
devgen release notes --version 2.0.0
## Custom Templates
DevGen uses `.tpl` files for its commit prompt. To override the default, set `custom_template` in your config (see `devgen config info`) and point it at a `.tpl` file with `{{ diff_text }}`, `{{ context }}`, `{{ group_name }}`, and the conditional `{% if use_emoji %}` block.
Example minimal template:
```
{{ diff_text }}
Summarize the change above in one Conventional Commit line.
```
### 🛡️ Essential Files
## Architecture
Don't waste time searching for templates. Generate the essential files for your project instantly.
DevGen is built around a small set of composable components:
- **`BaseProvider`** — abstract base for every AI provider; handles API-key validation, token-limit detection, and error wrapping. Adding a new provider is one subclass with a single `_generate()` method.
- **`GitOperator`** — all `subprocess` calls to `git` live here, behind a `GitError` exception.
- **`DiffBuilder` / `FileGrouper` / `ManifestInspector`** — split a staged diff into per-directory groups with compact project context, so the model sees a focused slice instead of a wall of text.
- **`Section` enum** — single source of truth for changelog ordering and emoji, shared by `ChangelogGenerator` and `ReleaseNotesGenerator`.
The CLI entry point is `devgen.cli.main:app` (Typer), exposed as the `devgen` script.
## Development
```bash
# Interactive search for gitignore templates
devgen gitignore generate
# Generate a license interactively
devgen license generate
git clone https://github.com/S4NKALP/DevGen.git
cd DevGen
uv sync --all-extras --dev # install runtime + dev deps
uv run pre-commit run --all-files
uv run devgen --help
```
## ⚙️ Configuration
The project uses `uv` for dependency management, `ruff` for lint + format, and `pyright` for type checking. CI runs all three on Python 3.10 through 3.13 (see `.github/workflows/ci.yml`).
Your settings live in `~/.devgen.yaml`. You can tweak your AI provider, model, and preferences there.
## Contributing
| Option | Description |
| :--------- | :----------------------------------------------------------- |
| `provider` | `gemini`, `openai`, `anthropic`, `huggingface`, `openrouter` |
| `model` | Specific model name (e.g., `gemini-2.5-flash`, `gpt-4o`) |
| `emoji` | Enable/disable gitmojis in commits (`true`/`false`) |
Issues and PRs welcome. Please run `uv run pre-commit run --all-files` before opening a PR so CI stays green.
## 🤝 Contributing
## License
We love contributions! Found a bug? Want a new feature? Open an issue or submit a PR.
GPL-3.0-or-later. See [LICENSE](LICENSE).
## � Acknowledgments
## Acknowledgments
DevGen wouldn't be possible without these amazing open-source projects and AI providers:
Built on the shoulders of:
- **[Typer](https://typer.tiangolo.com/)** & **[Rich](https://rich.readthedocs.io/)** for building the beautiful, intuitive, and responsive CLI interface.
- **[Questionary](https://github.com/tmbo/questionary)** for creating interactive, user-friendly prompts and selection menus.
- **[Jinja2](https://jinja.palletsprojects.com/)** for the powerful template engine used to generate files and messages.
- **[Google Gemini](https://deepmind.google/technologies/gemini/)**, **[OpenAI](https://openai.com/)**, **[Anthropic](https://www.anthropic.com/)**, **[HuggingFace](https://huggingface.co/)**, and **[OpenRouter](https://openrouter.ai/)** for providing the advanced AI models that power the semantic generation features.
## �📝 License
Proudly open source under the [GPL-3.0-or-later](LICENSE) License.
---
- **Typer** & **Rich** — CLI and terminal UI
- **Questionary** — interactive prompts
- **Google Gemini**, **OpenAI**, **Anthropic**, **OpenRouter**, **HuggingFace**, and **Ollama** — AI providers
- **Ruff** — lint and format
<div align="center">
Made with ❤️ by <a href="https://github.com/S4NKALP">Sankalp</a>
+11 -2
View File
@@ -21,8 +21,17 @@ def generate_with_ai(
provider_module = import_module(f"devgen.providers.{provider}")
class_name = "".join([x.capitalize() for x in provider.split("_")]) + "Provider"
provider_class = getattr(provider_module, class_name)
except (ModuleNotFoundError, AttributeError) as e:
raise ImportError(f"Provider `{provider}` not found or invalid: {e}") from e
except ModuleNotFoundError as e:
raise ImportError(
f"Provider `{provider}` is not a built-in module: {e}. "
f"Available providers: gemini, openai, anthropic, huggingface, "
f"openrouter, ollama."
) from e
except AttributeError as e:
raise ImportError(
f"Provider `{provider}` exists but does not expose a "
f"`{class_name}` class. This is a packaging bug."
) from e
provider_instance = provider_class()
return provider_instance.generate(prompt, api_key=api_key, model=model, **kwargs)
+53 -1
View File
@@ -1,5 +1,6 @@
from typing import Annotated
import questionary
import typer
from devgen.modules.commit_generator import run_commit_engine
@@ -50,12 +51,35 @@ def run_commit(
help="Force regeneration of commit messages.",
),
] = False,
check: Annotated[
bool,
typer.Option(
"--check",
help="Review/edit commit message before committing.",
),
] = False,
max_groups: Annotated[
int | None,
typer.Option(
"--max-groups",
help="Maximum number of commit groups. Lower this if you hit token-limit errors.",
),
] = None,
max_diff_size: Annotated[
int | None,
typer.Option(
"--max-diff-size",
help="Maximum diff size in chars per group (default 8000). "
"Lower this if you hit token-limit errors.",
),
] = None,
) -> None:
log_file = get_main_log_path()
logger = configure_logger("devgen.cli.commit", log_file, console=debug)
logger.info(f"Log file: {log_file}")
logger.info(
f"Options: dry_run={dry_run}, push={push}, debug={debug}, force={force_rebuild}"
f"Options: dry_run={dry_run}, push={push}, debug={debug}, force={force_rebuild}, "
f"check={check}, max_groups={max_groups}, max_diff_size={max_diff_size}"
)
run_commit_engine(
@@ -63,7 +87,10 @@ def run_commit(
push=push,
debug=debug,
force_rebuild=force_rebuild,
check=check,
logger=logger,
max_groups=max_groups,
max_diff_size=max_diff_size,
)
@@ -94,3 +121,28 @@ def validate() -> None:
typer.echo(f"- {f}")
else:
typer.secho("[i] No staged files.", fg=typer.colors.RED)
@app.command("undo")
def undo_commit() -> None:
"""Undoes the last commit but keeps changes staged."""
from devgen.utils import run_git_command
try:
# Check if there's at least one commit
run_git_command(["git", "rev-parse", "HEAD"])
except Exception:
typer.secho("No commits found to undo.", fg=typer.colors.RED)
return
if questionary.confirm(
"Are you sure you want to undo the last commit? (Changes will remain staged)",
default=False,
).ask():
try:
run_git_command(["git", "reset", "--soft", "HEAD~1"])
typer.secho(
"Last commit undone. Changes are still staged.", fg=typer.colors.GREEN
)
except Exception as e:
typer.secho(f"Failed to undo commit: {e}", fg=typer.colors.RED)
+55 -2
View File
@@ -62,7 +62,14 @@ def edit_config(
if key == "provider":
value = questionary.select(
"Select AI Provider:",
choices=["gemini", "openai", "huggingface", "openrouter", "anthropic"],
choices=[
"gemini",
"openai",
"huggingface",
"openrouter",
"anthropic",
"ollama",
],
default=str(current_val) if current_val else "gemini",
style=style,
).ask()
@@ -117,7 +124,14 @@ def set_config() -> None:
# Questions
provider = questionary.select(
"Select AI Provider:",
choices=["gemini", "openai", "huggingface", "openrouter", "anthropic"],
choices=[
"gemini",
"openai",
"huggingface",
"openrouter",
"anthropic",
"ollama",
],
default=config.get("provider", "gemini"),
style=style,
).ask()
@@ -151,12 +165,24 @@ def set_config() -> None:
raise typer.Exit(code=130)
emoji = emoji_choice == "Yes"
ollama_host = config.get("ollama_host", "http://localhost:11434")
if provider == "ollama":
ollama_host_input = questionary.text(
"Ollama server URL:",
default=ollama_host,
style=style,
).ask()
if ollama_host_input is None:
raise typer.Exit(code=130)
ollama_host = ollama_host_input.strip() or ollama_host
# Save Config
new_config = {
"provider": provider,
"model": model,
"api_key": api_key,
"emoji": emoji,
"ollama_host": ollama_host,
}
# Merge with existing config to preserve other keys?
@@ -167,3 +193,30 @@ def set_config() -> None:
_save_config(config)
typer.secho("\nConfiguration saved.", fg=typer.colors.GREEN)
typer.echo(yaml.dump(new_config, default_flow_style=False))
@app.command("info")
def config_info() -> None:
"""Show information about configuration options and templates."""
from rich.console import Console
from rich.table import Table
console = Console()
table = Table(title="Custom Template Variables", box=None)
table.add_column("Variable", style="cyan")
table.add_column("Description", style="white")
table.add_row("{{ group_name }}", "The folder name being committed (or 'root').")
table.add_row("{{ diff_text }}", "The git diff of the changes.")
table.add_row("{{ context }}", "Project context (manifest files content).")
console.print(table)
console.print(
"\n[bold]Tip:[/bold] If you hardcode emojis in your template, the AI will likely include them regardless of the 'emoji' setting.",
style="yellow",
)
console.print("\n[bold]Example Template:[/bold]")
console.print(
"custom_template: |\n [type]: [desc]\n \n Diff: {{ diff_text }}\n",
style="dim",
)
+12 -4
View File
@@ -15,7 +15,7 @@ from devgen.cli.setup import app as setup_app
app = typer.Typer(
name="devgen",
help="devgen-py: AI-Powered Git Commit & Release Automation.",
add_completion=False,
add_completion=True,
no_args_is_help=True,
rich_markup_mode="markdown",
)
@@ -48,13 +48,21 @@ def _version_callback(value: bool) -> None:
typer.echo(f"devgen version: {version}")
else:
typer.secho(
"Error: Version not found in pyproject.toml",
"Could not determine version: no `project.version` or "
"`tool.poetry.version` found in pyproject.toml.",
fg=typer.colors.RED,
err=True,
)
except (FileNotFoundError, KeyError, Exception) as e:
except FileNotFoundError:
typer.secho(
f"Error: Could not determine version: {e}",
f"pyproject.toml not found at {pyproject_path}. "
"The installation may be corrupt.",
fg=typer.colors.RED,
err=True,
)
except toml.TomlDecodeError as e:
typer.secho(
f"pyproject.toml is not valid TOML: {e}",
fg=typer.colors.RED,
err=True,
)
+23 -4
View File
@@ -33,7 +33,14 @@ def setup_config() -> None:
# Questions
provider = questionary.select(
"Select AI Provider:",
choices=["gemini", "openai", "huggingface", "openrouter", "anthropic"],
choices=[
"gemini",
"openai",
"huggingface",
"openrouter",
"anthropic",
"ollama",
],
default=current_config.get("provider", "gemini"),
style=style,
).ask()
@@ -67,13 +74,25 @@ def setup_config() -> None:
raise typer.Exit(code=130)
emoji = emoji_choice == "Yes"
# Save Config
new_config = {
ollama_host = current_config.get("ollama_host", "http://localhost:11434")
if provider == "ollama":
ollama_host_input = questionary.text(
"Ollama server URL:",
default=ollama_host,
style=style,
).ask()
if ollama_host_input is None:
raise typer.Exit(code=130)
ollama_host = ollama_host_input.strip() or ollama_host
# Save Config — merge with existing to preserve extra keys
new_config = {**current_config, **{
"provider": provider,
"model": model,
"api_key": api_key,
"emoji": emoji,
}
"ollama_host": ollama_host,
}}
try:
with config_path.open("w", encoding="utf-8") as f:
+120 -143
View File
@@ -5,174 +5,151 @@ from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional
from devgen.modules.changelog_sections import (
DEFAULT_STYLES,
Section,
section_for_type,
)
from devgen.utils import configure_logger, run_git_command
class ChangelogGenerator:
"""Generates a changelog from git history using Semantic Release style."""
#: Conventional commit header: ``type(scope)!: subject``
_CC_PATTERN = re.compile(r"^(\w+)(?:\(([^)]+)\))?(!?):\s+(.*)")
def __init__(self, logger=None):
self.logger = logger or configure_logger("devgen.changelog")
# ------------------------------------------------------------------ commits
def get_commits(self, from_ref: str = "", to_ref: str = "HEAD") -> List[str]:
"""Fetches commit messages in the specified range."""
range_spec = f"{from_ref}..{to_ref}" if from_ref else to_ref
# Format: hash|author|date|subject|body
fmt = "%H|%an|%ad|%s|%b"
cmd = ["git", "log", f"--format={fmt}", "--date=short", range_spec]
if not from_ref:
# If no start ref, try to find the last tag
try:
last_tag = run_git_command(["git", "describe", "--tags", "--abbrev=0"])
cmd = [
"git",
"log",
f"--format={fmt}",
"--date=short",
f"{last_tag}..HEAD",
]
self.logger.info(f"Generating changelog from last tag: {last_tag}")
except (RuntimeError, subprocess.CalledProcessError):
self.logger.info("No tags found, generating for all commits.")
cmd = ["git", "log", f"--format={fmt}", "--date=short"]
"""Fetch commit lines in the requested range."""
if from_ref:
range_spec = f"{from_ref}..{to_ref}"
cmd = self._log_cmd(range_spec)
else:
cmd = self._resolve_range(to_ref)
try:
return run_git_command(cmd).split("\n")
except subprocess.CalledProcessError as e:
self.logger.error(f"Git command failed: {e}")
raise RuntimeError(f"Git command failed: {e}")
raise RuntimeError(f"Git command failed: {e}") from e
def parse_commits(self, raw_commits: List[str]) -> Dict[str, List[Dict]]:
"""Parses raw commit strings into structured data."""
groups = defaultdict(list)
# Conventional Commit Regex: type(scope)!: subject
cc_pattern = re.compile(r"^(\w+)(?:\(([^)]+)\))?(!?):\s+(.*)")
for line in raw_commits:
if not line.strip():
continue
parts = line.split("|", 4)
if len(parts) < 4:
continue
commit_hash, author, date, subject, body = (
parts[0],
parts[1],
parts[2],
parts[3],
parts[4] if len(parts) > 4 else "",
)
match = cc_pattern.match(subject)
if match:
c_type, c_scope, breaking, c_subject = match.groups()
entry = {
"hash": commit_hash,
"author": author,
"date": date,
"scope": c_scope,
"subject": c_subject,
"body": body,
"breaking": bool(breaking),
}
if breaking:
groups["BREAKING CHANGES"].append(entry)
if c_type in ["feat", "feature"]:
groups["Features"].append(entry)
elif c_type in ["fix", "bug"]:
groups["Bug Fixes"].append(entry)
elif c_type in ["docs"]:
groups["Documentation"].append(entry)
elif c_type in [
"style",
"refactor",
"perf",
"test",
"build",
"ci",
"chore",
]:
groups["Other Changes"].append(entry)
else:
groups["Other Changes"].append(entry)
else:
# Non-conventional commits
groups["Other Changes"].append(
{
"hash": commit_hash,
"author": author,
"date": date,
"scope": None,
"subject": subject,
"body": body,
"breaking": False,
}
)
return groups
def generate_markdown(
self, groups: Dict[str, List[Dict]], version: str = "Unreleased"
) -> str:
"""Generates markdown changelog from parsed commits."""
date_str = datetime.now().strftime("%Y-%m-%d")
md = [f"# {version} ({date_str})\n"]
# Order: Breaking, Features, Fixes, Docs, Others
order = [
"BREAKING CHANGES",
"Features",
"Bug Fixes",
"Documentation",
"Other Changes",
def _log_cmd(self, range_spec: str) -> List[str]:
# Format: hash|author|date|subject|body
return [
"git",
"log",
"--format=%H|%an|%ad|%s|%b",
"--date=short",
range_spec,
]
emoji_map = {
"BREAKING CHANGES": "💥 BREAKING CHANGES",
"Features": "✨ Features",
"Bug Fixes": "🐛 Bug Fixes",
"Documentation": "📚 Documentation",
"Other Changes": "🔨 Other Changes",
def _resolve_range(self, to_ref: str) -> List[str]:
try:
last_tag = run_git_command(["git", "describe", "--tags", "--abbrev=0"])
self.logger.info(f"Generating changelog from last tag: {last_tag}")
return self._log_cmd(f"{last_tag}..HEAD")
except (RuntimeError, subprocess.CalledProcessError):
self.logger.info("No tags found, generating for all commits.")
return ["git", "log", "--format=%H|%an|%ad|%s|%b", "--date=short"]
# ----------------------------------------------------------------- parsing
def parse_commits(self, raw_commits: List[str]) -> Dict[Section, List[Dict]]:
"""Group raw commit lines into :class:`Section` buckets."""
groups: Dict[Section, List[Dict]] = defaultdict(list)
for line in raw_commits:
entry = self._parse_line(line)
if entry is None:
continue
section = section_for_type(entry["type"])
entry["section"] = section
if entry["breaking"]:
groups[Section.BREAKING].append(entry)
groups[section].append(entry)
return dict(groups)
def _parse_line(self, line: str) -> Dict | None:
if not line.strip():
return None
parts = line.split("|", 4)
if len(parts) < 4:
return None
commit_hash, author, date, subject = parts[:4]
body = parts[4] if len(parts) > 4 else ""
match = self._CC_PATTERN.match(subject)
if match:
c_type, c_scope, breaking, c_subject = match.groups()
return {
"type": c_type,
"hash": commit_hash,
"author": author,
"date": date,
"scope": c_scope,
"subject": c_subject,
"body": body,
"breaking": bool(breaking),
}
return {
"type": "",
"hash": commit_hash,
"author": author,
"date": date,
"scope": None,
"subject": subject,
"body": body,
"breaking": False,
}
for section in order:
commits = groups.get(section)
if commits:
header = emoji_map.get(section, section)
md.append(f"## {header}\n")
for c in commits:
scope = f"**{c['scope']}**: " if c["scope"] else ""
md.append(f"- {scope}{c['subject']} ({c['hash'][:7]})")
md.append("")
# ------------------------------------------------------------------ output
return "\n".join(md)
def generate_markdown(
self,
groups: Dict[Section, List[Dict]],
version: str = "Unreleased",
) -> str:
"""Render groups as a markdown changelog block."""
date_str = datetime.now().strftime("%Y-%m-%d")
out = [f"## {version} ({date_str})\n"]
for section in Section.ordered():
commits = groups.get(section)
if not commits:
continue
style = DEFAULT_STYLES[section]
out.append(f"## {style.emoji} {style.heading}\n")
for c in commits:
scope = f"**{c['scope']}**: " if c.get("scope") else ""
out.append(f"- {scope}{c['subject']} ({c['hash'][:7]})")
out.append("")
return "\n".join(out)
def run(self, output_file: Optional[str] = "CHANGELOG.md", from_ref: str = ""):
"""Main execution method."""
"""Fetch → parse → write (or print) the changelog."""
raw_commits = self.get_commits(from_ref)
if not raw_commits or not raw_commits[0]:
self.logger.warning("No commits found.")
return
groups = self.parse_commits(raw_commits)
md_content = self.generate_markdown(groups)
parsed = self.parse_commits(raw_commits)
md_content = self.generate_markdown(parsed)
if output_file:
path = Path(output_file)
# If file exists, prepend? For now, just overwrite or append logic could be complex.
# Let's implement prepend logic if file exists.
if path.exists():
old_content = path.read_text(encoding="utf-8")
new_content = old_content + "\n\n" + md_content
path.write_text(new_content, encoding="utf-8")
else:
path.write_text(md_content, encoding="utf-8")
self.logger.info(f"Changelog written to {output_file}")
print(f" Changelog updated: {output_file}")
else:
if not output_file:
print(md_content)
return
path = Path(output_file)
if path.exists():
old = path.read_text(encoding="utf-8")
if old.strip().startswith("# CHANGELOG"):
header, _, rest = old.partition("\n")
new_content = f"{header}\n\n{md_content}\n{rest}"
else:
new_content = f"# CHANGELOG\n\n{md_content}\n\n{old}"
else:
new_content = f"# CHANGELOG\n\n{md_content}"
path.write_text(new_content, encoding="utf-8")
self.logger.info(f"Changelog written to {output_file}")
print(f" Changelog updated: {output_file}")
+79
View File
@@ -0,0 +1,79 @@
"""Shared section definitions for changelog and release-note generators.
Both generators render the same set of Conventional Commit groups; this
module is the single source of truth for the section name, the emoji,
and the display order.
"""
from dataclasses import dataclass
from enum import Enum
from typing import Mapping
class Section(str, Enum):
"""Sections in the order they should be rendered."""
BREAKING = "BREAKING CHANGES"
FEATURES = "Features"
BUG_FIXES = "Bug Fixes"
PERFORMANCE = "Performance"
DOCUMENTATION = "Documentation"
REFACTOR = "Refactor"
TESTS = "Tests"
STYLE = "Style"
CHORE = "Chore"
OTHER = "Other Changes"
@classmethod
def ordered(cls) -> tuple["Section", ...]:
return tuple(cls)
@dataclass(frozen=True)
class SectionStyle:
"""Visual style for a section (emoji + name for the heading)."""
emoji: str
heading: str
# Default style for each section. Generators can override per output
# format (changelog vs. release notes).
DEFAULT_STYLES: Mapping[Section, SectionStyle] = {
Section.BREAKING: SectionStyle("💥", "BREAKING CHANGES"),
Section.FEATURES: SectionStyle("✨", "Features"),
Section.BUG_FIXES: SectionStyle("🐛", "Bug Fixes"),
Section.PERFORMANCE: SectionStyle("⚡", "Performance"),
Section.DOCUMENTATION: SectionStyle("📚", "Documentation"),
Section.REFACTOR: SectionStyle("♻️", "Refactor"),
Section.TESTS: SectionStyle("✅", "Tests"),
Section.STYLE: SectionStyle("💄", "Style"),
Section.CHORE: SectionStyle("🔧", "Chore"),
Section.OTHER: SectionStyle("🧹", "Other Changes"),
}
# Conventional commit type → Section mapping. Long-form aliases ("feature",
# "bug") are also accepted.
TYPE_TO_SECTION: Mapping[str, Section] = {
"feat": Section.FEATURES,
"feature": Section.FEATURES,
"fix": Section.BUG_FIXES,
"bug": Section.BUG_FIXES,
"perf": Section.PERFORMANCE,
"docs": Section.DOCUMENTATION,
"refactor": Section.REFACTOR,
"test": Section.TESTS,
"style": Section.STYLE,
"build": Section.CHORE,
"ci": Section.CHORE,
"chore": Section.CHORE,
}
def section_for_type(commit_type: str) -> Section:
"""Return the Section for a Conventional Commit ``type``.
Unknown types fall back to :attr:`Section.OTHER`.
"""
return TYPE_TO_SECTION.get(commit_type.lower(), Section.OTHER)
+312 -227
View File
@@ -1,35 +1,147 @@
import subprocess
from collections import defaultdict
"""Orchestrates the AI-powered commit flow.
The :class:`CommitEngine` is a thin coordinator. The real work lives in:
* :class:`devgen.modules.git_ops.GitOperator` — all git side-effects
* :class:`devgen.modules.diff_builder.FileGrouper` — files → commit groups
* :class:`devgen.modules.diff_builder.DiffBuilder` — staged diff + truncation
* :class:`devgen.modules.diff_builder.ManifestInspector` — project context
* :class:`devgen.ai.generate_with_ai` — provider-agnostic AI call
"""
import sys
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List
from typing import Any, Dict, List, Optional
import questionary
from rich.console import Console
from rich.markdown import Markdown
from rich.panel import Panel
from rich.theme import Theme
from devgen.ai import generate_with_ai
from devgen.modules.diff_builder import (
DEFAULT_MAX_DIFF_SIZE,
DiffBuilder,
FileGrouper,
ManifestInspector,
)
from devgen.modules.git_ops import GitOperator
from devgen.utils import (
configure_logger,
extract_commit_messages,
get_commit_dry_run_path,
is_file_recent,
load_template_env,
run_git_command,
render_custom_template,
sanitize_ai_commit_message,
)
from rich.console import Console
from rich.panel import Panel
from rich.markdown import Markdown
from rich.theme import Theme
class CommitEngineError(Exception):
"""Exception raised for errors in the commit engine."""
"""Raised when the commit flow cannot proceed."""
pass
class CacheManager:
"""Manages the dry-run cache file at ``~/.cache/devgen/commit_dry_run.md``."""
def __init__(self, path: Path, max_age_minutes: int = 120) -> None:
self.path = path
self.max_age_minutes = max_age_minutes
def init_dry_run(self) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
ts = datetime.now().astimezone().strftime("%Y-%m-%d %H:%M:%S (%Z)")
with self.path.open("w", encoding="utf-8") as f:
f.write(f"# Dry Run: Commit Messages\n_Generated: {ts}_\n\n")
def load(self, *, dry_run: bool, force_rebuild: bool) -> Dict[str, str]:
if dry_run:
self.init_dry_run()
return {}
if not force_rebuild and is_file_recent(self.path, self.max_age_minutes):
return extract_commit_messages(self.path)
return {}
def append(self, group: str, message: str) -> None:
with self.path.open("a", encoding="utf-8") as f:
f.write(f"## Group: `{group}`\n\n```md\n{message}\n```\n\n---\n\n")
class CommitMessageBuilder:
"""Renders the prompt and calls the AI provider."""
def __init__(
self,
template_env,
config: Dict[str, Any],
provider: str,
model: str,
debug: bool = False,
) -> None:
self.template_env = template_env
self.config = config
self.provider = provider
self.model = model
self.debug = debug
def build_message(
self,
group: str,
diff: str,
manifest_context: str,
extra_kwargs: Dict[str, Any],
) -> str:
custom_template = self.config.get("custom_template")
use_emoji = self.config.get("emoji", True)
ollama_host = self.config.get("ollama_host")
if custom_template:
prompt = render_custom_template(
custom_template,
group_name=group,
diff_text=diff,
use_emoji=use_emoji,
context=manifest_context,
)
else:
prompt = (
self.template_env.get_template("commit_message.tpl")
.render(
group_name=group,
diff_text=diff,
use_emoji=use_emoji,
context=manifest_context,
)
.strip()
)
provider = (
extra_kwargs.get("provider") or self.config.get("provider") or self.provider
)
model = extra_kwargs.get("model") or self.config.get("model") or self.model
api_key = extra_kwargs.get("api_key") or self.config.get("api_key")
call_kwargs = dict(extra_kwargs)
if ollama_host and provider == "ollama":
call_kwargs.setdefault("ollama_host", ollama_host)
return generate_with_ai(
prompt,
provider=provider,
model=model,
api_key=api_key,
debug=self.debug,
**call_kwargs,
)
class CommitEngine:
"""
Engine for generating AI-powered commit messages.
Handles detection of changes, grouping, AI generation, and git operations.
"""Coordinates the AI-powered commit flow.
The constructor signature is stable (used by ``cli/commit.py``) but
most logic now lives in composed objects. The public ``execute()``
method remains the single entry point.
"""
def __init__(
@@ -38,15 +150,19 @@ class CommitEngine:
push: bool = False,
debug: bool = False,
force_rebuild: bool = False,
check: bool = False,
provider: str = "gemini",
model: str = "gemini-2.5-flash",
logger: Any | None = None,
**kwargs,
):
max_groups: int | None = None,
max_diff_size: int | None = None,
**kwargs: Any,
) -> None:
self.dry_run = dry_run
self.push = push
self.debug = debug
self.force_rebuild = force_rebuild
self.check = check
self.provider = provider
self.model = model
self.logger = logger or configure_logger(
@@ -55,254 +171,223 @@ class CommitEngine:
console=debug,
)
self.kwargs = kwargs
self.dry_run_path = get_commit_dry_run_path()
self.template_env = load_template_env("commit")
# CLI flags win over config; config wins over default.
from devgen.utils import load_config
self.config = load_config()
self.console = Console(
theme=Theme(
{"info": "dim cyan", "warning": "magenta", "danger": "bold red"}
)
)
# Load config from ~/.devgen.yaml
from devgen.utils import load_config
self.config = load_config()
def detect_changes(self) -> List[str]:
"""Detects changed, deleted, or untracked files."""
try:
out = run_git_command(
[
"git",
"ls-files",
"--deleted",
"--modified",
"--others",
"--exclude-standard",
]
)
return [f.strip() for f in out.split("\n") if f.strip()]
except subprocess.CalledProcessError as e:
msg = f"Git command failed: {' '.join(e.cmd)}\nError: {e.stderr.strip()}"
self.logger.error(msg)
raise CommitEngineError(msg) from e
def group_files(self, files: List[str]) -> Dict[str, List[str]]:
"""Groups files by their parent directory."""
groups = defaultdict(list)
for f in files:
parent = str(Path(f).parent)
key = "root" if parent == "." else parent
groups[key].append(f)
return groups
def generate_diff(self, files: List[str]) -> str:
"""Generates diff for specific files."""
try:
return run_git_command(
["git", "--no-pager", "diff", "--staged", "--", *files]
)
except subprocess.CalledProcessError as e:
msg = f"Git command failed: {' '.join(e.cmd)}\nError: {e.stderr.strip()}"
self.logger.error(msg)
raise CommitEngineError(msg) from e
def _init_dry_run(self):
"""Initializes the dry-run file."""
self.dry_run_path.parent.mkdir(parents=True, exist_ok=True)
with self.dry_run_path.open("w", encoding="utf-8") as f:
ts = datetime.now().astimezone().strftime("%Y-%m-%d %H:%M:%S (%Z)")
f.write(f"# Dry Run: Commit Messages\n_Generated: {ts}_\n\n")
def _log_dry_run(self, group: str, msg: str):
"""Appends a dry-run entry and prints to console."""
self.console.print(
Panel(
Markdown(msg),
title=f"Dry Run: {group}",
border_style="yellow",
expand=False,
self.git = GitOperator(logger=self.logger)
self.grouper = FileGrouper(
max_groups=(
max_groups
if max_groups is not None
else self.config.get("max_groups", 5)
)
)
with self.dry_run_path.open("a", encoding="utf-8") as f:
f.write(f"## Group: `{group}`\n\n```md\n{msg}\n```\n\n---\n\n")
def stage_files(self, files: List[str]):
"""Stages files in git."""
if not files:
return
self.logger.info(f"Staging: {files}")
self.console.print(f"[info]Staging {len(files)} files...[/info]")
try:
run_git_command(["git", "add", *files])
except subprocess.CalledProcessError as e:
msg = f"Git command failed: {' '.join(e.cmd)}\nError: {e.stderr.strip()}"
self.logger.error(msg)
raise CommitEngineError(msg) from e
def commit_staged(self, msg: str):
"""Commits staged changes."""
self.logger.info(f"Committing:\n{msg}")
self.console.print(
Panel(Markdown(msg), title="Commit Message", border_style="green")
self.diff_builder = DiffBuilder(
max_size=(
max_diff_size
if max_diff_size is not None
else self.config.get("max_diff_size", DEFAULT_MAX_DIFF_SIZE)
),
logger=self.logger,
)
try:
run_git_command(["git", "commit", "-m", msg])
except subprocess.CalledProcessError as e:
msg = f"Git command failed: {' '.join(e.cmd)}\nError: {e.stderr.strip()}"
self.logger.error(msg)
raise CommitEngineError(msg) from e
def push_commits(self):
"""Pushes commits to remote."""
self.logger.info("Pushing to remote...")
with self.console.status("[bold green]Pushing to remote...[/bold green]"):
try:
run_git_command(["git", "push"])
except subprocess.CalledProcessError as e:
msg = (
f"Git command failed: {' '.join(e.cmd)}\nError: {e.stderr.strip()}"
)
self.logger.error(msg)
raise CommitEngineError(msg) from e
self.console.print("[bold green]Push successful.[/bold green]")
def generate_message(self, group: str, diff: str, cache: Dict[str, str]) -> str:
"""Generates a commit message using AI or cache."""
if not self.force_rebuild and group in cache:
self.logger.info(f"Using cached message for {group}")
return cache[group]
# Get settings from config or kwargs
provider = (
self.kwargs.get("provider") or self.config.get("provider") or self.provider
self.template_env = load_template_env("commit")
self.cache = CacheManager(get_commit_dry_run_path())
self.message_builder = CommitMessageBuilder(
self.template_env,
self.config,
provider=self.provider,
model=self.model,
debug=self.debug,
)
model = self.kwargs.get("model") or self.config.get("model") or self.model
api_key = self.kwargs.get("api_key") or self.config.get("api_key")
use_emoji = self.config.get("emoji", True)
template = self.template_env.get_template("commit_message.j2")
prompt = template.render(group_name=group, diff_text=diff, use_emoji=use_emoji)
# ------------------------------------------------------------------ public
with self.console.status("[bold blue]Generating commit message...[/bold blue]"):
raw = generate_with_ai(
prompt,
provider=provider,
model=model,
api_key=api_key,
debug=self.debug,
**self.kwargs,
)
return sanitize_ai_commit_message(raw)
def is_ahead_of_remote(self) -> bool:
"""Checks if local branch has unpushed commits."""
try:
run_git_command(["git", "fetch", "origin"])
count = run_git_command(
["git", "rev-list", "--count", "@{u}..HEAD"], check=False
)
if count and int(count) > 0:
return True
except (subprocess.CalledProcessError, CommitEngineError):
# Maybe no upstream
try:
return bool(run_git_command(["git", "rev-parse", "HEAD"], check=False))
except subprocess.CalledProcessError:
return False
return False
def load_cache(self) -> Dict[str, str]:
"""Loads dry-run cache."""
if self.dry_run:
self._init_dry_run()
return {}
if not self.force_rebuild and is_file_recent(self.dry_run_path):
self.logger.info(f"Loading cache from {self.dry_run_path}")
return extract_commit_messages(self.dry_run_path)
return {}
def process_group(
self, group: str, files: List[str], cache: Dict[str, str]
) -> bool:
"""Processes a single file group."""
self.stage_files(files)
diff = self.generate_diff(files)
if not diff.strip():
self.logger.info(f"Skipping empty diff for {group}")
try:
run_git_command(["git", "reset", "HEAD", "--", *files])
except subprocess.CalledProcessError:
pass # Ignore reset errors
return True
msg = self.generate_message(group, diff, cache)
if not msg:
self.logger.error(f"Empty message for {group}")
return False
if self.dry_run:
self._log_dry_run(group, msg)
try:
run_git_command(["git", "reset", "HEAD", "--", *files])
except subprocess.CalledProcessError:
pass
else:
self.commit_staged(msg)
return True
def execute(self):
"""Main execution method."""
files = self.detect_changes()
ahead = self.is_ahead_of_remote()
def execute(self) -> None:
files = self.git.detect_changed()
# Only consult the remote state if the user wants to push —
# avoids a network round-trip on every run.
ahead = self.git.is_ahead_of_remote(fetch=self.push) if self.push else False
if not files and not ahead:
self.logger.info("Nothing to commit or push.")
return
failed = []
failed: list[str] = []
if files:
groups = self.group_files(files)
cache = self.load_cache()
for group, group_files in groups.items():
try:
if not self.process_group(group, group_files, cache):
failed.append(group)
except KeyboardInterrupt:
self.logger.warning("\nOperation interrupted by user.")
raise
else:
self.logger.info("No changes to commit, checking push...")
failed = self._process_groups(files)
if self.push and not self.dry_run:
if not failed:
self.push_commits()
self.git.push()
self.console.print("[bold green]Push successful.[/bold green]")
else:
self.logger.error("Push aborted due to failed commits.")
if self.dry_run:
self.console.print(
f"[bold green]Dry run done.[/bold green] See {self.dry_run_path}"
f"[bold green]Dry run done.[/bold green] See {self.cache.path}"
)
else:
self.console.print("[bold green]Done.[/bold green]")
if failed:
self.console.print(f"[bold red]Failed groups: {failed}[/bold red]")
# ------------------------------------------------------------------ groups
def run_commit_engine(**kwargs):
"""Entry point for the commit engine."""
def _process_groups(self, files: List[str]) -> list[str]:
groups = self.grouper.group(files)
cache_map = self.cache.load(
dry_run=self.dry_run, force_rebuild=self.force_rebuild
)
failed: list[str] = []
for i, (group, group_files) in enumerate(groups.items()):
if not self._process_group(group, group_files, cache_map):
failed.append(group)
if len(groups) > 1 and i < len(groups) - 1:
import time
time.sleep(15) # Conservative 4 RPM guard for free-tier
return failed
def _process_group(
self,
group: str,
files: List[str],
cache: Dict[str, str],
) -> bool:
self.git.stage(files)
diff = self.diff_builder.build(files)
if not diff.strip():
self.logger.info(f"Skipping empty diff for {group}")
self.git.reset(files)
return True
if not self.force_rebuild and group in cache:
self.logger.info(f"Using cached message for {group}")
return self._apply_message(group, cache[group], files)
try:
manifest_context = ManifestInspector.summary()
if manifest_context:
self.logger.info("Including manifest context in prompt")
with self.console.status(
"[bold blue]Generating commit message...[/bold blue]"
):
raw = self.message_builder.build_message(
group=group,
diff=diff,
manifest_context=manifest_context,
extra_kwargs=self.kwargs,
)
message = sanitize_ai_commit_message(raw)
except Exception as e:
self.logger.error(
f"Failed to generate message for group {group!r}: {e}",
exc_info=True,
)
self.git.reset(files)
return False
if not message:
self.logger.error(f"Empty message for {group}")
self.git.reset(files)
return False
return self._apply_message(group, message, files)
def _apply_message(
self,
group: str,
message: str,
files: List[str],
) -> bool:
if self.dry_run:
self.console.print(
Panel(
Markdown(message),
title=f"Dry Run: {group}",
border_style="yellow",
expand=False,
)
)
self.cache.append(group, message)
self.git.reset(files)
return True
if self.check:
message = self._review_with_user(group, message, files)
if message is None:
return False
try:
self.console.print(
Panel(Markdown(message), title="Commit Message", border_style="green")
)
self.git.commit(message)
except Exception as e:
self.logger.error(f"Failed to process group {group}: {e}", exc_info=True)
self.git.reset(files)
return False
return True
def _review_with_user(
self,
group: str,
message: str,
files: List[str],
) -> Optional[str]:
"""Present the message; return the final message, or None to abort."""
self.console.print(
Panel(
Markdown(message),
title=f"Proposed Commit Message [group: {group}]",
border_style="cyan",
)
)
choice = questionary.select(
"How would you like to proceed?",
choices=["Confirm", "Edit", "Abort"],
default="Confirm",
).ask()
if not choice or choice == "Abort":
self.logger.info(f"Commit aborted by user at group {group}")
self.git.reset(files)
return None
if choice == "Edit":
edited = questionary.text(
"Edit commit message:",
multiline=True,
default=message,
).ask()
if not edited:
self.logger.info(f"Empty edit, commit cancelled for {group}")
self.git.reset(files)
return None
return edited
return message
def run_commit_engine(**kwargs: Any) -> None:
"""Entry point used by the CLI."""
debug = kwargs.get("debug", False)
logger = configure_logger("devgen.commit", console=debug)
logger = configure_logger("devgen.cli.commit", console=debug)
try:
engine = CommitEngine(**kwargs)
engine.execute()
CommitEngine(**kwargs).execute()
except KeyboardInterrupt:
logger.warning("Interrupted by user. Exiting...")
sys.exit(0)
except Exception as e:
logger.error(f"Commit engine failed: {e}", exc_info=True)
__all__ = ["run_commit_engine"]
__all__ = ["CommitEngine", "CommitEngineError", "run_commit_engine"]
+224
View File
@@ -0,0 +1,224 @@
"""Builds the prompt inputs for the commit engine.
Three small composable classes:
* :class:`FileGrouper` — groups a flat file list into per-commit folders
* :class:`DiffBuilder` — produces a (possibly truncated) staged diff
* :class:`ManifestInspector` — extracts a one-line-per-file project summary
"""
import json
import re
import subprocess
from collections import defaultdict
from pathlib import Path
from typing import Dict, List, Optional
from devgen.utils import run_git_command
# Lock files and other metadata blobs whose full diff would just bloat the
# prompt. We mark them as updated and skip the actual diff.
IGNORED_DIFF_PATTERNS = (
"uv.lock",
"package-lock.json",
"pnpm-lock.yaml",
"yarn.lock",
"composer.lock",
"Gemfile.lock",
"poetry.lock",
)
# Default cap for a single group diff. Real-world prompts above this size
# are likely to hit the model's context window; the cap is overridable
# via ``max_diff_size`` in the config.
DEFAULT_MAX_DIFF_SIZE = 8000
# Manifests we read for project context, in priority order. Lock files
# are excluded — they're noisy and rarely change in a way that informs
# a commit message.
_MANIFEST_FILES = (
"pyproject.toml",
"package.json",
"requirements.txt",
)
class DiffBuilder:
"""Produces a staged diff for a list of paths with truncation."""
def __init__(
self,
max_size: int = DEFAULT_MAX_DIFF_SIZE,
cwd: Optional[Path] = None,
logger=None,
) -> None:
self.max_size = max_size
self.cwd = cwd
self.logger = logger
def build(self, files: List[str]) -> str:
summary, to_diff = self._partition(files)
diff = self._run_staged_diff(to_diff)
full = "\n".join(summary + [diff]).strip()
if len(full) > self.max_size:
return self._truncate(full)
return full
# --- internals --------------------------------------------------------
def _partition(self, files: List[str]) -> tuple[list[str], list[str]]:
summary, to_diff = [], []
for f in files:
if Path(f).name in IGNORED_DIFF_PATTERNS:
summary.append(f"[METADATA UPDATED] {f}")
else:
to_diff.append(f)
return summary, to_diff
def _run_staged_diff(self, files: List[str]) -> str:
if not files:
return ""
try:
return run_git_command(
["git", "--no-pager", "diff", "--staged", "--", *files],
cwd=self.cwd,
)
except subprocess.CalledProcessError as e:
raise RuntimeError(
f"Git command failed: {' '.join(e.cmd)}\nError: {e.stderr.strip()}"
) from e
def _truncate(self, full: str) -> str:
if self.logger is not None:
self.logger.info(
f"Truncating diff from {len(full)} to {self.max_size} chars "
f"(config: max_diff_size). If you still hit token-limit errors, "
f"lower this further or split the commit into more groups."
)
half = self.max_size // 2
return (
full[:half]
+ "\n\n... [DIFF TRUNCATED FOR TOKEN OPTIMIZATION] ...\n\n"
+ full[-half:]
)
class FileGrouper:
"""Groups files by parent directory, merging down to ``max_groups``."""
def __init__(self, max_groups: int = 5) -> None:
self.max_groups = max(1, max_groups)
def group(self, files: List[str]) -> Dict[str, List[str]]:
groups: Dict[str, List[str]] = defaultdict(list)
for f in files:
parent = str(Path(f).parent)
key = "root" if parent == "." else parent
groups[key].append(f)
if len(groups) <= self.max_groups:
return dict(groups)
return self._merge_deepest(dict(groups))
def _merge_deepest(self, groups: Dict[str, List[str]]) -> Dict[str, List[str]]:
while len(groups) > self.max_groups:
candidates = [k for k in groups if k != "root"]
if not candidates:
break
deepest = max(candidates, key=lambda p: len(Path(p).parts))
parent_path = str(Path(deepest).parent)
new_key = "root" if parent_path == "." else parent_path
files = groups.pop(deepest)
if new_key in groups:
groups[new_key].extend(files)
else:
groups["root"].extend(files)
return groups
class ManifestInspector:
"""Extracts a compact one-line-per-file project summary.
Dumping the first 100 lines of ``pyproject.toml``/``requirements.txt``
is extremely wasteful — most of it is build/tool config the model does
not need. We extract just the project identity and the package list
so the model can write context-aware commit messages.
"""
@staticmethod
def summary() -> str:
lines = [
s
for s in (
ManifestInspector._pyproject(),
ManifestInspector._package_json(),
ManifestInspector._requirements(),
)
if s
]
if not lines:
return ""
return "\nProject: " + " | ".join(lines)
# --- extractors -------------------------------------------------------
@staticmethod
def _pkg_name(spec: str) -> str:
return re.split(r"[><=!~\[]", spec, 1)[0].strip()
@staticmethod
def _pyproject() -> Optional[str]:
path = Path("pyproject.toml")
if not path.exists():
return None
try:
import toml
data = toml.load(path)
except Exception:
return None
proj = data.get("project", {}) if isinstance(data, dict) else {}
parts = [
f"{k}={proj[k]}" for k in ("name", "version", "description") if proj.get(k)
]
deps = proj.get("dependencies") or []
if deps:
names = [n for n in (ManifestInspector._pkg_name(d) for d in deps) if n]
if names:
parts.append(f"deps={','.join(names)}")
return f"[pyproject.toml] {' '.join(parts)}" if parts else None
@staticmethod
def _package_json() -> Optional[str]:
path = Path("package.json")
if not path.exists():
return None
try:
data = json.loads(path.read_text(encoding="utf-8"))
except Exception:
return None
parts = [
f"{k}={data[k]}" for k in ("name", "version", "description") if data.get(k)
]
deps = data.get("dependencies") or {}
if isinstance(deps, dict) and deps:
parts.append(f"deps={','.join(deps.keys())}")
return f"[package.json] {' '.join(parts)}" if parts else None
@staticmethod
def _requirements() -> Optional[str]:
path = Path("requirements.txt")
if not path.exists():
return None
try:
names = [
n
for n in (
ManifestInspector._pkg_name(line)
for line in path.read_text(encoding="utf-8").splitlines()
)
if n and not n.startswith("#")
]
except Exception:
return None
return f"[requirements.txt] deps={','.join(names)}" if names else None
+152
View File
@@ -0,0 +1,152 @@
"""Encapsulates every ``git`` invocation the commit engine makes.
Wraps :func:`devgen.utils.run_git_command` with the project's preferred
error handling and turns raw CLI output into typed results. Higher-level
classes (:class:`CommitEngine`) depend on this instead of calling git
themselves, so the engine code stays focused on orchestration.
"""
import subprocess
from pathlib import Path
from typing import Optional
from devgen.utils import configure_logger, run_git_command
class GitError(RuntimeError):
"""Raised when a git command exits non-zero."""
class GitOperator:
"""All git side-effects in one place."""
def __init__(
self,
cwd: Optional[Path] = None,
logger=None,
quiet_commands: bool = True,
) -> None:
self.cwd = cwd
self.logger = logger or configure_logger("devgen.git")
self.quiet_commands = quiet_commands
# ------------------------------------------------------------------ helpers
def _run(self, args, *, check: bool = True) -> str:
try:
return run_git_command(args, check=check, cwd=self.cwd)
except subprocess.CalledProcessError as e:
cmd_str = " ".join(e.cmd) if e.cmd else "git"
stderr = (e.stderr or "").strip()
raise GitError(f"Git command failed: {cmd_str}\nError: {stderr}") from e
def _format_error(self, e: subprocess.CalledProcessError) -> str:
cmd_str = " ".join(e.cmd) if e.cmd else "git"
return f"Git command failed: {cmd_str}\nError: {(e.stderr or '').strip()}"
# ------------------------------------------------------------------ queries
def has_upstream(self) -> bool:
"""Return True if the current branch tracks an upstream."""
return bool(self.upstream())
def upstream(self) -> str:
"""Return the upstream branch name, or empty string if none."""
return self._run(
[
"git",
"rev-parse",
"--abbrev-ref",
"--symbolic-full-name",
"@{u}",
],
check=False,
)
def is_ahead_of_remote(self, fetch: bool = False) -> bool:
"""Return True if local HEAD has unpushed commits.
When ``fetch`` is True, refresh the upstream ref first — but only
do that when the caller is about to push. Skipping the fetch by
default avoids a network round-trip on every run and shrinks the
TOCTOU window between fetch and rev-list.
"""
upstream = self.upstream()
if not upstream:
return False
if fetch:
try:
self._run(["git", "fetch", "origin", upstream], check=False)
except GitError:
pass
try:
count = self._run(["git", "rev-list", "--count", "@{u}..HEAD"], check=False)
return bool(count) and int(count) > 0
except (GitError, ValueError):
return False
def detect_changed(self) -> list[str]:
"""Return modified, deleted, and untracked files (deduplicated)."""
out = self._run(
[
"git",
"ls-files",
"--deleted",
"--modified",
"--others",
"--exclude-standard",
]
)
files = {f.strip() for f in out.split("\n") if f.strip()}
staged_out = self._run(["git", "diff", "--name-only", "--cached"])
files.update(f.strip() for f in staged_out.split("\n") if f.strip())
return sorted(files)
# ------------------------------------------------------------------ actions
def stage(self, files: list[str]) -> None:
"""Stage the given paths — ``git add`` for existing files, ``git rm`` for deleted."""
if not files:
return
existing = [f for f in files if Path(self.cwd or ".").joinpath(f).exists()]
deleted = [f for f in files if f not in existing]
try:
if existing:
self._run(["git", "add", *existing])
if deleted:
self._run(["git", "rm", *deleted])
except GitError:
# Fallback: file state may have changed between check and command.
# Try a single git add --all for the whole set; git will handle
# both additions and removals correctly.
self._run(["git", "add", "--all", "--", *files])
def commit(self, message: str) -> None:
"""``git commit -m`` with the given message."""
self._run(["git", "commit", "-m", message])
def reset(self, files: list[str]) -> None:
"""Unstage the given paths. Errors are swallowed — best-effort."""
if not files:
return
try:
self._run(["git", "reset", "HEAD", "--", *files], check=False)
except GitError:
pass
def push(self) -> None:
"""Fetch upstream then push. Falls back to a no-upstream warning."""
upstream = self.upstream()
if upstream:
try:
self._run(["git", "fetch", "origin", upstream], check=False)
except GitError:
pass
try:
self._run(["git", "push"])
except GitError as e:
if "no upstream branch" in str(e).lower():
if not self.quiet_commands:
print("[warning]No upstream branch. Skipping push.[/warning]")
return
raise
+1 -3
View File
@@ -8,9 +8,7 @@ class LicenseGenerator:
"""Generates license files from templates."""
def __init__(self):
self.templates_dir = (
Path(__file__).parent.parent / "prompt_templates" / "licenses"
)
self.templates_dir = Path(__file__).parent.parent / "templates" / "licenses"
def list_licenses(self) -> List[Dict[str, str]]:
"""Lists available license templates."""
+28 -38
View File
@@ -2,65 +2,55 @@ from datetime import datetime
from pathlib import Path
from devgen.modules.changelog_generator import ChangelogGenerator
from devgen.modules.changelog_sections import Section
from devgen.utils import configure_logger
# Release-note-specific heading styles (use the same emoji, simpler heading).
RELEASE_NOTE_STYLES = {
Section.BREAKING: ("⚠️", "BREAKING CHANGES"),
Section.FEATURES: ("✨", "Features"),
Section.BUG_FIXES: ("🐛", "Bug Fixes"),
Section.PERFORMANCE: ("⚡", "Performance"),
Section.DOCUMENTATION: ("📚", "Documentation"),
Section.REFACTOR: ("♻️", "Refactor"),
Section.TESTS: ("✅", "Tests"),
Section.STYLE: ("💄", "Style"),
Section.CHORE: ("🔧", "Chore"),
Section.OTHER: ("🧹", "Other Changes"),
}
class ReleaseNotesGenerator(ChangelogGenerator):
"""Generate short, clean release notes from git history."""
SECTION_EMOJIS = {
"BREAKING CHANGES": "⚠️",
"Features": "✨",
"Bug Fixes": "🐛",
"Documentation": "📚",
"Refactor": "♻️",
"Tests": "✅",
"Chore": "🔧",
"Style": "✨",
"Other Changes": "🧹",
}
def __init__(self, logger=None):
super().__init__(logger or configure_logger("devgen.releasenotes"))
def generate_release_markdown(self, groups, version="Unreleased"):
"""Generate human-friendly release notes."""
"""Render a human-friendly release-notes block."""
date_str = datetime.now().strftime("%Y-%m-%d")
md = [f"## 🚀 Release {version} — {date_str}\n"]
order = [
"BREAKING CHANGES",
"Features",
"Bug Fixes",
"Documentation",
"Other Changes",
]
for section in order:
out = [f"## 🚀 Release {version} — {date_str}\n"]
for section in Section.ordered():
commits = groups.get(section)
if not commits:
continue
emoji = self.SECTION_EMOJIS.get(section, "")
md.append(f"### {emoji} {section}")
emoji, heading = RELEASE_NOTE_STYLES[section]
out.append(f"### {emoji} {heading}")
for c in commits:
scope = f"**{c['scope']}**: " if c["scope"] else ""
md.append(f"- {scope}{c['subject']}")
md.append("")
return "\n".join(md)
scope = f"**{c['scope']}**: " if c.get("scope") else ""
out.append(f"- {scope}{c['subject']}")
out.append("")
return "\n".join(out)
def run(self, output_file="RELEASE-NOTES.md", version="Unreleased", from_ref=""):
"""Main logic: get commits → parse → generate → write."""
"""Fetch → parse → write release notes."""
raw_commits = self.get_commits(from_ref)
if not raw_commits or not raw_commits[0]:
print("❗ No commits found for release notes.")
return
parsed = self.parse_commits(raw_commits)
md = self.generate_release_markdown(parsed, version=version)
groups = self.parse_commits(raw_commits)
md = self.generate_release_markdown(groups, version=version)
Path(output_file).write_text(md, encoding="utf-8")
print(f" Release notes written to {output_file}")
-24
View File
@@ -1,24 +0,0 @@
def get_provider(name):
name_lower = name.lower()
if name_lower == "gemini":
from devgen.providers.gemini import GeminiProvider
return GeminiProvider()
elif name_lower == "openai":
from devgen.providers.openai import OpenaiProvider
return OpenaiProvider()
elif name_lower == "huggingface":
from devgen.providers.huggingface import HuggingfaceProvider
return HuggingfaceProvider()
elif name_lower == "openrouter":
from devgen.providers.openrouter import OpenrouterProvider
return OpenrouterProvider()
elif name_lower == "anthropic":
from devgen.providers.anthropic import AnthropicProvider
return AnthropicProvider()
raise NotImplementedError(f"Provider '{name}' is not implemented yet.")
+13 -17
View File
@@ -1,23 +1,19 @@
import anthropic
from devgen.providers.base import BaseProvider
class AnthropicProvider:
class AnthropicProvider(BaseProvider):
"""Generates content using Anthropic's Claude models."""
def generate(
self, prompt: str, api_key: str, model: str = "claude-3-opus-20240229", **kwargs
) -> str:
"""Generates a response using the Anthropic API."""
if not api_key:
raise ValueError("Anthropic API key is required.")
DISPLAY_NAME = "Anthropic"
DEFAULT_MODEL = "claude-3-opus-20240229"
try:
client = anthropic.Anthropic(api_key=api_key)
message = client.messages.create(
model=model,
max_tokens=1024,
messages=[{"role": "user", "content": prompt}],
)
return message.content[0].text.strip()
except Exception as e:
raise RuntimeError(f"Anthropic generation failed: {e}")
def _generate(self, prompt, api_key, model, **kwargs):
client = anthropic.Anthropic(api_key=api_key)
message = client.messages.create(
model=model,
max_tokens=1024,
messages=[{"role": "user", "content": prompt}],
)
return message.content[0].text or ""
+91
View File
@@ -0,0 +1,91 @@
"""Base class for AI providers.
Every provider implements :meth:`_generate` and inherits the standard
contract: API-key validation, token-limit detection, and friendly
RuntimeError wrapping. Providers with provider-specific error shapes
(eg. Hugging Face's HTTPError / RequestException split) override
:meth:`_handle_error`.
"""
from abc import ABC, abstractmethod
from typing import Any
from devgen.utils import format_token_limit_error, is_token_limit_error
class BaseProvider(ABC):
"""Abstract base for all AI providers."""
#: Human-readable name used in error messages.
DISPLAY_NAME: str = ""
#: Whether the provider requires an API key.
REQUIRES_API_KEY: bool = True
#: Default model id when the user doesn't specify one.
DEFAULT_MODEL: str = ""
def generate(
self,
prompt: str,
api_key: str | None = None,
model: str | None = None,
**kwargs: Any,
) -> str:
"""Public entry point. Validates input, dispatches, and wraps errors.
Args:
prompt: The prompt to send to the model.
api_key: Provider API key. May be ``None`` for local providers
(see :attr:`REQUIRES_API_KEY`).
model: Model id. Falls back to :attr:`DEFAULT_MODEL` when ``None``.
**kwargs: Forwarded to :meth:`_generate`.
Returns:
The generated text, stripped.
Raises:
ValueError: If the provider requires an API key and none was given.
RuntimeError: For any underlying failure, with a friendly message.
"""
if self.REQUIRES_API_KEY and not api_key:
raise ValueError(
f"{self.DISPLAY_NAME} API key is missing. "
"Set it via `devgen setup config` or pass --api-key."
)
chosen_model = model or self.DEFAULT_MODEL
try:
text = self._generate(prompt, api_key, chosen_model, **kwargs)
return (text or "").strip()
except Exception as e:
self._handle_error(e)
def _handle_error(self, error: Exception) -> None:
"""Translate an internal exception into a user-facing RuntimeError.
Token-limit / context-window errors are reformatted with
:func:`format_token_limit_error`; everything else is wrapped in a
generic provider message. Subclasses override this to add
provider-specific error shapes (eg. HTTPError vs RequestException).
"""
if is_token_limit_error(error):
raise RuntimeError(
format_token_limit_error(self.DISPLAY_NAME, error)
) from error
raise RuntimeError(
f"{self.DISPLAY_NAME} request failed: {error}. "
"Check the model name, API key permissions, and your account quota."
) from error
@abstractmethod
def _generate(
self,
prompt: str,
api_key: str | None,
model: str,
**kwargs: Any,
) -> str:
"""Provider-specific generation. Return the raw generated text.
Implementations may raise any exception; :meth:`_handle_error` will
translate it. Re-raise an exception to defer to default handling.
"""
raise NotImplementedError
+56 -17
View File
@@ -1,24 +1,63 @@
import google.generativeai as genai
import time
from google import genai
from google.genai import types
from devgen.providers.base import BaseProvider
from devgen.utils import is_token_limit_error
class GeminiProvider:
class GeminiProvider(BaseProvider):
"""Generates content using Google's Gemini models."""
def generate(
self, prompt: str, api_key: str, model: str = "gemini-pro", **kwargs
) -> str:
"""Generates a response using the Gemini API."""
if not api_key:
raise ValueError("Gemini API key is required.")
DISPLAY_NAME = "Gemini"
DEFAULT_MODEL = "gemini-2.5-flash"
genai.configure(api_key=api_key)
def _generate(self, prompt, api_key, model, **kwargs):
max_retries = kwargs.get("max_retries", 5)
base_delay = kwargs.get("retry_delay", 10) # 10s base for 429 backoff
client = genai.Client(api_key=api_key)
# Handle model name mapping if needed, or trust user input
# gemini-pro is a common default
for attempt in range(max_retries + 1):
try:
response = client.models.generate_content(
model=model,
contents=prompt,
config=types.GenerateContentConfig(
temperature=kwargs.get("temperature", 0.7),
top_p=kwargs.get("top_p", 0.95),
top_k=kwargs.get("top_k", 40),
max_output_tokens=kwargs.get("max_output_tokens", 2048),
),
)
return response.text or ""
except Exception as e:
if is_token_limit_error(e):
raise # base class handles with friendly message
if self._is_quota_error(e) and attempt < max_retries:
delay = base_delay * (2**attempt)
print(
f"\n[Gemini] Quota limit hit (429). This is often a temporary "
f"rate limit (RPM). Waiting {delay}s and retrying... "
f"(Attempt {attempt + 1}/{max_retries})"
)
time.sleep(delay)
continue
if self._is_quota_error(e):
raise RuntimeError(
"Gemini API quota exhausted (429) after multiple retries.\n\n"
"POSSIBLE CAUSES:\n"
"1. Rate Limit (RPM/RPD): You are sending requests too fast for the Free Tier.\n"
"2. Daily Limit: You have reached the maximum free requests allowed for today (typically 1,500).\n"
"3. Credit/Billing: If on a paid plan, your credit may be exhausted.\n\n"
"Please check your usage metrics at: https://aistudio.google.com/app/usage"
) from e
raise
try:
model_instance = genai.GenerativeModel(model)
response = model_instance.generate_content(prompt)
return response.text.strip()
except Exception as e:
raise RuntimeError(f"Gemini generation failed: {e}")
@staticmethod
def _is_quota_error(error: Exception) -> bool:
text = str(error).upper()
return any(
marker in text
for marker in ("429", "RESOURCE_EXHAUSTED", "QUOTA_EXCEEDED", "THROTTLED")
)
+35 -26
View File
@@ -1,29 +1,19 @@
import requests
from devgen.providers.base import BaseProvider
from devgen.utils import format_token_limit_error, is_token_limit_error
class HuggingfaceProvider:
class HuggingfaceProvider(BaseProvider):
"""Generates content using Hugging Face Inference API."""
DISPLAY_NAME = "Hugging Face"
API_URL_TEMPLATE = "https://api-inference.huggingface.co/models/{model}"
DEFAULT_MODEL = "mistralai/Mistral-7B-Instruct-v0.2"
def generate(
self,
prompt: str,
api_key: str,
model: str = "mistralai/Mistral-7B-Instruct-v0.2",
**kwargs,
) -> str:
"""Generates a response using Hugging Face API."""
if not api_key:
raise ValueError("Hugging Face API token is required.")
def _generate(self, prompt, api_key, model, **kwargs):
api_url = self.API_URL_TEMPLATE.format(model=model)
headers = {"Authorization": f"Bearer {api_key}"}
# HF models often expect specific prompting formats, but we'll send raw prompt
# Some models are text-generation, some are conversational.
# Assuming text-generation for generic usage.
payload = {
"inputs": prompt,
"parameters": {"max_new_tokens": 500, "return_full_text": False},
@@ -33,13 +23,32 @@ class HuggingfaceProvider:
response = requests.post(api_url, headers=headers, json=payload)
response.raise_for_status()
result = response.json()
except requests.HTTPError as e:
if is_token_limit_error(e):
raise RuntimeError(
format_token_limit_error(self.DISPLAY_NAME, e)
) from e
status = e.response.status_code if e.response is not None else "?"
raise RuntimeError(
f"Hugging Face request failed (HTTP {status}): {e}. "
"Check the model id and your token permissions."
) from e
except requests.RequestException as e:
raise RuntimeError(
f"Hugging Face network error: {e}. Check your connection."
) from e
if isinstance(result, list) and "generated_text" in result[0]:
return result[0]["generated_text"].strip()
elif isinstance(result, dict) and "error" in result:
raise RuntimeError(f"Hugging Face API error: {result['error']}")
else:
return str(result)
except Exception as e:
raise RuntimeError(f"Hugging Face generation failed: {e}")
if isinstance(result, list) and result and "generated_text" in result[0]:
return result[0]["generated_text"]
if isinstance(result, dict) and "error" in result:
err = result["error"]
if "is currently loading" in str(err).lower():
raise RuntimeError(
f"Hugging Face model is loading: {err}. "
"Wait a minute and retry, or pick a different model."
)
raise RuntimeError(f"Hugging Face API error: {err}")
raise RuntimeError(
"Hugging Face returned an unexpected response shape. "
"The model may not support text-generation."
)
+51
View File
@@ -0,0 +1,51 @@
from openai import OpenAI
from devgen.providers.base import BaseProvider
from devgen.utils import format_token_limit_error, is_token_limit_error
class OllamaProvider(BaseProvider):
"""Generates content using a local Ollama server.
Ollama exposes an OpenAI-compatible endpoint at /v1/chat/completions,
so we reuse the ``openai`` client. The host defaults to
http://localhost:11434 but can be overridden via the ``ollama_host``
kwarg (or ``ollama_host`` in ~/.devgen.yaml).
"""
DISPLAY_NAME = "Ollama"
REQUIRES_API_KEY = False
DEFAULT_HOST = "http://localhost:11434"
DEFAULT_MODEL = "llama3.2"
def _generate(self, prompt, api_key, model, **kwargs):
host = kwargs.get("ollama_host") or self.DEFAULT_HOST
base_url = f"{host.rstrip('/')}/v1"
kwargs.pop("debug", None)
kwargs.pop("ollama_host", None)
client = OpenAI(base_url=base_url, api_key=api_key or "ollama")
response = client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": prompt}],
**kwargs,
)
if not response.choices:
raise RuntimeError(
f"Ollama returned no choices for model {model!r}. "
"The model may not be installed locally."
)
return response.choices[0].message.content or ""
def _handle_error(self, error):
"""Override default wrapping — Ollama has no API key to check."""
if is_token_limit_error(error):
raise RuntimeError(
format_token_limit_error(self.DISPLAY_NAME, error)
) from error
raise RuntimeError(
f"{self.DISPLAY_NAME} request failed: {error}. "
"Is the Ollama server running? Start it with `ollama serve` and "
"pull a model with `ollama pull <model>`."
) from error
+9 -37
View File
@@ -1,48 +1,20 @@
from openai import OpenAI
from devgen.providers.base import BaseProvider
class OpenaiProvider:
"""Generates a response string using the OpenAI ChatCompletion API based on the provided prompt and parameters. This method initializes an OpenAI client with the given API key, sends a chat completion request with specified model and additional parameters, and returns the content of the generated message.
Args:
prompt (str): The input prompt to generate a response for.
api_key (str): The API key used to authenticate with the OpenAI service.
model (str, optional): The model to use for generation; defaults to "gpt-4o".
**kwargs: Additional keyword arguments to customize the API request (e.g., temperature).
Returns:
str: The content of the generated response message.
"""
class OpenaiProvider(BaseProvider):
"""Generates content using OpenAI's Chat Completions API."""
DISPLAY_NAME = "OpenAI"
DEFAULT_MODEL = "gpt-4o"
def generate(
self, prompt: str, api_key: str, model: str | None = None, **kwargs
) -> str:
"""Generates a response from the OpenAI ChatCompletion API based on the provided prompt and parameters.
Creates a client instance with the specified API key, sends a chat completion request using the selected model and additional parameters, and returns the generated message content as a string.
Args:
prompt (str): Prompt input.
api_key (str): OpenAI API key.
model (str, optional): Model to use (default: gpt-4o).
**kwargs: Additional OpenAI ChatCompletion parameters (e.g., temperature).
Returns:
str: Generated response content.
"""
# 1. Create a client instance with the API key.
try:
client = OpenAI(api_key=api_key)
except Exception as e:
# Add error handling if the client fails to initialize
raise RuntimeError(f"Failed to initialize OpenAI client: {e}")
# 2. Use the modern API syntax: client.chat.completions.create
def _generate(self, prompt, api_key, model, **kwargs):
kwargs.pop("debug", None)
client = OpenAI(api_key=api_key)
response = client.chat.completions.create(
model=model or self.DEFAULT_MODEL,
model=model,
messages=[{"role": "user", "content": prompt}],
**kwargs,
)
return response.choices[0].message.content.strip()
return response.choices[0].message.content or ""
+18 -26
View File
@@ -1,33 +1,25 @@
from openai import OpenAI
from devgen.providers.base import BaseProvider
class OpenrouterProvider:
class OpenrouterProvider(BaseProvider):
"""Generates content using OpenRouter (OpenAI-compatible API)."""
DISPLAY_NAME = "OpenRouter"
BASE_URL = "https://openrouter.ai/api/v1"
DEFAULT_MODEL = "openai/gpt-3.5-turbo"
def generate(
self, prompt: str, api_key: str, model: str = "openai/gpt-3.5-turbo", **kwargs
) -> str:
"""Generates a response using OpenRouter."""
if not api_key:
raise ValueError("OpenRouter API key is required.")
try:
client = OpenAI(
base_url=self.BASE_URL,
api_key=api_key,
)
response = client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": prompt}],
extra_headers={
"HTTP-Referer": "https://github.com/S4NKALP/devgen", # Optional
"X-Title": "devgen CLI", # Optional
},
**kwargs,
)
return response.choices[0].message.content.strip()
except Exception as e:
raise RuntimeError(f"OpenRouter generation failed: {e}")
def _generate(self, prompt, api_key, model, **kwargs):
kwargs.pop("debug", None)
client = OpenAI(base_url=self.BASE_URL, api_key=api_key)
response = client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": prompt}],
extra_headers={
"HTTP-Referer": "https://github.com/S4NKALP/devgen",
"X-Title": "devgen CLI",
},
**kwargs,
)
return response.choices[0].message.content
-15
View File
@@ -1,15 +0,0 @@
Analyze the following git diff for the directory `{{ group_name }}` and generate a detailed semantic release commit message4. Format the output as:
1. A summary title in semantic release format (type(scope): summary) — scope can be the directory or 'root'.
2. A short summary paragraph if needed.
3. Bullet points listing the main changes, grouped as 'New Features', 'Enhancements & Refinements', and 'Bug Fixes' if relevant. Use bold for feature/section names.
{% if use_emoji %}
4. Use appropriate emojis for the commit type and sections (e.g., 🚀 for features, 🐛 for bugs).
{% else %}
4. Do NOT use any emojis in the commit message.
{% endif %}
Output only the commit message, don't include explanations or extra text.
Here is the git diff for `{{ group_name }}`:
{{ diff_text }}
+14
View File
@@ -0,0 +1,14 @@
You write Conventional Commits. Output ONLY the message — no preamble, no labels, no quotes, no markdown.
Format:
<type>(<scope>): <summary>
[optional 1-2 line body on WHY]
types: feat fix refactor perf docs test build ci chore style
scope: {{ group_name }}{% if use_emoji %} (prefix summary with one matching emoji){% endif %}
Example:
feat(auth): add login rate limit
Prevents brute-force attacks.
{{ diff_text }}{{ context }}
+123 -10
View File
@@ -31,25 +31,63 @@ def is_file_recent(file_path: Path | str, max_age_minutes: int = 120) -> bool:
def sanitize_ai_commit_message(raw_text: str) -> str:
"""
Cleans up the AI-generated commit message.
It looks for a conventional commit header and extracts everything from there.
It handles bolded headers and common AI prefixes.
"""
if not raw_text:
return ""
lines = raw_text.strip().split("\n")
cleaned_lines = []
in_block = False
# Regex for conventional commit header
# Regex for conventional commit header (including optional bolding)
# Examples:
# - feat(root): summary
# - **fix: bug fix**
# - chore(deps)!: breaking change
header_pattern = re.compile(
r"^(feat|fix|chore|refactor|docs|style|test|build|ci)(\(.*\))?!?: .*"
r"^(\*\*)?(feat|fix|chore|refactor|docs|style|test|build|ci)(\(.*\))?!?: .*",
re.IGNORECASE,
)
cleaned_lines = []
found_header = False
for line in lines:
stripped = line.strip()
if in_block:
if header_pattern.match(stripped) or "**Sponsor**" in line:
if not found_header:
# First, strip bolding if it wraps the whole line or parts of it
# This is simpler than handling it in the regex
candidate = stripped.replace("**", "").strip()
# Clean the line by removing labels like "1. Title:", "### Message:", etc.
clean_line = re.sub(
r"^(#+\s+)?(\d+\.?|\*|-)?\s*(Title|Commit Message|Message):\s*",
"",
candidate,
flags=re.IGNORECASE,
).strip()
if header_pattern.match(clean_line):
found_header = True
cleaned_lines.append(clean_line)
else:
# If we hit another header or a known separator, we stop
if "**Sponsor**" in line:
break
cleaned_lines.append(line)
elif header_pattern.match(stripped):
in_block = True
cleaned_lines.append(line)
return "\n".join(cleaned_lines).strip() if cleaned_lines else ""
if cleaned_lines:
return "\n".join(cleaned_lines).strip()
# Fallback: if no conventional commit header found, just take the first non-empty line
# to avoid failing completely, but log a warning if possible.
for line in lines:
if line.strip():
return line.strip()
return ""
def parse_markdown_sections(
@@ -171,9 +209,21 @@ def delete_file(filepath: Path | str) -> bool:
def load_template_env(sub_dir: str) -> Environment:
template_dir = Path(__file__).parent / "templates" / sub_dir
if not template_dir.is_dir():
raise RuntimeError(
f"Template directory not found: {template_dir}. "
"The installation may be corrupted."
)
return Environment(loader=FileSystemLoader(template_dir))
def render_custom_template(template_str: str, **context) -> str:
"""Renders a Jinja2 template from a string."""
env = Environment()
template = env.from_string(template_str)
return template.render(**context)
def load_config() -> Dict[str, Any]:
config_path = Path.home() / ".devgen.yaml"
@@ -183,6 +233,8 @@ def load_config() -> Dict[str, Any]:
"model": "gemini-2.5-flash",
"api_key": "",
"emoji": True,
"max_groups": 5,
"ollama_host": "http://localhost:11434",
}
try:
with config_path.open("w", encoding="utf-8") as f:
@@ -200,6 +252,65 @@ def load_config() -> Dict[str, Any]:
return {}
# Substrings used to identify token / context-length errors from AI providers.
# Matched case-insensitively against the full stringified exception, so we
# catch the wide variety of error formats each provider returns.
_TOKEN_LIMIT_PATTERNS = (
"context length",
"context_length",
"context_length_exceeded",
"context window",
"context window exceeded",
"context_size",
"maximum context",
"max context",
"max_tokens",
"max tokens",
"prompt is too long",
"too many tokens",
"token limit",
"tokens in the prompt",
"input is too long",
"request too large",
"request was too large",
"string too long",
"too long for the model",
"reduce the length of the messages",
"tokens_exceeded",
)
def is_token_limit_error(error: BaseException | str) -> bool:
"""Return True if the error looks like a model context/token overflow.
Different SDKs phrase the same condition differently. We look for the
common substrings so we can give a single, actionable message instead of
the raw provider text.
"""
text = str(error).lower()
return any(p in text for p in _TOKEN_LIMIT_PATTERNS)
def format_token_limit_error(
provider: str,
error: BaseException | str,
*,
group: str | None = None,
) -> str:
"""Build a friendly message for a context-window overflow.
Kept short so it doesn't drown the log line. The original provider
error is included so the user can still see the exact cause.
"""
scope = f" ({group})" if group else ""
return (
f"{provider}: diff exceeds context window{scope}. "
f"Cut token usage: --max-groups N, --max-diff-size N, "
f"or a larger-context model (gemini-2.5-flash, gpt-4o, claude-3-5-sonnet). "
f"Details: {error}"
)
__all__ = [
"ensure_log_directory",
"get_main_log_path",
@@ -215,6 +326,8 @@ __all__ = [
"load_template_env",
"load_config",
"get_questionary_style",
"is_token_limit_error",
"format_token_limit_error",
]
+2 -2
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "devgen-cli"
version = "0.2.2"
version = "0.2.8"
description = "A collection of developer tools"
readme = "README.md"
requires-python = ">=3.10"
@@ -26,7 +26,7 @@ classifiers = [
]
dependencies = [
"anthropic>=0.75.0",
"google-generativeai>=0.8.5",
"google-genai>=0.1.0",
"jinja2>=3.1.6",
"openai>=2.11.0",
"pyyaml>=6.0.3",
+12 -12
View File
@@ -1,12 +1,12 @@
anthropic>=0.75.0
google-generativeai>=0.8.5
jinja2>=3.1.6
openai>=2.11.0
pyyaml>=6.0.3
questionary>=2.1.1
requests>=2.32.5
rich>=14.2.0
toml>=0.10.2
typer>=0.20.2
pre-commit>=4.5.0
ruff>=0.14.9
anthropic==0.105.2
google-genai==2.8.0
jinja2==3.1.6
openai==2.41.0
pre-commit==4.5.0
pyyaml==6.0.3
questionary==2.1.1
requests==2.34.2
rich==15.0.0
ruff==0.15.16
toml==0.10.2
typer==0.26.7
+1 -1
View File
@@ -6,7 +6,7 @@ from setuptools import find_packages, setup
setup(
name="devgen-cli",
version="0.2.2",
version="0.2.8",
packages=find_packages(),
include_package_data=True,
install_requires=[
Generated
+144 -320
View File
@@ -19,7 +19,7 @@ wheels = [
[[package]]
name = "anthropic"
version = "0.75.0"
version = "0.76.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
@@ -31,41 +31,32 @@ dependencies = [
{ name = "sniffio" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/04/1f/08e95f4b7e2d35205ae5dcbb4ae97e7d477fc521c275c02609e2931ece2d/anthropic-0.75.0.tar.gz", hash = "sha256:e8607422f4ab616db2ea5baacc215dd5f028da99ce2f022e33c7c535b29f3dfb", size = 439565, upload-time = "2025-11-24T20:41:45.28Z" }
sdist = { url = "https://files.pythonhosted.org/packages/6e/be/d11abafaa15d6304826438170f7574d750218f49a106c54424a40cef4494/anthropic-0.76.0.tar.gz", hash = "sha256:e0cae6a368986d5cf6df743dfbb1b9519e6a9eee9c6c942ad8121c0b34416ffe", size = 495483, upload-time = "2026-01-13T18:41:14.908Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/60/1c/1cd02b7ae64302a6e06724bf80a96401d5313708651d277b1458504a1730/anthropic-0.75.0-py3-none-any.whl", hash = "sha256:ea8317271b6c15d80225a9f3c670152746e88805a7a61e14d4a374577164965b", size = 388164, upload-time = "2025-11-24T20:41:43.587Z" },
{ url = "https://files.pythonhosted.org/packages/e5/70/7b0fd9c1a738f59d3babe2b4212031c34ab7d0fda4ffef15b58a55c5bcea/anthropic-0.76.0-py3-none-any.whl", hash = "sha256:81efa3113901192af2f0fe977d3ec73fdadb1e691586306c4256cd6d5ccc331c", size = 390309, upload-time = "2026-01-13T18:41:13.483Z" },
]
[[package]]
name = "anyio"
version = "4.12.0"
version = "4.12.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
{ name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/16/ce/8a777047513153587e5434fd752e89334ac33e379aa3497db860eeb60377/anyio-4.12.0.tar.gz", hash = "sha256:73c693b567b0c55130c104d0b43a9baf3aa6a31fc6110116509f27bf75e21ec0", size = 228266, upload-time = "2025-11-28T23:37:38.911Z" }
sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl", hash = "sha256:dad2376a628f98eeca4881fc56cd06affd18f659b17a747d3ff0307ced94b1bb", size = 113362, upload-time = "2025-11-28T23:36:57.897Z" },
]
[[package]]
name = "cachetools"
version = "6.2.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fb/44/ca1675be2a83aeee1886ab745b28cda92093066590233cc501890eb8417a/cachetools-6.2.2.tar.gz", hash = "sha256:8e6d266b25e539df852251cfd6f990b4bc3a141db73b939058d809ebd2590fc6", size = 31571, upload-time = "2025-11-13T17:42:51.465Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e6/46/eb6eca305c77a4489affe1c5d8f4cae82f285d9addd8de4ec084a7184221/cachetools-6.2.2-py3-none-any.whl", hash = "sha256:6c09c98183bf58560c97b2abfcedcbaf6a896a490f534b031b661d3723b45ace", size = 11503, upload-time = "2025-11-13T17:42:50.232Z" },
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
]
[[package]]
name = "certifi"
version = "2025.11.12"
version = "2026.1.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" }
sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" },
{ url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" },
]
[[package]]
@@ -189,11 +180,11 @@ wheels = [
[[package]]
name = "devgen-cli"
version = "0.2.2"
version = "0.2.8"
source = { editable = "." }
dependencies = [
{ name = "anthropic" },
{ name = "google-generativeai" },
{ name = "google-genai" },
{ name = "jinja2" },
{ name = "openai" },
{ name = "pyyaml" },
@@ -213,7 +204,7 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "anthropic", specifier = ">=0.75.0" },
{ name = "google-generativeai", specifier = ">=0.8.5" },
{ name = "google-genai", specifier = ">=0.1.0" },
{ name = "jinja2", specifier = ">=3.1.6" },
{ name = "openai", specifier = ">=2.11.0" },
{ name = "pyyaml", specifier = ">=6.0.3" },
@@ -271,229 +262,50 @@ wheels = [
[[package]]
name = "filelock"
version = "3.20.0"
version = "3.20.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/58/46/0028a82567109b5ef6e4d2a1f04a583fb513e6cf9527fcdd09afd817deeb/filelock-3.20.0.tar.gz", hash = "sha256:711e943b4ec6be42e1d4e6690b48dc175c822967466bb31c0c293f34334c13f4", size = 18922, upload-time = "2025-10-08T18:03:50.056Z" }
sdist = { url = "https://files.pythonhosted.org/packages/1d/65/ce7f1b70157833bf3cb851b556a37d4547ceafc158aa9b34b36782f23696/filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1", size = 19485, upload-time = "2026-01-09T17:55:05.421Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl", hash = "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2", size = 16054, upload-time = "2025-10-08T18:03:48.35Z" },
]
[[package]]
name = "google-ai-generativelanguage"
version = "0.6.15"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "google-api-core", version = "2.25.2", source = { registry = "https://pypi.org/simple" }, extra = ["grpc"], marker = "python_full_version >= '3.14'" },
{ name = "google-api-core", version = "2.28.1", source = { registry = "https://pypi.org/simple" }, extra = ["grpc"], marker = "python_full_version < '3.14'" },
{ name = "google-auth" },
{ name = "proto-plus" },
{ name = "protobuf" },
]
sdist = { url = "https://files.pythonhosted.org/packages/11/d1/48fe5d7a43d278e9f6b5ada810b0a3530bbeac7ed7fcbcd366f932f05316/google_ai_generativelanguage-0.6.15.tar.gz", hash = "sha256:8f6d9dc4c12b065fe2d0289026171acea5183ebf2d0b11cefe12f3821e159ec3", size = 1375443, upload-time = "2025-01-13T21:50:47.459Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7c/a3/67b8a6ff5001a1d8864922f2d6488dc2a14367ceb651bc3f09a947f2f306/google_ai_generativelanguage-0.6.15-py3-none-any.whl", hash = "sha256:5a03ef86377aa184ffef3662ca28f19eeee158733e45d7947982eb953c6ebb6c", size = 1327356, upload-time = "2025-01-13T21:50:44.174Z" },
]
[[package]]
name = "google-api-core"
version = "2.25.2"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.14'",
]
dependencies = [
{ name = "google-auth", marker = "python_full_version >= '3.14'" },
{ name = "googleapis-common-protos", marker = "python_full_version >= '3.14'" },
{ name = "proto-plus", marker = "python_full_version >= '3.14'" },
{ name = "protobuf", marker = "python_full_version >= '3.14'" },
{ name = "requests", marker = "python_full_version >= '3.14'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/09/cd/63f1557235c2440fe0577acdbc32577c5c002684c58c7f4d770a92366a24/google_api_core-2.25.2.tar.gz", hash = "sha256:1c63aa6af0d0d5e37966f157a77f9396d820fba59f9e43e9415bc3dc5baff300", size = 166266, upload-time = "2025-10-03T00:07:34.778Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/d8/894716a5423933f5c8d2d5f04b16f052a515f78e815dab0c2c6f1fd105dc/google_api_core-2.25.2-py3-none-any.whl", hash = "sha256:e9a8f62d363dc8424a8497f4c2a47d6bcda6c16514c935629c257ab5d10210e7", size = 162489, upload-time = "2025-10-03T00:07:32.924Z" },
]
[package.optional-dependencies]
grpc = [
{ name = "grpcio", marker = "python_full_version >= '3.14'" },
{ name = "grpcio-status", marker = "python_full_version >= '3.14'" },
]
[[package]]
name = "google-api-core"
version = "2.28.1"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version == '3.13.*'",
"python_full_version >= '3.11' and python_full_version < '3.13'",
"python_full_version < '3.11'",
]
dependencies = [
{ name = "google-auth", marker = "python_full_version < '3.14'" },
{ name = "googleapis-common-protos", marker = "python_full_version < '3.14'" },
{ name = "proto-plus", marker = "python_full_version < '3.14'" },
{ name = "protobuf", marker = "python_full_version < '3.14'" },
{ name = "requests", marker = "python_full_version < '3.14'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/61/da/83d7043169ac2c8c7469f0e375610d78ae2160134bf1b80634c482fa079c/google_api_core-2.28.1.tar.gz", hash = "sha256:2b405df02d68e68ce0fbc138559e6036559e685159d148ae5861013dc201baf8", size = 176759, upload-time = "2025-10-28T21:34:51.529Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ed/d4/90197b416cb61cefd316964fd9e7bd8324bcbafabf40eef14a9f20b81974/google_api_core-2.28.1-py3-none-any.whl", hash = "sha256:4021b0f8ceb77a6fb4de6fde4502cecab45062e66ff4f2895169e0b35bc9466c", size = 173706, upload-time = "2025-10-28T21:34:50.151Z" },
]
[package.optional-dependencies]
grpc = [
{ name = "grpcio", marker = "python_full_version < '3.14'" },
{ name = "grpcio-status", marker = "python_full_version < '3.14'" },
]
[[package]]
name = "google-api-python-client"
version = "2.187.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "google-api-core", version = "2.25.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" },
{ name = "google-api-core", version = "2.28.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" },
{ name = "google-auth" },
{ name = "google-auth-httplib2" },
{ name = "httplib2" },
{ name = "uritemplate" },
]
sdist = { url = "https://files.pythonhosted.org/packages/75/83/60cdacf139d768dd7f0fcbe8d95b418299810068093fdf8228c6af89bb70/google_api_python_client-2.187.0.tar.gz", hash = "sha256:e98e8e8f49e1b5048c2f8276473d6485febc76c9c47892a8b4d1afa2c9ec8278", size = 14068154, upload-time = "2025-11-06T01:48:53.274Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/96/58/c1e716be1b055b504d80db2c8413f6c6a890a6ae218a65f178b63bc30356/google_api_python_client-2.187.0-py3-none-any.whl", hash = "sha256:d8d0f6d85d7d1d10bdab32e642312ed572bdc98919f72f831b44b9a9cebba32f", size = 14641434, upload-time = "2025-11-06T01:48:50.763Z" },
{ url = "https://files.pythonhosted.org/packages/b5/36/7fb70f04bf00bc646cd5bb45aa9eddb15e19437a28b8fb2b4a5249fac770/filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1", size = 16701, upload-time = "2026-01-09T17:55:04.334Z" },
]
[[package]]
name = "google-auth"
version = "2.43.0"
version = "2.47.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cachetools" },
{ name = "pyasn1-modules" },
{ name = "rsa" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ff/ef/66d14cf0e01b08d2d51ffc3c20410c4e134a1548fc246a6081eae585a4fe/google_auth-2.43.0.tar.gz", hash = "sha256:88228eee5fc21b62a1b5fe773ca15e67778cb07dc8363adcb4a8827b52d81483", size = 296359, upload-time = "2025-11-06T00:13:36.587Z" }
sdist = { url = "https://files.pythonhosted.org/packages/60/3c/ec64b9a275ca22fa1cd3b6e77fefcf837b0732c890aa32d2bd21313d9b33/google_auth-2.47.0.tar.gz", hash = "sha256:833229070a9dfee1a353ae9877dcd2dec069a8281a4e72e72f77d4a70ff945da", size = 323719, upload-time = "2026-01-06T21:55:31.045Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6f/d1/385110a9ae86d91cc14c5282c61fe9f4dc41c0b9f7d423c6ad77038c4448/google_auth-2.43.0-py2.py3-none-any.whl", hash = "sha256:af628ba6fa493f75c7e9dbe9373d148ca9f4399b5ea29976519e0a3848eddd16", size = 223114, upload-time = "2025-11-06T00:13:35.209Z" },
{ url = "https://files.pythonhosted.org/packages/db/18/79e9008530b79527e0d5f79e7eef08d3b179b7f851cfd3a2f27822fbdfa9/google_auth-2.47.0-py3-none-any.whl", hash = "sha256:c516d68336bfde7cf0da26aab674a36fedcf04b37ac4edd59c597178760c3498", size = 234867, upload-time = "2026-01-06T21:55:28.6Z" },
]
[package.optional-dependencies]
requests = [
{ name = "requests" },
]
[[package]]
name = "google-auth-httplib2"
version = "0.2.1"
name = "google-genai"
version = "1.60.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "google-auth" },
{ name = "httplib2" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e0/83/7ef576d1c7ccea214e7b001e69c006bc75e058a3a1f2ab810167204b698b/google_auth_httplib2-0.2.1.tar.gz", hash = "sha256:5ef03be3927423c87fb69607b42df23a444e434ddb2555b73b3679793187b7de", size = 11086, upload-time = "2025-10-30T21:13:16.569Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/44/a7/ca23dd006255f70e2bc469d3f9f0c82ea455335bfd682ad4d677adc435de/google_auth_httplib2-0.2.1-py3-none-any.whl", hash = "sha256:1be94c611db91c01f9703e7f62b0a59bbd5587a95571c7b6fade510d648bc08b", size = 9525, upload-time = "2025-10-30T21:13:15.758Z" },
]
[[package]]
name = "google-generativeai"
version = "0.8.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "google-ai-generativelanguage" },
{ name = "google-api-core", version = "2.25.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" },
{ name = "google-api-core", version = "2.28.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" },
{ name = "google-api-python-client" },
{ name = "google-auth" },
{ name = "protobuf" },
{ name = "anyio" },
{ name = "distro" },
{ name = "google-auth", extra = ["requests"] },
{ name = "httpx" },
{ name = "pydantic" },
{ name = "tqdm" },
{ name = "requests" },
{ name = "sniffio" },
{ name = "tenacity" },
{ name = "typing-extensions" },
{ name = "websockets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0a/3f/a753be0dcee352b7d63bc6d1ba14a72591d63b6391dac0cdff7ac168c530/google_genai-1.60.0.tar.gz", hash = "sha256:9768061775fddfaecfefb0d6d7a6cabefb3952ebd246cd5f65247151c07d33d1", size = 487721, upload-time = "2026-01-21T22:17:30.398Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6e/40/c42ff9ded9f09ec9392879a8e6538a00b2dc185e834a3392917626255419/google_generativeai-0.8.5-py3-none-any.whl", hash = "sha256:22b420817fb263f8ed520b33285f45976d5b21e904da32b80d4fd20c055123a2", size = 155427, upload-time = "2025-04-17T00:40:00.67Z" },
]
[[package]]
name = "googleapis-common-protos"
version = "1.72.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "protobuf" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e5/7b/adfd75544c415c487b33061fe7ae526165241c1ea133f9a9125a56b39fd8/googleapis_common_protos-1.72.0.tar.gz", hash = "sha256:e55a601c1b32b52d7a3e65f43563e2aa61bcd737998ee672ac9b951cd49319f5", size = 147433, upload-time = "2025-11-06T18:29:24.087Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c4/ab/09169d5a4612a5f92490806649ac8d41e3ec9129c636754575b3553f4ea4/googleapis_common_protos-1.72.0-py3-none-any.whl", hash = "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038", size = 297515, upload-time = "2025-11-06T18:29:13.14Z" },
]
[[package]]
name = "grpcio"
version = "1.76.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b6/e0/318c1ce3ae5a17894d5791e87aea147587c9e702f24122cc7a5c8bbaeeb1/grpcio-1.76.0.tar.gz", hash = "sha256:7be78388d6da1a25c0d5ec506523db58b18be22d9c37d8d3a32c08be4987bd73", size = 12785182, upload-time = "2025-10-21T16:23:12.106Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/88/17/ff4795dc9a34b6aee6ec379f1b66438a3789cd1315aac0cbab60d92f74b3/grpcio-1.76.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:65a20de41e85648e00305c1bb09a3598f840422e522277641145a32d42dcefcc", size = 5840037, upload-time = "2025-10-21T16:20:25.069Z" },
{ url = "https://files.pythonhosted.org/packages/4e/ff/35f9b96e3fa2f12e1dcd58a4513a2e2294a001d64dec81677361b7040c9a/grpcio-1.76.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:40ad3afe81676fd9ec6d9d406eda00933f218038433980aa19d401490e46ecde", size = 11836482, upload-time = "2025-10-21T16:20:30.113Z" },
{ url = "https://files.pythonhosted.org/packages/3e/1c/8374990f9545e99462caacea5413ed783014b3b66ace49e35c533f07507b/grpcio-1.76.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:035d90bc79eaa4bed83f524331d55e35820725c9fbb00ffa1904d5550ed7ede3", size = 6407178, upload-time = "2025-10-21T16:20:32.733Z" },
{ url = "https://files.pythonhosted.org/packages/1e/77/36fd7d7c75a6c12542c90a6d647a27935a1ecaad03e0ffdb7c42db6b04d2/grpcio-1.76.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4215d3a102bd95e2e11b5395c78562967959824156af11fa93d18fdd18050990", size = 7075684, upload-time = "2025-10-21T16:20:35.435Z" },
{ url = "https://files.pythonhosted.org/packages/38/f7/e3cdb252492278e004722306c5a8935eae91e64ea11f0af3437a7de2e2b7/grpcio-1.76.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:49ce47231818806067aea3324d4bf13825b658ad662d3b25fada0bdad9b8a6af", size = 6611133, upload-time = "2025-10-21T16:20:37.541Z" },
{ url = "https://files.pythonhosted.org/packages/7e/20/340db7af162ccd20a0893b5f3c4a5d676af7b71105517e62279b5b61d95a/grpcio-1.76.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8cc3309d8e08fd79089e13ed4819d0af72aa935dd8f435a195fd152796752ff2", size = 7195507, upload-time = "2025-10-21T16:20:39.643Z" },
{ url = "https://files.pythonhosted.org/packages/10/f0/b2160addc1487bd8fa4810857a27132fb4ce35c1b330c2f3ac45d697b106/grpcio-1.76.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:971fd5a1d6e62e00d945423a567e42eb1fa678ba89072832185ca836a94daaa6", size = 8160651, upload-time = "2025-10-21T16:20:42.492Z" },
{ url = "https://files.pythonhosted.org/packages/2c/2c/ac6f98aa113c6ef111b3f347854e99ebb7fb9d8f7bb3af1491d438f62af4/grpcio-1.76.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9d9adda641db7207e800a7f089068f6f645959f2df27e870ee81d44701dd9db3", size = 7620568, upload-time = "2025-10-21T16:20:45.995Z" },
{ url = "https://files.pythonhosted.org/packages/90/84/7852f7e087285e3ac17a2703bc4129fafee52d77c6c82af97d905566857e/grpcio-1.76.0-cp310-cp310-win32.whl", hash = "sha256:063065249d9e7e0782d03d2bca50787f53bd0fb89a67de9a7b521c4a01f1989b", size = 3998879, upload-time = "2025-10-21T16:20:48.592Z" },
{ url = "https://files.pythonhosted.org/packages/10/30/d3d2adcbb6dd3ff59d6ac3df6ef830e02b437fb5c90990429fd180e52f30/grpcio-1.76.0-cp310-cp310-win_amd64.whl", hash = "sha256:a6ae758eb08088d36812dd5d9af7a9859c05b1e0f714470ea243694b49278e7b", size = 4706892, upload-time = "2025-10-21T16:20:50.697Z" },
{ url = "https://files.pythonhosted.org/packages/a0/00/8163a1beeb6971f66b4bbe6ac9457b97948beba8dd2fc8e1281dce7f79ec/grpcio-1.76.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:2e1743fbd7f5fa713a1b0a8ac8ebabf0ec980b5d8809ec358d488e273b9cf02a", size = 5843567, upload-time = "2025-10-21T16:20:52.829Z" },
{ url = "https://files.pythonhosted.org/packages/10/c1/934202f5cf335e6d852530ce14ddb0fef21be612ba9ecbbcbd4d748ca32d/grpcio-1.76.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:a8c2cf1209497cf659a667d7dea88985e834c24b7c3b605e6254cbb5076d985c", size = 11848017, upload-time = "2025-10-21T16:20:56.705Z" },
{ url = "https://files.pythonhosted.org/packages/11/0b/8dec16b1863d74af6eb3543928600ec2195af49ca58b16334972f6775663/grpcio-1.76.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:08caea849a9d3c71a542827d6df9d5a69067b0a1efbea8a855633ff5d9571465", size = 6412027, upload-time = "2025-10-21T16:20:59.3Z" },
{ url = "https://files.pythonhosted.org/packages/d7/64/7b9e6e7ab910bea9d46f2c090380bab274a0b91fb0a2fe9b0cd399fffa12/grpcio-1.76.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f0e34c2079d47ae9f6188211db9e777c619a21d4faba6977774e8fa43b085e48", size = 7075913, upload-time = "2025-10-21T16:21:01.645Z" },
{ url = "https://files.pythonhosted.org/packages/68/86/093c46e9546073cefa789bd76d44c5cb2abc824ca62af0c18be590ff13ba/grpcio-1.76.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8843114c0cfce61b40ad48df65abcfc00d4dba82eae8718fab5352390848c5da", size = 6615417, upload-time = "2025-10-21T16:21:03.844Z" },
{ url = "https://files.pythonhosted.org/packages/f7/b6/5709a3a68500a9c03da6fb71740dcdd5ef245e39266461a03f31a57036d8/grpcio-1.76.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8eddfb4d203a237da6f3cc8a540dad0517d274b5a1e9e636fd8d2c79b5c1d397", size = 7199683, upload-time = "2025-10-21T16:21:06.195Z" },
{ url = "https://files.pythonhosted.org/packages/91/d3/4b1f2bf16ed52ce0b508161df3a2d186e4935379a159a834cb4a7d687429/grpcio-1.76.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:32483fe2aab2c3794101c2a159070584e5db11d0aa091b2c0ea9c4fc43d0d749", size = 8163109, upload-time = "2025-10-21T16:21:08.498Z" },
{ url = "https://files.pythonhosted.org/packages/5c/61/d9043f95f5f4cf085ac5dd6137b469d41befb04bd80280952ffa2a4c3f12/grpcio-1.76.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dcfe41187da8992c5f40aa8c5ec086fa3672834d2be57a32384c08d5a05b4c00", size = 7626676, upload-time = "2025-10-21T16:21:10.693Z" },
{ url = "https://files.pythonhosted.org/packages/36/95/fd9a5152ca02d8881e4dd419cdd790e11805979f499a2e5b96488b85cf27/grpcio-1.76.0-cp311-cp311-win32.whl", hash = "sha256:2107b0c024d1b35f4083f11245c0e23846ae64d02f40b2b226684840260ed054", size = 3997688, upload-time = "2025-10-21T16:21:12.746Z" },
{ url = "https://files.pythonhosted.org/packages/60/9c/5c359c8d4c9176cfa3c61ecd4efe5affe1f38d9bae81e81ac7186b4c9cc8/grpcio-1.76.0-cp311-cp311-win_amd64.whl", hash = "sha256:522175aba7af9113c48ec10cc471b9b9bd4f6ceb36aeb4544a8e2c80ed9d252d", size = 4709315, upload-time = "2025-10-21T16:21:15.26Z" },
{ url = "https://files.pythonhosted.org/packages/bf/05/8e29121994b8d959ffa0afd28996d452f291b48cfc0875619de0bde2c50c/grpcio-1.76.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:81fd9652b37b36f16138611c7e884eb82e0cec137c40d3ef7c3f9b3ed00f6ed8", size = 5799718, upload-time = "2025-10-21T16:21:17.939Z" },
{ url = "https://files.pythonhosted.org/packages/d9/75/11d0e66b3cdf998c996489581bdad8900db79ebd83513e45c19548f1cba4/grpcio-1.76.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:04bbe1bfe3a68bbfd4e52402ab7d4eb59d72d02647ae2042204326cf4bbad280", size = 11825627, upload-time = "2025-10-21T16:21:20.466Z" },
{ url = "https://files.pythonhosted.org/packages/28/50/2f0aa0498bc188048f5d9504dcc5c2c24f2eb1a9337cd0fa09a61a2e75f0/grpcio-1.76.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d388087771c837cdb6515539f43b9d4bf0b0f23593a24054ac16f7a960be16f4", size = 6359167, upload-time = "2025-10-21T16:21:23.122Z" },
{ url = "https://files.pythonhosted.org/packages/66/e5/bbf0bb97d29ede1d59d6588af40018cfc345b17ce979b7b45424628dc8bb/grpcio-1.76.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:9f8f757bebaaea112c00dba718fc0d3260052ce714e25804a03f93f5d1c6cc11", size = 7044267, upload-time = "2025-10-21T16:21:25.995Z" },
{ url = "https://files.pythonhosted.org/packages/f5/86/f6ec2164f743d9609691115ae8ece098c76b894ebe4f7c94a655c6b03e98/grpcio-1.76.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:980a846182ce88c4f2f7e2c22c56aefd515daeb36149d1c897f83cf57999e0b6", size = 6573963, upload-time = "2025-10-21T16:21:28.631Z" },
{ url = "https://files.pythonhosted.org/packages/60/bc/8d9d0d8505feccfdf38a766d262c71e73639c165b311c9457208b56d92ae/grpcio-1.76.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f92f88e6c033db65a5ae3d97905c8fea9c725b63e28d5a75cb73b49bda5024d8", size = 7164484, upload-time = "2025-10-21T16:21:30.837Z" },
{ url = "https://files.pythonhosted.org/packages/67/e6/5d6c2fc10b95edf6df9b8f19cf10a34263b7fd48493936fffd5085521292/grpcio-1.76.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4baf3cbe2f0be3289eb68ac8ae771156971848bb8aaff60bad42005539431980", size = 8127777, upload-time = "2025-10-21T16:21:33.577Z" },
{ url = "https://files.pythonhosted.org/packages/3f/c8/dce8ff21c86abe025efe304d9e31fdb0deaaa3b502b6a78141080f206da0/grpcio-1.76.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:615ba64c208aaceb5ec83bfdce7728b80bfeb8be97562944836a7a0a9647d882", size = 7594014, upload-time = "2025-10-21T16:21:41.882Z" },
{ url = "https://files.pythonhosted.org/packages/e0/42/ad28191ebf983a5d0ecef90bab66baa5a6b18f2bfdef9d0a63b1973d9f75/grpcio-1.76.0-cp312-cp312-win32.whl", hash = "sha256:45d59a649a82df5718fd9527ce775fd66d1af35e6d31abdcdc906a49c6822958", size = 3984750, upload-time = "2025-10-21T16:21:44.006Z" },
{ url = "https://files.pythonhosted.org/packages/9e/00/7bd478cbb851c04a48baccaa49b75abaa8e4122f7d86da797500cccdd771/grpcio-1.76.0-cp312-cp312-win_amd64.whl", hash = "sha256:c088e7a90b6017307f423efbb9d1ba97a22aa2170876223f9709e9d1de0b5347", size = 4704003, upload-time = "2025-10-21T16:21:46.244Z" },
{ url = "https://files.pythonhosted.org/packages/fc/ed/71467ab770effc9e8cef5f2e7388beb2be26ed642d567697bb103a790c72/grpcio-1.76.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:26ef06c73eb53267c2b319f43e6634c7556ea37672029241a056629af27c10e2", size = 5807716, upload-time = "2025-10-21T16:21:48.475Z" },
{ url = "https://files.pythonhosted.org/packages/2c/85/c6ed56f9817fab03fa8a111ca91469941fb514e3e3ce6d793cb8f1e1347b/grpcio-1.76.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:45e0111e73f43f735d70786557dc38141185072d7ff8dc1829d6a77ac1471468", size = 11821522, upload-time = "2025-10-21T16:21:51.142Z" },
{ url = "https://files.pythonhosted.org/packages/ac/31/2b8a235ab40c39cbc141ef647f8a6eb7b0028f023015a4842933bc0d6831/grpcio-1.76.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:83d57312a58dcfe2a3a0f9d1389b299438909a02db60e2f2ea2ae2d8034909d3", size = 6362558, upload-time = "2025-10-21T16:21:54.213Z" },
{ url = "https://files.pythonhosted.org/packages/bd/64/9784eab483358e08847498ee56faf8ff6ea8e0a4592568d9f68edc97e9e9/grpcio-1.76.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3e2a27c89eb9ac3d81ec8835e12414d73536c6e620355d65102503064a4ed6eb", size = 7049990, upload-time = "2025-10-21T16:21:56.476Z" },
{ url = "https://files.pythonhosted.org/packages/2b/94/8c12319a6369434e7a184b987e8e9f3b49a114c489b8315f029e24de4837/grpcio-1.76.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61f69297cba3950a524f61c7c8ee12e55c486cb5f7db47ff9dcee33da6f0d3ae", size = 6575387, upload-time = "2025-10-21T16:21:59.051Z" },
{ url = "https://files.pythonhosted.org/packages/15/0f/f12c32b03f731f4a6242f771f63039df182c8b8e2cf8075b245b409259d4/grpcio-1.76.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a15c17af8839b6801d554263c546c69c4d7718ad4321e3166175b37eaacca77", size = 7166668, upload-time = "2025-10-21T16:22:02.049Z" },
{ url = "https://files.pythonhosted.org/packages/ff/2d/3ec9ce0c2b1d92dd59d1c3264aaec9f0f7c817d6e8ac683b97198a36ed5a/grpcio-1.76.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:25a18e9810fbc7e7f03ec2516addc116a957f8cbb8cbc95ccc80faa072743d03", size = 8124928, upload-time = "2025-10-21T16:22:04.984Z" },
{ url = "https://files.pythonhosted.org/packages/1a/74/fd3317be5672f4856bcdd1a9e7b5e17554692d3db9a3b273879dc02d657d/grpcio-1.76.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:931091142fd8cc14edccc0845a79248bc155425eee9a98b2db2ea4f00a235a42", size = 7589983, upload-time = "2025-10-21T16:22:07.881Z" },
{ url = "https://files.pythonhosted.org/packages/45/bb/ca038cf420f405971f19821c8c15bcbc875505f6ffadafe9ffd77871dc4c/grpcio-1.76.0-cp313-cp313-win32.whl", hash = "sha256:5e8571632780e08526f118f74170ad8d50fb0a48c23a746bef2a6ebade3abd6f", size = 3984727, upload-time = "2025-10-21T16:22:10.032Z" },
{ url = "https://files.pythonhosted.org/packages/41/80/84087dc56437ced7cdd4b13d7875e7439a52a261e3ab4e06488ba6173b0a/grpcio-1.76.0-cp313-cp313-win_amd64.whl", hash = "sha256:f9f7bd5faab55f47231ad8dba7787866b69f5e93bc306e3915606779bbfb4ba8", size = 4702799, upload-time = "2025-10-21T16:22:12.709Z" },
{ url = "https://files.pythonhosted.org/packages/b4/46/39adac80de49d678e6e073b70204091e76631e03e94928b9ea4ecf0f6e0e/grpcio-1.76.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:ff8a59ea85a1f2191a0ffcc61298c571bc566332f82e5f5be1b83c9d8e668a62", size = 5808417, upload-time = "2025-10-21T16:22:15.02Z" },
{ url = "https://files.pythonhosted.org/packages/9c/f5/a4531f7fb8b4e2a60b94e39d5d924469b7a6988176b3422487be61fe2998/grpcio-1.76.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:06c3d6b076e7b593905d04fdba6a0525711b3466f43b3400266f04ff735de0cd", size = 11828219, upload-time = "2025-10-21T16:22:17.954Z" },
{ url = "https://files.pythonhosted.org/packages/4b/1c/de55d868ed7a8bd6acc6b1d6ddc4aa36d07a9f31d33c912c804adb1b971b/grpcio-1.76.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd5ef5932f6475c436c4a55e4336ebbe47bd3272be04964a03d316bbf4afbcbc", size = 6367826, upload-time = "2025-10-21T16:22:20.721Z" },
{ url = "https://files.pythonhosted.org/packages/59/64/99e44c02b5adb0ad13ab3adc89cb33cb54bfa90c74770f2607eea629b86f/grpcio-1.76.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b331680e46239e090f5b3cead313cc772f6caa7d0fc8de349337563125361a4a", size = 7049550, upload-time = "2025-10-21T16:22:23.637Z" },
{ url = "https://files.pythonhosted.org/packages/43/28/40a5be3f9a86949b83e7d6a2ad6011d993cbe9b6bd27bea881f61c7788b6/grpcio-1.76.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2229ae655ec4e8999599469559e97630185fdd53ae1e8997d147b7c9b2b72cba", size = 6575564, upload-time = "2025-10-21T16:22:26.016Z" },
{ url = "https://files.pythonhosted.org/packages/4b/a9/1be18e6055b64467440208a8559afac243c66a8b904213af6f392dc2212f/grpcio-1.76.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:490fa6d203992c47c7b9e4a9d39003a0c2bcc1c9aa3c058730884bbbb0ee9f09", size = 7176236, upload-time = "2025-10-21T16:22:28.362Z" },
{ url = "https://files.pythonhosted.org/packages/0f/55/dba05d3fcc151ce6e81327541d2cc8394f442f6b350fead67401661bf041/grpcio-1.76.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:479496325ce554792dba6548fae3df31a72cef7bad71ca2e12b0e58f9b336bfc", size = 8125795, upload-time = "2025-10-21T16:22:31.075Z" },
{ url = "https://files.pythonhosted.org/packages/4a/45/122df922d05655f63930cf42c9e3f72ba20aadb26c100ee105cad4ce4257/grpcio-1.76.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c9b93f79f48b03ada57ea24725d83a30284a012ec27eab2cf7e50a550cbbbcc", size = 7592214, upload-time = "2025-10-21T16:22:33.831Z" },
{ url = "https://files.pythonhosted.org/packages/4a/6e/0b899b7f6b66e5af39e377055fb4a6675c9ee28431df5708139df2e93233/grpcio-1.76.0-cp314-cp314-win32.whl", hash = "sha256:747fa73efa9b8b1488a95d0ba1039c8e2dca0f741612d80415b1e1c560febf4e", size = 4062961, upload-time = "2025-10-21T16:22:36.468Z" },
{ url = "https://files.pythonhosted.org/packages/19/41/0b430b01a2eb38ee887f88c1f07644a1df8e289353b78e82b37ef988fb64/grpcio-1.76.0-cp314-cp314-win_amd64.whl", hash = "sha256:922fa70ba549fce362d2e2871ab542082d66e2aaf0c19480ea453905b01f384e", size = 4834462, upload-time = "2025-10-21T16:22:39.772Z" },
]
[[package]]
name = "grpcio-status"
version = "1.71.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "googleapis-common-protos" },
{ name = "grpcio" },
{ name = "protobuf" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fd/d1/b6e9877fedae3add1afdeae1f89d1927d296da9cf977eca0eb08fb8a460e/grpcio_status-1.71.2.tar.gz", hash = "sha256:c7a97e176df71cdc2c179cd1847d7fc86cca5832ad12e9798d7fed6b7a1aab50", size = 13677, upload-time = "2025-06-28T04:24:05.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/67/58/317b0134129b556a93a3b0afe00ee675b5657f0155509e22fcb853bafe2d/grpcio_status-1.71.2-py3-none-any.whl", hash = "sha256:803c98cb6a8b7dc6dbb785b1111aed739f241ab5e9da0bba96888aa74704cfd3", size = 14424, upload-time = "2025-06-28T04:23:42.136Z" },
{ url = "https://files.pythonhosted.org/packages/31/e5/384b1f383917b5f0ae92e28f47bc27b16e3d26cd9bacb25e9f8ecab3c8fe/google_genai-1.60.0-py3-none-any.whl", hash = "sha256:967338378ffecebec19a8ed90cf8797b26818bacbefd7846a9280beb1099f7f3", size = 719431, upload-time = "2026-01-21T22:17:28.086Z" },
]
[[package]]
@@ -518,18 +330,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httplib2"
version = "0.31.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyparsing" },
]
sdist = { url = "https://files.pythonhosted.org/packages/52/77/6653db69c1f7ecfe5e3f9726fdadc981794656fcd7d98c4209fecfea9993/httplib2-0.31.0.tar.gz", hash = "sha256:ac7ab497c50975147d4f7b1ade44becc7df2f8954d42b38b3d69c515f531135c", size = 250759, upload-time = "2025-09-11T12:16:03.403Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8c/a2/0d269db0f6163be503775dc8b6a6fa15820cc9fdc866f6ba608d86b721f2/httplib2-0.31.0-py3-none-any.whl", hash = "sha256:b9cd78abea9b4e43a7714c6e0f8b6b8561a6fc1e95d5dbd367f5bf0ef35f5d24", size = 91148, upload-time = "2025-09-11T12:16:01.803Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
@@ -547,11 +347,11 @@ wheels = [
[[package]]
name = "identify"
version = "2.6.15"
version = "2.6.16"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ff/e7/685de97986c916a6d93b3876139e00eef26ad5bbbd61925d670ae8013449/identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf", size = 99311, upload-time = "2025-10-02T17:43:40.631Z" }
sdist = { url = "https://files.pythonhosted.org/packages/5b/8d/e8b97e6bd3fb6fb271346f7981362f1e04d6a7463abd0de79e1fda17c067/identify-2.6.16.tar.gz", hash = "sha256:846857203b5511bbe94d5a352a48ef2359532bc8f6727b5544077a0dcfb24980", size = 99360, upload-time = "2026-01-12T18:58:58.201Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0f/1c/e5fd8f973d4f375adb21565739498e2e9a1e54c858a97b9a8ccfdc81da9b/identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757", size = 99183, upload-time = "2025-10-02T17:43:39.137Z" },
{ url = "https://files.pythonhosted.org/packages/b8/58/40fbbcefeda82364720eba5cf2270f98496bdfa19ea75b4cccae79c698e6/identify-2.6.16-py2.py3-none-any.whl", hash = "sha256:391ee4d77741d994189522896270b787aed8670389bfd60f326d677d64a6dfb0", size = 99202, upload-time = "2026-01-12T18:58:56.627Z" },
]
[[package]]
@@ -780,16 +580,16 @@ wheels = [
[[package]]
name = "nodeenv"
version = "1.9.1"
version = "1.10.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" }
sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" },
{ url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" },
]
[[package]]
name = "openai"
version = "2.11.0"
version = "2.15.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
@@ -801,9 +601,9 @@ dependencies = [
{ name = "tqdm" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f4/8c/aa6aea6072f985ace9d6515046b9088ff00c157f9654da0c7b1e129d9506/openai-2.11.0.tar.gz", hash = "sha256:b3da01d92eda31524930b6ec9d7167c535e843918d7ba8a76b1c38f1104f321e", size = 624540, upload-time = "2025-12-11T19:11:58.539Z" }
sdist = { url = "https://files.pythonhosted.org/packages/94/f4/4690ecb5d70023ce6bfcfeabfe717020f654bde59a775058ec6ac4692463/openai-2.15.0.tar.gz", hash = "sha256:42eb8cbb407d84770633f31bf727d4ffb4138711c670565a41663d9439174fba", size = 627383, upload-time = "2026-01-09T22:10:08.603Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/f1/d9251b565fce9f8daeb45611e3e0d2f7f248429e40908dcee3b6fe1b5944/openai-2.11.0-py3-none-any.whl", hash = "sha256:21189da44d2e3d027b08c7a920ba4454b8b7d6d30ae7e64d9de11dbe946d4faa", size = 1064131, upload-time = "2025-12-11T19:11:56.816Z" },
{ url = "https://files.pythonhosted.org/packages/b5/df/c306f7375d42bafb379934c2df4c2fa3964656c8c782bac75ee10c102818/openai-2.15.0-py3-none-any.whl", hash = "sha256:6ae23b932cd7230f7244e52954daa6602716d6b9bf235401a107af731baea6c3", size = 1067879, upload-time = "2026-01-09T22:10:06.446Z" },
]
[[package]]
@@ -817,7 +617,7 @@ wheels = [
[[package]]
name = "pre-commit"
version = "4.5.0"
version = "4.5.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cfgv" },
@@ -826,9 +626,9 @@ dependencies = [
{ name = "pyyaml" },
{ name = "virtualenv" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f4/9b/6a4ffb4ed980519da959e1cf3122fc6cb41211daa58dbae1c73c0e519a37/pre_commit-4.5.0.tar.gz", hash = "sha256:dc5a065e932b19fc1d4c653c6939068fe54325af8e741e74e88db4d28a4dd66b", size = 198428, upload-time = "2025-11-22T21:02:42.304Z" }
sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232, upload-time = "2025-12-16T21:14:33.552Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/c4/b2d28e9d2edf4f1713eb3c29307f1a63f3d67cf09bdda29715a36a68921a/pre_commit-4.5.0-py2.py3-none-any.whl", hash = "sha256:25e2ce09595174d9c97860a95609f9f852c0614ba602de3561e267547f2335e1", size = 226429, upload-time = "2025-11-22T21:02:40.836Z" },
{ url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" },
]
[[package]]
@@ -843,32 +643,6 @@ wheels = [
{ 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" },
]
[[package]]
name = "proto-plus"
version = "1.26.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "protobuf" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f4/ac/87285f15f7cce6d4a008f33f1757fb5a13611ea8914eb58c3d0d26243468/proto_plus-1.26.1.tar.gz", hash = "sha256:21a515a4c4c0088a773899e23c7bbade3d18f9c66c73edd4c7ee3816bc96a012", size = 56142, upload-time = "2025-03-10T15:54:38.843Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4e/6d/280c4c2ce28b1593a19ad5239c8b826871fc6ec275c21afc8e1820108039/proto_plus-1.26.1-py3-none-any.whl", hash = "sha256:13285478c2dcf2abb829db158e1047e2f1e8d63a077d94263c2b88b043c75a66", size = 50163, upload-time = "2025-03-10T15:54:37.335Z" },
]
[[package]]
name = "protobuf"
version = "5.29.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/43/29/d09e70352e4e88c9c7a198d5645d7277811448d76c23b00345670f7c8a38/protobuf-5.29.5.tar.gz", hash = "sha256:bc1463bafd4b0929216c35f437a8e28731a2b7fe3d98bb77a600efced5a15c84", size = 425226, upload-time = "2025-05-28T23:51:59.82Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5f/11/6e40e9fc5bba02988a214c07cf324595789ca7820160bfd1f8be96e48539/protobuf-5.29.5-cp310-abi3-win32.whl", hash = "sha256:3f1c6468a2cfd102ff4703976138844f78ebd1fb45f49011afc5139e9e283079", size = 422963, upload-time = "2025-05-28T23:51:41.204Z" },
{ url = "https://files.pythonhosted.org/packages/81/7f/73cefb093e1a2a7c3ffd839e6f9fcafb7a427d300c7f8aef9c64405d8ac6/protobuf-5.29.5-cp310-abi3-win_amd64.whl", hash = "sha256:3f76e3a3675b4a4d867b52e4a5f5b78a2ef9565549d4037e06cf7b0942b1d3fc", size = 434818, upload-time = "2025-05-28T23:51:44.297Z" },
{ url = "https://files.pythonhosted.org/packages/dd/73/10e1661c21f139f2c6ad9b23040ff36fee624310dc28fba20d33fdae124c/protobuf-5.29.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:e38c5add5a311f2a6eb0340716ef9b039c1dfa428b28f25a7838ac329204a671", size = 418091, upload-time = "2025-05-28T23:51:45.907Z" },
{ url = "https://files.pythonhosted.org/packages/6c/04/98f6f8cf5b07ab1294c13f34b4e69b3722bb609c5b701d6c169828f9f8aa/protobuf-5.29.5-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:fa18533a299d7ab6c55a238bf8629311439995f2e7eca5caaff08663606e9015", size = 319824, upload-time = "2025-05-28T23:51:47.545Z" },
{ url = "https://files.pythonhosted.org/packages/85/e4/07c80521879c2d15f321465ac24c70efe2381378c00bf5e56a0f4fbac8cd/protobuf-5.29.5-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:63848923da3325e1bf7e9003d680ce6e14b07e55d0473253a690c3a8b8fd6e61", size = 319942, upload-time = "2025-05-28T23:51:49.11Z" },
{ url = "https://files.pythonhosted.org/packages/7e/cc/7e77861000a0691aeea8f4566e5d3aa716f2b1dece4a24439437e41d3d25/protobuf-5.29.5-py3-none-any.whl", hash = "sha256:6cf42630262c59b2d8de33954443d94b746c952b01434fc58a417fdbd2e84bd5", size = 172823, upload-time = "2025-05-28T23:51:58.157Z" },
]
[[package]]
name = "pyasn1"
version = "0.6.1"
@@ -1032,15 +806,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
[[package]]
name = "pyparsing"
version = "3.2.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f2/a5/181488fc2b9d093e3972d2a472855aae8a03f000592dbfce716a512b3359/pyparsing-3.2.5.tar.gz", hash = "sha256:2df8d5b7b2802ef88e8d016a2eb9c7aeaa923529cd251ed0fe4608275d4105b6", size = 1099274, upload-time = "2025-09-21T04:11:06.277Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/10/5e/1aa9a93198c6b64513c9d7752de7422c06402de6600a8767da1524f9570b/pyparsing-3.2.5-py3-none-any.whl", hash = "sha256:e38a4f02064cf41fe6593d328d0512495ad1f3d8a91c4f73fc401b3079a59a5e", size = 113890, upload-time = "2025-09-21T04:11:04.117Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
@@ -1159,28 +924,28 @@ wheels = [
[[package]]
name = "ruff"
version = "0.14.9"
version = "0.14.13"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f6/1b/ab712a9d5044435be8e9a2beb17cbfa4c241aa9b5e4413febac2a8b79ef2/ruff-0.14.9.tar.gz", hash = "sha256:35f85b25dd586381c0cc053f48826109384c81c00ad7ef1bd977bfcc28119d5b", size = 5809165, upload-time = "2025-12-11T21:39:47.381Z" }
sdist = { url = "https://files.pythonhosted.org/packages/50/0a/1914efb7903174b381ee2ffeebb4253e729de57f114e63595114c8ca451f/ruff-0.14.13.tar.gz", hash = "sha256:83cd6c0763190784b99650a20fec7633c59f6ebe41c5cc9d45ee42749563ad47", size = 6059504, upload-time = "2026-01-15T20:15:16.918Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b8/1c/d1b1bba22cffec02351c78ab9ed4f7d7391876e12720298448b29b7229c1/ruff-0.14.9-py3-none-linux_armv6l.whl", hash = "sha256:f1ec5de1ce150ca6e43691f4a9ef5c04574ad9ca35c8b3b0e18877314aba7e75", size = 13576541, upload-time = "2025-12-11T21:39:14.806Z" },
{ url = "https://files.pythonhosted.org/packages/94/ab/ffe580e6ea1fca67f6337b0af59fc7e683344a43642d2d55d251ff83ceae/ruff-0.14.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ed9d7417a299fc6030b4f26333bf1117ed82a61ea91238558c0268c14e00d0c2", size = 13779363, upload-time = "2025-12-11T21:39:20.29Z" },
{ url = "https://files.pythonhosted.org/packages/7d/f8/2be49047f929d6965401855461e697ab185e1a6a683d914c5c19c7962d9e/ruff-0.14.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d5dc3473c3f0e4a1008d0ef1d75cee24a48e254c8bed3a7afdd2b4392657ed2c", size = 12925292, upload-time = "2025-12-11T21:39:38.757Z" },
{ url = "https://files.pythonhosted.org/packages/9e/e9/08840ff5127916bb989c86f18924fd568938b06f58b60e206176f327c0fe/ruff-0.14.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84bf7c698fc8f3cb8278830fb6b5a47f9bcc1ed8cb4f689b9dd02698fa840697", size = 13362894, upload-time = "2025-12-11T21:39:02.524Z" },
{ url = "https://files.pythonhosted.org/packages/31/1c/5b4e8e7750613ef43390bb58658eaf1d862c0cc3352d139cd718a2cea164/ruff-0.14.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:aa733093d1f9d88a5d98988d8834ef5d6f9828d03743bf5e338bf980a19fce27", size = 13311482, upload-time = "2025-12-11T21:39:17.51Z" },
{ url = "https://files.pythonhosted.org/packages/5b/3a/459dce7a8cb35ba1ea3e9c88f19077667a7977234f3b5ab197fad240b404/ruff-0.14.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a1cfb04eda979b20c8c19550c8b5f498df64ff8da151283311ce3199e8b3648", size = 14016100, upload-time = "2025-12-11T21:39:41.948Z" },
{ url = "https://files.pythonhosted.org/packages/a6/31/f064f4ec32524f9956a0890fc6a944e5cf06c63c554e39957d208c0ffc45/ruff-0.14.9-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:1e5cb521e5ccf0008bd74d5595a4580313844a42b9103b7388eca5a12c970743", size = 15477729, upload-time = "2025-12-11T21:39:23.279Z" },
{ url = "https://files.pythonhosted.org/packages/7a/6d/f364252aad36ccd443494bc5f02e41bf677f964b58902a17c0b16c53d890/ruff-0.14.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd429a8926be6bba4befa8cdcf3f4dd2591c413ea5066b1e99155ed245ae42bb", size = 15122386, upload-time = "2025-12-11T21:39:33.125Z" },
{ url = "https://files.pythonhosted.org/packages/20/02/e848787912d16209aba2799a4d5a1775660b6a3d0ab3944a4ccc13e64a02/ruff-0.14.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ab208c1b7a492e37caeaf290b1378148f75e13c2225af5d44628b95fd7834273", size = 14497124, upload-time = "2025-12-11T21:38:59.33Z" },
{ url = "https://files.pythonhosted.org/packages/f3/51/0489a6a5595b7760b5dbac0dd82852b510326e7d88d51dbffcd2e07e3ff3/ruff-0.14.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:72034534e5b11e8a593f517b2f2f2b273eb68a30978c6a2d40473ad0aaa4cb4a", size = 14195343, upload-time = "2025-12-11T21:39:44.866Z" },
{ url = "https://files.pythonhosted.org/packages/f6/53/3bb8d2fa73e4c2f80acc65213ee0830fa0c49c6479313f7a68a00f39e208/ruff-0.14.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:712ff04f44663f1b90a1195f51525836e3413c8a773574a7b7775554269c30ed", size = 14346425, upload-time = "2025-12-11T21:39:05.927Z" },
{ url = "https://files.pythonhosted.org/packages/ad/04/bdb1d0ab876372da3e983896481760867fc84f969c5c09d428e8f01b557f/ruff-0.14.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a111fee1db6f1d5d5810245295527cda1d367c5aa8f42e0fca9a78ede9b4498b", size = 13258768, upload-time = "2025-12-11T21:39:08.691Z" },
{ url = "https://files.pythonhosted.org/packages/40/d9/8bf8e1e41a311afd2abc8ad12be1b6c6c8b925506d9069b67bb5e9a04af3/ruff-0.14.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8769efc71558fecc25eb295ddec7d1030d41a51e9dcf127cbd63ec517f22d567", size = 13326939, upload-time = "2025-12-11T21:39:53.842Z" },
{ url = "https://files.pythonhosted.org/packages/f4/56/a213fa9edb6dd849f1cfbc236206ead10913693c72a67fb7ddc1833bf95d/ruff-0.14.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:347e3bf16197e8a2de17940cd75fd6491e25c0aa7edf7d61aa03f146a1aa885a", size = 13578888, upload-time = "2025-12-11T21:39:35.988Z" },
{ url = "https://files.pythonhosted.org/packages/33/09/6a4a67ffa4abae6bf44c972a4521337ffce9cbc7808faadede754ef7a79c/ruff-0.14.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7715d14e5bccf5b660f54516558aa94781d3eb0838f8e706fb60e3ff6eff03a8", size = 14314473, upload-time = "2025-12-11T21:39:50.78Z" },
{ url = "https://files.pythonhosted.org/packages/12/0d/15cc82da5d83f27a3c6b04f3a232d61bc8c50d38a6cd8da79228e5f8b8d6/ruff-0.14.9-py3-none-win32.whl", hash = "sha256:df0937f30aaabe83da172adaf8937003ff28172f59ca9f17883b4213783df197", size = 13202651, upload-time = "2025-12-11T21:39:26.628Z" },
{ url = "https://files.pythonhosted.org/packages/32/f7/c78b060388eefe0304d9d42e68fab8cffd049128ec466456cef9b8d4f06f/ruff-0.14.9-py3-none-win_amd64.whl", hash = "sha256:c0b53a10e61df15a42ed711ec0bda0c582039cf6c754c49c020084c55b5b0bc2", size = 14702079, upload-time = "2025-12-11T21:39:11.954Z" },
{ url = "https://files.pythonhosted.org/packages/26/09/7a9520315decd2334afa65ed258fed438f070e31f05a2e43dd480a5e5911/ruff-0.14.9-py3-none-win_arm64.whl", hash = "sha256:8e821c366517a074046d92f0e9213ed1c13dbc5b37a7fc20b07f79b64d62cc84", size = 13744730, upload-time = "2025-12-11T21:39:29.659Z" },
{ url = "https://files.pythonhosted.org/packages/c3/ae/0deefbc65ca74b0ab1fd3917f94dc3b398233346a74b8bbb0a916a1a6bf6/ruff-0.14.13-py3-none-linux_armv6l.whl", hash = "sha256:76f62c62cd37c276cb03a275b198c7c15bd1d60c989f944db08a8c1c2dbec18b", size = 13062418, upload-time = "2026-01-15T20:14:50.779Z" },
{ url = "https://files.pythonhosted.org/packages/47/df/5916604faa530a97a3c154c62a81cb6b735c0cb05d1e26d5ad0f0c8ac48a/ruff-0.14.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:914a8023ece0528d5cc33f5a684f5f38199bbb566a04815c2c211d8f40b5d0ed", size = 13442344, upload-time = "2026-01-15T20:15:07.94Z" },
{ url = "https://files.pythonhosted.org/packages/4c/f3/e0e694dd69163c3a1671e102aa574a50357536f18a33375050334d5cd517/ruff-0.14.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d24899478c35ebfa730597a4a775d430ad0d5631b8647a3ab368c29b7e7bd063", size = 12354720, upload-time = "2026-01-15T20:15:09.854Z" },
{ url = "https://files.pythonhosted.org/packages/c3/e8/67f5fcbbaee25e8fc3b56cc33e9892eca7ffe09f773c8e5907757a7e3bdb/ruff-0.14.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9aaf3870f14d925bbaf18b8a2347ee0ae7d95a2e490e4d4aea6813ed15ebc80e", size = 12774493, upload-time = "2026-01-15T20:15:20.908Z" },
{ url = "https://files.pythonhosted.org/packages/6b/ce/d2e9cb510870b52a9565d885c0d7668cc050e30fa2c8ac3fb1fda15c083d/ruff-0.14.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac5b7f63dd3b27cc811850f5ffd8fff845b00ad70e60b043aabf8d6ecc304e09", size = 12815174, upload-time = "2026-01-15T20:15:05.74Z" },
{ url = "https://files.pythonhosted.org/packages/88/00/c38e5da58beebcf4fa32d0ddd993b63dfacefd02ab7922614231330845bf/ruff-0.14.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:78d2b1097750d90ba82ce4ba676e85230a0ed694178ca5e61aa9b459970b3eb9", size = 13680909, upload-time = "2026-01-15T20:15:14.537Z" },
{ url = "https://files.pythonhosted.org/packages/61/61/cd37c9dd5bd0a3099ba79b2a5899ad417d8f3b04038810b0501a80814fd7/ruff-0.14.13-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:7d0bf87705acbbcb8d4c24b2d77fbb73d40210a95c3903b443cd9e30824a5032", size = 15144215, upload-time = "2026-01-15T20:15:22.886Z" },
{ url = "https://files.pythonhosted.org/packages/56/8a/85502d7edbf98c2df7b8876f316c0157359165e16cdf98507c65c8d07d3d/ruff-0.14.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a3eb5da8e2c9e9f13431032fdcbe7681de9ceda5835efee3269417c13f1fed5c", size = 14706067, upload-time = "2026-01-15T20:14:48.271Z" },
{ url = "https://files.pythonhosted.org/packages/7e/2f/de0df127feb2ee8c1e54354dc1179b4a23798f0866019528c938ba439aca/ruff-0.14.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:642442b42957093811cd8d2140dfadd19c7417030a7a68cf8d51fcdd5f217427", size = 14133916, upload-time = "2026-01-15T20:14:57.357Z" },
{ url = "https://files.pythonhosted.org/packages/0d/77/9b99686bb9fe07a757c82f6f95e555c7a47801a9305576a9c67e0a31d280/ruff-0.14.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4acdf009f32b46f6e8864af19cbf6841eaaed8638e65c8dac845aea0d703c841", size = 13859207, upload-time = "2026-01-15T20:14:55.111Z" },
{ url = "https://files.pythonhosted.org/packages/7d/46/2bdcb34a87a179a4d23022d818c1c236cb40e477faf0d7c9afb6813e5876/ruff-0.14.13-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:591a7f68860ea4e003917d19b5c4f5ac39ff558f162dc753a2c5de897fd5502c", size = 14043686, upload-time = "2026-01-15T20:14:52.841Z" },
{ url = "https://files.pythonhosted.org/packages/1a/a9/5c6a4f56a0512c691cf143371bcf60505ed0f0860f24a85da8bd123b2bf1/ruff-0.14.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:774c77e841cc6e046fc3e91623ce0903d1cd07e3a36b1a9fe79b81dab3de506b", size = 12663837, upload-time = "2026-01-15T20:15:18.921Z" },
{ url = "https://files.pythonhosted.org/packages/fe/bb/b920016ece7651fa7fcd335d9d199306665486694d4361547ccb19394c44/ruff-0.14.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:61f4e40077a1248436772bb6512db5fc4457fe4c49e7a94ea7c5088655dd21ae", size = 12805867, upload-time = "2026-01-15T20:14:59.272Z" },
{ url = "https://files.pythonhosted.org/packages/7d/b3/0bd909851e5696cd21e32a8fc25727e5f58f1934b3596975503e6e85415c/ruff-0.14.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:6d02f1428357fae9e98ac7aa94b7e966fd24151088510d32cf6f902d6c09235e", size = 13208528, upload-time = "2026-01-15T20:15:03.732Z" },
{ url = "https://files.pythonhosted.org/packages/3b/3b/e2d94cb613f6bbd5155a75cbe072813756363eba46a3f2177a1fcd0cd670/ruff-0.14.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e399341472ce15237be0c0ae5fbceca4b04cd9bebab1a2b2c979e015455d8f0c", size = 13929242, upload-time = "2026-01-15T20:15:11.918Z" },
{ url = "https://files.pythonhosted.org/packages/6a/c5/abd840d4132fd51a12f594934af5eba1d5d27298a6f5b5d6c3be45301caf/ruff-0.14.13-py3-none-win32.whl", hash = "sha256:ef720f529aec113968b45dfdb838ac8934e519711da53a0456038a0efecbd680", size = 12919024, upload-time = "2026-01-15T20:14:43.647Z" },
{ url = "https://files.pythonhosted.org/packages/c2/55/6384b0b8ce731b6e2ade2b5449bf07c0e4c31e8a2e68ea65b3bafadcecc5/ruff-0.14.13-py3-none-win_amd64.whl", hash = "sha256:6070bd026e409734b9257e03e3ef18c6e1a216f0435c6751d7a8ec69cb59abef", size = 14097887, upload-time = "2026-01-15T20:15:01.48Z" },
{ url = "https://files.pythonhosted.org/packages/4d/e1/7348090988095e4e39560cfc2f7555b1b2a7357deba19167b600fdf5215d/ruff-0.14.13-py3-none-win_arm64.whl", hash = "sha256:7ab819e14f1ad9fe39f246cfcc435880ef7a9390d81a2b6ac7e01039083dd247", size = 13080224, upload-time = "2026-01-15T20:14:45.853Z" },
]
[[package]]
@@ -1201,6 +966,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
]
[[package]]
name = "tenacity"
version = "9.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/0a/d4/2b0cd0fe285e14b36db076e78c93766ff1d529d70408bd1d2a5a84f1d929/tenacity-9.1.2.tar.gz", hash = "sha256:1169d376c297e7de388d18b4481760d478b0e99a777cad3a9c86e556f4b697cb", size = 48036, upload-time = "2025-04-02T08:25:09.966Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/30/643397144bfbfec6f6ef821f36f33e57d35946c44a2352d3c9f0ae847619/tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138", size = 28248, upload-time = "2025-04-02T08:25:07.678Z" },
]
[[package]]
name = "toml"
version = "0.10.2"
@@ -1224,7 +998,7 @@ wheels = [
[[package]]
name = "typer"
version = "0.20.0"
version = "0.21.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
@@ -1232,9 +1006,9 @@ dependencies = [
{ name = "shellingham" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8f/28/7c85c8032b91dbe79725b6f17d2fffc595dff06a35c7a30a37bef73a1ab4/typer-0.20.0.tar.gz", hash = "sha256:1aaf6494031793e4876fb0bacfa6a912b551cf43c1e63c800df8b1a866720c37", size = 106492, upload-time = "2025-10-20T17:03:49.445Z" }
sdist = { url = "https://files.pythonhosted.org/packages/36/bf/8825b5929afd84d0dabd606c67cd57b8388cb3ec385f7ef19c5cc2202069/typer-0.21.1.tar.gz", hash = "sha256:ea835607cd752343b6b2b7ce676893e5a0324082268b48f27aa058bdb7d2145d", size = 110371, upload-time = "2026-01-06T11:21:10.989Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/64/7713ffe4b5983314e9d436a90d5bd4f63b6054e2aca783a3cfc44cb95bbf/typer-0.20.0-py3-none-any.whl", hash = "sha256:5b463df6793ec1dca6213a3cf4c0f03bc6e322ac5e16e13ddd622a889489784a", size = 47028, upload-time = "2025-10-20T17:03:47.617Z" },
{ url = "https://files.pythonhosted.org/packages/a0/1d/d9257dd49ff2ca23ea5f132edf1281a0c4f9de8a762b9ae399b670a59235/typer-0.21.1-py3-none-any.whl", hash = "sha256:7985e89081c636b88d172c2ee0cfe33c253160994d47bdfdc302defd7d1f1d01", size = 47381, upload-time = "2026-01-06T11:21:09.824Z" },
]
[[package]]
@@ -1258,27 +1032,18 @@ wheels = [
{ 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" },
]
[[package]]
name = "uritemplate"
version = "4.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/98/60/f174043244c5306c9988380d2cb10009f91563fc4b31293d27e17201af56/uritemplate-4.2.0.tar.gz", hash = "sha256:480c2ed180878955863323eea31b0ede668795de182617fef9c6ca09e6ec9d0e", size = 33267, upload-time = "2025-06-02T15:12:06.318Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a9/99/3ae339466c9183ea5b8ae87b34c0b897eda475d2aec2307cae60e5cd4f29/uritemplate-4.2.0-py3-none-any.whl", hash = "sha256:962201ba1c4edcab02e60f9a0d3821e82dfc5d2d6662a21abd533879bdb8a686", size = 11488, upload-time = "2025-06-02T15:12:03.405Z" },
]
[[package]]
name = "urllib3"
version = "2.6.2"
version = "2.6.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1e/24/a2a2ed9addd907787d7aa0355ba36a6cadf1768b934c652ea78acbd59dcd/urllib3-2.6.2.tar.gz", hash = "sha256:016f9c98bb7e98085cb2b4b17b87d2c702975664e4f060c6532e64d1c1a5e797", size = 432930, upload-time = "2025-12-11T15:56:40.252Z" }
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6d/b9/4095b668ea3678bf6a0af005527f39de12fb026516fb3df17495a733b7f8/urllib3-2.6.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd", size = 131182, upload-time = "2025-12-11T15:56:38.584Z" },
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
]
[[package]]
name = "virtualenv"
version = "20.35.4"
version = "20.36.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "distlib" },
@@ -1286,9 +1051,9 @@ dependencies = [
{ name = "platformdirs" },
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/20/28/e6f1a6f655d620846bd9df527390ecc26b3805a0c5989048c210e22c5ca9/virtualenv-20.35.4.tar.gz", hash = "sha256:643d3914d73d3eeb0c552cbb12d7e82adf0e504dbf86a3182f8771a153a1971c", size = 6028799, upload-time = "2025-10-29T06:57:40.511Z" }
sdist = { url = "https://files.pythonhosted.org/packages/aa/a3/4d310fa5f00863544e1d0f4de93bddec248499ccf97d4791bc3122c9d4f3/virtualenv-20.36.1.tar.gz", hash = "sha256:8befb5c81842c641f8ee658481e42641c68b5eab3521d8e092d18320902466ba", size = 6032239, upload-time = "2026-01-09T18:21:01.296Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/79/0c/c05523fa3181fdf0c9c52a6ba91a23fbf3246cc095f26f6516f9c60e6771/virtualenv-20.35.4-py3-none-any.whl", hash = "sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b", size = 6005095, upload-time = "2025-10-29T06:57:37.598Z" },
{ url = "https://files.pythonhosted.org/packages/6a/2a/dc2228b2888f51192c7dc766106cd475f1b768c10caaf9727659726f7391/virtualenv-20.36.1-py3-none-any.whl", hash = "sha256:575a8d6b124ef88f6f51d56d656132389f961062a9177016a50e4f507bbcc19f", size = 6008258, upload-time = "2026-01-09T18:20:59.425Z" },
]
[[package]]
@@ -1299,3 +1064,62 @@ sdist = { url = "https://files.pythonhosted.org/packages/24/30/6b0809f4510673dc7
wheels = [
{ url = "https://files.pythonhosted.org/packages/af/b5/123f13c975e9f27ab9c0770f514345bd406d0e8d3b7a0723af9d43f710af/wcwidth-0.2.14-py2.py3-none-any.whl", hash = "sha256:a7bb560c8aee30f9957e5f9895805edd20602f2d7f720186dfd906e82b4982e1", size = 37286, upload-time = "2025-09-22T16:29:51.641Z" },
]
[[package]]
name = "websockets"
version = "15.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/da/6462a9f510c0c49837bbc9345aca92d767a56c1fb2939e1579df1e1cdcf7/websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b", size = 175423, upload-time = "2025-03-05T20:01:35.363Z" },
{ url = "https://files.pythonhosted.org/packages/1c/9f/9d11c1a4eb046a9e106483b9ff69bce7ac880443f00e5ce64261b47b07e7/websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205", size = 173080, upload-time = "2025-03-05T20:01:37.304Z" },
{ url = "https://files.pythonhosted.org/packages/d5/4f/b462242432d93ea45f297b6179c7333dd0402b855a912a04e7fc61c0d71f/websockets-15.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5756779642579d902eed757b21b0164cd6fe338506a8083eb58af5c372e39d9a", size = 173329, upload-time = "2025-03-05T20:01:39.668Z" },
{ url = "https://files.pythonhosted.org/packages/6e/0c/6afa1f4644d7ed50284ac59cc70ef8abd44ccf7d45850d989ea7310538d0/websockets-15.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdfe3e2a29e4db3659dbd5bbf04560cea53dd9610273917799f1cde46aa725e", size = 182312, upload-time = "2025-03-05T20:01:41.815Z" },
{ url = "https://files.pythonhosted.org/packages/dd/d4/ffc8bd1350b229ca7a4db2a3e1c482cf87cea1baccd0ef3e72bc720caeec/websockets-15.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c2529b320eb9e35af0fa3016c187dffb84a3ecc572bcee7c3ce302bfeba52bf", size = 181319, upload-time = "2025-03-05T20:01:43.967Z" },
{ url = "https://files.pythonhosted.org/packages/97/3a/5323a6bb94917af13bbb34009fac01e55c51dfde354f63692bf2533ffbc2/websockets-15.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac1e5c9054fe23226fb11e05a6e630837f074174c4c2f0fe442996112a6de4fb", size = 181631, upload-time = "2025-03-05T20:01:46.104Z" },
{ url = "https://files.pythonhosted.org/packages/a6/cc/1aeb0f7cee59ef065724041bb7ed667b6ab1eeffe5141696cccec2687b66/websockets-15.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5df592cd503496351d6dc14f7cdad49f268d8e618f80dce0cd5a36b93c3fc08d", size = 182016, upload-time = "2025-03-05T20:01:47.603Z" },
{ url = "https://files.pythonhosted.org/packages/79/f9/c86f8f7af208e4161a7f7e02774e9d0a81c632ae76db2ff22549e1718a51/websockets-15.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a34631031a8f05657e8e90903e656959234f3a04552259458aac0b0f9ae6fd9", size = 181426, upload-time = "2025-03-05T20:01:48.949Z" },
{ url = "https://files.pythonhosted.org/packages/c7/b9/828b0bc6753db905b91df6ae477c0b14a141090df64fb17f8a9d7e3516cf/websockets-15.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d00075aa65772e7ce9e990cab3ff1de702aa09be3940d1dc88d5abf1ab8a09c", size = 181360, upload-time = "2025-03-05T20:01:50.938Z" },
{ url = "https://files.pythonhosted.org/packages/89/fb/250f5533ec468ba6327055b7d98b9df056fb1ce623b8b6aaafb30b55d02e/websockets-15.0.1-cp310-cp310-win32.whl", hash = "sha256:1234d4ef35db82f5446dca8e35a7da7964d02c127b095e172e54397fb6a6c256", size = 176388, upload-time = "2025-03-05T20:01:52.213Z" },
{ url = "https://files.pythonhosted.org/packages/1c/46/aca7082012768bb98e5608f01658ff3ac8437e563eca41cf068bd5849a5e/websockets-15.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:39c1fec2c11dc8d89bba6b2bf1556af381611a173ac2b511cf7231622058af41", size = 176830, upload-time = "2025-03-05T20:01:53.922Z" },
{ url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" },
{ url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" },
{ url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" },
{ url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" },
{ url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" },
{ url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" },
{ url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" },
{ url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" },
{ url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" },
{ url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" },
{ url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" },
{ url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" },
{ url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" },
{ url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" },
{ url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" },
{ url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" },
{ url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" },
{ url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" },
{ url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" },
{ url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" },
{ url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" },
{ url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" },
{ url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" },
{ url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" },
{ url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" },
{ url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" },
{ url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" },
{ url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" },
{ url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" },
{ url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" },
{ url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" },
{ url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/d40f779fa16f74d3468357197af8d6ad07e7c5a27ea1ca74ceb38986f77a/websockets-15.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0c9e74d766f2818bb95f84c25be4dea09841ac0f734d1966f415e4edfc4ef1c3", size = 173109, upload-time = "2025-03-05T20:03:17.769Z" },
{ url = "https://files.pythonhosted.org/packages/bc/cd/5b887b8585a593073fd92f7c23ecd3985cd2c3175025a91b0d69b0551372/websockets-15.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1009ee0c7739c08a0cd59de430d6de452a55e42d6b522de7aa15e6f67db0b8e1", size = 173343, upload-time = "2025-03-05T20:03:19.094Z" },
{ url = "https://files.pythonhosted.org/packages/fe/ae/d34f7556890341e900a95acf4886833646306269f899d58ad62f588bf410/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76d1f20b1c7a2fa82367e04982e708723ba0e7b8d43aa643d3dcd404d74f1475", size = 174599, upload-time = "2025-03-05T20:03:21.1Z" },
{ url = "https://files.pythonhosted.org/packages/71/e6/5fd43993a87db364ec60fc1d608273a1a465c0caba69176dd160e197ce42/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f29d80eb9a9263b8d109135351caf568cc3f80b9928bccde535c235de55c22d9", size = 174207, upload-time = "2025-03-05T20:03:23.221Z" },
{ url = "https://files.pythonhosted.org/packages/2b/fb/c492d6daa5ec067c2988ac80c61359ace5c4c674c532985ac5a123436cec/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b359ed09954d7c18bbc1680f380c7301f92c60bf924171629c5db97febb12f04", size = 174155, upload-time = "2025-03-05T20:03:25.321Z" },
{ url = "https://files.pythonhosted.org/packages/68/a1/dcb68430b1d00b698ae7a7e0194433bce4f07ded185f0ee5fb21e2a2e91e/websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122", size = 176884, upload-time = "2025-03-05T20:03:27.934Z" },
{ url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" },
]