added file preview generation for Docker, base model to generate cropped images

This commit is contained in:
Alexander Karpov 2023-03-25 18:19:42 +03:00
parent 359e2929fc
commit 91b851805f
16 changed files with 444 additions and 131 deletions

View File

@ -1,33 +1,10 @@
from django.db.models.signals import post_save, pre_delete, pre_save
from django.db.models.signals import pre_delete, pre_save
from django.dispatch import receiver
from akarpov.blog.models import Post, PostRating, Tag
from akarpov.common.tasks import crop_model_image
from akarpov.blog.models import PostRating, Tag
from akarpov.utils.generators import generate_hex_color
@receiver(pre_save, sender=Post)
def post_on_save(sender, instance: Post, **kwargs):
if instance.id:
previous = Post.objects.get(id=instance.id)
if (
previous.image != instance.image
and kwargs["update_fields"] != frozenset({"image_cropped"})
and instance
):
if instance.image:
crop_model_image.apply_async(
kwargs={
"pk": instance.pk,
"app_label": "blog",
"model_name": "Post",
},
countdown=2,
)
else:
instance.image_cropped = None
@receiver(pre_save, sender=Tag)
def tag_create(sender, instance: Tag, **kwargs):
if instance.id is None:
@ -71,17 +48,3 @@ def post_rating_delete(sender, instance: PostRating, **kwargs):
post.rating += 1
post.rating_down -= 1
post.save()
@receiver(post_save, sender=Post)
def post_on_create(sender, instance: Post, created, **kwargs):
if created:
if instance.image:
crop_model_image.apply_async(
kwargs={
"pk": instance.pk,
"app_label": "blog",
"model_name": "Post",
},
countdown=2,
)

View File

@ -1,13 +1,73 @@
import os
from django.db import models
from akarpov.common.tasks import crop_model_image
from akarpov.utils.files import user_file_upload_mixin
from akarpov.utils.generators import generate_charset
def create_cropped_model_image(sender, instance, created, **kwargs):
model = sender
if created:
if instance.image:
crop_model_image.apply_async(
kwargs={
"pk": instance.pk,
"app_label": model._meta.app_label,
"model_name": model._meta.model_name,
},
countdown=2,
)
def update_cropped_model_image(sender, instance, **kwargs):
model = sender
if instance.id:
previous = model.objects.get(id=instance.id)
if previous.image != instance.image:
# delete previous cropped image
if instance.image_cropped:
if os.path.isfile(instance.image_cropped.path):
os.remove(instance.image_cropped.path)
# run task to create new cropped image
if kwargs["update_fields"] != frozenset({"image_cropped"}) and instance:
if instance.image:
crop_model_image.apply_async(
kwargs={
"pk": instance.pk,
"app_label": model._meta.app_label,
"model_name": model._meta.model_name,
},
countdown=2,
)
else:
instance.image_cropped = None
def delete_cropped_model_image(sender, instance, **kwargs):
print(instance.image_cropped)
if instance.image_cropped:
if os.path.isfile(instance.image_cropped.path):
os.remove(instance.image_cropped.path)
class BaseImageModel(models.Model):
"""
stores user's images in their media folder, creates, updates and deletes preview
requires celery to run
"""
image = models.ImageField(upload_to=user_file_upload_mixin, blank=True)
image_cropped = models.ImageField(upload_to="cropped/", blank=True)
@classmethod
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
models.signals.pre_save.connect(update_cropped_model_image, sender=cls)
models.signals.post_save.connect(create_cropped_model_image, sender=cls)
models.signals.post_delete.connect(delete_cropped_model_image, sender=cls)
class Meta:
abstract = True

View File

@ -1,6 +1,6 @@
from django.contrib import admin
from akarpov.files.models import BaseFile, Folder
from akarpov.files.models import File, Folder
admin.site.register(BaseFile)
admin.site.register(File)
admin.site.register(Folder)

View File

@ -0,0 +1,108 @@
# Generated by Django 4.1.7 on 2023-03-24 13:50
import akarpov.utils.files
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import model_utils.fields
class Migration(migrations.Migration):
dependencies = [
("shortener", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("files", "0006_alter_basefile_slug"),
]
operations = [
migrations.CreateModel(
name="File",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("slug", models.SlugField(blank=True, max_length=20, unique=True)),
(
"created",
model_utils.fields.AutoCreatedField(
default=django.utils.timezone.now,
editable=False,
verbose_name="created",
),
),
(
"modified",
model_utils.fields.AutoLastModifiedField(
default=django.utils.timezone.now,
editable=False,
verbose_name="modified",
),
),
("name", models.CharField(max_length=100)),
("description", models.TextField(blank=True)),
("private", models.BooleanField(default=True)),
("preview", models.FileField(blank=True, upload_to="file/previews/")),
(
"file",
models.FileField(
upload_to=akarpov.utils.files.user_file_upload_mixin
),
),
],
options={
"abstract": False,
},
),
migrations.AlterField(
model_name="folder",
name="parent",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="children",
to="files.folder",
),
),
migrations.DeleteModel(
name="BaseFile",
),
migrations.AddField(
model_name="file",
name="folder",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="files",
to="files.folder",
),
),
migrations.AddField(
model_name="file",
name="short_link",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="shortener.link",
),
),
migrations.AddField(
model_name="file",
name="user",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="files",
to=settings.AUTH_USER_MODEL,
),
),
]

View File

@ -14,7 +14,7 @@
from akarpov.utils.files import user_file_upload_mixin
class BaseFile(TimeStampedModel, ShortLink):
class File(TimeStampedModel, ShortLink):
"""model to store user's files"""
name = CharField(max_length=100)
@ -27,6 +27,7 @@ class BaseFile(TimeStampedModel, ShortLink):
"files.Folder", related_name="files", blank=True, null=True, on_delete=CASCADE
)
preview = FileField(blank=True, upload_to="file/previews/")
file = FileField(blank=False, upload_to=user_file_upload_mixin)
def get_absolute_url(self):
@ -41,7 +42,9 @@ class Folder(TimeStampedModel, ShortLink):
slug = SlugField(max_length=20, blank=True)
user = ForeignKey("users.User", related_name="files_folders", on_delete=CASCADE)
parent = ForeignKey("self", blank=True, related_name="children", on_delete=CASCADE)
parent = ForeignKey(
"self", null=True, blank=True, related_name="children", on_delete=CASCADE
)
def get_absolute_url(self):
return reverse("files:folder", kwargs={"slug": self.slug})

View File

@ -76,5 +76,5 @@ def _font_points_to_pixels(pt):
def create_preview(file_path: str) -> str:
# TODO: add text image generation/code image
manager = PreviewManager(cache_path, create_folder=True)
path_to_preview_image = manager.get_jpeg_preview(file_path)
path_to_preview_image = manager.get_pdf_preview(file_path)
return path_to_preview_image

16
akarpov/files/signals.py Normal file
View File

@ -0,0 +1,16 @@
from django.db.models.signals import post_save
from django.dispatch import receiver
from akarpov.files.models import File
from akarpov.files.tasks import generate_file_review
@receiver(post_save, sender=File)
def post_on_create(sender, instance: File, created, **kwargs):
if created:
generate_file_review.apply_async(
kwargs={
"pk": instance.pk,
},
countdown=2,
)

19
akarpov/files/tasks.py Normal file
View File

@ -0,0 +1,19 @@
from celery import shared_task
from django.core.files import File
from akarpov.files.models import File as FileModel
from akarpov.files.services.preview import create_preview
@shared_task()
def generate_file_review(pk: int):
file = FileModel.objects.get(pk=pk)
pth = create_preview(file.file)
with open(pth, "rb") as f:
file.preview.save(
pth.split("/")[-1],
File(f.read()),
save=False,
)
file.save(update_fields=["preview"])
return pk

View File

@ -1,11 +1,11 @@
from django.views.generic import DetailView
from akarpov.files.models import BaseFile, Folder
from akarpov.files.models import File, Folder
class FileView(DetailView):
template_name = "files/view.html"
model = BaseFile
model = File
slug_field = "slug"

View File

@ -1,41 +0,0 @@
from django.db.models.signals import post_save, pre_save
from django.dispatch import receiver
from akarpov.common.tasks import crop_model_image
from akarpov.test_platform.models import Form
@receiver(post_save, sender=Form)
def form_on_create(sender, instance: Form, created, **kwargs):
if created:
if instance.image:
crop_model_image.apply_async(
kwargs={
"pk": instance.pk,
"app_label": "test_platform",
"model_name": "Form",
},
countdown=2,
)
@receiver(pre_save, sender=Form)
def form_on_save(sender, instance: Form, **kwargs):
if instance.id:
previous = Form.objects.get(id=instance.id)
if (
previous.image != instance.image
and kwargs["update_fields"] != frozenset({"image_cropped"})
and instance
):
if instance.image:
crop_model_image.apply_async(
kwargs={
"pk": instance.pk,
"app_label": "test_platform",
"model_name": "Form",
},
countdown=2,
)
else:
instance.image_cropped = None

View File

@ -1,43 +0,0 @@
from django.db.models.signals import post_save, pre_save
from django.dispatch import receiver
from akarpov.common.tasks import crop_model_image
from akarpov.users.models import User
@receiver(pre_save, sender=User)
def on_change(sender, instance: User, **kwargs):
if instance.id is None: # new object will be created
pass
else:
previous = User.objects.get(id=instance.id)
if previous.image != instance.image and kwargs["update_fields"] != frozenset(
{"image_cropped"}
):
if instance.image:
crop_model_image.apply_async(
kwargs={
"pk": instance.pk,
"app_label": "users",
"model_name": "User",
},
countdown=2,
)
else:
instance.image_cropped = None
instance.save()
@receiver(post_save, sender=User)
def post_on_create(sender, instance: User, created, **kwargs):
if created:
if instance.image:
if instance.image:
crop_model_image.apply_async(
kwargs={
"pk": instance.pk,
"app_label": "users",
"model_name": "User",
},
countdown=2,
)

View File

@ -16,6 +16,7 @@ ARG BUILD_ENVIRONMENT=local
ARG APP_HOME=/app
ENV PYTHONUNBUFFERED 1
ENV DRAWIO_VERSION 15.7.3
ENV PYTHONDONTWRITEBYTECODE 1
ENV BUILD_ENV ${BUILD_ENVIRONMENT}
@ -24,16 +25,21 @@ WORKDIR ${APP_HOME}
# Install required system dependencies
RUN apt-get update && \
apt-get install -y build-essential libpq-dev gettext libmagic-dev libjpeg-dev zlib1g-dev && \
# Dependencies for file preview generation
# apt-get install -y poppler-utils libfile-mimeinfo-perl libimage-exiftool-perl ghostscript libsecret-1-0 zlib1g-dev libjpeg-dev imagemagick libmagic1 webp libreoffice inkscape ffmpeg xvfb && \
apt-get purge -y --auto-remove -o APT:AutoRemove:RecommendsImportant=false && \
rm -rf /var/lib/apt/lists/*
RUN curl -sSL https://install.python-poetry.org | python3 -
# Dependencies for file preview generation
# RUN curl -LO https://github.com/jgraph/drawio-desktop/releases/download/v${DRAWIO_VERSION}/drawio-x86_64-${DRAWIO_VERSION}.AppImage && mv drawio-x86_64-${DRAWIO_VERSION}.AppImage /usr/local/bin/drawio
ENV PATH="/root/.local/bin:$PATH"
RUN poetry config virtualenvs.create false
COPY ./pyproject.toml ./poetry.lock /app/
RUN poetry install --no-root --only main
RUN preview --check-dependencies
COPY ./compose/production/django/entrypoint /entrypoint

BIN
models.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

222
poetry.lock generated
View File

@ -285,6 +285,48 @@ d = ["aiohttp (>=3.7.4)"]
jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"]
uvloop = ["uvloop (>=0.15.2)"]
[[package]]
name = "cairocffi"
version = "1.5.0"
description = "cffi-based cairo bindings for Python"
category = "main"
optional = false
python-versions = ">=3.7"
files = [
{file = "cairocffi-1.5.0.tar.gz", hash = "sha256:d105b49009d9b4970a459e38ff030cb5dfc8c8ee231e867d28f77ee9df44495e"},
]
[package.dependencies]
cffi = ">=1.1.0"
[package.extras]
doc = ["sphinx", "sphinx_rtd_theme"]
test = ["flake8", "isort", "numpy", "pikepdf", "pytest"]
xcb = ["xcffib (>=0.3.2)"]
[[package]]
name = "cairosvg"
version = "2.7.0"
description = "A Simple SVG Converter based on Cairo"
category = "main"
optional = false
python-versions = ">=3.5"
files = [
{file = "CairoSVG-2.7.0-py3-none-any.whl", hash = "sha256:17cb96423a896258848322a95c80160e714a58f1af3dd73b8e1750994519b9f9"},
{file = "CairoSVG-2.7.0.tar.gz", hash = "sha256:ac4dc7c1d38b3a15717db2633a3a383012e0be664c727c911637e6af6a49293c"},
]
[package.dependencies]
cairocffi = "*"
cssselect2 = "*"
defusedxml = "*"
pillow = "*"
tinycss2 = "*"
[package.extras]
doc = ["sphinx", "sphinx-rtd-theme"]
test = ["flake8", "isort", "pytest"]
[[package]]
name = "celery"
version = "5.2.7"
@ -767,6 +809,26 @@ test = ["hypothesis (>=1.11.4,!=3.79.2)", "iso8601", "pretend", "pytest (>=6.2.0
test-randomorder = ["pytest-randomly"]
tox = ["tox"]
[[package]]
name = "cssselect2"
version = "0.7.0"
description = "CSS selectors for Python ElementTree"
category = "main"
optional = false
python-versions = ">=3.7"
files = [
{file = "cssselect2-0.7.0-py3-none-any.whl", hash = "sha256:fd23a65bfd444595913f02fc71f6b286c29261e354c41d722ca7a261a49b5969"},
{file = "cssselect2-0.7.0.tar.gz", hash = "sha256:1ccd984dab89fc68955043aca4e1b03e0cf29cad9880f6e28e3ba7a74b14aa5a"},
]
[package.dependencies]
tinycss2 = "*"
webencodings = "*"
[package.extras]
doc = ["sphinx", "sphinx_rtd_theme"]
test = ["flake8", "isort", "pytest"]
[[package]]
name = "decorator"
version = "5.1.1"
@ -1539,6 +1601,24 @@ files = [
[package.dependencies]
python-dateutil = ">=2.4"
[[package]]
name = "ffmpeg-python"
version = "0.2.0"
description = "Python bindings for FFmpeg - with complex filtering support"
category = "main"
optional = false
python-versions = "*"
files = [
{file = "ffmpeg-python-0.2.0.tar.gz", hash = "sha256:65225db34627c578ef0e11c8b1eb528bb35e024752f6f10b78c011f6f64c4127"},
{file = "ffmpeg_python-0.2.0-py3-none-any.whl", hash = "sha256:ac441a0404e053f8b6a1113a77c0f452f1cfc62f6344a769475ffdc0f56c23c5"},
]
[package.dependencies]
future = "*"
[package.extras]
dev = ["Sphinx (==2.1.0)", "future (==0.17.1)", "numpy (==1.16.4)", "pytest (==4.6.1)", "pytest-mock (==1.10.4)", "tox (==3.12.1)"]
[[package]]
name = "filelock"
version = "3.9.0"
@ -1622,6 +1702,17 @@ files = [
{file = "funcy-1.18.tar.gz", hash = "sha256:15448d19a8ebcc7a585afe7a384a19186d0bd67cbf56fb42cd1fd0f76313f9b2"},
]
[[package]]
name = "future"
version = "0.18.3"
description = "Clean single-source support for Python 3 and 2"
category = "main"
optional = false
python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
files = [
{file = "future-0.18.3.tar.gz", hash = "sha256:34a17436ed1e96697a86f9de3d15a3b0be01d8bc8de9c1dffd59fb8234ed5307"},
]
[[package]]
name = "gunicorn"
version = "20.1.0"
@ -2527,6 +2618,37 @@ nodeenv = ">=0.11.1"
pyyaml = ">=5.1"
virtualenv = ">=20.10.0"
[[package]]
name = "preview-generator"
version = "0.29"
description = "A library for generating preview (thumbnails, text or json overview) for file-based content"
category = "main"
optional = false
python-versions = ">= 3.7"
files = [
{file = "preview_generator-0.29.tar.gz", hash = "sha256:343978dace795b4b9da862d96f2e4a9c088e58a255c32a97ecd8729d4947377a"},
]
[package.dependencies]
cairosvg = {version = "*", optional = true, markers = "extra == \"all\""}
ffmpeg-python = {version = "*", optional = true, markers = "extra == \"all\""}
filelock = "*"
pyexifinfo = "*"
python-magic = "*"
Wand = "*"
xvfbwrapper = {version = "*", optional = true, markers = "extra == \"all\""}
[package.extras]
3d = ["vtk"]
all = ["cairosvg", "ffmpeg-python", "xvfbwrapper", "xvfbwrapper"]
cairosvg = ["cairosvg"]
dev = ["ImageHash", "black", "flake8", "isort", "mypy", "pre-commit", "pytest", "pytest-dotenv"]
drawio = ["xvfbwrapper"]
raw = ["rawpy"]
scribus = ["xvfbwrapper"]
testing = ["ImageHash", "pytest", "pytest-dotenv"]
video = ["ffmpeg-python"]
[[package]]
name = "prometheus-client"
version = "0.16.0"
@ -2716,6 +2838,31 @@ files = [
{file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"},
]
[[package]]
name = "pydotplus"
version = "2.0.2"
description = "Python interface to Graphviz's Dot language"
category = "main"
optional = false
python-versions = "*"
files = [
{file = "pydotplus-2.0.2.tar.gz", hash = "sha256:91e85e9ee9b85d2391ead7d635e3d9c7f5f44fd60a60e59b13e2403fa66505c4"},
]
[package.dependencies]
pyparsing = ">=2.0.1"
[[package]]
name = "pyexifinfo"
version = "0.4.0"
description = "Simple Metadata extraction using Exiftool"
category = "main"
optional = false
python-versions = "*"
files = [
{file = "pyexifinfo-0.4.0.tar.gz", hash = "sha256:578b34b3c593fe77bbe6b62588f9f2ec679dca63f7d486148c9a6ff1fdd4bdc9"},
]
[[package]]
name = "pyflakes"
version = "3.0.1"
@ -2840,6 +2987,21 @@ files = [
[package.dependencies]
pylint = ">=1.7"
[[package]]
name = "pyparsing"
version = "3.0.9"
description = "pyparsing module - Classes and methods to define and execute parsing grammars"
category = "main"
optional = false
python-versions = ">=3.6.8"
files = [
{file = "pyparsing-3.0.9-py3-none-any.whl", hash = "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"},
{file = "pyparsing-3.0.9.tar.gz", hash = "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb"},
]
[package.extras]
diagrams = ["jinja2", "railroad-diagrams"]
[[package]]
name = "pyrsistent"
version = "0.19.3"
@ -3461,6 +3623,25 @@ files = [
{file = "text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8"},
]
[[package]]
name = "tinycss2"
version = "1.2.1"
description = "A tiny CSS parser"
category = "main"
optional = false
python-versions = ">=3.7"
files = [
{file = "tinycss2-1.2.1-py3-none-any.whl", hash = "sha256:2b80a96d41e7c3914b8cda8bc7f705a4d9c49275616e886103dd839dfc847847"},
{file = "tinycss2-1.2.1.tar.gz", hash = "sha256:8cff3a8f066c2ec677c06dbc7b45619804a6938478d9d73c284b29d14ecb0627"},
]
[package.dependencies]
webencodings = ">=0.4"
[package.extras]
doc = ["sphinx", "sphinx_rtd_theme"]
test = ["flake8", "isort", "pytest"]
[[package]]
name = "tomli"
version = "2.0.1"
@ -3659,6 +3840,22 @@ platformdirs = ">=2.4,<4"
docs = ["furo (>=2022.12.7)", "proselint (>=0.13)", "sphinx (>=6.1.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=22.12)"]
test = ["covdefaults (>=2.2.2)", "coverage (>=7.1)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23)", "pytest (>=7.2.1)", "pytest-env (>=0.8.1)", "pytest-freezegun (>=0.4.2)", "pytest-mock (>=3.10)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)"]
[[package]]
name = "wand"
version = "0.6.11"
description = "Ctypes-based simple MagickWand API binding for Python"
category = "main"
optional = false
python-versions = "*"
files = [
{file = "Wand-0.6.11-py2.py3-none-any.whl", hash = "sha256:1b77e25439ace57f665d1ccc6cff2766fad0834005b89ae3e7aaf3ba12b124b0"},
{file = "Wand-0.6.11.tar.gz", hash = "sha256:b661700da9f8f1e931e52726e4fc643a565b9514f5883d41b773e3c37c9fa995"},
]
[package.extras]
doc = ["Sphinx (>=5.3.0)"]
test = ["pytest (>=7.2.0)"]
[[package]]
name = "watchdog"
version = "2.3.1"
@ -3743,6 +3940,18 @@ files = [
{file = "wcwidth-0.2.6.tar.gz", hash = "sha256:a5220780a404dbe3353789870978e472cfe477761f06ee55077256e509b156d0"},
]
[[package]]
name = "webencodings"
version = "0.5.1"
description = "Character encoding aliases for legacy web content"
category = "main"
optional = false
python-versions = "*"
files = [
{file = "webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78"},
{file = "webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923"},
]
[[package]]
name = "werkzeug"
version = "2.2.3"
@ -3862,7 +4071,18 @@ files = [
{file = "wrapt-1.15.0.tar.gz", hash = "sha256:d06730c6aed78cee4126234cf2d071e01b44b915e725a6cb439a879ec9754a3a"},
]
[[package]]
name = "xvfbwrapper"
version = "0.2.9"
description = "run headless display inside X virtual framebuffer (Xvfb)"
category = "main"
optional = false
python-versions = "*"
files = [
{file = "xvfbwrapper-0.2.9.tar.gz", hash = "sha256:bcf4ae571941b40254faf7a73432dfc119ad21ce688f1fdec533067037ecfc24"},
]
[metadata]
lock-version = "2.0"
python-versions = "^3.11"
content-hash = "f9a99f36ec97a450398a9bf31df6970377ae436a6f3e0500acf0eaf08dc4199f"
content-hash = "8bb4acf1ceb7b54ad1864fe625ca1aa68d4cc371ce45170bede728ad84fa2c84"

View File

@ -72,6 +72,8 @@ django-active-link = "^0.1.8"
channels = "^4.0.0"
django-upload-validator = "^1.1.6"
markdown = "^3.4.3"
preview-generator = {extras = ["all"], version = "^0.29"}
pydotplus = "^2.0.2"
[build-system]