gen3-dataops-toolkit 4.2.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.2.0 → gen3_dataops_toolkit-4.3.0}/PKG-INFO +15 -1
  2. {gen3_dataops_toolkit-4.2.0 → gen3_dataops_toolkit-4.3.0}/README.md +14 -0
  3. {gen3_dataops_toolkit-4.2.0 → gen3_dataops_toolkit-4.3.0}/pyproject.toml +1 -1
  4. {gen3_dataops_toolkit-4.2.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/cli/delete_cmds.py +50 -0
  5. gen3_dataops_toolkit-4.3.0/src/g3dt/import_order.py +265 -0
  6. {gen3_dataops_toolkit-4.2.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/services/delete/delete_all_metadata_for_project.py +41 -19
  7. {gen3_dataops_toolkit-4.2.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/services/delete/delete_metadata.sh +18 -3
  8. {gen3_dataops_toolkit-4.2.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/services/delete/delete_metadata_by_guid.py +40 -19
  9. {gen3_dataops_toolkit-4.2.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/services/delete/delete_synth_metadata_by_version.py +40 -19
  10. {gen3_dataops_toolkit-4.2.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/utils/athena_utils.py +0 -9
  11. {gen3_dataops_toolkit-4.2.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/__init__.py +0 -0
  12. {gen3_dataops_toolkit-4.2.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/cli/__init__.py +0 -0
  13. {gen3_dataops_toolkit-4.2.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/cli/_internal/__init__.py +0 -0
  14. {gen3_dataops_toolkit-4.2.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/cli/_internal/aws_quiet.py +0 -0
  15. {gen3_dataops_toolkit-4.2.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/cli/_internal/dispatch.py +0 -0
  16. {gen3_dataops_toolkit-4.2.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/cli/_internal/helptext.py +0 -0
  17. {gen3_dataops_toolkit-4.2.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/cli/_internal/registry.py +0 -0
  18. {gen3_dataops_toolkit-4.2.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/cli/_internal/resolve.py +0 -0
  19. {gen3_dataops_toolkit-4.2.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/cli/_internal/runner.py +0 -0
  20. {gen3_dataops_toolkit-4.2.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/cli/_internal/safety.py +0 -0
  21. {gen3_dataops_toolkit-4.2.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/cli/config_cmds.py +0 -0
  22. {gen3_dataops_toolkit-4.2.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/cli/dict_cmds.py +0 -0
  23. {gen3_dataops_toolkit-4.2.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/cli/ec2_cmds.py +0 -0
  24. {gen3_dataops_toolkit-4.2.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/cli/indexd_cmds.py +0 -0
  25. {gen3_dataops_toolkit-4.2.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/cli/jobs.py +0 -0
  26. {gen3_dataops_toolkit-4.2.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/cli/k8s.py +0 -0
  27. {gen3_dataops_toolkit-4.2.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/cli/main.py +0 -0
  28. {gen3_dataops_toolkit-4.2.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/cli/metadata.py +0 -0
  29. {gen3_dataops_toolkit-4.2.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/cli/pipeline_cmds.py +0 -0
  30. {gen3_dataops_toolkit-4.2.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/cli/release_cmds.py +0 -0
  31. {gen3_dataops_toolkit-4.2.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/cli/study_cmds.py +0 -0
  32. {gen3_dataops_toolkit-4.2.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/cli/synth.py +0 -0
  33. {gen3_dataops_toolkit-4.2.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/config.py +0 -0
  34. {gen3_dataops_toolkit-4.2.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/contexts.py +0 -0
  35. {gen3_dataops_toolkit-4.2.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/indexd/__init__.py +0 -0
  36. {gen3_dataops_toolkit-4.2.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/indexd/file_access.py +0 -0
  37. {gen3_dataops_toolkit-4.2.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/indexd/indexd_registrar.py +0 -0
  38. {gen3_dataops_toolkit-4.2.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/ingest/ingest.py +0 -0
  39. {gen3_dataops_toolkit-4.2.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/resolver.py +0 -0
  40. {gen3_dataops_toolkit-4.2.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/services/dictionary/deploy_dd.sh +0 -0
  41. {gen3_dataops_toolkit-4.2.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/services/dictionary/pull_dict.sh +0 -0
  42. {gen3_dataops_toolkit-4.2.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/services/dictionary/upload_dictionary.py +0 -0
  43. {gen3_dataops_toolkit-4.2.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/services/indexd/register_indexd.py +0 -0
  44. {gen3_dataops_toolkit-4.2.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/services/indexd/verify_file_access.py +0 -0
  45. {gen3_dataops_toolkit-4.2.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/services/k8s_ops/argocd_restart_etl.sh +0 -0
  46. {gen3_dataops_toolkit-4.2.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/services/k8s_ops/argocd_restart_ms.sh +0 -0
  47. {gen3_dataops_toolkit-4.2.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/services/k8s_ops/argocd_restart_schema.sh +0 -0
  48. {gen3_dataops_toolkit-4.2.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/services/k8s_ops/login_to_pod.sh +0 -0
  49. {gen3_dataops_toolkit-4.2.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/services/k8s_ops/restart_etl_and_ms.sh +0 -0
  50. {gen3_dataops_toolkit-4.2.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/services/synthetic_data/delete_synth_metadata_sheepdog.py +0 -0
  51. {gen3_dataops_toolkit-4.2.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/services/synthetic_data/full_deploy_dd_and_synth.sh +0 -0
  52. {gen3_dataops_toolkit-4.2.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/services/synthetic_data/generate_synth_metadata.sh +0 -0
  53. {gen3_dataops_toolkit-4.2.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/services/synthetic_data/upload_synth_metadata_sheepdog.py +0 -0
  54. {gen3_dataops_toolkit-4.2.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/services/upload/metadata/upload_all_studies.sh +0 -0
  55. {gen3_dataops_toolkit-4.2.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/services/upload/metadata/upload_metadata.py +0 -0
  56. {gen3_dataops_toolkit-4.2.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/studies.py +0 -0
  57. {gen3_dataops_toolkit-4.2.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/upload/__init__.py +0 -0
  58. {gen3_dataops_toolkit-4.2.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/upload/metadata_deleter.py +0 -0
  59. {gen3_dataops_toolkit-4.2.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/upload/metadata_submitter.py +0 -0
  60. {gen3_dataops_toolkit-4.2.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/upload/upload_synthdata_s3.py +0 -0
  61. {gen3_dataops_toolkit-4.2.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/utils/dbt_utils.py +0 -0
  62. {gen3_dataops_toolkit-4.2.0 → gen3_dataops_toolkit-4.3.0}/src/g3dt/utils/release_writer.py +0 -0
  63. {gen3_dataops_toolkit-4.2.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.2.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.2.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"
@@ -14,6 +14,12 @@ synthetic projects are never registered), bare names default to version
14
14
  ``data_version`` property via GraphQL rather than Athena receipts (synthetic
15
15
  uploads write none).
16
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
+
17
23
  Every command confirms before acting. Production always requires typing the
18
24
  target id, even with ``--yes``. Deleting ALL versions always prompts, even with
19
25
  ``--yes``. Confirmation happens locally before any EC2 dispatch (SSM has no
@@ -186,6 +192,21 @@ def metadata(
186
192
  help="Gen3 program for --synthetic (default: program1). "
187
193
  "Invalid without --synthetic.",
188
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
+ ),
189
210
  yes: bool = typer.Option(
190
211
  False, "--yes", "-y", help="Skip the non-prod prompt (specific-version only)."
191
212
  ),
@@ -212,6 +233,26 @@ def metadata(
212
233
  err=True,
213
234
  )
214
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)
215
256
  if version is None:
216
257
  fallback = None
217
258
  elif synthetic:
@@ -266,6 +307,10 @@ def metadata(
266
307
  # optional on the wire, but an explicit value keeps the contract
267
308
  # visible in logs and SSM command history.
268
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]
269
314
  return a
270
315
 
271
316
  def remote_cli(env_name):
@@ -284,6 +329,11 @@ def metadata(
284
329
  a.append("--synthetic")
285
330
  if program_id is not None:
286
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]
287
337
  return a
288
338
 
289
339
  dispatch.run_or_dispatch(
@@ -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
@@ -10,6 +10,11 @@ from g3dt.upload.metadata_submitter import (
10
10
  from g3dt.upload.metadata_deleter import (
11
11
  delete_project_metadata,
12
12
  )
13
+ from g3dt.import_order import (
14
+ ImportOrderError,
15
+ resolve_import_order,
16
+ to_deletion_order,
17
+ )
13
18
 
14
19
  # ANSI colour codes
15
20
  GREEN = "\033[92m"
@@ -43,20 +48,6 @@ def setup_logger():
43
48
  from g3dt import config as g3dt_config # noqa: E402
44
49
 
45
50
 
46
- def load_import_order(import_order_path, exclude_nodes=None):
47
- """
48
- Reads the DataImportOrder.txt file and returns the node list
49
- in deletion order (reversed, with excluded nodes removed).
50
- """
51
- if exclude_nodes is None:
52
- exclude_nodes = EXCLUDE_NODES
53
- with open(import_order_path, 'r', encoding='utf-8') as f:
54
- nodes = [line.strip() for line in f if line.strip()]
55
- nodes = [n for n in nodes if n not in exclude_nodes]
56
- nodes.reverse()
57
- return nodes
58
-
59
-
60
51
  def main():
61
52
  logger = setup_logger()
62
53
 
@@ -98,8 +89,21 @@ def main():
98
89
  )
99
90
  parser.add_argument(
100
91
  "--import-order",
101
- default="DataImportOrder.txt",
102
- help="Path to DataImportOrder.txt",
92
+ default=None,
93
+ help=(
94
+ "Path or s3:// URI of DataImportOrder.txt. Default: auto "
95
+ "(the study's release bucket, then ./DataImportOrder.txt, "
96
+ "then derived from the dictionary)."
97
+ ),
98
+ )
99
+ parser.add_argument(
100
+ "--dict-version",
101
+ default=None,
102
+ help=(
103
+ "Dictionary git tag to derive the node order from (verbatim, "
104
+ "e.g. v1.3.0). Default: the env's deployed dictionary. Only "
105
+ "used when the order is derived."
106
+ ),
103
107
  )
104
108
  parser.add_argument(
105
109
  "--node",
@@ -119,6 +123,12 @@ def main():
119
123
 
120
124
  args = parser.parse_args()
121
125
 
126
+ if args.import_order and args.dict_version:
127
+ parser.error(
128
+ "--import-order names the exact file; --dict-version derives one "
129
+ "— pass only one."
130
+ )
131
+
122
132
  # Env facts from SSM; the study registry from the marker or
123
133
  # SSM /{project}/{env}/studies/* (legacy studies.yaml fallback until 5.0).
124
134
  # Synthetic projects are not registered studies: --synthetic skips the
@@ -126,6 +136,7 @@ def main():
126
136
  try:
127
137
  env_cfg = g3dt_config.resolve_env(args.env)
128
138
  if args.synthetic:
139
+ study_cfg = None
129
140
  project_id = args.study
130
141
  program_id = args.program_id
131
142
  else:
@@ -162,12 +173,23 @@ def main():
162
173
  BLUE, RESET, args.node,
163
174
  )
164
175
  else:
165
- nodes_to_delete = load_import_order(args.import_order)
176
+ try:
177
+ nodes, source = resolve_import_order(
178
+ env_cfg=env_cfg,
179
+ session=session,
180
+ import_order=args.import_order,
181
+ dict_version=args.dict_version,
182
+ study_cfg=study_cfg,
183
+ )
184
+ except ImportOrderError as exc:
185
+ logger.error(str(exc))
186
+ sys.exit(1)
187
+ nodes_to_delete = to_deletion_order(nodes, EXCLUDE_NODES)
166
188
  logger.info(
167
- "Loaded %s nodes from %s (deletion order, "
189
+ "Import order source: %s (%s nodes, deletion order, "
168
190
  "excluding %s)",
191
+ source,
169
192
  len(nodes_to_delete),
170
- args.import_order,
171
193
  EXCLUDE_NODES,
172
194
  )
173
195
 
@@ -24,6 +24,11 @@ Arguments:
24
24
  --synthetic (optional) Registry-free synthetic-data mode: each study name is
25
25
  the Gen3 project code itself (no SSM study registry lookup)
26
26
  --program-id (optional) Gen3 program for --synthetic mode (default program1)
27
+ --import-order (optional) Path or s3:// URI of DataImportOrder.txt. Default:
28
+ auto — the study's release bucket (registered studies), then
29
+ ./DataImportOrder.txt, then derived from the dictionary
30
+ --dict-version (optional) Dictionary git tag to derive the node order from
31
+ (default: the env's deployed dictionary)
27
32
 
28
33
  Behaviour:
29
34
  * version 'all' -> delete_all_metadata_for_project.py (deletes whole nodes)
@@ -52,6 +57,8 @@ VERSION=""
52
57
  NODE=""
53
58
  SYNTHETIC=0
54
59
  PROGRAM_ID="program1"
60
+ IMPORT_ORDER=""
61
+ DICT_VERSION=""
55
62
 
56
63
  while [[ $# -gt 0 ]]; do
57
64
  case "$1" in
@@ -79,6 +86,14 @@ while [[ $# -gt 0 ]]; do
79
86
  PROGRAM_ID="$2"
80
87
  shift 2
81
88
  ;;
89
+ --import-order)
90
+ IMPORT_ORDER="$2"
91
+ shift 2
92
+ ;;
93
+ --dict-version)
94
+ DICT_VERSION="$2"
95
+ shift 2
96
+ ;;
82
97
  *)
83
98
  echo "ERROR: Unknown argument: $1"
84
99
  usage
@@ -157,17 +172,17 @@ for i in "${!STUDY_NAMES[@]}"; do
157
172
  CMD=("${G3DT_PYTHON:-python3}" "${SCRIPT_DIR}/delete_all_metadata_for_project.py"
158
173
  --study "$study" --env "$ENV")
159
174
  [[ $SYNTHETIC -eq 1 ]] && CMD+=(--synthetic --program-id "$PROGRAM_ID")
160
- [[ -n "$NODE" ]] && CMD+=(--node "$NODE")
161
175
  elif [[ $SYNTHETIC -eq 1 ]]; then
162
176
  CMD=("${G3DT_PYTHON:-python3}" "${SCRIPT_DIR}/delete_synth_metadata_by_version.py"
163
177
  --study "$study" --env "$ENV" --version "$study_version"
164
178
  --program-id "$PROGRAM_ID" --skip-if-empty)
165
- [[ -n "$NODE" ]] && CMD+=(--node "$NODE")
166
179
  else
167
180
  CMD=("${G3DT_PYTHON:-python3}" "${SCRIPT_DIR}/delete_metadata_by_guid.py"
168
181
  --study "$study" --env "$ENV" --version "$study_version" --skip-if-empty)
169
- [[ -n "$NODE" ]] && CMD+=(--node "$NODE")
170
182
  fi
183
+ [[ -n "$NODE" ]] && CMD+=(--node "$NODE")
184
+ [[ -n "$IMPORT_ORDER" ]] && CMD+=(--import-order "$IMPORT_ORDER")
185
+ [[ -n "$DICT_VERSION" ]] && CMD+=(--dict-version "$DICT_VERSION")
171
186
 
172
187
  # Run the worker without aborting the loop on a non-zero exit.
173
188
  set +e
@@ -13,6 +13,11 @@ from g3dt.upload.metadata_deleter import (
13
13
  query_metadata_upload_guids,
14
14
  delete_records_by_guid,
15
15
  )
16
+ from g3dt.import_order import (
17
+ ImportOrderError,
18
+ resolve_import_order,
19
+ to_deletion_order,
20
+ )
16
21
 
17
22
  # ANSI colour codes (matching metadata_submitter.py style)
18
23
  GREEN = "\033[92m"
@@ -51,20 +56,6 @@ def setup_logger():
51
56
  from g3dt import config as g3dt_config, resolver # noqa: E402
52
57
 
53
58
 
54
- def load_import_order(import_order_path, exclude_nodes=None):
55
- """
56
- Reads the DataImportOrder.txt file and returns the node list
57
- in deletion order (reversed, with excluded nodes removed).
58
- """
59
- if exclude_nodes is None:
60
- exclude_nodes = EXCLUDE_NODES
61
- with open(import_order_path, 'r', encoding='utf-8') as f:
62
- nodes = [line.strip() for line in f if line.strip()]
63
- nodes = [n for n in nodes if n not in exclude_nodes]
64
- nodes.reverse()
65
- return nodes
66
-
67
-
68
59
  def main():
69
60
  logger = setup_logger()
70
61
 
@@ -96,8 +87,21 @@ def main():
96
87
  )
97
88
  parser.add_argument(
98
89
  "--import-order",
99
- default="DataImportOrder.txt",
100
- help="Path to DataImportOrder.txt",
90
+ default=None,
91
+ help=(
92
+ "Path or s3:// URI of DataImportOrder.txt. Default: auto "
93
+ "(the study's release bucket, then ./DataImportOrder.txt, "
94
+ "then derived from the dictionary)."
95
+ ),
96
+ )
97
+ parser.add_argument(
98
+ "--dict-version",
99
+ default=None,
100
+ help=(
101
+ "Dictionary git tag to derive the node order from (verbatim, "
102
+ "e.g. v1.3.0). Default: the env's deployed dictionary. Only "
103
+ "used when the order is derived."
104
+ ),
101
105
  )
102
106
  parser.add_argument(
103
107
  "--node",
@@ -151,6 +155,12 @@ def main():
151
155
 
152
156
  args = parser.parse_args()
153
157
 
158
+ if args.import_order and args.dict_version:
159
+ parser.error(
160
+ "--import-order names the exact file; --dict-version derives one "
161
+ "— pass only one."
162
+ )
163
+
154
164
  if args.verbose:
155
165
  logger.setLevel(logging.DEBUG)
156
166
 
@@ -225,12 +235,23 @@ def main():
225
235
  BLUE, RESET, args.node,
226
236
  )
227
237
  else:
228
- nodes_to_delete = load_import_order(args.import_order)
238
+ try:
239
+ nodes, source = resolve_import_order(
240
+ env_cfg=env_cfg,
241
+ session=session,
242
+ import_order=args.import_order,
243
+ dict_version=args.dict_version,
244
+ study_cfg=study_cfg,
245
+ )
246
+ except ImportOrderError as exc:
247
+ logger.error(str(exc))
248
+ sys.exit(1)
249
+ nodes_to_delete = to_deletion_order(nodes, EXCLUDE_NODES)
229
250
  logger.info(
230
- "Loaded %s nodes from %s (deletion order, "
251
+ "Import order source: %s (%s nodes, deletion order, "
231
252
  "excluding %s)",
253
+ source,
232
254
  len(nodes_to_delete),
233
- args.import_order,
234
255
  EXCLUDE_NODES,
235
256
  )
236
257
 
@@ -26,6 +26,11 @@ from g3dt.upload.metadata_submitter import (
26
26
  create_gen3_submission_class,
27
27
  )
28
28
  from g3dt.upload.metadata_deleter import delete_node_records_by_property
29
+ from g3dt.import_order import (
30
+ ImportOrderError,
31
+ resolve_import_order,
32
+ to_deletion_order,
33
+ )
29
34
 
30
35
  # ANSI colour codes (matching metadata_submitter.py style)
31
36
  GREEN = "\033[92m"
@@ -64,20 +69,6 @@ def setup_logger():
64
69
  from g3dt import config as g3dt_config # noqa: E402
65
70
 
66
71
 
67
- def load_import_order(import_order_path, exclude_nodes=None):
68
- """
69
- Reads the DataImportOrder.txt file and returns the node list
70
- in deletion order (reversed, with excluded nodes removed).
71
- """
72
- if exclude_nodes is None:
73
- exclude_nodes = EXCLUDE_NODES
74
- with open(import_order_path, 'r', encoding='utf-8') as f:
75
- nodes = [line.strip() for line in f if line.strip()]
76
- nodes = [n for n in nodes if n not in exclude_nodes]
77
- nodes.reverse()
78
- return nodes
79
-
80
-
81
72
  def main():
82
73
  logger = setup_logger()
83
74
 
@@ -116,8 +107,21 @@ def main():
116
107
  )
117
108
  parser.add_argument(
118
109
  "--import-order",
119
- default="DataImportOrder.txt",
120
- help="Path to DataImportOrder.txt",
110
+ default=None,
111
+ help=(
112
+ "Path or s3:// URI of DataImportOrder.txt. Default: auto "
113
+ "(./DataImportOrder.txt, then derived from the dictionary — "
114
+ "synthetic projects have no release bucket)."
115
+ ),
116
+ )
117
+ parser.add_argument(
118
+ "--dict-version",
119
+ default=None,
120
+ help=(
121
+ "Dictionary git tag to derive the node order from (verbatim, "
122
+ "e.g. v1.3.0). Default: the env's deployed dictionary. Only "
123
+ "used when the order is derived."
124
+ ),
121
125
  )
122
126
  parser.add_argument(
123
127
  "--node",
@@ -171,6 +175,12 @@ def main():
171
175
 
172
176
  args = parser.parse_args()
173
177
 
178
+ if args.import_order and args.dict_version:
179
+ parser.error(
180
+ "--import-order names the exact file; --dict-version derives one "
181
+ "— pass only one."
182
+ )
183
+
174
184
  if args.verbose:
175
185
  logger.setLevel(logging.DEBUG)
176
186
 
@@ -215,12 +225,23 @@ def main():
215
225
  BLUE, RESET, args.node,
216
226
  )
217
227
  else:
218
- nodes_to_delete = load_import_order(args.import_order)
228
+ try:
229
+ nodes, source = resolve_import_order(
230
+ env_cfg=env_cfg,
231
+ session=session,
232
+ import_order=args.import_order,
233
+ dict_version=args.dict_version,
234
+ study_cfg=None, # synthetic projects are never registered
235
+ )
236
+ except ImportOrderError as exc:
237
+ logger.error(str(exc))
238
+ sys.exit(1)
239
+ nodes_to_delete = to_deletion_order(nodes, EXCLUDE_NODES)
219
240
  logger.info(
220
- "Loaded %s nodes from %s (deletion order, "
241
+ "Import order source: %s (%s nodes, deletion order, "
221
242
  "excluding %s)",
243
+ source,
222
244
  len(nodes_to_delete),
223
- args.import_order,
224
245
  EXCLUDE_NODES,
225
246
  )
226
247
 
@@ -13,7 +13,6 @@ import pytz
13
13
  import base64
14
14
  import numpy as np
15
15
  from decimal import Decimal
16
- from gen3_validator.dict import DataDictionary
17
16
 
18
17
  logger = logging.getLogger(__name__)
19
18
 
@@ -916,14 +915,6 @@ def write_gold_json_to_s3(
916
915
  logger.info(f"Object created at s3://{s3_bucket}/{s3_object_key}")
917
916
 
918
917
 
919
- def construct_data_import_order(s3_uri) -> list:
920
- from g3dt.validate.validate import load_schema_from_s3_uri
921
- schema_dict = load_schema_from_s3_uri(s3_uri)
922
- dd = DataDictionary(schema_dict)
923
- dd.schema = schema_dict
924
- dd.calculate_node_order()
925
- return dd.node_order
926
-
927
918
  def write_release_jsons_to_s3(s3_bucket, release_id, study_id, table_name, json_data,
928
919
  s3_client=None, key_prefix="release_jsons"):
929
920
  """