devmedic 0.2.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.
- devmedic/__init__.py +1 -0
- devmedic/__main__.py +5 -0
- devmedic/caches.py +173 -0
- devmedic/cli.py +152 -0
- devmedic/dashboard.py +645 -0
- devmedic/junk.py +185 -0
- devmedic/menu.py +295 -0
- devmedic/ports.py +126 -0
- devmedic/procs.py +113 -0
- devmedic/repos.py +129 -0
- devmedic/util.py +109 -0
- devmedic-0.2.0.dist-info/METADATA +133 -0
- devmedic-0.2.0.dist-info/RECORD +17 -0
- devmedic-0.2.0.dist-info/WHEEL +5 -0
- devmedic-0.2.0.dist-info/entry_points.txt +2 -0
- devmedic-0.2.0.dist-info/licenses/LICENSE +21 -0
- devmedic-0.2.0.dist-info/top_level.txt +1 -0
devmedic/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.0"
|
devmedic/__main__.py
ADDED
devmedic/caches.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Global tool caches (pip, npm, cargo, …) plus system things like journald/apt/docker."""
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
import subprocess
|
|
5
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
6
|
+
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
|
|
9
|
+
from .util import (HOME, confirm, console, dir_size, have, human_size, remove_tree,
|
|
10
|
+
run, short_path, size_style)
|
|
11
|
+
|
|
12
|
+
h = lambda *p: os.path.join(HOME, *p)
|
|
13
|
+
|
|
14
|
+
# key, label, path, clean command (None -> delete dir contents), note
|
|
15
|
+
CACHES = [
|
|
16
|
+
("pip", "pip cache", h(".cache", "pip"), ["pip", "cache", "purge"], ""),
|
|
17
|
+
("uv", "uv cache", h(".cache", "uv"), ["uv", "cache", "clean"], ""),
|
|
18
|
+
("npm", "npm cache", h(".npm", "_cacache"), ["npm", "cache", "clean", "--force"], ""),
|
|
19
|
+
("yarn", "yarn cache", h(".cache", "yarn"), ["yarn", "cache", "clean"], ""),
|
|
20
|
+
("pnpm", "pnpm store", h(".local", "share", "pnpm", "store"), ["pnpm", "store", "prune"], "prunes unused only"),
|
|
21
|
+
("cargo", "cargo registry", h(".cargo", "registry"), None, ""),
|
|
22
|
+
("go", "Go module cache", h("go", "pkg", "mod"), ["go", "clean", "-modcache"], ""),
|
|
23
|
+
("gobuild", "Go build cache", h(".cache", "go-build"), ["go", "clean", "-cache"], ""),
|
|
24
|
+
("gradle", "Gradle caches", h(".gradle", "caches"), None, ""),
|
|
25
|
+
("maven", "Maven repo", h(".m2", "repository"), None, ""),
|
|
26
|
+
("hf", "HuggingFace models", h(".cache", "huggingface"), None, "re-download is big!"),
|
|
27
|
+
("thumbs", "Thumbnails", h(".cache", "thumbnails"), None, ""),
|
|
28
|
+
("trash", "Trash", h(".local", "share", "Trash"), None, "permanent!"),
|
|
29
|
+
("vscode", "VS Code cache", h(".config", "Code", "Cache"), None, "close VS Code first"),
|
|
30
|
+
("chrome", "Chrome cache", h(".cache", "google-chrome"), None, "close Chrome first"),
|
|
31
|
+
("firefox", "Firefox cache", h(".cache", "mozilla"), None, "close Firefox first"),
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def journal_usage():
|
|
36
|
+
out = run(["journalctl", "--disk-usage"])
|
|
37
|
+
m = re.search(r"take up ([\d.]+)([KMGT])", out)
|
|
38
|
+
if not m:
|
|
39
|
+
return None
|
|
40
|
+
mult = {"K": 1024, "M": 1024**2, "G": 1024**3, "T": 1024**4}[m.group(2)]
|
|
41
|
+
return int(float(m.group(1)) * mult)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def apt_usage():
|
|
45
|
+
path = "/var/cache/apt/archives"
|
|
46
|
+
return dir_size(path) if os.path.isdir(path) else None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def snap_disabled():
|
|
50
|
+
"""Old disabled snap revisions: list of (name, rev, bytes)."""
|
|
51
|
+
out = run(["snap", "list", "--all"]) if have("snap") else ""
|
|
52
|
+
res = []
|
|
53
|
+
for line in out.splitlines()[1:]:
|
|
54
|
+
cols = line.split()
|
|
55
|
+
if len(cols) >= 3 and "disabled" in line:
|
|
56
|
+
snap_file = f"/var/lib/snapd/snaps/{cols[0]}_{cols[2]}.snap"
|
|
57
|
+
try:
|
|
58
|
+
size = os.stat(snap_file).st_blocks * 512
|
|
59
|
+
except OSError:
|
|
60
|
+
size = 0
|
|
61
|
+
res.append((cols[0], cols[2], size))
|
|
62
|
+
return res
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def docker_usage():
|
|
66
|
+
"""Reclaimable bytes from `docker system df`, or None if unavailable."""
|
|
67
|
+
if not have("docker"):
|
|
68
|
+
return None
|
|
69
|
+
out = run(["docker", "system", "df", "--format", "{{.Reclaimable}}"], timeout=20)
|
|
70
|
+
if not out.strip():
|
|
71
|
+
return None
|
|
72
|
+
total = 0
|
|
73
|
+
for line in out.splitlines():
|
|
74
|
+
m = re.match(r"([\d.]+)\s*([kKMGT]?B)", line.strip())
|
|
75
|
+
if m:
|
|
76
|
+
mult = {"B": 1, "kB": 1000, "KB": 1000, "MB": 1000**2,
|
|
77
|
+
"GB": 1000**3, "TB": 1000**4}[m.group(2)]
|
|
78
|
+
total += int(float(m.group(1)) * mult)
|
|
79
|
+
return total
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def collect():
|
|
83
|
+
present = [c for c in CACHES if os.path.isdir(c[2])]
|
|
84
|
+
with ThreadPoolExecutor(max_workers=8) as pool:
|
|
85
|
+
sizes = list(pool.map(dir_size, [c[2] for c in present]))
|
|
86
|
+
j, a, d = pool.submit(journal_usage), pool.submit(apt_usage), pool.submit(docker_usage)
|
|
87
|
+
snaps = pool.submit(snap_disabled)
|
|
88
|
+
user = [{"key": c[0], "label": c[1], "path": c[2], "cmd": c[3], "note": c[4],
|
|
89
|
+
"size": s} for c, s in zip(present, sizes) if s > 0]
|
|
90
|
+
user.sort(key=lambda r: r["size"], reverse=True)
|
|
91
|
+
system = []
|
|
92
|
+
if j.result() and j.result() > 250 * 1024**2:
|
|
93
|
+
system.append({"key": "journal", "label": "systemd journal logs", "size": j.result(),
|
|
94
|
+
"fix": "sudo journalctl --vacuum-size=200M"})
|
|
95
|
+
if a.result():
|
|
96
|
+
system.append({"key": "apt", "label": "apt package cache", "size": a.result(),
|
|
97
|
+
"fix": "sudo apt clean && sudo apt autoremove --purge"})
|
|
98
|
+
if d.result():
|
|
99
|
+
system.append({"key": "docker", "label": "Docker (reclaimable)", "size": d.result(),
|
|
100
|
+
"fix": "docker system prune (add -a --volumes for everything)"})
|
|
101
|
+
if snaps.result():
|
|
102
|
+
cmds = "; ".join(f"sudo snap remove {n} --revision={r}" for n, r, _ in snaps.result())
|
|
103
|
+
system.append({"key": "snap", "label": f"Old snap revisions ({len(snaps.result())})",
|
|
104
|
+
"size": sum(sz for *_, sz in snaps.result()) or None, "fix": cmds})
|
|
105
|
+
return user, system
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def show(user, system):
|
|
109
|
+
t = Table(title="Caches you can clean", title_justify="left")
|
|
110
|
+
t.add_column("Key", style="bold cyan")
|
|
111
|
+
t.add_column("Size", justify="right")
|
|
112
|
+
t.add_column("What")
|
|
113
|
+
t.add_column("Path", style="dim")
|
|
114
|
+
t.add_column("Note", style="yellow")
|
|
115
|
+
for r in user:
|
|
116
|
+
t.add_row(r["key"], f"[{size_style(r['size'])}]{human_size(r['size'])}[/]",
|
|
117
|
+
r["label"], short_path(r["path"]), r["note"])
|
|
118
|
+
console.print(t)
|
|
119
|
+
total = sum(r["size"] for r in user)
|
|
120
|
+
console.print(f"[bold]{human_size(total)}[/] in user caches. "
|
|
121
|
+
f"Clean with: [bold]devmedic caches --clean pip npm …[/] or [bold]--clean all[/]")
|
|
122
|
+
if system:
|
|
123
|
+
s = Table(title="System (needs sudo / docker)", title_justify="left")
|
|
124
|
+
s.add_column("Size", justify="right")
|
|
125
|
+
s.add_column("What")
|
|
126
|
+
s.add_column("Fix command", style="bold", overflow="fold")
|
|
127
|
+
for r in system:
|
|
128
|
+
size = f"[{size_style(r['size'])}]{human_size(r['size'])}[/]" if r["size"] else "—"
|
|
129
|
+
s.add_row(size, r["label"], r["fix"])
|
|
130
|
+
console.print(s)
|
|
131
|
+
console.print("[dim]devmedic never runs sudo itself — copy the command if you want it.[/]")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def clean(user, keys, assume_yes=False):
|
|
135
|
+
by_key = {r["key"]: r for r in user}
|
|
136
|
+
if "all" in keys:
|
|
137
|
+
# 'all' deliberately excludes the ones with a warning note.
|
|
138
|
+
chosen = [r for r in user if not r["note"]]
|
|
139
|
+
else:
|
|
140
|
+
unknown = [k for k in keys if k not in by_key]
|
|
141
|
+
if unknown:
|
|
142
|
+
console.print(f"[red]Unknown / empty cache(s): {', '.join(unknown)}[/]. "
|
|
143
|
+
f"Available: {', '.join(by_key) or 'none'}")
|
|
144
|
+
chosen = [by_key[k] for k in keys if k in by_key]
|
|
145
|
+
if not chosen:
|
|
146
|
+
return
|
|
147
|
+
total = sum(r["size"] for r in chosen)
|
|
148
|
+
for r in chosen:
|
|
149
|
+
console.print(f" • {r['label']:<22} {human_size(r['size']):>10}"
|
|
150
|
+
+ (f" [yellow]({r['note']})[/]" if r["note"] else ""))
|
|
151
|
+
if not confirm(f"Clean these ({human_size(total)})?", assume_yes):
|
|
152
|
+
return
|
|
153
|
+
for r in chosen:
|
|
154
|
+
ok = False
|
|
155
|
+
if r["cmd"] and have(r["cmd"][0]):
|
|
156
|
+
try:
|
|
157
|
+
ok = subprocess.run(r["cmd"], capture_output=True, timeout=300).returncode == 0
|
|
158
|
+
except (OSError, subprocess.SubprocessError):
|
|
159
|
+
ok = False
|
|
160
|
+
if not ok:
|
|
161
|
+
ok = all(remove_tree(e.path) if e.is_dir(follow_symlinks=False)
|
|
162
|
+
else _unlink(e.path) for e in os.scandir(r["path"]))
|
|
163
|
+
after = dir_size(r["path"]) if os.path.isdir(r["path"]) else 0
|
|
164
|
+
mark = "[green]✔[/]" if ok else "[yellow]~[/]"
|
|
165
|
+
console.print(f"{mark} {r['label']}: freed {human_size(max(r['size'] - after, 0))}")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _unlink(path):
|
|
169
|
+
try:
|
|
170
|
+
os.unlink(path)
|
|
171
|
+
return True
|
|
172
|
+
except OSError:
|
|
173
|
+
return False
|
devmedic/cli.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""devmedic — a health check for your developer machine."""
|
|
2
|
+
import argparse
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from . import __version__, caches, dashboard, junk, menu, ports, procs, repos
|
|
7
|
+
from .util import console
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def cmd_overview(args):
|
|
11
|
+
# Interactive menu in a real terminal; full dashboard when piped/redirected.
|
|
12
|
+
if sys.stdin.isatty() and sys.stdout.isatty():
|
|
13
|
+
menu.run()
|
|
14
|
+
else:
|
|
15
|
+
dashboard.show()
|
|
16
|
+
return 0
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def cmd_all(args):
|
|
20
|
+
dashboard.show()
|
|
21
|
+
return 0
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def cmd_section(args):
|
|
25
|
+
menu.show_section(args.cmd)
|
|
26
|
+
return 0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def cmd_watch(args):
|
|
30
|
+
dashboard.watch(args.interval)
|
|
31
|
+
return 0
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def cmd_ports(args):
|
|
35
|
+
ports.show(args.all)
|
|
36
|
+
return 0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def cmd_kill(args):
|
|
40
|
+
return ports.kill(args.port, args.force, args.yes)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def cmd_junk(args):
|
|
44
|
+
items = junk.scan(args.path, args.older)
|
|
45
|
+
junk.show(items, args.limit, args.path)
|
|
46
|
+
if args.clean:
|
|
47
|
+
junk.clean(items, args.yes, args.limit)
|
|
48
|
+
elif items:
|
|
49
|
+
console.print("[dim]Run again with --clean to pick folders to delete. "
|
|
50
|
+
"--older 30 shows only projects untouched for 30 days.[/]")
|
|
51
|
+
return 0
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def cmd_caches(args):
|
|
55
|
+
user, system = caches.collect()
|
|
56
|
+
caches.show(user, system)
|
|
57
|
+
if args.clean:
|
|
58
|
+
console.print()
|
|
59
|
+
caches.clean(user, args.clean, args.yes)
|
|
60
|
+
return 0
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def cmd_repos(args):
|
|
64
|
+
repos.show(repos.scan(args.path), args.all, args.path)
|
|
65
|
+
return 0
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def cmd_procs(args):
|
|
69
|
+
procs.show(procs.collect(), args.verbose)
|
|
70
|
+
return 0
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def build_parser():
|
|
74
|
+
p = argparse.ArgumentParser(prog="devmedic", description=__doc__)
|
|
75
|
+
p.add_argument("--version", action="version", version=f"devmedic {__version__}")
|
|
76
|
+
sub = p.add_subparsers(dest="cmd")
|
|
77
|
+
|
|
78
|
+
s = sub.add_parser("menu", help="interactive section picker (default)")
|
|
79
|
+
s.set_defaults(func=cmd_overview)
|
|
80
|
+
|
|
81
|
+
s = sub.add_parser("all", aliases=["check"], help="full dashboard on one screen")
|
|
82
|
+
s.set_defaults(func=cmd_all)
|
|
83
|
+
|
|
84
|
+
for key, icon, title, *_ in menu.SECTIONS:
|
|
85
|
+
s = sub.add_parser(key, help=f"{title} section")
|
|
86
|
+
s.set_defaults(func=cmd_section)
|
|
87
|
+
|
|
88
|
+
s = sub.add_parser("watch", help="live full-screen dashboard (Ctrl+C to exit)")
|
|
89
|
+
s.add_argument("-n", "--interval", type=float, default=1.0, metavar="SEC")
|
|
90
|
+
s.set_defaults(func=cmd_watch)
|
|
91
|
+
|
|
92
|
+
s = sub.add_parser("ports", help="what is listening on which port")
|
|
93
|
+
s.add_argument("-a", "--all", action="store_true", help="include system services")
|
|
94
|
+
s.set_defaults(func=cmd_ports)
|
|
95
|
+
|
|
96
|
+
s = sub.add_parser("kill", help="kill whatever holds a port")
|
|
97
|
+
s.add_argument("port", type=int)
|
|
98
|
+
s.add_argument("-f", "--force", action="store_true", help="SIGKILL immediately")
|
|
99
|
+
s.add_argument("-y", "--yes", action="store_true", help="don't ask")
|
|
100
|
+
s.set_defaults(func=cmd_kill)
|
|
101
|
+
|
|
102
|
+
s = sub.add_parser("junk", help="find node_modules, .venv, target/ … and their sizes")
|
|
103
|
+
s.add_argument("path", nargs="?", default=os.path.expanduser("~"))
|
|
104
|
+
s.add_argument("--older", type=int, default=0, metavar="DAYS",
|
|
105
|
+
help="only projects untouched for DAYS days")
|
|
106
|
+
s.add_argument("--limit", type=int, default=40)
|
|
107
|
+
s.add_argument("--clean", action="store_true", help="choose folders to delete")
|
|
108
|
+
s.add_argument("-y", "--yes", action="store_true", help="delete all listed without asking")
|
|
109
|
+
s.set_defaults(func=cmd_junk)
|
|
110
|
+
|
|
111
|
+
s = sub.add_parser("caches", help="tool & system caches (pip, npm, docker, journal …)")
|
|
112
|
+
s.add_argument("--clean", nargs="+", metavar="KEY", help="cache keys to clean, or 'all'")
|
|
113
|
+
s.add_argument("-y", "--yes", action="store_true")
|
|
114
|
+
s.set_defaults(func=cmd_caches)
|
|
115
|
+
|
|
116
|
+
s = sub.add_parser("repos", help="git repos with uncommitted / unpushed work")
|
|
117
|
+
s.add_argument("path", nargs="?", default=os.path.expanduser("~"))
|
|
118
|
+
s.add_argument("-a", "--all", action="store_true", help="show clean repos too")
|
|
119
|
+
s.set_defaults(func=cmd_repos)
|
|
120
|
+
|
|
121
|
+
s = sub.add_parser("procs", help="dev processes grouped by project")
|
|
122
|
+
s.add_argument("-v", "--verbose", action="store_true")
|
|
123
|
+
s.set_defaults(func=cmd_procs)
|
|
124
|
+
return p
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
WSL_URL = "https://learn.microsoft.com/windows/wsl/install"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def main(argv=None):
|
|
131
|
+
args = build_parser().parse_args(argv)
|
|
132
|
+
if sys.platform == "win32":
|
|
133
|
+
console.print("[bold yellow]devmedic doesn't support native Windows yet.[/]\n"
|
|
134
|
+
"It works great inside WSL (Ubuntu on Windows):\n"
|
|
135
|
+
f" 1. Install WSL: [cyan]{WSL_URL}[/]\n"
|
|
136
|
+
" 2. In the Ubuntu terminal: [bold]pipx install devmedic[/]\n"
|
|
137
|
+
"Native Windows support is coming in a future release.")
|
|
138
|
+
return 1
|
|
139
|
+
if not sys.platform.startswith("linux"):
|
|
140
|
+
console.print("[yellow]devmedic is built for Linux; some checks may be "
|
|
141
|
+
"missing on this system.[/]")
|
|
142
|
+
if not getattr(args, "func", None):
|
|
143
|
+
args.func = cmd_overview
|
|
144
|
+
try:
|
|
145
|
+
return args.func(args) or 0
|
|
146
|
+
except KeyboardInterrupt:
|
|
147
|
+
console.print("\n[dim]Interrupted.[/]")
|
|
148
|
+
return 130
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
if __name__ == "__main__":
|
|
152
|
+
sys.exit(main())
|