snapdelta-web 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.
Files changed (68) hide show
  1. snapdelta_web/__init__.py +1 -0
  2. snapdelta_web/access.py +66 -0
  3. snapdelta_web/api.py +327 -0
  4. snapdelta_web/app.py +807 -0
  5. snapdelta_web/auth.py +58 -0
  6. snapdelta_web/cli.py +81 -0
  7. snapdelta_web/jobs.py +104 -0
  8. snapdelta_web/mcp_guide.md +34 -0
  9. snapdelta_web/mcp_server.py +361 -0
  10. snapdelta_web/settings_form.py +233 -0
  11. snapdelta_web/state.py +21 -0
  12. snapdelta_web/static/app.css +561 -0
  13. snapdelta_web/static/app.js +392 -0
  14. snapdelta_web/static/favicon.svg +1 -0
  15. snapdelta_web/static/vendor/VERSIONS +4 -0
  16. snapdelta_web/static/vendor/fonts/bricolage-grotesque-latin-ext-opsz-normal.woff2 +0 -0
  17. snapdelta_web/static/vendor/fonts/bricolage-grotesque-latin-opsz-normal.woff2 +0 -0
  18. snapdelta_web/static/vendor/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
  19. snapdelta_web/static/vendor/fonts/jetbrains-mono-latin-500-normal.woff2 +0 -0
  20. snapdelta_web/static/vendor/fonts/jetbrains-mono-latin-ext-400-normal.woff2 +0 -0
  21. snapdelta_web/static/vendor/fonts/jetbrains-mono-latin-ext-500-normal.woff2 +0 -0
  22. snapdelta_web/static/vendor/fonts.css +55 -0
  23. snapdelta_web/static/vendor/htmx.min.js +1 -0
  24. snapdelta_web/static/vendor/licenses/bricolage-grotesque-OFL.txt +93 -0
  25. snapdelta_web/static/vendor/licenses/htmx-LICENSE +13 -0
  26. snapdelta_web/static/vendor/licenses/jetbrains-mono-OFL.txt +93 -0
  27. snapdelta_web/static/vendor/licenses/swagger-ui-LICENSE +202 -0
  28. snapdelta_web/static/vendor/swagger-ui/swagger-ui-bundle.js +2 -0
  29. snapdelta_web/static/vendor/swagger-ui/swagger-ui.css +3 -0
  30. snapdelta_web/templates/_admin_nav.html +8 -0
  31. snapdelta_web/templates/_cards.html +4 -0
  32. snapdelta_web/templates/_macros.html +110 -0
  33. snapdelta_web/templates/_mcp_result.html +1 -0
  34. snapdelta_web/templates/_model_rows.html +4 -0
  35. snapdelta_web/templates/_models.html +12 -0
  36. snapdelta_web/templates/_palette.html +8 -0
  37. snapdelta_web/templates/_review.html +3 -0
  38. snapdelta_web/templates/_snapshot_options.html +8 -0
  39. snapdelta_web/templates/_take_job.html +31 -0
  40. snapdelta_web/templates/_take_targets.html +4 -0
  41. snapdelta_web/templates/_target_options.html +2 -0
  42. snapdelta_web/templates/_timeline.html +52 -0
  43. snapdelta_web/templates/activity.html +35 -0
  44. snapdelta_web/templates/admin.html +42 -0
  45. snapdelta_web/templates/admin_about.html +40 -0
  46. snapdelta_web/templates/admin_inventory.html +34 -0
  47. snapdelta_web/templates/admin_models.html +35 -0
  48. snapdelta_web/templates/admin_settings.html +69 -0
  49. snapdelta_web/templates/base.html +68 -0
  50. snapdelta_web/templates/diff.html +78 -0
  51. snapdelta_web/templates/diffs.html +42 -0
  52. snapdelta_web/templates/error.html +23 -0
  53. snapdelta_web/templates/login.html +14 -0
  54. snapdelta_web/templates/mcp_docs.html +69 -0
  55. snapdelta_web/templates/new.html +35 -0
  56. snapdelta_web/templates/not_found.html +22 -0
  57. snapdelta_web/templates/run.html +25 -0
  58. snapdelta_web/templates/snapshot.html +37 -0
  59. snapdelta_web/templates/snapshots.html +28 -0
  60. snapdelta_web/templates/take.html +31 -0
  61. snapdelta_web/templates/take_job.html +6 -0
  62. snapdelta_web/templates/target.html +31 -0
  63. snapdelta_web/views.py +744 -0
  64. snapdelta_web-0.1.0.dist-info/METADATA +75 -0
  65. snapdelta_web-0.1.0.dist-info/RECORD +68 -0
  66. snapdelta_web-0.1.0.dist-info/WHEEL +4 -0
  67. snapdelta_web-0.1.0.dist-info/entry_points.txt +3 -0
  68. snapdelta_web-0.1.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1 @@
1
+ """snapdelta-web: local web UI for the snapdelta store."""
@@ -0,0 +1,66 @@
1
+ """Store access from the web: names resolve through the store backend, which refuses names that leave the store."""
2
+
3
+ import threading
4
+ from collections import OrderedDict
5
+ from collections.abc import Callable, Hashable, Mapping
6
+
7
+ from snapdelta.errors import NotFoundError, SnapdeltaError
8
+ from snapdelta.output import Side
9
+ from snapdelta.profile import paired
10
+ from snapdelta.snapshot import StoredSource
11
+ from snapdelta.store import Store
12
+
13
+
14
+ class LruCache[K: Hashable, V]:
15
+ """The last `size` values; the web server runs requests in threads, so a lock guards the order."""
16
+
17
+ def __init__(self, size: int) -> None:
18
+ self.size = size
19
+ self._values: OrderedDict[K, V] = OrderedDict()
20
+ self._lock = threading.Lock()
21
+
22
+ def get(self, key: K, load: Callable[[], V]) -> V:
23
+ with self._lock:
24
+ if key in self._values:
25
+ self._values.move_to_end(key)
26
+ return self._values[key]
27
+ value = load()
28
+ with self._lock:
29
+ self._values[key] = value
30
+ self._values.move_to_end(key)
31
+ while len(self._values) > self.size:
32
+ self._values.popitem(last=False)
33
+ return value
34
+
35
+ def discard(self, key: K) -> None:
36
+ with self._lock:
37
+ self._values.pop(key, None)
38
+
39
+
40
+ class SnapshotCache:
41
+ """Loaded snapshots for the pages that only read them: a saved snapshot never changes, it can only go away.
42
+
43
+ Never give a cached snapshot to a compare: a compare changes `top_level` while it runs.
44
+ """
45
+
46
+ def __init__(self, size: int = 4) -> None:
47
+ self._cache: LruCache[tuple[str, str], StoredSource] = LruCache(size)
48
+
49
+ def load(self, store: Store, reference: str) -> StoredSource:
50
+ # A delete from the CLI or from another request leaves a stale entry, so the store decides first.
51
+ if not store.has_snapshot(reference):
52
+ raise NotFoundError(f"no snapshot {reference!r} in {store.location}")
53
+ return self._cache.get((store.location, reference), lambda: store.load_snapshot(reference))
54
+
55
+ def side(self, store: Store, side: Side, pairs: Mapping[str, str] | None = None) -> StoredSource | None:
56
+ """The snapshot that an output side names, with the names of the pairs of its profile; None when it is gone."""
57
+ if side.snapshot is None:
58
+ return None
59
+ try:
60
+ loaded = self.load(store, side.snapshot)
61
+ if not pairs:
62
+ return loaded
63
+ key = (store.location, f"{side.snapshot} {sorted(pairs.items())}")
64
+ return self._cache.get(key, lambda: paired(loaded, pairs))
65
+ except SnapdeltaError:
66
+ return None
snapdelta_web/api.py ADDED
@@ -0,0 +1,327 @@
1
+ """The JSON API, for a pipeline or an AI agent: take snapshots, compare them, read and describe the diffs."""
2
+
3
+ from datetime import datetime
4
+ from typing import Annotated, Any, Literal, Self
5
+
6
+ from fastapi import FastAPI, Query, Request
7
+ from fastapi.openapi.docs import get_swagger_ui_html
8
+ from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse, Response
9
+ from pydantic import BaseModel, Field
10
+
11
+ from snapdelta.about import components, package_version, plugins
12
+ from snapdelta.activity import Run, RunStatus
13
+ from snapdelta.errors import NotFoundError, SnapdeltaError
14
+ from snapdelta.output import Output, Review, Side, new_review
15
+ from snapdelta.render import renderer
16
+ from snapdelta.snapshot import SnapshotFile
17
+ from snapdelta.source import source_classes
18
+ from snapdelta.take import diff_snapshots, snapshot_targets
19
+
20
+ from .access import LruCache
21
+ from .state import AppState
22
+
23
+ PREFIX = "/api/v1"
24
+
25
+
26
+ class Component(BaseModel):
27
+ name: str
28
+ version: str
29
+ sources: list[str] = []
30
+
31
+
32
+ class VersionInfo(BaseModel):
33
+ components: list[Component]
34
+ plugins: list[Component]
35
+
36
+
37
+ class SourceInfo(BaseModel):
38
+ name: str
39
+ distribution: str | None
40
+ fetches: bool
41
+
42
+
43
+ class SnapshotInfo(BaseModel):
44
+ reference: str
45
+ target: str
46
+ label: str | None
47
+ captured_at: datetime
48
+
49
+
50
+ class SnapshotRequest(BaseModel):
51
+ source: str | None = Field(default=None, description="The source; none: the platform of each inventory host.")
52
+ targets: list[str] = Field(default=[], description="Target names.")
53
+ groups: list[str] = Field(default=[], description="Inventory groups: every host of each group.")
54
+ all_targets: bool = Field(default=False, description="Every target of the source, or every inventory host.")
55
+ label: str | None = Field(default=None, description="The label of the snapshots, for example before.")
56
+
57
+
58
+ class SnapshotResult(BaseModel):
59
+ ok: bool = Field(description="True when every target was saved.")
60
+ saved: dict[str, str] = Field(description="The reference of the snapshot of each saved target.")
61
+ failed: dict[str, str] = Field(description="The error of each failed target.")
62
+
63
+
64
+ class DiffRequest(BaseModel):
65
+ left: str = Field(description="A snapshot: TARGET/NAME, TARGET@latest or TARGET@LABEL (newest with that label).")
66
+ right: str | None = Field(default=None, description="The right snapshot; none with live.")
67
+ live: bool = Field(default=False, description="Fetch the target of left now as the right side.")
68
+ target: str | None = Field(default=None, description="With live: another target than the one of left.")
69
+ models: list[str] = Field(default=[], description="Only these models; none: every model of both sides.")
70
+ profile: str | None = Field(default=None, description="A compare profile of the settings.")
71
+ title: str | None = None
72
+ description: str | None = None
73
+ save: bool = Field(default=True, description="Save the output, and with live the new snapshot.")
74
+
75
+
76
+ class DiffResult(BaseModel):
77
+ name: str | None = Field(description="The name of the saved output; none with save false.")
78
+ url: str | None = Field(description="The page of the diff in the web UI.")
79
+ has_differences: bool
80
+ summary: dict[str, int]
81
+ left: Side
82
+ right: Side
83
+ title: str | None
84
+ description: str | None
85
+ output: Output | None = Field(default=None, description="The whole output, with include_output.")
86
+
87
+
88
+ class DiffItem(BaseModel):
89
+ name: str
90
+ created_at: datetime
91
+ title: str | None
92
+ left: Side
93
+ right: Side
94
+ has_differences: bool
95
+ summary: dict[str, int]
96
+ accepted: int = Field(description="How many changed objects a person accepted.")
97
+ changes: int = Field(description="How many objects have a change of their own.")
98
+
99
+ @classmethod
100
+ def of(cls, name: str, output: Output) -> Self:
101
+ accepted, changes = output.review_counts()
102
+ return cls(
103
+ name=name,
104
+ created_at=output.created_at,
105
+ title=output.title,
106
+ left=output.left,
107
+ right=output.right,
108
+ has_differences=output.has_differences(),
109
+ summary=output.summary,
110
+ accepted=accepted,
111
+ changes=changes,
112
+ )
113
+
114
+
115
+ class RunItem(BaseModel):
116
+ """A run without its log: what the activity list shows."""
117
+
118
+ id: str
119
+ kind: str
120
+ origin: str
121
+ user: str | None
122
+ started_at: datetime
123
+ duration_s: float
124
+ status: RunStatus
125
+ targets: list[str]
126
+ saved: list[str]
127
+ output: str | None
128
+ errors: dict[str, str]
129
+ log_lines: int
130
+
131
+ @classmethod
132
+ def of(cls, run: Run) -> Self:
133
+ values = run.model_dump(exclude={"log", "ended_at"})
134
+ return cls(**values, duration_s=run.duration_s, log_lines=len(run.log))
135
+
136
+
137
+ class ReviewRequest(BaseModel):
138
+ key: str = Field(description="MODEL:UNIQUE_ID of a changed object, as in the reviews of the output.")
139
+ accepted: bool = Field(default=True, description="False withdraws the acceptance.")
140
+ by: str | None = Field(default=None, description="Who accepts.")
141
+ comment: str | None = Field(default=None, description="Why the change is right.")
142
+
143
+
144
+ class ReviewState(BaseModel):
145
+ accepted: int
146
+ changes: int
147
+ to_review: list[str] = Field(description="The keys of the changes that nobody accepted yet.")
148
+ reviews: dict[str, Review]
149
+
150
+
151
+ class DescribeRequest(BaseModel):
152
+ title: str | None = Field(default=None, description="The new title; none keeps it, an empty text removes it.")
153
+ description: str | None = Field(default=None, description="The new description; none keeps it.")
154
+
155
+
156
+ def create_api(state: AppState, row_cache: LruCache[Any, Any]) -> FastAPI:
157
+ """The API application, mounted under PREFIX; its OpenAPI description is at PREFIX/openapi.json."""
158
+ api = FastAPI(
159
+ title="snapdelta API",
160
+ version=package_version("snapdelta-web"),
161
+ description="Take snapshots, compare them and read the diffs. With a token, send Authorization: Bearer TOKEN.",
162
+ docs_url=None,
163
+ redoc_url=None,
164
+ openapi_url="/openapi.json",
165
+ )
166
+
167
+ @api.exception_handler(SnapdeltaError)
168
+ async def snapdelta_error(request: Request, error: SnapdeltaError) -> JSONResponse:
169
+ return JSONResponse({"error": str(error)}, status_code=404 if isinstance(error, NotFoundError) else 400)
170
+
171
+ @api.exception_handler(Exception)
172
+ async def unexpected_error(request: Request, error: Exception) -> JSONResponse:
173
+ # The details stay in the server log: they can hold paths and data of the store.
174
+ return JSONResponse({"error": "unexpected error: see the log of snapdelta-web"}, status_code=500)
175
+
176
+ @api.get("/docs", include_in_schema=False)
177
+ def docs(request: Request) -> HTMLResponse:
178
+ # The Swagger UI files come from this server, so that the page works with no internet access.
179
+ return get_swagger_ui_html(
180
+ openapi_url=f"{request.scope.get('root_path', '')}/openapi.json",
181
+ title="snapdelta API",
182
+ swagger_js_url="/static/vendor/swagger-ui/swagger-ui-bundle.js",
183
+ swagger_css_url="/static/vendor/swagger-ui/swagger-ui.css",
184
+ swagger_favicon_url="/static/favicon.svg",
185
+ )
186
+
187
+ @api.get("/version")
188
+ def version() -> VersionInfo:
189
+ """The versions of snapdelta, the web UI, diffsync, Python and the plugins."""
190
+ return VersionInfo(
191
+ components=[Component(**vars(item)) for item in components(["snapdelta-web"])],
192
+ plugins=[Component(**vars(item)) for item in plugins()],
193
+ )
194
+
195
+ @api.get("/sources")
196
+ def sources() -> list[SourceInfo]:
197
+ """The installed sources."""
198
+ return [
199
+ SourceInfo(name=name, distribution=cls.distribution, fetches=cls.fetches)
200
+ for name, cls in sorted(source_classes().items())
201
+ ]
202
+
203
+ @api.get("/targets")
204
+ def targets(q: str = "") -> list[str]:
205
+ """The targets that have snapshots in the store, whose name contains q."""
206
+ return state.store.targets(q)
207
+
208
+ @api.get("/snapshots")
209
+ def snapshots(target: str) -> list[SnapshotInfo]:
210
+ """The snapshots of one target, newest first."""
211
+ return [
212
+ SnapshotInfo(
213
+ reference=entry.reference, target=entry.target, label=entry.label, captured_at=entry.captured_at
214
+ )
215
+ for entry in state.store.snapshot_entries(target)
216
+ ]
217
+
218
+ @api.get("/snapshots/{target}/{name}")
219
+ def snapshot(target: str, name: str) -> SnapshotFile:
220
+ """The whole snapshot document: metadata and data."""
221
+ return state.store.read_snapshot(state.store.resolve_snapshot(f"{target}/{name}"))
222
+
223
+ @api.post("/snapshots")
224
+ def take(request: SnapshotRequest) -> SnapshotResult:
225
+ """Fetch the targets, at the same time, and save a snapshot of each; the call returns when all are done."""
226
+ taken, failures = snapshot_targets(
227
+ state.store,
228
+ state.settings,
229
+ request.source,
230
+ request.targets,
231
+ request.groups,
232
+ request.all_targets,
233
+ request.label,
234
+ origin="api",
235
+ )
236
+ saved = {target: item.reference for target, item in taken.items()}
237
+ return SnapshotResult(ok=not failures, saved=saved, failed=failures)
238
+
239
+ @api.post("/diffs")
240
+ def run_diff(request: DiffRequest, include_output: bool = False) -> DiffResult:
241
+ """Compare two snapshots, or a snapshot and its target now; has_differences tells the result."""
242
+ done = diff_snapshots(
243
+ state.store,
244
+ state.settings,
245
+ request.left,
246
+ request.right,
247
+ live=request.live,
248
+ target=request.target,
249
+ models=request.models,
250
+ profile=request.profile,
251
+ title=request.title,
252
+ description=request.description,
253
+ save=request.save,
254
+ origin="api",
255
+ )
256
+ output, name = done.output, done.name
257
+ return DiffResult(
258
+ name=name,
259
+ url=f"/diffs/{name}" if name else None,
260
+ has_differences=output.has_differences(),
261
+ summary=output.summary,
262
+ left=output.left,
263
+ right=output.right,
264
+ title=output.title,
265
+ description=output.description,
266
+ output=output if include_output else None,
267
+ )
268
+
269
+ @api.get("/diffs")
270
+ def diffs(q: str = "", limit: Annotated[int, Query(ge=1, le=500)] = 50) -> list[DiffItem]:
271
+ """The newest saved diffs, whose left or right target contains q."""
272
+ return [
273
+ DiffItem.of(entry.name, state.store.load_output(entry.name))
274
+ for entry in state.store.output_entries(q)[:limit]
275
+ ]
276
+
277
+ @api.get("/diffs/{name}", response_model=None)
278
+ def diff(name: str, format: Literal["json", "text", "markdown", "csv"] = "json") -> Response:
279
+ """A saved diff: the output document, or the same text as `snapdelta output --format`."""
280
+ output = state.store.load_output(state.store.resolve_output(name))
281
+ if format == "json":
282
+ return Response(output.model_dump_json(by_alias=True), media_type="application/json")
283
+ chosen = renderer(format)
284
+ return PlainTextResponse(chosen.render(output), media_type=chosen.media_type)
285
+
286
+ @api.patch("/diffs/{name}")
287
+ def describe(name: str, request: DescribeRequest) -> DiffItem:
288
+ """Change the title or the description of a saved diff."""
289
+ resolved = state.store.resolve_output(name)
290
+ current = state.store.load_output(resolved)
291
+ title = current.title if request.title is None else request.title
292
+ description = current.description if request.description is None else request.description
293
+ output = state.store.describe_output(resolved, title, description)
294
+ row_cache.discard((state.store.location, resolved))
295
+ return DiffItem.of(resolved, output)
296
+
297
+ @api.get("/runs")
298
+ def runs(kind: str | None = None, limit: Annotated[int, Query(ge=1, le=500)] = 50) -> list[RunItem]:
299
+ """The newest runs (snapshot or diff), from the CLI, the web UI, the API and MCP."""
300
+ return [RunItem.of(run) for run in state.store.runs(limit, kind)]
301
+
302
+ @api.get("/runs/{run_id}")
303
+ def run(run_id: str) -> Run:
304
+ """One run, with the log messages of its loads."""
305
+ return state.store.load_run(run_id)
306
+
307
+ @api.get("/diffs/{name}/reviews")
308
+ def reviews(name: str) -> ReviewState:
309
+ """The accepted changes of a saved diff, and the ones still to review."""
310
+ return review_state(state.store.load_output(state.store.resolve_output(name)))
311
+
312
+ @api.post("/diffs/{name}/reviews")
313
+ def review(name: str, request: ReviewRequest) -> ReviewState:
314
+ """Accept one change of a saved diff after a review, or withdraw the acceptance."""
315
+ resolved = state.store.resolve_output(name)
316
+ given = new_review(request.by, request.comment) if request.accepted else None
317
+ output = state.store.review_output(resolved, request.key, given)
318
+ row_cache.discard((state.store.location, resolved))
319
+ return review_state(output)
320
+
321
+ return api
322
+
323
+
324
+ def review_state(output: Output) -> ReviewState:
325
+ accepted, changes = output.review_counts()
326
+ to_review = [key for key in output.change_keys() if key not in output.reviews]
327
+ return ReviewState(accepted=accepted, changes=changes, to_review=to_review, reviews=output.reviews)