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
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
"""Manifest assembly: walk, classify, hash, budget.
|
|
2
|
+
|
|
3
|
+
Two passes over the tree. Pass one walks, stats, classifies and hashes; pass two
|
|
4
|
+
streams the bytes into the archive. That is two reads of every byte, and on a
|
|
5
|
+
large capture tree it is a real cost — but the alternative is buffering the tar
|
|
6
|
+
to a temp file so the manifest can be prepended, which doubles peak disk on
|
|
7
|
+
exactly the runs where disk is already the constraint. Sequential reads are the
|
|
8
|
+
cheaper half of that trade.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import subprocess
|
|
15
|
+
import time
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Optional
|
|
19
|
+
|
|
20
|
+
from .. import __version__
|
|
21
|
+
from ..contracts.bundle import (
|
|
22
|
+
BUNDLE_SCHEMA_VERSION,
|
|
23
|
+
BundleCoverage,
|
|
24
|
+
BundleFile,
|
|
25
|
+
BundleGit,
|
|
26
|
+
BundleManifest,
|
|
27
|
+
BundleRun,
|
|
28
|
+
BundleSpec,
|
|
29
|
+
BundleTest,
|
|
30
|
+
)
|
|
31
|
+
from . import classify as _classify
|
|
32
|
+
from .hashing import sha256_file
|
|
33
|
+
|
|
34
|
+
#: Default home for written bundles, and therefore excluded from the walk.
|
|
35
|
+
BUNDLES_SUBDIR = "bundles"
|
|
36
|
+
|
|
37
|
+
#: Kinds a user can exclude by flag. Excluding these does NOT set ``truncated``
|
|
38
|
+
#: — that flag means "we dropped something that belonged here", and a declared
|
|
39
|
+
#: scope is not a loss.
|
|
40
|
+
OPTIONAL_KINDS = {
|
|
41
|
+
"guides": {
|
|
42
|
+
_classify.KIND_GUIDE_HTML,
|
|
43
|
+
_classify.KIND_GUIDE_MD,
|
|
44
|
+
_classify.KIND_GUIDE_ASSET,
|
|
45
|
+
_classify.KIND_INTERACTIVE_MANIFEST,
|
|
46
|
+
},
|
|
47
|
+
"traces": {_classify.KIND_TRACE},
|
|
48
|
+
"videos": {_classify.KIND_VIDEO},
|
|
49
|
+
"coverage": {
|
|
50
|
+
_classify.KIND_COVERAGE_RECORD,
|
|
51
|
+
_classify.KIND_COVERAGE_SOURCEMAP,
|
|
52
|
+
_classify.KIND_COVERAGE_GAPS,
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class Candidate:
|
|
59
|
+
"""A file that passed classification, with its content facts filled in."""
|
|
60
|
+
|
|
61
|
+
entry: BundleFile
|
|
62
|
+
source: Path
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class Assembly:
|
|
67
|
+
manifest: BundleManifest
|
|
68
|
+
candidates: list[Candidate]
|
|
69
|
+
junit_xml: Optional[bytes] = None
|
|
70
|
+
events: list[tuple[str, Path]] = field(default_factory=list)
|
|
71
|
+
warnings: list[str] = field(default_factory=list)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _git(project_root: Path, *args: str) -> Optional[str]:
|
|
75
|
+
"""Run a read-only git command, or ``None`` if git/the repo is unavailable."""
|
|
76
|
+
try:
|
|
77
|
+
out = subprocess.run(
|
|
78
|
+
["git", *args],
|
|
79
|
+
cwd=project_root,
|
|
80
|
+
capture_output=True,
|
|
81
|
+
text=True,
|
|
82
|
+
timeout=5,
|
|
83
|
+
)
|
|
84
|
+
except (OSError, subprocess.SubprocessError):
|
|
85
|
+
return None
|
|
86
|
+
value = out.stdout.strip()
|
|
87
|
+
return value if out.returncode == 0 and value else None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def detect_git(
|
|
91
|
+
project_root: Path,
|
|
92
|
+
*,
|
|
93
|
+
sha: Optional[str] = None,
|
|
94
|
+
branch: Optional[str] = None,
|
|
95
|
+
pr: Optional[int] = None,
|
|
96
|
+
) -> BundleGit:
|
|
97
|
+
"""Resolve git provenance: explicit flags → CI env → local git → nothing.
|
|
98
|
+
|
|
99
|
+
CI variables come before ``git rev-parse`` because a CI checkout is often
|
|
100
|
+
detached at a merge commit that does not describe the change under test.
|
|
101
|
+
"""
|
|
102
|
+
sha = sha or os.environ.get("GITHUB_SHA") or _git(project_root, "rev-parse", "HEAD")
|
|
103
|
+
branch = branch or os.environ.get("GITHUB_REF_NAME") or _git(project_root, "rev-parse", "--abbrev-ref", "HEAD")
|
|
104
|
+
if pr is None:
|
|
105
|
+
ref = os.environ.get("GITHUB_REF", "")
|
|
106
|
+
# refs/pull/42/merge
|
|
107
|
+
if ref.startswith("refs/pull/"):
|
|
108
|
+
candidate = ref.split("/")[2] if len(ref.split("/")) > 2 else ""
|
|
109
|
+
if candidate.isdigit():
|
|
110
|
+
pr = int(candidate)
|
|
111
|
+
if branch == "HEAD":
|
|
112
|
+
branch = None
|
|
113
|
+
return BundleGit(sha=sha, branch=branch, pr=pr)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _spec_from_sidecar(sidecar: Optional[dict]) -> BundleSpec:
|
|
117
|
+
if not sidecar:
|
|
118
|
+
return BundleSpec()
|
|
119
|
+
raw = sidecar.get("spec")
|
|
120
|
+
if not isinstance(raw, dict):
|
|
121
|
+
return BundleSpec()
|
|
122
|
+
return BundleSpec.model_validate(raw)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def walk_candidates(
|
|
126
|
+
output_dir: Path,
|
|
127
|
+
ctx: _classify.ClassifyContext,
|
|
128
|
+
*,
|
|
129
|
+
exclude_kinds: frozenset[str] = frozenset(),
|
|
130
|
+
skip_rel: frozenset[str] = frozenset(),
|
|
131
|
+
) -> tuple[list[Candidate], list[str]]:
|
|
132
|
+
"""Classify and hash everything under ``output_dir``.
|
|
133
|
+
|
|
134
|
+
Walks the whole tree rather than only the roots we know about: an
|
|
135
|
+
unclassified file becomes ``kind="other"`` and still ships. Dropping a file
|
|
136
|
+
silently is a far worse failure than carrying one nobody labelled, and the
|
|
137
|
+
contract explicitly allows unknown kinds.
|
|
138
|
+
"""
|
|
139
|
+
candidates: list[Candidate] = []
|
|
140
|
+
warnings: list[str] = []
|
|
141
|
+
|
|
142
|
+
for path in sorted(output_dir.rglob("*")):
|
|
143
|
+
if path.is_dir():
|
|
144
|
+
continue
|
|
145
|
+
if path.is_symlink():
|
|
146
|
+
# A symlinked capture tree is a path-traversal vector for whatever
|
|
147
|
+
# unpacks this. Never follow one; say so rather than dropping it
|
|
148
|
+
# quietly.
|
|
149
|
+
warnings.append(f"skipped symlink {path.relative_to(output_dir).as_posix()}")
|
|
150
|
+
continue
|
|
151
|
+
if not path.is_file():
|
|
152
|
+
continue
|
|
153
|
+
|
|
154
|
+
rel = path.relative_to(output_dir)
|
|
155
|
+
rel_posix = rel.as_posix()
|
|
156
|
+
if any(rel_posix == s or rel_posix.startswith(s + "/") for s in skip_rel):
|
|
157
|
+
continue
|
|
158
|
+
|
|
159
|
+
entry = _classify.classify(rel, ctx)
|
|
160
|
+
if entry is None:
|
|
161
|
+
continue
|
|
162
|
+
if entry.kind in exclude_kinds:
|
|
163
|
+
continue
|
|
164
|
+
|
|
165
|
+
# A coverage record describes itself; trust that over the filename.
|
|
166
|
+
if entry.kind == _classify.KIND_COVERAGE_RECORD:
|
|
167
|
+
attribution = _classify.read_coverage_record_attribution(path)
|
|
168
|
+
if attribution:
|
|
169
|
+
scenario, locale, layout = attribution
|
|
170
|
+
entry = entry.model_copy(update={"scenario": scenario, "locale": locale, "layout": layout})
|
|
171
|
+
|
|
172
|
+
try:
|
|
173
|
+
size = path.stat().st_size
|
|
174
|
+
digest = sha256_file(path)
|
|
175
|
+
except OSError as e:
|
|
176
|
+
warnings.append(f"skipped unreadable {rel_posix}: {e}")
|
|
177
|
+
continue
|
|
178
|
+
|
|
179
|
+
candidates.append(
|
|
180
|
+
Candidate(
|
|
181
|
+
entry=entry.model_copy(update={"path": f"output/{rel_posix}", "sha256": digest, "size": size}),
|
|
182
|
+
source=path,
|
|
183
|
+
)
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
return candidates, warnings
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def apply_size_budget(candidates: list[Candidate], max_size: int) -> tuple[list[Candidate], bool, list[str]]:
|
|
190
|
+
"""Drop the cheapest-to-lose kinds until the payload fits ``max_size``.
|
|
191
|
+
|
|
192
|
+
Returns ``(kept, truncated, warnings)``. ``max_size <= 0`` disables the
|
|
193
|
+
budget. Every drop is reported — a bundle that silently omits data reads to
|
|
194
|
+
its consumer as "this never happened", which is exactly the wrong
|
|
195
|
+
conclusion.
|
|
196
|
+
"""
|
|
197
|
+
if max_size <= 0:
|
|
198
|
+
return candidates, False, []
|
|
199
|
+
|
|
200
|
+
total = sum(c.entry.size for c in candidates)
|
|
201
|
+
if total <= max_size:
|
|
202
|
+
return candidates, False, []
|
|
203
|
+
|
|
204
|
+
kept = list(candidates)
|
|
205
|
+
warnings: list[str] = []
|
|
206
|
+
for kind in _classify.DROP_ORDER:
|
|
207
|
+
if total <= max_size:
|
|
208
|
+
break
|
|
209
|
+
victims = [c for c in kept if c.entry.kind == kind]
|
|
210
|
+
if not victims:
|
|
211
|
+
continue
|
|
212
|
+
freed = sum(c.entry.size for c in victims)
|
|
213
|
+
kept = [c for c in kept if c.entry.kind != kind]
|
|
214
|
+
total -= freed
|
|
215
|
+
warnings.append(f"size budget: dropped {len(victims)} {kind} file(s) ({freed / 1_048_576:.1f} MiB)")
|
|
216
|
+
|
|
217
|
+
if total > max_size:
|
|
218
|
+
warnings.append(
|
|
219
|
+
f"size budget exceeded even after dropping every optional artifact "
|
|
220
|
+
f"({total / 1_048_576:.1f} MiB > {max_size / 1_048_576:.1f} MiB); "
|
|
221
|
+
"screenshots and their sidecars are never dropped — raise --max-size"
|
|
222
|
+
)
|
|
223
|
+
return kept, True, warnings
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def build(
|
|
227
|
+
*,
|
|
228
|
+
project_root: Path,
|
|
229
|
+
output_dir: Path,
|
|
230
|
+
run_id: str,
|
|
231
|
+
tests: list[BundleTest],
|
|
232
|
+
locales: frozenset[str],
|
|
233
|
+
layouts: frozenset[str],
|
|
234
|
+
coverage_dirname: str = "coverage",
|
|
235
|
+
screenshots_dirname: str = "screenshots",
|
|
236
|
+
guides_dirname: str = "guides",
|
|
237
|
+
reports_dirname: str = "reports",
|
|
238
|
+
sidecar: Optional[dict] = None,
|
|
239
|
+
executor: str = "local",
|
|
240
|
+
git: Optional[BundleGit] = None,
|
|
241
|
+
exclude_kinds: frozenset[str] = frozenset(),
|
|
242
|
+
max_size: int = 0,
|
|
243
|
+
junit_xml: Optional[bytes] = None,
|
|
244
|
+
events: Optional[list[tuple[str, Path]]] = None,
|
|
245
|
+
extra_warnings: Optional[list[str]] = None,
|
|
246
|
+
) -> Assembly:
|
|
247
|
+
"""Assemble the manifest and the list of files to archive."""
|
|
248
|
+
ctx = _classify.ClassifyContext(
|
|
249
|
+
run_id=run_id,
|
|
250
|
+
locales=locales,
|
|
251
|
+
layouts=layouts,
|
|
252
|
+
nodeid_index=_classify.nodeid_index(tests),
|
|
253
|
+
screenshots_dirname=screenshots_dirname,
|
|
254
|
+
coverage_dirname=coverage_dirname,
|
|
255
|
+
guides_dirname=guides_dirname,
|
|
256
|
+
reports_dirname=reports_dirname,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
# The two generated members are archived at the root, not under output/, so
|
|
260
|
+
# their source directories are skipped in the walk.
|
|
261
|
+
from ..runmeta import EVENTS_SUBDIR, JUNIT_SUBDIR, REPORT_SUBDIR, RUN_SIDECAR_NAME
|
|
262
|
+
|
|
263
|
+
candidates, warnings = walk_candidates(
|
|
264
|
+
output_dir,
|
|
265
|
+
ctx,
|
|
266
|
+
exclude_kinds=exclude_kinds,
|
|
267
|
+
# ``bundles`` is where we write by default; without skipping it, each
|
|
268
|
+
# run would archive its predecessor and the tree would grow
|
|
269
|
+
# quadratically. ``report`` is the same argument once removed: the
|
|
270
|
+
# rendered site *contains copies of* the screenshots this bundle already
|
|
271
|
+
# carries, so archiving it would roughly double the payload for nothing.
|
|
272
|
+
skip_rel=frozenset({EVENTS_SUBDIR, JUNIT_SUBDIR, RUN_SIDECAR_NAME, BUNDLES_SUBDIR, REPORT_SUBDIR}),
|
|
273
|
+
)
|
|
274
|
+
warnings.extend(extra_warnings or [])
|
|
275
|
+
|
|
276
|
+
candidates, truncated, budget_warnings = apply_size_budget(candidates, max_size)
|
|
277
|
+
warnings.extend(budget_warnings)
|
|
278
|
+
|
|
279
|
+
files = [c.entry for c in candidates]
|
|
280
|
+
|
|
281
|
+
# The generated members are content-addressed too, so a consumer's dedup
|
|
282
|
+
# covers every byte in the archive. manifest.json is the one exception —
|
|
283
|
+
# it cannot contain its own digest.
|
|
284
|
+
from .hashing import sha256_bytes
|
|
285
|
+
|
|
286
|
+
if junit_xml is not None:
|
|
287
|
+
files.append(
|
|
288
|
+
BundleFile(path="junit.xml", kind=_classify.KIND_JUNIT, sha256=sha256_bytes(junit_xml), size=len(junit_xml))
|
|
289
|
+
)
|
|
290
|
+
for arcname, source in events or []:
|
|
291
|
+
try:
|
|
292
|
+
files.append(
|
|
293
|
+
BundleFile(
|
|
294
|
+
path=arcname,
|
|
295
|
+
kind=_classify.KIND_EVENTS,
|
|
296
|
+
sha256=sha256_file(source),
|
|
297
|
+
size=source.stat().st_size,
|
|
298
|
+
)
|
|
299
|
+
)
|
|
300
|
+
except OSError as e:
|
|
301
|
+
warnings.append(f"skipped unreadable {arcname}: {e}")
|
|
302
|
+
|
|
303
|
+
files.sort(key=lambda f: f.path)
|
|
304
|
+
|
|
305
|
+
coverage_records = _classify.count_coverage_records(output_dir / coverage_dirname, run_id)
|
|
306
|
+
# ``run.created_at`` describes the RUN, not the bundling of it — so it comes
|
|
307
|
+
# from the sidecar the run wrote, and only falls back to now when there is
|
|
308
|
+
# none. That is both more truthful and what makes an archive reproducible:
|
|
309
|
+
# re-bundling an unchanged tree must produce identical bytes, or the
|
|
310
|
+
# content-addressed upload protocol re-sends everything every time.
|
|
311
|
+
created_at = (sidecar or {}).get("created_at") or time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
312
|
+
manifest = BundleManifest(
|
|
313
|
+
steppy_bundle=BUNDLE_SCHEMA_VERSION,
|
|
314
|
+
run=BundleRun(
|
|
315
|
+
run_id=run_id,
|
|
316
|
+
created_at=created_at,
|
|
317
|
+
steppy_version=__version__,
|
|
318
|
+
executor=executor,
|
|
319
|
+
git=git or BundleGit(),
|
|
320
|
+
spec=_spec_from_sidecar(sidecar),
|
|
321
|
+
),
|
|
322
|
+
tests=tests,
|
|
323
|
+
files=files,
|
|
324
|
+
coverage=BundleCoverage(run_id=run_id, records=coverage_records) if coverage_records else None,
|
|
325
|
+
truncated=truncated,
|
|
326
|
+
warnings=warnings,
|
|
327
|
+
)
|
|
328
|
+
return Assembly(
|
|
329
|
+
manifest=manifest,
|
|
330
|
+
candidates=candidates,
|
|
331
|
+
junit_xml=junit_xml,
|
|
332
|
+
events=list(events or []),
|
|
333
|
+
warnings=warnings,
|
|
334
|
+
)
|