envlock-cli 1.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- envlock/__init__.py +5 -0
- envlock/__main__.py +3 -0
- envlock/cli.py +175 -0
- envlock/collectors.py +221 -0
- envlock/diff.py +167 -0
- envlock/renderer.py +180 -0
- envlock/snapshot.py +58 -0
- envlock/versions.py +49 -0
- envlock_cli-1.1.0.dist-info/METADATA +185 -0
- envlock_cli-1.1.0.dist-info/RECORD +14 -0
- envlock_cli-1.1.0.dist-info/WHEEL +5 -0
- envlock_cli-1.1.0.dist-info/entry_points.txt +2 -0
- envlock_cli-1.1.0.dist-info/licenses/LICENSE +21 -0
- envlock_cli-1.1.0.dist-info/top_level.txt +1 -0
envlock/__init__.py
ADDED
envlock/__main__.py
ADDED
envlock/cli.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
envlock — freeze a project's Python/Node/system environment and detect drift.
|
|
4
|
+
|
|
5
|
+
Exit codes: 0 no drift (at or above --fail-on) 1 drift detected 2 usage or I/O error
|
|
6
|
+
"""
|
|
7
|
+
import argparse
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from shellcolorize import Color
|
|
12
|
+
|
|
13
|
+
from . import __version__
|
|
14
|
+
from .diff import diff_snapshots
|
|
15
|
+
from .renderer import render_json, render_markdown, render_terminal
|
|
16
|
+
from .snapshot import DEFAULT_PATH, EnvSnapshot, take_snapshot
|
|
17
|
+
|
|
18
|
+
EXIT_OK, EXIT_DRIFT, EXIT_ERROR = 0, 1, 2
|
|
19
|
+
FAIL_LEVELS = {'info': 'INFO', 'warning': 'WARNING', 'critical': 'CRITICAL'}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# Progress and errors go to stderr so stdout only carries the report (safe to pipe into jq).
|
|
23
|
+
def _ok(msg): print(f" {Color.GREEN}✔{Color.RESET} {msg}", file=sys.stderr)
|
|
24
|
+
def _step(msg): print(f" {Color.CYAN}▶{Color.RESET} {msg}", file=sys.stderr)
|
|
25
|
+
def _err(msg): print(f" {Color.RED}✖{Color.RESET} {msg}", file=sys.stderr)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ── snapshot ──────────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
def cmd_snapshot(args: argparse.Namespace) -> int:
|
|
31
|
+
output = Path(args.output) if args.output else DEFAULT_PATH
|
|
32
|
+
path = Path(args.path)
|
|
33
|
+
if not path.is_dir():
|
|
34
|
+
_err(f'Not a directory: {path}')
|
|
35
|
+
return EXIT_ERROR
|
|
36
|
+
|
|
37
|
+
print(f"\n {Color.CYAN}{Color.BOLD}envlock · snapshot{Color.RESET}\n", file=sys.stderr)
|
|
38
|
+
_step(f'Scanning environment at {path.resolve()}...')
|
|
39
|
+
snap = take_snapshot(path=str(path))
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
snap.save(output)
|
|
43
|
+
except OSError as e:
|
|
44
|
+
_err(f'Cannot write {output}: {e.strerror}')
|
|
45
|
+
return EXIT_ERROR
|
|
46
|
+
|
|
47
|
+
py, nd, system = snap.python, snap.node, snap.system
|
|
48
|
+
n_pkgs = len(py.get('packages', {}).get('pip_installed', {}))
|
|
49
|
+
print()
|
|
50
|
+
_ok(f'Baseline saved: {output}')
|
|
51
|
+
print(f" Python : {py.get('python_version') or 'not found'} "
|
|
52
|
+
f"({n_pkgs} packages, {py.get('interpreter') or 'no interpreter'})")
|
|
53
|
+
print(f" Node : {nd.get('node_version') or 'not found'} "
|
|
54
|
+
f"(files: {', '.join(nd.get('lockfiles', [])) or 'none'})")
|
|
55
|
+
print(f" OS : {system.get('os') or '?'} [{system.get('arch') or '?'}]")
|
|
56
|
+
print(f" Runtimes : {', '.join(system.get('runtimes', {})) or 'none detected'}")
|
|
57
|
+
print()
|
|
58
|
+
return EXIT_OK
|
|
59
|
+
|
|
60
|
+
# ── check / diff ──────────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
def _load(path: Path):
|
|
63
|
+
try:
|
|
64
|
+
return EnvSnapshot.load(path)
|
|
65
|
+
except FileNotFoundError:
|
|
66
|
+
_err(f'Snapshot not found: {path}')
|
|
67
|
+
except (OSError, ValueError) as e:
|
|
68
|
+
_err(f'Cannot read snapshot {path}: {e}')
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _report(report, args: argparse.Namespace) -> int:
|
|
73
|
+
fmt = args.format
|
|
74
|
+
if fmt == 'terminal' and args.output:
|
|
75
|
+
fmt = 'markdown' if str(args.output).endswith('.md') else 'json'
|
|
76
|
+
|
|
77
|
+
if fmt == 'terminal':
|
|
78
|
+
render_terminal(report)
|
|
79
|
+
else:
|
|
80
|
+
text = render_json(report) if fmt == 'json' else render_markdown(report)
|
|
81
|
+
if args.output:
|
|
82
|
+
try:
|
|
83
|
+
Path(args.output).write_text(text + '\n', encoding='utf-8')
|
|
84
|
+
except OSError as e:
|
|
85
|
+
_err(f'Cannot write {args.output}: {e.strerror}')
|
|
86
|
+
return EXIT_ERROR
|
|
87
|
+
_ok(f'Report saved to {args.output}')
|
|
88
|
+
else:
|
|
89
|
+
print(text)
|
|
90
|
+
|
|
91
|
+
if args.fail_on == 'never':
|
|
92
|
+
return EXIT_OK
|
|
93
|
+
return EXIT_DRIFT if report.has_at_least(FAIL_LEVELS[args.fail_on]) else EXIT_OK
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def cmd_check(args: argparse.Namespace) -> int:
|
|
97
|
+
baseline_path = Path(args.baseline) if args.baseline else DEFAULT_PATH
|
|
98
|
+
if not baseline_path.exists():
|
|
99
|
+
_err(f'Baseline not found: {baseline_path}')
|
|
100
|
+
_err("Run 'envlock snapshot' first to create one.")
|
|
101
|
+
return EXIT_ERROR
|
|
102
|
+
baseline = _load(baseline_path)
|
|
103
|
+
if baseline is None:
|
|
104
|
+
return EXIT_ERROR
|
|
105
|
+
|
|
106
|
+
_step('Collecting current environment...')
|
|
107
|
+
current = take_snapshot(path=args.path)
|
|
108
|
+
return _report(diff_snapshots(baseline, current), args)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def cmd_diff(args: argparse.Namespace) -> int:
|
|
112
|
+
snap_a, snap_b = _load(Path(args.snapshot_a)), _load(Path(args.snapshot_b))
|
|
113
|
+
if snap_a is None or snap_b is None:
|
|
114
|
+
return EXIT_ERROR
|
|
115
|
+
return _report(diff_snapshots(snap_a, snap_b), args)
|
|
116
|
+
|
|
117
|
+
# ── main ──────────────────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
def _add_report_options(p: argparse.ArgumentParser) -> None:
|
|
120
|
+
p.add_argument('--format', '-f', choices=['terminal', 'json', 'markdown'], default='terminal',
|
|
121
|
+
help='Report format (default: terminal)')
|
|
122
|
+
p.add_argument('--output', '-o', metavar='FILE',
|
|
123
|
+
help='Write the report to FILE (json or markdown; inferred from .md/.json if needed)')
|
|
124
|
+
p.add_argument('--fail-on', choices=['info', 'warning', 'critical', 'never'], default='info',
|
|
125
|
+
help='Lowest severity that makes the exit code 1 (default: info = any change)')
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
129
|
+
parser = argparse.ArgumentParser(
|
|
130
|
+
prog='envlock',
|
|
131
|
+
description='Freeze Python, Node and system environments — detect drift before it breaks your builds.',
|
|
132
|
+
epilog='exit codes: 0 no drift · 1 drift detected · 2 error',
|
|
133
|
+
)
|
|
134
|
+
parser.add_argument('-v', '--version', action='version', version=f'envlock {__version__}')
|
|
135
|
+
sub = parser.add_subparsers(dest='command', metavar='COMMAND')
|
|
136
|
+
|
|
137
|
+
p_snap = sub.add_parser('snapshot', help='Capture the current environment as baseline')
|
|
138
|
+
p_snap.add_argument('--path', '-p', metavar='DIR', default='.',
|
|
139
|
+
help='Project directory to scan (default: current dir)')
|
|
140
|
+
p_snap.add_argument('--output', '-o', metavar='FILE',
|
|
141
|
+
help=f'Output path (default: {DEFAULT_PATH})')
|
|
142
|
+
|
|
143
|
+
p_check = sub.add_parser('check', help='Compare the current environment against the baseline')
|
|
144
|
+
p_check.add_argument('--baseline', '-b', metavar='FILE',
|
|
145
|
+
help=f'Baseline to compare against (default: {DEFAULT_PATH})')
|
|
146
|
+
p_check.add_argument('--path', '-p', metavar='DIR', default='.',
|
|
147
|
+
help='Project directory to scan (default: current dir)')
|
|
148
|
+
_add_report_options(p_check)
|
|
149
|
+
|
|
150
|
+
p_diff = sub.add_parser('diff', help='Compare two snapshot files')
|
|
151
|
+
p_diff.add_argument('snapshot_a', metavar='BASELINE')
|
|
152
|
+
p_diff.add_argument('snapshot_b', metavar='CURRENT')
|
|
153
|
+
_add_report_options(p_diff)
|
|
154
|
+
return parser
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def main(argv=None) -> None:
|
|
158
|
+
parser = build_parser()
|
|
159
|
+
args = parser.parse_args(argv)
|
|
160
|
+
Color.auto()
|
|
161
|
+
|
|
162
|
+
if args.command is None:
|
|
163
|
+
parser.print_help()
|
|
164
|
+
sys.exit(EXIT_OK)
|
|
165
|
+
|
|
166
|
+
dispatch = {'snapshot': cmd_snapshot, 'check': cmd_check, 'diff': cmd_diff}
|
|
167
|
+
try:
|
|
168
|
+
sys.exit(dispatch[args.command](args))
|
|
169
|
+
except KeyboardInterrupt:
|
|
170
|
+
_err('Interrupted')
|
|
171
|
+
sys.exit(130)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
if __name__ == '__main__':
|
|
175
|
+
main()
|
envlock/collectors.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Environment collectors — Python, Node, and system-level dependencies.
|
|
3
|
+
Each collector returns a plain dict ready for JSON serialisation.
|
|
4
|
+
"""
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Dict, Optional
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _run(cmd: list, cwd: Optional[str] = None, timeout: int = 60, stderr: bool = False) -> str:
|
|
15
|
+
"""Run a command and return its stdout ('' on any failure). With stderr=True, include stderr."""
|
|
16
|
+
try:
|
|
17
|
+
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, cwd=cwd)
|
|
18
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
19
|
+
return ''
|
|
20
|
+
return (r.stdout + r.stderr) if stderr else r.stdout
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def normalize_name(name: str) -> str:
|
|
24
|
+
"""PEP 503 normalisation: 'PyYAML', 'pyyaml', 'py_yaml' → 'pyyaml' / 'py-yaml'."""
|
|
25
|
+
return re.sub(r'[-_.]+', '-', name).lower()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
_REQ_RE = re.compile(r'^([A-Za-z0-9][A-Za-z0-9._-]*)\s*(\[[^\]]*\])?\s*([^;]*)')
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def parse_requirement(line: str) -> Optional[tuple]:
|
|
32
|
+
"""
|
|
33
|
+
Parse one requirement ('requests[socks]>=2.31 ; python_version>"3.8" # note')
|
|
34
|
+
into (normalized_name, spec). Returns None for blank lines, comments and options.
|
|
35
|
+
"""
|
|
36
|
+
line = line.split(' #', 1)[0].strip()
|
|
37
|
+
if not line or line.startswith(('#', '-')) or '://' in line:
|
|
38
|
+
return None
|
|
39
|
+
m = _REQ_RE.match(line)
|
|
40
|
+
if not m:
|
|
41
|
+
return None
|
|
42
|
+
return normalize_name(m.group(1)), m.group(3).strip()
|
|
43
|
+
|
|
44
|
+
# ── Python ────────────────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
def find_python(path: str = '.') -> Optional[str]:
|
|
47
|
+
"""
|
|
48
|
+
The interpreter whose packages describe the project:
|
|
49
|
+
a virtualenv inside the project, then the active $VIRTUAL_ENV, then python3 on PATH.
|
|
50
|
+
"""
|
|
51
|
+
candidates = [Path(path) / d / 'bin' / 'python' for d in ('.venv', 'venv', 'env')]
|
|
52
|
+
if os.environ.get('VIRTUAL_ENV'):
|
|
53
|
+
candidates.append(Path(os.environ['VIRTUAL_ENV']) / 'bin' / 'python')
|
|
54
|
+
for c in candidates:
|
|
55
|
+
if c.is_file() and os.access(c, os.X_OK):
|
|
56
|
+
return str(c)
|
|
57
|
+
return shutil.which('python3') or shutil.which('python')
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def collect_python(path: str = '.') -> Dict:
|
|
61
|
+
"""
|
|
62
|
+
Python environment of the project at `path`:
|
|
63
|
+
interpreter + version, installed packages (pip), requirements.txt and pyproject.toml deps.
|
|
64
|
+
"""
|
|
65
|
+
result: Dict = {'python_version': None, 'interpreter': None, 'venv': None,
|
|
66
|
+
'packages': {}, 'lockfiles': []}
|
|
67
|
+
|
|
68
|
+
python = find_python(path)
|
|
69
|
+
if python:
|
|
70
|
+
result['interpreter'] = python
|
|
71
|
+
venv = Path(python).parent.parent
|
|
72
|
+
if (venv / 'pyvenv.cfg').is_file():
|
|
73
|
+
result['venv'] = str(venv)
|
|
74
|
+
ver = _run([python, '--version'], stderr=True).strip()
|
|
75
|
+
if ver:
|
|
76
|
+
result['python_version'] = ver.split()[-1]
|
|
77
|
+
out = _run([python, '-m', 'pip', 'list', '--format=json', '--disable-pip-version-check'])
|
|
78
|
+
try:
|
|
79
|
+
result['packages']['pip_installed'] = {
|
|
80
|
+
normalize_name(p['name']): p['version'] for p in json.loads(out)
|
|
81
|
+
}
|
|
82
|
+
except (json.JSONDecodeError, KeyError, TypeError):
|
|
83
|
+
pass
|
|
84
|
+
|
|
85
|
+
req = Path(path) / 'requirements.txt'
|
|
86
|
+
if req.is_file():
|
|
87
|
+
result['lockfiles'].append('requirements.txt')
|
|
88
|
+
pkgs = dict(filter(None, (parse_requirement(line)
|
|
89
|
+
for line in req.read_text(errors='replace').splitlines())))
|
|
90
|
+
if pkgs:
|
|
91
|
+
result['packages']['requirements_txt'] = pkgs
|
|
92
|
+
|
|
93
|
+
ppt = Path(path) / 'pyproject.toml'
|
|
94
|
+
if ppt.is_file():
|
|
95
|
+
result['lockfiles'].append('pyproject.toml')
|
|
96
|
+
try:
|
|
97
|
+
import tomllib # Python 3.11+
|
|
98
|
+
except ImportError:
|
|
99
|
+
try:
|
|
100
|
+
import tomli as tomllib
|
|
101
|
+
except ImportError:
|
|
102
|
+
tomllib = None
|
|
103
|
+
if tomllib:
|
|
104
|
+
try:
|
|
105
|
+
data = tomllib.loads(ppt.read_text())
|
|
106
|
+
except (tomllib.TOMLDecodeError, UnicodeDecodeError):
|
|
107
|
+
data = {}
|
|
108
|
+
deps = data.get('project', {}).get('dependencies', [])
|
|
109
|
+
pkgs = dict(filter(None, (parse_requirement(d) for d in deps)))
|
|
110
|
+
if pkgs:
|
|
111
|
+
result['packages']['pyproject_deps'] = pkgs
|
|
112
|
+
|
|
113
|
+
return result
|
|
114
|
+
|
|
115
|
+
# ── Node / npm ────────────────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
def collect_node(path: str = '.') -> Dict:
|
|
118
|
+
"""
|
|
119
|
+
Node.js environment: node/npm versions, package.json deps + devDeps,
|
|
120
|
+
package-lock.json resolved versions and yarn.lock presence.
|
|
121
|
+
"""
|
|
122
|
+
result: Dict = {'node_version': None, 'npm_version': None, 'packages': {}, 'lockfiles': []}
|
|
123
|
+
|
|
124
|
+
ver = _run(['node', '--version']).strip()
|
|
125
|
+
if ver:
|
|
126
|
+
result['node_version'] = ver.lstrip('v')
|
|
127
|
+
ver = _run(['npm', '--version']).strip()
|
|
128
|
+
if ver:
|
|
129
|
+
result['npm_version'] = ver
|
|
130
|
+
|
|
131
|
+
pkg_json = Path(path) / 'package.json'
|
|
132
|
+
if pkg_json.is_file():
|
|
133
|
+
result['lockfiles'].append('package.json')
|
|
134
|
+
try:
|
|
135
|
+
data = json.loads(pkg_json.read_text())
|
|
136
|
+
for key in ('dependencies', 'devDependencies'):
|
|
137
|
+
if isinstance(data.get(key), dict):
|
|
138
|
+
result['packages'][key] = data[key]
|
|
139
|
+
except json.JSONDecodeError:
|
|
140
|
+
pass
|
|
141
|
+
|
|
142
|
+
lock = Path(path) / 'package-lock.json'
|
|
143
|
+
if lock.is_file():
|
|
144
|
+
result['lockfiles'].append('package-lock.json')
|
|
145
|
+
try:
|
|
146
|
+
data = json.loads(lock.read_text())
|
|
147
|
+
locked = {}
|
|
148
|
+
# lockfileVersion 2/3: "packages" keyed by "node_modules/<name>"
|
|
149
|
+
for name, info in data.get('packages', {}).items():
|
|
150
|
+
if name.startswith('node_modules/') and '/node_modules/' not in name:
|
|
151
|
+
locked[name[len('node_modules/'):]] = info.get('version', '?')
|
|
152
|
+
# lockfileVersion 1: nested "dependencies"
|
|
153
|
+
if not locked:
|
|
154
|
+
for name, info in data.get('dependencies', {}).items():
|
|
155
|
+
locked[name] = info.get('version', '?')
|
|
156
|
+
if locked:
|
|
157
|
+
result['packages']['locked'] = locked
|
|
158
|
+
except json.JSONDecodeError:
|
|
159
|
+
pass
|
|
160
|
+
|
|
161
|
+
if (Path(path) / 'yarn.lock').is_file():
|
|
162
|
+
result['lockfiles'].append('yarn.lock')
|
|
163
|
+
|
|
164
|
+
return result
|
|
165
|
+
|
|
166
|
+
# ── System ────────────────────────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
_RUNTIMES = [
|
|
169
|
+
('python', ['python3', '--version']),
|
|
170
|
+
('node', ['node', '--version']),
|
|
171
|
+
('ruby', ['ruby', '--version']),
|
|
172
|
+
('go', ['go', 'version']),
|
|
173
|
+
('java', ['java', '-version']), # prints to stderr
|
|
174
|
+
('rustc', ['rustc', '--version']),
|
|
175
|
+
]
|
|
176
|
+
|
|
177
|
+
_ENV_KEYS = [
|
|
178
|
+
'VIRTUAL_ENV', 'CONDA_DEFAULT_ENV', 'NVM_DIR', 'NODE_ENV',
|
|
179
|
+
'PYTHONPATH', 'GOPATH', 'JAVA_HOME', 'PATH',
|
|
180
|
+
]
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def collect_system() -> Dict:
|
|
184
|
+
"""OS, architecture, language runtimes and environment variables relevant to reproducibility."""
|
|
185
|
+
result: Dict = {'os': None, 'arch': None, 'env_vars': {}, 'runtimes': {}}
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
info = {}
|
|
189
|
+
for line in Path('/etc/os-release').read_text().splitlines():
|
|
190
|
+
if '=' in line:
|
|
191
|
+
k, v = line.split('=', 1)
|
|
192
|
+
info[k] = v.strip().strip('"')
|
|
193
|
+
result['os'] = f"{info.get('NAME', '?')} {info.get('VERSION_ID', '')}".strip()
|
|
194
|
+
except OSError:
|
|
195
|
+
import platform
|
|
196
|
+
result['os'] = f"{platform.system()} {platform.release()}".strip() or None
|
|
197
|
+
|
|
198
|
+
arch = _run(['uname', '-m']).strip()
|
|
199
|
+
if arch:
|
|
200
|
+
result['arch'] = arch
|
|
201
|
+
|
|
202
|
+
for name, cmd in _RUNTIMES:
|
|
203
|
+
out = _run(cmd, stderr=True).strip()
|
|
204
|
+
if out:
|
|
205
|
+
result['runtimes'][name] = out.splitlines()[0]
|
|
206
|
+
|
|
207
|
+
for k in _ENV_KEYS:
|
|
208
|
+
v = os.environ.get(k)
|
|
209
|
+
if v:
|
|
210
|
+
result['env_vars'][k] = v
|
|
211
|
+
|
|
212
|
+
return result
|
|
213
|
+
|
|
214
|
+
# ── Unified snapshot ──────────────────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
def collect_all(path: str = '.') -> Dict:
|
|
217
|
+
return {
|
|
218
|
+
'python': collect_python(path),
|
|
219
|
+
'node': collect_node(path),
|
|
220
|
+
'system': collect_system(),
|
|
221
|
+
}
|
envlock/diff.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Diff two EnvSnapshots and produce a structured drift report.
|
|
3
|
+
|
|
4
|
+
Severity rules:
|
|
5
|
+
CRITICAL Python/Node major.minor change, OS or architecture change
|
|
6
|
+
WARNING package removed or downgraded, declared constraint changed,
|
|
7
|
+
runtime patch change, lockfile removed, env var removed/changed
|
|
8
|
+
INFO package added or upgraded, lockfile or env var added
|
|
9
|
+
"""
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import List, Optional
|
|
12
|
+
|
|
13
|
+
from .versions import compare_versions, same_minor
|
|
14
|
+
|
|
15
|
+
SEVERITIES = ('CRITICAL', 'WARNING', 'INFO')
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class Change:
|
|
20
|
+
section: str
|
|
21
|
+
kind: str # added | removed | changed
|
|
22
|
+
severity: str # CRITICAL | WARNING | INFO
|
|
23
|
+
description: str
|
|
24
|
+
detail: Optional[str] = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class EnvDriftReport:
|
|
29
|
+
baseline_meta: dict
|
|
30
|
+
current_meta: dict
|
|
31
|
+
changes: List[Change] = field(default_factory=list)
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def is_clean(self) -> bool:
|
|
35
|
+
return not self.changes
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def critical(self) -> List[Change]:
|
|
39
|
+
return [c for c in self.changes if c.severity == 'CRITICAL']
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def warnings(self) -> List[Change]:
|
|
43
|
+
return [c for c in self.changes if c.severity == 'WARNING']
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def info(self) -> List[Change]:
|
|
47
|
+
return [c for c in self.changes if c.severity == 'INFO']
|
|
48
|
+
|
|
49
|
+
def has_at_least(self, severity: str) -> bool:
|
|
50
|
+
"""True if any change is as severe as `severity` or more."""
|
|
51
|
+
rank = SEVERITIES.index(severity)
|
|
52
|
+
return any(SEVERITIES.index(c.severity) <= rank for c in self.changes)
|
|
53
|
+
|
|
54
|
+
# ── Per-section diffing ───────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
def _diff_runtime(label: str, section: str, a: Optional[str], b: Optional[str],
|
|
57
|
+
critical_on_minor: bool) -> List[Change]:
|
|
58
|
+
if a == b or (not a and not b):
|
|
59
|
+
return []
|
|
60
|
+
if a and b:
|
|
61
|
+
if critical_on_minor and not same_minor(a, b):
|
|
62
|
+
severity = 'CRITICAL'
|
|
63
|
+
elif section == 'system.os':
|
|
64
|
+
severity = 'CRITICAL'
|
|
65
|
+
else:
|
|
66
|
+
severity = 'WARNING'
|
|
67
|
+
return [Change(section, 'changed', severity, f'{label} version changed', f'{a} → {b}')]
|
|
68
|
+
if b:
|
|
69
|
+
return [Change(section, 'added', 'INFO', f'{label} detected', b)]
|
|
70
|
+
return [Change(section, 'removed', 'WARNING', f'{label} no longer detected', f'was {a}')]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _diff_versions(section: str, label: str, a: dict, b: dict) -> List[Change]:
|
|
74
|
+
"""Resolved versions (pip list, package-lock): upgrades are INFO, downgrades WARNING."""
|
|
75
|
+
changes = []
|
|
76
|
+
for k in sorted(b.keys() - a.keys()):
|
|
77
|
+
changes.append(Change(section, 'added', 'INFO', f'{label}: {k} added', b[k] or None))
|
|
78
|
+
for k in sorted(a.keys() - b.keys()):
|
|
79
|
+
changes.append(Change(section, 'removed', 'WARNING', f'{label}: {k} removed',
|
|
80
|
+
f'was {a[k]}' if a[k] else None))
|
|
81
|
+
for k in sorted(a.keys() & b.keys()):
|
|
82
|
+
if a[k] == b[k]:
|
|
83
|
+
continue
|
|
84
|
+
order = compare_versions(str(a[k]), str(b[k]))
|
|
85
|
+
if order is not None and order < 0:
|
|
86
|
+
changes.append(Change(section, 'changed', 'INFO', f'{label}: {k} upgraded', f'{a[k]} → {b[k]}'))
|
|
87
|
+
elif order is not None and order > 0:
|
|
88
|
+
changes.append(Change(section, 'changed', 'WARNING', f'{label}: {k} downgraded', f'{a[k]} → {b[k]}'))
|
|
89
|
+
else:
|
|
90
|
+
changes.append(Change(section, 'changed', 'WARNING', f'{label}: {k} version changed',
|
|
91
|
+
f'{a[k]} → {b[k]}'))
|
|
92
|
+
return changes
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _diff_declared(section: str, label: str, a: dict, b: dict) -> List[Change]:
|
|
96
|
+
"""Declared constraints (requirements.txt, pyproject, package.json): any change is a WARNING."""
|
|
97
|
+
changes = []
|
|
98
|
+
for k in sorted(b.keys() - a.keys()):
|
|
99
|
+
changes.append(Change(section, 'added', 'INFO', f'{label}: {k} added', b[k] or None))
|
|
100
|
+
for k in sorted(a.keys() - b.keys()):
|
|
101
|
+
changes.append(Change(section, 'removed', 'WARNING', f'{label}: {k} removed',
|
|
102
|
+
f'was {a[k]}' if a[k] else None))
|
|
103
|
+
for k in sorted(a.keys() & b.keys()):
|
|
104
|
+
if a[k] != b[k]:
|
|
105
|
+
changes.append(Change(section, 'changed', 'WARNING', f'{label}: {k} constraint changed',
|
|
106
|
+
f'{a[k] or "(any)"} → {b[k] or "(any)"}'))
|
|
107
|
+
return changes
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _diff_list(section: str, label: str, a: list, b: list) -> List[Change]:
|
|
111
|
+
changes = [Change(section, 'added', 'INFO', f'{label}: {x} added') for x in sorted(set(b) - set(a))]
|
|
112
|
+
changes += [Change(section, 'removed', 'WARNING', f'{label}: {x} removed') for x in sorted(set(a) - set(b))]
|
|
113
|
+
return changes
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _diff_env_vars(a: dict, b: dict) -> List[Change]:
|
|
117
|
+
skip = {'PATH'} # too noisy / session-specific
|
|
118
|
+
a = {k: v for k, v in a.items() if k not in skip}
|
|
119
|
+
b = {k: v for k, v in b.items() if k not in skip}
|
|
120
|
+
changes = [Change('env_vars', 'added', 'INFO', f'Env var added: {k}', b[k]) for k in sorted(b.keys() - a.keys())]
|
|
121
|
+
changes += [Change('env_vars', 'removed', 'WARNING', f'Env var removed: {k}') for k in sorted(a.keys() - b.keys())]
|
|
122
|
+
changes += [Change('env_vars', 'changed', 'WARNING', f'Env var changed: {k}', f'{a[k][:60]} → {b[k][:60]}')
|
|
123
|
+
for k in sorted(a.keys() & b.keys()) if a[k] != b[k]]
|
|
124
|
+
return changes
|
|
125
|
+
|
|
126
|
+
# ── Entry point ───────────────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
def diff_snapshots(snap_a, snap_b) -> EnvDriftReport:
|
|
129
|
+
report = EnvDriftReport(baseline_meta=snap_a.meta, current_meta=snap_b.meta)
|
|
130
|
+
ch = report.changes
|
|
131
|
+
py_a, py_b = snap_a.python, snap_b.python
|
|
132
|
+
nd_a, nd_b = snap_a.node, snap_b.node
|
|
133
|
+
sy_a, sy_b = snap_a.system, snap_b.system
|
|
134
|
+
|
|
135
|
+
def pkgs(env: dict, key: str) -> dict:
|
|
136
|
+
return env.get('packages', {}).get(key, {}) or {}
|
|
137
|
+
|
|
138
|
+
# Python
|
|
139
|
+
ch += _diff_runtime('Python', 'python.runtime', py_a.get('python_version'),
|
|
140
|
+
py_b.get('python_version'), critical_on_minor=True)
|
|
141
|
+
ch += _diff_versions('python.packages', 'pip', pkgs(py_a, 'pip_installed'), pkgs(py_b, 'pip_installed'))
|
|
142
|
+
ch += _diff_declared('python.packages', 'requirements.txt',
|
|
143
|
+
pkgs(py_a, 'requirements_txt'), pkgs(py_b, 'requirements_txt'))
|
|
144
|
+
ch += _diff_declared('python.packages', 'pyproject',
|
|
145
|
+
pkgs(py_a, 'pyproject_deps'), pkgs(py_b, 'pyproject_deps'))
|
|
146
|
+
ch += _diff_list('lockfiles', 'Python lockfile', py_a.get('lockfiles', []), py_b.get('lockfiles', []))
|
|
147
|
+
|
|
148
|
+
# Node
|
|
149
|
+
ch += _diff_runtime('Node.js', 'node.runtime', nd_a.get('node_version'),
|
|
150
|
+
nd_b.get('node_version'), critical_on_minor=True)
|
|
151
|
+
ch += _diff_declared('node.packages', 'npm dep', pkgs(nd_a, 'dependencies'), pkgs(nd_b, 'dependencies'))
|
|
152
|
+
ch += _diff_declared('node.packages', 'npm devDep',
|
|
153
|
+
pkgs(nd_a, 'devDependencies'), pkgs(nd_b, 'devDependencies'))
|
|
154
|
+
ch += _diff_versions('node.packages', 'npm locked', pkgs(nd_a, 'locked'), pkgs(nd_b, 'locked'))
|
|
155
|
+
ch += _diff_list('lockfiles', 'Node lockfile', nd_a.get('lockfiles', []), nd_b.get('lockfiles', []))
|
|
156
|
+
|
|
157
|
+
# System (python/node runtimes are already covered above)
|
|
158
|
+
rt_a, rt_b = sy_a.get('runtimes', {}), sy_b.get('runtimes', {})
|
|
159
|
+
for name in sorted((rt_a.keys() | rt_b.keys()) - {'python', 'node'}):
|
|
160
|
+
ch += _diff_runtime(name.capitalize(), 'system.runtime', rt_a.get(name), rt_b.get(name),
|
|
161
|
+
critical_on_minor=False)
|
|
162
|
+
ch += _diff_runtime('OS', 'system.os', sy_a.get('os'), sy_b.get('os'), critical_on_minor=False)
|
|
163
|
+
ch += _diff_runtime('Architecture', 'system.os', sy_a.get('arch'), sy_b.get('arch'),
|
|
164
|
+
critical_on_minor=False)
|
|
165
|
+
ch += _diff_env_vars(sy_a.get('env_vars', {}), sy_b.get('env_vars', {}))
|
|
166
|
+
|
|
167
|
+
return report
|
envlock/renderer.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Render an EnvDriftReport to terminal, Markdown, or JSON.
|
|
3
|
+
"""
|
|
4
|
+
import json
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from shellcolorize import Color
|
|
7
|
+
from .diff import EnvDriftReport, Change
|
|
8
|
+
|
|
9
|
+
# Attribute names, resolved at render time so Color.auto()/disable() are respected.
|
|
10
|
+
_SEV_COLOR = {
|
|
11
|
+
'CRITICAL': 'RED',
|
|
12
|
+
'WARNING': 'YELLOW',
|
|
13
|
+
'INFO': 'CYAN',
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
_SEV_ICON = {
|
|
17
|
+
'CRITICAL': '⛔',
|
|
18
|
+
'WARNING': '⚠ ',
|
|
19
|
+
'INFO': '·',
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
_KIND_SYM = {
|
|
23
|
+
'added': '+',
|
|
24
|
+
'removed': '-',
|
|
25
|
+
'changed': '~',
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
_SECTIONS = [
|
|
29
|
+
'python.runtime', 'python.packages',
|
|
30
|
+
'node.runtime', 'node.packages',
|
|
31
|
+
'system.os', 'system.runtime', 'lockfiles', 'env_vars',
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
_SEC_LABEL = {
|
|
35
|
+
'python.runtime': 'Python Runtime',
|
|
36
|
+
'python.packages': 'Python Packages',
|
|
37
|
+
'node.runtime': 'Node.js Runtime',
|
|
38
|
+
'node.packages': 'Node.js Packages',
|
|
39
|
+
'system.os': 'Operating System',
|
|
40
|
+
'system.runtime': 'Other Runtimes',
|
|
41
|
+
'lockfiles': 'Lockfiles',
|
|
42
|
+
'env_vars': 'Environment Variables',
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _header() -> None:
|
|
47
|
+
title = 'envlock · environment drift report'
|
|
48
|
+
w = len(title) + 4
|
|
49
|
+
print()
|
|
50
|
+
print(f" {Color.CYAN}╔{'═' * w}╗{Color.RESET}")
|
|
51
|
+
print(f" {Color.CYAN}║{Color.RESET} {Color.BOLD}{Color.CYAN}{title}{Color.RESET} {Color.CYAN}║{Color.RESET}")
|
|
52
|
+
print(f" {Color.CYAN}╚{'═' * w}╝{Color.RESET}")
|
|
53
|
+
print()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _meta_line(label: str, meta: dict) -> None:
|
|
57
|
+
ts = meta.get('captured_at', '?')
|
|
58
|
+
host = meta.get('hostname', '?')
|
|
59
|
+
proj = meta.get('project_path', '?')
|
|
60
|
+
print(f" {Color.DIM}{label:<10}{Color.RESET} {ts} ({host}) {Color.DIM}{proj}{Color.RESET}")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _section(label: str) -> None:
|
|
64
|
+
print()
|
|
65
|
+
print(f" {Color.CYAN}── {label} {'─' * max(0, 42 - len(label))}{Color.RESET}")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _change_line(c: Change) -> None:
|
|
69
|
+
color = getattr(Color, _SEV_COLOR.get(c.severity, ''), '')
|
|
70
|
+
icon = _SEV_ICON.get(c.severity, ' ')
|
|
71
|
+
sym = _KIND_SYM.get(c.kind, ' ')
|
|
72
|
+
detail = f" {Color.DIM}{c.detail}{Color.RESET}" if c.detail else ''
|
|
73
|
+
print(f" {color}{icon} {sym} {c.description}{Color.RESET}{detail}")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def render_terminal(report: EnvDriftReport) -> None:
|
|
77
|
+
_header()
|
|
78
|
+
_meta_line('Baseline', report.baseline_meta)
|
|
79
|
+
_meta_line('Current', report.current_meta)
|
|
80
|
+
print()
|
|
81
|
+
|
|
82
|
+
if report.is_clean:
|
|
83
|
+
print(f" {Color.GREEN}✔ No drift detected — environment matches baseline.{Color.RESET}")
|
|
84
|
+
print()
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
by_sec = {}
|
|
88
|
+
for c in report.changes:
|
|
89
|
+
by_sec.setdefault(c.section, []).append(c)
|
|
90
|
+
|
|
91
|
+
for sec in _SECTIONS:
|
|
92
|
+
changes = by_sec.get(sec, [])
|
|
93
|
+
if not changes:
|
|
94
|
+
continue
|
|
95
|
+
_section(_SEC_LABEL.get(sec, sec))
|
|
96
|
+
for c in changes:
|
|
97
|
+
_change_line(c)
|
|
98
|
+
|
|
99
|
+
print()
|
|
100
|
+
print(f" {'─' * 44}")
|
|
101
|
+
total = len(report.changes)
|
|
102
|
+
crit = len(report.critical)
|
|
103
|
+
warn = len(report.warnings)
|
|
104
|
+
info = len(report.info)
|
|
105
|
+
parts = []
|
|
106
|
+
if crit: parts.append(f"{Color.RED}{crit} critical{Color.RESET}")
|
|
107
|
+
if warn: parts.append(f"{Color.YELLOW}{warn} warning{'s' if warn > 1 else ''}{Color.RESET}")
|
|
108
|
+
if info: parts.append(f"{Color.CYAN}{info} info{Color.RESET}")
|
|
109
|
+
print(f" {Color.BOLD}{total} change{'s' if total != 1 else ''} detected{Color.RESET}"
|
|
110
|
+
+ (f" ({', '.join(parts)})" if parts else ''))
|
|
111
|
+
print()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def render_markdown(report: EnvDriftReport) -> str:
|
|
115
|
+
lines = []
|
|
116
|
+
ts_now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
|
117
|
+
|
|
118
|
+
lines += [
|
|
119
|
+
"# envlock — Environment Drift Report",
|
|
120
|
+
"",
|
|
121
|
+
"| | |",
|
|
122
|
+
"|---|---|",
|
|
123
|
+
f"| **Baseline** | {report.baseline_meta.get('captured_at','?')} ({report.baseline_meta.get('hostname','?')}) |",
|
|
124
|
+
f"| **Current** | {report.current_meta.get('captured_at','?')} ({report.current_meta.get('hostname','?')}) |",
|
|
125
|
+
f"| **Project** | {report.current_meta.get('project_path','?')} |",
|
|
126
|
+
f"| **Generated** | {ts_now} |",
|
|
127
|
+
f"| **Changes** | {len(report.changes)} ({len(report.critical)} critical, {len(report.warnings)} warnings, {len(report.info)} info) |",
|
|
128
|
+
"",
|
|
129
|
+
"---",
|
|
130
|
+
"",
|
|
131
|
+
]
|
|
132
|
+
|
|
133
|
+
if report.is_clean:
|
|
134
|
+
lines += ["## ✅ No drift detected", "", "Environment matches baseline.", ""]
|
|
135
|
+
return '\n'.join(lines)
|
|
136
|
+
|
|
137
|
+
by_sec = {}
|
|
138
|
+
for c in report.changes:
|
|
139
|
+
by_sec.setdefault(c.section, []).append(c)
|
|
140
|
+
|
|
141
|
+
_icons = {'CRITICAL': '⛔', 'WARNING': '⚠️', 'INFO': 'ℹ️'}
|
|
142
|
+
_syms = {'added': '+', 'removed': '−', 'changed': '~'}
|
|
143
|
+
|
|
144
|
+
for sec in _SECTIONS:
|
|
145
|
+
changes = by_sec.get(sec, [])
|
|
146
|
+
if not changes:
|
|
147
|
+
continue
|
|
148
|
+
lines += [f"## {_SEC_LABEL.get(sec, sec)}", ""]
|
|
149
|
+
for c in changes:
|
|
150
|
+
icon = _icons.get(c.severity, '')
|
|
151
|
+
sym = _syms.get(c.kind, ' ')
|
|
152
|
+
detail = f" _{c.detail}_" if c.detail else ''
|
|
153
|
+
lines.append(f"- {icon} `{sym}` {c.description}{detail}")
|
|
154
|
+
lines.append("")
|
|
155
|
+
|
|
156
|
+
lines += ["---", "", f"*Generated by [envlock](https://github.com/serber1990/envlock) at {ts_now}*"]
|
|
157
|
+
return '\n'.join(lines)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def render_json(report: EnvDriftReport) -> str:
|
|
161
|
+
return json.dumps({
|
|
162
|
+
'baseline_meta': report.baseline_meta,
|
|
163
|
+
'current_meta': report.current_meta,
|
|
164
|
+
'summary': {
|
|
165
|
+
'total': len(report.changes),
|
|
166
|
+
'critical': len(report.critical),
|
|
167
|
+
'warnings': len(report.warnings),
|
|
168
|
+
'info': len(report.info),
|
|
169
|
+
},
|
|
170
|
+
'changes': [
|
|
171
|
+
{
|
|
172
|
+
'section': c.section,
|
|
173
|
+
'kind': c.kind,
|
|
174
|
+
'severity': c.severity,
|
|
175
|
+
'description': c.description,
|
|
176
|
+
'detail': c.detail,
|
|
177
|
+
}
|
|
178
|
+
for c in report.changes
|
|
179
|
+
],
|
|
180
|
+
}, indent=2)
|
envlock/snapshot.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""
|
|
2
|
+
EnvSnapshot — captures, saves, and loads an environment state.
|
|
3
|
+
"""
|
|
4
|
+
import json
|
|
5
|
+
import socket
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from . import __version__
|
|
10
|
+
from .collectors import collect_all
|
|
11
|
+
|
|
12
|
+
DEFAULT_PATH = Path('.envlock.json')
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class EnvSnapshot:
|
|
16
|
+
def __init__(self, data: dict):
|
|
17
|
+
if not isinstance(data, dict):
|
|
18
|
+
raise ValueError('not an envlock snapshot (expected a JSON object)')
|
|
19
|
+
self._data = data
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def meta(self) -> dict:
|
|
23
|
+
return self._data.get('meta', {})
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def python(self) -> dict:
|
|
27
|
+
return self._data.get('python', {})
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def node(self) -> dict:
|
|
31
|
+
return self._data.get('node', {})
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def system(self) -> dict:
|
|
35
|
+
return self._data.get('system', {})
|
|
36
|
+
|
|
37
|
+
def to_dict(self) -> dict:
|
|
38
|
+
return self._data
|
|
39
|
+
|
|
40
|
+
def save(self, path: Path) -> None:
|
|
41
|
+
path = Path(path)
|
|
42
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
path.write_text(json.dumps(self._data, indent=2) + '\n', encoding='utf-8')
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def load(cls, path: Path) -> 'EnvSnapshot':
|
|
47
|
+
return cls(json.loads(Path(path).read_text(encoding='utf-8')))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def take_snapshot(path: str = '.') -> EnvSnapshot:
|
|
51
|
+
env = collect_all(path)
|
|
52
|
+
env['meta'] = {
|
|
53
|
+
'captured_at': datetime.now(timezone.utc).isoformat(timespec='seconds'),
|
|
54
|
+
'hostname': socket.gethostname(),
|
|
55
|
+
'project_path': str(Path(path).resolve()),
|
|
56
|
+
'envlock_version': __version__,
|
|
57
|
+
}
|
|
58
|
+
return EnvSnapshot(env)
|
envlock/versions.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Dependency-free version comparison, good enough for drift reports.
|
|
3
|
+
|
|
4
|
+
Handles PEP 440-ish ("2.0.0rc1"), semver ("1.2.3-beta.1") and Debian-style
|
|
5
|
+
epochs ("1:2.3-4"). Returns None when a version cannot be interpreted.
|
|
6
|
+
"""
|
|
7
|
+
import re
|
|
8
|
+
from typing import Optional, Tuple
|
|
9
|
+
|
|
10
|
+
_EPOCH = re.compile(r'^(\d+):')
|
|
11
|
+
_RELEASE = re.compile(r'^[vV]?(\d+(?:\.\d+)*)')
|
|
12
|
+
_PRE = re.compile(r'(?:a|alpha|b|beta|c|rc|pre|preview|dev)\.?\d*$|-(?:alpha|beta|rc|pre|dev)', re.I)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _parse(v: str) -> Optional[Tuple[int, Tuple[int, ...], bool]]:
|
|
16
|
+
v = v.strip()
|
|
17
|
+
epoch = 0
|
|
18
|
+
m = _EPOCH.match(v)
|
|
19
|
+
if m:
|
|
20
|
+
epoch, v = int(m.group(1)), v[m.end():]
|
|
21
|
+
m = _RELEASE.match(v)
|
|
22
|
+
if not m:
|
|
23
|
+
return None
|
|
24
|
+
release = tuple(int(x) for x in m.group(1).split('.'))
|
|
25
|
+
rest = v[m.end():]
|
|
26
|
+
return epoch, release, bool(_PRE.search(rest))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def compare_versions(a: str, b: str) -> Optional[int]:
|
|
30
|
+
"""-1 if a < b, 0 if equal, 1 if a > b, None if either is not a version."""
|
|
31
|
+
pa, pb = _parse(a), _parse(b)
|
|
32
|
+
if pa is None or pb is None:
|
|
33
|
+
return None
|
|
34
|
+
(ea, ra, pre_a), (eb, rb, pre_b) = pa, pb
|
|
35
|
+
width = max(len(ra), len(rb))
|
|
36
|
+
ra, rb = ra + (0,) * (width - len(ra)), rb + (0,) * (width - len(rb))
|
|
37
|
+
# A pre-release sorts before its final release: 2.0.0rc1 < 2.0.0.
|
|
38
|
+
key_a, key_b = (ea, ra, not pre_a), (eb, rb, not pre_b)
|
|
39
|
+
if key_a == key_b:
|
|
40
|
+
return 0
|
|
41
|
+
return 1 if key_a > key_b else -1
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def same_minor(a: str, b: str) -> bool:
|
|
45
|
+
"""True when both versions share major.minor (3.12.1 vs 3.12.4)."""
|
|
46
|
+
pa, pb = _parse(a), _parse(b)
|
|
47
|
+
if pa is None or pb is None:
|
|
48
|
+
return False
|
|
49
|
+
return pa[0] == pb[0] and pa[1][:2] == pb[1][:2]
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: envlock-cli
|
|
3
|
+
Version: 1.1.0
|
|
4
|
+
Summary: Freeze and compare Python, Node, and system environments — detect dependency drift before it breaks your builds.
|
|
5
|
+
Author-email: Serber1990 <serber1990@pm.me>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/serber1990/envlock
|
|
8
|
+
Project-URL: Repository, https://github.com/serber1990/envlock
|
|
9
|
+
Project-URL: Issues, https://github.com/serber1990/envlock/issues
|
|
10
|
+
Project-URL: Changelog, https://github.com/serber1990/envlock/blob/main/CHANGELOG.md
|
|
11
|
+
Keywords: environment,dependencies,drift,reproducibility,devops,ci,python,node
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Operating System :: POSIX
|
|
14
|
+
Classifier: Environment :: Console
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Intended Audience :: System Administrators
|
|
17
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
18
|
+
Classifier: Topic :: Utilities
|
|
19
|
+
Requires-Python: >=3.9
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Requires-Dist: shellcolorize>=1.1.0
|
|
23
|
+
Requires-Dist: tomli>=1.1; python_version < "3.11"
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
26
|
+
Requires-Dist: ruff>=0.4; extra == "dev"
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# envlock
|
|
30
|
+
|
|
31
|
+
[](https://github.com/serber1990/envlock/actions/workflows/ci.yml)
|
|
32
|
+
[](https://badge.fury.io/py/envlock-cli)
|
|
33
|
+
[](LICENSE)
|
|
34
|
+
|
|
35
|
+
Freeze your environment. Detect when it drifts.
|
|
36
|
+
|
|
37
|
+
**envlock** snapshots a project's Python packages, Node.js dependencies and system runtimes, then tells you
|
|
38
|
+
exactly what changed — so "works on my machine" stops being an excuse. Use it locally, in CI, or to compare
|
|
39
|
+
staging with production.
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## ✨ What it tracks
|
|
44
|
+
|
|
45
|
+
| Layer | What's captured |
|
|
46
|
+
|-------|----------------|
|
|
47
|
+
| **Python** | Interpreter and version (the project's `.venv` / `venv` / `env`, or the active virtualenv), installed packages, `requirements.txt`, `pyproject.toml` dependencies |
|
|
48
|
+
| **Node.js** | Node and npm versions, `package.json` dependencies + devDependencies, `package-lock.json` resolved versions, `yarn.lock` presence |
|
|
49
|
+
| **System** | OS, architecture, Go / Ruby / Java / Rust versions, relevant env vars (`VIRTUAL_ENV`, `NODE_ENV`, `JAVA_HOME`…) |
|
|
50
|
+
|
|
51
|
+
### Severity levels
|
|
52
|
+
|
|
53
|
+
| Severity | Examples |
|
|
54
|
+
|----------|----------|
|
|
55
|
+
| ⛔ **CRITICAL** | Python/Node major or minor version changed (3.12 → 3.13), OS or architecture changed |
|
|
56
|
+
| ⚠️ **WARNING** | Package removed or downgraded, declared constraint changed, runtime patch release, lockfile or env var removed |
|
|
57
|
+
| ℹ️ **INFO** | Package added or upgraded, new lockfile or env var |
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## 📥 Installation
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
pip install envlock-cli # the command is `envlock`
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## 🛠 Usage
|
|
70
|
+
|
|
71
|
+
### 1 — Take a baseline snapshot
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
envlock snapshot # saves .envlock.json in the current directory
|
|
75
|
+
envlock snapshot --path /srv/myapp # scan another project
|
|
76
|
+
envlock snapshot --output locks/prod.json # custom location
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### 2 — Check for drift
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
envlock check # terminal report
|
|
83
|
+
envlock check --format json | jq .summary # JSON on stdout (progress goes to stderr)
|
|
84
|
+
envlock check --format markdown -o drift-report.md # Markdown report file
|
|
85
|
+
envlock check --baseline locks/prod.json --fail-on critical
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### 3 — Diff any two snapshots
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
envlock diff staging.json production.json
|
|
92
|
+
envlock diff before.json after.json --format markdown -o drift.md
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
## 📄 Example output
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
╔════════════════════════════════════════╗
|
|
101
|
+
║ envlock · environment drift report ║
|
|
102
|
+
╚════════════════════════════════════════╝
|
|
103
|
+
|
|
104
|
+
Baseline 2026-05-10T09:00:00+00:00 (dev-laptop) /srv/app
|
|
105
|
+
Current 2026-05-12T14:22:18+00:00 (dev-laptop) /srv/app
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
── Python Runtime ────────────────────────────
|
|
109
|
+
⛔ ~ Python version changed 3.12.1 → 3.13.0
|
|
110
|
+
|
|
111
|
+
── Python Packages ───────────────────────────
|
|
112
|
+
· + pip: httpx added 0.27.0
|
|
113
|
+
· ~ pip: requests upgraded 2.31.0 → 2.32.3
|
|
114
|
+
⚠ ~ pip: urllib3 downgraded 2.0.7 → 1.26.18
|
|
115
|
+
|
|
116
|
+
── Node.js Packages ──────────────────────────
|
|
117
|
+
· + npm dep: express added ^4.19.2
|
|
118
|
+
|
|
119
|
+
────────────────────────────────────────────
|
|
120
|
+
5 changes detected (1 critical, 1 warning, 3 info)
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
---
|
|
124
|
+
|
|
125
|
+
## 🔁 Use in CI
|
|
126
|
+
|
|
127
|
+
The exit code tells your pipeline what happened:
|
|
128
|
+
|
|
129
|
+
| Exit code | Meaning |
|
|
130
|
+
|-----------|---------|
|
|
131
|
+
| `0` | No drift at or above `--fail-on` |
|
|
132
|
+
| `1` | Drift detected |
|
|
133
|
+
| `2` | Error (missing or invalid baseline, unreadable path) |
|
|
134
|
+
|
|
135
|
+
```yaml
|
|
136
|
+
# GitHub Actions — fail only on critical drift, keep the report as an artifact
|
|
137
|
+
- name: Check environment drift
|
|
138
|
+
run: envlock check --baseline .envlock.json --fail-on critical --format markdown -o drift-report.md
|
|
139
|
+
- uses: actions/upload-artifact@v4
|
|
140
|
+
if: always()
|
|
141
|
+
with:
|
|
142
|
+
name: drift-report
|
|
143
|
+
path: drift-report.md
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
## 📋 Options
|
|
149
|
+
|
|
150
|
+
### `envlock snapshot`
|
|
151
|
+
| Option | Description |
|
|
152
|
+
|--------|-------------|
|
|
153
|
+
| `-p`, `--path DIR` | Project directory to scan (default: `.`) |
|
|
154
|
+
| `-o`, `--output FILE` | Where to save the baseline (default: `.envlock.json`) |
|
|
155
|
+
|
|
156
|
+
### `envlock check` / `envlock diff BASELINE CURRENT`
|
|
157
|
+
| Option | Description |
|
|
158
|
+
|--------|-------------|
|
|
159
|
+
| `-b`, `--baseline FILE` | *(check)* Baseline to compare against (default: `.envlock.json`) |
|
|
160
|
+
| `-p`, `--path DIR` | *(check)* Project directory to scan (default: `.`) |
|
|
161
|
+
| `-f`, `--format` | `terminal` (default), `json` or `markdown` |
|
|
162
|
+
| `-o`, `--output FILE` | Write the report to a file (JSON or Markdown) |
|
|
163
|
+
| `--fail-on LEVEL` | `info` (default: any change), `warning`, `critical` or `never` |
|
|
164
|
+
|
|
165
|
+
---
|
|
166
|
+
|
|
167
|
+
## 🧪 Development
|
|
168
|
+
|
|
169
|
+
```bash
|
|
170
|
+
pip install -e ".[dev]"
|
|
171
|
+
ruff check .
|
|
172
|
+
pytest
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
See [CHANGELOG.md](CHANGELOG.md) for release notes.
|
|
176
|
+
|
|
177
|
+
---
|
|
178
|
+
|
|
179
|
+
## 📝 License
|
|
180
|
+
|
|
181
|
+
MIT — see [LICENSE](LICENSE).
|
|
182
|
+
|
|
183
|
+
## 🌐 Connect
|
|
184
|
+
|
|
185
|
+
[](https://github.com/serber1990)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
envlock/__init__.py,sha256=Pufh-jUt1mxJjrQgISobG7cxRKcwXV8xiI7mB_5FcbE,131
|
|
2
|
+
envlock/__main__.py,sha256=bYt9eEaoRQWdejEHFD8REx9jxVEdZptECFsV7F49Ink,30
|
|
3
|
+
envlock/cli.py,sha256=gRkh3IkRFebQkz3Q4mv4DSpRHyTfb7A6sto_ZEcBohQ,7223
|
|
4
|
+
envlock/collectors.py,sha256=Xr1PYyMhgxAF89LXjdPQEEM06nQc4_k-EZyMb5TLP6E,8451
|
|
5
|
+
envlock/diff.py,sha256=Um_ewf8uhk6V8qzXiueb5OW2-0b7jmJ77Fza78QXauk,8011
|
|
6
|
+
envlock/renderer.py,sha256=JordXij8pa0qFIytekUElmpiYGh4v3d9pCRPXfoNhHA,5794
|
|
7
|
+
envlock/snapshot.py,sha256=Fmx_IviwGZue5DIFBF3-RFedL22OxBMWCrp_84Qpliw,1565
|
|
8
|
+
envlock/versions.py,sha256=lJjzaNVt_MjXtJW3J9P38vaAqXCbA4CVMoaAxQBksrU,1667
|
|
9
|
+
envlock_cli-1.1.0.dist-info/licenses/LICENSE,sha256=rOAaRlMktc-uI4u5mxNvBLTLqUvsyujBQMivrLv2olo,1067
|
|
10
|
+
envlock_cli-1.1.0.dist-info/METADATA,sha256=l5LElDH37PatGBJ-sV5zGyJ3SpZE8sNVqA9V7h277PU,6532
|
|
11
|
+
envlock_cli-1.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
12
|
+
envlock_cli-1.1.0.dist-info/entry_points.txt,sha256=P1oKpwy_78znwOZMnF-KeQjC84byhnpOisTFGf8F2Bs,45
|
|
13
|
+
envlock_cli-1.1.0.dist-info/top_level.txt,sha256=0WkvYcSrDn5BGereto7gPP50GErBODo6H6ncBxyA5Ek,8
|
|
14
|
+
envlock_cli-1.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Serber1990
|
|
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 @@
|
|
|
1
|
+
envlock
|