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.
Files changed (129) hide show
  1. {fred_core-2.0.2 → fred_core-2.0.4}/PKG-INFO +17 -11
  2. {fred_core-2.0.2 → fred_core-2.0.4}/README.md +16 -10
  3. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/common/__init__.py +0 -2
  4. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/common/fastapi_handlers.py +5 -2
  5. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/common/structures.py +1 -45
  6. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/common/utils.py +1 -1
  7. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/filesystem/minio_filesystem.py +1 -1
  8. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/kpi/kpi_writer.py +4 -1
  9. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/kpi/opensearch_kpi_store.py +8 -6
  10. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/logs/opensearch_log_store.py +9 -6
  11. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/portable/observability.py +59 -3
  12. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/store/opensearch_mapping_validator.py +7 -4
  13. fred_core-2.0.4/fred_core/tests/common/test_fastapi_handlers.py +67 -0
  14. fred_core-2.0.4/fred_core/tests/filesystem/test_local_filesystem.py +71 -0
  15. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/tests/kpi/test_noop_kpi_writer.py +2 -2
  16. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/tests/logs/test_memory_log_store.py +5 -4
  17. fred_core-2.0.4/fred_core/tests/model/test_http_clients.py +252 -0
  18. fred_core-2.0.4/fred_core/tests/portable/test_observability.py +144 -0
  19. fred_core-2.0.4/fred_core/tests/security/test_authorization_decorator.py +122 -0
  20. fred_core-2.0.4/fred_core/tests/store/test_local_content_store.py +58 -0
  21. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/tests/test_log_kpi_store.py +7 -0
  22. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/tests/test_prometheus_kpi_store.py +8 -0
  23. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core.egg-info/PKG-INFO +17 -11
  24. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core.egg-info/SOURCES.txt +6 -0
  25. {fred_core-2.0.2 → fred_core-2.0.4}/pyproject.toml +1 -1
  26. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/__init__.py +0 -0
  27. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/cli/__init__.py +0 -0
  28. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/cli/auth.py +0 -0
  29. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/cli/ui.py +0 -0
  30. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/common/config_files.py +0 -0
  31. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/common/config_loader.py +0 -0
  32. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/common/env.py +0 -0
  33. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/common/lru_cache.py +0 -0
  34. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/common/team_id.py +0 -0
  35. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/filesystem/local_filesystem.py +0 -0
  36. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/filesystem/structures.py +0 -0
  37. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/history/__init__.py +0 -0
  38. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/history/base_history_store.py +0 -0
  39. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/history/history_models.py +0 -0
  40. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/history/history_schema.py +0 -0
  41. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/history/postgres_history_store.py +0 -0
  42. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/kpi/__init__.py +0 -0
  43. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/kpi/base_kpi_store.py +0 -0
  44. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/kpi/base_kpi_writer.py +0 -0
  45. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/kpi/kpi_phase_metric.py +0 -0
  46. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/kpi/kpi_process.py +0 -0
  47. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/kpi/kpi_reader_structures.py +0 -0
  48. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/kpi/kpi_writer_structures.py +0 -0
  49. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/kpi/log_kpi_store.py +0 -0
  50. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/kpi/noop_kpi_writer.py +0 -0
  51. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/kpi/prometheus_kpi_store.py +0 -0
  52. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/logs/__init__.py +0 -0
  53. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/logs/base_log_store.py +0 -0
  54. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/logs/log_setup.py +0 -0
  55. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/logs/log_structures.py +0 -0
  56. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/logs/memory_log_store.py +0 -0
  57. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/logs/null_log_store.py +0 -0
  58. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/model/factory.py +0 -0
  59. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/model/http_clients.py +0 -0
  60. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/model/models.py +0 -0
  61. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/models/__init__.py +0 -0
  62. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/models/base.py +0 -0
  63. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/portable/__init__.py +0 -0
  64. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/py.typed +0 -0
  65. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/scheduler/__init__.py +0 -0
  66. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/scheduler/backend.py +0 -0
  67. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/scheduler/scheduler_structures.py +0 -0
  68. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/scheduler/temporal_client_provider.py +0 -0
  69. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/security/__init__.py +0 -0
  70. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/security/authorization.py +0 -0
  71. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/security/authorization_decorator.py +0 -0
  72. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/security/backend_to_backend_auth.py +0 -0
  73. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/security/keycloak/__init__.py +0 -0
  74. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/security/keycloak/keycloack_admin_client.py +0 -0
  75. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/security/models.py +0 -0
  76. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/security/oidc.py +0 -0
  77. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/security/outbound.py +0 -0
  78. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/security/rbac.py +0 -0
  79. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/security/rebac/noop_engine.py +0 -0
  80. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/security/rebac/openfga_engine.py +0 -0
  81. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/security/rebac/openfga_schema.py +0 -0
  82. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/security/rebac/rebac_engine.py +0 -0
  83. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/security/rebac/rebac_factory.py +0 -0
  84. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/security/rebac/schema.fga +0 -0
  85. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/security/rebac/schema.fga.json +0 -0
  86. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/security/structure.py +0 -0
  87. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/security/whitelist_access_control/access_control.py +0 -0
  88. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/session/__init__.py +0 -0
  89. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/session/session_schema.py +0 -0
  90. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/session/stores/__init__.py +0 -0
  91. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/session/stores/base_session_store.py +0 -0
  92. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/session/stores/postgres_session_store.py +0 -0
  93. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/session/stores/session_models.py +0 -0
  94. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/sql/__init__.py +0 -0
  95. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/sql/alembic_env.py +0 -0
  96. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/sql/async_session.py +0 -0
  97. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/sql/base_sql.py +0 -0
  98. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/sql/mixin.py +0 -0
  99. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/store/__init__.py +0 -0
  100. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/store/base_content_store.py +0 -0
  101. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/store/local_content_store.py +0 -0
  102. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/store/minio_content_store.py +0 -0
  103. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/store/vector_search.py +0 -0
  104. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/tests/__init__.py +0 -0
  105. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/tests/cli/test_auth.py +0 -0
  106. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/tests/cli/test_ui.py +0 -0
  107. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/tests/common/test_config_loader.py +0 -0
  108. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/tests/common/test_env.py +0 -0
  109. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/tests/common/test_log_setup.py +0 -0
  110. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/tests/common/test_lru_cache.py +0 -0
  111. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/tests/integration/__init__.py +0 -0
  112. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/tests/integration/test_rebac.py +0 -0
  113. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/tests/model/test_embedding_factory.py +0 -0
  114. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/tests/model/test_vertex_model_garden_auth.py +0 -0
  115. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/tests/scheduler/test_backend.py +0 -0
  116. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/tests/security/__init__.py +0 -0
  117. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/tests/security/test_authorization.py +0 -0
  118. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/tests/security/test_rebac_engine_team_helpers.py +0 -0
  119. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/tests/security/test_whitelist_access_control.py +0 -0
  120. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/tests/session/test_postgres_json_session_store_sqlite.py +0 -0
  121. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/users/__init__.py +0 -0
  122. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/users/store/__init__.py +0 -0
  123. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/users/store/base_user_store.py +0 -0
  124. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/users/store/postgres_user_store.py +0 -0
  125. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core/users/user_models.py +0 -0
  126. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core.egg-info/dependency_links.txt +0 -0
  127. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core.egg-info/requires.txt +0 -0
  128. {fred_core-2.0.2 → fred_core-2.0.4}/fred_core.egg-info/top_level.txt +0 -0
  129. {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.2
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(f"Authorization denied for user {exc.user_id}: {exc}")
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
- f"Unhandled exception in {request.method} {request.url}: {exc}",
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, model_validator
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(f"[{error_id}] {msg}")
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(f"Bucket '{bucket_name}' created.")
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(f"[KPI] ensure_ready failed (continuing best-effort): {e}")
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(f"[OPENSEARCH][KPI] created index '{self.index}'.")
158
+ logger.info("[OPENSEARCH][KPI] created index '%s'", self.index)
159
159
  else:
160
- logger.info(f"[OPENSEARCH][KPI] index '{self.index}' already exists.")
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(f"[OPENSEARCH][KPI] ensure_ready failed: {e}")
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(f"[OPENSEARCH][KPI] index_event failed: {e}")
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("[KPI] bulk_index completed with partial errors.")
207
+ logger.warning(
208
+ "[OPENSEARCH][KPI] bulk_index completed with partial errors."
209
+ )
208
210
  except OpenSearchException as e:
209
- logger.error(f"[OPENSEARCH][KPI] bulk_index failed: {e}")
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(f"[LOG] created index '{self.index}'.")
160
+ logger.info("[OPENSEARCH][LOG] created index '%s'.", self.index)
161
161
  else:
162
- logger.info(f"[LOG] index '{self.index}' already exists.")
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(f"[LOG] ensure_ready failed: {e}")
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
- raise e
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
- print("[LOG] bulk_index completed with partial errors.")
187
+ logger.warning(
188
+ "[OPENSEARCH][LOG] bulk_index completed with partial errors."
189
+ )
187
190
  except OpenSearchException as e:
188
- print(f"[LOG] bulk_index failed: {e}")
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
- **attributes: Any,
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(self, name: str, **attributes: Any) -> Span:
110
- return _LoggingSpan(name=name, logger=self._logger, extra=attributes)
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(f"[OPENSEARCH][MAPPING] {error_msg}")
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(f"[OPENSEARCH][MAPPING] {error_msg}")
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
- f"[OPENSEARCH][MAPPING] Index '{index_name}' mapping validation passed"
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
- f"[MAPPING] Failed to validate mapping for index '{index_name}': {e}"
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: str = "INFO",
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: str | None = None,
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: str = "asc",
53
+ order: "Literal['asc', 'desc']" = "asc",
53
54
  ) -> LogQuery:
54
55
  return LogQuery(
55
56
  since=since,