Skip to content

Commit e4cebbc

Browse files
Add presence tracking to structures (#52)
1 parent 12bca60 commit e4cebbc

File tree

1 file changed

+205
-3
lines changed

1 file changed

+205
-3
lines changed

designs/shapes.md

Lines changed: 205 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -253,12 +253,16 @@ whose constructors only allow keyword arguments. For example:
253253

254254
```python
255255
class ExampleStructure:
256+
required_param: str
257+
struct_param: OtherStructure
258+
optional_param: str | None
259+
256260
def __init__(
257261
self,
258262
*, # This prevents positional arguments
259263
required_param: str,
260264
struct_param: OtherStructure,
261-
optional_param: Optional[str] = None,
265+
optional_param: str | None = None
262266
):
263267
self.required_param = required_param
264268
self.struct_param = struct_param
@@ -270,14 +274,14 @@ class ExampleStructure:
270274
"StructParam": self.struct_param.as_dict(),
271275
}
272276

273-
if self.optional_param:
277+
if self.optional_param is not None:
274278
d["OptionalParam"] = self.optional_param
275279

276280
@staticmethod
277281
def from_dict(d: Dict) -> ExampleStructure:
278282
return ExampleStructure(
279283
required_param=d["RequiredParam"],
280-
struct_param=OtherStructure.from_dict(d["StructParam"])
284+
struct_param=OtherStructure.from_dict(d["StructParam"]),
281285
optional_param=d.get("OptionalParam"),
282286
)
283287
```
@@ -369,6 +373,204 @@ print(example_dict.get("OptionalParam")) # None
369373
This is a small example of a minor annoyance, but one that you must always be
370374
aware of when using dicts.
371375

376+
### Default Values
377+
378+
Default values on structures are indicated by wrapping them in a simple class.
379+
380+
```python
381+
class _DEFAULT:
382+
def __init__(self, wrapped: Any):
383+
"""Wraps a value to signal it was provided by default.
384+
385+
These values will be immediately unwrapped in the associated
386+
initializers so the values can be used as normal, the defaultedness
387+
will then be tracked separately.
388+
"""
389+
self._wrapped = wrapped
390+
391+
@property
392+
def value(self) -> Any:
393+
# Prevent mutations from leaking by simply returning copies of mutable
394+
# defaults. We could also just make immutable subclasses.
395+
if isinstance(self._wrapped, list):
396+
return list(self._wrapped)
397+
if isinstance(self._wrapped, dict):
398+
return dict(self._wrapped)
399+
return self._wrapped
400+
401+
def __repr__(self) -> str:
402+
return f"_DEFAULT({repr(self._wrapped)})"
403+
404+
def __str__(self) -> str:
405+
return str(self._wrapped)
406+
407+
408+
D = TypeVar("D")
409+
410+
411+
def _default(value: D) -> D:
412+
"""Wraps a value to signal it was provided by default.
413+
414+
These values will be immediately unwrapped in the associated
415+
initializers so the values can be used as normal, the defaultedness
416+
will then be tracked separately.
417+
418+
We use this wrapper function for brevity, but also because many completion
419+
tools will show the code of the default rather than the result, and
420+
`_default(7)` is a bit more clear than `cast(int, _DEFAULT(7))`.
421+
"""
422+
return cast(D, _DEFAULT(value))
423+
424+
425+
class StructWithDefaults:
426+
default_int: int
427+
default_list: list
428+
429+
def __init__(
430+
self,
431+
*,
432+
default_int: int = _default(7),
433+
default_list: list = _default([]),
434+
):
435+
self._has: dict[str, bool] = {}
436+
self._set_default_attr("default_int", default_int)
437+
self._set_default_attr("default_list", default_list)
438+
439+
def _set_default_attr(self, name: str, value: Any) -> None:
440+
# Setting the attributes this way saves a ton of lines of repeated
441+
# code.
442+
if isinstance(value, _DEFAULT):
443+
object.__setattr__(self, name, value.value())
444+
self._has[name] = False
445+
else:
446+
setattr(self, name, value)
447+
448+
def __setattr__(self, name: str, value: Any) -> None:
449+
object.__setattr__(self, name, value)
450+
self._has[name] = True
451+
452+
def _hasattr(self, name: str) -> bool:
453+
if self._has[name]:
454+
return True
455+
# Lists and dicts are mutable. We could make immutable variants, but
456+
# that's kind of a bad experience. Instead we can just check to see if
457+
# the value is empty.
458+
if isinstance((v := getattr(self, name)), (dict, list)) and len(v) == 0:
459+
self._has[name] = True
460+
return True
461+
return False
462+
```
463+
464+
One of the goals of customizable default values is to reduce the amount of
465+
nullable members that are exposed. With that in mind, the typical strategy of
466+
assigning the default value to `None` can't be used since that implicitly adds
467+
`None` to the type signature. That would also make IntelliSense marginally
468+
worse since you can't easily see the actual default value.
469+
470+
Instead, a default wrapper is used. The presence of the wrapper signifies to
471+
the initializer function that a default was used. The value is then immediately
472+
unwrapped so it can be used where needed. The defaultedness is stored in an
473+
internal dict that is updated whenever the property is re-assigned. A private
474+
function exists to give the serializer this information.
475+
476+
To make this wrapper class pass the type checker, it is simply "cast" to the
477+
needed type. This isn't a problem since the true value is immediately unwrapped
478+
in the initializer. A wrapper function performs the actual wrapping. This has
479+
the advantage of not requiring the type signature to be repeated since it can
480+
be inferred from the type of the function's input. It also has the advantage of
481+
looking a bit nicer in many IntelliSense tools, who show the code assigned to
482+
as the default value rather than the resolved value.
483+
484+
#### Alternative: Subclassing
485+
486+
One potential alternative is to create "default" subclasses of the various
487+
defaultable types.
488+
489+
```python
490+
class _DEFAULT_INT(int):
491+
pass
492+
493+
494+
class _DEFAULT_STR(str):
495+
pass
496+
497+
498+
class WithWrappers:
499+
def __init__(
500+
self,
501+
*,
502+
default_int: int = _DEFAULT_INT(7),
503+
default_str: str = _DEFAULT_STR("foo"),
504+
):
505+
self.default_int = default_int
506+
self.default_str = default_str
507+
```
508+
509+
The advantage of this is that it requires no upkeep and no lying to the type
510+
system. These values are real, normal value that can be used everywhere their
511+
base classes can. During serialization we can check if it's the default
512+
type.
513+
514+
Unfortunately, this isn't wholly possible because not all of the defaultable
515+
values can be subclassed. Neither `bool` nor `NoneType` can have subclasses,
516+
so we'd need to create our own sentinel values. This risks unexpected behavior
517+
if a customer were to use an `is` check.
518+
519+
#### Alternative: kwargs
520+
521+
Another possible alternative is to use the keyword arguments dictionary
522+
feature.
523+
524+
```python
525+
class _WithKwargsType(TypedDict):
526+
default_int: NotRequired[int]
527+
default_str: NotRequired[str]
528+
default_list: NotRequired[list[str]]
529+
530+
531+
class WithKwargs:
532+
default_int: int
533+
default_str: str
534+
default_list: list[str]
535+
536+
# This syntax for typing kwargs requires PEP 692
537+
def __init__(self, **kwargs: **_WithKwargsType):
538+
self._has = {}
539+
self.default_int = kwargs.get("default_int", 7)
540+
self._has["default_int"] = "default_int" in kwargs
541+
self.default_str = kwargs.get("default_str", "foo")
542+
self._has["default_str"] = "default_str" in kwargs
543+
self.default_list = kwargs.get("default_list", [])
544+
self._has["default_list"] = "default_list" in kwargs
545+
546+
def __setattr__(self, name: str, value: Any) -> None:
547+
object.__setattr__(self, name, value)
548+
self._has[name] = True
549+
550+
def _hasattr(self, name: str) -> bool:
551+
if self._has[name]:
552+
return True
553+
if isinstance((v := getattr(self, name)), (dict, list)) and len(v) == 0:
554+
self._has[name] = True
555+
return True
556+
return False
557+
```
558+
559+
This leverages another feature of python that natively allows for presence
560+
checks. The kwargs dictionary implicitly contains that metadata because keys
561+
not set simply aren't present. This otherwise uses the same internal dict
562+
mechanism to continue to keep track of defaultedness.
563+
564+
The major disadvantage to this is that it essentially forgoes IntelliSense
565+
and type checking until [PEP 692](https://peps.python.org/pep-0692/) lands.
566+
This isn't expected to happen until 3.12 at the earliest, which is expected
567+
in late 2023 / early 2024. Then the tools need to be updated for support,
568+
which isn't straight-forward.
569+
570+
Another disadvantage is that it excludes the ability to include the default
571+
value in the IntelliSense since the typing of kwargs relies on TypedDicts
572+
which don't support default values.
573+
372574
## Errors
373575

374576
Modeled errors are specialized structures that have a `code` and `message`.

0 commit comments

Comments
 (0)