@@ -44,6 +44,8 @@ def get_image(self) -> MyImageBlob:
44
44
45
45
from __future__ import annotations
46
46
from contextvars import ContextVar
47
+ import traceback
48
+ import logging
47
49
import io
48
50
import os
49
51
import re
@@ -69,7 +71,6 @@ def get_image(self) -> MyImageBlob:
69
71
model_validator ,
70
72
)
71
73
from labthings_fastapi .dependencies .thing_server import find_thing_server
72
- from starlette .exceptions import HTTPException
73
74
from typing_extensions import Self , Protocol , runtime_checkable
74
75
75
76
@@ -187,6 +188,17 @@ def open(self) -> io.IOBase:
187
188
def response (self ) -> Response :
188
189
return FileResponse (self ._file_path , media_type = self .media_type )
189
190
191
+ class BlobRef (BaseModel ):
192
+ href : str
193
+ """The URL where the data may be retrieved. This will be `blob://local`
194
+ if the data is stored locally."""
195
+ rel : Literal ["output" ] = "output"
196
+ description : str = (
197
+ "The output from this action is not serialised to JSON, so it must be "
198
+ "retrieved as a file. This link will return the file."
199
+ )
200
+ media_type : str
201
+
190
202
191
203
class Blob (BaseModel ):
192
204
"""A container for binary data that may be retrieved over HTTP
@@ -202,89 +214,25 @@ class Blob(BaseModel):
202
214
`media_type` attribute, as this will propagate to the auto-generated
203
215
documentation.
204
216
"""
205
-
206
- href : str
217
+ href : str = "blob://local"
207
218
"""The URL where the data may be retrieved. This will be `blob://local`
208
219
if the data is stored locally."""
209
- media_type : str = "*/*"
210
- """The MIME type of the data. This should be overridden in subclasses."""
211
220
rel : Literal ["output" ] = "output"
212
221
description : str = (
213
222
"The output from this action is not serialised to JSON, so it must be "
214
223
"retrieved as a file. This link will return the file."
215
224
)
216
-
217
- _data : Optional [ServerSideBlobData ] = None
218
- """This object holds the data, either in memory or as a file.
225
+ media_type : str = "*/*"
226
+ """The MIME type of the data. This should be overridden in subclasses."""
219
227
220
- If `_data` is `None`, then the Blob has not been deserialised yet, and the
221
- `href` should point to a valid address where the data may be downloaded.
222
- """
223
-
224
- @model_validator (mode = "after" )
225
- def retrieve_data (self ):
226
- """Retrieve the data from the URL
227
-
228
- When a [`Blob`](#labthings_fastapi.outputs.blob.Blob) is created
229
- using its constructor, [`pydantic`](https://docs.pydantic.dev/latest/)
230
- will attempt to deserialise it by retrieving the data from the URL
231
- specified in `href`. Currently, this must be a URL pointing to a
232
- [`Blob`](#labthings_fastapi.outputs.blob.Blob) that already exists on
233
- this server.
234
-
235
- This validator will only work if the function to resolve URLs to
236
- [`BlobData`](#labthings_fastapi.outputs.blob.BlobData) objects
237
- has been set in the context variable
238
- [`url_to_blobdata_ctx`](#labthings_fastapi.outputs.blob.url_to_blobdata_ctx).
239
- This is done when actions are being invoked over HTTP by the
240
- [`BlobIOContextDep`](#labthings_fastapi.outputs.blob.BlobIOContextDep) dependency.
241
- """
242
- if self .href == "blob://local" :
243
- if self ._data :
244
- return self
245
- raise ValueError ("Blob objects must have data if the href is blob://local" )
246
- try :
247
- url_to_blobdata = url_to_blobdata_ctx .get ()
248
- self ._data = url_to_blobdata (self .href )
249
- self .href = "blob://local"
250
- except LookupError :
251
- raise LookupError (
252
- "Blobs may only be created from URLs passed in over HTTP."
253
- f"The URL in question was { self .href } ."
254
- )
255
- return self
228
+ _data : ServerSideBlobData
229
+ """This object holds the data, either in memory or as a file."""
256
230
257
231
@model_serializer (mode = "plain" , when_used = "always" )
258
232
def to_dict (self ) -> Mapping [str , str ]:
259
- """Serialise the Blob to a dictionary and make it downloadable
260
-
261
- When [`pydantic`](https://docs.pydantic.dev/latest/) serialises this object,
262
- it will call this method to convert it to a dictionary. There is a
263
- significant side-effect, which is that we will add the blob to the
264
- [`BlobDataManager`](#labthings_fastapi.outputs.blob.BlobDataManager) so it
265
- can be downloaded.
266
-
267
- This serialiser will only work if the function to assign URLs to
268
- [`BlobData`](#labthings_fastapi.outputs.blob.BlobData) objects
269
- has been set in the context variable
270
- [`blobdata_to_url_ctx`](#labthings_fastapi.outputs.blob.blobdata_to_url_ctx).
271
- This is done when actions are being returned over HTTP by the
272
- [`BlobIOContextDep`](#labthings_fastapi.outputs.blob.BlobIOContextDep) dependency.
273
- """
274
- if self .href == "blob://local" :
275
- try :
276
- blobdata_to_url = blobdata_to_url_ctx .get ()
277
- # MyPy seems to miss that `self.data` is a property, hence the ignore
278
- href = blobdata_to_url (self .data ) # type: ignore[arg-type]
279
- except LookupError :
280
- raise LookupError (
281
- "Blobs may only be serialised inside the "
282
- "context created by BlobIOContextDep."
283
- )
284
- else :
285
- href = self .href
233
+ """Serialise the Blob to a dictionary and make it downloadable"""
286
234
return {
287
- "href" : href ,
235
+ "href" : self . href ,
288
236
"media_type" : self .media_type ,
289
237
"rel" : self .rel ,
290
238
"description" : self .description ,
@@ -348,10 +296,7 @@ def open(self) -> io.IOBase:
348
296
@classmethod
349
297
def from_bytes (cls , data : bytes ) -> Self :
350
298
"""Create a BlobOutput from a bytes object"""
351
- return cls .model_construct ( # type: ignore[return-value]
352
- href = "blob://local" ,
353
- _data = BlobBytes (data , media_type = cls .default_media_type ()),
354
- )
299
+ return cls .model_construct (_data = BlobBytes (data , media_type = cls .default_media_type ()))
355
300
356
301
@classmethod
357
302
def from_temporary_directory (cls , folder : TemporaryDirectory , file : str ) -> Self :
@@ -362,9 +307,8 @@ def from_temporary_directory(cls, folder: TemporaryDirectory, file: str) -> Self
362
307
collected.
363
308
"""
364
309
file_path = os .path .join (folder .name , file )
365
- return cls .model_construct ( # type: ignore[return-value]
366
- href = "blob://local" ,
367
- _data = BlobFile (
310
+ return cls .model_construct (
311
+ _data = BlobFile (
368
312
file_path ,
369
313
media_type = cls .default_media_type (),
370
314
# Prevent the temporary directory from being cleaned up
@@ -381,36 +325,13 @@ def from_file(cls, file: str) -> Self:
381
325
temporary. If you are using temporary files, consider creating your
382
326
Blob with `from_temporary_directory` instead.
383
327
"""
384
- return cls .model_construct ( # type: ignore[return-value]
385
- href = "blob://local" ,
386
- _data = BlobFile (file , media_type = cls .default_media_type ()),
387
- )
328
+ return cls .model_construct (_data = BlobFile (file , media_type = cls .default_media_type ()))
388
329
389
330
def response (self ):
390
331
""" "Return a suitable response for serving the output"""
391
332
return self .data .response ()
392
333
393
334
394
- def blob_type (media_type : str ) -> type [Blob ]:
395
- """Create a BlobOutput subclass for a given media type
396
-
397
- This convenience function may confuse static type checkers, so it is usually
398
- clearer to make a subclass instead, e.g.:
399
-
400
- ```python
401
- class MyImageBlob(Blob):
402
- media_type = "image/png"
403
- ```
404
- """
405
- if "'" in media_type or "\\ " in media_type :
406
- raise ValueError ("media_type must not contain single quotes or backslashes" )
407
- return create_model (
408
- f"{ media_type .replace ('/' , '_' )} _blob" ,
409
- __base__ = Blob ,
410
- media_type = (eval (f"Literal[r'{ media_type } ']" ), media_type ),
411
- )
412
-
413
-
414
335
class BlobDataManager :
415
336
"""A class to manage BlobData objects
416
337
@@ -452,59 +373,3 @@ def download_blob(self, blob_id: uuid.UUID):
452
373
def attach_to_app (self , app : FastAPI ):
453
374
"""Attach the BlobDataManager to a FastAPI app"""
454
375
app .get ("/blob/{blob_id}" )(self .download_blob )
455
-
456
-
457
- blobdata_to_url_ctx = ContextVar [Callable [[ServerSideBlobData ], str ]]("blobdata_to_url" )
458
- """This context variable gives access to a function that makes BlobData objects
459
- downloadable, by assigning a URL and adding them to the
460
- [`BlobDataManager`](#labthings_fastapi.outputs.blob.BlobDataManager).
461
-
462
- It is only available within a
463
- [`blob_serialisation_context_manager`](#labthings_fastapi.outputs.blob.blob_serialisation_context_manager)
464
- because it requires access to the `BlobDataManager` and the `url_for` function
465
- from the FastAPI app.
466
- """
467
-
468
- url_to_blobdata_ctx = ContextVar [Callable [[str ], BlobData ]]("url_to_blobdata" )
469
- """This context variable gives access to a function that makes BlobData objects
470
- from a URL, by retrieving them from the
471
- [`BlobDataManager`](#labthings_fastapi.outputs.blob.BlobDataManager).
472
-
473
- It is only available within a
474
- [`blob_serialisation_context_manager`](#labthings_fastapi.outputs.blob.blob_serialisation_context_manager)
475
- because it requires access to the `BlobDataManager`.
476
- """
477
-
478
-
479
- async def blob_serialisation_context_manager (request : Request ):
480
- """Set context variables to allow blobs to be [de]serialised"""
481
- thing_server = find_thing_server (request .app )
482
- blob_manager : BlobDataManager = thing_server .blob_data_manager
483
- url_for = request .url_for
484
-
485
- def blobdata_to_url (blob : ServerSideBlobData ) -> str :
486
- blob_id = blob_manager .add_blob (blob )
487
- return str (url_for ("download_blob" , blob_id = blob_id ))
488
-
489
- def url_to_blobdata (url : str ) -> BlobData :
490
- m = re .search (r"blob/([0-9a-z\-]+)" , url )
491
- if not m :
492
- raise HTTPException (
493
- status_code = 404 , detail = "Could not find blob ID in href"
494
- )
495
- invocation_id = uuid .UUID (m .group (1 ))
496
- return blob_manager .get_blob (invocation_id )
497
-
498
- t1 = blobdata_to_url_ctx .set (blobdata_to_url )
499
- t2 = url_to_blobdata_ctx .set (url_to_blobdata )
500
- try :
501
- yield blob_manager
502
- finally :
503
- blobdata_to_url_ctx .reset (t1 )
504
- url_to_blobdata_ctx .reset (t2 )
505
-
506
-
507
- BlobIOContextDep : TypeAlias = Annotated [
508
- BlobDataManager , Depends (blob_serialisation_context_manager )
509
- ]
510
- """A dependency that enables `Blob`s to be serialised and deserialised."""
0 commit comments