-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathnoxfile.py
More file actions
493 lines (389 loc) · 15.7 KB
/
noxfile.py
File metadata and controls
493 lines (389 loc) · 15.7 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
"""Nox configuration for development tasks."""
import json
import re
import sys
from pathlib import Path
import nox
from nox.command import CommandFailed
nox.options.reuse_existing_virtualenvs = True
nox.options.default_venv_backend = "uv"
LICENSES_JSON_PATH = "reports/licenses.json"
SBOM_CYCLONEDX_PATH = "reports/sbom.json"
SBOM_SPDX_PATH = "reports/sbom.spdx"
VULNERABILITIES_JSON_PATH = "reports/vulnerabilities.json"
JUNIT_XML_PREFIX = "--junitxml=reports/junit_"
UTF8 = "utf-8"
def _read_python_version() -> str:
"""Read Python version from .python-version file.
Returns:
str: Python version string (e.g., "3.14" or "3.14.1")
Raises:
FileNotFoundError: If .python-version file does not exist
ValueError: If version format is invalid (not 2 or 3 segments)
OSError: If reading the file fails
"""
version_file = Path(".python-version")
if not version_file.exists():
print("Error: .python-version file not found")
sys.exit(1)
try:
version = version_file.read_text(encoding="utf-8").strip()
except OSError:
print("Error: Failed to read .python-version file")
sys.exit(1)
if not re.match(r"^\d+\.\d+(?:\.\d+)?$", version):
print(f"Error: Invalid Python version format in .python-version: {version}. Expected X.Y or X.Y.Z")
sys.exit(2)
return version
PYTHON_VERSION = _read_python_version()
TEST_PYTHON_VERSIONS = PYTHON_VERSION # We don't do matrix testing locally
def _setup_venv(session: nox.Session, all_extras: bool = True) -> None:
"""Install dependencies for the given session using uv."""
args = ["uv", "sync", "--frozen"]
if all_extras:
args.append("--all-extras")
session.run_install(
*args,
env={
"UV_PROJECT_ENVIRONMENT": session.virtualenv.location,
"UV_PYTHON": str(session.python),
},
)
def _format_json_with_jq(session: nox.Session, path: str) -> None:
"""Format JSON file using jq for better readability.
Args:
session: The nox session instance
path: Path to the JSON file to format
"""
with Path(f"{path}.tmp").open("w", encoding="utf-8") as outfile:
session.run("jq", ".", path, stdout=outfile, external=True)
session.run("mv", f"{path}.tmp", path, stdout=outfile, external=True)
@nox.session(python=[PYTHON_VERSION])
def audit(session: nox.Session) -> None:
"""Run security audit and license checks."""
_setup_venv(session, True)
# pip-audit to check for vulnerabilities
ignore_vulns = [
"CVE-2025-53000", # No fix for nbconvert yet
"CVE-2026-4539", # No fix available
"CVE-2026-39373", # No fix available
]
try:
session.run(
"pip-audit",
"-f",
"json",
"-o",
VULNERABILITIES_JSON_PATH,
*[arg for v in ignore_vulns for arg in ("--ignore", v)],
)
_format_json_with_jq(session, VULNERABILITIES_JSON_PATH)
except CommandFailed:
_format_json_with_jq(session, VULNERABILITIES_JSON_PATH)
session.log(f"pip-audit found vulnerabilities - see {VULNERABILITIES_JSON_PATH} for details")
session.run( # Retry without JSON for readable output
"pip-audit", *[arg for v in ignore_vulns for arg in ("--ignore", v)]
)
# pip-licenses to check for compliance
pip_licenses_base_args = [
"pip-licenses",
"--with-system",
"--with-authors",
"--with-maintainer",
"--with-url",
"--with-description",
]
# Filter by .license-types-allowed file if it exists
allowed_licenses = []
licenses_allow_file = Path(".license-types-allowed")
if licenses_allow_file.exists():
allowed_licenses = [
line.strip()
for line in licenses_allow_file.read_text(encoding="utf-8").splitlines()
if line.strip() and not line.strip().startswith(("#", "//"))
]
session.log(f"Found {len(allowed_licenses)} allowed licenses in .license-types-allowed")
if allowed_licenses:
allowed_licenses_str = ";".join(allowed_licenses)
session.log(f"Using --allow-only with: {allowed_licenses_str}")
pip_licenses_base_args.extend(["--partial-match", "--allow-only", allowed_licenses_str])
# Generate CSV and JSON reports
session.run(
*pip_licenses_base_args,
"--format=csv",
"--order=license",
"--output-file=reports/licenses.csv",
)
session.run(
*pip_licenses_base_args,
"--with-license-file",
"--with-notice-file",
"--format=json",
"--output-file=" + LICENSES_JSON_PATH,
)
# Group by license type
_format_json_with_jq(session, LICENSES_JSON_PATH)
licenses_data = json.loads(Path(LICENSES_JSON_PATH).read_text(encoding="utf-8"))
licenses_grouped: dict[str, list[dict[str, str]]] = {}
licenses_grouped = {}
for pkg in licenses_data:
license_name = pkg["License"]
package_info = {"Name": pkg["Name"], "Version": pkg["Version"]}
if license_name not in licenses_grouped:
licenses_grouped[license_name] = []
licenses_grouped[license_name].append(package_info)
Path("reports/licenses_grouped.json").write_text(
json.dumps(licenses_grouped, indent=2),
encoding="utf-8",
)
_format_json_with_jq(session, "reports/licenses_grouped.json")
# SBOMs
session.run("cyclonedx-py", "environment", "-o", SBOM_CYCLONEDX_PATH)
_format_json_with_jq(session, SBOM_CYCLONEDX_PATH)
# Generates an SPDX SBOM including vulnerability scanning
session.run(
"trivy",
"fs",
"uv.lock",
"--include-dev-deps",
"--scanners",
"vuln",
"--format",
"spdx",
"--output",
SBOM_SPDX_PATH,
external=True,
)
def _generate_attributions(session: nox.Session, licenses_data: list[dict[str, str]]) -> None:
"""Generate ATTRIBUTIONS.md from package license data.
Args:
session: The nox session instance
licenses_data: List of package metadata dicts from pip-licenses
"""
attributions = "# Attributions\n\n"
attributions += "[//]: # (This file is generated by mise run docs)\n\n"
attributions += "This project includes code from the following third-party open source projects:\n\n"
for pkg in licenses_data:
attributions += _format_package_attribution(pkg)
attributions = attributions.rstrip() + "\n"
Path("ATTRIBUTIONS.md").write_text(attributions, encoding="utf-8")
session.log("Generated ATTRIBUTIONS.md file")
def _format_package_attribution(pkg: dict[str, str]) -> str:
"""Format attribution for a single package.
Args:
pkg: Package information dictionary
Returns:
str: Formatted attribution text for the package
"""
name = pkg.get("Name", "Unknown")
version = pkg.get("Version", "Unknown")
license_name = pkg.get("License", "Unknown")
authors = pkg.get("Author", "Unknown")
maintainers = pkg.get("Maintainer", "")
url = pkg.get("URL", "")
description = pkg.get("Description", "")
attribution = f"## {name} ({version}) - {license_name}\n\n"
if description:
attribution += f"{description}\n\n"
if url:
attribution += f"* URL: {url}\n"
if authors and authors != "UNKNOWN":
attribution += f"* Author(s): {authors}\n"
if maintainers and maintainers != "UNKNOWN":
attribution += f"* Maintainer(s): {maintainers}\n"
attribution += "\n"
license_text = pkg.get("LicenseText", "")
if license_text and license_text != "UNKNOWN":
attribution += "### License Text\n\n"
# Sanitize backtick sequences to not escape the code block
sanitized_license_text = license_text.replace("```", "~~~")
attribution += f"```\n{sanitized_license_text}\n```\n\n"
notice_text = pkg.get("NoticeText", "")
if notice_text and notice_text != "UNKNOWN":
attribution += "### Notice\n\n"
# Sanitize backtick sequences to not escape the code block
sanitized_notice_text = notice_text.replace("```", "~~~")
attribution += f"```\n{sanitized_notice_text}\n```\n\n"
return attribution
@nox.session(python=[PYTHON_VERSION])
def docs(session: nox.Session) -> None:
"""Generate ATTRIBUTIONS.md from installed package metadata.
Runs pip-licenses in-memory (no disk artifact) and writes ATTRIBUTIONS.md.
For the full compliance audit and reports/licenses.json artifact, run `mise run audit`.
Args:
session: The nox session instance
"""
_setup_venv(session, True)
# Capture license data in memory — no compliance enforcement (that's audit's job)
raw = session.run(
"pip-licenses",
"--with-system",
"--with-authors",
"--with-maintainer",
"--with-url",
"--with-description",
"--with-license-file",
"--with-notice-file",
"--format=json",
silent=True,
)
licenses_data: list[dict[str, str]] = json.loads(raw or "[]")
_generate_attributions(session, licenses_data)
def _prepare_coverage(session: nox.Session, posargs: list[str]) -> None:
"""Clean coverage data unless keep-coverage flag is specified.
Args:
session: The nox session
posargs: Command line arguments
"""
if "--cov-append" not in posargs:
session.run("rm", "-rf", ".coverage", external=True)
def _sanitize_for_filename(text: str) -> str:
"""Sanitize text for use in filenames by replacing spaces and special chars.
Args:
text: Text to sanitize
Returns:
Sanitized text suitable for filenames
"""
return re.sub(r"[\s\(\)]", "-", text).strip("-")
def _extract_custom_marker(posargs: list[str]) -> tuple[str | None, list[str]]:
"""Extract custom marker from pytest arguments.
Args:
posargs: Command line arguments
Returns:
Tuple of (custom_marker, filtered_posargs)
"""
custom_marker = None
new_posargs: list[str] = []
skip_next = False
for i, arg in enumerate(posargs):
if skip_next:
skip_next = False
continue
if arg == "-m" and i + 1 < len(posargs):
custom_marker = posargs[i + 1]
skip_next = True
elif arg != "-m" or i == 0 or posargs[i - 1] != "-m":
new_posargs.append(arg)
return custom_marker, new_posargs
def _get_report_type(session: nox.Session, custom_marker: str | None) -> str:
"""Generate report type string based on marker and Python version.
Args:
session: The nox session
custom_marker: Optional pytest marker
Returns:
Report type string
"""
# Create a report type based on marker
report_type = "regular"
if custom_marker:
# Replace spaces and special chars with underscores
report_type = re.sub(r"[\s\(\)]", "_", custom_marker).strip("_")
# Add Python version to the report type
if isinstance(session.python, str):
python_version = f"py{session.python.replace('.', '')}"
else:
# Handle case where session.python is a list, bool, or None
python_version = f"py{session.python!s}"
return f"{python_version}_{report_type}"
def _inject_headline(headline: str, file_name: str) -> None:
"""Inject headline into file.
- Checks if report file actually exists
- If so, injects headline
- If not, does nothing
Args:
headline: Headline to inject as first line
file_name: Name of the report file
"""
file = Path(file_name)
if file.is_file():
header = f"{headline}\n"
content = file.read_text(encoding=UTF8)
content = header + content
file.write_text(content, encoding=UTF8)
def _run_pytest(
session: nox.Session, test_type: str, custom_marker: str | None, posargs: list[str], report_type: str
) -> None:
"""Run pytest with specified arguments.
Args:
session: The nox session
test_type: Type of test ('sequential' or 'not sequential')
custom_marker: Optional pytest marker
posargs: Additional pytest arguments
report_type: Report type string for output files
"""
is_sequential = test_type == "sequential"
# Build base pytest arguments
sanitized_test_type = _sanitize_for_filename(test_type)
if custom_marker:
sanitized_custom_marker = _sanitize_for_filename(custom_marker)
pytest_args = [
"pytest",
"--disable-warnings",
JUNIT_XML_PREFIX + sanitized_test_type + "_" + sanitized_custom_marker + ".xml",
]
else:
pytest_args = ["pytest", "--disable-warnings", JUNIT_XML_PREFIX + sanitized_test_type + ".xml"]
# Distribute tests across available CPUs if not sequential
if not is_sequential:
pytest_args.extend(["-n", "logical", "--dist", "worksteal"])
# Apply the appropriate marker
marker_value = f"({test_type})"
if custom_marker:
marker_value += f" and ({custom_marker})"
pytest_args.extend(["-m", marker_value])
# Add additional arguments
pytest_args.extend(posargs)
# Report output as markdown for GitHub step summaries
report_file_name = f"reports/pytest_{report_type}_{'sequential' if is_sequential else 'parallel'}.md"
pytest_args.extend(["--md-report-output", report_file_name])
# Remove report file if it exists,
# as it's only generated for failing tests on the pytest run below
report_file = Path(report_file_name)
if report_file.is_file():
report_file.unlink()
# Run pytest with the constructed arguments
session.run(*pytest_args)
# Inject headline into the report file indicating the report type
_inject_headline(f"# Failing tests with for test execution with {report_type}\n", report_file_name)
def _generate_coverage_report(session: nox.Session) -> None:
"""Generate coverage report in markdown format.
Args:
session: The nox session
"""
coverage_report_file_name = "reports/coverage.md"
with Path(coverage_report_file_name).open("w", encoding=UTF8) as outfile:
session.run("coverage", "report", "--format=markdown", stdout=outfile)
_inject_headline("# Coverage report", coverage_report_file_name)
def _run_test_suite(session: nox.Session, marker: str = "", cov_append: bool = False) -> None:
"""Run test suite with specified marker.
Args:
session: The nox session
marker: Pytest marker expression
cov_append: Whether to append to existing coverage data
"""
_setup_venv(session)
posargs = session.posargs[:]
if "-m" not in posargs and marker:
posargs.extend(["-m", marker])
if cov_append:
posargs.append("--cov-append")
# Conditionally clean coverage data
# Will remove .coverage file if --cov-append is not specified
_prepare_coverage(session, posargs)
# Extract custom markers from posargs if present
custom_marker, filtered_posargs = _extract_custom_marker(posargs)
# Determine report type from python version and custom marker
report_type = _get_report_type(session, custom_marker)
# Run parallel tests
_run_pytest(session, "not sequential", custom_marker, filtered_posargs, report_type)
# Run sequential tests
if "--cov-append" not in filtered_posargs:
filtered_posargs.extend(["--cov-append"])
_run_pytest(session, "sequential", custom_marker, filtered_posargs, report_type)
# Generate coverage report in markdown (only after last test suite)
# Note: This will be called multiple times, which is fine as it updates the same report
_generate_coverage_report(session)
@nox.session(python=TEST_PYTHON_VERSIONS, default=False)
def test(session: nox.Session) -> None:
"""Run tests with pytest."""
_run_test_suite(session)