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.
- logicfp-2.1.0/MANIFEST.in +17 -0
- logicfp-2.1.0/PKG-INFO +66 -0
- logicfp-2.1.0/README.md +53 -0
- logicfp-2.1.0/pyproject.toml +35 -0
- logicfp-2.1.0/setup.cfg +4 -0
- logicfp-2.1.0/src/logicfp/__init__.py +41 -0
- logicfp-2.1.0/src/logicfp/_version.py +3 -0
- logicfp-2.1.0/src/logicfp/app_factory.py +118 -0
- logicfp-2.1.0/src/logicfp/application/__init__.py +3 -0
- logicfp-2.1.0/src/logicfp/application/context_builder.py +22 -0
- logicfp-2.1.0/src/logicfp/application/metrics.py +15 -0
- logicfp-2.1.0/src/logicfp/application/validator.py +75 -0
- logicfp-2.1.0/src/logicfp/cli.py +232 -0
- logicfp-2.1.0/src/logicfp/config/__init__.py +35 -0
- logicfp-2.1.0/src/logicfp/config/loader.py +427 -0
- logicfp-2.1.0/src/logicfp/config/policy_config.py +11 -0
- logicfp-2.1.0/src/logicfp/config/runtime_config.py +10 -0
- logicfp-2.1.0/src/logicfp/config/runtime_settings.py +14 -0
- logicfp-2.1.0/src/logicfp/config/strategy_config.py +9 -0
- logicfp-2.1.0/src/logicfp/config/yaml_support.py +100 -0
- logicfp-2.1.0/src/logicfp/decorator.py +8 -0
- logicfp-2.1.0/src/logicfp/decorator_impl.py +401 -0
- logicfp-2.1.0/src/logicfp/domain/__init__.py +4 -0
- logicfp-2.1.0/src/logicfp/domain/errors.py +49 -0
- logicfp-2.1.0/src/logicfp/domain/executor.py +52 -0
- logicfp-2.1.0/src/logicfp/domain/fsm.py +77 -0
- logicfp-2.1.0/src/logicfp/domain/models.py +57 -0
- logicfp-2.1.0/src/logicfp/engineering.py +55 -0
- logicfp-2.1.0/src/logicfp/handler_registry.py +27 -0
- logicfp-2.1.0/src/logicfp/handlers/__init__.py +22 -0
- logicfp-2.1.0/src/logicfp/handlers/demo_handlers.py +72 -0
- logicfp-2.1.0/src/logicfp/handlers/registry.py +54 -0
- logicfp-2.1.0/src/logicfp/handlers/schemas.py +9 -0
- logicfp-2.1.0/src/logicfp/infra/__init__.py +1 -0
- logicfp-2.1.0/src/logicfp/infra/consensus/__init__.py +16 -0
- logicfp-2.1.0/src/logicfp/infra/consensus/client.py +24 -0
- logicfp-2.1.0/src/logicfp/infra/consensus/factory.py +26 -0
- logicfp-2.1.0/src/logicfp/infra/consensus/heartbeat.py +10 -0
- logicfp-2.1.0/src/logicfp/infra/consensus/memory.py +11 -0
- logicfp-2.1.0/src/logicfp/infra/consensus/redis.py +48 -0
- logicfp-2.1.0/src/logicfp/infra/logging/__init__.py +10 -0
- logicfp-2.1.0/src/logicfp/infra/logging/event_logger.py +40 -0
- logicfp-2.1.0/src/logicfp/infra/metrics/__init__.py +5 -0
- logicfp-2.1.0/src/logicfp/infra/metrics/prometheus.py +28 -0
- logicfp-2.1.0/src/logicfp/lifespan.py +21 -0
- logicfp-2.1.0/src/logicfp/middleware.py +66 -0
- logicfp-2.1.0/src/logicfp/router.py +60 -0
- logicfp-2.1.0/src/logicfp/runtime.py +186 -0
- logicfp-2.1.0/src/logicfp/schemas.py +16 -0
- logicfp-2.1.0/src/logicfp/service.py +14 -0
- logicfp-2.1.0/src/logicfp/user_mode.py +8 -0
- 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)
|
logicfp-2.1.0/README.md
ADDED
|
@@ -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__"}
|
logicfp-2.1.0/setup.cfg
ADDED
|
@@ -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,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,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())
|