devbits 1.1.2__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.
- {devbits-1.1.2 → devbits-1.2.0}/PKG-INFO +21 -1
- {devbits-1.1.2 → devbits-1.2.0}/README.md +20 -0
- {devbits-1.1.2 → devbits-1.2.0}/devbits/__init__.py +1 -1
- {devbits-1.1.2 → devbits-1.2.0}/devbits/cli.py +124 -2
- devbits-1.2.0/devbits/network.py +265 -0
- {devbits-1.1.2 → devbits-1.2.0}/devbits/scripts.py +4 -0
- {devbits-1.1.2 → devbits-1.2.0}/devbits/utils.py +1 -1
- {devbits-1.1.2 → devbits-1.2.0}/devbits.egg-info/PKG-INFO +21 -1
- {devbits-1.1.2 → devbits-1.2.0}/devbits.egg-info/SOURCES.txt +1 -0
- {devbits-1.1.2 → devbits-1.2.0}/devbits.egg-info/entry_points.txt +1 -0
- {devbits-1.1.2 → devbits-1.2.0}/pyproject.toml +2 -1
- devbits-1.2.0/tests/test_cli.py +139 -0
- devbits-1.1.2/tests/test_cli.py +0 -28
- {devbits-1.1.2 → devbits-1.2.0}/LICENSE +0 -0
- {devbits-1.1.2 → devbits-1.2.0}/devbits/cache.py +0 -0
- {devbits-1.1.2 → devbits-1.2.0}/devbits/gui.py +0 -0
- {devbits-1.1.2 → devbits-1.2.0}/devbits/image.py +0 -0
- {devbits-1.1.2 → devbits-1.2.0}/devbits/media.py +0 -0
- {devbits-1.1.2 → devbits-1.2.0}/devbits/project.py +0 -0
- {devbits-1.1.2 → devbits-1.2.0}/devbits.egg-info/dependency_links.txt +0 -0
- {devbits-1.1.2 → devbits-1.2.0}/devbits.egg-info/requires.txt +0 -0
- {devbits-1.1.2 → devbits-1.2.0}/devbits.egg-info/top_level.txt +0 -0
- {devbits-1.1.2 → devbits-1.2.0}/setup.cfg +0 -0
- {devbits-1.1.2 → devbits-1.2.0}/tests/test_gui_cli.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: devbits
|
|
3
|
-
Version: 1.
|
|
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,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import argparse
|
|
4
|
+
import os
|
|
4
5
|
import sys
|
|
5
6
|
from pathlib import Path
|
|
6
7
|
|
|
@@ -8,10 +9,48 @@ from . import __version__
|
|
|
8
9
|
from .cache import clear_cache
|
|
9
10
|
from .image import batch_images, check_images, contact_sheet, image_to_ico, recolor_image, resize_image
|
|
10
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
|
|
11
13
|
from .project import print_tree, rename_files, sample_files, top_sizes
|
|
12
14
|
from .utils import ensure_exists
|
|
13
15
|
|
|
14
16
|
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
# Helper: terminal colors
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
|
|
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
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _use_color(force: bool | None = None) -> bool:
|
|
36
|
+
"""Whether to emit ANSI colors: only on a TTY, unless overridden.
|
|
37
|
+
|
|
38
|
+
Honors the ``NO_COLOR`` convention (https://no-color.org). ``force`` of
|
|
39
|
+
``True``/``False`` bypasses auto-detection (e.g. from a ``--no-color`` flag).
|
|
40
|
+
"""
|
|
41
|
+
if force is not None:
|
|
42
|
+
return force
|
|
43
|
+
if os.environ.get("NO_COLOR"):
|
|
44
|
+
return False
|
|
45
|
+
return sys.stdout.isatty()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _colorize(text: str, kind: str, enabled: bool) -> str:
|
|
49
|
+
if not enabled:
|
|
50
|
+
return text
|
|
51
|
+
return f"{_ANSI[kind]}{text}{_ANSI['reset']}"
|
|
52
|
+
|
|
53
|
+
|
|
15
54
|
# ---------------------------------------------------------------------------
|
|
16
55
|
# Helper: derive default output path from input path
|
|
17
56
|
# ---------------------------------------------------------------------------
|
|
@@ -344,6 +383,8 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
344
383
|
help="Root folder to display. Default: current directory")
|
|
345
384
|
p.add_argument("--depth", type=int, default=3,
|
|
346
385
|
help="Maximum depth to traverse. Default: 3")
|
|
386
|
+
p.add_argument("--no-color", action="store_true",
|
|
387
|
+
help="Disable colored output (also honors NO_COLOR).")
|
|
347
388
|
p.set_defaults(func=cmd_tree)
|
|
348
389
|
|
|
349
390
|
# ── size ───────────────────────────────────────────────────
|
|
@@ -362,6 +403,8 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
362
403
|
help="Folder to inspect. Default: current directory")
|
|
363
404
|
p.add_argument("--top", type=int, default=20,
|
|
364
405
|
help="Number of items to show. Default: 20")
|
|
406
|
+
p.add_argument("--no-color", action="store_true",
|
|
407
|
+
help="Disable colored output (also honors NO_COLOR).")
|
|
365
408
|
p.set_defaults(func=cmd_size)
|
|
366
409
|
|
|
367
410
|
# ── renamefiles ────────────────────────────────────────────
|
|
@@ -410,6 +453,39 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
410
453
|
help="Move files instead of copying.")
|
|
411
454
|
p.set_defaults(func=cmd_samplefiles)
|
|
412
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
|
+
|
|
413
489
|
return parser
|
|
414
490
|
|
|
415
491
|
|
|
@@ -509,13 +585,28 @@ def cmd_contactsheet(args: argparse.Namespace) -> None:
|
|
|
509
585
|
|
|
510
586
|
|
|
511
587
|
def cmd_tree(args: argparse.Namespace) -> None:
|
|
588
|
+
color = _use_color(False if args.no_color else None)
|
|
512
589
|
for line in print_tree(ensure_exists(args.folder), args.depth):
|
|
513
|
-
|
|
590
|
+
if not color:
|
|
591
|
+
print(line)
|
|
592
|
+
continue
|
|
593
|
+
# Color only the name, leaving the tree-drawing prefix untouched.
|
|
594
|
+
marker = "── "
|
|
595
|
+
idx = line.rfind(marker)
|
|
596
|
+
split = idx + len(marker) if idx != -1 else 0
|
|
597
|
+
prefix, name = line[:split], line[split:]
|
|
598
|
+
kind = "dir" if name.endswith("/") else "file"
|
|
599
|
+
print(f"{prefix}{_colorize(name, kind, color)}")
|
|
514
600
|
|
|
515
601
|
|
|
516
602
|
def cmd_size(args: argparse.Namespace) -> None:
|
|
603
|
+
color = _use_color(False if args.no_color else None)
|
|
517
604
|
for path, _, size_text in top_sizes(ensure_exists(args.folder), args.top):
|
|
518
|
-
|
|
605
|
+
kind = "dir" if path.is_dir() else "file"
|
|
606
|
+
label = f"{path}{os.sep}" if path.is_dir() else str(path)
|
|
607
|
+
size_col = _colorize(f"{size_text:>10}", "size", color)
|
|
608
|
+
name_col = _colorize(label, kind, color)
|
|
609
|
+
print(f"{size_col} {name_col}")
|
|
519
610
|
|
|
520
611
|
|
|
521
612
|
def cmd_renamefiles(args: argparse.Namespace) -> None:
|
|
@@ -530,6 +621,37 @@ def cmd_samplefiles(args: argparse.Namespace) -> None:
|
|
|
530
621
|
print(f"Saved {len(outputs)} file(s) to {args.output}")
|
|
531
622
|
|
|
532
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
|
+
|
|
533
655
|
def main(argv: list[str] | None = None) -> int:
|
|
534
656
|
parser = build_parser()
|
|
535
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
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: devbits
|
|
3
|
-
Version: 1.
|
|
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:
|
|
@@ -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.
|
|
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
|
devbits-1.1.2/tests/test_cli.py
DELETED
|
@@ -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
|
|
File without changes
|
|
File without changes
|