steppy-dev 0.3.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.
- steppy/CHANGELOG.md +862 -0
- steppy/__init__.py +9 -0
- steppy/__main__.py +13 -0
- steppy/_cli_common.py +183 -0
- steppy/_docker/Dockerfile.installed +53 -0
- steppy/_parallel/__init__.py +7 -0
- steppy/_parallel/compose.py +349 -0
- steppy/_parallel/event_bus.py +418 -0
- steppy/_parallel/failures.py +267 -0
- steppy/_parallel/fingerprint.py +141 -0
- steppy/_parallel/image.py +380 -0
- steppy/_parallel/lastfailed.py +125 -0
- steppy/_parallel/lifecycle.py +90 -0
- steppy/_parallel/preauth.py +109 -0
- steppy/_parallel/progress.py +101 -0
- steppy/_parallel/queue.py +385 -0
- steppy/_parallel/renderer.py +489 -0
- steppy/_parallel/retry.py +316 -0
- steppy/_player_bundle/THIRD_PARTY_LICENSES.txt +158 -0
- steppy/_player_bundle/__init__.py +100 -0
- steppy/_player_bundle/steppy-cursor-legacy.js +2 -0
- steppy/_player_bundle/steppy-cursor-legacy.js.map +1 -0
- steppy/_player_bundle/steppy-player.js +5419 -0
- steppy/_player_bundle/steppy-player.js.map +1 -0
- steppy/_player_bundle/steppy-video.js +472 -0
- steppy/_player_bundle/steppy-video.js.map +1 -0
- steppy/_templating.py +117 -0
- steppy/assertions.py +251 -0
- steppy/auth.py +493 -0
- steppy/build.py +2798 -0
- steppy/build_models.py +669 -0
- steppy/buildid.py +134 -0
- steppy/bundle/__init__.py +47 -0
- steppy/bundle/archive.py +239 -0
- steppy/bundle/classify.py +329 -0
- steppy/bundle/create.py +490 -0
- steppy/bundle/hashing.py +28 -0
- steppy/bundle/manifest.py +334 -0
- steppy/bundle/results.py +546 -0
- steppy/bundle_cli.py +455 -0
- steppy/captures.py +207 -0
- steppy/cli.py +2489 -0
- steppy/cloud/__init__.py +58 -0
- steppy/cloud/client.py +267 -0
- steppy/cloud/upload.py +497 -0
- steppy/compat.py +109 -0
- steppy/config.py +231 -0
- steppy/config_schema.py +87 -0
- steppy/contracts/__init__.py +54 -0
- steppy/contracts/bundle.py +215 -0
- steppy/contracts/report.py +346 -0
- steppy/contracts/tours.py +505 -0
- steppy/coordinator.py +1352 -0
- steppy/coverage/__init__.py +9 -0
- steppy/coverage/gaps.py +190 -0
- steppy/coverage/manifest.py +193 -0
- steppy/coverage/merge.py +363 -0
- steppy/coverage/normalize.py +174 -0
- steppy/coverage/report.py +178 -0
- steppy/coverage/sourcemap.py +219 -0
- steppy/coverage/store.py +473 -0
- steppy/coverage/triage.py +151 -0
- steppy/coverage_cli.py +265 -0
- steppy/durations.py +48 -0
- steppy/exclusive.py +178 -0
- steppy/flaky_cli.py +169 -0
- steppy/generator.py +633 -0
- steppy/guide.py +1467 -0
- steppy/history.py +352 -0
- steppy/init_cli.py +330 -0
- steppy/locales.py +282 -0
- steppy/migrate/__init__.py +35 -0
- steppy/migrate/apply.py +194 -0
- steppy/migrate/plan.py +199 -0
- steppy/migrate/rules.py +465 -0
- steppy/migrate/verify.py +163 -0
- steppy/migrate_cli.py +152 -0
- steppy/models.py +2539 -0
- steppy/naming.py +37 -0
- steppy/parser.py +475 -0
- steppy/pdf.py +193 -0
- steppy/presets.py +284 -0
- steppy/record_cli.py +796 -0
- steppy/report/__init__.py +37 -0
- steppy/report/assets.py +197 -0
- steppy/report/build.py +261 -0
- steppy/report/coverage_source.py +277 -0
- steppy/report/model.py +423 -0
- steppy/report/render.py +303 -0
- steppy/report/source.py +238 -0
- steppy/report_cli.py +173 -0
- steppy/runmeta.py +114 -0
- steppy/selection.py +183 -0
- steppy/sitemap.py +205 -0
- steppy/skills/steppy-app-tours/SKILL.md +448 -0
- steppy/skills/steppy-ci-report/SKILL.md +567 -0
- steppy/skills/steppy-scenario-author/SKILL.md +2176 -0
- steppy/skills/steppy-template/SKILL.md +676 -0
- steppy/templates/_steppy_events.py +291 -0
- steppy/templates/_steppy_tours.py +404 -0
- steppy/templates/angular/component-interactive.ts.j2 +109 -0
- steppy/templates/angular/component-video.ts.j2 +112 -0
- steppy/templates/angular/component.ts.j2 +229 -0
- steppy/templates/angular/content.ts.j2 +31 -0
- steppy/templates/angular/gitignore.j2 +3 -0
- steppy/templates/angular/group.component.ts.j2 +39 -0
- steppy/templates/angular/onboarding.component.ts.j2 +43 -0
- steppy/templates/angular/routes.ts.j2 +34 -0
- steppy/templates/ci/github-actions.yml +284 -0
- steppy/templates/ci/gitlab-ci.yml +178 -0
- steppy/templates/conftest.py.j2 +2405 -0
- steppy/templates/guide.book.html.j2 +145 -0
- steppy/templates/guide.fragment.html.j2 +257 -0
- steppy/templates/guide.html.j2 +301 -0
- steppy/templates/guide.md.j2 +18 -0
- steppy/templates/init/config.yaml +124 -0
- steppy/templates/init/credentials.yaml.example +30 -0
- steppy/templates/init/scenarios/example--first-flow.md +53 -0
- steppy/templates/report/_head.html.j2 +14 -0
- steppy/templates/report/_mark.html.j2 +3 -0
- steppy/templates/report/coverage.html.j2 +124 -0
- steppy/templates/report/index.html.j2 +203 -0
- steppy/templates/report/report.css +401 -0
- steppy/templates/report/scenario.html.j2 +137 -0
- steppy/templates/static/_headers.j2 +30 -0
- steppy/templates/static/_mode_links.html.j2 +22 -0
- steppy/templates/static/_seo_head.html.j2 +37 -0
- steppy/templates/static/cursor.js.j2 +10 -0
- steppy/templates/static/group.html.j2 +78 -0
- steppy/templates/static/guides.css +678 -0
- steppy/templates/static/index.html.j2 +79 -0
- steppy/templates/static/interactive.html.j2 +100 -0
- steppy/templates/static/onboarding.html.j2 +64 -0
- steppy/templates/static/page.html.j2 +145 -0
- steppy/templates/static/robots.txt.j2 +7 -0
- steppy/templates/static/video.html.j2 +135 -0
- steppy/templates/test_scenario.py.j2 +767 -0
- steppy/testids.py +166 -0
- steppy/totp.py +26 -0
- steppy/tour_spec.py +163 -0
- steppy/tours/__init__.py +22 -0
- steppy/tours/actions.py +402 -0
- steppy/tours/compiler.py +144 -0
- steppy/tours/diagnostics.py +137 -0
- steppy/tours/emit.py +239 -0
- steppy/tours/ids.py +40 -0
- steppy/tours/inference.py +399 -0
- steppy/tours/model.py +120 -0
- steppy/tours/paths.py +126 -0
- steppy/tours/pipeline.py +277 -0
- steppy/tours/publish.py +124 -0
- steppy/tours/replay.py +234 -0
- steppy/tours/routes.py +175 -0
- steppy/tours/settings.py +164 -0
- steppy/tours/text.py +237 -0
- steppy/tours_cli.py +219 -0
- steppy/tts/__init__.py +280 -0
- steppy/tts/cache.py +185 -0
- steppy/tts/presets.py +125 -0
- steppy/tts/providers/__init__.py +6 -0
- steppy/tts/providers/http.py +184 -0
- steppy/tts/providers/local.py +207 -0
- steppy/tts/providers/null.py +28 -0
- steppy/tts/registry.py +201 -0
- steppy/tts/track.py +158 -0
- steppy/tts/types.py +94 -0
- steppy/update_cli.py +460 -0
- steppy/video.py +730 -0
- steppy/video_cache.py +239 -0
- steppy/video_cli.py +271 -0
- steppy/worker.py +301 -0
- steppy_dev-0.3.0.dist-info/METADATA +1335 -0
- steppy_dev-0.3.0.dist-info/RECORD +177 -0
- steppy_dev-0.3.0.dist-info/WHEEL +4 -0
- steppy_dev-0.3.0.dist-info/entry_points.txt +2 -0
- steppy_dev-0.3.0.dist-info/licenses/LICENSE +202 -0
- steppy_dev-0.3.0.dist-info/licenses/NOTICE +13 -0
steppy/buildid.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Build ids for in-app tours: grammar and resolution.
|
|
2
|
+
|
|
3
|
+
A tour file carries the id of the app build it was compiled for, and remote
|
|
4
|
+
mode only ever fetches the tours of the app's own id (spec fixed decision 8).
|
|
5
|
+
``tours.build_id`` in ``config.yaml`` accepts:
|
|
6
|
+
|
|
7
|
+
``git-sha``
|
|
8
|
+
The first 12 hex digits of ``HEAD`` of the repository holding the project
|
|
9
|
+
root. Fixed length on purpose: ``git rev-parse --short`` grows with the repo.
|
|
10
|
+
``git-sha:<repo-path>``
|
|
11
|
+
The same, for a repository elsewhere — when the scenarios live next to,
|
|
12
|
+
not inside, the app repository.
|
|
13
|
+
a literal
|
|
14
|
+
``^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,62}[A-Za-z0-9])?$`` — a release number, a
|
|
15
|
+
CI variable through ``${APP_BUILD_ID:-git-sha}``. The id becomes a path
|
|
16
|
+
segment (``tours/<id>/``), so ``..`` and separators are unrepresentable.
|
|
17
|
+
|
|
18
|
+
An unresolved ``${VAR}`` is accepted when the config loads (``steppy run``
|
|
19
|
+
must keep working where the app-build variable is unset) and rejected when an
|
|
20
|
+
id is actually needed. No dirty-tree suffix: two runs over the same tree must
|
|
21
|
+
produce identical bytes.
|
|
22
|
+
|
|
23
|
+
A stdlib-only leaf.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import re
|
|
29
|
+
import subprocess
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Literal, Optional
|
|
33
|
+
|
|
34
|
+
BUILD_ID_PATTERN = r"^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,62}[A-Za-z0-9])?$"
|
|
35
|
+
GIT_SHA_PREFIX_LEN = 12
|
|
36
|
+
GIT_SHA = "git-sha"
|
|
37
|
+
|
|
38
|
+
_WINDOWS_RESERVED = frozenset(
|
|
39
|
+
{"con", "prn", "aux", "nul"} | {f"com{i}" for i in range(1, 10)} | {f"lpt{i}" for i in range(1, 10)}
|
|
40
|
+
)
|
|
41
|
+
_SHA_RE = re.compile(r"^(?:[0-9a-f]{40}|[0-9a-f]{64})$")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class BuildIdError(ValueError):
|
|
45
|
+
"""A ``tours.build_id`` value that is malformed or cannot be resolved."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class BuildIdSpec:
|
|
50
|
+
kind: Literal["git", "literal"]
|
|
51
|
+
repo: Optional[str] = None
|
|
52
|
+
value: Optional[str] = None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def is_build_id(value: object) -> bool:
|
|
56
|
+
# Windows reserves a device name before *any* extension: `con.v2` is as
|
|
57
|
+
# un-creatable a directory as `con`.
|
|
58
|
+
return (
|
|
59
|
+
isinstance(value, str)
|
|
60
|
+
and re.fullmatch(BUILD_ID_PATTERN, value) is not None
|
|
61
|
+
and value.split(".", 1)[0].casefold() not in _WINDOWS_RESERVED
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def parse_build_id_spec(raw: object) -> BuildIdSpec:
|
|
66
|
+
"""Parse a ``tours.build_id`` value; raise :class:`BuildIdError` on a bad one."""
|
|
67
|
+
if not isinstance(raw, str) or not raw.strip():
|
|
68
|
+
raise BuildIdError("tours.build_id must be a non-empty string (git-sha, git-sha:<repo-path> or a literal)")
|
|
69
|
+
text = raw.strip()
|
|
70
|
+
if "${" in text:
|
|
71
|
+
raise BuildIdError(
|
|
72
|
+
f"tours.build_id {text!r} references an environment variable that is not set; "
|
|
73
|
+
"give it a default, e.g. ${APP_BUILD_ID:-git-sha}"
|
|
74
|
+
)
|
|
75
|
+
if text == GIT_SHA:
|
|
76
|
+
return BuildIdSpec(kind="git")
|
|
77
|
+
if text.startswith(GIT_SHA + ":"):
|
|
78
|
+
repo = text[len(GIT_SHA) + 1 :].strip()
|
|
79
|
+
if not repo:
|
|
80
|
+
raise BuildIdError("tours.build_id 'git-sha:' needs a repository path after the colon")
|
|
81
|
+
return BuildIdSpec(kind="git", repo=repo)
|
|
82
|
+
if not is_build_id(text):
|
|
83
|
+
raise BuildIdError(
|
|
84
|
+
f"tours.build_id {text!r} is not a valid build id: use git-sha, git-sha:<repo-path>, "
|
|
85
|
+
f"or a literal matching {BUILD_ID_PATTERN} (it becomes a directory name)"
|
|
86
|
+
)
|
|
87
|
+
return BuildIdSpec(kind="literal", value=text)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def check_build_id_syntax(raw: object) -> str:
|
|
91
|
+
"""Load-time check: let an unresolved ``${…}`` through, validate everything else."""
|
|
92
|
+
if isinstance(raw, str) and "${" in raw:
|
|
93
|
+
return raw
|
|
94
|
+
parse_build_id_spec(raw)
|
|
95
|
+
return raw # type: ignore[return-value]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _git_head(repo: Path) -> str:
|
|
99
|
+
try:
|
|
100
|
+
out = subprocess.run(
|
|
101
|
+
["git", "rev-parse", "HEAD"],
|
|
102
|
+
cwd=repo,
|
|
103
|
+
capture_output=True,
|
|
104
|
+
text=True,
|
|
105
|
+
timeout=5,
|
|
106
|
+
)
|
|
107
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
108
|
+
raise BuildIdError(
|
|
109
|
+
f"tours.build_id: cannot run git in {repo} ({exc}); set a literal build id or pass --build-id"
|
|
110
|
+
) from exc
|
|
111
|
+
sha = out.stdout.strip().lower()
|
|
112
|
+
if out.returncode != 0 or not _SHA_RE.fullmatch(sha):
|
|
113
|
+
detail = out.stderr.strip().splitlines()[-1] if out.stderr.strip() else "not a git repository"
|
|
114
|
+
raise BuildIdError(
|
|
115
|
+
f"tours.build_id: git rev-parse HEAD failed in {repo} ({detail}); "
|
|
116
|
+
"set a literal build id, ${APP_BUILD_ID:-git-sha}, or pass --build-id"
|
|
117
|
+
)
|
|
118
|
+
return sha
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def resolve_build_id(raw: object, project_root: Path, *, override: Optional[str] = None) -> str:
|
|
122
|
+
"""Resolve the build id to the value written into tour files.
|
|
123
|
+
|
|
124
|
+
``override`` (``steppy tours --build-id``) wins over the configured value
|
|
125
|
+
and goes through the same grammar.
|
|
126
|
+
"""
|
|
127
|
+
spec = parse_build_id_spec(override if override else raw)
|
|
128
|
+
if spec.kind == "literal":
|
|
129
|
+
assert spec.value is not None
|
|
130
|
+
return spec.value
|
|
131
|
+
repo = Path(project_root)
|
|
132
|
+
if spec.repo:
|
|
133
|
+
repo = (repo / spec.repo).resolve()
|
|
134
|
+
return _git_head(repo)[:GIT_SHA_PREFIX_LEN]
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Results-bundle producer — ``steppy bundle create`` and ``upload``.
|
|
2
|
+
|
|
3
|
+
Packages one run's ``output/`` tree into a portable, self-describing archive.
|
|
4
|
+
The contract it emits lives in :mod:`steppy.contracts.bundle`; this package is
|
|
5
|
+
the producer side of it.
|
|
6
|
+
|
|
7
|
+
``create`` and ``upload`` share everything up to the point bytes are written:
|
|
8
|
+
:func:`plan_bundle` decides the bundle, :func:`archive_members` maps each
|
|
9
|
+
manifest entry back to its bytes, and only ``create`` goes on to tar them.
|
|
10
|
+
Uploading writes no archive at all — the protocol is per-file. The transport
|
|
11
|
+
lives in :mod:`steppy.cloud`, not here.
|
|
12
|
+
|
|
13
|
+
**Lazy-imported only.** Nothing here may be reached by ``import steppy.cli`` —
|
|
14
|
+
``archive`` optionally pulls in ``zstandard`` and the walk is not free. Guard:
|
|
15
|
+
``tests/test_no_bundle_import_on_default_path.py``.
|
|
16
|
+
|
|
17
|
+
Module split follows the concerns, not the file count:
|
|
18
|
+
|
|
19
|
+
``hashing`` streaming sha256, shared with the worker-image tagger
|
|
20
|
+
``classify`` path → :class:`~steppy.contracts.bundle.BundleFile`; pure
|
|
21
|
+
``results`` junit XML + event JSONL → ``manifest.tests[]``
|
|
22
|
+
``manifest`` assembly: run identity, ordering, the size budget, ``truncated``
|
|
23
|
+
``archive`` deterministic tar + codec dispatch (zstd, gzip)
|
|
24
|
+
``create`` orchestration, and the only module that touches the console
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from .create import (
|
|
28
|
+
BundleError,
|
|
29
|
+
BundlePlan,
|
|
30
|
+
BundleResult,
|
|
31
|
+
archive_members,
|
|
32
|
+
create_bundle,
|
|
33
|
+
inspect_bundle,
|
|
34
|
+
plan_bundle,
|
|
35
|
+
verify_bundle,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"BundleError",
|
|
40
|
+
"BundlePlan",
|
|
41
|
+
"BundleResult",
|
|
42
|
+
"archive_members",
|
|
43
|
+
"create_bundle",
|
|
44
|
+
"inspect_bundle",
|
|
45
|
+
"plan_bundle",
|
|
46
|
+
"verify_bundle",
|
|
47
|
+
]
|
steppy/bundle/archive.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""Deterministic tar writing and compression-codec dispatch.
|
|
2
|
+
|
|
3
|
+
**Determinism is a feature, not tidiness.** The upload protocol the contract
|
|
4
|
+
describes is content-addressed: a consumer asks only for digests it does not
|
|
5
|
+
already hold. That only pays off if an unchanged run produces an unchanged
|
|
6
|
+
archive, so every source of ambient nondeterminism is pinned here — member
|
|
7
|
+
order, timestamps, ownership, mode, tar format, and the gzip header.
|
|
8
|
+
|
|
9
|
+
Codec: zstd when the optional ``steppy-dev[bundle]`` extra is installed, gzip
|
|
10
|
+
otherwise. Falling back rather than failing is deliberate — a results archive is
|
|
11
|
+
useful with no control plane at all, and making the free path depend on an extra
|
|
12
|
+
would be the wrong trade for a few percent of size on data that is already
|
|
13
|
+
PNG-compressed.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import gzip
|
|
19
|
+
import tarfile
|
|
20
|
+
from contextlib import contextmanager
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Iterator, Optional
|
|
24
|
+
|
|
25
|
+
FORMAT_AUTO = "auto"
|
|
26
|
+
FORMAT_ZSTD = "zst"
|
|
27
|
+
FORMAT_GZIP = "gz"
|
|
28
|
+
|
|
29
|
+
#: zstd level 10 is the knee of the curve for mixed PNG/JSON/text: materially
|
|
30
|
+
#: smaller than the default 3 on the text members, and still fast enough that
|
|
31
|
+
#: compression never dominates the walk.
|
|
32
|
+
ZSTD_LEVEL = 10
|
|
33
|
+
|
|
34
|
+
_EXTRA_HINT = "install the extra with: pip install 'steppy-dev[bundle]'"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ArchiveError(Exception):
|
|
38
|
+
"""Unusable codec request — always actionable."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def zstandard_available() -> bool:
|
|
42
|
+
import importlib.util
|
|
43
|
+
|
|
44
|
+
return importlib.util.find_spec("zstandard") is not None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def resolve_format(requested: str) -> str:
|
|
48
|
+
"""Resolve ``auto`` to a concrete codec, or validate an explicit one.
|
|
49
|
+
|
|
50
|
+
An explicit ``zst`` that cannot be honoured is an ERROR, never a silent
|
|
51
|
+
downgrade: someone who named the codec is entitled to get it or be told why.
|
|
52
|
+
"""
|
|
53
|
+
if requested == FORMAT_AUTO:
|
|
54
|
+
return FORMAT_ZSTD if zstandard_available() else FORMAT_GZIP
|
|
55
|
+
if requested == FORMAT_ZSTD:
|
|
56
|
+
if not zstandard_available():
|
|
57
|
+
raise ArchiveError(f"--format zst needs the 'zstandard' package, which is not installed; {_EXTRA_HINT}")
|
|
58
|
+
return FORMAT_ZSTD
|
|
59
|
+
if requested == FORMAT_GZIP:
|
|
60
|
+
return FORMAT_GZIP
|
|
61
|
+
raise ArchiveError(f"unknown --format {requested!r} (expected auto, zst or gz)")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def extension_for(fmt: str) -> str:
|
|
65
|
+
return ".tar.zst" if fmt == FORMAT_ZSTD else ".tar.gz"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _normalise(info: tarfile.TarInfo) -> tarfile.TarInfo:
|
|
69
|
+
"""Strip everything that varies between machines and runs."""
|
|
70
|
+
info.mtime = 0
|
|
71
|
+
info.uid = 0
|
|
72
|
+
info.gid = 0
|
|
73
|
+
info.uname = ""
|
|
74
|
+
info.gname = ""
|
|
75
|
+
info.mode = 0o644
|
|
76
|
+
return info
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@contextmanager
|
|
80
|
+
def _compressed_stream(path: Path, fmt: str) -> Iterator:
|
|
81
|
+
"""Open the outer compressed stream, pinned for reproducibility."""
|
|
82
|
+
with open(path, "wb") as raw:
|
|
83
|
+
if fmt == FORMAT_ZSTD:
|
|
84
|
+
import zstandard
|
|
85
|
+
|
|
86
|
+
# threads=0 keeps it single-threaded. Multithreaded zstd is
|
|
87
|
+
# deterministic in practice, but pinning removes the question.
|
|
88
|
+
compressor = zstandard.ZstdCompressor(level=ZSTD_LEVEL, threads=0)
|
|
89
|
+
with compressor.stream_writer(raw) as stream:
|
|
90
|
+
yield stream
|
|
91
|
+
else:
|
|
92
|
+
# NOT tarfile.open(mode="w:gz"): that stamps the current time into
|
|
93
|
+
# the gzip header, which alone would make every archive differ.
|
|
94
|
+
# `filename=""` is equally load-bearing — GzipFile otherwise takes
|
|
95
|
+
# the name from `fileobj.name` and embeds it, so the same tree
|
|
96
|
+
# written to two paths would produce two different files.
|
|
97
|
+
with gzip.GzipFile(filename="", fileobj=raw, mode="wb", mtime=0) as stream:
|
|
98
|
+
yield stream
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass(frozen=True)
|
|
102
|
+
class ArchiveMember:
|
|
103
|
+
"""One file to write: where it comes from, where it lands in the archive."""
|
|
104
|
+
|
|
105
|
+
#: POSIX path inside the archive. Always ``/``-separated — a bundle built on
|
|
106
|
+
#: Windows must extract identically on Linux.
|
|
107
|
+
arcname: str
|
|
108
|
+
source: Optional[Path] = None
|
|
109
|
+
data: Optional[bytes] = None
|
|
110
|
+
|
|
111
|
+
def __post_init__(self) -> None:
|
|
112
|
+
if (self.source is None) == (self.data is None):
|
|
113
|
+
raise ValueError("ArchiveMember needs exactly one of source or data")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def write_archive(target: Path, members: list[ArchiveMember], *, fmt: str) -> int:
|
|
117
|
+
"""Write *members* to *target*. Returns the compressed size in bytes.
|
|
118
|
+
|
|
119
|
+
Members are written in the order given — the caller sorts, so that
|
|
120
|
+
``manifest.json`` can be member #1 and readable without a full scan.
|
|
121
|
+
No directory entries are emitted at all, which sidesteps every
|
|
122
|
+
Windows/POSIX directory-metadata difference.
|
|
123
|
+
"""
|
|
124
|
+
import io
|
|
125
|
+
|
|
126
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
127
|
+
with _compressed_stream(target, fmt) as stream:
|
|
128
|
+
# PAX, not GNU: GNU's long-name and sparse handling is not stable
|
|
129
|
+
# across platforms, and paths here can be long (scenario/locale/layout).
|
|
130
|
+
with tarfile.open(fileobj=stream, mode="w|", format=tarfile.PAX_FORMAT, encoding="utf-8") as tar:
|
|
131
|
+
for member in members:
|
|
132
|
+
if member.data is not None:
|
|
133
|
+
info = tarfile.TarInfo(name=member.arcname)
|
|
134
|
+
info.size = len(member.data)
|
|
135
|
+
tar.addfile(_normalise(info), io.BytesIO(member.data))
|
|
136
|
+
else:
|
|
137
|
+
info = tar.gettarinfo(name=str(member.source), arcname=member.arcname)
|
|
138
|
+
info = _normalise(info)
|
|
139
|
+
with open(member.source, "rb") as fh:
|
|
140
|
+
tar.addfile(info, fh)
|
|
141
|
+
return target.stat().st_size
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@contextmanager
|
|
145
|
+
def open_archive(path: Path) -> Iterator[tarfile.TarFile]:
|
|
146
|
+
"""Read an archive written by :func:`write_archive`, sniffing the codec.
|
|
147
|
+
|
|
148
|
+
Sniffs by extension: the producer chose it, and a bundle is always named by
|
|
149
|
+
its producer.
|
|
150
|
+
"""
|
|
151
|
+
if path.suffix == ".zst":
|
|
152
|
+
if not zstandard_available():
|
|
153
|
+
raise ArchiveError(f"reading {path.name} needs the 'zstandard' package; {_EXTRA_HINT}")
|
|
154
|
+
import zstandard
|
|
155
|
+
|
|
156
|
+
with open(path, "rb") as raw:
|
|
157
|
+
decompressor = zstandard.ZstdDecompressor()
|
|
158
|
+
with decompressor.stream_reader(raw) as stream:
|
|
159
|
+
with tarfile.open(fileobj=stream, mode="r|") as tar:
|
|
160
|
+
yield tar
|
|
161
|
+
elif path.suffix == ".gz":
|
|
162
|
+
with tarfile.open(path, mode="r:gz") as tar:
|
|
163
|
+
yield tar
|
|
164
|
+
else:
|
|
165
|
+
raise ArchiveError(f"unrecognised bundle extension {path.suffix!r} (expected .tar.zst or .tar.gz)")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def read_members(
|
|
169
|
+
path: Path,
|
|
170
|
+
*,
|
|
171
|
+
into: Optional[dict[str, Path]] = None,
|
|
172
|
+
texts: Optional[set[str]] = None,
|
|
173
|
+
) -> tuple[dict[str, bytes], list[str]]:
|
|
174
|
+
"""Extract selected members in **one** sequential pass.
|
|
175
|
+
|
|
176
|
+
``into`` maps an arcname to the file it should be written to; ``texts`` names
|
|
177
|
+
arcnames to return as bytes. Returns ``(fetched_texts, warnings)``, with one
|
|
178
|
+
warning per requested arcname the archive did not contain.
|
|
179
|
+
|
|
180
|
+
One pass because the zstd path is a non-seekable ``r|`` stream: members arrive
|
|
181
|
+
once, in archive order, and ``getmember`` does not exist. That also rules out
|
|
182
|
+
"extract everything, then pick" — hence the caller declaring what it wants up
|
|
183
|
+
front, which it can do because ``manifest.json`` is member #1.
|
|
184
|
+
|
|
185
|
+
**Destinations come from the caller, never from the archive.** Going through
|
|
186
|
+
``tarfile`` member-by-member skips Python's extraction filters, and an
|
|
187
|
+
arcname in a downloaded bundle is attacker-controlled — so this function
|
|
188
|
+
writes only to paths a caller already decided on, and refuses anything that
|
|
189
|
+
is not a regular file (a hostile archive can carry symlinks and hardlinks,
|
|
190
|
+
for which ``extractfile`` returns ``None`` anyway).
|
|
191
|
+
"""
|
|
192
|
+
wanted_files = dict(into or {})
|
|
193
|
+
wanted_texts = set(texts or ())
|
|
194
|
+
fetched: dict[str, bytes] = {}
|
|
195
|
+
warnings: list[str] = []
|
|
196
|
+
seen: set[str] = set()
|
|
197
|
+
|
|
198
|
+
with open_archive(path) as tar:
|
|
199
|
+
for member in tar:
|
|
200
|
+
name = member.name
|
|
201
|
+
if name not in wanted_files and name not in wanted_texts:
|
|
202
|
+
continue
|
|
203
|
+
if not member.isfile():
|
|
204
|
+
warnings.append(f"{name} is not a regular file in the archive; skipped")
|
|
205
|
+
seen.add(name)
|
|
206
|
+
continue
|
|
207
|
+
handle = tar.extractfile(member)
|
|
208
|
+
if handle is None:
|
|
209
|
+
warnings.append(f"{name} could not be read from the archive; skipped")
|
|
210
|
+
seen.add(name)
|
|
211
|
+
continue
|
|
212
|
+
seen.add(name)
|
|
213
|
+
if name in wanted_texts:
|
|
214
|
+
payload = handle.read()
|
|
215
|
+
fetched[name] = payload
|
|
216
|
+
target = wanted_files.get(name)
|
|
217
|
+
if target is not None:
|
|
218
|
+
_write_bytes(target, payload)
|
|
219
|
+
continue
|
|
220
|
+
_stream_to(handle, wanted_files[name])
|
|
221
|
+
|
|
222
|
+
for name in sorted((set(wanted_files) | wanted_texts) - seen):
|
|
223
|
+
warnings.append(f"{name} is listed in the manifest but absent from the archive")
|
|
224
|
+
return fetched, warnings
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _write_bytes(target: Path, payload: bytes) -> None:
|
|
228
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
229
|
+
target.write_bytes(payload)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _stream_to(handle, target: Path, chunk: int = 1024 * 1024) -> None:
|
|
233
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
234
|
+
with open(target, "wb") as out:
|
|
235
|
+
while True:
|
|
236
|
+
block = handle.read(chunk)
|
|
237
|
+
if not block:
|
|
238
|
+
break
|
|
239
|
+
out.write(block)
|