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.
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 = {
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
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
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()
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
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
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(),
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
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)
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:
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():
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)
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
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 """
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
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):
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]
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
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.txtPreview (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
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
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):
Now I have enough to give a complete 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.
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 classhero.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:
- First call:
_original_required = True;field.required = False. - Second call:
_original_required = self.required = self.field.required = False(corrupted);field.required = False.
Then on restore (wagtail/blocks/stream_block.py:241-244 → wagtail/blocks/struct_block.py:323-326 → wagtail/blocks/field_block.py:76-78):
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."
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.
The same bug fires whenever a single FieldBlock instance is reachable from two paths in the schema during one clean_deferred cycle:
- The same
StructBlocksubclass used as twoStreamBlockchild types (above). - A single block object explicitly aliased into two slots (e.g.
s1 = shared; s2 = sharedin aStreamBlock/StructBlockbody, or the same instance passed twice intolocal_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.
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.
I assume this is not specific to ImageChooserBlock then, i.e. it can be replicated even with a simple CharBlock?
Tool Call: grep -n "^class CharBlock" wagtail/blocks/field_block.py Status: Completed
158:class CharBlock(FieldBlock):
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 permanentlyTrace through FieldBlock.defer_required_validation:
- First call (via
hero):_original_required = True,field.required = False. - Second call (via
featured, same instance):_original_required = self.required = False,field.required = False. - 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.
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)
Tool Call: ToolSearch Status: Completed
Tool: TaskCreate
Tool: TaskUpdate
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
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
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):
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(
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):
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).
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)
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.
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)
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'...
All 383 tests pass (the 1 expected failure is pre-existing).Summary:
- Fix (
wagtail/blocks/field_block.py:65-75): madeFieldBlock.defer_required_validationidempotent by returning early whenis_deferred_validationis alreadyTrue. This prevents the second defer call from clobbering_original_requiredwith the already-deferred (False) value. Also tidied theFalse or getattr(...)expression tobool(getattr(...))since the literalFalsewas a no-op. - Regression test (
wagtail/tests/test_blocks.py,TestStreamBlock.test_clean_deferred_restores_required_for_shared_field_blocks): builds aStreamBlockwith twoHeroBlockchild types (aStructBlocksubclass declaringtitle = CharBlock(required=True)at class level), confirms the twoHeroBlockinstances share the sameCharBlockobject, runsclean_deferred, then asserts thatrequiredis restored toTrueand that a subsequentclean()rejects an emptytitle. - Verification: confirmed the test fails on the unfixed code (assertion
shared_title.required is Truefails afterclean_deferred) and passes once the fix is in place. Fullwagtail.tests.test_blockssuite — 383 tests — passes.
- Make FieldBlock.defer_required_validation idempotent
- Add regression test in wagtail.tests.test_blocks
- Run the test suite
Do you think this is the best way to fix the issue? What other approaches might be better?
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.
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?
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?
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.
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
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
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
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):
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):
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):
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>
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:
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()
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'...
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:
Summary of the swap:
- Reverted
FieldBlock.defer_required_validationto its original form (no leaf-level guard). - Added call-site
is_deferred_validationchecks in the four container blocks that cascade defer/restore:wagtail/blocks/stream_block.py:164-175(defer) and247-251(restore)wagtail/blocks/struct_block.py:304-308(defer) and324-328(restore)wagtail/blocks/list_block.py:200-203(defer) and252-255(restore)wagtail/contrib/typed_table_block/blocks.py:364-373(both)
- A single short comment in
BaseStreamBlockexplains 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.
- Swap to call-site dedupe in container blocks
- Add regression test in wagtail.tests.test_blocks
- Re-run tests after swap
Does this mean the we should have tests for the changes in StreamBlock, StructBlock, ListBlock, and TypedTableBlock? Or was this specific to StreamBlock?
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:
StreamBlockiterates[hero, featured]— two distinct objects, both withis_deferred_validation=False. The StreamBlock guard never fires.hero.defer_required_validationiterates itschild_blocks, hits the sharedCharBlock, defers it.featured.defer_required_validationiterates itschild_blocks, hits the sameCharBlock— this 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 twoStreamBlockchild types directly. - ListBlock: a
ListBlock(shared_block)inside aStreamBlockwhereshared_blockis also referenced from another child of the sameStreamBlock. - TypedTableBlock: same shape as the StructBlock test, but with a
TypedTableBlockdeclaring shared column blocks. (Lives inwagtail/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?
yes please
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"]},
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 ],
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,
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)
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()
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)
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.
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.
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):
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.
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):
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.
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:
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'...
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(-)
All done. Summary:
Fixes (call-site dedupe at each cascading container's defer/restore methods):
wagtail/blocks/stream_block.py—BaseStreamBlockwagtail/blocks/struct_block.py—BaseStructBlockwagtail/blocks/list_block.py—ListBlockwagtail/contrib/typed_table_block/blocks.py—BaseTypedTableBlock
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 (twoHeroBlockinstances sharing a class-levelCharBlock).wagtail/tests/test_blocks.py::TestStreamBlock::test_clean_deferred_with_field_block_aliased_to_multiple_children— exercises the StreamBlock guard (sameCharBlockaliased as twoStreamBlockchildren).wagtail/tests/test_blocks.py::TestStreamBlock::test_clean_deferred_with_field_block_shared_with_list_block_child— exercises the ListBlock guard (aCharBlockis both a directStreamBlockchild and thechild_blockof a siblingListBlock).wagtail/contrib/typed_table_block/tests.py::TestTableBlock::test_defer_restores_required_for_shared_column_block— exercises the TypedTableBlock guard (aCharBlockused 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).
- 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
Since StructBlock also does the shallow copy, does it suffer the same problem without StreamBlock involved?
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 FalseThe 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?
Yes please, add it in TestStructBlock
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):
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)
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]
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.
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.
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):
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)
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 anyStreamFieldinvolvement, demonstrating that the bug surface isn'tStreamField-specific.
Is it possible for the same issue to happen with ListBlock without StreamBlock involved?
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 FalseThe 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.
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
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. ListBlockas the top-level: still one cascade path. Even ifchild_blockis aStructBlockwith shared fields inside it, the bug would be caught by theStructBlockguard, not theListBlockguard.- Two
ListBlocks sharing achild_block: you can construct the twoListBlockobjects, but there's no all-ListBlockcontainer to hold both of them in a singleclean_deferredcycle. You'd need aStructBlock/StreamBlockparent — at which point it's no longer "onlyListBlocks 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.
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
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)),
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)
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)
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"])
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(
[
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"])
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)
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.