From 0f67367a1e0478de55cdf54e042b3520379654ed Mon Sep 17 00:00:00 2001 From: Martin Castro Laminrs Date: Mon, 1 Jun 2026 20:07:39 +0200 Subject: [PATCH 1/5] chore: consolidate Python lint stack on Ruff + mypy + bandit (#651, #652) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove Black, standalone isort, and flake8 entirely — their config blocks, dev dependencies, and scripts/lint.sh steps. Ruff now owns lint + format + import order (the `I` rules); mypy and bandit run alongside. This resolves the three-formatter conflict and the Black 24-vs-26 pin skew (#452). Enable a strict mypy subset on the package (disallow_untyped_defs, warn_return_any) and wire the now-green Python lint gate (ruff check + ruff format --check + mypy + bandit) into backend CI. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 27 ++++-- django_admin_react/audit.py | 58 ------------ poetry.lock | 184 +----------------------------------- pyproject.toml | 71 ++++---------- scripts/lint.sh | 18 +--- 5 files changed, 40 insertions(+), 318 deletions(-) delete mode 100644 django_admin_react/audit.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4e2c283..99b9a72 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,15 +11,12 @@ # (run the test suites in CI); the no-CI revisit is recorded in # `SECURITY.md` §8 / `docs/agents/decisions.md` / OQ-A-001. # -# SCOPE: the backend job runs `pytest` (the regression that motivated -# #452 was a broken test). The frontend job runs the full `pnpm` gate — -# typecheck + lint + test + build — which is already self-consistent and -# green. Enforcing the *Python lint* gate in CI is a deliberate near-term -# follow-up: `scripts/lint.sh` currently runs two formatters (`ruff -# format` + `black`) whose output conflicts, so it is not yet satisfiable -# on a clean tree. That gets de-conflicted and the small existing lint -# debt cleared first (follow-up off #452), then the lint step is added -# here. +# SCOPE: the backend job runs the Python lint gate (ruff check + ruff +# format --check + mypy + bandit) and then `pytest`. The frontend job +# runs the full `pnpm` gate — typecheck + lint + test + build. The Python +# lint stack was collapsed onto a single Ruff-based chain in #651/#652 +# (black/isort/flake8 removed), which made the gate satisfiable on a +# clean tree and let it be wired in here. # # SECURITY POSTURE: # - Least-privilege: top-level `contents: read`; no job needs write. @@ -87,6 +84,18 @@ jobs: - name: Pin Django to the matrix version run: poetry run pip install "django~=${{ matrix.django }}.0" + # Python lint gate (#651/#652): a single Ruff-based stack — + # ruff check (lint, incl. `I` import order) + ruff format --check + + # mypy (strict subset on the package) + bandit (security). Black, + # standalone isort, and flake8 were removed. This is the same gate + # scripts/lint.sh runs locally. + - name: Lint (ruff + mypy + bandit) + run: | + poetry run ruff check django_admin_react tests + poetry run ruff format --check django_admin_react tests + poetry run mypy django_admin_react + poetry run bandit -r django_admin_react -c pyproject.toml -q + # pytest with coverage (per pyproject `addopts`), including # tests/test_security.py. `filterwarnings = ["error"]` means a new # warning fails the run. diff --git a/django_admin_react/audit.py b/django_admin_react/audit.py deleted file mode 100644 index 05f5c26..0000000 --- a/django_admin_react/audit.py +++ /dev/null @@ -1,58 +0,0 @@ -"""Access to Django's admin audit log (``LogEntry``). - -This module is deliberately **outside** ``django_admin_react/api/``. -The ``api/`` package obeys the hard rule (``SECURITY.md`` §3 rule 10 / -``ACCEPTANCE.md`` §3.1 B-2): every **consumer-model** queryset starts -from ``ModelAdmin.get_queryset(request)``, never ``Model.objects.*``. - -``django.contrib.admin.models.LogEntry`` is **not** a consumer model — -it is Django's own framework audit table, and Django's own -``ModelAdmin.history_view`` reads it via ``LogEntry.objects.filter(...)`` -directly. The get_queryset rule is categorically inapplicable to it. -Keeping the LogEntry access here, in its own single-responsibility -module, makes that distinction explicit at the file-system level rather -than burying a special case inside the consumer-model API layer. - -Public surface: - -- :func:`object_log_entries` — the ``LogEntry`` queryset for one object, - newest-first, with the acting user pre-fetched. -- :func:`recent_actions_for_user` — the most recent ``LogEntry`` rows for - one user (the index "Recent actions" panel), newest-first. -""" - -from __future__ import annotations - -from django.contrib.admin.models import LogEntry -from django.contrib.contenttypes.models import ContentType -from django.db.models import Model -from django.db.models import QuerySet - - -def object_log_entries(obj: Model) -> QuerySet[LogEntry]: - """Return the ``LogEntry`` rows for ``obj``, newest action first. - - Scoped by the object's ``ContentType`` + ``object_id`` — the same - pair Django's admin ``history_view`` uses. ``select_related("user")`` - so the timeline serializer doesn't N+1 on the acting user. - """ - content_type = ContentType.objects.get_for_model(type(obj)) - return ( - LogEntry.objects.filter(content_type=content_type, object_id=str(obj.pk)) - .select_related("user") - .order_by("-action_time") - ) - - -def recent_actions_for_user(user_pk: str | int, limit: int) -> QuerySet[LogEntry]: - """Return the most recent ``LogEntry`` rows for one user, newest first. - - The user-scoped counterpart of :func:`object_log_entries`: filtered by - the acting user and capped at ``limit`` — exactly how Django's admin - index "Recent actions" panel reads the log - (``LogEntry.objects.filter(user=...)``). Same get_queryset-rule - rationale as the module docstring: LogEntry is a framework audit - table, not a consumer model, so it is read directly here (outside - ``api/``) rather than via ``ModelAdmin.get_queryset``. - """ - return LogEntry.objects.filter(user__pk=user_pk).order_by("-action_time")[:limit] diff --git a/poetry.lock b/poetry.lock index ca95638..1263140 100644 --- a/poetry.lock +++ b/poetry.lock @@ -70,59 +70,6 @@ test = ["beautifulsoup4 (>=4.8.0)", "coverage (>=4.5.4)", "fixtures (>=3.0.0)", toml = ["tomli (>=1.1.0) ; python_version < \"3.11\""] yaml = ["PyYAML"] -[[package]] -name = "black" -version = "26.5.1" -description = "The uncompromising code formatter." -optional = false -python-versions = ">=3.10" -groups = ["dev"] -files = [ - {file = "black-26.5.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9942db8888e06943c5dde66ca0037dcff82a2a4ec1ad0ada9e0d2ee9d9823893"}, - {file = "black-26.5.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:89c93167a74d3a75dfaa38a5c7cca015537d5820dd7f17d63267d674a61cae90"}, - {file = "black-26.5.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22f2cd76d069cc54c71f10360744ba8983fbb616903b4304a85b734915c8e1b4"}, - {file = "black-26.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:87ed5c6f450580a2f6790bc7cbfb016dfc73bc750249762268a3695361315eef"}, - {file = "black-26.5.1-cp310-cp310-win_arm64.whl", hash = "sha256:58b4bd92cf88aacf83d88479c8f9caee044b1ec55f2451a337354a7ea2590a22"}, - {file = "black-26.5.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:96ae2c733b2aabdd9986e2c5df628ff3473676cd1c5faded1ff496cf6d74083c"}, - {file = "black-26.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0e48b87e03bf109288e55cfceadcfa15ff5470aca2851a851950ed2926f450d7"}, - {file = "black-26.5.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5119fa92ae61f786e8c3662fd60aece1d0a2dd5cca5d0c79417a95e7a4272a59"}, - {file = "black-26.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:30d3c14661f2792e9142cce3eeeb1cbc175b3eb5f733be0c8eeb99651e52b0c3"}, - {file = "black-26.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:1ef92b76f7733f282fd096ea406200b5a286c42947412b0eaff3a74e3616cefe"}, - {file = "black-26.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4ad6fa01f941920f54f2bbb35f3df7673428a0ef98a0b0840c2eaef3b110efa8"}, - {file = "black-26.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3915f256e75a2d7cf88d8953d37f780455dc586cc72dee059c528fe77f581217"}, - {file = "black-26.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d98d4137277c75dfb898ec8d846c4fd68ba1e9cf77f95e2865c203dc18f4c3d"}, - {file = "black-26.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:a1dca32d9f1784af512a13410ec204c6f7f0aa9797a111c42e1c03449821c264"}, - {file = "black-26.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:1037d5ac7b7b310b2632ad867ec8d0e4c4819dcdb0b820f63135da746a24e418"}, - {file = "black-26.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2b36cf2ddf5566e205f6535f782a62194a184d33e175b64ae8c40b1737522be3"}, - {file = "black-26.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1f7ea64ebfa01b50f693508fc39f875e264446d3b097088f84f203b9d09618a0"}, - {file = "black-26.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecb3e624844c798144e9bd986954e0adc81d8911a1f30f375e1252fe26e8c294"}, - {file = "black-26.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:e1a26503279b6b310669fb0b219c39e4820b77e8189fe80f522bb511f247db0a"}, - {file = "black-26.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c34b25da232ead53a6f335b76dbea124f4d152ad568b9080d6f944bc2b34b52"}, - {file = "black-26.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e88976690a64b0af98312ca958415849cb42423423c5f2ee74af4b49a97a2168"}, - {file = "black-26.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32d5ea7f6c8bdfa6e648326ebca1f02b0764e2a029edc6f8dce2627e19d468c3"}, - {file = "black-26.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ea8d16dc41655aa113cd64665e7219446cd7e4ff2248d7178eaa905190c86b18"}, - {file = "black-26.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:577f21094ea469ef92ec1adaf2c9441a226d2144d01a5be2fa823cecf6543e50"}, - {file = "black-26.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:ed1a20af114c301a0269bf01163d51dbef72737fd65f850001e7cbe7f3c7abae"}, - {file = "black-26.5.1-py3-none-any.whl", hash = "sha256:4ed7f7da04046d2e488437170797d3b4a4ad83906683bcb7dfc68b673bbce5e2"}, - {file = "black-26.5.1.tar.gz", hash = "sha256:dd321f668053961824bcc1be1cc1df748b2d7e4fa28086b08331e577b0100a73"}, -] - -[package.dependencies] -click = ">=8.0.0" -mypy-extensions = ">=0.4.3" -packaging = ">=22.0" -pathspec = ">=1.0.0" -platformdirs = ">=2" -pytokens = ">=0.4.0,<0.5.0" -tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} -typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""} - -[package.extras] -colorama = ["colorama (>=0.4.3)"] -d = ["aiohttp (>=3.10)"] -jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] -uvloop = ["uvloop (>=0.15.2) ; sys_platform != \"win32\"", "winloop (>=0.5.0) ; sys_platform == \"win32\""] - [[package]] name = "boolean-py" version = "5.0" @@ -314,21 +261,6 @@ files = [ {file = "charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5"}, ] -[[package]] -name = "click" -version = "8.4.1" -description = "Composable command line interface toolkit" -optional = false -python-versions = ">=3.10" -groups = ["dev"] -files = [ - {file = "click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2"}, - {file = "click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96"}, -] - -[package.dependencies] -colorama = {version = "*", markers = "platform_system == \"Windows\""} - [[package]] name = "colorama" version = "0.4.6" @@ -641,41 +573,6 @@ files = [ {file = "filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90"}, ] -[[package]] -name = "flake8" -version = "7.3.0" -description = "the modular source code checker: pep8 pyflakes and co" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "flake8-7.3.0-py2.py3-none-any.whl", hash = "sha256:b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e"}, - {file = "flake8-7.3.0.tar.gz", hash = "sha256:fe044858146b9fc69b551a4b490d69cf960fcb78ad1edcb84e7fbb1b4a8e3872"}, -] - -[package.dependencies] -mccabe = ">=0.7.0,<0.8.0" -pycodestyle = ">=2.14.0,<2.15.0" -pyflakes = ">=3.4.0,<3.5.0" - -[[package]] -name = "flake8-pyproject" -version = "1.2.4" -description = "Flake8 plug-in loading the configuration from pyproject.toml" -optional = false -python-versions = ">=3.6" -groups = ["dev"] -files = [ - {file = "flake8_pyproject-1.2.4-py3-none-any.whl", hash = "sha256:ea34c057f9a9329c76d98723bb2bb498cc6ba8ff9872c4d19932d48c91249a77"}, -] - -[package.dependencies] -Flake8 = ">=5" -TOMLi = {version = "*", markers = "python_version < \"3.11\""} - -[package.extras] -dev = ["Flit (>=3.4)", "pyTest (>=7)", "pyTest-cov (>=7) ; python_version >= \"3.10\""] - [[package]] name = "idna" version = "3.16" @@ -1236,30 +1133,6 @@ files = [ [package.dependencies] defusedxml = ">=0.7.1,<0.8.0" -[[package]] -name = "pycodestyle" -version = "2.14.0" -description = "Python style guide checker" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "pycodestyle-2.14.0-py2.py3-none-any.whl", hash = "sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d"}, - {file = "pycodestyle-2.14.0.tar.gz", hash = "sha256:c4b5b517d278089ff9d0abdec919cd97262a3367449ea1c8b49b91529167b783"}, -] - -[[package]] -name = "pyflakes" -version = "3.4.0" -description = "passive checker of Python programs" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "pyflakes-3.4.0-py2.py3-none-any.whl", hash = "sha256:f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f"}, - {file = "pyflakes-3.4.0.tar.gz", hash = "sha256:b24f96fafb7d2ab0ec5075b7350b3d2d2218eab42003821c06344973d3ea2f58"}, -] - [[package]] name = "pygments" version = "2.20.0" @@ -1411,61 +1284,6 @@ files = [ [package.dependencies] pytest = ">=7.0.0" -[[package]] -name = "pytokens" -version = "0.4.1" -description = "A Fast, spec compliant Python 3.14+ tokenizer that runs on older Pythons." -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "pytokens-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2a44ed93ea23415c54f3face3b65ef2b844d96aeb3455b8a69b3df6beab6acc5"}, - {file = "pytokens-0.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:add8bf86b71a5d9fb5b89f023a80b791e04fba57960aa790cc6125f7f1d39dfe"}, - {file = "pytokens-0.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:670d286910b531c7b7e3c0b453fd8156f250adb140146d234a82219459b9640c"}, - {file = "pytokens-0.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4e691d7f5186bd2842c14813f79f8884bb03f5995f0575272009982c5ac6c0f7"}, - {file = "pytokens-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:27b83ad28825978742beef057bfe406ad6ed524b2d28c252c5de7b4a6dd48fa2"}, - {file = "pytokens-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d70e77c55ae8380c91c0c18dea05951482e263982911fc7410b1ffd1dadd3440"}, - {file = "pytokens-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a58d057208cb9075c144950d789511220b07636dd2e4708d5645d24de666bdc"}, - {file = "pytokens-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b49750419d300e2b5a3813cf229d4e5a4c728dae470bcc89867a9ad6f25a722d"}, - {file = "pytokens-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d9907d61f15bf7261d7e775bd5d7ee4d2930e04424bab1972591918497623a16"}, - {file = "pytokens-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:ee44d0f85b803321710f9239f335aafe16553b39106384cef8e6de40cb4ef2f6"}, - {file = "pytokens-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:140709331e846b728475786df8aeb27d24f48cbcf7bcd449f8de75cae7a45083"}, - {file = "pytokens-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d6c4268598f762bc8e91f5dbf2ab2f61f7b95bdc07953b602db879b3c8c18e1"}, - {file = "pytokens-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24afde1f53d95348b5a0eb19488661147285ca4dd7ed752bbc3e1c6242a304d1"}, - {file = "pytokens-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ad948d085ed6c16413eb5fec6b3e02fa00dc29a2534f088d3302c47eb59adf9"}, - {file = "pytokens-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:3f901fe783e06e48e8cbdc82d631fca8f118333798193e026a50ce1b3757ea68"}, - {file = "pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b"}, - {file = "pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f"}, - {file = "pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1"}, - {file = "pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4"}, - {file = "pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78"}, - {file = "pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321"}, - {file = "pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa"}, - {file = "pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d"}, - {file = "pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324"}, - {file = "pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9"}, - {file = "pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb"}, - {file = "pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3"}, - {file = "pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975"}, - {file = "pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a"}, - {file = "pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918"}, - {file = "pytokens-0.4.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:da5baeaf7116dced9c6bb76dc31ba04a2dc3695f3d9f74741d7910122b456edc"}, - {file = "pytokens-0.4.1-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:11edda0942da80ff58c4408407616a310adecae1ddd22eef8c692fe266fa5009"}, - {file = "pytokens-0.4.1-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0fc71786e629cef478cbf29d7ea1923299181d0699dbe7c3c0f4a583811d9fc1"}, - {file = "pytokens-0.4.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dcafc12c30dbaf1e2af0490978352e0c4041a7cde31f4f81435c2a5e8b9cabb6"}, - {file = "pytokens-0.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:42f144f3aafa5d92bad964d471a581651e28b24434d184871bd02e3a0d956037"}, - {file = "pytokens-0.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:34bcc734bd2f2d5fe3b34e7b3c0116bfb2397f2d9666139988e7a3eb5f7400e3"}, - {file = "pytokens-0.4.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:941d4343bf27b605e9213b26bfa1c4bf197c9c599a9627eb7305b0defcfe40c1"}, - {file = "pytokens-0.4.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3ad72b851e781478366288743198101e5eb34a414f1d5627cdd585ca3b25f1db"}, - {file = "pytokens-0.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:682fa37ff4d8e95f7df6fe6fe6a431e8ed8e788023c6bcc0f0880a12eab80ad1"}, - {file = "pytokens-0.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:30f51edd9bb7f85c748979384165601d028b84f7bd13fe14d3e065304093916a"}, - {file = "pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de"}, - {file = "pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a"}, -] - -[package.extras] -dev = ["black", "build", "mypy", "pytest", "pytest-cov", "setuptools", "tox", "twine", "wheel"] - [[package]] name = "pyyaml" version = "6.0.3" @@ -2082,4 +1900,4 @@ zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] [metadata] lock-version = "2.1" python-versions = "^3.10" -content-hash = "7b927f9507fbf97f4a8278bef8472a40e534bc9f6babfdf5958cca5a158ba8d4" +content-hash = "c2210bc3ebd928924194ed2d653058b0897c26dcff5aa0d5f08373e6038b6910" diff --git a/pyproject.toml b/pyproject.toml index fb49211..30dc194 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -75,20 +75,17 @@ django-admin-mcp-api = ">=1.1.0,<2.0.0" pytest = "^9.0.3" pytest-django = "^4.8" pytest-cov = "^5.0" +# Single Python lint stack (#651/#652): Ruff owns lint + format + +# import sorting (the `I` rules), mypy owns typing, bandit owns +# security. Black, standalone isort, and flake8 were removed — their +# pycodestyle/pyflakes/isort coverage is subsumed by Ruff's `E/W/F/I` +# selectors, and running them alongside ruff-format produced the +# three-way formatter conflict tracked in #452. ruff = "^0.6" mypy = "^1.10" django-stubs = { version = "^5.0", extras = ["compatible-mypy"] } bandit = "^1.7" pip-audit = "^2.7" -# Dependabot #4: black wrote intermediate cache files with -# unsanitized names — arbitrary-file-write (CVE-2026-32274). The -# advisory's first-patched version is 26.3.1; we pin to that minimum -# so ``poetry lock --update`` cannot resolve back to the vulnerable -# 26.3.0 even though the lockfile is already on 26.5.x. -black = "^26.3.1" -isort = "^5.13" -flake8 = "^7.1" -Flake8-pyproject = "^1.2" pylint = "^3.2" pylint-django = "^2.5" @@ -127,7 +124,7 @@ ignore = [ "examples/**/*.py" = ["S101", "DJ001"] [tool.ruff.lint.isort] -# Match standalone isort: one import per line (repo policy). +# One import per line (repo policy). force-single-line = true known-first-party = ["django_admin_react", "tests"] @@ -151,11 +148,17 @@ filterwarnings = [ ] # --------------------------------------------------------------------------- # -# MyPy — best effort for v1 +# MyPy — strict subset on the package (#655). Full ``strict = true`` would +# also require typing the test suite (gaps tracked in #318); instead we +# enable the high-value strict flags that the ~740-LOC package already +# satisfies, so typing coverage can't regress. ``scripts/lint.sh`` runs +# ``mypy django_admin_react`` as the blocking gate. # --------------------------------------------------------------------------- # [tool.mypy] python_version = "3.10" strict = false +disallow_untyped_defs = true +warn_return_any = true warn_unused_ignores = true warn_redundant_casts = true ignore_missing_imports = true @@ -164,51 +167,9 @@ plugins = ["mypy_django_plugin.main"] [tool.django-stubs] django_settings_module = "tests.test_project.settings" -# --------------------------------------------------------------------------- # -# Black — formatter (the source of truth for line wrapping) -# --------------------------------------------------------------------------- # -[tool.black] -line-length = 100 -target-version = ["py310", "py311", "py312"] -include = "\\.pyi?$" -extend-exclude = "frontend|examples/[^/]+/migrations|tests/test_project/migrations|dist" - -# --------------------------------------------------------------------------- # -# isort — import sorter (one import per line per repo policy) -# --------------------------------------------------------------------------- # -[tool.isort] -profile = "black" -line_length = 100 -force_single_line = true -known_first_party = ["django_admin_react", "tests"] -skip_glob = ["frontend/*", "examples/*/migrations/*", "dist/*"] - -# --------------------------------------------------------------------------- # -# flake8 — additional lint checks beyond ruff / black -# --------------------------------------------------------------------------- # -[tool.flake8] -max-line-length = 100 -extend-ignore = [ - "E203", # whitespace before ':' — conflicts with black - "E501", # line length — already enforced by black - "W503", # line break before binary operator — black style -] -exclude = [ - ".git", - "__pycache__", - "frontend", - "examples/*/migrations", - "tests/test_project/migrations", - "dist", - ".venv", -] -per-file-ignores = [ - "tests/*:S101", -] - # --------------------------------------------------------------------------- # # pylint — used in `--errors-only` mode for additional checks not in -# ruff / black / flake8. Style is owned by ruff + black. +# ruff. Style + formatting + import order are owned by ruff. # --------------------------------------------------------------------------- # [tool.pylint.main] load-plugins = ["pylint_django"] @@ -227,7 +188,7 @@ disable = [ "missing-function-docstring", "too-few-public-methods", "import-outside-toplevel", # we intentionally do lazy imports - "line-too-long", # black owns line length + "line-too-long", # ruff owns line length "fixme", "duplicate-code", ] diff --git a/scripts/lint.sh b/scripts/lint.sh index 1968744..4762cca 100755 --- a/scripts/lint.sh +++ b/scripts/lint.sh @@ -9,10 +9,11 @@ # LINT_FE_ONLY=1 bash scripts/lint.sh # skip Python # # Run this locally before opening / merging a PR — it is the fast -# feedback loop and the authoritative lint gate. CI -# (.github/workflows/ci.yml) runs the test suites (backend `pytest` + the -# frontend gate); adding this script's Python lint gate to CI is a -# follow-up (it must be de-conflicted first — two formatters disagree, #452). +# feedback loop and the authoritative lint gate. The Python lint stack is +# a single chain — Ruff (check + format + import order) + mypy + bandit — +# so it can't conflict with itself (#651/#652 collapsed the old +# ruff/black/isort/flake8 stack). CI (.github/workflows/ci.yml) runs the +# same Python lint gate plus the test suites. set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" @@ -58,15 +59,6 @@ if [[ "$FE_ONLY" != "1" ]]; then step "ruff format --check" poetry run ruff format --check "${PY_TARGETS[@]}" - step "black --check" - poetry run black --check "${PY_TARGETS[@]}" - - step "isort --check-only" - poetry run isort --check-only "${PY_TARGETS[@]}" - - step "flake8" - poetry run flake8 "${PY_TARGETS[@]}" - step "pylint (errors only)" poetry run pylint --errors-only "${PY_TARGETS[@]}" From 26554ec8060c0b4c4a776cb329e868a96d04ae1d Mon Sep 17 00:00:00 2001 From: Martin Castro Laminrs Date: Mon, 1 Jun 2026 20:08:05 +0200 Subject: [PATCH 2/5] refactor: fix stale comments and tighten typing (#654, #655) - Remove the misleading "Real implementation lands in PR #2" note on the shipped _PackageSettings dataclass and a dead `# noqa: ARG002` line in views.py that suppressed nothing (#654). (audit.py dead-code removal landed in the preceding lint-stack commit.) - Type the admin_site view helpers as AdminSite (type-only import, so the package still works with django.contrib.admin removed) and add the missing `from typing import Any` to tests/test_spa_index.py (F821) (#655). - Normalize residual lint debt (import order + slice/assert formatting) across tests and templatetags now that the gate is green (#651). Co-Authored-By: Claude Opus 4.8 (1M context) --- django_admin_react/conf.py | 9 ++++--- .../templatetags/experience_toggle.py | 2 +- django_admin_react/views.py | 25 ++++++++++++------- tests/test_login.py | 4 ++- tests/test_login_next_redirect.py | 7 +++--- tests/test_spa_index.py | 7 +++--- 6 files changed, 33 insertions(+), 21 deletions(-) diff --git a/django_admin_react/conf.py b/django_admin_react/conf.py index 5a123ab..2499af4 100644 --- a/django_admin_react/conf.py +++ b/django_admin_react/conf.py @@ -91,7 +91,7 @@ "REACT_LOGIN": True, # PWA (Issue #86) — all optional; sane defaults make the manifest # work with zero config. See ``django_admin_react/pwa.py`` + - # ``docs/ux/pwa.md``. + # ``ARCHITECTURE.md`` §5.4. # # ``PWA_NAME`` — installed-app name. ``None`` (default) falls # back to the AdminSite ``site_header``, then @@ -152,8 +152,11 @@ class _PackageSettings: """Resolved package settings. - Real implementation lands in PR #2. For now this is a stub so other - modules can import the typed attribute names. + An immutable, frozen record of the merged + ``settings.DJANGO_ADMIN_REACT`` overrides on top of :data:`DEFAULTS`, + built once by :func:`_load` and cached. Each field carries its + default; modules read the typed attribute names off the cached + instance via this module's :func:`__getattr__`. """ ADMIN_SITE: str = DEFAULTS["ADMIN_SITE"] diff --git a/django_admin_react/templatetags/experience_toggle.py b/django_admin_react/templatetags/experience_toggle.py index 53f566c..f90cfd6 100644 --- a/django_admin_react/templatetags/experience_toggle.py +++ b/django_admin_react/templatetags/experience_toggle.py @@ -76,7 +76,7 @@ def experience_toggle_strip(context: dict[str, Any]) -> dict[str, Any]: if not path.startswith(legacy_root): return {"visible": False} - tail = path[len(legacy_root):] + tail = path[len(legacy_root) :] query = request.META.get("QUERY_STRING", "") if hasattr(request, "META") else "" target = react_root + tail + (("?" + query) if query else "") return {"visible": True, "target": target, "react_root": react_root} diff --git a/django_admin_react/views.py b/django_admin_react/views.py index 7168ac6..4a84739 100644 --- a/django_admin_react/views.py +++ b/django_admin_react/views.py @@ -28,6 +28,7 @@ import re from functools import lru_cache from pathlib import Path +from typing import TYPE_CHECKING from typing import Any from typing import cast @@ -47,8 +48,6 @@ from django.utils.http import urlencode from django.views.generic import View -from django_admin_react import conf as dar_conf - # Re-use the API package's helpers — this repo implements no API of its # own (#544), and the staff-gate / admin-site lookup logic is the same # `ModelAdmin`-driven source of truth. The SPA shell view consults them @@ -57,6 +56,15 @@ from django_admin_rest_api.api.permissions import is_admin_user from django_admin_rest_api.api.registry import get_admin_site +from django_admin_react import conf as dar_conf + +if TYPE_CHECKING: + # Type-only import: the package must keep working with + # ``django.contrib.admin`` removed from ``INSTALLED_APPS`` (see + # ``DarStaffAuthenticationForm``), so ``AdminSite`` is imported only + # for annotations and never at runtime. + from django.contrib.admin import AdminSite + # Path the Vite build writes its manifest to (matches # ``frontend/apps/web/vite.config.ts``'s build.outDir + manifest). _STATIC_ROOT: Path = Path(__file__).resolve().parent / "static" / "admin_react" @@ -75,7 +83,6 @@ class SpaIndexView(View): http_method_names = ["get"] def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: - # noqa: ARG002 — args/kwargs only present to satisfy CBV signature. admin_site = get_admin_site() # Default: redirect anonymous / unauthorized users to the HTML # login. When the consumer opts into the React login @@ -196,8 +203,8 @@ class DarLoginView(LoginView): # and redirects back to login) → login → … in an infinite loop. # Leaving it False makes the login page simply render for an # already-authenticated user, breaking the loop. (Proper - # "you need staff access" messaging for that case is ACCEPTANCE - # §2.3 O-5 — a separate SpaIndexView follow-up.) + # "you need staff access" messaging for that case is a separate + # SpaIndexView follow-up.) redirect_authenticated_user = False def get_context_data(self, **kwargs: Any) -> dict[str, Any]: @@ -248,7 +255,7 @@ def _load_manifest_entry() -> dict[str, Any] | None: # --------------------------------------------------------------------------- # # Helpers # # --------------------------------------------------------------------------- # -def _resolve_brand_title(admin_site: Any) -> str: +def _resolve_brand_title(admin_site: AdminSite) -> str: """Compute the SPA brand title. Resolution order: @@ -269,7 +276,7 @@ def _resolve_brand_title(admin_site: Any) -> str: return "Django Admin" -def _resolve_tab_title(admin_site: Any) -> str: +def _resolve_tab_title(admin_site: AdminSite) -> str: """Compute the browser-tab ````. Mirrors Django admin, which uses ``AdminSite.site_title`` for the @@ -293,7 +300,7 @@ def _resolve_tab_title(admin_site: Any) -> str: return "Django Admin" -def _resolve_brand_logo(admin_site: Any) -> str | None: +def _resolve_brand_logo(admin_site: AdminSite) -> str | None: """Compute the SPA logo / favicon URL. Resolution order: @@ -324,7 +331,7 @@ def _resolve_brand_logo(admin_site: Any) -> str | None: _HEX_COLOR_RE = re.compile(r"^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$") -def _resolve_primary_color(admin_site: Any) -> str: +def _resolve_primary_color(admin_site: AdminSite) -> str: """The validated accent color injected as ``--dar-primary``. Resolution order — matches ``BRAND_TITLE`` / ``BRAND_LOGO_URL`` so a diff --git a/tests/test_login.py b/tests/test_login.py index 05f59d6..8bd4de1 100644 --- a/tests/test_login.py +++ b/tests/test_login.py @@ -135,9 +135,11 @@ def test_spa_falls_back_to_package_login_when_admin_off(client: Client) -> None: of the React-rendered login (the post-2026-05-28 default is `REACT_LOGIN=True`, which serves the shell to anon instead — that path is covered in `test_spa_index.py`).""" - import django_admin_react.conf as _conf import importlib + + import django_admin_react.conf as _conf import django_admin_react.views as _views + importlib.reload(_conf) importlib.reload(_views) try: diff --git a/tests/test_login_next_redirect.py b/tests/test_login_next_redirect.py index 4ea6c60..b07d4b1 100644 --- a/tests/test_login_next_redirect.py +++ b/tests/test_login_next_redirect.py @@ -47,9 +47,10 @@ def test_next_param_rejects_external_host(staff_user): parsed = urlparse(target) # An empty netloc means same-host (a path-only redirect like `/admin/`). # Any non-empty netloc must NOT be the attacker's host. - assert parsed.netloc in ("", "testserver"), ( - f"Login redirected to off-host URL {target!r} — open redirect." - ) + assert parsed.netloc in ( + "", + "testserver", + ), f"Login redirected to off-host URL {target!r} — open redirect." def test_next_param_accepts_same_host_path(staff_user): diff --git a/tests/test_spa_index.py b/tests/test_spa_index.py index 10e8781..1c8ce19 100644 --- a/tests/test_spa_index.py +++ b/tests/test_spa_index.py @@ -10,6 +10,7 @@ import importlib import json from pathlib import Path +from typing import Any from unittest import mock from urllib.parse import parse_qs from urllib.parse import urlsplit @@ -330,7 +331,7 @@ def test_reverse_strip_template_has_no_leaking_hash_comments() -> None: ) # The strip itself rendered (sanity check) AND no `{#` / `#}` # made it into the output as literal text. - assert 'Open this page in /admin2/' in out + assert "Open this page in /admin2/" in out assert "{#" not in out assert "#}" not in out @@ -367,9 +368,7 @@ def test_reverse_strip_renders_above_admin_header_not_in_content( # `header` block, not the `content` block. strip_idx = body.index('aria-label="Experience toggle"') header_idx = body.index('id="header"') - assert strip_idx < header_idx, ( - "Strip must render above #header, not inside content" - ) + assert strip_idx < header_idx, "Strip must render above #header, not inside content" finally: _reload_conf() From 169d8fd5b72e432b625ba77b5003a419af9a71b3 Mon Sep 17 00:00:00 2001 From: Martin Castro Laminrs <mcastro@laminr.ai> Date: Mon, 1 Jun 2026 20:08:12 +0200 Subject: [PATCH 3/5] fix: repoint dangling doc references and add a doc-ref guard (#653) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repoint or remove docstring / comment / pre-commit citations to docs that no longer exist (docs/ux/pwa.md, pwa.md, theming.md, ACCEPTANCE.md, REVIEW_CHECKLIST.md, docs/threat-model.md) so they target the surviving ARCHITECTURE.md / SECURITY.md sections. (conf.py's docs/ux/pwa.md cites were fixed alongside the comment cleanup in the preceding commit.) Add tests/test_doc_refs.py plus a pre-commit hook that fails when a *.md file or §N section cited in the package source, tests, or .pre-commit-config.yaml no longer exists, so this defect class can't recur. Also drop the black + isort pre-commit hooks per #651/#652. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- .pre-commit-config.yaml | 45 +++++++-------- django_admin_react/pwa.py | 15 +++-- tests/test_doc_refs.py | 113 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 140 insertions(+), 33 deletions(-) create mode 100644 tests/test_doc_refs.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4b2d28b..093542a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,13 +5,13 @@ # pre-commit install # # Then every `git commit` runs these hooks against the staged diff. -# Anything below the [BLOCK] threshold in -# `docs/agents/security-expert/REVIEW_CHECKLIST.md` §1 aborts the commit. +# The security rules these enforce are documented in `SECURITY.md` §3. # # This file is the commit-time half of the quality gate, run together # with `scripts/lint.sh` before a PR. CI (`.github/workflows/ci.yml`) -# currently runs the test suites, not these hooks; wiring the lint/security -# hooks into CI is a follow-up (#452). +# runs the Python lint gate (ruff check + ruff format --check + mypy + +# bandit) and the test suites; these hooks add the secret-scan + pygrep +# house rules at commit time. repos: # ------------------------------------------------------------------- @@ -24,7 +24,9 @@ repos: args: ["protect", "--staged", "--no-banner", "--redact"] # ------------------------------------------------------------------- - # Python formatting + linting (same tools scripts/lint.sh uses). + # Python lint + format + import order — Ruff is the single source of + # truth (#651/#652). Black, standalone isort, and flake8 were removed; + # ruff-format owns formatting and the `I` rules own import sorting. # ------------------------------------------------------------------- - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.6.9 @@ -35,18 +37,6 @@ repos: - id: ruff-format files: ^(django_admin_react|tests|examples)/ - - repo: https://github.com/psf/black-pre-commit-mirror - rev: 24.8.0 - hooks: - - id: black - files: ^(django_admin_react|tests|examples)/ - - - repo: https://github.com/pycqa/isort - rev: 5.13.2 - hooks: - - id: isort - files: ^(django_admin_react|tests|examples)/ - # ------------------------------------------------------------------- # Python security lint (bandit). Runs only over the package, not tests # (asserts in tests are fine and would otherwise be noisy). @@ -63,7 +53,7 @@ repos: # ------------------------------------------------------------------- # House rules — local hooks that enforce package-specific invariants - # from docs/agents/security-expert/REVIEW_CHECKLIST.md. + # from SECURITY.md §3. # ------------------------------------------------------------------- - repo: local hooks: @@ -71,10 +61,9 @@ repos: # # The `exclude` list covers the small set of files that # legitimately *document* the forbidden patterns (e.g., the - # rule itself in SECURITY.md / ACCEPTANCE.md, the review - # checklist, the security test that scans for them, and forum - # review files that quote the rule when reviewing it). All - # other files in the repo MUST be free of these substrings. + # rule itself in SECURITY.md and the security test that scans for + # them). All other files in the repo MUST be free of these + # substrings. - id: no-partial-tokens name: No partial token redactions (e.g., ghp_…XYZ) language: pygrep @@ -83,10 +72,7 @@ repos: exclude: | (?x)^( SECURITY\.md - |ACCEPTANCE\.md |\.pre-commit-config\.yaml - |docs/threat-model\.md - |docs/agents/security-expert/.* |tests/test_security\.py |scripts/README\.md )$ @@ -118,3 +104,12 @@ repos: language: pygrep entry: "from ['\"]@dar/api['\"]" files: '^frontend/packages/(list|details|models|shell)/' + + # Doc-reference integrity (#653): fail if a docstring/comment cites + # a *.md file or §N section that no longer exists. + - id: doc-ref-guard + name: No dangling *.md / §N doc references + language: system + entry: poetry run pytest tests/test_doc_refs.py -q + pass_filenames: false + files: '\.(py|yaml)$' diff --git a/django_admin_react/pwa.py b/django_admin_react/pwa.py index edb05c3..4d44f26 100644 --- a/django_admin_react/pwa.py +++ b/django_admin_react/pwa.py @@ -1,7 +1,7 @@ """PWA surface: web app manifest + service worker (Issue #86). -Wire/UX contract: ``docs/ux/pwa.md``. The Security lane owns this -surface because its load-bearing properties are security ones: +Frontend build/ship context: ``ARCHITECTURE.md`` §5.4. The Security lane +owns this surface because its load-bearing properties are security ones: - The **manifest** (``<mount>/web.manifest``) is served unauthenticated (the install prompt fires before login) and is computed at request @@ -15,8 +15,7 @@ no-store`` (so the package's no-store API reads are never cached), never caches non-GET requests (mutation safety), and exposes a cache-purge message used on logout so read-cached payloads can't - outlive the session (``pwa.md`` §5 — defense-in-depth atop session - expiry). + outlive the session (defense-in-depth atop session expiry). Both views live **outside** ``api/`` because they're served at the mount root, not under ``api/v1/``, and the manifest is intentionally @@ -33,13 +32,13 @@ from django.shortcuts import render from django.views.generic import View -from django_admin_react import conf as dar_conf - # Re-use the API package's admin-site lookup (this repo implements no # API; the registry helper lives there). The PWA only needs the active # `AdminSite.name` for the manifest's start URL. from django_admin_rest_api.api.registry import get_admin_site +from django_admin_react import conf as dar_conf + # Theme colours keyed by the resolved colour scheme. Kept here (not in # the SPA's CSS-var system) because the manifest is rendered server-side # before any CSS loads; these are the install-banner / splash colours @@ -79,7 +78,7 @@ def _mount(request: HttpRequest, suffix: str) -> str: def _resolved_scheme(request: HttpRequest) -> str: """Resolve light/dark from the ``Sec-CH-Prefers-Color-Scheme`` hint. - Pairs with the theming client-hint path (``theming.md`` §2). Any + Pairs with the theming client-hint path (``ARCHITECTURE.md`` §5.3). Any value other than a case-insensitive ``"dark"`` resolves to light — the safe, neutral default when the hint is absent or unexpected. """ @@ -93,7 +92,7 @@ class ManifestView(View): Unauthenticated by design (the install prompt needs it pre-login). Carries no per-user data; every field is static or mount-/header- derived. ``Cache-Control: no-store`` is **not** set — the manifest - is deliberately cacheable/network-first (``pwa.md`` §2.1). + is deliberately cacheable/network-first. """ http_method_names = ["get"] diff --git a/tests/test_doc_refs.py b/tests/test_doc_refs.py new file mode 100644 index 0000000..e1dfe0d --- /dev/null +++ b/tests/test_doc_refs.py @@ -0,0 +1,113 @@ +"""Referential-integrity guard for documentation citations (#653). + +Docstrings and comments in this package cite docs by filename +(``ARCHITECTURE.md``) and by section (``§4.5``). A doc reorg once left a +trail of dangling citations to deleted files (``docs/ux/pwa.md``, +``theming.md``, ``ACCEPTANCE.md``, …) — this test fails fast when a cite +points at a ``*.md`` file or a ``§N`` section heading that no longer +exists, so the defect class can't recur. + +Scope: the package source, the test suite, and ``.pre-commit-config.yaml`` +(it carries doc citations too). It is intentionally simple and fast — a +regex sweep, no network, no imports of the cited docs. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +_REPO_ROOT = Path(__file__).resolve().parent.parent +_THIS_FILE = Path(__file__).resolve() + +# Files whose comments/docstrings we scan for citations. This guard file is +# excluded from its own scan — it legitimately quotes the (historically +# dangling) doc names it exists to forbid. +_SCANNED_FILES: list[Path] = [ + *sorted((_REPO_ROOT / "django_admin_react").rglob("*.py")), + *(p for p in sorted((_REPO_ROOT / "tests").rglob("*.py")) if p.resolve() != _THIS_FILE), + _REPO_ROOT / ".pre-commit-config.yaml", +] + +# A cited Markdown doc, e.g. ``ARCHITECTURE.md`` or ``docs/ux/pwa.md``. +# Captures an optional path prefix so ``docs/foo.md`` resolves relative to +# the repo root, while a bare ``FOO.md`` may live anywhere in the tree. +_MD_REF_RE = re.compile(r"(?<![\w./-])((?:[\w./-]+/)?[A-Za-z0-9_-]+\.md)\b") + +# A cited section, e.g. ``§4.5`` or ``§3``. The doc it belongs to is the +# nearest preceding ``*.md`` cite on the same line (the repo's convention +# is ``ARCHITECTURE.md §4.5``). +_SECTION_RE = re.compile(r"§\s*([\d]+(?:\.[\dA-Za-z]+)*)") + + +def _iter_lines() -> list[tuple[Path, int, str]]: + out: list[tuple[Path, int, str]] = [] + for path in _SCANNED_FILES: + if not path.is_file(): + continue + for lineno, line in enumerate(path.read_text("utf-8").splitlines(), start=1): + out.append((path, lineno, line)) + return out + + +def _resolve_md(ref: str) -> bool: + """True if a cited ``*.md`` reference resolves to a real file.""" + # Path-qualified (``docs/ux/pwa.md``): resolve from the repo root. + if "/" in ref: + return (_REPO_ROOT / ref).is_file() + # Bare filename (``ARCHITECTURE.md``): match anywhere in the tree, + # skipping vendored / build dirs. + skip = {"node_modules", ".git", "dist", ".venv", "__pycache__"} + for candidate in _REPO_ROOT.rglob(ref): + if not any(part in skip for part in candidate.parts): + return True + return False + + +def _section_exists(doc: Path, section: str) -> bool: + """True if ``doc`` has a heading for ``§section`` (e.g. ``## 4.5``).""" + text = doc.read_text("utf-8") + # Headings look like ``## 4. Backend design`` or ``### 4.5 URL mounting``. + pattern = re.compile(rf"^#{{1,6}}\s+{re.escape(section)}(?:[.\s]|$)", re.MULTILINE) + return bool(pattern.search(text)) + + +def test_no_dangling_md_references() -> None: + """Every cited ``*.md`` file in the scanned sources exists.""" + failures: list[str] = [] + for path, lineno, line in _iter_lines(): + for match in _MD_REF_RE.finditer(line): + ref = match.group(1) + if not _resolve_md(ref): + rel = path.relative_to(_REPO_ROOT) + failures.append(f"{rel}:{lineno} cites missing doc {ref!r}") + assert not failures, "Dangling Markdown references:\n" + "\n".join(failures) + + +def test_no_dangling_section_references() -> None: + """Every ``§N`` cite resolves to a heading in the doc named on its line.""" + failures: list[str] = [] + for path, lineno, line in _iter_lines(): + sections = _SECTION_RE.findall(line) + if not sections: + continue + md_refs = _MD_REF_RE.findall(line) + if not md_refs: + # A §N with no doc named on the same line — can't verify which + # doc it belongs to, so we don't guess. The repo convention + # always names the doc; flag the orphan so it gets fixed. + rel = path.relative_to(_REPO_ROOT) + cited = "/".join("§" + s for s in sections) + failures.append(f"{rel}:{lineno} cites {cited} with no doc on the line") + continue + # The section belongs to the last doc named before it on the line. + doc_ref = md_refs[-1] + doc_path = _REPO_ROOT / doc_ref + if not doc_path.is_file(): + # The missing-file case is already covered by the other test. + continue + for section in sections: + if not _section_exists(doc_path, section): + rel = path.relative_to(_REPO_ROOT) + failures.append(f"{rel}:{lineno} cites {doc_ref} §{section} — no such heading") + assert not failures, "Dangling section references:\n" + "\n".join(failures) From 55467388dd45515f1b9bcdff6bca61f9b8bf3bd0 Mon Sep 17 00:00:00 2001 From: Martin Castro Laminrs <mcastro@laminr.ai> Date: Mon, 1 Jun 2026 20:08:19 +0200 Subject: [PATCH 4/5] chore(frontend): enforce no-explicit-any and tidy JSDoc (#656) - Flip @typescript-eslint/no-explicit-any from 'off' to 'error' to lock in the existing zero-`any` state; no violations surfaced. - Remove the stale .eslintrc.cjs entry from the flat-config ignores. - Add /** JSDoc to the Checkbox, Input, Spinner, EmptyState, and DateHierarchyBar primitives for doc consistency. Verified: pnpm lint:js, pnpm lint:css, pnpm typecheck all pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- frontend/eslint.config.mjs | 10 +++++----- frontend/packages/list/src/DateHierarchyBar.tsx | 11 +++++++---- frontend/packages/ui/src/Checkbox.tsx | 6 ++++++ frontend/packages/ui/src/EmptyState.tsx | 5 +++++ frontend/packages/ui/src/Input.tsx | 5 +++++ frontend/packages/ui/src/Spinner.tsx | 5 +++++ 6 files changed, 33 insertions(+), 9 deletions(-) diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index a330699..7ca1f09 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -21,7 +21,6 @@ export default tseslint.config( '**/node_modules/**', '**/*.config.{js,cjs,mjs,ts}', 'vitest.setup.ts', - '.eslintrc.cjs', ], }, js.configs.recommended, @@ -34,10 +33,11 @@ export default tseslint.config( plugins: { 'react-hooks': reactHooks }, rules: { ...reactHooks.configs.recommended.rules, - // `Any` is used deliberately at wire boundaries (parsed JSON, - // Django-shaped signatures) — mirrors the Python side keeping - // `Any`. Tightening the tractable cases is tracked separately. - '@typescript-eslint/no-explicit-any': 'off', + // The codebase carries zero `any` today; lock that in (#656). A + // genuine wire boundary that needs `any` should add a narrowly + // scoped per-line `eslint-disable-next-line` with a reason rather + // than reopening the rule globally. + '@typescript-eslint/no-explicit-any': 'error', // Align with tsconfig's `noUnusedParameters` underscore convention. '@typescript-eslint/no-unused-vars': [ 'error', diff --git a/frontend/packages/list/src/DateHierarchyBar.tsx b/frontend/packages/list/src/DateHierarchyBar.tsx index 04e1ff2..d3326b4 100644 --- a/frontend/packages/list/src/DateHierarchyBar.tsx +++ b/frontend/packages/list/src/DateHierarchyBar.tsx @@ -20,10 +20,13 @@ export interface DateHierarchyBarProps { onNavigate: (path: { year?: number | null; month?: number | null; day?: number | null }) => void; } -// date_hierarchy drill-down bar (#304 — Django changelist parity). Reads -// `active` for the current drill path (breadcrumb, each crumb navigates -// up) and `buckets` for the next level's options (drill down). The -// backend caps the level by the field; clicking wires ?year/?month/?day. +/** + * `date_hierarchy` drill-down bar (#304 — Django changelist parity). + * Reads `dh.active` for the current drill path (a breadcrumb whose crumbs + * navigate up) and `dh.buckets` for the next level's options (drill down). + * The backend caps the level by the field; clicking calls `onNavigate` + * with the chosen `?year`/`?month`/`?day`. + */ export function DateHierarchyBar({ dh, onNavigate }: DateHierarchyBarProps) { const { active, buckets } = dh; const level: 'year' | 'month' | 'day' | 'done' = diff --git a/frontend/packages/ui/src/Checkbox.tsx b/frontend/packages/ui/src/Checkbox.tsx index ec4672a..a42aec6 100644 --- a/frontend/packages/ui/src/Checkbox.tsx +++ b/frontend/packages/ui/src/Checkbox.tsx @@ -12,6 +12,12 @@ import type { InputHTMLAttributes } from 'react'; export type CheckboxProps = Omit<InputHTMLAttributes<HTMLInputElement>, 'type'>; +/** + * Styled checkbox primitive: an `appearance-none` box that matches the + * themed text inputs (border + transparent surface) with a primary fill + * and inline-SVG tick when checked. Accepts all native checkbox input + * attributes except `type`. + */ export function Checkbox({ className = '', ...rest }: CheckboxProps) { return ( <span className="relative inline-flex h-4 w-4 shrink-0 align-middle"> diff --git a/frontend/packages/ui/src/EmptyState.tsx b/frontend/packages/ui/src/EmptyState.tsx index f02ed5c..77855bc 100644 --- a/frontend/packages/ui/src/EmptyState.tsx +++ b/frontend/packages/ui/src/EmptyState.tsx @@ -6,6 +6,11 @@ export interface EmptyStateProps { action?: ReactNode; } +/** + * Centered placeholder for empty collections: a required `title`, an + * optional `description`, and an optional `action` node (e.g. an "Add" + * button) rendered below. + */ export function EmptyState({ title, description, action }: EmptyStateProps) { return ( <div className="flex flex-col items-center justify-center text-center py-12 px-4"> diff --git a/frontend/packages/ui/src/Input.tsx b/frontend/packages/ui/src/Input.tsx index b711edf..f34afc8 100644 --- a/frontend/packages/ui/src/Input.tsx +++ b/frontend/packages/ui/src/Input.tsx @@ -6,6 +6,11 @@ export interface InputProps extends InputHTMLAttributes<HTMLInputElement> { error?: ReactNode; } +/** + * Text input primitive with an optional label, help text, and error + * message. Generates a stable-per-render `id` when none is supplied so the + * label's `htmlFor` always resolves. Forwards all native input attributes. + */ export function Input({ label, helpText, error, id, className = '', ...rest }: InputProps) { const inputId = id ?? `dar-input-${Math.random().toString(36).slice(2, 8)}`; return ( diff --git a/frontend/packages/ui/src/Spinner.tsx b/frontend/packages/ui/src/Spinner.tsx index 0fc4e61..0cb1d6b 100644 --- a/frontend/packages/ui/src/Spinner.tsx +++ b/frontend/packages/ui/src/Spinner.tsx @@ -9,6 +9,11 @@ const SIZE_CLASSES: Record<NonNullable<SpinnerProps['size']>, string> = { lg: 'h-10 w-10', }; +/** + * Animated loading spinner with an `aria-live` status region. `size` + * selects one of three preset dimensions (default `md`); an optional + * `label` renders beside the spinner and is announced to assistive tech. + */ export function Spinner({ size = 'md', label }: SpinnerProps) { return ( <span role="status" aria-live="polite" className="inline-flex items-center gap-2 text-gray-500"> From bed94dc760592bbdcca80f682332df56679118a6 Mon Sep 17 00:00:00 2001 From: Martin Castro Laminrs <mcastro@laminr.ai> Date: Mon, 1 Jun 2026 20:08:23 +0200 Subject: [PATCH 5/5] docs: add CHANGELOG with [Unreleased] entries for the audit fixes (#651-#656) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- CHANGELOG.md | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c18c027 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,44 @@ +# Changelog + +All notable changes to this project are documented here. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Changed + +- **Python lint stack consolidated onto Ruff (#651, #652).** Removed Black, + standalone isort, and flake8 entirely (their `[tool.*]` config, dev + dependencies, pre-commit hooks, and `scripts/lint.sh` steps). Ruff now owns + lint + format + import order (the `I` rules), with mypy + bandit alongside — + resolving the three-formatter conflict (#452/#452-skew) and the Black 24-vs-26 + pin skew. The now-green Python lint gate (ruff check + ruff format --check + + mypy + bandit) is wired into backend CI. +- **mypy tightened on the package (#655).** Enabled the `disallow_untyped_defs` + and `warn_return_any` strict subset for `django_admin_react`; typed the + `admin_site` view helpers as `AdminSite` (type-only import) instead of `Any`. +- **Frontend `@typescript-eslint/no-explicit-any` promoted from `off` to + `error` (#656)** to lock in the existing zero-`any` state, and added `/**` + JSDoc to the `Checkbox`, `Input`, `Spinner`, `EmptyState`, and + `DateHierarchyBar` primitives. + +### Removed + +- **Dead `django_admin_react/audit.py` module (#654).** It was imported nowhere + and had 0% coverage; the `LogEntry` access it duplicated belongs in the + sibling `django-admin-rest-api`. + +### Fixed + +- **Dangling documentation references (#653).** Repointed or removed docstring / + comment / pre-commit citations to docs that no longer exist (`docs/ux/pwa.md`, + `pwa.md`, `theming.md`, `ACCEPTANCE.md`, `REVIEW_CHECKLIST.md`, + `docs/threat-model.md`) so they target the surviving `ARCHITECTURE.md` / + `SECURITY.md` sections. Added a fast doc-reference guard + (`tests/test_doc_refs.py` + a pre-commit hook) that fails when a `*.md` file + or `§N` section cited in source no longer exists. +- **Stale comments (#654).** Removed the misleading "Real implementation lands + in PR #2" note on the shipped `_PackageSettings` dataclass and a dead + `# noqa: ARG002` line in `views.py` that suppressed nothing.