diff --git a/.flake8 b/.flake8 deleted file mode 100644 index 1abb1ca1..00000000 --- a/.flake8 +++ /dev/null @@ -1,38 +0,0 @@ -[flake8] -max-line-length = 88 -max-complexity = 8 -# http://flake8.pycqa.org/en/2.5.5/warnings.html#warning-error-codes -ignore = - # pydocstyle - docstring conventions (PEP257) - D100 # Missing docstring in public module - D101 # Missing docstring in public class - D102 # Missing docstring in public method - D103 # Missing docstring in public function - D104 # Missing docstring in public package - D105 # Missing docstring in magic method - D106 # Missing docstring in public nested class - D107 # Missing docstring in __init__ - D412 # No blank lines allowed between a section header and its content - # pycodestyle - style checker (PEP8) - W503 # line break before binary operator - # the following are ignored in CI using --extend-ignore option: - ; D205 # [pydocstyle] 1 blank line required between summary line and description - ; D400 # [pydocstyle] First line should end with a period - ; D401 # [pydocstyle] First line should be in imperative mood - ; S308 # [bandit] Use of mark_safe() may expose cross-site scripting vulnerabilities and should be reviewed. - ; S703 # [bandit] Potential XSS on mark_safe function. - -per-file-ignores = - ; D205 - 1 blank line required between summary line and description - ; D400 - First line should end with a period - ; D401 - First line should be in imperative mood - ; S101 - use of assert - ; S106 - hard-coded password - ; E501 - line-length - ; E731 - assigning a lambda to a variable - *tests/*:D205,D400,D401,S101,S106,E501,E731 - */migrations/*:E501 - ; F403 - unable to detect undefined names - ; F405 - may be undefined, or defined from star imports - */settings.py:F403,F405 - */settings/*:F403,F405 diff --git a/.github/workflows/tox.yml b/.github/workflows/tox.yml index 3b74a3e0..9497167e 100644 --- a/.github/workflows/tox.yml +++ b/.github/workflows/tox.yml @@ -14,18 +14,41 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - toxenv: [fmt,lint,mypy] + toxenv: [fmt, lint, mypy] env: TOXENV: ${{ matrix.toxenv }} steps: - name: Check out the repository - uses: actions/checkout@v2 + uses: actions/checkout@v4 - - name: Set up Python 3.9 - uses: actions/setup-python@v1 + - name: Set up Python (3.11) + uses: actions/setup-python@v4 with: - python-version: 3.9 + python-version: "3.11" + + - name: Install and run tox + run: | + pip install tox + tox + + checks: + name: Run Django checks + runs-on: ubuntu-latest + strategy: + matrix: + toxenv: ["django-checks"] + env: + TOXENV: ${{ matrix.toxenv }} + + steps: + - name: Check out the repository + uses: actions/checkout@v4 + + - name: Set up Python (3.11) + uses: actions/setup-python@v4 + with: + python-version: "3.11" - name: Install and run tox run: | @@ -37,35 +60,34 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python: [3.9] - django: [22,32,main] + python: ["3.8", "3.9", "3.10", "3.11", "3.12"] + # build LTS version, next version, HEAD + django: ["32", "42", "50", "main"] + exclude: + - python: "3.8" + django: "50" + - python: "3.8" + django: "main" + - python: "3.9" + django: "50" + - python: "3.9" + django: "main" + - python: "3.10" + django: "main" + - python: "3.11" + django: "32" + - python: "3.12" + django: "32" env: - TOXENV: py${{ matrix.python }}-django${{ matrix.django }} - - # services: - # postgres: - # image: postgres:12 - # env: - # POSTGRES_USER: postgres - # POSTGRES_PASSWORD: postgres - # POSTGRES_DB: onfido - # # Set health checks to wait until postgres has started - # options: >- - # --health-cmd pg_isready - # --health-interval 10s - # --health-timeout 5s - # --health-retries 5 - # ports: - # # Maps tcp port 5432 on service container to the host - # - 5432:5432 + TOXENV: django${{ matrix.django }}-py${{ matrix.python }} steps: - name: Check out the repository - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python }} - uses: actions/setup-python@v1 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python }} diff --git a/.isort.cfg b/.isort.cfg deleted file mode 100644 index 629cb680..00000000 --- a/.isort.cfg +++ /dev/null @@ -1,8 +0,0 @@ -[settings] -default_section=THIRDPARTY -indent=' ' -sections=FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,LOCALFOLDER -multi_line_output=3 -line_length=88 -include_trailing_comma=True -use_parentheses=True diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 14896d20..def92585 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,39 +1,22 @@ repos: - # python import sorting - will amend files - - repo: https://github.com/pre-commit/mirrors-isort - rev: v5.9.3 - hooks: - - id: isort - # python code formatting - will amend files - repo: https://github.com/ambv/black - rev: 21.9b0 + rev: 23.10.1 hooks: - id: black - - repo: https://github.com/asottile/pyupgrade - rev: v2.29.0 - hooks: - - id: pyupgrade - - # Flake8 includes pyflakes, pycodestyle, mccabe, pydocstyle, bandit - - repo: https://gitlab.com/pycqa/flake8 - rev: 3.9.2 + - repo: https://github.com/charliermarsh/ruff-pre-commit + # Ruff version. + rev: "v0.1.5" hooks: - - id: flake8 - additional_dependencies: - - flake8-bandit - - flake8-blind-except - - flake8-docstrings - - flake8-logging-format - - flake8-print + - id: ruff + args: [--fix, --exit-non-zero-on-fix] # python static type checking - repo: https://github.com/pre-commit/mirrors-mypy - rev: v0.910-1 + rev: v1.7.0 hooks: - id: mypy - # additional_dependencies: args: - --disallow-untyped-defs - --disallow-incomplete-defs @@ -41,4 +24,3 @@ repos: - --no-implicit-optional - --ignore-missing-imports - --follow-imports=silent - exclude: ^tests diff --git a/.ruff.toml b/.ruff.toml new file mode 100644 index 00000000..ccc19473 --- /dev/null +++ b/.ruff.toml @@ -0,0 +1,66 @@ +line-length = 88 +ignore = [ + "D100", # Missing docstring in public module + "D101", # Missing docstring in public class + "D102", # Missing docstring in public method + "D103", # Missing docstring in public function + "D104", # Missing docstring in public package + "D105", # Missing docstring in magic method + "D106", # Missing docstring in public nested class + "D107", # Missing docstring in __init__ + "D203", # 1 blank line required before class docstring + "D212", # Multi-line docstring summary should start at the first line + "D213", # Multi-line docstring summary should start at the second line + "D404", # First word of the docstring should not be "This" + "D405", # Section name should be properly capitalized + "D406", # Section name should end with a newline + "D407", # Missing dashed underline after section + "D410", # Missing blank line after section + "D411", # Missing blank line before section + "D412", # No blank lines allowed between a section header and its content + "D416", # Section name should end with a colon + "D417", + "D417", # Missing argument description in the docstring +] +select = [ + "A", # flake8 builtins + "C9", # mcabe + "D", # pydocstyle + "E", # pycodestyle (errors) + "F", # Pyflakes + "I", # isort + "S", # flake8-bandit + "T2", # flake8-print + "W", # pycodestype (warnings) +] + +[isort] +combine-as-imports = true + +[mccabe] +max-complexity = 8 + +[per-file-ignores] +"*tests/*" = [ + "D205", # 1 blank line required between summary line and description + "D400", # First line should end with a period + "D401", # First line should be in imperative mood + "D415", # First line should end with a period, question mark, or exclamation point + "E501", # Line too long + "E731", # Do not assign a lambda expression, use a def + "S101", # Use of assert detected + "S105", # Possible hardcoded password + "S106", # Possible hardcoded password + "S113", # Probable use of requests call with timeout set to {value} +] +"*/migrations/*" = [ + "E501", # Line too long +] +"*/settings.py" = [ + "F403", # from {name} import * used; unable to detect undefined names + "F405", # {name} may be undefined, or defined from star imports: +] +"*/settings/*" = [ + "F403", # from {name} import * used; unable to detect undefined names + "F405", # {name} may be undefined, or defined from star imports: +] diff --git a/LICENSE b/LICENSE index 8db54619..fb557feb 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2020 YunoJuno Ltd +Copyright (c) 2023 YunoJuno Ltd Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/example/apps.py b/example/apps.py index e3927ac9..c147216b 100644 --- a/example/apps.py +++ b/example/apps.py @@ -4,3 +4,4 @@ class ExampleAppConfig(AppConfig): name = "example" verbose_name = "Sample Cats app " + default_auto_field = "django.db.models.AutoField" diff --git a/example/migrations/0001_initial.py b/example/migrations/0001_initial.py index a3e9e74f..0f71447b 100644 --- a/example/migrations/0001_initial.py +++ b/example/migrations/0001_initial.py @@ -7,7 +7,6 @@ class Migration(migrations.Migration): - initial = True dependencies = [] diff --git a/pyproject.toml b/pyproject.toml index 02d356cf..ec8d6974 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "django-s3-upload" -version = "0.4" +version = "1.0" description = "Integrates direct client-side uploading to s3 with Django." authors = ["YunoJuno "] license = "MIT" @@ -10,46 +10,43 @@ homepage = "https://github.com/yunojuno/django-s3-upload" repository = "https://github.com/yunojuno/django-s3-upload" documentation = "https://github.com/yunojuno/django-s3-upload" classifiers = [ - "Development Status :: 4 - Beta", "Environment :: Web Environment", "Framework :: Django", - "Framework :: Django :: 2.2", - "Framework :: Django :: 3.0", - "Framework :: Django :: 3.1", "Framework :: Django :: 3.2", + "Framework :: Django :: 4.0", + "Framework :: Django :: 4.1", + "Framework :: Django :: 4.2", + "Framework :: Django :: 5.0", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", ] packages = [{ include = "s3upload" }] [tool.poetry.dependencies] -python = "^3.7" -django = "^2.2 || ^3.0 || ^4.0" +python = "^3.8" +django = "^3.2 || ^4.0 || ^5.0" boto3 = "^1.14" -[tool.poetry.dev-dependencies] -black = {version = "*", allow-prereleases = true} +[tool.poetry.group.test.dependencies] coverage = "*" -flake8 = "*" -flake8-bandit = "*" -flake8-blind-except = "*" -flake8-docstrings = "*" -flake8-logging-format = "*" -flake8-print = "*" -freezegun = "*" -isort = "*" -mypy = "*" -pre-commit = "*" pytest = "*" pytest-cov = "*" pytest-django = "*" tox = "*" +[tool.poetry.group.dev.dependencies] +black = "*" +coverage = "*" +mypy = "*" +pre-commit = "*" +ruff = "*" + [build-system] requires = ["poetry>=0.12"] build-backend = "poetry.masonry.api" diff --git a/s3upload/__init__.py b/s3upload/__init__.py index 241b0c93..e69de29b 100644 --- a/s3upload/__init__.py +++ b/s3upload/__init__.py @@ -1 +0,0 @@ -default_app_config = "s3upload.apps.S3UploadAppConfig" diff --git a/s3upload/apps.py b/s3upload/apps.py index 03f15640..04b20acb 100644 --- a/s3upload/apps.py +++ b/s3upload/apps.py @@ -4,3 +4,4 @@ class S3UploadAppConfig(AppConfig): name = "s3upload" verbose_name = "S3 Uploads" + default_auto_field = "django.db.models.AutoField" diff --git a/s3upload/views.py b/s3upload/views.py index 81382279..32e28c7a 100644 --- a/s3upload/views.py +++ b/s3upload/views.py @@ -12,7 +12,6 @@ @require_POST def get_upload_params(request: HttpRequest) -> JsonResponse: # noqa: C901 - content_type = request.POST["type"] filename = get_valid_filename(request.POST["name"]) dest = settings.S3UPLOAD_DESTINATIONS[request.POST["dest"]] diff --git a/tests/settings.py b/tests/settings.py index b5dd3e5c..cac8095c 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -76,7 +76,7 @@ # used by the example app -def create_filename(filename): +def create_filename(filename: str) -> str: ext = filename.split(".")[-1] filename = "{}.{}".format(uuid.uuid4().hex, ext) return path.join("custom", filename) diff --git a/tests/test_widgets.py b/tests/test_widgets.py index 9c715581..648663a1 100644 --- a/tests/test_widgets.py +++ b/tests/test_widgets.py @@ -43,11 +43,11 @@ class WidgetTests(TestCase): - def setUp(self): + def setUp(self) -> None: admin = User.objects.create_superuser("admin", "u@email.com", "admin") admin.save() - def test_init(self): + def test_init(self) -> None: # Test initialising the widget without an invalid destination self.assertRaises(ImproperlyConfigured, widgets.S3UploadWidget, "foo") self.assertRaises(ValueError, widgets.S3UploadWidget, None) @@ -55,71 +55,71 @@ def test_init(self): with override_settings(S3UPLOAD_DESTINATIONS={"foo": {}}): widgets.S3UploadWidget("foo") - def test_check_urls(self): + def test_check_urls(self) -> None: reversed_url = reverse("s3upload") resolved_url = resolve("/s3upload/get_upload_params/") self.assertEqual(reversed_url, "/s3upload/get_upload_params/") self.assertEqual(resolved_url.view_name, "s3upload") @override_settings(S3UPLOAD_DESTINATIONS={"foo": {}}) - def test_check_widget_html(self): + def test_check_widget_html(self) -> None: widget = widgets.S3UploadWidget(dest="foo") - html = widget.render("filename", None) + html = widget.render("filename", "") self.assertEqual(html, HTML_OUTPUT) - def test_check_signing_logged_in(self): + def test_check_signing_logged_in(self) -> None: self.client.login(username="admin", password="admin") data = {"dest": "files", "name": "image.jpg", "type": "image/jpeg"} response = self.client.post(reverse("s3upload"), data) self.assertEqual(response.status_code, 200) - def test_check_signing_logged_out(self): + def test_check_signing_logged_out(self) -> None: data = {"dest": "files", "name": "image.jpg", "type": "image/jpeg"} response = self.client.post(reverse("s3upload"), data) self.assertEqual(response.status_code, 403) - def test_check_allowed_type(self): + def test_check_allowed_type(self) -> None: data = {"dest": "imgs", "name": "image.jpg", "type": "image/jpeg"} response = self.client.post(reverse("s3upload"), data) self.assertEqual(response.status_code, 200) - def test_check_disallowed_type(self): + def test_check_disallowed_type(self) -> None: data = {"dest": "imgs", "name": "image.mp4", "type": "video/mp4"} response = self.client.post(reverse("s3upload"), data) self.assertEqual(response.status_code, 400) - def test_check_allowed_type_logged_in(self): + def test_check_allowed_type_logged_in(self) -> None: self.client.login(username="admin", password="admin") data = {"dest": "vids", "name": "video.mp4", "type": "video/mp4"} response = self.client.post(reverse("s3upload"), data) self.assertEqual(response.status_code, 200) - def test_check_disallowed_type_logged_out(self): + def test_check_disallowed_type_logged_out(self) -> None: data = {"dest": "vids", "name": "video.mp4", "type": "video/mp4"} response = self.client.post(reverse("s3upload"), data) self.assertEqual(response.status_code, 403) - def test_check_disallowed_extensions(self): + def test_check_disallowed_extensions(self) -> None: data = {"dest": "imgs", "name": "image.jfif", "type": "image/jpeg"} response = self.client.post(reverse("s3upload"), data) self.assertEqual(response.status_code, 415) - def test_check_allowed_extensions(self): + def test_check_allowed_extensions(self) -> None: data = {"dest": "imgs", "name": "image.jpg", "type": "image/jpeg"} response = self.client.post(reverse("s3upload"), data) self.assertEqual(response.status_code, 200) - def test_check_disallowed_extensions__uppercase(self): + def test_check_disallowed_extensions__uppercase(self) -> None: data = {"dest": "imgs", "name": "image.JFIF", "type": "image/jpeg"} response = self.client.post(reverse("s3upload"), data) self.assertEqual(response.status_code, 415) - def test_check_allowed_extensions__uppercase(self): + def test_check_allowed_extensions__uppercase(self) -> None: data = {"dest": "imgs", "name": "image.JPG", "type": "image/jpeg"} response = self.client.post(reverse("s3upload"), data) self.assertEqual(response.status_code, 200) - def test_check_signing_fields(self): + def test_check_signing_fields(self) -> None: self.client.login(username="admin", password="admin") data = {"dest": "imgs", "name": "image.jpg", "type": "image/jpeg"} response = self.client.post(reverse("s3upload"), data) @@ -138,7 +138,7 @@ def test_check_signing_fields(self): self.assertEqual(aws_payload["key"], "uploads/imgs/image.jpg") self.assertEqual(aws_payload["content-type"], "image/jpeg") - def test_check_signing_fields_unique_filename(self): + def test_check_signing_fields_unique_filename(self) -> None: data = {"dest": "misc", "name": "image.jpg", "type": "image/jpeg"} response = self.client.post(reverse("s3upload"), data) response_dict = json.loads(response.content.decode()) @@ -156,7 +156,7 @@ def test_check_signing_fields_unique_filename(self): self.assertEqual(aws_payload["key"], "images/unique.jpg") self.assertEqual(aws_payload["content-type"], "image/jpeg") - def test_check_policy_conditions(self): + def test_check_policy_conditions(self) -> None: self.client.login(username="admin", password="admin") data = {"dest": "cached", "name": "video.mp4", "type": "video/mp4"} response = self.client.post(reverse("s3upload"), data) @@ -183,7 +183,7 @@ def test_check_policy_conditions(self): } } ) - def test_check_signed_url(self): + def test_check_signed_url(self) -> None: data = {"dest": "misc", "name": "image.jpg", "type": "image/jpeg"} response = self.client.post(reverse("s3upload"), data) response_dict = json.loads(response.content.decode()) @@ -194,7 +194,7 @@ def test_check_signed_url(self): self.assertTrue("Signature" in parsed_qs) self.assertTrue("Expires" in parsed_qs) - def test_content_length_range(self): + def test_content_length_range(self) -> None: # Content_length_range setting is always sent as part of policy. # Initial request data doesn't affect it. data = {"dest": "imgs", "name": "image.jpg", "type": "image/jpeg"} diff --git a/tox.ini b/tox.ini index a7874d26..7ac65526 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,15 @@ [tox] isolated_build = True -envlist = fmt, lint, mypy, py{3.7,3.8,3.9,3.10}-django{22,30,31,32,main} +envlist = + fmt, lint, mypy, + django-checks, + ; https://docs.djangoproject.com/en/5.0/releases/ + django32-py{38,39,310} + django40-py{38,39,310} + django41-py{38,39,310,311} + django42-py{38,39,310,311} + django50-py{310,311,312} + djangomain-py{311,312} [testenv] deps = @@ -8,44 +17,38 @@ deps = pytest pytest-cov pytest-django - django22: Django>=2.2,<2.3 - django30: Django>=3.0,<3.1 - django31: Django>=3.1,<3.2 django32: Django>=3.2,<3.3 + django40: Django>=4.0,<4.1 + django41: Django>=4.1,<4.2 + django42: Django>=4.2,<4.3 + django50: https://github.com/django/django/archive/stable/5.0.x.tar.gz djangomain: https://github.com/django/django/archive/main.tar.gz commands = pytest --cov=s3upload --verbose tests/ +[testenv:django-checks] +description = Django system checks and missing migrations +deps = Django +commands = + python manage.py check --fail-level WARNING + python manage.py makemigrations --dry-run --check --verbosity 3 + [testenv:fmt] -description = Python source code formatting (isort, black) +description = Python source code formatting (black) deps = - isort black commands = - isort --check-only s3upload black --check s3upload [testenv:lint] -description = Python source code linting (flake8, bandit, pydocstyle) +description = Python source code linting (ruff) deps = - flake8 - flake8-bandit - flake8-blind-except - flake8-docstrings - flake8-logging-format - flake8-print + ruff commands = - flake8 s3upload - -[testenv:django-checks] -description = Django system checks and missing migrations -deps = Django -commands = - python manage.py check --fail-level WARNING --settings=tests.settings - python manage.py makemigrations --dry-run --check --verbosity 3 + ruff s3upload [testenv:mypy] description = Python source code type hints (mypy)