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.
Files changed (177) hide show
  1. steppy/CHANGELOG.md +862 -0
  2. steppy/__init__.py +9 -0
  3. steppy/__main__.py +13 -0
  4. steppy/_cli_common.py +183 -0
  5. steppy/_docker/Dockerfile.installed +53 -0
  6. steppy/_parallel/__init__.py +7 -0
  7. steppy/_parallel/compose.py +349 -0
  8. steppy/_parallel/event_bus.py +418 -0
  9. steppy/_parallel/failures.py +267 -0
  10. steppy/_parallel/fingerprint.py +141 -0
  11. steppy/_parallel/image.py +380 -0
  12. steppy/_parallel/lastfailed.py +125 -0
  13. steppy/_parallel/lifecycle.py +90 -0
  14. steppy/_parallel/preauth.py +109 -0
  15. steppy/_parallel/progress.py +101 -0
  16. steppy/_parallel/queue.py +385 -0
  17. steppy/_parallel/renderer.py +489 -0
  18. steppy/_parallel/retry.py +316 -0
  19. steppy/_player_bundle/THIRD_PARTY_LICENSES.txt +158 -0
  20. steppy/_player_bundle/__init__.py +100 -0
  21. steppy/_player_bundle/steppy-cursor-legacy.js +2 -0
  22. steppy/_player_bundle/steppy-cursor-legacy.js.map +1 -0
  23. steppy/_player_bundle/steppy-player.js +5419 -0
  24. steppy/_player_bundle/steppy-player.js.map +1 -0
  25. steppy/_player_bundle/steppy-video.js +472 -0
  26. steppy/_player_bundle/steppy-video.js.map +1 -0
  27. steppy/_templating.py +117 -0
  28. steppy/assertions.py +251 -0
  29. steppy/auth.py +493 -0
  30. steppy/build.py +2798 -0
  31. steppy/build_models.py +669 -0
  32. steppy/buildid.py +134 -0
  33. steppy/bundle/__init__.py +47 -0
  34. steppy/bundle/archive.py +239 -0
  35. steppy/bundle/classify.py +329 -0
  36. steppy/bundle/create.py +490 -0
  37. steppy/bundle/hashing.py +28 -0
  38. steppy/bundle/manifest.py +334 -0
  39. steppy/bundle/results.py +546 -0
  40. steppy/bundle_cli.py +455 -0
  41. steppy/captures.py +207 -0
  42. steppy/cli.py +2489 -0
  43. steppy/cloud/__init__.py +58 -0
  44. steppy/cloud/client.py +267 -0
  45. steppy/cloud/upload.py +497 -0
  46. steppy/compat.py +109 -0
  47. steppy/config.py +231 -0
  48. steppy/config_schema.py +87 -0
  49. steppy/contracts/__init__.py +54 -0
  50. steppy/contracts/bundle.py +215 -0
  51. steppy/contracts/report.py +346 -0
  52. steppy/contracts/tours.py +505 -0
  53. steppy/coordinator.py +1352 -0
  54. steppy/coverage/__init__.py +9 -0
  55. steppy/coverage/gaps.py +190 -0
  56. steppy/coverage/manifest.py +193 -0
  57. steppy/coverage/merge.py +363 -0
  58. steppy/coverage/normalize.py +174 -0
  59. steppy/coverage/report.py +178 -0
  60. steppy/coverage/sourcemap.py +219 -0
  61. steppy/coverage/store.py +473 -0
  62. steppy/coverage/triage.py +151 -0
  63. steppy/coverage_cli.py +265 -0
  64. steppy/durations.py +48 -0
  65. steppy/exclusive.py +178 -0
  66. steppy/flaky_cli.py +169 -0
  67. steppy/generator.py +633 -0
  68. steppy/guide.py +1467 -0
  69. steppy/history.py +352 -0
  70. steppy/init_cli.py +330 -0
  71. steppy/locales.py +282 -0
  72. steppy/migrate/__init__.py +35 -0
  73. steppy/migrate/apply.py +194 -0
  74. steppy/migrate/plan.py +199 -0
  75. steppy/migrate/rules.py +465 -0
  76. steppy/migrate/verify.py +163 -0
  77. steppy/migrate_cli.py +152 -0
  78. steppy/models.py +2539 -0
  79. steppy/naming.py +37 -0
  80. steppy/parser.py +475 -0
  81. steppy/pdf.py +193 -0
  82. steppy/presets.py +284 -0
  83. steppy/record_cli.py +796 -0
  84. steppy/report/__init__.py +37 -0
  85. steppy/report/assets.py +197 -0
  86. steppy/report/build.py +261 -0
  87. steppy/report/coverage_source.py +277 -0
  88. steppy/report/model.py +423 -0
  89. steppy/report/render.py +303 -0
  90. steppy/report/source.py +238 -0
  91. steppy/report_cli.py +173 -0
  92. steppy/runmeta.py +114 -0
  93. steppy/selection.py +183 -0
  94. steppy/sitemap.py +205 -0
  95. steppy/skills/steppy-app-tours/SKILL.md +448 -0
  96. steppy/skills/steppy-ci-report/SKILL.md +567 -0
  97. steppy/skills/steppy-scenario-author/SKILL.md +2176 -0
  98. steppy/skills/steppy-template/SKILL.md +676 -0
  99. steppy/templates/_steppy_events.py +291 -0
  100. steppy/templates/_steppy_tours.py +404 -0
  101. steppy/templates/angular/component-interactive.ts.j2 +109 -0
  102. steppy/templates/angular/component-video.ts.j2 +112 -0
  103. steppy/templates/angular/component.ts.j2 +229 -0
  104. steppy/templates/angular/content.ts.j2 +31 -0
  105. steppy/templates/angular/gitignore.j2 +3 -0
  106. steppy/templates/angular/group.component.ts.j2 +39 -0
  107. steppy/templates/angular/onboarding.component.ts.j2 +43 -0
  108. steppy/templates/angular/routes.ts.j2 +34 -0
  109. steppy/templates/ci/github-actions.yml +284 -0
  110. steppy/templates/ci/gitlab-ci.yml +178 -0
  111. steppy/templates/conftest.py.j2 +2405 -0
  112. steppy/templates/guide.book.html.j2 +145 -0
  113. steppy/templates/guide.fragment.html.j2 +257 -0
  114. steppy/templates/guide.html.j2 +301 -0
  115. steppy/templates/guide.md.j2 +18 -0
  116. steppy/templates/init/config.yaml +124 -0
  117. steppy/templates/init/credentials.yaml.example +30 -0
  118. steppy/templates/init/scenarios/example--first-flow.md +53 -0
  119. steppy/templates/report/_head.html.j2 +14 -0
  120. steppy/templates/report/_mark.html.j2 +3 -0
  121. steppy/templates/report/coverage.html.j2 +124 -0
  122. steppy/templates/report/index.html.j2 +203 -0
  123. steppy/templates/report/report.css +401 -0
  124. steppy/templates/report/scenario.html.j2 +137 -0
  125. steppy/templates/static/_headers.j2 +30 -0
  126. steppy/templates/static/_mode_links.html.j2 +22 -0
  127. steppy/templates/static/_seo_head.html.j2 +37 -0
  128. steppy/templates/static/cursor.js.j2 +10 -0
  129. steppy/templates/static/group.html.j2 +78 -0
  130. steppy/templates/static/guides.css +678 -0
  131. steppy/templates/static/index.html.j2 +79 -0
  132. steppy/templates/static/interactive.html.j2 +100 -0
  133. steppy/templates/static/onboarding.html.j2 +64 -0
  134. steppy/templates/static/page.html.j2 +145 -0
  135. steppy/templates/static/robots.txt.j2 +7 -0
  136. steppy/templates/static/video.html.j2 +135 -0
  137. steppy/templates/test_scenario.py.j2 +767 -0
  138. steppy/testids.py +166 -0
  139. steppy/totp.py +26 -0
  140. steppy/tour_spec.py +163 -0
  141. steppy/tours/__init__.py +22 -0
  142. steppy/tours/actions.py +402 -0
  143. steppy/tours/compiler.py +144 -0
  144. steppy/tours/diagnostics.py +137 -0
  145. steppy/tours/emit.py +239 -0
  146. steppy/tours/ids.py +40 -0
  147. steppy/tours/inference.py +399 -0
  148. steppy/tours/model.py +120 -0
  149. steppy/tours/paths.py +126 -0
  150. steppy/tours/pipeline.py +277 -0
  151. steppy/tours/publish.py +124 -0
  152. steppy/tours/replay.py +234 -0
  153. steppy/tours/routes.py +175 -0
  154. steppy/tours/settings.py +164 -0
  155. steppy/tours/text.py +237 -0
  156. steppy/tours_cli.py +219 -0
  157. steppy/tts/__init__.py +280 -0
  158. steppy/tts/cache.py +185 -0
  159. steppy/tts/presets.py +125 -0
  160. steppy/tts/providers/__init__.py +6 -0
  161. steppy/tts/providers/http.py +184 -0
  162. steppy/tts/providers/local.py +207 -0
  163. steppy/tts/providers/null.py +28 -0
  164. steppy/tts/registry.py +201 -0
  165. steppy/tts/track.py +158 -0
  166. steppy/tts/types.py +94 -0
  167. steppy/update_cli.py +460 -0
  168. steppy/video.py +730 -0
  169. steppy/video_cache.py +239 -0
  170. steppy/video_cli.py +271 -0
  171. steppy/worker.py +301 -0
  172. steppy_dev-0.3.0.dist-info/METADATA +1335 -0
  173. steppy_dev-0.3.0.dist-info/RECORD +177 -0
  174. steppy_dev-0.3.0.dist-info/WHEEL +4 -0
  175. steppy_dev-0.3.0.dist-info/entry_points.txt +2 -0
  176. steppy_dev-0.3.0.dist-info/licenses/LICENSE +202 -0
  177. 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
+ ]
@@ -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)