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.
- tofscope/__init__.py +1 -0
- tofscope/api/__init__.py +0 -0
- tofscope/api/app.py +549 -0
- tofscope/api/auth.py +82 -0
- tofscope/api/schemas.py +91 -0
- tofscope/application/__init__.py +0 -0
- tofscope/application/analyses.py +237 -0
- tofscope/application/cache.py +166 -0
- tofscope/application/commands.py +153 -0
- tofscope/application/explore.py +161 -0
- tofscope/application/identification.py +153 -0
- tofscope/application/identification_models.py +75 -0
- tofscope/application/jobs.py +347 -0
- tofscope/application/lifecycle.py +80 -0
- tofscope/application/models.py +74 -0
- tofscope/application/preparations.py +46 -0
- tofscope/application/resources.py +63 -0
- tofscope/application/saves.py +76 -0
- tofscope/application/signals.py +74 -0
- tofscope/application/spatial.py +78 -0
- tofscope/cli.py +157 -0
- tofscope/config.py +139 -0
- tofscope/presentation/__init__.py +0 -0
- tofscope/presentation/coordinates.py +31 -0
- tofscope/presentation/data.py +54 -0
- tofscope/presentation/explore.py +267 -0
- tofscope/presentation/exports.py +102 -0
- tofscope/presentation/isotopes.py +31 -0
- tofscope/presentation/volumes.py +210 -0
- tofscope/resources/__init__.py +0 -0
- tofscope/resources/reference/README.md +36 -0
- tofscope/resources/reference/isotopes.json +36901 -0
- tofscope/science/__init__.py +0 -0
- tofscope/science/abundance.py +42 -0
- tofscope/science/depth_calibration.py +73 -0
- tofscope/science/integration.py +149 -0
- tofscope/science/isotopes.py +90 -0
- tofscope/science/raw_peaks.py +50 -0
- tofscope/science/signals.py +114 -0
- tofscope/science/volume_layers.py +112 -0
- tofscope/static/assets/VtkScene-q-o3MPFv.js +5774 -0
- tofscope/static/assets/datasophos-wordmark.png +0 -0
- tofscope/static/assets/favicon.svg +5 -0
- tofscope/static/assets/index-CeV0NVRS.css +1 -0
- tofscope/static/assets/index-djLJCKw_.js +24 -0
- tofscope/static/index.html +14 -0
- tofscope/static/licenses/matplotlib.txt +99 -0
- tofscope/static/manifest.json +13 -0
- tofscope/storage/__init__.py +0 -0
- tofscope/storage/acquisitions.py +281 -0
- tofscope/storage/analysis_document.py +210 -0
- tofscope/storage/bounded_events.py +167 -0
- tofscope/storage/compact_copy.py +143 -0
- tofscope/storage/dataset_io.py +166 -0
- tofscope/storage/explorer.py +201 -0
- tofscope/storage/file_browser.py +41 -0
- tofscope/storage/identification_document.py +47 -0
- tofscope/storage/integration_store.py +140 -0
- tofscope/storage/native_saved_copy.py +252 -0
- tofscope/storage/raw_preparation.py +195 -0
- tofscope/storage/saved_files.py +331 -0
- tofscope/storage/vendor_export.py +114 -0
- tofscope-0.1.0.dist-info/METADATA +14 -0
- tofscope-0.1.0.dist-info/RECORD +68 -0
- tofscope-0.1.0.dist-info/WHEEL +5 -0
- tofscope-0.1.0.dist-info/entry_points.txt +2 -0
- tofscope-0.1.0.dist-info/licenses/LICENSE +21 -0
- tofscope-0.1.0.dist-info/top_level.txt +1 -0
tofscope/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
tofscope/api/__init__.py
ADDED
|
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
|
+
}
|