import argparse
import functools
import http.server
import sys
import webbrowser
from collections.abc import Iterable
from pathlib import Path
from typing import Any
from urllib.parse import quote
[docs]
class CustomHandler(http.server.SimpleHTTPRequestHandler):
def __init__(self, *args: Any, paths: list[Path], **kwargs: Any) -> None:
self.paths = paths if paths is not None else []
super().__init__(*args, **kwargs)
def _make_homepage(self) -> bytes:
links = "".join(
f'<li><a href="/{i}">{i} ({path.as_posix()})</a></li>'
for i, path in enumerate(self.paths)
)
return f"<h1>Available Files</h1><ul>{links}</ul>".encode()
[docs]
def do_GET(self) -> None:
if self.path == "/":
# Serve a simple HTML page with links
self.send_response(200)
self.send_header("Content-type", "text/html; charset=utf-8")
self.end_headers()
self.wfile.write(self._make_homepage())
else:
try:
parts = self.path.split("/")
idx = int(parts[1])
basepath = Path(self.paths[idx])
filepath = basepath / Path(*parts[2:])
if filepath.is_dir():
# Serve a simple HTML page with links
self.send_response(200)
self.send_header("Content-type", "text/html; charset=utf-8")
self.end_headers()
links = "".join(
f'<li><a href="/{idx}/{path.relative_to(basepath)}">{path.as_posix()}</a></li>'
for _, path in enumerate(sorted(filepath.iterdir()))
)
# we have checked that the filepath exists and is_dir() above
# let's consider it enough sanitization for this use case
# SONAR-IGNORE
self.wfile.write(
f"<h1>Available Files in {quote(filepath.as_posix())}</h1><ul>{links}</ul>".encode()
)
# END-SONAR-IGNORE
else:
if filepath.exists():
self.send_response(200)
if filepath.suffix == ".json":
self.send_header("Content-Type", "application/json")
else:
self.send_header(
"Content-Disposition",
f'attachment; filename="{quote(filepath.name)}"',
)
self.send_header("Content-type", "application/octet-stream")
self.end_headers()
with filepath.open("rb") as f:
self.wfile.write(f.read())
else:
self.send_error(404, "File not found")
except (ValueError, IndexError):
self.send_error(404, "File not found")
except Exception as e:
print(e, file=sys.stderr)
self.send_error(500, str(e))
[docs]
def end_headers(self) -> None:
self.send_header("Access-Control-Allow-Origin", "*")
super().end_headers()
def _init_parser(
subparser: "argparse._SubParsersAction[Any]",
) -> argparse.ArgumentParser:
parser: argparse.ArgumentParser = subparser.add_parser(
"view",
help="View tilesets in your browser.",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
parser.add_argument(
"--giro3d-base",
default="https://giro3d.org/examples",
help="The base for the giro3d viewer",
)
parser.add_argument(
"files",
nargs="+",
help="List of tileset.json to view in the browser.",
)
return parser
[docs]
def open_giro3d(giro3d_base: str, tileset_url: str) -> None:
url = f"{giro3d_base}/3dtiles_simple_viewer.html?tileset_url={tileset_url}"
webbrowser.open(url)
[docs]
def calculate_paths_and_url_suffixes(
files: Iterable[str | Path],
) -> list[tuple[Path, str]]:
paths_and_urls_suffixes = []
i = 0 # noqa # no idea why flake-simplify flags this, as they advertise supporting for-loop with continue
for file in files:
path = Path(file)
if path.is_file():
path_to_serve = path.parent
tileset_path = path
elif path.is_dir():
potential_tileset_path = path / "tileset.json"
print(f"INFO: {path} is a directory, trying {potential_tileset_path}")
if potential_tileset_path.is_file():
print(f"-> {potential_tileset_path} found")
path_to_serve = path
tileset_path = potential_tileset_path
else:
print("WARNING: not found, skipping")
continue
else:
print(f"WARNING: {file} does not exist, skipping")
continue
paths_and_urls_suffixes.append((path_to_serve, f"{i}/{tileset_path.name}"))
i += 1
return paths_and_urls_suffixes
def _get_handler_class(paths: list[Path]) -> functools.partial[CustomHandler]:
return functools.partial(CustomHandler, paths=paths)
def _main(args: argparse.Namespace) -> None:
paths_and_urls_suffixes = calculate_paths_and_url_suffixes(args.files)
if len(paths_and_urls_suffixes) == 0:
print("No tileset to serve, exiting")
sys.exit(1)
# The api of these RequestHandler is not simple and not nice :-/
paths = [path for (path, _) in paths_and_urls_suffixes]
handler_cls = _get_handler_class(paths)
with http.server.HTTPServer(("", 0), handler_cls) as httpd:
path_str = "\n".join([f"- {str(p)}" for p in paths])
print(
f"Serving:\n{path_str}\nat http://{httpd.server_name}:{httpd.server_port}"
)
print("Now launching your browser!")
for _, url_suffix in paths_and_urls_suffixes:
# this is perfectly fine in our context, we can't nor we need having https here
# SONAR-IGNORE
tileset_url = f"http://{httpd.server_name}:{httpd.server_port}/{url_suffix}"
# END-SONAR-IGNORE
# technically, there is a race condition here because we open
# giro3d before httpd is started that being said, in practice, this
# takes so much time (opening a browser page through xdg-open, loading the page, javascript executes,
# then launches the request...), that I think it won't be a problem
open_giro3d(args.giro3d_base, tileset_url)
try:
httpd.serve_forever()
except KeyboardInterrupt:
print("Exiting.")