relenv 0.21.2__py3-none-any.whl → 0.22.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.
Files changed (49) hide show
  1. relenv/__init__.py +14 -2
  2. relenv/__main__.py +12 -6
  3. relenv/_resources/xz/config.h +148 -0
  4. relenv/_resources/xz/readme.md +4 -0
  5. relenv/build/__init__.py +28 -30
  6. relenv/build/common/__init__.py +50 -0
  7. relenv/build/common/_sysconfigdata_template.py +72 -0
  8. relenv/build/common/builder.py +907 -0
  9. relenv/build/common/builders.py +163 -0
  10. relenv/build/common/download.py +324 -0
  11. relenv/build/common/install.py +609 -0
  12. relenv/build/common/ui.py +432 -0
  13. relenv/build/darwin.py +128 -14
  14. relenv/build/linux.py +292 -74
  15. relenv/build/windows.py +123 -169
  16. relenv/buildenv.py +48 -17
  17. relenv/check.py +10 -5
  18. relenv/common.py +489 -165
  19. relenv/create.py +147 -7
  20. relenv/fetch.py +16 -4
  21. relenv/manifest.py +15 -7
  22. relenv/python-versions.json +329 -0
  23. relenv/pyversions.py +817 -30
  24. relenv/relocate.py +101 -55
  25. relenv/runtime.py +452 -282
  26. relenv/toolchain.py +9 -3
  27. {relenv-0.21.2.dist-info → relenv-0.22.0.dist-info}/METADATA +1 -1
  28. relenv-0.22.0.dist-info/RECORD +48 -0
  29. tests/__init__.py +2 -0
  30. tests/_pytest_typing.py +45 -0
  31. tests/conftest.py +42 -36
  32. tests/test_build.py +426 -9
  33. tests/test_common.py +311 -48
  34. tests/test_create.py +149 -6
  35. tests/test_downloads.py +19 -15
  36. tests/test_fips_photon.py +6 -3
  37. tests/test_module_imports.py +44 -0
  38. tests/test_pyversions_runtime.py +177 -0
  39. tests/test_relocate.py +45 -39
  40. tests/test_relocate_module.py +257 -0
  41. tests/test_runtime.py +1802 -6
  42. tests/test_verify_build.py +477 -34
  43. relenv/build/common.py +0 -1707
  44. relenv-0.21.2.dist-info/RECORD +0 -35
  45. {relenv-0.21.2.dist-info → relenv-0.22.0.dist-info}/WHEEL +0 -0
  46. {relenv-0.21.2.dist-info → relenv-0.22.0.dist-info}/entry_points.txt +0 -0
  47. {relenv-0.21.2.dist-info → relenv-0.22.0.dist-info}/licenses/LICENSE.md +0 -0
  48. {relenv-0.21.2.dist-info → relenv-0.22.0.dist-info}/licenses/NOTICE +0 -0
  49. {relenv-0.21.2.dist-info → relenv-0.22.0.dist-info}/top_level.txt +0 -0
tests/test_common.py CHANGED
@@ -1,27 +1,38 @@
1
1
  # Copyright 2022-2025 Broadcom.
2
- # SPDX-License-Identifier: Apache-2
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ from __future__ import annotations
4
+
3
5
  import os
4
6
  import pathlib
7
+ import pickle
5
8
  import platform
6
9
  import shutil
7
10
  import subprocess
8
11
  import sys
9
12
  import tarfile
13
+ from types import ModuleType
14
+ from typing import BinaryIO, Callable, Literal, Optional
10
15
  from unittest.mock import patch
11
16
 
12
17
  import pytest
13
18
 
19
+ import relenv.common
14
20
  from relenv.common import (
15
21
  MODULE_DIR,
16
22
  SHEBANG_TPL_LINUX,
17
23
  SHEBANG_TPL_MACOS,
18
24
  RelenvException,
25
+ Version,
26
+ addpackage,
19
27
  archived_build,
28
+ download_url,
20
29
  extract_archive,
21
30
  format_shebang,
22
31
  get_download_location,
23
32
  get_toolchain,
24
33
  get_triplet,
34
+ list_archived_builds,
35
+ makepath,
25
36
  relative_interpreter,
26
37
  runcmd,
27
38
  sanitize_sys_path,
@@ -29,21 +40,46 @@ from relenv.common import (
29
40
  work_dirs,
30
41
  work_root,
31
42
  )
43
+ from tests._pytest_typing import mark_skipif, parametrize
44
+
45
+
46
+ def _mock_ppbt_module(
47
+ monkeypatch: pytest.MonkeyPatch, triplet: str, archive_path: pathlib.Path
48
+ ) -> None:
49
+ """
50
+ Provide a lightweight ppbt.common stub so get_toolchain() skips the real extraction.
51
+ """
52
+ stub_package = ModuleType("ppbt")
53
+ stub_common = ModuleType("ppbt.common")
54
+ setattr(stub_package, "common", stub_common)
55
+
56
+ # pytest will clean these entries up automatically via monkeypatch
57
+ monkeypatch.setitem(sys.modules, "ppbt", stub_package)
58
+ monkeypatch.setitem(sys.modules, "ppbt.common", stub_common)
59
+
60
+ setattr(stub_common, "ARCHIVE", archive_path)
32
61
 
62
+ def fake_extract_archive(dest: str, archive: str) -> None:
63
+ dest_path = pathlib.Path(dest)
64
+ dest_path.mkdir(parents=True, exist_ok=True)
65
+ (dest_path / triplet).mkdir(parents=True, exist_ok=True)
33
66
 
34
- def test_get_triplet_linux():
67
+ setattr(stub_common, "extract_archive", fake_extract_archive)
68
+
69
+
70
+ def test_get_triplet_linux() -> None:
35
71
  assert get_triplet("aarch64", "linux") == "aarch64-linux-gnu"
36
72
 
37
73
 
38
- def test_get_triplet_darwin():
74
+ def test_get_triplet_darwin() -> None:
39
75
  assert get_triplet("x86_64", "darwin") == "x86_64-macos"
40
76
 
41
77
 
42
- def test_get_triplet_windows():
78
+ def test_get_triplet_windows() -> None:
43
79
  assert get_triplet("amd64", "win32") == "amd64-win"
44
80
 
45
81
 
46
- def test_get_triplet_default():
82
+ def test_get_triplet_default() -> None:
47
83
  machine = platform.machine().lower()
48
84
  plat = sys.platform
49
85
  if plat == "win32":
@@ -56,12 +92,12 @@ def test_get_triplet_default():
56
92
  pytest.fail(f"Do not know how to test for '{plat}' platform")
57
93
 
58
94
 
59
- def test_get_triplet_unknown():
95
+ def test_get_triplet_unknown() -> None:
60
96
  with pytest.raises(RelenvException):
61
97
  get_triplet("aarch64", "oijfsdf")
62
98
 
63
99
 
64
- def test_archived_build():
100
+ def test_archived_build() -> None:
65
101
  dirs = work_dirs()
66
102
  build = archived_build()
67
103
  try:
@@ -70,23 +106,23 @@ def test_archived_build():
70
106
  pytest.fail("Archived build value not relative to build dir")
71
107
 
72
108
 
73
- def test_work_root_when_passed_relative_path():
109
+ def test_work_root_when_passed_relative_path() -> None:
74
110
  name = "foo"
75
111
  assert work_root(name) == pathlib.Path(name).resolve()
76
112
 
77
113
 
78
- def test_work_root_when_passed_full_path():
114
+ def test_work_root_when_passed_full_path() -> None:
79
115
  name = "/foo/bar"
80
116
  if sys.platform == "win32":
81
117
  name = "D:/foo/bar"
82
118
  assert work_root(name) == pathlib.Path(name)
83
119
 
84
120
 
85
- def test_work_root_when_nothing_passed():
121
+ def test_work_root_when_nothing_passed() -> None:
86
122
  assert work_root() == MODULE_DIR
87
123
 
88
124
 
89
- def test_work_dirs_attributes():
125
+ def test_work_dirs_attributes() -> None:
90
126
  dirs = work_dirs()
91
127
  checkfor = [
92
128
  "root",
@@ -100,118 +136,215 @@ def test_work_dirs_attributes():
100
136
  assert hasattr(dirs, attr)
101
137
 
102
138
 
103
- def test_runcmd_success():
139
+ def test_runcmd_success() -> None:
104
140
  ret = runcmd(["echo", "foo"])
105
141
  assert ret.returncode == 0
106
142
 
107
143
 
108
- def test_runcmd_fail():
144
+ def test_runcmd_fail() -> None:
109
145
  with pytest.raises(RelenvException):
110
146
  runcmd([sys.executable, "-c", "import sys;sys.exit(1)"])
111
147
 
112
148
 
113
- def test_work_dir_with_root_module_dir():
149
+ def test_work_dir_with_root_module_dir() -> None:
114
150
  ret = work_dir("fakedir")
115
151
  assert ret == MODULE_DIR / "_fakedir"
116
152
 
117
153
 
118
- def test_work_dir_with_root_given(tmp_path):
154
+ def test_work_dir_with_root_given(tmp_path: pathlib.Path) -> None:
119
155
  ret = work_dir("fakedir", root=tmp_path)
120
156
  assert ret == tmp_path / "fakedir"
121
157
 
122
158
 
123
- def test_get_toolchain(tmp_path):
159
+ def test_get_toolchain(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
124
160
  data_dir = tmp_path / "data"
125
- with patch("relenv.common.DATA_DIR", data_dir):
126
- ret = get_toolchain(arch="aarch64")
127
- if sys.platform in ["darwin", "win32"]:
128
- assert "data" in str(ret)
129
- else:
130
- assert f"{data_dir}/toolchain" in str(ret)
131
-
132
-
133
- def test_get_toolchain_no_arch(tmp_path):
161
+ triplet = "aarch64-linux-gnu"
162
+ monkeypatch.setattr(relenv.common, "DATA_DIR", data_dir, raising=False)
163
+ monkeypatch.setattr(sys, "platform", "linux")
164
+ monkeypatch.setattr(
165
+ relenv.common, "get_triplet", lambda machine=None, plat=None: triplet
166
+ )
167
+ monkeypatch.setenv("RELENV_TOOLCHAIN_CACHE", str(data_dir / "toolchain"))
168
+ archive_path = tmp_path / "dummy-toolchain.tar.xz"
169
+ archive_path.write_bytes(b"")
170
+ _mock_ppbt_module(monkeypatch, triplet, archive_path)
171
+ ret = get_toolchain(arch="aarch64")
172
+ assert ret == data_dir / "toolchain" / triplet
173
+
174
+
175
+ def test_get_toolchain_linux_existing(tmp_path: pathlib.Path) -> None:
134
176
  data_dir = tmp_path / "data"
135
- with patch("relenv.common.DATA_DIR", data_dir):
177
+ triplet = "x86_64-linux-gnu"
178
+ toolchain_path = data_dir / "toolchain" / triplet
179
+ toolchain_path.mkdir(parents=True)
180
+ with patch("relenv.common.DATA_DIR", data_dir), patch(
181
+ "sys.platform", "linux"
182
+ ), patch("relenv.common.get_triplet", return_value=triplet), patch.dict(
183
+ os.environ,
184
+ {"RELENV_TOOLCHAIN_CACHE": str(data_dir / "toolchain")},
185
+ ):
136
186
  ret = get_toolchain()
137
- if sys.platform in ["darwin", "win32"]:
138
- assert "data" in str(ret)
139
- else:
140
- assert f"{data_dir}/toolchain" in str(ret)
187
+ assert ret == toolchain_path
141
188
 
142
189
 
143
- @pytest.mark.parametrize("open_arg", (":gz", ":xz", ":bz2", ""))
144
- def test_extract_archive(tmp_path, open_arg):
190
+ def test_get_toolchain_no_arch(
191
+ tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
192
+ ) -> None:
193
+ data_dir = tmp_path / "data"
194
+ triplet = "x86_64-linux-gnu"
195
+ monkeypatch.setattr(relenv.common, "DATA_DIR", data_dir, raising=False)
196
+ monkeypatch.setattr(sys, "platform", "linux")
197
+ monkeypatch.setattr(
198
+ relenv.common, "get_triplet", lambda machine=None, plat=None: triplet
199
+ )
200
+ monkeypatch.setenv("RELENV_TOOLCHAIN_CACHE", str(data_dir / "toolchain"))
201
+ archive_path = tmp_path / "dummy-toolchain.tar.xz"
202
+ archive_path.write_bytes(b"")
203
+ _mock_ppbt_module(monkeypatch, triplet, archive_path)
204
+ ret = get_toolchain()
205
+ assert ret == data_dir / "toolchain" / triplet
206
+
207
+
208
+ WriteMode = Literal["w:gz", "w:xz", "w:bz2", "w"]
209
+
210
+
211
+ @parametrize(
212
+ ("suffix", "mode"),
213
+ (
214
+ (".tgz", "w:gz"),
215
+ (".tar.gz", "w:gz"),
216
+ (".tar.xz", "w:xz"),
217
+ (".tar.bz2", "w:bz2"),
218
+ (".tar", "w"),
219
+ ),
220
+ )
221
+ def test_extract_archive(tmp_path: pathlib.Path, suffix: str, mode: WriteMode) -> None:
145
222
  to_be_archived = tmp_path / "to_be_archived"
146
223
  to_be_archived.mkdir()
147
224
  test_file = to_be_archived / "testfile"
148
225
  test_file.touch()
149
- tar_file = tmp_path / "fake_archive"
226
+ tar_file = tmp_path / f"fake_archive{suffix}"
150
227
  to_dir = tmp_path / "extracted"
151
- with tarfile.open(str(tar_file), "w{}".format(open_arg)) as tar:
228
+ with tarfile.open(str(tar_file), mode=mode) as tar:
152
229
  tar.add(str(to_be_archived), to_be_archived.name)
153
230
  extract_archive(str(to_dir), str(tar_file))
154
231
  assert to_dir.exists()
155
232
  assert (to_dir / to_be_archived.name / test_file.name) in to_dir.glob("**/*")
156
233
 
157
234
 
158
- def test_get_download_location(tmp_path):
235
+ def test_get_download_location(tmp_path: pathlib.Path) -> None:
159
236
  url = "https://test.com/1.0.0/test-1.0.0.tar.xz"
160
237
  loc = get_download_location(url, str(tmp_path))
161
238
  assert loc == str(tmp_path / "test-1.0.0.tar.xz")
162
239
 
163
240
 
164
- @pytest.mark.skipif(shutil.which("shellcheck") is None, reason="Test needs shellcheck")
165
- def test_shebang_tpl_linux():
166
- sh = format_shebang("python3", SHEBANG_TPL_LINUX).split("'''")[1].strip("'")
241
+ def test_download_url_writes_file(tmp_path: pathlib.Path) -> None:
242
+ dest = tmp_path / "downloads"
243
+ dest.mkdir()
244
+ data = b"payload"
245
+
246
+ def fake_fetch(
247
+ url: str,
248
+ fp: BinaryIO,
249
+ backoff: int,
250
+ timeout: float,
251
+ progress_callback: Optional[Callable[[int, int], None]] = None,
252
+ ) -> None:
253
+ fp.write(data)
254
+
255
+ with patch("relenv.common.fetch_url", side_effect=fake_fetch):
256
+ path = download_url("https://example.com/a.txt", dest)
257
+
258
+ assert pathlib.Path(path).read_bytes() == data
259
+
260
+
261
+ def test_download_url_failure_cleans_up(tmp_path: pathlib.Path) -> None:
262
+ dest = tmp_path / "downloads"
263
+ dest.mkdir()
264
+ created = dest / "a.txt"
265
+
266
+ def fake_fetch(
267
+ url: str,
268
+ fp: BinaryIO,
269
+ backoff: int,
270
+ timeout: float,
271
+ progress_callback: Optional[Callable[[int, int], None]] = None,
272
+ ) -> None:
273
+ raise RelenvException("fail")
274
+
275
+ with patch("relenv.common.get_download_location", return_value=str(created)), patch(
276
+ "relenv.common.fetch_url", side_effect=fake_fetch
277
+ ), patch("relenv.common.log") as log_mock:
278
+ with pytest.raises(RelenvException):
279
+ download_url("https://example.com/a.txt", dest)
280
+ log_mock.error.assert_called()
281
+ assert not created.exists()
282
+
283
+
284
+ def _extract_shell_snippet(tpl: str) -> str:
285
+ rendered = format_shebang("python3", tpl)
286
+ lines = rendered.splitlines()[1:] # skip #!/bin/sh
287
+ snippet: list[str] = []
288
+ for line in lines:
289
+ if line.startswith("'''"):
290
+ break
291
+ snippet.append(line)
292
+ return "\n".join(snippet)
293
+
294
+
295
+ @mark_skipif(shutil.which("shellcheck") is None, reason="Test needs shellcheck")
296
+ def test_shebang_tpl_linux() -> None:
297
+ sh = _extract_shell_snippet(SHEBANG_TPL_LINUX)
167
298
  proc = subprocess.Popen(["shellcheck", "-s", "sh", "-"], stdin=subprocess.PIPE)
299
+ assert proc.stdin is not None
168
300
  proc.stdin.write(sh.encode())
169
301
  proc.communicate()
170
302
  assert proc.returncode == 0
171
303
 
172
304
 
173
- @pytest.mark.skipif(shutil.which("shellcheck") is None, reason="Test needs shellcheck")
174
- def test_shebang_tpl_macos():
175
- sh = format_shebang("python3", SHEBANG_TPL_MACOS).split("'''")[1].strip("'")
305
+ @mark_skipif(shutil.which("shellcheck") is None, reason="Test needs shellcheck")
306
+ def test_shebang_tpl_macos() -> None:
307
+ sh = _extract_shell_snippet(SHEBANG_TPL_MACOS)
176
308
  proc = subprocess.Popen(["shellcheck", "-s", "sh", "-"], stdin=subprocess.PIPE)
309
+ assert proc.stdin is not None
177
310
  proc.stdin.write(sh.encode())
178
311
  proc.communicate()
179
312
  assert proc.returncode == 0
180
313
 
181
314
 
182
- def test_format_shebang_newline():
315
+ def test_format_shebang_newline() -> None:
183
316
  assert format_shebang("python3", SHEBANG_TPL_LINUX).endswith("\n")
184
317
 
185
318
 
186
- def test_relative_interpreter_default_location():
319
+ def test_relative_interpreter_default_location() -> None:
187
320
  assert relative_interpreter(
188
321
  "/tmp/relenv", "/tmp/relenv/bin", "/tmp/relenv/bin/python3"
189
322
  ) == pathlib.Path("..", "bin", "python3")
190
323
 
191
324
 
192
- def test_relative_interpreter_pip_dir_location():
325
+ def test_relative_interpreter_pip_dir_location() -> None:
193
326
  assert relative_interpreter(
194
327
  "/tmp/relenv", "/tmp/relenv", "/tmp/relenv/bin/python3"
195
328
  ) == pathlib.Path("bin", "python3")
196
329
 
197
330
 
198
- def test_relative_interpreter_alternate_location():
331
+ def test_relative_interpreter_alternate_location() -> None:
199
332
  assert relative_interpreter(
200
333
  "/tmp/relenv", "/tmp/relenv/bar/bin", "/tmp/relenv/bin/python3"
201
334
  ) == pathlib.Path("..", "..", "bin", "python3")
202
335
 
203
336
 
204
- def test_relative_interpreter_interpreter_not_relative_to_root():
337
+ def test_relative_interpreter_interpreter_not_relative_to_root() -> None:
205
338
  with pytest.raises(ValueError):
206
339
  relative_interpreter("/tmp/relenv", "/tmp/relenv/bar/bin", "/tmp/bin/python3")
207
340
 
208
341
 
209
- def test_relative_interpreter_scripts_not_relative_to_root():
342
+ def test_relative_interpreter_scripts_not_relative_to_root() -> None:
210
343
  with pytest.raises(ValueError):
211
344
  relative_interpreter("/tmp/relenv", "/tmp/bar/bin", "/tmp/relenv/bin/python3")
212
345
 
213
346
 
214
- def test_sanitize_sys_path():
347
+ def test_sanitize_sys_path() -> None:
215
348
  if sys.platform.startswith("win"):
216
349
  path_prefix = "C:\\"
217
350
  separator = "\\"
@@ -237,3 +370,133 @@ def test_sanitize_sys_path():
237
370
  new_sys_path = sanitize_sys_path(sys_path)
238
371
  assert new_sys_path != sys_path
239
372
  assert new_sys_path == expected
373
+
374
+
375
+ def test_version_parse_and_str() -> None:
376
+ version = Version("3.10.4")
377
+ assert version.major == 3
378
+ assert version.minor == 10
379
+ assert version.micro == 4
380
+ assert str(version) == "3.10.4"
381
+
382
+
383
+ def test_version_equality_and_hash_handles_missing_parts() -> None:
384
+ left = Version("3.10")
385
+ right = Version("3.10.0")
386
+ assert left == right
387
+ assert isinstance(hash(left), int)
388
+ assert isinstance(hash(right), int)
389
+
390
+
391
+ def test_version_comparisons() -> None:
392
+ assert Version("3.9") < Version("3.10")
393
+ assert Version("3.10.1") > Version("3.10.0")
394
+ assert Version("3.11") >= Version("3.11.0")
395
+ assert Version("3.12.2") <= Version("3.12.2")
396
+
397
+
398
+ def test_version_parse_string_too_many_parts() -> None:
399
+ with pytest.raises(RuntimeError):
400
+ Version.parse_string("1.2.3.4")
401
+
402
+
403
+ def test_work_dirs_pickle_roundtrip(tmp_path: pathlib.Path) -> None:
404
+ data_dir = tmp_path / "data"
405
+ with patch("relenv.common.DATA_DIR", data_dir):
406
+ dirs = work_dirs(tmp_path)
407
+ restored = pickle.loads(pickle.dumps(dirs))
408
+ assert restored.root == dirs.root
409
+ assert restored.toolchain == dirs.toolchain
410
+ assert restored.download == dirs.download
411
+
412
+
413
+ def test_work_dirs_with_data_dir_root(tmp_path: pathlib.Path) -> None:
414
+ data_dir = tmp_path / "data"
415
+ with patch("relenv.common.DATA_DIR", data_dir):
416
+ dirs = work_dirs(data_dir)
417
+ assert dirs.build == data_dir / "build"
418
+ assert dirs.logs == data_dir / "logs"
419
+
420
+
421
+ def test_list_archived_builds(tmp_path: pathlib.Path) -> None:
422
+ data_dir = tmp_path / "data"
423
+ build_dir = data_dir / "build"
424
+ build_dir.mkdir(parents=True)
425
+ archive = build_dir / "3.10.0-x86_64-linux-gnu.tar.xz"
426
+ archive.write_bytes(b"")
427
+ with patch("relenv.common.DATA_DIR", data_dir):
428
+ builds = list_archived_builds()
429
+ assert ("3.10.0", "x86_64", "linux-gnu") in builds
430
+
431
+
432
+ def test_addpackage_reads_paths(tmp_path: pathlib.Path) -> None:
433
+ sitedir = tmp_path
434
+ module_dir = tmp_path / "package"
435
+ module_dir.mkdir()
436
+ pth_file = sitedir / "example.pth"
437
+ pth_file.write_text(f"{module_dir.name}\n")
438
+ result = addpackage(str(sitedir), pth_file.name)
439
+ assert result == [str(module_dir.resolve())]
440
+
441
+
442
+ def test_sanitize_sys_path_with_editable_paths(tmp_path: pathlib.Path) -> None:
443
+ base = tmp_path / "base"
444
+ base.mkdir()
445
+ known_path = base / "lib"
446
+ known_path.mkdir()
447
+ editable_file = known_path / "__editable__.demo.pth"
448
+ editable_file.touch()
449
+ extra_path = str(known_path / "extra")
450
+ with patch.object(sys, "prefix", str(base)), patch.object(
451
+ sys, "base_prefix", str(base)
452
+ ), patch.dict(os.environ, {}, clear=True), patch(
453
+ "relenv.common.addpackage", return_value=[extra_path]
454
+ ):
455
+ sanitized = sanitize_sys_path([str(known_path)])
456
+ assert extra_path in sanitized
457
+
458
+
459
+ def test_makepath_oserror() -> None:
460
+ with patch("os.path.abspath", side_effect=OSError):
461
+ result, case = makepath("foo", "Bar")
462
+ expected = os.path.join("foo", "Bar")
463
+ assert result == expected
464
+ assert case == os.path.normcase(expected)
465
+
466
+
467
+ def test_copyright_headers() -> None:
468
+ """Verify all Python source files have the correct copyright header."""
469
+ expected_header = (
470
+ "# Copyright 2022-2025 Broadcom.\n" "# SPDX-License-Identifier: Apache-2.0\n"
471
+ )
472
+
473
+ # Find all Python files in relenv/ and tests/
474
+ root = MODULE_DIR.parent
475
+ python_files: list[pathlib.Path] = []
476
+ for directory in ("relenv", "tests"):
477
+ dir_path = root / directory
478
+ if dir_path.exists():
479
+ python_files.extend(dir_path.rglob("*.py"))
480
+
481
+ # Skip generated and cache files
482
+ python_files = [
483
+ f
484
+ for f in python_files
485
+ if "__pycache__" not in f.parts
486
+ and ".nox" not in f.parts
487
+ and "build" not in f.parts
488
+ ]
489
+
490
+ failures = []
491
+ for py_file in python_files:
492
+ with open(py_file, "r", encoding="utf-8") as f:
493
+ content = f.read()
494
+
495
+ if not content.startswith(expected_header):
496
+ # Read first two lines for error message
497
+ lines = content.split("\n", 2)
498
+ actual = "\n".join(lines[:2]) + "\n" if len(lines) >= 2 else content
499
+ failures.append(f"{py_file.relative_to(root)}: {actual!r}")
500
+
501
+ if failures:
502
+ pytest.fail("Files with incorrect copyright headers:\n" + "\n".join(failures))