unifi-network-maps 1.4.11__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.
Files changed (99) hide show
  1. unifi_network_maps/__init__.py +1 -0
  2. unifi_network_maps/__main__.py +8 -0
  3. unifi_network_maps/adapters/__init__.py +1 -0
  4. unifi_network_maps/adapters/config.py +49 -0
  5. unifi_network_maps/adapters/unifi.py +457 -0
  6. unifi_network_maps/assets/__init__.py +0 -0
  7. unifi_network_maps/assets/icons/__init__.py +0 -0
  8. unifi_network_maps/assets/icons/access-point.svg +1 -0
  9. unifi_network_maps/assets/icons/isometric/ISOPACKS_LICENSE +7 -0
  10. unifi_network_maps/assets/icons/isometric/block.svg +23 -0
  11. unifi_network_maps/assets/icons/isometric/cache.svg +48 -0
  12. unifi_network_maps/assets/icons/isometric/cardterminal.svg +316 -0
  13. unifi_network_maps/assets/icons/isometric/cloud.svg +89 -0
  14. unifi_network_maps/assets/icons/isometric/cronjob.svg +409 -0
  15. unifi_network_maps/assets/icons/isometric/cube.svg +24 -0
  16. unifi_network_maps/assets/icons/isometric/desktop.svg +107 -0
  17. unifi_network_maps/assets/icons/isometric/diamond.svg +23 -0
  18. unifi_network_maps/assets/icons/isometric/dns.svg +46 -0
  19. unifi_network_maps/assets/icons/isometric/document.svg +62 -0
  20. unifi_network_maps/assets/icons/isometric/firewall.svg +200 -0
  21. unifi_network_maps/assets/icons/isometric/function-module.svg +215 -0
  22. unifi_network_maps/assets/icons/isometric/image.svg +65 -0
  23. unifi_network_maps/assets/icons/isometric/laptop.svg +37 -0
  24. unifi_network_maps/assets/icons/isometric/loadbalancer.svg +65 -0
  25. unifi_network_maps/assets/icons/isometric/lock.svg +155 -0
  26. unifi_network_maps/assets/icons/isometric/mail.svg +35 -0
  27. unifi_network_maps/assets/icons/isometric/mailmultiple.svg +91 -0
  28. unifi_network_maps/assets/icons/isometric/mobiledevice.svg +66 -0
  29. unifi_network_maps/assets/icons/isometric/office.svg +136 -0
  30. unifi_network_maps/assets/icons/isometric/package-module.svg +39 -0
  31. unifi_network_maps/assets/icons/isometric/paymentcard.svg +92 -0
  32. unifi_network_maps/assets/icons/isometric/plane.svg +1 -0
  33. unifi_network_maps/assets/icons/isometric/printer.svg +122 -0
  34. unifi_network_maps/assets/icons/isometric/pyramid.svg +28 -0
  35. unifi_network_maps/assets/icons/isometric/queue.svg +38 -0
  36. unifi_network_maps/assets/icons/isometric/router.svg +39 -0
  37. unifi_network_maps/assets/icons/isometric/server.svg +112 -0
  38. unifi_network_maps/assets/icons/isometric/speech.svg +70 -0
  39. unifi_network_maps/assets/icons/isometric/sphere.svg +15 -0
  40. unifi_network_maps/assets/icons/isometric/storage.svg +92 -0
  41. unifi_network_maps/assets/icons/isometric/switch-module.svg +45 -0
  42. unifi_network_maps/assets/icons/isometric/tower.svg +50 -0
  43. unifi_network_maps/assets/icons/isometric/truck-2.svg +1 -0
  44. unifi_network_maps/assets/icons/isometric/truck.svg +1 -0
  45. unifi_network_maps/assets/icons/isometric/user.svg +231 -0
  46. unifi_network_maps/assets/icons/isometric/vm.svg +50 -0
  47. unifi_network_maps/assets/icons/laptop.svg +1 -0
  48. unifi_network_maps/assets/icons/router-network.svg +1 -0
  49. unifi_network_maps/assets/icons/server-network.svg +1 -0
  50. unifi_network_maps/assets/icons/server.svg +1 -0
  51. unifi_network_maps/assets/themes/dark.yaml +50 -0
  52. unifi_network_maps/assets/themes/default.yaml +47 -0
  53. unifi_network_maps/cli/__init__.py +5 -0
  54. unifi_network_maps/cli/__main__.py +8 -0
  55. unifi_network_maps/cli/args.py +166 -0
  56. unifi_network_maps/cli/main.py +134 -0
  57. unifi_network_maps/cli/render.py +255 -0
  58. unifi_network_maps/cli/runtime.py +157 -0
  59. unifi_network_maps/io/__init__.py +1 -0
  60. unifi_network_maps/io/debug.py +60 -0
  61. unifi_network_maps/io/export.py +32 -0
  62. unifi_network_maps/io/mkdocs_assets.py +21 -0
  63. unifi_network_maps/io/mock_data.py +23 -0
  64. unifi_network_maps/io/mock_generate.py +7 -0
  65. unifi_network_maps/model/__init__.py +1 -0
  66. unifi_network_maps/model/labels.py +35 -0
  67. unifi_network_maps/model/lldp.py +99 -0
  68. unifi_network_maps/model/mock.py +307 -0
  69. unifi_network_maps/model/ports.py +23 -0
  70. unifi_network_maps/model/topology.py +909 -0
  71. unifi_network_maps/render/__init__.py +1 -0
  72. unifi_network_maps/render/device_ports_md.py +492 -0
  73. unifi_network_maps/render/legend.py +30 -0
  74. unifi_network_maps/render/lldp_md.py +352 -0
  75. unifi_network_maps/render/markdown_tables.py +21 -0
  76. unifi_network_maps/render/mermaid.py +273 -0
  77. unifi_network_maps/render/mermaid_theme.py +56 -0
  78. unifi_network_maps/render/mkdocs.py +167 -0
  79. unifi_network_maps/render/svg.py +1235 -0
  80. unifi_network_maps/render/svg_theme.py +64 -0
  81. unifi_network_maps/render/templates/device_port_block.md.j2 +5 -0
  82. unifi_network_maps/render/templates/legend_compact.html.j2 +14 -0
  83. unifi_network_maps/render/templates/lldp_device_section.md.j2 +15 -0
  84. unifi_network_maps/render/templates/markdown_section.md.j2 +3 -0
  85. unifi_network_maps/render/templates/mermaid_legend.mmd.j2 +30 -0
  86. unifi_network_maps/render/templates/mkdocs_document.md.j2 +23 -0
  87. unifi_network_maps/render/templates/mkdocs_dual_theme_style.html.j2 +8 -0
  88. unifi_network_maps/render/templates/mkdocs_html_block.html.j2 +2 -0
  89. unifi_network_maps/render/templates/mkdocs_legend.css.j2 +29 -0
  90. unifi_network_maps/render/templates/mkdocs_legend.js.j2 +18 -0
  91. unifi_network_maps/render/templates/mkdocs_mermaid_block.md.j2 +4 -0
  92. unifi_network_maps/render/templating.py +19 -0
  93. unifi_network_maps/render/theme.py +109 -0
  94. unifi_network_maps-1.4.11.dist-info/METADATA +290 -0
  95. unifi_network_maps-1.4.11.dist-info/RECORD +99 -0
  96. unifi_network_maps-1.4.11.dist-info/WHEEL +5 -0
  97. unifi_network_maps-1.4.11.dist-info/entry_points.txt +2 -0
  98. unifi_network_maps-1.4.11.dist-info/licenses/LICENSE +21 -0
  99. unifi_network_maps-1.4.11.dist-info/top_level.txt +1 -0
@@ -0,0 +1,157 @@
1
+ """Runtime data preparation for CLI rendering."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import logging
7
+ from pathlib import Path
8
+
9
+ from ..adapters.config import Config
10
+ from ..adapters.unifi import fetch_clients, fetch_devices
11
+ from ..io.debug import debug_dump_devices
12
+ from ..model.topology import (
13
+ ClientPortMap,
14
+ Device,
15
+ TopologyResult,
16
+ build_client_edges,
17
+ build_client_port_map,
18
+ build_device_index,
19
+ build_topology,
20
+ group_devices_by_type,
21
+ normalize_devices,
22
+ )
23
+ from ..render.mermaid_theme import MermaidTheme
24
+ from ..render.theme import load_theme
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ def load_devices_data(
30
+ args: argparse.Namespace,
31
+ config: Config | None,
32
+ site: str,
33
+ *,
34
+ raw_devices_override: list[object] | None = None,
35
+ ) -> tuple[list[object], list[Device]]:
36
+ if raw_devices_override is None:
37
+ if config is None:
38
+ raise ValueError("Config required to fetch devices")
39
+ raw_devices = list(
40
+ fetch_devices(config, site=site, detailed=True, use_cache=not args.no_cache)
41
+ )
42
+ else:
43
+ raw_devices = raw_devices_override
44
+ devices = normalize_devices(raw_devices)
45
+ if args.debug_dump:
46
+ debug_dump_devices(raw_devices, devices, sample_count=max(0, args.debug_sample))
47
+ return raw_devices, devices
48
+
49
+
50
+ def build_topology_data(
51
+ args: argparse.Namespace,
52
+ config: Config | None,
53
+ site: str,
54
+ *,
55
+ include_ports: bool | None = None,
56
+ raw_devices_override: list[object] | None = None,
57
+ ) -> tuple[list[Device], list[str], TopologyResult]:
58
+ _raw_devices, devices = load_devices_data(
59
+ args,
60
+ config,
61
+ site,
62
+ raw_devices_override=raw_devices_override,
63
+ )
64
+ groups_for_rank = group_devices_by_type(devices)
65
+ gateways = groups_for_rank.get("gateway", [])
66
+ topology = build_topology(
67
+ devices,
68
+ include_ports=include_ports if include_ports is not None else args.include_ports,
69
+ only_unifi=args.only_unifi,
70
+ gateways=gateways,
71
+ )
72
+ return devices, gateways, topology
73
+
74
+
75
+ def build_edges_with_clients(
76
+ args: argparse.Namespace,
77
+ edges: list,
78
+ devices: list[Device],
79
+ config: Config | None,
80
+ site: str,
81
+ *,
82
+ clients_override: list[object] | None = None,
83
+ ) -> tuple[list, list | None]:
84
+ clients = None
85
+ if args.include_clients:
86
+ if clients_override is None:
87
+ if config is None:
88
+ raise ValueError("Config required to fetch clients")
89
+ clients = list(fetch_clients(config, site=site, use_cache=not args.no_cache))
90
+ else:
91
+ clients = clients_override
92
+ device_index = build_device_index(devices)
93
+ edges = edges + build_client_edges(
94
+ clients,
95
+ device_index,
96
+ include_ports=args.include_ports,
97
+ client_mode=args.client_scope,
98
+ )
99
+ return edges, clients
100
+
101
+
102
+ def select_edges(topology: TopologyResult) -> tuple[list, bool]:
103
+ if topology.tree_edges:
104
+ return topology.tree_edges, True
105
+ logging.warning("No gateway found for hierarchy; rendering raw edges.")
106
+ return topology.raw_edges, False
107
+
108
+
109
+ def load_topology_for_render(
110
+ args: argparse.Namespace,
111
+ *,
112
+ config: Config | None,
113
+ site: str,
114
+ mock_devices: list[object] | None,
115
+ ) -> tuple[list[Device], TopologyResult] | None:
116
+ try:
117
+ include_ports = True if args.format == "mkdocs" else None
118
+ devices, _gateways, topology = build_topology_data(
119
+ args,
120
+ config,
121
+ site,
122
+ include_ports=include_ports,
123
+ raw_devices_override=mock_devices,
124
+ )
125
+ except Exception as exc:
126
+ logging.error("Failed to build topology: %s", exc)
127
+ return None
128
+ return devices, topology
129
+
130
+
131
+ def load_dark_mermaid_theme() -> MermaidTheme | None:
132
+ dark_theme_path = Path(__file__).resolve().parents[1] / "assets" / "themes" / "dark.yaml"
133
+ try:
134
+ dark_theme, _ = load_theme(dark_theme_path)
135
+ except Exception as exc: # noqa: BLE001
136
+ logger.warning("Failed to load dark theme: %s", exc)
137
+ return None
138
+ return dark_theme
139
+
140
+
141
+ def resolve_mkdocs_client_ports(
142
+ args: argparse.Namespace,
143
+ devices: list[Device],
144
+ config: Config | None,
145
+ site: str,
146
+ mock_clients: list[object] | None,
147
+ ) -> tuple[ClientPortMap | None, int | None]:
148
+ if not args.include_clients:
149
+ return None, None
150
+ if mock_clients is None:
151
+ if config is None:
152
+ return None, 2
153
+ clients = list(fetch_clients(config, site=site))
154
+ else:
155
+ clients = mock_clients
156
+ client_ports = build_client_port_map(devices, clients, client_mode=args.client_scope)
157
+ return client_ports, None
@@ -0,0 +1 @@
1
+ """Package module."""
@@ -0,0 +1,60 @@
1
+ """Debug helpers for dumping device data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ from collections.abc import Iterable, Sequence
8
+
9
+ from ..model.topology import Device, group_devices_by_type
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ def device_to_dict(device: object) -> dict[str, object]:
15
+ to_dict = getattr(device, "to_dict", None)
16
+ if callable(to_dict):
17
+ result = to_dict()
18
+ if isinstance(result, dict):
19
+ return result
20
+ return {"repr": repr(result)}
21
+ if hasattr(device, "__dict__"):
22
+ return dict(device.__dict__)
23
+ if isinstance(device, dict):
24
+ return dict(device)
25
+ return {"repr": repr(device)}
26
+
27
+
28
+ def debug_dump_devices(
29
+ raw_devices: Sequence[object],
30
+ normalized: Iterable[Device],
31
+ *,
32
+ sample_count: int,
33
+ ) -> None:
34
+ name_to_device: dict[str, object] = {}
35
+ for device in raw_devices:
36
+ name = getattr(device, "name", None)
37
+ if name:
38
+ name_to_device[name] = device
39
+
40
+ groups = group_devices_by_type(normalized)
41
+ gateways = groups.get("gateway", [])
42
+ samples: list[str] = []
43
+ for group in ("switch", "ap", "other"):
44
+ for name in groups.get(group, []):
45
+ if name not in gateways:
46
+ samples.append(name)
47
+ if len(samples) >= sample_count:
48
+ break
49
+ if len(samples) >= sample_count:
50
+ break
51
+
52
+ selected_names = gateways[:1] + samples
53
+ payload = []
54
+ for name in selected_names:
55
+ device = name_to_device.get(name)
56
+ if device is None:
57
+ continue
58
+ payload.append({"name": name, "data": device_to_dict(device)})
59
+
60
+ logger.info("Debug dump devices: %s", json.dumps(payload, indent=2, sort_keys=True))
@@ -0,0 +1,32 @@
1
+ """Output helpers for files and stdout."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+ import tempfile
8
+ from pathlib import Path
9
+
10
+
11
+ def write_output(content: str, *, output_path: str | None, stdout: bool) -> None:
12
+ if output_path:
13
+ _write_atomic(Path(output_path), content)
14
+ if stdout or not output_path:
15
+ sys.stdout.write(content)
16
+
17
+
18
+ def _write_atomic(path: Path, content: str) -> None:
19
+ path.parent.mkdir(parents=True, exist_ok=True)
20
+ with tempfile.NamedTemporaryFile(
21
+ mode="w",
22
+ encoding="utf-8",
23
+ dir=str(path.parent),
24
+ prefix=f".{path.name}.",
25
+ suffix=".tmp",
26
+ delete=False,
27
+ ) as temp_file:
28
+ temp_file.write(content)
29
+ temp_file.flush()
30
+ os.fsync(temp_file.fileno())
31
+ tmp_path = Path(temp_file.name)
32
+ tmp_path.replace(path)
@@ -0,0 +1,21 @@
1
+ """MkDocs asset output helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from ..render.templating import render_template
8
+
9
+
10
+ def write_mkdocs_sidebar_assets(output_path: str) -> None:
11
+ output_dir = Path(output_path).resolve().parent
12
+ assets_dir = output_dir / "assets"
13
+ assets_dir.mkdir(parents=True, exist_ok=True)
14
+ (assets_dir / "legend.js").write_text(
15
+ render_template("mkdocs_legend.js.j2"),
16
+ encoding="utf-8",
17
+ )
18
+ (assets_dir / "legend.css").write_text(
19
+ render_template("mkdocs_legend.css.j2"),
20
+ encoding="utf-8",
21
+ )
@@ -0,0 +1,23 @@
1
+ """Load mock UniFi data from JSON fixtures."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+
9
+ def _as_list(value: object, name: str) -> list[object]:
10
+ if value is None:
11
+ return []
12
+ if isinstance(value, list):
13
+ return value
14
+ raise ValueError(f"Mock data field '{name}' must be a list")
15
+
16
+
17
+ def load_mock_data(path: str) -> tuple[list[object], list[object]]:
18
+ payload = json.loads(Path(path).read_text(encoding="utf-8"))
19
+ if not isinstance(payload, dict):
20
+ raise ValueError("Mock data must be a JSON object")
21
+ devices = _as_list(payload.get("devices"), "devices")
22
+ clients = _as_list(payload.get("clients"), "clients")
23
+ return devices, clients
@@ -0,0 +1,7 @@
1
+ """Generate mock UniFi data using Faker."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..model.mock import MockOptions, generate_mock_payload, mock_payload_json
6
+
7
+ __all__ = ["MockOptions", "generate_mock_payload", "mock_payload_json"]
@@ -0,0 +1 @@
1
+ """Package module."""
@@ -0,0 +1,35 @@
1
+ """Edge label helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+
7
+
8
+ def compose_port_label(left: str, right: str, port_map: dict[tuple[str, str], str]) -> str | None:
9
+ left_label = port_map.get((left, right))
10
+ right_label = port_map.get((right, left))
11
+ if left_label and right_label:
12
+ return f"{left}: {left_label} <-> {right}: {right_label}"
13
+ if left_label:
14
+ return f"{left}: {left_label} <-> {right}: ?"
15
+ if right_label:
16
+ return f"{left}: ? <-> {right}: {right_label}"
17
+ return None
18
+
19
+
20
+ def order_edge_names(
21
+ left: str,
22
+ right: str,
23
+ port_map: dict[tuple[str, str], str],
24
+ rank_for_name: Callable[[str], int],
25
+ ) -> tuple[str, str]:
26
+ left_label = port_map.get((left, right))
27
+ right_label = port_map.get((right, left))
28
+ if left_label is None and right_label is not None:
29
+ return (right, left)
30
+ if left_label and right_label:
31
+ left_rank = rank_for_name(left)
32
+ right_rank = rank_for_name(right)
33
+ if (left_rank, left.lower()) > (right_rank, right.lower()):
34
+ return (right, left)
35
+ return (left, right)
@@ -0,0 +1,99 @@
1
+ """LLDP parsing and port label helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from .ports import extract_port_number, normalize_port_label
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class LLDPEntry:
12
+ chassis_id: str
13
+ port_id: str
14
+ port_desc: str | None = None
15
+ local_port_name: str | None = None
16
+ local_port_idx: int | None = None
17
+
18
+
19
+ def coerce_lldp(entry: object) -> LLDPEntry:
20
+ if isinstance(entry, dict):
21
+ chassis_id = entry.get("chassis_id") or entry.get("chassisId")
22
+ port_id = entry.get("port_id") or entry.get("portId")
23
+ port_desc = (
24
+ entry.get("port_desc")
25
+ or entry.get("portDesc")
26
+ or entry.get("port_descr")
27
+ or entry.get("portDescr")
28
+ )
29
+ local_port_name = entry.get("local_port_name") or entry.get("localPortName")
30
+ local_port_idx = entry.get("local_port_idx") or entry.get("localPortIdx")
31
+ else:
32
+ chassis_id = getattr(entry, "chassis_id", None) or getattr(entry, "chassisId", None)
33
+ port_id = getattr(entry, "port_id", None) or getattr(entry, "portId", None)
34
+ port_desc = (
35
+ getattr(entry, "port_desc", None)
36
+ or getattr(entry, "portDesc", None)
37
+ or getattr(entry, "port_descr", None)
38
+ or getattr(entry, "portDescr", None)
39
+ )
40
+ local_port_name = getattr(entry, "local_port_name", None) or getattr(
41
+ entry, "localPortName", None
42
+ )
43
+ local_port_idx = getattr(entry, "local_port_idx", None) or getattr(
44
+ entry, "localPortIdx", None
45
+ )
46
+ if not chassis_id or not port_id:
47
+ raise ValueError("LLDP entry missing chassis_id or port_id")
48
+ return LLDPEntry(
49
+ chassis_id=str(chassis_id),
50
+ port_id=str(port_id),
51
+ port_desc=str(port_desc) if port_desc else None,
52
+ local_port_name=str(local_port_name) if local_port_name else None,
53
+ local_port_idx=int(local_port_idx) if local_port_idx is not None else None,
54
+ )
55
+
56
+
57
+ def _looks_like_mac(value: str | None) -> bool:
58
+ if not value:
59
+ return False
60
+ cleaned = value.strip().lower()
61
+ if cleaned.count(":") == 5:
62
+ return all(
63
+ len(part) == 2 and all(ch in "0123456789abcdef" for ch in part)
64
+ for part in cleaned.split(":")
65
+ )
66
+ return False
67
+
68
+
69
+ def _port_label_parts(entry: LLDPEntry) -> tuple[int | None, str | None, str | None]:
70
+ number = entry.local_port_idx
71
+ name = normalize_port_label(entry.local_port_name) if entry.local_port_name else None
72
+ desc = (
73
+ entry.port_desc.strip()
74
+ if entry.port_desc and not _looks_like_mac(entry.port_desc)
75
+ else None
76
+ )
77
+
78
+ if entry.port_id and not _looks_like_mac(entry.port_id) and name is None:
79
+ name = normalize_port_label(entry.port_id)
80
+
81
+ if number is None:
82
+ number = extract_port_number(name)
83
+ if number is None:
84
+ number = extract_port_number(desc)
85
+
86
+ return number, name, desc
87
+
88
+
89
+ def local_port_label(entry: LLDPEntry) -> str | None:
90
+ number, name, desc = _port_label_parts(entry)
91
+ if number is not None and desc:
92
+ return f"Port {number} ({desc})"
93
+ if number is not None:
94
+ return f"Port {number}"
95
+ if name:
96
+ return name
97
+ if desc:
98
+ return desc
99
+ return None