devsnap 0.1.1__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.
- devsnap-0.1.1/LICENSE +21 -0
- devsnap-0.1.1/PKG-INFO +104 -0
- devsnap-0.1.1/README.md +84 -0
- devsnap-0.1.1/pyproject.toml +35 -0
- devsnap-0.1.1/setup.cfg +4 -0
- devsnap-0.1.1/src/devsnap/__init__.py +13 -0
- devsnap-0.1.1/src/devsnap/__main__.py +6 -0
- devsnap-0.1.1/src/devsnap/cli.py +57 -0
- devsnap-0.1.1/src/devsnap/collectors/__init__.py +16 -0
- devsnap-0.1.1/src/devsnap/collectors/base.py +45 -0
- devsnap-0.1.1/src/devsnap/collectors/env.py +48 -0
- devsnap-0.1.1/src/devsnap/collectors/python.py +31 -0
- devsnap-0.1.1/src/devsnap/collectors/system.py +26 -0
- devsnap-0.1.1/src/devsnap/collectors/tools.py +35 -0
- devsnap-0.1.1/src/devsnap/core.py +98 -0
- devsnap-0.1.1/src/devsnap.egg-info/PKG-INFO +104 -0
- devsnap-0.1.1/src/devsnap.egg-info/SOURCES.txt +21 -0
- devsnap-0.1.1/src/devsnap.egg-info/dependency_links.txt +1 -0
- devsnap-0.1.1/src/devsnap.egg-info/entry_points.txt +2 -0
- devsnap-0.1.1/src/devsnap.egg-info/top_level.txt +1 -0
- devsnap-0.1.1/tests/test_cli.py +21 -0
- devsnap-0.1.1/tests/test_collectors.py +25 -0
- devsnap-0.1.1/tests/test_core.py +37 -0
devsnap-0.1.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 gptfei
|
|
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.
|
devsnap-0.1.1/PKG-INFO
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: devsnap
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Snapshot and diff your development environment — end 'works on my machine' debugging.
|
|
5
|
+
Author: gptfei
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/gptfei/devsnap
|
|
8
|
+
Project-URL: Repository, https://github.com/gptfei/devsnap
|
|
9
|
+
Project-URL: Issues, https://github.com/gptfei/devsnap/issues
|
|
10
|
+
Keywords: environment,snapshot,diff,debugging,devtools,cli
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Software Development :: Debuggers
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# devsnap
|
|
22
|
+
|
|
23
|
+
**Snapshot and diff your development environment — end "works on my machine" debugging.**
|
|
24
|
+
|
|
25
|
+
`devsnap` is a zero-dependency Python CLI that captures your OS, Python,
|
|
26
|
+
tool versions (git, node, docker, go, rust, ...), installed packages and
|
|
27
|
+
selected environment variables into one shareable JSON snapshot. Secrets
|
|
28
|
+
are masked automatically, so a snapshot is safe to paste into a bug report.
|
|
29
|
+
Diff two snapshots and see exactly what differs between two machines.
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install devsnap # once published to PyPI
|
|
35
|
+
# or from source:
|
|
36
|
+
pip install git+https://github.com/gptfei/devsnap
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Requires Python 3.9+. No third-party dependencies.
|
|
40
|
+
|
|
41
|
+
## Usage
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
# Take a snapshot on machine A
|
|
45
|
+
devsnap snap -o machine-a.json
|
|
46
|
+
|
|
47
|
+
# Take one on machine B, then compare
|
|
48
|
+
devsnap snap -o machine-b.json
|
|
49
|
+
devsnap diff machine-a.json machine-b.json
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Example diff output:
|
|
53
|
+
|
|
54
|
+
```text
|
|
55
|
+
[python]
|
|
56
|
+
~ version: 3.11.4 -> 3.12.3
|
|
57
|
+
|
|
58
|
+
[tools]
|
|
59
|
+
~ git: git version 2.40.1 -> git version 2.43.0
|
|
60
|
+
+ docker = Docker version 25.0.3
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`devsnap diff` exits with code 1 when environments differ, so CI can gate
|
|
64
|
+
on environment drift. Run `devsnap snap` without `-o` to print a readable
|
|
65
|
+
report instead of JSON.
|
|
66
|
+
|
|
67
|
+
As a library:
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
from devsnap import collect_snapshot, diff_snapshots
|
|
71
|
+
|
|
72
|
+
mine = collect_snapshot()
|
|
73
|
+
print(mine.to_json())
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Why
|
|
77
|
+
|
|
78
|
+
"It works on my machine" bugs burn hours because the difference between
|
|
79
|
+
two environments is invisible. Existing answers are heavy (full system
|
|
80
|
+
inventory tools) or manual (copying `pip freeze` output around). devsnap
|
|
81
|
+
is the small middle ground: one command, one file, one diff.
|
|
82
|
+
|
|
83
|
+
## Contributing
|
|
84
|
+
|
|
85
|
+
Contributions are welcome — and designed to be easy:
|
|
86
|
+
|
|
87
|
+
- **Add a tool** to `collectors/tools.py` (one line, alphabetical).
|
|
88
|
+
- **Add a collector** for a stack we miss (Java, .NET, PHP, databases...).
|
|
89
|
+
Copy any collector in `src/devsnap/collectors/`, see `base.py`.
|
|
90
|
+
- **Report** a machine where a snapshot comes out wrong.
|
|
91
|
+
|
|
92
|
+
Read [CONTRIBUTING.md](CONTRIBUTING.md), and look for issues labelled
|
|
93
|
+
`good first issue`.
|
|
94
|
+
|
|
95
|
+
## 中文简介
|
|
96
|
+
|
|
97
|
+
`devsnap` 是一个零依赖的 Python 命令行工具:一键把操作系统、Python、
|
|
98
|
+
常用工具版本、已装包和(自动打码后的)环境变量拍成一份 JSON 快照,
|
|
99
|
+
可以安全地贴进 issue 里。两份快照一 diff,“在我机器上能跑”的差异
|
|
100
|
+
立刻现形。
|
|
101
|
+
|
|
102
|
+
## License
|
|
103
|
+
|
|
104
|
+
MIT — see [LICENSE](LICENSE).
|
devsnap-0.1.1/README.md
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# devsnap
|
|
2
|
+
|
|
3
|
+
**Snapshot and diff your development environment — end "works on my machine" debugging.**
|
|
4
|
+
|
|
5
|
+
`devsnap` is a zero-dependency Python CLI that captures your OS, Python,
|
|
6
|
+
tool versions (git, node, docker, go, rust, ...), installed packages and
|
|
7
|
+
selected environment variables into one shareable JSON snapshot. Secrets
|
|
8
|
+
are masked automatically, so a snapshot is safe to paste into a bug report.
|
|
9
|
+
Diff two snapshots and see exactly what differs between two machines.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install devsnap # once published to PyPI
|
|
15
|
+
# or from source:
|
|
16
|
+
pip install git+https://github.com/gptfei/devsnap
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Requires Python 3.9+. No third-party dependencies.
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
# Take a snapshot on machine A
|
|
25
|
+
devsnap snap -o machine-a.json
|
|
26
|
+
|
|
27
|
+
# Take one on machine B, then compare
|
|
28
|
+
devsnap snap -o machine-b.json
|
|
29
|
+
devsnap diff machine-a.json machine-b.json
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Example diff output:
|
|
33
|
+
|
|
34
|
+
```text
|
|
35
|
+
[python]
|
|
36
|
+
~ version: 3.11.4 -> 3.12.3
|
|
37
|
+
|
|
38
|
+
[tools]
|
|
39
|
+
~ git: git version 2.40.1 -> git version 2.43.0
|
|
40
|
+
+ docker = Docker version 25.0.3
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`devsnap diff` exits with code 1 when environments differ, so CI can gate
|
|
44
|
+
on environment drift. Run `devsnap snap` without `-o` to print a readable
|
|
45
|
+
report instead of JSON.
|
|
46
|
+
|
|
47
|
+
As a library:
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from devsnap import collect_snapshot, diff_snapshots
|
|
51
|
+
|
|
52
|
+
mine = collect_snapshot()
|
|
53
|
+
print(mine.to_json())
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Why
|
|
57
|
+
|
|
58
|
+
"It works on my machine" bugs burn hours because the difference between
|
|
59
|
+
two environments is invisible. Existing answers are heavy (full system
|
|
60
|
+
inventory tools) or manual (copying `pip freeze` output around). devsnap
|
|
61
|
+
is the small middle ground: one command, one file, one diff.
|
|
62
|
+
|
|
63
|
+
## Contributing
|
|
64
|
+
|
|
65
|
+
Contributions are welcome — and designed to be easy:
|
|
66
|
+
|
|
67
|
+
- **Add a tool** to `collectors/tools.py` (one line, alphabetical).
|
|
68
|
+
- **Add a collector** for a stack we miss (Java, .NET, PHP, databases...).
|
|
69
|
+
Copy any collector in `src/devsnap/collectors/`, see `base.py`.
|
|
70
|
+
- **Report** a machine where a snapshot comes out wrong.
|
|
71
|
+
|
|
72
|
+
Read [CONTRIBUTING.md](CONTRIBUTING.md), and look for issues labelled
|
|
73
|
+
`good first issue`.
|
|
74
|
+
|
|
75
|
+
## 中文简介
|
|
76
|
+
|
|
77
|
+
`devsnap` 是一个零依赖的 Python 命令行工具:一键把操作系统、Python、
|
|
78
|
+
常用工具版本、已装包和(自动打码后的)环境变量拍成一份 JSON 快照,
|
|
79
|
+
可以安全地贴进 issue 里。两份快照一 diff,“在我机器上能跑”的差异
|
|
80
|
+
立刻现形。
|
|
81
|
+
|
|
82
|
+
## License
|
|
83
|
+
|
|
84
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "devsnap"
|
|
7
|
+
version = "0.1.1"
|
|
8
|
+
description = "Snapshot and diff your development environment — end 'works on my machine' debugging."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "gptfei" }]
|
|
13
|
+
keywords = ["environment", "snapshot", "diff", "debugging", "devtools", "cli"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Topic :: Software Development :: Debuggers",
|
|
20
|
+
]
|
|
21
|
+
dependencies = []
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://github.com/gptfei/devsnap"
|
|
25
|
+
Repository = "https://github.com/gptfei/devsnap"
|
|
26
|
+
Issues = "https://github.com/gptfei/devsnap/issues"
|
|
27
|
+
|
|
28
|
+
[project.scripts]
|
|
29
|
+
devsnap = "devsnap.cli:main"
|
|
30
|
+
|
|
31
|
+
[tool.setuptools.packages.find]
|
|
32
|
+
where = ["src"]
|
|
33
|
+
|
|
34
|
+
[tool.pytest.ini_options]
|
|
35
|
+
testpaths = ["tests"]
|
devsnap-0.1.1/setup.cfg
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""devsnap — snapshot and diff your development environment."""
|
|
2
|
+
|
|
3
|
+
from .core import Snapshot, collect_snapshot, diff_snapshots, render_diff, render_snapshot
|
|
4
|
+
|
|
5
|
+
__version__ = "0.1.1"
|
|
6
|
+
__all__ = [
|
|
7
|
+
"Snapshot",
|
|
8
|
+
"collect_snapshot",
|
|
9
|
+
"diff_snapshots",
|
|
10
|
+
"render_diff",
|
|
11
|
+
"render_snapshot",
|
|
12
|
+
"__version__",
|
|
13
|
+
]
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Command line interface: ``devsnap snap`` and ``devsnap diff``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .core import Snapshot, collect_snapshot, diff_snapshots, render_diff, render_snapshot
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
13
|
+
parser = argparse.ArgumentParser(
|
|
14
|
+
prog="devsnap",
|
|
15
|
+
description="Snapshot and diff your development environment.",
|
|
16
|
+
)
|
|
17
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
18
|
+
|
|
19
|
+
snap = sub.add_parser("snap", help="take a snapshot of this machine")
|
|
20
|
+
snap.add_argument("-o", "--output", help="write JSON snapshot to this file")
|
|
21
|
+
snap.add_argument(
|
|
22
|
+
"--only",
|
|
23
|
+
help="comma-separated collectors to run (system, python, tools, env)",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
diff = sub.add_parser("diff", help="compare two snapshot JSON files")
|
|
27
|
+
diff.add_argument("old", help="older snapshot JSON file")
|
|
28
|
+
diff.add_argument("new", help="newer snapshot JSON file")
|
|
29
|
+
return parser
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def main(argv: list[str] | None = None) -> int:
|
|
33
|
+
args = build_parser().parse_args(argv)
|
|
34
|
+
|
|
35
|
+
if args.command == "snap":
|
|
36
|
+
only = [part.strip() for part in args.only.split(",")] if args.only else None
|
|
37
|
+
snapshot = collect_snapshot(only=only)
|
|
38
|
+
if args.output:
|
|
39
|
+
Path(args.output).write_text(snapshot.to_json(), encoding="utf-8")
|
|
40
|
+
print(f"Snapshot written to {args.output}")
|
|
41
|
+
else:
|
|
42
|
+
print(render_snapshot(snapshot), end="")
|
|
43
|
+
return 0
|
|
44
|
+
|
|
45
|
+
if args.command == "diff":
|
|
46
|
+
old = Snapshot.from_json(Path(args.old).read_text(encoding="utf-8"))
|
|
47
|
+
new = Snapshot.from_json(Path(args.new).read_text(encoding="utf-8"))
|
|
48
|
+
changes = diff_snapshots(old, new)
|
|
49
|
+
print(render_diff(changes), end="")
|
|
50
|
+
# Non-zero exit when environments differ, so CI can gate on it.
|
|
51
|
+
return 1 if changes else 0
|
|
52
|
+
|
|
53
|
+
return 2
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
if __name__ == "__main__":
|
|
57
|
+
sys.exit(main())
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""All collectors, in snapshot display order."""
|
|
2
|
+
|
|
3
|
+
from .base import Collector
|
|
4
|
+
from .env import EnvCollector
|
|
5
|
+
from .python import PythonCollector
|
|
6
|
+
from .system import SystemCollector
|
|
7
|
+
from .tools import ToolsCollector
|
|
8
|
+
|
|
9
|
+
ALL_COLLECTORS: list[Collector] = [
|
|
10
|
+
SystemCollector(),
|
|
11
|
+
PythonCollector(),
|
|
12
|
+
ToolsCollector(),
|
|
13
|
+
EnvCollector(),
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
__all__ = ["ALL_COLLECTORS", "Collector"]
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Base class for collectors.
|
|
2
|
+
|
|
3
|
+
A collector inspects one slice of the development environment (Python,
|
|
4
|
+
Git, Node, Docker, ...) and returns a flat ``{key: value}`` mapping of
|
|
5
|
+
strings. Collectors must never raise: a missing tool is reported as
|
|
6
|
+
``"not found"`` so a snapshot always succeeds on any machine.
|
|
7
|
+
|
|
8
|
+
Want to add support for a new tool? Copy any existing collector, give it
|
|
9
|
+
a unique ``name``, and register it in ``collectors/__init__.py``.
|
|
10
|
+
See CONTRIBUTING.md — this is the easiest first contribution to devsnap.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import shutil
|
|
16
|
+
import subprocess
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Collector:
|
|
20
|
+
name: str = "base"
|
|
21
|
+
|
|
22
|
+
def collect(self) -> dict[str, str]:
|
|
23
|
+
raise NotImplementedError
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def run_version(argv: list[str], timeout: float = 5.0) -> str:
|
|
27
|
+
"""Run a ``--version``-style command and return its first output line.
|
|
28
|
+
|
|
29
|
+
Returns ``"not found"`` when the executable is missing and
|
|
30
|
+
``"unknown"`` when it exists but produces no usable output.
|
|
31
|
+
"""
|
|
32
|
+
exe = shutil.which(argv[0])
|
|
33
|
+
if exe is None:
|
|
34
|
+
return "not found"
|
|
35
|
+
try:
|
|
36
|
+
proc = subprocess.run(
|
|
37
|
+
argv,
|
|
38
|
+
capture_output=True,
|
|
39
|
+
text=True,
|
|
40
|
+
timeout=timeout,
|
|
41
|
+
)
|
|
42
|
+
except (OSError, subprocess.SubprocessError):
|
|
43
|
+
return "unknown"
|
|
44
|
+
output = (proc.stdout or proc.stderr).strip().splitlines()
|
|
45
|
+
return output[0].strip() if output else "unknown"
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Selected environment variables, with secrets masked.
|
|
2
|
+
|
|
3
|
+
Only a small allow-list of development-relevant variables is captured,
|
|
4
|
+
and any variable whose name looks secret-bearing is masked. Snapshots
|
|
5
|
+
are meant to be pasted into bug reports, so they must be safe to share.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
from .base import Collector
|
|
13
|
+
|
|
14
|
+
CAPTURED = [
|
|
15
|
+
"CI",
|
|
16
|
+
"EDITOR",
|
|
17
|
+
"HOME",
|
|
18
|
+
"LANG",
|
|
19
|
+
"PATH",
|
|
20
|
+
"PWD",
|
|
21
|
+
"SHELL",
|
|
22
|
+
"TERM",
|
|
23
|
+
"TZ",
|
|
24
|
+
"USER",
|
|
25
|
+
"VIRTUAL_ENV",
|
|
26
|
+
"CONDA_DEFAULT_ENV",
|
|
27
|
+
"NODE_ENV",
|
|
28
|
+
"PYTHONPATH",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
SECRET_MARKERS = ("KEY", "TOKEN", "SECRET", "PASSWORD", "PASSWD", "CREDENTIAL")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def mask_value(name: str, value: str) -> str:
|
|
35
|
+
if any(marker in name.upper() for marker in SECRET_MARKERS):
|
|
36
|
+
return "***masked***"
|
|
37
|
+
return value
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class EnvCollector(Collector):
|
|
41
|
+
name = "env"
|
|
42
|
+
|
|
43
|
+
def collect(self) -> dict[str, str]:
|
|
44
|
+
return {
|
|
45
|
+
name: mask_value(name, os.environ[name])
|
|
46
|
+
for name in CAPTURED
|
|
47
|
+
if name in os.environ
|
|
48
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Python interpreter and installed packages."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.metadata
|
|
6
|
+
import platform
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from .base import Collector
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PythonCollector(Collector):
|
|
13
|
+
name = "python"
|
|
14
|
+
|
|
15
|
+
def collect(self) -> dict[str, str]:
|
|
16
|
+
data = {
|
|
17
|
+
"version": platform.python_version(),
|
|
18
|
+
"implementation": platform.python_implementation(),
|
|
19
|
+
"executable": sys.executable,
|
|
20
|
+
}
|
|
21
|
+
try:
|
|
22
|
+
packages = sorted(
|
|
23
|
+
f"{dist.metadata['Name']}=={dist.version}"
|
|
24
|
+
for dist in importlib.metadata.distributions()
|
|
25
|
+
if dist.metadata.get("Name")
|
|
26
|
+
)
|
|
27
|
+
except Exception: # a snapshot must never fail on a broken env
|
|
28
|
+
packages = []
|
|
29
|
+
data["packages_count"] = str(len(packages))
|
|
30
|
+
data["packages"] = ", ".join(packages)
|
|
31
|
+
return data
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Operating system and shell information."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import platform
|
|
7
|
+
|
|
8
|
+
from .base import Collector
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SystemCollector(Collector):
|
|
12
|
+
name = "system"
|
|
13
|
+
|
|
14
|
+
def collect(self) -> dict[str, str]:
|
|
15
|
+
return {
|
|
16
|
+
"os": platform.system(),
|
|
17
|
+
"os_release": platform.release(),
|
|
18
|
+
"os_version": platform.version(),
|
|
19
|
+
"machine": platform.machine(),
|
|
20
|
+
"architecture": platform.architecture()[0] or "unknown",
|
|
21
|
+
"shell": os.environ.get("SHELL", "unknown"),
|
|
22
|
+
"terminal": os.environ.get("TERM", "unknown"),
|
|
23
|
+
"locale": os.environ.get("LANG", "unknown"),
|
|
24
|
+
"timezone": os.environ.get("TZ", "system default"),
|
|
25
|
+
"cpu_count": str(os.cpu_count() or "unknown"),
|
|
26
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Versions of common external development tools.
|
|
2
|
+
|
|
3
|
+
Each tool is one line here. Adding a tool the community uses is a
|
|
4
|
+
welcome contribution — please keep the list alphabetical.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from .base import Collector, run_version
|
|
10
|
+
|
|
11
|
+
# tool name -> argv used to ask it for its version
|
|
12
|
+
TOOLS: dict[str, list[str]] = {
|
|
13
|
+
"bun": ["bun", "--version"],
|
|
14
|
+
"cargo": ["cargo", "--version"],
|
|
15
|
+
"cmake": ["cmake", "--version"],
|
|
16
|
+
"deno": ["deno", "--version"],
|
|
17
|
+
"docker": ["docker", "--version"],
|
|
18
|
+
"git": ["git", "--version"],
|
|
19
|
+
"go": ["go", "version"],
|
|
20
|
+
"java": ["java", "--version"],
|
|
21
|
+
"kubectl": ["kubectl", "version", "--client"],
|
|
22
|
+
"node": ["node", "--version"],
|
|
23
|
+
"npm": ["npm", "--version"],
|
|
24
|
+
"pnpm": ["pnpm", "--version"],
|
|
25
|
+
"ruby": ["ruby", "--version"],
|
|
26
|
+
"rustc": ["rustc", "--version"],
|
|
27
|
+
"yarn": ["yarn", "--version"],
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ToolsCollector(Collector):
|
|
32
|
+
name = "tools"
|
|
33
|
+
|
|
34
|
+
def collect(self) -> dict[str, str]:
|
|
35
|
+
return {name: run_version(argv) for name, argv in sorted(TOOLS.items())}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Snapshot collection, serialisation and diffing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
|
|
9
|
+
from .collectors import ALL_COLLECTORS
|
|
10
|
+
|
|
11
|
+
FORMAT_VERSION = 1
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class Snapshot:
|
|
16
|
+
created_at: str
|
|
17
|
+
sections: dict[str, dict[str, str]] = field(default_factory=dict)
|
|
18
|
+
|
|
19
|
+
def to_dict(self) -> dict:
|
|
20
|
+
return {
|
|
21
|
+
"format": FORMAT_VERSION,
|
|
22
|
+
"created_at": self.created_at,
|
|
23
|
+
"sections": self.sections,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
@classmethod
|
|
27
|
+
def from_dict(cls, data: dict) -> "Snapshot":
|
|
28
|
+
if data.get("format") != FORMAT_VERSION:
|
|
29
|
+
raise ValueError(f"unsupported snapshot format: {data.get('format')!r}")
|
|
30
|
+
return cls(created_at=data["created_at"], sections=data["sections"])
|
|
31
|
+
|
|
32
|
+
def to_json(self) -> str:
|
|
33
|
+
return json.dumps(self.to_dict(), indent=2, ensure_ascii=False, sort_keys=True)
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
def from_json(cls, text: str) -> "Snapshot":
|
|
37
|
+
return cls.from_dict(json.loads(text))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def collect_snapshot(only: list[str] | None = None) -> Snapshot:
|
|
41
|
+
sections: dict[str, dict[str, str]] = {}
|
|
42
|
+
for collector in ALL_COLLECTORS:
|
|
43
|
+
if only and collector.name not in only:
|
|
44
|
+
continue
|
|
45
|
+
try:
|
|
46
|
+
sections[collector.name] = collector.collect()
|
|
47
|
+
except Exception as exc: # one bad collector must not lose the snapshot
|
|
48
|
+
sections[collector.name] = {"error": f"{type(exc).__name__}: {exc}"}
|
|
49
|
+
return Snapshot(
|
|
50
|
+
created_at=datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
|
51
|
+
sections=sections,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def diff_snapshots(old: Snapshot, new: Snapshot) -> dict[str, dict[str, tuple[str | None, str | None]]]:
|
|
56
|
+
"""Return ``{section: {key: (old_value, new_value)}}`` for changed keys only.
|
|
57
|
+
|
|
58
|
+
``None`` marks a key that is absent on that side.
|
|
59
|
+
"""
|
|
60
|
+
diff: dict[str, dict[str, tuple[str | None, str | None]]] = {}
|
|
61
|
+
for section in sorted(set(old.sections) | set(new.sections)):
|
|
62
|
+
old_items = old.sections.get(section, {})
|
|
63
|
+
new_items = new.sections.get(section, {})
|
|
64
|
+
changes = {}
|
|
65
|
+
for key in sorted(set(old_items) | set(new_items)):
|
|
66
|
+
before, after = old_items.get(key), new_items.get(key)
|
|
67
|
+
if before != after:
|
|
68
|
+
changes[key] = (before, after)
|
|
69
|
+
if changes:
|
|
70
|
+
diff[section] = changes
|
|
71
|
+
return diff
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def render_snapshot(snapshot: Snapshot) -> str:
|
|
75
|
+
lines = [f"devsnap snapshot — {snapshot.created_at}", ""]
|
|
76
|
+
for section, items in snapshot.sections.items():
|
|
77
|
+
lines.append(f"[{section}]")
|
|
78
|
+
for key in sorted(items):
|
|
79
|
+
lines.append(f" {key} = {items[key]}")
|
|
80
|
+
lines.append("")
|
|
81
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def render_diff(diff: dict) -> str:
|
|
85
|
+
if not diff:
|
|
86
|
+
return "No differences.\n"
|
|
87
|
+
lines = []
|
|
88
|
+
for section, changes in diff.items():
|
|
89
|
+
lines.append(f"[{section}]")
|
|
90
|
+
for key, (before, after) in changes.items():
|
|
91
|
+
if before is None:
|
|
92
|
+
lines.append(f" + {key} = {after}")
|
|
93
|
+
elif after is None:
|
|
94
|
+
lines.append(f" - {key} = {before}")
|
|
95
|
+
else:
|
|
96
|
+
lines.append(f" ~ {key}: {before} -> {after}")
|
|
97
|
+
lines.append("")
|
|
98
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: devsnap
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Snapshot and diff your development environment — end 'works on my machine' debugging.
|
|
5
|
+
Author: gptfei
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/gptfei/devsnap
|
|
8
|
+
Project-URL: Repository, https://github.com/gptfei/devsnap
|
|
9
|
+
Project-URL: Issues, https://github.com/gptfei/devsnap/issues
|
|
10
|
+
Keywords: environment,snapshot,diff,debugging,devtools,cli
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Software Development :: Debuggers
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# devsnap
|
|
22
|
+
|
|
23
|
+
**Snapshot and diff your development environment — end "works on my machine" debugging.**
|
|
24
|
+
|
|
25
|
+
`devsnap` is a zero-dependency Python CLI that captures your OS, Python,
|
|
26
|
+
tool versions (git, node, docker, go, rust, ...), installed packages and
|
|
27
|
+
selected environment variables into one shareable JSON snapshot. Secrets
|
|
28
|
+
are masked automatically, so a snapshot is safe to paste into a bug report.
|
|
29
|
+
Diff two snapshots and see exactly what differs between two machines.
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install devsnap # once published to PyPI
|
|
35
|
+
# or from source:
|
|
36
|
+
pip install git+https://github.com/gptfei/devsnap
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Requires Python 3.9+. No third-party dependencies.
|
|
40
|
+
|
|
41
|
+
## Usage
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
# Take a snapshot on machine A
|
|
45
|
+
devsnap snap -o machine-a.json
|
|
46
|
+
|
|
47
|
+
# Take one on machine B, then compare
|
|
48
|
+
devsnap snap -o machine-b.json
|
|
49
|
+
devsnap diff machine-a.json machine-b.json
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Example diff output:
|
|
53
|
+
|
|
54
|
+
```text
|
|
55
|
+
[python]
|
|
56
|
+
~ version: 3.11.4 -> 3.12.3
|
|
57
|
+
|
|
58
|
+
[tools]
|
|
59
|
+
~ git: git version 2.40.1 -> git version 2.43.0
|
|
60
|
+
+ docker = Docker version 25.0.3
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`devsnap diff` exits with code 1 when environments differ, so CI can gate
|
|
64
|
+
on environment drift. Run `devsnap snap` without `-o` to print a readable
|
|
65
|
+
report instead of JSON.
|
|
66
|
+
|
|
67
|
+
As a library:
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
from devsnap import collect_snapshot, diff_snapshots
|
|
71
|
+
|
|
72
|
+
mine = collect_snapshot()
|
|
73
|
+
print(mine.to_json())
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Why
|
|
77
|
+
|
|
78
|
+
"It works on my machine" bugs burn hours because the difference between
|
|
79
|
+
two environments is invisible. Existing answers are heavy (full system
|
|
80
|
+
inventory tools) or manual (copying `pip freeze` output around). devsnap
|
|
81
|
+
is the small middle ground: one command, one file, one diff.
|
|
82
|
+
|
|
83
|
+
## Contributing
|
|
84
|
+
|
|
85
|
+
Contributions are welcome — and designed to be easy:
|
|
86
|
+
|
|
87
|
+
- **Add a tool** to `collectors/tools.py` (one line, alphabetical).
|
|
88
|
+
- **Add a collector** for a stack we miss (Java, .NET, PHP, databases...).
|
|
89
|
+
Copy any collector in `src/devsnap/collectors/`, see `base.py`.
|
|
90
|
+
- **Report** a machine where a snapshot comes out wrong.
|
|
91
|
+
|
|
92
|
+
Read [CONTRIBUTING.md](CONTRIBUTING.md), and look for issues labelled
|
|
93
|
+
`good first issue`.
|
|
94
|
+
|
|
95
|
+
## 中文简介
|
|
96
|
+
|
|
97
|
+
`devsnap` 是一个零依赖的 Python 命令行工具:一键把操作系统、Python、
|
|
98
|
+
常用工具版本、已装包和(自动打码后的)环境变量拍成一份 JSON 快照,
|
|
99
|
+
可以安全地贴进 issue 里。两份快照一 diff,“在我机器上能跑”的差异
|
|
100
|
+
立刻现形。
|
|
101
|
+
|
|
102
|
+
## License
|
|
103
|
+
|
|
104
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/devsnap/__init__.py
|
|
5
|
+
src/devsnap/__main__.py
|
|
6
|
+
src/devsnap/cli.py
|
|
7
|
+
src/devsnap/core.py
|
|
8
|
+
src/devsnap.egg-info/PKG-INFO
|
|
9
|
+
src/devsnap.egg-info/SOURCES.txt
|
|
10
|
+
src/devsnap.egg-info/dependency_links.txt
|
|
11
|
+
src/devsnap.egg-info/entry_points.txt
|
|
12
|
+
src/devsnap.egg-info/top_level.txt
|
|
13
|
+
src/devsnap/collectors/__init__.py
|
|
14
|
+
src/devsnap/collectors/base.py
|
|
15
|
+
src/devsnap/collectors/env.py
|
|
16
|
+
src/devsnap/collectors/python.py
|
|
17
|
+
src/devsnap/collectors/system.py
|
|
18
|
+
src/devsnap/collectors/tools.py
|
|
19
|
+
tests/test_cli.py
|
|
20
|
+
tests/test_collectors.py
|
|
21
|
+
tests/test_core.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
devsnap
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
from devsnap.cli import main
|
|
4
|
+
from devsnap.core import Snapshot
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_snap_to_file(tmp_path):
|
|
8
|
+
out = tmp_path / "snap.json"
|
|
9
|
+
assert main(["snap", "-o", str(out), "--only", "system"]) == 0
|
|
10
|
+
data = json.loads(out.read_text())
|
|
11
|
+
assert data["sections"]["system"]["os"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_diff_exit_codes(tmp_path):
|
|
15
|
+
old = Snapshot(created_at="t1", sections={"tools": {"git": "2.40"}})
|
|
16
|
+
new = Snapshot(created_at="t2", sections={"tools": {"git": "2.43"}})
|
|
17
|
+
a, b = tmp_path / "a.json", tmp_path / "b.json"
|
|
18
|
+
a.write_text(old.to_json())
|
|
19
|
+
b.write_text(new.to_json())
|
|
20
|
+
assert main(["diff", str(a), str(b)]) == 1
|
|
21
|
+
assert main(["diff", str(a), str(a)]) == 0
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
|
|
3
|
+
sys.path.insert(0, "src")
|
|
4
|
+
|
|
5
|
+
from devsnap.collectors.base import run_version
|
|
6
|
+
from devsnap.collectors.env import mask_value
|
|
7
|
+
from devsnap.collectors.tools import TOOLS
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_run_version_missing_tool():
|
|
11
|
+
assert run_version(["definitely-not-a-real-tool-xyz", "--version"]) == "not found"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_run_version_real_tool():
|
|
15
|
+
assert run_version(["git", "--version"]).startswith("git version")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_secrets_are_masked():
|
|
19
|
+
assert mask_value("API_TOKEN", "abc123") == "***masked***"
|
|
20
|
+
assert mask_value("AWS_SECRET_ACCESS_KEY", "abc123") == "***masked***"
|
|
21
|
+
assert mask_value("EDITOR", "vim") == "vim"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_tools_are_alphabetical_and_unique():
|
|
25
|
+
assert list(TOOLS) == sorted(TOOLS)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from devsnap.core import Snapshot, collect_snapshot, diff_snapshots, render_diff
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def sample(sections):
|
|
5
|
+
return Snapshot(created_at="2026-01-01T00:00:00+00:00", sections=sections)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_snapshot_json_roundtrip():
|
|
9
|
+
snap = sample({"system": {"os": "Linux"}})
|
|
10
|
+
assert Snapshot.from_json(snap.to_json()).sections == snap.sections
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_collect_has_core_sections():
|
|
14
|
+
snap = collect_snapshot()
|
|
15
|
+
assert "system" in snap.sections
|
|
16
|
+
assert "python" in snap.sections
|
|
17
|
+
assert snap.sections["python"]["version"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_collect_only_filter():
|
|
21
|
+
snap = collect_snapshot(only=["system"])
|
|
22
|
+
assert list(snap.sections) == ["system"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_diff_detects_change_add_remove():
|
|
26
|
+
old = sample({"tools": {"git": "git version 2.40", "node": "v18"}})
|
|
27
|
+
new = sample({"tools": {"git": "git version 2.43", "docker": "Docker version 25"}})
|
|
28
|
+
diff = diff_snapshots(old, new)
|
|
29
|
+
assert diff["tools"]["git"] == ("git version 2.40", "git version 2.43")
|
|
30
|
+
assert diff["tools"]["node"] == ("v18", None)
|
|
31
|
+
assert diff["tools"]["docker"] == (None, "Docker version 25")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_diff_identical_is_empty_and_renders_so():
|
|
35
|
+
snap = sample({"system": {"os": "Linux"}})
|
|
36
|
+
assert diff_snapshots(snap, snap) == {}
|
|
37
|
+
assert render_diff({}) == "No differences.\n"
|