gen3-dataops-toolkit 2.0.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.
Files changed (53) hide show
  1. g3dt/__init__.py +0 -0
  2. g3dt/cli/__init__.py +5 -0
  3. g3dt/cli/_internal/__init__.py +1 -0
  4. g3dt/cli/_internal/dispatch.py +428 -0
  5. g3dt/cli/_internal/registry.py +65 -0
  6. g3dt/cli/_internal/resolve.py +22 -0
  7. g3dt/cli/_internal/runner.py +76 -0
  8. g3dt/cli/_internal/safety.py +110 -0
  9. g3dt/cli/config_cmds.py +202 -0
  10. g3dt/cli/delete_cmds.py +101 -0
  11. g3dt/cli/dict_cmds.py +102 -0
  12. g3dt/cli/ec2_cmds.py +114 -0
  13. g3dt/cli/indexd_cmds.py +57 -0
  14. g3dt/cli/jobs.py +83 -0
  15. g3dt/cli/k8s.py +54 -0
  16. g3dt/cli/main.py +110 -0
  17. g3dt/cli/metadata.py +76 -0
  18. g3dt/cli/synth.py +206 -0
  19. g3dt/config.py +393 -0
  20. g3dt/indexd/__init__.py +0 -0
  21. g3dt/indexd/indexd_registrar.py +244 -0
  22. g3dt/ingest/ingest.py +629 -0
  23. g3dt/resolver.py +163 -0
  24. g3dt/services/delete/delete_all_metadata_for_project.py +170 -0
  25. g3dt/services/delete/delete_metadata.sh +153 -0
  26. g3dt/services/delete/delete_metadata_by_guid.py +338 -0
  27. g3dt/services/dictionary/deploy_dd.sh +65 -0
  28. g3dt/services/dictionary/pull_dict.sh +59 -0
  29. g3dt/services/dictionary/upload_dictionary.py +109 -0
  30. g3dt/services/indexd/register_indexd.py +240 -0
  31. g3dt/services/k8s_ops/argocd_restart_etl.sh +140 -0
  32. g3dt/services/k8s_ops/argocd_restart_ms.sh +102 -0
  33. g3dt/services/k8s_ops/argocd_restart_schema.sh +106 -0
  34. g3dt/services/k8s_ops/login_to_pod.sh +110 -0
  35. g3dt/services/k8s_ops/restart_etl_and_ms.sh +56 -0
  36. g3dt/services/synthetic_data/delete_synth_metadata_sheepdog.py +183 -0
  37. g3dt/services/synthetic_data/full_deploy_dd_and_synth.sh +124 -0
  38. g3dt/services/synthetic_data/generate_synth_metadata.sh +133 -0
  39. g3dt/services/synthetic_data/upload_synth_metadata_sheepdog.py +165 -0
  40. g3dt/services/upload/metadata/upload_all_studies.sh +108 -0
  41. g3dt/services/upload/metadata/upload_metadata.py +152 -0
  42. g3dt/upload/__init__.py +1 -0
  43. g3dt/upload/metadata_deleter.py +265 -0
  44. g3dt/upload/metadata_submitter.py +1093 -0
  45. g3dt/upload/upload_synthdata_s3.py +164 -0
  46. g3dt/utils/athena_utils.py +834 -0
  47. g3dt/utils/dbt_utils.py +66 -0
  48. g3dt/utils/release_writer.py +188 -0
  49. g3dt/validate/validate.py +609 -0
  50. gen3_dataops_toolkit-2.0.0.dist-info/METADATA +125 -0
  51. gen3_dataops_toolkit-2.0.0.dist-info/RECORD +53 -0
  52. gen3_dataops_toolkit-2.0.0.dist-info/WHEEL +4 -0
  53. gen3_dataops_toolkit-2.0.0.dist-info/entry_points.txt +3 -0
g3dt/config.py ADDED
@@ -0,0 +1,393 @@
1
+ """Configuration resolution for the g3dt CLI and services.
2
+
3
+ The platform has exactly two kinds of configuration:
4
+
5
+ * **INPUTS** — human-authored values, committed as
6
+ ``config/<projectId>.<env>.json`` in the CDK repo (gen3-aws-data-pipeline)
7
+ and read only by ``cdk deploy``. The toolkit never reads these files.
8
+ * **OUTPUTS** — every resource name the CDK creates, plus the mirrored Gen3
9
+ app facts, published to SSM Parameter Store under ``/{project}/{env}/...``
10
+ on deploy. The toolkit resolves everything from there at runtime
11
+ (see :mod:`g3dt.resolver`).
12
+
13
+ The only local configuration is a tiny bootstrap **marker** — ``g3dt.yaml`` —
14
+ that tells the CLI which project/region to resolve (plus optional per-env AWS
15
+ profile names and the study registry). Search order: ``./g3dt.yaml`` →
16
+ ``~/.g3dt/g3dt.yaml`` → ``/etc/g3dt/g3dt.yaml`` (the EC2 job box's copy,
17
+ written by CDK user-data). Environment variables override the file:
18
+ ``G3DT_PROJECT``, ``AWS_REGION``, ``G3DT_DEFAULT_ENV``; ``G3DT_MARKER`` points
19
+ at an explicit marker path.
20
+
21
+ Design notes
22
+ ------------
23
+ * ``resolve_env`` returns the same frozen :class:`EnvConfig` the pre-2.0 CLI
24
+ used, so command groups and services are agnostic to where values came from.
25
+ * Studies are project data, not CDK-created names, so they stay in the marker
26
+ (``studies:`` block) for now. ``resolve_study`` keeps the safety-critical
27
+ ``{study}_{env_base}`` derivation that keeps staging data out of prod.
28
+ """
29
+ from __future__ import annotations
30
+
31
+ import functools
32
+ import os
33
+ from dataclasses import dataclass
34
+ from pathlib import Path
35
+ from typing import Dict, List, Optional, Tuple
36
+
37
+ import yaml
38
+
39
+ DEFAULT_REGION = "ap-southeast-2"
40
+
41
+ #: Marker locations, most specific first.
42
+ MARKER_PATHS = ("g3dt.yaml", "~/.g3dt/g3dt.yaml", "/etc/g3dt/g3dt.yaml")
43
+
44
+ #: Marker keys `g3dt config set` may write.
45
+ SETTABLE_MARKER_KEYS = ("project", "region", "default_env")
46
+
47
+ #: Gen3 app facts mirrored to SSM /{project}/{env}/app/* by the CDK.
48
+ REQUIRED_APP_KEYS = (
49
+ "dictionary_version",
50
+ "aws_secret_name",
51
+ "schema_s3_uri",
52
+ "domain",
53
+ "app_name",
54
+ "namespace",
55
+ "cluster_name",
56
+ "schema_repo",
57
+ )
58
+
59
+ #: Operational table conventions. Fixed names, exactly like the CDK's
60
+ #: `releases` table (lib/names.ts) — they live in the env's metadata Glue DB
61
+ #: and under the env's metadata bucket, both resolved from SSM.
62
+ METADATA_UPLOAD_TABLE = "metadata_upload_iceberg"
63
+ METADATA_UPLOAD_PREFIX = "metadata_upload/"
64
+ FILE_METADATA_TABLE = "file_metadata"
65
+ INDEXD_REGISTRY_TABLE = "indexd_registry"
66
+ INDEXD_PREFIX = "indexd/"
67
+
68
+ #: Where a project's study registry lives when it isn't in the local marker:
69
+ #: s3://<metadata-bucket>/<STUDIES_S3_KEY> (see resolve_study).
70
+ STUDIES_S3_KEY = "config/studies.yaml"
71
+
72
+ #: Suffixes used to strip the environment from a study key.
73
+ _STUDY_ENV_SUFFIXES = ("_staging", "_prod", "_test")
74
+
75
+
76
+ class ConfigError(KeyError):
77
+ """Raised when an env/study cannot be resolved or required keys are missing.
78
+
79
+ Subclasses ``KeyError`` so existing ``except KeyError`` handlers still catch
80
+ it, but carries a human-readable message that doubles as CLI help.
81
+ """
82
+
83
+ def __str__(self) -> str: # KeyError repr adds quotes; we want the raw text
84
+ return self.args[0] if self.args else ""
85
+
86
+
87
+ # --------------------------------------------------------------------------- #
88
+ # The bootstrap marker (g3dt.yaml) #
89
+ # --------------------------------------------------------------------------- #
90
+ def marker_path() -> Optional[Path]:
91
+ """Return the first marker file that exists, or ``None``.
92
+
93
+ ``$G3DT_MARKER`` overrides the search entirely (useful in tests and CI).
94
+ """
95
+ override = os.getenv("G3DT_MARKER")
96
+ if override:
97
+ return Path(override).expanduser()
98
+ for candidate in MARKER_PATHS:
99
+ p = Path(candidate).expanduser()
100
+ if p.is_file():
101
+ return p
102
+ return None
103
+
104
+
105
+ @functools.lru_cache(maxsize=None)
106
+ def _load_yaml_cached(path_str: str) -> dict:
107
+ with open(path_str, "r", encoding="utf-8") as f:
108
+ return yaml.safe_load(f) or {}
109
+
110
+
111
+ def load_marker() -> dict:
112
+ """Read the g3dt.yaml marker; env vars override file values.
113
+
114
+ Returns a dict with at least ``project``/``region``/``default_env`` keys
115
+ (possibly ``None``) plus whatever else the file carries (``profiles``,
116
+ ``studies``, ``ssh_key``, ``ssh_user``).
117
+ """
118
+ path = marker_path()
119
+ data = dict(_load_yaml_cached(str(path))) if path and path.is_file() else {}
120
+ data["project"] = os.getenv("G3DT_PROJECT", data.get("project"))
121
+ data["region"] = os.getenv("AWS_REGION", data.get("region", DEFAULT_REGION))
122
+ data["default_env"] = os.getenv("G3DT_DEFAULT_ENV", data.get("default_env"))
123
+ return data
124
+
125
+
126
+ def require_project(marker: Optional[dict] = None) -> str:
127
+ """Return the project id or fail with setup instructions."""
128
+ m = marker if marker is not None else load_marker()
129
+ project = m.get("project")
130
+ if not project:
131
+ raise ConfigError(
132
+ "No project configured. Create a g3dt.yaml marker (searched: "
133
+ f"{', '.join(MARKER_PATHS)}) with at least:\n"
134
+ " project: <projectId>\n"
135
+ " region: ap-southeast-2\n"
136
+ "or set $G3DT_PROJECT. Run `g3dt config set project <id>` to write one."
137
+ )
138
+ return project
139
+
140
+
141
+ def set_marker_value(key: str, value: str) -> Tuple[Optional[str], str, Path]:
142
+ """Set one bootstrap key in the user's marker file and write it back.
143
+
144
+ Writes to the existing marker if one is found, else creates
145
+ ``~/.g3dt/g3dt.yaml``. Returns ``(old_value, new_value, path)``.
146
+ """
147
+ if key not in SETTABLE_MARKER_KEYS:
148
+ raise ConfigError(
149
+ f"Key '{key}' is not a settable bootstrap key. "
150
+ f"Settable keys: {', '.join(SETTABLE_MARKER_KEYS)}. "
151
+ f"Deployed settings (dictionary_version, domain, ...) are CDK INPUTS: "
152
+ f"edit config/<project>.<env>.json in gen3-aws-data-pipeline and "
153
+ f"redeploy — the values flow to SSM, not to this file."
154
+ )
155
+ path = marker_path()
156
+ if path is None:
157
+ path = Path("~/.g3dt/g3dt.yaml").expanduser()
158
+ if path.is_file():
159
+ data = dict(_load_yaml_cached(str(path)))
160
+ else:
161
+ path.parent.mkdir(parents=True, exist_ok=True)
162
+ data = {}
163
+ old = data.get(key)
164
+ data[key] = value
165
+ with open(path, "w", encoding="utf-8") as f:
166
+ yaml.safe_dump(data, f, default_flow_style=False, sort_keys=False)
167
+ _load_yaml_cached.cache_clear()
168
+ return old, value, path
169
+
170
+
171
+ # --------------------------------------------------------------------------- #
172
+ # Environments #
173
+ # --------------------------------------------------------------------------- #
174
+ def env_base(env: str) -> str:
175
+ """Strip a trailing ``_ec2`` suffix: ``staging_ec2`` -> ``staging``."""
176
+ return env[:-4] if env.endswith("_ec2") else env
177
+
178
+
179
+ def aws_profile_for(env: str, marker: Optional[dict] = None) -> Optional[str]:
180
+ """Return the local AWS named profile for ``env``, or ``None`` (ambient).
181
+
182
+ Read from the marker's optional ``profiles:`` map, e.g.::
183
+
184
+ profiles:
185
+ test: etl_test
186
+ staging: etl_staging
187
+
188
+ On the EC2 box / CodeBuild there is no ``profiles`` map, so the default
189
+ credential chain (the instance/build role) is used — by design.
190
+ """
191
+ m = marker if marker is not None else load_marker()
192
+ profiles = m.get("profiles") or {}
193
+ return profiles.get(env_base(env))
194
+
195
+
196
+ @dataclass(frozen=True)
197
+ class EnvConfig:
198
+ """Resolved settings for one environment (names from SSM, auth from marker)."""
199
+
200
+ name: str
201
+ is_ec2: bool
202
+ region: str
203
+ dictionary_version: str
204
+ aws_profile: Optional[str]
205
+ aws_secret_name: str
206
+ schema_s3_uri: str
207
+ domain: str
208
+ app_name: str
209
+ namespace: str
210
+ cluster_name: str
211
+ schema_repo: str
212
+ ec2_instance_id: Optional[str] = None
213
+ ssh_key: Optional[str] = None
214
+ ssh_user: Optional[str] = None
215
+
216
+
217
+ def resolve_env(env: str, project: Optional[str] = None) -> EnvConfig:
218
+ """Resolve one environment: app INPUT facts + CDK OUTPUT names, from SSM.
219
+
220
+ ``env`` may carry a ``_ec2`` suffix (dispatch pseudo-env): SSM has one tree
221
+ per real env, so the suffix is stripped for resolution and recorded as
222
+ ``is_ec2``. An ``*_ec2`` env authenticates with the ambient credential
223
+ chain (``aws_profile=None``) — on the box that is the instance profile.
224
+ """
225
+ from g3dt import resolver # late import: resolver imports ConfigError from here
226
+
227
+ marker = load_marker()
228
+ project = project or require_project(marker)
229
+ base = env_base(env)
230
+ is_ec2 = env.endswith("_ec2")
231
+ profile = None if is_ec2 else aws_profile_for(base, marker)
232
+
233
+ rc = resolver.resolve(project, base, profile=profile)
234
+
235
+ missing = [k for k in REQUIRED_APP_KEYS if f"app/{k}" not in rc.params]
236
+ if missing:
237
+ raise ConfigError(
238
+ f"Environment '{env}' is missing required app fact(s) in SSM: "
239
+ f"{', '.join(missing)} (expected at /{project}/{base}/app/*). "
240
+ f"Re-run `cdk deploy` in gen3-aws-data-pipeline so the inputs are "
241
+ f"mirrored to SSM."
242
+ )
243
+ return EnvConfig(
244
+ name=env,
245
+ is_ec2=is_ec2,
246
+ region=rc.get("meta/region", marker["region"]),
247
+ dictionary_version=rc.app("dictionary_version"),
248
+ aws_profile=profile,
249
+ aws_secret_name=rc.app("aws_secret_name"),
250
+ schema_s3_uri=rc.app("schema_s3_uri"),
251
+ domain=rc.app("domain"),
252
+ app_name=rc.app("app_name"),
253
+ namespace=rc.app("namespace"),
254
+ cluster_name=rc.app("cluster_name"),
255
+ schema_repo=rc.app("schema_repo"),
256
+ ec2_instance_id=rc.get("ec2/instanceId"),
257
+ ssh_key=(marker.get("ssh_key") if is_ec2 else None),
258
+ ssh_user=(marker.get("ssh_user") if is_ec2 else None),
259
+ )
260
+
261
+
262
+ def list_envs(project: Optional[str] = None) -> List[str]:
263
+ """Return the environments that have a deployed SSM tree for the project."""
264
+ from g3dt import resolver
265
+
266
+ marker = load_marker()
267
+ project = project or require_project(marker)
268
+ # Listing spans envs, so authenticate with any configured profile (they all
269
+ # target the same account) or the ambient chain.
270
+ profiles = marker.get("profiles") or {}
271
+ profile = next(iter(profiles.values()), None)
272
+ return resolver.list_envs(project, profile=profile)
273
+
274
+
275
+ def script_env(e: EnvConfig) -> Dict[str, str]:
276
+ """Environment variables for a wrapped service script.
277
+
278
+ The pre-2.0 shell scripts parsed the legacy YAML config themselves with
279
+ ``yq``; now the Python caller resolves everything from SSM and hands it
280
+ over as ``G3DT_*`` variables.
281
+ """
282
+ env = dict(os.environ)
283
+ values = {
284
+ "G3DT_ENV": e.name,
285
+ "G3DT_REGION": e.region,
286
+ "G3DT_AWS_PROFILE": e.aws_profile or "",
287
+ "G3DT_DICTIONARY_VERSION": e.dictionary_version,
288
+ "G3DT_AWS_SECRET_NAME": e.aws_secret_name,
289
+ "G3DT_SCHEMA_S3_URI": e.schema_s3_uri,
290
+ "G3DT_DOMAIN": e.domain,
291
+ "G3DT_APP_NAME": e.app_name,
292
+ "G3DT_NAMESPACE": e.namespace,
293
+ "G3DT_CLUSTER_NAME": e.cluster_name,
294
+ "G3DT_SCHEMA_REPO": e.schema_repo,
295
+ }
296
+ env.update({k: v for k, v in values.items() if v is not None})
297
+ return env
298
+
299
+
300
+ # --------------------------------------------------------------------------- #
301
+ # Studies (project data — marker-resident for now) #
302
+ # --------------------------------------------------------------------------- #
303
+ @dataclass(frozen=True)
304
+ class StudyConfig:
305
+ """Resolved settings for one study (in a given environment)."""
306
+
307
+ key: str
308
+ project_id: str
309
+ program_id: str
310
+ s3_metadata_path: str
311
+
312
+
313
+ def list_studies(marker: Optional[dict] = None, env: Optional[str] = None) -> List[str]:
314
+ """Return unique bare study names (env suffix stripped) for friendly help."""
315
+ m = marker if marker is not None else load_marker()
316
+ studies = m.get("studies") or {}
317
+ if not studies and env:
318
+ studies = _studies_from_s3(env, m) or {}
319
+ prefixes = set()
320
+ for key in studies:
321
+ for suffix in _STUDY_ENV_SUFFIXES:
322
+ if key.endswith(suffix):
323
+ prefixes.add(key[: -len(suffix)])
324
+ break
325
+ else:
326
+ prefixes.add(key)
327
+ return sorted(prefixes)
328
+
329
+
330
+ @functools.lru_cache(maxsize=None)
331
+ def _studies_from_s3_cached(project: str, base_env: str, profile: Optional[str]) -> dict:
332
+ """Fetch the project's study registry from S3 (see STUDIES_S3_KEY)."""
333
+ import boto3
334
+ from botocore.exceptions import ClientError
335
+
336
+ from g3dt import resolver
337
+
338
+ rc = resolver.resolve(project, base_env, profile=profile)
339
+ session = boto3.Session(profile_name=profile) if profile else boto3.Session()
340
+ try:
341
+ body = session.client("s3").get_object(
342
+ Bucket=rc.metadata_bucket, Key=STUDIES_S3_KEY
343
+ )["Body"].read()
344
+ except ClientError:
345
+ return {}
346
+ data = yaml.safe_load(body) or {}
347
+ # Accept either a bare map of study keys or a {studies: {...}} wrapper.
348
+ return data.get("studies", data)
349
+
350
+
351
+ def _studies_from_s3(env: str, marker: dict) -> dict:
352
+ """Best-effort S3 fallback for the study registry (empty dict on any miss)."""
353
+ try:
354
+ project = require_project(marker)
355
+ base = env_base(env)
356
+ is_ec2 = env.endswith("_ec2")
357
+ profile = None if is_ec2 else aws_profile_for(base, marker)
358
+ return _studies_from_s3_cached(project, base, profile)
359
+ except Exception:
360
+ return {}
361
+
362
+
363
+ def resolve_study(study: str, env: str, marker: Optional[dict] = None) -> StudyConfig:
364
+ """Resolve a study against an environment.
365
+
366
+ The registry comes from the marker's ``studies:`` map if present, else from
367
+ ``s3://<metadata-bucket>/config/studies.yaml`` (so the EC2 job box — whose
368
+ marker carries only the bootstrap — resolves the same registry the laptop
369
+ does; upload it once per env with ``aws s3 cp``).
370
+
371
+ Tries ``<study>_<env_base>`` first (so ``ausdiab`` + ``staging`` →
372
+ ``ausdiab_staging``), then the literal ``study`` key for back-compat. This
373
+ derivation is the safety rule that keeps staging data out of prod.
374
+ """
375
+ m = marker if marker is not None else load_marker()
376
+ studies = m.get("studies") or {}
377
+ if not studies:
378
+ studies = _studies_from_s3(env, m) or {}
379
+ candidates = [f"{study}_{env_base(env)}", study]
380
+ for key in candidates:
381
+ sc = studies.get(key)
382
+ if sc:
383
+ return StudyConfig(
384
+ key=key,
385
+ project_id=sc["project_id"],
386
+ program_id=sc["program_id"],
387
+ s3_metadata_path=sc["s3_metadata_path"],
388
+ )
389
+ valid = ", ".join(list_studies(m)) or "(none — add a studies: block to g3dt.yaml)"
390
+ raise ConfigError(
391
+ f"Study '{study}' (env '{env}') not found. Tried {candidates}. "
392
+ f"Valid studies: {valid}"
393
+ )
File without changes
@@ -0,0 +1,244 @@
1
+ import logging
2
+ import uuid
3
+ import urllib.parse
4
+ from datetime import datetime
5
+ from typing import List, Dict, Any, Optional
6
+
7
+ import boto3
8
+ import awswrangler as wr
9
+ import pandas as pd
10
+ import pytz
11
+ from gen3.index import Gen3Index
12
+
13
+ from g3dt.utils.athena_utils import write_iceberg_to_db
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ # ---------- baseid helpers ----------
19
+
20
+ def filename_to_baseid(filename: str) -> str:
21
+ """Generate a deterministic UUIDv5 baseid from a filename.
22
+
23
+ Uses uuid.NAMESPACE_DNS as the namespace so the same filename
24
+ always produces the same baseid, enabling idempotent indexd
25
+ registration and version tracking.
26
+
27
+ Parameters
28
+ ----------
29
+ filename : str
30
+ The basename of the file (e.g. ``"data.csv"``).
31
+
32
+ Returns
33
+ -------
34
+ str
35
+ A UUID string derived from the filename.
36
+ """
37
+ return str(uuid.uuid5(uuid.NAMESPACE_DNS, filename))
38
+
39
+
40
+ # ---------- S3 scanning ----------
41
+
42
+ def _parse_s3(uri: str):
43
+ """Parse an S3 URI into (bucket, key)."""
44
+ p = urllib.parse.urlparse(uri)
45
+ return p.netloc, p.path.lstrip("/")
46
+
47
+
48
+ def scan_s3_files(
49
+ s3_paths: List[str],
50
+ boto3_session: Optional[boto3.Session] = None,
51
+ ) -> pd.DataFrame:
52
+ """Recursively list files under S3 prefixes and collect metadata.
53
+
54
+ For each object found, retrieves ``ETag`` (md5), ``ContentLength``
55
+ (file_size), and computes a deterministic ``baseid`` from the filename.
56
+
57
+ Parameters
58
+ ----------
59
+ s3_paths : list[str]
60
+ One or more S3 URI prefixes to scan (e.g. ``["s3://bucket/data/"]``).
61
+ boto3_session : boto3.Session, optional
62
+ A boto3 session. Uses the default session when *None*.
63
+
64
+ Returns
65
+ -------
66
+ pd.DataFrame
67
+ Columns: ``file_name``, ``md5``, ``file_size``, ``s3_url``, ``baseid``.
68
+ """
69
+ session = boto3_session or boto3.Session()
70
+ s3_client = session.client("s3")
71
+
72
+ records: List[Dict[str, Any]] = []
73
+
74
+ for prefix in s3_paths:
75
+ logger.info("Scanning S3 path: %s", prefix)
76
+ file_uris = wr.s3.list_objects(
77
+ prefix, boto3_session=session
78
+ )
79
+
80
+ total = len(file_uris)
81
+ for i, uri in enumerate(file_uris, 1):
82
+ bucket, key = _parse_s3(uri)
83
+ file_name = key.rsplit("/", 1)[-1] if "/" in key else key
84
+ logger.info(
85
+ "[S3 scan %d/%d] %s", i, total, file_name
86
+ )
87
+
88
+ try:
89
+ head = s3_client.head_object(Bucket=bucket, Key=key)
90
+ except Exception as e:
91
+ logger.error("Failed to head object %s: %s", uri, e)
92
+ continue
93
+
94
+ md5 = (head.get("ETag") or "").strip('"')
95
+ file_size = head.get("ContentLength", 0)
96
+
97
+ records.append(
98
+ {
99
+ "file_name": file_name,
100
+ "md5": md5,
101
+ "file_size": file_size,
102
+ "s3_url": uri,
103
+ "baseid": filename_to_baseid(file_name),
104
+ }
105
+ )
106
+
107
+ if not records:
108
+ return pd.DataFrame(
109
+ columns=["file_name", "md5", "file_size",
110
+ "s3_url", "baseid"]
111
+ )
112
+
113
+ return pd.DataFrame(records)
114
+
115
+
116
+ # ---------- indexd registration ----------
117
+
118
+ def register_files_with_indexd(
119
+ index: Gen3Index,
120
+ df: pd.DataFrame,
121
+ authz: List[str],
122
+ ) -> pd.DataFrame:
123
+ """Register files with Gen3 indexd and return results.
124
+
125
+ For each row in *df*, calls ``Gen3Index.create_record`` with the
126
+ file's hashes, size, S3 URL, baseid, and authz. Re-submitting
127
+ the same baseid will create a new revision in indexd, making this
128
+ safe to re-run (idempotent at the API level).
129
+
130
+ Parameters
131
+ ----------
132
+ index : Gen3Index
133
+ An authenticated Gen3Index client.
134
+ df : pd.DataFrame
135
+ Must contain columns: ``file_name``, ``md5``, ``file_size``,
136
+ ``s3_url``, ``baseid``.
137
+ authz : list[str]
138
+ Authz paths for the files (e.g.
139
+ ``["/programs/program1/projects/EDCAD-PMS"]``).
140
+
141
+ Returns
142
+ -------
143
+ pd.DataFrame
144
+ A copy of *df* with additional columns: ``did``, ``rev``,
145
+ ``registered_at``. Rows that failed are excluded.
146
+ """
147
+ results: List[Dict[str, Any]] = []
148
+ tz = pytz.timezone("Australia/Melbourne")
149
+
150
+ total = len(df)
151
+ for i, (_, row) in enumerate(df.iterrows(), 1):
152
+ baseid = row["baseid"]
153
+ file_name = row["file_name"]
154
+ logger.info(
155
+ "[indexd upload %d/%d] %s", i, total, file_name
156
+ )
157
+
158
+ try:
159
+ resp = index.create_record(
160
+ hashes={"md5": row["md5"]},
161
+ size=int(row["file_size"]),
162
+ urls=[row["s3_url"]],
163
+ urls_metadata={row["s3_url"]: {}},
164
+ file_name=file_name,
165
+ baseid=baseid,
166
+ authz=authz,
167
+ )
168
+ logger.info(
169
+ "Registered %s → did=%s baseid=%s",
170
+ file_name, resp.get("did"), resp.get("baseid"),
171
+ )
172
+ results.append(
173
+ {
174
+ **row.to_dict(),
175
+ "did": resp["did"],
176
+ "rev": resp["rev"],
177
+ "registered_at": datetime.now(tz).isoformat(),
178
+ }
179
+ )
180
+ except Exception as e:
181
+ logger.error("Failed to register %s: %s", file_name, e)
182
+
183
+ if not results:
184
+ cols = list(df.columns) + ["did", "rev", "registered_at"]
185
+ return pd.DataFrame(columns=cols)
186
+
187
+ return pd.DataFrame(results)
188
+
189
+
190
+ # ---------- Glue / Iceberg persistence ----------
191
+
192
+ def write_to_glue(
193
+ df: pd.DataFrame,
194
+ database: str,
195
+ table: str,
196
+ athena_s3_output: str,
197
+ table_location: str,
198
+ workgroup: str = "primary",
199
+ partition_cols: Optional[List[str]] = None,
200
+ merge_cols: Optional[List[str]] = None,
201
+ schema_evolution: bool = False,
202
+ boto3_session: Optional[boto3.Session] = None,
203
+ ) -> None:
204
+ """Write a DataFrame to a Glue Iceberg table.
205
+
206
+ Thin wrapper around :func:`write_iceberg_to_db` that provides a
207
+ consistent interface for the indexd module.
208
+
209
+ Parameters
210
+ ----------
211
+ df : pd.DataFrame
212
+ Data to write.
213
+ database : str
214
+ Glue database name.
215
+ table : str
216
+ Iceberg table name.
217
+ athena_s3_output : str
218
+ S3 URI for Athena query results.
219
+ table_location : str
220
+ S3 path for Iceberg table data files.
221
+ workgroup : str
222
+ Athena workgroup. Defaults to ``"primary"``.
223
+ partition_cols : list[str], optional
224
+ Columns to partition the Iceberg table by.
225
+ merge_cols : list[str], optional
226
+ Columns to use for MERGE INTO (upsert) semantics.
227
+ schema_evolution : bool
228
+ If True, allow schema evolution for new columns.
229
+ boto3_session : boto3.Session, optional
230
+ A boto3 session.
231
+ """
232
+ write_iceberg_to_db(
233
+ df=df,
234
+ database=database,
235
+ table=table,
236
+ athena_s3_output=athena_s3_output,
237
+ workgroup=workgroup,
238
+ table_location=table_location,
239
+ partition_cols=partition_cols,
240
+ merge_cols=merge_cols,
241
+ schema_evolution=schema_evolution,
242
+ boto3_session=boto3_session,
243
+ )
244
+ logger.info("Wrote %d rows to %s.%s", len(df), database, table)