Skip to content

Commit fb64c4e

Browse files
committed
Add project files
1 parent b0122c7 commit fb64c4e

8 files changed

Lines changed: 293 additions & 0 deletions

File tree

.gitignore

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# Byte-compiled / optimized / DLL files
2+
__pycache__/
3+
*.py[cod]
4+
5+
# C extensions
6+
*.so
7+
8+
# Distribution / packaging
9+
.Python
10+
env/
11+
build/
12+
develop-eggs/
13+
dist/
14+
downloads/
15+
eggs/
16+
.eggs/
17+
lib/
18+
lib64/
19+
parts/
20+
sdist/
21+
var/
22+
*.egg-info/
23+
.installed.cfg
24+
*.egg
25+
26+
# PyInstaller
27+
# Usually these files are written by a python script from a template
28+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
29+
*.manifest
30+
*.spec
31+
32+
# Installer logs
33+
pip-log.txt
34+
pip-delete-this-directory.txt
35+
36+
# Unit test / coverage reports
37+
htmlcov/
38+
.tox/
39+
.coverage
40+
.coverage.*
41+
.cache
42+
nosetests.xml
43+
coverage.xml
44+
*,cover
45+
46+
# Translations
47+
*.mo
48+
*.pot
49+
50+
# Django stuff:
51+
*.log
52+
53+
# Sphinx documentation
54+
docs/_build/
55+
56+
# PyBuilder
57+
target/
58+
59+
# PyCharm
60+
.idea
61+
62+
# pyenv
63+
.python-version
64+
65+
# OSX
66+
.DS_Store

MANIFEST.in

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
include README.md
2+
recursive-include maybe_missing py.typed *.pyi

README.md

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
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+.

maybe_missing/__init__.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
from enum import Enum as _Enum, auto as _auto
2+
from typing import (
3+
Final as _Final,
4+
Literal as _Literal,
5+
TypeAlias as _TypeAlias,
6+
TypeVar as _TypeVar,
7+
)
8+
9+
__all__ = ["Maybe", "MISSING"]
10+
11+
_T = _TypeVar("_T")
12+
13+
14+
class _MissingSentinel(_Enum):
15+
MISSING = _auto()
16+
17+
18+
Maybe: _TypeAlias = _T | _Literal[_MissingSentinel.MISSING]
19+
20+
MISSING: _Final = _MissingSentinel.MISSING

maybe_missing/__init__.pyi

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import enum
2+
import typing
3+
4+
__all__ = ["Maybe", "MISSING"]
5+
6+
_T = typing.TypeVar("_T")
7+
8+
class _MissingSentinel(enum.Enum):
9+
MISSING = ...
10+
11+
MISSING: typing.Final[_MissingSentinel]
12+
Maybe: typing.TypeAlias = _T | typing.Literal[_MissingSentinel.MISSING]

maybe_missing/py.typed

Whitespace-only changes.

pyproject.toml

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
[build-system]
2+
requires = ["setuptools>=77.0.3"]
3+
build-backend = "setuptools.build_meta"
4+
5+
[project]
6+
name = "maybe-missing"
7+
version = "0.1"
8+
license = "Apache-2.0"
9+
description = "A tiny typed sentinel for distinguishing None from not provided"
10+
readme = "README.md"
11+
authors = [{ name = "Alexander Tikhonov", email = "random.gauss@gmail.com" }]
12+
requires-python = ">=3.10"
13+
keywords = ["typing", "sentinel", "optional", "api", "dataclass", "maybe"]
14+
classifiers = [
15+
"Intended Audience :: Developers",
16+
"Operating System :: OS Independent",
17+
"Programming Language :: Python :: 3",
18+
"Programming Language :: Python :: 3.10",
19+
"Programming Language :: Python :: 3.11",
20+
"Programming Language :: Python :: 3.12",
21+
"Programming Language :: Python :: 3.13",
22+
"Programming Language :: Python :: 3.14",
23+
"Development Status :: 5 - Production/Stable",
24+
"Typing :: Typed",
25+
]
26+
27+
[project.urls]
28+
Homepage = "https://github.com/Fatal1ty/maybe-missing"
29+
30+
[tool.setuptools]
31+
include-package-data = true
32+
33+
[tool.setuptools.packages.find]
34+
include = ["maybe_missing*"]
35+
36+
[tool.setuptools.package-data]
37+
maybe_missing = ["py.typed", "*.pyi"]

tests/test_public_api.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import unittest
2+
3+
import maybe_missing
4+
from maybe_missing import * # noqa: F403
5+
6+
7+
class PublicApiTests(unittest.TestCase):
8+
def test_module_exports_only_public_names(self) -> None:
9+
self.assertEqual(maybe_missing.__all__, ["Maybe", "MISSING"])
10+
11+
public_names = {
12+
name
13+
for name in dir(maybe_missing)
14+
if not name.startswith("_") and name not in {"__all__"}
15+
}
16+
self.assertEqual(public_names, {"Maybe", "MISSING"})
17+
18+
def test_star_import_respects_all(self) -> None:
19+
self.assertIn("Maybe", globals())
20+
self.assertIn("MISSING", globals())
21+
self.assertIs(MISSING, maybe_missing.MISSING) # noqa: F405
22+
23+
def test_missing_is_distinct_from_none(self) -> None:
24+
self.assertIsNot(maybe_missing.MISSING, None)
25+
value = None
26+
missing = maybe_missing.MISSING
27+
self.assertIsNone(value)
28+
self.assertIsNot(value, missing)
29+
30+
31+
if __name__ == "__main__":
32+
unittest.main()

0 commit comments

Comments
 (0)