Repository navigation
472 lines (459 loc) · 21.8 KB
/
Copy pathpython-package.yml
File metadata and controls
472 lines (459 loc) · 21.8 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
# This workflow will install Python dependencies, run tests and lint with a variety of Python versions
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions
name: Python Package
on:
# `push` restricted to `main` so feature branches only run via `pull_request`.
# Otherwise every push to a branch with an open PR would trigger the workflow
# twice (once for `push`, once for `pull_request`) — doubling CI cost and
# latency for no benefit.
push:
branches: [main]
pull_request:
# Allow re-running the workflow without an empty push (handy after the
# workflow gets `disabled_inactivity` after 60 days idle).
workflow_dispatch:
schedule:
- cron: '0 0 */7 * *'
# Cancel an in-flight run when a newer commit lands on the same ref. Keeps the
# queue lean and stops stale runs from blocking the merge button.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
# Least privilege for every job's GITHUB_TOKEN. Nothing here writes to the
# repository: checkout only reads it, and the Codecov uploads authenticate
# with CODECOV_TOKEN, not with the workflow token.
permissions:
contents: read
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'
- name: Install lint deps
run: |
python -m pip install --upgrade pip
pip install flake8
- name: Lint
run: |
flake8 PyMemoryEditor tests
type-check:
needs: lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'
- name: Install dev deps
# Include the `speed` extra (NumPy) so mypy type-checks the NumPy fast
# path against real stubs. Without it, `import numpy` resolves to `Any`
# and the `# type: ignore` in scan_numpy.py reads as unused.
run: |
python -m pip install --upgrade pip
pip install -e ".[dev,speed]"
- name: Run mypy
run: |
mypy PyMemoryEditor
# The shared layer branches on sys.platform in places (process/util.py,
# the backend dispatch in __init__.py), and mypy only follows the branch
# for the platform it is told about. Checking as Windows and macOS too
# covers the branches this Linux runner would otherwise skip, without
# paying for a Windows or macOS runner. The backends themselves stay
# under ignore_errors (see pyproject.toml).
- name: Run mypy as Windows and macOS
run: |
mypy --platform win32 PyMemoryEditor
mypy --platform darwin PyMemoryEditor
build:
needs: lint
runs-on: ${{ matrix.os }}
# Hard ceiling, same purpose as build-macos'. A cell takes ~4 minutes
# (measured 2026-10-08: 2m30s to 4m25s); without this the default is 6h.
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
python-version: ['3.10', '3.11', '3.12', '3.13', '3.14', '3.15']
os:
- ubuntu-latest
- windows-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: ${{ matrix.python-version }}
# 3.15 is still a release candidate; setup-python only installs it
# with prereleases allowed. Harmless once 3.15.0 is out.
allow-prereleases: true
- name: Install Qt system libraries (Linux)
# PySide6 links against libEGL/libGL/libxkbcommon/libfontconfig and the
# XCB stack at import time, even when running under the `offscreen`
# platform plugin. The Ubuntu runner ships without them, so pytest-qt's
# `import QtGui` crashes with `libEGL.so.1: cannot open shared object`.
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libegl1 \
libgl1 \
libxkbcommon0 \
libfontconfig1 \
libdbus-1-3 \
libxcb-cursor0 \
libxcb-icccm4 \
libxcb-image0 \
libxcb-keysyms1 \
libxcb-randr0 \
libxcb-render-util0 \
libxcb-shape0 \
libxcb-sync1 \
libxcb-xfixes0 \
libxcb-xinerama0 \
libxcb-xkb1 \
libxkbcommon-x11-0
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"
- name: Test with pytest
env:
QT_QPA_PLATFORM: offscreen
# `--ignore=tests/mcp_server`: the MCP server has its own job
# (`build-mcp`) so this matrix keeps measuring the library and the app,
# at the wall clock it always had. Its tests walk a live process's whole
# address space, which on macOS is ~390 GiB (see build-macos below) --
# running them in every cell of a 4x2 matrix plus macOS plus speed paid
# that cost eight times over, and it pushed build-macos past its
# 20-minute budget.
#
# --maxfail=10 rather than -x: one broken test should not hide the next
# nine, but a wholesale breakage (a backend that fails to import) still
# stops early instead of printing 600 tracebacks.
#
# --cov-fail-under is the real, in-repo coverage gate (deterministic and
# independent of any external service), and `--cov-config` is what keeps
# it meaningful now that the MCP suite lives in `build-mcp`.
#
# Without it this job measures PyMemoryEditor/mcp -- 918 statements it
# never runs -- as uncovered, which is a measurement of absence, not of
# quality. It also varies by platform in a way the gate cannot absorb:
# measured that way, Linux came out at 46% and macOS at 59%, so a floor
# picked from macOS alone failed all eight matrix cells. Scoped to the
# library, the numbers are 87% on macOS and 66% on Linux. The floor is
# 62: four points under the lowest cell (Linux), so it trips on a real
# regression rather than on platform spread. (It was 60 from before the
# MCP server existed, when nothing had been measured.)
#
# (coverage.py only counts the host backend + shared code -- the two
# foreign backends aren't imported, which is most of that platform
# spread.) Ratchet up once the numbers settle on Codecov.
run: |
pytest tests --ignore=tests/mcp_server -v -s --maxfail=10 --cov=PyMemoryEditor --cov-config=.coveragerc-lib --cov-report=term --cov-report=xml --cov-fail-under=62
- name: Upload coverage to Codecov
# Upload from every matrix cell so Codecov merges each OS's backend
# coverage into one combined view (each platform exercises a different
# win32/linux/macos backend). Informational only — see codecov.yml — so a
# flaky upload never blocks the merge; the hard gate is --cov-fail-under
# above. Runs even when tests fail so partial coverage stays visible.
if: always()
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1
with:
files: ./coverage.xml
flags: ${{ runner.os }}-py${{ matrix.python-version }}
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false
# macOS gets its own short row instead of joining the matrix above. Its
# purpose is to measure the `macos` backend so the merged Codecov report
# covers all three platforms; the full Python-version sweep stays on Linux
# and Windows, where it costs a fraction of the wall clock.
#
# Three versions -- the floor (3.10), the reference (3.12) and the newest
# stable (3.14) -- rather than one: the backend is ctypes over Mach structs
# and a ctypes or struct-layout change between CPython versions would only
# show up here. A cell took 4m06s on 2026-10-11, under the slowest Windows
# cell, and the cells run in parallel, so the extra two cost runner minutes
# (free for a public repository) but no wall clock. With build-mcp's macOS
# cell that is four concurrent macOS jobs, inside the free plan's five.
#
# Unlike the matrix, this job installs `.[dev,speed]` (NumPy). That is not a
# preference — it is what makes a macOS cell viable at all. A macOS process
# exposes ~390 GiB of sparse address space across ~145 regions, two orders of
# magnitude more than a Linux one, and the scan tests walk all of it. Measured
# on an M-series Mac, this exact command takes ~82s with NumPy and 681s
# without: an 8x gap that a 3-core runner only widens, which is what made
# macOS look like it hung here before (it was removed in 041bfc0).
# The pure-Python scan path loses nothing — every cell of the matrix
# above still exercises it.
build-macos:
needs: lint
runs-on: macos-latest
# Hard ceiling so a pathological scan can never sit at the 6h default.
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
python-version: ['3.10', '3.12', '3.14']
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: ${{ matrix.python-version }}
# No Qt system-library step here: the PySide6 macOS wheel bundles its own
# Qt frameworks, unlike the Linux one.
- name: Install dependencies (with NumPy speed extra)
run: |
python -m pip install --upgrade pip
pip install -e ".[dev,speed]"
- name: Test with pytest
env:
QT_QPA_PLATFORM: offscreen
# Same conservative coverage gate as the main matrix (see that step's note).
run: |
pytest tests --ignore=tests/mcp_server -v -s --maxfail=10 --cov=PyMemoryEditor --cov-config=.coveragerc-lib --cov-report=term --cov-report=xml --cov-fail-under=62
- name: Upload coverage to Codecov
# Flag mirrors the matrix format (`runner.os` is "macOS" here) so Codecov
# groups this cell alongside the Linux and Windows uploads.
if: always()
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1
with:
files: ./coverage.xml
flags: macOS-py${{ matrix.python-version }}
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false
# Isolated job for the optional NumPy-accelerated scan fast path (the
# `[speed]` extra). The matrix above deliberately runs WITHOUT NumPy so the
# pure-Python scan path stays covered; this single Linux job installs
# `.[speed]` on top of the dev deps to exercise the vectorized path.
build-speed:
needs: lint
runs-on: ubuntu-latest
# ~3 minutes measured; see the `build` note.
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'
- name: Install Qt system libraries (Linux)
# PySide6 links against libEGL/libGL/libxkbcommon/libfontconfig and the
# XCB stack at import time, even when running under the `offscreen`
# platform plugin. The Ubuntu runner ships without them, so pytest-qt's
# `import QtGui` crashes with `libEGL.so.1: cannot open shared object`.
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libegl1 \
libgl1 \
libxkbcommon0 \
libfontconfig1 \
libdbus-1-3 \
libxcb-cursor0 \
libxcb-icccm4 \
libxcb-image0 \
libxcb-keysyms1 \
libxcb-randr0 \
libxcb-render-util0 \
libxcb-shape0 \
libxcb-sync1 \
libxcb-xfixes0 \
libxcb-xinerama0 \
libxcb-xkb1 \
libxkbcommon-x11-0
- name: Install dependencies (with NumPy speed extra)
run: |
python -m pip install --upgrade pip
pip install -e ".[dev,speed]"
- name: Test with pytest (NumPy fast path)
env:
QT_QPA_PLATFORM: offscreen
# Same conservative coverage gate as the main matrix (see that step's note).
run: |
pytest tests --ignore=tests/mcp_server -v -s --maxfail=10 --cov=PyMemoryEditor --cov-config=.coveragerc-lib --cov-report=term --cov-report=xml --cov-fail-under=62
- name: Upload coverage to Codecov
if: always()
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1
with:
files: ./coverage.xml
flags: speed-Linux-py3.12
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false
# The MCP server gets its own job rather than a row in the matrix above.
#
# Its tests are the expensive ones: `test_live.py` and half of
# `test_fake_fidelity.py` attach to the running test process and scan its
# entire address space, which is exactly the workload the build-macos note
# below measures at ~390 GiB. Running them in all ten cells of the other
# jobs multiplied that by ten and timed macOS out; running them once per OS
# keeps the platform coverage that actually matters and costs a fraction.
#
# One Python version, three operating systems -- the opposite shape from the
# matrix above, and deliberately so. Nothing in this package is
# version-sensitive (it is plain 3.10+ typing over the library's own API),
# but plenty of it is platform-sensitive: the Windows-only read-only handle,
# macOS `task_for_pid`, and the per-platform process enumeration and denylist.
# The one Windows-only bug this suite has caught so far was a test reading a
# UTF-8 file with the locale's encoding, which only cp1252 could fail on.
#
# NumPy is installed for the same reason build-macos installs it: without the
# vectorized scan path, walking a macOS address space takes ~8x longer.
build-mcp:
needs: lint
runs-on: ${{ matrix.os }}
# Hard ceiling, same purpose as build-macos'. The suite itself takes
# ~15s of pytest on all three runners; this is generous on purpose.
#
# It was briefly 45 while this job took 20+ minutes everywhere. The cause
# was not scan cost: one test parametrized a 200 000-character value, so
# its pytest id was 400 KB, and the Actions log uploader spent ~150s per
# such line. Five lines, twenty minutes. Fixed at the test.
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
# `scan_heavy` -- the tests that scan a *live* process's whole address
# space -- runs on Linux only. The cost is wildly platform-dependent: a
# macOS process exposes ~390 GiB across ~145 regions and Windows is not
# much better, so on a runner each of those scans is minutes. Measured
# on the previous attempt: this job took 22m21s on macOS and hit its
# timeout at 30m on Windows, while the same suite is 18s locally.
#
# Nothing is lost by pinning them to Linux. What the other two OSes are
# here for is the platform-specific code -- the Windows-only read-only
# handle, macOS task_for_pid, per-platform enumeration and the
# truncated-name denylist -- none of which are scans. The library's own
# live-scan coverage on those platforms comes from `build` and
# `build-macos`.
#
# Set through PYTEST_ADDOPTS rather than the run: line because the
# Windows runner's default shell is pwsh, and `-m "not scan_heavy"`
# would need different quoting there than on bash.
include:
- os: ubuntu-latest
addopts: ''
- os: windows-latest
addopts: '-m "not scan_heavy"'
- os: macos-latest
addopts: '-m "not scan_heavy"'
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'
- name: Install dependencies (MCP SDK + NumPy speed extra)
run: |
python -m pip install --upgrade pip
pip install -e ".[dev,speed]"
- name: Test the MCP server
# Coverage is scoped to the package under test, via both --cov and
# .coveragerc-mcp. Measured against the whole package instead, this gate
# came out at 71% on macOS but 60.4% on Linux -- four tenths of a point
# above a floor of 60, on a number mostly about code this job does not
# target. Scoped, the MCP package is 91% covered by its own suite on
# macOS and 90.4% on Linux, so 85 leaves five points of headroom.
env:
PYTEST_ADDOPTS: ${{ matrix.addopts }}
# `-q --durations=25` rather than the `-v -s` the library jobs use. This
# job's timing is the open question -- 20 minutes on a runner against
# 12.5s in a container, and cutting the process-table walks 14x changed
# nothing -- and `-v -s` emitted 977 log lines in the first 8 seconds,
# so GitHub truncated the log long before the part that would say which
# tests were slow. Quiet output keeps the log small enough that the
# duration table at the end survives.
#
# -p no:pytest-qt: the dev extra brings pytest-qt, whose plugin imports
# QtGui at startup. The name is the entry point's, hyphen included --
# `-p no:pytestqt` silently does nothing, which is how this failed twice.
# It dies with `ImportError: libEGL.so.1` on a bare Linux runner; the
# library jobs apt-install a pile of X libraries for it. This job runs no
# Qt tests, so it disables the plugin instead of paying for them.
run: |
pytest tests/mcp_server -p no:pytest-qt -q --durations=25 --maxfail=10 --cov=PyMemoryEditor/mcp --cov-config=.coveragerc-mcp --cov-report=term --cov-report=xml --cov-fail-under=85
- name: Upload coverage to Codecov
# Its own flag, so the merged report shows MCP coverage alongside the
# library's instead of one overwriting the other.
if: always()
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1
with:
files: ./coverage.xml
flags: mcp-${{ runner.os }}-py3.12
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false
# Build the Sphinx docs with warnings as errors, so a broken cross-reference,
# a missing toctree entry or an autodoc import failure fails the PR instead
# of surfacing later on Read the Docs. Read the Docs keeps
# `fail_on_warning: false` (.readthedocs.yaml) so a warning that only its
# environment produces never stops the published docs from updating; this
# job is the gate.
docs:
needs: lint
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.11'
- name: Install docs dependencies
run: |
python -m pip install --upgrade pip
pip install -r docs/requirements.txt
pip install -e .
- name: Build HTML docs (warnings are errors)
run: |
python -m sphinx -W --keep-going -b html docs docs/_build/html
# Dependency audit. The core library has no dependencies; this checks the
# extras (Qt app, MCP SDK, NumPy) and their transitive closure against the
# PyPI advisory database.
#
# Advisory on pull requests, a gate everywhere else. On a PR,
# `continue-on-error` keeps a new upstream CVE from blocking an unrelated
# change. On main (push, the weekly `schedule`, a manual dispatch) a finding
# fails the run -- and publish.yml only releases a commit whose run here is
# green, so a release cannot ship with an unanswered advisory in the extras'
# ranges. The answer is a pin bump, or `--ignore-vuln <ID>` below with a
# comment saying why it does not apply. Dependabot security PRs remain the
# fix path; this job is the visibility the disabled version-update PRs
# don't provide (see .github/dependabot.yml). It was green on the runs that
# led up to v3.2.0, which is what made it safe to turn into a gate.
audit:
needs: lint
runs-on: ubuntu-latest
timeout-minutes: 10
continue-on-error: ${{ github.event_name == 'pull_request' }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'
# The extras go into their own venv and only their frozen closure is
# audited: auditing the job's environment would also audit pip-audit's own
# dependencies, and a CVE in one of those would block a release of a
# package that never ships it.
- name: Install the package with every runtime extra
run: |
python -m pip install --upgrade pip pip-audit
python -m venv "$RUNNER_TEMP/target"
"$RUNNER_TEMP/target/bin/pip" install ".[app,mcp,speed]"
"$RUNNER_TEMP/target/bin/pip" freeze --exclude PyMemoryEditor > "$RUNNER_TEMP/requirements.txt"
# Retried because the advisory service is a network dependency: a
# transient API failure must not turn a release gate red. A real finding
# fails all three attempts the same way.
- name: pip-audit
run: |
for attempt in 1 2 3; do
pip-audit --progress-spinner off --no-deps --disable-pip \
-r "$RUNNER_TEMP/requirements.txt" && exit 0
[ "$attempt" -lt 3 ] && sleep 30
done
exit 1