@@ -203,17 +203,28 @@ def _reject_str_and_mapping(value: object, field_name: str) -> None:
203203 )
204204
205205
206- def _require_iterable (value : Iterable [Any ], field_name : str ) -> Iterable [Any ]:
207- # Probe with iter() so a non-iterable value (an int, a bool, ...)
208- # raises a message naming the field, matching the treatment
209- # patronymic_rules already gets, instead of a bare "'int' object is
210- # not iterable" surfacing from whatever tuple()/frozenset() call
211- # happens to run first.
206+ def _require_iterable (value : Iterable [Any ], field_name : str ,
207+ expected : str = "an iterable" ) -> Iterable [Any ]:
208+ """Probe a value's iterability and return its iterator, raising a
209+ TypeError naming the field if it has none. `expected` carries the
210+ field's own phrasing ("a mapping of Script to order"); the default
211+ suits every plain iterable field.
212+
213+ Probing with iter() is what makes the message possible: a
214+ non-iterable (an int, a bool, ...) is named here, matching the
215+ treatment patronymic_rules already gets, instead of a bare "'int'
216+ object is not iterable" surfacing from whatever tuple()/frozenset()
217+ happens to run first. It is ALSO the reason callers consume the
218+ returned iterator OUTSIDE any try of their own: an exception raised
219+ inside a caller's generator while it is being consumed is the
220+ caller's own error, and must propagate untouched rather than be
221+ rewritten as a shape complaint about the field.
222+ """
212223 try :
213224 return iter (value )
214225 except TypeError :
215226 raise TypeError (
216- f"{ field_name } must be an iterable , got { value !r} "
227+ f"{ field_name } must be { expected } , got { value !r} "
217228 ) from None
218229
219230
@@ -284,22 +295,11 @@ def _validated_script_orders(
284295 f"e.g. raw.decode('utf-8')"
285296 )
286297 raw = value .items () if isinstance (value , Mapping ) else value
287- try :
288- # Probe with iter() only (mirrors the patronymic_rules probe in
289- # Policy.__post_init__, and _validated_segment_scripts below):
290- # a genuine non-iterable is caught here and gets the curated
291- # message below, while an exception raised INSIDE a caller's
292- # generator during consumption (the tuple() below) must
293- # propagate untouched, not be rewritten as "not a mapping".
294- raw_iter = _require_iterable (raw , "script_orders" ) # type: ignore[arg-type]
295- except TypeError :
296- raise TypeError (
297- f"script_orders must be a mapping of Script to order, "
298- f"got { value !r} "
299- ) from None
300- entries : tuple [Any , ...] = tuple (raw_iter )
298+ raw_iter = _require_iterable (
299+ raw , "script_orders" , # type: ignore[arg-type]
300+ "a mapping of Script to order" )
301301 canonical : dict [Script , tuple [Role , Role , Role ]] = {}
302- for entry in entries :
302+ for entry in raw_iter :
303303 try :
304304 key , order = entry
305305 except (TypeError , ValueError ):
@@ -320,18 +320,9 @@ def _validated_segment_scripts(value: object) -> frozenset[Script]:
320320 their string values), coerced via _validated_script so the
321321 unknown-script wording stays single-sourced."""
322322 _reject_str_and_mapping (value , "segment_scripts" )
323- # Probe with iter() only (mirrors the patronymic_rules probe in
324- # Policy.__post_init__): a genuine non-iterable is caught here and
325- # gets the curated message below, while an exception raised INSIDE
326- # a caller's generator during consumption (the frozenset() below)
327- # must propagate untouched, not be rewritten as "not an iterable".
328- try :
329- script_iter = _require_iterable (value , "segment_scripts" ) # type: ignore[arg-type]
330- except TypeError :
331- raise TypeError (
332- f"segment_scripts must be an iterable of Script members, "
333- f"got { value !r} "
334- ) from None
323+ script_iter = _require_iterable (
324+ value , "segment_scripts" , # type: ignore[arg-type]
325+ "an iterable of Script members" )
335326 return frozenset (_validated_script (s ) for s in script_iter )
336327
337328
@@ -359,6 +350,35 @@ def _canonical_script_pair(pair: Iterable[Any]) -> tuple[Any, ...]:
359350 return out # non-iterable order value
360351
361352
353+ def _canonical_patch_script_orders (value : object ) -> object :
354+ """Canonicalize a PolicyPatch.script_orders value for hashability
355+ without validating it: malformed shapes are stored so Policy can
356+ quote them at apply time; a caller-generator's own exception
357+ propagates from the UNGUARDED materialization below (deferring is
358+ impossible once a one-shot iterator is consumed)."""
359+ # Excluded HERE rather than delegated: a string's elements are
360+ # themselves tuple-izable, so no TypeError ever fires to signal
361+ # "leave this alone" -- "han" would shred to (("h",), ("a",),
362+ # ("n",)) and Policy's bare-string message would have nothing left
363+ # to quote. bytes shred the same way, into ints.
364+ if isinstance (value , (str , bytes , bytearray , memoryview )):
365+ return value # deferred whole: Policy's guards quote it
366+ # Any, not object: a patch defers validation, so anything a caller
367+ # wrote can arrive here, and the probe below is precisely the
368+ # runtime question mypy has no way to answer statically.
369+ pairs : Any = value .items () if isinstance (value , Mapping ) else value
370+ try :
371+ pairs_iter = iter (pairs ) # probe only
372+ except TypeError :
373+ return value # non-iterable: defer to apply
374+ items = tuple (pairs_iter ) # UNGUARDED: caller errors propagate
375+ try :
376+ return tuple (map (_canonical_script_pair , items ))
377+ except TypeError :
378+ return items # malformed entry: materialized, so
379+ # Policy can still re-iterate + quote
380+
381+
362382@dataclass (frozen = True , slots = True )
363383class Policy :
364384 """The behavior switches a parser runs with: name order,
@@ -627,90 +647,13 @@ def __post_init__(self) -> None:
627647 # from a {Script: order} dict (or a list of pairs) must already
628648 # be hashable, since a Locale holds it -- and hashable all the
629649 # way down, hence _canonical_script_pair. Validation still
630- # belongs to Policy at apply time, so a shape tuple() cannot
631- # digest is left exactly as written for it to report.
632- # The str case must be excluded HERE rather than delegated to
633- # _canonical_script_pair: a string's elements are themselves
634- # tuple-izable, so no TypeError ever fires to signal "leave this
635- # alone" -- "han" would shred to (("h",), ("a",), ("n",)) and
636- # Policy's bare-string message would have nothing left to quote.
637- if self .script_orders is not UNSET and not isinstance (
638- self .script_orders , str ):
639- raw = self .script_orders
640- # Mapping.items() is always iterable, so it needs no probe;
641- # only the non-Mapping branch can fail the iter() check below.
642- pairs = raw .items () if isinstance (raw , Mapping ) else raw
643- # Four outcomes share this block, and only one of them
644- # should propagate immediately:
645- # 1. raw itself isn't iterable at all (script_orders=5) --
646- # caught by the iter() probe. Shape problem, defers to
647- # apply time, where Policy's own guard raises the
648- # curated message ("Policy validates ... at apply").
649- # 2. an already-yielded PAIR has a bad shape (a bare int
650- # from a shredded bytes buffer, e.g. b"han" iterating
651- # to 104) -- caught around _canonical_script_pair
652- # below. Also a shape problem: abort the whole
653- # conversion and leave script_orders as a RE-ITERABLE
654- # value (see case 4) Policy's curated message (e.g. the
655- # decode-first bytes hint, or the bad-pair message) can
656- # still quote at apply time.
657- # 3. the caller's OWN exception, raised by their
658- # generator while it is being consumed -- this is not
659- # a shape problem this guard exists to catch, and
660- # deferral is impossible anyway: by the time
661- # apply_patch reaches Policy.__post_init__, the same
662- # generator is already exhausted, so catching this
663- # here would lose the error for good rather than
664- # merely delay it. Consuming a one-shot iterator
665- # therefore runs UNGUARDED (case 4), so this
666- # propagates on its own terms.
667- # 4. a ONE-SHOT iterator (iter(x) is x -- generators,
668- # map/zip/filter objects, ...) cannot be safely
669- # re-iterated once consumed. Deferring case 2 by
670- # leaving it as originally written -- fine for
671- # re-iterable inputs (bytes, list, dict, tuple) -- would
672- # leave Policy re-validating an EXHAUSTED generator at
673- # apply time: iterating it again silently yields
674- # nothing, storing () ("opt out of per-script
675- # ordering") and dropping the Han/Hangul family-first
676- # defaults with NO error anywhere. So a one-shot input
677- # is eagerly materialized into a tuple first (case 3's
678- # unguarded consumption), and case 2's deferral stores
679- # THAT re-iterable tuple instead of the original,
680- # exhausted generator.
681- try :
682- pairs_iter = iter (pairs )
683- except TypeError :
684- pairs_iter = None # non-iterable: deferred, case 1
685- if pairs_iter is not None :
686- # mypy sees pairs' declared type (tuple[...] | ItemsView)
687- # as never identical to iter(pairs)'s Iterator type, but
688- # at runtime pairs can be anything iterable a caller
689- # wrote (a generator especially) -- the whole point of
690- # this check.
691- one_shot = pairs_iter is pairs # type: ignore[comparison-overlap]
692- items : Iterable [Any ] = tuple (pairs_iter ) if one_shot else pairs
693- canonical = []
694- deferred = False
695- for item in items :
696- try :
697- canonical .append (_canonical_script_pair (item ))
698- except TypeError :
699- deferred = True # bad pair shape: case 2
700- break
701- if not deferred :
702- object .__setattr__ (
703- self , "script_orders" , tuple (canonical ))
704- elif one_shot :
705- # items is already the materialized (re-iterable)
706- # tuple built above -- store it so Policy can
707- # re-examine and quote it at apply time, instead of
708- # the original, now-exhausted generator.
709- object .__setattr__ (self , "script_orders" , items )
710- # else: re-iterable input (bytes/list/dict/tuple/...) is
711- # left exactly as originally written; Policy can freely
712- # re-iterate it at apply time and quote the caller's
713- # value there -- unchanged from the prior behavior.
650+ # belongs to Policy at apply time, so a shape the canonicalizer
651+ # cannot digest is left for it to report; the one-shot and
652+ # malformed-entry cases are _canonical_patch_script_orders'.
653+ if self .script_orders is not UNSET :
654+ object .__setattr__ (
655+ self , "script_orders" ,
656+ _canonical_patch_script_orders (self .script_orders ))
714657 for f in dataclasses .fields (self ):
715658 if f .metadata .get ("compose" ) != "union" :
716659 continue
0 commit comments