unifi-network-maps 1.4.12__py3-none-any.whl → 1.4.14__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.
- unifi_network_maps/__init__.py +1 -1
- unifi_network_maps/adapters/config.py +6 -2
- unifi_network_maps/adapters/unifi.py +124 -7
- unifi_network_maps/cli/args.py +1 -1
- unifi_network_maps/cli/main.py +85 -4
- unifi_network_maps/cli/render.py +4 -2
- unifi_network_maps/io/export.py +11 -2
- unifi_network_maps/io/mkdocs_assets.py +4 -2
- unifi_network_maps/io/mock_data.py +21 -2
- unifi_network_maps/io/paths.py +201 -0
- unifi_network_maps/model/mock.py +17 -1
- unifi_network_maps/model/topology.py +3 -3
- unifi_network_maps/model/vlans.py +119 -0
- unifi_network_maps/render/device_ports_md.py +27 -18
- unifi_network_maps/render/lldp_md.py +4 -1
- unifi_network_maps/render/theme.py +2 -1
- {unifi_network_maps-1.4.12.dist-info → unifi_network_maps-1.4.14.dist-info}/METADATA +9 -3
- {unifi_network_maps-1.4.12.dist-info → unifi_network_maps-1.4.14.dist-info}/RECORD +22 -20
- {unifi_network_maps-1.4.12.dist-info → unifi_network_maps-1.4.14.dist-info}/WHEEL +1 -1
- {unifi_network_maps-1.4.12.dist-info → unifi_network_maps-1.4.14.dist-info}/entry_points.txt +0 -0
- {unifi_network_maps-1.4.12.dist-info → unifi_network_maps-1.4.14.dist-info}/licenses/LICENSE +0 -0
- {unifi_network_maps-1.4.12.dist-info → unifi_network_maps-1.4.14.dist-info}/top_level.txt +0 -0
unifi_network_maps/__init__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "1.4.
|
|
1
|
+
__version__ = "1.4.14"
|
|
@@ -4,6 +4,9 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import os
|
|
6
6
|
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from ..io.paths import resolve_env_file
|
|
7
10
|
|
|
8
11
|
|
|
9
12
|
def _parse_bool(value: str | None, default: bool = True) -> bool:
|
|
@@ -26,13 +29,14 @@ class Config:
|
|
|
26
29
|
verify_ssl: bool
|
|
27
30
|
|
|
28
31
|
@classmethod
|
|
29
|
-
def from_env(cls, *, env_file: str | None = None) -> Config:
|
|
32
|
+
def from_env(cls, *, env_file: str | Path | None = None) -> Config:
|
|
30
33
|
if env_file:
|
|
31
34
|
try:
|
|
32
35
|
from dotenv import load_dotenv
|
|
33
36
|
except ImportError:
|
|
34
37
|
raise ValueError("python-dotenv required for --env-file") from None
|
|
35
|
-
|
|
38
|
+
env_path = resolve_env_file(env_file)
|
|
39
|
+
load_dotenv(dotenv_path=env_path)
|
|
36
40
|
url = os.environ.get("UNIFI_URL", "").strip()
|
|
37
41
|
site = os.environ.get("UNIFI_SITE", "default").strip()
|
|
38
42
|
user = os.environ.get("UNIFI_USER", "").strip()
|
|
@@ -7,6 +7,7 @@ import json
|
|
|
7
7
|
import logging
|
|
8
8
|
import os
|
|
9
9
|
import stat
|
|
10
|
+
import tempfile
|
|
10
11
|
import time
|
|
11
12
|
from collections.abc import Callable, Iterator, Sequence
|
|
12
13
|
from concurrent.futures import ThreadPoolExecutor
|
|
@@ -15,6 +16,8 @@ from contextlib import contextmanager
|
|
|
15
16
|
from pathlib import Path
|
|
16
17
|
from typing import IO, TYPE_CHECKING
|
|
17
18
|
|
|
19
|
+
from ..io.paths import resolve_cache_dir
|
|
20
|
+
from ..model.vlans import build_vlan_info, normalize_networks
|
|
18
21
|
from .config import Config
|
|
19
22
|
|
|
20
23
|
if TYPE_CHECKING:
|
|
@@ -24,7 +27,15 @@ logger = logging.getLogger(__name__)
|
|
|
24
27
|
|
|
25
28
|
|
|
26
29
|
def _cache_dir() -> Path:
|
|
27
|
-
|
|
30
|
+
default_dir = ".cache/unifi_network_maps"
|
|
31
|
+
if os.environ.get("PYTEST_CURRENT_TEST"):
|
|
32
|
+
default_dir = str(Path(tempfile.gettempdir()) / f"unifi_network_maps_pytest_{os.getpid()}")
|
|
33
|
+
value = os.environ.get("UNIFI_CACHE_DIR", default_dir)
|
|
34
|
+
try:
|
|
35
|
+
return resolve_cache_dir(value)
|
|
36
|
+
except ValueError as exc:
|
|
37
|
+
logger.warning("Invalid UNIFI_CACHE_DIR (%s); using default: %s", value, exc)
|
|
38
|
+
return resolve_cache_dir(".cache/unifi_network_maps")
|
|
28
39
|
|
|
29
40
|
|
|
30
41
|
def _device_attr(device: object, name: str) -> object | None:
|
|
@@ -160,6 +171,19 @@ def _serialize_devices_for_cache(devices: Sequence[object]) -> list[dict[str, ob
|
|
|
160
171
|
return [_serialize_device_for_cache(device) for device in devices]
|
|
161
172
|
|
|
162
173
|
|
|
174
|
+
def _serialize_network_for_cache(network: object) -> dict[str, object]:
|
|
175
|
+
return {
|
|
176
|
+
"name": _first_attr(network, "name", "network_name", "networkName"),
|
|
177
|
+
"vlan": _first_attr(network, "vlan", "vlan_id", "vlanId", "vlanid"),
|
|
178
|
+
"vlan_enabled": _first_attr(network, "vlan_enabled", "vlanEnabled"),
|
|
179
|
+
"purpose": _first_attr(network, "purpose"),
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _serialize_networks_for_cache(networks: Sequence[object]) -> list[dict[str, object]]:
|
|
184
|
+
return [_serialize_network_for_cache(network) for network in networks]
|
|
185
|
+
|
|
186
|
+
|
|
163
187
|
def _cache_lock_path(path: Path) -> Path:
|
|
164
188
|
return path.with_suffix(path.suffix + ".lock")
|
|
165
189
|
|
|
@@ -377,13 +401,13 @@ def fetch_devices(
|
|
|
377
401
|
cached = None
|
|
378
402
|
stale_cached, cache_age = None, None
|
|
379
403
|
if cached is not None:
|
|
380
|
-
logger.
|
|
404
|
+
logger.debug("Using cached devices (%d)", len(cached))
|
|
381
405
|
return cached
|
|
382
406
|
|
|
383
407
|
try:
|
|
384
408
|
controller = _init_controller(config, is_udm_pro=True)
|
|
385
409
|
except UnifiAuthenticationError:
|
|
386
|
-
logger.
|
|
410
|
+
logger.debug("UDM Pro authentication failed, retrying legacy auth")
|
|
387
411
|
controller = _init_controller(config, is_udm_pro=False)
|
|
388
412
|
|
|
389
413
|
def _fetch() -> Sequence[object]:
|
|
@@ -402,7 +426,7 @@ def fetch_devices(
|
|
|
402
426
|
raise
|
|
403
427
|
if use_cache:
|
|
404
428
|
_save_cache(cache_path, _serialize_devices_for_cache(devices))
|
|
405
|
-
logger.
|
|
429
|
+
logger.debug("Fetched %d devices", len(devices))
|
|
406
430
|
return devices
|
|
407
431
|
|
|
408
432
|
|
|
@@ -428,13 +452,13 @@ def fetch_clients(
|
|
|
428
452
|
cached = None
|
|
429
453
|
stale_cached, cache_age = None, None
|
|
430
454
|
if cached is not None:
|
|
431
|
-
logger.
|
|
455
|
+
logger.debug("Using cached clients (%d)", len(cached))
|
|
432
456
|
return cached
|
|
433
457
|
|
|
434
458
|
try:
|
|
435
459
|
controller = _init_controller(config, is_udm_pro=True)
|
|
436
460
|
except UnifiAuthenticationError:
|
|
437
|
-
logger.
|
|
461
|
+
logger.debug("UDM Pro authentication failed, retrying legacy auth")
|
|
438
462
|
controller = _init_controller(config, is_udm_pro=False)
|
|
439
463
|
|
|
440
464
|
def _fetch() -> Sequence[object]:
|
|
@@ -453,5 +477,98 @@ def fetch_clients(
|
|
|
453
477
|
raise
|
|
454
478
|
if use_cache:
|
|
455
479
|
_save_cache(cache_path, clients)
|
|
456
|
-
logger.
|
|
480
|
+
logger.debug("Fetched %d clients", len(clients))
|
|
457
481
|
return clients
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def fetch_networks(
|
|
485
|
+
config: Config,
|
|
486
|
+
*,
|
|
487
|
+
site: str | None = None,
|
|
488
|
+
use_cache: bool = True,
|
|
489
|
+
) -> Sequence[object]:
|
|
490
|
+
"""Fetch network inventory from UniFi Controller."""
|
|
491
|
+
try:
|
|
492
|
+
from unifi_controller_api import UnifiAuthenticationError
|
|
493
|
+
except ImportError as exc:
|
|
494
|
+
raise RuntimeError("Missing dependency: unifi-controller-api") from exc
|
|
495
|
+
|
|
496
|
+
site_name = site or config.site
|
|
497
|
+
ttl_seconds = _cache_ttl_seconds()
|
|
498
|
+
cache_path = _cache_dir() / f"networks_{_cache_key(config.url, site_name)}.json"
|
|
499
|
+
if use_cache and _is_cache_dir_safe(cache_path.parent):
|
|
500
|
+
cached = _load_cache(cache_path, ttl_seconds)
|
|
501
|
+
stale_cached, cache_age = _load_cache_with_age(cache_path)
|
|
502
|
+
else:
|
|
503
|
+
cached = None
|
|
504
|
+
stale_cached, cache_age = None, None
|
|
505
|
+
if cached is not None:
|
|
506
|
+
logger.debug("Using cached networks (%d)", len(cached))
|
|
507
|
+
return cached
|
|
508
|
+
|
|
509
|
+
try:
|
|
510
|
+
controller = _init_controller(config, is_udm_pro=True)
|
|
511
|
+
except UnifiAuthenticationError:
|
|
512
|
+
logger.debug("UDM Pro authentication failed, retrying legacy auth")
|
|
513
|
+
controller = _init_controller(config, is_udm_pro=False)
|
|
514
|
+
|
|
515
|
+
def _fetch() -> Sequence[object]:
|
|
516
|
+
try:
|
|
517
|
+
return controller.get_unifi_site_networkconf(site_name=site_name, raw=False)
|
|
518
|
+
except Exception as exc: # noqa: BLE001 - fallback to raw network data
|
|
519
|
+
logger.warning("Networkconf model parse failed; retrying raw fetch: %s", exc)
|
|
520
|
+
return controller.get_unifi_site_networkconf(site_name=site_name, raw=True)
|
|
521
|
+
|
|
522
|
+
try:
|
|
523
|
+
networks = _call_with_retries("network fetch", _fetch)
|
|
524
|
+
except Exception as exc: # noqa: BLE001 - fallback to cache
|
|
525
|
+
if stale_cached is not None:
|
|
526
|
+
logger.warning(
|
|
527
|
+
"Network fetch failed; using stale cache (%ds old): %s",
|
|
528
|
+
int(cache_age or 0),
|
|
529
|
+
exc,
|
|
530
|
+
)
|
|
531
|
+
return stale_cached
|
|
532
|
+
raise
|
|
533
|
+
if use_cache:
|
|
534
|
+
_save_cache(cache_path, _serialize_networks_for_cache(networks))
|
|
535
|
+
logger.debug("Fetched %d networks", len(networks))
|
|
536
|
+
return networks
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def fetch_payload(
|
|
540
|
+
config: Config,
|
|
541
|
+
*,
|
|
542
|
+
site: str | None = None,
|
|
543
|
+
include_clients: bool = True,
|
|
544
|
+
use_cache: bool = True,
|
|
545
|
+
) -> dict[str, list[object] | list[dict[str, object]]]:
|
|
546
|
+
"""Fetch devices, clients, and VLAN inventory for payload output."""
|
|
547
|
+
devices = list(fetch_devices(config, site=site, detailed=True, use_cache=use_cache))
|
|
548
|
+
clients = _fetch_payload_clients(
|
|
549
|
+
config,
|
|
550
|
+
site=site,
|
|
551
|
+
include_clients=include_clients,
|
|
552
|
+
use_cache=use_cache,
|
|
553
|
+
)
|
|
554
|
+
networks = list(fetch_networks(config, site=site, use_cache=use_cache))
|
|
555
|
+
normalized_networks = normalize_networks(networks)
|
|
556
|
+
vlan_info = build_vlan_info(clients, normalized_networks)
|
|
557
|
+
return {
|
|
558
|
+
"devices": devices,
|
|
559
|
+
"clients": clients,
|
|
560
|
+
"networks": normalized_networks,
|
|
561
|
+
"vlan_info": vlan_info,
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def _fetch_payload_clients(
|
|
566
|
+
config: Config,
|
|
567
|
+
*,
|
|
568
|
+
site: str | None,
|
|
569
|
+
include_clients: bool,
|
|
570
|
+
use_cache: bool,
|
|
571
|
+
) -> list[object]:
|
|
572
|
+
if not include_clients:
|
|
573
|
+
return []
|
|
574
|
+
return list(fetch_clients(config, site=site, use_cache=use_cache))
|
unifi_network_maps/cli/args.py
CHANGED
|
@@ -125,7 +125,7 @@ def add_general_render_args(parser: argparse._ArgumentGroup) -> None:
|
|
|
125
125
|
parser.add_argument(
|
|
126
126
|
"--format",
|
|
127
127
|
default="mermaid",
|
|
128
|
-
choices=["mermaid", "svg", "svg-iso", "lldp-md", "mkdocs"],
|
|
128
|
+
choices=["mermaid", "svg", "svg-iso", "lldp-md", "mkdocs", "json"],
|
|
129
129
|
help="Output format",
|
|
130
130
|
)
|
|
131
131
|
parser.add_argument(
|
unifi_network_maps/cli/main.py
CHANGED
|
@@ -3,11 +3,21 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import argparse
|
|
6
|
+
import json
|
|
6
7
|
import logging
|
|
8
|
+
from pathlib import Path
|
|
7
9
|
|
|
8
10
|
from ..adapters.config import Config
|
|
11
|
+
from ..adapters.unifi import fetch_payload
|
|
9
12
|
from ..io.export import write_output
|
|
10
|
-
from ..io.mock_data import load_mock_data
|
|
13
|
+
from ..io.mock_data import load_mock_data, load_mock_payload
|
|
14
|
+
from ..io.paths import (
|
|
15
|
+
resolve_env_file,
|
|
16
|
+
resolve_mock_data_path,
|
|
17
|
+
resolve_output_path,
|
|
18
|
+
resolve_theme_path,
|
|
19
|
+
)
|
|
20
|
+
from ..model.vlans import build_vlan_info, normalize_networks
|
|
11
21
|
from ..render.legend import render_legend_only, resolve_legend_style
|
|
12
22
|
from ..render.theme import resolve_themes
|
|
13
23
|
from .args import build_parser
|
|
@@ -16,7 +26,7 @@ from .render import render_lldp_format, render_standard_format
|
|
|
16
26
|
logger = logging.getLogger(__name__)
|
|
17
27
|
|
|
18
28
|
|
|
19
|
-
def _load_dotenv(env_file: str | None = None) -> None:
|
|
29
|
+
def _load_dotenv(env_file: str | Path | None = None) -> None:
|
|
20
30
|
try:
|
|
21
31
|
from dotenv import load_dotenv
|
|
22
32
|
except ImportError:
|
|
@@ -30,6 +40,36 @@ def _parse_args(argv: list[str] | None) -> argparse.Namespace:
|
|
|
30
40
|
return parser.parse_args(argv)
|
|
31
41
|
|
|
32
42
|
|
|
43
|
+
class _DowngradeInfoToDebugFilter(logging.Filter):
|
|
44
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
45
|
+
if record.name.startswith("unifi_controller_api") and record.levelno == logging.INFO:
|
|
46
|
+
record.levelno = logging.DEBUG
|
|
47
|
+
record.levelname = logging.getLevelName(logging.DEBUG)
|
|
48
|
+
return True
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _downgrade_unifi_controller_logs() -> logging.Filter:
|
|
52
|
+
return _DowngradeInfoToDebugFilter()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _validate_paths(args: argparse.Namespace) -> bool:
|
|
56
|
+
try:
|
|
57
|
+
if args.env_file:
|
|
58
|
+
resolve_env_file(args.env_file)
|
|
59
|
+
if args.mock_data:
|
|
60
|
+
resolve_mock_data_path(args.mock_data, require_exists=False)
|
|
61
|
+
if args.theme_file:
|
|
62
|
+
resolve_theme_path(args.theme_file, require_exists=False)
|
|
63
|
+
if args.generate_mock:
|
|
64
|
+
resolve_output_path(args.generate_mock, format_name="mock")
|
|
65
|
+
if args.output:
|
|
66
|
+
resolve_output_path(args.output, format_name=args.format)
|
|
67
|
+
except ValueError as exc:
|
|
68
|
+
logging.error(str(exc))
|
|
69
|
+
return False
|
|
70
|
+
return True
|
|
71
|
+
|
|
72
|
+
|
|
33
73
|
def _load_config(args: argparse.Namespace) -> Config | None:
|
|
34
74
|
try:
|
|
35
75
|
_load_dotenv(args.env_file)
|
|
@@ -59,7 +99,8 @@ def _handle_generate_mock(args: argparse.Namespace) -> int | None:
|
|
|
59
99
|
wireless_client_count=max(0, args.mock_wireless_clients),
|
|
60
100
|
)
|
|
61
101
|
content = mock_payload_json(options)
|
|
62
|
-
|
|
102
|
+
output_kwargs = {"format_name": "mock"} if args.generate_mock else {}
|
|
103
|
+
write_output(content, output_path=args.generate_mock, stdout=args.stdout, **output_kwargs)
|
|
63
104
|
return 0
|
|
64
105
|
|
|
65
106
|
|
|
@@ -79,9 +120,45 @@ def _load_runtime_context(
|
|
|
79
120
|
return config, site, None, None
|
|
80
121
|
|
|
81
122
|
|
|
123
|
+
def _handle_json_format(
|
|
124
|
+
args: argparse.Namespace,
|
|
125
|
+
*,
|
|
126
|
+
config: Config | None,
|
|
127
|
+
site: str,
|
|
128
|
+
) -> int | None:
|
|
129
|
+
if args.format != "json":
|
|
130
|
+
return None
|
|
131
|
+
payload: dict[str, list[object] | list[dict[str, object]]]
|
|
132
|
+
if args.mock_data:
|
|
133
|
+
payload = load_mock_payload(args.mock_data)
|
|
134
|
+
if not args.include_clients:
|
|
135
|
+
payload["clients"] = []
|
|
136
|
+
networks = normalize_networks(payload.get("networks", []))
|
|
137
|
+
payload["networks"] = networks
|
|
138
|
+
payload["vlan_info"] = build_vlan_info(payload.get("clients", []), networks)
|
|
139
|
+
else:
|
|
140
|
+
if config is None:
|
|
141
|
+
logging.error("Config required to run")
|
|
142
|
+
return 2
|
|
143
|
+
payload = fetch_payload(
|
|
144
|
+
config,
|
|
145
|
+
site=site,
|
|
146
|
+
include_clients=args.include_clients,
|
|
147
|
+
use_cache=not args.no_cache,
|
|
148
|
+
)
|
|
149
|
+
content = json.dumps(payload, indent=2, sort_keys=True)
|
|
150
|
+
output_kwargs = {"format_name": args.format} if args.output else {}
|
|
151
|
+
write_output(content, output_path=args.output, stdout=args.stdout, **output_kwargs)
|
|
152
|
+
return 0
|
|
153
|
+
|
|
154
|
+
|
|
82
155
|
def main(argv: list[str] | None = None) -> int:
|
|
83
156
|
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
|
|
157
|
+
for handler in logging.getLogger().handlers:
|
|
158
|
+
handler.addFilter(_downgrade_unifi_controller_logs())
|
|
84
159
|
args = _parse_args(argv)
|
|
160
|
+
if not _validate_paths(args):
|
|
161
|
+
return 2
|
|
85
162
|
mock_result = _handle_generate_mock(args)
|
|
86
163
|
if mock_result is not None:
|
|
87
164
|
return mock_result
|
|
@@ -90,6 +167,9 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
90
167
|
except ValueError as exc:
|
|
91
168
|
logging.error(str(exc))
|
|
92
169
|
return 2
|
|
170
|
+
payload_result = _handle_json_format(args, config=config, site=site)
|
|
171
|
+
if payload_result is not None:
|
|
172
|
+
return payload_result
|
|
93
173
|
try:
|
|
94
174
|
mermaid_theme, svg_theme = resolve_themes(args.theme_file)
|
|
95
175
|
except Exception as exc: # noqa: BLE001
|
|
@@ -107,7 +187,8 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
107
187
|
markdown=args.markdown,
|
|
108
188
|
theme=mermaid_theme,
|
|
109
189
|
)
|
|
110
|
-
|
|
190
|
+
output_kwargs = {"format_name": args.format} if args.output else {}
|
|
191
|
+
write_output(content, output_path=args.output, stdout=args.stdout, **output_kwargs)
|
|
111
192
|
return 0
|
|
112
193
|
|
|
113
194
|
if args.format == "lldp-md":
|
unifi_network_maps/cli/render.py
CHANGED
|
@@ -207,7 +207,8 @@ def render_lldp_format(
|
|
|
207
207
|
client_mode=args.client_scope,
|
|
208
208
|
only_unifi=args.only_unifi,
|
|
209
209
|
)
|
|
210
|
-
|
|
210
|
+
output_kwargs = {"format_name": args.format} if args.output else {}
|
|
211
|
+
write_output(content, output_path=args.output, stdout=args.stdout, **output_kwargs)
|
|
211
212
|
return 0
|
|
212
213
|
|
|
213
214
|
|
|
@@ -267,5 +268,6 @@ def render_standard_format(
|
|
|
267
268
|
logging.error("Unsupported format: %s", args.format)
|
|
268
269
|
return 2
|
|
269
270
|
|
|
270
|
-
|
|
271
|
+
output_kwargs = {"format_name": args.format} if args.output else {}
|
|
272
|
+
write_output(content, output_path=args.output, stdout=args.stdout, **output_kwargs)
|
|
271
273
|
return 0
|
unifi_network_maps/io/export.py
CHANGED
|
@@ -7,10 +7,19 @@ import sys
|
|
|
7
7
|
import tempfile
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
|
|
10
|
+
from .paths import resolve_output_path
|
|
10
11
|
|
|
11
|
-
|
|
12
|
+
|
|
13
|
+
def write_output(
|
|
14
|
+
content: str,
|
|
15
|
+
*,
|
|
16
|
+
output_path: str | Path | None,
|
|
17
|
+
stdout: bool,
|
|
18
|
+
format_name: str | None = None,
|
|
19
|
+
) -> None:
|
|
12
20
|
if output_path:
|
|
13
|
-
|
|
21
|
+
resolved = resolve_output_path(output_path, format_name=format_name)
|
|
22
|
+
_write_atomic(resolved, content)
|
|
14
23
|
if stdout or not output_path:
|
|
15
24
|
sys.stdout.write(content)
|
|
16
25
|
|
|
@@ -5,10 +5,12 @@ from __future__ import annotations
|
|
|
5
5
|
from pathlib import Path
|
|
6
6
|
|
|
7
7
|
from ..render.templating import render_template
|
|
8
|
+
from .paths import resolve_output_file
|
|
8
9
|
|
|
9
10
|
|
|
10
|
-
def write_mkdocs_sidebar_assets(output_path: str) -> None:
|
|
11
|
-
|
|
11
|
+
def write_mkdocs_sidebar_assets(output_path: str | Path) -> None:
|
|
12
|
+
resolved = resolve_output_file(output_path, extensions=None, label="MkDocs output file")
|
|
13
|
+
output_dir = resolved.parent
|
|
12
14
|
assets_dir = output_dir / "assets"
|
|
13
15
|
assets_dir.mkdir(parents=True, exist_ok=True)
|
|
14
16
|
(assets_dir / "legend.js").write_text(
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
|
-
|
|
6
|
+
|
|
7
|
+
from .paths import resolve_mock_data_path
|
|
7
8
|
|
|
8
9
|
|
|
9
10
|
def _as_list(value: object, name: str) -> list[object]:
|
|
@@ -15,9 +16,27 @@ def _as_list(value: object, name: str) -> list[object]:
|
|
|
15
16
|
|
|
16
17
|
|
|
17
18
|
def load_mock_data(path: str) -> tuple[list[object], list[object]]:
|
|
18
|
-
|
|
19
|
+
resolved = resolve_mock_data_path(path)
|
|
20
|
+
payload = json.loads(resolved.read_text(encoding="utf-8"))
|
|
19
21
|
if not isinstance(payload, dict):
|
|
20
22
|
raise ValueError("Mock data must be a JSON object")
|
|
21
23
|
devices = _as_list(payload.get("devices"), "devices")
|
|
22
24
|
clients = _as_list(payload.get("clients"), "clients")
|
|
23
25
|
return devices, clients
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def load_mock_payload(path: str) -> dict[str, list[object] | list[dict[str, object]]]:
|
|
29
|
+
resolved = resolve_mock_data_path(path)
|
|
30
|
+
payload = json.loads(resolved.read_text(encoding="utf-8"))
|
|
31
|
+
if not isinstance(payload, dict):
|
|
32
|
+
raise ValueError("Mock data must be a JSON object")
|
|
33
|
+
devices = _as_list(payload.get("devices"), "devices")
|
|
34
|
+
clients = _as_list(payload.get("clients"), "clients")
|
|
35
|
+
networks = _as_list(payload.get("networks"), "networks")
|
|
36
|
+
vlan_info = _as_list(payload.get("vlan_info"), "vlan_info")
|
|
37
|
+
return {
|
|
38
|
+
"devices": devices,
|
|
39
|
+
"clients": clients,
|
|
40
|
+
"networks": networks,
|
|
41
|
+
"vlan_info": vlan_info,
|
|
42
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Path validation helpers for user-supplied file system inputs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import tempfile
|
|
8
|
+
from collections.abc import Iterable
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _safe_home_dir() -> Path | None:
|
|
15
|
+
try:
|
|
16
|
+
return Path.home().resolve()
|
|
17
|
+
except Exception:
|
|
18
|
+
return None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _base_roots() -> list[Path]:
|
|
22
|
+
roots = [Path.cwd().resolve()]
|
|
23
|
+
home = _safe_home_dir()
|
|
24
|
+
if home:
|
|
25
|
+
roots.append(home)
|
|
26
|
+
try:
|
|
27
|
+
roots.append(Path(tempfile.gettempdir()).resolve())
|
|
28
|
+
except OSError as exc:
|
|
29
|
+
# Best-effort temp dir; resolution can fail in restricted environments.
|
|
30
|
+
logger.debug("Failed to resolve temp directory: %s", exc)
|
|
31
|
+
return roots
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _extra_roots_from_env() -> list[Path]:
|
|
35
|
+
extra = os.environ.get("UNIFI_ALLOWED_PATHS", "")
|
|
36
|
+
roots: list[Path] = []
|
|
37
|
+
if extra:
|
|
38
|
+
for raw in extra.split(os.pathsep):
|
|
39
|
+
raw = raw.strip()
|
|
40
|
+
if raw:
|
|
41
|
+
roots.append(Path(raw).expanduser().resolve())
|
|
42
|
+
return roots
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _allowed_roots() -> tuple[Path, ...]:
|
|
46
|
+
roots = _base_roots() + _extra_roots_from_env()
|
|
47
|
+
seen: set[str] = set()
|
|
48
|
+
unique: list[Path] = []
|
|
49
|
+
for root in roots:
|
|
50
|
+
key = str(root)
|
|
51
|
+
if key not in seen:
|
|
52
|
+
seen.add(key)
|
|
53
|
+
unique.append(root)
|
|
54
|
+
return tuple(unique)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _resolve_user_path(path: str | Path) -> Path:
|
|
58
|
+
return Path(path).expanduser().resolve(strict=False)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _ensure_within_allowed(path: Path, roots: Iterable[Path], *, label: str) -> None:
|
|
62
|
+
for root in roots:
|
|
63
|
+
try:
|
|
64
|
+
path.relative_to(root)
|
|
65
|
+
except ValueError:
|
|
66
|
+
continue
|
|
67
|
+
else:
|
|
68
|
+
return
|
|
69
|
+
root_list = ", ".join(str(root) for root in roots)
|
|
70
|
+
raise ValueError(f"{label} must be within: {root_list}")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _ensure_no_symlink(path: Path, *, label: str) -> None:
|
|
74
|
+
if path.exists() and path.is_symlink():
|
|
75
|
+
raise ValueError(f"{label} must not be a symlink: {path}")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _ensure_no_symlink_in_parents(path: Path, *, label: str) -> None:
|
|
79
|
+
for parent in path.parents:
|
|
80
|
+
if parent.exists() and parent.is_symlink():
|
|
81
|
+
raise ValueError(f"{label} parent must not be a symlink: {parent}")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _normalize_extensions(extensions: Iterable[str]) -> set[str]:
|
|
85
|
+
normalized = set()
|
|
86
|
+
for ext in extensions:
|
|
87
|
+
ext = ext.strip().lower()
|
|
88
|
+
if not ext:
|
|
89
|
+
continue
|
|
90
|
+
if not ext.startswith("."):
|
|
91
|
+
ext = f".{ext}"
|
|
92
|
+
normalized.add(ext)
|
|
93
|
+
return normalized
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _ensure_extension(
|
|
97
|
+
path: Path,
|
|
98
|
+
extensions: Iterable[str] | None,
|
|
99
|
+
*,
|
|
100
|
+
label: str,
|
|
101
|
+
allow_missing: bool = False,
|
|
102
|
+
) -> None:
|
|
103
|
+
if not extensions:
|
|
104
|
+
return
|
|
105
|
+
allowed = _normalize_extensions(extensions)
|
|
106
|
+
suffix = path.suffix.lower()
|
|
107
|
+
if not suffix:
|
|
108
|
+
if allow_missing:
|
|
109
|
+
return
|
|
110
|
+
raise ValueError(f"{label} must have one of: {', '.join(sorted(allowed))}")
|
|
111
|
+
if suffix not in allowed:
|
|
112
|
+
raise ValueError(f"{label} must have one of: {', '.join(sorted(allowed))}")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def resolve_input_file(
|
|
116
|
+
path: str | Path,
|
|
117
|
+
*,
|
|
118
|
+
extensions: Iterable[str] | None,
|
|
119
|
+
label: str,
|
|
120
|
+
require_exists: bool = True,
|
|
121
|
+
) -> Path:
|
|
122
|
+
resolved = _resolve_user_path(path)
|
|
123
|
+
_ensure_within_allowed(resolved, _allowed_roots(), label=label)
|
|
124
|
+
_ensure_extension(resolved, extensions, label=label)
|
|
125
|
+
if require_exists:
|
|
126
|
+
if not resolved.exists():
|
|
127
|
+
raise ValueError(f"{label} does not exist: {resolved}")
|
|
128
|
+
if not resolved.is_file():
|
|
129
|
+
raise ValueError(f"{label} must be a file: {resolved}")
|
|
130
|
+
return resolved
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def resolve_output_file(
|
|
134
|
+
path: str | Path,
|
|
135
|
+
*,
|
|
136
|
+
extensions: Iterable[str] | None,
|
|
137
|
+
label: str,
|
|
138
|
+
allow_missing_extension: bool = False,
|
|
139
|
+
) -> Path:
|
|
140
|
+
resolved = _resolve_user_path(path)
|
|
141
|
+
_ensure_within_allowed(resolved, _allowed_roots(), label=label)
|
|
142
|
+
_ensure_extension(
|
|
143
|
+
resolved,
|
|
144
|
+
extensions,
|
|
145
|
+
label=label,
|
|
146
|
+
allow_missing=allow_missing_extension,
|
|
147
|
+
)
|
|
148
|
+
return resolved
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def resolve_env_file(path: str | Path) -> Path:
|
|
152
|
+
resolved = _resolve_user_path(path)
|
|
153
|
+
_ensure_within_allowed(resolved, _allowed_roots(), label="Env file")
|
|
154
|
+
if not (resolved.name.startswith(".env") or resolved.name.endswith(".env")):
|
|
155
|
+
raise ValueError("Env file must end with .env")
|
|
156
|
+
if resolved.exists() and not resolved.is_file():
|
|
157
|
+
raise ValueError(f"Env file must be a file: {resolved}")
|
|
158
|
+
return resolved
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def resolve_mock_data_path(path: str | Path, *, require_exists: bool = True) -> Path:
|
|
162
|
+
return resolve_input_file(
|
|
163
|
+
path,
|
|
164
|
+
extensions={".json"},
|
|
165
|
+
label="Mock data file",
|
|
166
|
+
require_exists=require_exists,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def resolve_theme_path(path: str | Path, *, require_exists: bool = True) -> Path:
|
|
171
|
+
return resolve_input_file(
|
|
172
|
+
path,
|
|
173
|
+
extensions={".yml", ".yaml"},
|
|
174
|
+
label="Theme file",
|
|
175
|
+
require_exists=require_exists,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def resolve_output_path(path: str | Path, *, format_name: str | None) -> Path:
|
|
180
|
+
extensions: set[str] | None
|
|
181
|
+
if format_name == "svg" or format_name == "svg-iso":
|
|
182
|
+
extensions = {".svg"}
|
|
183
|
+
elif format_name in {"mock", "json"}:
|
|
184
|
+
extensions = {".json"}
|
|
185
|
+
elif format_name == "mermaid":
|
|
186
|
+
extensions = {".md", ".mermaid", ".mmd"}
|
|
187
|
+
elif format_name == "lldp-md":
|
|
188
|
+
extensions = {".md"}
|
|
189
|
+
elif format_name == "mkdocs":
|
|
190
|
+
extensions = {".md"}
|
|
191
|
+
else:
|
|
192
|
+
extensions = None
|
|
193
|
+
return resolve_output_file(path, extensions=extensions, label="Output file")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def resolve_cache_dir(path: str | Path) -> Path:
|
|
197
|
+
resolved = _resolve_user_path(path)
|
|
198
|
+
_ensure_within_allowed(resolved, _allowed_roots(), label="Cache directory")
|
|
199
|
+
_ensure_no_symlink(resolved, label="Cache directory")
|
|
200
|
+
_ensure_no_symlink_in_parents(resolved, label="Cache directory")
|
|
201
|
+
return resolved
|
unifi_network_maps/model/mock.py
CHANGED
|
@@ -9,6 +9,8 @@ from typing import Any
|
|
|
9
9
|
|
|
10
10
|
from faker import Faker
|
|
11
11
|
|
|
12
|
+
from .vlans import build_vlan_info, normalize_networks
|
|
13
|
+
|
|
12
14
|
|
|
13
15
|
@dataclass(frozen=True)
|
|
14
16
|
class MockOptions:
|
|
@@ -34,7 +36,14 @@ def generate_mock_payload(options: MockOptions) -> dict[str, list[dict[str, Any]
|
|
|
34
36
|
state = _build_state(options.seed)
|
|
35
37
|
devices, core_switch, aps = _build_devices(options, state)
|
|
36
38
|
clients = _build_clients(options, state, core_switch, aps)
|
|
37
|
-
|
|
39
|
+
networks = _build_networks()
|
|
40
|
+
vlan_info = build_vlan_info(clients, networks)
|
|
41
|
+
return {
|
|
42
|
+
"devices": devices,
|
|
43
|
+
"clients": clients,
|
|
44
|
+
"networks": normalize_networks(networks),
|
|
45
|
+
"vlan_info": vlan_info,
|
|
46
|
+
}
|
|
38
47
|
|
|
39
48
|
|
|
40
49
|
def mock_payload_json(options: MockOptions) -> str:
|
|
@@ -108,6 +117,13 @@ def _build_clients(
|
|
|
108
117
|
return clients
|
|
109
118
|
|
|
110
119
|
|
|
120
|
+
def _build_networks() -> list[dict[str, Any]]:
|
|
121
|
+
return [
|
|
122
|
+
{"name": "LAN", "vlan_enabled": False, "purpose": "corporate"},
|
|
123
|
+
{"name": "Guest", "vlan": 20, "vlan_enabled": True, "purpose": "guest"},
|
|
124
|
+
]
|
|
125
|
+
|
|
126
|
+
|
|
111
127
|
def _build_wired_clients(
|
|
112
128
|
count: int, state: _MockState, core_switch: dict[str, Any]
|
|
113
129
|
) -> list[dict[str, Any]]:
|
|
@@ -723,7 +723,7 @@ def build_edges(
|
|
|
723
723
|
)
|
|
724
724
|
|
|
725
725
|
poe_edges = sum(1 for edge in edges if edge.poe)
|
|
726
|
-
logger.
|
|
726
|
+
logger.debug("Built %d unique edges (%d PoE)", len(edges), poe_edges)
|
|
727
727
|
return edges
|
|
728
728
|
|
|
729
729
|
|
|
@@ -978,14 +978,14 @@ def build_topology(
|
|
|
978
978
|
) -> TopologyResult:
|
|
979
979
|
normalized_devices = list(devices)
|
|
980
980
|
lldp_entries = sum(len(device.lldp_info) for device in normalized_devices)
|
|
981
|
-
logger.
|
|
981
|
+
logger.debug(
|
|
982
982
|
"Normalized %d devices (%d LLDP entries)",
|
|
983
983
|
len(normalized_devices),
|
|
984
984
|
lldp_entries,
|
|
985
985
|
)
|
|
986
986
|
raw_edges = build_edges(normalized_devices, include_ports=include_ports, only_unifi=only_unifi)
|
|
987
987
|
tree_edges = build_tree_edges_by_topology(raw_edges, gateways)
|
|
988
|
-
logger.
|
|
988
|
+
logger.debug(
|
|
989
989
|
"Built %d hierarchy edges (gateways=%d)",
|
|
990
990
|
len(tree_edges),
|
|
991
991
|
len(gateways),
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""VLAN inventory helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _as_list(value: object | None) -> list[object]:
|
|
9
|
+
if value is None:
|
|
10
|
+
return []
|
|
11
|
+
if isinstance(value, list):
|
|
12
|
+
return value
|
|
13
|
+
if isinstance(value, dict):
|
|
14
|
+
return [value]
|
|
15
|
+
if isinstance(value, str | bytes):
|
|
16
|
+
return []
|
|
17
|
+
if isinstance(value, Iterable):
|
|
18
|
+
return list(value)
|
|
19
|
+
return []
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _get_attr(obj: object, name: str) -> object | None:
|
|
23
|
+
if isinstance(obj, dict):
|
|
24
|
+
return obj.get(name)
|
|
25
|
+
return getattr(obj, name, None)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _first_attr(obj: object, *names: str) -> object | None:
|
|
29
|
+
for name in names:
|
|
30
|
+
value = _get_attr(obj, name)
|
|
31
|
+
if value is not None:
|
|
32
|
+
return value
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _as_bool(value: object | None) -> bool:
|
|
37
|
+
if isinstance(value, bool):
|
|
38
|
+
return value
|
|
39
|
+
if isinstance(value, int | float):
|
|
40
|
+
return value != 0
|
|
41
|
+
if isinstance(value, str):
|
|
42
|
+
return value.strip().lower() in {"1", "true", "yes", "y", "on"}
|
|
43
|
+
return False
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _as_vlan_id(value: object | None) -> int | None:
|
|
47
|
+
if isinstance(value, int):
|
|
48
|
+
return value if value > 0 else None
|
|
49
|
+
if isinstance(value, str):
|
|
50
|
+
return int(value) if value.isdigit() and int(value) > 0 else None
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _network_vlan_id(network: object) -> int | None:
|
|
55
|
+
vlan_value = _first_attr(network, "vlan", "vlan_id", "vlanId", "vlanid")
|
|
56
|
+
vlan_enabled = _as_bool(_first_attr(network, "vlan_enabled", "vlanEnabled"))
|
|
57
|
+
vlan_id = _as_vlan_id(vlan_value)
|
|
58
|
+
if vlan_id is not None:
|
|
59
|
+
return vlan_id
|
|
60
|
+
if not vlan_enabled:
|
|
61
|
+
return 1
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def normalize_networks(networks: Iterable[object]) -> list[dict[str, object]]:
|
|
66
|
+
normalized: list[dict[str, object]] = []
|
|
67
|
+
for network in _as_list(networks):
|
|
68
|
+
if network is None:
|
|
69
|
+
continue
|
|
70
|
+
normalized.append(
|
|
71
|
+
{
|
|
72
|
+
"network_id": _first_attr(network, "_id", "id", "network_id", "networkId"),
|
|
73
|
+
"name": _first_attr(network, "name", "network_name", "networkName"),
|
|
74
|
+
"vlan_id": _network_vlan_id(network),
|
|
75
|
+
"vlan_enabled": _as_bool(_first_attr(network, "vlan_enabled", "vlanEnabled")),
|
|
76
|
+
"purpose": _first_attr(network, "purpose"),
|
|
77
|
+
}
|
|
78
|
+
)
|
|
79
|
+
return normalized
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def build_vlan_info(
|
|
83
|
+
clients: Iterable[object], networks: Iterable[object]
|
|
84
|
+
) -> list[dict[str, object]]:
|
|
85
|
+
vlan_counts = _client_vlan_counts(clients)
|
|
86
|
+
vlan_entries = _network_vlan_entries(networks)
|
|
87
|
+
for vlan_id, count in vlan_counts.items():
|
|
88
|
+
entry = vlan_entries.setdefault(
|
|
89
|
+
vlan_id,
|
|
90
|
+
{"id": vlan_id, "name": None, "client_count": 0},
|
|
91
|
+
)
|
|
92
|
+
entry["client_count"] = count
|
|
93
|
+
return [vlan_entries[key] for key in sorted(vlan_entries)]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _client_vlan_counts(clients: Iterable[object]) -> dict[int, int]:
|
|
97
|
+
vlan_counts: dict[int, int] = {}
|
|
98
|
+
for client in _as_list(clients):
|
|
99
|
+
vlan_id = _as_vlan_id(_first_attr(client, "vlan", "vlan_id", "vlanId", "vlanid"))
|
|
100
|
+
if vlan_id is None:
|
|
101
|
+
continue
|
|
102
|
+
vlan_counts[vlan_id] = vlan_counts.get(vlan_id, 0) + 1
|
|
103
|
+
return vlan_counts
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _network_vlan_entries(networks: Iterable[object]) -> dict[int, dict[str, object]]:
|
|
107
|
+
vlan_entries: dict[int, dict[str, object]] = {}
|
|
108
|
+
for network in normalize_networks(networks):
|
|
109
|
+
vlan_id = network.get("vlan_id")
|
|
110
|
+
if not isinstance(vlan_id, int):
|
|
111
|
+
continue
|
|
112
|
+
entry = vlan_entries.setdefault(
|
|
113
|
+
vlan_id,
|
|
114
|
+
{"id": vlan_id, "name": None, "client_count": 0},
|
|
115
|
+
)
|
|
116
|
+
name = network.get("name")
|
|
117
|
+
if name and not entry["name"]:
|
|
118
|
+
entry["name"] = name
|
|
119
|
+
return vlan_entries
|
|
@@ -83,11 +83,11 @@ def _render_device_ports(
|
|
|
83
83
|
rows = _build_port_rows(device, port_map, client_ports)
|
|
84
84
|
table_rows = [
|
|
85
85
|
[
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
86
|
+
_escape_markdown_text(port_label),
|
|
87
|
+
_escape_connected_cell(connected or "-"),
|
|
88
|
+
_escape_markdown_text(speed),
|
|
89
|
+
_escape_markdown_text(poe_state),
|
|
90
|
+
_escape_markdown_text(power),
|
|
91
91
|
]
|
|
92
92
|
for port_label, connected, speed, poe_state, power in rows
|
|
93
93
|
]
|
|
@@ -224,9 +224,11 @@ def _format_connections(
|
|
|
224
224
|
for peer in sorted(peers, key=str.lower):
|
|
225
225
|
peer_label = port_map.get((peer, device_name))
|
|
226
226
|
if peer_label:
|
|
227
|
-
peer_entries.append(
|
|
227
|
+
peer_entries.append(
|
|
228
|
+
f"{_escape_markdown_text(peer)} ({_escape_markdown_text(peer_label)})"
|
|
229
|
+
)
|
|
228
230
|
else:
|
|
229
|
-
peer_entries.append(peer)
|
|
231
|
+
peer_entries.append(_escape_markdown_text(peer))
|
|
230
232
|
peer_text = ", ".join(peer_entries)
|
|
231
233
|
client_text = _format_client_connections(clients)
|
|
232
234
|
if peer_text and client_text:
|
|
@@ -292,8 +294,15 @@ def _port_sort_key(port: object) -> tuple[int, str]:
|
|
|
292
294
|
return (1, name.lower())
|
|
293
295
|
|
|
294
296
|
|
|
295
|
-
def
|
|
296
|
-
|
|
297
|
+
def _escape_markdown_text(value: str) -> str:
|
|
298
|
+
escaped = value.replace("\\", "\\\\")
|
|
299
|
+
for char in ("|", "[", "]", "*", "_", "`", "<", ">"):
|
|
300
|
+
escaped = escaped.replace(char, f"\\{char}")
|
|
301
|
+
return escaped
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _escape_connected_cell(value: str) -> str:
|
|
305
|
+
return value
|
|
297
306
|
|
|
298
307
|
|
|
299
308
|
def _render_device_details(device: Device) -> list[str]:
|
|
@@ -302,14 +311,14 @@ def _render_device_details(device: Device) -> list[str]:
|
|
|
302
311
|
"",
|
|
303
312
|
"| Field | Value |",
|
|
304
313
|
"| --- | --- |",
|
|
305
|
-
f"| Model | {
|
|
306
|
-
f"| Type | {
|
|
307
|
-
f"| IP | {
|
|
308
|
-
f"| MAC | {
|
|
309
|
-
f"| Firmware | {
|
|
310
|
-
f"| Uplink | {
|
|
311
|
-
f"| Ports | {
|
|
312
|
-
f"| PoE | {
|
|
314
|
+
f"| Model | {_escape_markdown_text(_device_model_label(device))} |",
|
|
315
|
+
f"| Type | {_escape_markdown_text(device.type or '-')} |",
|
|
316
|
+
f"| IP | {_escape_markdown_text(device.ip or '-')} |",
|
|
317
|
+
f"| MAC | {_escape_markdown_text(device.mac or '-')} |",
|
|
318
|
+
f"| Firmware | {_escape_markdown_text(device.version or '-')} |",
|
|
319
|
+
f"| Uplink | {_escape_markdown_text(_uplink_summary(device))} |",
|
|
320
|
+
f"| Ports | {_escape_markdown_text(_port_summary(device))} |",
|
|
321
|
+
f"| PoE | {_escape_markdown_text(_poe_summary(device))} |",
|
|
313
322
|
"",
|
|
314
323
|
]
|
|
315
324
|
return lines
|
|
@@ -367,7 +376,7 @@ def _format_client_connections(clients: list[str]) -> str:
|
|
|
367
376
|
if not clients:
|
|
368
377
|
return ""
|
|
369
378
|
if len(clients) == 1:
|
|
370
|
-
return f"{clients[0]} (client)"
|
|
379
|
+
return f"{_escape_markdown_text(clients[0])} (client)"
|
|
371
380
|
items = "".join(f"<li>{_escape_html(name)}</li>" for name in clients)
|
|
372
381
|
return f'<ul class="unifi-port-clients">{items}</ul>'
|
|
373
382
|
|
|
@@ -271,7 +271,10 @@ def _lldp_rows(
|
|
|
271
271
|
|
|
272
272
|
|
|
273
273
|
def _escape_cell(value: str) -> str:
|
|
274
|
-
|
|
274
|
+
escaped = value.replace("\\", "\\\\")
|
|
275
|
+
for char in ("|", "[", "]", "*", "_", "`", "<", ">"):
|
|
276
|
+
escaped = escaped.replace(char, f"\\{char}")
|
|
277
|
+
return escaped
|
|
275
278
|
|
|
276
279
|
|
|
277
280
|
def _client_rows(
|
|
@@ -6,6 +6,7 @@ from pathlib import Path
|
|
|
6
6
|
|
|
7
7
|
import yaml
|
|
8
8
|
|
|
9
|
+
from ..io.paths import resolve_theme_path
|
|
9
10
|
from .mermaid_theme import DEFAULT_THEME as DEFAULT_MERMAID_THEME
|
|
10
11
|
from .mermaid_theme import MermaidTheme
|
|
11
12
|
from .svg_theme import DEFAULT_THEME as DEFAULT_SVG_THEME
|
|
@@ -90,7 +91,7 @@ def _svg_theme_from_dict(data: dict, base: SvgTheme) -> SvgTheme:
|
|
|
90
91
|
|
|
91
92
|
|
|
92
93
|
def load_theme(path: str | Path) -> tuple[MermaidTheme, SvgTheme]:
|
|
93
|
-
theme_path =
|
|
94
|
+
theme_path = resolve_theme_path(path, require_exists=False)
|
|
94
95
|
payload = yaml.safe_load(theme_path.read_text(encoding="utf-8"))
|
|
95
96
|
if not isinstance(payload, dict):
|
|
96
97
|
raise ValueError("Theme file must contain a YAML mapping")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: unifi-network-maps
|
|
3
|
-
Version: 1.4.
|
|
3
|
+
Version: 1.4.14
|
|
4
4
|
Summary: Dynamic UniFi -> network maps in mermaid or svg
|
|
5
5
|
Author: Merlijn
|
|
6
6
|
License-Expression: MIT
|
|
@@ -32,7 +32,7 @@ Requires-Dist: pre-commit==4.5.1; extra == "dev"
|
|
|
32
32
|
Requires-Dist: pytest==9.0.2; extra == "dev"
|
|
33
33
|
Requires-Dist: pytest-cov==7.0.0; extra == "dev"
|
|
34
34
|
Requires-Dist: pyright==1.1.408; extra == "dev"
|
|
35
|
-
Requires-Dist: ruff==0.14.
|
|
35
|
+
Requires-Dist: ruff==0.14.14; extra == "dev"
|
|
36
36
|
Dynamic: license-file
|
|
37
37
|
|
|
38
38
|
# unifi-network-maps
|
|
@@ -147,6 +147,12 @@ Legend only:
|
|
|
147
147
|
unifi-network-maps --legend-only --stdout
|
|
148
148
|
```
|
|
149
149
|
|
|
150
|
+
JSON payload (devices + clients + VLAN inventory):
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
unifi-network-maps --format json --output ./payload.json
|
|
154
|
+
```
|
|
155
|
+
|
|
150
156
|
## Home Assistant integration
|
|
151
157
|
|
|
152
158
|
The live Home Assistant integration (Config Flow + coordinator + custom card) lives in a separate repo:
|
|
@@ -238,7 +244,7 @@ SVG:
|
|
|
238
244
|
- `--theme-file`: load a YAML theme for Mermaid + SVG colors (see `examples/theme.yaml` and `examples/theme-dark.yaml`).
|
|
239
245
|
|
|
240
246
|
Output:
|
|
241
|
-
- `--format mermaid|svg|svg-iso|lldp-md|mkdocs`: output format (default mermaid).
|
|
247
|
+
- `--format mermaid|svg|svg-iso|lldp-md|mkdocs|json`: output format (default mermaid).
|
|
242
248
|
- `--stdout`: write output to stdout.
|
|
243
249
|
- `--markdown`: wrap Mermaid output in a code fence.
|
|
244
250
|
- `--mkdocs-sidebar-legend`: write assets to place the compact legend in the MkDocs right sidebar.
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
unifi_network_maps/__init__.py,sha256=
|
|
1
|
+
unifi_network_maps/__init__.py,sha256=NZioYPEcnxQYvCUVptOXRB6uodGfQ2LLvoB6xgFKjps,23
|
|
2
2
|
unifi_network_maps/__main__.py,sha256=XsOjaqslAVgyVlOTokjVddZ2iT8apZXpJ_OB-9WEEe4,179
|
|
3
3
|
unifi_network_maps/adapters/__init__.py,sha256=nzx1KsiYalL_YuXKE6acW8Dj5flmMg0-i4gyYO0gV54,22
|
|
4
|
-
unifi_network_maps/adapters/config.py,sha256=
|
|
5
|
-
unifi_network_maps/adapters/unifi.py,sha256=
|
|
4
|
+
unifi_network_maps/adapters/config.py,sha256=gnvgAj9wPmYGGQfhcZSHRkcVGojODUYIINU_ztTcuUc,1671
|
|
5
|
+
unifi_network_maps/adapters/unifi.py,sha256=GIPeGVKPR53-Gj0xWYXNCwk1_FUFgQkyY5hDjS-RpAY,19429
|
|
6
6
|
unifi_network_maps/assets/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
7
|
unifi_network_maps/assets/icons/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
8
|
unifi_network_maps/assets/icons/access-point.svg,sha256=RJOgO2s9Ino5lWRrh4V7q8Jdwffros5bQq3UeDuQYF4,742
|
|
@@ -52,26 +52,28 @@ unifi_network_maps/assets/themes/dark.yaml,sha256=6n4_H6MZbA6DKLdF2eaNcCXYLKNJjV
|
|
|
52
52
|
unifi_network_maps/assets/themes/default.yaml,sha256=F2Jj18NmdaJ_zyERvGAn8NEWBwapjtozrtZUxayd5AU,849
|
|
53
53
|
unifi_network_maps/cli/__init__.py,sha256=cds9GvFNZmYAR22Ab3TSzfriSAW--kf9jvC5U-21AoA,70
|
|
54
54
|
unifi_network_maps/cli/__main__.py,sha256=nK_jh78VW3h3DRvSpjzpcf64zkCqniP2k82xUR9Hw2I,147
|
|
55
|
-
unifi_network_maps/cli/args.py,sha256=
|
|
56
|
-
unifi_network_maps/cli/main.py,sha256=
|
|
57
|
-
unifi_network_maps/cli/render.py,sha256=
|
|
55
|
+
unifi_network_maps/cli/args.py,sha256=ro3x8Yn77j7gcmJIyLDmpVsmYiYrXrzkF2LZ6QUQmHA,5560
|
|
56
|
+
unifi_network_maps/cli/main.py,sha256=W-iScP6x4zGRTrc3v_3bTgxkeyOCOBiRfqIjJqRiq3o,7088
|
|
57
|
+
unifi_network_maps/cli/render.py,sha256=wuvsC-In-ApbaQxBn8-UofB7jCiqUnjluGXRCRZpg5A,7535
|
|
58
58
|
unifi_network_maps/cli/runtime.py,sha256=cMtIShERup2z2_uCcEGcaJFJptvdI0L3FDWqKFwSevY,4830
|
|
59
59
|
unifi_network_maps/io/__init__.py,sha256=nzx1KsiYalL_YuXKE6acW8Dj5flmMg0-i4gyYO0gV54,22
|
|
60
60
|
unifi_network_maps/io/debug.py,sha256=eUVs6GLfs6VqI6ma8ra7NLhC3q4ek2K8OSRLnpQDF9s,1745
|
|
61
|
-
unifi_network_maps/io/export.py,sha256=
|
|
62
|
-
unifi_network_maps/io/mkdocs_assets.py,sha256=
|
|
63
|
-
unifi_network_maps/io/mock_data.py,sha256=
|
|
61
|
+
unifi_network_maps/io/export.py,sha256=1_RcXpPqoerCt7WsiheV4dwKgY2fFJlpjUGRs-EkHE4,1013
|
|
62
|
+
unifi_network_maps/io/mkdocs_assets.py,sha256=pszCivIql3maaLw7KFcAioQaVcyLltXgg-fPXPBvvEk,716
|
|
63
|
+
unifi_network_maps/io/mock_data.py,sha256=D5OT0iYipuGKT-gHGx6yNGujOcOsxjmQDCfSzxmJzkI,1446
|
|
64
64
|
unifi_network_maps/io/mock_generate.py,sha256=xiJz_qtNW7iVj7dezLNyVXAHtCHAi8U5CSXK5mrJE4U,233
|
|
65
|
+
unifi_network_maps/io/paths.py,sha256=AgrF16EkFc63NNDNRzSnxwv1nIjq24zqNgKL7Y6VOVE,5930
|
|
65
66
|
unifi_network_maps/model/__init__.py,sha256=nzx1KsiYalL_YuXKE6acW8Dj5flmMg0-i4gyYO0gV54,22
|
|
66
67
|
unifi_network_maps/model/labels.py,sha256=m_k8mbzWtOSDOjjHhLUqwIw93pg98HAtGtHkiERXmek,1135
|
|
67
68
|
unifi_network_maps/model/lldp.py,sha256=SrPW5XC2lfJgaGeVx-KnSFNltyok7gIWWQNg1SkOaj4,3300
|
|
68
|
-
unifi_network_maps/model/mock.py,sha256=
|
|
69
|
+
unifi_network_maps/model/mock.py,sha256=kkzt7BWt5Q8dZ2wgfgK3HLmVdHHmOjAgS_snJsWawAo,9172
|
|
69
70
|
unifi_network_maps/model/ports.py,sha256=o3NBlXcC5VV5iPWJsO4Ll1mRKJZC0f8zTHdlkkE34GU,609
|
|
70
|
-
unifi_network_maps/model/topology.py,sha256=
|
|
71
|
+
unifi_network_maps/model/topology.py,sha256=rC1J1h70JIpkm6eIuFz7AjoNtKobPpVfAbiWgMyow2w,31475
|
|
72
|
+
unifi_network_maps/model/vlans.py,sha256=EeRKvIuUs0FgEzrqbo2xi8Xlm5n5V_Z6b7jpXLiNUaY,3788
|
|
71
73
|
unifi_network_maps/render/__init__.py,sha256=nzx1KsiYalL_YuXKE6acW8Dj5flmMg0-i4gyYO0gV54,22
|
|
72
|
-
unifi_network_maps/render/device_ports_md.py,sha256=
|
|
74
|
+
unifi_network_maps/render/device_ports_md.py,sha256=PLbP4_EDwLNkUACVJB_uM0ywaQTbEc6OWMv4QBcoJbU,16425
|
|
73
75
|
unifi_network_maps/render/legend.py,sha256=TmZsxgCVOM2CZImI9zgRVyzrcg01HZFDj9F715d4CGo,772
|
|
74
|
-
unifi_network_maps/render/lldp_md.py,sha256=
|
|
76
|
+
unifi_network_maps/render/lldp_md.py,sha256=oHPAYAfyCPJCuAJM4NJ4RIqgf52ihh555oNAlMB0DFg,14873
|
|
75
77
|
unifi_network_maps/render/markdown_tables.py,sha256=VvM0fSnSmpeeDPcD5pXaL_j_PTF0STrMCaqnr2BVHn4,547
|
|
76
78
|
unifi_network_maps/render/mermaid.py,sha256=xsC57Xg-nKhmlVATzEbwLkMM2BOeDYlBjZuxBIPhHeI,8324
|
|
77
79
|
unifi_network_maps/render/mermaid_theme.py,sha256=7nqLlvhaUA4z0YOs0ByEx_yHWcQD_hJJjhDtRcbSpg4,1781
|
|
@@ -79,7 +81,7 @@ unifi_network_maps/render/mkdocs.py,sha256=EOST9_eP1ZoZQax-p-2fjlelrl3AKEJ9Gn-KX
|
|
|
79
81
|
unifi_network_maps/render/svg.py,sha256=Zd6TFaBhqa92O2Z7G_k3LnRXIbuaOAj-dYu2qzEwWvI,41171
|
|
80
82
|
unifi_network_maps/render/svg_theme.py,sha256=Si1ArM3v_-wAvHZyLFPiOZ0ohQRd6ezIckwC3_b-WIw,2684
|
|
81
83
|
unifi_network_maps/render/templating.py,sha256=VJbXzZFBPjL8LFFPcLf_EU5Eu53GN9_vpten2Mf9A-k,576
|
|
82
|
-
unifi_network_maps/render/theme.py,sha256=
|
|
84
|
+
unifi_network_maps/render/theme.py,sha256=LI-2dWaR0cRapvYBqeudGCT3tnT8B-nt-5h0yxkU8vU,4379
|
|
83
85
|
unifi_network_maps/render/templates/device_port_block.md.j2,sha256=ZfyE_lEHz6ZyYRxAYhAhwpxlLczn_U9eTkRLUrwU5Io,50
|
|
84
86
|
unifi_network_maps/render/templates/legend_compact.html.j2,sha256=DaY2m6N6Yd7qNKLuB5Bc395MHfrKOU9myAZvgcJrhpE,738
|
|
85
87
|
unifi_network_maps/render/templates/lldp_device_section.md.j2,sha256=ewX-SBrL5Hn1kuwDKo4fqqetj9tTpR7LQavSn6No_yY,206
|
|
@@ -91,9 +93,9 @@ unifi_network_maps/render/templates/mkdocs_html_block.html.j2,sha256=5l5-BbNujOc
|
|
|
91
93
|
unifi_network_maps/render/templates/mkdocs_legend.css.j2,sha256=tkTI-RagBSgdjUygVenlTsQFenU09ePbXOfDt_Q7YRM,612
|
|
92
94
|
unifi_network_maps/render/templates/mkdocs_legend.js.j2,sha256=qMYyCKsJ84uXf1wGgzbc7Bc49RU4oyuaGK9KrgQDQEI,685
|
|
93
95
|
unifi_network_maps/render/templates/mkdocs_mermaid_block.md.j2,sha256=9IncllWQpoI8BN3A7b2zOQ5cksj97ddsjHJ-aBhpw7o,66
|
|
94
|
-
unifi_network_maps-1.4.
|
|
95
|
-
unifi_network_maps-1.4.
|
|
96
|
-
unifi_network_maps-1.4.
|
|
97
|
-
unifi_network_maps-1.4.
|
|
98
|
-
unifi_network_maps-1.4.
|
|
99
|
-
unifi_network_maps-1.4.
|
|
96
|
+
unifi_network_maps-1.4.14.dist-info/licenses/LICENSE,sha256=mYo1siIIfIwyfdOuK2-Zt0ij2xBTii2hnpeTu79nD80,1074
|
|
97
|
+
unifi_network_maps-1.4.14.dist-info/METADATA,sha256=-c54OG728Xu3nWYCNYG9XlFm4kmi_gbI7Hhh6DIzm8s,10079
|
|
98
|
+
unifi_network_maps-1.4.14.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
99
|
+
unifi_network_maps-1.4.14.dist-info/entry_points.txt,sha256=cdJ7jsBgNgHxSflYUOqgz5BbvuM0Nnh-x8_Hbyh_LFg,67
|
|
100
|
+
unifi_network_maps-1.4.14.dist-info/top_level.txt,sha256=G0rUX1PNfVCn1u-KtB6QjFQHopCOVLnPMczvPOoraHg,19
|
|
101
|
+
unifi_network_maps-1.4.14.dist-info/RECORD,,
|
{unifi_network_maps-1.4.12.dist-info → unifi_network_maps-1.4.14.dist-info}/entry_points.txt
RENAMED
|
File without changes
|
{unifi_network_maps-1.4.12.dist-info → unifi_network_maps-1.4.14.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|
|
File without changes
|