gi-ingest 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
gi_ingest/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """The vendor offload CLI."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("gi-ingest")
7
+ except PackageNotFoundError: # running from a source checkout, not installed
8
+ __version__ = "0.0.0+dev"
9
+
10
+ __all__ = ["__version__"]
gi_ingest/api.py ADDED
@@ -0,0 +1,145 @@
1
+ """
2
+ Client for the GILabs vendor ingest API.
3
+
4
+ Authenticates with a ``gik_`` key on ``X-API-Key``. There is no organization
5
+ header: the key is bound to one vendor, so there is no tenant to select.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import contextlib
11
+ from dataclasses import dataclass
12
+ from typing import Any
13
+
14
+ import httpx
15
+
16
+ from gi_ingest import __version__
17
+
18
+ DEFAULT_API_URL = "https://gilabs-data-center-production.up.railway.app"
19
+
20
+ # Generous: seal validates a whole manifest against a bucket listing, which on a
21
+ # large delivery is not instant. Everything else is fast.
22
+ _TIMEOUT = httpx.Timeout(60.0, connect=15.0)
23
+
24
+
25
+ class ApiError(RuntimeError):
26
+ def __init__(self, status: int, detail: str) -> None:
27
+ super().__init__(f"[{status}] {detail}")
28
+ self.status = status
29
+ self.detail = detail
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class ScopedCredentials:
34
+ access_key_id: str
35
+ secret_access_key: str
36
+ session_token: str
37
+ expiration: str
38
+ region: str
39
+ bucket: str
40
+ prefix: str
41
+ s3_uri: str
42
+
43
+
44
+ class IngestClient:
45
+ def __init__(self, base_url: str, api_key: str) -> None:
46
+ self.base = base_url.rstrip("/")
47
+ self._client = httpx.Client(
48
+ headers={
49
+ "X-API-Key": api_key,
50
+ "Content-Type": "application/json",
51
+ # Both, because they answer different questions: the agent
52
+ # string is what shows up in a proxy log, and the explicit
53
+ # header is what a server can compare without parsing.
54
+ "User-Agent": f"gi-ingest/{__version__}",
55
+ "X-Client-Version": __version__,
56
+ },
57
+ timeout=_TIMEOUT,
58
+ )
59
+
60
+ def close(self) -> None:
61
+ self._client.close()
62
+
63
+ def __enter__(self) -> IngestClient:
64
+ return self
65
+
66
+ def __exit__(self, *_exc: object) -> None:
67
+ self.close()
68
+
69
+ def _request(self, method: str, path: str, **kwargs: Any) -> Any:
70
+ try:
71
+ r = self._client.request(method, f"{self.base}/api/v1{path}", **kwargs)
72
+ except httpx.HTTPError as exc:
73
+ raise ApiError(0, f"network error: {exc}") from exc
74
+ if r.status_code >= 400:
75
+ detail = r.text
76
+ with contextlib.suppress(Exception): # non-JSON error body
77
+ detail = r.json().get("detail", detail)
78
+ raise ApiError(r.status_code, str(detail))
79
+ if r.status_code == 204 or not r.content:
80
+ return None
81
+ return r.json()
82
+
83
+ # ---- identity + destinations ----
84
+
85
+ def whoami(self) -> dict:
86
+ return self._request("GET", "/ingest/whoami")
87
+
88
+ def destinations(self) -> list[dict]:
89
+ return self._request("GET", "/ingest/destinations")["destinations"]
90
+
91
+ def check_episodes(self, episode_ids: list[str]) -> dict[str, dict]:
92
+ """Which of these episodes the platform already has.
93
+
94
+ Called at stage time, before anything is uploaded: on a bandwidth-bound
95
+ overnight run, transferring a card a colleague already delivered is the
96
+ expensive mistake. Returns a map keyed by episode id.
97
+ """
98
+ if not episode_ids:
99
+ return {}
100
+ out: dict[str, dict] = {}
101
+ # The endpoint caps a request at 1000 ids.
102
+ for i in range(0, len(episode_ids), 1000):
103
+ chunk = episode_ids[i : i + 1000]
104
+ body = {"episodes": [{"episode_id": e} for e in chunk]}
105
+ resp = self._request("POST", "/ingest/episodes/check", json=body)
106
+ for result in resp["results"]:
107
+ out[result["episode_id"]] = result
108
+ return out
109
+
110
+ # ---- batches ----
111
+
112
+ def open_batch(self, *, project_id: str | None, device_kind: str | None) -> dict:
113
+ body: dict[str, Any] = {}
114
+ if project_id:
115
+ body["project_id"] = project_id
116
+ if device_kind:
117
+ body["device_kind"] = device_kind
118
+ return self._request("POST", "/ingest/batches", json=body)
119
+
120
+ def refresh_credentials(self, batch_id: str) -> ScopedCredentials:
121
+ raw = self._request("POST", f"/ingest/batches/{batch_id}/credentials")
122
+ return ScopedCredentials(**{k: raw[k] for k in ScopedCredentials.__annotations__})
123
+
124
+ def seal_batch(self, batch_id: str, manifest: dict) -> dict:
125
+ return self._request(
126
+ "POST", f"/ingest/batches/{batch_id}/seal", json={"manifest": manifest}
127
+ )
128
+
129
+ def get_batch(self, batch_id: str) -> dict:
130
+ return self._request("GET", f"/ingest/batches/{batch_id}")
131
+
132
+ def list_batches(self, *, status: str | None = None, limit: int = 50) -> list[dict]:
133
+ params: dict[str, Any] = {"limit": limit}
134
+ if status:
135
+ params["status"] = status
136
+ return self._request("GET", "/ingest/batches", params=params)["batches"]
137
+
138
+ def abandon_batch(self, batch_id: str) -> dict:
139
+ return self._request("DELETE", f"/ingest/batches/{batch_id}")
140
+
141
+
142
+ def credentials_from_open(payload: dict) -> ScopedCredentials:
143
+ """Pull the credentials out of an open-batch response."""
144
+ raw = payload["credentials"]
145
+ return ScopedCredentials(**{k: raw[k] for k in ScopedCredentials.__annotations__})
gi_ingest/config.py ADDED
@@ -0,0 +1,129 @@
1
+ """Credential and configuration storage: ``~/.config/gi-ingest/``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import stat
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+
11
+ from gi_ingest.api import DEFAULT_API_URL
12
+
13
+ CONFIG_DIR = Path.home() / ".config" / "gi-ingest"
14
+ CONFIG_FILE = CONFIG_DIR / "config.json"
15
+
16
+ DEFAULT_STAGING = Path.home() / "gilabs-staging"
17
+ DEFAULT_LEDGER = Path.home() / ".local" / "state" / "gi-ingest" / "ledger.db"
18
+
19
+ # Where the tool kept these before it was renamed from gilabs-ingest.
20
+ _LEGACY_CONFIG_DIR = Path.home() / ".config" / "gilabs-ingest"
21
+ _LEGACY_STATE_DIR = Path.home() / ".local" / "state" / "gilabs-ingest"
22
+
23
+
24
+ def adopt_legacy_paths() -> None:
25
+ """Move an older install's config and ledger to the new names.
26
+
27
+ The rename would otherwise orphan both, and the ledger is not a cache: it
28
+ holds which episodes are staged, which are in flight, and which the server
29
+ has confirmed. Losing it mid-round means re-staging cards that were already
30
+ copied, and cards that can no longer be reclaimed because nothing remembers
31
+ they were delivered.
32
+
33
+ That matters more than usual right now, because the version floor makes
34
+ upgrading mandatory rather than optional — an upgrade that silently drops
35
+ a manager's queue would be a bad trade for a shorter name.
36
+
37
+ Moves rather than copies, so there is one source of truth afterwards, and
38
+ only when the new location does not already exist. Best-effort: a failure
39
+ here leaves the old files untouched and costs a `login`, not data.
40
+ """
41
+ pairs = (
42
+ (_LEGACY_CONFIG_DIR, CONFIG_DIR),
43
+ (_LEGACY_STATE_DIR, DEFAULT_LEDGER.parent),
44
+ )
45
+ for legacy, current in pairs:
46
+ if not legacy.is_dir() or current.exists():
47
+ continue
48
+ try:
49
+ current.parent.mkdir(parents=True, exist_ok=True)
50
+ legacy.rename(current)
51
+ except OSError:
52
+ # A different filesystem, a permission, a race with another run.
53
+ # The old path stays put and the user re-runs `login`.
54
+ pass
55
+
56
+
57
+ @dataclass
58
+ class Config:
59
+ api_url: str = DEFAULT_API_URL
60
+ api_key: str | None = None
61
+ staging_dir: Path = DEFAULT_STAGING
62
+ ledger_path: Path = DEFAULT_LEDGER
63
+ project_id: str | None = None
64
+
65
+ @property
66
+ def is_authenticated(self) -> bool:
67
+ return bool(self.api_key)
68
+
69
+
70
+ def _follow_moved_state(path: Path) -> Path:
71
+ """Repoint a stored path that :func:`adopt_legacy_paths` moved out from under.
72
+
73
+ ``login`` writes the ledger path into config.json, so moving the directory
74
+ is only half the job — the recorded path still names the old location, and
75
+ the CLI would helpfully create a brand new empty ledger there. That is the
76
+ precise failure the move exists to prevent: a manager mid-round would find
77
+ nothing staged, nothing in flight, and no card reclaimable.
78
+
79
+ Only rewrites paths that sit under the legacy directory. Anything a manager
80
+ pointed somewhere deliberate is left where they put it.
81
+ """
82
+ try:
83
+ relative = path.relative_to(_LEGACY_STATE_DIR)
84
+ except ValueError:
85
+ return path
86
+ return DEFAULT_LEDGER.parent / relative
87
+
88
+
89
+ def load() -> Config:
90
+ adopt_legacy_paths()
91
+ if not CONFIG_FILE.exists():
92
+ return Config()
93
+ try:
94
+ raw = json.loads(CONFIG_FILE.read_text())
95
+ except (OSError, json.JSONDecodeError):
96
+ # A corrupt config reads as "not configured" rather than a traceback —
97
+ # the fix is `gi-ingest login`, and that is what the caller is told.
98
+ return Config()
99
+ return Config(
100
+ api_url=raw.get("api_url") or DEFAULT_API_URL,
101
+ api_key=raw.get("api_key"),
102
+ staging_dir=Path(raw["staging_dir"]) if raw.get("staging_dir") else DEFAULT_STAGING,
103
+ ledger_path=_follow_moved_state(
104
+ Path(raw["ledger_path"]) if raw.get("ledger_path") else DEFAULT_LEDGER
105
+ ),
106
+ project_id=raw.get("project_id"),
107
+ )
108
+
109
+
110
+ def save(config: Config) -> None:
111
+ """Persist config, 0600. The API key lives here in plaintext.
112
+
113
+ Same posture as the AWS CLI and `gidata`: a file only the user can read.
114
+ Worth knowing rather than assuming — the key is a bearer credential for the
115
+ vendor's whole upload surface, so a shared laptop should use a per-machine
116
+ key that can be revoked on its own.
117
+ """
118
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
119
+ payload = {
120
+ "api_url": config.api_url,
121
+ "api_key": config.api_key,
122
+ "staging_dir": str(config.staging_dir),
123
+ "ledger_path": str(config.ledger_path),
124
+ "project_id": config.project_id,
125
+ }
126
+ tmp = CONFIG_FILE.with_suffix(".tmp")
127
+ tmp.write_text(json.dumps(payload, indent=2) + "\n")
128
+ os.chmod(tmp, stat.S_IRUSR | stat.S_IWUSR)
129
+ tmp.replace(CONFIG_FILE)
gi_ingest/identity.py ADDED
@@ -0,0 +1,74 @@
1
+ """
2
+ Episode identity — computed client-side so a dedup check costs a request, not an
3
+ upload.
4
+
5
+ ``derive_episode_id`` must stay byte-identical to the worker's version in
6
+ data-processing-services (``worker/ego/preprocess/concat.py``). It is the uuid5
7
+ that becomes ``episodes.id`` for pipeline episodes, so a drift here would make
8
+ every dedup check miss and every re-upload look novel — silently, and only
9
+ visible as duplicated storage.
10
+
11
+ There is no import to share: the worker lives in another repository and this
12
+ package ships to vendors. The contract is pinned by ``tests/test_identity.py``,
13
+ which asserts the exact uuid5 for a known triple.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import re
19
+ import uuid
20
+
21
+ # NAMESPACE_OID, matching the worker. Not an arbitrary choice — changing it
22
+ # renames every episode.
23
+ _UUID_NAMESPACE = uuid.NAMESPACE_OID
24
+
25
+ _DATE_IN_TEXT_RE = re.compile(r"(\d{8})")
26
+ # ISO-8601 dates are the common shape in session metadata (`recorded_at`), and
27
+ # their hyphens defeat the 8-consecutive-digit match the worker uses on compact
28
+ # S3 keys. Matched first and compacted, so a metadata timestamp and a path date
29
+ # resolve to the same value instead of the 00000000 sentinel.
30
+ _ISO_DATE_RE = re.compile(r"(\d{4})-(\d{2})-(\d{2})")
31
+
32
+
33
+ def normalize_device(raw: str) -> str:
34
+ """Lowercase, hyphens to underscores — the worker's normalization."""
35
+ return raw.lower().replace("-", "_")
36
+
37
+
38
+ def derive_episode_id(date: str, device: str, session: str) -> str:
39
+ """The deterministic episode id the pipeline will assign.
40
+
41
+ Mirrors ``worker/ego/preprocess/concat.py::derive_episode_id``. Keep the
42
+ format string exactly as-is.
43
+ """
44
+ name = f"gilabs-ingest:{normalize_device(device)}:{session}:{date}"
45
+ return str(uuid.uuid5(_UUID_NAMESPACE, name))
46
+
47
+
48
+ def extract_date(*values: str | None) -> str:
49
+ """First 8-digit run found across ``values``, else the worker's ``00000000``.
50
+
51
+ The worker scans the S3 key for a date; the CLI has better sources (session
52
+ metadata, then the session directory name), but falls back to the same
53
+ sentinel so an undated session lands on the same id either way.
54
+ """
55
+ for value in values:
56
+ if not value:
57
+ continue
58
+ iso = _ISO_DATE_RE.search(value)
59
+ if iso:
60
+ return f"{iso.group(1)}{iso.group(2)}{iso.group(3)}"
61
+ compact = _DATE_IN_TEXT_RE.search(value)
62
+ if compact:
63
+ return compact.group(1)
64
+ return "00000000"
65
+
66
+
67
+ def episode_key(date: str, device: str, session: str) -> str:
68
+ """Path segment for this episode inside the batch prefix.
69
+
70
+ Human-readable on purpose: this is what a vendor sees in an error message
71
+ and what an engineer greps for in the bucket. Uniqueness within a batch is
72
+ enforced by the server at seal, not by this string.
73
+ """
74
+ return f"{date}/{normalize_device(device)}/{session}"