diff --git a/.github/dependabot.yml b/.github/dependabot.yml index d232a23..dd93a24 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,10 +1,10 @@ version: 2 updates: -- package-ecosystem: pip - directory: "/" - schedule: - interval: daily -- package-ecosystem: github-actions - directory: "/" - schedule: - interval: daily + - package-ecosystem: pip + directory: "/" + schedule: + interval: daily + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: daily diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b7f9901..eac578b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,33 +1,10 @@ name: CI - on: push: branches: - main pull_request: - jobs: - - lint: - runs-on: ubuntu-latest - strategy: - matrix: - lint-command: - - bandit -r . -x ./tests - - black --check --diff . - - flake8 . - - isort --check-only --diff . - - pydocstyle . - steps: - - uses: actions/checkout@v5 - - uses: actions/setup-python@v6 - with: - python-version: "3.x" - cache: 'pip' - cache-dependency-path: 'pyproject.toml' - - run: python -m pip install -e .[lint] - - run: ${{ matrix.lint-command }} - dist: runs-on: ubuntu-latest steps: @@ -43,11 +20,8 @@ jobs: - uses: actions/upload-artifact@v5 with: path: dist/* - pytest-os: name: PyTest - needs: - - lint strategy: matrix: os: @@ -67,11 +41,8 @@ jobs: with: flags: ${{ matrix.os }} token: ${{ secrets.CODECOV_TOKEN }} - pytest-python: name: PyTest - needs: - - lint strategy: matrix: python-version: @@ -80,7 +51,7 @@ jobs: - "3.12" - "3.13" django-version: - - "4.2" # LTS + - "4.2" # LTS runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 @@ -95,11 +66,8 @@ jobs: with: flags: py${{ matrix.python-version }} token: ${{ secrets.CODECOV_TOKEN }} - pytest-django: name: PyTest - needs: - - lint strategy: matrix: python-version: @@ -122,11 +90,8 @@ jobs: with: flags: dj${{ matrix.django-version }} token: ${{ secrets.CODECOV_TOKEN }} - pytest-extras: name: PyTest - needs: - - lint strategy: matrix: extras: @@ -151,10 +116,9 @@ jobs: with: flags: ${{ matrix.extras }} token: ${{ secrets.CODECOV_TOKEN }} - codeql: name: CodeQL - needs: [ dist, pytest-os, pytest-python, pytest-django, pytest-extras ] + needs: [dist, pytest-os, pytest-python, pytest-django, pytest-extras] runs-on: ubuntu-latest permissions: actions: read @@ -163,7 +127,7 @@ jobs: strategy: fail-fast: false matrix: - language: [ python ] + language: [python] steps: - name: Checkout uses: actions/checkout@v5 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 88e7ad0..59051bb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,14 +1,11 @@ name: Release - on: release: types: [published] workflow_dispatch: - jobs: pypi-build: runs-on: ubuntu-latest - steps: - uses: actions/checkout@v5 - uses: actions/setup-python@v6 @@ -20,14 +17,12 @@ jobs: with: name: release-dists path: dist/ - pypi-publish: runs-on: ubuntu-latest needs: - pypi-build permissions: id-token: write - steps: - uses: actions/download-artifact@v6 with: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..e6fd6ec --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,48 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: trailing-whitespace + - id: check-merge-conflict + - id: check-ast + - id: check-toml + - id: check-yaml + - id: check-symlinks + - id: debug-statements + - id: end-of-file-fixer + exclude: "pictures/templates/pictures/attrs.html" + - id: no-commit-to-branch + args: [--branch, main] + - repo: https://github.com/asottile/pyupgrade + rev: v3.20.0 + hooks: + - id: pyupgrade + - repo: https://github.com/adamchainz/django-upgrade + rev: 1.29.0 + hooks: + - id: django-upgrade + - repo: https://github.com/hukkin/mdformat + rev: 0.7.22 + hooks: + - id: mdformat + additional_dependencies: + - mdformat-ruff + - mdformat-deflist + - mdformat-footnote + - mdformat-gfm + - mdformat-gfm-alerts + - mdformat-tables + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.13.3 + hooks: + - id: ruff-check + args: [--fix, --exit-non-zero-on-fix] + - id: ruff-format + - repo: https://github.com/google/yamlfmt + rev: v0.19.0 + hooks: + - id: yamlfmt +ci: + autoupdate_schedule: weekly + skip: + - no-commit-to-branch diff --git a/README.md b/README.md index b0927e9..3c56233 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ and are ready to adopt in your project :) from django.db import models from pictures.models import PictureField + class Profile(models.Model): title = models.CharField(max_length=255) picture = PictureField(upload_to="avatars") @@ -77,7 +78,7 @@ python3 -m pip install django-pictures # settings.py INSTALLED_APPS = [ # ... - 'pictures', + "pictures", ] # the following are defaults, but you can override them @@ -96,7 +97,6 @@ PICTURES = { "USE_PLACEHOLDERS": True, "QUEUE_NAME": "pictures", "PROCESSOR": "pictures.tasks.process_picture", - } ``` @@ -155,8 +155,8 @@ from pictures.models import PictureField class Profile(models.Model): title = models.CharField(max_length=255) picture = PictureField( - upload_to="avatars", - aspect_ratios=[None, "1/1", "3/2", "16/9"], + upload_to="avatars", + aspect_ratios=[None, "1/1", "3/2", "16/9"], ) ``` @@ -263,6 +263,7 @@ available picture sizes in a DRF serializer. from rest_framework import serializers from pictures.contrib.rest_framework import PictureField + class PictureSerializer(serializers.Serializer): picture = PictureField() ``` @@ -274,6 +275,7 @@ providing the `aspect_ratios` and `file_types` arguments to the DRF field. from rest_framework import serializers from pictures.contrib.rest_framework import PictureField + class PictureSerializer(serializers.Serializer): picture = PictureField(aspect_ratios=["16/9"], file_types=["AVIF"]) ``` @@ -366,5 +368,4 @@ class MyPicture(Picture): [django-rq]: https://github.com/rq/django-rq [dramatiq]: https://dramatiq.io/ [drf]: https://www.django-rest-framework.org/ -[libavif-install]: https://pillow.readthedocs.io/en/latest/installation/building-from-source.html#external-libraries [migration]: tests/testapp/migrations/0002_alter_profile_picture.py diff --git a/pictures/migrations.py b/pictures/migrations.py index 0dac668..9fe6eba 100644 --- a/pictures/migrations.py +++ b/pictures/migrations.py @@ -1,5 +1,3 @@ -from typing import Type - from django.db import models from django.db.migrations import AlterField from django.db.models import Q @@ -28,7 +26,7 @@ def database_backwards(self, app_label, schema_editor, from_state, to_state): self.alter_picture_field(from_model, to_model) def alter_picture_field( - self, from_model: Type[models.Model], to_model: Type[models.Model] + self, from_model: type[models.Model], to_model: type[models.Model] ): from_field = from_model._meta.get_field(self.name) to_field = to_model._meta.get_field(self.name) @@ -46,7 +44,7 @@ def alter_picture_field( ): self.update_pictures(from_field, to_model) - def update_pictures(self, from_field: PictureField, to_model: Type[models.Model]): + def update_pictures(self, from_field: PictureField, to_model: type[models.Model]): """Remove obsolete pictures and create new ones.""" for obj in to_model._default_manager.exclude( Q(**{self.name: ""}) | Q(**{self.name: None}) @@ -57,13 +55,13 @@ def update_pictures(self, from_field: PictureField, to_model: Type[models.Model] ) new_field_file.update_all(old_field_file) - def from_picture_field(self, from_model: Type[models.Model]): + def from_picture_field(self, from_model: type[models.Model]): for obj in from_model._default_manager.all().iterator(): field_file = getattr(obj, self.name) field_file.delete_all() def to_picture_field( - self, from_model: Type[models.Model], to_model: Type[models.Model] + self, from_model: type[models.Model], to_model: type[models.Model] ): from_field = from_model._meta.get_field(self.name) if hasattr(from_field.attr_class, "delete_variations"): diff --git a/pictures/models.py b/pictures/models.py index 7581d2b..4b3f326 100644 --- a/pictures/models.py +++ b/pictures/models.py @@ -92,6 +92,7 @@ def url(self) -> str: def height(self) -> int | None: if self.aspect_ratio: return math.floor(self.width / self.aspect_ratio) + return None @property def name(self) -> str: @@ -129,7 +130,6 @@ def delete(self): class PictureFieldFile(ImageFieldFile): - def __xor__(self, other) -> tuple[set[Picture], set[Picture]]: """Return the new and obsolete :class:`Picture` instances.""" if not isinstance(other, PictureFieldFile): @@ -176,7 +176,7 @@ def update_all(self, other: PictureFieldFile | None = None): @property def width(self): self._require_file() - if self._committed and self.field.width_field: + if self._committed and self.field.width_field: # NoQA SIM102 if width := getattr(self.instance, self.field.width_field, None): # get width from width field, to avoid loading image return width @@ -185,7 +185,7 @@ def width(self): @property def height(self): self._require_file() - if self._committed and self.field.height_field: + if self._committed and self.field.height_field: # NoQA SIM102 if height := getattr(self.instance, self.field.height_field, None): # get height from height field, to avoid loading image return height diff --git a/pictures/tasks.py b/pictures/tasks.py index 48d0223..a2bf1ba 100644 --- a/pictures/tasks.py +++ b/pictures/tasks.py @@ -13,7 +13,6 @@ def noop(*args, **kwargs) -> None: class PictureProcessor(Protocol): - def __call__( self, storage: tuple[str, list, dict], @@ -33,11 +32,10 @@ def _process_picture( old = old or [] storage = utils.reconstruct(*storage) if new: - with storage.open(file_name) as fs: - with Image.open(fs) as img: - for picture in new: - picture = utils.reconstruct(*picture) - picture.save(img) + with storage.open(file_name) as fs, Image.open(fs) as img: + for picture in new: + picture = utils.reconstruct(*picture) + picture.save(img) for picture in old: picture = utils.reconstruct(*picture) diff --git a/pictures/templatetags/pictures.py b/pictures/templatetags/pictures.py index 638c7e6..af627a8 100644 --- a/pictures/templatetags/pictures.py +++ b/pictures/templatetags/pictures.py @@ -38,18 +38,16 @@ def picture(field_file, img_alt=None, ratio=None, container=None, **kwargs): img_attrs[key[4:]] = value else: raise TypeError(f"Invalid keyword argument: {key}") - return tmpl.render( - { - "field_file": field_file, - "alt": img_alt, - "ratio": (ratio or "3/2").replace("/", "x"), - "sources": sources, - "media": utils.sizes(field=field, container_width=container, **breakpoints), - "picture_attrs": picture_attrs, - "img_attrs": img_attrs, - "use_placeholders": settings.USE_PLACEHOLDERS, - } - ) + return tmpl.render({ + "field_file": field_file, + "alt": img_alt, + "ratio": (ratio or "3/2").replace("/", "x"), + "sources": sources, + "media": utils.sizes(field=field, container_width=container, **breakpoints), + "picture_attrs": picture_attrs, + "img_attrs": img_attrs, + "use_placeholders": settings.USE_PLACEHOLDERS, + }) @register.simple_tag() diff --git a/pictures/utils.py b/pictures/utils.py index 139cccf..e088042 100644 --- a/pictures/utils.py +++ b/pictures/utils.py @@ -79,7 +79,7 @@ def source_set( @lru_cache def placeholder(width: int, height: int, alt): - hue = random.randint(0, 360) # nosec + hue = random.randint(0, 360) # NoQA S311 img = Image.new("RGB", (width, height), color=f"hsl({hue}, 40%, 80%)") draw = ImageDraw.Draw(img) draw.line(((0, 0, width, height)), width=3, fill=f"hsl({hue}, 60%, 20%)") diff --git a/pyproject.toml b/pyproject.toml index 467008b..18beb83 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,13 +43,6 @@ test = [ "pytest-django", "redis", ] -lint = [ - "bandit==1.8.6", - "black==25.9.0", - "flake8==7.3.0", - "isort==6.1.0", - "pydocstyle[toml]==6.3.0", -] dramatiq = [ "django-dramatiq", ] @@ -102,3 +95,49 @@ skip = ["pictures/_version.py"] [tool.pydocstyle] add_ignore = "D1" + +[tool.ruff] +src = ["flit_gettext", "tests"] +line-length = 88 +indent-width = 4 + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +skip-magic-trailing-comma = false +line-ending = "auto" +preview = true + +[tool.ruff.lint] +select = [ + "D", # pydocstyle + "EXE", # flake8-executable + "F", # pyflakes + "I", # isort + "PT", # flake8-pytest-style + "RET", # flake8-return + "S", # flake8-bandit + "SIM", # flake8-simplify + "UP", # pyupgrade + "W", # pycodestyle warnings +] + +ignore = ["D1"] + +[tool.ruff.lint.per-file-ignores] +"*/test*.py" = ["S101", "E501", "PT011"] +"*/migrations/*.py" = ["E501"] + +[tool.ruff.lint.flake8-pytest-style] +fixture-parentheses = false +mark-parentheses = false + +[tool.ruff.lint.isort] +combine-as-imports = true +split-on-trailing-comma = true +section-order = ["future", "standard-library", "third-party", "first-party", "local-folder"] +force-wrap-aliases = true +known-first-party = ["pictures", "tests"] + +[tool.ruff.lint.pydocstyle] +convention = "pep257" diff --git a/tests/conftest.py b/tests/conftest.py index a62707a..8453122 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -35,7 +35,7 @@ def large_image_upload_file(): return SimpleUploadedFile("image.png", output.getvalue()) -@pytest.fixture(autouse=True, scope="function") +@pytest.fixture(autouse=True) def media_root(settings, tmpdir_factory): settings.MEDIA_ROOT = tmpdir_factory.mktemp("media", numbered=True) @@ -45,7 +45,7 @@ def instant_commit(monkeypatch): monkeypatch.setattr("django.db.transaction.on_commit", lambda f: f()) -@pytest.fixture() +@pytest.fixture def stub_worker(): try: import dramatiq diff --git a/tests/manage.py b/tests/manage.py index b6b55fc..5bf68d8 100755 --- a/tests/manage.py +++ b/tests/manage.py @@ -1,5 +1,6 @@ #!/usr/bin/env python """Django's command-line utility for administrative tasks.""" + import os import sys diff --git a/tests/test_checks.py b/tests/test_checks.py index 2882e1a..f0d21ba 100644 --- a/tests/test_checks.py +++ b/tests/test_checks.py @@ -7,7 +7,6 @@ def test_placeholder_url_check(settings, monkeypatch): """Test that the placeholder URL check works.""" - settings.PICTURES["USE_PLACEHOLDERS"] = True assert not checks.placeholder_url_check({}) diff --git a/tests/test_models.py b/tests/test_models.py index b745158..00fa40c 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -112,7 +112,6 @@ def test_process__copy(self): class TestPictureFieldFile: - @pytest.mark.django_db def test_symmetric_difference(self, image_upload_file): obj = SimpleModel.objects.create(picture=image_upload_file) diff --git a/tests/test_utils.py b/tests/test_utils.py index a569026..3ffc3f0 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -152,7 +152,6 @@ def url(self): def test_reconstruct(image_upload_file): - picture = TestPicture( image_upload_file.name, "WEBP", diff --git a/tests/testapp/migrations/0006_profile_other_picture.py b/tests/testapp/migrations/0006_profile_other_picture.py index 7a4c640..3b76b9f 100644 --- a/tests/testapp/migrations/0006_profile_other_picture.py +++ b/tests/testapp/migrations/0006_profile_other_picture.py @@ -6,7 +6,6 @@ class Migration(migrations.Migration): - dependencies = [ ("testapp", "0005_alter_profile_picture"), ] diff --git a/tests/testapp/settings.py b/tests/testapp/settings.py index 087a0e2..ed06790 100644 --- a/tests/testapp/settings.py +++ b/tests/testapp/settings.py @@ -21,7 +21,7 @@ # See https://docs.djangoproject.com/en/4.0/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = "django-insecure-e=qnar@mq(a8%x&(_x3)4cc)o@8j-3t&62m@8c!5k+3z8_sgsu" +SECRET_KEY = "django-insecure-e=qnar@mq(a8%x&(_x3)4cc)o@8j-3t&62m@8c!5k+3z8_sgsu" # NoQA S105 # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True