logicfp 2.1.0__tar.gz

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 (52) hide show
  1. logicfp-2.1.0/MANIFEST.in +17 -0
  2. logicfp-2.1.0/PKG-INFO +66 -0
  3. logicfp-2.1.0/README.md +53 -0
  4. logicfp-2.1.0/pyproject.toml +35 -0
  5. logicfp-2.1.0/setup.cfg +4 -0
  6. logicfp-2.1.0/src/logicfp/__init__.py +41 -0
  7. logicfp-2.1.0/src/logicfp/_version.py +3 -0
  8. logicfp-2.1.0/src/logicfp/app_factory.py +118 -0
  9. logicfp-2.1.0/src/logicfp/application/__init__.py +3 -0
  10. logicfp-2.1.0/src/logicfp/application/context_builder.py +22 -0
  11. logicfp-2.1.0/src/logicfp/application/metrics.py +15 -0
  12. logicfp-2.1.0/src/logicfp/application/validator.py +75 -0
  13. logicfp-2.1.0/src/logicfp/cli.py +232 -0
  14. logicfp-2.1.0/src/logicfp/config/__init__.py +35 -0
  15. logicfp-2.1.0/src/logicfp/config/loader.py +427 -0
  16. logicfp-2.1.0/src/logicfp/config/policy_config.py +11 -0
  17. logicfp-2.1.0/src/logicfp/config/runtime_config.py +10 -0
  18. logicfp-2.1.0/src/logicfp/config/runtime_settings.py +14 -0
  19. logicfp-2.1.0/src/logicfp/config/strategy_config.py +9 -0
  20. logicfp-2.1.0/src/logicfp/config/yaml_support.py +100 -0
  21. logicfp-2.1.0/src/logicfp/decorator.py +8 -0
  22. logicfp-2.1.0/src/logicfp/decorator_impl.py +401 -0
  23. logicfp-2.1.0/src/logicfp/domain/__init__.py +4 -0
  24. logicfp-2.1.0/src/logicfp/domain/errors.py +49 -0
  25. logicfp-2.1.0/src/logicfp/domain/executor.py +52 -0
  26. logicfp-2.1.0/src/logicfp/domain/fsm.py +77 -0
  27. logicfp-2.1.0/src/logicfp/domain/models.py +57 -0
  28. logicfp-2.1.0/src/logicfp/engineering.py +55 -0
  29. logicfp-2.1.0/src/logicfp/handler_registry.py +27 -0
  30. logicfp-2.1.0/src/logicfp/handlers/__init__.py +22 -0
  31. logicfp-2.1.0/src/logicfp/handlers/demo_handlers.py +72 -0
  32. logicfp-2.1.0/src/logicfp/handlers/registry.py +54 -0
  33. logicfp-2.1.0/src/logicfp/handlers/schemas.py +9 -0
  34. logicfp-2.1.0/src/logicfp/infra/__init__.py +1 -0
  35. logicfp-2.1.0/src/logicfp/infra/consensus/__init__.py +16 -0
  36. logicfp-2.1.0/src/logicfp/infra/consensus/client.py +24 -0
  37. logicfp-2.1.0/src/logicfp/infra/consensus/factory.py +26 -0
  38. logicfp-2.1.0/src/logicfp/infra/consensus/heartbeat.py +10 -0
  39. logicfp-2.1.0/src/logicfp/infra/consensus/memory.py +11 -0
  40. logicfp-2.1.0/src/logicfp/infra/consensus/redis.py +48 -0
  41. logicfp-2.1.0/src/logicfp/infra/logging/__init__.py +10 -0
  42. logicfp-2.1.0/src/logicfp/infra/logging/event_logger.py +40 -0
  43. logicfp-2.1.0/src/logicfp/infra/metrics/__init__.py +5 -0
  44. logicfp-2.1.0/src/logicfp/infra/metrics/prometheus.py +28 -0
  45. logicfp-2.1.0/src/logicfp/lifespan.py +21 -0
  46. logicfp-2.1.0/src/logicfp/middleware.py +66 -0
  47. logicfp-2.1.0/src/logicfp/router.py +60 -0
  48. logicfp-2.1.0/src/logicfp/runtime.py +186 -0
  49. logicfp-2.1.0/src/logicfp/schemas.py +16 -0
  50. logicfp-2.1.0/src/logicfp/service.py +14 -0
  51. logicfp-2.1.0/src/logicfp/user_mode.py +8 -0
  52. logicfp-2.1.0/src/logicfp.egg-info/SOURCES.txt +49 -0
@@ -0,0 +1,17 @@
1
+ prune analysis
2
+ prune demo
3
+ prune documents
4
+ prune examples
5
+ prune src/logicfp.egg-info
6
+ prune static
7
+ prune tests
8
+
9
+ exclude repomix-output.xml
10
+ exclude requirements.txt
11
+ exclude uv.lock
12
+
13
+ global-exclude __pycache__
14
+ global-exclude *.py[cod]
15
+ global-exclude *.sqlite
16
+ global-exclude *.egg-info
17
+ global-exclude .pytest_tmp*
logicfp-2.1.0/PKG-INFO ADDED
@@ -0,0 +1,66 @@
1
+ Metadata-Version: 2.4
2
+ Name: logicfp
3
+ Version: 2.1.0
4
+ Summary: logicfp protection runtime and HTTP service
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: fastapi==0.135.2
8
+ Requires-Dist: pydantic==2.12.5
9
+ Requires-Dist: uvicorn==0.42.0
10
+ Provides-Extra: release
11
+ Requires-Dist: build>=2.0.0; extra == "release"
12
+ Requires-Dist: twine>=6.1.0; extra == "release"
13
+
14
+ # Logic Fingerprint (logicfp)
15
+
16
+ `logicfp` is a Python protection library for wrapping function boundaries with circuit-breaker style control.
17
+
18
+ Developer documentation lives in [README.developer.md](D:/workspace/python/logic_fingerprint_ai/README.developer.md).
19
+
20
+ ## Install
21
+
22
+ ```bash
23
+ pip install logicfp
24
+ ```
25
+
26
+ ## Quick Start
27
+
28
+ ```python
29
+ from logicfp import protect
30
+
31
+
32
+ @protect(simple=False)
33
+ def call_model(request):
34
+ return {"answer": request.payload["text"].upper()}
35
+
36
+
37
+ result = call_model(payload={"text": "hello"})
38
+ ```
39
+
40
+ Use `@protect()` when you want the simplest user-mode entrypoint.
41
+ Use `create_protector()` when you need more than one protector instance.
42
+ Use `logicfp.user_mode` when you want explicit user-mode types like `ProtectRuntimeError`.
43
+
44
+ ## Minimal Config
45
+
46
+ Put your project config at:
47
+
48
+ ```text
49
+ your_project/config/config.yaml
50
+ ```
51
+
52
+ ```yaml
53
+ logicfp:
54
+ instance_id: decorator-node
55
+ default_source: user_function
56
+ backend_type: memory
57
+ ```
58
+
59
+ Use `logicfp:` as the main YAML section name. Older `logic_fingerprint:` configs are still accepted for compatibility.
60
+
61
+ ## Learn More
62
+
63
+ - Quick user-mode guide: [documents/Tutorial/用户模式快速接入.md](D:/workspace/python/logic_fingerprint_ai/documents/Tutorial/用户模式快速接入.md)
64
+ - User mode: [documents/Tutorial/用户模式示例.md](D:/workspace/python/logic_fingerprint_ai/documents/Tutorial/用户模式示例.md)
65
+ - Mode guide: [documents/Tutorial/protect 的用户模式与工程模式.md](D:/workspace/python/logic_fingerprint_ai/documents/Tutorial/protect%20的用户模式与工程模式.md)
66
+ - Optional engineering mode: [documents/Tutorial/工程模式示例.md](D:/workspace/python/logic_fingerprint_ai/documents/Tutorial/工程模式示例.md)
@@ -0,0 +1,53 @@
1
+ # Logic Fingerprint (logicfp)
2
+
3
+ `logicfp` is a Python protection library for wrapping function boundaries with circuit-breaker style control.
4
+
5
+ Developer documentation lives in [README.developer.md](D:/workspace/python/logic_fingerprint_ai/README.developer.md).
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install logicfp
11
+ ```
12
+
13
+ ## Quick Start
14
+
15
+ ```python
16
+ from logicfp import protect
17
+
18
+
19
+ @protect(simple=False)
20
+ def call_model(request):
21
+ return {"answer": request.payload["text"].upper()}
22
+
23
+
24
+ result = call_model(payload={"text": "hello"})
25
+ ```
26
+
27
+ Use `@protect()` when you want the simplest user-mode entrypoint.
28
+ Use `create_protector()` when you need more than one protector instance.
29
+ Use `logicfp.user_mode` when you want explicit user-mode types like `ProtectRuntimeError`.
30
+
31
+ ## Minimal Config
32
+
33
+ Put your project config at:
34
+
35
+ ```text
36
+ your_project/config/config.yaml
37
+ ```
38
+
39
+ ```yaml
40
+ logicfp:
41
+ instance_id: decorator-node
42
+ default_source: user_function
43
+ backend_type: memory
44
+ ```
45
+
46
+ Use `logicfp:` as the main YAML section name. Older `logic_fingerprint:` configs are still accepted for compatibility.
47
+
48
+ ## Learn More
49
+
50
+ - Quick user-mode guide: [documents/Tutorial/用户模式快速接入.md](D:/workspace/python/logic_fingerprint_ai/documents/Tutorial/用户模式快速接入.md)
51
+ - User mode: [documents/Tutorial/用户模式示例.md](D:/workspace/python/logic_fingerprint_ai/documents/Tutorial/用户模式示例.md)
52
+ - Mode guide: [documents/Tutorial/protect 的用户模式与工程模式.md](D:/workspace/python/logic_fingerprint_ai/documents/Tutorial/protect%20的用户模式与工程模式.md)
53
+ - Optional engineering mode: [documents/Tutorial/工程模式示例.md](D:/workspace/python/logic_fingerprint_ai/documents/Tutorial/工程模式示例.md)
@@ -0,0 +1,35 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "logicfp"
7
+ dynamic = ["version"]
8
+ description = "logicfp protection runtime and HTTP service"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ dependencies = [
12
+ "fastapi==0.135.2",
13
+ "pydantic==2.12.5",
14
+ "uvicorn==0.42.0",
15
+ ]
16
+
17
+ [project.optional-dependencies]
18
+ release = [
19
+ "build>=2.0.0",
20
+ "twine>=6.1.0",
21
+ ]
22
+
23
+ [project.scripts]
24
+ logicfp = "logicfp.cli:main"
25
+
26
+ [tool.setuptools]
27
+ package-dir = {"" = "src"}
28
+ include-package-data = false
29
+
30
+ [tool.setuptools.packages.find]
31
+ where = ["src"]
32
+ include = ["logicfp*"]
33
+
34
+ [tool.setuptools.dynamic]
35
+ version = {attr = "logicfp._version.__version__"}
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from ._version import __version__
6
+
7
+ __all__ = [
8
+ "protect",
9
+ "create_protector",
10
+ ]
11
+
12
+ _ENGINEERING_EXPORTS = {
13
+ "assemble_runtime",
14
+ "build_demo_runtime",
15
+ "build_production_runtime",
16
+ "build_runtime",
17
+ "create_http_app",
18
+ "create_app",
19
+ "create_demo_app",
20
+ }
21
+
22
+
23
+ def protect(*args: Any, **kwargs: Any):
24
+ from .decorator import protect as _protect
25
+
26
+ return _protect(*args, **kwargs)
27
+
28
+
29
+ def create_protector(*args: Any, **kwargs: Any):
30
+ from .decorator import create_protector as _create_protector
31
+
32
+ return _create_protector(*args, **kwargs)
33
+
34
+
35
+ def __getattr__(name: str):
36
+ if name in _ENGINEERING_EXPORTS:
37
+ raise AttributeError(
38
+ f"'logicfp.{name}' is not exported from the package root. "
39
+ "Import it from 'logicfp.engineering' instead."
40
+ )
41
+ raise AttributeError(f"module 'logicfp' has no attribute '{name}'")
@@ -0,0 +1,3 @@
1
+ from __future__ import annotations
2
+
3
+ __version__ = "2.1.0"
@@ -0,0 +1,118 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any, Literal
5
+
6
+ from fastapi import FastAPI
7
+ from fastapi.openapi.docs import (
8
+ get_swagger_ui_html,
9
+ get_swagger_ui_oauth2_redirect_html,
10
+ )
11
+ from fastapi.staticfiles import StaticFiles
12
+
13
+ from ._version import __version__
14
+ from .router import build_router
15
+ from .lifespan import build_lifespan
16
+ from .runtime import (
17
+ LogicFingerprintRuntime,
18
+ build_demo_runtime,
19
+ build_production_runtime,
20
+ )
21
+
22
+
23
+ DOCS_STATIC_DIR = Path(__file__).resolve().parents[2] / "static" / "dist"
24
+ HTTPAppMode = Literal["production", "demo"]
25
+
26
+
27
+ def _create_app_from_runtime(runtime: LogicFingerprintRuntime) -> FastAPI:
28
+ app = FastAPI(
29
+ title="logicfp",
30
+ version=__version__,
31
+ lifespan=build_lifespan(runtime, interval_seconds=1.0),
32
+ docs_url=None,
33
+ redoc_url=None,
34
+ )
35
+ app.state.runtime = runtime
36
+ app.state.runtime_config = runtime.config
37
+ app.state.runtime_settings = runtime.settings
38
+ if DOCS_STATIC_DIR.exists():
39
+ app.mount(
40
+ "/static-docs",
41
+ StaticFiles(directory=DOCS_STATIC_DIR),
42
+ name="static-docs",
43
+ )
44
+
45
+ @app.get("/docs", include_in_schema=False)
46
+ def swagger_ui_html():
47
+ return get_swagger_ui_html(
48
+ openapi_url=app.openapi_url,
49
+ title=f"{app.title} - Swagger UI",
50
+ oauth2_redirect_url=app.swagger_ui_oauth2_redirect_url,
51
+ swagger_js_url="/static-docs/swagger-ui-bundle.js",
52
+ swagger_css_url="/static-docs/swagger-ui.css",
53
+ swagger_favicon_url="/static-docs/favicon-32x32.png",
54
+ swagger_ui_parameters={
55
+ "presets": [
56
+ "SwaggerUIBundle.presets.apis",
57
+ "SwaggerUIStandalonePreset",
58
+ ],
59
+ },
60
+ )
61
+
62
+ @app.get(app.swagger_ui_oauth2_redirect_url, include_in_schema=False)
63
+ def swagger_ui_redirect():
64
+ return get_swagger_ui_oauth2_redirect_html()
65
+
66
+ app.include_router(build_router(runtime))
67
+ return app
68
+
69
+
70
+ def create_http_app(
71
+ *,
72
+ mode: HTTPAppMode = "production",
73
+ runtime: LogicFingerprintRuntime | None = None,
74
+ runtime_kwargs: dict[str, Any] | None = None,
75
+ ) -> FastAPI:
76
+ if runtime is not None and runtime_kwargs is not None:
77
+ raise ValueError("Pass either 'runtime' or 'runtime_kwargs', not both.")
78
+
79
+ if runtime is None:
80
+ runtime_builders = {
81
+ "production": build_production_runtime,
82
+ "demo": build_demo_runtime,
83
+ }
84
+ try:
85
+ runtime_builder = runtime_builders[mode]
86
+ except KeyError as exc:
87
+ raise ValueError(f"Unsupported HTTP app mode: {mode}") from exc
88
+ runtime = runtime_builder(**(runtime_kwargs or {}))
89
+
90
+ return _create_app_from_runtime(runtime)
91
+
92
+
93
+ def create_app(
94
+ *,
95
+ runtime: LogicFingerprintRuntime | None = None,
96
+ runtime_kwargs: dict[str, Any] | None = None,
97
+ ) -> FastAPI:
98
+ return create_http_app(
99
+ mode="production",
100
+ runtime=runtime,
101
+ runtime_kwargs=runtime_kwargs,
102
+ )
103
+
104
+
105
+ def create_demo_app(
106
+ *,
107
+ runtime: LogicFingerprintRuntime | None = None,
108
+ runtime_kwargs: dict[str, Any] | None = None,
109
+ ) -> FastAPI:
110
+ return create_http_app(
111
+ mode="demo",
112
+ runtime=runtime,
113
+ runtime_kwargs=runtime_kwargs,
114
+ )
115
+
116
+
117
+ app = create_http_app()
118
+
@@ -0,0 +1,3 @@
1
+ from .context_builder import ContextBuilder
2
+ from .metrics import InMemoryMetrics
3
+ from .validator import validate_input, validate_output
@@ -0,0 +1,22 @@
1
+ from datetime import datetime, timezone
2
+ from uuid import uuid4
3
+ from ..domain.models import HandlerRequest, RequestContext
4
+
5
+ class ContextBuilder:
6
+ def __init__(self, default_source: str = "api") -> None:
7
+ self.default_source = default_source
8
+ def build_context(self, context: RequestContext | None = None) -> RequestContext:
9
+ context = context or RequestContext()
10
+ return RequestContext(
11
+ request_id=context.request_id or f"req-{uuid4().hex}",
12
+ trace_id=context.trace_id or f"trace-{uuid4().hex}",
13
+ user_id=context.user_id,
14
+ source=context.source or self.default_source,
15
+ timestamp=context.timestamp or datetime.now(timezone.utc).isoformat(),
16
+ headers=dict(context.headers),
17
+ metadata=dict(context.metadata),
18
+ )
19
+ def build_request(self, request: HandlerRequest | None = None) -> HandlerRequest:
20
+ request = request or HandlerRequest()
21
+ return HandlerRequest(payload=dict(request.payload), context=self.build_context(request.context))
22
+
@@ -0,0 +1,15 @@
1
+ from dataclasses import dataclass, asdict
2
+
3
+ @dataclass(slots=True)
4
+ class InMemoryMetrics:
5
+ total_requests: int = 0
6
+ blocked_requests: int = 0
7
+ probe_requests: int = 0
8
+ success_requests: int = 0
9
+ failed_requests: int = 0
10
+ def record_total(self): self.total_requests += 1
11
+ def record_blocked(self): self.blocked_requests += 1
12
+ def record_probe(self): self.probe_requests += 1
13
+ def record_success(self): self.success_requests += 1
14
+ def record_failure(self): self.failed_requests += 1
15
+ def snapshot(self): return asdict(self)
@@ -0,0 +1,75 @@
1
+ from dataclasses import asdict, is_dataclass
2
+ from typing import Any
3
+
4
+ from pydantic import BaseModel, ValidationError
5
+
6
+ from ..domain.errors import OutputValidationErrorLF, ValidationErrorLF
7
+ from ..infra.logging.event_logger import LogEvent
8
+
9
+
10
+ def validate_input(
11
+ payload: dict[str, Any],
12
+ model: type[BaseModel] | None,
13
+ *,
14
+ event_logger=None,
15
+ handler: str | None = None,
16
+ request_id: str | None = None,
17
+ trace_id: str | None = None,
18
+ ) -> dict[str, Any]:
19
+ if model is None:
20
+ return payload
21
+
22
+ try:
23
+ parsed = model.model_validate(payload)
24
+ return parsed.model_dump()
25
+ except ValidationError as exc:
26
+ if event_logger is not None:
27
+ event_logger.emit(LogEvent(
28
+ event="input_validation_failed",
29
+ handler=handler,
30
+ request_id=request_id,
31
+ trace_id=trace_id,
32
+ error_code="ERR_VALIDATION",
33
+ message="Input validation failed.",
34
+ extra={"errors": exc.errors()},
35
+ ))
36
+ raise ValidationErrorLF(
37
+ "Input validation failed.",
38
+ details={"errors": exc.errors()},
39
+ ) from exc
40
+
41
+
42
+ def validate_output(
43
+ result: Any,
44
+ model: type[BaseModel] | None,
45
+ *,
46
+ event_logger=None,
47
+ handler: str | None = None,
48
+ request_id: str | None = None,
49
+ trace_id: str | None = None,
50
+ ) -> Any:
51
+ if model is None:
52
+ return result
53
+
54
+ raw = result
55
+ if is_dataclass(result):
56
+ raw = asdict(result)
57
+
58
+ try:
59
+ parsed = model.model_validate(raw)
60
+ return parsed.model_dump()
61
+ except ValidationError as exc:
62
+ if event_logger is not None:
63
+ event_logger.emit(LogEvent(
64
+ event="output_validation_failed",
65
+ handler=handler,
66
+ request_id=request_id,
67
+ trace_id=trace_id,
68
+ error_code="ERR_OUTPUT_VALIDATION",
69
+ message="Output validation failed.",
70
+ extra={"errors": exc.errors()},
71
+ ))
72
+ raise OutputValidationErrorLF(
73
+ "Output validation failed.",
74
+ details={"errors": exc.errors()},
75
+ ) from exc
@@ -0,0 +1,232 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import os
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Any, Sequence
8
+
9
+ import uvicorn
10
+
11
+ from .config import DEFAULT_CONFIG_FILE_NAME
12
+ from .config.yaml_support import load_simple_yaml_file, parse_simple_yaml
13
+ from .engineering import create_http_app
14
+
15
+
16
+ CLI_CONFIG_FILENAMES = (DEFAULT_CONFIG_FILE_NAME,)
17
+ CONFIG_SECTION_NAMES = ("logicfp", "logic_fingerprint", "logicfingerprint")
18
+
19
+ RUNTIME_KWARG_KEYS = (
20
+ "instance_id",
21
+ "default_source",
22
+ "backend_type",
23
+ "handler_registrars",
24
+ "redis_url",
25
+ "redis_decode_responses",
26
+ "redis_key",
27
+ "redis_key_prefix",
28
+ "redis_ttl_seconds",
29
+ "probe_rate",
30
+ "probe_interval_seconds",
31
+ "consecutive_success_threshold",
32
+ "total_nodes",
33
+ "global_fail_threshold",
34
+ )
35
+
36
+
37
+ @dataclass(slots=True)
38
+ class StartConfig:
39
+ host: str = "0.0.0.0"
40
+ port: int = 8000
41
+ demo: bool = False
42
+ config_path: Path | None = None
43
+ runtime_kwargs: dict[str, Any] = field(default_factory=dict)
44
+
45
+ def _iter_project_config_candidates(start_dir: Path) -> list[Path]:
46
+ candidates: list[Path] = []
47
+ for directory in (start_dir, *start_dir.parents):
48
+ for filename in CLI_CONFIG_FILENAMES:
49
+ candidates.append(directory / "config" / filename)
50
+ for filename in CLI_CONFIG_FILENAMES:
51
+ candidates.append(directory / filename)
52
+ return candidates
53
+
54
+
55
+ def _iter_system_config_dirs() -> list[Path]:
56
+ system_dirs = [
57
+ Path("/etc/logicfp"),
58
+ Path("/etc/logic_fingerprint"),
59
+ Path("/etc/logicfingerprint"),
60
+ ]
61
+
62
+ for env_name in ("PROGRAMDATA", "APPDATA", "LOCALAPPDATA"):
63
+ value = os.getenv(env_name)
64
+ if not value:
65
+ continue
66
+ base_dir = Path(value)
67
+ system_dirs.append(base_dir / "logicfp")
68
+ system_dirs.append(base_dir / "logic_fingerprint")
69
+ system_dirs.append(base_dir / "logicfingerprint")
70
+
71
+ return system_dirs
72
+
73
+
74
+ def discover_cli_config_path(
75
+ explicit_path: str | Path | None = None,
76
+ *,
77
+ start_dir: str | Path | None = None,
78
+ system_dirs: Sequence[Path] | None = None,
79
+ ) -> Path | None:
80
+ if explicit_path is not None:
81
+ resolved = Path(explicit_path).expanduser()
82
+ if not resolved.is_absolute():
83
+ resolved = (Path.cwd() / resolved).resolve()
84
+ if not resolved.is_file():
85
+ raise FileNotFoundError(f"CLI config file not found: {resolved}")
86
+ return resolved
87
+
88
+ current_dir = Path(start_dir or Path.cwd()).resolve()
89
+ for candidate in _iter_project_config_candidates(current_dir):
90
+ if candidate.is_file():
91
+ return candidate.resolve()
92
+
93
+ for system_dir in system_dirs or _iter_system_config_dirs():
94
+ if not system_dir.is_dir():
95
+ continue
96
+ for filename in CLI_CONFIG_FILENAMES:
97
+ candidate = system_dir / filename
98
+ if candidate.is_file():
99
+ return candidate.resolve()
100
+
101
+ return None
102
+
103
+
104
+ def load_cli_config(path: Path | None) -> dict[str, Any]:
105
+ return load_simple_yaml_file(path)
106
+
107
+
108
+ def _read_mapping(data: dict[str, Any], key: str) -> dict[str, Any]:
109
+ value = data.get(key, {})
110
+ if not isinstance(value, dict):
111
+ raise ValueError(f"Expected '{key}' to be a mapping in CLI config.")
112
+ return value
113
+
114
+
115
+ def _normalize_runtime_kwargs(data: dict[str, Any]) -> dict[str, Any]:
116
+ runtime_kwargs: dict[str, Any] = {}
117
+ logicfp_config: dict[str, Any] = {}
118
+
119
+ for section_name in CONFIG_SECTION_NAMES:
120
+ value = data.get(section_name)
121
+ if value is None:
122
+ continue
123
+ if not isinstance(value, dict):
124
+ raise ValueError(f"Expected '{section_name}' to be a mapping in CLI config.")
125
+ logicfp_config = value
126
+ break
127
+
128
+ for key in RUNTIME_KWARG_KEYS:
129
+ value = logicfp_config.get(key)
130
+ if value is None and key in data:
131
+ value = data[key]
132
+ if value is None:
133
+ continue
134
+ if key == "handler_registrars":
135
+ if isinstance(value, list):
136
+ value = tuple(str(item) for item in value)
137
+ elif isinstance(value, str):
138
+ value = tuple(item.strip() for item in value.split(",") if item.strip())
139
+ runtime_kwargs[key] = value
140
+
141
+ return runtime_kwargs
142
+
143
+
144
+ def load_start_config(
145
+ *,
146
+ config_path: Path | None,
147
+ port_override: int | None = None,
148
+ host_override: str | None = None,
149
+ demo_override: bool = False,
150
+ ) -> StartConfig:
151
+ data = load_cli_config(config_path)
152
+ server = _read_mapping(data, "server")
153
+ app = _read_mapping(data, "app")
154
+
155
+ host = host_override or str(server.get("host", "0.0.0.0"))
156
+ port = port_override if port_override is not None else int(server.get("port", 8000))
157
+ demo = demo_override or bool(app.get("demo", data.get("demo", False)))
158
+
159
+ return StartConfig(
160
+ host=host,
161
+ port=port,
162
+ demo=demo,
163
+ config_path=config_path,
164
+ runtime_kwargs={
165
+ **({"config_file": str(config_path)} if config_path is not None else {}),
166
+ **_normalize_runtime_kwargs(data),
167
+ },
168
+ )
169
+
170
+
171
+ def _build_parser() -> argparse.ArgumentParser:
172
+ parser = argparse.ArgumentParser(prog="logicfp")
173
+ subparsers = parser.add_subparsers(dest="command", required=True)
174
+
175
+ start_parser = subparsers.add_parser("start", help="Start the HTTP service.")
176
+ start_parser.add_argument("--host", help="Bind host. Defaults to config or 0.0.0.0.")
177
+ start_parser.add_argument(
178
+ "--port",
179
+ type=int,
180
+ help="Bind port. Defaults to config or 8000.",
181
+ )
182
+ start_parser.add_argument(
183
+ "--config",
184
+ help="Path to CLI YAML config. Explicit path has the highest priority.",
185
+ )
186
+ start_parser.add_argument(
187
+ "--demo",
188
+ action="store_true",
189
+ help="Start in demo mode.",
190
+ )
191
+ return parser
192
+
193
+
194
+ def start_command(args: argparse.Namespace) -> int:
195
+ config_path = discover_cli_config_path(args.config)
196
+ start_config = load_start_config(
197
+ config_path=config_path,
198
+ port_override=args.port,
199
+ host_override=args.host,
200
+ demo_override=args.demo,
201
+ )
202
+
203
+ if start_config.config_path is not None:
204
+ print(f"Using CLI config: {start_config.config_path}")
205
+
206
+ mode = "demo" if start_config.demo else "production"
207
+ print(
208
+ f"Starting logicfp HTTP service on "
209
+ f"{start_config.host}:{start_config.port} ({mode})"
210
+ )
211
+
212
+ app = create_http_app(
213
+ mode="demo" if start_config.demo else "production",
214
+ runtime_kwargs=start_config.runtime_kwargs,
215
+ )
216
+ uvicorn.run(app, host=start_config.host, port=start_config.port)
217
+ return 0
218
+
219
+
220
+ def main(argv: Sequence[str] | None = None) -> int:
221
+ parser = _build_parser()
222
+ args = parser.parse_args(argv)
223
+
224
+ if args.command == "start":
225
+ return start_command(args)
226
+
227
+ parser.error(f"Unsupported command: {args.command}")
228
+ return 2
229
+
230
+
231
+ if __name__ == "__main__":
232
+ raise SystemExit(main())