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 +147 -0
- kanne-1.0.0/PKG-INFO +70 -0
- kanne-1.0.0/README.md +57 -0
- kanne-1.0.0/kanne/__init__.py +39 -0
- kanne-1.0.0/kanne/contrib/rath/coerce_pint.py +18 -0
- kanne-1.0.0/kanne/helpers.py +7 -0
- kanne-1.0.0/kanne/kanne.py +40 -0
- kanne-1.0.0/kanne/py.typed +0 -0
- kanne-1.0.0/kanne/registry.py +23 -0
- kanne-1.0.0/kanne/scalars.py +378 -0
- kanne-1.0.0/kanne/vars.py +31 -0
- kanne-1.0.0/pyproject.toml +65 -0
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
|
+
[](https://codecov.io/gh/jhnnsrs/kanne)
|
|
17
|
+
[](https://pypi.org/project/kanne/)
|
|
18
|
+

|
|
19
|
+
[](https://pypi.python.org/pypi/kanne/)
|
|
20
|
+
[](https://pypi.python.org/pypi/kanne/)
|
|
21
|
+
[](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
|
+
[](https://codecov.io/gh/jhnnsrs/kanne)
|
|
4
|
+
[](https://pypi.org/project/kanne/)
|
|
5
|
+

|
|
6
|
+
[](https://pypi.python.org/pypi/kanne/)
|
|
7
|
+
[](https://pypi.python.org/pypi/kanne/)
|
|
8
|
+
[](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,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"
|