dotfill 0.1.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.
dotfill/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """dotfill — local-only API token/identity .env helper."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("dotfill")
7
+ except PackageNotFoundError:
8
+ __version__ = "0.0.0"
dotfill/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Allow `python -m dotfill`."""
2
+
3
+ from .entrypoints import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
dotfill/api.py ADDED
@@ -0,0 +1,456 @@
1
+ """HTTP API for the dotfill local web UI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import secrets
7
+ from pathlib import Path
8
+ from typing import Annotated
9
+ from urllib.parse import urlsplit
10
+
11
+ from fastapi import APIRouter, Depends, FastAPI, Header, HTTPException, Request, status
12
+ from fastapi.responses import JSONResponse
13
+ from pydantic import BaseModel, ConfigDict
14
+
15
+ from .config import resolve_url_template
16
+ from .config_paths import ConfigContext, resolve_config_context
17
+ from .envdoc import EnvDocument
18
+ from .errors import (
19
+ ConfigValidationError,
20
+ DotfillError,
21
+ DuplicateManagedVariableError,
22
+ ImportScanError,
23
+ SaveError,
24
+ ServiceTestError,
25
+ UnresolvedIdentityError,
26
+ UrlTemplateError,
27
+ )
28
+ from .import_scan import (
29
+ build_updates_from_choices,
30
+ scan_source_text,
31
+ )
32
+ from .models import (
33
+ AppState,
34
+ CommitImportRequest,
35
+ SaveTokenRequest,
36
+ ScanDroppedRequest,
37
+ ScanPathRequest,
38
+ SessionState,
39
+ TestResult,
40
+ )
41
+ from .open_paths import open_directory, open_env_location
42
+ from .resolver import build_app_state, service_icon, service_test_fingerprint
43
+ from .save import save_assignments
44
+ from .service_test import run_service_test
45
+
46
+ log = logging.getLogger(__name__)
47
+
48
+ SESSION_HEADER = "X-Dotfill-Session"
49
+ _MUTATING_METHODS = {"DELETE", "PATCH", "POST", "PUT"}
50
+ _LOCAL_ORIGIN_HOSTS = {"127.0.0.1", "localhost", "::1"}
51
+
52
+
53
+ class AppContext:
54
+ """Shared mutable singleton for the FastAPI app."""
55
+
56
+ def __init__(
57
+ self,
58
+ session: SessionState,
59
+ *,
60
+ config_context: ConfigContext | None = None,
61
+ env_path: Path | None = None,
62
+ ) -> None:
63
+ self.config_context = config_context or resolve_config_context()
64
+ self.env_path_override = env_path
65
+ self.session = session
66
+
67
+
68
+ def _require_session(
69
+ ctx: AppContext, provided: str | None
70
+ ) -> None:
71
+ if not provided or not secrets.compare_digest(provided, ctx.session.token):
72
+ raise HTTPException(
73
+ status_code=status.HTTP_401_UNAUTHORIZED,
74
+ detail="Invalid or missing session token",
75
+ )
76
+
77
+
78
+ def _state(ctx: AppContext) -> AppState:
79
+ return build_app_state(
80
+ ctx.config_context,
81
+ ctx.session,
82
+ env_path_override=ctx.env_path_override,
83
+ )
84
+
85
+
86
+ def _is_local_origin(origin: str) -> bool:
87
+ parsed = urlsplit(origin)
88
+ return parsed.scheme in {"http", "https"} and parsed.hostname in _LOCAL_ORIGIN_HOSTS
89
+
90
+
91
+ def _identity_payload(state: AppState) -> list[dict[str, object]]:
92
+ return [
93
+ {
94
+ "name": i.name,
95
+ "detected_value": i.detected_value,
96
+ "explicit_value": i.explicit_value,
97
+ "effective_value": i.effective_value,
98
+ "source": i.source,
99
+ }
100
+ for i in state.identities
101
+ ]
102
+
103
+
104
+ def _derived_payload(state: AppState) -> list[dict[str, object]]:
105
+ return [
106
+ {
107
+ "variable_name": d.variable_name,
108
+ "current_value": d.current_value,
109
+ "computed_default": d.computed_default,
110
+ "source_identity_name": d.source_identity_name,
111
+ "status": d.status,
112
+ }
113
+ for d in state.derived
114
+ ]
115
+
116
+
117
+ def _service_payload(state: AppState) -> list[dict[str, object]]:
118
+ return [
119
+ {
120
+ "service_id": s.service_id,
121
+ "display_name": s.display_name,
122
+ "token_var": s.token_var,
123
+ "token_present": s.token_present,
124
+ "masked_token": s.masked_token,
125
+ "resolved_token_url": s.resolved_token_url,
126
+ "resolved_test_url": s.resolved_test_url,
127
+ "test_status": s.test_status,
128
+ "icon": s.icon or service_icon(None),
129
+ }
130
+ for s in state.services
131
+ ]
132
+
133
+
134
+ def _state_payload(state: AppState) -> dict[str, object]:
135
+ return {
136
+ "env_path": str(state.env_path),
137
+ "config": {
138
+ "profile": state.config_context.profile,
139
+ "name": state.config_name,
140
+ "config_dir": str(state.config_context.config_dir),
141
+ "common_config_path": str(state.config_context.common_config_path),
142
+ "user_config_path": str(state.config_context.user_config_path),
143
+ },
144
+ "identities": _identity_payload(state),
145
+ "derived": _derived_payload(state),
146
+ "services": _service_payload(state),
147
+ "session": {
148
+ "queue_test_all_on_dashboard_load": state.session.queue_test_all_on_dashboard_load,
149
+ "backup_created": state.session.backup_created,
150
+ "backup_path": str(state.session.backup_path)
151
+ if state.session.backup_path
152
+ else None,
153
+ },
154
+ }
155
+
156
+
157
+ def _service_fingerprint(
158
+ state: AppState,
159
+ *,
160
+ service_id: str,
161
+ resolved_test_url: str,
162
+ token: str,
163
+ ) -> str:
164
+ svc_def = state.effective_config.services[service_id]
165
+ return service_test_fingerprint(
166
+ service_id=service_id,
167
+ token_var=svc_def.token_var,
168
+ resolved_test_url=resolved_test_url,
169
+ auth=svc_def.auth,
170
+ tls_verify=svc_def.tls_verify,
171
+ token=token,
172
+ session_token=state.session.token,
173
+ )
174
+
175
+
176
+ def create_app(ctx: AppContext) -> FastAPI:
177
+ app = FastAPI(title="dotfill", docs_url=None, redoc_url=None, openapi_url=None)
178
+ api = APIRouter(prefix="/api")
179
+
180
+ @app.middleware("http")
181
+ async def reject_unexpected_origin(
182
+ request: Request,
183
+ call_next, # type: ignore[no-untyped-def]
184
+ ):
185
+ if request.method in _MUTATING_METHODS and request.url.path.startswith("/api/"):
186
+ origin = request.headers.get("origin")
187
+ if origin and not _is_local_origin(origin):
188
+ return JSONResponse(
189
+ status_code=403,
190
+ content={
191
+ "error": "ForbiddenOrigin",
192
+ "message": "Unexpected request origin",
193
+ },
194
+ )
195
+ return await call_next(request)
196
+
197
+ def session_dep(
198
+ request: Request,
199
+ x_dotfill_session: Annotated[str | None, Header(alias=SESSION_HEADER)] = None,
200
+ ) -> AppContext:
201
+ # Allow the bootstrap endpoint to skip the header.
202
+ if request.url.path == "/api/bootstrap":
203
+ return ctx
204
+ _require_session(ctx, x_dotfill_session)
205
+ return ctx
206
+
207
+ @app.exception_handler(DotfillError)
208
+ async def _df_exc_handler(_: Request, exc: DotfillError) -> JSONResponse:
209
+ log.warning("Dotfill error: %s", exc)
210
+ status_map = {
211
+ DuplicateManagedVariableError: 409,
212
+ ConfigValidationError: 400,
213
+ UrlTemplateError: 400,
214
+ UnresolvedIdentityError: 409,
215
+ ImportScanError: 400,
216
+ ServiceTestError: 400,
217
+ SaveError: 500,
218
+ }
219
+ code = next(
220
+ (status_code for error_type, status_code in status_map.items() if isinstance(exc, error_type)),
221
+ 500,
222
+ )
223
+ return JSONResponse(
224
+ status_code=code,
225
+ content={"error": type(exc).__name__, "message": str(exc)},
226
+ )
227
+
228
+ @api.get("/bootstrap")
229
+ def bootstrap() -> dict[str, object]:
230
+ """Public endpoint: hands the SPA its session token and version."""
231
+ from . import __version__
232
+
233
+ return {"session_token": ctx.session.token, "version": __version__}
234
+
235
+ @api.post("/open-folder")
236
+ def open_folder(ctx_in: AppContext = Depends(session_dep)) -> dict[str, object]:
237
+ """Open the directory containing the .env file in the system file manager."""
238
+ state = _state(ctx_in)
239
+ open_env_location(state.env_path)
240
+ return {"ok": True}
241
+
242
+ @api.post("/open-config-folder")
243
+ def open_config_folder(ctx_in: AppContext = Depends(session_dep)) -> dict[str, object]:
244
+ """Open the resolved dotfill config directory in the system file manager."""
245
+ open_directory(ctx_in.config_context.config_dir, create=True)
246
+ return {"ok": True}
247
+
248
+ @api.get("/state")
249
+ def get_state(ctx_in: AppContext = Depends(session_dep)) -> dict[str, object]:
250
+ return _state_payload(_state(ctx_in))
251
+
252
+ @api.post("/token/{service_id}")
253
+ def save_token(
254
+ service_id: str,
255
+ body: SaveTokenRequest,
256
+ ctx_in: AppContext = Depends(session_dep),
257
+ ) -> dict[str, object]:
258
+ state = _state(ctx_in)
259
+ svc = next(
260
+ (s for s in state.services if s.service_id == service_id),
261
+ None,
262
+ )
263
+ if svc is None:
264
+ raise HTTPException(status_code=404, detail=f"Unknown service {service_id}")
265
+ # Also fill in derived identity variables that are missing/empty.
266
+ derived_updates = {
267
+ d.variable_name: d.computed_default
268
+ for d in state.derived
269
+ if d.status == "missing" and d.computed_default
270
+ }
271
+ updates: dict[str, str] = dict(derived_updates)
272
+ updates[svc.token_var] = body.token.get_secret_value()
273
+ save_assignments(state.env_path, state.env_doc, updates, ctx_in.session)
274
+ log.info("Token saved for %s", service_id)
275
+ # Invalidate cached test result since the token value changed.
276
+ ctx_in.session.test_results.pop(service_id, None)
277
+ return {"ok": True, "updated": sorted(updates.keys())}
278
+
279
+ @api.post("/test/{service_id}")
280
+ def test_one(
281
+ service_id: str,
282
+ ctx_in: AppContext = Depends(session_dep),
283
+ ) -> dict[str, object]:
284
+ state = _state(ctx_in)
285
+ svc_def = state.effective_config.services.get(service_id)
286
+ if svc_def is None:
287
+ raise HTTPException(status_code=404, detail=f"Unknown service {service_id}")
288
+ token = state.env_doc.get(svc_def.token_var)
289
+ if not token:
290
+ raise HTTPException(status_code=400, detail="No token to test")
291
+ identity_values = {i.name: i.effective_value for i in state.identities}
292
+ try:
293
+ resolved = resolve_url_template(svc_def.test_url_template, identity_values)
294
+ except UrlTemplateError as exc:
295
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
296
+ result = run_service_test(
297
+ svc_def,
298
+ service_id=service_id,
299
+ resolved_test_url=resolved,
300
+ token=token,
301
+ )
302
+ result.fingerprint = _service_fingerprint(
303
+ state,
304
+ service_id=service_id,
305
+ resolved_test_url=resolved,
306
+ token=token,
307
+ )
308
+ ctx_in.session.test_results[service_id] = result
309
+ return {
310
+ "service_id": service_id,
311
+ "status": result.status,
312
+ "http_status": result.http_status,
313
+ "error_message": result.error_message,
314
+ }
315
+
316
+ @api.post("/test-all")
317
+ def test_all(
318
+ ctx_in: AppContext = Depends(session_dep),
319
+ ) -> dict[str, object]:
320
+ state = _state(ctx_in)
321
+ identity_values = {i.name: i.effective_value for i in state.identities}
322
+ results: list[dict[str, object]] = []
323
+ for svc_id, svc_def in state.effective_config.services.items():
324
+ token = state.env_doc.get(svc_def.token_var)
325
+ if not token:
326
+ continue
327
+ try:
328
+ resolved = resolve_url_template(
329
+ svc_def.test_url_template, identity_values
330
+ )
331
+ except UrlTemplateError as exc:
332
+ ctx_in.session.test_results[svc_id] = TestResult(
333
+ status="failed", error_message=str(exc)
334
+ )
335
+ results.append(
336
+ {
337
+ "service_id": svc_id,
338
+ "status": "failed",
339
+ "error_message": str(exc),
340
+ }
341
+ )
342
+ continue
343
+ result = run_service_test(
344
+ svc_def,
345
+ service_id=svc_id,
346
+ resolved_test_url=resolved,
347
+ token=token,
348
+ )
349
+ result.fingerprint = _service_fingerprint(
350
+ state,
351
+ service_id=svc_id,
352
+ resolved_test_url=resolved,
353
+ token=token,
354
+ )
355
+ ctx_in.session.test_results[svc_id] = result
356
+ results.append(
357
+ {
358
+ "service_id": svc_id,
359
+ "status": result.status,
360
+ "http_status": result.http_status,
361
+ "error_message": result.error_message,
362
+ }
363
+ )
364
+ ctx_in.session.queue_test_all_on_dashboard_load = False
365
+ return {"results": results}
366
+
367
+ @api.post("/import/scan-path")
368
+ def scan_path(
369
+ body: ScanPathRequest,
370
+ ctx_in: AppContext = Depends(session_dep),
371
+ ) -> dict[str, object]:
372
+ path = Path(body.path).expanduser()
373
+ if not path.exists():
374
+ raise HTTPException(status_code=400, detail=f"Path not found: {path}")
375
+ text = path.read_text(encoding="utf-8")
376
+ state = _state(ctx_in)
377
+ scan = scan_source_text(
378
+ source_label=str(path),
379
+ source_text=text,
380
+ current_doc=state.env_doc,
381
+ config=state.effective_config,
382
+ )
383
+ ctx_in.session.import_scans[scan.scan_id] = scan
384
+ return _scan_payload(scan)
385
+
386
+ @api.post("/import/scan-dropped")
387
+ def scan_dropped(
388
+ body: ScanDroppedRequest,
389
+ ctx_in: AppContext = Depends(session_dep),
390
+ ) -> dict[str, object]:
391
+ state = _state(ctx_in)
392
+ scan = scan_source_text(
393
+ source_label=f"Dropped file: {body.filename}",
394
+ source_text=body.content.get_secret_value(),
395
+ current_doc=state.env_doc,
396
+ config=state.effective_config,
397
+ )
398
+ ctx_in.session.import_scans[scan.scan_id] = scan
399
+ return _scan_payload(scan)
400
+
401
+ @api.post("/import/commit")
402
+ def commit_import(
403
+ body: CommitImportRequest,
404
+ ctx_in: AppContext = Depends(session_dep),
405
+ ) -> dict[str, object]:
406
+ scan = ctx_in.session.import_scans.get(body.scanId)
407
+ if scan is None:
408
+ raise HTTPException(status_code=404, detail="Unknown scan_id")
409
+ choices = [(c.sourceKey, c.targetKey) for c in body.mappings]
410
+ state = _state(ctx_in)
411
+ allowed_targets = {
412
+ service.token_var for service in state.effective_config.services.values()
413
+ } | set(state.effective_config.derived_variables)
414
+ updates = build_updates_from_choices(
415
+ scan,
416
+ choices,
417
+ allowed_targets=allowed_targets,
418
+ current_doc=state.env_doc,
419
+ )
420
+ save_assignments(state.env_path, state.env_doc, updates, ctx_in.session)
421
+ log.info(
422
+ "Import committed: %d variable(s) updated from %s",
423
+ len(updates),
424
+ scan.source_label,
425
+ )
426
+ token_vars_to_svc = {
427
+ s.token_var: sid for sid, s in state.effective_config.services.items()
428
+ }
429
+ for target_key in updates:
430
+ if target_key in token_vars_to_svc:
431
+ ctx_in.session.test_results.pop(token_vars_to_svc[target_key], None)
432
+ # Drop the scan once committed.
433
+ ctx_in.session.import_scans.pop(body.scanId, None)
434
+ return {"ok": True, "updated": sorted(updates.keys())}
435
+
436
+ app.include_router(api)
437
+ return app
438
+
439
+
440
+ def _scan_payload(scan) -> dict[str, object]: # type: ignore[no-untyped-def]
441
+ return {
442
+ "scan_id": scan.scan_id,
443
+ "source_label": scan.source_label,
444
+ "occupied_targets": scan.occupied_targets,
445
+ "rows": [
446
+ {
447
+ "source_key": r.source_key,
448
+ "target_key": r.target_key,
449
+ "mapping_kind": r.mapping_kind,
450
+ "locked": r.locked,
451
+ "status": r.status,
452
+ "masked_source_value": r.masked_source_value,
453
+ }
454
+ for r in scan.proposed_rows
455
+ ],
456
+ }