beaker-sdk 0.3.5__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. beaker_sdk-0.3.5/PKG-INFO +58 -0
  2. beaker_sdk-0.3.5/README.md +32 -0
  3. beaker_sdk-0.3.5/pyproject.toml +45 -0
  4. beaker_sdk-0.3.5/src/beaker/__init__.py +126 -0
  5. beaker_sdk-0.3.5/src/beaker/_bundle_digest.py +59 -0
  6. beaker_sdk-0.3.5/src/beaker/_image_dependency_validation.py +171 -0
  7. beaker_sdk-0.3.5/src/beaker/artifact_manifest.py +126 -0
  8. beaker_sdk-0.3.5/src/beaker/cli/__init__.py +6 -0
  9. beaker_sdk-0.3.5/src/beaker/cli/__main__.py +13 -0
  10. beaker_sdk-0.3.5/src/beaker/cli/_common.py +107 -0
  11. beaker_sdk-0.3.5/src/beaker/cli/_format.py +75 -0
  12. beaker_sdk-0.3.5/src/beaker/cli/agent_setup.py +228 -0
  13. beaker_sdk-0.3.5/src/beaker/cli/auth_cmd.py +156 -0
  14. beaker_sdk-0.3.5/src/beaker/cli/beaker_config.py +224 -0
  15. beaker_sdk-0.3.5/src/beaker/cli/browser_login.py +128 -0
  16. beaker_sdk-0.3.5/src/beaker/cli/credentials.py +56 -0
  17. beaker_sdk-0.3.5/src/beaker/cli/init_cmd.py +1208 -0
  18. beaker_sdk-0.3.5/src/beaker/cli/init_templates.py +783 -0
  19. beaker_sdk-0.3.5/src/beaker/cli/main.py +710 -0
  20. beaker_sdk-0.3.5/src/beaker/cli/project_env.py +108 -0
  21. beaker_sdk-0.3.5/src/beaker/cli/run_ops.py +889 -0
  22. beaker_sdk-0.3.5/src/beaker/cli/spec_resolution.py +164 -0
  23. beaker_sdk-0.3.5/src/beaker/cli/trace_ops.py +82 -0
  24. beaker_sdk-0.3.5/src/beaker/client.py +1086 -0
  25. beaker_sdk-0.3.5/src/beaker/config.py +87 -0
  26. beaker_sdk-0.3.5/src/beaker/dataset_schema.py +98 -0
  27. beaker_sdk-0.3.5/src/beaker/project_layout.py +38 -0
  28. beaker_sdk-0.3.5/src/beaker/py.typed +0 -0
  29. beaker_sdk-0.3.5/src/beaker/run_status.py +7 -0
  30. beaker_sdk-0.3.5/src/beaker/sdk/__init__.py +149 -0
  31. beaker_sdk-0.3.5/src/beaker/sdk/candidate_diff.py +123 -0
  32. beaker_sdk-0.3.5/src/beaker/sdk/dataset.py +289 -0
  33. beaker_sdk-0.3.5/src/beaker/sdk/domain_contracts.py +144 -0
  34. beaker_sdk-0.3.5/src/beaker/sdk/evaluation.py +113 -0
  35. beaker_sdk-0.3.5/src/beaker/sdk/field_access.py +46 -0
  36. beaker_sdk-0.3.5/src/beaker/sdk/inference.py +87 -0
  37. beaker_sdk-0.3.5/src/beaker/sdk/models.py +607 -0
  38. beaker_sdk-0.3.5/src/beaker/sdk/reflection_evidence.py +362 -0
  39. beaker_sdk-0.3.5/src/beaker/sdk/resources.py +174 -0
  40. beaker_sdk-0.3.5/src/beaker/sdk/spec_contract.py +327 -0
  41. beaker_sdk-0.3.5/src/beaker/sdk/utils.py +193 -0
  42. beaker_sdk-0.3.5/src/beaker/spec.py +192 -0
  43. beaker_sdk-0.3.5/src/beaker/testing/__init__.py +4 -0
  44. beaker_sdk-0.3.5/src/beaker/testing/tracing.py +6 -0
  45. beaker_sdk-0.3.5/src/beaker/tracing/README.md +126 -0
  46. beaker_sdk-0.3.5/src/beaker/tracing/__init__.py +24 -0
  47. beaker_sdk-0.3.5/src/beaker/tracing/core.py +912 -0
  48. beaker_sdk-0.3.5/src/beaker/tracing/integrations/__init__.py +6 -0
  49. beaker_sdk-0.3.5/src/beaker/tracing/integrations/pydantic_ai.py +198 -0
  50. beaker_sdk-0.3.5/src/beaker/tracing/otlp.py +160 -0
  51. beaker_sdk-0.3.5/src/beaker/tracing/projection.py +564 -0
@@ -0,0 +1,58 @@
1
+ Metadata-Version: 2.4
2
+ Name: beaker-sdk
3
+ Version: 0.3.5
4
+ Summary: Beaker prompt-optimization SDK and CLI by RilixAI.
5
+ Author: RilixAI
6
+ License-Expression: MIT
7
+ Classifier: Development Status :: 3 - Alpha
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: Programming Language :: Python :: 3.13
12
+ Classifier: Programming Language :: Python :: 3.14
13
+ Requires-Dist: pyyaml>=6
14
+ Requires-Dist: pydantic-ai>=1.74 ; extra == 'pydantic-ai'
15
+ Requires-Dist: opentelemetry-api>=1.30,<2 ; extra == 'pydantic-ai'
16
+ Requires-Dist: opentelemetry-sdk>=1.30,<2 ; extra == 'pydantic-ai'
17
+ Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.30,<2 ; extra == 'pydantic-ai'
18
+ Requires-Dist: opentelemetry-api>=1.30,<2 ; extra == 'tracing'
19
+ Requires-Dist: opentelemetry-sdk>=1.30,<2 ; extra == 'tracing'
20
+ Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.30,<2 ; extra == 'tracing'
21
+ Requires-Dist: opentelemetry-proto>=1.30,<2 ; extra == 'tracing'
22
+ Requires-Python: >=3.12
23
+ Provides-Extra: pydantic-ai
24
+ Provides-Extra: tracing
25
+ Description-Content-Type: text/markdown
26
+
27
+ # beaker
28
+
29
+ Beaker SDK and CLI package.
30
+
31
+ `beaker` gives developers the tools to define optimization specs,
32
+ validate them locally, upload datasets, configure hosted run environments,
33
+ launch optimization runs, and inspect results from code or the CLI.
34
+
35
+ Customer specs import contract types directly from `beaker`:
36
+
37
+ ```python
38
+ from beaker import Case, CaseDataLoader, CaseResult, CaseScore, Spec, spec
39
+ ```
40
+
41
+ Hosted model-selection rollouts also expose a generic OpenAI-compatible target:
42
+
43
+ ```python
44
+ from beaker import RolloutContext, inference_target
45
+
46
+
47
+ def build_eval_client(runtime: RolloutContext):
48
+ target = inference_target(runtime)
49
+ return YourFrameworkClient(
50
+ base_url=target.base_url,
51
+ api_key=target.api_key,
52
+ model=target.model,
53
+ )
54
+ ```
55
+
56
+ Call `inference_target` only in the evaluation path when `runtime.model` is
57
+ set. Ordinary dry-runs and production application calls should retain the
58
+ application's existing model and client defaults.
@@ -0,0 +1,32 @@
1
+ # beaker
2
+
3
+ Beaker SDK and CLI package.
4
+
5
+ `beaker` gives developers the tools to define optimization specs,
6
+ validate them locally, upload datasets, configure hosted run environments,
7
+ launch optimization runs, and inspect results from code or the CLI.
8
+
9
+ Customer specs import contract types directly from `beaker`:
10
+
11
+ ```python
12
+ from beaker import Case, CaseDataLoader, CaseResult, CaseScore, Spec, spec
13
+ ```
14
+
15
+ Hosted model-selection rollouts also expose a generic OpenAI-compatible target:
16
+
17
+ ```python
18
+ from beaker import RolloutContext, inference_target
19
+
20
+
21
+ def build_eval_client(runtime: RolloutContext):
22
+ target = inference_target(runtime)
23
+ return YourFrameworkClient(
24
+ base_url=target.base_url,
25
+ api_key=target.api_key,
26
+ model=target.model,
27
+ )
28
+ ```
29
+
30
+ Call `inference_target` only in the evaluation path when `runtime.model` is
31
+ set. Ordinary dry-runs and production application calls should retain the
32
+ application's existing model and client defaults.
@@ -0,0 +1,45 @@
1
+ [project]
2
+ name = "beaker-sdk"
3
+ version = "0.3.5"
4
+ description = "Beaker prompt-optimization SDK and CLI by RilixAI."
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ license = "MIT"
8
+ authors = [
9
+ { name = "RilixAI" },
10
+ ]
11
+ classifiers = [
12
+ "Development Status :: 3 - Alpha",
13
+ "Intended Audience :: Developers",
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3.12",
16
+ "Programming Language :: Python :: 3.13",
17
+ "Programming Language :: Python :: 3.14",
18
+ ]
19
+ dependencies = [
20
+ "pyyaml>=6",
21
+ ]
22
+
23
+ [project.optional-dependencies]
24
+ tracing = [
25
+ "opentelemetry-api>=1.30,<2",
26
+ "opentelemetry-sdk>=1.30,<2",
27
+ "opentelemetry-exporter-otlp-proto-http>=1.30,<2",
28
+ "opentelemetry-proto>=1.30,<2",
29
+ ]
30
+ pydantic-ai = [
31
+ "pydantic-ai>=1.74",
32
+ "opentelemetry-api>=1.30,<2",
33
+ "opentelemetry-sdk>=1.30,<2",
34
+ "opentelemetry-exporter-otlp-proto-http>=1.30,<2",
35
+ ]
36
+
37
+ [project.scripts]
38
+ beaker = "beaker.cli:main"
39
+
40
+ [build-system]
41
+ requires = ["uv_build>=0.9.4,<0.10.0"]
42
+ build-backend = "uv_build"
43
+
44
+ [tool.uv.build-backend]
45
+ module-name = "beaker"
@@ -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
@@ -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"]
@@ -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())