diff --git a/.editorconfig b/.editorconfig index 58708d8..5ae6a1e 100644 --- a/.editorconfig +++ b/.editorconfig @@ -3,17 +3,27 @@ # top-most EditorConfig file 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 - -# 4 space indentation -[*.py] -indent_style = tab +end_of_line = lf +max_line_length = 80 +insert_final_newline = true +indent_style = space 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 diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..a10038a --- /dev/null +++ b/.github/CODE_OF_CONDUCT.md @@ -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/ + + diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..2561826 --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -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 πŸ’œ + + diff --git a/.github/renovate.json b/.github/renovate.json new file mode 100644 index 0000000..336a46f --- /dev/null +++ b/.github/renovate.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "github>ivuorinen/renovate-config" + ], + "packageRules": [ + { + "managers": [ + "github-actions" + ], + "schedule": [ + "daily" + ] + } + ] +} diff --git a/.github/workflows/pr-lint.yml b/.github/workflows/pr-lint.yml new file mode 100644 index 0000000..728797a --- /dev/null +++ b/.github/workflows/pr-lint.yml @@ -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 diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml new file mode 100644 index 0000000..52e917c --- /dev/null +++ b/.github/workflows/python-tests.yml @@ -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 diff --git a/.gitignore b/.gitignore index fce81b6..7a4e45f 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,8 @@ projects/* !projects/.gitkeep .vscode .coverage +.pytest_cache +__pycache__ +.coverage +.ruff_cache +.venv diff --git a/.mega-linter.yml b/.mega-linter.yml new file mode 100644 index 0000000..8da70ef --- /dev/null +++ b/.mega-linter.yml @@ -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/) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..d682628 --- /dev/null +++ b/.pre-commit-config.yaml @@ -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 diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..3e388a4 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13.2 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..91d3522 --- /dev/null +++ b/AGENTS.md @@ -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 ` + - 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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..148c4dd --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index b8fa28c..9f2c13f 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,98 @@ - _) - _` | -_) _ \ \ \ \ / | -_) \ \ \ / - \__,_| \___| \___/ _| _| \_/ _| \___| \_/\_/ - aeonview - a simple timelapse tool +# aeonview -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. +```markdown +# _) +# _` | -_) _ \ \ \ \ / | -_) \ \ \ / +# \__,_| \___| \___/ _| _| \_/ _| \___| \_/\_/ +# aeonview - a simple timelapse tool +``` -sample: - http://www.youtube.com/watch?v=SnywvnjHpUk +**aeonview** is a Python-based tool for generating timelapse videos +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 -* curl -* mencoder -* lots of harddrive space -* cron +## Features + +- Timelapse image capture (`--mode image`) +- Video generation (`--mode video`) +- 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 +``` + +## 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 + + diff --git a/aeonview.py b/aeonview.py old mode 100755 new mode 100644 index 5cd7f70..e8702cb --- a/aeonview.py +++ b/aeonview.py @@ -1,221 +1,645 @@ -import sys, time, datetime, os, optparse, errno, re +"""aeonview - Timelapse generator using ffmpeg. -def aeonview(argv): - """ - 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" +Downloads webcam images on a schedule and generates daily, monthly, and yearly +timelapse videos by stitching frames together with ffmpeg. +""" - parser = optparse.OptionParser( - usage="Usage: %prog [options]", - description="aeonview for timelapses", - version="%prog v"+version - ) +from __future__ import annotations - basicopts = optparse.OptionGroup( - parser, "Basic settings", "These effect in both modes." - ) +import argparse +import hashlib +import logging +import subprocess +import sys +import tempfile +from datetime import datetime, timedelta +from pathlib import Path - basicopts.add_option("-m", "--mode", - default="image", - help="run mode: image or video [default: %default]") - - basicopts.add_option("-p", "--project", - help="Project name, used as directory name. " - "Defaults to 5 characters from md5 hash of the webcam url.", - type="string") - - basicopts.add_option("-d", "--dest", - help="Start of the destination. [default: %default]", - type="string", - default=os.getcwdu()+"/projects", - dest="path") - - basicopts.add_option("--mencoder", - help="Path to mencoder binary. [default: %default]", - type="string", - 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", - help="Video to process: daily, monthly or yearly [default: %default]", - type="string") - - videoopts.add_option('--generate', - help="Date to video. Format: YYYY-MM-DD. " - "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, - action="store_true") - - (options, args) = parser.parse_args(argv[1:]) - - if options.simulate == True: - print - print "--- Starting simulation, just echoing steps using your parameters." - print - print "(!) You are running aeonview from", os.getcwdu() - - if options.mode == 'image': - # We are now in the gathering mode. - - if options.url == None and options.simulate == True: - options.url = "http://example.com/webcam.jpg" - print "(!) Simulation: Using " + options.url + " as webcam url" - - if options.url == None: - print "(!) Need a webcam url, not gonna rock before that!" - print - parser.print_help() - sys.exit(-1) - - if options.project == None: - import hashlib # not needed before - m = hashlib.md5(options.url).hexdigest() - options.project = m[:5] # 5 first characters of md5-hash - 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: - if options.verbose == True or options.simulate == True: - print "(!) No destination defined, using:", options.path - else: - if options.verbose == True or options.simulate == True: - print "(!) Using destination:", options.path - - # If you want to change the path structure, here's your chance. - options.imgpath = time.strftime("/img/%Y-%m/%d/") - options.imgname = time.strftime("%H-%M-%S") - - # Let us build the destination path and filename - options.fileext = os.path.splitext(options.url)[1] - options.destdir = options.path + "/" + options.project + options.imgpath - options.destination = options.destdir + options.imgname + options.fileext - getit = options.url + " -o " + options.destination - - # Crude, but works. - if options.simulate == False: - os.system("curl --create-dirs --silent %s" % getit) - else: - print "(!) Simulation: Making path:", options.destdir - print "(!) Simulation: curl (--create-dirs and --silent)", getit - - elif options.mode == "video": - # We are now in the video producing mode - - vid_extension = ".avi" - #m = os.getcwd() + "/mencoder" - m = options.mencoder - mencoder = m + " -really-quiet -mf fps="+ str(options.fps) +" -nosound -ovc lavc -lavcopts vcodec=mpeg4" - - if options.project == None: - print "(!) No project defined, please specify what project you are working on." - print - parser.print_help() - sys.exit(-1) - - 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) +import requests -# http://stackoverflow.com/questions/600268/mkdir-p-functionality-in-python/600612#600612 -def mkdir_p(path): - try: - os.makedirs(path) - except OSError as exc: # Python >2.5 - if exc.errno == errno.EEXIST: - pass - else: raise +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." -# Modified http://markmail.org/message/k2pxsle2lslrmnut -def check_date(year, month, day): - tup1 = (year, month, day, 0,0,0,0,0,0) - try: - date = time.mktime (tup1) - tup2 = time.localtime (date) - if tup1[:2] != tup2[:2]: - return False - else: - return True - except OverflowError: - return False +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}" + ) + + logging.info("Saving image to %s", dest_file) + + 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 + ) + + 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 + ) -if __name__ == '__main__': - aeonview(sys.argv) +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", + help="Run mode", + ) + parser.add_argument("--project", help="Project name", default="default") + parser.add_argument( + "--dest", default=dest_default, help="Destination root path" + ) + parser.add_argument("--url", help="Webcam URL (required in image mode)") + parser.add_argument( + "--fps", type=int, default=10, help="Frames per second" + ) + parser.add_argument( + "--generate", help="Date for video generation (YYYY-MM-DD)" + ) + parser.add_argument( + "--timeframe", + choices=["daily", "monthly", "yearly"], + default="daily", + ) + parser.add_argument( + "--simulate", + action="store_true", + help="Simulation mode", + default=False, + ) + parser.add_argument( + "--verbose", + action="store_true", + help="Verbose output", + default=False, + ) + args = parser.parse_args() + return args, parser + + @staticmethod + def get_extension(url: str | None) -> str | None: + """Get the file extension from the URL. + + :param url: URL to extract the extension from. + :return: File extension string, or None if url is None. + """ + if url is None: + logging.error(AeonViewMessages.INVALID_IMAGE_EXTENSION) + return None + + url_lower = url.lower() + 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" + + return ".jpg" + + @staticmethod + def setup_logger(verbose: bool): + """Set up the logger for the application. + + :param verbose: Enable verbose logging if True. + """ + level = logging.DEBUG if verbose else logging.INFO + logging.basicConfig(level=level, format="[%(levelname)s] %(message)s") + + @staticmethod + def generate_ffmpeg_command( + input_dir: Path, output_file: Path, fps: int = 10 + ) -> list: + """Generate the ffmpeg command to create a video from images. + + :param input_dir: Directory containing the images. + :param output_file: Path to the output video file. + :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), + ] + + @staticmethod + def generate_concat_command( + input_files: list[Path], output_file: Path + ) -> tuple[list[str], Path]: + """Generate an ffmpeg concat demuxer command for joining video files. + + :param input_files: List of input video file paths. + :param output_file: Path to the output video file. + :return: Tuple of (command list, temp concat file path). + """ + concat_list = tempfile.NamedTemporaryFile( + mode="w", suffix=".txt", delete=False + ) + for f in input_files: + concat_list.write(f"file '{f}'\n") + concat_list.close() + + cmd = [ + "ffmpeg", + "-y", + "-f", + "concat", + "-safe", + "0", + "-i", + concat_list.name, + "-c", + "copy", + str(output_file), + ] + return cmd, Path(concat_list.name) + + +class AeonViewApp: + """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: + generate_date = ( + datetime.strptime(self.args.generate, "%Y-%m-%d") + if self.args.generate + else datetime.today() - timedelta(days=1) + ) + 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() + + +if __name__ == "__main__": # pragma: no cover + app = AeonViewApp() + app.run() + +# vim: set tw=100 fo=cqt wm=0 et: diff --git a/aeonview_test.py b/aeonview_test.py new file mode 100644 index 0000000..d5cab68 --- /dev/null +++ b/aeonview_test.py @@ -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() diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..12f27ef --- /dev/null +++ b/conftest.py @@ -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()) diff --git a/example.sh b/example.sh index c0e385c..471411b 100755 --- a/example.sh +++ b/example.sh @@ -1,3 +1,5 @@ #!/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 diff --git a/mencoder b/mencoder deleted file mode 100755 index 9e868fb..0000000 Binary files a/mencoder and /dev/null differ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..7c71df4 --- /dev/null +++ b/pyproject.toml @@ -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", +] diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..4e2204a --- /dev/null +++ b/uv.lock @@ -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" }, +]