unifi-network-maps 1.4.0__py3-none-any.whl → 1.4.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- __version__ = "1.4.0"
1
+ __version__ = "1.4.2"
@@ -0,0 +1,8 @@
1
+ """Module entrypoint for python -m unifi_network_maps."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .cli.main import main
6
+
7
+ if __name__ == "__main__":
8
+ raise SystemExit(main())
@@ -3,13 +3,17 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import hashlib
6
+ import json
6
7
  import logging
7
8
  import os
8
- import pickle
9
+ import stat
9
10
  import time
10
- from collections.abc import Iterable
11
+ from collections.abc import Callable, Iterator, Sequence
12
+ from concurrent.futures import ThreadPoolExecutor
13
+ from concurrent.futures import TimeoutError as FutureTimeoutError
14
+ from contextlib import contextmanager
11
15
  from pathlib import Path
12
- from typing import TYPE_CHECKING
16
+ from typing import IO, TYPE_CHECKING
13
17
 
14
18
  from .config import Config
15
19
 
@@ -23,6 +27,194 @@ def _cache_dir() -> Path:
23
27
  return Path(os.environ.get("UNIFI_CACHE_DIR", ".cache/unifi_network_maps"))
24
28
 
25
29
 
30
+ def _device_attr(device: object, name: str) -> object | None:
31
+ if isinstance(device, dict):
32
+ return device.get(name)
33
+ return getattr(device, name, None)
34
+
35
+
36
+ def _first_attr(device: object, *names: str) -> object | None:
37
+ for name in names:
38
+ value = _device_attr(device, name)
39
+ if value is not None:
40
+ return value
41
+ return None
42
+
43
+
44
+ def _as_list(value: object | None) -> list[object]:
45
+ if value is None:
46
+ return []
47
+ if isinstance(value, list):
48
+ return value
49
+ if isinstance(value, dict):
50
+ return [value]
51
+ if isinstance(value, str | bytes):
52
+ return []
53
+ try:
54
+ return list(value) # type: ignore[arg-type]
55
+ except TypeError:
56
+ return []
57
+
58
+
59
+ def _serialize_lldp_entry(entry: object) -> dict[str, object]:
60
+ return {
61
+ "chassis_id": _first_attr(entry, "chassis_id", "chassisId"),
62
+ "port_id": _first_attr(entry, "port_id", "portId"),
63
+ "port_desc": _first_attr(entry, "port_desc", "portDesc", "port_descr", "portDescr"),
64
+ "local_port_name": _first_attr(entry, "local_port_name", "localPortName"),
65
+ "local_port_idx": _first_attr(entry, "local_port_idx", "localPortIdx"),
66
+ }
67
+
68
+
69
+ def _serialize_lldp_entries(value: object | None) -> list[dict[str, object]]:
70
+ entries = _as_list(value)
71
+ serialized: list[dict[str, object]] = []
72
+ for entry in entries:
73
+ data = _serialize_lldp_entry(entry)
74
+ if data.get("chassis_id") and data.get("port_id"):
75
+ serialized.append(data)
76
+ return serialized
77
+
78
+
79
+ def _serialize_port_entry(entry: object) -> dict[str, object]:
80
+ aggregation_group = _first_attr(
81
+ entry,
82
+ "aggregation_group",
83
+ "aggregation_id",
84
+ "aggregate_id",
85
+ "agg_id",
86
+ "lag_id",
87
+ "lag_group",
88
+ "link_aggregation_group",
89
+ "link_aggregation_id",
90
+ "aggregate",
91
+ "aggregated_by",
92
+ )
93
+ return {
94
+ "port_idx": _first_attr(entry, "port_idx", "portIdx"),
95
+ "name": _first_attr(entry, "name"),
96
+ "ifname": _first_attr(entry, "ifname"),
97
+ "speed": _first_attr(entry, "speed"),
98
+ "aggregation_group": aggregation_group,
99
+ "port_poe": _first_attr(entry, "port_poe"),
100
+ "poe_enable": _first_attr(entry, "poe_enable"),
101
+ "poe_good": _first_attr(entry, "poe_good"),
102
+ "poe_power": _first_attr(entry, "poe_power"),
103
+ }
104
+
105
+
106
+ def _serialize_port_table(value: object | None) -> list[dict[str, object]]:
107
+ return [_serialize_port_entry(entry) for entry in _as_list(value)]
108
+
109
+
110
+ def _serialize_uplink(value: object | None) -> dict[str, object] | None:
111
+ if value is None:
112
+ return None
113
+ data = {
114
+ "uplink_mac": _first_attr(value, "uplink_mac", "uplink_device_mac"),
115
+ "uplink_device_name": _first_attr(value, "uplink_device_name", "uplink_name"),
116
+ "uplink_remote_port": _first_attr(value, "uplink_remote_port", "port_idx"),
117
+ }
118
+ if any(item is not None for item in data.values()):
119
+ return data
120
+ return None
121
+
122
+
123
+ def _device_lldp_value(device: object) -> object | None:
124
+ lldp_info = _device_attr(device, "lldp_info")
125
+ if lldp_info is None:
126
+ lldp_info = _device_attr(device, "lldp")
127
+ if lldp_info is None:
128
+ lldp_info = _device_attr(device, "lldp_table")
129
+ return lldp_info
130
+
131
+
132
+ def _device_uplink_fields(device: object) -> dict[str, object | None]:
133
+ return {
134
+ "uplink": _serialize_uplink(_device_attr(device, "uplink")),
135
+ "last_uplink": _serialize_uplink(_device_attr(device, "last_uplink")),
136
+ "uplink_mac": _first_attr(device, "uplink_mac", "uplink_device_mac"),
137
+ "uplink_device_name": _device_attr(device, "uplink_device_name"),
138
+ "uplink_remote_port": _device_attr(device, "uplink_remote_port"),
139
+ "last_uplink_mac": _device_attr(device, "last_uplink_mac"),
140
+ }
141
+
142
+
143
+ def _serialize_device_for_cache(device: object) -> dict[str, object]:
144
+ payload = {
145
+ "name": _device_attr(device, "name"),
146
+ "model_name": _device_attr(device, "model_name"),
147
+ "model": _device_attr(device, "model"),
148
+ "mac": _device_attr(device, "mac"),
149
+ "ip": _first_attr(device, "ip", "ip_address"),
150
+ "type": _first_attr(device, "type", "device_type"),
151
+ "displayable_version": _first_attr(device, "displayable_version", "version"),
152
+ "lldp_info": _serialize_lldp_entries(_device_lldp_value(device)),
153
+ "port_table": _serialize_port_table(_device_attr(device, "port_table")),
154
+ }
155
+ payload.update(_device_uplink_fields(device))
156
+ return payload
157
+
158
+
159
+ def _serialize_devices_for_cache(devices: Sequence[object]) -> list[dict[str, object]]:
160
+ return [_serialize_device_for_cache(device) for device in devices]
161
+
162
+
163
+ def _cache_lock_path(path: Path) -> Path:
164
+ return path.with_suffix(path.suffix + ".lock")
165
+
166
+
167
+ def _acquire_cache_lock(lock_file: IO[str]) -> None:
168
+ if os.name == "nt":
169
+ import msvcrt
170
+
171
+ msvcrt.locking(lock_file.fileno(), msvcrt.LK_LOCK, 1)
172
+ else:
173
+ import fcntl
174
+
175
+ fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
176
+
177
+
178
+ def _release_cache_lock(lock_file: IO[str]) -> None:
179
+ if os.name == "nt":
180
+ import msvcrt
181
+
182
+ msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1)
183
+ else:
184
+ import fcntl
185
+
186
+ fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
187
+
188
+
189
+ @contextmanager
190
+ def _cache_lock(path: Path) -> Iterator[None]:
191
+ lock_path = _cache_lock_path(path)
192
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
193
+ with lock_path.open("a+", encoding="utf-8") as lock_file:
194
+ try:
195
+ _acquire_cache_lock(lock_file)
196
+ yield
197
+ finally:
198
+ try:
199
+ _release_cache_lock(lock_file)
200
+ except OSError:
201
+ logger.debug("Failed to release cache lock %s", lock_path)
202
+
203
+
204
+ def _is_cache_dir_safe(path: Path) -> bool:
205
+ if not path.exists():
206
+ return True
207
+ try:
208
+ mode = stat.S_IMODE(path.stat().st_mode)
209
+ except OSError as exc:
210
+ logger.warning("Failed to stat cache dir %s: %s", path, exc)
211
+ return False
212
+ if mode & (stat.S_IWGRP | stat.S_IWOTH):
213
+ logger.warning("Cache dir %s is group/world-writable; skipping cache", path)
214
+ return False
215
+ return True
216
+
217
+
26
218
  def _cache_ttl_seconds() -> int:
27
219
  value = os.environ.get("UNIFI_CACHE_TTL_SECONDS", "").strip()
28
220
  if not value:
@@ -38,7 +230,7 @@ def _cache_key(*parts: str) -> str:
38
230
  return digest[:24]
39
231
 
40
232
 
41
- def _load_cache(path: Path, ttl_seconds: int) -> object | None:
233
+ def _load_cache(path: Path, ttl_seconds: int) -> Sequence[object] | None:
42
234
  data, age = _load_cache_with_age(path)
43
235
  if data is None:
44
236
  return None
@@ -49,14 +241,18 @@ def _load_cache(path: Path, ttl_seconds: int) -> object | None:
49
241
  return data
50
242
 
51
243
 
52
- def _load_cache_with_age(path: Path) -> tuple[object | None, float | None]:
244
+ def _load_cache_with_age(path: Path) -> tuple[Sequence[object] | None, float | None]:
53
245
  if not path.exists():
54
246
  return None, None
55
247
  try:
56
- payload = pickle.loads(path.read_bytes())
248
+ with _cache_lock(path):
249
+ payload = json.loads(path.read_text(encoding="utf-8"))
57
250
  except Exception as exc:
58
251
  logger.debug("Failed to read cache %s: %s", path, exc)
59
252
  return None, None
253
+ if not isinstance(payload, dict):
254
+ logger.debug("Cached payload at %s is not a dict", path)
255
+ return None, None
60
256
  timestamp = payload.get("timestamp")
61
257
  if not isinstance(timestamp, int | float):
62
258
  return None, None
@@ -67,13 +263,16 @@ def _load_cache_with_age(path: Path) -> tuple[object | None, float | None]:
67
263
  return data, time.time() - timestamp
68
264
 
69
265
 
70
- def _save_cache(path: Path, data: object) -> None:
266
+ def _save_cache(path: Path, data: Sequence[object]) -> None:
71
267
  try:
72
268
  path.parent.mkdir(parents=True, exist_ok=True)
269
+ if not _is_cache_dir_safe(path.parent):
270
+ return
73
271
  payload = {"timestamp": time.time(), "data": data}
74
272
  tmp_path = path.with_suffix(path.suffix + ".tmp")
75
- tmp_path.write_bytes(pickle.dumps(payload))
76
- tmp_path.replace(path)
273
+ with _cache_lock(path):
274
+ tmp_path.write_text(json.dumps(payload, ensure_ascii=True), encoding="utf-8")
275
+ tmp_path.replace(path)
77
276
  except Exception as exc:
78
277
  logger.debug("Failed to write cache %s: %s", path, exc)
79
278
 
@@ -99,13 +298,37 @@ def _retry_backoff_seconds() -> float:
99
298
  return 0.5
100
299
 
101
300
 
102
- def _call_with_retries(operation: str, func) -> object:
301
+ def _request_timeout_seconds() -> float | None:
302
+ value = os.environ.get("UNIFI_REQUEST_TIMEOUT_SECONDS", "").strip()
303
+ if not value:
304
+ return None
305
+ try:
306
+ return max(0.0, float(value))
307
+ except ValueError:
308
+ logger.warning("Invalid UNIFI_REQUEST_TIMEOUT_SECONDS value: %s", value)
309
+ return None
310
+
311
+
312
+ def _call_with_timeout[T](operation: str, func: Callable[[], T], timeout: float | None) -> T:
313
+ if timeout is None or timeout <= 0:
314
+ return func()
315
+ with ThreadPoolExecutor(max_workers=1) as executor:
316
+ future = executor.submit(func)
317
+ try:
318
+ return future.result(timeout=timeout)
319
+ except FutureTimeoutError as exc:
320
+ future.cancel()
321
+ raise TimeoutError(f"{operation} timed out after {timeout:.2f}s") from exc
322
+
323
+
324
+ def _call_with_retries[T](operation: str, func: Callable[[], T]) -> T:
103
325
  attempts = _retry_attempts()
104
326
  backoff = _retry_backoff_seconds()
327
+ timeout = _request_timeout_seconds()
105
328
  last_exc: Exception | None = None
106
329
  for attempt in range(1, attempts + 1):
107
330
  try:
108
- return func()
331
+ return _call_with_timeout(operation, func, timeout)
109
332
  except Exception as exc: # noqa: BLE001 - surface full error after retries
110
333
  last_exc = exc
111
334
  logger.warning("Failed %s attempt %d/%d: %s", operation, attempt, attempts, exc)
@@ -129,8 +352,12 @@ def _init_controller(config: Config, *, is_udm_pro: bool) -> UnifiController:
129
352
 
130
353
 
131
354
  def fetch_devices(
132
- config: Config, *, site: str | None = None, detailed: bool = True
133
- ) -> Iterable[object]:
355
+ config: Config,
356
+ *,
357
+ site: str | None = None,
358
+ detailed: bool = True,
359
+ use_cache: bool = True,
360
+ ) -> Sequence[object]:
134
361
  """Fetch devices from UniFi Controller.
135
362
 
136
363
  Uses `unifi-controller-api` to authenticate and return device objects.
@@ -142,9 +369,13 @@ def fetch_devices(
142
369
 
143
370
  site_name = site or config.site
144
371
  ttl_seconds = _cache_ttl_seconds()
145
- cache_path = _cache_dir() / f"devices_{_cache_key(config.url, site_name, str(detailed))}.pkl"
146
- cached = _load_cache(cache_path, ttl_seconds)
147
- stale_cached, cache_age = _load_cache_with_age(cache_path)
372
+ cache_path = _cache_dir() / f"devices_{_cache_key(config.url, site_name, str(detailed))}.json"
373
+ if use_cache and _is_cache_dir_safe(cache_path.parent):
374
+ cached = _load_cache(cache_path, ttl_seconds)
375
+ stale_cached, cache_age = _load_cache_with_age(cache_path)
376
+ else:
377
+ cached = None
378
+ stale_cached, cache_age = None, None
148
379
  if cached is not None:
149
380
  logger.info("Using cached devices (%d)", len(cached))
150
381
  return cached
@@ -155,7 +386,7 @@ def fetch_devices(
155
386
  logger.info("UDM Pro authentication failed, retrying legacy auth")
156
387
  controller = _init_controller(config, is_udm_pro=False)
157
388
 
158
- def _fetch() -> list[object]:
389
+ def _fetch() -> Sequence[object]:
159
390
  return controller.get_unifi_site_device(site_name=site_name, detailed=detailed, raw=False)
160
391
 
161
392
  try:
@@ -169,12 +400,18 @@ def fetch_devices(
169
400
  )
170
401
  return stale_cached
171
402
  raise
172
- _save_cache(cache_path, devices)
403
+ if use_cache:
404
+ _save_cache(cache_path, _serialize_devices_for_cache(devices))
173
405
  logger.info("Fetched %d devices", len(devices))
174
406
  return devices
175
407
 
176
408
 
177
- def fetch_clients(config: Config, *, site: str | None = None) -> Iterable[object]:
409
+ def fetch_clients(
410
+ config: Config,
411
+ *,
412
+ site: str | None = None,
413
+ use_cache: bool = True,
414
+ ) -> Sequence[object]:
178
415
  """Fetch active clients from UniFi Controller."""
179
416
  try:
180
417
  from unifi_controller_api import UnifiAuthenticationError
@@ -183,9 +420,13 @@ def fetch_clients(config: Config, *, site: str | None = None) -> Iterable[object
183
420
 
184
421
  site_name = site or config.site
185
422
  ttl_seconds = _cache_ttl_seconds()
186
- cache_path = _cache_dir() / f"clients_{_cache_key(config.url, site_name)}.pkl"
187
- cached = _load_cache(cache_path, ttl_seconds)
188
- stale_cached, cache_age = _load_cache_with_age(cache_path)
423
+ cache_path = _cache_dir() / f"clients_{_cache_key(config.url, site_name)}.json"
424
+ if use_cache and _is_cache_dir_safe(cache_path.parent):
425
+ cached = _load_cache(cache_path, ttl_seconds)
426
+ stale_cached, cache_age = _load_cache_with_age(cache_path)
427
+ else:
428
+ cached = None
429
+ stale_cached, cache_age = None, None
189
430
  if cached is not None:
190
431
  logger.info("Using cached clients (%d)", len(cached))
191
432
  return cached
@@ -196,7 +437,7 @@ def fetch_clients(config: Config, *, site: str | None = None) -> Iterable[object
196
437
  logger.info("UDM Pro authentication failed, retrying legacy auth")
197
438
  controller = _init_controller(config, is_udm_pro=False)
198
439
 
199
- def _fetch() -> list[object]:
440
+ def _fetch() -> Sequence[object]:
200
441
  return controller.get_unifi_site_client(site_name=site_name, raw=True)
201
442
 
202
443
  try:
@@ -210,6 +451,7 @@ def fetch_clients(config: Config, *, site: str | None = None) -> Iterable[object
210
451
  )
211
452
  return stale_cached
212
453
  raise
213
- _save_cache(cache_path, clients)
454
+ if use_cache:
455
+ _save_cache(cache_path, clients)
214
456
  logger.info("Fetched %d clients", len(clients))
215
457
  return clients