refactor: modernize to Python 3 with OOP structure and comprehensive tests (#2)

* feat: full upgrade to python3, tests, etc.

* chore: saving the wip state

* chore(lint): fixed pyright errors, tests

* chore(ci): install deps

* chore(lint): fix linting

* chore(lint): more linting fixes

* chore(ci): upgrade workflows

* fix(test): fix tests, tweak editorconfig

* test: add helper path test and stub requests (#4)

* fix: update paths and workflow (#5)

* fix: update python version and improve cross-platform paths (#6)

* fix: resolve MegaLinter YAML and markdown lint failures (#7)

* fix: implement all CodeRabbit review comments (#8)

* fix: apply CodeRabbit auto-fixes

Fixed 3 file(s) based on 3 unresolved review comments.

* fix: resolve MegaLinter editorconfig and pylint/pyright failures (#9)

* feat: migrate to uv-managed project (#10)

* feat: migrate to uv-managed project

* fix: align Python version in pyproject.toml and CI setup-uv config

* fix: ignore uv.lock in editorconfig, disable PYTHON_PYLINT/PYRIGHT in mega-linter, use uv for dep install (#11)

* fix: ignore uv.lock in editorconfig and disable PYTHON_PYLINT/PYRIGHT in mega-linter

* fix: update outdated comment in mega-linter config

* fix: use uv instead of pip to install deps in mega-linter config

* chore(deps): upgrade workflows

* chore(ci): mega-linter config tweaks

* chore(ci): mega-linter config tweaks

* feat(deps): add pyright and pylint with non-overlapping config

Add pyright>=1.1.0 and pylint>=3.0.0 as dev dependencies. Configure
pyright for basic type checking (py3.13) and refine pylint message
disables to avoid overlap with ruff's enabled rule sets.

* feat(ci): re-enable pyright and pylint in mega-linter

Remove PYTHON_PYLINT and PYTHON_PYRIGHT from DISABLE_LINTERS so
mega-linter runs all three linters: ruff, pyright, and pylint.

* fix: resolve pyright/pylint findings and apply ruff formatting

Add encoding="utf-8" to read_text() calls in tests (pylint W1514).
Apply ruff-format double-quote style consistently across both files.

* chore(hooks): add editorconfig-checker and fix lines exceeding 80 chars

Add editorconfig-checker pre-commit hook to catch line-length
violations locally. Shorten docstrings in aeonview.py and
aeonview_test.py that exceeded the 80-character editorconfig limit.
Remove double-quote-string-fixer hook that conflicted with ruff-format.

* fix(ci): configure mega-linter to use project configs for pyright/pylint

Point mega-linter at pyproject.toml for both linters so they use our
config instead of mega-linter's defaults. Add venvPath/venv to pyright
so it resolves imports from the uv-created .venv. Disable pylint
import-error since import checking is handled by pyright.
This commit is contained in:
2026-03-13 15:31:04 +02:00
committed by GitHub
parent 6aa17ba957
commit 2b68767f0d
20 changed files with 2628 additions and 238 deletions

View File

@@ -3,17 +3,27 @@
# top-most EditorConfig file # top-most EditorConfig file
root = true root = true
# Unix-style newlines with a newline ending every file
[*] [*]
end_of_line = lf
insert_final_newline = true
# Matches multiple files with brace expansion notation
# Set default charset
[*.{js,py}]
charset = utf-8 charset = utf-8
end_of_line = lf
# 4 space indentation max_line_length = 80
[*.py] insert_final_newline = true
indent_style = tab indent_style = space
indent_size = 2 indent_size = 2
trim_trailing_whitespace = true
[*.py]
indent_size = 4
[Makefile]
indent_style = tab
[*.md]
trim_trailing_whitespace = false
max_line_length = 200
[uv.lock]
max_line_length = unset
[*.{yml,toml}]
max_line_length = 200

57
.github/CODE_OF_CONDUCT.md vendored Normal file
View File

@@ -0,0 +1,57 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation
in our community a harassment-free experience for everyone, regardless
of age, body size, visible or invisible disability, ethnicity, sex
characteristics, gender identity and expression, level of experience,
education, socio-economic status, nationality, personal appearance,
race, caste, color, religion, or sexual identity and orientation.
We pledge to act and interact in ways that contribute to an open,
welcoming, diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment:
- Demonstrating empathy and kindness
- Being respectful of differing opinions
- Gracefully accepting constructive feedback
- Focusing on what is best for the community
Examples of unacceptable behavior:
- The use of sexualized language or imagery
- Trolling, insulting or derogatory comments
- Harassment of any kind
- Publishing others private information
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our
standards and will take appropriate and fair corrective action in
response to any behavior they deem inappropriate.
## Scope
This Code of Conduct applies within all community spaces and also
applies when an individual is officially representing the project.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior
may be reported to the project owner @ivuorinen.
All complaints will be reviewed and investigated and will result in a
response that is deemed necessary and appropriate to the circumstances.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][cc],
version 2.1.
[cc]: https://www.contributor-covenant.org/version/2/1/code_of_conduct/
<!-- vim: ft=md sw=2 ts=2 tw=72 fo=cqt wm=0 et : -->

90
.github/CONTRIBUTING.md vendored Normal file
View File

@@ -0,0 +1,90 @@
# Contributing to aeonview
Thanks for your interest in contributing to **aeonview**!
This guide will help you get started.
## 🛠 Project Setup
1. Clone the repository:
```bash
git clone https://github.com/ivuorinen/aeonview.git
cd aeonview
```
2. Set up your environment:
```bash
uv sync --all-groups
```
3. Install pre-commit hooks:
```bash
uv run pre-commit install
```
## ✅ Development Workflow
- Make sure your changes are **well-tested**.
- Use `make check` to run linting and tests.
- Follow the existing coding style (Ruff will enforce it).
- All new features must include documentation.
## 🧪 Running Tests
```bash
make test
```
## 🧹 Formatting & Linting
```bash
make format # auto-format code
make lint # check for lint errors
```
## ✅ Submitting a Pull Request
1. Create a feature branch:
```bash
git checkout -b feature/my-new-feature
```
2. Commit your changes:
```bash
git add .
git commit -m "feat: add support for X"
```
3. Push and open a pull request:
```bash
git push origin feature/my-new-feature
```
4. Follow the PR template and link any relevant issues.
## 📋 Commit Message Guidelines
Use [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/):
Examples:
- `feat: add monthly video generation`
- `fix: handle invalid date error`
- `docs: update usage instructions`
## 🙏 Code of Conduct
We expect contributors to follow our [Code of Conduct](CODE_OF_CONDUCT.md).
## Questions?
Feel free to open an issue or start a discussion!
Thanks for helping make Aeonview better 💜
<!-- vim: ft=md sw=2 ts=2 tw=72 fo=cqt wm=0 et :-->

16
.github/renovate.json vendored Normal file
View File

@@ -0,0 +1,16 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"github>ivuorinen/renovate-config"
],
"packageRules": [
{
"managers": [
"github-actions"
],
"schedule": [
"daily"
]
}
]
}

33
.github/workflows/pr-lint.yml vendored Normal file
View File

@@ -0,0 +1,33 @@
---
# yamllint disable rule:line-length
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
# yamllint enable rule:line-length
name: PR Lint
# yamllint disable-line rule:truthy
on:
push:
branches: [master, main]
pull_request:
branches: [master, main]
permissions: read-all
env:
TRIVY_SEVERITY: CRITICAL,HIGH
DISABLE_LINTERS: GO_GOLANGCI_LINT
jobs:
Linter:
name: PR Lint
runs-on: ubuntu-latest
permissions:
contents: write # only for delete-branch option
issues: write
pull-requests: write
statuses: write
steps:
# yamllint disable rule:line-length
- uses: ivuorinen/actions/pr-lint@7f6a23b59316795c4b3cb3b3b28dd53e53655a33 # v2026.03.11
# yamllint enable rule:line-length

39
.github/workflows/python-tests.yml vendored Normal file
View File

@@ -0,0 +1,39 @@
---
name: Python tests
# yamllint disable-line rule:truthy
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
permissions: read-all
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
# yamllint disable-line rule:line-length
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install uv
# yamllint disable-line rule:line-length
uses: astral-sh/setup-uv@e06108dd0aef18192324c70427afc47652e63a82 # v7.5.0
with:
python-version-file: ".python-version"
- name: Install dependencies
shell: bash
run: uv sync --all-groups
- name: Run Ruff linting
shell: bash
run: uv run ruff check .
- name: Run tests with coverage
shell: bash
run: |
uv run pytest --cov=aeonview --cov-report=term-missing

5
.gitignore vendored
View File

@@ -2,3 +2,8 @@ projects/*
!projects/.gitkeep !projects/.gitkeep
.vscode .vscode
.coverage .coverage
.pytest_cache
__pycache__
.coverage
.ruff_cache
.venv

38
.mega-linter.yml Normal file
View File

@@ -0,0 +1,38 @@
---
# Configuration file for MegaLinter
# See all available variables at
# https://megalinter.io/configuration/ and in linters documentation
APPLY_FIXES: all
SHOW_ELAPSED_TIME: false # Show elapsed time at the end of MegaLinter run
PARALLEL: true
VALIDATE_ALL_CODEBASE: true
FILEIO_REPORTER: false # Generate file.io report
GITHUB_STATUS_REPORTER: true # Generate GitHub status report
IGNORE_GENERATED_FILES: true # Ignore generated files
JAVASCRIPT_DEFAULT_STYLE: prettier # Default style for JavaScript
PRINT_ALPACA: false # Print Alpaca logo in console
SARIF_REPORTER: true # Generate SARIF report
SHOW_SKIPPED_LINTERS: false # Show skipped linters in MegaLinter log
# Disable linters that are replaced by uv-based workflows
DISABLE_LINTERS:
- JSON_PRETTIER
- PYTHON_BANDIT
- PYTHON_BLACK
- PYTHON_ISORT
- REPOSITORY_DEVSKIM
- REPOSITORY_DUSTILOCK
- YAML_PRETTIER
# Install Python dependencies before linting using uv
PRE_COMMANDS:
- command: pip install uv
cwd: workspace
- command: uv sync --all-groups
cwd: workspace
PYTHON_PYLINT_CONFIG_FILE: pyproject.toml
PYTHON_PYRIGHT_CONFIG_FILE: pyproject.toml
FILTER_REGEX_EXCLUDE: (node_modules|\.venv|docs/)

58
.pre-commit-config.yaml Normal file
View File

@@ -0,0 +1,58 @@
---
repos:
- repo: https://github.com/asottile/pyupgrade
rev: v3.21.2
hooks:
- id: pyupgrade
args: [--py3-plus]
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: "v0.15.6"
hooks:
- id: ruff
args: ["--fix"]
- id: ruff-format
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: check-ast
- id: check-builtin-literals
- id: check-docstring-first
- id: check-executables-have-shebangs
- id: check-json
- id: check-merge-conflict
- id: check-shebang-scripts-are-executable
- id: check-toml
- id: check-yaml
- id: debug-statements
- id: detect-aws-credentials
- id: end-of-file-fixer
- id: mixed-line-ending
- id: name-tests-test
- id: no-commit-to-branch
- id: trailing-whitespace
- repo: https://github.com/editorconfig-checker/editorconfig-checker.python
rev: "3.6.1"
hooks:
- id: editorconfig-checker
alias: ec
exclude: uv\.lock
- repo: local
hooks:
- id: pyright
name: pyright
entry: uv run pyright
language: system
types: [python]
pass_filenames: false
always_run: true
- id: pylint
name: pylint
entry: uv run pylint
language: system
types: [python]
- id: pytest
name: pytest
entry: uv run pytest
language: system
pass_filenames: false
always_run: true

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.13.2

28
AGENTS.md Normal file
View File

@@ -0,0 +1,28 @@
# Contributor Guidelines
## Development Workflow
- Use **Python 3.13** as defined in `.python-version`.
- Install dependencies with `uv sync --all-groups`.
- Use **pre-commit** for linting and testing:
- `uv run pre-commit run --files <changed files>`
- or `make check` to run `ruff` linting and tests.
- Run **ruff** for linting and formatting (`make lint`, `make format`).
- Run **pytest** for tests (`make test`).
- If you modify code (anything other than comments/docs), run both
linting and tests before committing.
- Aim for 100% coverage; add tests when adding or modifying code.
- Use `uv run pre-commit install` once to install git hooks.
- Commit messages and pull request titles must follow the
**Semantic Commit** convention (e.g. `fix:`, `feat:`).
- If Node packages are added, use **yarn** instead of npm.
## Repository Structure
- `aeonview.py` — main application code.
- `aeonview_test.py` — test suite using pytest.
- `projects/` — output directory used by the application.
- `.pre-commit-config.yaml` — hooks for linting and testing.
- `Makefile` — common commands (`format`, `lint`, `test`).
- `pyproject.toml` — project metadata and dependencies (managed by uv).
- `uv.lock` — locked dependency versions.

19
Makefile Normal file
View File

@@ -0,0 +1,19 @@
.PHONY: install format lint test check clean
install:
@uv sync --all-groups
format:
@uv run ruff format .
lint:
@uv run ruff check .
test:
@uv run pytest --cov=aeonview --cov-report=term-missing
check: lint test
clean:
@find . -type d -name '__pycache__' -exec rm -r {} +
@rm -rf .pytest_cache .coverage htmlcov .venv

108
README.md
View File

@@ -1,20 +1,98 @@
_) # aeonview
_` | -_) _ \ \ \ \ / | -_) \ \ \ /
\__,_| \___| \___/ _| _| \_/ _| \___| \_/\_/
aeonview - a simple timelapse tool
aeonview is a tool for automagical timelapse-video generation. ```markdown
it works as a glue between different linux programs to produce # _)
videos of elapsing time. works best with webcam-images from the net. # _` | -_) _ \ \ \ \ / | -_) \ \ \ /
# \__,_| \___| \___/ _| _| \_/ _| \___| \_/\_/
# aeonview - a simple timelapse tool
```
sample: **aeonview** is a Python-based tool for generating timelapse videos
http://www.youtube.com/watch?v=SnywvnjHpUk from webcam images using `ffmpeg`. It supports automated image
downloading, video stitching, and is fully scriptable via CLI.
Includes developer tooling and tests.
[![CI][ci-b]][ci-l] [![ruff][cc-b]][cc-l] [![MIT][lm-b]][lm-l]
Needed components: Low-quality sample: [aeonview 2min preview/Tampere Jan. 2008][sample]
* Python ## Features
* curl
* mencoder - Timelapse image capture (`--mode image`)
* lots of harddrive space - Video generation (`--mode video`)
* cron - Support for daily, monthly, yearly video runs *(daily implemented)*
- Uses `ffmpeg` and Python `requests`
- Fully tested with `pytest`
- Linting and formatting via `ruff`
- Pre-commit hooks and CI-ready
## Requirements
- Python 3.13+
- `ffmpeg` (system tool)
- [uv](https://docs.astral.sh/uv/) for dependency management
- lots of hard drive space
## Installation
```bash
# Clone the repo
git clone https://github.com/ivuorinen/aeonview.git
cd aeonview
# Install dependencies (creates .venv automatically)
uv sync --all-groups
# Install pre-commit hooks
uv run pre-commit install
```
## Usage
```bash
# Capture an image
uv run python aeonview.py \
--mode image \
--project example \
--url "https://example.com/webcam.jpg"
# Generate a video from yesterday's images
uv run python aeonview.py --mode video --project example
```
## Development
```bash
# Format code
make format
# Lint code
make lint
# Run tests
make test
# Lint and test with pre-commit
uv run pre-commit run --files <changed files>
```
## System Setup for ffmpeg
```bash
sudo apt update
sudo apt install ffmpeg
```
## License
MIT License © 2025 Ismo Vuorinen
[ci-b]: https://github.com/ivuorinen/aeonview/actions/workflows/python-tests.yml/badge.svg
[ci-l]: https://github.com/ivuorinen/aeonview/actions/workflows/python-tests.yml
[cc-b]: https://img.shields.io/badge/code%20style-ruff-blueviolet
[cc-l]: https://github.com/astral-sh/ruff
[lm-b]: https://img.shields.io/badge/License-MIT-yellow.svg
[lm-l]: https://opensource.org/licenses/MIT
[sample]: https://www.youtube.com/watch?v=SnywvnjHpUk
<!-- vim: set sw=2 ts=2 tw=72 fo=cqt wm=0 et: -->

814
aeonview.py Executable file → Normal file
View File

@@ -1,221 +1,645 @@
import sys, time, datetime, os, optparse, errno, re """aeonview - Timelapse generator using ffmpeg.
def aeonview(argv): Downloads webcam images on a schedule and generates daily, monthly, and yearly
""" timelapse videos by stitching frames together with ffmpeg.
aeonview is a tool for automagical timelapse-video generation. """
it works as a glue between different linux programs to produce
videos of elapsing time. works best with webcam-images from the net.
"""
version = "0.1.8"
parser = optparse.OptionParser( from __future__ import annotations
usage="Usage: %prog [options]",
description="aeonview for timelapses", import argparse
version="%prog v"+version import hashlib
import logging
import subprocess
import sys
import tempfile
from datetime import datetime, timedelta
from pathlib import Path
import requests
class AeonViewMessages:
"""Constant log messages used throughout the application."""
INVALID_URL = "Invalid URL provided."
INVALID_DATE = "Invalid date format provided."
DOWNLOAD_SUCCESS = "Image downloaded successfully."
DOWNLOAD_FAILURE = "Failed to download image."
VIDEO_GENERATION_SUCCESS = "Video generated successfully."
VIDEO_GENERATION_FAILURE = "Failed to generate video."
INVALID_IMAGE_FORMAT = "Invalid image format provided."
INVALID_IMAGE_EXTENSION = "Invalid image extension provided."
class AeonViewImages:
"""Handle image download and saving.
Downloads images from a webcam URL and saves them into a date-based
directory structure under the project path.
"""
def __init__(self, project_path: Path, url: str | None, args=None):
"""Initialize the AeonViewImages class.
:param project_path: Path to the project directory.
:param url: URL of the image to download.
:param args: Command-line arguments passed to the class.
"""
self.project_path = project_path
self.url = url or None
self.args = args or {}
self.simulate = getattr(args, "simulate", False)
def get_image_paths(
self,
url: str | None,
destination_base: Path | None,
date: datetime | None,
) -> dict:
"""Compute image paths for saving the downloaded image.
:param url: URL of the image.
:param destination_base: Base path where the image will be saved.
:param date: Date for which the image is requested.
:return: Dictionary with url, file, date, and destinations info.
:raises SystemExit: If any parameter is invalid.
"""
if url is None or not isinstance(url, str):
logging.error(AeonViewMessages.INVALID_URL)
sys.exit(1)
if not isinstance(date, datetime):
logging.error(AeonViewMessages.INVALID_DATE)
sys.exit(1)
if not url.startswith("http"):
logging.error(AeonViewMessages.INVALID_URL)
sys.exit(1)
if not url.endswith((".jpg", ".jpeg", ".png", ".gif", ".webp")):
logging.error(AeonViewMessages.INVALID_IMAGE_FORMAT)
sys.exit(1)
if destination_base is None:
logging.error("No destination base path provided.")
sys.exit(1)
if not isinstance(destination_base, Path):
logging.error("Invalid destination base path.")
sys.exit(1)
year = date.strftime("%Y")
month = date.strftime("%m")
day = date.strftime("%d")
year_month = f"{year}-{month}"
destination = AeonViewHelpers.build_path(
destination_base, year_month, day
)
extension = AeonViewHelpers.get_extension(url) or ""
file_name = date.strftime("%H-%M-%S") + extension
destination_file = AeonViewHelpers.build_path(destination, file_name)
if self.simulate:
logging.info("Simulate: would create %s", destination)
else:
AeonViewHelpers.mkdir_p(destination)
logging.info("Creating destination base path: %s", destination)
return {
"url": url,
"file": file_name,
"date": {
"year": year,
"month": month,
"day": day,
"hour": date.strftime("%H"),
"minute": date.strftime("%M"),
"second": date.strftime("%S"),
},
"destinations": {
"base": destination_base,
"year_month": year_month,
"day": day,
"file": destination_file,
},
}
def get_current_image(self):
"""Download the image from the URL and save it to the project directory.
:raises SystemExit: If the URL is missing or the date format is invalid.
"""
date_param = getattr(self.args, "date", None)
if date_param is not None:
try:
date = datetime.strptime(date_param, "%Y-%m-%d %H:%M:%S")
except ValueError:
logging.error(AeonViewMessages.INVALID_DATE)
sys.exit(1)
else:
date = datetime.now()
img_path = date.strftime("img/%Y-%m/%d")
img_name = date.strftime("%H-%M-%S")
if self.url is None:
logging.error(AeonViewMessages.INVALID_URL)
sys.exit(1)
file_ext = AeonViewHelpers.get_extension(self.url)
dest_dir = AeonViewHelpers.build_path(self.project_path, img_path)
dest_file = AeonViewHelpers.build_path(
dest_dir, f"{img_name}{file_ext}"
) )
basicopts = optparse.OptionGroup( logging.info("Saving image to %s", dest_file)
parser, "Basic settings", "These effect in both modes."
if not self.simulate:
AeonViewHelpers.mkdir_p(dest_dir)
self.download_image(dest_file)
else:
logging.info("Simulate: would create %s", dest_dir)
logging.info(
"Simulate: would download %s to %s", self.url, dest_file
) )
basicopts.add_option("-m", "--mode", def download_image(self, destination: Path | str):
"""Download the image using Python's requests library.
:param destination: Path where the image will be saved.
:raises SystemExit: If the URL is missing, destination is not a Path,
or the HTTP request fails.
"""
if self.url is None:
logging.error(AeonViewMessages.INVALID_URL)
sys.exit(1)
if not isinstance(destination, Path):
logging.error("Invalid destination path.")
sys.exit(1)
if not self.simulate:
logging.info("Downloading image from %s", self.url)
response = requests.get(self.url, stream=True, timeout=10)
if response.status_code == 200:
with open(destination, "wb") as f:
for chunk in response.iter_content(1024):
f.write(chunk)
logging.info(
"%s: %s", AeonViewMessages.DOWNLOAD_SUCCESS, destination
)
else:
logging.error(
"%s: %s", AeonViewMessages.DOWNLOAD_FAILURE, self.url
)
sys.exit(1)
else:
logging.info(
"Simulate: would download %s to %s", self.url, destination
)
class AeonViewVideos:
"""Handle video generation and management.
Generates daily timelapse videos from images, then concatenates daily
videos into monthly and yearly compilations using ffmpeg.
"""
def __init__(
self, project_path: Path, args: argparse.Namespace | None = None
):
"""Initialize the AeonViewVideos class.
:param project_path: Path to the project directory.
:param args: Command-line arguments passed to the class.
"""
self.project_path = project_path
self.args = args
self.simulate = getattr(args, "simulate", False)
self.fps = getattr(args, "fps", 10)
self.day = getattr(args, "day", None)
self.month = getattr(args, "month", None)
self.year = getattr(args, "year", None)
self.path_images = AeonViewHelpers.build_path(self.project_path, "img")
self.path_videos = AeonViewHelpers.build_path(self.project_path, "vid")
def generate_daily_video(self):
"""Generate a daily timelapse video from images.
:raises SystemExit: If the input image directory does not exist.
"""
year_month = f"{self.year}-{self.month}"
input_dir = AeonViewHelpers.build_path(
self.path_images, year_month, self.day
)
output_dir = AeonViewHelpers.build_path(self.path_videos, year_month)
output_file = AeonViewHelpers.build_path(output_dir, f"{self.day}.mp4")
ffmpeg_cmd = AeonViewHelpers.generate_ffmpeg_command(
input_dir, output_file, self.fps
)
logging.info("Generating video from %s", input_dir)
logging.info("Output file will be %s", output_file)
if not self.simulate:
logging.info("Running ffmpeg command: %s", " ".join(ffmpeg_cmd))
if not input_dir.exists():
logging.error("Input directory %s does not exist", input_dir)
sys.exit(1)
AeonViewHelpers.mkdir_p(output_dir)
subprocess.run(ffmpeg_cmd, check=True)
logging.info(
"%s: %s", AeonViewMessages.VIDEO_GENERATION_SUCCESS, output_file
)
else:
logging.info("Simulate: would run %s", " ".join(ffmpeg_cmd))
def generate_monthly_video(self):
"""Generate a monthly video by concatenating daily videos.
Discovers daily ``.mp4`` files in ``vid/YYYY-MM/`` and concatenates
them, excluding the monthly output file itself to avoid corruption
on re-runs.
:raises SystemExit: If the input directory is missing or contains
no daily videos.
"""
year_month = f"{self.year}-{self.month}"
output_dir = AeonViewHelpers.build_path(self.path_videos, year_month)
output_file = AeonViewHelpers.build_path(
output_dir, f"{year_month}.mp4"
)
if not output_dir.exists():
logging.error("Input directory %s does not exist", output_dir)
sys.exit(1)
daily_videos = sorted(
f for f in output_dir.glob("*.mp4") if f != output_file
)
if not daily_videos:
logging.error("No daily videos found in %s", output_dir)
sys.exit(1)
self._concatenate_videos(
f"monthly video for {year_month}", daily_videos, output_file
)
def generate_yearly_video(self):
"""Generate a yearly video by concatenating monthly videos.
Discovers monthly ``.mp4`` files across ``vid/YYYY-*/`` directories
and concatenates them into a single yearly video.
:raises SystemExit: If no monthly videos are found for the year.
"""
year = self.year
output_dir = AeonViewHelpers.build_path(self.path_videos, year)
output_file = AeonViewHelpers.build_path(output_dir, f"{year}.mp4")
monthly_videos = sorted(self.path_videos.glob(f"{year}-*/{year}-*.mp4"))
if not monthly_videos:
logging.error("No monthly videos found for year %s", year)
sys.exit(1)
self._concatenate_videos(
f"yearly video for {year}", monthly_videos, output_file
)
def _concatenate_videos(
self, label: str, input_videos: list[Path], output_file: Path
) -> None:
"""Concatenate video files into one using ffmpeg concat.
:param label: Human-readable label for log messages
(e.g. "monthly video for 2025-04").
:param input_videos: Paths of input videos to concatenate.
:param output_file: Path for the resulting video.
"""
logging.info("Generating %s", label)
logging.info("Output file will be %s", output_file)
if not self.simulate:
AeonViewHelpers.mkdir_p(output_file.parent)
cmd, concat_file = AeonViewHelpers.generate_concat_command(
input_videos, output_file
)
logging.info("Running ffmpeg command: %s", " ".join(cmd))
try:
subprocess.run(cmd, check=True)
logging.info(
"%s: %s",
AeonViewMessages.VIDEO_GENERATION_SUCCESS,
output_file,
)
finally:
concat_file.unlink(missing_ok=True)
else:
logging.info(
"Simulate: would concatenate %d videos into %s",
len(input_videos),
output_file,
)
class AeonViewHelpers:
"""Utility methods for paths, argument parsing, and ffmpeg."""
@staticmethod
def check_date(year: int, month: int, day: int) -> bool:
"""Check if the given year, month, and day form a valid date.
:param year: Year to check.
:param month: Month to check.
:param day: Day to check.
:return: True if valid date, False otherwise.
"""
try:
date = datetime(year, month, day)
return date.year == year and date.month == month and date.day == day
except ValueError:
return False
@staticmethod
def mkdir_p(path: Path):
"""Create a directory and all parent directories if they do not exist.
:param path: Path to the directory to create.
"""
path.mkdir(parents=True, exist_ok=True)
@staticmethod
def build_path(base: Path, *args) -> Path:
"""Build a path from the base and additional arguments.
:param base: Base path.
:param args: Parts of the path to join.
:return: Structured and resolved path.
"""
return Path(base).joinpath(*args or []).resolve()
@staticmethod
def parse_arguments():
"""Parse command-line arguments.
:return: Tuple of (parsed Namespace, ArgumentParser instance).
"""
dest_default = str(Path.cwd() / "projects")
parser = argparse.ArgumentParser(
description="aeonview - timelapse generator using ffmpeg"
)
parser.add_argument(
"--mode",
choices=["image", "video"],
default="image", default="image",
help="run mode: image or video [default: %default]") help="Run mode",
)
basicopts.add_option("-p", "--project", parser.add_argument("--project", help="Project name", default="default")
help="Project name, used as directory name. " parser.add_argument(
"Defaults to 5 characters from md5 hash of the webcam url.", "--dest", default=dest_default, help="Destination root path"
type="string") )
parser.add_argument("--url", help="Webcam URL (required in image mode)")
basicopts.add_option("-d", "--dest", parser.add_argument(
help="Start of the destination. [default: %default]", "--fps", type=int, default=10, help="Frames per second"
type="string", )
default=os.getcwdu()+"/projects", parser.add_argument(
dest="path") "--generate", help="Date for video generation (YYYY-MM-DD)"
)
basicopts.add_option("--mencoder", parser.add_argument(
help="Path to mencoder binary. [default: %default]", "--timeframe",
type="string", choices=["daily", "monthly", "yearly"],
default=os.getcwdu()+'/mencoder')
parser.add_option_group(basicopts)
# When mode is: image
imageopts = optparse.OptionGroup(
parser, "Options for --mode: image", "When we are gathering images.")
imageopts.add_option("--url", help="Webcam URL", type="string")
parser.add_option_group(imageopts)
# When mode is: video
videoopts = optparse.OptionGroup(parser,
"Options for --mode: video", "When we are making movies.")
videoopts.add_option("--videorun",
default="daily", default="daily",
help="Video to process: daily, monthly or yearly [default: %default]", )
type="string") parser.add_argument(
"--simulate",
videoopts.add_option('--generate', action="store_true",
help="Date to video. Format: YYYY-MM-DD. " help="Simulation mode",
"Default is calculated yesterday, currently %default",
type="string",
default=datetime.date.today()-datetime.timedelta(1))
# TODO: mode for monthly videos
#videoopts.add_option("--gen-month",
# help="Month to video. Format: YYYY-MM. "
# "Default is last month, currently %default",
# type="string",
# default=datetime.date.today()-datetime.timedelta(30))
videoopts.add_option("--fps",
default="10",
help="Frames per second, numeric [default: %default]",
type="int")
parser.add_option_group(videoopts)
parser.add_option("-v", help="Verbose", action="store_true", dest="verbose", default=False)
parser.add_option("-q", help="Quiet", action="store_false", dest="verbose", default=True)
parser.add_option("-s", "--simulate",
help="Demostrates what will happen (good for checking your settings and destinations)",
default=False, default=False,
action="store_true") )
parser.add_argument(
"--verbose",
action="store_true",
help="Verbose output",
default=False,
)
args = parser.parse_args()
return args, parser
(options, args) = parser.parse_args(argv[1:]) @staticmethod
def get_extension(url: str | None) -> str | None:
"""Get the file extension from the URL.
if options.simulate == True: :param url: URL to extract the extension from.
print :return: File extension string, or None if url is None.
print "--- Starting simulation, just echoing steps using your parameters." """
print if url is None:
print "(!) You are running aeonview from", os.getcwdu() logging.error(AeonViewMessages.INVALID_IMAGE_EXTENSION)
return None
if options.mode == 'image': url_lower = url.lower()
# We are now in the gathering mode. if url_lower.endswith(".jpeg"):
return ".jpeg"
if url_lower.endswith(".png"):
return ".png"
if url_lower.endswith(".gif"):
return ".gif"
if url_lower.endswith(".webp"):
return ".webp"
if options.url == None and options.simulate == True: return ".jpg"
options.url = "http://example.com/webcam.jpg"
print "(!) Simulation: Using " + options.url + " as webcam url"
if options.url == None: @staticmethod
print "(!) Need a webcam url, not gonna rock before that!" def setup_logger(verbose: bool):
print """Set up the logger for the application.
parser.print_help()
sys.exit(-1)
if options.project == None: :param verbose: Enable verbose logging if True.
import hashlib # not needed before """
m = hashlib.md5(options.url).hexdigest() level = logging.DEBUG if verbose else logging.INFO
options.project = m[:5] # 5 first characters of md5-hash logging.basicConfig(level=level, format="[%(levelname)s] %(message)s")
if options.verbose == True or options.simulate == True:
print "(!) No project defined, using part of md5-hash of the webcam url:", options.project
if options.path == None: @staticmethod
if options.verbose == True or options.simulate == True: def generate_ffmpeg_command(
print "(!) No destination defined, using:", options.path input_dir: Path, output_file: Path, fps: int = 10
else: ) -> list:
if options.verbose == True or options.simulate == True: """Generate the ffmpeg command to create a video from images.
print "(!) Using destination:", options.path
# If you want to change the path structure, here's your chance. :param input_dir: Directory containing the images.
options.imgpath = time.strftime("/img/%Y-%m/%d/") :param output_file: Path to the output video file.
options.imgname = time.strftime("%H-%M-%S") :param fps: Frames per second for the video.
:return: ffmpeg command as a list of strings.
"""
return [
"ffmpeg",
"-y",
"-framerate",
str(fps),
"-pattern_type",
"glob",
"-i",
str(input_dir / "*.{jpg,jpeg,png,gif,webp}"),
"-c:v",
"libx264",
"-pix_fmt",
"yuv420p",
str(output_file),
]
# Let us build the destination path and filename @staticmethod
options.fileext = os.path.splitext(options.url)[1] def generate_concat_command(
options.destdir = options.path + "/" + options.project + options.imgpath input_files: list[Path], output_file: Path
options.destination = options.destdir + options.imgname + options.fileext ) -> tuple[list[str], Path]:
getit = options.url + " -o " + options.destination """Generate an ffmpeg concat demuxer command for joining video files.
# Crude, but works. :param input_files: List of input video file paths.
if options.simulate == False: :param output_file: Path to the output video file.
os.system("curl --create-dirs --silent %s" % getit) :return: Tuple of (command list, temp concat file path).
else: """
print "(!) Simulation: Making path:", options.destdir concat_list = tempfile.NamedTemporaryFile(
print "(!) Simulation: curl (--create-dirs and --silent)", getit mode="w", suffix=".txt", delete=False
)
for f in input_files:
concat_list.write(f"file '{f}'\n")
concat_list.close()
elif options.mode == "video": cmd = [
# We are now in the video producing mode "ffmpeg",
"-y",
vid_extension = ".avi" "-f",
#m = os.getcwd() + "/mencoder" "concat",
m = options.mencoder "-safe",
mencoder = m + " -really-quiet -mf fps="+ str(options.fps) +" -nosound -ovc lavc -lavcopts vcodec=mpeg4" "0",
"-i",
if options.project == None: concat_list.name,
print "(!) No project defined, please specify what project you are working on." "-c",
print "copy",
parser.print_help() str(output_file),
sys.exit(-1) ]
return cmd, Path(concat_list.name)
if options.videorun == "daily":
vid_date = str(options.generate).split("-")
year = vid_date[0]
month = vid_date[1]
day = vid_date[2]
if check_date(int(year), int(month), int(day)):
proj_dir = options.path + "/" + options.project
video_dir = proj_dir + "/img/" + year + "-" + month + "/" + day + "/*"
video_out_dir = proj_dir + "/vid/" + year + "-" + month + "/"
video_out_day = video_out_dir + day + vid_extension
mfdir = os.path.dirname(os.path.realpath(video_dir))
command = mencoder + " -o " + video_out_day + " 'mf://" + mfdir + "/*'"
if options.simulate == False:
mkdir_p( video_out_dir )
os.system(command)
else:
print "(!) Video dir to process:", video_dir
print "(!) Video output-file:", video_out_day
print "(!) Made directory structure:", video_out_dir
print "(!) Command to run", command
else:
print "(!) Error: check your date. Value provided:", options.generate
elif options.videorun == "monthly":
print "Monthly: TODO"
# TODO Monthly script. Joins daily movies of that month.
elif options.videorun == "yearly":
print "Yearly: TODO"
# TODO Yearly script. Joins monthly movies together.
else:
print "(!) What? Please choose between -r daily/montly/yearly"
else:
parser.print_help()
sys.exit(-1)
# http://stackoverflow.com/questions/600268/mkdir-p-functionality-in-python/600612#600612 class AeonViewApp:
def mkdir_p(path): """Main application class for AeonView.
Parses arguments and dispatches to image download or video generation
workflows based on the selected mode.
"""
def __init__(self):
args, parser = AeonViewHelpers.parse_arguments()
self.args: argparse.Namespace = args
self.parser: argparse.ArgumentParser = parser
if self.args is None:
logging.error("No arguments provided.")
self.parser.print_help()
sys.exit(1)
AeonViewHelpers.setup_logger(self.args.verbose)
self.base_path = Path(self.args.dest).resolve()
def run(self):
"""Execute the application based on the provided arguments."""
if self.args.simulate:
logging.info("Simulation mode active. No actions will be executed.")
if self.args.mode == "image":
self.process_image()
elif self.args.mode == "video":
self.process_video()
def process_image(self):
"""Process image download and saving based on the provided arguments.
:raises SystemExit: If the URL is missing or invalid.
"""
if not self.args.url or self.args.url is None:
logging.error("--url is required in image mode")
self.parser.print_help()
sys.exit(1)
if not isinstance(self.args.url, str) or not self.args.url.startswith(
"http"
):
logging.error("%s: %s", AeonViewMessages.INVALID_URL, self.args.url)
sys.exit(1)
url = self.args.url
project = self.args.project or "default"
if project == "default" and url:
project = hashlib.md5(url.encode()).hexdigest()[:5]
project_path = AeonViewHelpers.build_path(self.base_path, project)
avi = AeonViewImages(project_path, url, self.args)
avi.get_current_image()
def process_video(self):
"""Process video generation based on the provided arguments.
:raises SystemExit: If the project is missing, invalid, or the
generate date cannot be parsed.
"""
if not self.args.project:
logging.error("--project is required in video mode")
self.parser.print_help()
sys.exit(1)
if not isinstance(self.args.project, str):
logging.error("Invalid project name: %s", self.args.project)
sys.exit(1)
project_path = AeonViewHelpers.build_path(
self.base_path, self.args.project
)
if not project_path.exists():
logging.error("Project path %s does not exist.", project_path)
sys.exit(1)
generate_date = None
try: try:
os.makedirs(path) generate_date = (
except OSError as exc: # Python >2.5 datetime.strptime(self.args.generate, "%Y-%m-%d")
if exc.errno == errno.EEXIST: if self.args.generate
pass else datetime.today() - timedelta(days=1)
else: raise )
except ValueError:
logging.error(AeonViewMessages.INVALID_DATE)
sys.exit(1)
year = generate_date.strftime("%Y")
month = generate_date.strftime("%m")
day = generate_date.strftime("%d")
self.args.day = day
self.args.month = month
self.args.year = year
args: argparse.Namespace = self.args
avm = AeonViewVideos(project_path, args)
if self.args.timeframe == "daily":
avm.generate_daily_video()
elif self.args.timeframe == "monthly":
avm.generate_monthly_video()
elif self.args.timeframe == "yearly":
avm.generate_yearly_video()
# Modified http://markmail.org/message/k2pxsle2lslrmnut if __name__ == "__main__": # pragma: no cover
def check_date(year, month, day): app = AeonViewApp()
tup1 = (year, month, day, 0,0,0,0,0,0) app.run()
try:
date = time.mktime (tup1)
tup2 = time.localtime (date)
if tup1[:2] != tup2[:2]:
return False
else:
return True
except OverflowError:
return False
# vim: set tw=100 fo=cqt wm=0 et:
if __name__ == '__main__':
aeonview(sys.argv)

908
aeonview_test.py Normal file
View File

@@ -0,0 +1,908 @@
"""Tests for the aeonview timelapse generator."""
# vim: set ft=python ts=4 sw=4 sts=4 et wrap:
import argparse
import contextlib
import logging
import subprocess
import tempfile
from datetime import datetime
from pathlib import Path
from unittest import mock
import pytest
from aeonview import (
AeonViewApp,
AeonViewHelpers,
AeonViewImages,
AeonViewMessages,
AeonViewVideos,
)
default_dest = str(Path.cwd() / "projects")
default_project = "default"
default_fps = 10
default_timeframe = "daily"
default_simulate = False
default_verbose = False
default_image_domain = "https://example.com/image"
default_test_path = Path(tempfile.gettempdir(), "test_project").resolve()
tmp_images = Path(tempfile.gettempdir(), "images")
# ---------------------------------------------------------------------------
# Test helpers
# ---------------------------------------------------------------------------
def make_video_args(
simulate=False, fps=10, day="01", month="04", year="2025"
) -> argparse.Namespace:
"""Create an ``argparse.Namespace`` with video-generation defaults."""
return argparse.Namespace(
simulate=simulate, fps=fps, day=day, month=month, year=year
)
@contextlib.contextmanager
def expect_error_exit():
"""Expect ``SystemExit`` and silence ``logging.error``."""
with pytest.raises(SystemExit), mock.patch("aeonview.logging.error"):
yield
def make_app_with_project(tmp: str) -> tuple[AeonViewApp, Path]:
"""Create an ``AeonViewApp`` with base_path at *tmp*/'proj'."""
app = AeonViewApp()
app.base_path = Path(tmp).resolve()
proj_path = app.base_path / "proj"
proj_path.mkdir()
return app, proj_path
# ---------------------------------------------------------------------------
# AeonViewHelpers tests
# ---------------------------------------------------------------------------
def test_build_path_resolves_correctly():
base = Path(tempfile.gettempdir())
result = AeonViewHelpers.build_path(base, "a", "b", "c")
assert result == Path(base, "a", "b", "c").resolve()
def test_check_date_valid():
assert AeonViewHelpers.check_date(2023, 12, 31)
def test_check_date_invalid():
assert not AeonViewHelpers.check_date(2023, 2, 30)
def test_mkdir_p_creates_directory():
with tempfile.TemporaryDirectory() as tmp:
test_path = Path(tmp) / "a" / "b" / "c"
AeonViewHelpers.mkdir_p(test_path)
assert test_path.exists()
assert test_path.is_dir()
@pytest.mark.parametrize("ext", ["jpg", "png", "gif", "webp"])
def test_get_extension_valid(ext):
assert (
AeonViewHelpers.get_extension(f"{default_image_domain}.{ext}")
== f".{ext}"
)
def test_get_extension_invalid():
assert AeonViewHelpers.get_extension(default_image_domain) == ".jpg"
assert AeonViewHelpers.get_extension(None) is None
def test_get_extension_jpeg():
assert (
AeonViewHelpers.get_extension(f"{default_image_domain}.jpeg") == ".jpeg"
)
def test_generate_ffmpeg_command():
input_dir = tmp_images
output_file = Path(tempfile.gettempdir(), "output.mp4")
fps = 24
cmd = AeonViewHelpers.generate_ffmpeg_command(input_dir, output_file, fps)
assert "ffmpeg" in cmd[0]
assert str(fps) in cmd
assert str(output_file) == cmd[-1]
assert str(input_dir / "*.{jpg,jpeg,png,gif,webp}") in cmd
def test_generate_ffmpeg_command_output_format():
input_dir = tmp_images
output_file = Path(tempfile.gettempdir(), "video.mp4")
cmd = AeonViewHelpers.generate_ffmpeg_command(input_dir, output_file, 30)
assert str(tmp_images / "*.{jpg,jpeg,png,gif,webp}") in cmd
assert str(output_file) in cmd
assert "-c:v" in cmd
assert "libx264" in cmd
assert "-pix_fmt" in cmd
assert "yuv420p" in cmd
@mock.patch("subprocess.run")
def test_simulate_ffmpeg_call(mock_run):
input_dir = tmp_images
output_file = Path(tempfile.gettempdir(), "out.mp4")
cmd = AeonViewHelpers.generate_ffmpeg_command(input_dir, output_file, 10)
subprocess.run(cmd, check=True)
mock_run.assert_called_once_with(cmd, check=True)
def test_generate_concat_command():
with tempfile.TemporaryDirectory() as tmp:
files = [Path(tmp) / "01.mp4", Path(tmp) / "02.mp4"]
for f in files:
f.touch()
output = Path(tmp) / "output.mp4"
cmd, concat_file = AeonViewHelpers.generate_concat_command(
files, output
)
try:
assert cmd[0] == "ffmpeg"
assert "-f" in cmd
assert "concat" in cmd
assert "-safe" in cmd
assert "0" in cmd
assert "-c" in cmd
assert "copy" in cmd
assert str(output) == cmd[-1]
assert concat_file.exists()
content = concat_file.read_text(encoding="utf-8")
for f in files:
assert f"file '{f}'" in content
finally:
concat_file.unlink(missing_ok=True)
# ---------------------------------------------------------------------------
# AeonViewImages tests
# ---------------------------------------------------------------------------
def test_get_image_paths_valid():
url = f"{default_image_domain}.jpg"
destination_base = default_test_path
date = datetime(2025, 4, 10, 12, 30, 45)
aeon_view_images = AeonViewImages(destination_base, url)
paths = aeon_view_images.get_image_paths(url, destination_base, date)
a_p = destination_base / "2025-04" / "10" / "12-30-45.jpg"
assert paths["url"] == url
assert paths["file"] == "12-30-45.jpg"
assert paths["destinations"]["file"] == a_p
def test_get_image_paths_invalid_url():
with expect_error_exit():
aeon_view_images = AeonViewImages(default_test_path, "invalid-url")
aeon_view_images.get_image_paths(
"invalid-url", default_test_path, datetime(2025, 4, 10)
)
def test_get_image_paths_invalid_date():
with expect_error_exit():
aeon_view_images = AeonViewImages(
default_test_path, f"{default_image_domain}.jpg"
)
# noinspection PyTypeChecker
aeon_view_images.get_image_paths(
f"{default_image_domain}.jpg",
default_test_path,
"invalid-date", # pyright: ignore [reportArgumentType]
)
def test_get_image_paths_none_url():
avi = AeonViewImages(default_test_path, None)
with expect_error_exit():
avi.get_image_paths(None, default_test_path, datetime(2025, 4, 10))
def test_get_image_paths_no_image_extension():
url = "https://example.com/image.bmp"
avi = AeonViewImages(default_test_path, url)
with expect_error_exit():
avi.get_image_paths(url, default_test_path, datetime(2025, 4, 10))
def test_get_image_paths_none_destination():
url = f"{default_image_domain}.jpg"
avi = AeonViewImages(default_test_path, url)
with expect_error_exit():
avi.get_image_paths(url, None, datetime(2025, 4, 10))
def test_get_image_paths_non_path_destination():
url = f"{default_image_domain}.jpg"
avi = AeonViewImages(default_test_path, url)
with expect_error_exit():
# noinspection PyTypeChecker
avi.get_image_paths(
url,
"/tmp/not-a-path-object", # pyright: ignore [reportArgumentType]
datetime(2025, 4, 10),
)
def test_get_image_paths_creates_directory():
with tempfile.TemporaryDirectory() as tmp:
url = f"{default_image_domain}.jpg"
dest = Path(tmp) / "newproject"
avi = AeonViewImages(dest, url)
paths = avi.get_image_paths(url, dest, datetime(2025, 4, 10, 12, 0, 0))
assert paths["file"] == "12-00-00.jpg"
assert (dest / "2025-04" / "10").exists()
def test_get_image_paths_simulate_no_mkdir():
with tempfile.TemporaryDirectory() as tmp:
url = f"{default_image_domain}.jpg"
dest = Path(tmp) / "simproject"
args = argparse.Namespace(simulate=True)
avi = AeonViewImages(dest, url, args)
with mock.patch("aeonview.logging.info"):
paths = avi.get_image_paths(
url, dest, datetime(2025, 4, 10, 12, 0, 0)
)
assert paths["file"] == "12-00-00.jpg"
assert not (dest / "2025-04" / "10").exists()
@mock.patch("aeonview.AeonViewHelpers.mkdir_p")
@mock.patch("aeonview.AeonViewImages.download_image")
def test_get_current_image(mock_download_image, mock_mkdir_p):
args = argparse.Namespace(simulate=False, date="2025-04-10 12:30:45")
avi = AeonViewImages(default_test_path, f"{default_image_domain}.jpg", args)
avi.get_current_image()
mock_mkdir_p.assert_called()
mock_download_image.assert_called()
def test_get_current_image_invalid_date_format():
args = argparse.Namespace(simulate=False, date="not-a-date")
avi = AeonViewImages(default_test_path, f"{default_image_domain}.jpg", args)
with expect_error_exit():
avi.get_current_image()
@mock.patch("aeonview.AeonViewHelpers.mkdir_p")
@mock.patch("aeonview.AeonViewImages.download_image")
def test_get_current_image_no_date_uses_now(mock_download, mock_mkdir):
args = argparse.Namespace(simulate=False, date=None)
avi = AeonViewImages(default_test_path, f"{default_image_domain}.jpg", args)
avi.get_current_image()
mock_mkdir.assert_called()
mock_download.assert_called()
def test_get_current_image_no_url():
args = argparse.Namespace(simulate=False, date="2025-04-10 12:30:45")
avi = AeonViewImages(default_test_path, None, args)
with expect_error_exit():
avi.get_current_image()
@mock.patch("aeonview.requests.get")
def test_download_image_success(mock_get):
mock_response = mock.Mock()
mock_response.status_code = 200
mock_response.iter_content = mock.Mock(return_value=[b"data"])
mock_get.return_value = mock_response
args = argparse.Namespace(simulate=False)
avi = AeonViewImages(default_test_path, f"{default_image_domain}.jpg", args)
with tempfile.NamedTemporaryFile(delete=True) as temp_file:
destination = Path(temp_file.name)
avi.download_image(destination)
mock_get.assert_called_once_with(
f"{default_image_domain}.jpg", stream=True, timeout=10
)
assert destination.exists()
@mock.patch("aeonview.requests.get")
def test_download_image_failure(mock_get):
mock_response = mock.Mock()
mock_response.status_code = 404
mock_get.return_value = mock_response
args = argparse.Namespace(simulate=False)
avi = AeonViewImages(default_test_path, f"{default_image_domain}.jpg", args)
destination = Path(tempfile.gettempdir(), "image.jpg")
with expect_error_exit():
avi.download_image(destination)
def test_download_image_no_url():
args = argparse.Namespace(simulate=False)
avi = AeonViewImages(default_test_path, None, args)
with expect_error_exit():
avi.download_image(Path(tempfile.gettempdir(), "image.jpg"))
def test_download_image_non_path_destination():
args = argparse.Namespace(simulate=False)
avi = AeonViewImages(default_test_path, f"{default_image_domain}.jpg", args)
with expect_error_exit():
# noinspection PyTypeChecker
avi.download_image(
"/tmp/image.jpg" # pyright: ignore [reportArgumentType]
)
def test_download_image_simulate():
args = argparse.Namespace(simulate=True)
avi = AeonViewImages(default_test_path, f"{default_image_domain}.jpg", args)
with mock.patch("aeonview.logging.info") as log:
avi.download_image(Path(tempfile.gettempdir(), "image.jpg"))
assert any("Simulate" in str(call) for call in log.call_args_list)
@mock.patch("aeonview.AeonViewHelpers.mkdir_p")
@mock.patch("aeonview.AeonViewImages.download_image")
def test_image_simulation(mock_download_image, mock_mkdir_p):
args = mock.MagicMock()
args.simulate = True
args.date = "2025-04-10 12:30:45"
avi = AeonViewImages(default_test_path, f"{default_image_domain}.jpg", args)
with mock.patch("aeonview.logging.info") as log:
avi.get_current_image()
mock_mkdir_p.assert_not_called()
mock_download_image.assert_not_called()
assert any(
"Saving image to" in str(call) for call in log.call_args_list
)
# ---------------------------------------------------------------------------
# AeonViewVideos tests
# ---------------------------------------------------------------------------
@mock.patch("subprocess.run")
def test_generate_daily_video(mock_subprocess_run):
with tempfile.TemporaryDirectory() as tmp:
project_path = Path(tmp).resolve()
args = make_video_args()
avv = AeonViewVideos(project_path, args)
# Create input directory so the existence check passes
(project_path / "img" / "2025-04" / "01").mkdir(parents=True)
with mock.patch("aeonview.AeonViewHelpers.mkdir_p") as mock_mkdir_p:
with mock.patch("aeonview.logging.info") as log:
avv.generate_daily_video()
last_call_args = log.call_args_list[-1][0]
assert last_call_args[0] == "%s: %s"
assert (
last_call_args[1]
== AeonViewMessages.VIDEO_GENERATION_SUCCESS
)
assert last_call_args[2] == (
project_path / "vid" / "2025-04" / "01.mp4"
)
mock_mkdir_p.assert_called()
mock_subprocess_run.assert_called()
@mock.patch("aeonview.AeonViewHelpers.mkdir_p")
def test_generate_daily_video_simulate(mock_mkdir_p):
args = make_video_args(simulate=True)
avv = AeonViewVideos(default_test_path, args)
avv.generate_daily_video()
mock_mkdir_p.assert_not_called()
def test_generate_daily_video_missing_input_dir():
with tempfile.TemporaryDirectory() as tmp:
project_path = Path(tmp).resolve()
args = make_video_args()
avv = AeonViewVideos(project_path, args)
with expect_error_exit():
avv.generate_daily_video()
@mock.patch("aeonview.AeonViewHelpers.mkdir_p")
@mock.patch("subprocess.run")
def test_video_simulation(mock_subprocess_run, mock_mkdir_p):
args = mock.MagicMock()
args.simulate = True
args.fps = 10
args.day = "01"
args.month = "01"
args.year = "2023"
avv = AeonViewVideos(default_test_path, args)
with mock.patch("aeonview.logging.info") as log:
avv.generate_daily_video()
mock_mkdir_p.assert_not_called()
mock_subprocess_run.assert_not_called()
assert any(
"Generating video from" in str(call) for call in log.call_args_list
)
# --- Monthly video tests ---
@mock.patch("subprocess.run")
def test_generate_monthly_video(mock_subprocess_run):
with tempfile.TemporaryDirectory() as tmp:
project_path = Path(tmp).resolve()
vid_dir = project_path / "vid" / "2025-04"
vid_dir.mkdir(parents=True)
# Create fake daily videos
for day in ["01", "02", "03"]:
(vid_dir / f"{day}.mp4").touch()
args = make_video_args()
avv = AeonViewVideos(project_path, args)
avv.generate_monthly_video()
mock_subprocess_run.assert_called_once()
call_cmd = mock_subprocess_run.call_args[0][0]
assert "concat" in call_cmd
assert str(vid_dir / "2025-04.mp4") == call_cmd[-1]
@mock.patch("subprocess.run")
def test_generate_monthly_video_excludes_output_file(mock_subprocess_run):
"""Verify monthly output file is excluded from inputs on re-runs."""
captured_content = {}
def capture_concat(cmd, **_kwargs):
idx = cmd.index("-i") + 1
captured_content["text"] = Path(cmd[idx]).read_text(encoding="utf-8")
mock_subprocess_run.side_effect = capture_concat
with tempfile.TemporaryDirectory() as tmp:
project_path = Path(tmp).resolve()
vid_dir = project_path / "vid" / "2025-04"
vid_dir.mkdir(parents=True)
# Create daily videos plus a leftover monthly output
for day in ["01", "02"]:
(vid_dir / f"{day}.mp4").touch()
(vid_dir / "2025-04.mp4").touch() # leftover from previous run
args = make_video_args()
avv = AeonViewVideos(project_path, args)
avv.generate_monthly_video()
mock_subprocess_run.assert_called_once()
content = captured_content["text"]
assert "2025-04.mp4" not in content
assert "01.mp4" in content
assert "02.mp4" in content
def test_generate_monthly_video_simulate():
with tempfile.TemporaryDirectory() as tmp:
project_path = Path(tmp).resolve()
vid_dir = project_path / "vid" / "2025-04"
vid_dir.mkdir(parents=True)
(vid_dir / "01.mp4").touch()
args = make_video_args(simulate=True)
avv = AeonViewVideos(project_path, args)
with mock.patch("subprocess.run") as mock_run:
avv.generate_monthly_video()
mock_run.assert_not_called()
def test_generate_monthly_video_no_input_dir():
with tempfile.TemporaryDirectory() as tmp:
project_path = Path(tmp).resolve()
# Don't create the vid directory
args = make_video_args()
avv = AeonViewVideos(project_path, args)
with expect_error_exit():
avv.generate_monthly_video()
def test_generate_monthly_video_no_mp4_files():
with tempfile.TemporaryDirectory() as tmp:
project_path = Path(tmp).resolve()
vid_dir = project_path / "vid" / "2025-04"
vid_dir.mkdir(parents=True)
# No mp4 files in directory
args = make_video_args()
avv = AeonViewVideos(project_path, args)
with expect_error_exit():
avv.generate_monthly_video()
# --- Yearly video tests ---
@mock.patch("subprocess.run")
def test_generate_yearly_video(mock_subprocess_run):
with tempfile.TemporaryDirectory() as tmp:
project_path = Path(tmp).resolve()
# Create monthly video files in their directories
for month in ["01", "02", "03"]:
month_dir = project_path / "vid" / f"2025-{month}"
month_dir.mkdir(parents=True)
(month_dir / f"2025-{month}.mp4").touch()
args = make_video_args(month="01", year="2025")
avv = AeonViewVideos(project_path, args)
avv.generate_yearly_video()
mock_subprocess_run.assert_called_once()
call_cmd = mock_subprocess_run.call_args[0][0]
assert "concat" in call_cmd
output_dir = project_path / "vid" / "2025"
assert str(output_dir / "2025.mp4") == call_cmd[-1]
def test_generate_yearly_video_simulate():
with tempfile.TemporaryDirectory() as tmp:
project_path = Path(tmp).resolve()
month_dir = project_path / "vid" / "2025-01"
month_dir.mkdir(parents=True)
(month_dir / "2025-01.mp4").touch()
args = make_video_args(simulate=True, month="01", year="2025")
avv = AeonViewVideos(project_path, args)
with mock.patch("subprocess.run") as mock_run:
avv.generate_yearly_video()
mock_run.assert_not_called()
def test_generate_yearly_video_no_monthly_videos():
with tempfile.TemporaryDirectory() as tmp:
project_path = Path(tmp).resolve()
(project_path / "vid").mkdir(parents=True)
args = make_video_args(month="01", year="2025")
avv = AeonViewVideos(project_path, args)
with expect_error_exit():
avv.generate_yearly_video()
# ---------------------------------------------------------------------------
# Argument parsing tests
# ---------------------------------------------------------------------------
@mock.patch(
"sys.argv",
["aeonview.py", "--mode", "image", "--url", f"{default_image_domain}.jpg"],
)
def test_parse_arguments_image_mode():
args, _ = AeonViewHelpers.parse_arguments()
assert args.mode == "image"
assert args.url == f"{default_image_domain}.jpg"
assert args.dest == default_dest
@mock.patch(
"sys.argv",
["aeonview.py", "--mode", "video", "--project", f"{default_project}"],
)
def test_parse_arguments_video_mode():
args, _ = AeonViewHelpers.parse_arguments()
assert args.mode == "video"
assert args.project == default_project
assert args.dest == default_dest
@mock.patch("sys.argv", ["aeonview.py", "--mode", "image", "--simulate"])
def test_parse_arguments_simulate_mode():
args, _ = AeonViewHelpers.parse_arguments()
assert args.mode == "image"
assert args.simulate
@mock.patch("sys.argv", ["aeonview.py", "--mode", "video", "--fps", "30"])
def test_parse_arguments_fps():
args, _ = AeonViewHelpers.parse_arguments()
assert args.mode == "video"
assert args.project == default_project
assert args.dest == default_dest
assert args.fps == 30
@mock.patch(
"sys.argv", ["aeonview.py", "--mode", "video", "--generate", "2023-10-01"]
)
def test_parse_arguments_generate_date():
args, _ = AeonViewHelpers.parse_arguments()
assert args.mode == "video"
assert args.generate == "2023-10-01"
@mock.patch("sys.argv", ["aeonview.py", "--mode", "image", "--verbose"])
def test_parse_arguments_verbose():
args, _ = AeonViewHelpers.parse_arguments()
assert args.mode == "image"
assert args.verbose
@mock.patch("sys.argv", ["aeonview.py"])
def test_parse_arguments_defaults():
args, _ = AeonViewHelpers.parse_arguments()
assert args.mode == "image"
assert args.project == default_project
assert args.dest == default_dest
assert args.fps == 10
assert args.timeframe == "daily"
assert not args.simulate
assert not args.verbose
# ---------------------------------------------------------------------------
# Logger tests
# ---------------------------------------------------------------------------
@mock.patch("logging.basicConfig")
def test_setup_logger_verbose(mock_basic_config):
AeonViewHelpers.setup_logger(verbose=True)
mock_basic_config.assert_called_once_with(
level=logging.DEBUG, format="[%(levelname)s] %(message)s"
)
@mock.patch("logging.basicConfig")
def test_setup_logger_non_verbose(mock_basic_config):
AeonViewHelpers.setup_logger(verbose=False)
mock_basic_config.assert_called_once_with(
level=logging.INFO, format="[%(levelname)s] %(message)s"
)
# ---------------------------------------------------------------------------
# AeonViewApp tests
# ---------------------------------------------------------------------------
@mock.patch(
"sys.argv",
["aeonview", "--mode", "image", "--url", "https://example.com/image.jpg"],
)
def test_app_init():
app = AeonViewApp()
assert app.args.mode == "image"
assert app.args.url == "https://example.com/image.jpg"
@mock.patch(
"sys.argv",
["aeonview", "--mode", "image", "--url", "https://example.com/image.jpg"],
)
@mock.patch("aeonview.AeonViewApp.process_image")
def test_app_run_image_mode(mock_process):
app = AeonViewApp()
app.run()
mock_process.assert_called_once()
@mock.patch(
"sys.argv",
[
"aeonview",
"--mode",
"video",
"--project",
"myproj",
],
)
@mock.patch("aeonview.AeonViewApp.process_video")
def test_app_run_video_mode(mock_process):
app = AeonViewApp()
app.run()
mock_process.assert_called_once()
@mock.patch(
"sys.argv",
[
"aeonview",
"--mode",
"image",
"--simulate",
"--url",
"https://example.com/image.jpg",
],
)
def test_app_run_simulate_logs():
app = AeonViewApp()
with (
mock.patch("aeonview.AeonViewApp.process_image"),
mock.patch("aeonview.logging.info") as log,
):
app.run()
assert any("Simulation" in str(call) for call in log.call_args_list)
@mock.patch("sys.argv", ["aeonview", "--mode", "image"])
def test_app_process_image_no_url():
app = AeonViewApp()
with expect_error_exit():
app.process_image()
@mock.patch("sys.argv", ["aeonview", "--mode", "image", "--url", "not-http"])
def test_app_process_image_invalid_url():
app = AeonViewApp()
with expect_error_exit():
app.process_image()
@mock.patch(
"sys.argv",
["aeonview", "--mode", "image", "--url", "https://example.com/image.jpg"],
)
@mock.patch("aeonview.AeonViewImages.get_current_image")
def test_app_process_image_default_project_hash(mock_get_image):
app = AeonViewApp()
app.process_image()
mock_get_image.assert_called_once()
@mock.patch(
"sys.argv",
[
"aeonview",
"--mode",
"image",
"--url",
"https://example.com/image.jpg",
"--project",
"myproject",
],
)
@mock.patch("aeonview.AeonViewImages.get_current_image")
def test_app_process_image_named_project(mock_get_image):
app = AeonViewApp()
app.process_image()
mock_get_image.assert_called_once()
@mock.patch(
"sys.argv",
["aeonview", "--mode", "video", "--project", ""],
)
def test_app_process_video_no_project():
app = AeonViewApp()
app.args.project = ""
with expect_error_exit():
app.process_video()
@mock.patch(
"sys.argv",
["aeonview", "--mode", "video", "--project", "nonexistent"],
)
def test_app_process_video_missing_path():
app = AeonViewApp()
with expect_error_exit():
app.process_video()
@mock.patch(
"sys.argv",
[
"aeonview",
"--mode",
"video",
"--project",
"proj",
"--generate",
"2025-04-10",
],
)
@mock.patch("aeonview.AeonViewVideos.generate_daily_video")
def test_app_process_video_with_generate_date(mock_gen):
with tempfile.TemporaryDirectory() as tmp:
app, _ = make_app_with_project(tmp)
app.process_video()
mock_gen.assert_called_once()
@mock.patch(
"sys.argv",
["aeonview", "--mode", "video", "--project", "proj"],
)
@mock.patch("aeonview.AeonViewVideos.generate_daily_video")
def test_app_process_video_default_date(mock_gen):
with tempfile.TemporaryDirectory() as tmp:
app, _ = make_app_with_project(tmp)
app.process_video()
mock_gen.assert_called_once()
@mock.patch(
"sys.argv",
[
"aeonview",
"--mode",
"video",
"--project",
"proj",
"--generate",
"invalid-date",
],
)
def test_app_process_video_invalid_date():
with tempfile.TemporaryDirectory() as tmp:
app, _ = make_app_with_project(tmp)
with expect_error_exit():
app.process_video()
@mock.patch(
"sys.argv",
[
"aeonview",
"--mode",
"video",
"--project",
"proj",
"--timeframe",
"monthly",
],
)
@mock.patch("aeonview.AeonViewVideos.generate_monthly_video")
def test_app_process_video_monthly(mock_gen):
with tempfile.TemporaryDirectory() as tmp:
app, _ = make_app_with_project(tmp)
app.process_video()
mock_gen.assert_called_once()
@mock.patch(
"sys.argv",
[
"aeonview",
"--mode",
"video",
"--project",
"proj",
"--timeframe",
"yearly",
],
)
@mock.patch("aeonview.AeonViewVideos.generate_yearly_video")
def test_app_process_video_yearly(mock_gen):
with tempfile.TemporaryDirectory() as tmp:
app, _ = make_app_with_project(tmp)
app.process_video()
mock_gen.assert_called_once()
@mock.patch("sys.argv", ["aeonview"])
def test_app_init_no_args():
with (
mock.patch(
"aeonview.AeonViewHelpers.parse_arguments",
return_value=(None, argparse.ArgumentParser()),
),
pytest.raises(SystemExit),
):
AeonViewApp()
@mock.patch(
"sys.argv",
[
"aeonview",
"--mode",
"video",
"--project",
"proj",
"--generate",
"2025-04-10",
],
)
def test_app_process_video_invalid_project_type():
with tempfile.TemporaryDirectory() as tmp:
app, _ = make_app_with_project(tmp)
# noinspection PyTypeChecker
app.args.project = 12345 # pyright: ignore [reportAttributeAccessIssue]
with expect_error_exit():
app.process_video()

9
conftest.py Normal file
View File

@@ -0,0 +1,9 @@
# conftest.py
from unittest import mock
import pytest
@pytest.fixture(autouse=True)
def no_network_requests(monkeypatch):
monkeypatch.setattr("subprocess.run", mock.Mock())

View File

@@ -1,3 +1,5 @@
#!/bin/sh #!/bin/sh
python aeonview.py -p abc-la --url http://abclocal.go.com/three/kabc/webcam/web2-1.jpg python aeonview.py \
-p abc-la \
--url http://abclocal.go.com/three/kabc/webcam/web2-1.jpg

BIN
mencoder

Binary file not shown.

70
pyproject.toml Normal file
View File

@@ -0,0 +1,70 @@
[project]
name = "aeonview"
version = "0.1.0"
description = "A simple timelapse tool using ffmpeg and Python"
readme = "README.md"
requires-python = ">=3.13.2"
license = { text = "MIT" }
authors = [{ name = "Ismo Vuorinen" }]
dependencies = ["requests>=2.32.3"]
[dependency-groups]
dev = [
"pre-commit>=3.5.0",
"pylint>=3.0.0",
"pyright>=1.1.0",
"pytest>=8.0.0",
"pytest-cov>=4.1.0",
"ruff>=0.3.3",
]
[tool.ruff]
line-length = 80
target-version = "py313"
[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP", "C4", "T20"]
ignore = ["E501"]
[tool.ruff.lint.per-file-ignores]
"*_test.py" = ["S101"]
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
[tool.pytest.ini_options]
testpaths = ["."]
python_files = ["*_test.py"]
[tool.pyright]
pythonVersion = "3.13"
typeCheckingMode = "basic"
venvPath = "."
venv = ".venv"
[tool.pylint.MAIN]
ignore-patterns = ["^\\.#"]
ignore-paths = ["^\\.#"]
ignore = ["CVS", ".venv"]
[tool.pylint."messages_control"]
disable = [
"attribute-defined-outside-init",
"invalid-name",
"missing-docstring",
"protected-access",
"too-many-instance-attributes",
"too-few-public-methods",
"format",
"line-too-long",
"ungrouped-imports",
"wrong-import-order",
"unused-import",
"reimported",
"consider-using-f-string",
"unnecessary-comprehension",
"use-a-generator",
"consider-using-with",
"import-error",
]

505
uv.lock generated Normal file
View File

@@ -0,0 +1,505 @@
version = 1
revision = 3
requires-python = ">=3.13.2"
[[package]]
name = "aeonview"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "requests" },
]
[package.dev-dependencies]
dev = [
{ name = "pre-commit" },
{ name = "pylint" },
{ name = "pyright" },
{ name = "pytest" },
{ name = "pytest-cov" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [{ name = "requests", specifier = ">=2.32.3" }]
[package.metadata.requires-dev]
dev = [
{ name = "pre-commit", specifier = ">=3.5.0" },
{ name = "pylint", specifier = ">=3.0.0" },
{ name = "pyright", specifier = ">=1.1.0" },
{ name = "pytest", specifier = ">=8.0.0" },
{ name = "pytest-cov", specifier = ">=4.1.0" },
{ name = "ruff", specifier = ">=0.3.3" },
]
[[package]]
name = "astroid"
version = "4.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/07/63/0adf26577da5eff6eb7a177876c1cfa213856be9926a000f65c4add9692b/astroid-4.0.4.tar.gz", hash = "sha256:986fed8bcf79fb82c78b18a53352a0b287a73817d6dbcfba3162da36667c49a0", size = 406358, upload-time = "2026-02-07T23:35:07.509Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b0/cf/1c5f42b110e57bc5502eb80dbc3b03d256926062519224835ef08134f1f9/astroid-4.0.4-py3-none-any.whl", hash = "sha256:52f39653876c7dec3e3afd4c2696920e05c83832b9737afc21928f2d2eb7a753", size = 276445, upload-time = "2026-02-07T23:35:05.344Z" },
]
[[package]]
name = "certifi"
version = "2026.2.25"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
]
[[package]]
name = "cfgv"
version = "3.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" },
]
[[package]]
name = "charset-normalizer"
version = "3.4.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1d/35/02daf95b9cd686320bb622eb148792655c9412dbb9b67abb5694e5910a24/charset_normalizer-3.4.5.tar.gz", hash = "sha256:95adae7b6c42a6c5b5b559b1a99149f090a57128155daeea91732c8d970d8644", size = 134804, upload-time = "2026-03-06T06:03:19.46Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f5/48/9f34ec4bb24aa3fdba1890c1bddb97c8a4be1bd84ef5c42ac2352563ad05/charset_normalizer-3.4.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac59c15e3f1465f722607800c68713f9fbc2f672b9eb649fe831da4019ae9b23", size = 280788, upload-time = "2026-03-06T06:01:37.126Z" },
{ url = "https://files.pythonhosted.org/packages/0e/09/6003e7ffeb90cc0560da893e3208396a44c210c5ee42efff539639def59b/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:165c7b21d19365464e8f70e5ce5e12524c58b48c78c1f5a57524603c1ab003f8", size = 188890, upload-time = "2026-03-06T06:01:38.73Z" },
{ url = "https://files.pythonhosted.org/packages/42/1e/02706edf19e390680daa694d17e2b8eab4b5f7ac285e2a51168b4b22ee6b/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:28269983f25a4da0425743d0d257a2d6921ea7d9b83599d4039486ec5b9f911d", size = 206136, upload-time = "2026-03-06T06:01:40.016Z" },
{ url = "https://files.pythonhosted.org/packages/c7/87/942c3def1b37baf3cf786bad01249190f3ca3d5e63a84f831e704977de1f/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d27ce22ec453564770d29d03a9506d449efbb9fa13c00842262b2f6801c48cce", size = 202551, upload-time = "2026-03-06T06:01:41.522Z" },
{ url = "https://files.pythonhosted.org/packages/94/0a/af49691938dfe175d71b8a929bd7e4ace2809c0c5134e28bc535660d5262/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0625665e4ebdddb553ab185de5db7054393af8879fb0c87bd5690d14379d6819", size = 195572, upload-time = "2026-03-06T06:01:43.208Z" },
{ url = "https://files.pythonhosted.org/packages/20/ea/dfb1792a8050a8e694cfbde1570ff97ff74e48afd874152d38163d1df9ae/charset_normalizer-3.4.5-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:c23eb3263356d94858655b3e63f85ac5d50970c6e8febcdde7830209139cc37d", size = 184438, upload-time = "2026-03-06T06:01:44.755Z" },
{ url = "https://files.pythonhosted.org/packages/72/12/c281e2067466e3ddd0595bfaea58a6946765ace5c72dfa3edc2f5f118026/charset_normalizer-3.4.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e6302ca4ae283deb0af68d2fbf467474b8b6aedcd3dab4db187e07f94c109763", size = 193035, upload-time = "2026-03-06T06:01:46.051Z" },
{ url = "https://files.pythonhosted.org/packages/ba/4f/3792c056e7708e10464bad0438a44708886fb8f92e3c3d29ec5e2d964d42/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e51ae7d81c825761d941962450f50d041db028b7278e7b08930b4541b3e45cb9", size = 191340, upload-time = "2026-03-06T06:01:47.547Z" },
{ url = "https://files.pythonhosted.org/packages/e7/86/80ddba897127b5c7a9bccc481b0cd36c8fefa485d113262f0fe4332f0bf4/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:597d10dec876923e5c59e48dbd366e852eacb2b806029491d307daea6b917d7c", size = 185464, upload-time = "2026-03-06T06:01:48.764Z" },
{ url = "https://files.pythonhosted.org/packages/4d/00/b5eff85ba198faacab83e0e4b6f0648155f072278e3b392a82478f8b988b/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5cffde4032a197bd3b42fd0b9509ec60fb70918d6970e4cc773f20fc9180ca67", size = 208014, upload-time = "2026-03-06T06:01:50.371Z" },
{ url = "https://files.pythonhosted.org/packages/c8/11/d36f70be01597fd30850dde8a1269ebc8efadd23ba5785808454f2389bde/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2da4eedcb6338e2321e831a0165759c0c620e37f8cd044a263ff67493be8ffb3", size = 193297, upload-time = "2026-03-06T06:01:51.933Z" },
{ url = "https://files.pythonhosted.org/packages/1a/1d/259eb0a53d4910536c7c2abb9cb25f4153548efb42800c6a9456764649c0/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:65a126fb4b070d05340a84fc709dd9e7c75d9b063b610ece8a60197a291d0adf", size = 204321, upload-time = "2026-03-06T06:01:53.887Z" },
{ url = "https://files.pythonhosted.org/packages/84/31/faa6c5b9d3688715e1ed1bb9d124c384fe2fc1633a409e503ffe1c6398c1/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7a80a9242963416bd81f99349d5f3fce1843c303bd404f204918b6d75a75fd6", size = 197509, upload-time = "2026-03-06T06:01:56.439Z" },
{ url = "https://files.pythonhosted.org/packages/fd/a5/c7d9dd1503ffc08950b3260f5d39ec2366dd08254f0900ecbcf3a6197c7c/charset_normalizer-3.4.5-cp313-cp313-win32.whl", hash = "sha256:f1d725b754e967e648046f00c4facc42d414840f5ccc670c5670f59f83693e4f", size = 132284, upload-time = "2026-03-06T06:01:57.812Z" },
{ url = "https://files.pythonhosted.org/packages/b9/0f/57072b253af40c8aa6636e6de7d75985624c1eb392815b2f934199340a89/charset_normalizer-3.4.5-cp313-cp313-win_amd64.whl", hash = "sha256:e37bd100d2c5d3ba35db9c7c5ba5a9228cbcffe5c4778dc824b164e5257813d7", size = 142630, upload-time = "2026-03-06T06:01:59.062Z" },
{ url = "https://files.pythonhosted.org/packages/31/41/1c4b7cc9f13bd9d369ce3bc993e13d374ce25fa38a2663644283ecf422c1/charset_normalizer-3.4.5-cp313-cp313-win_arm64.whl", hash = "sha256:93b3b2cc5cf1b8743660ce77a4f45f3f6d1172068207c1defc779a36eea6bb36", size = 133254, upload-time = "2026-03-06T06:02:00.281Z" },
{ url = "https://files.pythonhosted.org/packages/43/be/0f0fd9bb4a7fa4fb5067fb7d9ac693d4e928d306f80a0d02bde43a7c4aee/charset_normalizer-3.4.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8197abe5ca1ffb7d91e78360f915eef5addff270f8a71c1fc5be24a56f3e4873", size = 280232, upload-time = "2026-03-06T06:02:01.508Z" },
{ url = "https://files.pythonhosted.org/packages/28/02/983b5445e4bef49cd8c9da73a8e029f0825f39b74a06d201bfaa2e55142a/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2aecdb364b8a1802afdc7f9327d55dad5366bc97d8502d0f5854e50712dbc5f", size = 189688, upload-time = "2026-03-06T06:02:02.857Z" },
{ url = "https://files.pythonhosted.org/packages/d0/88/152745c5166437687028027dc080e2daed6fe11cfa95a22f4602591c42db/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a66aa5022bf81ab4b1bebfb009db4fd68e0c6d4307a1ce5ef6a26e5878dfc9e4", size = 206833, upload-time = "2026-03-06T06:02:05.127Z" },
{ url = "https://files.pythonhosted.org/packages/cb/0f/ebc15c8b02af2f19be9678d6eed115feeeccc45ce1f4b098d986c13e8769/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d77f97e515688bd615c1d1f795d540f32542d514242067adcb8ef532504cb9ee", size = 202879, upload-time = "2026-03-06T06:02:06.446Z" },
{ url = "https://files.pythonhosted.org/packages/38/9c/71336bff6934418dc8d1e8a1644176ac9088068bc571da612767619c97b3/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01a1ed54b953303ca7e310fafe0fe347aab348bd81834a0bcd602eb538f89d66", size = 195764, upload-time = "2026-03-06T06:02:08.763Z" },
{ url = "https://files.pythonhosted.org/packages/b7/95/ce92fde4f98615661871bc282a856cf9b8a15f686ba0af012984660d480b/charset_normalizer-3.4.5-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:b2d37d78297b39a9eb9eb92c0f6df98c706467282055419df141389b23f93362", size = 183728, upload-time = "2026-03-06T06:02:10.137Z" },
{ url = "https://files.pythonhosted.org/packages/1c/e7/f5b4588d94e747ce45ae680f0f242bc2d98dbd4eccfab73e6160b6893893/charset_normalizer-3.4.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e71bbb595973622b817c042bd943c3f3667e9c9983ce3d205f973f486fec98a7", size = 192937, upload-time = "2026-03-06T06:02:11.663Z" },
{ url = "https://files.pythonhosted.org/packages/f9/29/9d94ed6b929bf9f48bf6ede6e7474576499f07c4c5e878fb186083622716/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4cd966c2559f501c6fd69294d082c2934c8dd4719deb32c22961a5ac6db0df1d", size = 192040, upload-time = "2026-03-06T06:02:13.489Z" },
{ url = "https://files.pythonhosted.org/packages/15/d2/1a093a1cf827957f9445f2fe7298bcc16f8fc5e05c1ed2ad1af0b239035e/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d5e52d127045d6ae01a1e821acfad2f3a1866c54d0e837828538fabe8d9d1bd6", size = 184107, upload-time = "2026-03-06T06:02:14.83Z" },
{ url = "https://files.pythonhosted.org/packages/0f/7d/82068ce16bd36135df7b97f6333c5d808b94e01d4599a682e2337ed5fd14/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:30a2b1a48478c3428d047ed9690d57c23038dac838a87ad624c85c0a78ebeb39", size = 208310, upload-time = "2026-03-06T06:02:16.165Z" },
{ url = "https://files.pythonhosted.org/packages/84/4e/4dfb52307bb6af4a5c9e73e482d171b81d36f522b21ccd28a49656baa680/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d8ed79b8f6372ca4254955005830fd61c1ccdd8c0fac6603e2c145c61dd95db6", size = 192918, upload-time = "2026-03-06T06:02:18.144Z" },
{ url = "https://files.pythonhosted.org/packages/08/a4/159ff7da662cf7201502ca89980b8f06acf3e887b278956646a8aeb178ab/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c5af897b45fa606b12464ccbe0014bbf8c09191e0a66aab6aa9d5cf6e77e0c94", size = 204615, upload-time = "2026-03-06T06:02:19.821Z" },
{ url = "https://files.pythonhosted.org/packages/d6/62/0dd6172203cb6b429ffffc9935001fde42e5250d57f07b0c28c6046deb6b/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1088345bcc93c58d8d8f3d783eca4a6e7a7752bbff26c3eee7e73c597c191c2e", size = 197784, upload-time = "2026-03-06T06:02:21.86Z" },
{ url = "https://files.pythonhosted.org/packages/c7/5e/1aab5cb737039b9c59e63627dc8bbc0d02562a14f831cc450e5f91d84ce1/charset_normalizer-3.4.5-cp314-cp314-win32.whl", hash = "sha256:ee57b926940ba00bca7ba7041e665cc956e55ef482f851b9b65acb20d867e7a2", size = 133009, upload-time = "2026-03-06T06:02:23.289Z" },
{ url = "https://files.pythonhosted.org/packages/40/65/e7c6c77d7aaa4c0d7974f2e403e17f0ed2cb0fc135f77d686b916bf1eead/charset_normalizer-3.4.5-cp314-cp314-win_amd64.whl", hash = "sha256:4481e6da1830c8a1cc0b746b47f603b653dadb690bcd851d039ffaefe70533aa", size = 143511, upload-time = "2026-03-06T06:02:26.195Z" },
{ url = "https://files.pythonhosted.org/packages/ba/91/52b0841c71f152f563b8e072896c14e3d83b195c188b338d3cc2e582d1d4/charset_normalizer-3.4.5-cp314-cp314-win_arm64.whl", hash = "sha256:97ab7787092eb9b50fb47fa04f24c75b768a606af1bcba1957f07f128a7219e4", size = 133775, upload-time = "2026-03-06T06:02:27.473Z" },
{ url = "https://files.pythonhosted.org/packages/c5/60/3a621758945513adfd4db86827a5bafcc615f913dbd0b4c2ed64a65731be/charset_normalizer-3.4.5-py3-none-any.whl", hash = "sha256:9db5e3fcdcee89a78c04dffb3fe33c79f77bd741a624946db2591c81b2fc85b0", size = 55455, upload-time = "2026-03-06T06:03:17.827Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "coverage"
version = "7.13.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/db/23/aad45061a31677d68e47499197a131eea55da4875d16c1f42021ab963503/coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9", size = 219474, upload-time = "2026-02-09T12:57:19.332Z" },
{ url = "https://files.pythonhosted.org/packages/a5/70/9b8b67a0945f3dfec1fd896c5cefb7c19d5a3a6d74630b99a895170999ae/coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac", size = 219844, upload-time = "2026-02-09T12:57:20.66Z" },
{ url = "https://files.pythonhosted.org/packages/97/fd/7e859f8fab324cef6c4ad7cff156ca7c489fef9179d5749b0c8d321281c2/coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea", size = 250832, upload-time = "2026-02-09T12:57:22.007Z" },
{ url = "https://files.pythonhosted.org/packages/e4/dc/b2442d10020c2f52617828862d8b6ee337859cd8f3a1f13d607dddda9cf7/coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b", size = 253434, upload-time = "2026-02-09T12:57:23.339Z" },
{ url = "https://files.pythonhosted.org/packages/5a/88/6728a7ad17428b18d836540630487231f5470fb82454871149502f5e5aa2/coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525", size = 254676, upload-time = "2026-02-09T12:57:24.774Z" },
{ url = "https://files.pythonhosted.org/packages/7c/bc/21244b1b8cedf0dff0a2b53b208015fe798d5f2a8d5348dbfece04224fff/coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242", size = 256807, upload-time = "2026-02-09T12:57:26.125Z" },
{ url = "https://files.pythonhosted.org/packages/97/a0/ddba7ed3251cff51006737a727d84e05b61517d1784a9988a846ba508877/coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148", size = 251058, upload-time = "2026-02-09T12:57:27.614Z" },
{ url = "https://files.pythonhosted.org/packages/9b/55/e289addf7ff54d3a540526f33751951bf0878f3809b47f6dfb3def69c6f7/coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a", size = 252805, upload-time = "2026-02-09T12:57:29.066Z" },
{ url = "https://files.pythonhosted.org/packages/13/4e/cc276b1fa4a59be56d96f1dabddbdc30f4ba22e3b1cd42504c37b3313255/coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23", size = 250766, upload-time = "2026-02-09T12:57:30.522Z" },
{ url = "https://files.pythonhosted.org/packages/94/44/1093b8f93018f8b41a8cf29636c9292502f05e4a113d4d107d14a3acd044/coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80", size = 254923, upload-time = "2026-02-09T12:57:31.946Z" },
{ url = "https://files.pythonhosted.org/packages/8b/55/ea2796da2d42257f37dbea1aab239ba9263b31bd91d5527cdd6db5efe174/coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea", size = 250591, upload-time = "2026-02-09T12:57:33.842Z" },
{ url = "https://files.pythonhosted.org/packages/d4/fa/7c4bb72aacf8af5020675aa633e59c1fbe296d22aed191b6a5b711eb2bc7/coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a", size = 252364, upload-time = "2026-02-09T12:57:35.743Z" },
{ url = "https://files.pythonhosted.org/packages/5c/38/a8d2ec0146479c20bbaa7181b5b455a0c41101eed57f10dd19a78ab44c80/coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d", size = 222010, upload-time = "2026-02-09T12:57:37.25Z" },
{ url = "https://files.pythonhosted.org/packages/e2/0c/dbfafbe90a185943dcfbc766fe0e1909f658811492d79b741523a414a6cc/coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd", size = 222818, upload-time = "2026-02-09T12:57:38.734Z" },
{ url = "https://files.pythonhosted.org/packages/04/d1/934918a138c932c90d78301f45f677fb05c39a3112b96fd2c8e60503cdc7/coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af", size = 221438, upload-time = "2026-02-09T12:57:40.223Z" },
{ url = "https://files.pythonhosted.org/packages/52/57/ee93ced533bcb3e6df961c0c6e42da2fc6addae53fb95b94a89b1e33ebd7/coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d", size = 220165, upload-time = "2026-02-09T12:57:41.639Z" },
{ url = "https://files.pythonhosted.org/packages/c5/e0/969fc285a6fbdda49d91af278488d904dcd7651b2693872f0ff94e40e84a/coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12", size = 220516, upload-time = "2026-02-09T12:57:44.215Z" },
{ url = "https://files.pythonhosted.org/packages/b1/b8/9531944e16267e2735a30a9641ff49671f07e8138ecf1ca13db9fd2560c7/coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b", size = 261804, upload-time = "2026-02-09T12:57:45.989Z" },
{ url = "https://files.pythonhosted.org/packages/8a/f3/e63df6d500314a2a60390d1989240d5f27318a7a68fa30ad3806e2a9323e/coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9", size = 263885, upload-time = "2026-02-09T12:57:47.42Z" },
{ url = "https://files.pythonhosted.org/packages/f3/67/7654810de580e14b37670b60a09c599fa348e48312db5b216d730857ffe6/coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092", size = 266308, upload-time = "2026-02-09T12:57:49.345Z" },
{ url = "https://files.pythonhosted.org/packages/37/6f/39d41eca0eab3cc82115953ad41c4e77935286c930e8fad15eaed1389d83/coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9", size = 267452, upload-time = "2026-02-09T12:57:50.811Z" },
{ url = "https://files.pythonhosted.org/packages/50/6d/39c0fbb8fc5cd4d2090811e553c2108cf5112e882f82505ee7495349a6bf/coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26", size = 261057, upload-time = "2026-02-09T12:57:52.447Z" },
{ url = "https://files.pythonhosted.org/packages/a4/a2/60010c669df5fa603bb5a97fb75407e191a846510da70ac657eb696b7fce/coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2", size = 263875, upload-time = "2026-02-09T12:57:53.938Z" },
{ url = "https://files.pythonhosted.org/packages/3e/d9/63b22a6bdbd17f1f96e9ed58604c2a6b0e72a9133e37d663bef185877cf6/coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940", size = 261500, upload-time = "2026-02-09T12:57:56.012Z" },
{ url = "https://files.pythonhosted.org/packages/70/bf/69f86ba1ad85bc3ad240e4c0e57a2e620fbc0e1645a47b5c62f0e941ad7f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c", size = 265212, upload-time = "2026-02-09T12:57:57.5Z" },
{ url = "https://files.pythonhosted.org/packages/ae/f2/5f65a278a8c2148731831574c73e42f57204243d33bedaaf18fa79c5958f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0", size = 260398, upload-time = "2026-02-09T12:57:59.027Z" },
{ url = "https://files.pythonhosted.org/packages/ef/80/6e8280a350ee9fea92f14b8357448a242dcaa243cb2c72ab0ca591f66c8c/coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b", size = 262584, upload-time = "2026-02-09T12:58:01.129Z" },
{ url = "https://files.pythonhosted.org/packages/22/63/01ff182fc95f260b539590fb12c11ad3e21332c15f9799cb5e2386f71d9f/coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9", size = 222688, upload-time = "2026-02-09T12:58:02.736Z" },
{ url = "https://files.pythonhosted.org/packages/a9/43/89de4ef5d3cd53b886afa114065f7e9d3707bdb3e5efae13535b46ae483d/coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd", size = 223746, upload-time = "2026-02-09T12:58:05.362Z" },
{ url = "https://files.pythonhosted.org/packages/35/39/7cf0aa9a10d470a5309b38b289b9bb07ddeac5d61af9b664fe9775a4cb3e/coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997", size = 222003, upload-time = "2026-02-09T12:58:06.952Z" },
{ url = "https://files.pythonhosted.org/packages/92/11/a9cf762bb83386467737d32187756a42094927150c3e107df4cb078e8590/coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601", size = 219522, upload-time = "2026-02-09T12:58:08.623Z" },
{ url = "https://files.pythonhosted.org/packages/d3/28/56e6d892b7b052236d67c95f1936b6a7cf7c3e2634bf27610b8cbd7f9c60/coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689", size = 219855, upload-time = "2026-02-09T12:58:10.176Z" },
{ url = "https://files.pythonhosted.org/packages/e5/69/233459ee9eb0c0d10fcc2fe425a029b3fa5ce0f040c966ebce851d030c70/coverage-7.13.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c", size = 250887, upload-time = "2026-02-09T12:58:12.503Z" },
{ url = "https://files.pythonhosted.org/packages/06/90/2cdab0974b9b5bbc1623f7876b73603aecac11b8d95b85b5b86b32de5eab/coverage-7.13.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129", size = 253396, upload-time = "2026-02-09T12:58:14.615Z" },
{ url = "https://files.pythonhosted.org/packages/ac/15/ea4da0f85bf7d7b27635039e649e99deb8173fe551096ea15017f7053537/coverage-7.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552", size = 254745, upload-time = "2026-02-09T12:58:16.162Z" },
{ url = "https://files.pythonhosted.org/packages/99/11/bb356e86920c655ca4d61daee4e2bbc7258f0a37de0be32d233b561134ff/coverage-7.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a", size = 257055, upload-time = "2026-02-09T12:58:17.892Z" },
{ url = "https://files.pythonhosted.org/packages/c9/0f/9ae1f8cb17029e09da06ca4e28c9e1d5c1c0a511c7074592e37e0836c915/coverage-7.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356", size = 250911, upload-time = "2026-02-09T12:58:19.495Z" },
{ url = "https://files.pythonhosted.org/packages/89/3a/adfb68558fa815cbc29747b553bc833d2150228f251b127f1ce97e48547c/coverage-7.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71", size = 252754, upload-time = "2026-02-09T12:58:21.064Z" },
{ url = "https://files.pythonhosted.org/packages/32/b1/540d0c27c4e748bd3cd0bd001076ee416eda993c2bae47a73b7cc9357931/coverage-7.13.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5", size = 250720, upload-time = "2026-02-09T12:58:22.622Z" },
{ url = "https://files.pythonhosted.org/packages/c7/95/383609462b3ffb1fe133014a7c84fc0dd01ed55ac6140fa1093b5af7ebb1/coverage-7.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98", size = 254994, upload-time = "2026-02-09T12:58:24.548Z" },
{ url = "https://files.pythonhosted.org/packages/f7/ba/1761138e86c81680bfc3c49579d66312865457f9fe405b033184e5793cb3/coverage-7.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5", size = 250531, upload-time = "2026-02-09T12:58:26.271Z" },
{ url = "https://files.pythonhosted.org/packages/f8/8e/05900df797a9c11837ab59c4d6fe94094e029582aab75c3309a93e6fb4e3/coverage-7.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0", size = 252189, upload-time = "2026-02-09T12:58:27.807Z" },
{ url = "https://files.pythonhosted.org/packages/00/bd/29c9f2db9ea4ed2738b8a9508c35626eb205d51af4ab7bf56a21a2e49926/coverage-7.13.4-cp314-cp314-win32.whl", hash = "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb", size = 222258, upload-time = "2026-02-09T12:58:29.441Z" },
{ url = "https://files.pythonhosted.org/packages/a7/4d/1f8e723f6829977410efeb88f73673d794075091c8c7c18848d273dc9d73/coverage-7.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505", size = 223073, upload-time = "2026-02-09T12:58:31.026Z" },
{ url = "https://files.pythonhosted.org/packages/51/5b/84100025be913b44e082ea32abcf1afbf4e872f5120b7a1cab1d331b1e13/coverage-7.13.4-cp314-cp314-win_arm64.whl", hash = "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2", size = 221638, upload-time = "2026-02-09T12:58:32.599Z" },
{ url = "https://files.pythonhosted.org/packages/a7/e4/c884a405d6ead1370433dad1e3720216b4f9fd8ef5b64bfd984a2a60a11a/coverage-7.13.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056", size = 220246, upload-time = "2026-02-09T12:58:34.181Z" },
{ url = "https://files.pythonhosted.org/packages/81/5c/4d7ed8b23b233b0fffbc9dfec53c232be2e695468523242ea9fd30f97ad2/coverage-7.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc", size = 220514, upload-time = "2026-02-09T12:58:35.704Z" },
{ url = "https://files.pythonhosted.org/packages/2f/6f/3284d4203fd2f28edd73034968398cd2d4cb04ab192abc8cff007ea35679/coverage-7.13.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9", size = 261877, upload-time = "2026-02-09T12:58:37.864Z" },
{ url = "https://files.pythonhosted.org/packages/09/aa/b672a647bbe1556a85337dc95bfd40d146e9965ead9cc2fe81bde1e5cbce/coverage-7.13.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf", size = 264004, upload-time = "2026-02-09T12:58:39.492Z" },
{ url = "https://files.pythonhosted.org/packages/79/a1/aa384dbe9181f98bba87dd23dda436f0c6cf2e148aecbb4e50fc51c1a656/coverage-7.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55", size = 266408, upload-time = "2026-02-09T12:58:41.852Z" },
{ url = "https://files.pythonhosted.org/packages/53/5e/5150bf17b4019bc600799f376bb9606941e55bd5a775dc1e096b6ffea952/coverage-7.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72", size = 267544, upload-time = "2026-02-09T12:58:44.093Z" },
{ url = "https://files.pythonhosted.org/packages/e0/ed/f1de5c675987a4a7a672250d2c5c9d73d289dbf13410f00ed7181d8017dd/coverage-7.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a", size = 260980, upload-time = "2026-02-09T12:58:45.721Z" },
{ url = "https://files.pythonhosted.org/packages/b3/e3/fe758d01850aa172419a6743fe76ba8b92c29d181d4f676ffe2dae2ba631/coverage-7.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6", size = 263871, upload-time = "2026-02-09T12:58:47.334Z" },
{ url = "https://files.pythonhosted.org/packages/b6/76/b829869d464115e22499541def9796b25312b8cf235d3bb00b39f1675395/coverage-7.13.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3", size = 261472, upload-time = "2026-02-09T12:58:48.995Z" },
{ url = "https://files.pythonhosted.org/packages/14/9e/caedb1679e73e2f6ad240173f55218488bfe043e38da577c4ec977489915/coverage-7.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750", size = 265210, upload-time = "2026-02-09T12:58:51.178Z" },
{ url = "https://files.pythonhosted.org/packages/3a/10/0dd02cb009b16ede425b49ec344aba13a6ae1dc39600840ea6abcb085ac4/coverage-7.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39", size = 260319, upload-time = "2026-02-09T12:58:53.081Z" },
{ url = "https://files.pythonhosted.org/packages/92/8e/234d2c927af27c6d7a5ffad5bd2cf31634c46a477b4c7adfbfa66baf7ebb/coverage-7.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0", size = 262638, upload-time = "2026-02-09T12:58:55.258Z" },
{ url = "https://files.pythonhosted.org/packages/2f/64/e5547c8ff6964e5965c35a480855911b61509cce544f4d442caa759a0702/coverage-7.13.4-cp314-cp314t-win32.whl", hash = "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea", size = 223040, upload-time = "2026-02-09T12:58:56.936Z" },
{ url = "https://files.pythonhosted.org/packages/c7/96/38086d58a181aac86d503dfa9c47eb20715a79c3e3acbdf786e92e5c09a8/coverage-7.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932", size = 224148, upload-time = "2026-02-09T12:58:58.645Z" },
{ url = "https://files.pythonhosted.org/packages/ce/72/8d10abd3740a0beb98c305e0c3faf454366221c0f37a8bcf8f60020bb65a/coverage-7.13.4-cp314-cp314t-win_arm64.whl", hash = "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b", size = 222172, upload-time = "2026-02-09T12:59:00.396Z" },
{ url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" },
]
[[package]]
name = "dill"
version = "0.4.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/81/e1/56027a71e31b02ddc53c7d65b01e68edf64dea2932122fe7746a516f75d5/dill-0.4.1.tar.gz", hash = "sha256:423092df4182177d4d8ba8290c8a5b640c66ab35ec7da59ccfa00f6fa3eea5fa", size = 187315, upload-time = "2026-01-19T02:36:56.85Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl", hash = "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d", size = 120019, upload-time = "2026-01-19T02:36:55.663Z" },
]
[[package]]
name = "distlib"
version = "0.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" },
]
[[package]]
name = "filelock"
version = "3.25.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/b8/00651a0f559862f3bb7d6f7477b192afe3f583cc5e26403b44e59a55ab34/filelock-3.25.2.tar.gz", hash = "sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694", size = 40480, upload-time = "2026-03-11T20:45:38.487Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70", size = 26759, upload-time = "2026-03-11T20:45:37.437Z" },
]
[[package]]
name = "identify"
version = "2.6.17"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/57/84/376a3b96e5a8d33a7aa2c5b3b31a4b3c364117184bf0b17418055f6ace66/identify-2.6.17.tar.gz", hash = "sha256:f816b0b596b204c9fdf076ded172322f2723cf958d02f9c3587504834c8ff04d", size = 99579, upload-time = "2026-03-01T20:04:12.702Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/40/66/71c1227dff78aaeb942fed29dd5651f2aec166cc7c9aeea3e8b26a539b7d/identify-2.6.17-py2.py3-none-any.whl", hash = "sha256:be5f8412d5ed4b20f2bd41a65f920990bdccaa6a4a18a08f1eefdcd0bdd885f0", size = 99382, upload-time = "2026-03-01T20:04:11.439Z" },
]
[[package]]
name = "idna"
version = "3.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "isort"
version = "8.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ef/7c/ec4ab396d31b3b395e2e999c8f46dec78c5e29209fac49d1f4dace04041d/isort-8.0.1.tar.gz", hash = "sha256:171ac4ff559cdc060bcfff550bc8404a486fee0caab245679c2abe7cb253c78d", size = 769592, upload-time = "2026-02-28T10:08:20.685Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3e/95/c7c34aa53c16353c56d0b802fba48d5f5caa2cdee7958acbcb795c830416/isort-8.0.1-py3-none-any.whl", hash = "sha256:28b89bc70f751b559aeca209e6120393d43fbe2490de0559662be7a9787e3d75", size = 89733, upload-time = "2026-02-28T10:08:19.466Z" },
]
[[package]]
name = "mccabe"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658, upload-time = "2022-01-24T01:14:51.113Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" },
]
[[package]]
name = "nodeenv"
version = "1.10.0"
source = { registry = "https://pypi.org/simple" }
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/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 = "packaging"
version = "26.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
]
[[package]]
name = "platformdirs"
version = "4.9.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/19/56/8d4c30c8a1d07013911a8fdbd8f89440ef9f08d07a1b50ab8ca8be5a20f9/platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", size = 28737, upload-time = "2026-03-05T18:34:13.271Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pre-commit"
version = "4.5.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cfgv" },
{ name = "identify" },
{ name = "nodeenv" },
{ name = "pyyaml" },
{ name = "virtualenv" },
]
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/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]]
name = "pygments"
version = "2.19.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
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 = "pylint"
version = "4.0.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "astroid" },
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "dill" },
{ name = "isort" },
{ name = "mccabe" },
{ name = "platformdirs" },
{ name = "tomlkit" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e4/b6/74d9a8a68b8067efce8d07707fe6a236324ee1e7808d2eb3646ec8517c7d/pylint-4.0.5.tar.gz", hash = "sha256:8cd6a618df75deb013bd7eb98327a95f02a6fb839205a6bbf5456ef96afb317c", size = 1572474, upload-time = "2026-02-20T09:07:33.621Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d5/6f/9ac2548e290764781f9e7e2aaf0685b086379dabfb29ca38536985471eaf/pylint-4.0.5-py3-none-any.whl", hash = "sha256:00f51c9b14a3b3ae08cff6b2cdd43f28165c78b165b628692e428fb1f8dc2cf2", size = 536694, upload-time = "2026-02-20T09:07:31.028Z" },
]
[[package]]
name = "pyright"
version = "1.1.408"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nodeenv" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/74/b2/5db700e52554b8f025faa9c3c624c59f1f6c8841ba81ab97641b54322f16/pyright-1.1.408.tar.gz", hash = "sha256:f28f2321f96852fa50b5829ea492f6adb0e6954568d1caa3f3af3a5f555eb684", size = 4400578, upload-time = "2026-01-08T08:07:38.795Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/82/a2c93e32800940d9573fb28c346772a14778b84ba7524e691b324620ab89/pyright-1.1.408-py3-none-any.whl", hash = "sha256:090b32865f4fdb1e0e6cd82bf5618480d48eecd2eb2e70f960982a3d9a4c17c1", size = 6399144, upload-time = "2026-01-08T08:07:37.082Z" },
]
[[package]]
name = "pytest"
version = "9.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
]
[[package]]
name = "pytest-cov"
version = "7.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "coverage" },
{ name = "pluggy" },
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" },
]
[[package]]
name = "python-discovery"
version = "1.1.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "filelock" },
{ name = "platformdirs" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d7/7e/9f3b0dd3a074a6c3e1e79f35e465b1f2ee4b262d619de00cfce523cc9b24/python_discovery-1.1.3.tar.gz", hash = "sha256:7acca36e818cd88e9b2ba03e045ad7e93e1713e29c6bbfba5d90202310b7baa5", size = 56945, upload-time = "2026-03-10T15:08:15.038Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e7/80/73211fc5bfbfc562369b4aa61dc1e4bf07dc7b34df7b317e4539316b809c/python_discovery-1.1.3-py3-none-any.whl", hash = "sha256:90e795f0121bc84572e737c9aa9966311b9fde44ffb88a5953b3ec9b31c6945e", size = 31485, upload-time = "2026-03-10T15:08:13.06Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "requests"
version = "2.32.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "charset-normalizer" },
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
]
[[package]]
name = "ruff"
version = "0.15.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/77/9b/840e0039e65fcf12758adf684d2289024d6140cde9268cc59887dc55189c/ruff-0.15.5.tar.gz", hash = "sha256:7c3601d3b6d76dce18c5c824fc8d06f4eef33d6df0c21ec7799510cde0f159a2", size = 4574214, upload-time = "2026-03-05T20:06:34.946Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/47/20/5369c3ce21588c708bcbe517a8fbe1a8dfdb5dfd5137e14790b1da71612c/ruff-0.15.5-py3-none-linux_armv6l.whl", hash = "sha256:4ae44c42281f42e3b06b988e442d344a5b9b72450ff3c892e30d11b29a96a57c", size = 10478185, upload-time = "2026-03-05T20:06:29.093Z" },
{ url = "https://files.pythonhosted.org/packages/44/ed/e81dd668547da281e5dce710cf0bc60193f8d3d43833e8241d006720e42b/ruff-0.15.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6edd3792d408ebcf61adabc01822da687579a1a023f297618ac27a5b51ef0080", size = 10859201, upload-time = "2026-03-05T20:06:32.632Z" },
{ url = "https://files.pythonhosted.org/packages/c4/8f/533075f00aaf19b07c5cd6aa6e5d89424b06b3b3f4583bfa9c640a079059/ruff-0.15.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:89f463f7c8205a9f8dea9d658d59eff49db05f88f89cc3047fb1a02d9f344010", size = 10184752, upload-time = "2026-03-05T20:06:40.312Z" },
{ url = "https://files.pythonhosted.org/packages/66/0e/ba49e2c3fa0395b3152bad634c7432f7edfc509c133b8f4529053ff024fb/ruff-0.15.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba786a8295c6574c1116704cf0b9e6563de3432ac888d8f83685654fe528fd65", size = 10534857, upload-time = "2026-03-05T20:06:19.581Z" },
{ url = "https://files.pythonhosted.org/packages/59/71/39234440f27a226475a0659561adb0d784b4d247dfe7f43ffc12dd02e288/ruff-0.15.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd4b801e57955fe9f02b31d20375ab3a5c4415f2e5105b79fb94cf2642c91440", size = 10309120, upload-time = "2026-03-05T20:06:00.435Z" },
{ url = "https://files.pythonhosted.org/packages/f5/87/4140aa86a93df032156982b726f4952aaec4a883bb98cb6ef73c347da253/ruff-0.15.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:391f7c73388f3d8c11b794dbbc2959a5b5afe66642c142a6effa90b45f6f5204", size = 11047428, upload-time = "2026-03-05T20:05:51.867Z" },
{ url = "https://files.pythonhosted.org/packages/5a/f7/4953e7e3287676f78fbe85e3a0ca414c5ca81237b7575bdadc00229ac240/ruff-0.15.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8dc18f30302e379fe1e998548b0f5e9f4dff907f52f73ad6da419ea9c19d66c8", size = 11914251, upload-time = "2026-03-05T20:06:22.887Z" },
{ url = "https://files.pythonhosted.org/packages/77/46/0f7c865c10cf896ccf5a939c3e84e1cfaeed608ff5249584799a74d33835/ruff-0.15.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc6e7f90087e2d27f98dc34ed1b3ab7c8f0d273cc5431415454e22c0bd2a681", size = 11333801, upload-time = "2026-03-05T20:05:57.168Z" },
{ url = "https://files.pythonhosted.org/packages/d3/01/a10fe54b653061585e655f5286c2662ebddb68831ed3eaebfb0eb08c0a16/ruff-0.15.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1cb7169f53c1ddb06e71a9aebd7e98fc0fea936b39afb36d8e86d36ecc2636a", size = 11206821, upload-time = "2026-03-05T20:06:03.441Z" },
{ url = "https://files.pythonhosted.org/packages/7a/0d/2132ceaf20c5e8699aa83da2706ecb5c5dcdf78b453f77edca7fb70f8a93/ruff-0.15.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9b037924500a31ee17389b5c8c4d88874cc6ea8e42f12e9c61a3d754ff72f1ca", size = 11133326, upload-time = "2026-03-05T20:06:25.655Z" },
{ url = "https://files.pythonhosted.org/packages/72/cb/2e5259a7eb2a0f87c08c0fe5bf5825a1e4b90883a52685524596bfc93072/ruff-0.15.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:65bb414e5b4eadd95a8c1e4804f6772bbe8995889f203a01f77ddf2d790929dd", size = 10510820, upload-time = "2026-03-05T20:06:37.79Z" },
{ url = "https://files.pythonhosted.org/packages/ff/20/b67ce78f9e6c59ffbdb5b4503d0090e749b5f2d31b599b554698a80d861c/ruff-0.15.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d20aa469ae3b57033519c559e9bc9cd9e782842e39be05b50e852c7c981fa01d", size = 10302395, upload-time = "2026-03-05T20:05:54.504Z" },
{ url = "https://files.pythonhosted.org/packages/5f/e5/719f1acccd31b720d477751558ed74e9c88134adcc377e5e886af89d3072/ruff-0.15.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:15388dd28c9161cdb8eda68993533acc870aa4e646a0a277aa166de9ad5a8752", size = 10754069, upload-time = "2026-03-05T20:06:06.422Z" },
{ url = "https://files.pythonhosted.org/packages/c3/9c/d1db14469e32d98f3ca27079dbd30b7b44dbb5317d06ab36718dee3baf03/ruff-0.15.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b30da330cbd03bed0c21420b6b953158f60c74c54c5f4c1dabbdf3a57bf355d2", size = 11304315, upload-time = "2026-03-05T20:06:10.867Z" },
{ url = "https://files.pythonhosted.org/packages/28/3a/950367aee7c69027f4f422059227b290ed780366b6aecee5de5039d50fa8/ruff-0.15.5-py3-none-win32.whl", hash = "sha256:732e5ee1f98ba5b3679029989a06ca39a950cced52143a0ea82a2102cb592b74", size = 10551676, upload-time = "2026-03-05T20:06:13.705Z" },
{ url = "https://files.pythonhosted.org/packages/b8/00/bf077a505b4e649bdd3c47ff8ec967735ce2544c8e4a43aba42ee9bf935d/ruff-0.15.5-py3-none-win_amd64.whl", hash = "sha256:821d41c5fa9e19117616c35eaa3f4b75046ec76c65e7ae20a333e9a8696bc7fe", size = 11678972, upload-time = "2026-03-05T20:06:45.379Z" },
{ url = "https://files.pythonhosted.org/packages/fe/4e/cd76eca6db6115604b7626668e891c9dd03330384082e33662fb0f113614/ruff-0.15.5-py3-none-win_arm64.whl", hash = "sha256:b498d1c60d2fe5c10c45ec3f698901065772730b411f164ae270bb6bfcc4740b", size = 10965572, upload-time = "2026-03-05T20:06:16.984Z" },
]
[[package]]
name = "tomlkit"
version = "0.14.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/af/14b24e41977adb296d6bd1fb59402cf7d60ce364f90c890bd2ec65c43b5a/tomlkit-0.14.0.tar.gz", hash = "sha256:cf00efca415dbd57575befb1f6634c4f42d2d87dbba376128adb42c121b87064", size = 187167, upload-time = "2026-01-13T01:14:53.304Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b5/11/87d6d29fb5d237229d67973a6c9e06e048f01cf4994dee194ab0ea841814/tomlkit-0.14.0-py3-none-any.whl", hash = "sha256:592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680", size = 39310, upload-time = "2026-01-13T01:14:51.965Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "urllib3"
version = "2.6.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
]
[[package]]
name = "virtualenv"
version = "21.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "distlib" },
{ name = "filelock" },
{ name = "platformdirs" },
{ name = "python-discovery" },
]
sdist = { url = "https://files.pythonhosted.org/packages/aa/92/58199fe10049f9703c2666e809c4f686c54ef0a68b0f6afccf518c0b1eb9/virtualenv-21.2.0.tar.gz", hash = "sha256:1720dc3a62ef5b443092e3f499228599045d7fea4c79199770499df8becf9098", size = 5840618, upload-time = "2026-03-09T17:24:38.013Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c6/59/7d02447a55b2e55755011a647479041bc92a82e143f96a8195cb33bd0a1c/virtualenv-21.2.0-py3-none-any.whl", hash = "sha256:1bd755b504931164a5a496d217c014d098426cddc79363ad66ac78125f9d908f", size = 5825084, upload-time = "2026-03-09T17:24:35.378Z" },
]