fastware 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.
fastware/__init__.py ADDED
@@ -0,0 +1,104 @@
1
+ """A fast, batteries-included ASGI framework. The FastAPI alternative."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.metadata
6
+
7
+ # -- Tier 1: Core symbols (eager imports -- lightweight modules) -------------
8
+
9
+ from fastware.types import Scope, Receive, Send
10
+ from fastware.responses import (
11
+ set_cookie,
12
+ delete_cookie,
13
+ HTTPError,
14
+ JSONResponse,
15
+ TextResponse,
16
+ HTMLResponse,
17
+ BytesResponse,
18
+ StreamResponse,
19
+ FileResponse,
20
+ send_error,
21
+ )
22
+ from fastware.request import State, Request
23
+ from fastware.routing import Router, ParsedSegment
24
+ from fastware.websocket import WebSocket
25
+ from fastware.app import AppConfig, create_app
26
+ from fastware.di import DependencyResolver
27
+ from fastware.sse import Broadcaster, sse_route
28
+
29
+ __version__ = importlib.metadata.version("fastware")
30
+
31
+ # -- Tier 1: Server symbols (lazy -- granian is ~60ms to import) ------------
32
+
33
+ _SERVER_SYMBOLS = {
34
+ "check_already_running",
35
+ "ensure_port_available",
36
+ "read_port_file",
37
+ "serve_background",
38
+ "serve",
39
+ "stop",
40
+ "status",
41
+ "ServerStatus",
42
+ "PortInUseError",
43
+ "AlreadyRunningError",
44
+ }
45
+
46
+
47
+ def __getattr__(name: str) -> object:
48
+ if name in _SERVER_SYMBOLS:
49
+ from fastware import server
50
+ return getattr(server, name)
51
+ raise AttributeError(f"module 'fastware' has no attribute {name!r}")
52
+
53
+
54
+ # -- Tier 2+: Feature modules stay in sub-modules ---------------------------
55
+ # fastware.auth, fastware.middleware, fastware.logging, fastware.testing,
56
+ # fastware.features, fastware.audit, fastware.error_log, fastware.tasks,
57
+ # fastware.config, fastware.mcp, fastware.dev
58
+
59
+ __all__ = [
60
+ # types
61
+ "Scope",
62
+ "Receive",
63
+ "Send",
64
+ # responses
65
+ "set_cookie",
66
+ "delete_cookie",
67
+ "HTTPError",
68
+ "JSONResponse",
69
+ "TextResponse",
70
+ "HTMLResponse",
71
+ "BytesResponse",
72
+ "StreamResponse",
73
+ "FileResponse",
74
+ "send_error",
75
+ # request
76
+ "State",
77
+ "Request",
78
+ # routing
79
+ "Router",
80
+ "ParsedSegment",
81
+ # websocket
82
+ "WebSocket",
83
+ # app
84
+ "AppConfig",
85
+ "create_app",
86
+ # di
87
+ "DependencyResolver",
88
+ # sse
89
+ "Broadcaster",
90
+ "sse_route",
91
+ # server (lazy)
92
+ "check_already_running",
93
+ "ensure_port_available",
94
+ "read_port_file",
95
+ "serve_background",
96
+ "serve",
97
+ "stop",
98
+ "status",
99
+ "ServerStatus",
100
+ "PortInUseError",
101
+ "AlreadyRunningError",
102
+ # metadata
103
+ "__version__",
104
+ ]
fastware/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Enable `python -m fastware`."""
2
+
3
+ # Placeholder until CLI is implemented
4
+ def main() -> None:
5
+ print("fastware CLI not yet implemented")
6
+
7
+ if __name__ == "__main__":
8
+ main()
fastware/app.py ADDED
@@ -0,0 +1,453 @@
1
+ """ASGI application factory with middleware, static files, SPA fallback, and lifespan."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ import logging
7
+ import mimetypes
8
+ from pathlib import Path
9
+ from typing import Any, Callable
10
+
11
+ import msgspec
12
+
13
+ from fastware.types import Scope, Receive, Send
14
+ from fastware.responses import (
15
+ JSONResponse,
16
+ TextResponse,
17
+ HTMLResponse,
18
+ BytesResponse,
19
+ StreamResponse,
20
+ FileResponse,
21
+ HTTPError,
22
+ _send_response,
23
+ send_error,
24
+ )
25
+ from fastware.request import Request, State
26
+ from fastware.routing import Router
27
+ from fastware.websocket import WebSocket
28
+
29
+ __all__ = [
30
+ "AppConfig",
31
+ "create_app",
32
+ ]
33
+
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # ASGI send helpers (stream + result dispatch)
37
+ # ---------------------------------------------------------------------------
38
+
39
+ async def _send_stream(send: Callable, resp: StreamResponse) -> None:
40
+ """Send a streaming HTTP response, iterating the async generator."""
41
+ headers = [
42
+ [b"content-type", resp.content_type.encode()],
43
+ ]
44
+ for key, value in resp.headers.items():
45
+ headers.append([key.encode(), value.encode()])
46
+ for cookie in resp.cookies:
47
+ headers.append([b"set-cookie", cookie.encode()])
48
+ await send({"type": "http.response.start", "status": resp.status, "headers": headers})
49
+ async for chunk in resp.generator:
50
+ payload = chunk.encode() if isinstance(chunk, str) else chunk
51
+ await send({"type": "http.response.body", "body": payload, "more_body": True})
52
+ await send({"type": "http.response.body", "body": b"", "more_body": False})
53
+
54
+
55
+ def _deep_convert_pydantic(obj: Any) -> Any:
56
+ """Recursively convert Pydantic models to plain dicts/lists.
57
+
58
+ Walks dicts and lists, calling ``.model_dump(mode="json")`` on any
59
+ object that has ``model_dump`` (i.e. Pydantic BaseModel instances).
60
+ """
61
+ if hasattr(obj, "model_dump"):
62
+ return obj.model_dump(mode="json")
63
+ if isinstance(obj, dict):
64
+ return {k: _deep_convert_pydantic(v) for k, v in obj.items()}
65
+ if isinstance(obj, list):
66
+ return [_deep_convert_pydantic(item) for item in obj]
67
+ return obj
68
+
69
+
70
+ async def _send_result(send: Callable, result: Any) -> None:
71
+ """Dispatch a handler return value to the appropriate sender."""
72
+ # Pydantic BaseModel instances: serialize via model_dump before JSON encoding
73
+ if hasattr(result, "model_dump"):
74
+ result = result.model_dump(mode="json")
75
+
76
+ # Auto-wrap plain dicts/lists as JSON responses, converting nested Pydantic models
77
+ if isinstance(result, (dict, list)):
78
+ result = JSONResponse(_deep_convert_pydantic(result))
79
+
80
+ if isinstance(result, JSONResponse):
81
+ await _send_response(
82
+ send, result.status, msgspec.json.encode(result.data),
83
+ "application/json", extra_headers=result.headers, cookies=result.cookies,
84
+ )
85
+ elif isinstance(result, TextResponse):
86
+ await _send_response(
87
+ send, result.status, result.text.encode(),
88
+ result.content_type, extra_headers=result.headers, cookies=result.cookies,
89
+ )
90
+ elif isinstance(result, HTMLResponse):
91
+ await _send_response(
92
+ send, result.status, result.html.encode(),
93
+ "text/html", extra_headers=result.headers, cookies=result.cookies,
94
+ )
95
+ elif isinstance(result, BytesResponse):
96
+ await _send_response(
97
+ send, result.status, result.data,
98
+ result.content_type, extra_headers=result.headers, cookies=result.cookies,
99
+ )
100
+ elif isinstance(result, StreamResponse):
101
+ await _send_stream(send, result)
102
+ elif isinstance(result, FileResponse):
103
+ content_type = result.content_type
104
+ if content_type is None:
105
+ mime, _ = mimetypes.guess_type(str(result.path))
106
+ content_type = mime or "application/octet-stream"
107
+ body = result.path.read_bytes()
108
+ await _send_response(
109
+ send, result.status, body, content_type,
110
+ extra_headers=result.headers, cookies=result.cookies,
111
+ )
112
+ else:
113
+ # Fallback: try JSON-encoding anything else
114
+ await _send_response(send, 200, msgspec.json.encode(result), "application/json")
115
+
116
+
117
+ # ---------------------------------------------------------------------------
118
+ # Static file + SPA helpers
119
+ # ---------------------------------------------------------------------------
120
+
121
+ async def _serve_static(send: Callable, static_dir: Path, rel_path: str) -> bool:
122
+ """Serve a static file. Returns True if served, False if not found."""
123
+ file_path = (static_dir / rel_path).resolve()
124
+ # Prevent path traversal
125
+ if not str(file_path).startswith(str(static_dir.resolve())):
126
+ return False
127
+ if not file_path.is_file():
128
+ return False
129
+ mime, _ = mimetypes.guess_type(str(file_path))
130
+ body = file_path.read_bytes()
131
+ await _send_response(send, 200, body, mime or "application/octet-stream")
132
+ return True
133
+
134
+
135
+ async def _serve_spa_fallback(send: Callable, spa_fallback: Path) -> None:
136
+ """Serve the SPA fallback file (typically index.html)."""
137
+ if spa_fallback.is_file():
138
+ body = spa_fallback.read_bytes()
139
+ await _send_response(send, 200, body, "text/html")
140
+ else:
141
+ await _send_response(send, 404, msgspec.json.encode({"detail": "Not found"}), "application/json")
142
+
143
+
144
+ # ---------------------------------------------------------------------------
145
+ # App configuration
146
+ # ---------------------------------------------------------------------------
147
+
148
+ @dataclasses.dataclass
149
+ class AppConfig:
150
+ """Configuration for :func:`create_app`.
151
+
152
+ All fields correspond to the keyword arguments of ``create_app``.
153
+ Pass an ``AppConfig`` instance as the *config* parameter, and/or
154
+ supply individual keyword arguments. Keyword arguments override
155
+ matching fields on the config object.
156
+ """
157
+
158
+ middleware: list[Callable] | None = None
159
+ static_dir: Path | None = None
160
+ static_path: str = "/assets"
161
+ spa_fallback: Path | None = None
162
+ api_prefix: str | None = None
163
+ lifespan: Callable | None = None
164
+ name: str | None = None
165
+ exception_handlers: dict[type, Callable] | None = None
166
+ dependency_overrides: dict[Callable, Callable] | None = None
167
+ cors_origins: list[str] | None = None
168
+ trusted_hosts: list[str] | None = None
169
+ request_id: bool = True
170
+ request_timing: bool = True
171
+ vite_dev_port: int | None = None
172
+
173
+
174
+ # ---------------------------------------------------------------------------
175
+ # App factory
176
+ # ---------------------------------------------------------------------------
177
+
178
+ def create_app(
179
+ router: Router,
180
+ config: AppConfig | None = None,
181
+ **kwargs: Any,
182
+ ) -> Callable:
183
+ """Create an ASGI application callable.
184
+
185
+ Accepts an optional *config* (:class:`AppConfig`) and/or keyword
186
+ arguments. Keyword arguments override matching fields on the config
187
+ object. If neither is supplied, defaults from ``AppConfig`` are used.
188
+
189
+ If *api_prefix* is set (e.g. ``"/api"``), the SPA fallback will not
190
+ serve index.html for paths that start with the prefix -- they fall
191
+ through to the 404 handler instead.
192
+
193
+ Built-in middleware (applied when their parameters are truthy):
194
+ - ``trusted_hosts``: TrustedHostMiddleware (outermost)
195
+ - ``vite_dev_port``: ViteDevProxy
196
+ - ``cors_origins``: CORSMiddleware
197
+ - ``request_id``: RequestIDMiddleware
198
+ - ``request_timing``: RequestTimingMiddleware (innermost)
199
+
200
+ Custom middleware supplied via *middleware* wraps after built-in
201
+ middleware (between the app and the built-in stack).
202
+ """
203
+ # Build effective config: start from defaults, overlay config, overlay kwargs.
204
+ if config is None:
205
+ config = AppConfig()
206
+ _field_names = {f.name for f in dataclasses.fields(AppConfig)}
207
+ unknown = set(kwargs) - _field_names
208
+ if unknown:
209
+ raise TypeError(
210
+ f"create_app() got unexpected keyword argument(s): {', '.join(sorted(unknown))}"
211
+ )
212
+ # Merge: kwargs override config fields.
213
+ effective = dataclasses.replace(config, **kwargs)
214
+
215
+ middleware = effective.middleware
216
+ static_dir = effective.static_dir
217
+ static_path = effective.static_path
218
+ spa_fallback = effective.spa_fallback
219
+ api_prefix = effective.api_prefix
220
+ lifespan = effective.lifespan
221
+ name = effective.name
222
+ exception_handlers = effective.exception_handlers
223
+ dependency_overrides = effective.dependency_overrides
224
+ cors_origins = effective.cors_origins
225
+ trusted_hosts = effective.trusted_hosts
226
+ request_id = effective.request_id
227
+ request_timing = effective.request_timing
228
+ vite_dev_port = effective.vite_dev_port
229
+
230
+ from fastware.di import DependencyResolver
231
+
232
+ log = logging.getLogger(name or "fastware")
233
+ _resolver = DependencyResolver(overrides=dependency_overrides)
234
+
235
+ # Pre-sort exception handlers by MRO depth (most specific first) so
236
+ # that a handler for a subclass is checked before a handler for its
237
+ # parent. Ties are broken by insertion order.
238
+ _exc_handlers: list[tuple[type, Callable]] = []
239
+ if exception_handlers:
240
+ _exc_handlers = sorted(
241
+ exception_handlers.items(),
242
+ key=lambda pair: len(pair[0].__mro__),
243
+ reverse=True,
244
+ )
245
+ _lifespan_state: dict[str, Any] = {}
246
+
247
+ async def app(scope: dict, receive: Callable, send: Callable) -> None:
248
+ nonlocal _lifespan_state
249
+
250
+ # -- Lifespan protocol --
251
+ if scope["type"] == "lifespan":
252
+ message = await receive()
253
+ if message["type"] == "lifespan.startup":
254
+ if lifespan is not None:
255
+ # Enter the context manager; it stays open until shutdown
256
+ ctx = lifespan(app)
257
+ yielded = await ctx.__aenter__()
258
+ if isinstance(yielded, dict):
259
+ _lifespan_state = yielded
260
+ await send({"type": "lifespan.startup.complete"})
261
+ await receive() # blocks until lifespan.shutdown
262
+ await ctx.__aexit__(None, None, None)
263
+ else:
264
+ await send({"type": "lifespan.startup.complete"})
265
+ await receive()
266
+ await send({"type": "lifespan.shutdown.complete"})
267
+ return
268
+
269
+ # -- WebSocket routing (app-scoped via router) --
270
+ if scope["type"] == "websocket":
271
+ # Merge lifespan state into scope for WebSocket connections
272
+ if "state" not in scope:
273
+ scope["state"] = {}
274
+ scope["state"].update(_lifespan_state)
275
+
276
+ path = scope.get("path", "")
277
+ ws_match = router._match_ws_with_deps(path)
278
+ if ws_match:
279
+ ws_handler, ws_params, ws_deps = ws_match
280
+ scope["path_params"] = ws_params
281
+ ws = WebSocket(scope, receive, send)
282
+ if ws_deps:
283
+ resolved, cleanups = await _resolver.resolve(ws_deps, ws)
284
+ try:
285
+ await ws_handler(ws, **resolved)
286
+ finally:
287
+ await DependencyResolver.cleanup(cleanups)
288
+ else:
289
+ await ws_handler(ws)
290
+ else:
291
+ # Reject unknown WebSocket paths
292
+ await receive() # consume websocket.connect per ASGI spec
293
+ await send({"type": "websocket.close", "code": 4004})
294
+ return
295
+
296
+ if scope["type"] != "http":
297
+ return
298
+
299
+ # Merge lifespan state into scope for every request
300
+ if "state" not in scope:
301
+ scope["state"] = {}
302
+ scope["state"].update(_lifespan_state)
303
+
304
+ method = scope["method"]
305
+ path = scope["path"]
306
+
307
+ # -- Route matching --
308
+ match = router._match_with_deps(method, path)
309
+ if match:
310
+ handler, path_params, route_deps, response_model = match
311
+ try:
312
+ # Read body for all methods (GET with empty body costs nothing)
313
+ body = b""
314
+ while True:
315
+ msg = await receive()
316
+ body += msg.get("body", b"")
317
+ if not msg.get("more_body", False):
318
+ break
319
+ body = body or None
320
+
321
+ request = Request(scope, path_params, body, receive=receive)
322
+
323
+ if route_deps:
324
+ resolved, cleanups = await _resolver.resolve(
325
+ route_deps, request,
326
+ )
327
+ try:
328
+ # Filter resolved deps to only those the handler
329
+ # actually accepts, so router-level deps (e.g. auth)
330
+ # don't cause TypeError on handlers that don't need
331
+ # the resolved value.
332
+ import inspect as _inspect
333
+ _sig = _inspect.signature(handler)
334
+ _params = _sig.parameters
335
+ if any(
336
+ p.kind == _inspect.Parameter.VAR_KEYWORD
337
+ for p in _params.values()
338
+ ):
339
+ filtered = resolved
340
+ else:
341
+ accepted = set(_params.keys())
342
+ filtered = {
343
+ k: v for k, v in resolved.items()
344
+ if k in accepted
345
+ }
346
+ result = await handler(request, **filtered)
347
+ finally:
348
+ await DependencyResolver.cleanup(cleanups)
349
+ else:
350
+ result = await handler(request)
351
+
352
+ # Apply response_model validation if configured and result
353
+ # is a plain dict (skip if handler returned a Response type)
354
+ if response_model is not None and isinstance(result, dict):
355
+ from pydantic import ValidationError
356
+ try:
357
+ validated = response_model.model_validate(result)
358
+ result = validated.model_dump(mode="json")
359
+ except ValidationError as ve:
360
+ log.error(
361
+ "response_model validation failed on %s %s: %s",
362
+ method, path, ve,
363
+ )
364
+ await _send_response(
365
+ send, 500,
366
+ msgspec.json.encode({"detail": str(ve)}),
367
+ "application/json",
368
+ )
369
+ return
370
+
371
+ await _send_result(send, result)
372
+ except HTTPError as exc:
373
+ await _send_response(
374
+ send, exc.status_code,
375
+ msgspec.json.encode({"detail": exc.detail}),
376
+ "application/json",
377
+ )
378
+ except Exception as exc:
379
+ # Check registered exception handlers (most specific first)
380
+ handled = False
381
+ for exc_type, exc_handler in _exc_handlers:
382
+ if isinstance(exc, exc_type):
383
+ try:
384
+ result = await exc_handler(request, exc)
385
+ await _send_result(send, result)
386
+ handled = True
387
+ except Exception:
388
+ log.exception(
389
+ "Exception handler error on %s %s",
390
+ method, path,
391
+ )
392
+ break
393
+ if not handled:
394
+ log.exception("Handler error on %s %s", method, path)
395
+ await _send_response(
396
+ send, 500,
397
+ msgspec.json.encode({"detail": "Internal server error"}),
398
+ "application/json",
399
+ )
400
+ return
401
+
402
+ # -- Static files --
403
+ if static_dir and path.startswith(static_path + "/"):
404
+ rel = path[len(static_path) + 1:]
405
+ if await _serve_static(send, static_dir, rel):
406
+ return
407
+
408
+ # -- SPA fallback (GET only, skip API paths) --
409
+ if spa_fallback and method == "GET":
410
+ # Never serve index.html for API paths -- they should 404
411
+ if api_prefix and path.startswith(api_prefix):
412
+ pass # fall through to 404
413
+ else:
414
+ # Before returning index.html, check if the path maps to an
415
+ # actual file under the static root (spa_fallback's parent dir).
416
+ # This lets sub-directories be served without registering each
417
+ # one as a separate static_path prefix.
418
+ static_root = spa_fallback.parent
419
+ candidate = path.lstrip("/")
420
+ if candidate and await _serve_static(send, static_root, candidate):
421
+ return
422
+ await _serve_spa_fallback(send, spa_fallback)
423
+ return
424
+
425
+ # -- 404 --
426
+ await _send_response(send, 404, msgspec.json.encode({"detail": "Not found"}), "application/json")
427
+
428
+ # -- Apply middleware in reverse so the first in the list is outermost --
429
+ wrapped = app
430
+ for mw in reversed(middleware or []):
431
+ wrapped = mw(wrapped)
432
+
433
+ # -- Built-in middleware (innermost first, outermost last) --
434
+ from fastware.middleware import (
435
+ CORSMiddleware as _CORSMiddleware,
436
+ RequestIDMiddleware as _RequestIDMiddleware,
437
+ RequestTimingMiddleware as _RequestTimingMiddleware,
438
+ TrustedHostMiddleware as _TrustedHostMiddleware,
439
+ ViteDevProxy as _ViteDevProxy,
440
+ )
441
+
442
+ if request_timing:
443
+ wrapped = _RequestTimingMiddleware(wrapped)
444
+ if request_id:
445
+ wrapped = _RequestIDMiddleware(wrapped)
446
+ if cors_origins:
447
+ wrapped = _CORSMiddleware(wrapped, allow_origins=cors_origins)
448
+ if vite_dev_port is not None:
449
+ wrapped = _ViteDevProxy(wrapped, vite_port=vite_dev_port)
450
+ if trusted_hosts:
451
+ wrapped = _TrustedHostMiddleware(wrapped, allowed_hosts=trusted_hosts)
452
+
453
+ return wrapped
fastware/audit.py ADDED
@@ -0,0 +1,49 @@
1
+ """Append-only JSONL audit log writer.
2
+
3
+ Each entry is a single JSON line with an ISO timestamp, event type, and
4
+ optional payload dict. Thread-safe via ``threading.Lock``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import threading
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+
14
+ __all__ = [
15
+ "AuditLog",
16
+ ]
17
+
18
+
19
+ class AuditLog:
20
+ """Append-only JSONL audit log.
21
+
22
+ Parameters
23
+ ----------
24
+ path:
25
+ Path to the JSONL file. Created on first write if it does not exist.
26
+ """
27
+
28
+ def __init__(self, path: str | Path) -> None:
29
+ self._path = Path(path)
30
+ self._lock = threading.Lock()
31
+
32
+ def log(self, event_type: str, payload: dict | None = None) -> None:
33
+ """Append a single audit entry as a JSON line.
34
+
35
+ Each line has the shape::
36
+
37
+ {"timestamp": "...", "event_type": "...", "payload": {...}}
38
+ """
39
+ entry: dict = {
40
+ "timestamp": datetime.now(timezone.utc).isoformat(),
41
+ "event_type": event_type,
42
+ }
43
+ if payload is not None:
44
+ entry["payload"] = payload
45
+ line = json.dumps(entry, separators=(",", ":"))
46
+ with self._lock:
47
+ self._path.parent.mkdir(parents=True, exist_ok=True)
48
+ with self._path.open("a") as f:
49
+ f.write(line + "\n")