beaker-sdk 0.3.5__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.
Files changed (52) hide show
  1. beaker/__init__.py +126 -0
  2. beaker/_bundle_digest.py +59 -0
  3. beaker/_image_dependency_validation.py +171 -0
  4. beaker/artifact_manifest.py +126 -0
  5. beaker/cli/__init__.py +6 -0
  6. beaker/cli/__main__.py +13 -0
  7. beaker/cli/_common.py +107 -0
  8. beaker/cli/_format.py +75 -0
  9. beaker/cli/agent_setup.py +228 -0
  10. beaker/cli/auth_cmd.py +156 -0
  11. beaker/cli/beaker_config.py +224 -0
  12. beaker/cli/browser_login.py +128 -0
  13. beaker/cli/credentials.py +56 -0
  14. beaker/cli/init_cmd.py +1208 -0
  15. beaker/cli/init_templates.py +783 -0
  16. beaker/cli/main.py +710 -0
  17. beaker/cli/project_env.py +108 -0
  18. beaker/cli/run_ops.py +889 -0
  19. beaker/cli/spec_resolution.py +164 -0
  20. beaker/cli/trace_ops.py +82 -0
  21. beaker/client.py +1086 -0
  22. beaker/config.py +87 -0
  23. beaker/dataset_schema.py +98 -0
  24. beaker/project_layout.py +38 -0
  25. beaker/py.typed +0 -0
  26. beaker/run_status.py +7 -0
  27. beaker/sdk/__init__.py +149 -0
  28. beaker/sdk/candidate_diff.py +123 -0
  29. beaker/sdk/dataset.py +289 -0
  30. beaker/sdk/domain_contracts.py +144 -0
  31. beaker/sdk/evaluation.py +113 -0
  32. beaker/sdk/field_access.py +46 -0
  33. beaker/sdk/inference.py +87 -0
  34. beaker/sdk/models.py +607 -0
  35. beaker/sdk/reflection_evidence.py +362 -0
  36. beaker/sdk/resources.py +174 -0
  37. beaker/sdk/spec_contract.py +327 -0
  38. beaker/sdk/utils.py +193 -0
  39. beaker/spec.py +192 -0
  40. beaker/testing/__init__.py +4 -0
  41. beaker/testing/tracing.py +6 -0
  42. beaker/tracing/README.md +126 -0
  43. beaker/tracing/__init__.py +24 -0
  44. beaker/tracing/core.py +912 -0
  45. beaker/tracing/integrations/__init__.py +6 -0
  46. beaker/tracing/integrations/pydantic_ai.py +198 -0
  47. beaker/tracing/otlp.py +160 -0
  48. beaker/tracing/projection.py +564 -0
  49. beaker_sdk-0.3.5.dist-info/METADATA +58 -0
  50. beaker_sdk-0.3.5.dist-info/RECORD +52 -0
  51. beaker_sdk-0.3.5.dist-info/WHEEL +4 -0
  52. beaker_sdk-0.3.5.dist-info/entry_points.txt +3 -0
beaker/__init__.py ADDED
@@ -0,0 +1,126 @@
1
+ """Beaker SDK package.
2
+
3
+ ``beaker`` gives developers the contract types, decorators, client, and CLI
4
+ support needed to define optimization specs, validate them locally, upload
5
+ datasets, launch hosted optimization runs, and inspect results.
6
+
7
+ The spec-authoring contract surface is exported directly from this module, so
8
+ customer specs can write ``from beaker import Spec, Case, OptimizationTargets``
9
+ without going through an internal-looking submodule. The optional
10
+ custom-finalizer types (``OptimizationFinalizerResult``, ``OptimizationCandidateRecord``,
11
+ ``OptimizationEvalReportRecord``, and friends) are advanced and live under
12
+ ``beaker.sdk`` — this keeps the headline surface on spec authoring and stops
13
+ ``OptimizationCandidateRecord`` (an optimizer *output*) from sitting beside
14
+ ``OptimizationTargets`` (your *input*) as a look-alike. The HTTP client remains
15
+ lazy-loaded so build-worker and bundle-inspection paths that only need SDK
16
+ contracts do not import client/config dependencies.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from typing import TYPE_CHECKING
22
+
23
+ from beaker.dataset_schema import STANDARD_JSONL_CASE_SCHEMA, DatasetSchema
24
+ from beaker.sdk import (
25
+ Case,
26
+ CaseDataLoader,
27
+ CaseFailure,
28
+ CaseResult,
29
+ CaseScore,
30
+ CaseScorer,
31
+ ContextSearchEvidence,
32
+ DatasetRowContext,
33
+ DirectoryResource,
34
+ ErrorOutput,
35
+ EvaluationReport,
36
+ Evidence,
37
+ EvidenceProvider,
38
+ FieldConfig,
39
+ FieldDiff,
40
+ InferenceTarget,
41
+ OptimizationContext,
42
+ OptimizationTargets,
43
+ Result,
44
+ RolloutBatch,
45
+ RolloutContext,
46
+ RunCase,
47
+ Spec,
48
+ SpecRegistration,
49
+ Trajectory,
50
+ ValueNormalizer,
51
+ build_cases_by_split,
52
+ extract_field_from_object,
53
+ inference_target,
54
+ load_cases_by_split,
55
+ load_spec_from_target,
56
+ materialize_dataset,
57
+ objective_score,
58
+ optimization_targets_from_prompts,
59
+ result_to_dict,
60
+ serialize_cases,
61
+ spec,
62
+ validate_spec,
63
+ )
64
+
65
+
66
+ if TYPE_CHECKING:
67
+ from beaker.client import BeakerClient, BeakerClientError
68
+
69
+
70
+ __version__ = "0.3.5"
71
+
72
+ __all__ = [
73
+ "Case",
74
+ "CaseDataLoader",
75
+ "CaseFailure",
76
+ "CaseResult",
77
+ "CaseScore",
78
+ "CaseScorer",
79
+ "ContextSearchEvidence",
80
+ "BeakerClient",
81
+ "BeakerClientError",
82
+ "DatasetSchema",
83
+ "DatasetRowContext",
84
+ "ErrorOutput",
85
+ "EvaluationReport",
86
+ "Evidence",
87
+ "EvidenceProvider",
88
+ "FieldConfig",
89
+ "FieldDiff",
90
+ "InferenceTarget",
91
+ "OptimizationContext",
92
+ "OptimizationTargets",
93
+ "Result",
94
+ "RolloutBatch",
95
+ "RolloutContext",
96
+ "RunCase",
97
+ "Spec",
98
+ "SpecRegistration",
99
+ "STANDARD_JSONL_CASE_SCHEMA",
100
+ "Trajectory",
101
+ "ValueNormalizer",
102
+ "__version__",
103
+ "build_cases_by_split",
104
+ "extract_field_from_object",
105
+ "inference_target",
106
+ "load_cases_by_split",
107
+ "load_spec_from_target",
108
+ "materialize_dataset",
109
+ "objective_score",
110
+ "DirectoryResource",
111
+ "optimization_targets_from_prompts",
112
+ "result_to_dict",
113
+ "serialize_cases",
114
+ "spec",
115
+ "validate_spec",
116
+ ]
117
+
118
+
119
+ def __getattr__(name: str) -> object:
120
+ """Lazily expose HTTP client types; SDK contract types are eager exports."""
121
+
122
+ if name in {"BeakerClient", "BeakerClientError"}:
123
+ from beaker import client as _client
124
+
125
+ return getattr(_client, name)
126
+ raise AttributeError(f"module 'beaker' has no attribute {name!r}")
@@ -0,0 +1,59 @@
1
+ """Shared bundle-digest algorithm for spec source bundles.
2
+
3
+ The digest binds a spec version to the exact source tree it was built
4
+ from: it's computed over a bundle's files before upload and recomputed
5
+ after extraction, and the two must match. This prevents a source change
6
+ between hashing and packaging, or a stale upload, from producing a build
7
+ whose contents differ from what was recorded.
8
+
9
+ Both computations must agree byte-for-byte on the input to ``sha256``.
10
+ The canonical input for one file is::
11
+
12
+ rel_posix.encode("utf-8") || 0x00 || file_content || 0x00
13
+
14
+ concatenated in ascending ``rel_posix`` order over every regular file in
15
+ the bundle.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import hashlib
21
+ from collections.abc import Iterable
22
+ from pathlib import Path
23
+
24
+
25
+ _CHUNK_SIZE = 64 * 1024
26
+
27
+
28
+ def digest_bundle_files(files: Iterable[tuple[str, Path]]) -> str:
29
+ """Hash a sequence of ``(rel_posix, file_path)`` pairs into a sha256 hex.
30
+
31
+ Callers must supply pairs already sorted by ``rel_posix`` so the
32
+ digest is order-stable.
33
+ """
34
+ digest = hashlib.sha256()
35
+ for rel_posix, file_path in files:
36
+ digest.update(rel_posix.encode("utf-8"))
37
+ digest.update(b"\x00")
38
+ with file_path.open("rb") as fh:
39
+ for chunk in iter(lambda: fh.read(_CHUNK_SIZE), b""):
40
+ digest.update(chunk)
41
+ digest.update(b"\x00")
42
+ return digest.hexdigest()
43
+
44
+
45
+ def digest_extracted_bundle(root: Path) -> str:
46
+ """Hash every regular file under ``root`` in ``sorted(rglob("*"))`` order.
47
+
48
+ Used to recompute the digest over an already-extracted bundle. Any
49
+ file filtering must be applied before packaging, so the walk here is
50
+ unfiltered and its result matches a :func:`digest_bundle_files` digest
51
+ of the same files.
52
+ """
53
+ files: list[tuple[str, Path]] = []
54
+ for path in sorted(root.rglob("*")):
55
+ if not path.is_file():
56
+ continue
57
+ rel_posix = path.relative_to(root).as_posix()
58
+ files.append((rel_posix, path))
59
+ return digest_bundle_files(files)
@@ -0,0 +1,171 @@
1
+ """Validation for managed-environment ``pip_install`` / ``apt_install`` entries.
2
+
3
+ These strings are forwarded to ``pip install <entry>`` /
4
+ ``apt-get install <entry>`` while preparing an optimization environment, so
5
+ entries that smuggle in flags, extra tokens, shell metacharacters, or bare URLs
6
+ are rejected before they can reconfigure package managers. The same rules are
7
+ enforced for every client, not just the CLI.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from collections.abc import Sequence
14
+
15
+
16
+ # Distribution names customers cannot pin themselves: these packages are managed
17
+ # by Beaker so the execution environment stays compatible with the service.
18
+ RESERVED_PIP_INSTALL_NAMES: frozenset[str] = frozenset(
19
+ {
20
+ "beaker-sdk",
21
+ "beaker" + "-runtime",
22
+ }
23
+ )
24
+
25
+ _PIP_REQUIREMENT_NAME_RE = re.compile(r"^\s*([A-Za-z0-9][A-Za-z0-9._-]*)")
26
+
27
+ # Shell metacharacters that have no legitimate place in a PEP 508 spec.
28
+ # Each entry is passed to ``pip install <entry>``, so any of these would
29
+ # let it escape its argument slot. Whitespace is rejected separately
30
+ # below because a multi-token entry like ``"--index-url http://x pkg"``
31
+ # would split into two arguments and reconfigure the resolver.
32
+ _PIP_FORBIDDEN_CHARS: frozenset[str] = frozenset({";", "|", "&", "`", "$", "\n", "\r", "\t"})
33
+
34
+ # Bare-URL prefixes pip accepts as a full requirement. Without a leading
35
+ # ``name @``, pip will fetch and execute setup.py / build hooks from
36
+ # whatever the URL points to. The ``name @ <https-url>`` form is allowed
37
+ # because the distribution is named explicitly; bare URLs are refused.
38
+ _PIP_BARE_URL_SCHEMES: tuple[str, ...] = (
39
+ "http://",
40
+ "https://",
41
+ "ftp://",
42
+ "file://",
43
+ "git+",
44
+ "hg+",
45
+ "svn+",
46
+ "bzr+",
47
+ "ssh://",
48
+ )
49
+
50
+ # Allowed apt entry shape: a Debian package name, optionally pinned to
51
+ # a specific version (``name=version``). Excludes shell metacharacters,
52
+ # whitespace, flags, repository specs, and anything else that would let
53
+ # an entry reconfigure ``apt-get`` (``-t target-release``,
54
+ # ``-o Dpkg::Options::=...``, etc.).
55
+ _APT_ENTRY_RE = re.compile(r"^[a-z0-9][a-z0-9.+\-]*(?:=[A-Za-z0-9.+:~\-]+)?$")
56
+
57
+
58
+ class ImageInstallValidationError(ValueError):
59
+ """Raised when a ``pip_install`` / ``apt_install`` entry is unsafe.
60
+
61
+ Subclasses :class:`ValueError` so callers can treat it as a standard
62
+ validation error.
63
+ """
64
+
65
+
66
+ def canonicalize_distribution_name(value: str) -> str:
67
+ """PEP 503-normalize the leading distribution name in a pip spec.
68
+
69
+ ``beaker-sdk``, ``BEAKER_SDK``, ``beaker-sdk[aws]``, and
70
+ ``beaker-sdk==0.1.0`` all
71
+ canonicalize consistently so the
72
+ reserved-name check works regardless of casing, extras, or pinning
73
+ syntax.
74
+ """
75
+ match = _PIP_REQUIREMENT_NAME_RE.match(value)
76
+ if match is None:
77
+ return ""
78
+ return re.sub(r"[-_.]+", "-", match.group(1)).lower()
79
+
80
+
81
+ def validate_pip_install_entries(pip_install: Sequence[str]) -> None:
82
+ """Reject pip entries that are unsafe to install.
83
+
84
+ Each string is passed to ``pip install``. An entry starting with
85
+ ``-`` is treated as a flag (``--index-url``, ``-e <url>``, ``-r``)
86
+ that could redirect the resolver or pull in arbitrary code, and a
87
+ bare URL (``https://...``, ``git+...``) tells pip to fetch and run
88
+ setup hooks from that URL. Only PEP 508 requirements are allowed,
89
+ including the explicitly-named ``name @ https://...`` form.
90
+ """
91
+ for entry in pip_install:
92
+ if canonicalize_distribution_name(entry) in RESERVED_PIP_INSTALL_NAMES:
93
+ raise ImageInstallValidationError(
94
+ f"pip_install {entry!r} is not allowed: Beaker-managed "
95
+ "packages are pinned automatically. Remove this entry from "
96
+ "your pip-install list."
97
+ )
98
+ stripped = entry.strip()
99
+ if not stripped:
100
+ raise ImageInstallValidationError("pip_install entries must be non-empty.")
101
+ if stripped.startswith("-"):
102
+ raise ImageInstallValidationError(
103
+ f"pip_install {entry!r} is not allowed: pip flags (entries "
104
+ "starting with '-') cannot be passed through. Use a PEP 508 "
105
+ "requirement spec like 'pkg==1.2' or 'pkg @ https://...'."
106
+ )
107
+ forbidden = _PIP_FORBIDDEN_CHARS.intersection(stripped)
108
+ if forbidden:
109
+ raise ImageInstallValidationError(
110
+ f"pip_install {entry!r} contains shell metacharacters "
111
+ f"({''.join(sorted(forbidden))!r}); refuse to forward."
112
+ )
113
+ # Reject internal whitespace EXCEPT the single ``name @ url``
114
+ # form, which PEP 508 requires to contain spaces around the
115
+ # ``@``.
116
+ if any(ch.isspace() for ch in stripped) and " @ " not in stripped:
117
+ raise ImageInstallValidationError(
118
+ f"pip_install {entry!r} contains whitespace; pass exactly "
119
+ "one PEP 508 requirement per entry (no embedded flags or "
120
+ "multi-token strings)."
121
+ )
122
+ # If this is the URL-requirement form, the URL portion has to be
123
+ # HTTPS. PEP 508 spells it ``name @ url``, but pip also accepts
124
+ # ``name@url`` with no spaces, so an entry like ``pkg@file:///x``
125
+ # would slip past the bare-URL and whitespace checks. Partition
126
+ # on the first ``@`` so both shapes are gated here. Non-HTTPS
127
+ # schemes (``file://``, ``git+``, ``ssh://``, ``http://`` and
128
+ # other VCS/transport schemes) can make pip read local files or
129
+ # fetch and execute remote code, so they're refused.
130
+ if "@" in stripped:
131
+ name_portion, _, url_portion = stripped.partition("@")
132
+ # The distribution name before ``@`` must be a real PEP 508
133
+ # name. An entry like ``"@https://wrong.example/x.tar.gz"`` has
134
+ # no name before ``@`` and would otherwise pass the checks
135
+ # below, so require a well-formed name here.
136
+ if not _PIP_REQUIREMENT_NAME_RE.match(name_portion):
137
+ raise ImageInstallValidationError(
138
+ f"pip_install {entry!r} is missing a distribution "
139
+ "name before '@'. Use 'name @ https://...' (or "
140
+ "'name@https://...') with an explicit PEP 508 name."
141
+ )
142
+ if not url_portion.strip().lower().startswith("https://"):
143
+ raise ImageInstallValidationError(
144
+ f"pip_install {entry!r} has a non-https URL after '@'. "
145
+ "Only 'name @ https://...' (or 'name@https://...') is "
146
+ "allowed; file://, http://, git+, ssh://, ftp://, and "
147
+ "VCS schemes can let pip resolve to arbitrary code or "
148
+ "read sensitive files during environment preparation."
149
+ )
150
+ if any(stripped.lower().startswith(scheme) for scheme in _PIP_BARE_URL_SCHEMES):
151
+ raise ImageInstallValidationError(
152
+ f"pip_install {entry!r} is a bare URL; use the 'name @ https://...' form so the distribution is named."
153
+ )
154
+
155
+
156
+ def validate_apt_install_entries(apt_install: Sequence[str]) -> None:
157
+ """Reject apt entries that aren't a plain ``name`` or ``name=version``.
158
+
159
+ Each string is passed to ``apt-get install``, so an entry like
160
+ ``"-t bullseye-backports libfoo"`` or
161
+ ``"-o APT::Get::AllowUnauthenticated=true libfoo"`` would reconfigure
162
+ the package manager. The allowed shape forbids whitespace, flags,
163
+ repository overrides, and shell metacharacters.
164
+ """
165
+ for entry in apt_install:
166
+ if not _APT_ENTRY_RE.fullmatch(entry):
167
+ raise ImageInstallValidationError(
168
+ f"apt_install {entry!r} is not a valid Debian package "
169
+ "spec. Expected 'name' or 'name=version' with no flags, "
170
+ "whitespace, or shell metacharacters."
171
+ )
@@ -0,0 +1,126 @@
1
+ """Canonical artifact manifest helpers used by upload clients."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import hashlib
7
+ import json
8
+ import posixpath
9
+ import re
10
+ from collections.abc import Mapping, Sequence
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+
15
+ JsonDict = dict[str, Any]
16
+
17
+ MAX_ARTIFACT_PATH_LENGTH = 1024
18
+ SHA256_HEX_PATTERN = r"^[0-9a-fA-F]{64}$"
19
+
20
+ _CONTROL_CHARS = re.compile(r"[\x00-\x1f\x7f]")
21
+ _SHA256_HEX = re.compile(SHA256_HEX_PATTERN)
22
+
23
+
24
+ def normalize_artifact_path(path: str) -> str:
25
+ """Normalize a caller-supplied relative path inside an artifact."""
26
+ if not isinstance(path, str):
27
+ raise ValueError(f"Artifact paths must be strings, got {type(path).__name__}.")
28
+ normalized = path.replace("\\", "/").strip()
29
+ if not normalized or normalized.startswith("/"):
30
+ raise ValueError(f"Artifact paths must be relative and non-empty: {path!r}.")
31
+ if _CONTROL_CHARS.search(normalized):
32
+ raise ValueError(f"Artifact paths must not contain control characters: {path!r}.")
33
+ parts = [part for part in normalized.split("/") if part not in {"", "."}]
34
+ if not parts or any(part == ".." for part in parts):
35
+ raise ValueError(f"Artifact paths must not contain parent-directory segments: {path!r}.")
36
+ safe_path = posixpath.join(*parts)
37
+ if len(safe_path) > MAX_ARTIFACT_PATH_LENGTH:
38
+ raise ValueError(f"Artifact paths must be {MAX_ARTIFACT_PATH_LENGTH} characters or fewer: {path!r}.")
39
+ return safe_path
40
+
41
+
42
+ def canonical_manifest_files(files: Sequence[Mapping[str, Any]]) -> list[JsonDict]:
43
+ """Return normalized file entries sorted by artifact path.
44
+
45
+ The digest identity includes path, byte size, and file SHA-256. Content type
46
+ is preserved for references and upload headers but intentionally excluded
47
+ from the digest.
48
+ """
49
+ normalized: list[JsonDict] = []
50
+ seen_paths: set[str] = set()
51
+ for file in files:
52
+ path = normalize_artifact_path(str(file.get("path") or ""))
53
+ if path in seen_paths:
54
+ raise ValueError(f"Duplicate artifact path after normalization: {path!r}.")
55
+ seen_paths.add(path)
56
+ size_bytes = _normalize_size_bytes(file.get("size_bytes"))
57
+ sha256 = normalize_sha256(str(file.get("sha256") or ""))
58
+ entry: JsonDict = {
59
+ "path": path,
60
+ "size_bytes": size_bytes,
61
+ "sha256": sha256,
62
+ }
63
+ content_type = file.get("content_type")
64
+ if content_type is not None:
65
+ entry["content_type"] = str(content_type)
66
+ normalized.append(entry)
67
+ return sorted(normalized, key=lambda item: str(item["path"]))
68
+
69
+
70
+ def manifest_digest(files: Sequence[Mapping[str, Any]]) -> str:
71
+ """Compute the stable SHA-256 digest for an artifact manifest."""
72
+ return digest_canonical_manifest_files(canonical_manifest_files(files))
73
+
74
+
75
+ def digest_canonical_manifest_files(canonical_files: Sequence[Mapping[str, Any]]) -> str:
76
+ """Compute the digest for files that have already been canonicalized.
77
+
78
+ Use this when the caller has just produced ``canonical_manifest_files``
79
+ output and wants to avoid re-running normalization, sorting, and
80
+ duplicate-detection a second time.
81
+ """
82
+ identity = [
83
+ {
84
+ "path": file["path"],
85
+ "size_bytes": file["size_bytes"],
86
+ "sha256": file["sha256"],
87
+ }
88
+ for file in canonical_files
89
+ ]
90
+ payload = json.dumps(identity, sort_keys=True, separators=(",", ":")).encode("utf-8")
91
+ return hashlib.sha256(payload).hexdigest()
92
+
93
+
94
+ def file_sha256_hex(path: Path) -> str:
95
+ """Hash a local file without loading it all into memory."""
96
+ digest = hashlib.sha256()
97
+ with path.open("rb") as handle:
98
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
99
+ digest.update(chunk)
100
+ return digest.hexdigest()
101
+
102
+
103
+ def normalize_sha256(value: str) -> str:
104
+ """Normalize and validate a SHA-256 hex digest."""
105
+ digest = value.strip().lower()
106
+ if not _SHA256_HEX.fullmatch(digest):
107
+ raise ValueError("sha256 must be a 64-character hex digest.")
108
+ return digest
109
+
110
+
111
+ def sha256_hex_to_base64(value: str) -> str:
112
+ """Return the base64 form used by checksum upload headers."""
113
+ digest = normalize_sha256(value)
114
+ return base64.b64encode(bytes.fromhex(digest)).decode("ascii")
115
+
116
+
117
+ def _normalize_size_bytes(value: Any) -> int:
118
+ if isinstance(value, bool) or value is None:
119
+ raise ValueError("size_bytes must be a non-negative integer.")
120
+ try:
121
+ size_bytes = int(value)
122
+ except (TypeError, ValueError) as exc:
123
+ raise ValueError("size_bytes must be a non-negative integer.") from exc
124
+ if size_bytes < 0:
125
+ raise ValueError("size_bytes must be a non-negative integer.")
126
+ return size_bytes
beaker/cli/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """``beaker`` command-line entry point."""
2
+
3
+ from beaker.cli.main import build_parser, main
4
+
5
+
6
+ __all__ = ["build_parser", "main"]
beaker/cli/__main__.py ADDED
@@ -0,0 +1,13 @@
1
+ """Allow ``python -m beaker.cli ...`` in addition to the ``beaker`` console script."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from beaker.cli.main import main
6
+
7
+
8
+ if __name__ == "__main__":
9
+ # ``main()`` returns 0 / 1 / 2 (mirroring the ``beaker`` console
10
+ # script). Wrapping in ``SystemExit`` propagates that to the OS so CI
11
+ # and shell scripts can branch on ``$?`` — without this, a failed
12
+ # ``python -m beaker.cli push`` exits 0 and silently succeeds.
13
+ raise SystemExit(main())
beaker/cli/_common.py ADDED
@@ -0,0 +1,107 @@
1
+ """Small helpers shared across ``beaker`` CLI command modules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ from collections.abc import Mapping
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from beaker.cli.beaker_config import BEAKER_CONFIG_RELATIVE_PATH, find_beaker_yaml, project_config_relative_path
12
+ from beaker.client import BeakerClient
13
+ from beaker.config import BeakerConfig
14
+
15
+
16
+ DEFAULT_API_BASE_URL = "https://o9f72bncec.execute-api.us-east-2.amazonaws.com/prod/"
17
+ DEFAULT_APP_BASE_URL = "https://platform.rilix.ai"
18
+
19
+
20
+ def add_agent_key_argument(parser: argparse.ArgumentParser) -> None:
21
+ """Add the shared public-agent selector to an API-backed command."""
22
+ parser.add_argument(
23
+ "--agent",
24
+ "--agent-key",
25
+ dest="agent_key",
26
+ default=None,
27
+ help=(
28
+ "Public agent key to operate on. Defaults to $BEAKER_AGENT_KEY, then "
29
+ f"{BEAKER_CONFIG_RELATIVE_PATH.as_posix()} agent_key when present."
30
+ ),
31
+ )
32
+
33
+
34
+ def client_from_args(
35
+ args: argparse.Namespace,
36
+ client_cls: type[BeakerClient] = BeakerClient,
37
+ *,
38
+ config_agent_key: str | None = None,
39
+ ) -> BeakerClient:
40
+ """Build a :class:`BeakerClient` from CLI credentials and agent selection.
41
+
42
+ Shared by every command group so the credential checks and error messages
43
+ stay identical across ``push``, ``spec``, ``dataset``, and ``run``.
44
+
45
+ ``client_cls`` lets each command module pass its own module-level
46
+ ``BeakerClient`` name so existing tests can monkeypatch the client at the
47
+ module they target.
48
+ """
49
+ if not args.api_key:
50
+ raise SystemExit(
51
+ "beaker: --api-key or $BEAKER_API_KEY is required. "
52
+ 'Run `beaker auth status`, then `beaker login --agent --agent-name "<Agent Name>"` '
53
+ "to create repo-local credentials."
54
+ )
55
+ agent_key = require_agent_key(args, config_agent_key=config_agent_key)
56
+ return client_cls(base_url=args.base_url, api_key=args.api_key, agent_key=agent_key)
57
+
58
+
59
+ def require_agent_key(args: argparse.Namespace, *, config_agent_key: str | None = None) -> str:
60
+ agent_key = resolve_agent_key(args, config_agent_key=config_agent_key)
61
+ if not agent_key:
62
+ raise SystemExit(
63
+ "beaker: --agent/--agent-key, $BEAKER_AGENT_KEY, or "
64
+ f"{project_config_relative_path().as_posix()} agent_key is required. "
65
+ 'Run `beaker login --agent --agent-name "<Agent Name>"` after selecting the optimization target.'
66
+ )
67
+ return agent_key
68
+
69
+
70
+ def resolve_agent_key(args: argparse.Namespace, *, config_agent_key: str | None = None) -> str | None:
71
+ """Resolve agent selection using flag > env > config precedence."""
72
+ flag_value = _clean_optional_text(getattr(args, "agent_key", None))
73
+ if flag_value:
74
+ return flag_value
75
+ env_value = _clean_optional_text(os.environ.get("BEAKER_AGENT_KEY"))
76
+ if env_value:
77
+ return env_value
78
+ return _clean_optional_text(config_agent_key)
79
+
80
+
81
+ def config_agent_key_from_cwd(*, spec_table: Mapping[str, Any] | None = None) -> str | None:
82
+ """Read an agent key fallback from the nearest hidden config.
83
+
84
+ ``spec_table`` is the already-selected named/singular spec table for
85
+ commands that have one; it takes precedence over top-level config.
86
+ """
87
+ table_key = _agent_key_from_mapping(spec_table)
88
+ if table_key:
89
+ return table_key
90
+ found = find_beaker_yaml(Path.cwd())
91
+ if found is None:
92
+ return None
93
+ _config_path, raw = found
94
+ return _clean_optional_text(BeakerConfig.from_mapping(raw).agent_key)
95
+
96
+
97
+ def _agent_key_from_mapping(raw: Mapping[str, Any] | None) -> str | None:
98
+ if raw is None:
99
+ return None
100
+ return _clean_optional_text(raw.get("agent_key") or raw.get("project_key"))
101
+
102
+
103
+ def _clean_optional_text(value: Any) -> str | None:
104
+ if value is None:
105
+ return None
106
+ text = str(value).strip()
107
+ return text or None
beaker/cli/_format.py ADDED
@@ -0,0 +1,75 @@
1
+ """Terminal color helpers for the ``beaker`` CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+ from typing import IO
8
+
9
+
10
+ _STYLES: dict[str, str] = {
11
+ "bold": "1",
12
+ "dim": "2",
13
+ "red": "31",
14
+ "green": "32",
15
+ "yellow": "33",
16
+ "blue": "34",
17
+ "magenta": "35",
18
+ "cyan": "36",
19
+ }
20
+ _RESET = "\033[0m"
21
+
22
+
23
+ def supports_color(stream: IO[str] | None = None) -> bool:
24
+ """Return whether the CLI should emit ANSI color."""
25
+ if os.environ.get("NO_COLOR") or os.environ.get("TERM") == "dumb":
26
+ return False
27
+ target = stream if stream is not None else sys.stdout
28
+ return hasattr(target, "isatty") and target.isatty()
29
+
30
+
31
+ def paint(text: object, *styles: str, stream: IO[str] | None = None) -> str:
32
+ """Wrap ``text`` in ANSI styles."""
33
+ value = str(text)
34
+ if not styles or not supports_color(stream):
35
+ return value
36
+ codes = [_STYLES[style] for style in styles if style in _STYLES]
37
+ if not codes:
38
+ return value
39
+ return f"\033[{';'.join(codes)}m{value}{_RESET}"
40
+
41
+
42
+ def heading(text: object, *, stream: IO[str] | None = None) -> str:
43
+ return paint(text, "bold", stream=stream)
44
+
45
+
46
+ def key(text: object, *, stream: IO[str] | None = None) -> str:
47
+ return paint(text, "cyan", stream=stream)
48
+
49
+
50
+ def ok(text: object, *, stream: IO[str] | None = None) -> str:
51
+ return paint(text, "green", "bold", stream=stream)
52
+
53
+
54
+ def warn(text: object, *, stream: IO[str] | None = None) -> str:
55
+ return paint(text, "yellow", "bold", stream=stream)
56
+
57
+
58
+ def error(text: object, *, stream: IO[str] | None = None) -> str:
59
+ return paint(text, "red", "bold", stream=stream)
60
+
61
+
62
+ def muted(text: object, *, stream: IO[str] | None = None) -> str:
63
+ return paint(text, "dim", stream=stream)
64
+
65
+
66
+ def status(text: object, *, stream: IO[str] | None = None) -> str:
67
+ value = str(text)
68
+ normalized = value.upper()
69
+ if normalized in {"READY", "COMPLETED", "COMPLETE", "PASSED", "APPROVED"}:
70
+ return ok(value, stream=stream)
71
+ if normalized in {"FAILED", "ERROR", "DENIED", "EXPIRED", "CANCELLED", "CONSUMED"}:
72
+ return error(value, stream=stream)
73
+ if normalized in {"WARN", "WARNING", "BUILDING", "QUEUED", "RUNNING", "SCAFFOLD"}:
74
+ return warn(value, stream=stream)
75
+ return key(value, stream=stream)