djust 0.5.6rc1

Pre-releaseSecurityReleased

Before you upgrade, read BREAKING CHANGES below.

BREAKING CHANGES

  • Dropped Python 3.9 support (requires-python = ">=3.10"). Python 3.9 reached end-of-life on 2025-10-05; the ecosystem has since moved on (orjson, pytest, python-dotenv, requests, and mcp have all dropped py3.9 support in versions that carry security fixes). Keeping py3.9 in the requires-python constraint kept 4 Dependabot alerts stuck open against the py3.9 resolution train — alerts which had no upstream patch available on py3.9. Closes Dependabot alerts #41 (orjson recursion DoS), #87 (pytest tmpdir race), #89 (python-dotenv symlink follow in set_key), #62 (requests insecure temp file reuse). Existing py3.9 users can continue installing djust v0.5.x from PyPI; v0.5.6+ requires py3.10+. Also bumped [tool.ruff] target-version to py310 and [tool.mypy] python_version to 3.10; collapsed the orjson / mcp conditional pins (previously carried a py3.9-stuck floor).

Added

  • dj-remove — exit animations before element removal (v0.6.0) — Phoenix JS.hide / phx-remove parity. When a VDOM patch, morph loop, or dj-update prune would physically remove an element carrying dj-remove="...", djust delays the actual removeChild() until the CSS transition the attribute describes has played out (or a 600 ms fallback timer fires, overridable via dj-remove-duration="N"). Two forms: three-token dj-remove="opacity-100 transition-opacity-300 opacity-0" matches the dj-transition shape (start → active → end), and single-token dj-remove="fade-out" applies one class and waits for transitionend. If a subsequent patch strips the dj-remove attribute from a pending element, the pending removal cancels and the element stays mounted. Public hook window.djust.maybeDeferRemoval(node) is called from five removal sites in 12-vdom-patch.js. Descendants of a [dj-remove] element are NOT independently deferred — they travel with their parent, matching Phoenix. New static/djust/src/42-dj-remove.js. 10 JSDOM cases in tests/js/dj_remove.test.js. Phase 2a of the v0.6.0 Animations & transitions work; FLIP / dj-transition-group / skeletons remain separate follow-ups.

  • dj-transition-group — orchestrate enter/leave animations for child lists (v0.6.0) — React <TransitionGroup> / Vue <transition-group> parity. Authors mark a parent container and specify enter + leave specs once; djust wires those specs onto each child by setting dj-transition (enter) and dj-remove (leave) — re-using the already-shipped phase-1 / phase-2a runners (#885 / #898) rather than re-implementing the phase-cycling or removal-deferral machinery. Two forms: short dj-transition-group="fade-in | fade-out" (pipe-separated halves, each accepting the same 1- or 3-token shape as dj-transition / dj-remove), and long form with bare dj-transition-group plus dj-group-enter / dj-group-leave on the parent. Initial children get the leave spec only by default (so they animate out if later removed, but nothing animates in on first paint); opt them into first-paint enter animation via dj-group-appear on the parent. Never overwrites author-specified dj-transition or dj-remove on a child — escape hatch for per-item overrides. A per-parent MutationObserver picks up newly appended children; a document-level observer handles parents that arrive via VDOM patch or attribute mutation. New static/djust/src/43-dj-transition-group.js. 11 JSDOM cases in tests/js/dj_transition_group.test.js cover short-form parsing, invalid input, manual _handleChildAdded, respect for pre-existing per-child attrs, default leave-only initial wiring, dj-group-appear enter opt-in, post-mount append via observer, _uninstall disconnecting the per-parent observer, parent-removal auto-cleanup via the root observer, end-to-end VDOM RemoveChild deferral through the wired dj-remove, and cancel-on-strip uninstalling the per-parent observer when dj-transition-group is removed at runtime (symmetric with dj-remove). Phase 2c of the v0.6.0 Animations & transitions work; FLIP and skeletons remain separate follow-ups. (python/djust/static/djust/src/43-dj-transition-group.js)

Fixed

  • Code-scanning cleanup: remaining ~35 py/cyclic-import notes + 7 misc note-level alerts. Real refactor: extracted ContextProviderMixin from live_view.py to a new _context_provider.py module so components/base.py can import it without creating a module-level cycle back through live_view -> serialization -> components/base. live_view.py re-exports ContextProviderMixin for back-compat (existing user code importing from djust.live_view import ContextProviderMixin keeps working). Closes 3 real cyclic-import alerts (#2112, #2113, #2114). The remaining ~28 theming cyclic-import notes (in manager.py, registry.py, theme_css_generator.py, pack_css_generator.py, theme_packs.py, manifest.py, css_generator.py) are all from ... import statements INSIDE function bodies (or the module-level counterpart paired with such a lazy import) — deliberate cycle breakers where the runtime module graph is acyclic — dismissed with specific justification. Also fixed 3 py/mixed-returns via mechanical cleanup: theming/inspector.py (added 405 Method-Not-Allowed fallback), admin_ext/views.py (replaced bare return with return None in run_action), management/commands/djust_audit.py (explicit return None from all handle() branches). Dismissed 3 py/unused-global-variable false positives (lazy-init cache pattern in components/icons.py:_icon_sets_cache, theming/theme_packs.py:_theme_imports_done, observability/log_handler.py:_installed_handler — same pattern as _psycopg dismissed in #2104/#2105) and 1 py/ineffectual-statement false positive (tutorials/mixin.py:371await coro is a real async effect, not an ineffectual expression). No behavior change; full Python suite passes (3428 passed, 15 skipped). (python/djust/_context_provider.py, python/djust/live_view.py, python/djust/components/base.py, python/djust/theming/inspector.py, python/djust/admin_ext/views.py, python/djust/management/commands/djust_audit.py)

  • Cleanup: 36 py/empty-except + 6 misc CodeQL note-severity alerts — Narrowed over-broad except Exception: pass to specific exception types where the call surface was knowable, and added logger.debug(...) (with import logging; logger = logging.getLogger(__name__) where not already present) for optional-feature probes in components/gallery/views.py (optional djust_theming static CSS link), components/icons.py (optional DJUST_COMPONENTS_ICON_SETS setting), auth/admin_views.py (optional django-allauth OAuth stats, 2 sites), auth/djust_admin.py (optional allauth registry), and mixins/context.py (best-effort descriptor resolution). Annotated "skip invalid numeric input" sites with justification comments (+ passcontinue for clarity) across components/templatetags/_charts.py (4), components/rust_handlers.py (8), components/components/{calendar_heatmap,heatmap,line_chart,source_citation}.py, components/descriptors/carousel.py, components/function_component.py (2), components/mixins/data_table.py (3), components/templatetags/djust_components.py (2), and similar narrow/intentional catches in checks.py, components/base.py (optional @event_handler decoration), mixins/waiters.py (idempotent waiter removal), observability/dry_run.py (best-effort bulk-op count), theming/management/commands/djust_theme.py, and theming/templatetags/theme_tags.py. Re-export in components/templatetags/djust_components.py (_get_field_type, _infer_columns, _queryset_to_rows from _forms) made explicit via __all__ (closes py/unused-import #2171). Deleted 3 JS unused-variable declarations: decoder in components/static/djust_components/ttyd/ttyd_terminal.js:35, resolvedMode in theming/static/djust_theming/js/theme.js:416, and getCookie() in theming/static/djust_theming/js/theme.js:449. Dismissed 2 py/unused-global-variable false positives (#2104, #2105_psycopg / _psycopg_sql in db/notifications.py are lazy module-level caches assigned via global inside _ensure_psycopg(); CodeQL's scope analyzer doesn't track global-write patterns). 4 note-level py/cyclic-import alerts (#2096, #2112-#2114) left for scanner rescan — expected to auto-close as PR #928's refactor propagates. No behavior change; full Python suite passes (3428 passed, 15 skipped).

  • Code-quality cleanup — ~66 CodeQL note-severity alerts — mechanical fixes: deleted unused imports (treated re-exports with __all__ + # noqa: F401 preservation; replaced side-effect submodule imports with importlib.import_module), removed ~30 unused local variables across rust_handlers.py, templatetags/djust_components.py, components/*.py, and templatetags/_forms.py / _advanced.py, removed ~4 unused module-level names (default_app_config in components/__init__.py, theming/__init__.py, admin_ext/__init__.py — obsolete since Django 3.2 auto-discovery), simplified 3 lambda vals: f(vals) wrappers in AGG_FUNCS (pivot-table aggregations) to bare sum / len, deduped 2 import json / import asyncio occurrences in function_component.py / mixins/data_table.py / db/notifications.py, reconciled import X + from X import Y conflicts in gallery/registry.py and templatetags/djust_components.py, and removed ineffectual single-... statements in Protocol / abstract method bodies in api/auth.py and tenants/audit.py. No behavior change; full suite passes (3428). Plus 3 dismissed with justification: 2 × py/catch-base-exception in async_work.py (existing # noqa: BLE001 comments + documented design intent of surfacing every failure via AsyncResult.errored), and 1 × js/syntax-error on theming/templates/.../theme_head.html (CodeQL's JS analyzer erroneously parsing a Django template as JavaScript).

  • Break themes → _base → presets/theme_packs cyclic import (873 CodeQL alerts) + add explicit event.origin check to service worker message handler — CodeQL's py/unsafe-cyclic-import rule flagged 872 alerts across the theming subsystem: themes/_base.py imported dataclasses + shared style instances from ..presets and ..theme_packs, and those two modules re-imported each theme file under .themes.* at module load — a real cycle that happened to work only because ColorScale / ThemeTokens / etc. were defined earlier in presets.py than the theme imports. Extracted the pure data into two new dependency-free modules: python/djust/theming/_types.py (14 dataclass types: ColorScale, ThemeTokens, SurfaceTreatment, ThemePreset, TypographyStyle, LayoutStyle, SurfaceStyle, IconStyle, AnimationStyle, InteractionStyle, DesignSystem, PatternStyle, IllustrationStyle, ThemePack — stdlib imports only) and python/djust/theming/_constants.py (~60 shared style instances — PATTERN_*, ILLUST_*, ICON_*, ANIM_*, INTERACT_* at both the design-system and pack levels; depends only on _types). themes/_base.py now imports from those two modules, bypassing the cycle; presets.py and theme_packs.py import from the same new modules and re-export every type and instance under __all__ for full backward compat (no theme author touches any import site). Also resolved the pre-existing shadow between two InteractionStyle class definitions (the narrow DS-level InteractionStyle at theme_packs.py:150 was silently shadowed by the wider pack-level one at :1374 — all INTERACT_* module-level instances relied on fields only the wider class had; unified on the superset definition in _types.py) and the INTERACT_MINIMAL / INTERACT_PLAYFUL name collision between the DS-level and pack-level bindings (kept the distinct runtime bindings via _INTERACT_MINIMAL_DS / _INTERACT_PLAYFUL_DS). Also tightened the service-worker message handler in python/djust/static/djust/service-worker.js with an explicit event.origin !== self.location.origin early return at the top of the listener, satisfying CodeQL's js/missing-origin-check rule (alert #2170 — follow-up to the source+scope check shipped in #925). 7 regression cases in python/djust/tests/test_theming_imports_backcompat.py cover: presets/theme_packs type exports still importable, shared instance exports still importable, _base re-exports identical object identity to presets / theme_packs, per-theme files (vercel used as smoke) still construct a full triple, lazy theme-pack registry still populates 71 packs + 73 design systems, and the DS-vs-pack InteractionStyle distinction for minimal / playful is preserved (DS link_hover="underline", pack button_click="ripple" — both bindings round-trip). Expected alert closure: 872 × py/unsafe-cyclic-import + 1 × js/missing-origin-check = 873. (python/djust/theming/_types.py, python/djust/theming/_constants.py, python/djust/theming/presets.py, python/djust/theming/theme_packs.py, python/djust/theming/themes/_base.py, python/djust/static/djust/service-worker.js)

  • Dead conditional in djust/theming/templatetags/theme_form_tags.py — the label-visibility check at line 88 had isinstance(field.widget, template.library.InvalidTemplateLibrary if False else type(None)). The if False else type(None) ternary always evaluated to type(None), making the first operand unreachable dead code (CodeQL py/constant-conditional-expression). Dropped the dead branch; the isinstance check is now isinstance(field.widget, type(None)) with a comment explaining the intent.

  • Close 21 py/undefined-export CodeQL alertsdjust/auth/__init__.py and djust/tenants/__init__.py use a __getattr__-based lazy-import dispatcher to defer Django-ORM-dependent imports. CodeQL's static analysis doesn't recognize this pattern; names declared in __all__ but only resolved via __getattr__ were flagged. Added a TYPE_CHECKING block to each __init__.py with eager import statements gated behind if TYPE_CHECKING: — the imports execute only under static analysis (mypy, CodeQL, IDEs), never at runtime. The lazy-import runtime behavior is unchanged. New python/djust/tests/test_lazy_import_resolution.py (47 parameterized cases) regression-tests that every __all__ entry resolves.

  • 3 real bugs caught by CodeQL scanning (6 alerts closed)python/djust/components/gallery/views.py (py/stack-trace-exposure, 2 alerts): the gallery's per-variant render fallback interpolated the raw Exception repr into the HTML returned to the user (f'<div ...>Render error: {exc}</div>'), leaking internal template / class paths and error detail to any gallery viewer. Fixed to log via logger.exception(...) and return a generic Render error — see server logs message at both the type == "tag" template-render path and the type == "class" render-callable path. python/djust/theming/build_themes.py (py/call-to-non-callable, 1 alert): BuildTimeGenerator.__init__ assigned the generate_manifest: bool constructor argument onto self.generate_manifest, which shadowed the method of the same name at def generate_manifest(self, generated_files). Calling self.generate_manifest(generated_files) at line 521 from build_all() would have raised TypeError: 'bool' object is not callable on any invocation of the full build — the method was effectively unreachable. Renamed the attribute to self._generate_manifest (underscore = internal flag), updated the single consumer inside the method to match; the callable is now callable again. python/djust/theming/accessibility.py (py/str-format/missing-named-argument, 3 alerts): AccessibilityValidator.generate_accessibility_report_html passed an HTML+CSS string through str.format(**kwargs) where the embedded literal CSS braces (body { font-family: ... }) were being parsed by Python's format machinery as placeholder keys, raising KeyError / ValueError at runtime on the very first { it hit. Refactored to keep the CSS in a separate un-formatted string (_css_styles) and feed it as a single {styles} placeholder into the HTML template (_html_template); no double-brace escaping hazard, template semantics preserved. 4 regression cases in python/djust/tests/test_codeql_bugfixes.py cover: exception-message not reflected in either gallery render fallback; generate_manifest(True) calls the method (no TypeError); generate_manifest(False) short-circuits to ""; HTML report renders end-to-end with both <!DOCTYPE html> and surviving CSS font-family tokens. (python/djust/components/gallery/views.py, python/djust/theming/build_themes.py, python/djust/theming/accessibility.py)

Security

  • Client-side markdown preview: escape user input before markdown transforms — closes 1 CodeQL js/xss-through-dom alert (#1978, warning)inlineFormat in python/djust/components/static/djust_components/markdown-textarea.js applied regex-based markdown substitutions on raw user input and wrote the result into the preview pane via innerHTML, so a user typing # <script>alert(1)</script> into their textarea saw the raw <script> tag rendered in their own preview. Self-XSS in most deployments, but propagates to other users wherever a textarea's data-raw payload later lands in another user's view (shared drafts, admin review screens, collaborative editors). Fix: call escapeHtml() at the top of inlineFormat (before any regex transform — the markdown syntax chars *, _, `, [, ], (, ) are not in the escape set so the substitutions still match). Added _sanitizeUrl() that rewrites javascript:, data:, and vbscript: URL schemes (case-insensitive, leading-whitespace tolerant) to # in link targets, closing the [click](javascript:alert(1)) attack surface. 11 JSDOM regression cases in tests/js/markdown_textarea_xss.test.js cover <script> / <img onerror> / <b> escaping in headings / paragraphs / lists, preserved **bold** / *italic* / `code` functionality, javascript: / data: / VBScript: URL rewriting, safe https:// and relative URLs preserved, and fenced-code-block escaping still works. (python/djust/components/static/djust_components/markdown-textarea.js)

  • Service worker postMessage same-origin source check — closes 1 CodeQL js/missing-origin-check alert (#2106, warning)python/djust/static/djust/service-worker.js processed any incoming message event without inspecting event.source. Service workers are inherently same-origin (they cannot be loaded cross-origin, so postMessage from a cross-origin page can't reach the SW), but defense-in-depth: a compromised same-origin frame outside the SW scope could still reach the handler. Fix: two-layer gate before touching event.data — (1) reject messages whose event.source is missing or whose event.source.type is not 'window' (rejects worker / sharedworker clients we don't expect), (2) reject WindowClient sources whose url doesn't start with self.registration.scope. 4 new JSDOM regression cases in tests/js/service_worker.test.js (new describe block "message origin check") cover no-source rejection, non-WindowClient rejection, out-of-scope URL rejection, and valid-WindowClient acceptance. Existing 12 SW tests unchanged — the pre-existing harness was updated to back-fill type: 'window' + a scope-valid url on caller-supplied source objects, preserving the exact inputs each test verifies. (python/djust/static/djust/service-worker.js)

  • Open-redirect + path-traversal hardening + dismiss py/clear-text-* CodeQL false-positives (7 alerts closed/dismissed)Real (3 code fixes, closing 4 alerts): python/djust/auth/views.py SignupView.get_success_url accepted any next POST param and passed it straight to redirect(), so a crafted form post could bounce newly-authenticated users to an attacker-controlled host — fixed by validating with Django's url_has_allowed_host_and_scheme() against the current request host (with require_https=self.request.is_secure()); off-site, protocol-relative (//evil.com), and scheme-different values all fall back to settings.LOGIN_REDIRECT_URL. python/djust/admin_ext/views.py:admin_login_required interpolated request.path directly into the login-redirect query string (?next=<path>), letting a path containing & / # / encoded control chars smuggle extra query params into the redirect — fixed with urllib.parse.urlencode({"next": request.path}). python/djust/theming/gallery/storybook.py:get_component_template_source joined an HTTP-accessible component_name URL kwarg into _COMPONENTS_DIR / f"{name}.html" with no validation — fixed with an allowlist regex ^[a-z0-9_-]+$ plus a resolved-path-under-base check so traversal payloads (../../../etc/passwd, ../secret, foo/bar) return "" instead of reading outside the components directory. False-positives (4 dismissed): py/clear-text-storage-sensitive-data + py/clear-text-logging alerts trace taint from MEDICAL_THEME / LEGAL_THEME constant imports in theming/presets.py — CodeQL's healthcare-PII heuristic matches the word "medical" / "legal" as identifiers, but the tainted values are CSS theme names (palette tokens, radii, font stacks), not healthcare or legal data. Dismissed on GitHub with "won't fix" and justification. 5 regression cases in python/djust/tests/test_security_redirects_paths.py cover off-site / same-site / protocol-relative redirect outcomes plus path-traversal rejection and known-valid component name round-trip. (python/djust/auth/views.py, python/djust/admin_ext/views.py, python/djust/theming/gallery/storybook.py)

  • Drop exception messages from API error responses — closes 8-10 CodeQL py/stack-trace-exposure alerts — Stack traces and exception messages can reveal internal file paths, local variable names, DB schema details, and dependency versions, giving attackers a head-start on probing. Three call sites were rewritten to return generic messages and log the full traceback server-side via logger.exception() instead of echoing str(e) / type(e).__name__: {e} back in the JSON response body. python/djust/theming/inspector.py (3 sites at theme_inspector_api GET/POST + theme_css_api) — these endpoints are publicly accessible with no access gating, so this is real prod exposure. python/djust/observability/views.py (4 sites at reset_view_state mount failure, eval_handler invalid-JSON body, eval_handler TypeError, eval_handler catch-all) — DEBUG-gated dev tools, but CodeQL still flags the response content; consistent generic-message pattern closes the alerts and the full trace is still captured in the standard log stream. python/djust/api/dispatch.py:384 — the serialize_error path's str(exc) dropped in favor of the same generic message the sibling "handler_error" / catch-all "serialize_error" branches already use. Added logger = logging.getLogger(__name__) to the two files that lacked one. 3 regression cases in python/djust/tests/test_stack_trace_exposure.py verify the sentinel exception message is not reflected in the response body. Two alerts on python/djust/components/gallery/views.py:726,762 share the reflective-XSS cookie-flow surface cleared by PR #918 and may auto-close on rescan; if they don't, dismiss-with-justification is appropriate (allowlist-validated values, escape() already applied). (python/djust/theming/inspector.py, python/djust/observability/views.py, python/djust/api/dispatch.py)

  • Escape user input in gallery 404 responses & theme option fragments — closes 6 CodeQL py/reflective-xss alerts (error severity) — Three real reflective-XSS sites in python/djust/theming/gallery/views.py (lines 276, 281, 306): storybook_detail_view and storybook_category_view echoed the user-controlled URL kwargs component_name / category into HttpResponseNotFound(f"Unknown ...: {value}") with Content-Type: text/html, so a visitor hitting /storybook/<script>alert(1)</script>/ got the raw payload reflected in the 404 body. Fix: wrap the interpolations with django.utils.html.escape(). Three defense-in-depth sites in python/djust/components/gallery/views.py (lines 677, 726, 762 via _resolve_theme): cookie values (gallery_ds, gallery_preset) flow through an allowlist validator before being interpolated into <option> fragments, so the genuine attack surface is zero — but CodeQL's taint analyzer doesn't recognize the allowlist pattern. Added escape() on the cookie-derived values' HTML interpolation sites; on validated input this is a no-op (allowlist values are plain ASCII identifiers), and it clears the taint flag for the static analyzer. 4 regression cases in python/djust/tests/test_gallery_xss.py cover both the real-XSS 404 body escaping and the allowlist + escape behavior for malicious cookie values. (python/djust/theming/gallery/views.py, python/djust/components/gallery/views.py)

  • Sanitize user-controlled values in log calls — closes 9 CodeQL py/log-injection alerts — Added djust._log_utils.sanitize_for_log(): strips CR/LF/TAB/control chars, replaces with ?, truncates to 200 chars, always returns a string (None / non-string inputs become their repr). Applied at 5 call sites in python/djust/api/dispatch.py (wrapping view_slug, handler_name) and python/djust/theming/gallery/component_registry.py (wrapping component_name, str(exc)) — the sites where HTTP request data flows into logger.exception / logger.debug calls. Format strings unchanged; djust already uses %s-style lazy logging per CLAUDE.md. 8 unit tests in python/djust/tests/test_log_sanitization.py. No behavior change for non-malicious input.

  • Refresh uv.lock to pull in CVE-fix versions for 8 packages — Addresses 23 open Dependabot alerts (13 unique CVEs). Bumps: Django 4.2.29 → 5.2.13 (CVE floor 4.2.30; tightened pyproject.toml ceiling to <6 to keep the major-version jump out of a security-only PR), cryptography 46.0.5 → 46.0.7 (buffer overflow + DNS name constraints), orjson 3.11.5 → 3.11.8 (deep-recursion DoS, floor 3.11.6), requests 2.32.5 → 2.33.1 (insecure temp-file reuse, floor 2.33.0), Pygments 2.19.2 → 2.20.0 (GUID-matching ReDoS), pytest 8.4.2 → 9.0.3 (tmpdir vulnerability), black 25.11.0 → 26.3.1 (arbitrary file writes from unsanitized cache input, dev-only), python-dotenv 1.2.1 → 1.2.2 (symlink following in set_key). Full Python test suite passes (3428 cases); full JS suite passes (1264 cases). No app code or test changes; lockfile + pyproject.toml Django ceiling only. Also catches Cargo.lock up to the v0.5.5rc1 crate versions (stale at 0.5.3rc1 on origin/main).

Changed

  • Drop black dev dependency; ruff format is now the canonical formatter — Pre-commit config has used ruff + ruff-format hooks since v0.5.x; no Makefile / CI / import site references black. Removed black>=24.10.0 / black>=26.3.1 from the dev group in pyproject.toml and the [tool.black] config section. Ruff already has matching line-length = 100 and target-version = "py39". Permanently closes the Dependabot black CVE alert on the Python 3.9 resolution train (black 26.x dropped 3.9 so that alert couldn't be patched; dropping black removes the surface entirely).

All releases · Atom feed