sqlalchemy-file 0.6.0__tar.gz → 0.7.0rc1__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.
@@ -1,6 +1,9 @@
1
1
  *.pyc
2
2
  env*
3
3
  .mypy_cache
4
+ .ty
5
+ .zensical
6
+ .ruff_cache
4
7
  .vscode
5
8
  .idea
6
9
  poetry.lock
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.5
2
2
  Name: sqlalchemy-file
3
- Version: 0.6.0
3
+ Version: 0.7.0.rc1
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
@@ -17,37 +17,24 @@ Classifier: Intended Audience :: Developers
17
17
  Classifier: License :: OSI Approved :: MIT License
18
18
  Classifier: Operating System :: OS Independent
19
19
  Classifier: Programming Language :: Python
20
- Classifier: Programming Language :: Python :: 3.7
21
- Classifier: Programming Language :: Python :: 3.8
22
- Classifier: Programming Language :: Python :: 3.9
23
- Classifier: Programming Language :: Python :: 3.10
24
20
  Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Programming Language :: Python :: 3.14
24
+ Classifier: Programming Language :: Python :: 3.15
25
25
  Classifier: Topic :: Database
26
26
  Classifier: Topic :: Database :: Database Engines/Servers
27
27
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
28
- Requires-Python: >=3.7
29
- Requires-Dist: apache-libcloud<3.9,>=3.6
30
- Requires-Dist: sqlalchemy<2.1,>=1.4
31
- Provides-Extra: dev
32
- Requires-Dist: pre-commit<4.0.0,>=2.20.0; extra == 'dev'
33
- Requires-Dist: uvicorn<0.24.0,>=0.20.0; extra == 'dev'
34
- Provides-Extra: doc
35
- Requires-Dist: mkdocs-material<10.0.0,>=9.0.0; extra == 'doc'
36
- Requires-Dist: mkdocstrings[python]<0.24.0,>=0.19.0; extra == 'doc'
37
- Provides-Extra: test
38
- Requires-Dist: black==23.9.1; extra == 'test'
39
- Requires-Dist: coverage<7.4.0,>=7.0.0; extra == 'test'
40
- Requires-Dist: fastapi<0.104,>=0.92; extra == 'test'
41
- Requires-Dist: fasteners==0.19; extra == 'test'
42
- Requires-Dist: flask-sqlalchemy<3.2,>=3.0; extra == 'test'
43
- Requires-Dist: flask<2.3,>=2.2; extra == 'test'
44
- Requires-Dist: mypy==1.5.1; extra == 'test'
45
- Requires-Dist: pillow<10.1.0,>=9.4.0; extra == 'test'
46
- Requires-Dist: psycopg2-binary<3.0.0,>=2.9.5; extra == 'test'
47
- Requires-Dist: pymysql[rsa]<1.2.0,>=1.0.2; extra == 'test'
48
- Requires-Dist: pytest<7.5.0,>=7.2.0; extra == 'test'
49
- Requires-Dist: python-multipart==0.0.6; extra == 'test'
50
- Requires-Dist: ruff==0.0.292; extra == 'test'
28
+ Requires-Python: >=3.11
29
+ Requires-Dist: apache-libcloud>=3.6
30
+ Requires-Dist: sqlalchemy>=2.0
31
+ Provides-Extra: all
32
+ Requires-Dist: fasteners>=0.15; extra == 'all'
33
+ Requires-Dist: pillow>=9.4; extra == 'all'
34
+ Provides-Extra: local
35
+ Requires-Dist: fasteners>=0.15; extra == 'local'
36
+ Provides-Extra: pillow
37
+ Requires-Dist: pillow>=9.4; extra == 'pillow'
51
38
  Description-Content-Type: text/markdown
52
39
 
53
40
  # sqlalchemy-file
@@ -105,9 +92,7 @@ The key features are:
105
92
 
106
93
  ## Requirements
107
94
 
108
- A recent and currently supported version of Python (right
109
- now, <a href="https://www.python.org/downloads/" class="external-link" target="_blank">Python supports versions 3.7 and
110
- above</a>).
95
+ Python 3.11 or above.
111
96
 
112
97
  As **SQLAlchemy-file** is based on **Apache Libcloud** and **SQLAlchemy**, it requires them. They will be automatically
113
98
  installed when you install SQLAlchemy-file.
@@ -126,6 +111,27 @@ $ pip install sqlalchemy-file
126
111
  $ poetry add sqlalchemy-file
127
112
  ```
128
113
 
114
+ ### Optional dependencies (extras)
115
+
116
+ Some features need third-party packages that are **not** installed by default:
117
+
118
+ | Extra | Installs | Required for |
119
+ |--------------------------------|---------------------------------------------|---------------------------------------------------------------------|
120
+ | `sqlalchemy-file[pillow]` | `Pillow` | `ImageField`, `ImageValidator`, `ThumbnailGenerator` |
121
+ | `sqlalchemy-file[local]` | `fasteners` | `LocalStorageDriver` backend (required by libcloud's local storage) |
122
+ | `sqlalchemy-file[all]` | `Pillow` + `fasteners` | Everything above |
123
+
124
+ ```shell
125
+ $ pip install "sqlalchemy-file[local,pillow]"
126
+ ```
127
+
128
+ ```shell
129
+ $ poetry add "sqlalchemy-file[all]"
130
+ ```
131
+
132
+ Without the matching extra you get an explicit `ImportError` telling you which extra to install
133
+ (e.g. use `ImageField` without `Pillow`, or the local storage driver without `fasteners`).
134
+
129
135
  ## Example
130
136
 
131
137
  Attaching files to models is as simple as declaring a field on the model itself
@@ -178,11 +184,11 @@ with Session(engine) as session, open("./example.txt", "rb") as local_file:
178
184
  session.add(Attachment(name="attachment4", content=file))
179
185
 
180
186
  # from a File object specifying a content path
181
- session.add(Attachment(name="attachment5", content=File(content_path="./example.txt")))
187
+ session.add(
188
+ Attachment(name="attachment5", content=File(content_path="./example.txt"))
189
+ )
182
190
 
183
191
  session.commit()
184
-
185
-
186
192
  ```
187
193
 
188
194
  ## Related projects and inspirations
@@ -53,9 +53,7 @@ The key features are:
53
53
 
54
54
  ## Requirements
55
55
 
56
- A recent and currently supported version of Python (right
57
- now, <a href="https://www.python.org/downloads/" class="external-link" target="_blank">Python supports versions 3.7 and
58
- above</a>).
56
+ Python 3.11 or above.
59
57
 
60
58
  As **SQLAlchemy-file** is based on **Apache Libcloud** and **SQLAlchemy**, it requires them. They will be automatically
61
59
  installed when you install SQLAlchemy-file.
@@ -74,6 +72,27 @@ $ pip install sqlalchemy-file
74
72
  $ poetry add sqlalchemy-file
75
73
  ```
76
74
 
75
+ ### Optional dependencies (extras)
76
+
77
+ Some features need third-party packages that are **not** installed by default:
78
+
79
+ | Extra | Installs | Required for |
80
+ |--------------------------------|---------------------------------------------|---------------------------------------------------------------------|
81
+ | `sqlalchemy-file[pillow]` | `Pillow` | `ImageField`, `ImageValidator`, `ThumbnailGenerator` |
82
+ | `sqlalchemy-file[local]` | `fasteners` | `LocalStorageDriver` backend (required by libcloud's local storage) |
83
+ | `sqlalchemy-file[all]` | `Pillow` + `fasteners` | Everything above |
84
+
85
+ ```shell
86
+ $ pip install "sqlalchemy-file[local,pillow]"
87
+ ```
88
+
89
+ ```shell
90
+ $ poetry add "sqlalchemy-file[all]"
91
+ ```
92
+
93
+ Without the matching extra you get an explicit `ImportError` telling you which extra to install
94
+ (e.g. use `ImageField` without `Pillow`, or the local storage driver without `fasteners`).
95
+
77
96
  ## Example
78
97
 
79
98
  Attaching files to models is as simple as declaring a field on the model itself
@@ -126,11 +145,11 @@ with Session(engine) as session, open("./example.txt", "rb") as local_file:
126
145
  session.add(Attachment(name="attachment4", content=file))
127
146
 
128
147
  # from a File object specifying a content path
129
- session.add(Attachment(name="attachment5", content=File(content_path="./example.txt")))
148
+ session.add(
149
+ Attachment(name="attachment5", content=File(content_path="./example.txt"))
150
+ )
130
151
 
131
152
  session.commit()
132
-
133
-
134
153
  ```
135
154
 
136
155
  ## Related projects and inspirations
@@ -0,0 +1,210 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "sqlalchemy-file"
7
+ description = "SQLAlchemy-file is a SQLAlchemy extension for attaching files to SQLAlchemy model and uploading them to various storage."
8
+ readme = "README.md"
9
+ license = "MIT"
10
+ requires-python = ">=3.11"
11
+ keywords = ["sqlalchemy", "sqlmodel", "file-upload", "apache-libcloud"]
12
+ authors = [
13
+ { name = "Jocelin Hounon", email = "hounonj@gmail.com" },
14
+ ]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Environment :: Web Environment",
18
+ "Framework :: AsyncIO",
19
+ "Intended Audience :: Developers",
20
+ "License :: OSI Approved :: MIT License",
21
+ "Operating System :: OS Independent",
22
+ "Programming Language :: Python",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Programming Language :: Python :: 3.13",
26
+ "Programming Language :: Python :: 3.14",
27
+ "Programming Language :: Python :: 3.15",
28
+ "Topic :: Database",
29
+ "Topic :: Database :: Database Engines/Servers",
30
+ "Topic :: Software Development :: Libraries :: Python Modules",
31
+ ]
32
+ dependencies = [
33
+ "SQLAlchemy >=2.0",
34
+ "apache-libcloud >=3.6",
35
+ ]
36
+ dynamic = ["version"]
37
+
38
+ [project.urls]
39
+ Homepage = "https://jowilf.github.io/sqlalchemy-file"
40
+ Documentation = "https://jowilf.github.io/sqlalchemy-file"
41
+ Repository = "https://github.com/jowilf/sqlalchemy-file"
42
+ Changelog = "https://jowilf.github.io/sqlalchemy-file/changelog/"
43
+
44
+ [project.optional-dependencies]
45
+ # Optional feature dependencies. Install with e.g.
46
+ # `pip install sqlalchemy-file[local,pillow]`.
47
+ pillow = [
48
+ # Required by ImageField, ImageValidator and ThumbnailGenerator.
49
+ "Pillow >=9.4",
50
+ ]
51
+ local = [
52
+ # Required by libcloud's LocalStorageDriver backend.
53
+ "fasteners >=0.15",
54
+ ]
55
+ all = [
56
+ "Pillow >=9.4",
57
+ "fasteners >=0.15",
58
+ ]
59
+ # Dev tooling in PEP 735 groups, managed by uv. Tasks in [tool.poe.tasks];
60
+ # run with `uv run poe <task>`.
61
+ [dependency-groups]
62
+ dev = [
63
+ "poethepoet >=0.30.0, <0.49.0",
64
+ "pre-commit >=2.20.0",
65
+ "uvicorn >=0.20.0",
66
+ ]
67
+ lint = [
68
+ "ruff ==0.17.1",
69
+ "ty ==0.0.89",
70
+ ]
71
+ test = [
72
+ "pytest >=8",
73
+ "testcontainers[mysql,postgres,minio] >=4.6",
74
+ "fasteners >=0.15",
75
+ "PyMySQL >=1.0.2",
76
+ "psycopg2-binary >=2.9.5, <3.0.0",
77
+ "Pillow >=9.4",
78
+ "python-multipart >=0.0.6",
79
+ "sqlmodel >=0.0.11",
80
+ "fastapi >=0.92",
81
+ "Flask >=2.2",
82
+ "Flask-SQLAlchemy >=3.0",
83
+ { include-group = "lint" },
84
+ ]
85
+ cov = [
86
+ "coverage[toml] >=7.4.0",
87
+ ]
88
+ docs = [
89
+ "mkdocstrings[python] >=0.27.0",
90
+ "zensical ==0.0.71",
91
+ ]
92
+ # Mutually exclusive SQLA test environments, see [tool.uv] conflicts.
93
+ sqla20 = [
94
+ "SQLAlchemy >=2.0, <2.1",
95
+ ]
96
+ sqla21 = [
97
+ "SQLAlchemy >=2.1",
98
+ ]
99
+
100
+ [tool.uv]
101
+ conflicts = [
102
+ [
103
+ { group = "sqla20" },
104
+ { group = "sqla21" },
105
+ ],
106
+ ]
107
+ # sqla21 default so plain `uv sync` tests the 2.1 series. sqla20 is opt-in;
108
+ # conflicting groups require `uv run --no-default-groups --group sqla20 ...`.
109
+ default-groups = ["dev", "lint", "test", "cov", "docs", "sqla21"]
110
+
111
+ [tool.poe.tasks]
112
+ # Code quality
113
+ format = ["_ruff-fix", "_ruff-format"]
114
+ lint = ["_ty", "_ruff-check", "_ruff-format-check"]
115
+ _ty = "ty check sqlalchemy_file"
116
+ _ruff-check = "ruff check sqlalchemy_file tests"
117
+ _ruff-fix = "ruff check sqlalchemy_file tests --fix"
118
+ _ruff-format = "ruff format sqlalchemy_file tests docs_src examples"
119
+ _ruff-format-check = "ruff format sqlalchemy_file tests docs_src examples --check"
120
+
121
+ # Tests (sqla tasks re-sync their pinned group)
122
+ test = "coverage run -m pytest tests"
123
+ test-sqla20 = "uv run --no-default-groups --group sqla20 --group test --group cov coverage run -m pytest tests"
124
+ test-sqla21 = "uv run --group sqla21 coverage run -m pytest tests"
125
+ test-matrix = ["test-sqla20", "test-sqla21"]
126
+
127
+ # Coverage
128
+ cov-report = ["_cov-combine", "_cov-report", "_cov-xml"]
129
+ _cov-combine = "coverage combine"
130
+ _cov-report = "coverage report --show-missing"
131
+ _cov-xml = "coverage xml"
132
+
133
+ # Docs
134
+ docs-serve = "zensical serve -a localhost:8080"
135
+ docs-build = "zensical build"
136
+
137
+ # Clean
138
+ clean = "rm -rf .coverage .coverage.* coverage.xml .pytest_cache .ruff_cache .mypy_cache site .zensical"
139
+
140
+ [tool.hatch.version]
141
+ path = "sqlalchemy_file/__init__.py"
142
+
143
+ [tool.pytest.ini_options]
144
+ filterwarnings = [
145
+ "ignore:datetime.datetime.utcnow\\(\\) is deprecated:DeprecationWarning",
146
+ ]
147
+
148
+ [tool.coverage.report]
149
+ fail_under = 99
150
+ show_missing = true
151
+ exclude_lines = [
152
+ "pragma: no cover",
153
+ "if TYPE_CHECKING:",
154
+ "except ImportError"
155
+ ]
156
+
157
+ [tool.coverage.run]
158
+ parallel = true
159
+ command_line = "-m pytest"
160
+ source = ["sqlalchemy_file", "tests"]
161
+
162
+ [tool.ruff]
163
+ target-version = "py311"
164
+
165
+ [tool.ruff.lint]
166
+ select = [
167
+ "B", # flake8-bugbear
168
+ "C4", # flake8-comprehensions
169
+ "C90", # mccabe
170
+ "D", # pydocstyle
171
+ "E", # pycodestyle errors
172
+ "ERA", # eradicate
173
+ "F", # pyflakes
174
+ "I", # isort
175
+ "N", # pep8-naming
176
+ "PIE", # flake8-pie,
177
+ "PLC", # pylint - convention
178
+ "PLE", # pylint - error
179
+ "PLW", # pylint - warning
180
+ "Q", # flake8-quotes
181
+ "RET", # flake8-return,
182
+ "RUF", # Ruff-specific rules
183
+ "SIM", # flake8-simplify
184
+ "UP", # pyupgrade
185
+ "W", # pycodestyle warnings
186
+ ]
187
+ ignore = ["E501", "B904", "B008", "D100", "D101", "D102", "D103", "D104", "D105", "D106", "D107", "D205"]
188
+
189
+ [tool.ruff.lint.pydocstyle]
190
+ convention = "google"
191
+
192
+ [tool.ruff.lint.isort]
193
+ known-third-party = ["sqlalchemy_file"]
194
+
195
+ [tool.ruff.lint.per-file-ignores]
196
+ "__init__.py" = ["F401"]
197
+ "docs_src/**" = ["N999"]
198
+ # Test fixtures intentionally keep temp files open for the test body.
199
+ "tests/**" = ["SIM115"]
200
+
201
+ [tool.ty.src]
202
+ include = ["sqlalchemy_file"]
203
+
204
+ [tool.ty.rules]
205
+ unused-ignore-comment = "error"
206
+ unused-type-ignore-comment = "error"
207
+
208
+ [tool.hatch.build.targets.wheel]
209
+ [tool.hatch.build.targets.sdist]
210
+ include = ["sqlalchemy_file"]
@@ -0,0 +1,5 @@
1
+ __version__ = "0.7.0.rc1"
2
+
3
+ from .file import File as File
4
+ from .types import FileField as FileField
5
+ from .types import ImageField as ImageField
@@ -1,8 +1,7 @@
1
- import typing
2
1
  from typing import Any
3
2
 
4
3
 
5
- class BaseFile(typing.Dict[str, Any]):
4
+ class BaseFile(dict[str, Any]):
6
5
  """Base class for file object.
7
6
 
8
7
  It keeps information on a content related to a specific storage.
@@ -37,7 +36,7 @@ class BaseFile(typing.Dict[str, Any]):
37
36
  raise AttributeError(name)
38
37
 
39
38
  def __delitem__(self, key: str) -> None:
40
- if object.__getattribute__(self, "_frozen"):
39
+ if getattr(self, "_frozen", False):
41
40
  raise TypeError("Already saved files are immutable")
42
41
  dict.__delitem__(self, key)
43
42
 
@@ -1,8 +1,9 @@
1
+ import copy
1
2
  import os.path
2
3
  import uuid
3
4
  import warnings
4
- from datetime import datetime
5
- from typing import Any, Dict, List, Optional
5
+ from datetime import UTC, datetime
6
+ from typing import Any
6
7
 
7
8
  from sqlalchemy_file.base import BaseFile
8
9
  from sqlalchemy_file.helpers import (
@@ -41,10 +42,10 @@ class File(BaseFile):
41
42
  def __init__(
42
43
  self,
43
44
  content: Any = None,
44
- filename: Optional[str] = None,
45
- content_type: Optional[str] = None,
46
- content_path: Optional[str] = None,
47
- **kwargs: Dict[str, Any],
45
+ filename: str | None = None,
46
+ content_type: str | None = None,
47
+ content_path: str | None = None,
48
+ **kwargs: Any,
48
49
  ) -> None:
49
50
  if content is None and content_path is None:
50
51
  raise ValueError("Either content or content_path must be specified")
@@ -77,24 +78,26 @@ class File(BaseFile):
77
78
  )
78
79
  self._thaw()
79
80
 
80
- def apply_validators(self, validators: List[Validator], key: str = "") -> None:
81
+ def apply_validators(self, validators: list[Validator], key: str = "") -> None:
81
82
  """Apply validators to current file."""
82
83
  for validator in validators:
83
84
  validator.process(self, key)
84
85
 
85
86
  def apply_processors(
86
87
  self,
87
- processors: List[Processor],
88
- upload_storage: Optional[str] = None,
88
+ processors: list[Processor],
89
+ upload_storage: str | None = None,
89
90
  ) -> None:
90
91
  """Apply processors to current file."""
91
92
  for processor in processors:
92
93
  processor.process(self, upload_storage)
93
94
  self._freeze()
94
95
 
95
- def save_to_storage(self, upload_storage: Optional[str] = None) -> None:
96
+ def save_to_storage(self, upload_storage: str | None = None) -> None:
96
97
  """Save current file into provided `upload_storage`."""
97
- extra = self.get("extra", {})
98
+ # Deep copy `extra`: column-level config must not leak between files.
99
+ extra = copy.deepcopy(self.get("extra", None) or {})
100
+
98
101
  extra.update({"content_type": self.content_type})
99
102
 
100
103
  metadata = self.get("metadata", None)
@@ -102,7 +105,7 @@ class File(BaseFile):
102
105
  warnings.warn(
103
106
  'metadata attribute is deprecated. Use extra={"meta_data": ...} instead',
104
107
  DeprecationWarning,
105
- stacklevel=1,
108
+ stacklevel=2,
106
109
  )
107
110
  extra.update({"meta_data": metadata})
108
111
 
@@ -112,6 +115,7 @@ class File(BaseFile):
112
115
  extra["meta_data"].update(
113
116
  {"filename": self.filename, "content_type": self.content_type}
114
117
  )
118
+ self["extra"] = extra
115
119
  stored_file = self.store_content(
116
120
  self.original_content,
117
121
  upload_storage,
@@ -121,7 +125,7 @@ class File(BaseFile):
121
125
  )
122
126
  self["file_id"] = stored_file.name
123
127
  self["upload_storage"] = upload_storage
124
- self["uploaded_at"] = datetime.utcnow().isoformat()
128
+ self["uploaded_at"] = datetime.now(UTC).isoformat()
125
129
  self["path"] = f"{upload_storage}/{stored_file.name}"
126
130
  self["url"] = stored_file.get_cdn_url()
127
131
  self["saved"] = True
@@ -129,18 +133,19 @@ class File(BaseFile):
129
133
  def store_content(
130
134
  self,
131
135
  content: Any,
132
- upload_storage: Optional[str] = None,
133
- name: Optional[str] = None,
134
- metadata: Optional[Dict[str, Any]] = None,
135
- extra: Optional[Dict[str, Any]] = None,
136
- headers: Optional[Dict[str, str]] = None,
137
- content_path: Optional[str] = None,
136
+ upload_storage: str | None = None,
137
+ name: str | None = None,
138
+ metadata: dict[str, Any] | None = None,
139
+ extra: dict[str, Any] | None = None,
140
+ headers: dict[str, str] | None = None,
141
+ content_path: str | None = None,
138
142
  ) -> StoredFile:
139
143
  """Store content into provided `upload_storage`
140
144
  with additional `metadata`. Can be used by processors
141
145
  to store additional files.
142
146
  """
143
147
  name = name or str(uuid.uuid4())
148
+ upload_storage = upload_storage or StorageManager.get_default()
144
149
  stored_file = StorageManager.save_file(
145
150
  name=name,
146
151
  content=content,
@@ -153,7 +158,7 @@ class File(BaseFile):
153
158
  self["files"].append(f"{upload_storage}/{name}")
154
159
  return stored_file
155
160
 
156
- def encode(self) -> Dict[str, Any]:
161
+ def encode(self) -> dict[str, Any]:
157
162
  return {k: v for k, v in self.items() if k not in ["original_content"]}
158
163
 
159
164
  @classmethod
@@ -4,14 +4,14 @@ import os
4
4
  import re
5
5
  from builtins import RuntimeError
6
6
  from tempfile import SpooledTemporaryFile
7
- from typing import Any, Dict, List, Union
7
+ from typing import Any
8
8
 
9
9
  INMEMORY_FILESIZE = 1024 * 1024
10
10
  LOCAL_STORAGE_DRIVER_NAME = "Local Storage"
11
11
 
12
12
 
13
- def get_metadata_file_obj(metadata: Dict[str, Any]) -> "SpooledTemporaryFile[bytes]":
14
- f = SpooledTemporaryFile(INMEMORY_FILESIZE)
13
+ def get_metadata_file_obj(metadata: dict[str, Any]) -> "SpooledTemporaryFile[bytes]":
14
+ f = SpooledTemporaryFile(INMEMORY_FILESIZE) # noqa: SIM115
15
15
  f.write(json.dumps(metadata).encode())
16
16
  f.seek(0)
17
17
  return f
@@ -23,7 +23,7 @@ def get_content_from_file_obj(fileobj: Any) -> Any:
23
23
  Converts ``str`` and ``bytes`` to an actual file.
24
24
  """
25
25
  if isinstance(fileobj, (str, bytes)):
26
- f = SpooledTemporaryFile(INMEMORY_FILESIZE)
26
+ f = SpooledTemporaryFile(INMEMORY_FILESIZE) # noqa: SIM115
27
27
  f.write(fileobj.encode() if isinstance(fileobj, str) else fileobj)
28
28
  f.seek(0)
29
29
  return f
@@ -33,10 +33,13 @@ def get_content_from_file_obj(fileobj: Any) -> Any:
33
33
 
34
34
 
35
35
  def get_filename_from_fileob(fileobj: Any) -> Any:
36
- if getattr(fileobj, "filename", None) is not None:
37
- return fileobj.filename
38
- if getattr(fileobj, "name", None) is not None:
39
- return os.path.basename(fileobj.name)
36
+ filename = getattr(fileobj, "filename", None)
37
+ if filename:
38
+ return filename
39
+ name = getattr(fileobj, "name", None)
40
+ # `name` can be a raw file descriptor (int).
41
+ if isinstance(name, str):
42
+ return os.path.basename(name)
40
43
  return "unnamed"
41
44
 
42
45
 
@@ -47,8 +50,9 @@ def get_content_type_from_fileobj(fileobj: Any, filename: str) -> Any:
47
50
 
48
51
 
49
52
  def get_content_size_from_fileobj(file: Any) -> Any:
50
- if hasattr(file, "size"):
51
- return file.size
53
+ size = getattr(file, "size", None)
54
+ if size is not None:
55
+ return size
52
56
  if hasattr(file, "name"):
53
57
  try:
54
58
  return os.path.getsize(file.name)
@@ -63,19 +67,18 @@ def get_content_size_from_fileobj(file: Any) -> Any:
63
67
  raise RuntimeError("Unable to determine the file's size.") # pragma: no cover
64
68
 
65
69
 
66
- def convert_size(size: Union[str, int]) -> int:
67
- # convert size to number of bytes ex: 1k -> 1000; 1Ki->1024
70
+ def convert_size(size: str | int) -> int:
71
+ """Convert a human readable size to a number of bytes: ``1k`` -> 1000, ``1Ki`` -> 1024."""
68
72
  if isinstance(size, str):
69
- pattern = re.compile(r"^(\d+)\s*(k|([KM]i?))$")
70
- m = re.fullmatch(pattern, size)
73
+ m = re.fullmatch(r"^(\d+)\s*(k|([KM]i?))$", size)
71
74
  if m is None:
72
- raise ValueError("Invalid size %s" % size)
75
+ raise ValueError(f"Invalid size {size}")
73
76
  value, si, _ = m.groups()
74
77
  si_map = {"k": 1000, "K": 1000, "M": 1000**2, "Ki": 1024, "Mi": 1024**2}
75
78
  return int(value) * si_map[si]
76
79
  return size
77
80
 
78
81
 
79
- def flatmap(lists: List[List[Any]]) -> List[Any]:
82
+ def flatmap(lists: list[list[Any]]) -> list[Any]:
80
83
  """Flattens a list of lists into a single list."""
81
84
  return [value for _list in lists for value in _list]