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.
- beaker_sdk-0.3.5/PKG-INFO +58 -0
- beaker_sdk-0.3.5/README.md +32 -0
- beaker_sdk-0.3.5/pyproject.toml +45 -0
- beaker_sdk-0.3.5/src/beaker/__init__.py +126 -0
- beaker_sdk-0.3.5/src/beaker/_bundle_digest.py +59 -0
- beaker_sdk-0.3.5/src/beaker/_image_dependency_validation.py +171 -0
- beaker_sdk-0.3.5/src/beaker/artifact_manifest.py +126 -0
- beaker_sdk-0.3.5/src/beaker/cli/__init__.py +6 -0
- beaker_sdk-0.3.5/src/beaker/cli/__main__.py +13 -0
- beaker_sdk-0.3.5/src/beaker/cli/_common.py +107 -0
- beaker_sdk-0.3.5/src/beaker/cli/_format.py +75 -0
- beaker_sdk-0.3.5/src/beaker/cli/agent_setup.py +228 -0
- beaker_sdk-0.3.5/src/beaker/cli/auth_cmd.py +156 -0
- beaker_sdk-0.3.5/src/beaker/cli/beaker_config.py +224 -0
- beaker_sdk-0.3.5/src/beaker/cli/browser_login.py +128 -0
- beaker_sdk-0.3.5/src/beaker/cli/credentials.py +56 -0
- beaker_sdk-0.3.5/src/beaker/cli/init_cmd.py +1208 -0
- beaker_sdk-0.3.5/src/beaker/cli/init_templates.py +783 -0
- beaker_sdk-0.3.5/src/beaker/cli/main.py +710 -0
- beaker_sdk-0.3.5/src/beaker/cli/project_env.py +108 -0
- beaker_sdk-0.3.5/src/beaker/cli/run_ops.py +889 -0
- beaker_sdk-0.3.5/src/beaker/cli/spec_resolution.py +164 -0
- beaker_sdk-0.3.5/src/beaker/cli/trace_ops.py +82 -0
- beaker_sdk-0.3.5/src/beaker/client.py +1086 -0
- beaker_sdk-0.3.5/src/beaker/config.py +87 -0
- beaker_sdk-0.3.5/src/beaker/dataset_schema.py +98 -0
- beaker_sdk-0.3.5/src/beaker/project_layout.py +38 -0
- beaker_sdk-0.3.5/src/beaker/py.typed +0 -0
- beaker_sdk-0.3.5/src/beaker/run_status.py +7 -0
- beaker_sdk-0.3.5/src/beaker/sdk/__init__.py +149 -0
- beaker_sdk-0.3.5/src/beaker/sdk/candidate_diff.py +123 -0
- beaker_sdk-0.3.5/src/beaker/sdk/dataset.py +289 -0
- beaker_sdk-0.3.5/src/beaker/sdk/domain_contracts.py +144 -0
- beaker_sdk-0.3.5/src/beaker/sdk/evaluation.py +113 -0
- beaker_sdk-0.3.5/src/beaker/sdk/field_access.py +46 -0
- beaker_sdk-0.3.5/src/beaker/sdk/inference.py +87 -0
- beaker_sdk-0.3.5/src/beaker/sdk/models.py +607 -0
- beaker_sdk-0.3.5/src/beaker/sdk/reflection_evidence.py +362 -0
- beaker_sdk-0.3.5/src/beaker/sdk/resources.py +174 -0
- beaker_sdk-0.3.5/src/beaker/sdk/spec_contract.py +327 -0
- beaker_sdk-0.3.5/src/beaker/sdk/utils.py +193 -0
- beaker_sdk-0.3.5/src/beaker/spec.py +192 -0
- beaker_sdk-0.3.5/src/beaker/testing/__init__.py +4 -0
- beaker_sdk-0.3.5/src/beaker/testing/tracing.py +6 -0
- beaker_sdk-0.3.5/src/beaker/tracing/README.md +126 -0
- beaker_sdk-0.3.5/src/beaker/tracing/__init__.py +24 -0
- beaker_sdk-0.3.5/src/beaker/tracing/core.py +912 -0
- beaker_sdk-0.3.5/src/beaker/tracing/integrations/__init__.py +6 -0
- beaker_sdk-0.3.5/src/beaker/tracing/integrations/pydantic_ai.py +198 -0
- beaker_sdk-0.3.5/src/beaker/tracing/otlp.py +160 -0
- 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,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())
|