unifi-network-maps 1.2.4__py3-none-any.whl → 1.3.0__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 (82) hide show
  1. unifi_network_maps/__init__.py +1 -0
  2. unifi_network_maps/adapters/__init__.py +1 -0
  3. {unifi_mermaid → unifi_network_maps/adapters}/config.py +7 -1
  4. {unifi_mermaid → unifi_network_maps/adapters}/unifi.py +1 -1
  5. unifi_network_maps/assets/themes/dark.yaml +47 -0
  6. unifi_network_maps/assets/themes/default.yaml +47 -0
  7. unifi_network_maps/cli/__init__.py +41 -0
  8. unifi_network_maps/cli/__main__.py +8 -0
  9. unifi_network_maps/cli/main.py +281 -0
  10. unifi_network_maps/io/__init__.py +1 -0
  11. {unifi_mermaid → unifi_network_maps/io}/debug.py +1 -1
  12. unifi_network_maps/model/__init__.py +1 -0
  13. unifi_network_maps/model/labels.py +35 -0
  14. {unifi_mermaid → unifi_network_maps/model}/lldp.py +19 -33
  15. unifi_network_maps/model/ports.py +23 -0
  16. {unifi_mermaid → unifi_network_maps/model}/topology.py +216 -89
  17. unifi_network_maps/render/__init__.py +1 -0
  18. {unifi_mermaid → unifi_network_maps/render}/mermaid.py +21 -16
  19. unifi_network_maps/render/mermaid_theme.py +46 -0
  20. {unifi_mermaid → unifi_network_maps/render}/svg.py +208 -175
  21. unifi_network_maps/render/svg_theme.py +64 -0
  22. unifi_network_maps/render/theme.py +90 -0
  23. {unifi_network_maps-1.2.4.dist-info → unifi_network_maps-1.3.0.dist-info}/METADATA +63 -8
  24. unifi_network_maps-1.3.0.dist-info/RECORD +75 -0
  25. unifi_network_maps-1.3.0.dist-info/entry_points.txt +2 -0
  26. unifi_network_maps-1.3.0.dist-info/licenses/LICENSES.md +10 -0
  27. unifi_network_maps-1.3.0.dist-info/top_level.txt +1 -0
  28. unifi_mermaid/__init__.py +0 -1
  29. unifi_mermaid/cli.py +0 -197
  30. unifi_mermaid/labels.py +0 -15
  31. unifi_network_maps-1.2.4.dist-info/RECORD +0 -63
  32. unifi_network_maps-1.2.4.dist-info/entry_points.txt +0 -2
  33. unifi_network_maps-1.2.4.dist-info/licenses/LICENSES.md +0 -10
  34. unifi_network_maps-1.2.4.dist-info/top_level.txt +0 -1
  35. {unifi_mermaid → unifi_network_maps}/assets/__init__.py +0 -0
  36. {unifi_mermaid → unifi_network_maps}/assets/icons/__init__.py +0 -0
  37. {unifi_mermaid → unifi_network_maps}/assets/icons/access-point.svg +0 -0
  38. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/ISOPACKS_LICENSE +0 -0
  39. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/block.svg +0 -0
  40. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/cache.svg +0 -0
  41. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/cardterminal.svg +0 -0
  42. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/cloud.svg +0 -0
  43. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/cronjob.svg +0 -0
  44. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/cube.svg +0 -0
  45. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/desktop.svg +0 -0
  46. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/diamond.svg +0 -0
  47. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/dns.svg +0 -0
  48. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/document.svg +0 -0
  49. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/firewall.svg +0 -0
  50. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/function-module.svg +0 -0
  51. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/image.svg +0 -0
  52. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/laptop.svg +0 -0
  53. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/loadbalancer.svg +0 -0
  54. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/lock.svg +0 -0
  55. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/mail.svg +0 -0
  56. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/mailmultiple.svg +0 -0
  57. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/mobiledevice.svg +0 -0
  58. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/office.svg +0 -0
  59. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/package-module.svg +0 -0
  60. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/paymentcard.svg +0 -0
  61. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/plane.svg +0 -0
  62. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/printer.svg +0 -0
  63. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/pyramid.svg +0 -0
  64. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/queue.svg +0 -0
  65. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/router.svg +0 -0
  66. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/server.svg +0 -0
  67. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/speech.svg +0 -0
  68. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/sphere.svg +0 -0
  69. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/storage.svg +0 -0
  70. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/switch-module.svg +0 -0
  71. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/tower.svg +0 -0
  72. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/truck-2.svg +0 -0
  73. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/truck.svg +0 -0
  74. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/user.svg +0 -0
  75. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/vm.svg +0 -0
  76. {unifi_mermaid → unifi_network_maps}/assets/icons/laptop.svg +0 -0
  77. {unifi_mermaid → unifi_network_maps}/assets/icons/router-network.svg +0 -0
  78. {unifi_mermaid → unifi_network_maps}/assets/icons/server-network.svg +0 -0
  79. {unifi_mermaid → unifi_network_maps}/assets/icons/server.svg +0 -0
  80. {unifi_mermaid → unifi_network_maps/io}/export.py +0 -0
  81. {unifi_network_maps-1.2.4.dist-info → unifi_network_maps-1.3.0.dist-info}/WHEEL +0 -0
  82. {unifi_network_maps-1.2.4.dist-info → unifi_network_maps-1.3.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1 @@
1
+ __version__ = "1.3.0"
@@ -0,0 +1 @@
1
+ """Package module."""
@@ -26,7 +26,13 @@ class Config:
26
26
  verify_ssl: bool
27
27
 
28
28
  @classmethod
29
- def from_env(cls) -> Config:
29
+ def from_env(cls, *, env_file: str | None = None) -> Config:
30
+ if env_file:
31
+ try:
32
+ from dotenv import load_dotenv
33
+ except ImportError:
34
+ raise ValueError("python-dotenv required for --env-file") from None
35
+ load_dotenv(dotenv_path=env_file)
30
36
  url = os.environ.get("UNIFI_URL", "").strip()
31
37
  site = os.environ.get("UNIFI_SITE", "default").strip()
32
38
  user = os.environ.get("UNIFI_USER", "").strip()
@@ -20,7 +20,7 @@ logger = logging.getLogger(__name__)
20
20
 
21
21
 
22
22
  def _cache_dir() -> Path:
23
- return Path(os.environ.get("UNIFI_CACHE_DIR", ".cache/unifi_mermaid"))
23
+ return Path(os.environ.get("UNIFI_CACHE_DIR", ".cache/unifi_network_maps"))
24
24
 
25
25
 
26
26
  def _cache_ttl_seconds() -> int:
@@ -0,0 +1,47 @@
1
+ mermaid:
2
+ nodes:
3
+ gateway:
4
+ fill: "#5c3b00"
5
+ stroke: "#ffb347"
6
+ switch:
7
+ fill: "#0f2b3d"
8
+ stroke: "#5dade2"
9
+ ap:
10
+ fill: "#0f3b2d"
11
+ stroke: "#2ecc71"
12
+ client:
13
+ fill: "#2b1f3b"
14
+ stroke: "#b084ff"
15
+ other:
16
+ fill: "#2a2a2a"
17
+ stroke: "#9e9e9e"
18
+ poe_link: "#64b5f6"
19
+ poe_link_width: 2
20
+ poe_link_arrow: "none"
21
+ standard_link: "#66bb6a"
22
+ standard_link_width: 2
23
+ standard_link_arrow: "none"
24
+ svg:
25
+ links:
26
+ standard:
27
+ from: "#66bb6a"
28
+ to: "#2e7d32"
29
+ poe:
30
+ from: "#64b5f6"
31
+ to: "#1e88e5"
32
+ nodes:
33
+ gateway:
34
+ from: "#5c3b00"
35
+ to: "#3e2a00"
36
+ switch:
37
+ from: "#0f2b3d"
38
+ to: "#0a1f2c"
39
+ ap:
40
+ from: "#0f3b2d"
41
+ to: "#0b2a20"
42
+ client:
43
+ from: "#2b1f3b"
44
+ to: "#20162b"
45
+ other:
46
+ from: "#2a2a2a"
47
+ to: "#1f1f1f"
@@ -0,0 +1,47 @@
1
+ mermaid:
2
+ nodes:
3
+ gateway:
4
+ fill: "#ffe3b3"
5
+ stroke: "#d98300"
6
+ switch:
7
+ fill: "#d6ecff"
8
+ stroke: "#3a7bd5"
9
+ ap:
10
+ fill: "#d7f5e7"
11
+ stroke: "#27ae60"
12
+ client:
13
+ fill: "#f2e5ff"
14
+ stroke: "#7f3fbf"
15
+ other:
16
+ fill: "#eeeeee"
17
+ stroke: "#8f8f8f"
18
+ poe_link: "#1e88e5"
19
+ poe_link_width: 2
20
+ poe_link_arrow: "none"
21
+ standard_link: "#2ecc71"
22
+ standard_link_width: 2
23
+ standard_link_arrow: "none"
24
+ svg:
25
+ links:
26
+ standard:
27
+ from: "#2ecc71"
28
+ to: "#1b8f4a"
29
+ poe:
30
+ from: "#1e88e5"
31
+ to: "#0d47a1"
32
+ nodes:
33
+ gateway:
34
+ from: "#ffe3b3"
35
+ to: "#f7c77b"
36
+ switch:
37
+ from: "#d6ecff"
38
+ to: "#b6dcff"
39
+ ap:
40
+ from: "#d7f5e7"
41
+ to: "#b8f0d2"
42
+ client:
43
+ from: "#f2e5ff"
44
+ to: "#e0c9ff"
45
+ other:
46
+ from: "#eeeeee"
47
+ to: "#d7d7d7"
@@ -0,0 +1,41 @@
1
+ """CLI package facade."""
2
+
3
+ from .main import (
4
+ Config,
5
+ SvgOptions,
6
+ build_client_edges,
7
+ build_device_index,
8
+ build_node_type_map,
9
+ build_topology,
10
+ debug_dump_devices,
11
+ fetch_clients,
12
+ fetch_devices,
13
+ group_devices_by_type,
14
+ main,
15
+ normalize_devices,
16
+ render_legend,
17
+ render_mermaid,
18
+ render_svg,
19
+ resolve_themes,
20
+ write_output,
21
+ )
22
+
23
+ __all__ = [
24
+ "Config",
25
+ "SvgOptions",
26
+ "build_client_edges",
27
+ "build_device_index",
28
+ "build_node_type_map",
29
+ "build_topology",
30
+ "debug_dump_devices",
31
+ "fetch_clients",
32
+ "fetch_devices",
33
+ "group_devices_by_type",
34
+ "main",
35
+ "normalize_devices",
36
+ "render_legend",
37
+ "render_mermaid",
38
+ "render_svg",
39
+ "resolve_themes",
40
+ "write_output",
41
+ ]
@@ -0,0 +1,8 @@
1
+ """CLI module entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .main import main
6
+
7
+ if __name__ == "__main__":
8
+ raise SystemExit(main())
@@ -0,0 +1,281 @@
1
+ """CLI entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import logging
7
+
8
+ from ..adapters.config import Config
9
+ from ..adapters.unifi import fetch_clients, fetch_devices
10
+ from ..io.debug import debug_dump_devices
11
+ from ..io.export import write_output
12
+ from ..model.topology import (
13
+ Device,
14
+ build_client_edges,
15
+ build_device_index,
16
+ build_node_type_map,
17
+ build_topology,
18
+ group_devices_by_type,
19
+ normalize_devices,
20
+ )
21
+ from ..render.mermaid import render_legend, render_mermaid
22
+ from ..render.mermaid_theme import MermaidTheme
23
+ from ..render.svg import SvgOptions, render_svg
24
+ from ..render.svg_theme import SvgTheme
25
+ from ..render.theme import resolve_themes
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ def _load_dotenv(env_file: str | None = None) -> None:
31
+ try:
32
+ from dotenv import load_dotenv
33
+ except ImportError:
34
+ logger.info("python-dotenv not installed; skipping .env loading")
35
+ return
36
+ load_dotenv(dotenv_path=env_file) if env_file else load_dotenv()
37
+
38
+
39
+ def _build_parser() -> argparse.ArgumentParser:
40
+ parser = argparse.ArgumentParser(
41
+ description="Generate network maps from UniFi LLDP data, as mermaid or SVG"
42
+ )
43
+ _add_source_args(parser.add_argument_group("Source"))
44
+ _add_functional_args(parser.add_argument_group("Functional"))
45
+ _add_mermaid_args(parser.add_argument_group("Mermaid"))
46
+ _add_svg_args(parser.add_argument_group("SVG"))
47
+ _add_general_render_args(parser.add_argument_group("Output"))
48
+ _add_debug_args(parser.add_argument_group("Debug"))
49
+ return parser
50
+
51
+
52
+ def _add_source_args(parser: argparse._ArgumentGroup) -> None:
53
+ parser.add_argument("--site", default=None, help="UniFi site name (overrides UNIFI_SITE)")
54
+ parser.add_argument(
55
+ "--env-file",
56
+ default=None,
57
+ help="Path to .env file (overrides default .env discovery)",
58
+ )
59
+
60
+
61
+ def _add_functional_args(parser: argparse._ArgumentGroup) -> None:
62
+ parser.add_argument("--include-ports", action="store_true", help="Include port labels in edges")
63
+ parser.add_argument(
64
+ "--include-clients",
65
+ action="store_true",
66
+ help="Include active clients as leaf nodes",
67
+ )
68
+ parser.add_argument(
69
+ "--only-unifi", action="store_true", help="Only include neighbors that are UniFi devices"
70
+ )
71
+
72
+
73
+ def _add_mermaid_args(parser: argparse._ArgumentGroup) -> None:
74
+ parser.add_argument("--direction", default="TB", choices=["LR", "TB"], help="Mermaid direction")
75
+ parser.add_argument(
76
+ "--group-by-type",
77
+ action="store_true",
78
+ help="Group nodes by gateway/switch/ap in Mermaid subgraphs",
79
+ )
80
+ parser.add_argument(
81
+ "--legend-only",
82
+ action="store_true",
83
+ help="Render only the legend as a separate Mermaid graph",
84
+ )
85
+
86
+
87
+ def _add_svg_args(parser: argparse._ArgumentGroup) -> None:
88
+ parser.add_argument("--svg-width", type=int, default=None, help="SVG width override")
89
+ parser.add_argument("--svg-height", type=int, default=None, help="SVG height override")
90
+ parser.add_argument("--theme-file", default=None, help="Path to theme YAML file")
91
+
92
+
93
+ def _add_general_render_args(parser: argparse._ArgumentGroup) -> None:
94
+ parser.add_argument(
95
+ "--format",
96
+ default="mermaid",
97
+ choices=["mermaid", "svg", "svg-iso"],
98
+ help="Output format",
99
+ )
100
+ parser.add_argument(
101
+ "--markdown",
102
+ action="store_true",
103
+ help="Wrap output in a Markdown mermaid code fence for notes tools like Obsidian",
104
+ )
105
+ parser.add_argument("--output", default=None, help="Output file path")
106
+ parser.add_argument("--stdout", action="store_true", help="Write output to stdout")
107
+
108
+
109
+ def _add_debug_args(parser: argparse._ArgumentGroup) -> None:
110
+ parser.add_argument(
111
+ "--debug-dump",
112
+ action="store_true",
113
+ help="Dump gateway and sample device data to stderr for debugging",
114
+ )
115
+ parser.add_argument(
116
+ "--debug-sample",
117
+ type=int,
118
+ default=2,
119
+ help="Number of non-gateway devices to include in debug dump (default: 2)",
120
+ )
121
+
122
+
123
+ def _parse_args(argv: list[str] | None) -> argparse.Namespace:
124
+ parser = _build_parser()
125
+ return parser.parse_args(argv)
126
+
127
+
128
+ def _load_config(args: argparse.Namespace) -> Config | None:
129
+ try:
130
+ _load_dotenv(args.env_file)
131
+ return Config.from_env(env_file=args.env_file)
132
+ except ValueError as exc:
133
+ logging.error(str(exc))
134
+ return None
135
+
136
+
137
+ def _resolve_site(args: argparse.Namespace, config: Config) -> str:
138
+ return args.site or config.site
139
+
140
+
141
+ def _render_legend_only(args: argparse.Namespace, mermaid_theme: MermaidTheme) -> str:
142
+ content = render_legend(theme=mermaid_theme)
143
+ if args.markdown:
144
+ content = f"""```mermaid
145
+ {content}```
146
+ """
147
+ return content
148
+
149
+
150
+ def _build_topology_data(
151
+ args: argparse.Namespace, config: Config, site: str
152
+ ) -> tuple[list[Device], list[str], object]:
153
+ raw_devices = list(fetch_devices(config, site=site, detailed=True))
154
+ devices = normalize_devices(raw_devices)
155
+ if args.debug_dump:
156
+ debug_dump_devices(raw_devices, devices, sample_count=max(0, args.debug_sample))
157
+ groups_for_rank = group_devices_by_type(devices)
158
+ gateways = groups_for_rank.get("gateway", [])
159
+ topology = build_topology(
160
+ devices,
161
+ include_ports=args.include_ports,
162
+ only_unifi=args.only_unifi,
163
+ gateways=gateways,
164
+ )
165
+ return devices, gateways, topology
166
+
167
+
168
+ def _build_edges_with_clients(
169
+ args: argparse.Namespace,
170
+ edges: list,
171
+ devices: list[Device],
172
+ config: Config,
173
+ site: str,
174
+ ) -> tuple[list, list | None]:
175
+ clients = None
176
+ if args.include_clients:
177
+ clients = list(fetch_clients(config, site=site))
178
+ device_index = build_device_index(devices)
179
+ edges = edges + build_client_edges(clients, device_index, include_ports=args.include_ports)
180
+ return edges, clients
181
+
182
+
183
+ def _select_edges(topology: object) -> tuple[list, bool]:
184
+ if topology.tree_edges:
185
+ return topology.tree_edges, True
186
+ logging.warning("No gateway found for hierarchy; rendering raw edges.")
187
+ return topology.raw_edges, False
188
+
189
+
190
+ def _render_mermaid_output(
191
+ args: argparse.Namespace,
192
+ devices: list[Device],
193
+ topology: object,
194
+ config: Config,
195
+ site: str,
196
+ mermaid_theme: MermaidTheme,
197
+ ) -> str:
198
+ edges, _has_tree = _select_edges(topology)
199
+ edges, clients = _build_edges_with_clients(args, edges, devices, config, site)
200
+ groups = None
201
+ group_order = None
202
+ if args.group_by_type:
203
+ groups = group_devices_by_type(devices)
204
+ group_order = ["gateway", "switch", "ap", "other"]
205
+ content = render_mermaid(
206
+ edges,
207
+ direction=args.direction,
208
+ groups=groups,
209
+ group_order=group_order,
210
+ node_types=build_node_type_map(devices, clients),
211
+ theme=mermaid_theme,
212
+ )
213
+ if args.markdown:
214
+ content = f"""```mermaid
215
+ {content}```
216
+ """
217
+ return content
218
+
219
+
220
+ def _render_svg_output(
221
+ args: argparse.Namespace,
222
+ devices: list[Device],
223
+ topology: object,
224
+ config: Config,
225
+ site: str,
226
+ svg_theme: SvgTheme,
227
+ ) -> str:
228
+ edges, _has_tree = _select_edges(topology)
229
+ edges, clients = _build_edges_with_clients(args, edges, devices, config, site)
230
+ options = SvgOptions(width=args.svg_width, height=args.svg_height)
231
+ if args.format == "svg-iso":
232
+ from ..render.svg import render_svg_isometric
233
+
234
+ return render_svg_isometric(
235
+ edges,
236
+ node_types=build_node_type_map(devices, clients),
237
+ options=options,
238
+ theme=svg_theme,
239
+ )
240
+ return render_svg(
241
+ edges,
242
+ node_types=build_node_type_map(devices, clients),
243
+ options=options,
244
+ theme=svg_theme,
245
+ )
246
+
247
+
248
+ def main(argv: list[str] | None = None) -> int:
249
+ logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
250
+ args = _parse_args(argv)
251
+ config = _load_config(args)
252
+ if config is None:
253
+ return 2
254
+ site = _resolve_site(args, config)
255
+ mermaid_theme, svg_theme = resolve_themes(args.theme_file)
256
+
257
+ if args.legend_only:
258
+ content = _render_legend_only(args, mermaid_theme)
259
+ write_output(content, output_path=args.output, stdout=args.stdout)
260
+ return 0
261
+
262
+ try:
263
+ devices, _gateways, topology = _build_topology_data(args, config, site)
264
+ except Exception as exc:
265
+ logging.error("Failed to build topology: %s", exc)
266
+ return 1
267
+
268
+ if args.format == "mermaid":
269
+ content = _render_mermaid_output(args, devices, topology, config, site, mermaid_theme)
270
+ elif args.format in {"svg", "svg-iso"}:
271
+ content = _render_svg_output(args, devices, topology, config, site, svg_theme)
272
+ else:
273
+ logging.error("Unsupported format: %s", args.format)
274
+ return 2
275
+
276
+ write_output(content, output_path=args.output, stdout=args.stdout)
277
+ return 0
278
+
279
+
280
+ if __name__ == "__main__":
281
+ raise SystemExit(main())
@@ -0,0 +1 @@
1
+ """Package module."""
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
  import json
6
6
  import logging
7
7
 
8
- from .topology import group_devices_by_type
8
+ from ..model.topology import group_devices_by_type
9
9
 
10
10
  logger = logging.getLogger(__name__)
11
11
 
@@ -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)
@@ -2,9 +2,10 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import re
6
5
  from dataclasses import dataclass
7
6
 
7
+ from .ports import extract_port_number, normalize_port_label
8
+
8
9
 
9
10
  @dataclass(frozen=True)
10
11
  class LLDPEntry:
@@ -51,43 +52,28 @@ def _looks_like_mac(value: str | None) -> bool:
51
52
  return False
52
53
 
53
54
 
54
- def _extract_port_number(label: str | None) -> int | None:
55
- if not label:
56
- return None
57
- match = re.search(r"(?:^|[^0-9])(?:port|eth)\s*([0-9]+)", label.strip(), re.IGNORECASE)
58
- if match:
59
- return int(match.group(1))
60
- return None
61
-
62
-
63
- def _normalize_port_label(label: str) -> str:
64
- trimmed = label.strip()
65
- number = _extract_port_number(trimmed)
66
- if number is not None:
67
- return f"Port {number}"
68
- return trimmed
69
-
55
+ def _port_label_parts(entry: LLDPEntry) -> tuple[int | None, str | None, str | None]:
56
+ number = entry.local_port_idx
57
+ name = normalize_port_label(entry.local_port_name) if entry.local_port_name else None
58
+ desc = (
59
+ entry.port_desc.strip()
60
+ if entry.port_desc and not _looks_like_mac(entry.port_desc)
61
+ else None
62
+ )
70
63
 
71
- def local_port_label(entry: LLDPEntry) -> str | None:
72
- number = None
73
- name = None
74
- desc = None
75
-
76
- if entry.local_port_idx is not None:
77
- number = entry.local_port_idx
78
- if entry.local_port_name:
79
- name = _normalize_port_label(entry.local_port_name)
80
- if entry.port_desc and not _looks_like_mac(entry.port_desc):
81
- desc = entry.port_desc.strip()
82
- if entry.port_id and not _looks_like_mac(entry.port_id):
83
- if name is None:
84
- name = _normalize_port_label(entry.port_id)
64
+ if entry.port_id and not _looks_like_mac(entry.port_id) and name is None:
65
+ name = normalize_port_label(entry.port_id)
85
66
 
86
67
  if number is None:
87
- number = _extract_port_number(name)
68
+ number = extract_port_number(name)
88
69
  if number is None:
89
- number = _extract_port_number(desc)
70
+ number = extract_port_number(desc)
90
71
 
72
+ return number, name, desc
73
+
74
+
75
+ def local_port_label(entry: LLDPEntry) -> str | None:
76
+ number, name, desc = _port_label_parts(entry)
91
77
  if number is not None and desc:
92
78
  return f"Port {number} ({desc})"
93
79
  if number is not None:
@@ -0,0 +1,23 @@
1
+ """Port parsing helpers shared across modules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+
8
+ def extract_port_number(label: str | None) -> int | None:
9
+ if not label:
10
+ return None
11
+ # Matches: "Port 3", "eth1"; non-matches: "wan", "portX".
12
+ match = re.search(r"(?:^|[^0-9])(?:port|eth)\s*([0-9]+)", label.strip(), re.IGNORECASE)
13
+ if match:
14
+ return int(match.group(1))
15
+ return None
16
+
17
+
18
+ def normalize_port_label(label: str) -> str:
19
+ trimmed = label.strip()
20
+ number = extract_port_number(trimmed)
21
+ if number is not None:
22
+ return f"Port {number}"
23
+ return trimmed