mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 21:47:02 +01:00
`requires-python` and the CI pin already said 3.14, but ruff, mypy and pylint were still grading `meta/` against 3.10 -- three tools judging an interpreter nothing runs any more, and ruff withholding the pyupgrade fixes that only become safe at the newer target. `pin_ci_python.py` rewrites these three knobs. What the stale target was hiding: `datetime.timezone.utc` where `datetime.UTC` is now correct (UP017), and ruff's stable formatter applying PEP 758 -- `except (A, B):` becomes `except A, B:`. That makes the source 3.14-only at the syntax level, which is what `requires-python = ">=3.14"` already promised; kuhy chose it deliberately over holding ruff a release back (2026-08-29). Also drops a comment in `wsg_grabber/models.py` that the bump makes false: it explained why the file used ``str, Enum`` instead of ``StrEnum`` under mypy at 3.10, sitting directly above a ``class Verdict(StrEnum)`` that had already stopped obeying it. ruff, ruff-format, mypy --strict and pylint all clean at the new target. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SfnJmxxsZ534AKk16rC2Gr
196 lines
7.2 KiB
Python
196 lines
7.2 KiB
Python
"""Endurain API client for activity uploads.
|
|
|
|
Endurain does not deduplicate uploads, so the distinction between "definitely
|
|
rejected" and "might have been committed" decides whether a retry is safe:
|
|
|
|
* 4xx -- the server refused the file. Retrying is pointless but harmless;
|
|
the file is routed to Endurain's bulk_import for manual recovery.
|
|
* 5xx / timeout / connection loss -- the server may have created the activity
|
|
before the response was lost. Retrying blindly would duplicate it,
|
|
so the file is left in the inbox and reported as AMBIGUOUS. The
|
|
caller must not treat this as either success or a clean failure.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from datetime import UTC, datetime
|
|
from enum import Enum
|
|
import logging
|
|
from typing import TYPE_CHECKING, Any, Protocol
|
|
|
|
import requests
|
|
|
|
if TYPE_CHECKING:
|
|
from pathlib import Path
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
UPLOAD_PATH = "/api/v1/activities/create/upload"
|
|
# Endurain exposes no "does this run exist" query, so existence is decided by
|
|
# fetching a recent page and comparing start times client-side.
|
|
RECENT_PATH = "/api/v1/activities/user/{user_id}/page_number/1/num_records/{n}"
|
|
_RECENT_RECORDS = 50
|
|
# Start times are compared with a tolerance because the recorded value is not
|
|
# the one in the file: a run whose export embeds 21:51:04Z was stored by
|
|
# Endurain as 21:51:05Z. Two genuinely distinct runs starting inside two
|
|
# minutes of each other is not a real scenario, so the window is generous.
|
|
_START_TOLERANCE_SECONDS = 120
|
|
# The API key goes in X-API-Key. Endurain's own OpenAPI schema declares the
|
|
# APIKeyHeader security scheme as name "X-Client-Type", which is wrong --
|
|
# verified against the running server: X-API-Key with a bad key returns
|
|
# {"detail":"Invalid API key"} (parsed, then rejected), whereas X-Client-Type
|
|
# returns {"detail":"Not authenticated..."} (never read as a key at all).
|
|
_API_KEY_HEADER = "X-API-Key"
|
|
_TIMEOUT = 120
|
|
_HTTP_BAD_REQUEST = 400
|
|
_HTTP_SERVER_ERROR = 500
|
|
|
|
|
|
class SupportsUpload(Protocol):
|
|
"""The upload surface :func:`_process` needs.
|
|
|
|
Declared structurally so tests can substitute a stub without subclassing
|
|
the real client (and without mypy complaining about the swap).
|
|
"""
|
|
|
|
def upload(self, path: Path) -> Result:
|
|
"""Upload one activity file and report what happened."""
|
|
... # pragma: no cover - structural typing only, never executed
|
|
|
|
def recent_start_times(self, user_id: int) -> list[datetime] | None:
|
|
"""Return recent activity start times, or None if unknowable."""
|
|
... # pragma: no cover - structural typing only, never executed
|
|
|
|
|
|
class Outcome(Enum):
|
|
"""What happened to one upload attempt."""
|
|
|
|
OK = "ok"
|
|
REJECTED = "rejected"
|
|
AMBIGUOUS = "ambiguous"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Result:
|
|
"""The outcome of a single upload attempt."""
|
|
|
|
outcome: Outcome
|
|
activity_id: int | None
|
|
detail: str
|
|
|
|
|
|
class EndurainClient:
|
|
"""Minimal client for the endpoints this importer needs."""
|
|
|
|
def __init__(self, base_url: str, api_key: str) -> None:
|
|
"""Build a client for ``base_url`` authenticating with ``api_key``."""
|
|
self._base = base_url.rstrip("/")
|
|
self._session = requests.Session()
|
|
self._session.headers.update({_API_KEY_HEADER: api_key})
|
|
|
|
def about(self) -> dict[str, Any]:
|
|
"""Liveness probe; raises on any non-2xx."""
|
|
resp = self._session.get(f"{self._base}/api/v1/about", timeout=30)
|
|
resp.raise_for_status()
|
|
body: dict[str, Any] = resp.json()
|
|
return body
|
|
|
|
def recent_start_times(self, user_id: int) -> list[datetime] | None:
|
|
"""Return start times of recent activities, or None if unknowable.
|
|
|
|
None means "the question could not be answered" -- a transport failure,
|
|
an auth failure, or a body that did not parse. It is deliberately not
|
|
an empty list: callers must not read a failed lookup as "no matching
|
|
activity exists" and upload on the strength of it.
|
|
"""
|
|
url = f"{self._base}{RECENT_PATH}".format(user_id=user_id, n=_RECENT_RECORDS)
|
|
try:
|
|
resp = self._session.get(url, timeout=30)
|
|
except requests.RequestException as exc:
|
|
_logger.warning("could not list recent activities: %s", exc)
|
|
return None
|
|
if resp.status_code >= _HTTP_BAD_REQUEST:
|
|
_logger.warning(
|
|
"could not list recent activities over HTTP: %s (API keys "
|
|
"cannot read activities; the database fallback answers instead)",
|
|
resp.status_code,
|
|
)
|
|
return None
|
|
try:
|
|
body = resp.json()
|
|
except ValueError:
|
|
_logger.warning("recent activities response was not JSON")
|
|
return None
|
|
if not isinstance(body, list):
|
|
_logger.warning("recent activities response was not a list")
|
|
return None
|
|
return [
|
|
parsed
|
|
for item in body
|
|
if isinstance(item, dict)
|
|
and (parsed := _parse_start(item.get("start_time"))) is not None
|
|
]
|
|
|
|
def upload(self, path: Path) -> Result:
|
|
"""Upload one activity file."""
|
|
try:
|
|
with path.open("rb") as handle:
|
|
resp = self._session.post(
|
|
f"{self._base}{UPLOAD_PATH}",
|
|
files={"file": (path.name, handle)},
|
|
timeout=_TIMEOUT,
|
|
)
|
|
except requests.RequestException as exc:
|
|
# The request may or may not have reached the server.
|
|
return Result(Outcome.AMBIGUOUS, None, f"transport error: {exc}")
|
|
|
|
if resp.status_code >= _HTTP_SERVER_ERROR:
|
|
return Result(Outcome.AMBIGUOUS, None, f"server error {resp.status_code}")
|
|
if resp.status_code >= _HTTP_BAD_REQUEST:
|
|
return Result(
|
|
Outcome.REJECTED,
|
|
None,
|
|
f"{resp.status_code}: {resp.text[:200]}",
|
|
)
|
|
return Result(Outcome.OK, _activity_id(resp), "uploaded")
|
|
|
|
|
|
def _activity_id(resp: requests.Response) -> int | None:
|
|
"""Best-effort extraction of the new activity id from a success body."""
|
|
try:
|
|
body = resp.json()
|
|
except ValueError:
|
|
return None
|
|
if isinstance(body, int):
|
|
return body
|
|
if isinstance(body, dict):
|
|
for key in ("id", "activity_id"):
|
|
value = body.get(key)
|
|
if isinstance(value, int):
|
|
return value
|
|
return None
|
|
|
|
|
|
def _parse_start(raw: object) -> datetime | None:
|
|
"""Parse an Endurain start_time into an aware UTC datetime."""
|
|
if not isinstance(raw, str) or not raw:
|
|
return None
|
|
try:
|
|
parsed = datetime.fromisoformat(raw)
|
|
except ValueError:
|
|
return None
|
|
# Endurain returns local-offset timestamps; a naive value is read as UTC
|
|
# so the comparison below never comes down to the reader's timezone.
|
|
if parsed.tzinfo is None:
|
|
parsed = parsed.replace(tzinfo=UTC)
|
|
return parsed.astimezone(UTC)
|
|
|
|
|
|
def already_present(start: datetime, known: list[datetime]) -> bool:
|
|
"""True when ``start`` matches a known activity within the tolerance."""
|
|
return any(
|
|
abs((start - other).total_seconds()) <= _START_TOLERANCE_SECONDS
|
|
for other in known
|
|
)
|