dj-bucket 0.1.2__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.
- dj_bucket-0.1.2/LICENSE.md +11 -0
- dj_bucket-0.1.2/PKG-INFO +45 -0
- dj_bucket-0.1.2/README.md +29 -0
- dj_bucket-0.1.2/dj_bucket/__init__.py +6 -0
- dj_bucket-0.1.2/dj_bucket/apps.py +6 -0
- dj_bucket-0.1.2/dj_bucket/migrations/0001_initial.py +32 -0
- dj_bucket-0.1.2/dj_bucket/migrations/__init__.py +0 -0
- dj_bucket-0.1.2/dj_bucket/models.py +133 -0
- dj_bucket-0.1.2/dj_bucket/settings.py +1 -0
- dj_bucket-0.1.2/dj_bucket/urls.py +12 -0
- dj_bucket-0.1.2/dj_bucket/views.py +17 -0
- dj_bucket-0.1.2/pyproject.toml +67 -0
- dj_bucket-0.1.2/tests/__init__.py +0 -0
- dj_bucket-0.1.2/tests/settings.py +34 -0
- dj_bucket-0.1.2/tests/test_dj_bucket.py +58 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Copyright 2026 Gandi SAS
|
|
2
|
+
|
|
3
|
+
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
|
4
|
+
|
|
5
|
+
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
|
|
6
|
+
|
|
7
|
+
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
|
|
8
|
+
|
|
9
|
+
3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
|
|
10
|
+
|
|
11
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
dj_bucket-0.1.2/PKG-INFO
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: dj-bucket
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: Use your database as a S3 storage
|
|
5
|
+
Author-Email: Benjamin Hirschfield <me@bennybarry.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
8
|
+
Classifier: Framework :: Django
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
12
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
13
|
+
Requires-Python: <4,>=3.11
|
|
14
|
+
Requires-Dist: django<6.0,>=4.1
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# dj-bucket
|
|
18
|
+
|
|
19
|
+
Store blobs into a database table.
|
|
20
|
+
|
|
21
|
+
## Setup
|
|
22
|
+
|
|
23
|
+
In your configuration add the dj-bucket app to the installed apps:
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
INSTALLED_APPS: = [
|
|
27
|
+
"dj_bucket",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
BUCKET_PATH = "bucket/"
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Then make sure a database backend is setup, for testing you can use the SQLite:
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
DATABASES = {
|
|
37
|
+
"default": {
|
|
38
|
+
"ENGINE": "django.db.backends.sqlite3",
|
|
39
|
+
"NAME": BASE_DIR / "db.sqlite3",
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Run the Django database migrations to create the table that will store the
|
|
45
|
+
blobs.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# dj-bucket
|
|
2
|
+
|
|
3
|
+
Store blobs into a database table.
|
|
4
|
+
|
|
5
|
+
## Setup
|
|
6
|
+
|
|
7
|
+
In your configuration add the dj-bucket app to the installed apps:
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
INSTALLED_APPS: = [
|
|
11
|
+
"dj_bucket",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
BUCKET_PATH = "bucket/"
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Then make sure a database backend is setup, for testing you can use the SQLite:
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
DATABASES = {
|
|
21
|
+
"default": {
|
|
22
|
+
"ENGINE": "django.db.backends.sqlite3",
|
|
23
|
+
"NAME": BASE_DIR / "db.sqlite3",
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Run the Django database migrations to create the table that will store the
|
|
29
|
+
blobs.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Generated by Django 4.2.29 on 2026-04-07 08:39
|
|
2
|
+
|
|
3
|
+
from django.db import migrations, models
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Migration(migrations.Migration):
|
|
7
|
+
initial = True
|
|
8
|
+
|
|
9
|
+
dependencies = []
|
|
10
|
+
|
|
11
|
+
operations = [
|
|
12
|
+
migrations.CreateModel(
|
|
13
|
+
name="BucketItem",
|
|
14
|
+
fields=[
|
|
15
|
+
(
|
|
16
|
+
"id",
|
|
17
|
+
models.BigAutoField(
|
|
18
|
+
auto_created=True,
|
|
19
|
+
primary_key=True,
|
|
20
|
+
serialize=False,
|
|
21
|
+
verbose_name="ID",
|
|
22
|
+
),
|
|
23
|
+
),
|
|
24
|
+
("raw", models.BinaryField(help_text="Raw bytes representing the file")),
|
|
25
|
+
("path", models.CharField(max_length=255, unique=True)),
|
|
26
|
+
("mimetype", models.CharField(max_length=64)),
|
|
27
|
+
("accessed_at", models.DateTimeField()),
|
|
28
|
+
("created_at", models.DateTimeField()),
|
|
29
|
+
("modified_at", models.DateTimeField()),
|
|
30
|
+
],
|
|
31
|
+
),
|
|
32
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import mimetypes
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from io import BytesIO, StringIO
|
|
4
|
+
from urllib.parse import urljoin
|
|
5
|
+
|
|
6
|
+
from django.core.files import File
|
|
7
|
+
from django.core.files.storage import Storage
|
|
8
|
+
from django.db import models
|
|
9
|
+
from django.utils.deconstruct import deconstructible
|
|
10
|
+
from django.utils.encoding import filepath_to_uri
|
|
11
|
+
from django.utils.timezone import now
|
|
12
|
+
|
|
13
|
+
from .settings import BUCKET_PATH
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class BucketItem(models.Model):
|
|
17
|
+
app_label = "dj_bucket"
|
|
18
|
+
|
|
19
|
+
raw = models.BinaryField()
|
|
20
|
+
path = models.CharField(max_length=255, unique=True)
|
|
21
|
+
mimetype = models.CharField(max_length=64)
|
|
22
|
+
accessed_at = models.DateTimeField()
|
|
23
|
+
created_at = models.DateTimeField()
|
|
24
|
+
modified_at = models.DateTimeField()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@deconstructible(path="dj_bucket.infrastructure.dj_bucket.models.Bucket")
|
|
28
|
+
class Bucket(Storage):
|
|
29
|
+
def _open(self, name: str, mode: str) -> StringIO | BytesIO:
|
|
30
|
+
# Figure out if mode should be handled
|
|
31
|
+
if mode.startswith("w"):
|
|
32
|
+
if "b" in mode:
|
|
33
|
+
return BytesIO()
|
|
34
|
+
return StringIO()
|
|
35
|
+
elif mode.startswith("r"):
|
|
36
|
+
item = BucketItem.objects.filter(path=name).first()
|
|
37
|
+
if not item:
|
|
38
|
+
raise FileNotFoundError(f"No such file: {name}")
|
|
39
|
+
if "b" in mode:
|
|
40
|
+
return BytesIO(item.raw)
|
|
41
|
+
return StringIO(item.raw.decode("utf-8"))
|
|
42
|
+
|
|
43
|
+
def _save(self, name: str, content: File) -> str:
|
|
44
|
+
raw = content.read()
|
|
45
|
+
content_type, _ = mimetypes.guess_type(name)
|
|
46
|
+
current_dt = now()
|
|
47
|
+
_, created = BucketItem.objects.update_or_create(
|
|
48
|
+
path=name,
|
|
49
|
+
defaults={
|
|
50
|
+
"path": name,
|
|
51
|
+
"raw": raw,
|
|
52
|
+
"mimetype": content_type,
|
|
53
|
+
"accessed_at": current_dt,
|
|
54
|
+
"created_at": current_dt,
|
|
55
|
+
"modified_at": current_dt,
|
|
56
|
+
},
|
|
57
|
+
)
|
|
58
|
+
if not created:
|
|
59
|
+
raise ValueError(f"Could not save upload '{name}' ({content_type})")
|
|
60
|
+
return name
|
|
61
|
+
|
|
62
|
+
def delete(self, name: str) -> None:
|
|
63
|
+
"""
|
|
64
|
+
Delete the specified file from the storage system.
|
|
65
|
+
"""
|
|
66
|
+
BucketItem.objects.filter(path=name).delete()
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def base_url(self) -> str:
|
|
70
|
+
return f"/{BUCKET_PATH}"
|
|
71
|
+
|
|
72
|
+
def exists(self, name: str) -> bool:
|
|
73
|
+
"""
|
|
74
|
+
Return True if a file referenced by the given name already exists in the
|
|
75
|
+
storage system, or False if the name is available for a new file.
|
|
76
|
+
"""
|
|
77
|
+
return bool(BucketItem.objects.filter(path=name).count())
|
|
78
|
+
|
|
79
|
+
def size(self, name: str) -> int:
|
|
80
|
+
"""
|
|
81
|
+
Return the total size, in bytes, of the file specified by name.
|
|
82
|
+
"""
|
|
83
|
+
item = (
|
|
84
|
+
BucketItem.objects.annotate(
|
|
85
|
+
raw_length=models.Func("raw", function="OCTET_LENGTH")
|
|
86
|
+
)
|
|
87
|
+
.values("raw_length")
|
|
88
|
+
.first()
|
|
89
|
+
)
|
|
90
|
+
if item:
|
|
91
|
+
return item["raw_length"]
|
|
92
|
+
raise FileNotFoundError(f"No such file: {name}")
|
|
93
|
+
|
|
94
|
+
def url(self, name: str) -> str:
|
|
95
|
+
"""
|
|
96
|
+
Return an absolute URL where the file's contents can be accessed
|
|
97
|
+
directly by a web browser.
|
|
98
|
+
"""
|
|
99
|
+
item = BucketItem.objects.filter(path=name).first()
|
|
100
|
+
if item:
|
|
101
|
+
url = filepath_to_uri(item.path)
|
|
102
|
+
return urljoin(self.base_url, url)
|
|
103
|
+
raise FileNotFoundError(f"No such file: {name}")
|
|
104
|
+
|
|
105
|
+
def get_accessed_time(self, name: str) -> datetime:
|
|
106
|
+
"""
|
|
107
|
+
Return the last accessed time (as a datetime) of the file specified by
|
|
108
|
+
name. The datetime will be timezone-aware if USE_TZ=True.
|
|
109
|
+
"""
|
|
110
|
+
item = BucketItem.objects.filter(path=name).first()
|
|
111
|
+
if item:
|
|
112
|
+
return item.accessed_at
|
|
113
|
+
raise FileNotFoundError(f"No such file: {name}")
|
|
114
|
+
|
|
115
|
+
def get_created_time(self, name: str) -> datetime:
|
|
116
|
+
"""
|
|
117
|
+
Return the creation time (as a datetime) of the file specified by name.
|
|
118
|
+
The datetime will be timezone-aware if USE_TZ=True.
|
|
119
|
+
"""
|
|
120
|
+
item = BucketItem.objects.filter(path=name).first()
|
|
121
|
+
if item:
|
|
122
|
+
return item.created_at
|
|
123
|
+
raise FileNotFoundError(f"No such file: {name}")
|
|
124
|
+
|
|
125
|
+
def get_modified_time(self, name: str) -> datetime:
|
|
126
|
+
"""
|
|
127
|
+
Return the last modified time (as a datetime) of the file specified by
|
|
128
|
+
name. The datetime will be timezone-aware if USE_TZ=True.
|
|
129
|
+
"""
|
|
130
|
+
item = BucketItem.objects.filter(path=name).first()
|
|
131
|
+
if item:
|
|
132
|
+
return item.modified_at
|
|
133
|
+
raise FileNotFoundError(f"No such file: {name}")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
BUCKET_PATH = "bucket/"
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from io import BytesIO
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from django.http import (
|
|
5
|
+
FileResponse,
|
|
6
|
+
Http404,
|
|
7
|
+
HttpRequest,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
from .models import BucketItem
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def dj_bucket(request: HttpRequest, name: str) -> FileResponse:
|
|
14
|
+
item = BucketItem.objects.filter(path=name).first()
|
|
15
|
+
if item:
|
|
16
|
+
return FileResponse(BytesIO(item.raw), filename=Path(name).name)
|
|
17
|
+
raise Http404
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "dj-bucket"
|
|
3
|
+
version = "0.1.2"
|
|
4
|
+
authors = [
|
|
5
|
+
{ name = "Benjamin Hirschfield", email = "me@bennybarry.com" },
|
|
6
|
+
]
|
|
7
|
+
classifiers = [
|
|
8
|
+
"Development Status :: 5 - Production/Stable",
|
|
9
|
+
"Framework :: Django",
|
|
10
|
+
"Intended Audience :: Developers",
|
|
11
|
+
"License :: OSI Approved :: MIT License",
|
|
12
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
13
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
14
|
+
]
|
|
15
|
+
description = "Use your database as a S3 storage"
|
|
16
|
+
requires-python = ">=3.11,<4"
|
|
17
|
+
readme = "README.md"
|
|
18
|
+
dependencies = [
|
|
19
|
+
"django >=4.1,<6.0",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.license]
|
|
23
|
+
text = "MIT"
|
|
24
|
+
|
|
25
|
+
[dependency-groups]
|
|
26
|
+
dev = [
|
|
27
|
+
"mypy >=2.3.1,<3",
|
|
28
|
+
"pytest >=9.1.1,<10",
|
|
29
|
+
"pytest-cov >=7.1.0,<8",
|
|
30
|
+
"pytest-django >=4.14.0,<5",
|
|
31
|
+
"ruff >=0.15.18,<1",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[tool.pyright]
|
|
35
|
+
typeCheckingMode = "basic"
|
|
36
|
+
|
|
37
|
+
[tool.ruff]
|
|
38
|
+
line-length = 88
|
|
39
|
+
target-version = "py312"
|
|
40
|
+
extend-exclude = [
|
|
41
|
+
"dj_bucket/migrations",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
[tool.pytest.ini_options]
|
|
45
|
+
DJANGO_SETTINGS_MODULE = "tests.settings"
|
|
46
|
+
|
|
47
|
+
[tool.mypy]
|
|
48
|
+
ignore_missing_imports = true
|
|
49
|
+
exclude = "migrations/"
|
|
50
|
+
|
|
51
|
+
[[tool.mypy.overrides]]
|
|
52
|
+
module = "dj_bucket.*"
|
|
53
|
+
disallow_any_generics = true
|
|
54
|
+
disallow_untyped_defs = true
|
|
55
|
+
|
|
56
|
+
[[tool.mypy.overrides]]
|
|
57
|
+
module = [
|
|
58
|
+
"django.*",
|
|
59
|
+
"wagtail.*",
|
|
60
|
+
]
|
|
61
|
+
ignore_missing_imports = true
|
|
62
|
+
|
|
63
|
+
[build-system]
|
|
64
|
+
requires = [
|
|
65
|
+
"pdm-backend",
|
|
66
|
+
]
|
|
67
|
+
build-backend = "pdm.backend"
|
|
File without changes
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Django settings for tests project.
|
|
3
|
+
|
|
4
|
+
Generated by 'django-admin startproject' using Django 5.2.17.
|
|
5
|
+
|
|
6
|
+
For more information on this file, see
|
|
7
|
+
https://docs.djangoproject.com/en/5.2/topics/settings/
|
|
8
|
+
|
|
9
|
+
For the full list of settings and their values, see
|
|
10
|
+
https://docs.djangoproject.com/en/5.2/ref/settings/
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
|
16
|
+
BASE_DIR = Path(__file__).resolve().parent.parent
|
|
17
|
+
|
|
18
|
+
# Application definition
|
|
19
|
+
|
|
20
|
+
INSTALLED_APPS: list[str] = [
|
|
21
|
+
"dj_bucket",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
# Database
|
|
25
|
+
# https://docs.djangoproject.com/en/5.2/ref/settings/#databases
|
|
26
|
+
|
|
27
|
+
DATABASES = {
|
|
28
|
+
"default": {
|
|
29
|
+
"ENGINE": "django.db.backends.sqlite3",
|
|
30
|
+
"NAME": BASE_DIR / "db.sqlite3",
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
BUCKET_PATH = "bucket/"
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from io import BytesIO
|
|
2
|
+
|
|
3
|
+
from django.test import TestCase
|
|
4
|
+
|
|
5
|
+
from dj_bucket.models import Bucket, BucketItem
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class DjBucketTestCase(TestCase):
|
|
9
|
+
@classmethod
|
|
10
|
+
def setUpTestData(cls) -> None:
|
|
11
|
+
cls.text = b"Hello World"
|
|
12
|
+
cls.red_pixel = (
|
|
13
|
+
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00"
|
|
14
|
+
b"\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\t"
|
|
15
|
+
b"pHYs\x00\x00.#\x00\x00.#\x01x\xa5?v\x00\x00\x00\rIDAT\x08[c"
|
|
16
|
+
b"\xf8\xcf\xc0\xf0\x1f\x00\x05\x00\x01\xff"
|
|
17
|
+
b"\x17Qp\x06\x00\x00\x00\x00IEND\xaeB`\x82"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
def test_upload_text(self):
|
|
21
|
+
file_path = "file.txt"
|
|
22
|
+
storage = Bucket()
|
|
23
|
+
fd = BytesIO(self.text)
|
|
24
|
+
|
|
25
|
+
name = storage.save(file_path, fd)
|
|
26
|
+
self.assertEqual(file_path, name)
|
|
27
|
+
|
|
28
|
+
fd = storage.open(name)
|
|
29
|
+
self.assertEqual(self.text, fd.read())
|
|
30
|
+
self.assertTrue(storage.exists(file_path))
|
|
31
|
+
|
|
32
|
+
model = BucketItem.objects.first()
|
|
33
|
+
self.assertIsNotNone(model)
|
|
34
|
+
self.assertEqual(model.raw, self.text) # type: ignore
|
|
35
|
+
self.assertEqual(model.mimetype, "text/plain") # type: ignore
|
|
36
|
+
self.assertEqual(storage.size(name), len(self.text))
|
|
37
|
+
|
|
38
|
+
self.assertEqual("/bucket/file.txt", storage.url(name))
|
|
39
|
+
|
|
40
|
+
def test_upload_blob(self):
|
|
41
|
+
file_path = "images/red_pixel.png"
|
|
42
|
+
storage = Bucket()
|
|
43
|
+
fd = BytesIO(self.red_pixel)
|
|
44
|
+
|
|
45
|
+
name = storage.save(file_path, fd)
|
|
46
|
+
self.assertEqual(file_path, name)
|
|
47
|
+
|
|
48
|
+
fd = storage.open(name)
|
|
49
|
+
self.assertEqual(self.red_pixel, fd.read())
|
|
50
|
+
self.assertTrue(storage.exists(file_path))
|
|
51
|
+
|
|
52
|
+
model = BucketItem.objects.first()
|
|
53
|
+
self.assertIsNotNone(model)
|
|
54
|
+
self.assertEqual(model.raw, self.red_pixel) # type: ignore
|
|
55
|
+
self.assertEqual(model.mimetype, "image/png") # type: ignore
|
|
56
|
+
self.assertEqual(storage.size(name), len(self.red_pixel))
|
|
57
|
+
|
|
58
|
+
self.assertEqual("/bucket/images/red_pixel.png", storage.url(name))
|