Before you upgrade, read Deprecated below and the upgrade guide.
Added
LIVEVIEW_CONFIG["http_fallback_background_timeout"]bounds the work the HTTP page-POST fallback runs inside the request (#3634). Since #3630 the fallback runsstart_async/@backgroundwork before it answers, and nothing bounded a task: amount()that starts a watcher held every POST open for as long as it ran. Set the key to a number of seconds (defaultNone, no deadline, and no change in behaviour) and one POST waits for background work for that long in total, across mount's work, the handler's and a page's after a child event. Each task then runs on a worker thread (a sync callback runs on the request thread otherwise, so no timeout could free the request). At the deadline the fallback answers from the state it has: the running task is abandoned, every task still queued is dropped without starting, a warning names them, andhandle_async_resultreceives aTimeoutErroraserror=for each. Anasync defcallback is cancelled; a sync callback cannot be and keeps running, its result discarded (it can still write to the view the answer was rendered from). At most 16 background worker threads (abandoned ones still alive plus ones a POST is waiting for; slots are reserved atomically, so concurrent POSTs cannot overshoot) may exist per process; past that a task is not started (warning,TimeoutErrorforhandle_async_result). Anasync defcallback runs on a private event loop under the key. A value that is not a number from above 0 up tothreading.TIMEOUT_MAXmeans no deadline, and system checkdjust.C028reports it. The guide's hard-limit paragraph is replaced with this contract (docs/website/guides/http-only-mode.md).djust_audit --a11yandmanage.py checkgain Y005 (a chart or<svg>with no accessible name) and Y006 (a legend told apart by colour alone) (#3685). A hand-built chart passed all four existingYrules even when a screen reader got nothing from it. Y005 flags an element withrole="img", or an inline<svg>, that has no non-blankaria-label/aria-labelledby/titleand, for an<svg>, no<title>child (WCAG 1.1.1); it skipsaria-hidden="true",role="presentation"/"none", an<svg>inside a<button>or<a href>(the control owns the name; Y001 covers a control with none), a nested<svg>, hidden sprite sheets, opening tags with{{ }}/{% %}between their attributes, HTML comments, and<script>/<style>bodies. Unlike Y001, an emptyaria-label=""is not a name. Y006 flags a<li>inside an element whoseclasshas alegendtoken (legend,chart-legend,legend__list) that has no text, no<img>with analt, no{{ }}/{% %}and no name attribute (WCAG 1.4.1). Both are warnings, suppressible by id (Y005,Y006), and are found by a one-pass tag scan that stays linear on unterminated and unclosed tags. Not included: the rendered-page axe-core pass from the issue (contrast, heading order, names that exist only after render), which needs a browser and is tracked separately. Documented in the accessibility guide,docs/system-checks.mdand the error-code reference.Behavior change: (a) Y005 and Y006 are registered system checks, so
manage.py check(andcheck --fail-level WARNING) now report them for every project, not onlydjust_audit --a11y. (b) A decorative icon<svg>outside a<button>/<a href>with noaria-hiddenis reported by design; on the demo project all 11 hits were such icons. (c) Mark decorative graphicsaria-hidden="true", name a chart witharia-label/<title>, or silence withDJUST_CONFIG['suppress_checks'] = ['Y005'](orY006).A view can declare its own query budget:
LiveView.query_budget, read byLiveViewSmokeTest(#3686).LiveViewSmokeTesthad onemax_queriesfor every view of an app, so a view that renders in 3 queries could grow tenfold and still pass under the limit a heavy report needs.query_budget = 3on a view caps its mount plus first render together;query_budget = {"mount": 30, "render": 5}caps each phase on its own (a phase left out falls back tomax_queries).test_smoke_queriesnow takes each view's limit fromview_config[View]["max_queries"](new), elseView.query_budget, else the classmax_queries, and reports a view over budget by name with where its limit came from (shop.views.ProductListView: 5 queries (max 3, from query_budget)). A budget the smoke test cannot enforce (a dict key other thanmount/render, abool, a negative or non-int value) fails the test with aValueErrornaming the view rather than being ignored. The default message for a view with no budget is unchanged. Run-time measurement, event budgets and thedjust_auditlisting follow in #3701. Documented in the testing guide, the smoke/fuzz guide and theLiveViewand testing API references.http_status_of_statedecides a page's HTTP status once, from the state it already has (#3688).LiveView.get_http_status()(#3558) answered 200 unless a view overrode it, so a page that draws its own not-found state answered 200 unless that view, and every other view like it, remembered the override. Sethttp_status_of_state = {"not_found": 404, "forbidden": 403, "gone": 410}once, on a base view, and the defaultget_http_status()answers the status mapped to the view'sstate(state_attributenames another attribute). Only a string state is looked up; the map defaults to empty, so a view that declares nothing, and a view that overridesget_http_status(), answer exactly as before. Only the HTTP response has a status (a socket frame has none), and the streaming render answers it too. 13 regression cases inpython/djust/tests/test_http_status_of_state_3688.py. Behavior note:http_status_of_state,state_attributeandparams_formare now framework class attributes, so a view withexposure_policy = "explicit"cannot declare astate()field under those names.params_formdeclares, validates and types a view's URL parameters (#3689).handle_params(params, uri)received the query string as raw strings, so the example in its own docstring (int(params.get("page", 1))) answered 500 on?page=abc, and every view that reads its URL wrote its own parsing, defaults and idea of what a bad value does. Setparams_formto a Django form and djust binds the URL's parameters to it beforehandle_params(), which receivescleaned_data(the form'sinitialvalues fill every field the URL left out or empty, aBooleanFieldand a multi-key widget such asSplitDateTimeFieldincluded). Invalid input calls the newhandle_invalid_params(form, uri)instead; its default handshandle_params()the initial values, and the messages reach the template asparams_errors({field: [messages]}, errors that belong to no field under"__all__"), so?page=abcrenders the default page with the error. Override it to raiseHttp404, or to answer 400 throughget_http_status(). One helper (python/djust/_params_form.py) runs for the HTTP GET, the stateless HTTP fallback POST, the WebSocket and SSE mount, andurl_change(live_patchand back/forward); on the socket paths validation runs on a worker thread inside a guardedhandle_paramssync-turn step, so aModelChoiceFieldcan query. A view withoutparams_formkeeps receiving the raw dict. The guide and thehandle_paramsdocstring now use the form (docs/website/guides/navigation.md). 53 regression cases inpython/djust/tests/test_params_form_3689.py.A view's
query_budgetis measured at run time, accepts event budgets, and is listed bydjust_audit(#3701). Follows #3686, wherequery_budgetwas read byLiveViewSmokeTestonly. Run time: a view that declares aquery_budgethas the queries of each phase counted, on the thread that runs the step (a sync handler ormount()runs on a worker whose database connection a counter on the event loop would never see), and a phase over its number is a warning on thedjust._query_budgetlogger whileDEBUGis on or under pytest; withLIVEVIEW_CONFIG["query_budget"] = "error"the step raisesQueryBudgetExceededinstead ("warn"measures whateverDEBUGsays,"off"never). The meter hooksdjust.sync_turn.run_sync_step, which every sync hop of every transport already goes through, so the HTTP GET, the WebSocket and SSE mount,url_change, events and the test client share one definition. A view with noquery_budgetpays one class attribute read per sync hop and installs nothing on the connection. Event budgets:query_budgettakes"event": n(every@event_handler) and"events": {handler: n}(one handler, which wins), and the newLiveViewSmokeTest.test_smoke_event_queriescalls each budgeted handler with the fuzz payloads plusview_config[View]["event_params"]and fails when a call ran more queries than its budget, whether or notfuzzis on. A handler named in"events"that no call succeeded for is an error rather than a pass, and one covered only by"event"is aUserWarning; a handler name the view does not have, or anasync def/@backgroundhandler (nothing can measure those), is aValueError, as is any other unknown key, so a budget nothing enforces is still rejected. One reader,djust._query_budget.declared_budget, serves the smoke test, the run-time meter, V024 (a new Error check for an invalidquery_budget) anddjust_audit, and a test pins that nothing else reads the attribute; an event is the handler's own queries in the smoke test and at run time alike. An invalidLIVEVIEW_CONFIG["query_budget"]is C029 at startup, and a request reads it as"warn"and logs that once instead of failing. Audit:djust_auditlists each view's budget,(none), orinvalid: <reason>(query_budget_errorin--json, which never crashes over a bad declaration). Not measured:async defand@backgroundhandlers,start_asyncwork andhandle_async_result, ticks, the authorization hops, theon_mounthooks andconnected()/disconnected(), a step nested inside another (an embeddedlive_renderchild'sget_context_data()), and any database but the default. On an HTTP GET a view that overridesget_context_data()has it called twice (measured; framework behaviour, not changed here), and each pass is judged on its own, so arenderor int budget under-reports the page. Documented in the testing guide, the smoke/fuzz guide, thedjust_auditguide and theLiveViewand testing API references.V023 checks
params_form;LiveViewTestClientdriveshandle_params;params_form,http_status_of_stateandstate_attributeare reserved names (#3706). Aparams_formthat is not a form class raisedImproperlyConfigured, and a form whose__init__needs an argument beyonddata=(a user, a queryset, a tenant) raisedTypeError, both on the first request.manage.py checknow reports each as V023 (Error), using the request path's own verdicts (djust._params_form.form_class_problemandconstruction_problem) for every failure visible in the signature; it reads signatures and instantiates nothing, so an__init__that raises, a positional-onlydatathe form'ssuper().__init__rejects, or a lazy object wrapping a form class still fails on the first request.LiveViewTestClient.mount()followedmount()with nothing, so a view declaringparams_formcould not be unit-tested through it: it now callshandle_params(params, uri)right aftermount(), as the HTTP GET and the socket mount do, through the sameparams_formdecision, and takesmount(query_params={...})(a mapping or the query string) for the URL's parameters and a newurl_change(query_params)for alive_patch/ back-forward frame. The three names are also in the framework-internal attribute set: a legacy view that assignedself.state_attribute = ...(orparams_form,http_status_of_state) inmount()published it as reactive state; anexposure_policy = "explicit"view could already not declare astate()field under them, and a test now pins that. Not in this change: aLIVEVIEW_CONFIG["http_status_of_state"]project-wide default (it needs a precedence rule between the view attribute and the config), a V013-style check that depends on it, theHttp404-from-mount()alternative, and a per-fieldhandle_invalid_paramsfallback; #3706 stays open for them. Documented in the navigation guide, the testing API reference,docs/system-checks.mdand the error-code reference.scripts/check_pr_body.pyand a non-gatingpr-body-lintworkflow: lint a PR body for placeholders and dangling references (#3713). It fails on unresolved placeholder tokens (TBD,XXX,<N>,N tests,#NNN), also inside a URL (.../pull/NNN,.../runs/XXXXXXXX,#issuecomment-TBD), and on a#<number>, agithub.com/<this repo>/(issues|pull)/<number>URL, an<this repo>#<number>or anissuecomment-<id>that does not resolve in this repo. It leaves fenced code (including one opened on a list-item line), inline code,<code>/<pre>, HTML comments,#inside URLs, the text of a link (the target is checked, not the label, for inline and reference-style links),owner/repo#Nof another repo, a word or slash glued to the#,List<N>and HTML entities alone; 4-space indented code is not recognised; a line carryingpr-body-lint: ignoreis skipped. The logic is a pure function with the resolver injected; a lookup that could not be made (network, rate limit) isuncheckedand does not fail the run. Its work is bounded: at most 50 distinct references are looked up per body and the rest are reported as onetoo many references to checknote, and every pattern is linear (a 64 KiB body of[or<a>is instant).--annotateprints::warninglines so findings show on the run summary of a job that is green. The workflow (.github/workflows/pr-body-lint.yml) iscontinue-on-error: trueand does not appear intest.yml'stest-summarycondition, so it cannot block a merge until it has run green on the runner once (the rc4 rule); a test parses the YAML and pins the trigger (pull_requestonly), a read-only token, no${{ }}in anyrun:, and the body arriving only through an environment variable. Run over the last 200 merged PR bodies it reports no findings; the first pass over the last 40 found one false positive (a Dependabot body linking another repo's issues as<a href=...>#363</a>), which is why link text is not read as a reference. 61 regression cases intests/unit/test_check_pr_body_3713.py;scripts/check-pr-body-gateoff.json(run withscripts/gate_off.py) drops 48 rules in turn and every drop turns a test red. It does not cover claims about code, which stay with the differential-test habit.docs/patterns/unverified-claim.mdalso records thatgh run view --log-failedand--logshow a truncated view of a long job and to read the job log withgh api --allow-escape-sequences repos/<repo>/actions/jobs/<id>/logs.scripts/gate_off.py: one shared gate-off harness (#3714). State the mutations as data (file, text to find, replacement, label, and the test command that must catch it) in a.jsonor.tomlfile and runpython scripts/gate_off.py <file>to get aCAUGHT/SURVIVED/INVALIDtable. It implements the rules CLAUDE.md already states (the issue records five v1.3.0-15 PRs each re-learning them in a throwaway harness). A mutation text that is not found (or found the wrong number of times), a replacement that changes nothing, a.pytarget that no longer compiles, a smoke command that fails on the mutant, a command that cannot start, tests that fail to collect (pytest's1 error, not1 failed), a hang or runaway during collection (an import-time hang is the same "broke the module" as an import-time raise) and a run in which no test passed are allINVALID, never a number;runner: "exit"reads only the exit status and treats 126/127 asINVALID. Test-time errors are counted with failures. Each run has a deadline and an RSS ceiling over its whole process group and a run-time runaway is reported asCAUGHT(HUNG/RUNAWAY-MEMORY) with the measurement. The file is restored from saved bytes in afinally(also on SIGINT, SIGTERM, SIGHUP andatexit), written to a new inode with a fresh mtime, and verified byte-identical by SHA-256; any restore failure, including anOSError, is aRestoreError(exit 3).__pycache__for each touched file is cleared before and after, and a symlinked target is mutated through the link and left a link. Overlapping runs are refused and a killed run is recovered, by two mechanisms: before anything runs the harness takes an exclusiveflockon the directory of every target (the directory's inode survives the mutation'sos.replace; the lock needs no environment variable or state directory, so a second run under anotherHOMEis still refused with exit 4 and touches nothing; two files of one directory exclude each other, files in different directories do not), and before the first write it journals the original bytes and the mutant's hash under the target's full resolved path in a directory outside the tree ($GATE_OFF_STATE_DIR, else.cache/gate-offin the home the user database gives, not$HOME); a journal left by a SIGKILLed run is restored from before anything is snapshotted (or refused, exit 4, if the file was edited since). Recovery needs the same journal directory: a run killed under one explicit$GATE_OFF_STATE_DIRand followed by a run under another can still adopt the mutant, sogit statusafter a SIGKILL remains the backstop. The harness tolerates only benign conditions (a process group already gone, a temp file already removed, the poll tick); a group that cannot be killed, a.pycthat cannot be deleted or apsthat is unavailable stop the run instead of turning a bound off. A red unmutated baseline stops the run. It cannot judge an equivalent mutation or two shadowing mechanisms; aSURVIVEDrow prints that question. 66 regression cases intests/unit/test_gate_off_3714.pyrun it against a real fixture package (caught, survived, no-op, import-raising, import-hanging, setup-error, endless-loop, runaway-allocation, smoke-failing mutations; a crash, a real SIGINT / SIGTERM, an overlapping second run and a SIGKILL mid-run all end with the file restored or the second run refused). The harness is gate-off'd with itself:scripts/gate-off-selfcheck.jsondrops each rule in turn and every drop turns a test red. CLAUDE.md's three gate-off paragraphs now point at the script.Each pytest process now reports its worst generation-2 GC pause and its longest stall in the terminal summary (#3715).
tests/timing_report.py(registered in the rootconftest.py) records gen-2 pauses fromgc.callbacksand stalls from a 20 Hz daemon ticker thread that notes how late it ran (a thread needs the GIL, so a stop-the-world collection, a native call that holds it, a VM pause or CPU starvation each show up as one late tick), each with the test that was running. Serial runs print one line; under xdist every worker ships its numbers to the controller throughconfig.workeroutputand the controller prints one line per worker, so the next timing failure on CI can be attributed from the log. It changes no outcome and no exit code, prints nothing for--collect-only, and removes its callback and thread when the session ends. Pinned intests/test_timing_report_3715.py, which runs it throughpytester, serially and under-n 2.DJUST_CONFIG["debug_panel"]turns the DEBUG-only debug panel off. DefaultTrue: the panel loads wheneverDEBUGis on, as before. WithFalse, djust keepsclient-dev.js(hot reload, toasts, error overlay) but serves nodebug-panel.js/debug-panel.css, nowindow.DJUST_DEBUG_INFO, no toolbar button or icon, no per-event_debugpayload on WebSocket or HTTP responses, andgenerate_swleavesdebug-panel.jsout of the precache list. One reader,djust.config.debug_panel_enabled(), gates every entry point. Useful when a development Content-Security-Policy blocks the panel's icon and inlinestyle=attributes and fills the console with violations (docs/website/advanced/debug-panel.md).System check
djust.T027: a JSX-style<RustButton … />template tag. The tags are deprecated since 1.3 and removed in 2.0.0, and rendering one emits aDeprecationWarning. The check finds them in templates before the page is requested, as the deprecation policy asks of template-syntax deprecations. New cases inTestT027RustJsxTags.static_render = True: a view that renders once keeps no diff baseline (#3626). For a page that is rendered on the GET and the socket's mount and then not patched (a report, a diagram, a document), djust kept the rendered tree and a copy of every context value in the live view and in every stored snapshot, for a diff that never came. A view that sets the attribute drops both after each render (newRustLiveView.release_baseline(), which, unlikereset(), keeps the version counter, plusretain_state_keys([])), so the state backend stores only the template and the version. Measured on a page rendering a cached ~200 KB diagram of 2,000 nodes: 474,054 bytes stored after the GET (context 204,004, tree 269,862) became 196, and theLarge LiveView statewarning, which measures the stored bytes, no longer fires. Every later render is full and correct instead of a patch: an event that changes state answershtml_updateover the socket (htmlon the HTTP fallback, with consecutive versions, withview_state_in_sessionon or off), an event that changes nothing still answersnoop, work started at mount (start_async) answers the whole page once it finishes, and navigating away and back, a reconnect and a mount after the GET render the page from the view's state. Such a full frame reports the reasonstatic_renderand logs no DJE-053 warning. An embedded or sticky child keeps its own baseline (a child with handlers inside a static page, and a static child inside a normal page, both update as before), and a native (LVN) renderer is never released. Opt-in, default off: a normal view is unchanged. The warning's advice names the attribute, and the Best Practices guide's "Large LiveView state" section documents the trade (a full render and a full-page frame per later render, against memory per open page and per stored snapshot) and when not to use it (pages whose own handlers update them often,temporary_assigns, streams). Stream rows in the snapshot of a view that does patch remain #3632. 29 regression cases inpython/djust/tests/test_static_render_3626.py, among them a value of every shape the change detector compares differently (an immutable, a container, any other object),static_assignson a static view, a bound component's event (it takes the whole page, where the same event on a normal page is the scoped patch), and the diagnostics (no DJE-053, thefull_html_updatesignal's reason isstatic_render), the destination<title>after adj-navigate(a title built from a valueget_context_datacomputed still reaches the tab: the release keeps exactly the values the<title>reads), andtemporary_assigns(the rows are gone after the first event, so the view logs one warning per class naming the combination). A Rust test,release_baseline_drops_the_tree_and_every_cache_built_from_it, pins thatrelease_baselinedrops the tree, the cached HTML, the node cache, the pending changed set and both text indexes.A parity ratchet for djust's own template libraries (#3663, #3669).
make template-library-parityrenders everydjust_componentsandtheme_tagstag that has a native handler through Django's engine and throughDjustTemplateBackend, and compares a per-tag table with the checked-inscripts/template-library-parity-baseline.csv: the class (IDENTICAL,DIFFERENT-escape-policy-only,DIFFERENT,UNEXERCISED), how many variants differ and in which aspects, which variants one engine rejects, and how much of the tag was exercised. Discovery follows each handler into therender_*helper that reads its arguments, every string parameter is called alone with amark_safeand a hostile value, and a child tag is rendered inside an open parent and compared on what it added. The ratchet is exact in both directions: a tag that gets worse fails, and so does one that gets better (with the command that regenerates the baseline). The harness runs in its own process with fixed uuids and clock;python/tests/test_template_library_parity_3663.pycarries its gate-offs andscripts/template-library-parity-gateoff.pymutates the ratchet and the tags to show each turns the test red. Newpr_tiertest marker (tests/slow_tier.py): a test carrying it stays in the PR fast tier however long.test_durationsrecords it.
Changed
X105 also flags CSS named colours and
style=attributes, and a new X106 flags literalfont-familystacks (#3687).djust_audit --astalready flagged hex and colour-function literals in the stylesheets a LiveView template links; three neighbouring cases passed. X105 now also counts a CSS named colour (white,red,black, ...) in a property that can hold a colour (color,background,border*,outline*,box-shadow,fill,stroke, and similar) and as the whole value of a custom property, soanimation-name: orangeandgrid-area: tanare not findings;transparent,currentColor, the CSS-wide keywords and any literal inside avar()fallback stay allowed. It reads thestyle="..."attributes of templates in the live closure the same way (a{{ }}value is unknown, not a literal; a{% %}tag is skipped and its branches are read) and reports one finding per template, with{# djust: noqa X105 #}on the declaration's line to suppress. New X106 flags afont-familythat is not avar(--font-*)theme token, and afontshorthand that names a family, in linked stylesheets (outside@font-face) and instyleattributes;inherit,initial,unsetandrevertare allowed. X106 has its own code (/* djust: noqa X106 */,{# djust: noqa X106 #}), is a warning, and is listed in the UI summary cache (rules,counts) without triggeringdjust.I003.Behavior change: (a)
djust_audit --astreports more X1xx warnings, and X106 is new. (b) Projects that rundjust_audit --ast --strictin CI will start to fail on the named colours,styleattributes and font stacks they already had. (c) Fix the finding with a theme token (hsl(var(--foreground)),var(--font-sans)), suppress one declaration withnoqa X105/noqa X106, or keep the previous scope with--ast-no-ui.LiveViewTestClient.mount()now runshandle_params()(#3706). The client used to callmount()only, so state a view builds inhandle_params()was missing under test while the HTTP GET and the socket mount always had it. A test of a view whosehandle_params()assumes a key the URL does not carry, or raises, now fails the way the page does; the same applies toassert_http_ws_djid_parity()andcreate_test_view(), which mount through the same code. Two snippets in the navigation guide called helpers they never defined fromhandle_params()(fetch_products,_load_tab_data); the doc-snippet harness had never run those hooks, found them once the client did, and the snippets now define them.Test waits on Channels communicators take the 30 s default again; a ratchet keeps explicit short timeouts from regrowing (#3709). An explicit
timeout=3onreceive_json_from/receive_from/receive_output/connect/disconnectoverrides the 30 s default the rootconftest.pyinstalls (#3692), so the wait is a 3 s wall-clock bound again; a stop-the-world GC pause was measured at 3.38 s (#3708). 433 short awaited waits in 156 test files lost theirtimeout=: 404 plain waits, and 29 inside atry(26except: passdrains, mostly of the connect frame, which always arrives; 2except: breakloops that read until a close or error frame the test then asserts; 1 benchmark fallbackexcept: response = {}, which keeps itstry). 34 helper definitions whose shorttimeout=default was forwarded to a wait (_receive_until(..., timeout=3)and its siblings) default to 30 s.python/djust/tests/test_communicator_timeouts_ratchet_3709.pycounts, by AST, an awaited communicator wait with a short numeric timeout, a helper with a short forwarded default, and a call to such a helper with a short literal, per file against a baseline; it exempts only absence idioms (receive_nothing, the last line of apytest.raisesblock). It does not cover everything: waits whosetimeoutis a variable (remaining,deadline),asyncio.wait_for(..., <10)andasyncio.timeout(<10)(not communicator waits), and helpers imported from another module are counted inUNCOVERED_BASELINE(which also only falls) but not converted, so #3709 stays open for them.try/except: passdrains were exempt as absence idioms in this change; four of the six read until a frame that does arrive and were converted, and the ratchet counts drains from then on (#3726).BASELINEentries are on purpose, each with its reason inBASELINE_REASONS(pinned to the same files by a test); for examplepython/djust/tests/test_ws_communicator_defaults_3464.pypins that an explicit timeout still wins over the default, so it passestimeout=0.01.No test bounds a duration with an absolute number of seconds any more, and a ratchet keeps it that way (#3711). The sweep behind #3710 found 31
asserts in 23 test files that compared a measured duration with a constant (the issue named 16 lines). A complexity claim ("linear on tag soup", "bounded") is now judged by growth withdjust.tests._growth.assert_scales_linearlyinpython/djust/tests/_growth.py(four times the input must cost under ten times as much, interleaved, best of three, on this thread's CPU time), and a "did not block" claim by an ordering (a test-owned event, a frame received while another call is still blocked).python/djust/tests/test_no_absolute_time_bounds_3711.pyfinds the shape by AST and lets the per-file count only fall; its baseline is empty. It followselapsed = _took(fn)andself._took(fn),budget_ms = ...,await helper(),datetime.now()/timezone.now()spans,loop.time()however the loop is named, aliased clock imports (from time import perf_counter as pc,import time as t,from time import time),assertLess/assertLessEqual/assertGreater/assertTrue,if elapsed > N: raise/pytest.fail,assert not elapsed > N, module-level asserts and the walrus, with a positive test per variant. Three of the sites were not testing what they claimed.test_save_block_timeout_logs_and_continues_end_to_endbounded the round trip at 2 s although the slow body it stood in for costs 0.25 s, so the bound could not see the wrapper's 150 ms timeout go; and two growth checks fail on real behaviour and arexfail(strict=True)with the measurement, not fixed here:_blank_template_commentsis quadratic on{% comment %}openers (#3723,python/djust/tests/test_template_checks_skip_comments_3283.py) andextract_template_variablesgrows about 6-8x per doubling of the template's blocks, roughly n^2.9 (#3724,python/tests/test_template_variable_extraction.py).Test files no longer end a wait for a frame that arrives with a 2-3 s window; the #3709 ratchet counts those waits per file (#3726).
test_live_auth_outside_declarations.py,test_live_auth_event_holders_and_user_checks.py,test_model_form_route_change_3125.pyandtest_mount_object_permission_after_params.pyread frames until amount, a close or a navigation answer that DOES arrive in the positive case, withreceive_output(timeout=2)/receive_json_from(timeout=3)inside atry: ... except: pass, and then asserted it had arrived. A runner that stalled past that window failed 68 tests (a 3.5 s delay on every outgoing frame reproduces it). They now wait withdjust.tests._ws_frames.receive_until(a 10 s deadline of its own, not the 30 s default) and return the decoded frames.test_security_view_import_failclosed.pyandtest_sync_turn_wrapper_more_hops_3637.pywait for theerrorframe that refuses the mount before they assert on the absence of amountor on the state (the 3637 one asserted "no mount" against[]under a stall);test_recovery_version_staleness_1817.pypasses 30 instead of 2 / 3 to its_recv; andtest_anonymous_event_after_refused_mount_does_not_runnow sends its event whatever the mount answered (it never did).test_communicator_timeouts_ratchet_3709.pyno longer exempts atrywhose handlers onlypass/break/continue, and counts per file a communicator wait whose timeout reads as a number under 10 s: a literal, arithmetic on literals, a module constant,**{"timeout": 2}, in a keyword or a positional slot, wrapped inasyncio.wait_for/asyncio.timeoutor not, and a call to a forwarding helper with a short keyword or positional timeout. What it cannot read as a number (a parameter, a call) and bounds on things that are not frame waits stay inUNCOVERED_BASELINEas totals that cannot name the file. Genuine absence windows sit inBASELINEwith a reason inBASELINE_REASONS; the last line of apytest.raisesblock is still exempt. #3709 stays open for the variable-timeout andwait_forclasses.The
dj-paramsdeprecation warning now shows a typed replacement. Its example isdj-value-todo-id:int="{{ todo.id }}"rather than an untypeddj-value-*attribute, becausedj-paramscarried JSON types and a plaindj-value-*value arrives as a string.client.jsandclient.min.jsare regenerated.Packaging: daphne is now an optional extra,
djust[daphne]— djust's base dependency is plainchannels>=4.0.0,<5instead ofchannels[daphne], so a barepip install djustno longer installs daphne or, throughdaphne -> twisted[tls], Twisted (or autobahn, pyOpenSSL and service-identity). Apps served by uvicorn or hypercorn, which never import either, stop carrying Twisted's advisories in their dependency audits; eight of them, most recently GHSA-vpp8-g58w-m8mp (HTTP/2 rapid reset), had to be ignored downstream. Migration: an app that serves with daphne, or lists"daphne"inINSTALLED_APPSfor itsrunserveroverride, must now installdjust[daphne](or depend ondaphnedirectly); without it, Django fails at startup withModuleNotFoundError: No module named 'daphne'. A test suite that importschannels.testing(for exampleWebsocketCommunicator) needs it too, because Channels' own test package importsdaphne.testing; djust'sLiveViewTestClientdoes not.djust newprojects already use uvicorn and plainchannelsand need no change.djust[all]and thedevextra include daphne.djust_doctor's no-ASGI-server hint now namespip install 'djust[daphne]', and the installation, system-check and error-code docs say how to get daphne.tests/test_base_dependencies_no_daphne.pyfails if daphne or Twisted becomes a base dependency again, directly inpyproject.toml, transitively throughuv.lock, or in the installed package metadata.
Fixed
ManualClock.sleepno longer loses a wake-up whenadvanceruns before the sleeper's first step (#3451).RoomClock._waitturns its absolute deadline intosecondsand passesManualClock.sleep(seconds)toasyncio.create_task; the oldasync def sleepcomputedself.value + secondswhen the task first ran, so anadvance()that ran in between (a task already ready ahead of the sleeper in the loop's queue) left the sleeper registeredsecondspast the time it was asked to wake at, and nothing woke it.sleep()now fixes its deadline when it is called, and a sleeper that is already due at its first step returns at once. With that window widened by an injected delay,test_presence_timeout_retries_after_retirement_without_another_joinfails after 30 s with no tick and the log signature of the CI failures (Clock presence-retry started, thenstopped (graceful)with nothing between), and passes with the fix. The natural interleaving was not observed, so that this was the cause of the CI failures is the best-supported explanation, not a proven one; the failing run'sdjust timing report (#3715)showed no stall longer than 1.3 s on the failing worker, which rules out a 30 s runner stall for that sighting. Customtime_sourceobjects should measuresleepfrom the call, as documented in the shared-room clock guide. New cases:test_manual_sleep_deadline_is_fixed_when_sleep_is_called,test_manual_sleep_already_due_at_its_first_step_needs_no_settleandtest_clock_ticks_when_time_moves_between_its_wait_and_the_sleeper_startintest_shared_room_clock_3005.py.- A release run blocked by the pre-release security audit now raises an alert —
release.ymlgains anaudit-blocked-alertjob that opens onerelease-labelled issue per tag (or comments on it when the run is retried) naming the tag, the failing audit job(s) and the run URL, so a blocked tag no longer sits unpublished unnoticed (#3482). - The stateless HTTP fallback POST now runs
handle_params()andstart_asyncwork (#3624). Withview_state_in_session: False(and whenever the fallback rebuilds the view withmount()), the POST calledmount()but neverhandle_params(params, uri), so a page that read its URL there met the event with its defaults and answered with a diff that removed the rows the user was acting on; it also discardedstart_async/@backgroundwork, so a page that loads after its first render kept its loading state on every POST. The fallback now callshandle_params()aftermount()with the POST's query string (one shared helper with the GET), and runs queued background work inside the request, before the answer is rendered, through the dispatch the socket and SSE share. The one-off "background work does not run" warning is gone. Refs #3503: itspush_event/set_layout/defer()gaps were already closed by #3541; new tests pin that every queue a view can fill reaches the HTTP answer. sync_turn_wrappernow covershandle_tick()andhandle_async_result(), and the test client (#3625). An app that opens a per-turn database scope in the wrapper (row-level security, a request-scoped context variable) had it in every step but these two: the tick and the hook that receives astart_asyncresult ran on the worker thread with no scope, so rows read there came back empty or wrong.handle_tickis now the"tick"step (stock andworker_threadsshapes) andhandle_async_resultan"async"step on every owner: the consumer, the runtime's explicit and legacy result turns, and embedded and sticky children.LiveViewTestClient.send_event(),send_push(),trigger_info()andrender_async()(callback andhandle_async_result) andLiveComponentTestClient.send_event()enter the same steps as the consumer, so a test of a handler no longer runs without the scope production gives it; anasync defpush or info hook is now run to completion (on a helper thread inside a running event loop) rather than left as an unawaited coroutine, and anasync defhandle_async_resultor default-pathhandle_tickis refused with the sameTypeErrorwith or without a wrapper. A structural test pins the set of places that reference the two hooks and that each enters the step through the shared helper. The sync hooks that are still not wrapped (object-permission checks,on_mount,connected/disconnected, cursor and presence hooks, time-travel replay) are listed in the turn middleware guide and tracked in #3637. New cases inpython/djust/tests/test_sync_turn_wrapper_tick_async_3625.py.ThemeMixinno longer puts about 80 KB of theme HTML in every view's state, and theLarge LiveView statewarning now says what it measured (#3626).ThemeMixin._setup_theme_context()assignedtheme_head,theme_css,theme_switcherandtheme_presetsas public attributes, so each was serialised into the stored Rust snapshot and the session save, and the private_theme_css_raw(the push-event CSS, ~18 KB) into the private-state save, even on a page that only prints the{% theme_head %}tag. They are now supplied to the template lazily, beside the context processors' output (ThemeMixin._apply_context_processors), andview.theme_headand the other three still work in Python as class attributes that render on each read and are never stored. A trivial one-counterThemeMixinview stored 79,862 bytes after a GET and now stores 584; the rendered page is byte-identical on first render, and the next render after a theme switch re-sends the four values (_djust_forced_changed_keys, consumed once byrust_bridge). Behaviour notes: the mixin's values still win over atheme_contextprocessor's, as the attributes did;theme_contextis not needed for{{ theme_head }}on aThemeMixinview; an assignment in a subclass (self.theme_head = ...) still takes precedence, and, as any assignment, makes it state; a restore skips the four strings a session written by an older release holds (_state_restore.class_member_kindtreats a_djust_framework_derivedattribute like a property). Breaking, for code that reached the old attributes through the context:get_context_data()no longer returnstheme_head,theme_css,theme_switcherortheme_presets(readself.theme_head; templates are unaffected), and a view with norequestrenders them empty.theme_presets_countis still supplied. A view that skippedmount()(reconnect restore, restored sticky child) gets the values withouttheme_context, from its request.theme_presetsis aRequestScopedListon these views, a reallistthe framework does not store (#3631; first shipped here as a lazy non-list object, which had nojson.dumpsform). The same warning now prints the snapshot's three parts (context, rendered tree, template) with the largest context keys, and its advice matches what was measured: render fewer and leaner rows;temporary_assignsempties the context copy after the next render but not the tree; streams andget_render_only_context()do not shrink the snapshot. New cases inTestTheViewStateOmitsTheHeavyChunks,TestTheNamesStillWork,TestTheHttpPostFallback,TestTheWebSocketPath(test_theme_mixin_state_weight_3626.py), inTestAViewThatSkippedMount,TestTheDerivedNames,TestAThemeSwitch,TestTheLeftovers(test_theme_mixin_restore_and_switch_3626.py) and inTestDescribeSnapshot,TestTheAdviceMatchesWhatReducesTheNumber,TestTheWarningItself(test_large_state_warning_3626.py). Refs #3626: the context processor's owntheme_presetslist (~11.7 KB in any view's snapshot) is #3631, and the stored tree/stream rows are #3632.- The
theme_contextprocessor'stheme_presets(about 12 KB) is no longer copied into every LiveView's stored state (#3631). djust copies each JSON-serialisable context-processor value into the view's Rust state, so any app that installeddjust.theming.context_processors.theme_contextstored the preset list in the snapshot of every view, on the HTTP GET and on every WebSocket event, whether or not a template read it. A one-counter view stored 12,208 bytes after a GET and now stores 510. The value is aRequestScopedList: a reallist(isinstance(..., list),json.dumps,{{ theme_presets|json_script:"p" }},len, indexing,inand{% for %}behave as before, in both template engines) that the bridge sends to the renderer through the raw-value sidecar instead of storing, and drops from a snapshot an earlier build stored.ThemeMixinsupplies the same type, which also restoresjson.dumpsandisinstance(..., list)on itstheme_presets(theLazyThemeListit used since #3626, which had no JSON form, is gone). A processor of your own opts a large request-derived list out of the stored state withdjust.request_scoped.RequestScopedList(items); a plain list is stored as before. Behaviour note:type(ctx["theme_presets"]) is listis nowFalse, andyaml.safe_dumpof it raisesRepresenterError(convert withlist(...); seeupgrade-to-1.3.md). The type is public:from djust.request_scoped import RequestScopedList. New cases intest_theme_context_state_weight_3631.py(TestTheStoredSnapshotOmitsThePresets,TestNothingBreaksForReaders,TestTheHttpPostFallback,TestAStaleStateEntryIsShed,TestTheWebSocketPath). Refs #3626. - The
Large LiveView statewarning now also fires when a view grows past the threshold after it was created (#3632, part 3).InMemoryStateBackend.set()was the only caller of the warning, and the view calls it once at creation (empty state) and again when a stored snapshot is re-used, so a view that grew large during its life was not reported until the next reload.refresh(), the post-render store, now reports it too: once per cache key while it stays over the threshold (not once per event), again if it drops back under and grows past it again, and with the same breakdown. In-memory backend only (Redis has no large-state warning). The warning, fromset()as well, now names the state by a short hash (state-1a2b3c4d) instead of the cache key, which holds the Django session key and the request path (the older ERROR lines of the in-memory and Redis backends, for a corrupt snapshot or a failed serialisation, name the same label; they used to print the raw key);refresh(..., warn_on_large_state=False)is honoured, and a warning that a-W errorfilter turns into an exception is logged, not raised, inside the render path.refresh()already serialises the view on every render, so the check reuses that snapshot (len()and a set lookup); the breakdown is decoded only on the render that crosses the line. Stream rows in the stored state and stream/temporary-assign rows in the stored tree (parts 1 and 2) are not changed: clearing stream items after each render removes every row from the page on the next event (measured;test_stream_state_weight_3632.py), so those remain open in #3632. New cases inTestRefreshWarns,TestRefreshCost,TestTheRealConsumer(test_large_state_refresh_warning_3632.py). Refs #3632. - An HTTP-only page on the default session policy leaves its loading state on the first POST (#3635). A page that loads after its first render (
start_async/assign_asyncqueued inmount()orhandle_params()) and is served to a client that never opens a socket or SSE stream stayed on its loading skeleton for good: the GET cannot run the queued work, saved the loading state in the session, and every page-POST fallback restored it without runningmount()'s work. The GET now records a digest of the loading state it saved (<view key>__mount_work) when work is still owed. The first POST that finds the saved state unchanged runsmount()again, puts the saved state back on top and runs the work over it before the handler, after every refusal; the loaded state is then saved and the work is not repeated. State another writer saved in between invalidates the digest, so nothing is run twice.mount()therefore runs twice for such a page, as it does between a GET and a socket mount. A view whosemount()queued nothing records nothing. sync_turn_wrappernow covers the authorization checks,on_mounthooks,connected()/disconnected(), the cursor and presence hooks and time-travel replay (#3637). These sync view hooks ran on the worker thread through a baresync_to_asyncwith no wrapper entry, so an app that opens a per-turn database scope in the wrapper (row-level security) had none of it there; the worst case wasget_object(), which read no rows, so the object-permission check failed closed as "Access denied" for a caller who was allowed, on the socket only (the HTTP fallback was fine). Two new steps joinSYNC_STEPS:"auth"(check_permissions(), Django's access mixins and replayed decorators, handlerpermission_required, andget_object()+has_object_permission()) and"lifecycle"(theon_mountchain,connected(),disconnected());handle_cursor_move(),update_presence_heartbeat()and theget_presence_key()/untrack_presence()reads are"hook"steps, a time-travel replay runs its handler as an"event"step, and the remaining rawget_context_data()/get_template()/ component-render hops (streamed fragments, sticky-child saves, scoped component render, actor mount, hot reload) are"render"steps. The steps are entered at the framework function that calls the hook (check_object_permission,check_view_auth,run_on_mount_hooks, ...), so every transport and every caller (mount, event,url_change, embedded and sticky children, HTTP, SSE, API) is covered by one change. Authorization steps fail closed: a wrapper that raises refuses the check, a wrapper that swallows an exception from the check or anon_mounthook can no longer turn it into a pass (it raisesPermissionDenied), and the object-permission cache is written only after the step exits cleanly._should_restore_snapshot(),_restore_snapshot()and_capture_snapshot_state()(documented subclass overrides) are"lifecycle"steps, and anasync defhook is handed back unwrapped by the step helpers instead of being hidden behind a sync shim (it would otherwise never run once a wrapper was configured). A wrapper that raises on a step name it does not know (else: raise ValueError(step)) fails every mount after this change: make it ignore unknown steps (example in the turn middleware guide and the 1.3 upgrade guide). An allowlist pin fails when any callable handed tosync_to_async/asyncio.to_thread/run_in_executoris neither stepped, a verified leaf, nor on a reasoned framework-internal list, and when a documented override point has no recorded disposition. New cases inpython/djust/tests/test_sync_turn_wrapper_more_hops_3637.py.- A handler whose only effect is a stream call now reaches the page, and
temporary_assignsrows underdj-update="append"/"prepend"are added after the rows already there — over a WebSocket or SSE, a handler that only calledstream_insert/stream_delete/stream_prune/stream_reset(orstream()with items) was answered with anoop, so the row waited for the next unrelated render; the same handler as a tick did not send a frame._snapshot_assigns, the one comparison every skip decision uses, now sees a stream mutation (a_stream_epochbumped only when a stream actually changed), while an event that touches no stream, a prune above the current size, a delete that matches no row or a reset of an empty stream stays anoop. And on a view withtemporary_assigns,self.rows = [new_row]next to another change replied with a patch that had no row (the key was always left out of the changed keys), while as the only change it rewrote the first stored row and removed the rest, because the differ had nodj-updatehandling (only the client's full-HTML path did). The key now counts as changed once it holds anything but its reset default, and the differ keeps adj-updatecontainer's stored children and adds the new ones byid(splice_accumulating_subtrees). Only a container whose body reads atemporary_assignskey (orstreams, or an{% include %}) accumulates, found in the stored tree by itsidand not by position; any otherdj-updatelist, including one in a view that declarestemporary_assigns, is re-rendered whole as before.prependis one insert at the front per event with each row keeping its ownid, attributes anddj-id. Behaviour notes: an accumulating container's rows stay in the server's copy of the page, so the stored tree grows with every row added and each event costs a little more (about 1 ms with 200 rows, 15 ms with 8,000); a row with noidis not added, and DEBUG logs a warning naming the view. (#3639) sync_turn_wrapper: the teardown hooks, the private-state restore and three more missed hops now enter a step, and the structural pin that should have found them is rebuilt (#3646). Hardening follow-up to #3637. Rebuilding the pin to actually verify what it allows turned up real hops that ran an application hook (or a row read) on a worker thread with no wrapper entry: (1) a released view's teardown hooks_on_sticky_unmount(),_cleanup_on_unregister()and_cleanup_uploads()are now"lifecycle"steps wherever the release runs on a worker thread (SSE navigation, the release of a view mounted beside the page,response.close()of an unstarted stream, and a WebSocketlive_redirectthat discards a sticky child whose auth now fails via_preserve_sticky_children); on the event loop (the WebSocket release, the SSE stream's own close) they stay unwrapped, since a wrapper that opens a transaction raises there. A wrapper that raises on entry no longer skips a teardown hook (the failure is logged and the hook runs without the scope). (2)_restore_private_state()is a"lifecycle"step: it re-fetches private model references (Model.objects.get) and assigns withsetattr, so under row-level security a private model attribute came backNoneafter a reconnect. (3) the worker-pool server-push turn called the push handler raw in its one hop, while the stock turn enters a"hook"step. (4) a{% live_render lazy=True %}child'smount()andget_context_data()ran raw when its slot was filled; they are"mount"and"render"steps now. (5) the per-event signed-snapshot refresh called_capture_snapshot_state()raw (the mount twin was stepped in #3637). The pin (test_sync_turn_structural_pin_3646.py, moved out of the #3637 test) is keyed by (file, enclosing function, target text) instead of a bare name, so a nested helper namedrun/call/turnor a rawcomponent.renderno longer hides; it sees the bare@sync_to_asyncdecorator, aliased imports,SyncToAsync,executor.submit,threading.Thread/Timer(and aThreadsubclass) andanyio.to_thread.run_sync; every hand-off it allows is verified (a leaf must enter its step, a plumbing hop may not reach a stepped hook outside a step unless it declares it); and the documented-override points are derived from every class in the package, each with a checkedstepped:<step>/inside:<steps>@<function>/unwrapped:<category>:<reason>disposition. The guide's "entries per request" list now includeshandle_params, the second render and the POST event step (measured, and pinned by a test), and its "Not wrapped" text states which thread runs each teardown hook. Review follow-up: everyinside:disposition in the hooks table is now checked by running the hook with a recording wrapper (it had assertedinside:renderfor hooks that a template tag calls, which run with no step open:as_live()/as_live_field(), thefield_valuefilter, form adapters, componentget_context_data()/_render_custom(),_on_sticky_update(),get_template(); of the 34inside:entries 16 were exactly right, 7 listed steps that never ran, 3 missed that the hook also runs with no step open, and 8 named a step that was not open); those are nowunwrapped:template-timeand the guide has a "Not wrapped: template-time form and tag hooks" section stating that a row read there (aModelChoiceFieldqueryset) runs without the wrapper scope._restore_private_state()is a guarded step, like_restore_snapshot(), so a wrapper whose__exit__swallows an exception can no longer turn a failing private-state restore into a silent partial one. The pin also reports afunctools.partial(sync_to_async, ...)factory. New cases inpython/djust/tests/test_sync_turn_structural_pin_3646.py,python/djust/tests/test_sync_turn_inside_audit_3646.pyandpython/djust/tests/test_sync_turn_lifecycle_gaps_3646.py.- The limits of per-container
dj-updateaccumulation are resolved, or documented where they cannot be — a container that renders its rows through an{% include %}is now decided by reading the included file (static names, found the way the renderer finds them, nested includes,onlyandwithhonoured; in the view's own template./xisxat the template-dir root, as the renderer reads it), so a plain list in an include no longer swallows a removal or an edit in a view that declarestemporary_assignsfor another key; a dynamic name, a missing file, a cycle, a chain over 8 deep or an{% extends %}stay as before (assumed to read a key). A{% with items=rows %}alias made outside the container, and a context key that holds the same object (context["feed"] = self.rows), now count as the temporary key; a{% with rows=other %}around the container hides the key there; a{% if a > b %}beforedj-updatein the container's tag no longer hides it; a nested container's body belongs to the nested container, so an outer list that is an ordinary attribute stays authoritative. Rows that repeat anidin aprependcontainer (the mount render, or one batch) used to produceMoveChildpatches the client could not apply (1/N patches failed); rows are keyed byidplus their occurrence number, so each new row is one insert at the front again. The scan no longer goes quadratic on unterminated{{(20,000 of them took about 3 s once per class) and is bounded (1,000,000 characters, 1,000 nested elements, 2 s; over any limit the lists are re-rendered whole and a warning is logged); a timeout, an exception or an unreadable included file is not remembered (the next render scans again, up to 3 timeouts in a row; one warning per class and cause), while the size, depth and include-count bounds are; its result is dropped by the dev file watcher when any template changes, so an edited template or included file is read again without a restart. Not fixable, and now documented plainly: a value derived from the attribute (list(self.rows)), a container whose tag lives inside an included file, and a tag that reads the view without naming the key leave the container re-rendered whole, which on atemporary_assignsview keeps only the latest render's rows and lets an unrelated event blank the list. (#3647) - djust's own template libraries no longer log a warning per tag the native engine already handles —
djust_componentsandtheme_tagsmeeting djust's built-in handlers logged about 206 "already provides" warnings on the first page, burying real clashes. A refusal of a tag from a library underdjust.is now logged at DEBUG; the refusal itself is unchanged, and any other library that clashes still warns once (#3655). - A tag that takes an object renders under the Rust engine as it does under Django (#3666).
{% field_error field=form.name %}rendered nothing under the Rust engine and the error under Django: the engine hands a native handler its operands as strings and JSON, so aBoundFieldarrived as its widget HTML, aFormas itsstr, adatetimeasstr(datetime)and an item of a list as whateverstrgives. The tags that take an object are measured, not listed by hand (scripts/lib/template_library_parity/objects.pyrenders every parameter of every tag with a value that records how it is used):field_error,form_errors,combobox,rich_select,relative_time,avatar_group,conversation_thread,notification_popoverandtoast_container. They no longer have a native handler class (rust_handlers.OBJECT_ARGUMENT_TAGS): each is registered as the{% load %}bridge handler, so the Python tag's own node renders it and the output is Django's by construction. They are registered up front, so they still work in a template that does not load the library. Behaviour change: the markup oftoast_container,notification_popover,combobox,relative_timeandrich_selectunder the Rust engine is now the Django tag's (toast_containerisdj-toast-container, nottoast-container), and a{% toast_container %}with notoastsis aTemplateSyntaxErroras it is under Django (the handler used to read a context variable namedtoasts). Also behaviour changes: the six simple/inclusion tags among them now refuse an unknown keyword as Django does (see #3667), andtoast_container, an inclusion tag, needs a template loader (APP_DIRSorDIRS) as it does under Django's engine, where the native handler needed none; per render,toast_containergoes from about 64 to 285 microseconds for three toasts (median of 400 renders, one machine) and the other eight were measured within about 16% of the handlers they replace. Not changed: aFormorBoundFieldbound by{% for %}or{% with %}inside the template still reaches a tag as itsstr(#3755);json_viewerrenders adatetimedifferently in the two engines, a type dispatch the attribute-read measurement cannot see (#3756); a callable operand is not called by the engine as Django would. Newpython/tests/test_native_handler_fidelity_3666_3667.py; the parity baseline rows for the nine tags improve. - Native component handlers refuse the input the Python tags refuse (#3667). Where the Python tag raised, the native handler rendered. Now a required parameter that nothing supplies is a
TemplateSyntaxErrorat compile time with Django's message ('data_table' did not receive value(s) for the argument(s): 'rows', 'columns',djust._positional_args.missing_arguments, a transcription of the end ofparse_bits);sliderraisesValueErrorfor a non-numericvalueorvalue_endinstead of echoing it into the markup;activity_feed(max),progress(value) andpagination(total_pages) raise instead of falling back to a default, andanimated_numberraises for a non-stringseparator.notification_centerwithnotifications=None, the one row that ran the other way, renders an empty list as the Python tag does instead of raising.notification_popoverwith a list of strings emitted<built-in method title of str object at 0x...>into the page in BOTH engines (astrhas atitle, the method, read withgetattr(item, "title", "")); an item attribute that is callable now reads as absent. Behaviour change: a template that relied on the lenient rendering of one of these calls, and was already invalid under Django, now raises under the Rust engine. Unchanged and documented: an unknown keyword is still accepted by the native handlers that remain (rust_only_keysin the parity baseline;dj_selectreadsoptions_varon purpose). It is NOT accepted by six of the nine tags bridged under #3666 (combobox,field_error,form_errors,relative_time,rich_select,toast_container): they take the Python tag's keywords, so{% toast_container toasts=t class="x" %}now raises Django'sunexpected keyword argumentwhere it used to render. Newpython/tests/test_native_handler_fidelity_3666_3667.pyderives the variants one engine alone rejects, over every tag, fromscripts/template-library-parity.py --fidelity. - The template-library bridge no longer treats
tabsandsplit_paneas bridgeable, and no longer lets one entry decide for a whole library —_may_overrideignored the raw-block registry, so djust's native raw handlers fortabs/split_panewere counted asdjust_components' and bridged as a dead handler (their output is unchanged: byte-identical before and after), and a library could have displaced a djust raw-block handler. Separately, a library's module-keyed policy and #3655's "is this djust's own?" log level came from the first-registered entry, so they changed with the import order of a multi-module library and one re-exported function could classify a third-party library as djust's. Policy is now keyed on the module every entry shares, the shared-name check compares an order-independent identity, and the log level reads the refused tag's own module (#3668). - Review leftovers of #3535, #3652 and #3678 closed (#3691).
djust.T027now scans a template once (the line of each<Rust…/>tag was counted from the start of the template, so a template with very many tags cost time quadratic in its size) and no longer reports an example inside{% verbatim %}, which the renderer does not render as a tag; a tag in an HTML comment or a<script>body is still reported, because the renderer renders and warns for it too (a differential test againstdjust._rust.render_templatepins that). The doc-snippet network guard intests/test_doc_snippets_execute.pyalso refusessocket.gethostbynameandsocket.connect_ex, which bypassed thegetaddrinfoandconnectguards. New pins for T027's severity, the scope of the settings-warning dedupe and the:inthint of thedj-paramswarning, and a fixture inpython/djust/tests/test_static_render_3626.pythat clears the once-per-classtemporary_assignswarning registry so a "does not warn" negative no longer passes vacuously after an earlier test released the class. New cases inTestT027RustJsxTags. - A test that turned the worker pool on no longer leaves it on for the rest of the xdist worker —
monkeypatch.setitem(config._config, ...)is restored onto a dict the config has since swapped out, soworker_threadsstayed set; the per-test global reset now putsworker_threadsandworker_pinningback (#3692). - Four intermittent
python-testsfailures onmainwere timing races in the tests, not framework bugs — each was reproduced deterministically by forcing the interleave, and none is a product defect. (1)python/djust/tests/test_view_replacement_teardown_3244_3245.pyread the presence list when a replacementmountframe arrived, but a replaced view is untracked inreceive()'sfinally, after that frame (by design, #3254): it now waits for the consumer to finish the frame withframe_barrier(a ping/pong) inpython/djust/tests/_ws_frames.py. (2)python/djust/tests/test_live_auth_denials_and_wrappers.pytimed out incommunicator.connect()at Channels' 1 s default, because the rootconftest.pylengthened four of the seven communicator waits (#3464) and leftconnect,waitandget_responseout; the list is now derived from the Channels classes and pinned inpython/djust/tests/test_ws_communicator_defaults_3464.py. (3)python/djust/tests/test_stream_terminal_flush_3227.pypassed an explicittimeout=3that overrode the 30 s default; it now waits for the frame. (4)python/djust/tests/test_ratelimit_post_fallback.py(#3707) drained arate=1bucket and asserted it stayed empty, which holds only while under a second of wall clock passes; the bucket clock is now owned by the test, as it is in the other rate-limit tests that drain a bucket and assert it stays empty. The likely shared cause of (2) to (4) is a worker stalling for seconds (not witnessed in the CI logs, which carry no GC data): a full local run on Python 3.12 measured generation-2 GC pauses of median 0.32 s and up to 3.4 s in a worker holding every imported test module, andgc.freeze()when collection finishes (conftest.py) cuts that to median 0.10 s, max 1.05 s (#3692, #3707). - A push to a view mounted beside the page view no longer loses its
target_idwhenworker_threadsis on — the offloaded push turn hand-splices its patch frame and skipped the slot's stamp, so the client applied the patch to the page's container instead of the view's (#3692). TtydTerminalViewno longer 500s on a bad?rows=/?cols=, and no longer accepts an unbounded size — a value that is not an integer (?rows=x,?cols=) now falls back to the view's class default instead of raisingValueError, and an integer is clamped to 1..500 rows / 1..1000 columns (a digit string too long forint()to parse is a huge number and clamps to the cap, whateverPYTHONINTMAXSTRDIGITSis set to) before it reaches the CSScalc()min-height and xterm, so a link cannot make the viewer's own tab allocate a huge terminal.?theme=was not affected (#3699).- Per-match line numbers in the template checks are computed in one pass, and the doc-snippet network guard covers three more resolver calls — the
text[:offset].count("\n") + 1idiom (O(template x matches)) behind T001, S007, T004, T013, T005, T010, T011, T014, T015, T017, T025, A070, A075, A090, T016, T024, Y001-Y006, S011,djust_typecheckandscripts/check-template-comments.pyis replaced bydjust.checks.utils.LineCounter(and an incremental counter in the script), which reads each character once; the reported lines are unchanged. Theno_networkguard intests/test_doc_snippets_execute.pynow also refusessocket.gethostbyname_ex,gethostbyaddrandgetnameinfo(#3705). _blank_template_commentsis linear in the number of{% comment %}openers. It searched for the end of each opener's line withcontent.find("\n", position), which runs to the end of a template that has no newline once per opener: 4x the openers cost 14x as much (0.02 s to 0.30 s for 10,000 to 40,000{% comment %}). The next newline is now remembered while the scan moves forward past it, and 4x the openers costs 4.1x. The output is unchanged: a differential test (python/djust/tests/test_blank_template_comments_differential_3723.py) compares the new function with the old one, kept verbatim as the oracle, over 47 hand-written templates and 4,000 seeded random ones, and the growth test that wasxfail(strict=True)intest_template_checks_skip_comments_3283.pyis now an ordinary test.- Parsing a template is linear in its size when the text between tags holds no
>;extract_template_variableswas ~n^2.9. The cause was not the variable walk but the parser: for every{{ }}and{% if %}it decided whether the position was inside an HTML tag by walking back over the preceding tokens until it met a text token containing>. A template whose blocks are separated by whitespace (every indented template) has no such token, so each call re-read the whole prefix, and each piece was prepended to aString, which copies it. 4x the blocks cost 43x as much (0.006 s to 0.28 s for 100 to 400 blocks). One forward pass over the tokens now gives the answer for every position (html_tag_context_table, reset at the same>-bearing text tokens the walk stopped at), built lazily the first time a parse asks and found again by the token slice being parsed, so 4x the blocks costs about 4x. The renderer parses with the same function, so a coldrender_templateof the same template (a source not seen before) went from 36x to 3.9x for 4x the blocks; a Rust test compares every position of 5,000 random token streams (their text tokens combine fragments, so a token can hold a>and a trailing unclosed tag,</p><div class=") and 9 named shapes with the old walk, which is kept in the test module as the oracle. The growth test that wasxfail(strict=True)inpython/tests/test_template_variable_extraction.pyis now an ordinary test over three block shapes. - An overriding
get_context_data()ran twice per HTTP GET (#3725). The GET evaluated the view's context once and cached it for the render that follows, but the cache short-circuit sat inside the stockget_context_data(), so a view that defined its own method (with or withoutsuper()) ran its whole body a second time from_sync_state_to_rust(): queries, API calls and side effects happened twice on first paint, and a query budget read double. The render now takes the context the request already computed, through one helper (ContextMixin._context_for_render) used by both render sinks, for legacy and explicit views. Events and later renders still evaluate afresh, because the GET clears the cache. A page GET now enters one"render"sync step instead of two (the sequence in the turn-middleware guide). Not changed: the HTTP POST fallback of a legacy view still evaluates twice (#3734), and an explicit-exposure view with atemplate_namepage shell evaluates three times on a live mount (#3733). Regression coverage inpython/djust/tests/test_single_get_context_data_3725.py. - The
dj-updateaccumulation scan is linear on Python versions whosehtml.parseris quadratic — before 3.12.11 / 3.13.5 (gh-135462, CVE-2025-6069),HTMLParser.close()rescans an unterminated<aor<!--at the end of the input once per<, so a template ending in junk after its last container took 36s instead of milliseconds (#3647'soutside any containercases on 3.12.9). The scan no longer feeds the parser what follows the last>, which can complete no tag. The bounded-scan tests inTestTheScanIsBoundednow assert that 4x the input costs under 8x the CPU time (time.thread_time), not that a scan finishes within 1s of wall-clock time, so they no longer fail on a loaded machine. - The framework's own
asgi.pyexamples now serve static files.djust.schema.BEST_PRACTICES["setup"]["asgi"](what the MCP server anddjust aihand to agents) and theDjustMiddlewareStackdocstring example both showed a bare"http": get_asgi_application(), the exact shape the same module lists as common mistake 5 ("static files returning 404 with ASGI servers"). Both now wrap the HTTP handler inASGIStaticFilesHandlerand callget_asgi_application()before importing djust, matching thedjust newscaffold and the README (#3653). - The HTTP fallback now hands a
start_asyncoutcome tohandle_async_resultas anasyncstep — the fallback ran the hook as ahookstep, unlike the socket (#3625). The step map names it, and the pin test that lists everyhandle_async_resultcall site includes the fallback, which turned main red after #3630 and #3636 merged. - The README and QUICKSTART
asgi.pynow serve static files. Both showed"http": get_asgi_application(), so underuvicorn(the documented server) the client script at the static URL /static/djust/client.js was a 404: the page renderedCount: 0, the client never loaded, and the counter never moved. Browser-verified against the README steps applied verbatim. The blocks now wrap the HTTP handler inASGIStaticFilesHandler, as thedjust newscaffold and the installation guide already do, and callget_asgi_application()before importingdjust.websocketso Django is set up first. With the fix the same project connects and counts 0 → 1 → 2 with no console errors. - The README hero example now runs. It set
template_string = """...""", an attribute noLiveViewmixin reads, so a reader who copied the first code block on the page gotValueError: Either template_name or template must be seton the first GET and on WebSocket mount. The attribute istemplate; the same rename is applied to everyLiveViewexample underdocs/state-management/. A new test (tests/test_readme_snippets_execute.py) executes every README block that defines aLiveView, mounts the inline-template ones throughLiveViewTestClient, asserts the mounted state reaches the HTML and fires every@event_handler, so the AST-only doc-snippet check can no longer stay green over a class attribute that exists nowhere.
Security
Native Rust component and theme handlers read a bare positional operand's resolved text as
key=valuepairs, so attacker-controlled data passed as a positional argument could set any parameter of the tag; positional arguments were otherwise dropped —{% dj_tag name %}withname = "variant=primary"rendered withvariantset, and{% dj_button label %}with"event=delete"could set the click event name;class,valueand the other parameters were reachable the same way. Output stayed escaped, so there is no markup or script injection. It affected the nativesimple_tag/inclusion_taghandlers indjust_componentsandtheme_tagsunder the Rust engine. The same cause made{% dj_tag "Python" %}(valid Django,labelis the first parameter) render an empty<span class="tag tag-default"></span>: the handlers skipped every argument without an=. The 67 such handlers that have a positional parameter now name each positional token after the parameter the Django tag's own signature declares at that position, at template compile time, through a new optional inline-handler hookrewrite_args_at_parse(args)(djust._positional_args), so{% rating 4 %},{% progress 25 %}and{% theme_mode_toggle "btn" False %}render as their keyword forms and a positional value is only ever a value. Django's positional checks are mirrored with its messages (received too many positional arguments,received some positional argument(s) after some keyword argument(s),received multiple values for keyword argument; Django reports a parameter given both by position and by keyword as a render-timeTypeError, the Rust engine refuses it at compile time). Not covered: the raw@register.tagnative handlers (alert,card,accordion, ...) are unchanged, and a bare variable whose value containskey=valuecan still set their parameters, in a template Django itself refuses (Unexpected argument ... Use key=value format.); that is tracked in a follow-up issue. (#3664){% avatar src=... %}no longer prints a script-capable URL, and every other URL parameter of djust's template tags and components was swept for the same gap (#3665). The Pythonavatartag emittedsrcverbatim, sojavascript:alert(1)rendered<img src="javascript:alert(1)">under Django's engine while the native handler emittedsrc="#"; it now goes throughurl_attr(src, image=True), the policy the handler uses (data:image/...stays, any other non-navigation scheme becomes#; on a TAG amark_safevalue is the author's explicit opt-out).javascript:in an<img src>does not run in a current browser, so none of this is exploitable today; each is a missing sanitiser. The sweep (scripts/template-library-url-sweep.py) drives EVERY parameter each tag can read — not the ones named like URLs — and every parameter of every driveable class component (ComponentandLiveComponent, under each CSS framework), with plain, case-folded, whitespace/control-character-obfuscated, entity- and percent-encodedjavascript:/vbscript:/data:text/htmlpayloads in four argument shapes (and once with the payload under a key other arguments name), through BOTH engines, and reads the final attributes. Besides the avatar it found seven more places that printed such a URL into an<img>(eight sinks in all): thesrcsetofresponsive_imagein both engines and of theResponsiveImageclass, and five class components that escaped withhtml.escapewhere their tag twins callurl_attr(ImageCropper,ImageUploadPreview,LiveIndicator,MentionsInput,OrgChart). Asrcsetis a list of URLs, so a newsrcset_attrchecks each candidate asurl_attrchecks one image source; candidates are split exactly as the HTML specification's "parse a srcset attribute" splits them (parentheses in a descriptor do not nest, only ASCII whitespace separates), and the tests judge it with an independent port of that algorithm, not with the production parser. The six class sinks that escaped withhtml.escape(ImageCropper,ImageUploadPreview,LiveIndicator,MentionsInput,OrgChart,ResponsiveImage'ssrcset) never honour the safe marker: aSafeStringis checked and escaped like any other string, exactly ashtml.escapetreated it before (an already-escaped&becomes&amp;). The other class components (Avatar,Carousel,NavMenu,Breadcrumb,ResponsiveImage'ssrc, theLiveComponents) already honoured it and still do, as do the template tags: amark_safeURL is the author's explicit opt-out. Behaviour changes: ablob:image URL, which the native handlers already printed as#, is now#in the Pythonavatartag and in these class sinks too; asrcsetwith no candidate ("", whitespace) is now omitted by everyresponsive_imagepath (Django's engine used to printsrcset=""); aNoneavatar no longer renders a broken<img>inMentionsInput; anddjust_theming's URL denylist also refused the obsoletelivescript:andmhtml:(#3677 then replaced that denylist with thedjust_componentsallowlist). Nohref,actionor other navigation sink emitted a script URL in either engine. The sweep also showed the theming tags passing every scheme thedjust_componentsallowlist rejects (blob:,file:, custom schemes, ...); that was left for a decision and is fixed by #3677, which moves theming onto the allowlist. New cases intest_avatar_src_scheme_3665.py,test_image_url_sinks_3665.pyandtest_url_scheme_sweep_3665.py; the parity baseline'savatarrow moves (escaping 8 -> 6, other-attrs 3 -> 1) because the two engines now agree on thesrc.Native Rust handlers of raw
@register.tagcomponent tags read a bare variable's value askey=valuepairs, so data passed as a bare operand could set a parameter of the tag —{% alert val %}body{% endalert %}withval = "title=PWNED type=danger"rendered the titlePWNED type=danger, and{% card val %}/{% accordion val %}could be made to setclass/id. Output stayed escaped, so there is no markup or script injection, and it needed a template Django itself refuses (Unexpected argument 'val'. Use key=value format.). Measured on thedjust_componentslibrary: 134 native handlers belong to raw tags; Django refuses a bare variable on 125 of them, and 117 of the 134 took the injected parameter. Each such handler is now wrapped at registration (with_argument_contract,djust._positional_args) so that at template compile time the tag's own Django compile function checks the tokens through the existingvalidate_at_parsehook, stopping where the tag asks for its body (no Rust change). The Rust engine therefore refuses a bare variable, attribute path, filtered variable, literal or number on those 125 tags with Django's message as aTemplateSyntaxError, before any context exists; the keyword formtitle=valis unchanged, and the 9 raw tags whose compile function never reads its arguments (menu_divider,timeline, ...) still accept and ignore a bare token, as Django does. Behaviour change: a template that put a bare operand on one of these tags rendered under the Rust engine and is now aTemplateSyntaxError; it was already invalid under Django. (#3676)djust_themingnow sanitises every URL with the same allowlistdjust_componentsuses, instead of its own denylist (#3677). The theming tags kept a denylist (javascript:,vbscript:,data:, and since #3665livescript:andmhtml:), so every scheme it did not name passed through the 19 sinks the sweep reaches in both engines (the tests pin 21):theme_button,theme_nav,theme_nav_group,theme_nav_item,theme_breadcrumb,theme_avatarsrc, and theactionand*_urlof everytheme_*_pagethat has them.blob:,file:,jar:,view-source:,intent:,ms-word:,feed:,ws:,git:and custom application schemes are not script schemes in a current browser, so none of this is a script-execution hole today; a denylist ages badly, though, since every new dangerous scheme is a hole until someone adds it. The theming helpers now call a newdjust.components.utils.url_allowed(the decisionsafe_urlalready made, now callable without its escaping) and the theming denylist is deleted.url_attrandsafe_urlbehave as before and the components allowlist is not widened. What happens to a URL whose scheme is nothttp,https,ftp,ftps,mailtoortel(a relative URL is fine) depends on the sink:- Data-derived sinks (
theme_nav,theme_nav_group,theme_nav_item,theme_sidebar_nav,theme_breadcrumbitems,theme_pagination'surl_pattern,theme_avatarsrc(an inlinedata:image/...stays), and everytheme_*_pageactionand link):#, as before. For examplesms:,geo:,webcal:and app deep links such asmyapp://record/42now render#in these tags; they passed through before. This is the behaviour change for theme authors. - Developer-supplied literals (
{% theme_button href=... %}, andhref,src,actionorformactionpassed through**attrs, for example totheme_input): a script scheme (javascript:,vbscript:,data:,livescript:,mhtml:, in any spelling a browser reads as that scheme) still raisesValueError, exactly as before. Any OTHER rejected scheme renders#and logs a warning naming the parameter, not the URL; it does not raise, because these sinks rendered such a URL before andhref=record.urlis data, so awebcal:orblob:value must not turn the page into a 500. mark_safeURLs get no exemption in any theming sink: a marked-safe URL is made plain text before the template prints it, so it is judged and escaped like any other string, assafe_urldoes (an already-escaped&therefore prints as&amp;). Before, a template printed it raw, andmark_safe("javascript:x"), which the allowlist cannot see as a scheme, reached the page as ajavascript:URL to a browser.
The URL-scheme sweep (
scripts/template-library-url-sweep.py) now reads every scheme outside the allowlist, not only the script schemes, with 20 new payloads in the forms a browser still reads as that scheme; run over the tags with the test corpus onmainit reported 38 holes in the theming tags (19 sinks in each of the two engines), and the sweep test finds none after the change.python/tests/test_theming_url_allowlist_3677.pydrives every theming sink in both engines and pins the set of sinks from the templates (every URL-bearing attribute, composite values included) and from the tag functions, so a new sink is a visible decision. That wider scanner also found one gap indjust_components, recorded and pinned, not changed here: the RustAvatar(crates/djust_components/src/simple/avatar.rs) does not strip a leading C0 control character before reading the scheme, so\x01blob:...reaches an<img src>where the Pythonurl_attrreturns#. New cases intest_theming_url_allowlist_3677.py(replacestest_theming_url_denylist_3665.py),test_theming_url_scheme_guard.pyandtest_url_scheme_sweep_3665.py.- Data-derived sinks (
TtydTerminalViewtook the WebSocket its terminal connects to straight from?url=, so a crafted link could point a victim's terminal at any server — the browser sends every keystroke to that endpoint and draws its replies as terminal output, so an attacker's server could show a fake prompt and record what was typed. It is not a script scheme, so this was not script injection.?url=is now honoured only for aws://orwss://URL whose origin (scheme, host and port, compared exactly) is the view's ownttyd_urlor is listed in the newttyd_allowed_originsclass attribute, and only if it carries no query string (a ttyd started with--url-argturns URL arguments into command-line arguments of the spawned process); anything else is ignored, with a warning that does not echo the value, and the view falls back tottyd_url. The URL the browser receives is rebuilt from the allowed origin plus the requested path rather than echoed, and a value containing a backslash, whitespace, a control or non-ASCII character, userinfo, no host or more than 2048 characters is refused outright. A subclass's ownttyd_urlis trusted as written and may carry a query. Behaviour change:/shell/?url=ws://other-host:7681no longer reachesother-hostunless it is listed inttyd_allowed_origins(or is the view's ownttyd_url).TtydTerminalViewis also added to the packages the #3665 URL sweep walks (pinned as an undriven page) so a renderable class outside the swept packages is no longer invisible to it (#3679).pulldown-cmark0.13.5 → 0.13.6 (RUSTSEC-2026-0433, #3750). The advisory is quadratic parsing of nested image wikilinks underENABLE_WIKILINKS.djust_templatesenables only thehtmlfeature and never setsENABLE_WIKILINKS, so the option the advisory names is not reachable from djust's markdown filter; the bump clearscargo audit --deny unsound, whichsecurity-scanruns. Lockfile only.Lock: autobahn 26.7.1 -> 26.10.2 (CVE-2026-116579) —
autobahnis reached only through the optionaldjust[daphne]extra (and the dev environment). The lock now constrains it to the fixed release (>=26.10.1) andcbor2follows it to 6.1.5 on CPython. This constrainsuv.lock, not the published metadata, so installers ofdjust[daphne]resolve their own latest autobahn.The HTTP fallback refuses an event the page view has no handler for, and
viewis a reserved state field name. Two low-severity findings from the 2026-10 explicit-path probes. (1) A POST naming an event that resolved to no attribute, or to a non-callable one, on the page view fell through dispatch and was answered with 200, a full render and a state save as if a handler had run; the socket transports and the component branch already answered 400. The page view now answers{"error": "Event handler not found"}with 400 and writes nothing. The event-name guard also used$, which matches before a trailing newline, so"bump\n"passed the syntactic check on every transport; it now uses\Z. (2)ExposureContractacceptedviewas astate()field name although the explicit render context reserves it for the view object; it is now rejected like the other reserved names. New cases intest_http_unknown_event_refused.py,TestEventGuardandtest_invalid_field_names_are_rejected; seven existing tests that had pinned the 200 fall-through for an unknown event (three HTTP-fallback background-work tests, three sticky-child HTTP tests that now drive a handler the parent defines, andTestErrorHandling.test_invalid_event_handler) now expect the refusal.Lock: pyasn1 0.6.4 -> 0.6.5 (CVE-2026-119194, CVE-2026-119195, CVE-2026-119196, CVE-2026-119197) —
pyasn1is a lock-only constraint; it is raised to>=0.6.5, the release that fixes the four advisories pip-audit now reports. This constrainsuv.lock, not the published metadata.pulldown-cmark0.13.4 → 0.13.5 (RUSTSEC-2026-0414) — the advisory is an exponential event growth withENABLE_WIKILINKS, which djust never sets (markdown.rsenables tables, strikethrough and task lists only), so it was not reachable; the bump clears the weeklyvendorscan that had been failing onmain.Cargo.lockand the SBOMpython/djust/djust.cdx.jsonare regenerated.
Documentation
- Roadmap: v1.3.0-10 drain bucket — adds the HTTP fallback parity, sync-turn-wrapper coverage and theme state weight issues (#3624, #3503, #3625, #3626, #3482) to the v1.3 drain plan, with the shipped rows marked done.
- Roadmap: v1.3.0-14 drain bucket — records the follow-ups of the v1.3.0-13 drain (HTTP fallback, sync-turn-wrapper hops,
dj-updateaccumulation) and renames the earlier colliding v1.3.0-10 bucket to v1.3.0-13.
Deprecated
- Everything djust 2.0 removes now warns first (#3500). Each warning goes through
warn_deprecated, names its replacement and says it is removed in2.0.0;docs/API_STABILITY.mdlists them all.AccordionMixin,TabsMixin,ModalMixin,CollapsibleMixin,SheetMixin,DropdownMixin,TooltipMixinandCarouselMixinwarn when a view subclasses them. Use the matching component indjust.components.descriptors.@reactivewarns where it is applied. It cannot work on aLiveView, which has noupdate()(the decorator raisesTypeErrorthere), and on other hosts the decorated function's body is never called. Use a plain attribute or@computed.social_auth_providers(deprecated in 1.3) now warns in the standard form, with a removal version.@event,LiveViewFormand the_legacytheme module, whose warnings said "no earlier than 1.1.0", now name2.0.0.- The module shims
djust.template_backendanddjust.state_backendwarn on import,djust._view_resolution.is_view_import_allowedanddjust.rust_components.Buttonwarn when called, anddjust.simple_live_view.LiveViewwarns on access. The scaffolder, the framework's own imports and the test suite use the real paths. - The SSE
/event/endpoint (DjustSSEEventView) warns on each POST. StreamingMixin's asyncstream_insert/stream_deletegain the namesastream_insert/astream_delete. On aLiveViewthe old names were shadowed by the sync collection-stream methods and reachable only asStreamingMixin.stream_insert(self, ...), which now warns.- At startup,
DjustConfig.ready()warns once for each deprecated setting in use:DJUST_WS_COMPRESSION,DJUST_VDOM_CACHE_*,DJUST_STATE_BACKEND,DJUST_REDIS_URL, thetemplate_auto_call/virtual_keyed_ops/django_value_reprkeys, and aTEMPLATESentry namingdjust.template_backend.DjustTemplateBackend. They keep working in 1.x; 2.0 refuses to start while one is set.
- Defining
_drain_announcementsor_drain_focuson aLiveViewis deprecated (#3629, follow-up to #3503). The WebSocket consumer and the SSE runtime drain these two duck-typed hooks behindhasattrand send anaccessibility/focusframe, but nothing in djust defines them, so no built-in API ever queued an announcement or a focus command, and the shipped client has nowindow.djust.accessibilityobject to receive the frames unless the application's own script assigns one. The HTTP fallback never drained them, so a view that defined them lost them there silently. The frames are wire-protocol anddjust.d.tstypes the client object, so rather than delete the flush (or add a producer and a client half for a feature the 2.0 line has already removed, #3539) a view class that defines either hook (in its own body or through a mixin) now emits oneDeprecationWarningper hook at itsclassstatement, naming the removal (no earlier than 2.0.0) and the replacement: arole="status"live region the view renders, andJS.focus()throughpush_commands(), which every transport carries (a live region announces only a changed text, so a repeated identical message needs the alternating-suffix workaround in the guide). Behaviour is unchanged until 2.0.0. New: an "Announcing a change and moving focus from the server" section in the accessibility guide and an entry inAPI_STABILITY.md. - Client APIs that djust 2.0.0 removes now warn once per page when used. The JSON
dj-paramsattribute (usedj-value-*attributes; the warning used to appear only withdjustDebug), the pre-1.0[data-djust-root]marker indj-layoutswaps (usedj-root), and thewindow.LiveViewWebSocket,window.setCacheConfigandwindow.globalLoadingManageraliases (use the same names onwindow.djust). Each warning names its replacement, and each API keeps working until 2.0.0.client-dev.js, the demo loading test page and the Playwright loading check now readwindow.djust.*, so they don't trigger the warning. Covered bytests/js/deprecation_warnings_2_0.test.js. - JSX-style
<Rust…/>component template tags. Rendering a tag such as<RustButton id="b" label="Go" />now emits aDeprecationWarning: the tags are deprecated since 1.3 and removed in 2.0.0, where they become a template error. Use the matchingdjust.components.uicomponent. 3 regression cases inpython/tests/test_rust_component_tag_deprecation.py.