fred-core 2.0.2__tar.gz → 2.0.4__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.
- {fred_core-2.0.2 → fred_core-2.0.4}/PKG-INFO +17 -11
- {fred_core-2.0.2 → fred_core-2.0.4}/README.md +16 -10
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/common/__init__.py +0 -2
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/common/fastapi_handlers.py +5 -2
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/common/structures.py +1 -45
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/common/utils.py +1 -1
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/filesystem/minio_filesystem.py +1 -1
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/kpi/kpi_writer.py +4 -1
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/kpi/opensearch_kpi_store.py +8 -6
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/logs/opensearch_log_store.py +9 -6
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/portable/observability.py +59 -3
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/store/opensearch_mapping_validator.py +7 -4
- fred_core-2.0.4/fred_core/tests/common/test_fastapi_handlers.py +67 -0
- fred_core-2.0.4/fred_core/tests/filesystem/test_local_filesystem.py +71 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/tests/kpi/test_noop_kpi_writer.py +2 -2
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/tests/logs/test_memory_log_store.py +5 -4
- fred_core-2.0.4/fred_core/tests/model/test_http_clients.py +252 -0
- fred_core-2.0.4/fred_core/tests/portable/test_observability.py +144 -0
- fred_core-2.0.4/fred_core/tests/security/test_authorization_decorator.py +122 -0
- fred_core-2.0.4/fred_core/tests/store/test_local_content_store.py +58 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/tests/test_log_kpi_store.py +7 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/tests/test_prometheus_kpi_store.py +8 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core.egg-info/PKG-INFO +17 -11
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core.egg-info/SOURCES.txt +6 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/pyproject.toml +1 -1
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/__init__.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/cli/__init__.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/cli/auth.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/cli/ui.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/common/config_files.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/common/config_loader.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/common/env.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/common/lru_cache.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/common/team_id.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/filesystem/local_filesystem.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/filesystem/structures.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/history/__init__.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/history/base_history_store.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/history/history_models.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/history/history_schema.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/history/postgres_history_store.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/kpi/__init__.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/kpi/base_kpi_store.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/kpi/base_kpi_writer.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/kpi/kpi_phase_metric.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/kpi/kpi_process.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/kpi/kpi_reader_structures.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/kpi/kpi_writer_structures.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/kpi/log_kpi_store.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/kpi/noop_kpi_writer.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/kpi/prometheus_kpi_store.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/logs/__init__.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/logs/base_log_store.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/logs/log_setup.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/logs/log_structures.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/logs/memory_log_store.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/logs/null_log_store.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/model/factory.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/model/http_clients.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/model/models.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/models/__init__.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/models/base.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/portable/__init__.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/py.typed +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/scheduler/__init__.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/scheduler/backend.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/scheduler/scheduler_structures.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/scheduler/temporal_client_provider.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/security/__init__.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/security/authorization.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/security/authorization_decorator.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/security/backend_to_backend_auth.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/security/keycloak/__init__.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/security/keycloak/keycloack_admin_client.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/security/models.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/security/oidc.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/security/outbound.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/security/rbac.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/security/rebac/noop_engine.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/security/rebac/openfga_engine.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/security/rebac/openfga_schema.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/security/rebac/rebac_engine.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/security/rebac/rebac_factory.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/security/rebac/schema.fga +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/security/rebac/schema.fga.json +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/security/structure.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/security/whitelist_access_control/access_control.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/session/__init__.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/session/session_schema.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/session/stores/__init__.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/session/stores/base_session_store.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/session/stores/postgres_session_store.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/session/stores/session_models.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/sql/__init__.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/sql/alembic_env.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/sql/async_session.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/sql/base_sql.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/sql/mixin.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/store/__init__.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/store/base_content_store.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/store/local_content_store.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/store/minio_content_store.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/store/vector_search.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/tests/__init__.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/tests/cli/test_auth.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/tests/cli/test_ui.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/tests/common/test_config_loader.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/tests/common/test_env.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/tests/common/test_log_setup.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/tests/common/test_lru_cache.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/tests/integration/__init__.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/tests/integration/test_rebac.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/tests/model/test_embedding_factory.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/tests/model/test_vertex_model_garden_auth.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/tests/scheduler/test_backend.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/tests/security/__init__.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/tests/security/test_authorization.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/tests/security/test_rebac_engine_team_helpers.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/tests/security/test_whitelist_access_control.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/tests/session/test_postgres_json_session_store_sqlite.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/users/__init__.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/users/store/__init__.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/users/store/base_user_store.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/users/store/postgres_user_store.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/users/user_models.py +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core.egg-info/dependency_links.txt +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core.egg-info/requires.txt +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/fred_core.egg-info/top_level.txt +0 -0
- {fred_core-2.0.2 → fred_core-2.0.4}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fred-core
|
|
3
|
-
Version: 2.0.
|
|
3
|
+
Version: 2.0.4
|
|
4
4
|
Summary: Core shared utilities for Fred backends (config, storage, security, and runtime helpers).
|
|
5
5
|
Author-email: Thales <noreply@thalesgroup.com>
|
|
6
6
|
License: Apache-2.0
|
|
@@ -55,26 +55,26 @@ Requires-Dist: ruff>=0.12.5; extra == "dev"
|
|
|
55
55
|
`fred-core` is the shared utility layer for Fred backends. It centralizes the
|
|
56
56
|
foundational building blocks that must stay consistent across services.
|
|
57
57
|
|
|
58
|
-
What it provides
|
|
59
|
-
|
|
58
|
+
## What it provides
|
|
59
|
+
|
|
60
60
|
- Configuration helpers used by multiple backends.
|
|
61
61
|
- Storage and session primitives.
|
|
62
62
|
- Security and access-control utilities (ReBAC helpers, Keycloak helpers).
|
|
63
63
|
- Common runtime helpers (logging, KPI, scheduling utilities).
|
|
64
64
|
|
|
65
|
-
What it is not
|
|
66
|
-
|
|
65
|
+
## What it is not
|
|
66
|
+
|
|
67
67
|
- A full runtime or service on its own.
|
|
68
68
|
- A public SDK for agent authoring (that is `fred-sdk`).
|
|
69
69
|
|
|
70
|
-
Install
|
|
71
|
-
|
|
70
|
+
## Install
|
|
71
|
+
|
|
72
72
|
```bash
|
|
73
73
|
pip install fred-core
|
|
74
74
|
```
|
|
75
75
|
|
|
76
|
-
Usage (example)
|
|
77
|
-
|
|
76
|
+
## Usage (example)
|
|
77
|
+
|
|
78
78
|
Fred backends import shared helpers from `fred_core` to keep configuration and
|
|
79
79
|
behavior aligned:
|
|
80
80
|
|
|
@@ -89,9 +89,15 @@ env_path = config_files.load_environment()
|
|
|
89
89
|
yaml_path = config_files.resolve_config_file_path()
|
|
90
90
|
```
|
|
91
91
|
|
|
92
|
-
Notes
|
|
93
|
-
|
|
92
|
+
## Notes
|
|
93
|
+
|
|
94
94
|
`fred-core` is designed for internal Fred services and adapters. If you are
|
|
95
95
|
building agents or workflows, you likely want `fred-sdk` instead. In most
|
|
96
96
|
cases, end users should not install `fred-core` directly because it is pulled
|
|
97
97
|
in transitively by `fred-sdk`.
|
|
98
|
+
|
|
99
|
+
## Development validation
|
|
100
|
+
|
|
101
|
+
- `make test` runs the default offline test suite.
|
|
102
|
+
- `make coverage-offline` runs the canonical offline coverage command with
|
|
103
|
+
terminal missing-line output.
|
|
@@ -3,26 +3,26 @@
|
|
|
3
3
|
`fred-core` is the shared utility layer for Fred backends. It centralizes the
|
|
4
4
|
foundational building blocks that must stay consistent across services.
|
|
5
5
|
|
|
6
|
-
What it provides
|
|
7
|
-
|
|
6
|
+
## What it provides
|
|
7
|
+
|
|
8
8
|
- Configuration helpers used by multiple backends.
|
|
9
9
|
- Storage and session primitives.
|
|
10
10
|
- Security and access-control utilities (ReBAC helpers, Keycloak helpers).
|
|
11
11
|
- Common runtime helpers (logging, KPI, scheduling utilities).
|
|
12
12
|
|
|
13
|
-
What it is not
|
|
14
|
-
|
|
13
|
+
## What it is not
|
|
14
|
+
|
|
15
15
|
- A full runtime or service on its own.
|
|
16
16
|
- A public SDK for agent authoring (that is `fred-sdk`).
|
|
17
17
|
|
|
18
|
-
Install
|
|
19
|
-
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
20
|
```bash
|
|
21
21
|
pip install fred-core
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
-
Usage (example)
|
|
25
|
-
|
|
24
|
+
## Usage (example)
|
|
25
|
+
|
|
26
26
|
Fred backends import shared helpers from `fred_core` to keep configuration and
|
|
27
27
|
behavior aligned:
|
|
28
28
|
|
|
@@ -37,9 +37,15 @@ env_path = config_files.load_environment()
|
|
|
37
37
|
yaml_path = config_files.resolve_config_file_path()
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
-
Notes
|
|
41
|
-
|
|
40
|
+
## Notes
|
|
41
|
+
|
|
42
42
|
`fred-core` is designed for internal Fred services and adapters. If you are
|
|
43
43
|
building agents or workflows, you likely want `fred-sdk` instead. In most
|
|
44
44
|
cases, end users should not install `fred-core` directly because it is pulled
|
|
45
45
|
in transitively by `fred-sdk`.
|
|
46
|
+
|
|
47
|
+
## Development validation
|
|
48
|
+
|
|
49
|
+
- `make test` runs the default offline test suite.
|
|
50
|
+
- `make coverage-offline` runs the canonical offline coverage command with
|
|
51
|
+
terminal missing-line output.
|
|
@@ -31,7 +31,6 @@ from .structures import (
|
|
|
31
31
|
OwnerFilter,
|
|
32
32
|
PostgresStoreConfig,
|
|
33
33
|
PostgresTableConfig,
|
|
34
|
-
SQLStorageConfig,
|
|
35
34
|
StoreConfig,
|
|
36
35
|
TemporalSchedulerConfig,
|
|
37
36
|
)
|
|
@@ -49,7 +48,6 @@ __all__ = [
|
|
|
49
48
|
"OwnerFilter",
|
|
50
49
|
"PostgresStoreConfig",
|
|
51
50
|
"PostgresTableConfig",
|
|
52
|
-
"SQLStorageConfig",
|
|
53
51
|
"StoreConfig",
|
|
54
52
|
"TeamId",
|
|
55
53
|
"PERSONAL_TEAM_ID",
|
|
@@ -54,7 +54,7 @@ def register_exception_handlers(app: FastAPI) -> None:
|
|
|
54
54
|
request: Request, exc: AuthorizationError
|
|
55
55
|
) -> JSONResponse:
|
|
56
56
|
"""Handle AuthorizationError by returning a 403 Forbidden response."""
|
|
57
|
-
logger.warning(
|
|
57
|
+
logger.warning("Authorization denied for user %s: %s", exc.user_id, exc)
|
|
58
58
|
return JSONResponse(
|
|
59
59
|
status_code=403,
|
|
60
60
|
content={"detail": _authorization_detail_for_client(exc)},
|
|
@@ -66,7 +66,10 @@ def register_exception_handlers(app: FastAPI) -> None:
|
|
|
66
66
|
) -> JSONResponse:
|
|
67
67
|
"""Handle all unhandled exceptions by logging and returning 500."""
|
|
68
68
|
logger.error(
|
|
69
|
-
|
|
69
|
+
"Unhandled exception in %s %s: %s",
|
|
70
|
+
request.method,
|
|
71
|
+
request.url,
|
|
72
|
+
exc,
|
|
70
73
|
exc_info=True,
|
|
71
74
|
)
|
|
72
75
|
return JSONResponse(
|
|
@@ -14,10 +14,9 @@
|
|
|
14
14
|
|
|
15
15
|
import os
|
|
16
16
|
from enum import Enum
|
|
17
|
-
from pathlib import Path
|
|
18
17
|
from typing import Annotated, Any, Dict, Literal, Optional, Union
|
|
19
18
|
|
|
20
|
-
from pydantic import BaseModel, Field
|
|
19
|
+
from pydantic import BaseModel, Field
|
|
21
20
|
|
|
22
21
|
|
|
23
22
|
class OwnerFilter(str, Enum):
|
|
@@ -163,53 +162,10 @@ class InMemoryStoreConfig(BaseModel):
|
|
|
163
162
|
type: Literal["memory"] = "memory"
|
|
164
163
|
|
|
165
164
|
|
|
166
|
-
class SQLStorageConfig(BaseModel):
|
|
167
|
-
type: Literal["sql"] = "sql"
|
|
168
|
-
driver: str
|
|
169
|
-
mode: Literal["read_and_write", "read_only"]
|
|
170
|
-
database: Optional[str] = None
|
|
171
|
-
host: Optional[str] = None
|
|
172
|
-
port: Optional[int] = None
|
|
173
|
-
username: Optional[str] = Field(
|
|
174
|
-
default_factory=lambda: os.getenv("TABULAR_POSTGRES_USERNAME")
|
|
175
|
-
)
|
|
176
|
-
# Prefer TABULAR_POSTGRES_PASSWORD; fall back to legacy SQL_PASSWORD for backward compatibility.
|
|
177
|
-
password: Optional[str] = Field(
|
|
178
|
-
default_factory=lambda: os.getenv("TABULAR_POSTGRES_PASSWORD")
|
|
179
|
-
)
|
|
180
|
-
path: Optional[str] = None
|
|
181
|
-
|
|
182
|
-
@model_validator(mode="after")
|
|
183
|
-
def build_path(self) -> "SQLStorageConfig":
|
|
184
|
-
if not self.driver:
|
|
185
|
-
raise ValueError("Driver is required.")
|
|
186
|
-
|
|
187
|
-
if self.path:
|
|
188
|
-
# Facultatif : expanduser si tu veux supporter les "~"
|
|
189
|
-
self.path = str(Path(self.path).expanduser())
|
|
190
|
-
else:
|
|
191
|
-
if not self.database:
|
|
192
|
-
raise ValueError("Database name is required to build the path.")
|
|
193
|
-
|
|
194
|
-
auth = ""
|
|
195
|
-
if self.username:
|
|
196
|
-
auth = self.username
|
|
197
|
-
if self.password:
|
|
198
|
-
auth += f":{self.password}"
|
|
199
|
-
auth += "@"
|
|
200
|
-
|
|
201
|
-
host = self.host or "localhost"
|
|
202
|
-
port = f":{self.port}" if self.port else ""
|
|
203
|
-
self.path = f"{auth}{host}{port}/{self.database}"
|
|
204
|
-
|
|
205
|
-
return self
|
|
206
|
-
|
|
207
|
-
|
|
208
165
|
StoreConfig = Annotated[
|
|
209
166
|
Union[
|
|
210
167
|
DuckdbStoreConfig,
|
|
211
168
|
OpenSearchIndexConfig,
|
|
212
|
-
SQLStorageConfig,
|
|
213
169
|
LogStoreConfig,
|
|
214
170
|
PostgresTableConfig,
|
|
215
171
|
InMemoryStoreConfig,
|
|
@@ -54,7 +54,7 @@ def raise_internal_error(logger: logging.Logger, msg: str, exc: Exception):
|
|
|
54
54
|
"""
|
|
55
55
|
|
|
56
56
|
error_id = str(uuid.uuid4())[:8]
|
|
57
|
-
logger.exception(
|
|
57
|
+
logger.exception("[%s] %s", error_id, msg)
|
|
58
58
|
raise HTTPException(
|
|
59
59
|
status_code=500, detail=f"{msg}. Contact support with error ID: {error_id}."
|
|
60
60
|
)
|
|
@@ -79,7 +79,7 @@ class MinioFilesystem(BaseFilesystem):
|
|
|
79
79
|
|
|
80
80
|
if not self.client.bucket_exists(bucket_name):
|
|
81
81
|
self.client.make_bucket(bucket_name)
|
|
82
|
-
logger.info(
|
|
82
|
+
logger.info("[MINIO_SETUP] bucket=%s created", bucket_name)
|
|
83
83
|
|
|
84
84
|
def _resolve_path(self, path: str) -> str:
|
|
85
85
|
"""
|
|
@@ -161,7 +161,10 @@ class KPIWriter(BaseKPIWriter):
|
|
|
161
161
|
try:
|
|
162
162
|
self.store.ensure_ready()
|
|
163
163
|
except Exception as e:
|
|
164
|
-
logger.warning(
|
|
164
|
+
logger.warning(
|
|
165
|
+
"[KPI] ensure_ready failed (continuing best-effort): %s",
|
|
166
|
+
e,
|
|
167
|
+
)
|
|
165
168
|
self._start_summary_thread_if_enabled()
|
|
166
169
|
|
|
167
170
|
# ---- generic emit --------------------------------------------------------
|
|
@@ -155,14 +155,14 @@ class OpenSearchKPIStore(BaseKPIStore):
|
|
|
155
155
|
try:
|
|
156
156
|
if not self.client.indices.exists(index=self.index):
|
|
157
157
|
self.client.indices.create(index=self.index, body=KPI_INDEX_MAPPING)
|
|
158
|
-
logger.info(
|
|
158
|
+
logger.info("[OPENSEARCH][KPI] created index '%s'", self.index)
|
|
159
159
|
else:
|
|
160
|
-
logger.info(
|
|
160
|
+
logger.info("[OPENSEARCH][KPI] index '%s' already exists.", self.index)
|
|
161
161
|
self._ensure_dim_mapping("service", {"type": "keyword"})
|
|
162
162
|
# Validate existing mapping matches expected mapping
|
|
163
163
|
validate_index_mapping(self.client, self.index, KPI_INDEX_MAPPING)
|
|
164
164
|
except OpenSearchException as e:
|
|
165
|
-
logger.error(
|
|
165
|
+
logger.error("[OPENSEARCH][KPI] ensure_ready failed: %s", e)
|
|
166
166
|
raise
|
|
167
167
|
|
|
168
168
|
def _ensure_dim_mapping(self, name: str, mapping: Dict[str, Any]) -> None:
|
|
@@ -191,7 +191,7 @@ class OpenSearchKPIStore(BaseKPIStore):
|
|
|
191
191
|
try:
|
|
192
192
|
self.client.index(index=self.index, body=event.to_doc())
|
|
193
193
|
except OpenSearchException as e:
|
|
194
|
-
logger.error(
|
|
194
|
+
logger.error("[OPENSEARCH][KPI] index_event failed: %s", e)
|
|
195
195
|
raise
|
|
196
196
|
|
|
197
197
|
def bulk_index(self, events: List[KPIEvent]) -> None:
|
|
@@ -204,9 +204,11 @@ class OpenSearchKPIStore(BaseKPIStore):
|
|
|
204
204
|
try:
|
|
205
205
|
resp = self.client.bulk(body=actions)
|
|
206
206
|
if resp.get("errors"):
|
|
207
|
-
logger.warning(
|
|
207
|
+
logger.warning(
|
|
208
|
+
"[OPENSEARCH][KPI] bulk_index completed with partial errors."
|
|
209
|
+
)
|
|
208
210
|
except OpenSearchException as e:
|
|
209
|
-
logger.error(
|
|
211
|
+
logger.error("[OPENSEARCH][KPI] bulk_index failed: %s", e)
|
|
210
212
|
raise
|
|
211
213
|
|
|
212
214
|
# -- reads -----------------------------------------------------------------
|
|
@@ -157,13 +157,13 @@ class OpenSearchLogStore(BaseLogStore):
|
|
|
157
157
|
try:
|
|
158
158
|
if not self.client.indices.exists(index=self.index):
|
|
159
159
|
self.client.indices.create(index=self.index, body=LOG_INDEX_MAPPING)
|
|
160
|
-
logger.info(
|
|
160
|
+
logger.info("[OPENSEARCH][LOG] created index '%s'.", self.index)
|
|
161
161
|
else:
|
|
162
|
-
logger.info(
|
|
162
|
+
logger.info("[OPENSEARCH][LOG] index '%s' already exists.", self.index)
|
|
163
163
|
# If you have a generic validator like KPI does, call it here:
|
|
164
164
|
# validate_index_mapping(self.client, self.index, LOG_INDEX_MAPPING)
|
|
165
165
|
except OpenSearchException as e:
|
|
166
|
-
logger.error(
|
|
166
|
+
logger.error("[OPENSEARCH][LOG] ensure_ready failed: %s", e)
|
|
167
167
|
raise
|
|
168
168
|
|
|
169
169
|
# -- writes ----------------------------------------------------------------
|
|
@@ -171,7 +171,8 @@ class OpenSearchLogStore(BaseLogStore):
|
|
|
171
171
|
try:
|
|
172
172
|
self.client.index(index=self.index, body=_doc_from_event(event))
|
|
173
173
|
except OpenSearchException as e:
|
|
174
|
-
|
|
174
|
+
logger.error("[OPENSEARCH][LOG] index_event failed: %s", e)
|
|
175
|
+
raise
|
|
175
176
|
|
|
176
177
|
def bulk_index(self, events: List[LogEventDTO]) -> None:
|
|
177
178
|
if not events:
|
|
@@ -183,9 +184,11 @@ class OpenSearchLogStore(BaseLogStore):
|
|
|
183
184
|
try:
|
|
184
185
|
resp = self.client.bulk(body=actions)
|
|
185
186
|
if resp.get("errors"):
|
|
186
|
-
|
|
187
|
+
logger.warning(
|
|
188
|
+
"[OPENSEARCH][LOG] bulk_index completed with partial errors."
|
|
189
|
+
)
|
|
187
190
|
except OpenSearchException as e:
|
|
188
|
-
|
|
191
|
+
logger.error("[OPENSEARCH][LOG] bulk_index failed: %s", e)
|
|
189
192
|
raise
|
|
190
193
|
|
|
191
194
|
# -- reads -----------------------------------------------------------------
|
|
@@ -39,6 +39,7 @@ from __future__ import annotations
|
|
|
39
39
|
|
|
40
40
|
import logging
|
|
41
41
|
import time
|
|
42
|
+
from collections.abc import Mapping
|
|
42
43
|
from contextlib import contextmanager
|
|
43
44
|
from dataclasses import dataclass, field
|
|
44
45
|
from typing import Any, Generator
|
|
@@ -57,6 +58,24 @@ class Span:
|
|
|
57
58
|
manager here because span lifetime often crosses await boundaries).
|
|
58
59
|
"""
|
|
59
60
|
|
|
61
|
+
@property
|
|
62
|
+
def span_id(self) -> str | None:
|
|
63
|
+
"""
|
|
64
|
+
Return the backend span identifier when the implementation exposes one.
|
|
65
|
+
|
|
66
|
+
Why this exists:
|
|
67
|
+
- some tracing backends can create parent/child relationships only when
|
|
68
|
+
the caller can read a stable span id from the current span
|
|
69
|
+
|
|
70
|
+
How to use it:
|
|
71
|
+
- treat `None` as "this backend does not expose parent-linking ids"
|
|
72
|
+
|
|
73
|
+
Example:
|
|
74
|
+
- `if span.span_id is not None: trace_context["parent_span_id"] = span.span_id`
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
return None
|
|
78
|
+
|
|
60
79
|
def set_attribute(self, key: str, value: Any) -> None:
|
|
61
80
|
"""Record one key/value attribute on this span."""
|
|
62
81
|
|
|
@@ -80,7 +99,11 @@ class Tracer:
|
|
|
80
99
|
def start_span(
|
|
81
100
|
self,
|
|
82
101
|
name: str,
|
|
83
|
-
|
|
102
|
+
*,
|
|
103
|
+
context: object | None = None,
|
|
104
|
+
attributes: Mapping[str, object] | None = None,
|
|
105
|
+
parent: Span | None = None,
|
|
106
|
+
**kwargs: object,
|
|
84
107
|
) -> Span:
|
|
85
108
|
"""Open a new span. Returns a no-op Span by default."""
|
|
86
109
|
return Span()
|
|
@@ -106,8 +129,41 @@ class LoggingTracer(Tracer):
|
|
|
106
129
|
def __init__(self, logger: logging.Logger | None = None) -> None:
|
|
107
130
|
self._logger = logger or logging.getLogger("fred_core.traces")
|
|
108
131
|
|
|
109
|
-
def start_span(
|
|
110
|
-
|
|
132
|
+
def start_span(
|
|
133
|
+
self,
|
|
134
|
+
name: str,
|
|
135
|
+
*,
|
|
136
|
+
context: object | None = None,
|
|
137
|
+
attributes: Mapping[str, object] | None = None,
|
|
138
|
+
parent: Span | None = None,
|
|
139
|
+
**kwargs: object,
|
|
140
|
+
) -> Span:
|
|
141
|
+
"""
|
|
142
|
+
Open a new logging-backed span.
|
|
143
|
+
|
|
144
|
+
Why this exists:
|
|
145
|
+
- runtime code passes structured attributes and optional parent spans
|
|
146
|
+
through one shared tracing seam
|
|
147
|
+
|
|
148
|
+
How to use it:
|
|
149
|
+
- pass `attributes=` for the canonical attribute bag
|
|
150
|
+
- optional `parent` contributes `parent_span_id` when available
|
|
151
|
+
|
|
152
|
+
Example:
|
|
153
|
+
- `tracer.start_span("agent.run", attributes={"agent_id": "demo"})`
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
del context
|
|
157
|
+
combined_attributes: dict[str, object] = dict(attributes or {})
|
|
158
|
+
combined_attributes.update(kwargs)
|
|
159
|
+
parent_span_id = parent.span_id if parent is not None else None
|
|
160
|
+
if parent_span_id is not None:
|
|
161
|
+
combined_attributes.setdefault("parent_span_id", parent_span_id)
|
|
162
|
+
return _LoggingSpan(
|
|
163
|
+
name=name,
|
|
164
|
+
logger=self._logger,
|
|
165
|
+
extra=combined_attributes,
|
|
166
|
+
)
|
|
111
167
|
|
|
112
168
|
|
|
113
169
|
@dataclass
|
|
@@ -60,7 +60,7 @@ def validate_index_mapping(
|
|
|
60
60
|
if field_name not in current_properties:
|
|
61
61
|
error_msg = f"Missing root field: '{field_name}'"
|
|
62
62
|
if allow_missing_fields:
|
|
63
|
-
logger.warning(
|
|
63
|
+
logger.warning("[OPENSEARCH][MAPPING] %s", error_msg)
|
|
64
64
|
else:
|
|
65
65
|
mismatches.append(error_msg)
|
|
66
66
|
continue
|
|
@@ -76,20 +76,23 @@ def validate_index_mapping(
|
|
|
76
76
|
error_msg = (
|
|
77
77
|
f"Index '{index_name}' has mapping validation errors: {mismatches}"
|
|
78
78
|
)
|
|
79
|
-
logger.error(
|
|
79
|
+
logger.error("[OPENSEARCH][MAPPING] %s", error_msg)
|
|
80
80
|
|
|
81
81
|
if strict:
|
|
82
82
|
raise MappingValidationError(error_msg)
|
|
83
83
|
else:
|
|
84
84
|
logger.info(
|
|
85
|
-
|
|
85
|
+
"[OPENSEARCH][MAPPING] index '%s' mapping validation passed",
|
|
86
|
+
index_name,
|
|
86
87
|
)
|
|
87
88
|
|
|
88
89
|
except Exception as e:
|
|
89
90
|
if isinstance(e, MappingValidationError):
|
|
90
91
|
raise
|
|
91
92
|
logger.error(
|
|
92
|
-
|
|
93
|
+
"[OPENSEARCH][MAPPING] validation failed for index '%s': %s",
|
|
94
|
+
index_name,
|
|
95
|
+
e,
|
|
93
96
|
)
|
|
94
97
|
if strict:
|
|
95
98
|
raise MappingValidationError(
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from fastapi import FastAPI
|
|
6
|
+
from fastapi.testclient import TestClient
|
|
7
|
+
|
|
8
|
+
from fred_core.common.fastapi_handlers import register_exception_handlers
|
|
9
|
+
from fred_core.security.models import AuthorizationError, Resource
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_authorization_error_handler_returns_team_specific_detail(
|
|
13
|
+
caplog,
|
|
14
|
+
) -> None:
|
|
15
|
+
app = FastAPI()
|
|
16
|
+
register_exception_handlers(app)
|
|
17
|
+
|
|
18
|
+
@app.get("/teams")
|
|
19
|
+
async def denied() -> None:
|
|
20
|
+
raise AuthorizationError(
|
|
21
|
+
user_id="alice",
|
|
22
|
+
action="can_update_agents",
|
|
23
|
+
resource=Resource.TEAM,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
with caplog.at_level(logging.WARNING):
|
|
27
|
+
response = TestClient(app, raise_server_exceptions=False).get("/teams")
|
|
28
|
+
|
|
29
|
+
assert response.status_code == 403
|
|
30
|
+
assert response.json() == {
|
|
31
|
+
"detail": "You are not allowed to manage agents in this team. Ask a team owner or manager."
|
|
32
|
+
}
|
|
33
|
+
assert "Authorization denied for user alice" in caplog.text
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_authorization_error_handler_humanizes_generic_resource_action() -> None:
|
|
37
|
+
app = FastAPI()
|
|
38
|
+
register_exception_handlers(app)
|
|
39
|
+
|
|
40
|
+
@app.get("/documents")
|
|
41
|
+
async def denied() -> None:
|
|
42
|
+
raise AuthorizationError(
|
|
43
|
+
user_id="alice",
|
|
44
|
+
action="read:global",
|
|
45
|
+
resource=Resource.DOCUMENTS,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
response = TestClient(app, raise_server_exceptions=False).get("/documents")
|
|
49
|
+
|
|
50
|
+
assert response.status_code == 403
|
|
51
|
+
assert response.json() == {"detail": "You are not allowed to read global document."}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_generic_exception_handler_returns_internal_server_error(caplog) -> None:
|
|
55
|
+
app = FastAPI()
|
|
56
|
+
register_exception_handlers(app)
|
|
57
|
+
|
|
58
|
+
@app.get("/explode")
|
|
59
|
+
async def explode() -> None:
|
|
60
|
+
raise RuntimeError("boom")
|
|
61
|
+
|
|
62
|
+
with caplog.at_level(logging.ERROR):
|
|
63
|
+
response = TestClient(app, raise_server_exceptions=False).get("/explode")
|
|
64
|
+
|
|
65
|
+
assert response.status_code == 500
|
|
66
|
+
assert response.json() == {"detail": "Internal server error"}
|
|
67
|
+
assert "Unhandled exception in GET http://testserver/explode: boom" in caplog.text
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from fred_core.filesystem.local_filesystem import LocalFilesystem
|
|
8
|
+
from fred_core.filesystem.structures import FilesystemResourceInfo
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@pytest.mark.asyncio
|
|
12
|
+
async def test_local_filesystem_roundtrip_listing_and_grep(tmp_path: Path) -> None:
|
|
13
|
+
filesystem = LocalFilesystem(str(tmp_path))
|
|
14
|
+
|
|
15
|
+
await filesystem.mkdir("docs")
|
|
16
|
+
await filesystem.mkdir("docs/nested")
|
|
17
|
+
await filesystem.write("docs/readme.txt", "hello world")
|
|
18
|
+
await filesystem.write("docs/nested/notes.txt", "fred runtime notes")
|
|
19
|
+
|
|
20
|
+
assert await filesystem.print_root_dir() == str(tmp_path.resolve())
|
|
21
|
+
assert await filesystem.exists("docs/readme.txt") is True
|
|
22
|
+
assert await filesystem.read("docs/readme.txt") == b"hello world"
|
|
23
|
+
assert await filesystem.cat("docs/nested/notes.txt") == "fred runtime notes"
|
|
24
|
+
|
|
25
|
+
info = await filesystem.stat("docs/readme.txt")
|
|
26
|
+
assert info.path == "docs/readme.txt"
|
|
27
|
+
assert info.type == FilesystemResourceInfo.FILE
|
|
28
|
+
assert info.size == len("hello world")
|
|
29
|
+
|
|
30
|
+
listing = await filesystem.list("docs")
|
|
31
|
+
assert [entry.path for entry in listing] == [
|
|
32
|
+
"docs/nested",
|
|
33
|
+
"docs/nested/notes.txt",
|
|
34
|
+
"docs/readme.txt",
|
|
35
|
+
]
|
|
36
|
+
assert listing[0].type == FilesystemResourceInfo.DIRECTORY
|
|
37
|
+
assert listing[1].type == FilesystemResourceInfo.FILE
|
|
38
|
+
|
|
39
|
+
matches = await filesystem.grep(r"fred\s+runtime", "docs")
|
|
40
|
+
assert matches == ["docs/nested/notes.txt"]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@pytest.mark.asyncio
|
|
44
|
+
async def test_local_filesystem_rejects_missing_parent_missing_file_and_escape(
|
|
45
|
+
tmp_path: Path,
|
|
46
|
+
) -> None:
|
|
47
|
+
filesystem = LocalFilesystem(str(tmp_path))
|
|
48
|
+
|
|
49
|
+
with pytest.raises(FileNotFoundError, match="Parent directory does not exist"):
|
|
50
|
+
await filesystem.write("missing/readme.txt", "hello")
|
|
51
|
+
|
|
52
|
+
with pytest.raises(FileNotFoundError, match="missing.txt not found"):
|
|
53
|
+
await filesystem.stat("missing.txt")
|
|
54
|
+
|
|
55
|
+
with pytest.raises(PermissionError, match="Access outside of filesystem root"):
|
|
56
|
+
await filesystem.read("../escape.txt")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@pytest.mark.asyncio
|
|
60
|
+
async def test_local_filesystem_delete_and_empty_list_are_safe(
|
|
61
|
+
tmp_path: Path,
|
|
62
|
+
) -> None:
|
|
63
|
+
filesystem = LocalFilesystem(str(tmp_path))
|
|
64
|
+
|
|
65
|
+
await filesystem.mkdir("docs")
|
|
66
|
+
await filesystem.write("docs/readme.txt", "hello")
|
|
67
|
+
await filesystem.delete("docs/readme.txt")
|
|
68
|
+
await filesystem.delete("docs/missing.txt")
|
|
69
|
+
|
|
70
|
+
assert await filesystem.exists("docs/readme.txt") is False
|
|
71
|
+
assert await filesystem.list("unknown") == []
|
|
@@ -11,7 +11,7 @@ Focus: verify the contract is honoured, not that operations do nothing.
|
|
|
11
11
|
|
|
12
12
|
from __future__ import annotations
|
|
13
13
|
|
|
14
|
-
from fred_core.kpi.kpi_writer_structures import KPIActor
|
|
14
|
+
from fred_core.kpi.kpi_writer_structures import Dims, KPIActor
|
|
15
15
|
from fred_core.kpi.noop_kpi_writer import NoOpKPIWriter
|
|
16
16
|
|
|
17
17
|
ACTOR = KPIActor(type="system")
|
|
@@ -61,7 +61,7 @@ class TestNoOpKPIWriterTimerContract:
|
|
|
61
61
|
# mutation must not raise
|
|
62
62
|
|
|
63
63
|
def test_timer_with_initial_dims_yields_copy(self) -> None:
|
|
64
|
-
initial = {"phase": "routing"}
|
|
64
|
+
initial: Dims = {"phase": "routing"}
|
|
65
65
|
with self.writer.timer("test.timer", dims=initial, actor=ACTOR) as d:
|
|
66
66
|
assert d["phase"] == "routing"
|
|
67
67
|
d["extra"] = "added"
|
|
@@ -10,10 +10,11 @@ Covers:
|
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
12
|
import time
|
|
13
|
+
from typing import Literal
|
|
13
14
|
|
|
14
15
|
import pytest
|
|
15
16
|
|
|
16
|
-
from fred_core.logs.log_structures import LogEventDTO, LogFilter, LogQuery
|
|
17
|
+
from fred_core.logs.log_structures import LogEventDTO, LogFilter, LogLevel, LogQuery
|
|
17
18
|
from fred_core.logs.memory_log_store import RamLogStore, _parse_since
|
|
18
19
|
|
|
19
20
|
# ---------------------------------------------------------------------------
|
|
@@ -25,7 +26,7 @@ def _event(
|
|
|
25
26
|
msg: str,
|
|
26
27
|
*,
|
|
27
28
|
ts: float | None = None,
|
|
28
|
-
level:
|
|
29
|
+
level: LogLevel = "INFO",
|
|
29
30
|
logger: str = "app",
|
|
30
31
|
service: str | None = None,
|
|
31
32
|
) -> LogEventDTO:
|
|
@@ -44,12 +45,12 @@ def _query(
|
|
|
44
45
|
*,
|
|
45
46
|
since: str = "now-1h",
|
|
46
47
|
until: str | None = None,
|
|
47
|
-
level_at_least:
|
|
48
|
+
level_at_least: LogLevel | None = None,
|
|
48
49
|
logger_like: str | None = None,
|
|
49
50
|
service: str | None = None,
|
|
50
51
|
text_like: str | None = None,
|
|
51
52
|
limit: int = 500,
|
|
52
|
-
order:
|
|
53
|
+
order: "Literal['asc', 'desc']" = "asc",
|
|
53
54
|
) -> LogQuery:
|
|
54
55
|
return LogQuery(
|
|
55
56
|
since=since,
|