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.
- {sqlalchemy_file-0.6.0 → sqlalchemy_file-0.7.0rc1}/.gitignore +3 -0
- {sqlalchemy_file-0.6.0 → sqlalchemy_file-0.7.0rc1}/PKG-INFO +41 -35
- {sqlalchemy_file-0.6.0 → sqlalchemy_file-0.7.0rc1}/README.md +25 -6
- sqlalchemy_file-0.7.0rc1/pyproject.toml +210 -0
- sqlalchemy_file-0.7.0rc1/sqlalchemy_file/__init__.py +5 -0
- {sqlalchemy_file-0.6.0 → sqlalchemy_file-0.7.0rc1}/sqlalchemy_file/base.py +2 -3
- {sqlalchemy_file-0.6.0 → sqlalchemy_file-0.7.0rc1}/sqlalchemy_file/file.py +25 -20
- {sqlalchemy_file-0.6.0 → sqlalchemy_file-0.7.0rc1}/sqlalchemy_file/helpers.py +19 -16
- {sqlalchemy_file-0.6.0 → sqlalchemy_file-0.7.0rc1}/sqlalchemy_file/mutable_list.py +18 -13
- {sqlalchemy_file-0.6.0 → sqlalchemy_file-0.7.0rc1}/sqlalchemy_file/processors.py +46 -32
- {sqlalchemy_file-0.6.0 → sqlalchemy_file-0.7.0rc1}/sqlalchemy_file/storage.py +29 -18
- {sqlalchemy_file-0.6.0 → sqlalchemy_file-0.7.0rc1}/sqlalchemy_file/stored_file.py +14 -10
- {sqlalchemy_file-0.6.0 → sqlalchemy_file-0.7.0rc1}/sqlalchemy_file/types.py +70 -64
- {sqlalchemy_file-0.6.0 → sqlalchemy_file-0.7.0rc1}/sqlalchemy_file/validators.py +25 -13
- sqlalchemy_file-0.6.0/pyproject.toml +0 -177
- sqlalchemy_file-0.6.0/sqlalchemy_file/__init__.py +0 -5
- {sqlalchemy_file-0.6.0 → sqlalchemy_file-0.7.0rc1}/LICENSE +0 -0
- {sqlalchemy_file-0.6.0 → sqlalchemy_file-0.7.0rc1}/sqlalchemy_file/exceptions.py +0 -0
- {sqlalchemy_file-0.6.0 → sqlalchemy_file-0.7.0rc1}/sqlalchemy_file/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: sqlalchemy-file
|
|
3
|
-
Version: 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.
|
|
29
|
-
Requires-Dist: apache-libcloud
|
|
30
|
-
Requires-Dist: sqlalchemy
|
|
31
|
-
Provides-Extra:
|
|
32
|
-
Requires-Dist:
|
|
33
|
-
Requires-Dist:
|
|
34
|
-
Provides-Extra:
|
|
35
|
-
Requires-Dist:
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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"]
|
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import typing
|
|
2
1
|
from typing import Any
|
|
3
2
|
|
|
4
3
|
|
|
5
|
-
class BaseFile(
|
|
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
|
|
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
|
|
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:
|
|
45
|
-
content_type:
|
|
46
|
-
content_path:
|
|
47
|
-
**kwargs:
|
|
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:
|
|
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:
|
|
88
|
-
upload_storage:
|
|
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:
|
|
96
|
+
def save_to_storage(self, upload_storage: str | None = None) -> None:
|
|
96
97
|
"""Save current file into provided `upload_storage`."""
|
|
97
|
-
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=
|
|
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.
|
|
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:
|
|
133
|
-
name:
|
|
134
|
-
metadata:
|
|
135
|
-
extra:
|
|
136
|
-
headers:
|
|
137
|
-
content_path:
|
|
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) ->
|
|
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
|
|
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:
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
51
|
-
|
|
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:
|
|
67
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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]
|