@@ -43,33 +43,24 @@ def get_image(self) -> MyImageBlob:
4343"""
4444
4545from __future__ import annotations
46- from contextvars import ContextVar
4746import io
4847import os
49- import re
5048import shutil
5149from typing import (
52- Annotated ,
53- Callable ,
5450 Literal ,
5551 Mapping ,
5652 Optional ,
5753)
5854from weakref import WeakValueDictionary
59- from typing_extensions import TypeAlias
6055from tempfile import TemporaryDirectory
6156import uuid
6257
63- from fastapi import FastAPI , Depends , Request
58+ from fastapi import FastAPI
6459from fastapi .responses import FileResponse , Response
6560from pydantic import (
6661 BaseModel ,
67- create_model ,
6862 model_serializer ,
69- model_validator ,
7063)
71- from labthings_fastapi .dependencies .thing_server import find_thing_server
72- from starlette .exceptions import HTTPException
7364from typing_extensions import Self , Protocol , runtime_checkable
7465
7566
@@ -203,88 +194,25 @@ class Blob(BaseModel):
203194 documentation.
204195 """
205196
206- href : str
197+ href : str = "blob://local"
207198 """The URL where the data may be retrieved. This will be `blob://local`
208199 if the data is stored locally."""
209- media_type : str = "*/*"
210- """The MIME type of the data. This should be overridden in subclasses."""
211200 rel : Literal ["output" ] = "output"
212201 description : str = (
213202 "The output from this action is not serialised to JSON, so it must be "
214203 "retrieved as a file. This link will return the file."
215204 )
205+ media_type : str = "*/*"
206+ """The MIME type of the data. This should be overridden in subclasses."""
216207
217- _data : Optional [ServerSideBlobData ] = None
218- """This object holds the data, either in memory or as a file.
219-
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
208+ _data : ServerSideBlobData
209+ """This object holds the data, either in memory or as a file."""
256210
257211 @model_serializer (mode = "plain" , when_used = "always" )
258212 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
213+ """Serialise the Blob to a dictionary and make it downloadable"""
286214 return {
287- "href" : href ,
215+ "href" : self . href ,
288216 "media_type" : self .media_type ,
289217 "rel" : self .rel ,
290218 "description" : self .description ,
@@ -348,9 +276,8 @@ def open(self) -> io.IOBase:
348276 @classmethod
349277 def from_bytes (cls , data : bytes ) -> Self :
350278 """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 ()),
279+ return cls .model_construct (
280+ _data = BlobBytes (data , media_type = cls .default_media_type ())
354281 )
355282
356283 @classmethod
@@ -362,8 +289,7 @@ def from_temporary_directory(cls, folder: TemporaryDirectory, file: str) -> Self
362289 collected.
363290 """
364291 file_path = os .path .join (folder .name , file )
365- return cls .model_construct ( # type: ignore[return-value]
366- href = "blob://local" ,
292+ return cls .model_construct (
367293 _data = BlobFile (
368294 file_path ,
369295 media_type = cls .default_media_type (),
@@ -381,36 +307,15 @@ def from_file(cls, file: str) -> Self:
381307 temporary. If you are using temporary files, consider creating your
382308 Blob with `from_temporary_directory` instead.
383309 """
384- return cls .model_construct ( # type: ignore[return-value]
385- href = "blob://local" ,
386- _data = BlobFile (file , media_type = cls .default_media_type ()),
310+ return cls .model_construct (
311+ _data = BlobFile (file , media_type = cls .default_media_type ())
387312 )
388313
389314 def response (self ):
390315 """ "Return a suitable response for serving the output"""
391316 return self .data .response ()
392317
393318
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-
414319class BlobDataManager :
415320 """A class to manage BlobData objects
416321
@@ -452,59 +357,3 @@ def download_blob(self, blob_id: uuid.UUID):
452357 def attach_to_app (self , app : FastAPI ):
453358 """Attach the BlobDataManager to a FastAPI app"""
454359 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