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 therequires-pythonconstraint 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 inset_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-versiontopy310and[tool.mypy] python_versionto3.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) — PhoenixJS.hide/phx-removeparity. When a VDOM patch, morph loop, ordj-updateprune would physically remove an element carryingdj-remove="...", djust delays the actualremoveChild()until the CSS transition the attribute describes has played out (or a 600 ms fallback timer fires, overridable viadj-remove-duration="N"). Two forms: three-tokendj-remove="opacity-100 transition-opacity-300 opacity-0"matches thedj-transitionshape (start → active → end), and single-tokendj-remove="fade-out"applies one class and waits fortransitionend. If a subsequent patch strips thedj-removeattribute from a pending element, the pending removal cancels and the element stays mounted. Public hookwindow.djust.maybeDeferRemoval(node)is called from five removal sites in12-vdom-patch.js. Descendants of a[dj-remove]element are NOT independently deferred — they travel with their parent, matching Phoenix. Newstatic/djust/src/42-dj-remove.js. 10 JSDOM cases intests/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 settingdj-transition(enter) anddj-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: shortdj-transition-group="fade-in | fade-out"(pipe-separated halves, each accepting the same 1- or 3-token shape asdj-transition/dj-remove), and long form with baredj-transition-groupplusdj-group-enter/dj-group-leaveon 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 viadj-group-appearon the parent. Never overwrites author-specifieddj-transitionordj-removeon a child — escape hatch for per-item overrides. A per-parentMutationObserverpicks up newly appended children; a document-level observer handles parents that arrive via VDOM patch or attribute mutation. Newstatic/djust/src/43-dj-transition-group.js. 11 JSDOM cases intests/js/dj_transition_group.test.jscover short-form parsing, invalid input, manual_handleChildAdded, respect for pre-existing per-child attrs, default leave-only initial wiring,dj-group-appearenter opt-in, post-mount append via observer,_uninstalldisconnecting the per-parent observer, parent-removal auto-cleanup via the root observer, end-to-end VDOMRemoveChilddeferral through the wireddj-remove, and cancel-on-strip uninstalling the per-parent observer whendj-transition-groupis removed at runtime (symmetric withdj-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-importnotes + 7 misc note-level alerts. Real refactor: extractedContextProviderMixinfromlive_view.pyto a new_context_provider.pymodule socomponents/base.pycan import it without creating a module-level cycle back throughlive_view -> serialization -> components/base.live_view.pyre-exportsContextProviderMixinfor back-compat (existing user code importingfrom djust.live_view import ContextProviderMixinkeeps working). Closes 3 real cyclic-import alerts (#2112, #2113, #2114). The remaining ~28 theming cyclic-import notes (inmanager.py,registry.py,theme_css_generator.py,pack_css_generator.py,theme_packs.py,manifest.py,css_generator.py) are allfrom ... importstatements 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 3py/mixed-returnsvia mechanical cleanup:theming/inspector.py(added 405 Method-Not-Allowed fallback),admin_ext/views.py(replaced barereturnwithreturn Noneinrun_action),management/commands/djust_audit.py(explicitreturn Nonefrom allhandle()branches). Dismissed 3py/unused-global-variablefalse positives (lazy-init cache pattern incomponents/icons.py:_icon_sets_cache,theming/theme_packs.py:_theme_imports_done,observability/log_handler.py:_installed_handler— same pattern as_psycopgdismissed in #2104/#2105) and 1py/ineffectual-statementfalse positive (tutorials/mixin.py:371—await corois 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-broadexcept Exception: passto specific exception types where the call surface was knowable, and addedlogger.debug(...)(withimport logging; logger = logging.getLogger(__name__)where not already present) for optional-feature probes incomponents/gallery/views.py(optionaldjust_themingstatic CSS link),components/icons.py(optionalDJUST_COMPONENTS_ICON_SETSsetting),auth/admin_views.py(optionaldjango-allauthOAuth stats, 2 sites),auth/djust_admin.py(optional allauth registry), andmixins/context.py(best-effort descriptor resolution). Annotated "skip invalid numeric input" sites with justification comments (+pass→continuefor clarity) acrosscomponents/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 inchecks.py,components/base.py(optional@event_handlerdecoration),mixins/waiters.py(idempotent waiter removal),observability/dry_run.py(best-effort bulk-op count),theming/management/commands/djust_theme.py, andtheming/templatetags/theme_tags.py. Re-export incomponents/templatetags/djust_components.py(_get_field_type,_infer_columns,_queryset_to_rowsfrom_forms) made explicit via__all__(closespy/unused-import#2171). Deleted 3 JS unused-variable declarations:decoderincomponents/static/djust_components/ttyd/ttyd_terminal.js:35,resolvedModeintheming/static/djust_theming/js/theme.js:416, andgetCookie()intheming/static/djust_theming/js/theme.js:449. Dismissed 2py/unused-global-variablefalse positives (#2104, #2105 —_psycopg/_psycopg_sqlindb/notifications.pyare lazy module-level caches assigned viaglobalinside_ensure_psycopg(); CodeQL's scope analyzer doesn't track global-write patterns). 4 note-levelpy/cyclic-importalerts (#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: F401preservation; replaced side-effect submodule imports withimportlib.import_module), removed ~30 unused local variables acrossrust_handlers.py,templatetags/djust_components.py,components/*.py, andtemplatetags/_forms.py/_advanced.py, removed ~4 unused module-level names (default_app_configincomponents/__init__.py,theming/__init__.py,admin_ext/__init__.py— obsolete since Django 3.2 auto-discovery), simplified 3lambda vals: f(vals)wrappers inAGG_FUNCS(pivot-table aggregations) to baresum/len, deduped 2import json/import asynciooccurrences infunction_component.py/mixins/data_table.py/db/notifications.py, reconciledimport X+from X import Yconflicts ingallery/registry.pyandtemplatetags/djust_components.py, and removed ineffectual single-...statements in Protocol / abstract method bodies inapi/auth.pyandtenants/audit.py. No behavior change; full suite passes (3428). Plus 3 dismissed with justification: 2 ×py/catch-base-exceptioninasync_work.py(existing# noqa: BLE001comments + documented design intent of surfacing every failure viaAsyncResult.errored), and 1 ×js/syntax-errorontheming/templates/.../theme_head.html(CodeQL's JS analyzer erroneously parsing a Django template as JavaScript).Break
themes → _base → presets/theme_packscyclic import (873 CodeQL alerts) + add explicitevent.origincheck to service workermessagehandler — CodeQL'spy/unsafe-cyclic-importrule flagged 872 alerts across the theming subsystem:themes/_base.pyimported dataclasses + shared style instances from..presetsand..theme_packs, and those two modules re-imported each theme file under.themes.*at module load — a real cycle that happened to work only becauseColorScale/ThemeTokens/ etc. were defined earlier inpresets.pythan 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) andpython/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.pynow imports from those two modules, bypassing the cycle;presets.pyandtheme_packs.pyimport 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 twoInteractionStyleclass definitions (the narrow DS-levelInteractionStyleattheme_packs.py:150was silently shadowed by the wider pack-level one at:1374— allINTERACT_*module-level instances relied on fields only the wider class had; unified on the superset definition in_types.py) and theINTERACT_MINIMAL/INTERACT_PLAYFULname 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-workermessagehandler inpython/djust/static/djust/service-worker.jswith an explicitevent.origin !== self.location.originearly return at the top of the listener, satisfying CodeQL'sjs/missing-origin-checkrule (alert #2170 — follow-up to the source+scope check shipped in #925). 7 regression cases inpython/djust/tests/test_theming_imports_backcompat.pycover: presets/theme_packs type exports still importable, shared instance exports still importable,_basere-exports identical object identity topresets/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-packInteractionStyledistinction forminimal/playfulis preserved (DSlink_hover="underline", packbutton_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 hadisinstance(field.widget, template.library.InvalidTemplateLibrary if False else type(None)). Theif False else type(None)ternary always evaluated totype(None), making the first operand unreachable dead code (CodeQLpy/constant-conditional-expression). Dropped the dead branch; the isinstance check is nowisinstance(field.widget, type(None))with a comment explaining the intent.Close 21
py/undefined-exportCodeQL alerts —djust/auth/__init__.pyanddjust/tenants/__init__.pyuse 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 aTYPE_CHECKINGblock to each__init__.pywith eager import statements gated behindif TYPE_CHECKING:— the imports execute only under static analysis (mypy, CodeQL, IDEs), never at runtime. The lazy-import runtime behavior is unchanged. Newpython/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 rawExceptionrepr 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 vialogger.exception(...)and return a genericRender error — see server logsmessage at both thetype == "tag"template-render path and thetype == "class"render-callable path.python/djust/theming/build_themes.py(py/call-to-non-callable, 1 alert):BuildTimeGenerator.__init__assigned thegenerate_manifest: boolconstructor argument ontoself.generate_manifest, which shadowed the method of the same name atdef generate_manifest(self, generated_files). Callingself.generate_manifest(generated_files)at line 521 frombuild_all()would have raisedTypeError: 'bool' object is not callableon any invocation of the full build — the method was effectively unreachable. Renamed the attribute toself._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_htmlpassed an HTML+CSS string throughstr.format(**kwargs)where the embedded literal CSS braces (body { font-family: ... }) were being parsed by Python's format machinery as placeholder keys, raisingKeyError/ValueErrorat 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 inpython/djust/tests/test_codeql_bugfixes.pycover: 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 CSSfont-familytokens. (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-domalert (#1978, warning) —inlineFormatinpython/djust/components/static/djust_components/markdown-textarea.jsapplied regex-based markdown substitutions on raw user input and wrote the result into the preview pane viainnerHTML, 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'sdata-rawpayload later lands in another user's view (shared drafts, admin review screens, collaborative editors). Fix: callescapeHtml()at the top ofinlineFormat(before any regex transform — the markdown syntax chars*,_,`,[,],(,)are not in the escape set so the substitutions still match). Added_sanitizeUrl()that rewritesjavascript:,data:, andvbscript:URL schemes (case-insensitive, leading-whitespace tolerant) to#in link targets, closing the[click](javascript:alert(1))attack surface. 11 JSDOM regression cases intests/js/markdown_textarea_xss.test.jscover<script>/<img onerror>/<b>escaping in headings / paragraphs / lists, preserved**bold**/*italic*/`code`functionality,javascript:/data:/VBScript:URL rewriting, safehttps://and relative URLs preserved, and fenced-code-block escaping still works. (python/djust/components/static/djust_components/markdown-textarea.js)Service worker
postMessagesame-origin source check — closes 1 CodeQLjs/missing-origin-checkalert (#2106, warning) —python/djust/static/djust/service-worker.jsprocessed any incomingmessageevent without inspectingevent.source. Service workers are inherently same-origin (they cannot be loaded cross-origin, sopostMessagefrom 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 touchingevent.data— (1) reject messages whoseevent.sourceis missing or whoseevent.source.typeis not'window'(rejectsworker/sharedworkerclients we don't expect), (2) reject WindowClient sources whoseurldoesn't start withself.registration.scope. 4 new JSDOM regression cases intests/js/service_worker.test.js(newdescribeblock "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-filltype: 'window'+ a scope-validurlon 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.pySignupView.get_success_urlaccepted anynextPOST param and passed it straight toredirect(), so a crafted form post could bounce newly-authenticated users to an attacker-controlled host — fixed by validating with Django'surl_has_allowed_host_and_scheme()against the current request host (withrequire_https=self.request.is_secure()); off-site, protocol-relative (//evil.com), and scheme-different values all fall back tosettings.LOGIN_REDIRECT_URL.python/djust/admin_ext/views.py:admin_login_requiredinterpolatedrequest.pathdirectly into the login-redirect query string (?next=<path>), letting a path containing&/#/ encoded control chars smuggle extra query params into the redirect — fixed withurllib.parse.urlencode({"next": request.path}).python/djust/theming/gallery/storybook.py:get_component_template_sourcejoined an HTTP-accessiblecomponent_nameURL 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-loggingalerts trace taint fromMEDICAL_THEME/LEGAL_THEMEconstant imports intheming/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 inpython/djust/tests/test_security_redirects_paths.pycover 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-exposurealerts — 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 vialogger.exception()instead of echoingstr(e)/type(e).__name__: {e}back in the JSON response body.python/djust/theming/inspector.py(3 sites attheme_inspector_apiGET/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 atreset_view_statemount failure,eval_handlerinvalid-JSON body,eval_handlerTypeError,eval_handlercatch-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— theserialize_errorpath'sstr(exc)dropped in favor of the same generic message the sibling"handler_error"/ catch-all"serialize_error"branches already use. Addedlogger = logging.getLogger(__name__)to the two files that lacked one. 3 regression cases inpython/djust/tests/test_stack_trace_exposure.pyverify the sentinel exception message is not reflected in the response body. Two alerts onpython/djust/components/gallery/views.py:726,762share 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-xssalerts (error severity) — Three real reflective-XSS sites inpython/djust/theming/gallery/views.py(lines 276, 281, 306):storybook_detail_viewandstorybook_category_viewechoed the user-controlled URL kwargscomponent_name/categoryintoHttpResponseNotFound(f"Unknown ...: {value}")withContent-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 withdjango.utils.html.escape(). Three defense-in-depth sites inpython/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. Addedescape()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 inpython/djust/tests/test_gallery_xss.pycover 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-injectionalerts — Addeddjust._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 theirrepr). Applied at 5 call sites inpython/djust/api/dispatch.py(wrappingview_slug,handler_name) andpython/djust/theming/gallery/component_registry.py(wrappingcomponent_name,str(exc)) — the sites where HTTP request data flows intologger.exception/logger.debugcalls. Format strings unchanged; djust already uses%s-style lazy logging per CLAUDE.md. 8 unit tests inpython/djust/tests/test_log_sanitization.py. No behavior change for non-malicious input.Refresh
uv.lockto 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; tightenedpyproject.tomlceiling to<6to 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 inset_key). Full Python test suite passes (3428 cases); full JS suite passes (1264 cases). No app code or test changes; lockfile +pyproject.tomlDjango ceiling only. Also catchesCargo.lockup to the v0.5.5rc1 crate versions (stale at 0.5.3rc1 on origin/main).
Changed
- Drop
blackdev dependency;ruff formatis now the canonical formatter — Pre-commit config has usedruff+ruff-formathooks since v0.5.x; noMakefile/ CI / import site references black. Removedblack>=24.10.0/black>=26.3.1from thedevgroup inpyproject.tomland the[tool.black]config section. Ruff already has matchingline-length = 100andtarget-version = "py39". Permanently closes the DependabotblackCVE 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).