djust 1.0.5

StableReleased
Install
pip install djust==1.0.5

Added

  • New system check djust.V012 — warns when a sticky-child template declares its own dj-view (nested duplicate binding) (#1803). A sticky child is embedded via {% live_render "...Path" sticky=True %}, which makes the framework emit the wrapper element itself — <div dj-view dj-sticky-view="<id>" dj-sticky-root data-djust-embedded="<id>"> (static/djust/src/45-child-view.js). If the child's own template root also carries a dj-view attribute, the rendered page ends up with a nested, duplicate dj-view inside the wrapper — the child's client-side mount breaks and its dj-click / dj-input events silently don't bind. This is a subtle footgun because normal page views require dj-view="<path>" on their root to be browser-mountable, so authors (and code-generating agents) reasonably add it everywhere, including sticky children, where it's wrong. Until now the only safeguard was a comment inside one example template. djust.V012 (LiveView/V category, Warning) walks LiveView subclasses with sticky = True, resolves each one's template source, and scans for a <div ... dj-view ...> root tag — converting the silent footgun into a manage.py check warning. False-positive guards: only sticky = True views are inspected (normal page views, which legitimately declare dj-view, are never flagged); the scan uses an anchored <div ... dj-view ...> opening-tag regex, not a bare substring; {% comment %} / {# #} / <!-- --> regions are stripped first so a dj-view documented inside a comment (e.g. the demo's audio_player.html wrapper-example comment) is ignored; internal djust classes are skipped unless their module is a test/example. Suppress with DJUST_CONFIG = {'suppress_checks': ['V012']}. Documented on the sticky LiveViews guide and the system-checks reference. New regression cases in TestV012StickyChildOwnDjView (tests/unit/test_checks_v012_sticky_own_dj_view.py) cover the positive trigger, the comment-only / non-sticky-page-view / correct-child silent cases, suppression, the #1459 empirical canary (a fresh type()-built sticky child), and the #1468 gate-off self-test. Empirically validated against manage.py check on the demo project (0 false positives; fires on an injected real footgun).

Fixed

  • Flaky test_total_wall_clock_is_max_not_sum made deterministic (#1795). The parallel-lazy-render concurrency test asserted a wall-clock ratio (parallel < serial/2); under full make test -n auto CPU saturation the 3 concurrent thunks couldn't get dedicated cores and the speedup ratio drifted past 0.5, false-failing the release make test (observed parallel=88.1ms vs threshold=85.8ms at the 1.0.5rc5 cut; passed in isolation). It now proves concurrency via deterministic interval overlap — each thunk records its [start, end] and a concurrent render satisfies max(start) < min(end) (all thunks start before any finishes), immune to timing jitter because launching N coroutines takes microseconds regardless of load. A new gate-off sibling, TestParallelRender::test_overlap_proof_rejects_a_serial_loop, pins that a serial loop does NOT overlap, so the proof stays non-tautological. Test-only; no production change.

  • html_recovery no longer resets an embedded sticky child to its mount() defaults — P0 data loss (#1813). On an HTTP-prerendered page embedding {% live_render "Child" sticky=True %}, after a user interacted with the sticky child a failed parent patch triggered html_recovery, which reset the sticky child to mount state and discarded the interactions. Two compounding defects, fixed together: (b1) the structural cure{% live_render sticky=True %} (python/djust/templatetags/live_tags.py) constructed a fresh child_cls() + mount() on every parent render (the two pre-existing escape hatches — _sticky_preserved auto-reattach and session-backed restore_sticky_child_state — are inert in the default config, the latter gated behind enable_state_snapshot=True), so every parent re-render and every _recovery_html snapshot rendered the child at mount defaults; now a live-instance-reuse hatch re-renders the parent's already-registered live child (_get_child_view(sticky_id) from the StickyChildRegistry) instead of mounting fresh, independent of enable_state_snapshot and composing with (not bypassing) the existing hatches. The shared _render_sticky_child_html helper keeps the fresh-mount and reuse paths byte-identical (parallel-path-drift guard, #1646). (b2)(ii) recovery freshnesshandle_request_html (python/djust/websocket.py) now re-renders the parent fresh when it has live sticky children (the embedded-child event branch sends a scoped embedded_update and deliberately does not re-arm recovery, so the cached _recovery_html was stale); re-rendering at recovery time is correct + lowest-overhead (recovery is rare) and faithful only because (b1) makes the re-render reflect the live child — non-sticky pages keep the cached-replay path unchanged. (a) the client-side trigger — the #1610 prerender skipMountHtml morph keys morphChildren by node.id, but the sticky wrapper (<div dj-view dj-sticky-view dj-sticky-root data-djust-embedded=...>) has no id and only aligns positionally, so once a preceding sibling count diverges the server dj-id was never stamped onto the live wrapper and the first parent patch fell back to a positional path that broke on child drift; _stampEmbeddedWrapperDjIds() (static/djust/src/03-websocket.js, bundle rebuilt) now runs after the morph and copies the server dj-id onto each live wrapper matched by its stable data-djust-embedded value (the 45-child-view.js selector). Regression coverage: 5 WebsocketCommunicator end-to-end cases in python/djust/tests/test_sticky_child_recovery_1813.py (default config, gate-off verified for both b1 and b2) and 7 cases in tests/js/ws-mount-prerender-divergence-1813-sticky-djid.test.js (gate-off verified for the dj-id stamp).

  • A worktree git push now runs the pre-push pytest suite against the worktree's Python source, not the main checkout's (#1810). #1796 fixed interpreter resolution from a git worktree, but the editable maturin develop install binds Python imports to the main checkout via a plain djust.pth that appends <main>/python to sys.path — so a git push from a linked worktree ran the pre-push suite against the main tree's source, silently passing/failing on code the worktree never changed (worktree pushes still needed --no-verify, leaving CI as the only correct gate). Root cause confirmed empirically: the .so-less worktree + plain .pth (not an __editable__ meta-path finder) means PYTHONPATH — which Python inserts before .pth processing — wins when it points at the worktree's python/, while a bare worktree import resolves the main tree (a sentinel added to the worktree's __init__.py was invisible without the prepend, visible with it). Fix: a new scripts/run-with-venv-python.sh --worktree-pythonpath mode emits the current worktree's python/ dir to prepend to PYTHONPATH (a no-op — empty output — in the main checkout or outside a git tree) and symlinks the matching compiled _rust.<cache_tag>-*.so from the main checkout into the worktree's python/djust/ so import djust._rust keeps resolving once the Python source is shadowed (the .so is gitignored, so the symlink never appears in git status). The pre-push pytest hook in .pre-commit-config.yaml now prepends this path. Caveat (documented in CONTRIBUTING.md): this shadows only Python source — Rust (djust._rust) changes still need maturin develop run against the worktree; CI remains authoritative. New cases in TestWorktreePythonpath (tests/test_run_with_venv_python.py): worktree path-emit, main-checkout/no-package no-op, the behavior-meaningful PYTHONPATH-shadow precedence test (with a gate-off proving the prepend is load-bearing — without it the main source wins, the exact #1810 bug), the .so symlink, and an entry-line source-pin on the config wiring (gate-off: reverting the entry line fails it).

  • System check djust.T004 no longer flags document.addEventListener for djust events that are dispatched on document, and now honors suppress_checks (#1809). T004 (document.addEventListener('djust:...') → use window) had two defects. (1) False positive that broke correct code: it assumed all djust: events dispatch on window, but djust dispatches a whole family on documentdjust:navigate-start, djust:navigate-end, djust:hvr-applied, djust:layout-changed, djust:ws-reconnected, djust:time-travel-state, djust:time-travel-event (sourced from the client bundle's document.dispatchEvent(new CustomEvent('djust:...')) sites in static/djust/client.js / src/03-websocket.js / 18-navigation.js / 40-dj-layout.js). Listening for those on document is correct, yet T004 flagged them and told the user to switch to window, which would break the listener (it would never fire). (2) Unsuppressible: the emission loop never called _is_check_suppressed, so DJUST_CONFIG = {"suppress_checks": ["T004"]} was a no-op. Fix: _DOC_DJUST_EVENT_RE now captures the event name; a new module constant _DOC_DISPATCHED_DJUST_EVENTS (frozenset, cited to the client.js dispatch sites) is used to skip the document-dispatched family; and the emission loop is gated on _is_check_suppressed("djust.T004") (mirrors T002/C013), so both the ["T004"] and ["djust.T004"] forms now silence it. Window-dispatched events (djust:push_event, djust:before-navigate, djust:error, djust:shell-swapped, djust:vdom-cache-applied, djust:upload:*) still warn — the legitimate purpose of T004 is preserved. New cases in TestT004DocumentDispatchedEvents (navigate-end + every document-dispatched event not flagged; window-dispatched push_event still flagged) and TestT004Suppress (fires without suppression; silenced via both short and qualified IDs) in python/tests/test_checks.py. Gate-off verified: disabling the allowlist makes the document-event tests fail; disabling the suppress guard makes the suppress tests fail. Docs updated in docs/system-checks.md and docs/guides/error-codes.md.

  • Embedded sticky-child ({% live_render "...View" sticky=True %}) events now produce a patch instead of a bare noop (#1802). A sticky / embedded child widget's dj-click (and other) events did nothing in the browser: the event routed to the child's handler correctly and the handler ran, but the consumer returned {"type": "noop"} — no patch/HTML was sent — so the child's DOM never updated. Sticky/app-shell widgets (a headline feature) were effectively render-only / non-interactive; the workaround was to move the handler + state onto the page view. Root cause (traced symptom-up against a real WebsocketCommunicator, confirming the noop): the auto-skip-render block in LiveViewConsumer.handle_event snapshotted public assigns on self.view_instance (the PARENT) both before and after the handler. Embedded-child events route via view_id so target_view is the CHILD; the handler mutates the child, leaving the parent's assigns unchanged → pre_assigns == post_assignsskip_render = True_send_noop fired BEFORE the embedded-child render branch (which builds the scoped embedded_update frame) could run. Fix (Python-only): bind change_target = target_view and take the pre/post assigns + push-command identity snapshots — and read _skip_render/_force_full_html/_pending_push_events, write _changed_keys — against change_target. For a top-level event target_view IS self.view_instance, so the common path is unchanged; for an embedded child the mutation is now detected and the existing embedded_update frame (full child HTML, applied client-side via 45-child-view.js's handleEmbeddedUpdate against [data-djust-embedded]) is sent. The LiveComponent (component_id) path and existing sticky render/redirect/persistence behavior are unaffected. Regression coverage: test_embedded_sticky_child_event_produces_update_not_noop (real WebsocketCommunicator: mounts a parent embedding a sticky=True NotificationsView, fires dismiss with view_id in params, asserts an embedded_update reflecting the mutated state — not noop), a standalone control, and a change_target source pin in python/djust/tests/test_sticky_child_event_noop_1802.py. Gate-off (change_target = self.view_instance) makes the integration test fail with {'type': 'noop'}; tests/integration/test_sticky_redirect_flow.py and test_sticky_http_get_1784.py still pass.

  • App-template dir collector uses is_dir() to match DjustTemplateBackend (#1805). utils._get_template_dirs_cached (the cached APP_DIRS collector used by the shell render) guarded each app's templates path with exists(), while djust's own DjustTemplateBackend._get_template_dirs (template/backend.py) uses is_dir(). The two parallel-path collectors disagreed: with exists(), a plain file literally named templates (no extension) would be wrongly added to the template search dirs. Switched the cached helper to is_dir() so both reject non-directories identically (pre-existing tech-debt surfaced in the #1804/#1801 review; harmless in practice). New cases in TestCollectorIsDirGuard pin that a file named templates is excluded while a real templates/ directory is still collected (gate-off verified against the pre-fix exists() guard). Also documents that the test_resolution_failure_is_logged_not_silent monkeypatch target depends on the from djust._rust import resolve_template_inheritance import staying inside get_template().

  • {% extends %} pages now keep the base template's <head> on the initial HTTP GET (#1801). A LiveView whose template {% extends "base.html" %} served an initial GET containing only the dj-root subtree (no <!doctype>/<html>/<head>/<title>/<style> from the base template), so every template-inheritance page — including the untouched djust new scaffold — rendered unstyled on first paint. Root cause (traced symptom-up against the real scaffold): get_template() collected the Rust resolver's template search directories with a hardcoded backend-name check that recognized only django.template.backends.django.DjangoTemplates. The scaffold (and any project) configuring djust's own backend djust.template.backend.DjustTemplateBackend with APP_DIRS=True had its app-template directories silently dropped, so resolve_template_inheritance raised RuntimeError: Template error: Template not found — which was swallowed by a broad except Exception that logged only at DEBUG and set self._full_template = None. render_full_template then fell through to its else (return self.render(request)) → the bare dj-root fragment with no shell/head. Two-part fix: (1) the APP_DIRS dir-collection now recognizes the djust backend(s) via a shared utils._APP_DIRS_TEMPLATE_BACKENDS set, and get_template() resolves dirs through the single get_template_dirs() helper it already shared with render_full_template step 2 — retiring the parallel-path-drift between the two (#1646): utils._get_template_dirs_cached() (used by the shell render) had the identical hardcoded check, so a point fix in only one place would have left the shell render broken for the same reason. (2) The broad swallow is narrowed to scope only the resolve_template_inheritance call (the legitimate raw-template fallback for genuinely-unresolvable templates) and now logs at WARNING — post-resolution VDOM extraction/strip is moved outside the try so an unexpected framework error surfaces instead of silently degrading to fragment-only. Verified against a fresh djust new demo --no-setup GET: the response now starts with <!DOCTYPE html> and includes the base <head>/<title>/<style>. Regression coverage: test_extends_get_includes_base_head, test_full_template_is_populated_for_extends, test_app_template_dirs_collected_for_djust_backend, and test_resolution_failure_is_logged_not_silent in tests/integration/test_extends_head_initial_get_1801.py drive the real as_view() GET under djust's own backend (the exact config the bug reproduces under); gating the backend-set fix off makes three fail fragment-only and gating the WARNING off makes the logging test fail (silent-catch pin). Non-extends LiveViews and the existing test_sticky_http_get_1784.py / SSR-parity suites are unaffected.

  • Serial-order test pollution that broke test_checks S005 + auto_navigate_meta (#1794). Under the broad serial pytest ordering (pytest python/ / make test-python), three tests failed that pass both in isolation and under the parallel make test -n auto gate (which isolates per-worker and never collects python/djust/tests/): test_checks.py::TestS005UnauthenticatedViews::test_s005_suppressed_with_login_required_false, and test_client_config_tag.py::test_auto_navigate_meta_emitted_when_enabled / ::..._engines_identical. Two independent polluters, neither a settings.DATABASES / LIVEVIEW_CONFIG leak (the reported hypothesis): (1) python/djust/tests/test_ws_auth_close_socket.py defines a module-level LiveView subclass _PublicView with no login_required and exposed state (self.ok), so it permanently joins LiveView.__subclasses__() and the djust.S005 check fired on it ("PublicView" in msg) for any later test asserting the S005 result set — fixed by marking it login_required = False ("intentionally public"), which is also its actual contract; (2) tests/unit/test_ws_compression_config.py::_fresh_config called importlib.reload(djust.config), rebinding djust.config.config to a new singleton while every from djust.config import config consumer (notably djust.templatetags.live_tags) kept the old reference — so the auto_navigate tests reset the new singleton while live_tags read the stale one and never emitted the <meta> — fixed by re-reading settings via config.reset() on the shared singleton (same effect, no orphaning) plus an autouse teardown fixture. Verified with three consecutive clean serial pytest tests/ python/tests/ python/djust/tests/ runs (7692 passed each). Test-only changes; no framework behavior change.

  • Pre-push hook (and make targets) now resolve the project venv from any git worktree (#1796). The native pre-push hook entries in .pre-commit-config.yaml — and ~31 make targets — hardcoded .venv/bin/python relative to the current working directory. A git worktree (e.g. the ones pipeline-drain subagents create under .claude/worktrees/) has no .venv of its own, so the hook failed with bash: .venv/bin/python: No such file or directory (exit 127), forcing git push --no-verify and skipping the real gates. New scripts/run-with-venv-python.sh resolves the interpreter relative to the MAIN working tree root (dirname of the absolute --git-common-dir, which points at <main-root>/.git for both the main checkout and every linked worktree), falling back to uv run python then python3 on PATH when no .venv exists (CI, fresh clone). All 7 hook entries route through it, and the Makefile's hardcoded references collapse to a single $(PYTHON) variable computed once via the resolver — so the pre-push gates and make test both run from any worktree instead of erroring. 6 regression cases in tests/test_run_with_venv_python.py (real git worktree resolution, main-checkout no-regression, python3 fallback, no-interpreter error, plus source-pins on the config and Makefile); the worktree case fails against the pre-#1796 resolver via the gate-off self-test.

  • djust new scaffold is now warning-clean and the deprecated cli.py startproject twin no longer ships broken templates (#1791, follow-up to #1787/#1790). After #1790 fixed the boot blockers, a fresh djust new project passed manage.py check (exit 0) but still emitted five warnings; it now emits zero. Fixed in the canonical scaffolder (python/djust/scaffolding/templates.py + generator.py): C012base.html loads {% load live_tags %} and uses {% djust_client_config %} instead of a manual <script src=".../client.js"> tag (the LiveView post-processing pipeline auto-injects client.js, so a manual tag double-loads); S005 — the in-memory demo view (and the --with-db demo view) declares login_required = False to acknowledge it is an intentionally-public to-do list with no per-user data; Y001/Y003index.html's icon-only toggle/delete buttons get aria-labels and the search/add-item inputs get aria-labels; A030django.contrib.admin is now opt-in (the default in-memory scaffold omits it, eliminating the brute-force-protection warning and the admin-only second DjangoTemplates backend), while --with-db/--from-schema still wire admin + its template backend + the /admin/ URL, where A030 fires by design as correct security guidance. Separately, the deprecated djust startproject command carried its own divergent project templates that still shipped the broken application = live_session() ASGI app and the dropped daphne stack (the same #1787 bug); rather than maintain a second drift-prone template set (parallel-path-drift), cmd_startproject now prints a deprecation notice and delegates to the canonical generate_project(), producing the same warning-clean, uvicorn-booting project. Regression coverage: TestScaffoldWarningClean1791 (tests/integration/test_scaffold_boot_1787.py) asserts a fresh scaffold's manage.py check emits zero WARNINGS and none of djust.C012/S005/Y001/Y003/A030; the rewritten TestStartProjectDeprecated (python/tests/test_cli_scaffold.py) pins the deprecation+delegation contract. Gate-off self-test confirmed the warning-clean assertion is non-tautological.

  • Request + context-processor outputs no longer leak into persisted LiveView state (#1786). A LiveView whose mount() assigned only JSON-serializable scalars still emitted, on every render/event, a flood of serialization warnings naming the request and the standard context-processor outputs (ASGIRequest/WSGIRequest, auth PermWrapper, messages FallbackStorage, SimpleLazyObject/UserLazyObject) — values the view never assigns to self. It also bloated the _prev_context_refs change-detection fingerprint (the dict '_prev_context_refs' has N keys — fingerprint truncated warning) and inflated the state written to the Redis state backend. Root cause: _sync_state_to_rust folds the request + context-processor outputs into the render context via _apply_context_processors; on the first render (and on every event, since those values get a fresh id() each cycle) they flowed through normalize_django_value (one warning per value) and into the _prev_context_refs fingerprint. Fix: _apply_context_processors now records the keys it added on self._context_processor_keys; _sync_state_to_rust excludes those keys (plus request) from the change-detection fingerprint and the set_changed_keys skip set, and skips the non-serializable ones from the update_state/normalize_django_value warning path. The non-serializable values still reach the Rust template via the existing raw-value sidecar (set_raw_py_values), so {{ user }} / {% csrf_token %} keep rendering (the #1779 contract is preserved); genuine user-assigned public state is untouched, so WS-reconnect restore and time-travel snapshots are unaffected. Regression coverage in TestContextProcessorStateLeak1786 (python/tests/test_context_processor_state_leak_1786.py) — asserts zero non-serializable warnings on render, the request-scoped keys are absent from _prev_context_refs and the serialized Rust state, and {{ user }}/{% csrf_token %} still render; fails against the pre-#1786 code.

  • Embedded {% live_render "...View" sticky=True %} now server-renders on the initial HTTP GET (#1784). Any page whose template embedded {% live_render %} returned HTTP 500 on the first load — so sticky / app-shell pages (a headline feature) could not be server-rendered at all. The page shell (including the live_render tag) is rendered through the Rust engine with a JSON-serialized context that structurally cannot carry the live parent LiveView object; the tag looked the parent up via context.get("view")/context.get("self") (both absent) and raised TemplateSyntaxError: {% live_render %} must be called inside a LiveView template; no parent view in the current render context. Fix (no Rust changes): an active-parent-view thread-local + active_parent_view() context manager (save/restore, nesting-safe, always cleared on error) in djust.templatetags.live_tags; both render_full_template and render_with_diff register self as the active parent for the duration of their Rust render (both re-run the tag through the Rust engine on the GET path), and live_render falls back to the thread-local when the render context has no view/self and to the parent's live request when the JSON-serialized request was stringified. The WS / Django-engine paths carry a real view in context, so the fallback is inert for them and existing sticky preservation across live_redirect is unchanged. sticky_demo (the only embedded-{% live_render %} example app, and the only demo app not wired into the demo project) is now wired into demo_project urls + INSTALLED_APPS so the initial-GET server-render path is exercised end-to-end — the gap that let the bug ship unexercised. Regression coverage in tests/integration/test_sticky_http_get_1784.py (test_sticky_live_render_http_get_returns_200, test_sticky_live_render_http_get_includes_child_html, and test_sticky_demo_dashboard_http_get_200) drives the real as_view() GET path for both the inline-template and template-inheritance branches; all three fail against the unfixed render_full_template.

  • djust new now scaffolds a project that actually boots (#1787). The generated asgi.py did application = live_session(), but live_session(prefix, patterns, ...) is a URL-pattern helper (returns List[URLPattern]), not an ASGI app — importing the scaffolded asgi.py raised TypeError: live_session() missing 2 required positional arguments, so make dev crashed immediately. The asgi template now lifts the demo_project/asgi.py pattern: a ProtocolTypeRouter whose "http" is ASGIStaticFilesHandler(get_asgi_application()) (serves client.js/CSS under uvicorn with no WhiteNoise) and whose "websocket" is AllowedHostsOriginValidator(AuthMiddlewareStack(URLRouter([path("ws/live/", LiveViewConsumer.as_asgi())]))), with get_asgi_application() called before the channels/djust imports so the app registry is populated. The dev stack moves off daphne to uvicorn: the Makefile dev target now runs uvicorn <name>.asgi:application --host 127.0.0.1 --port 8000 --reload, requirements.txt drops daphne>=4.0 for uvicorn[standard]>=0.30, and INSTALLED_APPS lists "channels" instead of "daphne". Separately, a freshly-scaffolded project failed manage.py check (which blocks migrate) on two ERRORS: djust.A014 (the scaffold ran in production mode — settings.py read os.environ but never loaded the generated .env, so DEBUG was False and the django-insecure- key was flagged) and admin.E403 (no DjangoTemplates backend for the admin). settings.py now ships a dependency-free .env loader (os.environ.setdefault per KEY=VALUE line, comments/blanks skipped) and the scaffolder writes a working .env (DEBUG=True + a real SECRET_KEY, gitignored) so dev mode is on out of the box; a second DjangoTemplates TEMPLATES backend satisfies the admin. The django-insecure- prefix is retained as the production marker (A014 still fires in real DEBUG=False deploys). manage.py check now exits 0; the remaining warning-level items (C012 manual client.js, S005 unauth view, Y001/Y003 aria) are deferred to a follow-up. Regression coverage in test_scaffold_boot_1787.py (test_scaffold_asgi_imports_and_check_passes asserts the generated asgi.py imports + application is a callable ASGI app, and manage.py check exits 0; fails against the pre-#1787 templates).

  • WebSocket recovery no longer forces a full page reload on the html_update fallback (#1785). When a LiveView event's VDOM diff returns no patches and the server sends a full-HTML html_update frame (the DJE-053 fallback), it now arms on-demand recovery — matching the patches path. Previously the html_update branch in handle_event skipped _arm_recovery, so a client that subsequently requested recovery (e.g. after a VDOM version mismatch on the full-HTML frame) received Recovery HTML unavailable — the server may have restarted and reloaded the whole page instead of morphing. Surfaced by a multi-replica djust.org /insights/ page reloading on every time-range switch. Added a WebsocketCommunicator regression test (TestWSRecoveryHtmlUpdate-style, in test_ws_recovery_html_update_1785.py) plus a source pin.

All releases · Atom feed