devsnap 0.1.1__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.
devsnap/__init__.py 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
+ ]
devsnap/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
devsnap/cli.py ADDED
@@ -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())}
devsnap/core.py ADDED
@@ -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,16 @@
1
+ devsnap/__init__.py,sha256=WdnkJOVhmkcC-vxSWnDrQRHrTXNZ39Bp1tog8rEKB0w,318
2
+ devsnap/__main__.py,sha256=4JMK66Wj4uLZTKbF-sT3LAxOsr6buig77PmOkJCRRxw,83
3
+ devsnap/cli.py,sha256=V-GP0UYVHDcT2UkepPDQFD0WouZfEbuw77ysseBWqwI,1945
4
+ devsnap/core.py,sha256=oIuVDtgl4bt1r4sqUpgiWjBhYPea5OUyW_zecoSs_k0,3338
5
+ devsnap/collectors/__init__.py,sha256=StU4CkiD_BotZvHRouce5_QEprx-mohMZ3p6QTvUmuk,384
6
+ devsnap/collectors/base.py,sha256=La6FN40hIQKeZ5-ArokTDzBOcxEfYJ_tJul9VoZXJy0,1409
7
+ devsnap/collectors/env.py,sha256=g_W4-Cy9kXrag9pUZ0oZO6nhrpy3Yf7e1vz83yzvS_I,1042
8
+ devsnap/collectors/python.py,sha256=X3lGt7UR1abtv7SJ3i4gLw5mL4Ll2zb-YJ2Fcj4a9Vw,891
9
+ devsnap/collectors/system.py,sha256=F-dL4JZmbPowuvQZWewKy3Y3SAs7GHvkr4Ed3tyRj6w,800
10
+ devsnap/collectors/tools.py,sha256=epfPJtdfHmGZzl2W5zJPhzB2C2iDk8MdJ0Rh4GUncUQ,1061
11
+ devsnap-0.1.1.dist-info/licenses/LICENSE,sha256=Cj0XVFIO6j89kpPNcfpffKbP5HMCbWcewAPMdY8yNVA,1063
12
+ devsnap-0.1.1.dist-info/METADATA,sha256=wizS4uaeYoOTfFgyTuA5hpKrK6VN6zdH0iq6xwufo3U,3178
13
+ devsnap-0.1.1.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
14
+ devsnap-0.1.1.dist-info/entry_points.txt,sha256=wFe1f1Si5xYvhnR3IIpMdjxPNabfEJE8kiORUlnxVgA,45
15
+ devsnap-0.1.1.dist-info/top_level.txt,sha256=sp2eu36GSRXo5zTKAQXk912hvKZyQhqrQxH3eI9epQE,8
16
+ devsnap-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ devsnap = devsnap.cli:main
@@ -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.
@@ -0,0 +1 @@
1
+ devsnap