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,124 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { mkdtemp, readFile, writeFile, rename, rm, lstat, realpath } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
|
|
7
|
+
const root = '/workspace';
|
|
8
|
+
const final = '/results/junit.xml';
|
|
9
|
+
const self = fileURLToPath(import.meta.url);
|
|
10
|
+
|
|
11
|
+
async function child(destination, files) {
|
|
12
|
+
const { startVitest } = await import('vitest/node');
|
|
13
|
+
let completed;
|
|
14
|
+
const reporter = {
|
|
15
|
+
onTestModuleCollected(module) {
|
|
16
|
+
for (const test of module.children.allTests()) {
|
|
17
|
+
const retry = test.options.retry;
|
|
18
|
+
const count = typeof retry === 'number' ? retry : retry?.count ?? 0;
|
|
19
|
+
if (count > 0 || (test.options.repeats ?? 0) > 0) {
|
|
20
|
+
throw Error('Retries and repeats are unsupported');
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
onTestRunEnd(modules, errors, reason) {
|
|
25
|
+
const cases = [];
|
|
26
|
+
let trustworthy = errors.length === 0 && reason !== 'interrupted';
|
|
27
|
+
for (const module of modules) {
|
|
28
|
+
if (module.errors().length) trustworthy = false;
|
|
29
|
+
for (const suite of module.children.allSuites()) {
|
|
30
|
+
if (suite.errors().length) trustworthy = false;
|
|
31
|
+
}
|
|
32
|
+
for (const test of module.children.allTests()) {
|
|
33
|
+
const state = test.result().state;
|
|
34
|
+
if (!['passed', 'failed', 'skipped'].includes(state)) trustworthy = false;
|
|
35
|
+
cases.push({ file: module.relativeModuleId, name: test.fullName, state });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
completed = { trustworthy, cases };
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
const ctx = await startVitest([], {
|
|
42
|
+
root, config: false, watch: false, run: true,
|
|
43
|
+
include: files.length ? files : ['**/*.test.ts'], exclude: ['**/node_modules/**', '**/.git/**'],
|
|
44
|
+
reporters: [reporter], environment: 'node', pool: 'forks', maxWorkers: 1,
|
|
45
|
+
fileParallelism: false, isolate: true, retry: 0, allowOnly: false, maxConcurrency: 1,
|
|
46
|
+
passWithNoTests: false, testTimeout: 5000, hookTimeout: 5000,
|
|
47
|
+
sequence: { shuffle: false },
|
|
48
|
+
}, {
|
|
49
|
+
configFile: false, envDir: false, cacheDir: '/tmp/patchground-vite',
|
|
50
|
+
resolve: { alias: { vitest: '/opt/patchground/node_modules/vitest/dist/index.js' } },
|
|
51
|
+
});
|
|
52
|
+
await ctx.close();
|
|
53
|
+
if (!completed || ctx.state.getUnhandledErrors().length) return;
|
|
54
|
+
await writeFile(destination, JSON.stringify(completed), { flag: 'wx' });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function escape(value) {
|
|
58
|
+
return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>')
|
|
59
|
+
.replaceAll('"', '"').replaceAll("'", ''').replaceAll("\n", " ")
|
|
60
|
+
.replaceAll("\r", " ").replaceAll("\t", "	");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function parent() {
|
|
64
|
+
await rm(final, { force: true });
|
|
65
|
+
const temporary = await mkdtemp(path.join(tmpdir(), 'patchground-vitest-'));
|
|
66
|
+
try {
|
|
67
|
+
const receipt = path.join(temporary, 'receipt.json');
|
|
68
|
+
const args = process.argv.slice(2);
|
|
69
|
+
const files = args.length ? args.slice(1) : [];
|
|
70
|
+
if (args.length && (args[0] !== '--files' || !files.length)) return;
|
|
71
|
+
for (const file of files) {
|
|
72
|
+
const parts = file.split('/');
|
|
73
|
+
if (!file.endsWith('.test.ts') || parts.some(part =>
|
|
74
|
+
!/^[\p{L}\p{N}_. -]+$/u.test(part) || ['.', '..', '.git', 'node_modules'].includes(part))) return;
|
|
75
|
+
}
|
|
76
|
+
if (new Set(files).size !== files.length) return;
|
|
77
|
+
for (const file of files) {
|
|
78
|
+
const full = path.join(root, file);
|
|
79
|
+
if (!(await lstat(full)).isFile() || !(await realpath(full)).startsWith(root + '/')) return;
|
|
80
|
+
}
|
|
81
|
+
const code = await new Promise((resolve, reject) => {
|
|
82
|
+
const process = spawn(globalThis.process.execPath, [self, '--child', receipt, ...files], {
|
|
83
|
+
stdio: 'ignore', env: { ...globalThis.process.env, CI: 'true' },
|
|
84
|
+
});
|
|
85
|
+
process.once('error', reject);
|
|
86
|
+
process.once('exit', (code, signal) => resolve(signal ? null : code));
|
|
87
|
+
});
|
|
88
|
+
const result = JSON.parse(await readFile(receipt, 'utf8'));
|
|
89
|
+
if (!result.trustworthy || !result.cases.length) return;
|
|
90
|
+
const failed = result.cases.some(test => test.state === 'failed');
|
|
91
|
+
if (code !== (failed ? 1 : 0)) return;
|
|
92
|
+
const seen = new Set();
|
|
93
|
+
result.cases.sort((a, b) => {
|
|
94
|
+
const left = `${a.file}::${a.name}`;
|
|
95
|
+
const right = `${b.file}::${b.name}`;
|
|
96
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
97
|
+
});
|
|
98
|
+
const cases = result.cases.map(test => {
|
|
99
|
+
if (!test.file || path.posix.isAbsolute(test.file) || test.file.split('/').includes('..')
|
|
100
|
+
|| test.file.includes('\\') || !test.file.endsWith('.test.ts') || !test.name
|
|
101
|
+
|| !test.file.isWellFormed() || !test.name.isWellFormed()
|
|
102
|
+
|| /[\u0000-\u0008\u000b\u000c\u000e-\u001f\ufffe\uffff]/u.test(test.file + test.name)) throw Error('unsafe identity');
|
|
103
|
+
const id = `${test.file}::${test.name}`;
|
|
104
|
+
if (seen.has(id)) throw Error('duplicate identity');
|
|
105
|
+
seen.add(id);
|
|
106
|
+
const status = test.state === 'failed' ? '<failure message="test failed"/>'
|
|
107
|
+
: test.state === 'skipped' ? '<skipped/>' : '';
|
|
108
|
+
return `<testcase classname="${escape(test.file)}" name="${escape(test.name)}">${status}</testcase>`;
|
|
109
|
+
});
|
|
110
|
+
const staging = '/results/.patchground-junit.xml';
|
|
111
|
+
await writeFile(staging, `<?xml version="1.0" encoding="UTF-8"?><testsuites><testsuite name="vitest">${cases.join('')}</testsuite></testsuites>`);
|
|
112
|
+
await rename(staging, final);
|
|
113
|
+
process.exitCode = failed ? 1 : 0;
|
|
114
|
+
} finally {
|
|
115
|
+
await rm(temporary, { recursive: true, force: true });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
if (process.argv[2] === '--child') await child(process.argv[3], process.argv.slice(4));
|
|
121
|
+
else { process.exitCode = 1; await parent(); }
|
|
122
|
+
} catch {
|
|
123
|
+
process.exitCode = 1;
|
|
124
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Shared descriptor-safe local access and non-executing Git executable selection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import stat
|
|
7
|
+
from collections.abc import Iterator
|
|
8
|
+
from contextlib import ExitStack, contextmanager
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SafetyError(Exception):
|
|
13
|
+
"""A local resource cannot be accessed safely."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def no_links(
|
|
17
|
+
path: Path, *, error: type[Exception] = SafetyError, label: str = "filesystem"
|
|
18
|
+
) -> None:
|
|
19
|
+
"""Check ancestors even when the destination does not exist yet."""
|
|
20
|
+
for entry in (*reversed(path.absolute().parents), path.absolute()):
|
|
21
|
+
if entry.is_symlink():
|
|
22
|
+
raise error(f"{label} paths must not contain symlinks")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def safe_path(
|
|
26
|
+
path: Path, *, directory: bool, error: type[Exception] = SafetyError, label: str = "filesystem"
|
|
27
|
+
) -> None:
|
|
28
|
+
"""Reject redirected ancestors as well as redirected leaf entries."""
|
|
29
|
+
no_links(path, error=error, label=label)
|
|
30
|
+
mode = path.lstat().st_mode
|
|
31
|
+
if not (stat.S_ISDIR(mode) if directory else stat.S_ISREG(mode)):
|
|
32
|
+
raise error(f"{label} path has an invalid file type")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@contextmanager
|
|
36
|
+
def open_directory(
|
|
37
|
+
path: Path, *, error: type[Exception] = SafetyError, label: str = "filesystem"
|
|
38
|
+
) -> Iterator[int]:
|
|
39
|
+
"""Pin every ancestor, not just the leaf, without following any links."""
|
|
40
|
+
root = os.open(path.absolute().anchor, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
|
41
|
+
try:
|
|
42
|
+
with ExitStack() as ancestors:
|
|
43
|
+
descriptor = root
|
|
44
|
+
for part in path.absolute().parts[1:]:
|
|
45
|
+
descriptor = ancestors.enter_context(
|
|
46
|
+
open_entry(
|
|
47
|
+
descriptor, part, os.O_RDONLY | os.O_DIRECTORY, error=error, label=label
|
|
48
|
+
)
|
|
49
|
+
)
|
|
50
|
+
yield descriptor
|
|
51
|
+
except OSError as exc:
|
|
52
|
+
raise error(f"{label} directory contains a symlink or is unavailable") from exc
|
|
53
|
+
finally:
|
|
54
|
+
os.close(root)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@contextmanager
|
|
58
|
+
def open_entry(
|
|
59
|
+
parent: int,
|
|
60
|
+
name: str,
|
|
61
|
+
flags: int = os.O_RDONLY,
|
|
62
|
+
*,
|
|
63
|
+
error: type[Exception] = SafetyError,
|
|
64
|
+
label: str = "filesystem",
|
|
65
|
+
) -> Iterator[int]:
|
|
66
|
+
"""Validate the opened inode; NONBLOCK prevents a raced-in FIFO from hanging."""
|
|
67
|
+
before = os.stat(name, dir_fd=parent, follow_symlinks=False)
|
|
68
|
+
if not (stat.S_ISDIR(before.st_mode) or stat.S_ISREG(before.st_mode)):
|
|
69
|
+
raise error(f"{label} tree contains a symlink or special file")
|
|
70
|
+
try:
|
|
71
|
+
descriptor = os.open(name, flags | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=parent)
|
|
72
|
+
except OSError as exc:
|
|
73
|
+
raise error(f"{label} tree contains a symlink or special file or changed") from exc
|
|
74
|
+
try:
|
|
75
|
+
actual = os.fstat(descriptor)
|
|
76
|
+
if (before.st_dev, before.st_ino) != (actual.st_dev, actual.st_ino) or not (
|
|
77
|
+
stat.S_ISDIR(actual.st_mode) or stat.S_ISREG(actual.st_mode)
|
|
78
|
+
):
|
|
79
|
+
raise error(f"{label} tree changed during access")
|
|
80
|
+
yield descriptor
|
|
81
|
+
after = os.stat(name, dir_fd=parent, follow_symlinks=False)
|
|
82
|
+
if (actual.st_dev, actual.st_ino) != (after.st_dev, after.st_ino):
|
|
83
|
+
raise error(f"{label} tree changed during access")
|
|
84
|
+
finally:
|
|
85
|
+
os.close(descriptor)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def resolve_git(*inputs: Path, error: type[Exception] = SafetyError) -> str:
|
|
89
|
+
"""Trust only absolute PATH candidates outside cwd and all supplied input trees.
|
|
90
|
+
|
|
91
|
+
PATH is an installation search policy, not proof of executable provenance. Never
|
|
92
|
+
probe a candidate by running it. Canonicalize both the search path and target so
|
|
93
|
+
symlinks cannot disguise challenge input as an installed Git executable.
|
|
94
|
+
"""
|
|
95
|
+
try:
|
|
96
|
+
roots: list[Path] = []
|
|
97
|
+
for path in (Path.cwd(), *inputs):
|
|
98
|
+
roots.extend((path.absolute(), path.resolve(strict=False)))
|
|
99
|
+
for directory in os.environ.get("PATH", os.defpath).split(os.pathsep):
|
|
100
|
+
if not Path(directory).is_absolute():
|
|
101
|
+
continue
|
|
102
|
+
candidate = Path(directory) / "git"
|
|
103
|
+
if any(candidate.is_relative_to(root) for root in roots):
|
|
104
|
+
continue
|
|
105
|
+
try:
|
|
106
|
+
executable = candidate.resolve(strict=True)
|
|
107
|
+
if any(executable.is_relative_to(root) for root in roots):
|
|
108
|
+
continue
|
|
109
|
+
if stat.S_ISREG(executable.stat().st_mode) and os.access(executable, os.X_OK):
|
|
110
|
+
return str(executable)
|
|
111
|
+
except (OSError, RuntimeError):
|
|
112
|
+
continue
|
|
113
|
+
except (OSError, RuntimeError):
|
|
114
|
+
pass
|
|
115
|
+
raise error("safe Git executable unavailable")
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""patchground.sandbox — see SPEC.md in this folder.
|
|
2
|
+
|
|
3
|
+
The only module that talks to the container engine. Its API takes plain values (paths, strings,
|
|
4
|
+
limits) and returns plain results; it knows nothing about sessions, packs or reports.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from patchground.sandbox.build import BuildResult, build_image
|
|
8
|
+
from patchground.sandbox.errors import BuildFailed, EngineUnavailable, JUnitError, SandboxError
|
|
9
|
+
from patchground.sandbox.junit import TestResult, TestStatus, parse_junit
|
|
10
|
+
from patchground.sandbox.probe import EngineProbe, EngineProblem, probe_engine
|
|
11
|
+
from patchground.sandbox.run import (
|
|
12
|
+
CommandResult,
|
|
13
|
+
Limits,
|
|
14
|
+
RunOutcome,
|
|
15
|
+
RunResult,
|
|
16
|
+
run_check,
|
|
17
|
+
run_command,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"BuildFailed",
|
|
22
|
+
"BuildResult",
|
|
23
|
+
"CommandResult",
|
|
24
|
+
"EngineProbe",
|
|
25
|
+
"EngineProblem",
|
|
26
|
+
"EngineUnavailable",
|
|
27
|
+
"JUnitError",
|
|
28
|
+
"Limits",
|
|
29
|
+
"RunOutcome",
|
|
30
|
+
"RunResult",
|
|
31
|
+
"SandboxError",
|
|
32
|
+
"TestResult",
|
|
33
|
+
"TestStatus",
|
|
34
|
+
"build_image",
|
|
35
|
+
"parse_junit",
|
|
36
|
+
"probe_engine",
|
|
37
|
+
"run_check",
|
|
38
|
+
"run_command",
|
|
39
|
+
]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Connection to the container engine (Docker, or Podman via its Docker-compatible socket)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import docker
|
|
6
|
+
import docker.errors
|
|
7
|
+
import requests
|
|
8
|
+
|
|
9
|
+
from patchground.sandbox.errors import EngineUnavailable
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def connect() -> docker.DockerClient:
|
|
13
|
+
"""Return a client for the engine configured in the environment, or raise EngineUnavailable."""
|
|
14
|
+
try:
|
|
15
|
+
client = docker.from_env()
|
|
16
|
+
except docker.errors.DockerException as exc:
|
|
17
|
+
raise EngineUnavailable(f"container engine not reachable: {exc}") from exc
|
|
18
|
+
try:
|
|
19
|
+
client.ping()
|
|
20
|
+
except (docker.errors.DockerException, requests.exceptions.RequestException) as exc:
|
|
21
|
+
client.close()
|
|
22
|
+
raise EngineUnavailable(f"container engine not reachable: {exc}") from exc
|
|
23
|
+
return client
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Build phase: build a challenge image from its environment directory.
|
|
2
|
+
|
|
3
|
+
The build has network access (dependencies install here, never at run time). Consent for untrusted
|
|
4
|
+
sources is the caller's job and must happen before :func:`build_image` is called.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import math
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import stat
|
|
14
|
+
import time
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import docker.errors
|
|
20
|
+
import requests
|
|
21
|
+
import urllib3.exceptions
|
|
22
|
+
|
|
23
|
+
from patchground.sandbox._engine import connect
|
|
24
|
+
from patchground.sandbox.errors import BuildFailed, SandboxError
|
|
25
|
+
|
|
26
|
+
#: Limits applied to every intermediate build container.
|
|
27
|
+
BUILD_MEMORY_BYTES = 4 * 1024**3
|
|
28
|
+
|
|
29
|
+
#: Default wall-clock budget for one image build.
|
|
30
|
+
DEFAULT_BUILD_TIMEOUT_SECONDS = 1800.0
|
|
31
|
+
#: A connection error this close to the deadline counts as the deadline being hit.
|
|
32
|
+
TIMEOUT_TOLERANCE_SECONDS = 1.0
|
|
33
|
+
|
|
34
|
+
_NAME_RE = re.compile(r"[a-z0-9]+(?:[._-][a-z0-9]+)*")
|
|
35
|
+
_BUILT_RE = re.compile(r"^Successfully built ([0-9a-f]+)$")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class BuildResult:
|
|
40
|
+
"""A built image: ``image_id`` is the engine's opaque id, ``tag`` the cache tag."""
|
|
41
|
+
|
|
42
|
+
image_id: str
|
|
43
|
+
tag: str
|
|
44
|
+
log: str
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def context_hash(context: Path) -> str:
|
|
48
|
+
"""Return a deterministic sha256 hex digest of a build context directory.
|
|
49
|
+
|
|
50
|
+
Covers every entry's relative path, kind, permission bits and content (symlinks by their
|
|
51
|
+
target text), in sorted path order, so the same tree always yields the same hash.
|
|
52
|
+
"""
|
|
53
|
+
entries: list[tuple[str, Path]] = []
|
|
54
|
+
for dirpath, dirnames, filenames in os.walk(context):
|
|
55
|
+
dirnames.sort()
|
|
56
|
+
base = Path(dirpath)
|
|
57
|
+
for name in [*dirnames, *filenames]:
|
|
58
|
+
path = base / name
|
|
59
|
+
entries.append((path.relative_to(context).as_posix(), path))
|
|
60
|
+
digest = hashlib.sha256()
|
|
61
|
+
for rel, path in sorted(entries):
|
|
62
|
+
st = path.lstat()
|
|
63
|
+
mode = stat.S_IMODE(st.st_mode)
|
|
64
|
+
digest.update(rel.encode("utf-8", "surrogateescape") + b"\0")
|
|
65
|
+
if stat.S_ISLNK(st.st_mode):
|
|
66
|
+
digest.update(b"L\0" + str(path.readlink()).encode("utf-8", "surrogateescape"))
|
|
67
|
+
elif stat.S_ISDIR(st.st_mode):
|
|
68
|
+
digest.update(b"D\0" + f"{mode:o}".encode())
|
|
69
|
+
elif stat.S_ISREG(st.st_mode):
|
|
70
|
+
content = path.read_bytes()
|
|
71
|
+
digest.update(b"F\0" + f"{mode:o}\0{len(content)}\0".encode() + content)
|
|
72
|
+
else:
|
|
73
|
+
digest.update(b"O\0")
|
|
74
|
+
digest.update(b"\0")
|
|
75
|
+
return digest.hexdigest()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _log_text(chunks: Any) -> str:
|
|
79
|
+
parts: list[str] = []
|
|
80
|
+
for chunk in chunks:
|
|
81
|
+
if not isinstance(chunk, dict):
|
|
82
|
+
parts.append(f"{chunk}\n")
|
|
83
|
+
continue
|
|
84
|
+
for key in ("stream", "error"):
|
|
85
|
+
value = chunk.get(key)
|
|
86
|
+
if isinstance(value, str):
|
|
87
|
+
parts.append(value if key == "stream" else f"{value}\n")
|
|
88
|
+
return "".join(parts)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _note(log: str, note: str) -> str:
|
|
92
|
+
if log and not log.endswith("\n"):
|
|
93
|
+
log += "\n"
|
|
94
|
+
return f"{log}[patchground] {note}\n"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def build_image(
|
|
98
|
+
dockerfile: Path, *, name: str, timeout_seconds: float = DEFAULT_BUILD_TIMEOUT_SECONDS
|
|
99
|
+
) -> BuildResult:
|
|
100
|
+
"""Build ``dockerfile`` with its parent directory as the context.
|
|
101
|
+
|
|
102
|
+
The image is tagged ``patchground/<name>:<hash12>`` where ``hash12`` is the start of
|
|
103
|
+
:func:`context_hash` over the context directory.
|
|
104
|
+
|
|
105
|
+
Uses the Docker Engine API's classic (legacy, non-BuildKit) builder, which is what the
|
|
106
|
+
Docker SDK drives. No host mounts and no engine socket are exposed to the build; each build
|
|
107
|
+
container is capped at :data:`BUILD_MEMORY_BYTES` without swap.
|
|
108
|
+
|
|
109
|
+
``timeout_seconds`` is the build's wall-clock budget. It is checked every time the builder
|
|
110
|
+
emits output, and the HTTP read timeout of the build request is set to the same value, so a
|
|
111
|
+
silent step (e.g. ``RUN sleep``) aborts the build once it has been quiet that long; a build
|
|
112
|
+
that goes quiet just before the deadline can therefore overrun it by up to one more
|
|
113
|
+
``timeout_seconds``. Aborting closes the build connection, which makes the engine cancel the
|
|
114
|
+
build and remove its intermediate container.
|
|
115
|
+
|
|
116
|
+
Raises :class:`BuildFailed` (with the build log) if the build fails or exceeds
|
|
117
|
+
``timeout_seconds``, :class:`EngineUnavailable` if the engine cannot be reached,
|
|
118
|
+
:class:`SandboxError` for engine-side failures (5xx, lost connection), and
|
|
119
|
+
:class:`ValueError` for an invalid ``name``, path or timeout.
|
|
120
|
+
"""
|
|
121
|
+
if not _NAME_RE.fullmatch(name):
|
|
122
|
+
raise ValueError(
|
|
123
|
+
f"invalid image name {name!r}: use lowercase letters, digits, '.', '_', '-'"
|
|
124
|
+
)
|
|
125
|
+
if timeout_seconds <= 0:
|
|
126
|
+
raise ValueError(f"timeout_seconds must be positive: {timeout_seconds}")
|
|
127
|
+
if not dockerfile.is_file():
|
|
128
|
+
raise ValueError(f"Dockerfile not found: {dockerfile}")
|
|
129
|
+
context = dockerfile.parent.resolve()
|
|
130
|
+
tag = f"patchground/{name}:{context_hash(context)[:12]}"
|
|
131
|
+
exceeded = f"image build exceeded {timeout_seconds:g} s"
|
|
132
|
+
chunks: list[Any] = []
|
|
133
|
+
client = connect()
|
|
134
|
+
started = time.monotonic()
|
|
135
|
+
try:
|
|
136
|
+
stream = client.api.build(
|
|
137
|
+
path=str(context),
|
|
138
|
+
dockerfile=dockerfile.name,
|
|
139
|
+
tag=tag,
|
|
140
|
+
rm=True,
|
|
141
|
+
forcerm=True,
|
|
142
|
+
pull=False,
|
|
143
|
+
container_limits={"memory": BUILD_MEMORY_BYTES, "memswap": BUILD_MEMORY_BYTES},
|
|
144
|
+
labels={"patchground": "1"},
|
|
145
|
+
decode=True,
|
|
146
|
+
timeout=math.ceil(timeout_seconds),
|
|
147
|
+
)
|
|
148
|
+
image_id: str | None = None
|
|
149
|
+
for chunk in stream:
|
|
150
|
+
chunks.append(chunk)
|
|
151
|
+
if time.monotonic() - started > timeout_seconds:
|
|
152
|
+
raise BuildFailed(_note(_log_text(chunks), exceeded), message=exceeded)
|
|
153
|
+
if not isinstance(chunk, dict):
|
|
154
|
+
continue
|
|
155
|
+
if "error" in chunk:
|
|
156
|
+
raise BuildFailed(_log_text(chunks))
|
|
157
|
+
image_id = _image_id(chunk) or image_id
|
|
158
|
+
log = _log_text(chunks)
|
|
159
|
+
if image_id is None:
|
|
160
|
+
raise BuildFailed(_note(log, "the builder reported no image id"))
|
|
161
|
+
image = client.images.get(image_id)
|
|
162
|
+
except (
|
|
163
|
+
requests.exceptions.ReadTimeout,
|
|
164
|
+
requests.exceptions.ConnectionError,
|
|
165
|
+
urllib3.exceptions.ReadTimeoutError,
|
|
166
|
+
urllib3.exceptions.ProtocolError,
|
|
167
|
+
) as exc:
|
|
168
|
+
# A silent build step outlasting the read timeout surfaces as ReadTimeout/ConnectionError
|
|
169
|
+
# (docker/docker-py#1966) or, while the log streams, as urllib3's ReadTimeoutError.
|
|
170
|
+
# Well before the deadline it is a lost engine instead.
|
|
171
|
+
if time.monotonic() - started >= timeout_seconds - TIMEOUT_TOLERANCE_SECONDS:
|
|
172
|
+
raise BuildFailed(_note(_log_text(chunks), exceeded), message=exceeded) from exc
|
|
173
|
+
raise SandboxError(f"engine connection lost during build: {exc}") from exc
|
|
174
|
+
except docker.errors.APIError as exc:
|
|
175
|
+
status = exc.status_code
|
|
176
|
+
if status is not None and 400 <= status < 500:
|
|
177
|
+
raise BuildFailed(_note(_log_text(chunks), str(exc))) from exc
|
|
178
|
+
raise SandboxError(f"image build did not complete: {exc}") from exc
|
|
179
|
+
except (
|
|
180
|
+
docker.errors.DockerException,
|
|
181
|
+
requests.exceptions.RequestException,
|
|
182
|
+
urllib3.exceptions.HTTPError,
|
|
183
|
+
) as exc:
|
|
184
|
+
raise SandboxError(f"image build did not complete: {exc}") from exc
|
|
185
|
+
finally:
|
|
186
|
+
client.close()
|
|
187
|
+
if image.id is None:
|
|
188
|
+
raise SandboxError("engine returned an image without an id")
|
|
189
|
+
return BuildResult(image_id=image.id, tag=tag, log=log)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _image_id(chunk: dict[str, Any]) -> str | None:
|
|
193
|
+
"""The image id announced by a build chunk (``aux.ID`` or "Successfully built <id>")."""
|
|
194
|
+
aux = chunk.get("aux")
|
|
195
|
+
if isinstance(aux, dict) and isinstance(aux.get("ID"), str):
|
|
196
|
+
return str(aux["ID"])
|
|
197
|
+
stream = chunk.get("stream")
|
|
198
|
+
if isinstance(stream, str):
|
|
199
|
+
match = _BUILT_RE.search(stream.strip())
|
|
200
|
+
if match:
|
|
201
|
+
return match.group(1)
|
|
202
|
+
return None
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Exceptions raised by the sandbox module."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SandboxError(Exception):
|
|
7
|
+
"""Base class for every sandbox failure that is not a test outcome."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class EngineUnavailable(SandboxError):
|
|
11
|
+
"""The container engine cannot be reached (not running, no socket, no permission)."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class BuildFailed(SandboxError):
|
|
15
|
+
"""The image build failed; ``log`` holds the builder output up to the failure."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, log: str, *, message: str = "image build failed") -> None:
|
|
18
|
+
super().__init__(message)
|
|
19
|
+
self.log = log
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class JUnitError(SandboxError):
|
|
23
|
+
"""A JUnit XML document could not be parsed (malformed, unsafe or wrong root)."""
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""JUnit XML parsing into per-test results.
|
|
2
|
+
|
|
3
|
+
The XML comes from inside a challenge container and is therefore untrusted: it is always parsed
|
|
4
|
+
with ``defusedxml`` (DTDs forbidden outright, which also rules out entity-expansion and XXE
|
|
5
|
+
payloads) and only then handed to ``junitparser`` for the JUnit structure.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import io
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from enum import StrEnum
|
|
13
|
+
from typing import IO
|
|
14
|
+
from xml.etree.ElementTree import ElementTree, ParseError
|
|
15
|
+
|
|
16
|
+
import defusedxml
|
|
17
|
+
import defusedxml.ElementTree
|
|
18
|
+
from junitparser import Error, Failure, JUnitXml, JUnitXmlError, Skipped
|
|
19
|
+
|
|
20
|
+
from patchground.sandbox.errors import JUnitError
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TestStatus(StrEnum):
|
|
24
|
+
"""Outcome of a single test case."""
|
|
25
|
+
|
|
26
|
+
__test__ = False # not a pytest test class
|
|
27
|
+
|
|
28
|
+
PASSED = "passed"
|
|
29
|
+
FAILED = "failed"
|
|
30
|
+
ERRORED = "errored"
|
|
31
|
+
SKIPPED = "skipped"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class TestResult:
|
|
36
|
+
"""One ``<testcase>``; ``id`` is ``classname::name`` (or ``name`` without a classname)."""
|
|
37
|
+
|
|
38
|
+
__test__ = False # not a pytest test class
|
|
39
|
+
|
|
40
|
+
id: str
|
|
41
|
+
name: str
|
|
42
|
+
classname: str
|
|
43
|
+
status: TestStatus
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _safe_parse(source: IO[bytes]) -> ElementTree:
|
|
47
|
+
return defusedxml.ElementTree.parse(source, forbid_dtd=True)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def parse_junit(data: bytes) -> tuple[TestResult, ...]:
|
|
51
|
+
"""Parse a JUnit XML document (``<testsuites>`` or ``<testsuite>`` root).
|
|
52
|
+
|
|
53
|
+
Status precedence inside one test case: error > failure > skipped > passed.
|
|
54
|
+
Raises :class:`JUnitError` if the document is malformed, unsafe or not JUnit.
|
|
55
|
+
"""
|
|
56
|
+
try:
|
|
57
|
+
xml = JUnitXml.fromfile(io.BytesIO(data), parse_func=_safe_parse)
|
|
58
|
+
results: list[TestResult] = []
|
|
59
|
+
for suite in xml:
|
|
60
|
+
for case in suite:
|
|
61
|
+
name = str(case.name or "")
|
|
62
|
+
classname = str(case.classname or "")
|
|
63
|
+
outcomes = case.result
|
|
64
|
+
if any(isinstance(r, Error) for r in outcomes):
|
|
65
|
+
status = TestStatus.ERRORED
|
|
66
|
+
elif any(isinstance(r, Failure) for r in outcomes):
|
|
67
|
+
status = TestStatus.FAILED
|
|
68
|
+
elif any(isinstance(r, Skipped) for r in outcomes):
|
|
69
|
+
status = TestStatus.SKIPPED
|
|
70
|
+
else:
|
|
71
|
+
status = TestStatus.PASSED
|
|
72
|
+
test_id = f"{classname}::{name}" if classname else name
|
|
73
|
+
results.append(TestResult(test_id, name, classname, status))
|
|
74
|
+
except (ParseError, defusedxml.DefusedXmlException, JUnitXmlError) as exc:
|
|
75
|
+
raise JUnitError(f"invalid JUnit XML: {exc}") from exc
|
|
76
|
+
return tuple(results)
|