Skip to content

Commit f6fc0f9

Browse files
committed
Generate the static file hashes at app startup, rather than requiring a build stage.
1 parent bdcdbbc commit f6fc0f9

File tree

11 files changed

+119
-76
lines changed

11 files changed

+119
-76
lines changed

README.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,4 +79,3 @@ The browser can be run with:
7979
```bash
8080
python -m simple_repository_browser
8181
```
82-

setup.py

Lines changed: 0 additions & 16 deletions
This file was deleted.

simple_repository_browser/__main__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ def handler(args: typing.Any) -> None:
4242
# Include the "base" folder, such that upstream templates can inherit from "base/...".
4343
here/"templates",
4444
],
45-
static_files_path=here / "static",
45+
static_files_paths=[here / "static_source"],
4646
crawl_popular_projects=args.crawl_popular_projects,
4747
url_prefix=args.url_prefix,
4848
browser_version=__version__,

simple_repository_browser/_app.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
from . import controller, crawler, errors, fetch_projects, model, view
1717
from .metadata_injector import MetadataInjector
18+
from .static_files import generate_manifest
1819

1920

2021
class AppBuilder:
@@ -24,15 +25,15 @@ def __init__(
2425
repository_url: str,
2526
cache_dir: Path,
2627
template_paths: typing.Sequence[Path],
27-
static_files_path: Path,
28+
static_files_paths: typing.Sequence[Path],
2829
crawl_popular_projects: bool,
2930
browser_version: str,
3031
) -> None:
3132
self.url_prefix = url_prefix
3233
self.repository_url = repository_url
3334
self.cache_dir = cache_dir
3435
self.template_paths = template_paths
35-
self.static_files_path = static_files_path
36+
self.static_files_manifest = generate_manifest(static_files_paths)
3637
self.crawl_popular_projects = crawl_popular_projects
3738
self.browser_version = browser_version
3839

@@ -50,6 +51,7 @@ def create_app(self) -> fastapi.FastAPI:
5051
_view = self.create_view()
5152

5253
async def lifespan(app: fastapi.FastAPI):
54+
5355
async with (
5456
httpx.AsyncClient(timeout=30) as http_client,
5557
aiosqlite.connect(self.db_path, timeout=5) as db,
@@ -61,7 +63,7 @@ async def lifespan(app: fastapi.FastAPI):
6163
),
6264
view=_view,
6365
)
64-
router = _controller.create_router(self.static_files_path)
66+
router = _controller.create_router(self.static_files_manifest)
6567
app.mount(self.url_prefix or "/", router)
6668

6769
if self.url_prefix:
@@ -107,7 +109,7 @@ async def catch_exceptions_middleware(request: fastapi.Request, call_next):
107109
return app
108110

109111
def create_view(self) -> view.View:
110-
return view.View(self.template_paths, self.browser_version, static_files_path=self.static_files_path)
112+
return view.View(self.template_paths, self.browser_version, static_files_manifest=self.static_files_manifest)
111113

112114
def create_crawler(self, http_client: httpx.AsyncClient, source: SimpleRepository) -> crawler.Crawler:
113115
return crawler.Crawler(

simple_repository_browser/_compile_static.py

Lines changed: 0 additions & 41 deletions
This file was deleted.

simple_repository_browser/controller.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,15 @@
22
import dataclasses
33
from enum import Enum
44
from functools import partial
5-
from pathlib import Path
65
import typing
76

87
import fastapi
98
from fastapi.responses import StreamingResponse
10-
from fastapi.staticfiles import StaticFiles
119
from markupsafe import Markup
1210
from packaging.version import InvalidVersion, Version
1311

1412
from . import errors, model, view
13+
from .static_files import HashedStaticFileHandler
1514

1615

1716
@dataclasses.dataclass(frozen=True)
@@ -82,9 +81,9 @@ def __init__(self, model: model.Model, view: view.View) -> None:
8281
self.model = model
8382
self.view = view
8483

85-
def create_router(self, static_file_path: Path) -> fastapi.APIRouter:
84+
def create_router(self, static_files_manifest) -> fastapi.APIRouter:
8685
router = self.router.build_fastapi_router(self)
87-
router.mount("/static", StaticFiles(directory=static_file_path), name="static")
86+
router.mount("/static", HashedStaticFileHandler(manifest=static_files_manifest), name="static")
8887
return router
8988

9089
@router.get("/", name="index")
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import argparse
2+
from hashlib import sha256
3+
import json
4+
import os
5+
import pathlib
6+
import shutil
7+
import sys
8+
import typing
9+
10+
from starlette.responses import FileResponse
11+
from starlette.staticfiles import StaticFiles
12+
from typing_extensions import override
13+
14+
15+
def compile_static_files(*, destination: pathlib.Path, sources: typing.Sequence[pathlib.Path]):
16+
"""Compile a static directory from one or more source directories."""
17+
# This function is designed to write the static files, could be useful for serving static
18+
# files via apache/nginx/etc.
19+
manifest = generate_manifest(sources)
20+
file_map = {'file-map': {}}
21+
22+
for input_filename, (hashed_relpath, source_path) in manifest.items():
23+
target = destination / hashed_relpath
24+
target.parent.mkdir(parents=True, exist_ok=True)
25+
shutil.copy(source_path, target)
26+
file_map['file-map'][str(input_filename)] = str(target)
27+
28+
json.dump(file_map, (destination / '.manifest.json').open('w'), indent=2)
29+
(destination / '.gitignore').write_text('*')
30+
31+
32+
def generate_manifest(sources: typing.Sequence[pathlib.Path]) -> dict[str, tuple[str, pathlib.Path]]:
33+
"""
34+
Generate a manifest which maps template_rel_path to a (hashed_relpath, full_path) tuple.
35+
"""
36+
manifest: dict[str, tuple[str, str]] = {}
37+
files_to_compile = {}
38+
for source in sources:
39+
assert source.exists()
40+
for path in sorted(source.glob('**/*')):
41+
if not path.is_file():
42+
continue
43+
if path.name.startswith('.'):
44+
continue
45+
rel = path.relative_to(source)
46+
files_to_compile[rel] = path
47+
48+
for rel, source_path in files_to_compile.items():
49+
file_hash = sha256(source_path.read_bytes()).hexdigest()[:12]
50+
name = f'{source_path.stem}.{file_hash}{source_path.suffix}'
51+
manifest[str(rel)] = (str(rel.parent / name), source_path)
52+
53+
return manifest
54+
55+
56+
class HashedStaticFileHandler(StaticFiles):
57+
def __init__(self, *, manifest, **kwargs):
58+
super().__init__(**kwargs)
59+
self.manifest = manifest
60+
self._inverted_manifest = {src: path for src, path in manifest.values()}
61+
62+
@override
63+
def lookup_path(self, path: str) -> tuple[str, os.stat_result | None]:
64+
actual_path = self._inverted_manifest.get(path)
65+
if actual_path is None:
66+
super.lookup_path(path)
67+
return actual_path, os.stat(actual_path)
68+
69+
@override
70+
async def get_response(self, path: str, scope):
71+
response: FileResponse = await super().get_response(path, scope)
72+
if response.status_code in [200, 304]:
73+
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
74+
return response
75+
76+
77+
def main(argv: typing.Sequence[str]) -> int:
78+
parser = argparse.ArgumentParser(prog='simple_repository_browser.static')
79+
80+
subparsers = parser.add_subparsers()
81+
82+
parser_compile_static = subparsers.add_parser('compile', help='Compile the static files into a directory')
83+
parser_compile_static.add_argument('destination', type=pathlib.Path, help='Where to write the static files')
84+
parser_compile_static.add_argument(
85+
'source',
86+
type=pathlib.Path,
87+
help='The source of static files to combine (may be provided multiple times)',
88+
nargs='+',
89+
)
90+
parser_compile_static.set_defaults(handler=handle_compile)
91+
92+
args = parser.parse_args(argv)
93+
args.handler(args)
94+
95+
96+
def handle_compile(args: argparse.Namespace):
97+
print(f'Writing static files to {args.destination}')
98+
compile_static_files(destination=args.destination, sources=args.source)
99+
100+
101+
if __name__ == '__main__':
102+
# Enable simple_repository_browser.static_files CLI.
103+
main(sys.argv[1:])

simple_repository_browser/tests/test_compatibility_matrix.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from simple_repository import model
2+
23
from simple_repository_browser.compatibility_matrix import compatibility_matrix
34

45

simple_repository_browser/tests/test_search.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import parsley
22
import pytest
3+
34
from simple_repository_browser import _search
45
from simple_repository_browser._search import Filter, FilterOn
56

simple_repository_browser/tests/test_view.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from packaging.requirements import Requirement
2+
23
from simple_repository_browser.view import render_markers
34

45

0 commit comments

Comments
 (0)