TSUMUGI 1.0.1__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 (64) hide show
  1. TSUMUGI/annotator.py +103 -0
  2. TSUMUGI/argparser.py +599 -0
  3. TSUMUGI/core.py +185 -0
  4. TSUMUGI/data/impc_phenodigm.csv +3406 -0
  5. TSUMUGI/data/mp.obo +143993 -0
  6. TSUMUGI/filterer.py +36 -0
  7. TSUMUGI/formatter.py +122 -0
  8. TSUMUGI/genewise_annotation_builder.py +94 -0
  9. TSUMUGI/io_handler.py +189 -0
  10. TSUMUGI/main.py +300 -0
  11. TSUMUGI/network_constructor.py +603 -0
  12. TSUMUGI/ontology_handler.py +62 -0
  13. TSUMUGI/pairwise_similarity_builder.py +66 -0
  14. TSUMUGI/report_generator.py +122 -0
  15. TSUMUGI/similarity_calculator.py +498 -0
  16. TSUMUGI/subcommands/count_filterer.py +47 -0
  17. TSUMUGI/subcommands/genes_filterer.py +89 -0
  18. TSUMUGI/subcommands/graphml_builder.py +158 -0
  19. TSUMUGI/subcommands/life_stage_filterer.py +48 -0
  20. TSUMUGI/subcommands/mp_filterer.py +142 -0
  21. TSUMUGI/subcommands/score_filterer.py +22 -0
  22. TSUMUGI/subcommands/sex_filterer.py +48 -0
  23. TSUMUGI/subcommands/webapp_builder.py +358 -0
  24. TSUMUGI/subcommands/zygosity_filterer.py +48 -0
  25. TSUMUGI/validator.py +65 -0
  26. TSUMUGI/web/app/css/app.css +1129 -0
  27. TSUMUGI/web/app/genelist/network_genelist.html +339 -0
  28. TSUMUGI/web/app/genelist/network_genelist.js +421 -0
  29. TSUMUGI/web/app/js/data/dataLoader.js +41 -0
  30. TSUMUGI/web/app/js/export/graphExporter.js +214 -0
  31. TSUMUGI/web/app/js/graph/centrality.js +495 -0
  32. TSUMUGI/web/app/js/graph/components.js +30 -0
  33. TSUMUGI/web/app/js/graph/filters.js +158 -0
  34. TSUMUGI/web/app/js/graph/highlighter.js +52 -0
  35. TSUMUGI/web/app/js/graph/layoutController.js +454 -0
  36. TSUMUGI/web/app/js/graph/valueScaler.js +43 -0
  37. TSUMUGI/web/app/js/search/geneSearcher.js +93 -0
  38. TSUMUGI/web/app/js/search/phenotypeSearcher.js +292 -0
  39. TSUMUGI/web/app/js/ui/dynamicFontSize.js +30 -0
  40. TSUMUGI/web/app/js/ui/mobilePanel.js +77 -0
  41. TSUMUGI/web/app/js/ui/slider.js +22 -0
  42. TSUMUGI/web/app/js/ui/tooltips.js +514 -0
  43. TSUMUGI/web/app/js/viewer/pageSetup.js +217 -0
  44. TSUMUGI/web/app/viewer.html +515 -0
  45. TSUMUGI/web/app/viewer.js +1593 -0
  46. TSUMUGI/web/css/sanitize.css +363 -0
  47. TSUMUGI/web/css/top.css +391 -0
  48. TSUMUGI/web/image/tsumugi-favicon.ico +0 -0
  49. TSUMUGI/web/image/tsumugi-icon.png +0 -0
  50. TSUMUGI/web/image/tsumugi-logo.png +0 -0
  51. TSUMUGI/web/image/tsumugi-logo.svg +69 -0
  52. TSUMUGI/web/js/genelist_formatter.js +123 -0
  53. TSUMUGI/web/js/top.js +338 -0
  54. TSUMUGI/web/open_webapp_linux.sh +25 -0
  55. TSUMUGI/web/open_webapp_mac.command +25 -0
  56. TSUMUGI/web/open_webapp_windows.bat +37 -0
  57. TSUMUGI/web/serve_index.py +110 -0
  58. TSUMUGI/web/template/template_index.html +197 -0
  59. TSUMUGI/web_deployer.py +150 -0
  60. tsumugi-1.0.1.dist-info/METADATA +504 -0
  61. tsumugi-1.0.1.dist-info/RECORD +64 -0
  62. tsumugi-1.0.1.dist-info/WHEEL +4 -0
  63. tsumugi-1.0.1.dist-info/entry_points.txt +3 -0
  64. tsumugi-1.0.1.dist-info/licenses/LICENSE +21 -0
TSUMUGI/web/js/top.js ADDED
@@ -0,0 +1,338 @@
1
+ // Track which search mode is active (defaults to 'phenotype')
2
+ let searchMode = "phenotype";
3
+
4
+ const geneListPlaceHolder = "Trappc11\r\nRab10\r\nInts8\r\nVrk1\r\nSox4"; // Example content for the placeholder
5
+
6
+ // ====================================================================
7
+ // Handle tab switching and keep searchMode in sync
8
+ // ====================================================================
9
+ function setSearchMode(mode) {
10
+ searchMode = mode;
11
+
12
+ document.getElementById("phenotypeSection").style.display = mode === "phenotype" ? "block" : "none";
13
+ document.getElementById("geneSection").style.display = mode === "gene" ? "block" : "none";
14
+ document.getElementById("geneListSection").style.display = mode === "geneList" ? "block" : "none";
15
+
16
+ // Update tab button styles
17
+ document.querySelectorAll(".Tab").forEach((tabButton) => {
18
+ tabButton.classList.remove("active-tab");
19
+ });
20
+ document.querySelectorAll(`button[data-tab="${mode}"]`).forEach((tabButton) => {
21
+ tabButton.classList.add("active-tab");
22
+ });
23
+
24
+ // Reset all input fields
25
+ document.querySelectorAll('input[type="text"], textarea').forEach((input) => {
26
+ input.value = "";
27
+ });
28
+ document.querySelectorAll("ul.suggestions").forEach((ul) => {
29
+ ul.innerHTML = "";
30
+ });
31
+
32
+ // Prefill the textarea when the Gene List tab is selected
33
+ const geneListTextarea = document.getElementById("geneList");
34
+ if (mode === "geneList") {
35
+ geneListTextarea.value = geneListPlaceHolder;
36
+ }
37
+
38
+ // Toggle the correct submit button
39
+ const submitBtn = document.getElementById("submitBtn");
40
+ const submitBtnList = document.getElementById("submitBtn_List");
41
+
42
+ submitBtn.style.display = mode === "geneList" ? "none" : "inline-block";
43
+ submitBtnList.style.display = mode === "geneList" ? "inline-block" : "none";
44
+
45
+ // Reset the submit buttons according to the active mode
46
+ if (mode === "geneList") {
47
+ submitBtnList.disabled = true;
48
+ } else {
49
+ submitBtn.disabled = true;
50
+ }
51
+
52
+ if (mode === "geneList") {
53
+ checkGeneListInput();
54
+ } else {
55
+ checkValidInput();
56
+ }
57
+ }
58
+
59
+ // Disable the submit button when the Gene List textarea is empty
60
+ function checkGeneListInput() {
61
+ const geneListTextarea = document.getElementById("geneList");
62
+ const submitBtnList = document.getElementById("submitBtn_List");
63
+
64
+ if (geneListTextarea.value.trim() === "") {
65
+ submitBtnList.disabled = true;
66
+ } else {
67
+ submitBtnList.disabled = false;
68
+ }
69
+ }
70
+
71
+ // ====================================================================
72
+ // Fetch JSON data from the URL and assign to phenotypes
73
+ // ====================================================================
74
+
75
+ const URL_MP_TERMS = "./data/available_mp_terms.json";
76
+ const URL_GENE_SYMBOLS = "./data/available_gene_symbols.txt";
77
+
78
+ // Track when loading the supporting data completes
79
+ let phenotypesLoaded = fetch(URL_MP_TERMS)
80
+ .then((response) => response.json())
81
+ .then((data) => {
82
+ phenotypes = data;
83
+ })
84
+ .catch((error) => console.error("Error fetching phenotypes:", error));
85
+
86
+ let geneSymbolsLoaded = fetch(URL_GENE_SYMBOLS)
87
+ .then((response) => response.text())
88
+ .then((data) => {
89
+ geneSymbols = data.split("\n").reduce((acc, symbol) => {
90
+ acc[symbol.trim()] = null;
91
+ return acc;
92
+ }, {});
93
+ })
94
+ .catch((error) => console.error("Error fetching gene symbols:", error));
95
+
96
+ // Initialize with the phenotype search mode
97
+ setSearchMode("phenotype");
98
+
99
+ // Attach click handlers to the tab buttons
100
+ document.querySelectorAll(".Tab").forEach((button) => {
101
+ button.addEventListener("click", () => setSearchMode(button.dataset.tab));
102
+ });
103
+
104
+ // Update the button whenever the Gene List textarea changes
105
+ document.getElementById("geneList").addEventListener("input", checkGeneListInput);
106
+
107
+ // Helper that waits for all prerequisite data to load
108
+ async function ensureDataLoaded() {
109
+ await Promise.all([phenotypesLoaded, geneSymbolsLoaded]);
110
+ }
111
+
112
+ // ====================================================================
113
+ // Input handling
114
+ // ====================================================================
115
+
116
+ // --------------------------------------------------------------------
117
+ // Display search suggestions based on the user's input
118
+ // --------------------------------------------------------------------
119
+
120
+ async function handleInput(event) {
121
+ await ensureDataLoaded(); // Ensure reference data has finished loading
122
+
123
+ const userInput = event.target.value.toLowerCase();
124
+ const suggestionList =
125
+ searchMode === "phenotype"
126
+ ? document.getElementById("phenotypeSuggestions")
127
+ : document.getElementById("geneSuggestions");
128
+
129
+ const submitButton = document.getElementById("submitBtn");
130
+
131
+ if (!submitButton) {
132
+ console.error(`submitButton not found`);
133
+ return;
134
+ }
135
+
136
+ suggestionList.innerHTML = "";
137
+
138
+ let isValidSelection = false;
139
+ if (userInput) {
140
+ const dataDictionary = searchMode === "phenotype" ? phenotypes : geneSymbols;
141
+ let matchingCandidates = Object.keys(dataDictionary)
142
+ .map((candidate) => ({
143
+ text: candidate,
144
+ score: wordMatchScore(userInput, candidate),
145
+ }))
146
+ .sort((a, b) => b.score - a.score)
147
+ .filter((candidate) => candidate.score > 0)
148
+ .slice(0, 10);
149
+
150
+ matchingCandidates.forEach((candidate) => {
151
+ const listItem = document.createElement("li");
152
+ listItem.textContent = candidate.text;
153
+ listItem.addEventListener("click", function () {
154
+ event.target.value = candidate.text;
155
+ suggestionList.innerHTML = "";
156
+ checkValidInput();
157
+ });
158
+ suggestionList.appendChild(listItem);
159
+ });
160
+
161
+ isValidSelection = matchingCandidates.some((candidate) => candidate.text.toLowerCase() === userInput);
162
+ }
163
+
164
+ submitButton.disabled = !isValidSelection;
165
+ }
166
+
167
+ // --------------------------------------------------------------------
168
+ // Validate the current input field
169
+ // --------------------------------------------------------------------
170
+ async function checkValidInput() {
171
+ await ensureDataLoaded();
172
+
173
+ const userInput =
174
+ searchMode === "phenotype" ? document.getElementById("phenotype") : document.getElementById("gene");
175
+
176
+ let isEmptyInput = userInput.value.trim() === "";
177
+
178
+ let isValidSelection = false;
179
+ if (searchMode === "phenotype") {
180
+ isValidSelection = phenotypes.hasOwnProperty(userInput.value);
181
+ } else if (searchMode === "gene") {
182
+ isValidSelection = geneSymbols.hasOwnProperty(userInput.value);
183
+ }
184
+
185
+ const submitBtn = document.getElementById("submitBtn");
186
+ submitBtn.disabled = !isValidSelection || isEmptyInput;
187
+ }
188
+
189
+ // --------------------------------------------------------------------
190
+ // Register input listeners once the datasets are ready
191
+ // --------------------------------------------------------------------
192
+ ensureDataLoaded().then(() => {
193
+ document.getElementById("phenotype").addEventListener("input", handleInput);
194
+ document.getElementById("gene").addEventListener("input", handleInput);
195
+ document.getElementById("phenotype").addEventListener("blur", checkValidInput);
196
+ document.getElementById("gene").addEventListener("blur", checkValidInput);
197
+ });
198
+
199
+ // ====================================================================
200
+ // Open the detail page that corresponds to the form selection in a new tab
201
+ // ====================================================================
202
+ function handleFormSubmit(event) {
203
+ event.preventDefault();
204
+
205
+ const rawMode = searchMode;
206
+ const mode = rawMode === "gene" ? "genesymbol" : rawMode === "geneList" ? "genelist" : rawMode;
207
+
208
+ // Run the Gene List workflow directly
209
+ if (rawMode === "geneList") {
210
+ fetchGeneData(); // Trigger immediately for gene lists
211
+ return;
212
+ }
213
+
214
+ // For phenotype/gene searches, navigate to the dedicated page
215
+ const userInput = mode === "phenotype" ? document.getElementById("phenotype") : document.getElementById("gene");
216
+ const submitBtn = document.getElementById("submitBtn");
217
+ const selectedData = mode === "phenotype" ? phenotypes[userInput.value] : userInput.value;
218
+
219
+ if (!submitBtn.disabled) {
220
+ const query = new URLSearchParams({
221
+ mode,
222
+ name: selectedData,
223
+ title: userInput.value,
224
+ });
225
+ window.open(`app/viewer.html?${query.toString()}`, "_blank");
226
+ }
227
+ }
228
+
229
+ // Listen for the form's submit event
230
+ document.getElementById("searchForm").addEventListener("submit", handleFormSubmit);
231
+
232
+ // ====================================================================
233
+ // Calculate similarity scores between the input strings
234
+ // ====================================================================
235
+
236
+ function jaroWinkler(s1, s2) {
237
+ const m = 0.1;
238
+ const scalingFactor = 0.1;
239
+ const s1Len = s1.length;
240
+ const s2Len = s2.length;
241
+
242
+ if (s1Len === 0 || s2Len === 0) return 0;
243
+
244
+ const matchWindow = Math.max(0, Math.floor(Math.max(s1Len, s2Len) / 2) - 1);
245
+ const s1Matches = new Array(s1Len).fill(false);
246
+ const s2Matches = new Array(s2Len).fill(false);
247
+ let matches = 0;
248
+
249
+ for (let i = 0; i < s1Len; i++) {
250
+ const start = Math.max(0, i - matchWindow);
251
+ const end = Math.min(i + matchWindow + 1, s2Len);
252
+
253
+ for (let j = start; j < end; j++) {
254
+ if (s2Matches[j]) continue;
255
+ if (s1[i] !== s2[j]) continue;
256
+ s1Matches[i] = true;
257
+ s2Matches[j] = true;
258
+ matches++;
259
+ break;
260
+ }
261
+ }
262
+
263
+ if (matches === 0) return 0;
264
+
265
+ let transpositions = 0;
266
+ let k = 0;
267
+
268
+ for (let i = 0; i < s1Len; i++) {
269
+ if (!s1Matches[i]) continue;
270
+ while (!s2Matches[k]) k++;
271
+ if (s1[i] !== s2[k]) transpositions++;
272
+ k++;
273
+ }
274
+
275
+ transpositions /= 2;
276
+
277
+ const jaroScore = (matches / s1Len + matches / s2Len + (matches - transpositions) / matches) / 3;
278
+
279
+ let prefixLength = 0;
280
+ for (let i = 0; i < Math.min(4, s1Len, s2Len); i++) {
281
+ if (s1[i] === s2[i]) prefixLength++;
282
+ else break;
283
+ }
284
+
285
+ return jaroScore + prefixLength * scalingFactor * (1 - jaroScore);
286
+ }
287
+
288
+ function wordMatchScore(term1, term2) {
289
+ const term1Words = term1.split(" ").filter(Boolean);
290
+ const term2Words = term2.split(" ").filter(Boolean);
291
+ let score = 0;
292
+
293
+ term1Words.forEach((word1) => {
294
+ let maxScore = 0;
295
+ term2Words.forEach((word2) => {
296
+ const similarity = jaroWinkler(word1.toLowerCase(), word2.toLowerCase());
297
+ maxScore = Math.max(maxScore, similarity);
298
+ });
299
+
300
+ score += maxScore;
301
+ });
302
+
303
+ return score;
304
+ }
305
+
306
+ // ====================================================================
307
+ // Info Tooltip Functionality
308
+ // ====================================================================
309
+
310
+ // Initialize tooltips when DOM is loaded
311
+ document.addEventListener("DOMContentLoaded", function () {
312
+ // Handle tooltip click interactions for mobile devices
313
+ const tooltipIcons = document.querySelectorAll(".info-tooltip-icon");
314
+
315
+ tooltipIcons.forEach((icon) => {
316
+ icon.addEventListener("click", function (e) {
317
+ e.preventDefault();
318
+ e.stopPropagation();
319
+
320
+ const container = this.parentElement;
321
+ container.classList.toggle("active");
322
+
323
+ // Close other active tooltips
324
+ document.querySelectorAll(".info-tooltip-container.active").forEach((el) => {
325
+ if (el !== container) {
326
+ el.classList.remove("active");
327
+ }
328
+ });
329
+ });
330
+ });
331
+
332
+ // Close tooltips when clicking outside
333
+ document.addEventListener("click", function () {
334
+ document.querySelectorAll(".info-tooltip-container.active").forEach((el) => {
335
+ el.classList.remove("active");
336
+ });
337
+ });
338
+ });
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env bash
2
+
3
+ cd "$(dirname "$0")"
4
+ echo "=== Current working directory ==="
5
+ pwd
6
+ echo
7
+
8
+ echo "=== Checking for Python executables ==="
9
+ for CMD in py python3 python; do
10
+ if command -v "$CMD" >/dev/null 2>&1; then
11
+ echo "Found $CMD: $(command -v "$CMD")"
12
+ echo
13
+ echo "=== Starting serve_index.py ==="
14
+ "$CMD" serve_index.py
15
+ echo
16
+ echo "=== Server stopped or exited ==="
17
+ read -rp "Press Enter to close this window..."
18
+ exit 0
19
+ fi
20
+ done
21
+
22
+ echo "[ERROR] Python not found."
23
+ echo "Please install Python 3 (apt install python3 or yum install python3)."
24
+ echo
25
+ read -rp "Press Enter to close this window..."
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env bash
2
+
3
+ cd "$(dirname "$0")"
4
+ echo "=== Current working directory ==="
5
+ pwd
6
+ echo
7
+
8
+ echo "=== Checking for Python executables ==="
9
+ for CMD in py python3 python; do
10
+ if command -v "$CMD" >/dev/null 2>&1; then
11
+ echo "Found $CMD: $(command -v "$CMD")"
12
+ echo
13
+ echo "=== Starting serve_index.py ==="
14
+ "$CMD" serve_index.py
15
+ echo
16
+ echo "=== Server stopped or exited ==="
17
+ read -rp "Press Enter to close this window..."
18
+ exit 0
19
+ fi
20
+ done
21
+
22
+ echo "[ERROR] Python not found."
23
+ echo "Please install Python 3 from https://www.python.org/downloads/ or use Homebrew (brew install python)."
24
+ echo
25
+ read -rp "Press Enter to close this window..."
@@ -0,0 +1,37 @@
1
+ :: File name: start_server_debug.bat
2
+ @echo off
3
+ setlocal
4
+ pushd %~dp0
5
+
6
+ echo === Current working directory ===
7
+ cd
8
+ echo.
9
+
10
+ echo === Trying to launch with Python launcher (py) ===
11
+ where py 2>nul
12
+ if %errorlevel%==0 (
13
+ echo Found py launcher: & where py
14
+ echo.
15
+ py -3 serve_index.py
16
+ goto :PAUSE_AND_END
17
+ )
18
+
19
+ echo === Trying python / pythonw ===
20
+ for %%P in (python.exe pythonw.exe) do (
21
+ where %%P >nul 2>nul
22
+ if not errorlevel 1 (
23
+ echo Found %%P: & where %%P
24
+ echo.
25
+ %%P serve_index.py
26
+ goto :PAUSE_AND_END
27
+ )
28
+ )
29
+
30
+ echo [ERROR] Python was not found.
31
+ echo Please install Python from https://www.python.org/downloads/
32
+ echo or check your PATH / py launcher settings.
33
+
34
+ :PAUSE_AND_END
35
+ echo.
36
+ echo === Script finished. Check any error messages above. ===
37
+ pause
@@ -0,0 +1,110 @@
1
+ # serve_index.py (WSL/mac/Linux/Windows aware; English-only messages)
2
+ import contextlib
3
+ import http.server
4
+ import os
5
+ import shlex
6
+ import socket
7
+ import socketserver
8
+ import subprocess
9
+ import sys
10
+ import threading
11
+ import webbrowser
12
+ from pathlib import Path
13
+
14
+ ROOT = Path(__file__).resolve().parent
15
+ INDEX = ROOT / "index.html"
16
+
17
+
18
+ def is_wsl() -> bool:
19
+ try:
20
+ with open("/proc/sys/kernel/osrelease", encoding="utf-8") as f:
21
+ return "microsoft" in f.read().lower()
22
+ except Exception:
23
+ return False
24
+
25
+
26
+ def _popen(cmdlist):
27
+ subprocess.Popen(cmdlist, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
28
+
29
+
30
+ def safe_open_url(url: str) -> None:
31
+ """Try multiple platform-specific openers; fall back to printing the URL."""
32
+ tried = []
33
+
34
+ # 0) WSL FIRST (because webbrowser may mistakenly pick gio)
35
+ if is_wsl():
36
+ for cmd in (["wslview", url], ["explorer.exe", url], ["powershell.exe", "/c", "start", url]):
37
+ try:
38
+ _popen(cmd)
39
+ return
40
+ except Exception as e:
41
+ tried.append(f"{' '.join(cmd)}: {e}")
42
+
43
+ # 1) Respect $BROWSER (allow commands with args)
44
+ browser_env = os.environ.get("BROWSER")
45
+ if browser_env:
46
+ try:
47
+ cmd = shlex.split(browser_env) + [url]
48
+ _popen(cmd)
49
+ return
50
+ except Exception as e:
51
+ tried.append(f"$BROWSER({browser_env}): {e}")
52
+
53
+ # 2) Python's webbrowser (may choose gio/xdg-open/open)
54
+ try:
55
+ # webbrowser may return True even if the underlying tool fails later; still worth trying.
56
+ if webbrowser.open_new_tab(url):
57
+ return
58
+ except Exception as e:
59
+ tried.append(f"webbrowser: {e}")
60
+
61
+ # 3) macOS
62
+ if sys.platform == "darwin":
63
+ try:
64
+ _popen(["open", url])
65
+ return
66
+ except Exception as e:
67
+ tried.append(f"open: {e}")
68
+
69
+ # 4) Linux/Unix (desktop)
70
+ for cmd in (["xdg-open", url], ["gio", "open", url]):
71
+ try:
72
+ _popen(cmd)
73
+ return
74
+ except Exception as e:
75
+ tried.append(f"{' '.join(cmd)}: {e}")
76
+
77
+ print("Could not auto-open the browser.")
78
+ print("Open this URL manually:", url)
79
+ if tried:
80
+ print("Tried:", *tried, sep="\n - ")
81
+
82
+
83
+ def find_free_port() -> int:
84
+ with contextlib.closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s:
85
+ s.bind(("127.0.0.1", 0))
86
+ return s.getsockname()[1]
87
+
88
+
89
+ class Handler(http.server.SimpleHTTPRequestHandler):
90
+ def __init__(self, *args, **kwargs):
91
+ super().__init__(*args, directory=str(ROOT), **kwargs)
92
+
93
+
94
+ def main():
95
+ if not INDEX.exists():
96
+ raise SystemExit("index.html not found. Place this script next to index.html.")
97
+ port = find_free_port()
98
+ with socketserver.ThreadingTCPServer(("127.0.0.1", port), Handler) as httpd:
99
+ url = f"http://127.0.0.1:{port}/index.html"
100
+ print(f"Serving {ROOT} at {url}")
101
+ threading.Thread(target=httpd.serve_forever, daemon=True).start()
102
+ safe_open_url(url)
103
+ try:
104
+ threading.Event().wait()
105
+ finally:
106
+ httpd.shutdown()
107
+
108
+
109
+ if __name__ == "__main__":
110
+ main()