|
| 1 | +"""Holds pip-audit's export to the flags that decide what it actually scans. |
| 2 | +
|
| 3 | +`uv export` emits the **root** project's dependencies unless asked for the whole workspace. So |
| 4 | +without `--all-packages`, a workspace member that is registered in `[tool.uv.workspace].members`, |
| 5 | +resolved into `uv.lock`, and passing every check in ci.yml still contributes nothing to the file |
| 6 | +pip-audit reads — the job goes green having looked at none of its dependencies. |
| 7 | +
|
| 8 | +Reproduced on uv 0.12.5 with a two-package workspace whose member depends on `idna`: the default |
| 9 | +export carries `packaging` (the root's dep) and no `idna`; adding `--all-packages` carries both. |
| 10 | +
|
| 11 | +Nothing fails when the flag is dropped. `members` is `[]` today, so the export is correct by |
| 12 | +coincidence rather than by construction, and the day a member lands is the day the coincidence |
| 13 | +ends — silently, since a passing scan of a smaller set looks exactly like a passing scan. |
| 14 | +
|
| 15 | +`--no-emit-workspace` is the second half. It supersedes `--no-emit-project` (uv: "Do not emit any |
| 16 | +workspace members, including the root project"), keeping first-party packages out of the audit as |
| 17 | +`--all-packages` pulls them in. Swapping it back for `--no-emit-project` would emit members into a |
| 18 | +file consumed with `--require-hashes`. |
| 19 | +""" |
| 20 | + |
| 21 | +import re |
| 22 | +import unittest |
| 23 | +from pathlib import Path |
| 24 | + |
| 25 | +# Not .resolve(): the workflow is a cross-package data dep, so it lives in the runfiles tree |
| 26 | +# beside this file rather than at the source path a resolved symlink would lead back to. |
| 27 | +_WORKFLOW = Path(__file__).parent.parent.parent / ".github" / "workflows" / "security.yml" |
| 28 | + |
| 29 | +_REQUIRED_FLAGS = ("--all-packages", "--no-emit-workspace") |
| 30 | + |
| 31 | + |
| 32 | +def export_command() -> str: |
| 33 | + """The `uv export` invocation from the pip-audit job, line continuations folded out.""" |
| 34 | + text = _WORKFLOW.read_text(encoding="utf-8") |
| 35 | + match = re.search(r"^\s*(uv export\b[\s\S]*?)(?=\n\s*- name:|\n\s*-\s+uses:)", text, re.M) |
| 36 | + if match is None: |
| 37 | + raise AssertionError("no `uv export` invocation in security.yml") |
| 38 | + return " ".join(match.group(1).replace("\\\n", " ").split()) |
| 39 | + |
| 40 | + |
| 41 | +class ExportCoverageTest(unittest.TestCase): |
| 42 | + def setUp(self): |
| 43 | + self.command = export_command() |
| 44 | + |
| 45 | + def test_export_covers_every_workspace_member(self): |
| 46 | + self.assertIn( |
| 47 | + "--all-packages", |
| 48 | + self.command, |
| 49 | + "without it `uv export` emits only the root project's dependencies, so a workspace " |
| 50 | + "member's deps are never handed to pip-audit and the job passes having skipped them", |
| 51 | + ) |
| 52 | + |
| 53 | + def test_first_party_packages_stay_out_of_the_audit(self): |
| 54 | + self.assertIn( |
| 55 | + "--no-emit-workspace", |
| 56 | + self.command, |
| 57 | + "`--all-packages` pulls members into the export; this is what keeps them from being " |
| 58 | + "emitted into a file that pip-audit consumes with --require-hashes", |
| 59 | + ) |
| 60 | + |
| 61 | + def test_the_superseded_flag_is_not_left_behind(self): |
| 62 | + """`--no-emit-project` drops only the root, so alongside `--all-packages` it is a bug.""" |
| 63 | + self.assertNotIn("--no-emit-project", self.command) |
| 64 | + |
| 65 | + def test_the_command_was_actually_read(self): |
| 66 | + """Non-vacuity guard: a regex that matched nothing would satisfy the negative test above.""" |
| 67 | + self.assertTrue(self.command.startswith("uv export "), self.command) |
| 68 | + self.assertIn("--output-file", self.command, "the export's own contract, not this test's") |
| 69 | + |
| 70 | + |
| 71 | +class WorkflowPinTest(unittest.TestCase): |
| 72 | + """The comment at the step is the only place a reader learns why the flags are there.""" |
| 73 | + |
| 74 | + def test_the_step_explains_the_flags(self): |
| 75 | + text = _WORKFLOW.read_text(encoding="utf-8") |
| 76 | + for flag in _REQUIRED_FLAGS: |
| 77 | + with self.subTest(flag=flag): |
| 78 | + # Named in prose somewhere other than the command line itself. |
| 79 | + self.assertGreater( |
| 80 | + text.count(flag), |
| 81 | + 1, |
| 82 | + f"{flag} appears only in the command; a reader deleting it finds no reason " |
| 83 | + "not to", |
| 84 | + ) |
| 85 | + |
| 86 | + |
| 87 | +if __name__ == "__main__": |
| 88 | + unittest.main() |
0 commit comments