tofscope 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. tofscope/__init__.py +1 -0
  2. tofscope/api/__init__.py +0 -0
  3. tofscope/api/app.py +549 -0
  4. tofscope/api/auth.py +82 -0
  5. tofscope/api/schemas.py +91 -0
  6. tofscope/application/__init__.py +0 -0
  7. tofscope/application/analyses.py +237 -0
  8. tofscope/application/cache.py +166 -0
  9. tofscope/application/commands.py +153 -0
  10. tofscope/application/explore.py +161 -0
  11. tofscope/application/identification.py +153 -0
  12. tofscope/application/identification_models.py +75 -0
  13. tofscope/application/jobs.py +347 -0
  14. tofscope/application/lifecycle.py +80 -0
  15. tofscope/application/models.py +74 -0
  16. tofscope/application/preparations.py +46 -0
  17. tofscope/application/resources.py +63 -0
  18. tofscope/application/saves.py +76 -0
  19. tofscope/application/signals.py +74 -0
  20. tofscope/application/spatial.py +78 -0
  21. tofscope/cli.py +157 -0
  22. tofscope/config.py +139 -0
  23. tofscope/presentation/__init__.py +0 -0
  24. tofscope/presentation/coordinates.py +31 -0
  25. tofscope/presentation/data.py +54 -0
  26. tofscope/presentation/explore.py +267 -0
  27. tofscope/presentation/exports.py +102 -0
  28. tofscope/presentation/isotopes.py +31 -0
  29. tofscope/presentation/volumes.py +210 -0
  30. tofscope/resources/__init__.py +0 -0
  31. tofscope/resources/reference/README.md +36 -0
  32. tofscope/resources/reference/isotopes.json +36901 -0
  33. tofscope/science/__init__.py +0 -0
  34. tofscope/science/abundance.py +42 -0
  35. tofscope/science/depth_calibration.py +73 -0
  36. tofscope/science/integration.py +149 -0
  37. tofscope/science/isotopes.py +90 -0
  38. tofscope/science/raw_peaks.py +50 -0
  39. tofscope/science/signals.py +114 -0
  40. tofscope/science/volume_layers.py +112 -0
  41. tofscope/static/assets/VtkScene-q-o3MPFv.js +5774 -0
  42. tofscope/static/assets/datasophos-wordmark.png +0 -0
  43. tofscope/static/assets/favicon.svg +5 -0
  44. tofscope/static/assets/index-CeV0NVRS.css +1 -0
  45. tofscope/static/assets/index-djLJCKw_.js +24 -0
  46. tofscope/static/index.html +14 -0
  47. tofscope/static/licenses/matplotlib.txt +99 -0
  48. tofscope/static/manifest.json +13 -0
  49. tofscope/storage/__init__.py +0 -0
  50. tofscope/storage/acquisitions.py +281 -0
  51. tofscope/storage/analysis_document.py +210 -0
  52. tofscope/storage/bounded_events.py +167 -0
  53. tofscope/storage/compact_copy.py +143 -0
  54. tofscope/storage/dataset_io.py +166 -0
  55. tofscope/storage/explorer.py +201 -0
  56. tofscope/storage/file_browser.py +41 -0
  57. tofscope/storage/identification_document.py +47 -0
  58. tofscope/storage/integration_store.py +140 -0
  59. tofscope/storage/native_saved_copy.py +252 -0
  60. tofscope/storage/raw_preparation.py +195 -0
  61. tofscope/storage/saved_files.py +331 -0
  62. tofscope/storage/vendor_export.py +114 -0
  63. tofscope-0.1.0.dist-info/METADATA +14 -0
  64. tofscope-0.1.0.dist-info/RECORD +68 -0
  65. tofscope-0.1.0.dist-info/WHEEL +5 -0
  66. tofscope-0.1.0.dist-info/entry_points.txt +2 -0
  67. tofscope-0.1.0.dist-info/licenses/LICENSE +21 -0
  68. tofscope-0.1.0.dist-info/top_level.txt +1 -0
tofscope/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
File without changes
tofscope/api/app.py ADDED
@@ -0,0 +1,549 @@
1
+ from contextlib import asynccontextmanager, ExitStack
2
+ from dataclasses import dataclass, asdict
3
+ import asyncio
4
+ from pathlib import Path
5
+ from fastapi import FastAPI, Request, Response, Depends, HTTPException, Query
6
+ from fastapi.exceptions import RequestValidationError
7
+ from fastapi.responses import JSONResponse, FileResponse, StreamingResponse
8
+ from starlette.exceptions import HTTPException as StarletteHTTPException
9
+ from starlette.staticfiles import StaticFiles
10
+ import json
11
+ from tofscope import __version__
12
+ from tofscope.storage.saved_files import SavedFile
13
+ from tofscope.storage.raw_preparation import PreparationPreview
14
+ from urllib.parse import quote
15
+ import os
16
+ from tofscope.application.saves import SaveService
17
+ from tofscope.application.analyses import AnalysisService, CapacityError, Conflict
18
+ from tofscope.application.explore import ExploreService
19
+ from tofscope.application.resources import ResourceBudget
20
+ from tofscope.application.cache import ArrayCache
21
+ from tofscope.application.jobs import JobService, JobSnapshot, IntegrationSpec
22
+ from tofscope.application.lifecycle import LifecycleService, ViewLease
23
+ from tofscope.application.identification_models import CandidateResult, MeasurementResult
24
+ from tofscope.application.models import AnalysisSnapshot
25
+ from tofscope.application.signals import image_signal
26
+ from tofscope.application.commands import CommandEnvelope
27
+ from tofscope.storage.acquisitions import (
28
+ AcquisitionRepository,
29
+ SourceChanged,
30
+ DatasetBusy,
31
+ )
32
+ from tofscope.storage.file_browser import list_data_files
33
+ from tofscope.api.auth import Auth
34
+ from tofscope.api.schemas import (
35
+ CandidateInput,
36
+ MeasureIsotopes,
37
+ Bootstrap,
38
+ SessionInfo,
39
+ OpenAcquisition,
40
+ PrepareAcquisition,
41
+ ReplaceAcquisition,
42
+ SubmitJob,
43
+ SaveCopy,
44
+ FolderLocations,
45
+ CreateSaveFolder,
46
+ )
47
+ from typing import Literal
48
+ from tofscope.presentation.data import ExploreData
49
+ from tofscope.presentation.explore import ExplorePresenter
50
+ from tofscope.presentation.exports import ExportService
51
+
52
+
53
+ @dataclass
54
+ class Services:
55
+ repository: AcquisitionRepository
56
+ analyses: AnalysisService
57
+ explore: ExploreService
58
+ cache: ArrayCache
59
+ jobs: JobService
60
+ lifecycle: LifecycleService
61
+ presenter: object = None
62
+ exports: object = None
63
+ saves: object = None
64
+ preparations: object = None
65
+ identification: object = None
66
+ volumes: object = None
67
+
68
+
69
+ def build_services(settings):
70
+ budget = ResourceBudget(settings)
71
+ repo = AcquisitionRepository(settings, budget=budget)
72
+ ws = AnalysisService(repo, settings)
73
+ explore = ExploreService(ws, repo)
74
+ cache = ArrayCache(repo, budget)
75
+
76
+ def prepare(snapshot):
77
+ with cache.load(
78
+ snapshot.owner, snapshot.id, snapshot.dataset, image_signal(snapshot).recipe, materialization_id=image_signal(snapshot).materialization_id
79
+ ):
80
+ pass
81
+
82
+ ws.prepare = prepare
83
+ jobs = JobService(ws, explore, cache, settings)
84
+ life = LifecycleService(ws, jobs, settings)
85
+ presenter = ExplorePresenter(ws, repo, cache, settings)
86
+ services = Services(
87
+ repo, ws, explore, cache, jobs, life, presenter, ExportService(presenter), SaveService(ws, jobs, cache, repo, settings)
88
+ )
89
+ from tofscope.application.preparations import PreparationService
90
+ services.preparations = PreparationService(repo, jobs, services.saves.files)
91
+ from tofscope.application.identification import IdentificationService
92
+ services.identification = IdentificationService(ws, explore, cache, jobs)
93
+ from tofscope.presentation.volumes import VolumePresenter
94
+ services.volumes = VolumePresenter(ws, repo, cache, settings)
95
+ return services
96
+
97
+
98
+ class ManagedResponse(Response):
99
+ def __init__(self, *args, reservation, **kwargs):
100
+ self.reservation = reservation
101
+ super().__init__(*args, **kwargs)
102
+
103
+ async def __call__(self, scope, receive, send):
104
+ try:
105
+ await super().__call__(scope, receive, send)
106
+ finally:
107
+ self.reservation.release()
108
+
109
+
110
+ def create_app(settings, services=None, bootstrap_secret=None):
111
+ @asynccontextmanager
112
+ async def lifespan(app):
113
+ import anyio.to_thread
114
+
115
+ anyio.to_thread.current_default_thread_limiter().total_tokens = 8
116
+ app.state.services = services or build_services(settings)
117
+
118
+ async def sweep():
119
+ while True:
120
+ await asyncio.sleep(5)
121
+ await anyio.to_thread.run_sync(app.state.services.lifecycle.sweep)
122
+
123
+ task = asyncio.create_task(sweep())
124
+ try:
125
+ yield
126
+ finally:
127
+ task.cancel()
128
+ await asyncio.gather(task, return_exceptions=True)
129
+ app.state.services.analyses.close_all()
130
+ app.state.services.jobs.shutdown()
131
+ app.state.services.cache.clear()
132
+
133
+ app = FastAPI(
134
+ title="ToFScope Explore",
135
+ version=__version__,
136
+ lifespan=lifespan,
137
+ docs_url=None,
138
+ redoc_url=None,
139
+ openapi_url="/api/v1/openapi.json",
140
+ )
141
+ app.state.auth = Auth(settings, bootstrap_secret)
142
+
143
+ def svc(request: Request):
144
+ return request.app.state.services
145
+
146
+ def owner(request: Request):
147
+ return app.state.auth.owner(request)
148
+
149
+ @app.middleware("http")
150
+ async def request_guard(request, call_next):
151
+ if request.method not in ("GET", "HEAD"):
152
+ if int(request.headers.get("content-length", "0")) > 1024 * 1024:
153
+ return JSONResponse(
154
+ {"code": "request_too_large", "message": "Request exceeds 1 MiB."},
155
+ 413,
156
+ )
157
+ body = bytearray()
158
+ async for chunk in request.stream():
159
+ body.extend(chunk)
160
+ if len(body) > 1024 * 1024:
161
+ return JSONResponse(
162
+ {
163
+ "code": "request_too_large",
164
+ "message": "Request exceeds 1 MiB.",
165
+ },
166
+ 413,
167
+ )
168
+ request._body = bytes(body)
169
+ result = await call_next(request)
170
+ result.headers["X-Content-Type-Options"] = "nosniff"
171
+ result.headers["Referrer-Policy"] = "no-referrer"
172
+ result.headers["Cache-Control"] = "no-store"
173
+ result.headers["Content-Security-Policy"] = (
174
+ "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self'; object-src 'none'; frame-ancestors 'none'"
175
+ )
176
+ return result
177
+
178
+ async def error(request, exc):
179
+ status = 422
180
+ code = "invalid_input"
181
+ message = "Invalid request; check the values and acquisition layout."
182
+ revision = None
183
+ if isinstance(exc, (KeyError, PermissionError)):
184
+ status = 404
185
+ code = "not_found"
186
+ message = "Resource unavailable."
187
+ elif isinstance(exc, CapacityError):
188
+ status = 429
189
+ code = "capacity"
190
+ message = str(exc)
191
+ elif isinstance(exc, Conflict):
192
+ status = 409
193
+ code = "revision_conflict"
194
+ message = str(exc)
195
+ revision = exc.revision
196
+ elif isinstance(exc, SourceChanged):
197
+ status = 409
198
+ code = "source_changed"
199
+ message = str(exc)
200
+ elif isinstance(exc, DatasetBusy):
201
+ status = 409
202
+ code = "dataset_busy"
203
+ message = str(exc)
204
+ elif isinstance(exc, StarletteHTTPException):
205
+ status = exc.status_code
206
+ code = {401: "unauthorized", 403: "forbidden", 404: "not_found"}.get(
207
+ status, "http_error"
208
+ )
209
+ message = str(exc.detail)
210
+ return JSONResponse(
211
+ {
212
+ "code": code,
213
+ "message": message,
214
+ "request_id": request.headers.get("x-request-id"),
215
+ "current_revision": revision,
216
+ },
217
+ status_code=status,
218
+ )
219
+
220
+ for kind in (
221
+ ValueError,
222
+ KeyError,
223
+ PermissionError,
224
+ RequestValidationError,
225
+ StarletteHTTPException,
226
+ ):
227
+ app.add_exception_handler(kind, error)
228
+
229
+ @app.get("/healthz")
230
+ async def health():
231
+ return {
232
+ "status": "ok",
233
+ "version": __version__,
234
+ "scope": "Explore-only",
235
+ "writes": False,
236
+ "original_writes": False,
237
+ "save_copies": True,
238
+ }
239
+
240
+ @app.get("/readyz")
241
+ async def ready():
242
+ if not (
243
+ Path(__file__).resolve().parents[1] / "static" / "manifest.json"
244
+ ).is_file():
245
+ raise HTTPException(503, "Frontend assets are unavailable.")
246
+ return {"ready": True, "version": __version__}
247
+
248
+ @app.post("/api/v1/auth/bootstrap", response_model=SessionInfo)
249
+ def bootstrap(body: Bootstrap, request: Request, response: Response):
250
+ return app.state.auth.exchange(request, body.secret, response)
251
+
252
+ @app.get("/api/v1/auth/session", response_model=SessionInfo)
253
+ def session(request: Request, o=Depends(owner)):
254
+ entry = app.state.auth.sessions.get(request.cookies.get("tofscope_session", ""))
255
+ return {"owner": o, "csrf": entry[1] if entry else ""}
256
+
257
+ @app.get('/api/v1/locations', response_model=FolderLocations)
258
+ def folder_locations(o=Depends(owner), s=Depends(svc)):
259
+ return FolderLocations(acquisitions=str(settings.data_root),
260
+ saved_copies=str(settings.output_root),
261
+ save_destination=str(s.saves.files.owner_path(o)),
262
+ can_save_to_acquisitions=settings.mode == 'local' and o == 'local')
263
+
264
+ @app.get('/api/v1/save-folders')
265
+ def save_folders(directory: str = '', location: Literal['acquisition', 'saved'] = 'saved',
266
+ query: str = '', offset: int = Query(0, ge=0), limit: int = Query(100, ge=1, le=100),
267
+ o=Depends(owner), s=Depends(svc)):
268
+ try:
269
+ return s.saves.files.browse(o, directory, location, query=query, offset=offset, limit=limit, folders_only=True)
270
+ except FileNotFoundError:
271
+ raise HTTPException(404, 'Save folder is unavailable.') from None
272
+
273
+ @app.post('/api/v1/save-folders', status_code=201)
274
+ def create_save_folder(body: CreateSaveFolder, o=Depends(owner), s=Depends(svc)):
275
+ try:
276
+ return s.saves.files.create_directory(o, body.directory, body.location, body.name)
277
+ except ValueError as exc:
278
+ raise HTTPException(422, str(exc)) from None
279
+ except FileNotFoundError:
280
+ raise HTTPException(404, 'Save folder is unavailable.') from None
281
+
282
+ @app.post('/api/v1/acquisitions/inspect', response_model=PreparationPreview)
283
+ def inspect_acquisition(body: OpenAcquisition, o=Depends(owner), s=Depends(svc)):
284
+ return s.preparations.inspect(o, body.relative_path, body.location)
285
+
286
+ @app.post('/api/v1/acquisitions/prepare', response_model=JobSnapshot, status_code=202)
287
+ def prepare_acquisition(body: PrepareAcquisition, o=Depends(owner), s=Depends(svc)):
288
+ try:
289
+ return s.preparations.submit(o, body.relative_path, body.location, body.generation, body.filename, accept_assumptions=body.accept_assumptions)
290
+ except FileExistsError:
291
+ raise HTTPException(409, 'Output already exists; choose a new name.') from None
292
+
293
+ @app.get('/api/v1/analyses', response_model=list[AnalysisSnapshot])
294
+ def open_files(o=Depends(owner), s=Depends(svc)):
295
+ return s.analyses.list(o)
296
+
297
+ @app.post('/api/v1/analyses', response_model=AnalysisSnapshot)
298
+ def open_analysis(body: OpenAcquisition, o=Depends(owner), s=Depends(svc)):
299
+ return s.analyses.create_analysis(o, s.repository.open(o, body.relative_path, body.location))
300
+
301
+ @app.get("/api/v1/analyses/{aid}", response_model=AnalysisSnapshot)
302
+ def analysis(aid: str, o=Depends(owner), s=Depends(svc)):
303
+ return s.analyses.get(o, aid)
304
+
305
+ @app.delete("/api/v1/analyses/{aid}", status_code=204)
306
+ def close(aid: str, expected_revision: int = Query(ge=0), o=Depends(owner), s=Depends(svc)):
307
+ s.analyses.close(o, aid, expected_revision)
308
+
309
+ @app.post("/api/v1/analyses/{aid}/replace", response_model=AnalysisSnapshot)
310
+ def replace_analysis(
311
+ aid: str, body: ReplaceAcquisition, o=Depends(owner), s=Depends(svc)
312
+ ):
313
+ s.analyses.get(o, aid)
314
+ e = s.analyses.entry(o, aid)
315
+ fingerprint = body.model_dump_json()
316
+ with e.lock:
317
+ previous = s.explore.validate_envelope(
318
+ e, body, fingerprint, check_source=False
319
+ )
320
+ if previous is not None:
321
+ return previous
322
+ result = s.analyses.replace(
323
+ o, aid, s.repository.open(o, body.relative_path, body.location), body.expected_revision
324
+ )
325
+ s.explore.receipt(e, body.request_id, fingerprint, result)
326
+ return result
327
+
328
+ @app.post("/api/v1/analyses/{aid}/commands", response_model=AnalysisSnapshot)
329
+ def command(aid: str, body: CommandEnvelope, o=Depends(owner), s=Depends(svc)):
330
+ return s.explore.apply(o, aid, body)
331
+
332
+ def managed_json(o, aid, services, prepare):
333
+ services.analyses.get(o, aid)
334
+ reservation = services.cache.budget.reserve(o, aid, settings.response_bytes)
335
+ try:
336
+ value = prepare()
337
+ chunks = bytearray()
338
+ for part in json.JSONEncoder(allow_nan=False, separators=(',', ':')).iterencode(asdict(value) if value is not None else None):
339
+ encoded = part.encode()
340
+ if len(chunks) + len(encoded) > settings.response_bytes:
341
+ raise CapacityError('Response exceeds configured limit; narrow the selected signals or tolerance.')
342
+ chunks.extend(encoded)
343
+ return ManagedResponse(bytes(chunks), media_type='application/json', reservation=reservation)
344
+ except BaseException:
345
+ reservation.release()
346
+ raise
347
+
348
+ @app.post('/api/v1/analyses/{aid}/candidates', response_model=CandidateResult)
349
+ def candidates(aid: str, body: CandidateInput, o=Depends(owner), s=Depends(svc)):
350
+ return managed_json(o, aid, s, lambda: s.identification.candidates(o, aid, body.mz, body.tolerance, body.unit, body.charges))
351
+
352
+ @app.post('/api/v1/analyses/{aid}/measurements', response_model=JobSnapshot, status_code=202)
353
+ def measure_isotopes(aid: str, body: MeasureIsotopes, o=Depends(owner), s=Depends(svc)):
354
+ return s.identification.measure(o, aid, body, body.signal_ids)
355
+
356
+ @app.get('/api/v1/analyses/{aid}/measurements', response_model=MeasurementResult | None)
357
+ def isotope_results(aid: str, o=Depends(owner), s=Depends(svc)):
358
+ return managed_json(o, aid, s, lambda: s.identification.result(o, aid))
359
+
360
+ @app.get('/api/v1/analyses/{aid}/isotopes.csv')
361
+ def isotope_export(aid: str, o=Depends(owner), s=Depends(svc)):
362
+ from tofscope.presentation.isotopes import isotope_csv
363
+ e = s.analyses.entry(o, aid)
364
+ reservation = s.cache.budget.reserve(o, aid, settings.response_bytes)
365
+ try:
366
+ with e.lock:
367
+ s.repository.assert_current(o, e.snapshot.dataset)
368
+ content = isotope_csv(e.snapshot)
369
+ if len(content) > settings.response_bytes:
370
+ raise ValueError('Export exceeds configured response limit.')
371
+ return ManagedResponse(content, media_type='text/csv', reservation=reservation,
372
+ headers={'Content-Disposition': 'attachment; filename="tofscope-isotopes.csv"'})
373
+ except BaseException:
374
+ reservation.release()
375
+ raise
376
+
377
+ @app.post(
378
+ "/api/v1/analyses/{aid}/jobs", response_model=JobSnapshot, status_code=202
379
+ )
380
+ def job(aid: str, body: SubmitJob, o=Depends(owner), s=Depends(svc)):
381
+ return s.jobs.submit(
382
+ o,
383
+ aid,
384
+ body,
385
+ IntegrationSpec(
386
+ tuple(body.spec.intervals), tuple(body.spec.frames), body.spec.name,
387
+ body.spec.signal_id, body.spec.expected_recipe_version, body.spec.candidate_id, body.spec.measured_mz
388
+ ),
389
+ )
390
+
391
+ @app.post('/api/v1/analyses/{aid}/saves', response_model=JobSnapshot, status_code=202)
392
+ def save_copy(aid: str, body: SaveCopy, o=Depends(owner), s=Depends(svc)):
393
+ return s.saves.submit(o, aid, body, body.filename)
394
+
395
+ @app.get('/api/v1/saved-files', response_model=list[SavedFile])
396
+ def saved_files(o=Depends(owner), s=Depends(svc)):
397
+ return s.saves.files.list(o)
398
+
399
+ @app.get('/api/v1/saved-files/{fid}/download')
400
+ def download_saved(fid: str, o=Depends(owner), s=Depends(svc)):
401
+ try:
402
+ path = s.saves.files.path(o, fid)
403
+ except FileNotFoundError:
404
+ raise KeyError('Saved file unavailable.') from None
405
+ expected = path.stat()
406
+ reservation = s.cache.budget.reserve(o, 'download:' + fid, 128 * 1024)
407
+ stream = None
408
+ try:
409
+ descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
410
+ stream = os.fdopen(descriptor, 'rb')
411
+ actual = os.fstat(descriptor)
412
+ if (expected.st_dev, expected.st_ino, expected.st_size, expected.st_mtime_ns) != (actual.st_dev, actual.st_ino, actual.st_size, actual.st_mtime_ns):
413
+ raise SourceChanged('Saved file changed before download.')
414
+ def chunks():
415
+ while chunk := stream.read(64 * 1024):
416
+ yield chunk
417
+ class DownloadResponse(StreamingResponse):
418
+ async def __call__(self, scope, receive, send):
419
+ try:
420
+ await super().__call__(scope, receive, send)
421
+ finally:
422
+ stream.close()
423
+ reservation.release()
424
+ return DownloadResponse(chunks(), media_type='application/x-hdf5', headers={
425
+ 'Content-Length': str(actual.st_size),
426
+ 'Content-Disposition': "attachment; filename*=UTF-8''" + quote(path.name),
427
+ })
428
+ except BaseException:
429
+ if stream:
430
+ stream.close()
431
+ reservation.release()
432
+ raise
433
+
434
+ @app.get("/api/v1/jobs/{jid}", response_model=JobSnapshot)
435
+ def job_status(jid: str, o=Depends(owner), s=Depends(svc)):
436
+ return s.jobs.get(o, jid)
437
+
438
+ @app.post("/api/v1/jobs/{jid}/cancel", response_model=JobSnapshot)
439
+ def cancel(jid: str, o=Depends(owner), s=Depends(svc)):
440
+ return s.jobs.cancel(o, jid)
441
+
442
+ @app.post("/api/v1/analyses/{aid}/views", response_model=ViewLease)
443
+ def attach(aid: str, o=Depends(owner), s=Depends(svc)):
444
+ return s.lifecycle.attach(o, aid)
445
+
446
+ @app.post("/api/v1/views/{vid}/heartbeat", response_model=ViewLease)
447
+ def heartbeat(vid: str, o=Depends(owner), s=Depends(svc)):
448
+ return s.lifecycle.heartbeat(o, vid)
449
+
450
+ @app.delete("/api/v1/views/{vid}", status_code=204)
451
+ def detach(vid: str, o=Depends(owner), s=Depends(svc)):
452
+ s.lifecycle.detach(o, vid)
453
+
454
+ @app.get("/api/v1/files")
455
+ def files(
456
+ directory: str = "",
457
+ location: Literal["acquisition", "saved"] = "acquisition",
458
+ query: str = "",
459
+ folders: bool = False,
460
+ offset: int = Query(0, ge=0),
461
+ limit: int = Query(100, ge=1, le=100),
462
+ o=Depends(owner),
463
+ s=Depends(svc),
464
+ ):
465
+ if location == 'saved':
466
+ if folders or directory:
467
+ try:
468
+ return s.saves.files.browse(o, directory, query=query, offset=offset, limit=limit)
469
+ except FileNotFoundError:
470
+ raise HTTPException(404, 'Save folder is unavailable.') from None
471
+ values = [f for f in s.saves.files.list(o) if query.lower() in f.name.lower()]
472
+ entries = [dict(name=f.name, path=f.id, kind='file', size=f.size) for f in values[offset:offset + limit]]
473
+ return dict(path='', entries=entries, total=len(values), next_offset=offset + limit if offset + limit < len(values) else None)
474
+ return list_data_files(
475
+ settings, directory, query=query, offset=offset, limit=limit
476
+ )
477
+
478
+ from tofscope.presentation.volumes import VolumeData
479
+
480
+ @app.get('/api/v1/analyses/{aid}/volumes', response_model=VolumeData)
481
+ def volumes(aid: str, o=Depends(owner), s=Depends(svc)):
482
+ s.analyses.get(o, aid)
483
+ reservation = s.cache.budget.reserve(o, aid, settings.response_bytes)
484
+ try:
485
+ with ExitStack() as preparation:
486
+ snapshot = s.volumes.snapshot(o, aid, preparation=preparation)
487
+ content = json.dumps(snapshot.to_dict(), allow_nan=False, separators=(',', ':')).encode()
488
+ return ManagedResponse(content, media_type='application/json', reservation=reservation)
489
+ except BaseException:
490
+ reservation.release()
491
+ raise
492
+
493
+ @app.get("/api/v1/analyses/{aid}/data", response_model=ExploreData)
494
+ def data(
495
+ aid: str,
496
+ panels: str = "ion,front,spectrum,profile",
497
+ coordinates: Literal["frame"] | None = None,
498
+ o=Depends(owner),
499
+ s=Depends(svc),
500
+ ):
501
+ s.analyses.get(o, aid)
502
+ reservation = s.cache.budget.reserve(o, aid, settings.response_bytes)
503
+ try:
504
+ snapshot = s.presenter.snapshot(o, aid, set(panels.split(",")), coordinates=coordinates)
505
+ content = json.dumps(
506
+ snapshot.to_dict(), allow_nan=False, separators=(",", ":")
507
+ ).encode()
508
+ return ManagedResponse(
509
+ content,
510
+ media_type="application/json",
511
+ reservation=reservation,
512
+ )
513
+ except BaseException:
514
+ reservation.release()
515
+ raise
516
+
517
+ @app.get("/api/v1/analyses/{aid}/exports/{format}")
518
+ def export(
519
+ aid: str, format: Literal["csv", "json"], o=Depends(owner), s=Depends(svc)
520
+ ):
521
+ s.analyses.get(o, aid)
522
+ reservation = s.cache.budget.reserve(o, aid, settings.response_bytes)
523
+ try:
524
+ d = s.exports.render(o, aid, format)
525
+ return ManagedResponse(
526
+ d.content,
527
+ media_type=d.media_type,
528
+ headers={"Content-Disposition": f'attachment; filename="{d.filename}"'},
529
+ reservation=reservation,
530
+ )
531
+ except BaseException:
532
+ reservation.release()
533
+ raise
534
+
535
+ @app.get("/api/v1/{unknown:path}", include_in_schema=False)
536
+ def unknown_api(unknown: str):
537
+ raise HTTPException(404, "API route unavailable.")
538
+
539
+ static = Path(__file__).resolve().parents[1] / "static"
540
+ if (static / "assets").is_dir():
541
+ app.mount("/assets", StaticFiles(directory=static / "assets"), name="assets")
542
+
543
+ @app.get("/", include_in_schema=False)
544
+ def index():
545
+ if not (static / "index.html").exists():
546
+ raise HTTPException(503, "Build frontend assets first.")
547
+ return FileResponse(static / "index.html")
548
+
549
+ return app
tofscope/api/auth.py ADDED
@@ -0,0 +1,82 @@
1
+ import hmac
2
+ import secrets
3
+ import time
4
+ from urllib.parse import urlsplit
5
+ from fastapi import HTTPException, Request
6
+
7
+
8
+ class Auth:
9
+ def __init__(self, settings, bootstrap_secret=None):
10
+ self.settings = settings
11
+ self.bootstrap_secret = bootstrap_secret or secrets.token_urlsafe(32)
12
+ self.sessions = {}
13
+
14
+ def origin(self, request):
15
+ if self.settings.public_origin:
16
+ return self.settings.public_origin.rstrip("/")
17
+ host = "localhost" if self.settings.host == "localhost" else self.settings.host
18
+ return f"http://{host}:{self.settings.port}"
19
+
20
+ def validate_origin(self, request, mutation):
21
+ expected = self.origin(request)
22
+ if request.headers.get("host") != urlsplit(expected).netloc:
23
+ raise HTTPException(403, "Unexpected host.")
24
+ origin = request.headers.get("origin")
25
+ if origin is not None and origin != expected:
26
+ raise HTTPException(403, "Cross-origin request denied.")
27
+ if mutation and origin != expected:
28
+ raise HTTPException(403, "Same-origin requests require an Origin header.")
29
+
30
+ def owner(self, request: Request):
31
+ mutation = request.method not in ("GET", "HEAD", "OPTIONS")
32
+ self.validate_origin(request, mutation)
33
+ if self.settings.mode == "proxy":
34
+ peer = request.client.host if request.client else ""
35
+ if peer not in self.settings.trusted_proxies:
36
+ raise HTTPException(401, "Untrusted proxy.")
37
+ owner = request.headers.get(self.settings.identity_header, "").strip()
38
+ if not owner or len(owner) > 256 or any(ord(c) < 32 for c in owner):
39
+ raise HTTPException(401, "Authenticated identity required.")
40
+ return owner
41
+ if request.headers.get(self.settings.identity_header):
42
+ raise HTTPException(401, "Proxy identity unavailable in local mode.")
43
+ token = request.cookies.get("tofscope_session", "")
44
+ entry = self.sessions.get(token)
45
+ if entry is None or entry[2] < time.monotonic():
46
+ raise HTTPException(401, "Open this service with the local launcher.")
47
+ owner, csrf, _ = entry
48
+ if mutation and not hmac.compare_digest(
49
+ request.headers.get("x-csrf-token", ""), csrf
50
+ ):
51
+ raise HTTPException(403, "CSRF token required.")
52
+ return owner
53
+
54
+ def exchange(self, request, secret, response):
55
+ self.validate_origin(request, True)
56
+ if (
57
+ self.settings.mode != "local"
58
+ or self.bootstrap_secret is None
59
+ or not hmac.compare_digest(secret, self.bootstrap_secret)
60
+ ):
61
+ raise HTTPException(401, "Invalid or consumed bootstrap.")
62
+ self.bootstrap_secret = None
63
+ token = secrets.token_urlsafe(32)
64
+ csrf = secrets.token_urlsafe(32)
65
+ self.sessions[token] = ("local", csrf, time.monotonic() + 86400)
66
+ response.set_cookie(
67
+ "tofscope_session",
68
+ token,
69
+ httponly=True,
70
+ samesite="strict",
71
+ secure=self.origin(request).startswith("https:"),
72
+ max_age=86400,
73
+ )
74
+ return {
75
+ "owner": "local",
76
+ "csrf": csrf,
77
+ "initial_file": str(
78
+ self.settings.initial_file.relative_to(self.settings.data_root)
79
+ )
80
+ if self.settings.initial_file
81
+ else None,
82
+ }