web-snapshot-cli 0.1.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.
snapshot/downloader.py ADDED
@@ -0,0 +1,483 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import re
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+
9
+ import httpx
10
+ from bs4 import BeautifulSoup
11
+ from markdownify import markdownify
12
+
13
+ from snapshot.crawl_policy import (
14
+ RobotsChecker,
15
+ discover_sitemap_urls,
16
+ url_matches_filters,
17
+ )
18
+ from snapshot.manifest import MANIFEST_NAME, SnapshotManifest
19
+ from snapshot.utils import (
20
+ SRCSET_ATTRS,
21
+ URL_ATTRS,
22
+ normalize_url,
23
+ page_local_path,
24
+ parse_srcset,
25
+ relative_href,
26
+ rewrite_css_urls,
27
+ same_origin,
28
+ url_to_local_path,
29
+ )
30
+
31
+
32
+ @dataclass
33
+ class SnapshotOptions:
34
+ crawl: bool = False
35
+ max_pages: int = 50
36
+ max_depth: int = 3
37
+ lang: str = "html"
38
+ download_assets: bool = True
39
+ timeout: float = 15.0
40
+ concurrency: int = 16
41
+ same_origin_only: bool = True
42
+ user_agent: str = "web-snapshot-cli/0.1 (+https://github.com/codingsushi79/Snapshot)"
43
+ cookies: list[str] = field(default_factory=list)
44
+ headers: list[str] = field(default_factory=list)
45
+ include_patterns: list[str] = field(default_factory=list)
46
+ exclude_patterns: list[str] = field(default_factory=list)
47
+ respect_robots: bool = True
48
+ crawl_delay: float = 0.0
49
+ use_sitemap: bool = False
50
+ resume: bool = False
51
+ verbose: bool = False
52
+ dry_run: bool = False
53
+
54
+
55
+ @dataclass
56
+ class SnapshotResult:
57
+ pages_saved: int = 0
58
+ assets_saved: int = 0
59
+ pages_skipped: int = 0
60
+ assets_skipped: int = 0
61
+ errors: list[str] = field(default_factory=list)
62
+
63
+
64
+ class SnapshotEngine:
65
+ def __init__(self, root_url: str, output_dir: Path, options: SnapshotOptions):
66
+ self.root_url = normalize_url(root_url) or root_url
67
+ self.output_dir = output_dir.resolve()
68
+ self.options = options
69
+ self._seen_pages: set[str] = set()
70
+ self._queued_pages: set[str] = set()
71
+ self._seen_assets: set[str] = set()
72
+ self._url_to_path: dict[str, Path] = {}
73
+ self._errors: list[str] = []
74
+ self._sem: asyncio.Semaphore | None = None
75
+ self._robots = RobotsChecker(options.user_agent, options.respect_robots)
76
+ self._pages_skipped = 0
77
+ self._assets_skipped = 0
78
+ self._log_fn: Callable[[str], None] | None = None
79
+
80
+ async def run(
81
+ self,
82
+ progress: Callable[[str], None] | None = None,
83
+ log: Callable[[str], None] | None = None,
84
+ ) -> SnapshotResult:
85
+ self._log_fn = log
86
+ self.output_dir.mkdir(parents=True, exist_ok=True)
87
+ self._sem = asyncio.Semaphore(self.options.concurrency)
88
+ self._load_resume_state()
89
+
90
+ client_headers = self._build_headers()
91
+ client_cookies = self._build_cookies()
92
+
93
+ limits = httpx.Limits(max_connections=self.options.concurrency * 2)
94
+ async with httpx.AsyncClient(
95
+ follow_redirects=True,
96
+ timeout=self.options.timeout,
97
+ headers=client_headers,
98
+ cookies=client_cookies,
99
+ limits=limits,
100
+ ) as client:
101
+ self._client = client
102
+ if self.options.crawl:
103
+ await self._crawl(progress)
104
+ else:
105
+ await self._snapshot_page(self.root_url, depth=0, progress=progress)
106
+
107
+ if not self.options.dry_run:
108
+ manifest = SnapshotManifest(
109
+ root_url=self.root_url,
110
+ output_dir=str(self.output_dir),
111
+ pages=sorted(self._seen_pages),
112
+ assets=sorted(self._seen_assets),
113
+ options=self._manifest_options(),
114
+ )
115
+ manifest.save(self.output_dir)
116
+
117
+ return SnapshotResult(
118
+ pages_saved=len(self._seen_pages),
119
+ assets_saved=len(self._seen_assets),
120
+ pages_skipped=self._pages_skipped,
121
+ assets_skipped=self._assets_skipped,
122
+ errors=self._errors,
123
+ )
124
+
125
+ def _manifest_options(self) -> dict:
126
+ return {
127
+ "crawl": self.options.crawl,
128
+ "max_pages": self.options.max_pages,
129
+ "max_depth": self.options.max_depth,
130
+ "lang": self.options.lang,
131
+ "download_assets": self.options.download_assets,
132
+ "same_origin_only": self.options.same_origin_only,
133
+ "respect_robots": self.options.respect_robots,
134
+ "crawl_delay": self.options.crawl_delay,
135
+ "use_sitemap": self.options.use_sitemap,
136
+ }
137
+
138
+ def _build_headers(self) -> dict[str, str]:
139
+ headers = {"User-Agent": self.options.user_agent}
140
+ for item in self.options.headers:
141
+ if ":" not in item:
142
+ continue
143
+ name, value = item.split(":", 1)
144
+ headers[name.strip()] = value.strip()
145
+ return headers
146
+
147
+ def _build_cookies(self) -> dict[str, str]:
148
+ cookies: dict[str, str] = {}
149
+ for item in self.options.cookies:
150
+ if "=" not in item:
151
+ continue
152
+ name, value = item.split("=", 1)
153
+ cookies[name.strip()] = value.strip()
154
+ return cookies
155
+
156
+ def _load_resume_state(self) -> None:
157
+ if not self.options.resume:
158
+ return
159
+ manifest_path = self.output_dir / MANIFEST_NAME
160
+ if not manifest_path.exists():
161
+ self._log("resume: no existing manifest, starting fresh")
162
+ return
163
+
164
+ manifest = SnapshotManifest.load(self.output_dir)
165
+ page_ext = ".md" if self.options.lang == "md" else ".html"
166
+ for page_url in manifest.pages:
167
+ local_path = page_local_path(page_url, self.output_dir, lang=self.options.lang)
168
+ self._seen_pages.add(page_url)
169
+ self._url_to_path[page_url] = local_path
170
+ for asset_url in manifest.assets:
171
+ local_path = url_to_local_path(asset_url, self.output_dir, page_ext=page_ext)
172
+ self._seen_assets.add(asset_url)
173
+ self._url_to_path[asset_url] = local_path
174
+ self._log(
175
+ f"resume: loaded {len(manifest.pages)} pages, "
176
+ f"{len(manifest.assets)} assets from manifest"
177
+ )
178
+
179
+ def _log(self, message: str) -> None:
180
+ if self._log_fn:
181
+ self._log_fn(message)
182
+
183
+ def _url_allowed(self, url: str) -> bool:
184
+ if self.options.same_origin_only and not same_origin(self.root_url, url):
185
+ return False
186
+ return url_matches_filters(
187
+ url, self.options.include_patterns, self.options.exclude_patterns
188
+ )
189
+
190
+ async def _crawl(self, progress: Callable[[str], None] | None) -> None:
191
+ queue: asyncio.Queue[tuple[str, int]] = asyncio.Queue()
192
+ seed_urls = [self.root_url]
193
+
194
+ if self.options.use_sitemap:
195
+ if progress:
196
+ progress("fetching sitemap…")
197
+ sitemap_urls = await discover_sitemap_urls(self._client, self.root_url, self._robots)
198
+ self._log(f"sitemap: discovered {len(sitemap_urls)} URLs")
199
+ seed_urls.extend(sitemap_urls)
200
+
201
+ for url in seed_urls:
202
+ normalized = normalize_url(url)
203
+ if not normalized or not self._url_allowed(normalized):
204
+ continue
205
+ if normalized in self._queued_pages:
206
+ continue
207
+ self._queued_pages.add(normalized)
208
+ await queue.put((normalized, 0))
209
+
210
+ workers = [
211
+ asyncio.create_task(self._crawl_worker(queue, progress))
212
+ for _ in range(min(self.options.concurrency, 8))
213
+ ]
214
+ await queue.join()
215
+ for worker in workers:
216
+ worker.cancel()
217
+ await asyncio.gather(*workers, return_exceptions=True)
218
+
219
+ async def _crawl_worker(
220
+ self,
221
+ queue: asyncio.Queue[tuple[str, int]],
222
+ progress: Callable[[str], None] | None,
223
+ ) -> None:
224
+ while True:
225
+ url, depth = await queue.get()
226
+ try:
227
+ if len(self._seen_pages) >= self.options.max_pages:
228
+ continue
229
+ if depth > self.options.max_depth:
230
+ continue
231
+ if not self._url_allowed(url):
232
+ self._log(f"skip (filter): {url}")
233
+ continue
234
+ if not await self._robots.can_fetch(self._client, url):
235
+ self._log(f"skip (robots.txt): {url}")
236
+ continue
237
+
238
+ if url in self._seen_pages and self._page_exists(url):
239
+ self._pages_skipped += 1
240
+ self._log(f"skip (resume): {url}")
241
+ links = self._links_from_saved_page(url)
242
+ else:
243
+ links = await self._snapshot_page(url, depth=depth, progress=progress)
244
+
245
+ if depth < self.options.max_depth and links:
246
+ for link in links:
247
+ if len(self._seen_pages) >= self.options.max_pages:
248
+ break
249
+ if link in self._queued_pages:
250
+ continue
251
+ if not self._url_allowed(link):
252
+ continue
253
+ self._queued_pages.add(link)
254
+ await queue.put((link, depth + 1))
255
+ finally:
256
+ queue.task_done()
257
+
258
+ def _page_exists(self, url: str) -> bool:
259
+ local_path = self._url_to_path.get(url)
260
+ if local_path is None:
261
+ local_path = page_local_path(url, self.output_dir, lang=self.options.lang)
262
+ return local_path.exists()
263
+
264
+ def _links_from_saved_page(self, url: str) -> list[str]:
265
+ local_path = self._url_to_path.get(url) or page_local_path(
266
+ url, self.output_dir, lang=self.options.lang
267
+ )
268
+ if not local_path.exists():
269
+ return []
270
+ try:
271
+ html = local_path.read_text(encoding="utf-8")
272
+ except OSError:
273
+ return []
274
+ soup = BeautifulSoup(html, "html.parser")
275
+ return self._collect_page_links(soup, url)
276
+
277
+ async def _snapshot_page(
278
+ self,
279
+ url: str,
280
+ depth: int,
281
+ progress: Callable[[str], None] | None,
282
+ ) -> list[str]:
283
+ if url in self._seen_pages and self._page_exists(url):
284
+ self._pages_skipped += 1
285
+ return self._links_from_saved_page(url)
286
+
287
+ if not self._url_allowed(url):
288
+ return []
289
+ if not await self._robots.can_fetch(self._client, url):
290
+ self._log(f"skip (robots.txt): {url}")
291
+ return []
292
+
293
+ if progress:
294
+ progress(f"page {url}")
295
+ self._log(f"fetch: {url}")
296
+
297
+ try:
298
+ response = await self._fetch(url)
299
+ except Exception as exc: # noqa: BLE001
300
+ self._errors.append(f"{url}: {exc}")
301
+ return []
302
+
303
+ self._seen_pages.add(url)
304
+
305
+ content_type = response.headers.get("content-type", "")
306
+ local_path = page_local_path(url, self.output_dir, lang=self.options.lang)
307
+ self._url_to_path[url] = local_path
308
+
309
+ if "text/html" not in content_type and not url.endswith((".html", ".htm")):
310
+ if not self.options.dry_run:
311
+ local_path.parent.mkdir(parents=True, exist_ok=True)
312
+ local_path.write_bytes(response.content)
313
+ else:
314
+ self._log(f"dry-run: would save {url} → {local_path}")
315
+ return []
316
+
317
+ soup = BeautifulSoup(response.text, "html.parser")
318
+ discovered = self._collect_page_links(soup, url)
319
+
320
+ asset_urls = self._collect_asset_urls(soup, url)
321
+ if self.options.download_assets:
322
+ await asyncio.gather(*(self._download_asset(u) for u in asset_urls))
323
+
324
+ if not self.options.dry_run:
325
+ local_path.parent.mkdir(parents=True, exist_ok=True)
326
+ self._rewrite_html(soup, url, local_path)
327
+ body = self._render_page(soup)
328
+ local_path.write_text(body, encoding="utf-8")
329
+ else:
330
+ self._log(f"dry-run: would save page {url} → {local_path}")
331
+
332
+ return discovered
333
+
334
+ def _collect_asset_urls(self, soup: BeautifulSoup, page_url: str) -> set[str]:
335
+ urls: set[str] = set()
336
+ for tag in soup.find_all(True):
337
+ for attr in URL_ATTRS:
338
+ value = tag.get(attr)
339
+ if value:
340
+ absolute = normalize_url(value, page_url)
341
+ if absolute and not self._is_page_url(absolute):
342
+ urls.add(absolute)
343
+ for attr in SRCSET_ATTRS:
344
+ value = tag.get(attr)
345
+ if value:
346
+ for piece in parse_srcset(value):
347
+ absolute = normalize_url(piece, page_url)
348
+ if absolute and not self._is_page_url(absolute):
349
+ urls.add(absolute)
350
+ for style in soup.find_all("style"):
351
+ if style.string:
352
+ for match in re.findall(r"url\(([^)]+)\)", style.string):
353
+ absolute = normalize_url(match.strip("'\""), page_url)
354
+ if absolute and not self._is_page_url(absolute):
355
+ urls.add(absolute)
356
+ return urls
357
+
358
+ def _collect_page_links(self, soup: BeautifulSoup, page_url: str) -> list[str]:
359
+ links: list[str] = []
360
+ for tag in soup.find_all("a", href=True):
361
+ absolute = normalize_url(tag["href"], page_url)
362
+ if absolute and self._is_page_url(absolute):
363
+ links.append(absolute)
364
+ return links
365
+
366
+ def _is_page_url(self, url: str) -> bool:
367
+ from snapshot.utils import _looks_like_page
368
+
369
+ return _looks_like_page(url)
370
+
371
+ async def _download_asset(self, url: str) -> None:
372
+ if url in self._seen_assets:
373
+ return
374
+ self._seen_assets.add(url)
375
+ local_path = url_to_local_path(url, self.output_dir)
376
+ self._url_to_path[url] = local_path
377
+ if local_path.exists():
378
+ self._assets_skipped += 1
379
+ self._log(f"skip (exists): {url}")
380
+ return
381
+
382
+ self._log(f"asset: {url}")
383
+ try:
384
+ response = await self._fetch(url)
385
+ if self.options.dry_run:
386
+ self._log(f"dry-run: would save asset {url} → {local_path}")
387
+ return
388
+ local_path.parent.mkdir(parents=True, exist_ok=True)
389
+ if url.endswith(".css") or "text/css" in response.headers.get("content-type", ""):
390
+ css = response.text
391
+ css = rewrite_css_urls(
392
+ css,
393
+ url,
394
+ lambda asset_url: self._css_mapper(asset_url, local_path),
395
+ )
396
+ local_path.write_text(css, encoding="utf-8")
397
+ nested = self._extract_css_urls(css, url)
398
+ await asyncio.gather(*(self._download_asset(u) for u in nested))
399
+ else:
400
+ local_path.write_bytes(response.content)
401
+ except Exception as exc: # noqa: BLE001
402
+ self._errors.append(f"{url}: {exc}")
403
+
404
+ def _css_mapper(self, asset_url: str, css_path: Path) -> str | None:
405
+ target = self._url_to_path.get(asset_url)
406
+ if target is None:
407
+ target = url_to_local_path(asset_url, self.output_dir)
408
+ self._url_to_path[asset_url] = target
409
+ return relative_href(css_path, target)
410
+
411
+ def _extract_css_urls(self, css: str, base_url: str) -> set[str]:
412
+ urls: set[str] = set()
413
+ for match in re.findall(r"url\(([^)]+)\)", css):
414
+ absolute = normalize_url(match.strip("'\""), base_url)
415
+ if absolute:
416
+ urls.add(absolute)
417
+ return urls
418
+
419
+ def _rewrite_html(self, soup: BeautifulSoup, page_url: str, page_path: Path) -> None:
420
+ for tag in soup.find_all(True):
421
+ for attr in URL_ATTRS:
422
+ value = tag.get(attr)
423
+ if not value:
424
+ continue
425
+ absolute = normalize_url(value, page_url)
426
+ if not absolute:
427
+ continue
428
+ if absolute not in self._url_to_path and not same_origin(self.root_url, absolute):
429
+ continue
430
+ target = self._url_to_path.get(absolute)
431
+ if target is None:
432
+ target = (
433
+ page_local_path(absolute, self.output_dir, lang=self.options.lang)
434
+ if self._is_page_url(absolute)
435
+ else url_to_local_path(absolute, self.output_dir)
436
+ )
437
+ tag[attr] = relative_href(page_path, target)
438
+ for attr in SRCSET_ATTRS:
439
+ value = tag.get(attr)
440
+ if not value:
441
+ continue
442
+ parts = []
443
+ for piece in value.split(","):
444
+ chunk = piece.strip().split()
445
+ if not chunk:
446
+ continue
447
+ absolute = normalize_url(chunk[0], page_url)
448
+ if absolute and absolute in self._url_to_path:
449
+ local = relative_href(page_path, self._url_to_path[absolute])
450
+ rest = " ".join(chunk[1:])
451
+ parts.append(f"{local} {rest}".strip())
452
+ else:
453
+ parts.append(piece.strip())
454
+ if parts:
455
+ tag[attr] = ", ".join(parts)
456
+
457
+ base_tag = soup.find("base")
458
+ if base_tag:
459
+ base_tag.decompose()
460
+
461
+ def _render_page(self, soup: BeautifulSoup) -> str:
462
+ if self.options.lang == "md":
463
+ return self._html_to_markdown(soup)
464
+ return str(soup)
465
+
466
+ def _html_to_markdown(self, soup: BeautifulSoup) -> str:
467
+ title = soup.title.get_text(strip=True) if soup.title else "Untitled"
468
+ body = soup.body or soup
469
+ markdown = markdownify(
470
+ str(body),
471
+ heading_style="ATX",
472
+ bullets="-",
473
+ strip=["script", "style", "noscript"],
474
+ ).strip()
475
+ return f"# {title}\n\n{markdown}\n"
476
+
477
+ async def _fetch(self, url: str) -> httpx.Response:
478
+ async with self._sem:
479
+ response = await self._client.get(url)
480
+ response.raise_for_status()
481
+ if self.options.crawl_delay > 0:
482
+ await asyncio.sleep(self.options.crawl_delay)
483
+ return response
snapshot/manifest.py ADDED
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import asdict, dataclass, field
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ MANIFEST_NAME = ".snapshot.json"
10
+
11
+
12
+ @dataclass
13
+ class SnapshotManifest:
14
+ version: str = "1"
15
+ created_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
16
+ root_url: str = ""
17
+ output_dir: str = ""
18
+ pages: list[str] = field(default_factory=list)
19
+ assets: list[str] = field(default_factory=list)
20
+ options: dict[str, Any] = field(default_factory=dict)
21
+
22
+ def save(self, output_dir: Path) -> Path:
23
+ path = output_dir / MANIFEST_NAME
24
+ path.write_text(json.dumps(asdict(self), indent=2), encoding="utf-8")
25
+ return path
26
+
27
+ @classmethod
28
+ def load(cls, snapshot_dir: Path) -> SnapshotManifest:
29
+ path = snapshot_dir / MANIFEST_NAME
30
+ if not path.exists():
31
+ raise FileNotFoundError(
32
+ f"No snapshot manifest found at {path}. "
33
+ "Make sure this directory was created by snapshot."
34
+ )
35
+ data = json.loads(path.read_text(encoding="utf-8"))
36
+ return cls(**data)
37
+
38
+ @classmethod
39
+ def find_root(cls, snapshot_dir: Path) -> Path:
40
+ """Return the directory that contains the manifest (may be nested)."""
41
+ candidate = snapshot_dir.resolve()
42
+ if (candidate / MANIFEST_NAME).exists():
43
+ return candidate
44
+ for child in candidate.iterdir():
45
+ if child.is_dir() and (child / MANIFEST_NAME).exists():
46
+ return child
47
+ raise FileNotFoundError(
48
+ f"No {MANIFEST_NAME} found in {snapshot_dir}. "
49
+ "Pass the folder that contains your snapshot files."
50
+ )
snapshot/restore.py ADDED
@@ -0,0 +1,134 @@
1
+ from __future__ import annotations
2
+
3
+ import functools
4
+ import http.server
5
+ import mimetypes
6
+ import socketserver
7
+ import webbrowser
8
+ from pathlib import Path
9
+ from urllib.parse import urlparse
10
+
11
+ from snapshot.manifest import SnapshotManifest
12
+ from snapshot.utils import page_local_path
13
+
14
+
15
+ def serve_snapshot(
16
+ snapshot_dir: Path, host: str = "127.0.0.1", port: int = 8080, open_browser: bool = True
17
+ ) -> None:
18
+ """Serve a snapshot directory over HTTP."""
19
+ root = SnapshotManifest.find_root(snapshot_dir)
20
+ manifest = SnapshotManifest.load(root)
21
+ content_root = _content_root(root, manifest)
22
+
23
+ handler = functools.partial(
24
+ _SnapshotHandler,
25
+ directory=str(content_root),
26
+ manifest=manifest,
27
+ )
28
+
29
+ with _FindServer(host, port, handler) as httpd:
30
+ actual_port = httpd.server_address[1]
31
+ url = f"http://{host}:{actual_port}/"
32
+ print(f"snapshot restore: serving {content_root}")
33
+ print(f" root URL : {manifest.root_url}")
34
+ print(f" local URL: {url}")
35
+ print(" Press Ctrl+C to stop.")
36
+ if open_browser:
37
+ webbrowser.open(url)
38
+ try:
39
+ httpd.serve_forever()
40
+ except KeyboardInterrupt:
41
+ print("\nStopped.")
42
+
43
+
44
+ def _content_root(root: Path, manifest: SnapshotManifest) -> Path:
45
+ """Pick the host directory inside the snapshot output."""
46
+ parsed = urlparse(manifest.root_url)
47
+ host_name = parsed.netloc.replace(":", "_")
48
+ for candidate in root.iterdir():
49
+ if candidate.is_dir() and host_name in candidate.name:
50
+ return candidate
51
+ children = [p for p in root.iterdir() if p.is_dir() and p.name != "__pycache__"]
52
+ if len(children) == 1:
53
+ return children[0]
54
+ return root
55
+
56
+
57
+ class _SnapshotHandler(http.server.SimpleHTTPRequestHandler):
58
+ manifest: SnapshotManifest
59
+
60
+ def __init__(self, *args, directory: str, manifest: SnapshotManifest, **kwargs):
61
+ self.manifest = manifest
62
+ super().__init__(*args, directory=directory, **kwargs)
63
+
64
+ def do_GET(self) -> None: # noqa: N802
65
+ path = self.path.split("?", 1)[0]
66
+ resolved = self._resolve_path(path)
67
+ if resolved is not None:
68
+ self.path = "/" + resolved
69
+ return super().do_GET()
70
+
71
+ def _resolve_path(self, path: str) -> str | None:
72
+ root = Path(self.directory)
73
+ lang = self.manifest.options.get("lang", "html")
74
+ ext = ".md" if lang == "md" else ".html"
75
+
76
+ if path in {"/", ""}:
77
+ local = page_local_path(self.manifest.root_url, root, lang=lang)
78
+ if local.exists():
79
+ return local.relative_to(root).as_posix()
80
+ return None
81
+
82
+ clean = path.strip("/")
83
+ if not clean:
84
+ return None
85
+
86
+ direct = root / clean
87
+ if direct.is_file():
88
+ return clean
89
+
90
+ index = direct / f"index{ext}"
91
+ if index.is_file():
92
+ return index.relative_to(root).as_posix()
93
+
94
+ with_ext = root / f"{clean}{ext}"
95
+ if with_ext.is_file():
96
+ return with_ext.relative_to(root).as_posix()
97
+
98
+ return None
99
+
100
+ def log_message(self, format: str, *args) -> None: # noqa: A003
101
+ return
102
+
103
+
104
+ class _FindServer:
105
+ def __init__(self, host: str, port: int, handler):
106
+ self.host = host
107
+ self.port = port
108
+ self.handler = handler
109
+ self.httpd = None
110
+
111
+ def __enter__(self):
112
+ for attempt in range(20):
113
+ try:
114
+ self.httpd = socketserver.TCPServer(
115
+ (self.host, self.port + attempt),
116
+ self.handler,
117
+ )
118
+ self.httpd.allow_reuse_address = True
119
+ return self.httpd
120
+ except OSError:
121
+ continue
122
+ raise OSError(f"Could not bind to {self.host}:{self.port}+")
123
+
124
+ def __exit__(self, exc_type, exc, tb):
125
+ if self.httpd:
126
+ self.httpd.server_close()
127
+
128
+
129
+ # Ensure common web types are registered.
130
+ mimetypes.add_type("application/javascript", ".js")
131
+ mimetypes.add_type("application/javascript", ".mjs")
132
+ mimetypes.add_type("text/css", ".css")
133
+ mimetypes.add_type("image/webp", ".webp")
134
+ mimetypes.add_type("font/woff2", ".woff2")