topolib 0.8.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 (51) hide show
  1. topolib/__init__.py +4 -0
  2. topolib/analysis/__init__.py +4 -0
  3. topolib/analysis/metrics.py +80 -0
  4. topolib/analysis/traffic_matrix.py +344 -0
  5. topolib/assets/AMRES.json +1265 -0
  6. topolib/assets/Abilene.json +285 -0
  7. topolib/assets/Bell_canada.json +925 -0
  8. topolib/assets/Brazil.json +699 -0
  9. topolib/assets/CESNET.json +657 -0
  10. topolib/assets/CORONET.json +1957 -0
  11. topolib/assets/China.json +1135 -0
  12. topolib/assets/DT-14.json +470 -0
  13. topolib/assets/DT-17.json +525 -0
  14. topolib/assets/DT-50.json +1515 -0
  15. topolib/assets/ES-30.json +967 -0
  16. topolib/assets/EURO-16.json +455 -0
  17. topolib/assets/FR-43.json +1277 -0
  18. topolib/assets/FUNET.json +317 -0
  19. topolib/assets/GCN-BG.json +855 -0
  20. topolib/assets/GRNET.json +1717 -0
  21. topolib/assets/HyperOne.json +255 -0
  22. topolib/assets/IT-21.json +649 -0
  23. topolib/assets/India.json +517 -0
  24. topolib/assets/JPN-12.json +331 -0
  25. topolib/assets/KOREN.json +287 -0
  26. topolib/assets/NORDUNet.json +783 -0
  27. topolib/assets/NSFNet.json +399 -0
  28. topolib/assets/PANEURO.json +757 -0
  29. topolib/assets/PAVLOV.json +465 -0
  30. topolib/assets/PLN-12.json +343 -0
  31. topolib/assets/SANReN.json +161 -0
  32. topolib/assets/SERBIA-MONTENEGRO.json +139 -0
  33. topolib/assets/Telefonica-21.json +637 -0
  34. topolib/assets/Turk_Telekom.json +551 -0
  35. topolib/assets/UKNet.json +685 -0
  36. topolib/assets/Vega_Telecom.json +819 -0
  37. topolib/elements/__init__.py +5 -0
  38. topolib/elements/link.py +121 -0
  39. topolib/elements/node.py +230 -0
  40. topolib/topology/__init__.py +4 -0
  41. topolib/topology/path.py +84 -0
  42. topolib/topology/topology.py +469 -0
  43. topolib/visualization/__init__.py +1 -0
  44. topolib/visualization/_qt_screenshot.py +103 -0
  45. topolib/visualization/_qt_window.py +78 -0
  46. topolib/visualization/_templates.py +101 -0
  47. topolib/visualization/mapview.py +316 -0
  48. topolib-0.8.0.dist-info/METADATA +148 -0
  49. topolib-0.8.0.dist-info/RECORD +51 -0
  50. topolib-0.8.0.dist-info/WHEEL +4 -0
  51. topolib-0.8.0.dist-info/licenses/LICENSE +22 -0
@@ -0,0 +1,469 @@
1
+ """
2
+ Topology class for optical network topologies.
3
+
4
+ This module defines the Topology class, representing a network topology with nodes and links,
5
+ and providing an adjacency matrix using numpy.
6
+
7
+ This file uses NetworkX (BSD 3-Clause License):
8
+ https://github.com/networkx/networkx/blob/main/LICENSE.txt
9
+ """
10
+
11
+ from typing import List, Optional, Any
12
+ from numpy.typing import NDArray
13
+
14
+
15
+ # Standard library imports
16
+ import json
17
+ import csv
18
+ from pathlib import Path
19
+
20
+ # Third-party imports
21
+ import numpy as np
22
+ import networkx as nx
23
+ import jsonschema
24
+
25
+ # Local imports
26
+ from topolib.elements.node import Node
27
+ from topolib.elements.link import Link
28
+
29
+
30
+ class Topology:
31
+ """
32
+ Represents a network topology with nodes and links.
33
+
34
+ :param nodes: Initial list of nodes (optional).
35
+ :type nodes: list[topolib.elements.node.Node] or None
36
+ :param links: Initial list of links (optional).
37
+ :type links: list[topolib.elements.link.Link] or None
38
+
39
+ :ivar nodes: List of nodes in the topology.
40
+ :vartype nodes: list[Node]
41
+ :ivar links: List of links in the topology.
42
+ :vartype links: list[Link]
43
+
44
+ **Examples**
45
+ >>> from topolib.elements.node import Node
46
+ >>> from topolib.elements.link import Link
47
+ >>> from topolib.topology import Topology
48
+ >>> n1 = Node(1, "A", 0.0, 0.0)
49
+ >>> n2 = Node(2, "B", 1.0, 1.0)
50
+ >>> l1 = Link(1, n1, n2, 10.0)
51
+ >>> topo = Topology(nodes=[n1, n2], links=[l1])
52
+ >>> topo.adjacency_matrix()
53
+ array([[0, 1],
54
+ [1, 0]])
55
+ """
56
+
57
+ def __init__(
58
+ self,
59
+ nodes: Optional[List[Node]] = None,
60
+ links: Optional[List[Link]] = None,
61
+ name: Optional[str] = None,
62
+ ):
63
+ """
64
+ Initialize a Topology object.
65
+
66
+ :param nodes: Initial list of nodes (optional).
67
+ :type nodes: list[topolib.elements.node.Node] or None
68
+ :param links: Initial list of links (optional).
69
+ :type links: list[topolib.elements.link.Link] or None
70
+ :param name: Name of the topology (optional).
71
+ :type name: str or None
72
+ """
73
+ self.nodes = nodes if nodes is not None else []
74
+ self.links = links if links is not None else []
75
+ self.name = name
76
+ # Internal NetworkX graph for algorithms and visualization
77
+ self._graph: nx.DiGraph[Any] = nx.DiGraph()
78
+ for node in self.nodes:
79
+ self._graph.add_node(node.id, node=node)
80
+ for link in self.links:
81
+ self._graph.add_edge(link.source.id, link.target.id, link=link)
82
+
83
+ @classmethod
84
+ def from_json(cls, json_path: str) -> "Topology":
85
+ """
86
+ Create a Topology object from a JSON file.
87
+
88
+ :param json_path: Path to the JSON file containing the topology.
89
+ :type json_path: str
90
+ :return: Topology instance loaded from the file.
91
+ :rtype: Topology
92
+ """
93
+ with open(json_path, "r", encoding="utf-8") as f:
94
+ data = json.load(f)
95
+
96
+ # Validation schema for the assets JSON format
97
+ topology_schema: dict[str, Any] = {
98
+ "type": "object",
99
+ "properties": {
100
+ "name": {"type": "string"},
101
+ "nodes": {
102
+ "type": "array",
103
+ "items": {
104
+ "type": "object",
105
+ "properties": {
106
+ "id": {"type": "integer"},
107
+ "name": {"type": "string"},
108
+ "weight": {"type": "number"},
109
+ "latitude": {"type": "number"},
110
+ "longitude": {"type": "number"},
111
+ "pop": {"type": "integer"},
112
+ "DC": {"type": "integer"},
113
+ "IXP": {"type": "integer"},
114
+ },
115
+ "required": ["id", "name", "latitude", "longitude"],
116
+ },
117
+ },
118
+ "links": {
119
+ "type": "array",
120
+ "items": {
121
+ "type": "object",
122
+ "properties": {
123
+ "id": {"type": "integer"},
124
+ "src": {"type": "integer"},
125
+ "dst": {"type": "integer"},
126
+ "length": {"type": "number"},
127
+ },
128
+ "required": ["id", "src", "dst", "length"],
129
+ },
130
+ },
131
+ },
132
+ "required": ["nodes", "links"],
133
+ }
134
+ try:
135
+ jsonschema.validate(instance=data, schema=topology_schema)
136
+ except jsonschema.ValidationError as e:
137
+ raise ValueError(f"Invalid topology JSON format: {e.message}")
138
+ nodes = [
139
+ Node(
140
+ n["id"],
141
+ n["name"],
142
+ n["latitude"],
143
+ n["longitude"],
144
+ n.get("weight", 0),
145
+ n.get("pop", 0),
146
+ n.get("dc", n.get("DC", 0)),
147
+ n.get("ixp", n.get("IXP", 0)),
148
+ )
149
+ for n in data["nodes"]
150
+ ]
151
+ # Crear un dict para mapear id a Node
152
+ node_dict = {n.id: n for n in nodes}
153
+ links = [
154
+ Link(l["id"], node_dict[l["src"]],
155
+ node_dict[l["dst"]], l["length"])
156
+ for l in data["links"]
157
+ ]
158
+ name = data.get("name", None)
159
+ return cls(nodes=nodes, links=links, name=name)
160
+
161
+ def add_node(self, node: Node) -> None:
162
+ """
163
+ Add a node to the topology.
164
+
165
+ :param node: Node to add.
166
+ :type node: Node
167
+ """
168
+ self.nodes.append(node)
169
+ self._graph.add_node(node.id, node=node)
170
+
171
+ def add_link(self, link: Link) -> None:
172
+ """
173
+ Add a link to the topology.
174
+
175
+ :param link: Link to add.
176
+ :type link: Link
177
+ """
178
+ self.links.append(link)
179
+ self._graph.add_edge(link.source.id, link.target.id, link=link)
180
+
181
+ def remove_node(self, node_id: int) -> None:
182
+ """
183
+ Remove a node and all its links by node id.
184
+
185
+ :param node_id: ID of the node to remove.
186
+ :type node_id: int
187
+ """
188
+ self.nodes = [n for n in self.nodes if n.id != node_id]
189
+ self.links = [
190
+ l for l in self.links if l.source.id != node_id and l.target.id != node_id
191
+ ]
192
+ self._graph.remove_node(node_id)
193
+
194
+ def remove_link(self, link_id: int) -> None:
195
+ """
196
+ Remove a link by its id.
197
+
198
+ :param link_id: ID of the link to remove.
199
+ :type link_id: int
200
+ """
201
+ # Find the link and remove from graph
202
+ link = next((l for l in self.links if l.id == link_id), None)
203
+ if link:
204
+ self._graph.remove_edge(link.source.id, link.target.id)
205
+ self.links = [l for l in self.links if l.id != link_id]
206
+
207
+ def adjacency_matrix(self) -> NDArray[np.int_]:
208
+ """
209
+ Return the adjacency matrix of the topology as a numpy array.
210
+
211
+ :return: Adjacency matrix (1 if connected, 0 otherwise).
212
+ :rtype: numpy.ndarray
213
+
214
+ **Example**
215
+ >>> topo.adjacency_matrix()
216
+ array([[0, 1],
217
+ [1, 0]])
218
+ """
219
+ # Usa NetworkX para obtener la matriz de adyacencia
220
+ if not self.nodes:
221
+ return np.zeros((0, 0), dtype=int)
222
+ node_ids = [n.id for n in self.nodes]
223
+ mat = nx.to_numpy_array(self._graph, nodelist=node_ids, dtype=int)
224
+ return mat
225
+
226
+ def export_to_json(self, file_path: str) -> None:
227
+ """
228
+ Export the current topology to the JSON format used in the assets folder.
229
+
230
+ :param file_path: Path where the JSON file will be saved.
231
+ :type file_path: str
232
+
233
+ Example usage::
234
+
235
+ topo.export_to_json("/path/output.json")
236
+
237
+ Example output format::
238
+
239
+ {
240
+ "name": "Abilene",
241
+ "nodes": [
242
+ {
243
+ "id": 0,
244
+ "name": "Seattle",
245
+ ...
246
+ }
247
+ ],
248
+ "links": [
249
+ {
250
+ "id": 0,
251
+ "src": 0,
252
+ "dst": 1,
253
+ "length": 1482.26
254
+ }
255
+ ]
256
+ }
257
+ """
258
+ nodes_list: list[dict[str, Any]] = []
259
+ for n in self.nodes:
260
+ node_dict: dict[str, Any] = {
261
+ "id": n.id,
262
+ "name": getattr(n, "name", None),
263
+ "weight": getattr(n, "weight", 0),
264
+ "latitude": getattr(n, "latitude", None),
265
+ "longitude": getattr(n, "longitude", None),
266
+ "pop": getattr(n, "pop", 0),
267
+ "DC": getattr(n, "dc", getattr(n, "DC", 0)),
268
+ "IXP": getattr(n, "ixp", getattr(n, "IXP", 0)),
269
+ }
270
+ nodes_list.append(node_dict)
271
+ links_list: list[dict[str, Any]] = []
272
+ for l in self.links:
273
+ link_dict: dict[str, Any] = {
274
+ "id": l.id,
275
+ "src": l.source.id,
276
+ "dst": l.target.id,
277
+ "length": getattr(l, "length", None),
278
+ }
279
+ links_list.append(link_dict)
280
+ data: dict[str, Any] = {
281
+ "name": self.name if self.name else "Topology",
282
+ "nodes": nodes_list,
283
+ "links": links_list,
284
+ }
285
+ with open(file_path, "w", encoding="utf-8") as f:
286
+ json.dump(data, f, indent=4, ensure_ascii=False)
287
+
288
+ def export_to_csv(self, filename_prefix: str) -> None:
289
+ """
290
+ Export the topology to two CSV files: one for nodes and one for links.
291
+ The files will be named as <filename_prefix>_nodes.csv and <filename_prefix>_links.csv.
292
+
293
+ :param filename_prefix: Prefix for the output files (e.g., 'topology1').
294
+ :type filename_prefix: str
295
+
296
+ Example:
297
+ >>> topo.export_to_csv("mytopo")
298
+ # Generates 'mytopo_nodes.csv' and 'mytopo_links.csv'
299
+ """
300
+ # Export nodes
301
+ nodes_path = f"{filename_prefix}_nodes.csv"
302
+ with open(nodes_path, "w", newline="", encoding="utf-8") as f:
303
+ writer = csv.writer(f)
304
+ # Header
305
+ writer.writerow(
306
+ ["id", "name", "weight", "latitude",
307
+ "longitude", "pop", "DC", "IXP"]
308
+ )
309
+ for n in self.nodes:
310
+ writer.writerow(
311
+ [
312
+ n.id,
313
+ getattr(n, "name", None),
314
+ getattr(n, "weight", 0),
315
+ getattr(n, "latitude", None),
316
+ getattr(n, "longitude", None),
317
+ getattr(n, "pop", 0),
318
+ getattr(n, "dc", getattr(n, "DC", 0)),
319
+ getattr(n, "ixp", getattr(n, "IXP", 0)),
320
+ ]
321
+ )
322
+ # Export links
323
+ links_path = f"{filename_prefix}_links.csv"
324
+ with open(links_path, "w", newline="", encoding="utf-8") as f:
325
+ writer = csv.writer(f)
326
+ writer.writerow(["id", "src", "dst", "length"])
327
+ for l in self.links:
328
+ writer.writerow(
329
+ [
330
+ l.id,
331
+ l.source.id,
332
+ l.target.id,
333
+ getattr(l, "length", None),
334
+ ]
335
+ )
336
+
337
+ def export_to_flexnetsim_json(self, file_path: str, slots: int) -> None:
338
+ """
339
+ Export the current topology to a JSON file compatible with Flex Net Sim.
340
+
341
+ :param file_path: Path where the JSON file will be saved.
342
+ :type file_path: str
343
+ :param slots: Number of slots for each link.
344
+ :type slots: int
345
+
346
+ The generated format includes the following fields:
347
+ - alias: short name of the topology (uses self.name if available)
348
+ - name: full name of the topology (uses self.name if available)
349
+ - nodes: list of nodes with 'id' field
350
+ - links: list of links with id, src, dst, length, slots
351
+ """
352
+ alias = self.name if self.name else "Topology"
353
+ name = self.name if self.name else "Topology"
354
+ nodes_list = [{"id": n.id} for n in self.nodes]
355
+ links_list = []
356
+ for l in self.links:
357
+ link_dict = {
358
+ "id": l.id,
359
+ "src": l.source.id,
360
+ "dst": l.target.id,
361
+ "length": getattr(l, "length", None),
362
+ "slots": slots,
363
+ }
364
+ links_list.append(link_dict)
365
+ data = {
366
+ "alias": alias,
367
+ "name": name,
368
+ "nodes": nodes_list,
369
+ "links": links_list,
370
+ }
371
+ with open(file_path, "w", encoding="utf-8") as f:
372
+ json.dump(data, f, indent=4, ensure_ascii=False)
373
+
374
+ def export_to_flexnetsim_ksp_json(self, file_path: str, k: int = 3) -> None:
375
+ """
376
+ Export the k-shortest paths between all node pairs to a JSON file compatible with Flex Net Sim.
377
+
378
+ :param file_path: Path where the JSON file will be saved.
379
+ :type file_path: str
380
+ :param k: Number of shortest paths to compute for each node pair (default: 3).
381
+ :type k: int
382
+
383
+ Example output format::
384
+
385
+ {
386
+ "name": self.name,
387
+ "alias": self.name,
388
+ "routes": [
389
+ {"src": <id>, "dst": <id>, "paths": [[id, ...], ...]},
390
+ ...
391
+ ]
392
+ }
393
+ """
394
+
395
+ # Build a weighted graph using link length as edge weight
396
+ G = nx.DiGraph()
397
+ for l in self.links:
398
+ G.add_edge(l.source.id, l.target.id,
399
+ weight=getattr(l, "length", 1))
400
+ routes = []
401
+ node_ids = [n.id for n in self.nodes]
402
+ for src in node_ids:
403
+ for dst in node_ids:
404
+ if src == dst:
405
+ continue
406
+ try:
407
+ # Compute k shortest paths using link length as weight
408
+ paths_gen = nx.shortest_simple_paths(
409
+ G, src, dst, weight="weight")
410
+ paths = []
411
+ for i, path in enumerate(paths_gen):
412
+ if i >= k:
413
+ break
414
+ paths.append(path)
415
+ except (nx.NetworkXNoPath, nx.NodeNotFound):
416
+ paths = []
417
+ routes.append({"src": src, "dst": dst, "paths": paths})
418
+ data = {
419
+ "name": self.name if self.name else "Topology",
420
+ "alias": self.name if self.name else "Topology",
421
+ "routes": routes,
422
+ }
423
+ with open(file_path, "w", encoding="utf-8") as f:
424
+ json.dump(data, f, indent=4, ensure_ascii=False)
425
+
426
+ @classmethod
427
+ def list_available_topologies(cls) -> list[dict]:
428
+ """
429
+ List available topologies in the assets folder.
430
+
431
+ Returns a list of dictionaries with keys:
432
+ - 'name': topology name (filename without extension)
433
+ - 'nodes': number of nodes
434
+ - 'links': number of links
435
+
436
+ :return: List of available topologies with metadata.
437
+ :rtype: list[dict]
438
+ """
439
+ asset_dir = Path(__file__).parent.parent / "assets"
440
+ result = []
441
+ for json_path in asset_dir.glob("*.json"):
442
+ try:
443
+ topo = cls.from_json(str(json_path))
444
+ result.append({
445
+ "name": json_path.stem,
446
+ "nodes": len(topo.nodes),
447
+ "links": len(topo.links),
448
+ })
449
+ except Exception:
450
+ continue
451
+ return result
452
+
453
+ @staticmethod
454
+ def load_default_topology(name: str) -> "Topology":
455
+ """
456
+ Load a default topology from the assets folder by name (filename without extension).
457
+
458
+ :param name: Name of the topology asset (without .json extension)
459
+ :type name: str
460
+ :return: Topology instance loaded from the asset file
461
+ :rtype: Topology
462
+ :raises FileNotFoundError: If the asset file does not exist
463
+ """
464
+ asset_dir = Path(__file__).parent.parent / "assets"
465
+ asset_path = asset_dir / f"{name}.json"
466
+ if not asset_path.exists():
467
+ raise FileNotFoundError(
468
+ f"Topology asset '{name}.json' not found in assets directory.")
469
+ return Topology.from_json(str(asset_path))
@@ -0,0 +1 @@
1
+ from .mapview import MapView
@@ -0,0 +1,103 @@
1
+ """
2
+ Qt screenshot script for capturing maps as PNG images.
3
+
4
+ This module provides a standalone script that can be executed in a subprocess
5
+ to render HTML content and capture it as a PNG screenshot.
6
+ """
7
+
8
+ import sys
9
+ import os
10
+ from pathlib import Path
11
+
12
+ # Suppress Qt warnings
13
+ os.environ['QT_LOGGING_RULES'] = '*.debug=false;qt.webenginecontext.debug=false;qt.qpa.windows=false'
14
+ os.environ['QTWEBENGINE_CHROMIUM_FLAGS'] = '--disable-logging --log-level=3'
15
+
16
+ try:
17
+ from PyQt6.QtWidgets import QApplication
18
+ from PyQt6.QtWebEngineWidgets import QWebEngineView
19
+ from PyQt6.QtCore import QUrl, QTimer, QEventLoop, Qt
20
+ from PyQt6.QtWebEngineCore import QWebEngineSettings
21
+ from PyQt6.QtGui import QPixmap
22
+ except ImportError:
23
+ print("ERROR: PyQt6 not installed. Install with: pip install PyQt6 PyQt6-WebEngine")
24
+ sys.exit(1)
25
+
26
+
27
+ def capture_screenshot(html_path: str, output_path: str, width: int = 1920, height: int = 1080, wait_time: float = 1.0) -> None:
28
+ """Capture a screenshot of an HTML file.
29
+
30
+ :param html_path: Absolute path to the HTML file to render.
31
+ :type html_path: str
32
+ :param output_path: Path where the PNG screenshot will be saved.
33
+ :type output_path: str
34
+ :param width: Width of the screenshot in pixels.
35
+ :type width: int
36
+ :param height: Height of the screenshot in pixels.
37
+ :type height: int
38
+ :param wait_time: Time in seconds to wait for rendering before capture.
39
+ :type wait_time: float
40
+ """
41
+ app = QApplication(sys.argv)
42
+
43
+ # Create web view offscreen (no window displayed)
44
+ web_view = QWebEngineView()
45
+ web_view.setGeometry(0, 0, width, height)
46
+ # Render offscreen without showing window
47
+ web_view.setAttribute(Qt.WidgetAttribute.WA_DontShowOnScreen, True)
48
+ web_view.show() # Trigger rendering without displaying
49
+
50
+ # Configure settings
51
+ settings = web_view.settings()
52
+ settings.setAttribute(QWebEngineSettings.WebAttribute.LocalContentCanAccessRemoteUrls, True)
53
+ settings.setAttribute(QWebEngineSettings.WebAttribute.LocalContentCanAccessFileUrls, True)
54
+
55
+ def on_load_finished(ok):
56
+ if ok:
57
+ # Wait for Leaflet/JavaScript to fully render the map
58
+ QTimer.singleShot(int(wait_time * 1000), capture)
59
+
60
+ def capture():
61
+ # Grab the rendered content
62
+ pixmap = web_view.grab()
63
+ pixmap.save(output_path, 'PNG')
64
+ app.quit()
65
+
66
+ web_view.loadFinished.connect(on_load_finished)
67
+
68
+ # Load the HTML file
69
+ web_view.load(QUrl.fromLocalFile(html_path))
70
+
71
+ # Suppress output during execution
72
+ null_fd = os.open(os.devnull, os.O_RDWR)
73
+ save_stderr = os.dup(2)
74
+ os.dup2(null_fd, 2)
75
+
76
+ app.exec()
77
+
78
+ # Restore stderr
79
+ os.dup2(save_stderr, 2)
80
+ os.close(null_fd)
81
+ os.close(save_stderr)
82
+
83
+
84
+ if __name__ == "__main__":
85
+ if len(sys.argv) < 3:
86
+ print("Usage: python _qt_screenshot.py <html_path> <output_path> [width] [height] [wait_time]")
87
+ sys.exit(1)
88
+
89
+ html_path = sys.argv[1]
90
+ output_path = sys.argv[2]
91
+ width = int(sys.argv[3]) if len(sys.argv) > 3 else 1920
92
+ height = int(sys.argv[4]) if len(sys.argv) > 4 else 1080
93
+ wait_time = float(sys.argv[5]) if len(sys.argv) > 5 else 1.0
94
+
95
+ if not Path(html_path).exists():
96
+ print(f"ERROR: HTML file not found: {html_path}")
97
+ sys.exit(1)
98
+
99
+ try:
100
+ capture_screenshot(html_path, output_path, width, height, wait_time)
101
+ except Exception as e:
102
+ print(f"ERROR: Failed to capture screenshot: {e}")
103
+ sys.exit(1)
@@ -0,0 +1,78 @@
1
+ """
2
+ Qt window script for displaying maps in isolated subprocess.
3
+
4
+ This module provides a standalone script that can be executed in a subprocess
5
+ to display HTML content in a PyQt6 window without interfering with the main process.
6
+ """
7
+
8
+ import sys
9
+ import os
10
+
11
+ # Suppress Qt warnings
12
+ os.environ['QT_LOGGING_RULES'] = '*.debug=false;qt.webenginecontext.debug=false;qt.qpa.windows=false'
13
+ os.environ['QTWEBENGINE_CHROMIUM_FLAGS'] = '--disable-logging --log-level=3'
14
+
15
+ try:
16
+ from PyQt6.QtWidgets import QApplication, QMainWindow
17
+ from PyQt6.QtWebEngineWidgets import QWebEngineView
18
+ from PyQt6.QtCore import QUrl
19
+ from PyQt6.QtWebEngineCore import QWebEngineSettings
20
+ except ImportError:
21
+ print("ERROR: PyQt6 not installed. Install with: pip install PyQt6 PyQt6-WebEngine")
22
+ sys.exit(1)
23
+
24
+
25
+ class SuppressOutput:
26
+ """Context manager to suppress stdout/stderr during Qt event loop."""
27
+
28
+ def __enter__(self):
29
+ self.null_fd = os.open(os.devnull, os.O_RDWR)
30
+ self.save_stdout = os.dup(1)
31
+ self.save_stderr = os.dup(2)
32
+ os.dup2(self.null_fd, 1)
33
+ os.dup2(self.null_fd, 2)
34
+ return self
35
+
36
+ def __exit__(self, *args):
37
+ os.dup2(self.save_stdout, 1)
38
+ os.dup2(self.save_stderr, 2)
39
+ os.close(self.null_fd)
40
+ os.close(self.save_stdout)
41
+ os.close(self.save_stderr)
42
+
43
+
44
+ def show_html_in_window(html_path: str, window_title: str = "Map Viewer") -> None:
45
+ """Display an HTML file in a PyQt6 window.
46
+
47
+ :param html_path: Absolute path to the HTML file to display.
48
+ :type html_path: str
49
+ :param window_title: Title for the window.
50
+ :type window_title: str
51
+ """
52
+ app = QApplication(sys.argv)
53
+ window = QMainWindow()
54
+ window.setWindowTitle(window_title)
55
+ window.resize(1200, 800)
56
+
57
+ web_view = QWebEngineView()
58
+ settings = web_view.settings()
59
+ settings.setAttribute(QWebEngineSettings.WebAttribute.LocalContentCanAccessRemoteUrls, True)
60
+ settings.setAttribute(QWebEngineSettings.WebAttribute.LocalContentCanAccessFileUrls, True)
61
+
62
+ web_view.load(QUrl.fromLocalFile(html_path))
63
+ window.setCentralWidget(web_view)
64
+ window.show()
65
+
66
+ with SuppressOutput():
67
+ app.exec()
68
+
69
+
70
+ if __name__ == "__main__":
71
+ if len(sys.argv) < 2:
72
+ print("Usage: python _qt_window.py <html_path> [window_title]")
73
+ sys.exit(1)
74
+
75
+ html_path = sys.argv[1]
76
+ window_title = sys.argv[2] if len(sys.argv) > 2 else "Map Viewer"
77
+
78
+ show_html_in_window(html_path, window_title)