Compare commits

..
4 Commits
Author SHA1 Message Date
av daa4379dbe backups: в нотификацию добавлены размеры приложений и свободное место
Linting / Ansible Lint (push) Canceled after 0s
Linting / YAML Lint (push) Canceled after 0s
- список приложений теперь со значком статуса (забекаплено, упал дамп,
  бекапить нечего) и занятым местом; размеры считает dust одним вызовом
- в конце уведомления — свободное место на дисках сервера, по одной строке
  на файловую систему
2026-08-22 11:50:07 +03:00
av b5b43a484e в ruff включён набор UP: современный синтаксис аннотаций
- typing.Dict/List заменены на dict/list, Optional[X] — на X | None;
  эти же места подсвечивал basedpyright в редакторе
- из smtp-convert-secret-key-to-password.py убрана проверка на Python 2
2026-08-22 11:37:51 +03:00
av bdc319df64 линтеры python перенастроены: mypy заменён на pyrefly
- набор правил ruff расширен по канону rp-local-env (ANN, PTH, C90, G и др.),
  в lefthook добавлен job ruff check --fix
- код приведён под новые правила: логирование через %s вместо f-строк,
  os.path заменён на pathlib, run_backup_process и initialize разбиты
  на функции по порогу цикломатической сложности
2026-08-22 11:32:03 +03:00
av 321f6e7b6b backups: в нотификацию попадают все забекапленные приложения
- список строится по backup-targets, а не только по успешным скриптам дампа,
  поэтому приложения с бекапом одних данных (calibre, goatcounter, tududi,
  tuwunel) больше не теряются
- отсутствие backup.sh при наличии backup-targets — штатный случай, теперь
  это info, а warning остался только когда бекапить нечего вообще
2026-08-22 11:25:06 +03:00
8 changed files with 482 additions and 277 deletions
+11 -4
View File
@@ -15,7 +15,7 @@ Ansible-проект для автоматизации личного серве
- `templates/` — общие шаблоны (например `env.template`).
- `scripts/` — вспомогательные Python-скрипты (SMTP-утилиты для Yandex Cloud Postbox).
- `.gitea/workflows/lint.yml` — CI: yamllint + ansible-lint.
- `lefthook.yml` — pre-commit хуки (ruff, mypy, yamllint, ansible-lint, gitleaks, проверка vault).
- `lefthook.yml` — pre-commit хуки (ruff, pyrefly, yamllint, ansible-lint, gitleaks, проверка vault).
- `tasks.py` — задачи через invoke (`inv <task>`).
- `pyproject.toml` — зависимости Python, управляются через `uv`.
@@ -109,10 +109,13 @@ uv run ansible-galaxy install --role-file requirements.yml
## Линтинг и CI
- CI (`.gitea/workflows/lint.yml`): два параллельных job — yamllint и ansible-lint.
- Конфиги: `.yamllint.yml` (макс. длина строки 120), `.ansible-lint.yml` (профиль production, offline).
- Конфиги: `.yamllint.yml` (макс. длина строки 120), `.ansible-lint.yml` (профиль production, offline),
`[tool.ruff.lint]` и `[tool.pyrefly]` в `pyproject.toml`.
- Набор правил ruff расширен относительно дефолтного (ANN, PTH, ERA, PT, C90, RET, N, Q, TID, G, LOG,
FURB, PLC) и синхронизирован с остальными репозиториями — канон настройки лежит в `rp-local-env`.
- Pre-commit хуки через lefthook:
- `ruff format` + `ruff check` — форматирование и линтинг Python.
- `mypy` — проверка типов Python.
- `ruff format` + `ruff check --fix` + `ruff check` — форматирование и линтинг Python.
- `pyrefly` — проверка типов Python (заменил mypy).
- `yamllint` — линтинг YAML.
- `ansible-lint` — линтинг Ansible (профиль production).
- `gitleaks` — поиск секретов в staged-файлах.
@@ -149,3 +152,7 @@ ansible-playbook -i production.yml --diff playbook-gitea.yml
- Шаблоны скриптов бэкапов в `files/<app>/` (backup.template.sh, gobackup.template.yml и др.).
- `files/backups/backup-all.py` — оркестратор, запускает все бэкапы через restic.
- Cron-расписание настраивается в `playbook-backups.yml`.
- Уведомление включает список всех найденных приложений со значком статуса (✅ забекаплено,
❌ упал скрипт дампа, ⏭ бекапить нечего) и занятым местом, а в конце — свободное место
на дисках. Размеры считает `dust` (ставится ролью eget); если его нет, прогон продолжается
без размеров.
+366 -136
View File
@@ -11,13 +11,18 @@ restic-операции разнесены на фазы с разной час
- verify -- check --read-data-subset, помесячно (полное покрытие за год).
Один прогон выполняет фазы строго последовательно, поэтому restic-локи между фазами
не конфликтуют. Наложение соседних прогонов предотвращается flock в cron-задаче.
Размеры приложений считает dust (ставится ролью eget в bin_prefix); если его нет
или он упал, прогон продолжается, а размеры в уведомлении просто не показываются.
"""
import argparse
import itertools
import json
import logging
import os
import pwd
import shutil
import subprocess
import sys
import time
@@ -25,8 +30,9 @@ import tomllib
from abc import ABC
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from enum import Enum
from pathlib import Path
from typing import Any, Dict, List, Optional
from typing import Any
import requests
from croniter import croniter
@@ -41,6 +47,10 @@ BACKUP_TARGETS_FILE = "backup-targets"
# Used when backup-targets file not exists
BACKUP_DEFAULT_DIR = "backups"
# Утилита подсчёта размеров директорий (github.com/bootandy/dust).
# Ставится ролью eget в bin_prefix, который есть в PATH cron-задачи.
DUST_BIN = "dust"
# Retention policy applied by the `forget` phase on every run.
KEEP_DAILY = "90"
KEEP_MONTHLY = "36"
@@ -72,6 +82,7 @@ logger = logging.getLogger(__name__)
@dataclass
class Config:
host_name: str
roots: list[Path]
@dataclass
@@ -87,9 +98,9 @@ class MaintenanceOptions:
class Schedule:
"""Расписание обслуживающих фаз: фаза -> cron-выражение."""
cron: Dict[str, str] = field(default_factory=dict)
cron: dict[str, str] = field(default_factory=dict)
def due_phases(self, now: datetime) -> List[str]:
def due_phases(self, now: datetime) -> list[str]:
"""Фазы, которые нужно выполнить в этот прогон, в порядке PHASE_ORDER."""
phases = list(ALWAYS_PHASES)
for phase in SCHEDULED_PHASES:
@@ -114,14 +125,55 @@ class Schedule:
class Application:
path: Path
owner: str
backup_script: Optional[Path]
backup_targets: List[Path]
backup_script: Path | None
backup_targets: list[Path]
class AppStatus(Enum):
"""Что случилось с приложением в этот прогон."""
DONE = "done"
FAILED = "failed"
SKIPPED = "skipped"
APP_STATUS_ICONS = {
AppStatus.DONE: "",
AppStatus.FAILED: "",
AppStatus.SKIPPED: "",
}
@dataclass
class AppRunResult:
"""Строка приложения в уведомлении: статус бекапа и занятое место."""
name: str
status: AppStatus
size: int | None = None
@dataclass
class DiskUsage:
"""Занятое и свободное место на файловой системе."""
path: Path
total: int
free: int
@property
def used(self) -> int:
return self.total - self.free
@property
def used_percent(self) -> float:
return 100.0 * self.used / self.total if self.total else 0.0
@dataclass
class BackupResult:
success: bool
error: Optional[str] = None
error: str | None = None
@dataclass
@@ -129,7 +181,80 @@ class StorageRunResult:
name: str
success: bool
duration: float
phases: List[str]
phases: list[str]
def format_size(size: int) -> str:
"""Байты в человекочитаемый вид: 4.1 GiB, 512 MiB, 12 KiB."""
value = float(size)
for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
if value < 1024 or unit == "TiB":
precision = 0 if unit == "B" or value >= 100 else 1
return f"{value:.{precision}f} {unit}"
value /= 1024
return f"{value:.1f} TiB"
def measure_app_sizes(paths: list[Path]) -> dict[str, int]:
"""Размеры директорий приложений одним вызовом dust: путь -> байты.
dust с `-o b` печатает размеры строками вида "1052672B", а при нескольких
аргументах заворачивает их в корень "(total)" — разбираем оба случая.
"""
if not paths:
return {}
cmd = [DUST_BIN, "--output-json", "--output-format", "b", "--depth", "0"]
cmd += ["--no-progress", *(str(path) for path in paths)]
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
except (OSError, subprocess.TimeoutExpired) as exc:
logger.warning("Failed to run %s: %s", DUST_BIN, exc)
return {}
if result.returncode != 0:
logger.warning(
"%s exited with code %s: %s", DUST_BIN, result.returncode, result.stderr
)
return {}
try:
tree = json.loads(result.stdout)
except json.JSONDecodeError as exc:
logger.warning("Could not parse %s output: %s", DUST_BIN, exc)
return {}
sizes: dict[str, int] = {}
for node in [tree, *tree.get("children", [])]:
raw_size = str(node.get("size", "")).rstrip("B")
if not raw_size.isdigit():
continue
sizes[str(node.get("name", ""))] = int(raw_size)
return sizes
def collect_disk_usage(paths: list[Path]) -> list[DiskUsage]:
"""Занятое/свободное место по файловым системам, на которых лежат paths.
Пути с одной и той же файловой системы схлопываются: смысла показывать
/mnt/applications дважды нет.
"""
usages: list[DiskUsage] = []
seen_devices: set[int] = set()
for path in paths:
try:
device = path.stat().st_dev
if device in seen_devices:
continue
total, _used, free = shutil.disk_usage(path)
except OSError as exc:
logger.warning("Could not read disk usage for %s: %s", path, exc)
continue
seen_devices.add(device)
usages.append(DiskUsage(path=path, total=total, free=free))
return usages
def format_duration(seconds: float) -> str:
@@ -149,8 +274,8 @@ class Storage(ABC):
def run(
self,
backup_dirs: List[str],
phases: List[str],
backup_dirs: list[str],
phases: list[str],
maintenance: MaintenanceOptions,
) -> BackupResult:
"""Run the requested phases against this storage."""
@@ -160,7 +285,7 @@ class Storage(ABC):
class ResticStorage(Storage):
TYPE_NAME = "restic"
def __init__(self, name: str, params: Dict[str, Any]):
def __init__(self, name: str, params: dict[str, Any]) -> None:
self.name = name
self.restic_repository = str(params.get("restic_repository", ""))
self.restic_password = str(params.get("restic_password", ""))
@@ -170,7 +295,7 @@ class ResticStorage(Storage):
raise ValueError(
f"'env' must be a table for storage backend ResticStorage: '{self.name}'"
)
self.env: Dict[str, str] = {str(k): str(v) for k, v in env_raw.items()}
self.env: dict[str, str] = {str(k): str(v) for k, v in env_raw.items()}
if not self.restic_repository or not self.restic_password:
raise ValueError(
@@ -179,8 +304,8 @@ class ResticStorage(Storage):
def run(
self,
backup_dirs: List[str],
phases: List[str],
backup_dirs: list[str],
phases: list[str],
maintenance: MaintenanceOptions,
) -> BackupResult:
try:
@@ -191,12 +316,12 @@ class ResticStorage(Storage):
def __build_steps(
self,
backup_dirs: List[str],
phases: List[str],
backup_dirs: list[str],
phases: list[str],
maintenance: MaintenanceOptions,
) -> List[tuple[str, List[str]]]:
) -> list[tuple[str, list[str]]]:
"""Собрать restic-команды для запрошенных фаз в порядке PHASE_ORDER."""
steps: List[tuple[str, List[str]]] = []
steps: list[tuple[str, list[str]]] = []
for phase in PHASE_ORDER:
if phase not in phases:
@@ -261,8 +386,8 @@ class ResticStorage(Storage):
def __run_internal(
self,
backup_dirs: List[str],
phases: List[str],
backup_dirs: list[str],
phases: list[str],
maintenance: MaintenanceOptions,
) -> BackupResult:
logger.info("Starting restic run for storage '%s'", self.name)
@@ -283,9 +408,7 @@ class ResticStorage(Storage):
return BackupResult(success=True)
def __run_step(
self, step: str, cmd: List[str], env: Dict[str, str]
) -> Optional[str]:
def __run_step(self, step: str, cmd: list[str], env: dict[str, str]) -> str | None:
"""Run a single restic command. Return None on success or error text."""
result = subprocess.run(cmd, env=env, capture_output=True, text=True)
@@ -306,7 +429,7 @@ class Notifier(ABC):
class AppriseNotifier(Notifier):
TYPE_NAME = "apprise"
def __init__(self, name: str, params: Dict[str, Any]):
def __init__(self, name: str, params: dict[str, Any]) -> None:
self.name = name
self.api_url = str(params.get("api_url", "")).rstrip("/")
self.tag = str(params.get("tag", ""))
@@ -329,18 +452,20 @@ class AppriseNotifier(Notifier):
logger.info("Apprise notification sent successfully")
else:
logger.error(
f"Failed to send Apprise notification: {response.status_code} - {response.text}"
"Failed to send Apprise notification: %s - %s",
response.status_code,
response.text,
)
class ApplicationFinder:
def __init__(self, roots: List[Path]):
def __init__(self, roots: list[Path]) -> None:
self.roots = roots
self.warnings: List[str] = []
self.warnings: list[str] = []
def find_applications(self) -> List[Application]:
def find_applications(self) -> list[Application]:
"""Discover all applications with their backup scripts and targets."""
applications: List[Application] = []
applications: list[Application] = []
source_dirs = itertools.chain(*(root.iterdir() for root in self.roots))
for app_dir in source_dirs:
@@ -361,28 +486,27 @@ class ApplicationFinder:
)
)
except (KeyError, OSError) as e:
logger.warning(f"Could not get owner for {app_dir}: {e}")
logger.warning("Could not get owner for %s: %s", app_dir, e)
applications.sort(key=lambda app: app.path.name)
return applications
def _find_backup_script(self, app_dir: Path) -> Optional[Path]:
def _find_backup_script(self, app_dir: Path) -> Path | None:
"""Find executable backup script in application directory."""
for name in ("backup.sh", "backup"):
script_path = app_dir / name
if script_path.exists():
if os.access(script_path, os.X_OK):
return script_path
else:
logger.warning(
f"Backup script {script_path} exists but is not executable"
"Backup script %s exists but is not executable", script_path
)
return None
def _find_backup_targets(self, app_dir: Path) -> List[Path]:
def _find_backup_targets(self, app_dir: Path) -> list[Path]:
"""Resolve backup target directories for an application."""
targets_file = app_dir / BACKUP_TARGETS_FILE
resolved_targets: List[Path] = []
resolved_targets: list[Path] = []
if targets_file.exists():
for target_line in self._parse_targets_file(targets_file):
@@ -410,9 +534,9 @@ class ApplicationFinder:
return resolved_targets
def _parse_targets_file(self, targets_file: Path) -> List[str]:
def _parse_targets_file(self, targets_file: Path) -> list[str]:
"""Parse backup-targets file, skipping comments and empty lines."""
targets: List[str] = []
targets: list[str] = []
try:
for raw_line in targets_file.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
@@ -430,75 +554,156 @@ class BackupManager:
def __init__(
self,
config: Config,
storages: List[Storage],
notifiers: List[Notifier],
storages: list[Storage],
notifiers: list[Notifier],
schedule: Schedule,
maintenance: MaintenanceOptions,
forced_phases: Optional[List[str]] = None,
):
self.errors: List[str] = []
self.warnings: List[str] = []
self.successful_backups: List[str] = []
forced_phases: list[str] | None = None,
) -> None:
self.errors: list[str] = []
self.warnings: list[str] = []
self.app_results: list[AppRunResult] = []
self.disk_usages: list[DiskUsage] = []
self.config = config
self.storages = storages
self.notifiers = notifiers
self.schedule = schedule
self.maintenance = maintenance
self.forced_phases = forced_phases
self.active_phases: List[str] = []
self.active_phases: list[str] = []
self.archive_duration: float = 0.0
self.storage_results: List[StorageRunResult] = []
self.storage_results: list[StorageRunResult] = []
def run_backup_process(self, applications: List[Application]) -> bool:
def run_backup_process(self, applications: list[Application]) -> bool:
"""Main backup process"""
logger.info("Starting backup process")
logger.info(f"Found {len(applications)} application directories")
logger.info("Found %d application directories", len(applications))
# Какие фазы выполняем в этот прогон: либо принудительно из CLI, либо по расписанию.
self.active_phases = self._resolve_phases()
self._run_archive_phase(applications)
backup_dirs = self._collect_backup_dirs(applications)
overall_success = self._run_storages(backup_dirs)
self._collect_usage(applications)
self._send_notification(overall_success)
logger.info("Backup process completed")
if self.errors:
logger.error("Backup completed with %d errors", len(self.errors))
return False
if self.warnings:
logger.warning("Backup completed with %d warnings", len(self.warnings))
return True
logger.info("Backup completed successfully")
return True
def _resolve_phases(self) -> list[str]:
"""Какие фазы выполняем в этот прогон: принудительно из CLI или по расписанию."""
if self.forced_phases is not None:
self.active_phases = self.forced_phases
logger.info("Phases (forced): %s", ", ".join(self.active_phases))
else:
self.active_phases = self.schedule.due_phases(datetime.now())
logger.info("Phases (scheduled): %s", ", ".join(self.active_phases))
logger.info("Phases (forced): %s", ", ".join(self.forced_phases))
return self.forced_phases
phases = self.schedule.due_phases(datetime.now())
logger.info("Phases (scheduled): %s", ", ".join(phases))
return phases
def _run_archive_phase(self, applications: list[Application]) -> None:
"""Прогнать скрипты дампов приложений и собрать список того, что уедет в restic.
Фаза нужна только вместе с restic backup: без неё дампы делать некому и незачем.
"""
archive_start = time.monotonic()
# Archive phase (per-app backup scripts) нужна только если будем делать restic backup.
if PHASE_BACKUP in self.active_phases:
for app in applications:
app_dir = str(app.path)
username = app.owner
logger.info(f"Processing backup for app: {app_dir} (user {username})")
if app.backup_script is None:
warning_msg = (
f"No backup script found for app: {app_dir} (user {username})"
)
logger.warning(warning_msg)
self.warnings.append(warning_msg)
continue
self._run_app_backup(str(app.backup_script), app_dir, username)
status = self._archive_app(app)
self.app_results.append(AppRunResult(name=app.path.name, status=status))
else:
logger.info("Backup phase not active, skipping per-app archive scripts")
self.app_results = [
AppRunResult(name=app.path.name, status=AppStatus.SKIPPED)
for app in applications
]
self.archive_duration = time.monotonic() - archive_start
logger.info(
"Archive phase finished in %s", format_duration(self.archive_duration)
)
# Collect backup directories from applications
backup_dirs: List[str] = []
def _archive_app(self, app: Application) -> AppStatus:
"""Обработать одно приложение: сделать дамп, если он предусмотрен."""
app_dir = str(app.path)
username = app.owner
if app.backup_script is None:
if app.backup_targets:
# Приложение без дампа: restic забирает его данные как есть,
# отдельный шаг архивации ему не нужен.
logger.info(
"No backup script for app: %s (user %s), "
"data directories go to restic as is",
app_dir,
username,
)
return AppStatus.DONE
warning_msg = (
f"Nothing to back up for app: {app_dir} (user {username}): "
f"no backup script and no backup targets"
)
logger.warning(warning_msg)
self.warnings.append(warning_msg)
return AppStatus.SKIPPED
logger.info("Processing backup for app: %s (user %s)", app_dir, username)
if not self._run_app_backup(str(app.backup_script), app_dir, username):
return AppStatus.FAILED
# Дамп сделан, но в restic он попадёт только если есть цели бекапа;
# об их отсутствии уже предупредил ApplicationFinder.
return AppStatus.DONE if app.backup_targets else AppStatus.SKIPPED
def _collect_usage(self, applications: list[Application]) -> None:
"""Померить размеры приложений и свободное место на их файловых системах.
Считаем после архивации, чтобы свежие дампы попали в размер, и после
restic: цифры информационные, задерживать из-за них бекап незачем.
"""
usage_start = time.monotonic()
sizes = measure_app_sizes([app.path for app in applications])
by_name = {app.path.name: str(app.path) for app in applications}
for result in self.app_results:
result.size = sizes.get(by_name.get(result.name, ""))
# Корень системы плюс диски, на которых лежат приложения: на сервере это
# разные диски, и место кончается на них независимо.
self.disk_usages = collect_disk_usage([Path("/"), *self.config.roots])
logger.info(
"Usage stats collected in %s",
format_duration(time.monotonic() - usage_start),
)
@staticmethod
def _collect_backup_dirs(applications: list[Application]) -> list[str]:
"""Собрать цели бекапа всех приложений, сохраняя порядок и убирая дубли."""
backup_dirs: list[str] = []
for app in applications:
for target in app.backup_targets:
target_str = str(target)
if target_str not in backup_dirs:
backup_dirs.append(target_str)
logger.info(f"Found backup directories: {backup_dirs}")
logger.info("Found backup directories: %s", backup_dirs)
return backup_dirs
def _run_storages(self, backup_dirs: list[str]) -> bool:
"""Прогнать активные фазы по всем хранилищам.
Хранилища независимы: падение одного не отменяет попытку для остальных.
"""
overall_success = True
# Each storage is processed independently: a failure in one storage
# must not prevent the others from being attempted.
for storage in self.storages:
storage_start = time.monotonic()
try:
@@ -531,28 +736,14 @@ class BackupManager:
error_msg += f": {backup_result.error}"
self.errors.append(error_msg)
# Determine overall success
overall_success = overall_success and backup_result.success
# Send notification
self._send_notification(overall_success)
logger.info("Backup process completed")
if self.errors:
logger.error(f"Backup completed with {len(self.errors)} errors")
return False
elif self.warnings:
logger.warning(f"Backup completed with {len(self.warnings)} warnings")
return True
else:
logger.info("Backup completed successfully")
return True
return overall_success
def _run_app_backup(self, script_path: str, app_dir: str, username: str) -> bool:
"""Run backup script as the specified user"""
try:
logger.info(f"Running backup script {script_path} (user {username})")
logger.info("Running backup script %s (user %s)", script_path, username)
# Use su to run the script as the user
cmd = ["su", "--login", username, "--command", script_path]
@@ -566,10 +757,8 @@ class BackupManager:
)
if result.returncode == 0:
logger.info(f"Backup script for {username} completed successfully")
self.successful_backups.append(username)
logger.info("Backup script for %s completed successfully", username)
return True
else:
error_msg = f"Backup script {script_path} failed with return code {result.returncode}"
if result.stderr:
error_msg += f": {result.stderr}"
@@ -588,26 +777,56 @@ class BackupManager:
self.errors.append(f"App {username}: {error_msg}")
return False
def _render_apps(self) -> str:
"""Список приложений: значок статуса, имя и занятое место."""
if not self.app_results:
return ""
items = ""
for result in self.app_results:
# Размер отсутствует, только если dust не отработал: тогда просто имя.
size = f"{format_size(result.size)}" if result.size is not None else ""
items += f"<li>{APP_STATUS_ICONS[result.status]} {result.name}{size}</li>"
return f"<p>Приложения:</p><ul>{items}</ul>"
def _render_run_stats(self) -> str:
"""Фазы restic и затраченное время."""
phases_text = ", ".join(self.active_phases) if self.active_phases else ""
block = f"<p>🔧 Фазы restic: {phases_text}</p>"
block += f"<p>⏱ Время архивации: {format_duration(self.archive_duration)}</p>"
if self.storage_results:
items = "".join(
f"<li>{'' if r.success else ''} {r.name}: {format_duration(r.duration)}</li>"
for r in self.storage_results
)
block += f"<p>⏱ Время записи в хранилища:</p><ul>{items}</ul>"
return block
def _render_disks(self) -> str:
"""Свободное место на дисках сервера."""
if not self.disk_usages:
return ""
items = "".join(
f"<li>{u.path}: свободно {format_size(u.free)} из {format_size(u.total)}"
f" (занято {u.used_percent:.0f}%)</li>"
for u in self.disk_usages
)
return f"<p>💾 Свободное место:</p><ul>{items}</ul>"
def _send_notification(self, success: bool) -> None:
"""Send notification to Notifiers"""
host = self.config.host_name
phases_text = ", ".join(self.active_phases) if self.active_phases else ""
if success and not self.errors:
title = f"{host}: бекап успешно завершен"
message = f"<p><b>{host}</b>: бекап успешно завершен!</p>"
if self.successful_backups:
items = "".join(f"<li>{b}</li>" for b in self.successful_backups)
message += f"<p>Успешные бекапы:</p><ul>{items}</ul>"
else:
title = f"{host}: бекап завершен с ошибками ({len(self.errors)})"
message = f"<p><b>{host}</b>: бекап завершен с ошибками!</p>"
if self.successful_backups:
items = "".join(f"<li>{b}</li>" for b in self.successful_backups)
message += f"<p>✅ Успешные бекапы:</p><ul>{items}</ul>"
message += self._render_apps()
if not (success and not self.errors):
if self.warnings:
items = "".join(f"<li>{w}</li>" for w in self.warnings)
message += f"<p>⚠️ Предупреждения:</p><ul>{items}</ul>"
@@ -616,23 +835,17 @@ class BackupManager:
items = "".join(f"<li>{e}</li>" for e in self.errors)
message += f"<p>❌ Ошибки:</p><ul>{items}</ul>"
message += f"<p>🔧 Фазы restic: {phases_text}</p>"
message += f"<p>⏱ Время архивации: {format_duration(self.archive_duration)}</p>"
if self.storage_results:
items = "".join(
f"<li>{'' if r.success else ''} {r.name}: {format_duration(r.duration)}</li>"
for r in self.storage_results
)
message += f"<p>⏱ Время записи в хранилища:</p><ul>{items}</ul>"
message += self._render_run_stats()
message += self._render_disks()
for notificator in self.notifiers:
try:
notificator.send(title, message)
except Exception as e:
logger.error(f"Failed to send notification: {str(e)}")
logger.error("Failed to send notification: %s", e)
def parse_phases(raw: str) -> List[str]:
def parse_phases(raw: str) -> list[str]:
"""Разобрать CLI-список фаз, вернуть их в порядке PHASE_ORDER."""
requested = {p.strip() for p in raw.split(",") if p.strip()}
unknown = requested - set(PHASE_ORDER)
@@ -644,50 +857,40 @@ def parse_phases(raw: str) -> List[str]:
return [p for p in PHASE_ORDER if p in requested]
def initialize(
config_path: Path,
forced_phases: Optional[List[str]] = None,
) -> tuple[ApplicationFinder, BackupManager]:
try:
with config_path.open("rb") as config_file:
raw_config = tomllib.load(config_file)
except OSError as e:
logger.error(f"Failed to read config file {config_path}: {e}")
raise
host_name = str(raw_config.get("host_name", "unknown"))
roots_raw = raw_config.get("roots") or []
if not isinstance(roots_raw, list) or not roots_raw:
raise ValueError("roots must be a non-empty list of paths in config.toml")
roots = [Path(root) for root in roots_raw]
def build_storages(raw_config: dict[str, Any]) -> list[Storage]:
"""Собрать хранилища из секции [storage] конфига."""
storage_raw = raw_config.get("storage") or {}
storages: List[Storage] = []
storages: list[Storage] = []
for name, params in storage_raw.items():
if not isinstance(params, dict):
raise ValueError(f"Storage config for {name} must be a table")
storage_type = params.get("type", "")
if storage_type == ResticStorage.TYPE_NAME:
if params.get("type", "") == ResticStorage.TYPE_NAME:
storages.append(ResticStorage(name, params))
if not storages:
raise ValueError("At least one storage backend must be configured")
return storages
def build_notifiers(raw_config: dict[str, Any]) -> list[Notifier]:
"""Собрать нотификаторы из секции [notifier] конфига."""
notifications_raw = raw_config.get("notifier") or {}
notifiers: List[Notifier] = []
notifiers: list[Notifier] = []
for name, params in notifications_raw.items():
if not isinstance(params, dict):
raise ValueError(f"Notificator config for {name} must be a table")
notifier_type = params.get("type", "")
if notifier_type == AppriseNotifier.TYPE_NAME:
if params.get("type", "") == AppriseNotifier.TYPE_NAME:
notifiers.append(AppriseNotifier(name, params))
if not notifiers:
raise ValueError("At least one notification backend must be configured")
return notifiers
def build_schedule(raw_config: dict[str, Any]) -> Schedule:
"""Собрать расписание обслуживающих фаз из секции [schedule] конфига."""
schedule_raw = raw_config.get("schedule") or {}
if not isinstance(schedule_raw, dict):
raise ValueError("'schedule' must be a table in config.toml")
schedule = Schedule(
return Schedule(
cron={
phase: str(schedule_raw[phase])
for phase in SCHEDULED_PHASES
@@ -695,11 +898,14 @@ def initialize(
}
)
def build_maintenance(raw_config: dict[str, Any]) -> MaintenanceOptions:
"""Собрать параметры обслуживания из секции [maintenance] конфига."""
maintenance_raw = raw_config.get("maintenance") or {}
if not isinstance(maintenance_raw, dict):
raise ValueError("'maintenance' must be a table in config.toml")
defaults = MaintenanceOptions()
maintenance = MaintenanceOptions(
return MaintenanceOptions(
verify_subset=str(maintenance_raw.get("verify_subset", defaults.verify_subset)),
prune_max_unused=str(
maintenance_raw.get("prune_max_unused", defaults.prune_max_unused)
@@ -709,7 +915,31 @@ def initialize(
),
)
config = Config(host_name=host_name)
def initialize(
config_path: Path,
forced_phases: list[str] | None = None,
) -> tuple[ApplicationFinder, BackupManager]:
try:
with config_path.open("rb") as config_file:
raw_config = tomllib.load(config_file)
except OSError as e:
logger.error("Failed to read config file %s: %s", config_path, e)
raise
host_name = str(raw_config.get("host_name", "unknown"))
roots_raw = raw_config.get("roots") or []
if not isinstance(roots_raw, list) or not roots_raw:
raise ValueError("roots must be a non-empty list of paths in config.toml")
roots = [Path(root) for root in roots_raw]
storages = build_storages(raw_config)
notifiers = build_notifiers(raw_config)
schedule = build_schedule(raw_config)
maintenance = build_maintenance(raw_config)
config = Config(host_name=host_name, roots=roots)
app_finder = ApplicationFinder(roots)
backup_manager = BackupManager(
config=config,
@@ -752,7 +982,7 @@ def main() -> None:
logger.info("Backup process interrupted by user")
sys.exit(130)
except Exception as e:
logger.error(f"Unexpected error in backup process: {str(e)}")
logger.error("Unexpected error in backup process: %s", e)
sys.exit(1)
+7 -5
View File
@@ -1,7 +1,8 @@
#!/usr/bin/env python3
import os
import argparse
import os
from pathlib import Path
def main() -> None:
@@ -18,12 +19,13 @@ def main() -> None:
if args.keep < 0:
parser.error("--keep value cannot be negative")
if not os.path.isdir(args.directory):
directory = Path(args.directory)
if not directory.is_dir():
parser.error(f"Directory not found: {args.directory}")
# Get list of files (exclude subdirectories)
files = []
with os.scandir(args.directory) as entries:
with os.scandir(directory) as entries:
for entry in entries:
if entry.is_file():
files.append(entry.name)
@@ -36,9 +38,9 @@ def main() -> None:
# Delete files and print results
for filename in to_delete:
filepath = os.path.join(args.directory, filename)
filepath = directory / filename
try:
os.remove(filepath)
filepath.unlink()
print(f"Deleted: {filename}")
except Exception as e:
print(f"Error deleting {filename}: {str(e)}")
+7 -2
View File
@@ -14,13 +14,18 @@ pre-commit:
run: "uv run ruff format {staged_files}"
stage_fixed: true
- name: "fix python"
glob: "**/*.py"
run: "uv run ruff check --fix {staged_files}"
stage_fixed: true
- name: "check python"
glob: "**/*.py"
run: "uv run ruff check {staged_files}"
- name: "mypy"
- name: "pyrefly"
glob: "**/*.py"
run: "uv run mypy {staged_files}"
run: "uv run pyrefly check {staged_files}"
- name: "yamllint"
glob: "**/*.{yml,yaml}"
+41 -1
View File
@@ -9,10 +9,50 @@ dependencies = [
"ansible-lint>=25.12.2",
"croniter>=6.0.0",
"invoke>=2.2.1",
"mypy>=1.19.1",
"pyrefly>=1.2.0",
"requests>=2.32.5",
"ruff>=0.15.2",
"types-croniter>=6.0.0",
"types-requests>=2.32.4.20260107",
"yamllint>=1.37.1",
]
[tool.ruff.lint]
extend-select = [
# Правила, выпавшие из набора по умолчанию в ruff 0.16, но нужные проекту:
# E402 — импорты не в начале файла; A001/A002 — теневание встроенных имён
# аргументами invoke-задач.
"E402",
"A001",
"A002",
# Полная типизация: скрипты уезжают на сервер и правятся редко,
# аннотации — единственная страховка.
"ANN",
# pathlib вместо os.path, отсутствие закомментированного кода, стиль
# pytest и ограничение цикломатической сложности.
"PTH",
"ERA",
"PT",
"C90",
# Современный синтаксис: dict/list вместо typing.Dict/List, `X | None`
# вместо Optional. Те же места подсвечивает basedpyright в редакторе.
"UP",
# Наборы, по которым код уже чист: включены, чтобы так и оставалось.
"RET",
"N",
"Q",
"TID",
"G",
"LOG",
"FURB",
"PLC",
]
# Any в сигнатурах используется осознанно: разбор конфигов из TOML
# и параметры сторонних API без стабов типов.
ignore = ["ANN401"]
[tool.pyrefly]
# Без секции pyrefly ругается на отсутствие конфига; здесь же задаём,
# что проверять при запуске без аргументов.
project-includes = ["**/*.py"]
project-excludes = ["**/.venv/**", "**/galaxy.roles/**", "**/__pycache__/**"]
@@ -27,7 +27,6 @@ import hmac
import hashlib
import base64
import argparse
import sys
# These values are required to calculate the signature. Do not change them.
@@ -55,9 +54,6 @@ def calculate_key(secret_access_key: str) -> str:
def main() -> None:
if sys.version_info[0] < 3:
raise Exception("Must be using Python 3")
parser = argparse.ArgumentParser(
description="Convert a Secret Access Key to an SMTP password."
)
+21 -22
View File
@@ -5,6 +5,7 @@ import os
import re
import subprocess
import sys
from pathlib import Path
from invoke.context import Context
from invoke.exceptions import Exit
@@ -37,7 +38,7 @@ def _remote_host() -> str:
def _authelia_docker() -> str:
"""Команда запуска authelia CLI на том же образе, что и задеплоенный сервис"""
pattern = re.compile(r"""image:\s*["']?(\S*authelia/authelia:[^"'\s]+)""")
with open(AUTHELIA_COMPOSE_FILE, encoding="utf-8") as compose:
with Path(AUTHELIA_COMPOSE_FILE).open(encoding="utf-8") as compose:
for line in compose:
match = pattern.search(line)
if match:
@@ -56,7 +57,7 @@ def _rest_args() -> list[str]:
def _resolve_playbook(name: str) -> str:
candidates = [name, f"{name}.yml", f"playbook-{name}.yml"]
for candidate in candidates:
if os.path.isfile(candidate):
if Path(candidate).is_file():
return candidate
raise Exit(
f"Плейбук для '{name}' не найден. Проверял: {', '.join(candidates)}", code=1
@@ -141,16 +142,12 @@ def edit_encrypted(ctx: Context, path: str) -> None:
# Путь к канону — сосед по файловой системе (../ansible-roles/roles),
# поэтому не зависит от текущей директории и одинаков везде.
SHARED_ROLES_DIR = os.path.normpath(
os.path.join(
os.path.dirname(os.path.abspath(__file__)), "..", "ansible-roles", "roles"
)
)
SHARED_ROLES_DIR = Path(__file__).resolve().parent.parent / "ansible-roles" / "roles"
SYNCED_ROLES = ["eget", "app_image"]
def _require_canon() -> None:
if not os.path.isdir(SHARED_ROLES_DIR):
if not SHARED_ROLES_DIR.is_dir():
raise Exit(f"Канон не найден: {SHARED_ROLES_DIR}", code=1)
@@ -171,11 +168,13 @@ def roles_status(ctx: Context) -> None:
_require_canon()
drift = False
for role in SYNCED_ROLES:
canon = os.path.join(SHARED_ROLES_DIR, role)
if not os.path.isdir(canon):
canon = SHARED_ROLES_DIR / role
if not canon.is_dir():
print(f"{role}: missing in shared")
drift = True
elif subprocess.run(["diff", "-rq", f"roles/{role}", canon]).returncode != 0:
elif (
subprocess.run(["diff", "-rq", f"roles/{role}", str(canon)]).returncode != 0
):
print(f"{role}: differs")
drift = True
else:
@@ -189,10 +188,10 @@ def roles_pull(ctx: Context) -> None:
"""Канон → репозиторий: inv roles-pull [-- <role> ...]"""
_require_canon()
for role in _roles_to_sync():
src = os.path.join(SHARED_ROLES_DIR, role)
if not os.path.isdir(src):
src = SHARED_ROLES_DIR / role
if not src.is_dir():
raise Exit(f"Роль '{role}' отсутствует в каноне: {src}", code=1)
os.makedirs(f"roles/{role}", exist_ok=True)
Path("roles", role).mkdir(parents=True, exist_ok=True)
ctx.run(f"rsync -a -i --delete {src}/ roles/{role}/")
@@ -201,24 +200,24 @@ def roles_push(ctx: Context) -> None:
"""Репозиторий → канон, с подтверждением при расхождении: inv roles-push [-- <role> ...]"""
_require_canon()
for role in _roles_to_sync():
local = f"roles/{role}"
canon = os.path.join(SHARED_ROLES_DIR, role)
if not os.path.isdir(local):
local = Path("roles", role)
canon = SHARED_ROLES_DIR / role
if not local.is_dir():
raise Exit(f"Локальной роли нет: {local}", code=1)
differs = (
os.path.isdir(canon)
and subprocess.run(["diff", "-rq", local, canon]).returncode != 0
canon.is_dir()
and subprocess.run(["diff", "-rq", str(local), str(canon)]).returncode != 0
)
if differs:
# Защита от затирания более свежего канона устаревшей копией.
subprocess.run(["diff", "-r", canon, local])
subprocess.run(["diff", "-r", str(canon), str(local)])
answer = input(
f"Канон '{role}' отличается. Перезаписать его копией из этого репо? [y/N] "
)
if answer.strip().lower() != "y":
print(f"{role}: пропущено")
continue
os.makedirs(canon, exist_ok=True)
canon.mkdir(parents=True, exist_ok=True)
ctx.run(f"rsync -a -i --delete {local}/ {canon}/")
@@ -267,7 +266,7 @@ def authelia_validate_config(ctx: Context) -> None:
"""Отрендерить конфиг authelia из шаблона и проверить его"""
dest = "temp/configuration.yml"
# temp/ в .gitignore, на свежем клоне его нет — ansible сам директорию не создаёт.
os.makedirs(os.path.dirname(dest), exist_ok=True)
Path(dest).parent.mkdir(parents=True, exist_ok=True)
try:
ctx.run(
"uv run ansible localhost"
Generated
+21 -95
View File
@@ -415,66 +415,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" },
]
[[package]]
name = "librt"
version = "0.8.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/56/9c/b4b0c54d84da4a94b37bd44151e46d5e583c9534c7e02250b961b1b6d8a8/librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73", size = 177471, upload-time = "2026-02-17T16:13:06.101Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/95/21/d39b0a87ac52fc98f621fb6f8060efb017a767ebbbac2f99fbcbc9ddc0d7/librt-0.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a28f2612ab566b17f3698b0da021ff9960610301607c9a5e8eaca62f5e1c350a", size = 66516, upload-time = "2026-02-17T16:11:41.604Z" },
{ url = "https://files.pythonhosted.org/packages/69/f1/46375e71441c43e8ae335905e069f1c54febee63a146278bcee8782c84fd/librt-0.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:60a78b694c9aee2a0f1aaeaa7d101cf713e92e8423a941d2897f4fa37908dab9", size = 68634, upload-time = "2026-02-17T16:11:43.268Z" },
{ url = "https://files.pythonhosted.org/packages/0a/33/c510de7f93bf1fa19e13423a606d8189a02624a800710f6e6a0a0f0784b3/librt-0.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:758509ea3f1eba2a57558e7e98f4659d0ea7670bff49673b0dde18a3c7e6c0eb", size = 198941, upload-time = "2026-02-17T16:11:44.28Z" },
{ url = "https://files.pythonhosted.org/packages/dd/36/e725903416409a533d92398e88ce665476f275081d0d7d42f9c4951999e5/librt-0.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:039b9f2c506bd0ab0f8725aa5ba339c6f0cd19d3b514b50d134789809c24285d", size = 209991, upload-time = "2026-02-17T16:11:45.462Z" },
{ url = "https://files.pythonhosted.org/packages/30/7a/8d908a152e1875c9f8eac96c97a480df425e657cdb47854b9efaa4998889/librt-0.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bb54f1205a3a6ab41a6fd71dfcdcbd278670d3a90ca502a30d9da583105b6f7", size = 224476, upload-time = "2026-02-17T16:11:46.542Z" },
{ url = "https://files.pythonhosted.org/packages/a8/b8/a22c34f2c485b8903a06f3fe3315341fe6876ef3599792344669db98fcff/librt-0.8.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:05bd41cdee35b0c59c259f870f6da532a2c5ca57db95b5f23689fcb5c9e42440", size = 217518, upload-time = "2026-02-17T16:11:47.746Z" },
{ url = "https://files.pythonhosted.org/packages/79/6f/5c6fea00357e4f82ba44f81dbfb027921f1ab10e320d4a64e1c408d035d9/librt-0.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adfab487facf03f0d0857b8710cf82d0704a309d8ffc33b03d9302b4c64e91a9", size = 225116, upload-time = "2026-02-17T16:11:49.298Z" },
{ url = "https://files.pythonhosted.org/packages/f2/a0/95ced4e7b1267fe1e2720a111685bcddf0e781f7e9e0ce59d751c44dcfe5/librt-0.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:153188fe98a72f206042be10a2c6026139852805215ed9539186312d50a8e972", size = 217751, upload-time = "2026-02-17T16:11:50.49Z" },
{ url = "https://files.pythonhosted.org/packages/93/c2/0517281cb4d4101c27ab59472924e67f55e375bc46bedae94ac6dc6e1902/librt-0.8.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dd3c41254ee98604b08bd5b3af5bf0a89740d4ee0711de95b65166bf44091921", size = 218378, upload-time = "2026-02-17T16:11:51.783Z" },
{ url = "https://files.pythonhosted.org/packages/43/e8/37b3ac108e8976888e559a7b227d0ceac03c384cfd3e7a1c2ee248dbae79/librt-0.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e0d138c7ae532908cbb342162b2611dbd4d90c941cd25ab82084aaf71d2c0bd0", size = 241199, upload-time = "2026-02-17T16:11:53.561Z" },
{ url = "https://files.pythonhosted.org/packages/4b/5b/35812d041c53967fedf551a39399271bbe4257e681236a2cf1a69c8e7fa1/librt-0.8.1-cp312-cp312-win32.whl", hash = "sha256:43353b943613c5d9c49a25aaffdba46f888ec354e71e3529a00cca3f04d66a7a", size = 54917, upload-time = "2026-02-17T16:11:54.758Z" },
{ url = "https://files.pythonhosted.org/packages/de/d1/fa5d5331b862b9775aaf2a100f5ef86854e5d4407f71bddf102f4421e034/librt-0.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:ff8baf1f8d3f4b6b7257fcb75a501f2a5499d0dda57645baa09d4d0d34b19444", size = 62017, upload-time = "2026-02-17T16:11:55.748Z" },
{ url = "https://files.pythonhosted.org/packages/c7/7c/c614252f9acda59b01a66e2ddfd243ed1c7e1deab0293332dfbccf862808/librt-0.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f2ae3725904f7377e11cc37722d5d401e8b3d5851fb9273d7f4fe04f6b3d37d", size = 52441, upload-time = "2026-02-17T16:11:56.801Z" },
{ url = "https://files.pythonhosted.org/packages/c5/3c/f614c8e4eaac7cbf2bbdf9528790b21d89e277ee20d57dc6e559c626105f/librt-0.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e6bad1cd94f6764e1e21950542f818a09316645337fd5ab9a7acc45d99a8f35", size = 66529, upload-time = "2026-02-17T16:11:57.809Z" },
{ url = "https://files.pythonhosted.org/packages/ab/96/5836544a45100ae411eda07d29e3d99448e5258b6e9c8059deb92945f5c2/librt-0.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf450f498c30af55551ba4f66b9123b7185362ec8b625a773b3d39aa1a717583", size = 68669, upload-time = "2026-02-17T16:11:58.843Z" },
{ url = "https://files.pythonhosted.org/packages/06/53/f0b992b57af6d5531bf4677d75c44f095f2366a1741fb695ee462ae04b05/librt-0.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eca45e982fa074090057132e30585a7e8674e9e885d402eae85633e9f449ce6c", size = 199279, upload-time = "2026-02-17T16:11:59.862Z" },
{ url = "https://files.pythonhosted.org/packages/f3/ad/4848cc16e268d14280d8168aee4f31cea92bbd2b79ce33d3e166f2b4e4fc/librt-0.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c3811485fccfda840861905b8c70bba5ec094e02825598bb9d4ca3936857a04", size = 210288, upload-time = "2026-02-17T16:12:00.954Z" },
{ url = "https://files.pythonhosted.org/packages/52/05/27fdc2e95de26273d83b96742d8d3b7345f2ea2bdbd2405cc504644f2096/librt-0.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4af413908f77294605e28cfd98063f54b2c790561383971d2f52d113d9c363", size = 224809, upload-time = "2026-02-17T16:12:02.108Z" },
{ url = "https://files.pythonhosted.org/packages/7a/d0/78200a45ba3240cb042bc597d6f2accba9193a2c57d0356268cbbe2d0925/librt-0.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5212a5bd7fae98dae95710032902edcd2ec4dc994e883294f75c857b83f9aba0", size = 218075, upload-time = "2026-02-17T16:12:03.631Z" },
{ url = "https://files.pythonhosted.org/packages/af/72/a210839fa74c90474897124c064ffca07f8d4b347b6574d309686aae7ca6/librt-0.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e692aa2d1d604e6ca12d35e51fdc36f4cda6345e28e36374579f7ef3611b3012", size = 225486, upload-time = "2026-02-17T16:12:04.725Z" },
{ url = "https://files.pythonhosted.org/packages/a3/c1/a03cc63722339ddbf087485f253493e2b013039f5b707e8e6016141130fa/librt-0.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4be2a5c926b9770c9e08e717f05737a269b9d0ebc5d2f0060f0fe3fe9ce47acb", size = 218219, upload-time = "2026-02-17T16:12:05.828Z" },
{ url = "https://files.pythonhosted.org/packages/58/f5/fff6108af0acf941c6f274a946aea0e484bd10cd2dc37610287ce49388c5/librt-0.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fd1a720332ea335ceb544cf0a03f81df92abd4bb887679fd1e460976b0e6214b", size = 218750, upload-time = "2026-02-17T16:12:07.09Z" },
{ url = "https://files.pythonhosted.org/packages/71/67/5a387bfef30ec1e4b4f30562c8586566faf87e47d696768c19feb49e3646/librt-0.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2af9e01e0ef80d95ae3c720be101227edae5f2fe7e3dc63d8857fadfc5a1d", size = 241624, upload-time = "2026-02-17T16:12:08.43Z" },
{ url = "https://files.pythonhosted.org/packages/d4/be/24f8502db11d405232ac1162eb98069ca49c3306c1d75c6ccc61d9af8789/librt-0.8.1-cp313-cp313-win32.whl", hash = "sha256:086a32dbb71336627e78cc1d6ee305a68d038ef7d4c39aaff41ae8c9aa46e91a", size = 54969, upload-time = "2026-02-17T16:12:09.633Z" },
{ url = "https://files.pythonhosted.org/packages/5c/73/c9fdf6cb2a529c1a092ce769a12d88c8cca991194dfe641b6af12fa964d2/librt-0.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:e11769a1dbda4da7b00a76cfffa67aa47cfa66921d2724539eee4b9ede780b79", size = 62000, upload-time = "2026-02-17T16:12:10.632Z" },
{ url = "https://files.pythonhosted.org/packages/d3/97/68f80ca3ac4924f250cdfa6e20142a803e5e50fca96ef5148c52ee8c10ea/librt-0.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:924817ab3141aca17893386ee13261f1d100d1ef410d70afe4389f2359fea4f0", size = 52495, upload-time = "2026-02-17T16:12:11.633Z" },
{ url = "https://files.pythonhosted.org/packages/c9/6a/907ef6800f7bca71b525a05f1839b21f708c09043b1c6aa77b6b827b3996/librt-0.8.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6cfa7fe54fd4d1f47130017351a959fe5804bda7a0bc7e07a2cdbc3fdd28d34f", size = 66081, upload-time = "2026-02-17T16:12:12.766Z" },
{ url = "https://files.pythonhosted.org/packages/1b/18/25e991cd5640c9fb0f8d91b18797b29066b792f17bf8493da183bf5caabe/librt-0.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:228c2409c079f8c11fb2e5d7b277077f694cb93443eb760e00b3b83cb8b3176c", size = 68309, upload-time = "2026-02-17T16:12:13.756Z" },
{ url = "https://files.pythonhosted.org/packages/a4/36/46820d03f058cfb5a9de5940640ba03165ed8aded69e0733c417bb04df34/librt-0.8.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7aae78ab5e3206181780e56912d1b9bb9f90a7249ce12f0e8bf531d0462dd0fc", size = 196804, upload-time = "2026-02-17T16:12:14.818Z" },
{ url = "https://files.pythonhosted.org/packages/59/18/5dd0d3b87b8ff9c061849fbdb347758d1f724b9a82241aa908e0ec54ccd0/librt-0.8.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:172d57ec04346b047ca6af181e1ea4858086c80bdf455f61994c4aa6fc3f866c", size = 206907, upload-time = "2026-02-17T16:12:16.513Z" },
{ url = "https://files.pythonhosted.org/packages/d1/96/ef04902aad1424fd7299b62d1890e803e6ab4018c3044dca5922319c4b97/librt-0.8.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b1977c4ea97ce5eb7755a78fae68d87e4102e4aaf54985e8b56806849cc06a3", size = 221217, upload-time = "2026-02-17T16:12:17.906Z" },
{ url = "https://files.pythonhosted.org/packages/6d/ff/7e01f2dda84a8f5d280637a2e5827210a8acca9a567a54507ef1c75b342d/librt-0.8.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10c42e1f6fd06733ef65ae7bebce2872bcafd8d6e6b0a08fe0a05a23b044fb14", size = 214622, upload-time = "2026-02-17T16:12:19.108Z" },
{ url = "https://files.pythonhosted.org/packages/1e/8c/5b093d08a13946034fed57619742f790faf77058558b14ca36a6e331161e/librt-0.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4c8dfa264b9193c4ee19113c985c95f876fae5e51f731494fc4e0cf594990ba7", size = 221987, upload-time = "2026-02-17T16:12:20.331Z" },
{ url = "https://files.pythonhosted.org/packages/d3/cc/86b0b3b151d40920ad45a94ce0171dec1aebba8a9d72bb3fa00c73ab25dd/librt-0.8.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:01170b6729a438f0dedc4a26ed342e3dc4f02d1000b4b19f980e1877f0c297e6", size = 215132, upload-time = "2026-02-17T16:12:21.54Z" },
{ url = "https://files.pythonhosted.org/packages/fc/be/8588164a46edf1e69858d952654e216a9a91174688eeefb9efbb38a9c799/librt-0.8.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7b02679a0d783bdae30d443025b94465d8c3dc512f32f5b5031f93f57ac32071", size = 215195, upload-time = "2026-02-17T16:12:23.073Z" },
{ url = "https://files.pythonhosted.org/packages/f5/f2/0b9279bea735c734d69344ecfe056c1ba211694a72df10f568745c899c76/librt-0.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:190b109bb69592a3401fe1ffdea41a2e73370ace2ffdc4a0e8e2b39cdea81b78", size = 237946, upload-time = "2026-02-17T16:12:24.275Z" },
{ url = "https://files.pythonhosted.org/packages/e9/cc/5f2a34fbc8aeb35314a3641f9956fa9051a947424652fad9882be7a97949/librt-0.8.1-cp314-cp314-win32.whl", hash = "sha256:e70a57ecf89a0f64c24e37f38d3fe217a58169d2fe6ed6d70554964042474023", size = 50689, upload-time = "2026-02-17T16:12:25.766Z" },
{ url = "https://files.pythonhosted.org/packages/a0/76/cd4d010ab2147339ca2b93e959c3686e964edc6de66ddacc935c325883d7/librt-0.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:7e2f3edca35664499fbb36e4770650c4bd4a08abc1f4458eab9df4ec56389730", size = 57875, upload-time = "2026-02-17T16:12:27.465Z" },
{ url = "https://files.pythonhosted.org/packages/84/0f/2143cb3c3ca48bd3379dcd11817163ca50781927c4537345d608b5045998/librt-0.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:0d2f82168e55ddefd27c01c654ce52379c0750ddc31ee86b4b266bcf4d65f2a3", size = 48058, upload-time = "2026-02-17T16:12:28.556Z" },
{ url = "https://files.pythonhosted.org/packages/d2/0e/9b23a87e37baf00311c3efe6b48d6b6c168c29902dfc3f04c338372fd7db/librt-0.8.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c74a2da57a094bd48d03fa5d196da83d2815678385d2978657499063709abe1", size = 68313, upload-time = "2026-02-17T16:12:29.659Z" },
{ url = "https://files.pythonhosted.org/packages/db/9a/859c41e5a4f1c84200a7d2b92f586aa27133c8243b6cac9926f6e54d01b9/librt-0.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a355d99c4c0d8e5b770313b8b247411ed40949ca44e33e46a4789b9293a907ee", size = 70994, upload-time = "2026-02-17T16:12:31.516Z" },
{ url = "https://files.pythonhosted.org/packages/4c/28/10605366ee599ed34223ac2bf66404c6fb59399f47108215d16d5ad751a8/librt-0.8.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2eb345e8b33fb748227409c9f1233d4df354d6e54091f0e8fc53acdb2ffedeb7", size = 220770, upload-time = "2026-02-17T16:12:33.294Z" },
{ url = "https://files.pythonhosted.org/packages/af/8d/16ed8fd452dafae9c48d17a6bc1ee3e818fd40ef718d149a8eff2c9f4ea2/librt-0.8.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9be2f15e53ce4e83cc08adc29b26fb5978db62ef2a366fbdf716c8a6c8901040", size = 235409, upload-time = "2026-02-17T16:12:35.443Z" },
{ url = "https://files.pythonhosted.org/packages/89/1b/7bdf3e49349c134b25db816e4a3db6b94a47ac69d7d46b1e682c2c4949be/librt-0.8.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:785ae29c1f5c6e7c2cde2c7c0e148147f4503da3abc5d44d482068da5322fd9e", size = 246473, upload-time = "2026-02-17T16:12:36.656Z" },
{ url = "https://files.pythonhosted.org/packages/4e/8a/91fab8e4fd2a24930a17188c7af5380eb27b203d72101c9cc000dbdfd95a/librt-0.8.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d3a7da44baf692f0c6aeb5b2a09c5e6fc7a703bca9ffa337ddd2e2da53f7732", size = 238866, upload-time = "2026-02-17T16:12:37.849Z" },
{ url = "https://files.pythonhosted.org/packages/b9/e0/c45a098843fc7c07e18a7f8a24ca8496aecbf7bdcd54980c6ca1aaa79a8e/librt-0.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5fc48998000cbc39ec0d5311312dda93ecf92b39aaf184c5e817d5d440b29624", size = 250248, upload-time = "2026-02-17T16:12:39.445Z" },
{ url = "https://files.pythonhosted.org/packages/82/30/07627de23036640c952cce0c1fe78972e77d7d2f8fd54fa5ef4554ff4a56/librt-0.8.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e96baa6820280077a78244b2e06e416480ed859bbd8e5d641cf5742919d8beb4", size = 240629, upload-time = "2026-02-17T16:12:40.889Z" },
{ url = "https://files.pythonhosted.org/packages/fb/c1/55bfe1ee3542eba055616f9098eaf6eddb966efb0ca0f44eaa4aba327307/librt-0.8.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31362dbfe297b23590530007062c32c6f6176f6099646bb2c95ab1b00a57c382", size = 239615, upload-time = "2026-02-17T16:12:42.446Z" },
{ url = "https://files.pythonhosted.org/packages/2b/39/191d3d28abc26c9099b19852e6c99f7f6d400b82fa5a4e80291bd3803e19/librt-0.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc3656283d11540ab0ea01978378e73e10002145117055e03722417aeab30994", size = 263001, upload-time = "2026-02-17T16:12:43.627Z" },
{ url = "https://files.pythonhosted.org/packages/b9/eb/7697f60fbe7042ab4e88f4ee6af496b7f222fffb0a4e3593ef1f29f81652/librt-0.8.1-cp314-cp314t-win32.whl", hash = "sha256:738f08021b3142c2918c03692608baed43bc51144c29e35807682f8070ee2a3a", size = 51328, upload-time = "2026-02-17T16:12:45.148Z" },
{ url = "https://files.pythonhosted.org/packages/7c/72/34bf2eb7a15414a23e5e70ecb9440c1d3179f393d9349338a91e2781c0fb/librt-0.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:89815a22daf9c51884fb5dbe4f1ef65ee6a146e0b6a8df05f753e2e4a9359bf4", size = 58722, upload-time = "2026-02-17T16:12:46.85Z" },
{ url = "https://files.pythonhosted.org/packages/b2/c8/d148e041732d631fc76036f8b30fae4e77b027a1e95b7a84bb522481a940/librt-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:bf512a71a23504ed08103a13c941f763db13fb11177beb3d9244c98c29fb4a61", size = 48755, upload-time = "2026-02-17T16:12:47.943Z" },
]
[[package]]
name = "markupsafe"
version = "3.0.3"
@@ -538,39 +478,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
]
[[package]]
name = "mypy"
version = "1.19.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "librt", marker = "platform_python_implementation != 'PyPy'" },
{ name = "mypy-extensions" },
{ name = "pathspec" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" },
{ url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" },
{ url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" },
{ url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" },
{ url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" },
{ url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z" },
{ url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" },
{ url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" },
{ url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" },
{ url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" },
{ url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" },
{ url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" },
{ url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" },
{ url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" },
{ url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" },
{ url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" },
{ url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" },
{ url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872, upload-time = "2025-12-15T05:03:05.549Z" },
{ url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" },
]
[[package]]
name = "mypy-extensions"
version = "1.1.0"
@@ -607,7 +514,7 @@ dependencies = [
{ name = "ansible-lint" },
{ name = "croniter" },
{ name = "invoke" },
{ name = "mypy" },
{ name = "pyrefly" },
{ name = "requests" },
{ name = "ruff" },
{ name = "types-croniter" },
@@ -621,7 +528,7 @@ requires-dist = [
{ name = "ansible-lint", specifier = ">=25.12.2" },
{ name = "croniter", specifier = ">=6.0.0" },
{ name = "invoke", specifier = ">=2.2.1" },
{ name = "mypy", specifier = ">=1.19.1" },
{ name = "pyrefly", specifier = ">=1.2.0" },
{ name = "requests", specifier = ">=2.32.5" },
{ name = "ruff", specifier = ">=0.15.2" },
{ name = "types-croniter", specifier = ">=6.0.0" },
@@ -647,6 +554,25 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" },
]
[[package]]
name = "pyrefly"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/89/01/a86e9f24722b095c3f88e3616132b75a21b0df53804bdc6a45314dd4d93c/pyrefly-1.2.0.tar.gz", hash = "sha256:5485f960fc2481617068c918335c39ab1507ef90b6b5bd35bf57726e60e73185", size = 6243654, upload-time = "2026-08-01T02:56:27.592Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7d/9d/3c0ef1d4843987b22f996ed381ec9cf5a3b1273e29804db276252e4c95eb/pyrefly-1.2.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7f46d983ac49ddd2b043694960a01dc6a19a5cfd8eec609d6bd9c42866f91b4e", size = 14026305, upload-time = "2026-08-01T02:56:02.611Z" },
{ url = "https://files.pythonhosted.org/packages/0a/06/03bbb78fbea54cdc65b626619f3597d5611aca4fdef11e72a4e8360e7e63/pyrefly-1.2.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:756f669b5555090f5c1a4fef30db1785fabe657764f7e4e6dc88994dfb8ca82d", size = 13463880, upload-time = "2026-08-01T02:56:04.93Z" },
{ url = "https://files.pythonhosted.org/packages/13/5a/7d8bc00a38e93bbc9c3e7bd14d305f7948717e667c9bcddeab9dd42fd255/pyrefly-1.2.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e3465812ce5ef4781fb592edbf2724547296f0a3124be115d73c7e8b2401862d", size = 13907329, upload-time = "2026-08-01T02:56:07.104Z" },
{ url = "https://files.pythonhosted.org/packages/be/94/9e08b4bf799d0b8f36b55a2783c7ba5f51730cf0632a85a67b5b5ed876cd/pyrefly-1.2.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5de7b2ad2bba5c8055181681a84b74143eac2234a48ba5d1b7ed7e7a722b02bd", size = 15039020, upload-time = "2026-08-01T02:56:09.208Z" },
{ url = "https://files.pythonhosted.org/packages/5b/bd/bca5fd0c80f4daf8ee6903a29df9f3de1feb05ff0946b8f35ec8c5096b13/pyrefly-1.2.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:25822ea9505f589ea8a725e4268b475132fb89e038fbf092e446510443ac142a", size = 14986199, upload-time = "2026-08-01T02:56:11.924Z" },
{ url = "https://files.pythonhosted.org/packages/97/f7/f07087f3d185ad2eced0c56cef89ca5474dfb4ff25f146cd50a861c97553/pyrefly-1.2.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90efe75e17491ef5d636e10469e9278d7d0256b3b4c5e1f4750069bf3ae0f5d1", size = 14393715, upload-time = "2026-08-01T02:56:14.143Z" },
{ url = "https://files.pythonhosted.org/packages/d3/70/0d142c320e284b9e3ce35e9b1e58b8ce2ee1f578f2a7234bc30e5022b94f/pyrefly-1.2.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:368aaf7eee4f511ddc0f8e564cf14e01ab2f10b0db9105c6d5b153bf498d07bf", size = 13933008, upload-time = "2026-08-01T02:56:16.525Z" },
{ url = "https://files.pythonhosted.org/packages/5d/e8/e84f11b6e1f63fd453ad3654213b9a0f6f4de8cef6b58038eef2d0d5955d/pyrefly-1.2.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:d52d5da7bc65fb7675fbaa80eda879d4f8787c494f04cac21603330d3abbdbbe", size = 14431827, upload-time = "2026-08-01T02:56:18.645Z" },
{ url = "https://files.pythonhosted.org/packages/0f/06/810d31380f66c75e1c0779a408d3b16117b1b368b57894f6aa66bef21686/pyrefly-1.2.0-py3-none-win32.whl", hash = "sha256:8c90751de8506d938e8f802659c74cf35bd7a0036510ee6c634a38eebb280bfa", size = 13229447, upload-time = "2026-08-01T02:56:20.921Z" },
{ url = "https://files.pythonhosted.org/packages/ed/98/4dafa3c7a1caed2dc8cc708dde09ba27963c7736508f55b626fff3024113/pyrefly-1.2.0-py3-none-win_amd64.whl", hash = "sha256:8a8964c224ccc4882730130955815de21ff443c1ac3f0b90685b19bf63848170", size = 14087387, upload-time = "2026-08-01T02:56:23.188Z" },
{ url = "https://files.pythonhosted.org/packages/1b/1c/df3cb0a2e5591660ded7a1836cd2f29dc48c91adb1c0a3a700a96f6d09e1/pyrefly-1.2.0-py3-none-win_arm64.whl", hash = "sha256:3a90bb8df39dfbac74b1f3b2e9d7c526b8f80568884c3944d955023a73ebf61e", size = 13430873, upload-time = "2026-08-01T02:56:25.425Z" },
]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"