Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
daa4379dbe
|
||
|
|
b5b43a484e
|
||
|
|
bdc319df64
|
||
|
|
321f6e7b6b
|
@@ -15,7 +15,7 @@ Ansible-проект для автоматизации личного серве
|
|||||||
- `templates/` — общие шаблоны (например `env.template`).
|
- `templates/` — общие шаблоны (например `env.template`).
|
||||||
- `scripts/` — вспомогательные Python-скрипты (SMTP-утилиты для Yandex Cloud Postbox).
|
- `scripts/` — вспомогательные Python-скрипты (SMTP-утилиты для Yandex Cloud Postbox).
|
||||||
- `.gitea/workflows/lint.yml` — CI: yamllint + ansible-lint.
|
- `.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>`).
|
- `tasks.py` — задачи через invoke (`inv <task>`).
|
||||||
- `pyproject.toml` — зависимости Python, управляются через `uv`.
|
- `pyproject.toml` — зависимости Python, управляются через `uv`.
|
||||||
|
|
||||||
@@ -109,10 +109,13 @@ uv run ansible-galaxy install --role-file requirements.yml
|
|||||||
## Линтинг и CI
|
## Линтинг и CI
|
||||||
|
|
||||||
- CI (`.gitea/workflows/lint.yml`): два параллельных job — yamllint и ansible-lint.
|
- 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:
|
- Pre-commit хуки через lefthook:
|
||||||
- `ruff format` + `ruff check` — форматирование и линтинг Python.
|
- `ruff format` + `ruff check --fix` + `ruff check` — форматирование и линтинг Python.
|
||||||
- `mypy` — проверка типов Python.
|
- `pyrefly` — проверка типов Python (заменил mypy).
|
||||||
- `yamllint` — линтинг YAML.
|
- `yamllint` — линтинг YAML.
|
||||||
- `ansible-lint` — линтинг Ansible (профиль production).
|
- `ansible-lint` — линтинг Ansible (профиль production).
|
||||||
- `gitleaks` — поиск секретов в staged-файлах.
|
- `gitleaks` — поиск секретов в staged-файлах.
|
||||||
@@ -149,3 +152,7 @@ ansible-playbook -i production.yml --diff playbook-gitea.yml
|
|||||||
- Шаблоны скриптов бэкапов в `files/<app>/` (backup.template.sh, gobackup.template.yml и др.).
|
- Шаблоны скриптов бэкапов в `files/<app>/` (backup.template.sh, gobackup.template.yml и др.).
|
||||||
- `files/backups/backup-all.py` — оркестратор, запускает все бэкапы через restic.
|
- `files/backups/backup-all.py` — оркестратор, запускает все бэкапы через restic.
|
||||||
- Cron-расписание настраивается в `playbook-backups.yml`.
|
- Cron-расписание настраивается в `playbook-backups.yml`.
|
||||||
|
- Уведомление включает список всех найденных приложений со значком статуса (✅ забекаплено,
|
||||||
|
❌ упал скрипт дампа, ⏭ бекапить нечего) и занятым местом, а в конце — свободное место
|
||||||
|
на дисках. Размеры считает `dust` (ставится ролью eget); если его нет, прогон продолжается
|
||||||
|
без размеров.
|
||||||
|
|||||||
+366
-136
@@ -11,13 +11,18 @@ restic-операции разнесены на фазы с разной час
|
|||||||
- verify -- check --read-data-subset, помесячно (полное покрытие за год).
|
- verify -- check --read-data-subset, помесячно (полное покрытие за год).
|
||||||
Один прогон выполняет фазы строго последовательно, поэтому restic-локи между фазами
|
Один прогон выполняет фазы строго последовательно, поэтому restic-локи между фазами
|
||||||
не конфликтуют. Наложение соседних прогонов предотвращается flock в cron-задаче.
|
не конфликтуют. Наложение соседних прогонов предотвращается flock в cron-задаче.
|
||||||
|
|
||||||
|
Размеры приложений считает dust (ставится ролью eget в bin_prefix); если его нет
|
||||||
|
или он упал, прогон продолжается, а размеры в уведомлении просто не показываются.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import itertools
|
import itertools
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import pwd
|
import pwd
|
||||||
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
@@ -25,8 +30,9 @@ import tomllib
|
|||||||
from abc import ABC
|
from abc import ABC
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
from enum import Enum
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from croniter import croniter
|
from croniter import croniter
|
||||||
@@ -41,6 +47,10 @@ BACKUP_TARGETS_FILE = "backup-targets"
|
|||||||
# Used when backup-targets file not exists
|
# Used when backup-targets file not exists
|
||||||
BACKUP_DEFAULT_DIR = "backups"
|
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.
|
# Retention policy applied by the `forget` phase on every run.
|
||||||
KEEP_DAILY = "90"
|
KEEP_DAILY = "90"
|
||||||
KEEP_MONTHLY = "36"
|
KEEP_MONTHLY = "36"
|
||||||
@@ -72,6 +82,7 @@ logger = logging.getLogger(__name__)
|
|||||||
@dataclass
|
@dataclass
|
||||||
class Config:
|
class Config:
|
||||||
host_name: str
|
host_name: str
|
||||||
|
roots: list[Path]
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -87,9 +98,9 @@ class MaintenanceOptions:
|
|||||||
class Schedule:
|
class Schedule:
|
||||||
"""Расписание обслуживающих фаз: фаза -> cron-выражение."""
|
"""Расписание обслуживающих фаз: фаза -> 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."""
|
"""Фазы, которые нужно выполнить в этот прогон, в порядке PHASE_ORDER."""
|
||||||
phases = list(ALWAYS_PHASES)
|
phases = list(ALWAYS_PHASES)
|
||||||
for phase in SCHEDULED_PHASES:
|
for phase in SCHEDULED_PHASES:
|
||||||
@@ -114,14 +125,55 @@ class Schedule:
|
|||||||
class Application:
|
class Application:
|
||||||
path: Path
|
path: Path
|
||||||
owner: str
|
owner: str
|
||||||
backup_script: Optional[Path]
|
backup_script: Path | None
|
||||||
backup_targets: List[Path]
|
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
|
@dataclass
|
||||||
class BackupResult:
|
class BackupResult:
|
||||||
success: bool
|
success: bool
|
||||||
error: Optional[str] = None
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -129,7 +181,80 @@ class StorageRunResult:
|
|||||||
name: str
|
name: str
|
||||||
success: bool
|
success: bool
|
||||||
duration: float
|
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:
|
def format_duration(seconds: float) -> str:
|
||||||
@@ -149,8 +274,8 @@ class Storage(ABC):
|
|||||||
|
|
||||||
def run(
|
def run(
|
||||||
self,
|
self,
|
||||||
backup_dirs: List[str],
|
backup_dirs: list[str],
|
||||||
phases: List[str],
|
phases: list[str],
|
||||||
maintenance: MaintenanceOptions,
|
maintenance: MaintenanceOptions,
|
||||||
) -> BackupResult:
|
) -> BackupResult:
|
||||||
"""Run the requested phases against this storage."""
|
"""Run the requested phases against this storage."""
|
||||||
@@ -160,7 +285,7 @@ class Storage(ABC):
|
|||||||
class ResticStorage(Storage):
|
class ResticStorage(Storage):
|
||||||
TYPE_NAME = "restic"
|
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.name = name
|
||||||
self.restic_repository = str(params.get("restic_repository", ""))
|
self.restic_repository = str(params.get("restic_repository", ""))
|
||||||
self.restic_password = str(params.get("restic_password", ""))
|
self.restic_password = str(params.get("restic_password", ""))
|
||||||
@@ -170,7 +295,7 @@ class ResticStorage(Storage):
|
|||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"'env' must be a table for storage backend ResticStorage: '{self.name}'"
|
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:
|
if not self.restic_repository or not self.restic_password:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
@@ -179,8 +304,8 @@ class ResticStorage(Storage):
|
|||||||
|
|
||||||
def run(
|
def run(
|
||||||
self,
|
self,
|
||||||
backup_dirs: List[str],
|
backup_dirs: list[str],
|
||||||
phases: List[str],
|
phases: list[str],
|
||||||
maintenance: MaintenanceOptions,
|
maintenance: MaintenanceOptions,
|
||||||
) -> BackupResult:
|
) -> BackupResult:
|
||||||
try:
|
try:
|
||||||
@@ -191,12 +316,12 @@ class ResticStorage(Storage):
|
|||||||
|
|
||||||
def __build_steps(
|
def __build_steps(
|
||||||
self,
|
self,
|
||||||
backup_dirs: List[str],
|
backup_dirs: list[str],
|
||||||
phases: List[str],
|
phases: list[str],
|
||||||
maintenance: MaintenanceOptions,
|
maintenance: MaintenanceOptions,
|
||||||
) -> List[tuple[str, List[str]]]:
|
) -> list[tuple[str, list[str]]]:
|
||||||
"""Собрать restic-команды для запрошенных фаз в порядке PHASE_ORDER."""
|
"""Собрать restic-команды для запрошенных фаз в порядке PHASE_ORDER."""
|
||||||
steps: List[tuple[str, List[str]]] = []
|
steps: list[tuple[str, list[str]]] = []
|
||||||
|
|
||||||
for phase in PHASE_ORDER:
|
for phase in PHASE_ORDER:
|
||||||
if phase not in phases:
|
if phase not in phases:
|
||||||
@@ -261,8 +386,8 @@ class ResticStorage(Storage):
|
|||||||
|
|
||||||
def __run_internal(
|
def __run_internal(
|
||||||
self,
|
self,
|
||||||
backup_dirs: List[str],
|
backup_dirs: list[str],
|
||||||
phases: List[str],
|
phases: list[str],
|
||||||
maintenance: MaintenanceOptions,
|
maintenance: MaintenanceOptions,
|
||||||
) -> BackupResult:
|
) -> BackupResult:
|
||||||
logger.info("Starting restic run for storage '%s'", self.name)
|
logger.info("Starting restic run for storage '%s'", self.name)
|
||||||
@@ -283,9 +408,7 @@ class ResticStorage(Storage):
|
|||||||
|
|
||||||
return BackupResult(success=True)
|
return BackupResult(success=True)
|
||||||
|
|
||||||
def __run_step(
|
def __run_step(self, step: str, cmd: list[str], env: dict[str, str]) -> str | None:
|
||||||
self, step: str, cmd: List[str], env: Dict[str, str]
|
|
||||||
) -> Optional[str]:
|
|
||||||
"""Run a single restic command. Return None on success or error text."""
|
"""Run a single restic command. Return None on success or error text."""
|
||||||
result = subprocess.run(cmd, env=env, capture_output=True, text=True)
|
result = subprocess.run(cmd, env=env, capture_output=True, text=True)
|
||||||
|
|
||||||
@@ -306,7 +429,7 @@ class Notifier(ABC):
|
|||||||
class AppriseNotifier(Notifier):
|
class AppriseNotifier(Notifier):
|
||||||
TYPE_NAME = "apprise"
|
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.name = name
|
||||||
self.api_url = str(params.get("api_url", "")).rstrip("/")
|
self.api_url = str(params.get("api_url", "")).rstrip("/")
|
||||||
self.tag = str(params.get("tag", ""))
|
self.tag = str(params.get("tag", ""))
|
||||||
@@ -329,18 +452,20 @@ class AppriseNotifier(Notifier):
|
|||||||
logger.info("Apprise notification sent successfully")
|
logger.info("Apprise notification sent successfully")
|
||||||
else:
|
else:
|
||||||
logger.error(
|
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:
|
class ApplicationFinder:
|
||||||
def __init__(self, roots: List[Path]):
|
def __init__(self, roots: list[Path]) -> None:
|
||||||
self.roots = roots
|
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."""
|
"""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))
|
source_dirs = itertools.chain(*(root.iterdir() for root in self.roots))
|
||||||
|
|
||||||
for app_dir in source_dirs:
|
for app_dir in source_dirs:
|
||||||
@@ -361,28 +486,27 @@ class ApplicationFinder:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
except (KeyError, OSError) as e:
|
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)
|
applications.sort(key=lambda app: app.path.name)
|
||||||
return applications
|
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."""
|
"""Find executable backup script in application directory."""
|
||||||
for name in ("backup.sh", "backup"):
|
for name in ("backup.sh", "backup"):
|
||||||
script_path = app_dir / name
|
script_path = app_dir / name
|
||||||
if script_path.exists():
|
if script_path.exists():
|
||||||
if os.access(script_path, os.X_OK):
|
if os.access(script_path, os.X_OK):
|
||||||
return script_path
|
return script_path
|
||||||
else:
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Backup script {script_path} exists but is not executable"
|
"Backup script %s exists but is not executable", script_path
|
||||||
)
|
)
|
||||||
return None
|
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."""
|
"""Resolve backup target directories for an application."""
|
||||||
targets_file = app_dir / BACKUP_TARGETS_FILE
|
targets_file = app_dir / BACKUP_TARGETS_FILE
|
||||||
resolved_targets: List[Path] = []
|
resolved_targets: list[Path] = []
|
||||||
|
|
||||||
if targets_file.exists():
|
if targets_file.exists():
|
||||||
for target_line in self._parse_targets_file(targets_file):
|
for target_line in self._parse_targets_file(targets_file):
|
||||||
@@ -410,9 +534,9 @@ class ApplicationFinder:
|
|||||||
|
|
||||||
return resolved_targets
|
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."""
|
"""Parse backup-targets file, skipping comments and empty lines."""
|
||||||
targets: List[str] = []
|
targets: list[str] = []
|
||||||
try:
|
try:
|
||||||
for raw_line in targets_file.read_text(encoding="utf-8").splitlines():
|
for raw_line in targets_file.read_text(encoding="utf-8").splitlines():
|
||||||
line = raw_line.strip()
|
line = raw_line.strip()
|
||||||
@@ -430,75 +554,156 @@ class BackupManager:
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
config: Config,
|
config: Config,
|
||||||
storages: List[Storage],
|
storages: list[Storage],
|
||||||
notifiers: List[Notifier],
|
notifiers: list[Notifier],
|
||||||
schedule: Schedule,
|
schedule: Schedule,
|
||||||
maintenance: MaintenanceOptions,
|
maintenance: MaintenanceOptions,
|
||||||
forced_phases: Optional[List[str]] = None,
|
forced_phases: list[str] | None = None,
|
||||||
):
|
) -> None:
|
||||||
self.errors: List[str] = []
|
self.errors: list[str] = []
|
||||||
self.warnings: List[str] = []
|
self.warnings: list[str] = []
|
||||||
self.successful_backups: List[str] = []
|
self.app_results: list[AppRunResult] = []
|
||||||
|
self.disk_usages: list[DiskUsage] = []
|
||||||
self.config = config
|
self.config = config
|
||||||
self.storages = storages
|
self.storages = storages
|
||||||
self.notifiers = notifiers
|
self.notifiers = notifiers
|
||||||
self.schedule = schedule
|
self.schedule = schedule
|
||||||
self.maintenance = maintenance
|
self.maintenance = maintenance
|
||||||
self.forced_phases = forced_phases
|
self.forced_phases = forced_phases
|
||||||
self.active_phases: List[str] = []
|
self.active_phases: list[str] = []
|
||||||
self.archive_duration: float = 0.0
|
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"""
|
"""Main backup process"""
|
||||||
logger.info("Starting 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:
|
if self.forced_phases is not None:
|
||||||
self.active_phases = self.forced_phases
|
logger.info("Phases (forced): %s", ", ".join(self.forced_phases))
|
||||||
logger.info("Phases (forced): %s", ", ".join(self.active_phases))
|
return self.forced_phases
|
||||||
else:
|
|
||||||
self.active_phases = self.schedule.due_phases(datetime.now())
|
|
||||||
logger.info("Phases (scheduled): %s", ", ".join(self.active_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_start = time.monotonic()
|
||||||
# Archive phase (per-app backup scripts) нужна только если будем делать restic backup.
|
|
||||||
if PHASE_BACKUP in self.active_phases:
|
if PHASE_BACKUP in self.active_phases:
|
||||||
for app in applications:
|
for app in applications:
|
||||||
app_dir = str(app.path)
|
status = self._archive_app(app)
|
||||||
username = app.owner
|
self.app_results.append(AppRunResult(name=app.path.name, status=status))
|
||||||
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)
|
|
||||||
else:
|
else:
|
||||||
logger.info("Backup phase not active, skipping per-app archive scripts")
|
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
|
self.archive_duration = time.monotonic() - archive_start
|
||||||
logger.info(
|
logger.info(
|
||||||
"Archive phase finished in %s", format_duration(self.archive_duration)
|
"Archive phase finished in %s", format_duration(self.archive_duration)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Collect backup directories from applications
|
def _archive_app(self, app: Application) -> AppStatus:
|
||||||
backup_dirs: List[str] = []
|
"""Обработать одно приложение: сделать дамп, если он предусмотрен."""
|
||||||
|
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 app in applications:
|
||||||
for target in app.backup_targets:
|
for target in app.backup_targets:
|
||||||
target_str = str(target)
|
target_str = str(target)
|
||||||
if target_str not in backup_dirs:
|
if target_str not in backup_dirs:
|
||||||
backup_dirs.append(target_str)
|
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
|
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:
|
for storage in self.storages:
|
||||||
storage_start = time.monotonic()
|
storage_start = time.monotonic()
|
||||||
try:
|
try:
|
||||||
@@ -531,28 +736,14 @@ class BackupManager:
|
|||||||
error_msg += f": {backup_result.error}"
|
error_msg += f": {backup_result.error}"
|
||||||
self.errors.append(error_msg)
|
self.errors.append(error_msg)
|
||||||
|
|
||||||
# Determine overall success
|
|
||||||
overall_success = overall_success and backup_result.success
|
overall_success = overall_success and backup_result.success
|
||||||
|
|
||||||
# Send notification
|
return overall_success
|
||||||
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
|
|
||||||
|
|
||||||
def _run_app_backup(self, script_path: str, app_dir: str, username: str) -> bool:
|
def _run_app_backup(self, script_path: str, app_dir: str, username: str) -> bool:
|
||||||
"""Run backup script as the specified user"""
|
"""Run backup script as the specified user"""
|
||||||
try:
|
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
|
# Use su to run the script as the user
|
||||||
cmd = ["su", "--login", username, "--command", script_path]
|
cmd = ["su", "--login", username, "--command", script_path]
|
||||||
@@ -566,10 +757,8 @@ class BackupManager:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
logger.info(f"Backup script for {username} completed successfully")
|
logger.info("Backup script for %s completed successfully", username)
|
||||||
self.successful_backups.append(username)
|
|
||||||
return True
|
return True
|
||||||
else:
|
|
||||||
error_msg = f"Backup script {script_path} failed with return code {result.returncode}"
|
error_msg = f"Backup script {script_path} failed with return code {result.returncode}"
|
||||||
if result.stderr:
|
if result.stderr:
|
||||||
error_msg += f": {result.stderr}"
|
error_msg += f": {result.stderr}"
|
||||||
@@ -588,26 +777,56 @@ class BackupManager:
|
|||||||
self.errors.append(f"App {username}: {error_msg}")
|
self.errors.append(f"App {username}: {error_msg}")
|
||||||
return False
|
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:
|
def _send_notification(self, success: bool) -> None:
|
||||||
"""Send notification to Notifiers"""
|
"""Send notification to Notifiers"""
|
||||||
|
|
||||||
host = self.config.host_name
|
host = self.config.host_name
|
||||||
phases_text = ", ".join(self.active_phases) if self.active_phases else "—"
|
|
||||||
|
|
||||||
if success and not self.errors:
|
if success and not self.errors:
|
||||||
title = f"{host}: бекап успешно завершен"
|
title = f"{host}: бекап успешно завершен"
|
||||||
message = f"<p><b>{host}</b>: бекап успешно завершен!</p>"
|
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:
|
else:
|
||||||
title = f"{host}: бекап завершен с ошибками ({len(self.errors)})"
|
title = f"{host}: бекап завершен с ошибками ({len(self.errors)})"
|
||||||
message = f"<p><b>{host}</b>: бекап завершен с ошибками!</p>"
|
message = f"<p><b>{host}</b>: бекап завершен с ошибками!</p>"
|
||||||
|
|
||||||
if self.successful_backups:
|
message += self._render_apps()
|
||||||
items = "".join(f"<li>{b}</li>" for b in self.successful_backups)
|
|
||||||
message += f"<p>✅ Успешные бекапы:</p><ul>{items}</ul>"
|
|
||||||
|
|
||||||
|
if not (success and not self.errors):
|
||||||
if self.warnings:
|
if self.warnings:
|
||||||
items = "".join(f"<li>{w}</li>" for w in self.warnings)
|
items = "".join(f"<li>{w}</li>" for w in self.warnings)
|
||||||
message += f"<p>⚠️ Предупреждения:</p><ul>{items}</ul>"
|
message += f"<p>⚠️ Предупреждения:</p><ul>{items}</ul>"
|
||||||
@@ -616,23 +835,17 @@ class BackupManager:
|
|||||||
items = "".join(f"<li>{e}</li>" for e in self.errors)
|
items = "".join(f"<li>{e}</li>" for e in self.errors)
|
||||||
message += f"<p>❌ Ошибки:</p><ul>{items}</ul>"
|
message += f"<p>❌ Ошибки:</p><ul>{items}</ul>"
|
||||||
|
|
||||||
message += f"<p>🔧 Фазы restic: {phases_text}</p>"
|
message += self._render_run_stats()
|
||||||
message += f"<p>⏱ Время архивации: {format_duration(self.archive_duration)}</p>"
|
message += self._render_disks()
|
||||||
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>"
|
|
||||||
|
|
||||||
for notificator in self.notifiers:
|
for notificator in self.notifiers:
|
||||||
try:
|
try:
|
||||||
notificator.send(title, message)
|
notificator.send(title, message)
|
||||||
except Exception as e:
|
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."""
|
"""Разобрать CLI-список фаз, вернуть их в порядке PHASE_ORDER."""
|
||||||
requested = {p.strip() for p in raw.split(",") if p.strip()}
|
requested = {p.strip() for p in raw.split(",") if p.strip()}
|
||||||
unknown = requested - set(PHASE_ORDER)
|
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]
|
return [p for p in PHASE_ORDER if p in requested]
|
||||||
|
|
||||||
|
|
||||||
def initialize(
|
def build_storages(raw_config: dict[str, Any]) -> list[Storage]:
|
||||||
config_path: Path,
|
"""Собрать хранилища из секции [storage] конфига."""
|
||||||
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]
|
|
||||||
|
|
||||||
storage_raw = raw_config.get("storage") or {}
|
storage_raw = raw_config.get("storage") or {}
|
||||||
storages: List[Storage] = []
|
storages: list[Storage] = []
|
||||||
for name, params in storage_raw.items():
|
for name, params in storage_raw.items():
|
||||||
if not isinstance(params, dict):
|
if not isinstance(params, dict):
|
||||||
raise ValueError(f"Storage config for {name} must be a table")
|
raise ValueError(f"Storage config for {name} must be a table")
|
||||||
storage_type = params.get("type", "")
|
if params.get("type", "") == ResticStorage.TYPE_NAME:
|
||||||
if storage_type == ResticStorage.TYPE_NAME:
|
|
||||||
storages.append(ResticStorage(name, params))
|
storages.append(ResticStorage(name, params))
|
||||||
if not storages:
|
if not storages:
|
||||||
raise ValueError("At least one storage backend must be configured")
|
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 {}
|
notifications_raw = raw_config.get("notifier") or {}
|
||||||
notifiers: List[Notifier] = []
|
notifiers: list[Notifier] = []
|
||||||
for name, params in notifications_raw.items():
|
for name, params in notifications_raw.items():
|
||||||
if not isinstance(params, dict):
|
if not isinstance(params, dict):
|
||||||
raise ValueError(f"Notificator config for {name} must be a table")
|
raise ValueError(f"Notificator config for {name} must be a table")
|
||||||
notifier_type = params.get("type", "")
|
if params.get("type", "") == AppriseNotifier.TYPE_NAME:
|
||||||
if notifier_type == AppriseNotifier.TYPE_NAME:
|
|
||||||
notifiers.append(AppriseNotifier(name, params))
|
notifiers.append(AppriseNotifier(name, params))
|
||||||
if not notifiers:
|
if not notifiers:
|
||||||
raise ValueError("At least one notification backend must be configured")
|
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 {}
|
schedule_raw = raw_config.get("schedule") or {}
|
||||||
if not isinstance(schedule_raw, dict):
|
if not isinstance(schedule_raw, dict):
|
||||||
raise ValueError("'schedule' must be a table in config.toml")
|
raise ValueError("'schedule' must be a table in config.toml")
|
||||||
schedule = Schedule(
|
return Schedule(
|
||||||
cron={
|
cron={
|
||||||
phase: str(schedule_raw[phase])
|
phase: str(schedule_raw[phase])
|
||||||
for phase in SCHEDULED_PHASES
|
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 {}
|
maintenance_raw = raw_config.get("maintenance") or {}
|
||||||
if not isinstance(maintenance_raw, dict):
|
if not isinstance(maintenance_raw, dict):
|
||||||
raise ValueError("'maintenance' must be a table in config.toml")
|
raise ValueError("'maintenance' must be a table in config.toml")
|
||||||
defaults = MaintenanceOptions()
|
defaults = MaintenanceOptions()
|
||||||
maintenance = MaintenanceOptions(
|
return MaintenanceOptions(
|
||||||
verify_subset=str(maintenance_raw.get("verify_subset", defaults.verify_subset)),
|
verify_subset=str(maintenance_raw.get("verify_subset", defaults.verify_subset)),
|
||||||
prune_max_unused=str(
|
prune_max_unused=str(
|
||||||
maintenance_raw.get("prune_max_unused", defaults.prune_max_unused)
|
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)
|
app_finder = ApplicationFinder(roots)
|
||||||
backup_manager = BackupManager(
|
backup_manager = BackupManager(
|
||||||
config=config,
|
config=config,
|
||||||
@@ -752,7 +982,7 @@ def main() -> None:
|
|||||||
logger.info("Backup process interrupted by user")
|
logger.info("Backup process interrupted by user")
|
||||||
sys.exit(130)
|
sys.exit(130)
|
||||||
except Exception as e:
|
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)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+7
-5
@@ -1,7 +1,8 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
import os
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
@@ -18,12 +19,13 @@ def main() -> None:
|
|||||||
if args.keep < 0:
|
if args.keep < 0:
|
||||||
parser.error("--keep value cannot be negative")
|
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}")
|
parser.error(f"Directory not found: {args.directory}")
|
||||||
|
|
||||||
# Get list of files (exclude subdirectories)
|
# Get list of files (exclude subdirectories)
|
||||||
files = []
|
files = []
|
||||||
with os.scandir(args.directory) as entries:
|
with os.scandir(directory) as entries:
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
if entry.is_file():
|
if entry.is_file():
|
||||||
files.append(entry.name)
|
files.append(entry.name)
|
||||||
@@ -36,9 +38,9 @@ def main() -> None:
|
|||||||
|
|
||||||
# Delete files and print results
|
# Delete files and print results
|
||||||
for filename in to_delete:
|
for filename in to_delete:
|
||||||
filepath = os.path.join(args.directory, filename)
|
filepath = directory / filename
|
||||||
try:
|
try:
|
||||||
os.remove(filepath)
|
filepath.unlink()
|
||||||
print(f"Deleted: {filename}")
|
print(f"Deleted: {filename}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error deleting {filename}: {str(e)}")
|
print(f"Error deleting {filename}: {str(e)}")
|
||||||
|
|||||||
+7
-2
@@ -14,13 +14,18 @@ pre-commit:
|
|||||||
run: "uv run ruff format {staged_files}"
|
run: "uv run ruff format {staged_files}"
|
||||||
stage_fixed: true
|
stage_fixed: true
|
||||||
|
|
||||||
|
- name: "fix python"
|
||||||
|
glob: "**/*.py"
|
||||||
|
run: "uv run ruff check --fix {staged_files}"
|
||||||
|
stage_fixed: true
|
||||||
|
|
||||||
- name: "check python"
|
- name: "check python"
|
||||||
glob: "**/*.py"
|
glob: "**/*.py"
|
||||||
run: "uv run ruff check {staged_files}"
|
run: "uv run ruff check {staged_files}"
|
||||||
|
|
||||||
- name: "mypy"
|
- name: "pyrefly"
|
||||||
glob: "**/*.py"
|
glob: "**/*.py"
|
||||||
run: "uv run mypy {staged_files}"
|
run: "uv run pyrefly check {staged_files}"
|
||||||
|
|
||||||
- name: "yamllint"
|
- name: "yamllint"
|
||||||
glob: "**/*.{yml,yaml}"
|
glob: "**/*.{yml,yaml}"
|
||||||
|
|||||||
+41
-1
@@ -9,10 +9,50 @@ dependencies = [
|
|||||||
"ansible-lint>=25.12.2",
|
"ansible-lint>=25.12.2",
|
||||||
"croniter>=6.0.0",
|
"croniter>=6.0.0",
|
||||||
"invoke>=2.2.1",
|
"invoke>=2.2.1",
|
||||||
"mypy>=1.19.1",
|
"pyrefly>=1.2.0",
|
||||||
"requests>=2.32.5",
|
"requests>=2.32.5",
|
||||||
"ruff>=0.15.2",
|
"ruff>=0.15.2",
|
||||||
"types-croniter>=6.0.0",
|
"types-croniter>=6.0.0",
|
||||||
"types-requests>=2.32.4.20260107",
|
"types-requests>=2.32.4.20260107",
|
||||||
"yamllint>=1.37.1",
|
"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 hashlib
|
||||||
import base64
|
import base64
|
||||||
import argparse
|
import argparse
|
||||||
import sys
|
|
||||||
|
|
||||||
|
|
||||||
# These values are required to calculate the signature. Do not change them.
|
# 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:
|
def main() -> None:
|
||||||
if sys.version_info[0] < 3:
|
|
||||||
raise Exception("Must be using Python 3")
|
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Convert a Secret Access Key to an SMTP password."
|
description="Convert a Secret Access Key to an SMTP password."
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import os
|
|||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from invoke.context import Context
|
from invoke.context import Context
|
||||||
from invoke.exceptions import Exit
|
from invoke.exceptions import Exit
|
||||||
@@ -37,7 +38,7 @@ def _remote_host() -> str:
|
|||||||
def _authelia_docker() -> str:
|
def _authelia_docker() -> str:
|
||||||
"""Команда запуска authelia CLI на том же образе, что и задеплоенный сервис"""
|
"""Команда запуска authelia CLI на том же образе, что и задеплоенный сервис"""
|
||||||
pattern = re.compile(r"""image:\s*["']?(\S*authelia/authelia:[^"'\s]+)""")
|
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:
|
for line in compose:
|
||||||
match = pattern.search(line)
|
match = pattern.search(line)
|
||||||
if match:
|
if match:
|
||||||
@@ -56,7 +57,7 @@ def _rest_args() -> list[str]:
|
|||||||
def _resolve_playbook(name: str) -> str:
|
def _resolve_playbook(name: str) -> str:
|
||||||
candidates = [name, f"{name}.yml", f"playbook-{name}.yml"]
|
candidates = [name, f"{name}.yml", f"playbook-{name}.yml"]
|
||||||
for candidate in candidates:
|
for candidate in candidates:
|
||||||
if os.path.isfile(candidate):
|
if Path(candidate).is_file():
|
||||||
return candidate
|
return candidate
|
||||||
raise Exit(
|
raise Exit(
|
||||||
f"Плейбук для '{name}' не найден. Проверял: {', '.join(candidates)}", code=1
|
f"Плейбук для '{name}' не найден. Проверял: {', '.join(candidates)}", code=1
|
||||||
@@ -141,16 +142,12 @@ def edit_encrypted(ctx: Context, path: str) -> None:
|
|||||||
# Путь к канону — сосед по файловой системе (../ansible-roles/roles),
|
# Путь к канону — сосед по файловой системе (../ansible-roles/roles),
|
||||||
# поэтому не зависит от текущей директории и одинаков везде.
|
# поэтому не зависит от текущей директории и одинаков везде.
|
||||||
|
|
||||||
SHARED_ROLES_DIR = os.path.normpath(
|
SHARED_ROLES_DIR = Path(__file__).resolve().parent.parent / "ansible-roles" / "roles"
|
||||||
os.path.join(
|
|
||||||
os.path.dirname(os.path.abspath(__file__)), "..", "ansible-roles", "roles"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
SYNCED_ROLES = ["eget", "app_image"]
|
SYNCED_ROLES = ["eget", "app_image"]
|
||||||
|
|
||||||
|
|
||||||
def _require_canon() -> None:
|
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)
|
raise Exit(f"Канон не найден: {SHARED_ROLES_DIR}", code=1)
|
||||||
|
|
||||||
|
|
||||||
@@ -171,11 +168,13 @@ def roles_status(ctx: Context) -> None:
|
|||||||
_require_canon()
|
_require_canon()
|
||||||
drift = False
|
drift = False
|
||||||
for role in SYNCED_ROLES:
|
for role in SYNCED_ROLES:
|
||||||
canon = os.path.join(SHARED_ROLES_DIR, role)
|
canon = SHARED_ROLES_DIR / role
|
||||||
if not os.path.isdir(canon):
|
if not canon.is_dir():
|
||||||
print(f"{role}: missing in shared")
|
print(f"{role}: missing in shared")
|
||||||
drift = True
|
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")
|
print(f"{role}: differs")
|
||||||
drift = True
|
drift = True
|
||||||
else:
|
else:
|
||||||
@@ -189,10 +188,10 @@ def roles_pull(ctx: Context) -> None:
|
|||||||
"""Канон → репозиторий: inv roles-pull [-- <role> ...]"""
|
"""Канон → репозиторий: inv roles-pull [-- <role> ...]"""
|
||||||
_require_canon()
|
_require_canon()
|
||||||
for role in _roles_to_sync():
|
for role in _roles_to_sync():
|
||||||
src = os.path.join(SHARED_ROLES_DIR, role)
|
src = SHARED_ROLES_DIR / role
|
||||||
if not os.path.isdir(src):
|
if not src.is_dir():
|
||||||
raise Exit(f"Роль '{role}' отсутствует в каноне: {src}", code=1)
|
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}/")
|
ctx.run(f"rsync -a -i --delete {src}/ roles/{role}/")
|
||||||
|
|
||||||
|
|
||||||
@@ -201,24 +200,24 @@ def roles_push(ctx: Context) -> None:
|
|||||||
"""Репозиторий → канон, с подтверждением при расхождении: inv roles-push [-- <role> ...]"""
|
"""Репозиторий → канон, с подтверждением при расхождении: inv roles-push [-- <role> ...]"""
|
||||||
_require_canon()
|
_require_canon()
|
||||||
for role in _roles_to_sync():
|
for role in _roles_to_sync():
|
||||||
local = f"roles/{role}"
|
local = Path("roles", role)
|
||||||
canon = os.path.join(SHARED_ROLES_DIR, role)
|
canon = SHARED_ROLES_DIR / role
|
||||||
if not os.path.isdir(local):
|
if not local.is_dir():
|
||||||
raise Exit(f"Локальной роли нет: {local}", code=1)
|
raise Exit(f"Локальной роли нет: {local}", code=1)
|
||||||
differs = (
|
differs = (
|
||||||
os.path.isdir(canon)
|
canon.is_dir()
|
||||||
and subprocess.run(["diff", "-rq", local, canon]).returncode != 0
|
and subprocess.run(["diff", "-rq", str(local), str(canon)]).returncode != 0
|
||||||
)
|
)
|
||||||
if differs:
|
if differs:
|
||||||
# Защита от затирания более свежего канона устаревшей копией.
|
# Защита от затирания более свежего канона устаревшей копией.
|
||||||
subprocess.run(["diff", "-r", canon, local])
|
subprocess.run(["diff", "-r", str(canon), str(local)])
|
||||||
answer = input(
|
answer = input(
|
||||||
f"Канон '{role}' отличается. Перезаписать его копией из этого репо? [y/N] "
|
f"Канон '{role}' отличается. Перезаписать его копией из этого репо? [y/N] "
|
||||||
)
|
)
|
||||||
if answer.strip().lower() != "y":
|
if answer.strip().lower() != "y":
|
||||||
print(f"{role}: пропущено")
|
print(f"{role}: пропущено")
|
||||||
continue
|
continue
|
||||||
os.makedirs(canon, exist_ok=True)
|
canon.mkdir(parents=True, exist_ok=True)
|
||||||
ctx.run(f"rsync -a -i --delete {local}/ {canon}/")
|
ctx.run(f"rsync -a -i --delete {local}/ {canon}/")
|
||||||
|
|
||||||
|
|
||||||
@@ -267,7 +266,7 @@ def authelia_validate_config(ctx: Context) -> None:
|
|||||||
"""Отрендерить конфиг authelia из шаблона и проверить его"""
|
"""Отрендерить конфиг authelia из шаблона и проверить его"""
|
||||||
dest = "temp/configuration.yml"
|
dest = "temp/configuration.yml"
|
||||||
# temp/ в .gitignore, на свежем клоне его нет — ansible сам директорию не создаёт.
|
# temp/ в .gitignore, на свежем клоне его нет — ansible сам директорию не создаёт.
|
||||||
os.makedirs(os.path.dirname(dest), exist_ok=True)
|
Path(dest).parent.mkdir(parents=True, exist_ok=True)
|
||||||
try:
|
try:
|
||||||
ctx.run(
|
ctx.run(
|
||||||
"uv run ansible localhost"
|
"uv run ansible localhost"
|
||||||
|
|||||||
@@ -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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "markupsafe"
|
name = "markupsafe"
|
||||||
version = "3.0.3"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "mypy-extensions"
|
name = "mypy-extensions"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
@@ -607,7 +514,7 @@ dependencies = [
|
|||||||
{ name = "ansible-lint" },
|
{ name = "ansible-lint" },
|
||||||
{ name = "croniter" },
|
{ name = "croniter" },
|
||||||
{ name = "invoke" },
|
{ name = "invoke" },
|
||||||
{ name = "mypy" },
|
{ name = "pyrefly" },
|
||||||
{ name = "requests" },
|
{ name = "requests" },
|
||||||
{ name = "ruff" },
|
{ name = "ruff" },
|
||||||
{ name = "types-croniter" },
|
{ name = "types-croniter" },
|
||||||
@@ -621,7 +528,7 @@ requires-dist = [
|
|||||||
{ name = "ansible-lint", specifier = ">=25.12.2" },
|
{ name = "ansible-lint", specifier = ">=25.12.2" },
|
||||||
{ name = "croniter", specifier = ">=6.0.0" },
|
{ name = "croniter", specifier = ">=6.0.0" },
|
||||||
{ name = "invoke", specifier = ">=2.2.1" },
|
{ 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 = "requests", specifier = ">=2.32.5" },
|
||||||
{ name = "ruff", specifier = ">=0.15.2" },
|
{ name = "ruff", specifier = ">=0.15.2" },
|
||||||
{ name = "types-croniter", specifier = ">=6.0.0" },
|
{ 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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "python-dateutil"
|
name = "python-dateutil"
|
||||||
version = "2.9.0.post0"
|
version = "2.9.0.post0"
|
||||||
|
|||||||
Reference in New Issue
Block a user