unifi-network-maps 1.4.1__py3-none-any.whl → 1.4.3__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.
@@ -4,6 +4,9 @@ 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
7
10
 
8
11
  from ..adapters.config import Config
9
12
  from ..adapters.unifi import fetch_clients, fetch_devices
@@ -14,6 +17,7 @@ from ..model.topology import (
14
17
  ClientPortMap,
15
18
  Device,
16
19
  PortMap,
20
+ TopologyResult,
17
21
  build_client_edges,
18
22
  build_client_port_map,
19
23
  build_device_index,
@@ -29,7 +33,7 @@ from ..render.mermaid import render_legend, render_legend_compact, render_mermai
29
33
  from ..render.mermaid_theme import MermaidTheme
30
34
  from ..render.svg import SvgOptions, render_svg
31
35
  from ..render.svg_theme import SvgTheme
32
- from ..render.theme import resolve_themes
36
+ from ..render.theme import load_theme, resolve_themes
33
37
 
34
38
  logger = logging.getLogger(__name__)
35
39
 
@@ -120,6 +124,11 @@ def _add_functional_args(parser: argparse._ArgumentGroup) -> None:
120
124
  parser.add_argument(
121
125
  "--only-unifi", action="store_true", help="Only include neighbors that are UniFi devices"
122
126
  )
127
+ parser.add_argument(
128
+ "--no-cache",
129
+ action="store_true",
130
+ help="Disable UniFi API cache reads and writes",
131
+ )
123
132
 
124
133
 
125
134
  def _add_mermaid_args(parser: argparse._ArgumentGroup) -> None:
@@ -173,6 +182,16 @@ def _add_general_render_args(parser: argparse._ArgumentGroup) -> None:
173
182
  action="store_true",
174
183
  help="For mkdocs output, write sidebar legend assets next to the output file",
175
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
+ )
176
195
 
177
196
 
178
197
  def _add_debug_args(parser: argparse._ArgumentGroup) -> None:
@@ -236,7 +255,9 @@ def _load_devices_data(
236
255
  if raw_devices_override is None:
237
256
  if config is None:
238
257
  raise ValueError("Config required to fetch devices")
239
- raw_devices = list(fetch_devices(config, site=site, detailed=True))
258
+ raw_devices = list(
259
+ fetch_devices(config, site=site, detailed=True, use_cache=not args.no_cache)
260
+ )
240
261
  else:
241
262
  raw_devices = raw_devices_override
242
263
  devices = normalize_devices(raw_devices)
@@ -252,7 +273,7 @@ def _build_topology_data(
252
273
  *,
253
274
  include_ports: bool | None = None,
254
275
  raw_devices_override: list[object] | None = None,
255
- ) -> tuple[list[Device], list[str], object]:
276
+ ) -> tuple[list[Device], list[str], TopologyResult]:
256
277
  _raw_devices, devices = _load_devices_data(
257
278
  args,
258
279
  config,
@@ -284,7 +305,7 @@ def _build_edges_with_clients(
284
305
  if clients_override is None:
285
306
  if config is None:
286
307
  raise ValueError("Config required to fetch clients")
287
- clients = list(fetch_clients(config, site=site))
308
+ clients = list(fetch_clients(config, site=site, use_cache=not args.no_cache))
288
309
  else:
289
310
  clients = clients_override
290
311
  device_index = build_device_index(devices)
@@ -297,7 +318,7 @@ def _build_edges_with_clients(
297
318
  return edges, clients
298
319
 
299
320
 
300
- def _select_edges(topology: object) -> tuple[list, bool]:
321
+ def _select_edges(topology: TopologyResult) -> tuple[list, bool]:
301
322
  if topology.tree_edges:
302
323
  return topology.tree_edges, True
303
324
  logging.warning("No gateway found for hierarchy; rendering raw edges.")
@@ -307,7 +328,7 @@ def _select_edges(topology: object) -> tuple[list, bool]:
307
328
  def _render_mermaid_output(
308
329
  args: argparse.Namespace,
309
330
  devices: list[Device],
310
- topology: object,
331
+ topology: TopologyResult,
311
332
  config: Config | None,
312
333
  site: str,
313
334
  mermaid_theme: MermaidTheme,
@@ -346,40 +367,134 @@ def _render_mermaid_output(
346
367
  def _render_mkdocs_output(
347
368
  args: argparse.Namespace,
348
369
  devices: list[Device],
349
- topology: object,
350
- config: Config,
351
- site: str,
370
+ topology: TopologyResult,
352
371
  mermaid_theme: MermaidTheme,
353
372
  port_map: PortMap,
354
373
  client_ports: ClientPortMap | None,
374
+ timestamp_zone: str,
375
+ dark_mermaid_theme: MermaidTheme | None = None,
355
376
  ) -> str:
356
377
  edges, _has_tree = _select_edges(topology)
357
378
  clients = None
379
+ node_types = build_node_type_map(devices, clients, client_mode=args.client_scope)
358
380
  content = render_mermaid(
359
381
  edges,
360
382
  direction=args.direction,
361
- node_types=build_node_type_map(devices, clients, client_mode=args.client_scope),
383
+ node_types=node_types,
362
384
  theme=mermaid_theme,
363
385
  )
364
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:
365
449
  if legend_style == "compact":
366
- legend_block = (
450
+ return (
367
451
  '<div class="unifi-legend" data-unifi-legend>\n'
368
452
  + render_legend_compact(theme=mermaid_theme)
369
453
  + "</div>"
370
454
  )
371
- legend_header = ""
372
- else:
373
- legend_block = (
374
- "```mermaid\n"
375
- + render_legend(theme=mermaid_theme, legend_scale=args.legend_scale)
376
- + "```"
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>"
377
475
  )
378
- legend_header = "## Legend\n\n"
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:
379
489
  return (
380
- f"# UniFi network\n\n## Map\n\n```mermaid\n{content}```\n\n"
381
- f"{legend_header}{legend_block}\n\n"
382
- f"{render_device_port_overview(devices, port_map, client_ports=client_ports)}"
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"
383
498
  )
384
499
 
385
500
 
@@ -446,7 +561,7 @@ def _write_mkdocs_sidebar_assets(output_path: str) -> None:
446
561
  def _render_svg_output(
447
562
  args: argparse.Namespace,
448
563
  devices: list[Device],
449
- topology: object,
564
+ topology: TopologyResult,
450
565
  config: Config | None,
451
566
  site: str,
452
567
  svg_theme: SvgTheme,
@@ -480,87 +595,97 @@ def _render_svg_output(
480
595
  )
481
596
 
482
597
 
483
- def main(argv: list[str] | None = None) -> int:
484
- logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
485
- args = _parse_args(argv)
486
- if args.generate_mock:
487
- try:
488
- from ..io.mock_generate import MockOptions, mock_payload_json
489
- except ImportError as exc:
490
- logging.error("Faker is required for --generate-mock: %s", exc)
491
- return 2
492
- options = MockOptions(
493
- seed=args.mock_seed,
494
- switch_count=max(1, args.mock_switches),
495
- ap_count=max(0, args.mock_aps),
496
- wired_client_count=max(0, args.mock_wired_clients),
497
- wireless_client_count=max(0, args.mock_wireless_clients),
498
- )
499
- content = mock_payload_json(options)
500
- write_output(content, output_path=args.generate_mock, stdout=args.stdout)
501
- return 0
502
- mock_devices = None
503
- mock_clients: list[object] | None = None
598
+ def _handle_generate_mock(args: argparse.Namespace) -> int | None:
599
+ if not args.generate_mock:
600
+ return None
601
+ try:
602
+ from ..io.mock_generate import MockOptions, mock_payload_json
603
+ except ImportError as exc:
604
+ logging.error("Faker is required for --generate-mock: %s", exc)
605
+ return 2
606
+ options = MockOptions(
607
+ seed=args.mock_seed,
608
+ switch_count=max(1, args.mock_switches),
609
+ ap_count=max(0, args.mock_aps),
610
+ wired_client_count=max(0, args.mock_wired_clients),
611
+ wireless_client_count=max(0, args.mock_wireless_clients),
612
+ )
613
+ content = mock_payload_json(options)
614
+ write_output(content, output_path=args.generate_mock, stdout=args.stdout)
615
+ return 0
616
+
617
+
618
+ def _load_runtime_context(
619
+ args: argparse.Namespace,
620
+ ) -> tuple[Config | None, str, list[object] | None, list[object] | None]:
504
621
  if args.mock_data:
505
622
  try:
506
623
  mock_devices, mock_clients = load_mock_data(args.mock_data)
507
624
  except Exception as exc: # noqa: BLE001
508
- logging.error("Failed to load mock data: %s", exc)
509
- return 2
510
- config = None
511
- site = "mock"
512
- else:
513
- config = _load_config(args)
514
- if config is None:
515
- return 2
516
- site = _resolve_site(args, config)
517
- mermaid_theme, svg_theme = resolve_themes(args.theme_file)
625
+ raise ValueError(f"Failed to load mock data: {exc}") from exc
626
+ return None, "mock", mock_devices, mock_clients
627
+ config = _load_config(args)
628
+ if config is None:
629
+ raise ValueError("Config required to run")
630
+ site = _resolve_site(args, config)
631
+ return config, site, None, None
518
632
 
519
- if args.legend_only:
520
- content = _render_legend_only(args, mermaid_theme)
521
- write_output(content, output_path=args.output, stdout=args.stdout)
522
- return 0
523
-
524
- if args.format == "lldp-md":
525
- try:
526
- _raw_devices, devices = _load_devices_data(
527
- args,
528
- config,
529
- site,
530
- raw_devices_override=mock_devices,
531
- )
532
- except Exception as exc:
533
- logging.error("Failed to load devices: %s", exc)
534
- return 1
535
- if mock_clients is None:
536
- if config is None:
537
- logging.error("Mock data required for client rendering")
538
- return 2
539
- clients = list(fetch_clients(config, site=site))
540
- else:
541
- clients = mock_clients
542
- content = render_lldp_md(
543
- devices,
544
- clients=clients,
545
- include_ports=args.include_ports,
546
- show_clients=args.include_clients,
547
- client_mode=args.client_scope,
548
- )
549
- write_output(content, output_path=args.output, stdout=args.stdout)
550
- return 0
551
633
 
634
+ def _render_lldp_format(
635
+ args: argparse.Namespace,
636
+ *,
637
+ config: Config | None,
638
+ site: str,
639
+ mock_devices: list[object] | None,
640
+ mock_clients: list[object] | None,
641
+ ) -> int:
552
642
  try:
553
- include_ports = True if args.format == "mkdocs" else None
554
- devices, _gateways, topology = _build_topology_data(
643
+ _raw_devices, devices = _load_devices_data(
555
644
  args,
556
645
  config,
557
646
  site,
558
- include_ports=include_ports,
559
647
  raw_devices_override=mock_devices,
560
648
  )
561
649
  except Exception as exc:
562
- logging.error("Failed to build topology: %s", exc)
650
+ logging.error("Failed to load devices: %s", exc)
563
651
  return 1
652
+ if mock_clients is None:
653
+ if config is None:
654
+ logging.error("Mock data required for client rendering")
655
+ return 2
656
+ clients = list(fetch_clients(config, site=site))
657
+ else:
658
+ clients = mock_clients
659
+ content = render_lldp_md(
660
+ devices,
661
+ clients=clients,
662
+ include_ports=args.include_ports,
663
+ show_clients=args.include_clients,
664
+ client_mode=args.client_scope,
665
+ )
666
+ write_output(content, output_path=args.output, stdout=args.stdout)
667
+ return 0
668
+
669
+
670
+ def _render_standard_format(
671
+ args: argparse.Namespace,
672
+ *,
673
+ config: Config | None,
674
+ site: str,
675
+ mock_devices: list[object] | None,
676
+ mock_clients: list[object] | None,
677
+ mermaid_theme: MermaidTheme,
678
+ svg_theme: SvgTheme,
679
+ ) -> int:
680
+ topology_result = _load_topology_for_render(
681
+ args,
682
+ config=config,
683
+ site=site,
684
+ mock_devices=mock_devices,
685
+ )
686
+ if topology_result is None:
687
+ return 1
688
+ devices, topology = topology_result
564
689
 
565
690
  if args.format == "mermaid":
566
691
  content = _render_mermaid_output(
@@ -573,25 +698,17 @@ def main(argv: list[str] | None = None) -> int:
573
698
  clients_override=mock_clients,
574
699
  )
575
700
  elif args.format == "mkdocs":
576
- if args.mkdocs_sidebar_legend and not args.output:
577
- logging.error("--mkdocs-sidebar-legend requires --output")
578
- return 2
579
- if args.mkdocs_sidebar_legend:
580
- _write_mkdocs_sidebar_assets(args.output)
581
- port_map = build_port_map(devices, only_unifi=args.only_unifi)
582
- client_ports = None
583
- if args.include_clients:
584
- if mock_clients is None:
585
- if config is None:
586
- logging.error("Mock data required for client rendering")
587
- return 2
588
- clients = list(fetch_clients(config, site=site))
589
- else:
590
- clients = mock_clients
591
- client_ports = build_client_port_map(devices, clients, client_mode=args.client_scope)
592
- content = _render_mkdocs_output(
593
- args, devices, topology, config, site, mermaid_theme, port_map, client_ports
701
+ content = _render_mkdocs_format(
702
+ args,
703
+ devices=devices,
704
+ topology=topology,
705
+ config=config,
706
+ site=site,
707
+ mermaid_theme=mermaid_theme,
708
+ mock_clients=mock_clients,
594
709
  )
710
+ if content is None:
711
+ return 2
595
712
  elif args.format in {"svg", "svg-iso"}:
596
713
  content = _render_svg_output(
597
714
  args,
@@ -610,5 +727,133 @@ def main(argv: list[str] | None = None) -> int:
610
727
  return 0
611
728
 
612
729
 
730
+ def _load_topology_for_render(
731
+ args: argparse.Namespace,
732
+ *,
733
+ config: Config | None,
734
+ site: str,
735
+ mock_devices: list[object] | None,
736
+ ) -> tuple[list[Device], TopologyResult] | None:
737
+ try:
738
+ include_ports = True if args.format == "mkdocs" else None
739
+ devices, _gateways, topology = _build_topology_data(
740
+ args,
741
+ config,
742
+ site,
743
+ include_ports=include_ports,
744
+ raw_devices_override=mock_devices,
745
+ )
746
+ except Exception as exc:
747
+ logging.error("Failed to build topology: %s", exc)
748
+ return None
749
+ return devices, topology
750
+
751
+
752
+ def _load_dark_mermaid_theme() -> MermaidTheme | None:
753
+ dark_theme_path = Path(__file__).resolve().parents[1] / "assets" / "themes" / "dark.yaml"
754
+ try:
755
+ dark_theme, _ = load_theme(dark_theme_path)
756
+ except Exception as exc: # noqa: BLE001
757
+ logger.warning("Failed to load dark theme: %s", exc)
758
+ return None
759
+ return dark_theme
760
+
761
+
762
+ def _resolve_mkdocs_client_ports(
763
+ args: argparse.Namespace,
764
+ devices: list[Device],
765
+ config: Config | None,
766
+ site: str,
767
+ mock_clients: list[object] | None,
768
+ ) -> tuple[ClientPortMap | None, int | None]:
769
+ if not args.include_clients:
770
+ return None, None
771
+ if mock_clients is None:
772
+ if config is None:
773
+ return None, 2
774
+ clients = list(fetch_clients(config, site=site))
775
+ else:
776
+ clients = mock_clients
777
+ client_ports = build_client_port_map(devices, clients, client_mode=args.client_scope)
778
+ return client_ports, None
779
+
780
+
781
+ def _render_mkdocs_format(
782
+ args: argparse.Namespace,
783
+ *,
784
+ devices: list[Device],
785
+ topology: TopologyResult,
786
+ config: Config | None,
787
+ site: str,
788
+ mermaid_theme: MermaidTheme,
789
+ mock_clients: list[object] | None,
790
+ ) -> str | None:
791
+ if args.mkdocs_sidebar_legend and not args.output:
792
+ logging.error("--mkdocs-sidebar-legend requires --output")
793
+ return None
794
+ if args.mkdocs_sidebar_legend:
795
+ _write_mkdocs_sidebar_assets(args.output)
796
+ port_map = build_port_map(devices, only_unifi=args.only_unifi)
797
+ client_ports, error_code = _resolve_mkdocs_client_ports(
798
+ args,
799
+ devices,
800
+ config,
801
+ site,
802
+ mock_clients,
803
+ )
804
+ if error_code is not None:
805
+ logging.error("Mock data required for client rendering")
806
+ return None
807
+ dark_mermaid_theme = _load_dark_mermaid_theme() if args.mkdocs_dual_theme else None
808
+ return _render_mkdocs_output(
809
+ args,
810
+ devices,
811
+ topology,
812
+ mermaid_theme,
813
+ port_map,
814
+ client_ports,
815
+ args.mkdocs_timestamp_zone,
816
+ dark_mermaid_theme,
817
+ )
818
+
819
+
820
+ def main(argv: list[str] | None = None) -> int:
821
+ logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
822
+ args = _parse_args(argv)
823
+ mock_result = _handle_generate_mock(args)
824
+ if mock_result is not None:
825
+ return mock_result
826
+ try:
827
+ config, site, mock_devices, mock_clients = _load_runtime_context(args)
828
+ except ValueError as exc:
829
+ logging.error(str(exc))
830
+ return 2
831
+ mermaid_theme, svg_theme = resolve_themes(args.theme_file)
832
+
833
+ if args.legend_only:
834
+ content = _render_legend_only(args, mermaid_theme)
835
+ write_output(content, output_path=args.output, stdout=args.stdout)
836
+ return 0
837
+
838
+ if args.format == "lldp-md":
839
+ return _render_lldp_format(
840
+ args,
841
+ config=config,
842
+ site=site,
843
+ mock_devices=mock_devices,
844
+ mock_clients=mock_clients,
845
+ )
846
+
847
+ return _render_standard_format(
848
+ args,
849
+ config=config,
850
+ site=site,
851
+ mock_devices=mock_devices,
852
+ mock_clients=mock_clients,
853
+ mermaid_theme=mermaid_theme,
854
+ svg_theme=svg_theme,
855
+ )
856
+
857
+
613
858
  if __name__ == "__main__":
614
859
  raise SystemExit(main())
@@ -4,22 +4,32 @@ from __future__ import annotations
4
4
 
5
5
  import json
6
6
  import logging
7
+ from collections.abc import Iterable, Sequence
7
8
 
8
- from ..model.topology import group_devices_by_type
9
+ from ..model.topology import Device, group_devices_by_type
9
10
 
10
11
  logger = logging.getLogger(__name__)
11
12
 
12
13
 
13
- def device_to_dict(device: object) -> dict:
14
- if hasattr(device, "to_dict"):
15
- return device.to_dict()
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)}
16
21
  if hasattr(device, "__dict__"):
17
22
  return dict(device.__dict__)
23
+ if isinstance(device, dict):
24
+ return dict(device)
18
25
  return {"repr": repr(device)}
19
26
 
20
27
 
21
28
  def debug_dump_devices(
22
- raw_devices: list[object], normalized: list[object], *, sample_count: int
29
+ raw_devices: Sequence[object],
30
+ normalized: Iterable[Device],
31
+ *,
32
+ sample_count: int,
23
33
  ) -> None:
24
34
  name_to_device: dict[str, object] = {}
25
35
  for device in raw_devices:
@@ -2,12 +2,31 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import os
5
6
  import sys
7
+ import tempfile
6
8
  from pathlib import Path
7
9
 
8
10
 
9
11
  def write_output(content: str, *, output_path: str | None, stdout: bool) -> None:
10
12
  if output_path:
11
- Path(output_path).write_text(content, encoding="utf-8")
13
+ _write_atomic(Path(output_path), content)
12
14
  if stdout or not output_path:
13
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)