unifi-network-maps 1.4.4__py3-none-any.whl → 1.4.5__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 (33) hide show
  1. unifi_network_maps/__init__.py +1 -1
  2. unifi_network_maps/cli/__init__.py +2 -38
  3. unifi_network_maps/cli/args.py +166 -0
  4. unifi_network_maps/cli/main.py +18 -752
  5. unifi_network_maps/cli/render.py +255 -0
  6. unifi_network_maps/cli/runtime.py +157 -0
  7. unifi_network_maps/io/mkdocs_assets.py +21 -0
  8. unifi_network_maps/io/mock_generate.py +2 -294
  9. unifi_network_maps/model/mock.py +307 -0
  10. unifi_network_maps/render/device_ports_md.py +44 -27
  11. unifi_network_maps/render/legend.py +30 -0
  12. unifi_network_maps/render/lldp_md.py +81 -60
  13. unifi_network_maps/render/markdown_tables.py +21 -0
  14. unifi_network_maps/render/mermaid.py +72 -85
  15. unifi_network_maps/render/mkdocs.py +167 -0
  16. unifi_network_maps/render/templates/device_port_block.md.j2 +5 -0
  17. unifi_network_maps/render/templates/legend_compact.html.j2 +14 -0
  18. unifi_network_maps/render/templates/lldp_device_section.md.j2 +15 -0
  19. unifi_network_maps/render/templates/markdown_section.md.j2 +3 -0
  20. unifi_network_maps/render/templates/mermaid_legend.mmd.j2 +30 -0
  21. unifi_network_maps/render/templates/mkdocs_document.md.j2 +23 -0
  22. unifi_network_maps/render/templates/mkdocs_dual_theme_style.html.j2 +8 -0
  23. unifi_network_maps/render/templates/mkdocs_html_block.html.j2 +2 -0
  24. unifi_network_maps/render/templates/mkdocs_legend.css.j2 +29 -0
  25. unifi_network_maps/render/templates/mkdocs_legend.js.j2 +18 -0
  26. unifi_network_maps/render/templates/mkdocs_mermaid_block.md.j2 +4 -0
  27. unifi_network_maps/render/templating.py +19 -0
  28. {unifi_network_maps-1.4.4.dist-info → unifi_network_maps-1.4.5.dist-info}/METADATA +2 -1
  29. {unifi_network_maps-1.4.4.dist-info → unifi_network_maps-1.4.5.dist-info}/RECORD +33 -13
  30. {unifi_network_maps-1.4.4.dist-info → unifi_network_maps-1.4.5.dist-info}/WHEEL +0 -0
  31. {unifi_network_maps-1.4.4.dist-info → unifi_network_maps-1.4.5.dist-info}/entry_points.txt +0 -0
  32. {unifi_network_maps-1.4.4.dist-info → unifi_network_maps-1.4.5.dist-info}/licenses/LICENSE +0 -0
  33. {unifi_network_maps-1.4.4.dist-info → unifi_network_maps-1.4.5.dist-info}/top_level.txt +0 -0
@@ -4,36 +4,14 @@ from __future__ import annotations
4
4
 
5
5
  import argparse
6
6
  import logging
7
- from datetime import datetime
8
- from pathlib import Path
9
- from zoneinfo import ZoneInfo
10
7
 
11
8
  from ..adapters.config import Config
12
- from ..adapters.unifi import fetch_clients, fetch_devices
13
- from ..io.debug import debug_dump_devices
14
9
  from ..io.export import write_output
15
10
  from ..io.mock_data import load_mock_data
16
- from ..model.topology import (
17
- ClientPortMap,
18
- Device,
19
- PortMap,
20
- TopologyResult,
21
- build_client_edges,
22
- build_client_port_map,
23
- build_device_index,
24
- build_node_type_map,
25
- build_port_map,
26
- build_topology,
27
- group_devices_by_type,
28
- normalize_devices,
29
- )
30
- from ..render.device_ports_md import render_device_port_overview
31
- from ..render.lldp_md import render_lldp_md
32
- from ..render.mermaid import render_legend, render_legend_compact, render_mermaid
33
- from ..render.mermaid_theme import MermaidTheme
34
- from ..render.svg import SvgOptions, render_svg
35
- from ..render.svg_theme import SvgTheme
36
- from ..render.theme import load_theme, resolve_themes
11
+ from ..render.legend import render_legend_only, resolve_legend_style
12
+ from ..render.theme import resolve_themes
13
+ from .args import build_parser
14
+ from .render import render_lldp_format, render_standard_format
37
15
 
38
16
  logger = logging.getLogger(__name__)
39
17
 
@@ -47,169 +25,8 @@ def _load_dotenv(env_file: str | None = None) -> None:
47
25
  load_dotenv(dotenv_path=env_file) if env_file else load_dotenv()
48
26
 
49
27
 
50
- def _build_parser() -> argparse.ArgumentParser:
51
- parser = argparse.ArgumentParser(
52
- description="Generate network maps from UniFi LLDP data, as mermaid or SVG"
53
- )
54
- _add_source_args(parser.add_argument_group("Source"))
55
- _add_mock_args(parser.add_argument_group("Mock"))
56
- _add_functional_args(parser.add_argument_group("Functional"))
57
- _add_mermaid_args(parser.add_argument_group("Mermaid"))
58
- _add_svg_args(parser.add_argument_group("SVG"))
59
- _add_general_render_args(parser.add_argument_group("Output"))
60
- _add_debug_args(parser.add_argument_group("Debug"))
61
- return parser
62
-
63
-
64
- def _add_source_args(parser: argparse._ArgumentGroup) -> None:
65
- parser.add_argument("--site", default=None, help="UniFi site name (overrides UNIFI_SITE)")
66
- parser.add_argument(
67
- "--env-file",
68
- default=None,
69
- help="Path to .env file (overrides default .env discovery)",
70
- )
71
- parser.add_argument(
72
- "--mock-data",
73
- default=None,
74
- help="Path to mock data JSON (skips UniFi API calls)",
75
- )
76
-
77
-
78
- def _add_mock_args(parser: argparse._ArgumentGroup) -> None:
79
- parser.add_argument(
80
- "--generate-mock",
81
- default=None,
82
- help="Write mock data JSON to the given path and exit",
83
- )
84
- parser.add_argument("--mock-seed", type=int, default=1337, help="Seed for mock generation")
85
- parser.add_argument(
86
- "--mock-switches",
87
- type=int,
88
- default=1,
89
- help="Number of switches to generate (default: 1)",
90
- )
91
- parser.add_argument(
92
- "--mock-aps",
93
- type=int,
94
- default=2,
95
- help="Number of access points to generate (default: 2)",
96
- )
97
- parser.add_argument(
98
- "--mock-wired-clients",
99
- type=int,
100
- default=2,
101
- help="Number of wired clients to generate (default: 2)",
102
- )
103
- parser.add_argument(
104
- "--mock-wireless-clients",
105
- type=int,
106
- default=2,
107
- help="Number of wireless clients to generate (default: 2)",
108
- )
109
-
110
-
111
- def _add_functional_args(parser: argparse._ArgumentGroup) -> None:
112
- parser.add_argument("--include-ports", action="store_true", help="Include port labels in edges")
113
- parser.add_argument(
114
- "--include-clients",
115
- action="store_true",
116
- help="Include active clients as leaf nodes",
117
- )
118
- parser.add_argument(
119
- "--client-scope",
120
- choices=["wired", "wireless", "all"],
121
- default="wired",
122
- help="Client types to include (default: wired)",
123
- )
124
- parser.add_argument(
125
- "--only-unifi", action="store_true", help="Only include neighbors that are UniFi devices"
126
- )
127
- parser.add_argument(
128
- "--no-cache",
129
- action="store_true",
130
- help="Disable UniFi API cache reads and writes",
131
- )
132
-
133
-
134
- def _add_mermaid_args(parser: argparse._ArgumentGroup) -> None:
135
- parser.add_argument("--direction", default="TB", choices=["LR", "TB"], help="Mermaid direction")
136
- parser.add_argument(
137
- "--group-by-type",
138
- action="store_true",
139
- help="Group nodes by gateway/switch/ap in Mermaid subgraphs",
140
- )
141
- parser.add_argument(
142
- "--legend-scale",
143
- type=float,
144
- default=1.0,
145
- help="Scale legend font/link sizes for Mermaid output (default: 1.0)",
146
- )
147
- parser.add_argument(
148
- "--legend-style",
149
- default="auto",
150
- choices=["auto", "compact", "diagram"],
151
- help="Legend style (auto uses compact for mkdocs, diagram otherwise)",
152
- )
153
- parser.add_argument(
154
- "--legend-only",
155
- action="store_true",
156
- help="Render only the legend as a separate Mermaid graph",
157
- )
158
-
159
-
160
- def _add_svg_args(parser: argparse._ArgumentGroup) -> None:
161
- parser.add_argument("--svg-width", type=int, default=None, help="SVG width override")
162
- parser.add_argument("--svg-height", type=int, default=None, help="SVG height override")
163
- parser.add_argument("--theme-file", default=None, help="Path to theme YAML file")
164
-
165
-
166
- def _add_general_render_args(parser: argparse._ArgumentGroup) -> None:
167
- parser.add_argument(
168
- "--format",
169
- default="mermaid",
170
- choices=["mermaid", "svg", "svg-iso", "lldp-md", "mkdocs"],
171
- help="Output format",
172
- )
173
- parser.add_argument(
174
- "--markdown",
175
- action="store_true",
176
- help="Wrap output in a Markdown mermaid code fence for notes tools like Obsidian",
177
- )
178
- parser.add_argument("--output", default=None, help="Output file path")
179
- parser.add_argument("--stdout", action="store_true", help="Write output to stdout")
180
- parser.add_argument(
181
- "--mkdocs-sidebar-legend",
182
- action="store_true",
183
- help="For mkdocs output, write sidebar legend assets next to the output file",
184
- )
185
- parser.add_argument(
186
- "--mkdocs-dual-theme",
187
- action="store_true",
188
- help="Render light/dark Mermaid blocks for MkDocs Material theme switching",
189
- )
190
- parser.add_argument(
191
- "--mkdocs-timestamp-zone",
192
- default="Europe/Amsterdam",
193
- help="Timezone for mkdocs generated timestamp (use 'off' to disable)",
194
- )
195
-
196
-
197
- def _add_debug_args(parser: argparse._ArgumentGroup) -> None:
198
- parser.add_argument(
199
- "--debug-dump",
200
- action="store_true",
201
- help="Dump gateway and sample device data to stderr for debugging",
202
- )
203
- parser.add_argument(
204
- "--debug-sample",
205
- type=int,
206
- default=2,
207
- help="Number of non-gateway devices to include in debug dump (default: 2)",
208
- )
209
-
210
-
211
28
  def _parse_args(argv: list[str] | None) -> argparse.Namespace:
212
- parser = _build_parser()
29
+ parser = build_parser()
213
30
  return parser.parse_args(argv)
214
31
 
215
32
 
@@ -226,385 +43,11 @@ def _resolve_site(args: argparse.Namespace, config: Config) -> str:
226
43
  return args.site or config.site
227
44
 
228
45
 
229
- def _resolve_legend_style(args: argparse.Namespace) -> str:
230
- if args.legend_style == "auto":
231
- return "compact" if args.format == "mkdocs" else "diagram"
232
- return args.legend_style
233
-
234
-
235
- def _render_legend_only(args: argparse.Namespace, mermaid_theme: MermaidTheme) -> str:
236
- legend_style = _resolve_legend_style(args)
237
- if legend_style == "compact":
238
- content = "# Legend\n\n" + render_legend_compact(theme=mermaid_theme)
239
- else:
240
- content = render_legend(theme=mermaid_theme, legend_scale=args.legend_scale)
241
- if args.markdown:
242
- content = f"""```mermaid
243
- {content}```
244
- """
245
- return content
246
-
247
-
248
- def _load_devices_data(
249
- args: argparse.Namespace,
250
- config: Config | None,
251
- site: str,
252
- *,
253
- raw_devices_override: list[object] | None = None,
254
- ) -> tuple[list[object], list[Device]]:
255
- if raw_devices_override is None:
256
- if config is None:
257
- raise ValueError("Config required to fetch devices")
258
- raw_devices = list(
259
- fetch_devices(config, site=site, detailed=True, use_cache=not args.no_cache)
260
- )
261
- else:
262
- raw_devices = raw_devices_override
263
- devices = normalize_devices(raw_devices)
264
- if args.debug_dump:
265
- debug_dump_devices(raw_devices, devices, sample_count=max(0, args.debug_sample))
266
- return raw_devices, devices
267
-
268
-
269
- def _build_topology_data(
270
- args: argparse.Namespace,
271
- config: Config | None,
272
- site: str,
273
- *,
274
- include_ports: bool | None = None,
275
- raw_devices_override: list[object] | None = None,
276
- ) -> tuple[list[Device], list[str], TopologyResult]:
277
- _raw_devices, devices = _load_devices_data(
278
- args,
279
- config,
280
- site,
281
- raw_devices_override=raw_devices_override,
282
- )
283
- groups_for_rank = group_devices_by_type(devices)
284
- gateways = groups_for_rank.get("gateway", [])
285
- topology = build_topology(
286
- devices,
287
- include_ports=include_ports if include_ports is not None else args.include_ports,
288
- only_unifi=args.only_unifi,
289
- gateways=gateways,
290
- )
291
- return devices, gateways, topology
292
-
293
-
294
- def _build_edges_with_clients(
295
- args: argparse.Namespace,
296
- edges: list,
297
- devices: list[Device],
298
- config: Config | None,
299
- site: str,
300
- *,
301
- clients_override: list[object] | None = None,
302
- ) -> tuple[list, list | None]:
303
- clients = None
304
- if args.include_clients:
305
- if clients_override is None:
306
- if config is None:
307
- raise ValueError("Config required to fetch clients")
308
- clients = list(fetch_clients(config, site=site, use_cache=not args.no_cache))
309
- else:
310
- clients = clients_override
311
- device_index = build_device_index(devices)
312
- edges = edges + build_client_edges(
313
- clients,
314
- device_index,
315
- include_ports=args.include_ports,
316
- client_mode=args.client_scope,
317
- )
318
- return edges, clients
319
-
320
-
321
- def _select_edges(topology: TopologyResult) -> tuple[list, bool]:
322
- if topology.tree_edges:
323
- return topology.tree_edges, True
324
- logging.warning("No gateway found for hierarchy; rendering raw edges.")
325
- return topology.raw_edges, False
326
-
327
-
328
- def _render_mermaid_output(
329
- args: argparse.Namespace,
330
- devices: list[Device],
331
- topology: TopologyResult,
332
- config: Config | None,
333
- site: str,
334
- mermaid_theme: MermaidTheme,
335
- *,
336
- clients_override: list[object] | None = None,
337
- ) -> str:
338
- edges, _has_tree = _select_edges(topology)
339
- edges, clients = _build_edges_with_clients(
340
- args,
341
- edges,
342
- devices,
343
- config,
344
- site,
345
- clients_override=clients_override,
346
- )
347
- groups = None
348
- group_order = None
349
- if args.group_by_type:
350
- groups = group_devices_by_type(devices)
351
- group_order = ["gateway", "switch", "ap", "other"]
352
- content = render_mermaid(
353
- edges,
354
- direction=args.direction,
355
- groups=groups,
356
- group_order=group_order,
357
- node_types=build_node_type_map(devices, clients, client_mode=args.client_scope),
358
- theme=mermaid_theme,
359
- )
360
- if args.markdown:
361
- content = f"""```mermaid
362
- {content}```
363
- """
364
- return content
365
-
366
-
367
- def _render_mkdocs_output(
368
- args: argparse.Namespace,
369
- devices: list[Device],
370
- topology: TopologyResult,
371
- mermaid_theme: MermaidTheme,
372
- port_map: PortMap,
373
- client_ports: ClientPortMap | None,
374
- timestamp_zone: str,
375
- dark_mermaid_theme: MermaidTheme | None = None,
376
- ) -> str:
377
- edges, _has_tree = _select_edges(topology)
378
- clients = None
379
- node_types = build_node_type_map(devices, clients, client_mode=args.client_scope)
380
- content = render_mermaid(
381
- edges,
382
- direction=args.direction,
383
- node_types=node_types,
384
- theme=mermaid_theme,
385
- )
386
- legend_style = _resolve_legend_style(args)
387
- dual_theme = args.mkdocs_dual_theme and dark_mermaid_theme is not None
388
- legend_header = "## Legend\n\n" if legend_style != "compact" else ""
389
- if dual_theme and dark_mermaid_theme is not None:
390
- dark_content = render_mermaid(
391
- edges,
392
- direction=args.direction,
393
- node_types=node_types,
394
- theme=dark_mermaid_theme,
395
- )
396
- map_block = _mkdocs_dual_mermaid_block(content, dark_content, base_class="unifi-mermaid")
397
- legend_block = _mkdocs_dual_legend_block(
398
- legend_style,
399
- mermaid_theme=mermaid_theme,
400
- dark_mermaid_theme=dark_mermaid_theme,
401
- legend_scale=args.legend_scale,
402
- )
403
- dual_style = _mkdocs_dual_theme_style()
404
- else:
405
- map_block = _mkdocs_mermaid_block(content, class_name="unifi-mermaid")
406
- legend_block = _mkdocs_single_legend_block(
407
- legend_style,
408
- mermaid_theme=mermaid_theme,
409
- legend_scale=args.legend_scale,
410
- )
411
- dual_style = ""
412
- timestamp_line = ""
413
- if timestamp_zone.strip().lower() not in {"off", "none", "false"}:
414
- try:
415
- zone = ZoneInfo(timestamp_zone)
416
- except Exception as exc: # noqa: BLE001
417
- logger.warning("Invalid mkdocs timestamp zone '%s': %s", timestamp_zone, exc)
418
- else:
419
- generated_at = datetime.now(zone).strftime("%Y-%m-%d %H:%M:%S %Z")
420
- timestamp_line = f"Generated: {generated_at}\n\n"
421
- return (
422
- f"# UniFi network\n\n{timestamp_line}{dual_style}## Map\n\n{map_block}\n\n"
423
- f"{legend_header}{legend_block}\n\n"
424
- f"{render_device_port_overview(devices, port_map, client_ports=client_ports)}"
425
- )
426
-
427
-
428
- def _mkdocs_mermaid_block(content: str, *, class_name: str) -> str:
429
- return f'<div class="{class_name}">\n```mermaid\n{content}```\n</div>'
430
-
431
-
432
- def _mkdocs_dual_mermaid_block(
433
- light_content: str,
434
- dark_content: str,
435
- *,
436
- base_class: str,
437
- ) -> str:
438
- light = _mkdocs_mermaid_block(light_content, class_name=f"{base_class} {base_class}--light")
439
- dark = _mkdocs_mermaid_block(dark_content, class_name=f"{base_class} {base_class}--dark")
440
- return f"{light}\n{dark}"
441
-
442
-
443
- def _mkdocs_single_legend_block(
444
- legend_style: str,
445
- *,
446
- mermaid_theme: MermaidTheme,
447
- legend_scale: float,
448
- ) -> str:
449
- if legend_style == "compact":
450
- return (
451
- '<div class="unifi-legend" data-unifi-legend>\n'
452
- + render_legend_compact(theme=mermaid_theme)
453
- + "</div>"
454
- )
455
- return "```mermaid\n" + render_legend(theme=mermaid_theme, legend_scale=legend_scale) + "```"
456
-
457
-
458
- def _mkdocs_dual_legend_block(
459
- legend_style: str,
460
- *,
461
- mermaid_theme: MermaidTheme,
462
- dark_mermaid_theme: MermaidTheme,
463
- legend_scale: float,
464
- ) -> str:
465
- if legend_style == "compact":
466
- light = (
467
- '<div class="unifi-legend unifi-legend--light" data-unifi-legend>\n'
468
- + render_legend_compact(theme=mermaid_theme)
469
- + "</div>"
470
- )
471
- dark = (
472
- '<div class="unifi-legend unifi-legend--dark" data-unifi-legend>\n'
473
- + render_legend_compact(theme=dark_mermaid_theme)
474
- + "</div>"
475
- )
476
- return f"{light}\n{dark}"
477
- light = _mkdocs_mermaid_block(
478
- render_legend(theme=mermaid_theme, legend_scale=legend_scale),
479
- class_name="unifi-legend unifi-legend--light",
480
- )
481
- dark = _mkdocs_mermaid_block(
482
- render_legend(theme=dark_mermaid_theme, legend_scale=legend_scale),
483
- class_name="unifi-legend unifi-legend--dark",
484
- )
485
- return f"{light}\n{dark}"
486
-
487
-
488
- def _mkdocs_dual_theme_style() -> str:
489
- return (
490
- "<style>\n"
491
- ".unifi-mermaid--light,.unifi-legend--light{display:none;}\n"
492
- ".unifi-mermaid--dark,.unifi-legend--dark{display:none;}\n"
493
- '[data-md-color-scheme="default"] .unifi-mermaid--light{display:block;}\n'
494
- '[data-md-color-scheme="default"] .unifi-legend--light{display:block;}\n'
495
- '[data-md-color-scheme="slate"] .unifi-mermaid--dark{display:block;}\n'
496
- '[data-md-color-scheme="slate"] .unifi-legend--dark{display:block;}\n'
497
- "</style>\n\n"
498
- )
499
-
500
-
501
- def _write_mkdocs_sidebar_assets(output_path: str) -> None:
502
- from pathlib import Path
503
-
504
- output_dir = Path(output_path).resolve().parent
505
- assets_dir = output_dir / "assets"
506
- assets_dir.mkdir(parents=True, exist_ok=True)
507
- (assets_dir / "legend.js").write_text(
508
- (
509
- 'document.addEventListener("DOMContentLoaded", () => {\n'
510
- ' const legends = document.querySelectorAll("[data-unifi-legend]");\n'
511
- ' const sidebar = document.querySelector(".md-sidebar--secondary .md-sidebar__scrollwrap");\n'
512
- " if (!legends.length || !sidebar) {\n"
513
- " return;\n"
514
- " }\n"
515
- ' const wrapper = document.createElement("div");\n'
516
- ' wrapper.className = "unifi-legend-sidebar";\n'
517
- ' const title = document.createElement("div");\n'
518
- ' title.className = "unifi-legend-title";\n'
519
- ' title.textContent = "Legend";\n'
520
- " wrapper.appendChild(title);\n"
521
- " legends.forEach((legend) => {\n"
522
- " wrapper.appendChild(legend.cloneNode(true));\n"
523
- ' legend.classList.add("unifi-legend-hidden");\n'
524
- " });\n"
525
- " sidebar.appendChild(wrapper);\n"
526
- "});\n"
527
- ),
528
- encoding="utf-8",
529
- )
530
- (assets_dir / "legend.css").write_text(
531
- (
532
- ".unifi-legend-hidden,\n"
533
- ".unifi-legend-hidden.unifi-legend,\n"
534
- ".unifi-legend-hidden.unifi-legend--light,\n"
535
- ".unifi-legend-hidden.unifi-legend--dark {\n"
536
- " display: none !important;\n"
537
- "}\n\n"
538
- ".unifi-legend-sidebar {\n"
539
- " margin-top: 1rem;\n"
540
- " padding: 0.5rem 0.75rem;\n"
541
- " border: 1px solid rgba(0, 0, 0, 0.08);\n"
542
- " border-radius: 6px;\n"
543
- " font-size: 0.75rem;\n"
544
- "}\n\n"
545
- ".unifi-legend-title {\n"
546
- " font-weight: 600;\n"
547
- " margin-bottom: 0.5rem;\n"
548
- "}\n\n"
549
- ".unifi-legend-sidebar table {\n"
550
- " width: 100%;\n"
551
- " border-collapse: collapse;\n"
552
- "}\n\n"
553
- ".unifi-legend-sidebar td,\n"
554
- ".unifi-legend-sidebar th {\n"
555
- " border: 0;\n"
556
- " padding: 0.15rem 0;\n"
557
- "}\n\n"
558
- ".unifi-legend-sidebar svg {\n"
559
- " display: block;\n"
560
- "}\n"
561
- ),
562
- encoding="utf-8",
563
- )
564
-
565
-
566
- def _render_svg_output(
567
- args: argparse.Namespace,
568
- devices: list[Device],
569
- topology: TopologyResult,
570
- config: Config | None,
571
- site: str,
572
- svg_theme: SvgTheme,
573
- *,
574
- clients_override: list[object] | None = None,
575
- ) -> str:
576
- edges, _has_tree = _select_edges(topology)
577
- edges, clients = _build_edges_with_clients(
578
- args,
579
- edges,
580
- devices,
581
- config,
582
- site,
583
- clients_override=clients_override,
584
- )
585
- options = SvgOptions(width=args.svg_width, height=args.svg_height)
586
- if args.format == "svg-iso":
587
- from ..render.svg import render_svg_isometric
588
-
589
- return render_svg_isometric(
590
- edges,
591
- node_types=build_node_type_map(devices, clients, client_mode=args.client_scope),
592
- options=options,
593
- theme=svg_theme,
594
- )
595
- return render_svg(
596
- edges,
597
- node_types=build_node_type_map(devices, clients, client_mode=args.client_scope),
598
- options=options,
599
- theme=svg_theme,
600
- )
601
-
602
-
603
46
  def _handle_generate_mock(args: argparse.Namespace) -> int | None:
604
47
  if not args.generate_mock:
605
48
  return None
606
49
  try:
607
- from ..io.mock_generate import MockOptions, mock_payload_json
50
+ from ..model.mock import MockOptions, mock_payload_json
608
51
  except ImportError as exc:
609
52
  logging.error("Faker is required for --generate-mock: %s", exc)
610
53
  return 2
@@ -636,192 +79,6 @@ def _load_runtime_context(
636
79
  return config, site, None, None
637
80
 
638
81
 
639
- def _render_lldp_format(
640
- args: argparse.Namespace,
641
- *,
642
- config: Config | None,
643
- site: str,
644
- mock_devices: list[object] | None,
645
- mock_clients: list[object] | None,
646
- ) -> int:
647
- try:
648
- _raw_devices, devices = _load_devices_data(
649
- args,
650
- config,
651
- site,
652
- raw_devices_override=mock_devices,
653
- )
654
- except Exception as exc:
655
- logging.error("Failed to load devices: %s", exc)
656
- return 1
657
- if mock_clients is None:
658
- if config is None:
659
- logging.error("Mock data required for client rendering")
660
- return 2
661
- clients = list(fetch_clients(config, site=site))
662
- else:
663
- clients = mock_clients
664
- content = render_lldp_md(
665
- devices,
666
- clients=clients,
667
- include_ports=args.include_ports,
668
- show_clients=args.include_clients,
669
- client_mode=args.client_scope,
670
- )
671
- write_output(content, output_path=args.output, stdout=args.stdout)
672
- return 0
673
-
674
-
675
- def _render_standard_format(
676
- args: argparse.Namespace,
677
- *,
678
- config: Config | None,
679
- site: str,
680
- mock_devices: list[object] | None,
681
- mock_clients: list[object] | None,
682
- mermaid_theme: MermaidTheme,
683
- svg_theme: SvgTheme,
684
- ) -> int:
685
- topology_result = _load_topology_for_render(
686
- args,
687
- config=config,
688
- site=site,
689
- mock_devices=mock_devices,
690
- )
691
- if topology_result is None:
692
- return 1
693
- devices, topology = topology_result
694
-
695
- if args.format == "mermaid":
696
- content = _render_mermaid_output(
697
- args,
698
- devices,
699
- topology,
700
- config,
701
- site,
702
- mermaid_theme,
703
- clients_override=mock_clients,
704
- )
705
- elif args.format == "mkdocs":
706
- content = _render_mkdocs_format(
707
- args,
708
- devices=devices,
709
- topology=topology,
710
- config=config,
711
- site=site,
712
- mermaid_theme=mermaid_theme,
713
- mock_clients=mock_clients,
714
- )
715
- if content is None:
716
- return 2
717
- elif args.format in {"svg", "svg-iso"}:
718
- content = _render_svg_output(
719
- args,
720
- devices,
721
- topology,
722
- config,
723
- site,
724
- svg_theme,
725
- clients_override=mock_clients,
726
- )
727
- else:
728
- logging.error("Unsupported format: %s", args.format)
729
- return 2
730
-
731
- write_output(content, output_path=args.output, stdout=args.stdout)
732
- return 0
733
-
734
-
735
- def _load_topology_for_render(
736
- args: argparse.Namespace,
737
- *,
738
- config: Config | None,
739
- site: str,
740
- mock_devices: list[object] | None,
741
- ) -> tuple[list[Device], TopologyResult] | None:
742
- try:
743
- include_ports = True if args.format == "mkdocs" else None
744
- devices, _gateways, topology = _build_topology_data(
745
- args,
746
- config,
747
- site,
748
- include_ports=include_ports,
749
- raw_devices_override=mock_devices,
750
- )
751
- except Exception as exc:
752
- logging.error("Failed to build topology: %s", exc)
753
- return None
754
- return devices, topology
755
-
756
-
757
- def _load_dark_mermaid_theme() -> MermaidTheme | None:
758
- dark_theme_path = Path(__file__).resolve().parents[1] / "assets" / "themes" / "dark.yaml"
759
- try:
760
- dark_theme, _ = load_theme(dark_theme_path)
761
- except Exception as exc: # noqa: BLE001
762
- logger.warning("Failed to load dark theme: %s", exc)
763
- return None
764
- return dark_theme
765
-
766
-
767
- def _resolve_mkdocs_client_ports(
768
- args: argparse.Namespace,
769
- devices: list[Device],
770
- config: Config | None,
771
- site: str,
772
- mock_clients: list[object] | None,
773
- ) -> tuple[ClientPortMap | None, int | None]:
774
- if not args.include_clients:
775
- return None, None
776
- if mock_clients is None:
777
- if config is None:
778
- return None, 2
779
- clients = list(fetch_clients(config, site=site))
780
- else:
781
- clients = mock_clients
782
- client_ports = build_client_port_map(devices, clients, client_mode=args.client_scope)
783
- return client_ports, None
784
-
785
-
786
- def _render_mkdocs_format(
787
- args: argparse.Namespace,
788
- *,
789
- devices: list[Device],
790
- topology: TopologyResult,
791
- config: Config | None,
792
- site: str,
793
- mermaid_theme: MermaidTheme,
794
- mock_clients: list[object] | None,
795
- ) -> str | None:
796
- if args.mkdocs_sidebar_legend and not args.output:
797
- logging.error("--mkdocs-sidebar-legend requires --output")
798
- return None
799
- if args.mkdocs_sidebar_legend:
800
- _write_mkdocs_sidebar_assets(args.output)
801
- port_map = build_port_map(devices, only_unifi=args.only_unifi)
802
- client_ports, error_code = _resolve_mkdocs_client_ports(
803
- args,
804
- devices,
805
- config,
806
- site,
807
- mock_clients,
808
- )
809
- if error_code is not None:
810
- logging.error("Mock data required for client rendering")
811
- return None
812
- dark_mermaid_theme = _load_dark_mermaid_theme() if args.mkdocs_dual_theme else None
813
- return _render_mkdocs_output(
814
- args,
815
- devices,
816
- topology,
817
- mermaid_theme,
818
- port_map,
819
- client_ports,
820
- args.mkdocs_timestamp_zone,
821
- dark_mermaid_theme,
822
- )
823
-
824
-
825
82
  def main(argv: list[str] | None = None) -> int:
826
83
  logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
827
84
  args = _parse_args(argv)
@@ -836,12 +93,21 @@ def main(argv: list[str] | None = None) -> int:
836
93
  mermaid_theme, svg_theme = resolve_themes(args.theme_file)
837
94
 
838
95
  if args.legend_only:
839
- content = _render_legend_only(args, mermaid_theme)
96
+ legend_style = resolve_legend_style(
97
+ format_name=args.format,
98
+ legend_style=args.legend_style,
99
+ )
100
+ content = render_legend_only(
101
+ legend_style=legend_style,
102
+ legend_scale=args.legend_scale,
103
+ markdown=args.markdown,
104
+ theme=mermaid_theme,
105
+ )
840
106
  write_output(content, output_path=args.output, stdout=args.stdout)
841
107
  return 0
842
108
 
843
109
  if args.format == "lldp-md":
844
- return _render_lldp_format(
110
+ return render_lldp_format(
845
111
  args,
846
112
  config=config,
847
113
  site=site,
@@ -849,7 +115,7 @@ def main(argv: list[str] | None = None) -> int:
849
115
  mock_clients=mock_clients,
850
116
  )
851
117
 
852
- return _render_standard_format(
118
+ return render_standard_format(
853
119
  args,
854
120
  config=config,
855
121
  site=site,