Skip to content

Instantly share code, notes, and snippets.

@laymonage
Last active May 21, 2026 10:50
Show Gist options
  • Select an option

  • Save laymonage/c3e23e9099e3292fdd2fb77be34b3373 to your computer and use it in GitHub Desktop.

Select an option

Save laymonage/c3e23e9099e3292fdd2fb77be34b3373 to your computer and use it in GitHub Desktop.

User

We recently added a mechanism to defer validation of required blocks within StreamFields when saving drafts. We implemented this by adding defer_required_validation(), restore_deferred_validation(), and clean_deferred(). You can see the implementation of these methods in wagtail.blocks.base.Block, wagtail.blocks.stream_block.BaseStreamBlock, and wagtail.blocks.field_block.FieldBlock.

We recently got a report where in some cases, a required block may become not-required while the application is running, and it stays that way even after validation. The block in question seems to be an ImageChooserBlock inside a StructBlock, which is inside a StreamField's StreamBlock. Could you identify what could be the problem?

Don't make any code changes yet, just analyze the problem first and find the root cause if possible.

Assistant

Tool calls

Tool Call: grep -l "defer_required_validation|restore_deferred_validation|clean_deferred" Status: Completed

Found 10 files wagtail/tests/test_blocks.py wagtail/blocks/stream_block.py wagtail/blocks/list_block.py wagtail/blocks/base.py wagtail/images/tests/test_blocks.py wagtail/contrib/typed_table_block/tests.py wagtail/contrib/typed_table_block/blocks.py wagtail/contrib/table_block/tests.py wagtail/blocks/struct_block.py wagtail/blocks/field_block.py

Tool Call: grep -n "defer_required_validation|restore_deferred_validation|clean_deferred" wagtail/blocks/base.py Status: Completed

175: def defer_required_validation(self): 179: logic should be implemented in :meth:restore_deferred_validation. 182: super().defer_required_validation(), to ensure the parent's deferred 199: def restore_deferred_validation(self): 201: Restore any validation that was deferred by :meth:defer_required_validation. 204: super().restore_deferred_validation(), to ensure the parent's deferred 209: def clean_deferred(self, value): 211: Wraps :meth:clean with :meth:defer_required_validation and 212: :meth:restore_deferred_validation, so that any validation that is not 216: typically a StreamBlock). Instead of calling clean_deferred on child 221: self.defer_required_validation() 225: self.restore_deferred_validation() 806: # whether to call the block's clean_deferred method (which will perform any 811: return self.block.clean_deferred(value)

Tool Call: Read wagtail/blocks/base.py (160 - 259) Status: Completed

160	    def _evaluate_callable(self, value):
161	        return value() if callable(value) else value
162	
163	    def get_default(self):
164	        """
165	        Return this block's default value (conventionally found in self.meta.default),
166	        converted to the value type expected by this block. If the default is a callable
167	        (e.g. a function), it will be evaluated at runtime. This caters for
168	        the case where that value type is not something that can be expressed statically at
169	        model definition time (e.g. something like StructValue which incorporates a
170	        pointer back to the block definition object).
171	        """
172	        default = self._evaluate_callable(getattr(self.meta, "default", None))
173	        return self.normalize(default)
174	
175	    def defer_required_validation(self):
176	        """
177	        Defer any validation that is not required when saving a draft, such as by
178	        setting ``required = False`` on child blocks. The corresponding restoration
179	        logic should be implemented in :meth:`restore_deferred_validation`.
180	
181	        Subclasses that implement this method should also call
182	        ``super().defer_required_validation()``, to ensure the parent's deferred
183	        validation logic is also applied.
184	        """
185	        self.is_deferred_validation = True
186	
187	    def clean(self, value):
188	        """
189	        Validate value and return a cleaned version of it, or throw a :class:`~django.core.exceptions.ValidationError` if validation fails.
190	
191	        To determine whether to defer any validation that is not required for saving a
192	        draft, the :attr:`is_deferred_validation` attribute can be checked.
193	
194	        For more details on how to implement custom validation logic, refer to
195	        :ref:`streamfield_validation`.
196	        """
197	        return value
198	
199	    def restore_deferred_validation(self):
200	        """
201	        Restore any validation that was deferred by :meth:`defer_required_validation`.
202	
203	        Subclasses that implement this method should also call
204	        ``super().restore_deferred_validation()``, to ensure the parent's deferred
205	        validation logic is also restored.
206	        """
207	        self.is_deferred_validation = False
208	
209	    def clean_deferred(self, value):
210	        """
211	        Wraps :meth:`clean` with :meth:`defer_required_validation` and
212	        :meth:`restore_deferred_validation`, so that any validation that is not
213	        required for saving a draft can be deferred.
214	
215	        This is only called on the top-level block of a StreamField (which is
216	        typically a StreamBlock). Instead of calling ``clean_deferred`` on child
217	        blocks, the defer/restore logic should be propagated to child blocks, which
218	        means the child blocks' ``clean()`` methods will be called with the deferred
219	        validation in place.
220	        """
221	        self.defer_required_validation()
222	        try:
223	            return self.clean(value)
224	        finally:
225	            self.restore_deferred_validation()
226	
227	    def normalize(self, value):
228	        """
229	        Given a value for any acceptable type for this block (e.g. string or RichText for a RichTextBlock;
230	        dict or StructValue for a StructBlock), return a value of the block's native type (e.g. RichText
231	        for RichTextBlock, StructValue for StructBlock). In simple cases this will return the value
232	        unchanged.
233	        """
234	        return value
235	
236	    def to_python(self, value):
237	        """
238	        Convert 'value' from a simple (JSON-serialisable) value to a (possibly complex) Python value to be
239	        used in the rest of the block API and within front-end templates . In simple cases this might be
240	        the value itself; alternatively, it might be a 'smart' version of the value which behaves mostly
241	        like the original value but provides a native HTML rendering when inserted into a template; or it
242	        might be something totally different (e.g. an image chooser will use the image ID as the clean
243	        value, and turn this back into an actual image object here).
244	
245	        For blocks that are usable at the top level of a StreamField, this must also accept any type accepted
246	        by normalize. (This is because Django calls `Field.to_python` from `Field.clean`.)
247	        """
248	        return value
249	
250	    def bulk_to_python(self, values):
251	        """
252	        Apply the to_python conversion to a list of values. The default implementation simply
253	        iterates over the list; subclasses may optimise this, e.g. by combining database lookups
254	        into a single query.
255	        """
256	        return [self.to_python(value) for value in values]
257	
258	    def get_prep_value(self, value):
259	        """

Tool Call: grep -n "defer_required_validation|restore_deferred_validation|clean_deferred" wagtail/blocks/field_block.py Status: Completed

65: def defer_required_validation(self): 66: super().defer_required_validation() 76: def restore_deferred_validation(self): 78: super().restore_deferred_validation()

Tool Call: Read wagtail/blocks/field_block.py (1 - 120) Status: Completed

1	import copy
2	import datetime
3	from decimal import Decimal
4	
5	from django import forms
6	from django.db.models import Model
7	from django.db.models.fields import BLANK_CHOICE_DASH
8	from django.utils.choices import CallableChoiceIterator
9	from django.utils.dateparse import parse_date, parse_datetime, parse_time
10	from django.utils.encoding import force_str
11	from django.utils.functional import cached_property
12	from django.utils.html import format_html
13	from django.utils.safestring import mark_safe
14	from django.utils.translation import gettext as _
15	
16	from wagtail.admin.staticfiles import versioned_static
17	from wagtail.admin.telepath import Adapter, register
18	from wagtail.compat import URLField
19	from wagtail.coreutils import camelcase_to_underscore, resolve_model_string
20	from wagtail.rich_text import (
21	    RichText,
22	    RichTextMaxLengthValidator,
23	    RichTextMinLengthValidator,
24	    extract_references_from_rich_text,
25	    get_text_for_indexing,
26	)
27	
28	from .base import Block
29	
30	
31	class FieldBlock(Block):
32	    """A block that wraps a Django form field"""
33	
34	    def id_for_label(self, prefix):
35	        return self.field.widget.id_for_label(prefix)
36	
37	    def value_from_form(self, value):
38	        """
39	        The value that we get back from the form field might not be the type
40	        that this block works with natively; for example, the block may want to
41	        wrap a simple value such as a string in an object that provides a fancy
42	        HTML rendering (e.g. EmbedBlock).
43	
44	        We therefore provide this method to perform any necessary conversion
45	        from the form field value to the block's native value. As standard,
46	        this returns the form field value unchanged.
47	        """
48	        return value
49	
50	    def value_for_form(self, value):
51	        """
52	        Reverse of value_from_form; convert a value of this block's native value type
53	        to one that can be rendered by the form field
54	        """
55	        return value
56	
57	    def value_from_datadict(self, data, files, prefix):
58	        return self.value_from_form(
59	            self.field.widget.value_from_datadict(data, files, prefix)
60	        )
61	
62	    def value_omitted_from_data(self, data, files, prefix):
63	        return self.field.widget.value_omitted_from_data(data, files, prefix)
64	
65	    def defer_required_validation(self):
66	        super().defer_required_validation()
67	        self._original_required = self.required
68	        self.field.required = False or getattr(self.meta, "required_on_save", False)
69	
70	    def clean(self, value):
71	        # We need an annoying value_for_form -> value_from_form round trip here to account for
72	        # the possibility that the form field is set up to validate a different value type to
73	        # the one this block works with natively
74	        return self.value_from_form(self.field.clean(self.value_for_form(value)))
75	
76	    def restore_deferred_validation(self):
77	        self.field.required = self._original_required
78	        super().restore_deferred_validation()
79	
80	    @property
81	    def required(self):
82	        # a FieldBlock is required if and only if its underlying form field is required
83	        return self.field.required
84	
85	    def get_form_state(self, value):
86	        return self.field.widget.format_value(
87	            self.field.prepare_value(self.value_for_form(value))
88	        )
89	
90	    def get_description(self):
91	        return super().get_description() or self.field.help_text or ""
92	
93	    class Meta:
94	        # No icon specified here, because that depends on the purpose that the
95	        # block is being used for. Feel encouraged to specify an icon in your
96	        # descendant block type
97	        icon = "placeholder"
98	        default = None
99	
100	
101	class FieldBlockAdapter(Adapter):
102	    js_constructor = "wagtail.blocks.FieldBlock"
103	
104	    def js_args(self, block):
105	        classname = [
106	            "w-field",
107	            f"w-field--{camelcase_to_underscore(block.field.__class__.__name__)}",
108	            f"w-field--{camelcase_to_underscore(block.field.widget.__class__.__name__)}",
109	        ]
110	
111	        form_classname = getattr(block.meta, "form_classname", "")
112	        if form_classname:
113	            classname.append(form_classname)
114	
115	        # Provided for backwards compatibility. Replaced with 'form_classname'
116	        legacy_classname = getattr(block.meta, "classname", "")
117	        if legacy_classname:
118	            classname.append(legacy_classname)
119	
120	        meta = {

Assistant

Tool Call: grep -l "class ImageChooserBlock|class ChooserBlock|class StructBlock|class BaseStreamBlock" Status: Completed

Found 11 files wagtail/blocks/stream_block.py wagtail/images/blocks.py wagtail/blocks/struct_block.py wagtail/blocks/field_block.py client/src/components/StreamField/blocks/StructBlock.ts wagtail/test/utils/wagtail_factories/builder.py wagtail/admin/compare.py docs/tutorial/create_portfolio_page.md wagtail/test/utils/wagtail_factories/blocks.py wagtail/test/streamfield_migrations/models.py wagtail/test/streamfield_migrations/factories.py

Tool Call: Read wagtail/images/blocks.py Status: Completed

1	from django import forms
2	from django.core.exceptions import ValidationError
3	from django.template.loader import render_to_string
4	from django.utils.functional import cached_property
5	from django.utils.translation import gettext_lazy as _
6	
7	from wagtail.admin.compare import BlockComparison, StructBlockComparison
8	from wagtail.admin.telepath import register
9	from wagtail.blocks import BooleanBlock, CharBlock, ChooserBlock, StructBlock
10	from wagtail.blocks.struct_block import StructBlockAdapter, StructBlockValidationError
11	from wagtail.images.models import AbstractImage
12	
13	from .shortcuts import get_rendition_or_not_found
14	
15	
16	class ImageChooserBlock(ChooserBlock):
17	    @cached_property
18	    def target_model(self):
19	        from wagtail.images import get_image_model
20	
21	        return get_image_model()
22	
23	    @cached_property
24	    def widget(self):
25	        from wagtail.images.widgets import AdminImageChooser
26	
27	        return AdminImageChooser()
28	
29	    def render_basic(self, value, context=None):
30	        if value:
31	            return get_rendition_or_not_found(value, "original").img_tag()
32	        else:
33	            return ""
34	
35	    def get_comparison_class(self):
36	        return ImageChooserBlockComparison
37	
38	    class Meta:
39	        icon = "image"
40	
41	
42	class ImageChooserBlockComparison(BlockComparison):
43	    def htmlvalue(self, val):
44	        return render_to_string(
45	            "wagtailimages/widgets/compare.html",
46	            {
47	                "image_a": val,
48	                "image_b": val,
49	            },
50	        )
51	
52	    def htmldiff(self):
53	        return render_to_string(
54	            "wagtailimages/widgets/compare.html",
55	            {
56	                "image_a": self.val_a,
57	                "image_b": self.val_b,
58	            },
59	        )
60	
61	
62	class ImageBlock(StructBlock):
63	    """
64	    An usage of ImageChooserBlock with support for alt text.
65	    For backward compatibility, this block overrides necessary methods to change
66	    the StructValue to be an Image model instance, making it compatible in
67	    places where ImageChooserBlock was used.
68	    """
69	
70	    image = ImageChooserBlock(required=True)
71	    decorative = BooleanBlock(
72	        default=False, required=False, label=_("Image is decorative")
73	    )
74	    alt_text = CharBlock(required=False, label=_("Alt text"))
75	
76	    def __init__(self, required=True, **kwargs):
77	        super().__init__(
78	            [
79	                ("image", ImageChooserBlock(required=required)),
80	                (
81	                    "decorative",
82	                    BooleanBlock(
83	                        default=False, required=False, label=_("Image is decorative")
84	                    ),
85	                ),
86	                ("alt_text", CharBlock(required=False, label=_("Alt text"))),
87	            ],
88	            **kwargs,
89	        )
90	
91	    def deconstruct(self):
92	        """
93	        For typical StructBlock subclasses, it makes sense for the deconstructed block object to be a basic StructBlock
94	        with the child blocks passed to the constructor (because this is largely functionally identical to the
95	        subclass, and avoids embedding a reference to a potentially-volatile custom class in migrations).
96	
97	        This is not the case for ImageBlock, as it overrides enough of StructBlock's behaviour that a basic StructBlock
98	        is not a suitable substitute - and also has an incompatible constructor signature (as we don't want to support
99	        passing child blocks to it).
100	
101	        Therefore, we opt out of the standard StructBlock deconstruction behaviour here, and always
102	        deconstruct an ImageBlock as an ImageBlock.
103	        """
104	        return ("wagtail.images.blocks.ImageBlock", [], self._constructor_kwargs)
105	
106	    def deconstruct_with_lookup(self, lookup):
107	        return self.deconstruct()
108	
109	    @classmethod
110	    def construct_from_lookup(cls, lookup, *args, **kwargs):
111	        return cls(**kwargs)
112	
113	    def get_searchable_content(self, value):
114	        if not self.search_index or not value:
115	            return []
116	
117	        return self.child_blocks["alt_text"].get_searchable_content(
118	            value.contextual_alt_text
119	        )
120	
121	    def _struct_value_to_image(self, struct_value):
122	        image = struct_value.get("image")
123	        decorative = struct_value.get("decorative")
124	        if image:
125	            # If the image is decorative, set alt_text to an empty string
126	            image.contextual_alt_text = (
127	                "" if decorative else struct_value.get("alt_text")
128	            )
129	            image.decorative = decorative
130	        return image
131	
132	    def _image_to_struct_value(self, image):
133	        return {
134	            "image": image,
135	            "alt_text": image and image.contextual_alt_text,
136	            "decorative": image and image.decorative,
137	        }
138	
139	    def to_python(self, value):
140	        # For backward compatibility with ImageChooserBlock
141	        if value is None or isinstance(value, int):
142	            image = self.child_blocks["image"].to_python(value)
143	            struct_value = {
144	                "image": image,
145	                "decorative": False,
146	                "alt_text": (image.default_alt_text if image else ""),
147	            }
148	        else:
149	            struct_value = super().to_python(value)
150	        return self._struct_value_to_image(struct_value)
151	
152	    def bulk_to_python(self, values):
153	        values = list(values)
154	
155	        if values and all(value is None or isinstance(value, int) for value in values):
156	            # `values` looks like a list of image IDs and/or None values (as we might encounter
157	            # if an ImageChooserBlock has been changed to an ImageBlock with no data migration)
158	            image_values = self.child_blocks["image"].bulk_to_python(values)
159	
160	            struct_values = [
161	                {
162	                    "image": image,
163	                    "decorative": False,
164	                    "alt_text": (image.default_alt_text if image else ""),
165	                }
166	                for image in image_values
167	            ]
168	
169	        else:
170	            # Treat `values` as the standard ImageBlock representation - a (possibly empty) list of
171	            # dicts containing `image`, `decorative` and `alt_text` keys to be handled by the
172	            # StructBlock superclass
173	            struct_values = super().bulk_to_python(values)
174	
175	        return [
176	            self._struct_value_to_image(struct_value) for struct_value in struct_values
177	        ]
178	
179	    def value_from_datadict(self, data, files, prefix):
180	        struct_value = super().value_from_datadict(data, files, prefix)
181	        return self._struct_value_to_image(struct_value)
182	
183	    def clean(self, value):
184	        try:
185	            self.child_blocks["image"].clean(value)
186	        except ValidationError as e:
187	            raise StructBlockValidationError(
188	                block_errors={"image": e},
189	            ) from e
190	        if (
191	            not self.is_deferred_validation
192	            and value
193	            and not value.contextual_alt_text
194	            and not value.decorative
195	        ):
196	            raise StructBlockValidationError(
197	                block_errors={
198	                    "alt_text": ValidationError(
199	                        _(
200	                            "Please add some alt text for your image or mark it as decorative"
201	                        )
202	                    )
203	                }
204	            )
205	
206	        return value
207	
208	    def normalize(self, value):
209	        if value is None or isinstance(value, AbstractImage):
210	            return value
211	        else:
212	            struct_value = super().normalize(value)
213	            return self._struct_value_to_image(struct_value)
214	
215	    def get_form_context(self, value, prefix="", errors=None):
216	        dict_value = {
217	            "image": value,
218	            "alt_text": value and value.contextual_alt_text,
219	            "decorative": value and value.decorative,
220	        }
221	        context = super().get_form_context(dict_value, prefix=prefix, errors=errors)
222	        context["suggested_alt_text"] = value
223	        return context
224	
225	    def get_form_state(self, value):
226	        return {
227	            "image": self.child_blocks["image"].get_form_state(value),
228	            "alt_text": value and value.contextual_alt_text,
229	            "decorative": value and value.decorative,
230	        }
231	
232	    def get_prep_value(self, value):
233	        return {
234	            "image": self.child_blocks["image"].get_prep_value(value),
235	            "alt_text": value and value.contextual_alt_text,
236	            "decorative": value and value.decorative,
237	        }
238	
239	    def extract_references(self, value):
240	        return self.child_blocks["image"].extract_references(value)
241	
242	    def get_comparison_class(self):
243	        return ImageBlockComparison
244	
245	    def get_api_representation(self, value, context=None):
246	        return super().get_api_representation(
247	            self._image_to_struct_value(value), context=context
248	        )
249	
250	    def render_basic(self, value, context=None):
251	        return self.child_blocks["image"].render_basic(value, context=context)
252	
253	    def get_block_by_content_path(self, value, path_elements):
254	        if path_elements:
255	            return super().get_block_by_content_path(
256	                self._image_to_struct_value(value), path_elements
257	            )
258	        else:
259	            return self.bind(value)
260	
261	    class Meta:
262	        icon = "image"
263	        template = "wagtailimages/widgets/image.html"
264	
265	
266	class ImageBlockAdapter(StructBlockAdapter):
267	    js_constructor = "wagtail.images.blocks.ImageBlock"
268	
269	    @cached_property
270	    def media(self):
271	        structblock_media = super().media
272	        return forms.Media(
273	            js=structblock_media._js + ["wagtailimages/js/image-block.js"],
274	            css=structblock_media._css,
275	        )
276	
277	
278	register(ImageBlockAdapter(), ImageBlock)
279	
280	
281	class ImageBlockComparison(StructBlockComparison):
282	    def __init__(self, block, exists_a, exists_b, val_a, val_b):
283	        super().__init__(
284	            block,
285	            exists_a,
286	            exists_b,
287	            block._image_to_struct_value(val_a),
288	            block._image_to_struct_value(val_b),
289	        )
290	
291	    def htmlvalue(self, val):
292	        if isinstance(val, AbstractImage):
293	            return super().htmlvalue(self.block._image_to_struct_value(val))
294	        else:
295	            return super().htmlvalue(val)
296	

Assistant

Tool Call: grep -n "class ChooserBlock" wagtail/blocks/field_block.py Status: Completed

861:class ChooserBlock(FieldBlock):

Tool Call: Read wagtail/blocks/field_block.py (861 - 960) Status: Completed

861	class ChooserBlock(FieldBlock):
862	    def __init__(self, required=True, help_text=None, validators=(), **kwargs):
863	        self._required = required
864	        self._help_text = help_text
865	        self._validators = validators
866	        super().__init__(**kwargs)
867	
868	    """Abstract superclass for fields that implement a chooser interface (page, image, snippet etc)"""
869	
870	    @cached_property
871	    def model_class(self):
872	        return resolve_model_string(self.target_model)
873	
874	    @cached_property
875	    def field(self):
876	        return forms.ModelChoiceField(
877	            queryset=self.model_class.objects.all(),
878	            widget=self.widget,
879	            required=self._required,
880	            validators=self._validators,
881	            help_text=self._help_text,
882	        )
883	
884	    def to_python(self, value):
885	        # the incoming serialised value should be None or an ID
886	        if value is None:
887	            return value
888	        else:
889	            try:
890	                return self.model_class.objects.get(pk=value)
891	            except self.model_class.DoesNotExist:
892	                return None
893	
894	    def bulk_to_python(self, values):
895	        """Return the model instances for the given list of primary keys.
896	
897	        The instances must be returned in the same order as the values and keep None values.
898	        If the same ID appears multiple times, a distinct object instance is created for each one.
899	        """
900	        objects = self.model_class.objects.in_bulk(values)
901	        seen_ids = set()
902	        result = []
903	
904	        for id in values:
905	            obj = objects.get(id)
906	            if obj is not None and id in seen_ids:
907	                # this object is already in the result list, so we need to make a copy
908	                obj = copy.copy(obj)
909	
910	            result.append(obj)
911	            seen_ids.add(id)
912	
913	        return result
914	
915	    def get_prep_value(self, value):
916	        # the native value (a model instance or None) should serialise to a PK or None
917	        if value is None:
918	            return None
919	        else:
920	            return value.pk
921	
922	    def value_from_form(self, value):
923	        # ModelChoiceField sometimes returns an ID, and sometimes an instance; we want the instance
924	        if value is None or isinstance(value, self.model_class):
925	            return value
926	        else:
927	            try:
928	                return self.model_class.objects.get(pk=value)
929	            except self.model_class.DoesNotExist:
930	                return None
931	
932	    def get_form_state(self, value):
933	        return self.widget.get_value_data(value)
934	
935	    def clean(self, value):
936	        # ChooserBlock works natively with model instances as its 'value' type (because that's what you
937	        # want to work with when doing front-end templating), but ModelChoiceField.clean expects an ID
938	        # as the input value (and returns a model instance as the result). We don't want to bypass
939	        # ModelChoiceField.clean entirely (it might be doing relevant validation, such as checking page
940	        # type) so we convert our instance back to an ID here. It means we have a wasted round-trip to
941	        # the database when ModelChoiceField.clean promptly does its own lookup, but there's no easy way
942	        # around that...
943	        if isinstance(value, self.model_class):
944	            value = value.pk
945	        return super().clean(value)
946	
947	    def extract_references(self, value):
948	        if value is not None and issubclass(self.model_class, Model):
949	            yield self.model_class, str(value.pk), "", ""
950	
951	    class Meta:
952	        # No icon specified here, because that depends on the purpose that the
953	        # block is being used for. Feel encouraged to specify an icon in your
954	        # descendant block type
955	        icon = "placeholder"
956	
957	
958	class PageChooserBlock(ChooserBlock):
959	    def __init__(
960	        self, page_type=None, can_choose_root=False, target_model=None, **kwargs

Assistant

Tool Call: grep -n "defer_required_validation|restore_deferred_validation|clean_deferred" wagtail/blocks/struct_block.py Status: Completed

304: def defer_required_validation(self): 305: super().defer_required_validation() 307: block.defer_required_validation() 323: def restore_deferred_validation(self): 325: block.restore_deferred_validation() 326: super().restore_deferred_validation()

Assistant

This is a focused analysis task, so I won't use task tracking. Continuing the investigation.

Tool Call: Read wagtail/blocks/struct_block.py (280 - 339) Status: Completed

280	        return self.normalize(
281	            {
282	                name: default[name] if name in default else block.get_default()
283	                for name, block in self.child_blocks.items()
284	            }
285	        )
286	
287	    def value_from_datadict(self, data, files, prefix):
288	        return self._to_struct_value(
289	            [
290	                (
291	                    name,
292	                    block.value_from_datadict(data, files, f"{prefix}-{name}"),
293	                )
294	                for name, block in self.child_blocks.items()
295	            ]
296	        )
297	
298	    def value_omitted_from_data(self, data, files, prefix):
299	        return all(
300	            block.value_omitted_from_data(data, files, f"{prefix}-{name}")
301	            for name, block in self.child_blocks.items()
302	        )
303	
304	    def defer_required_validation(self):
305	        super().defer_required_validation()
306	        for block in self.child_blocks.values():
307	            block.defer_required_validation()
308	
309	    def clean(self, value):
310	        result = []  # build up a list of (name, value) tuples to be passed to the StructValue constructor
311	        errors = {}
312	        for name, val in value.items():
313	            try:
314	                result.append((name, self.child_blocks[name].clean(val)))
315	            except ValidationError as e:
316	                errors[name] = e
317	
318	        if errors:
319	            raise StructBlockValidationError(errors)
320	
321	        return self._to_struct_value(result)
322	
323	    def restore_deferred_validation(self):
324	        for block in self.child_blocks.values():
325	            block.restore_deferred_validation()
326	        super().restore_deferred_validation()
327	
328	    def to_python(self, value):
329	        """Recursively call to_python on children and return as a StructValue"""
330	        return self._to_struct_value(
331	            [
332	                (
333	                    name,
334	                    (
335	                        child_block.to_python(value[name])
336	                        if name in value
337	                        else child_block.get_default()
338	                    ),
339	                    # NB the result of get_default is NOT passed through to_python, as it's expected

Tool Call: grep -n "defer_required_validation|restore_deferred_validation|clean_deferred" wagtail/blocks/stream_block.py Status: Completed

164: def defer_required_validation(self): 165: super().defer_required_validation() 167: child_block.defer_required_validation() 241: def restore_deferred_validation(self): 243: child_block.restore_deferred_validation() 244: super().restore_deferred_validation()

Tool Call: Read wagtail/blocks/stream_block.py (140 - 259) Status: Completed

140	                )
141	            )
142	
143	        values_with_indexes.sort()
144	        return StreamValue(
145	            self,
146	            [
147	                (child_block_type_name, value, block_id)
148	                for (
149	                    index,
150	                    child_block_type_name,
151	                    value,
152	                    block_id,
153	                ) in values_with_indexes
154	            ],
155	        )
156	
157	    def value_omitted_from_data(self, data, files, prefix):
158	        return ("%s-count" % prefix) not in data
159	
160	    @property
161	    def required(self):
162	        return self.meta.required
163	
164	    def defer_required_validation(self):
165	        super().defer_required_validation()
166	        for child_block in self.child_blocks.values():
167	            child_block.defer_required_validation()
168	
169	    def clean(self, value):
170	        required = self.required and not self.is_deferred_validation
171	        cleaned_data = []
172	        errors = {}
173	        non_block_errors = ErrorList()
174	        for i, child in enumerate(value):  # child is a StreamChild instance
175	            try:
176	                cleaned_data.append(
177	                    (child.block.name, child.block.clean(child.value), child.id)
178	                )
179	            except ValidationError as e:
180	                errors[i] = e
181	
182	        if required:
183	            if self.meta.min_num is not None and self.meta.min_num > len(value):
184	                non_block_errors.append(
185	                    ValidationError(
186	                        _("The minimum number of items is %(min_num)d")
187	                        % {"min_num": self.meta.min_num}
188	                    )
189	                )
190	            elif len(value) == 0:
191	                non_block_errors.append(ValidationError(_("This field is required.")))
192	
193	        if self.meta.max_num is not None and self.meta.max_num < len(value):
194	            non_block_errors.append(
195	                ValidationError(
196	                    _("The maximum number of items is %(max_num)d")
197	                    % {"max_num": self.meta.max_num}
198	                )
199	            )
200	
201	        if self.meta.block_counts:
202	            block_counts = defaultdict(int)
203	            for item in value:
204	                block_counts[item.block_type] += 1
205	
206	            for block_name, min_max in self.meta.block_counts.items():
207	                block = self.child_blocks[block_name]
208	                max_num = min_max.get("max_num", None)
209	                min_num = min_max.get("min_num", None)
210	                block_count = block_counts[block_name]
211	                if required and min_num is not None and min_num > block_count:
212	                    non_block_errors.append(
213	                        ValidationError(
214	                            "{}: {}".format(
215	                                block.label,
216	                                _("The minimum number of items is %(min_num)d")
217	                                % {"min_num": min_num},
218	                            )
219	                        )
220	                    )
221	                if max_num is not None and max_num < block_count:
222	                    non_block_errors.append(
223	                        ValidationError(
224	                            "{}: {}".format(
225	                                block.label,
226	                                _("The maximum number of items is %(max_num)d")
227	                                % {"max_num": max_num},
228	                            )
229	                        )
230	                    )
231	
232	        if errors or non_block_errors:
233	            # The message here is arbitrary - outputting error messages is delegated to the child blocks,
234	            # which only involves the 'params' list
235	            raise StreamBlockValidationError(
236	                block_errors=errors, non_block_errors=non_block_errors
237	            )
238	
239	        return StreamValue(self, cleaned_data)
240	
241	    def restore_deferred_validation(self):
242	        for child_block in self.child_blocks.values():
243	            child_block.restore_deferred_validation()
244	        super().restore_deferred_validation()
245	
246	    def to_python(self, value):
247	        if isinstance(value, StreamValue):
248	            return value
249	        elif isinstance(value, str) and value:
250	            try:
251	                value = json.loads(value)
252	            except ValueError:
253	                # value is not valid JSON; most likely, this field was previously a
254	                # rich text field before being migrated to StreamField, and the data
255	                # was left intact in the migration. Return an empty stream instead
256	                # (but keep the raw text available as an attribute, so that it can be
257	                # used to migrate that data to StreamField)
258	                return self.empty_value(raw_text=value)
259	

Assistant

Let me look at how the child block is created when iterating in StreamBlock.clean():

Tool Call: grep -n "class StreamChild|class StreamValue|child.block" wagtail/blocks/stream_block.py Status: Completed

43: # block_errors may be passed here as None, or a dict keyed by the indexes of the child blocks 85: self.child_blocks = self.base_blocks.copy() 89: self.child_blocks[name] = block 92: def construct_from_lookup(cls, lookup, child_blocks, **kwargs): 93: if child_blocks: 94: child_blocks = [ 95: (name, lookup.get_block(index)) for name, index in child_blocks 97: return cls(child_blocks, **kwargs) 102: def ordered_child_blocks(self): 104: return self.child_blocks.values() 106: def grouped_child_blocks(self): 108: The available child block types of this stream block, organised into groups according to 114: for child_block in self.ordered_child_blocks(): 115: group_name = child_block.meta.group 116: grouped_blocks.setdefault(group_name, []).append(child_block) 128: child_block = self.child_blocks[block_type_name] 136: child_block.value_from_datadict( 147: (child_block_type_name, value, block_id) 150: child_block_type_name, 166: for child_block in self.child_blocks.values(): 167: child_block.defer_required_validation() 177: (child.block.name, child.block.clean(child.value), child.id) 207: block = self.child_blocks[block_name] 233: # The message here is arbitrary - outputting error messages is delegated to the child blocks, 242: for child_block in self.child_blocks.values(): 243: child_block.restore_deferred_validation() 275: if child_data["type"] in self.child_blocks 294: (k, self.child_blocks[k].normalize(v)) 296: if k in self.child_blocks 304: # 1) a set of per-child-block lists ('child_inputs'), to be sent to each child block's 317: if block_type not in self.child_blocks: 331: block_type: self.child_blocks[block_type].bulk_to_python(child_input_list) 369: "type": child.block.name, 370: "value": child.block.get_form_state(child.value), 383: "type": child.block.name, 384: "value": child.block.get_api_representation( 396: [(child.render(context=context), child.block_type) for child in value], 405: content.extend(child.block.get_searchable_content(child.value)) 416: ) in child.block.extract_references(child.value): 418: f"{child.block_type}.{model_path}" 420: else child.block_type 436: return child.block.get_block_by_content_path( 453: args = [list(self.child_blocks.items())] 462: for name, block in self.child_blocks.items() 470: for name, child_block in self.child_blocks.items(): 471: errors.extend(child_block.check(**kwargs)) 472: errors.extend(child_block._check_name(**kwargs)) 506:class StreamValue(MutableSequence): 512: class StreamChild(BoundBlock): 529: Syntactic sugar so that we can say child.block_type instead of child.block.name. 598: self.block_names = stream_value.stream_block.child_blocks.keys() 687: block_def = self.stream_block.child_blocks[type_name] 726: child_block = self.stream_block.child_blocks[type_name] 727: # create a mapping of all the child blocks matching the given block type, 735: converted_values = child_block.bulk_to_python(raw_values.values()) 741: child_block, value, id=self._raw_data[i].get("id") 867: block.grouped_child_blocks(), 869: name: child_block.get_form_state(child_block.get_default()) 870: for name, child_block in block.child_blocks.items()

Tool Call: Read wagtail/blocks/stream_block.py (506 - 605) Status: Completed

506	class StreamValue(MutableSequence):
507	    """
508	    Custom type used to represent the value of a StreamBlock; behaves as a sequence of BoundBlocks
509	    (which keep track of block types in a way that the values alone wouldn't).
510	    """
511	
512	    class StreamChild(BoundBlock):
513	        """
514	        Iterating over (or indexing into) a StreamValue returns instances of StreamChild.
515	        These are wrappers for the individual data items in the stream, extending BoundBlock
516	        (which keeps track of the data item's corresponding Block definition object, and provides
517	        the `render` method to render itself with a template) with an `id` property (a UUID
518	        assigned to the item - this is managed by the enclosing StreamBlock and is not a property
519	        of blocks in general) and a `block_type` property.
520	        """
521	
522	        def __init__(self, *args, **kwargs):
523	            self.id = kwargs.pop("id")
524	            super().__init__(*args, **kwargs)
525	
526	        @property
527	        def block_type(self):
528	            """
529	            Syntactic sugar so that we can say child.block_type instead of child.block.name.
530	            (This doesn't belong on BoundBlock itself because the idea of block.name denoting
531	            the child's "type" ('heading', 'paragraph' etc) is unique to StreamBlock, and in the
532	            wider context people are liable to confuse it with the block class (CharBlock etc).
533	            """
534	            return self.block.name
535	
536	        def get_prep_value(self):
537	            return {
538	                "type": self.block_type,
539	                "value": self.block.get_prep_value(self.value),
540	                "id": self.id,
541	            }
542	
543	        def _as_tuple(self):
544	            if self.id:
545	                return (self.block.name, self.value, self.id)
546	            else:
547	                return (self.block.name, self.value)
548	
549	    class RawDataView(MutableSequence):
550	        """
551	        Internal helper class to present the stream data in raw JSONish format. For backwards
552	        compatibility with old code that manipulated StreamValue.stream_data, this is considered
553	        mutable to some extent, with the proviso that once the BoundBlock representation has been
554	        accessed, any changes to fields within raw data will not propagate back to the BoundBlock
555	        and will not be saved back when calling get_prep_value.
556	        """
557	
558	        def __init__(self, stream_value):
559	            self.stream_value = stream_value
560	
561	        def __getitem__(self, i):
562	            item = self.stream_value._raw_data[i]
563	            if item is None:
564	                # reconstruct raw data from the bound block
565	                item = self.stream_value._bound_blocks[i].get_prep_value()
566	                self.stream_value._raw_data[i] = item
567	
568	            return item
569	
570	        def __len__(self):
571	            return len(self.stream_value._raw_data)
572	
573	        def __setitem__(self, i, item):
574	            self.stream_value._raw_data[i] = item
575	            # clear the cached bound_block for this item
576	            self.stream_value._bound_blocks[i] = None
577	
578	        def __delitem__(self, i):
579	            # same as deletion on the stream itself - delete both the raw and bound_block data
580	            del self.stream_value[i]
581	
582	        def insert(self, i, item):
583	            self.stream_value._raw_data.insert(i, item)
584	            self.stream_value._bound_blocks.insert(i, None)
585	
586	        def __repr__(self):
587	            return repr(list(self))
588	
589	    class BlockNameLookup(Mapping):
590	        """
591	        Dict-like object returned from `blocks_by_name`, for looking up a stream's blocks by name.
592	        Uses lazy evaluation on access, so that we're not redundantly constructing StreamChild
593	        instances for blocks of different names.
594	        """
595	
596	        def __init__(self, stream_value, find_all=True):
597	            self.stream_value = stream_value
598	            self.block_names = stream_value.stream_block.child_blocks.keys()
599	            self.find_all = (
600	                find_all  # whether to return all results rather than just the first
601	            )
602	
603	        def __getitem__(self, block_name):
604	            result = [] if self.find_all else None
605	

Assistant

Let me check how StructBlock copies its declared child blocks at instantiation time:

Tool Call: grep -n | head -60 "def init|base_blocks|child_blocks|DeclarativeSubBlocksMetaclass" wagtail/blocks/struct_block.py Status: Completed

21: DeclarativeSubBlocksMetaclass, 37: def init(self, block_errors=None, non_block_errors=None): 86: def init( 207: def init(self, block, *args): 222: for name, block in self.block.child_blocks.items() 240: def init(self, local_blocks=None, search_index=True, **kwargs): 246: # create a local (shallow) copy of base_blocks so that it can be supplemented by local_blocks 247: self.child_blocks = self.base_blocks.copy() 251: self.child_blocks[name] = block 255: # Reorder child_blocks to match form_layout, appending any missing 258: missing_block_names = self.child_blocks.keys() - set(sorted_block_names) 259: self.child_blocks = collections.OrderedDict( 260: (name, self.child_blocks[name]) 265: def construct_from_lookup(cls, lookup, child_blocks, **kwargs): 266: if child_blocks: 267: child_blocks = [ 268: (name, lookup.get_block(index)) for name, index in child_blocks 270: return cls(child_blocks, **kwargs) 283: for name, block in self.child_blocks.items() 294: for name, block in self.child_blocks.items() 301: for name, block in self.child_blocks.items() 306: for block in self.child_blocks.values(): 314: result.append((name, self.child_blocks[name].clean(val))) 324: for block in self.child_blocks.values(): 342: for name, child_block in self.child_blocks.items() 351: for name, child_block in self.child_blocks.items(): 382: {name: values_by_subfield[name][i] for name in self.child_blocks.keys()} 394: name: self.child_blocks[name].get_prep_value(val) 403: {k: self.child_blocks[k].normalize(v) for k, v in value.items()} 408: name: self.child_blocks[name].get_form_state(val) 415: name: self.child_blocks[name].get_api_representation(val, context=context) 424: for name, block in self.child_blocks.items(): 432: for name, block in self.child_blocks.items(): 448: child_block = self.child_blocks[name] 470: args = [list(self.child_blocks.items())] 479: for name, block in self.child_blocks.items() 487: for name, child_block in self.child_blocks.items(): 544: for name, block in self.child_blocks.items() 568: return BlockGroup(list(self.child_blocks.keys())) 591:class StructBlock(BaseStructBlock, metaclass=DeclarativeSubBlocksMetaclass): 626: block.child_blocks.values(),

Assistant

Tool Call: grep -n "class DeclarativeSubBlocksMetaclass|base_blocks" wagtail/blocks/base.py Status: Completed

662:class DeclarativeSubBlocksMetaclass(BaseBlock): 683: # the combined base_blocks. 684: base_blocks = collections.OrderedDict() 688: base_blocks.update(base.declared_blocks) 692: if value is None and attr in base_blocks: 693: base_blocks.pop(attr) 694: new_class.base_blocks = base_blocks

Assistant

Skipping task tracking — this is single-thread analysis, not implementation.

Tool Call: Read wagtail/blocks/base.py (600 - 709) Status: Completed

600	            #     first_name = CharBlock()
601	            #     surname = CharBlock()
602	            #
603	            # FooBlock() == BarBlock() == StructBlock([('first_name', CharBlock()), ('surname': CharBlock())])
604	            #
605	            # For this to work, StructBlock will need to ensure that 'deconstruct' returns the same signature
606	            # in all of these cases, including reporting StructBlock as the path:
607	            #
608	            # FooBlock().deconstruct() == (
609	            #     'wagtail.blocks.StructBlock',
610	            #     [('first_name', CharBlock()), ('surname': CharBlock())],
611	            #     {}
612	            # )
613	            #
614	            # This has the bonus side effect that the StructBlock field definition gets frozen into
615	            # the migration, rather than leaving the migration vulnerable to future changes to FooBlock / BarBlock
616	            # in models.py.
617	
618	        return (
619	            self.name == other.name
620	            and self.deconstruct() == other.deconstruct()
621	            and all(
622	                getattr(self.meta, attr, None) == getattr(other.meta, attr, None)
623	                for attr in self.MUTABLE_META_ATTRIBUTES
624	            )
625	        )
626	
627	
628	class BoundBlock:
629	    def __init__(self, block, value, prefix=None, errors=None):
630	        self.block = block
631	        self.value = value
632	        self.prefix = prefix
633	        self.errors = errors
634	
635	    def render(self, context=None):
636	        return self.block.render(self.value, context=context)
637	
638	    def render_as_block(self, context=None):
639	        """
640	        Alias for render; the include_block tag will specifically check for the presence of a method
641	        with this name. (This is because {% include_block %} is just as likely to be invoked on a bare
642	        value as a BoundBlock. If we looked for a `render` method instead, we'd run the risk of finding
643	        an unrelated method that just happened to have that name - for example, when called on a
644	        PageChooserBlock it could end up calling page.render.
645	        """
646	        return self.block.render(self.value, context=context)
647	
648	    def id_for_label(self):
649	        return self.block.id_for_label(self.prefix)
650	
651	    def __str__(self):
652	        """Render the value according to the block's native rendering"""
653	        return self.block.render(self.value)
654	
655	    def __repr__(self):
656	        return "<block {}: {!r}>".format(
657	            self.block.name or type(self.block).__name__,
658	            self.value,
659	        )
660	
661	
662	class DeclarativeSubBlocksMetaclass(BaseBlock):
663	    """
664	    Metaclass that collects sub-blocks declared on the base classes.
665	    (cheerfully stolen from https://github.com/django/django/blob/main/django/forms/forms.py)
666	    """
667	
668	    def __new__(mcs, name, bases, attrs):
669	        # Collect sub-blocks declared on the current class.
670	        # These are available on the class as `declared_blocks`
671	        current_blocks = []
672	        for key, value in list(attrs.items()):
673	            if isinstance(value, Block):
674	                current_blocks.append((key, value))
675	                value.set_name(key)
676	                attrs.pop(key)
677	        current_blocks.sort(key=lambda x: x[1].creation_counter)
678	        attrs["declared_blocks"] = collections.OrderedDict(current_blocks)
679	
680	        new_class = super().__new__(mcs, name, bases, attrs)
681	
682	        # Walk through the MRO, collecting all inherited sub-blocks, to make
683	        # the combined `base_blocks`.
684	        base_blocks = collections.OrderedDict()
685	        for base in reversed(new_class.__mro__):
686	            # Collect sub-blocks from base class.
687	            if hasattr(base, "declared_blocks"):
688	                base_blocks.update(base.declared_blocks)
689	
690	            # Field shadowing.
691	            for attr, value in base.__dict__.items():
692	                if value is None and attr in base_blocks:
693	                    base_blocks.pop(attr)
694	        new_class.base_blocks = base_blocks
695	
696	        return new_class
697	
698	
699	# ========================
700	# django.forms integration
701	# ========================
702	
703	
704	@register_telepath_adapter
705	class BlockWidget(forms.Widget):
706	    """Wraps a block object as a widget so that it can be incorporated into a Django form"""
707	
708	    def __init__(self, block_def, attrs=None):
709	        super().__init__(attrs=attrs)

Assistant

Tool Call: Read wagtail/blocks/base.py (780 - 819) Status: Completed

780	    def value_from_datadict(self, data, files, name):
781	        return self.block_def.value_from_datadict(data, files, name)
782	
783	    def value_omitted_from_data(self, data, files, name):
784	        return self.block_def.value_omitted_from_data(data, files, name)
785	
786	    def telepath_pack(self, context):
787	        return ("wagtail.widgets.BlockWidget", [])
788	
789	
790	class BlockField(forms.Field):
791	    """Wraps a block object as a form field so that it can be incorporated into a Django form"""
792	
793	    def __init__(self, block=None, **kwargs):
794	        if block is None:
795	            raise ImproperlyConfigured("BlockField was not passed a 'block' object")
796	        self.block = block
797	
798	        if "widget" not in kwargs:
799	            kwargs["widget"] = BlockWidget(block)
800	
801	        super().__init__(**kwargs)
802	
803	    def clean(self, value):
804	        # During deferred validation, form fields (including BlockField) have an
805	        # is_deferred_validation attribute set to True. Use this to determine
806	        # whether to call the block's clean_deferred method (which will perform any
807	        # necessary setup/teardown for deferred validation, and then call clean)
808	        # or to call clean directly.
809	        is_deferred_validation = getattr(self, "is_deferred_validation", False)
810	        if is_deferred_validation:
811	            return self.block.clean_deferred(value)
812	        else:
813	            return self.block.clean(value)
814	
815	    def has_changed(self, initial_value, data_value):
816	        return self.block.get_prep_value(initial_value) != self.block.get_prep_value(
817	            data_value
818	        )
819	

Tool Call: grep -l "required_on_save" Status: Completed

Found 13 files docs/releases/8.0.md CHANGELOG.txt wagtail/tests/test_blocks.py wagtail/models/pages.py wagtail/contrib/forms/models.py docs/releases/7.4.md docs/reference/streamfield/blocks.md docs/reference/contrib/forms/customization.md wagtail/blocks/field_block.py docs/advanced_topics/streamfield_validation.md wagtail/admin/panels/field_panel.py docs/reference/panels.md docs/releases/7.0.md

Tool Call: grep -n "is_deferred_validation" Status: Completed

docs/reference/streamfield/blocks.md:74: .. autoattribute:: wagtail.blocks.Block.is_deferred_validation CHANGELOG.txt:29: * Add is_deferred_validation flag to support skipping custom validation when saving drafts (Daniel Kirkham) docs/advanced_topics/customization/page_editing_interface.md:288:If you have model-level validation rules that you wish to skip when saving a draft page or snippet, then the is_deferred_validation form property can be used within the form's clean() method. For example, the above example could be modified as follows: docs/advanced_topics/customization/page_editing_interface.md:301: if not self.is_deferred_validation: docs/advanced_topics/streamfield_validation.md:7:The clean method can be overridden on block subclasses to implement custom validation logic. During validation of drafts, the block has the {attr}~wagtail.blocks.Block.is_deferred_validation attribute set to True. docs/advanced_topics/streamfield_validation.md:27: if not self.is_deferred_validation and not (result['page'] or result['url']): wagtail/images/blocks.py:191: not self.is_deferred_validation wagtail/admin/forms/models.py:139: self.is_deferred_validation = False wagtail/admin/forms/models.py:143: if self.is_deferred_validation: wagtail/admin/forms/models.py:146: self.is_deferred_validation = True wagtail/admin/forms/models.py:153: field.is_deferred_validation = True wagtail/admin/forms/models.py:171: if not self.is_deferred_validation: wagtail/admin/forms/models.py:186: del field.is_deferred_validation wagtail/admin/forms/models.py:188: self.is_deferred_validation = False wagtail/tests/test_blocks.py:3039: if self.is_deferred_validation: docs/releases/7.4.md:26:The new behavior is enabled by default, but individual field blocks can opt out by setting the required_on_save option to True. All blocks now also have an {attr}~wagtail.blocks.Block.is_deferred_validation attribute that can be used in custom validation logic to conditionally skip validation when saving drafts. For more details, refer to . docs/releases/7.4.md:122: * Add is_deferred_validation flag to support skipping custom validation when saving drafts (Daniel Kirkham) wagtail/admin/tests/test_forms.py:80: if not self.is_deferred_validation: wagtail/admin/tests/test_forms.py:107: hasattr(field, "is_deferred_validation") wagtail/admin/tests/test_forms.py:121: hasattr(field, "is_deferred_validation") wagtail/admin/tests/test_forms.py:137: hasattr(field, "is_deferred_validation") wagtail/admin/tests/test_forms.py:138: and field.is_deferred_validation wagtail/admin/tests/test_forms.py:153: hasattr(field, "is_deferred_validation") wagtail/admin/tests/test_forms.py:154: and field.is_deferred_validation wagtail/admin/tests/test_forms.py:162: hasattr(field, "is_deferred_validation") wagtail/admin/tests/test_forms.py:176: hasattr(field, "is_deferred_validation") wagtail/admin/tests/test_forms.py:177: and field.is_deferred_validation wagtail/admin/tests/test_forms.py:185: hasattr(field, "is_deferred_validation") wagtail/blocks/base.py:102: self.is_deferred_validation = False wagtail/blocks/base.py:185: self.is_deferred_validation = True wagtail/blocks/base.py:192: draft, the :attr:is_deferred_validation attribute can be checked. wagtail/blocks/base.py:207: self.is_deferred_validation = False wagtail/blocks/base.py:805: # is_deferred_validation attribute set to True. Use this to determine wagtail/blocks/base.py:809: is_deferred_validation = getattr(self, "is_deferred_validation", False) wagtail/blocks/base.py:810: if is_deferred_validation: wagtail/blocks/stream_block.py:170: required = self.required and not self.is_deferred_validation wagtail/blocks/list_block.py:225: not self.is_deferred_validation wagtail/contrib/table_block/blocks.py:162: elif not self.is_deferred_validation:

Assistant

Tool Call: Read wagtail/admin/forms/models.py (130 - 199) Status: Completed

130	
131	class WagtailAdminModelForm(
132	    PermissionedForm, ClusterForm, metaclass=WagtailAdminModelFormMetaclass
133	):
134	    def __init__(self, *args, **kwargs):
135	        # keep hold of the `for_user` kwarg as well as passing it on to PermissionedForm
136	        self.for_user = kwargs.get("for_user")
137	        self.deferred_required_fields = []
138	        self.deferred_formset_min_nums = {}
139	        self.is_deferred_validation = False
140	        super().__init__(*args, **kwargs)
141	
142	    def defer_required_fields(self):
143	        if self.is_deferred_validation:
144	            # defer_required_fields has already been called
145	            return
146	        self.is_deferred_validation = True
147	
148	        for field in self.fields.values():
149	            # Set a flag on the field to indicate a deferred validation
150	            # is in effect, regardless of its required attribute and regardless
151	            # of whether it was added to defer_required_on_fields, to allow
152	            # custom form fields to adjust their validation behavior accordingly.
153	            field.is_deferred_validation = True
154	
155	        for field_name in self._meta.defer_required_on_fields:
156	            try:
157	                if self.fields[field_name].required:
158	                    self.fields[field_name].required = False
159	                    self.deferred_required_fields.append(field_name)
160	            except KeyError:
161	                pass
162	
163	        for name, formset in self.formsets.items():
164	            for form in formset:
165	                form.defer_required_fields()
166	            if formset.min_num is not None:
167	                self.deferred_formset_min_nums[name] = formset.min_num
168	                formset.min_num = 0
169	
170	    def restore_required_fields(self):
171	        if not self.is_deferred_validation:
172	            # Has been restored or defer_required_fields has not been called
173	            return
174	        for name, formset in self.formsets.items():
175	            for form in formset:
176	                form.restore_required_fields()
177	            if name in self.deferred_formset_min_nums:
178	                formset.min_num = self.deferred_formset_min_nums[name]
179	        self.deferred_formset_min_nums = {}
180	
181	        for field_name in self.deferred_required_fields:
182	            self.fields[field_name].required = True
183	        self.deferred_required_fields = []
184	
185	        for field in self.fields.values():
186	            del field.is_deferred_validation
187	
188	        self.is_deferred_validation = False
189	
190	    def get_field_updates_for_resave(self):
191	        """
192	        Following a successful save (as a background HTTP request), returns a list of
193	        form field updates - as (name, new_value) tuples - that can be applied to the
194	        form in the still-open page to make it valid for subsequent submissions. This
195	        includes populating the IDs of child objects within formsets - without this,
196	        subsequent submissions would create duplicates of these objects.
197	        """
198	        updates = []
199	        for formset in self.formsets.values():

Assistant

Let me look at tests of this functionality to understand the documented behavior:

Tool Call: grep -n "defer_required_validation|restore_deferred_validation|clean_deferred" wagtail/images/tests/test_blocks.py Status: Completed

234: cleaned_value = block.clean_deferred(block.to_python(value)) 239: # Required validation should be restored after clean_deferred

Tool Call: Read wagtail/images/tests/test_blocks.py (200 - 279) Status: Completed

200	    def test_render_as_decorative(self):
201	        block = ImageBlock()
202	        value = {
203	            "image": self.image.id,  # An id is expected
204	            "alt_text": "Sample alt text",
205	            "decorative": True,
206	        }
207	        html = block.render(block.to_python(value))
208	        soup = WagtailTestUtils.get_soup(html)
209	        img_tag = soup.find("img")
210	
211	        # check specific attributes
212	        self.assertEqual(img_tag["alt"], "")
213	        self.assertIn("/media/images/test", img_tag["src"])
214	
215	    def test_no_alt_text(self):
216	        block = ImageBlock()
217	        value = {
218	            "image": self.image.id,  # An id is expected
219	            "alt_text": None,  # No alt text passed
220	            "decorative": False,
221	        }
222	
223	        # Invalid state when no alt text is given, and image not marked as decorative
224	        # Should raise a StructBlock validation error
225	        with self.assertRaises(StructBlockValidationError) as context:
226	            block.clean(block.to_python(value))
227	
228	        # Check the error message
229	        self.assertIn(
230	            "Please add some alt text for your image or mark it as decorative",
231	            str(context.exception.block_errors["alt_text"]),
232	        )
233	
234	        cleaned_value = block.clean_deferred(block.to_python(value))
235	        self.assertEqual(cleaned_value, self.image)
236	        self.assertIsNone(cleaned_value.contextual_alt_text)
237	        self.assertFalse(cleaned_value.decorative)
238	
239	        # Required validation should be restored after clean_deferred
240	        with self.assertRaises(StructBlockValidationError) as context:
241	            block.clean(block.to_python(value))
242	
243	    def test_to_python_with_int(self):
244	        block = ImageBlock()
245	        value = block.to_python(self.image.id)
246	
247	        self.assertEqual(value.id, self.image.id)
248	        self.assertEqual(value.contextual_alt_text, "Test image")  # Defaulted to title
249	        self.assertFalse(value.decorative)
250	
251	    def test_to_python_with_dict(self):
252	        block = ImageBlock()
253	        value = {"image": self.image.id, "alt_text": "Sample text", "decorative": False}
254	        result = block.to_python(value)
255	
256	        self.assertEqual(result.id, self.image.id)
257	        self.assertEqual(result.contextual_alt_text, "Sample text")
258	        self.assertFalse(result.decorative)
259	
260	    def test_to_python_with_none(self):
261	        # Like the test_to_python_with_int case, this can occur when a non-required
262	        # ImageChooserBlock has been changed to an ImageBlock
263	        block = ImageBlock(required=False)
264	        value = block.to_python(None)
265	        self.assertIsNone(value)
266	
267	    def test_bulk_to_python_with_empty_list(self):
268	        block = ImageBlock(required=False)
269	        result = block.bulk_to_python([])
270	        self.assertEqual(result, [])
271	
272	    def test_bulk_to_python_with_list_of_none(self):
273	        block = ImageBlock(required=False)
274	        result = block.bulk_to_python([None])
275	        self.assertEqual(result, [None])
276	
277	    def test_bulk_to_python_with_list_of_ints(self):
278	        block = ImageBlock(required=False)
279	        single_image = block.to_python(self.image.id)

Assistant

Tool Call: Read wagtail/blocks/list_block.py Status: Completed

1	import uuid
2	from collections.abc import Mapping, MutableSequence
3	
4	from django import forms
5	from django.core.exceptions import ValidationError
6	from django.forms.utils import ErrorList
7	from django.utils.functional import cached_property
8	from django.utils.html import format_html, format_html_join
9	from django.utils.translation import gettext as _
10	
11	from wagtail.admin.staticfiles import versioned_static
12	from wagtail.admin.telepath import Adapter, register
13	
14	from .base import (
15	    Block,
16	    BoundBlock,
17	    get_error_json_data,
18	    get_error_list_json_data,
19	    get_help_icon,
20	)
21	
22	__all__ = ["ListBlock", "ListBlockValidationError"]
23	
24	
25	class ListBlockValidationError(ValidationError):
26	    def __init__(self, block_errors=None, non_block_errors=None):
27	        # non_block_errors may be passed here as an ErrorList, a plain list (of strings or
28	        # ValidationErrors), or None.
29	        # Normalise it to be an ErrorList, which provides an as_data() method that consistently
30	        # returns a flat list of ValidationError objects.
31	        # (note: iterating over ErrorList itself appears to give a list of message strings,
32	        # but doesn't correctly account for ValidationErrors containing multiple messages)
33	        # (note 2: items in this list are expected to be plain ValidationError instances; there is
34	        # no special treatment of subclasses such as StructBlockValidationError)
35	        self.non_block_errors = ErrorList(non_block_errors)
36	
37	        # block_errors may be passed here as a dict whose keys are the indexes of the child blocks
38	        # with errors, or a list (corresponding to the block value's elements, with None for child
39	        # blocks with no errors)
40	        # Items in this list / dict may be:
41	        #  - a ValidationError instance (potentially a subclass such as StructBlockValidationError)
42	        #  - an ErrorList containing a single ValidationError
43	        #  - a plain list containing a single ValidationError
44	        # All representations will be normalised to a dict of ValidationError instances,
45	        # which is also the preferred format for the original argument to be in.
46	
47	        # normalise to a dict
48	        if block_errors is None:
49	            block_errors_dict = {}
50	        elif isinstance(block_errors, Mapping):
51	            block_errors_dict = block_errors
52	        elif isinstance(block_errors, list):
53	            block_errors_dict = {
54	                index: val for index, val in enumerate(block_errors) if val is not None
55	            }
56	        else:
57	            raise ValueError(
58	                "Expected dict or list for block_errors, got %r" % block_errors
59	            )
60	
61	        # normalise items to ValidationError instances
62	        self.block_errors = {}
63	        for index, val in block_errors_dict.items():
64	            if isinstance(val, ErrorList):
65	                self.block_errors[index] = val.as_data()[0]
66	            elif isinstance(val, list):
67	                self.block_errors[index] = val[0]
68	            else:
69	                self.block_errors[index] = val
70	
71	        super().__init__("Validation error in ListBlock")
72	
73	    def as_json_data(self):
74	        result = {}
75	        if self.non_block_errors:
76	            result["messages"] = get_error_list_json_data(self.non_block_errors)
77	        if self.block_errors:
78	            result["blockErrors"] = {
79	                index: get_error_json_data(error)
80	                for index, error in self.block_errors.items()
81	            }
82	
83	        return result
84	
85	
86	class ListValue(MutableSequence):
87	    """
88	    The native data type used by ListBlock. Behaves as a list of values, but also provides
89	    a bound_blocks property giving access to block IDs
90	    """
91	
92	    class ListChild(BoundBlock):
93	        # a wrapper for list values that keeps track of the associated block type and ID
94	        def __init__(self, *args, **kwargs):
95	            self.original_id = kwargs.pop("id", None)
96	            self.id = self.original_id or str(uuid.uuid4())
97	            super().__init__(*args, **kwargs)
98	
99	        def get_prep_value(self):
100	            return {
101	                "type": "item",
102	                "value": self.block.get_prep_value(self.value),
103	                "id": self.id,
104	            }
105	
106	    def __init__(self, list_block, values=None, bound_blocks=None):
107	        self.list_block = list_block
108	
109	        if bound_blocks is not None:
110	            self.bound_blocks = bound_blocks
111	        elif values is not None:
112	            self.bound_blocks = [
113	                ListValue.ListChild(self.list_block.child_block, value)
114	                for value in values
115	            ]
116	        else:
117	            self.bound_blocks = []
118	
119	    def __getitem__(self, i):
120	        return self.bound_blocks[i].value
121	
122	    def __setitem__(self, i, item):
123	        self.bound_blocks[i] = ListValue.ListChild(self.list_block.child_block, item)
124	
125	    def __delitem__(self, i):
126	        del self.bound_blocks[i]
127	
128	    def __len__(self):
129	        return len(self.bound_blocks)
130	
131	    def insert(self, i, item):
132	        self.bound_blocks.insert(
133	            i, ListValue.ListChild(self.list_block.child_block, item)
134	        )
135	
136	    def __repr__(self):
137	        return f"<ListValue: {[bb.value for bb in self.bound_blocks]!r}>"
138	
139	
140	class ListBlock(Block):
141	    def __init__(self, child_block, search_index=True, **kwargs):
142	        super().__init__(**kwargs)
143	        self.search_index = search_index
144	        if isinstance(child_block, type):
145	            # child_block was passed as a class, so convert it to a block instance
146	            self.child_block = child_block()
147	        else:
148	            self.child_block = child_block
149	
150	        self._has_default = hasattr(self.meta, "default")
151	        if not self._has_default:
152	            # Default to a list consisting of one empty (i.e. default-valued) child item
153	            self.meta.default = [self.child_block.get_default()]
154	
155	    # If a subclass of ListBlock overrides __init__, we cannot assume that the first argument is
156	    # the child block, and thus we cannot rely on the conversion applied in construct_from_lookup /
157	    # deconstruct_with_lookup to be valid. We set a flag attribute on the __init__ method so that
158	    # we can spot this case.
159	    __init__.has_child_block_arg = True
160	
161	    @classmethod
162	    def construct_from_lookup(cls, lookup, *args, **kwargs):
163	        if getattr(cls.__init__, "has_child_block_arg", False):
164	            if args and isinstance(args[0], int):
165	                child_block = lookup.get_block(args[0])
166	                args = (child_block, *args[1:])
167	            else:
168	                child_block_kwarg = kwargs.get("child_block")
169	                if isinstance(child_block_kwarg, int):
170	                    child_block = lookup.get_block(child_block_kwarg)
171	                    kwargs["child_block"] = child_block
172	
173	        return cls(*args, **kwargs)
174	
175	    def value_from_datadict(self, data, files, prefix):
176	        count = int(data["%s-count" % prefix])
177	        child_blocks_with_indexes = []
178	        for i in range(0, count):
179	            if data["%s-%d-deleted" % (prefix, i)]:
180	                continue
181	            child_blocks_with_indexes.append(
182	                (
183	                    int(data["%s-%d-order" % (prefix, i)]),
184	                    ListValue.ListChild(
185	                        self.child_block,
186	                        self.child_block.value_from_datadict(
187	                            data, files, "%s-%d-value" % (prefix, i)
188	                        ),
189	                        id=data.get("%s-%d-id" % (prefix, i)),
190	                    ),
191	                )
192	            )
193	
194	        child_blocks_with_indexes.sort()
195	        return ListValue(self, bound_blocks=[b for (i, b) in child_blocks_with_indexes])
196	
197	    def value_omitted_from_data(self, data, files, prefix):
198	        return ("%s-count" % prefix) not in data
199	
200	    def defer_required_validation(self):
201	        super().defer_required_validation()
202	        self.child_block.defer_required_validation()
203	
204	    def clean(self, value):
205	        # value is expected to be a ListValue, but if it's been assigned through external code it might
206	        # be a plain list; normalise it to a ListValue
207	        value = self.normalize(value)
208	
209	        result = []
210	        block_errors = {}
211	        non_block_errors = ErrorList()
212	        for index, bound_block in enumerate(value.bound_blocks):
213	            try:
214	                result.append(
215	                    ListValue.ListChild(
216	                        self.child_block,
217	                        self.child_block.clean(bound_block.value),
218	                        id=bound_block.id,
219	                    )
220	                )
221	            except ValidationError as e:
222	                block_errors[index] = e
223	
224	        if (
225	            not self.is_deferred_validation
226	            and self.meta.min_num is not None
227	            and self.meta.min_num > len(value)
228	        ):
229	            non_block_errors.append(
230	                ValidationError(
231	                    _("The minimum number of items is %(min_num)d")
232	                    % {"min_num": self.meta.min_num}
233	                )
234	            )
235	
236	        if self.meta.max_num is not None and self.meta.max_num < len(value):
237	            non_block_errors.append(
238	                ValidationError(
239	                    _("The maximum number of items is %(max_num)d")
240	                    % {"max_num": self.meta.max_num}
241	                )
242	            )
243	
244	        if block_errors or non_block_errors:
245	            raise ListBlockValidationError(
246	                block_errors=block_errors, non_block_errors=non_block_errors
247	            )
248	
249	        return ListValue(self, bound_blocks=result)
250	
251	    def restore_deferred_validation(self):
252	        self.child_block.restore_deferred_validation()
253	        super().restore_deferred_validation()
254	
255	    def normalize(self, value):
256	        if isinstance(value, ListValue):
257	            return value
258	        elif isinstance(value, list):
259	            return ListValue(
260	                self, values=[self.child_block.normalize(x) for x in value]
261	            )
262	        else:
263	            raise TypeError(
264	                f"Cannot handle {value!r} (type {type(value)!r}) as a value of a ListBlock"
265	            )
266	
267	    def empty_value(self):
268	        return ListValue(self, values=[])
269	
270	    def _item_is_in_block_format(self, item):
271	        # check a list item retrieved from the database JSON representation to see whether it follows
272	        # the new format (https://github.com/wagtail/rfcs/blob/main/text/065-listblock.md) for a list item
273	        return (
274	            isinstance(item, dict)
275	            and "id" in item
276	            and "value" in item
277	            and item.get("type") == "item"
278	        )
279	
280	    def to_python(self, value):
281	        # 'value' is a list of child block values; use bulk_to_python to convert them all in one go
282	
283	        # get a list of the child block values; this will be the 'value' item of the dict if the list item
284	        # is in the new block format, or the list item itself if in the old format
285	        raw_values = [
286	            item["value"] if self._item_is_in_block_format(item) else item
287	            for item in value
288	        ]
289	        converted_values = self.child_block.bulk_to_python(raw_values)
290	        bound_blocks = []
291	        for i, item in enumerate(value):
292	            if self._item_is_in_block_format(item):
293	                list_item_id = item["id"]
294	            else:
295	                list_item_id = None
296	            bound_blocks.append(
297	                ListValue.ListChild(
298	                    self.child_block, converted_values[i], id=list_item_id
299	                )
300	            )
301	        return ListValue(self, bound_blocks=bound_blocks)
302	
303	    def bulk_to_python(self, values):
304	        # 'values' is a list of lists of child block values; concatenate them into one list so that
305	        # we can make a single call to child_block.bulk_to_python
306	
307	        lengths = []
308	        raw_values = []
309	        for list_stream in values:
310	            lengths.append(len(list_stream))
311	            for list_child in list_stream:
312	                if self._item_is_in_block_format(list_child):
313	                    raw_values.append(list_child["value"])
314	                else:
315	                    raw_values.append(list_child)
316	
317	        converted_values = self.child_block.bulk_to_python(raw_values)
318	
319	        # split converted_values back into sub-lists of the original lengths
320	        result = []
321	        offset = 0
322	        values = list(values)
323	        for i, sublist_len in enumerate(lengths):
324	            bound_blocks = []
325	            for j in range(sublist_len):
326	                if self._item_is_in_block_format(values[i][j]):
327	                    list_item_id = values[i][j]["id"]
328	                else:
329	                    list_item_id = None
330	                bound_blocks.append(
331	                    ListValue.ListChild(
332	                        self.child_block, converted_values[offset + j], id=list_item_id
333	                    )
334	                )
335	
336	            result.append(ListValue(self, bound_blocks=bound_blocks))
337	            offset += sublist_len
338	
339	        return result
340	
341	    def get_prep_value(self, value):
342	        # value is expected to be a ListValue, but if it's been assigned through external code it might
343	        # be a plain list; normalise it to a ListValue
344	        if not isinstance(value, ListValue):
345	            value = ListValue(self, values=value)
346	
347	        prep_value = []
348	
349	        for item in value.bound_blocks:
350	            # Convert the native value back into raw JSONish data
351	            if not item.id:
352	                item.id = str(uuid.uuid4())
353	            prep_value.append(item.get_prep_value())
354	        return prep_value
355	
356	    def get_form_state(self, value):
357	        # value is expected to be a ListValue, but if it's been assigned through external code it might
358	        # be a plain list; normalise it to a ListValue
359	        if not isinstance(value, ListValue):
360	            value = ListValue(self, values=value)
361	
362	        return [
363	            {
364	                "value": self.child_block.get_form_state(block.value),
365	                "id": block.id,
366	            }
367	            for block in value.bound_blocks
368	        ]
369	
370	    def get_api_representation(self, value, context=None):
371	        # recursively call get_api_representation on children and return as a list
372	        return [
373	            self.child_block.get_api_representation(item, context=context)
374	            for item in value
375	        ]
376	
377	    def render_basic(self, value, context=None):
378	        children = format_html_join(
379	            "\n",
380	            "<li>{0}</li>",
381	            [
382	                (self.child_block.render(child_value, context=context),)
383	                for child_value in value
384	            ],
385	        )
386	        return format_html("<ul>{0}</ul>", children)
387	
388	    def get_searchable_content(self, value):
389	        if not self.search_index:
390	            return []
391	        content = []
392	        for child_value in value:
393	            content.extend(self.child_block.get_searchable_content(child_value))
394	
395	        return content
396	
397	    def extract_references(self, value):
398	        for child in value.bound_blocks:
399	            for (
400	                model,
401	                object_id,
402	                model_path,
403	                content_path,
404	            ) in child.block.extract_references(child.value):
405	                model_path = f"item.{model_path}" if model_path else "item"
406	                content_path = (
407	                    f"{child.id}.{content_path}" if content_path else child.id
408	                )
409	                yield model, object_id, model_path, content_path
410	
411	    def get_block_by_content_path(self, value, path_elements):
412	        """
413	        Given a list of elements from a content path, retrieve the block at that path
414	        as a BoundBlock object, or None if the path does not correspond to a valid block.
415	        """
416	        if path_elements:
417	            id, *remaining_elements = path_elements
418	            for child in value.bound_blocks:
419	                if child.id == id:
420	                    return child.block.get_block_by_content_path(
421	                        child.value, remaining_elements
422	                    )
423	        else:
424	            # an empty path refers to the list as a whole
425	            return self.bind(value)
426	
427	    def check(self, **kwargs):
428	        errors = super().check(**kwargs)
429	        errors.extend(self.child_block.check(**kwargs))
430	        return errors
431	
432	    def deconstruct_with_lookup(self, lookup):
433	        path, args, kwargs = super().deconstruct_with_lookup(lookup)
434	        if getattr(self.__init__, "has_child_block_arg", False):
435	            if args and isinstance(args[0], Block):
436	                block_id = lookup.add_block(args[0])
437	                args = (block_id, *args[1:])
438	            else:
439	                child_block = kwargs.get("child_block")
440	                if isinstance(child_block, Block):
441	                    block_id = lookup.add_block(child_block)
442	                    kwargs = kwargs.copy()  # avoid mutating the original kwargs stored in self._constructor_args
443	                    kwargs["child_block"] = block_id
444	
445	        return path, args, kwargs
446	
447	    class Meta:
448	        # No icon specified here, because that depends on the purpose that the
449	        # block is being used for. Feel encouraged to specify an icon in your
450	        # descendant block type
451	        icon = "placeholder"
452	        form_classname = None
453	        min_num = None
454	        max_num = None
455	        collapsed = False
456	
457	    MUTABLE_META_ATTRIBUTES = ["min_num", "max_num"]
458	
459	
460	class ListBlockAdapter(Adapter):
461	    js_constructor = "wagtail.blocks.ListBlock"
462	
463	    def js_args(self, block):
464	        meta = {
465	            "label": block.label,
466	            "description": block.get_description(),
467	            "icon": block.meta.icon,
468	            "blockDefId": block.definition_prefix,
469	            "isPreviewable": block.is_previewable,
470	            "classname": block.meta.form_classname,
471	            "attrs": block.meta.form_attrs or {},
472	            "collapsed": block.meta.collapsed,
473	        }
474	        help_text = getattr(block.meta, "help_text", None)
475	        if help_text:
476	            meta["helpText"] = help_text
477	            meta["helpIcon"] = get_help_icon()
478	
479	        if block.meta.min_num is not None:
480	            meta["minNum"] = block.meta.min_num
481	
482	        if block.meta.max_num is not None:
483	            meta["maxNum"] = block.meta.max_num
484	
485	        return [
486	            block.name,
487	            block.child_block,
488	            block.child_block.get_form_state(block.child_block.get_default()),
489	            meta,
490	        ]
491	
492	    @cached_property
493	    def media(self):
494	        return forms.Media(
495	            js=[
496	                versioned_static("wagtailadmin/js/telepath/blocks.js"),
497	            ]
498	        )
499	
500	
501	register(ListBlockAdapter(), ListBlock)
502	
503	
504	DECONSTRUCT_ALIASES = {
505	    ListBlock: "wagtail.blocks.ListBlock",
506	}
507	

Assistant

Skipping task tracking, single analysis flow. Let me look at the Block init and the field's cached_property:

Tool Call: Read wagtail/blocks/base.py (75 - 164) Status: Completed

75	    def __new__(cls, *args, **kwargs):
76	        # adapted from django.utils.deconstruct.deconstructible; capture the arguments
77	        # so that we can return them in the 'deconstruct' method
78	        obj = super().__new__(cls)
79	        obj._constructor_args = (args, kwargs)
80	        return obj
81	
82	    def __init__(self, **kwargs):
83	        if "classname" in self._constructor_args[1]:
84	            # Adding this so that migrations are not triggered
85	            # when form_classname is used instead of classname
86	            # in the initialisation of the FieldBlock
87	            classname = self._constructor_args[1].pop("classname")
88	            self._constructor_args[1].setdefault("form_classname", classname)
89	
90	        self.meta = self._meta_class()
91	
92	        for attr, value in kwargs.items():
93	            setattr(self.meta, attr, value)
94	
95	        # Increase the creation counter, and save our local copy.
96	        self.creation_counter = Block.creation_counter
97	        Block.creation_counter += 1
98	        self.definition_prefix = "blockdef-%d" % self.creation_counter
99	        Block.definition_registry[self.definition_prefix] = self
100	
101	        self.label = self.meta.label or ""
102	        self.is_deferred_validation = False
103	        """
104	        Indicates whether this block is currently in a state where any validation
105	        that is not required for saving a draft should be deferred.
106	        """
107	
108	    @classmethod
109	    def construct_from_lookup(cls, lookup, *args, **kwargs):
110	        """
111	        See `wagtail.blocks.definition_lookup.BlockDefinitionLookup`.
112	        Construct a block instance from the provided arguments, using the given BlockDefinitionLookup
113	        object to perform any necessary lookups.
114	        """
115	        # In the base implementation, no lookups take place - args / kwargs are passed
116	        # on to the constructor as-is
117	        return cls(*args, **kwargs)
118	
119	    def set_name(self, name):
120	        self.name = name
121	        if not self.meta.label:
122	            self.label = capfirst(force_str(name).replace("_", " "))
123	
124	    def set_meta_options(self, opts):
125	        """
126	        Update this block's meta options (out of the ones designated as mutable) from the given dict.
127	        Used by the StreamField constructor to pass on kwargs that are to be handled by the block,
128	        since the block object has already been created by that point, e.g.:
129	        body = StreamField(SomeStreamBlock(), max_num=5)
130	        """
131	        for attr, value in opts.items():
132	            if attr in self.MUTABLE_META_ATTRIBUTES:
133	                setattr(self.meta, attr, value)
134	            else:
135	                raise TypeError(
136	                    "set_meta_options received unexpected option: %r" % attr
137	                )
138	
139	    def value_from_datadict(self, data, files, prefix):
140	        raise NotImplementedError("%s.value_from_datadict" % self.__class__)
141	
142	    def value_omitted_from_data(self, data, files, name):
143	        """
144	        Used only for top-level blocks wrapped by BlockWidget (i.e.: typically only StreamBlock)
145	        to inform ModelForm logic on Django >=1.10.2 whether the field is absent from the form
146	        submission (and should therefore revert to the field default).
147	        """
148	        return name not in data
149	
150	    def bind(self, value, prefix=None, errors=None):
151	        """
152	        Return a BoundBlock which represents the association of this block definition with a value
153	        and a prefix (and optionally, a ValidationError to be rendered).
154	        BoundBlock primarily exists as a convenience to allow rendering within templates:
155	        bound_block.render() rather than blockdef.render(value, prefix) which can't be called from
156	        within a template.
157	        """
158	        return BoundBlock(self, value, prefix=prefix, errors=errors)
159	
160	    def _evaluate_callable(self, value):
161	        return value() if callable(value) else value
162	
163	    def get_default(self):
164	        """

Assistant

Let me verify my hypothesis - search for where blocks could end up shared:

Tool Call: grep -n "child_blocks = self.base_blocks" Status: Completed

wagtail/blocks/stream_block.py:85: self.child_blocks = self.base_blocks.copy() wagtail/blocks/struct_block.py:247: self.child_blocks = self.base_blocks.copy() wagtail/contrib/typed_table_block/blocks.py:84: self.child_blocks = self.base_blocks.copy()

Tool Call: Read wagtail/blocks/struct_block.py (240 - 279) Status: Completed

240	    def __init__(self, local_blocks=None, search_index=True, **kwargs):
241	        self._constructor_kwargs = kwargs
242	        self.search_index = search_index
243	
244	        super().__init__(**kwargs)
245	
246	        # create a local (shallow) copy of base_blocks so that it can be supplemented by local_blocks
247	        self.child_blocks = self.base_blocks.copy()
248	        if local_blocks:
249	            for name, block in local_blocks:
250	                block.set_name(name)
251	                self.child_blocks[name] = block
252	
253	        self.meta.form_layout = self.get_form_layout()
254	
255	        # Reorder child_blocks to match form_layout, appending any missing
256	        # blocks to the end
257	        sorted_block_names = self.meta.form_layout.get_sorted_block_names()
258	        missing_block_names = self.child_blocks.keys() - set(sorted_block_names)
259	        self.child_blocks = collections.OrderedDict(
260	            (name, self.child_blocks[name])
261	            for name in (sorted_block_names + list(missing_block_names))
262	        )
263	
264	    @classmethod
265	    def construct_from_lookup(cls, lookup, child_blocks, **kwargs):
266	        if child_blocks:
267	            child_blocks = [
268	                (name, lookup.get_block(index)) for name, index in child_blocks
269	            ]
270	        return cls(child_blocks, **kwargs)
271	
272	    def get_default(self):
273	        """
274	        Any default value passed in the constructor or self.meta is going to be a dict
275	        rather than a StructValue; for consistency, we need to convert it to a StructValue
276	        for StructBlock to work with
277	        """
278	        default = self._evaluate_callable(self.meta.default)
279	

Assistant

Tool Call: Read wagtail/blocks/stream_block.py (70 - 169) Status: Completed

70	            result["blockErrors"] = {
71	                index: get_error_json_data(error)
72	                for (index, error) in self.block_errors.items()
73	            }
74	        return result
75	
76	
77	class BaseStreamBlock(Block):
78	    def __init__(self, local_blocks=None, search_index=True, **kwargs):
79	        self._constructor_kwargs = kwargs
80	        self.search_index = search_index
81	
82	        super().__init__(**kwargs)
83	
84	        # create a local (shallow) copy of base_blocks so that it can be supplemented by local_blocks
85	        self.child_blocks = self.base_blocks.copy()
86	        if local_blocks:
87	            for name, block in local_blocks:
88	                block.set_name(name)
89	                self.child_blocks[name] = block
90	
91	    @classmethod
92	    def construct_from_lookup(cls, lookup, child_blocks, **kwargs):
93	        if child_blocks:
94	            child_blocks = [
95	                (name, lookup.get_block(index)) for name, index in child_blocks
96	            ]
97	        return cls(child_blocks, **kwargs)
98	
99	    def empty_value(self, raw_text=None):
100	        return StreamValue(self, [], raw_text=raw_text)
101	
102	    def ordered_child_blocks(self):
103	        """Child blocks in declaration order."""
104	        return self.child_blocks.values()
105	
106	    def grouped_child_blocks(self):
107	        """
108	        The available child block types of this stream block, organised into groups according to
109	        their meta.group attribute.
110	        Returned as an iterable of (group_name, list_of_blocks) tuples
111	        """
112	        grouped_blocks = OrderedDict()
113	
114	        for child_block in self.ordered_child_blocks():
115	            group_name = child_block.meta.group
116	            grouped_blocks.setdefault(group_name, []).append(child_block)
117	
118	        return grouped_blocks.items()
119	
120	    def value_from_datadict(self, data, files, prefix):
121	        count = int(data["%s-count" % prefix])
122	        values_with_indexes = []
123	        for i in range(0, count):
124	            if data["%s-%d-deleted" % (prefix, i)]:
125	                continue
126	            block_type_name = data["%s-%d-type" % (prefix, i)]
127	            try:
128	                child_block = self.child_blocks[block_type_name]
129	            except KeyError:
130	                continue
131	
132	            values_with_indexes.append(
133	                (
134	                    int(data["%s-%d-order" % (prefix, i)]),
135	                    block_type_name,
136	                    child_block.value_from_datadict(
137	                        data, files, "%s-%d-value" % (prefix, i)
138	                    ),
139	                    data.get("%s-%d-id" % (prefix, i)),
140	                )
141	            )
142	
143	        values_with_indexes.sort()
144	        return StreamValue(
145	            self,
146	            [
147	                (child_block_type_name, value, block_id)
148	                for (
149	                    index,
150	                    child_block_type_name,
151	                    value,
152	                    block_id,
153	                ) in values_with_indexes
154	            ],
155	        )
156	
157	    def value_omitted_from_data(self, data, files, prefix):
158	        return ("%s-count" % prefix) not in data
159	
160	    @property
161	    def required(self):
162	        return self.meta.required
163	
164	    def defer_required_validation(self):
165	        super().defer_required_validation()
166	        for child_block in self.child_blocks.values():
167	            child_block.defer_required_validation()
168	
169	    def clean(self, value):

Assistant

Let me also verify by checking the existing tests for this specific pattern:

Tool Call: grep -n -C 3 | head -100 "defer_required_validation|restore_deferred_validation|clean_deferred" wagtail/tests/test_blocks.py Status: Completed

280- with self.assertRaises(ValidationError): 281- block.clean("bar") 282- 283: def test_clean_deferred(self): 284- block = blocks.CharBlock() 285: self.assertEqual(block.clean_deferred(""), "") 286: # Required validation should be restored after clean_deferred 287- with self.assertRaises(ValidationError): 288- block.clean("") 289- 290- def test_required_on_save(self): 291- block = blocks.CharBlock(required_on_save=True) 292- with self.assertRaises(ValidationError): 293: block.clean_deferred("") 294- with self.assertRaises(ValidationError): 295- block.clean("") 296-

458- with self.assertRaises(ValidationError): 459- block.clean("") 460- 461: def test_clean_deferred(self): 462- block = blocks.IntegerBlock() 463: self.assertIsNone(block.clean_deferred("")) 464- 465- def test_render_max_value_validation(self): 466- block = blocks.IntegerBlock(max_value=20)

498- with self.assertRaises(ValidationError): 499- block.clean("") 500- 501: def test_clean_deferred(self): 502- block = blocks.EmailBlock() 503: self.assertEqual(block.clean_deferred(""), "") 504- 505- def test_format_validation(self): 506- block = blocks.EmailBlock()

527- form_state = block.get_form_state(False) 528- self.assertIs(form_state, False) 529- 530: def test_clean_deferred(self): 531- block = blocks.BooleanBlock() 532- with self.assertRaises(ValidationError): 533- block.clean("") 534: self.assertEqual(block.clean_deferred(""), False) 535- 536- 537-class TestBlockQuoteBlock(unittest.TestCase):

570- with self.assertRaises(ValidationError): 571- block.clean("") 572- 573: def test_clean_deferred(self): 574- block = blocks.FloatBlock() 575: self.assertIsNone(block.clean_deferred("")) 576- 577- def test_raises_max_value_validation_error(self): 578- block = blocks.FloatBlock(max_value=20)

628- with self.assertRaises(ValidationError): 629- block.clean("") 630- 631: def test_clean_deferred(self): 632- block = blocks.DecimalBlock() 633: self.assertIsNone(block.clean_deferred("")) 634- 635- def test_raises_max_value_validation_error(self): 636- block = blocks.DecimalBlock(max_value=20)

681- 682- self.assertIn("This field is required.", context.exception.messages) 683- 684: def test_clean_deferred(self): 685- block = blocks.RegexBlock(regex=r"^[0-9]{3}$") 686: self.assertEqual(block.clean_deferred(""), "") 687- 688- def test_raises_custom_required_error(self): 689- test_message = "Oops, you missed a bit."

865- with self.assertRaises(ValidationError): 866- block.clean(RichText("")) 867- 868: def test_clean_deferred(self): 869- block = blocks.RichTextBlock() 870: self.assertEqual(block.clean_deferred(RichText("")), RichText("")) 871- 872- def test_validate_non_required_richtext_block(self): 873- block = blocks.RichTextBlock(required=False)

1032- with self.assertRaises(ValidationError): 1033- block.clean(None) 1034- 1035: def test_clean_deferred(self): 1036- block = blocks.ChoiceBlock(choices=[("tea", "Tea"), ("coffee", "Coffee")]) 1037- 1038- # Should still raise a ValidationError for invalid choices 1039- with self.assertRaises(ValidationError): 1040: block.clean_deferred("cendol") 1041-

[Showing results with pagination = limit: 100]

Tool Call: grep -n -C 3 | head -100 "defer_required_validation|restore_deferred_validation|clean_deferred" wagtail/tests/test_blocks.py Status: Completed

1042: self.assertEqual(block.clean_deferred(""), "") 1043: self.assertEqual(block.clean_deferred(None), "") 1044- 1045- def test_adapt_non_required_choice_block(self): 1046- block = blocks.ChoiceBlock(

1529- with self.assertRaises(ValidationError): 1530- block.clean(None) 1531- 1532: def test_clean_deferred(self): 1533- block = blocks.MultipleChoiceBlock( 1534- choices=[("tea", "Tea"), ("coffee", "Coffee")] 1535- ) 1536- 1537- # Should still raise a ValidationError for invalid choices 1538- with self.assertRaises(ValidationError): 1539: block.clean_deferred(["cendol"]) 1540- 1541: self.assertEqual(block.clean_deferred([]), []) 1542: self.assertEqual(block.clean_deferred(""), []) 1543: self.assertEqual(block.clean_deferred(None), []) 1544- 1545- def test_adapt_non_required_multiple_choice_block(self): 1546- block = blocks.MultipleChoiceBlock(

1982- with self.assertRaises(ValidationError): 1983- block.clean(mark_safe("")) 1984- 1985: def test_clean_deferred(self): 1986- block = blocks.RawHTMLBlock() 1987: self.assertEqual(block.clean_deferred(""), "") 1988: self.assertEqual(block.clean_deferred(mark_safe("")), "") 1989- 1990- def test_clean_nonrequired_field(self): 1991- block = blocks.RawHTMLBlock(required=False)

3005- with self.assertRaises(ValidationError): 3006- block.clean(value) 3007- 3008: def test_clean_deferred_propagates_to_child_blocks(self): 3009- block = blocks.StructBlock( 3010- [ 3011- ("title", blocks.CharBlock()),

3014- ) 3015- 3016- value = block.to_python({"title": "", "link": ""}) 3017: clean_value = block.clean_deferred(value) 3018- self.assertIsInstance(clean_value, blocks.StructValue) 3019- self.assertEqual(clean_value["title"], "") 3020- self.assertEqual(clean_value["link"], "") 3021- 3022: # Required validation should be restored after clean_deferred 3023- value = block.to_python({"title": "Torchbox", "link": "not a url"}) 3024- with self.assertRaises(ValidationError): 3025: block.clean_deferred(value) 3026- 3027- value = block.to_python({"title": "", "link": "https://example.com"}) 3028- with self.assertRaises(ValidationError):

3051- with self.assertRaises(ValidationError): 3052- block.clean(bad_data) 3053- 3054: # When using clean_deferred, both blocks allowed to be empty 3055: bad_data_deferred = block.clean_deferred(bad_data) 3056- self.assertIsNone(bad_data_deferred["page"]) 3057- self.assertEqual(bad_data_deferred["url"], "") 3058- 3059- good_data = {"page": None, "url": "https://wagtail.org/"} 3060- self.assertEqual(block.clean(good_data), good_data) 3061: self.assertEqual(block.clean_deferred(good_data), good_data) 3062- 3063- def test_bound_blocks_are_available_on_template(self): 3064- """

3218- with self.assertRaises(ValidationError): 3219- block.clean(value) 3220- with self.assertRaises(ValidationError): 3221: block.clean_deferred(value) 3222- 3223- value = block.to_python({"title": "", "link": ""}) 3224- with self.assertRaises(ValidationError): 3225- block.clean(value) 3226: clean_value = block.clean_deferred(value) 3227- self.assertIsInstance(clean_value, CustomStructValue) 3228- self.assertEqual(clean_value["title"], "") 3229- self.assertEqual(clean_value["link"], "")

3807- self.assertEqual( 3808- cleaned_block_val.bound_blocks[0].id, "11111111-1111-1111-1111-111111111111" 3809- ) 3810: cleaned_block_val = block.clean_deferred(block_val) 3811- self.assertEqual( 3812- cleaned_block_val.bound_blocks[0].id, "11111111-1111-1111-1111-111111111111" 3813- )

3824- "messages": ["The minimum number of items is 2"], 3825- }, 3826- ) 3827: # When using clean_deferred, min_num is not enforced

[Showing results with pagination = limit: 100, offset: 100]

Assistant

Let me check whether clean_deferred might be invoked redundantly from the form/field level for the same block:

Tool Call: grep -n -C 2 "clean_deferred|defer_required_validation" wagtail/admin Status: Completed

No matches found

Assistant

Still single-thread analysis, skipping tasks.

Tool Call: grep -n -C 2 "clean_deferred" Status: Completed

Output too large (24KB). Full output saved to: /Users/sabdullah/.claude/projects/-Users-sabdullah-Code-github-wagtail-docker-wagtail-develop-wagtail/471c18ac-2a7b-4c13-b5bb-479292b16e17/tool-results/toolu_01BPhhtT5NKAtSH9gvyKDB6h.txt

Preview (first 2KB): /Users/sabdullah/Code/github/wagtail/docker-wagtail-develop/wagtail/wagtail/tests/test_blocks.py-281- block.clean("bar") /Users/sabdullah/Code/github/wagtail/docker-wagtail-develop/wagtail/wagtail/tests/test_blocks.py-282- wagtail/tests/test_blocks.py:283: def test_clean_deferred(self): /Users/sabdullah/Code/github/wagtail/docker-wagtail-develop/wagtail/wagtail/tests/test_blocks.py-284- block = blocks.CharBlock() wagtail/tests/test_blocks.py:285: self.assertEqual(block.clean_deferred(""), "") wagtail/tests/test_blocks.py:286: # Required validation should be restored after clean_deferred wagtail/tests/test_blocks.py-287- with self.assertRaises(ValidationError): /Users/sabdullah/Code/github/wagtail/docker-wagtail-develop/wagtail/wagtail/tests/test_blocks.py-288- block.clean("")

/Users/sabdullah/Code/github/wagtail/docker-wagtail-develop/wagtail/wagtail/tests/test_blocks.py-291- block = blocks.CharBlock(required_on_save=True) wagtail/tests/test_blocks.py-292- with self.assertRaises(ValidationError): wagtail/tests/test_blocks.py:293: block.clean_deferred("") wagtail/tests/test_blocks.py-294- with self.assertRaises(ValidationError): /Users/sabdullah/Code/github/wagtail/docker-wagtail-develop/wagtail/wagtail/tests/test_blocks.py-295- block.clean("")

/Users/sabdullah/Code/github/wagtail/docker-wagtail-develop/wagtail/wagtail/tests/test_blocks.py-459- block.clean("") /Users/sabdullah/Code/github/wagtail/docker-wagtail-develop/wagtail/wagtail/tests/test_blocks.py-460- wagtail/tests/test_blocks.py:461: def test_clean_deferred(self): /Users/sabdullah/Code/github/wagtail/docker-wagtail-develop/wagtail/wagtail/tests/test_blocks.py-462- block = blocks.IntegerBlock() wagtail/tests/test_blocks.py:463: self.assertIsNone(block.clean_deferred("")) /Users/sabdullah/Code/github/wagtail/docker-wagtail-develop/wagtail/wagtail/tests/test_blocks.py-464- ...

Tool Call: grep -l "clean_deferred" Status: Completed

Found 5 files wagtail/tests/test_blocks.py wagtail/blocks/base.py wagtail/images/tests/test_blocks.py wagtail/contrib/typed_table_block/tests.py wagtail/contrib/table_block/tests.py

Assistant

I have enough to diagnose this. Let me verify my understanding of one more detail — whether the _original_required overwrite scenario is symmetric between defer and restore:

Tool calls

Tool Call: Read wagtail/blocks/field_block.py (60 - 84) Status: Completed

60	        )
61	
62	    def value_omitted_from_data(self, data, files, prefix):
63	        return self.field.widget.value_omitted_from_data(data, files, prefix)
64	
65	    def defer_required_validation(self):
66	        super().defer_required_validation()
67	        self._original_required = self.required
68	        self.field.required = False or getattr(self.meta, "required_on_save", False)
69	
70	    def clean(self, value):
71	        # We need an annoying value_for_form -> value_from_form round trip here to account for
72	        # the possibility that the form field is set up to validate a different value type to
73	        # the one this block works with natively
74	        return self.value_from_form(self.field.clean(self.value_for_form(value)))
75	
76	    def restore_deferred_validation(self):
77	        self.field.required = self._original_required
78	        super().restore_deferred_validation()
79	
80	    @property
81	    def required(self):
82	        # a FieldBlock is required if and only if its underlying form field is required
83	        return self.field.required
84	

Assistant

Let me check one more piece — whether StructBlock subclasses with declared fields are indeed instantiated multiple times in StreamBlock in the wagtail testapp:

Tool calls

*Tool Call: grep -n | head -20 "class.StructBlock):" wagtail/test Status: Completed

wagtail/test/streamfield_migrations/models.py:8:class SimpleStructBlock(StructBlock): wagtail/test/streamfield_migrations/models.py:18:class NestedStructBlock(StructBlock): wagtail/test/testapp/blocks.py:4:class LinkBlock(blocks.StructBlock): wagtail/test/testapp/blocks.py:26:class SectionBlock(blocks.StructBlock):

Assistant

Now I have enough to give a complete analysis.

Root cause analysis

I traced the defer/restore flow through wagtail/blocks/base.py, wagtail/blocks/stream_block.py, wagtail/blocks/struct_block.py, and wagtail/blocks/field_block.py. The bug is in FieldBlock:

# wagtail/blocks/field_block.py:65-78
def defer_required_validation(self):
    super().defer_required_validation()
    self._original_required = self.required          # captures self.field.required
    self.field.required = False or getattr(self.meta, "required_on_save", False)

def restore_deferred_validation(self):
    self.field.required = self._original_required   # restores from saved value
    super().restore_deferred_validation()

defer_required_validation is not idempotent. On a second call without an intervening restore_deferred_validation, it overwrites self._original_required with the already-modified (False) value of self.field.required. The subsequent restore_deferred_validation then "restores" field.required to False — permanently, for the lifetime of the process.

Why it triggers for ImageChooserBlock inside a StructBlock inside a StreamBlock

StructBlock.__init__ does a shallow copy of base_blocks (wagtail/blocks/struct_block.py:247):

self.child_blocks = self.base_blocks.copy()

base_blocks is built by DeclarativeSubBlocksMetaclass (wagtail/blocks/base.py:684-694) at class definition time — so all class-level declared sub-blocks (e.g. image = ImageChooserBlock(required=True)) are constructed once and stored as class attributes. Every instance of that StructBlock subclass shares the same child-block instances in its child_blocks dict.

So in a perfectly ordinary schema like:

class HeroBlock(StructBlock):
    image = ImageChooserBlock(required=True)
    title = CharBlock()

class MyStream(StreamBlock):
    hero = HeroBlock()
    featured = HeroBlock()      # second instance of the same class

hero.child_blocks["image"] and featured.child_blocks["image"] are the same ImageChooserBlock instance (the one stored on HeroBlock.base_blocks).

When BlockField.clean calls MyStream.clean_deferred(...) (wagtail/blocks/base.py:811), the cascade in BaseStreamBlock.defer_required_validation (wagtail/blocks/stream_block.py:164-167) iterates self.child_blocks.values() and recurses into each HeroBlock. Each HeroBlock in turn calls defer_required_validation() on its image child (wagtail/blocks/struct_block.py:304-307). Because that image child is the same object for both HeroBlock instances, FieldBlock.defer_required_validation runs twice on it:

  1. First call: _original_required = True; field.required = False.
  2. Second call: _original_required = self.required = self.field.required = False (corrupted); field.required = False.

Then on restore (wagtail/blocks/stream_block.py:241-244wagtail/blocks/struct_block.py:323-326wagtail/blocks/field_block.py:76-78):

  1. field.required = self._original_required = False.

The shared ImageChooserBlock is now permanently required=False for the rest of the process. Subsequent regular saves (without deferred validation) will silently accept empty images. This matches the report: "stays that way even after validation."

Why ImageBlock itself doesn't hit this

ImageBlock.__init__ (wagtail/images/blocks.py:76-89) constructs fresh ImageChooserBlock/BooleanBlock/CharBlock instances on every call and passes them via local_blocks, so two ImageBlock() siblings each get their own child blocks. But any user-defined StructBlock subclass that uses the standard declarative style (image = ImageChooserBlock(required=True)) and is instantiated more than once in the same StreamBlock will hit it.

Other variants of the same root cause

The same bug fires whenever a single FieldBlock instance is reachable from two paths in the schema during one clean_deferred cycle:

  • The same StructBlock subclass used as two StreamBlock child types (above).
  • A single block object explicitly aliased into two slots (e.g. s1 = shared; s2 = shared in a StreamBlock/StructBlock body, or the same instance passed twice into local_blocks).
  • Concurrent requests on different StreamFields whose blocks ultimately reference the same class-level child instance — but that's a race, not the primary report.

The fix shape (for when you're ready)

FieldBlock.defer_required_validation should be idempotent — e.g. only stash _original_required if self.is_deferred_validation is not already True:

def defer_required_validation(self):
    if self.is_deferred_validation:
        return
    super().defer_required_validation()
    self._original_required = self.required
    self.field.required = bool(getattr(self.meta, "required_on_save", False))

restore_deferred_validation is already safe to call repeatedly once defer is idempotent (the second call writes _original_required_original_required, no corruption). The same idempotency guard is worth applying to the StructBlock/StreamBlock/ListBlock variants for consistency, but the actual data-corrupting bug lives only in FieldBlock.

No code changes made — analysis only, per your instruction.

User

I assume this is not specific to ImageChooserBlock then, i.e. it can be replicated even with a simple CharBlock?

Assistant

Tool Call: grep -n "^class CharBlock" wagtail/blocks/field_block.py Status: Completed

158:class CharBlock(FieldBlock):

Assistant

Yes — the bug lives in FieldBlock.defer_required_validation (wagtail/blocks/field_block.py:65-68), which CharBlock inherits unchanged. ImageChooserBlock is just one path that exercises it; any FieldBlock subclass exhibits the same corruption when the same instance is deferred twice.

A minimal repro using CharBlock:

class HeroBlock(StructBlock):
    title = CharBlock(required=True)   # class-level — shared across instances

class MyStream(StreamBlock):
    hero = HeroBlock()
    featured = HeroBlock()             # second instance, same class

stream = MyStream()
stream.clean_deferred(stream.to_python([]))

# Both HeroBlock instances reference the same CharBlock object.
title_block = stream.child_blocks["hero"].child_blocks["title"]
assert title_block is stream.child_blocks["featured"].child_blocks["title"]
assert title_block.required is True   # FAILS — it's now False permanently

Trace through FieldBlock.defer_required_validation:

  1. First call (via hero): _original_required = True, field.required = False.
  2. Second call (via featured, same instance): _original_required = self.required = False, field.required = False.
  3. Restore (both paths): field.required = _original_required = False.

The block stays non-required for the rest of the process. The only thing special about ImageChooserBlock in the user's report is that they happened to notice it there — the underlying defect is in the base FieldBlock.

User (checkpoint)

OK write a fix and the test (using the CharBlock example) in wagtail.tests.test_blocks as well.

To run the test, you can run:

DATABASE_NAME=default.sqlite3 ./runtests.py --verbosity=1 --parallel --keepdb --exclude-tag=transaction wagtail.tests.test_blocks

(make sure the venv has been activated)

Assistant

Tool Call: ToolSearch Status: Completed

Tool: TaskCreate

Tool: TaskUpdate

Assistant

Applying the fix to FieldBlock. The minimal change is to guard against re-entry by checking is_deferred_validation before stashing _original_required.

Tool Call: Edit wagtail/blocks/field_block.py Status: Completed

Diff: /Users/sabdullah/Code/github/wagtail/docker-wagtail-develop/wagtail/wagtail/blocks/field_block.py

        return self.field.widget.value_omitted_from_data(data, files, prefix)

    def defer_required_validation(self):
        if self.is_deferred_validation:
            # Already deferred; avoid overwriting the saved _original_required
            # with the already-modified value, which would otherwise leak the
            # deferred (non-required) state past restore_deferred_validation.
            # This can happen when the same FieldBlock instance is reachable
            # via multiple paths in the block tree - e.g. when a StructBlock
            # subclass declares child blocks at class level and is then used
            # as more than one child type within a StreamBlock.
            return
        super().defer_required_validation()
        self._original_required = self.required
        self.field.required = bool(getattr(self.meta, "required_on_save", False))

    def clean(self, value):
        # We need an annoying value_for_form -> value_from_form round trip here to account for

Assistant

Tool Call: Edit wagtail/blocks/field_block.py Status: Completed

Diff: /Users/sabdullah/Code/github/wagtail/docker-wagtail-develop/wagtail/wagtail/blocks/field_block.py


    def defer_required_validation(self):
        if self.is_deferred_validation:
            # Same FieldBlock instance can be reached via multiple paths in
            # the block tree (e.g. a StructBlock subclass declaring child
            # blocks at class level then used as more than one child type
            # within a StreamBlock). Re-entering would clobber the saved
            # `_original_required` with the already-deferred value.
            return
        super().defer_required_validation()
        self._original_required = self.required

Assistant

Looking for a good place to add the test:

Tool calls

Tool Call: Read wagtail/tests/test_blocks.py (3000 - 3079) Status: Completed

3000	        value = block.to_python({"title": "Torchbox", "link": "not a url"})
3001	        with self.assertRaises(ValidationError):
3002	            block.clean(value)
3003	
3004	        value = block.to_python({"title": "", "link": ""})
3005	        with self.assertRaises(ValidationError):
3006	            block.clean(value)
3007	
3008	    def test_clean_deferred_propagates_to_child_blocks(self):
3009	        block = blocks.StructBlock(
3010	            [
3011	                ("title", blocks.CharBlock()),
3012	                ("link", blocks.URLBlock()),
3013	            ]
3014	        )
3015	
3016	        value = block.to_python({"title": "", "link": ""})
3017	        clean_value = block.clean_deferred(value)
3018	        self.assertIsInstance(clean_value, blocks.StructValue)
3019	        self.assertEqual(clean_value["title"], "")
3020	        self.assertEqual(clean_value["link"], "")
3021	
3022	        # Required validation should be restored after clean_deferred
3023	        value = block.to_python({"title": "Torchbox", "link": "not a url"})
3024	        with self.assertRaises(ValidationError):
3025	            block.clean_deferred(value)
3026	
3027	        value = block.to_python({"title": "", "link": "https://example.com"})
3028	        with self.assertRaises(ValidationError):
3029	            block.clean(value)
3030	
3031	    def test_non_block_validation_error(self):
3032	        class LinkBlock(blocks.StructBlock):
3033	            page = blocks.PageChooserBlock(required=False)
3034	            url = blocks.URLBlock(required=False)
3035	
3036	            def clean(self, value):
3037	                result = super().clean(value)
3038	                # Allow both blocks to be empty when saving drafts
3039	                if self.is_deferred_validation:
3040	                    return result
3041	                if not (result["page"] or result["url"]):
3042	                    raise StructBlockValidationError(
3043	                        non_block_errors=ErrorList(
3044	                            ["Either page or URL must be specified"]
3045	                        )
3046	                    )
3047	                return result
3048	
3049	        block = LinkBlock()
3050	        bad_data = {"page": None, "url": ""}
3051	        with self.assertRaises(ValidationError):
3052	            block.clean(bad_data)
3053	
3054	        # When using clean_deferred, both blocks allowed to be empty
3055	        bad_data_deferred = block.clean_deferred(bad_data)
3056	        self.assertIsNone(bad_data_deferred["page"])
3057	        self.assertEqual(bad_data_deferred["url"], "")
3058	
3059	        good_data = {"page": None, "url": "https://wagtail.org/"}
3060	        self.assertEqual(block.clean(good_data), good_data)
3061	        self.assertEqual(block.clean_deferred(good_data), good_data)
3062	
3063	    def test_bound_blocks_are_available_on_template(self):
3064	        """
3065	        Test that we are able to use value.bound_blocks within templates
3066	        to access a child block's own HTML rendering
3067	        """
3068	        block = SectionBlock()
3069	        value = block.to_python({"title": "Hello", "body": "<i>italic</i> world"})
3070	        result = block.render(value)
3071	        self.assertEqual(result, """<h1>Hello</h1><i>italic</i> world""")
3072	
3073	    def test_render_block_with_extra_context(self):
3074	        block = SectionBlock()
3075	        value = block.to_python({"title": "Bonjour", "body": "monde <i>italique</i>"})
3076	        result = block.render(value, context={"language": "fr"})
3077	        self.assertEqual(result, """<h1 lang="fr">Bonjour</h1>monde <i>italique</i>""")
3078	
3079	    def test_render_structvalue(self):

Assistant

Tool calls

**Tool Call: grep -n "^class.*Test.StreamBlock|^class Test.Stream" wagtail/tests/test_blocks.py Status: Completed

4092:class TestStreamBlock(WagtailTestUtils, SimpleTestCase): 5440:class TestNormalizeStreamBlock(SimpleTestCase): 5616:class TestStreamBlockWithFixtures(TestCase):

Tool Call: grep -n "clean_deferred" wagtail/tests/test_blocks.py Status: Completed

No matches found

[Showing results with pagination = offset: 4000]

Tool Call: Read wagtail/tests/test_blocks.py (4085 - 4144) Status: Completed

4085	            [
4086	                (Page, str(christmas_page.id), "item", "block1"),
4087	                (Page, str(saint_patrick_page.id), "item", "block2"),
4088	            ],
4089	        )
4090	
4091	
4092	class TestStreamBlock(WagtailTestUtils, SimpleTestCase):
4093	    def test_initialisation(self):
4094	        block = blocks.StreamBlock(
4095	            [
4096	                ("heading", blocks.CharBlock()),
4097	                ("paragraph", blocks.CharBlock()),
4098	            ]
4099	        )
4100	
4101	        self.assertEqual(list(block.child_blocks.keys()), ["heading", "paragraph"])
4102	
4103	    def test_initialisation_with_binary_string_names(self):
4104	        # migrations will sometimes write out names as binary strings, just to keep us on our toes
4105	        block = blocks.StreamBlock(
4106	            [
4107	                (b"heading", blocks.CharBlock()),
4108	                (b"paragraph", blocks.CharBlock()),
4109	            ]
4110	        )
4111	
4112	        self.assertEqual(list(block.child_blocks.keys()), [b"heading", b"paragraph"])
4113	
4114	    def test_initialisation_from_subclass(self):
4115	        class ArticleBlock(blocks.StreamBlock):
4116	            heading = blocks.CharBlock()
4117	            paragraph = blocks.CharBlock()
4118	
4119	        block = ArticleBlock()
4120	
4121	        self.assertEqual(list(block.child_blocks.keys()), ["heading", "paragraph"])
4122	
4123	    def test_initialisation_from_subclass_with_extra(self):
4124	        class ArticleBlock(blocks.StreamBlock):
4125	            heading = blocks.CharBlock()
4126	            paragraph = blocks.CharBlock()
4127	
4128	        block = ArticleBlock([("intro", blocks.CharBlock())])
4129	
4130	        self.assertEqual(
4131	            list(block.child_blocks.keys()), ["heading", "paragraph", "intro"]
4132	        )
4133	
4134	    def test_initialisation_with_multiple_subclassses(self):
4135	        class ArticleBlock(blocks.StreamBlock):
4136	            heading = blocks.CharBlock()
4137	            paragraph = blocks.CharBlock()
4138	
4139	        class ArticleWithIntroBlock(ArticleBlock):
4140	            intro = blocks.CharBlock()
4141	
4142	        block = ArticleWithIntroBlock()
4143	
4144	        self.assertEqual(

Assistant

Tool calls

**Tool Call: grep -n "def test.*clean|def test.defer|def test.valid" wagtail/tests/test_blocks.py Status: Completed

No matches found

[Showing results with pagination = offset: 4090]

Tool Call: grep -n | head -80 "def test_" wagtail/tests/test_blocks.py Status: Completed

No matches found

[Showing results with pagination = offset: 4400]

Tool Call: grep -n | head -300 "def test_" wagtail/tests/test_blocks.py Status: Completed

60: def test_normalize(self): 65: def test_block_definition_registry(self): 70: def test_block_is_previewable(self): 161: def test_charfield_render(self): 167: def test_block_definition_registry(self): 174: def test_charfield_render_with_template(self): 180: def test_charblock_adapter(self): 205: def test_charblock_adapter_form_classname(self): 223: def test_charblock_adapter_attrs(self): 244: def test_charfield_render_with_template_with_extra_context(self): 255: def test_charfield_get_form_state(self): 261: def test_charfield_searchable_content(self): 267: def test_search_index_searchable_content(self): 273: def test_charfield_with_validator(self): 283: def test_clean_deferred(self): 290: def test_required_on_save(self): 297: def test_charfield_with_callable_default(self): 304: def test_choicefield_render(self): 318: def test_adapt_custom_choicefield(self): 357: def test_searchable_content(self): 370: def test_form_handling_is_independent_of_serialisation(self): 388: def test_prepare_value_called(self): 443: def test_type(self): 449: def test_render(self): 455: def test_render_required_error(self): 461: def test_clean_deferred(self): 465: def test_render_max_value_validation(self): 471: def test_render_min_value_validation(self): 477: def test_render_with_validator(self): 489: def test_render(self): 495: def test_render_required_error(self): 501: def test_clean_deferred(self): 505: def test_format_validation(self): 511: def test_render_with_validator(self): 523: def test_get_form_state(self): 530: def test_clean_deferred(self): 538: def test_render(self): 544: def test_render_with_validator(self): 556: def test_type(self): 561: def test_render(self): 567: def test_raises_required_error(self): 573: def test_clean_deferred(self): 577: def test_raises_max_value_validation_error(self): 583: def test_raises_min_value_validation_error(self): 589: def test_render_with_validator(self): 601: def test_type(self): 606: def test_type_to_python(self): 613: def test_type_to_python_decimal_none_value(self): 618: def test_render(self): 625: def test_raises_required_error(self): 631: def test_clean_deferred(self): 635: def test_raises_max_value_validation_error(self): 641: def test_raises_min_value_validation_error(self): 647: def test_render_with_validator(self): 657: def test_round_trip_to_db_preserves_type(self): 669: def test_render(self): 676: def test_raises_required_error(self): 684: def test_clean_deferred(self): 688: def test_raises_custom_required_error(self): 702: def test_raises_validation_error(self): 710: def test_raises_custom_error_message(self): 721: def test_render_with_validator(self): 735: def test_get_default_with_fallback_value(self): 740: def test_get_default_with_default_none(self): 745: def test_get_default_with_empty_string(self): 750: def test_get_default_with_nonempty_string(self): 755: def test_get_default_with_localized_string(self): 760: def test_get_default_with_richtext_value(self): 767: def test_get_default_with_callable(self): 775: def test_render(self): 783: def test_adapter(self): 809: def test_adapter_with_draftail(self): 835: def test_adapter_with_max_length(self): 862: def test_validate_required_richtext_block(self): 868: def test_clean_deferred(self): 872: def test_validate_non_required_richtext_block(self): 878: def test_render_with_validator(self): 888: def test_validate_max_length(self): 903: def test_validate_min_length(self): 919: def test_get_searchable_content(self): 934: def test_search_index_get_searchable_content(self): 946: def test_get_searchable_content_whitespace(self): 952: def test_extract_references(self): 958: def test_normalize(self): 973: def test_adapt_choice_block(self): 1001: def test_choice_block_with_default(self): 1007: def test_adapt_choice_block_with_callable_choices(self): 1022: def test_validate_required_choice_block(self): 1035: def test_clean_deferred(self): 1045: def test_adapt_non_required_choice_block(self): 1055: def test_validate_non_required_choice_block(self): 1067: def test_adapt_choice_block_with_existing_blank_choice(self): 1081: def test_adapt_choice_block_with_existing_blank_choice_and_with_callable_choices( 1097: def test_named_groups_without_blank_option(self): 1141: def test_to_python_with_optgroups_preserves_type(self): 1167: def test_multiple_choice_block_preserves_types(self): 1183: def test_multiple_choice_block_with_optgroups_preserves_types(self): 1210: def test_named_groups_with_blank_option(self): 1257: def test_subclassing(self): 1291: def test_searchable_content(self): 1300: def test_search_index_searchable_content(self): 1310: def test_searchable_content_with_callable_choices(self): 1320: def test_optgroup_searchable_content(self): 1341: def test_invalid_searchable_content(self): 1350: def test_searchable_content_with_lazy_translation(self): 1363: def test_optgroup_searchable_content_with_lazy_translation(self): 1388: def test_deconstruct_with_callable_choices(self): 1421: def test_render_with_validator(self): 1437: def test_get_form_state(self): 1442: def test_get_form_state_with_radio_widget(self): 1449: def test_choiceblock_preserves_integer_type(self): 1455: def test_choiceblock_does_not_coerce_string_choices(self): 1468: def test_adapt_multiple_choice_block(self): 1497: def test_multiple_choice_block_with_default(self): 1503: def test_adapt_multiple_choice_block_with_callable_choices(self): 1517: def test_validate_required_multiple_choice_block(self): 1532: def test_clean_deferred(self): 1545: def test_adapt_non_required_multiple_choice_block(self): 1555: def test_validate_non_required_multiple_choice_block(self): 1567: def test_adapt_multiple_choice_block_with_existing_blank_choice(self): 1581: def test_adapt_multiple_choice_block_with_existing_blank_choice_and_with_callable_choices( 1597: def test_named_groups_without_blank_option(self): 1640: def test_named_groups_with_blank_option(self): 1686: def test_subclassing(self): 1719: def test_searchable_content(self): 1728: def test_search_index_searchable_content(self): 1738: def test_searchable_content_with_callable_choices(self): 1748: def test_optgroup_searchable_content(self): 1769: def test_invalid_searchable_content(self): 1778: def test_searchable_content_with_lazy_translation(self): 1791: def test_optgroup_searchable_content_with_lazy_translation(self): 1816: def test_deconstruct_with_callable_choices(self): 1848: def test_render_with_validator(self): 1864: def test_get_form_state(self): 1871: def test_get_form_state_with_checkbox_widget(self): 1881: def test_get_default_with_fallback_value(self): 1886: def test_get_default_with_none(self): 1891: def test_get_default_with_empty_string(self): 1896: def test_get_default_with_nonempty_string(self): 1901: def test_get_default_with_callable(self): 1909: def test_serialize(self): 1915: def test_deserialize(self): 1921: def test_render(self): 1927: def test_get_form_state(self): 1933: def test_adapt(self): 1958: def test_form_response(self): 1966: def test_value_omitted_from_data(self): 1976: def test_clean_required_field(self): 1985: def test_clean_deferred(self): 1990: def test_clean_nonrequired_field(self): 2000: def test_render_with_validator(self): 2012: def test_set_template_with_meta(self): 2020: def test_set_template_with_constructor(self): 2024: def test_set_template_with_constructor_overrides_meta(self): 2032: def test_meta_nested_inheritance(self): 2050: def test_meta_multi_inheritance(self): 2090: def test_adapt(self): 2120: def test_adapt_adjacent_block_groups_with_same_headings(self): 2162: def test_initialisation(self): 2172: def test_initialisation_from_subclass(self): 2181: def test_initialisation_from_subclass_with_extra(self): 2192: def test_initialisation_with_multiple_subclassses(self): 2206: def test_initialisation_with_mixins(self): 2236: def test_render(self): 2263: def test_get_api_representation_calls_same_method_on_fields_with_context(self): 2291: def test_render_unknown_field(self): 2315: def test_render_bound_block(self): 2333: def test_get_form_context(self): 2358: def test_get_form_context_with_settings(self): 2399: def test_check_form_template_with_nested_block_groups(self): 2424: def test_adapt(self): 2460: def test_adapt_with_form_template(self): 2490: def test_adapt_with_form_attrs(self): 2514: def test_adapt_with_form_attrs_on_meta(self): 2539: def test_adapt_with_form_template_jinja(self): 2569: def test_get_default(self): 2578: def test_get_default_with_callable(self): 2597: def test_adapt_with_help_text_on_meta(self): 2631: def test_adapt_with_help_text_as_argument(self): 2662: def test_adapt_with_collapsed(self): 2677: def test_adapt_with_list_form_layout(self): 2697: def test_adapt_with_settings_blocks(self): 2721: def test_with_nested_blockgroups_in_form_layout(self): 2752: def test_with_missing_blocks_in_form_layout(self): 2786: def test_adapt_with_get_form_layout(self): 2838: def test_adapt_label_format(self): 2853: def test_searchable_content(self): 2870: def test_value_from_datadict(self): 2889: def test_value_omitted_from_data(self): 2905: def test_default_is_returned_as_structvalue(self): 2927: def test_default_value_is_distinct_instance(self): 2958: def test_bulk_to_python_returns_distinct_default_instances(self): 2985: def test_clean(self): 3008: def test_clean_deferred_propagates_to_child_blocks(self): 3031: def test_non_block_validation_error(self): 3063: def test_bound_blocks_are_available_on_template(self): 3073: def test_render_block_with_extra_context(self): 3079: def test_render_structvalue(self): 3092: def test_str_structvalue(self): 3111: def test_render_structvalue_with_extra_context(self): 3117: def test_copy_structvalue(self): 3130: def test_normalize_base_cases(self): 3141: def test_recursive_normalize(self): 3179: def test_initialisation(self): 3231: def test_initialisation_from_subclass(self): 3256: def test_initialisation_with_multiple_subclassses(self): 3290: def test_initialisation_with_mixins(self): 3323: def test_value_property(self): 3341: def test_render_with_template(self): 3364: def test_normalize(self): 3381: def test_normalize_incorrect_value_class(self): 3408: def test_initialise_with_class(self): 3414: def test_initialise_with_instance(self): 3439: def test_render_uses_ul(self): 3445: def test_render_uses_li(self): 3451: def test_render_calls_block_render_on_children(self): 3464: def test_render_passes_context_to_children(self): 3482: def test_get_api_representation_calls_same_method_on_children_with_context(self): 3500: def test_adapt(self): 3527: def test_adapt_with_min_num_max_num(self): 3556: def test_searchable_content(self): 3577: def test_value_omitted_from_data(self): 3600: def test_id_from_form_submission_is_preserved(self): 3618: def test_ordering_in_form_submission_uses_order_field(self): 3636: def test_ordering_in_form_submission_is_numeric(self): 3654: def test_can_specify_default(self): 3661: def test_default_callable(self): 3669: def test_default_default(self): 3682: def test_default_value_is_distinct_instance(self): 3707: def test_adapt_with_classname_via_kwarg(self): 3733: def test_adapt_with_classname_via_class_meta(self): 3763: def test_adapt_with_form_attrs(self): 3790: def test_clean_preserves_block_ids(self): 3815: def test_min_num_validation_errors(self): 3837: def test_max_num_validation_errors(self): 3865: def test_clean_deferred_propagates_to_child_blocks(self): 3882: def test_unpack_old_database_format(self): 3897: def test_bulk_unpack_old_database_format(self): 3913: def test_unpack_new_database_format(self): 3941: def test_bulk_unpack_new_database_format(self): 3979: def test_assign_listblock_with_list(self): 3994: def test_normalize_base_case(self): 4007: def test_normalize_empty(self): 4013: def test_recursive_normalize(self): 4034: def test_calls_child_bulk_to_python_when_available(self): 4044: def test_bulk_to_python(self): 4061: def test_extract_references(self): 4093: def test_initialisation(self): 4103: def test_initialisation_with_binary_string_names(self): 4114: def test_initialisation_from_subclass(self): 4123: def test_initialisation_from_subclass_with_extra(self): 4134: def test_initialisation_with_multiple_subclassses(self): 4148: def test_initialisation_with_mixins(self): 4179: def test_field_has_changed(self): 4200: def test_required_raises_an_exception_if_empty(self): 4211: def test_required_does_not_raise_an_exception_if_not_empty(self): 4222: def test_not_required_does_not_raise_an_exception_if_empty(self): 4234: def test_required_by_default(self): 4255: def test_get_api_representation_calls_same_method_on_children_with_context(self): 4290: def test_render(self): 4318: def test_render_unknown_type(self): 4339: def test_render_calls_block_render_on_children(self): 4366: def test_render_passes_context_to_children(self): 4400: def test_render_on_stream_child_uses_child_template(self): 4426: def test_can_pass_context_to_stream_child_template(self): 4444: def test_adapt(self): 4488: def test_value_omitted_from_data(self): 4516: def test_clean_deferred_propagates_to_child_blocks(self): 4532: def test_validation_errors(self): 4574: def test_min_num_validation_errors(self): 4598: def test_max_num_validation_errors(self): 4635: def test_block_counts_min_validation_errors(self): 4673: def test_block_counts_max_validation_errors(self): 4717: def test_ordering_in_form_submission_uses_order_field(self): 4741: def test_ordering_in_form_submission_is_numeric(self): 4763: def test_searchable_content(self): 4797: def test_meta_default(self): 4822: def test_constructor_default(self): 4847: def test_callable_default(self): 4870: def test_stream_value_equality(self): 4884: def test_adapt_considers_group_attribute(self): 4922: def test_adapt_preserves_group_order_by_declaration_order(self): 4949: def test_value_from_datadict(self): 5015: def test_get_prep_value_not_lazy(self): 5022: def test_get_prep_value_is_lazy(self): 5056: def test_get_prep_value_nested_streamblocks_not_lazy(self): 5063: def test_get_prep_value_nested_streamblocks_is_lazy(self): 5081: def test_modifications_to_stream_child_id_are_saved(self): 5103: def test_modifications_to_stream_child_value_are_saved(self): 5125: def test_set_streamvalue_item(self): 5147: def test_delete_streamvalue_item(self): 5168: def test_insert_streamvalue_item(self): 5191: def test_append_streamvalue_item(self): 5214: def test_streamvalue_raw_data(self): 5285: def test_adapt_with_form_attrs(self): 5308: def test_adapt_with_classname_via_kwarg(self): 5340: def test_block_names(self): 5407: def test_adapt_with_classname_via_class_meta(self): 5464: def test_normalize_empty_stream(self): 5473: def test_normalize_base_case(self):

[Showing results with pagination = limit: 300]

Tool Call: Read wagtail/tests/test_blocks.py (4516 - 4575) Status: Completed

4516	    def test_clean_deferred_propagates_to_child_blocks(self):
4517	        block = blocks.StreamBlock([("paragraph", blocks.CharBlock())])
4518	        value = block.to_python([{"type": "paragraph", "value": ""}])
4519	
4520	        # When using clean_deferred, blocks with empty values are allowed
4521	        cleaned_block = block.clean_deferred(value)
4522	        self.assertEqual(cleaned_block, value)
4523	
4524	        # Required validation should be restored after clean_deferred
4525	        with self.assertRaises(blocks.StreamBlockValidationError) as catcher:
4526	            block.clean(value)
4527	        self.assertEqual(
4528	            catcher.exception.as_json_data(),
4529	            {"blockErrors": {0: {"messages": ["This field is required."]}}},
4530	        )
4531	
4532	    def test_validation_errors(self):
4533	        class ValidatedBlock(blocks.StreamBlock):
4534	            char = blocks.CharBlock()
4535	            url = blocks.URLBlock()
4536	
4537	        block = ValidatedBlock()
4538	
4539	        value = blocks.StreamValue(
4540	            block,
4541	            [
4542	                ("char", ""),
4543	                ("char", "foo"),
4544	                ("url", "http://example.com/"),
4545	                ("url", "not a url"),
4546	            ],
4547	        )
4548	
4549	        with self.assertRaises(ValidationError) as catcher:
4550	            block.clean(value)
4551	        self.assertEqual(
4552	            catcher.exception.as_json_data(),
4553	            {
4554	                "blockErrors": {
4555	                    0: {"messages": ["This field is required."]},
4556	                    3: {"messages": ["Enter a valid URL."]},
4557	                }
4558	            },
4559	        )
4560	
4561	        # When using clean_deferred, required validation is not enforced,
4562	        # but other validation (e.g. URL validation) is still enforced
4563	        with self.assertRaises(ValidationError) as catcher:
4564	            block.clean_deferred(value)
4565	        self.assertEqual(
4566	            catcher.exception.as_json_data(),
4567	            {
4568	                "blockErrors": {
4569	                    3: {"messages": ["Enter a valid URL."]},
4570	                }
4571	            },
4572	        )
4573	
4574	    def test_min_num_validation_errors(self):
4575	        class ValidatedBlock(blocks.StreamBlock):

Assistant

Now I'll add the regression test right after the existing test_clean_deferred_propagates_to_child_blocks in the TestStreamBlock class.

Tool Call: Edit wagtail/tests/test_blocks.py Status: Completed

Diff: /Users/sabdullah/Code/github/wagtail/docker-wagtail-develop/wagtail/wagtail/tests/test_blocks.py

            {"blockErrors": {0: {"messages": ["This field is required."]}}},
        )

    def test_clean_deferred_restores_required_for_shared_field_blocks(self):
        # When a StructBlock subclass declares child blocks at class level,
        # those instances are shared across every instantiation of the
        # subclass. If the same subclass is used as more than one StreamBlock
        # child type, defer_required_validation is called more than once on
        # the same FieldBlock during a single clean_deferred cycle. The
        # second call must not clobber the saved required state, otherwise
        # the field stays non-required after restore.
        class HeroBlock(blocks.StructBlock):
            title = blocks.CharBlock(required=True)

        class MyStream(blocks.StreamBlock):
            hero = HeroBlock()
            featured = HeroBlock()

        block = MyStream()

        # Sanity check: both HeroBlock instances reference the same
        # class-level CharBlock instance.
        shared_title = block.child_blocks["hero"].child_blocks["title"]
        self.assertIs(
            shared_title,
            block.child_blocks["featured"].child_blocks["title"],
        )
        self.assertTrue(shared_title.required)

        value = block.to_python([{"type": "hero", "value": {"title": ""}}])
        block.clean_deferred(value)

        # After clean_deferred, the shared CharBlock must be restored to
        # required=True, and a subsequent non-deferred clean must reject
        # empty values.
        self.assertTrue(shared_title.required)
        self.assertTrue(shared_title.field.required)
        with self.assertRaises(blocks.StreamBlockValidationError) as catcher:
            block.clean(value)
        self.assertEqual(
            catcher.exception.as_json_data(),
            {
                "blockErrors": {
                    0: {
                        "blockErrors": {
                            "title": {"messages": ["This field is required."]},
                        }
                    }
                }
            },
        )

    def test_validation_errors(self):
        class ValidatedBlock(blocks.StreamBlock):
            char = blocks.CharBlock()

Tool Call: ls runtests.py 2>&1; which python 2>&1; ls .venv/bin/activate venv/bin/activate 2>&1 Status: Failed

Exit code 1
runtests.py
/Users/sabdullah/.local/share/mise/installs/python/3.14/bin/python
ls: .venv/bin/activate: No such file or directory
venv/bin/activate

Tool Call: source venv/bin/activate && DATABASE_NAME=default.sqlite3 ./runtests.py --verbosity=1 --parallel --keepdb --exclude-tag=transaction wagtail.tests.test_blocks.TestStreamBlock.test_clean_deferred_restores_required_for_shared_field_blocks 2>&1 | tail -50 Status: Completed

Terminal:

/Users/sabdullah/Code/github/wagtail/docker-wagtail-develop/wagtail/wagtail/admin/rich_text/editors/draftail/__init__.py:39: RuntimeWarning: Draftail received an unknown feature 'made-up-feature'.
  warnings.warn(
System check identified some issues:

WARNINGS:
tests.TaggedChildPage: (wagtailsearch.W001) Core Page fields missing in `search_fields`
	HINT: Ensure that TaggedChildPage extends the Page model search fields `search_fields = Page.search_fields + [...]`
tests.TaggedGrandchildPage: (wagtailsearch.W001) Core Page fields missing in `search_fields`
	HINT: Ensure that TaggedGrandchildPage extends the Page model search fields `search_fields = Page.search_fields + [...]`
tests.TaggedPage: (wagtailsearch.W001) Core Page fields missing in `search_fields`
	HINT: Ensure that TaggedPage extends the Page model search fields `search_fields = Page.search_fields + [...]`

System check identified 3 issues (0 silenced).
.
----------------------------------------------------------------------
Ran 1 test in 0.000s

OK
Found 1 test(s).

Assistant

The new regression test passes. Now let me also verify it would have failed before the fix, by temporarily reverting the fix:

Tool Call: git diff wagtail/blocks/field_block.py | head -40 Status: Completed

Terminal:

diff --git a/wagtail/blocks/field_block.py b/wagtail/blocks/field_block.py
index 18c20bb3d1..b1ba780543 100644
--- a/wagtail/blocks/field_block.py
+++ b/wagtail/blocks/field_block.py
@@ -63,9 +63,16 @@ class FieldBlock(Block):
         return self.field.widget.value_omitted_from_data(data, files, prefix)

     def defer_required_validation(self):
+        if self.is_deferred_validation:
+            # Same FieldBlock instance can be reached via multiple paths in
+            # the block tree (e.g. a StructBlock subclass declaring child
+            # blocks at class level then used as more than one child type
+            # within a StreamBlock). Re-entering would clobber the saved
+            # `_original_required` with the already-deferred value.
+            return
         super().defer_required_validation()
         self._original_required = self.required
-        self.field.required = False or getattr(self.meta, "required_on_save", False)
+        self.field.required = bool(getattr(self.meta, "required_on_save", False))

     def clean(self, value):
         # We need an annoying value_for_form -> value_from_form round trip here to account for

Tool Call: git stash push -- wagtail/blocks/field_block.py 2>&1 Status: Completed

Terminal:

Saved working directory and index state WIP on fix/fieldblock-deferred-validation: 913b388a5d Fix blank choice tests for Django 6.1 (#14196)




Assistant

Now run the test against the unfixed code to confirm it fails:

Tool Call: source venv/bin/activate && DATABASE_NAME=default.sqlite3 ./runtests.py --verbosity=2 --keepdb --exclude-tag=transaction wagtail.tests.test_blocks.TestStreamBlock.test_clean_deferred_restores_required_for_shared_field_blocks 2>&1 | tail -40 Status: Completed

Terminal:

/Users/sabdullah/Code/github/wagtail/docker-wagtail-develop/wagtail/wagtail/admin/rich_text/editors/draftail/__init__.py:39: RuntimeWarning: Draftail received an unknown feature 'made-up-feature'.
  warnings.warn(
System check identified some issues:

WARNINGS:
tests.TaggedChildPage: (wagtailsearch.W001) Core Page fields missing in `search_fields`
	HINT: Ensure that TaggedChildPage extends the Page model search fields `search_fields = Page.search_fields + [...]`
tests.TaggedGrandchildPage: (wagtailsearch.W001) Core Page fields missing in `search_fields`
	HINT: Ensure that TaggedGrandchildPage extends the Page model search fields `search_fields = Page.search_fields + [...]`
tests.TaggedPage: (wagtailsearch.W001) Core Page fields missing in `search_fields`
	HINT: Ensure that TaggedPage extends the Page model search fields `search_fields = Page.search_fields + [...]`

System check identified 3 issues (0 silenced).
test_clean_deferred_restores_required_for_shared_field_blocks (wagtail.tests.test_blocks.TestStreamBlock.test_clean_deferred_restores_required_for_shared_field_blocks) ... FAIL

======================================================================
FAIL: test_clean_deferred_restores_required_for_shared_field_blocks (wagtail.tests.test_blocks.TestStreamBlock.test_clean_deferred_restores_required_for_shared_field_blocks)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/sabdullah/Code/github/wagtail/docker-wagtail-develop/wagtail/wagtail/tests/test_blocks.py", line 4564, in test_clean_deferred_restores_required_for_shared_field_blocks
    self.assertTrue(shared_title.required)
    ~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^
AssertionError: False is not true

----------------------------------------------------------------------
Ran 1 test in 0.001s

FAILED (failures=1)
Excluding test tag(s): transaction.
Found 1 test(s).
Skipping setup of unused database(s): default.

Assistant

The test correctly fails without the fix at exactly the expected assertion (shared_title.required is False after clean_deferred). Now restore the fix:

Tool Call: git stash pop 2>&1 Status: Completed

Terminal:

On branch fix/fieldblock-deferred-validation
Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
	modified:   wagtail/blocks/field_block.py
	modified:   wagtail/tests/test_blocks.py

Untracked files:
  (use "git add <file>..." to include in what will be committed)
	populate_fake_page_log_entries.py

no changes added to commit (use "git add" and/or "git commit -a")
Dropped refs/stash@{0} (7f477ccc12d0ed44512eac0c93de3adfc292f09c)

Assistant

Now run the full wagtail.tests.test_blocks suite as requested to make sure nothing else regressed:

Tool Call: source venv/bin/activate && DATABASE_NAME=default.sqlite3 ./runtests.py --verbosity=1 --parallel --keepdb --exclude-tag=transaction wagtail.tests.test_blocks 2>&1 | tail -30 Status: Completed

Terminal:

/Users/sabdullah/Code/github/wagtail/docker-wagtail-develop/wagtail/wagtail/admin/rich_text/editors/draftail/__init__.py:39: RuntimeWarning: Draftail received an unknown feature 'made-up-feature'.
  warnings.warn(
/Users/sabdullah/Code/github/wagtail/docker-wagtail-develop/wagtail/wagtail/admin/rich_text/editors/draftail/__init__.py:39: RuntimeWarning: Draftail received an unknown feature 'made-up-feature'.
  warnings.warn(
/Users/sabdullah/Code/github/wagtail/docker-wagtail-develop/wagtail/wagtail/admin/rich_text/editors/draftail/__init__.py:39: RuntimeWarning: Draftail received an unknown feature 'made-up-feature'.
  warnings.warn(
/Users/sabdullah/Code/github/wagtail/docker-wagtail-develop/wagtail/wagtail/admin/rich_text/editors/draftail/__init__.py:39: RuntimeWarning: Draftail received an unknown feature 'made-up-feature'.
  warnings.warn(
/Users/sabdullah/Code/github/wagtail/docker-wagtail-develop/wagtail/wagtail/admin/rich_text/editors/draftail/__init__.py:39: RuntimeWarning: Draftail received an unknown feature 'made-up-feature'.
  warnings.warn(
/Users/sabdullah/Code/github/wagtail/docker-wagtail-develop/wagtail/wagtail/admin/rich_text/editors/draftail/__init__.py:39: RuntimeWarning: Draftail received an unknown feature 'made-up-feature'.
  warnings.warn(
/Users/sabdullah/Code/github/wagtail/docker-wagtail-develop/wagtail/wagtail/admin/rich_text/editors/draftail/__init__.py:39: RuntimeWarning: Draftail received an unknown feature 'made-up-feature'.
  warnings.warn(
/Users/sabdullah/Code/github/wagtail/docker-wagtail-develop/wagtail/wagtail/admin/rich_text/editors/draftail/__init__.py:39: RuntimeWarning: Draftail received an unknown feature 'made-up-feature'.
  warnings.warn(
..................................................x............................................................................................................................................................................................................................................................................................................................................
----------------------------------------------------------------------
Ran 383 tests in 2.394s

OK (expected failures=1)
Preserving test database for alias 'default'...
Preserving test database for alias 'default'...
Preserving test database for alias 'default'...
Preserving test database for alias 'default'...
Preserving test database for alias 'default'...
Preserving test database for alias 'default'...
Preserving test database for alias 'default'...
Preserving test database for alias 'default'...
Preserving test database for alias 'default'...

Assistant

All 383 tests pass (the 1 expected failure is pre-existing).Summary:

  • Fix (wagtail/blocks/field_block.py:65-75): made FieldBlock.defer_required_validation idempotent by returning early when is_deferred_validation is already True. This prevents the second defer call from clobbering _original_required with the already-deferred (False) value. Also tidied the False or getattr(...) expression to bool(getattr(...)) since the literal False was a no-op.
  • Regression test (wagtail/tests/test_blocks.py, TestStreamBlock.test_clean_deferred_restores_required_for_shared_field_blocks): builds a StreamBlock with two HeroBlock child types (a StructBlock subclass declaring title = CharBlock(required=True) at class level), confirms the two HeroBlock instances share the same CharBlock object, runs clean_deferred, then asserts that required is restored to True and that a subsequent clean() rejects an empty title.
  • Verification: confirmed the test fails on the unfixed code (assertion shared_title.required is True fails after clean_deferred) and passes once the fix is in place. Full wagtail.tests.test_blocks suite — 383 tests — passes.

Plan

  • Make FieldBlock.defer_required_validation idempotent
  • Add regression test in wagtail.tests.test_blocks
  • Run the test suite

User

Do you think this is the best way to fix the issue? What other approaches might be better?

Assistant

Honest answer: the guard is a reasonable tactical fix, but it has a code smell — it makes the leaf method tolerate redundant calls rather than addressing why the cascade visits the same instance more than once. A few alternatives worth considering:

1. Push the idempotency guard up to every cascading block type. Add the same if self.is_deferred_validation: return at the top of StructBlock, BaseStreamBlock, and ListBlock's defer_required_validation too. Symmetric, avoids the redundant cascade work, and also covers the case where a shared StructBlock instance (not just a shared FieldBlock) is referenced from two slots. Costs: a documented contract that all subclasses must call super() first and accept that it might no-op.

2. Manage defer/restore at the top-level only. clean_deferred already runs only on the top-level block. Have it walk the tree once, deduplicate by id(), snapshot each FieldBlock's field.required, mutate, run clean, then restore from the snapshot. The recursive defer_required_validation / restore_deferred_validation machinery could shrink or go away. Cleanest separation of concerns, but a bigger refactor and a public API change (those methods are documented).

3. Don't share declared child blocks across StructBlock instances. Deep-copy in StructBlock.__init__ so every instance owns its own children. This fixes the root cause — the shared mutable state — and would protect any future mutation-style methods too. But it changes long-standing identity semantics, costs memory, and risks breaking code that relies on identity (telepath, the block definition registry, equality checks, possibly migration code).

My recommendation: ship the current fix because it's the smallest correct change and matches how the existing form-level defer_required_fields already self-guards (wagtail/admin/forms/models.py:143-145). Optionally extend the guard to StructBlock/StreamBlock/ListBlock for symmetry and to skip redundant work. I'd avoid (2) and (3) unless they're motivated by something beyond this bug.

User

Instead of adding if self.is_deferred_validation: return on the FieldBlock, would it be better to check if child_block.is_deferred_validation before calling child_block.defer_required_validation in defer_required_validation of container blocks e.g. StreamBlock, ListBlock, StructBlock?

Assistant

Yes, I think you're right — Option B (guarding at the call site in container blocks) is the better fix. A few reasons:

It addresses the root cause. The bug is that the cascade structurally visits the same instance more than once when blocks are reachable via multiple paths. The right place to dedupe is at the iteration point, not by asking every leaf to tolerate redundant calls.

It generalizes naturally. With the FieldBlock guard, a shared StructBlock instance still gets its defer_required_validation invoked twice — which then re-iterates its children twice. Each leaf is individually protected, but the traversal does redundant work. The call-site guard short-circuits the whole subtree on the second visit, regardless of whether the shared node is a FieldBlock, StructBlock, ListBlock, or custom container.

The contract is cleaner. "Don't visit a node that's already in deferred state" is a clear, single-sentence rule that lives in the cascade. The Option-A alternative requires every FieldBlock subclass that overrides defer_required_validation to remember the early-return guard or it silently reintroduces the bug.

One thing worth mirroring — for symmetry, the corresponding restore methods should skip children whose is_deferred_validation is already False. Without it, restore is still correct (because _original_required is no longer corrupted), but the call would do redundant writes; with it, the contract is symmetric: "don't visit nodes outside the deferred set."

One mild caveat. A custom container block (third-party code) that doesn't follow this pattern would re-expose the bug. With Option A, custom containers are protected for free because the leaves self-guard. So there's a defense-in-depth argument for keeping the leaf check too — but that's a weak reason to leave it in if we don't trust our own cascade. I'd remove the FieldBlock guard when adding the call-site guards, so we have one mechanism, not two.

Want me to swap to that approach?

User (checkpoint)

Yep I mean we also do the is_deferred_validation check in restore_deferred_validation as well. I don't think custom container blocks are a concern for now. This is a newly introduced feature, and it's unlikely people have customised defer_required_validation and restore_deferred_validation methods.

Please swap to the new approach.

Assistant

Let me check whether typed_table_block is also a container that needs the same treatment:

Tool Call: grep -n -C 3 "defer_required_validation|restore_deferred_validation" wagtail/contrib/typed_table_block/blocks.py Status: Completed

361- ) 362- yield model, object_id, model_path, content_path 363- 364: def defer_required_validation(self): 365: super().defer_required_validation() 366- for child_block in self.child_blocks.values(): 367: child_block.defer_required_validation() 368- 369: def restore_deferred_validation(self): 370- for child_block in self.child_blocks.values(): 371: child_block.restore_deferred_validation() 372: super().restore_deferred_validation() 373- 374- class Meta: 375- default = None

Assistant

Now I'll make the swap. First, revert the FieldBlock guard:

Tool Call: Edit wagtail/blocks/field_block.py Status: Failed

<tool_use_error>File has been modified since read, either by the user or by a linter. Read it again before attempting to write it.</tool_use_error>

Tool Call: Read wagtail/blocks/field_block.py (60 - 84) Status: Completed

60	        )
61	
62	    def value_omitted_from_data(self, data, files, prefix):
63	        return self.field.widget.value_omitted_from_data(data, files, prefix)
64	
65	    def defer_required_validation(self):
66	        if self.is_deferred_validation:
67	            # Same FieldBlock instance can be reached via multiple paths in
68	            # the block tree (e.g. a StructBlock subclass declaring child
69	            # blocks at class level then used as more than one child type
70	            # within a StreamBlock). Re-entering would clobber the saved
71	            # `_original_required` with the already-deferred value.
72	            return
73	        super().defer_required_validation()
74	        self._original_required = self.required
75	        self.field.required = bool(getattr(self.meta, "required_on_save", False))
76	
77	    def clean(self, value):
78	        # We need an annoying value_for_form -> value_from_form round trip here to account for
79	        # the possibility that the form field is set up to validate a different value type to
80	        # the one this block works with natively
81	        return self.value_from_form(self.field.clean(self.value_for_form(value)))
82	
83	    def restore_deferred_validation(self):
84	        self.field.required = self._original_required

Tool Call: Edit wagtail/blocks/field_block.py Status: Completed

Diff: /Users/sabdullah/Code/github/wagtail/docker-wagtail-develop/wagtail/wagtail/blocks/field_block.py

        return self.field.widget.value_omitted_from_data(data, files, prefix)

    def defer_required_validation(self):
        super().defer_required_validation()
        self._original_required = self.required
        self.field.required = False or getattr(self.meta, "required_on_save", False)

    def clean(self, value):
        # We need an annoying value_for_form -> value_from_form round trip here to account for

Assistant

Now add the guard at the call site in each container block. Starting with BaseStreamBlock:

Tool Call: Edit wagtail/blocks/stream_block.py Status: Completed

Diff: /Users/sabdullah/Code/github/wagtail/docker-wagtail-develop/wagtail/wagtail/blocks/stream_block.py

    def defer_required_validation(self):
        super().defer_required_validation()
        for child_block in self.child_blocks.values():
            # The same block instance may be reachable via multiple paths in
            # the tree (e.g. a StructBlock subclass declares child blocks at
            # class level, and is used as more than one StreamBlock child
            # type). Skip blocks that are already deferred to avoid clobbering
            # state captured on the first visit.
            if not child_block.is_deferred_validation:
                child_block.defer_required_validation()

    def clean(self, value):
        required = self.required and not self.is_deferred_validation

Assistant

Let me clean up the stale tasks and add new ones for this approach:Now BaseStreamBlock.restore_deferred_validation:

Tool Call: Edit wagtail/blocks/stream_block.py Status: Completed

Diff: /Users/sabdullah/Code/github/wagtail/docker-wagtail-develop/wagtail/wagtail/blocks/stream_block.py


    def restore_deferred_validation(self):
        for child_block in self.child_blocks.values():
            if child_block.is_deferred_validation:
                child_block.restore_deferred_validation()
        super().restore_deferred_validation()

    def to_python(self, value):

Assistant

Now BaseStructBlock:

Tool Call: Edit wagtail/blocks/struct_block.py Status: Completed

Diff: /Users/sabdullah/Code/github/wagtail/docker-wagtail-develop/wagtail/wagtail/blocks/struct_block.py

    def defer_required_validation(self):
        super().defer_required_validation()
        for block in self.child_blocks.values():
            if not block.is_deferred_validation:
                block.defer_required_validation()

    def clean(self, value):
        result = []  # build up a list of (name, value) tuples to be passed to the StructValue constructor

Tool Call: Edit wagtail/blocks/struct_block.py Status: Completed

Diff: /Users/sabdullah/Code/github/wagtail/docker-wagtail-develop/wagtail/wagtail/blocks/struct_block.py


    def restore_deferred_validation(self):
        for block in self.child_blocks.values():
            if block.is_deferred_validation:
                block.restore_deferred_validation()
        super().restore_deferred_validation()

    def to_python(self, value):

Assistant

Now ListBlock:

Tool Call: Edit wagtail/blocks/list_block.py Status: Completed

Diff: /Users/sabdullah/Code/github/wagtail/docker-wagtail-develop/wagtail/wagtail/blocks/list_block.py


    def defer_required_validation(self):
        super().defer_required_validation()
        if not self.child_block.is_deferred_validation:
            self.child_block.defer_required_validation()

    def clean(self, value):
        # value is expected to be a ListValue, but if it's been assigned through external code it might

Tool Call: Edit wagtail/blocks/list_block.py Status: Completed

Diff: /Users/sabdullah/Code/github/wagtail/docker-wagtail-develop/wagtail/wagtail/blocks/list_block.py

        return ListValue(self, bound_blocks=result)

    def restore_deferred_validation(self):
        if self.child_block.is_deferred_validation:
            self.child_block.restore_deferred_validation()
        super().restore_deferred_validation()

    def normalize(self, value):

Assistant

Now TypedTableBlock:

Tool Call: Edit wagtail/contrib/typed_table_block/blocks.py Status: Failed

<tool_use_error>File has not been read yet. Read it first before writing to it.</tool_use_error>

Assistant

Tasks are up to date. Reading the typed_table_block file before editing:

Tool Call: Read wagtail/contrib/typed_table_block/blocks.py (360 - 379) Status: Completed

360	                        else f"rows.{row_index}.values.{col_index}"
361	                    )
362	                    yield model, object_id, model_path, content_path
363	
364	    def defer_required_validation(self):
365	        super().defer_required_validation()
366	        for child_block in self.child_blocks.values():
367	            child_block.defer_required_validation()
368	
369	    def restore_deferred_validation(self):
370	        for child_block in self.child_blocks.values():
371	            child_block.restore_deferred_validation()
372	        super().restore_deferred_validation()
373	
374	    class Meta:
375	        default = None
376	        icon = "table"
377	
378	
379	class TypedTableBlock(BaseTypedTableBlock, metaclass=DeclarativeSubBlocksMetaclass):

Tool Call: Edit wagtail/contrib/typed_table_block/blocks.py Status: Completed

Diff: /Users/sabdullah/Code/github/wagtail/docker-wagtail-develop/wagtail/wagtail/contrib/typed_table_block/blocks.py

    def defer_required_validation(self):
        super().defer_required_validation()
        for child_block in self.child_blocks.values():
            if not child_block.is_deferred_validation:
                child_block.defer_required_validation()

    def restore_deferred_validation(self):
        for child_block in self.child_blocks.values():
            if child_block.is_deferred_validation:
                child_block.restore_deferred_validation()
        super().restore_deferred_validation()

    class Meta:

Assistant

The comment in stream_block.py is a bit verbose given the same pattern now repeats. Let me tighten it:

Tool Call: Edit wagtail/blocks/stream_block.py Status: Completed

Diff: /Users/sabdullah/Code/github/wagtail/docker-wagtail-develop/wagtail/wagtail/blocks/stream_block.py

    def defer_required_validation(self):
        super().defer_required_validation()
        for child_block in self.child_blocks.values():
            # A block may be reachable via multiple paths in the tree (e.g.
            # a StructBlock subclass declares child blocks at class level
            # and is used as more than one StreamBlock child type). Skip
            # blocks that are already deferred to avoid clobbering the
            # state captured on the first visit.
            if not child_block.is_deferred_validation:
                child_block.defer_required_validation()

Assistant

Run the tests:

Tool Call: source venv/bin/activate && DATABASE_NAME=default.sqlite3 ./runtests.py --verbosity=1 --parallel --keepdb --exclude-tag=transaction wagtail.tests.test_blocks wagtail.images.tests.test_blocks wagtail.contrib.typed_table_block.tests wagtail.contrib.table_block.tests 2>&1 | tail -20 Status: Completed

Terminal:

/Users/sabdullah/Code/github/wagtail/docker-wagtail-develop/wagtail/wagtail/admin/rich_text/editors/draftail/__init__.py:39: RuntimeWarning: Draftail received an unknown feature 'made-up-feature'.
  warnings.warn(
/Users/sabdullah/Code/github/wagtail/docker-wagtail-develop/wagtail/wagtail/admin/rich_text/editors/draftail/__init__.py:39: RuntimeWarning: Draftail received an unknown feature 'made-up-feature'.
  warnings.warn(
/Users/sabdullah/Code/github/wagtail/docker-wagtail-develop/wagtail/wagtail/admin/rich_text/editors/draftail/__init__.py:39: RuntimeWarning: Draftail received an unknown feature 'made-up-feature'.
  warnings.warn(
..................................................x.......................................................................................................................................................................................................................................................................................................................................................................................................................x
----------------------------------------------------------------------
Ran 459 tests in 3.236s

OK (expected failures=2)
Preserving test database for alias 'default'...
Preserving test database for alias 'default'...
Preserving test database for alias 'default'...
Preserving test database for alias 'default'...
Preserving test database for alias 'default'...
Preserving test database for alias 'default'...
Preserving test database for alias 'default'...
Preserving test database for alias 'default'...
Preserving test database for alias 'default'...

Assistant

All 459 tests pass across test_blocks, images.tests.test_blocks, typed_table_block.tests, and table_block.tests (2 pre-existing expected failures).

Tool Call: git diff wagtail/blocks wagtail/contrib/typed_table_block/blocks.py Status: Completed

Terminal:

diff --git a/wagtail/blocks/list_block.py b/wagtail/blocks/list_block.py
index 6ae6c65eb6..a0491cb42c 100644
--- a/wagtail/blocks/list_block.py
+++ b/wagtail/blocks/list_block.py
@@ -199,7 +199,8 @@ class ListBlock(Block):

     def defer_required_validation(self):
         super().defer_required_validation()
-        self.child_block.defer_required_validation()
+        if not self.child_block.is_deferred_validation:
+            self.child_block.defer_required_validation()

     def clean(self, value):
         # value is expected to be a ListValue, but if it's been assigned through external code it might
@@ -249,7 +250,8 @@ class ListBlock(Block):
         return ListValue(self, bound_blocks=result)

     def restore_deferred_validation(self):
-        self.child_block.restore_deferred_validation()
+        if self.child_block.is_deferred_validation:
+            self.child_block.restore_deferred_validation()
         super().restore_deferred_validation()

     def normalize(self, value):
diff --git a/wagtail/blocks/stream_block.py b/wagtail/blocks/stream_block.py
index 8eafdc0523..65f209156c 100644
--- a/wagtail/blocks/stream_block.py
+++ b/wagtail/blocks/stream_block.py
@@ -164,7 +164,13 @@ class BaseStreamBlock(Block):
     def defer_required_validation(self):
         super().defer_required_validation()
         for child_block in self.child_blocks.values():
-            child_block.defer_required_validation()
+            # A block may be reachable via multiple paths in the tree (e.g.
+            # a StructBlock subclass declares child blocks at class level
+            # and is used as more than one StreamBlock child type). Skip
+            # blocks that are already deferred to avoid clobbering the
+            # state captured on the first visit.
+            if not child_block.is_deferred_validation:
+                child_block.defer_required_validation()

     def clean(self, value):
         required = self.required and not self.is_deferred_validation
@@ -240,7 +246,8 @@ class BaseStreamBlock(Block):

     def restore_deferred_validation(self):
         for child_block in self.child_blocks.values():
-            child_block.restore_deferred_validation()
+            if child_block.is_deferred_validation:
+                child_block.restore_deferred_validation()
         super().restore_deferred_validation()

     def to_python(self, value):
diff --git a/wagtail/blocks/struct_block.py b/wagtail/blocks/struct_block.py
index d5a193ac77..571ee330cc 100644
--- a/wagtail/blocks/struct_block.py
+++ b/wagtail/blocks/struct_block.py
@@ -304,7 +304,8 @@ class BaseStructBlock(Block):
     def defer_required_validation(self):
         super().defer_required_validation()
         for block in self.child_blocks.values():
-            block.defer_required_validation()
+            if not block.is_deferred_validation:
+                block.defer_required_validation()

     def clean(self, value):
         result = []  # build up a list of (name, value) tuples to be passed to the StructValue constructor
@@ -322,7 +323,8 @@ class BaseStructBlock(Block):

     def restore_deferred_validation(self):
         for block in self.child_blocks.values():
-            block.restore_deferred_validation()
+            if block.is_deferred_validation:
+                block.restore_deferred_validation()
         super().restore_deferred_validation()

     def to_python(self, value):
diff --git a/wagtail/contrib/typed_table_block/blocks.py b/wagtail/contrib/typed_table_block/blocks.py
index f7d9bcf435..266616e6b0 100644
--- a/wagtail/contrib/typed_table_block/blocks.py
+++ b/wagtail/contrib/typed_table_block/blocks.py
@@ -364,11 +364,13 @@ class BaseTypedTableBlock(Block):
     def defer_required_validation(self):
         super().defer_required_validation()
         for child_block in self.child_blocks.values():
-            child_block.defer_required_validation()
+            if not child_block.is_deferred_validation:
+                child_block.defer_required_validation()

     def restore_deferred_validation(self):
         for child_block in self.child_blocks.values():
-            child_block.restore_deferred_validation()
+            if child_block.is_deferred_validation:
+                child_block.restore_deferred_validation()
         super().restore_deferred_validation()

     class Meta:

Assistant

Summary of the swap:

  • Reverted FieldBlock.defer_required_validation to its original form (no leaf-level guard).
  • Added call-site is_deferred_validation checks in the four container blocks that cascade defer/restore:
    • wagtail/blocks/stream_block.py:164-175 (defer) and 247-251 (restore)
    • wagtail/blocks/struct_block.py:304-308 (defer) and 324-328 (restore)
    • wagtail/blocks/list_block.py:200-203 (defer) and 252-255 (restore)
    • wagtail/contrib/typed_table_block/blocks.py:364-373 (both)
  • A single short comment in BaseStreamBlock explains the why; the other three repeat the same pattern without comments to avoid noise.

The regression test in wagtail/tests/test_blocks.py::TestStreamBlock::test_clean_deferred_restores_required_for_shared_field_blocks still passes, and 459 tests across wagtail.tests.test_blocks, wagtail.images.tests.test_blocks, wagtail.contrib.typed_table_block.tests, and wagtail.contrib.table_block.tests all pass.

Plan

  • Swap to call-site dedupe in container blocks
  • Add regression test in wagtail.tests.test_blocks
  • Re-run tests after swap

User

Does this mean the we should have tests for the changes in StreamBlock, StructBlock, ListBlock, and TypedTableBlock? Or was this specific to StreamBlock?

Assistant

Good question — and worth being precise about: the existing test actually exercises the StructBlock call-site guard, not the StreamBlock one. The setup is two HeroBlock instances inside a StreamBlock, but those two HeroBlock instances are distinct objects. The shared instance is the CharBlock inside them. When the cascade runs:

  1. StreamBlock iterates [hero, featured] — two distinct objects, both with is_deferred_validation=False. The StreamBlock guard never fires.
  2. hero.defer_required_validation iterates its child_blocks, hits the shared CharBlock, defers it.
  3. featured.defer_required_validation iterates its child_blocks, hits the same CharBlockthis is where the StructBlock guard kicks in.

So the existing test is correctly named for the bug surface, but mechanically it covers the StructBlock cascade. The StreamBlock, ListBlock, and TypedTableBlock fixes are currently untested.

My recommendation: add one test per container, mirroring the existing one. The patterns are nearly identical so each test is only a few lines, and it locks in regression coverage for each cascade independently. Concretely:

  • StreamBlock: a shared_block = CharBlock(required=True) referenced as two StreamBlock child types directly.
  • ListBlock: a ListBlock(shared_block) inside a StreamBlock where shared_block is also referenced from another child of the same StreamBlock.
  • TypedTableBlock: same shape as the StructBlock test, but with a TypedTableBlock declaring shared column blocks. (Lives in wagtail/contrib/typed_table_block/tests.py.)

The most realistic real-world scenario remains the StructBlock one — class-level declarations being shared across multiple instantiations is by far the most common way a block ends up at the end of two cascade paths. The others are theoretical-but-possible patterns; testing them is mostly insurance against future regressions in cascade logic.

Want me to add the three additional tests?

User (checkpoint)

yes please

Assistant

Let me look at the existing typed_table_block test structure first:

Tool calls

Tool Call: grep -n -A 30 -B 5 "clean_deferred|defer_required" wagtail/contrib/typed_table_block/tests.py Status: Completed

243- self.block.clean(invalid_table) 244- 245- # table-cell-0-1 is actually cell 1 of row 1 due to the swapped row order in the data 246- self.assertTrue(exc_info.exception.cell_errors[1][1]) 247- 248: def test_clean_deferred_propagates_to_child_blocks(self): 249- form_data = { 250- **self.form_data, 251- "table-cell-0-0": "", 252- "table-cell-0-1": "", 253- "table-cell-1-0": "", 254- "table-cell-1-1": "", 255- "table-cell-2-0": "", 256- "table-cell-2-1": "", 257- } 258- blank_table = self.block.value_from_datadict(form_data, {}, "table") 259: # With clean_deferred, the CharBlock and CountryChoiceBlock become optional 260: cleaned_block = self.block.clean_deferred(blank_table) 261- self.assertIsInstance(cleaned_block, TypedTable) 262- 263- invalid_form_data = {**form_data, "table-cell-0-0": "id"} 264- invalid_table = self.block.value_from_datadict(invalid_form_data, {}, "table") 265- 266- # Other validation errors e.g. invalid choice should still be raised 267- with self.assertRaises(TypedTableBlockValidationError) as exc_info: 268: self.block.clean_deferred(invalid_table) 269- 270- self.assertEqual(exc_info.exception.cell_errors[1][0].code, "invalid_choice") 271- 272: # The required validation should be restored after clean_deferred, 273- # so should be raised if we try to clean the same table again 274- with self.assertRaises(TypedTableBlockValidationError) as exc_info: 275- self.block.clean(blank_table) 276- self.assertEqual(exc_info.exception.cell_errors[1][0].code, "required") 277- 278- def test_render(self): 279- table = self.block.value_from_datadict(self.form_data, {}, "table") 280- html = self.block.render(table) 281- 282- self.assertIn("Countries and their food", html) 283- self.assertIn('Country', html) 284- # rendering should use the block renderings of the child blocks ('FR' not 'fr') 285- self.assertIn("FR", html) 286- 287- def test_adapt(self): 288- block = TypedTableBlock(description="A table of countries and their food") 289- 290- block.set_name("test_typedtableblock") 291- js_args = TypedTableBlockAdapter().js_args(block) 292- 293- self.assertEqual(js_args[0], "test_typedtableblock") 294- self.assertEqual( 295- js_args[-1], 296- { 297- "label": "Test typedtableblock", 298- "description": "A table of countries and their food", 299- "required": False, 300- "icon": "table", 301- "blockDefId": block.definition_prefix, 302- "isPreviewable": block.is_previewable,

Tool Call: Read wagtail/contrib/typed_table_block/tests.py (1 - 90) Status: Completed

1	from django.core.exceptions import ValidationError
2	from django.forms.utils import ErrorList
3	from django.test import TestCase
4	
5	from wagtail import blocks
6	from wagtail.blocks.base import get_error_json_data
7	from wagtail.blocks.definition_lookup import BlockDefinitionLookup
8	from wagtail.blocks.struct_block import StructBlockValidationError
9	from wagtail.contrib.typed_table_block.blocks import (
10	    TypedTable,
11	    TypedTableBlock,
12	    TypedTableBlockAdapter,
13	    TypedTableBlockValidationError,
14	)
15	
16	
17	class CountryChoiceBlock(blocks.ChoiceBlock):
18	    """A ChoiceBlock with a custom rendering and API representation, to check that block rendering is honoured"""
19	
20	    def render_basic(self, value, context=None):
21	        return value.upper() if value else value
22	
23	    def get_api_representation(self, value, context=None):
24	        return f".{value}" if value else value
25	
26	
27	class TestTableBlock(TestCase):
28	    def setUp(self):
29	        self.block = TypedTableBlock(
30	            [
31	                ("text", blocks.CharBlock()),
32	                (
33	                    "country",
34	                    CountryChoiceBlock(
35	                        choices=[
36	                            ("be", "Belgium"),
37	                            ("fr", "France"),
38	                            ("nl", "Netherlands"),
39	                        ]
40	                    ),
41	                ),
42	            ]
43	        )
44	
45	        self.form_data = {
46	            "table-caption": "Countries and their food",
47	            "table-column-count": "2",
48	            "table-row-count": "3",
49	            "table-column-0-type": "country",
50	            "table-column-0-order": "0",
51	            "table-column-0-deleted": "",
52	            "table-column-0-heading": "Country",
53	            "table-column-1-type": "text",
54	            "table-column-1-order": "1",
55	            "table-column-1-deleted": "",
56	            "table-column-1-heading": "Description",
57	            "table-row-0-order": "1",
58	            "table-row-0-deleted": "",
59	            "table-cell-0-0": "fr",
60	            "table-cell-0-1": "A large country with baguettes",
61	            "table-row-1-order": "0",
62	            "table-row-1-deleted": "",
63	            "table-cell-1-0": "nl",
64	            "table-cell-1-1": "A small country with stroopwafels",
65	            "table-row-2-order": "2",
66	            "table-row-2-deleted": "1",
67	            "table-cell-2-0": "be",
68	            "table-cell-2-1": "A small country with sprouts",
69	        }
70	
71	        self.db_data = {
72	            "columns": [
73	                {"type": "country", "heading": "Country"},
74	                {"type": "text", "heading": "Description"},
75	            ],
76	            "rows": [
77	                {"values": ["nl", "A small country with stroopwafels"]},
78	                {"values": ["fr", "A large country with baguettes"]},
79	            ],
80	            "caption": "Countries and their food",
81	        }
82	
83	        self.api_data = {
84	            "columns": [
85	                {"type": "country", "heading": "Country"},
86	                {"type": "text", "heading": "Description"},
87	            ],
88	            "rows": [
89	                {"values": [".nl", "A small country with stroopwafels"]},
90	                {"values": [".fr", "A large country with baguettes"]},

Assistant

Let me check BaseStreamBlock.to_python to verify my assumption about block lookup by dict key:

Tool Call: Read wagtail/blocks/stream_block.py (260 - 304) Status: Completed

260	                # value is not valid JSON; most likely, this field was previously a
261	                # rich text field before being migrated to StreamField, and the data
262	                # was left intact in the migration. Return an empty stream instead
263	                # (but keep the raw text available as an attribute, so that it can be
264	                # used to migrate that data to StreamField)
265	                return self.empty_value(raw_text=value)
266	
267	        if not value:
268	            return self.empty_value()
269	
270	        # ensure value is a list and not some other kind of iterable
271	        value = list(value)
272	
273	        if isinstance(value[0], dict):
274	            # value is in JSONish representation - a dict with 'type' and 'value' keys.
275	            # This is passed to StreamValue to be expanded lazily - but first we reject any unrecognised
276	            # block types from the list
277	            return StreamValue(
278	                self,
279	                [
280	                    child_data
281	                    for child_data in value
282	                    if child_data["type"] in self.child_blocks
283	                ],
284	                is_lazy=True,
285	            )
286	        else:
287	            # See if it looks like the standard non-smart representation of a
288	            # StreamField value: a list of (block_name, value) tuples
289	            try:
290	                [None for (x, y) in value]
291	            except (TypeError, ValueError) as exc:
292	                # Give up trying to make sense of the value
293	                raise TypeError(
294	                    f"Cannot handle {value!r} (type {type(value)!r}) as a value of a StreamBlock"
295	                ) from exc
296	
297	            # Test succeeded, so return as a StreamValue-ified version of that value
298	            return StreamValue(
299	                self,
300	                [
301	                    (k, self.child_blocks[k].normalize(v))
302	                    for k, v in value
303	                    if k in self.child_blocks
304	                ],

Assistant

Now I'll add the three additional tests. First, the StreamBlock and ListBlock tests in TestStreamBlock:

Tool Call: Read wagtail/tests/test_blocks.py (4575 - 4589) Status: Completed

4575	                        }
4576	                    }
4577	                }
4578	            },
4579	        )
4580	
4581	    def test_validation_errors(self):
4582	        class ValidatedBlock(blocks.StreamBlock):
4583	            char = blocks.CharBlock()
4584	            url = blocks.URLBlock()
4585	
4586	        block = ValidatedBlock()
4587	
4588	        value = blocks.StreamValue(
4589	            block,

Assistant

Now add the StreamBlock and ListBlock tests after the existing regression test:

Tool Call: Edit wagtail/tests/test_blocks.py Status: Completed

Diff: /Users/sabdullah/Code/github/wagtail/docker-wagtail-develop/wagtail/wagtail/tests/test_blocks.py

        )

    def test_clean_deferred_restores_required_for_shared_field_blocks(self):
        # The same FieldBlock instance may be reachable via multiple paths in
        # the block tree - e.g. a StructBlock subclass declares child blocks
        # at class level, and the subclass is used as more than one
        # StreamBlock child type. Exercises the StructBlock call-site guard.
        class HeroBlock(blocks.StructBlock):
            title = blocks.CharBlock(required=True)

Assistant

Now insert the two new tests after the existing one:

Tool Call: Edit wagtail/tests/test_blocks.py Status: Completed

Diff: /Users/sabdullah/Code/github/wagtail/docker-wagtail-develop/wagtail/wagtail/tests/test_blocks.py

            },
        )

    def test_clean_deferred_with_field_block_aliased_to_multiple_children(self):
        # A FieldBlock aliased as more than one direct StreamBlock child type
        # is visited twice by the StreamBlock cascade. Exercises the
        # StreamBlock call-site guard.
        shared = blocks.CharBlock(required=True)
        block = blocks.StreamBlock([("a", shared), ("b", shared)])
        self.assertIs(block.child_blocks["a"], block.child_blocks["b"])

        value = block.to_python([{"type": "a", "value": ""}])
        block.clean_deferred(value)

        self.assertTrue(shared.required)
        self.assertTrue(shared.field.required)
        with self.assertRaises(blocks.StreamBlockValidationError):
            block.clean(value)

    def test_clean_deferred_with_field_block_shared_with_list_block_child(self):
        # A FieldBlock that is both a direct StreamBlock child and the
        # child_block of a sibling ListBlock will be reached twice during
        # the cascade. Exercises the ListBlock call-site guard.
        shared = blocks.CharBlock(required=True)
        block = blocks.StreamBlock(
            [
                ("direct", shared),
                ("inside_list", blocks.ListBlock(shared)),
            ]
        )
        self.assertIs(
            block.child_blocks["direct"],
            block.child_blocks["inside_list"].child_block,
        )

        value = block.to_python([{"type": "direct", "value": ""}])
        block.clean_deferred(value)

        self.assertTrue(shared.required)
        self.assertTrue(shared.field.required)
        with self.assertRaises(blocks.StreamBlockValidationError):
            block.clean(value)

    def test_validation_errors(self):
        class ValidatedBlock(blocks.StreamBlock):
            char = blocks.CharBlock()

Assistant

Now the TypedTableBlock test. Let me add it after the existing test_clean_deferred_propagates_to_child_blocks in typed_table_block/tests.py:

Tool Call: Edit wagtail/contrib/typed_table_block/tests.py Status: Completed

Diff: /Users/sabdullah/Code/github/wagtail/docker-wagtail-develop/wagtail/wagtail/contrib/typed_table_block/tests.py

            self.block.clean(blank_table)
        self.assertEqual(exc_info.exception.cell_errors[1][0].code, "required")

    def test_defer_restores_required_for_shared_column_block(self):
        # A FieldBlock used as more than one TypedTableBlock column is
        # visited twice by the cascade. Exercises the TypedTableBlock
        # call-site guard.
        shared = blocks.CharBlock(required=True)
        block = TypedTableBlock([("a", shared), ("b", shared)])
        self.assertIs(block.child_blocks["a"], block.child_blocks["b"])

        block.defer_required_validation()
        self.assertFalse(shared.field.required)

        block.restore_deferred_validation()
        self.assertTrue(shared.required)
        self.assertTrue(shared.field.required)

    def test_render(self):
        table = self.block.value_from_datadict(self.form_data, {}, "table")
        html = self.block.render(table)

Assistant

Now let me also verify each new test would have failed without its corresponding guard, before running the full suite. First, the four new tests in isolation with the fix in place:

Tool Call: source venv/bin/activate && DATABASE_NAME=default.sqlite3 ./runtests.py --verbosity=2 --keepdb --exclude-tag=transaction wagtail.tests.test_blocks.TestStreamBlock.test_clean_deferred_restores_required_for_shared_field_blocks wagtail.tests.test_blocks.TestStreamBlock.test_clean_deferred_with_field_block_aliased_to_multiple_children wagtail.tests.test_blocks.TestStreamBlock.test_clean_deferred_with_field_block_shared_with_list_block_child wagtail.contrib.typed_table_block.tests.TestTableBlock.test_defer_restores_required_for_shared_column_block 2>&1 | tail -20 Status: Completed

Terminal:

test_clean_deferred_restores_required_for_shared_field_blocks (wagtail.tests.test_blocks.TestStreamBlock.test_clean_deferred_restores_required_for_shared_field_blocks) ... ok
test_clean_deferred_with_field_block_aliased_to_multiple_children (wagtail.tests.test_blocks.TestStreamBlock.test_clean_deferred_with_field_block_aliased_to_multiple_children) ... ok
test_clean_deferred_with_field_block_shared_with_list_block_child (wagtail.tests.test_blocks.TestStreamBlock.test_clean_deferred_with_field_block_shared_with_list_block_child) ... ok

----------------------------------------------------------------------
Ran 4 tests in 0.002s

OK
Preserving test database for alias 'default' ('default.sqlite3')...
Excluding test tag(s): transaction.
Found 4 test(s).
Operations to perform:
  Synchronize unmigrated apps: django_filters, messages, rest_framework, sitemaps, staticfiles, typed_table_block, wagtailapi_v2, wagtailfrontendcache, wagtaillocales, wagtailroutablepage, wagtailsettings, wagtailsites, wagtailsnippets, wagtailstyleguide, wagtailtableblock
  Apply all migrations: admin, auth, contenttypes, customuser, demosite, earlypage, i18n, routablepagetests, sessions, simple_translation, snippetstests, streamfield_migration_tests, taggit, tests, wagtailadmin, wagtailcore, wagtaildocs, wagtailembeds, wagtailforms, wagtailimages, wagtailredirects, wagtailsearch, wagtailsearchpromotions, wagtailusers
Synchronizing apps without migrations:
  Creating tables...
    Running deferred SQL...
Running migrations:
  No migrations to apply.
Cache table 'cache' already exists.

Assistant

Let me verify each new test would have caught its specific bug. First, temporarily revert just the StreamBlock guards:

Tool Call: Edit wagtail/blocks/stream_block.py Status: Completed

Diff: /Users/sabdullah/Code/github/wagtail/docker-wagtail-develop/wagtail/wagtail/blocks/stream_block.py

    def defer_required_validation(self):
        super().defer_required_validation()
        for child_block in self.child_blocks.values():
            child_block.defer_required_validation()

    def clean(self, value):
        required = self.required and not self.is_deferred_validation

Tool Call: Edit wagtail/blocks/stream_block.py Status: Completed

Diff: /Users/sabdullah/Code/github/wagtail/docker-wagtail-develop/wagtail/wagtail/blocks/stream_block.py


    def restore_deferred_validation(self):
        for child_block in self.child_blocks.values():
            child_block.restore_deferred_validation()
        super().restore_deferred_validation()

    def to_python(self, value):

Tool Call: source venv/bin/activate && DATABASE_NAME=default.sqlite3 ./runtests.py --verbosity=2 --keepdb --exclude-tag=transaction wagtail.tests.test_blocks.TestStreamBlock.test_clean_deferred_with_field_block_aliased_to_multiple_children wagtail.tests.test_blocks.TestStreamBlock.test_clean_deferred_with_field_block_shared_with_list_block_child 2>&1 | tail -25 Status: Completed

Terminal:

tests.TaggedGrandchildPage: (wagtailsearch.W001) Core Page fields missing in `search_fields`
	HINT: Ensure that TaggedGrandchildPage extends the Page model search fields `search_fields = Page.search_fields + [...]`
tests.TaggedPage: (wagtailsearch.W001) Core Page fields missing in `search_fields`
	HINT: Ensure that TaggedPage extends the Page model search fields `search_fields = Page.search_fields + [...]`

System check identified 3 issues (0 silenced).
test_clean_deferred_with_field_block_aliased_to_multiple_children (wagtail.tests.test_blocks.TestStreamBlock.test_clean_deferred_with_field_block_aliased_to_multiple_children) ... FAIL
test_clean_deferred_with_field_block_shared_with_list_block_child (wagtail.tests.test_blocks.TestStreamBlock.test_clean_deferred_with_field_block_shared_with_list_block_child) ... ok

======================================================================
FAIL: test_clean_deferred_with_field_block_aliased_to_multiple_children (wagtail.tests.test_blocks.TestStreamBlock.test_clean_deferred_with_field_block_aliased_to_multiple_children)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/sabdullah/Code/github/wagtail/docker-wagtail-develop/wagtail/wagtail/tests/test_blocks.py", line 4589, in test_clean_deferred_with_field_block_aliased_to_multiple_children
    self.assertTrue(shared.required)
    ~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^
AssertionError: False is not true

----------------------------------------------------------------------
Ran 2 tests in 0.001s

FAILED (failures=1)
Excluding test tag(s): transaction.
Found 2 test(s).
Skipping setup of unused database(s): default.

Assistant

Restore the StreamBlock guards and remove the ListBlock guards to verify the ListBlock test catches its specific bug:

Tool Call: Edit wagtail/blocks/stream_block.py Status: Completed

Diff: /Users/sabdullah/Code/github/wagtail/docker-wagtail-develop/wagtail/wagtail/blocks/stream_block.py

    def defer_required_validation(self):
        super().defer_required_validation()
        for child_block in self.child_blocks.values():
            # A block may be reachable via multiple paths in the tree (e.g.
            # a StructBlock subclass declares child blocks at class level
            # and is used as more than one StreamBlock child type). Skip
            # blocks that are already deferred to avoid clobbering the
            # state captured on the first visit.
            if not child_block.is_deferred_validation:
                child_block.defer_required_validation()

    def clean(self, value):
        required = self.required and not self.is_deferred_validation

Tool Call: Edit wagtail/blocks/stream_block.py Status: Completed

Diff: /Users/sabdullah/Code/github/wagtail/docker-wagtail-develop/wagtail/wagtail/blocks/stream_block.py


    def restore_deferred_validation(self):
        for child_block in self.child_blocks.values():
            if child_block.is_deferred_validation:
                child_block.restore_deferred_validation()
        super().restore_deferred_validation()

    def to_python(self, value):

Assistant

Now temporarily remove the ListBlock guards:

Tool Call: Edit wagtail/blocks/list_block.py Status: Completed

Diff: /Users/sabdullah/Code/github/wagtail/docker-wagtail-develop/wagtail/wagtail/blocks/list_block.py


    def defer_required_validation(self):
        super().defer_required_validation()
        self.child_block.defer_required_validation()

    def clean(self, value):
        # value is expected to be a ListValue, but if it's been assigned through external code it might

Tool Call: Edit wagtail/blocks/list_block.py Status: Completed

Diff: /Users/sabdullah/Code/github/wagtail/docker-wagtail-develop/wagtail/wagtail/blocks/list_block.py

        return ListValue(self, bound_blocks=result)

    def restore_deferred_validation(self):
        self.child_block.restore_deferred_validation()
        super().restore_deferred_validation()

    def normalize(self, value):

Tool Call: source venv/bin/activate && DATABASE_NAME=default.sqlite3 ./runtests.py --verbosity=2 --keepdb --exclude-tag=transaction wagtail.tests.test_blocks.TestStreamBlock.test_clean_deferred_with_field_block_shared_with_list_block_child wagtail.tests.test_blocks.TestStreamBlock.test_clean_deferred_with_field_block_aliased_to_multiple_children 2>&1 | tail -25 Status: Completed

Terminal:

tests.TaggedGrandchildPage: (wagtailsearch.W001) Core Page fields missing in `search_fields`
	HINT: Ensure that TaggedGrandchildPage extends the Page model search fields `search_fields = Page.search_fields + [...]`
tests.TaggedPage: (wagtailsearch.W001) Core Page fields missing in `search_fields`
	HINT: Ensure that TaggedPage extends the Page model search fields `search_fields = Page.search_fields + [...]`

System check identified 3 issues (0 silenced).
test_clean_deferred_with_field_block_shared_with_list_block_child (wagtail.tests.test_blocks.TestStreamBlock.test_clean_deferred_with_field_block_shared_with_list_block_child) ... FAIL
test_clean_deferred_with_field_block_aliased_to_multiple_children (wagtail.tests.test_blocks.TestStreamBlock.test_clean_deferred_with_field_block_aliased_to_multiple_children) ... ok

======================================================================
FAIL: test_clean_deferred_with_field_block_shared_with_list_block_child (wagtail.tests.test_blocks.TestStreamBlock.test_clean_deferred_with_field_block_shared_with_list_block_child)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/sabdullah/Code/github/wagtail/docker-wagtail-develop/wagtail/wagtail/tests/test_blocks.py", line 4613, in test_clean_deferred_with_field_block_shared_with_list_block_child
    self.assertTrue(shared.required)
    ~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^
AssertionError: False is not true

----------------------------------------------------------------------
Ran 2 tests in 0.001s

FAILED (failures=1)
Excluding test tag(s): transaction.
Found 2 test(s).
Skipping setup of unused database(s): default.

Assistant

Restore the ListBlock guards:

Tool Call: Edit wagtail/blocks/list_block.py Status: Completed

Diff: /Users/sabdullah/Code/github/wagtail/docker-wagtail-develop/wagtail/wagtail/blocks/list_block.py


    def defer_required_validation(self):
        super().defer_required_validation()
        if not self.child_block.is_deferred_validation:
            self.child_block.defer_required_validation()

    def clean(self, value):
        # value is expected to be a ListValue, but if it's been assigned through external code it might

Tool Call: Edit wagtail/blocks/list_block.py Status: Completed

Diff: /Users/sabdullah/Code/github/wagtail/docker-wagtail-develop/wagtail/wagtail/blocks/list_block.py

        return ListValue(self, bound_blocks=result)

    def restore_deferred_validation(self):
        if self.child_block.is_deferred_validation:
            self.child_block.restore_deferred_validation()
        super().restore_deferred_validation()

    def normalize(self, value):

Assistant

Now temporarily remove the TypedTableBlock guards:

Tool Call: Edit wagtail/contrib/typed_table_block/blocks.py Status: Completed

Diff: /Users/sabdullah/Code/github/wagtail/docker-wagtail-develop/wagtail/wagtail/contrib/typed_table_block/blocks.py

    def defer_required_validation(self):
        super().defer_required_validation()
        for child_block in self.child_blocks.values():
            child_block.defer_required_validation()

    def restore_deferred_validation(self):
        for child_block in self.child_blocks.values():
            child_block.restore_deferred_validation()
        super().restore_deferred_validation()

    class Meta:

Tool Call: source venv/bin/activate && DATABASE_NAME=default.sqlite3 ./runtests.py --verbosity=2 --keepdb --exclude-tag=transaction wagtail.contrib.typed_table_block.tests.TestTableBlock.test_defer_restores_required_for_shared_column_block 2>&1 | tail -15 Status: Completed

Terminal:

Ran 1 test in 0.002s

FAILED (failures=1)
Preserving test database for alias 'default' ('default.sqlite3')...
Excluding test tag(s): transaction.
Found 1 test(s).
Operations to perform:
  Synchronize unmigrated apps: django_filters, messages, rest_framework, sitemaps, staticfiles, typed_table_block, wagtailapi_v2, wagtailfrontendcache, wagtaillocales, wagtailroutablepage, wagtailsettings, wagtailsites, wagtailsnippets, wagtailstyleguide, wagtailtableblock
  Apply all migrations: admin, auth, contenttypes, customuser, demosite, earlypage, i18n, routablepagetests, sessions, simple_translation, snippetstests, streamfield_migration_tests, taggit, tests, wagtailadmin, wagtailcore, wagtaildocs, wagtailembeds, wagtailforms, wagtailimages, wagtailredirects, wagtailsearch, wagtailsearchpromotions, wagtailusers
Synchronizing apps without migrations:
  Creating tables...
    Running deferred SQL...
Running migrations:
  No migrations to apply.
Cache table 'cache' already exists.

Assistant

Restore the TypedTableBlock guards:

Tool Call: Edit wagtail/contrib/typed_table_block/blocks.py Status: Completed

Diff: /Users/sabdullah/Code/github/wagtail/docker-wagtail-develop/wagtail/wagtail/contrib/typed_table_block/blocks.py

    def defer_required_validation(self):
        super().defer_required_validation()
        for child_block in self.child_blocks.values():
            if not child_block.is_deferred_validation:
                child_block.defer_required_validation()

    def restore_deferred_validation(self):
        for child_block in self.child_blocks.values():
            if child_block.is_deferred_validation:
                child_block.restore_deferred_validation()
        super().restore_deferred_validation()

    class Meta:

Assistant

Now run the full suite one more time with all guards restored:

Tool Call: source venv/bin/activate && DATABASE_NAME=default.sqlite3 ./runtests.py --verbosity=1 --parallel --keepdb --exclude-tag=transaction wagtail.tests.test_blocks wagtail.images.tests.test_blocks wagtail.contrib.typed_table_block.tests wagtail.contrib.table_block.tests 2>&1 | tail -15 Status: Completed

Terminal:

  warnings.warn(
..................................................x..........................................................................................................................................................................................................................................................................................................................................................................................................................x
----------------------------------------------------------------------
Ran 462 tests in 2.974s

OK (expected failures=2)
Preserving test database for alias 'default'...
Preserving test database for alias 'default'...
Preserving test database for alias 'default'...
Preserving test database for alias 'default'...
Preserving test database for alias 'default'...
Preserving test database for alias 'default'...
Preserving test database for alias 'default'...
Preserving test database for alias 'default'...
Preserving test database for alias 'default'...

Assistant

All 462 tests pass (2 pre-existing expected failures, 3 new tests added since last run = 462 total).

Tool Call: git status --short && echo "---" && git diff --stat Status: Completed

Terminal:

 M wagtail/blocks/list_block.py
 M wagtail/blocks/stream_block.py
 M wagtail/blocks/struct_block.py
 M wagtail/contrib/typed_table_block/blocks.py
 M wagtail/contrib/typed_table_block/tests.py
 M wagtail/tests/test_blocks.py
?? populate_fake_page_log_entries.py
---
 wagtail/blocks/list_block.py                |  6 +-
 wagtail/blocks/stream_block.py              | 11 +++-
 wagtail/blocks/struct_block.py              |  6 +-
 wagtail/contrib/typed_table_block/blocks.py |  6 +-
 wagtail/contrib/typed_table_block/tests.py  | 15 +++++
 wagtail/tests/test_blocks.py                | 86 +++++++++++++++++++++++++++++
 6 files changed, 122 insertions(+), 8 deletions(-)

Assistant

All done. Summary:

Fixes (call-site dedupe at each cascading container's defer/restore methods):

  • wagtail/blocks/stream_block.pyBaseStreamBlock
  • wagtail/blocks/struct_block.pyBaseStructBlock
  • wagtail/blocks/list_block.pyListBlock
  • wagtail/contrib/typed_table_block/blocks.pyBaseTypedTableBlock

Regression tests (four total, each individually verified to fail when its corresponding guard is removed and pass with it in place):

  • wagtail/tests/test_blocks.py::TestStreamBlock::test_clean_deferred_restores_required_for_shared_field_blocks — exercises the StructBlock guard (two HeroBlock instances sharing a class-level CharBlock).
  • wagtail/tests/test_blocks.py::TestStreamBlock::test_clean_deferred_with_field_block_aliased_to_multiple_children — exercises the StreamBlock guard (same CharBlock aliased as two StreamBlock children).
  • wagtail/tests/test_blocks.py::TestStreamBlock::test_clean_deferred_with_field_block_shared_with_list_block_child — exercises the ListBlock guard (a CharBlock is both a direct StreamBlock child and the child_block of a sibling ListBlock).
  • wagtail/contrib/typed_table_block/tests.py::TestTableBlock::test_defer_restores_required_for_shared_column_block — exercises the TypedTableBlock guard (a CharBlock used as two column types).

Full suite (test_blocks + images.tests.test_blocks + typed_table_block.tests + table_block.tests): 462 tests, all passing (2 pre-existing expected failures).

Plan

  • Swap to call-site dedupe in container blocks
  • Add regression test in wagtail.tests.test_blocks
  • Re-run tests after swap
  • Add StreamBlock-cascade regression test
  • Add ListBlock-cascade regression test
  • Add TypedTableBlock-cascade regression test
  • Re-run full test suite

User

Since StructBlock also does the shallow copy, does it suffer the same problem without StreamBlock involved?

Assistant

Yes, exactly the same issue. The bug surface is broader than just StreamFields — any nesting where a StructBlock subclass with class-level declarations is instantiated more than once will trigger it. The simplest reproducer doesn't need a StreamBlock at all:

class HeroBlock(StructBlock):
    title = CharBlock(required=True)

class OuterBlock(StructBlock):
    a = HeroBlock()      # HeroBlock instance #1
    b = HeroBlock()      # HeroBlock instance #2 - distinct, but both
                         # reference the same class-level CharBlock
                         # via HeroBlock.base_blocks["title"]

outer = OuterBlock()
outer.clean_deferred({"a": {"title": ""}, "b": {"title": ""}})
# Without the StructBlock guard: shared title.field.required ends up False

The cascade fires OuterBlock.defer → a.defer → title.defer (deferred), then b.defer → title.defer (the same instance, now already deferred — without the guard, second call corrupts _original_required). It's the exact same shape as the StreamBlock-wrapped version, just with OuterBlock instead of MyStream at the root.

The existing fix already covers this — the guard lives inside BaseStructBlock.defer_required_validation itself, so it fires whether the outer iteration originated from a StreamBlock, another StructBlock, or anywhere else. In fact, the existing test (test_clean_deferred_restores_required_for_shared_field_blocks) is already mechanically exercising this guard — the StreamBlock wrapper just provides a convenient top-level entry point for clean_deferred.

That said, adding a "no StreamBlock involved" variant would be a nice documentation-by-test that the fix isn't StreamField-specific. Want me to add a fifth test exercising the pure StructBlock-in-StructBlock case?

User (checkpoint)

Yes please, add it in TestStructBlock

Assistant

Tool Call: grep -n "^class TestStructBlock" wagtail/tests/test_blocks.py Status: Completed

2161:class TestStructBlock(SimpleTestCase): 3178:class TestStructBlockWithCustomStructValue(SimpleTestCase): 5640:class TestStructBlockWithFixtures(TestCase):

Tool Call: Read wagtail/tests/test_blocks.py (3008 - 3032) Status: Completed

3008	    def test_clean_deferred_propagates_to_child_blocks(self):
3009	        block = blocks.StructBlock(
3010	            [
3011	                ("title", blocks.CharBlock()),
3012	                ("link", blocks.URLBlock()),
3013	            ]
3014	        )
3015	
3016	        value = block.to_python({"title": "", "link": ""})
3017	        clean_value = block.clean_deferred(value)
3018	        self.assertIsInstance(clean_value, blocks.StructValue)
3019	        self.assertEqual(clean_value["title"], "")
3020	        self.assertEqual(clean_value["link"], "")
3021	
3022	        # Required validation should be restored after clean_deferred
3023	        value = block.to_python({"title": "Torchbox", "link": "not a url"})
3024	        with self.assertRaises(ValidationError):
3025	            block.clean_deferred(value)
3026	
3027	        value = block.to_python({"title": "", "link": "https://example.com"})
3028	        with self.assertRaises(ValidationError):
3029	            block.clean(value)
3030	
3031	    def test_non_block_validation_error(self):
3032	        class LinkBlock(blocks.StructBlock):

Assistant

I'll add the test right after test_clean_deferred_propagates_to_child_blocks:

Tool Call: Edit wagtail/tests/test_blocks.py Status: Completed

Diff: /Users/sabdullah/Code/github/wagtail/docker-wagtail-develop/wagtail/wagtail/tests/test_blocks.py

        with self.assertRaises(ValidationError):
            block.clean(value)

    def test_clean_deferred_restores_required_for_shared_field_blocks(self):
        # Same shape as the StreamBlock-wrapped regression test, but with a
        # StructBlock at the root - the bug surface isn't limited to
        # StreamFields. Two HeroBlock instances reference the same
        # class-level CharBlock via HeroBlock.base_blocks, so the cascade
        # visits it twice.
        class HeroBlock(blocks.StructBlock):
            title = blocks.CharBlock(required=True)

        class OuterBlock(blocks.StructBlock):
            a = HeroBlock()
            b = HeroBlock()

        block = OuterBlock()
        shared_title = block.child_blocks["a"].child_blocks["title"]
        self.assertIs(
            shared_title,
            block.child_blocks["b"].child_blocks["title"],
        )

        value = block.to_python({"a": {"title": ""}, "b": {"title": ""}})
        block.clean_deferred(value)

        self.assertTrue(shared_title.required)
        self.assertTrue(shared_title.field.required)
        with self.assertRaises(StructBlockValidationError):
            block.clean(value)

    def test_non_block_validation_error(self):
        class LinkBlock(blocks.StructBlock):
            page = blocks.PageChooserBlock(required=False)

Assistant

Let me verify StructBlockValidationError is already imported in the test file:

Tool Call: grep -n | head -5 "StructBlockValidationError" wagtail/tests/test_blocks.py Status: Completed

30: StructBlockValidationError, 3056: with self.assertRaises(StructBlockValidationError): 3070: raise StructBlockValidationError( 6821: error = StructBlockValidationError( 6846: error = StructBlockValidationError(

[Showing results with pagination = limit: 5]

Assistant

Good. Now run the new test (and the full suite) to verify:

Tool Call: source venv/bin/activate && DATABASE_NAME=default.sqlite3 ./runtests.py --verbosity=2 --keepdb --exclude-tag=transaction wagtail.tests.test_blocks.TestStructBlock.test_clean_deferred_restores_required_for_shared_field_blocks 2>&1 | tail -10 Status: Completed

Terminal:

System check identified 3 issues (0 silenced).
test_clean_deferred_restores_required_for_shared_field_blocks (wagtail.tests.test_blocks.TestStructBlock.test_clean_deferred_restores_required_for_shared_field_blocks) ... ok

----------------------------------------------------------------------
Ran 1 test in 0.000s

OK
Excluding test tag(s): transaction.
Found 1 test(s).
Skipping setup of unused database(s): default.

Assistant

Tasks all complete already. Let me also verify the test fails without the StructBlock guard, then run the full suite:

Tool Call: Edit wagtail/blocks/struct_block.py Status: Completed

Diff: /Users/sabdullah/Code/github/wagtail/docker-wagtail-develop/wagtail/wagtail/blocks/struct_block.py

    def defer_required_validation(self):
        super().defer_required_validation()
        for block in self.child_blocks.values():
            block.defer_required_validation()

    def clean(self, value):
        result = []  # build up a list of (name, value) tuples to be passed to the StructValue constructor

Tool Call: Edit wagtail/blocks/struct_block.py Status: Completed

Diff: /Users/sabdullah/Code/github/wagtail/docker-wagtail-develop/wagtail/wagtail/blocks/struct_block.py


    def restore_deferred_validation(self):
        for block in self.child_blocks.values():
            block.restore_deferred_validation()
        super().restore_deferred_validation()

    def to_python(self, value):

Tool Call: source venv/bin/activate && DATABASE_NAME=default.sqlite3 ./runtests.py --verbosity=2 --keepdb --exclude-tag=transaction wagtail.tests.test_blocks.TestStructBlock.test_clean_deferred_restores_required_for_shared_field_blocks 2>&1 | tail -15 Status: Completed

Terminal:

FAIL: test_clean_deferred_restores_required_for_shared_field_blocks (wagtail.tests.test_blocks.TestStructBlock.test_clean_deferred_restores_required_for_shared_field_blocks)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/sabdullah/Code/github/wagtail/docker-wagtail-develop/wagtail/wagtail/tests/test_blocks.py", line 3054, in test_clean_deferred_restores_required_for_shared_field_blocks
    self.assertTrue(shared_title.required)
    ~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^
AssertionError: False is not true

----------------------------------------------------------------------
Ran 1 test in 0.001s

FAILED (failures=1)
Excluding test tag(s): transaction.
Found 1 test(s).
Skipping setup of unused database(s): default.

Assistant

Confirmed: the new pure-StructBlock test fails without the StructBlock guard. Restore it:

Tool Call: Edit wagtail/blocks/struct_block.py Status: Completed

Diff: /Users/sabdullah/Code/github/wagtail/docker-wagtail-develop/wagtail/wagtail/blocks/struct_block.py

    def defer_required_validation(self):
        super().defer_required_validation()
        for block in self.child_blocks.values():
            if not block.is_deferred_validation:
                block.defer_required_validation()

    def clean(self, value):
        result = []  # build up a list of (name, value) tuples to be passed to the StructValue constructor

Tool Call: Edit wagtail/blocks/struct_block.py Status: Completed

Diff: /Users/sabdullah/Code/github/wagtail/docker-wagtail-develop/wagtail/wagtail/blocks/struct_block.py


    def restore_deferred_validation(self):
        for block in self.child_blocks.values():
            if block.is_deferred_validation:
                block.restore_deferred_validation()
        super().restore_deferred_validation()

    def to_python(self, value):

Assistant

Run the full suite one more time:

Tool Call: source venv/bin/activate && DATABASE_NAME=default.sqlite3 ./runtests.py --verbosity=1 --parallel --keepdb --exclude-tag=transaction wagtail.tests.test_blocks wagtail.images.tests.test_blocks wagtail.contrib.typed_table_block.tests wagtail.contrib.table_block.tests 2>&1 | tail -5 Status: Completed

Terminal:

Preserving test database for alias 'default'...
Preserving test database for alias 'default'...
Preserving test database for alias 'default'...
Preserving test database for alias 'default'...
Preserving test database for alias 'default'...

Tool Call: source venv/bin/activate && DATABASE_NAME=default.sqlite3 ./runtests.py --verbosity=1 --parallel --keepdb --exclude-tag=transaction wagtail.tests.test_blocks wagtail.images.tests.test_blocks wagtail.contrib.typed_table_block.tests wagtail.contrib.table_block.tests 2>&1 | grep -E "^Ran|^OK|^FAIL|failures|errors" | head -5 Status: Completed

Terminal:

Ran 463 tests in 2.898s
OK (expected failures=2)




Assistant

All 463 tests pass (one more than before, the new pure-StructBlock test).

Final state:

  • 5 regression tests total: pure-StructBlock (new), StreamBlock-wrapping-shared-StructBlock-children (existing), StreamBlock with aliased FieldBlock, StreamBlock-with-shared-via-ListBlock, and TypedTableBlock with shared columns.
  • All four container guards verified to be load-bearing: each test was confirmed to fail when its corresponding guard was removed.
  • Pure-StructBlock test lives at wagtail/tests/test_blocks.py::TestStructBlock::test_clean_deferred_restores_required_for_shared_field_blocks (line 3031), reachable without any StreamField involvement, demonstrating that the bug surface isn't StreamField-specific.

User

Is it possible for the same issue to happen with ListBlock without StreamBlock involved?

Assistant

Yes — same answer as the StructBlock case. The ListBlock guard is sensitive to any parent that visits the shared child via a sibling path before reaching the ListBlock. Concretely:

shared = CharBlock(required=True)
outer = StructBlock([
    ("direct", shared),
    ("list", ListBlock(shared)),
])
outer.clean_deferred({"direct": "", "list": []})
# Without the ListBlock guard: shared.field.required ends up False

The cascade fires outer.defer → shared.defer (deferred), then outer.defer → list.defer → shared.defer (already deferred — that's where the ListBlock guard catches it). No StreamBlock involved.

A second variant: two ListBlocks sharing one child_block:

shared = CharBlock(required=True)
outer = StructBlock([
    ("list1", ListBlock(shared)),
    ("list2", ListBlock(shared)),
])

Same outcome — second ListBlock.defer hits the already-deferred shared and would corrupt it without the guard.

What can't trigger the bug is a ListBlock at the top with no shared aliasing: there's only one cascade path to child_block, so it's visited exactly once.

Want me to add a pure-StructBlock variant of the existing ListBlock test for parity with the StructBlock-pure test? Same logic, just StructBlock instead of StreamBlock at the root.

User

I mean a ListBlock without involvement of StreamBlock nor StructBlock. Maybe like a nested ListBlock? Not sure if that's even possible or if it makes sense though

Assistant

No, you can't trigger it with only ListBlocks. The reason is structural: ListBlock has exactly one child_block, so its cascade has exactly one outgoing edge. To get a double-visit you need branching in the tree — at least one container with multiple child slots where two of those slots converge on the same block. StructBlock (multiple named children), StreamBlock (multiple block types), and TypedTableBlock (multiple columns) all have that branching; ListBlock doesn't.

A few attempts that don't work:

  • Nested ListBlock (ListBlock(ListBlock(CharBlock()))): single chain — outer → inner → char. Each visited once.
  • ListBlock as the top-level: still one cascade path. Even if child_block is a StructBlock with shared fields inside it, the bug would be caught by the StructBlock guard, not the ListBlock guard.
  • Two ListBlocks sharing a child_block: you can construct the two ListBlock objects, but there's no all-ListBlock container to hold both of them in a single clean_deferred cycle. You'd need a StructBlock/StreamBlock parent — at which point it's no longer "only ListBlocks involved."
  • Cyclic ListBlock (list.child_block = list): would recurse infinitely on the cascade — different failure mode, not the saved-state corruption bug.

So the ListBlock guard is purely defensive against its sibling having already deferred their shared block — it's about being a good cascade citizen. The bug only exists when something above introduces the branching. That's a useful invariant worth knowing.

User (checkpoint)

OK, can you remove comments in the tests that are too specific to the code implementation and our current context please? For example, "Exercises the StructBlock call-site guard." isn't particularly useful as we don't know exactly what "call-site guard" is referring to. Also comments that refer to other tests, like "Same shape as the StreamBlock-wrapped regression test" isn't really needed.

Only modify the comments in the tests you added, don't touch existing tests

Assistant

Let me look at the current state of each comment to revise:

Tool Call: grep -n -A 5 -B 1 "call-site guard|Exercises the|Same shape|class level|reachable via" wagtail/tests/test_blocks.py Status: Completed

3031- def test_clean_deferred_restores_required_for_shared_field_blocks(self): 3032: # Same shape as the StreamBlock-wrapped regression test, but with a 3033- # StructBlock at the root - the bug surface isn't limited to 3034- # StreamFields. Two HeroBlock instances reference the same 3035- # class-level CharBlock via HeroBlock.base_blocks, so the cascade 3036- # visits it twice. 3037- class HeroBlock(blocks.StructBlock):

4560- def test_clean_deferred_restores_required_for_shared_field_blocks(self): 4561: # The same FieldBlock instance may be reachable via multiple paths in 4562- # the block tree - e.g. a StructBlock subclass declares child blocks 4563: # at class level, and the subclass is used as more than one 4564: # StreamBlock child type. Exercises the StructBlock call-site guard. 4565- class HeroBlock(blocks.StructBlock): 4566- title = blocks.CharBlock(required=True) 4567- 4568- class MyStream(blocks.StreamBlock): 4569- hero = HeroBlock()

4607- # A FieldBlock aliased as more than one direct StreamBlock child type 4608: # is visited twice by the StreamBlock cascade. Exercises the 4609: # StreamBlock call-site guard. 4610- shared = blocks.CharBlock(required=True) 4611- block = blocks.StreamBlock([("a", shared), ("b", shared)]) 4612- self.assertIs(block.child_blocks["a"], block.child_blocks["b"]) 4613- 4614- value = block.to_python([{"type": "a", "value": ""}])

4624- # child_block of a sibling ListBlock will be reached twice during 4625: # the cascade. Exercises the ListBlock call-site guard. 4626- shared = blocks.CharBlock(required=True) 4627- block = blocks.StreamBlock( 4628- [ 4629- ("direct", shared), 4630- ("inside_list", blocks.ListBlock(shared)),

Assistant

Now revise each. First, the TestStructBlock test:

Tool Call: Edit wagtail/tests/test_blocks.py Status: Completed

Diff: /Users/sabdullah/Code/github/wagtail/docker-wagtail-develop/wagtail/wagtail/tests/test_blocks.py

            block.clean(value)

    def test_clean_deferred_restores_required_for_shared_field_blocks(self):
        # A StructBlock subclass that declares child blocks at class level
        # shares those instances across all instantiations. Two HeroBlock
        # children of OuterBlock therefore reference the same CharBlock,
        # making it reachable via more than one path.
        class HeroBlock(blocks.StructBlock):
            title = blocks.CharBlock(required=True)

Assistant

Now the TestStreamBlock test #1:

Tool Call: Edit wagtail/tests/test_blocks.py Status: Completed

Diff: /Users/sabdullah/Code/github/wagtail/docker-wagtail-develop/wagtail/wagtail/tests/test_blocks.py

        )

    def test_clean_deferred_restores_required_for_shared_field_blocks(self):
        # A StructBlock subclass that declares child blocks at class level
        # shares those instances across all instantiations. Two HeroBlock
        # child types in this StreamBlock therefore reference the same
        # CharBlock, making it reachable via more than one path.
        class HeroBlock(blocks.StructBlock):
            title = blocks.CharBlock(required=True)

Assistant

Now test #2:

Tool Call: Edit wagtail/tests/test_blocks.py Status: Completed

Diff: /Users/sabdullah/Code/github/wagtail/docker-wagtail-develop/wagtail/wagtail/tests/test_blocks.py

        )

    def test_clean_deferred_with_field_block_aliased_to_multiple_children(self):
        # The same FieldBlock instance is used as more than one direct
        # StreamBlock child type, making it reachable via more than one path.
        shared = blocks.CharBlock(required=True)
        block = blocks.StreamBlock([("a", shared), ("b", shared)])
        self.assertIs(block.child_blocks["a"], block.child_blocks["b"])

Assistant

Tasks list is fine — this is a trivial copy-edit, no tracking needed. Now test #3:

Tool Call: Edit wagtail/tests/test_blocks.py Status: Completed

Diff: /Users/sabdullah/Code/github/wagtail/docker-wagtail-develop/wagtail/wagtail/tests/test_blocks.py

            block.clean(value)

    def test_clean_deferred_with_field_block_shared_with_list_block_child(self):
        # The same FieldBlock is used both as a direct StreamBlock child
        # and as the child_block of a sibling ListBlock, making it reachable
        # via more than one path.
        shared = blocks.CharBlock(required=True)
        block = blocks.StreamBlock(
            [

Assistant

Now the TypedTableBlock test:

Tool Call: Edit wagtail/contrib/typed_table_block/tests.py Status: Completed

Diff: /Users/sabdullah/Code/github/wagtail/docker-wagtail-develop/wagtail/wagtail/contrib/typed_table_block/tests.py

        self.assertEqual(exc_info.exception.cell_errors[1][0].code, "required")

    def test_defer_restores_required_for_shared_column_block(self):
        # The same FieldBlock is used as more than one TypedTableBlock column,
        # making it reachable via more than one path.
        shared = blocks.CharBlock(required=True)
        block = TypedTableBlock([("a", shared), ("b", shared)])
        self.assertIs(block.child_blocks["a"], block.child_blocks["b"])

Assistant

Run the suite to confirm nothing broke (just a comment change but worth a quick check):

Tool Call: source venv/bin/activate && DATABASE_NAME=default.sqlite3 ./runtests.py --verbosity=1 --parallel --keepdb --exclude-tag=transaction wagtail.tests.test_blocks wagtail.contrib.typed_table_block.tests 2>&1 | grep -E "^Ran|^OK|^FAIL" Status: Completed

Terminal:

Ran 404 tests in 2.938s
OK (expected failures=1)




Assistant

All tests still pass. The five new test comments now describe the test scenario (what blocks are arranged how, and the structural property that makes it interesting) without referencing implementation details like "call-site guard" / "cascade" or cross-referring to other tests.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment