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 +10 -0
- gi_ingest/api.py +145 -0
- gi_ingest/config.py +129 -0
- gi_ingest/identity.py +74 -0
- gi_ingest/ledger.py +509 -0
- gi_ingest/main.py +930 -0
- gi_ingest/manifest.py +71 -0
- gi_ingest/s3.py +253 -0
- gi_ingest/scanner.py +520 -0
- gi_ingest/stager.py +137 -0
- gi_ingest/version_gate.py +81 -0
- gi_ingest-0.2.0.dist-info/METADATA +156 -0
- gi_ingest-0.2.0.dist-info/RECORD +15 -0
- gi_ingest-0.2.0.dist-info/WHEEL +4 -0
- gi_ingest-0.2.0.dist-info/entry_points.txt +2 -0
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}"
|