Commit 9f2bd906 authored by Nguyễn Văn Vũ's avatar Nguyễn Văn Vũ

Week5 - Application Django Minio Backend with Docker

parent c6b4d60e
Contributing to Django Minio Backend
------------------------------
You can find a reference implementation of a Django app using **django-minio-backend** as a storage backend in
[DjangoExampleApplication/models.py](DjangoExampleApplication/models.py).
When you're finished with your changes, please open a pull request!
# Development Environment
Execute the following steps to prepare your development environment:
1. Clone the library:
```bash
git clone https://github.com/theriverman/django-minio-backend.git
cd django-minio-backend
```
1. Create a virtual environment and activate it:
```bash
python3 -m venv .venv
source .venv/bin/activate
```
1. Install Python Dependencies:
```bash
pip install -r requirements.txt
```
1. Execute Django Migrations:
```bash
python manage.py migrate
```
1. Create Admin Account (optional):
```bash
python manage.py createsuperuser
```
1. Run the Project:
```bash
python manage.py runserver
```
# Testing
You can run tests by executing the following command (in the repository root):
```bash
python manage.py test
```
**Note:** Tests are quite poor at the moment.
from typing import Union
from django.db.models.query import QuerySet
from django.contrib import admin
from django.core.handlers.wsgi import WSGIRequest
from .models import PublicAttachment, PrivateAttachment, Image, GenericAttachment
# https://docs.djangoproject.com/en/2.2/ref/contrib/admin/actions/#writing-action-functions
def delete_everywhere(model_admin: Union[PublicAttachment, PrivateAttachment],
request: WSGIRequest,
queryset: QuerySet):
"""
Delete object both in Django and in MinIO too.
:param model_admin: unused
:param request: unused
:param queryset: A QuerySet containing the set of objects selected by the user
:return:
"""
del model_admin, request # We don't need these
for obj in queryset:
obj.delete()
delete_everywhere.short_description = "Delete selected objects in Django and MinIO"
@admin.register(Image)
class ImageAdmin(admin.ModelAdmin):
list_display = ('id', 'image',)
readonly_fields = ('id', )
model = Image
actions = [delete_everywhere, ]
@admin.register(GenericAttachment)
class GenericAttachmentAdmin(admin.ModelAdmin):
list_display = ('id', 'file',)
readonly_fields = ('id', )
model = GenericAttachment
actions = [delete_everywhere, ]
# Register your models here.
@admin.register(PublicAttachment)
class PublicAttachmentAdmin(admin.ModelAdmin):
list_display = ('id', 'content_type',)
readonly_fields = ('id', 'content_object', 'file_name', 'file_size', )
model = PublicAttachment
actions = [delete_everywhere, ]
fieldsets = [
('General Information',
{'fields': ('id',)}),
('S3 Object',
{'fields': ('file_name', 'file_size', 'file',)}),
('S3 Object Details',
{'fields': ('content_object', 'content_type', 'object_id',)}),
]
def get_actions(self, request):
actions = super().get_actions(request)
if 'delete_selected' in actions:
del actions['delete_selected']
return actions
@admin.register(PrivateAttachment)
class PrivateAttachmentAdmin(admin.ModelAdmin):
list_display = ('id', 'content_type',)
readonly_fields = ('id', 'content_object', 'file_name', 'file_size')
model = PrivateAttachment
actions = [delete_everywhere, ]
fieldsets = [
('General Information',
{'fields': ('id',)}),
('S3 Object',
{'fields': ('file_name', 'file_size', 'file',)}),
('S3 Object Details',
{'fields': ('content_object', 'content_type', 'object_id',)}),
]
def get_actions(self, request):
actions = super().get_actions(request)
if 'delete_selected' in actions:
del actions['delete_selected']
return actions
from django.apps import AppConfig
class DjangoExampleApplicationConfig(AppConfig):
name = 'DjangoExampleApplication'
https://pixabay.com/photos/audience-concert-music-868074/
Simplified Pixabay License
https://pixabay.com/service/license/
Free for commercial use
No attribution required
# Generated by Django 3.1.3 on 2020-11-15 20:23
import DjangoExampleApplication.models
from django.db import migrations, models
import django.db.models.deletion
import django_minio_backend.models
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
]
operations = [
migrations.CreateModel(
name='Image',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('image', models.ImageField(storage=django_minio_backend.models.MinioBackend(bucket_name='django-backend-dev-public'), upload_to=django_minio_backend.models.iso_date_prefix)),
],
),
migrations.CreateModel(
name='PublicAttachment',
fields=[
('id', models.AutoField(primary_key=True, serialize=False, verbose_name='Public Attachment ID')),
('object_id', models.PositiveIntegerField(verbose_name="Related Object's ID")),
('file', models.FileField(storage=django_minio_backend.models.MinioBackend(bucket_name='django-backend-dev-public'), upload_to=django_minio_backend.models.iso_date_prefix, verbose_name='Object Upload')),
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype', verbose_name='Content Type')),
],
),
migrations.CreateModel(
name='PrivateAttachment',
fields=[
('id', models.AutoField(primary_key=True, serialize=False, verbose_name='Public Attachment ID')),
('object_id', models.PositiveIntegerField(verbose_name="Related Object's ID")),
('file', models.FileField(storage=django_minio_backend.models.MinioBackend(bucket_name='django-backend-dev-private'), upload_to=DjangoExampleApplication.models.PrivateAttachment.set_file_path_name, verbose_name='Object Upload')),
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype', verbose_name='Content Type')),
],
),
]
# Generated by Django 3.1.3 on 2021-03-13 10:49
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('DjangoExampleApplication', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='privateattachment',
name='content_type',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype', verbose_name='Content Type'),
),
migrations.AlterField(
model_name='privateattachment',
name='object_id',
field=models.PositiveIntegerField(blank=True, null=True, verbose_name="Related Object's ID"),
),
migrations.AlterField(
model_name='publicattachment',
name='content_type',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype', verbose_name='Content Type'),
),
migrations.AlterField(
model_name='publicattachment',
name='object_id',
field=models.PositiveIntegerField(blank=True, null=True, verbose_name="Related Object's ID"),
),
]
# Generated by Django 3.2.3 on 2021-07-18 22:07
from django.db import migrations, models
import uuid
class Migration(migrations.Migration):
dependencies = [
('DjangoExampleApplication', '0002_auto_20210313_1049'),
]
operations = [
migrations.CreateModel(
name='GenericAttachment',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('file', models.FileField(upload_to='', verbose_name='Object Upload (to default storage)')),
],
),
]
import uuid
import datetime
from django.db import models
from django.db.models.fields.files import FieldFile
from django.utils.timezone import utc
from django.contrib.contenttypes.models import ContentType
from django.contrib.contenttypes.fields import GenericForeignKey
from django_minio_backend import MinioBackend, iso_date_prefix
def get_iso_date() -> str:
now = datetime.datetime.utcnow().replace(tzinfo=utc)
return f"{now.year}-{now.month}-{now.day}"
class Image(models.Model):
"""
This is just for uploaded image
"""
objects = models.Manager()
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
image = models.ImageField(upload_to=iso_date_prefix, storage=MinioBackend(bucket_name='django-backend-dev-public'))
def delete(self, *args, **kwargs):
"""
Delete must be overridden because the inherited delete method does not call `self.image.delete()`.
"""
# noinspection PyUnresolvedReferences
self.image.delete()
super(Image, self).delete(*args, **kwargs)
class GenericAttachment(models.Model):
"""
This is for demonstrating uploads to the default file storage
"""
objects = models.Manager()
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
file = models.FileField(verbose_name="Object Upload (to default storage)")
def delete(self, *args, **kwargs):
"""
Delete must be overridden because the inherited delete method does not call `self.image.delete()`.
"""
# noinspection PyUnresolvedReferences
self.file.delete()
super(GenericAttachment, self).delete(*args, **kwargs)
# Create your models here.
class PublicAttachment(models.Model):
def set_file_path_name(self, file_name_ext: str) -> str:
"""
Defines the full absolute path to the file in the bucket. The original content's type is used as parent folder.
:param file_name_ext: (str) File name + extension. ie.: cat.png OR images/animals/2019/cat.png
:return: (str) Absolute path to file in Minio Bucket
"""
return f"{get_iso_date()}/{self.content_type.name}/{file_name_ext}"
def delete(self, *args, **kwargs):
"""
Delete must be overridden because the inherited delete method does not call `self.file.delete()`.
"""
self.file.delete()
super(PublicAttachment, self).delete(*args, **kwargs)
@property
def file_name(self):
try:
return self.file.name.split("/")[-1]
except AttributeError:
return "[Deleted Object]"
@property
def file_size(self):
return self.file.size
def __str__(self):
return str(self.file)
id = models.AutoField(primary_key=True, verbose_name="Public Attachment ID")
content_type: ContentType = models.ForeignKey(ContentType, null=True, blank=True, on_delete=models.CASCADE,
verbose_name="Content Type")
object_id = models.PositiveIntegerField(null=True, blank=True, verbose_name="Related Object's ID")
content_object = GenericForeignKey("content_type", "object_id")
file: FieldFile = models.FileField(verbose_name="Object Upload",
storage=MinioBackend( # Configure MinioBackend as storage backend here
bucket_name='django-backend-dev-public',
),
upload_to=iso_date_prefix)
class PrivateAttachment(models.Model):
def set_file_path_name(self, file_name_ext: str) -> str:
"""
Defines the full absolute path to the file in the bucket. The original content's type is used as parent folder.
:param file_name_ext: (str) File name + extension. ie.: cat.png OR images/animals/2019/cat.png
:return: (str) Absolute path to file in Minio Bucket
"""
return f"{get_iso_date()}/{self.content_type.name}/{file_name_ext}"
def delete(self, *args, **kwargs):
"""
Delete must be overridden because the inherited delete method does not call `self.file.delete()`.
"""
self.file.delete()
super(PrivateAttachment, self).delete(*args, **kwargs)
@property
def file_name(self):
try:
return self.file.name.split("/")[-1]
except AttributeError:
return "[Deleted Object]"
@property
def file_size(self):
return self.file.size
def __str__(self):
return str(self.file)
id = models.AutoField(primary_key=True, verbose_name="Public Attachment ID")
content_type: ContentType = models.ForeignKey(ContentType, null=True, blank=True, on_delete=models.CASCADE,
verbose_name="Content Type")
object_id = models.PositiveIntegerField(null=True, blank=True, verbose_name="Related Object's ID")
content_object = GenericForeignKey("content_type", "object_id")
file: FieldFile = models.FileField(verbose_name="Object Upload",
storage=MinioBackend( # Configure MinioBackend as storage backend here
bucket_name='django-backend-dev-private',
),
upload_to=set_file_path_name)
import time
from pathlib import Path
from django.conf import settings
from django.core.files import File
from django.contrib.contenttypes.models import ContentType
from django.test import TestCase
from django.core.validators import URLValidator
from DjangoExampleApplication.models import Image, PublicAttachment, PrivateAttachment
test_file_path = Path(settings.BASE_DIR) / "DjangoExampleApplication" / "assets" / "audience-868074_1920.jpg"
test_file_size = 339085
class ImageTestCase(TestCase):
obj: Image = None
def setUp(self):
# Open a test file from disk and upload to minIO as an image
with open(test_file_path, 'rb') as f:
self.obj = Image.objects.create()
self.obj.image.save(name='audience-868074_1920.jpg', content=f)
def tearDown(self):
# Remove uploaded file from minIO and remove the Image entry from Django's database
self.obj.delete() # deletes from both locations
def test_url_generation_works(self):
"""Accessing the value of obj.image.url"""
val = URLValidator()
val(self.obj.image.url) # 1st make sure it's an URL
self.assertTrue('audience-868074_1920' in self.obj.image.url) # 2nd make sure our filename matches
def test_read_image_size(self):
self.assertEqual(self.obj.image.size, test_file_size)
class PublicAttachmentTestCase(TestCase):
obj: PublicAttachment = None
filename = f'public_audience-868074_1920_{int(time.time())}.jpg' # adding unix time makes our filename unique
def setUp(self):
ct = ContentType.objects.get(app_label='auth', model='user') # PublicAttachment is generic so this is needed
with open(test_file_path, 'rb') as f:
# noinspection PyUnresolvedReferences
self.obj = PublicAttachment.objects.create()
self.obj.ct = ct
self.obj.object_id = 1 # we associate this uploaded file to user with pk=1
self.obj.file.save(name=self.filename, content=File(f), save=True)
def test_url_generation_works(self):
"""Accessing the value of obj.file.url"""
val = URLValidator()
val(self.obj.file.url) # 1st make sure it's an URL
self.assertTrue('public_audience-868074_1920' in self.obj.file.url) # 2nd make sure our filename matches
def test_read_file_size(self):
self.assertEqual(self.obj.file_size, test_file_size)
def test_read_file_name(self):
self.assertEqual(self.obj.file_name, self.filename)
class PrivateAttachmentTestCase(TestCase):
obj: PrivateAttachment = None
filename = f'private_audience-868074_1920_{int(time.time())}.jpg' # adding unix time makes our filename unique
def setUp(self):
ct = ContentType.objects.get(app_label='auth', model='user') # PublicAttachment is generic so this is needed
with open(test_file_path, 'rb') as f:
# noinspection PyUnresolvedReferences
self.obj = PublicAttachment.objects.create()
self.obj.ct = ct
self.obj.object_id = 1 # we associate this uploaded file to user with pk=1
self.obj.file.save(name=self.filename, content=File(f), save=True)
def test_url_generation_works(self):
"""Accessing the value of obj.file.url"""
val = URLValidator()
val(self.obj.file.url) # 1st make sure it's an URL
self.assertTrue('private_audience-868074_1920' in self.obj.file.url) # 2nd make sure our filename matches
def test_read_file_size(self):
self.assertEqual(self.obj.file_size, test_file_size)
def test_read_file_name(self):
self.assertEqual(self.obj.file_name, self.filename)
"""
ASGI config for DjangoExampleProject project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/3.0/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'DjangoExampleProject.settings')
application = get_asgi_application()
"""
Django settings for DjangoExampleProject project.
Generated by 'django-admin startproject' using Django 3.0.6.
For more information on this file, see
https://docs.djangoproject.com/en/3.0/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/3.0/ref/settings/
"""
import os
import distutils.util
from datetime import timedelta
from typing import List, Tuple
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/3.0/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'sp1d7_7z9))q58(6k&1)9m_@!8e420*m+3dasq-*711fu8)y!6'
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
ALLOWED_HOSTS = []
# Application definition
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'django_minio_backend.apps.DjangoMinioBackendConfig', # Driver
'DjangoExampleApplication', # Test App
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'DjangoExampleProject.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
WSGI_APPLICATION = 'DjangoExampleProject.wsgi.application'
# Database
# https://docs.djangoproject.com/en/3.0/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
}
}
# Password validation
# https://docs.djangoproject.com/en/3.0/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
# Internationalization
# https://docs.djangoproject.com/en/3.0/topics/i18n/
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_L10N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/3.0/howto/static-files/
STATIC_URL = '/static/'
STATICFILES_STORAGE = 'django_minio_backend.models.MinioBackendStatic'
DEFAULT_FILE_STORAGE = 'django_minio_backend.models.MinioBackend'
# #################### #
# django_minio_backend #
# #################### #
dummy_policy = {"Version": "2012-10-17",
"Statement": [
{
"Sid": "",
"Effect": "Allow",
"Principal": {"AWS": "*"},
"Action": "s3:GetBucketLocation",
"Resource": f"arn:aws:s3:::django-backend-dev-private"
},
{
"Sid": "",
"Effect": "Allow",
"Principal": {"AWS": "*"},
"Action": "s3:ListBucket",
"Resource": f"arn:aws:s3:::django-backend-dev-private"
},
{
"Sid": "",
"Effect": "Allow",
"Principal": {"AWS": "*"},
"Action": "s3:GetObject",
"Resource": f"arn:aws:s3:::django-backend-dev-private/*"
}
]}
MINIO_ENDPOINT = os.getenv("GH_MINIO_ENDPOINT", "play.min.io")
MINIO_EXTERNAL_ENDPOINT = os.getenv("GH_MINIO_EXTERNAL_ENDPOINT", "externalplay.min.io")
MINIO_EXTERNAL_ENDPOINT_USE_HTTPS = bool(distutils.util.strtobool(os.getenv("GH_MINIO_EXTERNAL_ENDPOINT_USE_HTTPS", "true")))
MINIO_ACCESS_KEY = os.getenv("GH_MINIO_ACCESS_KEY", "Q3AM3UQ867SPQQA43P2F")
MINIO_SECRET_KEY = os.getenv("GH_MINIO_SECRET_KEY", "zuf+tfteSlswRu7BJ86wekitnifILbZam1KYY3TG")
MINIO_USE_HTTPS = bool(distutils.util.strtobool(os.getenv("GH_MINIO_USE_HTTPS", "true")))
MINIO_REGION = os.getenv("GH_MINIO_REGION", "us-east-1")
MINIO_PRIVATE_BUCKETS = [
'django-backend-dev-private',
'my-media-files-bucket',
]
MINIO_PUBLIC_BUCKETS = [
'django-backend-dev-public',
't5p2g08k31',
'7xi7lx9rjh',
'my-static-files-bucket',
]
MINIO_URL_EXPIRY_HOURS = timedelta(days=1) # Default is 7 days (longest) if not defined
MINIO_CONSISTENCY_CHECK_ON_START = True
MINIO_POLICY_HOOKS: List[Tuple[str, dict]] = [
# ('django-backend-dev-private', dummy_policy)
]
MINIO_MEDIA_FILES_BUCKET = 'my-media-files-bucket' # replacement for STATIC_ROOT
MINIO_STATIC_FILES_BUCKET = 'my-static-files-bucket' # replacement for MEDIA_ROOT
MINIO_BUCKET_CHECK_ON_SAVE = False # Create bucket if missing, then save
"""DjangoExampleProject URL Configuration
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/3.0/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: path('', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.urls import path
urlpatterns = [
path('admin/', admin.site.urls),
]
"""
WSGI config for DjangoExampleProject project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/3.0/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'DjangoExampleProject.settings')
application = get_wsgi_application()
# syntax=docker/dockerfile:1
FROM python:3
ENV PYTHONUNBUFFERED=1
WORKDIR /code
# Copy Demo Project
COPY ./manage.py /code/manage.py
COPY ./django_minio_backend /code/django_minio_backend
COPY ./DjangoExampleProject /code/DjangoExampleProject
COPY ./DjangoExampleApplication /code/DjangoExampleApplication
# Copy and install requirements.txt
COPY requirements.txt /code/
RUN pip install -r /code/requirements.txt
MIT License
Copyright (c) 2019 Kristof
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
include LICENSE
include README*.md
include RELEASE-VERSION
include version.py
# recursive-include docs *
recursive-include django_minio_backend/management *
# Docker Compose Description for django-minio-backend
Execute the following step to start a demo environment using Docker Compose:
**Start the Docker Compose services:**
```shell
docker compose up -d
docker compose exec web python manage.py createsuperuser --noinput
docker compose exec web python manage.py collectstatic --noinput
```
## About docker-compose.yml
Note the following lines in `docker-compose.yml`:
```yaml
environment:
GH_MINIO_ENDPOINT: "nginx:9000"
GH_MINIO_USE_HTTPS: "false"
GH_MINIO_EXTERNAL_ENDPOINT: "localhost:9000"
GH_MINIO_EXTERNAL_ENDPOINT_USE_HTTPS: "false"
```
MinIO is load balanced by nginx, so all connections made from Django towards MinIO happens through the internal `nginx` FQDN. <br>
Therefore, the value of `GH_MINIO_ENDPOINT` is `nginx:9000`.
# Web Access
Both Django(:8000) and MinIO(:9001) expose a Web GUI and their ports are mapped to the host machine.
## Django Admin
Open your browser at http://localhost:8000/admin to access the Django admin portal:
* username: `admin`
* password: `123123`
## MinIO Console
Open your browser at http://localhost:9001 to access the MiniIO Console:
* username: `minio`
* password: `minio123`
# Developer Environment
An alternative docker-compose file is available for **django-minio-backend** which does not copy the source files into the container, but maps them as a volume.
**Input file**: `docker-compose.develop.yml`
If you would like to develop in a Docker Compose environment, execute the following commands:
```shell
docker compose -f docker-compose.develop.yml up -d
docker compose -f docker-compose.develop.yml exec web python manage.py createsuperuser --noinput
docker compose -f docker-compose.develop.yml exec web python manage.py collectstatic --noinput
```
[![django-app-tests](https://github.com/theriverman/django-minio-backend/actions/workflows/django-tests.yml/badge.svg)](https://github.com/theriverman/django-minio-backend/actions/workflows/django-tests.yml)
[![publish-py-dist-to-pypi](https://github.com/theriverman/django-minio-backend/actions/workflows/publish-to-pypi.yml/badge.svg)](https://github.com/theriverman/django-minio-backend/actions/workflows/publish-to-pypi.yml)
[![PYPI](https://img.shields.io/pypi/v/django-minio-backend.svg)](https://pypi.python.org/pypi/django-minio-backend)
# django-minio-backend
The **django-minio-backend** provides a wrapper around the
[MinIO Python SDK](https://docs.min.io/docs/python-client-quickstart-guide.html).
See [minio/minio-py](https://github.com/minio/minio-py) for the source.
## Integration
1. Get and install the package:
```bash
pip install django-minio-backend
```
2. Add `django_minio_backend` to `INSTALLED_APPS`:
```python
INSTALLED_APPS = [
# '...'
'django_minio_backend', # https://github.com/theriverman/django-minio-backend
]
```
If you would like to enable on-start consistency check, install via `DjangoMinioBackendConfig`:
```python
INSTALLED_APPS = [
# '...'
'django_minio_backend.apps.DjangoMinioBackendConfig', # https://github.com/theriverman/django-minio-backend
]
```
Then add the following parameter to your settings file:
```python
MINIO_CONSISTENCY_CHECK_ON_START = True
```
**Note:** The on-start consistency check equals to manually calling `python manage.py initialize_buckets`. <br>
It is recommended to turn *off* this feature during development by setting `MINIO_CONSISTENCY_CHECK_ON_START` to `False`,
because this operation can noticeably slow down Django's boot time when many buckets are configured.
3. Add the following parameters to your `settings.py`:
```python
from datetime import timedelta
from typing import List, Tuple
MINIO_ENDPOINT = 'minio.your-company.co.uk'
MINIO_EXTERNAL_ENDPOINT = "external-minio.your-company.co.uk" # Default is same as MINIO_ENDPOINT
MINIO_EXTERNAL_ENDPOINT_USE_HTTPS = True # Default is same as MINIO_USE_HTTPS
MINIO_REGION = 'us-east-1' # Default is set to None
MINIO_ACCESS_KEY = 'yourMinioAccessKey'
MINIO_SECRET_KEY = 'yourVeryS3cr3tP4ssw0rd'
MINIO_USE_HTTPS = True
MINIO_URL_EXPIRY_HOURS = timedelta(days=1) # Default is 7 days (longest) if not defined
MINIO_CONSISTENCY_CHECK_ON_START = True
MINIO_PRIVATE_BUCKETS = [
'django-backend-dev-private',
]
MINIO_PUBLIC_BUCKETS = [
'django-backend-dev-public',
]
MINIO_POLICY_HOOKS: List[Tuple[str, dict]] = []
# MINIO_MEDIA_FILES_BUCKET = 'my-media-files-bucket' # replacement for MEDIA_ROOT
# MINIO_STATIC_FILES_BUCKET = 'my-static-files-bucket' # replacement for STATIC_ROOT
MINIO_BUCKET_CHECK_ON_SAVE = True # Default: True // Creates bucket if missing, then save
# Custom HTTP Client (OPTIONAL)
import os
import certifi
import urllib3
timeout = timedelta(minutes=5).seconds
ca_certs = os.environ.get('SSL_CERT_FILE') or certifi.where()
MINIO_HTTP_CLIENT: urllib3.poolmanager.PoolManager = urllib3.PoolManager(
timeout=urllib3.util.Timeout(connect=timeout, read=timeout),
maxsize=10,
cert_reqs='CERT_REQUIRED',
ca_certs=ca_certs,
retries=urllib3.Retry(
total=5,
backoff_factor=0.2,
status_forcelist=[500, 502, 503, 504]
)
)
```
4. Implement your own Attachment handler and integrate **django-minio-backend**:
```python
from django.db import models
from django_minio_backend import MinioBackend, iso_date_prefix
class PrivateAttachment(models.Model):
file = models.FileField(verbose_name="Object Upload",
storage=MinioBackend(bucket_name='django-backend-dev-private'),
upload_to=iso_date_prefix)
```
5. Initialize the buckets & set their public policy (OPTIONAL):<br>
This `django-admin` command creates both the private and public buckets in case one of them does not exists,
and sets the *public* bucket's privacy policy from `private`(default) to `public`.<br>
```bash
python manage.py initialize_buckets
```
Code reference: [initialize_buckets.py](django_minio_backend/management/commands/initialize_buckets.py).
### Static Files Support
**django-minio-backend** allows serving static files from MinIO.
To learn more about Django static files, see [Managing static files](https://docs.djangoproject.com/en/3.2/howto/static-files/), and [STATICFILES_STORAGE](https://docs.djangoproject.com/en/3.2/ref/settings/#staticfiles-storage).
To enable static files support, update your `settings.py`:
```python
STATICFILES_STORAGE = 'django_minio_backend.models.MinioBackendStatic'
MINIO_STATIC_FILES_BUCKET = 'my-static-files-bucket' # replacement for STATIC_ROOT
# Add the value of MINIO_STATIC_FILES_BUCKET to one of the pre-configured bucket lists. eg.:
# MINIO_PRIVATE_BUCKETS.append(MINIO_STATIC_FILES_BUCKET)
# MINIO_PUBLIC_BUCKETS.append(MINIO_STATIC_FILES_BUCKET)
```
The value of `STATIC_URL` is ignored, but it must be defined otherwise Django will throw an error.
**IMPORTANT**<br>
The value set in `MINIO_STATIC_FILES_BUCKET` must be added either to `MINIO_PRIVATE_BUCKETS` or `MINIO_PUBLIC_BUCKETS`,
otherwise **django-minio-backend** will raise an exception. This setting determines the privacy of generated file URLs which can be unsigned public or signed private.
**Note:** If `MINIO_STATIC_FILES_BUCKET` is not set, the default value (`auto-generated-bucket-static-files`) will be used. Policy setting for default buckets is **private**.
### Default File Storage Support
**django-minio-backend** can be configured as a default file storage.
To learn more, see [DEFAULT_FILE_STORAGE](https://docs.djangoproject.com/en/3.2/ref/settings/#default-file-storage).
To configure **django-minio-backend** as the default file storage, update your `settings.py`:
```python
DEFAULT_FILE_STORAGE = 'django_minio_backend.models.MinioBackend'
MINIO_MEDIA_FILES_BUCKET = 'my-media-files-bucket' # replacement for MEDIA_ROOT
# Add the value of MINIO_STATIC_FILES_BUCKET to one of the pre-configured bucket lists. eg.:
# MINIO_PRIVATE_BUCKETS.append(MINIO_STATIC_FILES_BUCKET)
# MINIO_PUBLIC_BUCKETS.append(MINIO_STATIC_FILES_BUCKET)
```
The value of `MEDIA_URL` is ignored, but it must be defined otherwise Django will throw an error.
**IMPORTANT**<br>
The value set in `MINIO_MEDIA_FILES_BUCKET` must be added either to `MINIO_PRIVATE_BUCKETS` or `MINIO_PUBLIC_BUCKETS`,
otherwise **django-minio-backend** will raise an exception. This setting determines the privacy of generated file URLs which can be unsigned public or signed private.
**Note:** If `MINIO_MEDIA_FILES_BUCKET` is not set, the default value (`auto-generated-bucket-media-files`) will be used. Policy setting for default buckets is **private**.
### Health Check
To check the connection link between Django and MinIO, use the provided `MinioBackend.is_minio_available()` method.<br>
It returns a `MinioServerStatus` instance which can be quickly evaluated as boolean.<br>
**Example:**
```python
from django_minio_backend import MinioBackend
minio_available = MinioBackend().is_minio_available() # An empty string is fine this time
if minio_available:
print("OK")
else:
print("NOK")
print(minio_available.details)
```
### Policy Hooks
You can configure **django-minio-backend** to automatically execute a set of pre-defined policy hooks. <br>
Policy hooks can be defined in `settings.py` by adding `MINIO_POLICY_HOOKS` which must be a list of tuples. <br>
Policy hooks are automatically picked up by the `initialize_buckets` management command.
For an exemplary policy, see the implementation of `def set_bucket_to_public(self)`
in [django_minio_backend/models.py](django_minio_backend/models.py) or the contents
of [examples/policy_hook.example.py](examples/policy_hook.example.py).
### Consistency Check On Start
When enabled, the `initialize_buckets` management command gets called automatically when Django starts. <br>
This command connects to the configured minIO server and checks if all buckets defined in `settings.py`. <br>
In case a bucket is missing or its configuration differs, it gets created and corrected.
### Reference Implementation
For a reference implementation, see [Examples](examples).
## Behaviour
The following list summarises the key characteristics of **django-minio-backend**:
* Bucket existence is **not** checked on a save by default.
To enable this guard, set `MINIO_BUCKET_CHECK_ON_SAVE = True` in your `settings.py`.
* Bucket existences are **not** checked on Django start by default.
To enable this guard, set `MINIO_CONSISTENCY_CHECK_ON_START = True` in your `settings.py`.
* Many configuration errors are validated through `AppConfig` but not every error can be captured there.
* Files with the same name in the same bucket are **not** replaced on save by default. Django will store the newer file with an altered file name
To allow replacing existing files, pass the `replace_existing=True` kwarg to `MinioBackend`.
For example: `image = models.ImageField(storage=MinioBackend(bucket_name='images-public', replace_existing=True))`
* Depending on your configuration, **django-minio-backend** may communicate over two kind of interfaces: internal and external.
If your `settings.py` defines a different value for `MINIO_ENDPOINT` and `MINIO_EXTERNAL_ENDPOINT`, then the former will be used for internal communication
between Django and MinIO, and the latter for generating URLs for users. This behaviour optimises the network communication.
See **Networking** below for a thorough explanation
* The uploaded object's content-type is guessed during save. If `mimetypes.guess_type` fails to determine the correct content-type, then it falls back to `application/octet-stream`.
## Networking and Docker
If your Django application is running on a shared host with your MinIO instance, you should consider using the `MINIO_EXTERNAL_ENDPOINT` and `MINIO_EXTERNAL_ENDPOINT_USE_HTTPS` parameters.
This way most traffic will happen internally between Django and MinIO. The external endpoint parameters are required for external pre-signed URL generation.
If your Django application and MinIO instance are running on different hosts, you can omit the `MINIO_EXTERNAL_ENDPOINT` and `MINIO_EXTERNAL_ENDPOINT_USE_HTTPS` parameters,
and **django-minio-backend** will default to the value of `MINIO_ENDPOINT`.
Setting up and configuring custom networks in Docker is not in the scope of this document. <br>
To learn more about Docker networking, see [Networking overview](https://docs.docker.com/network/) and [Networking in Compose](https://docs.docker.com/compose/networking/).
See [README.Docker.md](README.Docker.md) for a real-life Docker Compose demonstration.
## Compatibility
* Django 2.2 or later
* Python 3.8.0 or later
* MinIO SDK 7.0.2 or later
## Contribution
Please find the details in [CONTRIBUTE.md](CONTRIBUTE.md)
## Copyright
* theriverman/django-minio-backend licensed under the MIT License
* minio/minio-py is licensed under the Apache License 2.0
Subproject commit 1bd19bedada908fc7888706712b45225ba12b404
from .apps import *
from .models import *
from django.apps import AppConfig
from .utils import get_setting, ConfigurationError
from .models import MinioBackend, MinioBackendStatic
__all__ = ['DjangoMinioBackendConfig', ]
class DjangoMinioBackendConfig(AppConfig):
name = 'django_minio_backend'
def ready(self):
mb = MinioBackend()
mb.validate_settings()
consistency_check_on_start = get_setting('MINIO_CONSISTENCY_CHECK_ON_START', False)
if consistency_check_on_start:
from django.core.management import call_command
print("Executing consistency checks...")
call_command('initialize_buckets', silenced=True)
# Validate configuration combinations for EXTERNAL ENDPOINT
external_address = bool(get_setting('MINIO_EXTERNAL_ENDPOINT'))
external_use_https = get_setting('MINIO_EXTERNAL_ENDPOINT_USE_HTTPS')
if (external_address and external_use_https is None) or (not external_address and external_use_https):
raise ConfigurationError('MINIO_EXTERNAL_ENDPOINT must be configured together with MINIO_EXTERNAL_ENDPOINT_USE_HTTPS')
# Validate static storage and default storage configurations
staticfiles_storage: str = get_setting('STATICFILES_STORAGE')
if staticfiles_storage.endswith(MinioBackendStatic.__name__):
mbs = MinioBackendStatic()
mbs.check_bucket_existence()
from typing import List
from django.core.management.base import BaseCommand
from django_minio_backend.models import MinioBackend
from django_minio_backend.utils import get_setting
class Command(BaseCommand):
help = 'Helps initializing Minio buckets by creating them and setting their policies.'
def add_arguments(self, parser):
parser.add_argument('--silenced', action='store_true', default=False, help='No console messages')
def handle(self, *args, **options):
silenced = options.get('silenced')
self.stdout.write(f"Initializing Minio buckets...\n") if not silenced else None
private_buckets: List[str] = get_setting("MINIO_PRIVATE_BUCKETS", [])
public_buckets: List[str] = get_setting("MINIO_PUBLIC_BUCKETS", [])
for bucket in [*public_buckets, *private_buckets]:
m = MinioBackend(bucket)
m.check_bucket_existence()
self.stdout.write(f"Bucket ({bucket}) OK", ending='\n') if not silenced else None
if m.is_bucket_public: # Based on settings.py configuration
m.set_bucket_to_public()
self.stdout.write(
f"Bucket ({m.bucket}) policy has been set to public", ending='\n') if not silenced else None
c = MinioBackend() # Client
for policy_tuple in get_setting('MINIO_POLICY_HOOKS', []):
bucket, policy = policy_tuple
c.set_bucket_policy(bucket, policy)
self.stdout.write(
f"Bucket ({m.bucket}) policy has been set via policy hook", ending='\n') if not silenced else None
self.stdout.write('\nAll private & public buckets have been verified.\n', ending='\n') if not silenced else None
self.stdout.flush()
from django.core.management.base import BaseCommand, CommandError
from django_minio_backend.models import MinioBackend
class Command(BaseCommand):
help = 'Checks if the configured MinIO service is available.'
def add_arguments(self, parser):
parser.add_argument('--silenced', action='store_true', default=False, help='No console messages')
def handle(self, *args, **options):
m = MinioBackend() # use default storage
silenced = options.get('silenced')
self.stdout.write(f"Checking the availability of MinIO at {m.base_url}\n") if not silenced else None
available = m.is_minio_available()
if not available:
self.stdout.flush()
raise CommandError(f'MinIO is NOT available at {m.base_url}\n'
f'Reason: {available.details}')
self.stdout.write(f'MinIO is available at {m.base_url}', ending='\n') if not silenced else None
self.stdout.flush()
"""
django-minio-backend
A MinIO-compatible custom storage backend for Django
References:
* https://github.com/minio/minio-py
* https://docs.djangoproject.com/en/3.2/howto/custom-file-storage/
"""
import io
import json
import logging
import mimetypes
import ssl
from datetime import datetime, timedelta
from pathlib import Path
from typing import Union, List
# noinspection PyPackageRequirements MinIO_requirement
import certifi
import minio
import minio.datatypes
import minio.error
import minio.helpers
# noinspection PyPackageRequirements MinIO_requirement
import urllib3
from django.core.files import File
from django.core.files.storage import Storage
from django.core.files.uploadedfile import InMemoryUploadedFile
from django.utils.deconstruct import deconstructible
from django.utils.timezone import utc
from .utils import MinioServerStatus, PrivatePublicMixedError, ConfigurationError, get_setting
__all__ = ['MinioBackend', 'MinioBackendStatic', 'get_iso_date', 'iso_date_prefix', ]
logger = logging.getLogger(__name__)
def get_iso_date() -> str:
"""Get current date in ISO8601 format [year-month-day] as string"""
now = datetime.utcnow().replace(tzinfo=utc)
return f"{now.year}-{now.month}-{now.day}"
def iso_date_prefix(_, file_name_ext: str) -> str:
"""
Get filename prepended with current date in ISO8601 format [year-month-day] as string
The date prefix will be the folder's name storing the object e.g.: 2020-12-31/cat.png
"""
return f"{get_iso_date()}/{file_name_ext}"
@deconstructible
class MinioBackend(Storage):
"""
:param bucket_name (str): The bucket's name where file(s) will be stored
:arg *args: An arbitrary number of arguments. Stored in the self._META_ARGS class field
:arg **kwargs: An arbitrary number of key-value arguments.
Stored in the self._META_KWARGS class field
Through self._META_KWARGS, the "metadata", "sse" and "progress" fields can be set
for the underlying put_object() MinIO SDK method
"""
DEFAULT_MEDIA_FILES_BUCKET = 'auto-generated-bucket-media-files'
DEFAULT_STATIC_FILES_BUCKET = 'auto-generated-bucket-static-files'
DEFAULT_PRIVATE_BUCKETS = [DEFAULT_MEDIA_FILES_BUCKET, DEFAULT_STATIC_FILES_BUCKET]
MINIO_MEDIA_FILES_BUCKET = get_setting("MINIO_MEDIA_FILES_BUCKET", default=DEFAULT_MEDIA_FILES_BUCKET)
MINIO_STATIC_FILES_BUCKET = get_setting("MINIO_STATIC_FILES_BUCKET", default=DEFAULT_STATIC_FILES_BUCKET)
def __init__(self,
bucket_name: str = '',
*args,
**kwargs):
# If bucket_name is not provided, MinioBackend acts as a DEFAULT_FILE_STORAGE
# The automatically selected bucket is MINIO_MEDIA_FILES_BUCKET from settings.py
# See https://docs.djangoproject.com/en/3.2/ref/settings/#default-file-storage
if not bucket_name or bucket_name == '':
self.__CONFIGURED_AS_DEFAULT_STORAGE = True
self._BUCKET_NAME: str = self.MINIO_MEDIA_FILES_BUCKET
else:
self.__CONFIGURED_AS_DEFAULT_STORAGE = False
self._BUCKET_NAME: str = bucket_name
self._META_ARGS = args
self._META_KWARGS = kwargs
self._REPLACE_EXISTING = kwargs.get('replace_existing', False)
self.__CLIENT: Union[minio.Minio, None] = None # This client is used for internal communication only. Communication this way should not leave the host network's perimeter
self.__CLIENT_EXT: Union[minio.Minio, None] = None # This client is used for external communication. This client is necessary for creating region-aware pre-signed URLs
self.__MINIO_ENDPOINT: str = get_setting("MINIO_ENDPOINT", "")
self.__MINIO_EXTERNAL_ENDPOINT: str = get_setting("MINIO_EXTERNAL_ENDPOINT", self.__MINIO_ENDPOINT)
self.__MINIO_ACCESS_KEY: str = get_setting("MINIO_ACCESS_KEY")
self.__MINIO_SECRET_KEY: str = get_setting("MINIO_SECRET_KEY")
self.__MINIO_USE_HTTPS: bool = get_setting("MINIO_USE_HTTPS")
self.__MINIO_REGION: str = get_setting("MINIO_REGION", "us-east-1") # MINIO defaults to "us-east-1" when region is set to None
self.__MINIO_EXTERNAL_ENDPOINT_USE_HTTPS: bool = get_setting("MINIO_EXTERNAL_ENDPOINT_USE_HTTPS", self.__MINIO_USE_HTTPS)
self.__MINIO_BUCKET_CHECK_ON_SAVE: bool = get_setting("MINIO_BUCKET_CHECK_ON_SAVE", False)
self.__BASE_URL = ("https://" if self.__MINIO_USE_HTTPS else "http://") + self.__MINIO_ENDPOINT
self.__BASE_URL_EXTERNAL = ("https://" if self.__MINIO_EXTERNAL_ENDPOINT_USE_HTTPS else "http://") + self.__MINIO_EXTERNAL_ENDPOINT
self.__SAME_ENDPOINTS = self.__MINIO_ENDPOINT == self.__MINIO_EXTERNAL_ENDPOINT
self.PRIVATE_BUCKETS: List[str] = get_setting("MINIO_PRIVATE_BUCKETS", [])
self.PUBLIC_BUCKETS: List[str] = get_setting("MINIO_PUBLIC_BUCKETS", [])
# Configure storage type
self.__STORAGE_TYPE = 'custom'
if self.bucket == self.MINIO_MEDIA_FILES_BUCKET:
self.__STORAGE_TYPE = 'media'
if self.bucket == self.MINIO_STATIC_FILES_BUCKET:
self.__STORAGE_TYPE = 'static'
# Enforce good bucket security (private vs public)
if (self.bucket in self.DEFAULT_PRIVATE_BUCKETS) and (self.bucket not in [*self.PRIVATE_BUCKETS, *self.PUBLIC_BUCKETS]):
self.PRIVATE_BUCKETS.extend(self.DEFAULT_PRIVATE_BUCKETS) # policy for default buckets is PRIVATE
# Require custom buckets to be declared explicitly
if self.bucket not in [*self.PRIVATE_BUCKETS, *self.PUBLIC_BUCKETS]:
raise ConfigurationError(f'The configured bucket ({self.bucket}) must be declared either in MINIO_PRIVATE_BUCKETS or MINIO_PUBLIC_BUCKETS')
# https://docs.min.io/docs/python-client-api-reference.html
http_client_from_kwargs = self._META_KWARGS.get("http_client", None)
http_client_from_settings = get_setting("MINIO_HTTP_CLIENT")
self.HTTP_CLIENT: urllib3.poolmanager.PoolManager = http_client_from_kwargs or http_client_from_settings
bucket_name_intersection: List[str] = list(set(self.PRIVATE_BUCKETS) & set(self.PUBLIC_BUCKETS))
if bucket_name_intersection:
raise PrivatePublicMixedError(
f'One or more buckets have been declared both private and public: {bucket_name_intersection}'
)
"""
django.core.files.storage.Storage
"""
def _save(self, file_path_name: str, content: InMemoryUploadedFile) -> str:
"""
Saves file to Minio by implementing Minio.put_object()
:param file_path_name (str): Path to file + file name + file extension | ie.: images/2018-12-31/cat.png
:param content (InMemoryUploadedFile): File object
:return:
"""
if self.__MINIO_BUCKET_CHECK_ON_SAVE:
# Create bucket if not exists
self.check_bucket_existence()
# Check if object with name already exists; delete if so
try:
if self._REPLACE_EXISTING and self.stat(file_path_name):
self.delete(file_path_name)
except AttributeError:
pass
# Upload object
file_path: Path = Path(file_path_name) # app name + file.suffix
content_bytes: io.BytesIO = io.BytesIO(content.read())
content_length: int = len(content_bytes.getvalue())
self.client.put_object(
bucket_name=self.bucket,
object_name=file_path.as_posix(),
data=content_bytes,
length=content_length,
content_type=self._guess_content_type(file_path_name, content),
metadata=self._META_KWARGS.get('metadata', None),
sse=self._META_KWARGS.get('sse', None),
progress=self._META_KWARGS.get('progress', None),
)
return file_path.as_posix()
def get_available_name(self, name, max_length=None):
"""
Return a filename that's free on the target storage system and
available for new content to be written to.
"""
if self._REPLACE_EXISTING:
return name
return super(MinioBackend, self).get_available_name(name, max_length)
def _open(self, object_name, mode='rb', **kwargs) -> File:
"""
Implements the Storage._open(name,mode='rb') method
:param name (str): object_name [path to file excluding bucket name which is implied]
:kwargs (dict): passed on to the underlying MinIO client's get_object() method
"""
resp: urllib3.response.HTTPResponse = urllib3.response.HTTPResponse()
if mode != 'rb':
raise ValueError('Files retrieved from MinIO are read-only. Use save() method to override contents')
try:
resp = self.client.get_object(self.bucket, object_name, kwargs)
file = File(file=io.BytesIO(resp.read()), name=object_name)
finally:
resp.close()
resp.release_conn()
return file
def stat(self, name: str) -> Union[minio.datatypes.Object, bool]:
"""Get object information and metadata of an object"""
object_name = Path(name).as_posix()
try:
obj = self.client.stat_object(self.bucket, object_name=object_name)
return obj
except (minio.error.S3Error, minio.error.ServerError, urllib3.exceptions.MaxRetryError):
raise AttributeError(f'Could not stat object ({name}) in bucket ({self.bucket})')
def delete(self, name: str):
"""
Deletes an object in Django and MinIO.
This method is called only when an object is deleted from its own `change view` ie.:
http://django.test/admin/upload/privateattachment/13/change/
This method is NOT called during a bulk_delete order!
:param name: File object name
"""
object_name = Path(name).as_posix()
self.client.remove_object(bucket_name=self.bucket, object_name=object_name)
def exists(self, name: str) -> bool:
"""Check if an object with name already exists"""
object_name = Path(name).as_posix()
try:
if self.stat(object_name):
return True
return False
except AttributeError as e:
logger.info(e)
return False
def listdir(self, bucket_name: str):
"""List all objects in a bucket"""
objects = self.client.list_objects(bucket_name=bucket_name, recursive=True)
return [(obj.object_name, obj) for obj in objects]
def size(self, name: str) -> int:
"""Get an object's size"""
object_name = Path(name).as_posix()
try:
obj = self.stat(object_name)
return obj.size if obj else 0
except AttributeError:
return 0
def url(self, name: str):
"""
Returns url to object.
If bucket is public, direct link is provided.
if bucket is private, a pre-signed link is provided.
:param name: (str) file path + file name + suffix
:return: (str) URL to object
"""
client = self.client if self.same_endpoints else self.client_external
if self.is_bucket_public:
# noinspection PyProtectedMember
base_url = client._base_url.build("GET", self.__MINIO_REGION).geturl()
return f'{base_url}{self.bucket}/{name}'
# private bucket
try:
u: str = client.presigned_get_object(
bucket_name=self.bucket,
object_name=name.encode('utf-8'),
expires=get_setting("MINIO_URL_EXPIRY_HOURS", timedelta(days=7)) # Default is 7 days
)
return u
except urllib3.exceptions.MaxRetryError:
raise ConnectionError("Couldn't connect to Minio. Check django_minio_backend parameters in Django-Settings")
def path(self, name):
"""The MinIO storage system doesn't support absolute paths"""
raise NotImplementedError("The MinIO storage system doesn't support absolute paths.")
def get_accessed_time(self, name: str) -> datetime:
"""
Return the last accessed time (as a datetime) of the file specified by
name. The datetime will be timezone-aware if USE_TZ=True.
"""
raise NotImplementedError('MinIO does not store last accessed time')
def get_created_time(self, name: str) -> datetime:
"""
Return the creation time (as a datetime) of the file specified by name.
The datetime will be timezone-aware if USE_TZ=True.
"""
raise NotImplementedError('MinIO does not store creation time')
def get_modified_time(self, name: str) -> datetime:
"""
Return the last modified time (as a datetime) of the file specified by
name. The datetime will be timezone-aware if USE_TZ=True.
"""
if get_setting("USE_TZ"):
return self.stat(name).last_modified
return self.stat(name).last_modified.replace(tzinfo=None) # remove timezone info
@staticmethod
def _guess_content_type(file_path_name: str, content: InMemoryUploadedFile):
if hasattr(content, 'content_type'):
return content.content_type
guess = mimetypes.guess_type(file_path_name)[0]
if guess is None:
return 'application/octet-stream' # default
return guess
"""
MinioBackend
"""
@property
def same_endpoints(self) -> bool:
"""
Returns True if (self.__MINIO_ENDPOINT == self.__MINIO_EXTERNAL_ENDPOINT)
"""
return self.__SAME_ENDPOINTS
@property
def bucket(self) -> str:
"""Get the configured bucket's [self.bucket] name"""
return self._BUCKET_NAME
@property
def is_bucket_public(self) -> bool:
"""Check if configured bucket [self.bucket] is public"""
return True if self.bucket in self.PUBLIC_BUCKETS else False
def is_minio_available(self) -> MinioServerStatus:
"""Check if configured MinIO server is available"""
if not self.__MINIO_ENDPOINT:
mss = MinioServerStatus(None)
mss.add_message('MINIO_ENDPOINT is not configured in Django settings')
return mss
with urllib3.PoolManager(cert_reqs=ssl.CERT_REQUIRED, ca_certs=certifi.where()) as http:
try:
r = http.request('GET', f'{self.__BASE_URL}/minio/index.html')
return MinioServerStatus(r)
except urllib3.exceptions.MaxRetryError as e:
mss = MinioServerStatus(None)
mss.add_message(f'Could not open connection to {self.__BASE_URL}/minio/index.html\n'
f'Reason: {e}')
return mss
except Exception as e:
mss = MinioServerStatus(None)
mss.add_message(repr(e))
return mss
@property
def client(self) -> minio.Minio:
"""
Get handle to an (already) instantiated minio.Minio instance. This is the default Client.
If "MINIO_EXTERNAL_ENDPOINT" != MINIO_ENDPOINT, this client is used for internal communication only
"""
return self.__CLIENT or self._create_new_client()
@property
def client_external(self) -> minio.Minio:
"""Get handle to an (already) instantiated EXTERNAL minio.Minio instance for generating pre-signed URLs for external access"""
return self.__CLIENT_EXT or self._create_new_client(external=True)
@property
def base_url(self) -> str:
"""Get internal base URL to MinIO"""
return self.__BASE_URL
@property
def base_url_external(self) -> str:
"""Get external base URL to MinIO"""
return self.__BASE_URL_EXTERNAL
def _create_new_client(self, external: bool = False) -> minio.Minio:
"""
Instantiates a new Minio client and assigns it to their respective class variable
:param external: If True, the returned value is self.__CLIENT_EXT instead of self.__CLIENT
"""
self.__CLIENT = minio.Minio(
endpoint=self.__MINIO_ENDPOINT,
access_key=self.__MINIO_ACCESS_KEY,
secret_key=self.__MINIO_SECRET_KEY,
secure=self.__MINIO_USE_HTTPS,
http_client=self.HTTP_CLIENT,
region=self.__MINIO_REGION,
)
self.__CLIENT_EXT = minio.Minio(
endpoint=self.__MINIO_EXTERNAL_ENDPOINT,
access_key=self.__MINIO_ACCESS_KEY,
secret_key=self.__MINIO_SECRET_KEY,
secure=self.__MINIO_EXTERNAL_ENDPOINT_USE_HTTPS,
http_client=self.HTTP_CLIENT,
region=self.__MINIO_REGION,
)
return self.__CLIENT_EXT if external else self.__CLIENT
# MAINTENANCE
def check_bucket_existence(self):
"""Check if configured bucket [self.bucket] exists"""
if not self.client.bucket_exists(self.bucket):
self.client.make_bucket(bucket_name=self.bucket)
def check_bucket_existences(self): # Execute this handler upon starting Django to make sure buckets exist
"""Check if all buckets configured in settings.py do exist. If not, create them"""
for bucket in [*self.PUBLIC_BUCKETS, *self.PRIVATE_BUCKETS]:
if not self.client.bucket_exists(bucket):
self.client.make_bucket(bucket_name=bucket)
def set_bucket_policy(self, bucket: str, policy: dict):
"""Set a custom bucket policy"""
self.client.set_bucket_policy(bucket_name=bucket, policy=json.dumps(policy))
def set_bucket_to_public(self):
"""Set bucket policy to be public. It can be then accessed via public URLs"""
policy_public_read_only = {"Version": "2012-10-17",
"Statement": [
{
"Sid": "",
"Effect": "Allow",
"Principal": {"AWS": "*"},
"Action": "s3:GetBucketLocation",
"Resource": f"arn:aws:s3:::{self.bucket}"
},
{
"Sid": "",
"Effect": "Allow",
"Principal": {"AWS": "*"},
"Action": "s3:ListBucket",
"Resource": f"arn:aws:s3:::{self.bucket}"
},
{
"Sid": "",
"Effect": "Allow",
"Principal": {"AWS": "*"},
"Action": "s3:GetObject",
"Resource": f"arn:aws:s3:::{self.bucket}/*"
}
]}
self.set_bucket_policy(self.bucket, policy_public_read_only)
def validate_settings(self):
"""
validate_settings raises a ConfigurationError exception when one of the following conditions is met:
* Neither MINIO_PRIVATE_BUCKETS nor MINIO_PUBLIC_BUCKETS have been declared and configured with at least 1 bucket
* A mandatory parameter (MINIO_ENDPOINT, MINIO_ACCESS_KEY, MINIO_SECRET_KEY or MINIO_USE_HTTPS) hasn't been declared and configured properly
"""
# minimum 1 bucket has to be declared
if not (get_setting("MINIO_PRIVATE_BUCKETS") or get_setting("MINIO_PUBLIC_BUCKETS")):
raise ConfigurationError(
'Either '
'MINIO_PRIVATE_BUCKETS'
' or '
'MINIO_PUBLIC_BUCKETS '
'must be configured in your settings.py (can be both)'
)
# mandatory parameters must be configured
mandatory_parameters = (self.__MINIO_ENDPOINT, self.__MINIO_ACCESS_KEY, self.__MINIO_SECRET_KEY)
if any([bool(x) is False for x in mandatory_parameters]) or (get_setting("MINIO_USE_HTTPS") is None):
raise ConfigurationError(
"A mandatory parameter (MINIO_ENDPOINT, MINIO_ACCESS_KEY, MINIO_SECRET_KEY or MINIO_USE_HTTPS) hasn't been configured properly"
)
@deconstructible
class MinioBackendStatic(MinioBackend):
"""
MinIO-compatible Django custom storage system for Django static files.
The used bucket can be configured in settings.py through `MINIO_STATIC_FILES_BUCKET`
:arg *args: Should not be used for static files. It's here for compatibility only
:arg **kwargs: Should not be used for static files. It's here for compatibility only
"""
def __init__(self, *args, **kwargs):
super().__init__(self.MINIO_STATIC_FILES_BUCKET, *args, **kwargs)
self.check_bucket_existence() # make sure the `MINIO_STATIC_FILES_BUCKET` exists
self.set_bucket_to_public() # the static files bucket must be publicly available
def path(self, name):
"""The MinIO storage system doesn't support absolute paths"""
raise NotImplementedError("The MinIO storage system doesn't support absolute paths.")
def get_accessed_time(self, name: str):
"""MinIO does not store last accessed time"""
raise NotImplementedError('MinIO does not store last accessed time')
def get_created_time(self, name: str):
"""MinIO does not store creation time"""
raise NotImplementedError('MinIO does not store creation time')
from django.test import TestCase
# Create your tests here.
# noinspection PyPackageRequirements minIO_requirement
import urllib3
from typing import Union, List
from django.conf import settings
__all__ = ['MinioServerStatus', 'PrivatePublicMixedError', 'ConfigurationError', 'get_setting', ]
class MinioServerStatus:
"""
MinioServerStatus is a simple status info wrapper for checking the availability of a remote MinIO server.
MinioBackend.is_minio_available() returns a MinioServerStatus instance
MinioServerStatus can be evaluated with the bool() method:
```
minio_available = MinioBackend.is_minio_available()
if bool(minio_available): # bool() can be omitted
print("OK")
```
"""
def __init__(self, request: Union[urllib3.response.HTTPResponse, None]):
self._request = request
self._bool = False
self._details: List[str] = []
self.status = None
self.data = None
self.__OK = 'MinIO is available'
self.___NOK = 'MinIO is NOT available'
if not self._request:
self.add_message('There was no HTTP request provided for MinioServerStatus upon initialisation.')
else:
self.status = self._request.status
self.data = self._request.data.decode() if self._request.data else 'No data available'
if self.status == 403: # Request was a legal, but the server refuses to respond to it -> it's running fine
self._bool = True
else:
self._details.append(self.__OK)
self._details.append('Reason: ' + self.data)
def __bool__(self):
return self._bool
def add_message(self, text: str):
self._details.append(text)
@property
def is_available(self):
return self._bool
@property
def details(self):
return '\n'.join(self._details)
def __repr__(self):
if self.is_available:
return self.__OK
return self.___NOK
class PrivatePublicMixedError(Exception):
"""Raised on public|private bucket configuration collisions"""
pass
class ConfigurationError(Exception):
"""Raised on django-minio-backend configuration errors"""
pass
def get_setting(name, default=None):
"""Get setting from settings.py. Return a default value if not defined"""
return getattr(settings, name, default)
# ORIGINAL SOURCE
# https://docs.min.io/docs/deploy-minio-on-docker-compose.html
# https://github.com/minio/minio/blob/master/docs/orchestration/docker-compose/docker-compose.yaml?raw=true
# IN THIS CONFIGURATION, THE PROJECT FILES ARE VOLUME MAPPED INTO THE CONTAINER FROM THE HOST
version: "3.9"
# Settings and configurations that are common for all containers
x-minio-common: &minio-common
image: minio/minio:RELEASE.2021-07-30T00-02-00Z
command: server --console-address ":9001" http://minio{1...4}/data{1...2}
expose:
- "9000"
- "9001"
environment:
MINIO_ROOT_USER: minio
MINIO_ROOT_PASSWORD: minio123
healthcheck:
test: [ "CMD", "curl", "-f", "http://localhost:9000/minio/health/live" ]
interval: 30s
timeout: 20s
retries: 3
services:
# starts Django from DjangoExampleProject + DjangoExampleApplication
web:
image: python:3
command: bash -c "
pip install -r /code/requirements.txt
&& python manage.py migrate
&& python manage.py runserver 0.0.0.0:8000
"
volumes:
- .:/code
working_dir: /code
environment:
PYTHONUNBUFFERED: "1"
GH_MINIO_ENDPOINT: "nginx:9000"
GH_MINIO_USE_HTTPS: "false"
GH_MINIO_EXTERNAL_ENDPOINT: "localhost:9000"
GH_MINIO_EXTERNAL_ENDPOINT_USE_HTTPS: "false"
GH_MINIO_ACCESS_KEY: "minio"
GH_MINIO_SECRET_KEY: "minio123"
# CREATE AN ADMIN ACCOUNT FOR INTERNAL DEMO PURPOSES ONLY!
DJANGO_SUPERUSER_USERNAME: "admin"
DJANGO_SUPERUSER_PASSWORD: "123123"
DJANGO_SUPERUSER_EMAIL: "admin@local.test"
ports:
- "8000:8000"
depends_on:
- nginx
# starts 4 docker containers running minio server instances.
# using nginx reverse proxy, load balancing, you can access
# it through port 9000.
minio1:
<<: *minio-common
hostname: minio1
volumes:
- data1-1:/data1
- data1-2:/data2
minio2:
<<: *minio-common
hostname: minio2
volumes:
- data2-1:/data1
- data2-2:/data2
minio3:
<<: *minio-common
hostname: minio3
volumes:
- data3-1:/data1
- data3-2:/data2
minio4:
<<: *minio-common
hostname: minio4
volumes:
- data4-1:/data1
- data4-2:/data2
nginx:
image: nginx:1.19.2-alpine
hostname: nginx
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
ports:
- "9000:9000"
- "9001:9001"
depends_on:
- minio1
- minio2
- minio3
- minio4
## By default this config uses default local driver,
## For custom volumes replace with volume driver configuration.
volumes:
data1-1:
data1-2:
data2-1:
data2-2:
data3-1:
data3-2:
data4-1:
data4-2:
# ORIGINAL SOURCE
# https://docs.min.io/docs/deploy-minio-on-docker-compose.html
# https://github.com/minio/minio/blob/master/docs/orchestration/docker-compose/docker-compose.yaml?raw=true
# IN THIS CONFIGURATION, THE PROJECT FILES ARE COPIED INTO THE CONTAINER
version: "3.9"
# Settings and configurations that are common for all containers
x-minio-common: &minio-common
image: minio/minio:RELEASE.2021-07-30T00-02-00Z
command: server --console-address ":9001" http://minio{1...4}/data{1...2}
expose:
- "9000"
- "9001"
environment:
MINIO_ROOT_USER: minio
MINIO_ROOT_PASSWORD: minio123
healthcheck:
test: [ "CMD", "curl", "-f", "http://localhost:9000/minio/health/live" ]
interval: 30s
timeout: 20s
retries: 3
services:
# starts Django from DjangoExampleProject + DjangoExampleApplication
web:
build: .
command: bash -c "
python manage.py migrate
&& python manage.py runserver 0.0.0.0:8000
"
environment:
GH_MINIO_ENDPOINT: "nginx:9000"
GH_MINIO_USE_HTTPS: "false"
GH_MINIO_EXTERNAL_ENDPOINT: "localhost:9000"
GH_MINIO_EXTERNAL_ENDPOINT_USE_HTTPS: "false"
GH_MINIO_ACCESS_KEY: "minio"
GH_MINIO_SECRET_KEY: "minio123"
# CREATE AN ADMIN ACCOUNT FOR INTERNAL DEMO PURPOSES ONLY!
DJANGO_SUPERUSER_USERNAME: "admin"
DJANGO_SUPERUSER_PASSWORD: "123123"
DJANGO_SUPERUSER_EMAIL: "admin@local.test"
ports:
- "8000:8000"
depends_on:
- nginx
# starts 4 docker containers running minio server instances.
# using nginx reverse proxy, load balancing, you can access
# it through port 9000.
minio1:
<<: *minio-common
hostname: minio1
volumes:
- data1-1:/data1
- data1-2:/data2
minio2:
<<: *minio-common
hostname: minio2
volumes:
- data2-1:/data1
- data2-2:/data2
minio3:
<<: *minio-common
hostname: minio3
volumes:
- data3-1:/data1
- data3-2:/data2
minio4:
<<: *minio-common
hostname: minio4
volumes:
- data4-1:/data1
- data4-2:/data2
nginx:
image: nginx:1.19.2-alpine
hostname: nginx
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
ports:
- "9000:9000"
- "9001:9001"
depends_on:
- minio1
- minio2
- minio3
- minio4
## By default this config uses default local driver,
## For custom volumes replace with volume driver configuration.
volumes:
data1-1:
data1-2:
data2-1:
data2-2:
data3-1:
data3-2:
data4-1:
data4-2:
from typing import List, Tuple
bucket_name: str = 'my-very-public-bucket'
# policy sets the appropriate bucket as world readable (no write)
# See the following good summary of Bucket Policies
# https://gist.github.com/krishnasrinivas/2f5a9affe6be6aff42fe723f02c86d6a
policy = {"Version": "2012-10-17",
"Statement": [
{
"Sid": "",
"Effect": "Allow",
"Principal": {"AWS": "*"},
"Action": "s3:GetBucketLocation",
"Resource": f"arn:aws:s3:::{bucket_name}"
},
{
"Sid": "",
"Effect": "Allow",
"Principal": {"AWS": "*"},
"Action": "s3:ListBucket",
"Resource": f"arn:aws:s3:::{bucket_name}"
},
{
"Sid": "",
"Effect": "Allow",
"Principal": {"AWS": "*"},
"Action": "s3:GetObject",
"Resource": f"arn:aws:s3:::{bucket_name}/*"
}
]}
MINIO_POLICY_HOOKS: List[Tuple[str, dict]] = [ # This array of (bucket_name, policy) tuples belong to Django settings
(bucket_name, policy),
]
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'DjangoExampleProject.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()
# ORIGINAL SOURCE
# https://docs.min.io/docs/deploy-minio-on-docker-compose.html
# https://github.com/minio/minio/blob/master/docs/orchestration/docker-compose/nginx.conf?raw=true
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 4096;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
keepalive_timeout 65;
# include /etc/nginx/conf.d/*.conf;
upstream minio {
server minio1:9000;
server minio2:9000;
server minio3:9000;
server minio4:9000;
}
upstream console {
ip_hash;
server minio1:9001;
server minio2:9001;
server minio3:9001;
server minio4:9001;
}
server {
listen 9000;
listen [::]:9000;
server_name localhost;
# To allow special characters in headers
ignore_invalid_headers off;
# Allow any size file to be uploaded.
# Set to a value such as 1000m; to restrict file size to a specific value
client_max_body_size 0;
# To disable buffering
proxy_buffering off;
location / {
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 300;
# Default is HTTP/1, keepalive is only enabled in HTTP/1.1
proxy_http_version 1.1;
proxy_set_header Connection "";
chunked_transfer_encoding off;
proxy_pass http://minio;
}
}
server {
listen 9001;
listen [::]:9001;
server_name localhost;
# To allow special characters in headers
ignore_invalid_headers off;
# Allow any size file to be uploaded.
# Set to a value such as 1000m; to restrict file size to a specific value
client_max_body_size 0;
# To disable buffering
proxy_buffering off;
location / {
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-NginX-Proxy true;
# This is necessary to pass the correct IP to be hashed
real_ip_header X-Real-IP;
proxy_connect_timeout 300;
# Default is HTTP/1, keepalive is only enabled in HTTP/1.1
proxy_http_version 1.1;
proxy_set_header Connection "";
chunked_transfer_encoding off;
proxy_pass http://console;
}
}
}
Django>=2.2.2
minio>=7.0.2
Pillow
setuptools
import os
from datetime import datetime
from setuptools import find_packages, setup
from version import get_git_version
with open("README.md", "r") as readme_file:
long_description = readme_file.read()
# allow setup.py to be run from any path
os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir)))
setup(
name='django-minio-backend',
version=get_git_version(),
packages=find_packages(),
include_package_data=True,
license=f'MIT License | Copyright (c) {datetime.now().year} Kristof Daja',
description='The django-minio-backend provides a wrapper around the MinIO Python Library.',
long_description=long_description,
long_description_content_type="text/markdown",
url='https://github.com/theriverman/django-minio-backend',
author='Kristof Daja (theriverman)',
author_email='kristof@daja.hu',
install_requires=[
'Django>=2.2.2',
'minio>=7.0.2'
],
classifiers=[
'Environment :: Web Environment',
'Framework :: Django',
'Framework :: Django :: 2.2',
'Framework :: Django :: 3.0',
'Framework :: Django :: 3.1',
'Framework :: Django :: 4.0',
'Framework :: Django :: 4.1',
'Intended Audience :: Developers',
'License :: OSI Approved :: MIT License',
'Operating System :: OS Independent',
'Programming Language :: Python',
'Programming Language :: Python :: 3.8',
'Programming Language :: Python :: 3.9',
'Programming Language :: Python :: 3.10',
'Programming Language :: Python :: 3.11',
'Topic :: Internet :: WWW/HTTP :: Dynamic Content',
'Topic :: Internet :: WWW/HTTP :: Dynamic Content :: Content Management System',
'Topic :: Internet :: WWW/HTTP :: WSGI :: Application',
],
)
# -*- coding: utf-8 -*-
# Author: Douglas Creager <dcreager@dcreager.net>
# Modifier: Kristof Daja <kristof@daja.hu>
# This file is placed into the public domain.
# Calculates the current version number. If possible, this is the
# output of “git describe”, modified to conform to the versioning
# scheme that setuptools uses. If “git describe” returns an error
# (most likely because we're in an unpacked copy of a release tarball,
# rather than in a git working copy), then we fall back on reading the
# contents of the RELEASE-VERSION file.
#
# To use this script, simply import it your setup.py file, and use the
# results of get_git_version() as your package version:
#
# from version import *
#
# setup(
# version=get_git_version(),
# .
# .
# .
# )
#
#
# This will automatically update the RELEASE-VERSION file, if
# necessary. Note that the RELEASE-VERSION file should *not* be
# checked into git; please add it to your top-level .gitignore file.
#
# You'll probably want to distribute the RELEASE-VERSION file in your
# sdist tarballs; to do this, just create a MANIFEST.in file that
# contains the following line:
#
# include RELEASE-VERSION
#
# Change History:
# 2020-12-12 - Updated for Python 3. Changed git describe --abbrev=7 to git describe --tags
#
__all__ = ["get_git_version"]
from subprocess import Popen, PIPE
def call_git_describe():
# noinspection PyBroadException
try:
p = Popen(['git', 'describe', '--tags'],
stdout=PIPE, stderr=PIPE)
p.stderr.close()
line = p.stdout.readlines()[0]
return line.strip().decode('utf-8')
except Exception:
return None
def is_dirty():
# noinspection PyBroadException
try:
p = Popen(["git", "diff-index", "--name-only", "HEAD"],
stdout=PIPE, stderr=PIPE)
p.stderr.close()
lines = p.stdout.readlines()
return len(lines) > 0
except Exception:
return False
def read_release_version():
# noinspection PyBroadException
try:
f = open("RELEASE-VERSION", "r")
try:
version = f.readlines()[0]
return version.strip()
finally:
f.close()
except Exception:
return None
def write_release_version(version):
f = open("RELEASE-VERSION", "w")
f.write("%s\n" % version)
f.close()
def get_git_version():
# Read in the version that's currently in RELEASE-VERSION.
release_version = read_release_version()
# First try to get the current version using “git describe”.
version = call_git_describe()
if is_dirty():
version += "-dirty"
# If that doesn't work, fall back on the value that's in
# RELEASE-VERSION.
if version is None:
version = release_version
# If we still don't have anything, that's an error.
if version is None:
raise ValueError("Cannot find the version number!")
# If the current version is different from what's in the
# RELEASE-VERSION file, update the file to be current.
if version != release_version:
write_release_version(version)
# Finally, return the current version.
return version
if __name__ == "__main__":
print(get_git_version())
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment