convergent-cli 0.1.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.
Potentially problematic release.
This version of convergent-cli might be problematic. Click here for more details.
- convergent_cli/__init__.py +1 -0
- convergent_cli/auth.py +227 -0
- convergent_cli/build_context.py +225 -0
- convergent_cli/calibration_finish.py +310 -0
- convergent_cli/calibration_frontier.py +499 -0
- convergent_cli/calibration_runner.py +1413 -0
- convergent_cli/calibration_send.py +44 -0
- convergent_cli/codex_app_server.py +127 -0
- convergent_cli/command_tree.py +102 -0
- convergent_cli/commands/__init__.py +58 -0
- convergent_cli/commands/agents.py +729 -0
- convergent_cli/commands/auth.py +117 -0
- convergent_cli/commands/calibration.py +3857 -0
- convergent_cli/commands/calibration_labels_load.py +627 -0
- convergent_cli/commands/capabilities.py +85 -0
- convergent_cli/commands/dataset_authoring.py +964 -0
- convergent_cli/commands/dataset_import.py +763 -0
- convergent_cli/commands/datasets.py +678 -0
- convergent_cli/commands/docs.py +120 -0
- convergent_cli/commands/doctor.py +124 -0
- convergent_cli/commands/environments.py +63 -0
- convergent_cli/commands/findings.py +206 -0
- convergent_cli/commands/graders.py +803 -0
- convergent_cli/commands/harness.py +366 -0
- convergent_cli/commands/issues.py +388 -0
- convergent_cli/commands/production.py +173 -0
- convergent_cli/commands/profile.py +86 -0
- convergent_cli/commands/rubrics.py +14 -0
- convergent_cli/commands/runs.py +1763 -0
- convergent_cli/commands/setup.py +968 -0
- convergent_cli/commands/task_qc.py +1041 -0
- convergent_cli/commands/task_qc_reports.py +393 -0
- convergent_cli/commands/verdicts.py +156 -0
- convergent_cli/commands/workspaces.py +170 -0
- convergent_cli/commands/worlds.py +292 -0
- convergent_cli/confirm.py +59 -0
- convergent_cli/control_client.py +141 -0
- convergent_cli/customer_client.py +1272 -0
- convergent_cli/docs_client.py +165 -0
- convergent_cli/errors.py +212 -0
- convergent_cli/harness_run.py +1169 -0
- convergent_cli/harness_tree.py +854 -0
- convergent_cli/installed_agent.py +1071 -0
- convergent_cli/json_files.py +23 -0
- convergent_cli/local_files.py +64 -0
- convergent_cli/main.py +106 -0
- convergent_cli/oauth.py +431 -0
- convergent_cli/output.py +15 -0
- convergent_cli/paging.py +62 -0
- convergent_cli/run_calibration.py +361 -0
- convergent_cli/run_usage.py +99 -0
- convergent_cli/sessions.py +275 -0
- convergent_cli/setup_agents.py +190 -0
- convergent_cli/setup_auth.py +463 -0
- convergent_cli/setup_instrument.py +244 -0
- convergent_cli/setup_mcp.py +525 -0
- convergent_cli/setup_wizard.py +125 -0
- convergent_cli/skill_files/convergent/SKILL.md +25 -0
- convergent_cli/skill_files/convergent-instrument/SKILL.md +15 -0
- convergent_cli/skill_files/convergent-verify/SKILL.md +15 -0
- convergent_cli/skills.py +489 -0
- convergent_cli/state.py +297 -0
- convergent_cli/transport.py +242 -0
- convergent_cli/verdict_feedback.py +161 -0
- convergent_cli/verifier_bench_preflight.py +32 -0
- convergent_cli/world_docker.py +145 -0
- convergent_cli/world_files.py +432 -0
- convergent_cli/world_live.py +660 -0
- convergent_cli/world_mcp.py +109 -0
- convergent_cli/world_probe_requirements.txt +29 -0
- convergent_cli/world_template/Dockerfile +10 -0
- convergent_cli/world_template/README.md +38 -0
- convergent_cli/world_template/convergent.toml +11 -0
- convergent_cli/world_template/examples/default/environment/catalog.json +3 -0
- convergent_cli/world_template/examples/default/instruction.md +1 -0
- convergent_cli/world_template/requirements.txt +1 -0
- convergent_cli/world_template/schemas/catalog.json +5 -0
- convergent_cli/world_template/server.py +25 -0
- convergent_cli-0.1.0.dist-info/METADATA +679 -0
- convergent_cli-0.1.0.dist-info/RECORD +83 -0
- convergent_cli-0.1.0.dist-info/WHEEL +4 -0
- convergent_cli-0.1.0.dist-info/entry_points.txt +2 -0
- convergent_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""The Convergent command line tool (`cv`)."""
|
convergent_cli/auth.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""High-level auth commands for the public CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Mapping
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from shutil import rmtree
|
|
10
|
+
|
|
11
|
+
from convergent_cli.control_client import get_control_json_at
|
|
12
|
+
from convergent_cli.customer_client import forget_calibration_tokens
|
|
13
|
+
from convergent_cli.errors import (
|
|
14
|
+
AuthError,
|
|
15
|
+
ControlRefusedError,
|
|
16
|
+
NetworkError,
|
|
17
|
+
SessionRejectedError,
|
|
18
|
+
UsageError,
|
|
19
|
+
)
|
|
20
|
+
from convergent_cli.oauth import browser_sign_in, headless_sign_in, resolve_client_id
|
|
21
|
+
from convergent_cli.output import json_text
|
|
22
|
+
from convergent_cli.sessions import (
|
|
23
|
+
StoredSession,
|
|
24
|
+
access_token_for_profile,
|
|
25
|
+
remove_session,
|
|
26
|
+
save_session,
|
|
27
|
+
session_path,
|
|
28
|
+
)
|
|
29
|
+
from convergent_cli.state import (
|
|
30
|
+
Config,
|
|
31
|
+
active_profile_name,
|
|
32
|
+
config_path,
|
|
33
|
+
load_config,
|
|
34
|
+
save_config,
|
|
35
|
+
validate_origin,
|
|
36
|
+
validate_profile_name,
|
|
37
|
+
write_private_file,
|
|
38
|
+
)
|
|
39
|
+
from convergent_cli.transport import Transport, UrllibTransport
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True, slots=True)
|
|
43
|
+
class LoginResult:
|
|
44
|
+
profile_name: str
|
|
45
|
+
origin: str
|
|
46
|
+
session_file: Path
|
|
47
|
+
identity: Mapping[str, object]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def login(
|
|
51
|
+
*,
|
|
52
|
+
profile_name: str | None,
|
|
53
|
+
control_api_url: str | None,
|
|
54
|
+
client_id: str | None,
|
|
55
|
+
headless: bool,
|
|
56
|
+
transport: Transport | None = None,
|
|
57
|
+
announce: Callable[[str], None] = print,
|
|
58
|
+
read_redirect: Callable[[], str] = input,
|
|
59
|
+
) -> LoginResult:
|
|
60
|
+
config = load_config()
|
|
61
|
+
name, origin, updated = _login_target(config, profile_name, control_api_url)
|
|
62
|
+
oauth_client = resolve_client_id(client_id, origin)
|
|
63
|
+
http = transport or UrllibTransport()
|
|
64
|
+
if headless:
|
|
65
|
+
session = headless_sign_in(
|
|
66
|
+
origin=origin,
|
|
67
|
+
client_id=oauth_client,
|
|
68
|
+
transport=http,
|
|
69
|
+
announce=announce,
|
|
70
|
+
read_redirect=read_redirect,
|
|
71
|
+
)
|
|
72
|
+
else:
|
|
73
|
+
session = browser_sign_in(
|
|
74
|
+
origin=origin,
|
|
75
|
+
client_id=oauth_client,
|
|
76
|
+
transport=http,
|
|
77
|
+
announce=announce,
|
|
78
|
+
)
|
|
79
|
+
session_file = _save_login_state(name, updated, session)
|
|
80
|
+
identity = _verify_login(name, origin, session.access_token, http)
|
|
81
|
+
return LoginResult(
|
|
82
|
+
profile_name=name, origin=origin, session_file=session_file, identity=identity
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def logout(*, profile_name: str | None) -> tuple[str, bool]:
|
|
87
|
+
config = load_config()
|
|
88
|
+
name = validate_profile_name(profile_name) if profile_name else active_profile_name(config)
|
|
89
|
+
config.profile(name)
|
|
90
|
+
removed = remove_session(name)
|
|
91
|
+
cache = config_path().parent / "calibration-tokens"
|
|
92
|
+
if cache.exists():
|
|
93
|
+
rmtree(cache)
|
|
94
|
+
forget_calibration_tokens()
|
|
95
|
+
return name, removed
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def whoami(
|
|
99
|
+
*,
|
|
100
|
+
profile_name: str | None,
|
|
101
|
+
transport: Transport | None = None,
|
|
102
|
+
now: datetime | None = None,
|
|
103
|
+
) -> Mapping[str, object]:
|
|
104
|
+
http = transport or UrllibTransport()
|
|
105
|
+
config = load_config()
|
|
106
|
+
name = validate_profile_name(profile_name) if profile_name else active_profile_name(config)
|
|
107
|
+
profile = config.profile(name)
|
|
108
|
+
token = access_token_for_profile(
|
|
109
|
+
name,
|
|
110
|
+
profile.origin,
|
|
111
|
+
transport=http,
|
|
112
|
+
now=now or datetime.now(UTC),
|
|
113
|
+
)
|
|
114
|
+
identity = get_control_json_at(
|
|
115
|
+
profile.origin, "/v1/auth/me", access_token=token, transport=http
|
|
116
|
+
)
|
|
117
|
+
return _identity_payload(name, profile.origin, identity)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def whoami_json(payload: Mapping[str, object]) -> str:
|
|
121
|
+
return json_text(payload)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _login_target(
|
|
125
|
+
config: Config,
|
|
126
|
+
profile_name: str | None,
|
|
127
|
+
control_api_url: str | None,
|
|
128
|
+
) -> tuple[str, str, Config]:
|
|
129
|
+
name = validate_profile_name(profile_name) if profile_name else active_profile_name(config)
|
|
130
|
+
existing = config.profiles.get(name)
|
|
131
|
+
if existing is None and control_api_url is None:
|
|
132
|
+
raise UsageError(
|
|
133
|
+
f"profile {name!r} does not exist yet, so cv has no origin for it "
|
|
134
|
+
"and will not guess one. Pass --control-api-url ORIGIN."
|
|
135
|
+
)
|
|
136
|
+
if control_api_url:
|
|
137
|
+
origin = validate_origin(control_api_url)
|
|
138
|
+
elif existing is not None:
|
|
139
|
+
origin = existing.origin
|
|
140
|
+
else:
|
|
141
|
+
raise AssertionError("unreachable login target state")
|
|
142
|
+
return name, origin, config.with_profile_origin(name, origin)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _verify_login(
|
|
146
|
+
profile_name: str, origin: str, access_token: str, http: Transport
|
|
147
|
+
) -> Mapping[str, object]:
|
|
148
|
+
try:
|
|
149
|
+
identity = get_control_json_at(
|
|
150
|
+
origin, "/v1/auth/me", access_token=access_token, transport=http
|
|
151
|
+
)
|
|
152
|
+
except SessionRejectedError as error:
|
|
153
|
+
raise SessionRejectedError(
|
|
154
|
+
f"the new session was rejected by {origin}; retry `cv login --profile {profile_name}`"
|
|
155
|
+
) from error
|
|
156
|
+
except ControlRefusedError as error:
|
|
157
|
+
raise ControlRefusedError(
|
|
158
|
+
f"signed in, but your account has no access to an organization at {origin}; "
|
|
159
|
+
"ask an administrator to add you"
|
|
160
|
+
) from error
|
|
161
|
+
except NetworkError as error:
|
|
162
|
+
raise NetworkError(
|
|
163
|
+
f"stored the session for profile {profile_name!r} but could not verify it: {error}"
|
|
164
|
+
) from error
|
|
165
|
+
return _identity_payload(profile_name, origin, identity)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _identity_payload(
|
|
169
|
+
profile_name: str,
|
|
170
|
+
origin: str,
|
|
171
|
+
identity: Mapping[str, object],
|
|
172
|
+
) -> Mapping[str, object]:
|
|
173
|
+
user_id = _required_string(identity, "user_id")
|
|
174
|
+
email = _required_string(identity, "email")
|
|
175
|
+
organization_id = _required_string(identity, "organization_id")
|
|
176
|
+
is_staff = identity.get("is_staff")
|
|
177
|
+
if not isinstance(is_staff, bool):
|
|
178
|
+
raise AuthError("/v1/auth/me returned no usable is_staff value")
|
|
179
|
+
permissions = identity.get("permissions")
|
|
180
|
+
if not isinstance(permissions, list) or not all(
|
|
181
|
+
isinstance(permission, str) for permission in permissions
|
|
182
|
+
):
|
|
183
|
+
raise AuthError("/v1/auth/me returned no usable permissions list")
|
|
184
|
+
return {
|
|
185
|
+
"profile": profile_name,
|
|
186
|
+
"origin": origin,
|
|
187
|
+
"user_id": user_id,
|
|
188
|
+
"email": email,
|
|
189
|
+
"organization_id": organization_id,
|
|
190
|
+
"is_staff": is_staff,
|
|
191
|
+
"permissions": permissions,
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _save_login_state(profile_name: str, config: Config, session: StoredSession) -> Path:
|
|
196
|
+
config_file = config_path()
|
|
197
|
+
session_file = session_path(profile_name)
|
|
198
|
+
previous_config = _file_snapshot(config_file)
|
|
199
|
+
previous_session = _file_snapshot(session_file)
|
|
200
|
+
try:
|
|
201
|
+
saved_session = save_session(profile_name, session)
|
|
202
|
+
save_config(config)
|
|
203
|
+
return saved_session
|
|
204
|
+
except BaseException:
|
|
205
|
+
_restore_file(config_file, previous_config)
|
|
206
|
+
_restore_file(session_file, previous_session)
|
|
207
|
+
raise
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _file_snapshot(path: Path) -> str | None:
|
|
211
|
+
if not path.exists():
|
|
212
|
+
return None
|
|
213
|
+
return path.read_text(encoding="utf-8")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _restore_file(path: Path, contents: str | None) -> None:
|
|
217
|
+
if contents is None:
|
|
218
|
+
path.unlink(missing_ok=True)
|
|
219
|
+
return
|
|
220
|
+
write_private_file(path, contents)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _required_string(identity: Mapping[str, object], key: str) -> str:
|
|
224
|
+
value = identity.get(key)
|
|
225
|
+
if not isinstance(value, str) or not value:
|
|
226
|
+
raise AuthError(f"/v1/auth/me returned no usable {key}")
|
|
227
|
+
return value
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Reproducible build-context archives for hosted builds, and their safe extraction.
|
|
2
|
+
|
|
3
|
+
``pack_build_context`` packs one project directory with a root ``Dockerfile``.
|
|
4
|
+
It leaves out, at any depth, version-control and cache directories, local
|
|
5
|
+
credential directories and files, and ``.env`` files (``SKIPPED_DIRECTORIES``
|
|
6
|
+
and ``SKIPPED_FILES``). With ``dockerignore`` it also leaves out what the root
|
|
7
|
+
``.dockerignore`` excludes, by Docker's rules: ``*``, ``?``, ``[...]``, and
|
|
8
|
+
``**`` patterns relative to the root, a pattern that matches a directory
|
|
9
|
+
excludes everything under it, ``!`` includes a path again, and the last
|
|
10
|
+
matching line wins. The root ``Dockerfile`` is always kept. The archive is
|
|
11
|
+
reproducible: entries in path order, no owner or time, modes of 0755 or 0644 by
|
|
12
|
+
the owner's execute bit, and a gzip header with no name or time.
|
|
13
|
+
|
|
14
|
+
``extract_build_context`` unpacks an archive into a new directory, refusing
|
|
15
|
+
anything but files and directories, paths outside it, duplicate entries, and
|
|
16
|
+
archives over the build service's limits, which this package repeats because it
|
|
17
|
+
cannot import the server. Nothing reaches the target unless every entry is
|
|
18
|
+
written.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import gzip
|
|
24
|
+
import io
|
|
25
|
+
import os
|
|
26
|
+
import posixpath
|
|
27
|
+
import re
|
|
28
|
+
import stat
|
|
29
|
+
import tarfile
|
|
30
|
+
import tempfile
|
|
31
|
+
from pathlib import Path, PurePosixPath
|
|
32
|
+
|
|
33
|
+
from convergent_cli.errors import UsageError
|
|
34
|
+
|
|
35
|
+
MAX_UPLOAD_BYTES = 6 * 1024 * 1024
|
|
36
|
+
"""The largest archive the server takes in one request; a larger one needs chunked upload."""
|
|
37
|
+
MAX_ARCHIVE_BYTES = 25 * 1024 * 1024
|
|
38
|
+
MAX_EXPANDED_BYTES = 100 * 1024 * 1024
|
|
39
|
+
MAX_ENTRIES = 10_000
|
|
40
|
+
SKIPPED_DIRECTORIES = frozenset(
|
|
41
|
+
{
|
|
42
|
+
".git",
|
|
43
|
+
".hg",
|
|
44
|
+
".svn",
|
|
45
|
+
".venv",
|
|
46
|
+
"venv",
|
|
47
|
+
"node_modules",
|
|
48
|
+
"__pycache__",
|
|
49
|
+
".mypy_cache",
|
|
50
|
+
".pytest_cache",
|
|
51
|
+
".ruff_cache",
|
|
52
|
+
".tox",
|
|
53
|
+
".aws",
|
|
54
|
+
".docker",
|
|
55
|
+
".gnupg",
|
|
56
|
+
".kube",
|
|
57
|
+
".ssh",
|
|
58
|
+
}
|
|
59
|
+
)
|
|
60
|
+
SKIPPED_FILES = frozenset(
|
|
61
|
+
{
|
|
62
|
+
".DS_Store",
|
|
63
|
+
".env",
|
|
64
|
+
".envrc",
|
|
65
|
+
".git-credentials",
|
|
66
|
+
".netrc",
|
|
67
|
+
".npmrc",
|
|
68
|
+
".pgpass",
|
|
69
|
+
".pypirc",
|
|
70
|
+
}
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def pack_build_context(root: Path, *, dockerignore: bool = False) -> tuple[bytes, list[str]]:
|
|
75
|
+
"""``root`` as a reproducible tar.gz, and the paths it holds."""
|
|
76
|
+
if not (root / "Dockerfile").is_file():
|
|
77
|
+
raise UsageError(f"{root} has no Dockerfile at its root")
|
|
78
|
+
rules = _dockerignore_rules(root) if dockerignore else []
|
|
79
|
+
# Without a ``!`` line nothing under an excluded directory comes back, so skip walking it.
|
|
80
|
+
prune = bool(rules) and not any(keep for keep, _ in rules)
|
|
81
|
+
entries: list[Path] = []
|
|
82
|
+
for parent, dirs, names in os.walk(root):
|
|
83
|
+
relative_parent = Path(parent).relative_to(root).as_posix()
|
|
84
|
+
dirs[:] = [
|
|
85
|
+
name
|
|
86
|
+
for name in dirs
|
|
87
|
+
if name not in SKIPPED_DIRECTORIES
|
|
88
|
+
and not (prune and _ignored(rules, posixpath.normpath(f"{relative_parent}/{name}")))
|
|
89
|
+
]
|
|
90
|
+
entries += [
|
|
91
|
+
Path(parent, name)
|
|
92
|
+
for name in (*dirs, *names)
|
|
93
|
+
if name not in SKIPPED_FILES and not name.startswith(".env.")
|
|
94
|
+
]
|
|
95
|
+
paths = sorted(path.relative_to(root).as_posix() for path in entries)
|
|
96
|
+
if rules:
|
|
97
|
+
paths = dockerignore_kept(root, paths)
|
|
98
|
+
buffer = io.BytesIO()
|
|
99
|
+
with (
|
|
100
|
+
gzip.GzipFile(fileobj=buffer, mode="wb", mtime=0) as zipped,
|
|
101
|
+
tarfile.open(fileobj=zipped, mode="w", format=tarfile.PAX_FORMAT) as archive,
|
|
102
|
+
):
|
|
103
|
+
for relative in paths:
|
|
104
|
+
path = root / relative
|
|
105
|
+
info = tarfile.TarInfo(relative)
|
|
106
|
+
mode = path.lstat().st_mode
|
|
107
|
+
if stat.S_ISDIR(mode):
|
|
108
|
+
info.type, info.mode = tarfile.DIRTYPE, 0o755
|
|
109
|
+
archive.addfile(info)
|
|
110
|
+
elif stat.S_ISREG(mode):
|
|
111
|
+
info.size, info.mode = path.stat().st_size, 0o755 if mode & 0o100 else 0o644
|
|
112
|
+
with path.open("rb") as content:
|
|
113
|
+
archive.addfile(info, content)
|
|
114
|
+
else:
|
|
115
|
+
raise UsageError(f"{relative} is a symlink or special file; copy its target")
|
|
116
|
+
data = buffer.getvalue()
|
|
117
|
+
if len(data) > MAX_UPLOAD_BYTES:
|
|
118
|
+
hint = "; leave out what the image does not need with .dockerignore" if dockerignore else ""
|
|
119
|
+
raise UsageError(f"the build context is over 6 MiB compressed ({len(data)} bytes){hint}")
|
|
120
|
+
return data, paths
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def dockerignore_kept(root: Path, paths: list[str]) -> list[str]:
|
|
124
|
+
"""``paths``, relative ``/`` paths under ``root``, less what its ``.dockerignore`` leaves out,
|
|
125
|
+
as ``docker build`` does: the root ``Dockerfile`` stays, and a kept path keeps the directories
|
|
126
|
+
above it."""
|
|
127
|
+
rules = _dockerignore_rules(root)
|
|
128
|
+
if not rules:
|
|
129
|
+
return paths
|
|
130
|
+
kept = {path for path in paths if path == "Dockerfile" or not _ignored(rules, path)}
|
|
131
|
+
kept |= {str(up) for path in kept for up in PurePosixPath(path).parents if str(up) != "."}
|
|
132
|
+
return [path for path in paths if path in kept]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _dockerignore_rules(root: Path) -> list[tuple[bool, re.Pattern[str]]]:
|
|
136
|
+
"""The root ``.dockerignore`` as ``(keep, pattern)`` rules, in file order."""
|
|
137
|
+
path = root / ".dockerignore"
|
|
138
|
+
if not path.is_file():
|
|
139
|
+
return []
|
|
140
|
+
rules = []
|
|
141
|
+
for raw in path.read_text(encoding="utf-8").splitlines():
|
|
142
|
+
line = raw.strip()
|
|
143
|
+
if not line or line.startswith("#"):
|
|
144
|
+
continue
|
|
145
|
+
keep = line.startswith("!")
|
|
146
|
+
pattern = posixpath.normpath(line[1:].strip() if keep else line).lstrip("/")
|
|
147
|
+
if pattern and pattern != ".":
|
|
148
|
+
rules.append((keep, re.compile(_pattern_regex(pattern))))
|
|
149
|
+
return rules
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _pattern_regex(pattern: str) -> str:
|
|
153
|
+
"""A Docker ignore pattern as an anchored regular expression over ``/`` paths."""
|
|
154
|
+
out, i = ["^"], 0
|
|
155
|
+
while i < len(pattern):
|
|
156
|
+
char = pattern[i]
|
|
157
|
+
if pattern.startswith("**/", i):
|
|
158
|
+
out.append("(?:.*/)?")
|
|
159
|
+
i += 3
|
|
160
|
+
continue
|
|
161
|
+
if pattern.startswith("**", i):
|
|
162
|
+
out.append(".*")
|
|
163
|
+
i += 2
|
|
164
|
+
continue
|
|
165
|
+
if char == "*":
|
|
166
|
+
out.append("[^/]*")
|
|
167
|
+
elif char == "?":
|
|
168
|
+
out.append("[^/]")
|
|
169
|
+
elif char == "\\" and i + 1 < len(pattern):
|
|
170
|
+
i += 1
|
|
171
|
+
out.append(re.escape(pattern[i]))
|
|
172
|
+
elif char == "[" and (end := pattern.find("]", i + 2)) != -1:
|
|
173
|
+
body = pattern[i + 1 : end]
|
|
174
|
+
out.append("[" + ("^" + body[1:] if body[0] in "!^" else body) + "]")
|
|
175
|
+
i = end
|
|
176
|
+
else:
|
|
177
|
+
out.append(re.escape(char))
|
|
178
|
+
i += 1
|
|
179
|
+
out.append("$")
|
|
180
|
+
return "".join(out)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _ignored(rules: list[tuple[bool, re.Pattern[str]]], path: str) -> bool:
|
|
184
|
+
"""Whether the last rule matching ``path`` or a directory above it excludes it."""
|
|
185
|
+
candidates = [path, *(str(up) for up in PurePosixPath(path).parents if str(up) != ".")]
|
|
186
|
+
ignored = False
|
|
187
|
+
for keep, pattern in rules:
|
|
188
|
+
if any(pattern.match(candidate) for candidate in candidates):
|
|
189
|
+
ignored = not keep
|
|
190
|
+
return ignored
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def extract_build_context(data: bytes, target: Path) -> int:
|
|
194
|
+
"""Unpack ``data`` into ``target``, an absent or empty directory; return the file count."""
|
|
195
|
+
if len(data) > MAX_ARCHIVE_BYTES:
|
|
196
|
+
raise UsageError("the archive is over 25 MiB")
|
|
197
|
+
try:
|
|
198
|
+
with gzip.GzipFile(fileobj=io.BytesIO(data)) as source:
|
|
199
|
+
raw = source.read(MAX_EXPANDED_BYTES + 1)
|
|
200
|
+
if len(raw) > MAX_EXPANDED_BYTES:
|
|
201
|
+
raise UsageError("the archive expands to over 100 MiB")
|
|
202
|
+
with (
|
|
203
|
+
tarfile.open(fileobj=io.BytesIO(raw), mode="r:") as archive,
|
|
204
|
+
tempfile.TemporaryDirectory(prefix=".cv-pull-", dir=target.parent) as temporary,
|
|
205
|
+
):
|
|
206
|
+
members = archive.getmembers()
|
|
207
|
+
names = [posixpath.normpath(member.name) for member in members]
|
|
208
|
+
if len(members) > MAX_ENTRIES or len(set(names)) != len(names):
|
|
209
|
+
raise UsageError("the archive has too many or duplicate entries")
|
|
210
|
+
if not all(
|
|
211
|
+
(member.isfile() and not member.issparse()) or member.isdir() for member in members
|
|
212
|
+
):
|
|
213
|
+
raise UsageError("the archive holds a link or special file")
|
|
214
|
+
if any(name.startswith(("/", "../")) or name == ".." for name in names):
|
|
215
|
+
raise UsageError("the archive holds a path outside its directory")
|
|
216
|
+
staging = Path(temporary) / "out"
|
|
217
|
+
staging.mkdir()
|
|
218
|
+
archive.extractall(staging, filter="data")
|
|
219
|
+
staging.rename(target)
|
|
220
|
+
except (OSError, EOFError, tarfile.TarError) as exc:
|
|
221
|
+
raise UsageError(f"the archive could not be extracted: {exc}") from None
|
|
222
|
+
return sum(member.isfile() for member in members)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
__all__ = ["extract_build_context", "pack_build_context"]
|