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.
@@ -1 +1 @@
1
- __version__ = "1.4.12"
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
- load_dotenv(dotenv_path=env_file)
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
- return Path(os.environ.get("UNIFI_CACHE_DIR", ".cache/unifi_network_maps"))
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.info("Using cached devices (%d)", len(cached))
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.info("UDM Pro authentication failed, retrying legacy auth")
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.info("Fetched %d devices", len(devices))
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.info("Using cached clients (%d)", len(cached))
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.info("UDM Pro authentication failed, retrying legacy auth")
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.info("Fetched %d clients", len(clients))
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))
@@ -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(
@@ -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
- write_output(content, output_path=args.generate_mock, stdout=args.stdout)
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
- write_output(content, output_path=args.output, stdout=args.stdout)
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":
@@ -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
- write_output(content, output_path=args.output, stdout=args.stdout)
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
- write_output(content, output_path=args.output, stdout=args.stdout)
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
@@ -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
- def write_output(content: str, *, output_path: str | None, stdout: bool) -> None:
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
- _write_atomic(Path(output_path), content)
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
- output_dir = Path(output_path).resolve().parent
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
- from pathlib import Path
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
- payload = json.loads(Path(path).read_text(encoding="utf-8"))
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
@@ -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
- return {"devices": devices, "clients": clients}
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.info("Built %d unique edges (%d PoE)", len(edges), poe_edges)
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.info(
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.info(
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
- _escape_cell(port_label),
87
- _escape_cell(connected or "-"),
88
- _escape_cell(speed),
89
- _escape_cell(poe_state),
90
- _escape_cell(power),
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(f"{peer} ({peer_label})")
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 _escape_cell(value: str) -> str:
296
- return value.replace("|", "\\|")
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 | {_escape_cell(_device_model_label(device))} |",
306
- f"| Type | {_escape_cell(device.type or '-')} |",
307
- f"| IP | {_escape_cell(device.ip or '-')} |",
308
- f"| MAC | {_escape_cell(device.mac or '-')} |",
309
- f"| Firmware | {_escape_cell(device.version or '-')} |",
310
- f"| Uplink | {_escape_cell(_uplink_summary(device))} |",
311
- f"| Ports | {_escape_cell(_port_summary(device))} |",
312
- f"| PoE | {_escape_cell(_poe_summary(device))} |",
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
- return value.replace("|", "\\|")
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 = Path(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.12
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.13; extra == "dev"
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=nGraM_ujqcNJ-Dyz-qi99_8ynLwzQU2N68yOSnVykOs,23
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=Bx9JDZxxY7Gjxyb8FDT0dxiKfgXt_TmzTDbgvpwB53s,1548
5
- unifi_network_maps/adapters/unifi.py,sha256=olrs-K7EWK7Uhib8pjtPnkZg3LhnnSz-lcGuHG8RsOI,15140
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=lIgQDeob_SIhjXg76hJsnpgNOKupSjSYum_MqarWOkE,5552
56
- unifi_network_maps/cli/main.py,sha256=jQXesuHJLTQl4lBk1DD6em67Wj9oEjBmH9X-X1zA6MI,4150
57
- unifi_network_maps/cli/render.py,sha256=hT4RS3Y5F2TVDe85xpaOckgz9b5c8igY5Q77Uoh8nmk,7357
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=2vURunQDja-_fKKMb-gQG-n34aeM8GFprNJqcGaP4qg,843
62
- unifi_network_maps/io/mkdocs_assets.py,sha256=oueDLiiIW05mN-mZ2BWV7oECX-EXDjJt8msCJHMcQwg,596
63
- unifi_network_maps/io/mock_data.py,sha256=uiQ8Ta8oxs5D1MUA1ONWmp09sxH0-FNlz3SJBXMeezo,714
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=kd1MSiIn-7KKu_nMVmheYPfTyAN5DHt4dzRrBifF_lI,8706
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=l26yHsRGuD-D1x4-qsaMBRTeA8SBU_xhRCi9ZwyG7w8,31472
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=vt5kGFSIAabMbcSxIuVVLSzdb_i58NHGi4hJM2ZLZR4,15975
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=wHMMoxcRicF3NirNh0L62PQFNQYOp6XFxNOTwFm7x0Y,14738
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=vKYdPhcGEOV1o_irwqzJlIXPgRvZqQEzYYV2_TxZn4E,4301
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.12.dist-info/licenses/LICENSE,sha256=mYo1siIIfIwyfdOuK2-Zt0ij2xBTii2hnpeTu79nD80,1074
95
- unifi_network_maps-1.4.12.dist-info/METADATA,sha256=ob7P4a7jb8JTC_OWLX4Ej0jPf8SrYNXWyhCCodGmyRM,9952
96
- unifi_network_maps-1.4.12.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
97
- unifi_network_maps-1.4.12.dist-info/entry_points.txt,sha256=cdJ7jsBgNgHxSflYUOqgz5BbvuM0Nnh-x8_Hbyh_LFg,67
98
- unifi_network_maps-1.4.12.dist-info/top_level.txt,sha256=G0rUX1PNfVCn1u-KtB6QjFQHopCOVLnPMczvPOoraHg,19
99
- unifi_network_maps-1.4.12.dist-info/RECORD,,
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,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.9.0)
2
+ Generator: setuptools (80.10.2)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5