|
12 | 12 |
|
13 | 13 | import bisect |
14 | 14 | import dataclasses |
15 | | -from collections.abc import Sequence |
| 15 | +from collections.abc import Mapping, Sequence |
16 | 16 | from dataclasses import dataclass |
17 | 17 | from enum import Enum, auto |
18 | | -from typing import TypeVar |
| 18 | +from types import MappingProxyType |
| 19 | +from typing import TYPE_CHECKING, TypeVar |
19 | 20 |
|
20 | 21 | from nameparser._lexicon import Lexicon |
21 | 22 | from nameparser._policy import Policy |
22 | 23 | from nameparser._types import (SHAPE_ACRONYM_TAG, AmbiguityKind, |
23 | 24 | Role, Segmenter, Span) |
24 | 25 |
|
25 | 26 |
|
26 | | -_T = TypeVar("_T") |
27 | | - |
28 | | -#: Field names per class, recorded the first time `copy_with` copies one. |
29 | | -_COPY_FIELDS: dict[type, tuple[str, ...]] = {} |
30 | | - |
31 | | - |
32 | | -def copy_with(obj: _T, /, **changes: object) -> _T: |
33 | | - """`dataclasses.replace` for the pipeline's own dataclasses, without |
34 | | - its per-call cost. |
35 | | -
|
36 | | - Every stage returns a copy of the state, and several copy tokens one |
37 | | - at a time, so this runs many times per parse. The stdlib replace |
38 | | - walks `fields()` and goes through `__init__` on every call; for a |
39 | | - class whose `__init__` only assigns its fields, copying the fields |
40 | | - directly gives the same object. `_copy_fields` checks that once per |
41 | | - class and refuses any class where it would not hold |
42 | | - (decisions.md#parse-cost has the measurement). |
43 | | - """ |
44 | | - cls = type(obj) |
45 | | - names = _COPY_FIELDS.get(cls) or _copy_fields(cls) |
46 | | - new = object.__new__(cls) |
47 | | - for name in names: |
48 | | - value = changes.pop(name) if name in changes else getattr(obj, name) |
49 | | - object.__setattr__(new, name, value) |
50 | | - if changes: |
51 | | - raise TypeError( |
52 | | - f"{cls.__name__} has no field named {', '.join(sorted(changes))}") |
53 | | - return new |
54 | | - |
55 | | - |
56 | | -def _copy_fields(cls: type) -> tuple[str, ...]: |
57 | | - params = getattr(cls, "__dataclass_params__", None) |
58 | | - if (params is None or not params.init or hasattr(cls, "__post_init__") |
59 | | - or not all(f.init for f in dataclasses.fields(cls))): |
60 | | - raise TypeError( |
61 | | - f"copy_with cannot copy {cls.__name__}: it needs a dataclass " |
62 | | - "whose generated __init__ only assigns its fields") |
63 | | - names = _COPY_FIELDS[cls] = tuple(f.name for f in dataclasses.fields(cls)) |
64 | | - return names |
65 | | - |
66 | | - |
67 | 27 | # The comma characters (ASCII/Arabic/fullwidth, #265). Shared here so |
68 | 28 | # tokenize (separators/segmentation) and extract (close-quote |
69 | 29 | # boundaries) cannot drift apart. |
@@ -253,3 +213,57 @@ class ParseState: |
253 | 213 | #: lean) rather than asking again. |
254 | 214 | one_case: bool | None = None |
255 | 215 | ambiguities: tuple[PendingAmbiguity, ...] = () |
| 216 | + |
| 217 | + |
| 218 | +def _copyable_fields(cls: type) -> tuple[str, ...]: |
| 219 | + """The fields `copy_with` carries for `cls`, or TypeError where a |
| 220 | + field copy would not build what `dataclasses.replace` builds: the |
| 221 | + class must be decorated itself (not inherit the decoration) and keep |
| 222 | + the generated `__init__`, with no `__post_init__` and no |
| 223 | + `init=False` field.""" |
| 224 | + params = cls.__dict__.get("__dataclass_params__") |
| 225 | + init = cls.__dict__.get("__init__") |
| 226 | + # dataclasses compiles the __init__ it generates from a string; one |
| 227 | + # written in the class body carries its source file instead. |
| 228 | + generated = init is not None and init.__code__.co_filename == "<string>" |
| 229 | + if (params is None or not generated |
| 230 | + or hasattr(cls, "__post_init__") |
| 231 | + or not all(f.init for f in dataclasses.fields(cls))): |
| 232 | + raise TypeError( |
| 233 | + f"copy_with cannot copy {cls.__name__}: it needs a dataclass " |
| 234 | + "whose generated __init__ only assigns its fields") |
| 235 | + return tuple(f.name for f in dataclasses.fields(cls)) |
| 236 | + |
| 237 | + |
| 238 | +#: Built once, at import: a pipeline class that stops qualifying fails |
| 239 | +#: here rather than in a parse, and `copy_with` copies nothing else. |
| 240 | +_COPY_FIELDS: Mapping[type, tuple[str, ...]] = MappingProxyType({ |
| 241 | + cls: _copyable_fields(cls) |
| 242 | + for cls in (WorkToken, PendingAmbiguity, ParseState)}) |
| 243 | + |
| 244 | +_T = TypeVar("_T") |
| 245 | + |
| 246 | +if TYPE_CHECKING: |
| 247 | + # mypy's dataclass plugin checks replace's keywords against the |
| 248 | + # class, which a `**changes: object` signature would not. |
| 249 | + from dataclasses import replace as copy_with |
| 250 | +else: |
| 251 | + def copy_with(obj: _T, /, **changes: object) -> _T: |
| 252 | + """`dataclasses.replace` for the pipeline's own dataclasses, |
| 253 | + without its per-call cost: a direct field copy, which for the |
| 254 | + classes in `_COPY_FIELDS` builds the same object |
| 255 | + (decisions.md#parse-cost has the measurement).""" |
| 256 | + cls = type(obj) |
| 257 | + names = _COPY_FIELDS.get(cls) |
| 258 | + if names is None: |
| 259 | + raise TypeError( |
| 260 | + f"copy_with copies only the pipeline's own dataclasses, " |
| 261 | + f"not {cls.__name__}") |
| 262 | + new = object.__new__(cls) |
| 263 | + for name in names: |
| 264 | + value = changes.pop(name) if name in changes else getattr(obj, name) |
| 265 | + object.__setattr__(new, name, value) |
| 266 | + if changes: |
| 267 | + raise TypeError(f"{cls.__name__} has no field named " |
| 268 | + f"{', '.join(sorted(changes))}") |
| 269 | + return new |
0 commit comments