-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathpyproject.toml
More file actions
596 lines (573 loc) · 32.3 KB
/
Copy pathpyproject.toml
File metadata and controls
596 lines (573 loc) · 32.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
[build-system]
requires = ["maturin>=1.14.1,<2.0"]
build-backend = "maturin"
# Distribution (PyPI) name is `processkit-py` — the bare `processkit` is already
# taken on PyPI. The import name stays `processkit` (the package dir + the
# module-name below): `pip install processkit-py` -> `import processkit`.
[project]
name = "processkit-py"
version = "1.5.0"
description = "Python bindings to the processkit Rust crate — asyncio-native, no-orphan process containment"
readme = "README.md"
# abi3 wheels (cp310+): Python 3.9 reached EOL October 2025; dev tools (mypy>=2.1) require 3.10+.
# Local dev is pinned to 3.12 via .python-version.
requires-python = ">=3.10"
license = "MIT"
license-files = ["LICENSE"]
authors = [{ name = "Anton Zhelezniakou", email = "github@zelanton.net" }]
keywords = ["process", "subprocess", "asyncio", "containment", "pyo3"]
# No "License ::" classifier — the SPDX `license` field above is the PEP 639 form.
classifiers = [
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Programming Language :: Python :: Implementation :: CPython",
"Programming Language :: Rust",
"Operating System :: POSIX :: Linux",
"Operating System :: MacOS",
"Operating System :: Microsoft :: Windows",
"Framework :: AsyncIO",
"Topic :: System :: Systems Administration",
"Topic :: Software Development :: Libraries :: Python Modules",
"Typing :: Typed",
]
dependencies = []
# Console-script entry point: after `pip install processkit-py`, the short `processkit`
# command is on PATH (e.g. `processkit run -- pytest -x`, `processkit doctor`), alongside
# the always-available `python -m processkit` form (useful with multiple interpreters).
# Points at the same `main_and_exit` that `src/processkit/__main__.py` already delegates
# to, so both invocation forms share one exit-code contract (see KB K-027/K-049 — no new
# codes, no shifted ranges).
[project.scripts]
processkit = "processkit._cli:main_and_exit"
# Autoload the pytest plugin (testing-seam fixtures + the `no_real_spawn` guard)
# in any pytest session where processkit is installed. The plugin module is pure
# Python and import-safe; pytest is not a runtime dependency (the entry point is
# only consulted by pytest, which is present exactly when the plugin is loaded).
[project.entry-points.pytest11]
processkit = "processkit.pytest_plugin"
[project.urls]
Homepage = "https://zelanton.github.io/processkit/"
Repository = "https://github.com/ZelAnton/processkit-py"
Documentation = "https://zelanton.github.io/processkit-py/"
Changelog = "https://github.com/ZelAnton/processkit-py/blob/main/CHANGELOG.md"
Issues = "https://github.com/ZelAnton/processkit-py/issues"
[dependency-groups]
dev = [
"maturin>=1.14.1,<2.0",
"pytest>=9.1.1",
"mypy>=2.1.0",
# Second typechecker gate (see `[tool.pyright]` below and the `pyright`
# job in ci.yml), alongside mypy: pyright is what most consumers actually
# see (VS Code/Pylance), and it disagrees with mypy often enough on
# overloads/Literal/protocols/Awaitable patterns that a green mypy alone
# doesn't guarantee a clean IDE experience. Pinned here (floor-pin, like
# the other dev-group tools above) rather than via `uvx` in the CI step,
# so the exact version is locked in `uv.lock` and reproducible offline —
# matching how mypy itself is pinned, not the `uvx 'yamllint>=1,<2' .`
# style used by the standalone `yaml-lint` job.
"pyright>=1.1.411",
"ruff>=0.15.20",
"pre-commit>=4.0.0",
"pytest-timeout>=2.4.0",
"pytest-xdist>=3.8.0",
"pytest-cov>=7.0.0",
"hypothesis>=6.156.1",
# `griffe` (distribution `griffelib`) statically analyses the type stub
# (`_processkit.pyi`) and the pure-Python shims for scripts/gen_api_reference.py,
# which renders docs/api-reference.md as static Markdown. Needed here (not in a
# docs-only group) because the drift guard tests/test_api_reference.py runs in
# the ordinary `uv run pytest` gate. The mdBook docs build itself uses no Python.
"griffelib>=2,<3",
# Mutation testing for the pure-Python binding layer (nightly-hardening.yml's
# `mutmut` job only; not run in the ordinary PR-gate `test` job). Requires a
# POSIX host (`os.fork()`-based worker model) -- no native Windows support, see
# `[tool.mutmut]` below for the exact scope.
"mutmut>=3.6.0,<4",
]
# The `benchmarks/` suite (overhead vs `subprocess`/`asyncio.subprocess`,
# `ProcessGroup` start/exit, line-streaming throughput, `output_all` at
# varying concurrency) — split out of `dev` so it never installs (or runs) in
# the PR gate; only the scheduled `nightly-hardening.yml` job installs this
# group. See `benchmarks/README.md` for how to run it locally.
bench = [
"pytest-benchmark>=5,<6",
]
# Event-loop compatibility matrix for `tests/test_event_loops.py` — turns
# docs/event-loops.md's "uvloop and anyio-on-asyncio are fully supported"
# claim into a checked contract. Split out of `dev` so the ordinary PR-gate
# `test` job (and every plain `uv run pytest`) never needs these installed;
# only the dedicated `event-loops` CI job (`--group event-loops`) does.
# uvloop ships no Windows wheels (and has none planned), so it is marked
# Windows-incompatible here via an environment marker — `uv sync --group
# event-loops` on Windows installs anyio only, and the uvloop-parametrized
# tests skip themselves (see tests/test_event_loops.py).
event-loops = [
"uvloop>=0.21.0,<1; sys_platform != 'win32'",
"anyio>=4.0,<5",
]
[tool.maturin]
# Build abi3 wheels (cp310+) to keep the wheel matrix flat across Python versions.
# module-name places the compiled extension at src/processkit/_processkit.pyd — inside
# the Python package so __init__.py can import from `._processkit`.
# bindings = "pyo3" is explicit (maturin auto-detects, but explicit avoids sdist ambiguity).
# features activates the Cargo `extension-module` feature defined in Cargo.toml [features].
bindings = "pyo3"
module-name = "processkit._processkit"
python-source = "src"
features = ["extension-module"]
# Keep the sdist lean and on-topic: drop the 3 MB README cover art (referenced by
# an absolute URL, never needed to build) and the CI/dev-harness metadata — both
# now live under `.github/`, so the one glob below excludes them (previously two
# separate root-level entries, "cover.png" and "release-token-bypass.md"). src/,
# tests/, docs/, and the standard manifests still ship so a source build (and
# downstream testing) works.
exclude = [
".github/**/*",
"docker/**/*",
"compose.yaml",
".dockerignore",
# Internal planning / session-handoff docs and agent-instruction files. They
# live only in a local commit and never reach `main`, but exclude them here
# too so they can never slip into a source dist from any build tree
# (belt-and-braces, like `.github/**/*`) — verified they otherwise DO leak
# (built an sdist from a tree where these happened to be tracked; every one
# of them showed up in the tarball before this list covered them).
"progress.md",
"*-plan.md",
".work/**/*",
"AGENTS.md",
"CLAUDE.md",
"CLAUDE.local.md",
".claude/**/*",
]
[tool.ruff]
line-length = 100
src = ["src", "tests", "scripts", "examples"]
[tool.ruff.lint]
select = ["E", "W", "F", "I", "B", "UP", "SIM", "RUF"]
[tool.mypy]
python_version = "3.12"
strict = true
warn_unreachable = true
files = ["src", "tests", "scripts", "examples"]
mypy_path = "src"
explicit_package_bases = true
namespace_packages = true
# Second typechecker gate (see the `pyright` job in ci.yml and the dev-group
# comment above): pyright disagrees with mypy often enough on
# overloads/Literal/protocols/Awaitable patterns that a green mypy alone
# doesn't guarantee a clean experience for the majority of consumers who
# actually see the API through pyright (VS Code/Pylance). Scoped to the
# *public typing surface* rather than mypy's broader `files` above — the
# compiled-extension stub, the rest of the `processkit` package (which
# defines/re-exports the same public names), the type-pin file, and the
# examples — not the wider internal test suite / release scripts mypy also
# covers, which are implementation detail rather than surface a consumer's
# own pyright run would ever type-check against.
[tool.pyright]
include = ["src/processkit", "tests/_typing_pins.py", "examples"]
pythonVersion = "3.12"
# The public surface has cleared both mypy strict and the independent pyright
# gate long enough to enforce pyright's full strict diagnostics too, including
# unknown values leaking through overloads, protocols, and example code.
typeCheckingMode = "strict"
# These two diagnostics are naming/reachability checks rather than type-safety
# checks. Private helpers intentionally cross module boundaries inside the
# private `_cli` package, while pytest fixtures/hooks, CLI entry functions, and
# declarative typing pins are discovered dynamically instead of called in the
# module that defines them. Ruff still guards ordinary unused imports.
reportPrivateUsage = "none"
reportUnusedFunction = "none"
[tool.pytest.ini_options]
# Four file-grouped pytest-xdist workers keep the suite parallel without turning
# high-core hosts into process storms. Most tests spawn their own child
# interpreters, so `-n auto` can multiply a host's logical CPU count into enough
# concurrent processes to starve workers, trip wall-clock timeouts, and leave
# crashed-worker descendants holding the extension module on Windows.
#
# `-m "not leak"` structurally excludes tests/test_memory_leaks.py (marked
# `leak` below) from this default selection, instead of relying on
# `--ignore=tests/test_memory_leaks.py` scattered across individual call
# sites (that approach missed several lanes — see the T-221 review). The leak
# module also needs `-p no:xdist` (RSS is only meaningful measured serially in
# one interpreter, not split across xdist workers) — callers that actually
# want it (nightly-hardening.yml's `memory-leaks` job, `just leak-test`)
# select the file explicitly and override `addopts` wholesale (dropping both
# `-n 4 --dist loadfile` and the `-m` filter) rather than trying to subtract
# just those pieces from this string.
addopts = "-ra --strict-markers --strict-config --import-mode=importlib -n 4 --dist loadfile -m \"not leak\""
markers = [
"leak: serial memory/reference-stability tests (tests/test_memory_leaks.py) — nightly-hardening only, never part of the default selection; run explicitly via `just leak-test` or `-m leak -p no:xdist`.",
]
filterwarnings = [
"error",
# pytest-benchmark (the `bench` dependency-group, autoloaded as a pytest11
# plugin whenever installed — not scoped to `benchmarks/`) warns once at
# configure time that it is disabling itself under xdist, which is exactly
# this suite's default (see the addopts comment above). Harmless
# here — nothing in `tests/` uses the `benchmark` fixture — but without this
# one narrow ignore, a dev environment with `bench` installed alongside
# `dev` would turn that benign self-disabling notice into a hard
# INTERNALERROR on every ordinary `pytest` run, via the blanket
# "error"-on-any-warning rule below. Matched by message text (not by
# `category:module` — e.g. `pytest_benchmark.logger.PytestBenchmarkWarning`)
# so pytest never has to `importlib.import_module("pytest_benchmark.*")` to
# resolve the filter: in a `dev`-only environment (no `bench` group, e.g.
# the ordinary PR-gate `uv run pytest`) that import fails, and pytest turns
# the resulting `PytestConfigWarning: Failed to import filter module ...`
# into the exact `INTERNALERROR` this filter exists to prevent.
"ignore:Benchmarks are automatically disabled because xdist plugin is active",
]
testpaths = ["tests"]
# The repo root on sys.path so tests can `import scripts.release.*` (the
# release.yml helper scripts) as an ordinary absolute import, alongside the
# installed `processkit` package.
pythonpath = ["."]
# Global hang guard (pytest-timeout): a sync-path regression (e.g. a
# consuming verb that blocks forever instead of raising) would otherwise hang
# CI to the job-level timeout instead of failing the one test that broke.
# 60s is generous against the slowest real test (well under 2s today) while
# still bounding a genuine hang. `thread` (the default method) works
# cross-platform, unlike `signal` (POSIX-only, and this suite runs on Windows).
timeout = 60
timeout_method = "thread"
[tool.coverage.run]
# Coverage is deliberately scoped to the *pure-Python* surface only: the
# hand-written `src/processkit/*.py` modules (the public API shim, the async
# helpers, the pytest plugin, the testing doubles) plus the release tooling in
# `scripts/release/`. The compiled `_processkit` extension (the PyO3/Rust
# binding) is native code — coverage.py has no way to instrument it, and
# couldn't even if asked to: it is exercised (indirectly, through every test
# that calls into `Command`/`ProcessGroup`/etc.) but its *own* line/branch
# coverage is tracked by the `processkit` Rust crate's own test suite, not
# here. Nothing needs to be listed in `omit` for it (there is no `.py` file to
# match — `_processkit.pyi` is a stub, never imported, so coverage.py never
# sees it) but this comment exists so the percentage below is never read as
# "how well the Rust core is tested".
source = ["src/processkit", "scripts/release"]
# Carved back out of that scope: the process-driving CLI modules and the
# `python -m processkit` entry point that calls into them. Unlike the compiled
# extension above, coverage.py does not see these modules only because of the
# current test wiring -- the CLI is in fact exercised end to end
# by `tests/test_cli_main.py` (the exit-code contract of `run`/`supervise`/
# `doctor`, the `--idle-timeout` streaming path, the final-flush/`SystemExit`
# path), but every one of those tests drives it in a **child process**
# (`subprocess.run([PY, "-m", "processkit", ...])`, or a `runpy.run_module`
# probe), and nothing in the test session ever imports `_cli` in-process.
# Subprocess coverage (`COVERAGE_PROCESS_START` + coverage's .pth startup
# hook) is now technically possible: the premise recorded in K-062 changed
# when `main_and_exit` stopped using `os._exit` and began terminating via
# `raise SystemExit(...)`, allowing normal interpreter finalization. That
# subprocess-coverage wiring has not been implemented, however, and its setup
# and maintenance cost has not yet been evaluated.
#
# With the current wiring these files are therefore reported at 0.00%, however
# well they are tested -- 440 statements and 140 branches of information-free
# zero. Left inside `source` they made the percentage below a function of *how
# much CLI code exists* rather than of how well the code is tested, and they
# dragged the whole gate down on every CLI change: integration review of batch
# B-20260725T170739Z caught the threshold being eaten from the inside, the
# 65 statements T-161 added taking the total from 46.08% to 43.56% against a
# `fail_under` of 43, with no test having been lost. Excluding them makes the
# number both honest and stable, and (having shed ~40% of the denominator to
# constant zero) sharper: a real regression in the measured modules now moves
# it ~1.6x further.
#
# Pure helpers in `parser.py`, `common.py`, `output.py`, `exit_codes.py`, and
# `doctor.py` now have direct in-process tests and deliberately remain measured.
# Only the entry/driver modules whose behavior is still exclusively exercised
# through subprocesses remain omitted.
omit = [
"src/processkit/_cli/__init__.py",
"src/processkit/_cli/run.py",
"src/processkit/_cli/supervise.py",
"src/processkit/__main__.py",
]
branch = true
# One data file per pytest-xdist worker (`.coverage.<host>.<pid>.<rand>`),
# combined by pytest-cov at session end — without this, concurrent workers
# racing to write the same `.coverage` file would silently under-report
# (last writer wins instead of a proper union of all workers' lines).
parallel = true
[tool.coverage.report]
show_missing = true
skip_covered = false
# The measured scope (`source` minus `omit`, minus the exclusions below) is
# 1,005 statements and 286 branches = 1,291 units, re-measured 2026-08-03 with
# coverage's own parser over this configuration -- the same method that produced
# the 1,254 quoted further down for the previous scope, so the two are directly
# comparable. The last full-suite *percentage* -- 74.96–75.12% across
# two Windows dev-box runs, 2026-07-29, four file-grouped xdist workers -- was
# taken on the 1,254-unit scope that preceded batch B-20260802T121700Z and has
# not been re-measured since, so treat it as the last known reading rather than
# the current one. The enforcing Ubuntu leg is expected to sit higher because it
# runs the POSIX-only tests Windows skips, but it has never had a baseline of
# its own: nothing but a `--cov` run on that leg establishes one, and the
# verification profile used for merges does not include `--cov`.
#
# `fail_under` sits three points below that last Windows reading. One point is
# ~13 units at this scope, so the margin is ~39 units of slack -- enough for
# platform/measurement noise while still catching a real loss in `_aio.py`, the
# directly-tested CLI helpers, `pytest_plugin.py`, `scripts/release/`, or the
# package `__init__.py`. Process-driving CLI modules remain outside the
# denominator because subprocess coverage is not currently wired as documented
# above, so their growth cannot consume this margin without producing
# measurable in-process coverage.
#
# A margin that thin gets eaten from the inside by a batch that only *adds*
# code, which has now happened twice. B-20260725T170739Z is the first case (see
# the `omit` rationale above). In B-20260802T121700Z the scope grew 1,254 ->
# 1,318 units (+64, +5%), and 43 of those 64 would have entered the denominator
# with nothing in the numerator on the enforcing leg: 25 for a Windows-only
# named-pipe block Linux never executes, 13 for `flush()` failure handlers no
# test reached, 5 for the tail of `wait_for_named_pipe` past its `Unsupported`
# guard. That is why the scope reads 1,291 and not 1,318: the platform block
# leaves the denominator at its `if` (27 units with the guard line and its
# never-completable branch; see the rationale beside it in `_aio.py`) and the
# flush handlers are now covered by name in tests/test_cli_units.py. The
# `wait_for_named_pipe` tail stays measured on purpose -- `wait_for_unix_socket`
# has exactly as dead a tail on Windows and is also measured, and excluding one
# of a symmetric pair only relocates the skew.
fail_under = 72
exclude_lines = [
# This one marker also retires a platform-exclusive branch: an
# `if sys.platform == "win32": # pragma: no cover ...` clause (as in
# `_aio.py`) drops that clause and its body from the scope while the `else`
# fallback stays measured, so neither leg carries the other's dead code. No
# separate platform regex -- extend the pragma, not this list.
"pragma: no cover",
"if TYPE_CHECKING:",
"raise NotImplementedError",
]
[tool.coverage.html]
directory = "htmlcov"
[tool.mutmut]
# Scoped deliberately narrow: the compiled `_processkit` extension is native code
# mutmut cannot instrument (the sibling `processkit` Rust crate runs cargo-mutants
# over that logic in its own repository), and `*.pyi`/tests are not mutation
# targets. `source_paths` must list whole
# directories (mutmut copies the tree verbatim to build the sandboxed `mutants/`
# tree the mutated modules import their siblings from -- e.g.
# `pytest_plugin.py`'s `from .testing import ...` and every module's
# `from ._processkit import ...` need the rest of `src/processkit/` present,
# just unmutated) -- `only_mutate` is the actual scope filter: everything under
# `source_paths` is copied unmodified, but only paths matching one of these glob
# patterns is actually mutated. This is the tightest scope mutmut's config model
# supports for a subset of a package: deadlines/cancellation/races in `_aio.py`,
# exit-code mapping in the `_cli/` package (argument parsing, the `run`/`doctor`
# subcommands, and the shared exit-code constants -- see `only_mutate` below;
# `__main__.py` itself is now just a two-line `sys.exit(main())` shim delegating
# into `_cli`), and the release helper scripts.
# (`pytest_plugin.py` would be a prime target too -- its cassette-mode/naming
# logic -- but is deliberately left OUT of `only_mutate`; see the note inside that
# list below for why mutmut's mutation trampoline is structurally incompatible
# with the pytester-driven inner sessions in `tests/test_pytest_plugin.py`.)
# `scripts` (the whole directory,
# not just `scripts/release`) is likewise a `source_paths` entry rather than an
# `also_copy` one: `tests/test_api_reference.py` does `from scripts import
# gen_api_reference` and `tests/test_ci_privileged_guard.py` loads
# `scripts/ci-privileged-guard.py` by path, so both siblings of `release/` need to
# exist, unmutated, in the sandbox tree for those tests to collect -- `only_mutate`
# below still confines actual mutation to `scripts/release/*.py`.
source_paths = ["src", "scripts"]
only_mutate = [
"src/processkit/_aio.py",
# `src/processkit/pytest_plugin.py` is deliberately NOT mutated, despite its
# cassette-naming / record-mode logic being an ideal mutation target. The
# module is autoloaded (the `pytest11` entry point) into *every* pytest
# session, including the *inner* sessions `tests/test_pytest_plugin.py` spins
# up via the `pytester` fixture. mutmut wraps each mutated function in a
# "trampoline" that, during its stats-collection pass (`MUTANT_UNDER_TEST=
# stats`), calls `record_trampoline_hit`, which eagerly evaluates
# `[p.resolve(strict=True) for p in Config.source_paths]` on *every* hit.
# `source_paths` are stored relative ("src"/"scripts") and `Path.resolve` is
# cwd-relative -- but `pytester` chdirs each inner session into its own tmp
# dir, where no `src/` exists, so the mutated plugin's hooks/fixtures firing
# inside an inner session raise `FileNotFoundError: 'src'`. That aborts the
# whole stats pass (it surfaces as pytester's "terminal summary report not
# found"), so mutating this one file breaks the entire mutmut run. This is
# structural, not a config bug: mutmut resolves `source_paths` lazily against
# cwd while pytester owns the inner cwd, and mutmut's scope filter is file- not
# function-granular, so the whole module must be excluded. Only the *mutation*
# coverage is given up -- the pure helpers (`_cassette_name`, `_is_record_mode`,
# `_cassette_dir`) stay directly unit-tested in `tests/test_pytest_plugin.py`.
# `src/processkit/_cli/` is the actual home of the exit-code mapping this
# section's intro paragraph describes (`run.py`'s exception -> exit-code
# translation, `doctor.py`'s independent-probe verdict codes, `parser.py`'s
# `--` splitting/argparse wiring, and the shared constants in
# `exit_codes.py`) -- it moved here from `__main__.py` when that logic was
# split into its own package. It is an ordinary importable package (no
# `pytest11` entry point, nothing pytester-driven touches it -- unlike
# `pytest_plugin.py` above), so none of that trampoline/cwd hazard applies.
"src/processkit/_cli/*.py",
# `__main__.py` itself is kept in scope too even though it is now just a
# two-line `sys.exit(main())` shim with almost nothing left to mutate
# meaningfully: leaving it listed costs nothing (mutmut will simply surface
# few or no surviving mutants there) and avoids a config gap if logic ever
# moves back into it.
"src/processkit/__main__.py",
"scripts/release/*.py",
]
# mutmut always copies `tests/`, `setup.cfg`, and `pyproject.toml` into the
# sandbox alongside this list (see mutmut's own `_load_config`), so those don't
# need to be repeated here. What's listed below covers what the *whole* test
# suite additionally reaches for by a repo-root-relative path -- the full suite,
# not just tests for the mutated modules, has to collect and pass inside the
# sandbox: mutmut's stats-collection pass is one `pytest` invocation over
# `tests/` that determines which tests exercise which mutated function, so an
# unrelated test failing (a collection error, a missing fixture file) fails that
# whole pass, not just the tests it belongs to.
# - `conftest.py` (repo root, not `tests/`): registers
# `pytest_plugins = ["pytester"]`, needed by `tests/test_pytest_plugin.py`
# (pytester-driven inner pytest sessions).
# - `README.md`, `docs/`: `tests/_docs_snippets.py`'s `ROOT` (repo root, one
# level up from `tests/` inside the sandbox) points at both, read by
# `tests/test_docs_snippets.py`; `tests/test_api_reference.py` separately
# reads `docs/api-reference.md`.
# - `examples/`: `tests/test_examples.py` runs every `examples/*.py` script in
# a child interpreter.
# - `CHANGELOG.md`: `tests/test_changelog_page.py` verifies the committed
# release-notes page against the root changelog.
# - `book.toml`: `tests/test_llms_txt.py` derives the generated LLM index
# metadata from the mdBook configuration.
# - `cliff.toml`: `tests/test_release_scripts.py` parses its `commit_parsers`
# table.
also_copy = ["conftest.py", "README.md", "CHANGELOG.md", "book.toml", "docs", "examples", "cliff.toml"]
# The repo-wide pytest-xdist default (see the comment on `addopts` in
# `[tool.pytest.ini_options]` above) breaks mutmut's in-process stats
# collection: mutmut's `StatsCollector` pytest plugin records which test hit
# which mutated function via hooks that run in the controller process, but
# under xdist the actual test bodies execute in worker *sub*processes, so
# those hook calls never happen in-process and no test ends up associated with
# any mutant (mutmut then aborts stats collection outright, before running a
# single mutant). `-p no:xdist` alone is not enough -- once the plugin that
# registers `-n`/`--dist` is disabled, the xdist options still present via the
# ini file's `addopts` (which mutmut's sandboxed pytest run reads same as any
# other run) become unrecognized and pytest itself fails to start; `-o
# addopts=...` fully overrides (not appends to) the ini value, so the
# replacement below must keep every other `addopts` flag verbatim (i.e. stay in
# sync with `[tool.pytest.ini_options] addopts` above, minus `-n 4 --dist
# loadfile`) rather than only dropping `-n 4` from it. That includes `-m "not
# leak"`: without it this baseline/stats pass would also collect
# tests/test_memory_leaks.py,
# whose thresholds are tuned for one serial single-run measurement, not
# mutmut's stats-collection pass -- a spurious failure there would abort the
# whole mutation run (see the `leak` marker registered in
# `[tool.pytest.ini_options]` above).
pytest_add_cli_args = [
"-p",
"no:xdist",
"-o",
"addopts=-ra --strict-markers --strict-config --import-mode=importlib -m \"not leak\"",
]
[tool.cibuildwheel]
# Release wheels install the exact maturin from scripts/release/toolchain.env
# into each fresh build environment, then disable a second PEP 517 resolution.
build-frontend = { name = "build", args = ["--no-isolation"] }
before-build = "python {package}/scripts/release/toolchain.py install-maturin"
# Two wheel families per OS/arch:
# * cp310-* → one abi3 wheel that runs on every GIL CPython 3.10+ (incl. 3.14),
# so the GIL matrix stays a single wheel instead of one per version.
# * cp314t-* → free-threaded CPython (PEP 703), officially supported as of 3.14.
# The limited API (abi3) is unavailable on free-threaded builds, so
# maturin emits a version-specific wheel. (cibuildwheel ships only
# the officially-supported free-threaded interpreter, 3.14t — there
# is no cp313t build; 3.13's free-threaded build was experimental.)
build = "cp310-* cp314t-*"
# Build both glibc (manylinux) and musl (musllinux) Linux wheels — Alpine/musl is
# common in containers. 32-bit musl has no Rust/Cargo, and 32-bit Windows is not a
# target. Each matrix runner builds its own native arch. The stock pypa
# musllinux_1_2 image already ships cc + musl-dev, and maturin auto-sets
# `-C target-feature=-crt-static` for the musl cdylib, so no extra build setup.
skip = "*-musllinux_i686 *-win32"
# Smoke-test the actual built wheel on each platform (see scripts/smoke.py).
test-command = "python {project}/scripts/smoke.py"
[tool.cibuildwheel.macos]
# Cross-compile Intel (x86_64) alongside the runner's native arm64: GitHub
# retired the free `macos-13` Intel runner, but Rust cross-compiles
# darwin-x86_64 from an arm64 host trivially (the `_build-dists.yml` workflow
# adds the `x86_64-apple-darwin` rustup target before this runs).
#
# Known, accepted gap: `test-command` (scripts/smoke.py) needs Rosetta 2 to
# execute the x86_64 interpreter on the arm64 (macos-14) runner, and this repo
# does not install Rosetta before the build. There is no free GitHub-hosted
# Intel macOS runner left to run a native x86_64 smoke test either. So the
# x86_64 wheel is built and shipped without a post-build import/exec smoke
# check — it relies on the arm64 build's smoke test (same source, same
# `[extension-module]` build) plus the CodeQL/pip-audit/rust-audit gates for
# confidence. If a free arm64 GitHub runner with Rosetta preinstalled becomes
# available, or a native Intel macOS runner returns, revisit this.
archs = ["arm64", "x86_64"]
[[tool.cibuildwheel.overrides]]
# Without this, maturin tags the x86_64 wheel `macosx_10_9` (the old default),
# but current Rust toolchains link `x86_64-apple-darwin` binaries with an
# embedded minimum target of 10.12 — delocate then refuses to repair the
# wheel ("Library dependencies do not satisfy target MacOS version 10.9").
# Pinning the env var makes maturin tag the wheel to match what the linker
# actually produced. Scoped to x86_64 only: the arm64 build's own minimum
# (11.0, since Apple Silicon shipped no earlier macOS) already matches its
# wheel tag without this override.
select = "*-macosx_x86_64"
environment = { MACOSX_DEPLOYMENT_TARGET = "10.12" }
[tool.cibuildwheel.linux]
# The manylinux/musllinux build containers ship no Rust toolchain — install a
# exact release snapshot toolchain before building. Pin rustup-init itself
# because this hook runs before the wheel build and therefore controls the
# toolchain that produces the artifact. On Windows/macOS the same snapshot is
# passed to dtolnay/rust-toolchain.
# The `--component` flags match rust-toolchain.toml so the fresh toolchain
# already satisfies it: otherwise rustup tries to add those components
# mid-build (when maturin first runs cargo), which has intermittently raced into a
# `bin/cargo-fmt` install conflict in the musl container. Installing them once, up
# front on a clean toolchain, closes that window (the build itself never runs
# fmt/clippy).
# The URLs are rustup-init 1.29.0 archive artifacts. Their per-target SHA-256
# values are the matching official `rustup-init.sha256` entries.
before-all = '''
set -eu
if ls /lib/ld-musl-*.so.1 >/dev/null 2>&1; then
rustup_init_libc='musl'
else
rustup_init_libc='gnu'
fi
case "$(uname -m):${rustup_init_libc}" in
x86_64:gnu)
rustup_init_target='x86_64-unknown-linux-gnu'
rustup_init_sha256='4acc9acc76d5079515b46346a485974457b5a79893cfb01112423c89aeb5aa10'
;;
x86_64:musl)
rustup_init_target='x86_64-unknown-linux-musl'
rustup_init_sha256='9cd3fda5fd293890e36ab271af6a786ee22084b5f6c2b83fd8323cec6f0992c1'
;;
aarch64:gnu)
rustup_init_target='aarch64-unknown-linux-gnu'
rustup_init_sha256='9732d6c5e2a098d3521fca8145d826ae0aaa067ef2385ead08e6feac88fa5792'
;;
aarch64:musl)
rustup_init_target='aarch64-unknown-linux-musl'
rustup_init_sha256='88761caacddb92cd79b0b1f939f3990ba1997d701a38b3e8dd6746a562f2a759'
;;
*)
echo "Unsupported Linux architecture/libc for pinned rustup-init" >&2
exit 1
;;
esac
rustup_init_url="https://static.rust-lang.org/rustup/archive/1.29.0/${rustup_init_target}/rustup-init"
rustup_init_dir="$(mktemp -d)"
rustup_init="${rustup_init_dir}/rustup-init"
trap 'rm -f "$rustup_init"; rmdir "$rustup_init_dir"' EXIT
curl --proto '=https' --tlsv1.2 --fail --silent --show-error --location \
--output "$rustup_init" "$rustup_init_url"
printf '%s %s\n' "$rustup_init_sha256" "$rustup_init" | sha256sum -c -
chmod 0755 "$rustup_init"
"$rustup_init" -y --profile minimal --default-toolchain "$RUST_TOOLCHAIN" \
--component rustfmt --component clippy
'''
environment-pass = ["RUST_TOOLCHAIN"]
environment = { PATH = "$HOME/.cargo/bin:$PATH" }