lockstep-evidence 0.1.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ritish Saini
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,154 @@
1
+ Metadata-Version: 2.4
2
+ Name: lockstep-evidence
3
+ Version: 0.1.0
4
+ Summary: Does what's actually installed match the lockfile? Runtime dependency drift, not just a commit-time CVE scan.
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://github.com/MaXiMo000/lockstep
7
+ Project-URL: Source, https://github.com/MaXiMo000/lockstep
8
+ Project-URL: Issues, https://github.com/MaXiMo000/lockstep/issues
9
+ Project-URL: Changelog, https://github.com/MaXiMo000/lockstep/releases
10
+ Keywords: dependencies,drift,supply-chain,evidence,reliability,pip
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: System Administrators
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Topic :: System :: Systems Administration
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Dynamic: license-file
22
+
23
+ # lockstep
24
+
25
+ [![ci](https://github.com/MaXiMo000/lockstep/actions/workflows/ci.yml/badge.svg)](https://github.com/MaXiMo000/lockstep/actions/workflows/ci.yml)
26
+
27
+ **Does what's actually installed match the lockfile? Runtime dependency
28
+ drift, not a commit-time CVE scan.**
29
+
30
+ [`carabiner`](https://github.com/MaXiMo000/carabiner)'s `deps` engine
31
+ answers "does the lockfile *committed to this repo* have a known CVE" --
32
+ a question about the source tree, checked in CI. `lockstep` answers a
33
+ different one: does the environment actually *running right now* --
34
+ the container, the server, the venv someone SSHed into and patched by
35
+ hand -- still match what that lockfile says should be there. A CVE scan
36
+ at commit time can't see drift that happens after deploy; this is built
37
+ specifically to.
38
+
39
+ ```
40
+ $ lockstep check requirements.lock
41
+ [!=] 'click' is pinned to 8.0.0 but 8.1.3 is installed
42
+ [--] 'requests' is pinned to 2.31.0 but is not installed
43
+ [++] 'pip' 24.0 is installed but not declared anywhere in the lockfile
44
+
45
+ 0/3 matched, 3 drifted
46
+ ```
47
+
48
+ (Real output, from a real venv.)
49
+
50
+ ## Install
51
+
52
+ ```
53
+ pip install lockstep-evidence # the command it installs is `lockstep`
54
+ ```
55
+
56
+ (`lockstep` was already taken on PyPI -- same story as every sibling in
57
+ this portfolio.)
58
+
59
+ ## Use
60
+
61
+ Run it **inside** the environment you want to verify -- the container at
62
+ runtime, the deployed server, the venv itself -- against a lockfile:
63
+
64
+ ```
65
+ lockstep check requirements.lock
66
+ ```
67
+
68
+ It reads the *running interpreter's own* installed-package metadata
69
+ (`importlib.metadata`, stdlib, no subprocess) and compares it against the
70
+ lockfile. `--json` prints the full machine-readable report; `--quiet-matched`
71
+ hides packages that already match, showing only drift.
72
+
73
+ ## Three kinds of drift, not one
74
+
75
+ | Status | Meaning |
76
+ |---|---|
77
+ | `matched` | Installed version equals the locked version. |
78
+ | `version_mismatch` | Installed, but a different version than declared. |
79
+ | `missing` | Declared in the lockfile, not installed at all. |
80
+ | `extra` | Installed, but the lockfile says nothing about it. |
81
+
82
+ `extra` is the headline case: the hotfix someone `pip install`ed directly
83
+ on a live server that never made it back into the lockfile, silently
84
+ outliving the deploy that was supposed to make it official. A commit-time
85
+ scan of the lockfile alone can never see this -- by the time it exists,
86
+ the running environment and the committed lockfile have already diverged.
87
+
88
+ Exit code is `1` if anything drifted, `0` if every declared package
89
+ matches exactly.
90
+
91
+ ## What this assumes
92
+
93
+ **A fully-pinned lockfile** -- the output of `pip freeze`, `pip-compile`,
94
+ or `poetry export`, every direct *and transitive* dependency pinned to an
95
+ exact version. Not a hand-written `requirements.txt` listing only your
96
+ own top-level packages. If you point this at a partial file, every
97
+ transitive dependency pip installed on its own behalf (which never
98
+ appears in a partial file) reports as `extra` -- correct, technically, but
99
+ noise that drowns the real signal. That's a lockfile-completeness problem
100
+ this tool surfaces, not a bug in it.
101
+
102
+ ## Not the same question as `pip check`
103
+
104
+ `pip check` already ships with pip, and answers the obvious first
105
+ question: are the packages *currently installed* internally consistent
106
+ with each other's own declared requirements (nothing needs `foo>=2.0`
107
+ while `foo 1.0` is what's actually there)? It never reads a lockfile at
108
+ all -- it can't tell you whether reality matches what you *intended* to
109
+ have installed, only whether what's installed is self-consistent right
110
+ now. An environment can pass `pip check` cleanly while still being three
111
+ versions behind its own lockfile, or running a hotfixed package the
112
+ lockfile has never heard of -- exactly lockstep's `extra` case, above.
113
+
114
+ The two are complementary, not competing: `pip check` catches a broken
115
+ dependency graph; `lockstep check` catches drift from your own declared
116
+ source of truth. Worth running both -- neither substitutes for the
117
+ other, and `pip check` costs nothing extra since it's already there.
118
+
119
+ ## What this does NOT do
120
+
121
+ - **Python only, via `pip`/PyPI metadata.** `carabiner`'s own `deps.py`
122
+ docstring makes the same call for the same reason: "the plan called for
123
+ pip-audit, npm audit and osv-scanner... it gets one" -- one ecosystem
124
+ done correctly beats three done shallowly. npm/Cargo/etc. runtime drift
125
+ is the same idea, applied to a different package manager; not built here.
126
+ - **No CVE data, no severity, no vulnerability database.** That's
127
+ `carabiner`'s `deps` engine's job already, at commit time. This only
128
+ asks "does reality match the declaration," not "is the declared version
129
+ itself safe" -- genuinely different questions, and conflating them would
130
+ duplicate an engine that already exists and does that one well.
131
+ - **Doesn't distinguish a top-level `extra` from a transitive one.** Real
132
+ dependency-tree resolution (which package pulled in which) is
133
+ `pip-compile`-level complexity; out of scope for what's meant to be a
134
+ small, fast check.
135
+
136
+ ## Tests
137
+
138
+ ```
139
+ pip install -e .
140
+ python tests/test_names.py # PEP 503 name normalization
141
+ python tests/test_parse.py # lockfile parsing -- extras, ===, hashes, markers
142
+ python tests/test_installed.py # reads THIS interpreter's real installed packages
143
+ python tests/test_check.py # matched/version_mismatch/missing/extra classification
144
+ python tests/test_cli.py # the real CLI, against this real environment
145
+ ```
146
+
147
+ 35 tests. Several exist because testing against real `pip-compile` output
148
+ shapes (not synthetic `"package==1.2.3"` lines) caught real parsing bugs
149
+ during development: PEP 440's `===` arbitrary-equality operator being
150
+ mangled by a regex that matched `==` first and left a stray `=` on the
151
+ version, and `package[extra]==1.2.3` syntax needing to be handled
152
+ explicitly rather than assumed away.
153
+
154
+ MIT licensed.
@@ -0,0 +1,132 @@
1
+ # lockstep
2
+
3
+ [![ci](https://github.com/MaXiMo000/lockstep/actions/workflows/ci.yml/badge.svg)](https://github.com/MaXiMo000/lockstep/actions/workflows/ci.yml)
4
+
5
+ **Does what's actually installed match the lockfile? Runtime dependency
6
+ drift, not a commit-time CVE scan.**
7
+
8
+ [`carabiner`](https://github.com/MaXiMo000/carabiner)'s `deps` engine
9
+ answers "does the lockfile *committed to this repo* have a known CVE" --
10
+ a question about the source tree, checked in CI. `lockstep` answers a
11
+ different one: does the environment actually *running right now* --
12
+ the container, the server, the venv someone SSHed into and patched by
13
+ hand -- still match what that lockfile says should be there. A CVE scan
14
+ at commit time can't see drift that happens after deploy; this is built
15
+ specifically to.
16
+
17
+ ```
18
+ $ lockstep check requirements.lock
19
+ [!=] 'click' is pinned to 8.0.0 but 8.1.3 is installed
20
+ [--] 'requests' is pinned to 2.31.0 but is not installed
21
+ [++] 'pip' 24.0 is installed but not declared anywhere in the lockfile
22
+
23
+ 0/3 matched, 3 drifted
24
+ ```
25
+
26
+ (Real output, from a real venv.)
27
+
28
+ ## Install
29
+
30
+ ```
31
+ pip install lockstep-evidence # the command it installs is `lockstep`
32
+ ```
33
+
34
+ (`lockstep` was already taken on PyPI -- same story as every sibling in
35
+ this portfolio.)
36
+
37
+ ## Use
38
+
39
+ Run it **inside** the environment you want to verify -- the container at
40
+ runtime, the deployed server, the venv itself -- against a lockfile:
41
+
42
+ ```
43
+ lockstep check requirements.lock
44
+ ```
45
+
46
+ It reads the *running interpreter's own* installed-package metadata
47
+ (`importlib.metadata`, stdlib, no subprocess) and compares it against the
48
+ lockfile. `--json` prints the full machine-readable report; `--quiet-matched`
49
+ hides packages that already match, showing only drift.
50
+
51
+ ## Three kinds of drift, not one
52
+
53
+ | Status | Meaning |
54
+ |---|---|
55
+ | `matched` | Installed version equals the locked version. |
56
+ | `version_mismatch` | Installed, but a different version than declared. |
57
+ | `missing` | Declared in the lockfile, not installed at all. |
58
+ | `extra` | Installed, but the lockfile says nothing about it. |
59
+
60
+ `extra` is the headline case: the hotfix someone `pip install`ed directly
61
+ on a live server that never made it back into the lockfile, silently
62
+ outliving the deploy that was supposed to make it official. A commit-time
63
+ scan of the lockfile alone can never see this -- by the time it exists,
64
+ the running environment and the committed lockfile have already diverged.
65
+
66
+ Exit code is `1` if anything drifted, `0` if every declared package
67
+ matches exactly.
68
+
69
+ ## What this assumes
70
+
71
+ **A fully-pinned lockfile** -- the output of `pip freeze`, `pip-compile`,
72
+ or `poetry export`, every direct *and transitive* dependency pinned to an
73
+ exact version. Not a hand-written `requirements.txt` listing only your
74
+ own top-level packages. If you point this at a partial file, every
75
+ transitive dependency pip installed on its own behalf (which never
76
+ appears in a partial file) reports as `extra` -- correct, technically, but
77
+ noise that drowns the real signal. That's a lockfile-completeness problem
78
+ this tool surfaces, not a bug in it.
79
+
80
+ ## Not the same question as `pip check`
81
+
82
+ `pip check` already ships with pip, and answers the obvious first
83
+ question: are the packages *currently installed* internally consistent
84
+ with each other's own declared requirements (nothing needs `foo>=2.0`
85
+ while `foo 1.0` is what's actually there)? It never reads a lockfile at
86
+ all -- it can't tell you whether reality matches what you *intended* to
87
+ have installed, only whether what's installed is self-consistent right
88
+ now. An environment can pass `pip check` cleanly while still being three
89
+ versions behind its own lockfile, or running a hotfixed package the
90
+ lockfile has never heard of -- exactly lockstep's `extra` case, above.
91
+
92
+ The two are complementary, not competing: `pip check` catches a broken
93
+ dependency graph; `lockstep check` catches drift from your own declared
94
+ source of truth. Worth running both -- neither substitutes for the
95
+ other, and `pip check` costs nothing extra since it's already there.
96
+
97
+ ## What this does NOT do
98
+
99
+ - **Python only, via `pip`/PyPI metadata.** `carabiner`'s own `deps.py`
100
+ docstring makes the same call for the same reason: "the plan called for
101
+ pip-audit, npm audit and osv-scanner... it gets one" -- one ecosystem
102
+ done correctly beats three done shallowly. npm/Cargo/etc. runtime drift
103
+ is the same idea, applied to a different package manager; not built here.
104
+ - **No CVE data, no severity, no vulnerability database.** That's
105
+ `carabiner`'s `deps` engine's job already, at commit time. This only
106
+ asks "does reality match the declaration," not "is the declared version
107
+ itself safe" -- genuinely different questions, and conflating them would
108
+ duplicate an engine that already exists and does that one well.
109
+ - **Doesn't distinguish a top-level `extra` from a transitive one.** Real
110
+ dependency-tree resolution (which package pulled in which) is
111
+ `pip-compile`-level complexity; out of scope for what's meant to be a
112
+ small, fast check.
113
+
114
+ ## Tests
115
+
116
+ ```
117
+ pip install -e .
118
+ python tests/test_names.py # PEP 503 name normalization
119
+ python tests/test_parse.py # lockfile parsing -- extras, ===, hashes, markers
120
+ python tests/test_installed.py # reads THIS interpreter's real installed packages
121
+ python tests/test_check.py # matched/version_mismatch/missing/extra classification
122
+ python tests/test_cli.py # the real CLI, against this real environment
123
+ ```
124
+
125
+ 35 tests. Several exist because testing against real `pip-compile` output
126
+ shapes (not synthetic `"package==1.2.3"` lines) caught real parsing bugs
127
+ during development: PEP 440's `===` arbitrary-equality operator being
128
+ mangled by a regex that matched `==` first and left a stray `=` on the
129
+ version, and `package[extra]==1.2.3` syntax needing to be handled
130
+ explicitly rather than assumed away.
131
+
132
+ MIT licensed.
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,52 @@
1
+ """Compare a lockfile's pinned versions against what's actually installed.
2
+
3
+ Three distinct kinds of drift, not one blended "different" bucket, because
4
+ each implies a different fix: a version_mismatch means re-pin or
5
+ reinstall; missing means something never got installed; extra means an
6
+ undeclared package is present -- the case this tool exists for, the patch
7
+ someone installed by hand on a live server that never made it back into
8
+ the lockfile.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ MATCHED, VERSION_MISMATCH, MISSING, EXTRA = "matched", "version_mismatch", "missing", "extra"
13
+
14
+
15
+ def check_drift(lockfile: dict, installed: dict[str, str]) -> list[dict]:
16
+ pinned = lockfile["pinned"]
17
+ results = []
18
+
19
+ for name in sorted(pinned):
20
+ locked_version = pinned[name]
21
+ actual_version = installed.get(name)
22
+ if actual_version is None:
23
+ results.append({
24
+ "name": name, "status": MISSING,
25
+ "locked_version": locked_version, "installed_version": None,
26
+ "detail": f"'{name}' is pinned to {locked_version} but is not installed",
27
+ })
28
+ elif actual_version != locked_version:
29
+ results.append({
30
+ "name": name, "status": VERSION_MISMATCH,
31
+ "locked_version": locked_version, "installed_version": actual_version,
32
+ "detail": (f"'{name}' is pinned to {locked_version} but "
33
+ f"{actual_version} is installed"),
34
+ })
35
+ else:
36
+ results.append({
37
+ "name": name, "status": MATCHED,
38
+ "locked_version": locked_version, "installed_version": actual_version,
39
+ "detail": f"'{name}' matches its locked version {locked_version}",
40
+ })
41
+
42
+ declared = set(pinned) | set(lockfile.get("unpinned", ()))
43
+ for name in sorted(installed):
44
+ if name not in declared:
45
+ results.append({
46
+ "name": name, "status": EXTRA,
47
+ "locked_version": None, "installed_version": installed[name],
48
+ "detail": (f"'{name}' {installed[name]} is installed but not declared "
49
+ "anywhere in the lockfile"),
50
+ })
51
+
52
+ return results
@@ -0,0 +1,54 @@
1
+ """lockstep check <lockfile> [--json] [--quiet-matched]"""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import pathlib
7
+ import sys
8
+
9
+ from .check import EXTRA, MATCHED, MISSING, VERSION_MISMATCH, check_drift
10
+ from .installed import installed_packages
11
+ from .parse import parse_lockfile
12
+
13
+ _TAG = {MATCHED: "OK", VERSION_MISMATCH: "!=", MISSING: "--", EXTRA: "++"}
14
+
15
+
16
+ def main(argv: list[str] | None = None) -> int:
17
+ parser = argparse.ArgumentParser(prog="lockstep")
18
+ sub = parser.add_subparsers(dest="command", required=True)
19
+
20
+ check_p = sub.add_parser(
21
+ "check", help="compare this environment's installed packages against a pinned lockfile")
22
+ check_p.add_argument("lockfile", help="a fully-pinned lockfile -- see README's \"What this assumes\"")
23
+ check_p.add_argument("--json", action="store_true", help="print the full report as JSON")
24
+ check_p.add_argument("--quiet-matched", action="store_true",
25
+ help="only print drift, not every package that already matches")
26
+
27
+ args = parser.parse_args(argv)
28
+
29
+ try:
30
+ text = pathlib.Path(args.lockfile).read_text(encoding="utf-8")
31
+ except OSError as exc:
32
+ sys.exit(f"lockstep: {exc}")
33
+
34
+ lockfile = parse_lockfile(text)
35
+ results = check_drift(lockfile, installed_packages())
36
+
37
+ if args.json:
38
+ print(json.dumps(results, indent=2))
39
+ else:
40
+ for r in results:
41
+ if args.quiet_matched and r["status"] == MATCHED:
42
+ continue
43
+ print(f"[{_TAG[r['status']]}] {r['detail']}")
44
+ n_drift = sum(1 for r in results if r["status"] != MATCHED)
45
+ summary = f"{len(results) - n_drift}/{len(results)} matched"
46
+ if n_drift:
47
+ summary += f", {n_drift} drifted"
48
+ print(f"\n{summary}")
49
+
50
+ return 1 if any(r["status"] != MATCHED for r in results) else 0
51
+
52
+
53
+ if __name__ == "__main__":
54
+ raise SystemExit(main())
@@ -0,0 +1,23 @@
1
+ """What's actually installed in the running Python environment, right now.
2
+
3
+ Not a second parse of anything -- `importlib.metadata` is the interpreter's
4
+ own view of itself, stdlib, no subprocess needed. Run `lockstep check`
5
+ *inside* the environment you want to verify (the container, the venv, the
6
+ deployed server) so this reflects that environment, not the one lockstep
7
+ itself happens to be installed in.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from importlib import metadata
12
+
13
+ from .names import normalize
14
+
15
+
16
+ def installed_packages() -> dict[str, str]:
17
+ result: dict[str, str] = {}
18
+ for dist in metadata.distributions():
19
+ name = dist.metadata.get("Name")
20
+ if not name:
21
+ continue # a distribution with no name in its own metadata -- skip, not a crash
22
+ result[normalize(name)] = dist.version
23
+ return result
@@ -0,0 +1,16 @@
1
+ """PEP 503 name normalization: pip, PyPI, and importlib.metadata all agree
2
+ that "Flask-Login", "flask_login", and "FLASK.LOGIN" name the same
3
+ distribution. Comparing a lockfile's spelling against the installed
4
+ environment's spelling without this would report real matches as drift
5
+ purely from formatting, which is worse than missing a real one -- it
6
+ teaches whoever runs this to stop trusting the output.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import re
11
+
12
+ _SEPARATORS = re.compile(r"[-_.]+")
13
+
14
+
15
+ def normalize(name: str) -> str:
16
+ return _SEPARATORS.sub("-", name).strip().lower()
@@ -0,0 +1,67 @@
1
+ """Parse a pinned requirements.txt-style lockfile into
2
+ {"pinned": {name: version}, "unpinned": [name, ...]}.
3
+
4
+ Only an exact pin (`package==1.2.3`) declares a single version runtime
5
+ could drift from -- a range (`package>=1.0`) or a bare name doesn't, so
6
+ those are recorded separately as "unpinned" rather than silently ignored
7
+ (which would make them read as untracked "extra" packages later) or
8
+ treated as a false pinned match.
9
+
10
+ lockstep assumes a **fully-pinned** lockfile -- the output of `pip freeze`,
11
+ `pip-compile`, or `poetry export`, every direct and transitive dependency
12
+ pinned -- not a hand-written requirements.txt listing only top-level
13
+ packages. See README's "What this assumes" for why: pip installs
14
+ transitive dependencies that never appear in a partial file, and every one
15
+ of them would otherwise report as "extra" for no reason but the lockfile
16
+ being incomplete.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import re
21
+
22
+ from .names import normalize
23
+
24
+ # Extras (`requests[security]`) and environment markers (`; python_version
25
+ # >= "3.8"`) are both real, common syntax in a real requirements file --
26
+ # found by testing this against actual pip-compile output, not synthetic
27
+ # "package==1.2.3" lines alone.
28
+ _PIN = re.compile(
29
+ r'^([A-Za-z0-9][A-Za-z0-9._-]*)' # name
30
+ r'(?:\[[^\]]*\])?' # optional extras, e.g. [security]
31
+ r'\s*(?:===|==)\s*' # PEP 440 arbitrary-equality (===) tried
32
+ r'([^\s;#]+)' # first, or == would eat two of its three '='
33
+ )
34
+ _RANGE_OP = re.compile(
35
+ r'^([A-Za-z0-9][A-Za-z0-9._-]*)(?:\[[^\]]*\])?\s*(?:>=|<=|!=|~=|>|<)'
36
+ )
37
+ _BARE_NAME = re.compile(r'^([A-Za-z0-9][A-Za-z0-9._-]*)(?:\[[^\]]*\])?\s*$')
38
+
39
+
40
+ def parse_lockfile(text: str) -> dict:
41
+ pinned: dict[str, str] = {}
42
+ unpinned: list[str] = []
43
+
44
+ for raw_line in text.splitlines():
45
+ # Environment markers and inline comments both trail after the
46
+ # version; strip the comment first so a marker doesn't hide behind
47
+ # it, then let each pattern's own [^\s;#]+ stop at a marker.
48
+ line = raw_line.split("#", 1)[0].strip()
49
+ if not line or line.startswith("-"):
50
+ continue # blank, or a pip option line (-r other.txt, --index-url ...)
51
+
52
+ m = _PIN.match(line)
53
+ if m:
54
+ name, version = m.groups()
55
+ pinned[normalize(name)] = version
56
+ continue
57
+
58
+ m = _RANGE_OP.match(line)
59
+ if m:
60
+ unpinned.append(normalize(m.group(1)))
61
+ continue
62
+
63
+ m = _BARE_NAME.match(line)
64
+ if m:
65
+ unpinned.append(normalize(m.group(1)))
66
+
67
+ return {"pinned": pinned, "unpinned": unpinned}
@@ -0,0 +1,154 @@
1
+ Metadata-Version: 2.4
2
+ Name: lockstep-evidence
3
+ Version: 0.1.0
4
+ Summary: Does what's actually installed match the lockfile? Runtime dependency drift, not just a commit-time CVE scan.
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://github.com/MaXiMo000/lockstep
7
+ Project-URL: Source, https://github.com/MaXiMo000/lockstep
8
+ Project-URL: Issues, https://github.com/MaXiMo000/lockstep/issues
9
+ Project-URL: Changelog, https://github.com/MaXiMo000/lockstep/releases
10
+ Keywords: dependencies,drift,supply-chain,evidence,reliability,pip
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: System Administrators
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Topic :: System :: Systems Administration
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Dynamic: license-file
22
+
23
+ # lockstep
24
+
25
+ [![ci](https://github.com/MaXiMo000/lockstep/actions/workflows/ci.yml/badge.svg)](https://github.com/MaXiMo000/lockstep/actions/workflows/ci.yml)
26
+
27
+ **Does what's actually installed match the lockfile? Runtime dependency
28
+ drift, not a commit-time CVE scan.**
29
+
30
+ [`carabiner`](https://github.com/MaXiMo000/carabiner)'s `deps` engine
31
+ answers "does the lockfile *committed to this repo* have a known CVE" --
32
+ a question about the source tree, checked in CI. `lockstep` answers a
33
+ different one: does the environment actually *running right now* --
34
+ the container, the server, the venv someone SSHed into and patched by
35
+ hand -- still match what that lockfile says should be there. A CVE scan
36
+ at commit time can't see drift that happens after deploy; this is built
37
+ specifically to.
38
+
39
+ ```
40
+ $ lockstep check requirements.lock
41
+ [!=] 'click' is pinned to 8.0.0 but 8.1.3 is installed
42
+ [--] 'requests' is pinned to 2.31.0 but is not installed
43
+ [++] 'pip' 24.0 is installed but not declared anywhere in the lockfile
44
+
45
+ 0/3 matched, 3 drifted
46
+ ```
47
+
48
+ (Real output, from a real venv.)
49
+
50
+ ## Install
51
+
52
+ ```
53
+ pip install lockstep-evidence # the command it installs is `lockstep`
54
+ ```
55
+
56
+ (`lockstep` was already taken on PyPI -- same story as every sibling in
57
+ this portfolio.)
58
+
59
+ ## Use
60
+
61
+ Run it **inside** the environment you want to verify -- the container at
62
+ runtime, the deployed server, the venv itself -- against a lockfile:
63
+
64
+ ```
65
+ lockstep check requirements.lock
66
+ ```
67
+
68
+ It reads the *running interpreter's own* installed-package metadata
69
+ (`importlib.metadata`, stdlib, no subprocess) and compares it against the
70
+ lockfile. `--json` prints the full machine-readable report; `--quiet-matched`
71
+ hides packages that already match, showing only drift.
72
+
73
+ ## Three kinds of drift, not one
74
+
75
+ | Status | Meaning |
76
+ |---|---|
77
+ | `matched` | Installed version equals the locked version. |
78
+ | `version_mismatch` | Installed, but a different version than declared. |
79
+ | `missing` | Declared in the lockfile, not installed at all. |
80
+ | `extra` | Installed, but the lockfile says nothing about it. |
81
+
82
+ `extra` is the headline case: the hotfix someone `pip install`ed directly
83
+ on a live server that never made it back into the lockfile, silently
84
+ outliving the deploy that was supposed to make it official. A commit-time
85
+ scan of the lockfile alone can never see this -- by the time it exists,
86
+ the running environment and the committed lockfile have already diverged.
87
+
88
+ Exit code is `1` if anything drifted, `0` if every declared package
89
+ matches exactly.
90
+
91
+ ## What this assumes
92
+
93
+ **A fully-pinned lockfile** -- the output of `pip freeze`, `pip-compile`,
94
+ or `poetry export`, every direct *and transitive* dependency pinned to an
95
+ exact version. Not a hand-written `requirements.txt` listing only your
96
+ own top-level packages. If you point this at a partial file, every
97
+ transitive dependency pip installed on its own behalf (which never
98
+ appears in a partial file) reports as `extra` -- correct, technically, but
99
+ noise that drowns the real signal. That's a lockfile-completeness problem
100
+ this tool surfaces, not a bug in it.
101
+
102
+ ## Not the same question as `pip check`
103
+
104
+ `pip check` already ships with pip, and answers the obvious first
105
+ question: are the packages *currently installed* internally consistent
106
+ with each other's own declared requirements (nothing needs `foo>=2.0`
107
+ while `foo 1.0` is what's actually there)? It never reads a lockfile at
108
+ all -- it can't tell you whether reality matches what you *intended* to
109
+ have installed, only whether what's installed is self-consistent right
110
+ now. An environment can pass `pip check` cleanly while still being three
111
+ versions behind its own lockfile, or running a hotfixed package the
112
+ lockfile has never heard of -- exactly lockstep's `extra` case, above.
113
+
114
+ The two are complementary, not competing: `pip check` catches a broken
115
+ dependency graph; `lockstep check` catches drift from your own declared
116
+ source of truth. Worth running both -- neither substitutes for the
117
+ other, and `pip check` costs nothing extra since it's already there.
118
+
119
+ ## What this does NOT do
120
+
121
+ - **Python only, via `pip`/PyPI metadata.** `carabiner`'s own `deps.py`
122
+ docstring makes the same call for the same reason: "the plan called for
123
+ pip-audit, npm audit and osv-scanner... it gets one" -- one ecosystem
124
+ done correctly beats three done shallowly. npm/Cargo/etc. runtime drift
125
+ is the same idea, applied to a different package manager; not built here.
126
+ - **No CVE data, no severity, no vulnerability database.** That's
127
+ `carabiner`'s `deps` engine's job already, at commit time. This only
128
+ asks "does reality match the declaration," not "is the declared version
129
+ itself safe" -- genuinely different questions, and conflating them would
130
+ duplicate an engine that already exists and does that one well.
131
+ - **Doesn't distinguish a top-level `extra` from a transitive one.** Real
132
+ dependency-tree resolution (which package pulled in which) is
133
+ `pip-compile`-level complexity; out of scope for what's meant to be a
134
+ small, fast check.
135
+
136
+ ## Tests
137
+
138
+ ```
139
+ pip install -e .
140
+ python tests/test_names.py # PEP 503 name normalization
141
+ python tests/test_parse.py # lockfile parsing -- extras, ===, hashes, markers
142
+ python tests/test_installed.py # reads THIS interpreter's real installed packages
143
+ python tests/test_check.py # matched/version_mismatch/missing/extra classification
144
+ python tests/test_cli.py # the real CLI, against this real environment
145
+ ```
146
+
147
+ 35 tests. Several exist because testing against real `pip-compile` output
148
+ shapes (not synthetic `"package==1.2.3"` lines) caught real parsing bugs
149
+ during development: PEP 440's `===` arbitrary-equality operator being
150
+ mangled by a regex that matched `==` first and left a stray `=` on the
151
+ version, and `package[extra]==1.2.3` syntax needing to be handled
152
+ explicitly rather than assumed away.
153
+
154
+ MIT licensed.
@@ -0,0 +1,19 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ lockstep/__init__.py
5
+ lockstep/check.py
6
+ lockstep/cli.py
7
+ lockstep/installed.py
8
+ lockstep/names.py
9
+ lockstep/parse.py
10
+ lockstep_evidence.egg-info/PKG-INFO
11
+ lockstep_evidence.egg-info/SOURCES.txt
12
+ lockstep_evidence.egg-info/dependency_links.txt
13
+ lockstep_evidence.egg-info/entry_points.txt
14
+ lockstep_evidence.egg-info/top_level.txt
15
+ tests/test_check.py
16
+ tests/test_cli.py
17
+ tests/test_installed.py
18
+ tests/test_names.py
19
+ tests/test_parse.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ lockstep = lockstep.cli:main
@@ -0,0 +1,39 @@
1
+ [project]
2
+ # "lockstep" was already taken on PyPI, same story as every sibling in
3
+ # this portfolio. The installed command stays the short name --
4
+ # python-dateutil installs `dateutil`, this installs `lockstep`.
5
+ name = "lockstep-evidence"
6
+ version = "0.1.0"
7
+ description = "Does what's actually installed match the lockfile? Runtime dependency drift, not just a commit-time CVE scan."
8
+ requires-python = ">=3.10"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ keywords = ["dependencies", "drift", "supply-chain", "evidence", "reliability", "pip"]
13
+ classifiers = [
14
+ "Development Status :: 3 - Alpha",
15
+ "Environment :: Console",
16
+ "Intended Audience :: Developers",
17
+ "Intended Audience :: System Administrators",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3",
20
+ "Topic :: System :: Systems Administration",
21
+ ]
22
+ # Stdlib only -- importlib.metadata reads the running interpreter's own
23
+ # installed-package data directly, no subprocess or extra parser needed.
24
+ dependencies = []
25
+
26
+ urls.Homepage = "https://github.com/MaXiMo000/lockstep"
27
+ urls.Source = "https://github.com/MaXiMo000/lockstep"
28
+ urls.Issues = "https://github.com/MaXiMo000/lockstep/issues"
29
+ urls.Changelog = "https://github.com/MaXiMo000/lockstep/releases"
30
+
31
+ [project.scripts]
32
+ lockstep = "lockstep.cli:main"
33
+
34
+ [build-system]
35
+ requires = ["setuptools>=77"]
36
+ build-backend = "setuptools.build_meta"
37
+
38
+ [tool.setuptools.packages.find]
39
+ include = ["lockstep*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,84 @@
1
+ """Run: python tests/test_check.py"""
2
+ from __future__ import annotations
3
+
4
+ import pathlib
5
+ import sys
6
+ import unittest
7
+
8
+ sys.path.insert(0, str(pathlib.Path(__file__).parent.parent))
9
+
10
+ from lockstep.check import EXTRA, MATCHED, MISSING, VERSION_MISMATCH, check_drift
11
+
12
+
13
+ class TestCheckDrift(unittest.TestCase):
14
+ def test_exact_match(self):
15
+ lockfile = {"pinned": {"requests": "2.31.0"}, "unpinned": []}
16
+ installed = {"requests": "2.31.0"}
17
+ results = check_drift(lockfile, installed)
18
+ self.assertEqual(results, [{
19
+ "name": "requests", "status": MATCHED,
20
+ "locked_version": "2.31.0", "installed_version": "2.31.0",
21
+ "detail": "'requests' matches its locked version 2.31.0",
22
+ }])
23
+
24
+ def test_version_mismatch(self):
25
+ lockfile = {"pinned": {"requests": "2.31.0"}, "unpinned": []}
26
+ installed = {"requests": "2.28.0"}
27
+ results = check_drift(lockfile, installed)
28
+ self.assertEqual(results[0]["status"], VERSION_MISMATCH)
29
+ self.assertEqual(results[0]["installed_version"], "2.28.0")
30
+
31
+ def test_pinned_but_not_installed_is_missing(self):
32
+ lockfile = {"pinned": {"requests": "2.31.0"}, "unpinned": []}
33
+ results = check_drift(lockfile, installed={})
34
+ self.assertEqual(results[0]["status"], MISSING)
35
+ self.assertIsNone(results[0]["installed_version"])
36
+
37
+ def test_installed_but_undeclared_is_extra(self):
38
+ """The headline case this tool exists for: a package present on
39
+ disk that the lockfile says nothing about at all."""
40
+ lockfile = {"pinned": {}, "unpinned": []}
41
+ installed = {"some-hotfix-package": "0.0.1"}
42
+ results = check_drift(lockfile, installed)
43
+ self.assertEqual(results[0]["status"], EXTRA)
44
+ self.assertEqual(results[0]["installed_version"], "0.0.1")
45
+
46
+ def test_unpinned_declared_package_is_not_reported_as_extra(self):
47
+ """A range/bare-name entry in the lockfile still counts as
48
+ 'declared' for the extra check, even though it has no version to
49
+ compare against for drift."""
50
+ lockfile = {"pinned": {}, "unpinned": ["click"]}
51
+ installed = {"click": "8.1.3"}
52
+ results = check_drift(lockfile, installed)
53
+ self.assertEqual(results, [])
54
+
55
+ def test_names_normalized_on_both_sides_still_match(self):
56
+ lockfile = {"pinned": {"flask-login": "0.6.2"}, "unpinned": []}
57
+ installed = {"flask-login": "0.6.2"} # already normalized, as installed_packages() gives it
58
+ results = check_drift(lockfile, installed)
59
+ self.assertEqual(results[0]["status"], MATCHED)
60
+
61
+ def test_results_are_sorted_by_name_deterministically(self):
62
+ lockfile = {"pinned": {"zeta": "1.0", "alpha": "1.0"}, "unpinned": []}
63
+ installed = {"zeta": "1.0", "alpha": "1.0"}
64
+ results = check_drift(lockfile, installed)
65
+ self.assertEqual([r["name"] for r in results], ["alpha", "zeta"])
66
+
67
+ def test_mixed_real_scenario(self):
68
+ lockfile = {"pinned": {"requests": "2.31.0", "click": "8.1.3"}, "unpinned": ["numpy"]}
69
+ installed = {
70
+ "requests": "2.31.0", # matched
71
+ "click": "8.0.0", # version_mismatch
72
+ "numpy": "1.24.0", # declared unpinned, no drift possible
73
+ "sneaky-hotfix": "0.1.0", # extra
74
+ # "flask" (if it were pinned) would be missing -- not in this scenario
75
+ }
76
+ results = check_drift(lockfile, installed)
77
+ by_name = {r["name"]: r["status"] for r in results}
78
+ self.assertEqual(by_name, {
79
+ "requests": MATCHED, "click": VERSION_MISMATCH, "sneaky-hotfix": EXTRA,
80
+ })
81
+
82
+
83
+ if __name__ == "__main__":
84
+ unittest.main()
@@ -0,0 +1,92 @@
1
+ """Run: python tests/test_cli.py
2
+
3
+ Exercises the real CLI entry point against the real, currently-running
4
+ Python environment -- not mocked installed-package data -- so this proves
5
+ the whole pipeline (read file -> parse -> introspect this interpreter ->
6
+ diff -> report) actually works end to end.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import contextlib
11
+ import io
12
+ import json
13
+ import pathlib
14
+ import sys
15
+ import tempfile
16
+ import unittest
17
+
18
+ sys.path.insert(0, str(pathlib.Path(__file__).parent.parent))
19
+
20
+ from lockstep.cli import main
21
+ from lockstep.installed import installed_packages
22
+
23
+
24
+ class TestCli(unittest.TestCase):
25
+ def setUp(self):
26
+ self.tmp = tempfile.TemporaryDirectory()
27
+ self.lockfile = pathlib.Path(self.tmp.name) / "requirements.lock"
28
+
29
+ def tearDown(self):
30
+ self.tmp.cleanup()
31
+
32
+ def test_correctly_pinned_real_package_matches(self):
33
+ """pip is installed in this real environment -- pin it to its
34
+ actual real version and lockstep should say it matches. Checked via
35
+ --json against pip's specific entry, not the overall exit code:
36
+ this test runs in a real, un-isolated dev environment with many
37
+ other installed packages a one-line lockfile never declares, which
38
+ correctly makes the *overall* result drift (see "What this
39
+ assumes" in README) -- that's real, intended behavior, not
40
+ something to work around by asserting a global pass here."""
41
+ pip_version = installed_packages()["pip"]
42
+ self.lockfile.write_text(f"pip=={pip_version}\n")
43
+ buf = io.StringIO()
44
+ with contextlib.redirect_stdout(buf):
45
+ main(["check", str(self.lockfile), "--json"])
46
+ report = json.loads(buf.getvalue())
47
+ pip_entry = next(r for r in report if r["name"] == "pip")
48
+ self.assertEqual(pip_entry["status"], "matched")
49
+
50
+ def test_wrong_pinned_version_of_a_real_package_is_drift(self):
51
+ self.lockfile.write_text("pip==0.0.1\n") # almost certainly not what's really installed
52
+ buf = io.StringIO()
53
+ with contextlib.redirect_stdout(buf):
54
+ code = main(["check", str(self.lockfile)])
55
+ self.assertEqual(code, 1)
56
+ self.assertIn("[!=]", buf.getvalue())
57
+
58
+ def test_a_package_that_cannot_exist_is_missing(self):
59
+ self.lockfile.write_text("this-package-definitely-does-not-exist-xyz==1.0.0\n")
60
+ buf = io.StringIO()
61
+ with contextlib.redirect_stdout(buf):
62
+ code = main(["check", str(self.lockfile)])
63
+ self.assertEqual(code, 1)
64
+ self.assertIn("[--]", buf.getvalue())
65
+
66
+ def test_quiet_matched_hides_ok_rows_but_keeps_drift(self):
67
+ pip_version = installed_packages()["pip"]
68
+ self.lockfile.write_text(f"pip=={pip_version}\nthis-does-not-exist-xyz==1.0\n")
69
+ buf = io.StringIO()
70
+ with contextlib.redirect_stdout(buf):
71
+ main(["check", str(self.lockfile), "--quiet-matched"])
72
+ self.assertNotIn("[OK]", buf.getvalue())
73
+ self.assertIn("[--]", buf.getvalue())
74
+
75
+ def test_missing_lockfile_is_a_clean_error_not_a_traceback(self):
76
+ with self.assertRaises(SystemExit) as ctx:
77
+ main(["check", str(self.lockfile)]) # never written
78
+ self.assertIn("lockstep:", str(ctx.exception))
79
+
80
+ def test_json_flag_prints_the_full_report(self):
81
+ pip_version = installed_packages()["pip"]
82
+ self.lockfile.write_text(f"pip=={pip_version}\n")
83
+ buf = io.StringIO()
84
+ with contextlib.redirect_stdout(buf):
85
+ main(["check", str(self.lockfile), "--json"])
86
+ report = json.loads(buf.getvalue())
87
+ self.assertEqual(report[0]["name"], "pip")
88
+ self.assertEqual(report[0]["status"], "matched")
89
+
90
+
91
+ if __name__ == "__main__":
92
+ unittest.main()
@@ -0,0 +1,42 @@
1
+ """Run: python tests/test_installed.py
2
+
3
+ Exercises the real importlib.metadata of the interpreter running this
4
+ test -- not a mock -- so this proves installed_packages() actually reads
5
+ real package metadata correctly, not just that it calls the right stdlib
6
+ function.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import pathlib
11
+ import sys
12
+ import unittest
13
+
14
+ sys.path.insert(0, str(pathlib.Path(__file__).parent.parent))
15
+
16
+ from lockstep.installed import installed_packages
17
+
18
+
19
+ class TestInstalledPackages(unittest.TestCase):
20
+ def test_pip_itself_is_found(self):
21
+ """pip is installed in essentially every real Python environment
22
+ this tool will ever run in -- a safe, real package to assert on
23
+ rather than a hypothetical one."""
24
+ packages = installed_packages()
25
+ self.assertIn("pip", packages)
26
+ self.assertTrue(packages["pip"]) # a non-empty version string
27
+
28
+ def test_all_keys_are_normalized(self):
29
+ packages = installed_packages()
30
+ for name in packages:
31
+ self.assertEqual(name, name.lower())
32
+ self.assertNotIn("_", name)
33
+
34
+ def test_returns_a_real_nonempty_mapping(self):
35
+ packages = installed_packages()
36
+ self.assertGreater(len(packages), 0)
37
+ for version in packages.values():
38
+ self.assertIsInstance(version, str)
39
+
40
+
41
+ if __name__ == "__main__":
42
+ unittest.main()
@@ -0,0 +1,29 @@
1
+ """Run: python tests/test_names.py"""
2
+ from __future__ import annotations
3
+
4
+ import pathlib
5
+ import sys
6
+ import unittest
7
+
8
+ sys.path.insert(0, str(pathlib.Path(__file__).parent.parent))
9
+
10
+ from lockstep.names import normalize
11
+
12
+
13
+ class TestNormalize(unittest.TestCase):
14
+ def test_dashes_underscores_dots_are_equivalent(self):
15
+ self.assertEqual(normalize("Flask-Login"), normalize("flask_login"))
16
+ self.assertEqual(normalize("flask_login"), normalize("flask.login"))
17
+
18
+ def test_case_insensitive(self):
19
+ self.assertEqual(normalize("Requests"), normalize("REQUESTS"))
20
+
21
+ def test_repeated_separators_collapse(self):
22
+ self.assertEqual(normalize("foo__bar"), normalize("foo-bar"))
23
+
24
+ def test_all_together(self):
25
+ self.assertEqual(normalize("Foo_Bar.Baz"), "foo-bar-baz")
26
+
27
+
28
+ if __name__ == "__main__":
29
+ unittest.main()
@@ -0,0 +1,102 @@
1
+ """Run: python tests/test_parse.py
2
+
3
+ Tested against real pip-compile/pip-freeze output shapes, not just
4
+ synthetic "package==1.2.3" lines -- two real bugs (the === operator, and
5
+ extras syntax) were found exactly this way while building this module.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import pathlib
10
+ import sys
11
+ import unittest
12
+
13
+ sys.path.insert(0, str(pathlib.Path(__file__).parent.parent))
14
+
15
+ from lockstep.parse import parse_lockfile
16
+
17
+
18
+ class TestParseLockfile(unittest.TestCase):
19
+ def test_simple_pin(self):
20
+ result = parse_lockfile("requests==2.31.0\n")
21
+ self.assertEqual(result["pinned"], {"requests": "2.31.0"})
22
+ self.assertEqual(result["unpinned"], [])
23
+
24
+ def test_names_are_normalized(self):
25
+ result = parse_lockfile("Flask-Login==0.6.2\n")
26
+ self.assertEqual(result["pinned"], {"flask-login": "0.6.2"})
27
+
28
+ def test_blank_lines_and_comments_are_skipped(self):
29
+ result = parse_lockfile("\n# a comment\n\nrequests==2.31.0\n")
30
+ self.assertEqual(result["pinned"], {"requests": "2.31.0"})
31
+
32
+ def test_inline_comment_after_a_pin(self):
33
+ result = parse_lockfile("requests==2.31.0 # via -r requirements.in\n")
34
+ self.assertEqual(result["pinned"], {"requests": "2.31.0"})
35
+
36
+ def test_pip_option_lines_are_skipped(self):
37
+ text = "-r base.txt\n--index-url https://pypi.org/simple\nrequests==2.31.0\n"
38
+ result = parse_lockfile(text)
39
+ self.assertEqual(result["pinned"], {"requests": "2.31.0"})
40
+
41
+ def test_hash_pinned_continuation_lines_are_skipped(self):
42
+ text = "requests==2.31.0 \\\n --hash=sha256:abc123\n --hash=sha256:def456\nclick==8.1.3\n"
43
+ result = parse_lockfile(text)
44
+ self.assertEqual(result["pinned"], {"requests": "2.31.0", "click": "8.1.3"})
45
+
46
+ def test_environment_marker_is_stripped_from_the_version(self):
47
+ result = parse_lockfile('urllib3==2.0.4 ; python_version >= "3.8"\n')
48
+ self.assertEqual(result["pinned"], {"urllib3": "2.0.4"})
49
+
50
+ def test_extras_syntax_does_not_break_the_pin(self):
51
+ """requests[security]==2.31.0 -- extras are real, common syntax in
52
+ a real pip-compile lockfile."""
53
+ result = parse_lockfile("requests[security]==2.31.0\n")
54
+ self.assertEqual(result["pinned"], {"requests": "2.31.0"})
55
+
56
+ def test_arbitrary_equality_operator_is_not_mangled(self):
57
+ """attrs===23.1.0 -- PEP 440's arbitrary-equality operator. A regex
58
+ matching a bare '==' first would eat two of its three '=' and
59
+ leave a leading '=' stuck on the front of the captured version --
60
+ a real bug caught by testing this exact operator, not assumed
61
+ equivalent to '=='."""
62
+ result = parse_lockfile("attrs===23.1.0\n")
63
+ self.assertEqual(result["pinned"], {"attrs": "23.1.0"})
64
+
65
+ def test_range_specifiers_are_unpinned_not_ignored(self):
66
+ result = parse_lockfile("click>=8.0\n")
67
+ self.assertEqual(result["pinned"], {})
68
+ self.assertEqual(result["unpinned"], ["click"])
69
+
70
+ def test_bare_name_with_no_operator_is_unpinned(self):
71
+ result = parse_lockfile("numpy\n")
72
+ self.assertEqual(result["unpinned"], ["numpy"])
73
+
74
+ def test_compatible_release_operator_is_unpinned(self):
75
+ result = parse_lockfile("django~=4.2\n")
76
+ self.assertEqual(result["unpinned"], ["django"])
77
+
78
+ def test_empty_file_is_nothing_not_an_error(self):
79
+ result = parse_lockfile("")
80
+ self.assertEqual(result, {"pinned": {}, "unpinned": []})
81
+
82
+ def test_a_realistic_mixed_file(self):
83
+ text = (
84
+ "# autogenerated by pip-compile\n"
85
+ "--index-url https://pypi.org/simple\n"
86
+ "\n"
87
+ "requests==2.31.0\n"
88
+ " # via -r requirements.in\n"
89
+ 'urllib3==2.0.4 ; python_version >= "3.8"\n'
90
+ "attrs===23.1.0\n"
91
+ "click>=8.0\n"
92
+ "numpy\n"
93
+ )
94
+ result = parse_lockfile(text)
95
+ self.assertEqual(result["pinned"], {
96
+ "requests": "2.31.0", "urllib3": "2.0.4", "attrs": "23.1.0",
97
+ })
98
+ self.assertEqual(sorted(result["unpinned"]), ["click", "numpy"])
99
+
100
+
101
+ if __name__ == "__main__":
102
+ unittest.main()