dh-cli 0.12.2__tar.gz → 0.12.4__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.
- {dh_cli-0.12.2 → dh_cli-0.12.4}/PKG-INFO +2 -2
- {dh_cli-0.12.2 → dh_cli-0.12.4}/README.md +1 -1
- {dh_cli-0.12.2 → dh_cli-0.12.4}/pyproject.toml +1 -1
- {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/engines_studios/engine_commands.py +94 -4
- dh_cli-0.12.4/src/dh_cli/engines_studios/retry.py +79 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/main.py +3 -5
- dh_cli-0.12.4/src/dh_cli/sweep.py +293 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/utility_commands.py +0 -32
- dh_cli-0.12.4/tests/engines_studios/test_engine_retry_commands.py +133 -0
- dh_cli-0.12.4/tests/engines_studios/test_retry.py +182 -0
- dh_cli-0.12.4/tests/github/__init__.py +0 -0
- dh_cli-0.12.4/tests/test_sweep.py +306 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/.gitignore +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/LICENSE +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/__init__.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/_identity.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/batch/__init__.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/batch/aws_batch.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/batch/commands/__init__.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/batch/commands/boltz.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/batch/commands/cancel.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/batch/commands/clean.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/batch/commands/disco.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/batch/commands/embed_t5.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/batch/commands/equilibrator.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/batch/commands/esmfold2.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/batch/commands/finalize.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/batch/commands/list_jobs.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/batch/commands/local.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/batch/commands/logs.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/batch/commands/orca.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/batch/commands/protmpnn.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/batch/commands/protmpnn_to_boltz.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/batch/commands/retry.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/batch/commands/rfd3.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/batch/commands/status.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/batch/commands/submit.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/batch/commands/wait_for.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/batch/fasta_utils.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/batch/h5_utils.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/batch/job_id.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/batch/manifest.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/batch/s3_transport.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/bedrock/__init__.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/bedrock/commands.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/bedrock/cost_report.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/bedrock/pricing.yaml +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/cloud_commands.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/codeartifact.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/engines_studios/__init__.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/engines_studios/api_client.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/engines_studios/auth.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/engines_studios/progress.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/engines_studios/ssh_config.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/engines_studios/studio_commands.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/github_commands.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/hz/__init__.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/hz/deploy.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/hz/local.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/hz/test.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/hz/tf.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/hz/users.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/warehouse.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/batch/__init__.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/batch/test_aws_batch_resources.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/batch/test_image_override.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/batch/test_no_train_command.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/batch/test_submit_cpu_only.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/batch/test_submit_image_validation.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/batch/test_submit_merge.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/batch/test_submit_mlflow_env.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/bedrock/conftest.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/bedrock/fixtures/A_cache_write.json +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/bedrock/fixtures/B_cache_read.json +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/bedrock/fixtures/C_plain.json +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/bedrock/fixtures/D_cursor_user.json +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/bedrock/fixtures/E_service_role.json +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/bedrock/fixtures/F_legacy_shared.json +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/bedrock/fixtures/G_unknown_model.json +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/bedrock/test_build_report.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/bedrock/test_classify_arn.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/bedrock/test_cli_exit_codes.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/bedrock/test_cost_calc.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/bedrock/test_cost_command.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/bedrock/test_cur_reconciliation.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/bedrock/test_key_command.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/bedrock/test_render_formats.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/bedrock/test_resolve_base_model.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/bedrock/test_rotate.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/bedrock/test_s3_walker.py +0 -0
- {dh_cli-0.12.2/tests/github → dh_cli-0.12.4/tests/engines_studios}/__init__.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/github/conftest.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/github/test_engine_role_cannot_read_github_pat.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/github/test_identity.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/github/test_login.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/github/test_login_error_paths.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/github/test_login_security.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/github/test_logout.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/github/test_rotate.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/github/test_status.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/hz/test_init.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/hz/test_suites.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/hz/test_users.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/test_cloud_gcp.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/test_disco_command.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/test_disco_jobdef.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/test_equilibrator_command.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/test_esmfold2_cli.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/test_finalize_boltz_tar.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/test_finalize_disco.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/test_finalize_disco_dispatch.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/test_finalize_equilibrator.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/test_finalize_esmfold2.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/test_finalize_protmpnn.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/test_finalize_rfd3.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/test_rfd3_command.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dh-cli
|
|
3
|
-
Version: 0.12.
|
|
3
|
+
Version: 0.12.4
|
|
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
|
|
@@ -166,7 +166,7 @@ dh --version
|
|
|
166
166
|
| `dh gh` | GitHub authentication |
|
|
167
167
|
| `dh wget` | Download from data warehouse |
|
|
168
168
|
| `dh wheel` | Build and publish packages |
|
|
169
|
-
| `dh
|
|
169
|
+
| `dh sweep` | Clean up merged branches across all workspace repos |
|
|
170
170
|
|
|
171
171
|
## Requirements
|
|
172
172
|
|
|
@@ -27,7 +27,7 @@ dh --version
|
|
|
27
27
|
| `dh gh` | GitHub authentication |
|
|
28
28
|
| `dh wget` | Download from data warehouse |
|
|
29
29
|
| `dh wheel` | Build and publish packages |
|
|
30
|
-
| `dh
|
|
30
|
+
| `dh sweep` | Clean up merged branches across all workspace repos |
|
|
31
31
|
|
|
32
32
|
## Requirements
|
|
33
33
|
|
|
@@ -8,8 +8,65 @@ import click
|
|
|
8
8
|
from .api_client import StudioManagerClient
|
|
9
9
|
from .auth import check_aws_auth, detect_aws_environment, get_aws_username
|
|
10
10
|
from .progress import format_idle_state, format_time_ago, wait_with_progress
|
|
11
|
+
from .retry import retry_on_capacity
|
|
11
12
|
from .ssh_config import update_ssh_config_silent
|
|
12
13
|
|
|
14
|
+
# Default capacity-retry budget: transient InsufficientInstanceCapacity in our
|
|
15
|
+
# G-family AZs usually clears within a few minutes (see topic-quota-and-caps.md).
|
|
16
|
+
_DEFAULT_RETRY_TIMEOUT = 600 # seconds (~10 min)
|
|
17
|
+
_DEFAULT_RETRY_INTERVAL = 30 # seconds
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _retry_options(func):
|
|
21
|
+
"""Shared --retry / --no-retry / interval options for launch and start."""
|
|
22
|
+
func = click.option(
|
|
23
|
+
"--no-retry",
|
|
24
|
+
is_flag=True,
|
|
25
|
+
help="Fail immediately on capacity errors instead of retrying.",
|
|
26
|
+
)(func)
|
|
27
|
+
func = click.option(
|
|
28
|
+
"--retry-timeout",
|
|
29
|
+
type=int,
|
|
30
|
+
default=_DEFAULT_RETRY_TIMEOUT,
|
|
31
|
+
show_default=True,
|
|
32
|
+
help="Max seconds to keep retrying transient capacity errors (0 disables).",
|
|
33
|
+
)(func)
|
|
34
|
+
func = click.option(
|
|
35
|
+
"--retry-interval",
|
|
36
|
+
type=int,
|
|
37
|
+
default=_DEFAULT_RETRY_INTERVAL,
|
|
38
|
+
show_default=True,
|
|
39
|
+
help="Seconds to wait between capacity-retry attempts.",
|
|
40
|
+
)(func)
|
|
41
|
+
return func
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _make_capacity_retry(engine_type: str, no_retry: bool, timeout: int, interval: int):
|
|
45
|
+
"""Build a retry wrapper that echoes status between capacity retries.
|
|
46
|
+
|
|
47
|
+
Returns a callable taking a zero-arg `action` and running it with bounded
|
|
48
|
+
capacity-retry. With `no_retry` (or timeout 0) it runs the action once.
|
|
49
|
+
"""
|
|
50
|
+
effective_timeout = 0 if no_retry else timeout
|
|
51
|
+
|
|
52
|
+
def on_retry(attempt: int, error: BaseException, wait_seconds: float) -> None:
|
|
53
|
+
click.echo(
|
|
54
|
+
f"⏳ Attempt {attempt} failed: no {engine_type} capacity available "
|
|
55
|
+
f"right now (AWS-side). Retrying in {int(wait_seconds)}s… "
|
|
56
|
+
f"(Ctrl+C to cancel)",
|
|
57
|
+
err=True,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def run(action):
|
|
61
|
+
return retry_on_capacity(
|
|
62
|
+
action,
|
|
63
|
+
timeout=effective_timeout,
|
|
64
|
+
interval=interval,
|
|
65
|
+
on_retry=on_retry,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
return run
|
|
69
|
+
|
|
13
70
|
|
|
14
71
|
@click.group()
|
|
15
72
|
def engine_cli():
|
|
@@ -44,6 +101,7 @@ def engine_cli():
|
|
|
44
101
|
default=None,
|
|
45
102
|
help="Environment (dev, sand, prod) - auto-detected if not specified",
|
|
46
103
|
)
|
|
104
|
+
@_retry_options
|
|
47
105
|
def launch_engine(
|
|
48
106
|
name: str,
|
|
49
107
|
engine_type: str,
|
|
@@ -53,6 +111,9 @@ def launch_engine(
|
|
|
53
111
|
no_wait: bool,
|
|
54
112
|
skip_ssh_config: bool,
|
|
55
113
|
env: Optional[str],
|
|
114
|
+
retry_interval: int,
|
|
115
|
+
retry_timeout: int,
|
|
116
|
+
no_retry: bool,
|
|
56
117
|
):
|
|
57
118
|
"""Launch a new engine for the current user (or specified user with --user flag)."""
|
|
58
119
|
|
|
@@ -83,8 +144,13 @@ def launch_engine(
|
|
|
83
144
|
click.echo(f"🚀 Launching {engine_type} engine '{name}' for {user}...")
|
|
84
145
|
|
|
85
146
|
try:
|
|
86
|
-
# Launch the engine
|
|
87
|
-
|
|
147
|
+
# Launch the engine, retrying transient AWS capacity errors by default.
|
|
148
|
+
retry = _make_capacity_retry(engine_type, no_retry, retry_timeout, retry_interval)
|
|
149
|
+
engine = retry(
|
|
150
|
+
lambda: client.launch_engine(
|
|
151
|
+
name=name, user=user, engine_type=engine_type, boot_disk_size=boot_disk_size
|
|
152
|
+
)
|
|
153
|
+
)
|
|
88
154
|
|
|
89
155
|
engine_id = engine["instance_id"]
|
|
90
156
|
click.echo(f"✓ EC2 instance launched: {engine_id}")
|
|
@@ -128,6 +194,10 @@ def launch_engine(
|
|
|
128
194
|
if update_ssh_config_silent(client, env):
|
|
129
195
|
click.echo("✓ SSH config updated")
|
|
130
196
|
|
|
197
|
+
except KeyboardInterrupt:
|
|
198
|
+
click.echo("\n✗ Cancelled while waiting for capacity.", err=True)
|
|
199
|
+
raise click.Abort()
|
|
200
|
+
|
|
131
201
|
except Exception as e:
|
|
132
202
|
error_msg = str(e)
|
|
133
203
|
|
|
@@ -197,7 +267,17 @@ def launch_engine(
|
|
|
197
267
|
default=None,
|
|
198
268
|
help="Environment (dev, sand, prod) - auto-detected if not specified",
|
|
199
269
|
)
|
|
200
|
-
|
|
270
|
+
@_retry_options
|
|
271
|
+
def start_engine(
|
|
272
|
+
name_or_id: str,
|
|
273
|
+
no_wait: bool,
|
|
274
|
+
skip_ssh_config: bool,
|
|
275
|
+
yes: bool,
|
|
276
|
+
env: Optional[str],
|
|
277
|
+
retry_interval: int,
|
|
278
|
+
retry_timeout: int,
|
|
279
|
+
no_retry: bool,
|
|
280
|
+
):
|
|
201
281
|
"""Start a stopped engine."""
|
|
202
282
|
|
|
203
283
|
# Check AWS auth first to provide clear error messages
|
|
@@ -225,7 +305,13 @@ def start_engine(name_or_id: str, no_wait: bool, skip_ssh_config: bool, yes: boo
|
|
|
225
305
|
engine_id = engine["instance_id"]
|
|
226
306
|
engine_name = engine.get("name", engine_id)
|
|
227
307
|
|
|
228
|
-
|
|
308
|
+
# Start the instance, retrying transient AWS capacity errors by default.
|
|
309
|
+
# Note: a stopped engine restarts in its ORIGINAL AZ, so retry here is a
|
|
310
|
+
# time-wait for that one AZ's capacity to return — it cannot fall to
|
|
311
|
+
# another AZ (unlike launch).
|
|
312
|
+
engine_type_label = engine.get("engine_type", "engine")
|
|
313
|
+
retry = _make_capacity_retry(engine_type_label, no_retry, retry_timeout, retry_interval)
|
|
314
|
+
result = retry(lambda: client.start_engine(engine_id))
|
|
229
315
|
|
|
230
316
|
if "error" in result:
|
|
231
317
|
click.echo(f"✗ Error: {result['error']}", err=True)
|
|
@@ -305,6 +391,10 @@ def start_engine(name_or_id: str, no_wait: bool, skip_ssh_config: bool, yes: boo
|
|
|
305
391
|
if update_ssh_config_silent(client, env):
|
|
306
392
|
click.echo("✓ SSH config updated")
|
|
307
393
|
|
|
394
|
+
except KeyboardInterrupt:
|
|
395
|
+
click.echo("\n✗ Cancelled while waiting for capacity.", err=True)
|
|
396
|
+
raise click.Abort()
|
|
397
|
+
|
|
308
398
|
except Exception as e:
|
|
309
399
|
click.echo(f"✗ Error: {e}", err=True)
|
|
310
400
|
raise click.Abort()
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Bounded capacity-retry for engine launch/start.
|
|
2
|
+
|
|
3
|
+
AWS periodically has no on-demand G-family capacity in our AZs, surfacing as
|
|
4
|
+
`InsufficientInstanceCapacity` (and, when a subnet is IP-starved,
|
|
5
|
+
`InsufficientFreeAddressesInSubnet`). These are transient: capacity returns
|
|
6
|
+
within minutes. This helper retries such failures within a wall-clock budget
|
|
7
|
+
so users (and agents) don't have to re-run the command by hand.
|
|
8
|
+
|
|
9
|
+
Quota errors (`VcpuLimitExceeded`), instance-count limits
|
|
10
|
+
(`InstanceLimitExceeded`), and validation errors are NOT capacity problems and
|
|
11
|
+
are never retried — they will not resolve by waiting.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import time
|
|
17
|
+
from typing import Callable, Optional, TypeVar
|
|
18
|
+
|
|
19
|
+
T = TypeVar("T")
|
|
20
|
+
|
|
21
|
+
# Substrings that mark a transient, retryable AWS capacity/IP shortage.
|
|
22
|
+
_CAPACITY_MARKERS = (
|
|
23
|
+
"InsufficientInstanceCapacity",
|
|
24
|
+
"InsufficientFreeAddressesInSubnet",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def is_capacity_error(error: BaseException) -> bool:
|
|
29
|
+
"""True if `error` is a transient AWS capacity/IP shortage worth retrying."""
|
|
30
|
+
message = str(error)
|
|
31
|
+
return any(marker in message for marker in _CAPACITY_MARKERS)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def retry_on_capacity(
|
|
35
|
+
action: Callable[[], T],
|
|
36
|
+
*,
|
|
37
|
+
timeout: float,
|
|
38
|
+
interval: float,
|
|
39
|
+
on_retry: Optional[Callable[[int, BaseException, float], None]] = None,
|
|
40
|
+
monotonic: Callable[[], float] = time.monotonic,
|
|
41
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
42
|
+
) -> T:
|
|
43
|
+
"""Call `action`, retrying transient capacity errors within a budget.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
action: Zero-arg callable performing the launch/start attempt.
|
|
47
|
+
timeout: Total wall-clock budget in seconds. A new attempt is only
|
|
48
|
+
started while elapsed time is strictly below this. ``0`` yields a
|
|
49
|
+
single attempt (no retries).
|
|
50
|
+
interval: Seconds to wait between attempts.
|
|
51
|
+
on_retry: Optional callback invoked before each sleep with
|
|
52
|
+
``(attempt_number, error, wait_seconds)`` — used to print status.
|
|
53
|
+
monotonic: Monotonic clock source (injected for testing).
|
|
54
|
+
sleep: Sleep function (injected for testing).
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
Whatever `action` returns on its first success.
|
|
58
|
+
|
|
59
|
+
Raises:
|
|
60
|
+
The last capacity error if the budget is exhausted, or immediately
|
|
61
|
+
re-raises any non-capacity error from `action`.
|
|
62
|
+
"""
|
|
63
|
+
start = monotonic()
|
|
64
|
+
attempt = 0
|
|
65
|
+
last_error: Optional[BaseException] = None
|
|
66
|
+
while True:
|
|
67
|
+
attempt += 1
|
|
68
|
+
try:
|
|
69
|
+
return action()
|
|
70
|
+
except Exception as error: # noqa: BLE001 — re-raised below unless retryable
|
|
71
|
+
if not is_capacity_error(error):
|
|
72
|
+
raise
|
|
73
|
+
last_error = error
|
|
74
|
+
# Stop if the next attempt would not begin within the budget.
|
|
75
|
+
if (monotonic() - start) + interval >= timeout:
|
|
76
|
+
raise last_error
|
|
77
|
+
if on_retry is not None:
|
|
78
|
+
on_retry(attempt, error, interval)
|
|
79
|
+
sleep(interval)
|
|
@@ -6,10 +6,8 @@ import typer
|
|
|
6
6
|
|
|
7
7
|
from dh_cli.cloud_commands import aws_app, gcp_app
|
|
8
8
|
from dh_cli.github_commands import gh_app
|
|
9
|
-
from dh_cli.
|
|
10
|
-
|
|
11
|
-
delete_local_branch,
|
|
12
|
-
)
|
|
9
|
+
from dh_cli.sweep import sweep
|
|
10
|
+
from dh_cli.utility_commands import build_and_upload_wheel
|
|
13
11
|
from dh_cli.warehouse import get_from_warehouse_typer
|
|
14
12
|
|
|
15
13
|
|
|
@@ -26,7 +24,7 @@ app = typer.Typer(
|
|
|
26
24
|
)
|
|
27
25
|
|
|
28
26
|
# Utility commands
|
|
29
|
-
app.command("
|
|
27
|
+
app.command("sweep")(sweep)
|
|
30
28
|
app.command("wget")(get_from_warehouse_typer)
|
|
31
29
|
|
|
32
30
|
# Cloud commands
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""Post-merge branch cleanup across every repo in the workspace.
|
|
2
|
+
|
|
3
|
+
`dh sweep` takes no arguments: the work to do is derived entirely from
|
|
4
|
+
origin state. A local branch whose upstream has been deleted (`[gone]`
|
|
5
|
+
after `git fetch -p`) is a cleanup candidate; it is only deleted once a
|
|
6
|
+
merged PR for it is confirmed via `gh`. Anything unproven or unsafe is
|
|
7
|
+
skipped and reported.
|
|
8
|
+
|
|
9
|
+
Because the outcome depends only on origin, the command is idempotent and
|
|
10
|
+
convergent -- running it on several machines needs no coordination.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import subprocess
|
|
17
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
import typer
|
|
22
|
+
|
|
23
|
+
FETCH_WORKERS = 8
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class RepoReport:
|
|
28
|
+
"""What happened (or didn't) in one repo."""
|
|
29
|
+
|
|
30
|
+
name: str
|
|
31
|
+
deleted: list[str] = field(default_factory=list)
|
|
32
|
+
skipped: list[tuple[str, str]] = field(default_factory=list)
|
|
33
|
+
advanced: str | None = None
|
|
34
|
+
note: str | None = None
|
|
35
|
+
error: str | None = None
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def changed(self) -> bool:
|
|
39
|
+
return bool(self.deleted) or self.advanced is not None
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def quiet(self) -> bool:
|
|
43
|
+
return not self.changed and not self.skipped and self.note is None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _git(repo: Path, *args: str) -> subprocess.CompletedProcess:
|
|
47
|
+
return subprocess.run(
|
|
48
|
+
["git", *args],
|
|
49
|
+
cwd=repo,
|
|
50
|
+
capture_output=True,
|
|
51
|
+
text=True,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _git_out(repo: Path, *args: str) -> str:
|
|
56
|
+
return _git(repo, *args).stdout.strip()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def discover_repos(root: Path) -> list[Path]:
|
|
60
|
+
"""Return immediate subdirectories of root that are distinct git repos.
|
|
61
|
+
|
|
62
|
+
Linked worktrees share their parent's ref store, so sweeping them
|
|
63
|
+
separately both races on concurrent fetch and double-counts branches.
|
|
64
|
+
Only one checkout per repo is returned, preferring the primary one.
|
|
65
|
+
"""
|
|
66
|
+
found: dict[str, Path] = {}
|
|
67
|
+
for path in sorted(p for p in root.iterdir() if (p / ".git").exists()):
|
|
68
|
+
common = _git_out(path, "rev-parse", "--path-format=absolute", "--git-common-dir")
|
|
69
|
+
key = common or str(path)
|
|
70
|
+
# A primary checkout owns its common dir; a linked worktree points elsewhere.
|
|
71
|
+
if key not in found or Path(key).is_relative_to(path):
|
|
72
|
+
found[key] = path
|
|
73
|
+
return sorted(found.values())
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def default_branch(repo: Path) -> str | None:
|
|
77
|
+
"""Resolve the repo's default branch from origin/HEAD, else main/master."""
|
|
78
|
+
head = _git_out(repo, "symbolic-ref", "--quiet", "refs/remotes/origin/HEAD")
|
|
79
|
+
if head.startswith("refs/remotes/origin/"):
|
|
80
|
+
return head.rsplit("/", 1)[-1]
|
|
81
|
+
for candidate in ("main", "master"):
|
|
82
|
+
if _git(repo, "show-ref", "--verify", "--quiet", f"refs/remotes/origin/{candidate}").returncode == 0:
|
|
83
|
+
return candidate
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def gone_branches(repo: Path) -> list[str]:
|
|
88
|
+
"""Local branches whose upstream no longer exists on the remote."""
|
|
89
|
+
out = _git_out(
|
|
90
|
+
repo,
|
|
91
|
+
"for-each-ref",
|
|
92
|
+
"--format=%(refname:short)%09%(upstream:track)",
|
|
93
|
+
"refs/heads",
|
|
94
|
+
)
|
|
95
|
+
gone = []
|
|
96
|
+
for line in out.splitlines():
|
|
97
|
+
name, _, track = line.partition("\t")
|
|
98
|
+
if "[gone]" in track:
|
|
99
|
+
gone.append(name)
|
|
100
|
+
return gone
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def is_dirty(repo: Path) -> bool:
|
|
104
|
+
"""True if the working tree has staged, unstaged, or untracked changes."""
|
|
105
|
+
return bool(_git_out(repo, "status", "--porcelain"))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def pr_is_merged(repo: Path, branch: str) -> bool | None:
|
|
109
|
+
"""Whether branch has a merged PR. None if gh cannot answer."""
|
|
110
|
+
result = subprocess.run(
|
|
111
|
+
[
|
|
112
|
+
"gh",
|
|
113
|
+
"pr",
|
|
114
|
+
"list",
|
|
115
|
+
"--head",
|
|
116
|
+
branch,
|
|
117
|
+
"--state",
|
|
118
|
+
"merged",
|
|
119
|
+
"--limit",
|
|
120
|
+
"1",
|
|
121
|
+
"--json",
|
|
122
|
+
"number",
|
|
123
|
+
],
|
|
124
|
+
cwd=repo,
|
|
125
|
+
capture_output=True,
|
|
126
|
+
text=True,
|
|
127
|
+
)
|
|
128
|
+
if result.returncode != 0:
|
|
129
|
+
return None
|
|
130
|
+
body = result.stdout.strip()
|
|
131
|
+
if not body:
|
|
132
|
+
return None
|
|
133
|
+
return body != "[]"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def fetch_pruned(repo: Path) -> str | None:
|
|
137
|
+
"""Fetch with prune. Returns an error message on failure."""
|
|
138
|
+
result = _git(repo, "fetch", "--prune", "--quiet")
|
|
139
|
+
if result.returncode == 0:
|
|
140
|
+
return None
|
|
141
|
+
lines = (result.stderr or result.stdout).strip().splitlines()
|
|
142
|
+
return lines[-1] if lines else "fetch failed"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _advance_default(repo: Path, default: str, current: str) -> str | None:
|
|
146
|
+
"""Fast-forward the local default branch to origin. Returns new short sha."""
|
|
147
|
+
before = _git_out(repo, "rev-parse", "--short", default)
|
|
148
|
+
if current == default:
|
|
149
|
+
result = _git(repo, "merge", "--ff-only", f"origin/{default}")
|
|
150
|
+
else:
|
|
151
|
+
# Updates the ref without a checkout; git refuses if not a fast-forward.
|
|
152
|
+
result = _git(repo, "fetch", "origin", f"{default}:{default}")
|
|
153
|
+
if result.returncode != 0:
|
|
154
|
+
return None
|
|
155
|
+
after = _git_out(repo, "rev-parse", "--short", default)
|
|
156
|
+
return after if after != before else None
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _behind_count(repo: Path, default: str) -> int:
|
|
160
|
+
"""How many commits origin/<default> is ahead of the local default branch."""
|
|
161
|
+
out = _git_out(repo, "rev-list", "--count", f"{default}..origin/{default}")
|
|
162
|
+
return int(out) if out.isdigit() else 0
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def sweep_repo(repo: Path, *, dry_run: bool = False) -> RepoReport:
|
|
166
|
+
"""Clean up one repo. Assumes fetch --prune has already run."""
|
|
167
|
+
report = RepoReport(name=repo.name)
|
|
168
|
+
|
|
169
|
+
default = default_branch(repo)
|
|
170
|
+
if default is None:
|
|
171
|
+
report.error = "no origin default branch"
|
|
172
|
+
return report
|
|
173
|
+
|
|
174
|
+
candidates = gone_branches(repo)
|
|
175
|
+
current = _git_out(repo, "branch", "--show-current")
|
|
176
|
+
dirty = is_dirty(repo)
|
|
177
|
+
|
|
178
|
+
for branch in candidates:
|
|
179
|
+
if branch == default:
|
|
180
|
+
report.skipped.append((branch, "is the default branch"))
|
|
181
|
+
continue
|
|
182
|
+
|
|
183
|
+
merged = pr_is_merged(repo, branch)
|
|
184
|
+
if merged is None:
|
|
185
|
+
report.skipped.append((branch, "no merged PR found (gh could not confirm)"))
|
|
186
|
+
continue
|
|
187
|
+
if not merged:
|
|
188
|
+
report.skipped.append((branch, "no merged PR — may hold unmerged work"))
|
|
189
|
+
continue
|
|
190
|
+
|
|
191
|
+
if branch == current:
|
|
192
|
+
if dirty:
|
|
193
|
+
report.skipped.append((branch, "checked out with uncommitted changes"))
|
|
194
|
+
continue
|
|
195
|
+
if not dry_run:
|
|
196
|
+
if _git(repo, "switch", "--quiet", default).returncode != 0:
|
|
197
|
+
report.skipped.append((branch, f"could not switch to {default}"))
|
|
198
|
+
continue
|
|
199
|
+
current = default
|
|
200
|
+
|
|
201
|
+
if dry_run:
|
|
202
|
+
report.deleted.append(branch)
|
|
203
|
+
continue
|
|
204
|
+
|
|
205
|
+
# -D is required: a squash-merged branch is never "fully merged" to git.
|
|
206
|
+
result = _git(repo, "branch", "-D", branch)
|
|
207
|
+
if result.returncode != 0:
|
|
208
|
+
report.skipped.append((branch, (result.stderr or "delete failed").strip().splitlines()[0]))
|
|
209
|
+
else:
|
|
210
|
+
report.deleted.append(branch)
|
|
211
|
+
|
|
212
|
+
if not (dirty and current == default):
|
|
213
|
+
if dry_run:
|
|
214
|
+
behind = _behind_count(repo, default)
|
|
215
|
+
if behind:
|
|
216
|
+
report.advanced = f"would fast-forward {default} ({behind} commit(s) behind)"
|
|
217
|
+
else:
|
|
218
|
+
new_sha = _advance_default(repo, default, current)
|
|
219
|
+
if new_sha:
|
|
220
|
+
report.advanced = f"{default} → {new_sha}"
|
|
221
|
+
else:
|
|
222
|
+
behind = _behind_count(repo, default)
|
|
223
|
+
if behind:
|
|
224
|
+
report.note = f"{default} is {behind} commit(s) behind — uncommitted changes, left alone"
|
|
225
|
+
|
|
226
|
+
return report
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _render(reports: list[RepoReport], dry_run: bool) -> None:
|
|
230
|
+
verb = "would delete" if dry_run else "deleted"
|
|
231
|
+
quiet = []
|
|
232
|
+
|
|
233
|
+
for report in reports:
|
|
234
|
+
if report.error:
|
|
235
|
+
typer.echo(f" {report.name}: {report.error}")
|
|
236
|
+
continue
|
|
237
|
+
if report.quiet:
|
|
238
|
+
quiet.append(report.name)
|
|
239
|
+
continue
|
|
240
|
+
typer.echo(f" {report.name}")
|
|
241
|
+
for branch in report.deleted:
|
|
242
|
+
typer.echo(f" {verb} {branch}")
|
|
243
|
+
if report.advanced:
|
|
244
|
+
typer.echo(f" {report.advanced}")
|
|
245
|
+
if report.note:
|
|
246
|
+
typer.echo(f" {report.note}")
|
|
247
|
+
for branch, reason in report.skipped:
|
|
248
|
+
typer.echo(f" kept {branch} — {reason}")
|
|
249
|
+
|
|
250
|
+
if quiet:
|
|
251
|
+
typer.echo(f" nothing to do: {', '.join(quiet)}")
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def sweep(
|
|
255
|
+
dry_run: bool = typer.Option(
|
|
256
|
+
False,
|
|
257
|
+
"--dry-run",
|
|
258
|
+
"-n",
|
|
259
|
+
help="Report what would change without touching anything.",
|
|
260
|
+
),
|
|
261
|
+
) -> None:
|
|
262
|
+
"""Delete merged branches and fast-forward main in every workspace repo.
|
|
263
|
+
|
|
264
|
+
Derived entirely from origin state, so it is safe to re-run and needs
|
|
265
|
+
no coordination between machines. Branches without a confirmed merged
|
|
266
|
+
PR are always kept.
|
|
267
|
+
"""
|
|
268
|
+
root = Path(os.environ.get("WORKSPACE_ROOT", "/workspaces/code"))
|
|
269
|
+
if not root.is_dir():
|
|
270
|
+
typer.echo(f"Workspace root not found: {root}")
|
|
271
|
+
raise typer.Exit(1)
|
|
272
|
+
|
|
273
|
+
repos = discover_repos(root)
|
|
274
|
+
if not repos:
|
|
275
|
+
typer.echo(f"No git repos found in {root}")
|
|
276
|
+
raise typer.Exit(1)
|
|
277
|
+
|
|
278
|
+
typer.echo(f"Fetching {len(repos)} repos...")
|
|
279
|
+
with ThreadPoolExecutor(max_workers=FETCH_WORKERS) as pool:
|
|
280
|
+
fetch_errors = dict(zip(repos, pool.map(fetch_pruned, repos)))
|
|
281
|
+
|
|
282
|
+
reports = []
|
|
283
|
+
for repo in repos:
|
|
284
|
+
if fetch_errors[repo]:
|
|
285
|
+
reports.append(RepoReport(name=repo.name, error=fetch_errors[repo]))
|
|
286
|
+
else:
|
|
287
|
+
reports.append(sweep_repo(repo, dry_run=dry_run))
|
|
288
|
+
|
|
289
|
+
_render(reports, dry_run)
|
|
290
|
+
|
|
291
|
+
kept = sum(len(r.skipped) for r in reports)
|
|
292
|
+
if kept:
|
|
293
|
+
typer.echo(f"\n{kept} branch(es) kept for review.")
|
|
@@ -11,38 +11,6 @@ import toml
|
|
|
11
11
|
# Import cloud helper lazily inside functions to avoid heavy deps at module load
|
|
12
12
|
|
|
13
13
|
|
|
14
|
-
def delete_local_branch(branch_name: str, folder_path: str):
|
|
15
|
-
"""Delete a local Git branch after fetching with pruning.
|
|
16
|
-
|
|
17
|
-
Args:
|
|
18
|
-
branch_name: Name of the branch to delete
|
|
19
|
-
folder_path: Path to the git repository folder
|
|
20
|
-
"""
|
|
21
|
-
try:
|
|
22
|
-
# Store current working directory
|
|
23
|
-
original_dir = os.getcwd()
|
|
24
|
-
|
|
25
|
-
# Change to the specified directory
|
|
26
|
-
os.chdir(folder_path)
|
|
27
|
-
print(f"Changed to directory: {folder_path}")
|
|
28
|
-
|
|
29
|
-
# Delete the specified branch
|
|
30
|
-
delete_branch_cmd = ["git", "branch", "-D", branch_name]
|
|
31
|
-
subprocess.run(delete_branch_cmd, check=True)
|
|
32
|
-
print(f"Deleted branch: {branch_name}")
|
|
33
|
-
|
|
34
|
-
# Fetch changes from the remote repository and prune obsolete branches
|
|
35
|
-
fetch_prune_cmd = ["git", "fetch", "-p"]
|
|
36
|
-
subprocess.run(fetch_prune_cmd, check=True)
|
|
37
|
-
print("Fetched changes and pruned obsolete branches")
|
|
38
|
-
|
|
39
|
-
except subprocess.CalledProcessError as e:
|
|
40
|
-
print(f"Error occurred while running Git commands: {e}")
|
|
41
|
-
finally:
|
|
42
|
-
# Always return to the original directory
|
|
43
|
-
os.chdir(original_dir)
|
|
44
|
-
|
|
45
|
-
|
|
46
14
|
def get_current_version_from_toml(file_path="pyproject.toml"):
|
|
47
15
|
"""Reads the version from a pyproject.toml file."""
|
|
48
16
|
try:
|