devbits 1.1.3__tar.gz → 1.2.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devbits
3
- Version: 1.1.3
3
+ Version: 1.2.0
4
4
  Summary: A lightweight CLI toolkit for daily development utilities.
5
5
  Author: Bruce Chuang
6
6
  License-Expression: MIT
@@ -80,6 +80,12 @@ clipvideo --help
80
80
  | `renamefiles` | Batch rename files sequentially. |
81
81
  | `samplefiles` | Copy or move the first N files to another folder. |
82
82
 
83
+ ### Network
84
+
85
+ | Command | Description |
86
+ |---------|-------------|
87
+ | `netscan` | List devices connected to your local network (Wi-Fi / router) with their IP, MAC, and hostname. `--lookup` adds the manufacturer. |
88
+
83
89
  ## Examples
84
90
 
85
91
  ```bash
@@ -107,8 +113,22 @@ batchimages ./photos -o ./resized --size 800,600
107
113
 
108
114
  # Clean Python caches
109
115
  clearcache . --all
116
+
117
+ # List every device on your local network
118
+ netscan
119
+
120
+ # Also identify each device's manufacturer (online OUI lookup)
121
+ netscan --lookup
122
+
123
+ # Scan a specific subnet, faster, without hostname lookups
124
+ netscan --network 192.168.1.0/24 --timeout 0.5 --no-resolve
110
125
  ```
111
126
 
127
+ > `netscan` reports IP, MAC, hostname and (with `--lookup`) the hardware
128
+ > **manufacturer** — a network scan can't read a device's CPU/RAM/OS. Phones and
129
+ > laptops that use a randomized/private MAC show up as `(private)` and can't be
130
+ > attributed to a vendor.
131
+
112
132
  ## Output Defaults
113
133
 
114
134
  When `-o` / `--output` is omitted, the output filename is derived from the input:
@@ -63,6 +63,12 @@ clipvideo --help
63
63
  | `renamefiles` | Batch rename files sequentially. |
64
64
  | `samplefiles` | Copy or move the first N files to another folder. |
65
65
 
66
+ ### Network
67
+
68
+ | Command | Description |
69
+ |---------|-------------|
70
+ | `netscan` | List devices connected to your local network (Wi-Fi / router) with their IP, MAC, and hostname. `--lookup` adds the manufacturer. |
71
+
66
72
  ## Examples
67
73
 
68
74
  ```bash
@@ -90,8 +96,22 @@ batchimages ./photos -o ./resized --size 800,600
90
96
 
91
97
  # Clean Python caches
92
98
  clearcache . --all
99
+
100
+ # List every device on your local network
101
+ netscan
102
+
103
+ # Also identify each device's manufacturer (online OUI lookup)
104
+ netscan --lookup
105
+
106
+ # Scan a specific subnet, faster, without hostname lookups
107
+ netscan --network 192.168.1.0/24 --timeout 0.5 --no-resolve
93
108
  ```
94
109
 
110
+ > `netscan` reports IP, MAC, hostname and (with `--lookup`) the hardware
111
+ > **manufacturer** — a network scan can't read a device's CPU/RAM/OS. Phones and
112
+ > laptops that use a randomized/private MAC show up as `(private)` and can't be
113
+ > attributed to a vendor.
114
+
95
115
  ## Output Defaults
96
116
 
97
117
  When `-o` / `--output` is omitted, the output filename is derived from the input:
@@ -1,3 +1,3 @@
1
1
  """devbits: A lightweight CLI toolkit for daily development utilities."""
2
2
 
3
- __version__ = "1.1.3"
3
+ __version__ = "1.2.0"
@@ -9,6 +9,7 @@ from . import __version__
9
9
  from .cache import clear_cache
10
10
  from .image import batch_images, check_images, contact_sheet, image_to_ico, recolor_image, resize_image
11
11
  from .media import clip_video, images_to_gif, images_to_video, resize_video, video_to_gif, video_to_images
12
+ from .network import scan_network
12
13
  from .project import print_tree, rename_files, sample_files, top_sizes
13
14
  from .utils import ensure_exists
14
15
 
@@ -17,7 +18,18 @@ from .utils import ensure_exists
17
18
  # Helper: terminal colors
18
19
  # ---------------------------------------------------------------------------
19
20
 
20
- _ANSI = {"dir": "\033[1;34m", "file": "\033[0m", "size": "\033[36m", "reset": "\033[0m"}
21
+ _ANSI = {
22
+ "dir": "\033[1;34m",
23
+ "file": "\033[0m",
24
+ "size": "\033[36m",
25
+ "header": "\033[1m",
26
+ "self": "\033[1;32m",
27
+ "gateway": "\033[1;33m",
28
+ "mac": "\033[36m",
29
+ "host": "\033[35m",
30
+ "vendor": "\033[33m",
31
+ "reset": "\033[0m",
32
+ }
21
33
 
22
34
 
23
35
  def _use_color(force: bool | None = None) -> bool:
@@ -441,6 +453,39 @@ def build_parser() -> argparse.ArgumentParser:
441
453
  help="Move files instead of copying.")
442
454
  p.set_defaults(func=cmd_samplefiles)
443
455
 
456
+ # ── netscan ────────────────────────────────────────────────
457
+ p = sub.add_parser(
458
+ "netscan",
459
+ help="List devices connected to the local network (Wi-Fi / router).",
460
+ formatter_class=argparse.RawDescriptionHelpFormatter,
461
+ description=(
462
+ "Discover hosts on your local subnet with a threaded ping sweep, then\n"
463
+ "report each device's IP, MAC address, and hostname. This machine and\n"
464
+ "the router (default gateway) are highlighted.\n\n"
465
+ "Scan only your own network; probing networks you don't administer may\n"
466
+ "violate policy or law.\n\n"
467
+ "Examples:\n"
468
+ " devbits netscan\n"
469
+ " devbits netscan --lookup # also show manufacturer\n"
470
+ " devbits netscan --network 192.168.1.0/24\n"
471
+ " devbits netscan --timeout 0.5 --workers 128 --no-resolve"
472
+ ),
473
+ )
474
+ p.add_argument("--network", metavar="CIDR", default=None,
475
+ help="Subnet to scan in CIDR, e.g. 192.168.1.0/24. Default: auto-detected.")
476
+ p.add_argument("--timeout", type=float, default=1.0,
477
+ help="Per-host ping timeout in seconds. Default: 1.0")
478
+ p.add_argument("--workers", type=int, default=64,
479
+ help="Number of concurrent ping workers. Default: 64")
480
+ p.add_argument("--no-resolve", action="store_true",
481
+ help="Skip reverse-DNS hostname lookups (faster).")
482
+ p.add_argument("--lookup", action="store_true",
483
+ help="Resolve each device's manufacturer online via macvendors.com "
484
+ "(sends MAC prefixes to a third-party service).")
485
+ p.add_argument("--no-color", action="store_true",
486
+ help="Disable colored output (also honors NO_COLOR).")
487
+ p.set_defaults(func=cmd_netscan)
488
+
444
489
  return parser
445
490
 
446
491
 
@@ -576,6 +621,37 @@ def cmd_samplefiles(args: argparse.Namespace) -> None:
576
621
  print(f"Saved {len(outputs)} file(s) to {args.output}")
577
622
 
578
623
 
624
+ def cmd_netscan(args: argparse.Namespace) -> None:
625
+ import ipaddress
626
+
627
+ color = _use_color(False if args.no_color else None)
628
+ network = ipaddress.ip_network(args.network, strict=False) if args.network else None
629
+
630
+ from .network import default_network
631
+ net = network or default_network()
632
+ print(f"Scanning {net} ...", file=sys.stderr)
633
+
634
+ if args.lookup:
635
+ print("Looking up device manufacturers online ...", file=sys.stderr)
636
+ devices = scan_network(
637
+ net, timeout=args.timeout, workers=args.workers,
638
+ resolve=not args.no_resolve, lookup=args.lookup,
639
+ )
640
+
641
+ vendor_col_head = f"{'VENDOR':<24}" if args.lookup else ""
642
+ header = f"{'IP':<16}{'MAC':<20}{vendor_col_head}{'HOSTNAME':<28}NOTE"
643
+ print(_colorize(header, "header", color))
644
+ for device in devices:
645
+ note = "this device" if device.is_self else ("gateway / router" if device.is_gateway else "")
646
+ kind = "self" if device.is_self else ("gateway" if device.is_gateway else None)
647
+ ip_col = _colorize(f"{device.ip:<16}", kind, color) if kind else f"{device.ip:<16}"
648
+ mac_col = _colorize(f"{device.mac or '-':<20}", "mac", color)
649
+ vendor_col = _colorize(f"{device.vendor or '-':<24}", "vendor", color) if args.lookup else ""
650
+ host_col = _colorize(f"{device.hostname or '-':<28}", "host", color)
651
+ print(f"{ip_col}{mac_col}{vendor_col}{host_col}{note}")
652
+ print(f"Found {len(devices)} device(s).")
653
+
654
+
579
655
  def main(argv: list[str] | None = None) -> int:
580
656
  parser = build_parser()
581
657
  args = parser.parse_args(argv)
@@ -0,0 +1,265 @@
1
+ from __future__ import annotations
2
+
3
+ import concurrent.futures
4
+ import ipaddress
5
+ import platform
6
+ import re
7
+ import socket
8
+ import ssl
9
+ import subprocess
10
+ import time
11
+ import urllib.error
12
+ import urllib.request
13
+ from dataclasses import dataclass
14
+
15
+ _MAC_RE = re.compile(r"(?:[0-9a-fA-F]{1,2}[:-]){5}[0-9a-fA-F]{1,2}")
16
+ _IP_RE = re.compile(r"\d{1,3}(?:\.\d{1,3}){3}")
17
+
18
+ #: Label used for locally-administered (randomized / private) MAC addresses,
19
+ #: which carry no manufacturer information.
20
+ PRIVATE_MAC_LABEL = "(private)"
21
+
22
+ _vendor_cache: dict[str, str | None] = {}
23
+
24
+
25
+ @dataclass
26
+ class Device:
27
+ """A single host discovered on the local network."""
28
+
29
+ ip: str
30
+ mac: str | None = None
31
+ hostname: str | None = None
32
+ vendor: str | None = None
33
+ is_self: bool = False
34
+ is_gateway: bool = False
35
+
36
+
37
+ def local_ip() -> str:
38
+ """Best-effort primary IPv4 address of this machine.
39
+
40
+ Opens a UDP socket toward a public address to learn which local interface
41
+ would route outbound traffic. No packets are actually sent, so this works
42
+ offline as long as a network interface with a route exists (e.g. a LAN with
43
+ no internet). Falls back to the hostname's address, then loopback.
44
+ """
45
+ ip = ""
46
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
47
+ try:
48
+ sock.connect(("8.8.8.8", 80))
49
+ ip = sock.getsockname()[0]
50
+ except OSError:
51
+ ip = ""
52
+ finally:
53
+ sock.close()
54
+ if not ip or ip.startswith("0."):
55
+ try:
56
+ ip = socket.gethostbyname(socket.gethostname())
57
+ except OSError:
58
+ ip = "127.0.0.1"
59
+ return ip
60
+
61
+
62
+ def default_network(prefix: int = 24) -> ipaddress.IPv4Network:
63
+ """The local subnet containing this machine's primary address."""
64
+ return ipaddress.ip_network(f"{local_ip()}/{prefix}", strict=False)
65
+
66
+
67
+ def gateway_ip() -> str | None:
68
+ """The default-gateway (router) address, or ``None`` if undetermined."""
69
+ system = platform.system().lower()
70
+ try:
71
+ if system == "windows":
72
+ out = subprocess.run(["route", "print", "0.0.0.0"], capture_output=True, text=True, timeout=5).stdout
73
+ for line in out.splitlines():
74
+ if line.strip().startswith("0.0.0.0"):
75
+ ips = _IP_RE.findall(line)
76
+ if len(ips) >= 3:
77
+ return ips[2] # destination, netmask, gateway
78
+ return None
79
+ if system == "darwin":
80
+ out = subprocess.run(["route", "-n", "get", "default"], capture_output=True, text=True, timeout=5).stdout
81
+ match = re.search(r"gateway:\s*(" + _IP_RE.pattern + ")", out)
82
+ return match.group(1) if match else None
83
+ # Linux and other Unixes
84
+ out = subprocess.run(["ip", "route"], capture_output=True, text=True, timeout=5).stdout
85
+ match = re.search(r"default via (" + _IP_RE.pattern + ")", out)
86
+ return match.group(1) if match else None
87
+ except Exception:
88
+ return None
89
+
90
+
91
+ def _normalize_mac(mac: str) -> str:
92
+ parts = re.split(r"[:-]", mac)
93
+ return ":".join(part.zfill(2).lower() for part in parts)
94
+
95
+
96
+ def is_private_mac(mac: str) -> bool:
97
+ """Whether ``mac`` is locally administered (a randomized / private address).
98
+
99
+ Modern phones and laptops rotate a random MAC per network for privacy. The
100
+ locally-administered bit (``0x02`` of the first octet) is set on these, and
101
+ they carry no manufacturer information, so an OUI lookup is pointless.
102
+ """
103
+ try:
104
+ return bool(int(mac.split(":")[0], 16) & 0x02)
105
+ except (ValueError, IndexError):
106
+ return False
107
+
108
+
109
+ def lookup_vendor(mac: str, timeout: float = 3.0, retries: int = 2) -> str | None:
110
+ """Resolve the manufacturer for ``mac`` via the macvendors.com API (online).
111
+
112
+ Results are cached per OUI prefix for the life of the process. Returns
113
+ ``None`` when the vendor is unknown or the service is unreachable.
114
+
115
+ NOTE: this sends the MAC's first three octets to a third-party service; it
116
+ only runs when the caller explicitly opts in (``scan_network(lookup=True)``).
117
+ """
118
+ prefix = ":".join(mac.split(":")[:3])
119
+ if prefix in _vendor_cache:
120
+ return _vendor_cache[prefix]
121
+
122
+ request = urllib.request.Request(
123
+ f"https://api.macvendors.com/{mac}",
124
+ headers={"User-Agent": "devbits-netscan"},
125
+ )
126
+ # Fall back to an unverified (still-encrypted) context if the default CA
127
+ # bundle can't validate the cert — common on macOS Python.framework installs
128
+ # that never ran "Install Certificates.command". The payload is only a MAC
129
+ # OUI prefix and a vendor name, so this is acceptable here.
130
+ contexts = [None, ssl._create_unverified_context()]
131
+ vendor: str | None = None
132
+ for context in contexts:
133
+ for attempt in range(retries + 1):
134
+ try:
135
+ with urllib.request.urlopen(request, timeout=timeout, context=context) as response:
136
+ vendor = response.read().decode("utf-8", "replace").strip() or None
137
+ _vendor_cache[prefix] = vendor
138
+ return vendor
139
+ except urllib.error.HTTPError as exc:
140
+ if exc.code == 429 and attempt < retries: # rate limited: back off and retry
141
+ time.sleep(1.2)
142
+ continue
143
+ _vendor_cache[prefix] = None # 404 / other HTTP error → unknown, don't retry other contexts
144
+ return None
145
+ except urllib.error.URLError as exc:
146
+ if isinstance(exc.reason, ssl.SSLError):
147
+ break # try the next (unverified) context
148
+ _vendor_cache[prefix] = None
149
+ return None
150
+ except Exception:
151
+ _vendor_cache[prefix] = None
152
+ return None
153
+ _vendor_cache[prefix] = vendor
154
+ return vendor
155
+
156
+
157
+ def arp_table() -> dict[str, str]:
158
+ """Map ``IP -> MAC`` from the system ARP cache (cross-platform).
159
+
160
+ Tries each candidate command until one yields entries:
161
+
162
+ * Windows: ``arp -a`` (already numeric; it has no ``-n`` flag).
163
+ * macOS / Linux: ``arp -an`` — the ``-n`` avoids slow per-entry reverse DNS.
164
+ * Linux without net-tools (no ``arp``): ``ip neigh show`` as a fallback.
165
+ """
166
+ if platform.system().lower() == "windows":
167
+ commands = [["arp", "-a"]]
168
+ else:
169
+ commands = [["arp", "-an"], ["ip", "neigh", "show"]]
170
+
171
+ for command in commands:
172
+ try:
173
+ out = subprocess.run(command, capture_output=True, text=True, timeout=10).stdout
174
+ except Exception:
175
+ continue
176
+ table: dict[str, str] = {}
177
+ for line in out.splitlines():
178
+ ip_match = _IP_RE.search(line)
179
+ mac_match = _MAC_RE.search(line)
180
+ if ip_match and mac_match:
181
+ table[ip_match.group()] = _normalize_mac(mac_match.group())
182
+ if table:
183
+ return table
184
+ return {}
185
+
186
+
187
+ def _ping(ip: str, timeout: float = 1.0) -> bool:
188
+ system = platform.system().lower()
189
+ if system == "windows":
190
+ cmd = ["ping", "-n", "1", "-w", str(int(timeout * 1000)), ip]
191
+ elif system == "darwin":
192
+ cmd = ["ping", "-c", "1", "-W", str(int(timeout * 1000)), ip] # -W is milliseconds on macOS
193
+ else:
194
+ cmd = ["ping", "-c", "1", "-W", str(max(1, int(round(timeout)))), ip] # -W is seconds on Linux
195
+ try:
196
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout + 2)
197
+ except Exception:
198
+ return False
199
+ if result.returncode != 0:
200
+ return False
201
+ if system == "windows":
202
+ # Windows ping can exit 0 while printing "Destination host unreachable"
203
+ # (a reply from the gateway, not the target). Require a real echo reply.
204
+ return "ttl=" in result.stdout.lower()
205
+ return True
206
+
207
+
208
+ def _resolve_hostname(ip: str) -> str | None:
209
+ try:
210
+ return socket.gethostbyaddr(ip)[0]
211
+ except Exception:
212
+ return None
213
+
214
+
215
+ def scan_network(
216
+ network: ipaddress.IPv4Network | None = None,
217
+ timeout: float = 1.0,
218
+ workers: int = 64,
219
+ resolve: bool = True,
220
+ lookup: bool = False,
221
+ ) -> list[Device]:
222
+ """Discover live hosts on ``network`` via a threaded ICMP ping sweep.
223
+
224
+ After the sweep, the system ARP cache is read for MAC addresses and (unless
225
+ ``resolve`` is false) reverse DNS is queried for hostnames. This machine and
226
+ the default gateway are always included even if they don't answer pings.
227
+
228
+ When ``lookup`` is true, each MAC's manufacturer is resolved online (private
229
+ /randomized MACs are labelled instead) — see :func:`lookup_vendor`.
230
+ """
231
+ net = network or default_network()
232
+ self_ip = local_ip()
233
+ gateway = gateway_ip()
234
+
235
+ hosts = [str(host) for host in net.hosts()]
236
+ alive: set[str] = set()
237
+ with concurrent.futures.ThreadPoolExecutor(max_workers=max(1, workers)) as executor:
238
+ futures = {executor.submit(_ping, ip, timeout): ip for ip in hosts}
239
+ for future in concurrent.futures.as_completed(futures):
240
+ if future.result():
241
+ alive.add(futures[future])
242
+
243
+ alive.add(self_ip)
244
+ if gateway and ipaddress.ip_address(gateway) in net:
245
+ alive.add(gateway)
246
+
247
+ arp = arp_table()
248
+ devices = [
249
+ Device(
250
+ ip=ip,
251
+ mac=arp.get(ip),
252
+ hostname=_resolve_hostname(ip) if resolve else None,
253
+ is_self=ip == self_ip,
254
+ is_gateway=ip == gateway,
255
+ )
256
+ for ip in alive
257
+ ]
258
+ if lookup:
259
+ for device in devices:
260
+ if not device.mac:
261
+ continue
262
+ device.vendor = PRIVATE_MAC_LABEL if is_private_mac(device.mac) else lookup_vendor(device.mac)
263
+
264
+ devices.sort(key=lambda device: tuple(int(octet) for octet in device.ip.split(".")))
265
+ return devices
@@ -77,3 +77,7 @@ def renamefiles() -> int:
77
77
 
78
78
  def samplefiles() -> int:
79
79
  return _run("samplefiles")
80
+
81
+
82
+ def netscan() -> int:
83
+ return _run("netscan")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devbits
3
- Version: 1.1.3
3
+ Version: 1.2.0
4
4
  Summary: A lightweight CLI toolkit for daily development utilities.
5
5
  Author: Bruce Chuang
6
6
  License-Expression: MIT
@@ -80,6 +80,12 @@ clipvideo --help
80
80
  | `renamefiles` | Batch rename files sequentially. |
81
81
  | `samplefiles` | Copy or move the first N files to another folder. |
82
82
 
83
+ ### Network
84
+
85
+ | Command | Description |
86
+ |---------|-------------|
87
+ | `netscan` | List devices connected to your local network (Wi-Fi / router) with their IP, MAC, and hostname. `--lookup` adds the manufacturer. |
88
+
83
89
  ## Examples
84
90
 
85
91
  ```bash
@@ -107,8 +113,22 @@ batchimages ./photos -o ./resized --size 800,600
107
113
 
108
114
  # Clean Python caches
109
115
  clearcache . --all
116
+
117
+ # List every device on your local network
118
+ netscan
119
+
120
+ # Also identify each device's manufacturer (online OUI lookup)
121
+ netscan --lookup
122
+
123
+ # Scan a specific subnet, faster, without hostname lookups
124
+ netscan --network 192.168.1.0/24 --timeout 0.5 --no-resolve
110
125
  ```
111
126
 
127
+ > `netscan` reports IP, MAC, hostname and (with `--lookup`) the hardware
128
+ > **manufacturer** — a network scan can't read a device's CPU/RAM/OS. Phones and
129
+ > laptops that use a randomized/private MAC show up as `(private)` and can't be
130
+ > attributed to a vendor.
131
+
112
132
  ## Output Defaults
113
133
 
114
134
  When `-o` / `--output` is omitted, the output filename is derived from the input:
@@ -7,6 +7,7 @@ devbits/cli.py
7
7
  devbits/gui.py
8
8
  devbits/image.py
9
9
  devbits/media.py
10
+ devbits/network.py
10
11
  devbits/project.py
11
12
  devbits/scripts.py
12
13
  devbits/utils.py
@@ -8,6 +8,7 @@ devbits = devbits.cli:main
8
8
  image2ico = devbits.scripts:image2ico
9
9
  images2gif = devbits.scripts:images2gif
10
10
  images2video = devbits.scripts:images2video
11
+ netscan = devbits.scripts:netscan
11
12
  recolor = devbits.scripts:recolor
12
13
  renamefiles = devbits.scripts:renamefiles
13
14
  resizeimage = devbits.scripts:resizeimage
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "devbits"
3
- version = "1.1.3"
3
+ version = "1.2.0"
4
4
  description = "A lightweight CLI toolkit for daily development utilities."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.9"
@@ -38,6 +38,7 @@ tree = "devbits.scripts:tree"
38
38
  size = "devbits.scripts:size"
39
39
  renamefiles = "devbits.scripts:renamefiles"
40
40
  samplefiles = "devbits.scripts:samplefiles"
41
+ netscan = "devbits.scripts:netscan"
41
42
 
42
43
  [tool.setuptools.packages.find]
43
44
  include = ["devbits*"]
@@ -0,0 +1,139 @@
1
+ from __future__ import annotations
2
+
3
+ import pytest
4
+
5
+ from devbits.cli import main
6
+
7
+
8
+ def test_help() -> None:
9
+ with pytest.raises(SystemExit) as exc:
10
+ main(["--help"])
11
+ assert exc.value.code == 0
12
+
13
+
14
+ def test_clearcache_dry_run(tmp_path) -> None:
15
+ cache = tmp_path / "pkg" / "__pycache__"
16
+ cache.mkdir(parents=True)
17
+ (cache / "a.pyc").write_bytes(b"test")
18
+ assert main(["clearcache", str(tmp_path), "--dry-run"]) == 0
19
+
20
+
21
+ def test_standalone_wrapper_help() -> None:
22
+ import pytest
23
+
24
+ from devbits.scripts import _run
25
+
26
+ with pytest.raises(SystemExit) as exc_info:
27
+ _run("clearcache", ["--help"])
28
+ assert exc_info.value.code == 0
29
+
30
+
31
+ def test_netscan_help() -> None:
32
+ with pytest.raises(SystemExit) as exc:
33
+ main(["netscan", "--help"])
34
+ assert exc.value.code == 0
35
+
36
+
37
+ def test_netscan_lists_devices(capsys, monkeypatch) -> None:
38
+ import ipaddress
39
+
40
+ from devbits.network import Device
41
+
42
+ fake = [
43
+ Device(ip="192.168.0.1", mac="06:f2:67:75:4d:e2", hostname=None, is_gateway=True),
44
+ Device(ip="192.168.0.50", mac="fc:91:5d:76:58:04", hostname="phone.local"),
45
+ Device(ip="192.168.0.203", mac="4e:cc:03:6c:47:f9", hostname=None, is_self=True),
46
+ ]
47
+ monkeypatch.setattr("devbits.cli.scan_network", lambda *a, **k: fake)
48
+ monkeypatch.setattr("devbits.network.default_network", lambda *a, **k: ipaddress.ip_network("192.168.0.0/24"))
49
+
50
+ assert main(["netscan", "--no-color", "--no-resolve"]) == 0
51
+ out = capsys.readouterr().out
52
+ assert "192.168.0.1" in out
53
+ assert "06:f2:67:75:4d:e2" in out
54
+ assert "gateway / router" in out
55
+ assert "this device" in out
56
+ assert "Found 3 device(s)." in out
57
+
58
+
59
+ def test_netscan_arp_parsing() -> None:
60
+ from devbits.network import _MAC_RE, _normalize_mac
61
+
62
+ line = "? (192.168.0.1) at 6:f2:67:75:4d:e2 on en0 ifscope [ethernet]"
63
+ assert _normalize_mac(_MAC_RE.search(line).group()) == "06:f2:67:75:4d:e2"
64
+
65
+
66
+ def test_arp_table_windows_command(monkeypatch) -> None:
67
+ # Windows arp has no -n flag; arp_table must use "arp -a" there.
68
+ import devbits.network as net
69
+
70
+ calls = []
71
+
72
+ class _Result:
73
+ stdout = " 192.168.0.1 aa-bb-cc-dd-ee-ff dynamic"
74
+
75
+ def fake_run(cmd, **kwargs):
76
+ calls.append(cmd)
77
+ return _Result()
78
+
79
+ monkeypatch.setattr(net.platform, "system", lambda: "Windows")
80
+ monkeypatch.setattr(net.subprocess, "run", fake_run)
81
+ table = net.arp_table()
82
+ assert calls == [["arp", "-a"]]
83
+ assert table == {"192.168.0.1": "aa:bb:cc:dd:ee:ff"}
84
+
85
+
86
+ def test_arp_table_linux_falls_back_to_ip_neigh(monkeypatch) -> None:
87
+ # When net-tools `arp` is missing, arp_table must fall back to `ip neigh`.
88
+ import devbits.network as net
89
+
90
+ calls = []
91
+
92
+ def fake_run(cmd, **kwargs):
93
+ calls.append(cmd)
94
+ if cmd[0] == "arp":
95
+ raise FileNotFoundError("arp not installed")
96
+
97
+ class _Result:
98
+ stdout = "192.168.0.1 dev eth0 lladdr aa:bb:cc:dd:ee:ff REACHABLE"
99
+
100
+ return _Result()
101
+
102
+ monkeypatch.setattr(net.platform, "system", lambda: "Linux")
103
+ monkeypatch.setattr(net.subprocess, "run", fake_run)
104
+ table = net.arp_table()
105
+ assert calls == [["arp", "-an"], ["ip", "neigh", "show"]]
106
+ assert table == {"192.168.0.1": "aa:bb:cc:dd:ee:ff"}
107
+
108
+
109
+ def test_private_mac_detection() -> None:
110
+ from devbits.network import is_private_mac
111
+
112
+ assert is_private_mac("4e:cc:03:6c:47:f9") # locally administered (0x02 bit set)
113
+ assert is_private_mac("06:f2:67:75:4d:e2")
114
+ assert not is_private_mac("fc:91:5d:76:58:04") # globally unique OUI
115
+ assert not is_private_mac("b8:27:eb:11:22:33")
116
+
117
+
118
+ def test_netscan_lookup_column(capsys, monkeypatch) -> None:
119
+ import ipaddress
120
+
121
+ import devbits.network as net
122
+ from devbits.network import scan_network
123
+
124
+ monkeypatch.setattr("devbits.cli.scan_network", scan_network)
125
+ monkeypatch.setattr(net, "_ping", lambda ip, timeout=1.0: False)
126
+ monkeypatch.setattr(net, "arp_table", lambda: {
127
+ "192.168.0.1": "fc:91:5d:76:58:04",
128
+ "192.168.0.203": "4e:cc:03:6c:47:f9",
129
+ })
130
+ monkeypatch.setattr(net, "local_ip", lambda: "192.168.0.203")
131
+ monkeypatch.setattr(net, "gateway_ip", lambda: "192.168.0.1")
132
+ monkeypatch.setattr(net, "default_network", lambda *a, **k: ipaddress.ip_network("192.168.0.0/24"))
133
+ monkeypatch.setattr(net, "lookup_vendor", lambda mac, **k: "Google, Inc.")
134
+
135
+ assert main(["netscan", "--no-color", "--no-resolve", "--lookup"]) == 0
136
+ out = capsys.readouterr().out
137
+ assert "VENDOR" in out
138
+ assert "Google, Inc." in out
139
+ assert "(private)" in out # randomized MAC of the self device
@@ -1,28 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import pytest
4
-
5
- from devbits.cli import main
6
-
7
-
8
- def test_help() -> None:
9
- with pytest.raises(SystemExit) as exc:
10
- main(["--help"])
11
- assert exc.value.code == 0
12
-
13
-
14
- def test_clearcache_dry_run(tmp_path) -> None:
15
- cache = tmp_path / "pkg" / "__pycache__"
16
- cache.mkdir(parents=True)
17
- (cache / "a.pyc").write_bytes(b"test")
18
- assert main(["clearcache", str(tmp_path), "--dry-run"]) == 0
19
-
20
-
21
- def test_standalone_wrapper_help() -> None:
22
- import pytest
23
-
24
- from devbits.scripts import _run
25
-
26
- with pytest.raises(SystemExit) as exc_info:
27
- _run("clearcache", ["--help"])
28
- assert exc_info.value.code == 0
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