lcl-fastapi 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.
Files changed (45) hide show
  1. lcl_fastapi/__init__.py +8 -0
  2. lcl_fastapi/cli/__init__.py +5 -0
  3. lcl_fastapi/cli/arguments.py +35 -0
  4. lcl_fastapi/cli/commands.py +148 -0
  5. lcl_fastapi/cli/main.py +41 -0
  6. lcl_fastapi/cli/output.py +34 -0
  7. lcl_fastapi/config.py +277 -0
  8. lcl_fastapi/context.py +50 -0
  9. lcl_fastapi/docs/__init__.py +1 -0
  10. lcl_fastapi/docs/swagger.py +62 -0
  11. lcl_fastapi/health/__init__.py +1 -0
  12. lcl_fastapi/health/sampler.py +178 -0
  13. lcl_fastapi/logging.py +103 -0
  14. lcl_fastapi/middleware/__init__.py +1 -0
  15. lcl_fastapi/middleware/request_context.py +117 -0
  16. lcl_fastapi/py.typed +0 -0
  17. lcl_fastapi/render/__init__.py +6 -0
  18. lcl_fastapi/render/config.py +86 -0
  19. lcl_fastapi/render/nginx.py +80 -0
  20. lcl_fastapi/render/systemd.py +92 -0
  21. lcl_fastapi/render/validation.py +60 -0
  22. lcl_fastapi/runtime/__init__.py +3 -0
  23. lcl_fastapi/runtime/access.py +30 -0
  24. lcl_fastapi/runtime/application.py +41 -0
  25. lcl_fastapi/runtime/common.py +149 -0
  26. lcl_fastapi/runtime/lease.py +64 -0
  27. lcl_fastapi/runtime/linux.py +129 -0
  28. lcl_fastapi/runtime/service.py +87 -0
  29. lcl_fastapi/runtime/state.py +123 -0
  30. lcl_fastapi/runtime/windows.py +108 -0
  31. lcl_fastapi/runtime/worker.py +175 -0
  32. lcl_fastapi/service.py +206 -0
  33. lcl_fastapi/static/defaults.lclcfg +30 -0
  34. lcl_fastapi/static/swagger/LICENSE +202 -0
  35. lcl_fastapi/static/swagger/NOTICE +2 -0
  36. lcl_fastapi/static/swagger/NOTICE.md +25 -0
  37. lcl_fastapi/static/swagger/favicon-32x32.png +0 -0
  38. lcl_fastapi/static/swagger/swagger-ui-bundle.js +2 -0
  39. lcl_fastapi/static/swagger/swagger-ui-bundle.js.LICENSE.txt +104 -0
  40. lcl_fastapi/static/swagger/swagger-ui.css +3 -0
  41. lcl_fastapi-0.1.0.dist-info/METADATA +161 -0
  42. lcl_fastapi-0.1.0.dist-info/RECORD +45 -0
  43. lcl_fastapi-0.1.0.dist-info/WHEEL +4 -0
  44. lcl_fastapi-0.1.0.dist-info/entry_points.txt +2 -0
  45. lcl_fastapi-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,8 @@
1
+ """Public application, configuration, logging, and request-context interfaces."""
2
+
3
+ from lcl_fastapi.context import RequestContext, get_config, get_request_context
4
+ from lcl_fastapi.logging import get_logger
5
+ from lcl_fastapi.service import LclFastAPI
6
+
7
+ # Unitless public names define the first-version import contract.
8
+ __all__ = ["LclFastAPI", "RequestContext", "get_config", "get_logger", "get_request_context"]
@@ -0,0 +1,5 @@
1
+ """Configuration-driven console operations built on lclang.cli."""
2
+
3
+ from lcl_fastapi.cli.main import main
4
+
5
+ __all__ = ["main"]
@@ -0,0 +1,35 @@
1
+ """Preflight service CLI policy using lclang's public option parser."""
2
+
3
+ from collections.abc import Sequence
4
+
5
+ from lclang.cli.parser import parse_cli_params, split_argv
6
+
7
+
8
+ def validate_arguments(arguments: Sequence[str]) -> None:
9
+ """Reject service overrides before lclang can construct a logger scope.
10
+
11
+ :param arguments: Full Python-style argv with an internal ``.py`` script label.
12
+ :raises ValueError: If the invocation supplies a forbidden configuration source or key.
13
+ :raises LclCliUsageError: If lclang rejects option syntax or argument arity.
14
+ """
15
+ parts = split_argv(arguments)
16
+ tokens = parts.tokens
17
+ if not tokens or tokens in (("-v",), ("--version",)):
18
+ return
19
+ option_start = next(
20
+ (index for index, token in enumerate(tokens) if token.startswith("-")), len(tokens)
21
+ )
22
+ command = tokens[:option_start]
23
+ params = parse_cli_params(parts, command or ("help",), tokens[option_start:])
24
+ if params.config_file_path is not None:
25
+ raise ValueError("-c/--config is unavailable; use -o config service.lclcfg")
26
+ if params.dryrun:
27
+ raise ValueError("dryrun is unavailable; render commands already only generate files")
28
+ allowed = {"config"}
29
+ if command in (("status",), ("logs",)):
30
+ allowed.add("json")
31
+ if command in (("nginx", "render"), ("systemd", "render")):
32
+ allowed.add("output")
33
+ unknown = params.overrides.keys() - allowed
34
+ if unknown:
35
+ raise ValueError(f"unsupported override: {', '.join(sorted(unknown))}")
@@ -0,0 +1,148 @@
1
+ """Declare the six operational commands using the supported lclang CLI surface."""
2
+
3
+ import asyncio
4
+ from pathlib import Path
5
+ from typing import Literal
6
+
7
+ from lclang.cli import CliContext, CliResult, Command, CommandGroup, ParameterDoc, cli
8
+
9
+ from lcl_fastapi.cli.output import format_logs, format_status
10
+ from lcl_fastapi.render.config import render_config
11
+ from lcl_fastapi.render.validation import text_value
12
+
13
+
14
+ async def config_path(context: CliContext) -> Path:
15
+ """Read the service file path as ordinary CLI data, never a CLI config layer.
16
+
17
+ :param context: Active lclang command invocation.
18
+ :returns: Absolute service configuration file path.
19
+ :raises ValueError: If the config parameter is not nonempty path text.
20
+ """
21
+ value = await context.frame.get("config")
22
+ return await asyncio.to_thread(Path(text_value(value, "config")).resolve)
23
+
24
+
25
+ async def json_output(context: CliContext) -> bool:
26
+ """Resolve the explicit LCL Boolean controlling operational JSON output.
27
+
28
+ :param context: Active lclang command invocation.
29
+ :returns: Whether the complete result should be emitted as JSON.
30
+ :raises ValueError: If json is not a Boolean.
31
+ """
32
+ value = await context.frame.get("json", fallback=False)
33
+ if not isinstance(value, bool):
34
+ raise ValueError('json must be Boolean; use -o json or -o json "LCL[True]"')
35
+ return value
36
+
37
+
38
+ def renderer_command(kind: Literal["nginx", "systemd"]) -> Command:
39
+ """Declare a render leaf that only writes the requested output file.
40
+
41
+ :param kind: Deployment artifact to render.
42
+ :returns: A lclang command with config and optional output parameters.
43
+ """
44
+
45
+ async def render_command(context: CliContext) -> CliResult:
46
+ """Generate deployment text and return it or write a caller-selected file.
47
+
48
+ :param context: Validated invocation with an ordinary service path parameter.
49
+ :returns: Rendered text or an empty success after writing a file.
50
+ :raises ValueError: If renderer configuration or output path is invalid.
51
+ :raises OSError: If reading configuration or writing the output fails.
52
+ """
53
+ result = await render_config(kind, await config_path(context))
54
+ output = await context.frame.get("output", fallback=None)
55
+ if output is None:
56
+ return CliResult.success(result.rstrip("\n"))
57
+ path = Path(text_value(output, "output"))
58
+ await asyncio.to_thread(path.write_text, result, encoding="utf-8", newline="\n")
59
+ return CliResult.success("")
60
+
61
+ return cli.command(
62
+ "render",
63
+ f"Generate {kind} configuration; never install or reload it.",
64
+ parameter_docs=(
65
+ ParameterDoc("config", str, True, "Service .lclcfg file path."),
66
+ ParameterDoc("output", str, False, "Output file path; omit for stdout."),
67
+ ),
68
+ )(render_command)
69
+
70
+
71
+ def command_group(pending: list[Path]) -> CommandGroup:
72
+ """Create an isolated CLI command tree and a deferred server-start handoff.
73
+
74
+ :param pending: Caller-owned empty list receiving the serve configuration path.
75
+ :returns: Root lclang group containing only supported first-version commands.
76
+ """
77
+ config_doc = ParameterDoc("config", str, True, "Service .lclcfg file path.")
78
+ json_doc = ParameterDoc("json", bool, False, "Return JSON; use -o json.", False)
79
+
80
+ @cli.command("serve", "Run the service until graceful shutdown.", (config_doc,))
81
+ async def serve_command(context: CliContext) -> CliResult:
82
+ """Defer server startup until the command logger and event loop are closed.
83
+
84
+ :param context: Active command invocation.
85
+ :returns: Empty success after retaining the service configuration path.
86
+ :raises ValueError: If the config parameter is invalid.
87
+ """
88
+ pending.append(await config_path(context))
89
+ return CliResult.success("")
90
+
91
+ @cli.command(
92
+ "status", "Inspect the local service identity and workers.", (config_doc, json_doc)
93
+ )
94
+ async def status_command(context: CliContext) -> CliResult:
95
+ """Read a runtime snapshot using PID and process-creation identity checks.
96
+
97
+ :param context: Active command invocation.
98
+ :returns: Plain text or JSON runtime status.
99
+ :raises ValueError: If command configuration is invalid.
100
+ :raises OSError: If local runtime inspection fails.
101
+ """
102
+ from lcl_fastapi.runtime.common import inspect_status
103
+
104
+ result = await inspect_status(await config_path(context))
105
+ return CliResult.success(format_status(result, as_json=await json_output(context)))
106
+
107
+ @cli.command("logs", "List live workers' observed active log segments.", (config_doc, json_doc))
108
+ async def logs_command(context: CliContext) -> CliResult:
109
+ """Return observed log paths without querying a network endpoint.
110
+
111
+ :param context: Active command invocation.
112
+ :returns: Plain paths or a JSON observation snapshot.
113
+ :raises ValueError: If command configuration or runtime output is invalid.
114
+ :raises OSError: If local runtime inspection fails.
115
+ """
116
+ from lcl_fastapi.runtime.common import active_logs
117
+
118
+ result = await active_logs(await config_path(context))
119
+ return CliResult.success(format_logs(result, as_json=await json_output(context)))
120
+
121
+ @cli.command("stop", "Request authenticated graceful shutdown over loopback.", (config_doc,))
122
+ async def stop_command(context: CliContext) -> CliResult:
123
+ """Invoke the identity-checked local control operation.
124
+
125
+ :param context: Active command invocation.
126
+ :returns: Empty success after the runtime accepts the stop request.
127
+ :raises ValueError: If configuration or service identity is invalid.
128
+ :raises OSError: If the verified local service cannot be reached.
129
+ """
130
+ from lcl_fastapi.runtime.common import stop
131
+
132
+ await stop(await config_path(context))
133
+ return CliResult.success("")
134
+
135
+ return CommandGroup(
136
+ "lcl_fastapi",
137
+ "Local service operations; configuration comes from .lclcfg.",
138
+ (
139
+ serve_command,
140
+ status_command,
141
+ logs_command,
142
+ stop_command,
143
+ CommandGroup(
144
+ "nginx", "Nginx deployment file generation.", (renderer_command("nginx"),)
145
+ ),
146
+ CommandGroup("systemd", "systemd unit generation.", (renderer_command("systemd"),)),
147
+ ),
148
+ )
@@ -0,0 +1,41 @@
1
+ """Run lclang CLI scopes before entering the platform's process manager."""
2
+
3
+ import asyncio
4
+ import sys
5
+ from collections.abc import Sequence
6
+ from importlib.metadata import version
7
+ from pathlib import Path
8
+
9
+ from lclang.cli import CliConfig, CliEntrance
10
+ from lclang.errors import LclError
11
+ from lclang.logger import LoggerHandlerConfig
12
+
13
+ from lcl_fastapi.cli.arguments import validate_arguments
14
+ from lcl_fastapi.cli.commands import command_group
15
+
16
+
17
+ def main(arguments: Sequence[str] | None = None) -> int:
18
+ """Execute an operational command and then any deferred foreground server.
19
+
20
+ :param arguments: Tokens after the console command, or None for process argv.
21
+ :returns: Zero for success and a nonzero command or startup error status.
22
+ """
23
+ tokens = list(sys.argv[1:] if arguments is None else arguments)
24
+ full_arguments = [sys.executable, "lcl-fastapi.py", *tokens]
25
+ pending: list[Path] = []
26
+ try:
27
+ validate_arguments(full_arguments)
28
+ entrance = CliEntrance(
29
+ command_group(pending),
30
+ version=version("lcl-fastapi"),
31
+ cli_config=CliConfig(LoggerHandlerConfig(console={"stream": "stderr"}, file={})),
32
+ )
33
+ result = asyncio.run(entrance.run(full_arguments))
34
+ if result == 0 and pending:
35
+ from lcl_fastapi.runtime.common import serve
36
+
37
+ serve(pending[0])
38
+ return result
39
+ except (ValueError, OSError, RuntimeError, LclError) as error:
40
+ print(f"error: {error}", file=sys.stderr)
41
+ return 2
@@ -0,0 +1,34 @@
1
+ """Format local operational results without mixing diagnostics into stdout."""
2
+
3
+ import json
4
+ from collections.abc import Mapping
5
+
6
+
7
+ def format_status(result: Mapping[str, object], *, as_json: bool) -> str:
8
+ """Format a runtime status snapshot for a person or a JSON consumer.
9
+
10
+ :param result: JSON-compatible runtime status fields.
11
+ :param as_json: Whether to emit the complete structured snapshot.
12
+ :returns: JSON or stable, readable field rows.
13
+ """
14
+ if as_json:
15
+ return json.dumps(dict(result), ensure_ascii=False, sort_keys=True)
16
+ return "\n".join(
17
+ f"{name.replace('_', ' ').title():<20} {value}" for name, value in result.items()
18
+ )
19
+
20
+
21
+ def format_logs(result: Mapping[str, object], *, as_json: bool) -> str:
22
+ """Format active paths while retaining observation metadata in JSON mode.
23
+
24
+ :param result: Runtime snapshot with paths, observed_at, and stale fields.
25
+ :param as_json: Whether to include the complete observation metadata.
26
+ :returns: JSON or one absolute path per line.
27
+ :raises ValueError: If the runtime does not return a list of textual paths.
28
+ """
29
+ paths = result.get("paths")
30
+ if not isinstance(paths, list) or not all(isinstance(path, str) for path in paths):
31
+ raise ValueError("runtime log snapshot must contain a paths list of strings")
32
+ if as_json:
33
+ return json.dumps(dict(result), ensure_ascii=False, sort_keys=True)
34
+ return "\n".join(str(path) for path in paths)
lcl_fastapi/config.py ADDED
@@ -0,0 +1,277 @@
1
+ """Read trusted LCL files into validated worker and service settings."""
2
+
3
+ import asyncio
4
+ import math
5
+ from collections.abc import AsyncIterator
6
+ from contextlib import asynccontextmanager
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from urllib.parse import urlsplit
10
+
11
+ from lclang.config import Config, load_config
12
+ from lclang.runtime import Frame
13
+
14
+
15
+ @dataclass(frozen=True, slots=True)
16
+ class Settings:
17
+ """Store validated values without retaining a configuration Frame.
18
+
19
+ :param app_name: Service name recorded in runtime state.
20
+ :param app_version: Downstream application's version text.
21
+ :param app_target: Import target in module:attribute form.
22
+ :param host: Loopback or all-IPv4 listener address.
23
+ :param port: TCP port between 1 and 65535.
24
+ :param workers: Configured worker count.
25
+ :param origin: Optional external HTTP(S) origin, never an ASGI path.
26
+ :param state_dir: Absolute service state directory.
27
+ :param pid_file: Absolute service PID file.
28
+ :param worker_state_dir: Absolute worker-state directory.
29
+ :param backlog: Pending connection queue capacity.
30
+ :param keep_alive_seconds: Idle HTTP connection timeout in seconds.
31
+ :param graceful_timeout_seconds: Graceful shutdown deadline in seconds.
32
+ :param health_enabled: Whether to register the default health endpoint.
33
+ :param health_path: Absolute health route path.
34
+ :param sample_interval_seconds: State and metric refresh interval in seconds.
35
+ :param disk_paths: Absolute filesystem paths included in health sampling.
36
+ :param docs_enabled: Whether to register built-in Swagger resources.
37
+ :param docs_path: Absolute Swagger HTML route path.
38
+ :param openapi_path: Absolute schema route path.
39
+ :param id_header: ASCII HTTP response header carrying the request ID.
40
+ :param worker_id_base: First allowed Snowflake worker ID.
41
+ :param worker_id_count: Number of allowed consecutive worker IDs.
42
+ """
43
+
44
+ app_name: str
45
+ app_version: str
46
+ app_target: str
47
+ host: str
48
+ port: int
49
+ workers: int
50
+ origin: str
51
+ state_dir: Path
52
+ pid_file: Path
53
+ worker_state_dir: Path
54
+ backlog: int
55
+ keep_alive_seconds: int
56
+ graceful_timeout_seconds: int
57
+ health_enabled: bool
58
+ health_path: str
59
+ sample_interval_seconds: float
60
+ disk_paths: tuple[Path, ...]
61
+ docs_enabled: bool
62
+ docs_path: str
63
+ openapi_path: str
64
+ id_header: str
65
+ worker_id_base: int
66
+ worker_id_count: int
67
+
68
+
69
+ def text_value(value: object, name: str) -> str:
70
+ """Validate nonempty text without control characters.
71
+
72
+ :param value: Resolved configuration value.
73
+ :param name: Qualified name used in diagnostics.
74
+ :returns: Validated text.
75
+ :raises ValueError: If text is absent, empty, or contains controls.
76
+ """
77
+ if not isinstance(value, str) or not value or any(ord(char) < 32 for char in value):
78
+ raise ValueError(f"{name}: expected nonempty text without controls")
79
+ return value
80
+
81
+
82
+ def integer_value(value: object, name: str, minimum: int, maximum: int) -> int:
83
+ """Validate a bounded integer without accepting Boolean values.
84
+
85
+ :param value: Resolved configuration value.
86
+ :param name: Qualified name used in diagnostics.
87
+ :param minimum: Inclusive lower bound.
88
+ :param maximum: Inclusive upper bound.
89
+ :returns: Validated integer.
90
+ :raises ValueError: If the value has an invalid type or exceeds a bound.
91
+ """
92
+ if type(value) is not int or not minimum <= value <= maximum:
93
+ raise ValueError(f"{name}: expected an integer from {minimum} to {maximum}")
94
+ return value
95
+
96
+
97
+ def origin_value(value: object) -> str:
98
+ """Validate external origin metadata without interpreting it as a path.
99
+
100
+ :param value: Empty text or an HTTP(S) origin.
101
+ :returns: Original validated origin.
102
+ :raises ValueError: If the value contains credentials, a path, or URL suffixes.
103
+ """
104
+ if value == "":
105
+ return ""
106
+ origin = text_value(value, "server.root_path")
107
+ parsed = urlsplit(origin)
108
+ if (
109
+ parsed.scheme not in {"http", "https"}
110
+ or not parsed.hostname
111
+ or parsed.username is not None
112
+ or parsed.password is not None
113
+ or parsed.path
114
+ or parsed.query
115
+ or parsed.fragment
116
+ or any(char.isspace() for char in origin)
117
+ or "\\" in origin
118
+ or "?" in origin
119
+ or "#" in origin
120
+ or parsed.netloc.endswith(":")
121
+ ):
122
+ raise ValueError("server.root_path: expected an HTTP(S) origin without a path")
123
+ if parsed.port is not None and not 1 <= parsed.port <= 65535:
124
+ raise ValueError("server.root_path: invalid port")
125
+ return origin
126
+
127
+
128
+ @asynccontextmanager
129
+ async def configuration_frame(
130
+ config_path: Path,
131
+ worker_pid: int | None = None,
132
+ ) -> AsyncIterator[Frame]:
133
+ """Load fresh source files and own their combined LCL Frame.
134
+
135
+ :param config_path: Trusted service configuration file.
136
+ :param worker_pid: Actual worker PID, or None for non-worker settings inspection.
137
+ :returns: Async context manager yielding a caller-scoped Frame.
138
+ :raises LclConfigError: If a source cannot be loaded or parsed.
139
+ :raises ValueError: If a service defines the framework-owned worker PID.
140
+ """
141
+ defaults = await load_config(Path(__file__).parent / "static" / "defaults.lclcfg")
142
+ loaded = await load_config(config_path)
143
+ if "worker_pid" in loaded.definitions:
144
+ raise ValueError("worker_pid is provided by the framework")
145
+ combined = Config(loaded.version, loaded.root_origin, defaults.expanded + loaded.expanded)
146
+ values: dict[str, object] = {} if worker_pid is None else {"worker_pid": worker_pid}
147
+ async with combined.frame_factory().create(values=values) as frame:
148
+ yield frame
149
+
150
+
151
+ async def settings_from_frame(frame: Frame, config_path: Path) -> Settings:
152
+ """Resolve settings while leaving logger expressions lazy.
153
+
154
+ :param frame: Caller-owned active configuration Frame.
155
+ :param config_path: File whose parent anchors relative filesystem values.
156
+ :returns: Validated detached service settings.
157
+ :raises ValueError: If a setting has an invalid type or combination.
158
+ :raises LclError: If a required value is absent or fails evaluation.
159
+ """
160
+
161
+ async def text(name: str) -> str:
162
+ """Resolve one nonempty string.
163
+
164
+ :param name: Qualified configuration key.
165
+ :returns: Validated string.
166
+ :raises ValueError: If the resolved value is not valid text.
167
+ """
168
+ return text_value(await frame.get(name), name)
169
+
170
+ async def number(name: str, minimum: int, maximum: int) -> int:
171
+ """Resolve one integer with explicit bounds.
172
+
173
+ :param name: Qualified configuration key.
174
+ :param minimum: Inclusive lower bound.
175
+ :param maximum: Inclusive upper bound.
176
+ :returns: Validated integer.
177
+ :raises ValueError: If the value is not a bounded integer.
178
+ """
179
+ return integer_value(await frame.get(name), name, minimum, maximum)
180
+
181
+ async def flag(name: str) -> bool:
182
+ """Resolve a strict Boolean.
183
+
184
+ :param name: Qualified configuration key.
185
+ :returns: Validated Boolean.
186
+ :raises ValueError: If the value is not Boolean.
187
+ """
188
+ value = await frame.get(name)
189
+ if not isinstance(value, bool):
190
+ raise ValueError(f"{name}: expected a Boolean")
191
+ return value
192
+
193
+ async def path(name: str) -> str:
194
+ """Resolve a route without query or fragment components.
195
+
196
+ :param name: Qualified route configuration key.
197
+ :returns: Absolute route path.
198
+ :raises ValueError: If the route is not an absolute path.
199
+ """
200
+ value = await text(name)
201
+ if not value.startswith("/") or value.startswith("//") or any(c in value for c in "?#"):
202
+ raise ValueError(f"{name}: expected an absolute route path")
203
+ return value
204
+
205
+ base = (await asyncio.to_thread(config_path.resolve)).parent
206
+
207
+ async def filesystem_path(name: str) -> Path:
208
+ """Resolve a configured path outside the event-loop thread.
209
+
210
+ :param name: Qualified filesystem configuration key.
211
+ :returns: Absolute path anchored to the configuration directory.
212
+ :raises ValueError: If the configured value is not text.
213
+ """
214
+ selected = base / await text(name)
215
+ return await asyncio.to_thread(selected.resolve)
216
+
217
+ host = await text("server.host")
218
+ if host not in {"127.0.0.1", "0.0.0.0"}:
219
+ raise ValueError("server.host: expected 127.0.0.1 or 0.0.0.0")
220
+ workers = await number("server.workers", 1, 1024)
221
+ worker_base = await number("snowflake.worker_id_base", 0, 1023)
222
+ worker_count = await number("snowflake.worker_id_count", workers, 1024 - worker_base)
223
+ interval = await frame.get("health.sample_interval_seconds")
224
+ if (
225
+ isinstance(interval, bool)
226
+ or not isinstance(interval, (int, float))
227
+ or not math.isfinite(interval)
228
+ or interval <= 0
229
+ ):
230
+ raise ValueError("health.sample_interval_seconds: expected a positive number")
231
+ disks = await frame.get("health.disk_paths")
232
+ if not isinstance(disks, (list, tuple)):
233
+ raise ValueError("health.disk_paths: expected a list of paths")
234
+ disk_paths = []
235
+ for item in disks:
236
+ disk_path = base / text_value(item, "health.disk_paths")
237
+ disk_paths.append(await asyncio.to_thread(disk_path.resolve))
238
+ header = await text("request.id_header")
239
+ if not all(char.isascii() and (char.isalnum() or char in "!#$%&'*+-.^_`|~") for char in header):
240
+ raise ValueError("request.id_header: expected an ASCII HTTP header name")
241
+ return Settings(
242
+ await text("app.name"),
243
+ await text("app.version"),
244
+ await text("app.target"),
245
+ host,
246
+ await number("server.port", 1, 65535),
247
+ workers,
248
+ origin_value(await frame.get("server.root_path")),
249
+ await filesystem_path("runtime.state_dir"),
250
+ await filesystem_path("runtime.pid_file"),
251
+ await filesystem_path("runtime.worker_state_dir"),
252
+ await number("server.backlog", 1, 65535),
253
+ await number("server.keep_alive_seconds", 0, 86400),
254
+ await number("server.graceful_timeout_seconds", 1, 86400),
255
+ await flag("health.enabled"),
256
+ await path("health.path"),
257
+ float(interval),
258
+ tuple(disk_paths),
259
+ await flag("docs.enabled"),
260
+ await path("docs.path"),
261
+ await path("docs.openapi_path"),
262
+ header,
263
+ worker_base,
264
+ worker_count,
265
+ )
266
+
267
+
268
+ async def load_settings(config_path: Path) -> Settings:
269
+ """Load detached settings without initializing worker logging.
270
+
271
+ :param config_path: Trusted service configuration path.
272
+ :returns: Validated service settings.
273
+ :raises LclError: If configuration loading or evaluation fails.
274
+ :raises ValueError: If a setting is invalid.
275
+ """
276
+ async with configuration_frame(config_path) as frame:
277
+ return await settings_from_frame(frame, config_path)
lcl_fastapi/context.py ADDED
@@ -0,0 +1,50 @@
1
+ """Task-local request and configuration bindings for active worker scopes."""
2
+
3
+ from contextvars import ContextVar
4
+ from dataclasses import dataclass
5
+
6
+ from lclang.runtime import Frame
7
+
8
+
9
+ @dataclass(frozen=True, slots=True)
10
+ class RequestContext:
11
+ """Describe one HTTP request without mutable shared state.
12
+
13
+ :param request_id: Decimal server-generated Snowflake identifier.
14
+ :param method: HTTP request method.
15
+ :param path: Request path without query parameters.
16
+ :param started_ns: Monotonic start time in nanoseconds.
17
+ """
18
+
19
+ request_id: str
20
+ method: str
21
+ path: str
22
+ started_ns: int
23
+
24
+
25
+ # Task-local bindings have no units; None means no active framework scope.
26
+ REQUEST_CONTEXT: ContextVar[RequestContext | None] = ContextVar("lcl_request", default=None)
27
+ CONFIG_FRAME: ContextVar[Frame | None] = ContextVar("lcl_config", default=None)
28
+
29
+
30
+ def get_request_context() -> RequestContext | None:
31
+ """Read the current request binding without creating state.
32
+
33
+ :returns: Immutable request context, or None outside an HTTP request.
34
+ """
35
+ return REQUEST_CONTEXT.get()
36
+
37
+
38
+ async def get_config(key: str) -> object:
39
+ """Evaluate a business setting in the current worker Frame.
40
+
41
+ :param key: Qualified LCL configuration name.
42
+ :returns: Value resolved by the worker's existing LCL Frame.
43
+ :raises RuntimeError: If called outside the worker lifespan or request scope.
44
+ :raises LclNameError: If the configuration name is absent.
45
+ :raises LclEvaluationError: If the configured expression cannot be evaluated.
46
+ """
47
+ frame = CONFIG_FRAME.get()
48
+ if frame is None:
49
+ raise RuntimeError("get_config requires an active worker lifespan or request")
50
+ return await frame.get(key)
@@ -0,0 +1 @@
1
+ """Offline documentation route assembly."""
@@ -0,0 +1,62 @@
1
+ """Serve Swagger HTML and assets exclusively from this application."""
2
+
3
+ from pathlib import Path
4
+
5
+ from fastapi import FastAPI
6
+ from fastapi.openapi.docs import get_swagger_ui_html
7
+ from starlette.responses import HTMLResponse, JSONResponse
8
+ from starlette.staticfiles import StaticFiles
9
+
10
+ from lcl_fastapi.config import Settings
11
+
12
+
13
+ def swagger_page(settings: Settings) -> HTMLResponse:
14
+ """Produce offline Swagger HTML without a remote validator or favicon.
15
+
16
+ :param settings: Validated application documentation settings.
17
+ :returns: HTML referring only to local static and OpenAPI endpoints.
18
+ """
19
+ return get_swagger_ui_html(
20
+ openapi_url=settings.openapi_path,
21
+ title=f"{settings.app_name} - Swagger UI",
22
+ swagger_js_url="/_lcl/static/swagger/swagger-ui-bundle.js",
23
+ swagger_css_url="/_lcl/static/swagger/swagger-ui.css",
24
+ swagger_favicon_url="/_lcl/static/swagger/favicon-32x32.png",
25
+ swagger_ui_parameters={"validatorUrl": None},
26
+ )
27
+
28
+
29
+ def register_documentation(app: FastAPI, settings: Settings) -> None:
30
+ """Add fallback docs routes while preserving exact business overrides.
31
+
32
+ :param app: Application whose existing routes take precedence.
33
+ :param settings: Worker-resolved documentation paths and enable flag.
34
+ """
35
+ if not settings.docs_enabled:
36
+ return
37
+
38
+ async def docs_page() -> HTMLResponse:
39
+ """Render the configured Swagger page.
40
+
41
+ :returns: Locally served Swagger HTML.
42
+ """
43
+ return swagger_page(settings)
44
+
45
+ async def schema_page() -> JSONResponse:
46
+ """Render the current application schema.
47
+
48
+ :returns: OpenAPI schema without the private shutdown endpoint.
49
+ """
50
+ return JSONResponse(app.openapi())
51
+
52
+ for path, endpoint in ((settings.docs_path, docs_page), (settings.openapi_path, schema_page)):
53
+ if not any(
54
+ getattr(route, "path", None) == path and "GET" in getattr(route, "methods", set())
55
+ for route in app.routes
56
+ ):
57
+ app.add_api_route(path, endpoint, methods=["GET"], include_in_schema=False)
58
+ app.mount(
59
+ "/_lcl/static/swagger",
60
+ StaticFiles(directory=Path(__file__).parent.parent / "static" / "swagger"),
61
+ name="lcl-swagger-static",
62
+ )
@@ -0,0 +1 @@
1
+ """Cached health metrics with failure-tolerant system sampling."""