evidentia-api 0.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ """Evidentia API: FastAPI REST server + bundled React web UI.
2
+
3
+ The FastAPI application is exposed as :data:`evidentia_api.app.app` and
4
+ can be served directly with uvicorn:
5
+
6
+ uvicorn evidentia_api.app:app --host 127.0.0.1 --port 8000
7
+
8
+ Typical users reach it via the CLI:
9
+
10
+ evidentia serve [--host HOST] [--port PORT] [--offline]
11
+
12
+ which is a thin Typer wrapper around :func:`evidentia_api.cli.serve`.
13
+ """
14
+
15
+ from importlib.metadata import PackageNotFoundError
16
+ from importlib.metadata import version as _pkg_version
17
+
18
+ try:
19
+ __version__ = _pkg_version("evidentia-api")
20
+ except PackageNotFoundError: # pragma: no cover — only hit in editable repos without install
21
+ __version__ = "0.0.0+unknown"
evidentia_api/app.py ADDED
@@ -0,0 +1,236 @@
1
+ """FastAPI application factory.
2
+
3
+ The module-level ``app`` is the canonical instance that uvicorn references.
4
+ For testing (TestClient), prefer :func:`create_app` which accepts overrides.
5
+
6
+ All API routes live under ``/api/*``. Static assets (the bundled React SPA)
7
+ are mounted at ``/`` and serve ``index.html`` for every non-``/api/*`` path
8
+ to support client-side routing (React Router).
9
+
10
+ In v0.4.0 the static directory is populated at wheel-build time by the
11
+ frontend build hook in ``.github/workflows/release.yml`` (it runs
12
+ ``npm run build`` in ``packages/evidentia-ui`` and copies ``dist/*``
13
+ into ``src/evidentia_api/static/``). When the directory is empty at
14
+ runtime (e.g. fresh dev install without a build), a placeholder JSON
15
+ response is returned instead of 404 so the error is self-explanatory.
16
+
17
+ Offline mode: the ``evidentia serve --offline`` CLI entry point sets
18
+ ``EVIDENTIA_API_OFFLINE=1`` in the subprocess env. This module reads
19
+ that at import time to flip the process-wide air-gap guard before any
20
+ router handler runs.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import logging
26
+ import os
27
+ from pathlib import Path
28
+
29
+ from fastapi import FastAPI, Request
30
+ from fastapi.middleware.cors import CORSMiddleware
31
+ from fastapi.responses import FileResponse, JSONResponse
32
+ from fastapi.staticfiles import StaticFiles
33
+
34
+ from evidentia_api import __version__
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+ STATIC_DIR = Path(__file__).parent / "static"
39
+ """Absolute path to the bundled SPA assets (populated at build time)."""
40
+
41
+
42
+ def create_app(
43
+ *,
44
+ offline: bool = False,
45
+ dev_mode: bool = False,
46
+ cors_origins: list[str] | None = None,
47
+ ) -> FastAPI:
48
+ """Build and return a FastAPI application.
49
+
50
+ Parameters
51
+ ----------
52
+ offline
53
+ When True, flips the process-wide air-gap guard at app creation.
54
+ Every LLM/network call then refuses non-loopback targets.
55
+ dev_mode
56
+ When True, CORS is permissive to support the Vite dev server
57
+ (http://127.0.0.1:5173) without a proxy.
58
+ cors_origins
59
+ Explicit allow-list override. Default: restrictive localhost-only.
60
+ """
61
+ if offline:
62
+ from evidentia_core.network_guard import set_offline
63
+
64
+ set_offline(True)
65
+
66
+ app = FastAPI(
67
+ title="Evidentia API",
68
+ description=(
69
+ "REST API for the Evidentia GRC tool. All endpoints mirror "
70
+ "CLI capabilities. Binds to 127.0.0.1 by default for localhost "
71
+ "web-UI use."
72
+ ),
73
+ version=__version__,
74
+ docs_url="/api/docs",
75
+ redoc_url="/api/redoc",
76
+ openapi_url="/api/openapi.json",
77
+ )
78
+
79
+ # CORS: dev_mode is permissive for Vite HMR; prod is localhost-only.
80
+ if cors_origins is None:
81
+ cors_origins = (
82
+ [
83
+ "http://127.0.0.1:5173",
84
+ "http://localhost:5173",
85
+ "http://127.0.0.1:8000",
86
+ "http://localhost:8000",
87
+ ]
88
+ if dev_mode
89
+ else ["http://127.0.0.1:8000", "http://localhost:8000"]
90
+ )
91
+ app.add_middleware(
92
+ CORSMiddleware,
93
+ allow_origins=cors_origins,
94
+ allow_credentials=True,
95
+ allow_methods=["GET", "POST", "PUT", "DELETE"],
96
+ allow_headers=["*"],
97
+ )
98
+
99
+ # Attach flags to app state so every router dep can consult them.
100
+ app.state.offline = offline
101
+ app.state.dev_mode = dev_mode
102
+
103
+ # Register routers. Each router is a focused module — see routers/*.py.
104
+ # Imports are deferred so module-load errors in one router don't take
105
+ # down the whole server.
106
+ from evidentia_api.routers import (
107
+ collectors as collectors_router,
108
+ )
109
+ from evidentia_api.routers import (
110
+ config as config_router,
111
+ )
112
+ from evidentia_api.routers import (
113
+ doctor as doctor_router,
114
+ )
115
+ from evidentia_api.routers import (
116
+ explain as explain_router,
117
+ )
118
+ from evidentia_api.routers import (
119
+ frameworks as frameworks_router,
120
+ )
121
+ from evidentia_api.routers import (
122
+ gaps as gaps_router,
123
+ )
124
+ from evidentia_api.routers import (
125
+ health as health_router,
126
+ )
127
+ from evidentia_api.routers import (
128
+ init_wizard as init_wizard_router,
129
+ )
130
+ from evidentia_api.routers import (
131
+ integrations as integrations_router,
132
+ )
133
+ from evidentia_api.routers import (
134
+ llm_status as llm_status_router,
135
+ )
136
+ from evidentia_api.routers import (
137
+ risks as risks_router,
138
+ )
139
+
140
+ app.include_router(health_router.router, prefix="/api", tags=["health"])
141
+ app.include_router(config_router.router, prefix="/api", tags=["config"])
142
+ app.include_router(doctor_router.router, prefix="/api", tags=["doctor"])
143
+ app.include_router(llm_status_router.router, prefix="/api", tags=["llm"])
144
+ app.include_router(
145
+ frameworks_router.router, prefix="/api", tags=["frameworks"]
146
+ )
147
+ app.include_router(gaps_router.router, prefix="/api", tags=["gaps"])
148
+ app.include_router(risks_router.router, prefix="/api", tags=["risks"])
149
+ app.include_router(explain_router.router, prefix="/api", tags=["explain"])
150
+ app.include_router(
151
+ init_wizard_router.router, prefix="/api", tags=["init"]
152
+ )
153
+ app.include_router(
154
+ integrations_router.router, prefix="/api", tags=["integrations"]
155
+ )
156
+ app.include_router(
157
+ collectors_router.router, prefix="/api", tags=["collectors"]
158
+ )
159
+
160
+ # Static SPA mount — everything that isn't /api/* falls through to index.html.
161
+ _mount_spa(app)
162
+
163
+ return app
164
+
165
+
166
+ def _mount_spa(app: FastAPI) -> None:
167
+ """Mount the bundled React SPA at the root.
168
+
169
+ If the static directory is missing or empty (common in fresh dev checkouts
170
+ before ``npm run build`` has been executed), serve a helpful placeholder
171
+ instead of 404 so users can self-diagnose.
172
+ """
173
+ if STATIC_DIR.is_dir() and (STATIC_DIR / "index.html").is_file():
174
+ # Mount static/assets first for JS/CSS/fonts; then SPA fallback.
175
+ assets_dir = STATIC_DIR / "assets"
176
+ if assets_dir.is_dir():
177
+ app.mount(
178
+ "/assets",
179
+ StaticFiles(directory=assets_dir),
180
+ name="spa-assets",
181
+ )
182
+
183
+ @app.get("/{full_path:path}", include_in_schema=False)
184
+ async def _spa_fallback(full_path: str, request: Request) -> FileResponse:
185
+ """Serve index.html for every non-API path so React Router owns routing."""
186
+ if full_path.startswith("api/"):
187
+ # Defensive — FastAPI routing should have caught these already.
188
+ return FileResponse(STATIC_DIR / "index.html", status_code=404)
189
+ target = STATIC_DIR / full_path
190
+ if target.is_file():
191
+ return FileResponse(target)
192
+ return FileResponse(STATIC_DIR / "index.html")
193
+
194
+ else:
195
+ logger.info(
196
+ "Static SPA directory is empty at %s — serving dev placeholder. "
197
+ "Run `npm run build` in packages/evidentia-ui/ to populate.",
198
+ STATIC_DIR,
199
+ )
200
+
201
+ @app.get("/{full_path:path}", include_in_schema=False)
202
+ async def _dev_placeholder(full_path: str) -> JSONResponse:
203
+ if full_path.startswith("api/"):
204
+ return JSONResponse(status_code=404, content={"detail": "Not Found"})
205
+ return JSONResponse(
206
+ status_code=503,
207
+ content={
208
+ "error": "spa_not_built",
209
+ "message": (
210
+ "The Evidentia web UI is not bundled in this install. "
211
+ "If you are developing, run `npm install && npm run dev` "
212
+ "in packages/evidentia-ui/ and use `evidentia "
213
+ "serve --dev`. If you are an end user, please reinstall "
214
+ "with `pip install --force-reinstall evidentia[gui]`."
215
+ ),
216
+ "api_docs": "/api/docs",
217
+ },
218
+ )
219
+
220
+
221
+ # Read env-var flags set by ``evidentia serve`` subprocess launch.
222
+ # EVIDENTIA_API_OFFLINE=1 -> offline mode
223
+ # EVIDENTIA_API_DEV=1 -> permissive CORS for Vite dev server
224
+ _env_offline = os.environ.get("EVIDENTIA_API_OFFLINE", "").strip().lower() in {
225
+ "1",
226
+ "true",
227
+ "yes",
228
+ }
229
+ _env_dev = os.environ.get("EVIDENTIA_API_DEV", "").strip().lower() in {
230
+ "1",
231
+ "true",
232
+ "yes",
233
+ }
234
+
235
+ # Default instance for `uvicorn evidentia_api.app:app` usage.
236
+ app = create_app(offline=_env_offline, dev_mode=_env_dev)
evidentia_api/cli.py ADDED
@@ -0,0 +1,123 @@
1
+ """`evidentia serve` implementation.
2
+
3
+ Invoked from :mod:`evidentia.cli.main` via the `serve` Typer command.
4
+ Uses ``subprocess.Popen`` against ``sys.executable -m uvicorn`` for
5
+ portability across Windows/macOS/Linux — per the Plan-agent pressure-test,
6
+ this pattern survives Ctrl+C on Windows consoles more reliably than
7
+ ``uvicorn.run()`` in-process.
8
+
9
+ Dev mode (``--dev``) leaves frontend serving to the Vite dev server
10
+ (``npm run dev`` in ``packages/evidentia-ui/``) on port 5173; the
11
+ FastAPI server at 8000 enables permissive CORS so the two co-exist.
12
+ Production mode serves the bundled SPA from the wheel.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+ import os
19
+ import subprocess
20
+ import sys
21
+ from pathlib import Path
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ def serve(
27
+ *,
28
+ host: str = "127.0.0.1",
29
+ port: int = 8000,
30
+ offline: bool = False,
31
+ dev: bool = False,
32
+ open_browser: bool = True,
33
+ reload: bool = False,
34
+ ) -> int:
35
+ """Spawn uvicorn serving the Evidentia API + web UI.
36
+
37
+ Returns the child process exit code. Errors raised before spawn (e.g.
38
+ missing uvicorn install) surface as ``ImportError`` for the caller to
39
+ translate into a friendly message.
40
+ """
41
+ # Verify uvicorn is importable before spawning — otherwise the
42
+ # subprocess error message is opaque on Windows.
43
+ try:
44
+ import uvicorn # noqa: F401
45
+ except ImportError as e:
46
+ raise ImportError(
47
+ "uvicorn is not installed. Install the [gui] extra: "
48
+ "`pip install 'evidentia[gui]'` or "
49
+ "`uv tool install 'evidentia[gui]'`."
50
+ ) from e
51
+
52
+ # Host-binding security: warn if binding to a non-loopback address.
53
+ if host not in ("127.0.0.1", "localhost", "::1"):
54
+ logger.warning(
55
+ "SECURITY: binding to %s exposes the web UI on your network. "
56
+ "Evidentia has no auth in v0.4.0 — anyone who can reach this "
57
+ "address can view and modify your gap reports. Bind to 127.0.0.1 "
58
+ "unless you know what you're doing.",
59
+ host,
60
+ )
61
+
62
+ # Environment plumbing: offline flag + dev mode are read by
63
+ # evidentia_api.app.create_app via module-level env vars so they
64
+ # survive the subprocess boundary.
65
+ env = os.environ.copy()
66
+ if offline:
67
+ env["EVIDENTIA_API_OFFLINE"] = "1"
68
+ if dev:
69
+ env["EVIDENTIA_API_DEV"] = "1"
70
+
71
+ cmd = [
72
+ sys.executable,
73
+ "-m",
74
+ "uvicorn",
75
+ "evidentia_api.app:app",
76
+ "--host",
77
+ host,
78
+ "--port",
79
+ str(port),
80
+ ]
81
+ if reload:
82
+ cmd.append("--reload")
83
+
84
+ if open_browser and not reload:
85
+ # Open browser slightly after spawn so the server has time to bind.
86
+ import threading
87
+ import webbrowser
88
+
89
+ def _open() -> None:
90
+ import time
91
+
92
+ time.sleep(1.5)
93
+ webbrowser.open(f"http://{host}:{port}")
94
+
95
+ threading.Thread(target=_open, daemon=True).start()
96
+
97
+ logger.info("Spawning: %s", " ".join(cmd))
98
+
99
+ # Windows: CREATE_NEW_PROCESS_GROUP lets Ctrl+C in the parent terminate
100
+ # the child cleanly without leaving a zombie on the port.
101
+ popen_kwargs: dict = {"env": env}
102
+ if sys.platform == "win32":
103
+ popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
104
+
105
+ proc = subprocess.Popen(cmd, **popen_kwargs)
106
+ try:
107
+ return proc.wait()
108
+ except KeyboardInterrupt:
109
+ logger.info("Received Ctrl+C; shutting down uvicorn...")
110
+ if sys.platform == "win32":
111
+ import signal
112
+
113
+ proc.send_signal(signal.CTRL_BREAK_EVENT)
114
+ else:
115
+ proc.terminate()
116
+ return proc.wait()
117
+
118
+
119
+ def resolve_static_dir() -> Path:
120
+ """Return the static-asset directory, for diagnostics."""
121
+ from evidentia_api.app import STATIC_DIR
122
+
123
+ return STATIC_DIR
evidentia_api/deps.py ADDED
@@ -0,0 +1,25 @@
1
+ """FastAPI dependency-injection helpers.
2
+
3
+ Each router consumes these via ``Depends(...)`` so tests can override them
4
+ via ``app.dependency_overrides`` for TestClient-based coverage.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from evidentia_core.catalogs.registry import FrameworkRegistry
10
+ from fastapi import Request
11
+
12
+
13
+ def get_registry() -> FrameworkRegistry:
14
+ """Return the shared FrameworkRegistry singleton."""
15
+ return FrameworkRegistry.get_instance()
16
+
17
+
18
+ def get_offline_flag(request: Request) -> bool:
19
+ """True if the server was started with --offline."""
20
+ return bool(getattr(request.app.state, "offline", False))
21
+
22
+
23
+ def get_dev_mode(request: Request) -> bool:
24
+ """True if the server was started with --dev (Vite proxy mode)."""
25
+ return bool(getattr(request.app.state, "dev_mode", False))
@@ -0,0 +1,5 @@
1
+ """FastAPI routers — one module per logical endpoint group.
2
+
3
+ Routers are registered in :func:`evidentia_api.app.create_app`; each
4
+ module exposes a ``router: APIRouter`` module-level attribute.
5
+ """
@@ -0,0 +1,151 @@
1
+ """Collectors router — AWS + GitHub evidence endpoints.
2
+
3
+ All endpoints are POST-only — running a collector has non-trivial
4
+ side-effects (AWS API calls, GitHub rate limits) so a GET shouldn't
5
+ trigger them. Response is a list of :class:`SecurityFinding` objects.
6
+
7
+ Credentials:
8
+ - AWS: boto3's standard chain (env, ~/.aws/credentials, instance profile)
9
+ - GitHub: $GITHUB_TOKEN environment variable on the server
10
+
11
+ No credential values ever flow through request/response bodies.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ import os
18
+ from typing import Any
19
+
20
+ from evidentia_core.models.finding import SecurityFinding
21
+ from fastapi import APIRouter, HTTPException
22
+
23
+ logger = logging.getLogger(__name__)
24
+ router = APIRouter()
25
+
26
+
27
+ @router.post("/collectors/aws/collect", response_model=list[SecurityFinding])
28
+ async def aws_collect(payload: dict[str, Any] | None = None) -> list[SecurityFinding]:
29
+ """Run the AWS collector (Config + Security Hub).
30
+
31
+ Request body (optional):
32
+
33
+ - ``region``: override region
34
+ - ``profile``: optional AWS profile name
35
+ - ``include_config``: bool (default True)
36
+ - ``include_security_hub``: bool (default True)
37
+ """
38
+ try:
39
+ from evidentia_collectors.aws import AwsCollector, AwsCollectorError
40
+ except ImportError as e:
41
+ raise HTTPException(
42
+ status_code=503,
43
+ detail=(
44
+ "AWS collector not installed. Run "
45
+ "`pip install 'evidentia-collectors[aws]'`."
46
+ ),
47
+ ) from e
48
+
49
+ body = payload or {}
50
+ region = body.get("region") if isinstance(body.get("region"), str) else None
51
+ profile = body.get("profile") if isinstance(body.get("profile"), str) else None
52
+ include_config = bool(body.get("include_config", True))
53
+ include_security_hub = bool(body.get("include_security_hub", True))
54
+
55
+ try:
56
+ collector = AwsCollector(region=region, profile=profile)
57
+ collector.test_connection()
58
+ except AwsCollectorError as e:
59
+ raise HTTPException(status_code=503, detail=str(e)) from e
60
+
61
+ try:
62
+ findings = collector.collect_all(
63
+ include_config=include_config,
64
+ include_security_hub=include_security_hub,
65
+ )
66
+ except Exception as e:
67
+ logger.exception("AWS collector failed")
68
+ raise HTTPException(status_code=500, detail=f"AWS collector failed: {e}") from e
69
+
70
+ return findings
71
+
72
+
73
+ @router.post("/collectors/github/collect", response_model=list[SecurityFinding])
74
+ async def github_collect(payload: dict[str, Any]) -> list[SecurityFinding]:
75
+ """Run the GitHub collector.
76
+
77
+ Request body (required):
78
+
79
+ - ``repo``: repository in 'owner/repo' format
80
+
81
+ Credentials are sourced from the server's ``$GITHUB_TOKEN`` env var.
82
+ """
83
+ try:
84
+ from evidentia_collectors.github import (
85
+ GitHubApiError,
86
+ GitHubCollector,
87
+ GitHubCollectorError,
88
+ )
89
+ except ImportError as e:
90
+ raise HTTPException(
91
+ status_code=503,
92
+ detail=f"GitHub collector import failed: {e}",
93
+ ) from e
94
+
95
+ repo = str(payload.get("repo") or "").strip()
96
+ if "/" not in repo:
97
+ raise HTTPException(
98
+ status_code=422,
99
+ detail="Request body must include 'repo' in 'owner/repo' format.",
100
+ )
101
+ owner, repo_name = repo.split("/", 1)
102
+ token = os.environ.get("GITHUB_TOKEN")
103
+
104
+ try:
105
+ with GitHubCollector(
106
+ owner=owner, repo=repo_name, token=token
107
+ ) as collector:
108
+ findings = collector.collect()
109
+ except GitHubCollectorError as e:
110
+ raise HTTPException(status_code=404, detail=str(e)) from e
111
+ except GitHubApiError as e:
112
+ raise HTTPException(status_code=502, detail=str(e)) from e
113
+
114
+ return findings
115
+
116
+
117
+ @router.get("/collectors/status")
118
+ async def collectors_status() -> dict[str, Any]:
119
+ """Report which collectors are installed + which credentials are set.
120
+
121
+ Never returns token values — only ``configured: bool`` + the env var
122
+ name the token was sourced from.
123
+ """
124
+ aws_installed = False
125
+ github_installed = False
126
+ try:
127
+ import evidentia_collectors.aws
128
+
129
+ aws_installed = True
130
+ except ImportError:
131
+ pass
132
+ try:
133
+ import evidentia_collectors.github # noqa: F401
134
+
135
+ github_installed = True
136
+ except ImportError:
137
+ pass
138
+
139
+ return {
140
+ "aws": {
141
+ "installed": aws_installed,
142
+ "credentials_hint": (
143
+ "boto3 standard chain (env / ~/.aws / instance profile)"
144
+ ),
145
+ },
146
+ "github": {
147
+ "installed": github_installed,
148
+ "token_configured": bool(os.environ.get("GITHUB_TOKEN")),
149
+ "token_source": "env:GITHUB_TOKEN" if os.environ.get("GITHUB_TOKEN") else None,
150
+ },
151
+ }
@@ -0,0 +1,74 @@
1
+ """Config router — read / write ``evidentia.yaml``.
2
+
3
+ GET returns the current config (walking CWD -> parents for the file).
4
+ PUT accepts a :class:`EvidentiaConfig` payload, validates it via
5
+ Pydantic, and writes the YAML back to the same path.
6
+
7
+ No secrets ever flow through this endpoint's request/response bodies —
8
+ the ``llm`` subsection is model + temperature only (no API keys). Keys
9
+ are handled separately via the ``/api/llm-status`` endpoint, which never
10
+ returns key values, only booleans + source identifiers.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ import yaml
20
+ from evidentia_core.config import (
21
+ CONFIG_FILENAME,
22
+ EvidentiaConfig,
23
+ find_config_file,
24
+ load_config,
25
+ )
26
+ from fastapi import APIRouter, HTTPException
27
+
28
+ logger = logging.getLogger(__name__)
29
+ router = APIRouter()
30
+
31
+
32
+ @router.get("/config", response_model=EvidentiaConfig)
33
+ async def get_config() -> EvidentiaConfig:
34
+ """Return the currently-loaded evidentia.yaml contents.
35
+
36
+ If no file exists in CWD or any parent, returns a default
37
+ (empty) :class:`EvidentiaConfig`.
38
+ """
39
+ return load_config()
40
+
41
+
42
+ @router.put("/config", response_model=EvidentiaConfig)
43
+ async def put_config(payload: EvidentiaConfig) -> EvidentiaConfig:
44
+ """Persist ``evidentia.yaml`` with the validated payload.
45
+
46
+ Writes to the discovered path (walking CWD -> parents), or to
47
+ ``./evidentia.yaml`` if none exists yet. Returns the persisted
48
+ config (with ``source_path`` populated so callers can confirm where
49
+ the write landed).
50
+ """
51
+ target = find_config_file() or (Path.cwd() / CONFIG_FILENAME)
52
+ try:
53
+ dumped: dict[str, Any] = payload.model_dump(
54
+ exclude={"source_path"}, exclude_defaults=False
55
+ )
56
+ yaml_text = yaml.safe_dump(
57
+ dumped, sort_keys=False, default_flow_style=False
58
+ )
59
+ target.write_text(yaml_text, encoding="utf-8")
60
+ except OSError as e:
61
+ logger.error("Failed to write %s: %s", target, e)
62
+ raise HTTPException(
63
+ status_code=500,
64
+ detail=f"Could not write config to {target}: {e}",
65
+ ) from e
66
+
67
+ # Clear the LRU cache so subsequent reads see the new contents.
68
+ from evidentia_core.config import _load_config_cached
69
+
70
+ _load_config_cached.cache_clear()
71
+
72
+ refreshed = load_config(target)
73
+ logger.info("Wrote %s (%d bytes)", target, len(yaml_text))
74
+ return refreshed