unifi-network-maps 1.4.11__tar.gz → 1.4.13__tar.gz

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 (132) hide show
  1. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/CHANGELOG.md +20 -2
  2. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/PKG-INFO +3 -3
  3. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/README.md +1 -1
  4. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/pyproject.toml +3 -3
  5. unifi_network_maps-1.4.13/src/unifi_network_maps/__init__.py +1 -0
  6. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/adapters/config.py +6 -2
  7. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/adapters/unifi.py +17 -7
  8. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/cli/main.py +46 -3
  9. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/cli/render.py +23 -5
  10. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/cli/runtime.py +7 -1
  11. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/io/export.py +11 -2
  12. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/io/mkdocs_assets.py +4 -2
  13. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/io/mock_data.py +4 -2
  14. unifi_network_maps-1.4.13/src/unifi_network_maps/io/paths.py +197 -0
  15. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/model/topology.py +91 -7
  16. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/render/device_ports_md.py +27 -18
  17. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/render/lldp_md.py +104 -5
  18. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/render/theme.py +2 -1
  19. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps.egg-info/PKG-INFO +3 -3
  20. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps.egg-info/SOURCES.txt +2 -0
  21. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps.egg-info/requires.txt +1 -1
  22. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/tests/test_clients.py +65 -0
  23. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/tests/test_device_ports_md.py +36 -0
  24. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/tests/test_lldp_md.py +167 -1
  25. unifi_network_maps-1.4.13/tests/test_mkdocs.py +113 -0
  26. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/tests/test_topology.py +162 -0
  27. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/tests/test_unifi.py +102 -0
  28. unifi_network_maps-1.4.11/src/unifi_network_maps/__init__.py +0 -1
  29. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/CONTRIBUTING.md +0 -0
  30. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/LICENSE +0 -0
  31. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/LICENSES.md +0 -0
  32. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/MANIFEST.in +0 -0
  33. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/RELEASING.md +0 -0
  34. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/SECURITY.md +0 -0
  35. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/setup.cfg +0 -0
  36. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/__main__.py +0 -0
  37. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/adapters/__init__.py +0 -0
  38. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/__init__.py +0 -0
  39. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/__init__.py +0 -0
  40. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/access-point.svg +0 -0
  41. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/isometric/ISOPACKS_LICENSE +0 -0
  42. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/isometric/block.svg +0 -0
  43. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/isometric/cache.svg +0 -0
  44. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/isometric/cardterminal.svg +0 -0
  45. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/isometric/cloud.svg +0 -0
  46. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/isometric/cronjob.svg +0 -0
  47. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/isometric/cube.svg +0 -0
  48. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/isometric/desktop.svg +0 -0
  49. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/isometric/diamond.svg +0 -0
  50. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/isometric/dns.svg +0 -0
  51. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/isometric/document.svg +0 -0
  52. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/isometric/firewall.svg +0 -0
  53. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/isometric/function-module.svg +0 -0
  54. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/isometric/image.svg +0 -0
  55. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/isometric/laptop.svg +0 -0
  56. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/isometric/loadbalancer.svg +0 -0
  57. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/isometric/lock.svg +0 -0
  58. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/isometric/mail.svg +0 -0
  59. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/isometric/mailmultiple.svg +0 -0
  60. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/isometric/mobiledevice.svg +0 -0
  61. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/isometric/office.svg +0 -0
  62. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/isometric/package-module.svg +0 -0
  63. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/isometric/paymentcard.svg +0 -0
  64. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/isometric/plane.svg +0 -0
  65. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/isometric/printer.svg +0 -0
  66. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/isometric/pyramid.svg +0 -0
  67. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/isometric/queue.svg +0 -0
  68. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/isometric/router.svg +0 -0
  69. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/isometric/server.svg +0 -0
  70. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/isometric/speech.svg +0 -0
  71. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/isometric/sphere.svg +0 -0
  72. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/isometric/storage.svg +0 -0
  73. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/isometric/switch-module.svg +0 -0
  74. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/isometric/tower.svg +0 -0
  75. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/isometric/truck-2.svg +0 -0
  76. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/isometric/truck.svg +0 -0
  77. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/isometric/user.svg +0 -0
  78. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/isometric/vm.svg +0 -0
  79. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/laptop.svg +0 -0
  80. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/router-network.svg +0 -0
  81. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/server-network.svg +0 -0
  82. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/server.svg +0 -0
  83. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/themes/dark.yaml +0 -0
  84. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/themes/default.yaml +0 -0
  85. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/cli/__init__.py +0 -0
  86. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/cli/__main__.py +0 -0
  87. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/cli/args.py +0 -0
  88. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/io/__init__.py +0 -0
  89. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/io/debug.py +0 -0
  90. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/io/mock_generate.py +0 -0
  91. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/model/__init__.py +0 -0
  92. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/model/labels.py +0 -0
  93. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/model/lldp.py +0 -0
  94. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/model/mock.py +0 -0
  95. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/model/ports.py +0 -0
  96. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/render/__init__.py +0 -0
  97. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/render/legend.py +0 -0
  98. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/render/markdown_tables.py +0 -0
  99. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/render/mermaid.py +0 -0
  100. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/render/mermaid_theme.py +0 -0
  101. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/render/mkdocs.py +0 -0
  102. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/render/svg.py +0 -0
  103. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/render/svg_theme.py +0 -0
  104. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/render/templates/device_port_block.md.j2 +0 -0
  105. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/render/templates/legend_compact.html.j2 +0 -0
  106. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/render/templates/lldp_device_section.md.j2 +0 -0
  107. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/render/templates/markdown_section.md.j2 +0 -0
  108. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/render/templates/mermaid_legend.mmd.j2 +0 -0
  109. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/render/templates/mkdocs_document.md.j2 +0 -0
  110. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/render/templates/mkdocs_dual_theme_style.html.j2 +0 -0
  111. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/render/templates/mkdocs_html_block.html.j2 +0 -0
  112. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/render/templates/mkdocs_legend.css.j2 +0 -0
  113. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/render/templates/mkdocs_legend.js.j2 +0 -0
  114. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/render/templates/mkdocs_mermaid_block.md.j2 +0 -0
  115. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps/render/templating.py +0 -0
  116. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps.egg-info/dependency_links.txt +0 -0
  117. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps.egg-info/entry_points.txt +0 -0
  118. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/src/unifi_network_maps.egg-info/top_level.txt +0 -0
  119. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/tests/test_cli.py +0 -0
  120. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/tests/test_config.py +0 -0
  121. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/tests/test_contract_unifi.py +0 -0
  122. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/tests/test_contract_unifi_live.py +0 -0
  123. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/tests/test_debug.py +0 -0
  124. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/tests/test_export.py +0 -0
  125. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/tests/test_groups.py +0 -0
  126. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/tests/test_labels.py +0 -0
  127. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/tests/test_lldp.py +0 -0
  128. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/tests/test_mermaid.py +0 -0
  129. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/tests/test_mock_generate.py +0 -0
  130. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/tests/test_svg.py +0 -0
  131. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/tests/test_svg_iso.py +0 -0
  132. {unifi_network_maps-1.4.11 → unifi_network_maps-1.4.13}/tests/test_theme.py +0 -0
@@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.4.13] - 2026-01-25
9
+ ### Fixed
10
+ - Path Traversal Vulnerability in File Operations
11
+ - Cache Directory Symlink Attack vector
12
+
13
+ ### Changed
14
+ - Improved escaping in Markdown Output
15
+ - Made logging less chatty, moved messages to debug level
16
+
17
+ ## [1.4.12] - 2026-01-21
18
+ ### Added
19
+ - Filter UniFi clients with --only-unifi, and not only neighbors
20
+
21
+ ### Fixed
22
+ - inconsistencies in --only-unifi
23
+
8
24
  ## [1.4.11] - 2026-01-19
9
25
  ### Added
10
26
  - Add data-edge-left/right attributes to SVG paths
@@ -176,8 +192,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
176
192
  - Introduced SVG renderer and tree layout fixes.
177
193
  - Increased test coverage and added coverage tooling.
178
194
 
179
- [Unreleased]: https://github.com/merlijntishauser/unifi-network-maps/compare/v1.4.11...HEAD
180
- [1.4.11]:https://github.com/merlijntishauser/unifi-network-maps/compare/v1.4.10...v1.4.111
195
+ [Unreleased]: https://github.com/merlijntishauser/unifi-network-maps/compare/v1.4.13...HEAD
196
+ [1.4.13]:https://github.com/merlijntishauser/unifi-network-maps/compare/v1.4.11...v1.4.13
197
+ [1.4.12]:https://github.com/merlijntishauser/unifi-network-maps/compare/v1.4.11...v1.4.12
198
+ [1.4.11]:https://github.com/merlijntishauser/unifi-network-maps/compare/v1.4.10...v1.4.11
181
199
  [1.4.10]:https://github.com/merlijntishauser/unifi-network-maps/compare/v1.4.9...v1.4.10
182
200
  [1.4.9]: https://github.com/merlijntishauser/unifi-network-maps/compare/v1.4.8...v1.4.9
183
201
  [1.4.8]: https://github.com/merlijntishauser/unifi-network-maps/compare/v1.4.7...v1.4.8
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: unifi-network-maps
3
- Version: 1.4.11
3
+ Version: 1.4.13
4
4
  Summary: Dynamic UniFi -> network maps in mermaid or svg
5
5
  Author: Merlijn
6
6
  License-Expression: MIT
@@ -32,7 +32,7 @@ Requires-Dist: pre-commit==4.5.1; extra == "dev"
32
32
  Requires-Dist: pytest==9.0.2; extra == "dev"
33
33
  Requires-Dist: pytest-cov==7.0.0; extra == "dev"
34
34
  Requires-Dist: pyright==1.1.408; extra == "dev"
35
- Requires-Dist: ruff==0.14.13; extra == "dev"
35
+ Requires-Dist: ruff==0.14.14; extra == "dev"
36
36
  Dynamic: license-file
37
37
 
38
38
  # unifi-network-maps
@@ -223,7 +223,7 @@ Functional:
223
223
  - `--include-ports`: show port labels (Mermaid shows both ends; SVG shows compact labels).
224
224
  - `--include-clients`: add active wired clients as leaf nodes.
225
225
  - `--client-scope wired|wireless|all`: which client types to include (default wired).
226
- - `--only-unifi`: only include neighbors that are UniFi devices.
226
+ - `--only-unifi`: only include neighbors that are UniFi devices; when clients are included, filters to UniFi-managed clients (by explicit UniFi flags or vendor/OUI).
227
227
  - `--no-cache`: disable UniFi API cache reads and writes.
228
228
 
229
229
  Mermaid:
@@ -186,7 +186,7 @@ Functional:
186
186
  - `--include-ports`: show port labels (Mermaid shows both ends; SVG shows compact labels).
187
187
  - `--include-clients`: add active wired clients as leaf nodes.
188
188
  - `--client-scope wired|wireless|all`: which client types to include (default wired).
189
- - `--only-unifi`: only include neighbors that are UniFi devices.
189
+ - `--only-unifi`: only include neighbors that are UniFi devices; when clients are included, filters to UniFi-managed clients (by explicit UniFi flags or vendor/OUI).
190
190
  - `--no-cache`: disable UniFi API cache reads and writes.
191
191
 
192
192
  Mermaid:
@@ -1,10 +1,10 @@
1
1
  [build-system]
2
- requires = ["setuptools==80.9.0", "wheel==0.45.1"]
2
+ requires = ["setuptools==80.10.1", "wheel==0.46.3"]
3
3
  build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "unifi-network-maps"
7
- version = "1.4.11"
7
+ version = "1.4.13"
8
8
  description = "Dynamic UniFi -> network maps in mermaid or svg"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -44,7 +44,7 @@ dev = [
44
44
  "pytest==9.0.2",
45
45
  "pytest-cov==7.0.0",
46
46
  "pyright==1.1.408",
47
- "ruff==0.14.13"
47
+ "ruff==0.14.14"
48
48
  ]
49
49
 
50
50
  [project.scripts]
@@ -0,0 +1 @@
1
+ __version__ = "1.4.13"
@@ -4,6 +4,9 @@ from __future__ import annotations
4
4
 
5
5
  import os
6
6
  from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ from ..io.paths import resolve_env_file
7
10
 
8
11
 
9
12
  def _parse_bool(value: str | None, default: bool = True) -> bool:
@@ -26,13 +29,14 @@ class Config:
26
29
  verify_ssl: bool
27
30
 
28
31
  @classmethod
29
- def from_env(cls, *, env_file: str | None = None) -> Config:
32
+ def from_env(cls, *, env_file: str | Path | None = None) -> Config:
30
33
  if env_file:
31
34
  try:
32
35
  from dotenv import load_dotenv
33
36
  except ImportError:
34
37
  raise ValueError("python-dotenv required for --env-file") from None
35
- load_dotenv(dotenv_path=env_file)
38
+ env_path = resolve_env_file(env_file)
39
+ load_dotenv(dotenv_path=env_path)
36
40
  url = os.environ.get("UNIFI_URL", "").strip()
37
41
  site = os.environ.get("UNIFI_SITE", "default").strip()
38
42
  user = os.environ.get("UNIFI_USER", "").strip()
@@ -7,6 +7,7 @@ import json
7
7
  import logging
8
8
  import os
9
9
  import stat
10
+ import tempfile
10
11
  import time
11
12
  from collections.abc import Callable, Iterator, Sequence
12
13
  from concurrent.futures import ThreadPoolExecutor
@@ -15,6 +16,7 @@ from contextlib import contextmanager
15
16
  from pathlib import Path
16
17
  from typing import IO, TYPE_CHECKING
17
18
 
19
+ from ..io.paths import resolve_cache_dir
18
20
  from .config import Config
19
21
 
20
22
  if TYPE_CHECKING:
@@ -24,7 +26,15 @@ logger = logging.getLogger(__name__)
24
26
 
25
27
 
26
28
  def _cache_dir() -> Path:
27
- return Path(os.environ.get("UNIFI_CACHE_DIR", ".cache/unifi_network_maps"))
29
+ default_dir = ".cache/unifi_network_maps"
30
+ if os.environ.get("PYTEST_CURRENT_TEST"):
31
+ default_dir = str(Path(tempfile.gettempdir()) / f"unifi_network_maps_pytest_{os.getpid()}")
32
+ value = os.environ.get("UNIFI_CACHE_DIR", default_dir)
33
+ try:
34
+ return resolve_cache_dir(value)
35
+ except ValueError as exc:
36
+ logger.warning("Invalid UNIFI_CACHE_DIR (%s); using default: %s", value, exc)
37
+ return resolve_cache_dir(".cache/unifi_network_maps")
28
38
 
29
39
 
30
40
  def _device_attr(device: object, name: str) -> object | None:
@@ -377,13 +387,13 @@ def fetch_devices(
377
387
  cached = None
378
388
  stale_cached, cache_age = None, None
379
389
  if cached is not None:
380
- logger.info("Using cached devices (%d)", len(cached))
390
+ logger.debug("Using cached devices (%d)", len(cached))
381
391
  return cached
382
392
 
383
393
  try:
384
394
  controller = _init_controller(config, is_udm_pro=True)
385
395
  except UnifiAuthenticationError:
386
- logger.info("UDM Pro authentication failed, retrying legacy auth")
396
+ logger.debug("UDM Pro authentication failed, retrying legacy auth")
387
397
  controller = _init_controller(config, is_udm_pro=False)
388
398
 
389
399
  def _fetch() -> Sequence[object]:
@@ -402,7 +412,7 @@ def fetch_devices(
402
412
  raise
403
413
  if use_cache:
404
414
  _save_cache(cache_path, _serialize_devices_for_cache(devices))
405
- logger.info("Fetched %d devices", len(devices))
415
+ logger.debug("Fetched %d devices", len(devices))
406
416
  return devices
407
417
 
408
418
 
@@ -428,13 +438,13 @@ def fetch_clients(
428
438
  cached = None
429
439
  stale_cached, cache_age = None, None
430
440
  if cached is not None:
431
- logger.info("Using cached clients (%d)", len(cached))
441
+ logger.debug("Using cached clients (%d)", len(cached))
432
442
  return cached
433
443
 
434
444
  try:
435
445
  controller = _init_controller(config, is_udm_pro=True)
436
446
  except UnifiAuthenticationError:
437
- logger.info("UDM Pro authentication failed, retrying legacy auth")
447
+ logger.debug("UDM Pro authentication failed, retrying legacy auth")
438
448
  controller = _init_controller(config, is_udm_pro=False)
439
449
 
440
450
  def _fetch() -> Sequence[object]:
@@ -453,5 +463,5 @@ def fetch_clients(
453
463
  raise
454
464
  if use_cache:
455
465
  _save_cache(cache_path, clients)
456
- logger.info("Fetched %d clients", len(clients))
466
+ logger.debug("Fetched %d clients", len(clients))
457
467
  return clients
@@ -4,10 +4,17 @@ from __future__ import annotations
4
4
 
5
5
  import argparse
6
6
  import logging
7
+ from pathlib import Path
7
8
 
8
9
  from ..adapters.config import Config
9
10
  from ..io.export import write_output
10
11
  from ..io.mock_data import load_mock_data
12
+ from ..io.paths import (
13
+ resolve_env_file,
14
+ resolve_mock_data_path,
15
+ resolve_output_path,
16
+ resolve_theme_path,
17
+ )
11
18
  from ..render.legend import render_legend_only, resolve_legend_style
12
19
  from ..render.theme import resolve_themes
13
20
  from .args import build_parser
@@ -16,7 +23,7 @@ from .render import render_lldp_format, render_standard_format
16
23
  logger = logging.getLogger(__name__)
17
24
 
18
25
 
19
- def _load_dotenv(env_file: str | None = None) -> None:
26
+ def _load_dotenv(env_file: str | Path | None = None) -> None:
20
27
  try:
21
28
  from dotenv import load_dotenv
22
29
  except ImportError:
@@ -30,6 +37,36 @@ def _parse_args(argv: list[str] | None) -> argparse.Namespace:
30
37
  return parser.parse_args(argv)
31
38
 
32
39
 
40
+ class _DowngradeInfoToDebugFilter(logging.Filter):
41
+ def filter(self, record: logging.LogRecord) -> bool:
42
+ if record.name.startswith("unifi_controller_api") and record.levelno == logging.INFO:
43
+ record.levelno = logging.DEBUG
44
+ record.levelname = logging.getLevelName(logging.DEBUG)
45
+ return True
46
+
47
+
48
+ def _downgrade_unifi_controller_logs() -> logging.Filter:
49
+ return _DowngradeInfoToDebugFilter()
50
+
51
+
52
+ def _validate_paths(args: argparse.Namespace) -> bool:
53
+ try:
54
+ if args.env_file:
55
+ resolve_env_file(args.env_file)
56
+ if args.mock_data:
57
+ resolve_mock_data_path(args.mock_data, require_exists=False)
58
+ if args.theme_file:
59
+ resolve_theme_path(args.theme_file, require_exists=False)
60
+ if args.generate_mock:
61
+ resolve_output_path(args.generate_mock, format_name="mock")
62
+ if args.output:
63
+ resolve_output_path(args.output, format_name=args.format)
64
+ except ValueError as exc:
65
+ logging.error(str(exc))
66
+ return False
67
+ return True
68
+
69
+
33
70
  def _load_config(args: argparse.Namespace) -> Config | None:
34
71
  try:
35
72
  _load_dotenv(args.env_file)
@@ -59,7 +96,8 @@ def _handle_generate_mock(args: argparse.Namespace) -> int | None:
59
96
  wireless_client_count=max(0, args.mock_wireless_clients),
60
97
  )
61
98
  content = mock_payload_json(options)
62
- write_output(content, output_path=args.generate_mock, stdout=args.stdout)
99
+ output_kwargs = {"format_name": "mock"} if args.generate_mock else {}
100
+ write_output(content, output_path=args.generate_mock, stdout=args.stdout, **output_kwargs)
63
101
  return 0
64
102
 
65
103
 
@@ -81,7 +119,11 @@ def _load_runtime_context(
81
119
 
82
120
  def main(argv: list[str] | None = None) -> int:
83
121
  logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
122
+ for handler in logging.getLogger().handlers:
123
+ handler.addFilter(_downgrade_unifi_controller_logs())
84
124
  args = _parse_args(argv)
125
+ if not _validate_paths(args):
126
+ return 2
85
127
  mock_result = _handle_generate_mock(args)
86
128
  if mock_result is not None:
87
129
  return mock_result
@@ -107,7 +149,8 @@ def main(argv: list[str] | None = None) -> int:
107
149
  markdown=args.markdown,
108
150
  theme=mermaid_theme,
109
151
  )
110
- write_output(content, output_path=args.output, stdout=args.stdout)
152
+ output_kwargs = {"format_name": args.format} if args.output else {}
153
+ write_output(content, output_path=args.output, stdout=args.stdout, **output_kwargs)
111
154
  return 0
112
155
 
113
156
  if args.format == "lldp-md":
@@ -62,7 +62,12 @@ def render_mermaid_output(
62
62
  direction=args.direction,
63
63
  groups=groups,
64
64
  group_order=group_order,
65
- node_types=build_node_type_map(devices, clients, client_mode=args.client_scope),
65
+ node_types=build_node_type_map(
66
+ devices,
67
+ clients,
68
+ client_mode=args.client_scope,
69
+ only_unifi=args.only_unifi,
70
+ ),
66
71
  theme=mermaid_theme,
67
72
  )
68
73
  if args.markdown:
@@ -97,13 +102,23 @@ def render_svg_output(
97
102
 
98
103
  return render_svg_isometric(
99
104
  edges,
100
- node_types=build_node_type_map(devices, clients, client_mode=args.client_scope),
105
+ node_types=build_node_type_map(
106
+ devices,
107
+ clients,
108
+ client_mode=args.client_scope,
109
+ only_unifi=args.only_unifi,
110
+ ),
101
111
  options=options,
102
112
  theme=svg_theme,
103
113
  )
104
114
  return render_svg(
105
115
  edges,
106
- node_types=build_node_type_map(devices, clients, client_mode=args.client_scope),
116
+ node_types=build_node_type_map(
117
+ devices,
118
+ clients,
119
+ client_mode=args.client_scope,
120
+ only_unifi=args.only_unifi,
121
+ ),
107
122
  options=options,
108
123
  theme=svg_theme,
109
124
  )
@@ -190,8 +205,10 @@ def render_lldp_format(
190
205
  include_ports=args.include_ports,
191
206
  show_clients=args.include_clients,
192
207
  client_mode=args.client_scope,
208
+ only_unifi=args.only_unifi,
193
209
  )
194
- write_output(content, output_path=args.output, stdout=args.stdout)
210
+ output_kwargs = {"format_name": args.format} if args.output else {}
211
+ write_output(content, output_path=args.output, stdout=args.stdout, **output_kwargs)
195
212
  return 0
196
213
 
197
214
 
@@ -251,5 +268,6 @@ def render_standard_format(
251
268
  logging.error("Unsupported format: %s", args.format)
252
269
  return 2
253
270
 
254
- write_output(content, output_path=args.output, stdout=args.stdout)
271
+ output_kwargs = {"format_name": args.format} if args.output else {}
272
+ write_output(content, output_path=args.output, stdout=args.stdout, **output_kwargs)
255
273
  return 0
@@ -95,6 +95,7 @@ def build_edges_with_clients(
95
95
  device_index,
96
96
  include_ports=args.include_ports,
97
97
  client_mode=args.client_scope,
98
+ only_unifi=args.only_unifi,
98
99
  )
99
100
  return edges, clients
100
101
 
@@ -153,5 +154,10 @@ def resolve_mkdocs_client_ports(
153
154
  clients = list(fetch_clients(config, site=site))
154
155
  else:
155
156
  clients = mock_clients
156
- client_ports = build_client_port_map(devices, clients, client_mode=args.client_scope)
157
+ client_ports = build_client_port_map(
158
+ devices,
159
+ clients,
160
+ client_mode=args.client_scope,
161
+ only_unifi=args.only_unifi,
162
+ )
157
163
  return client_ports, None
@@ -7,10 +7,19 @@ import sys
7
7
  import tempfile
8
8
  from pathlib import Path
9
9
 
10
+ from .paths import resolve_output_path
10
11
 
11
- def write_output(content: str, *, output_path: str | None, stdout: bool) -> None:
12
+
13
+ def write_output(
14
+ content: str,
15
+ *,
16
+ output_path: str | Path | None,
17
+ stdout: bool,
18
+ format_name: str | None = None,
19
+ ) -> None:
12
20
  if output_path:
13
- _write_atomic(Path(output_path), content)
21
+ resolved = resolve_output_path(output_path, format_name=format_name)
22
+ _write_atomic(resolved, content)
14
23
  if stdout or not output_path:
15
24
  sys.stdout.write(content)
16
25
 
@@ -5,10 +5,12 @@ from __future__ import annotations
5
5
  from pathlib import Path
6
6
 
7
7
  from ..render.templating import render_template
8
+ from .paths import resolve_output_file
8
9
 
9
10
 
10
- def write_mkdocs_sidebar_assets(output_path: str) -> None:
11
- output_dir = Path(output_path).resolve().parent
11
+ def write_mkdocs_sidebar_assets(output_path: str | Path) -> None:
12
+ resolved = resolve_output_file(output_path, extensions=None, label="MkDocs output file")
13
+ output_dir = resolved.parent
12
14
  assets_dir = output_dir / "assets"
13
15
  assets_dir.mkdir(parents=True, exist_ok=True)
14
16
  (assets_dir / "legend.js").write_text(
@@ -3,7 +3,8 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
- from pathlib import Path
6
+
7
+ from .paths import resolve_mock_data_path
7
8
 
8
9
 
9
10
  def _as_list(value: object, name: str) -> list[object]:
@@ -15,7 +16,8 @@ def _as_list(value: object, name: str) -> list[object]:
15
16
 
16
17
 
17
18
  def load_mock_data(path: str) -> tuple[list[object], list[object]]:
18
- payload = json.loads(Path(path).read_text(encoding="utf-8"))
19
+ resolved = resolve_mock_data_path(path)
20
+ payload = json.loads(resolved.read_text(encoding="utf-8"))
19
21
  if not isinstance(payload, dict):
20
22
  raise ValueError("Mock data must be a JSON object")
21
23
  devices = _as_list(payload.get("devices"), "devices")
@@ -0,0 +1,197 @@
1
+ """Path validation helpers for user-supplied file system inputs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import tempfile
7
+ from collections.abc import Iterable
8
+ from pathlib import Path
9
+
10
+
11
+ def _safe_home_dir() -> Path | None:
12
+ try:
13
+ return Path.home().resolve()
14
+ except Exception:
15
+ return None
16
+
17
+
18
+ def _base_roots() -> list[Path]:
19
+ roots = [Path.cwd().resolve()]
20
+ home = _safe_home_dir()
21
+ if home:
22
+ roots.append(home)
23
+ try:
24
+ roots.append(Path(tempfile.gettempdir()).resolve())
25
+ except Exception:
26
+ pass
27
+ return roots
28
+
29
+
30
+ def _extra_roots_from_env() -> list[Path]:
31
+ extra = os.environ.get("UNIFI_ALLOWED_PATHS", "")
32
+ roots: list[Path] = []
33
+ if extra:
34
+ for raw in extra.split(os.pathsep):
35
+ raw = raw.strip()
36
+ if raw:
37
+ roots.append(Path(raw).expanduser().resolve())
38
+ return roots
39
+
40
+
41
+ def _allowed_roots() -> tuple[Path, ...]:
42
+ roots = _base_roots() + _extra_roots_from_env()
43
+ seen: set[str] = set()
44
+ unique: list[Path] = []
45
+ for root in roots:
46
+ key = str(root)
47
+ if key not in seen:
48
+ seen.add(key)
49
+ unique.append(root)
50
+ return tuple(unique)
51
+
52
+
53
+ def _resolve_user_path(path: str | Path) -> Path:
54
+ return Path(path).expanduser().resolve(strict=False)
55
+
56
+
57
+ def _ensure_within_allowed(path: Path, roots: Iterable[Path], *, label: str) -> None:
58
+ for root in roots:
59
+ try:
60
+ path.relative_to(root)
61
+ except ValueError:
62
+ continue
63
+ else:
64
+ return
65
+ root_list = ", ".join(str(root) for root in roots)
66
+ raise ValueError(f"{label} must be within: {root_list}")
67
+
68
+
69
+ def _ensure_no_symlink(path: Path, *, label: str) -> None:
70
+ if path.exists() and path.is_symlink():
71
+ raise ValueError(f"{label} must not be a symlink: {path}")
72
+
73
+
74
+ def _ensure_no_symlink_in_parents(path: Path, *, label: str) -> None:
75
+ for parent in path.parents:
76
+ if parent.exists() and parent.is_symlink():
77
+ raise ValueError(f"{label} parent must not be a symlink: {parent}")
78
+
79
+
80
+ def _normalize_extensions(extensions: Iterable[str]) -> set[str]:
81
+ normalized = set()
82
+ for ext in extensions:
83
+ ext = ext.strip().lower()
84
+ if not ext:
85
+ continue
86
+ if not ext.startswith("."):
87
+ ext = f".{ext}"
88
+ normalized.add(ext)
89
+ return normalized
90
+
91
+
92
+ def _ensure_extension(
93
+ path: Path,
94
+ extensions: Iterable[str] | None,
95
+ *,
96
+ label: str,
97
+ allow_missing: bool = False,
98
+ ) -> None:
99
+ if not extensions:
100
+ return
101
+ allowed = _normalize_extensions(extensions)
102
+ suffix = path.suffix.lower()
103
+ if not suffix:
104
+ if allow_missing:
105
+ return
106
+ raise ValueError(f"{label} must have one of: {', '.join(sorted(allowed))}")
107
+ if suffix not in allowed:
108
+ raise ValueError(f"{label} must have one of: {', '.join(sorted(allowed))}")
109
+
110
+
111
+ def resolve_input_file(
112
+ path: str | Path,
113
+ *,
114
+ extensions: Iterable[str] | None,
115
+ label: str,
116
+ require_exists: bool = True,
117
+ ) -> Path:
118
+ resolved = _resolve_user_path(path)
119
+ _ensure_within_allowed(resolved, _allowed_roots(), label=label)
120
+ _ensure_extension(resolved, extensions, label=label)
121
+ if require_exists:
122
+ if not resolved.exists():
123
+ raise ValueError(f"{label} does not exist: {resolved}")
124
+ if not resolved.is_file():
125
+ raise ValueError(f"{label} must be a file: {resolved}")
126
+ return resolved
127
+
128
+
129
+ def resolve_output_file(
130
+ path: str | Path,
131
+ *,
132
+ extensions: Iterable[str] | None,
133
+ label: str,
134
+ allow_missing_extension: bool = False,
135
+ ) -> Path:
136
+ resolved = _resolve_user_path(path)
137
+ _ensure_within_allowed(resolved, _allowed_roots(), label=label)
138
+ _ensure_extension(
139
+ resolved,
140
+ extensions,
141
+ label=label,
142
+ allow_missing=allow_missing_extension,
143
+ )
144
+ return resolved
145
+
146
+
147
+ def resolve_env_file(path: str | Path) -> Path:
148
+ resolved = _resolve_user_path(path)
149
+ _ensure_within_allowed(resolved, _allowed_roots(), label="Env file")
150
+ if not (resolved.name.startswith(".env") or resolved.name.endswith(".env")):
151
+ raise ValueError("Env file must end with .env")
152
+ if resolved.exists() and not resolved.is_file():
153
+ raise ValueError(f"Env file must be a file: {resolved}")
154
+ return resolved
155
+
156
+
157
+ def resolve_mock_data_path(path: str | Path, *, require_exists: bool = True) -> Path:
158
+ return resolve_input_file(
159
+ path,
160
+ extensions={".json"},
161
+ label="Mock data file",
162
+ require_exists=require_exists,
163
+ )
164
+
165
+
166
+ def resolve_theme_path(path: str | Path, *, require_exists: bool = True) -> Path:
167
+ return resolve_input_file(
168
+ path,
169
+ extensions={".yml", ".yaml"},
170
+ label="Theme file",
171
+ require_exists=require_exists,
172
+ )
173
+
174
+
175
+ def resolve_output_path(path: str | Path, *, format_name: str | None) -> Path:
176
+ extensions: set[str] | None
177
+ if format_name == "svg" or format_name == "svg-iso":
178
+ extensions = {".svg"}
179
+ elif format_name == "mock":
180
+ extensions = {".json"}
181
+ elif format_name == "mermaid":
182
+ extensions = {".md", ".mermaid", ".mmd"}
183
+ elif format_name == "lldp-md":
184
+ extensions = {".md"}
185
+ elif format_name == "mkdocs":
186
+ extensions = {".md"}
187
+ else:
188
+ extensions = None
189
+ return resolve_output_file(path, extensions=extensions, label="Output file")
190
+
191
+
192
+ def resolve_cache_dir(path: str | Path) -> Path:
193
+ resolved = _resolve_user_path(path)
194
+ _ensure_within_allowed(resolved, _allowed_roots(), label="Cache directory")
195
+ _ensure_no_symlink(resolved, label="Cache directory")
196
+ _ensure_no_symlink_in_parents(resolved, label="Cache directory")
197
+ return resolved