patchground 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.
- patchground/__init__.py +10 -0
- patchground/__main__.py +3 -0
- patchground/authoring/__init__.py +5 -0
- patchground/authoring/create.py +179 -0
- patchground/authoring/templates/python-debug/TICKET.md.tmpl +11 -0
- patchground/authoring/templates/python-debug/challenge.yaml.tmpl +21 -0
- patchground/authoring/templates/python-debug/checks/hidden/tests/test_starter_hidden.py.tmpl +13 -0
- patchground/authoring/templates/python-debug/checks/visible/tests/test_starter.py.tmpl +9 -0
- patchground/authoring/templates/python-debug/environment/Dockerfile.tmpl +3 -0
- patchground/authoring/templates/python-debug/solution/reference.patch.tmpl +8 -0
- patchground/authoring/templates/python-debug/workspace/pyproject.toml.tmpl +8 -0
- patchground/authoring/templates/python-debug/workspace/src/starter.py.tmpl +3 -0
- patchground/authoring/templates/python-feature/TICKET.md.tmpl +13 -0
- patchground/authoring/templates/python-feature/challenge.yaml.tmpl +22 -0
- patchground/authoring/templates/python-feature/checks/hidden/tests/test_starter_hidden.py.tmpl +13 -0
- patchground/authoring/templates/python-feature/checks/visible/tests/test_starter.py.tmpl +9 -0
- patchground/authoring/templates/python-feature/environment/Dockerfile.tmpl +3 -0
- patchground/authoring/templates/python-feature/solution/reference.patch.tmpl +9 -0
- patchground/authoring/templates/python-feature/workspace/pyproject.toml.tmpl +8 -0
- patchground/authoring/templates/python-feature/workspace/src/starter.py.tmpl +8 -0
- patchground/authoring/templates/typescript-debug/TICKET.md.tmpl +11 -0
- patchground/authoring/templates/typescript-debug/challenge.yaml.tmpl +21 -0
- patchground/authoring/templates/typescript-debug/checks/hidden/tests/starter-hidden.test.ts.tmpl +14 -0
- patchground/authoring/templates/typescript-debug/checks/visible/tests/starter.test.ts.tmpl +10 -0
- patchground/authoring/templates/typescript-debug/solution/reference.patch.tmpl +9 -0
- patchground/authoring/templates/typescript-debug/workspace/package.json.tmpl +6 -0
- patchground/authoring/templates/typescript-debug/workspace/src/starter.ts.tmpl +4 -0
- patchground/authoring/templates/typescript-feature/TICKET.md.tmpl +13 -0
- patchground/authoring/templates/typescript-feature/challenge.yaml.tmpl +22 -0
- patchground/authoring/templates/typescript-feature/checks/hidden/tests/starter-hidden.test.ts.tmpl +14 -0
- patchground/authoring/templates/typescript-feature/checks/visible/tests/starter.test.ts.tmpl +10 -0
- patchground/authoring/templates/typescript-feature/solution/reference.patch.tmpl +10 -0
- patchground/authoring/templates/typescript-feature/workspace/package.json.tmpl +6 -0
- patchground/authoring/templates/typescript-feature/workspace/src/starter.ts.tmpl +9 -0
- patchground/automation/__init__.py +5 -0
- patchground/automation/__main__.py +131 -0
- patchground/automation/workflow.py +214 -0
- patchground/catalog/README.md +10 -0
- patchground/catalog/cache-expiry/TICKET.md +49 -0
- patchground/catalog/cache-expiry/challenge.yaml +32 -0
- patchground/catalog/cache-expiry/checks/hidden/tests/test_cache_hidden.py +164 -0
- patchground/catalog/cache-expiry/checks/visible/tests/test_cache.py +56 -0
- patchground/catalog/cache-expiry/debrief/questions.md +4 -0
- patchground/catalog/cache-expiry/environment/Dockerfile +3 -0
- patchground/catalog/cache-expiry/solution/reference.patch +41 -0
- patchground/catalog/cache-expiry/workspace/pyproject.toml +8 -0
- patchground/catalog/cache-expiry/workspace/src/profilecache/__init__.py +6 -0
- patchground/catalog/cache-expiry/workspace/src/profilecache/cache.py +67 -0
- patchground/catalog/cache-expiry/workspace/src/profilecache/client.py +38 -0
- patchground/catalog/csv-import-row-errors/TICKET.md +97 -0
- patchground/catalog/csv-import-row-errors/challenge.yaml +33 -0
- patchground/catalog/csv-import-row-errors/checks/hidden/tests/test_import_hidden.py +359 -0
- patchground/catalog/csv-import-row-errors/checks/visible/tests/test_import.py +34 -0
- patchground/catalog/csv-import-row-errors/debrief/questions.md +5 -0
- patchground/catalog/csv-import-row-errors/environment/Dockerfile +3 -0
- patchground/catalog/csv-import-row-errors/solution/reference.patch +74 -0
- patchground/catalog/csv-import-row-errors/workspace/pyproject.toml +8 -0
- patchground/catalog/csv-import-row-errors/workspace/src/stockimport/__init__.py +4 -0
- patchground/catalog/csv-import-row-errors/workspace/src/stockimport/importer.py +69 -0
- patchground/catalog/csv-import-row-errors/workspace/src/stockimport/models.py +32 -0
- patchground/catalog/duplicate-charges-on-retry/TICKET.md +28 -0
- patchground/catalog/duplicate-charges-on-retry/challenge.yaml +30 -0
- patchground/catalog/duplicate-charges-on-retry/checks/hidden/tests/test_payment_regressions.py +87 -0
- patchground/catalog/duplicate-charges-on-retry/checks/visible/tests/test_checkout.py +34 -0
- patchground/catalog/duplicate-charges-on-retry/debrief/questions.md +6 -0
- patchground/catalog/duplicate-charges-on-retry/environment/Dockerfile +3 -0
- patchground/catalog/duplicate-charges-on-retry/solution/reference.patch +11 -0
- patchground/catalog/duplicate-charges-on-retry/workspace/pyproject.toml +8 -0
- patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/__init__.py +1 -0
- patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/gateway.py +38 -0
- patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/models.py +19 -0
- patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/service.py +23 -0
- patchground/catalog/event-bus-subscriptions/TICKET.md +108 -0
- patchground/catalog/event-bus-subscriptions/challenge.yaml +33 -0
- patchground/catalog/event-bus-subscriptions/checks/hidden/tests/event-bus-contract.test.ts +566 -0
- patchground/catalog/event-bus-subscriptions/checks/visible/tests/event-bus.test.ts +70 -0
- patchground/catalog/event-bus-subscriptions/debrief/questions.md +5 -0
- patchground/catalog/event-bus-subscriptions/environment/Dockerfile +7 -0
- patchground/catalog/event-bus-subscriptions/environment/package-lock.json +1054 -0
- patchground/catalog/event-bus-subscriptions/environment/package.json +13 -0
- patchground/catalog/event-bus-subscriptions/environment/vitest-runner.mjs +124 -0
- patchground/catalog/event-bus-subscriptions/solution/reference.patch +82 -0
- patchground/catalog/event-bus-subscriptions/workspace/package.json +6 -0
- patchground/catalog/event-bus-subscriptions/workspace/src/bus.ts +32 -0
- patchground/catalog/order-notifications-boundary/TICKET.md +51 -0
- patchground/catalog/order-notifications-boundary/challenge.yaml +32 -0
- patchground/catalog/order-notifications-boundary/checks/hidden/tests/test_boundary_hidden.py +87 -0
- patchground/catalog/order-notifications-boundary/checks/visible/tests/test_confirmation.py +41 -0
- patchground/catalog/order-notifications-boundary/debrief/questions.md +5 -0
- patchground/catalog/order-notifications-boundary/environment/Dockerfile +3 -0
- patchground/catalog/order-notifications-boundary/solution/reference.patch +77 -0
- patchground/catalog/order-notifications-boundary/workspace/pyproject.toml +8 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/__init__.py +6 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/app.py +9 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/models.py +25 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/service.py +31 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/smtp_gateway.py +19 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/sqlite_store.py +44 -0
- patchground/catalog/pagination-last-page/TICKET.md +44 -0
- patchground/catalog/pagination-last-page/challenge.yaml +31 -0
- patchground/catalog/pagination-last-page/checks/hidden/tests/test_pagination_hidden.py +32 -0
- patchground/catalog/pagination-last-page/checks/visible/tests/test_pagination.py +37 -0
- patchground/catalog/pagination-last-page/debrief/questions.md +9 -0
- patchground/catalog/pagination-last-page/environment/Dockerfile +3 -0
- patchground/catalog/pagination-last-page/solution/reference.patch +22 -0
- patchground/catalog/pagination-last-page/workspace/pyproject.toml +8 -0
- patchground/catalog/pagination-last-page/workspace/src/catalog/__init__.py +1 -0
- patchground/catalog/pagination-last-page/workspace/src/catalog/pagination.py +52 -0
- patchground/catalog/release-version-ordering/TICKET.md +72 -0
- patchground/catalog/release-version-ordering/challenge.yaml +31 -0
- patchground/catalog/release-version-ordering/checks/hidden/tests/releasekit_contract.test.ts +92 -0
- patchground/catalog/release-version-ordering/checks/visible/tests/releasekit.test.ts +31 -0
- patchground/catalog/release-version-ordering/debrief/questions.md +9 -0
- patchground/catalog/release-version-ordering/environment/Dockerfile +7 -0
- patchground/catalog/release-version-ordering/environment/package-lock.json +1054 -0
- patchground/catalog/release-version-ordering/environment/package.json +13 -0
- patchground/catalog/release-version-ordering/environment/vitest-runner.mjs +124 -0
- patchground/catalog/release-version-ordering/solution/reference.patch +36 -0
- patchground/catalog/release-version-ordering/workspace/README.md +7 -0
- patchground/catalog/release-version-ordering/workspace/src/releasekit/index.ts +2 -0
- patchground/catalog/release-version-ordering/workspace/src/releasekit/ordering.ts +41 -0
- patchground/catalog/release-version-ordering/workspace/src/releasekit/parse.ts +24 -0
- patchground/cli/__init__.py +1 -0
- patchground/cli/app.py +59 -0
- patchground/cli/authoring.py +41 -0
- patchground/cli/consent.py +77 -0
- patchground/cli/discovery.py +189 -0
- patchground/cli/doctor.py +249 -0
- patchground/cli/packs.py +142 -0
- patchground/cli/practice.py +141 -0
- patchground/cli/render.py +58 -0
- patchground/cli/reports.py +53 -0
- patchground/cli/sessions.py +195 -0
- patchground/cli/validate.py +117 -0
- patchground/packs/__init__.py +23 -0
- patchground/packs/builtin.py +53 -0
- patchground/packs/discovery.py +285 -0
- patchground/packs/execution.py +420 -0
- patchground/packs/models.py +98 -0
- patchground/packs/registry.py +318 -0
- patchground/packs/source.py +230 -0
- patchground/packs/store.py +420 -0
- patchground/py.typed +0 -0
- patchground/report/__init__.py +6 -0
- patchground/report/analysis.py +188 -0
- patchground/report/models.py +59 -0
- patchground/report/regression.py +597 -0
- patchground/report/render.py +92 -0
- patchground/report/workflow.py +84 -0
- patchground/runner_assets/Dockerfile +7 -0
- patchground/runner_assets/__init__.py +16 -0
- patchground/runner_assets/package-lock.json +1054 -0
- patchground/runner_assets/package.json +13 -0
- patchground/runner_assets/vitest-runner.mjs +124 -0
- patchground/safeio/__init__.py +115 -0
- patchground/sandbox/__init__.py +39 -0
- patchground/sandbox/_engine.py +23 -0
- patchground/sandbox/build.py +202 -0
- patchground/sandbox/errors.py +23 -0
- patchground/sandbox/junit.py +76 -0
- patchground/sandbox/probe.py +118 -0
- patchground/sandbox/run.py +535 -0
- patchground/sessions/__init__.py +44 -0
- patchground/sessions/diagnostics.py +145 -0
- patchground/sessions/files.py +113 -0
- patchground/sessions/git.py +70 -0
- patchground/sessions/history.py +386 -0
- patchground/sessions/history_ignore.py +279 -0
- patchground/sessions/models.py +126 -0
- patchground/sessions/practice.py +202 -0
- patchground/sessions/reset.py +121 -0
- patchground/sessions/store.py +203 -0
- patchground/sessions/submission.py +344 -0
- patchground/sessions/workflow.py +297 -0
- patchground/spec/__init__.py +35 -0
- patchground/spec/_schema.py +53 -0
- patchground/spec/loader.py +713 -0
- patchground/spec/models.py +394 -0
- patchground/spec/schema.py +50 -0
- patchground/verify/__init__.py +29 -0
- patchground/verify/errors.py +10 -0
- patchground/verify/pytest_adapter.py +95 -0
- patchground/verify/record.py +94 -0
- patchground/verify/reference.py +158 -0
- patchground/verify/result.py +199 -0
- patchground/verify/runners.py +47 -0
- patchground/verify/tampering.py +106 -0
- patchground/verify/validate.py +686 -0
- patchground/verify/vitest_adapter.py +252 -0
- patchground-0.1.0.dist-info/METADATA +258 -0
- patchground-0.1.0.dist-info/RECORD +194 -0
- patchground-0.1.0.dist-info/WHEEL +4 -0
- patchground-0.1.0.dist-info/entry_points.txt +3 -0
- patchground-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Apply ``reference.patch`` to a temporary copy of the starting workspace with host git.
|
|
2
|
+
|
|
3
|
+
``git apply`` here is not a challenge command: it only edits files in a copy that lives in the
|
|
4
|
+
system temp directory, outside any repository. Git must never read configuration the challenge
|
|
5
|
+
controls: a ``.git`` inside the workspace could define ``filter.*.clean``/``smudge`` commands that
|
|
6
|
+
``.gitattributes`` would make git run on the host. So the copy never contains ``.git`` entries
|
|
7
|
+
(:func:`copy_workspace`), and git runs with ``GIT_DIR`` pointing at a fresh, empty bare repository
|
|
8
|
+
created next to the copy (no discovery at all), with ``GIT_CEILING_DIRECTORIES`` as a second fence,
|
|
9
|
+
system and global config and system attributes ignored, and hooks, fsmonitor and the attributes
|
|
10
|
+
file disabled. ``--unsafe-paths`` is never passed, so the patch cannot touch anything outside the
|
|
11
|
+
copy.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import hashlib
|
|
17
|
+
import os
|
|
18
|
+
import shutil
|
|
19
|
+
import stat
|
|
20
|
+
import subprocess
|
|
21
|
+
import tempfile
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from patchground import safeio
|
|
25
|
+
from patchground.verify.errors import VerifyEnvironmentError
|
|
26
|
+
|
|
27
|
+
GIT_TIMEOUT_SECONDS = 60.0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class PatchError(Exception):
|
|
31
|
+
"""``git apply`` rejected the patch; ``stderr`` is git's explanation."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, stderr: str) -> None:
|
|
34
|
+
super().__init__(stderr)
|
|
35
|
+
self.stderr = stderr
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def snapshot(directory: Path) -> dict[str, str]:
|
|
39
|
+
"""Map relative POSIX path -> sha256 of the content for every non-directory entry.
|
|
40
|
+
|
|
41
|
+
Symlinks are recorded by their target text and never followed.
|
|
42
|
+
"""
|
|
43
|
+
entries: dict[str, str] = {}
|
|
44
|
+
for current, dirnames, filenames in directory.walk(follow_symlinks=False):
|
|
45
|
+
names = filenames + [d for d in dirnames if (current / d).is_symlink()]
|
|
46
|
+
for name in names:
|
|
47
|
+
path = current / name
|
|
48
|
+
rel = path.relative_to(directory).as_posix()
|
|
49
|
+
if path.is_symlink():
|
|
50
|
+
entries[rel] = "L" + hashlib.sha256(os.fsencode(path.readlink())).hexdigest()
|
|
51
|
+
elif stat.S_ISREG(path.lstat().st_mode):
|
|
52
|
+
entries[rel] = hashlib.sha256(path.read_bytes()).hexdigest()
|
|
53
|
+
else:
|
|
54
|
+
entries[rel] = "special"
|
|
55
|
+
return entries
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _is_git_entry(name: str) -> bool:
|
|
59
|
+
return name.casefold() == ".git"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _ignore_git(_directory: str, names: list[str]) -> set[str]:
|
|
63
|
+
return {name for name in names if _is_git_entry(name)}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def copy_workspace(source: Path, target: Path) -> None:
|
|
67
|
+
"""Copy ``source`` to the new directory ``target`` without any ``.git`` entry (any case/depth).
|
|
68
|
+
|
|
69
|
+
Symlinks are copied as symlinks. Raises :class:`VerifyEnvironmentError` if the copy fails.
|
|
70
|
+
"""
|
|
71
|
+
try:
|
|
72
|
+
shutil.copytree(source, target, symlinks=True, ignore=_ignore_git)
|
|
73
|
+
except (OSError, shutil.Error) as exc:
|
|
74
|
+
raise VerifyEnvironmentError(
|
|
75
|
+
f"could not copy the workspace to a temporary folder: {exc}"
|
|
76
|
+
) from exc
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
_GIT_HARDENING = (
|
|
80
|
+
"-c",
|
|
81
|
+
f"core.hooksPath={os.devnull}",
|
|
82
|
+
"-c",
|
|
83
|
+
"core.fsmonitor=false",
|
|
84
|
+
"-c",
|
|
85
|
+
f"core.attributesFile={os.devnull}",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _git_env(temp_root: Path) -> dict[str, str]:
|
|
90
|
+
return {
|
|
91
|
+
"PATH": os.defpath,
|
|
92
|
+
"HOME": str(temp_root),
|
|
93
|
+
"LC_ALL": "C",
|
|
94
|
+
"GIT_CEILING_DIRECTORIES": str(temp_root.parent),
|
|
95
|
+
"GIT_CONFIG_NOSYSTEM": "1",
|
|
96
|
+
"GIT_CONFIG_GLOBAL": os.devnull,
|
|
97
|
+
"GIT_ATTR_NOSYSTEM": "1",
|
|
98
|
+
"GIT_TERMINAL_PROMPT": "0",
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _git(*inputs: Path) -> str:
|
|
103
|
+
return safeio.resolve_git(*inputs, error=VerifyEnvironmentError)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def apply_patch(
|
|
107
|
+
patch: Path, target: Path, temp_root: Path, *, inputs: tuple[Path, ...] = ()
|
|
108
|
+
) -> None:
|
|
109
|
+
"""Apply ``patch`` (paths relative to ``target``, ``-p1``) inside ``target``.
|
|
110
|
+
|
|
111
|
+
``temp_root`` is the temporary directory that contains ``target``; an empty bare repository
|
|
112
|
+
is created there (outside ``target``) and used as ``GIT_DIR``, so git never discovers or
|
|
113
|
+
reads any other repository, including a ``.git`` inside ``target``. Raises
|
|
114
|
+
:class:`PatchError` if git rejects the patch and :class:`VerifyEnvironmentError` if git is
|
|
115
|
+
missing or cannot run.
|
|
116
|
+
"""
|
|
117
|
+
git = _git(patch.parent, target, temp_root, *inputs)
|
|
118
|
+
target = target.resolve()
|
|
119
|
+
env = _git_env(temp_root)
|
|
120
|
+
try:
|
|
121
|
+
git_dir = Path(tempfile.mkdtemp(prefix="gitdir-", dir=temp_root)).resolve()
|
|
122
|
+
except OSError as exc:
|
|
123
|
+
raise VerifyEnvironmentError(f"could not create a temporary git directory: {exc}") from exc
|
|
124
|
+
if git_dir.is_relative_to(target):
|
|
125
|
+
raise ValueError("temp_root must not lie inside the target")
|
|
126
|
+
init = _run_git(
|
|
127
|
+
[git, *_GIT_HARDENING, "init", "--bare", "-q", "--template=", str(git_dir)], temp_root, env
|
|
128
|
+
)
|
|
129
|
+
if init.returncode != 0:
|
|
130
|
+
raise VerifyEnvironmentError("could not initialize the isolated reference repository")
|
|
131
|
+
env = {**env, "GIT_DIR": str(git_dir), "GIT_WORK_TREE": str(target)}
|
|
132
|
+
patch_arg = str(patch.resolve())
|
|
133
|
+
for argv in (
|
|
134
|
+
[git, *_GIT_HARDENING, "apply", "--check", "-p1", patch_arg],
|
|
135
|
+
[git, *_GIT_HARDENING, "apply", "-p1", "--whitespace=nowarn", patch_arg],
|
|
136
|
+
):
|
|
137
|
+
completed = _run_git(argv, target, env)
|
|
138
|
+
if completed.returncode != 0:
|
|
139
|
+
raise PatchError(f"git apply exited {completed.returncode}")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _run_git(argv: list[str], cwd: Path, env: dict[str, str]) -> subprocess.CompletedProcess[str]:
|
|
143
|
+
try:
|
|
144
|
+
return subprocess.run(
|
|
145
|
+
argv,
|
|
146
|
+
cwd=cwd,
|
|
147
|
+
env=env,
|
|
148
|
+
stdin=subprocess.DEVNULL,
|
|
149
|
+
capture_output=True,
|
|
150
|
+
text=True,
|
|
151
|
+
errors="replace",
|
|
152
|
+
timeout=GIT_TIMEOUT_SECONDS,
|
|
153
|
+
check=False,
|
|
154
|
+
)
|
|
155
|
+
except OSError as exc:
|
|
156
|
+
raise VerifyEnvironmentError(f"could not run git: {exc}") from exc
|
|
157
|
+
except subprocess.TimeoutExpired as exc:
|
|
158
|
+
raise VerifyEnvironmentError(f"git did not finish within {GIT_TIMEOUT_SECONDS:g}s") from exc
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Structured results of a validation: per-step results and the overall report.
|
|
2
|
+
|
|
3
|
+
Everything here is plain data with a JSON form (``to_dict``). The JSON form never carries file
|
|
4
|
+
contents, the reference patch or logs of runs that include hidden checks: only test ids and
|
|
5
|
+
statuses, counts, and short human-readable details.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Sequence
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from datetime import UTC, datetime
|
|
13
|
+
from enum import StrEnum
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from patchground.sandbox import RunOutcome, RunResult, TestStatus
|
|
18
|
+
from patchground.spec import Challenge, ManifestIssue
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class StepName(StrEnum):
|
|
22
|
+
"""The verification steps of ``patchground-contract-verification``, in order."""
|
|
23
|
+
|
|
24
|
+
MANIFEST = "manifest"
|
|
25
|
+
BUILD = "build"
|
|
26
|
+
BROKEN_FAILS = "broken_fails"
|
|
27
|
+
REFERENCE_PASSES = "reference_passes"
|
|
28
|
+
DETERMINISM = "determinism"
|
|
29
|
+
ANTI_GAMING = "anti_gaming"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class StepStatus(StrEnum):
|
|
33
|
+
PASSED = "passed"
|
|
34
|
+
FAILED = "failed"
|
|
35
|
+
NOT_RUN = "not_run"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def utc_now() -> str:
|
|
39
|
+
"""Current UTC time as ISO 8601 with millisecond precision and a ``Z`` suffix."""
|
|
40
|
+
return datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class TestOutcome:
|
|
45
|
+
"""One test of a run: its id and status only."""
|
|
46
|
+
|
|
47
|
+
__test__ = False # not a pytest test class
|
|
48
|
+
|
|
49
|
+
id: str
|
|
50
|
+
status: TestStatus
|
|
51
|
+
|
|
52
|
+
def to_dict(self) -> dict[str, Any]:
|
|
53
|
+
return {"id": self.id, "status": self.status.value}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class RunSummary:
|
|
58
|
+
"""One sandbox run as recorded by validation (no log text)."""
|
|
59
|
+
|
|
60
|
+
label: str
|
|
61
|
+
outcome: RunOutcome
|
|
62
|
+
tests: tuple[TestOutcome, ...]
|
|
63
|
+
|
|
64
|
+
@classmethod
|
|
65
|
+
def from_result(cls, label: str, result: RunResult) -> RunSummary:
|
|
66
|
+
return cls(label, result.outcome, tuple(TestOutcome(t.id, t.status) for t in result.tests))
|
|
67
|
+
|
|
68
|
+
def count(self, status: TestStatus) -> int:
|
|
69
|
+
return sum(1 for t in self.tests if t.status is status)
|
|
70
|
+
|
|
71
|
+
def ids(self) -> list[str]:
|
|
72
|
+
return [t.id for t in self.tests]
|
|
73
|
+
|
|
74
|
+
def to_dict(self) -> dict[str, Any]:
|
|
75
|
+
return {
|
|
76
|
+
"label": self.label,
|
|
77
|
+
"outcome": self.outcome.value,
|
|
78
|
+
"counts": {
|
|
79
|
+
"passed": self.count(TestStatus.PASSED),
|
|
80
|
+
"failed": self.count(TestStatus.FAILED),
|
|
81
|
+
"errored": self.count(TestStatus.ERRORED),
|
|
82
|
+
"skipped": self.count(TestStatus.SKIPPED),
|
|
83
|
+
},
|
|
84
|
+
"tests": [t.to_dict() for t in self.tests],
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True)
|
|
89
|
+
class StepResult:
|
|
90
|
+
"""Result of one step.
|
|
91
|
+
|
|
92
|
+
``issues``/``issue_lines`` are set for a failed ``manifest`` step (``issue_lines`` are the
|
|
93
|
+
``file:line: field: message`` renderings, relative to the working directory when possible);
|
|
94
|
+
``log_tail`` holds the end of the build log for a failed ``build`` step. ``notes`` are
|
|
95
|
+
sandbox explanations (``[patchground]`` lines) for inconclusive runs.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
name: StepName
|
|
99
|
+
status: StepStatus
|
|
100
|
+
detail: str
|
|
101
|
+
started_at: str | None = None
|
|
102
|
+
finished_at: str | None = None
|
|
103
|
+
runs: tuple[RunSummary, ...] = ()
|
|
104
|
+
issues: tuple[ManifestIssue, ...] = ()
|
|
105
|
+
issue_lines: tuple[str, ...] = ()
|
|
106
|
+
log_tail: str | None = None
|
|
107
|
+
notes: tuple[str, ...] = ()
|
|
108
|
+
|
|
109
|
+
def to_record(self) -> dict[str, Any]:
|
|
110
|
+
"""The shape stored in ``verification.json``."""
|
|
111
|
+
return {
|
|
112
|
+
"name": self.name.value,
|
|
113
|
+
"status": self.status.value,
|
|
114
|
+
"detail": self.detail,
|
|
115
|
+
"started_at": self.started_at,
|
|
116
|
+
"finished_at": self.finished_at,
|
|
117
|
+
"runs": [run.to_dict() for run in self.runs],
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
def to_dict(self) -> dict[str, Any]:
|
|
121
|
+
"""The record shape plus the failure diagnostics that are set."""
|
|
122
|
+
data = self.to_record()
|
|
123
|
+
if self.issues:
|
|
124
|
+
data["issues"] = [
|
|
125
|
+
{
|
|
126
|
+
"file": str(issue.file),
|
|
127
|
+
"line": issue.line,
|
|
128
|
+
"field": issue.field,
|
|
129
|
+
"message": issue.message,
|
|
130
|
+
"text": text,
|
|
131
|
+
}
|
|
132
|
+
for issue, text in zip(self.issues, self.issue_lines, strict=True)
|
|
133
|
+
]
|
|
134
|
+
if self.log_tail is not None:
|
|
135
|
+
data["log_tail"] = self.log_tail
|
|
136
|
+
if self.notes:
|
|
137
|
+
data["notes"] = list(self.notes)
|
|
138
|
+
return data
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@dataclass(frozen=True)
|
|
142
|
+
class ValidationReport:
|
|
143
|
+
"""Outcome of :func:`patchground.verify.validate_challenge`."""
|
|
144
|
+
|
|
145
|
+
folder: Path
|
|
146
|
+
steps: tuple[StepResult, ...]
|
|
147
|
+
validated_at: str
|
|
148
|
+
challenge: Challenge | None = None
|
|
149
|
+
image_id: str | None = None
|
|
150
|
+
verification_file: Path | None = field(default=None)
|
|
151
|
+
removed_verification_file: Path | None = field(default=None)
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def valid(self) -> bool:
|
|
155
|
+
"""True when no step failed and at least one step ran."""
|
|
156
|
+
return self.failed_step is None and any(
|
|
157
|
+
step.status is StepStatus.PASSED for step in self.steps
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
@property
|
|
161
|
+
def failed_step(self) -> StepName | None:
|
|
162
|
+
for step in self.steps:
|
|
163
|
+
if step.status is StepStatus.FAILED:
|
|
164
|
+
return step.name
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
def step(self, name: StepName) -> StepResult:
|
|
168
|
+
for step in self.steps:
|
|
169
|
+
if step.name is name:
|
|
170
|
+
return step
|
|
171
|
+
raise KeyError(name)
|
|
172
|
+
|
|
173
|
+
def to_dict(self) -> dict[str, Any]:
|
|
174
|
+
failed = self.failed_step
|
|
175
|
+
return {
|
|
176
|
+
"valid": self.valid,
|
|
177
|
+
"failed_step": failed.value if failed is not None else None,
|
|
178
|
+
"steps": [step.to_dict() for step in self.steps],
|
|
179
|
+
"verification_file": (
|
|
180
|
+
str(self.verification_file) if self.verification_file is not None else None
|
|
181
|
+
),
|
|
182
|
+
"removed_verification_file": (
|
|
183
|
+
str(self.removed_verification_file)
|
|
184
|
+
if self.removed_verification_file is not None
|
|
185
|
+
else None
|
|
186
|
+
),
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def run_label(*parts: str) -> str:
|
|
191
|
+
return "+".join(parts)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def format_ids(ids: Sequence[str], limit: int = 10) -> str:
|
|
195
|
+
"""Comma-separated ids, truncated after ``limit`` with a count of the rest."""
|
|
196
|
+
shown = ", ".join(ids[:limit])
|
|
197
|
+
if len(ids) > limit:
|
|
198
|
+
shown += f", … ({len(ids) - limit} more)"
|
|
199
|
+
return shown
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Recognize the bounded runner recipes without executing authored commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import shlex
|
|
7
|
+
from pathlib import PurePath, PurePosixPath
|
|
8
|
+
from typing import Literal
|
|
9
|
+
|
|
10
|
+
from patchground.runner_assets import VITEST_COMMAND
|
|
11
|
+
from patchground.verify.pytest_adapter import supports_pytest
|
|
12
|
+
|
|
13
|
+
Runner = Literal["pytest", "vitest"]
|
|
14
|
+
VITEST_EXCLUDED_PARTS = frozenset({"node_modules", ".git"})
|
|
15
|
+
_SAFE_MODULE = re.compile(r"[A-Za-z0-9_][A-Za-z0-9_./-]*")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def is_vitest_path(path: PurePath) -> bool:
|
|
19
|
+
return path.name.endswith(".test.ts") and not VITEST_EXCLUDED_PARTS.intersection(path.parts)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def vitest_identity(module: str, name: str) -> str:
|
|
23
|
+
path = PurePosixPath(module)
|
|
24
|
+
identity = f"{module}::{name}"
|
|
25
|
+
if (
|
|
26
|
+
not _SAFE_MODULE.fullmatch(module)
|
|
27
|
+
or not is_vitest_path(path)
|
|
28
|
+
or path.is_absolute()
|
|
29
|
+
or ".." in path.parts
|
|
30
|
+
or any(part.startswith("-") for part in path.parts)
|
|
31
|
+
or not name
|
|
32
|
+
or not identity.isprintable()
|
|
33
|
+
or len(identity) > 240
|
|
34
|
+
):
|
|
35
|
+
raise ValueError("TypeScript test identity cannot be represented safely")
|
|
36
|
+
return identity
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def runner_for(command: str) -> Runner | None:
|
|
40
|
+
if supports_pytest(command):
|
|
41
|
+
return "pytest"
|
|
42
|
+
try:
|
|
43
|
+
if shlex.split(command) == shlex.split(VITEST_COMMAND):
|
|
44
|
+
return "vitest"
|
|
45
|
+
except ValueError:
|
|
46
|
+
pass
|
|
47
|
+
return None
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Bounded pytest test-module mutations in disposable editable layers only."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
from collections import Counter
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Literal
|
|
9
|
+
|
|
10
|
+
from patchground.verify.pytest_adapter import (
|
|
11
|
+
UnsupportedPytest,
|
|
12
|
+
is_test_filename,
|
|
13
|
+
iter_test_definitions,
|
|
14
|
+
supports_pytest,
|
|
15
|
+
)
|
|
16
|
+
from patchground.verify.pytest_adapter import (
|
|
17
|
+
parse_module as parse_pytest_module,
|
|
18
|
+
)
|
|
19
|
+
from patchground.verify.runners import Runner, is_vitest_path, vitest_identity
|
|
20
|
+
from patchground.verify.vitest_adapter import UnsupportedVitest, parse_module, skip_module
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"Mutation",
|
|
24
|
+
"UnsupportedTampering",
|
|
25
|
+
"mutate",
|
|
26
|
+
"supports_pytest",
|
|
27
|
+
"test_modules",
|
|
28
|
+
"vitest_identities",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
Mutation = Literal["empty", "deleted", "skipped"]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class UnsupportedTampering(Exception):
|
|
35
|
+
"""No supported mutation can be established; never report this as verified."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _syntax(path: Path) -> ast.Module:
|
|
39
|
+
try:
|
|
40
|
+
return parse_pytest_module(path)
|
|
41
|
+
except UnsupportedPytest as exc:
|
|
42
|
+
raise UnsupportedTampering("editable modules are not supported Python source") from exc
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _vitest_modules(layers: list[Path]) -> list[tuple[Path, str]]:
|
|
46
|
+
return [
|
|
47
|
+
(path, path.relative_to(layer).as_posix())
|
|
48
|
+
for layer in layers
|
|
49
|
+
for path in sorted(layer.rglob("*.test.ts"))
|
|
50
|
+
if is_vitest_path(path.relative_to(layer))
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def vitest_identities(layers: list[Path]) -> Counter[str]:
|
|
55
|
+
"""Expected editable identities, checked against actual collection before mutation."""
|
|
56
|
+
try:
|
|
57
|
+
return Counter(
|
|
58
|
+
vitest_identity(relative, name)
|
|
59
|
+
for path, relative in _vitest_modules(layers)
|
|
60
|
+
for name in parse_module(path).definitions
|
|
61
|
+
)
|
|
62
|
+
except (UnsupportedVitest, ValueError) as exc:
|
|
63
|
+
raise UnsupportedTampering(
|
|
64
|
+
"TypeScript test identities cannot be established safely"
|
|
65
|
+
) from exc
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_modules(layers: list[Path], *, runner: Runner = "pytest") -> list[Path]:
|
|
69
|
+
"""Select conventional modules with static tests, preserving helper-only modules."""
|
|
70
|
+
if runner == "vitest":
|
|
71
|
+
try:
|
|
72
|
+
return [path for path, _ in _vitest_modules(layers) if parse_module(path).definitions]
|
|
73
|
+
except UnsupportedVitest as exc:
|
|
74
|
+
raise UnsupportedTampering("unsupported TypeScript test module") from exc
|
|
75
|
+
candidates = sorted(
|
|
76
|
+
path for layer in layers for path in layer.rglob("*.py") if is_test_filename(path.name)
|
|
77
|
+
)
|
|
78
|
+
return [
|
|
79
|
+
path for path in candidates if next(iter_test_definitions(_syntax(path)), None) is not None
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def mutate(modules: list[Path], mutation: Mutation, *, runner: Runner = "pytest") -> None:
|
|
84
|
+
for path in modules:
|
|
85
|
+
if mutation == "deleted":
|
|
86
|
+
path.unlink()
|
|
87
|
+
elif mutation == "skipped" and runner == "vitest":
|
|
88
|
+
try:
|
|
89
|
+
skip_module(path)
|
|
90
|
+
except UnsupportedVitest as exc:
|
|
91
|
+
raise UnsupportedTampering("unsupported TypeScript skip mutation") from exc
|
|
92
|
+
elif mutation == "skipped":
|
|
93
|
+
syntax = _syntax(path)
|
|
94
|
+
names = {node.id for node in ast.walk(syntax) if isinstance(node, ast.Name)}
|
|
95
|
+
alias = "_patchground_anti_gaming_pytest"
|
|
96
|
+
while alias in names:
|
|
97
|
+
alias += "_"
|
|
98
|
+
suffix = (
|
|
99
|
+
f"\nimport pytest as {alias}\n"
|
|
100
|
+
"pytestmark = globals().get('pytestmark', [])\n"
|
|
101
|
+
"pytestmark = (list(pytestmark) if isinstance(pytestmark, (list, tuple)) "
|
|
102
|
+
"else [pytestmark]) + "
|
|
103
|
+
f"[{alias}.mark.skip(reason='Patchground editable-test probe')]\n"
|
|
104
|
+
)
|
|
105
|
+
with path.open("ab") as stream:
|
|
106
|
+
stream.write(suffix.encode("ascii"))
|