blod.py 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166
  1. """Schema for Blobs and Blob Loaders.
  2. The goal is to facilitate decoupling of content loading from content parsing code.
  3. In addition, content loading code should provide a lazy loading interface by default.
  4. """
  5. from __future__ import annotations
  6. import contextlib
  7. import mimetypes
  8. from abc import ABC, abstractmethod
  9. from collections.abc import Generator, Iterable, Mapping
  10. from io import BufferedReader, BytesIO
  11. from pathlib import PurePath
  12. from typing import Any, Optional, Union
  13. from pydantic import BaseModel, root_validator
  14. PathLike = Union[str, PurePath]
  15. class Blob(BaseModel):
  16. """A blob is used to represent raw data by either reference or value.
  17. Provides an interface to materialize the blob in different representations, and
  18. help to decouple the development of data loaders from the downstream parsing of
  19. the raw data.
  20. Inspired by: https://developer.mozilla.org/en-US/docs/Web/API/Blob
  21. """
  22. data: Union[bytes, str, None] # Raw data
  23. mimetype: Optional[str] = None # Not to be confused with a file extension
  24. encoding: str = "utf-8" # Use utf-8 as default encoding, if decoding to string
  25. # Location where the original content was found
  26. # Represent location on the local file system
  27. # Useful for situations where downstream code assumes it must work with file paths
  28. # rather than in-memory content.
  29. path: Optional[PathLike] = None
  30. class Config:
  31. arbitrary_types_allowed = True
  32. frozen = True
  33. @property
  34. def source(self) -> Optional[str]:
  35. """The source location of the blob as string if known otherwise none."""
  36. return str(self.path) if self.path else None
  37. @root_validator(pre=True)
  38. def check_blob_is_valid(cls, values: Mapping[str, Any]) -> Mapping[str, Any]:
  39. """Verify that either data or path is provided."""
  40. if "data" not in values and "path" not in values:
  41. raise ValueError("Either data or path must be provided")
  42. return values
  43. def as_string(self) -> str:
  44. """Read data as a string."""
  45. if self.data is None and self.path:
  46. with open(str(self.path), encoding=self.encoding) as f:
  47. return f.read()
  48. elif isinstance(self.data, bytes):
  49. return self.data.decode(self.encoding)
  50. elif isinstance(self.data, str):
  51. return self.data
  52. else:
  53. raise ValueError(f"Unable to get string for blob {self}")
  54. def as_bytes(self) -> bytes:
  55. """Read data as bytes."""
  56. if isinstance(self.data, bytes):
  57. return self.data
  58. elif isinstance(self.data, str):
  59. return self.data.encode(self.encoding)
  60. elif self.data is None and self.path:
  61. with open(str(self.path), "rb") as f:
  62. return f.read()
  63. else:
  64. raise ValueError(f"Unable to get bytes for blob {self}")
  65. @contextlib.contextmanager
  66. def as_bytes_io(self) -> Generator[Union[BytesIO, BufferedReader], None, None]:
  67. """Read data as a byte stream."""
  68. if isinstance(self.data, bytes):
  69. yield BytesIO(self.data)
  70. elif self.data is None and self.path:
  71. with open(str(self.path), "rb") as f:
  72. yield f
  73. else:
  74. raise NotImplementedError(f"Unable to convert blob {self}")
  75. @classmethod
  76. def from_path(
  77. cls,
  78. path: PathLike,
  79. *,
  80. encoding: str = "utf-8",
  81. mime_type: Optional[str] = None,
  82. guess_type: bool = True,
  83. ) -> Blob:
  84. """Load the blob from a path like object.
  85. Args:
  86. path: path like object to file to be read
  87. encoding: Encoding to use if decoding the bytes into a string
  88. mime_type: if provided, will be set as the mime-type of the data
  89. guess_type: If True, the mimetype will be guessed from the file extension,
  90. if a mime-type was not provided
  91. Returns:
  92. Blob instance
  93. """
  94. if mime_type is None and guess_type:
  95. _mimetype = mimetypes.guess_type(path)[0] if guess_type else None
  96. else:
  97. _mimetype = mime_type
  98. # We do not load the data immediately, instead we treat the blob as a
  99. # reference to the underlying data.
  100. return cls(data=None, mimetype=_mimetype, encoding=encoding, path=path)
  101. @classmethod
  102. def from_data(
  103. cls,
  104. data: Union[str, bytes],
  105. *,
  106. encoding: str = "utf-8",
  107. mime_type: Optional[str] = None,
  108. path: Optional[str] = None,
  109. ) -> Blob:
  110. """Initialize the blob from in-memory data.
  111. Args:
  112. data: the in-memory data associated with the blob
  113. encoding: Encoding to use if decoding the bytes into a string
  114. mime_type: if provided, will be set as the mime-type of the data
  115. path: if provided, will be set as the source from which the data came
  116. Returns:
  117. Blob instance
  118. """
  119. return cls(data=data, mimetype=mime_type, encoding=encoding, path=path)
  120. def __repr__(self) -> str:
  121. """Define the blob representation."""
  122. str_repr = f"Blob {id(self)}"
  123. if self.source:
  124. str_repr += f" {self.source}"
  125. return str_repr
  126. class BlobLoader(ABC):
  127. """Abstract interface for blob loaders implementation.
  128. Implementer should be able to load raw content from a datasource system according
  129. to some criteria and return the raw content lazily as a stream of blobs.
  130. """
  131. @abstractmethod
  132. def yield_blobs(
  133. self,
  134. ) -> Iterable[Blob]:
  135. """A lazy loader for raw data represented by Blob object.
  136. Returns:
  137. A generator over blobs
  138. """