bmsdna-devtools 0.2.0__py3-none-any.whl

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.
File without changes
@@ -0,0 +1,37 @@
1
+ """Azure DevOps auth: explicit PAT, or fall back to the caller's `az` login."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import subprocess
7
+ import sys
8
+
9
+ from .cli_tools import require_az
10
+
11
+ # Well-known Azure DevOps resource ID for `az account get-access-token`.
12
+ ADO_RESOURCE_ID = "499b84ac-1321-427f-aa17-267ca6975798"
13
+
14
+
15
+ def get_az_devops_token() -> str:
16
+ az = require_az()
17
+ result = subprocess.run(
18
+ [az, "account", "get-access-token", "--resource", ADO_RESOURCE_ID, "--query", "accessToken", "-o", "tsv"],
19
+ capture_output=True,
20
+ encoding="utf-8",
21
+ )
22
+ if result.returncode != 0:
23
+ sys.exit(f"az login required and no PAT provided.\n{result.stderr.strip()}")
24
+ return result.stdout.strip()
25
+
26
+
27
+ def auth_header(pat: str | None) -> dict[str, str]:
28
+ """Basic-auth header for an explicit PAT, else Bearer via `az` token.
29
+
30
+ Never hardcode a PAT literal in a caller — pass it in from an env var
31
+ (e.g. AZURE_DEVOPS_PAT) or a CLI flag, or omit it and let `az` supply a
32
+ short-lived token from the operator's own login.
33
+ """
34
+ if pat:
35
+ token = base64.b64encode(f":{pat}".encode()).decode()
36
+ return {"Authorization": f"Basic {token}"}
37
+ return {"Authorization": f"Bearer {get_az_devops_token()}"}
@@ -0,0 +1,76 @@
1
+ """Download an Azure App Service log archive and filter it for errors/warnings.
2
+
3
+ Pulls the log zip via `az webapp log download`, unpacks it in memory, and
4
+ writes every line matching common error/warning markers to a filtered file.
5
+ The webapp/resource-group/slot for a given `--env` come from the consuming
6
+ repo's pyproject.toml (see env_config.py) rather than being baked in here.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import pathlib
12
+ import re
13
+ import subprocess
14
+ import sys
15
+ import zipfile
16
+
17
+ from .cli_tools import require_az
18
+
19
+ # Matches common error/warning markers across granian, uvicorn, and python tracebacks.
20
+ ERROR_RE = re.compile(r"\b(ERROR|CRITICAL|WARNING|Traceback|Exception|\b[45]\d\d\b|FAILED|FATAL)\b")
21
+
22
+
23
+ def run_az(args: list[str]) -> str:
24
+ az = require_az()
25
+ proc = subprocess.run([az, *args], capture_output=True, encoding="utf-8")
26
+ if proc.returncode != 0:
27
+ sys.exit(proc.stderr.strip() or proc.stdout.strip())
28
+ return proc.stdout
29
+
30
+
31
+ def download_logs(webapp: str, resource_group: str, slot: str, archive: pathlib.Path) -> None:
32
+ print(f"Downloading logs for {webapp}/{slot}...", flush=True)
33
+ run_az([
34
+ "webapp", "log", "download",
35
+ "--name", webapp,
36
+ "--resource-group", resource_group,
37
+ "--slot", slot,
38
+ "--log-file", str(archive),
39
+ ])
40
+
41
+
42
+ def extract_errors(archive: pathlib.Path, error_file: pathlib.Path) -> int:
43
+ """Unzip the archive and write all matching lines to error_file. Returns count."""
44
+ count = 0
45
+ with zipfile.ZipFile(archive) as zf, error_file.open("w") as out:
46
+ for member in zf.namelist():
47
+ if member.endswith("/"):
48
+ continue
49
+ with zf.open(member) as fh:
50
+ for raw in fh:
51
+ line = raw.decode("utf-8", "replace")
52
+ if ERROR_RE.search(line):
53
+ out.write(f"{member}: {line}")
54
+ count += 1
55
+ return count
56
+
57
+
58
+ def fetch(
59
+ webapp: str,
60
+ resource_group: str,
61
+ slot: str,
62
+ out_dir: pathlib.Path,
63
+ keep_archive: bool = False,
64
+ ) -> pathlib.Path:
65
+ out_dir.mkdir(parents=True, exist_ok=True)
66
+ archive = out_dir / f"{slot}_logs.zip"
67
+ error_file = out_dir / f"{slot}_errors.log"
68
+
69
+ download_logs(webapp, resource_group, slot, archive)
70
+ count = extract_errors(archive, error_file)
71
+
72
+ if not keep_archive:
73
+ archive.unlink()
74
+
75
+ print(f"✓ {count} error/warning lines → {error_file}")
76
+ return error_file
bmsdna/devtools/cli.py ADDED
@@ -0,0 +1,138 @@
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ import typer
8
+
9
+ from . import app_service_logs, commit as commit_mod
10
+ from . import env_config
11
+ from . import gh_pr
12
+ from . import logs as logs_mod
13
+ from . import pr_build, worktree as worktree_mod
14
+ from .cli_tools import require_az, require_gh
15
+ from .gitrepo import GitHubRemote, current_branch, current_remote
16
+
17
+ # Non-ASCII output (checkmarks, en-dashes in ADO project names, etc.) needs a
18
+ # UTF-8 stream — the default Windows console codepage isn't UTF-8, and would
19
+ # otherwise raise UnicodeEncodeError on the first ✓/✗ printed.
20
+ if sys.platform == "win32":
21
+ sys.stdout.reconfigure(encoding="utf-8") # type: ignore[union-attr]
22
+ sys.stderr.reconfigure(encoding="utf-8") # type: ignore[union-attr]
23
+
24
+ app = typer.Typer(name="bdt", help="Shared BMS developer tooling: PRs/builds (Azure DevOps or GitHub), worktrees, commits, logs")
25
+
26
+ pr_app = typer.Typer(name="pr", help="Pull request commands (Azure DevOps or GitHub, auto-detected from the git remote)")
27
+ app.add_typer(pr_app, name="pr")
28
+
29
+ logs_app = typer.Typer(name="logs", help="Application Insights / Log Analytics queries")
30
+ app.add_typer(logs_app, name="logs")
31
+
32
+
33
+ @pr_app.command("create")
34
+ def pr_create(
35
+ target: str = typer.Option("main", "--target", help="Target branch (e.g. main, test)"),
36
+ args: list[str] = typer.Argument(None, help="Extra args passed through to `az repos pr create` / `gh pr create`"),
37
+ ) -> None:
38
+ """Create a PR from the current branch into --target (Azure DevOps or GitHub, auto-detected)."""
39
+ remote = current_remote()
40
+ if isinstance(remote, GitHubRemote):
41
+ raise typer.Exit(gh_pr.create(require_gh(), target, args or []))
42
+
43
+ az = require_az()
44
+ cmd = [
45
+ az, "repos", "pr", "create",
46
+ "--target-branch", target,
47
+ "--source-branch", current_branch(),
48
+ "--auto-complete", "false",
49
+ *(args or []),
50
+ ]
51
+ raise typer.Exit(subprocess.run(cmd).returncode)
52
+
53
+
54
+ @pr_app.command("status")
55
+ def pr_status(
56
+ target_branch: str = typer.Option("main", "--target-branch", help="Target branch of the PR (Azure DevOps only — gh has no equivalent filter, it always resolves the PR for the current branch)"),
57
+ wait: bool = typer.Option(False, "--wait", help="Poll until all pipelines/checks are completed"),
58
+ pat: str | None = typer.Option(None, "--pat", envvar="AZURE_DEVOPS_PAT", help="Azure DevOps PAT (else falls back to `az` login)"),
59
+ ) -> None:
60
+ """Show build/check status for the PR opened from the current branch (Azure DevOps or GitHub, auto-detected)."""
61
+ remote = current_remote()
62
+ if isinstance(remote, GitHubRemote):
63
+ gh_pr.run(require_gh(), wait)
64
+ return
65
+ pr_build.run(remote, pat, target_branch, wait)
66
+
67
+
68
+ @app.command()
69
+ def worktree(
70
+ name: str,
71
+ base: str = typer.Option("dev", "--base", help="Branch to base the new worktree on"),
72
+ env_file: str | None = typer.Option(None, "--env-file", help="File to copy into the worktree as .env (default: auto-detect .local_env then .env)"),
73
+ submodules: bool = typer.Option(True, "--submodules/--no-submodules", help="Run `git submodule update --init` in the new worktree"),
74
+ install: str | None = typer.Option(None, "--install", help="Shell command to run inside the new worktree after creation, e.g. 'just install'"),
75
+ ) -> None:
76
+ """Create a git worktree under .worktrees/<name>, mirroring the `just worktree` recipe."""
77
+ install_cmd = install.split() if install else None
78
+ worktree_mod.create(name, base=base, env_file=env_file, submodules=submodules, install_cmd=install_cmd)
79
+
80
+
81
+ @app.command()
82
+ def commit(
83
+ message: str,
84
+ files: list[str],
85
+ json_output: bool = typer.Option(False, "--json", help="Structured JSON output for AI-agent callers"),
86
+ no_verify: bool = typer.Option(False, "--no-verify", help="Skip pre-commit hooks"),
87
+ subrepo: list[str] = typer.Option([], "--subrepo", help="Submodule directory name to split matching files into (repeatable)"),
88
+ skip_message_check: bool = typer.Option(False, "--skip-message-check", help="Don't require a conventional-commit-style message"),
89
+ allow_main: bool = typer.Option(False, "--allow-main", help="Allow committing directly on main/master"),
90
+ ) -> None:
91
+ """Stage, commit, and push files, with pre-flight checks and a pre-commit-hook retry."""
92
+ result = commit_mod.commit_and_push(
93
+ message,
94
+ files,
95
+ no_verify=no_verify,
96
+ require_message_quality=not skip_message_check,
97
+ require_feature_branch=not allow_main,
98
+ subrepos=subrepo,
99
+ )
100
+ commit_mod.emit(result, use_json=json_output)
101
+
102
+
103
+ @logs_app.command("roles")
104
+ def logs_roles(
105
+ resource_group: str = typer.Option(..., "--resource-group", envvar="AZURE_RESOURCE_GROUP"),
106
+ app_insights: str = typer.Option(..., "--app-insights", envvar="AZURE_APP_INSIGHTS"),
107
+ minutes: int = typer.Option(30, "--minutes"),
108
+ ) -> None:
109
+ """List cloud_RoleName values seen in the last N minutes (to pick a --role for `logs tail`)."""
110
+ logs_mod.print_roles(app_insights, resource_group, minutes)
111
+
112
+
113
+ @logs_app.command("tail")
114
+ def logs_tail(
115
+ role: str = typer.Option(..., "--role", help="cloud_RoleName to filter; 'all' for no filter"),
116
+ resource_group: str = typer.Option(..., "--resource-group", envvar="AZURE_RESOURCE_GROUP"),
117
+ app_insights: str = typer.Option(..., "--app-insights", envvar="AZURE_APP_INSIGHTS"),
118
+ minutes: int = typer.Option(30, "--minutes"),
119
+ level: str = typer.Option("verbose", "--level", help=f"Minimum severity: {', '.join(logs_mod.SEVERITY_MAP)}"),
120
+ no_color: bool = typer.Option(False, "--no-color"),
121
+ ) -> None:
122
+ """Fetch recent traces/exceptions for a role from Application Insights."""
123
+ logs_mod.print_logs(app_insights, resource_group, minutes, level, role, no_color)
124
+
125
+
126
+ @logs_app.command("fetch")
127
+ def logs_fetch(
128
+ env: str = typer.Option(..., "--env", help="Named environment configured under tool.bdt.envs in pyproject.toml"),
129
+ out: Path = typer.Option(Path("logs"), "--out", help="Output directory for the extracted error log"),
130
+ keep_archive: bool = typer.Option(False, "--keep-archive", help="Keep the downloaded .zip instead of deleting it"),
131
+ ) -> None:
132
+ """Download an App Service log archive and extract error/warning lines."""
133
+ cfg = env_config.resolve_env(env)
134
+ app_service_logs.fetch(cfg["webapp"], cfg["resource_group"], cfg["slot"], out, keep_archive=keep_archive)
135
+
136
+
137
+ if __name__ == "__main__":
138
+ app()
@@ -0,0 +1,31 @@
1
+ """Locating required external CLIs (az, gh) with clear errors when missing.
2
+
3
+ Uses `shutil.which` rather than a bare command name so Windows .cmd/.bat/.exe
4
+ shims (e.g. az.cmd from the MSI installer) resolve correctly via PATHEXT —
5
+ the same lookup `where`/`Get-Command` would do — instead of guessing an
6
+ extension or relying on shell=True (which also avoids any shell-quoting
7
+ concerns for arguments that come from user input, e.g. PR titles).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import shutil
13
+ import sys
14
+
15
+ AZ_INSTALL_HINT = "Install the Azure CLI: https://learn.microsoft.com/cli/azure/install-azure-cli"
16
+ GH_INSTALL_HINT = "Install the GitHub CLI: https://cli.github.com"
17
+
18
+
19
+ def require_tool(name: str, install_hint: str) -> str:
20
+ path = shutil.which(name)
21
+ if not path:
22
+ sys.exit(f"'{name}' is required for this command but wasn't found on PATH.\n{install_hint}")
23
+ return path
24
+
25
+
26
+ def require_az() -> str:
27
+ return require_tool("az", AZ_INSTALL_HINT)
28
+
29
+
30
+ def require_gh() -> str:
31
+ return require_tool("gh", GH_INSTALL_HINT)
@@ -0,0 +1,204 @@
1
+ """Commit-and-push helper: pre-flight checks, one retry after a pre-commit
2
+ reformat, optional JSON output for AI-agent callers, optional subrepo split
3
+ for repos that vendor a submodule (e.g. a `database/` git submodule).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import os
10
+ import subprocess
11
+ import sys
12
+ from dataclasses import dataclass, field
13
+
14
+ IS_SANDBOX_ENV_VAR = "IS_BMS_AI_SANDBOX"
15
+
16
+
17
+ def _run(cmd: list[str], cwd: str | None = None) -> subprocess.CompletedProcess:
18
+ try:
19
+ return subprocess.run(cmd, capture_output=True, encoding="utf-8", cwd=cwd)
20
+ except FileNotFoundError:
21
+ sys.exit("'git' is required for this command but wasn't found on PATH.")
22
+
23
+
24
+ def _sha(cwd: str | None = None) -> str | None:
25
+ r = _run(["git", "rev-parse", "--short", "HEAD"], cwd=cwd)
26
+ return r.stdout.strip() if r.returncode == 0 else None
27
+
28
+
29
+ def _in_sandbox() -> bool:
30
+ return os.getenv(IS_SANDBOX_ENV_VAR, "0").lower() in ("1", "true", "yes")
31
+
32
+
33
+ @dataclass
34
+ class CommitResult:
35
+ success: bool
36
+ committed: bool
37
+ pushed: bool
38
+ message: str
39
+ files: list[str]
40
+ commit_sha: str | None = None
41
+ error: str | None = None
42
+ hint: str | None = None
43
+ extra: dict = field(default_factory=dict)
44
+
45
+ def as_dict(self) -> dict:
46
+ d = {
47
+ "success": self.success,
48
+ "committed": self.committed,
49
+ "pushed": self.pushed,
50
+ "message": self.message,
51
+ "files": self.files,
52
+ "commit_sha": self.commit_sha,
53
+ "error": self.error,
54
+ "hint": self.hint,
55
+ }
56
+ d.update(self.extra)
57
+ return d
58
+
59
+
60
+ def _git_commit(message: str, cwd: str | None = None, no_verify: bool = False) -> subprocess.CompletedProcess:
61
+ cmd = ["git", "commit", "-m", message]
62
+ if no_verify:
63
+ cmd.append("--no-verify")
64
+ return _run(cmd, cwd=cwd)
65
+
66
+
67
+ def _commit_with_retry(
68
+ message: str, files: list[str], cwd: str | None, no_verify: bool
69
+ ) -> tuple[bool, subprocess.CompletedProcess | None]:
70
+ """Commit, retrying once (re-`git add`) if a pre-commit hook reformatted files."""
71
+ _run(["git", "add", *files], cwd=cwd)
72
+ r = _git_commit(message, cwd=cwd, no_verify=no_verify)
73
+ if r.returncode == 0:
74
+ return True, None
75
+ if "nothing to commit" in r.stdout + r.stderr:
76
+ return False, None
77
+ _run(["git", "add", *files], cwd=cwd)
78
+ r2 = _git_commit(message, cwd=cwd, no_verify=no_verify)
79
+ if r2.returncode == 0:
80
+ return True, None
81
+ return False, r2
82
+
83
+
84
+ def commit_and_push(
85
+ message: str,
86
+ files: list[str],
87
+ *,
88
+ no_verify: bool = False,
89
+ require_message_quality: bool = True,
90
+ require_feature_branch: bool = True,
91
+ subrepos: list[str] | None = None,
92
+ ) -> CommitResult:
93
+ """Stage, commit, and push `files`, applying the same pre-flight checks
94
+ and pre-commit-hook retry as the per-repo `commit.py` scripts.
95
+
96
+ `subrepos` is a list of submodule directory names (e.g. ["database"]);
97
+ files under one of those prefixes are committed/pushed inside the
98
+ submodule first, then the submodule bump is staged in the parent repo.
99
+ """
100
+ # Normalize to forward slashes so subrepo-prefix matching below works the
101
+ # same whether a caller passes "database/x.sql" or "database\x.sql" (both
102
+ # os.path.exists and git accept either separator fine on Windows).
103
+ files = [f.replace("\\", "/") for f in files]
104
+ subrepos = subrepos or []
105
+ print("pre-flight checks:", flush=True)
106
+
107
+ def check(ok: bool, label: str) -> bool:
108
+ print(f" {'✓' if ok else '✗'} {label}", file=sys.stdout if ok else sys.stderr)
109
+ return ok
110
+
111
+ missing = [f for f in files if not os.path.exists(f)]
112
+ if not check(not missing, "files exist"):
113
+ return CommitResult(
114
+ False, False, False, message, files,
115
+ error=f"File not found: {missing[0]} — did you typo the path? Run `git status` to see changed files",
116
+ hint=f"Run `git status` to see what files are actually changed. Missing: {missing}",
117
+ )
118
+
119
+ msg_ok = not require_message_quality or (len(message) >= 20 and ":" in message)
120
+ if not check(msg_ok, "commit message quality (len>=20, has colon)"):
121
+ return CommitResult(
122
+ False, False, False, message, files,
123
+ error=f"Commit message too short or missing type prefix (e.g. 'feat(x): ...') — got: {message!r}",
124
+ hint="Use conventional commits format: 'feat(scope): description' or 'fix: description'",
125
+ )
126
+
127
+ current_branch = _run(["git", "rev-parse", "--abbrev-ref", "HEAD"]).stdout.strip()
128
+ branch_ok = not require_feature_branch or current_branch not in ("main", "master")
129
+ if not check(branch_ok, f"not on main/master (branch: {current_branch})"):
130
+ return CommitResult(
131
+ False, False, False, message, files,
132
+ error=f"Direct push to {current_branch} blocked — create a feature branch first",
133
+ hint="Run `git checkout -b feat/my-branch` to create a feature branch first.",
134
+ )
135
+
136
+ main_files = list(files)
137
+ for subrepo in subrepos:
138
+ prefix = subrepo + "/"
139
+ subrepo_files = [f[len(prefix):] for f in files if f.startswith(prefix)]
140
+ main_files = [f for f in main_files if not f.startswith(prefix) and f != subrepo]
141
+ if not subrepo_files:
142
+ continue
143
+
144
+ ok, failure = _commit_with_retry(message, subrepo_files, cwd=subrepo, no_verify=no_verify)
145
+ if failure is not None:
146
+ return CommitResult(
147
+ False, False, False, message, subrepo_files,
148
+ error=(failure.stdout + failure.stderr).strip(),
149
+ hint="Pre-commit hook may have failed in the subrepo. Check the error output above.",
150
+ )
151
+ if ok and not _in_sandbox():
152
+ pr = _run(["git", "push"], cwd=subrepo)
153
+ if pr.returncode != 0:
154
+ return CommitResult(
155
+ False, True, False, message, subrepo_files,
156
+ commit_sha=_sha(cwd=subrepo),
157
+ error=(pr.stdout + pr.stderr).strip(),
158
+ hint=f"Push failed. Try `git pull --rebase` in the {subrepo} submodule.",
159
+ )
160
+ print(f" ✓ {subrepo} subrepo pushed", flush=True)
161
+ if ok:
162
+ main_files.append(subrepo)
163
+
164
+ ok, failure = _commit_with_retry(message, main_files, cwd=None, no_verify=no_verify)
165
+ if failure is not None:
166
+ return CommitResult(
167
+ False, False, False, message, files,
168
+ error=(failure.stdout + failure.stderr).strip(),
169
+ hint="Pre-commit hook may have reformatted files and failed. Check output.",
170
+ )
171
+ committed = ok
172
+ print(" ✓ committed", flush=True)
173
+
174
+ if _in_sandbox():
175
+ return CommitResult(True, committed, False, message, files, commit_sha=_sha())
176
+
177
+ pr = _run(["git", "push"])
178
+ if pr.returncode != 0:
179
+ return CommitResult(
180
+ True, committed, False, message, files,
181
+ commit_sha=_sha(),
182
+ error=(pr.stdout + pr.stderr).strip(),
183
+ hint="Push rejected. Run `git pull --rebase`, resolve conflicts, then retry.",
184
+ )
185
+
186
+ print(" ✓ pushed", flush=True)
187
+ return CommitResult(True, committed, True, message, files, commit_sha=_sha())
188
+
189
+
190
+ def emit(result: CommitResult, *, use_json: bool) -> None:
191
+ if use_json:
192
+ print(json.dumps(result.as_dict(), indent=2))
193
+ elif result.success:
194
+ if not result.committed:
195
+ print("nothing to commit")
196
+ elif not result.pushed:
197
+ print("committed (sandbox mode, push handled separately)")
198
+ else:
199
+ print("committed & pushed")
200
+ else:
201
+ print(f"ERROR: {result.error}", file=sys.stderr)
202
+ if result.hint:
203
+ print(f"HINT: {result.hint}", file=sys.stderr)
204
+ sys.exit(0 if result.success else 1)
@@ -0,0 +1,61 @@
1
+ """Named environments for `bdt logs fetch`, configured in the consuming repo's
2
+ pyproject.toml under [tool.bdt.envs.<name>].
3
+
4
+ This lets a repo define its webapp/resource-group/slot combinations once
5
+ (e.g. "prod", "staging") instead of passing all three flags on every call.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import sys
11
+ import tomllib
12
+ from pathlib import Path
13
+
14
+ REQUIRED_KEYS = ("webapp", "resource_group", "slot")
15
+
16
+ CONFIG_EXAMPLE = """\
17
+ [tool.bdt.envs.prod]
18
+ webapp = "my-app"
19
+ resource_group = "my-app-rg"
20
+ slot = "production"
21
+ """
22
+
23
+
24
+ def _find_pyproject(start: Path) -> Path | None:
25
+ for directory in (start, *start.parents):
26
+ candidate = directory / "pyproject.toml"
27
+ if candidate.is_file():
28
+ return candidate
29
+ return None
30
+
31
+
32
+ def load_envs(start: Path | None = None) -> dict[str, dict[str, str]]:
33
+ path = _find_pyproject(start or Path.cwd())
34
+ if path is None:
35
+ return {}
36
+ with path.open("rb") as f:
37
+ data = tomllib.load(f)
38
+ envs = data.get("tool", {}).get("bdt", {}).get("envs", {})
39
+ return envs if isinstance(envs, dict) else {}
40
+
41
+
42
+ def resolve_env(name: str, start: Path | None = None) -> dict[str, str]:
43
+ """Look up a named environment, exiting with an actionable message if it's missing."""
44
+ envs = load_envs(start)
45
+ if not envs:
46
+ sys.exit(
47
+ "ERROR: no environments configured. Add a [tool.bdt.envs.<name>] table "
48
+ f"to pyproject.toml, e.g.:\n\n{CONFIG_EXAMPLE}"
49
+ )
50
+ if name not in envs:
51
+ available = ", ".join(sorted(envs))
52
+ sys.exit(f"ERROR: unknown --env '{name}'. Configured environments: {available}")
53
+
54
+ env = envs[name]
55
+ missing = [k for k in REQUIRED_KEYS if k not in env]
56
+ if missing:
57
+ sys.exit(
58
+ f"ERROR: [tool.bdt.envs.{name}] in pyproject.toml is missing required "
59
+ f"key(s): {', '.join(missing)}"
60
+ )
61
+ return env