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 ADDED
@@ -0,0 +1,5 @@
1
+ __version__ = "1.1.0"
2
+
3
+ from .cli import main # noqa: E402 (after __version__: cli imports it)
4
+
5
+ __all__ = ["main", "__version__"]
envlock/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
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
+ [![CI](https://github.com/serber1990/envlock/actions/workflows/ci.yml/badge.svg)](https://github.com/serber1990/envlock/actions/workflows/ci.yml)
32
+ [![PyPI version](https://badge.fury.io/py/envlock-cli.svg)](https://badge.fury.io/py/envlock-cli)
33
+ [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](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
+ [![GitHub](https://img.shields.io/badge/GitHub-@serber1990-181717?style=flat-square&logo=github)](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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ envlock = envlock.cli:main
@@ -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