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/__init__.py ADDED
File without changes
g3dt/cli/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """The ``acdc`` operations CLI.
2
+
3
+ A thin, self-documenting command layer over the project's existing service
4
+ scripts and library functions. Run ``acdc --help`` or ``acdc docs`` to start.
5
+ """
@@ -0,0 +1 @@
1
+ """Internal helpers for the acdc CLI (subprocess runner, safety guards, EC2 dispatch)."""
@@ -0,0 +1,428 @@
1
+ """Hybrid local/EC2 execution for long-running data-plane commands.
2
+
3
+ Short, interactive steps run locally (see :mod:`runner`). Long jobs (metadata
4
+ upload/delete, synth upload/delete, indexd register) can instead run on the
5
+ project's per-env EC2 job box via ``--on ec2``. The mechanism is **AWS SSM Run
6
+ Command**, which is disconnect-safe by construction: the SSM agent executes the
7
+ job server-side, so the laptop can sleep and you re-attach by ``run_id``.
8
+
9
+ Everything is resolved from the env's own SSM tree (``/{project}/{env}/...``):
10
+ the target instance (``ec2/instanceId``), the S3 log destination
11
+ (``ec2/logBucket`` + ``ec2/logPrefix``), and the CloudWatch log group
12
+ (``ec2/logGroup``). Dispatching to another environment's box is structurally
13
+ impossible — there is nothing local to misconfigure.
14
+
15
+ Authentication split (the crux):
16
+ * the laptop authenticates the SSM call with the *local* env's named
17
+ profile (from the marker's ``profiles:`` map);
18
+ * the job on the box runs under the ``*_ec2`` pseudo-env, which uses the
19
+ ambient instance profile.
20
+
21
+ The box needs no repository, git credentials, or poetry: CDK user-data
22
+ pip-installs the pinned toolkit, so the remote command is a bare ``g3dt ...``
23
+ console-script invocation.
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import datetime
28
+ import shlex
29
+ import time
30
+ from enum import Enum
31
+ from typing import Callable, List, Optional, Sequence, Tuple
32
+
33
+ import typer
34
+ from botocore.exceptions import ClientError
35
+
36
+ from g3dt.config import (
37
+ ConfigError,
38
+ EnvConfig,
39
+ aws_profile_for,
40
+ env_base,
41
+ load_marker,
42
+ require_project,
43
+ resolve_env,
44
+ script_env,
45
+ )
46
+ from g3dt.upload.metadata_submitter import create_boto3_session
47
+ from g3dt.cli._internal import registry, runner
48
+
49
+
50
+ class Target(str, Enum):
51
+ """Where a command should execute."""
52
+
53
+ local = "local"
54
+ ec2 = "ec2"
55
+
56
+
57
+ def new_run_id(label: str) -> str:
58
+ """Build a sortable, human-friendly run id: ``20260615T1430-metadata-upload``."""
59
+ ts = datetime.datetime.now().strftime("%Y%m%dT%H%M%S")
60
+ return f"{ts}-{label}"
61
+
62
+
63
+ def resolve_dispatch_envs(env: str) -> Tuple[EnvConfig, EnvConfig]:
64
+ """Return ``(auth_env, remote_env)`` for an EC2 dispatch.
65
+
66
+ ``auth_env`` is the non-ec2 form used to authenticate the SSM call from the
67
+ laptop (named profile from the marker); ``remote_env`` is the ``*_ec2``
68
+ variant the remote job runs under (ambient instance profile). Both resolve
69
+ the same SSM tree, so they can never disagree on names. Accepts either the
70
+ base or the ``_ec2`` form for ``env``.
71
+ """
72
+ base = env_base(env)
73
+ return resolve_env(base), resolve_env(f"{base}_ec2")
74
+
75
+
76
+ def build_remote_command(run_id: str, remote_argv: Sequence[str]) -> str:
77
+ """Build the shell snippet SSM runs on the EC2 box.
78
+
79
+ ``remote_argv`` is a ``g3dt`` CLI argv (e.g. ``["metadata", "upload", ...]``)
80
+ — the box has the toolkit pip-installed by CDK user-data, so no repo clone,
81
+ ``git pull``, or poetry is involved. Output tees to an on-box archive under
82
+ ``~/.g3dt/logs/`` and to stdout, which SSM forwards to CloudWatch Logs for
83
+ live ``g3dt jobs logs --follow``. PYTHONUNBUFFERED keeps the stream
84
+ line-by-line; ``set -o pipefail`` keeps the job's exit status flowing
85
+ through ``tee`` so failures still mark the invocation Failed.
86
+ """
87
+ inner = " ".join(shlex.quote(str(a)) for a in remote_argv)
88
+ return " && ".join(
89
+ [
90
+ "set -euo pipefail",
91
+ "mkdir -p ~/.g3dt/logs",
92
+ f"PYTHONUNBUFFERED=1 g3dt {inner} 2>&1 | tee ~/.g3dt/logs/{run_id}.log",
93
+ ]
94
+ )
95
+
96
+
97
+ def dispatch_ssm(
98
+ auth_env: EnvConfig,
99
+ remote_env: EnvConfig,
100
+ remote_argv: Sequence[str],
101
+ label: str,
102
+ ) -> str:
103
+ """Launch a ``g3dt`` argv on the env's EC2 box via SSM Run Command."""
104
+ from g3dt import resolver
105
+
106
+ if not remote_env.ec2_instance_id:
107
+ typer.secho(
108
+ f"No ec2/instanceId published for env '{env_base(remote_env.name)}'. "
109
+ f"Has the ec2-job-runner stack been deployed? "
110
+ f"(cdk deploy in gen3-aws-data-pipeline)",
111
+ fg=typer.colors.RED,
112
+ err=True,
113
+ )
114
+ raise typer.Exit(2)
115
+
116
+ # Log destinations come from the same SSM tree as the instance id.
117
+ rc = resolver.resolve(
118
+ require_project(), env_base(remote_env.name), profile=auth_env.aws_profile
119
+ )
120
+ log_bucket, log_prefix = rc.ec2_log_bucket, rc.ec2_log_prefix
121
+ log_group = rc.ec2_log_group
122
+
123
+ session = create_boto3_session(
124
+ aws_profile=auth_env.aws_profile, aws_region=auth_env.region
125
+ )
126
+ ssm = session.client("ssm")
127
+ run_id = new_run_id(label)
128
+ # SSM AWS-RunShellScript executes as root, whose PATH and HOME differ from
129
+ # the operator user's. Run the job inside a login shell for the box's user
130
+ # so ~, PATH (incl. the pip console script), and /etc/profile.d/g3dt.sh
131
+ # resolve.
132
+ remote_user = remote_env.ssh_user or "ec2-user"
133
+ command = "runuser -l {user} -c {script}".format(
134
+ user=remote_user,
135
+ script=shlex.quote(build_remote_command(run_id, remote_argv)),
136
+ )
137
+
138
+ resp = ssm.send_command(
139
+ InstanceIds=[remote_env.ec2_instance_id],
140
+ DocumentName="AWS-RunShellScript",
141
+ Parameters={"commands": [command], "executionTimeout": ["172800"]},
142
+ OutputS3BucketName=log_bucket,
143
+ OutputS3KeyPrefix=f"{log_prefix}/{run_id}",
144
+ CloudWatchOutputConfig={
145
+ "CloudWatchLogGroupName": log_group,
146
+ "CloudWatchOutputEnabled": True,
147
+ },
148
+ Comment=f"g3dt {run_id}",
149
+ )
150
+ command_id = resp["Command"]["CommandId"]
151
+ s3_log_uri = f"s3://{log_bucket}/{log_prefix}/{run_id}"
152
+ registry.record(
153
+ run_id,
154
+ command_id=command_id,
155
+ instance_id=remote_env.ec2_instance_id,
156
+ env=remote_env.name,
157
+ argv=list(remote_argv),
158
+ mechanism="ssm",
159
+ s3_log_uri=s3_log_uri,
160
+ cw_log_group=log_group,
161
+ started_at=run_id.split("-", 1)[0],
162
+ )
163
+ _print_dispatch_banner(run_id, remote_env)
164
+ return run_id
165
+
166
+
167
+ def run_or_dispatch(
168
+ on: Target,
169
+ env: str,
170
+ script_relpath: str,
171
+ build_args: Callable[[str], Sequence[str]],
172
+ label: str,
173
+ *,
174
+ interpreter: str = "python",
175
+ remote_cli: Optional[Callable[[str], Sequence[str]]] = None,
176
+ ) -> None:
177
+ """Run a packaged service script locally, or dispatch the CLI form to EC2.
178
+
179
+ ``build_args(env_name)`` returns the service-script arguments for the given
180
+ ``--env`` value; locally we run ``<interpreter> <packaged script> <args>``
181
+ with the resolved env exported as ``G3DT_*`` variables.
182
+
183
+ ``remote_cli(env_name)`` returns the equivalent ``g3dt`` subcommand argv
184
+ for the ``*_ec2`` env — on the box the CLI re-enters this function with
185
+ ``on=local`` and runs the same packaged script there.
186
+
187
+ ``interpreter`` is ``"python"`` (run with the venv interpreter) or ``"bash"``.
188
+ """
189
+ try:
190
+ if on == Target.local:
191
+ local_env = resolve_env(env)
192
+ args = build_args(local_env.name)
193
+ build = runner.bash_script if interpreter == "bash" else runner.python_script
194
+ runner.run(build(script_relpath, *args), env=script_env(local_env))
195
+ return
196
+ if remote_cli is None:
197
+ raise ConfigError(f"{label} does not support --on ec2.")
198
+ auth_env, remote_env = resolve_dispatch_envs(env)
199
+ except ConfigError as exc:
200
+ typer.secho(str(exc), fg=typer.colors.RED, err=True)
201
+ raise typer.Exit(1)
202
+
203
+ dispatch_ssm(auth_env, remote_env, list(remote_cli(remote_env.name)), label)
204
+
205
+
206
+ # --------------------------------------------------------------------------- #
207
+ # Watching dispatched runs (g3dt jobs) #
208
+ # --------------------------------------------------------------------------- #
209
+ def _auth_session_for_run(rec: dict):
210
+ """Authenticate follow-up calls (status/logs/stop) for a recorded run.
211
+
212
+ Uses the marker's profile for the run's base env; region from the marker
213
+ (no SSM read needed just to poll an invocation).
214
+ """
215
+ marker = load_marker()
216
+ return create_boto3_session(
217
+ aws_profile=aws_profile_for(rec["env"], marker),
218
+ aws_region=marker["region"],
219
+ )
220
+
221
+
222
+ def status(run_id: str) -> dict:
223
+ """Return the SSM invocation status for a dispatched run."""
224
+ rec = registry.get(run_id)
225
+ if not rec:
226
+ typer.secho(f"Unknown run id: {run_id}", fg=typer.colors.RED, err=True)
227
+ raise typer.Exit(1)
228
+ if not rec.get("command_id"):
229
+ typer.secho(
230
+ f"Run {run_id} has no SSM command id; check it on the box.",
231
+ fg=typer.colors.YELLOW,
232
+ )
233
+ return rec
234
+ ssm = _auth_session_for_run(rec).client("ssm")
235
+ return ssm.get_command_invocation(
236
+ CommandId=rec["command_id"], InstanceId=rec["instance_id"]
237
+ )
238
+
239
+
240
+ def status_label(rec: dict) -> str:
241
+ """Best-effort current status for the all-runs listing (never raises).
242
+
243
+ Unlike :func:`status`, this is keyed off the registry record and stays quiet
244
+ so it can be called in a loop.
245
+ """
246
+ if not rec.get("command_id"):
247
+ return "n/a"
248
+ ssm = _auth_session_for_run(rec).client("ssm")
249
+ try:
250
+ inv = ssm.get_command_invocation(
251
+ CommandId=rec["command_id"], InstanceId=rec["instance_id"]
252
+ )
253
+ return inv.get("Status", "unknown")
254
+ except ssm.exceptions.InvocationDoesNotExist:
255
+ return "pending" # sent but not yet registered, or aged out of SSM history
256
+ except ClientError:
257
+ return "unknown"
258
+
259
+
260
+ def stop(run_id: str) -> None:
261
+ """Cancel a running SSM-dispatched job.
262
+
263
+ Asks the SSM agent to terminate the in-flight command on the box.
264
+ Cancellation isn't instantaneous and isn't guaranteed by AWS, but in
265
+ practice it stops the process; ``g3dt jobs status <run_id>`` shows
266
+ ``Cancelled`` once it takes effect.
267
+ """
268
+ rec = registry.get(run_id)
269
+ if not rec:
270
+ typer.secho(f"Unknown run id: {run_id}", fg=typer.colors.RED, err=True)
271
+ raise typer.Exit(1)
272
+ if not rec.get("command_id"):
273
+ typer.secho(
274
+ f"Run {run_id} has no SSM command id and can't be stopped remotely.",
275
+ fg=typer.colors.YELLOW,
276
+ err=True,
277
+ )
278
+ raise typer.Exit(1)
279
+ current = status_label(rec)
280
+ if current in _TERMINAL_STATUSES:
281
+ typer.secho(f"Run {run_id} already finished: {current}", fg=typer.colors.YELLOW)
282
+ return
283
+ ssm = _auth_session_for_run(rec).client("ssm")
284
+ try:
285
+ ssm.cancel_command(
286
+ CommandId=rec["command_id"], InstanceIds=[rec["instance_id"]]
287
+ )
288
+ except ClientError as exc:
289
+ typer.secho(f"Could not stop {run_id}: {exc}", fg=typer.colors.RED, err=True)
290
+ raise typer.Exit(1)
291
+ typer.secho(
292
+ f"Stop requested for {run_id} ({rec['instance_id']}).", fg=typer.colors.GREEN
293
+ )
294
+
295
+
296
+ def _read_s3_logs(rec: dict) -> str:
297
+ """Best-effort fetch of the full stdout/stderr SSM wrote to S3."""
298
+ uri = rec.get("s3_log_uri") or ""
299
+ if not uri.startswith("s3://"):
300
+ return ""
301
+ bucket, _, prefix = uri[len("s3://"):].partition("/")
302
+ s3 = _auth_session_for_run(rec).client("s3")
303
+ listing = s3.list_objects_v2(Bucket=bucket, Prefix=prefix)
304
+ chunks: List[str] = []
305
+ for obj in listing.get("Contents", []):
306
+ key = obj["Key"]
307
+ if key.endswith(("stdout", "stderr")):
308
+ body = s3.get_object(Bucket=bucket, Key=key)["Body"].read()
309
+ chunks.append(body.decode("utf-8", errors="replace"))
310
+ return "\n".join(chunks)
311
+
312
+
313
+ #: SSM invocation states that mean the run is over.
314
+ _TERMINAL_STATUSES = ("Success", "Failed", "Cancelled", "TimedOut")
315
+
316
+
317
+ def _read_cw_logs(rec: dict, start_time: int = 0) -> List[dict]:
318
+ """Fetch CloudWatch Logs events for a run's SSM stdout/stderr streams.
319
+
320
+ SSM names the streams ``<command-id>/<instance-id>/<plugin>/{stdout,stderr}``,
321
+ so the ``<command-id>/<instance-id>`` prefix captures both. ``start_time``
322
+ (epoch millis) lets the follow loop pull only newer events; callers de-dup
323
+ by ``eventId``. Returns ``[]`` if the group/stream doesn't exist yet (run
324
+ just started, or the instance role lacks CloudWatch Logs write permission).
325
+ """
326
+ group = rec.get("cw_log_group")
327
+ command_id = rec.get("command_id")
328
+ instance_id = rec.get("instance_id")
329
+ if not (group and command_id and instance_id):
330
+ return []
331
+ client = _auth_session_for_run(rec).client("logs")
332
+ kwargs = {
333
+ "logGroupName": group,
334
+ "logStreamNamePrefix": f"{command_id}/{instance_id}",
335
+ "startTime": start_time,
336
+ }
337
+ events: List[dict] = []
338
+ try:
339
+ while True:
340
+ resp = client.filter_log_events(**kwargs)
341
+ events.extend(resp.get("events", []))
342
+ token = resp.get("nextToken")
343
+ if not token:
344
+ break
345
+ kwargs["nextToken"] = token
346
+ except client.exceptions.ResourceNotFoundException:
347
+ return []
348
+ return events
349
+
350
+
351
+ def _stream_cw_logs(run_id: str, rec: dict, *, follow: bool, poll_seconds: int) -> None:
352
+ """Print a run's CloudWatch output, optionally following until it finishes."""
353
+ seen_ids: set = set()
354
+ last_ts = 0
355
+
356
+ def drain() -> None:
357
+ nonlocal last_ts
358
+ for ev in sorted(_read_cw_logs(rec, last_ts), key=lambda e: e["timestamp"]):
359
+ if ev["eventId"] in seen_ids:
360
+ continue
361
+ seen_ids.add(ev["eventId"])
362
+ last_ts = max(last_ts, ev["timestamp"])
363
+ typer.echo(ev["message"])
364
+
365
+ while True:
366
+ drain()
367
+ if not follow:
368
+ break
369
+ inv = status(run_id)
370
+ if isinstance(inv, dict) and inv.get("Status") in _TERMINAL_STATUSES:
371
+ drain() # catch events ingested between the last drain and completion
372
+ typer.secho(f"\n[run {run_id} finished: {inv['Status']}]",
373
+ fg=typer.colors.BRIGHT_BLACK)
374
+ break
375
+ time.sleep(poll_seconds)
376
+
377
+
378
+ def logs(run_id: str, follow: bool = False, poll_seconds: int = 5) -> None:
379
+ """Print (and optionally follow) the logs for a dispatched run."""
380
+ rec = registry.get(run_id)
381
+ if not rec:
382
+ typer.secho(f"Unknown run id: {run_id}", fg=typer.colors.RED, err=True)
383
+ raise typer.Exit(1)
384
+
385
+ # Runs dispatched with CloudWatch output stream live; S3 output is only
386
+ # uploaded on completion, so it is the fallback.
387
+ if rec.get("cw_log_group"):
388
+ _stream_cw_logs(run_id, rec, follow=follow, poll_seconds=poll_seconds)
389
+ return
390
+
391
+ seen = 0
392
+ while True:
393
+ text = _read_s3_logs(rec)
394
+ if len(text) > seen:
395
+ typer.echo(text[seen:], nl=False)
396
+ seen = len(text)
397
+ if not follow:
398
+ break
399
+ inv = status(run_id)
400
+ if isinstance(inv, dict) and inv.get("Status") in _TERMINAL_STATUSES:
401
+ typer.secho(f"\n[run {run_id} finished: {inv['Status']}]",
402
+ fg=typer.colors.BRIGHT_BLACK)
403
+ break
404
+ time.sleep(poll_seconds)
405
+
406
+
407
+ def _print_dispatch_banner(run_id: str, remote_env: EnvConfig) -> None:
408
+ typer.secho(
409
+ f"Dispatched to EC2 ({remote_env.ec2_instance_id} / {remote_env.name})",
410
+ fg=typer.colors.GREEN,
411
+ )
412
+ typer.echo(f" run id : {run_id}")
413
+ typer.echo(f" logs : g3dt jobs logs {run_id} --follow")
414
+ typer.echo(f" status : g3dt jobs status {run_id}")
415
+
416
+
417
+ __all__ = [
418
+ "Target",
419
+ "run_or_dispatch",
420
+ "dispatch_ssm",
421
+ "resolve_dispatch_envs",
422
+ "build_remote_command",
423
+ "new_run_id",
424
+ "status",
425
+ "status_label",
426
+ "stop",
427
+ "logs",
428
+ ]
@@ -0,0 +1,65 @@
1
+ """A tiny local registry of EC2-dispatched runs (``~/.g3dt/runs.json``).
2
+
3
+ Maps a human-friendly ``run_id`` to the underlying SSM command id so users
4
+ never have to copy long AWS identifiers — ``g3dt jobs status <run_id>`` and
5
+ ``g3dt jobs logs <run_id>`` look everything up here.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ from pathlib import Path
12
+ from typing import Dict, List, Optional
13
+
14
+ REGISTRY_DIR = Path(os.path.expanduser("~")) / ".g3dt"
15
+ REGISTRY_FILE = REGISTRY_DIR / "runs.json"
16
+
17
+
18
+ def _load() -> Dict[str, dict]:
19
+ if REGISTRY_FILE.exists():
20
+ try:
21
+ return json.loads(REGISTRY_FILE.read_text())
22
+ except (json.JSONDecodeError, OSError):
23
+ return {}
24
+ return {}
25
+
26
+
27
+ def _save(data: Dict[str, dict]) -> None:
28
+ REGISTRY_DIR.mkdir(parents=True, exist_ok=True)
29
+ REGISTRY_FILE.write_text(json.dumps(data, indent=2, sort_keys=True))
30
+
31
+
32
+ def record(
33
+ run_id: str,
34
+ *,
35
+ command_id: Optional[str],
36
+ instance_id: Optional[str],
37
+ env: str,
38
+ argv: List[str],
39
+ mechanism: str,
40
+ s3_log_uri: Optional[str] = None,
41
+ cw_log_group: Optional[str] = None,
42
+ started_at: Optional[str] = None,
43
+ ) -> None:
44
+ """Persist a dispatched run."""
45
+ data = _load()
46
+ data[run_id] = {
47
+ "run_id": run_id,
48
+ "command_id": command_id,
49
+ "instance_id": instance_id,
50
+ "env": env,
51
+ "argv": list(argv),
52
+ "mechanism": mechanism,
53
+ "s3_log_uri": s3_log_uri,
54
+ "cw_log_group": cw_log_group,
55
+ "started_at": started_at,
56
+ }
57
+ _save(data)
58
+
59
+
60
+ def get(run_id: str) -> Optional[dict]:
61
+ return _load().get(run_id)
62
+
63
+
64
+ def all_runs() -> Dict[str, dict]:
65
+ return _load()
@@ -0,0 +1,22 @@
1
+ """Thin wrappers that turn config resolution errors into clean CLI exits."""
2
+ from __future__ import annotations
3
+
4
+ import typer
5
+
6
+ from g3dt import config
7
+
8
+
9
+ def env_of(env: str) -> config.EnvConfig:
10
+ try:
11
+ return config.resolve_env(env)
12
+ except config.ConfigError as exc:
13
+ typer.secho(str(exc), fg=typer.colors.RED, err=True)
14
+ raise typer.Exit(1)
15
+
16
+
17
+ def study_of(study: str, env: str) -> config.StudyConfig:
18
+ try:
19
+ return config.resolve_study(study, env)
20
+ except config.ConfigError as exc:
21
+ typer.secho(str(exc), fg=typer.colors.RED, err=True)
22
+ raise typer.Exit(1)
@@ -0,0 +1,76 @@
1
+ """Run local subprocesses with live-streamed output and clean exit handling.
2
+
3
+ Commands shell out through :func:`run` so failures surface as ``typer.Exit``
4
+ with the child's exit code (preserving the ``set -e`` semantics of the wrapped
5
+ shell scripts). Tests patch :func:`run` to assert the exact argv built.
6
+
7
+ Service scripts ship *inside* the installed package (``g3dt/services/...``),
8
+ so the toolkit works from a bare ``pip install`` with no repository checkout —
9
+ :func:`package_path` resolves them from the package directory.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import subprocess
14
+ import sys
15
+ from importlib import resources
16
+ from pathlib import Path
17
+ from typing import List, Optional, Sequence
18
+
19
+ import typer
20
+
21
+
22
+ def package_path(relpath: str) -> Path:
23
+ """Resolve a path shipped inside the ``g3dt`` package (e.g. ``services/...``).
24
+
25
+ ``relpath`` may use the historical ``services/...`` form; it is resolved
26
+ against the installed package directory, never a repo checkout.
27
+ """
28
+ return Path(str(resources.files("g3dt"))) / relpath
29
+
30
+
31
+ def python_script(relpath: str, *args: str) -> List[str]:
32
+ """Build an argv that runs a packaged service script with this interpreter.
33
+
34
+ Using ``sys.executable`` guarantees the script runs inside the same
35
+ environment as the CLI itself.
36
+ """
37
+ return [sys.executable, str(package_path(relpath)), *[str(a) for a in args]]
38
+
39
+
40
+ def bash_script(relpath: str, *args: str) -> List[str]:
41
+ """Build an argv that runs a packaged shell script via bash."""
42
+ return ["bash", str(package_path(relpath)), *[str(a) for a in args]]
43
+
44
+
45
+ def run(
46
+ argv: Sequence[str],
47
+ *,
48
+ cwd: Optional[Path] = None,
49
+ env: Optional[dict] = None,
50
+ echo: bool = True,
51
+ ) -> int:
52
+ """Run ``argv``, streaming output live. Raise ``typer.Exit`` on failure.
53
+
54
+ Returns 0 on success. The working directory defaults to the caller's
55
+ current directory (there is no repo root to default to — the toolkit is
56
+ installable-only).
57
+ """
58
+ argv = [str(a) for a in argv]
59
+ workdir = Path(cwd) if cwd else Path.cwd()
60
+ if echo:
61
+ typer.secho(f"$ {' '.join(argv)}", fg=typer.colors.BRIGHT_BLACK)
62
+ try:
63
+ completed = subprocess.run(argv, cwd=str(workdir), env=env, check=False)
64
+ except FileNotFoundError as exc:
65
+ typer.secho(
66
+ f"Command not found: {argv[0]} ({exc})", fg=typer.colors.RED, err=True
67
+ )
68
+ raise typer.Exit(127)
69
+ if completed.returncode != 0:
70
+ typer.secho(
71
+ f"Command failed (exit {completed.returncode}): {' '.join(argv)}",
72
+ fg=typer.colors.RED,
73
+ err=True,
74
+ )
75
+ raise typer.Exit(completed.returncode)
76
+ return 0