devmedic 0.2.0__tar.gz → 0.3.0__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.
Files changed (24) hide show
  1. {devmedic-0.2.0 → devmedic-0.3.0}/PKG-INFO +17 -5
  2. {devmedic-0.2.0 → devmedic-0.3.0}/README.md +14 -3
  3. devmedic-0.3.0/devmedic/__init__.py +1 -0
  4. {devmedic-0.2.0 → devmedic-0.3.0}/devmedic/caches.py +70 -10
  5. {devmedic-0.2.0 → devmedic-0.3.0}/devmedic/cli.py +14 -10
  6. {devmedic-0.2.0 → devmedic-0.3.0}/devmedic/dashboard.py +99 -28
  7. {devmedic-0.2.0 → devmedic-0.3.0}/devmedic/junk.py +14 -11
  8. {devmedic-0.2.0 → devmedic-0.3.0}/devmedic/menu.py +24 -3
  9. {devmedic-0.2.0 → devmedic-0.3.0}/devmedic/ports.py +28 -17
  10. {devmedic-0.2.0 → devmedic-0.3.0}/devmedic/procs.py +8 -5
  11. {devmedic-0.2.0 → devmedic-0.3.0}/devmedic/repos.py +4 -5
  12. {devmedic-0.2.0 → devmedic-0.3.0}/devmedic/util.py +72 -8
  13. {devmedic-0.2.0 → devmedic-0.3.0}/devmedic.egg-info/PKG-INFO +17 -5
  14. {devmedic-0.2.0 → devmedic-0.3.0}/pyproject.toml +2 -1
  15. {devmedic-0.2.0 → devmedic-0.3.0}/tests/test_devmedic.py +76 -22
  16. devmedic-0.2.0/devmedic/__init__.py +0 -1
  17. {devmedic-0.2.0 → devmedic-0.3.0}/LICENSE +0 -0
  18. {devmedic-0.2.0 → devmedic-0.3.0}/devmedic/__main__.py +0 -0
  19. {devmedic-0.2.0 → devmedic-0.3.0}/devmedic.egg-info/SOURCES.txt +0 -0
  20. {devmedic-0.2.0 → devmedic-0.3.0}/devmedic.egg-info/dependency_links.txt +0 -0
  21. {devmedic-0.2.0 → devmedic-0.3.0}/devmedic.egg-info/entry_points.txt +0 -0
  22. {devmedic-0.2.0 → devmedic-0.3.0}/devmedic.egg-info/requires.txt +0 -0
  23. {devmedic-0.2.0 → devmedic-0.3.0}/devmedic.egg-info/top_level.txt +0 -0
  24. {devmedic-0.2.0 → devmedic-0.3.0}/setup.cfg +0 -0
@@ -1,15 +1,16 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devmedic
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: A health check for your developer machine: ports, junk folders, caches, git repos, dev processes — offline.
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/yaswanthme007/devmedic
7
7
  Project-URL: Issues, https://github.com/yaswanthme007/devmedic/issues
8
- Keywords: developer-tools,cli,linux,ubuntu,cleanup,ports,disk-usage
8
+ Keywords: developer-tools,cli,linux,ubuntu,windows,cleanup,ports,disk-usage
9
9
  Classifier: Environment :: Console
10
10
  Classifier: Intended Audience :: Developers
11
11
  Classifier: License :: OSI Approved :: MIT License
12
12
  Classifier: Operating System :: POSIX :: Linux
13
+ Classifier: Operating System :: Microsoft :: Windows
13
14
  Classifier: Programming Language :: Python :: 3
14
15
  Classifier: Topic :: Software Development
15
16
  Classifier: Topic :: System :: Monitoring
@@ -47,16 +48,24 @@ devmedic answers all of them in seconds.
47
48
 
48
49
  ## Install
49
50
 
50
- Requires **Linux** (built and tested on Ubuntu) and **Python 3.9+**.
51
- On **Windows**, use it inside [WSL](https://learn.microsoft.com/windows/wsl/install) for now; native support is planned.
51
+ Works on **Linux** (built on Ubuntu) and **Windows 10/11**, with **Python 3.9+**.
52
52
 
53
53
  **With pipx (recommended)**
54
54
 
55
+ Linux:
56
+
55
57
  ```bash
56
58
  sudo apt install pipx && pipx ensurepath # once
57
59
  pipx install devmedic
58
60
  ```
59
61
 
62
+ Windows (PowerShell or Windows Terminal):
63
+
64
+ ```powershell
65
+ py -m pip install --user pipx; py -m pipx ensurepath # once, then open a new terminal
66
+ pipx install devmedic
67
+ ```
68
+
60
69
  **With pip**
61
70
 
62
71
  ```bash
@@ -115,7 +124,10 @@ devmedic is careful by design:
115
124
  - **Nothing is deleted without confirmation** (unless you pass `-y`).
116
125
  - **Conservative junk detection:** `target/` only next to `Cargo.toml`/`pom.xml`, venvs only if
117
126
  they contain `pyvenv.cfg`. Generic `build/` and `dist/` folders are **never** touched.
118
- - **Never runs `sudo`.** System fixes (journal, apt, snap, docker) are printed for you to copy.
127
+ - **Links are never followed:** symlinks and Windows junctions (used by pnpm) are skipped when
128
+ scanning and removed as links, so nothing outside the folder is ever deleted.
129
+ - **Never runs `sudo` or Administrator commands.** System fixes (journal, apt, snap, Recycle Bin,
130
+ docker) are printed for you to copy.
119
131
  - **Only kills what you confirm**, and can't touch other users' processes.
120
132
  - **Offline and private:** no network requests, no data collection.
121
133
 
@@ -25,16 +25,24 @@ devmedic answers all of them in seconds.
25
25
 
26
26
  ## Install
27
27
 
28
- Requires **Linux** (built and tested on Ubuntu) and **Python 3.9+**.
29
- On **Windows**, use it inside [WSL](https://learn.microsoft.com/windows/wsl/install) for now; native support is planned.
28
+ Works on **Linux** (built on Ubuntu) and **Windows 10/11**, with **Python 3.9+**.
30
29
 
31
30
  **With pipx (recommended)**
32
31
 
32
+ Linux:
33
+
33
34
  ```bash
34
35
  sudo apt install pipx && pipx ensurepath # once
35
36
  pipx install devmedic
36
37
  ```
37
38
 
39
+ Windows (PowerShell or Windows Terminal):
40
+
41
+ ```powershell
42
+ py -m pip install --user pipx; py -m pipx ensurepath # once, then open a new terminal
43
+ pipx install devmedic
44
+ ```
45
+
38
46
  **With pip**
39
47
 
40
48
  ```bash
@@ -93,7 +101,10 @@ devmedic is careful by design:
93
101
  - **Nothing is deleted without confirmation** (unless you pass `-y`).
94
102
  - **Conservative junk detection:** `target/` only next to `Cargo.toml`/`pom.xml`, venvs only if
95
103
  they contain `pyvenv.cfg`. Generic `build/` and `dist/` folders are **never** touched.
96
- - **Never runs `sudo`.** System fixes (journal, apt, snap, docker) are printed for you to copy.
104
+ - **Links are never followed:** symlinks and Windows junctions (used by pnpm) are skipped when
105
+ scanning and removed as links, so nothing outside the folder is ever deleted.
106
+ - **Never runs `sudo` or Administrator commands.** System fixes (journal, apt, snap, Recycle Bin,
107
+ docker) are printed for you to copy.
97
108
  - **Only kills what you confirm**, and can't touch other users' processes.
98
109
  - **Offline and private:** no network requests, no data collection.
99
110
 
@@ -0,0 +1 @@
1
+ __version__ = "0.3.0"
@@ -6,13 +6,35 @@ from concurrent.futures import ThreadPoolExecutor
6
6
 
7
7
  from rich.table import Table
8
8
 
9
- from .util import (HOME, confirm, console, dir_size, have, human_size, remove_tree,
10
- run, short_path, size_style)
9
+ from .util import (HOME, WINDOWS, confirm, console, dir_size, have, human_size,
10
+ is_real_dir, remove_tree, run, run_full, short_path, size_style)
11
11
 
12
12
  h = lambda *p: os.path.join(HOME, *p)
13
+ LOCAL = os.environ.get("LOCALAPPDATA") or h("AppData", "Local")
14
+ ROAMING = os.environ.get("APPDATA") or h("AppData", "Roaming")
15
+ lo = lambda *p: os.path.join(LOCAL, *p)
16
+ ro = lambda *p: os.path.join(ROAMING, *p)
13
17
 
14
18
  # key, label, path, clean command (None -> delete dir contents), note
15
- CACHES = [
19
+ WINDOWS_CACHES = [
20
+ ("pip", "pip cache", lo("pip", "Cache"), ["pip", "cache", "purge"], ""),
21
+ ("uv", "uv cache", lo("uv", "cache"), ["uv", "cache", "clean"], ""),
22
+ ("npm", "npm cache", lo("npm-cache", "_cacache"), ["npm", "cache", "clean", "--force"], ""),
23
+ ("yarn", "yarn cache", lo("Yarn", "Cache"), ["yarn", "cache", "clean"], ""),
24
+ ("pnpm", "pnpm store", lo("pnpm", "store"), ["pnpm", "store", "prune"], "prunes unused only"),
25
+ ("cargo", "cargo registry", h(".cargo", "registry"), None, ""),
26
+ ("go", "Go module cache", h("go", "pkg", "mod"), ["go", "clean", "-modcache"], ""),
27
+ ("gobuild", "Go build cache", lo("go-build"), ["go", "clean", "-cache"], ""),
28
+ ("gradle", "Gradle caches", h(".gradle", "caches"), None, ""),
29
+ ("maven", "Maven repo", h(".m2", "repository"), None, ""),
30
+ ("nuget", "NuGet packages", h(".nuget", "packages"), ["dotnet", "nuget", "locals", "all", "--clear"], ""),
31
+ ("hf", "HuggingFace models", h(".cache", "huggingface"), None, "re-download is big!"),
32
+ ("temp", "Temp files", os.environ.get("TEMP") or lo("Temp"), None, "files in use are skipped"),
33
+ ("vscode", "VS Code cache", ro("Code", "Cache"), None, "close VS Code first"),
34
+ ("chrome", "Chrome cache", lo("Google", "Chrome", "User Data", "Default", "Cache"), None, "close Chrome first"),
35
+ ("edge", "Edge cache", lo("Microsoft", "Edge", "User Data", "Default", "Cache"), None, "close Edge first"),
36
+ ]
37
+ LINUX_CACHES = [
16
38
  ("pip", "pip cache", h(".cache", "pip"), ["pip", "cache", "purge"], ""),
17
39
  ("uv", "uv cache", h(".cache", "uv"), ["uv", "cache", "clean"], ""),
18
40
  ("npm", "npm cache", h(".npm", "_cacache"), ["npm", "cache", "clean", "--force"], ""),
@@ -30,6 +52,28 @@ CACHES = [
30
52
  ("chrome", "Chrome cache", h(".cache", "google-chrome"), None, "close Chrome first"),
31
53
  ("firefox", "Firefox cache", h(".cache", "mozilla"), None, "close Firefox first"),
32
54
  ]
55
+ CACHES = WINDOWS_CACHES if WINDOWS else LINUX_CACHES
56
+
57
+
58
+ def recycle_bin_usage():
59
+ """Bytes in the Windows Recycle Bin (all drives), or None."""
60
+ if not WINDOWS:
61
+ return None
62
+ import ctypes
63
+ from ctypes import wintypes
64
+
65
+ class SHQUERYRBINFO(ctypes.Structure):
66
+ _fields_ = [("cbSize", wintypes.DWORD), ("i64Size", ctypes.c_longlong),
67
+ ("i64NumItems", ctypes.c_longlong)]
68
+
69
+ info = SHQUERYRBINFO()
70
+ info.cbSize = ctypes.sizeof(info)
71
+ try:
72
+ if ctypes.windll.shell32.SHQueryRecycleBinW(None, ctypes.byref(info)) != 0:
73
+ return None
74
+ except (AttributeError, OSError):
75
+ return None
76
+ return info.i64Size or None
33
77
 
34
78
 
35
79
  def journal_usage():
@@ -85,6 +129,7 @@ def collect():
85
129
  sizes = list(pool.map(dir_size, [c[2] for c in present]))
86
130
  j, a, d = pool.submit(journal_usage), pool.submit(apt_usage), pool.submit(docker_usage)
87
131
  snaps = pool.submit(snap_disabled)
132
+ rbin = pool.submit(recycle_bin_usage)
88
133
  user = [{"key": c[0], "label": c[1], "path": c[2], "cmd": c[3], "note": c[4],
89
134
  "size": s} for c, s in zip(present, sizes) if s > 0]
90
135
  user.sort(key=lambda r: r["size"], reverse=True)
@@ -95,6 +140,9 @@ def collect():
95
140
  if a.result():
96
141
  system.append({"key": "apt", "label": "apt package cache", "size": a.result(),
97
142
  "fix": "sudo apt clean && sudo apt autoremove --purge"})
143
+ if rbin.result():
144
+ system.append({"key": "recyclebin", "label": "Recycle Bin", "size": rbin.result(),
145
+ "fix": "Clear-RecycleBin -Force (PowerShell, permanent!)"})
98
146
  if d.result():
99
147
  system.append({"key": "docker", "label": "Docker (reclaimable)", "size": d.result(),
100
148
  "fix": "docker system prune (add -a --volumes for everything)"})
@@ -120,7 +168,8 @@ def show(user, system):
120
168
  console.print(f"[bold]{human_size(total)}[/] in user caches. "
121
169
  f"Clean with: [bold]devmedic caches --clean pip npm …[/] or [bold]--clean all[/]")
122
170
  if system:
123
- s = Table(title="System (needs sudo / docker)", title_justify="left")
171
+ needs = "Administrator / PowerShell" if WINDOWS else "sudo / docker"
172
+ s = Table(title=f"System (needs {needs})", title_justify="left")
124
173
  s.add_column("Size", justify="right")
125
174
  s.add_column("What")
126
175
  s.add_column("Fix command", style="bold", overflow="fold")
@@ -128,7 +177,8 @@ def show(user, system):
128
177
  size = f"[{size_style(r['size'])}]{human_size(r['size'])}[/]" if r["size"] else "—"
129
178
  s.add_row(size, r["label"], r["fix"])
130
179
  console.print(s)
131
- console.print("[dim]devmedic never runs sudo itself — copy the command if you want it.[/]")
180
+ console.print("[dim]devmedic never runs system-wide commands itself — "
181
+ "copy the command if you want it.[/]")
132
182
 
133
183
 
134
184
  def clean(user, keys, assume_yes=False):
@@ -154,20 +204,30 @@ def clean(user, keys, assume_yes=False):
154
204
  ok = False
155
205
  if r["cmd"] and have(r["cmd"][0]):
156
206
  try:
157
- ok = subprocess.run(r["cmd"], capture_output=True, timeout=300).returncode == 0
207
+ ok = run_full(r["cmd"], timeout=300).returncode == 0
158
208
  except (OSError, subprocess.SubprocessError):
159
209
  ok = False
160
210
  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"]))
211
+ # Try every entry (a list, not a generator: one locked file must not
212
+ # stop the rest from being cleaned).
213
+ try:
214
+ entries = list(os.scandir(r["path"]))
215
+ except OSError:
216
+ entries = []
217
+ ok = all([_remove_entry(e) for e in entries])
163
218
  after = dir_size(r["path"]) if os.path.isdir(r["path"]) else 0
164
219
  mark = "[green]✔[/]" if ok else "[yellow]~[/]"
165
220
  console.print(f"{mark} {r['label']}: freed {human_size(max(r['size'] - after, 0))}")
166
221
 
167
222
 
168
- def _unlink(path):
223
+ def _remove_entry(entry):
224
+ if is_real_dir(entry):
225
+ return remove_tree(entry.path)
169
226
  try:
170
- os.unlink(path)
227
+ if entry.is_dir(follow_symlinks=False):
228
+ os.rmdir(entry.path) # Windows junction: remove the link, never the target
229
+ else:
230
+ os.unlink(entry.path)
171
231
  return True
172
232
  except OSError:
173
233
  return False
@@ -124,21 +124,25 @@ def build_parser():
124
124
  return p
125
125
 
126
126
 
127
- WSL_URL = "https://learn.microsoft.com/windows/wsl/install"
127
+ def utf8_output():
128
+ """Windows pipes/redirects default to a legacy code page that can't print
129
+ emoji or box-drawing characters; switch to UTF-8 instead of crashing."""
130
+ for stream in (sys.stdout, sys.stderr):
131
+ enc = (getattr(stream, "encoding", "") or "").lower().replace("-", "")
132
+ if enc != "utf8" and hasattr(stream, "reconfigure"):
133
+ try:
134
+ stream.reconfigure(encoding="utf-8", errors="replace")
135
+ except (OSError, ValueError):
136
+ pass
128
137
 
129
138
 
130
139
  def main(argv=None):
131
140
  args = build_parser().parse_args(argv)
132
141
  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
+ utf8_output()
143
+ elif not sys.platform.startswith("linux"):
144
+ console.print("[yellow]devmedic supports Linux and Windows; on this system "
145
+ "some checks may be missing.[/]")
142
146
  if not getattr(args, "func", None):
143
147
  args.func = cmd_overview
144
148
  try:
@@ -18,8 +18,8 @@ from rich.table import Table
18
18
  from rich.text import Text
19
19
 
20
20
  from . import caches, junk, ports, procs, repos
21
- from .util import (HOME, console, dir_size, have, human_age, human_size, run,
22
- short_path)
21
+ from .util import (HOME, WINDOWS, console, dir_size, have, human_age, human_size, run,
22
+ run_full, short_path)
23
23
 
24
24
  # ───────────────────────── visuals ─────────────────────────
25
25
 
@@ -93,7 +93,22 @@ def panel(body, title, color):
93
93
 
94
94
  # ───────────────────────── collectors ─────────────────────────
95
95
 
96
+ def windows_version():
97
+ """'Windows 11 Pro' — platform.release() says '10' on Windows 11, so use the build."""
98
+ ver = platform.version() # e.g. 10.0.22631
99
+ try:
100
+ build = int(ver.split(".")[2])
101
+ except (IndexError, ValueError):
102
+ build = 0
103
+ name = "11" if build >= 22000 else platform.release()
104
+ edition = getattr(platform, "win32_edition", lambda: "")() or ""
105
+ edition = {"Professional": "Pro", "Core": "Home"}.get(edition, edition)
106
+ return f"Windows {name} {edition}".strip()
107
+
108
+
96
109
  def os_name():
110
+ if WINDOWS:
111
+ return windows_version()
97
112
  try:
98
113
  with open("/etc/os-release") as f:
99
114
  for line in f:
@@ -104,14 +119,25 @@ def os_name():
104
119
  return platform.system()
105
120
 
106
121
 
122
+ def tidy_cpu(name):
123
+ name = re.sub(r"\((R|TM)\)|CPU|Processor|\s@.*|\d+-Core", "", name)
124
+ return " ".join(name.split())
125
+
126
+
107
127
  def cpu_model():
128
+ if WINDOWS:
129
+ try:
130
+ import winreg
131
+ with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE,
132
+ r"HARDWARE\DESCRIPTION\System\CentralProcessor\0") as k:
133
+ return tidy_cpu(winreg.QueryValueEx(k, "ProcessorNameString")[0])
134
+ except OSError:
135
+ pass
108
136
  try:
109
137
  with open("/proc/cpuinfo") as f:
110
138
  for line in f:
111
139
  if line.startswith("model name"):
112
- name = line.split(":", 1)[1].strip()
113
- name = re.sub(r"\((R|TM)\)|CPU|Processor|\s@.*", "", name)
114
- return " ".join(name.split())
140
+ return tidy_cpu(line.split(":", 1)[1])
115
141
  except OSError:
116
142
  pass
117
143
  return platform.processor() or "unknown CPU"
@@ -136,15 +162,32 @@ def battery():
136
162
  return None
137
163
 
138
164
 
165
+ def load_average():
166
+ try:
167
+ return psutil.getloadavg() # emulated on Windows (zeros on the first call)
168
+ except (AttributeError, OSError):
169
+ return (0.0, 0.0, 0.0)
170
+
171
+
172
+ def shell_name():
173
+ """The shell devmedic was started from (bash, zsh, pwsh, cmd…)."""
174
+ try:
175
+ name = psutil.Process(os.getppid()).name()
176
+ except psutil.Error:
177
+ name = os.environ.get("SHELL", "")
178
+ name = os.path.splitext(os.path.basename(name))[0]
179
+ return {"powershell": "Windows PowerShell"}.get(name.lower(), name)
180
+
181
+
139
182
  def system_info():
140
183
  return {
141
184
  "user": getpass.getuser(), "host": socket.gethostname(), "os": os_name(),
142
- "kernel": platform.release(), "cpu": cpu_model(),
185
+ "kernel": platform.version() if WINDOWS else platform.release(), "cpu": cpu_model(),
143
186
  "cores": psutil.cpu_count(logical=True),
144
187
  "phys": psutil.cpu_count(logical=False),
145
- "boot": psutil.boot_time(), "load": os.getloadavg(),
188
+ "boot": psutil.boot_time(), "load": load_average(),
146
189
  "temp": temperature(), "battery": battery(),
147
- "shell": os.path.basename(os.environ.get("SHELL", "")),
190
+ "shell": shell_name(),
148
191
  }
149
192
 
150
193
 
@@ -155,8 +198,10 @@ def memory_hogs(limit=5):
155
198
  if mi and p.info["name"]:
156
199
  name = p.info["name"]
157
200
  # Group chrome/chromium helpers, "Web Content", etc. under the app.
158
- name = re.sub(r"^(chrome|chromium|firefox|code|electron)\b.*", r"\1", name,
159
- flags=re.I)
201
+ if name.lower().endswith(".exe"):
202
+ name = name[:-4]
203
+ name = re.sub(r"^(chrome|chromium|firefox|code|electron|brave|msedge)\b.*", r"\1",
204
+ name, flags=re.I)
160
205
  by_name[name][0] += mi.rss
161
206
  by_name[name][1] += 1
162
207
  return sorted(((n, rss, c) for n, (rss, c) in by_name.items()),
@@ -166,7 +211,9 @@ def memory_hogs(limit=5):
166
211
  def disks():
167
212
  seen, out = set(), []
168
213
  for p in psutil.disk_partitions(all=False):
169
- if p.fstype in ("squashfs", "tmpfs", "overlay") or p.device in seen:
214
+ if p.fstype in ("squashfs", "tmpfs", "overlay", "") or p.device in seen:
215
+ continue # "" = empty CD/DVD drive on Windows
216
+ if "cdrom" in p.opts:
170
217
  continue
171
218
  if p.mountpoint.startswith(("/snap", "/var/snap", "/run")):
172
219
  continue
@@ -181,7 +228,8 @@ def disks():
181
228
 
182
229
 
183
230
  TOOLS = [ # label, version command
184
- ("git", ["git", "--version"]), ("python", ["python3", "--version"]),
231
+ ("git", ["git", "--version"]),
232
+ ("python", ["python", "--version"] if WINDOWS else ["python3", "--version"]),
185
233
  ("pip", ["pip3", "--version"]), ("node", ["node", "--version"]),
186
234
  ("npm", ["npm", "--version"]), ("pnpm", ["pnpm", "--version"]),
187
235
  ("yarn", ["yarn", "--version"]), ("bun", ["bun", "--version"]),
@@ -192,6 +240,10 @@ TOOLS = [ # label, version command
192
240
  ("gh", ["gh", "--version"]), ("code", ["code", "--version"]),
193
241
  ("nvim", ["nvim", "--version"]), ("vim", ["vim", "--version"]),
194
242
  ]
243
+ if WINDOWS:
244
+ TOOLS = [t for t in TOOLS if t[0] not in ("gcc", "make", "vim")] + [
245
+ ("pwsh", ["pwsh", "--version"]), ("winget", ["winget", "--version"]),
246
+ ("wsl", ["wsl", "--version"]), ("dotnet", ["dotnet", "--version"])]
195
247
  VERSION_RE = re.compile(r"(\d+\.\d+(?:\.\d+)?)")
196
248
 
197
249
 
@@ -199,11 +251,13 @@ def tool_version(cmd):
199
251
  if not have(cmd[0]):
200
252
  return None
201
253
  try:
202
- r = subprocess.run(cmd, capture_output=True, text=True, timeout=4)
254
+ r = run_full(cmd, timeout=4)
203
255
  except (OSError, subprocess.SubprocessError):
204
256
  return "?"
205
- m = VERSION_RE.search(r.stdout + r.stderr)
206
- return m.group(1) if m else "?"
257
+ # No version in the output = not really installed (e.g. Windows' "python"
258
+ # shortcut that just opens the Microsoft Store).
259
+ m = VERSION_RE.search((r.stdout + r.stderr).replace("\x00", ""))
260
+ return m.group(1) if m else None
207
261
 
208
262
 
209
263
  def dev_tools():
@@ -216,8 +270,8 @@ def docker_status():
216
270
  if not have("docker"):
217
271
  return {"state": "missing"}
218
272
  try:
219
- r = subprocess.run(["docker", "ps", "--format", "{{.Names}}\t{{.Image}}\t{{.Status}}"],
220
- capture_output=True, text=True, timeout=5)
273
+ r = run_full(["docker", "ps", "--format", "{{.Names}}\t{{.Image}}\t{{.Status}}"],
274
+ timeout=5)
221
275
  except (OSError, subprocess.SubprocessError):
222
276
  return {"state": "down"}
223
277
  if "permission denied" in r.stderr.lower():
@@ -232,21 +286,34 @@ def network():
232
286
  ips = []
233
287
  stats = psutil.net_if_stats()
234
288
  for name, addrs in psutil.net_if_addrs().items():
235
- if name == "lo" or name.startswith(("docker", "br-", "veth", "virbr")):
289
+ if name == "lo" or name.startswith(("docker", "br-", "veth", "virbr", "Loopback",
290
+ "vEthernet", "VirtualBox", "VMware")):
236
291
  continue
237
292
  if name in stats and not stats[name].isup:
238
293
  continue
239
294
  for a in addrs:
240
- if a.family == socket.AF_INET:
295
+ if a.family == socket.AF_INET and not a.address.startswith("169.254."):
241
296
  ips.append((name, a.address))
242
- wifi = None
243
- if have("nmcli"):
244
- for line in run(["nmcli", "-t", "-f", "active,ssid", "dev", "wifi"], timeout=3).splitlines():
245
- if line.startswith("yes:"):
246
- wifi = line[4:] or None
247
- break
248
297
  io = psutil.net_io_counters()
249
- return {"ips": ips, "wifi": wifi, "sent": io.bytes_sent, "recv": io.bytes_recv}
298
+ return {"ips": ips, "wifi": wifi_name(), "sent": io.bytes_sent, "recv": io.bytes_recv}
299
+
300
+
301
+ def wifi_name():
302
+ if WINDOWS:
303
+ # " SSID : MyNetwork" (skip the BSSID line)
304
+ for line in run(["netsh", "wlan", "show", "interfaces"], timeout=4).splitlines():
305
+ key, _, value = line.partition(":")
306
+ if key.strip() == "SSID" and value.strip():
307
+ return value.strip()
308
+ return None
309
+ if have("nmcli"):
310
+ # Active connections only — `nmcli dev wifi` may trigger a slow rescan.
311
+ for line in run(["nmcli", "-t", "-f", "NAME,TYPE", "connection", "show", "--active"],
312
+ timeout=3).splitlines():
313
+ name, _, kind = line.rpartition(":")
314
+ if kind == "802-11-wireless" and name:
315
+ return name.replace("\\:", ":")
316
+ return None
250
317
 
251
318
 
252
319
  def git_summary():
@@ -329,13 +396,17 @@ def health(d):
329
396
  if stale:
330
397
  hit(3 * len(stale), f"{len(stale)} dev server(s) running > 1 day → [bold]devmedic ports[/]")
331
398
  if d["docker"]["state"] == "denied":
332
- hit(2, "Docker needs sudo → [bold]sudo usermod -aG docker $USER[/] then log out/in")
399
+ hit(2, DOCKER_FIX)
333
400
  bat = d["sys"]["battery"]
334
401
  if bat and not bat.power_plugged and bat.percent < 20:
335
402
  tips.append(f"[yellow]Battery low ({bat.percent:.0f}%)[/] — plug in before long builds")
336
403
  return max(score, 0), tips
337
404
 
338
405
 
406
+ DOCKER_FIX = ("Docker isn't reachable → start [bold]Docker Desktop[/]" if WINDOWS else
407
+ "Docker needs sudo → [bold]sudo usermod -aG docker $USER[/] then log out/in")
408
+
409
+
339
410
  def grade(score):
340
411
  for limit, g, color in ((90, "A", "green"), (75, "B", "cyan"), (60, "C", "yellow")):
341
412
  if score >= limit:
@@ -350,7 +421,7 @@ def header(d, score):
350
421
  g, color = grade(score)
351
422
  info = Text.assemble(
352
423
  (f"{s['user']}", "bold cyan"), ("@", "grey50"), (f"{s['host']}\n", "bold magenta"),
353
- (f"{s['os']}", "bold"), (f" · kernel {s['kernel']}\n", "grey62"),
424
+ (f"{s['os']}", "bold"), (f" · {'build' if WINDOWS else 'kernel'} {s['kernel']}\n", "grey62"),
354
425
  (f"up {human_age(s['boot']).replace(' ago', '')}", "green"),
355
426
  (f" · {s['shell'] or 'shell ?'} · {time.strftime('%a %d %b %H:%M')}", "grey62"),
356
427
  )
@@ -6,7 +6,7 @@ from concurrent.futures import ThreadPoolExecutor
6
6
  from rich.progress import Progress, SpinnerColumn, TextColumn
7
7
  from rich.table import Table
8
8
 
9
- from .util import (HOME, confirm, console, dir_size, display_path, human_age, human_size,
9
+ from .util import (HOME, confirm, console, dir_size, display_path, is_real_dir, human_age, human_size,
10
10
  remove_tree, short_path, size_style)
11
11
 
12
12
  # name -> (kind, check) ; check(parent_dir, junk_dir) says whether it's really junk
@@ -45,7 +45,9 @@ SKIP_DIRS = {".git", ".cache", ".local", ".npm", ".cargo", ".rustup", "snap",
45
45
  ".mozilla", ".config", ".vscode", ".vscode-server", ".var",
46
46
  ".m2", ".gradle", ".nvm", ".pyenv", ".docker", ".Trash"}
47
47
  # Skipped only directly under $HOME / filesystem root.
48
- SKIP_AT_TOP = {"go", "proc", "sys", "dev", "run", "tmp", "boot", "lost+found"}
48
+ SKIP_AT_TOP = {"go", "proc", "sys", "dev", "run", "tmp", "boot", "lost+found",
49
+ # Windows: app data, cloud-synced folders (scanning can trigger downloads)
50
+ "AppData", "OneDrive", "iCloudDrive", "Dropbox", "scoop"}
49
51
 
50
52
 
51
53
  def find(root, max_depth=8):
@@ -59,13 +61,13 @@ def find(root, max_depth=8):
59
61
  except OSError:
60
62
  continue
61
63
  for e in entries:
62
- if not e.is_dir(follow_symlinks=False):
64
+ if not is_real_dir(e):
63
65
  continue
64
66
  pattern = PATTERNS.get(e.name)
65
67
  if pattern and pattern[1](current, e.path):
66
68
  yield e.path, pattern[0]
67
69
  continue
68
- at_top = current in (HOME, "/")
70
+ at_top = current == HOME or os.path.dirname(current) == current # ~ or a drive root
69
71
  if depth < max_depth and e.name not in SKIP_DIRS and not (
70
72
  at_top and (e.name.startswith(".") or e.name in SKIP_AT_TOP)):
71
73
  stack.append((e.path, depth + 1))
@@ -76,13 +78,14 @@ def project_last_touched(junk_path):
76
78
  parent = os.path.dirname(junk_path)
77
79
  newest = 0
78
80
  try:
79
- for e in os.scandir(parent):
80
- if e.path == junk_path or e.name in PATTERNS:
81
- continue
82
- try:
83
- newest = max(newest, e.stat(follow_symlinks=False).st_mtime)
84
- except OSError:
85
- pass
81
+ with os.scandir(parent) as it:
82
+ for e in it:
83
+ if e.path == junk_path or e.name in PATTERNS:
84
+ continue
85
+ try:
86
+ newest = max(newest, e.stat(follow_symlinks=False).st_mtime)
87
+ except OSError:
88
+ pass
86
89
  except OSError:
87
90
  pass
88
91
  return newest or os.stat(junk_path).st_mtime
@@ -9,7 +9,7 @@ from rich.table import Table
9
9
  from rich.text import Text
10
10
 
11
11
  from . import caches, dashboard as db, ports, procs, repos
12
- from .util import console, human_size, short_path
12
+ from .util import WINDOWS, console, human_size, short_path
13
13
 
14
14
  # ───────────────────────── section details ─────────────────────────
15
15
 
@@ -50,8 +50,7 @@ def detail_tools(d):
50
50
  t.add_row(*row[:3])
51
51
  console.print(t)
52
52
  elif dk["state"] == "denied":
53
- console.print("Fix Docker permissions: [bold]sudo usermod -aG docker $USER[/] "
54
- "then log out and back in.")
53
+ console.print(db.DOCKER_FIX)
55
54
 
56
55
 
57
56
  def detail_projects(d):
@@ -188,7 +187,29 @@ KEYS = {b"\x1b[A": "up", b"\x1bOA": "up", b"k": "up",
188
187
  b"r": "refresh", b"a": "all", b"w": "watch", b"\x1b[H": "home", b"\x1b[F": "end"}
189
188
 
190
189
 
190
+ # Windows (msvcrt) sends special keys as a prefix + one letter.
191
+ WINDOWS_KEYS = {"H": "up", "P": "down", "M": "enter", "K": "back", "G": "home", "O": "end"}
192
+
193
+
194
+ def decode_windows_key(first, second=""):
195
+ """Map msvcrt.getwch() output to our key names (pure, so it's testable anywhere)."""
196
+ if first in ("\x00", "\xe0"):
197
+ return WINDOWS_KEYS.get(second, "other")
198
+ if first == "\x03":
199
+ raise KeyboardInterrupt
200
+ if first.isdigit():
201
+ return first
202
+ return KEYS.get(first.encode("utf-8", "ignore"), "other")
203
+
204
+
191
205
  def read_key():
206
+ if WINDOWS:
207
+ import msvcrt
208
+
209
+ first = msvcrt.getwch()
210
+ second = msvcrt.getwch() if first in ("\x00", "\xe0") else ""
211
+ return decode_windows_key(first, second)
212
+
192
213
  import termios # POSIX only — imported here so the package loads everywhere
193
214
  import tty
194
215
 
@@ -1,19 +1,28 @@
1
1
  """Who is listening on which port, and killing them."""
2
- import os
3
- import signal
4
- import time
5
-
6
2
  import psutil
7
3
  from rich.table import Table
8
4
 
9
- from .util import confirm, console, short_path
5
+ from .util import WINDOWS, confirm, console, is_mine, short_path
10
6
 
11
7
 
12
8
  # Friendly names for common root-owned services we can't inspect.
13
9
  WELL_KNOWN = {22: "sshd", 25: "smtp", 53: "DNS (systemd-resolved)", 80: "http",
14
10
  443: "https", 631: "CUPS printing", 3306: "MySQL", 5432: "PostgreSQL",
15
11
  6379: "Redis", 27017: "MongoDB", 5353: "mDNS (avahi)", 2375: "Docker",
16
- 11434: "Ollama"}
12
+ 11434: "Ollama", 135: "Windows RPC", 139: "NetBIOS", 445: "SMB file sharing",
13
+ 5040: "Windows CDP", 7680: "Delivery Optimization"}
14
+
15
+
16
+ def admin_hint(pid=None, port=None):
17
+ """How to do something as root/Administrator on this OS."""
18
+ if WINDOWS:
19
+ if pid:
20
+ return f"run PowerShell as Administrator: [bold]Stop-Process -Id {pid} -Force[/]"
21
+ return (f"run PowerShell as Administrator: "
22
+ f"[bold]Get-NetTCPConnection -LocalPort {port} | Select OwningProcess[/]")
23
+ if pid:
24
+ return f"try: [bold]sudo kill {pid}[/]"
25
+ return f"try: [bold]sudo ss -ltnp 'sport = :{port}'[/]"
17
26
 
18
27
 
19
28
  def listening(include_system=False):
@@ -35,14 +44,16 @@ def listening(include_system=False):
35
44
  "name": WELL_KNOWN.get(port, "?"), "cmd": "", "cwd": "", "user": "", "mine": False}
36
45
  if c.pid:
37
46
  try:
38
- p = psutil.Process(c.pid)
39
- info["name"] = p.name()
40
- info["cmd"] = " ".join(" ".join(p.cmdline()).split())
41
- info["user"] = p.username()
42
- info["mine"] = p.uids().real == os.getuid()
43
- info["cwd"] = p.cwd()
44
- except (psutil.AccessDenied, psutil.NoSuchProcess, psutil.ZombieProcess):
45
- pass
47
+ # as_dict: fields we're not allowed to read come back as None
48
+ # instead of aborting (common on Windows for other users' processes).
49
+ d = psutil.Process(c.pid).as_dict(["name", "cmdline", "username", "cwd"])
50
+ except (psutil.NoSuchProcess, psutil.ZombieProcess):
51
+ d = {}
52
+ info["name"] = d.get("name") or info["name"]
53
+ info["cmd"] = " ".join(" ".join(d.get("cmdline") or []).split())
54
+ info["user"] = d.get("username") or ""
55
+ info["mine"] = is_mine(info["user"])
56
+ info["cwd"] = d.get("cwd") or ""
46
57
  rows[key] = info
47
58
  result = sorted(rows.values(), key=lambda r: r["port"])
48
59
  if not include_system:
@@ -87,14 +98,14 @@ def kill(port, force=False, assume_yes=False):
87
98
  for r in targets:
88
99
  if not r["pid"]:
89
100
  console.print(f"[red]Port {port} is held by a process you can't see "
90
- f"(probably root).[/] Try: [bold]sudo ss -ltnp 'sport = :{port}'[/]")
101
+ f"(probably a system service).[/] To find it, {admin_hint(port=port)}")
91
102
  status = 1
92
103
  continue
93
104
  console.print(f"Port [bold cyan]{port}[/] → PID {r['pid']} "
94
105
  f"[bold]{r['name']}[/] [dim]{r['cmd'][:100]}[/]")
95
106
  if not r["mine"]:
96
107
  console.print(f"[yellow]Owned by {r['user'] or 'another user'}; "
97
- f"you may need sudo.[/]")
108
+ f"you may need {'Administrator rights' if WINDOWS else 'sudo'}.[/]")
98
109
  if not confirm("Kill it?", assume_yes):
99
110
  continue
100
111
  status |= terminate(r["pid"], force)
@@ -117,7 +128,7 @@ def terminate(pid, force=False):
117
128
  except psutil.NoSuchProcess:
118
129
  pass
119
130
  except psutil.AccessDenied:
120
- console.print(f"[red]Permission denied.[/] Try: [bold]sudo kill {pid}[/]")
131
+ console.print(f"[red]Permission denied.[/] To force it, {admin_hint(pid=pid)}")
121
132
  return 1
122
133
  except psutil.TimeoutExpired:
123
134
  console.print(f"[red]PID {pid} is still alive.[/]")
@@ -6,7 +6,7 @@ from collections import defaultdict
6
6
  import psutil
7
7
  from rich.table import Table
8
8
 
9
- from .util import HOME, console, human_age, human_size, short_path, size_style
9
+ from .util import HOME, console, human_age, human_size, is_mine, short_path, size_style
10
10
 
11
11
  DEV_NAMES = {"node", "python", "python3", "java", "ruby", "go", "cargo", "rustc",
12
12
  "deno", "bun", "php", "dotnet", "gradle", "mvn", "npm", "pnpm", "yarn",
@@ -21,7 +21,10 @@ def is_dev(name, cmdline):
21
21
  base = name.split(".")[0].lower()
22
22
  if base in DEV_NAMES or name.lower() in DEV_NAMES:
23
23
  return True
24
- return bool(cmdline) and os.path.basename(cmdline[0]).lower() in DEV_NAMES
24
+ if not cmdline:
25
+ return False
26
+ exe = os.path.basename(cmdline[0].replace("\\", "/")).lower()
27
+ return exe in DEV_NAMES or os.path.splitext(exe)[0] in DEV_NAMES
25
28
 
26
29
 
27
30
  def collect():
@@ -32,12 +35,12 @@ def collect():
32
35
  ports[c.pid].add(c.laddr.port)
33
36
  except (psutil.AccessDenied, OSError):
34
37
  pass
35
- me, self_pid = os.getuid(), os.getpid()
38
+ self_pid = os.getpid()
36
39
  rows = []
37
40
  for p in psutil.process_iter(["pid", "name", "cmdline", "memory_info", "create_time",
38
- "uids", "cwd", "cpu_percent"]):
41
+ "username", "cwd"]):
39
42
  info = p.info
40
- if not info["uids"] or info["uids"].real != me or info["pid"] == self_pid:
43
+ if info["pid"] == self_pid or not is_mine(info["username"]):
41
44
  continue
42
45
  if not is_dev(info["name"] or "", info["cmdline"] or []):
43
46
  continue
@@ -7,7 +7,7 @@ from rich.progress import Progress, SpinnerColumn, TextColumn
7
7
  from rich.table import Table
8
8
 
9
9
  from .junk import PATTERNS, SKIP_AT_TOP, SKIP_DIRS
10
- from .util import HOME, console, display_path, human_age, short_path
10
+ from .util import HOME, console, display_path, human_age, is_real_dir, run_full, short_path
11
11
 
12
12
 
13
13
  def find(root, max_depth=6):
@@ -22,9 +22,9 @@ def find(root, max_depth=6):
22
22
  entries = list(os.scandir(current))
23
23
  except OSError:
24
24
  continue
25
- at_top = current in (HOME, "/")
25
+ at_top = current == HOME or os.path.dirname(current) == current # ~ or a drive root
26
26
  for e in entries:
27
- if (depth < max_depth and e.is_dir(follow_symlinks=False)
27
+ if (depth < max_depth and is_real_dir(e)
28
28
  and e.name not in SKIP_DIRS and e.name not in PATTERNS
29
29
  and not (at_top and (e.name.startswith(".") or e.name in SKIP_AT_TOP))):
30
30
  stack.append((e.path, depth + 1))
@@ -32,8 +32,7 @@ def find(root, max_depth=6):
32
32
 
33
33
  def git(repo, *args):
34
34
  try:
35
- r = subprocess.run(["git", "-C", repo, *args], capture_output=True, text=True,
36
- timeout=15)
35
+ r = run_full(["git", "-C", repo, *args])
37
36
  return r.stdout if r.returncode == 0 else ""
38
37
  except (OSError, subprocess.SubprocessError):
39
38
  return ""
@@ -1,13 +1,38 @@
1
- """Shared helpers: sizes, ages, confirmation prompts."""
1
+ """Shared helpers: sizes, ages, confirmation prompts, cross-platform bits."""
2
+ import getpass
2
3
  import os
3
4
  import shutil
5
+ import stat
4
6
  import subprocess
7
+ import sys
5
8
  import time
6
9
 
7
10
  from rich.console import Console
8
11
 
9
12
  console = Console()
10
13
  HOME = os.path.expanduser("~")
14
+ WINDOWS = sys.platform == "win32"
15
+ _REPARSE_POINT = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
16
+
17
+
18
+ def is_real_dir(entry):
19
+ """A directory we may descend into: not a symlink, and on Windows not a
20
+ junction/reparse point (pnpm and Windows itself use junctions)."""
21
+ try:
22
+ if not entry.is_dir(follow_symlinks=False):
23
+ return False
24
+ if WINDOWS:
25
+ attrs = entry.stat(follow_symlinks=False).st_file_attributes
26
+ return not attrs & _REPARSE_POINT
27
+ except OSError:
28
+ return False
29
+ return True
30
+
31
+
32
+ def file_bytes(st):
33
+ """Bytes a file really occupies on disk (st_blocks isn't available on Windows)."""
34
+ blocks = getattr(st, "st_blocks", None)
35
+ return blocks * 512 if blocks is not None else st.st_size
11
36
 
12
37
 
13
38
  def dir_size(path):
@@ -24,16 +49,19 @@ def dir_size(path):
24
49
  st = entry.stat(follow_symlinks=False)
25
50
  except OSError:
26
51
  continue
27
- if entry.is_dir(follow_symlinks=False):
52
+ if is_real_dir(entry):
28
53
  stack.append(entry.path)
29
54
  continue
30
- # Hardlinked files (pnpm store, etc.) count once.
55
+ if entry.is_dir(follow_symlinks=False):
56
+ continue # junction: don't follow, don't count
57
+ # Hardlinked files (pnpm store, etc.) count once. On Windows
58
+ # scandir doesn't report link counts, so this is Linux/macOS only.
31
59
  if st.st_nlink > 1:
32
60
  key = (st.st_dev, st.st_ino)
33
61
  if key in seen:
34
62
  continue
35
63
  seen.add(key)
36
- total += st.st_blocks * 512
64
+ total += file_bytes(st)
37
65
  except OSError:
38
66
  continue
39
67
  return total
@@ -90,12 +118,22 @@ def confirm(question, assume_yes=False):
90
118
  return answer.strip().lower() in ("y", "yes")
91
119
 
92
120
 
121
+ def resolve(cmd):
122
+ """Full path for cmd[0] so Windows can launch npm.cmd, yarn.cmd, etc."""
123
+ exe = shutil.which(cmd[0])
124
+ return [exe, *cmd[1:]] if exe else list(cmd)
125
+
126
+
127
+ def run_full(cmd, timeout=15):
128
+ """subprocess.run with text output that never fails on odd encodings."""
129
+ return subprocess.run(resolve(cmd), capture_output=True, text=True,
130
+ errors="replace", timeout=timeout)
131
+
132
+
93
133
  def run(cmd, timeout=15):
94
134
  """Run a command, return stdout ('' on any failure)."""
95
135
  try:
96
- return subprocess.run(
97
- cmd, capture_output=True, text=True, timeout=timeout
98
- ).stdout
136
+ return run_full(cmd, timeout).stdout
99
137
  except (OSError, subprocess.SubprocessError):
100
138
  return ""
101
139
 
@@ -104,6 +142,32 @@ def have(binary):
104
142
  return shutil.which(binary) is not None
105
143
 
106
144
 
145
+ def current_user():
146
+ try:
147
+ return getpass.getuser().lower()
148
+ except Exception: # no USER/USERNAME and no pwd entry
149
+ return ""
150
+
151
+
152
+ def is_mine(username):
153
+ """Does a process owner string (e.g. 'alice' or 'LAPTOP\\alice') mean us?"""
154
+ if not username:
155
+ return False
156
+ return username.lower().rsplit("\\", 1)[-1] == current_user()
157
+
158
+
159
+ def _force_remove(func, path, _exc):
160
+ # Windows refuses to delete read-only files (e.g. git objects): clear the flag, retry.
161
+ try:
162
+ os.chmod(path, stat.S_IWRITE)
163
+ func(path)
164
+ except OSError:
165
+ pass
166
+
167
+
107
168
  def remove_tree(path):
108
- shutil.rmtree(path, ignore_errors=True)
169
+ if sys.version_info >= (3, 12):
170
+ shutil.rmtree(path, onexc=_force_remove)
171
+ else:
172
+ shutil.rmtree(path, onerror=_force_remove)
109
173
  return not os.path.exists(path)
@@ -1,15 +1,16 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devmedic
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: A health check for your developer machine: ports, junk folders, caches, git repos, dev processes — offline.
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/yaswanthme007/devmedic
7
7
  Project-URL: Issues, https://github.com/yaswanthme007/devmedic/issues
8
- Keywords: developer-tools,cli,linux,ubuntu,cleanup,ports,disk-usage
8
+ Keywords: developer-tools,cli,linux,ubuntu,windows,cleanup,ports,disk-usage
9
9
  Classifier: Environment :: Console
10
10
  Classifier: Intended Audience :: Developers
11
11
  Classifier: License :: OSI Approved :: MIT License
12
12
  Classifier: Operating System :: POSIX :: Linux
13
+ Classifier: Operating System :: Microsoft :: Windows
13
14
  Classifier: Programming Language :: Python :: 3
14
15
  Classifier: Topic :: Software Development
15
16
  Classifier: Topic :: System :: Monitoring
@@ -47,16 +48,24 @@ devmedic answers all of them in seconds.
47
48
 
48
49
  ## Install
49
50
 
50
- Requires **Linux** (built and tested on Ubuntu) and **Python 3.9+**.
51
- On **Windows**, use it inside [WSL](https://learn.microsoft.com/windows/wsl/install) for now; native support is planned.
51
+ Works on **Linux** (built on Ubuntu) and **Windows 10/11**, with **Python 3.9+**.
52
52
 
53
53
  **With pipx (recommended)**
54
54
 
55
+ Linux:
56
+
55
57
  ```bash
56
58
  sudo apt install pipx && pipx ensurepath # once
57
59
  pipx install devmedic
58
60
  ```
59
61
 
62
+ Windows (PowerShell or Windows Terminal):
63
+
64
+ ```powershell
65
+ py -m pip install --user pipx; py -m pipx ensurepath # once, then open a new terminal
66
+ pipx install devmedic
67
+ ```
68
+
60
69
  **With pip**
61
70
 
62
71
  ```bash
@@ -115,7 +124,10 @@ devmedic is careful by design:
115
124
  - **Nothing is deleted without confirmation** (unless you pass `-y`).
116
125
  - **Conservative junk detection:** `target/` only next to `Cargo.toml`/`pom.xml`, venvs only if
117
126
  they contain `pyvenv.cfg`. Generic `build/` and `dist/` folders are **never** touched.
118
- - **Never runs `sudo`.** System fixes (journal, apt, snap, docker) are printed for you to copy.
127
+ - **Links are never followed:** symlinks and Windows junctions (used by pnpm) are skipped when
128
+ scanning and removed as links, so nothing outside the folder is ever deleted.
129
+ - **Never runs `sudo` or Administrator commands.** System fixes (journal, apt, snap, Recycle Bin,
130
+ docker) are printed for you to copy.
119
131
  - **Only kills what you confirm**, and can't touch other users' processes.
120
132
  - **Offline and private:** no network requests, no data collection.
121
133
 
@@ -10,12 +10,13 @@ readme = "README.md"
10
10
  license = { text = "MIT" }
11
11
  requires-python = ">=3.9"
12
12
  dependencies = ["psutil>=5.8", "rich>=12.0"]
13
- keywords = ["developer-tools", "cli", "linux", "ubuntu", "cleanup", "ports", "disk-usage"]
13
+ keywords = ["developer-tools", "cli", "linux", "ubuntu", "windows", "cleanup", "ports", "disk-usage"]
14
14
  classifiers = [
15
15
  "Environment :: Console",
16
16
  "Intended Audience :: Developers",
17
17
  "License :: OSI Approved :: MIT License",
18
18
  "Operating System :: POSIX :: Linux",
19
+ "Operating System :: Microsoft :: Windows",
19
20
  "Programming Language :: Python :: 3",
20
21
  "Topic :: Software Development",
21
22
  "Topic :: System :: Monitoring",
@@ -10,7 +10,14 @@ import time
10
10
  import unittest
11
11
  from contextlib import redirect_stdout
12
12
 
13
- from devmedic import caches, cli, junk, ports, repos, util
13
+ from devmedic import caches, cli, junk, menu, ports, repos, util
14
+
15
+ WINDOWS = sys.platform == "win32"
16
+
17
+
18
+ def rel(path, root):
19
+ """Relative path with forward slashes, so expectations work on every OS."""
20
+ return os.path.relpath(path, root).replace(os.sep, "/")
14
21
 
15
22
 
16
23
  def write(path, size=1000):
@@ -33,6 +40,7 @@ class UtilTests(unittest.TestCase):
33
40
  with self.assertRaises(ValueError):
34
41
  junk.parse_selection("abc", 3)
35
42
 
43
+ @unittest.skipIf(WINDOWS, "scandir doesn't report hardlink counts on Windows")
36
44
  def test_dir_size_counts_hardlinks_once(self):
37
45
  with tempfile.TemporaryDirectory() as d:
38
46
  write(os.path.join(d, "a"), 100_000)
@@ -59,14 +67,15 @@ class JunkTests(unittest.TestCase):
59
67
  shutil.rmtree(self.root, ignore_errors=True)
60
68
 
61
69
  def found(self):
62
- return sorted(os.path.relpath(p, self.root) for p, _ in junk.find(self.root))
70
+ return sorted(rel(p, self.root) for p, _ in junk.find(self.root))
63
71
 
64
72
  def test_detects_only_real_junk(self):
65
73
  self.assertEqual(self.found(), ["py2/.venv", "rust/target", "web/node_modules"])
66
74
 
67
75
  def test_clean_deletes_only_selected(self):
68
76
  items = junk.scan(self.root)
69
- target = [i for i in items if i["path"].endswith("rust/target")]
77
+ target = [i for i in items if rel(i["path"], self.root) == "rust/target"]
78
+ self.assertEqual(len(target), 1)
70
79
  with redirect_stdout(io.StringIO()):
71
80
  junk.clean(target, assume_yes=True)
72
81
  self.assertFalse(os.path.exists(os.path.join(self.root, "rust", "target")))
@@ -78,7 +87,7 @@ class JunkTests(unittest.TestCase):
78
87
  old = os.path.join(self.root, "web", "package.json")
79
88
  past = time.time() - 90 * 86400
80
89
  os.utime(old, (past, past))
81
- paths = [os.path.relpath(i["path"], self.root) for i in junk.scan(self.root, 30)]
90
+ paths = [rel(i["path"], self.root) for i in junk.scan(self.root, 30)]
82
91
  self.assertEqual(paths, ["web/node_modules"])
83
92
 
84
93
 
@@ -173,24 +182,69 @@ class CacheTests(unittest.TestCase):
173
182
  shutil.rmtree(u["path"])
174
183
 
175
184
 
176
- class WindowsTests(unittest.TestCase):
177
- """Native Windows isn't supported yet: it must fail politely, not crash."""
178
-
179
- def test_friendly_message_on_windows(self):
180
- code = (
181
- "import sys\n"
182
- "sys.modules['termios'] = None\n" # simulate: module doesn't exist
183
- "sys.modules['tty'] = None\n"
184
- "from devmedic import cli\n" # must import without termios
185
- "sys.platform = 'win32'\n"
186
- "sys.exit(cli.main([]))\n"
187
- )
188
- env = dict(os.environ, COLUMNS="100")
189
- r = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True,
190
- env=env, cwd=os.path.dirname(os.path.dirname(__file__)))
191
- self.assertEqual(r.returncode, 1, r.stderr)
192
- self.assertNotIn("Traceback", r.stderr)
193
- self.assertIn("WSL", r.stdout)
185
+ class CrossPlatformTests(unittest.TestCase):
186
+ def test_is_mine(self):
187
+ me = util.current_user()
188
+ self.assertTrue(util.is_mine(me))
189
+ self.assertTrue(util.is_mine("SOME-PC\\" + me.upper())) # Windows DOMAIN\user
190
+ self.assertFalse(util.is_mine("definitely-not-" + me))
191
+ self.assertFalse(util.is_mine(None))
192
+
193
+ def test_windows_key_decoding(self):
194
+ d = menu.decode_windows_key
195
+ self.assertEqual(d("\xe0", "H"), "up")
196
+ self.assertEqual(d("\x00", "P"), "down")
197
+ self.assertEqual(d("\r"), "enter")
198
+ self.assertEqual(d("q"), "quit")
199
+ self.assertEqual(d("7"), "7")
200
+ self.assertEqual(d("\xe0", "Z"), "other")
201
+ with self.assertRaises(KeyboardInterrupt):
202
+ d("\x03")
203
+
204
+ def test_utf8_output_survives_legacy_encoding(self):
205
+ buf = io.TextIOWrapper(io.BytesIO(), encoding="cp1252")
206
+ old = sys.stdout
207
+ try:
208
+ sys.stdout = buf
209
+ cli.utf8_output()
210
+ print("🩺 ████ ╭─╮") # would raise UnicodeEncodeError in cp1252
211
+ buf.flush()
212
+ finally:
213
+ sys.stdout = old
214
+ self.assertIn("🩺".encode("utf-8"), buf.buffer.getvalue())
215
+
216
+
217
+ @unittest.skipUnless(WINDOWS, "Windows junctions")
218
+ class JunctionTests(unittest.TestCase):
219
+ """pnpm and Windows use junctions; we must never follow or delete through them."""
220
+
221
+ def setUp(self):
222
+ self.root = tempfile.mkdtemp()
223
+ self.target = os.path.join(self.root, "real")
224
+ write(os.path.join(self.target, "node_modules", "pkg", "big"), 200_000)
225
+ write(os.path.join(self.target, "package.json"))
226
+ self.link = os.path.join(self.root, "link")
227
+ subprocess.run(["cmd", "/c", "mklink", "/J", self.link, self.target], check=True,
228
+ capture_output=True)
229
+
230
+ def tearDown(self):
231
+ subprocess.run(["cmd", "/c", "rmdir", self.link], capture_output=True)
232
+ shutil.rmtree(self.root, ignore_errors=True)
233
+
234
+ def test_scan_does_not_follow_junction(self):
235
+ found = sorted(rel(p, self.root) for p, _ in junk.find(self.root))
236
+ self.assertEqual(found, ["real/node_modules"])
237
+
238
+ def test_size_ignores_junction(self):
239
+ with_link = util.dir_size(self.root)
240
+ self.assertLess(with_link, 2 * util.dir_size(self.target))
241
+
242
+ def test_removing_junction_keeps_target(self):
243
+ with os.scandir(self.root) as it:
244
+ entry = next(e for e in it if e.name == "link")
245
+ self.assertTrue(caches._remove_entry(entry))
246
+ self.assertFalse(os.path.exists(self.link))
247
+ self.assertTrue(os.path.exists(os.path.join(self.target, "node_modules", "pkg", "big")))
194
248
 
195
249
 
196
250
  class SmokeTests(unittest.TestCase):
@@ -1 +0,0 @@
1
- __version__ = "0.2.0"
File without changes
File without changes
File without changes