refactor: move Python implementation to legacy/, make Go primary
Deploy to K8s / deploy (push) Successful in 20s
Deploy to K8s / deploy (push) Successful in 20s
Go (go/) becomes the primary implementation. The original Python version moves to legacy/ and is kept runnable; new features are still built in both (see the Dual implementation rule in CLAUDE.md). - git mv app.py, scripts/, templates/, tests/, pyproject.toml, uv.lock and build/ into legacy/ - Makefile: Python targets point at legacy/, venv in legacy/.venv, CREDENTIALS_PATH exported for the root .secret/ file - CI and make image build the Python image with legacy/ as context; the Makefile is no longer copied into the image - legacy/.dockerignore moved to the context root so it takes effect - CLAUDE.md, docs/README.md, Go comments, fixture README, capture script and VS Code launch config updated to the new layout Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
.git
|
||||
.venv
|
||||
**/__pycache__
|
||||
**/*.pyc
|
||||
.claude
|
||||
.gemini
|
||||
+359
@@ -0,0 +1,359 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
import re
|
||||
import time
|
||||
import os
|
||||
import io
|
||||
import qrcode
|
||||
import logging
|
||||
from flask import Flask, render_template, g, send_file, request, jsonify
|
||||
|
||||
# Configure logging, allowing override via LOG_LEVEL environment variable
|
||||
log_level = os.environ.get("LOG_LEVEL", "INFO").upper()
|
||||
logging.basicConfig(level=getattr(logging, log_level, logging.INFO), format='%(asctime)s - %(name)s:%(filename)s:%(lineno)d [%(funcName)s] - %(levelname)s - %(message)s')
|
||||
|
||||
# Add scripts directory to path to allow importing from it
|
||||
scripts_dir = Path(__file__).parent / "scripts"
|
||||
sys.path.append(str(scripts_dir))
|
||||
|
||||
from config import (
|
||||
ATTENDANCE_SHEET_ID, PAYMENTS_SHEET_ID, JUNIOR_SHEET_GID,
|
||||
BANK_ACCOUNT, CREDENTIALS_PATH, MONTHS_TO_SHOW,
|
||||
)
|
||||
from attendance import get_members_with_fees, get_junior_members_with_fees
|
||||
from match_payments import reconcile, fetch_sheet_data, fetch_exceptions
|
||||
from views import (
|
||||
build_adults_view_model,
|
||||
build_juniors_view_model,
|
||||
build_payments_view_model,
|
||||
adapt_junior_members,
|
||||
)
|
||||
from cache_utils import get_sheet_modified_time, read_cache, write_cache, _LAST_CHECKED, flush_cache
|
||||
from sync_fio_to_sheets import sync_to_sheets
|
||||
from infer_payments import infer_payments
|
||||
|
||||
def get_cached_data(cache_key, sheet_id, fetch_func, *args, serialize=None, deserialize=None, **kwargs):
|
||||
mod_time = get_sheet_modified_time(cache_key)
|
||||
if mod_time:
|
||||
cached = read_cache(cache_key, mod_time)
|
||||
if cached is not None:
|
||||
return deserialize(cached) if deserialize else cached
|
||||
data = fetch_func(*args, **kwargs)
|
||||
if mod_time:
|
||||
write_cache(cache_key, mod_time, serialize(data) if serialize else data)
|
||||
return data
|
||||
|
||||
def warmup_cache():
|
||||
"""Pre-fetch all cached data so first request is fast."""
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info("Warming up cache...")
|
||||
credentials_path = CREDENTIALS_PATH
|
||||
|
||||
get_cached_data("attendance_regular", ATTENDANCE_SHEET_ID, get_members_with_fees)
|
||||
get_cached_data("attendance_juniors", ATTENDANCE_SHEET_ID, get_junior_members_with_fees)
|
||||
get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, credentials_path)
|
||||
get_cached_data("exceptions_dict", PAYMENTS_SHEET_ID, fetch_exceptions,
|
||||
PAYMENTS_SHEET_ID, credentials_path,
|
||||
serialize=lambda d: [[list(k), v] for k, v in d.items()],
|
||||
deserialize=lambda c: {tuple(k): v for k, v in c},
|
||||
)
|
||||
logger.info("Cache warmup complete.")
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
import json as _json
|
||||
_meta_path = Path(__file__).parent / "build_meta.json"
|
||||
BUILD_META = _json.loads(_meta_path.read_text()) if _meta_path.exists() else {
|
||||
"tag": "dev", "commit": "local", "build_date": ""
|
||||
}
|
||||
|
||||
|
||||
def _unwrap_view_model_for_api(vm: dict) -> dict:
|
||||
"""Expand pre-stringified JSON fields and rename to match Go API contract."""
|
||||
out = dict(vm)
|
||||
out["member_data"] = _json.loads(out.pop("member_data"))
|
||||
out["month_labels"] = _json.loads(out.pop("month_labels_json"))
|
||||
out["raw_payments"] = _json.loads(out.pop("raw_payments_json"))
|
||||
return out
|
||||
|
||||
|
||||
warmup_cache()
|
||||
|
||||
@app.before_request
|
||||
def start_timer():
|
||||
g.start_time = time.perf_counter()
|
||||
g.steps = []
|
||||
|
||||
def record_step(name):
|
||||
g.steps.append((name, time.perf_counter()))
|
||||
|
||||
@app.context_processor
|
||||
def inject_render_time():
|
||||
def get_render_time():
|
||||
total = time.perf_counter() - g.start_time
|
||||
breakdown = []
|
||||
last_time = g.start_time
|
||||
for name, timestamp in g.steps:
|
||||
duration = timestamp - last_time
|
||||
breakdown.append(f"{name}:{duration:.3f}s")
|
||||
last_time = timestamp
|
||||
|
||||
# Add remaining time as 'render'
|
||||
render_duration = time.perf_counter() - last_time
|
||||
breakdown.append(f"render:{render_duration:.3f}s")
|
||||
|
||||
return {
|
||||
"total": f"{total:.3f}",
|
||||
"breakdown": " | ".join(breakdown)
|
||||
}
|
||||
return dict(get_render_time=get_render_time, build_meta=BUILD_META)
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
# Redirect root to /adults for convenience while there are no other apps
|
||||
return '<meta http-equiv="refresh" content="0; url=/adults" />'
|
||||
|
||||
@app.route("/flush-cache", methods=["GET", "POST"])
|
||||
def flush_cache_endpoint():
|
||||
if request.method == "GET":
|
||||
return render_template("flush-cache.html")
|
||||
deleted = flush_cache()
|
||||
return render_template("flush-cache.html", flushed=True, deleted=deleted)
|
||||
|
||||
@app.route("/sync-bank")
|
||||
def sync_bank():
|
||||
import contextlib
|
||||
output = io.StringIO()
|
||||
success = True
|
||||
try:
|
||||
with contextlib.redirect_stdout(output), contextlib.redirect_stderr(output):
|
||||
# sync_to_sheets: equivalent of make sync-2026
|
||||
output.write("=== Syncing Fio transactions (2026) ===\n")
|
||||
sync_to_sheets(
|
||||
spreadsheet_id=PAYMENTS_SHEET_ID,
|
||||
credentials_path=CREDENTIALS_PATH,
|
||||
date_from_str="2026-01-01",
|
||||
date_to_str="2026-12-31",
|
||||
sort_by_date=True,
|
||||
)
|
||||
output.write("\n=== Inferring payment details ===\n")
|
||||
infer_payments(PAYMENTS_SHEET_ID, CREDENTIALS_PATH)
|
||||
output.write("\n=== Flushing cache ===\n")
|
||||
deleted = flush_cache()
|
||||
output.write(f"Deleted {deleted} cache files.\n")
|
||||
output.write("\n=== Done ===\n")
|
||||
except Exception as e:
|
||||
import traceback
|
||||
output.write(f"\n!!! Error: {e}\n")
|
||||
output.write(traceback.format_exc())
|
||||
success = False
|
||||
return render_template("sync.html", output=output.getvalue(), success=success)
|
||||
|
||||
@app.route("/version")
|
||||
def version():
|
||||
return BUILD_META
|
||||
|
||||
@app.route("/api/version")
|
||||
def api_version():
|
||||
return jsonify(BUILD_META)
|
||||
|
||||
@app.route("/api/adults")
|
||||
def api_adults():
|
||||
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit"
|
||||
payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit"
|
||||
members_data = get_cached_data("attendance_regular", ATTENDANCE_SHEET_ID, get_members_with_fees)
|
||||
if not members_data:
|
||||
return jsonify({"error": "no data"}), 503
|
||||
members, sorted_months = members_data
|
||||
transactions = get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, CREDENTIALS_PATH)
|
||||
exceptions = get_cached_data(
|
||||
"exceptions_dict", PAYMENTS_SHEET_ID, fetch_exceptions,
|
||||
PAYMENTS_SHEET_ID, CREDENTIALS_PATH,
|
||||
serialize=lambda d: [[list(k), v] for k, v in d.items()],
|
||||
deserialize=lambda c: {tuple(k): v for k, v in c},
|
||||
)
|
||||
result = reconcile(members, sorted_months, transactions, exceptions)
|
||||
vm = build_adults_view_model(
|
||||
members, sorted_months, result, transactions,
|
||||
datetime.now().strftime("%Y-%m"),
|
||||
attendance_url=attendance_url, payments_url=payments_url, bank_account=BANK_ACCOUNT,
|
||||
)
|
||||
return jsonify(_unwrap_view_model_for_api(vm))
|
||||
|
||||
@app.route("/api/juniors")
|
||||
def api_juniors():
|
||||
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit#gid={JUNIOR_SHEET_GID}"
|
||||
payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit"
|
||||
junior_members_data = get_cached_data("attendance_juniors", ATTENDANCE_SHEET_ID, get_junior_members_with_fees)
|
||||
if not junior_members_data:
|
||||
return jsonify({"error": "no data"}), 503
|
||||
junior_members, sorted_months = junior_members_data
|
||||
transactions = get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, CREDENTIALS_PATH)
|
||||
exceptions = get_cached_data(
|
||||
"exceptions_dict", PAYMENTS_SHEET_ID, fetch_exceptions,
|
||||
PAYMENTS_SHEET_ID, CREDENTIALS_PATH,
|
||||
serialize=lambda d: [[list(k), v] for k, v in d.items()],
|
||||
deserialize=lambda c: {tuple(k): v for k, v in c},
|
||||
)
|
||||
adapted_members = adapt_junior_members(junior_members)
|
||||
result = reconcile(adapted_members, sorted_months, transactions, exceptions)
|
||||
vm = build_juniors_view_model(
|
||||
junior_members, adapted_members, sorted_months, result, transactions,
|
||||
datetime.now().strftime("%Y-%m"),
|
||||
attendance_url=attendance_url, payments_url=payments_url, bank_account=BANK_ACCOUNT,
|
||||
)
|
||||
return jsonify(_unwrap_view_model_for_api(vm))
|
||||
|
||||
@app.route("/api/payments")
|
||||
def api_payments():
|
||||
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit"
|
||||
payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit"
|
||||
transactions = get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, CREDENTIALS_PATH)
|
||||
adults_data = get_cached_data("attendance_regular", ATTENDANCE_SHEET_ID, get_members_with_fees)
|
||||
juniors_data = get_cached_data("attendance_juniors", ATTENDANCE_SHEET_ID, get_junior_members_with_fees)
|
||||
member_names = []
|
||||
if adults_data:
|
||||
member_names.extend(name for name, _, _ in adults_data[0])
|
||||
if juniors_data:
|
||||
member_names.extend(name for name, _, _ in juniors_data[0])
|
||||
vm = build_payments_view_model(
|
||||
transactions, member_names,
|
||||
attendance_url=attendance_url, payments_url=payments_url,
|
||||
)
|
||||
return jsonify(vm)
|
||||
|
||||
@app.route("/adults")
|
||||
def adults_view():
|
||||
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit"
|
||||
payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit"
|
||||
credentials_path = CREDENTIALS_PATH
|
||||
|
||||
members_data = get_cached_data("attendance_regular", ATTENDANCE_SHEET_ID, get_members_with_fees)
|
||||
record_step("fetch_members")
|
||||
if not members_data:
|
||||
return "No data."
|
||||
members, sorted_months = members_data
|
||||
|
||||
transactions = get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, credentials_path)
|
||||
record_step("fetch_payments")
|
||||
exceptions = get_cached_data(
|
||||
"exceptions_dict", PAYMENTS_SHEET_ID, fetch_exceptions,
|
||||
PAYMENTS_SHEET_ID, credentials_path,
|
||||
serialize=lambda d: [[list(k), v] for k, v in d.items()],
|
||||
deserialize=lambda c: {tuple(k): v for k, v in c},
|
||||
)
|
||||
record_step("fetch_exceptions")
|
||||
result = reconcile(members, sorted_months, transactions, exceptions)
|
||||
record_step("reconcile")
|
||||
|
||||
vm = build_adults_view_model(
|
||||
members, sorted_months, result, transactions,
|
||||
datetime.now().strftime("%Y-%m"),
|
||||
attendance_url=attendance_url,
|
||||
payments_url=payments_url,
|
||||
bank_account=BANK_ACCOUNT,
|
||||
)
|
||||
record_step("process_data")
|
||||
return render_template("adults.html", months_to_show=MONTHS_TO_SHOW, **vm)
|
||||
|
||||
@app.route("/juniors")
|
||||
def juniors_view():
|
||||
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit#gid={JUNIOR_SHEET_GID}"
|
||||
payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit"
|
||||
credentials_path = CREDENTIALS_PATH
|
||||
|
||||
junior_members_data = get_cached_data("attendance_juniors", ATTENDANCE_SHEET_ID, get_junior_members_with_fees)
|
||||
record_step("fetch_junior_members")
|
||||
if not junior_members_data:
|
||||
return "No data."
|
||||
junior_members, sorted_months = junior_members_data
|
||||
|
||||
transactions = get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, credentials_path)
|
||||
record_step("fetch_payments")
|
||||
exceptions = get_cached_data(
|
||||
"exceptions_dict", PAYMENTS_SHEET_ID, fetch_exceptions,
|
||||
PAYMENTS_SHEET_ID, credentials_path,
|
||||
serialize=lambda d: [[list(k), v] for k, v in d.items()],
|
||||
deserialize=lambda c: {tuple(k): v for k, v in c},
|
||||
)
|
||||
record_step("fetch_exceptions")
|
||||
|
||||
adapted_members = adapt_junior_members(junior_members)
|
||||
result = reconcile(adapted_members, sorted_months, transactions, exceptions)
|
||||
record_step("reconcile")
|
||||
|
||||
vm = build_juniors_view_model(
|
||||
junior_members, adapted_members, sorted_months, result, transactions,
|
||||
datetime.now().strftime("%Y-%m"),
|
||||
attendance_url=attendance_url,
|
||||
payments_url=payments_url,
|
||||
bank_account=BANK_ACCOUNT,
|
||||
)
|
||||
record_step("process_data")
|
||||
return render_template("juniors.html", months_to_show=MONTHS_TO_SHOW, **vm)
|
||||
|
||||
@app.route("/payments")
|
||||
def payments():
|
||||
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit"
|
||||
payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit"
|
||||
credentials_path = CREDENTIALS_PATH
|
||||
|
||||
transactions = get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, credentials_path)
|
||||
record_step("fetch_payments")
|
||||
|
||||
adults_data = get_cached_data("attendance_regular", ATTENDANCE_SHEET_ID, get_members_with_fees)
|
||||
juniors_data = get_cached_data("attendance_juniors", ATTENDANCE_SHEET_ID, get_junior_members_with_fees)
|
||||
member_names = []
|
||||
if adults_data:
|
||||
member_names.extend(name for name, _, _ in adults_data[0])
|
||||
if juniors_data:
|
||||
member_names.extend(name for name, _, _ in juniors_data[0])
|
||||
|
||||
vm = build_payments_view_model(
|
||||
transactions, member_names,
|
||||
attendance_url=attendance_url,
|
||||
payments_url=payments_url,
|
||||
)
|
||||
record_step("process_data")
|
||||
return render_template("payments.html", **vm)
|
||||
|
||||
@app.route("/qr")
|
||||
def qr_code():
|
||||
account = request.args.get("account", BANK_ACCOUNT)
|
||||
amount = request.args.get("amount", "0")
|
||||
message = request.args.get("message", "")
|
||||
|
||||
# Validate account: allow IBAN (letters+digits) or Czech format (digits/digits)
|
||||
if not re.match(r'^[A-Z]{2}\d{2,34}$|^\d{1,16}/\d{4}$', account):
|
||||
account = BANK_ACCOUNT
|
||||
|
||||
# QR Platba standard: SPD*1.0*ACC:accountNumber*BC:bankCode*AM:amount*CC:CZK*MSG:message
|
||||
acc_parts = account.split('/')
|
||||
if len(acc_parts) == 2:
|
||||
acc_str = f"{acc_parts[0]}*BC:{acc_parts[1]}"
|
||||
else:
|
||||
acc_str = account
|
||||
|
||||
try:
|
||||
amt_val = float(amount)
|
||||
if amt_val < 0 or amt_val > 10_000_000:
|
||||
amt_val = 0
|
||||
amt_str = f"{amt_val:.2f}"
|
||||
except ValueError:
|
||||
amt_str = "0.00"
|
||||
|
||||
# Message max 60 characters, strip SPD delimiters to prevent injection
|
||||
msg_str = message[:60].replace("*", "")
|
||||
|
||||
qr_data = f"SPD*1.0*ACC:{acc_str}*AM:{amt_str}*CC:CZK*MSG:{msg_str}"
|
||||
|
||||
img = qrcode.make(qr_data)
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format='PNG')
|
||||
buf.seek(0)
|
||||
|
||||
return send_file(buf, mimetype='image/png')
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(debug=True, host='0.0.0.0', port=5001)
|
||||
@@ -0,0 +1,43 @@
|
||||
FROM python:3.13-alpine
|
||||
|
||||
RUN apk add --no-cache bash tzdata \
|
||||
&& cp /usr/share/zoneinfo/Europe/Prague /etc/localtime \
|
||||
&& echo "Europe/Prague" > /etc/timezone
|
||||
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN pip install --no-cache-dir \
|
||||
flask \
|
||||
google-api-python-client \
|
||||
google-auth-httplib2 \
|
||||
google-auth-oauthlib \
|
||||
qrcode \
|
||||
pillow \
|
||||
gunicorn
|
||||
|
||||
COPY app.py ./
|
||||
COPY scripts/ ./scripts/
|
||||
COPY templates/ ./templates/
|
||||
|
||||
COPY build/entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
ARG GIT_TAG=unknown
|
||||
ARG GIT_COMMIT=unknown
|
||||
ARG BUILD_DATE=unknown
|
||||
|
||||
LABEL org.opencontainers.image.version="${GIT_TAG}" \
|
||||
org.opencontainers.image.revision="${GIT_COMMIT}" \
|
||||
org.opencontainers.image.created="${BUILD_DATE}" \
|
||||
org.opencontainers.image.title="fuj-management"
|
||||
|
||||
RUN echo "{\"tag\": \"${GIT_TAG}\", \"commit\": \"${GIT_COMMIT}\", \"build_date\": \"${BUILD_DATE}\"}" > /app/build_meta.json
|
||||
|
||||
EXPOSE 5001
|
||||
|
||||
HEALTHCHECK --interval=60s --timeout=5s --start-period=5s \
|
||||
CMD wget -q -O /dev/null http://localhost:5001/ || exit 1
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
Executable
+11
@@ -0,0 +1,11 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
echo "[entrypoint] Starting gunicorn on port 5001..."
|
||||
|
||||
exec gunicorn \
|
||||
--bind 0.0.0.0:5001 \
|
||||
--workers "${GUNICORN_WORKERS:-2}" \
|
||||
--timeout "${GUNICORN_TIMEOUT:-120}" \
|
||||
--access-logfile - \
|
||||
app:app
|
||||
@@ -0,0 +1,22 @@
|
||||
[project]
|
||||
name = "fuj-management"
|
||||
version = "0.10"
|
||||
description = "Legacy Python implementation of the FUJ (Frisbee Ultimate Jablonec) management tools; the Go implementation in ../go is primary"
|
||||
dependencies = [
|
||||
"flask>=3.1.3",
|
||||
"google-api-python-client>=2.162.0",
|
||||
"google-auth-httplib2>=0.2.0",
|
||||
"google-auth-oauthlib>=1.2.1",
|
||||
"qrcode[pil]>=8.0",
|
||||
"gunicorn>=23.0",
|
||||
]
|
||||
requires-python = ">=3.13"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
"pytest-cov>=6.0",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
package = false
|
||||
@@ -0,0 +1,565 @@
|
||||
"""Fixture seed registry for capture_fixtures.py.
|
||||
|
||||
Seeds are keyed by (func_name, case_id). Values are dicts whose keys
|
||||
match the fixture input schema defined in docs/plans/2026-05-06-2111-go-m3-fixture-capture.md.
|
||||
|
||||
Real-data seeds for parse_month_references and match_members are loaded
|
||||
from tmp/payments_transactions_cache.json and tmp/attendance_regular_cache.json
|
||||
at hardcoded indices selected once interactively for coverage.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper to load cache files
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
def _load_cache(name: str) -> Any:
|
||||
path = os.path.join(_REPO, "tmp", name)
|
||||
if not os.path.exists(path):
|
||||
return None
|
||||
with open(path, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Handcrafted seed registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SEEDS: dict[tuple[str, str], dict] = {}
|
||||
|
||||
|
||||
# --- normalize ---
|
||||
|
||||
SEEDS[("normalize", "simple_ascii")] = {"text": "hello world"}
|
||||
SEEDS[("normalize", "czech_basic")] = {"text": "štefan čakrtový"}
|
||||
SEEDS[("normalize", "czech_full_set")] = {
|
||||
"text": "áčďéěíňóřšťůúýžÁČĎÉĚÍŇÓŘŠŤŮÚÝŽ"
|
||||
}
|
||||
SEEDS[("normalize", "with_parens")] = {"text": "Pavel Smutný (Štrúdl)"}
|
||||
SEEDS[("normalize", "mixed_case")] = {"text": "Henrietta OTTOVÁ"}
|
||||
SEEDS[("normalize", "empty_string")] = {"text": ""}
|
||||
SEEDS[("normalize", "digits_symbols")] = {"text": "FUJ2026! +3"}
|
||||
|
||||
|
||||
# --- parse_month_references ---
|
||||
|
||||
SEEDS[("parse_month_references", "empty_string")] = {
|
||||
"text": "", "default_year": 2026
|
||||
}
|
||||
SEEDS[("parse_month_references", "single_czech_leden")] = {
|
||||
"text": "leden", "default_year": 2026
|
||||
}
|
||||
SEEDS[("parse_month_references", "single_czech_prosinec_high_month")] = {
|
||||
"text": "prosinec", "default_year": 2026
|
||||
}
|
||||
SEEDS[("parse_month_references", "single_czech_rijen_high_month")] = {
|
||||
"text": "říjen", "default_year": 2026
|
||||
}
|
||||
SEEDS[("parse_month_references", "range_wrap_prosinec_leden")] = {
|
||||
"text": "prosinec-leden", "default_year": 2026
|
||||
}
|
||||
SEEDS[("parse_month_references", "range_wrap_listopad_leden")] = {
|
||||
"text": "listopad-leden", "default_year": 2026
|
||||
}
|
||||
SEEDS[("parse_month_references", "range_no_wrap_leden_unor")] = {
|
||||
"text": "leden-únor", "default_year": 2026
|
||||
}
|
||||
SEEDS[("parse_month_references", "numeric_slash_two_digit_year")] = {
|
||||
"text": "01/26", "default_year": 2026
|
||||
}
|
||||
SEEDS[("parse_month_references", "numeric_slash_four_digit_year")] = {
|
||||
"text": "1/2026", "default_year": 2026
|
||||
}
|
||||
SEEDS[("parse_month_references", "numeric_slash_leading_zero")] = {
|
||||
"text": "03/2026", "default_year": 2026
|
||||
}
|
||||
SEEDS[("parse_month_references", "numeric_plus_multi")] = {
|
||||
"text": "11+12/2025", "default_year": 2026
|
||||
}
|
||||
SEEDS[("parse_month_references", "numeric_dot_format")] = {
|
||||
"text": "12.2025", "default_year": 2026
|
||||
}
|
||||
SEEDS[("parse_month_references", "mixed_czech_numeric")] = {
|
||||
"text": "leden+únor+03/2026", "default_year": 2026
|
||||
}
|
||||
SEEDS[("parse_month_references", "no_month_found")] = {
|
||||
"text": "random text without months", "default_year": 2026
|
||||
}
|
||||
|
||||
|
||||
# --- calculate_fee ---
|
||||
|
||||
SEEDS[("calculate_fee", "zero_sessions")] = {
|
||||
"attendance_count": 0, "month_key": "2026-01"
|
||||
}
|
||||
SEEDS[("calculate_fee", "one_session")] = {
|
||||
"attendance_count": 1, "month_key": "2026-01"
|
||||
}
|
||||
SEEDS[("calculate_fee", "two_sessions_known_rate")] = {
|
||||
"attendance_count": 2, "month_key": "2026-01"
|
||||
}
|
||||
SEEDS[("calculate_fee", "three_sessions_known_rate")] = {
|
||||
"attendance_count": 3, "month_key": "2026-02"
|
||||
}
|
||||
SEEDS[("calculate_fee", "two_sessions_reduced_march")] = {
|
||||
"attendance_count": 2, "month_key": "2026-03"
|
||||
}
|
||||
SEEDS[("calculate_fee", "two_sessions_default_fallback")] = {
|
||||
"attendance_count": 2, "month_key": "2099-01"
|
||||
}
|
||||
|
||||
|
||||
# --- calculate_junior_fee ---
|
||||
|
||||
SEEDS[("calculate_junior_fee", "zero_sessions")] = {
|
||||
"attendance_count": 0, "month_key": "2026-01"
|
||||
}
|
||||
SEEDS[("calculate_junior_fee", "one_session_unknown")] = {
|
||||
"attendance_count": 1, "month_key": "2026-01"
|
||||
}
|
||||
SEEDS[("calculate_junior_fee", "two_sessions_default")] = {
|
||||
"attendance_count": 2, "month_key": "2026-01"
|
||||
}
|
||||
SEEDS[("calculate_junior_fee", "two_sessions_reduced_march")] = {
|
||||
"attendance_count": 2, "month_key": "2026-03"
|
||||
}
|
||||
SEEDS[("calculate_junior_fee", "two_sessions_reduced_sep")] = {
|
||||
"attendance_count": 2, "month_key": "2025-09"
|
||||
}
|
||||
SEEDS[("calculate_junior_fee", "two_sessions_default_fallback")] = {
|
||||
"attendance_count": 2, "month_key": "2099-06"
|
||||
}
|
||||
|
||||
|
||||
# --- parse_czk_amount ---
|
||||
|
||||
SEEDS[("parse_czk_amount", "none_value")] = {
|
||||
"val": {"type": "none"}
|
||||
}
|
||||
SEEDS[("parse_czk_amount", "empty_string")] = {
|
||||
"val": {"type": "string", "value": ""}
|
||||
}
|
||||
SEEDS[("parse_czk_amount", "plain_int")] = {
|
||||
"val": {"type": "int", "value": 750}
|
||||
}
|
||||
SEEDS[("parse_czk_amount", "plain_float")] = {
|
||||
"val": {"type": "float", "value": 750.0}
|
||||
}
|
||||
SEEDS[("parse_czk_amount", "czech_comma_decimal")] = {
|
||||
"val": {"type": "string", "value": "1.500,00"}
|
||||
}
|
||||
SEEDS[("parse_czk_amount", "czech_comma_no_thousands")] = {
|
||||
"val": {"type": "string", "value": "750,00"}
|
||||
}
|
||||
SEEDS[("parse_czk_amount", "dot_decimal")] = {
|
||||
"val": {"type": "string", "value": "1500.00"}
|
||||
}
|
||||
SEEDS[("parse_czk_amount", "dot_thousand_separator")] = {
|
||||
"val": {"type": "string", "value": "1.500"}
|
||||
}
|
||||
SEEDS[("parse_czk_amount", "with_kc_suffix")] = {
|
||||
"val": {"type": "string", "value": "750 Kč"}
|
||||
}
|
||||
SEEDS[("parse_czk_amount", "with_czk_suffix")] = {
|
||||
"val": {"type": "string", "value": "1500CZK"}
|
||||
}
|
||||
SEEDS[("parse_czk_amount", "space_thousands")] = {
|
||||
"val": {"type": "string", "value": "1 500"}
|
||||
}
|
||||
|
||||
|
||||
# --- generate_sync_id ---
|
||||
|
||||
def _sync_tx(date, amount, currency, sender, vs, message, bank_id):
|
||||
"""Build a generate_sync_id input seed."""
|
||||
return {
|
||||
"tx": {
|
||||
"date": date,
|
||||
"amount": amount,
|
||||
"currency": currency,
|
||||
"sender": sender,
|
||||
"vs": vs,
|
||||
"message": message,
|
||||
"bank_id": bank_id,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
SEEDS[("generate_sync_id", "typical_float_amount")] = _sync_tx(
|
||||
"2026-01-15",
|
||||
{"type": "float", "value": 750.0},
|
||||
"CZK",
|
||||
"Test Sender",
|
||||
"123456",
|
||||
"pausal leden",
|
||||
"100000001",
|
||||
)
|
||||
SEEDS[("generate_sync_id", "integer_amount")] = _sync_tx(
|
||||
"2026-01-15",
|
||||
{"type": "int", "value": 750},
|
||||
"CZK",
|
||||
"Test Sender",
|
||||
"123456",
|
||||
"pausal leden",
|
||||
"100000001",
|
||||
)
|
||||
SEEDS[("generate_sync_id", "missing_currency")] = {
|
||||
"tx": {
|
||||
"date": "2026-02-01",
|
||||
"amount": {"type": "float", "value": 500.0},
|
||||
"sender": "Another Person",
|
||||
"vs": "654321",
|
||||
"message": "trenink",
|
||||
"bank_id": "200000002",
|
||||
}
|
||||
}
|
||||
SEEDS[("generate_sync_id", "empty_fields")] = _sync_tx(
|
||||
"2026-03-01",
|
||||
{"type": "float", "value": 0.0},
|
||||
"CZK",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
)
|
||||
SEEDS[("generate_sync_id", "large_amount")] = _sync_tx(
|
||||
"2025-10-05",
|
||||
{"type": "float", "value": 2100.0},
|
||||
"CZK",
|
||||
"Payer Name",
|
||||
"987654",
|
||||
"FUJ treninky",
|
||||
"300000003",
|
||||
)
|
||||
|
||||
|
||||
# --- build_name_variants ---
|
||||
|
||||
SEEDS[("build_name_variants", "full_name_no_nick")] = {
|
||||
"full_name": "Jan Novák"
|
||||
}
|
||||
SEEDS[("build_name_variants", "with_nickname")] = {
|
||||
"full_name": "František Vrbík (Štrúdl)"
|
||||
}
|
||||
SEEDS[("build_name_variants", "three_word_name")] = {
|
||||
"full_name": "Jan Tomášek (Honza)"
|
||||
}
|
||||
SEEDS[("build_name_variants", "single_word")] = {
|
||||
"full_name": "Jáchym"
|
||||
}
|
||||
SEEDS[("build_name_variants", "short_name_filtered")] = {
|
||||
"full_name": "Jo"
|
||||
}
|
||||
SEEDS[("build_name_variants", "common_diacritics")] = {
|
||||
"full_name": "Alžběta Testovická"
|
||||
}
|
||||
|
||||
|
||||
# --- match_members ---
|
||||
|
||||
# Synthetic roster — deliberately NOT real member names.
|
||||
# Tomáš Fiktivný has a nickname (Tov) for nickname-match tests.
|
||||
# Pavel Smutný has a nickname (Štrúdl) for nickname tests.
|
||||
# Adam Novák: normalized last name "novak" is in _COMMON_SURNAMES → common-surname filter test.
|
||||
_ROSTER = [
|
||||
"Alžběta Testovická",
|
||||
"Tomáš Fiktivný (Tov)",
|
||||
"Pavel Smutný (Štrúdl)",
|
||||
"Jana Nováková",
|
||||
"Adam Novák",
|
||||
]
|
||||
|
||||
SEEDS[("match_members", "exact_full_name")] = {
|
||||
"text": "platba od alzbeta testovicka leden",
|
||||
"member_names": _ROSTER,
|
||||
}
|
||||
SEEDS[("match_members", "first_and_last")] = {
|
||||
"text": "jan nový payment tomas fiktivny",
|
||||
"member_names": _ROSTER,
|
||||
}
|
||||
SEEDS[("match_members", "nickname_match")] = {
|
||||
"text": "payment from strudl",
|
||||
"member_names": _ROSTER,
|
||||
}
|
||||
SEEDS[("match_members", "review_lastname_only")] = {
|
||||
"text": "testovicka leden",
|
||||
"member_names": _ROSTER,
|
||||
}
|
||||
SEEDS[("match_members", "common_surname_no_match")] = {
|
||||
"text": "novak leden",
|
||||
"member_names": _ROSTER,
|
||||
}
|
||||
SEEDS[("match_members", "no_match")] = {
|
||||
"text": "xyz platba",
|
||||
"member_names": _ROSTER,
|
||||
}
|
||||
SEEDS[("match_members", "two_members_exact")] = {
|
||||
"text": "pavel smutny a alzbeta testovicka",
|
||||
"member_names": _ROSTER,
|
||||
}
|
||||
|
||||
|
||||
# --- infer_transaction_details ---
|
||||
|
||||
SEEDS[("infer_transaction_details", "member_in_message")] = {
|
||||
"tx": {
|
||||
"sender": "Test Payer",
|
||||
"message": "alzbeta testovicka leden 2026",
|
||||
"user_id": "",
|
||||
"date": {"type": "string", "value": "2026-01-15"},
|
||||
},
|
||||
"member_names": _ROSTER,
|
||||
"default_year": 2026,
|
||||
}
|
||||
SEEDS[("infer_transaction_details", "member_in_sender")] = {
|
||||
"tx": {
|
||||
"sender": "Tomáš Fiktivný",
|
||||
"message": "FUJ trenink",
|
||||
"user_id": "",
|
||||
"date": {"type": "string", "value": "2026-02-01"},
|
||||
},
|
||||
"member_names": _ROSTER,
|
||||
"default_year": 2026,
|
||||
}
|
||||
SEEDS[("infer_transaction_details", "month_fallback_from_date")] = {
|
||||
"tx": {
|
||||
"sender": "Alžběta Testovická",
|
||||
"message": "platba",
|
||||
"user_id": "",
|
||||
"date": {"type": "string", "value": "2026-03-15"},
|
||||
},
|
||||
"member_names": _ROSTER,
|
||||
"default_year": 2026,
|
||||
}
|
||||
SEEDS[("infer_transaction_details", "serial_date")] = {
|
||||
"tx": {
|
||||
"sender": "Jana Nováková",
|
||||
"message": "leden",
|
||||
"user_id": "",
|
||||
"date": {"type": "float", "value": 46027.0}, # 2026-01-15 in Sheets serial
|
||||
},
|
||||
"member_names": _ROSTER,
|
||||
"default_year": 2026,
|
||||
}
|
||||
SEEDS[("infer_transaction_details", "no_member_no_month")] = {
|
||||
"tx": {
|
||||
"sender": "Unknown Person",
|
||||
"message": "random text",
|
||||
"user_id": "",
|
||||
"date": {"type": "none"},
|
||||
},
|
||||
"member_names": _ROSTER,
|
||||
"default_year": 2026,
|
||||
}
|
||||
|
||||
|
||||
# --- format_date ---
|
||||
|
||||
SEEDS[("format_date", "string_iso")] = {"val": {"type": "string", "value": "2026-01-15"}}
|
||||
SEEDS[("format_date", "string_non_iso")] = {"val": {"type": "string", "value": "garbage"}}
|
||||
SEEDS[("format_date", "empty_string")] = {"val": {"type": "string", "value": ""}}
|
||||
SEEDS[("format_date", "none_value")] = {"val": {"type": "none"}}
|
||||
SEEDS[("format_date", "serial_int")] = {"val": {"type": "int", "value": 46027}}
|
||||
SEEDS[("format_date", "serial_float")] = {"val": {"type": "float", "value": 46027.5}}
|
||||
SEEDS[("format_date", "serial_float_exact")] = {"val": {"type": "float", "value": 45957.0}} # 2025-10-01
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reconcile handcrafted seeds
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _tx(date, amount, person, purpose, sender="Payer", message="", bank_id="", inferred_amount=None):
|
||||
return {
|
||||
"date": date,
|
||||
"amount": amount,
|
||||
"manual_fix": "",
|
||||
"person": person,
|
||||
"purpose": purpose,
|
||||
"inferred_amount": inferred_amount if inferred_amount is not None else amount,
|
||||
"sender": sender,
|
||||
"message": message,
|
||||
"bank_id": bank_id,
|
||||
}
|
||||
|
||||
|
||||
def _member(name, tier, fees: dict):
|
||||
"""fees: {month: (fee, count) or int}. Returns a dict so the scrubber
|
||||
can find the 'name' key and apply deterministic pseudonymisation."""
|
||||
return {"name": name, "tier": tier, "fees": fees}
|
||||
|
||||
|
||||
def _reconcile_seed(members, sorted_months, transactions, exceptions=None, default_year=2026):
|
||||
return {
|
||||
"members": members,
|
||||
"sorted_months": sorted_months,
|
||||
"transactions": transactions,
|
||||
"exceptions": exceptions or [],
|
||||
"default_year": default_year,
|
||||
}
|
||||
|
||||
|
||||
# 01 — greedy exact: Alice pays exactly 750, expected 750
|
||||
SEEDS[("reconcile", "01_greedy_exact")] = _reconcile_seed(
|
||||
members=[_member("Alice Dvořák", "A", {"2026-01": (750, 3)})],
|
||||
sorted_months=["2026-01"],
|
||||
transactions=[_tx("2026-01-20", 750, "Alice Dvořák", "2026-01", sender="Alice Dvořák")],
|
||||
)
|
||||
|
||||
# 02 — greedy overpayment → credit: Alice pays 900, expected 750
|
||||
SEEDS[("reconcile", "02_greedy_overpayment")] = _reconcile_seed(
|
||||
members=[_member("Alice Dvořák", "A", {"2026-01": (750, 3)})],
|
||||
sorted_months=["2026-01"],
|
||||
transactions=[_tx("2026-01-20", 900, "Alice Dvořák", "2026-01", sender="Alice Dvořák")],
|
||||
)
|
||||
|
||||
# 03 — proportional: Alice pays 800 for 3 months (750+750+350=1850 expected)
|
||||
SEEDS[("reconcile", "03_proportional_remainder")] = _reconcile_seed(
|
||||
members=[_member("Alice Dvořák", "A", {
|
||||
"2026-01": (750, 3),
|
||||
"2026-02": (750, 2),
|
||||
"2026-03": (350, 2),
|
||||
})],
|
||||
sorted_months=["2026-01", "2026-02", "2026-03"],
|
||||
transactions=[_tx("2026-03-10", 800, "Alice Dvořák", "2026-01,2026-02,2026-03", sender="Alice Dvořák")],
|
||||
)
|
||||
|
||||
# 04 — even-split: all expected=0, payment spread evenly
|
||||
SEEDS[("reconcile", "04_even_split_prepayment")] = _reconcile_seed(
|
||||
members=[_member("Bob Kratochvíl", "A", {
|
||||
"2026-04": (0, 0),
|
||||
"2026-05": (0, 0),
|
||||
})],
|
||||
sorted_months=["2026-04", "2026-05"],
|
||||
transactions=[_tx("2026-03-25", 700, "Bob Kratochvíl", "2026-04,2026-05", sender="Bob Kratochvíl")],
|
||||
)
|
||||
|
||||
# 05 — out-of-window: payment references 2025-08 which is outside sorted_months
|
||||
SEEDS[("reconcile", "05_out_of_window_credit")] = _reconcile_seed(
|
||||
members=[_member("Alice Dvořák", "A", {"2026-01": (750, 3)})],
|
||||
sorted_months=["2026-01"],
|
||||
transactions=[_tx("2026-01-20", 1500, "Alice Dvořák", "2026-01,2025-08", sender="Alice Dvořák")],
|
||||
)
|
||||
|
||||
# 06 — exception override: Alice's 2026-01 fee overridden from 750 to 300
|
||||
SEEDS[("reconcile", "06_exception_override")] = _reconcile_seed(
|
||||
members=[_member("Alice Dvořák", "A", {"2026-01": (750, 3)})],
|
||||
sorted_months=["2026-01"],
|
||||
transactions=[_tx("2026-01-20", 300, "Alice Dvořák", "2026-01", sender="Alice Dvořák")],
|
||||
# exceptions as list of [name, period, amount, note] (capture_fixtures converts to dict)
|
||||
exceptions=[{"name": "Alice Dvořák", "period": "2026-01", "amount": 300, "note": "injury discount"}],
|
||||
)
|
||||
|
||||
# 07 — other purpose: tournament fee split between Alice and Bob
|
||||
SEEDS[("reconcile", "07_other_purpose_split")] = _reconcile_seed(
|
||||
members=[
|
||||
_member("Alice Dvořák", "A", {"2026-01": (750, 3)}),
|
||||
_member("Bob Kratochvíl", "A", {"2026-01": (750, 2)}),
|
||||
],
|
||||
sorted_months=["2026-01"],
|
||||
transactions=[_tx("2026-01-10", 800, "Alice Dvořák, Bob Kratochvíl", "other:tournament", sender="Alice Dvořák")],
|
||||
)
|
||||
|
||||
# 08 — junior with attendance=1 (expected=0 in reconcile, unknown in UI)
|
||||
SEEDS[("reconcile", "08_junior_question_mark")] = _reconcile_seed(
|
||||
members=[_member("Karel Junior", "A", {"2026-01": (0, 1)})],
|
||||
sorted_months=["2026-01"],
|
||||
transactions=[_tx("2026-01-20", 200, "Karel Junior", "2026-01", sender="Karel Junior")],
|
||||
)
|
||||
|
||||
# 09 — multi-person comma-split: Alice and Bob share a payment for 2 months
|
||||
SEEDS[("reconcile", "09_multiperson_multimonth")] = _reconcile_seed(
|
||||
members=[
|
||||
_member("Alice Dvořák", "A", {"2026-01": (750, 3), "2026-02": (750, 2)}),
|
||||
_member("Bob Kratochvíl", "A", {"2026-01": (750, 2), "2026-02": (350, 2)}),
|
||||
],
|
||||
sorted_months=["2026-01", "2026-02"],
|
||||
transactions=[_tx("2026-02-15", 2000, "Alice Dvořák, Bob Kratochvíl", "2026-01,2026-02", sender="Alice Dvořák")],
|
||||
)
|
||||
|
||||
# 10 — unmatched: no person, garbage message
|
||||
SEEDS[("reconcile", "10_unmatched")] = _reconcile_seed(
|
||||
members=[_member("Alice Dvořák", "A", {"2026-01": (750, 3)})],
|
||||
sorted_months=["2026-01"],
|
||||
transactions=[_tx("2026-01-20", 500, "", "", sender="Unknown Payer", message="garbage xyz 999")],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Real-data seeds
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Indices into tmp/payments_transactions_cache.json['data'] selected for coverage.
|
||||
# DO NOT change these — they are deliberately frozen to make re-runs deterministic.
|
||||
_REAL_PMR_INDICES = [
|
||||
(16, "real_single_leden"),
|
||||
(17, "real_range_prosinec_leden"),
|
||||
(18, "real_list_prosinec_leden_unor"),
|
||||
(22, "real_martin_prosinec_leden"),
|
||||
(23, "real_range_listopad_leden"),
|
||||
(25, "real_filip_prosinec_leden_unor"),
|
||||
(36, "real_mixed_czech_numeric"),
|
||||
(42, "real_dominika_numeric_multi"),
|
||||
# index 67 removed: the name-sweep scrubber changes the text prefix in a way
|
||||
# that breaks the numeric-slash parser (empty result vs expected "2026-03").
|
||||
(72, "real_tomik_numeric_plus"),
|
||||
(73, "real_franc_numeric_space"),
|
||||
(74, "real_jana_numeric_multi"),
|
||||
(80, "real_alex_numeric_long"),
|
||||
(89, "real_emily_numeric_long"),
|
||||
(90, "real_jachym_numeric_multi"),
|
||||
]
|
||||
|
||||
# Real match_members seeds are intentionally omitted: after PII scrubbing
|
||||
# the member_names pseudonyms are inconsistent with the (un-scrubbed) text,
|
||||
# causing all Go parity assertions to fail. The synthetic seeds below cover
|
||||
# the same code paths without any real data.
|
||||
_REAL_MM_INDICES: list = []
|
||||
|
||||
|
||||
def real_parse_month_references_seeds(default_year: int = 2026):
|
||||
"""Yield (case_id, seed) from real cache messages."""
|
||||
cache = _load_cache("payments_transactions_cache.json")
|
||||
if cache is None:
|
||||
return
|
||||
txs = cache.get("data", [])
|
||||
for idx, case_id in _REAL_PMR_INDICES:
|
||||
if idx >= len(txs):
|
||||
continue
|
||||
msg = str(txs[idx].get("message", ""))
|
||||
yield case_id, {"text": msg, "default_year": default_year}
|
||||
|
||||
|
||||
def _real_member_names():
|
||||
"""Return canonical member names from the regular attendance cache."""
|
||||
cache = _load_cache("attendance_regular_cache.json")
|
||||
if cache is None:
|
||||
return []
|
||||
rows = cache.get("data", [])
|
||||
if rows and isinstance(rows[0], list):
|
||||
rows = rows[0]
|
||||
return [row[0] for row in rows if isinstance(row, (list, tuple)) and len(row) >= 2]
|
||||
|
||||
|
||||
def real_match_members_seeds():
|
||||
"""Yield (case_id, seed) using real senders/messages against real roster."""
|
||||
cache = _load_cache("payments_transactions_cache.json")
|
||||
member_names = _real_member_names()
|
||||
if cache is None or not member_names:
|
||||
return
|
||||
txs = cache.get("data", [])
|
||||
for idx, case_id in _REAL_MM_INDICES:
|
||||
if idx >= len(txs):
|
||||
continue
|
||||
tx = txs[idx]
|
||||
sender = str(tx.get("sender", ""))
|
||||
message = str(tx.get("message", ""))
|
||||
text = f"{sender} {message}"
|
||||
yield case_id, {"text": text, "member_names": member_names}
|
||||
@@ -0,0 +1,291 @@
|
||||
"""Shared attendance/fee logic for FUJ Tuesday practices."""
|
||||
|
||||
import csv
|
||||
import io
|
||||
import urllib.request
|
||||
from datetime import datetime
|
||||
|
||||
from config import ATTENDANCE_SHEET_ID as SHEET_ID, JUNIOR_SHEET_GID
|
||||
|
||||
EXPORT_URL = f"https://docs.google.com/spreadsheets/d/{SHEET_ID}/export?format=csv&gid=0"
|
||||
JUNIOR_EXPORT_URL = f"https://docs.google.com/spreadsheets/d/{SHEET_ID}/export?format=csv&gid={JUNIOR_SHEET_GID}"
|
||||
|
||||
ADULT_FEE_DEFAULT = 700 # CZK, for 2+ practices in a month
|
||||
ADULT_FEE_SINGLE = 200 # CZK, for exactly 1 practice in a month
|
||||
ADULT_FEE_MONTHLY_RATE = {
|
||||
"2025-09": 750,
|
||||
"2025-10": 750,
|
||||
"2025-11": 750,
|
||||
"2025-12": 750,
|
||||
"2026-01": 750,
|
||||
"2026-02": 750,
|
||||
"2026-03": 350,
|
||||
"2026-04": 700,
|
||||
"2026-05": 450,
|
||||
"2026-06": 600,
|
||||
"2026-07": 600,
|
||||
"2026-08": 600,
|
||||
}
|
||||
|
||||
JUNIOR_FEE_DEFAULT = 500 # CZK for 2+ practices
|
||||
JUNIOR_MONTHLY_RATE = {
|
||||
"2025-09": 250,
|
||||
"2026-03": 250 # reduced fee for March 2026
|
||||
}
|
||||
ADULT_MERGED_MONTHS = {
|
||||
"2025-09": "2025-10", # keys are merged into values
|
||||
}
|
||||
|
||||
JUNIOR_MERGED_MONTHS = {
|
||||
"2025-12": "2026-01", # keys are merged into values
|
||||
"2025-09": "2025-10"
|
||||
}
|
||||
|
||||
COL_NAME = 0
|
||||
COL_TIER = 1
|
||||
FIRST_DATE_COL = 3
|
||||
|
||||
|
||||
def fetch_csv(url: str = EXPORT_URL) -> list[list[str]]:
|
||||
"""Fetch the attendance Google Sheet as parsed CSV rows."""
|
||||
req = urllib.request.Request(url)
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
text = resp.read().decode("utf-8")
|
||||
reader = csv.reader(io.StringIO(text))
|
||||
return list(reader)
|
||||
|
||||
|
||||
def parse_dates(header_row: list[str]) -> list[tuple[int, datetime]]:
|
||||
"""Return (column_index, date) pairs for all date columns."""
|
||||
dates = []
|
||||
|
||||
for i in range(FIRST_DATE_COL, len(header_row)):
|
||||
raw = header_row[i].strip()
|
||||
if not raw:
|
||||
continue
|
||||
|
||||
try:
|
||||
# Try DD.MM.YYYY
|
||||
dt = datetime.strptime(raw, "%d.%m.%Y")
|
||||
dates.append((i, dt))
|
||||
except ValueError:
|
||||
try:
|
||||
# Fallback to MM/DD/YYYY
|
||||
dt = datetime.strptime(raw, "%m/%d/%Y")
|
||||
dates.append((i, dt))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return dates
|
||||
|
||||
|
||||
def group_by_month(dates: list[tuple[int, datetime]], merged_months: dict[str, str]) -> dict[str, list[int]]:
|
||||
"""Group column indices by YYYY-MM, handling merged months."""
|
||||
months: dict[str, list[int]] = {}
|
||||
for col, dt in dates:
|
||||
key = dt.strftime("%Y-%m")
|
||||
# Apply merged month mapping if configured
|
||||
target_key = merged_months.get(key, key)
|
||||
months.setdefault(target_key, []).append(col)
|
||||
return months
|
||||
|
||||
|
||||
def calculate_fee(attendance_count: int, month_key: str) -> int:
|
||||
"""Apply fee rules: 0 → 0, 1 → 200, 2+ → configured rate (default 750)."""
|
||||
if attendance_count == 0:
|
||||
return 0
|
||||
if attendance_count == 1:
|
||||
return ADULT_FEE_SINGLE
|
||||
return ADULT_FEE_MONTHLY_RATE.get(month_key, ADULT_FEE_DEFAULT)
|
||||
|
||||
|
||||
def calculate_junior_fee(attendance_count: int, month_key: str) -> str | int:
|
||||
"""Apply junior fee rules: 0 → 0, 1 → '?', 2+ → Configured Rate (default 500)."""
|
||||
if attendance_count == 0:
|
||||
return 0
|
||||
if attendance_count == 1:
|
||||
return "?"
|
||||
return JUNIOR_MONTHLY_RATE.get(month_key, JUNIOR_FEE_DEFAULT)
|
||||
|
||||
|
||||
def get_members(rows: list[list[str]]) -> list[tuple[str, str, list[str]]]:
|
||||
"""Parse member rows. Returns list of (name, tier, row).
|
||||
|
||||
Stopped at row where first column contains '# last line'.
|
||||
Skips rows starting with '#'.
|
||||
"""
|
||||
members = []
|
||||
for row in rows[1:]:
|
||||
if not row or len(row) <= COL_NAME:
|
||||
continue
|
||||
|
||||
first_col = row[COL_NAME].strip()
|
||||
|
||||
# Terminator for rows to process
|
||||
if "# last line" in first_col.lower():
|
||||
break
|
||||
|
||||
# Ignore comments
|
||||
if first_col.startswith("#"):
|
||||
continue
|
||||
|
||||
if not first_col or first_col.lower() in ("jméno", "name", "jmeno"):
|
||||
continue
|
||||
|
||||
tier = row[COL_TIER].strip().upper() if len(row) > COL_TIER else ""
|
||||
members.append((first_col, tier, row))
|
||||
return members
|
||||
|
||||
|
||||
def get_junior_members_from_sheet(rows: list[list[str]]) -> list[tuple[str, str, list[str]]]:
|
||||
"""Parse junior member rows from the junior sheet.
|
||||
|
||||
Stopped at row where first column contains '# Treneri'.
|
||||
Returns list of (name, tier, row) for members where tier is not 'X'.
|
||||
"""
|
||||
members = []
|
||||
for row in rows[1:]:
|
||||
if not row or len(row) <= COL_NAME:
|
||||
continue
|
||||
|
||||
first_col = row[COL_NAME].strip()
|
||||
|
||||
# Terminator for rows to process in junior sheet
|
||||
if "# treneri" in first_col.lower() or "# trenéři" in first_col.lower():
|
||||
break
|
||||
|
||||
# Ignore comments
|
||||
if first_col.startswith("#"):
|
||||
continue
|
||||
|
||||
if not first_col or first_col.lower() in ("jméno", "name", "jmeno"):
|
||||
continue
|
||||
|
||||
tier = row[COL_TIER].strip().upper() if len(row) > COL_TIER else ""
|
||||
if tier == "X":
|
||||
continue
|
||||
|
||||
members.append((first_col, tier, row))
|
||||
return members
|
||||
|
||||
|
||||
def get_members_with_fees() -> tuple[list[tuple[str, str, dict[str, int]]], list[str]]:
|
||||
"""Fetch attendance data and compute fees.
|
||||
|
||||
Returns:
|
||||
(members, sorted_months) where members is a list of
|
||||
(name, tier, {month_key: (fee, count)}) for ALL members (all tiers).
|
||||
sorted_months is the list of YYYY-MM keys in order.
|
||||
"""
|
||||
rows = fetch_csv()
|
||||
if len(rows) < 2:
|
||||
return [], []
|
||||
|
||||
header_row = rows[0]
|
||||
dates = parse_dates(header_row)
|
||||
if not dates:
|
||||
return [], []
|
||||
|
||||
months = group_by_month(dates, ADULT_MERGED_MONTHS)
|
||||
sorted_months = sorted(months.keys())
|
||||
members_raw = get_members(rows)
|
||||
|
||||
members = []
|
||||
for name, tier, row in members_raw:
|
||||
month_fees = {}
|
||||
for month_key in sorted_months:
|
||||
cols = months[month_key]
|
||||
count = sum(
|
||||
1
|
||||
for c in cols
|
||||
if c < len(row) and row[c].strip().upper() == "TRUE"
|
||||
)
|
||||
fee = calculate_fee(count, month_key) if tier == "A" else 0
|
||||
month_fees[month_key] = (fee, count)
|
||||
members.append((name, tier, month_fees))
|
||||
|
||||
return members, sorted_months
|
||||
|
||||
|
||||
def get_junior_members_with_fees() -> tuple[list[tuple[str, str, dict[str, tuple[str | int, int, int, int]]]], list[str]]:
|
||||
"""Fetch attendance data from both sheets and compute junior fees.
|
||||
|
||||
Merges members by exact name match.
|
||||
|
||||
Returns:
|
||||
(members, sorted_months) where members is a list of
|
||||
(name, tier, {month_key: (fee, total_count, adult_count, junior_count)}).
|
||||
"""
|
||||
main_rows = fetch_csv(EXPORT_URL)
|
||||
junior_rows = fetch_csv(JUNIOR_EXPORT_URL)
|
||||
|
||||
if len(main_rows) < 2 or len(junior_rows) < 2:
|
||||
return [], []
|
||||
|
||||
main_dates = parse_dates(main_rows[0])
|
||||
junior_dates = parse_dates(junior_rows[0])
|
||||
|
||||
main_months = group_by_month(main_dates, JUNIOR_MERGED_MONTHS)
|
||||
junior_months = group_by_month(junior_dates, JUNIOR_MERGED_MONTHS)
|
||||
|
||||
# Collect all unique sorted months
|
||||
all_months = set(main_months.keys()).union(set(junior_months.keys()))
|
||||
sorted_months = sorted(list(all_months))
|
||||
|
||||
from typing import Any
|
||||
merged_members: dict[str, Any] = {}
|
||||
|
||||
# Process Junior Tier from Main Sheet (Adult Practices)
|
||||
main_members_raw = get_members(main_rows)
|
||||
for name, tier, row in main_members_raw:
|
||||
if tier != "J":
|
||||
continue
|
||||
|
||||
if name not in merged_members:
|
||||
merged_members[name] = {"tier": tier, "months": {}}
|
||||
|
||||
for month_key in sorted_months:
|
||||
if month_key not in merged_members[name]["months"]:
|
||||
merged_members[name]["months"][month_key] = {"adult": 0, "junior": 0}
|
||||
|
||||
cols = main_months.get(month_key, [])
|
||||
adult_count = sum(
|
||||
1
|
||||
for c in cols
|
||||
if c < len(row) and row[c].strip().upper() == "TRUE"
|
||||
)
|
||||
merged_members[name]["months"][month_key]["adult"] += adult_count
|
||||
|
||||
# Process Junior Sheet (Junior Practices)
|
||||
junior_members_raw = get_junior_members_from_sheet(junior_rows)
|
||||
for name, tier, row in junior_members_raw:
|
||||
if name not in merged_members:
|
||||
merged_members[name] = {"tier": tier, "months": {}}
|
||||
|
||||
for month_key in sorted_months:
|
||||
if month_key not in merged_members[name]["months"]:
|
||||
merged_members[name]["months"][month_key] = {"adult": 0, "junior": 0}
|
||||
|
||||
cols = junior_months.get(month_key, [])
|
||||
junior_count = sum(
|
||||
1
|
||||
for c in cols
|
||||
if c < len(row) and row[c].strip().upper() == "TRUE"
|
||||
)
|
||||
merged_members[name]["months"][month_key]["junior"] += junior_count
|
||||
|
||||
# Compile the final result format
|
||||
members = []
|
||||
for name, data in merged_members.items():
|
||||
month_fees = {}
|
||||
for month_key in sorted_months:
|
||||
adult_count = data["months"].get(month_key, {}).get("adult", 0)
|
||||
junior_count = data["months"].get(month_key, {}).get("junior", 0)
|
||||
total_count = adult_count + junior_count
|
||||
|
||||
fee = calculate_junior_fee(total_count, month_key)
|
||||
month_fees[month_key] = (fee, total_count, adult_count, junior_count)
|
||||
|
||||
members.append((name, data["tier"], month_fees))
|
||||
|
||||
return members, sorted_months
|
||||
@@ -0,0 +1,173 @@
|
||||
import json
|
||||
import socket
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from google.oauth2 import service_account
|
||||
from googleapiclient.discovery import build
|
||||
|
||||
from config import (
|
||||
CACHE_DIR, CREDENTIALS_PATH as CREDS_PATH, DRIVE_TIMEOUT,
|
||||
CACHE_TTL_SECONDS, CACHE_API_CHECK_TTL_SECONDS, CACHE_SHEET_MAP,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Global state to track last Drive API check time per sheet
|
||||
_LAST_CHECKED = {}
|
||||
_DRIVE_SERVICE = None
|
||||
|
||||
def _get_drive_service():
|
||||
global _DRIVE_SERVICE
|
||||
if _DRIVE_SERVICE is not None:
|
||||
return _DRIVE_SERVICE
|
||||
|
||||
if not CREDS_PATH.exists():
|
||||
logger.warning(f"Credentials not found at {CREDS_PATH}. Cannot check Google Drive API.")
|
||||
return None
|
||||
|
||||
try:
|
||||
creds = service_account.Credentials.from_service_account_file(
|
||||
str(CREDS_PATH),
|
||||
scopes=["https://www.googleapis.com/auth/drive.readonly"]
|
||||
)
|
||||
|
||||
# Apply timeout safely to the httplib2 connection without mutating global socket
|
||||
import httplib2
|
||||
import google_auth_httplib2
|
||||
http = httplib2.Http(timeout=DRIVE_TIMEOUT)
|
||||
http = google_auth_httplib2.AuthorizedHttp(creds, http=http)
|
||||
|
||||
_DRIVE_SERVICE = build("drive", "v3", http=http, cache_discovery=False)
|
||||
return _DRIVE_SERVICE
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to build Drive API service: {e}")
|
||||
return None
|
||||
|
||||
import time
|
||||
|
||||
def get_sheet_modified_time(cache_key: str) -> str | None:
|
||||
"""Gets the modifiedTime from Google Drive API for a given cache_key.
|
||||
Returns the ISO timestamp string if successful.
|
||||
If the Drive API fails (e.g., lack of permissions for public sheets),
|
||||
it generates a virtual time bucket string to provide a 5-minute TTL cache.
|
||||
"""
|
||||
sheet_id = CACHE_SHEET_MAP.get(cache_key, cache_key)
|
||||
|
||||
cache_file = CACHE_DIR / f"{cache_key}_cache.json"
|
||||
|
||||
# 1. Check if we should skip the Drive API check entirely (global memory TTL)
|
||||
now = time.time()
|
||||
last_check = _LAST_CHECKED.get(sheet_id, 0)
|
||||
|
||||
if CACHE_API_CHECK_TTL_SECONDS > 0 and (now - last_check) < CACHE_API_CHECK_TTL_SECONDS:
|
||||
# We checked recently. Return cached modifiedTime if cache file exists.
|
||||
if cache_file.exists():
|
||||
try:
|
||||
with open(cache_file, "r", encoding="utf-8") as f:
|
||||
cache_data = json.load(f)
|
||||
cached_time = cache_data.get("modifiedTime")
|
||||
if cached_time:
|
||||
logger.info(f"Skipping Drive API check for {sheet_id} due to {CACHE_API_CHECK_TTL_SECONDS}s API check TTL")
|
||||
return cached_time
|
||||
except Exception as e:
|
||||
logger.warning(f"Error reading existing cache during API skip for {sheet_id}: {e}")
|
||||
|
||||
# 2. Check if the cache file is simply too new (legacy check)
|
||||
if CACHE_TTL_SECONDS > 0 and cache_file.exists():
|
||||
try:
|
||||
file_mtime = cache_file.stat().st_mtime
|
||||
if time.time() - file_mtime < CACHE_TTL_SECONDS:
|
||||
with open(cache_file, "r", encoding="utf-8") as f:
|
||||
cache_data = json.load(f)
|
||||
cached_time = cache_data.get("modifiedTime")
|
||||
if cached_time:
|
||||
logger.info(f"Skipping Drive API check for {sheet_id} due to {CACHE_TTL_SECONDS}s max CACHE_TTL")
|
||||
# We consider this a valid check, update the global state
|
||||
_LAST_CHECKED[sheet_id] = now
|
||||
return cached_time
|
||||
except Exception as e:
|
||||
logger.warning(f"Error checking cache TTL for {sheet_id}: {e}")
|
||||
|
||||
def _fallback_ttl():
|
||||
bucket = int(time.time() // 300)
|
||||
return f"ttl-5m-{bucket}"
|
||||
|
||||
logger.info(f"Checking Drive API for {sheet_id}")
|
||||
drive_service = _get_drive_service()
|
||||
if not drive_service:
|
||||
return _fallback_ttl()
|
||||
|
||||
try:
|
||||
file_meta = drive_service.files().get(fileId=sheet_id, fields="modifiedTime", supportsAllDrives=True).execute()
|
||||
# Successfully checked API, update the global state
|
||||
_LAST_CHECKED[sheet_id] = time.time()
|
||||
return file_meta.get("modifiedTime")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not get modifiedTime for sheet {sheet_id}: {e}. Falling back to 5-minute TTL.")
|
||||
return _fallback_ttl()
|
||||
|
||||
def read_cache(sheet_id: str, current_modified_time: str) -> list | dict | None:
|
||||
"""Reads the JSON cache for the given sheet_id.
|
||||
Returns the cached data if it exists AND the cached modifiedTime matches
|
||||
current_modified_time.
|
||||
Otherwise, returns None.
|
||||
"""
|
||||
if not current_modified_time:
|
||||
return None
|
||||
|
||||
cache_file = CACHE_DIR / f"{sheet_id}_cache.json"
|
||||
if not cache_file.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(cache_file, "r", encoding="utf-8") as f:
|
||||
cache_data = json.load(f)
|
||||
|
||||
cached_time = cache_data.get("modifiedTime")
|
||||
if cached_time == current_modified_time:
|
||||
logger.info(f"Cache hit for {sheet_id} ({current_modified_time})")
|
||||
return cache_data.get("data")
|
||||
else:
|
||||
logger.info(f"Cache miss for {sheet_id}. Cached: {cached_time}, Current: {current_modified_time}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to read cache {cache_file}: {e}")
|
||||
return None
|
||||
|
||||
def write_cache(sheet_id: str, modified_time: str, data: list | dict) -> None:
|
||||
"""Writes the data to a JSON cache file with the given modified_time."""
|
||||
if not modified_time:
|
||||
return
|
||||
|
||||
try:
|
||||
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
cache_file = CACHE_DIR / f"{sheet_id}_cache.json"
|
||||
|
||||
cache_data = {
|
||||
"modifiedTime": modified_time,
|
||||
"data": data,
|
||||
"cachedAt": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
with open(cache_file, "w", encoding="utf-8") as f:
|
||||
json.dump(cache_data, f, ensure_ascii=False)
|
||||
|
||||
logger.info(f"Wrote cache for {sheet_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to write cache {sheet_id}: {e}")
|
||||
|
||||
def flush_cache():
|
||||
"""Delete all cache files and reset in-memory state. Returns count of deleted files."""
|
||||
global _DRIVE_SERVICE
|
||||
_LAST_CHECKED.clear()
|
||||
_DRIVE_SERVICE = None
|
||||
|
||||
deleted = 0
|
||||
if CACHE_DIR.exists():
|
||||
for f in CACHE_DIR.glob("*_cache.json"):
|
||||
f.unlink()
|
||||
deleted += 1
|
||||
logger.info(f"Deleted cache file: {f.name}")
|
||||
|
||||
logger.info(f"Cache flushed: {deleted} files deleted, timers reset")
|
||||
return deleted
|
||||
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Calculate monthly fees from the FUJ Tuesday practice attendance sheet."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from attendance import get_members_with_fees
|
||||
|
||||
|
||||
def main():
|
||||
members, sorted_months = get_members_with_fees()
|
||||
if not members:
|
||||
print("No data.")
|
||||
return
|
||||
|
||||
# Filter to adults only for display
|
||||
results = [(name, fees) for name, tier, fees in members if tier == "A"]
|
||||
|
||||
# Format month labels
|
||||
month_labels = {
|
||||
m: datetime.strptime(m, "%Y-%m").strftime("%b %Y") for m in sorted_months
|
||||
}
|
||||
|
||||
# Print table
|
||||
name_width = max((len(r[0]) for r in results), default=20)
|
||||
col_width = 15
|
||||
|
||||
header = f"{'Member':<{name_width}}"
|
||||
for m in sorted_months:
|
||||
header += f" | {month_labels[m]:>{col_width}}"
|
||||
print(header)
|
||||
print("-" * len(header))
|
||||
|
||||
monthly_totals = {m: 0 for m in sorted_months}
|
||||
for name, month_fees in results:
|
||||
line = f"{name:<{name_width}}"
|
||||
for m in sorted_months:
|
||||
fee, count = month_fees[m]
|
||||
monthly_totals[m] += fee
|
||||
cell = f"{fee} CZK ({count})" if count > 0 else "-"
|
||||
line += f" | {cell:>{col_width}}"
|
||||
print(line)
|
||||
|
||||
# Totals row
|
||||
print("-" * len(header))
|
||||
totals_line = f"{'TOTAL':<{name_width}}"
|
||||
for m in sorted_months:
|
||||
cell = f"{monthly_totals[m]} CZK"
|
||||
totals_line += f" | {cell:>{col_width}}"
|
||||
print(totals_line)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+47
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regenerate the full fixture corpus.
|
||||
# Safe to re-run — always overwrites.
|
||||
# Requires: legacy/tmp/*_cache.json present (for real-data seeds for parse_month_references and match_members).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
LEGACY="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
REPO="$(cd "$LEGACY/.." && pwd)"
|
||||
FIXTURES="$REPO/go/tests/fixtures"
|
||||
CAPTURE_CMD="PYTHONPATH=$LEGACY/scripts:$LEGACY python3 $LEGACY/scripts/capture_fixtures.py"
|
||||
SCRUB_CMD="python3 $LEGACY/scripts/scrub_fixtures.py"
|
||||
|
||||
run_func() {
|
||||
local func="$1"
|
||||
local dir="$FIXTURES/pure/$func"
|
||||
mkdir -p "$dir"
|
||||
echo " Capturing $func..."
|
||||
eval "$CAPTURE_CMD --func $func --all" | while IFS= read -r line; do
|
||||
case_id="$(python3 -c "import sys,json; print(json.loads('''$line''')['case'])" 2>/dev/null || \
|
||||
echo "$line" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['case'])")"
|
||||
echo "$line" | python3 "$LEGACY/scripts/scrub_fixtures.py" > "$dir/${case_id}.json"
|
||||
done
|
||||
}
|
||||
|
||||
echo "==> Capturing pure-function fixtures..."
|
||||
|
||||
run_func normalize
|
||||
run_func parse_month_references
|
||||
run_func calculate_fee
|
||||
run_func calculate_junior_fee
|
||||
run_func parse_czk_amount
|
||||
run_func generate_sync_id
|
||||
run_func build_name_variants
|
||||
run_func match_members
|
||||
run_func infer_transaction_details
|
||||
run_func format_date
|
||||
|
||||
echo "==> Capturing reconcile fixtures..."
|
||||
mkdir -p "$FIXTURES/reconcile"
|
||||
eval "$CAPTURE_CMD --func reconcile --all" | while IFS= read -r line; do
|
||||
case_id="$(echo "$line" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['case'])")"
|
||||
echo "$line" | python3 "$LEGACY/scripts/scrub_fixtures.py" > "$FIXTURES/reconcile/${case_id}.json"
|
||||
done
|
||||
|
||||
echo "==> Done. Review with: git diff go/tests/fixtures/"
|
||||
echo "==> Audit PII: git ls-files go/tests/fixtures | xargs grep -l '<real name>' should return zero."
|
||||
@@ -0,0 +1,353 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Capture pure-function output as JSON fixtures for parity testing.
|
||||
|
||||
Each invocation emits exactly one JSON object to stdout.
|
||||
Pipe through scrub_fixtures.py before writing to go/tests/fixtures/.
|
||||
|
||||
Usage:
|
||||
# Single case:
|
||||
python capture_fixtures.py --func normalize --case simple_ascii \\
|
||||
--input-seed simple_ascii | python scrub_fixtures.py \\
|
||||
> go/tests/fixtures/pure/normalize/simple_ascii.json
|
||||
|
||||
# All seeds for a function (newline-delimited JSON, one object per line):
|
||||
python capture_fixtures.py --func normalize --all
|
||||
|
||||
# Feed input from stdin (for ad-hoc cases):
|
||||
echo '{"text":"hello"}' | python capture_fixtures.py --func normalize \\
|
||||
--case adhoc --input-stdin
|
||||
|
||||
See scripts/_fixture_seeds.py for the seed registry.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import os
|
||||
import datetime
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from czech_utils import normalize, parse_month_references
|
||||
from attendance import calculate_fee, calculate_junior_fee
|
||||
from infer_payments import parse_czk_amount
|
||||
from sync_fio_to_sheets import generate_sync_id as _py_generate_sync_id
|
||||
from match_payments import (
|
||||
_build_name_variants,
|
||||
match_members,
|
||||
infer_transaction_details,
|
||||
format_date,
|
||||
reconcile,
|
||||
)
|
||||
from czech_utils import normalize as _norm
|
||||
|
||||
import _fixture_seeds as seeds
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Type-envelope helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _decode_envelope(envelope):
|
||||
"""Convert a {type, value} envelope to a Python value for function calls."""
|
||||
if not isinstance(envelope, dict):
|
||||
return envelope
|
||||
t = envelope.get("type", "raw")
|
||||
v = envelope.get("value")
|
||||
if t == "none":
|
||||
return None
|
||||
if t == "int":
|
||||
return int(v)
|
||||
if t == "float":
|
||||
return float(v)
|
||||
if t == "string":
|
||||
return v
|
||||
return v # raw JSON value (for fields that don't use an envelope)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-function capture implementations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def capture_normalize(inp: dict) -> dict:
|
||||
result = normalize(inp["text"])
|
||||
return {"text": result}
|
||||
|
||||
|
||||
def capture_parse_month_references(inp: dict) -> dict:
|
||||
result = parse_month_references(inp["text"], inp.get("default_year", 2026))
|
||||
return {"months": result}
|
||||
|
||||
|
||||
def capture_calculate_fee(inp: dict) -> dict:
|
||||
result = calculate_fee(inp["attendance_count"], inp["month_key"])
|
||||
return {"fee": result}
|
||||
|
||||
|
||||
def capture_calculate_junior_fee(inp: dict) -> dict:
|
||||
raw = calculate_junior_fee(inp["attendance_count"], inp["month_key"])
|
||||
if raw == "?":
|
||||
return {"value": 0, "unknown": True}
|
||||
return {"value": int(raw), "unknown": False}
|
||||
|
||||
|
||||
def capture_parse_czk_amount(inp: dict) -> dict:
|
||||
val = _decode_envelope(inp["val"])
|
||||
result = parse_czk_amount(val)
|
||||
return {"amount": float(result)}
|
||||
|
||||
|
||||
def capture_generate_sync_id(inp: dict) -> dict:
|
||||
tx_in = inp["tx"]
|
||||
# Build the tx dict that generate_sync_id expects:
|
||||
# amount must be the Python-native type to replicate str(amount) faithfully.
|
||||
tx = {k: v for k, v in tx_in.items() if k != "amount"}
|
||||
tx["amount"] = _decode_envelope(tx_in["amount"])
|
||||
result = _py_generate_sync_id(tx)
|
||||
return {"sync_id": result}
|
||||
|
||||
|
||||
def capture_build_name_variants(inp: dict) -> dict:
|
||||
result = _build_name_variants(inp["full_name"])
|
||||
return {"variants": result}
|
||||
|
||||
|
||||
def capture_match_members(inp: dict) -> dict:
|
||||
matches = match_members(inp["text"], inp["member_names"])
|
||||
return {
|
||||
"matches": [{"name": name, "confidence": conf} for name, conf in matches]
|
||||
}
|
||||
|
||||
|
||||
def capture_infer_transaction_details(inp: dict) -> dict:
|
||||
tx_in = inp["tx"]
|
||||
tx = dict(tx_in)
|
||||
tx["date"] = _decode_envelope(tx_in.get("date"))
|
||||
result = infer_transaction_details(tx, inp["member_names"])
|
||||
return {
|
||||
"matches": [{"name": n, "confidence": c} for n, c in result["members"]],
|
||||
"months": result["months"],
|
||||
"search_text": result.get("search_text", result.get("matched_text", "")),
|
||||
}
|
||||
|
||||
|
||||
def capture_format_date(inp: dict) -> dict:
|
||||
val = _decode_envelope(inp["val"])
|
||||
result = format_date(val)
|
||||
return {"date": result}
|
||||
|
||||
|
||||
def _build_exceptions(exc_list):
|
||||
"""Convert seed exceptions to the dict reconcile() expects.
|
||||
Accepts both the legacy list format [name, period, amount, note] and the
|
||||
new dict format {"name": ..., "period": ..., "amount": ..., "note": ...}."""
|
||||
if not exc_list:
|
||||
return {}
|
||||
result = {}
|
||||
for row in exc_list:
|
||||
if isinstance(row, dict):
|
||||
name = row.get("name", "")
|
||||
period = row.get("period", "")
|
||||
amount = row.get("amount", 0)
|
||||
note = row.get("note", "")
|
||||
else:
|
||||
name, period, amount = row[0], row[1], row[2]
|
||||
note = row[3] if len(row) > 3 else ""
|
||||
result[(_norm(name), _norm(period))] = {"amount": int(amount), "note": note}
|
||||
return result
|
||||
|
||||
|
||||
def _member_fee_dict(fees_raw: dict) -> dict:
|
||||
"""Convert seed fees dict to the form reconcile() expects."""
|
||||
# Seeds store fees as [fee, count] lists (JSON) or (fee, count) tuples.
|
||||
result = {}
|
||||
for month, v in fees_raw.items():
|
||||
if isinstance(v, (list, tuple)) and len(v) == 2:
|
||||
result[month] = (int(v[0]), int(v[1]))
|
||||
else:
|
||||
result[month] = int(v)
|
||||
return result
|
||||
|
||||
|
||||
def _tx_entry_out(tx):
|
||||
"""Convert a reconcile output TxEntry dict to a serializable form."""
|
||||
return {
|
||||
"amount": float(tx.get("amount", 0)),
|
||||
"date": tx.get("date", ""),
|
||||
"sender": tx.get("sender", ""),
|
||||
"message": tx.get("message", ""),
|
||||
"confidence": tx.get("confidence", ""),
|
||||
}
|
||||
|
||||
|
||||
def _other_entry_out(e):
|
||||
return {
|
||||
"amount": float(e.get("amount", 0)),
|
||||
"date": e.get("date", ""),
|
||||
"sender": e.get("sender", ""),
|
||||
"message": e.get("message", ""),
|
||||
"purpose": e.get("purpose", ""),
|
||||
"confidence": e.get("confidence", ""),
|
||||
}
|
||||
|
||||
|
||||
def _month_data_out(md):
|
||||
return {
|
||||
"expected": int(md["expected"]) if isinstance(md["expected"], (int, float)) else 0,
|
||||
"original_expected": int(md["original_expected"]) if isinstance(md.get("original_expected"), (int, float)) else 0,
|
||||
"attendance_count": int(md.get("attendance_count", 0)),
|
||||
"exception": md.get("exception"),
|
||||
"paid": float(md["paid"]),
|
||||
"transactions": [_tx_entry_out(t) for t in md.get("transactions", [])],
|
||||
}
|
||||
|
||||
|
||||
def _unmatched_tx_out(tx):
|
||||
return {
|
||||
"date": tx.get("date", ""),
|
||||
"amount": float(tx.get("amount", 0)),
|
||||
"person": tx.get("person", ""),
|
||||
"purpose": tx.get("purpose", ""),
|
||||
"sender": tx.get("sender", ""),
|
||||
"message": tx.get("message", ""),
|
||||
"bank_id": tx.get("bank_id", ""),
|
||||
}
|
||||
|
||||
|
||||
def capture_reconcile(inp: dict) -> dict:
|
||||
# Convert members from seed format to reconcile() format.
|
||||
# Accepts both the new dict format {"name":..., "tier":..., "fees":{...}}
|
||||
# and the legacy tuple format [name, tier, fees_dict].
|
||||
members_in = inp["members"]
|
||||
members = []
|
||||
for m in members_in:
|
||||
if isinstance(m, dict):
|
||||
name, tier, fees_raw = m["name"], m["tier"], m.get("fees", {})
|
||||
else:
|
||||
name, tier, fees_raw = m[0], m[1], m[2]
|
||||
members.append((name, tier, _member_fee_dict(fees_raw)))
|
||||
|
||||
exceptions = _build_exceptions(inp.get("exceptions") or [])
|
||||
sorted_months = inp["sorted_months"]
|
||||
transactions = inp["transactions"]
|
||||
|
||||
result = reconcile(members, sorted_months, transactions, exceptions)
|
||||
|
||||
members_out = {}
|
||||
for name, mr in result["members"].items():
|
||||
members_out[name] = {
|
||||
"tier": mr["tier"],
|
||||
"months": {m: _month_data_out(md) for m, md in mr["months"].items()},
|
||||
"other_transactions": [_other_entry_out(e) for e in mr.get("other_transactions", [])],
|
||||
"total_balance": int(mr["total_balance"]),
|
||||
}
|
||||
|
||||
return {
|
||||
"members": members_out,
|
||||
"unmatched": [_unmatched_tx_out(tx) for tx in result["unmatched"]],
|
||||
"credits": {k: int(v) for k, v in result["credits"].items()},
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dispatcher
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_DISPATCHERS = {
|
||||
"normalize": capture_normalize,
|
||||
"parse_month_references": capture_parse_month_references,
|
||||
"calculate_fee": capture_calculate_fee,
|
||||
"calculate_junior_fee": capture_calculate_junior_fee,
|
||||
"parse_czk_amount": capture_parse_czk_amount,
|
||||
"generate_sync_id": capture_generate_sync_id,
|
||||
"build_name_variants": capture_build_name_variants,
|
||||
"match_members": capture_match_members,
|
||||
"infer_transaction_details": capture_infer_transaction_details,
|
||||
"format_date": capture_format_date,
|
||||
"reconcile": capture_reconcile,
|
||||
}
|
||||
|
||||
_FUNC_MODULE = {
|
||||
"normalize": "scripts.czech_utils.normalize",
|
||||
"parse_month_references": "scripts.czech_utils.parse_month_references",
|
||||
"calculate_fee": "scripts.attendance.calculate_fee",
|
||||
"calculate_junior_fee": "scripts.attendance.calculate_junior_fee",
|
||||
"parse_czk_amount": "scripts.infer_payments.parse_czk_amount",
|
||||
"generate_sync_id": "scripts.sync_fio_to_sheets.generate_sync_id",
|
||||
"build_name_variants": "scripts.match_payments._build_name_variants",
|
||||
"match_members": "scripts.match_payments.match_members",
|
||||
"infer_transaction_details": "scripts.match_payments.infer_transaction_details",
|
||||
"format_date": "scripts.match_payments.format_date",
|
||||
"reconcile": "scripts.match_payments.reconcile",
|
||||
}
|
||||
|
||||
|
||||
def _emit(func_name: str, case_id: str, inp: dict) -> None:
|
||||
dispatch = _DISPATCHERS[func_name]
|
||||
output = dispatch(inp)
|
||||
doc = {
|
||||
"case": case_id,
|
||||
"func": _FUNC_MODULE[func_name],
|
||||
"captured_at": datetime.date.today().isoformat(),
|
||||
"input": inp,
|
||||
"output": output,
|
||||
}
|
||||
print(json.dumps(doc, ensure_ascii=False))
|
||||
|
||||
|
||||
def _all_seeds(func_name: str):
|
||||
"""Yield (case_id, seed) for all seeds of a function."""
|
||||
for (fn, case_id), seed in seeds.SEEDS.items():
|
||||
if fn == func_name:
|
||||
yield case_id, seed
|
||||
|
||||
# Real-data seeds
|
||||
if func_name == "parse_month_references":
|
||||
yield from seeds.real_parse_month_references_seeds()
|
||||
if func_name == "match_members":
|
||||
yield from seeds.real_match_members_seeds()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Capture pure-function outputs as JSON fixtures."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--func", required=True, choices=list(_DISPATCHERS), help="Function to capture."
|
||||
)
|
||||
group = parser.add_mutually_exclusive_group(required=True)
|
||||
group.add_argument("--case", help="Case ID (file stem). Use with --input-seed or --input-stdin.")
|
||||
group.add_argument("--all", action="store_true", help="Emit all seeds for the function.")
|
||||
parser.add_argument(
|
||||
"--input-seed", metavar="SEED_ID",
|
||||
help="Seed key in _fixture_seeds.SEEDS (required unless --input-stdin or --all).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--input-stdin", action="store_true",
|
||||
help="Read input JSON from stdin instead of seed registry.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.all:
|
||||
for case_id, seed in _all_seeds(args.func):
|
||||
_emit(args.func, case_id, seed)
|
||||
return
|
||||
|
||||
# Single case
|
||||
if args.input_stdin:
|
||||
inp = json.load(sys.stdin)
|
||||
elif args.input_seed:
|
||||
key = (args.func, args.input_seed)
|
||||
if key not in seeds.SEEDS:
|
||||
sys.exit(f"Seed ({args.func!r}, {args.input_seed!r}) not found in _fixture_seeds.SEEDS")
|
||||
inp = seeds.SEEDS[key]
|
||||
else:
|
||||
parser.error("Provide --input-seed SEED_ID or --input-stdin.")
|
||||
|
||||
_emit(args.func, args.case, inp)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Centralized configuration for FUJ management scripts.
|
||||
|
||||
External service IDs, credentials, and tunable parameters.
|
||||
Domain-specific constants (fees, column indices) stay in their respective modules.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Paths
|
||||
PROJECT_ROOT = Path(__file__).parent.parent
|
||||
CREDENTIALS_PATH = Path(os.environ.get(
|
||||
"CREDENTIALS_PATH",
|
||||
str(PROJECT_ROOT / ".secret" / "fuj-management-bot-credentials.json"),
|
||||
))
|
||||
|
||||
# Google Sheets IDs
|
||||
ATTENDANCE_SHEET_ID = "1E2e_gT_K5AwSRCDLDTa2UetZTkHmBOcz0kFbBUNUNBA"
|
||||
PAYMENTS_SHEET_ID = "1Om0YPoDVCH5cV8BrNz5LG5eR5MMU05ypQC7UMN1xn_Y"
|
||||
|
||||
# Attendance sheet tab GIDs
|
||||
JUNIOR_SHEET_GID = "1213318614"
|
||||
|
||||
# Bank accounts — hardcoded list mirroring go/internal/config/config.go.
|
||||
# The entry with primary=True is used for QR codes and as the BANK_ACCOUNT default.
|
||||
ACCOUNTS = [
|
||||
{"iban": "CZ0820100000002502035405", "acct_num": "2502035405", "token_env": "FIO_API_TOKEN_NEW", "primary": True},
|
||||
{"iban": "CZ8520100000002800359168", "acct_num": "2800359168", "token_env": "FIO_API_TOKEN_OLD", "primary": False},
|
||||
]
|
||||
# Resolve API tokens from the environment once at import time.
|
||||
LOADED_ACCOUNTS = [
|
||||
{**a, "token": os.environ.get(a["token_env"], "")}
|
||||
for a in ACCOUNTS
|
||||
]
|
||||
BANK_ACCOUNT = next(a["iban"] for a in ACCOUNTS if a["primary"])
|
||||
|
||||
# Cache settings
|
||||
CACHE_DIR = PROJECT_ROOT / "tmp"
|
||||
DRIVE_TIMEOUT = 10 # seconds
|
||||
CACHE_TTL_SECONDS = int(os.environ.get("CACHE_TTL_SECONDS", 300)) # 5 min default
|
||||
CACHE_API_CHECK_TTL_SECONDS = int(os.environ.get("CACHE_API_CHECK_TTL_SECONDS", 300)) # 5 min default
|
||||
|
||||
# Display settings
|
||||
MONTHS_TO_SHOW = int(os.environ.get("MONTHS_TO_SHOW", 5)) # show last N months; 0 = show all
|
||||
|
||||
# Maps cache keys to their source sheet IDs (used by cache_utils)
|
||||
CACHE_SHEET_MAP = {
|
||||
"attendance_regular": ATTENDANCE_SHEET_ID,
|
||||
"attendance_juniors": ATTENDANCE_SHEET_ID,
|
||||
"exceptions_dict": PAYMENTS_SHEET_ID,
|
||||
"payments_transactions": PAYMENTS_SHEET_ID,
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Czech text utilities — diacritics normalization and month parsing."""
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
CZECH_MONTHS = {
|
||||
"leden": 1, "ledna": 1, "lednu": 1,
|
||||
"unor": 2, "unora": 2, "unoru": 2,
|
||||
"brezen": 3, "brezna": 3, "breznu": 3,
|
||||
"duben": 4, "dubna": 4, "dubnu": 4,
|
||||
"kveten": 5, "kvetna": 5, "kvetnu": 5,
|
||||
"cerven": 6, "cervna": 6, "cervnu": 6,
|
||||
"cervenec": 7, "cervnce": 7, "cervenci": 7,
|
||||
"srpen": 8, "srpna": 8, "srpnu": 8,
|
||||
"zari": 9,
|
||||
"rijen": 10, "rijna": 10, "rijnu": 10,
|
||||
"listopad": 11, "listopadu": 11,
|
||||
"prosinec": 12, "prosince": 12, "prosinci": 12,
|
||||
}
|
||||
|
||||
|
||||
def normalize(text: str) -> str:
|
||||
"""Strip diacritics and lowercase."""
|
||||
nfkd = unicodedata.normalize("NFKD", text)
|
||||
return "".join(c for c in nfkd if not unicodedata.combining(c)).lower()
|
||||
|
||||
|
||||
def parse_month_references(text: str, default_year: int = 2026) -> list[str]:
|
||||
"""Extract YYYY-MM month references from Czech free text.
|
||||
|
||||
Handles:
|
||||
- Czech month names: "leden", "únor", "prosinec" (all declensions)
|
||||
- Numeric: "01/26", "1/2026", "11+12/2025"
|
||||
- Ranges: "listopad-leden" (November through January)
|
||||
- Slash-separated numeric months: "11+12/2025"
|
||||
|
||||
Returns sorted list of unique YYYY-MM strings.
|
||||
"""
|
||||
normalized = normalize(text)
|
||||
results: set[str] = set()
|
||||
|
||||
# Pattern: numeric months with year, e.g. "11+12/2025", "01/26", "1/2026"
|
||||
# Match groups of digits separated by + before a /year
|
||||
numeric_pattern = re.findall(
|
||||
r"([\d+]+)\s*/\s*(\d{2,4})", normalized
|
||||
)
|
||||
for months_part, year_str in numeric_pattern:
|
||||
year = int(year_str)
|
||||
if year < 100:
|
||||
year += 2000
|
||||
for m_str in months_part.split("+"):
|
||||
m_str = m_str.strip()
|
||||
if m_str.isdigit():
|
||||
m = int(m_str)
|
||||
if 1 <= m <= 12:
|
||||
results.add(f"{year:04d}-{m:02d}")
|
||||
|
||||
# Pattern: standalone numeric month.year, e.g. "12.2025"
|
||||
dot_pattern = re.findall(r"(\d{1,2})\s*\.\s*(\d{4})", normalized)
|
||||
for m_str, year_str in dot_pattern:
|
||||
m, year = int(m_str), int(year_str)
|
||||
if 1 <= m <= 12:
|
||||
results.add(f"{year:04d}-{m:02d}")
|
||||
|
||||
# Czech month names — handle ranges like "listopad-leden"
|
||||
# First, find range patterns
|
||||
month_name_re = "|".join(sorted(CZECH_MONTHS.keys(), key=len, reverse=True))
|
||||
range_pattern = re.findall(
|
||||
rf"({month_name_re})\s*-\s*({month_name_re})", normalized
|
||||
)
|
||||
found_in_ranges: set[str] = set()
|
||||
for start_name, end_name in range_pattern:
|
||||
found_in_ranges.add(start_name)
|
||||
found_in_ranges.add(end_name)
|
||||
start_m = CZECH_MONTHS[start_name]
|
||||
end_m = CZECH_MONTHS[end_name]
|
||||
# Walk from start to end, wrapping around December→January
|
||||
m = start_m
|
||||
while True:
|
||||
year = default_year if m >= start_m and start_m > end_m else default_year
|
||||
# If range wraps (e.g. Nov-Jan), months >= start are previous year
|
||||
if start_m > end_m and m >= start_m:
|
||||
year = default_year - 1
|
||||
results.add(f"{year:04d}-{m:02d}")
|
||||
if m == end_m:
|
||||
break
|
||||
m = m % 12 + 1
|
||||
|
||||
# Individual Czech month names (not already part of a range)
|
||||
for match in re.finditer(rf"\b({month_name_re})\b", normalized):
|
||||
name = match.group(1)
|
||||
if name in found_in_ranges:
|
||||
continue
|
||||
m = CZECH_MONTHS[name]
|
||||
# Heuristic: if month > 9 and we're early in the year, it's likely previous year
|
||||
year = default_year
|
||||
if m >= 10:
|
||||
year = default_year - 1
|
||||
results.add(f"{year:04d}-{m:02d}")
|
||||
|
||||
return sorted(results)
|
||||
@@ -0,0 +1,252 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Shared Fio bank fetching utilities."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import urllib.request
|
||||
from datetime import datetime
|
||||
from html.parser import HTMLParser
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Transaction fetching
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class FioTableParser(HTMLParser):
|
||||
"""Parse the second <table class="table"> on the Fio transparent page.
|
||||
|
||||
Columns: Datum | Částka | Typ | Název protiúčtu | Zpráva pro příjemce | KS | VS | SS | Poznámka
|
||||
Indices: 0 1 2 3 4 5 6 7 8
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._table_count = 0
|
||||
self._in_target_table = False
|
||||
self._in_thead = False
|
||||
self._in_row = False
|
||||
self._in_cell = False
|
||||
self._current_row: list[str] = []
|
||||
self._rows: list[list[str]] = []
|
||||
self._cell_text = ""
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
cls = dict(attrs).get("class", "")
|
||||
if tag == "table" and "table" in cls.split():
|
||||
self._table_count += 1
|
||||
if self._table_count == 2:
|
||||
self._in_target_table = True
|
||||
if self._in_target_table:
|
||||
if tag == "thead":
|
||||
self._in_thead = True
|
||||
if tag == "tr" and not self._in_thead:
|
||||
self._in_row = True
|
||||
self._current_row = []
|
||||
if self._in_row and tag in ("td", "th"):
|
||||
self._in_cell = True
|
||||
self._cell_text = ""
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
if self._in_cell and tag in ("td", "th"):
|
||||
self._in_cell = False
|
||||
self._current_row.append(self._cell_text.strip())
|
||||
if tag == "thead":
|
||||
self._in_thead = False
|
||||
if self._in_row and tag == "tr":
|
||||
self._in_row = False
|
||||
if self._current_row:
|
||||
self._rows.append(self._current_row)
|
||||
if tag == "table" and self._in_target_table:
|
||||
self._in_target_table = False
|
||||
|
||||
def handle_data(self, data):
|
||||
if self._in_cell:
|
||||
self._cell_text += data
|
||||
|
||||
def get_rows(self) -> list[list[str]]:
|
||||
return self._rows
|
||||
|
||||
|
||||
# Fio transparent table column indices
|
||||
_COL_DATE = 0
|
||||
_COL_AMOUNT = 1
|
||||
_COL_SENDER = 3
|
||||
_COL_MESSAGE = 4
|
||||
_COL_KS = 5
|
||||
_COL_VS = 6
|
||||
_COL_SS = 7
|
||||
_COL_NOTE = 8
|
||||
|
||||
|
||||
def parse_czech_amount(s: str) -> float | None:
|
||||
"""Parse '1 500,00 CZK' to float."""
|
||||
s = s.replace("\xa0", "").replace(" ", "").replace(",", ".")
|
||||
s = re.sub(r"[A-Za-z]+", "", s).strip()
|
||||
try:
|
||||
return float(s)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def parse_czech_date(s: str) -> str | None:
|
||||
"""Parse a Czech date to 'YYYY-MM-DD'. Accepts 4-digit and 2-digit years
|
||||
with dot or slash separators; Fio's transparent page mixes 'DD.MM.YYYY'
|
||||
and 'DD.MM.YY' in the same response."""
|
||||
s = s.strip()
|
||||
for fmt in ("%d.%m.%Y", "%d/%m/%Y", "%d.%m.%y", "%d/%m/%y"):
|
||||
try:
|
||||
return datetime.strptime(s, fmt).strftime("%Y-%m-%d")
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def fetch_transactions_transparent(
|
||||
date_from: str, date_to: str, account_id: str
|
||||
) -> list[dict]:
|
||||
"""Fetch transactions from Fio transparent account HTML page.
|
||||
|
||||
Args:
|
||||
date_from: D.M.YYYY format
|
||||
date_to: D.M.YYYY format
|
||||
"""
|
||||
url = (
|
||||
f"https://ib.fio.cz/ib/transparent?a={account_id}"
|
||||
f"&f={date_from}&t={date_to}"
|
||||
)
|
||||
req = urllib.request.Request(url)
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
html = resp.read().decode("utf-8")
|
||||
|
||||
parser = FioTableParser()
|
||||
parser.feed(html)
|
||||
rows = parser.get_rows()
|
||||
|
||||
transactions = []
|
||||
for row in rows:
|
||||
if len(row) < 5:
|
||||
continue
|
||||
|
||||
def col(i):
|
||||
return row[i].strip() if i < len(row) else ""
|
||||
|
||||
date_str = parse_czech_date(col(_COL_DATE))
|
||||
amount = parse_czech_amount(col(_COL_AMOUNT))
|
||||
|
||||
if date_str is None or amount is None or amount <= 0:
|
||||
continue
|
||||
|
||||
transactions.append({
|
||||
"date": date_str,
|
||||
"amount": amount,
|
||||
"sender": col(_COL_SENDER),
|
||||
"message": col(_COL_MESSAGE),
|
||||
"ks": col(_COL_KS),
|
||||
"vs": col(_COL_VS),
|
||||
"ss": col(_COL_SS),
|
||||
"note": col(_COL_NOTE),
|
||||
"bank_id": "", # HTML scraping doesn't give stable ID
|
||||
})
|
||||
|
||||
print(f"fio: transparent fetched {len(rows)} raw rows, {len(transactions)} transaction(s) after filtering", file=sys.stderr)
|
||||
return transactions
|
||||
|
||||
|
||||
def fetch_transactions_api(
|
||||
token: str, date_from: str, date_to: str
|
||||
) -> list[dict]:
|
||||
"""Fetch transactions via Fio REST API (JSON).
|
||||
|
||||
Args:
|
||||
token: Fio API token
|
||||
date_from: YYYY-MM-DD format
|
||||
date_to: YYYY-MM-DD format
|
||||
"""
|
||||
url = (
|
||||
f"https://fioapi.fio.cz/v1/rest/periods/{token}"
|
||||
f"/{date_from}/{date_to}/transactions.json"
|
||||
)
|
||||
req = urllib.request.Request(url)
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
transactions = []
|
||||
tx_list = data.get("accountStatement", {}).get("transactionList", {})
|
||||
raw_list = tx_list.get("transaction") or []
|
||||
for tx in raw_list:
|
||||
# Each field is {"value": ..., "name": ..., "id": ...} or null
|
||||
def val(col_id):
|
||||
col = tx.get(f"column{col_id}")
|
||||
return col["value"] if col else ""
|
||||
|
||||
amount = float(val(1) or 0)
|
||||
if amount <= 0:
|
||||
continue # Skip outgoing
|
||||
|
||||
date_raw = val(0) or ""
|
||||
# API returns date as "YYYY-MM-DD+HHMM" or ISO format
|
||||
date_str = date_raw[:10] if date_raw else ""
|
||||
|
||||
transactions.append({
|
||||
"date": date_str,
|
||||
"amount": amount,
|
||||
"sender": str(val(10) or ""), # column10 = sender name
|
||||
"message": str(val(16) or ""), # column16 = message for recipient
|
||||
"vs": str(val(5) or ""), # column5 = VS
|
||||
"ks": str(val(4) or ""), # column4 = KS
|
||||
"ss": str(val(6) or ""), # column6 = SS
|
||||
"user_id": str(val(7) or ""), # column7 = user identification
|
||||
"sender_account": str(val(2) or ""), # column2 = sender account
|
||||
"bank_id": str(val(22) or ""), # column22 = ID operace
|
||||
"currency": str(val(14) or "CZK"), # column14 = Currency
|
||||
})
|
||||
|
||||
print(f"fio: api fetched {len(raw_list)} raw transaction(s), {len(transactions)} after filtering", file=sys.stderr)
|
||||
return transactions
|
||||
|
||||
|
||||
def fetch_transactions_for(account: dict, date_from: str, date_to: str) -> list[dict]:
|
||||
"""Fetch transactions for a single loaded account dict (from config.LOADED_ACCOUNTS).
|
||||
|
||||
Uses the API path if the account has a token, otherwise the transparent scraper.
|
||||
date_from/date_to: YYYY-MM-DD.
|
||||
"""
|
||||
token = (account.get("token") or "").strip()
|
||||
acct_num = account["acct_num"]
|
||||
if token:
|
||||
print(f"fio: account {acct_num}: using authenticated API, window {date_from}..{date_to}", file=sys.stderr)
|
||||
return fetch_transactions_api(token, date_from, date_to)
|
||||
|
||||
print(
|
||||
f"fio: account {acct_num}: using transparent page (no token — expect publishing lag), "
|
||||
f"window {date_from}..{date_to}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
from_dt = datetime.strptime(date_from, "%Y-%m-%d")
|
||||
to_dt = datetime.strptime(date_to, "%Y-%m-%d")
|
||||
return fetch_transactions_transparent(
|
||||
from_dt.strftime("%d.%m.%Y"),
|
||||
to_dt.strftime("%d.%m.%Y"),
|
||||
account_id=acct_num,
|
||||
)
|
||||
|
||||
|
||||
def fetch_transactions_all(
|
||||
date_from: str, date_to: str, accounts: list[dict] | None = None
|
||||
) -> list[dict]:
|
||||
"""Fetch and combine transactions from all configured accounts.
|
||||
|
||||
accounts: list of loaded account dicts (defaults to config.LOADED_ACCOUNTS).
|
||||
Returns a flat list of all transactions across all accounts.
|
||||
"""
|
||||
if accounts is None:
|
||||
from config import LOADED_ACCOUNTS
|
||||
accounts = LOADED_ACCOUNTS
|
||||
all_txns: list[dict] = []
|
||||
for account in accounts:
|
||||
txns = fetch_transactions_for(account, date_from, date_to)
|
||||
print(f"fio: account {account['acct_num']}: {len(txns)} transaction(s)", file=sys.stderr)
|
||||
all_txns.extend(txns)
|
||||
print(f"fio: total {len(all_txns)} transaction(s) across {len(accounts)} account(s)", file=sys.stderr)
|
||||
return all_txns
|
||||
@@ -0,0 +1,200 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Infer 'Person', 'Purpose', and 'Amount' for transactions in Google Sheets."""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
|
||||
# Add the current directory to sys.path to import local modules
|
||||
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from googleapiclient.discovery import build
|
||||
from sync_fio_to_sheets import get_sheets_service, DEFAULT_SPREADSHEET_ID
|
||||
from match_payments import infer_transaction_details, canonical_member_key
|
||||
from attendance import get_members_with_fees, get_junior_members_with_fees
|
||||
|
||||
def parse_czk_amount(val) -> float:
|
||||
"""Parse Czech currency string or handle raw numeric value."""
|
||||
if val is None or val == "":
|
||||
return 0.0
|
||||
if isinstance(val, (int, float)):
|
||||
return float(val)
|
||||
|
||||
val = str(val)
|
||||
# Strip currency symbol and spaces
|
||||
val = val.replace("Kč", "").replace("CZK", "").strip()
|
||||
# Remove thousand separators (often space or dot)
|
||||
# Heuristic: if there's a comma, it's the decimal separator.
|
||||
# If there's a dot, it might be a thousand separator OR decimal separator.
|
||||
if "," in val:
|
||||
# 1.500,00 -> 1500.00
|
||||
val = val.replace(".", "").replace(" ", "").replace(",", ".")
|
||||
else:
|
||||
# 1 500.00 -> 1500.00 or 1.500.00 -> ???
|
||||
# If there are multiple dots, it's thousand separator.
|
||||
if val.count(".") > 1:
|
||||
val = val.replace(".", "").replace(" ", "")
|
||||
# If there's one dot, it might be decimal separator.
|
||||
else:
|
||||
val = val.replace(" ", "")
|
||||
|
||||
try:
|
||||
return float(val)
|
||||
except ValueError:
|
||||
return 0.0
|
||||
|
||||
# Column names as requested by the user
|
||||
COL_MANUAL = "manual fix"
|
||||
COL_PERSON = "Person"
|
||||
COL_PURPOSE = "Purpose"
|
||||
COL_AMOUNT = "Inferred Amount"
|
||||
|
||||
def infer_payments(spreadsheet_id: str, credentials_path: str, dry_run: bool = False):
|
||||
print(f"Connecting to Google Sheets...")
|
||||
service = get_sheets_service(credentials_path)
|
||||
sheet = service.spreadsheets()
|
||||
|
||||
# 1. Fetch all data from the sheet
|
||||
print("Reading sheet data...")
|
||||
result = sheet.values().get(
|
||||
spreadsheetId=spreadsheet_id,
|
||||
range="A1:Z", # Read a broad range to find existing columns
|
||||
valueRenderOption="UNFORMATTED_VALUE"
|
||||
).execute()
|
||||
rows = result.get("values", [])
|
||||
if not rows:
|
||||
print("Sheet is empty.")
|
||||
return
|
||||
|
||||
header = rows[0]
|
||||
|
||||
# Identify indices of existing columns
|
||||
def get_col_index(label):
|
||||
normalized_label = label.lower().strip()
|
||||
for i, h in enumerate(header):
|
||||
if h.lower().strip() == normalized_label:
|
||||
return i
|
||||
return -1
|
||||
|
||||
idx_date = get_col_index("Date")
|
||||
idx_amount_raw = get_col_index("Amount") # Bank Amount
|
||||
idx_sender = get_col_index("Sender")
|
||||
idx_message = get_col_index("Message")
|
||||
idx_vs = get_col_index("VS")
|
||||
|
||||
target_labels = [COL_MANUAL, COL_PERSON, COL_PURPOSE, COL_AMOUNT]
|
||||
|
||||
# Refresh indices
|
||||
idx_manual = get_col_index(COL_MANUAL)
|
||||
idx_inferred_person = get_col_index(COL_PERSON)
|
||||
idx_inferred_purpose = get_col_index(COL_PURPOSE)
|
||||
idx_inferred_amount = get_col_index(COL_AMOUNT)
|
||||
|
||||
if idx_inferred_person == -1 or idx_inferred_purpose == -1 or idx_inferred_amount == -1:
|
||||
print(f"Error: Required columns {target_labels[1:]} not found in sheet.")
|
||||
print(f"Current header: {header}")
|
||||
return
|
||||
|
||||
# 2. Fetch members for matching — union adults + juniors so junior-only
|
||||
# members (e.g. kids not on the adult sheet) are visible to the matcher.
|
||||
print("Fetching member list for matching...")
|
||||
adult_members, _ = get_members_with_fees()
|
||||
junior_members, _ = get_junior_members_with_fees()
|
||||
|
||||
seen: set[str] = set()
|
||||
member_names: list[str] = []
|
||||
for m in adult_members + junior_members:
|
||||
key = canonical_member_key(m[0])
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
member_names.append(m[0])
|
||||
|
||||
# 3. Process rows
|
||||
print("Inferring details for empty rows...")
|
||||
updates = []
|
||||
|
||||
for i, row in enumerate(rows[1:], start=2):
|
||||
# Extend row if it's shorter than existing header
|
||||
while len(row) < len(header):
|
||||
row.append("")
|
||||
|
||||
# Check if already filled (manual override)
|
||||
val_manual = str(row[idx_manual]) if idx_manual != -1 and idx_manual < len(row) else ""
|
||||
val_person = str(row[idx_inferred_person]) if idx_inferred_person < len(row) else ""
|
||||
val_purpose = str(row[idx_inferred_purpose]) if idx_inferred_purpose < len(row) else ""
|
||||
|
||||
if val_manual.strip() or val_person.strip() or val_purpose.strip():
|
||||
continue
|
||||
|
||||
# Prepare transaction dict for matching logic
|
||||
tx = {
|
||||
"date": row[idx_date] if idx_date != -1 and idx_date < len(row) else "",
|
||||
"amount": parse_czk_amount(row[idx_amount_raw]) if idx_amount_raw != -1 and idx_amount_raw < len(row) and row[idx_amount_raw] else 0,
|
||||
"sender": row[idx_sender] if idx_sender != -1 and idx_sender < len(row) else "",
|
||||
"message": row[idx_message] if idx_message != -1 and idx_message < len(row) else "",
|
||||
"vs": row[idx_vs] if idx_vs != -1 and idx_vs < len(row) else "",
|
||||
}
|
||||
|
||||
inference = infer_transaction_details(tx, member_names)
|
||||
|
||||
# Sort members by confidence and add markers
|
||||
peeps = []
|
||||
for name, conf in inference["members"]:
|
||||
prefix = "[?] " if conf == "review" else ""
|
||||
peeps.append(f"{prefix}{name}")
|
||||
|
||||
matched_months = inference["months"]
|
||||
|
||||
if peeps or matched_months:
|
||||
person_val = ", ".join(peeps)
|
||||
purpose_val = ", ".join(matched_months)
|
||||
amount_val = str(tx["amount"]) # For now, use total amount
|
||||
|
||||
print(f"Row {i}: Inferred {person_val} for {purpose_val} ({amount_val} CZK)")
|
||||
|
||||
# Update the row in memory (for terminal output/dry run)
|
||||
row[idx_inferred_person] = person_val
|
||||
row[idx_inferred_purpose] = purpose_val
|
||||
row[idx_inferred_amount] = amount_val
|
||||
|
||||
# Prepare batch update
|
||||
updates.append({
|
||||
"range": f"R{i}C{idx_inferred_person+1}:R{i}C{idx_inferred_amount+1}",
|
||||
"values": [[person_val, purpose_val, amount_val]]
|
||||
})
|
||||
|
||||
if not updates:
|
||||
print("No new inferences to make.")
|
||||
return
|
||||
|
||||
if dry_run:
|
||||
print(f"Dry run: would update {len(updates)} rows.")
|
||||
else:
|
||||
print(f"Applying {len(updates)} updates to the sheet...")
|
||||
body = {
|
||||
"valueInputOption": "USER_ENTERED",
|
||||
"data": updates
|
||||
}
|
||||
sheet.values().batchUpdate(
|
||||
spreadsheetId=spreadsheet_id,
|
||||
body=body
|
||||
).execute()
|
||||
print("Update completed successfully.")
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Infer payment details in Google Sheets.")
|
||||
parser.add_argument("--sheet-id", default=DEFAULT_SPREADSHEET_ID, help="Google Sheet ID")
|
||||
parser.add_argument("--credentials", default="credentials.json", help="Path to Google API credentials JSON")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Print updates without applying them")
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
infer_payments(args.sheet_id, args.credentials, args.dry_run)
|
||||
except Exception as e:
|
||||
print(f"Inference failed: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,712 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Match Fio bank payments against expected attendance fees."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import urllib.request
|
||||
from datetime import datetime, timedelta
|
||||
from html.parser import HTMLParser
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from attendance import get_members_with_fees
|
||||
from czech_utils import normalize, parse_month_references
|
||||
from sync_fio_to_sheets import get_sheets_service, DEFAULT_SPREADSHEET_ID
|
||||
|
||||
|
||||
def canonical_member_key(name: str) -> str:
|
||||
"""Diacritic-, case-, and whitespace-insensitive key for member-name matching.
|
||||
|
||||
Used to resolve `Person`-column values from the payments sheet to canonical
|
||||
attendance-sheet names, tolerating cells like "Maria Maco" vs "Mária Maco".
|
||||
"""
|
||||
return re.sub(r"\s+", " ", normalize(name)).strip()
|
||||
|
||||
|
||||
def _expected_amount(value) -> float:
|
||||
"""Numeric value of an expected fee; non-numeric markers like '?' → 0.
|
||||
|
||||
Juniors with exactly 1 session get expected='?' (manual-review marker).
|
||||
Treat those as 0 for arithmetic so payments become surplus/credit.
|
||||
"""
|
||||
return value if isinstance(value, (int, float)) else 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Name matching
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _build_name_variants(name: str) -> list[str]:
|
||||
"""Build searchable name variants from a member name.
|
||||
|
||||
E.g. 'František Vrbík (Štrúdl)' → ['frantisek vrbik', 'strudl', 'vrbik']
|
||||
"""
|
||||
# Extract nickname from parentheses
|
||||
nickname_match = re.search(r"\(([^)]+)\)", name)
|
||||
nickname = nickname_match.group(1) if nickname_match else ""
|
||||
|
||||
# Base name without nickname
|
||||
base = re.sub(r"\s*\([^)]*\)\s*", " ", name).strip()
|
||||
normalized_base = normalize(base)
|
||||
normalized_nick = normalize(nickname)
|
||||
|
||||
variants = [normalized_base]
|
||||
if normalized_nick:
|
||||
variants.append(normalized_nick)
|
||||
|
||||
# Also add last name alone (for matching in messages)
|
||||
parts = normalized_base.split()
|
||||
if len(parts) >= 2:
|
||||
variants.append(parts[-1]) # last name
|
||||
variants.append(parts[0]) # first name
|
||||
|
||||
return [v for v in variants if len(v) >= 3]
|
||||
|
||||
|
||||
def _word_in(needle: str, haystack: str) -> bool:
|
||||
"""Return True if needle appears as a whole word in haystack."""
|
||||
return bool(re.search(rf"\b{re.escape(needle)}\b", haystack))
|
||||
|
||||
|
||||
def match_members(
|
||||
text: str, member_names: list[str]
|
||||
) -> list[tuple[str, str]]:
|
||||
"""Find members mentioned in text.
|
||||
|
||||
Returns list of (member_name, confidence) where confidence is 'auto' or 'review'.
|
||||
"""
|
||||
normalized_text = normalize(text)
|
||||
|
||||
# Short-circuit: if any member's full canonical name appears verbatim (whole words),
|
||||
# return only those matches and skip all fuzzy/nickname checks. This prevents a
|
||||
# nickname that is a substring of another member's surname from producing false hits.
|
||||
exact_matches = []
|
||||
for name in member_names:
|
||||
variants = _build_name_variants(name)
|
||||
full_name = variants[0] if variants else ""
|
||||
if full_name and _word_in(full_name, normalized_text):
|
||||
exact_matches.append((name, "auto"))
|
||||
if exact_matches:
|
||||
return exact_matches
|
||||
|
||||
matches = []
|
||||
|
||||
for name in member_names:
|
||||
variants = _build_name_variants(name)
|
||||
full_name = variants[0] if variants else ""
|
||||
parts = full_name.split()
|
||||
|
||||
# 1. Full name match (exact sequence) = high confidence
|
||||
if full_name and full_name in normalized_text:
|
||||
matches.append((name, "auto"))
|
||||
continue
|
||||
|
||||
# 2. Both first and last name present (any order) = high confidence
|
||||
if len(parts) >= 2:
|
||||
if _word_in(parts[0], normalized_text) and _word_in(parts[-1], normalized_text):
|
||||
matches.append((name, "auto"))
|
||||
continue
|
||||
|
||||
# 3. Nickname present = high confidence
|
||||
nickname = ""
|
||||
nickname_match = re.search(r"\(([^)]+)\)", name)
|
||||
if nickname_match:
|
||||
nickname = normalize(nickname_match.group(1))
|
||||
if nickname and _word_in(nickname, normalized_text):
|
||||
matches.append((name, "auto"))
|
||||
continue
|
||||
|
||||
# 4. Partial matches = review confidence
|
||||
if len(parts) >= 2:
|
||||
first_name = parts[0]
|
||||
last_name = parts[-1]
|
||||
_COMMON_SURNAMES = {"novak", "novakova", "prach"}
|
||||
|
||||
if len(last_name) >= 4 and last_name not in _COMMON_SURNAMES and _word_in(last_name, normalized_text):
|
||||
matches.append((name, "review"))
|
||||
continue
|
||||
|
||||
if len(first_name) >= 3 and _word_in(first_name, normalized_text):
|
||||
matches.append((name, "review"))
|
||||
continue
|
||||
elif len(parts) == 1:
|
||||
if len(parts[0]) >= 4 and _word_in(parts[0], normalized_text):
|
||||
matches.append((name, "review"))
|
||||
continue
|
||||
|
||||
# --- Filtering ---
|
||||
# If we have any "auto" matches, discard all "review" matches
|
||||
auto_matches = [m for m in matches if m[1] == "auto"]
|
||||
if auto_matches:
|
||||
return auto_matches
|
||||
|
||||
return matches
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reconciliation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def infer_transaction_details(tx: dict, member_names: list[str]) -> dict:
|
||||
"""Infer member(s) and month(s) for a single transaction.
|
||||
|
||||
Returns:
|
||||
{
|
||||
'members': [(name, confidence)],
|
||||
'months': [YYYY-MM],
|
||||
'matched_text': str
|
||||
}
|
||||
"""
|
||||
# Combine sender + message for searching
|
||||
search_text = f"{tx.get('sender', '')} {tx.get('message', '')} {tx.get('user_id', '')}"
|
||||
matched_members = match_members(search_text, member_names)
|
||||
matched_months = parse_month_references(
|
||||
tx.get("message", "") + " " + tx.get("user_id", "")
|
||||
)
|
||||
|
||||
if not matched_members:
|
||||
# Try matching sender name alone with more lenient matching
|
||||
matched_members = match_members(tx.get("sender", ""), member_names)
|
||||
|
||||
if not matched_months:
|
||||
# If no month specified, try to infer from payment date
|
||||
tx_date = tx.get("date")
|
||||
if tx_date:
|
||||
try:
|
||||
if isinstance(tx_date, (int, float)):
|
||||
# Handle Google Sheets serial date
|
||||
dt = datetime(1899, 12, 30) + timedelta(days=tx_date)
|
||||
else:
|
||||
dt = datetime.strptime(str(tx_date), "%Y-%m-%d")
|
||||
# Assume payment is for the current month
|
||||
matched_months = [dt.strftime("%Y-%m")]
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
return {
|
||||
"members": matched_members,
|
||||
"months": matched_months,
|
||||
"search_text": search_text
|
||||
}
|
||||
|
||||
|
||||
def format_date(val) -> str:
|
||||
"""Normalize date from Google Sheet (handles serial numbers and strings)."""
|
||||
if val is None or val == "":
|
||||
return ""
|
||||
|
||||
# Handle Google Sheets serial dates (number of days since 1899-12-30)
|
||||
if isinstance(val, (int, float)):
|
||||
base_date = datetime(1899, 12, 30)
|
||||
dt = base_date + timedelta(days=val)
|
||||
return dt.strftime("%Y-%m-%d")
|
||||
|
||||
val_str = str(val).strip()
|
||||
if not val_str:
|
||||
return ""
|
||||
|
||||
# If already YYYY-MM-DD, return as is
|
||||
if len(val_str) == 10 and val_str[4] == "-" and val_str[7] == "-":
|
||||
return val_str
|
||||
|
||||
return val_str
|
||||
|
||||
def fetch_sheet_data(spreadsheet_id: str, credentials_path: str) -> list[dict]:
|
||||
"""Fetch all rows from the Google Sheet and convert to a list of dicts."""
|
||||
service = get_sheets_service(credentials_path)
|
||||
sheet = service.spreadsheets()
|
||||
|
||||
result = sheet.values().get(
|
||||
spreadsheetId=spreadsheet_id,
|
||||
range="A1:Z",
|
||||
valueRenderOption="UNFORMATTED_VALUE"
|
||||
).execute()
|
||||
rows = result.get("values", [])
|
||||
if not rows:
|
||||
return []
|
||||
|
||||
header = rows[0]
|
||||
def get_col_index(label):
|
||||
normalized_label = label.lower().strip()
|
||||
for i, h in enumerate(header):
|
||||
if h.lower().strip() == normalized_label:
|
||||
return i
|
||||
return -1
|
||||
|
||||
idx_date = get_col_index("Date")
|
||||
idx_amount = get_col_index("Amount")
|
||||
idx_manual = get_col_index("manual fix")
|
||||
idx_person = get_col_index("Person")
|
||||
idx_purpose = get_col_index("Purpose")
|
||||
idx_inferred_amount = get_col_index("Inferred Amount")
|
||||
idx_sender = get_col_index("Sender")
|
||||
idx_message = get_col_index("Message")
|
||||
idx_bank_id = get_col_index("Bank ID")
|
||||
idx_vs = get_col_index("VS")
|
||||
idx_sync_id = get_col_index("Sync ID")
|
||||
|
||||
required = {"Date": idx_date, "Amount": idx_amount, "Person": idx_person, "Purpose": idx_purpose}
|
||||
missing = [name for name, idx in required.items() if idx == -1]
|
||||
if missing:
|
||||
raise ValueError(f"Required columns missing from payments sheet: {', '.join(missing)}. Found headers: {header}")
|
||||
|
||||
transactions = []
|
||||
for row in rows[1:]:
|
||||
def get_val(idx):
|
||||
return row[idx] if idx != -1 and idx < len(row) else ""
|
||||
|
||||
def get_str(idx):
|
||||
v = get_val(idx)
|
||||
if isinstance(v, float) and v.is_integer():
|
||||
return str(int(v))
|
||||
return str(v)
|
||||
|
||||
def get_float(idx):
|
||||
v = get_val(idx)
|
||||
if isinstance(v, (int, float)):
|
||||
return float(v)
|
||||
try:
|
||||
return float(str(v).strip())
|
||||
except (ValueError, TypeError):
|
||||
return 0.0
|
||||
|
||||
tx = {
|
||||
"date": format_date(get_val(idx_date)),
|
||||
"amount": get_float(idx_amount),
|
||||
"manual_fix": get_val(idx_manual),
|
||||
"person": get_val(idx_person),
|
||||
"purpose": get_val(idx_purpose),
|
||||
"inferred_amount": get_val(idx_inferred_amount),
|
||||
"sender": get_val(idx_sender),
|
||||
"vs": get_str(idx_vs),
|
||||
"message": get_str(idx_message),
|
||||
"bank_id": get_val(idx_bank_id),
|
||||
"sync_id": get_val(idx_sync_id),
|
||||
}
|
||||
transactions.append(tx)
|
||||
|
||||
return transactions
|
||||
|
||||
|
||||
def fetch_exceptions(spreadsheet_id: str, credentials_path: str) -> dict[tuple[str, str], dict]:
|
||||
"""Fetch manual fee overrides from the 'exceptions' sheet.
|
||||
|
||||
Returns a dict mapping (member_name, period_YYYYMM) to {'amount': int, 'note': str}.
|
||||
"""
|
||||
service = get_sheets_service(credentials_path)
|
||||
try:
|
||||
result = service.spreadsheets().values().get(
|
||||
spreadsheetId=spreadsheet_id,
|
||||
range="'exceptions'!A2:D",
|
||||
valueRenderOption="UNFORMATTED_VALUE"
|
||||
).execute()
|
||||
rows = result.get("values", [])
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not fetch exceptions: {e}")
|
||||
return {}
|
||||
|
||||
exceptions = {}
|
||||
for row in rows:
|
||||
if len(row) < 3 or str(row[0]).lower().startswith("name"):
|
||||
continue
|
||||
|
||||
name = str(row[0]).strip()
|
||||
period = str(row[1]).strip()
|
||||
# Robust normalization using czech_utils.normalize
|
||||
norm_name = normalize(name)
|
||||
norm_period = normalize(period)
|
||||
|
||||
try:
|
||||
amount = int(row[2])
|
||||
note = str(row[3]).strip() if len(row) > 3 else ""
|
||||
exceptions[(norm_name, norm_period)] = {"amount": amount, "note": note}
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
|
||||
return exceptions
|
||||
|
||||
|
||||
def reconcile(
|
||||
members: list[tuple[str, str, dict[str, int]]],
|
||||
sorted_months: list[str],
|
||||
transactions: list[dict],
|
||||
exceptions: dict[tuple[str, str], dict] = None,
|
||||
) -> dict:
|
||||
"""Match transactions to members and months.
|
||||
|
||||
Returns a dict with:
|
||||
- 'members': {name: {'tier': str, 'months': {YYYY-MM: {'expected': int, 'paid': int, 'transactions': list}}}}
|
||||
- 'unmatched': list of transactions that couldn't be matched
|
||||
- 'credits': {name: int} — excess payments tracked as credit
|
||||
"""
|
||||
member_names = [name for name, _, _ in members]
|
||||
member_tiers = {name: tier for name, tier, _ in members}
|
||||
member_fees = {name: fees for name, _, fees in members}
|
||||
|
||||
# Map canonical key → first attendance-sheet name with that key, so a
|
||||
# `Person` cell that drifts in diacritics/case/whitespace still resolves.
|
||||
canonical_by_key: dict[str, str] = {}
|
||||
for name in member_names:
|
||||
canonical_by_key.setdefault(canonical_member_key(name), name)
|
||||
|
||||
# Initialize ledger
|
||||
ledger: dict[str, dict[str, dict]] = {}
|
||||
other_ledger: dict[str, list] = {}
|
||||
exceptions = exceptions or {}
|
||||
for name in member_names:
|
||||
ledger[name] = {}
|
||||
other_ledger[name] = []
|
||||
for m in sorted_months:
|
||||
# Robust normalization for lookup
|
||||
norm_name = normalize(name)
|
||||
norm_period = normalize(m)
|
||||
fee_data = member_fees[name].get(m, (0, 0))
|
||||
original_expected = fee_data[0] if isinstance(fee_data, (tuple, list)) else fee_data
|
||||
attendance_count = fee_data[1] if isinstance(fee_data, (tuple, list)) else 0
|
||||
|
||||
ex_data = exceptions.get((norm_name, norm_period))
|
||||
if ex_data is not None:
|
||||
expected = ex_data["amount"]
|
||||
exception_info = ex_data
|
||||
else:
|
||||
expected = original_expected
|
||||
exception_info = None
|
||||
|
||||
ledger[name][m] = {
|
||||
"expected": expected,
|
||||
"original_expected": original_expected,
|
||||
"attendance_count": attendance_count,
|
||||
"exception": exception_info,
|
||||
"paid": 0,
|
||||
"transactions": [],
|
||||
}
|
||||
|
||||
unmatched = []
|
||||
credits: dict[str, int] = {}
|
||||
|
||||
for tx in transactions:
|
||||
# Use sheet columns if they exist, otherwise fallback to inference
|
||||
person_str = str(tx.get("person", "")).strip()
|
||||
purpose_str = str(tx.get("purpose", "")).strip()
|
||||
|
||||
# Strip markers like [?]
|
||||
person_str = re.sub(r"\[\?\]\s*", "", person_str)
|
||||
is_other = purpose_str.lower().startswith("other:")
|
||||
|
||||
if person_str and purpose_str:
|
||||
# We have pre-matched data (either from script or manual)
|
||||
# Support multiple people/months in the comma-separated string
|
||||
matched_members = [(p.strip(), "auto") for p in person_str.split(",") if p.strip()]
|
||||
matched_months = [purpose_str] if is_other else [m.strip() for m in purpose_str.split(",") if m.strip()]
|
||||
|
||||
# Use Inferred Amount if available, otherwise bank Amount
|
||||
amount = tx.get("inferred_amount")
|
||||
if amount is None or amount == "":
|
||||
amount = tx.get("amount", 0)
|
||||
try:
|
||||
amount = float(amount)
|
||||
except (ValueError, TypeError):
|
||||
amount = 0
|
||||
else:
|
||||
# Fallback to inference (for rows not yet processed by infer_payments.py)
|
||||
inference = infer_transaction_details(tx, member_names)
|
||||
matched_members = inference["members"]
|
||||
matched_months = inference["months"]
|
||||
amount = tx.get("amount", 0)
|
||||
try:
|
||||
amount = float(amount)
|
||||
except (ValueError, TypeError):
|
||||
amount = 0
|
||||
|
||||
if not matched_members or not matched_months:
|
||||
unmatched.append(tx)
|
||||
continue
|
||||
|
||||
# Allocate payment across matched members and months
|
||||
if is_other:
|
||||
num_allocations = len(matched_members)
|
||||
per_allocation = amount / num_allocations if num_allocations > 0 else 0
|
||||
for raw_member_name, confidence in matched_members:
|
||||
member_name = canonical_by_key.get(canonical_member_key(raw_member_name))
|
||||
if member_name is not None:
|
||||
other_ledger[member_name].append({
|
||||
"amount": per_allocation,
|
||||
"date": tx["date"],
|
||||
"sender": tx["sender"],
|
||||
"message": tx["message"],
|
||||
"purpose": purpose_str,
|
||||
"confidence": confidence,
|
||||
})
|
||||
continue
|
||||
|
||||
member_share = amount / len(matched_members) if matched_members else 0
|
||||
|
||||
for raw_member_name, confidence in matched_members:
|
||||
member_name = canonical_by_key.get(canonical_member_key(raw_member_name))
|
||||
if member_name is None:
|
||||
logger.warning(
|
||||
"Payment matched to unknown member %r (tx: %s, %s) — adding to unmatched",
|
||||
raw_member_name, tx.get("date", "?"), tx.get("message", "?"),
|
||||
)
|
||||
unmatched.append(tx)
|
||||
continue
|
||||
if member_name != raw_member_name:
|
||||
logger.info(
|
||||
"Person cell %r resolved to canonical member %r — consider fixing the sheet",
|
||||
raw_member_name, member_name,
|
||||
)
|
||||
|
||||
in_window = [(m, ledger[member_name][m]["expected"]) for m in matched_months if m in ledger[member_name]]
|
||||
out_of_window = [m for m in matched_months if m not in ledger[member_name]]
|
||||
|
||||
# Out-of-window months (outside display range): even split → credit, same as before.
|
||||
n_total = len(matched_months)
|
||||
if out_of_window and n_total > 0:
|
||||
out_credit = member_share / n_total * len(out_of_window)
|
||||
credits[member_name] = credits.get(member_name, 0) + int(out_credit)
|
||||
else:
|
||||
out_credit = 0.0
|
||||
|
||||
in_window_share = member_share - out_credit
|
||||
|
||||
if not in_window:
|
||||
continue
|
||||
|
||||
total_expected = sum(_expected_amount(e) for _, e in in_window)
|
||||
|
||||
if total_expected > 0:
|
||||
# Fill-first: iterate in_window in matched_months order (chronological by
|
||||
# convention from infer_payments.py), allocating min(remaining, deficit) to
|
||||
# each month. Deficit is net of what prior transactions already paid, so a
|
||||
# second payment on the same months correctly fills only what remains due.
|
||||
# Any surplus after all deficits are covered goes to the credit bucket.
|
||||
remaining = in_window_share
|
||||
for m, exp in in_window:
|
||||
paid_so_far = ledger[member_name][m]["paid"]
|
||||
deficit = max(0.0, float(_expected_amount(exp)) - paid_so_far)
|
||||
alloc = min(remaining, deficit)
|
||||
if alloc <= 0:
|
||||
continue
|
||||
ledger[member_name][m]["paid"] += alloc
|
||||
ledger[member_name][m]["transactions"].append({
|
||||
"amount": alloc,
|
||||
"date": tx["date"],
|
||||
"sender": tx["sender"],
|
||||
"message": tx["message"],
|
||||
"confidence": confidence,
|
||||
})
|
||||
remaining -= alloc
|
||||
if remaining > 0:
|
||||
credits[member_name] = credits.get(member_name, 0) + int(remaining)
|
||||
else:
|
||||
# Fallback: no expected fees (prepayment before attendance recorded); even split.
|
||||
per_month = in_window_share / len(in_window)
|
||||
for m, _ in in_window:
|
||||
ledger[member_name][m]["paid"] += per_month
|
||||
ledger[member_name][m]["transactions"].append({
|
||||
"amount": per_month,
|
||||
"date": tx["date"],
|
||||
"sender": tx["sender"],
|
||||
"message": tx["message"],
|
||||
"confidence": confidence,
|
||||
})
|
||||
|
||||
# Calculate final total balances (window + off-window credits)
|
||||
final_balances: dict[str, int] = {}
|
||||
for name in member_names:
|
||||
window_balance = sum(
|
||||
int(mdata["paid"]) - _expected_amount(mdata["expected"])
|
||||
for mdata in ledger[name].values()
|
||||
)
|
||||
final_balances[name] = window_balance + credits.get(name, 0)
|
||||
|
||||
return {
|
||||
"members": {
|
||||
name: {
|
||||
"tier": member_tiers[name],
|
||||
"months": ledger[name],
|
||||
"other_transactions": other_ledger[name],
|
||||
"total_balance": final_balances[name]
|
||||
}
|
||||
for name in member_names
|
||||
},
|
||||
"unmatched": unmatched,
|
||||
"credits": final_balances, # Redefine credits as any positive total balance
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Report output
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def print_report(result: dict, sorted_months: list[str]):
|
||||
month_labels = {
|
||||
m: datetime.strptime(m, "%Y-%m").strftime("%b %Y") for m in sorted_months
|
||||
}
|
||||
|
||||
# --- Per-member breakdown (adults only) ---
|
||||
print("=" * 80)
|
||||
print("PAYMENT RECONCILIATION REPORT")
|
||||
print("=" * 80)
|
||||
|
||||
adults = {
|
||||
name: data
|
||||
for name, data in result["members"].items()
|
||||
if data["tier"] == "A"
|
||||
}
|
||||
|
||||
total_expected = 0
|
||||
total_paid = 0
|
||||
|
||||
# Summary table
|
||||
name_width = max((len(n) for n in adults), default=20)
|
||||
header = f"{'Member':<{name_width}}"
|
||||
for m in sorted_months:
|
||||
header += f" | {month_labels[m]:>10}"
|
||||
header += " | {'Balance':>10}"
|
||||
print(f"\n{'Member':<{name_width}}", end="")
|
||||
for m in sorted_months:
|
||||
print(f" | {month_labels[m]:>10}", end="")
|
||||
print(f" | {'Balance':>10}")
|
||||
print("-" * (name_width + (len(sorted_months) + 1) * 13))
|
||||
|
||||
for name in sorted(adults.keys()):
|
||||
data = adults[name]
|
||||
line = f"{name:<{name_width}}"
|
||||
member_balance = 0
|
||||
for m in sorted_months:
|
||||
mdata = data["months"].get(m, {"expected": 0, "paid": 0})
|
||||
expected = mdata["expected"]
|
||||
original = mdata["original_expected"]
|
||||
paid = int(mdata["paid"])
|
||||
total_expected += expected
|
||||
total_paid += paid
|
||||
|
||||
cell_status = ""
|
||||
if expected == 0 and paid == 0:
|
||||
cell = "-"
|
||||
elif paid >= expected and expected > 0:
|
||||
cell = "OK"
|
||||
elif paid > 0:
|
||||
cell = f"{paid}/{expected}"
|
||||
else:
|
||||
cell = f"UNPAID {expected}"
|
||||
|
||||
member_balance += paid - expected
|
||||
line += f" | {cell:>10}"
|
||||
balance_str = f"{member_balance:+d}" if member_balance != 0 else "0"
|
||||
line += f" | {balance_str:>10}"
|
||||
print(line)
|
||||
|
||||
print("-" * (name_width + (len(sorted_months) + 1) * 13))
|
||||
print(f"{'TOTAL':<{name_width}}", end="")
|
||||
for _ in sorted_months:
|
||||
print(f" | {'':>10}", end="")
|
||||
balance = total_paid - total_expected
|
||||
print(f" | {f'Expected: {total_expected}, Paid: {int(total_paid)}, Balance: {balance:+d}'}")
|
||||
|
||||
# --- Credits (Total Surplus) ---
|
||||
all_credits = {
|
||||
name: data["total_balance"]
|
||||
for name, data in result["members"].items()
|
||||
if data["total_balance"] > 0
|
||||
}
|
||||
|
||||
if all_credits:
|
||||
print(f"\n{'TOTAL CREDITS (advance payments or surplus):'}")
|
||||
for name, amount in sorted(all_credits.items()):
|
||||
print(f" {name}: {amount} CZK")
|
||||
|
||||
# --- Debts (Missing Payments) ---
|
||||
all_debts = {
|
||||
name: data["total_balance"]
|
||||
for name, data in result["members"].items()
|
||||
if data["total_balance"] < 0
|
||||
}
|
||||
|
||||
if all_debts:
|
||||
print(f"\n{'TOTAL DEBTS (missing payments):'}")
|
||||
for name, amount in sorted(all_debts.items()):
|
||||
print(f" {name}: {abs(amount)} CZK")
|
||||
|
||||
# --- Unmatched transactions ---
|
||||
if result["unmatched"]:
|
||||
print(f"\n{'UNMATCHED TRANSACTIONS (need manual review)':}")
|
||||
print(f" {'Date':<12} {'Amount':>10} {'Sender':<30} {'Message'}")
|
||||
print(f" {'-'*12} {'-'*10} {'-'*30} {'-'*30}")
|
||||
for tx in result["unmatched"]:
|
||||
print(
|
||||
f" {tx['date']:<12} {tx['amount']:>10.0f} "
|
||||
f"{tx['sender']:<30} {tx['message']}"
|
||||
)
|
||||
|
||||
# --- Detailed matched transactions ---
|
||||
print(f"\n{'MATCHED TRANSACTION DETAILS':}")
|
||||
for name in sorted(adults.keys()):
|
||||
data = adults[name]
|
||||
has_payments = any(
|
||||
data["months"].get(m, {}).get("transactions")
|
||||
for m in sorted_months
|
||||
)
|
||||
if not has_payments:
|
||||
continue
|
||||
print(f"\n {name}:")
|
||||
for m in sorted_months:
|
||||
mdata = data["months"].get(m, {})
|
||||
for tx in mdata.get("transactions", []):
|
||||
conf = " [REVIEW]" if tx["confidence"] == "review" else ""
|
||||
print(
|
||||
f" {month_labels[m]}: {tx['amount']:.0f} CZK "
|
||||
f"from {tx['sender']} — \"{tx['message']}\"{conf}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Match bank payments against expected attendance fees."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--sheet-id", default=DEFAULT_SPREADSHEET_ID, help="Google Sheet ID"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--credentials", default=".secret/fuj-management-bot-credentials.json",
|
||||
help="Path to Google API credentials JSON"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--bank", action="store_true", help="Scrape bank instead of using Sheet data"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
print(f"Fetching attendance data...")
|
||||
members, sorted_months = get_members_with_fees()
|
||||
if not members:
|
||||
print("No attendance data found.")
|
||||
return
|
||||
|
||||
if args.bank:
|
||||
print(f"Fetching transactions from Fio bank ({args.date_from} to {args.date_to})...")
|
||||
from fio_utils import fetch_transactions
|
||||
transactions = fetch_transactions(args.date_from, args.date_to)
|
||||
else:
|
||||
print(f"Fetching transactions from Google Sheet ({args.sheet_id})...")
|
||||
transactions = fetch_sheet_data(args.sheet_id, args.credentials)
|
||||
|
||||
print(f"Processing {len(transactions)} transactions.\n")
|
||||
|
||||
exceptions = fetch_exceptions(args.sheet_id, args.credentials)
|
||||
if exceptions:
|
||||
print(f"Loaded {len(exceptions)} fee exceptions.")
|
||||
|
||||
result = reconcile(members, sorted_months, transactions, exceptions)
|
||||
print_report(result, sorted_months)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,330 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Scrub PII from fixture JSON.
|
||||
|
||||
Reads one JSON fixture from stdin (as produced by capture_fixtures.py),
|
||||
replaces PII fields with deterministic pseudonyms, writes scrubbed JSON
|
||||
to stdout.
|
||||
|
||||
Run in the two-step pipeline:
|
||||
python capture_fixtures.py ... | python scrub_fixtures.py > fixture.json
|
||||
|
||||
Or process multiple lines (--multi for newline-delimited input):
|
||||
python capture_fixtures.py --func foo --all | python scrub_fixtures.py --multi \\
|
||||
| while read line; do ...
|
||||
|
||||
PII handling:
|
||||
- Member names: replaced with Member_<8hex> (sha256(name)[:8]), deterministic.
|
||||
- Senders / account numbers / VS / bank_id / user_id: stable digit-preserving hash.
|
||||
- Notes (exception text): replaced with "<scrubbed>".
|
||||
- Messages: name-substring sweep applied; rest preserved.
|
||||
- All other fields (dates, amounts, months, fees): preserved verbatim.
|
||||
|
||||
Function-specific exceptions:
|
||||
- match_members / infer_transaction_details: these functions are tested with
|
||||
synthetic member names only. Only real-roster message sweeping is applied;
|
||||
field-key scrubbing is skipped so Go can perform genuine name matching.
|
||||
- generate_sync_id: after normal field-key scrubbing the output sync_id is
|
||||
recomputed from the now-scrubbed inputs so the hash remains consistent.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
_REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bijection helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _sha256_hex(s: str) -> str:
|
||||
return hashlib.sha256(s.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def scrub_name(name: str) -> str:
|
||||
"""Deterministic pseudonym for a member name."""
|
||||
if not name:
|
||||
return name
|
||||
return f"Member_{_sha256_hex(name)[:8]}"
|
||||
|
||||
|
||||
def scrub_id_digits(s: str) -> str:
|
||||
"""Length-preserving digit hash for VS, bank_id, user_id, etc."""
|
||||
s = str(s)
|
||||
if not s:
|
||||
return s
|
||||
if re.match(r"^\d+$", s):
|
||||
n = len(s)
|
||||
hashed = int(_sha256_hex(s), 16) % (10 ** n)
|
||||
return f"{hashed:0{n}d}"
|
||||
return f"id_{_sha256_hex(s)[:8]}"
|
||||
|
||||
|
||||
def scrub_account(s: str) -> str:
|
||||
"""Preserve Czech bank account format PREFIX/BANKCODE."""
|
||||
s = str(s)
|
||||
if not s:
|
||||
return s
|
||||
m = re.match(r"^(\d+)/(\d{4})$", s)
|
||||
if m:
|
||||
prefix, bank = m.group(1), m.group(2)
|
||||
n = len(prefix)
|
||||
new_prefix = int(_sha256_hex(prefix), 16) % (10 ** n)
|
||||
new_bank = int(_sha256_hex(bank), 16) % 10000
|
||||
return f"{new_prefix:0{n}d}/{new_bank:04d}"
|
||||
return scrub_id_digits(s)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Name roster for message sweeps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _load_member_names() -> list[str]:
|
||||
"""Load canonical names from the attendance cache (may not exist)."""
|
||||
path = os.path.join(_REPO, "tmp", "attendance_regular_cache.json")
|
||||
if not os.path.exists(path):
|
||||
return []
|
||||
try:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
cache = json.load(f)
|
||||
rows = cache.get("data", [])
|
||||
if rows and isinstance(rows[0], list):
|
||||
rows = rows[0]
|
||||
names = []
|
||||
for row in rows:
|
||||
if isinstance(row, (list, tuple)) and len(row) >= 1:
|
||||
names.append(str(row[0]))
|
||||
return names
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _build_name_map(names: list[str]) -> dict[str, str]:
|
||||
"""Map each real name (and its normalized form) to its pseudonym."""
|
||||
mapping: dict[str, str] = {}
|
||||
for name in names:
|
||||
pseudo = scrub_name(name)
|
||||
mapping[name] = pseudo
|
||||
# Also add first+last without parenthetical nicknames
|
||||
base = re.sub(r"\s*\([^)]*\)\s*", " ", name).strip()
|
||||
if base != name:
|
||||
mapping[base] = pseudo
|
||||
return mapping
|
||||
|
||||
|
||||
def _sweep_names_in_text(text: str, name_map: dict[str, str]) -> str:
|
||||
"""Replace real-name substrings in free text, longest match first."""
|
||||
# Sort descending by length so longer names replace before their substrings
|
||||
for real in sorted(name_map, key=len, reverse=True):
|
||||
if real and real in text:
|
||||
text = text.replace(real, name_map[real])
|
||||
return text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scramble whitelist — only these keys are scrambled; everything else is kept
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_SCRAMBLE_KEYS = {
|
||||
"name",
|
||||
"member_names",
|
||||
"person",
|
||||
"sender",
|
||||
"sender_account",
|
||||
"account",
|
||||
"vs",
|
||||
"bank_id",
|
||||
"user_id",
|
||||
"note",
|
||||
}
|
||||
|
||||
# Dict keys whose *child keys* (not values) are member names and need scrubbing.
|
||||
# e.g. the reconcile output: {"members": {"Alice Dvořák": {...}}, "credits": {"Alice Dvořák": 0}}
|
||||
_MEMBER_KEY_DICTS = {"members", "credits"}
|
||||
|
||||
_MESSAGE_KEYS = {"message", "text", "search_text"}
|
||||
|
||||
|
||||
def _scrub_value(key: str, value: Any, name_map: dict[str, str]) -> Any:
|
||||
"""Scrub a single value based on its field key."""
|
||||
if isinstance(value, list):
|
||||
if key == "member_names":
|
||||
return [scrub_name(str(v)) for v in value]
|
||||
# Don't propagate parent key into list elements — each element is an
|
||||
# independent document. Propagating would incorrectly flag nested dicts
|
||||
# (e.g. the fees dict inside a member tuple) as member-name-keyed dicts.
|
||||
return [_scrub_doc(v, name_map) for v in value]
|
||||
if isinstance(value, dict):
|
||||
# Pass the current key as parent context so dicts like
|
||||
# {"members": {"Real Name": ...}} get their keys scrubbed too.
|
||||
return _scrub_doc(value, name_map, _parent_key=key)
|
||||
if key not in _SCRAMBLE_KEYS and key not in _MESSAGE_KEYS:
|
||||
return value
|
||||
if not isinstance(value, str):
|
||||
value = str(value)
|
||||
if key in _MESSAGE_KEYS:
|
||||
return _sweep_names_in_text(value, name_map)
|
||||
if key == "name":
|
||||
return scrub_name(value)
|
||||
if key in ("sender_account", "account"):
|
||||
return scrub_account(value)
|
||||
if key == "note":
|
||||
return "<scrubbed>"
|
||||
if key == "person":
|
||||
# "person" may contain comma-separated member names (e.g. "Alice, Bob").
|
||||
# Sweep with name_map so each name gets its own consistent pseudonym,
|
||||
# matching what the output.members keys will look like.
|
||||
return _sweep_names_in_text(value, name_map) if value else value
|
||||
# vs, bank_id, user_id, sender
|
||||
return scrub_id_digits(value) if re.match(r"^\d+$", value) else scrub_name(value) if value else value
|
||||
|
||||
|
||||
def _scrub_doc(doc: Any, name_map: dict[str, str], _parent_key: str = "") -> Any:
|
||||
"""Recursively scrub a JSON document."""
|
||||
if isinstance(doc, dict):
|
||||
if _parent_key in _MEMBER_KEY_DICTS:
|
||||
# Keys of this dict are member names — scrub the keys and recurse.
|
||||
return {
|
||||
scrub_name(k): _scrub_doc(v, name_map)
|
||||
for k, v in doc.items()
|
||||
}
|
||||
return {k: _scrub_value(k, v, name_map) for k, v in doc.items()}
|
||||
if isinstance(doc, list):
|
||||
return [_scrub_doc(item, name_map) for item in doc]
|
||||
return doc
|
||||
|
||||
|
||||
# Functions where field-key scrubbing would break parity (name matching tests).
|
||||
# Only real-roster message sweep is applied for these.
|
||||
_NO_FIELD_SCRUB_FUNCS = {
|
||||
"scripts.match_payments.match_members",
|
||||
"scripts.match_payments.infer_transaction_details",
|
||||
}
|
||||
|
||||
|
||||
def _scrub_messages_only(doc: Any, name_map: dict[str, str]) -> Any:
|
||||
"""Sweep only message/text/search_text fields; leave all other values unchanged."""
|
||||
if isinstance(doc, dict):
|
||||
return {
|
||||
k: (_sweep_names_in_text(v, name_map) if k in _MESSAGE_KEYS and isinstance(v, str)
|
||||
else _scrub_messages_only(v, name_map))
|
||||
for k, v in doc.items()
|
||||
}
|
||||
if isinstance(doc, list):
|
||||
return [_scrub_messages_only(item, name_map) for item in doc]
|
||||
return doc
|
||||
|
||||
|
||||
def _recompute_sync_id(tx_scrubbed: dict) -> str:
|
||||
"""Recompute generate_sync_id hash from already-scrubbed tx fields.
|
||||
|
||||
After the scrubber changes sender/vs/bank_id the original hash is invalid.
|
||||
Replicates the Python generate_sync_id formula (pipe-separated, lowercased)
|
||||
and always treats amount as float64 to match Go's formatAmount behaviour.
|
||||
"""
|
||||
envelope = tx_scrubbed.get("amount", {})
|
||||
if isinstance(envelope, dict):
|
||||
t = envelope.get("type", "")
|
||||
v = envelope.get("value")
|
||||
if t in ("int", "float"):
|
||||
amount = float(v) # always float — matches Go's formatAmount
|
||||
else:
|
||||
amount = ""
|
||||
else:
|
||||
amount = float(envelope) if envelope not in (None, "") else ""
|
||||
|
||||
currency = tx_scrubbed.get("currency", "") or "CZK"
|
||||
components = [
|
||||
str(tx_scrubbed.get("date", "")),
|
||||
str(amount),
|
||||
currency,
|
||||
str(tx_scrubbed.get("sender", "")),
|
||||
str(tx_scrubbed.get("vs", "")),
|
||||
str(tx_scrubbed.get("message", "")),
|
||||
str(tx_scrubbed.get("bank_id", "")),
|
||||
]
|
||||
raw_str = "|".join(components).lower()
|
||||
return hashlib.sha256(raw_str.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _extract_inline_names(doc: Any) -> list[str]:
|
||||
"""Extract names from member_names and 'name' fields in the fixture itself."""
|
||||
names: list[str] = []
|
||||
if isinstance(doc, dict):
|
||||
for k, v in doc.items():
|
||||
if k == "member_names" and isinstance(v, list):
|
||||
names.extend(str(n) for n in v)
|
||||
elif k == "name" and isinstance(v, str):
|
||||
names.append(v)
|
||||
else:
|
||||
names.extend(_extract_inline_names(v))
|
||||
elif isinstance(doc, list):
|
||||
for item in doc:
|
||||
names.extend(_extract_inline_names(item))
|
||||
return names
|
||||
|
||||
|
||||
def scrub_fixture(doc: dict) -> dict:
|
||||
"""Scrub a single fixture document in-place (returns new dict)."""
|
||||
roster_names = _load_member_names()
|
||||
inline_names = _extract_inline_names(doc)
|
||||
all_names = list(dict.fromkeys(roster_names + inline_names))
|
||||
name_map = _build_name_map(all_names)
|
||||
|
||||
func = doc.get("func", "")
|
||||
|
||||
# match_members / infer_transaction_details: tested with synthetic names only.
|
||||
# Field-key scrubbing would make member_names pseudonyms inconsistent with
|
||||
# the text, breaking Go's name-matching assertions. Only sweep messages.
|
||||
if func in _NO_FIELD_SCRUB_FUNCS:
|
||||
# Synthetic member names only — no field scrubbing, no message sweep.
|
||||
# Any sweep would create inconsistency between scrubbed output fields
|
||||
# (search_text) and un-scrubbed input fields (sender, member_names).
|
||||
return _scrub_messages_only(doc, {})
|
||||
|
||||
result = _scrub_doc(doc, name_map)
|
||||
|
||||
# generate_sync_id: recompute hash from the now-scrubbed inputs so the
|
||||
# fixture is self-consistent (scrubbed fields → Go hashes scrubbed values).
|
||||
if func.endswith("generate_sync_id"):
|
||||
result["output"]["sync_id"] = _recompute_sync_id(result["input"].get("tx", {}))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Scrub PII from fixture JSON.")
|
||||
parser.add_argument(
|
||||
"--multi", action="store_true",
|
||||
help="Process newline-delimited JSON (one object per line) from stdin.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.multi:
|
||||
for line in sys.stdin:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
doc = json.loads(line)
|
||||
print(json.dumps(scrub_fixture(doc), ensure_ascii=False))
|
||||
else:
|
||||
doc = json.load(sys.stdin)
|
||||
out = scrub_fixture(doc)
|
||||
print(json.dumps(out, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,289 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Sync Fio bank transactions to a Google Sheet intermediary ledger."""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import os
|
||||
import pickle
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from google.auth.transport.requests import Request
|
||||
from google_auth_oauthlib.flow import InstalledAppFlow
|
||||
from google.oauth2 import service_account
|
||||
from googleapiclient.discovery import build
|
||||
|
||||
from fio_utils import fetch_transactions_all
|
||||
|
||||
from config import PAYMENTS_SHEET_ID as DEFAULT_SPREADSHEET_ID
|
||||
SCOPES = ["https://www.googleapis.com/auth/spreadsheets"]
|
||||
TOKEN_FILE = "token.pickle"
|
||||
COLUMN_LABELS = ["Date", "Amount", "manual fix", "Person", "Purpose", "Inferred Amount", "Sender", "VS", "Message", "Bank ID", "Sync ID"]
|
||||
_SHEETS_SERVICE = None
|
||||
|
||||
def get_sheets_service(credentials_path: str):
|
||||
"""Authenticate and return the Google Sheets API service."""
|
||||
global _SHEETS_SERVICE
|
||||
if _SHEETS_SERVICE is not None:
|
||||
return _SHEETS_SERVICE
|
||||
|
||||
if not os.path.exists(credentials_path):
|
||||
raise FileNotFoundError(f"Credentials file not found: {credentials_path}")
|
||||
|
||||
# Check if it's a service account
|
||||
import json
|
||||
with open(credentials_path, "r") as f:
|
||||
creds_data = json.load(f)
|
||||
|
||||
if creds_data.get("type") == "service_account":
|
||||
creds = service_account.Credentials.from_service_account_file(
|
||||
credentials_path, scopes=SCOPES
|
||||
)
|
||||
else:
|
||||
# Fallback to OAuth2 flow
|
||||
creds = None
|
||||
if os.path.exists(TOKEN_FILE):
|
||||
with open(TOKEN_FILE, "rb") as token:
|
||||
creds = pickle.load(token)
|
||||
|
||||
if not creds or not creds.valid:
|
||||
if creds and creds.expired and creds.refresh_token:
|
||||
creds.refresh(Request())
|
||||
else:
|
||||
flow = InstalledAppFlow.from_client_secrets_file(credentials_path, SCOPES)
|
||||
creds = flow.run_local_server(port=0)
|
||||
|
||||
with open(TOKEN_FILE, "wb") as token:
|
||||
pickle.dump(creds, token)
|
||||
|
||||
_SHEETS_SERVICE = build("sheets", "v4", credentials=creds)
|
||||
return _SHEETS_SERVICE
|
||||
|
||||
|
||||
def generate_sync_id(tx: dict) -> str:
|
||||
"""Generate a unique SHA-256 hash for a transaction.
|
||||
|
||||
Hash components: date|amount|currency|sender|vs|message|bank_id
|
||||
"""
|
||||
components = [
|
||||
str(tx.get("date", "")),
|
||||
str(tx.get("amount", "")),
|
||||
str(tx.get("currency", "CZK")),
|
||||
str(tx.get("sender", "")),
|
||||
str(tx.get("vs", "")),
|
||||
str(tx.get("message", "")),
|
||||
str(tx.get("bank_id", "")),
|
||||
]
|
||||
raw_str = "|".join(components).lower()
|
||||
return hashlib.sha256(raw_str.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _trunc(s: str, n: int = 40) -> str:
|
||||
s = str(s)
|
||||
return s if len(s) <= n else s[: n - 1] + "…"
|
||||
|
||||
|
||||
def _print_fio_table(transactions: list[dict], statuses: list[str]) -> None:
|
||||
headers = ["DATE", "AMOUNT", "SENDER", "VS", "MESSAGE", "BANKID", "STATUS"]
|
||||
rows = [
|
||||
[
|
||||
str(tx.get("date", "")),
|
||||
f"{float(tx.get('amount', 0)):.2f}",
|
||||
str(tx.get("sender", "")),
|
||||
str(tx.get("vs", "")),
|
||||
_trunc(str(tx.get("message", ""))),
|
||||
str(tx.get("bank_id", "")),
|
||||
status,
|
||||
]
|
||||
for tx, status in zip(transactions, statuses)
|
||||
]
|
||||
widths = [
|
||||
max(len(headers[i]), max((len(r[i]) for r in rows), default=0))
|
||||
for i in range(len(headers))
|
||||
]
|
||||
sep = " "
|
||||
print(sep.join(h.ljust(w) for h, w in zip(headers, widths)))
|
||||
for row in rows:
|
||||
print(sep.join(cell.ljust(w) for cell, w in zip(row, widths)))
|
||||
|
||||
|
||||
def sort_sheet_by_date(service, spreadsheet_id):
|
||||
"""Sort the sheet by the Date column (Column B)."""
|
||||
# Get the sheet ID (gid) of the first sheet
|
||||
spreadsheet = service.spreadsheets().get(spreadsheetId=spreadsheet_id).execute()
|
||||
sheet_id = spreadsheet['sheets'][0]['properties']['sheetId']
|
||||
|
||||
requests = [{
|
||||
"sortRange": {
|
||||
"range": {
|
||||
"sheetId": sheet_id,
|
||||
"startRowIndex": 1, # Skip header
|
||||
"endRowIndex": 10000
|
||||
},
|
||||
"sortSpecs": [{
|
||||
"dimensionIndex": 0, # Column A (Date)
|
||||
"sortOrder": "ASCENDING"
|
||||
}]
|
||||
}
|
||||
}]
|
||||
|
||||
service.spreadsheets().batchUpdate(
|
||||
spreadsheetId=spreadsheet_id,
|
||||
body={"requests": requests}
|
||||
).execute()
|
||||
print("Sheet sorted by date.")
|
||||
|
||||
|
||||
def sync_to_sheets(
|
||||
spreadsheet_id: str,
|
||||
credentials_path: str,
|
||||
days: int = None,
|
||||
date_from_str: str = None,
|
||||
date_to_str: str = None,
|
||||
sort_by_date: bool = False,
|
||||
dry_run: bool = False,
|
||||
print_fio_table: bool = False,
|
||||
):
|
||||
print(f"Connecting to Google Sheets using {credentials_path}...")
|
||||
service = get_sheets_service(credentials_path)
|
||||
sheet = service.spreadsheets()
|
||||
|
||||
# 1. Read existing sync IDs from Column K
|
||||
print(f"Reading existing sync IDs from sheet...")
|
||||
try:
|
||||
result = sheet.values().get(
|
||||
spreadsheetId=spreadsheet_id,
|
||||
range="A1:K" # Include header and all columns to check Sync ID
|
||||
).execute()
|
||||
values = result.get("values", [])
|
||||
|
||||
# Check and insert labels if missing
|
||||
if not values or values[0] != COLUMN_LABELS:
|
||||
if dry_run:
|
||||
print("Dry run: would write header row")
|
||||
else:
|
||||
print("Inserting column labels...")
|
||||
sheet.values().update(
|
||||
spreadsheetId=spreadsheet_id,
|
||||
range="A1",
|
||||
valueInputOption="USER_ENTERED",
|
||||
body={"values": [COLUMN_LABELS]}
|
||||
).execute()
|
||||
existing_ids = set()
|
||||
else:
|
||||
# Sync ID is the last column (index 10)
|
||||
existing_ids = {row[10] for row in values[1:] if len(row) > 10}
|
||||
except Exception as e:
|
||||
print(f"Error reading sheet (maybe empty?): {e}")
|
||||
existing_ids = set()
|
||||
|
||||
# 2. Fetch Fio transactions
|
||||
if date_from_str and date_to_str:
|
||||
df_str = date_from_str
|
||||
dt_str = date_to_str
|
||||
else:
|
||||
now = datetime.now()
|
||||
date_to = now
|
||||
date_from = now - timedelta(days=days or 30)
|
||||
df_str = date_from.strftime("%Y-%m-%d")
|
||||
dt_str = date_to.strftime("%Y-%m-%d")
|
||||
|
||||
print(f"Fetching Fio transactions from {df_str} to {dt_str}...")
|
||||
transactions = fetch_transactions_all(df_str, dt_str)
|
||||
print(f"Found {len(transactions)} transactions.")
|
||||
|
||||
if dry_run:
|
||||
print(f"Dry run: window {df_str} to {dt_str}, fetched {len(transactions)} transaction(s) from Fio")
|
||||
|
||||
# 3. Determine NEW/DUP for each transaction
|
||||
new_rows = []
|
||||
tx_statuses = []
|
||||
for tx in transactions:
|
||||
sync_id = generate_sync_id(tx)
|
||||
if sync_id not in existing_ids:
|
||||
# Schema: Date | Amount | Manual | Person | Purpose | Inferred Amount | Sender | VS | Message | Bank ID | Sync ID
|
||||
new_rows.append([
|
||||
tx.get("date", ""),
|
||||
tx.get("amount", ""),
|
||||
"", # Manual
|
||||
"", # Person
|
||||
"", # Purpose
|
||||
"", # Inferred Amount
|
||||
tx.get("sender", ""),
|
||||
tx.get("vs", ""),
|
||||
tx.get("message", ""),
|
||||
tx.get("bank_id", ""),
|
||||
sync_id,
|
||||
])
|
||||
tx_statuses.append("NEW")
|
||||
else:
|
||||
tx_statuses.append("DUP")
|
||||
|
||||
# 4. Print table (before early-return so all transactions are shown including DUPs)
|
||||
if print_fio_table and transactions:
|
||||
_print_fio_table(transactions, tx_statuses)
|
||||
|
||||
if not new_rows:
|
||||
if dry_run:
|
||||
print("Dry run: would sync 0 new transaction(s).")
|
||||
else:
|
||||
print("No new transactions to sync.")
|
||||
return
|
||||
|
||||
# 5. Append to sheet or print dry-run would-write lines
|
||||
if dry_run:
|
||||
for tx, status in zip(transactions, tx_statuses):
|
||||
if status == "NEW":
|
||||
print(
|
||||
f"Dry run: would append"
|
||||
f" date={tx.get('date', '')}"
|
||||
f" amount={tx.get('amount', '')}"
|
||||
f" sender={tx.get('sender', '')}"
|
||||
f" vs={tx.get('vs', '')}"
|
||||
f" message={tx.get('message', '')}"
|
||||
)
|
||||
if sort_by_date:
|
||||
print("Dry run: would sort by date")
|
||||
print(f"Dry run: would sync {len(new_rows)} new transaction(s).")
|
||||
else:
|
||||
print(f"Appending {len(new_rows)} new transactions to the sheet...")
|
||||
body = {"values": new_rows}
|
||||
sheet.values().append(
|
||||
spreadsheetId=spreadsheet_id,
|
||||
range="A2", # Appends to the end of the sheet
|
||||
valueInputOption="USER_ENTERED",
|
||||
body=body
|
||||
).execute()
|
||||
print("Sync completed successfully.")
|
||||
if sort_by_date:
|
||||
sort_sheet_by_date(service, spreadsheet_id)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Sync Fio transactions to Google Sheets.")
|
||||
parser.add_argument("--days", type=int, default=30, help="Days to look back (default: 30)")
|
||||
parser.add_argument("--sheet-id", default=DEFAULT_SPREADSHEET_ID, help="Google Sheet ID")
|
||||
parser.add_argument("--credentials", default="credentials.json", help="Path to Google API credentials JSON")
|
||||
parser.add_argument("--from", dest="date_from", help="Start date YYYY-MM-DD")
|
||||
parser.add_argument("--to", dest="date_to", help="End date YYYY-MM-DD")
|
||||
parser.add_argument("--sort-by-date", action="store_true", help="Sort the sheet by date after sync")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Fetch and dedup without writing to the sheet")
|
||||
parser.add_argument("--print-fio-table", action="store_true", help="Print aligned table of all fetched transactions with NEW/DUP status (use with --dry-run)")
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
sync_to_sheets(
|
||||
spreadsheet_id=args.sheet_id,
|
||||
credentials_path=args.credentials,
|
||||
days=args.days,
|
||||
date_from_str=args.date_from,
|
||||
date_to_str=args.date_to,
|
||||
sort_by_date=args.sort_by_date,
|
||||
dry_run=args.dry_run,
|
||||
print_fio_table=args.print_fio_table,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Sync failed: {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,446 @@
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
from attendance import ADULT_MERGED_MONTHS, JUNIOR_MERGED_MONTHS
|
||||
from match_payments import canonical_member_key
|
||||
|
||||
|
||||
def get_month_labels(sorted_months, merged_months):
|
||||
labels = {}
|
||||
for m in sorted_months:
|
||||
dt = datetime.strptime(m, "%Y-%m")
|
||||
merged_in = sorted([k for k, v in merged_months.items() if v == m])
|
||||
if merged_in:
|
||||
all_dts = [datetime.strptime(x, "%Y-%m") for x in sorted(merged_in + [m])]
|
||||
years = {d.year for d in all_dts}
|
||||
if len(years) > 1:
|
||||
parts = [d.strftime("%b %Y") for d in all_dts]
|
||||
labels[m] = "+".join(parts)
|
||||
else:
|
||||
parts = [d.strftime("%b") for d in all_dts]
|
||||
labels[m] = f"{'+'.join(parts)} {dt.strftime('%Y')}"
|
||||
else:
|
||||
labels[m] = dt.strftime("%b %Y")
|
||||
return labels
|
||||
|
||||
|
||||
def group_payments_by_person(transactions, member_names=None):
|
||||
canonical_by_key = (
|
||||
{canonical_member_key(n): n for n in member_names} if member_names else {}
|
||||
)
|
||||
grouped = {}
|
||||
for tx in transactions:
|
||||
person = str(tx.get("person", "")).strip()
|
||||
if not person:
|
||||
continue
|
||||
for p in person.split(","):
|
||||
p = re.sub(r"\[\?\]\s*", "", p).strip()
|
||||
if not p:
|
||||
continue
|
||||
key = canonical_by_key.get(canonical_member_key(p), p)
|
||||
grouped.setdefault(key, []).append(tx)
|
||||
for rows in grouped.values():
|
||||
rows.sort(key=lambda t: str(t.get("date", "")), reverse=True)
|
||||
return grouped
|
||||
|
||||
|
||||
def adapt_junior_members(junior_members):
|
||||
"""Convert 4-tuple junior fee data to (fee, total_count) for reconcile."""
|
||||
adapted = []
|
||||
for name, tier, fees_dict in junior_members:
|
||||
adapted_fees = {}
|
||||
for m, fee_data in fees_dict.items():
|
||||
if len(fee_data) == 4:
|
||||
fee, total_count, _, _ = fee_data
|
||||
adapted_fees[m] = (fee, total_count)
|
||||
else:
|
||||
fee, count = fee_data
|
||||
adapted_fees[m] = (fee, count)
|
||||
adapted.append((name, tier, adapted_fees))
|
||||
return adapted
|
||||
|
||||
|
||||
def build_adults_view_model(
|
||||
members,
|
||||
sorted_months,
|
||||
result,
|
||||
transactions,
|
||||
current_month,
|
||||
*,
|
||||
attendance_url,
|
||||
payments_url,
|
||||
bank_account,
|
||||
):
|
||||
month_labels = get_month_labels(sorted_months, ADULT_MERGED_MONTHS)
|
||||
adult_names = sorted([name for name, tier, _ in members if tier == "A"])
|
||||
|
||||
monthly_totals = {m: {"expected": 0, "paid": 0} for m in sorted_months}
|
||||
formatted_results = []
|
||||
for name in adult_names:
|
||||
data = result["members"][name]
|
||||
row = {
|
||||
"name": name,
|
||||
"months": [],
|
||||
"balance": data["total_balance"],
|
||||
"unpaid_periods": "",
|
||||
"raw_unpaid_periods": "",
|
||||
}
|
||||
unpaid_months = []
|
||||
raw_unpaid_months = []
|
||||
for m in sorted_months:
|
||||
mdata = data["months"].get(m, {
|
||||
"expected": 0, "original_expected": 0,
|
||||
"attendance_count": 0, "paid": 0, "exception": None,
|
||||
})
|
||||
expected = mdata.get("expected", 0)
|
||||
original_expected = mdata.get("original_expected", 0)
|
||||
count = mdata.get("attendance_count", 0)
|
||||
paid = int(mdata.get("paid", 0))
|
||||
exception_info = mdata.get("exception", None)
|
||||
|
||||
monthly_totals[m]["expected"] += expected
|
||||
monthly_totals[m]["paid"] += paid
|
||||
|
||||
override_amount = exception_info["amount"] if exception_info else None
|
||||
|
||||
if override_amount is not None and override_amount != original_expected:
|
||||
is_overridden = True
|
||||
fee_display = (
|
||||
f"{override_amount} ({original_expected}) CZK ({count})"
|
||||
if count > 0
|
||||
else f"{override_amount} ({original_expected}) CZK"
|
||||
)
|
||||
else:
|
||||
is_overridden = False
|
||||
fee_display = (
|
||||
f"{expected} CZK ({count})" if count > 0 else f"{expected} CZK"
|
||||
)
|
||||
|
||||
status = "empty"
|
||||
cell_text = "-"
|
||||
amount_to_pay = 0
|
||||
|
||||
if expected > 0:
|
||||
amount_to_pay = max(0, expected - paid)
|
||||
if paid >= expected:
|
||||
status = "ok"
|
||||
cell_text = f"{paid}/{fee_display}"
|
||||
elif paid > 0:
|
||||
status = "partial"
|
||||
cell_text = f"{paid}/{fee_display}"
|
||||
if m < current_month:
|
||||
unpaid_months.append(month_labels[m])
|
||||
raw_unpaid_months.append(
|
||||
datetime.strptime(m, "%Y-%m").strftime("%m/%Y")
|
||||
)
|
||||
else:
|
||||
status = "unpaid"
|
||||
cell_text = f"0/{fee_display}"
|
||||
if m < current_month:
|
||||
unpaid_months.append(month_labels[m])
|
||||
raw_unpaid_months.append(
|
||||
datetime.strptime(m, "%Y-%m").strftime("%m/%Y")
|
||||
)
|
||||
elif paid > 0:
|
||||
status = "surplus"
|
||||
cell_text = f"PAID {paid}"
|
||||
else:
|
||||
cell_text = "-"
|
||||
amount_to_pay = 0
|
||||
|
||||
if expected > 0 or paid > 0:
|
||||
tooltip = f"Received: {paid}, Expected: {expected}"
|
||||
else:
|
||||
tooltip = ""
|
||||
|
||||
row["months"].append({
|
||||
"text": cell_text,
|
||||
"overridden": is_overridden,
|
||||
"status": status,
|
||||
"amount": amount_to_pay,
|
||||
"month": month_labels[m],
|
||||
"raw_month": m,
|
||||
"tooltip": tooltip,
|
||||
})
|
||||
|
||||
settled_balance = 0
|
||||
for m, mdata in data["months"].items():
|
||||
if m >= current_month:
|
||||
continue
|
||||
exp = mdata.get("expected", 0)
|
||||
if isinstance(exp, int):
|
||||
settled_balance += int(mdata.get("paid", 0)) - exp
|
||||
|
||||
payable_amount = max(0, -settled_balance)
|
||||
row["unpaid_periods"] = ", ".join(unpaid_months)
|
||||
row["raw_unpaid_periods"] = "+".join(raw_unpaid_months)
|
||||
row["balance"] = settled_balance
|
||||
row["payable_amount"] = payable_amount
|
||||
formatted_results.append(row)
|
||||
|
||||
formatted_totals = []
|
||||
for m in sorted_months:
|
||||
t = monthly_totals[m]
|
||||
status = "empty"
|
||||
if t["expected"] > 0 or t["paid"] > 0:
|
||||
if t["paid"] == t["expected"]:
|
||||
status = "ok"
|
||||
elif t["paid"] < t["expected"]:
|
||||
status = "unpaid"
|
||||
else:
|
||||
status = "surplus"
|
||||
formatted_totals.append({
|
||||
"text": f"{t['paid']} / {t['expected']} CZK",
|
||||
"status": status,
|
||||
})
|
||||
|
||||
def _settled_balance(name):
|
||||
data = result["members"][name]
|
||||
total = 0
|
||||
for m, mdata in data["months"].items():
|
||||
if m >= current_month:
|
||||
continue
|
||||
exp = mdata.get("expected", 0)
|
||||
if isinstance(exp, int):
|
||||
total += int(mdata.get("paid", 0)) - exp
|
||||
return total
|
||||
|
||||
credits = sorted(
|
||||
[{"name": n, "amount": _settled_balance(n)} for n in adult_names if _settled_balance(n) > 0],
|
||||
key=lambda x: x["name"],
|
||||
)
|
||||
debts = sorted(
|
||||
[{"name": n, "amount": abs(_settled_balance(n))} for n in adult_names if _settled_balance(n) < 0],
|
||||
key=lambda x: x["name"],
|
||||
)
|
||||
|
||||
raw_payments_by_person = group_payments_by_person(
|
||||
transactions, [name for name, _, _ in members]
|
||||
)
|
||||
|
||||
return dict(
|
||||
months=[month_labels[m] for m in sorted_months],
|
||||
raw_months=sorted_months,
|
||||
results=formatted_results,
|
||||
totals=formatted_totals,
|
||||
member_data=json.dumps(result["members"]),
|
||||
month_labels_json=json.dumps(month_labels),
|
||||
raw_payments_json=json.dumps(raw_payments_by_person),
|
||||
credits=credits,
|
||||
debts=debts,
|
||||
unmatched=result["unmatched"],
|
||||
attendance_url=attendance_url,
|
||||
payments_url=payments_url,
|
||||
bank_account=bank_account,
|
||||
current_month=current_month,
|
||||
)
|
||||
|
||||
|
||||
def build_juniors_view_model(
|
||||
junior_members,
|
||||
adapted_members,
|
||||
sorted_months,
|
||||
result,
|
||||
transactions,
|
||||
current_month,
|
||||
*,
|
||||
attendance_url,
|
||||
payments_url,
|
||||
bank_account,
|
||||
):
|
||||
month_labels = get_month_labels(sorted_months, JUNIOR_MERGED_MONTHS)
|
||||
junior_names = sorted([name for name, tier, _ in adapted_members])
|
||||
junior_members_dict = {name: fees_dict for name, _, fees_dict in junior_members}
|
||||
|
||||
monthly_totals = {m: {"expected": 0, "paid": 0} for m in sorted_months}
|
||||
formatted_results = []
|
||||
for name in junior_names:
|
||||
data = result["members"][name]
|
||||
row = {
|
||||
"name": name,
|
||||
"months": [],
|
||||
"balance": data["total_balance"],
|
||||
"unpaid_periods": "",
|
||||
"raw_unpaid_periods": "",
|
||||
}
|
||||
unpaid_months = []
|
||||
raw_unpaid_months = []
|
||||
for m in sorted_months:
|
||||
mdata = data["months"].get(m, {
|
||||
"expected": 0, "original_expected": 0,
|
||||
"attendance_count": 0, "paid": 0, "exception": None,
|
||||
})
|
||||
expected = mdata.get("expected", 0)
|
||||
original_expected = mdata.get("original_expected", 0)
|
||||
count = mdata.get("attendance_count", 0)
|
||||
paid = int(mdata.get("paid", 0))
|
||||
exception_info = mdata.get("exception", None)
|
||||
|
||||
if expected != "?" and isinstance(expected, int):
|
||||
monthly_totals[m]["expected"] += expected
|
||||
monthly_totals[m]["paid"] += paid
|
||||
|
||||
orig_fee_data = junior_members_dict.get(name, {}).get(m)
|
||||
adult_count = 0
|
||||
junior_count = 0
|
||||
if orig_fee_data and len(orig_fee_data) == 4:
|
||||
_, _, adult_count, junior_count = orig_fee_data
|
||||
|
||||
breakdown = ""
|
||||
if adult_count > 0 and junior_count > 0:
|
||||
breakdown = f":{junior_count}J,{adult_count}A"
|
||||
elif junior_count > 0:
|
||||
breakdown = f":{junior_count}J"
|
||||
elif adult_count > 0:
|
||||
breakdown = f":{adult_count}A"
|
||||
|
||||
count_str = f" ({count}{breakdown})" if count > 0 else ""
|
||||
|
||||
override_amount = exception_info["amount"] if exception_info else None
|
||||
|
||||
if override_amount is not None and override_amount != original_expected:
|
||||
is_overridden = True
|
||||
fee_display = f"{override_amount} ({original_expected}) CZK{count_str}"
|
||||
else:
|
||||
is_overridden = False
|
||||
fee_display = f"{expected} CZK{count_str}"
|
||||
|
||||
status = "empty"
|
||||
cell_text = "-"
|
||||
amount_to_pay = 0
|
||||
|
||||
is_unknown = original_expected == "?"
|
||||
if is_unknown or (isinstance(expected, int) and expected > 0):
|
||||
if is_unknown:
|
||||
status = "empty"
|
||||
cell_text = f"?{count_str}"
|
||||
elif paid >= expected:
|
||||
status = "ok"
|
||||
cell_text = f"{paid}/{fee_display}"
|
||||
elif paid > 0:
|
||||
status = "partial"
|
||||
cell_text = f"{paid}/{fee_display}"
|
||||
amount_to_pay = expected - paid
|
||||
if m < current_month:
|
||||
unpaid_months.append(month_labels[m])
|
||||
raw_unpaid_months.append(
|
||||
datetime.strptime(m, "%Y-%m").strftime("%m/%Y")
|
||||
)
|
||||
else:
|
||||
status = "unpaid"
|
||||
cell_text = f"0/{fee_display}"
|
||||
amount_to_pay = expected
|
||||
if m < current_month:
|
||||
unpaid_months.append(month_labels[m])
|
||||
raw_unpaid_months.append(
|
||||
datetime.strptime(m, "%Y-%m").strftime("%m/%Y")
|
||||
)
|
||||
elif paid > 0:
|
||||
status = "surplus"
|
||||
cell_text = f"PAID {paid}"
|
||||
|
||||
if (not is_unknown and isinstance(expected, int) and expected > 0) or paid > 0:
|
||||
tooltip = f"Received: {paid}, Expected: {expected}"
|
||||
else:
|
||||
tooltip = ""
|
||||
|
||||
row["months"].append({
|
||||
"text": cell_text,
|
||||
"overridden": is_overridden,
|
||||
"status": status,
|
||||
"amount": amount_to_pay,
|
||||
"month": month_labels[m],
|
||||
"raw_month": m,
|
||||
"tooltip": tooltip,
|
||||
})
|
||||
|
||||
settled_balance = 0
|
||||
for m, mdata in data["months"].items():
|
||||
if m >= current_month:
|
||||
continue
|
||||
exp = mdata.get("expected", 0)
|
||||
if isinstance(exp, int):
|
||||
settled_balance += int(mdata.get("paid", 0)) - exp
|
||||
|
||||
payable_amount = max(0, -settled_balance)
|
||||
row["unpaid_periods"] = ", ".join(unpaid_months)
|
||||
row["raw_unpaid_periods"] = "+".join(raw_unpaid_months)
|
||||
row["balance"] = settled_balance
|
||||
row["payable_amount"] = payable_amount
|
||||
formatted_results.append(row)
|
||||
|
||||
formatted_totals = []
|
||||
for m in sorted_months:
|
||||
t = monthly_totals[m]
|
||||
status = "empty"
|
||||
if t["expected"] > 0 or t["paid"] > 0:
|
||||
if t["paid"] == t["expected"]:
|
||||
status = "ok"
|
||||
elif t["paid"] < t["expected"]:
|
||||
status = "unpaid"
|
||||
else:
|
||||
status = "surplus"
|
||||
formatted_totals.append({
|
||||
"text": f"{t['paid']} / {t['expected']} CZK",
|
||||
"status": status,
|
||||
})
|
||||
|
||||
junior_all_names = [name for name, _, _ in adapted_members]
|
||||
|
||||
def _junior_settled_balance(name):
|
||||
data = result["members"][name]
|
||||
total = 0
|
||||
for m, mdata in data["months"].items():
|
||||
if m >= current_month:
|
||||
continue
|
||||
exp = mdata.get("expected", 0)
|
||||
if isinstance(exp, int):
|
||||
total += int(mdata.get("paid", 0)) - exp
|
||||
return total
|
||||
|
||||
credits = sorted(
|
||||
[{"name": n, "amount": _junior_settled_balance(n)} for n in junior_all_names if _junior_settled_balance(n) > 0],
|
||||
key=lambda x: x["name"],
|
||||
)
|
||||
debts = sorted(
|
||||
[{"name": n, "amount": abs(_junior_settled_balance(n))} for n in junior_all_names if _junior_settled_balance(n) < 0],
|
||||
key=lambda x: x["name"],
|
||||
)
|
||||
|
||||
raw_payments_by_person = group_payments_by_person(
|
||||
transactions, [name for name, _, _ in adapted_members]
|
||||
)
|
||||
|
||||
return dict(
|
||||
months=[month_labels[m] for m in sorted_months],
|
||||
raw_months=sorted_months,
|
||||
results=formatted_results,
|
||||
totals=formatted_totals,
|
||||
member_data=json.dumps(result["members"]),
|
||||
month_labels_json=json.dumps(month_labels),
|
||||
raw_payments_json=json.dumps(raw_payments_by_person),
|
||||
credits=credits,
|
||||
debts=debts,
|
||||
unmatched=result["unmatched"],
|
||||
attendance_url=attendance_url,
|
||||
payments_url=payments_url,
|
||||
bank_account=bank_account,
|
||||
current_month=current_month,
|
||||
)
|
||||
|
||||
|
||||
def build_payments_view_model(transactions, member_names, *, attendance_url, payments_url):
|
||||
grouped = group_payments_by_person(transactions, member_names)
|
||||
for tx in transactions:
|
||||
if not str(tx.get("person", "")).strip():
|
||||
grouped.setdefault("Unmatched / Unknown", []).append(tx)
|
||||
for rows in grouped.values():
|
||||
rows.sort(key=lambda t: str(t.get("date", "")), reverse=True)
|
||||
sorted_people = sorted(grouped.keys())
|
||||
return dict(
|
||||
grouped_payments=grouped,
|
||||
sorted_people=sorted_people,
|
||||
attendance_url=attendance_url,
|
||||
payments_url=payments_url,
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,163 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>FUJ - Flush Cache</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
background-color: #0c0c0c;
|
||||
color: #cccccc;
|
||||
padding: 10px;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
font-size: 11px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #00ff00;
|
||||
font-family: inherit;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 20px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.nav {
|
||||
margin-bottom: 20px;
|
||||
font-size: 12px;
|
||||
color: #555;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nav > div {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nav a {
|
||||
color: #00ff00;
|
||||
text-decoration: none;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid #333;
|
||||
}
|
||||
|
||||
.nav a.active {
|
||||
color: #000;
|
||||
background-color: #00ff00;
|
||||
border-color: #00ff00;
|
||||
}
|
||||
|
||||
.nav a:hover {
|
||||
color: #fff;
|
||||
border-color: #555;
|
||||
}
|
||||
|
||||
.nav-archived a {
|
||||
font-size: 10px;
|
||||
color: #666;
|
||||
border-color: #222;
|
||||
}
|
||||
|
||||
.nav-archived a.active {
|
||||
color: #ccc;
|
||||
background-color: #333;
|
||||
border-color: #555;
|
||||
}
|
||||
|
||||
.nav-archived a:hover {
|
||||
color: #999;
|
||||
border-color: #444;
|
||||
}
|
||||
|
||||
.flush-container {
|
||||
background-color: #111;
|
||||
border: 1px solid #333;
|
||||
padding: 30px;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
margin-bottom: 30px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.flush-btn {
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
color: #00ff00;
|
||||
background-color: #1a1a1a;
|
||||
border: 1px solid #00ff00;
|
||||
padding: 8px 24px;
|
||||
cursor: pointer;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.flush-btn:hover {
|
||||
background-color: #00ff00;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.status {
|
||||
margin-bottom: 15px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.status-ok { color: #00ff00; }
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
color: #333;
|
||||
margin-top: 20px;
|
||||
font-size: 10px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="nav">
|
||||
<div>
|
||||
<a href="/adults">[Adults]</a>
|
||||
<a href="/juniors">[Juniors]</a>
|
||||
</div>
|
||||
<div class="nav-archived">
|
||||
<span style="color: #666; margin-right: 5px;">Archived:</span>
|
||||
<a href="/payments">[Payments Ledger]</a>
|
||||
</div>
|
||||
<div class="nav-archived">
|
||||
<span style="color: #666; margin-right: 5px;">Tools:</span>
|
||||
<a href="/sync-bank">[Sync Bank Data]</a>
|
||||
<a href="/flush-cache" class="active">[Flush Cache]</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1>Flush Cache</h1>
|
||||
|
||||
{% if flushed %}
|
||||
<div class="status">
|
||||
<span class="status-ok">Cache flushed successfully. {{ deleted }} file(s) deleted.</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="flush-container">
|
||||
<p style="margin-bottom: 20px; color: #888;">Clears all cached Google Sheets data and resets refresh timers.</p>
|
||||
<form method="POST" action="/flush-cache">
|
||||
<button type="submit" class="flush-btn">[Flush Cache]</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
{{ build_meta.tag }} | {{ build_meta.commit }} | {{ build_meta.build_date }}
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,248 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>FUJ Payments Ledger</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
background-color: #0c0c0c;
|
||||
color: #cccccc;
|
||||
padding: 10px;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
font-size: 11px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #00ff00;
|
||||
font-family: inherit;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 20px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
color: #00ff00;
|
||||
font-size: 12px;
|
||||
margin-top: 30px;
|
||||
margin-bottom: 5px;
|
||||
text-transform: uppercase;
|
||||
width: 100%;
|
||||
max-width: 1000px;
|
||||
border-bottom: 1px solid #333;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.nav {
|
||||
margin-bottom: 20px;
|
||||
font-size: 12px;
|
||||
color: #555;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nav > div {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nav a {
|
||||
color: #00ff00;
|
||||
text-decoration: none;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid #333;
|
||||
}
|
||||
|
||||
.nav a.active {
|
||||
color: #000;
|
||||
background-color: #00ff00;
|
||||
border-color: #00ff00;
|
||||
}
|
||||
|
||||
.nav a:hover {
|
||||
color: #fff;
|
||||
border-color: #555;
|
||||
}
|
||||
|
||||
.nav-archived a {
|
||||
font-size: 10px;
|
||||
color: #666;
|
||||
border-color: #222;
|
||||
}
|
||||
|
||||
.nav-archived a.active {
|
||||
color: #ccc;
|
||||
background-color: #333;
|
||||
border-color: #555;
|
||||
}
|
||||
|
||||
.nav-archived a:hover {
|
||||
color: #999;
|
||||
border-color: #444;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin-bottom: 20px;
|
||||
text-align: center;
|
||||
color: #888;
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.description a {
|
||||
color: #00ff00;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.description a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.ledger-container {
|
||||
width: 100%;
|
||||
max-width: 1000px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.member-block {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.txn-table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.txn-table th,
|
||||
.txn-table td {
|
||||
padding: 2px 8px;
|
||||
text-align: left;
|
||||
border-bottom: 1px dashed #222;
|
||||
}
|
||||
|
||||
.txn-table th {
|
||||
color: #555;
|
||||
text-transform: lowercase;
|
||||
font-weight: normal;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
|
||||
.txn-date {
|
||||
min-width: 80px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.txn-amount {
|
||||
min-width: 80px;
|
||||
text-align: right !important;
|
||||
color: #00ff00;
|
||||
}
|
||||
|
||||
.txn-purpose {
|
||||
min-width: 100px;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.txn-message {
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
tr:hover {
|
||||
background-color: #1a1a1a;
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 50px;
|
||||
margin-bottom: 20px;
|
||||
color: #333;
|
||||
font-size: 9px;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.perf-breakdown {
|
||||
display: none;
|
||||
margin-top: 5px;
|
||||
color: #222;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="nav">
|
||||
<div>
|
||||
<a href="/adults">[Adults]</a>
|
||||
<a href="/juniors">[Juniors]</a>
|
||||
</div>
|
||||
<div class="nav-archived">
|
||||
<span style="color: #666; margin-right: 5px;">Archived:</span>
|
||||
<a href="/payments" class="active">[Payments Ledger]</a>
|
||||
</div>
|
||||
<div class="nav-archived">
|
||||
<span style="color: #666; margin-right: 5px;">Tools:</span>
|
||||
<a href="/sync-bank">[Sync Bank Data]</a>
|
||||
<a href="/flush-cache">[Flush Cache]</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1>Payments Ledger</h1>
|
||||
|
||||
<div class="description">
|
||||
All bank transactions from the Google Sheet, grouped by member.<br>
|
||||
Source: <a href="{{ attendance_url }}" target="_blank">Attendance Sheet</a> |
|
||||
<a href="{{ payments_url }}" target="_blank">Payments Ledger</a>
|
||||
</div>
|
||||
|
||||
<div class="ledger-container">
|
||||
{% for person in sorted_people %}
|
||||
<div class="member-block">
|
||||
<h2>{{ person }}</h2>
|
||||
<table class="txn-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="txn-date">Date</th>
|
||||
<th class="txn-amount">Amount</th>
|
||||
<th class="txn-purpose">Purpose</th>
|
||||
<th class="txn-message">Bank Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for tx in grouped_payments[person] %}
|
||||
<tr>
|
||||
<td class="txn-date">{{ tx.date }}</td>
|
||||
<td class="txn-amount">{{ tx.amount }} CZK</td>
|
||||
<td class="txn-purpose">{{ tx.purpose }}</td>
|
||||
<td class="txn-message">{{ tx.message }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% set rt = get_render_time() %}
|
||||
<div class="footer"
|
||||
onclick="document.getElementById('perf-details').style.display = (document.getElementById('perf-details').style.display === 'block' ? 'none' : 'block')">
|
||||
{{ build_meta.tag }}@{{ build_meta.commit }} | render time: {{ rt.total }}s
|
||||
<div id="perf-details" class="perf-breakdown">
|
||||
{{ rt.breakdown }}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,153 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>FUJ - Sync Bank Data</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
background-color: #0c0c0c;
|
||||
color: #cccccc;
|
||||
padding: 10px;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
font-size: 11px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #00ff00;
|
||||
font-family: inherit;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 20px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.nav {
|
||||
margin-bottom: 20px;
|
||||
font-size: 12px;
|
||||
color: #555;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nav > div {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nav a {
|
||||
color: #00ff00;
|
||||
text-decoration: none;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid #333;
|
||||
}
|
||||
|
||||
.nav a.active {
|
||||
color: #000;
|
||||
background-color: #00ff00;
|
||||
border-color: #00ff00;
|
||||
}
|
||||
|
||||
.nav a:hover {
|
||||
color: #fff;
|
||||
border-color: #555;
|
||||
}
|
||||
|
||||
.nav-archived a {
|
||||
font-size: 10px;
|
||||
color: #666;
|
||||
border-color: #222;
|
||||
}
|
||||
|
||||
.nav-archived a.active {
|
||||
color: #ccc;
|
||||
background-color: #333;
|
||||
border-color: #555;
|
||||
}
|
||||
|
||||
.nav-archived a:hover {
|
||||
color: #999;
|
||||
border-color: #444;
|
||||
}
|
||||
|
||||
.output-container {
|
||||
background-color: #111;
|
||||
border: 1px solid #333;
|
||||
padding: 15px;
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
margin-bottom: 30px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.output-container pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
color: {% if success %}#cccccc{% else %}#ff6666{% endif %};
|
||||
}
|
||||
|
||||
.status {
|
||||
margin-bottom: 15px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.status-ok { color: #00ff00; }
|
||||
.status-error { color: #ff6666; }
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
color: #333;
|
||||
margin-top: 20px;
|
||||
font-size: 10px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="nav">
|
||||
<div>
|
||||
<a href="/adults">[Adults]</a>
|
||||
<a href="/juniors">[Juniors]</a>
|
||||
</div>
|
||||
<div class="nav-archived">
|
||||
<span style="color: #666; margin-right: 5px;">Archived:</span>
|
||||
<a href="/payments">[Payments Ledger]</a>
|
||||
</div>
|
||||
<div class="nav-archived">
|
||||
<span style="color: #666; margin-right: 5px;">Tools:</span>
|
||||
<a href="/sync-bank" class="active">[Sync Bank Data]</a>
|
||||
<a href="/flush-cache">[Flush Cache]</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1>Sync Bank Data</h1>
|
||||
|
||||
<div class="status">
|
||||
{% if success %}
|
||||
<span class="status-ok">Sync completed successfully.</span>
|
||||
{% else %}
|
||||
<span class="status-error">Sync failed - see output below.</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="output-container">
|
||||
<pre>{{ output }}</pre>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
{{ build_meta.tag }} | {{ build_meta.commit }} | {{ build_meta.build_date }}
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,189 @@
|
||||
import unittest
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
from app import app
|
||||
|
||||
EXPECTED_ADULTS_KEYS = {
|
||||
"months", "raw_months", "results", "totals", "member_data", "month_labels",
|
||||
"raw_payments", "credits", "debts", "unmatched", "attendance_url",
|
||||
"payments_url", "bank_account", "current_month",
|
||||
}
|
||||
EXPECTED_JUNIORS_KEYS = EXPECTED_ADULTS_KEYS
|
||||
EXPECTED_PAYMENTS_KEYS = {"grouped_payments", "sorted_people", "attendance_url", "payments_url"}
|
||||
EXPECTED_VERSION_KEYS = {"tag", "commit", "build_date"}
|
||||
|
||||
|
||||
def _bypass_cache(cache_key, sheet_id, fetch_func, *args, serialize=None, deserialize=None, **kwargs):
|
||||
"""Test helper: call fetch_func directly, bypassing the cache layer."""
|
||||
return fetch_func(*args, **kwargs)
|
||||
|
||||
|
||||
class TestWebApp(unittest.TestCase):
|
||||
def setUp(self):
|
||||
app.config['TESTING'] = True
|
||||
self.client = app.test_client()
|
||||
|
||||
def test_index_page(self):
|
||||
"""Test that / returns the refresh meta tag"""
|
||||
response = self.client.get('/')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(b'url=/adults', response.data)
|
||||
|
||||
@patch('app.get_cached_data', side_effect=_bypass_cache)
|
||||
@patch('app.fetch_sheet_data')
|
||||
def test_payments_route(self, mock_fetch_sheet, mock_cache):
|
||||
"""Test that /payments returns 200 and groups transactions"""
|
||||
mock_fetch_sheet.return_value = [{
|
||||
'date': '2026-01-01',
|
||||
'amount': 750,
|
||||
'person': 'Test Member',
|
||||
'purpose': '2026-01',
|
||||
'message': 'Direct Member Payment',
|
||||
'sender': 'External Bank User'
|
||||
}]
|
||||
response = self.client.get('/payments')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(b'Payments Ledger', response.data)
|
||||
self.assertIn(b'Test Member', response.data)
|
||||
self.assertIn(b'Direct Member Payment', response.data)
|
||||
|
||||
@patch('app.get_cached_data', side_effect=_bypass_cache)
|
||||
@patch('app.fetch_sheet_data')
|
||||
@patch('app.fetch_exceptions', return_value={})
|
||||
@patch('app.get_members_with_fees')
|
||||
def test_adults_route(self, mock_get_members, mock_exceptions, mock_fetch_sheet, mock_cache):
|
||||
"""Test that /adults returns 200 and shows combined matches"""
|
||||
mock_get_members.return_value = (
|
||||
[('Test Member', 'A', {'2026-01': (750, 4)})],
|
||||
['2026-01']
|
||||
)
|
||||
mock_fetch_sheet.return_value = [{
|
||||
'date': '2026-01-01',
|
||||
'amount': 750,
|
||||
'person': 'Test Member',
|
||||
'purpose': '2026-01',
|
||||
'message': 'test payment',
|
||||
'sender': 'External Bank User',
|
||||
'inferred_amount': 750
|
||||
}]
|
||||
|
||||
response = self.client.get('/adults')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(b'Adults Dashboard', response.data)
|
||||
self.assertIn(b'Test Member', response.data)
|
||||
self.assertNotIn(b'OK', response.data)
|
||||
self.assertIn(b'750/750 CZK (4)', response.data)
|
||||
|
||||
@patch('app.get_cached_data', side_effect=_bypass_cache)
|
||||
@patch('app.fetch_sheet_data')
|
||||
@patch('app.fetch_exceptions', return_value={})
|
||||
@patch('app.get_junior_members_with_fees')
|
||||
def test_juniors_route(self, mock_get_junior_members, mock_exceptions, mock_fetch_sheet, mock_cache):
|
||||
"""Test that /juniors returns 200, uses single line format, and displays '?' properly"""
|
||||
mock_get_junior_members.return_value = (
|
||||
[
|
||||
('Junior One', 'J', {'2026-01': (500, 3, 0, 3)}),
|
||||
('Junior Two', 'X', {'2026-01': ('?', 1, 0, 1)})
|
||||
],
|
||||
['2026-01']
|
||||
)
|
||||
mock_exceptions.return_value = {}
|
||||
mock_fetch_sheet.return_value = [{
|
||||
'date': '2026-01-15',
|
||||
'amount': 500,
|
||||
'person': 'Junior One',
|
||||
'purpose': '2026-01',
|
||||
'message': '',
|
||||
'sender': 'Parent',
|
||||
'inferred_amount': 500
|
||||
}]
|
||||
|
||||
response = self.client.get('/juniors')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(b'Juniors Dashboard', response.data)
|
||||
self.assertIn(b'Junior One', response.data)
|
||||
self.assertIn(b'Junior Two', response.data)
|
||||
self.assertNotIn(b'OK', response.data)
|
||||
self.assertIn(b'500/500 CZK', response.data)
|
||||
self.assertIn(b'?', response.data)
|
||||
|
||||
def test_api_version(self):
|
||||
"""Test /api/version returns BUILD_META keys as JSON."""
|
||||
response = self.client.get('/api/version')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(response.is_json)
|
||||
data = json.loads(response.data)
|
||||
self.assertEqual(set(data.keys()), EXPECTED_VERSION_KEYS)
|
||||
|
||||
@patch('app.get_cached_data', side_effect=_bypass_cache)
|
||||
@patch('app.fetch_sheet_data')
|
||||
@patch('app.fetch_exceptions', return_value={})
|
||||
@patch('app.get_members_with_fees')
|
||||
def test_api_adults(self, mock_get_members, mock_exceptions, mock_fetch_sheet, mock_cache):
|
||||
"""Test /api/adults returns JSON with correct top-level keys and unwrapped fields."""
|
||||
mock_get_members.return_value = (
|
||||
[('Test Member', 'A', {'2026-01': (750, 4)})],
|
||||
['2026-01']
|
||||
)
|
||||
mock_fetch_sheet.return_value = [{
|
||||
'date': '2026-01-01', 'amount': 750, 'person': 'Test Member',
|
||||
'purpose': '2026-01', 'message': 'test payment',
|
||||
'sender': 'External Bank User', 'inferred_amount': 750,
|
||||
'vs': '', 'sync_id': 'abc123',
|
||||
}]
|
||||
response = self.client.get('/api/adults')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(response.is_json)
|
||||
data = json.loads(response.data)
|
||||
self.assertEqual(set(data.keys()), EXPECTED_ADULTS_KEYS)
|
||||
self.assertIsInstance(data['member_data'], dict)
|
||||
self.assertIsInstance(data['month_labels'], dict)
|
||||
self.assertIsInstance(data['raw_payments'], dict)
|
||||
|
||||
@patch('app.get_cached_data', side_effect=_bypass_cache)
|
||||
@patch('app.fetch_sheet_data')
|
||||
@patch('app.fetch_exceptions', return_value={})
|
||||
@patch('app.get_junior_members_with_fees')
|
||||
def test_api_juniors(self, mock_get_junior_members, mock_exceptions, mock_fetch_sheet, mock_cache):
|
||||
"""Test /api/juniors returns JSON with correct top-level keys and unwrapped fields."""
|
||||
mock_get_junior_members.return_value = (
|
||||
[
|
||||
('Junior One', 'J', {'2026-01': (500, 3, 0, 3)}),
|
||||
('Junior Two', 'X', {'2026-01': ('?', 1, 0, 1)}),
|
||||
],
|
||||
['2026-01']
|
||||
)
|
||||
mock_fetch_sheet.return_value = [{
|
||||
'date': '2026-01-15', 'amount': 500, 'person': 'Junior One',
|
||||
'purpose': '2026-01', 'message': '', 'sender': 'Parent', 'inferred_amount': 500,
|
||||
'vs': '', 'sync_id': 'def456',
|
||||
}]
|
||||
response = self.client.get('/api/juniors')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(response.is_json)
|
||||
data = json.loads(response.data)
|
||||
self.assertEqual(set(data.keys()), EXPECTED_JUNIORS_KEYS)
|
||||
self.assertIsInstance(data['member_data'], dict)
|
||||
self.assertIsInstance(data['month_labels'], dict)
|
||||
self.assertIsInstance(data['raw_payments'], dict)
|
||||
|
||||
@patch('app.get_cached_data', side_effect=_bypass_cache)
|
||||
@patch('app.fetch_sheet_data')
|
||||
def test_api_payments(self, mock_fetch_sheet, mock_cache):
|
||||
"""Test /api/payments returns JSON with correct top-level keys."""
|
||||
mock_fetch_sheet.return_value = [{
|
||||
'date': '2026-01-01', 'amount': 750, 'person': 'Test Member',
|
||||
'purpose': '2026-01', 'message': 'test', 'sender': 'Someone',
|
||||
'vs': '', 'sync_id': 'ghi789',
|
||||
}]
|
||||
response = self.client.get('/api/payments')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(response.is_json)
|
||||
data = json.loads(response.data)
|
||||
self.assertEqual(set(data.keys()), EXPECTED_PAYMENTS_KEYS)
|
||||
self.assertIsInstance(data['grouped_payments'], dict)
|
||||
self.assertIsInstance(data['sorted_people'], list)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,72 @@
|
||||
import unittest
|
||||
from scripts.match_payments import match_members
|
||||
|
||||
|
||||
MEMBERS = [
|
||||
"Henrietta Ottová",
|
||||
"Tomáš Němeček (Tov)",
|
||||
"František Vrbík (Štrúdl)",
|
||||
"Jana Nováková",
|
||||
]
|
||||
|
||||
|
||||
class TestMatchMembersExact(unittest.TestCase):
|
||||
def test_full_name_in_message_returns_only_that_member(self):
|
||||
# "tov" is a substring of "ottova" — the old code returned both members
|
||||
result = match_members("Henrietta Ottová (Heny): 04/2026", MEMBERS)
|
||||
names = [r[0] for r in result]
|
||||
self.assertEqual(names, ["Henrietta Ottová"])
|
||||
self.assertTrue(all(conf == "auto" for _, conf in result))
|
||||
|
||||
def test_nickname_tov_not_matched_inside_ottova(self):
|
||||
# Bare nickname message should NOT match Tomáš via "tov" inside "ottova"
|
||||
result = match_members("platba ottova 04/2026", MEMBERS)
|
||||
names = [r[0] for r in result]
|
||||
self.assertNotIn("Tomáš Němeček (Tov)", names)
|
||||
|
||||
def test_combined_payment_two_full_names(self):
|
||||
result = match_members("Henrietta Ottová a Tomáš Němeček 04/2026", MEMBERS)
|
||||
names = [r[0] for r in result]
|
||||
self.assertIn("Henrietta Ottová", names)
|
||||
self.assertIn("Tomáš Němeček (Tov)", names)
|
||||
self.assertTrue(all(conf == "auto" for _, conf in result))
|
||||
|
||||
def test_nickname_alone_still_matches_correctly(self):
|
||||
# "Tov" alone should still match Tomáš (as long as "ottova" is not in the text)
|
||||
result = match_members("Tov platba 04/2026", MEMBERS)
|
||||
names = [r[0] for r in result]
|
||||
self.assertIn("Tomáš Němeček (Tov)", names)
|
||||
|
||||
def test_full_name_no_diacritics_still_matches(self):
|
||||
result = match_members("Henrietta Ottova 04/2026", MEMBERS)
|
||||
names = [r[0] for r in result]
|
||||
self.assertIn("Henrietta Ottová", names)
|
||||
self.assertNotIn("Tomáš Němeček (Tov)", names)
|
||||
|
||||
def test_first_last_name_present_any_order(self):
|
||||
result = match_members("Platba od Nemeček Tomas 04/2026", MEMBERS)
|
||||
names = [r[0] for r in result]
|
||||
self.assertIn("Tomáš Němeček (Tov)", names)
|
||||
|
||||
def test_shared_first_name_junior_in_roster_wins_exact(self):
|
||||
# Regression: two members share first name "Jáchym"; message has full name
|
||||
# of the junior-only member → exact match must win, no [?] on the adult.
|
||||
roster = ["Jáchym Hrušák (G)", "Jáchym Kubík"]
|
||||
result = match_members(
|
||||
"JIŘÍ KUBÍK Jáchym Kubík: 01/2026+03/2026+04/2026", roster
|
||||
)
|
||||
self.assertEqual(result, [("Jáchym Kubík", "auto")])
|
||||
|
||||
def test_shared_first_name_without_junior_in_roster_falls_back(self):
|
||||
# Without Kubík in the roster (old behaviour), Hrušák wins via first-name
|
||||
# partial match — confirms the roster-expansion fix is the real solution.
|
||||
roster = ["Jáchym Hrušák (G)"]
|
||||
result = match_members(
|
||||
"JIŘÍ KUBÍK Jáchym Kubík: 01/2026+03/2026+04/2026", roster
|
||||
)
|
||||
names = [r[0] for r in result]
|
||||
self.assertIn("Jáchym Hrušák (G)", names)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,69 @@
|
||||
import unittest
|
||||
|
||||
from scripts.match_payments import canonical_member_key, reconcile
|
||||
|
||||
|
||||
class TestCanonicalMemberKey(unittest.TestCase):
|
||||
def test_diacritics_and_case_collapse(self):
|
||||
self.assertEqual(canonical_member_key("Mária Maco"), "maria maco")
|
||||
self.assertEqual(canonical_member_key("MARIA MACO"), "maria maco")
|
||||
self.assertEqual(canonical_member_key("maria maco"), "maria maco")
|
||||
|
||||
def test_whitespace_runs_collapse(self):
|
||||
self.assertEqual(canonical_member_key("Mária Maco"), "maria maco")
|
||||
self.assertEqual(canonical_member_key(" Mária Maco "), "maria maco")
|
||||
|
||||
def test_unknown_name_passes_through_normalized(self):
|
||||
# Two genuinely different names must not collide.
|
||||
self.assertNotEqual(
|
||||
canonical_member_key("Mária Maco"),
|
||||
canonical_member_key("Marek Maco"),
|
||||
)
|
||||
|
||||
|
||||
class TestReconcileTolerantPersonMatching(unittest.TestCase):
|
||||
def _members(self):
|
||||
return [("Mária Maco", "A", {"2026-04": (750, 4)})]
|
||||
|
||||
def _tx(self, person):
|
||||
return {
|
||||
"date": "2026-04-15",
|
||||
"amount": 750,
|
||||
"person": person,
|
||||
"purpose": "2026-04",
|
||||
"inferred_amount": 750,
|
||||
"sender": "Maco Family",
|
||||
"message": "fee",
|
||||
}
|
||||
|
||||
def test_person_without_diacritics_matches(self):
|
||||
result = reconcile(self._members(), ["2026-04"], [self._tx("Maria Maco")], {})
|
||||
|
||||
member = result["members"]["Mária Maco"]
|
||||
self.assertEqual(member["months"]["2026-04"]["paid"], 750)
|
||||
self.assertEqual(len(member["months"]["2026-04"]["transactions"]), 1)
|
||||
self.assertEqual(result["unmatched"], [])
|
||||
|
||||
def test_person_with_extra_whitespace_matches(self):
|
||||
result = reconcile(self._members(), ["2026-04"], [self._tx("Mária Maco")], {})
|
||||
|
||||
self.assertEqual(result["members"]["Mária Maco"]["months"]["2026-04"]["paid"], 750)
|
||||
self.assertEqual(result["unmatched"], [])
|
||||
|
||||
def test_person_lowercase_matches(self):
|
||||
result = reconcile(self._members(), ["2026-04"], [self._tx("mária maco")], {})
|
||||
|
||||
self.assertEqual(result["members"]["Mária Maco"]["months"]["2026-04"]["paid"], 750)
|
||||
self.assertEqual(result["unmatched"], [])
|
||||
|
||||
def test_truly_unknown_person_still_unmatched(self):
|
||||
result = reconcile(
|
||||
self._members(), ["2026-04"], [self._tx("Někdo Neznámý")], {}
|
||||
)
|
||||
|
||||
self.assertEqual(result["members"]["Mária Maco"]["months"]["2026-04"]["paid"], 0)
|
||||
self.assertEqual(len(result["unmatched"]), 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,169 @@
|
||||
import unittest
|
||||
from scripts.match_payments import reconcile
|
||||
|
||||
class TestReconcileWithExceptions(unittest.TestCase):
|
||||
def test_reconcile_applies_exceptions(self):
|
||||
# 1. Setup mock data
|
||||
# Member: Alice, Tier A, expected 750 (attendance-based)
|
||||
members = [
|
||||
('Alice', 'A', {'2026-01': (750, 4)})
|
||||
]
|
||||
sorted_months = ['2026-01']
|
||||
|
||||
# Exception: Alice should only pay 400 in 2026-01 (normalized keys, no accents)
|
||||
exceptions = {
|
||||
('alice', '2026-01'): {'amount': 400, 'note': 'Test exception'}
|
||||
}
|
||||
|
||||
# Transaction: Alice paid 400
|
||||
transactions = [{
|
||||
'date': '2026-01-05',
|
||||
'amount': 400,
|
||||
'person': 'Alice',
|
||||
'purpose': '2026-01',
|
||||
'inferred_amount': 400,
|
||||
'sender': 'Alice Sender',
|
||||
'message': 'fee'
|
||||
}]
|
||||
|
||||
# 2. Reconcile
|
||||
result = reconcile(members, sorted_months, transactions, exceptions)
|
||||
|
||||
# 3. Assertions
|
||||
alice_data = result['members']['Alice']
|
||||
jan_data = alice_data['months']['2026-01']
|
||||
|
||||
self.assertEqual(jan_data['expected'], 400, "Expected amount should be overridden by exception")
|
||||
self.assertEqual(jan_data['paid'], 400, "Paid amount should be 400")
|
||||
self.assertEqual(alice_data['total_balance'], 0, "Balance should be 0 because 400/400")
|
||||
|
||||
def test_reconcile_fallback_to_attendance(self):
|
||||
# Alice has attendance-based fee 750, NO exception
|
||||
members = [
|
||||
('Alice', 'A', {'2026-01': (750, 4)})
|
||||
]
|
||||
sorted_months = ['2026-01']
|
||||
exceptions = {} # No exceptions
|
||||
|
||||
transactions = []
|
||||
|
||||
result = reconcile(members, sorted_months, transactions, exceptions)
|
||||
|
||||
alice_data = result['members']['Alice']
|
||||
self.assertEqual(alice_data['months']['2026-01']['expected'], 750, "Should fallback to attendance fee")
|
||||
|
||||
def _tx(person, purpose, amount):
|
||||
return {
|
||||
'date': '2026-01-01',
|
||||
'amount': amount,
|
||||
'person': person,
|
||||
'purpose': purpose,
|
||||
'inferred_amount': amount,
|
||||
'sender': 'Sender',
|
||||
'message': 'fee',
|
||||
}
|
||||
|
||||
|
||||
class TestMultiMonthAllocation(unittest.TestCase):
|
||||
"""Fee-aware allocation across multiple months in a single payment."""
|
||||
|
||||
def test_greedy_exact_match(self):
|
||||
"""Payment equals total expected → every month fully covered (green)."""
|
||||
members = [('Alice', 'A', {'2026-02': (750, 3), '2026-03': (350, 3), '2026-04': (150, 2)})]
|
||||
sorted_months = ['2026-02', '2026-03', '2026-04']
|
||||
tx = _tx('Alice', '2026-02, 2026-03, 2026-04', 1250)
|
||||
|
||||
result = reconcile(members, sorted_months, [tx])
|
||||
months = result['members']['Alice']['months']
|
||||
|
||||
self.assertEqual(int(months['2026-02']['paid']), 750)
|
||||
self.assertEqual(int(months['2026-03']['paid']), 350)
|
||||
self.assertEqual(int(months['2026-04']['paid']), 150)
|
||||
|
||||
def test_greedy_overpayment_goes_to_credit(self):
|
||||
"""Payment exceeds total expected → each month fully covered, surplus → credit."""
|
||||
members = [('Alice', 'A', {'2026-01': (750, 3), '2026-02': (750, 3)})]
|
||||
sorted_months = ['2026-01', '2026-02']
|
||||
tx = _tx('Alice', '2026-01, 2026-02', 2000)
|
||||
|
||||
result = reconcile(members, sorted_months, [tx])
|
||||
months = result['members']['Alice']['months']
|
||||
|
||||
self.assertEqual(int(months['2026-01']['paid']), 750)
|
||||
self.assertEqual(int(months['2026-02']['paid']), 750)
|
||||
self.assertEqual(result['credits'].get('Alice', 0), 500)
|
||||
|
||||
def test_underpayment_fills_earliest_first(self):
|
||||
"""Payment < total expected → fill earliest months first, spill remainder to later."""
|
||||
members = [('Alice', 'A', {'2026-02': (750, 3), '2026-03': (350, 3), '2026-04': (750, 3)})]
|
||||
sorted_months = ['2026-02', '2026-03', '2026-04']
|
||||
amount = 1250
|
||||
tx = _tx('Alice', '2026-02, 2026-03, 2026-04', amount)
|
||||
|
||||
result = reconcile(members, sorted_months, [tx])
|
||||
months = result['members']['Alice']['months']
|
||||
|
||||
# 02 filled first (750), then 03 (350), then remainder 150 to 04
|
||||
self.assertAlmostEqual(months['2026-02']['paid'], 750, places=2)
|
||||
self.assertAlmostEqual(months['2026-03']['paid'], 350, places=2)
|
||||
self.assertAlmostEqual(months['2026-04']['paid'], 150, places=2)
|
||||
# No CZK lost
|
||||
self.assertAlmostEqual(
|
||||
months['2026-02']['paid'] + months['2026-03']['paid'] + months['2026-04']['paid'],
|
||||
amount, places=2,
|
||||
)
|
||||
|
||||
def test_fill_first_across_two_transactions(self):
|
||||
"""Prior txn fills 02 partially; later txn finishes 02 then spills to 03."""
|
||||
members = [('Matyáš', 'A', {'2026-02': (500, 2), '2026-03': (250, 1)})]
|
||||
sorted_months = ['2026-02', '2026-03']
|
||||
tx1 = _tx('Matyáš', '2026-02', 200)
|
||||
tx2 = _tx('Matyáš', '2026-02, 2026-03', 550)
|
||||
|
||||
result = reconcile(members, sorted_months, [tx1, tx2])
|
||||
months = result['members']['Matyáš']['months']
|
||||
|
||||
self.assertAlmostEqual(months['2026-02']['paid'], 500, places=2)
|
||||
self.assertAlmostEqual(months['2026-03']['paid'], 250, places=2)
|
||||
|
||||
def test_single_month_unchanged(self):
|
||||
"""Single-month payment: full amount goes to that month (regression guard)."""
|
||||
members = [('Alice', 'A', {'2026-01': (750, 3)})]
|
||||
sorted_months = ['2026-01']
|
||||
tx = _tx('Alice', '2026-01', 750)
|
||||
|
||||
result = reconcile(members, sorted_months, [tx])
|
||||
self.assertAlmostEqual(result['members']['Alice']['months']['2026-01']['paid'], 750, places=2)
|
||||
|
||||
def test_two_members_multi_month(self):
|
||||
"""Two members, 2 months: each member gets member_share, then fee-aware per month."""
|
||||
members = [
|
||||
('Alice', 'A', {'2026-01': (750, 3), '2026-02': (350, 3)}),
|
||||
('Bob', 'A', {'2026-01': (750, 3), '2026-02': (350, 3)}),
|
||||
]
|
||||
sorted_months = ['2026-01', '2026-02']
|
||||
# Both members pay together; total expected per member = 1100
|
||||
tx = _tx('Alice, Bob', '2026-01, 2026-02', 2200)
|
||||
|
||||
result = reconcile(members, sorted_months, [tx])
|
||||
|
||||
for name in ('Alice', 'Bob'):
|
||||
months = result['members'][name]['months']
|
||||
self.assertAlmostEqual(months['2026-01']['paid'], 750, places=2)
|
||||
self.assertAlmostEqual(months['2026-02']['paid'], 350, places=2)
|
||||
|
||||
def test_fallback_even_split_when_no_expected(self):
|
||||
"""All matched months have expected=0 → falls back to even split."""
|
||||
members = [('Alice', 'A', {'2026-01': (0, 0), '2026-02': (0, 0)})]
|
||||
sorted_months = ['2026-01', '2026-02']
|
||||
tx = _tx('Alice', '2026-01, 2026-02', 300)
|
||||
|
||||
result = reconcile(members, sorted_months, [tx])
|
||||
months = result['members']['Alice']['months']
|
||||
|
||||
self.assertAlmostEqual(months['2026-01']['paid'], 150, places=2)
|
||||
self.assertAlmostEqual(months['2026-02']['paid'], 150, places=2)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Generated
+778
@@ -0,0 +1,778 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.13"
|
||||
|
||||
[[package]]
|
||||
name = "blinker"
|
||||
version = "1.9.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2026.2.25"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cffi"
|
||||
version = "2.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "charset-normalizer"
|
||||
version = "3.4.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coverage"
|
||||
version = "7.13.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/db/23/aad45061a31677d68e47499197a131eea55da4875d16c1f42021ab963503/coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9", size = 219474, upload-time = "2026-02-09T12:57:19.332Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/70/9b8b67a0945f3dfec1fd896c5cefb7c19d5a3a6d74630b99a895170999ae/coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac", size = 219844, upload-time = "2026-02-09T12:57:20.66Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/fd/7e859f8fab324cef6c4ad7cff156ca7c489fef9179d5749b0c8d321281c2/coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea", size = 250832, upload-time = "2026-02-09T12:57:22.007Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/dc/b2442d10020c2f52617828862d8b6ee337859cd8f3a1f13d607dddda9cf7/coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b", size = 253434, upload-time = "2026-02-09T12:57:23.339Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/88/6728a7ad17428b18d836540630487231f5470fb82454871149502f5e5aa2/coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525", size = 254676, upload-time = "2026-02-09T12:57:24.774Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/bc/21244b1b8cedf0dff0a2b53b208015fe798d5f2a8d5348dbfece04224fff/coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242", size = 256807, upload-time = "2026-02-09T12:57:26.125Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/a0/ddba7ed3251cff51006737a727d84e05b61517d1784a9988a846ba508877/coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148", size = 251058, upload-time = "2026-02-09T12:57:27.614Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/55/e289addf7ff54d3a540526f33751951bf0878f3809b47f6dfb3def69c6f7/coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a", size = 252805, upload-time = "2026-02-09T12:57:29.066Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/4e/cc276b1fa4a59be56d96f1dabddbdc30f4ba22e3b1cd42504c37b3313255/coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23", size = 250766, upload-time = "2026-02-09T12:57:30.522Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/44/1093b8f93018f8b41a8cf29636c9292502f05e4a113d4d107d14a3acd044/coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80", size = 254923, upload-time = "2026-02-09T12:57:31.946Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/55/ea2796da2d42257f37dbea1aab239ba9263b31bd91d5527cdd6db5efe174/coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea", size = 250591, upload-time = "2026-02-09T12:57:33.842Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/fa/7c4bb72aacf8af5020675aa633e59c1fbe296d22aed191b6a5b711eb2bc7/coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a", size = 252364, upload-time = "2026-02-09T12:57:35.743Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/38/a8d2ec0146479c20bbaa7181b5b455a0c41101eed57f10dd19a78ab44c80/coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d", size = 222010, upload-time = "2026-02-09T12:57:37.25Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/0c/dbfafbe90a185943dcfbc766fe0e1909f658811492d79b741523a414a6cc/coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd", size = 222818, upload-time = "2026-02-09T12:57:38.734Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/d1/934918a138c932c90d78301f45f677fb05c39a3112b96fd2c8e60503cdc7/coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af", size = 221438, upload-time = "2026-02-09T12:57:40.223Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/57/ee93ced533bcb3e6df961c0c6e42da2fc6addae53fb95b94a89b1e33ebd7/coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d", size = 220165, upload-time = "2026-02-09T12:57:41.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/e0/969fc285a6fbdda49d91af278488d904dcd7651b2693872f0ff94e40e84a/coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12", size = 220516, upload-time = "2026-02-09T12:57:44.215Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/b8/9531944e16267e2735a30a9641ff49671f07e8138ecf1ca13db9fd2560c7/coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b", size = 261804, upload-time = "2026-02-09T12:57:45.989Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/f3/e63df6d500314a2a60390d1989240d5f27318a7a68fa30ad3806e2a9323e/coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9", size = 263885, upload-time = "2026-02-09T12:57:47.42Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/67/7654810de580e14b37670b60a09c599fa348e48312db5b216d730857ffe6/coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092", size = 266308, upload-time = "2026-02-09T12:57:49.345Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/6f/39d41eca0eab3cc82115953ad41c4e77935286c930e8fad15eaed1389d83/coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9", size = 267452, upload-time = "2026-02-09T12:57:50.811Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/6d/39c0fbb8fc5cd4d2090811e553c2108cf5112e882f82505ee7495349a6bf/coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26", size = 261057, upload-time = "2026-02-09T12:57:52.447Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/a2/60010c669df5fa603bb5a97fb75407e191a846510da70ac657eb696b7fce/coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2", size = 263875, upload-time = "2026-02-09T12:57:53.938Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/d9/63b22a6bdbd17f1f96e9ed58604c2a6b0e72a9133e37d663bef185877cf6/coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940", size = 261500, upload-time = "2026-02-09T12:57:56.012Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/bf/69f86ba1ad85bc3ad240e4c0e57a2e620fbc0e1645a47b5c62f0e941ad7f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c", size = 265212, upload-time = "2026-02-09T12:57:57.5Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/f2/5f65a278a8c2148731831574c73e42f57204243d33bedaaf18fa79c5958f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0", size = 260398, upload-time = "2026-02-09T12:57:59.027Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/80/6e8280a350ee9fea92f14b8357448a242dcaa243cb2c72ab0ca591f66c8c/coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b", size = 262584, upload-time = "2026-02-09T12:58:01.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/63/01ff182fc95f260b539590fb12c11ad3e21332c15f9799cb5e2386f71d9f/coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9", size = 222688, upload-time = "2026-02-09T12:58:02.736Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/43/89de4ef5d3cd53b886afa114065f7e9d3707bdb3e5efae13535b46ae483d/coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd", size = 223746, upload-time = "2026-02-09T12:58:05.362Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/39/7cf0aa9a10d470a5309b38b289b9bb07ddeac5d61af9b664fe9775a4cb3e/coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997", size = 222003, upload-time = "2026-02-09T12:58:06.952Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/11/a9cf762bb83386467737d32187756a42094927150c3e107df4cb078e8590/coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601", size = 219522, upload-time = "2026-02-09T12:58:08.623Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/28/56e6d892b7b052236d67c95f1936b6a7cf7c3e2634bf27610b8cbd7f9c60/coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689", size = 219855, upload-time = "2026-02-09T12:58:10.176Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/69/233459ee9eb0c0d10fcc2fe425a029b3fa5ce0f040c966ebce851d030c70/coverage-7.13.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c", size = 250887, upload-time = "2026-02-09T12:58:12.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/90/2cdab0974b9b5bbc1623f7876b73603aecac11b8d95b85b5b86b32de5eab/coverage-7.13.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129", size = 253396, upload-time = "2026-02-09T12:58:14.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/15/ea4da0f85bf7d7b27635039e649e99deb8173fe551096ea15017f7053537/coverage-7.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552", size = 254745, upload-time = "2026-02-09T12:58:16.162Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/11/bb356e86920c655ca4d61daee4e2bbc7258f0a37de0be32d233b561134ff/coverage-7.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a", size = 257055, upload-time = "2026-02-09T12:58:17.892Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/0f/9ae1f8cb17029e09da06ca4e28c9e1d5c1c0a511c7074592e37e0836c915/coverage-7.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356", size = 250911, upload-time = "2026-02-09T12:58:19.495Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/3a/adfb68558fa815cbc29747b553bc833d2150228f251b127f1ce97e48547c/coverage-7.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71", size = 252754, upload-time = "2026-02-09T12:58:21.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/b1/540d0c27c4e748bd3cd0bd001076ee416eda993c2bae47a73b7cc9357931/coverage-7.13.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5", size = 250720, upload-time = "2026-02-09T12:58:22.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/95/383609462b3ffb1fe133014a7c84fc0dd01ed55ac6140fa1093b5af7ebb1/coverage-7.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98", size = 254994, upload-time = "2026-02-09T12:58:24.548Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/ba/1761138e86c81680bfc3c49579d66312865457f9fe405b033184e5793cb3/coverage-7.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5", size = 250531, upload-time = "2026-02-09T12:58:26.271Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/8e/05900df797a9c11837ab59c4d6fe94094e029582aab75c3309a93e6fb4e3/coverage-7.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0", size = 252189, upload-time = "2026-02-09T12:58:27.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/bd/29c9f2db9ea4ed2738b8a9508c35626eb205d51af4ab7bf56a21a2e49926/coverage-7.13.4-cp314-cp314-win32.whl", hash = "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb", size = 222258, upload-time = "2026-02-09T12:58:29.441Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/4d/1f8e723f6829977410efeb88f73673d794075091c8c7c18848d273dc9d73/coverage-7.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505", size = 223073, upload-time = "2026-02-09T12:58:31.026Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/5b/84100025be913b44e082ea32abcf1afbf4e872f5120b7a1cab1d331b1e13/coverage-7.13.4-cp314-cp314-win_arm64.whl", hash = "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2", size = 221638, upload-time = "2026-02-09T12:58:32.599Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/e4/c884a405d6ead1370433dad1e3720216b4f9fd8ef5b64bfd984a2a60a11a/coverage-7.13.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056", size = 220246, upload-time = "2026-02-09T12:58:34.181Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/5c/4d7ed8b23b233b0fffbc9dfec53c232be2e695468523242ea9fd30f97ad2/coverage-7.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc", size = 220514, upload-time = "2026-02-09T12:58:35.704Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/6f/3284d4203fd2f28edd73034968398cd2d4cb04ab192abc8cff007ea35679/coverage-7.13.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9", size = 261877, upload-time = "2026-02-09T12:58:37.864Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/aa/b672a647bbe1556a85337dc95bfd40d146e9965ead9cc2fe81bde1e5cbce/coverage-7.13.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf", size = 264004, upload-time = "2026-02-09T12:58:39.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/a1/aa384dbe9181f98bba87dd23dda436f0c6cf2e148aecbb4e50fc51c1a656/coverage-7.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55", size = 266408, upload-time = "2026-02-09T12:58:41.852Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/5e/5150bf17b4019bc600799f376bb9606941e55bd5a775dc1e096b6ffea952/coverage-7.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72", size = 267544, upload-time = "2026-02-09T12:58:44.093Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/ed/f1de5c675987a4a7a672250d2c5c9d73d289dbf13410f00ed7181d8017dd/coverage-7.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a", size = 260980, upload-time = "2026-02-09T12:58:45.721Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/e3/fe758d01850aa172419a6743fe76ba8b92c29d181d4f676ffe2dae2ba631/coverage-7.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6", size = 263871, upload-time = "2026-02-09T12:58:47.334Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/76/b829869d464115e22499541def9796b25312b8cf235d3bb00b39f1675395/coverage-7.13.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3", size = 261472, upload-time = "2026-02-09T12:58:48.995Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/9e/caedb1679e73e2f6ad240173f55218488bfe043e38da577c4ec977489915/coverage-7.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750", size = 265210, upload-time = "2026-02-09T12:58:51.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/10/0dd02cb009b16ede425b49ec344aba13a6ae1dc39600840ea6abcb085ac4/coverage-7.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39", size = 260319, upload-time = "2026-02-09T12:58:53.081Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/8e/234d2c927af27c6d7a5ffad5bd2cf31634c46a477b4c7adfbfa66baf7ebb/coverage-7.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0", size = 262638, upload-time = "2026-02-09T12:58:55.258Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/64/e5547c8ff6964e5965c35a480855911b61509cce544f4d442caa759a0702/coverage-7.13.4-cp314-cp314t-win32.whl", hash = "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea", size = 223040, upload-time = "2026-02-09T12:58:56.936Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/96/38086d58a181aac86d503dfa9c47eb20715a79c3e3acbdf786e92e5c09a8/coverage-7.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932", size = 224148, upload-time = "2026-02-09T12:58:58.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/72/8d10abd3740a0beb98c305e0c3faf454366221c0f37a8bcf8f60020bb65a/coverage-7.13.4-cp314-cp314t-win_arm64.whl", hash = "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b", size = 222172, upload-time = "2026-02-09T12:59:00.396Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "46.0.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flask"
|
||||
version = "3.1.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "blinker" },
|
||||
{ name = "click" },
|
||||
{ name = "itsdangerous" },
|
||||
{ name = "jinja2" },
|
||||
{ name = "markupsafe" },
|
||||
{ name = "werkzeug" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fuj-management"
|
||||
version = "0.10"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "flask" },
|
||||
{ name = "google-api-python-client" },
|
||||
{ name = "google-auth-httplib2" },
|
||||
{ name = "google-auth-oauthlib" },
|
||||
{ name = "gunicorn" },
|
||||
{ name = "qrcode", extra = ["pil"] },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-cov" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "flask", specifier = ">=3.1.3" },
|
||||
{ name = "google-api-python-client", specifier = ">=2.162.0" },
|
||||
{ name = "google-auth-httplib2", specifier = ">=0.2.0" },
|
||||
{ name = "google-auth-oauthlib", specifier = ">=1.2.1" },
|
||||
{ name = "gunicorn", specifier = ">=23.0" },
|
||||
{ name = "qrcode", extras = ["pil"], specifier = ">=8.0" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "pytest", specifier = ">=8.0" },
|
||||
{ name = "pytest-cov", specifier = ">=6.0" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "google-api-core"
|
||||
version = "2.30.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "google-auth" },
|
||||
{ name = "googleapis-common-protos" },
|
||||
{ name = "proto-plus" },
|
||||
{ name = "protobuf" },
|
||||
{ name = "requests" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/22/98/586ec94553b569080caef635f98a3723db36a38eac0e3d7eb3ea9d2e4b9a/google_api_core-2.30.0.tar.gz", hash = "sha256:02edfa9fab31e17fc0befb5f161b3bf93c9096d99aed584625f38065c511ad9b", size = 176959, upload-time = "2026-02-18T20:28:11.926Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/45/27/09c33d67f7e0dcf06d7ac17d196594e66989299374bfb0d4331d1038e76b/google_api_core-2.30.0-py3-none-any.whl", hash = "sha256:80be49ee937ff9aba0fd79a6eddfde35fe658b9953ab9b79c57dd7061afa8df5", size = 173288, upload-time = "2026-02-18T20:28:10.367Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "google-api-python-client"
|
||||
version = "2.190.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "google-api-core" },
|
||||
{ name = "google-auth" },
|
||||
{ name = "google-auth-httplib2" },
|
||||
{ name = "httplib2" },
|
||||
{ name = "uritemplate" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e4/8d/4ab3e3516b93bb50ed7814738ea61d49cba3f72f4e331dc9518ae2731e92/google_api_python_client-2.190.0.tar.gz", hash = "sha256:5357f34552e3724d80d2604c8fa146766e0a9d6bb0afada886fafed9feafeef6", size = 14111143, upload-time = "2026-02-12T00:38:03.37Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/07/ad/223d5f4b0b987669ffeb3eadd7e9f85ece633aa7fd3246f1e2f6238e1e05/google_api_python_client-2.190.0-py3-none-any.whl", hash = "sha256:d9b5266758f96c39b8c21d9bbfeb4e58c14dbfba3c931f7c5a8d7fdcd292dd57", size = 14682070, upload-time = "2026-02-12T00:38:00.974Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "google-auth"
|
||||
version = "2.48.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cryptography" },
|
||||
{ name = "pyasn1-modules" },
|
||||
{ name = "rsa" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0c/41/242044323fbd746615884b1c16639749e73665b718209946ebad7ba8a813/google_auth-2.48.0.tar.gz", hash = "sha256:4f7e706b0cd3208a3d940a19a822c37a476ddba5450156c3e6624a71f7c841ce", size = 326522, upload-time = "2026-01-26T19:22:47.157Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/83/1d/d6466de3a5249d35e832a52834115ca9d1d0de6abc22065f049707516d47/google_auth-2.48.0-py3-none-any.whl", hash = "sha256:2e2a537873d449434252a9632c28bfc268b0adb1e53f9fb62afc5333a975903f", size = 236499, upload-time = "2026-01-26T19:22:45.099Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "google-auth-httplib2"
|
||||
version = "0.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "google-auth" },
|
||||
{ name = "httplib2" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d5/ad/c1f2b1175096a8d04cf202ad5ea6065f108d26be6fc7215876bde4a7981d/google_auth_httplib2-0.3.0.tar.gz", hash = "sha256:177898a0175252480d5ed916aeea183c2df87c1f9c26705d74ae6b951c268b0b", size = 11134, upload-time = "2025-12-15T22:13:51.825Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/99/d5/3c97526c8796d3caf5f4b3bed2b05e8a7102326f00a334e7a438237f3b22/google_auth_httplib2-0.3.0-py3-none-any.whl", hash = "sha256:426167e5df066e3f5a0fc7ea18768c08e7296046594ce4c8c409c2457dd1f776", size = 9529, upload-time = "2025-12-15T22:13:51.048Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "google-auth-oauthlib"
|
||||
version = "1.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "google-auth" },
|
||||
{ name = "requests-oauthlib" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ac/b4/1b19567e4c567b796f5c593d89895f3cfae5a38e04f27c6af87618fd0942/google_auth_oauthlib-1.3.0.tar.gz", hash = "sha256:cd39e807ac7229d6b8b9c1e297321d36fcc8a9e4857dff4301870985df51a528", size = 21777, upload-time = "2026-02-27T14:13:01.489Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/56/909fd5632226d3fba31d7aeffd4754410735d49362f5809956fe3e9af344/google_auth_oauthlib-1.3.0-py3-none-any.whl", hash = "sha256:386b3fb85cf4a5b819c6ad23e3128d975216b4cac76324de1d90b128aaf38f29", size = 19308, upload-time = "2026-02-27T14:12:47.865Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "googleapis-common-protos"
|
||||
version = "1.72.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "protobuf" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e5/7b/adfd75544c415c487b33061fe7ae526165241c1ea133f9a9125a56b39fd8/googleapis_common_protos-1.72.0.tar.gz", hash = "sha256:e55a601c1b32b52d7a3e65f43563e2aa61bcd737998ee672ac9b951cd49319f5", size = 147433, upload-time = "2025-11-06T18:29:24.087Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/ab/09169d5a4612a5f92490806649ac8d41e3ec9129c636754575b3553f4ea4/googleapis_common_protos-1.72.0-py3-none-any.whl", hash = "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038", size = 297515, upload-time = "2025-11-06T18:29:13.14Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gunicorn"
|
||||
version = "25.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "packaging" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/66/13/ef67f59f6a7896fdc2c1d62b5665c5219d6b0a9a1784938eb9a28e55e128/gunicorn-25.1.0.tar.gz", hash = "sha256:1426611d959fa77e7de89f8c0f32eed6aa03ee735f98c01efba3e281b1c47616", size = 594377, upload-time = "2026-02-13T11:09:58.989Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/da/73/4ad5b1f6a2e21cf1e85afdaad2b7b1a933985e2f5d679147a1953aaa192c/gunicorn-25.1.0-py3-none-any.whl", hash = "sha256:d0b1236ccf27f72cfe14bce7caadf467186f19e865094ca84221424e839b8b8b", size = 197067, upload-time = "2026-02-13T11:09:57.146Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httplib2"
|
||||
version = "0.31.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pyparsing" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c1/1f/e86365613582c027dda5ddb64e1010e57a3d53e99ab8a72093fa13d565ec/httplib2-0.31.2.tar.gz", hash = "sha256:385e0869d7397484f4eab426197a4c020b606edd43372492337c0b4010ae5d24", size = 250800, upload-time = "2026-01-23T11:04:44.165Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/90/fd509079dfcab01102c0fdd87f3a9506894bc70afcf9e9785ef6b2b3aff6/httplib2-0.31.2-py3-none-any.whl", hash = "sha256:dbf0c2fa3862acf3c55c078ea9c0bc4481d7dc5117cae71be9514912cf9f8349", size = 91099, upload-time = "2026-01-23T11:04:42.78Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.11"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itsdangerous"
|
||||
version = "2.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jinja2"
|
||||
version = "3.1.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markupsafe" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markupsafe"
|
||||
version = "3.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
|
||||
{ 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 = "oauthlib"
|
||||
version = "3.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pillow"
|
||||
version = "12.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/11/6db24d4bd7685583caeae54b7009584e38da3c3d4488ed4cd25b439de486/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e", size = 4062689, upload-time = "2026-02-11T04:21:06.804Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/c0/ce6d3b1fe190f0021203e0d9b5b99e57843e345f15f9ef22fcd43842fd21/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9", size = 4138535, upload-time = "2026-02-11T04:21:08.452Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/c6/d5eb6a4fb32a3f9c21a8c7613ec706534ea1cf9f4b3663e99f0d83f6fca8/pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6", size = 3601364, upload-time = "2026-02-11T04:21:10.194Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/a1/16c4b823838ba4c9c52c0e6bbda903a3fe5a1bdbf1b8eb4fff7156f3e318/pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60", size = 5262561, upload-time = "2026-02-11T04:21:11.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/ad/ad9dc98ff24f485008aa5cdedaf1a219876f6f6c42a4626c08bc4e80b120/pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2", size = 4657460, upload-time = "2026-02-11T04:21:13.786Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/1b/f1a4ea9a895b5732152789326202a82464d5254759fbacae4deea3069334/pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850", size = 6232698, upload-time = "2026-02-11T04:21:15.949Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/f4/86f51b8745070daf21fd2e5b1fe0eb35d4db9ca26e6d58366562fb56a743/pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289", size = 8041706, upload-time = "2026-02-11T04:21:17.723Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/9b/d6ecd956bb1266dd1045e995cce9b8d77759e740953a1c9aad9502a0461e/pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e", size = 6346621, upload-time = "2026-02-11T04:21:19.547Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/24/538bff45bde96535d7d998c6fed1a751c75ac7c53c37c90dc2601b243893/pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717", size = 7038069, upload-time = "2026-02-11T04:21:21.378Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/0e/58cb1a6bc48f746bc4cb3adb8cabff73e2742c92b3bf7a220b7cf69b9177/pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a", size = 6460040, upload-time = "2026-02-11T04:21:23.148Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/57/9045cb3ff11eeb6c1adce3b2d60d7d299d7b273a2e6c8381a524abfdc474/pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029", size = 7164523, upload-time = "2026-02-11T04:21:25.01Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/f2/9be9cb99f2175f0d4dbadd6616ce1bf068ee54a28277ea1bf1fbf729c250/pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b", size = 6332552, upload-time = "2026-02-11T04:21:27.238Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/eb/b0834ad8b583d7d9d42b80becff092082a1c3c156bb582590fcc973f1c7c/pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1", size = 7040108, upload-time = "2026-02-11T04:21:29.462Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/7d/fc09634e2aabdd0feabaff4a32f4a7d97789223e7c2042fd805ea4b4d2c2/pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a", size = 2453712, upload-time = "2026-02-11T04:21:31.072Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/2a/b9d62794fc8a0dd14c1943df68347badbd5511103e0d04c035ffe5cf2255/pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da", size = 5264880, upload-time = "2026-02-11T04:21:32.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/9d/e03d857d1347fa5ed9247e123fcd2a97b6220e15e9cb73ca0a8d91702c6e/pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc", size = 4660616, upload-time = "2026-02-11T04:21:34.97Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/ec/8a6d22afd02570d30954e043f09c32772bfe143ba9285e2fdb11284952cd/pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c", size = 6269008, upload-time = "2026-02-11T04:21:36.623Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/1d/6d875422c9f28a4a361f495a5f68d9de4a66941dc2c619103ca335fa6446/pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8", size = 8073226, upload-time = "2026-02-11T04:21:38.585Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/cd/134b0b6ee5eda6dc09e25e24b40fdafe11a520bc725c1d0bbaa5e00bf95b/pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20", size = 6380136, upload-time = "2026-02-11T04:21:40.562Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/a9/7628f013f18f001c1b98d8fffe3452f306a70dc6aba7d931019e0492f45e/pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13", size = 7067129, upload-time = "2026-02-11T04:21:42.521Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/f8/66ab30a2193b277785601e82ee2d49f68ea575d9637e5e234faaa98efa4c/pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf", size = 6491807, upload-time = "2026-02-11T04:21:44.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/0b/a877a6627dc8318fdb84e357c5e1a758c0941ab1ddffdafd231983788579/pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524", size = 7190954, upload-time = "2026-02-11T04:21:46.114Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/43/6f732ff85743cf746b1361b91665d9f5155e1483817f693f8d57ea93147f/pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986", size = 6336441, upload-time = "2026-02-11T04:21:48.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/44/e865ef3986611bb75bfabdf94a590016ea327833f434558801122979cd0e/pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c", size = 7045383, upload-time = "2026-02-11T04:21:50.015Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/c6/f4fb24268d0c6908b9f04143697ea18b0379490cb74ba9e8d41b898bd005/pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3", size = 2456104, upload-time = "2026-02-11T04:21:51.633Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proto-plus"
|
||||
version = "1.27.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "protobuf" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3a/02/8832cde80e7380c600fbf55090b6ab7b62bd6825dbedde6d6657c15a1f8e/proto_plus-1.27.1.tar.gz", hash = "sha256:912a7460446625b792f6448bade9e55cd4e41e6ac10e27009ef71a7f317fa147", size = 56929, upload-time = "2026-02-02T17:34:49.035Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/79/ac273cbbf744691821a9cca88957257f41afe271637794975ca090b9588b/proto_plus-1.27.1-py3-none-any.whl", hash = "sha256:e4643061f3a4d0de092d62aa4ad09fa4756b2cbb89d4627f3985018216f9fefc", size = 50480, upload-time = "2026-02-02T17:34:47.339Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "protobuf"
|
||||
version = "6.33.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ba/25/7c72c307aafc96fa87062aa6291d9f7c94836e43214d43722e86037aac02/protobuf-6.33.5.tar.gz", hash = "sha256:6ddcac2a081f8b7b9642c09406bc6a4290128fce5f471cddd165960bb9119e5c", size = 444465, upload-time = "2026-01-29T21:51:33.494Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/79/af92d0a8369732b027e6d6084251dd8e782c685c72da161bd4a2e00fbabb/protobuf-6.33.5-cp310-abi3-win32.whl", hash = "sha256:d71b040839446bac0f4d162e758bea99c8251161dae9d0983a3b88dee345153b", size = 425769, upload-time = "2026-01-29T21:51:21.751Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/75/bb9bc917d10e9ee13dee8607eb9ab963b7cf8be607c46e7862c748aa2af7/protobuf-6.33.5-cp310-abi3-win_amd64.whl", hash = "sha256:3093804752167bcab3998bec9f1048baae6e29505adaf1afd14a37bddede533c", size = 437118, upload-time = "2026-01-29T21:51:24.022Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/6b/e48dfc1191bc5b52950246275bf4089773e91cb5ba3592621723cdddca62/protobuf-6.33.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:a5cb85982d95d906df1e2210e58f8e4f1e3cdc088e52c921a041f9c9a0386de5", size = 427766, upload-time = "2026-01-29T21:51:25.413Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/b1/c79468184310de09d75095ed1314b839eb2f72df71097db9d1404a1b2717/protobuf-6.33.5-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:9b71e0281f36f179d00cbcb119cb19dec4d14a81393e5ea220f64b286173e190", size = 324638, upload-time = "2026-01-29T21:51:26.423Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/f5/65d838092fd01c44d16037953fd4c2cc851e783de9b8f02b27ec4ffd906f/protobuf-6.33.5-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:8afa18e1d6d20af15b417e728e9f60f3aa108ee76f23c3b2c07a2c3b546d3afd", size = 339411, upload-time = "2026-01-29T21:51:27.446Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/53/a9443aa3ca9ba8724fdfa02dd1887c1bcd8e89556b715cfbacca6b63dbec/protobuf-6.33.5-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:cbf16ba3350fb7b889fca858fb215967792dc125b35c7976ca4818bee3521cf0", size = 323465, upload-time = "2026-01-29T21:51:28.925Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/bf/2086963c69bdac3d7cff1cc7ff79b8ce5ea0bec6797a017e1be338a46248/protobuf-6.33.5-py3-none-any.whl", hash = "sha256:69915a973dd0f60f31a08b8318b73eab2bd6a392c79184b3612226b0a3f8ec02", size = 170687, upload-time = "2026-01-29T21:51:32.557Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyasn1"
|
||||
version = "0.6.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fe/b6/6e630dff89739fcd427e3f72b3d905ce0acb85a45d4ec3e2678718a3487f/pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b", size = 146586, upload-time = "2026-01-16T18:04:18.534Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/44/b5/a96872e5184f354da9c84ae119971a0a4c221fe9b27a4d94bd43f2596727/pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf", size = 83371, upload-time = "2026-01-16T18:04:17.174Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyasn1-modules"
|
||||
version = "0.4.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pyasn1" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycparser"
|
||||
version = "3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.19.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyparsing"
|
||||
version = "3.3.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "iniconfig" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-cov"
|
||||
version = "7.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "coverage" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pytest" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "qrcode"
|
||||
version = "8.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8f/b2/7fc2931bfae0af02d5f53b174e9cf701adbb35f39d69c2af63d4a39f81a9/qrcode-8.2.tar.gz", hash = "sha256:35c3f2a4172b33136ab9f6b3ef1c00260dd2f66f858f24d88418a015f446506c", size = 43317, upload-time = "2025-05-01T15:44:24.726Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/b8/d2d6d731733f51684bbf76bf34dab3b70a9148e8f2cef2bb544fccec681a/qrcode-8.2-py3-none-any.whl", hash = "sha256:16e64e0716c14960108e85d853062c9e8bba5ca8252c0b4d0231b9df4060ff4f", size = 45986, upload-time = "2025-05-01T15:44:22.781Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
pil = [
|
||||
{ name = "pillow" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.32.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "charset-normalizer" },
|
||||
{ name = "idna" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "requests-oauthlib"
|
||||
version = "2.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "oauthlib" },
|
||||
{ name = "requests" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650, upload-time = "2024-03-22T20:32:29.939Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179, upload-time = "2024-03-22T20:32:28.055Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rsa"
|
||||
version = "4.9.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pyasn1" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uritemplate"
|
||||
version = "4.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/98/60/f174043244c5306c9988380d2cb10009f91563fc4b31293d27e17201af56/uritemplate-4.2.0.tar.gz", hash = "sha256:480c2ed180878955863323eea31b0ede668795de182617fef9c6ca09e6ec9d0e", size = 33267, upload-time = "2025-06-02T15:12:06.318Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/99/3ae339466c9183ea5b8ae87b34c0b897eda475d2aec2307cae60e5cd4f29/uritemplate-4.2.0-py3-none-any.whl", hash = "sha256:962201ba1c4edcab02e60f9a0d3821e82dfc5d2d6662a21abd533879bdb8a686", size = 11488, upload-time = "2025-06-02T15:12:03.405Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.6.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "werkzeug"
|
||||
version = "3.1.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markupsafe" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/61/f1/ee81806690a87dab5f5653c1f146c92bc066d7f4cebc603ef88eb9e13957/werkzeug-3.1.6.tar.gz", hash = "sha256:210c6bede5a420a913956b4791a7f4d6843a43b6fcee4dfa08a65e93007d0d25", size = 864736, upload-time = "2026-02-19T15:17:18.884Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/ec/d58832f89ede95652fd01f4f24236af7d32b70cab2196dfcc2d2fd13c5c2/werkzeug-3.1.6-py3-none-any.whl", hash = "sha256:7ddf3357bb9564e407607f988f683d72038551200c704012bb9a4c523d42f131", size = 225166, upload-time = "2026-02-19T15:17:17.475Z" },
|
||||
]
|
||||
Reference in New Issue
Block a user