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.
- lockstep_evidence-0.1.0/LICENSE +21 -0
- lockstep_evidence-0.1.0/PKG-INFO +154 -0
- lockstep_evidence-0.1.0/README.md +132 -0
- lockstep_evidence-0.1.0/lockstep/__init__.py +1 -0
- lockstep_evidence-0.1.0/lockstep/check.py +52 -0
- lockstep_evidence-0.1.0/lockstep/cli.py +54 -0
- lockstep_evidence-0.1.0/lockstep/installed.py +23 -0
- lockstep_evidence-0.1.0/lockstep/names.py +16 -0
- lockstep_evidence-0.1.0/lockstep/parse.py +67 -0
- lockstep_evidence-0.1.0/lockstep_evidence.egg-info/PKG-INFO +154 -0
- lockstep_evidence-0.1.0/lockstep_evidence.egg-info/SOURCES.txt +19 -0
- lockstep_evidence-0.1.0/lockstep_evidence.egg-info/dependency_links.txt +1 -0
- lockstep_evidence-0.1.0/lockstep_evidence.egg-info/entry_points.txt +2 -0
- lockstep_evidence-0.1.0/lockstep_evidence.egg-info/top_level.txt +1 -0
- lockstep_evidence-0.1.0/pyproject.toml +39 -0
- lockstep_evidence-0.1.0/setup.cfg +4 -0
- lockstep_evidence-0.1.0/tests/test_check.py +84 -0
- lockstep_evidence-0.1.0/tests/test_cli.py +92 -0
- lockstep_evidence-0.1.0/tests/test_installed.py +42 -0
- lockstep_evidence-0.1.0/tests/test_names.py +29 -0
- lockstep_evidence-0.1.0/tests/test_parse.py +102 -0
|
@@ -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
|
+
[](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
|
+
[](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
|
+
[](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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
lockstep
|
|
@@ -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,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()
|