mirror of
https://github.com/ivuorinen/aeonview.git
synced 2026-03-17 11:59:52 +00:00
refactor: modernize to Python 3 with OOP structure and comprehensive tests (#2)
* feat: full upgrade to python3, tests, etc. * chore: saving the wip state * chore(lint): fixed pyright errors, tests * chore(ci): install deps * chore(lint): fix linting * chore(lint): more linting fixes * chore(ci): upgrade workflows * fix(test): fix tests, tweak editorconfig * test: add helper path test and stub requests (#4) * fix: update paths and workflow (#5) * fix: update python version and improve cross-platform paths (#6) * fix: resolve MegaLinter YAML and markdown lint failures (#7) * fix: implement all CodeRabbit review comments (#8) * fix: apply CodeRabbit auto-fixes Fixed 3 file(s) based on 3 unresolved review comments. * fix: resolve MegaLinter editorconfig and pylint/pyright failures (#9) * feat: migrate to uv-managed project (#10) * feat: migrate to uv-managed project * fix: align Python version in pyproject.toml and CI setup-uv config * fix: ignore uv.lock in editorconfig, disable PYTHON_PYLINT/PYRIGHT in mega-linter, use uv for dep install (#11) * fix: ignore uv.lock in editorconfig and disable PYTHON_PYLINT/PYRIGHT in mega-linter * fix: update outdated comment in mega-linter config * fix: use uv instead of pip to install deps in mega-linter config * chore(deps): upgrade workflows * chore(ci): mega-linter config tweaks * chore(ci): mega-linter config tweaks * feat(deps): add pyright and pylint with non-overlapping config Add pyright>=1.1.0 and pylint>=3.0.0 as dev dependencies. Configure pyright for basic type checking (py3.13) and refine pylint message disables to avoid overlap with ruff's enabled rule sets. * feat(ci): re-enable pyright and pylint in mega-linter Remove PYTHON_PYLINT and PYTHON_PYRIGHT from DISABLE_LINTERS so mega-linter runs all three linters: ruff, pyright, and pylint. * fix: resolve pyright/pylint findings and apply ruff formatting Add encoding="utf-8" to read_text() calls in tests (pylint W1514). Apply ruff-format double-quote style consistently across both files. * chore(hooks): add editorconfig-checker and fix lines exceeding 80 chars Add editorconfig-checker pre-commit hook to catch line-length violations locally. Shorten docstrings in aeonview.py and aeonview_test.py that exceeded the 80-character editorconfig limit. Remove double-quote-string-fixer hook that conflicted with ruff-format. * fix(ci): configure mega-linter to use project configs for pyright/pylint Point mega-linter at pyproject.toml for both linters so they use our config instead of mega-linter's defaults. Add venvPath/venv to pyright so it resolves imports from the uv-created .venv. Disable pylint import-error since import checking is handled by pyright.
This commit is contained in:
@@ -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
|
||||
|
||||
57
.github/CODE_OF_CONDUCT.md
vendored
Normal file
57
.github/CODE_OF_CONDUCT.md
vendored
Normal file
@@ -0,0 +1,57 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation
|
||||
in our community a harassment-free experience for everyone, regardless
|
||||
of age, body size, visible or invisible disability, ethnicity, sex
|
||||
characteristics, gender identity and expression, level of experience,
|
||||
education, socio-economic status, nationality, personal appearance,
|
||||
race, caste, color, religion, or sexual identity and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open,
|
||||
welcoming, diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment:
|
||||
|
||||
- Demonstrating empathy and kindness
|
||||
- Being respectful of differing opinions
|
||||
- Gracefully accepting constructive feedback
|
||||
- Focusing on what is best for the community
|
||||
|
||||
Examples of unacceptable behavior:
|
||||
|
||||
- The use of sexualized language or imagery
|
||||
- Trolling, insulting or derogatory comments
|
||||
- Harassment of any kind
|
||||
- Publishing others’ private information
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our
|
||||
standards and will take appropriate and fair corrective action in
|
||||
response to any behavior they deem inappropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces and also
|
||||
applies when an individual is officially representing the project.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior
|
||||
may be reported to the project owner @ivuorinen.
|
||||
|
||||
All complaints will be reviewed and investigated and will result in a
|
||||
response that is deemed necessary and appropriate to the circumstances.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][cc],
|
||||
version 2.1.
|
||||
|
||||
[cc]: https://www.contributor-covenant.org/version/2/1/code_of_conduct/
|
||||
|
||||
<!-- vim: ft=md sw=2 ts=2 tw=72 fo=cqt wm=0 et : -->
|
||||
90
.github/CONTRIBUTING.md
vendored
Normal file
90
.github/CONTRIBUTING.md
vendored
Normal file
@@ -0,0 +1,90 @@
|
||||
# Contributing to aeonview
|
||||
|
||||
Thanks for your interest in contributing to **aeonview**!
|
||||
This guide will help you get started.
|
||||
|
||||
## 🛠 Project Setup
|
||||
|
||||
1. Clone the repository:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/ivuorinen/aeonview.git
|
||||
cd aeonview
|
||||
```
|
||||
|
||||
2. Set up your environment:
|
||||
|
||||
```bash
|
||||
uv sync --all-groups
|
||||
```
|
||||
|
||||
3. Install pre-commit hooks:
|
||||
|
||||
```bash
|
||||
uv run pre-commit install
|
||||
```
|
||||
|
||||
## ✅ Development Workflow
|
||||
|
||||
- Make sure your changes are **well-tested**.
|
||||
- Use `make check` to run linting and tests.
|
||||
- Follow the existing coding style (Ruff will enforce it).
|
||||
- All new features must include documentation.
|
||||
|
||||
## 🧪 Running Tests
|
||||
|
||||
```bash
|
||||
make test
|
||||
```
|
||||
|
||||
## 🧹 Formatting & Linting
|
||||
|
||||
```bash
|
||||
make format # auto-format code
|
||||
make lint # check for lint errors
|
||||
```
|
||||
|
||||
## ✅ Submitting a Pull Request
|
||||
|
||||
1. Create a feature branch:
|
||||
|
||||
```bash
|
||||
git checkout -b feature/my-new-feature
|
||||
```
|
||||
|
||||
2. Commit your changes:
|
||||
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "feat: add support for X"
|
||||
```
|
||||
|
||||
3. Push and open a pull request:
|
||||
|
||||
```bash
|
||||
git push origin feature/my-new-feature
|
||||
```
|
||||
|
||||
4. Follow the PR template and link any relevant issues.
|
||||
|
||||
## 📋 Commit Message Guidelines
|
||||
|
||||
Use [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/):
|
||||
|
||||
Examples:
|
||||
|
||||
- `feat: add monthly video generation`
|
||||
- `fix: handle invalid date error`
|
||||
- `docs: update usage instructions`
|
||||
|
||||
## 🙏 Code of Conduct
|
||||
|
||||
We expect contributors to follow our [Code of Conduct](CODE_OF_CONDUCT.md).
|
||||
|
||||
## Questions?
|
||||
|
||||
Feel free to open an issue or start a discussion!
|
||||
|
||||
Thanks for helping make Aeonview better 💜
|
||||
|
||||
<!-- vim: ft=md sw=2 ts=2 tw=72 fo=cqt wm=0 et :-->
|
||||
16
.github/renovate.json
vendored
Normal file
16
.github/renovate.json
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": [
|
||||
"github>ivuorinen/renovate-config"
|
||||
],
|
||||
"packageRules": [
|
||||
{
|
||||
"managers": [
|
||||
"github-actions"
|
||||
],
|
||||
"schedule": [
|
||||
"daily"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
33
.github/workflows/pr-lint.yml
vendored
Normal file
33
.github/workflows/pr-lint.yml
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
---
|
||||
# yamllint disable rule:line-length
|
||||
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
|
||||
# yamllint enable rule:line-length
|
||||
name: PR Lint
|
||||
|
||||
# yamllint disable-line rule:truthy
|
||||
on:
|
||||
push:
|
||||
branches: [master, main]
|
||||
pull_request:
|
||||
branches: [master, main]
|
||||
|
||||
permissions: read-all
|
||||
|
||||
env:
|
||||
TRIVY_SEVERITY: CRITICAL,HIGH
|
||||
DISABLE_LINTERS: GO_GOLANGCI_LINT
|
||||
|
||||
jobs:
|
||||
Linter:
|
||||
name: PR Lint
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write # only for delete-branch option
|
||||
issues: write
|
||||
pull-requests: write
|
||||
statuses: write
|
||||
|
||||
steps:
|
||||
# yamllint disable rule:line-length
|
||||
- uses: ivuorinen/actions/pr-lint@7f6a23b59316795c4b3cb3b3b28dd53e53655a33 # v2026.03.11
|
||||
# yamllint enable rule:line-length
|
||||
39
.github/workflows/python-tests.yml
vendored
Normal file
39
.github/workflows/python-tests.yml
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
---
|
||||
name: Python tests
|
||||
|
||||
# yamllint disable-line rule:truthy
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
pull_request:
|
||||
branches: [main, master]
|
||||
|
||||
permissions: read-all
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
# yamllint disable-line rule:line-length
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install uv
|
||||
# yamllint disable-line rule:line-length
|
||||
uses: astral-sh/setup-uv@e06108dd0aef18192324c70427afc47652e63a82 # v7.5.0
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
run: uv sync --all-groups
|
||||
|
||||
- name: Run Ruff linting
|
||||
shell: bash
|
||||
run: uv run ruff check .
|
||||
|
||||
- name: Run tests with coverage
|
||||
shell: bash
|
||||
run: |
|
||||
uv run pytest --cov=aeonview --cov-report=term-missing
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -2,3 +2,8 @@ projects/*
|
||||
!projects/.gitkeep
|
||||
.vscode
|
||||
.coverage
|
||||
.pytest_cache
|
||||
__pycache__
|
||||
.coverage
|
||||
.ruff_cache
|
||||
.venv
|
||||
|
||||
38
.mega-linter.yml
Normal file
38
.mega-linter.yml
Normal file
@@ -0,0 +1,38 @@
|
||||
---
|
||||
# Configuration file for MegaLinter
|
||||
# See all available variables at
|
||||
# https://megalinter.io/configuration/ and in linters documentation
|
||||
|
||||
APPLY_FIXES: all
|
||||
SHOW_ELAPSED_TIME: false # Show elapsed time at the end of MegaLinter run
|
||||
PARALLEL: true
|
||||
VALIDATE_ALL_CODEBASE: true
|
||||
FILEIO_REPORTER: false # Generate file.io report
|
||||
GITHUB_STATUS_REPORTER: true # Generate GitHub status report
|
||||
IGNORE_GENERATED_FILES: true # Ignore generated files
|
||||
JAVASCRIPT_DEFAULT_STYLE: prettier # Default style for JavaScript
|
||||
PRINT_ALPACA: false # Print Alpaca logo in console
|
||||
SARIF_REPORTER: true # Generate SARIF report
|
||||
SHOW_SKIPPED_LINTERS: false # Show skipped linters in MegaLinter log
|
||||
|
||||
# Disable linters that are replaced by uv-based workflows
|
||||
DISABLE_LINTERS:
|
||||
- JSON_PRETTIER
|
||||
- PYTHON_BANDIT
|
||||
- PYTHON_BLACK
|
||||
- PYTHON_ISORT
|
||||
- REPOSITORY_DEVSKIM
|
||||
- REPOSITORY_DUSTILOCK
|
||||
- YAML_PRETTIER
|
||||
|
||||
# Install Python dependencies before linting using uv
|
||||
PRE_COMMANDS:
|
||||
- command: pip install uv
|
||||
cwd: workspace
|
||||
- command: uv sync --all-groups
|
||||
cwd: workspace
|
||||
|
||||
PYTHON_PYLINT_CONFIG_FILE: pyproject.toml
|
||||
PYTHON_PYRIGHT_CONFIG_FILE: pyproject.toml
|
||||
|
||||
FILTER_REGEX_EXCLUDE: (node_modules|\.venv|docs/)
|
||||
58
.pre-commit-config.yaml
Normal file
58
.pre-commit-config.yaml
Normal file
@@ -0,0 +1,58 @@
|
||||
---
|
||||
repos:
|
||||
- repo: https://github.com/asottile/pyupgrade
|
||||
rev: v3.21.2
|
||||
hooks:
|
||||
- id: pyupgrade
|
||||
args: [--py3-plus]
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: "v0.15.6"
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: ["--fix"]
|
||||
- id: ruff-format
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v6.0.0
|
||||
hooks:
|
||||
- id: check-ast
|
||||
- id: check-builtin-literals
|
||||
- id: check-docstring-first
|
||||
- id: check-executables-have-shebangs
|
||||
- id: check-json
|
||||
- id: check-merge-conflict
|
||||
- id: check-shebang-scripts-are-executable
|
||||
- id: check-toml
|
||||
- id: check-yaml
|
||||
- id: debug-statements
|
||||
- id: detect-aws-credentials
|
||||
- id: end-of-file-fixer
|
||||
- id: mixed-line-ending
|
||||
- id: name-tests-test
|
||||
- id: no-commit-to-branch
|
||||
- id: trailing-whitespace
|
||||
- repo: https://github.com/editorconfig-checker/editorconfig-checker.python
|
||||
rev: "3.6.1"
|
||||
hooks:
|
||||
- id: editorconfig-checker
|
||||
alias: ec
|
||||
exclude: uv\.lock
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: pyright
|
||||
name: pyright
|
||||
entry: uv run pyright
|
||||
language: system
|
||||
types: [python]
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
- id: pylint
|
||||
name: pylint
|
||||
entry: uv run pylint
|
||||
language: system
|
||||
types: [python]
|
||||
- id: pytest
|
||||
name: pytest
|
||||
entry: uv run pytest
|
||||
language: system
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
1
.python-version
Normal file
1
.python-version
Normal file
@@ -0,0 +1 @@
|
||||
3.13.2
|
||||
28
AGENTS.md
Normal file
28
AGENTS.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Contributor Guidelines
|
||||
|
||||
## Development Workflow
|
||||
|
||||
- Use **Python 3.13** as defined in `.python-version`.
|
||||
- Install dependencies with `uv sync --all-groups`.
|
||||
- Use **pre-commit** for linting and testing:
|
||||
- `uv run pre-commit run --files <changed files>`
|
||||
- or `make check` to run `ruff` linting and tests.
|
||||
- Run **ruff** for linting and formatting (`make lint`, `make format`).
|
||||
- Run **pytest** for tests (`make test`).
|
||||
- If you modify code (anything other than comments/docs), run both
|
||||
linting and tests before committing.
|
||||
- Aim for 100% coverage; add tests when adding or modifying code.
|
||||
- Use `uv run pre-commit install` once to install git hooks.
|
||||
- Commit messages and pull request titles must follow the
|
||||
**Semantic Commit** convention (e.g. `fix:`, `feat:`).
|
||||
- If Node packages are added, use **yarn** instead of npm.
|
||||
|
||||
## Repository Structure
|
||||
|
||||
- `aeonview.py` — main application code.
|
||||
- `aeonview_test.py` — test suite using pytest.
|
||||
- `projects/` — output directory used by the application.
|
||||
- `.pre-commit-config.yaml` — hooks for linting and testing.
|
||||
- `Makefile` — common commands (`format`, `lint`, `test`).
|
||||
- `pyproject.toml` — project metadata and dependencies (managed by uv).
|
||||
- `uv.lock` — locked dependency versions.
|
||||
19
Makefile
Normal file
19
Makefile
Normal file
@@ -0,0 +1,19 @@
|
||||
.PHONY: install format lint test check clean
|
||||
|
||||
install:
|
||||
@uv sync --all-groups
|
||||
|
||||
format:
|
||||
@uv run ruff format .
|
||||
|
||||
lint:
|
||||
@uv run ruff check .
|
||||
|
||||
test:
|
||||
@uv run pytest --cov=aeonview --cov-report=term-missing
|
||||
|
||||
check: lint test
|
||||
|
||||
clean:
|
||||
@find . -type d -name '__pycache__' -exec rm -r {} +
|
||||
@rm -rf .pytest_cache .coverage htmlcov .venv
|
||||
108
README.md
108
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 <changed files>
|
||||
```
|
||||
|
||||
## System Setup for ffmpeg
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install ffmpeg
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT License © 2025 Ismo Vuorinen
|
||||
|
||||
[ci-b]: https://github.com/ivuorinen/aeonview/actions/workflows/python-tests.yml/badge.svg
|
||||
[ci-l]: https://github.com/ivuorinen/aeonview/actions/workflows/python-tests.yml
|
||||
[cc-b]: https://img.shields.io/badge/code%20style-ruff-blueviolet
|
||||
[cc-l]: https://github.com/astral-sh/ruff
|
||||
[lm-b]: https://img.shields.io/badge/License-MIT-yellow.svg
|
||||
[lm-l]: https://opensource.org/licenses/MIT
|
||||
[sample]: https://www.youtube.com/watch?v=SnywvnjHpUk
|
||||
|
||||
<!-- vim: set sw=2 ts=2 tw=72 fo=cqt wm=0 et: -->
|
||||
|
||||
846
aeonview.py
Executable file → Normal file
846
aeonview.py
Executable file → Normal file
@@ -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:
|
||||
|
||||
908
aeonview_test.py
Normal file
908
aeonview_test.py
Normal file
@@ -0,0 +1,908 @@
|
||||
"""Tests for the aeonview timelapse generator."""
|
||||
|
||||
# vim: set ft=python ts=4 sw=4 sts=4 et wrap:
|
||||
import argparse
|
||||
import contextlib
|
||||
import logging
|
||||
import subprocess
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from aeonview import (
|
||||
AeonViewApp,
|
||||
AeonViewHelpers,
|
||||
AeonViewImages,
|
||||
AeonViewMessages,
|
||||
AeonViewVideos,
|
||||
)
|
||||
|
||||
default_dest = str(Path.cwd() / "projects")
|
||||
default_project = "default"
|
||||
default_fps = 10
|
||||
default_timeframe = "daily"
|
||||
default_simulate = False
|
||||
default_verbose = False
|
||||
default_image_domain = "https://example.com/image"
|
||||
default_test_path = Path(tempfile.gettempdir(), "test_project").resolve()
|
||||
tmp_images = Path(tempfile.gettempdir(), "images")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def make_video_args(
|
||||
simulate=False, fps=10, day="01", month="04", year="2025"
|
||||
) -> argparse.Namespace:
|
||||
"""Create an ``argparse.Namespace`` with video-generation defaults."""
|
||||
return argparse.Namespace(
|
||||
simulate=simulate, fps=fps, day=day, month=month, year=year
|
||||
)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def expect_error_exit():
|
||||
"""Expect ``SystemExit`` and silence ``logging.error``."""
|
||||
with pytest.raises(SystemExit), mock.patch("aeonview.logging.error"):
|
||||
yield
|
||||
|
||||
|
||||
def make_app_with_project(tmp: str) -> tuple[AeonViewApp, Path]:
|
||||
"""Create an ``AeonViewApp`` with base_path at *tmp*/'proj'."""
|
||||
app = AeonViewApp()
|
||||
app.base_path = Path(tmp).resolve()
|
||||
proj_path = app.base_path / "proj"
|
||||
proj_path.mkdir()
|
||||
return app, proj_path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AeonViewHelpers tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_build_path_resolves_correctly():
|
||||
base = Path(tempfile.gettempdir())
|
||||
result = AeonViewHelpers.build_path(base, "a", "b", "c")
|
||||
assert result == Path(base, "a", "b", "c").resolve()
|
||||
|
||||
|
||||
def test_check_date_valid():
|
||||
assert AeonViewHelpers.check_date(2023, 12, 31)
|
||||
|
||||
|
||||
def test_check_date_invalid():
|
||||
assert not AeonViewHelpers.check_date(2023, 2, 30)
|
||||
|
||||
|
||||
def test_mkdir_p_creates_directory():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
test_path = Path(tmp) / "a" / "b" / "c"
|
||||
AeonViewHelpers.mkdir_p(test_path)
|
||||
assert test_path.exists()
|
||||
assert test_path.is_dir()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ext", ["jpg", "png", "gif", "webp"])
|
||||
def test_get_extension_valid(ext):
|
||||
assert (
|
||||
AeonViewHelpers.get_extension(f"{default_image_domain}.{ext}")
|
||||
== f".{ext}"
|
||||
)
|
||||
|
||||
|
||||
def test_get_extension_invalid():
|
||||
assert AeonViewHelpers.get_extension(default_image_domain) == ".jpg"
|
||||
assert AeonViewHelpers.get_extension(None) is None
|
||||
|
||||
|
||||
def test_get_extension_jpeg():
|
||||
assert (
|
||||
AeonViewHelpers.get_extension(f"{default_image_domain}.jpeg") == ".jpeg"
|
||||
)
|
||||
|
||||
|
||||
def test_generate_ffmpeg_command():
|
||||
input_dir = tmp_images
|
||||
output_file = Path(tempfile.gettempdir(), "output.mp4")
|
||||
fps = 24
|
||||
cmd = AeonViewHelpers.generate_ffmpeg_command(input_dir, output_file, fps)
|
||||
assert "ffmpeg" in cmd[0]
|
||||
assert str(fps) in cmd
|
||||
assert str(output_file) == cmd[-1]
|
||||
assert str(input_dir / "*.{jpg,jpeg,png,gif,webp}") in cmd
|
||||
|
||||
|
||||
def test_generate_ffmpeg_command_output_format():
|
||||
input_dir = tmp_images
|
||||
output_file = Path(tempfile.gettempdir(), "video.mp4")
|
||||
cmd = AeonViewHelpers.generate_ffmpeg_command(input_dir, output_file, 30)
|
||||
assert str(tmp_images / "*.{jpg,jpeg,png,gif,webp}") in cmd
|
||||
assert str(output_file) in cmd
|
||||
assert "-c:v" in cmd
|
||||
assert "libx264" in cmd
|
||||
assert "-pix_fmt" in cmd
|
||||
assert "yuv420p" in cmd
|
||||
|
||||
|
||||
@mock.patch("subprocess.run")
|
||||
def test_simulate_ffmpeg_call(mock_run):
|
||||
input_dir = tmp_images
|
||||
output_file = Path(tempfile.gettempdir(), "out.mp4")
|
||||
cmd = AeonViewHelpers.generate_ffmpeg_command(input_dir, output_file, 10)
|
||||
subprocess.run(cmd, check=True)
|
||||
mock_run.assert_called_once_with(cmd, check=True)
|
||||
|
||||
|
||||
def test_generate_concat_command():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
files = [Path(tmp) / "01.mp4", Path(tmp) / "02.mp4"]
|
||||
for f in files:
|
||||
f.touch()
|
||||
output = Path(tmp) / "output.mp4"
|
||||
cmd, concat_file = AeonViewHelpers.generate_concat_command(
|
||||
files, output
|
||||
)
|
||||
try:
|
||||
assert cmd[0] == "ffmpeg"
|
||||
assert "-f" in cmd
|
||||
assert "concat" in cmd
|
||||
assert "-safe" in cmd
|
||||
assert "0" in cmd
|
||||
assert "-c" in cmd
|
||||
assert "copy" in cmd
|
||||
assert str(output) == cmd[-1]
|
||||
assert concat_file.exists()
|
||||
content = concat_file.read_text(encoding="utf-8")
|
||||
for f in files:
|
||||
assert f"file '{f}'" in content
|
||||
finally:
|
||||
concat_file.unlink(missing_ok=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AeonViewImages tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_get_image_paths_valid():
|
||||
url = f"{default_image_domain}.jpg"
|
||||
destination_base = default_test_path
|
||||
date = datetime(2025, 4, 10, 12, 30, 45)
|
||||
aeon_view_images = AeonViewImages(destination_base, url)
|
||||
paths = aeon_view_images.get_image_paths(url, destination_base, date)
|
||||
|
||||
a_p = destination_base / "2025-04" / "10" / "12-30-45.jpg"
|
||||
|
||||
assert paths["url"] == url
|
||||
assert paths["file"] == "12-30-45.jpg"
|
||||
assert paths["destinations"]["file"] == a_p
|
||||
|
||||
|
||||
def test_get_image_paths_invalid_url():
|
||||
with expect_error_exit():
|
||||
aeon_view_images = AeonViewImages(default_test_path, "invalid-url")
|
||||
aeon_view_images.get_image_paths(
|
||||
"invalid-url", default_test_path, datetime(2025, 4, 10)
|
||||
)
|
||||
|
||||
|
||||
def test_get_image_paths_invalid_date():
|
||||
with expect_error_exit():
|
||||
aeon_view_images = AeonViewImages(
|
||||
default_test_path, f"{default_image_domain}.jpg"
|
||||
)
|
||||
# noinspection PyTypeChecker
|
||||
aeon_view_images.get_image_paths(
|
||||
f"{default_image_domain}.jpg",
|
||||
default_test_path,
|
||||
"invalid-date", # pyright: ignore [reportArgumentType]
|
||||
)
|
||||
|
||||
|
||||
def test_get_image_paths_none_url():
|
||||
avi = AeonViewImages(default_test_path, None)
|
||||
with expect_error_exit():
|
||||
avi.get_image_paths(None, default_test_path, datetime(2025, 4, 10))
|
||||
|
||||
|
||||
def test_get_image_paths_no_image_extension():
|
||||
url = "https://example.com/image.bmp"
|
||||
avi = AeonViewImages(default_test_path, url)
|
||||
with expect_error_exit():
|
||||
avi.get_image_paths(url, default_test_path, datetime(2025, 4, 10))
|
||||
|
||||
|
||||
def test_get_image_paths_none_destination():
|
||||
url = f"{default_image_domain}.jpg"
|
||||
avi = AeonViewImages(default_test_path, url)
|
||||
with expect_error_exit():
|
||||
avi.get_image_paths(url, None, datetime(2025, 4, 10))
|
||||
|
||||
|
||||
def test_get_image_paths_non_path_destination():
|
||||
url = f"{default_image_domain}.jpg"
|
||||
avi = AeonViewImages(default_test_path, url)
|
||||
with expect_error_exit():
|
||||
# noinspection PyTypeChecker
|
||||
avi.get_image_paths(
|
||||
url,
|
||||
"/tmp/not-a-path-object", # pyright: ignore [reportArgumentType]
|
||||
datetime(2025, 4, 10),
|
||||
)
|
||||
|
||||
|
||||
def test_get_image_paths_creates_directory():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
url = f"{default_image_domain}.jpg"
|
||||
dest = Path(tmp) / "newproject"
|
||||
avi = AeonViewImages(dest, url)
|
||||
paths = avi.get_image_paths(url, dest, datetime(2025, 4, 10, 12, 0, 0))
|
||||
assert paths["file"] == "12-00-00.jpg"
|
||||
assert (dest / "2025-04" / "10").exists()
|
||||
|
||||
|
||||
def test_get_image_paths_simulate_no_mkdir():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
url = f"{default_image_domain}.jpg"
|
||||
dest = Path(tmp) / "simproject"
|
||||
args = argparse.Namespace(simulate=True)
|
||||
avi = AeonViewImages(dest, url, args)
|
||||
with mock.patch("aeonview.logging.info"):
|
||||
paths = avi.get_image_paths(
|
||||
url, dest, datetime(2025, 4, 10, 12, 0, 0)
|
||||
)
|
||||
assert paths["file"] == "12-00-00.jpg"
|
||||
assert not (dest / "2025-04" / "10").exists()
|
||||
|
||||
|
||||
@mock.patch("aeonview.AeonViewHelpers.mkdir_p")
|
||||
@mock.patch("aeonview.AeonViewImages.download_image")
|
||||
def test_get_current_image(mock_download_image, mock_mkdir_p):
|
||||
args = argparse.Namespace(simulate=False, date="2025-04-10 12:30:45")
|
||||
avi = AeonViewImages(default_test_path, f"{default_image_domain}.jpg", args)
|
||||
avi.get_current_image()
|
||||
mock_mkdir_p.assert_called()
|
||||
mock_download_image.assert_called()
|
||||
|
||||
|
||||
def test_get_current_image_invalid_date_format():
|
||||
args = argparse.Namespace(simulate=False, date="not-a-date")
|
||||
avi = AeonViewImages(default_test_path, f"{default_image_domain}.jpg", args)
|
||||
with expect_error_exit():
|
||||
avi.get_current_image()
|
||||
|
||||
|
||||
@mock.patch("aeonview.AeonViewHelpers.mkdir_p")
|
||||
@mock.patch("aeonview.AeonViewImages.download_image")
|
||||
def test_get_current_image_no_date_uses_now(mock_download, mock_mkdir):
|
||||
args = argparse.Namespace(simulate=False, date=None)
|
||||
avi = AeonViewImages(default_test_path, f"{default_image_domain}.jpg", args)
|
||||
avi.get_current_image()
|
||||
mock_mkdir.assert_called()
|
||||
mock_download.assert_called()
|
||||
|
||||
|
||||
def test_get_current_image_no_url():
|
||||
args = argparse.Namespace(simulate=False, date="2025-04-10 12:30:45")
|
||||
avi = AeonViewImages(default_test_path, None, args)
|
||||
with expect_error_exit():
|
||||
avi.get_current_image()
|
||||
|
||||
|
||||
@mock.patch("aeonview.requests.get")
|
||||
def test_download_image_success(mock_get):
|
||||
mock_response = mock.Mock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.iter_content = mock.Mock(return_value=[b"data"])
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
args = argparse.Namespace(simulate=False)
|
||||
avi = AeonViewImages(default_test_path, f"{default_image_domain}.jpg", args)
|
||||
with tempfile.NamedTemporaryFile(delete=True) as temp_file:
|
||||
destination = Path(temp_file.name)
|
||||
avi.download_image(destination)
|
||||
mock_get.assert_called_once_with(
|
||||
f"{default_image_domain}.jpg", stream=True, timeout=10
|
||||
)
|
||||
assert destination.exists()
|
||||
|
||||
|
||||
@mock.patch("aeonview.requests.get")
|
||||
def test_download_image_failure(mock_get):
|
||||
mock_response = mock.Mock()
|
||||
mock_response.status_code = 404
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
args = argparse.Namespace(simulate=False)
|
||||
avi = AeonViewImages(default_test_path, f"{default_image_domain}.jpg", args)
|
||||
destination = Path(tempfile.gettempdir(), "image.jpg")
|
||||
|
||||
with expect_error_exit():
|
||||
avi.download_image(destination)
|
||||
|
||||
|
||||
def test_download_image_no_url():
|
||||
args = argparse.Namespace(simulate=False)
|
||||
avi = AeonViewImages(default_test_path, None, args)
|
||||
with expect_error_exit():
|
||||
avi.download_image(Path(tempfile.gettempdir(), "image.jpg"))
|
||||
|
||||
|
||||
def test_download_image_non_path_destination():
|
||||
args = argparse.Namespace(simulate=False)
|
||||
avi = AeonViewImages(default_test_path, f"{default_image_domain}.jpg", args)
|
||||
with expect_error_exit():
|
||||
# noinspection PyTypeChecker
|
||||
avi.download_image(
|
||||
"/tmp/image.jpg" # pyright: ignore [reportArgumentType]
|
||||
)
|
||||
|
||||
|
||||
def test_download_image_simulate():
|
||||
args = argparse.Namespace(simulate=True)
|
||||
avi = AeonViewImages(default_test_path, f"{default_image_domain}.jpg", args)
|
||||
with mock.patch("aeonview.logging.info") as log:
|
||||
avi.download_image(Path(tempfile.gettempdir(), "image.jpg"))
|
||||
assert any("Simulate" in str(call) for call in log.call_args_list)
|
||||
|
||||
|
||||
@mock.patch("aeonview.AeonViewHelpers.mkdir_p")
|
||||
@mock.patch("aeonview.AeonViewImages.download_image")
|
||||
def test_image_simulation(mock_download_image, mock_mkdir_p):
|
||||
args = mock.MagicMock()
|
||||
args.simulate = True
|
||||
args.date = "2025-04-10 12:30:45"
|
||||
avi = AeonViewImages(default_test_path, f"{default_image_domain}.jpg", args)
|
||||
with mock.patch("aeonview.logging.info") as log:
|
||||
avi.get_current_image()
|
||||
mock_mkdir_p.assert_not_called()
|
||||
mock_download_image.assert_not_called()
|
||||
assert any(
|
||||
"Saving image to" in str(call) for call in log.call_args_list
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AeonViewVideos tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@mock.patch("subprocess.run")
|
||||
def test_generate_daily_video(mock_subprocess_run):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
project_path = Path(tmp).resolve()
|
||||
args = make_video_args()
|
||||
avv = AeonViewVideos(project_path, args)
|
||||
# Create input directory so the existence check passes
|
||||
(project_path / "img" / "2025-04" / "01").mkdir(parents=True)
|
||||
with mock.patch("aeonview.AeonViewHelpers.mkdir_p") as mock_mkdir_p:
|
||||
with mock.patch("aeonview.logging.info") as log:
|
||||
avv.generate_daily_video()
|
||||
last_call_args = log.call_args_list[-1][0]
|
||||
assert last_call_args[0] == "%s: %s"
|
||||
assert (
|
||||
last_call_args[1]
|
||||
== AeonViewMessages.VIDEO_GENERATION_SUCCESS
|
||||
)
|
||||
assert last_call_args[2] == (
|
||||
project_path / "vid" / "2025-04" / "01.mp4"
|
||||
)
|
||||
mock_mkdir_p.assert_called()
|
||||
mock_subprocess_run.assert_called()
|
||||
|
||||
|
||||
@mock.patch("aeonview.AeonViewHelpers.mkdir_p")
|
||||
def test_generate_daily_video_simulate(mock_mkdir_p):
|
||||
args = make_video_args(simulate=True)
|
||||
avv = AeonViewVideos(default_test_path, args)
|
||||
avv.generate_daily_video()
|
||||
mock_mkdir_p.assert_not_called()
|
||||
|
||||
|
||||
def test_generate_daily_video_missing_input_dir():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
project_path = Path(tmp).resolve()
|
||||
args = make_video_args()
|
||||
avv = AeonViewVideos(project_path, args)
|
||||
with expect_error_exit():
|
||||
avv.generate_daily_video()
|
||||
|
||||
|
||||
@mock.patch("aeonview.AeonViewHelpers.mkdir_p")
|
||||
@mock.patch("subprocess.run")
|
||||
def test_video_simulation(mock_subprocess_run, mock_mkdir_p):
|
||||
args = mock.MagicMock()
|
||||
args.simulate = True
|
||||
args.fps = 10
|
||||
args.day = "01"
|
||||
args.month = "01"
|
||||
args.year = "2023"
|
||||
avv = AeonViewVideos(default_test_path, args)
|
||||
with mock.patch("aeonview.logging.info") as log:
|
||||
avv.generate_daily_video()
|
||||
mock_mkdir_p.assert_not_called()
|
||||
mock_subprocess_run.assert_not_called()
|
||||
assert any(
|
||||
"Generating video from" in str(call) for call in log.call_args_list
|
||||
)
|
||||
|
||||
|
||||
# --- Monthly video tests ---
|
||||
|
||||
|
||||
@mock.patch("subprocess.run")
|
||||
def test_generate_monthly_video(mock_subprocess_run):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
project_path = Path(tmp).resolve()
|
||||
vid_dir = project_path / "vid" / "2025-04"
|
||||
vid_dir.mkdir(parents=True)
|
||||
# Create fake daily videos
|
||||
for day in ["01", "02", "03"]:
|
||||
(vid_dir / f"{day}.mp4").touch()
|
||||
args = make_video_args()
|
||||
avv = AeonViewVideos(project_path, args)
|
||||
avv.generate_monthly_video()
|
||||
mock_subprocess_run.assert_called_once()
|
||||
call_cmd = mock_subprocess_run.call_args[0][0]
|
||||
assert "concat" in call_cmd
|
||||
assert str(vid_dir / "2025-04.mp4") == call_cmd[-1]
|
||||
|
||||
|
||||
@mock.patch("subprocess.run")
|
||||
def test_generate_monthly_video_excludes_output_file(mock_subprocess_run):
|
||||
"""Verify monthly output file is excluded from inputs on re-runs."""
|
||||
captured_content = {}
|
||||
|
||||
def capture_concat(cmd, **_kwargs):
|
||||
idx = cmd.index("-i") + 1
|
||||
captured_content["text"] = Path(cmd[idx]).read_text(encoding="utf-8")
|
||||
|
||||
mock_subprocess_run.side_effect = capture_concat
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
project_path = Path(tmp).resolve()
|
||||
vid_dir = project_path / "vid" / "2025-04"
|
||||
vid_dir.mkdir(parents=True)
|
||||
# Create daily videos plus a leftover monthly output
|
||||
for day in ["01", "02"]:
|
||||
(vid_dir / f"{day}.mp4").touch()
|
||||
(vid_dir / "2025-04.mp4").touch() # leftover from previous run
|
||||
args = make_video_args()
|
||||
avv = AeonViewVideos(project_path, args)
|
||||
avv.generate_monthly_video()
|
||||
mock_subprocess_run.assert_called_once()
|
||||
content = captured_content["text"]
|
||||
assert "2025-04.mp4" not in content
|
||||
assert "01.mp4" in content
|
||||
assert "02.mp4" in content
|
||||
|
||||
|
||||
def test_generate_monthly_video_simulate():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
project_path = Path(tmp).resolve()
|
||||
vid_dir = project_path / "vid" / "2025-04"
|
||||
vid_dir.mkdir(parents=True)
|
||||
(vid_dir / "01.mp4").touch()
|
||||
args = make_video_args(simulate=True)
|
||||
avv = AeonViewVideos(project_path, args)
|
||||
with mock.patch("subprocess.run") as mock_run:
|
||||
avv.generate_monthly_video()
|
||||
mock_run.assert_not_called()
|
||||
|
||||
|
||||
def test_generate_monthly_video_no_input_dir():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
project_path = Path(tmp).resolve()
|
||||
# Don't create the vid directory
|
||||
args = make_video_args()
|
||||
avv = AeonViewVideos(project_path, args)
|
||||
with expect_error_exit():
|
||||
avv.generate_monthly_video()
|
||||
|
||||
|
||||
def test_generate_monthly_video_no_mp4_files():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
project_path = Path(tmp).resolve()
|
||||
vid_dir = project_path / "vid" / "2025-04"
|
||||
vid_dir.mkdir(parents=True)
|
||||
# No mp4 files in directory
|
||||
args = make_video_args()
|
||||
avv = AeonViewVideos(project_path, args)
|
||||
with expect_error_exit():
|
||||
avv.generate_monthly_video()
|
||||
|
||||
|
||||
# --- Yearly video tests ---
|
||||
|
||||
|
||||
@mock.patch("subprocess.run")
|
||||
def test_generate_yearly_video(mock_subprocess_run):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
project_path = Path(tmp).resolve()
|
||||
# Create monthly video files in their directories
|
||||
for month in ["01", "02", "03"]:
|
||||
month_dir = project_path / "vid" / f"2025-{month}"
|
||||
month_dir.mkdir(parents=True)
|
||||
(month_dir / f"2025-{month}.mp4").touch()
|
||||
args = make_video_args(month="01", year="2025")
|
||||
avv = AeonViewVideos(project_path, args)
|
||||
avv.generate_yearly_video()
|
||||
mock_subprocess_run.assert_called_once()
|
||||
call_cmd = mock_subprocess_run.call_args[0][0]
|
||||
assert "concat" in call_cmd
|
||||
output_dir = project_path / "vid" / "2025"
|
||||
assert str(output_dir / "2025.mp4") == call_cmd[-1]
|
||||
|
||||
|
||||
def test_generate_yearly_video_simulate():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
project_path = Path(tmp).resolve()
|
||||
month_dir = project_path / "vid" / "2025-01"
|
||||
month_dir.mkdir(parents=True)
|
||||
(month_dir / "2025-01.mp4").touch()
|
||||
args = make_video_args(simulate=True, month="01", year="2025")
|
||||
avv = AeonViewVideos(project_path, args)
|
||||
with mock.patch("subprocess.run") as mock_run:
|
||||
avv.generate_yearly_video()
|
||||
mock_run.assert_not_called()
|
||||
|
||||
|
||||
def test_generate_yearly_video_no_monthly_videos():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
project_path = Path(tmp).resolve()
|
||||
(project_path / "vid").mkdir(parents=True)
|
||||
args = make_video_args(month="01", year="2025")
|
||||
avv = AeonViewVideos(project_path, args)
|
||||
with expect_error_exit():
|
||||
avv.generate_yearly_video()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Argument parsing tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"sys.argv",
|
||||
["aeonview.py", "--mode", "image", "--url", f"{default_image_domain}.jpg"],
|
||||
)
|
||||
def test_parse_arguments_image_mode():
|
||||
args, _ = AeonViewHelpers.parse_arguments()
|
||||
assert args.mode == "image"
|
||||
assert args.url == f"{default_image_domain}.jpg"
|
||||
assert args.dest == default_dest
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"sys.argv",
|
||||
["aeonview.py", "--mode", "video", "--project", f"{default_project}"],
|
||||
)
|
||||
def test_parse_arguments_video_mode():
|
||||
args, _ = AeonViewHelpers.parse_arguments()
|
||||
assert args.mode == "video"
|
||||
assert args.project == default_project
|
||||
assert args.dest == default_dest
|
||||
|
||||
|
||||
@mock.patch("sys.argv", ["aeonview.py", "--mode", "image", "--simulate"])
|
||||
def test_parse_arguments_simulate_mode():
|
||||
args, _ = AeonViewHelpers.parse_arguments()
|
||||
assert args.mode == "image"
|
||||
assert args.simulate
|
||||
|
||||
|
||||
@mock.patch("sys.argv", ["aeonview.py", "--mode", "video", "--fps", "30"])
|
||||
def test_parse_arguments_fps():
|
||||
args, _ = AeonViewHelpers.parse_arguments()
|
||||
assert args.mode == "video"
|
||||
assert args.project == default_project
|
||||
assert args.dest == default_dest
|
||||
assert args.fps == 30
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"sys.argv", ["aeonview.py", "--mode", "video", "--generate", "2023-10-01"]
|
||||
)
|
||||
def test_parse_arguments_generate_date():
|
||||
args, _ = AeonViewHelpers.parse_arguments()
|
||||
assert args.mode == "video"
|
||||
assert args.generate == "2023-10-01"
|
||||
|
||||
|
||||
@mock.patch("sys.argv", ["aeonview.py", "--mode", "image", "--verbose"])
|
||||
def test_parse_arguments_verbose():
|
||||
args, _ = AeonViewHelpers.parse_arguments()
|
||||
assert args.mode == "image"
|
||||
assert args.verbose
|
||||
|
||||
|
||||
@mock.patch("sys.argv", ["aeonview.py"])
|
||||
def test_parse_arguments_defaults():
|
||||
args, _ = AeonViewHelpers.parse_arguments()
|
||||
assert args.mode == "image"
|
||||
assert args.project == default_project
|
||||
assert args.dest == default_dest
|
||||
assert args.fps == 10
|
||||
assert args.timeframe == "daily"
|
||||
assert not args.simulate
|
||||
assert not args.verbose
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Logger tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@mock.patch("logging.basicConfig")
|
||||
def test_setup_logger_verbose(mock_basic_config):
|
||||
AeonViewHelpers.setup_logger(verbose=True)
|
||||
mock_basic_config.assert_called_once_with(
|
||||
level=logging.DEBUG, format="[%(levelname)s] %(message)s"
|
||||
)
|
||||
|
||||
|
||||
@mock.patch("logging.basicConfig")
|
||||
def test_setup_logger_non_verbose(mock_basic_config):
|
||||
AeonViewHelpers.setup_logger(verbose=False)
|
||||
mock_basic_config.assert_called_once_with(
|
||||
level=logging.INFO, format="[%(levelname)s] %(message)s"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AeonViewApp tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"sys.argv",
|
||||
["aeonview", "--mode", "image", "--url", "https://example.com/image.jpg"],
|
||||
)
|
||||
def test_app_init():
|
||||
app = AeonViewApp()
|
||||
assert app.args.mode == "image"
|
||||
assert app.args.url == "https://example.com/image.jpg"
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"sys.argv",
|
||||
["aeonview", "--mode", "image", "--url", "https://example.com/image.jpg"],
|
||||
)
|
||||
@mock.patch("aeonview.AeonViewApp.process_image")
|
||||
def test_app_run_image_mode(mock_process):
|
||||
app = AeonViewApp()
|
||||
app.run()
|
||||
mock_process.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"sys.argv",
|
||||
[
|
||||
"aeonview",
|
||||
"--mode",
|
||||
"video",
|
||||
"--project",
|
||||
"myproj",
|
||||
],
|
||||
)
|
||||
@mock.patch("aeonview.AeonViewApp.process_video")
|
||||
def test_app_run_video_mode(mock_process):
|
||||
app = AeonViewApp()
|
||||
app.run()
|
||||
mock_process.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"sys.argv",
|
||||
[
|
||||
"aeonview",
|
||||
"--mode",
|
||||
"image",
|
||||
"--simulate",
|
||||
"--url",
|
||||
"https://example.com/image.jpg",
|
||||
],
|
||||
)
|
||||
def test_app_run_simulate_logs():
|
||||
app = AeonViewApp()
|
||||
with (
|
||||
mock.patch("aeonview.AeonViewApp.process_image"),
|
||||
mock.patch("aeonview.logging.info") as log,
|
||||
):
|
||||
app.run()
|
||||
assert any("Simulation" in str(call) for call in log.call_args_list)
|
||||
|
||||
|
||||
@mock.patch("sys.argv", ["aeonview", "--mode", "image"])
|
||||
def test_app_process_image_no_url():
|
||||
app = AeonViewApp()
|
||||
with expect_error_exit():
|
||||
app.process_image()
|
||||
|
||||
|
||||
@mock.patch("sys.argv", ["aeonview", "--mode", "image", "--url", "not-http"])
|
||||
def test_app_process_image_invalid_url():
|
||||
app = AeonViewApp()
|
||||
with expect_error_exit():
|
||||
app.process_image()
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"sys.argv",
|
||||
["aeonview", "--mode", "image", "--url", "https://example.com/image.jpg"],
|
||||
)
|
||||
@mock.patch("aeonview.AeonViewImages.get_current_image")
|
||||
def test_app_process_image_default_project_hash(mock_get_image):
|
||||
app = AeonViewApp()
|
||||
app.process_image()
|
||||
mock_get_image.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"sys.argv",
|
||||
[
|
||||
"aeonview",
|
||||
"--mode",
|
||||
"image",
|
||||
"--url",
|
||||
"https://example.com/image.jpg",
|
||||
"--project",
|
||||
"myproject",
|
||||
],
|
||||
)
|
||||
@mock.patch("aeonview.AeonViewImages.get_current_image")
|
||||
def test_app_process_image_named_project(mock_get_image):
|
||||
app = AeonViewApp()
|
||||
app.process_image()
|
||||
mock_get_image.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"sys.argv",
|
||||
["aeonview", "--mode", "video", "--project", ""],
|
||||
)
|
||||
def test_app_process_video_no_project():
|
||||
app = AeonViewApp()
|
||||
app.args.project = ""
|
||||
with expect_error_exit():
|
||||
app.process_video()
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"sys.argv",
|
||||
["aeonview", "--mode", "video", "--project", "nonexistent"],
|
||||
)
|
||||
def test_app_process_video_missing_path():
|
||||
app = AeonViewApp()
|
||||
with expect_error_exit():
|
||||
app.process_video()
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"sys.argv",
|
||||
[
|
||||
"aeonview",
|
||||
"--mode",
|
||||
"video",
|
||||
"--project",
|
||||
"proj",
|
||||
"--generate",
|
||||
"2025-04-10",
|
||||
],
|
||||
)
|
||||
@mock.patch("aeonview.AeonViewVideos.generate_daily_video")
|
||||
def test_app_process_video_with_generate_date(mock_gen):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
app, _ = make_app_with_project(tmp)
|
||||
app.process_video()
|
||||
mock_gen.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"sys.argv",
|
||||
["aeonview", "--mode", "video", "--project", "proj"],
|
||||
)
|
||||
@mock.patch("aeonview.AeonViewVideos.generate_daily_video")
|
||||
def test_app_process_video_default_date(mock_gen):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
app, _ = make_app_with_project(tmp)
|
||||
app.process_video()
|
||||
mock_gen.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"sys.argv",
|
||||
[
|
||||
"aeonview",
|
||||
"--mode",
|
||||
"video",
|
||||
"--project",
|
||||
"proj",
|
||||
"--generate",
|
||||
"invalid-date",
|
||||
],
|
||||
)
|
||||
def test_app_process_video_invalid_date():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
app, _ = make_app_with_project(tmp)
|
||||
with expect_error_exit():
|
||||
app.process_video()
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"sys.argv",
|
||||
[
|
||||
"aeonview",
|
||||
"--mode",
|
||||
"video",
|
||||
"--project",
|
||||
"proj",
|
||||
"--timeframe",
|
||||
"monthly",
|
||||
],
|
||||
)
|
||||
@mock.patch("aeonview.AeonViewVideos.generate_monthly_video")
|
||||
def test_app_process_video_monthly(mock_gen):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
app, _ = make_app_with_project(tmp)
|
||||
app.process_video()
|
||||
mock_gen.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"sys.argv",
|
||||
[
|
||||
"aeonview",
|
||||
"--mode",
|
||||
"video",
|
||||
"--project",
|
||||
"proj",
|
||||
"--timeframe",
|
||||
"yearly",
|
||||
],
|
||||
)
|
||||
@mock.patch("aeonview.AeonViewVideos.generate_yearly_video")
|
||||
def test_app_process_video_yearly(mock_gen):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
app, _ = make_app_with_project(tmp)
|
||||
app.process_video()
|
||||
mock_gen.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch("sys.argv", ["aeonview"])
|
||||
def test_app_init_no_args():
|
||||
with (
|
||||
mock.patch(
|
||||
"aeonview.AeonViewHelpers.parse_arguments",
|
||||
return_value=(None, argparse.ArgumentParser()),
|
||||
),
|
||||
pytest.raises(SystemExit),
|
||||
):
|
||||
AeonViewApp()
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"sys.argv",
|
||||
[
|
||||
"aeonview",
|
||||
"--mode",
|
||||
"video",
|
||||
"--project",
|
||||
"proj",
|
||||
"--generate",
|
||||
"2025-04-10",
|
||||
],
|
||||
)
|
||||
def test_app_process_video_invalid_project_type():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
app, _ = make_app_with_project(tmp)
|
||||
# noinspection PyTypeChecker
|
||||
app.args.project = 12345 # pyright: ignore [reportAttributeAccessIssue]
|
||||
with expect_error_exit():
|
||||
app.process_video()
|
||||
9
conftest.py
Normal file
9
conftest.py
Normal file
@@ -0,0 +1,9 @@
|
||||
# conftest.py
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def no_network_requests(monkeypatch):
|
||||
monkeypatch.setattr("subprocess.run", mock.Mock())
|
||||
@@ -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
|
||||
|
||||
70
pyproject.toml
Normal file
70
pyproject.toml
Normal file
@@ -0,0 +1,70 @@
|
||||
[project]
|
||||
name = "aeonview"
|
||||
version = "0.1.0"
|
||||
description = "A simple timelapse tool using ffmpeg and Python"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.13.2"
|
||||
license = { text = "MIT" }
|
||||
authors = [{ name = "Ismo Vuorinen" }]
|
||||
dependencies = ["requests>=2.32.3"]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pre-commit>=3.5.0",
|
||||
"pylint>=3.0.0",
|
||||
"pyright>=1.1.0",
|
||||
"pytest>=8.0.0",
|
||||
"pytest-cov>=4.1.0",
|
||||
"ruff>=0.3.3",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 80
|
||||
target-version = "py313"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "B", "UP", "C4", "T20"]
|
||||
ignore = ["E501"]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"*_test.py" = ["S101"]
|
||||
|
||||
[tool.ruff.format]
|
||||
quote-style = "double"
|
||||
indent-style = "space"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["."]
|
||||
python_files = ["*_test.py"]
|
||||
|
||||
[tool.pyright]
|
||||
pythonVersion = "3.13"
|
||||
typeCheckingMode = "basic"
|
||||
venvPath = "."
|
||||
venv = ".venv"
|
||||
|
||||
[tool.pylint.MAIN]
|
||||
ignore-patterns = ["^\\.#"]
|
||||
ignore-paths = ["^\\.#"]
|
||||
ignore = ["CVS", ".venv"]
|
||||
|
||||
[tool.pylint."messages_control"]
|
||||
disable = [
|
||||
"attribute-defined-outside-init",
|
||||
"invalid-name",
|
||||
"missing-docstring",
|
||||
"protected-access",
|
||||
"too-many-instance-attributes",
|
||||
"too-few-public-methods",
|
||||
"format",
|
||||
"line-too-long",
|
||||
"ungrouped-imports",
|
||||
"wrong-import-order",
|
||||
"unused-import",
|
||||
"reimported",
|
||||
"consider-using-f-string",
|
||||
"unnecessary-comprehension",
|
||||
"use-a-generator",
|
||||
"consider-using-with",
|
||||
"import-error",
|
||||
]
|
||||
505
uv.lock
generated
Normal file
505
uv.lock
generated
Normal file
@@ -0,0 +1,505 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.13.2"
|
||||
|
||||
[[package]]
|
||||
name = "aeonview"
|
||||
version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "requests" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "pre-commit" },
|
||||
{ name = "pylint" },
|
||||
{ name = "pyright" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-cov" },
|
||||
{ name = "ruff" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [{ name = "requests", specifier = ">=2.32.3" }]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "pre-commit", specifier = ">=3.5.0" },
|
||||
{ name = "pylint", specifier = ">=3.0.0" },
|
||||
{ name = "pyright", specifier = ">=1.1.0" },
|
||||
{ name = "pytest", specifier = ">=8.0.0" },
|
||||
{ name = "pytest-cov", specifier = ">=4.1.0" },
|
||||
{ name = "ruff", specifier = ">=0.3.3" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "astroid"
|
||||
version = "4.0.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/07/63/0adf26577da5eff6eb7a177876c1cfa213856be9926a000f65c4add9692b/astroid-4.0.4.tar.gz", hash = "sha256:986fed8bcf79fb82c78b18a53352a0b287a73817d6dbcfba3162da36667c49a0", size = 406358, upload-time = "2026-02-07T23:35:07.509Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/cf/1c5f42b110e57bc5502eb80dbc3b03d256926062519224835ef08134f1f9/astroid-4.0.4-py3-none-any.whl", hash = "sha256:52f39653876c7dec3e3afd4c2696920e05c83832b9737afc21928f2d2eb7a753", size = 276445, upload-time = "2026-02-07T23:35:05.344Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2026.2.25"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfgv"
|
||||
version = "3.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "charset-normalizer"
|
||||
version = "3.4.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1d/35/02daf95b9cd686320bb622eb148792655c9412dbb9b67abb5694e5910a24/charset_normalizer-3.4.5.tar.gz", hash = "sha256:95adae7b6c42a6c5b5b559b1a99149f090a57128155daeea91732c8d970d8644", size = 134804, upload-time = "2026-03-06T06:03:19.46Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/48/9f34ec4bb24aa3fdba1890c1bddb97c8a4be1bd84ef5c42ac2352563ad05/charset_normalizer-3.4.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac59c15e3f1465f722607800c68713f9fbc2f672b9eb649fe831da4019ae9b23", size = 280788, upload-time = "2026-03-06T06:01:37.126Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/09/6003e7ffeb90cc0560da893e3208396a44c210c5ee42efff539639def59b/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:165c7b21d19365464e8f70e5ce5e12524c58b48c78c1f5a57524603c1ab003f8", size = 188890, upload-time = "2026-03-06T06:01:38.73Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/1e/02706edf19e390680daa694d17e2b8eab4b5f7ac285e2a51168b4b22ee6b/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:28269983f25a4da0425743d0d257a2d6921ea7d9b83599d4039486ec5b9f911d", size = 206136, upload-time = "2026-03-06T06:01:40.016Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/87/942c3def1b37baf3cf786bad01249190f3ca3d5e63a84f831e704977de1f/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d27ce22ec453564770d29d03a9506d449efbb9fa13c00842262b2f6801c48cce", size = 202551, upload-time = "2026-03-06T06:01:41.522Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/0a/af49691938dfe175d71b8a929bd7e4ace2809c0c5134e28bc535660d5262/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0625665e4ebdddb553ab185de5db7054393af8879fb0c87bd5690d14379d6819", size = 195572, upload-time = "2026-03-06T06:01:43.208Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/ea/dfb1792a8050a8e694cfbde1570ff97ff74e48afd874152d38163d1df9ae/charset_normalizer-3.4.5-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:c23eb3263356d94858655b3e63f85ac5d50970c6e8febcdde7830209139cc37d", size = 184438, upload-time = "2026-03-06T06:01:44.755Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/12/c281e2067466e3ddd0595bfaea58a6946765ace5c72dfa3edc2f5f118026/charset_normalizer-3.4.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e6302ca4ae283deb0af68d2fbf467474b8b6aedcd3dab4db187e07f94c109763", size = 193035, upload-time = "2026-03-06T06:01:46.051Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/4f/3792c056e7708e10464bad0438a44708886fb8f92e3c3d29ec5e2d964d42/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e51ae7d81c825761d941962450f50d041db028b7278e7b08930b4541b3e45cb9", size = 191340, upload-time = "2026-03-06T06:01:47.547Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/86/80ddba897127b5c7a9bccc481b0cd36c8fefa485d113262f0fe4332f0bf4/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:597d10dec876923e5c59e48dbd366e852eacb2b806029491d307daea6b917d7c", size = 185464, upload-time = "2026-03-06T06:01:48.764Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/00/b5eff85ba198faacab83e0e4b6f0648155f072278e3b392a82478f8b988b/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5cffde4032a197bd3b42fd0b9509ec60fb70918d6970e4cc773f20fc9180ca67", size = 208014, upload-time = "2026-03-06T06:01:50.371Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/11/d36f70be01597fd30850dde8a1269ebc8efadd23ba5785808454f2389bde/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2da4eedcb6338e2321e831a0165759c0c620e37f8cd044a263ff67493be8ffb3", size = 193297, upload-time = "2026-03-06T06:01:51.933Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/1d/259eb0a53d4910536c7c2abb9cb25f4153548efb42800c6a9456764649c0/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:65a126fb4b070d05340a84fc709dd9e7c75d9b063b610ece8a60197a291d0adf", size = 204321, upload-time = "2026-03-06T06:01:53.887Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/31/faa6c5b9d3688715e1ed1bb9d124c384fe2fc1633a409e503ffe1c6398c1/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7a80a9242963416bd81f99349d5f3fce1843c303bd404f204918b6d75a75fd6", size = 197509, upload-time = "2026-03-06T06:01:56.439Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/a5/c7d9dd1503ffc08950b3260f5d39ec2366dd08254f0900ecbcf3a6197c7c/charset_normalizer-3.4.5-cp313-cp313-win32.whl", hash = "sha256:f1d725b754e967e648046f00c4facc42d414840f5ccc670c5670f59f83693e4f", size = 132284, upload-time = "2026-03-06T06:01:57.812Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/0f/57072b253af40c8aa6636e6de7d75985624c1eb392815b2f934199340a89/charset_normalizer-3.4.5-cp313-cp313-win_amd64.whl", hash = "sha256:e37bd100d2c5d3ba35db9c7c5ba5a9228cbcffe5c4778dc824b164e5257813d7", size = 142630, upload-time = "2026-03-06T06:01:59.062Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/41/1c4b7cc9f13bd9d369ce3bc993e13d374ce25fa38a2663644283ecf422c1/charset_normalizer-3.4.5-cp313-cp313-win_arm64.whl", hash = "sha256:93b3b2cc5cf1b8743660ce77a4f45f3f6d1172068207c1defc779a36eea6bb36", size = 133254, upload-time = "2026-03-06T06:02:00.281Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/be/0f0fd9bb4a7fa4fb5067fb7d9ac693d4e928d306f80a0d02bde43a7c4aee/charset_normalizer-3.4.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8197abe5ca1ffb7d91e78360f915eef5addff270f8a71c1fc5be24a56f3e4873", size = 280232, upload-time = "2026-03-06T06:02:01.508Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/02/983b5445e4bef49cd8c9da73a8e029f0825f39b74a06d201bfaa2e55142a/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2aecdb364b8a1802afdc7f9327d55dad5366bc97d8502d0f5854e50712dbc5f", size = 189688, upload-time = "2026-03-06T06:02:02.857Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/88/152745c5166437687028027dc080e2daed6fe11cfa95a22f4602591c42db/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a66aa5022bf81ab4b1bebfb009db4fd68e0c6d4307a1ce5ef6a26e5878dfc9e4", size = 206833, upload-time = "2026-03-06T06:02:05.127Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/0f/ebc15c8b02af2f19be9678d6eed115feeeccc45ce1f4b098d986c13e8769/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d77f97e515688bd615c1d1f795d540f32542d514242067adcb8ef532504cb9ee", size = 202879, upload-time = "2026-03-06T06:02:06.446Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/9c/71336bff6934418dc8d1e8a1644176ac9088068bc571da612767619c97b3/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01a1ed54b953303ca7e310fafe0fe347aab348bd81834a0bcd602eb538f89d66", size = 195764, upload-time = "2026-03-06T06:02:08.763Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/95/ce92fde4f98615661871bc282a856cf9b8a15f686ba0af012984660d480b/charset_normalizer-3.4.5-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:b2d37d78297b39a9eb9eb92c0f6df98c706467282055419df141389b23f93362", size = 183728, upload-time = "2026-03-06T06:02:10.137Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/e7/f5b4588d94e747ce45ae680f0f242bc2d98dbd4eccfab73e6160b6893893/charset_normalizer-3.4.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e71bbb595973622b817c042bd943c3f3667e9c9983ce3d205f973f486fec98a7", size = 192937, upload-time = "2026-03-06T06:02:11.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/29/9d94ed6b929bf9f48bf6ede6e7474576499f07c4c5e878fb186083622716/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4cd966c2559f501c6fd69294d082c2934c8dd4719deb32c22961a5ac6db0df1d", size = 192040, upload-time = "2026-03-06T06:02:13.489Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/d2/1a093a1cf827957f9445f2fe7298bcc16f8fc5e05c1ed2ad1af0b239035e/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d5e52d127045d6ae01a1e821acfad2f3a1866c54d0e837828538fabe8d9d1bd6", size = 184107, upload-time = "2026-03-06T06:02:14.83Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/7d/82068ce16bd36135df7b97f6333c5d808b94e01d4599a682e2337ed5fd14/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:30a2b1a48478c3428d047ed9690d57c23038dac838a87ad624c85c0a78ebeb39", size = 208310, upload-time = "2026-03-06T06:02:16.165Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/4e/4dfb52307bb6af4a5c9e73e482d171b81d36f522b21ccd28a49656baa680/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d8ed79b8f6372ca4254955005830fd61c1ccdd8c0fac6603e2c145c61dd95db6", size = 192918, upload-time = "2026-03-06T06:02:18.144Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/a4/159ff7da662cf7201502ca89980b8f06acf3e887b278956646a8aeb178ab/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c5af897b45fa606b12464ccbe0014bbf8c09191e0a66aab6aa9d5cf6e77e0c94", size = 204615, upload-time = "2026-03-06T06:02:19.821Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/62/0dd6172203cb6b429ffffc9935001fde42e5250d57f07b0c28c6046deb6b/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1088345bcc93c58d8d8f3d783eca4a6e7a7752bbff26c3eee7e73c597c191c2e", size = 197784, upload-time = "2026-03-06T06:02:21.86Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/5e/1aab5cb737039b9c59e63627dc8bbc0d02562a14f831cc450e5f91d84ce1/charset_normalizer-3.4.5-cp314-cp314-win32.whl", hash = "sha256:ee57b926940ba00bca7ba7041e665cc956e55ef482f851b9b65acb20d867e7a2", size = 133009, upload-time = "2026-03-06T06:02:23.289Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/65/e7c6c77d7aaa4c0d7974f2e403e17f0ed2cb0fc135f77d686b916bf1eead/charset_normalizer-3.4.5-cp314-cp314-win_amd64.whl", hash = "sha256:4481e6da1830c8a1cc0b746b47f603b653dadb690bcd851d039ffaefe70533aa", size = 143511, upload-time = "2026-03-06T06:02:26.195Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/91/52b0841c71f152f563b8e072896c14e3d83b195c188b338d3cc2e582d1d4/charset_normalizer-3.4.5-cp314-cp314-win_arm64.whl", hash = "sha256:97ab7787092eb9b50fb47fa04f24c75b768a606af1bcba1957f07f128a7219e4", size = 133775, upload-time = "2026-03-06T06:02:27.473Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/60/3a621758945513adfd4db86827a5bafcc615f913dbd0b4c2ed64a65731be/charset_normalizer-3.4.5-py3-none-any.whl", hash = "sha256:9db5e3fcdcee89a78c04dffb3fe33c79f77bd741a624946db2591c81b2fc85b0", size = 55455, upload-time = "2026-03-06T06:03:17.827Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coverage"
|
||||
version = "7.13.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/db/23/aad45061a31677d68e47499197a131eea55da4875d16c1f42021ab963503/coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9", size = 219474, upload-time = "2026-02-09T12:57:19.332Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/70/9b8b67a0945f3dfec1fd896c5cefb7c19d5a3a6d74630b99a895170999ae/coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac", size = 219844, upload-time = "2026-02-09T12:57:20.66Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/fd/7e859f8fab324cef6c4ad7cff156ca7c489fef9179d5749b0c8d321281c2/coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea", size = 250832, upload-time = "2026-02-09T12:57:22.007Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/dc/b2442d10020c2f52617828862d8b6ee337859cd8f3a1f13d607dddda9cf7/coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b", size = 253434, upload-time = "2026-02-09T12:57:23.339Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/88/6728a7ad17428b18d836540630487231f5470fb82454871149502f5e5aa2/coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525", size = 254676, upload-time = "2026-02-09T12:57:24.774Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/bc/21244b1b8cedf0dff0a2b53b208015fe798d5f2a8d5348dbfece04224fff/coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242", size = 256807, upload-time = "2026-02-09T12:57:26.125Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/a0/ddba7ed3251cff51006737a727d84e05b61517d1784a9988a846ba508877/coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148", size = 251058, upload-time = "2026-02-09T12:57:27.614Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/55/e289addf7ff54d3a540526f33751951bf0878f3809b47f6dfb3def69c6f7/coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a", size = 252805, upload-time = "2026-02-09T12:57:29.066Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/4e/cc276b1fa4a59be56d96f1dabddbdc30f4ba22e3b1cd42504c37b3313255/coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23", size = 250766, upload-time = "2026-02-09T12:57:30.522Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/44/1093b8f93018f8b41a8cf29636c9292502f05e4a113d4d107d14a3acd044/coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80", size = 254923, upload-time = "2026-02-09T12:57:31.946Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/55/ea2796da2d42257f37dbea1aab239ba9263b31bd91d5527cdd6db5efe174/coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea", size = 250591, upload-time = "2026-02-09T12:57:33.842Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/fa/7c4bb72aacf8af5020675aa633e59c1fbe296d22aed191b6a5b711eb2bc7/coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a", size = 252364, upload-time = "2026-02-09T12:57:35.743Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/38/a8d2ec0146479c20bbaa7181b5b455a0c41101eed57f10dd19a78ab44c80/coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d", size = 222010, upload-time = "2026-02-09T12:57:37.25Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/0c/dbfafbe90a185943dcfbc766fe0e1909f658811492d79b741523a414a6cc/coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd", size = 222818, upload-time = "2026-02-09T12:57:38.734Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/d1/934918a138c932c90d78301f45f677fb05c39a3112b96fd2c8e60503cdc7/coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af", size = 221438, upload-time = "2026-02-09T12:57:40.223Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/57/ee93ced533bcb3e6df961c0c6e42da2fc6addae53fb95b94a89b1e33ebd7/coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d", size = 220165, upload-time = "2026-02-09T12:57:41.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/e0/969fc285a6fbdda49d91af278488d904dcd7651b2693872f0ff94e40e84a/coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12", size = 220516, upload-time = "2026-02-09T12:57:44.215Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/b8/9531944e16267e2735a30a9641ff49671f07e8138ecf1ca13db9fd2560c7/coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b", size = 261804, upload-time = "2026-02-09T12:57:45.989Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/f3/e63df6d500314a2a60390d1989240d5f27318a7a68fa30ad3806e2a9323e/coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9", size = 263885, upload-time = "2026-02-09T12:57:47.42Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/67/7654810de580e14b37670b60a09c599fa348e48312db5b216d730857ffe6/coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092", size = 266308, upload-time = "2026-02-09T12:57:49.345Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/6f/39d41eca0eab3cc82115953ad41c4e77935286c930e8fad15eaed1389d83/coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9", size = 267452, upload-time = "2026-02-09T12:57:50.811Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/6d/39c0fbb8fc5cd4d2090811e553c2108cf5112e882f82505ee7495349a6bf/coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26", size = 261057, upload-time = "2026-02-09T12:57:52.447Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/a2/60010c669df5fa603bb5a97fb75407e191a846510da70ac657eb696b7fce/coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2", size = 263875, upload-time = "2026-02-09T12:57:53.938Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/d9/63b22a6bdbd17f1f96e9ed58604c2a6b0e72a9133e37d663bef185877cf6/coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940", size = 261500, upload-time = "2026-02-09T12:57:56.012Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/bf/69f86ba1ad85bc3ad240e4c0e57a2e620fbc0e1645a47b5c62f0e941ad7f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c", size = 265212, upload-time = "2026-02-09T12:57:57.5Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/f2/5f65a278a8c2148731831574c73e42f57204243d33bedaaf18fa79c5958f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0", size = 260398, upload-time = "2026-02-09T12:57:59.027Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/80/6e8280a350ee9fea92f14b8357448a242dcaa243cb2c72ab0ca591f66c8c/coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b", size = 262584, upload-time = "2026-02-09T12:58:01.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/63/01ff182fc95f260b539590fb12c11ad3e21332c15f9799cb5e2386f71d9f/coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9", size = 222688, upload-time = "2026-02-09T12:58:02.736Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/43/89de4ef5d3cd53b886afa114065f7e9d3707bdb3e5efae13535b46ae483d/coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd", size = 223746, upload-time = "2026-02-09T12:58:05.362Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/39/7cf0aa9a10d470a5309b38b289b9bb07ddeac5d61af9b664fe9775a4cb3e/coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997", size = 222003, upload-time = "2026-02-09T12:58:06.952Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/11/a9cf762bb83386467737d32187756a42094927150c3e107df4cb078e8590/coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601", size = 219522, upload-time = "2026-02-09T12:58:08.623Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/28/56e6d892b7b052236d67c95f1936b6a7cf7c3e2634bf27610b8cbd7f9c60/coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689", size = 219855, upload-time = "2026-02-09T12:58:10.176Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/69/233459ee9eb0c0d10fcc2fe425a029b3fa5ce0f040c966ebce851d030c70/coverage-7.13.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c", size = 250887, upload-time = "2026-02-09T12:58:12.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/90/2cdab0974b9b5bbc1623f7876b73603aecac11b8d95b85b5b86b32de5eab/coverage-7.13.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129", size = 253396, upload-time = "2026-02-09T12:58:14.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/15/ea4da0f85bf7d7b27635039e649e99deb8173fe551096ea15017f7053537/coverage-7.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552", size = 254745, upload-time = "2026-02-09T12:58:16.162Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/11/bb356e86920c655ca4d61daee4e2bbc7258f0a37de0be32d233b561134ff/coverage-7.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a", size = 257055, upload-time = "2026-02-09T12:58:17.892Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/0f/9ae1f8cb17029e09da06ca4e28c9e1d5c1c0a511c7074592e37e0836c915/coverage-7.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356", size = 250911, upload-time = "2026-02-09T12:58:19.495Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/3a/adfb68558fa815cbc29747b553bc833d2150228f251b127f1ce97e48547c/coverage-7.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71", size = 252754, upload-time = "2026-02-09T12:58:21.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/b1/540d0c27c4e748bd3cd0bd001076ee416eda993c2bae47a73b7cc9357931/coverage-7.13.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5", size = 250720, upload-time = "2026-02-09T12:58:22.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/95/383609462b3ffb1fe133014a7c84fc0dd01ed55ac6140fa1093b5af7ebb1/coverage-7.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98", size = 254994, upload-time = "2026-02-09T12:58:24.548Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/ba/1761138e86c81680bfc3c49579d66312865457f9fe405b033184e5793cb3/coverage-7.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5", size = 250531, upload-time = "2026-02-09T12:58:26.271Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/8e/05900df797a9c11837ab59c4d6fe94094e029582aab75c3309a93e6fb4e3/coverage-7.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0", size = 252189, upload-time = "2026-02-09T12:58:27.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/bd/29c9f2db9ea4ed2738b8a9508c35626eb205d51af4ab7bf56a21a2e49926/coverage-7.13.4-cp314-cp314-win32.whl", hash = "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb", size = 222258, upload-time = "2026-02-09T12:58:29.441Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/4d/1f8e723f6829977410efeb88f73673d794075091c8c7c18848d273dc9d73/coverage-7.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505", size = 223073, upload-time = "2026-02-09T12:58:31.026Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/5b/84100025be913b44e082ea32abcf1afbf4e872f5120b7a1cab1d331b1e13/coverage-7.13.4-cp314-cp314-win_arm64.whl", hash = "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2", size = 221638, upload-time = "2026-02-09T12:58:32.599Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/e4/c884a405d6ead1370433dad1e3720216b4f9fd8ef5b64bfd984a2a60a11a/coverage-7.13.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056", size = 220246, upload-time = "2026-02-09T12:58:34.181Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/5c/4d7ed8b23b233b0fffbc9dfec53c232be2e695468523242ea9fd30f97ad2/coverage-7.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc", size = 220514, upload-time = "2026-02-09T12:58:35.704Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/6f/3284d4203fd2f28edd73034968398cd2d4cb04ab192abc8cff007ea35679/coverage-7.13.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9", size = 261877, upload-time = "2026-02-09T12:58:37.864Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/aa/b672a647bbe1556a85337dc95bfd40d146e9965ead9cc2fe81bde1e5cbce/coverage-7.13.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf", size = 264004, upload-time = "2026-02-09T12:58:39.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/a1/aa384dbe9181f98bba87dd23dda436f0c6cf2e148aecbb4e50fc51c1a656/coverage-7.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55", size = 266408, upload-time = "2026-02-09T12:58:41.852Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/5e/5150bf17b4019bc600799f376bb9606941e55bd5a775dc1e096b6ffea952/coverage-7.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72", size = 267544, upload-time = "2026-02-09T12:58:44.093Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/ed/f1de5c675987a4a7a672250d2c5c9d73d289dbf13410f00ed7181d8017dd/coverage-7.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a", size = 260980, upload-time = "2026-02-09T12:58:45.721Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/e3/fe758d01850aa172419a6743fe76ba8b92c29d181d4f676ffe2dae2ba631/coverage-7.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6", size = 263871, upload-time = "2026-02-09T12:58:47.334Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/76/b829869d464115e22499541def9796b25312b8cf235d3bb00b39f1675395/coverage-7.13.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3", size = 261472, upload-time = "2026-02-09T12:58:48.995Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/9e/caedb1679e73e2f6ad240173f55218488bfe043e38da577c4ec977489915/coverage-7.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750", size = 265210, upload-time = "2026-02-09T12:58:51.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/10/0dd02cb009b16ede425b49ec344aba13a6ae1dc39600840ea6abcb085ac4/coverage-7.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39", size = 260319, upload-time = "2026-02-09T12:58:53.081Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/8e/234d2c927af27c6d7a5ffad5bd2cf31634c46a477b4c7adfbfa66baf7ebb/coverage-7.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0", size = 262638, upload-time = "2026-02-09T12:58:55.258Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/64/e5547c8ff6964e5965c35a480855911b61509cce544f4d442caa759a0702/coverage-7.13.4-cp314-cp314t-win32.whl", hash = "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea", size = 223040, upload-time = "2026-02-09T12:58:56.936Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/96/38086d58a181aac86d503dfa9c47eb20715a79c3e3acbdf786e92e5c09a8/coverage-7.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932", size = 224148, upload-time = "2026-02-09T12:58:58.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/72/8d10abd3740a0beb98c305e0c3faf454366221c0f37a8bcf8f60020bb65a/coverage-7.13.4-cp314-cp314t-win_arm64.whl", hash = "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b", size = 222172, upload-time = "2026-02-09T12:59:00.396Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dill"
|
||||
version = "0.4.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/81/e1/56027a71e31b02ddc53c7d65b01e68edf64dea2932122fe7746a516f75d5/dill-0.4.1.tar.gz", hash = "sha256:423092df4182177d4d8ba8290c8a5b640c66ab35ec7da59ccfa00f6fa3eea5fa", size = 187315, upload-time = "2026-01-19T02:36:56.85Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl", hash = "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d", size = 120019, upload-time = "2026-01-19T02:36:55.663Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "distlib"
|
||||
version = "0.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filelock"
|
||||
version = "3.25.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/94/b8/00651a0f559862f3bb7d6f7477b192afe3f583cc5e26403b44e59a55ab34/filelock-3.25.2.tar.gz", hash = "sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694", size = 40480, upload-time = "2026-03-11T20:45:38.487Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70", size = 26759, upload-time = "2026-03-11T20:45:37.437Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "identify"
|
||||
version = "2.6.17"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/57/84/376a3b96e5a8d33a7aa2c5b3b31a4b3c364117184bf0b17418055f6ace66/identify-2.6.17.tar.gz", hash = "sha256:f816b0b596b204c9fdf076ded172322f2723cf958d02f9c3587504834c8ff04d", size = 99579, upload-time = "2026-03-01T20:04:12.702Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/40/66/71c1227dff78aaeb942fed29dd5651f2aec166cc7c9aeea3e8b26a539b7d/identify-2.6.17-py2.py3-none-any.whl", hash = "sha256:be5f8412d5ed4b20f2bd41a65f920990bdccaa6a4a18a08f1eefdcd0bdd885f0", size = 99382, upload-time = "2026-03-01T20:04:11.439Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.11"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "isort"
|
||||
version = "8.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ef/7c/ec4ab396d31b3b395e2e999c8f46dec78c5e29209fac49d1f4dace04041d/isort-8.0.1.tar.gz", hash = "sha256:171ac4ff559cdc060bcfff550bc8404a486fee0caab245679c2abe7cb253c78d", size = 769592, upload-time = "2026-02-28T10:08:20.685Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/95/c7c34aa53c16353c56d0b802fba48d5f5caa2cdee7958acbcb795c830416/isort-8.0.1-py3-none-any.whl", hash = "sha256:28b89bc70f751b559aeca209e6120393d43fbe2490de0559662be7a9787e3d75", size = 89733, upload-time = "2026-02-28T10:08:19.466Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mccabe"
|
||||
version = "0.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658, upload-time = "2022-01-24T01:14:51.113Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nodeenv"
|
||||
version = "1.10.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "platformdirs"
|
||||
version = "4.9.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/19/56/8d4c30c8a1d07013911a8fdbd8f89440ef9f08d07a1b50ab8ca8be5a20f9/platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", size = 28737, upload-time = "2026-03-05T18:34:13.271Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pre-commit"
|
||||
version = "4.5.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cfgv" },
|
||||
{ name = "identify" },
|
||||
{ name = "nodeenv" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "virtualenv" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232, upload-time = "2025-12-16T21:14:33.552Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.19.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pylint"
|
||||
version = "4.0.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "astroid" },
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "dill" },
|
||||
{ name = "isort" },
|
||||
{ name = "mccabe" },
|
||||
{ name = "platformdirs" },
|
||||
{ name = "tomlkit" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e4/b6/74d9a8a68b8067efce8d07707fe6a236324ee1e7808d2eb3646ec8517c7d/pylint-4.0.5.tar.gz", hash = "sha256:8cd6a618df75deb013bd7eb98327a95f02a6fb839205a6bbf5456ef96afb317c", size = 1572474, upload-time = "2026-02-20T09:07:33.621Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/6f/9ac2548e290764781f9e7e2aaf0685b086379dabfb29ca38536985471eaf/pylint-4.0.5-py3-none-any.whl", hash = "sha256:00f51c9b14a3b3ae08cff6b2cdd43f28165c78b165b628692e428fb1f8dc2cf2", size = 536694, upload-time = "2026-02-20T09:07:31.028Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyright"
|
||||
version = "1.1.408"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "nodeenv" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/74/b2/5db700e52554b8f025faa9c3c624c59f1f6c8841ba81ab97641b54322f16/pyright-1.1.408.tar.gz", hash = "sha256:f28f2321f96852fa50b5829ea492f6adb0e6954568d1caa3f3af3a5f555eb684", size = 4400578, upload-time = "2026-01-08T08:07:38.795Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/82/a2c93e32800940d9573fb28c346772a14778b84ba7524e691b324620ab89/pyright-1.1.408-py3-none-any.whl", hash = "sha256:090b32865f4fdb1e0e6cd82bf5618480d48eecd2eb2e70f960982a3d9a4c17c1", size = 6399144, upload-time = "2026-01-08T08:07:37.082Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "iniconfig" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-cov"
|
||||
version = "7.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "coverage" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pytest" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-discovery"
|
||||
version = "1.1.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "filelock" },
|
||||
{ name = "platformdirs" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d7/7e/9f3b0dd3a074a6c3e1e79f35e465b1f2ee4b262d619de00cfce523cc9b24/python_discovery-1.1.3.tar.gz", hash = "sha256:7acca36e818cd88e9b2ba03e045ad7e93e1713e29c6bbfba5d90202310b7baa5", size = 56945, upload-time = "2026-03-10T15:08:15.038Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/80/73211fc5bfbfc562369b4aa61dc1e4bf07dc7b34df7b317e4539316b809c/python_discovery-1.1.3-py3-none-any.whl", hash = "sha256:90e795f0121bc84572e737c9aa9966311b9fde44ffb88a5953b3ec9b31c6945e", size = 31485, upload-time = "2026-03-10T15:08:13.06Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyyaml"
|
||||
version = "6.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.32.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "charset-normalizer" },
|
||||
{ name = "idna" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.15.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/77/9b/840e0039e65fcf12758adf684d2289024d6140cde9268cc59887dc55189c/ruff-0.15.5.tar.gz", hash = "sha256:7c3601d3b6d76dce18c5c824fc8d06f4eef33d6df0c21ec7799510cde0f159a2", size = 4574214, upload-time = "2026-03-05T20:06:34.946Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/47/20/5369c3ce21588c708bcbe517a8fbe1a8dfdb5dfd5137e14790b1da71612c/ruff-0.15.5-py3-none-linux_armv6l.whl", hash = "sha256:4ae44c42281f42e3b06b988e442d344a5b9b72450ff3c892e30d11b29a96a57c", size = 10478185, upload-time = "2026-03-05T20:06:29.093Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/ed/e81dd668547da281e5dce710cf0bc60193f8d3d43833e8241d006720e42b/ruff-0.15.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6edd3792d408ebcf61adabc01822da687579a1a023f297618ac27a5b51ef0080", size = 10859201, upload-time = "2026-03-05T20:06:32.632Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/8f/533075f00aaf19b07c5cd6aa6e5d89424b06b3b3f4583bfa9c640a079059/ruff-0.15.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:89f463f7c8205a9f8dea9d658d59eff49db05f88f89cc3047fb1a02d9f344010", size = 10184752, upload-time = "2026-03-05T20:06:40.312Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/0e/ba49e2c3fa0395b3152bad634c7432f7edfc509c133b8f4529053ff024fb/ruff-0.15.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba786a8295c6574c1116704cf0b9e6563de3432ac888d8f83685654fe528fd65", size = 10534857, upload-time = "2026-03-05T20:06:19.581Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/71/39234440f27a226475a0659561adb0d784b4d247dfe7f43ffc12dd02e288/ruff-0.15.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd4b801e57955fe9f02b31d20375ab3a5c4415f2e5105b79fb94cf2642c91440", size = 10309120, upload-time = "2026-03-05T20:06:00.435Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/87/4140aa86a93df032156982b726f4952aaec4a883bb98cb6ef73c347da253/ruff-0.15.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:391f7c73388f3d8c11b794dbbc2959a5b5afe66642c142a6effa90b45f6f5204", size = 11047428, upload-time = "2026-03-05T20:05:51.867Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/f7/4953e7e3287676f78fbe85e3a0ca414c5ca81237b7575bdadc00229ac240/ruff-0.15.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8dc18f30302e379fe1e998548b0f5e9f4dff907f52f73ad6da419ea9c19d66c8", size = 11914251, upload-time = "2026-03-05T20:06:22.887Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/46/0f7c865c10cf896ccf5a939c3e84e1cfaeed608ff5249584799a74d33835/ruff-0.15.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc6e7f90087e2d27f98dc34ed1b3ab7c8f0d273cc5431415454e22c0bd2a681", size = 11333801, upload-time = "2026-03-05T20:05:57.168Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/01/a10fe54b653061585e655f5286c2662ebddb68831ed3eaebfb0eb08c0a16/ruff-0.15.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1cb7169f53c1ddb06e71a9aebd7e98fc0fea936b39afb36d8e86d36ecc2636a", size = 11206821, upload-time = "2026-03-05T20:06:03.441Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/0d/2132ceaf20c5e8699aa83da2706ecb5c5dcdf78b453f77edca7fb70f8a93/ruff-0.15.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9b037924500a31ee17389b5c8c4d88874cc6ea8e42f12e9c61a3d754ff72f1ca", size = 11133326, upload-time = "2026-03-05T20:06:25.655Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/cb/2e5259a7eb2a0f87c08c0fe5bf5825a1e4b90883a52685524596bfc93072/ruff-0.15.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:65bb414e5b4eadd95a8c1e4804f6772bbe8995889f203a01f77ddf2d790929dd", size = 10510820, upload-time = "2026-03-05T20:06:37.79Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/20/b67ce78f9e6c59ffbdb5b4503d0090e749b5f2d31b599b554698a80d861c/ruff-0.15.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d20aa469ae3b57033519c559e9bc9cd9e782842e39be05b50e852c7c981fa01d", size = 10302395, upload-time = "2026-03-05T20:05:54.504Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/e5/719f1acccd31b720d477751558ed74e9c88134adcc377e5e886af89d3072/ruff-0.15.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:15388dd28c9161cdb8eda68993533acc870aa4e646a0a277aa166de9ad5a8752", size = 10754069, upload-time = "2026-03-05T20:06:06.422Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/9c/d1db14469e32d98f3ca27079dbd30b7b44dbb5317d06ab36718dee3baf03/ruff-0.15.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b30da330cbd03bed0c21420b6b953158f60c74c54c5f4c1dabbdf3a57bf355d2", size = 11304315, upload-time = "2026-03-05T20:06:10.867Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/3a/950367aee7c69027f4f422059227b290ed780366b6aecee5de5039d50fa8/ruff-0.15.5-py3-none-win32.whl", hash = "sha256:732e5ee1f98ba5b3679029989a06ca39a950cced52143a0ea82a2102cb592b74", size = 10551676, upload-time = "2026-03-05T20:06:13.705Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/00/bf077a505b4e649bdd3c47ff8ec967735ce2544c8e4a43aba42ee9bf935d/ruff-0.15.5-py3-none-win_amd64.whl", hash = "sha256:821d41c5fa9e19117616c35eaa3f4b75046ec76c65e7ae20a333e9a8696bc7fe", size = 11678972, upload-time = "2026-03-05T20:06:45.379Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/4e/cd76eca6db6115604b7626668e891c9dd03330384082e33662fb0f113614/ruff-0.15.5-py3-none-win_arm64.whl", hash = "sha256:b498d1c60d2fe5c10c45ec3f698901065772730b411f164ae270bb6bfcc4740b", size = 10965572, upload-time = "2026-03-05T20:06:16.984Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tomlkit"
|
||||
version = "0.14.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c3/af/14b24e41977adb296d6bd1fb59402cf7d60ce364f90c890bd2ec65c43b5a/tomlkit-0.14.0.tar.gz", hash = "sha256:cf00efca415dbd57575befb1f6634c4f42d2d87dbba376128adb42c121b87064", size = 187167, upload-time = "2026-01-13T01:14:53.304Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/11/87d6d29fb5d237229d67973a6c9e06e048f01cf4994dee194ab0ea841814/tomlkit-0.14.0-py3-none-any.whl", hash = "sha256:592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680", size = 39310, upload-time = "2026-01-13T01:14:51.965Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.15.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.6.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "virtualenv"
|
||||
version = "21.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "distlib" },
|
||||
{ name = "filelock" },
|
||||
{ name = "platformdirs" },
|
||||
{ name = "python-discovery" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/aa/92/58199fe10049f9703c2666e809c4f686c54ef0a68b0f6afccf518c0b1eb9/virtualenv-21.2.0.tar.gz", hash = "sha256:1720dc3a62ef5b443092e3f499228599045d7fea4c79199770499df8becf9098", size = 5840618, upload-time = "2026-03-09T17:24:38.013Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/59/7d02447a55b2e55755011a647479041bc92a82e143f96a8195cb33bd0a1c/virtualenv-21.2.0-py3-none-any.whl", hash = "sha256:1bd755b504931164a5a496d217c014d098426cddc79363ad66ac78125f9d908f", size = 5825084, upload-time = "2026-03-09T17:24:35.378Z" },
|
||||
]
|
||||
Reference in New Issue
Block a user