gen3-dataops-toolkit 4.1.0__tar.gz → 4.3.0__tar.gz

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 (63) hide show
  1. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.3.0}/PKG-INFO +15 -1
  2. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.3.0}/README.md +14 -0
  3. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.3.0}/pyproject.toml +2 -2
  4. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/cli/delete_cmds.py +133 -11
  5. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/cli/synth.py +9 -0
  6. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/config.py +74 -1
  7. gen3_dataops_toolkit-4.3.0/src/g3dt/import_order.py +265 -0
  8. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/services/delete/delete_all_metadata_for_project.py +65 -23
  9. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/services/delete/delete_metadata.sh +46 -9
  10. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/services/delete/delete_metadata_by_guid.py +40 -19
  11. gen3_dataops_toolkit-4.3.0/src/g3dt/services/delete/delete_synth_metadata_by_version.py +355 -0
  12. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/services/dictionary/upload_dictionary.py +13 -8
  13. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/services/synthetic_data/generate_synth_metadata.sh +8 -0
  14. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/upload/metadata_deleter.py +78 -0
  15. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/utils/athena_utils.py +0 -9
  16. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/__init__.py +0 -0
  17. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/cli/__init__.py +0 -0
  18. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/cli/_internal/__init__.py +0 -0
  19. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/cli/_internal/aws_quiet.py +0 -0
  20. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/cli/_internal/dispatch.py +0 -0
  21. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/cli/_internal/helptext.py +0 -0
  22. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/cli/_internal/registry.py +0 -0
  23. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/cli/_internal/resolve.py +0 -0
  24. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/cli/_internal/runner.py +0 -0
  25. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/cli/_internal/safety.py +0 -0
  26. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/cli/config_cmds.py +0 -0
  27. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/cli/dict_cmds.py +0 -0
  28. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/cli/ec2_cmds.py +0 -0
  29. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/cli/indexd_cmds.py +0 -0
  30. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/cli/jobs.py +0 -0
  31. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/cli/k8s.py +0 -0
  32. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/cli/main.py +0 -0
  33. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/cli/metadata.py +0 -0
  34. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/cli/pipeline_cmds.py +0 -0
  35. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/cli/release_cmds.py +0 -0
  36. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/cli/study_cmds.py +0 -0
  37. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/contexts.py +0 -0
  38. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/indexd/__init__.py +0 -0
  39. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/indexd/file_access.py +0 -0
  40. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/indexd/indexd_registrar.py +0 -0
  41. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/ingest/ingest.py +0 -0
  42. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/resolver.py +0 -0
  43. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/services/dictionary/deploy_dd.sh +0 -0
  44. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/services/dictionary/pull_dict.sh +0 -0
  45. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/services/indexd/register_indexd.py +0 -0
  46. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/services/indexd/verify_file_access.py +0 -0
  47. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/services/k8s_ops/argocd_restart_etl.sh +0 -0
  48. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/services/k8s_ops/argocd_restart_ms.sh +0 -0
  49. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/services/k8s_ops/argocd_restart_schema.sh +0 -0
  50. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/services/k8s_ops/login_to_pod.sh +0 -0
  51. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/services/k8s_ops/restart_etl_and_ms.sh +0 -0
  52. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/services/synthetic_data/delete_synth_metadata_sheepdog.py +0 -0
  53. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/services/synthetic_data/full_deploy_dd_and_synth.sh +0 -0
  54. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/services/synthetic_data/upload_synth_metadata_sheepdog.py +0 -0
  55. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/services/upload/metadata/upload_all_studies.sh +0 -0
  56. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/services/upload/metadata/upload_metadata.py +0 -0
  57. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/studies.py +0 -0
  58. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/upload/__init__.py +0 -0
  59. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/upload/metadata_submitter.py +0 -0
  60. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/upload/upload_synthdata_s3.py +0 -0
  61. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/utils/dbt_utils.py +0 -0
  62. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/utils/release_writer.py +0 -0
  63. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/validate/validate.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gen3-dataops-toolkit
3
- Version: 4.1.0
3
+ Version: 4.3.0
4
4
  Summary: Gen3 DataOps toolkit (g3dt): operate SSM-published Gen3 data pipeline environments
5
5
  License: Apache-2.0
6
6
  Author: JoshuaHarris391
@@ -194,6 +194,20 @@ version until the CDK config catches up —
194
194
  `g3dt config diff --env <env> --file <wrapper>/config/<project>.<env>.json`
195
195
  reports exactly that gap and exits 1, so it can gate CI.
196
196
 
197
+ ### Deleting metadata: where the node order comes from
198
+
199
+ `delete metadata` walks nodes children-before-parents. The order is resolved
200
+ from the first available of: an explicit `--import-order <path|s3://…>`
201
+ (failures are fatal — an explicit source is never silently skipped), the
202
+ registered study's release bucket (`DataImportOrder.txt` next to the release's
203
+ node JSONs), a `DataImportOrder.txt` in the current directory, or a
204
+ topological sort derived from the dictionary itself — the `--dict-version`
205
+ tag's bundle when given (downloaded to `~/.g3dt/schemas` if needed), else the
206
+ env's deployed dictionary. Deriving matters when deleting data submitted
207
+ under an older dictionary whose node layout differs from today's: pass
208
+ `--dict-version <old-tag>`. With `--on ec2`, `--import-order` must be an
209
+ `s3://` URI (a laptop path does not exist on the box).
210
+
197
211
  Synthetic data is only schema-valid against the dictionary that generated it, so
198
212
  `synth generate` records the dictionary version in each batch and `synth upload`
199
213
  refuses a batch that doesn't match the version being uploaded (override with
@@ -160,6 +160,20 @@ version until the CDK config catches up —
160
160
  `g3dt config diff --env <env> --file <wrapper>/config/<project>.<env>.json`
161
161
  reports exactly that gap and exits 1, so it can gate CI.
162
162
 
163
+ ### Deleting metadata: where the node order comes from
164
+
165
+ `delete metadata` walks nodes children-before-parents. The order is resolved
166
+ from the first available of: an explicit `--import-order <path|s3://…>`
167
+ (failures are fatal — an explicit source is never silently skipped), the
168
+ registered study's release bucket (`DataImportOrder.txt` next to the release's
169
+ node JSONs), a `DataImportOrder.txt` in the current directory, or a
170
+ topological sort derived from the dictionary itself — the `--dict-version`
171
+ tag's bundle when given (downloaded to `~/.g3dt/schemas` if needed), else the
172
+ env's deployed dictionary. Deriving matters when deleting data submitted
173
+ under an older dictionary whose node layout differs from today's: pass
174
+ `--dict-version <old-tag>`. With `--on ec2`, `--import-order` must be an
175
+ `s3://` URI (a laptop path does not exist on the box).
176
+
163
177
  Synthetic data is only schema-valid against the dictionary that generated it, so
164
178
  `synth generate` records the dictionary version in each batch and `synth upload`
165
179
  refuses a batch that doesn't match the version being uploaded (override with
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "gen3-dataops-toolkit"
3
- version = "4.1.0"
3
+ version = "4.3.0"
4
4
  description = "Gen3 DataOps toolkit (g3dt): operate SSM-published Gen3 data pipeline environments"
5
5
  authors = ["JoshuaHarris391 <harjo391@gmail.com>"]
6
6
  readme = "README.md"
@@ -44,7 +44,7 @@ moto = ">=5.0"
44
44
  optional = true
45
45
 
46
46
  [tool.poetry.group.synth.dependencies]
47
- gen3-metadata-simulator = "^0.3.0"
47
+ gen3-metadata-simulator = "^0.5.3"
48
48
 
49
49
  [build-system]
50
50
  requires = ["poetry-core>=2.0.0,<3.0.0"]
@@ -7,6 +7,19 @@ the bare names (a specific version like ``0.9.8``, resolved via an Athena GUID
7
7
  lookup, or ``all`` for every version). A bare study with no version anywhere
8
8
  is refused (exit 2).
9
9
 
10
+ ``--synthetic`` switches to registry-free mode for synthetic data: each
11
+ ``--studies`` name is the Gen3 project code itself (no SSM study registry —
12
+ synthetic projects are never registered), bare names default to version
13
+ ``all``, and a specific version is matched verbatim against the records'
14
+ ``data_version`` property via GraphQL rather than Athena receipts (synthetic
15
+ uploads write none).
16
+
17
+ The node deletion order comes from, in order: an explicit ``--import-order``
18
+ (path or s3:// URI; failures are fatal, never silently skipped), the
19
+ registered study's release bucket, a ``DataImportOrder.txt`` in the current
20
+ directory, or a topological sort derived from the dictionary itself — the
21
+ ``--dict-version`` bundle when given, else the env's deployed dictionary.
22
+
10
23
  Every command confirms before acting. Production always requires typing the
11
24
  target id, even with ``--yes``. Deleting ALL versions always prompts, even with
12
25
  ``--yes``. Confirmation happens locally before any EC2 dispatch (SSM has no
@@ -61,12 +74,30 @@ def _normalise_version(raw: str, where: str) -> str:
61
74
  return match.group(1)
62
75
 
63
76
 
64
- def _parse_study_specs(studies: str, fallback, env: str):
77
+ def _synthetic_version(raw: str) -> str:
78
+ """Canonicalise a synthetic version token: ``all`` (any case) or verbatim.
79
+
80
+ Synthetic versions are matched exactly against the records' ``data_version``
81
+ property, and the natural label there is the dictionary version WITH its
82
+ leading ``v`` (batch dirs are ``~/.g3dt/synth_metadata/v1.3.0/...``) — so
83
+ unlike ``_normalise_version`` nothing is stripped. A version that matches
84
+ no records is reported by the worker (skip + hint), not silently absorbed.
85
+ """
86
+ token = raw.strip()
87
+ return "all" if token.lower() == "all" else token
88
+
89
+
90
+ def _parse_study_specs(studies: str, fallback, env: str, synthetic: bool = False):
65
91
  """Turn ``--studies`` into ``[(resolved_study_key, version), ...]``.
66
92
 
67
93
  Each comma-separated entry is ``name`` or ``name:version``. A bare name
68
94
  takes *fallback* (the ``--version`` default); *fallback* is ``None`` when
69
- ``--version`` was not given, which makes a bare name a usage error.
95
+ ``--version`` was not given, which makes a bare name a usage error —
96
+ except with *synthetic*, where a bare name defaults to ``all`` (the whole
97
+ point of the flag is "wipe the synthetic project").
98
+
99
+ With *synthetic* the raw name IS the Gen3 project code: no study-registry
100
+ lookup, and version tokens pass through :func:`_synthetic_version`.
70
101
 
71
102
  Every entry is validated before anything is dispatched, so a typo in the
72
103
  last study cannot leave the earlier ones already deleted.
@@ -102,9 +133,15 @@ def _parse_study_specs(studies: str, fallback, env: str):
102
133
  raise typer.Exit(2)
103
134
 
104
135
  if sep:
105
- version = _normalise_version(raw_version, f"for study '{name}'")
136
+ version = (
137
+ _synthetic_version(raw_version)
138
+ if synthetic
139
+ else _normalise_version(raw_version, f"for study '{name}'")
140
+ )
106
141
  elif fallback is not None:
107
142
  version = fallback
143
+ elif synthetic:
144
+ version = "all"
108
145
  else:
109
146
  typer.secho(
110
147
  f"No version for study '{name}': add ':<version>' to it "
@@ -115,7 +152,7 @@ def _parse_study_specs(studies: str, fallback, env: str):
115
152
  )
116
153
  raise typer.Exit(2)
117
154
 
118
- specs.append((study_of(name, env).key, version))
155
+ specs.append((name if synthetic else study_of(name, env).key, version))
119
156
 
120
157
  if not specs:
121
158
  typer.secho("--studies is empty.", fg=typer.colors.RED, err=True)
@@ -141,6 +178,35 @@ def metadata(
141
178
  "':version', e.g. 0.9.8, or 'all' for every version.",
142
179
  ),
143
180
  node: Optional[str] = typer.Option(None, "--node", help="Delete only this node type."),
181
+ synthetic: bool = typer.Option(
182
+ False,
183
+ "--synthetic",
184
+ help="Registry-free synthetic-data mode: each --studies name is the "
185
+ "Gen3 project id itself (no SSM study registry). Bare names "
186
+ "default to version 'all'; a specific version matches records' "
187
+ "data_version property verbatim.",
188
+ ),
189
+ program_id: Optional[str] = typer.Option(
190
+ None,
191
+ "--program-id",
192
+ help="Gen3 program for --synthetic (default: program1). "
193
+ "Invalid without --synthetic.",
194
+ ),
195
+ import_order: Optional[str] = typer.Option(
196
+ None,
197
+ "--import-order",
198
+ help="Path or s3:// URI of DataImportOrder.txt. Default: auto — the "
199
+ "study's release bucket (registered studies), then "
200
+ "./DataImportOrder.txt, then derived from the dictionary. "
201
+ "With --on ec2 only s3:// URIs are accepted.",
202
+ ),
203
+ dict_version: Optional[str] = typer.Option(
204
+ None,
205
+ "--dict-version",
206
+ help="Dictionary git tag to derive the node order from (verbatim, "
207
+ "e.g. v1.3.0). Default: the env's deployed dictionary. Only "
208
+ "used when the order is derived.",
209
+ ),
144
210
  yes: bool = typer.Option(
145
211
  False, "--yes", "-y", help="Skip the non-prod prompt (specific-version only)."
146
212
  ),
@@ -156,12 +222,44 @@ def metadata(
156
222
 
157
223
  g3dt delete metadata --studies "ausdiab:0.7.5,cdah:0.8.1" --env staging
158
224
  g3dt delete metadata --studies "ausdiab:all,cdah" --version 0.9.8 --env staging
225
+ g3dt delete metadata --studies "synthetic_dataset_1,synthetic_dataset_2" --env test --synthetic
159
226
  """
160
227
  env = resolve.active_env(env)
161
- fallback = (
162
- _normalise_version(version, "for --version") if version is not None else None
163
- )
164
- specs = _parse_study_specs(studies, fallback, env)
228
+ if program_id is not None and not synthetic:
229
+ typer.secho(
230
+ "--program-id is only valid with --synthetic (registered studies "
231
+ "carry their program in the study registry).",
232
+ fg=typer.colors.RED,
233
+ err=True,
234
+ )
235
+ raise typer.Exit(2)
236
+ if import_order and dict_version:
237
+ typer.secho(
238
+ "--import-order names the exact file; --dict-version derives one "
239
+ "— pass only one.",
240
+ fg=typer.colors.RED,
241
+ err=True,
242
+ )
243
+ raise typer.Exit(2)
244
+ # A laptop path forwarded to the EC2 box would resolve against the box's
245
+ # filesystem — at best a crash after confirmation, at worst a same-named
246
+ # DIFFERENT file ordering the delete. s3:// URIs (and --dict-version)
247
+ # resolve identically anywhere, so only those may travel.
248
+ if on == Target.ec2 and import_order and not import_order.startswith("s3://"):
249
+ typer.secho(
250
+ "--import-order with --on ec2 must be an s3:// URI (a local path "
251
+ "does not exist on the box). Upload the file, or run locally.",
252
+ fg=typer.colors.RED,
253
+ err=True,
254
+ )
255
+ raise typer.Exit(2)
256
+ if version is None:
257
+ fallback = None
258
+ elif synthetic:
259
+ fallback = _synthetic_version(version)
260
+ else:
261
+ fallback = _normalise_version(version, "for --version")
262
+ specs = _parse_study_specs(studies, fallback, env, synthetic=synthetic)
165
263
  versions = [v for _, v in specs]
166
264
 
167
265
  # The typed production confirmation stays the study keys alone: short
@@ -171,13 +269,17 @@ def metadata(
171
269
  uniform = len(set(versions)) == 1
172
270
  any_all = "all" in versions
173
271
 
272
+ prefix = "synthetic " if synthetic else ""
174
273
  if uniform and versions[0] == "all":
175
- action = "deletion of ALL VERSIONS"
274
+ action = f"{prefix}deletion of ALL VERSIONS"
176
275
  elif uniform:
177
- action = f"deletion of v{versions[0]}"
276
+ # Synthetic versions are verbatim data_version values (often already
277
+ # v-prefixed); Athena versions are canonical x.y.z, displayed with v.
278
+ shown = versions[0] if synthetic else f"v{versions[0]}"
279
+ action = f"{prefix}deletion of {shown}"
178
280
  else:
179
281
  plan = ", ".join(f"{key}:{v}" for key, v in specs)
180
- action = f"deletion of per-study versions [{plan}]"
282
+ action = f"{prefix}deletion of per-study versions [{plan}]"
181
283
 
182
284
  # Deleting every version is the most destructive path: always prompt (pass
183
285
  # assume_yes=False so --yes can't bypass it; prod still types the target).
@@ -200,6 +302,15 @@ def metadata(
200
302
  ]
201
303
  if node:
202
304
  a += ["--node", node]
305
+ if synthetic:
306
+ # Program is always passed explicitly: the shell default makes it
307
+ # optional on the wire, but an explicit value keeps the contract
308
+ # visible in logs and SSM command history.
309
+ a += ["--synthetic", "--program-id", program_id or "program1"]
310
+ if import_order:
311
+ a += ["--import-order", import_order]
312
+ if dict_version:
313
+ a += ["--dict-version", dict_version]
203
314
  return a
204
315
 
205
316
  def remote_cli(env_name):
@@ -212,6 +323,17 @@ def metadata(
212
323
  a.append("--yes")
213
324
  if node:
214
325
  a += ["--node", node]
326
+ if synthetic:
327
+ # Without this the remote re-entry would re-parse --studies
328
+ # against the study registry on the box and exit 2.
329
+ a.append("--synthetic")
330
+ if program_id is not None:
331
+ a += ["--program-id", program_id]
332
+ if import_order:
333
+ # Guaranteed s3:// by the pre-dispatch gate above.
334
+ a += ["--import-order", import_order]
335
+ if dict_version:
336
+ a += ["--dict-version", dict_version]
215
337
  return a
216
338
 
217
339
  dispatch.run_or_dispatch(
@@ -370,6 +370,13 @@ def generate(
370
370
  None, "--version", "-v",
371
371
  help="Version label for output dir (default: env dictionary_version).",
372
372
  ),
373
+ data_version: Optional[str] = typer.Option(
374
+ None, "--data-version",
375
+ help="Stamp every generated record's data_version property with this "
376
+ "value (requires the dictionary to declare data_version). Makes "
377
+ "the batch deletable later with "
378
+ "'delete metadata --synthetic --version <value>'.",
379
+ ),
373
380
  ) -> None:
374
381
  """Generate synthetic metadata locally with gen3-metadata-simulator.
375
382
 
@@ -431,6 +438,8 @@ def generate(
431
438
  args += ["--num-records", num_records]
432
439
  if seed is not None:
433
440
  args += ["--seed", str(seed)]
441
+ if data_version:
442
+ args += ["--data-version", data_version]
434
443
  env_vars = script_env(e, ver)
435
444
  if effective_provider is Provider.llm:
436
445
  env_vars.update(
@@ -40,6 +40,7 @@ import re
40
40
  from dataclasses import dataclass
41
41
  from pathlib import Path
42
42
  from typing import Dict, List, Optional, Tuple
43
+ from urllib.parse import unquote, urlsplit
43
44
 
44
45
  import yaml
45
46
 
@@ -393,6 +394,8 @@ class EnvConfig:
393
394
  dictionary_version: str
394
395
  aws_profile: Optional[str]
395
396
  aws_secret_name: str
397
+ # Canonical scheme-less "bucket/key" (normalize_s3_location at resolve time;
398
+ # callers prepend s3:// themselves).
396
399
  schema_s3_uri: str
397
400
  domain: str
398
401
  app_name: str
@@ -421,6 +424,74 @@ def _app_or_default(rc, leaf: str, default: str) -> str:
421
424
  return (rc.get(f"app/{leaf}") or "").strip() or default
422
425
 
423
426
 
427
+ #: The two S3 endpoint URL host styles. Path-style must be tried first: a bare
428
+ #: ``s3.<region>.amazonaws.com`` host would otherwise match the virtual-hosted
429
+ #: pattern with bucket "s3". The virtual-hosted bucket group is greedy so
430
+ #: dotted bucket names keep their dots.
431
+ _S3_PATH_STYLE_HOST = re.compile(r"^s3([.-][a-z0-9-]+)*\.amazonaws\.com$")
432
+ _S3_VIRTUAL_HOST = re.compile(r"^(?P<bucket>.+)\.s3([.-][a-z0-9-]+)*\.amazonaws\.com$")
433
+
434
+ #: Remediation appended to normalize_s3_location errors for the SSM app fact.
435
+ _SCHEMA_S3_URI_HINT = (
436
+ "Fix gen3.schemaS3Uri in the CDK config (gen3-aws-data-pipeline) and "
437
+ "re-run `cdk deploy`."
438
+ )
439
+
440
+
441
+ def normalize_s3_location(
442
+ value: str, *, param: str = "app/schema_s3_uri", hint: Optional[str] = None
443
+ ) -> str:
444
+ """Canonicalize an operator-supplied S3 location to scheme-less ``bucket[/key]``.
445
+
446
+ Accepted forms: ``bucket/key`` (canonical), ``s3://bucket/key`` — a repeated
447
+ scheme (``s3://s3://...``) is tolerated, since that is exactly what a
448
+ scheme-carrying SSM value produces once callers prepend ``s3://`` — and the
449
+ two S3 endpoint URL styles (``https://<bucket>.s3.<region>.amazonaws.com/<key>``,
450
+ ``https://s3.<region>.amazonaws.com/<bucket>/<key>``); query strings on URL
451
+ forms (presigned links, ``?versionId=``) are dropped.
452
+
453
+ An object key is NOT required — ``resolve_env`` gates every command, so
454
+ shape is enforced at point of use (upload). The key is preserved verbatim,
455
+ trailing slash included, except for percent-decoding of URL forms.
456
+
457
+ :raises ConfigError: empty value, unrecognizable http(s) host (e.g. an AWS
458
+ console page URL), or a bucket segment containing ``:`` (the
459
+ ``s3:/bucket`` one-slash typo).
460
+ """
461
+ original = value.strip()
462
+
463
+ def _bad() -> ConfigError:
464
+ msg = (
465
+ f"Cannot interpret {param} value '{original}' as an S3 location. "
466
+ "Accepted forms: bucket/key, s3://bucket/key, or an S3 endpoint "
467
+ "URL (https://<bucket>.s3.<region>.amazonaws.com/<key> or "
468
+ "https://s3.<region>.amazonaws.com/<bucket>/<key>)."
469
+ )
470
+ return ConfigError(msg + (f" {hint}" if hint else ""))
471
+
472
+ v = original
473
+ if not v:
474
+ raise _bad()
475
+ while v.lower().startswith("s3://"):
476
+ v = v[len("s3://") :]
477
+ if v.lower().startswith(("https://", "http://")):
478
+ parts = urlsplit(v)
479
+ host = parts.hostname or ""
480
+ path = unquote(parts.path)
481
+ if _S3_PATH_STYLE_HOST.match(host):
482
+ v = path.lstrip("/")
483
+ else:
484
+ m = _S3_VIRTUAL_HOST.match(host)
485
+ if not m:
486
+ raise _bad()
487
+ key = path.lstrip("/")
488
+ v = f"{m.group('bucket')}/{key}" if key else m.group("bucket")
489
+ bucket = v.split("/", 1)[0]
490
+ if not bucket or ":" in bucket:
491
+ raise _bad()
492
+ return v
493
+
494
+
424
495
  def resolve_env(env: str, project: Optional[str] = None) -> EnvConfig:
425
496
  """Resolve one environment: app INPUT facts + CDK OUTPUT names, from SSM.
426
497
 
@@ -454,7 +525,9 @@ def resolve_env(env: str, project: Optional[str] = None) -> EnvConfig:
454
525
  dictionary_version=rc.app("dictionary_version"),
455
526
  aws_profile=profile,
456
527
  aws_secret_name=rc.app("aws_secret_name"),
457
- schema_s3_uri=rc.app("schema_s3_uri"),
528
+ schema_s3_uri=normalize_s3_location(
529
+ rc.app("schema_s3_uri"), hint=_SCHEMA_S3_URI_HINT
530
+ ),
458
531
  domain=rc.app("domain"),
459
532
  app_name=rc.app("app_name"),
460
533
  namespace=rc.app("namespace"),
@@ -0,0 +1,265 @@
1
+ """Node import-order resolution for metadata deletion.
2
+
3
+ Deleting Gen3 metadata must walk nodes children-before-parents, which the
4
+ toolkit historically took from a ``DataImportOrder.txt`` in the caller's
5
+ working directory — a silent dependency that crashed runs started anywhere
6
+ else. This module resolves the order from an explicit chain of sources:
7
+
8
+ 1. an explicit ``--import-order`` (local path or ``s3://`` URI) — fatal if
9
+ unreadable, never silently skipped;
10
+ 2. the study's release bucket (registered studies only — releases ship a
11
+ ``DataImportOrder.txt`` next to the node JSONs);
12
+ 3. a ``DataImportOrder.txt`` in the current directory (legacy behavior);
13
+ 4. derivation from the dictionary itself: a topological sort over the raw
14
+ bundle's ``links`` (proven byte-identical to simulator-written order
15
+ files), reading either the deployed dictionary at ``schema_s3_uri`` or a
16
+ ``--dict-version`` bundle cached under ``~/.g3dt/schemas``.
17
+
18
+ Deliberately NOT used: ``gen3_validator``'s ``DataDictionary.get_node_order``
19
+ (it force-moves core_metadata_collection last, which a deletion reversal would
20
+ delete FIRST — before the file nodes that link to it) and its
21
+ ``get_node_link`` (inspects only ``links[0]`` for subgroups).
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import logging
27
+ import os
28
+ import urllib.error
29
+ import urllib.request
30
+ from collections import deque
31
+ from pathlib import Path
32
+ from typing import List, Optional, Sequence, Tuple
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+ #: Bundle keys that are shared definitions, not nodes.
37
+ _META_KEYS = frozenset(
38
+ {"_definitions.yaml", "_terms.yaml", "_settings.yaml", "root.yaml", "metaschema.yaml"}
39
+ )
40
+
41
+ _ORDER_FILENAME = "DataImportOrder.txt"
42
+
43
+
44
+ class ImportOrderError(Exception):
45
+ """A node order could not be resolved; the message names the fix."""
46
+
47
+
48
+ def derive_import_order(schema: dict) -> List[str]:
49
+ """Topologically sort a raw Gen3 dictionary bundle into submission order.
50
+
51
+ Works on the UNRESOLVED bundle ({"<node>.yaml": {...}, ...}): ``links``
52
+ blocks are self-contained (no $refs), so no schema resolution is needed.
53
+ Skips the shared ``_*.yaml`` definitions, entries without ``properties``,
54
+ and ``submittable: false`` nodes (which removes ``program``). Every
55
+ ``links`` entry is flattened — subgroup members and plain links alike —
56
+ into edges ``parent -> child``; edges to nodes outside the set are
57
+ dropped (this handles ``project -> program``).
58
+
59
+ Kahn's algorithm with sorted tie-breaking keeps the output deterministic
60
+ and byte-compatible with simulator-written DataImportOrder.txt files.
61
+ Members of a dependency cycle (impossible in a valid Gen3 dictionary) are
62
+ appended at the end with a warning rather than dropped.
63
+
64
+ Returns SUBMISSION order (parents first); callers reverse for deletion.
65
+ """
66
+ nodes = {}
67
+ for key, value in schema.items():
68
+ if key in _META_KEYS or not isinstance(value, dict):
69
+ continue
70
+ if "properties" not in value or value.get("submittable") is False:
71
+ continue
72
+ name = key[: -len(".yaml")] if key.endswith(".yaml") else key
73
+ nodes[name] = value
74
+
75
+ graph = {n: [] for n in nodes}
76
+ in_degree = {n: 0 for n in nodes}
77
+ for name, node in nodes.items():
78
+ for entry in node.get("links", []) or []:
79
+ members = entry.get("subgroup", [entry]) if isinstance(entry, dict) else []
80
+ for member in members:
81
+ parent = member.get("target_type")
82
+ if parent in nodes:
83
+ graph[parent].append(name)
84
+ in_degree[name] += 1
85
+
86
+ queue = deque(sorted(n for n, d in in_degree.items() if d == 0))
87
+ ordered: List[str] = []
88
+ while queue:
89
+ node = queue.popleft()
90
+ ordered.append(node)
91
+ for child in sorted(graph[node]):
92
+ in_degree[child] -= 1
93
+ if in_degree[child] == 0:
94
+ queue.append(child)
95
+ queue = deque(sorted(queue))
96
+
97
+ leftovers = sorted(n for n in nodes if n not in ordered)
98
+ if leftovers:
99
+ logger.warning(
100
+ "Dictionary link cycle detected; appending unordered node(s): %s "
101
+ "— deleting them may require a re-run.",
102
+ ", ".join(leftovers),
103
+ )
104
+ ordered.extend(leftovers)
105
+ return ordered
106
+
107
+
108
+ def ensure_local_dictionary(env_cfg, version: str, schema_dir: Optional[Path] = None) -> Path:
109
+ """Return the local bundle for ``version``, downloading it if needed.
110
+
111
+ The cache is ``$G3DT_SCHEMA_DIR`` (else ``~/.g3dt/schemas``), shared with
112
+ pull_dict.sh. A cache hit requires a non-empty file that parses as JSON —
113
+ ``wget -O`` leaves a zero-byte file behind on a failed pull, so mere
114
+ existence is not trusted. Downloads are validated as JSON BEFORE being
115
+ written, to a temp name replaced atomically, so a failed or partial
116
+ download can never poison the cache.
117
+ """
118
+ from g3dt import config
119
+
120
+ if schema_dir is None:
121
+ schema_dir = Path(
122
+ os.environ.get("G3DT_SCHEMA_DIR", "~/.g3dt/schemas")
123
+ ).expanduser()
124
+ target = schema_dir / config.dictionary_filename(env_cfg, version)
125
+
126
+ if target.exists() and target.stat().st_size > 0:
127
+ try:
128
+ json.loads(target.read_text(encoding="utf-8"))
129
+ return target
130
+ except ValueError:
131
+ logger.warning("Cached dictionary %s is corrupt; re-downloading.", target)
132
+
133
+ url = config.dictionary_url(env_cfg, version)
134
+ logger.info("Downloading dictionary %s from %s", version, url)
135
+ try:
136
+ with urllib.request.urlopen(url) as resp:
137
+ body = resp.read()
138
+ except (urllib.error.HTTPError, urllib.error.URLError) as exc:
139
+ raise ImportOrderError(
140
+ f"Could not download dictionary tag '{version}' from {url}: {exc}. "
141
+ f"Check the tag exists in the schema repo, or pass --import-order."
142
+ )
143
+ try:
144
+ json.loads(body.decode("utf-8"))
145
+ except ValueError:
146
+ raise ImportOrderError(
147
+ f"Downloaded dictionary tag '{version}' from {url} is not valid "
148
+ f"JSON — is the tag/path right?"
149
+ )
150
+ schema_dir.mkdir(parents=True, exist_ok=True)
151
+ tmp = target.with_name(target.name + ".part")
152
+ tmp.write_bytes(body)
153
+ os.replace(tmp, target)
154
+ return target
155
+
156
+
157
+ def to_deletion_order(nodes: Sequence[str], exclude_nodes: Sequence[str]) -> List[str]:
158
+ """Filter excluded nodes out of a submission order and reverse it."""
159
+ kept = [n for n in nodes if n not in exclude_nodes]
160
+ kept.reverse()
161
+ return kept
162
+
163
+
164
+ def resolve_import_order(
165
+ *,
166
+ env_cfg,
167
+ session,
168
+ import_order: Optional[str] = None,
169
+ dict_version: Optional[str] = None,
170
+ study_cfg=None,
171
+ cwd: Optional[Path] = None,
172
+ ) -> Tuple[List[str], str]:
173
+ """Resolve the node SUBMISSION order for a delete, returning (nodes, source).
174
+
175
+ The chain (first hit wins): explicit ``import_order`` (fatal on failure —
176
+ an explicit source is never silently skipped), the registered study's
177
+ release bucket (``study_cfg.s3_metadata_path``; a listing error warns and
178
+ falls through), a ``DataImportOrder.txt`` in the current directory
179
+ (legacy behavior), then derivation from the dictionary — the
180
+ ``dict_version`` bundle when given, else the deployed dictionary at
181
+ ``s3://{env_cfg.schema_s3_uri}``.
182
+
183
+ ``source`` is a human-readable description of the winning step; workers
184
+ log it so operators can always see what ordered a destructive run.
185
+ """
186
+ from g3dt.upload.metadata_submitter import (
187
+ find_data_import_order_file_s3,
188
+ read_data_import_order_txt_s3,
189
+ read_metadata_json_s3,
190
+ )
191
+
192
+ # 1. Explicit flag: local path or s3:// URI. Failures are fatal.
193
+ if import_order:
194
+ if import_order.startswith("s3://"):
195
+ try:
196
+ nodes = read_data_import_order_txt_s3(import_order, session)
197
+ except Exception as exc:
198
+ raise ImportOrderError(
199
+ f"Could not read --import-order {import_order}: {exc}"
200
+ )
201
+ return nodes, f"explicit --import-order {import_order}"
202
+ path = Path(import_order)
203
+ try:
204
+ lines = path.read_text(encoding="utf-8").splitlines()
205
+ except OSError as exc:
206
+ raise ImportOrderError(
207
+ f"Could not read --import-order {import_order}: {exc}"
208
+ )
209
+ return (
210
+ [line.strip() for line in lines if line.strip()],
211
+ f"explicit --import-order {path}",
212
+ )
213
+
214
+ # 2. Registered study: the release ships its own DataImportOrder.txt.
215
+ if study_cfg is not None and str(
216
+ getattr(study_cfg, "s3_metadata_path", "")
217
+ ).startswith("s3://"):
218
+ try:
219
+ uri = find_data_import_order_file_s3(
220
+ s3_uri=study_cfg.s3_metadata_path, session=session
221
+ )
222
+ return (
223
+ read_data_import_order_txt_s3(uri, session),
224
+ f"release bucket {uri}",
225
+ )
226
+ except Exception as exc:
227
+ logger.warning(
228
+ "No usable DataImportOrder.txt under %s (%s) — falling back.",
229
+ study_cfg.s3_metadata_path,
230
+ exc,
231
+ )
232
+
233
+ # 3. Legacy: a DataImportOrder.txt in the working directory.
234
+ local = (cwd or Path.cwd()) / _ORDER_FILENAME
235
+ if local.exists():
236
+ lines = local.read_text(encoding="utf-8").splitlines()
237
+ return (
238
+ [line.strip() for line in lines if line.strip()],
239
+ f"{_ORDER_FILENAME} in current directory ({local.resolve()})",
240
+ )
241
+
242
+ # 4. Derive from the dictionary.
243
+ if dict_version:
244
+ bundle_path = ensure_local_dictionary(env_cfg, dict_version)
245
+ schema = json.loads(bundle_path.read_text(encoding="utf-8"))
246
+ source = f"derived from dictionary {dict_version} ({bundle_path})"
247
+ else:
248
+ uri = f"s3://{env_cfg.schema_s3_uri}"
249
+ try:
250
+ schema = read_metadata_json_s3(uri, session)
251
+ except Exception as exc:
252
+ raise ImportOrderError(
253
+ f"No DataImportOrder.txt found and the deployed dictionary at "
254
+ f"{uri} (SSM app/schema_s3_uri) could not be read: {exc}. "
255
+ f"Pass --import-order <path|s3://...> or --dict-version <tag>."
256
+ )
257
+ source = f"derived from deployed dictionary {uri}"
258
+
259
+ nodes = derive_import_order(schema)
260
+ if not nodes:
261
+ raise ImportOrderError(
262
+ f"Deriving the node order produced no submittable nodes — is the "
263
+ f"object read for '{source}' a Gen3 dictionary bundle?"
264
+ )
265
+ return nodes, source