dh-cli 0.3.1__tar.gz → 0.4.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 (51) hide show
  1. {dh_cli-0.3.1 → dh_cli-0.4.0}/PKG-INFO +1 -1
  2. {dh_cli-0.3.1 → dh_cli-0.4.0}/pyproject.toml +1 -1
  3. {dh_cli-0.3.1 → dh_cli-0.4.0}/src/dh_cli/batch/commands/finalize.py +5 -4
  4. {dh_cli-0.3.1 → dh_cli-0.4.0}/src/dh_cli/batch/commands/protmpnn.py +132 -7
  5. dh_cli-0.4.0/src/dh_cli/hz/__init__.py +67 -0
  6. dh_cli-0.4.0/src/dh_cli/hz/deploy.py +37 -0
  7. dh_cli-0.4.0/src/dh_cli/hz/local.py +21 -0
  8. dh_cli-0.4.0/src/dh_cli/hz/test.py +111 -0
  9. dh_cli-0.4.0/src/dh_cli/hz/tf.py +53 -0
  10. dh_cli-0.4.0/src/dh_cli/hz/users.py +135 -0
  11. {dh_cli-0.3.1 → dh_cli-0.4.0}/src/dh_cli/main.py +5 -0
  12. dh_cli-0.4.0/tests/hz/test_init.py +68 -0
  13. dh_cli-0.4.0/tests/hz/test_suites.py +102 -0
  14. dh_cli-0.4.0/tests/hz/test_users.py +208 -0
  15. {dh_cli-0.3.1 → dh_cli-0.4.0}/.gitignore +0 -0
  16. {dh_cli-0.3.1 → dh_cli-0.4.0}/LICENSE +0 -0
  17. {dh_cli-0.3.1 → dh_cli-0.4.0}/README.md +0 -0
  18. {dh_cli-0.3.1 → dh_cli-0.4.0}/src/dh_cli/__init__.py +0 -0
  19. {dh_cli-0.3.1 → dh_cli-0.4.0}/src/dh_cli/batch/__init__.py +0 -0
  20. {dh_cli-0.3.1 → dh_cli-0.4.0}/src/dh_cli/batch/aws_batch.py +0 -0
  21. {dh_cli-0.3.1 → dh_cli-0.4.0}/src/dh_cli/batch/commands/__init__.py +0 -0
  22. {dh_cli-0.3.1 → dh_cli-0.4.0}/src/dh_cli/batch/commands/boltz.py +0 -0
  23. {dh_cli-0.3.1 → dh_cli-0.4.0}/src/dh_cli/batch/commands/cancel.py +0 -0
  24. {dh_cli-0.3.1 → dh_cli-0.4.0}/src/dh_cli/batch/commands/clean.py +0 -0
  25. {dh_cli-0.3.1 → dh_cli-0.4.0}/src/dh_cli/batch/commands/embed_t5.py +0 -0
  26. {dh_cli-0.3.1 → dh_cli-0.4.0}/src/dh_cli/batch/commands/list_jobs.py +0 -0
  27. {dh_cli-0.3.1 → dh_cli-0.4.0}/src/dh_cli/batch/commands/local.py +0 -0
  28. {dh_cli-0.3.1 → dh_cli-0.4.0}/src/dh_cli/batch/commands/logs.py +0 -0
  29. {dh_cli-0.3.1 → dh_cli-0.4.0}/src/dh_cli/batch/commands/protmpnn_to_boltz.py +0 -0
  30. {dh_cli-0.3.1 → dh_cli-0.4.0}/src/dh_cli/batch/commands/retry.py +0 -0
  31. {dh_cli-0.3.1 → dh_cli-0.4.0}/src/dh_cli/batch/commands/status.py +0 -0
  32. {dh_cli-0.3.1 → dh_cli-0.4.0}/src/dh_cli/batch/commands/submit.py +0 -0
  33. {dh_cli-0.3.1 → dh_cli-0.4.0}/src/dh_cli/batch/commands/train.py +0 -0
  34. {dh_cli-0.3.1 → dh_cli-0.4.0}/src/dh_cli/batch/commands/wait_for.py +0 -0
  35. {dh_cli-0.3.1 → dh_cli-0.4.0}/src/dh_cli/batch/fasta_utils.py +0 -0
  36. {dh_cli-0.3.1 → dh_cli-0.4.0}/src/dh_cli/batch/h5_utils.py +0 -0
  37. {dh_cli-0.3.1 → dh_cli-0.4.0}/src/dh_cli/batch/job_id.py +0 -0
  38. {dh_cli-0.3.1 → dh_cli-0.4.0}/src/dh_cli/batch/manifest.py +0 -0
  39. {dh_cli-0.3.1 → dh_cli-0.4.0}/src/dh_cli/batch/s3_transport.py +0 -0
  40. {dh_cli-0.3.1 → dh_cli-0.4.0}/src/dh_cli/cloud_commands.py +0 -0
  41. {dh_cli-0.3.1 → dh_cli-0.4.0}/src/dh_cli/codeartifact.py +0 -0
  42. {dh_cli-0.3.1 → dh_cli-0.4.0}/src/dh_cli/engines_studios/__init__.py +0 -0
  43. {dh_cli-0.3.1 → dh_cli-0.4.0}/src/dh_cli/engines_studios/api_client.py +0 -0
  44. {dh_cli-0.3.1 → dh_cli-0.4.0}/src/dh_cli/engines_studios/auth.py +0 -0
  45. {dh_cli-0.3.1 → dh_cli-0.4.0}/src/dh_cli/engines_studios/engine_commands.py +0 -0
  46. {dh_cli-0.3.1 → dh_cli-0.4.0}/src/dh_cli/engines_studios/progress.py +0 -0
  47. {dh_cli-0.3.1 → dh_cli-0.4.0}/src/dh_cli/engines_studios/ssh_config.py +0 -0
  48. {dh_cli-0.3.1 → dh_cli-0.4.0}/src/dh_cli/engines_studios/studio_commands.py +0 -0
  49. {dh_cli-0.3.1 → dh_cli-0.4.0}/src/dh_cli/github_commands.py +0 -0
  50. {dh_cli-0.3.1 → dh_cli-0.4.0}/src/dh_cli/utility_commands.py +0 -0
  51. {dh_cli-0.3.1 → dh_cli-0.4.0}/src/dh_cli/warehouse.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dh-cli
3
- Version: 0.3.1
3
+ Version: 0.4.0
4
4
  Summary: Dayhoff Labs developer CLI
5
5
  Author-email: Dayhoff Labs <dev@dayhofflabs.com>
6
6
  License: # PolyForm Noncommercial License 1.0.0
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "dh-cli"
7
- version = "0.3.1"
7
+ version = "0.4.0"
8
8
  description = "Dayhoff Labs developer CLI"
9
9
  requires-python = ">=3.11"
10
10
  readme = "README.md"
@@ -489,10 +489,11 @@ def _finalize_protmpnn(output_dir: Path, output_path: Path):
489
489
  pdbs_dest.mkdir(exist_ok=True)
490
490
  for config_dir in output_dir.iterdir():
491
491
  if config_dir.is_dir():
492
- config_pdbs = config_dir / "pdbs"
493
- if config_pdbs.exists():
494
- for pdb_file in config_pdbs.glob("*.pdb"):
495
- shutil.copy2(pdb_file, pdbs_dest / pdb_file.name)
492
+ for subdir_name in ("pdbs", "backbones"):
493
+ config_pdbs = config_dir / subdir_name
494
+ if config_pdbs.exists():
495
+ for pdb_file in config_pdbs.glob("*.pdb"):
496
+ shutil.copy2(pdb_file, pdbs_dest / pdb_file.name)
496
497
 
497
498
  top_conf = merged.iloc[0]["overall_confidence"] if num_variants > 0 else "N/A"
498
499
 
@@ -7,8 +7,8 @@ from pathlib import Path
7
7
 
8
8
  import click
9
9
 
10
- from ..aws_batch import BatchClient, BatchError
11
- from ..job_id import generate_job_id
10
+ from ..aws_batch import BatchClient, BatchError, resolve_dependency
11
+ from ..job_id import generate_job_id, get_aws_username
12
12
  from ..manifest import (
13
13
  BATCH_JOBS_BASE,
14
14
  BatchConfig,
@@ -63,7 +63,14 @@ DEFAULT_IMAGE_URI = (
63
63
  help="Drop into container shell for debugging",
64
64
  )
65
65
  @click.option("--base-path", default=BATCH_JOBS_BASE, help="Base path for job data")
66
- def protmpnn(input_dir, workers, queue, dry_run, run_local, run_remote, run_shell, base_path):
66
+ @click.option("--after", "after", multiple=True, help="Job ID(s) to wait for before starting")
67
+ @click.option(
68
+ "--auto-validate-top",
69
+ type=int,
70
+ default=None,
71
+ help="Auto-submit Boltz validation for top N variants after completion",
72
+ )
73
+ def protmpnn(input_dir, workers, queue, dry_run, run_local, run_remote, run_shell, base_path, after, auto_validate_top):
67
74
  """Design protein sequences with ProtMPNN/LigandMPNN.
68
75
 
69
76
  Processes a directory of YAML config files, each specifying a PDB
@@ -106,17 +113,17 @@ def protmpnn(input_dir, workers, queue, dry_run, run_local, run_remote, run_shel
106
113
  return
107
114
 
108
115
  if run_local:
109
- _run_local_mode(input_path)
116
+ _run_local_mode(input_path, auto_validate_top, base_path)
110
117
  return
111
118
 
112
119
  # Auto-detect GPU for smart defaulting
113
120
  if not run_remote and not dry_run:
114
121
  if _has_local_gpu():
115
122
  click.echo("GPU detected — running locally (use --remote to override)")
116
- _run_local_mode(input_path)
123
+ _run_local_mode(input_path, auto_validate_top, base_path)
117
124
  return
118
125
 
119
- _submit_batch_job(input_path, workers, queue, dry_run, base_path)
126
+ _submit_batch_job(input_path, workers, queue, dry_run, base_path, after, auto_validate_top)
120
127
 
121
128
 
122
129
  def _has_local_gpu() -> bool:
@@ -161,6 +168,8 @@ def _submit_batch_job(
161
168
  queue: str,
162
169
  dry_run: bool,
163
170
  base_path: str,
171
+ after: tuple[str, ...] = (),
172
+ auto_validate_top: int | None = None,
164
173
  ):
165
174
  """Submit ProtMPNN job to AWS Batch."""
166
175
  click.echo(f"Scanning {input_path} for YAML files...")
@@ -228,11 +237,15 @@ def _submit_batch_job(
228
237
  destination=None,
229
238
  finalized=False,
230
239
  ),
240
+ depends_on=list(after) if after else None,
231
241
  )
232
242
 
233
243
  save_manifest(manifest, base_path)
234
244
 
235
245
  try:
246
+ resolved = [resolve_dependency(jid, base_path) for jid in after]
247
+ depends_on = [{"jobId": aws_id} for aws_id in resolved if aws_id is not None] or None
248
+
236
249
  client = BatchClient()
237
250
 
238
251
  environment = {
@@ -250,6 +263,8 @@ def _submit_batch_job(
250
263
  environment=environment,
251
264
  timeout_seconds=1 * 3600, # 1 hour
252
265
  retry_attempts=5,
266
+ depends_on=depends_on,
267
+ share_identifier=get_aws_username(),
253
268
  )
254
269
 
255
270
  manifest.status = JobStatus.SUBMITTED
@@ -260,6 +275,8 @@ def _submit_batch_job(
260
275
  click.echo(click.style("Job submitted successfully!", fg="green"))
261
276
  click.echo()
262
277
  click.echo(f"AWS Batch Job ID: {batch_job_id}")
278
+ if depends_on:
279
+ click.echo(f"Waiting on: {', '.join(after)}")
263
280
  click.echo()
264
281
  click.echo("Next steps:")
265
282
  click.echo(f" Check status: dh batch status {job_id}")
@@ -271,6 +288,11 @@ def _submit_batch_job(
271
288
  f" Finalize: dh batch finalize {job_id} --output ./results/"
272
289
  )
273
290
 
291
+ if auto_validate_top:
292
+ _submit_boltz_validation(
293
+ job_id, batch_job_id, job_dir, auto_validate_top, base_path
294
+ )
295
+
274
296
  except BatchError as e:
275
297
  manifest.status = JobStatus.FAILED
276
298
  manifest.error_message = str(e)
@@ -279,7 +301,7 @@ def _submit_batch_job(
279
301
  raise SystemExit(1)
280
302
 
281
303
 
282
- def _run_local_mode(input_path: Path):
304
+ def _run_local_mode(input_path: Path, auto_validate_top: int | None = None, base_path: str = BATCH_JOBS_BASE):
283
305
  """Run ProtMPNN locally in a Docker container."""
284
306
  import subprocess
285
307
 
@@ -360,6 +382,11 @@ def _run_local_mode(input_path: Path):
360
382
  click.echo(click.style("Design complete!", fg="green"))
361
383
  click.echo(f"Results: {temp_output_dir / 'results.csv'}")
362
384
  click.echo(f" {len(merged)} variants generated")
385
+
386
+ if auto_validate_top:
387
+ _run_local_boltz_validation(
388
+ temp_output_dir, input_path, auto_validate_top
389
+ )
363
390
  else:
364
391
  click.echo(click.style("Warning: No results CSV found", fg="yellow"))
365
392
 
@@ -416,3 +443,101 @@ def _run_shell_mode(input_path: Path):
416
443
  err=True,
417
444
  )
418
445
  raise SystemExit(1)
446
+
447
+
448
+ def _convert_to_boltz(results_dir: Path, config_dir: Path, top_n: int) -> Path:
449
+ """Run protmpnn-to-boltz conversion, return the output directory."""
450
+ from .protmpnn_to_boltz import (
451
+ _build_boltz_yaml,
452
+ _load_ligand_smiles,
453
+ _write_pymol_script,
454
+ )
455
+
456
+ import pandas as pd
457
+ import yaml
458
+
459
+ csv_path = results_dir / "results.csv"
460
+ if not csv_path.exists():
461
+ worker_csvs = sorted(results_dir.glob("results_worker_*.csv"))
462
+ if not worker_csvs:
463
+ raise FileNotFoundError(f"No results CSV in {results_dir}")
464
+ dfs = [pd.read_csv(f) for f in worker_csvs]
465
+ df = pd.concat(dfs, ignore_index=True).sort_values(
466
+ "overall_confidence", ascending=False
467
+ )
468
+ else:
469
+ df = pd.read_csv(csv_path)
470
+
471
+ top_n = min(top_n, len(df))
472
+ top_variants = df.head(top_n)
473
+ ligand_map = _load_ligand_smiles(str(config_dir), results_dir)
474
+
475
+ boltz_dir = results_dir.parent / "boltz_input"
476
+ boltz_dir.mkdir(parents=True, exist_ok=True)
477
+
478
+ generated = []
479
+ for _idx, row in top_variants.iterrows():
480
+ config_name = row.get("config_name", "unknown")
481
+ variant_id = int(row.get("variant_id", _idx))
482
+ sequence = row["sequence"]
483
+ confidence = row.get("overall_confidence", float("nan"))
484
+
485
+ boltz_yaml = _build_boltz_yaml(
486
+ sequence=sequence,
487
+ config_name=config_name,
488
+ variant_id=variant_id,
489
+ ligand_smiles=ligand_map.get(config_name),
490
+ )
491
+
492
+ filename = f"{config_name}_var{variant_id:03d}.yaml"
493
+ with open(boltz_dir / filename, "w") as f:
494
+ yaml.dump(boltz_yaml, f, default_flow_style=False, sort_keys=False)
495
+ generated.append((filename, confidence))
496
+
497
+ _write_pymol_script(boltz_dir, results_dir, generated, ligand_map)
498
+ return boltz_dir
499
+
500
+
501
+ def _run_local_boltz_validation(
502
+ results_dir: Path, original_input_path: Path, top_n: int
503
+ ):
504
+ """Convert top variants to Boltz YAMLs and run Boltz locally."""
505
+ click.echo()
506
+ click.echo(f"Auto-validating top {top_n} variants with Boltz...")
507
+
508
+ boltz_dir = _convert_to_boltz(results_dir, original_input_path, top_n)
509
+ num_yamls = len(list(boltz_dir.glob("*.yaml")))
510
+ click.echo(f"Generated {num_yamls} Boltz configs at {boltz_dir}/")
511
+ click.echo()
512
+
513
+ from .boltz import _run_local_mode as boltz_local
514
+
515
+ boltz_local(boltz_dir)
516
+
517
+
518
+ def _submit_boltz_validation(
519
+ protmpnn_job_id: str,
520
+ protmpnn_aws_job_id: str,
521
+ job_dir: Path,
522
+ top_n: int,
523
+ base_path: str,
524
+ ):
525
+ """Pre-register a dependent Boltz Batch job that runs after ProtMPNN completes.
526
+
527
+ The ProtMPNN worker writes results to job_dir/output/. The Boltz conversion
528
+ happens at finalize time — we set up a post-finalize hook via an environment
529
+ variable that tells the ProtMPNN finalizer to convert and submit Boltz.
530
+ """
531
+ click.echo()
532
+ click.echo(
533
+ f"Boltz validation for top {top_n} will run after ProtMPNN finalize."
534
+ )
535
+ click.echo(
536
+ "After ProtMPNN completes, finalize will auto-convert and submit Boltz:"
537
+ )
538
+ click.echo(f" dh batch finalize {protmpnn_job_id} --auto-validate-top {top_n}")
539
+ click.echo()
540
+ click.echo(
541
+ "Or manually: dh batch protmpnn-to-boltz <results_dir> --top "
542
+ f"{top_n} && dh batch boltz <boltz_dir>"
543
+ )
@@ -0,0 +1,67 @@
1
+ """dh hz — Horizyn API management commands."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ import typer
7
+
8
+ hz_app = typer.Typer(
9
+ help="Manage Horizyn API: users, deployments, local servers, and tests.",
10
+ context_settings={"help_option_names": ["-h", "--help"]},
11
+ )
12
+
13
+
14
+ def _find_workspace_root() -> Path:
15
+ root = os.environ.get("WORKSPACE_ROOT")
16
+ if root:
17
+ p = Path(root)
18
+ if p.is_dir():
19
+ return p
20
+
21
+ for child in Path("/workspaces").iterdir():
22
+ if child.is_dir() and not child.name.startswith("."):
23
+ return child
24
+
25
+ typer.echo("Cannot determine workspace root. Set $WORKSPACE_ROOT.", err=True)
26
+ raise typer.Exit(1)
27
+
28
+
29
+ def require_repo(name: str) -> Path:
30
+ """Return the path to a cloned repo, or exit with a helpful error."""
31
+ root = _find_workspace_root()
32
+ repo = root / name
33
+ if not repo.is_dir():
34
+ typer.echo(
35
+ f"Repository '{name}' not found at {repo}.\n"
36
+ f"Clone it with: dc clone {name}",
37
+ err=True,
38
+ )
39
+ raise typer.Exit(1)
40
+ return repo
41
+
42
+
43
+ def run_script(script: Path, args: list[str] | None = None, cwd: Path | None = None) -> None:
44
+ """Run a shell script, streaming output. Exit on failure."""
45
+ import subprocess
46
+
47
+ if not script.exists():
48
+ typer.echo(f"Script not found: {script}", err=True)
49
+ raise typer.Exit(1)
50
+
51
+ cmd = ["bash", str(script)] + (args or [])
52
+ result = subprocess.run(cmd, cwd=cwd)
53
+ if result.returncode != 0:
54
+ raise typer.Exit(result.returncode)
55
+
56
+
57
+ from dh_cli.hz.users import users_app # noqa: E402
58
+ from dh_cli.hz.deploy import deploy_app # noqa: E402
59
+ from dh_cli.hz.local import local_app # noqa: E402
60
+ from dh_cli.hz.test import test_app # noqa: E402
61
+ from dh_cli.hz.tf import tf_app # noqa: E402
62
+
63
+ hz_app.add_typer(users_app, name="users", help="Manage authorized users and tiers.")
64
+ hz_app.add_typer(deploy_app, name="deploy", help="Deploy API, docking, or frontend.")
65
+ hz_app.add_typer(local_app, name="local", help="Run local development servers.")
66
+ hz_app.add_typer(test_app, name="test", help="Run integration test suites.")
67
+ hz_app.add_typer(tf_app, name="tf", help="Terraform apply for Horizyn infrastructure.")
@@ -0,0 +1,37 @@
1
+ """Deploy API server, docking service, or frontend."""
2
+
3
+ import typer
4
+
5
+ from dh_cli.hz import require_repo, run_script
6
+
7
+ deploy_app = typer.Typer(context_settings={"help_option_names": ["-h", "--help"]})
8
+
9
+
10
+ @deploy_app.command("api")
11
+ def deploy_api(
12
+ env: str = typer.Option("prod", "--env", "-e", help="Environment: prod or dev."),
13
+ ):
14
+ """Build and deploy the Horizyn API server."""
15
+ repo = require_repo("horizyn-api")
16
+ script = repo / f"server/build-and-push-{env}.sh"
17
+ run_script(script, cwd=repo)
18
+
19
+
20
+ @deploy_app.command("docking")
21
+ def deploy_docking(
22
+ env: str = typer.Option("prod", "--env", "-e", help="Environment: prod or dev."),
23
+ ):
24
+ """Build and deploy the docking service."""
25
+ repo = require_repo("horizyn-api")
26
+ script = repo / f"docking_service/build-and-push-{env}.sh"
27
+ run_script(script, cwd=repo)
28
+
29
+
30
+ @deploy_app.command("frontend")
31
+ def deploy_frontend(
32
+ env: str = typer.Option("prod", "--env", "-e", help="Environment: prod or dev."),
33
+ ):
34
+ """Build and deploy the Horizyn frontend."""
35
+ repo = require_repo("horizyn-frontend")
36
+ script = repo / "scripts/deploy.sh"
37
+ run_script(script, args=[env], cwd=repo)
@@ -0,0 +1,21 @@
1
+ """Run local development servers."""
2
+
3
+ import typer
4
+
5
+ from dh_cli.hz import require_repo, run_script
6
+
7
+ local_app = typer.Typer(context_settings={"help_option_names": ["-h", "--help"]})
8
+
9
+
10
+ @local_app.command("api")
11
+ def local_api():
12
+ """Start the local Horizyn API server in Docker."""
13
+ repo = require_repo("horizyn-api")
14
+ run_script(repo / "server/run_local_server.sh", cwd=repo)
15
+
16
+
17
+ @local_app.command("docking")
18
+ def local_docking():
19
+ """Start the local docking service in Docker."""
20
+ repo = require_repo("horizyn-api")
21
+ run_script(repo / "docking_service/run_local_server.sh", cwd=repo)
@@ -0,0 +1,111 @@
1
+ """Run integration test suites."""
2
+
3
+ import re
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ import typer
8
+
9
+ from dh_cli.hz import require_repo, run_script
10
+
11
+ test_app = typer.Typer(context_settings={"help_option_names": ["-h", "--help"]})
12
+
13
+
14
+ def _discover_suites(test_dir: Path) -> list[tuple[int, str, Path]]:
15
+ """Return sorted list of (number, name, path) for numbered test scripts."""
16
+ suites = []
17
+ for f in test_dir.glob("*.sh"):
18
+ m = re.match(r"^(\d+)_(.+)\.sh$", f.name)
19
+ if m:
20
+ suites.append((int(m.group(1)), m.group(2), f))
21
+ return sorted(suites, key=lambda t: t[0])
22
+
23
+
24
+ def _resolve_suites(
25
+ available: list[tuple[int, str, Path]], selectors: list[str]
26
+ ) -> list[tuple[int, str, Path]]:
27
+ """Match selectors (numbers or name substrings) to available suites."""
28
+ matched = []
29
+ for sel in selectors:
30
+ sel = sel.strip()
31
+ found = False
32
+ if sel.isdigit():
33
+ num = int(sel)
34
+ for suite in available:
35
+ if suite[0] == num:
36
+ matched.append(suite)
37
+ found = True
38
+ break
39
+ if not found:
40
+ for suite in available:
41
+ if sel.lower() in suite[1].lower():
42
+ matched.append(suite)
43
+ found = True
44
+ break
45
+ if not found:
46
+ typer.echo(f"No suite matching '{sel}'.", err=True)
47
+ raise typer.Exit(1)
48
+ return matched
49
+
50
+
51
+ @test_app.command("list")
52
+ def list_tests(
53
+ target: str = typer.Option("local", "--target", "-t", help="local or deployed."),
54
+ ):
55
+ """Show available test suites."""
56
+ repo = require_repo("horizyn-api")
57
+ test_dir = repo / f"test/server/{target}"
58
+ if not test_dir.is_dir():
59
+ typer.echo(f"Test directory not found: {test_dir}", err=True)
60
+ raise typer.Exit(1)
61
+
62
+ suites = _discover_suites(test_dir)
63
+ typer.echo(f"\n {target} test suites:\n")
64
+ for num, name, path in suites:
65
+ typer.echo(f" {num:3d} {name}")
66
+ typer.echo()
67
+
68
+
69
+ @test_app.command("local")
70
+ def test_local(
71
+ suites: Optional[list[str]] = typer.Argument(None, help="Suite numbers or names. Omit to run all."),
72
+ ):
73
+ """Run local integration tests."""
74
+ repo = require_repo("horizyn-api")
75
+ test_dir = repo / "test/server/local"
76
+
77
+ if not suites:
78
+ run_script(test_dir / "run_all_tests.sh", cwd=test_dir)
79
+ return
80
+
81
+ available = _discover_suites(test_dir)
82
+ selected = _resolve_suites(available, suites)
83
+
84
+ for num, name, path in selected:
85
+ typer.echo(f"\n{'='*60}")
86
+ typer.echo(f" Running: {num}_{name}")
87
+ typer.echo(f"{'='*60}\n")
88
+ run_script(path, cwd=test_dir)
89
+
90
+
91
+ @test_app.command("deployed")
92
+ def test_deployed(
93
+ suites: Optional[list[str]] = typer.Argument(None, help="Suite numbers or names. Omit to run all."),
94
+ env: str = typer.Option("dev", "--env", "-e", help="Environment: dev or prod."),
95
+ ):
96
+ """Run deployed integration tests."""
97
+ repo = require_repo("horizyn-api")
98
+ test_dir = repo / "test/server/deployed"
99
+
100
+ if not suites:
101
+ run_script(test_dir / "run_all_tests.sh", args=[env], cwd=test_dir)
102
+ return
103
+
104
+ available = _discover_suites(test_dir)
105
+ selected = _resolve_suites(available, suites)
106
+
107
+ for num, name, path in selected:
108
+ typer.echo(f"\n{'='*60}")
109
+ typer.echo(f" Running: {num}_{name}")
110
+ typer.echo(f"{'='*60}\n")
111
+ run_script(path, args=[env], cwd=test_dir)
@@ -0,0 +1,53 @@
1
+ """Terraform deployments for Horizyn infrastructure."""
2
+
3
+ import typer
4
+
5
+ from dh_cli.hz import require_repo, run_script
6
+
7
+ tf_app = typer.Typer(context_settings={"help_option_names": ["-h", "--help"]})
8
+
9
+ TF_MODULES = {
10
+ ("api", "dev"): "terraform/environments/dev/horizyn_api/api",
11
+ ("api", "prod"): "terraform/environments/dev/horizyn_api_prod",
12
+ ("frontend", "dev"): "terraform/environments/dev/horizyn_frontend",
13
+ ("frontend", "prod"): "terraform/environments/dev/horizyn_frontend_prod",
14
+ ("docking", "dev"): "terraform/environments/dev/boltz_docking/service",
15
+ ("docking", "prod"): "terraform/environments/dev/boltz_docking_prod/service",
16
+ }
17
+
18
+
19
+ def _run_tf(target: str, env: str, action: str) -> None:
20
+ repo = require_repo("blueprints")
21
+ module_path = TF_MODULES.get((target, env))
22
+ if not module_path:
23
+ typer.echo(f"Unknown combination: {target} / {env}", err=True)
24
+ raise typer.Exit(1)
25
+ script = repo / "scripts/terraform-deploy"
26
+ run_script(script, args=[module_path, action], cwd=repo)
27
+
28
+
29
+ @tf_app.command("api")
30
+ def tf_api(
31
+ env: str = typer.Option("prod", "--env", "-e", help="Environment: prod or dev."),
32
+ yolo: bool = typer.Option(False, "--yolo", help="Auto-approve (no confirmation prompt)."),
33
+ ):
34
+ """Terraform apply for the Horizyn API server."""
35
+ _run_tf("api", env, "yolo" if yolo else "plan")
36
+
37
+
38
+ @tf_app.command("frontend")
39
+ def tf_frontend(
40
+ env: str = typer.Option("prod", "--env", "-e", help="Environment: prod or dev."),
41
+ yolo: bool = typer.Option(False, "--yolo", help="Auto-approve (no confirmation prompt)."),
42
+ ):
43
+ """Terraform apply for the Horizyn frontend."""
44
+ _run_tf("frontend", env, "yolo" if yolo else "plan")
45
+
46
+
47
+ @tf_app.command("docking")
48
+ def tf_docking(
49
+ env: str = typer.Option("prod", "--env", "-e", help="Environment: prod or dev."),
50
+ yolo: bool = typer.Option(False, "--yolo", help="Auto-approve (no confirmation prompt)."),
51
+ ):
52
+ """Terraform apply for the Boltz docking service."""
53
+ _run_tf("docking", env, "yolo" if yolo else "plan")
@@ -0,0 +1,135 @@
1
+ """User management via SSM parameters."""
2
+
3
+ import typer
4
+
5
+ users_app = typer.Typer(context_settings={"help_option_names": ["-h", "--help"]})
6
+
7
+ SSM_PARAMS = {
8
+ "allowed_emails": "/horizyn/{env}/auth/allowed_emails",
9
+ "allowed_domains": "/horizyn/{env}/auth/allowed_domains",
10
+ "admin_domains": "/horizyn/{env}/auth/admin_domains",
11
+ "admin_emails": "/horizyn/{env}/auth/admin_emails",
12
+ "alpha_emails": "/horizyn/{env}/auth/alpha_emails",
13
+ }
14
+
15
+
16
+ def _ssm_client():
17
+ import boto3
18
+
19
+ return boto3.client("ssm", region_name="us-east-1")
20
+
21
+
22
+ def _get_param(ssm, name: str) -> str:
23
+ try:
24
+ resp = ssm.get_parameter(Name=name)
25
+ return resp["Parameter"]["Value"]
26
+ except ssm.exceptions.ParameterNotFound:
27
+ return ""
28
+
29
+
30
+ def _put_param(ssm, name: str, value: str) -> None:
31
+ ssm.put_parameter(Name=name, Value=value, Type="String", Overwrite=True)
32
+ typer.echo(f"Updated {name}")
33
+
34
+
35
+ def _parse_csv(raw: str) -> set[str]:
36
+ return {e.strip().lower() for e in raw.split(",") if e.strip()}
37
+
38
+
39
+ def _format_csv(emails: set[str]) -> str:
40
+ return ",".join(sorted(emails))
41
+
42
+
43
+ @users_app.command("list")
44
+ def list_users(
45
+ env: str = typer.Option("prod", "--env", "-e", help="Environment: prod or dev."),
46
+ ):
47
+ """Show all authorized users and their tiers."""
48
+ ssm = _ssm_client()
49
+
50
+ admin_domains = _parse_csv(_get_param(ssm, SSM_PARAMS["admin_domains"].format(env=env)))
51
+ admin_emails = _parse_csv(_get_param(ssm, SSM_PARAMS["admin_emails"].format(env=env)))
52
+ alpha_emails = _parse_csv(_get_param(ssm, SSM_PARAMS["alpha_emails"].format(env=env)))
53
+ allowed_domains = _parse_csv(_get_param(ssm, SSM_PARAMS["allowed_domains"].format(env=env)))
54
+ allowed_emails = _parse_csv(_get_param(ssm, SSM_PARAMS["allowed_emails"].format(env=env)))
55
+
56
+ typer.echo(f"\n Horizyn users ({env})\n")
57
+
58
+ typer.echo(" Admin domains: " + (", ".join(sorted(admin_domains)) or "(none)"))
59
+ typer.echo(" Admin emails: " + (", ".join(sorted(admin_emails)) or "(none)"))
60
+ typer.echo(" Alpha emails: " + (", ".join(sorted(alpha_emails)) or "(none)"))
61
+ typer.echo(" Allowed domains: " + (", ".join(sorted(allowed_domains)) or "(none)"))
62
+ typer.echo(" Allowed emails: " + (", ".join(sorted(allowed_emails)) or "(none)"))
63
+ typer.echo()
64
+
65
+
66
+ @users_app.command("add")
67
+ def add_user(
68
+ email: str = typer.Argument(help="Email address to add."),
69
+ env: str = typer.Option("prod", "--env", "-e", help="Environment: prod or dev."),
70
+ tier: str = typer.Option("beta", "--tier", "-t", help="Tier: beta or alpha."),
71
+ ):
72
+ """Add a user to the authorized emails list (and optionally set tier)."""
73
+ email = email.strip().lower()
74
+ ssm = _ssm_client()
75
+
76
+ allowed = _parse_csv(_get_param(ssm, SSM_PARAMS["allowed_emails"].format(env=env)))
77
+ if email in allowed:
78
+ typer.echo(f"{email} is already in allowed_emails for {env}.")
79
+ else:
80
+ allowed.add(email)
81
+ _put_param(ssm, SSM_PARAMS["allowed_emails"].format(env=env), _format_csv(allowed))
82
+
83
+ if tier == "alpha":
84
+ alphas = _parse_csv(_get_param(ssm, SSM_PARAMS["alpha_emails"].format(env=env)))
85
+ if email in alphas:
86
+ typer.echo(f"{email} is already in alpha_emails for {env}.")
87
+ else:
88
+ alphas.add(email)
89
+ _put_param(ssm, SSM_PARAMS["alpha_emails"].format(env=env), _format_csv(alphas))
90
+
91
+
92
+ @users_app.command("remove")
93
+ def remove_user(
94
+ email: str = typer.Argument(help="Email address to remove."),
95
+ env: str = typer.Option("prod", "--env", "-e", help="Environment: prod or dev."),
96
+ ):
97
+ """Remove a user from all email lists for the given environment."""
98
+ email = email.strip().lower()
99
+ ssm = _ssm_client()
100
+
101
+ for param_key in ("allowed_emails", "admin_emails", "alpha_emails"):
102
+ param_name = SSM_PARAMS[param_key].format(env=env)
103
+ current = _parse_csv(_get_param(ssm, param_name))
104
+ if email in current:
105
+ current.discard(email)
106
+ _put_param(ssm, param_name, _format_csv(current))
107
+ typer.echo(f" Removed {email} from {param_key}")
108
+
109
+
110
+ @users_app.command("promote")
111
+ def promote_user(
112
+ email: str = typer.Argument(help="Email address to promote."),
113
+ tier: str = typer.Option(..., "--tier", "-t", help="Target tier: alpha."),
114
+ env: str = typer.Option("prod", "--env", "-e", help="Environment: prod or dev."),
115
+ ):
116
+ """Promote a user to a higher tier."""
117
+ email = email.strip().lower()
118
+
119
+ if tier not in ("alpha",):
120
+ typer.echo("Only --tier alpha is supported. Admin is domain-based.", err=True)
121
+ raise typer.Exit(1)
122
+
123
+ ssm = _ssm_client()
124
+
125
+ allowed = _parse_csv(_get_param(ssm, SSM_PARAMS["allowed_emails"].format(env=env)))
126
+ if email not in allowed:
127
+ typer.echo(f"{email} is not in allowed_emails for {env}. Add them first.", err=True)
128
+ raise typer.Exit(1)
129
+
130
+ alphas = _parse_csv(_get_param(ssm, SSM_PARAMS["alpha_emails"].format(env=env)))
131
+ if email in alphas:
132
+ typer.echo(f"{email} is already alpha in {env}.")
133
+ else:
134
+ alphas.add(email)
135
+ _put_param(ssm, SSM_PARAMS["alpha_emails"].format(env=env), _format_csv(alphas))
@@ -33,6 +33,11 @@ app.add_typer(gcp_app, name="gcp", help="Manage GCP authentication and impersona
33
33
  app.add_typer(aws_app, name="aws", help="Manage AWS SSO authentication.")
34
34
  app.add_typer(gh_app, name="gh", help="Manage GitHub authentication.")
35
35
 
36
+ # Horizyn API management
37
+ from dh_cli.hz import hz_app
38
+
39
+ app.add_typer(hz_app, name="hz", help="Manage Horizyn API: users, deployments, tests.")
40
+
36
41
 
37
42
  # Engine and Studio commands (v2 - Click-based, passthrough wrapper)
38
43
  @app.command(
@@ -0,0 +1,68 @@
1
+ """Tests for dh hz workspace discovery and repo resolution."""
2
+
3
+ from pathlib import Path
4
+ from unittest.mock import patch
5
+
6
+ import pytest
7
+ from click.exceptions import Exit
8
+
9
+ from dh_cli.hz import _find_workspace_root, require_repo
10
+
11
+
12
+ class TestFindWorkspaceRoot:
13
+ def test_uses_workspace_root_env_var(self, tmp_path):
14
+ with patch.dict("os.environ", {"WORKSPACE_ROOT": str(tmp_path)}):
15
+ assert _find_workspace_root() == tmp_path
16
+
17
+ def test_falls_back_to_scanning_workspaces(self, tmp_path):
18
+ user_dir = tmp_path / "alice"
19
+ user_dir.mkdir()
20
+
21
+ with (
22
+ patch.dict("os.environ", {"WORKSPACE_ROOT": ""}),
23
+ patch("dh_cli.hz.Path") as mock_path,
24
+ ):
25
+ # WORKSPACE_ROOT="" → falsy, so it hits the scan branch
26
+ # Make Path("/workspaces") return something that iterates to user_dir
27
+ def path_factory(arg):
28
+ if arg == "/workspaces":
29
+ p = Path(tmp_path)
30
+ return p
31
+ return Path(arg)
32
+
33
+ mock_path.side_effect = path_factory
34
+
35
+ result = _find_workspace_root()
36
+ assert result == user_dir
37
+
38
+ def test_exits_when_nothing_found(self, tmp_path):
39
+ empty_ws = tmp_path / "empty_workspaces"
40
+ empty_ws.mkdir()
41
+
42
+ with (
43
+ patch.dict("os.environ", {"WORKSPACE_ROOT": ""}),
44
+ patch("dh_cli.hz.Path") as mock_path,
45
+ ):
46
+ def path_factory(arg):
47
+ if arg == "/workspaces":
48
+ return Path(empty_ws)
49
+ return Path(arg)
50
+
51
+ mock_path.side_effect = path_factory
52
+
53
+ with pytest.raises(Exit):
54
+ _find_workspace_root()
55
+
56
+
57
+ class TestRequireRepo:
58
+ def test_returns_repo_path(self, tmp_path):
59
+ repo = tmp_path / "horizyn-api"
60
+ repo.mkdir()
61
+
62
+ with patch("dh_cli.hz._find_workspace_root", return_value=tmp_path):
63
+ assert require_repo("horizyn-api") == repo
64
+
65
+ def test_exits_when_repo_missing(self, tmp_path):
66
+ with patch("dh_cli.hz._find_workspace_root", return_value=tmp_path):
67
+ with pytest.raises(Exit):
68
+ require_repo("horizyn-api")
@@ -0,0 +1,102 @@
1
+ """Tests for dh hz test suite discovery and resolution."""
2
+
3
+ from pathlib import Path
4
+
5
+ import pytest
6
+ from click.exceptions import Exit
7
+
8
+ from dh_cli.hz.test import _discover_suites, _resolve_suites
9
+
10
+
11
+ @pytest.fixture
12
+ def test_dir(tmp_path):
13
+ """Create a fake test directory with numbered scripts."""
14
+ scripts = [
15
+ "1_fast.sh",
16
+ "2_full_sets.sh",
17
+ "3_custom_sets.sh",
18
+ "10_error_handling.sh",
19
+ "20_feature_bundle.sh",
20
+ "common.sh",
21
+ "run_all_tests.sh",
22
+ ]
23
+ for name in scripts:
24
+ (tmp_path / name).write_text("#!/bin/bash\necho test")
25
+ return tmp_path
26
+
27
+
28
+ class TestDiscoverSuites:
29
+ def test_finds_numbered_scripts(self, test_dir):
30
+ suites = _discover_suites(test_dir)
31
+ numbers = [s[0] for s in suites]
32
+ assert numbers == [1, 2, 3, 10, 20]
33
+
34
+ def test_extracts_names(self, test_dir):
35
+ suites = _discover_suites(test_dir)
36
+ names = [s[1] for s in suites]
37
+ assert "fast" in names
38
+ assert "full_sets" in names
39
+ assert "feature_bundle" in names
40
+
41
+ def test_excludes_non_numbered_scripts(self, test_dir):
42
+ suites = _discover_suites(test_dir)
43
+ names = [s[1] for s in suites]
44
+ assert "common" not in names
45
+ assert "run_all_tests" not in names
46
+
47
+ def test_returns_correct_paths(self, test_dir):
48
+ suites = _discover_suites(test_dir)
49
+ for num, name, path in suites:
50
+ assert path.exists()
51
+ assert path.name == f"{num}_{name}.sh"
52
+
53
+ def test_sorted_by_number(self, test_dir):
54
+ suites = _discover_suites(test_dir)
55
+ numbers = [s[0] for s in suites]
56
+ assert numbers == sorted(numbers)
57
+
58
+ def test_empty_directory(self, tmp_path):
59
+ assert _discover_suites(tmp_path) == []
60
+
61
+
62
+ class TestResolveSuites:
63
+ @pytest.fixture
64
+ def available(self, test_dir):
65
+ return _discover_suites(test_dir)
66
+
67
+ def test_resolve_by_number(self, available):
68
+ result = _resolve_suites(available, ["1"])
69
+ assert len(result) == 1
70
+ assert result[0][0] == 1
71
+
72
+ def test_resolve_multiple_numbers(self, available):
73
+ result = _resolve_suites(available, ["1", "3", "20"])
74
+ assert [s[0] for s in result] == [1, 3, 20]
75
+
76
+ def test_resolve_by_name(self, available):
77
+ result = _resolve_suites(available, ["fast"])
78
+ assert len(result) == 1
79
+ assert result[0][1] == "fast"
80
+
81
+ def test_resolve_by_name_substring(self, available):
82
+ result = _resolve_suites(available, ["error"])
83
+ assert len(result) == 1
84
+ assert result[0][1] == "error_handling"
85
+
86
+ def test_resolve_mixed_numbers_and_names(self, available):
87
+ result = _resolve_suites(available, ["1", "error"])
88
+ assert len(result) == 2
89
+ assert result[0][0] == 1
90
+ assert result[1][1] == "error_handling"
91
+
92
+ def test_unknown_selector_exits(self, available):
93
+ with pytest.raises(Exit):
94
+ _resolve_suites(available, ["999"])
95
+
96
+ def test_unknown_name_exits(self, available):
97
+ with pytest.raises(Exit):
98
+ _resolve_suites(available, ["nonexistent"])
99
+
100
+ def test_name_match_is_case_insensitive(self, available):
101
+ result = _resolve_suites(available, ["FAST"])
102
+ assert result[0][1] == "fast"
@@ -0,0 +1,208 @@
1
+ """Tests for dh hz users SSM management."""
2
+
3
+ from unittest.mock import MagicMock, patch
4
+
5
+ import pytest
6
+ from click.exceptions import Exit
7
+
8
+ from dh_cli.hz.users import _format_csv, _parse_csv
9
+
10
+
11
+ class TestParseCSV:
12
+ def test_parses_comma_separated(self):
13
+ assert _parse_csv("alice@x.com,bob@y.com") == {"alice@x.com", "bob@y.com"}
14
+
15
+ def test_strips_whitespace(self):
16
+ assert _parse_csv(" alice@x.com , bob@y.com ") == {"alice@x.com", "bob@y.com"}
17
+
18
+ def test_lowercases(self):
19
+ assert _parse_csv("Alice@X.COM") == {"alice@x.com"}
20
+
21
+ def test_empty_string(self):
22
+ assert _parse_csv("") == set()
23
+
24
+ def test_trailing_comma(self):
25
+ assert _parse_csv("alice@x.com,") == {"alice@x.com"}
26
+
27
+ def test_deduplicates(self):
28
+ assert _parse_csv("a@x.com,a@x.com") == {"a@x.com"}
29
+
30
+
31
+ class TestFormatCSV:
32
+ def test_sorts_alphabetically(self):
33
+ assert _format_csv({"bob@y.com", "alice@x.com"}) == "alice@x.com,bob@y.com"
34
+
35
+ def test_empty_set(self):
36
+ assert _format_csv(set()) == ""
37
+
38
+ def test_single_email(self):
39
+ assert _format_csv({"alice@x.com"}) == "alice@x.com"
40
+
41
+
42
+ class TestAddUser:
43
+ def _make_ssm(self, params: dict[str, str]):
44
+ """Create a mock SSM client with given param values."""
45
+ ssm = MagicMock()
46
+
47
+ not_found = type("ParameterNotFound", (Exception,), {})
48
+ ssm.exceptions.ParameterNotFound = not_found
49
+
50
+ def get_parameter(Name):
51
+ if Name in params:
52
+ return {"Parameter": {"Value": params[Name]}}
53
+ raise not_found()
54
+
55
+ ssm.get_parameter.side_effect = get_parameter
56
+
57
+ def put_parameter(Name, Value, Type, Overwrite):
58
+ params[Name] = Value
59
+
60
+ ssm.put_parameter.side_effect = put_parameter
61
+ return ssm
62
+
63
+ def test_add_beta_user(self):
64
+ params = {"/horizyn/prod/auth/allowed_emails": "existing@x.com"}
65
+ ssm = self._make_ssm(params)
66
+
67
+ with patch("dh_cli.hz.users._ssm_client", return_value=ssm):
68
+ from dh_cli.hz.users import add_user
69
+
70
+ # Invoke the typer command's underlying logic
71
+ ctx = MagicMock()
72
+ add_user("new@y.com", env="prod", tier="beta")
73
+
74
+ assert "new@y.com" in params["/horizyn/prod/auth/allowed_emails"]
75
+ assert "existing@x.com" in params["/horizyn/prod/auth/allowed_emails"]
76
+
77
+ def test_add_alpha_user(self):
78
+ params = {
79
+ "/horizyn/prod/auth/allowed_emails": "existing@x.com",
80
+ "/horizyn/prod/auth/alpha_emails": "",
81
+ }
82
+ ssm = self._make_ssm(params)
83
+
84
+ with patch("dh_cli.hz.users._ssm_client", return_value=ssm):
85
+ from dh_cli.hz.users import add_user
86
+
87
+ add_user("new@y.com", env="prod", tier="alpha")
88
+
89
+ assert "new@y.com" in params["/horizyn/prod/auth/allowed_emails"]
90
+ assert "new@y.com" in params["/horizyn/prod/auth/alpha_emails"]
91
+
92
+ def test_add_existing_user_is_idempotent(self):
93
+ params = {"/horizyn/prod/auth/allowed_emails": "existing@x.com"}
94
+ ssm = self._make_ssm(params)
95
+
96
+ with patch("dh_cli.hz.users._ssm_client", return_value=ssm):
97
+ from dh_cli.hz.users import add_user
98
+
99
+ add_user("existing@x.com", env="prod", tier="beta")
100
+
101
+ ssm.put_parameter.assert_not_called()
102
+
103
+
104
+ class TestRemoveUser:
105
+ def _make_ssm(self, params: dict[str, str]):
106
+ ssm = MagicMock()
107
+
108
+ not_found = type("ParameterNotFound", (Exception,), {})
109
+ ssm.exceptions.ParameterNotFound = not_found
110
+
111
+ def get_parameter(Name):
112
+ if Name in params:
113
+ return {"Parameter": {"Value": params[Name]}}
114
+ raise not_found()
115
+
116
+ ssm.get_parameter.side_effect = get_parameter
117
+
118
+ def put_parameter(Name, Value, Type, Overwrite):
119
+ params[Name] = Value
120
+
121
+ ssm.put_parameter.side_effect = put_parameter
122
+ return ssm
123
+
124
+ def test_remove_from_all_lists(self):
125
+ params = {
126
+ "/horizyn/prod/auth/allowed_emails": "alice@x.com,bob@y.com",
127
+ "/horizyn/prod/auth/admin_emails": "alice@x.com",
128
+ "/horizyn/prod/auth/alpha_emails": "alice@x.com",
129
+ }
130
+ ssm = self._make_ssm(params)
131
+
132
+ with patch("dh_cli.hz.users._ssm_client", return_value=ssm):
133
+ from dh_cli.hz.users import remove_user
134
+
135
+ remove_user("alice@x.com", env="prod")
136
+
137
+ assert "alice@x.com" not in params["/horizyn/prod/auth/allowed_emails"]
138
+ assert "bob@y.com" in params["/horizyn/prod/auth/allowed_emails"]
139
+ assert "alice@x.com" not in params["/horizyn/prod/auth/admin_emails"]
140
+ assert "alice@x.com" not in params["/horizyn/prod/auth/alpha_emails"]
141
+
142
+ def test_remove_nonexistent_user_is_noop(self):
143
+ params = {
144
+ "/horizyn/prod/auth/allowed_emails": "bob@y.com",
145
+ "/horizyn/prod/auth/admin_emails": "",
146
+ "/horizyn/prod/auth/alpha_emails": "",
147
+ }
148
+ ssm = self._make_ssm(params)
149
+
150
+ with patch("dh_cli.hz.users._ssm_client", return_value=ssm):
151
+ from dh_cli.hz.users import remove_user
152
+
153
+ remove_user("ghost@z.com", env="prod")
154
+
155
+ ssm.put_parameter.assert_not_called()
156
+
157
+
158
+ class TestPromoteUser:
159
+ def _make_ssm(self, params: dict[str, str]):
160
+ ssm = MagicMock()
161
+
162
+ not_found = type("ParameterNotFound", (Exception,), {})
163
+ ssm.exceptions.ParameterNotFound = not_found
164
+
165
+ def get_parameter(Name):
166
+ if Name in params:
167
+ return {"Parameter": {"Value": params[Name]}}
168
+ raise not_found()
169
+
170
+ ssm.get_parameter.side_effect = get_parameter
171
+
172
+ def put_parameter(Name, Value, Type, Overwrite):
173
+ params[Name] = Value
174
+
175
+ ssm.put_parameter.side_effect = put_parameter
176
+ return ssm
177
+
178
+ def test_promote_to_alpha(self):
179
+ params = {
180
+ "/horizyn/prod/auth/allowed_emails": "alice@x.com",
181
+ "/horizyn/prod/auth/alpha_emails": "",
182
+ }
183
+ ssm = self._make_ssm(params)
184
+
185
+ with patch("dh_cli.hz.users._ssm_client", return_value=ssm):
186
+ from dh_cli.hz.users import promote_user
187
+
188
+ promote_user("alice@x.com", tier="alpha", env="prod")
189
+
190
+ assert "alice@x.com" in params["/horizyn/prod/auth/alpha_emails"]
191
+
192
+ def test_promote_nonexistent_user_exits(self):
193
+ params = {
194
+ "/horizyn/prod/auth/allowed_emails": "bob@y.com",
195
+ }
196
+ ssm = self._make_ssm(params)
197
+
198
+ with patch("dh_cli.hz.users._ssm_client", return_value=ssm):
199
+ from dh_cli.hz.users import promote_user
200
+
201
+ with pytest.raises(Exit):
202
+ promote_user("ghost@z.com", tier="alpha", env="prod")
203
+
204
+ def test_promote_invalid_tier_exits(self):
205
+ with pytest.raises(Exit):
206
+ from dh_cli.hz.users import promote_user
207
+
208
+ promote_user("alice@x.com", tier="superadmin", env="prod")
File without changes
File without changes
File without changes
File without changes
File without changes