gen3-dataops-toolkit 4.1.0__tar.gz → 4.2.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 (62) hide show
  1. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.2.0}/PKG-INFO +1 -1
  2. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.2.0}/pyproject.toml +2 -2
  3. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.2.0}/src/g3dt/cli/delete_cmds.py +83 -11
  4. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.2.0}/src/g3dt/cli/synth.py +9 -0
  5. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.2.0}/src/g3dt/config.py +74 -1
  6. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.2.0}/src/g3dt/services/delete/delete_all_metadata_for_project.py +24 -4
  7. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.2.0}/src/g3dt/services/delete/delete_metadata.sh +29 -7
  8. gen3_dataops_toolkit-4.2.0/src/g3dt/services/delete/delete_synth_metadata_by_version.py +334 -0
  9. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.2.0}/src/g3dt/services/dictionary/upload_dictionary.py +13 -8
  10. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.2.0}/src/g3dt/services/synthetic_data/generate_synth_metadata.sh +8 -0
  11. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.2.0}/src/g3dt/upload/metadata_deleter.py +78 -0
  12. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.2.0}/README.md +0 -0
  13. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.2.0}/src/g3dt/__init__.py +0 -0
  14. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.2.0}/src/g3dt/cli/__init__.py +0 -0
  15. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.2.0}/src/g3dt/cli/_internal/__init__.py +0 -0
  16. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.2.0}/src/g3dt/cli/_internal/aws_quiet.py +0 -0
  17. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.2.0}/src/g3dt/cli/_internal/dispatch.py +0 -0
  18. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.2.0}/src/g3dt/cli/_internal/helptext.py +0 -0
  19. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.2.0}/src/g3dt/cli/_internal/registry.py +0 -0
  20. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.2.0}/src/g3dt/cli/_internal/resolve.py +0 -0
  21. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.2.0}/src/g3dt/cli/_internal/runner.py +0 -0
  22. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.2.0}/src/g3dt/cli/_internal/safety.py +0 -0
  23. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.2.0}/src/g3dt/cli/config_cmds.py +0 -0
  24. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.2.0}/src/g3dt/cli/dict_cmds.py +0 -0
  25. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.2.0}/src/g3dt/cli/ec2_cmds.py +0 -0
  26. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.2.0}/src/g3dt/cli/indexd_cmds.py +0 -0
  27. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.2.0}/src/g3dt/cli/jobs.py +0 -0
  28. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.2.0}/src/g3dt/cli/k8s.py +0 -0
  29. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.2.0}/src/g3dt/cli/main.py +0 -0
  30. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.2.0}/src/g3dt/cli/metadata.py +0 -0
  31. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.2.0}/src/g3dt/cli/pipeline_cmds.py +0 -0
  32. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.2.0}/src/g3dt/cli/release_cmds.py +0 -0
  33. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.2.0}/src/g3dt/cli/study_cmds.py +0 -0
  34. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.2.0}/src/g3dt/contexts.py +0 -0
  35. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.2.0}/src/g3dt/indexd/__init__.py +0 -0
  36. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.2.0}/src/g3dt/indexd/file_access.py +0 -0
  37. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.2.0}/src/g3dt/indexd/indexd_registrar.py +0 -0
  38. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.2.0}/src/g3dt/ingest/ingest.py +0 -0
  39. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.2.0}/src/g3dt/resolver.py +0 -0
  40. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.2.0}/src/g3dt/services/delete/delete_metadata_by_guid.py +0 -0
  41. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.2.0}/src/g3dt/services/dictionary/deploy_dd.sh +0 -0
  42. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.2.0}/src/g3dt/services/dictionary/pull_dict.sh +0 -0
  43. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.2.0}/src/g3dt/services/indexd/register_indexd.py +0 -0
  44. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.2.0}/src/g3dt/services/indexd/verify_file_access.py +0 -0
  45. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.2.0}/src/g3dt/services/k8s_ops/argocd_restart_etl.sh +0 -0
  46. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.2.0}/src/g3dt/services/k8s_ops/argocd_restart_ms.sh +0 -0
  47. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.2.0}/src/g3dt/services/k8s_ops/argocd_restart_schema.sh +0 -0
  48. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.2.0}/src/g3dt/services/k8s_ops/login_to_pod.sh +0 -0
  49. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.2.0}/src/g3dt/services/k8s_ops/restart_etl_and_ms.sh +0 -0
  50. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.2.0}/src/g3dt/services/synthetic_data/delete_synth_metadata_sheepdog.py +0 -0
  51. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.2.0}/src/g3dt/services/synthetic_data/full_deploy_dd_and_synth.sh +0 -0
  52. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.2.0}/src/g3dt/services/synthetic_data/upload_synth_metadata_sheepdog.py +0 -0
  53. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.2.0}/src/g3dt/services/upload/metadata/upload_all_studies.sh +0 -0
  54. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.2.0}/src/g3dt/services/upload/metadata/upload_metadata.py +0 -0
  55. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.2.0}/src/g3dt/studies.py +0 -0
  56. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.2.0}/src/g3dt/upload/__init__.py +0 -0
  57. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.2.0}/src/g3dt/upload/metadata_submitter.py +0 -0
  58. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.2.0}/src/g3dt/upload/upload_synthdata_s3.py +0 -0
  59. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.2.0}/src/g3dt/utils/athena_utils.py +0 -0
  60. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.2.0}/src/g3dt/utils/dbt_utils.py +0 -0
  61. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.2.0}/src/g3dt/utils/release_writer.py +0 -0
  62. {gen3_dataops_toolkit-4.1.0 → gen3_dataops_toolkit-4.2.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.2.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
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "gen3-dataops-toolkit"
3
- version = "4.1.0"
3
+ version = "4.2.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,13 @@ 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
+
10
17
  Every command confirms before acting. Production always requires typing the
11
18
  target id, even with ``--yes``. Deleting ALL versions always prompts, even with
12
19
  ``--yes``. Confirmation happens locally before any EC2 dispatch (SSM has no
@@ -61,12 +68,30 @@ def _normalise_version(raw: str, where: str) -> str:
61
68
  return match.group(1)
62
69
 
63
70
 
64
- def _parse_study_specs(studies: str, fallback, env: str):
71
+ def _synthetic_version(raw: str) -> str:
72
+ """Canonicalise a synthetic version token: ``all`` (any case) or verbatim.
73
+
74
+ Synthetic versions are matched exactly against the records' ``data_version``
75
+ property, and the natural label there is the dictionary version WITH its
76
+ leading ``v`` (batch dirs are ``~/.g3dt/synth_metadata/v1.3.0/...``) — so
77
+ unlike ``_normalise_version`` nothing is stripped. A version that matches
78
+ no records is reported by the worker (skip + hint), not silently absorbed.
79
+ """
80
+ token = raw.strip()
81
+ return "all" if token.lower() == "all" else token
82
+
83
+
84
+ def _parse_study_specs(studies: str, fallback, env: str, synthetic: bool = False):
65
85
  """Turn ``--studies`` into ``[(resolved_study_key, version), ...]``.
66
86
 
67
87
  Each comma-separated entry is ``name`` or ``name:version``. A bare name
68
88
  takes *fallback* (the ``--version`` default); *fallback* is ``None`` when
69
- ``--version`` was not given, which makes a bare name a usage error.
89
+ ``--version`` was not given, which makes a bare name a usage error —
90
+ except with *synthetic*, where a bare name defaults to ``all`` (the whole
91
+ point of the flag is "wipe the synthetic project").
92
+
93
+ With *synthetic* the raw name IS the Gen3 project code: no study-registry
94
+ lookup, and version tokens pass through :func:`_synthetic_version`.
70
95
 
71
96
  Every entry is validated before anything is dispatched, so a typo in the
72
97
  last study cannot leave the earlier ones already deleted.
@@ -102,9 +127,15 @@ def _parse_study_specs(studies: str, fallback, env: str):
102
127
  raise typer.Exit(2)
103
128
 
104
129
  if sep:
105
- version = _normalise_version(raw_version, f"for study '{name}'")
130
+ version = (
131
+ _synthetic_version(raw_version)
132
+ if synthetic
133
+ else _normalise_version(raw_version, f"for study '{name}'")
134
+ )
106
135
  elif fallback is not None:
107
136
  version = fallback
137
+ elif synthetic:
138
+ version = "all"
108
139
  else:
109
140
  typer.secho(
110
141
  f"No version for study '{name}': add ':<version>' to it "
@@ -115,7 +146,7 @@ def _parse_study_specs(studies: str, fallback, env: str):
115
146
  )
116
147
  raise typer.Exit(2)
117
148
 
118
- specs.append((study_of(name, env).key, version))
149
+ specs.append((name if synthetic else study_of(name, env).key, version))
119
150
 
120
151
  if not specs:
121
152
  typer.secho("--studies is empty.", fg=typer.colors.RED, err=True)
@@ -141,6 +172,20 @@ def metadata(
141
172
  "':version', e.g. 0.9.8, or 'all' for every version.",
142
173
  ),
143
174
  node: Optional[str] = typer.Option(None, "--node", help="Delete only this node type."),
175
+ synthetic: bool = typer.Option(
176
+ False,
177
+ "--synthetic",
178
+ help="Registry-free synthetic-data mode: each --studies name is the "
179
+ "Gen3 project id itself (no SSM study registry). Bare names "
180
+ "default to version 'all'; a specific version matches records' "
181
+ "data_version property verbatim.",
182
+ ),
183
+ program_id: Optional[str] = typer.Option(
184
+ None,
185
+ "--program-id",
186
+ help="Gen3 program for --synthetic (default: program1). "
187
+ "Invalid without --synthetic.",
188
+ ),
144
189
  yes: bool = typer.Option(
145
190
  False, "--yes", "-y", help="Skip the non-prod prompt (specific-version only)."
146
191
  ),
@@ -156,12 +201,24 @@ def metadata(
156
201
 
157
202
  g3dt delete metadata --studies "ausdiab:0.7.5,cdah:0.8.1" --env staging
158
203
  g3dt delete metadata --studies "ausdiab:all,cdah" --version 0.9.8 --env staging
204
+ g3dt delete metadata --studies "synthetic_dataset_1,synthetic_dataset_2" --env test --synthetic
159
205
  """
160
206
  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)
207
+ if program_id is not None and not synthetic:
208
+ typer.secho(
209
+ "--program-id is only valid with --synthetic (registered studies "
210
+ "carry their program in the study registry).",
211
+ fg=typer.colors.RED,
212
+ err=True,
213
+ )
214
+ raise typer.Exit(2)
215
+ if version is None:
216
+ fallback = None
217
+ elif synthetic:
218
+ fallback = _synthetic_version(version)
219
+ else:
220
+ fallback = _normalise_version(version, "for --version")
221
+ specs = _parse_study_specs(studies, fallback, env, synthetic=synthetic)
165
222
  versions = [v for _, v in specs]
166
223
 
167
224
  # The typed production confirmation stays the study keys alone: short
@@ -171,13 +228,17 @@ def metadata(
171
228
  uniform = len(set(versions)) == 1
172
229
  any_all = "all" in versions
173
230
 
231
+ prefix = "synthetic " if synthetic else ""
174
232
  if uniform and versions[0] == "all":
175
- action = "deletion of ALL VERSIONS"
233
+ action = f"{prefix}deletion of ALL VERSIONS"
176
234
  elif uniform:
177
- action = f"deletion of v{versions[0]}"
235
+ # Synthetic versions are verbatim data_version values (often already
236
+ # v-prefixed); Athena versions are canonical x.y.z, displayed with v.
237
+ shown = versions[0] if synthetic else f"v{versions[0]}"
238
+ action = f"{prefix}deletion of {shown}"
178
239
  else:
179
240
  plan = ", ".join(f"{key}:{v}" for key, v in specs)
180
- action = f"deletion of per-study versions [{plan}]"
241
+ action = f"{prefix}deletion of per-study versions [{plan}]"
181
242
 
182
243
  # Deleting every version is the most destructive path: always prompt (pass
183
244
  # assume_yes=False so --yes can't bypass it; prod still types the target).
@@ -200,6 +261,11 @@ def metadata(
200
261
  ]
201
262
  if node:
202
263
  a += ["--node", node]
264
+ if synthetic:
265
+ # Program is always passed explicitly: the shell default makes it
266
+ # optional on the wire, but an explicit value keeps the contract
267
+ # visible in logs and SSM command history.
268
+ a += ["--synthetic", "--program-id", program_id or "program1"]
203
269
  return a
204
270
 
205
271
  def remote_cli(env_name):
@@ -212,6 +278,12 @@ def metadata(
212
278
  a.append("--yes")
213
279
  if node:
214
280
  a += ["--node", node]
281
+ if synthetic:
282
+ # Without this the remote re-entry would re-parse --studies
283
+ # against the study registry on the box and exit 2.
284
+ a.append("--synthetic")
285
+ if program_id is not None:
286
+ a += ["--program-id", program_id]
215
287
  return a
216
288
 
217
289
  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"),
@@ -81,6 +81,21 @@ def main():
81
81
  required=True,
82
82
  help="Environment to use (selects AWS secret, profile, etc.)",
83
83
  )
84
+ parser.add_argument(
85
+ "--synthetic",
86
+ action="store_true",
87
+ default=False,
88
+ help=(
89
+ "Registry-free mode for synthetic data: --study is used directly "
90
+ "as the Gen3 project code (no SSM study registry lookup), with "
91
+ "the program from --program-id."
92
+ ),
93
+ )
94
+ parser.add_argument(
95
+ "--program-id",
96
+ default="program1",
97
+ help="Gen3 program for --synthetic mode.",
98
+ )
84
99
  parser.add_argument(
85
100
  "--import-order",
86
101
  default="DataImportOrder.txt",
@@ -106,16 +121,21 @@ def main():
106
121
 
107
122
  # Env facts from SSM; the study registry from the marker or
108
123
  # SSM /{project}/{env}/studies/* (legacy studies.yaml fallback until 5.0).
124
+ # Synthetic projects are not registered studies: --synthetic skips the
125
+ # registry and takes --study as the Gen3 project code itself.
109
126
  try:
110
127
  env_cfg = g3dt_config.resolve_env(args.env)
111
- study_cfg = g3dt_config.resolve_study(args.study, args.env)
128
+ if args.synthetic:
129
+ project_id = args.study
130
+ program_id = args.program_id
131
+ else:
132
+ study_cfg = g3dt_config.resolve_study(args.study, args.env)
133
+ project_id = study_cfg.project_id
134
+ program_id = study_cfg.program_id
112
135
  except g3dt_config.ConfigError as exc:
113
136
  logger.error(str(exc))
114
137
  sys.exit(1)
115
138
 
116
- project_id = study_cfg.project_id
117
- program_id = study_cfg.program_id
118
-
119
139
  aws_secret_name = env_cfg.aws_secret_name
120
140
  aws_profile = env_cfg.aws_profile
121
141
  aws_region = env_cfg.region
@@ -10,21 +10,26 @@ SKIP_EXIT_CODE=3
10
10
 
11
11
  usage() {
12
12
  cat <<EOF
13
- Usage: $(basename "$0") --studies <name[:version|all],...> --env <environment> [--version <version|all>] [--node <node>]
13
+ Usage: $(basename "$0") --studies <name[:version|all],...> --env <environment> [--version <version|all>] [--node <node>] [--synthetic] [--program-id <program>]
14
14
 
15
15
  Delete metadata for each study sequentially, in a single job.
16
16
 
17
17
  Arguments:
18
- --studies Comma-separated study config keys, each optionally qualified with
19
- its own version (e.g. ausdiab_staging:0.7.5,cdah_staging:0.8.1,
20
- or bare ausdiab_staging to take the --version default)
21
- --env Environment string passed to the Python worker (e.g. staging_ec2)
22
- --version Default version for bare --studies entries (e.g. 0.9.8), or 'all'
23
- --node (optional) Restrict deletion to a single node type
18
+ --studies Comma-separated study config keys, each optionally qualified with
19
+ its own version (e.g. ausdiab_staging:0.7.5,cdah_staging:0.8.1,
20
+ or bare ausdiab_staging to take the --version default)
21
+ --env Environment string passed to the Python worker (e.g. staging_ec2)
22
+ --version Default version for bare --studies entries (e.g. 0.9.8), or 'all'
23
+ --node (optional) Restrict deletion to a single node type
24
+ --synthetic (optional) Registry-free synthetic-data mode: each study name is
25
+ the Gen3 project code itself (no SSM study registry lookup)
26
+ --program-id (optional) Gen3 program for --synthetic mode (default program1)
24
27
 
25
28
  Behaviour:
26
29
  * version 'all' -> delete_all_metadata_for_project.py (deletes whole nodes)
27
30
  * version <x.y.z> -> delete_metadata_by_guid.py (Athena GUID lookup for that version)
31
+ * --synthetic + 'all' -> delete_all_metadata_for_project.py --synthetic
32
+ * --synthetic + version -> delete_synth_metadata_by_version.py (GraphQL data_version filter)
28
33
 
29
34
  A study that exists but has no data at the requested version is skipped and the
30
35
  loop continues. Only genuine errors (Gen3/AWS failures) count as failures.
@@ -45,6 +50,8 @@ STUDIES=""
45
50
  ENV=""
46
51
  VERSION=""
47
52
  NODE=""
53
+ SYNTHETIC=0
54
+ PROGRAM_ID="program1"
48
55
 
49
56
  while [[ $# -gt 0 ]]; do
50
57
  case "$1" in
@@ -64,6 +71,14 @@ while [[ $# -gt 0 ]]; do
64
71
  NODE="$2"
65
72
  shift 2
66
73
  ;;
74
+ --synthetic)
75
+ SYNTHETIC=1
76
+ shift
77
+ ;;
78
+ --program-id)
79
+ PROGRAM_ID="$2"
80
+ shift 2
81
+ ;;
67
82
  *)
68
83
  echo "ERROR: Unknown argument: $1"
69
84
  usage
@@ -122,6 +137,7 @@ echo "Environment : ${ENV}"
122
137
  echo "Studies : ${STUDIES}"
123
138
  echo "Version : ${VERSION:-(per study, from --studies)}"
124
139
  [[ -n "$NODE" ]] && echo "Node : ${NODE}"
140
+ [[ $SYNTHETIC -eq 1 ]] && echo "Synthetic : yes (program: ${PROGRAM_ID})"
125
141
  echo "Failure log : ${FAILED_LOG}"
126
142
  echo "============================================"
127
143
  echo ""
@@ -140,6 +156,12 @@ for i in "${!STUDY_NAMES[@]}"; do
140
156
  if [[ "$study_version_lc" == "all" ]]; then
141
157
  CMD=("${G3DT_PYTHON:-python3}" "${SCRIPT_DIR}/delete_all_metadata_for_project.py"
142
158
  --study "$study" --env "$ENV")
159
+ [[ $SYNTHETIC -eq 1 ]] && CMD+=(--synthetic --program-id "$PROGRAM_ID")
160
+ [[ -n "$NODE" ]] && CMD+=(--node "$NODE")
161
+ elif [[ $SYNTHETIC -eq 1 ]]; then
162
+ CMD=("${G3DT_PYTHON:-python3}" "${SCRIPT_DIR}/delete_synth_metadata_by_version.py"
163
+ --study "$study" --env "$ENV" --version "$study_version"
164
+ --program-id "$PROGRAM_ID" --skip-if-empty)
143
165
  [[ -n "$NODE" ]] && CMD+=(--node "$NODE")
144
166
  else
145
167
  CMD=("${G3DT_PYTHON:-python3}" "${SCRIPT_DIR}/delete_metadata_by_guid.py"
@@ -0,0 +1,334 @@
1
+ """Registry-free, version-filtered deletion for synthetic metadata.
2
+
3
+ Synthetic uploads write no Athena receipts (upload_synth_metadata_sheepdog
4
+ submits with upload_to_database=False), so delete_metadata_by_guid.py's
5
+ receipt lookup can never find them. This worker instead filters records
6
+ server-side: per node (reverse DataImportOrder), it queries sheepdog GraphQL
7
+ for records whose `data_version` property equals --version and deletes them.
8
+ The --study value is used directly as the Gen3 project code — no SSM study
9
+ registry involvement.
10
+
11
+ A node whose schema does not declare `data_version` makes the GraphQL query
12
+ error; that node is warned about and skipped. If nothing is deleted anywhere,
13
+ the run exits with the skip code (3, with --skip-if-empty) plus a hint, so the
14
+ bulk loop counts it as skipped rather than failed.
15
+ """
16
+ import sys
17
+ import time
18
+ import logging
19
+ import argparse
20
+
21
+ from gen3.submission import Gen3SubmissionQueryError
22
+
23
+ from g3dt.upload.metadata_submitter import (
24
+ create_boto3_session,
25
+ get_gen3_api_key_aws_secret,
26
+ create_gen3_submission_class,
27
+ )
28
+ from g3dt.upload.metadata_deleter import delete_node_records_by_property
29
+
30
+ # ANSI colour codes (matching metadata_submitter.py style)
31
+ GREEN = "\033[92m"
32
+ RED = "\033[91m"
33
+ YELLOW = "\033[93m"
34
+ BLUE = "\033[94m"
35
+ RESET = "\033[0m"
36
+
37
+ EXCLUDE_NODES = [
38
+ "program",
39
+ "project",
40
+ "acknowledgement",
41
+ "publication",
42
+ ]
43
+
44
+ # Exit code that signals "project exists but has no data at this version —
45
+ # skipped". The bulk caller (services/delete/delete_metadata.sh) treats this as
46
+ # a skip-and-continue rather than a failure. Only emitted with --skip-if-empty.
47
+ SKIP_EXIT_CODE = 3
48
+
49
+
50
+ def setup_logger():
51
+ logger = logging.getLogger()
52
+ logger.setLevel(logging.INFO)
53
+ if not logger.handlers:
54
+ handler = logging.StreamHandler(sys.stdout)
55
+ formatter = logging.Formatter(
56
+ '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
57
+ )
58
+ handler.setFormatter(formatter)
59
+ logger.addHandler(handler)
60
+ return logger
61
+
62
+
63
+ # Shared config resolution (SSM-backed) — see src/g3dt/config.py
64
+ from g3dt import config as g3dt_config # noqa: E402
65
+
66
+
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
+ def main():
82
+ logger = setup_logger()
83
+
84
+ parser = argparse.ArgumentParser(
85
+ description=(
86
+ "Delete synthetic Gen3 metadata records at one data version. "
87
+ "Filters each node (in reverse DataImportOrder) via sheepdog "
88
+ "GraphQL on the records' data_version property and deletes the "
89
+ "matches. --study is the Gen3 project code itself — no study "
90
+ "registry lookup."
91
+ ),
92
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
93
+ )
94
+ parser.add_argument(
95
+ "--study",
96
+ required=True,
97
+ help="Gen3 project code (e.g. synthetic_dataset_1)",
98
+ )
99
+ parser.add_argument(
100
+ "--env",
101
+ required=True,
102
+ help="Environment to use (selects AWS secret, profile, etc.)",
103
+ )
104
+ parser.add_argument(
105
+ "--version",
106
+ required=True,
107
+ help=(
108
+ "Value the records' data_version property must equal, matched "
109
+ "verbatim (e.g. v1.3.0)"
110
+ ),
111
+ )
112
+ parser.add_argument(
113
+ "--program-id",
114
+ default="program1",
115
+ help="Gen3 program the project lives under.",
116
+ )
117
+ parser.add_argument(
118
+ "--import-order",
119
+ default="DataImportOrder.txt",
120
+ help="Path to DataImportOrder.txt",
121
+ )
122
+ parser.add_argument(
123
+ "--node",
124
+ default=None,
125
+ help=(
126
+ "Delete only a specific node (e.g. 'subject'). "
127
+ "If omitted, all nodes are processed in reverse "
128
+ "DataImportOrder."
129
+ ),
130
+ )
131
+ parser.add_argument(
132
+ "--prompt",
133
+ action="store_true",
134
+ default=False,
135
+ help="Prompt for confirmation before deleting.",
136
+ )
137
+ parser.add_argument(
138
+ "--batch-size",
139
+ type=int,
140
+ default=40,
141
+ help="Number of UUIDs per DELETE request.",
142
+ )
143
+ parser.add_argument(
144
+ "--batch-delay",
145
+ type=float,
146
+ default=0.5,
147
+ help="Seconds to pause between batches.",
148
+ )
149
+ parser.add_argument(
150
+ "--verbose",
151
+ action="store_true",
152
+ default=False,
153
+ help="Log full API response JSON for each request.",
154
+ )
155
+ parser.add_argument(
156
+ "--delay",
157
+ type=float,
158
+ default=1.0,
159
+ help="Seconds to wait between nodes.",
160
+ )
161
+ parser.add_argument(
162
+ "--skip-if-empty",
163
+ action="store_true",
164
+ default=False,
165
+ help=(
166
+ "If no records match the given version, exit with the skip "
167
+ "code (3) instead of 0. Used by the bulk delete loop to "
168
+ "skip-and-continue rather than treat it as a failure."
169
+ ),
170
+ )
171
+
172
+ args = parser.parse_args()
173
+
174
+ if args.verbose:
175
+ logger.setLevel(logging.DEBUG)
176
+
177
+ # Env facts from SSM. Deliberately NO resolve_study: synthetic projects
178
+ # are not in the study registry — the --study value IS the project code.
179
+ try:
180
+ env_cfg = g3dt_config.resolve_env(args.env)
181
+ except g3dt_config.ConfigError as exc:
182
+ logger.error(str(exc))
183
+ sys.exit(1)
184
+
185
+ project_id = args.study
186
+ program_id = args.program_id
187
+
188
+ aws_secret_name = env_cfg.aws_secret_name
189
+ aws_profile = env_cfg.aws_profile
190
+ aws_region = env_cfg.region
191
+
192
+ compound_project_id = f"{program_id}-{project_id}"
193
+
194
+ logger.info(
195
+ "Synthetic | Env: %s | Project: %s | Version: %s",
196
+ args.env,
197
+ compound_project_id,
198
+ args.version,
199
+ )
200
+
201
+ # AWS and Gen3 authentication
202
+ session = create_boto3_session(aws_profile=aws_profile)
203
+ api_key = get_gen3_api_key_aws_secret(
204
+ secret_name=aws_secret_name,
205
+ region_name=aws_region,
206
+ session=session,
207
+ )
208
+ sub = create_gen3_submission_class(api_key)
209
+
210
+ # Determine node list
211
+ if args.node:
212
+ nodes_to_delete = [args.node]
213
+ logger.info(
214
+ "%s[SINGLE NODE]%s Targeting node: %s",
215
+ BLUE, RESET, args.node,
216
+ )
217
+ else:
218
+ nodes_to_delete = load_import_order(args.import_order)
219
+ logger.info(
220
+ "Loaded %s nodes from %s (deletion order, "
221
+ "excluding %s)",
222
+ len(nodes_to_delete),
223
+ args.import_order,
224
+ EXCLUDE_NODES,
225
+ )
226
+
227
+ if args.prompt:
228
+ confirm = input(
229
+ f"Proceed with deletion for project "
230
+ f"{compound_project_id}, data_version {args.version}, "
231
+ f"{len(nodes_to_delete)} node(s)? (yes/no): "
232
+ ).strip().lower()
233
+ if confirm != "yes":
234
+ logger.info("Deletion cancelled by user.")
235
+ return
236
+
237
+ # Process each node
238
+ total_deleted = 0
239
+ total_skipped = 0
240
+ failed_nodes = []
241
+ total_nodes = len(nodes_to_delete)
242
+
243
+ for idx, node in enumerate(nodes_to_delete, start=1):
244
+ logger.info(
245
+ "%s[Node %d/%d]%s | Project: %-10s | "
246
+ "Node: %-25s | Querying...",
247
+ BLUE, idx, total_nodes, RESET,
248
+ compound_project_id, node,
249
+ )
250
+
251
+ try:
252
+ deleted = delete_node_records_by_property(
253
+ gen3_submission=sub,
254
+ program_id=program_id,
255
+ project_id=project_id,
256
+ node=node,
257
+ property_name="data_version",
258
+ property_value=args.version,
259
+ batch_size=args.batch_size,
260
+ batch_delay=args.batch_delay,
261
+ verbose=args.verbose,
262
+ )
263
+ except Gen3SubmissionQueryError as exc:
264
+ logger.warning(
265
+ "%s[SKIP]%s | Node: %-25s | GraphQL rejected the "
266
+ "data_version filter (property not in this node's "
267
+ "schema?): %s",
268
+ YELLOW, RESET, node, exc,
269
+ )
270
+ total_skipped += 1
271
+ continue
272
+ except Exception as exc:
273
+ logger.error(
274
+ "%s[FAILED]%s | Node: %-25s | %s",
275
+ RED, RESET, node, exc,
276
+ )
277
+ failed_nodes.append(node)
278
+ continue
279
+
280
+ if deleted == 0:
281
+ logger.info(
282
+ "%s[SKIP]%s | Project: %-10s | "
283
+ "Node: %-25s | No records found",
284
+ YELLOW, RESET,
285
+ compound_project_id, node,
286
+ )
287
+ total_skipped += 1
288
+ continue
289
+
290
+ logger.info(
291
+ "%s[SUCCESS]%s | Project: %-10s | "
292
+ "Node: %-25s | Deleted: %s",
293
+ GREEN, RESET,
294
+ compound_project_id, node, deleted,
295
+ )
296
+ total_deleted += deleted
297
+
298
+ if idx < total_nodes:
299
+ time.sleep(args.delay)
300
+
301
+ logger.info(
302
+ "=========================================="
303
+ )
304
+ logger.info(
305
+ "Deletion complete. Total deleted: %s | "
306
+ "Nodes skipped: %s | Nodes failed: %s",
307
+ total_deleted,
308
+ total_skipped,
309
+ len(failed_nodes),
310
+ )
311
+
312
+ if failed_nodes:
313
+ logger.error(
314
+ "Failed nodes: %s", ", ".join(failed_nodes)
315
+ )
316
+ sys.exit(1)
317
+
318
+ # No records matched the requested version on any node. Usually the data
319
+ # was generated without the property (see `g3dt synth generate
320
+ # --data-version`), so surface the actionable hint rather than a silent
321
+ # "0 deleted".
322
+ if total_deleted == 0:
323
+ logger.warning(
324
+ "Data version '%s' not found for study '%s'. Ensure each data node "
325
+ "has a `data_version` property for versioning to work.",
326
+ args.version,
327
+ args.study,
328
+ )
329
+ if args.skip_if_empty:
330
+ sys.exit(SKIP_EXIT_CODE)
331
+
332
+
333
+ if __name__ == "__main__":
334
+ main()
@@ -4,6 +4,8 @@ import logging
4
4
  import sys
5
5
  from botocore.exceptions import ClientError
6
6
 
7
+ from g3dt.config import ConfigError, normalize_s3_location
8
+
7
9
  logging.basicConfig(
8
10
  level=logging.INFO,
9
11
  format="%(asctime)s %(levelname)s %(message)s",
@@ -49,23 +51,26 @@ def upload_dict_to_s3(dict_file_path, s3_target_uri, dict_version, profile_name=
49
51
 
50
52
  Args:
51
53
  dict_file_path (str): Local dictionary path.
52
- s3_target_uri (str): URI like s3://bucket/key.
54
+ s3_target_uri (str): Target like s3://bucket/key. Forgiving: bare
55
+ bucket/key, a doubled s3:// scheme, and S3 endpoint https URLs
56
+ are all accepted (see g3dt.config.normalize_s3_location).
53
57
  dict_version (str): Dictionary version string.
54
58
  profile_name (str, optional): AWS profile name.
55
59
 
56
60
  Returns:
57
61
  bool: True on success, False otherwise.
58
62
  """
59
- if not s3_target_uri.startswith("s3://"):
60
- logger.error(f"Invalid S3 URI: {s3_target_uri}")
63
+ try:
64
+ location = normalize_s3_location(s3_target_uri, param="s3_uri argument")
65
+ except ConfigError as e:
66
+ logger.error(str(e))
67
+ return False
68
+ if "/" not in location:
69
+ logger.error(f"S3 location missing an object key: {s3_target_uri}")
61
70
  return False
62
71
 
63
72
  try:
64
- s3_path = s3_target_uri[len("s3://") :]
65
- if "/" not in s3_path:
66
- logger.error(f"S3 URI missing key: {s3_target_uri}")
67
- return False
68
- bucket, key = s3_path.split("/", 1)
73
+ bucket, key = location.split("/", 1)
69
74
  # Set S3 metadata key to "version" instead of "dict_version"
70
75
  extra_args = {"Metadata": {"version": dict_version or "unknown"}}
71
76
  s3_client = get_s3_client(profile_name)
@@ -34,6 +34,9 @@ Options:
34
34
  'random' needs no key; 'llm' uses \$G3DT_LLM_PROVIDER /
35
35
  \$G3DT_LLM_MODEL / \$LLM_API_KEY_FILE (set by g3dt).
36
36
  --seed N RNG seed for reproducible output.
37
+ --data-version V Stamp every record's data_version property with V
38
+ (requires the dictionary to declare data_version;
39
+ enables versioned deletion later).
37
40
  --output-root DIR Root output dir. Default: ${DEFAULT_OUTPUT_ROOT}
38
41
  -h, --help Show this help and exit.
39
42
 
@@ -57,6 +60,7 @@ STUDIES="${DEFAULT_STUDIES}"
57
60
  NUM_RECORDS="${DEFAULT_NUM_RECORDS}"
58
61
  PROVIDER="${DEFAULT_PROVIDER}"
59
62
  SEED=""
63
+ DATA_VERSION=""
60
64
  OUTPUT_ROOT="${DEFAULT_OUTPUT_ROOT}"
61
65
 
62
66
  while [[ $# -gt 0 ]]; do
@@ -67,6 +71,7 @@ while [[ $# -gt 0 ]]; do
67
71
  --num-records) NUM_RECORDS="$2"; shift 2 ;;
68
72
  --provider) PROVIDER="$2"; shift 2 ;;
69
73
  --seed) SEED="$2"; shift 2 ;;
74
+ --data-version) DATA_VERSION="$2"; shift 2 ;;
70
75
  --output-root) OUTPUT_ROOT="$2"; shift 2 ;;
71
76
  -h|--help) usage; exit 0 ;;
72
77
  *) echo "Unknown argument: $1" >&2; usage; exit 1 ;;
@@ -122,6 +127,9 @@ for i in "${!STUDY_ARRAY[@]}"; do
122
127
  --num-records "$N"
123
128
  --provider "$PROVIDER")
124
129
  [[ -n "$SEED" ]] && CMD+=(--seed "$SEED")
130
+ # --set pins a declared data property to a constant on every record; the
131
+ # simulator errors before generating if no node declares data_version.
132
+ [[ -n "$DATA_VERSION" ]] && CMD+=(--set "data_version=${DATA_VERSION}")
125
133
  if [[ "$PROVIDER" == "llm" ]]; then
126
134
  # Vendor/model resolved by g3dt (flags > SSM > default) and forwarded
127
135
  # as simulator flags, because the simulator's own precedence puts
@@ -202,6 +202,84 @@ def delete_records_by_guid(
202
202
  )
203
203
 
204
204
 
205
+ def delete_node_records_by_property(
206
+ gen3_submission: Gen3Submission,
207
+ program_id: str,
208
+ project_id: str,
209
+ node: str,
210
+ property_name: str,
211
+ property_value: str,
212
+ page_size: int = 100,
213
+ batch_size: int = 40,
214
+ batch_delay: float = 0.5,
215
+ verbose: bool = False,
216
+ ) -> int:
217
+ """
218
+ Deletes every record of one node whose ``property_name`` equals
219
+ ``property_value``, via the sheepdog GraphQL endpoint — no Athena
220
+ receipts required.
221
+
222
+ Pages with query -> delete -> re-query until the query returns no
223
+ records (the same pattern the SDK's own delete_nodes uses), so a
224
+ partially failed batch is simply retried on the next round. If two
225
+ consecutive rounds return the same first id, no progress is being
226
+ made (e.g. every deletion is failing) and a RuntimeError is raised.
227
+
228
+ GraphQL errors from the query (e.g. the node's schema does not
229
+ declare the property) propagate to the caller as
230
+ Gen3SubmissionQueryError — per-node tolerance is the caller's
231
+ decision, not this function's.
232
+
233
+ Args:
234
+ gen3_submission (Gen3Submission): An authenticated
235
+ Gen3Submission instance.
236
+ program_id (str): The Gen3 program name.
237
+ project_id (str): The Gen3 project name (bare, not compound).
238
+ node (str): The node type to delete records from.
239
+ property_name (str): Node property to filter on
240
+ (e.g. "data_version").
241
+ property_value (str): Exact value the property must equal.
242
+ page_size (int, optional): Records fetched per query round.
243
+ batch_size (int, optional): Passed to delete_records_by_guid.
244
+ batch_delay (float, optional): Passed to delete_records_by_guid.
245
+ verbose (bool, optional): Passed to delete_records_by_guid.
246
+
247
+ Returns:
248
+ int: Number of distinct records deleted.
249
+ """
250
+ compound_project_id = f"{program_id}-{project_id}"
251
+ seen = set()
252
+ first_uuid = ""
253
+ while True:
254
+ query_string = (
255
+ f'{{ {node} (first: {page_size}, '
256
+ f'project_id: "{compound_project_id}", '
257
+ f'{property_name}: "{property_value}") {{ id }} }}'
258
+ )
259
+ res = gen3_submission.query(query_string)
260
+ uuids = [x["id"] for x in res["data"][node]]
261
+ if not uuids:
262
+ break
263
+ if first_uuid == uuids[0]:
264
+ raise RuntimeError(
265
+ f"No progress deleting '{node}' records with "
266
+ f"{property_name}='{property_value}' — record "
267
+ f"{uuids[0]} keeps reappearing (deletions failing?)."
268
+ )
269
+ first_uuid = uuids[0]
270
+ seen.update(uuids)
271
+ delete_records_by_guid(
272
+ gen3_submission,
273
+ program_id,
274
+ project_id,
275
+ uuids,
276
+ batch_size=batch_size,
277
+ batch_delay=batch_delay,
278
+ verbose=verbose,
279
+ )
280
+ return len(seen)
281
+
282
+
205
283
  def delete_project_metadata(
206
284
  gen3_submission: Gen3Submission,
207
285
  program_id: str,