sqlalchemy-file 0.4.0__tar.gz → 0.5.0__tar.gz

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.
@@ -14,3 +14,4 @@ site
14
14
  .cache
15
15
  __pycache__/
16
16
  .pytest_cache/
17
+ upload_dir/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: sqlalchemy-file
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: SQLAlchemy-file is a SQLAlchemy extension for attaching files to SQLAlchemy model and uploading them to various storage.
5
5
  Project-URL: Homepage, https://jowilf.github.io/sqlalchemy-file
6
6
  Project-URL: Documentation, https://jowilf.github.io/sqlalchemy-file
@@ -30,24 +30,24 @@ Requires-Dist: apache-libcloud<3.8,>=3.6
30
30
  Requires-Dist: sqlalchemy<2.1,>=1.4
31
31
  Provides-Extra: dev
32
32
  Requires-Dist: pre-commit<4.0.0,>=2.20.0; extra == 'dev'
33
- Requires-Dist: uvicorn<0.21.0,>=0.20.0; extra == 'dev'
33
+ Requires-Dist: uvicorn<0.22.0,>=0.20.0; extra == 'dev'
34
34
  Provides-Extra: doc
35
35
  Requires-Dist: mkdocs-material<10.0.0,>=9.0.0; extra == 'doc'
36
- Requires-Dist: mkdocstrings[python]<0.21.0,>=0.19.0; extra == 'doc'
36
+ Requires-Dist: mkdocstrings[python]<0.23.0,>=0.19.0; extra == 'doc'
37
37
  Provides-Extra: test
38
- Requires-Dist: black==23.1.0; extra == 'test'
38
+ Requires-Dist: black==23.7.0; extra == 'test'
39
39
  Requires-Dist: coverage<7.3.0,>=7.0.0; extra == 'test'
40
- Requires-Dist: fastapi<0.93,>=0.92; extra == 'test'
40
+ Requires-Dist: fastapi<0.96,>=0.92; extra == 'test'
41
41
  Requires-Dist: fasteners==0.18; extra == 'test'
42
42
  Requires-Dist: flask-sqlalchemy<3.1,>=3.0; extra == 'test'
43
43
  Requires-Dist: flask<2.3,>=2.2; extra == 'test'
44
- Requires-Dist: mypy==1.0.1; extra == 'test'
45
- Requires-Dist: pillow<9.5.0,>=9.4.0; extra == 'test'
44
+ Requires-Dist: mypy==1.4.1; extra == 'test'
45
+ Requires-Dist: pillow<10.1.0,>=9.4.0; extra == 'test'
46
46
  Requires-Dist: psycopg2-binary<3.0.0,>=2.9.5; extra == 'test'
47
47
  Requires-Dist: pymysql[rsa]<1.1.0,>=1.0.2; extra == 'test'
48
- Requires-Dist: pytest<7.3.0,>=7.2.0; extra == 'test'
48
+ Requires-Dist: pytest<7.5.0,>=7.2.0; extra == 'test'
49
49
  Requires-Dist: python-multipart==0.0.6; extra == 'test'
50
- Requires-Dist: ruff==0.0.253; extra == 'test'
50
+ Requires-Dist: ruff==0.0.278; extra == 'test'
51
51
  Description-Content-Type: text/markdown
52
52
 
53
53
  # sqlalchemy-file
@@ -153,8 +153,8 @@ class Attachment(Base):
153
153
 
154
154
 
155
155
  # Configure Storage
156
- os.makedirs("/tmp/storage/attachment", 0o777, exist_ok=True)
157
- container = LocalStorageDriver("/tmp/storage").get_container("attachment")
156
+ os.makedirs("./upload_dir/attachment", 0o777, exist_ok=True)
157
+ container = LocalStorageDriver("./upload_dir").get_container("attachment")
158
158
  StorageManager.add_storage("default", container)
159
159
 
160
160
  # Save your model
@@ -163,8 +163,8 @@ engine = create_engine(
163
163
  )
164
164
  Base.metadata.create_all(engine)
165
165
 
166
- with Session(engine) as session:
167
- session.add(Attachment(name="attachment1", content=open("./example.txt", "rb")))
166
+ with Session(engine) as session, open("./example.txt", "rb") as local_file:
167
+ session.add(Attachment(name="attachment1", content=local_file))
168
168
  session.add(Attachment(name="attachment2", content=b"Hello world"))
169
169
  session.add(Attachment(name="attachment3", content="Hello world"))
170
170
  file = File(content="Hello World", filename="hello.txt", content_type="text/plain")
@@ -101,8 +101,8 @@ class Attachment(Base):
101
101
 
102
102
 
103
103
  # Configure Storage
104
- os.makedirs("/tmp/storage/attachment", 0o777, exist_ok=True)
105
- container = LocalStorageDriver("/tmp/storage").get_container("attachment")
104
+ os.makedirs("./upload_dir/attachment", 0o777, exist_ok=True)
105
+ container = LocalStorageDriver("./upload_dir").get_container("attachment")
106
106
  StorageManager.add_storage("default", container)
107
107
 
108
108
  # Save your model
@@ -111,8 +111,8 @@ engine = create_engine(
111
111
  )
112
112
  Base.metadata.create_all(engine)
113
113
 
114
- with Session(engine) as session:
115
- session.add(Attachment(name="attachment1", content=open("./example.txt", "rb")))
114
+ with Session(engine) as session, open("./example.txt", "rb") as local_file:
115
+ session.add(Attachment(name="attachment1", content=local_file))
116
116
  session.add(Attachment(name="attachment2", content=b"Hello world"))
117
117
  session.add(Attachment(name="attachment3", content="Hello world"))
118
118
  file = File(content="Hello World", filename="hello.txt", content_type="text/plain")
@@ -43,27 +43,27 @@ Changelog = "https://jowilf.github.io/sqlalchemy-file/changelog/"
43
43
 
44
44
  [project.optional-dependencies]
45
45
  test = [
46
- "pytest >=7.2.0, <7.3.0",
47
- "mypy ==1.0.1",
48
- "ruff ==0.0.253",
49
- "black ==23.1.0",
46
+ "pytest >=7.2.0, <7.5.0",
47
+ "mypy ==1.4.1",
48
+ "ruff ==0.0.278",
49
+ "black ==23.7.0",
50
50
  "coverage >=7.0.0, <7.3.0",
51
51
  "fasteners ==0.18",
52
52
  "PyMySQL[rsa] >=1.0.2, <1.1.0",
53
53
  "psycopg2-binary >=2.9.5, <3.0.0",
54
- "Pillow >=9.4.0, <9.5.0",
54
+ "Pillow >=9.4.0, <10.1.0",
55
55
  "python-multipart ==0.0.6",
56
- "fastapi >=0.92, <0.93",
56
+ "fastapi >=0.92, <0.96",
57
57
  "Flask >=2.2, <2.3",
58
58
  "Flask-SQLAlchemy >=3.0,<3.1"
59
59
  ]
60
60
  doc = [
61
61
  "mkdocs-material >=9.0.0, <10.0.0",
62
- "mkdocstrings[python] >=0.19.0, <0.21.0"
62
+ "mkdocstrings[python] >=0.19.0, <0.23.0"
63
63
  ]
64
64
  dev = [
65
65
  "pre-commit >=2.20.0, <4.0.0",
66
- "uvicorn >=0.20.0, <0.21.0",
66
+ "uvicorn >=0.20.0, <0.22.0",
67
67
  ]
68
68
 
69
69
  [tool.hatch.envs.default]
@@ -73,7 +73,7 @@ features = [
73
73
  ]
74
74
  [tool.hatch.envs.default.scripts]
75
75
  format = [
76
- "ruff sqlalchemy_file tests docs_src --fix",
76
+ "ruff sqlalchemy_file tests --fix",
77
77
  "black ."
78
78
  ]
79
79
 
@@ -135,18 +135,39 @@ source = ["sqlalchemy_file", "tests"]
135
135
 
136
136
  [tool.ruff]
137
137
  select = [
138
+ "B", # flake8-bugbear
139
+ "C4", # flake8-comprehensions
140
+ "C90", # mccabe
141
+ "D", # pydocstyle
138
142
  "E", # pycodestyle errors
139
- "W", # pycodestyle warnings
143
+ "ERA", # eradicate
140
144
  "F", # pyflakes
141
145
  "I", # isort
142
- "C", # flake8-comprehensions
143
- "B", # flake8-bugbear
146
+ "N", # pep8-naming
147
+ "PIE", # flake8-pie,
148
+ "PLC", # pylint - convention
149
+ "PLE", # pylint - error
150
+ "PLW", # pylint - warning
151
+ "Q", # flake8-quotes
152
+ "RET", # flake8-return,
153
+ "RUF", # Ruff-specific rules
154
+ "SIM", # flake8-simplify
155
+ "UP", # pyupgrade
156
+ "W", # pycodestyle warnings
144
157
  ]
145
- ignore = ["E501", "B904", "B008"]
158
+ ignore = ["E501", "B904", "B008", "D100", "D101", "D102", "D103", "D104", "D105", "D106", "D107", "D205"]
159
+ target-version = "py38"
160
+
161
+ [tool.ruff.pydocstyle]
162
+ convention = "google"
146
163
 
147
164
  [tool.ruff.isort]
148
165
  known-third-party = ["sqlalchemy_file"]
149
166
 
167
+ [tool.ruff.per-file-ignores]
168
+ "__init__.py" = ["F401"]
169
+ "docs_src/**" = ["N999"]
170
+
150
171
  [tool.mypy]
151
172
  strict = true
152
173
  warn_unused_ignores = false
@@ -0,0 +1,4 @@
1
+ __version__ = "0.5.0"
2
+
3
+ from .file import File
4
+ from .types import FileField, ImageField
@@ -3,8 +3,9 @@ from typing import Any
3
3
 
4
4
 
5
5
  class BaseFile(typing.Dict[str, Any]):
6
- """
7
- Base class for file object. It keeps information on a content related to a specific storage.
6
+ """Base class for file object.
7
+
8
+ It keeps information on a content related to a specific storage.
8
9
  It is a specialized dictionary that provides also attribute style access,
9
10
  the dictionary parent permits easy encoding/decoding to JSON.
10
11
 
@@ -2,11 +2,11 @@ class ValidationError(Exception):
2
2
  """Base class for ValidationError
3
3
  Parameters:
4
4
  key: Current Column key
5
- msg: Validation error message
5
+ msg: Validation error message.
6
6
  """
7
7
 
8
8
  def __init__(self, key: str, msg: str): # pragma: no cover
9
- super().__init__("{}: {}".format(key, msg))
9
+ super().__init__(f"{key}: {msg}")
10
10
  self.key = key
11
11
  self.msg = msg
12
12
 
@@ -17,15 +17,13 @@ from sqlalchemy_file.validators import Validator
17
17
 
18
18
 
19
19
  class File(BaseFile):
20
- """
21
- Takes a file as content and uploads it to the appropriate storage
20
+ """Takes a file as content and uploads it to the appropriate storage
22
21
  according to the attached Column and file information into the
23
22
  database as JSON.
24
23
 
25
24
  Default attributes provided for all ``File`` include:
26
25
 
27
26
  Attributes:
28
-
29
27
  filename (str): This is the name of the uploaded file
30
28
  file_id: This is the generated UUID for the uploaded file
31
29
  upload_storage: Name of the storage used to save the uploaded file
@@ -69,7 +67,7 @@ class File(BaseFile):
69
67
  self._thaw()
70
68
 
71
69
  def apply_validators(self, validators: List[Validator], key: str = "") -> None:
72
- """Apply validators to current file"""
70
+ """Apply validators to current file."""
73
71
  for validator in validators:
74
72
  validator.process(self, key)
75
73
 
@@ -78,13 +76,13 @@ class File(BaseFile):
78
76
  processors: List[Processor],
79
77
  upload_storage: Optional[str] = None,
80
78
  ) -> None:
81
- """Apply processors to current file"""
79
+ """Apply processors to current file."""
82
80
  for processor in processors:
83
81
  processor.process(self, upload_storage)
84
82
  self._freeze()
85
83
 
86
84
  def save_to_storage(self, upload_storage: Optional[str] = None) -> None:
87
- """Save current file into provided `upload_storage`"""
85
+ """Save current file into provided `upload_storage`."""
88
86
  extra = self.get("extra", {})
89
87
  extra.update({"content_type": self.content_type})
90
88
 
@@ -93,6 +91,7 @@ class File(BaseFile):
93
91
  warnings.warn(
94
92
  'metadata attribute is deprecated. Use extra={"meta_data": ...} instead',
95
93
  DeprecationWarning,
94
+ stacklevel=1,
96
95
  )
97
96
  extra.update({"meta_data": metadata})
98
97
 
@@ -111,7 +110,7 @@ class File(BaseFile):
111
110
  self["file_id"] = stored_file.name
112
111
  self["upload_storage"] = upload_storage
113
112
  self["uploaded_at"] = datetime.utcnow().isoformat()
114
- self["path"] = "{}/{}".format(upload_storage, stored_file.name)
113
+ self["path"] = f"{upload_storage}/{stored_file.name}"
115
114
  self["url"] = stored_file.get_cdn_url()
116
115
  self["saved"] = True
117
116
 
@@ -125,7 +124,7 @@ class File(BaseFile):
125
124
  headers: Optional[Dict[str, str]] = None,
126
125
  ) -> StoredFile:
127
126
  """Store content into provided `upload_storage`
128
- with additional `metadata`. Can be use by processors
127
+ with additional `metadata`. Can be used by processors
129
128
  to store additional files.
130
129
  """
131
130
  name = name or str(uuid.uuid4())
@@ -137,7 +136,7 @@ class File(BaseFile):
137
136
  extra=extra,
138
137
  headers=headers,
139
138
  )
140
- self["files"].append("{}/{}".format(upload_storage, name))
139
+ self["files"].append(f"{upload_storage}/{name}")
141
140
  return stored_file
142
141
 
143
142
  def encode(self) -> Dict[str, Any]:
@@ -4,7 +4,7 @@ import os
4
4
  import re
5
5
  from builtins import RuntimeError
6
6
  from tempfile import SpooledTemporaryFile
7
- from typing import Any, Dict, Union
7
+ from typing import Any, Dict, List, Union
8
8
 
9
9
  INMEMORY_FILESIZE = 1024 * 1024
10
10
  LOCAL_STORAGE_DRIVER_NAME = "Local Storage"
@@ -18,7 +18,8 @@ def get_metadata_file_obj(metadata: Dict[str, Any]) -> "SpooledTemporaryFile[byt
18
18
 
19
19
 
20
20
  def get_content_from_file_obj(fileobj: Any) -> Any:
21
- """Provides a real file object from file content
21
+ """Provides a real file object from file content.
22
+
22
23
  Converts ``str`` and ``bytes`` to an actual file.
23
24
  """
24
25
  if isinstance(fileobj, (str, bytes)):
@@ -26,7 +27,7 @@ def get_content_from_file_obj(fileobj: Any) -> Any:
26
27
  f.write(fileobj.encode() if isinstance(fileobj, str) else fileobj)
27
28
  f.seek(0)
28
29
  return f
29
- elif getattr(fileobj, "file", None) is not None:
30
+ if getattr(fileobj, "file", None) is not None:
30
31
  return fileobj.file
31
32
  return fileobj
32
33
 
@@ -34,7 +35,7 @@ def get_content_from_file_obj(fileobj: Any) -> Any:
34
35
  def get_filename_from_fileob(fileobj: Any) -> Any:
35
36
  if getattr(fileobj, "filename", None) is not None:
36
37
  return fileobj.filename
37
- elif getattr(fileobj, "name", None) is not None:
38
+ if getattr(fileobj, "name", None) is not None:
38
39
  return os.path.basename(fileobj.name)
39
40
  return "unnamed"
40
41
 
@@ -64,9 +65,7 @@ def get_content_size_from_fileobj(file: Any) -> Any:
64
65
 
65
66
  def convert_size(size: Union[str, int]) -> int:
66
67
  # convert size to number of bytes ex: 1k -> 1000; 1Ki->1024
67
- if isinstance(size, int):
68
- return size
69
- elif isinstance(size, str):
68
+ if isinstance(size, str):
70
69
  pattern = re.compile(r"^(\d+)\s*(k|([KM]i?))$")
71
70
  m = re.fullmatch(pattern, size)
72
71
  if m is None:
@@ -74,3 +73,9 @@ def convert_size(size: Union[str, int]) -> int:
74
73
  value, si, _ = m.groups()
75
74
  si_map = {"k": 1000, "K": 1000, "M": 1000**2, "Ki": 1024, "Mi": 1024**2}
76
75
  return int(value) * si_map[si]
76
+ return size
77
+
78
+
79
+ def flatmap(lists: List[List[Any]]) -> List[Any]:
80
+ """Flattens a list of lists into a single list."""
81
+ return [value for _list in lists for value in _list]
@@ -7,8 +7,7 @@ T = TypeVar("T", bound=Any)
7
7
 
8
8
 
9
9
  class MutableList(Mutable, typing.List[T]):
10
- """
11
- A list type that implements :class:`Mutable`.
10
+ """A list type that implements :class:`Mutable`.
12
11
 
13
12
  The :class:`MutableList` object implements a list that will
14
13
  emit change events to the underlying mapping when the contents of
@@ -32,8 +31,7 @@ class MutableList(Mutable, typing.List[T]):
32
31
  return MutableList(value)
33
32
  # this call will raise ValueError
34
33
  return Mutable.coerce(key, value) # pragma: no cover
35
- else:
36
- return value # pragma: no cover
34
+ return value # pragma: no cover
37
35
 
38
36
  @no_type_check
39
37
  def __reduce_ex__(self, proto): # pragma: no cover
@@ -50,20 +48,14 @@ class MutableList(Mutable, typing.List[T]):
50
48
 
51
49
  def __setitem__(self, index: Any, value: Any) -> None:
52
50
  """Detect list set events and emit change events."""
53
- if isinstance(index, slice):
54
- old_value = self[index]
55
- else:
56
- old_value = [self[index]]
51
+ old_value = self[index] if isinstance(index, slice) else [self[index]]
57
52
  list.__setitem__(self, index, value)
58
53
  self.changed()
59
54
  self._removed.extend(old_value)
60
55
 
61
56
  def __delitem__(self, index: Any) -> None:
62
57
  """Detect list del events and emit change events."""
63
- if isinstance(index, slice):
64
- old_value = self[index]
65
- else:
66
- old_value = [self[index]]
58
+ old_value = self[index] if isinstance(index, slice) else [self[index]]
67
59
  list.__delitem__(self, index)
68
60
  self.changed()
69
61
  self._removed.extend(old_value)
@@ -10,8 +10,7 @@ if TYPE_CHECKING:
10
10
 
11
11
 
12
12
  class Processor:
13
- """
14
- Interface that must be implemented by file processors.
13
+ """Interface that must be implemented by file processors.
15
14
  Can be used to add additional data to the stored file or change it.
16
15
  When file processors are run the file has already been stored.
17
16
 
@@ -21,21 +20,18 @@ class Processor:
21
20
  def process(
22
21
  self, file: "File", upload_storage: Optional[str] = None
23
22
  ) -> None: # pragma: no cover
24
- """
25
- Should be overridden in inherited class
23
+ """Should be overridden in inherited class
26
24
  Parameters:
27
25
  file: [File][sqlalchemy_file.file.File] object,
28
26
  Use file.original_content to access uploaded file
29
27
  upload_storage: pass this to
30
28
  [file.store_content()][sqlalchemy_file.file.File.store_content]
31
- to attach additional files to the original file
29
+ to attach additional files to the original file.
32
30
  """
33
- pass
34
31
 
35
32
 
36
33
  class ThumbnailGenerator(Processor):
37
- """
38
- Generate thumbnail from original content.
34
+ """Generate thumbnail from original content.
39
35
 
40
36
  The default thumbnail format and size are `PNG@128x128`, those can be changed
41
37
  by giving custom `thumbnail_size` and `thumbnail_format`
@@ -124,7 +120,7 @@ class ThumbnailGenerator(Processor):
124
120
  "height": height,
125
121
  }
126
122
  )
127
- extra.update({"meta_data": metadata})
123
+ extra.update({"content_type": content_type, "meta_data": metadata})
128
124
  stored_file = file.store_content(
129
125
  output, upload_storage, extra=extra, headers=file.get("headers", None)
130
126
  )
@@ -135,7 +131,7 @@ class ThumbnailGenerator(Processor):
135
131
  "width": width,
136
132
  "height": height,
137
133
  "upload_storage": upload_storage,
138
- "path": "{}/{}".format(upload_storage, stored_file.name),
134
+ "path": f"{upload_storage}/{stored_file.name}",
139
135
  "url": stored_file.get_cdn_url(),
140
136
  }
141
137
  }
@@ -1,5 +1,6 @@
1
+ import contextlib
1
2
  import warnings
2
- from typing import Any, Dict, Iterator, Optional
3
+ from typing import Any, ClassVar, Dict, Iterator, Optional
3
4
 
4
5
  from libcloud.storage.base import Container
5
6
  from libcloud.storage.types import ObjectDoesNotExistError
@@ -8,8 +9,7 @@ from sqlalchemy_file.stored_file import StoredFile
8
9
 
9
10
 
10
11
  class StorageManager:
11
- """
12
- Takes care of managing the whole Storage environment for the application.
12
+ """Takes care of managing the whole Storage environment for the application.
13
13
 
14
14
  Use [add_storage][sqlalchemy_file.storage.StorageManager.add_storage] method
15
15
  to add new `libcloud.storage.base.Container`and associate a name which
@@ -23,46 +23,45 @@ class StorageManager:
23
23
 
24
24
  """
25
25
 
26
- _default_storage_name: Optional[str] = None
27
- _storages: Dict[str, Container] = {}
26
+ _default_storage_name: ClassVar[Optional[str]] = None
27
+ _storages: ClassVar[Dict[str, Container]] = {}
28
28
 
29
29
  @classmethod
30
30
  def set_default(cls, name: str) -> None:
31
- """Replaces the current application default storage"""
31
+ """Replaces the current application default storage."""
32
32
  if name not in cls._storages:
33
- raise RuntimeError("{} storage has not been added".format(name))
33
+ raise RuntimeError(f"{name} storage has not been added")
34
34
  cls._default_storage_name = name
35
35
 
36
36
  @classmethod
37
37
  def get_default(cls) -> str:
38
- """Gets the current application default storage"""
38
+ """Gets the current application default storage."""
39
39
  if cls._default_storage_name is None:
40
40
  raise RuntimeError("No default storage has been added")
41
41
  return cls._default_storage_name
42
42
 
43
43
  @classmethod
44
44
  def add_storage(cls, name: str, container: Container) -> None:
45
- """Add new storage"""
45
+ """Add new storage."""
46
46
  assert isinstance(container, Container), "Invalid container"
47
47
  if name in cls._storages:
48
- raise RuntimeError("Storage {} has already been added".format(name))
48
+ raise RuntimeError(f"Storage {name} has already been added")
49
49
  if cls._default_storage_name is None:
50
50
  cls._default_storage_name = name
51
51
  cls._storages[name] = container
52
52
 
53
53
  @classmethod
54
54
  def get(cls, name: Optional[str] = None) -> Container:
55
- """
56
- Gets the container instance associate to the name,
57
- return default if name isn't provided
55
+ """Gets the container instance associate to the name,
56
+ return default if name isn't provided.
58
57
  """
59
58
  if name is None and cls._default_storage_name is None:
60
59
  raise RuntimeError("No default storage have been added")
61
- elif name is None:
60
+ if name is None:
62
61
  name = cls._default_storage_name
63
62
  if name in cls._storages:
64
63
  return cls._storages[name]
65
- raise RuntimeError("{} storage has not been added".format(name))
64
+ raise RuntimeError(f"{name} storage has not been added")
66
65
 
67
66
  @classmethod
68
67
  def save_file(
@@ -78,6 +77,7 @@ class StorageManager:
78
77
  warnings.warn(
79
78
  'metadata attribute is deprecated. Use extra={"meta_data": ...} instead',
80
79
  DeprecationWarning,
80
+ stacklevel=1,
81
81
  )
82
82
  extra = {
83
83
  "meta_data": metadata,
@@ -110,30 +110,28 @@ class StorageManager:
110
110
  @classmethod
111
111
  def get_file(cls, path: str) -> StoredFile:
112
112
  """Retrieve the file with `provided` path,
113
- path is expected to be `storage_name/file_id`
113
+ path is expected to be `storage_name/file_id`.
114
114
  """
115
115
  upload_storage, file_id = path.split("/")
116
116
  return StoredFile(StorageManager.get(upload_storage).get_object(file_id))
117
117
 
118
118
  @classmethod
119
119
  def delete_file(cls, path: str) -> bool:
120
- """Delete the file with `provided` path
121
- path is expected to be `storage_name/file_id`
120
+ """Delete the file with `provided` path.
121
+
122
+ The path is expected to be `storage_name/file_id`.
122
123
  """
123
124
  upload_storage, file_id = path.split("/")
124
125
  obj = StorageManager.get(upload_storage).get_object(file_id)
125
126
  if obj.driver.name == LOCAL_STORAGE_DRIVER_NAME:
126
127
  """Try deleting associated metadata file"""
127
- try:
128
+ with contextlib.suppress(ObjectDoesNotExistError):
128
129
  obj.container.get_object(f"{obj.name}.metadata.json").delete()
129
- except ObjectDoesNotExistError:
130
- pass
130
+
131
131
  return obj.delete()
132
132
 
133
133
  @classmethod
134
134
  def _clear(cls) -> None:
135
- """
136
- This is only for testing pourposes, resets the StorageManager
137
- """
135
+ """This is only for testing pourposes, resets the StorageManager."""
138
136
  cls._default_storage_name = None
139
137
  cls._storages = {}
@@ -0,0 +1,71 @@
1
+ import io
2
+ import json
3
+ from typing import Optional
4
+
5
+ from libcloud.storage.base import Object
6
+ from libcloud.storage.types import ObjectDoesNotExistError
7
+ from sqlalchemy_file.helpers import LOCAL_STORAGE_DRIVER_NAME
8
+
9
+
10
+ class StoredFile(io.IOBase):
11
+ """Represents a file that has been stored in a database. This class provides
12
+ a file-like interface for reading the file content.
13
+
14
+ Attributes:
15
+ name: The name of the file (Same as file_id).
16
+ filename: The original name of the uploaded file.
17
+ content_type: The content type of the uploaded file.
18
+ object: The `Object` representing the file in the storage service.
19
+ """
20
+
21
+ def __init__(self, obj: Object) -> None:
22
+ if obj.driver.name == LOCAL_STORAGE_DRIVER_NAME:
23
+ """Retrieve metadata from associated metadata file"""
24
+ try:
25
+ metadata_obj = obj.container.get_object(f"{obj.name}.metadata.json")
26
+ with open(metadata_obj.get_cdn_url()) as metadata_file:
27
+ obj.meta_data = json.load(metadata_file)
28
+ except ObjectDoesNotExistError:
29
+ pass
30
+ self.name = obj.name
31
+ self.filename = obj.meta_data.get("filename", "unnamed")
32
+ self.content_type = obj.extra.get(
33
+ "content_type",
34
+ obj.meta_data.get("content_type", "application/octet-stream"),
35
+ )
36
+ self.object = obj
37
+
38
+ def get_cdn_url(self) -> Optional[str]:
39
+ """Retrieves the CDN URL of the file if available."""
40
+ try:
41
+ return self.object.get_cdn_url()
42
+ except NotImplementedError:
43
+ return None
44
+
45
+ def read(self, n: int = -1, chunk_size: Optional[int] = None) -> bytes:
46
+ """Reads the content of the file.
47
+
48
+ Arguments:
49
+ n: The number of bytes to read. If not specified or set to -1,
50
+ it reads the entire content of the file. Defaults to -1.
51
+ chunk_size: The size of the chunks to read at a time.
52
+ If not specified, the default chunk size of the storage provider will be used.
53
+
54
+ """
55
+ return next(
56
+ self.object.range_as_stream(
57
+ 0, end_bytes=n if n > 0 else None, chunk_size=chunk_size
58
+ )
59
+ )
60
+
61
+ def close(self) -> None:
62
+ pass # No need to close;
63
+
64
+ def seekable(self) -> bool:
65
+ return False # Seeking is not supported ; pragma: no cover
66
+
67
+ def writable(self) -> bool:
68
+ return False # Writing is not supported ; pragma: no cover
69
+
70
+ def readable(self) -> bool:
71
+ return True # Reading is supported ; pragma: no cover
@@ -1,10 +1,11 @@
1
- from typing import Any, Dict, List, Optional, Set, Tuple, Type, Union
1
+ from typing import Any, ClassVar, Dict, List, Optional, Set, Tuple, Type, Union
2
2
 
3
3
  from sqlalchemy import event, inspect, orm, types
4
4
  from sqlalchemy.engine import Connection, Dialect
5
5
  from sqlalchemy.orm import ColumnProperty, Mapper, Session, SessionTransaction
6
6
  from sqlalchemy.orm.attributes import get_history
7
7
  from sqlalchemy_file.file import File
8
+ from sqlalchemy_file.helpers import flatmap
8
9
  from sqlalchemy_file.mutable_list import MutableList
9
10
  from sqlalchemy_file.processors import Processor, ThumbnailGenerator
10
11
  from sqlalchemy_file.storage import StorageManager
@@ -12,9 +13,7 @@ from sqlalchemy_file.validators import ImageValidator, Validator
12
13
 
13
14
 
14
15
  class FileField(types.TypeDecorator): # type: ignore
15
-
16
- """
17
- Provides support for storing attachments to **SQLAlchemy** models.
16
+ """Provides support for storing attachments to **SQLAlchemy** models.
18
17
 
19
18
  [FileField][sqlalchemy_file.types.FileField] can be used as a Column type to
20
19
  store files into the model. The actual file itself will be uploaded to a specific
@@ -34,7 +33,7 @@ class FileField(types.TypeDecorator): # type: ignore
34
33
  """
35
34
 
36
35
  impl = types.JSON
37
- cache_ok = True
36
+ cache_ok = False
38
37
 
39
38
  def __init__(
40
39
  self,
@@ -48,18 +47,17 @@ class FileField(types.TypeDecorator): # type: ignore
48
47
  headers: Optional[Dict[str, str]] = None,
49
48
  **kwargs: Dict[str, Any],
50
49
  ) -> None:
51
- """
52
- Parameters:
53
- upload_storage: storage to use
54
- validators: List of validators to apply
55
- processors: List of validators to apply
56
- upload_type: File class to use, could be
57
- used to set custom File class
58
- multiple: Use this to save multiple files
59
- extra: Extra attributes (driver specific)
60
- headers: Additional request headers,
61
- such as CORS headers. For example:
62
- headers = {'Access-Control-Allow-Origin': 'http://mozilla.com'}
50
+ """Parameters:
51
+ upload_storage: storage to use
52
+ validators: List of validators to apply
53
+ processors: List of validators to apply
54
+ upload_type: File class to use, could be
55
+ used to set custom File class
56
+ multiple: Use this to save multiple files
57
+ extra: Extra attributes (driver specific)
58
+ headers: Additional request headers,
59
+ such as CORS headers. For example:
60
+ headers = {'Access-Control-Allow-Origin': 'http://mozilla.com'}.
63
61
  """
64
62
  super().__init__(*args, **kwargs)
65
63
  if processors is None:
@@ -85,7 +83,7 @@ class FileField(types.TypeDecorator): # type: ignore
85
83
  raise ValueError(f"Expected {self.upload_type}, received: {type(value)}")
86
84
  if self.multiple and not (
87
85
  isinstance(value, list)
88
- and all([isinstance(v, self.upload_type) for v in value])
86
+ and all(isinstance(v, self.upload_type) for v in value)
89
87
  ): # pragma: no cover
90
88
  raise ValueError(
91
89
  f"Expected MutableList[{self.upload_type}], received: {type(value)}"
@@ -111,7 +109,7 @@ class ImageField(FileField):
111
109
  but also validates that the uploaded object is a valid image.
112
110
  """
113
111
 
114
- cache_ok = True
112
+ cache_ok = False
115
113
 
116
114
  def __init__(
117
115
  self,
@@ -127,20 +125,19 @@ class ImageField(FileField):
127
125
  headers: Optional[Dict[str, str]] = None,
128
126
  **kwargs: Dict[str, Any],
129
127
  ) -> None:
130
- """
131
- Parameters:
132
- upload_storage: storage to use
133
- image_validator: ImageField use default image
134
- validator, Use this property to customize it.
135
- thumbnail_size: If set, a thumbnail will be generated
136
- from original image using [ThumbnailGenerator]
137
- [sqlalchemy_file.processors.ThumbnailGenerator]
138
- validators: List of additional validators to apply
139
- processors: List of validators to apply
140
- upload_type: File class to use, could be
141
- used to set custom File class
142
- multiple: Use this to save multiple files
143
- extra: Extra attributes (driver specific)
128
+ """Parameters
129
+ upload_storage: storage to use
130
+ image_validator: ImageField use default image
131
+ validator, Use this property to customize it.
132
+ thumbnail_size: If set, a thumbnail will be generated
133
+ from original image using [ThumbnailGenerator]
134
+ [sqlalchemy_file.processors.ThumbnailGenerator]
135
+ validators: List of additional validators to apply
136
+ processors: List of validators to apply
137
+ upload_type: File class to use, could be
138
+ used to set custom File class
139
+ multiple: Use this to save multiple files
140
+ extra: Extra attributes (driver specific).
144
141
  """
145
142
  if validators is None:
146
143
  validators = []
@@ -165,7 +162,7 @@ class ImageField(FileField):
165
162
 
166
163
 
167
164
  class FileFieldSessionTracker:
168
- mapped_entities: Dict[Type[Any], List[str]] = {}
165
+ mapped_entities: ClassVar[Dict[Type[Any], List[str]]] = {}
169
166
 
170
167
  @classmethod
171
168
  def delete_files(cls, paths: Set[str], ctx: str) -> None:
@@ -194,14 +191,14 @@ class FileFieldSessionTracker:
194
191
  paths = []
195
192
  for item in data:
196
193
  if isinstance(item, list):
197
- paths.extend([f["path"] for f in item])
194
+ paths.extend([f["files"] for f in item])
198
195
  elif isinstance(item, File):
199
- paths.append(item["path"])
200
- return paths
196
+ paths.append(item["files"])
197
+ return flatmap(paths)
201
198
 
202
199
  @classmethod
203
200
  def _mapper_configured(cls, mapper: Mapper, class_: Any) -> None: # type: ignore[type-arg]
204
- """Detect and listen all class with FileField Column"""
201
+ """Detect and listen all class with FileField Column."""
205
202
  for mapper_property in mapper.iterate_properties:
206
203
  if isinstance(mapper_property, ColumnProperty) and isinstance(
207
204
  mapper_property.columns[0].type, FileField
@@ -217,7 +214,7 @@ class FileFieldSessionTracker:
217
214
 
218
215
  @classmethod
219
216
  def _after_configured(cls) -> None:
220
- for entity in cls.mapped_entities.keys():
217
+ for entity in cls.mapped_entities:
221
218
  event.listen(entity, "before_insert", cls._before_insert)
222
219
  event.listen(entity, "before_update", cls._before_update)
223
220
  event.listen(entity, "after_update", cls._after_update)
@@ -225,21 +222,20 @@ class FileFieldSessionTracker:
225
222
 
226
223
  @classmethod
227
224
  def _after_commit(cls, session: Session) -> None:
228
- """After commit, old files are automatically deleted"""
225
+ """After commit, old files are automatically deleted."""
229
226
  cls.delete_files(getattr(session, "_old_files", set()), "after_commit")
230
227
  cls.clear_session(session)
231
228
 
232
229
  @classmethod
233
230
  def _after_soft_rollback(cls, session: Session, _: SessionTransaction) -> None:
234
- """After rollback, new files are automatically deleted"""
231
+ """After rollback, new files are automatically deleted."""
235
232
  cls.delete_files(getattr(session, "_new_files", set()), "after_soft_rollback")
236
233
  cls.clear_session(session)
237
234
 
238
235
  @classmethod
239
236
  def _after_delete(cls, mapper: Mapper, _: Connection, obj: Any) -> None: # type: ignore[type-arg]
240
- """
241
- After delete mark all linked files as old in order to delete
242
- them when after session is committed
237
+ """After delete mark all linked files as old in order to delete
238
+ them when after session is committed.
243
239
  """
244
240
  tracked_columns: List[str] = cls.mapped_entities.get(mapper.class_, [])
245
241
  for key in tracked_columns:
@@ -247,17 +243,18 @@ class FileFieldSessionTracker:
247
243
  if value is not None:
248
244
  cls.add_old_files_to_session(
249
245
  inspect(obj).session,
250
- [
251
- f["path"]
252
- for f in (value if isinstance(value, list) else [value])
253
- ],
246
+ flatmap(
247
+ [
248
+ f["files"]
249
+ for f in (value if isinstance(value, list) else [value])
250
+ ]
251
+ ),
254
252
  )
255
253
 
256
254
  @classmethod
257
255
  def _after_update(cls, mapper: Mapper, _: Connection, obj: Any) -> None: # type: ignore[type-arg]
258
- """
259
- After update, mark all edited files as old
260
- in order to delete them when after session is committed
256
+ """After update, mark all edited files as old
257
+ in order to delete them when after session is committed.
261
258
  """
262
259
  tracked_columns: List[str] = cls.mapped_entities.get(mapper.class_, [])
263
260
  for key in tracked_columns:
@@ -268,8 +265,7 @@ class FileFieldSessionTracker:
268
265
 
269
266
  @classmethod
270
267
  def _before_update(cls, mapper: Mapper, _: Connection, obj: Any) -> None: # type: ignore[type-arg]
271
- """
272
- Before updating values, validate and save files. For multiple fields,
268
+ """Before updating values, validate and save files. For multiple fields,
273
269
  mark all removed files as old, as _removed attribute will be
274
270
  reinitialised after update.
275
271
  """
@@ -287,13 +283,15 @@ class FileFieldSessionTracker:
287
283
  )
288
284
  if isinstance(value, MutableList):
289
285
  _removed = getattr(value, "_removed", ())
290
- cls.add_old_files_to_session(session, [f["path"] for f in _removed])
286
+ cls.add_old_files_to_session(
287
+ session, flatmap([f["files"] for f in _removed])
288
+ )
291
289
 
292
290
  @classmethod
293
291
  def _before_insert(cls, mapper: Mapper, _: Connection, obj: Any) -> None: # type: ignore[type-arg]
294
292
  """Before inserting values, mark all created files as new. They will be
295
- automatically removed when session rollback"""
296
-
293
+ automatically removed when session rollback.
294
+ """
297
295
  tracked_columns: List[str] = cls.mapped_entities.get(mapper.class_, [])
298
296
  for key in tracked_columns:
299
297
  value = getattr(obj, key)
@@ -308,14 +306,27 @@ class FileFieldSessionTracker:
308
306
  def prepare_file_attr(
309
307
  cls, mapper: Mapper, obj: Any, key: str # type: ignore[type-arg]
310
308
  ) -> Tuple[bool, Union[File, List[File]]]:
311
- """
312
- Prepare file before saved to database, convert bytes and string,
313
- saved file into the upload_storage, apply validators and processors
309
+ """Prepare file(s) for saving into the database by converting bytes or strings into
310
+ File objects, applying validators, uploading the file(s) to the upload storage,
311
+ and applying processors.
312
+
313
+ Arguments:
314
+ mapper (Mapper): The mapper instance associated with the object.
315
+ obj (Any): The object containing the file attribute.
316
+ key (str): The name of the file attribute.
317
+
318
+ Returns:
319
+ Tuple[bool, Union[File, List[File]]]: A tuple containing a boolean flag indicating
320
+ whether the file(s) were changed, and the prepared file object(s). If the column type
321
+ allows multiple files, the prepared file objects are returned as a list.
314
322
  """
315
323
  value = getattr(obj, key)
316
324
 
317
- """Become True when it is new file for single field,
318
- or when new items is added for multiple field"""
325
+ """
326
+ The `changed` flag becomes True in two cases:
327
+ 1. For a single-field attribute, when a new file is assigned, replacing the previous file.
328
+ 2. For a multiple-field attribute, when new file objects are added to the existing ones.
329
+ """
319
330
  changed = False
320
331
 
321
332
  column_type = mapper.attrs.get(key).columns[0].type # type: ignore[misc,union-attr]
@@ -325,7 +336,7 @@ class FileFieldSessionTracker:
325
336
  prepared_values: List[File] = []
326
337
  for v in value if isinstance(value, list) else [value]:
327
338
  if not isinstance(v, upload_type):
328
- v = upload_type(v)
339
+ v = upload_type(v) # noqa: PLW2901
329
340
  if not getattr(v, "saved", False):
330
341
  changed = True
331
342
  v.apply_validators(column_type.validators, key)
@@ -15,8 +15,8 @@ if TYPE_CHECKING:
15
15
 
16
16
 
17
17
  class Validator:
18
- """
19
- Interface that must be implemented by file validators.
18
+ """Interface that must be implemented by file validators.
19
+
20
20
  File validators get executed before a file is stored on the database
21
21
  using one of the supported fields. Can be used to add additional data
22
22
  to file object or change it.
@@ -25,19 +25,17 @@ class Validator:
25
25
 
26
26
  @abstractmethod
27
27
  def process(self, file: "File", attr_key: str) -> None: # pragma: no cover
28
- """
29
- Should be overridden in inherited class
28
+ """Should be overridden in inherited class.
30
29
 
31
30
  Parameters:
32
31
  file: [File][sqlalchemy_file.file.File] object
33
32
  attr_key: current SQLAlchemy column key. Can be passed to
34
33
  [ValidationError][sqlalchemy_file.exceptions.ValidationError]
35
34
  """
36
- pass
37
35
 
38
36
 
39
37
  class SizeValidator(Validator):
40
- """Validate file maximum size
38
+ """Validate file maximum size.
41
39
 
42
40
  Attributes:
43
41
  max_size:
@@ -56,6 +54,7 @@ class SizeValidator(Validator):
56
54
 
57
55
  For more information, view
58
56
  [Wikipedia: Binary prefix](https://en.wikipedia.org/wiki/Binary_prefix)
57
+
59
58
  Example:
60
59
  ```Python
61
60
  class Attachment(Base):
@@ -79,8 +78,7 @@ class SizeValidator(Validator):
79
78
  if file.size > convert_size(self.max_size):
80
79
  raise SizeValidationError(
81
80
  attr_key,
82
- "The file is too large (%s bytes). Allowed maximum size is %s."
83
- % (file.size, self.max_size),
81
+ f"The file is too large ({file.size} bytes). Allowed maximum size is {self.max_size}.",
84
82
  )
85
83
 
86
84
 
@@ -119,13 +117,12 @@ class ContentTypeValidator(Validator):
119
117
  ):
120
118
  raise ContentTypeValidationError(
121
119
  attr_key,
122
- "File content_type %s is not allowed. Allowed content_types are: %s"
123
- % (file.content_type, self.allowed_content_types),
120
+ f"File content_type {file.content_type} is not allowed. Allowed content_types are: {self.allowed_content_types}",
124
121
  )
125
122
 
126
123
 
127
124
  class ImageValidator(ContentTypeValidator):
128
- """Default Validator for ImageField
125
+ """Default Validator for ImageField.
129
126
 
130
127
  Attributes:
131
128
  min_wh: Minimum allowed dimension (w, h).
@@ -1,5 +0,0 @@
1
- __version__ = "0.4.0"
2
-
3
- from .file import File as File
4
- from .types import FileField as FileField
5
- from .types import ImageField as ImageField
@@ -1,38 +0,0 @@
1
- import json
2
- import tempfile
3
- from typing import Optional
4
-
5
- from libcloud.storage.base import Object
6
- from libcloud.storage.types import ObjectDoesNotExistError
7
- from sqlalchemy_file.helpers import LOCAL_STORAGE_DRIVER_NAME
8
-
9
-
10
- class StoredFile:
11
- def __init__(self, obj: Object) -> None:
12
- if obj.driver.name == LOCAL_STORAGE_DRIVER_NAME:
13
- """Retrieve metadata from associated metadata file"""
14
- try:
15
- metadata_obj = obj.container.get_object(f"{obj.name}.metadata.json")
16
- obj.meta_data = json.load(open(metadata_obj.get_cdn_url()))
17
- except ObjectDoesNotExistError:
18
- pass
19
- self.name = obj.name
20
- self.filename = obj.meta_data.get("filename", "unnamed")
21
- self.content_type = obj.extra.get(
22
- "content_type",
23
- obj.meta_data.get("content_type", "application/octet-stream"),
24
- )
25
- self.object = obj
26
-
27
- def get_cdn_url(self) -> Optional[str]:
28
- try:
29
- return self.object.get_cdn_url()
30
- except NotImplementedError:
31
- return None
32
-
33
- def read(self, n: int = -1) -> bytes:
34
- if self.object.driver.name == LOCAL_STORAGE_DRIVER_NAME:
35
- return open(self.object.get_cdn_url(), "rb").read(n)
36
- _file = tempfile.NamedTemporaryFile()
37
- self.object.download(_file.name, overwrite_existing=True)
38
- return _file.read(n)
File without changes