Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a86dcc93ee | ||
|
|
456040ed2b | ||
|
|
df1d1765d1 | ||
|
|
09ba5e9644 | ||
|
|
850e88972e | ||
|
|
96e776f7cc | ||
|
|
f70b5208ec | ||
|
|
e2ec269f16 | ||
|
|
90ca0c0f59 | ||
|
|
9781523e8e | ||
|
|
96b2499ecf | ||
|
|
4f8e358a60 | ||
|
|
72c57f0d33 | ||
|
|
0b023482d7 | ||
|
|
8069895bc7 | ||
|
|
d991af9c47 | ||
|
|
d8fcf0849a | ||
|
|
aad3703ea3 | ||
|
|
a8b48e9f07 | ||
|
|
7016cff3a7 | ||
|
|
55ebbaef15 | ||
|
|
80f68a6ccd | ||
|
|
fd3ce03484 | ||
|
|
51d2b7549f | ||
|
|
5660e40eb4 | ||
|
|
c6914e2139 | ||
|
|
4f0a055216 | ||
|
|
16c6f70057 | ||
|
|
3daf798dcd | ||
|
|
d6f2452d21 | ||
|
|
59aa40bdcf | ||
|
|
8fdc346913 | ||
|
|
cdca283cd2 | ||
|
|
e30d2d854d | ||
|
|
e77472fb0f | ||
|
|
e3dc6729dc | ||
|
|
c7c1da8459 | ||
|
|
b0ad273f87 | ||
|
|
dcecfec258 | ||
|
|
f21ba55d6d | ||
|
|
48ae3be0d4 | ||
|
|
5682a51d97 | ||
|
|
3007623f4c | ||
|
|
f2e5097e7f | ||
|
|
d95c88b240 | ||
|
|
dc2b00651f | ||
|
|
2c9c5ed22d | ||
|
|
8a42b547ec | ||
|
|
d7fd658b98 | ||
|
|
34a7635a6d | ||
|
|
fdd38d5a49 | ||
|
|
aa4fc8018e | ||
|
|
763537a324 | ||
|
|
867156b65a | ||
|
|
0e50e5f5b0 | ||
|
|
3fde0941eb | ||
|
|
5b7303c2a6 | ||
|
|
6a8e92b4fc | ||
|
|
89bfe85002 | ||
|
|
5515ca5633 | ||
|
|
e7385c55fb | ||
|
|
52cf0d4a7b | ||
|
|
ceebf728ca | ||
|
|
277cb324ba | ||
|
|
8c98b64495 | ||
|
|
b9899a774f | ||
|
|
96c8dd83b1 | ||
|
|
92e9f5ac19 | ||
|
|
75eb20fab3 | ||
|
|
5b7bd55ccd | ||
|
|
386e0ee72d | ||
|
|
b31b06eb11 | ||
|
|
cf637f34e8 | ||
|
|
cb99c2fa56 | ||
|
|
a67220cfb7 | ||
|
|
f9914fa482 | ||
|
|
fab6465c69 | ||
|
|
b94e8502c8 | ||
|
|
4fe5ef0d34 | ||
|
|
9e9c42ced7 | ||
|
|
81da7f1846 | ||
|
|
a01bdac09f | ||
|
|
b74fb8071a | ||
|
|
d135381ad5 | ||
|
|
daa6f203c3 | ||
|
|
1b36c3f2d7 | ||
|
|
9554f7af2a | ||
|
|
06f25af35f | ||
|
|
cc94b53d48 | ||
|
|
b09de402cc | ||
|
|
c121a7fa59 | ||
|
|
525aebadf4 | ||
|
|
1219c1ad81 | ||
|
|
cfa891dcde | ||
|
|
286c46d2b5 | ||
|
|
f2ff3327c3 | ||
|
|
37e5d94373 | ||
|
|
40c9b3ca57 | ||
|
|
4d309c313d | ||
|
|
ba747c727c | ||
|
|
3635887a85 | ||
|
|
a7507255c7 | ||
|
|
d373f95388 | ||
|
|
fe7da77ed6 | ||
|
|
407458149b | ||
|
|
e9a45cf140 | ||
|
|
70e150611b | ||
|
|
f85da0461f | ||
|
|
7c15e2183b | ||
|
|
a2f3657269 | ||
|
|
c50f8a4b89 | ||
|
|
a488d5e4ac | ||
|
|
48836797b9 | ||
|
|
09151decad | ||
|
|
24dc7f57b4 | ||
|
|
449a955d15 | ||
|
|
87ed8bf878 | ||
|
|
6e4b177fe3 | ||
|
|
8db30f14bc | ||
|
|
d502849daf | ||
|
|
f3c68aaf29 | ||
|
|
7f6b247b40 | ||
|
|
6e7525241d | ||
|
|
b7faa9e17c | ||
|
|
97a0b34f9c | ||
|
|
b975ffc5e3 |
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -12,11 +12,11 @@ jobs:
|
||||
permissions:
|
||||
id-token: write # IMPORTANT: this permission is mandatory for trusted publishing
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.12'
|
||||
python-version: '3.10'
|
||||
cache: 'pip'
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
|
||||
@@ -47,3 +47,4 @@ logs/
|
||||
devgen.egg-info/
|
||||
build/
|
||||
devgen_utils.egg-info/
|
||||
devgen_cli.egg-info/
|
||||
@@ -0,0 +1,3 @@
|
||||
graft devgen/templates
|
||||
include README.md
|
||||
include LICENSE
|
||||
@@ -2,248 +2,149 @@
|
||||
|
||||
<div align="center">
|
||||
|
||||
<img src="https://img.shields.io/pypi/v/devgen?color=blue&label=PyPI&logo=pypi&logoColor=white" alt="PyPI">
|
||||
<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>
|
||||
**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://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>
|
||||
|
||||
**DevGen** is an AI-powered developer toolkit that automates common Git and project management tasks. Generate semantic commit messages, changelogs, release notes, `.gitignore` files, and licenses—all from a single unified CLI.
|
||||
## Why DevGen
|
||||
|
||||
## ✨ Features
|
||||
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.
|
||||
|
||||
- 🚀 **AI-Powered Commit Messages** - Automatically generate semantic commit messages from your staged changes using AI
|
||||
- 📝 **Changelog Generation** - Create changelogs from git history using conventional commits
|
||||
- 🚀 **Release Notes** - Generate clean, emoji-enhanced release notes for your releases
|
||||
- 🙈 **Gitignore Templates** - Generate `.gitignore` files from GitHub's official templates
|
||||
- 📄 **License Generation** - Quickly generate open source licenses with interactive setup
|
||||
- ⚙️ **Multi-Provider AI Support** - Works with Gemini, OpenAI, Anthropic, HuggingFace, and OpenRouter
|
||||
## Features
|
||||
|
||||
## 📦 Installation
|
||||
- **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.
|
||||
|
||||
## Supported AI Providers
|
||||
|
||||
| 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 |
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# Stable release from PyPI
|
||||
pip install devgen
|
||||
# Recommended: isolated environment
|
||||
pipx install devgen-cli
|
||||
|
||||
# Using pipx (recommended for isolated CLI)
|
||||
pipx install devgen
|
||||
# Or use uv for speed
|
||||
uv tool install devgen-cli
|
||||
|
||||
# Using uv (fast Python package manager)
|
||||
uv tool install devgen
|
||||
# Or plain pip
|
||||
pip install devgen-cli
|
||||
|
||||
# Shell completion (bash/zsh/fish)
|
||||
devgen --install-completion
|
||||
```
|
||||
|
||||
## 🚀 Quick Start
|
||||
Requires **Python 3.10 or newer**.
|
||||
|
||||
### 1. Initial Setup
|
||||
|
||||
Configure your AI provider and API key:
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# 1. Configure a provider and API key
|
||||
devgen setup config
|
||||
```
|
||||
|
||||
This will create a configuration file at `~/.devgen.yaml` with your preferred AI provider, model, and API key.
|
||||
# 2. Stage your work as usual
|
||||
git add .
|
||||
|
||||
### 2. Generate Commit Messages
|
||||
|
||||
Stage your changes and let DevGen generate semantic commit messages:
|
||||
|
||||
```bash
|
||||
# Dry run to preview commit messages
|
||||
devgen commit run --dry-run
|
||||
|
||||
# Actually commit with AI-generated messages
|
||||
# 3. Let DevGen write the commit message
|
||||
devgen commit run
|
||||
|
||||
# Commit and automatically push
|
||||
# 4. Or preview first, then commit
|
||||
devgen commit run --dry-run
|
||||
devgen commit run --push
|
||||
|
||||
# Force regeneration of commit messages
|
||||
devgen commit run --force-rebuild
|
||||
```
|
||||
|
||||
**How it works:**
|
||||
- DevGen detects all staged and unstaged changes
|
||||
- Groups files by directory
|
||||
- Generates semantic commit messages for each group using AI
|
||||
- Follows [Conventional Commits](https://www.conventionalcommits.org/) format
|
||||
## Commands
|
||||
|
||||
**Additional commit commands:**
|
||||
```bash
|
||||
# View cached commit messages
|
||||
devgen commit list-cached
|
||||
| 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 |
|
||||
|
||||
# Clear the commit cache
|
||||
devgen commit clear-cache
|
||||
Run `devgen <command> --help` for full options on any subcommand.
|
||||
|
||||
# Check what files are staged
|
||||
devgen commit validate
|
||||
## 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.
|
||||
```
|
||||
|
||||
### 3. Generate Changelogs
|
||||
## Architecture
|
||||
|
||||
Create changelogs from your git history:
|
||||
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
|
||||
# Generate changelog from last tag to HEAD
|
||||
devgen changelog generate
|
||||
|
||||
# Generate from a specific reference
|
||||
devgen changelog generate --from v1.0.0
|
||||
|
||||
# Output to a custom file
|
||||
devgen changelog generate --output CHANGES.md
|
||||
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
|
||||
```
|
||||
|
||||
The changelog follows Semantic Release conventions, categorizing commits into:
|
||||
- **BREAKING CHANGES**
|
||||
- **Features**
|
||||
- **Bug Fixes**
|
||||
- **Documentation**
|
||||
- **Other Changes**
|
||||
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`).
|
||||
|
||||
### 4. Generate Release Notes
|
||||
## Contributing
|
||||
|
||||
Create release notes for your releases:
|
||||
Issues and PRs welcome. Please run `uv run pre-commit run --all-files` before opening a PR so CI stays green.
|
||||
|
||||
```bash
|
||||
# Generate release notes
|
||||
devgen release notes --version 1.4.0
|
||||
## License
|
||||
|
||||
# Custom output file
|
||||
devgen release notes --version 1.4.0 --output RELEASE.md
|
||||
GPL-3.0-or-later. See [LICENSE](LICENSE).
|
||||
|
||||
# From a specific reference
|
||||
devgen release notes --version 1.4.0 --from v1.3.0
|
||||
```
|
||||
## Acknowledgments
|
||||
|
||||
### 5. Generate .gitignore Files
|
||||
Built on the shoulders of:
|
||||
|
||||
Create `.gitignore` files from GitHub templates:
|
||||
- **Typer** & **Rich** — CLI and terminal UI
|
||||
- **Questionary** — interactive prompts
|
||||
- **Google Gemini**, **OpenAI**, **Anthropic**, **OpenRouter**, **HuggingFace**, and **Ollama** — AI providers
|
||||
- **Ruff** — lint and format
|
||||
|
||||
```bash
|
||||
# Interactive mode (search and select templates)
|
||||
devgen gitignore generate
|
||||
|
||||
# Specify templates directly
|
||||
devgen gitignore generate Python Node Docker
|
||||
|
||||
# List available templates
|
||||
devgen gitignore list
|
||||
|
||||
# Use cached templates (offline mode)
|
||||
devgen gitignore generate --offline Python
|
||||
```
|
||||
|
||||
**Options:**
|
||||
- `--append` / `--overwrite` - Append to or overwrite existing `.gitignore`
|
||||
- `--output` - Specify output file path (default: `.gitignore`)
|
||||
- `--offline` - Use only cached templates
|
||||
|
||||
### 6. Generate Licenses
|
||||
|
||||
Generate open source licenses interactively:
|
||||
|
||||
```bash
|
||||
devgen license generate
|
||||
```
|
||||
|
||||
This will:
|
||||
1. Show available licenses (MIT, Apache-2.0, GPL-3.0, etc.)
|
||||
2. Prompt for author name
|
||||
3. Prompt for year (defaults to current year)
|
||||
4. Generate the license file
|
||||
|
||||
**Available licenses:**
|
||||
- MIT
|
||||
- Apache-2.0
|
||||
- GPL-2.0, GPL-3.0
|
||||
- LGPL-2.1
|
||||
- AGPL-3.0
|
||||
- BSD-2-Clause, BSD-3-Clause
|
||||
- MPL-2.0
|
||||
- EPL-2.0
|
||||
- BSL-1.0
|
||||
- CC0-1.0
|
||||
- Unlicense
|
||||
|
||||
## ⚙️ Configuration
|
||||
|
||||
Configuration is stored in `~/.devgen.yaml`. You can edit it manually or use the interactive setup:
|
||||
|
||||
```bash
|
||||
devgen setup config
|
||||
```
|
||||
|
||||
**Configuration options:**
|
||||
- `provider` - AI provider: `gemini`, `openai`, `anthropic`, `huggingface`, or `openrouter`
|
||||
- `model` - Model name (e.g., `gemini-2.5-flash`, `gpt-4`, `claude-3-opus`)
|
||||
- `api_key` - Your API key
|
||||
- `emoji` - Enable/disable emojis in commit messages (default: `true`)
|
||||
|
||||
## 📋 Command Reference
|
||||
|
||||
### Commit Commands
|
||||
```bash
|
||||
devgen commit run [--dry-run] [--push] [--debug] [--force-rebuild]
|
||||
devgen commit validate
|
||||
devgen commit list-cached
|
||||
devgen commit clear-cache
|
||||
```
|
||||
|
||||
### Changelog Commands
|
||||
```bash
|
||||
devgen changelog generate [--output FILE] [--from REF]
|
||||
```
|
||||
|
||||
### Release Commands
|
||||
```bash
|
||||
devgen release notes [--version VERSION] [--output FILE] [--from REF]
|
||||
```
|
||||
|
||||
### Gitignore Commands
|
||||
```bash
|
||||
devgen gitignore generate [TEMPLATES...] [--output FILE] [--append/--overwrite] [--offline]
|
||||
devgen gitignore list [--cached]
|
||||
```
|
||||
|
||||
### License Commands
|
||||
```bash
|
||||
devgen license generate [--output FILE]
|
||||
```
|
||||
|
||||
### Setup Commands
|
||||
```bash
|
||||
devgen setup config
|
||||
```
|
||||
|
||||
## 🎯 Use Cases
|
||||
|
||||
- **Automated Commit Workflow**: Let AI analyze your changes and generate meaningful commit messages
|
||||
- **Release Preparation**: Automatically generate changelogs and release notes from git history
|
||||
- **Project Setup**: Quickly add `.gitignore` and license files to new projects
|
||||
- **CI/CD Integration**: Use in scripts to automate documentation generation
|
||||
|
||||
## 🔧 Requirements
|
||||
|
||||
- Python 3.10 or higher
|
||||
- Git repository (for commit/changelog/release features)
|
||||
- API key for your chosen AI provider
|
||||
|
||||
## 📝 License
|
||||
|
||||
This project is licensed under the GPL-3.0-or-later License - see the [LICENSE](LICENSE) file for details.
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Contributions are welcome! Please feel free to submit a Pull Request.
|
||||
|
||||
## 📚 Links
|
||||
|
||||
- **Homepage**: [https://github.com/S4NKALP/devgen](https://github.com/S4NKALP/devgen)
|
||||
- **PyPI**: [https://pypi.org/project/devgen](https://pypi.org/project/devgen)
|
||||
|
||||
## 🙏 Acknowledgments
|
||||
|
||||
- Uses GitHub's official [gitignore templates](https://github.com/github/gitignore)
|
||||
- Follows [Conventional Commits](https://www.conventionalcommits.org/) specification
|
||||
<div align="center">
|
||||
Made with ❤️ by <a href="https://github.com/S4NKALP">Sankalp</a>
|
||||
</div>
|
||||
+11
-2
@@ -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)
|
||||
+54
-2
@@ -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)
|
||||
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
@@ -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
@@ -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,
|
||||
)
|
||||
|
||||
+20
-1
@@ -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,12 +74,24 @@ def setup_config() -> None:
|
||||
raise typer.Exit(code=130)
|
||||
emoji = emoji_choice == "Yes"
|
||||
|
||||
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
|
||||
new_config = {
|
||||
"provider": provider,
|
||||
"model": model,
|
||||
"api_key": api_key,
|
||||
"emoji": emoji,
|
||||
"ollama_host": ollama_host,
|
||||
}
|
||||
|
||||
try:
|
||||
|
||||
@@ -5,186 +5,151 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from devgen.utils import configure_logger
|
||||
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")
|
||||
|
||||
def _exec_git(self, args: List[str]) -> str:
|
||||
"""Executes a git command."""
|
||||
try:
|
||||
res = subprocess.run(
|
||||
args,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
check=True,
|
||||
)
|
||||
return res.stdout.strip()
|
||||
except subprocess.CalledProcessError as e:
|
||||
self.logger.error(f"Git command failed: {e}")
|
||||
raise RuntimeError(f"Git command failed: {e}")
|
||||
# ------------------------------------------------------------------ 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
|
||||
"""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}") from e
|
||||
|
||||
def _log_cmd(self, range_spec: str) -> List[str]:
|
||||
# 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 = self._exec_git(["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:
|
||||
self.logger.info("No tags found, generating for all commits.")
|
||||
cmd = ["git", "log", f"--format={fmt}", "--date=short"]
|
||||
|
||||
return self._exec_git(cmd).split("\n")
|
||||
|
||||
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",
|
||||
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.lstrip()}"
|
||||
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}")
|
||||
@@ -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)
|
||||
+319
-184
@@ -1,30 +1,146 @@
|
||||
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,
|
||||
render_custom_template,
|
||||
sanitize_ai_commit_message,
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
|
||||
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):
|
||||
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__(
|
||||
@@ -33,225 +149,244 @@ 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(
|
||||
"devgen.commit", Path.home() / ".cache" / "devgen" / "commit.log"
|
||||
"devgen.commit",
|
||||
Path.home() / ".cache" / "devgen" / "commit.log",
|
||||
console=debug,
|
||||
)
|
||||
self.kwargs = kwargs
|
||||
self.dry_run_path = get_commit_dry_run_path()
|
||||
self.template_env = load_template_env("commit")
|
||||
|
||||
# Load config from ~/.devgen.yaml
|
||||
# CLI flags win over config; config wins over default.
|
||||
from devgen.utils import load_config
|
||||
|
||||
self.config = load_config()
|
||||
|
||||
def _exec_git(self, args: List[str], allow_error: bool = False) -> str:
|
||||
"""Executes a git command."""
|
||||
try:
|
||||
res = subprocess.run(
|
||||
args,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
check=not allow_error,
|
||||
self.console = Console(
|
||||
theme=Theme(
|
||||
{"info": "dim cyan", "warning": "magenta", "danger": "bold red"}
|
||||
)
|
||||
return res.stdout.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 detect_changes(self) -> List[str]:
|
||||
"""Detects changed, deleted, or untracked files."""
|
||||
out = self._exec_git(
|
||||
[
|
||||
"git",
|
||||
"ls-files",
|
||||
"--deleted",
|
||||
"--modified",
|
||||
"--others",
|
||||
"--exclude-standard",
|
||||
]
|
||||
)
|
||||
return [f.strip() for f in out.split("\n") if f.strip()]
|
||||
|
||||
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."""
|
||||
return self._exec_git(["git", "--no-pager", "diff", "--staged", "--", *files])
|
||||
|
||||
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."""
|
||||
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._exec_git(["git", "add", *files])
|
||||
|
||||
def commit_staged(self, msg: str):
|
||||
"""Commits staged changes."""
|
||||
self.logger.info(f"Committing:\n{msg}")
|
||||
self._exec_git(["git", "commit", "-m", msg])
|
||||
|
||||
def push_commits(self):
|
||||
"""Pushes commits to remote."""
|
||||
self.logger.info("Pushing to remote...")
|
||||
self._exec_git(["git", "push"])
|
||||
self.logger.info("Push successful.")
|
||||
|
||||
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.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)
|
||||
)
|
||||
)
|
||||
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)
|
||||
|
||||
raw = generate_with_ai(
|
||||
prompt,
|
||||
provider=provider,
|
||||
model=model,
|
||||
api_key=api_key,
|
||||
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,
|
||||
)
|
||||
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,
|
||||
**self.kwargs,
|
||||
)
|
||||
return sanitize_ai_commit_message(raw)
|
||||
|
||||
def is_ahead_of_remote(self) -> bool:
|
||||
"""Checks if local branch has unpushed commits."""
|
||||
try:
|
||||
self._exec_git(["git", "fetch", "origin"])
|
||||
count = self._exec_git(
|
||||
["git", "rev-list", "--count", "@{u}..HEAD"], allow_error=True
|
||||
)
|
||||
if count and int(count) > 0:
|
||||
return True
|
||||
except CommitEngineError:
|
||||
# Maybe no upstream
|
||||
return bool(self._exec_git(["git", "rev-parse", "HEAD"], allow_error=True))
|
||||
return False
|
||||
# ------------------------------------------------------------------ public
|
||||
|
||||
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}")
|
||||
self._exec_git(["git", "reset", "HEAD", "--", *files])
|
||||
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)
|
||||
self._exec_git(["git", "reset", "HEAD", "--", *files])
|
||||
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.logger.info(f"Dry run done. See {self.dry_run_path}")
|
||||
self.console.print(
|
||||
f"[bold green]Dry run done.[/bold green] See {self.cache.path}"
|
||||
)
|
||||
else:
|
||||
self.logger.info("Done.")
|
||||
self.console.print("[bold green]Done.[/bold green]")
|
||||
if failed:
|
||||
self.logger.warning(f"Failed groups: {failed}")
|
||||
self.console.print(f"[bold red]Failed groups: {failed}[/bold red]")
|
||||
|
||||
# ------------------------------------------------------------------ groups
|
||||
|
||||
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):
|
||||
"""Entry point for the commit engine."""
|
||||
logger = configure_logger("devgen.commit")
|
||||
def run_commit_engine(**kwargs: Any) -> None:
|
||||
"""Entry point used by the CLI."""
|
||||
debug = kwargs.get("debug", False)
|
||||
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"]
|
||||
@@ -0,0 +1,220 @@
|
||||
"""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 any(p in f for p 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
|
||||
groups[new_key].extend(groups.pop(deepest))
|
||||
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
|
||||
@@ -0,0 +1,144 @@
|
||||
"""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:
|
||||
"""``git add`` the given paths."""
|
||||
if not files:
|
||||
return
|
||||
try:
|
||||
self._run(["git", "add", *files])
|
||||
except GitError as e:
|
||||
raise GitError(f"Could not stage files: {e}") from e
|
||||
|
||||
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
|
||||
@@ -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."""
|
||||
|
||||
@@ -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}")
|
||||
@@ -1,21 +0,0 @@
|
||||
from devgen.providers.anthropic import AnthropicProvider
|
||||
from devgen.providers.gemini import GeminiProvider
|
||||
from devgen.providers.huggingface import HuggingfaceProvider
|
||||
from devgen.providers.openai import OpenaiProvider
|
||||
from devgen.providers.openrouter import OpenrouterProvider
|
||||
|
||||
|
||||
def get_provider(name):
|
||||
name_lower = name.lower()
|
||||
if name_lower == "gemini":
|
||||
return GeminiProvider()
|
||||
elif name_lower == "openai":
|
||||
return OpenaiProvider()
|
||||
elif name_lower == "huggingface":
|
||||
return HuggingfaceProvider()
|
||||
elif name_lower == "openrouter":
|
||||
return OpenrouterProvider()
|
||||
elif name_lower == "anthropic":
|
||||
return AnthropicProvider()
|
||||
|
||||
raise NotImplementedError(f"Provider '{name}' is not implemented yet.")
|
||||
@@ -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
|
||||
@@ -0,0 +1,90 @@
|
||||
"""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:
|
||||
return self._generate(prompt, api_key, chosen_model, **kwargs).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
@@ -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
|
||||
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")
|
||||
)
|
||||
@@ -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."
|
||||
)
|
||||
@@ -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
|
||||
|
||||
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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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 }}
|
||||
+166
-19
@@ -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(
|
||||
@@ -75,7 +113,7 @@ def extract_commit_messages(filepath: Path | str) -> dict[str, str]:
|
||||
|
||||
|
||||
def configure_logger(
|
||||
name: str = "devgen", log_file: Optional[Path | str] = None
|
||||
name: str = "devgen", log_file: Optional[Path | str] = None, console: bool = True
|
||||
) -> logging.Logger:
|
||||
logger = logging.getLogger(name)
|
||||
logger.setLevel(logging.INFO)
|
||||
@@ -88,9 +126,10 @@ def configure_logger(
|
||||
)
|
||||
|
||||
# Console handler
|
||||
ch = logging.StreamHandler()
|
||||
ch.setFormatter(formatter)
|
||||
logger.addHandler(ch)
|
||||
if console:
|
||||
ch = logging.StreamHandler()
|
||||
ch.setFormatter(formatter)
|
||||
logger.addHandler(ch)
|
||||
|
||||
# File handler
|
||||
if log_file:
|
||||
@@ -103,15 +142,52 @@ def configure_logger(
|
||||
return logger
|
||||
|
||||
|
||||
def get_git_staged_files() -> list[str]:
|
||||
def run_git_command(
|
||||
args: list[str],
|
||||
check: bool = True,
|
||||
cwd: Optional[Path] = None,
|
||||
encoding: str = "utf-8",
|
||||
errors: str = "replace",
|
||||
) -> str:
|
||||
"""
|
||||
Executes a git command and returns the output.
|
||||
|
||||
Args:
|
||||
args: List of command arguments (e.g., ["git", "status"]).
|
||||
check: Whether to raise an exception on non-zero exit code.
|
||||
cwd: Current working directory for the command.
|
||||
encoding: Output encoding.
|
||||
errors: Error handling strategy for encoding.
|
||||
|
||||
Returns:
|
||||
The standard output of the command, stripped of leading/trailing whitespace.
|
||||
|
||||
Raises:
|
||||
subprocess.CalledProcessError: If the command fails and check is True.
|
||||
"""
|
||||
try:
|
||||
res = subprocess.run(
|
||||
["git", "diff", "--name-only", "--cached"],
|
||||
args,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
encoding=encoding,
|
||||
errors=errors,
|
||||
check=check,
|
||||
cwd=cwd,
|
||||
)
|
||||
return [f for f in res.stdout.splitlines() if f.strip()]
|
||||
return res.stdout.strip()
|
||||
except subprocess.CalledProcessError as e:
|
||||
# Log the error if a logger is configured, but re-raise
|
||||
# We don't have access to a specific logger here easily without passing it in,
|
||||
# so we rely on the caller to handle logging if needed, or we could log to a default one.
|
||||
# For now, just re-raise as the caller expects.
|
||||
raise e
|
||||
|
||||
|
||||
def get_git_staged_files() -> list[str]:
|
||||
try:
|
||||
output = run_git_command(["git", "diff", "--name-only", "--cached"])
|
||||
return [f for f in output.splitlines() if f.strip()]
|
||||
except subprocess.CalledProcessError:
|
||||
return []
|
||||
|
||||
@@ -132,10 +208,17 @@ def delete_file(filepath: Path | str) -> bool:
|
||||
|
||||
|
||||
def load_template_env(sub_dir: str) -> Environment:
|
||||
template_dir = Path(__file__).parent / "prompt_templates" / sub_dir
|
||||
template_dir = Path(__file__).parent / "templates" / sub_dir
|
||||
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"
|
||||
|
||||
@@ -145,6 +228,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:
|
||||
@@ -162,6 +247,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",
|
||||
@@ -170,12 +314,15 @@ __all__ = [
|
||||
"sanitize_ai_commit_message",
|
||||
"extract_commit_messages",
|
||||
"configure_logger",
|
||||
"run_git_command",
|
||||
"get_git_staged_files",
|
||||
"read_file_content",
|
||||
"delete_file",
|
||||
"load_template_env",
|
||||
"load_config",
|
||||
"get_questionary_style",
|
||||
"is_token_limit_error",
|
||||
"format_token_limit_error",
|
||||
]
|
||||
|
||||
|
||||
|
||||
+3
-3
@@ -3,8 +3,8 @@ requires = ["setuptools>=61", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "devgen"
|
||||
version = "0.2.0"
|
||||
name = "devgen-cli"
|
||||
version = "0.2.5"
|
||||
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
@@ -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
|
||||
@@ -5,8 +5,8 @@ Setup script for devgen package.
|
||||
from setuptools import find_packages, setup
|
||||
|
||||
setup(
|
||||
name="devgen",
|
||||
version="0.2.0",
|
||||
name="devgen-cli",
|
||||
version="0.2.5",
|
||||
packages=find_packages(),
|
||||
include_package_data=True,
|
||||
install_requires=[
|
||||
|
||||
@@ -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]]
|
||||
@@ -188,12 +179,12 @@ wheels = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "devgen"
|
||||
version = "0.1.0"
|
||||
name = "devgen-cli"
|
||||
version = "0.2.5"
|
||||
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" },
|
||||
@@ -262,7 +253,7 @@ name = "exceptiongroup"
|
||||
version = "1.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
|
||||
wheels = [
|
||||
@@ -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" },
|
||||
]
|
||||
Reference in new issue
Block a user