kanne 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
kanne-1.0.0/.gitignore ADDED
@@ -0,0 +1,147 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ **/__pycache__
4
+ *.py[cod]
5
+ *$py.class
6
+ *.pyc
7
+
8
+ # C extensions
9
+ *.so
10
+
11
+ # Distribution / packaging
12
+ .Python
13
+ build/
14
+ develop-eggs/
15
+ dist/
16
+ downloads/
17
+ eggs/
18
+ .eggs/
19
+ lib/
20
+ lib64/
21
+ parts/
22
+ sdist/
23
+ var/
24
+ wheels/
25
+ pip-wheel-metadata/
26
+ share/python-wheels/
27
+ *.egg-info/
28
+ .installed.cfg
29
+ *.egg
30
+ MANIFEST
31
+
32
+ # PyInstaller
33
+ # Usually these files are written by a python script from a template
34
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
35
+ *.manifest
36
+ *.spec
37
+
38
+ # Installer logs
39
+ pip-log.txt
40
+ pip-delete-this-directory.txt
41
+
42
+ # Unit test / coverage reports
43
+ htmlcov/
44
+ .tox/
45
+ .nox/
46
+ .coverage
47
+ .coverage.*
48
+ .cache
49
+ nosetests.xml
50
+ coverage.xml
51
+ *.cover
52
+ *.py,cover
53
+ .hypothesis/
54
+ .pytest_cache/
55
+ cover/
56
+
57
+ # Translations
58
+ *.mo
59
+ *.pot
60
+
61
+ # Django stuff:
62
+ *.log
63
+ local_settings.py
64
+ db.sqlite3
65
+ db.sqlite3-journal
66
+
67
+ # Flask stuff:
68
+ instance/
69
+ .webassets-cache
70
+
71
+ # Scrapy stuff:
72
+ .scrapy
73
+
74
+ # Sphinx documentation
75
+ docs/_build/
76
+
77
+ # PyBuilder
78
+ .pybuilder/
79
+ target/
80
+
81
+ # Jupyter Notebook
82
+ .ipynb_checkpoints
83
+
84
+ # IPython
85
+ profile_default/
86
+ ipython_config.py
87
+
88
+ # pyenv
89
+ # For a library or package, you might want to ignore these files since the code is
90
+ # intended to run in multiple environments; otherwise, check them in:
91
+ # .python-version
92
+
93
+ # pipenv
94
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
95
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
96
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
97
+ # install all needed dependencies.
98
+ #Pipfile.lock
99
+
100
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow
101
+ __pypackages__/
102
+
103
+ # Celery stuff
104
+ celerybeat-schedule
105
+ celerybeat.pid
106
+
107
+ # SageMath parsed files
108
+ *.sage.py
109
+
110
+ # Environments
111
+ .env
112
+ .venv
113
+ env/
114
+ venv/
115
+ ENV/
116
+ env.bak/
117
+ venv.bak/
118
+
119
+ # Spyder project settings
120
+ .spyderproject
121
+ .spyproject
122
+
123
+ # Rope project settings
124
+ .ropeproject
125
+
126
+ # mkdocs documentation
127
+ /site
128
+
129
+ # mypy
130
+ .mypy_cache/
131
+ .dmypy.json
132
+ dmypy.json
133
+
134
+ # Pyre type checker
135
+ .pyre/
136
+
137
+ # pytype static type analyzer
138
+ .pytype/
139
+
140
+ # Cython debug symbols
141
+ cython_debug/
142
+
143
+ # static files generated from Django application using `collectstatic`
144
+ media
145
+ export
146
+ static_collected
147
+ data
kanne-1.0.0/PKG-INFO ADDED
@@ -0,0 +1,70 @@
1
+ Metadata-Version: 2.4
2
+ Name: kanne
3
+ Version: 1.0.0
4
+ Summary: a context-aware si-unit registry manager built on pint and pydantic
5
+ Author-email: jhnnsrs <jhnnsrs@gmail.com>
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.11
8
+ Requires-Dist: pint>=0.25.2
9
+ Requires-Dist: pydantic>=2
10
+ Provides-Extra: qtpy
11
+ Requires-Dist: qtpy>1; extra == 'qtpy'
12
+ Description-Content-Type: text/markdown
13
+
14
+ # kanne
15
+
16
+ [![codecov](https://codecov.io/gh/jhnnsrs/kanne/branch/master/graph/badge.svg?token=UGXEA2THBV)](https://codecov.io/gh/jhnnsrs/kanne)
17
+ [![PyPI version](https://badge.fury.io/py/kanne.svg)](https://pypi.org/project/kanne/)
18
+ ![Maintainer](https://img.shields.io/badge/maintainer-jhnnsrs-blue)
19
+ [![PyPI pyversions](https://img.shields.io/pypi/pyversions/kanne.svg)](https://pypi.python.org/pypi/kanne/)
20
+ [![PyPI status](https://img.shields.io/pypi/status/kanne.svg)](https://pypi.python.org/pypi/kanne/)
21
+ [![PyPI download day](https://img.shields.io/pypi/dm/kanne.svg)](https://pypi.python.org/pypi/kanne/)
22
+
23
+
24
+ # kanne
25
+
26
+
27
+
28
+ Kanne is a small utility package to help manage unit-aware quantities and a simple registry/context pattern for parsing and validating units using Pint. It provides convenience helpers, validators, and a minimal context manager (`Kanne`) to make working with Pint registries explicit and testable.
29
+
30
+ Key ideas:
31
+ - Keep unit parsing and validation ergonomic.
32
+ - Provide a lightweight context aware registry (`Kanne`) for libraries.
33
+ - Export commonly-used units and small helpers for convenience.
34
+ ## 📦 Installation
35
+
36
+
37
+ ```bash
38
+ uv add kanne
39
+ ```
40
+
41
+ ## ⚡ Quick Start
42
+
43
+ Kanne provides a context manager to manage Pint registries easily.
44
+
45
+
46
+
47
+ ```python
48
+
49
+ # Global Pattern
50
+ from kanne import Kanne, Millisecond, Millimeter, define_unit
51
+ from pydantic import BaseModel
52
+
53
+
54
+ class Event(BaseModel):
55
+ duration: Millisecond
56
+
57
+ class OtherEvent(BaseModel):
58
+ length: Millimeter
59
+
60
+
61
+ Event(duration="1500 ms") # works fine
62
+ OtherEvent(length="1500 mm") # raises a validation error
63
+ event = Event(duration="1500 ms") # works fine
64
+ other = OtherEvent(length="1500 mm") # raises a validation error
65
+
66
+
67
+ event.duration + other.length # raises an error since they are not compatible
68
+
69
+ ```
70
+
kanne-1.0.0/README.md ADDED
@@ -0,0 +1,57 @@
1
+ # kanne
2
+
3
+ [![codecov](https://codecov.io/gh/jhnnsrs/kanne/branch/master/graph/badge.svg?token=UGXEA2THBV)](https://codecov.io/gh/jhnnsrs/kanne)
4
+ [![PyPI version](https://badge.fury.io/py/kanne.svg)](https://pypi.org/project/kanne/)
5
+ ![Maintainer](https://img.shields.io/badge/maintainer-jhnnsrs-blue)
6
+ [![PyPI pyversions](https://img.shields.io/pypi/pyversions/kanne.svg)](https://pypi.python.org/pypi/kanne/)
7
+ [![PyPI status](https://img.shields.io/pypi/status/kanne.svg)](https://pypi.python.org/pypi/kanne/)
8
+ [![PyPI download day](https://img.shields.io/pypi/dm/kanne.svg)](https://pypi.python.org/pypi/kanne/)
9
+
10
+
11
+ # kanne
12
+
13
+
14
+
15
+ Kanne is a small utility package to help manage unit-aware quantities and a simple registry/context pattern for parsing and validating units using Pint. It provides convenience helpers, validators, and a minimal context manager (`Kanne`) to make working with Pint registries explicit and testable.
16
+
17
+ Key ideas:
18
+ - Keep unit parsing and validation ergonomic.
19
+ - Provide a lightweight context aware registry (`Kanne`) for libraries.
20
+ - Export commonly-used units and small helpers for convenience.
21
+ ## 📦 Installation
22
+
23
+
24
+ ```bash
25
+ uv add kanne
26
+ ```
27
+
28
+ ## ⚡ Quick Start
29
+
30
+ Kanne provides a context manager to manage Pint registries easily.
31
+
32
+
33
+
34
+ ```python
35
+
36
+ # Global Pattern
37
+ from kanne import Kanne, Millisecond, Millimeter, define_unit
38
+ from pydantic import BaseModel
39
+
40
+
41
+ class Event(BaseModel):
42
+ duration: Millisecond
43
+
44
+ class OtherEvent(BaseModel):
45
+ length: Millimeter
46
+
47
+
48
+ Event(duration="1500 ms") # works fine
49
+ OtherEvent(length="1500 mm") # raises a validation error
50
+ event = Event(duration="1500 ms") # works fine
51
+ other = OtherEvent(length="1500 mm") # raises a validation error
52
+
53
+
54
+ event.duration + other.length # raises an error since they are not compatible
55
+
56
+ ```
57
+
@@ -0,0 +1,39 @@
1
+ from .scalars import *
2
+ from .helpers import define_unit
3
+ from .vars import get_current_registry
4
+
5
+ __all__ = [
6
+ "define_unit",
7
+ "get_current_registry",
8
+ # base
9
+ "PintScalar",
10
+ "PintQuantity",
11
+ # unit scalars
12
+ "Millisecond",
13
+ "Second",
14
+ "Micrometer",
15
+ "Microliter",
16
+ "Milliliter",
17
+ "Microgram",
18
+ "Milligram",
19
+ "Gram",
20
+ "Kilogram",
21
+ "Hertz",
22
+ "Ampere",
23
+ "Picoampere",
24
+ # coercible input aliases
25
+ "CoercibleValue",
26
+ "Coercible",
27
+ "MillisecondCoercible",
28
+ "SecondCoercible",
29
+ "MicrometerCoercible",
30
+ "MicroliterCoercible",
31
+ "MilliliterCoercible",
32
+ "MicrogramCoercible",
33
+ "MilligramCoercible",
34
+ "GramCoercible",
35
+ "KilogramCoercible",
36
+ "HertzCoercible",
37
+ "AmpereCoercible",
38
+ "PicoampereCoercible",
39
+ ]
@@ -0,0 +1,18 @@
1
+ from rath.links.base import ContinuationLink
2
+ from rath.links.parsing import ParsingLink, Operation, apply_typemap_recursive
3
+ from pydantic import Field, BaseModel
4
+ from typing import Any, Callable, Dict, Awaitable
5
+ from kanne.scalars import DEFAULT_COERCERS
6
+
7
+
8
+ class CoercePintLink(ParsingLink):
9
+ coercers: Dict[Any, Callable[[Any], Awaitable[Any]]] = Field(
10
+ default=DEFAULT_COERCERS
11
+ )
12
+
13
+ async def aparse(self, operation: Operation) -> Operation:
14
+ operation.variables = await apply_typemap_recursive(
15
+ operation.variables, self.coercers
16
+ )
17
+
18
+ return operation
@@ -0,0 +1,7 @@
1
+ from .vars import get_current_registry
2
+
3
+
4
+ def define_unit(definition: str | type) -> None:
5
+ """Define a new unit in the current KanneRegistry."""
6
+ registry = get_current_registry()
7
+ return registry.define(definition)
@@ -0,0 +1,40 @@
1
+ from types import TracebackType
2
+ from typing import Any
3
+
4
+ from .registry import KanneRegistry
5
+ from pydantic import BaseModel, ConfigDict, Field
6
+ from .vars import set_current_registry, reset_current_registry
7
+ from .registry import get_global_registry
8
+
9
+
10
+ class Kanne(BaseModel):
11
+ """Context manager that sets a `KanneRegistry` on entry and resets it on exit.
12
+ by default, uses the global KanneRegistry.
13
+
14
+ Example:
15
+ registry = KanneRegistry()
16
+ with Kanne(registry=registry):
17
+ # registry is available via get_current_registry()
18
+ ...
19
+ """
20
+
21
+ registry: KanneRegistry = Field(default_factory=get_global_registry)
22
+ model_config = ConfigDict(arbitrary_types_allowed=True)
23
+
24
+ # runtime token for contextvar reset
25
+ _token: Any = None
26
+
27
+ def __enter__(self) -> "Kanne":
28
+ self._token = set_current_registry(self.registry)
29
+ return self
30
+
31
+ def __exit__(
32
+ self,
33
+ exc_type: type[BaseException] | None,
34
+ exc_value: BaseException | None,
35
+ traceback: TracebackType | None,
36
+ ) -> None:
37
+ # Ensure we always reset the token even if exceptions occur
38
+ if self._token is not None:
39
+ reset_current_registry(self._token)
40
+ self._token = None
File without changes
@@ -0,0 +1,23 @@
1
+ from typing import Any
2
+
3
+ import pint
4
+
5
+ GLOBAL_REGISTRY: "KanneRegistry | None" = None
6
+
7
+
8
+ class KanneRegistry(pint.UnitRegistry[Any]):
9
+ """Custom Pint UnitRegistry for Kanne with predefined units."""
10
+
11
+
12
+ def get_global_registry() -> KanneRegistry:
13
+ """Get or create the global KanneRegistry instance."""
14
+ global GLOBAL_REGISTRY
15
+ if GLOBAL_REGISTRY is None:
16
+ GLOBAL_REGISTRY = KanneRegistry()
17
+ return GLOBAL_REGISTRY
18
+
19
+
20
+ def reset_global_registry() -> None:
21
+ """Reset the global KanneRegistry instance."""
22
+ global GLOBAL_REGISTRY
23
+ GLOBAL_REGISTRY = None
@@ -0,0 +1,378 @@
1
+ from typing import Any, Awaitable, Callable, ClassVar, TypeVar, Union
2
+
3
+ import pint
4
+ from pydantic import GetCoreSchemaHandler
5
+ from pydantic_core import CoreSchema, core_schema
6
+
7
+ from kanne.vars import get_current_registry
8
+
9
+ _S = TypeVar("_S", bound="PintScalar")
10
+
11
+ #: Anything that can be coerced into a scalar at runtime: a number (assumed to
12
+ #: already be in the scalar's unit), a unit string Pint can parse (``"2 s"``), an
13
+ #: existing :class:`pint.Quantity`, or another :class:`PintScalar`.
14
+ CoercibleValue = Union[float, int, str, "pint.Quantity[Any]", "PintScalar"]
15
+
16
+
17
+ def unit_validator(target_unit: str) -> Callable[[Any], float]:
18
+ """Return a validator that coerces any *coercible* value into the float
19
+ magnitude expressed in ``target_unit``.
20
+
21
+ Kept as a standalone helper; the :class:`PintScalar` subclasses use the same
22
+ coercion rules.
23
+ """
24
+
25
+ def validate(v: Any) -> float: # noqa: ANN401
26
+ return _to_magnitude(v, target_unit)
27
+
28
+ return validate
29
+
30
+
31
+ def _to_magnitude(v: Any, target_unit: str) -> float: # noqa: ANN401
32
+ """Coerce a *coercible* value to a plain float magnitude in ``target_unit``.
33
+
34
+ Coercible inputs are: a :class:`PintScalar`, an existing
35
+ :class:`pint.Quantity`, a string Pint can parse (``"1 mm"``), or a number
36
+ (assumed to already be in ``target_unit``).
37
+ """
38
+ if isinstance(v, PintScalar):
39
+ return float(v.to(target_unit).magnitude)
40
+ if isinstance(v, pint.Quantity):
41
+ return float(v.to(target_unit).magnitude)
42
+ if isinstance(v, str):
43
+ return float(get_current_registry()(v).to(target_unit).magnitude)
44
+ if isinstance(v, (int, float)):
45
+ return float(v)
46
+ raise ValueError(f"Invalid input format for {target_unit}: {v!r}")
47
+
48
+
49
+ def _as_operand(value: Any) -> Any: # noqa: ANN401
50
+ """Promote a :class:`PintScalar` to a real ``pint.Quantity`` for arithmetic;
51
+ leave everything else (numbers, quantities) untouched so Pint handles it."""
52
+ if isinstance(value, PintScalar):
53
+ return value.quantity
54
+ return value
55
+
56
+
57
+ class PintScalar:
58
+ """A dimensionful scalar.
59
+
60
+ It is a thin wrapper around a single ``float`` *magnitude* expressed in
61
+ :attr:`unit`. For Pydantic (and therefore turms / JSON) it presents as an
62
+ ordinary number: it validates from a number/string/quantity and serializes
63
+ back to a float.
64
+
65
+ At runtime it is dimensionful — arithmetic and comparison operators promote
66
+ it to a real :class:`pint.Quantity` (using the registry bound to the current
67
+ context)::
68
+
69
+ ms = Millisecond(5) # magnitude 5.0, unit "millisecond"
70
+ float(ms) # 5.0 — what gets serialized
71
+ ms + Second(1) # <Quantity(1005.0, 'millisecond')>
72
+ (ms * 2).to("second") # <Quantity(0.01, 'second')>
73
+ Millisecond(1000) == Second(1) # True — compared dimensionfully
74
+
75
+ Unlike a ``float`` subclass this is *not* substitutable for ``float``: pass
76
+ it to a ``float``-typed API via :func:`float` or :attr:`magnitude` so the
77
+ conversion (and any dimension loss) is explicit.
78
+ """
79
+
80
+ #: The unit this scalar's magnitude is expressed in. Subclasses must set it.
81
+ unit: ClassVar[str] = ""
82
+
83
+ __slots__ = ("_magnitude",)
84
+
85
+ def __init__(self, value: "float | PintScalar | pint.Quantity[Any] | str") -> None:
86
+ self._magnitude = _to_magnitude(value, self.unit)
87
+
88
+ def __init_subclass__(cls, **kwargs: Any) -> None: # noqa: ANN401
89
+ super().__init_subclass__(**kwargs)
90
+ if not cls.unit:
91
+ raise TypeError(f"{cls.__name__} must define a non-empty `unit`")
92
+
93
+ # -- numeric / pint bridge -----------------------------------------
94
+ @property
95
+ def magnitude(self) -> float:
96
+ """The raw float magnitude in :attr:`unit`."""
97
+ return self._magnitude
98
+
99
+ @property
100
+ def quantity(self) -> "pint.Quantity[Any]":
101
+ """This scalar as a real :class:`pint.Quantity` in :attr:`unit`."""
102
+ return get_current_registry().Quantity(self._magnitude, self.unit)
103
+
104
+ def to(self, unit: str) -> "pint.Quantity[Any]":
105
+ """Convert to ``unit`` and return a :class:`pint.Quantity`."""
106
+ return self.quantity.to(unit)
107
+
108
+ def __float__(self) -> float:
109
+ return self._magnitude
110
+
111
+ def __int__(self) -> int:
112
+ return int(self._magnitude)
113
+
114
+ def __bool__(self) -> bool:
115
+ return bool(self._magnitude)
116
+
117
+ def __hash__(self) -> int:
118
+ return hash((type(self).unit, self._magnitude))
119
+
120
+ def __repr__(self) -> str:
121
+ return f"{type(self).__name__}({self._magnitude!r})"
122
+
123
+ # -- pydantic ------------------------------------------------------
124
+ @classmethod
125
+ def __get_pydantic_core_schema__(
126
+ cls,
127
+ source_type: Any, # noqa: ANN401
128
+ handler: GetCoreSchemaHandler,
129
+ ) -> CoreSchema:
130
+ """Validate from any coercible value into ``cls`` and serialize to float.
131
+
132
+ The schema is built on a ``float`` core schema so the field is a plain
133
+ ``number`` in the generated JSON schema (what turms expects).
134
+ """
135
+ return core_schema.no_info_after_validator_function(
136
+ cls,
137
+ core_schema.no_info_before_validator_function(
138
+ cls._coerce_magnitude,
139
+ core_schema.float_schema(),
140
+ ),
141
+ serialization=core_schema.plain_serializer_function_ser_schema(
142
+ float, return_schema=core_schema.float_schema()
143
+ ),
144
+ )
145
+
146
+ @classmethod
147
+ def _coerce_magnitude(cls, v: Any) -> float: # noqa: ANN401
148
+ return _to_magnitude(v, cls.unit)
149
+
150
+ @classmethod
151
+ def validate(cls: type[_S], value: CoercibleValue) -> _S:
152
+ """Coerce any *coercible* value into an instance of this scalar.
153
+
154
+ Accepts a number (assumed to be in :attr:`unit`), a unit string
155
+ (``"2 s"``), a :class:`pint.Quantity`, or another :class:`PintScalar`;
156
+ the result is always an instance of ``cls`` with its magnitude in
157
+ :attr:`unit`::
158
+
159
+ Millisecond.validate(5) # Millisecond(5.0)
160
+ Millisecond.validate("2 s") # Millisecond(2000.0)
161
+ Millisecond.validate(Second(1)) # Millisecond(1000.0)
162
+
163
+ This is the same coercion Pydantic applies, exposed for use outside a
164
+ model (and as the hook turms can call to build the scalar).
165
+ """
166
+ return cls(value)
167
+
168
+ # -- arithmetic (returns dimensionful pint.Quantity) ---------------
169
+ def __add__(self, other: Any) -> "pint.Quantity[Any]": # noqa: ANN401
170
+ return self.quantity + _as_operand(other)
171
+
172
+ def __radd__(self, other: Any) -> "pint.Quantity[Any]": # noqa: ANN401
173
+ return _as_operand(other) + self.quantity
174
+
175
+ def __sub__(self, other: Any) -> "pint.Quantity[Any]": # noqa: ANN401
176
+ return self.quantity - _as_operand(other)
177
+
178
+ def __rsub__(self, other: Any) -> "pint.Quantity[Any]": # noqa: ANN401
179
+ return _as_operand(other) - self.quantity
180
+
181
+ def __mul__(self, other: Any) -> "pint.Quantity[Any]": # noqa: ANN401
182
+ return self.quantity * _as_operand(other)
183
+
184
+ def __rmul__(self, other: Any) -> "pint.Quantity[Any]": # noqa: ANN401
185
+ return _as_operand(other) * self.quantity
186
+
187
+ def __truediv__(self, other: Any) -> "pint.Quantity[Any]": # noqa: ANN401
188
+ return self.quantity / _as_operand(other)
189
+
190
+ def __rtruediv__(self, other: Any) -> "pint.Quantity[Any]": # noqa: ANN401
191
+ return _as_operand(other) / self.quantity
192
+
193
+ def __pow__(self, other: Any) -> "pint.Quantity[Any]": # noqa: ANN401
194
+ return self.quantity ** _as_operand(other)
195
+
196
+ def __neg__(self) -> "pint.Quantity[Any]":
197
+ return -self.quantity
198
+
199
+ def __pos__(self) -> "pint.Quantity[Any]":
200
+ return +self.quantity
201
+
202
+ def __abs__(self) -> "pint.Quantity[Any]":
203
+ return abs(self.quantity)
204
+
205
+ # -- comparisons (dimensionful against quantity-like operands) -----
206
+ def __eq__(self, other: object) -> bool:
207
+ if isinstance(other, (PintScalar, pint.Quantity)):
208
+ return bool(self.quantity == _as_operand(other))
209
+ if isinstance(other, (int, float)):
210
+ return self._magnitude == other
211
+ return NotImplemented
212
+
213
+ def __ne__(self, other: object) -> bool:
214
+ result = self.__eq__(other)
215
+ if result is NotImplemented:
216
+ return result
217
+ return not result
218
+
219
+ def __lt__(self, other: Any) -> bool: # noqa: ANN401
220
+ if isinstance(other, (PintScalar, pint.Quantity)):
221
+ return bool(self.quantity < _as_operand(other))
222
+ return self._magnitude < other
223
+
224
+ def __le__(self, other: Any) -> bool: # noqa: ANN401
225
+ if isinstance(other, (PintScalar, pint.Quantity)):
226
+ return bool(self.quantity <= _as_operand(other))
227
+ return self._magnitude <= other
228
+
229
+ def __gt__(self, other: Any) -> bool: # noqa: ANN401
230
+ if isinstance(other, (PintScalar, pint.Quantity)):
231
+ return bool(self.quantity > _as_operand(other))
232
+ return self._magnitude > other
233
+
234
+ def __ge__(self, other: Any) -> bool: # noqa: ANN401
235
+ if isinstance(other, (PintScalar, pint.Quantity)):
236
+ return bool(self.quantity >= _as_operand(other))
237
+ return self._magnitude >= other
238
+
239
+
240
+ # Backwards-compatible alias for the original base-class name.
241
+ PintQuantity = PintScalar
242
+
243
+
244
+ # --- Concrete unit scalars ------------------------------------------------
245
+ # Each is a real, importable class so turms can reference it by name.
246
+
247
+
248
+ class Millisecond(PintScalar):
249
+ """Scalar whose magnitude is expressed in milliseconds."""
250
+
251
+ unit = "millisecond"
252
+
253
+
254
+ class Second(PintScalar):
255
+ """Scalar whose magnitude is expressed in seconds."""
256
+
257
+ unit = "second"
258
+
259
+
260
+ class Micrometer(PintScalar):
261
+ """Scalar whose magnitude is expressed in micrometers."""
262
+
263
+ unit = "micrometer"
264
+
265
+
266
+ class Microliter(PintScalar):
267
+ """Scalar whose magnitude is expressed in microliters."""
268
+
269
+ unit = "microliter"
270
+
271
+
272
+ class Milliliter(PintScalar):
273
+ """Scalar whose magnitude is expressed in milliliters."""
274
+
275
+ unit = "milliliter"
276
+
277
+
278
+ class Microgram(PintScalar):
279
+ """Scalar whose magnitude is expressed in micrograms."""
280
+
281
+ unit = "microgram"
282
+
283
+
284
+ class Milligram(PintScalar):
285
+ """Scalar whose magnitude is expressed in milligrams."""
286
+
287
+ unit = "milligram"
288
+
289
+
290
+ class Gram(PintScalar):
291
+ """Scalar whose magnitude is expressed in grams."""
292
+
293
+ unit = "gram"
294
+
295
+
296
+ class Kilogram(PintScalar):
297
+ """Scalar whose magnitude is expressed in kilograms."""
298
+
299
+ unit = "kilogram"
300
+
301
+
302
+ class Hertz(PintScalar):
303
+ """Scalar whose magnitude is expressed in hertz."""
304
+
305
+ unit = "hertz"
306
+
307
+
308
+ class Ampere(PintScalar):
309
+ """Scalar whose magnitude is expressed in amperes."""
310
+
311
+ unit = "ampere"
312
+
313
+
314
+ class Picoampere(PintScalar):
315
+ """Scalar whose magnitude is expressed in picoamperes."""
316
+
317
+ unit = "picoampere"
318
+
319
+
320
+ # --- Coercible aliases ----------------------------------------------------
321
+ # Use these as the *field / input* annotation (e.g. in turms-generated models)
322
+ # so model construction accepts a coercible value directly::
323
+ #
324
+ # class Op(BaseModel):
325
+ # exposure: MillisecondCoercible # Op(exposure=5) and Op(exposure="2 s")
326
+ # # both type-check and coerce to Millisecond
327
+ #
328
+ # These are field-usable: every member has a Pydantic core schema, and Pydantic's
329
+ # smart union coerces the input to the scalar at runtime (verified). A read of the
330
+ # field is typed as the union — narrow with ``cast`` if you need the scalar type.
331
+ # A real ``pint.Quantity`` is intentionally *not* a member (it has no core schema
332
+ # and would break field generation); pass one as ``Millisecond(quantity)`` or rely
333
+ # on the scalar's own validator, which still accepts a Quantity at runtime.
334
+ Coercible = Union[float, int, str, PintScalar]
335
+
336
+ MillisecondCoercible = Union[Millisecond, float, int, str]
337
+ SecondCoercible = Union[Second, float, int, str]
338
+ MicrometerCoercible = Union[Micrometer, float, int, str]
339
+ MicroliterCoercible = Union[Microliter, float, int, str]
340
+ MilliliterCoercible = Union[Milliliter, float, int, str]
341
+ MicrogramCoercible = Union[Microgram, float, int, str]
342
+ MilligramCoercible = Union[Milligram, float, int, str]
343
+ GramCoercible = Union[Gram, float, int, str]
344
+ KilogramCoercible = Union[Kilogram, float, int, str]
345
+ HertzCoercible = Union[Hertz, float, int, str]
346
+ AmpereCoercible = Union[Ampere, float, int, str]
347
+ PicoampereCoercible = Union[Picoampere, float, int, str]
348
+
349
+
350
+ # --- Serialization helpers (used by the rath contrib link) ----------------
351
+
352
+
353
+ def serialize_quantity(unit: str) -> Callable[[PintScalar], Awaitable[float]]:
354
+ """Build an async serializer that renders a scalar as its float magnitude
355
+ in ``unit`` (e.g. for sending GraphQL variables)."""
356
+
357
+ async def aserialize_quantity(q: PintScalar) -> float:
358
+ return float(q.to(unit).magnitude)
359
+
360
+ return aserialize_quantity
361
+
362
+
363
+ Coercer = Callable[[Any], Awaitable[Any]]
364
+
365
+ DEFAULT_COERCERS: dict[Any, Coercer] = {
366
+ Millisecond: serialize_quantity("millisecond"),
367
+ Second: serialize_quantity("second"),
368
+ Micrometer: serialize_quantity("micrometer"),
369
+ Microliter: serialize_quantity("microliter"),
370
+ Milliliter: serialize_quantity("milliliter"),
371
+ Microgram: serialize_quantity("microgram"),
372
+ Milligram: serialize_quantity("milligram"),
373
+ Gram: serialize_quantity("gram"),
374
+ Kilogram: serialize_quantity("kilogram"),
375
+ Hertz: serialize_quantity("hertz"),
376
+ Ampere: serialize_quantity("ampere"),
377
+ Picoampere: serialize_quantity("picoampere"),
378
+ }
@@ -0,0 +1,31 @@
1
+ from contextvars import ContextVar, Token
2
+ from kanne.registry import KanneRegistry, get_global_registry
3
+
4
+
5
+ current_kanne_registry: ContextVar[KanneRegistry | None] = ContextVar(
6
+ "current_kanne_registry", default=None
7
+ )
8
+
9
+
10
+ def get_current_registry(allow_global: bool = True) -> KanneRegistry:
11
+ """Get the current KanneRegistry from context, or create a new one."""
12
+ registry = current_kanne_registry.get()
13
+ if registry is None:
14
+ if allow_global:
15
+ registry = get_global_registry()
16
+ return registry
17
+ else:
18
+ raise RuntimeError(
19
+ "No KanneRegistry found in context. This is undefined behavior. Create and set a KanneRegistry before using Kanne or set allow_global=True."
20
+ )
21
+ return registry
22
+
23
+
24
+ def set_current_registry(registry: KanneRegistry) -> Token["KanneRegistry | None"]:
25
+ """Set the current KanneRegistry in context."""
26
+ return current_kanne_registry.set(registry)
27
+
28
+
29
+ def reset_current_registry(token: Token["KanneRegistry | None"]) -> None:
30
+ """Reset the current KanneRegistry in context."""
31
+ current_kanne_registry.reset(token)
@@ -0,0 +1,65 @@
1
+ [project]
2
+ name = "kanne"
3
+ version = "1.0.0"
4
+ description = "a context-aware si-unit registry manager built on pint and pydantic"
5
+ authors = [{ name = "jhnnsrs", email = "jhnnsrs@gmail.com" }]
6
+ requires-python = ">=3.11"
7
+ readme = "README.md"
8
+ license = "MIT"
9
+ dependencies = [
10
+ "pydantic>=2",
11
+ "pint>=0.25.2",
12
+ ]
13
+
14
+ [project.optional-dependencies]
15
+ qtpy = ["qtpy>1"]
16
+
17
+ [tool.uv]
18
+ dev-dependencies = [
19
+ "pytest>=9.0.1",
20
+ "pytest-cov>=7.0.0",
21
+ "python-semantic-release>=10.5.2",
22
+ "scipy>=1.16.3",
23
+ ]
24
+
25
+ [tool.hatch.build.targets.sdist]
26
+ include = ["kanne"]
27
+
28
+ [tool.hatch.build.targets.wheel]
29
+ include = ["kanne"]
30
+
31
+ [build-system]
32
+ requires = ["hatchling"]
33
+ build-backend = "hatchling.build"
34
+
35
+ [tool.pytest.ini_options]
36
+ asyncio_mode = "auto"
37
+ qt_api = "pyqt5"
38
+ markers = ["qt: marks tests that require a running qt application", "process: marks tests that require the process extra"]
39
+
40
+ [[tool.pydoc-markdown.loaders]]
41
+ type = "python"
42
+ search_path = ["kanne"]
43
+
44
+ [tool.pydoc-markdown.renderer]
45
+ type = "docusaurus"
46
+ docs_base_path = "website/docs"
47
+
48
+ [tool.mypy]
49
+ ignore_missing_imports = true
50
+ strict = true
51
+
52
+ # The optional `rath` contrib depends on an untyped third-party library, so we
53
+ # can't subclass its (Any-typed) link classes under strict mode. Scope the
54
+ # relaxation to just that module.
55
+ [[tool.mypy.overrides]]
56
+ module = "kanne.contrib.rath.*"
57
+ disallow_subclassing_any = false
58
+
59
+
60
+
61
+ [tool.semantic_release]
62
+ version_toml = ["pyproject.toml:project.version"]
63
+ upload_to_pypi = true
64
+ branch = "main"
65
+ build_command = "uv build"