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.
@@ -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.
@@ -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,6 @@
1
+ """A django application made to do django media storage using postgresql."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ __version__ = version("dj-bucket")
6
+ __all__ = ["__version__"]
@@ -0,0 +1,6 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class DjBucketConfig(AppConfig):
5
+ default_auto_field = "django.db.models.BigAutoField"
6
+ name = "dj_bucket"
@@ -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,12 @@
1
+ from django.urls import re_path
2
+
3
+ from .settings import BUCKET_PATH
4
+ from .views import dj_bucket
5
+
6
+ urlpatterns = [
7
+ re_path(
8
+ f"^{BUCKET_PATH}/(?P<name>.+)/?$",
9
+ dj_bucket,
10
+ name="dj_bucket",
11
+ )
12
+ ]
@@ -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))