Skip to content

c108.dataclasses

Dataclasses tools.

Provides a decorator that adds a merge() method to dataclasses, enabling functional-style updates with sentinel values to distinguish "not provided" from None or other values.

Mergeable

Bases: Protocol[T]

Protocol for classes decorated with @mergeable.

Source code in c108/dataclasses.py
29
30
31
32
33
34
35
@runtime_checkable
class Mergeable(Protocol[T]):
    """Protocol for classes decorated with @mergeable."""

    def merge(self: T, **kwargs: Any) -> T:
        """Create a new instance with selectively updated fields."""
        ...

merge(**kwargs)

Create a new instance with selectively updated fields.

Source code in c108/dataclasses.py
33
34
35
def merge(self: T, **kwargs: Any) -> T:
    """Create a new instance with selectively updated fields."""
    ...

mergeable(cls=None, *, sentinel=UNSET, include=None, exclude=None, include_private=True)

Decorator that adds a merge() method to a dataclass for creating modified copies.

The merge() method creates a new instance with selectively updated fields, using a sentinel value to distinguish "not provided" from None or other values.

Similar to dataclasses.replace() but with sentinel support and chainable syntax.

Can be used with or without parentheses

@mergeable # Uses all defaults @mergeable() # Same as above @mergeable(sentinel=None) # With parameters

Parameters:

Name Type Description Default
cls type[T] | None

The dataclass to decorate (automatically provided when used without parentheses)

None
sentinel

Sentinel value indicating "use existing value" (default: UNSET). Common values: UNSET, MISSING (for distinguishing from None) or None.

UNSET
include list[str] | None

If provided, ONLY these fields can be merged (whitelist mode). Overrides default field discovery. Can explicitly include private fields. Cannot be used together with exclude.

None
exclude list[str] | None

If provided, these fields are excluded from merging (blacklist mode). Applied after default field discovery. Cannot be used together with include.

None
include_private bool

If True (default), private fields (starting with '_') can be merged, matching dataclasses.replace() behavior. If False, private fields are excluded by default.

True

Returns:

Type Description
type[Mergeable[T]] | Callable[[type[T]], type[Mergeable[T]]]

Decorated class with merge() method added, or decorator function if called with arguments.

Raises:

Type Description
TypeError

If cls is not a dataclass

ValueError

If both include and exclude are specified

ValueError

If include/exclude reference non-existent fields

ValueError

If include references fields with init=False

ValueError

If include references InitVar fields

Examples:

>>> @mergeable
... @dataclass
... class Config:
...     timeout: int = 30
...     retries: int = 3
...     def merge(self, **kwargs) -> Self:
...         '''New Config instance with selectively updated fields'''
...         # This is a stub for Docs and type hinting
...         raise NotImplementedError("Implementation handled by @mergeable")
>>> c1 = Config()
>>> c2 = c1.merge(timeout=60)
>>> c2.timeout
60
>>> @mergeable(sentinel=None)
... @dataclass
... class Options:
...     value: int | None = 5
...     def merge(self, **kwargs) -> Self:
...         '''New Options instance with selectively updated fields'''
>>> o1 = Options()
>>> o2 = o1.merge(value=None)  # None means "keep existing"
>>> o2.value
5
>>> @mergeable(include=['timeout'])
... @dataclass
... class Limited:
...     timeout: int = 30
...     internal: int = 99
...     def merge(self, **kwargs) -> Self:
...         '''New Limited instance with selectively updated fields'''
>>> lim = Limited()
>>> lim.merge(timeout=60)
Limited(timeout=60, internal=99)
Notes
  • Only fields with init=True are mergeable
  • InitVar fields are never mergeable
  • Fields with init=False are reset to defaults in new instance
  • Uses shallow copy semantics for field values
Source code in c108/dataclasses.py
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
def mergeable(
    cls: type[T] | None = None,
    *,
    sentinel=UNSET,
    include: list[str] | None = None,
    exclude: list[str] | None = None,
    include_private: bool = True,
) -> type[Mergeable[T]] | Callable[[type[T]], type[Mergeable[T]]]:
    """
    Decorator that adds a merge() method to a dataclass for creating modified copies.

    The merge() method creates a new instance with selectively updated fields,
    using a sentinel value to distinguish "not provided" from None or other values.

    Similar to dataclasses.replace() but with sentinel support and chainable syntax.

    Can be used with or without parentheses:
        @mergeable              # Uses all defaults
        @mergeable()            # Same as above
        @mergeable(sentinel=None)  # With parameters

    Args:
        cls: The dataclass to decorate (automatically provided when used without parentheses)
        sentinel: Sentinel value indicating "use existing value" (default: UNSET).
                  Common values: UNSET, MISSING (for distinguishing from None) or None.
        include: If provided, ONLY these fields can be merged (whitelist mode).
                 Overrides default field discovery. Can explicitly include private fields.
                 Cannot be used together with exclude.
        exclude: If provided, these fields are excluded from merging (blacklist mode).
                 Applied after default field discovery.
                 Cannot be used together with include.
        include_private: If True (default), private fields (starting with '_')
                        can be merged, matching dataclasses.replace() behavior.
                        If False, private fields are excluded by default.

    Returns:
        Decorated class with merge() method added, or decorator function if called with arguments.

    Raises:
        TypeError: If cls is not a dataclass
        ValueError: If both include and exclude are specified
        ValueError: If include/exclude reference non-existent fields
        ValueError: If include references fields with init=False
        ValueError: If include references InitVar fields

    Examples:
        >>> @mergeable
        ... @dataclass
        ... class Config:
        ...     timeout: int = 30
        ...     retries: int = 3
        ...     def merge(self, **kwargs) -> Self:
        ...         '''New Config instance with selectively updated fields'''
        ...         # This is a stub for Docs and type hinting
        ...         raise NotImplementedError("Implementation handled by @mergeable")

        >>> c1 = Config()
        >>> c2 = c1.merge(timeout=60)
        >>> c2.timeout
        60

        >>> @mergeable(sentinel=None)
        ... @dataclass
        ... class Options:
        ...     value: int | None = 5
        ...     def merge(self, **kwargs) -> Self:
        ...         '''New Options instance with selectively updated fields'''

        >>> o1 = Options()
        >>> o2 = o1.merge(value=None)  # None means "keep existing"
        >>> o2.value
        5

        >>> @mergeable(include=['timeout'])
        ... @dataclass
        ... class Limited:
        ...     timeout: int = 30
        ...     internal: int = 99
        ...     def merge(self, **kwargs) -> Self:
        ...         '''New Limited instance with selectively updated fields'''

        >>> lim = Limited()
        >>> lim.merge(timeout=60)
        Limited(timeout=60, internal=99)

    Notes:
        - Only fields with init=True are mergeable
        - InitVar fields are never mergeable
        - Fields with init=False are reset to defaults in new instance
        - Uses shallow copy semantics for field values
    """

    def decorator(target_cls: type[T]) -> type[Mergeable[T]]:
        # Validate it's a dataclass
        if not hasattr(target_cls, "__dataclass_fields__"):
            raise TypeError(f"{target_cls.__name__} must be a dataclass")

        # Validate include/exclude not both specified
        if include is not None and exclude is not None:
            raise ValueError("Cannot specify both 'include' and 'exclude' parameters")

        # Get all fields
        all_fields = fields(target_cls)
        field_map = {f.name: f for f in all_fields}

        # Get fields with init=True (candidates for merging)
        init_fields = [f for f in all_fields if f.init and not _is_init_var(f)]

        # Determine mergeable fields
        if include is not None:
            # Whitelist mode
            mergeable_names = set(include)

            # Validate all included fields exist
            for name in include:
                attr = getattr(target_cls, name, None)
                if attr is None:
                    raise ValueError(f"Attribute '{name}' not found in {target_cls.__name__}")
                if _is_init_var(attr):
                    raise ValueError(f"Attribute '{name}' is an InitVar and cannot be merged")
                if name not in field_map:
                    raise ValueError(f"Field '{name}' does not exist on {target_cls.__name__}")
                field = field_map[name]
                if not field.init:
                    raise ValueError(f"Field '{name}' has init=False and cannot be merged")

        elif exclude is not None:
            # Blacklist mode - start with init fields
            if include_private:
                base_names = {f.name for f in init_fields}
            else:
                base_names = {f.name for f in init_fields if not f.name.startswith("_")}

            # Filter out excluded fields (only exclude fields that actually exist)
            mergeable_names = base_names - {name for name in exclude if name in field_map}

        else:
            # Default mode
            if include_private:
                mergeable_names = {f.name for f in init_fields}
            else:
                mergeable_names = {f.name for f in init_fields if not f.name.startswith("_")}

        # Determine sentinel check function
        def __check_fn(value, default):
            return default if value is sentinel else value

        # Create merge method
        def merge(self, **kwargs):
            # Validate all kwargs are valid mergeable fields
            invalid = set(kwargs.keys()) - mergeable_names
            if invalid:
                raise TypeError(
                    f"merge() got unexpected keyword argument(s): {', '.join(sorted(invalid))}"
                )

            # Build merged dict for all init=True fields
            merged = {}
            for field in init_fields:
                fname = field.name
                if fname in mergeable_names and fname in kwargs:
                    # Mergeable field with new value provided
                    merged[fname] = __check_fn(kwargs[fname], getattr(self, fname))
                else:
                    # Not mergeable or not provided - use existing value
                    merged[fname] = getattr(self, fname)

            return target_cls(**merged)

        # Build signature for IDE support
        hints = get_type_hints(target_cls)
        params = [inspect.Parameter("self", inspect.Parameter.POSITIONAL_OR_KEYWORD)]

        for fname in sorted(mergeable_names):
            annotation = hints.get(fname, Any)
            params.append(
                inspect.Parameter(
                    fname, inspect.Parameter.KEYWORD_ONLY, default=sentinel, annotation=annotation
                )
            )

        merge.__signature__ = inspect.Signature(params, return_annotation=target_cls)
        merge.__annotations__ = {fname: hints.get(fname, Any) for fname in mergeable_names}
        merge.__annotations__["return"] = target_cls

        # Attach to class
        target_cls.merge = merge

        return cast(type[Mergeable[T]], target_cls)

    # Support both @mergeable and @mergeable()
    if cls is not None:
        # Called as @mergeable without parentheses
        return decorator(cls)
    else:
        # Called as @mergeable() with parentheses (or with arguments)
        return decorator