create-caspian-app 1.3.6 → 1.3.7

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.
package/dist/main.py CHANGED
@@ -130,12 +130,16 @@ def _build_mcp_cors_middleware() -> "Middleware":
130
130
  origins = ["*"]
131
131
  allow_credentials = False
132
132
 
133
- methods = _csv_env("CORS_ALLOWED_METHODS") or [
134
- "GET", "POST", "DELETE", "OPTIONS"]
133
+ methods = _csv_env("CORS_ALLOWED_METHODS") or ["GET", "POST", "DELETE", "OPTIONS"]
135
134
 
136
135
  headers = _csv_env("CORS_ALLOWED_HEADERS")
137
- for required in ("Content-Type", "Accept", "Authorization",
138
- "mcp-session-id", "mcp-protocol-version"):
136
+ for required in (
137
+ "Content-Type",
138
+ "Accept",
139
+ "Authorization",
140
+ "mcp-session-id",
141
+ "mcp-protocol-version",
142
+ ):
139
143
  if required.lower() not in {h.lower() for h in headers}:
140
144
  headers.append(required)
141
145
 
@@ -233,6 +237,7 @@ if cfg.mcp:
233
237
  # Optional, feature-gated module: only generated when mcp is enabled in
234
238
  # caspian.config.json, so suppress the static "module not found" check.
235
239
  from src.lib.mcp.mcp_server import mcp # type: ignore[import-not-found]
240
+
236
241
  # Inner path "/" so the mount prefix below is the full endpoint path.
237
242
  # CORS is outermost so preflight is answered before the token check.
238
243
  mcp_app = mcp.http_app(
@@ -312,6 +317,7 @@ async def combined_lifespan(app: FastAPI):
312
317
 
313
318
  yield
314
319
 
320
+
315
321
  app = FastAPI(
316
322
  title=cfg.projectName,
317
323
  version=cfg.version,
@@ -326,22 +332,23 @@ app = FastAPI(
326
332
  async def healthcheck():
327
333
  return {"status": "ok"}
328
334
 
335
+
329
336
  # ====
330
337
  # Configuration
331
338
  # ====
332
- SESSION_LIFETIME_HOURS = int(os.getenv('SESSION_LIFETIME_HOURS', 7))
333
- MAX_CONTENT_LENGTH_MB = int(os.getenv('MAX_CONTENT_LENGTH_MB', 16))
334
- CACHE_ENABLED = os.getenv('CACHE_ENABLED', 'false').lower() == 'true'
335
- DEFAULT_TTL = int(os.getenv('CACHE_TTL', 600))
339
+ SESSION_LIFETIME_HOURS = int(os.getenv("SESSION_LIFETIME_HOURS", 7))
340
+ MAX_CONTENT_LENGTH_MB = int(os.getenv("MAX_CONTENT_LENGTH_MB", 16))
341
+ CACHE_ENABLED = os.getenv("CACHE_ENABLED", "false").lower() == "true"
342
+ DEFAULT_TTL = int(os.getenv("CACHE_TTL", 600))
336
343
  REQUEST_TIMEOUT_SECONDS = max(
337
344
  1.0,
338
- float(os.getenv('CASPIAN_REQUEST_TIMEOUT_SECONDS', 20)),
345
+ float(os.getenv("CASPIAN_REQUEST_TIMEOUT_SECONDS", 20)),
339
346
  )
340
347
  # Path prefixes that serve long-lived streaming responses (SSE, etc.) and must
341
348
  # not be subject to the per-request timeout. The MCP streamable-HTTP transport
342
349
  # keeps GET /mcp/ open indefinitely; wrapping it in asyncio.wait_for cancels the
343
350
  # stream mid-response and corrupts the ASGI message sequence.
344
- STREAMING_PATH_PREFIXES = ('/mcp',)
351
+ STREAMING_PATH_PREFIXES = ("/mcp",)
345
352
  # Public assets: exempt from auth, request logging, and rate limiting, since one
346
353
  # page load pulls many of them.
347
354
  MAX_CONTENT_LENGTH_BYTES = max(1, MAX_CONTENT_LENGTH_MB) * 1024 * 1024
@@ -365,9 +372,7 @@ def _build_security_headers() -> dict[str, str]:
365
372
 
366
373
  # The header set depends only on IS_PRODUCTION, so build it once at import time
367
374
  # instead of allocating an identical dict on every single response.
368
- SECURITY_HEADERS: tuple[tuple[str, str], ...] = tuple(
369
- _build_security_headers().items()
370
- )
375
+ SECURITY_HEADERS: tuple[tuple[str, str], ...] = tuple(_build_security_headers().items())
371
376
 
372
377
 
373
378
  def _dev_cookie_scope() -> str:
@@ -382,15 +387,13 @@ def _dev_cookie_scope() -> str:
382
387
  bs_config_path = Path("settings/bs-config.json")
383
388
  if bs_config_path.exists():
384
389
  try:
385
- local_url = json.loads(
386
- bs_config_path.read_text(encoding="utf-8")
387
- ).get("local", "")
390
+ local_url = json.loads(bs_config_path.read_text(encoding="utf-8")).get("local", "")
388
391
  parsed_url = urlparse(local_url)
389
392
  if parsed_url.hostname in {"localhost", "127.0.0.1"}:
390
393
  scope = str(parsed_url.port or "")
391
394
  else:
392
395
  scope = ""
393
- except (OSError, json.JSONDecodeError):
396
+ except OSError, json.JSONDecodeError:
394
397
  scope = ""
395
398
 
396
399
  return scope if scope and scope.isdigit() else ""
@@ -402,9 +405,7 @@ def _scoped_cookie_name(base_name: str) -> str:
402
405
 
403
406
 
404
407
  CSRF_COOKIE_NAME = _scoped_cookie_name("pp_csrf")
405
- SESSION_COOKIE_NAME = _scoped_cookie_name(
406
- os.getenv('AUTH_COOKIE_NAME', 'session')
407
- )
408
+ SESSION_COOKIE_NAME = _scoped_cookie_name(os.getenv("AUTH_COOKIE_NAME", "session"))
408
409
 
409
410
  # ====
410
411
  # Pure ASGI Middleware Classes
@@ -414,7 +415,8 @@ SESSION_COOKIE_NAME = _scoped_cookie_name(
414
415
  class CSRFMiddleware:
415
416
  """CSRF middleware that properly handles session modifications."""
416
417
 
417
- def __init__(self, app: ASGIApp): self.app = app
418
+ def __init__(self, app: ASGIApp):
419
+ self.app = app
418
420
 
419
421
  async def __call__(self, scope: Scope, receive: Receive, send: Send):
420
422
  if scope["type"] != "http":
@@ -436,13 +438,15 @@ class CSRFMiddleware:
436
438
  new_headers.append((b"set-cookie", cookie_value.encode()))
437
439
  message = {**message, "headers": new_headers}
438
440
  await send(message)
441
+
439
442
  await self.app(scope, receive, send_wrapper)
440
443
 
441
444
 
442
445
  class SecurityHeadersMiddleware:
443
446
  """Attach baseline browser security headers to HTTP responses."""
444
447
 
445
- def __init__(self, app: ASGIApp): self.app = app
448
+ def __init__(self, app: ASGIApp):
449
+ self.app = app
446
450
 
447
451
  async def __call__(self, scope: Scope, receive: Receive, send: Send):
448
452
  if scope["type"] != "http":
@@ -465,7 +469,8 @@ class SecurityHeadersMiddleware:
465
469
  class BodySizeLimitMiddleware:
466
470
  """Reject oversized HTTP request bodies before route or RPC parsing."""
467
471
 
468
- def __init__(self, app: ASGIApp): self.app = app
472
+ def __init__(self, app: ASGIApp):
473
+ self.app = app
469
474
 
470
475
  async def __call__(self, scope: Scope, receive: Receive, send: Send):
471
476
  if scope["type"] != "http":
@@ -526,7 +531,8 @@ class BodySizeLimitMiddleware:
526
531
  class AuthMiddleware:
527
532
  """Auth middleware using pure ASGI pattern for proper session handling."""
528
533
 
529
- def __init__(self, app: ASGIApp): self.app = app
534
+ def __init__(self, app: ASGIApp):
535
+ self.app = app
530
536
 
531
537
  async def __call__(self, scope: Scope, receive: Receive, send: Send):
532
538
  if scope["type"] != "http":
@@ -553,8 +559,7 @@ class AuthMiddleware:
553
559
  if auth_inst.is_auth_route(path):
554
560
  if is_authenticated:
555
561
  await RedirectResponse(
556
- url=auth_inst.settings.default_signin_redirect,
557
- status_code=303
562
+ url=auth_inst.settings.default_signin_redirect, status_code=303
558
563
  )(scope, receive, send)
559
564
  return
560
565
  await self.app(scope, receive, send)
@@ -570,7 +575,9 @@ class AuthMiddleware:
570
575
  )(scope, receive, send)
571
576
  return
572
577
  if not auth_inst.check_role(auth_inst.get_payload(), required_roles):
573
- await RedirectResponse(url='/unauthorized', status_code=303)(scope, receive, send)
578
+ await RedirectResponse(url="/unauthorized", status_code=303)(
579
+ scope, receive, send
580
+ )
574
581
  return
575
582
 
576
583
  if auth_inst.is_private_route(path):
@@ -587,24 +594,25 @@ class AuthMiddleware:
587
594
  class RPCMiddleware:
588
595
  """RPC middleware using pure ASGI pattern."""
589
596
 
590
- def __init__(self, app: ASGIApp): self.app = app
597
+ def __init__(self, app: ASGIApp):
598
+ self.app = app
591
599
 
592
600
  async def __call__(self, scope: Scope, receive: Receive, send: Send):
593
601
  if scope["type"] != "http":
594
602
  await self.app(scope, receive, send)
595
603
  return
596
604
  request = Request(scope, receive, send)
597
- if request.headers.get('X-PP-RPC') == 'true' and request.method == 'POST':
605
+ if request.headers.get("X-PP-RPC") == "true" and request.method == "POST":
598
606
  from casp.rpc import _handle_rpc_request
599
- session = dict(request.session) if hasattr(
600
- request, 'session') else {}
607
+
608
+ session = dict(request.session) if hasattr(request, "session") else {}
601
609
  response = await _handle_rpc_request(request, session)
602
610
  await response(scope, receive, send)
603
611
  return
604
612
  await self.app(scope, receive, send)
605
613
 
606
614
 
607
- RATE_LIMIT_PAGES = os.getenv('RATE_LIMIT_PAGES', '200/minute')
615
+ RATE_LIMIT_PAGES = os.getenv("RATE_LIMIT_PAGES", "200/minute")
608
616
 
609
617
 
610
618
  def client_ip(request: Request) -> str:
@@ -616,13 +624,13 @@ def client_ip(request: Request) -> str:
616
624
  `TRUST_FORWARDED_HEADERS`, because a client can otherwise set that header
617
625
  itself and mint a fresh bucket per request.
618
626
  """
619
- if _bool_env('TRUST_FORWARDED_HEADERS'):
620
- forwarded = request.headers.get('x-forwarded-for', '')
621
- first_hop = forwarded.split(',', 1)[0].strip()
627
+ if _bool_env("TRUST_FORWARDED_HEADERS"):
628
+ forwarded = request.headers.get("x-forwarded-for", "")
629
+ first_hop = forwarded.split(",", 1)[0].strip()
622
630
  if first_hop:
623
631
  return first_hop
624
632
 
625
- return request.client.host if request.client else 'unknown'
633
+ return request.client.host if request.client else "unknown"
626
634
 
627
635
 
628
636
  class RateLimitMiddleware:
@@ -648,20 +656,17 @@ class RateLimitMiddleware:
648
656
  return
649
657
 
650
658
  path = scope.get("path", "")
651
- if path == '/health':
659
+ if path == "/health":
652
660
  await self.app(scope, receive, send)
653
661
  return
654
662
 
655
663
  request = Request(scope, receive, send)
656
- allowed, wait_seconds = rpc_limiter.check(
657
- '__page__', client_ip(request), RATE_LIMIT_PAGES
658
- )
664
+ allowed, wait_seconds = rpc_limiter.check("__page__", client_ip(request), RATE_LIMIT_PAGES)
659
665
 
660
666
  if not allowed:
661
667
  response = HTMLResponse(
662
668
  content=(
663
- "<h1>429 - Too Many Requests</h1>"
664
- "<p>Please slow down and try again shortly.</p>"
669
+ "<h1>429 - Too Many Requests</h1><p>Please slow down and try again shortly.</p>"
665
670
  ),
666
671
  status_code=429,
667
672
  headers={"Retry-After": str(math.ceil(wait_seconds))},
@@ -675,7 +680,8 @@ class RateLimitMiddleware:
675
680
  class RequestDiagnosticsMiddleware:
676
681
  """Log request start/end in dev and fail visibly when a route stalls."""
677
682
 
678
- def __init__(self, app: ASGIApp): self.app = app
683
+ def __init__(self, app: ASGIApp):
684
+ self.app = app
679
685
 
680
686
  async def __call__(self, scope: Scope, receive: Receive, send: Send):
681
687
  if scope["type"] != "http":
@@ -687,9 +693,10 @@ class RequestDiagnosticsMiddleware:
687
693
  is_public_file = (
688
694
  method in {"GET", "HEAD"}
689
695
  and resolve_safe_public_path(
690
- 'public',
691
- path.lstrip('/'),
692
- ) is not None
696
+ "public",
697
+ path.lstrip("/"),
698
+ )
699
+ is not None
693
700
  )
694
701
  should_log = not is_public_file
695
702
  started = time.perf_counter()
@@ -728,14 +735,12 @@ class RequestDiagnosticsMiddleware:
728
735
  except Exception:
729
736
  if should_log and not IS_PRODUCTION:
730
737
  elapsed_ms = int((time.perf_counter() - started) * 1000)
731
- print(
732
- f"[request:error] {method} {path} after {elapsed_ms}ms", flush=True)
738
+ print(f"[request:error] {method} {path} after {elapsed_ms}ms", flush=True)
733
739
  raise
734
740
  finally:
735
741
  if should_log and not IS_PRODUCTION:
736
742
  elapsed_ms = int((time.perf_counter() - started) * 1000)
737
- print(
738
- f"[request:end] {method} {path} {elapsed_ms}ms", flush=True)
743
+ print(f"[request:end] {method} {path} {elapsed_ms}ms", flush=True)
739
744
 
740
745
 
741
746
  # ====
@@ -885,7 +890,7 @@ def is_request_cacheable(request: Request) -> bool:
885
890
  know whether the page it just rendered contains per-user data, so the
886
891
  presence of an authenticated session is the safe signal.
887
892
  """
888
- if request.method != 'GET':
893
+ if request.method != "GET":
889
894
  return False
890
895
 
891
896
  try:
@@ -899,7 +904,7 @@ def register_routes():
899
904
  idx = get_files_index()
900
905
  for route in idx.routes:
901
906
  base_path = f"src/app/{route.fs_dir}" if route.fs_dir else "src/app"
902
- full_path = f"{base_path}/index.py".replace('//', '/')
907
+ full_path = f"{base_path}/index.py".replace("//", "/")
903
908
  register_single_route(route.fastapi_rule, full_path)
904
909
 
905
910
 
@@ -929,7 +934,7 @@ def register_single_route(url_pattern: str, file_path: str):
929
934
  page_content_source = file_path
930
935
 
931
936
  module = load_route_module(file_path)
932
- if not hasattr(module, 'page'):
937
+ if not hasattr(module, "page"):
933
938
  raise AttributeError(f"Missing 'def page():' in {file_path}")
934
939
 
935
940
  sig = get_page_signature(file_path, module.page)
@@ -938,8 +943,8 @@ def register_single_route(url_pattern: str, file_path: str):
938
943
 
939
944
  if kwargs:
940
945
  call_args.append(kwargs)
941
- if 'request' in sig.parameters:
942
- call_kwargs['request'] = request
946
+ if "request" in sig.parameters:
947
+ call_kwargs["request"] = request
943
948
 
944
949
  for name, param in sig.parameters.items():
945
950
  if name in call_kwargs:
@@ -949,8 +954,7 @@ def register_single_route(url_pattern: str, file_path: str):
949
954
  if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
950
955
  continue
951
956
  if name in request.query_params:
952
- call_kwargs[name] = _coerce_query_param(
953
- request, name, param)
957
+ call_kwargs[name] = _coerce_query_param(request, name, param)
954
958
 
955
959
  if inspect.iscoroutinefunction(module.page):
956
960
  result = await module.page(*call_args, **call_kwargs)
@@ -963,7 +967,7 @@ def register_single_route(url_pattern: str, file_path: str):
963
967
  if inspect.isasyncgen(result) or inspect.isgenerator(result):
964
968
  return SSE(cast("AsyncGenerator | Generator", result))
965
969
 
966
- cache_settings = getattr(module, 'cache_settings', None)
970
+ cache_settings = getattr(module, "cache_settings", None)
967
971
  if cache_settings:
968
972
  req_should_cache = cache_settings.enabled
969
973
  req_cache_ttl = cache_settings.ttl
@@ -971,25 +975,24 @@ def register_single_route(url_pattern: str, file_path: str):
971
975
  if isinstance(result, tuple):
972
976
  page_content = result[0]
973
977
  content = str(page_content)
974
- page_content_source = getattr(
975
- page_content, 'source_path', file_path)
978
+ page_content_source = getattr(page_content, "source_path", file_path)
976
979
  if len(result) >= 2 and isinstance(result[1], dict):
977
980
  page_layout_props = result[1]
978
981
  else:
979
982
  content = str(result)
980
- page_content_source = getattr(result, 'source_path', file_path)
983
+ page_content_source = getattr(result, "source_path", file_path)
981
984
 
982
985
  dynamic_meta = _runtime_metadata.get()
983
- static_meta = getattr(module, 'metadata', None)
986
+ static_meta = getattr(module, "metadata", None)
984
987
 
985
988
  def extract_meta(obj):
986
989
  d = {}
987
990
  if not obj:
988
991
  return d
989
992
  if obj.title:
990
- d['title'] = obj.title
993
+ d["title"] = obj.title
991
994
  if obj.description:
992
- d['description'] = obj.description
995
+ d["description"] = obj.description
993
996
  if obj.extra:
994
997
  d.update(obj.extra)
995
998
  return d
@@ -1007,12 +1010,12 @@ def register_single_route(url_pattern: str, file_path: str):
1007
1010
  context_data=full_context,
1008
1011
  page_component_source=page_content_source,
1009
1012
  control_mode=True,
1010
- component_compiler=transform_components
1013
+ component_compiler=transform_components,
1011
1014
  )
1012
1015
 
1013
1016
  html_output = finalize_html(html_output)
1014
1017
  response = HTMLResponse(content=html_output)
1015
- response.headers['X-PP-Root-Layout'] = root_layout_id
1018
+ response.headers["X-PP-Root-Layout"] = root_layout_id
1016
1019
 
1017
1020
  # Cache Save Logic
1018
1021
  should_cache = False
@@ -1032,23 +1035,27 @@ def register_single_route(url_pattern: str, file_path: str):
1032
1035
 
1033
1036
  return response
1034
1037
 
1035
- endpoint = file_path.replace('/', '_').replace('\\', '_').replace(
1036
- '.', '_').replace('[', '').replace(']', '').replace('(', '').replace(')', '')
1038
+ endpoint = (
1039
+ file_path.replace("/", "_")
1040
+ .replace("\\", "_")
1041
+ .replace(".", "_")
1042
+ .replace("[", "")
1043
+ .replace("]", "")
1044
+ .replace("(", "")
1045
+ .replace(")", "")
1046
+ )
1037
1047
 
1038
- route_methods = ['GET', 'POST']
1048
+ route_methods = ["GET", "POST"]
1039
1049
  module = load_route_module(file_path)
1040
- declared_route_methods = getattr(module, 'route_methods', None)
1050
+ declared_route_methods = getattr(module, "route_methods", None)
1041
1051
  if isinstance(declared_route_methods, (list, tuple)) and declared_route_methods:
1042
1052
  normalized_methods = [
1043
- str(method).strip().upper()
1044
- for method in declared_route_methods
1045
- if str(method).strip()
1053
+ str(method).strip().upper() for method in declared_route_methods if str(method).strip()
1046
1054
  ]
1047
1055
  if normalized_methods:
1048
1056
  route_methods = list(dict.fromkeys(normalized_methods))
1049
1057
 
1050
- app.add_api_route(url_pattern, make_handler,
1051
- methods=route_methods, name=endpoint)
1058
+ app.add_api_route(url_pattern, make_handler, methods=route_methods, name=endpoint)
1052
1059
 
1053
1060
 
1054
1061
  def defer_component_roots(html_output: str) -> str:
@@ -1068,7 +1075,7 @@ def defer_component_roots(html_output: str) -> str:
1068
1075
  outer template is materialized, so morphing and RPC re-render still operate
1069
1076
  on live ``[pp-component]`` DOM.
1070
1077
  """
1071
- if 'pp-component' not in html_output:
1078
+ if "pp-component" not in html_output:
1072
1079
  return html_output
1073
1080
 
1074
1081
  # Fast path: the render pipeline recorded the page subtree's exact
@@ -1079,11 +1086,7 @@ def defer_component_roots(html_output: str) -> str:
1079
1086
  # the region string-level. Every mismatch falls back to the full parse.
1080
1087
  if not _DEFER_FAST_DISABLED:
1081
1088
  region = _finalize_page_region.get()
1082
- if (
1083
- region
1084
- and len(region) >= _DEFER_REGION_MIN_BYTES
1085
- and html_output.count(region) == 1
1086
- ):
1089
+ if region and len(region) >= _DEFER_REGION_MIN_BYTES and html_output.count(region) == 1:
1087
1090
  deferred = _defer_with_verbatim_region(html_output, region)
1088
1091
  if deferred is not None:
1089
1092
  return deferred
@@ -1093,8 +1096,12 @@ def defer_component_roots(html_output: str) -> str:
1093
1096
  return _defer_component_roots_in_soup(soup, placeholders, html_output)
1094
1097
 
1095
1098
 
1096
- _DEFER_FAST_DISABLED = os.getenv(
1097
- 'CASP_DEFER_FAST', '').strip().lower() in {'0', 'off', 'false', 'no'}
1099
+ _DEFER_FAST_DISABLED = os.getenv("CASP_DEFER_FAST", "").strip().lower() in {
1100
+ "0",
1101
+ "off",
1102
+ "false",
1103
+ "no",
1104
+ }
1098
1105
  # Below this, masking the region saves less than the two extra scans it costs.
1099
1106
  _DEFER_REGION_MIN_BYTES = 4096
1100
1107
 
@@ -1111,8 +1118,7 @@ def _protect_region_brace_entities(value: str) -> str:
1111
1118
  """
1112
1119
  from casp.html_native import _ESCAPED_BRACE_ENTITY_RE
1113
1120
 
1114
- return _ESCAPED_BRACE_ENTITY_RE.sub(
1115
- lambda match: '&amp;' + match.group(0)[1:], value)
1121
+ return _ESCAPED_BRACE_ENTITY_RE.sub(lambda match: "&amp;" + match.group(0)[1:], value)
1116
1122
 
1117
1123
 
1118
1124
  def _defer_with_verbatim_region(html_output: str, region: str) -> Optional[str]:
@@ -1130,10 +1136,9 @@ def _defer_with_verbatim_region(html_output: str, region: str) -> Optional[str]:
1130
1136
  # stripped core.
1131
1137
  region_root_key = None
1132
1138
  region_core = region.strip()
1133
- open_tag_end = region_core.find('>')
1134
- if region_core.startswith('<') and open_tag_end > 0:
1135
- key_match = re.search(
1136
- r'\spp-component="([^"]+)"', region_core[:open_tag_end + 1])
1139
+ open_tag_end = region_core.find(">")
1140
+ if region_core.startswith("<") and open_tag_end > 0:
1141
+ key_match = re.search(r'\spp-component="([^"]+)"', region_core[: open_tag_end + 1])
1137
1142
  if key_match:
1138
1143
  region_root_key = key_match.group(1)
1139
1144
 
@@ -1160,51 +1165,47 @@ def _defer_with_verbatim_region(html_output: str, region: str) -> Optional[str]:
1160
1165
  # template (the outermost one gets wrapped below, or already is one), so
1161
1166
  # its entities need the protection layer but no wrapper of its own.
1162
1167
  region_enclosed = any(
1163
- isinstance(parent, Tag) and parent.has_attr('pp-component')
1164
- for parent in token_node.parents
1168
+ isinstance(parent, Tag) and parent.has_attr("pp-component") for parent in token_node.parents
1165
1169
  )
1166
1170
 
1167
- if not region_enclosed and region_root_key is None and 'pp-component' in region:
1171
+ if not region_enclosed and region_root_key is None and "pp-component" in region:
1168
1172
  # Boundaries live inside the region but its root is not one: they
1169
1173
  # would need wrapping at arbitrary depth, which only the tree pass can
1170
1174
  # locate.
1171
1175
  return None
1172
1176
 
1173
1177
  roots = []
1174
- stack = [
1175
- child for child in reversed(body.contents) if isinstance(child, Tag)
1176
- ]
1178
+ stack = [child for child in reversed(body.contents) if isinstance(child, Tag)]
1177
1179
  while stack:
1178
1180
  el = stack.pop()
1179
- if el.has_attr('pp-component'):
1180
- if el.name != 'template':
1181
+ if el.has_attr("pp-component"):
1182
+ if el.name != "template":
1181
1183
  roots.append(el)
1182
1184
  continue
1183
- stack.extend(
1184
- child for child in reversed(el.contents) if isinstance(child, Tag)
1185
- )
1185
+ stack.extend(child for child in reversed(el.contents) if isinstance(child, Tag))
1186
1186
 
1187
1187
  for root in roots:
1188
- key = root.get('pp-component')
1188
+ key = root.get("pp-component")
1189
1189
  if key is None:
1190
1190
  continue
1191
- template = soup.new_tag('template')
1192
- template['pp-component'] = key
1191
+ template = soup.new_tag("template")
1192
+ template["pp-component"] = key
1193
1193
  root.insert_before(template)
1194
1194
  template.append(root.extract())
1195
1195
 
1196
1196
  if placeholders:
1197
+
1197
1198
  def protect_brace_entities(value: str) -> str:
1198
1199
  return _ESCAPED_BRACE_PLACEHOLDER_RE.sub(
1199
1200
  lambda match: placeholders.get(match.group(0), match.group(0)),
1200
1201
  value,
1201
1202
  )
1202
1203
 
1203
- for template in body.select('template[pp-component]'):
1204
+ for template in body.select("template[pp-component]"):
1204
1205
  for node in list(template.descendants):
1205
1206
  if isinstance(node, NavigableString):
1206
1207
  original = str(node)
1207
- if '__PP_ESCAPED_BRACE_' not in original:
1208
+ if "__PP_ESCAPED_BRACE_" not in original:
1208
1209
  continue
1209
1210
  content = protect_brace_entities(original)
1210
1211
  if content != original:
@@ -1212,18 +1213,16 @@ def _defer_with_verbatim_region(html_output: str, region: str) -> Optional[str]:
1212
1213
  elif isinstance(node, Tag):
1213
1214
  for name, value in node.attrs.items():
1214
1215
  if isinstance(value, str):
1215
- if '__PP_ESCAPED_BRACE_' in value:
1216
- node.attrs[name] = protect_brace_entities(
1217
- value)
1216
+ if "__PP_ESCAPED_BRACE_" in value:
1217
+ node.attrs[name] = protect_brace_entities(value)
1218
1218
  elif isinstance(value, list):
1219
1219
  for index, item in enumerate(value):
1220
1220
  item = str(item)
1221
- if '__PP_ESCAPED_BRACE_' in item:
1221
+ if "__PP_ESCAPED_BRACE_" in item:
1222
1222
  item = protect_brace_entities(item)
1223
1223
  value[index] = item
1224
1224
 
1225
- serialized = restore_escaped_brace_entities(
1226
- serialize_fragment(soup), placeholders)
1225
+ serialized = restore_escaped_brace_entities(serialize_fragment(soup), placeholders)
1227
1226
  if token not in serialized:
1228
1227
  return None
1229
1228
 
@@ -1235,15 +1234,15 @@ def _defer_with_verbatim_region(html_output: str, region: str) -> Optional[str]:
1235
1234
  # would be ambiguous to split off string-level, so leave that shape to
1236
1235
  # the full parse.
1237
1236
  core = region_core
1238
- if not core.startswith('<') or core[1] in ('!', '?') or core.endswith('-->'):
1237
+ if not core.startswith("<") or core[1] in ("!", "?") or core.endswith("-->"):
1239
1238
  return None
1240
- prefix_len = region.find('<')
1239
+ prefix_len = region.find("<")
1241
1240
  prefix = region[:prefix_len]
1242
- suffix = region[prefix_len + len(core):]
1241
+ suffix = region[prefix_len + len(core) :]
1243
1242
  region_out = (
1244
1243
  f'{prefix}<template pp-component="{region_root_key}">'
1245
- f'{_protect_region_brace_entities(core)}'
1246
- f'</template>{suffix}'
1244
+ f"{_protect_region_brace_entities(core)}"
1245
+ f"</template>{suffix}"
1247
1246
  )
1248
1247
  else:
1249
1248
  region_out = region
@@ -1261,6 +1260,7 @@ def _defer_component_roots_in_soup(
1261
1260
  Split out so callers that already parsed the document can reuse the
1262
1261
  component-deferral pass.
1263
1262
  """
1263
+
1264
1264
  def unchanged() -> str:
1265
1265
  return fallback_html
1266
1266
 
@@ -1277,27 +1277,23 @@ def _defer_component_roots_in_soup(
1277
1277
  # element already inside a ``<template pp-component>`` stays untouched,
1278
1278
  # matching the ancestor-check semantics.
1279
1279
  roots = []
1280
- stack = [
1281
- child for child in reversed(body.contents) if isinstance(child, Tag)
1282
- ]
1280
+ stack = [child for child in reversed(body.contents) if isinstance(child, Tag)]
1283
1281
  while stack:
1284
1282
  el = stack.pop()
1285
- if el.has_attr('pp-component'):
1286
- if el.name != 'template':
1283
+ if el.has_attr("pp-component"):
1284
+ if el.name != "template":
1287
1285
  roots.append(el)
1288
1286
  continue
1289
- stack.extend(
1290
- child for child in reversed(el.contents) if isinstance(child, Tag)
1291
- )
1287
+ stack.extend(child for child in reversed(el.contents) if isinstance(child, Tag))
1292
1288
  if not roots:
1293
1289
  return unchanged()
1294
1290
 
1295
1291
  for root in roots:
1296
- key = root.get('pp-component')
1292
+ key = root.get("pp-component")
1297
1293
  if key is None:
1298
1294
  continue
1299
- template = soup.new_tag('template')
1300
- template['pp-component'] = key
1295
+ template = soup.new_tag("template")
1296
+ template["pp-component"] = key
1301
1297
  root.insert_before(template)
1302
1298
  template.append(root.extract())
1303
1299
 
@@ -1321,11 +1317,11 @@ def _defer_component_roots_in_soup(
1321
1317
  value,
1322
1318
  )
1323
1319
 
1324
- for template in body.select('template[pp-component]'):
1320
+ for template in body.select("template[pp-component]"):
1325
1321
  for node in list(template.descendants):
1326
1322
  if isinstance(node, NavigableString):
1327
1323
  original = str(node)
1328
- if '__PP_ESCAPED_BRACE_' not in original:
1324
+ if "__PP_ESCAPED_BRACE_" not in original:
1329
1325
  continue
1330
1326
  content = protect_brace_entities(original)
1331
1327
  if content != original:
@@ -1333,13 +1329,12 @@ def _defer_component_roots_in_soup(
1333
1329
  elif isinstance(node, Tag):
1334
1330
  for name, value in node.attrs.items():
1335
1331
  if isinstance(value, str):
1336
- if '__PP_ESCAPED_BRACE_' in value:
1337
- node.attrs[name] = protect_brace_entities(
1338
- value)
1332
+ if "__PP_ESCAPED_BRACE_" in value:
1333
+ node.attrs[name] = protect_brace_entities(value)
1339
1334
  elif isinstance(value, list):
1340
1335
  for index, item in enumerate(value):
1341
1336
  item = str(item)
1342
- if '__PP_ESCAPED_BRACE_' in item:
1337
+ if "__PP_ESCAPED_BRACE_" in item:
1343
1338
  item = protect_brace_entities(item)
1344
1339
  value[index] = item
1345
1340
 
@@ -1364,11 +1359,9 @@ _DEV_CONSOLE_BRIDGE_TAG = '<script src="/__pp-devlog.js"></script>'
1364
1359
  def _inject_dev_console_bridge(html_output: str) -> str:
1365
1360
  if not os.getenv("CASPIAN_BROWSER_SYNC_PORT"):
1366
1361
  return html_output
1367
- if '</head>' not in html_output or '__pp-devlog.js' in html_output:
1362
+ if "</head>" not in html_output or "__pp-devlog.js" in html_output:
1368
1363
  return html_output
1369
- return html_output.replace(
1370
- '</head>', f'{_DEV_CONSOLE_BRIDGE_TAG}</head>', 1
1371
- )
1364
+ return html_output.replace("</head>", f"{_DEV_CONSOLE_BRIDGE_TAG}</head>", 1)
1372
1365
 
1373
1366
 
1374
1367
  def finalize_html(html_output: str) -> str:
@@ -1462,19 +1455,19 @@ async def _render_special_page(
1462
1455
  @app.exception_handler(StarletteHTTPException)
1463
1456
  async def custom_404_handler(request: Request, exc: StarletteHTTPException):
1464
1457
  if exc.status_code == 404:
1465
- not_found_path = os.path.join('src', 'app', 'not_found.py')
1458
+ not_found_path = os.path.join("src", "app", "not_found.py")
1466
1459
  if os.path.exists(not_found_path):
1467
1460
  html_output, root_layout_id = await _render_special_page(
1468
1461
  page_path=not_found_path,
1469
1462
  request=request,
1470
1463
  default_metadata={
1471
- 'title': "Page Not Found",
1472
- 'description': "The page you are looking for does not exist."
1464
+ "title": "Page Not Found",
1465
+ "description": "The page you are looking for does not exist.",
1473
1466
  },
1474
1467
  context_data={},
1475
1468
  )
1476
1469
  resp = HTMLResponse(content=html_output, status_code=404)
1477
- resp.headers['X-PP-Root-Layout'] = root_layout_id
1470
+ resp.headers["X-PP-Root-Layout"] = root_layout_id
1478
1471
  return resp
1479
1472
  return HTMLResponse(content=f"<h1>{exc.detail}</h1>", status_code=exc.status_code)
1480
1473
 
@@ -1486,30 +1479,33 @@ async def custom_general_exception_handler(request: Request, exc: Exception):
1486
1479
  error_message = _client_error_message(exc)
1487
1480
  error_trace = full_trace if not IS_PRODUCTION else None
1488
1481
 
1489
- error_page_path = os.path.join('src', 'app', 'error.py')
1482
+ error_page_path = os.path.join("src", "app", "error.py")
1490
1483
  if os.path.exists(error_page_path):
1491
- context_data = {'request': request,
1492
- 'error_message': error_message, 'error_trace': error_trace}
1484
+ context_data = {
1485
+ "request": request,
1486
+ "error_message": error_message,
1487
+ "error_trace": error_trace,
1488
+ }
1493
1489
  try:
1494
1490
  html_output, root_layout_id = await _render_special_page(
1495
1491
  page_path=error_page_path,
1496
1492
  request=request,
1497
1493
  default_metadata={
1498
- 'title': 'Application Error',
1499
- 'description': 'An unexpected error occurred.'
1494
+ "title": "Application Error",
1495
+ "description": "An unexpected error occurred.",
1500
1496
  },
1501
1497
  context_data=context_data,
1502
1498
  )
1503
1499
  resp = HTMLResponse(content=html_output, status_code=500)
1504
- resp.headers['X-PP-Root-Layout'] = root_layout_id
1500
+ resp.headers["X-PP-Root-Layout"] = root_layout_id
1505
1501
  return resp
1506
1502
  except Exception as render_exc:
1507
1503
  print("Error rendering error.py:", render_exc)
1508
1504
  return HTMLResponse(
1509
- content=f"<h1>500 - Internal Server Error</h1><p>{error_message}</p>",
1510
- status_code=500
1505
+ content=f"<h1>500 - Internal Server Error</h1><p>{error_message}</p>", status_code=500
1511
1506
  )
1512
1507
 
1508
+
1513
1509
  # ====
1514
1510
  # Middleware Order (LAST added runs FIRST)
1515
1511
  # ====
@@ -1522,9 +1518,9 @@ app.add_middleware(
1522
1518
  secret_key=_get_session_secret(),
1523
1519
  session_cookie=SESSION_COOKIE_NAME,
1524
1520
  max_age=SESSION_LIFETIME_HOURS * 3600,
1525
- same_site='lax',
1521
+ same_site="lax",
1526
1522
  https_only=IS_PRODUCTION,
1527
- path='/',
1523
+ path="/",
1528
1524
  )
1529
1525
  app.add_middleware(BodySizeLimitMiddleware)
1530
1526
  # Outermost of the security layers: reject flooding before any session
@@ -1535,9 +1531,9 @@ app.add_middleware(RateLimitMiddleware)
1535
1531
  # in attachment mode unless its MIME type is explicitly safe to render inline.
1536
1532
  app.add_middleware(
1537
1533
  PublicFilesMiddleware,
1538
- directory='public',
1534
+ directory="public",
1539
1535
  inline_safe_subdirectories={
1540
- 'uploads': INLINE_SAFE_UPLOAD_MEDIA_TYPES,
1536
+ "uploads": INLINE_SAFE_UPLOAD_MEDIA_TYPES,
1541
1537
  },
1542
1538
  )
1543
1539
  app.add_middleware(SecurityHeadersMiddleware)
@@ -1545,9 +1541,9 @@ app.add_middleware(SecurityHeadersMiddleware)
1545
1541
  if not IS_PRODUCTION:
1546
1542
  app.add_middleware(RequestDiagnosticsMiddleware)
1547
1543
 
1548
- if __name__ == '__main__':
1549
- port = int(os.getenv('PORT', 5091))
1550
- workers = max(1, int(os.getenv('UVICORN_WORKERS', '1')))
1544
+ if __name__ == "__main__":
1545
+ port = int(os.getenv("PORT", 5091))
1546
+ workers = max(1, int(os.getenv("UVICORN_WORKERS", "1")))
1551
1547
  uvicorn.run(
1552
1548
  "main:app",
1553
1549
  host="0.0.0.0",