testsAndMisc/python_pkg/endurain_import/__main__.py
Krzysztof kuhy Rudnicki 325d5a8baa
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Python tests / test (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
Stop the endurain importer duplicating RunnerUp's own uploads
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
2026-08-23 14:36:03 +02:00

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())