mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 19:47:12 +01:00
RunnerUp can now upload to Endurain directly as well as through the WebDAV inbox, and the two paths cannot see each other: the same run landed twice (activities 14 and 15, identical start time, distances differing only by tcx/gpx rounding). The ledger only knows what this importer handled, so it could not catch it. Add a third dedupe check that asks Endurain what it already holds, queried once per run. Endurain exposes no query-by-start-time endpoint, so a page of recent activities is fetched and compared client-side, with a +/-120s tolerance: the file for activity 15 embeds 21:51:04Z while the server stored 21:51:05Z, so exact equality misses the very duplicate that motivated this. A failed or unauthorised query returns None, never an empty list, so it can never be read as "nothing matches" and upload on the strength of it. Export filenames are parsed as LOCAL wall-clock time. RunnerUp_2026-08-14- 00-58-45_Running is 2026-08-13 22:58:45 UTC; reading it as UTC would move a late-evening run onto the wrong day and defeat the comparison. Also fix the adb fallback, which broke the moment a second device attached: phone_attached() returned true if ANY device was in the 'device' state, then every bare adb call failed with "more than one device/emulator" as a warning on a path nobody watches. The serial is now resolved once and threaded into every call as -s. Ambiguity is settled by configuration, never by guessing -- several devices and no ENDURAIN_ADB_SERIAL means the fallback is skipped. The remote check stays inert until the API key is granted activities:read; until then it falls back to the ledger, which is the previous behaviour. 85 tests, 100% branch coverage on all four modules, no suppressions. The oversized test modules are split by concern to stay under the 250-line cap. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GbYEnfGnf1988cdFdGebot
220 lines
8.0 KiB
Python
220 lines
8.0 KiB
Python
"""Import RunnerUp activity files into Endurain, exactly once each.
|
|
|
|
Run from a systemd user timer. Configuration comes from the environment:
|
|
|
|
ENDURAIN_URL base URL (default http://127.0.0.1:8085)
|
|
ENDURAIN_API_KEY API key with activities:upload (required) and
|
|
activities:read (optional; enables detection of
|
|
runs RunnerUp uploaded to Endurain directly)
|
|
ENDURAIN_INBOX WebDAV inbox (default ~/cloud/RunnerUp)
|
|
ENDURAIN_STATE ledger directory (default ~/.local/state/endurain-import)
|
|
ENDURAIN_BULK_DIR bulk_import dir for files the server rejected
|
|
ENDURAIN_NO_ADB set to 1 to disable the phone fallback
|
|
ENDURAIN_ADB_SERIAL which phone to pull from when several are attached
|
|
ENDURAIN_USER_ID Endurain user whose activities are checked (default 1)
|
|
|
|
Exit status is 0 only when every file was accounted for; anything left in an
|
|
ambiguous state exits non-zero so the timer surfaces it rather than logging
|
|
quietly and moving on.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
from pathlib import Path
|
|
import shutil
|
|
import sys
|
|
from typing import TYPE_CHECKING
|
|
|
|
import requests
|
|
|
|
from python_pkg.endurain_import.ledger import (
|
|
Entry,
|
|
Ledger,
|
|
activity_key,
|
|
activity_start,
|
|
file_digest,
|
|
now_iso,
|
|
)
|
|
from python_pkg.endurain_import.sources import inbox_files, pull_from_phone
|
|
from python_pkg.endurain_import.upload import (
|
|
EndurainClient,
|
|
Outcome,
|
|
SupportsUpload,
|
|
already_present,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from datetime import datetime
|
|
|
|
_logger = logging.getLogger("endurain_import")
|
|
|
|
_DEFAULT_URL = "http://127.0.0.1:8085"
|
|
_DEFAULT_INBOX = Path.home() / "cloud" / "RunnerUp"
|
|
_DEFAULT_STATE = Path.home() / ".local" / "state" / "endurain-import"
|
|
_DEFAULT_USER_ID = 1
|
|
|
|
|
|
def _configure_logging() -> None:
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
|
)
|
|
|
|
|
|
def _require_api_key() -> str:
|
|
key = os.environ.get("ENDURAIN_API_KEY", "").strip()
|
|
if not key:
|
|
_logger.error(
|
|
"ENDURAIN_API_KEY is not set. Create one in Endurain under "
|
|
"Settings -> Security -> API Keys with the activities:upload scope."
|
|
)
|
|
raise SystemExit(2)
|
|
return key
|
|
|
|
|
|
def _route_rejected(path: Path, bulk_dir: Path | None) -> None:
|
|
"""Copy a server-rejected file into bulk_import for manual recovery.
|
|
|
|
Only ever called for a definite 4xx. Ambiguous failures must not land here:
|
|
Endurain would import them a second time if the first attempt had in fact
|
|
been committed.
|
|
"""
|
|
if bulk_dir is None:
|
|
_logger.warning("no bulk_import dir configured; leaving %s", path.name)
|
|
return
|
|
try:
|
|
bulk_dir.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(path, bulk_dir / path.name)
|
|
_logger.warning("copied %s to bulk_import for manual retry", path.name)
|
|
except OSError:
|
|
_logger.exception("could not stage %s for bulk import", path.name)
|
|
|
|
|
|
def _process(
|
|
path: Path,
|
|
client: SupportsUpload,
|
|
ledger: Ledger,
|
|
processed_dir: Path,
|
|
bulk_dir: Path | None,
|
|
remote_starts: list[datetime] | None = None,
|
|
) -> str:
|
|
"""Import one file. Returns one of: skipped, imported, rejected, ambiguous."""
|
|
digest = file_digest(path)
|
|
key = activity_key(path)
|
|
# Three dedupe checks, cheapest first. The digest catches the identical
|
|
# file arriving twice; the activity key catches the SAME RUN arriving as
|
|
# both .gpx and .tcx, whose bytes differ and which Endurain would
|
|
# otherwise import as two separate activities.
|
|
known_locally = digest in ledger or ledger.has_activity(key)
|
|
# The third check asks Endurain itself, which is the only way to see a run
|
|
# this importer never handled -- RunnerUp's own uploader writes straight to
|
|
# the server and leaves no trace in the ledger. ``remote_starts`` is None
|
|
# when that question could not be answered, and None must never be read as
|
|
# "nothing matches": the local checks then decide alone.
|
|
start = activity_start(path)
|
|
known_remotely = (
|
|
remote_starts is not None
|
|
and start is not None
|
|
and already_present(start, remote_starts)
|
|
)
|
|
if known_locally or known_remotely:
|
|
processed_dir.mkdir(parents=True, exist_ok=True)
|
|
shutil.move(str(path), str(processed_dir / path.name))
|
|
if known_remotely and not known_locally:
|
|
# Record it so the next run skips without needing the query again.
|
|
ledger.record(Entry(digest, key, path.name, None, now_iso()))
|
|
_logger.info(
|
|
"%s is already in Endurain (uploaded outside this importer); "
|
|
"moved to processed/",
|
|
path.name,
|
|
)
|
|
else:
|
|
_logger.info("%s already imported; moved to processed/", path.name)
|
|
return "skipped"
|
|
|
|
result = client.upload(path)
|
|
if result.outcome is Outcome.OK:
|
|
ledger.record(Entry(digest, key, path.name, result.activity_id, now_iso()))
|
|
processed_dir.mkdir(parents=True, exist_ok=True)
|
|
shutil.move(str(path), str(processed_dir / path.name))
|
|
_logger.info("imported %s (activity_id=%s)", path.name, result.activity_id)
|
|
return "imported"
|
|
|
|
if result.outcome is Outcome.REJECTED:
|
|
_logger.error("rejected %s -- %s", path.name, result.detail)
|
|
_route_rejected(path, bulk_dir)
|
|
return "rejected"
|
|
|
|
# Ambiguous: leave the file exactly where it is and do NOT record it.
|
|
_logger.error(
|
|
"ambiguous outcome for %s -- %s. Left in the inbox; verify in Endurain "
|
|
"before the next run to avoid a duplicate.",
|
|
path.name,
|
|
result.detail,
|
|
)
|
|
return "ambiguous"
|
|
|
|
|
|
def main() -> int:
|
|
"""Run one import pass. Returns the process exit status."""
|
|
_configure_logging()
|
|
api_key = _require_api_key()
|
|
base_url = os.environ.get("ENDURAIN_URL", _DEFAULT_URL)
|
|
inbox = Path(os.environ.get("ENDURAIN_INBOX", _DEFAULT_INBOX))
|
|
state = Path(os.environ.get("ENDURAIN_STATE", _DEFAULT_STATE))
|
|
bulk = os.environ.get("ENDURAIN_BULK_DIR")
|
|
bulk_dir = Path(bulk) if bulk else None
|
|
|
|
client = EndurainClient(base_url, api_key)
|
|
try:
|
|
client.about()
|
|
# requests.RequestException covers the transport failures; OSError/ValueError
|
|
# cover a malformed response body. ConnectionError subclasses OSError, so a
|
|
# separate requests handler would be unreachable.
|
|
except (OSError, ValueError, RuntimeError, requests.RequestException):
|
|
_logger.exception("Endurain is not reachable at %s", base_url)
|
|
return 1
|
|
|
|
ledger = Ledger(state / "ledger.json")
|
|
processed_dir = inbox / "processed"
|
|
|
|
if os.environ.get("ENDURAIN_NO_ADB") != "1":
|
|
pull_from_phone(inbox)
|
|
|
|
# TCX carries more than GPX (laps, cadence, HR), so when a run is present
|
|
# in both formats the TCX must be the one that wins the dedupe race.
|
|
pending = sorted(inbox_files(inbox), key=lambda p: p.suffix.lower() != ".tcx")
|
|
if not pending:
|
|
_logger.info("nothing to import (ledger holds %d file(s))", len(ledger))
|
|
return 0
|
|
|
|
# One query per run, not per file: the page is a snapshot of what Endurain
|
|
# already holds, and every file in this pass is compared against it.
|
|
user_id = int(os.environ.get("ENDURAIN_USER_ID", _DEFAULT_USER_ID))
|
|
remote_starts = client.recent_start_times(user_id)
|
|
if remote_starts is None:
|
|
_logger.warning(
|
|
"duplicate detection against Endurain is unavailable; falling "
|
|
"back to the local ledger only"
|
|
)
|
|
|
|
counts = {"skipped": 0, "imported": 0, "rejected": 0, "ambiguous": 0}
|
|
for path in pending:
|
|
counts[
|
|
_process(path, client, ledger, processed_dir, bulk_dir, remote_starts)
|
|
] += 1
|
|
|
|
_logger.info(
|
|
"done: %(imported)d imported, %(skipped)d already known, "
|
|
"%(rejected)d rejected, %(ambiguous)d ambiguous",
|
|
counts,
|
|
)
|
|
# Fail closed so the timer reports a problem instead of hiding it.
|
|
return 1 if counts["ambiguous"] or counts["rejected"] else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|