pythonboot-web 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.
@@ -0,0 +1,24 @@
1
+ """FastAPI routing, dependency injection, and exception handling integration."""
2
+
3
+ from pythonboot_web.integration import inject, register_exception_handlers, register_routers
4
+ from pythonboot_web.docs import register_api_docs, resolve_docs_options
5
+ from pythonboot_web.datetime import DateTimeFormatMiddleware, date_time, resolve_datetime_format
6
+ from pythonboot_web.context_middleware import ApplicationContextMiddleware
7
+ from pythonboot_web.request_context import RequestContextHolder
8
+ from pythonboot_web.decorators import (
9
+ controller, controller_advise, delete, exception_handler, get, patch, post, put, route,
10
+ )
11
+
12
+ __all__ = [
13
+ "controller", "controller_advise", "exception_handler", "route", "get", "post", "put", "patch", "delete",
14
+ "inject", "register_routers", "register_exception_handlers", "resolve_datetime_format",
15
+ "DateTimeFormatMiddleware", "date_time",
16
+ "resolve_docs_options", "register_api_docs",
17
+ "ApplicationContextMiddleware",
18
+ "RequestContextHolder",
19
+ ]
20
+
21
+ from pythonboot_web.application import PythonBootApplication, create_app
22
+ from pythonboot_web.bootstrap import pythonboot_application
23
+
24
+ __all__ += ["PythonBootApplication", "create_app", "pythonboot_application"]
@@ -0,0 +1,151 @@
1
+ """PythonBoot application creation and startup entry points."""
2
+
3
+ from collections.abc import AsyncIterator
4
+ from contextlib import asynccontextmanager
5
+ import logging
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import uvicorn
11
+ from fastapi import FastAPI
12
+
13
+ from pythonboot.beans import scan_components
14
+ from pythonboot.boot.autoconfiguration import assemble_auto_configurations
15
+ from pythonboot.context import ApplicationContext
16
+ from pythonboot.config import configure_logging, load_configuration
17
+ from pythonboot.util import ContextUtil
18
+ from pythonboot_web import (
19
+ ApplicationContextMiddleware, DateTimeFormatMiddleware, register_api_docs, register_exception_handlers,
20
+ register_routers, resolve_datetime_format, resolve_docs_options,
21
+ )
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ def create_app(
27
+ *, title: str = "PythonBoot", version: str = "0.1.0",
28
+ scan_packages: tuple[str, ...] = (),
29
+ exclude_modules: tuple[str, ...] = (), config_dir: str | Path | None = None,
30
+ auto_configure: bool = True, exclude_auto_configurations: tuple[str, ...] = (),
31
+ exclude: tuple[type, ...] = (),
32
+ **options: Any,
33
+ ) -> FastAPI:
34
+ """Create an independent application instance."""
35
+ if isinstance(scan_packages, str):
36
+ raise ValueError("scan_packages must be a sequence of package names, not a string")
37
+ if isinstance(exclude, (str, type)) or any(not isinstance(target, type) for target in exclude):
38
+ raise TypeError("exclude must be a sequence of classes")
39
+ excluded_classes = tuple(exclude)
40
+ result = scan_components(
41
+ tuple(scan_packages), exclude_modules=exclude_modules, exclude=excluded_classes,
42
+ )
43
+ logger.info("Scanned %d component definitions from %d modules.", len(result.definitions), len(result.modules))
44
+ configuration = load_configuration(config_dir) if config_dir is not None else {}
45
+ configure_logging(configuration)
46
+ docs_options = resolve_docs_options(configuration)
47
+ date_time_format = resolve_datetime_format(configuration)
48
+ definitions = result.definitions
49
+ if auto_configure:
50
+ definitions = assemble_auto_configurations(
51
+ definitions, result.modules, configuration, exclude=exclude_auto_configurations,
52
+ exclude_classes=excluded_classes,
53
+ )
54
+ logger.info("Auto-configuration assembled %d bean definitions.", len(definitions))
55
+ else:
56
+ logger.info("Auto-configuration is disabled.")
57
+ context = ApplicationContext(definitions, configuration=configuration)
58
+ fastapi_options = {
59
+ **options,
60
+ "docs_url": None,
61
+ "redoc_url": None,
62
+ "openapi_url": docs_options.openapi_path if docs_options.enabled else None,
63
+ }
64
+ app = FastAPI(title=title, version=version, **fastapi_options)
65
+ app.state.context = context
66
+ native_lifespan = app.router.lifespan_context
67
+
68
+ @asynccontextmanager
69
+ async def lifespan(application: FastAPI) -> AsyncIterator[Any]:
70
+ with ContextUtil.use(context):
71
+ logger.info("Starting application context.")
72
+ await context.start()
73
+ try:
74
+ async with native_lifespan(application) as state:
75
+ yield state
76
+ finally:
77
+ logger.info("Closing application context.")
78
+ await context.close()
79
+
80
+ app.router.lifespan_context = lifespan
81
+ app.add_middleware(ApplicationContextMiddleware, context=context)
82
+ app.add_middleware(DateTimeFormatMiddleware, date_time_format=date_time_format)
83
+ register_api_docs(app, docs_options)
84
+ register_routers(
85
+ app, result.modules, exclude=excluded_classes, date_time_format=date_time_format,
86
+ )
87
+ register_exception_handlers(app, result.modules, exclude=excluded_classes)
88
+ logger.info("Created FastAPI application '%s' version %s.", title, version)
89
+ return app
90
+
91
+
92
+ class PythonBootApplication:
93
+ """Extensible startup class with an independent FastAPI application per instance."""
94
+
95
+ def __init__(
96
+ self,
97
+ *,
98
+ title: str = "PythonBoot",
99
+ version: str = "0.1.0",
100
+ scan_packages: tuple[str, ...] | None = None,
101
+ config_dir: str | Path | None = None,
102
+ auto_configure: bool = True,
103
+ exclude_auto_configurations: tuple[str, ...] | None = None,
104
+ exclude: tuple[type, ...] | None = None,
105
+ **options: Any,
106
+ ) -> None:
107
+ packages = (
108
+ scan_packages if scan_packages is not None
109
+ else getattr(type(self), "__pythonboot_scan_packages__", None)
110
+ )
111
+ module_name = type(self).__module__
112
+ module = sys.modules.get(module_name)
113
+ if module_name == "__main__":
114
+ module_name = getattr(getattr(module, "__spec__", None), "name", module_name)
115
+ if packages is None:
116
+ if type(self) is PythonBootApplication:
117
+ packages = ()
118
+ else:
119
+ package_name = getattr(module, "__package__", None) or module_name.rpartition(".")[0]
120
+ if not package_name:
121
+ raise ValueError("The application class has no importable parent package; set scan_packages explicitly")
122
+ packages = (package_name,)
123
+ self.app: FastAPI = create_app(
124
+ title=title, version=version, scan_packages=packages,
125
+ exclude_modules=(module_name,), config_dir=config_dir,
126
+ auto_configure=auto_configure,
127
+ exclude_auto_configurations=(
128
+ exclude_auto_configurations if exclude_auto_configurations is not None
129
+ else getattr(type(self), "__pythonboot_exclude_auto_configurations__", ())
130
+ ),
131
+ exclude=(
132
+ exclude if exclude is not None else getattr(type(self), "__pythonboot_exclude__", ())
133
+ ),
134
+ **options,
135
+ )
136
+ self.context: ApplicationContext = self.app.state.context
137
+ self.configure(self.app)
138
+
139
+ def configure(self, app: FastAPI) -> None:
140
+ """Allow subclasses to register routes, middleware, and exception handlers."""
141
+
142
+ def run(
143
+ self,
144
+ *,
145
+ host: str = "127.0.0.1",
146
+ port: int = 8000,
147
+ log_level: str = "info",
148
+ ) -> None:
149
+ """Start the application; use a Uvicorn module entry for reload or multiple workers."""
150
+ logger.info("Starting Uvicorn server on %s:%d.", host, port)
151
+ uvicorn.run(self.app, host=host, port=port, log_level=log_level)
@@ -0,0 +1,38 @@
1
+ """Composite application startup declarations."""
2
+
3
+ from typing import Callable, TypeVar
4
+
5
+ T = TypeVar("T", bound=type)
6
+
7
+
8
+ def pythonboot_application(
9
+ cls: T | None = None, *, scan_packages: tuple[str, ...] | None = None,
10
+ exclude_auto_configurations: tuple[str, ...] = (),
11
+ exclude: tuple[type, ...] = (),
12
+ ) -> T | Callable[[T], T]:
13
+ """Scan the application package by default, or use explicit package patterns."""
14
+ if isinstance(scan_packages, str):
15
+ raise ValueError("scan_packages must be a sequence of package names, not a string")
16
+ packages = tuple(scan_packages) if scan_packages is not None else None
17
+ if packages is not None and any(not isinstance(package, str) or not package for package in packages):
18
+ raise ValueError("Scan package names must be non-empty strings")
19
+ if isinstance(exclude_auto_configurations, str) or any(
20
+ not isinstance(name, str) or not name for name in exclude_auto_configurations
21
+ ):
22
+ raise ValueError("Auto-configuration exclusions must be a sequence of non-empty names")
23
+ exclusions = tuple(exclude_auto_configurations)
24
+ if isinstance(exclude, (str, type)) or any(not isinstance(target, type) for target in exclude):
25
+ raise TypeError("exclude must be a sequence of classes")
26
+ excluded_classes = tuple(exclude)
27
+
28
+ def decorate(cls: T) -> T:
29
+ from pythonboot_web.application import PythonBootApplication
30
+
31
+ if not issubclass(cls, PythonBootApplication):
32
+ raise TypeError("Application classes must inherit PythonBootApplication")
33
+ cls.__pythonboot_scan_packages__ = packages
34
+ cls.__pythonboot_exclude_auto_configurations__ = exclusions
35
+ cls.__pythonboot_exclude__ = excluded_classes
36
+ return cls
37
+
38
+ return decorate(cls) if cls is not None else decorate
@@ -0,0 +1,42 @@
1
+ """Bind the application container for HTTP, WebSocket, and background callbacks."""
2
+
3
+ from fastapi import Request
4
+ from starlette.types import ASGIApp, Message, Receive, Scope, Send
5
+
6
+ from pythonboot.context import ApplicationContext
7
+ from pythonboot.util import ContextUtil
8
+ from pythonboot_web.request_context import RequestContextHolder, ResponseContext
9
+
10
+
11
+ class ApplicationContextMiddleware:
12
+ def __init__(self, app: ASGIApp, context: ApplicationContext) -> None:
13
+ self.app = app
14
+ self.context = context
15
+
16
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
17
+ with ContextUtil.use(self.context):
18
+ if scope["type"] != "http":
19
+ await self.app(scope, receive, send)
20
+ return
21
+ body_parts: list[bytes] = []
22
+
23
+ async def request_receive() -> Message:
24
+ message = await receive()
25
+ if message["type"] == "http.request":
26
+ body_parts.append(message.get("body", b""))
27
+ if not message.get("more_body", False):
28
+ request._body = b"".join(body_parts)
29
+ body_parts.clear()
30
+ return message
31
+
32
+ request = Request(scope, receive=request_receive)
33
+ response = ResponseContext()
34
+
35
+ async def response_send(message: Message) -> None:
36
+ if message["type"] == "http.response.start":
37
+ await send(response.apply(message))
38
+ return
39
+ await send(message)
40
+
41
+ with RequestContextHolder.use(request, response):
42
+ await self.app(scope, request_receive, response_send)
@@ -0,0 +1,208 @@
1
+ """Date-time request parameter configuration and parsing."""
2
+
3
+ from collections.abc import Mapping
4
+ from dataclasses import dataclass
5
+ from datetime import datetime
6
+ import json
7
+ from types import UnionType
8
+ from typing import Annotated, Any, Union, get_args, get_origin
9
+
10
+ from pydantic import BaseModel, BeforeValidator
11
+ from starlette.types import ASGIApp, Message, Receive, Scope, Send
12
+
13
+ from pythonboot.exceptions import ConfigurationError
14
+
15
+ DEFAULT_DATETIME_FORMAT = "%Y-%m-%d %H:%M:%S"
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class DateTimeFormat:
20
+ """Per-parameter date-time format metadata."""
21
+
22
+ value: str
23
+
24
+
25
+ def date_time(format: str) -> DateTimeFormat:
26
+ """Set a controller parameter or body field date-time format."""
27
+ if not isinstance(format, str) or not format:
28
+ raise ValueError("date_time format must be a non-empty string")
29
+ return DateTimeFormat(format)
30
+
31
+
32
+ class DateTimeFormatMiddleware:
33
+ """Format serialized JSON date-time values using the application setting."""
34
+
35
+ def __init__(self, app: ASGIApp, date_time_format: str) -> None:
36
+ self.app = app
37
+ self.date_time_format = date_time_format
38
+
39
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
40
+ if scope["type"] != "http":
41
+ await self.app(scope, receive, send)
42
+ return
43
+ start: Message | None = None
44
+ body_parts: list[bytes] = []
45
+ json_response = False
46
+
47
+ async def capture(message: Message) -> None:
48
+ nonlocal start, json_response
49
+ if message["type"] == "http.response.start":
50
+ start = message
51
+ json_response = _is_json_response(message)
52
+ if not json_response:
53
+ await send(message)
54
+ return
55
+ if message["type"] != "http.response.body":
56
+ await send(message)
57
+ return
58
+ if not json_response:
59
+ await send(message)
60
+ return
61
+ body_parts.append(message.get("body", b""))
62
+ if message.get("more_body", False):
63
+ return
64
+ if start is None:
65
+ await send(message)
66
+ return
67
+ body = b"".join(body_parts)
68
+ body = _format_json_date_times(body, self.date_time_format)
69
+ await send(_response_start_with_length(start, len(body)))
70
+ await send({"type": "http.response.body", "body": body, "more_body": False})
71
+
72
+ await self.app(scope, receive, capture)
73
+
74
+
75
+ def resolve_datetime_format(configuration: Mapping[str, Any]) -> str:
76
+ """Read the PythonBoot web date-time format setting."""
77
+ pythonboot = configuration.get("pythonboot", {})
78
+ if not isinstance(pythonboot, Mapping):
79
+ raise ConfigurationError("pythonboot configuration must be a mapping")
80
+ web = pythonboot.get("web", {})
81
+ if not isinstance(web, Mapping):
82
+ raise ConfigurationError("pythonboot.web configuration must be a mapping")
83
+ value = web.get("datetime_format", DEFAULT_DATETIME_FORMAT)
84
+ if not isinstance(value, str) or not value:
85
+ raise ConfigurationError("pythonboot.web.datetime_format must be a non-empty string")
86
+ return value
87
+
88
+
89
+ def date_time_annotation(annotation: Any, date_time_format: str) -> Any:
90
+ """Add configured date-time parsing to controller parameters and body models."""
91
+ arguments = get_args(annotation)
92
+ if get_origin(annotation) is Annotated:
93
+ target, *metadata = arguments
94
+ else:
95
+ target, metadata = annotation, []
96
+ format_value = _metadata_format(metadata) or date_time_format
97
+ parser = _date_time_parser(format_value)
98
+
99
+ if target is datetime:
100
+ return Annotated[datetime, *metadata, BeforeValidator(parser)]
101
+ if isinstance(target, type) and issubclass(target, BaseModel):
102
+ def normalize_body(value: Any) -> Any:
103
+ return _normalize_model_values(value, target, parser)
104
+
105
+ return Annotated[target, *metadata, BeforeValidator(normalize_body)]
106
+ return annotation
107
+
108
+
109
+ def _date_time_parser(format_value: str) -> Any:
110
+ def parse(value: Any) -> Any:
111
+ if not isinstance(value, str):
112
+ return value
113
+ try:
114
+ return datetime.strptime(value, format_value)
115
+ except ValueError as error:
116
+ raise ValueError(
117
+ f"Date-time value must match the configured format: {format_value}"
118
+ ) from error
119
+ return parse
120
+
121
+
122
+ def _normalize_model_values(value: Any, model_type: type[BaseModel], parser: Any) -> Any:
123
+ """Normalize only declared date-time fields before Pydantic validates a body model."""
124
+ if not isinstance(value, Mapping):
125
+ return value
126
+ return {
127
+ name: _normalize_value(item, _field_annotation(model_type.model_fields[name]), parser)
128
+ if name in model_type.model_fields else item
129
+ for name, item in value.items()
130
+ }
131
+
132
+
133
+ def _normalize_value(value: Any, annotation: Any, parser: Any) -> Any:
134
+ """Recursively normalize date-time fields in nested body values."""
135
+ if annotation is datetime:
136
+ parsed = parser(value)
137
+ return parsed.isoformat() if isinstance(parsed, datetime) else parsed
138
+ origin = get_origin(annotation)
139
+ arguments = get_args(annotation)
140
+ if origin is Annotated:
141
+ return _normalize_value(
142
+ value, arguments[0], _date_time_parser(_metadata_format(arguments[1:]))
143
+ if _metadata_format(arguments[1:]) else parser,
144
+ )
145
+ if origin in (Union, UnionType):
146
+ date_time_types = [item for item in arguments if item is datetime]
147
+ if date_time_types:
148
+ return _normalize_value(value, date_time_types[0], parser)
149
+ for item in arguments:
150
+ if item is not type(None):
151
+ return _normalize_value(value, item, parser)
152
+ return value
153
+ if origin in (list, set, tuple) and isinstance(value, list) and arguments:
154
+ return [_normalize_value(item, arguments[0], parser) for item in value]
155
+ if isinstance(annotation, type) and issubclass(annotation, BaseModel):
156
+ return _normalize_model_values(value, annotation, parser)
157
+ return value
158
+
159
+
160
+ def _metadata_format(metadata: list[Any] | tuple[Any, ...]) -> str | None:
161
+ for item in metadata:
162
+ if isinstance(item, DateTimeFormat):
163
+ return item.value
164
+ return None
165
+
166
+
167
+ def _field_annotation(field: Any) -> Any:
168
+ return Annotated[field.annotation, *field.metadata] if field.metadata else field.annotation
169
+
170
+
171
+ def _is_json_response(message: Message) -> bool:
172
+ return any(
173
+ name.lower() == b"content-type" and b"application/json" in value.lower()
174
+ for name, value in message.get("headers", [])
175
+ )
176
+
177
+
178
+ def _format_json_date_times(body: bytes, date_time_format: str) -> bytes:
179
+ try:
180
+ value = json.loads(body)
181
+ except (UnicodeDecodeError, json.JSONDecodeError):
182
+ return body
183
+ return json.dumps(
184
+ _format_value(value, date_time_format), ensure_ascii=False, separators=(",", ":"),
185
+ ).encode()
186
+
187
+
188
+ def _format_value(value: Any, date_time_format: str) -> Any:
189
+ if isinstance(value, list):
190
+ return [_format_value(item, date_time_format) for item in value]
191
+ if isinstance(value, dict):
192
+ return {name: _format_value(item, date_time_format) for name, item in value.items()}
193
+ if not isinstance(value, str) or "T" not in value:
194
+ return value
195
+ try:
196
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
197
+ except ValueError:
198
+ return value
199
+ return parsed.strftime(date_time_format)
200
+
201
+
202
+ def _response_start_with_length(message: Message, length: int) -> Message:
203
+ headers = [
204
+ (name, value) for name, value in message.get("headers", [])
205
+ if name.lower() != b"content-length"
206
+ ]
207
+ headers.append((b"content-length", str(length).encode()))
208
+ return {**message, "headers": headers}
@@ -0,0 +1,129 @@
1
+ """Class-based controllers and method route declarations."""
2
+
3
+ import inspect
4
+ from dataclasses import dataclass
5
+ from typing import Any, Callable, TypeVar
6
+
7
+ from pythonboot.beans import Scope, component
8
+
9
+ T = TypeVar("T", bound=type)
10
+ F = TypeVar("F", bound=Callable[..., Any])
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class RouteDefinition:
15
+ path: str
16
+ methods: tuple[str, ...]
17
+ options: dict[str, Any]
18
+
19
+
20
+ def controller(
21
+ cls: T | None = None,
22
+ *,
23
+ prefix: str = "",
24
+ tags: list[str] | None = None,
25
+ name: str | None = None,
26
+ init_method: str | None = None,
27
+ destroy_method: str | None = None,
28
+ lazy: bool = False,
29
+ scope: Scope = "singleton",
30
+ primary: bool = False,
31
+ qualifiers: tuple[str, ...] = (),
32
+ depends_on: tuple[str, ...] = (),
33
+ **router_options: Any,
34
+ ) -> T | Callable[[T], T]:
35
+ """Declare a controller, singleton by default, with container-injected dependencies."""
36
+ def decorate(target: T) -> T:
37
+ if not inspect.isclass(target):
38
+ raise TypeError("@controller can only decorate classes")
39
+ component(
40
+ target, name=name, init_method=init_method, destroy_method=destroy_method,
41
+ lazy=lazy, scope=scope, primary=primary, qualifiers=qualifiers, depends_on=depends_on,
42
+ )
43
+ target.__pythonboot_controller__ = {
44
+ **router_options, "prefix": prefix, "tags": list(tags or []),
45
+ }
46
+ return target
47
+
48
+ return decorate(cls) if cls is not None else decorate
49
+
50
+
51
+ def controller_advise(
52
+ cls: T | None = None,
53
+ *,
54
+ name: str | None = None,
55
+ init_method: str | None = None,
56
+ destroy_method: str | None = None,
57
+ lazy: bool = False,
58
+ scope: Scope = "singleton",
59
+ primary: bool = False,
60
+ qualifiers: tuple[str, ...] = (),
61
+ depends_on: tuple[str, ...] = (),
62
+ ) -> T | Callable[[T], T]:
63
+ """Declare a managed global exception handler component."""
64
+ def decorate(target: T) -> T:
65
+ if not inspect.isclass(target):
66
+ raise TypeError("@controller_advise can only decorate classes")
67
+ component(
68
+ target, name=name, init_method=init_method, destroy_method=destroy_method,
69
+ lazy=lazy, scope=scope, primary=primary, qualifiers=qualifiers, depends_on=depends_on,
70
+ )
71
+ target.__pythonboot_controller_advise__ = True
72
+ return target
73
+
74
+ return decorate(cls) if cls is not None else decorate
75
+
76
+
77
+ def exception_handler(
78
+ exception_type: int | type[Exception],
79
+ ) -> Callable[[F], F]:
80
+ """Map a controller advice instance method to an exception or status code."""
81
+ if not isinstance(exception_type, int) and (
82
+ not inspect.isclass(exception_type) or not issubclass(exception_type, Exception)
83
+ ):
84
+ raise TypeError("exception_handler target must be an exception type or status code")
85
+
86
+ def decorate(method: F) -> F:
87
+ if not inspect.isfunction(method):
88
+ raise TypeError("@exception_handler can only decorate instance methods")
89
+ handlers = list(getattr(method, "__pythonboot_exception_handlers__", ()))
90
+ handlers.append(exception_type)
91
+ method.__pythonboot_exception_handlers__ = tuple(handlers)
92
+ return method
93
+
94
+ return decorate
95
+
96
+
97
+ def route(
98
+ path: str, *, methods: tuple[str, ...] = ("GET",), **options: Any
99
+ ) -> Callable[[F], F]:
100
+ """Declare a controller instance route using FastAPI add_api_route options."""
101
+ def decorate(method: F) -> F:
102
+ if not inspect.isfunction(method):
103
+ raise TypeError("Route decorators can only decorate controller instance methods")
104
+ definitions = list(getattr(method, "__pythonboot_routes__", ()))
105
+ definitions.append(RouteDefinition(path, tuple(methods), dict(options)))
106
+ method.__pythonboot_routes__ = tuple(definitions)
107
+ return method
108
+
109
+ return decorate
110
+
111
+
112
+ def get(path: str, **options: Any) -> Callable[[F], F]:
113
+ return route(path, methods=("GET",), **options)
114
+
115
+
116
+ def post(path: str, **options: Any) -> Callable[[F], F]:
117
+ return route(path, methods=("POST",), **options)
118
+
119
+
120
+ def put(path: str, **options: Any) -> Callable[[F], F]:
121
+ return route(path, methods=("PUT",), **options)
122
+
123
+
124
+ def patch(path: str, **options: Any) -> Callable[[F], F]:
125
+ return route(path, methods=("PATCH",), **options)
126
+
127
+
128
+ def delete(path: str, **options: Any) -> Callable[[F], F]:
129
+ return route(path, methods=("DELETE",), **options)
pythonboot_web/docs.py ADDED
@@ -0,0 +1,70 @@
1
+ """OpenAPI documentation UI registration."""
2
+
3
+ from collections.abc import Mapping
4
+ from dataclasses import dataclass
5
+ from html import escape
6
+ from typing import Any, Literal
7
+
8
+ from fastapi import FastAPI, Request
9
+ from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html
10
+ from fastapi.responses import HTMLResponse
11
+
12
+ from pythonboot.exceptions import ConfigurationError
13
+
14
+ DocsProvider = Literal["scalar", "swagger", "redoc"]
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class DocsOptions:
19
+ enabled: bool
20
+ provider: DocsProvider
21
+ path: str
22
+ openapi_path: str
23
+
24
+
25
+ def resolve_docs_options(configuration: Mapping[str, Any]) -> DocsOptions:
26
+ """Read documentation UI settings from the application configuration."""
27
+ pythonboot = configuration.get("pythonboot", {})
28
+ if not isinstance(pythonboot, Mapping):
29
+ raise ConfigurationError("pythonboot configuration must be a mapping")
30
+ values = pythonboot.get("docs", {})
31
+ if not isinstance(values, Mapping):
32
+ raise ConfigurationError("pythonboot.docs configuration must be a mapping")
33
+ enabled = values.get("enabled", True)
34
+ provider = values.get("provider", "scalar")
35
+ path = values.get("path", "/docs")
36
+ openapi_path = values.get("openapi_path", "/openapi.json")
37
+ if not isinstance(enabled, bool):
38
+ raise ConfigurationError("pythonboot.docs.enabled must be a boolean")
39
+ if provider not in ("scalar", "swagger", "redoc"):
40
+ raise ConfigurationError("pythonboot.docs.provider must be scalar, swagger, or redoc")
41
+ for name, value in (("pythonboot.docs.path", path), ("pythonboot.docs.openapi_path", openapi_path)):
42
+ if not isinstance(value, str) or not value.startswith("/") or value == "/":
43
+ raise ConfigurationError(f"{name} must be an absolute non-root path")
44
+ if path == openapi_path:
45
+ raise ConfigurationError("pythonboot.docs.path and pythonboot.docs.openapi_path must be different")
46
+ return DocsOptions(enabled, provider, path, openapi_path)
47
+
48
+
49
+ def register_api_docs(app: FastAPI, options: DocsOptions) -> None:
50
+ """Register the selected documentation UI for an application."""
51
+ if not options.enabled:
52
+ return
53
+ async def endpoint(request: Request) -> HTMLResponse:
54
+ openapi_url = f"{request.scope.get('root_path', '')}{app.openapi_url}"
55
+ title = f"{app.title} API Reference"
56
+ if options.provider == "swagger":
57
+ return get_swagger_ui_html(openapi_url=openapi_url, title=title)
58
+ if options.provider == "redoc":
59
+ return get_redoc_html(openapi_url=openapi_url, title=title)
60
+ return HTMLResponse(
61
+ "<!doctype html>"
62
+ "<html><head>"
63
+ f"<title>{escape(title)}</title>"
64
+ "</head><body>"
65
+ f'<script id="api-reference" data-url="{escape(openapi_url, quote=True)}"></script>'
66
+ '<script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>'
67
+ "</body></html>"
68
+ )
69
+
70
+ app.add_api_route(options.path, endpoint, methods=["GET"], include_in_schema=False)
@@ -0,0 +1,154 @@
1
+ """Container integration preserving native FastAPI routing and dependencies."""
2
+
3
+ import inspect
4
+ from functools import wraps
5
+ from types import ModuleType
6
+ from typing import Any, Callable, TypeVar, get_type_hints
7
+
8
+ from fastapi import APIRouter, FastAPI, Request
9
+
10
+ from pythonboot.exceptions import BeanDefinitionError
11
+ from pythonboot_web.datetime import date_time_annotation
12
+
13
+ T = TypeVar("T")
14
+
15
+
16
+ def inject(
17
+ bean_type: type[T], *, name: str | None = None, qualifier: str | None = None,
18
+ ) -> Callable[[Request], T]:
19
+ """Use Depends(inject(Service)) to resolve a bean according to its scope."""
20
+ def dependency(request: Request) -> T:
21
+ return request.app.state.context.get_bean(bean_type, name=name, qualifier=qualifier)
22
+
23
+ return dependency
24
+
25
+
26
+ def _controller_endpoint(
27
+ cls: type, method: Callable[..., Any], *, date_time_format: str,
28
+ ) -> Callable[..., Any]:
29
+ """Hide self and resolve the current application's controller for each request."""
30
+ signature = inspect.signature(method)
31
+ parameters = list(signature.parameters.values())
32
+ if not parameters or parameters[0].name != "self":
33
+ raise BeanDefinitionError(f"Controller route {cls.__name__}.{method.__name__} must be an instance method")
34
+ parameters = parameters[1:]
35
+ if any(parameter.kind in (
36
+ parameter.POSITIONAL_ONLY, parameter.VAR_POSITIONAL, parameter.VAR_KEYWORD,
37
+ ) for parameter in parameters):
38
+ raise BeanDefinitionError("Controller routes do not support positional-only parameters, *args, or **kwargs")
39
+ try:
40
+ hints = get_type_hints(method, include_extras=True)
41
+ except (NameError, TypeError) as error:
42
+ raise BeanDefinitionError(f"Cannot resolve type hints for controller route {cls.__name__}.{method.__name__}") from error
43
+ parameters = [
44
+ parameter.replace(annotation=date_time_annotation(
45
+ hints.get(parameter.name, parameter.annotation), date_time_format,
46
+ ))
47
+ for parameter in parameters
48
+ ]
49
+ request_name = "_pythonboot_request"
50
+ while request_name in signature.parameters:
51
+ request_name += "_"
52
+ parameters.append(inspect.Parameter(
53
+ request_name, kind=inspect.Parameter.KEYWORD_ONLY, annotation=Request,
54
+ ))
55
+ component_options = vars(cls)["__pythonboot_component__"]
56
+ bean_name = component_options.name or cls.__name__[:1].lower() + cls.__name__[1:]
57
+
58
+ def bound_method(kwargs: dict[str, Any]) -> Callable[..., Any]:
59
+ request: Request = kwargs.pop(request_name)
60
+ instance = request.app.state.context.get_bean(cls, name=bean_name)
61
+ return getattr(instance, method.__name__)
62
+
63
+ if inspect.iscoroutinefunction(method):
64
+ @wraps(method)
65
+ async def endpoint(*args: Any, **kwargs: Any) -> Any:
66
+ target = bound_method(kwargs)
67
+ return await target(*args, **kwargs)
68
+ else:
69
+ @wraps(method)
70
+ def endpoint(*args: Any, **kwargs: Any) -> Any:
71
+ target = bound_method(kwargs)
72
+ return target(*args, **kwargs)
73
+
74
+ endpoint.__name__ = f"{cls.__name__}_{method.__name__}"
75
+ endpoint.__signature__ = signature.replace(
76
+ parameters=parameters,
77
+ return_annotation=hints.get("return", signature.return_annotation),
78
+ )
79
+ return endpoint
80
+
81
+
82
+ def register_routers(
83
+ app: FastAPI, modules: tuple[ModuleType, ...], *, exclude: tuple[type, ...] = (),
84
+ date_time_format: str,
85
+ ) -> None:
86
+ """Automatically register class-based controllers and native APIRouter objects."""
87
+ seen: set[int] = set()
88
+ for module in modules:
89
+ for value in vars(module).values():
90
+ if isinstance(value, APIRouter) and id(value) not in seen:
91
+ seen.add(id(value))
92
+ app.include_router(value)
93
+ elif (
94
+ inspect.isclass(value)
95
+ and value not in exclude
96
+ and value.__module__ == module.__name__
97
+ and "__pythonboot_controller__" in vars(value)
98
+ and id(value) not in seen
99
+ ):
100
+ seen.add(id(value))
101
+ router = APIRouter(**vars(value)["__pythonboot_controller__"])
102
+ for _, method in inspect.getmembers(value, inspect.isfunction):
103
+ definitions = getattr(method, "__pythonboot_routes__", ())
104
+ if not definitions:
105
+ continue
106
+ endpoint = _controller_endpoint(value, method, date_time_format=date_time_format)
107
+ for definition in definitions:
108
+ router.add_api_route(
109
+ definition.path, endpoint,
110
+ methods=list(definition.methods), **definition.options,
111
+ )
112
+ app.include_router(router)
113
+
114
+
115
+ def register_exception_handlers(
116
+ app: FastAPI, modules: tuple[ModuleType, ...], *, exclude: tuple[type, ...] = (),
117
+ ) -> None:
118
+ """Register managed controller advice methods as FastAPI exception handlers."""
119
+ excluded_classes = set(exclude)
120
+ for module in modules:
121
+ for advice_type in vars(module).values():
122
+ if (
123
+ not inspect.isclass(advice_type)
124
+ or advice_type in excluded_classes
125
+ or advice_type.__module__ != module.__name__
126
+ or not vars(advice_type).get("__pythonboot_controller_advise__")
127
+ ):
128
+ continue
129
+ component_options = vars(advice_type)["__pythonboot_component__"]
130
+ bean_name = component_options.name or advice_type.__name__[:1].lower() + advice_type.__name__[1:]
131
+ for method_name, method in vars(advice_type).items():
132
+ targets = getattr(method, "__pythonboot_exception_handlers__", ())
133
+ if not targets:
134
+ continue
135
+ signature = inspect.signature(method)
136
+ parameters = list(signature.parameters.values())
137
+ if not parameters or parameters[0].name != "self":
138
+ raise BeanDefinitionError(
139
+ f"Exception handler {advice_type.__name__}.{method_name} must be an instance method"
140
+ )
141
+
142
+ async def handler(
143
+ request: Request, error: Exception, *,
144
+ _method_name: str = method_name, _advice_type: type = advice_type,
145
+ _bean_name: str = bean_name,
146
+ ) -> Any:
147
+ instance = request.app.state.context.get_bean(_advice_type, name=_bean_name)
148
+ result = getattr(instance, _method_name)(request, error)
149
+ if inspect.isawaitable(result):
150
+ return await result
151
+ return result
152
+
153
+ for target in targets:
154
+ app.add_exception_handler(target, handler)
@@ -0,0 +1,150 @@
1
+ """Access the HTTP request bound to the current execution context."""
2
+
3
+ from collections.abc import Iterator
4
+ from contextlib import contextmanager
5
+ from contextvars import ContextVar
6
+ from dataclasses import dataclass, field
7
+ from http.cookies import SimpleCookie
8
+ from threading import RLock
9
+ from typing import Any
10
+
11
+ from fastapi import Request
12
+ from starlette.types import Message
13
+
14
+ _current_request: ContextVar[Request | None] = ContextVar(
15
+ "pythonboot_current_request", default=None,
16
+ )
17
+
18
+
19
+ @dataclass
20
+ class _RequestData:
21
+ values: dict[str, Any] = field(default_factory=dict)
22
+ lock: Any = field(default_factory=RLock)
23
+
24
+
25
+ _current_data: ContextVar[_RequestData | None] = ContextVar(
26
+ "pythonboot_request_data", default=None,
27
+ )
28
+ _current_response: ContextVar["ResponseContext | None"] = ContextVar(
29
+ "pythonboot_current_response", default=None,
30
+ )
31
+
32
+
33
+ class ResponseContext:
34
+ """Mutable response settings applied before the ASGI response starts."""
35
+
36
+ def __init__(self) -> None:
37
+ self._status_code: int | None = None
38
+ self._headers: dict[bytes, bytes] = {}
39
+ self._cookies: list[bytes] = []
40
+ self._committed = False
41
+ self._lock = RLock()
42
+
43
+ @property
44
+ def status_code(self) -> int | None:
45
+ return self._status_code
46
+
47
+ @status_code.setter
48
+ def status_code(self, value: int) -> None:
49
+ if not isinstance(value, int) or not 100 <= value <= 599:
50
+ raise ValueError("status_code must be an integer between 100 and 599")
51
+ with self._lock:
52
+ self._ensure_mutable()
53
+ self._status_code = value
54
+
55
+ def set_header(self, name: str, value: str) -> None:
56
+ """Set a response header."""
57
+ if not isinstance(name, str) or not isinstance(value, str):
58
+ raise TypeError("response header name and value must be strings")
59
+ with self._lock:
60
+ self._ensure_mutable()
61
+ self._headers[name.encode("latin-1").lower()] = value.encode("latin-1")
62
+
63
+ def set_cookie(self, key: str, value: str, **options: Any) -> None:
64
+ """Add a Set-Cookie header."""
65
+ cookie = SimpleCookie()
66
+ cookie[key] = value
67
+ for name, option_value in options.items():
68
+ cookie[key][name.replace("_", "-")] = option_value
69
+ with self._lock:
70
+ self._ensure_mutable()
71
+ self._cookies.append(cookie.output(header="").strip().encode("latin-1"))
72
+
73
+ def redirect(self, url: str, status_code: int = 307) -> None:
74
+ """Set a redirect location and status code."""
75
+ self.status_code = status_code
76
+ self.set_header("Location", url)
77
+
78
+ def apply(self, message: Message) -> Message:
79
+ """Apply pending settings to an ASGI response-start message."""
80
+ with self._lock:
81
+ self._committed = True
82
+ headers = [
83
+ (name, value) for name, value in message.get("headers", [])
84
+ if name.lower() not in self._headers
85
+ ]
86
+ headers.extend(self._headers.items())
87
+ headers.extend((b"set-cookie", cookie) for cookie in self._cookies)
88
+ return {
89
+ **message,
90
+ "status": self._status_code if self._status_code is not None else message["status"],
91
+ "headers": headers,
92
+ }
93
+
94
+ def _ensure_mutable(self) -> None:
95
+ if self._committed:
96
+ raise RuntimeError("The HTTP response has already started")
97
+
98
+
99
+ class RequestContextHolder:
100
+ """Expose the current request to controllers, services, and advice."""
101
+
102
+ @staticmethod
103
+ def get_request() -> Request:
104
+ request = _current_request.get()
105
+ if request is None:
106
+ raise RuntimeError("No HTTP request is bound to the current execution context")
107
+ return request
108
+
109
+ @staticmethod
110
+ def get_response() -> ResponseContext:
111
+ """Return the mutable response context for the current request."""
112
+ response = _current_response.get()
113
+ if response is None:
114
+ raise RuntimeError("No HTTP request is bound to the current execution context")
115
+ return response
116
+
117
+ @staticmethod
118
+ def putData(key: str, value: Any) -> None:
119
+ """Store a custom value in the current request's thread-safe map."""
120
+ data = RequestContextHolder._data()
121
+ with data.lock:
122
+ data.values[key] = value
123
+
124
+ @staticmethod
125
+ def getData(key: str, default: Any = None) -> Any:
126
+ """Read a custom value from the current request's thread-safe map."""
127
+ data = RequestContextHolder._data()
128
+ with data.lock:
129
+ return data.values.get(key, default)
130
+
131
+ @staticmethod
132
+ def _data() -> _RequestData:
133
+ data = _current_data.get()
134
+ if data is None:
135
+ raise RuntimeError("No HTTP request is bound to the current execution context")
136
+ return data
137
+
138
+ @staticmethod
139
+ @contextmanager
140
+ def use(request: Request, response: ResponseContext) -> Iterator[Request]:
141
+ """Bind a request temporarily and restore the previous binding on exit."""
142
+ token = _current_request.set(request)
143
+ data_token = _current_data.set(_RequestData())
144
+ response_token = _current_response.set(response)
145
+ try:
146
+ yield request
147
+ finally:
148
+ _current_response.reset(response_token)
149
+ _current_data.reset(data_token)
150
+ _current_request.reset(token)
@@ -0,0 +1,45 @@
1
+ Metadata-Version: 2.5
2
+ Name: pythonboot-web
3
+ Version: 0.1.0
4
+ Summary: FastAPI integration and Web application startup for PythonBoot
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: fastapi<1,>=0.115
7
+ Requires-Dist: pythonboot<0.2,>=0.1.0
8
+ Requires-Dist: uvicorn[standard]<1,>=0.34
9
+ Description-Content-Type: text/markdown
10
+
11
+ # pythonboot-web
12
+
13
+ PythonBoot 的独立 Web 实现包,包含 Controller、路由、异常处理、请求与响应上下文、
14
+ 日期格式化、API 文档、ASGI 生命周期和 Web 启动类。
15
+
16
+ 依赖 `pythonboot`、FastAPI 和 Uvicorn,用户推荐安装 `pythonboot-starter-web`。
17
+ 核心接口从 `pythonboot` 导入,Web 接口从 `pythonboot_web` 导入。
18
+ 原来的 `pythonboot.web` 和核心包顶层 Web 导出已移除。
19
+ YAML 的 `pythonboot.web` 配置键保持不变。
20
+
21
+ ```python
22
+ from pythonboot import autowired, service
23
+ from pythonboot_web import PythonBootApplication, controller, get, pythonboot_application
24
+
25
+ @service
26
+ class GreetingService:
27
+ def greeting(self) -> str:
28
+ return "Hello"
29
+
30
+ @controller(prefix="/greeting")
31
+ class GreetingController:
32
+ service: GreetingService = autowired()
33
+
34
+ @get("")
35
+ def greeting(self) -> str:
36
+ return self.service.greeting()
37
+
38
+ @pythonboot_application(scan_packages=("myapp",))
39
+ class Application(PythonBootApplication):
40
+ pass
41
+ ```
42
+
43
+
44
+ 完整分层应用见 [全量 Starter 示例](../../example/README.md),包含全部 Starter、PostgreSQL/Redis 和同步/异步 HTTP 用法。
45
+ See the [complete Starter application](../../example/README.md) for all Starters and sync/async HTTP examples.
@@ -0,0 +1,12 @@
1
+ pythonboot_web/__init__.py,sha256=7cwbWVtwO-7gwkP1XpLsTmM1mJ_VRvMCrKT0jmtE38Q,1204
2
+ pythonboot_web/application.py,sha256=uFMdMUnH3I142Cm6CjgOCrAZsj2LQhNoWweugVOgt2s,6408
3
+ pythonboot_web/bootstrap.py,sha256=nHVrFDaruKsP9hDumLAYQoVfbrQTdmNnFiDJNfkLfZk,1810
4
+ pythonboot_web/context_middleware.py,sha256=4sN3aayKCIyvRRXRPJJ9qvBYU9qqzgB2wqI-VtAmedk,1692
5
+ pythonboot_web/datetime.py,sha256=P1s-2ZoISeu23KtFNJJPBSihdib8nzu7jTXW2wD8Opo,7848
6
+ pythonboot_web/decorators.py,sha256=mHxZA0HYYtiHarWB87Nzn9cHhJYjh6Dm76LFnRRLAQg,4374
7
+ pythonboot_web/docs.py,sha256=hhgA8EwyZv4aNDi-jrSvToqxJVodby66zC1ElsXro3c,3046
8
+ pythonboot_web/integration.py,sha256=3of7MsVPtZb0yQNIaY0shkbF-MAFGt_UG1cyOXoJ_r8,6821
9
+ pythonboot_web/request_context.py,sha256=dVjm4gra2e7g0L43QZ1GdZXaP9JvNI4q85fRKT873XA,5386
10
+ pythonboot_web-0.1.0.dist-info/METADATA,sha256=yUGqc_kDsbMyUkFGskj3JWGxkhDMav0ZRkzqV7c421E,1579
11
+ pythonboot_web-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
12
+ pythonboot_web-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any