|
| 1 | +# maybe-missing |
| 2 | + |
| 3 | +`maybe-missing` is a tiny typed helper for one annoyingly real distinction: |
| 4 | + |
| 5 | +- `None` means “the caller explicitly sent null” |
| 6 | +- `MISSING` means “the caller did not send this value at all” |
| 7 | + |
| 8 | +That difference matters in patch APIs, dataclasses, settings layers, and anywhere a database column is nullable but an omitted field should mean “leave it alone”. |
| 9 | + |
| 10 | +## Why this exists |
| 11 | + |
| 12 | +Python’s `Optional[T]` answers only one question: can the value be `None`? |
| 13 | + |
| 14 | +It does **not** answer the other question: was there a value in the first place? |
| 15 | + |
| 16 | +That becomes awkward fast: |
| 17 | + |
| 18 | +- JSON `null` is a valid payload value |
| 19 | +- a nullable Postgres column may need to be set to `NULL` intentionally |
| 20 | +- omitting a field in a PATCH request should usually mean “don’t update it” |
| 21 | +- a dataclass default should sometimes mean “missing”, not “defaulted to null” |
| 22 | + |
| 23 | +So this package gives you exactly two public names: |
| 24 | + |
| 25 | +- `Maybe[T]` |
| 26 | +- `MISSING` |
| 27 | + |
| 28 | +And then gets out of your way. |
| 29 | + |
| 30 | +## Installation |
| 31 | + |
| 32 | +```bash |
| 33 | +pip install maybe-missing |
| 34 | +``` |
| 35 | + |
| 36 | +## Usage |
| 37 | + |
| 38 | +```python |
| 39 | +from maybe_missing import Maybe, MISSING |
| 40 | + |
| 41 | + |
| 42 | +def update_display_name(display_name: Maybe[str | None] = MISSING) -> None: |
| 43 | + if display_name is MISSING: |
| 44 | + print("leave the existing value alone") |
| 45 | + elif display_name is None: |
| 46 | + print("explicitly store NULL") |
| 47 | + else: |
| 48 | + print(f"store {display_name!r}") |
| 49 | +``` |
| 50 | + |
| 51 | +## Dataclass example |
| 52 | + |
| 53 | +```python |
| 54 | +from dataclasses import dataclass |
| 55 | + |
| 56 | +from maybe_missing import Maybe, MISSING |
| 57 | + |
| 58 | + |
| 59 | +@dataclass(slots=True) |
| 60 | +class UserPatch: |
| 61 | + nickname: Maybe[str | None] = MISSING |
| 62 | + bio: Maybe[str | None] = MISSING |
| 63 | + |
| 64 | + |
| 65 | +def apply_patch(patch: UserPatch) -> None: |
| 66 | + if patch.nickname is not MISSING: |
| 67 | + # update nickname to a string or to NULL |
| 68 | + ... |
| 69 | + |
| 70 | + if patch.bio is not MISSING: |
| 71 | + # update bio to a string or to NULL |
| 72 | + ... |
| 73 | +``` |
| 74 | + |
| 75 | +## API example |
| 76 | + |
| 77 | +Imagine a request body for partial updates: |
| 78 | + |
| 79 | +```json |
| 80 | +{ |
| 81 | + "nickname": null |
| 82 | +} |
| 83 | +``` |
| 84 | + |
| 85 | +This should mean: |
| 86 | + |
| 87 | +- `nickname` was provided |
| 88 | +- its value is explicitly `null` |
| 89 | +- the server should write `NULL` |
| 90 | + |
| 91 | +While this body: |
| 92 | + |
| 93 | +```json |
| 94 | +{} |
| 95 | +``` |
| 96 | + |
| 97 | +should mean: |
| 98 | + |
| 99 | +- `nickname` was not provided |
| 100 | +- keep the current value as is |
| 101 | + |
| 102 | +`Maybe[T]` helps model that cleanly in Python code. |
| 103 | + |
| 104 | +## There are only a couple of lines though |
| 105 | + |
| 106 | +Yes. |
| 107 | + |
| 108 | +There are only a couple of lines. |
| 109 | + |
| 110 | +And that’s exactly why it’s more pleasant not to duplicate them across your projects and just do: |
| 111 | + |
| 112 | +```python |
| 113 | +from maybe_missing import Maybe, MISSING |
| 114 | +``` |
| 115 | + |
| 116 | +Isn’t it? |
| 117 | + |
| 118 | +## Typing |
| 119 | + |
| 120 | +The package includes `py.typed`, so type checkers and IDEs can treat it as a typed distribution. |
| 121 | + |
| 122 | +## Compatibility |
| 123 | + |
| 124 | +This package currently targets Python 3.10+. |
0 commit comments