hexastack-fastapi 0.0.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.
Potentially problematic release.
This version of hexastack-fastapi might be problematic. Click here for more details.
- hexastack_fastapi/__init__.py +6 -0
- hexastack_fastapi/adapters/__init__.py +41 -0
- hexastack_fastapi/adapters/app.py +118 -0
- hexastack_fastapi/adapters/db_session.py +160 -0
- hexastack_fastapi/adapters/dependencies.py +190 -0
- hexastack_fastapi/adapters/docs.py +80 -0
- hexastack_fastapi/adapters/health.py +62 -0
- hexastack_fastapi/adapters/routing.py +203 -0
- hexastack_fastapi/adapters/ui.py +491 -0
- hexastack_fastapi/infra/__init__.py +41 -0
- hexastack_fastapi/infra/autodiscovery.py +103 -0
- hexastack_fastapi/infra/bootstrap.py +85 -0
- hexastack_fastapi/infra/config.py +113 -0
- hexastack_fastapi/infra/decorators.py +199 -0
- hexastack_fastapi/infra/exception_handlers.py +60 -0
- hexastack_fastapi/infra/middleware/__init__.py +11 -0
- hexastack_fastapi/infra/middleware/correlation.py +100 -0
- hexastack_fastapi/infra/middleware/logging.py +95 -0
- hexastack_fastapi/py.typed +0 -0
- hexastack_fastapi/testing/__init__.py +18 -0
- hexastack_fastapi/testing/cursor.py +115 -0
- hexastack_fastapi/testing/recorder.py +238 -0
- hexastack_fastapi/testing/server.py +120 -0
- hexastack_fastapi-0.0.0.dist-info/METADATA +169 -0
- hexastack_fastapi-0.0.0.dist-info/RECORD +27 -0
- hexastack_fastapi-0.0.0.dist-info/WHEEL +4 -0
- hexastack_fastapi-0.0.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
|
2
|
+
|
|
3
|
+
from hexastack_core.utils.context import (
|
|
4
|
+
UserContext,
|
|
5
|
+
correlation_id_ctx,
|
|
6
|
+
new_correlation_id,
|
|
7
|
+
set_correlation_id,
|
|
8
|
+
set_user_context,
|
|
9
|
+
user_ctx,
|
|
10
|
+
)
|
|
11
|
+
from hexastack_fastapi.infra.config import HexastackFastApiConfig
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CorrelationHttpMiddleware:
|
|
15
|
+
"""ASGI middleware managing correlation and user identity context from HTTP headers.
|
|
16
|
+
|
|
17
|
+
Notes/Architectural Intent:
|
|
18
|
+
Extracts or generates correlation IDs and tenant/user identities from incoming
|
|
19
|
+
request headers, sets them in ContextVars for CQRS/logging consumption, and
|
|
20
|
+
attaches the correlation ID to outgoing HTTP response headers.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
app: ASGIApp,
|
|
26
|
+
config: HexastackFastApiConfig | None = None,
|
|
27
|
+
) -> None:
|
|
28
|
+
"""Initialize middleware with target ASGI application and configuration.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
app: The downstream ASGI application callable.
|
|
32
|
+
config: Optional HexastackFastApiConfig controlling header names.
|
|
33
|
+
"""
|
|
34
|
+
self._app = app
|
|
35
|
+
cfg = config or HexastackFastApiConfig()
|
|
36
|
+
self._correlation_header = cfg.correlation_header.lower().encode("latin1")
|
|
37
|
+
self._correlation_header_name = cfg.correlation_header
|
|
38
|
+
self._user_header = cfg.user_header.lower().encode("latin1")
|
|
39
|
+
self._tenant_header = cfg.tenant_header.lower().encode("latin1")
|
|
40
|
+
|
|
41
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
42
|
+
"""Process incoming ASGI request, inject context tokens, and wrap response.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
scope: ASGI connection scope dictionary.
|
|
46
|
+
receive: ASGI receive callable.
|
|
47
|
+
send: ASGI send callable.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
None.
|
|
51
|
+
|
|
52
|
+
Raises:
|
|
53
|
+
Exception: Propagates unhandled exceptions to upstream exception handlers.
|
|
54
|
+
"""
|
|
55
|
+
if scope["type"] != "http":
|
|
56
|
+
await self._app(scope, receive, send)
|
|
57
|
+
return
|
|
58
|
+
|
|
59
|
+
headers: list[tuple[bytes, bytes]] = scope.get("headers", [])
|
|
60
|
+
header_map: dict[bytes, bytes] = dict(headers)
|
|
61
|
+
|
|
62
|
+
# 1. Extract or generate Correlation ID
|
|
63
|
+
raw_cid = header_map.get(self._correlation_header)
|
|
64
|
+
correlation_id = raw_cid.decode("latin1") if raw_cid else new_correlation_id()
|
|
65
|
+
corr_token = set_correlation_id(correlation_id)
|
|
66
|
+
|
|
67
|
+
# 2. Extract User / Tenant context
|
|
68
|
+
raw_uid = header_map.get(self._user_header)
|
|
69
|
+
raw_tid = header_map.get(self._tenant_header)
|
|
70
|
+
user_token = None
|
|
71
|
+
if raw_uid or raw_tid:
|
|
72
|
+
user_context = UserContext(
|
|
73
|
+
user_id=raw_uid.decode("latin1") if raw_uid else "anonymous",
|
|
74
|
+
tenant_id=raw_tid.decode("latin1") if raw_tid else None,
|
|
75
|
+
)
|
|
76
|
+
user_token = set_user_context(user_context)
|
|
77
|
+
|
|
78
|
+
# 3. Intercept response to inject correlation header
|
|
79
|
+
async def send_wrapper(message: Message) -> None:
|
|
80
|
+
if message["type"] == "http.response.start":
|
|
81
|
+
resp_headers: list[tuple[bytes, bytes]] = list(
|
|
82
|
+
message.get("headers", [])
|
|
83
|
+
)
|
|
84
|
+
resp_headers.append(
|
|
85
|
+
(self._correlation_header, correlation_id.encode("latin1"))
|
|
86
|
+
)
|
|
87
|
+
message["headers"] = resp_headers
|
|
88
|
+
await send(message)
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
await self._app(scope, receive, send_wrapper)
|
|
92
|
+
finally:
|
|
93
|
+
correlation_id_ctx.reset(corr_token)
|
|
94
|
+
if user_token is not None:
|
|
95
|
+
user_ctx.reset(user_token)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
__all__ = [
|
|
99
|
+
"CorrelationHttpMiddleware",
|
|
100
|
+
]
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import time
|
|
2
|
+
|
|
3
|
+
from rodi import Container
|
|
4
|
+
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
|
5
|
+
|
|
6
|
+
from hexastack_core.adapters.logging.standard import StandardLogger
|
|
7
|
+
from hexastack_core.ports.logging import LoggingPort
|
|
8
|
+
from hexastack_fastapi.infra.config import HexastackFastApiConfig
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RequestLoggingHttpMiddleware:
|
|
12
|
+
"""ASGI middleware producing structured access logs for HTTP requests.
|
|
13
|
+
|
|
14
|
+
Notes/Architectural Intent:
|
|
15
|
+
Measures HTTP transaction duration, captures client metadata and response status,
|
|
16
|
+
and logs via the LoggingPort resolved from the DI container.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
app: ASGIApp,
|
|
22
|
+
config: HexastackFastApiConfig | None = None,
|
|
23
|
+
container: Container | None = None,
|
|
24
|
+
logger: LoggingPort | None = None,
|
|
25
|
+
) -> None:
|
|
26
|
+
"""Initialize request logging middleware.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
app: Downstream ASGI application.
|
|
30
|
+
config: Optional HexastackFastApiConfig instance.
|
|
31
|
+
container: Optional rodi Container to dynamically resolve LoggingPort.
|
|
32
|
+
logger: Optional pre-configured LoggingPort instance.
|
|
33
|
+
"""
|
|
34
|
+
self._app = app
|
|
35
|
+
self._cfg = config or HexastackFastApiConfig()
|
|
36
|
+
self._container = container
|
|
37
|
+
self._logger = logger or StandardLogger("hexastack.http")
|
|
38
|
+
self._exclude_paths = set(self._cfg.logging.exclude_paths)
|
|
39
|
+
|
|
40
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
41
|
+
"""Intercept request, measure duration, and log access details."""
|
|
42
|
+
if scope["type"] != "http" or not self._cfg.logging.enable:
|
|
43
|
+
await self._app(scope, receive, send)
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
path = scope.get("path", "/")
|
|
47
|
+
if path in self._exclude_paths:
|
|
48
|
+
await self._app(scope, receive, send)
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
method = scope.get("method", "GET")
|
|
52
|
+
http_version = scope.get("http_version", "1.1")
|
|
53
|
+
client = scope.get("client")
|
|
54
|
+
client_ip = client[0] if client else "unknown"
|
|
55
|
+
|
|
56
|
+
status_code = 500
|
|
57
|
+
start_time = time.perf_counter()
|
|
58
|
+
|
|
59
|
+
async def send_wrapper(message: Message) -> None:
|
|
60
|
+
nonlocal status_code
|
|
61
|
+
if message["type"] == "http.response.start":
|
|
62
|
+
status_code = message.get("status", 200)
|
|
63
|
+
await send(message)
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
await self._app(scope, receive, send_wrapper)
|
|
67
|
+
finally:
|
|
68
|
+
end_time = time.perf_counter()
|
|
69
|
+
duration_ms = round((end_time - start_time) * 1000, 2)
|
|
70
|
+
log = self._get_logger()
|
|
71
|
+
msg = f"{method} {path} HTTP/{http_version} -> {status_code} ({duration_ms}ms)"
|
|
72
|
+
extra = {
|
|
73
|
+
"http_method": method,
|
|
74
|
+
"http_path": path,
|
|
75
|
+
"http_status": status_code,
|
|
76
|
+
"duration_ms": duration_ms,
|
|
77
|
+
"client_ip": client_ip,
|
|
78
|
+
}
|
|
79
|
+
if status_code >= 500:
|
|
80
|
+
log.error(msg, extra=extra)
|
|
81
|
+
elif status_code >= 400:
|
|
82
|
+
log.warning(msg, extra=extra)
|
|
83
|
+
else:
|
|
84
|
+
log.info(msg, extra=extra)
|
|
85
|
+
|
|
86
|
+
def _get_logger(self) -> LoggingPort:
|
|
87
|
+
"""Resolve LoggingPort from container if available, else use fallback."""
|
|
88
|
+
if self._container is not None and LoggingPort in self._container:
|
|
89
|
+
return self._container.resolve(LoggingPort)
|
|
90
|
+
return self._logger
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
__all__ = [
|
|
94
|
+
"RequestLoggingHttpMiddleware",
|
|
95
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Testing utilities and fixtures for Hexastack FastAPI and DevTools applications."""
|
|
2
|
+
|
|
3
|
+
from hexastack_fastapi.testing.cursor import VIRTUAL_CURSOR_SCRIPT, smart_click
|
|
4
|
+
from hexastack_fastapi.testing.recorder import DemoNarrator
|
|
5
|
+
from hexastack_fastapi.testing.server import (
|
|
6
|
+
EphemeralServer,
|
|
7
|
+
ephemeral_server,
|
|
8
|
+
find_free_port,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"DemoNarrator",
|
|
13
|
+
"ephemeral_server",
|
|
14
|
+
"EphemeralServer",
|
|
15
|
+
"find_free_port",
|
|
16
|
+
"smart_click",
|
|
17
|
+
"VIRTUAL_CURSOR_SCRIPT",
|
|
18
|
+
]
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Visual virtual cursor overlay script and human mouse emulation for feature demo recordings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from playwright.sync_api import Locator, Page
|
|
9
|
+
|
|
10
|
+
VIRTUAL_CURSOR_SCRIPT = """
|
|
11
|
+
(() => {
|
|
12
|
+
let mouseX = 100;
|
|
13
|
+
let mouseY = 100;
|
|
14
|
+
let cursor = null;
|
|
15
|
+
let isPressed = false;
|
|
16
|
+
|
|
17
|
+
window.addEventListener('mousemove', (e) => {
|
|
18
|
+
mouseX = e.clientX;
|
|
19
|
+
mouseY = e.clientY;
|
|
20
|
+
if (cursor) {
|
|
21
|
+
cursor.style.left = mouseX + 'px';
|
|
22
|
+
cursor.style.top = mouseY + 'px';
|
|
23
|
+
}
|
|
24
|
+
}, { passive: true });
|
|
25
|
+
|
|
26
|
+
window.addEventListener('mousedown', () => {
|
|
27
|
+
isPressed = true;
|
|
28
|
+
if (cursor) {
|
|
29
|
+
cursor.style.backgroundColor = 'rgba(16, 185, 129, 0.9)'; // emerald-500
|
|
30
|
+
cursor.style.transform = 'translate(-50%, -50%) scale(1.4)';
|
|
31
|
+
}
|
|
32
|
+
}, { passive: true });
|
|
33
|
+
|
|
34
|
+
window.addEventListener('mouseup', () => {
|
|
35
|
+
isPressed = false;
|
|
36
|
+
if (cursor) {
|
|
37
|
+
cursor.style.backgroundColor = 'rgba(239, 68, 68, 0.8)';
|
|
38
|
+
cursor.style.transform = 'translate(-50%, -50%) scale(1.0)';
|
|
39
|
+
}
|
|
40
|
+
}, { passive: true });
|
|
41
|
+
|
|
42
|
+
const initCursor = () => {
|
|
43
|
+
if (document.getElementById('playwright-virtual-cursor')) return;
|
|
44
|
+
|
|
45
|
+
cursor = document.createElement('div');
|
|
46
|
+
cursor.id = 'playwright-virtual-cursor';
|
|
47
|
+
cursor.style.position = 'fixed';
|
|
48
|
+
cursor.style.left = mouseX + 'px';
|
|
49
|
+
cursor.style.top = mouseY + 'px';
|
|
50
|
+
cursor.style.width = '24px';
|
|
51
|
+
cursor.style.height = '24px';
|
|
52
|
+
cursor.style.borderRadius = '50%';
|
|
53
|
+
cursor.style.backgroundColor = isPressed ? 'rgba(16, 185, 129, 0.9)' : 'rgba(239, 68, 68, 0.8)';
|
|
54
|
+
cursor.style.border = '3px solid #fff';
|
|
55
|
+
cursor.style.boxShadow = '0 0 10px rgba(0,0,0,0.5), 0 0 5px rgba(239, 68, 68, 0.8)';
|
|
56
|
+
cursor.style.pointerEvents = 'none';
|
|
57
|
+
cursor.style.zIndex = '2147483647';
|
|
58
|
+
cursor.style.transition = 'transform 0.1s ease-out, background-color 0.1s ease';
|
|
59
|
+
cursor.style.transform = isPressed ? 'translate(-50%, -50%) scale(1.4)' : 'translate(-50%, -50%) scale(1.0)';
|
|
60
|
+
|
|
61
|
+
const root = document.documentElement || document.body;
|
|
62
|
+
if (root) {
|
|
63
|
+
root.appendChild(cursor);
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const interval = setInterval(() => {
|
|
68
|
+
const parent = document.documentElement || document.body;
|
|
69
|
+
if (parent) {
|
|
70
|
+
initCursor();
|
|
71
|
+
clearInterval(interval);
|
|
72
|
+
}
|
|
73
|
+
}, 50);
|
|
74
|
+
|
|
75
|
+
window.addEventListener('DOMContentLoaded', initCursor);
|
|
76
|
+
})();
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def smart_click(page: Page, locator: Locator | Any, steps: int = 35) -> None:
|
|
81
|
+
"""Emulate human-like smooth mouse movement, pause, and click for demo recordings.
|
|
82
|
+
|
|
83
|
+
If RECORD_DEMO is not active, executes instant locator.first.click() for maximum test speed.
|
|
84
|
+
"""
|
|
85
|
+
record_mode = os.environ.get("RECORD_DEMO") in ("1", "true", "True")
|
|
86
|
+
element = locator.first if hasattr(locator, "first") else locator
|
|
87
|
+
|
|
88
|
+
if not record_mode:
|
|
89
|
+
element.click()
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
element.scroll_into_view_if_needed()
|
|
93
|
+
box = element.bounding_box()
|
|
94
|
+
if not box:
|
|
95
|
+
element.click()
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
x = box["x"] + box["width"] / 2
|
|
99
|
+
y = box["y"] + box["height"] / 2
|
|
100
|
+
|
|
101
|
+
# Smooth human mouse movement
|
|
102
|
+
page.mouse.move(x, y, steps=steps)
|
|
103
|
+
page.wait_for_timeout(800) # Human-like pause showing target focus
|
|
104
|
+
|
|
105
|
+
# Visual click ripple
|
|
106
|
+
page.mouse.down()
|
|
107
|
+
page.wait_for_timeout(300)
|
|
108
|
+
page.mouse.up()
|
|
109
|
+
page.wait_for_timeout(600) # Settle delay
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
__all__ = [
|
|
113
|
+
"smart_click",
|
|
114
|
+
"VIRTUAL_CURSOR_SCRIPT",
|
|
115
|
+
]
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""Feature demo narrator and WebVTT caption generator for Playwright recordings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from hexastack_core.domain.exceptions import MissingDependencyError
|
|
11
|
+
from hexastack_fastapi.testing.cursor import VIRTUAL_CURSOR_SCRIPT
|
|
12
|
+
|
|
13
|
+
CAPTION_BANNER_SCRIPT = """
|
|
14
|
+
(() => {
|
|
15
|
+
const initBanner = () => {
|
|
16
|
+
if (document.getElementById('hexastack-caption-banner')) return;
|
|
17
|
+
const banner = document.createElement('div');
|
|
18
|
+
banner.id = 'hexastack-caption-banner';
|
|
19
|
+
banner.style.position = 'fixed';
|
|
20
|
+
banner.style.bottom = '32px';
|
|
21
|
+
banner.style.left = '50%';
|
|
22
|
+
banner.style.transform = 'translateX(-50%)';
|
|
23
|
+
banner.style.backgroundColor = 'rgba(15, 23, 42, 0.92)';
|
|
24
|
+
banner.style.color = '#ffffff';
|
|
25
|
+
banner.style.padding = '12px 24px';
|
|
26
|
+
banner.style.borderRadius = '8px';
|
|
27
|
+
banner.style.fontSize = '17px';
|
|
28
|
+
banner.style.fontFamily = 'system-ui, -apple-system, sans-serif';
|
|
29
|
+
banner.style.fontWeight = '500';
|
|
30
|
+
banner.style.boxShadow = '0 10px 25px rgba(0, 0, 0, 0.5)';
|
|
31
|
+
banner.style.border = '1px solid rgba(255, 255, 255, 0.15)';
|
|
32
|
+
banner.style.backdropFilter = 'blur(8px)';
|
|
33
|
+
banner.style.zIndex = '2147483646';
|
|
34
|
+
banner.style.display = 'none';
|
|
35
|
+
banner.style.transition = 'opacity 0.2s ease-in-out';
|
|
36
|
+
banner.style.pointerEvents = 'none';
|
|
37
|
+
banner.style.maxWidth = '80%';
|
|
38
|
+
banner.style.textAlign = 'center';
|
|
39
|
+
|
|
40
|
+
const root = document.documentElement || document.body;
|
|
41
|
+
if (root) root.appendChild(banner);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
window.addEventListener('DOMContentLoaded', initBanner);
|
|
45
|
+
setInterval(() => {
|
|
46
|
+
const root = document.documentElement || document.body;
|
|
47
|
+
if (root) initBanner();
|
|
48
|
+
}, 100);
|
|
49
|
+
})();
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class DemoNarrator:
|
|
54
|
+
"""Orchestrates synchronized UI actions, live banner captions, and WebVTT subtitle export.
|
|
55
|
+
|
|
56
|
+
Notes/Architectural Intent:
|
|
57
|
+
Provides high-level narrative actions (`step`, `click`, `fill`, `goto`) that:
|
|
58
|
+
1. Run at full native speed with zero overhead during CI / standard test runs.
|
|
59
|
+
2. In demo recording mode (`RECORD_DEMO=1`), displays an elegant on-screen DOM subtitle
|
|
60
|
+
banner burned into the video, animates smooth cursor movement, and writes .vtt subtitle files.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(self, page: Any, output_name: str | None = None) -> None:
|
|
64
|
+
"""Initialize demo narrator attached to a Playwright page.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
page: Active Playwright Page instance.
|
|
68
|
+
output_name: Base filename (without extension) for saving .vtt captions.
|
|
69
|
+
|
|
70
|
+
Raises:
|
|
71
|
+
MissingDependencyError: If `playwright` is not installed.
|
|
72
|
+
"""
|
|
73
|
+
try:
|
|
74
|
+
import playwright # noqa: F401
|
|
75
|
+
except ImportError as e:
|
|
76
|
+
raise MissingDependencyError(
|
|
77
|
+
"Playwright is required to record feature demos. "
|
|
78
|
+
"Install with 'pip install hexastack-fastapi[testing]' or 'pip install playwright'."
|
|
79
|
+
) from e
|
|
80
|
+
|
|
81
|
+
self.page = page
|
|
82
|
+
self.output_name = output_name
|
|
83
|
+
self.record_mode = os.environ.get("RECORD_DEMO") in ("1", "true", "True")
|
|
84
|
+
self.start_time: float = time.time()
|
|
85
|
+
self.captions: list[tuple[float, float, str]] = []
|
|
86
|
+
self._current_step_start: float | None = None
|
|
87
|
+
self._current_step_text: str | None = None
|
|
88
|
+
|
|
89
|
+
if self.record_mode:
|
|
90
|
+
self.page.add_init_script(VIRTUAL_CURSOR_SCRIPT)
|
|
91
|
+
self.page.add_init_script(CAPTION_BANNER_SCRIPT)
|
|
92
|
+
|
|
93
|
+
def step(self, caption: str) -> None:
|
|
94
|
+
"""Mark a new narrative step with human-readable on-screen caption banner.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
caption: Subtitle narrative explaining the current action to viewers.
|
|
98
|
+
"""
|
|
99
|
+
now = time.time() - self.start_time
|
|
100
|
+
|
|
101
|
+
if self._current_step_start is not None and self._current_step_text is not None:
|
|
102
|
+
self.captions.append(
|
|
103
|
+
(self._current_step_start, now, self._current_step_text)
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
self._current_step_start = now
|
|
107
|
+
self._current_step_text = caption
|
|
108
|
+
|
|
109
|
+
if not self.record_mode:
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
safe_caption = caption.replace("'", "\\'").replace("\n", " ")
|
|
113
|
+
self.page.evaluate(
|
|
114
|
+
f"""(() => {{
|
|
115
|
+
const banner = document.getElementById('hexastack-caption-banner');
|
|
116
|
+
if (banner) {{
|
|
117
|
+
banner.innerText = '{safe_caption}';
|
|
118
|
+
banner.style.display = 'block';
|
|
119
|
+
banner.style.opacity = '1';
|
|
120
|
+
}}
|
|
121
|
+
}})();"""
|
|
122
|
+
)
|
|
123
|
+
self.page.wait_for_timeout(400)
|
|
124
|
+
|
|
125
|
+
def goto(self, url: str, caption: str | None = None) -> None:
|
|
126
|
+
"""Navigate to a URL with optional step caption."""
|
|
127
|
+
if caption:
|
|
128
|
+
self.step(caption)
|
|
129
|
+
self.page.goto(url)
|
|
130
|
+
if self.record_mode:
|
|
131
|
+
self.page.wait_for_timeout(600)
|
|
132
|
+
if caption:
|
|
133
|
+
self.step(caption)
|
|
134
|
+
|
|
135
|
+
def click(self, locator: Any, caption: str | None = None, steps: int = 35) -> None:
|
|
136
|
+
"""Smoothly click a target element with visual cursor interpolation and caption.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
locator: Playwright Locator or element target.
|
|
140
|
+
caption: Optional narration text for this specific click.
|
|
141
|
+
steps: Number of mouse interpolation steps for smooth movement.
|
|
142
|
+
"""
|
|
143
|
+
if caption:
|
|
144
|
+
self.step(caption)
|
|
145
|
+
|
|
146
|
+
element = locator.first if hasattr(locator, "first") else locator
|
|
147
|
+
|
|
148
|
+
if not self.record_mode:
|
|
149
|
+
element.click()
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
element.scroll_into_view_if_needed()
|
|
153
|
+
box = element.bounding_box()
|
|
154
|
+
if not box:
|
|
155
|
+
element.click()
|
|
156
|
+
return
|
|
157
|
+
|
|
158
|
+
x = box["x"] + box["width"] / 2
|
|
159
|
+
y = box["y"] + box["height"] / 2
|
|
160
|
+
|
|
161
|
+
self.page.mouse.move(x, y, steps=steps)
|
|
162
|
+
self.page.wait_for_timeout(800)
|
|
163
|
+
|
|
164
|
+
self.page.mouse.down()
|
|
165
|
+
page_timeout = 300
|
|
166
|
+
self.page.wait_for_timeout(page_timeout)
|
|
167
|
+
self.page.mouse.up()
|
|
168
|
+
self.page.wait_for_timeout(700)
|
|
169
|
+
|
|
170
|
+
def fill(self, locator: Any, value: str, caption: str | None = None) -> None:
|
|
171
|
+
"""Type text into an input field with optional human-like typing cadence.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
locator: Playwright input locator.
|
|
175
|
+
value: Text to fill.
|
|
176
|
+
caption: Optional narration text.
|
|
177
|
+
"""
|
|
178
|
+
if caption:
|
|
179
|
+
self.step(caption)
|
|
180
|
+
|
|
181
|
+
element = locator.first if hasattr(locator, "first") else locator
|
|
182
|
+
|
|
183
|
+
if not self.record_mode:
|
|
184
|
+
element.fill(value)
|
|
185
|
+
return
|
|
186
|
+
|
|
187
|
+
self.click(element)
|
|
188
|
+
element.fill("")
|
|
189
|
+
for char in value:
|
|
190
|
+
self.page.keyboard.type(char, delay=60)
|
|
191
|
+
self.page.wait_for_timeout(400)
|
|
192
|
+
|
|
193
|
+
def finish(self) -> Path | None:
|
|
194
|
+
"""Complete the demo recording and write WebVTT subtitles to disk.
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
Path to the written .vtt file if in demo mode, or None if in CI mode.
|
|
198
|
+
"""
|
|
199
|
+
if not self.record_mode:
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
now = time.time() - self.start_time
|
|
203
|
+
if self._current_step_start is not None and self._current_step_text is not None:
|
|
204
|
+
self.captions.append(
|
|
205
|
+
(self._current_step_start, now, self._current_step_text)
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
self.page.wait_for_timeout(1000)
|
|
209
|
+
|
|
210
|
+
# Generate WebVTT content
|
|
211
|
+
vtt_lines = ["WEBVTT", ""]
|
|
212
|
+
for idx, (start_s, end_s, text) in enumerate(self.captions, start=1):
|
|
213
|
+
vtt_lines.append(str(idx))
|
|
214
|
+
vtt_lines.append(
|
|
215
|
+
f"{self._format_vtt_time(start_s)} --> {self._format_vtt_time(end_s)}"
|
|
216
|
+
)
|
|
217
|
+
vtt_lines.append(text)
|
|
218
|
+
vtt_lines.append("")
|
|
219
|
+
|
|
220
|
+
out_dir = Path("docs/assets/demos")
|
|
221
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
222
|
+
vtt_path = out_dir / f"{self.output_name or 'demo'}.vtt"
|
|
223
|
+
vtt_path.write_text("\n".join(vtt_lines), encoding="utf-8")
|
|
224
|
+
return vtt_path
|
|
225
|
+
|
|
226
|
+
@staticmethod
|
|
227
|
+
def _format_vtt_time(seconds: float) -> str:
|
|
228
|
+
"""Format seconds float into standard WebVTT timestamp (HH:MM:SS.mmm)."""
|
|
229
|
+
hrs = int(seconds // 3600)
|
|
230
|
+
mins = int((seconds % 3600) // 60)
|
|
231
|
+
secs = int(seconds % 60)
|
|
232
|
+
millis = int(round((seconds - int(seconds)) * 1000))
|
|
233
|
+
return f"{hrs:02d}:{mins:02d}:{secs:02d}.{millis:03d}"
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
__all__ = [
|
|
237
|
+
"DemoNarrator",
|
|
238
|
+
]
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Ephemeral background server utility for Playwright and integration testing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import http.client
|
|
6
|
+
import os
|
|
7
|
+
import socket
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
from collections.abc import Generator
|
|
12
|
+
from contextlib import contextmanager
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def find_free_port() -> int:
|
|
17
|
+
"""Find a dynamically available free TCP port on localhost."""
|
|
18
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
19
|
+
s.bind(("127.0.0.1", 0))
|
|
20
|
+
return int(s.getsockname()[1])
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class EphemeralServer:
|
|
24
|
+
"""Manages lifecycle of an ephemeral Uvicorn web server running in background."""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
app_factory_code: str,
|
|
29
|
+
port: int | None = None,
|
|
30
|
+
env: dict[str, str] | None = None,
|
|
31
|
+
) -> None:
|
|
32
|
+
"""Initialize ephemeral server with Python factory script code.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
app_factory_code: Python code string that creates and runs the ASGI app.
|
|
36
|
+
port: Optional fixed port. If omitted, finds an ephemeral free port.
|
|
37
|
+
env: Optional environment variables dictionary for subprocess.
|
|
38
|
+
"""
|
|
39
|
+
self.port = port or find_free_port()
|
|
40
|
+
self.app_factory_code = app_factory_code
|
|
41
|
+
self.env = env
|
|
42
|
+
self.proc: subprocess.Popen[Any] | None = None
|
|
43
|
+
self.base_url = f"http://127.0.0.1:{self.port}"
|
|
44
|
+
|
|
45
|
+
def start(self, ready_path: str = "/", timeout: float = 10.0) -> str:
|
|
46
|
+
"""Start the server process and poll until healthy.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
ready_path: Relative URL path to poll for HTTP 200 readiness.
|
|
50
|
+
timeout: Maximum seconds to wait before failing.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
The base URL of the running server.
|
|
54
|
+
|
|
55
|
+
Raises:
|
|
56
|
+
RuntimeError: If server fails to start within timeout.
|
|
57
|
+
"""
|
|
58
|
+
cmd = [
|
|
59
|
+
sys.executable,
|
|
60
|
+
"-c",
|
|
61
|
+
self.app_factory_code.format(port=self.port),
|
|
62
|
+
]
|
|
63
|
+
proc_env = dict(os.environ)
|
|
64
|
+
if self.env:
|
|
65
|
+
proc_env.update(self.env)
|
|
66
|
+
|
|
67
|
+
self.proc = subprocess.Popen(
|
|
68
|
+
cmd,
|
|
69
|
+
stdout=subprocess.DEVNULL,
|
|
70
|
+
stderr=subprocess.DEVNULL,
|
|
71
|
+
env=proc_env,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
start_time = time.time()
|
|
75
|
+
while time.time() - start_time < timeout:
|
|
76
|
+
try:
|
|
77
|
+
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=1.0)
|
|
78
|
+
conn.request("GET", ready_path)
|
|
79
|
+
resp = conn.getresponse()
|
|
80
|
+
if resp.status in (200, 404): # Responding is ready
|
|
81
|
+
conn.close()
|
|
82
|
+
return self.base_url
|
|
83
|
+
conn.close()
|
|
84
|
+
except Exception:
|
|
85
|
+
time.sleep(0.25)
|
|
86
|
+
|
|
87
|
+
self.stop()
|
|
88
|
+
raise RuntimeError(f"Ephemeral server failed to start at {self.base_url}")
|
|
89
|
+
|
|
90
|
+
def stop(self) -> None:
|
|
91
|
+
"""Stop and terminate the background server process."""
|
|
92
|
+
if self.proc is not None:
|
|
93
|
+
self.proc.terminate()
|
|
94
|
+
try:
|
|
95
|
+
self.proc.wait(timeout=3.0)
|
|
96
|
+
except subprocess.TimeoutExpired:
|
|
97
|
+
self.proc.kill()
|
|
98
|
+
self.proc = None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@contextmanager
|
|
102
|
+
def ephemeral_server(
|
|
103
|
+
app_factory_code: str,
|
|
104
|
+
ready_path: str = "/",
|
|
105
|
+
env: dict[str, str] | None = None,
|
|
106
|
+
) -> Generator[str]:
|
|
107
|
+
"""Context manager launching an ephemeral server on a free port."""
|
|
108
|
+
server = EphemeralServer(app_factory_code, env=env)
|
|
109
|
+
try:
|
|
110
|
+
url = server.start(ready_path=ready_path)
|
|
111
|
+
yield url
|
|
112
|
+
finally:
|
|
113
|
+
server.stop()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
__all__ = [
|
|
117
|
+
"ephemeral_server",
|
|
118
|
+
"EphemeralServer",
|
|
119
|
+
"find_free_port",
|
|
120
|
+
]
|