devbits 1.3.2__tar.gz → 1.3.3__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 (27) hide show
  1. {devbits-1.3.2 → devbits-1.3.3}/PKG-INFO +1 -1
  2. {devbits-1.3.2 → devbits-1.3.3}/devbits/__init__.py +1 -1
  3. {devbits-1.3.2 → devbits-1.3.3}/devbits/gui.py +2 -2
  4. {devbits-1.3.2 → devbits-1.3.3}/devbits/network.py +53 -7
  5. {devbits-1.3.2 → devbits-1.3.3}/devbits.egg-info/PKG-INFO +1 -1
  6. {devbits-1.3.2 → devbits-1.3.3}/pyproject.toml +1 -1
  7. {devbits-1.3.2 → devbits-1.3.3}/tests/test_cli.py +43 -0
  8. {devbits-1.3.2 → devbits-1.3.3}/LICENSE +0 -0
  9. {devbits-1.3.2 → devbits-1.3.3}/README.md +0 -0
  10. {devbits-1.3.2 → devbits-1.3.3}/devbits/cache.py +0 -0
  11. {devbits-1.3.2 → devbits-1.3.3}/devbits/cli.py +0 -0
  12. {devbits-1.3.2 → devbits-1.3.3}/devbits/image.py +0 -0
  13. {devbits-1.3.2 → devbits-1.3.3}/devbits/media.py +0 -0
  14. {devbits-1.3.2 → devbits-1.3.3}/devbits/project.py +0 -0
  15. {devbits-1.3.2 → devbits-1.3.3}/devbits/scripts.py +0 -0
  16. {devbits-1.3.2 → devbits-1.3.3}/devbits/tui.py +0 -0
  17. {devbits-1.3.2 → devbits-1.3.3}/devbits/utils.py +0 -0
  18. {devbits-1.3.2 → devbits-1.3.3}/devbits/wifi.py +0 -0
  19. {devbits-1.3.2 → devbits-1.3.3}/devbits.egg-info/SOURCES.txt +0 -0
  20. {devbits-1.3.2 → devbits-1.3.3}/devbits.egg-info/dependency_links.txt +0 -0
  21. {devbits-1.3.2 → devbits-1.3.3}/devbits.egg-info/entry_points.txt +0 -0
  22. {devbits-1.3.2 → devbits-1.3.3}/devbits.egg-info/requires.txt +0 -0
  23. {devbits-1.3.2 → devbits-1.3.3}/devbits.egg-info/top_level.txt +0 -0
  24. {devbits-1.3.2 → devbits-1.3.3}/setup.cfg +0 -0
  25. {devbits-1.3.2 → devbits-1.3.3}/tests/test_gui_cli.py +0 -0
  26. {devbits-1.3.2 → devbits-1.3.3}/tests/test_tui.py +0 -0
  27. {devbits-1.3.2 → devbits-1.3.3}/tests/test_wifi.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devbits
3
- Version: 1.3.2
3
+ Version: 1.3.3
4
4
  Summary: A lightweight CLI toolkit for daily development utilities.
5
5
  Author: Bruce Chuang
6
6
  License-Expression: MIT
@@ -1,3 +1,3 @@
1
1
  """devbits: A lightweight CLI toolkit for daily development utilities."""
2
2
 
3
- __version__ = "1.3.2"
3
+ __version__ = "1.3.3"
@@ -1763,7 +1763,7 @@ def _has_audio(path: Path) -> bool:
1763
1763
  out = subprocess.run(
1764
1764
  [probe, "-v", "error", "-select_streams", "a",
1765
1765
  "-show_entries", "stream=codec_type", "-of", "csv=p=0", str(path)],
1766
- capture_output=True, text=True, timeout=15,
1766
+ capture_output=True, text=True, errors="replace", timeout=15,
1767
1767
  )
1768
1768
  return "audio" in out.stdout
1769
1769
  except (subprocess.SubprocessError, OSError):
@@ -2116,7 +2116,7 @@ class _Handler(BaseHTTPRequestHandler):
2116
2116
  global _export_progress
2117
2117
  proc = subprocess.Popen(
2118
2118
  cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
2119
- text=True, bufsize=1,
2119
+ text=True, errors="replace", bufsize=1,
2120
2120
  )
2121
2121
  try:
2122
2122
  for line in proc.stdout:
@@ -1,7 +1,10 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import codecs
3
4
  import concurrent.futures
5
+ import functools
4
6
  import ipaddress
7
+ import locale
5
8
  import platform
6
9
  import re
7
10
  import socket
@@ -34,6 +37,48 @@ class Device:
34
37
  is_gateway: bool = False
35
38
 
36
39
 
40
+ @functools.lru_cache(maxsize=1)
41
+ def _console_encoding() -> str:
42
+ """The encoding the OS command-line tools print in.
43
+
44
+ On Windows this is the console output code page (e.g. ``cp950`` on a
45
+ Traditional Chinese install), *not* UTF-8. Elsewhere the locale's preferred
46
+ encoding is right.
47
+ """
48
+ if platform.system().lower() == "windows":
49
+ try:
50
+ import ctypes
51
+
52
+ codepage = ctypes.windll.kernel32.GetConsoleOutputCP() or ctypes.windll.kernel32.GetOEMCP()
53
+ codecs.lookup(f"cp{codepage}") # reject code pages Python can't handle
54
+ return f"cp{codepage}"
55
+ except Exception:
56
+ pass
57
+ try:
58
+ return codecs.lookup(locale.getpreferredencoding(False)).name
59
+ except Exception:
60
+ return "utf-8"
61
+
62
+
63
+ def _run_text(cmd: list[str], timeout: float) -> tuple[int, str]:
64
+ """Run ``cmd`` and return ``(returncode, stdout)``, decoded defensively.
65
+
66
+ Deliberately *not* ``text=True``: that decodes with UTF-8 strictly, so on a
67
+ non-English Windows the UnicodeDecodeError is raised inside subprocess'
68
+ reader threads, which leaves ``stdout`` as ``None`` and produces a confusing
69
+ ``'NoneType' object has no attribute ...`` far from the real cause. Decoding
70
+ the bytes here with ``errors="replace"`` cannot fail, and everything we parse
71
+ out of these tools (IPs, MACs, ``TTL=``) is ASCII anyway.
72
+ """
73
+ proc = subprocess.run(cmd, capture_output=True, timeout=timeout)
74
+ raw = proc.stdout
75
+ if raw is None:
76
+ return proc.returncode, ""
77
+ if isinstance(raw, str): # a text-mode fake / caller override
78
+ return proc.returncode, raw
79
+ return proc.returncode, raw.decode(_console_encoding(), "replace")
80
+
81
+
37
82
  def local_ip() -> str:
38
83
  """Best-effort primary IPv4 address of this machine.
39
84
 
@@ -69,7 +114,7 @@ def gateway_ip() -> str | None:
69
114
  system = platform.system().lower()
70
115
  try:
71
116
  if system == "windows":
72
- out = subprocess.run(["route", "print", "0.0.0.0"], capture_output=True, text=True, timeout=5).stdout
117
+ out = _run_text(["route", "print", "0.0.0.0"], timeout=5)[1]
73
118
  for line in out.splitlines():
74
119
  if line.strip().startswith("0.0.0.0"):
75
120
  ips = _IP_RE.findall(line)
@@ -77,11 +122,11 @@ def gateway_ip() -> str | None:
77
122
  return ips[2] # destination, netmask, gateway
78
123
  return None
79
124
  if system == "darwin":
80
- out = subprocess.run(["route", "-n", "get", "default"], capture_output=True, text=True, timeout=5).stdout
125
+ out = _run_text(["route", "-n", "get", "default"], timeout=5)[1]
81
126
  match = re.search(r"gateway:\s*(" + _IP_RE.pattern + ")", out)
82
127
  return match.group(1) if match else None
83
128
  # Linux and other Unixes
84
- out = subprocess.run(["ip", "route"], capture_output=True, text=True, timeout=5).stdout
129
+ out = _run_text(["ip", "route"], timeout=5)[1]
85
130
  match = re.search(r"default via (" + _IP_RE.pattern + ")", out)
86
131
  return match.group(1) if match else None
87
132
  except Exception:
@@ -170,7 +215,7 @@ def arp_table() -> dict[str, str]:
170
215
 
171
216
  for command in commands:
172
217
  try:
173
- out = subprocess.run(command, capture_output=True, text=True, timeout=10).stdout
218
+ out = _run_text(command, timeout=10)[1]
174
219
  except Exception:
175
220
  continue
176
221
  table: dict[str, str] = {}
@@ -193,15 +238,16 @@ def _ping(ip: str, timeout: float = 1.0) -> bool:
193
238
  else:
194
239
  cmd = ["ping", "-c", "1", "-W", str(max(1, int(round(timeout)))), ip] # -W is seconds on Linux
195
240
  try:
196
- result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout + 2)
241
+ returncode, out = _run_text(cmd, timeout=timeout + 2)
197
242
  except Exception:
198
243
  return False
199
- if result.returncode != 0:
244
+ if returncode != 0:
200
245
  return False
201
246
  if system == "windows":
202
247
  # Windows ping can exit 0 while printing "Destination host unreachable"
203
248
  # (a reply from the gateway, not the target). Require a real echo reply.
204
- return "ttl=" in result.stdout.lower()
249
+ # "TTL=" is ASCII in every locale, so this survives localized output.
250
+ return "ttl=" in out.lower()
205
251
  return True
206
252
 
207
253
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devbits
3
- Version: 1.3.2
3
+ Version: 1.3.3
4
4
  Summary: A lightweight CLI toolkit for daily development utilities.
5
5
  Author: Bruce Chuang
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "devbits"
3
- version = "1.3.2"
3
+ version = "1.3.3"
4
4
  description = "A lightweight CLI toolkit for daily development utilities."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.9"
@@ -70,6 +70,7 @@ def test_arp_table_windows_command(monkeypatch) -> None:
70
70
  calls = []
71
71
 
72
72
  class _Result:
73
+ returncode = 0
73
74
  stdout = " 192.168.0.1 aa-bb-cc-dd-ee-ff dynamic"
74
75
 
75
76
  def fake_run(cmd, **kwargs):
@@ -95,6 +96,7 @@ def test_arp_table_linux_falls_back_to_ip_neigh(monkeypatch) -> None:
95
96
  raise FileNotFoundError("arp not installed")
96
97
 
97
98
  class _Result:
99
+ returncode = 0
98
100
  stdout = "192.168.0.1 dev eth0 lladdr aa:bb:cc:dd:ee:ff REACHABLE"
99
101
 
100
102
  return _Result()
@@ -106,6 +108,47 @@ def test_arp_table_linux_falls_back_to_ip_neigh(monkeypatch) -> None:
106
108
  assert table == {"192.168.0.1": "aa:bb:cc:dd:ee:ff"}
107
109
 
108
110
 
111
+ def test_subprocess_output_survives_non_utf8_locale(monkeypatch) -> None:
112
+ # A Traditional Chinese Windows prints cp950 bytes, which are not valid
113
+ # UTF-8. Decoding must not raise, and ping must still detect the reply.
114
+ import subprocess
115
+
116
+ import devbits.network as net
117
+
118
+ # "回覆自 192.168.50.138: 位元組=32 時間<1ms TTL=64" in cp950.
119
+ cp950 = (
120
+ "回覆自 192.168.50.138: 位元組=32 時間<1ms TTL=64".encode("cp950")
121
+ )
122
+
123
+ def fake_run(cmd, **kwargs):
124
+ assert not kwargs.get("text"), "text=True decodes as UTF-8 and crashes here"
125
+ return subprocess.CompletedProcess(cmd, 0, cp950, b"")
126
+
127
+ monkeypatch.setattr(net.platform, "system", lambda: "Windows")
128
+ monkeypatch.setattr(net.subprocess, "run", fake_run)
129
+ net._console_encoding.cache_clear()
130
+ monkeypatch.setattr(net, "_console_encoding", lambda: "cp950")
131
+
132
+ assert net._ping("192.168.50.138") is True
133
+
134
+
135
+ def test_ping_tolerates_undecodable_bytes(monkeypatch) -> None:
136
+ # Even if the code page guess is wrong, no byte sequence may raise.
137
+ import subprocess
138
+
139
+ import devbits.network as net
140
+
141
+ def fake_run(cmd, **kwargs):
142
+ return subprocess.CompletedProcess(cmd, 0, b"\xa4\xa8 TTL=64 \xff\xfe", b"")
143
+
144
+ monkeypatch.setattr(net.platform, "system", lambda: "Windows")
145
+ monkeypatch.setattr(net.subprocess, "run", fake_run)
146
+ net._console_encoding.cache_clear()
147
+ monkeypatch.setattr(net, "_console_encoding", lambda: "utf-8")
148
+
149
+ assert net._ping("192.168.50.138") is True
150
+
151
+
109
152
  def test_private_mac_detection() -> None:
110
153
  from devbits.network import is_private_mac
111
154
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes