dataclass-values 0.1.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.
- dataclass_values-0.1.0/.github/workflows/publish.yml +27 -0
- dataclass_values-0.1.0/.github/workflows/test.yml +12 -0
- dataclass_values-0.1.0/.gitignore +6 -0
- dataclass_values-0.1.0/LICENSE +26 -0
- dataclass_values-0.1.0/PKG-INFO +70 -0
- dataclass_values-0.1.0/README.md +53 -0
- dataclass_values-0.1.0/pyproject.toml +42 -0
- dataclass_values-0.1.0/src/dataclass_values/__init__.py +38 -0
- dataclass_values-0.1.0/src/dataclass_values/py.typed +0 -0
- dataclass_values-0.1.0/src/dataclass_values/space.py +126 -0
- dataclass_values-0.1.0/src/dataclass_values/values.py +317 -0
- dataclass_values-0.1.0/tests/test_space.py +246 -0
- dataclass_values-0.1.0/tests/test_values.py +207 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Publishes to PyPI when a version tag (v*) is pushed, through PyPI trusted publishing:
|
|
2
|
+
# no API token is stored anywhere. One-time setup on pypi.org: a "pending publisher" for
|
|
3
|
+
# project dataclass-values, owner zacharyyamaoka, repository dataclass_values, workflow publish.yml,
|
|
4
|
+
# environment pypi.
|
|
5
|
+
name: publish
|
|
6
|
+
on:
|
|
7
|
+
push:
|
|
8
|
+
tags: ["v*"]
|
|
9
|
+
workflow_dispatch:
|
|
10
|
+
jobs:
|
|
11
|
+
test:
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@v4
|
|
15
|
+
- uses: astral-sh/setup-uv@v6
|
|
16
|
+
- run: uv run --isolated --extra test python -m pytest -q
|
|
17
|
+
publish:
|
|
18
|
+
needs: test
|
|
19
|
+
runs-on: ubuntu-latest
|
|
20
|
+
environment: pypi
|
|
21
|
+
permissions:
|
|
22
|
+
id-token: write
|
|
23
|
+
steps:
|
|
24
|
+
- uses: actions/checkout@v4
|
|
25
|
+
- uses: astral-sh/setup-uv@v6
|
|
26
|
+
- run: uv build
|
|
27
|
+
- run: uv publish --trusted-publishing always
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
name: test
|
|
2
|
+
on: [push, pull_request]
|
|
3
|
+
jobs:
|
|
4
|
+
test:
|
|
5
|
+
runs-on: ubuntu-latest
|
|
6
|
+
strategy:
|
|
7
|
+
matrix:
|
|
8
|
+
python: ["3.11", "3.12", "3.13"]
|
|
9
|
+
steps:
|
|
10
|
+
- uses: actions/checkout@v4
|
|
11
|
+
- uses: astral-sh/setup-uv@v6
|
|
12
|
+
- run: uv run --isolated --python ${{ matrix.python }} --extra test python -m pytest -q
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Zach Yamaoka
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
src/robot_msgs/ros/vision_msgs.py embeds message definitions from
|
|
26
|
+
https://github.com/ros-perception/vision_msgs (tag 4.1.1), licensed Apache-2.0.
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: dataclass-values
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Value(1.2, "m", low=0.6, high=1.85, source="..."): a float, int, str or tuple that remembers where it came from, plus a Space derived from any dataclass of them.
|
|
5
|
+
Project-URL: Source, https://github.com/zacharyyamaoka/dataclass_values
|
|
6
|
+
Author: Zach Yamaoka
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: configuration,dataclasses,provenance,sampling,units
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Typing :: Typed
|
|
13
|
+
Requires-Python: >=3.11
|
|
14
|
+
Provides-Extra: test
|
|
15
|
+
Requires-Dist: pytest>=8; extra == 'test'
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# dataclass-values
|
|
19
|
+
|
|
20
|
+
A number that remembers where it came from, and still is the number. Stdlib only.
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from dataclass_values import Basis, Normal, Space, Value, plain
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class Belt:
|
|
28
|
+
width_m: float = Value(1.2, "m", low=0.6, high=1.85, basis=Basis.CHOSEN, source="site survey")
|
|
29
|
+
speed_mps: float = Value(0.5, "m/s", dist=Normal(0.5, 0.05), low=0.2, high=0.8)
|
|
30
|
+
|
|
31
|
+
belt = Belt()
|
|
32
|
+
belt.width_m * 2 # 2.4, a plain float
|
|
33
|
+
Space(belt).sample() # a Belt, every ranged field redrawn, basis=SAMPLED
|
|
34
|
+
Space(belt).violations(Belt(9.0, 0.5)) # ["width_m=9.0 outside [0.6, 1.85]"]
|
|
35
|
+
plain(belt) # the same Belt with bare floats, for ROS / JSON
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Why
|
|
39
|
+
|
|
40
|
+
The syntax applies to any domain, so it is its own package: robot_msgs is one vocabulary built on it,
|
|
41
|
+
py_dataclass_viewer is one app built on it (Zach, 2026-11-10: "the pydataclass syntax can technically
|
|
42
|
+
apply to any domain! robot_msgs is just a particular domain.").
|
|
43
|
+
|
|
44
|
+
Zach's rule (Oct 5 2026, do not re-litigate):
|
|
45
|
+
|
|
46
|
+
- `Value(1.2, "m", low=0.6, high=1.85, basis=Basis.CHOSEN, source="...")` IS the builtin: a
|
|
47
|
+
float/int/str/tuple subclass. "I definitely don't want to have to do `width_m.value`."
|
|
48
|
+
- Spaces are derived: `Space(obj).sample()` returns the same class, every ranged value marked
|
|
49
|
+
`basis=SAMPLED`. No second `PoseSpace` class to drift from the one it shadows.
|
|
50
|
+
- `plain()` strips the notes at a runtime boundary. "We can use Value wherever we use a raw value
|
|
51
|
+
normally and we can always strip it back to the plain value."
|
|
52
|
+
|
|
53
|
+
## API
|
|
54
|
+
|
|
55
|
+
- `Value(raw, unit=None, *, low, high, step, choices, dist, basis, source, measured_by, notes, date_accessed)`
|
|
56
|
+
returns `FloatValue | IntValue | StrValue | TupleValue` (also exported). Refused at construction when
|
|
57
|
+
outside `[low, high]` (tuples elementwise) or not in `choices`. `v` is a short alias.
|
|
58
|
+
- `meta(x)` the evidence dict or `None`; `plain(obj)` strips every `Value`, through dataclasses,
|
|
59
|
+
lists and tuples.
|
|
60
|
+
- `Basis`: `CHOSEN DATASHEET MARKET ASSUMED MEASURED SAMPLED` (a `StrEnum`).
|
|
61
|
+
- `Uniform`, `Normal`, `LogUniform`, `Choice`: per-value distributions with `.draw(rng)`.
|
|
62
|
+
- `Space(instance)`: `.sample(rng=None)`, `.contains(other)`, `.violations(other)` (dotted field
|
|
63
|
+
paths). Subclasses add bounds by overriding the hooks `extra_draw(drawn, rng)` and
|
|
64
|
+
`extra_violations(other)`; robot_msgs uses them for numpy array bounds.
|
|
65
|
+
|
|
66
|
+
## Tests
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
env -u VIRTUAL_ENV uv run --isolated --extra test python -m pytest -q
|
|
70
|
+
```
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# dataclass-values
|
|
2
|
+
|
|
3
|
+
A number that remembers where it came from, and still is the number. Stdlib only.
|
|
4
|
+
|
|
5
|
+
```python
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from dataclass_values import Basis, Normal, Space, Value, plain
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class Belt:
|
|
11
|
+
width_m: float = Value(1.2, "m", low=0.6, high=1.85, basis=Basis.CHOSEN, source="site survey")
|
|
12
|
+
speed_mps: float = Value(0.5, "m/s", dist=Normal(0.5, 0.05), low=0.2, high=0.8)
|
|
13
|
+
|
|
14
|
+
belt = Belt()
|
|
15
|
+
belt.width_m * 2 # 2.4, a plain float
|
|
16
|
+
Space(belt).sample() # a Belt, every ranged field redrawn, basis=SAMPLED
|
|
17
|
+
Space(belt).violations(Belt(9.0, 0.5)) # ["width_m=9.0 outside [0.6, 1.85]"]
|
|
18
|
+
plain(belt) # the same Belt with bare floats, for ROS / JSON
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Why
|
|
22
|
+
|
|
23
|
+
The syntax applies to any domain, so it is its own package: robot_msgs is one vocabulary built on it,
|
|
24
|
+
py_dataclass_viewer is one app built on it (Zach, 2026-11-10: "the pydataclass syntax can technically
|
|
25
|
+
apply to any domain! robot_msgs is just a particular domain.").
|
|
26
|
+
|
|
27
|
+
Zach's rule (Oct 5 2026, do not re-litigate):
|
|
28
|
+
|
|
29
|
+
- `Value(1.2, "m", low=0.6, high=1.85, basis=Basis.CHOSEN, source="...")` IS the builtin: a
|
|
30
|
+
float/int/str/tuple subclass. "I definitely don't want to have to do `width_m.value`."
|
|
31
|
+
- Spaces are derived: `Space(obj).sample()` returns the same class, every ranged value marked
|
|
32
|
+
`basis=SAMPLED`. No second `PoseSpace` class to drift from the one it shadows.
|
|
33
|
+
- `plain()` strips the notes at a runtime boundary. "We can use Value wherever we use a raw value
|
|
34
|
+
normally and we can always strip it back to the plain value."
|
|
35
|
+
|
|
36
|
+
## API
|
|
37
|
+
|
|
38
|
+
- `Value(raw, unit=None, *, low, high, step, choices, dist, basis, source, measured_by, notes, date_accessed)`
|
|
39
|
+
returns `FloatValue | IntValue | StrValue | TupleValue` (also exported). Refused at construction when
|
|
40
|
+
outside `[low, high]` (tuples elementwise) or not in `choices`. `v` is a short alias.
|
|
41
|
+
- `meta(x)` the evidence dict or `None`; `plain(obj)` strips every `Value`, through dataclasses,
|
|
42
|
+
lists and tuples.
|
|
43
|
+
- `Basis`: `CHOSEN DATASHEET MARKET ASSUMED MEASURED SAMPLED` (a `StrEnum`).
|
|
44
|
+
- `Uniform`, `Normal`, `LogUniform`, `Choice`: per-value distributions with `.draw(rng)`.
|
|
45
|
+
- `Space(instance)`: `.sample(rng=None)`, `.contains(other)`, `.violations(other)` (dotted field
|
|
46
|
+
paths). Subclasses add bounds by overriding the hooks `extra_draw(drawn, rng)` and
|
|
47
|
+
`extra_violations(other)`; robot_msgs uses them for numpy array bounds.
|
|
48
|
+
|
|
49
|
+
## Tests
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
env -u VIRTUAL_ENV uv run --isolated --extra test python -m pytest -q
|
|
53
|
+
```
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "dataclass-values"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Value(1.2, \"m\", low=0.6, high=1.85, source=\"...\"): a float, int, str or tuple that remembers where it came from, plus a Space derived from any dataclass of them."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
authors = [{ name = "Zach Yamaoka" }]
|
|
13
|
+
keywords = ["dataclasses", "units", "provenance", "sampling", "configuration"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Operating System :: OS Independent",
|
|
17
|
+
"Typing :: Typed",
|
|
18
|
+
]
|
|
19
|
+
requires-python = ">=3.11"
|
|
20
|
+
# WHY zero runtime dependencies: this is the layer every other package (robot_msgs, the dataclass viewer)
|
|
21
|
+
# builds on, so it must be importable anywhere. numpy-style extensions subclass Space in their own package.
|
|
22
|
+
dependencies = []
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Source = "https://github.com/zacharyyamaoka/dataclass_values"
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
test = ["pytest>=8"]
|
|
29
|
+
|
|
30
|
+
[tool.hatch.build.targets.wheel]
|
|
31
|
+
packages = ["src/dataclass_values"]
|
|
32
|
+
|
|
33
|
+
[tool.pytest.ini_options]
|
|
34
|
+
testpaths = ["tests"]
|
|
35
|
+
pythonpath = ["src"]
|
|
36
|
+
|
|
37
|
+
[tool.ruff]
|
|
38
|
+
line-length = 100
|
|
39
|
+
target-version = "py311"
|
|
40
|
+
|
|
41
|
+
[tool.ruff.lint]
|
|
42
|
+
select = ["E", "F", "I", "UP", "B"]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""dataclass_values: Value(...) carries a number's evidence; Space(obj) samples and checks it.
|
|
2
|
+
|
|
3
|
+
Domain-free and stdlib-only. See README.md.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from .space import Space
|
|
7
|
+
from .values import (
|
|
8
|
+
Basis,
|
|
9
|
+
Choice,
|
|
10
|
+
FloatValue,
|
|
11
|
+
IntValue,
|
|
12
|
+
LogUniform,
|
|
13
|
+
Normal,
|
|
14
|
+
StrValue,
|
|
15
|
+
TupleValue,
|
|
16
|
+
Uniform,
|
|
17
|
+
Value,
|
|
18
|
+
meta,
|
|
19
|
+
plain,
|
|
20
|
+
v,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"Basis",
|
|
25
|
+
"Choice",
|
|
26
|
+
"FloatValue",
|
|
27
|
+
"IntValue",
|
|
28
|
+
"LogUniform",
|
|
29
|
+
"Normal",
|
|
30
|
+
"Space",
|
|
31
|
+
"StrValue",
|
|
32
|
+
"TupleValue",
|
|
33
|
+
"Uniform",
|
|
34
|
+
"Value",
|
|
35
|
+
"meta",
|
|
36
|
+
"plain",
|
|
37
|
+
"v",
|
|
38
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Space: an instance seen as a space (gymnasium-style), derived from the instance itself.
|
|
2
|
+
|
|
3
|
+
from dataclass_values import Space, Value
|
|
4
|
+
pick = Pose(position=Point(x=Value(0.5, "m", low=0.4, high=0.6), y=0.0, z=0.3))
|
|
5
|
+
Space(pick).sample(rng) # -> a Pose, x drawn in [0.4, 0.6], marked basis=SAMPLED
|
|
6
|
+
Space(pick).contains(measured) # -> the acceptable range in a test
|
|
7
|
+
Space(pick).violations(measured) # -> ["position.x=0.7 outside [0.4, 0.6]"]
|
|
8
|
+
|
|
9
|
+
WHY the same class and a derived space (Zach, Oct 5 2026, "do not re-litigate"): a second
|
|
10
|
+
`PoseSpace` / `CheckedBelt` class "drifts" from the message it shadows. So a field carries its own
|
|
11
|
+
range as a `Value`, which IS the builtin float/int/str/tuple, and `Space(obj)` is a view over the
|
|
12
|
+
instance whose `sample()` returns the ordinary class. Nothing is discarded: the authored instance is
|
|
13
|
+
untouched, and each drawn value keeps its unit, range and source, marked basis=SAMPLED. Call
|
|
14
|
+
`plain()` only at a runtime boundary where you choose to drop the notes.
|
|
15
|
+
|
|
16
|
+
Subclasses add kinds of bound the scalar core knows nothing about (robot_msgs bounds numpy array
|
|
17
|
+
fields) by overriding the two hooks `extra_draw` and `extra_violations`.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import random
|
|
23
|
+
from dataclasses import fields, is_dataclass, replace
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from .values import META, Basis, TupleValue, Value, meta, plain, within
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Space:
|
|
30
|
+
"""Every `Value` in `instance` with a dist, choices or [low, high] can be drawn and checked."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, instance: Any) -> None:
|
|
33
|
+
self.instance = instance
|
|
34
|
+
|
|
35
|
+
# ── sampling ──────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
def sample(self, rng: random.Random | None = None):
|
|
38
|
+
"""The same class as the instance, every ranged Value redrawn (basis=SAMPLED)."""
|
|
39
|
+
rng = rng or random.Random()
|
|
40
|
+
return self.extra_draw(self._draw(self.instance, rng), rng)
|
|
41
|
+
|
|
42
|
+
def extra_draw(self, drawn, rng: random.Random):
|
|
43
|
+
"""Hook: take the already-drawn instance and redraw whatever else this space bounds."""
|
|
44
|
+
return drawn
|
|
45
|
+
|
|
46
|
+
def _draw(self, x, rng):
|
|
47
|
+
if is_dataclass(x) and not isinstance(x, type):
|
|
48
|
+
return replace(
|
|
49
|
+
x, **{f.name: self._draw(getattr(x, f.name), rng) for f in fields(x) if f.init}
|
|
50
|
+
)
|
|
51
|
+
if isinstance(x, TupleValue) and x.dist is None and x.choices is None:
|
|
52
|
+
return x # a vector is one claim: drawn only through its own choices or dist
|
|
53
|
+
if isinstance(x, (list, tuple)) and not isinstance(x, TupleValue):
|
|
54
|
+
return type(x)(self._draw(i, rng) for i in x)
|
|
55
|
+
m = meta(x)
|
|
56
|
+
if not m or (
|
|
57
|
+
m["dist"] is None and m["choices"] is None and (m["low"] is None or m["high"] is None)
|
|
58
|
+
):
|
|
59
|
+
return x
|
|
60
|
+
if m["dist"] is not None:
|
|
61
|
+
drawn = m["dist"].draw(rng)
|
|
62
|
+
elif m["choices"] is not None:
|
|
63
|
+
drawn = rng.choice(m["choices"])
|
|
64
|
+
else:
|
|
65
|
+
drawn = rng.uniform(m["low"], m["high"])
|
|
66
|
+
if isinstance(drawn, (int, float)) and not isinstance(drawn, bool):
|
|
67
|
+
if m["low"] is not None:
|
|
68
|
+
drawn = max(m["low"], drawn)
|
|
69
|
+
if m["high"] is not None:
|
|
70
|
+
drawn = min(m["high"], drawn)
|
|
71
|
+
if isinstance(x, int):
|
|
72
|
+
drawn = round(drawn)
|
|
73
|
+
if m["step"]:
|
|
74
|
+
base = m["low"] if m["low"] is not None else 0
|
|
75
|
+
drawn = base + round((drawn - base) / m["step"]) * m["step"]
|
|
76
|
+
drawn = round(drawn) if isinstance(x, int) else drawn
|
|
77
|
+
return Value(
|
|
78
|
+
drawn,
|
|
79
|
+
m["unit"],
|
|
80
|
+
**{
|
|
81
|
+
**{k: m[k] for k in META if k != "unit"},
|
|
82
|
+
"basis": Basis.SAMPLED,
|
|
83
|
+
"notes": f"sampled from {x!r}" + (f"; {m['notes']}" if m["notes"] else ""),
|
|
84
|
+
},
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
# ── checking ──────────────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
def contains(self, other: Any) -> bool:
|
|
90
|
+
"""True when every ranged field of `other` lies inside this space."""
|
|
91
|
+
return not self.violations(other)
|
|
92
|
+
|
|
93
|
+
def violations(self, other: Any) -> list[str]:
|
|
94
|
+
"""One line per field of `other` outside its range: ``position.x=0.7 outside [0.4, 0.6]``"""
|
|
95
|
+
if type(other) is not type(self.instance):
|
|
96
|
+
raise TypeError(
|
|
97
|
+
f"a {type(self.instance).__name__} space cannot hold a {type(other).__name__}"
|
|
98
|
+
)
|
|
99
|
+
found: list[str] = []
|
|
100
|
+
self._check(self.instance, other, "", found)
|
|
101
|
+
found.extend(self.extra_violations(other))
|
|
102
|
+
return found
|
|
103
|
+
|
|
104
|
+
def extra_violations(self, other: Any) -> list[str]:
|
|
105
|
+
"""Hook: one line per way `other` breaks a bound this space adds beyond the scalar ones."""
|
|
106
|
+
return []
|
|
107
|
+
|
|
108
|
+
def _check(self, ranged, other, path, found):
|
|
109
|
+
if is_dataclass(ranged) and not isinstance(ranged, type):
|
|
110
|
+
for f in fields(ranged):
|
|
111
|
+
self._check(
|
|
112
|
+
getattr(ranged, f.name), getattr(other, f.name), f"{path}{f.name}.", found
|
|
113
|
+
)
|
|
114
|
+
return
|
|
115
|
+
m = meta(ranged)
|
|
116
|
+
if not m:
|
|
117
|
+
return
|
|
118
|
+
name = path.rstrip(".")
|
|
119
|
+
if (
|
|
120
|
+
m["low"] is not None
|
|
121
|
+
and m["high"] is not None
|
|
122
|
+
and not within(other, m["low"], m["high"])
|
|
123
|
+
):
|
|
124
|
+
found.append(f"{name}={plain(other)!r} outside [{m['low']}, {m['high']}]")
|
|
125
|
+
elif m["choices"] is not None and other not in m["choices"]:
|
|
126
|
+
found.append(f"{name}={plain(other)!r} not one of {m['choices']}")
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
"""A number (or string) that remembers where it came from, and still is the number.
|
|
2
|
+
|
|
3
|
+
width_m = Value(1.2, "m", low=0.6, high=1.85, basis=Basis.CHOSEN, source="…")
|
|
4
|
+
v = Value # optional short alias; the real name is the readable one
|
|
5
|
+
|
|
6
|
+
WHY `Value(...)` reads like a type cast (Zach, Oct 5 2026): "v should be v = Value, v as Value, it's
|
|
7
|
+
like a type casting; the actual thing needs a readable name". `Value(1.2, "m")` returns a
|
|
8
|
+
FloatValue, so `isinstance(x, Value)` and `isinstance(x, float)` are both true.
|
|
9
|
+
WHY a float subclass and not a wrapper: "I definitely don't want to have to do width_m.value". The
|
|
10
|
+
class keeps `width_m: float`, `belt.width_m * 2` is a plain 2.4, ROS/numpy see a float, and the
|
|
11
|
+
evidence rides along.
|
|
12
|
+
WHY the value is first and unnamed, unit optionally second: both are obvious at a glance; everything
|
|
13
|
+
else is keyword-only, so the old positional puzzle (est(…, C, 0.6, 1.85)) cannot come back.
|
|
14
|
+
WHY arithmetic returns a plain number: a derived number is a new claim and must not inherit the
|
|
15
|
+
original's source.
|
|
16
|
+
WHY a tuple carrier: a vector (xyz, a limit pair) is one claim with one source;
|
|
17
|
+
`Value((0.0, 0.0, 0.3), "m")` is still a tuple, and `low`/`high` given as tuples are checked
|
|
18
|
+
elementwise.
|
|
19
|
+
WHY its own package (Zach, Nov 10 2026): "the pydataclass syntax can technically apply to any
|
|
20
|
+
domain! robot_msgs is just a particular domain." Domain-free, stdlib only.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import math
|
|
26
|
+
import random
|
|
27
|
+
from dataclasses import dataclass, fields, is_dataclass, replace
|
|
28
|
+
from enum import StrEnum
|
|
29
|
+
from typing import overload
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Basis(StrEnum):
|
|
33
|
+
"""Where a number comes from (CHOSEN..ASSUMED match bam_facility_model.estimate.Basis)."""
|
|
34
|
+
|
|
35
|
+
CHOSEN = "chosen"
|
|
36
|
+
DATASHEET = "datasheet"
|
|
37
|
+
MARKET = "market"
|
|
38
|
+
ASSUMED = "assumed"
|
|
39
|
+
MEASURED = "measured"
|
|
40
|
+
SAMPLED = "sampled" # drawn by Space.sample() from this value's range, choices or distribution
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# --- distributions: per-value, first class ---
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class Uniform:
|
|
46
|
+
low: float
|
|
47
|
+
high: float
|
|
48
|
+
|
|
49
|
+
def draw(self, rng: random.Random) -> float:
|
|
50
|
+
return rng.uniform(self.low, self.high)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class Normal:
|
|
55
|
+
mean: float
|
|
56
|
+
std: float
|
|
57
|
+
|
|
58
|
+
def draw(self, rng: random.Random) -> float:
|
|
59
|
+
return rng.gauss(self.mean, self.std)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class LogUniform:
|
|
64
|
+
low: float
|
|
65
|
+
high: float
|
|
66
|
+
|
|
67
|
+
def draw(self, rng: random.Random) -> float:
|
|
68
|
+
return math.exp(rng.uniform(math.log(self.low), math.log(self.high)))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class Choice:
|
|
73
|
+
"""A discrete distribution: options with optional weights."""
|
|
74
|
+
|
|
75
|
+
options: tuple
|
|
76
|
+
weights: tuple | None = None
|
|
77
|
+
|
|
78
|
+
def draw(self, rng: random.Random):
|
|
79
|
+
return rng.choices(self.options, weights=self.weights)[0]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
Distribution = Uniform | Normal | LogUniform | Choice
|
|
83
|
+
|
|
84
|
+
META = (
|
|
85
|
+
"unit",
|
|
86
|
+
"low",
|
|
87
|
+
"high",
|
|
88
|
+
"step",
|
|
89
|
+
"choices",
|
|
90
|
+
"dist",
|
|
91
|
+
"basis",
|
|
92
|
+
"source",
|
|
93
|
+
"measured_by",
|
|
94
|
+
"notes",
|
|
95
|
+
"date_accessed",
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def within(raw, low, high) -> bool:
|
|
100
|
+
"""low <= raw <= high; for a tuple raw, elementwise (a scalar bound applies to each element)."""
|
|
101
|
+
if isinstance(raw, tuple):
|
|
102
|
+
lows = low if isinstance(low, tuple) else (low,) * len(raw)
|
|
103
|
+
highs = high if isinstance(high, tuple) else (high,) * len(raw)
|
|
104
|
+
if len(lows) != len(raw) or len(highs) != len(raw):
|
|
105
|
+
raise ValueError(f"{raw!r} has {len(raw)} elements but low={low!r}, high={high!r}")
|
|
106
|
+
return all(lo <= x <= hi for lo, x, hi in zip(lows, raw, highs, strict=True))
|
|
107
|
+
return low <= raw <= high
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _rebuild(cls, raw, meta):
|
|
111
|
+
meta = dict(meta)
|
|
112
|
+
unit = meta.pop("unit") # unit is positional-only in the constructor
|
|
113
|
+
return cls(raw, unit, **meta)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class Value:
|
|
117
|
+
"""`Value(raw, unit=None, *, low, high, step, choices, dist, basis, source, ...)`.
|
|
118
|
+
|
|
119
|
+
Calling Value(...) picks the carrier for the raw type (FloatValue / IntValue / StrValue /
|
|
120
|
+
TupleValue), each of which subclasses both Value and the builtin. bool and None cannot carry
|
|
121
|
+
evidence: Python forbids subclassing them.
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
_base: type = object
|
|
125
|
+
|
|
126
|
+
@overload
|
|
127
|
+
def __new__(
|
|
128
|
+
cls,
|
|
129
|
+
raw: int,
|
|
130
|
+
unit: str | None = None,
|
|
131
|
+
/,
|
|
132
|
+
*,
|
|
133
|
+
low: float | None = None,
|
|
134
|
+
high: float | None = None,
|
|
135
|
+
step: float | None = None,
|
|
136
|
+
choices: tuple | None = None,
|
|
137
|
+
dist: Distribution | None = None,
|
|
138
|
+
basis: Basis | None = None,
|
|
139
|
+
source: str = "",
|
|
140
|
+
measured_by: str = "",
|
|
141
|
+
notes: str = "",
|
|
142
|
+
date_accessed: str = "",
|
|
143
|
+
) -> IntValue: ...
|
|
144
|
+
|
|
145
|
+
@overload
|
|
146
|
+
def __new__(
|
|
147
|
+
cls,
|
|
148
|
+
raw: float,
|
|
149
|
+
unit: str | None = None,
|
|
150
|
+
/,
|
|
151
|
+
*,
|
|
152
|
+
low: float | None = None,
|
|
153
|
+
high: float | None = None,
|
|
154
|
+
step: float | None = None,
|
|
155
|
+
choices: tuple | None = None,
|
|
156
|
+
dist: Distribution | None = None,
|
|
157
|
+
basis: Basis | None = None,
|
|
158
|
+
source: str = "",
|
|
159
|
+
measured_by: str = "",
|
|
160
|
+
notes: str = "",
|
|
161
|
+
date_accessed: str = "",
|
|
162
|
+
) -> FloatValue: ...
|
|
163
|
+
|
|
164
|
+
@overload
|
|
165
|
+
def __new__(
|
|
166
|
+
cls,
|
|
167
|
+
raw: str,
|
|
168
|
+
unit: str | None = None,
|
|
169
|
+
/,
|
|
170
|
+
*,
|
|
171
|
+
low: None = None,
|
|
172
|
+
high: None = None,
|
|
173
|
+
step: None = None,
|
|
174
|
+
choices: tuple | None = None,
|
|
175
|
+
dist: Distribution | None = None,
|
|
176
|
+
basis: Basis | None = None,
|
|
177
|
+
source: str = "",
|
|
178
|
+
measured_by: str = "",
|
|
179
|
+
notes: str = "",
|
|
180
|
+
date_accessed: str = "",
|
|
181
|
+
) -> StrValue: ...
|
|
182
|
+
|
|
183
|
+
@overload
|
|
184
|
+
def __new__(
|
|
185
|
+
cls,
|
|
186
|
+
raw: tuple,
|
|
187
|
+
unit: str | None = None,
|
|
188
|
+
/,
|
|
189
|
+
*,
|
|
190
|
+
low: float | tuple | None = None,
|
|
191
|
+
high: float | tuple | None = None,
|
|
192
|
+
step: float | None = None,
|
|
193
|
+
choices: tuple | None = None,
|
|
194
|
+
dist: Distribution | None = None,
|
|
195
|
+
basis: Basis | None = None,
|
|
196
|
+
source: str = "",
|
|
197
|
+
measured_by: str = "",
|
|
198
|
+
notes: str = "",
|
|
199
|
+
date_accessed: str = "",
|
|
200
|
+
) -> TupleValue: ...
|
|
201
|
+
|
|
202
|
+
def __new__(
|
|
203
|
+
cls,
|
|
204
|
+
raw=None,
|
|
205
|
+
unit=None,
|
|
206
|
+
/,
|
|
207
|
+
*,
|
|
208
|
+
low=None,
|
|
209
|
+
high=None,
|
|
210
|
+
step=None,
|
|
211
|
+
choices=None,
|
|
212
|
+
dist=None,
|
|
213
|
+
basis=None,
|
|
214
|
+
source="",
|
|
215
|
+
measured_by="",
|
|
216
|
+
notes="",
|
|
217
|
+
date_accessed="",
|
|
218
|
+
):
|
|
219
|
+
if cls is Value:
|
|
220
|
+
if isinstance(raw, bool) or raw is None:
|
|
221
|
+
raise TypeError(
|
|
222
|
+
"bool and None cannot carry evidence (Python forbids subclassing them); "
|
|
223
|
+
"leave them plain"
|
|
224
|
+
)
|
|
225
|
+
for base, carrier in (
|
|
226
|
+
(float, FloatValue),
|
|
227
|
+
(int, IntValue),
|
|
228
|
+
(str, StrValue),
|
|
229
|
+
(tuple, TupleValue),
|
|
230
|
+
):
|
|
231
|
+
if isinstance(raw, base):
|
|
232
|
+
cls = carrier
|
|
233
|
+
break
|
|
234
|
+
else:
|
|
235
|
+
raise TypeError(f"Value wraps float, int, str or tuple, not {type(raw).__name__}")
|
|
236
|
+
obj = cls._base.__new__(cls, raw)
|
|
237
|
+
meta = dict(
|
|
238
|
+
unit=unit,
|
|
239
|
+
low=low,
|
|
240
|
+
high=high,
|
|
241
|
+
step=step,
|
|
242
|
+
choices=tuple(choices) if choices is not None else None,
|
|
243
|
+
dist=dist,
|
|
244
|
+
basis=basis,
|
|
245
|
+
source=source,
|
|
246
|
+
measured_by=measured_by,
|
|
247
|
+
notes=notes,
|
|
248
|
+
date_accessed=date_accessed,
|
|
249
|
+
)
|
|
250
|
+
for k, val in meta.items():
|
|
251
|
+
object.__setattr__(obj, k, val)
|
|
252
|
+
if low is not None and high is not None and not within(raw, low, high):
|
|
253
|
+
raise ValueError(
|
|
254
|
+
f"{raw!r} outside [{low}, {high}] ({source or unit or ''})".rstrip(" ()")
|
|
255
|
+
)
|
|
256
|
+
if meta["choices"] is not None and raw not in meta["choices"]:
|
|
257
|
+
raise ValueError(f"{raw!r} not one of {meta['choices']}")
|
|
258
|
+
return obj
|
|
259
|
+
|
|
260
|
+
def __init__(self, *args, **kwargs): # all work happens in __new__
|
|
261
|
+
pass
|
|
262
|
+
|
|
263
|
+
def __setattr__(self, key, val):
|
|
264
|
+
raise AttributeError(
|
|
265
|
+
f"{type(self).__name__} is immutable; write a new Value(...) in the source"
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
def __reduce__(self): # copy, deepcopy, asdict and pickle rebuild through __new__
|
|
269
|
+
return (_rebuild, (type(self), self._base(self), self.meta()))
|
|
270
|
+
|
|
271
|
+
@property
|
|
272
|
+
def value(self):
|
|
273
|
+
"""The plain builtin. New code just uses the value itself."""
|
|
274
|
+
return self._base(self)
|
|
275
|
+
|
|
276
|
+
def meta(self) -> dict:
|
|
277
|
+
return {k: getattr(self, k) for k in META}
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
class FloatValue(Value, float):
|
|
281
|
+
_base = float
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
class IntValue(Value, int):
|
|
285
|
+
_base = int
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
class StrValue(Value, str):
|
|
289
|
+
_base = str
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
class TupleValue(Value, tuple):
|
|
293
|
+
"""A vector or pair that remembers where it came from.
|
|
294
|
+
|
|
295
|
+
`Value((0.0, 0.0, 0.3), "m", low=(-1, -1, 0), high=(1, 1, 2))`.
|
|
296
|
+
"""
|
|
297
|
+
|
|
298
|
+
_base = tuple
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
v = Value # the optional short alias
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def meta(x) -> dict | None:
|
|
305
|
+
"""The evidence on a value, or None for a plain one."""
|
|
306
|
+
return x.meta() if isinstance(x, Value) else None
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def plain(obj):
|
|
310
|
+
"""The same object with every Value replaced by its builtin: for ROS, JSON and the like."""
|
|
311
|
+
if isinstance(obj, TupleValue):
|
|
312
|
+
return tuple(plain(x) for x in obj)
|
|
313
|
+
if is_dataclass(obj) and not isinstance(obj, type):
|
|
314
|
+
return replace(obj, **{f.name: plain(getattr(obj, f.name)) for f in fields(obj) if f.init})
|
|
315
|
+
if isinstance(obj, (list, tuple)):
|
|
316
|
+
return type(obj)(plain(x) for x in obj)
|
|
317
|
+
return obj.value if isinstance(obj, Value) else obj
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""Space: a dataclass of Values seen as a space you can sample and check against."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import random
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from dataclass_values import (
|
|
11
|
+
Basis,
|
|
12
|
+
Choice,
|
|
13
|
+
LogUniform,
|
|
14
|
+
Normal,
|
|
15
|
+
Space,
|
|
16
|
+
TupleValue,
|
|
17
|
+
Uniform,
|
|
18
|
+
Value,
|
|
19
|
+
meta,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class Point:
|
|
25
|
+
x: float = 0.0
|
|
26
|
+
y: float = 0.0
|
|
27
|
+
z: float = 0.0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class Pose:
|
|
32
|
+
position: Point = field(default_factory=Point)
|
|
33
|
+
label: str = ""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def ranged_pose() -> Pose:
|
|
37
|
+
return Pose(
|
|
38
|
+
position=Point(
|
|
39
|
+
x=Value(0.5, "m", low=0.4, high=0.6, source="bin centre ± 10 cm"),
|
|
40
|
+
y=0.0,
|
|
41
|
+
z=Value(0.3, "m", dist=Normal(0.3, 0.01), low=0.25, high=0.35),
|
|
42
|
+
),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ── sampling ─────────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_sample_returns_the_same_class_drawn_inside_every_range():
|
|
50
|
+
space = Space(ranged_pose())
|
|
51
|
+
for seed in range(200):
|
|
52
|
+
drawn = space.sample(random.Random(seed))
|
|
53
|
+
assert type(drawn) is Pose and type(drawn.position) is Point
|
|
54
|
+
assert 0.4 <= drawn.position.x <= 0.6
|
|
55
|
+
assert 0.25 <= drawn.position.z <= 0.35
|
|
56
|
+
assert drawn.position.y == 0.0 # no range, unchanged
|
|
57
|
+
assert meta(drawn.position.x)["basis"] is Basis.SAMPLED
|
|
58
|
+
assert space.sample(random.Random(7)) == space.sample(random.Random(7))
|
|
59
|
+
assert len({space.sample(random.Random(s)).position.x for s in range(20)}) > 10
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_a_sampled_value_keeps_its_unit_source_and_range_and_notes_where_it_came_from():
|
|
63
|
+
drawn = Space(ranged_pose()).sample(random.Random(1)).position.x
|
|
64
|
+
m = meta(drawn)
|
|
65
|
+
assert (m["unit"], m["low"], m["high"], m["source"]) == ("m", 0.4, 0.6, "bin centre ± 10 cm")
|
|
66
|
+
assert m["notes"].startswith("sampled from 0.5")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_the_authored_instance_is_untouched():
|
|
70
|
+
pose = ranged_pose()
|
|
71
|
+
Space(pose).sample(random.Random(0))
|
|
72
|
+
assert meta(pose.position.x)["basis"] is None and pose.position.x == 0.5
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_sample_without_an_rng_still_works():
|
|
76
|
+
assert 0.4 <= Space(ranged_pose()).sample().position.x <= 0.6
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_distributions_draw_inside_their_clamps():
|
|
80
|
+
@dataclass(frozen=True)
|
|
81
|
+
class Scene:
|
|
82
|
+
gauss: float
|
|
83
|
+
log: float
|
|
84
|
+
uni: float
|
|
85
|
+
|
|
86
|
+
scene = Scene(
|
|
87
|
+
Value(1.0, dist=Normal(1.0, 5.0), low=0.0, high=2.0),
|
|
88
|
+
Value(1.0, dist=LogUniform(0.1, 10.0), low=0.1, high=10.0),
|
|
89
|
+
Value(1.0, dist=Uniform(0.0, 3.0), low=0.0, high=2.0),
|
|
90
|
+
)
|
|
91
|
+
for seed in range(100):
|
|
92
|
+
drawn = Space(scene).sample(random.Random(seed))
|
|
93
|
+
assert 0.0 <= drawn.gauss <= 2.0 and 0.1 <= drawn.log <= 10.0 and 0.0 <= drawn.uni <= 2.0
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_step_snaps_and_ints_stay_ints():
|
|
97
|
+
@dataclass(frozen=True)
|
|
98
|
+
class Rack:
|
|
99
|
+
pitch_mm: float
|
|
100
|
+
slots: int
|
|
101
|
+
|
|
102
|
+
rack = Rack(Value(5.0, low=0.0, high=10.0, step=2.5), Value(4, low=1, high=9, step=2))
|
|
103
|
+
for seed in range(50):
|
|
104
|
+
drawn = Space(rack).sample(random.Random(seed))
|
|
105
|
+
assert drawn.pitch_mm % 2.5 == 0
|
|
106
|
+
assert type(drawn.slots.value) is int and (drawn.slots - 1) % 2 == 0
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_plain_and_sample_on_a_dataclass_with_a_tuple_of_values():
|
|
110
|
+
@dataclass(frozen=True)
|
|
111
|
+
class Belt:
|
|
112
|
+
width_m: float
|
|
113
|
+
items: tuple = ()
|
|
114
|
+
|
|
115
|
+
belt = Belt(Value(1.2, "m", low=0.6, high=1.85), (Value(1, low=0, high=3), 2))
|
|
116
|
+
drawn = Space(belt).sample(random.Random(1))
|
|
117
|
+
assert meta(drawn.width_m)["basis"] is Basis.SAMPLED and 0.6 <= drawn.width_m <= 1.85
|
|
118
|
+
assert drawn.items[1] == 2 and meta(drawn.items[0])["basis"] is Basis.SAMPLED
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_a_string_choice_is_sampled():
|
|
122
|
+
@dataclass(frozen=True)
|
|
123
|
+
class Gripper:
|
|
124
|
+
kind: str
|
|
125
|
+
|
|
126
|
+
seen = {
|
|
127
|
+
Space(Gripper(Value("suction", choices=("suction", "jaw", "hook"))))
|
|
128
|
+
.sample(random.Random(s))
|
|
129
|
+
.kind
|
|
130
|
+
for s in range(40)
|
|
131
|
+
}
|
|
132
|
+
assert seen == {"suction", "jaw", "hook"}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_a_tuple_without_choices_or_dist_is_one_claim_and_is_left_unchanged():
|
|
136
|
+
@dataclass(frozen=True)
|
|
137
|
+
class Rig:
|
|
138
|
+
position_m: tuple
|
|
139
|
+
width_m: float
|
|
140
|
+
|
|
141
|
+
position = Value((0.0, 0.0, 0.3), "m", low=(-1, -1, 0), high=(1, 1, 2))
|
|
142
|
+
rig = Rig(position, Value(1.0, "m", low=0.5, high=1.5))
|
|
143
|
+
for seed in range(5):
|
|
144
|
+
drawn = Space(rig).sample(random.Random(seed))
|
|
145
|
+
assert drawn.position_m is position
|
|
146
|
+
assert meta(drawn.width_m)["basis"] is Basis.SAMPLED
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_a_tuple_is_drawn_from_choices_or_a_choice_dist():
|
|
150
|
+
options = ((0.0, 0.0), (1.0, 0.0), (0.0, 1.0))
|
|
151
|
+
by_choices = Value(options[0], "m", choices=options)
|
|
152
|
+
by_dist = Value(options[0], "m", choices=options, dist=Choice(options, (0.2, 0.3, 0.5)))
|
|
153
|
+
for x in (by_choices, by_dist):
|
|
154
|
+
seen = set()
|
|
155
|
+
for seed in range(30):
|
|
156
|
+
drawn = Space(x).sample(random.Random(seed))
|
|
157
|
+
assert type(drawn) is TupleValue and tuple(drawn) in options
|
|
158
|
+
assert meta(drawn)["basis"] is Basis.SAMPLED and drawn.unit == "m"
|
|
159
|
+
seen.add(tuple(drawn))
|
|
160
|
+
assert len(seen) > 1
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# ── contains / violations ────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_sampled_instances_are_inside_their_space():
|
|
167
|
+
space = Space(ranged_pose())
|
|
168
|
+
assert all(space.contains(space.sample(random.Random(s))) for s in range(50))
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def test_violations_name_every_broken_field_by_dotted_path():
|
|
172
|
+
space = Space(ranged_pose())
|
|
173
|
+
good = Pose(position=Point(x=0.45, y=9.0, z=0.3))
|
|
174
|
+
bad = Pose(position=Point(x=0.7, y=0.0, z=0.2))
|
|
175
|
+
assert space.contains(good) and space.violations(good) == []
|
|
176
|
+
assert not space.contains(bad)
|
|
177
|
+
broken = space.violations(bad)
|
|
178
|
+
assert [line.split(" ")[0] for line in broken] == ["position.x=0.7", "position.z=0.2"]
|
|
179
|
+
assert broken[0] == "position.x=0.7 outside [0.4, 0.6]"
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def test_violations_report_choices():
|
|
183
|
+
@dataclass(frozen=True)
|
|
184
|
+
class Gripper:
|
|
185
|
+
kind: str
|
|
186
|
+
|
|
187
|
+
space = Space(Gripper(Value("jaw", choices=("jaw", "hook"))))
|
|
188
|
+
assert space.contains(Gripper("hook"))
|
|
189
|
+
assert space.violations(Gripper("magnet")) == ["kind='magnet' not one of ('jaw', 'hook')"]
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def test_contains_checks_tuples_elementwise():
|
|
193
|
+
@dataclass(frozen=True)
|
|
194
|
+
class Rig:
|
|
195
|
+
position_m: tuple
|
|
196
|
+
|
|
197
|
+
space = Space(Rig(Value((0.5, 0.5), low=(0.0, 0.0), high=(1.0, 1.0))))
|
|
198
|
+
assert space.contains(Rig((0.9, 0.1)))
|
|
199
|
+
assert not space.contains(Rig((0.5, 9.0)))
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def test_contains_refuses_a_different_type():
|
|
203
|
+
with pytest.raises(TypeError, match="cannot hold"):
|
|
204
|
+
Space(ranged_pose()).contains(Point())
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# ── extension hooks: a subclass adds bounds the base knows nothing about ─────
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class Capped(Space):
|
|
211
|
+
"""A subclass bounding a plain `count` attribute, the way robot_msgs bounds array fields."""
|
|
212
|
+
|
|
213
|
+
def __init__(self, instance, *, cap: int) -> None:
|
|
214
|
+
super().__init__(instance)
|
|
215
|
+
self.cap = cap
|
|
216
|
+
|
|
217
|
+
def extra_draw(self, drawn, rng):
|
|
218
|
+
from dataclasses import replace
|
|
219
|
+
|
|
220
|
+
return replace(drawn, count=rng.randint(0, self.cap))
|
|
221
|
+
|
|
222
|
+
def extra_violations(self, other):
|
|
223
|
+
return [f"count={other.count} above cap {self.cap}"] if other.count > self.cap else []
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@dataclass(frozen=True)
|
|
227
|
+
class Counted:
|
|
228
|
+
width_m: float = 1.0
|
|
229
|
+
count: int = 0
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def test_a_subclass_can_add_draws_and_checks_through_the_hooks():
|
|
233
|
+
space = Capped(Counted(Value(1.0, low=0.5, high=1.5)), cap=3)
|
|
234
|
+
drawn = space.sample(random.Random(2))
|
|
235
|
+
assert type(drawn) is Counted and 0 <= drawn.count <= 3
|
|
236
|
+
assert meta(drawn.width_m)["basis"] is Basis.SAMPLED
|
|
237
|
+
assert space.contains(drawn)
|
|
238
|
+
assert space.violations(Counted(1.0, 9)) == ["count=9 above cap 3"]
|
|
239
|
+
# the scalar ranges are still checked alongside the extra ones
|
|
240
|
+
assert len(space.violations(Counted(2.0, 9))) == 2
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def test_the_base_hooks_add_nothing():
|
|
244
|
+
pose = ranged_pose()
|
|
245
|
+
assert Space(pose).extra_draw(pose, random.Random(0)) is pose
|
|
246
|
+
assert Space(pose).extra_violations(pose) == []
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Value, the four carriers, meta and plain: a number that remembers where it came from."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
import pickle
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import tempfile
|
|
11
|
+
import typing
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import pytest
|
|
16
|
+
|
|
17
|
+
import dataclass_values
|
|
18
|
+
from dataclass_values import Basis, TupleValue, Value, meta, plain
|
|
19
|
+
from dataclass_values.values import FloatValue, IntValue, StrValue
|
|
20
|
+
|
|
21
|
+
SRC = Path(__file__).resolve().parents[1] / "src"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_the_package_is_stdlib_only():
|
|
25
|
+
code = "import sys, dataclass_values; print(sorted(m for m in sys.modules if m == 'numpy'))"
|
|
26
|
+
out = subprocess.run(
|
|
27
|
+
[sys.executable, "-c", code], capture_output=True, text=True, env={"PYTHONPATH": str(SRC)}
|
|
28
|
+
)
|
|
29
|
+
assert out.stdout.strip() == "[]", out.stderr
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_public_names():
|
|
33
|
+
assert set(dataclass_values.__all__) == {
|
|
34
|
+
"Basis", "Choice", "FloatValue", "IntValue", "LogUniform", "Normal", "Space",
|
|
35
|
+
"StrValue", "TupleValue", "Uniform", "Value", "meta", "plain", "v",
|
|
36
|
+
} # fmt: skip
|
|
37
|
+
assert dataclass_values.v is Value
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ── scalars ──────────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_carriers_are_their_builtins():
|
|
44
|
+
assert type(Value(1.2, "m")) is FloatValue
|
|
45
|
+
assert type(Value(25)) is IntValue
|
|
46
|
+
assert type(Value("x")) is StrValue
|
|
47
|
+
assert isinstance(Value(1.2), float) and isinstance(Value(1.2), Value)
|
|
48
|
+
assert isinstance(Value(25), int)
|
|
49
|
+
assert isinstance(Value("x"), str)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_arithmetic_returns_a_plain_number():
|
|
53
|
+
assert Value(1.2, "m") * 2 == 2.4
|
|
54
|
+
assert type(Value(1.2, "m") * 2) is float
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_range_and_choices_are_checked_at_construction():
|
|
58
|
+
with pytest.raises(ValueError, match="outside"):
|
|
59
|
+
Value(2.0, low=0.6, high=1.85)
|
|
60
|
+
with pytest.raises(ValueError, match="not one of"):
|
|
61
|
+
Value(3, choices=(1, 2))
|
|
62
|
+
assert Value(1.85, low=0.6, high=1.85) == 1.85
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@pytest.mark.parametrize("bad", [True, None, [1, 2], {"a": 1}])
|
|
66
|
+
def test_bool_none_and_other_types_are_refused(bad):
|
|
67
|
+
with pytest.raises(TypeError):
|
|
68
|
+
Value(bad)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_immutable_and_meta():
|
|
72
|
+
x = Value(1.2, "m", basis=Basis.CHOSEN, source="s")
|
|
73
|
+
with pytest.raises(AttributeError):
|
|
74
|
+
x.unit = "cm"
|
|
75
|
+
assert meta(x)["basis"] is Basis.CHOSEN
|
|
76
|
+
assert meta(x)["unit"] == "m" and meta(x)["source"] == "s"
|
|
77
|
+
assert meta(1.2) is None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_basis_is_a_string():
|
|
81
|
+
assert Basis.CHOSEN == "chosen" and str(Basis.SAMPLED) == "sampled"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_value_property_is_the_builtin():
|
|
85
|
+
assert type(Value(1.2).value) is float
|
|
86
|
+
assert type(Value(2).value) is int
|
|
87
|
+
assert type(Value("x").value) is str
|
|
88
|
+
assert type(Value((1, 2)).value) is tuple
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_equality_and_hash_are_on_the_number():
|
|
92
|
+
assert Value(1.2, "m", source="a") == Value(1.2, "cm", source="b") == 1.2
|
|
93
|
+
assert hash(Value(1.2, "m")) == hash(1.2)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_copy_and_pickle_keep_the_evidence():
|
|
97
|
+
x = Value(1.2, "m", low=0.6, high=1.85, basis=Basis.CHOSEN, source="s")
|
|
98
|
+
for y in (copy.copy(x), copy.deepcopy(x), pickle.loads(pickle.dumps(x))):
|
|
99
|
+
assert y.meta() == x.meta()
|
|
100
|
+
assert type(y) is FloatValue
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_plain_and_nested_containers():
|
|
104
|
+
@dataclass(frozen=True)
|
|
105
|
+
class Belt:
|
|
106
|
+
width_m: float
|
|
107
|
+
items: tuple = ()
|
|
108
|
+
tags: list | None = None
|
|
109
|
+
|
|
110
|
+
belt = Belt(Value(1.2, "m"), (Value(1, low=0, high=3), 2), [Value("a"), "b"])
|
|
111
|
+
bare = plain(belt)
|
|
112
|
+
assert type(bare.width_m) is float
|
|
113
|
+
assert type(bare.items[0]) is int
|
|
114
|
+
assert type(bare.tags[0]) is str and isinstance(bare.tags, list)
|
|
115
|
+
assert bare == belt # equal on the numbers
|
|
116
|
+
assert plain(1.5) == 1.5
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ── typing.overload ──────────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_four_overloads_are_registered_in_order():
|
|
123
|
+
ns = {
|
|
124
|
+
"IntValue": IntValue, "FloatValue": FloatValue, "StrValue": StrValue,
|
|
125
|
+
"TupleValue": TupleValue, "Distribution": typing.Any, "Basis": Basis,
|
|
126
|
+
} # fmt: skip
|
|
127
|
+
returns = [typing.get_type_hints(o, ns)["return"] for o in typing.get_overloads(Value.__new__)]
|
|
128
|
+
assert returns == [IntValue, FloatValue, StrValue, TupleValue]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@pytest.mark.skipif(not shutil.which("pyright"), reason="pyright is not installed")
|
|
132
|
+
def test_type_checker_infers_the_carrier():
|
|
133
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
134
|
+
Path(tmp, "t.py").write_text(
|
|
135
|
+
"from dataclass_values import Value\n"
|
|
136
|
+
"reveal_type(Value(1.2))\nreveal_type(Value(25))\n"
|
|
137
|
+
"reveal_type(Value('x'))\nreveal_type(Value((1, 2)))\n"
|
|
138
|
+
)
|
|
139
|
+
Path(tmp, "pyrightconfig.json").write_text(
|
|
140
|
+
f'{{"extraPaths": ["{SRC}"], "pythonVersion": "3.11"}}'
|
|
141
|
+
)
|
|
142
|
+
out = subprocess.run(
|
|
143
|
+
["pyright", "t.py"], cwd=tmp, capture_output=True, text=True, timeout=120
|
|
144
|
+
).stdout
|
|
145
|
+
for expected in ('"FloatValue"', '"IntValue"', '"StrValue"', '"TupleValue"'):
|
|
146
|
+
assert expected in out
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# ── tuples ───────────────────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_a_tuple_carrier_that_is_still_a_tuple():
|
|
153
|
+
xyz = Value((0.0, 0.0, 0.3), "m", basis=Basis.MEASURED, source="rig")
|
|
154
|
+
assert type(xyz) is TupleValue and isinstance(xyz, tuple) and isinstance(xyz, Value)
|
|
155
|
+
assert xyz == (0.0, 0.0, 0.3) and xyz[2] == 0.3 and len(xyz) == 3
|
|
156
|
+
x, y, z = xyz
|
|
157
|
+
assert (x, y, z) == (0.0, 0.0, 0.3)
|
|
158
|
+
assert (xyz.unit, xyz.basis, xyz.source) == ("m", Basis.MEASURED, "rig")
|
|
159
|
+
assert type(xyz.value) is tuple
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_tuple_limits_are_checked_elementwise():
|
|
163
|
+
Value((0.0, 0.5, 1.0), low=(-1.0, 0.0, 0.0), high=(1.0, 1.0, 2.0))
|
|
164
|
+
with pytest.raises(ValueError):
|
|
165
|
+
Value((0.0, 0.5, 2.5), low=(-1.0, 0.0, 0.0), high=(1.0, 1.0, 2.0)) # only the last is out
|
|
166
|
+
with pytest.raises(ValueError):
|
|
167
|
+
Value((-0.5, 0.5, 1.0), low=(0.0, 0.0, 0.0), high=(1.0, 1.0, 2.0)) # only the first is out
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def test_tuple_range_is_not_lexicographic():
|
|
171
|
+
# (0.5, 9.0) <= (1.0, 1.0) is True as a tuple comparison; elementwise it is out of range
|
|
172
|
+
with pytest.raises(ValueError):
|
|
173
|
+
Value((0.5, 9.0), low=(0.0, 0.0), high=(1.0, 1.0))
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def test_scalar_limits_apply_to_every_element_and_a_wrong_length_is_refused():
|
|
177
|
+
Value((0.1, 0.9), low=0.0, high=1.0)
|
|
178
|
+
with pytest.raises(ValueError):
|
|
179
|
+
Value((0.1, 1.9), low=0.0, high=1.0)
|
|
180
|
+
with pytest.raises(ValueError, match="elements"):
|
|
181
|
+
Value((0.1, 0.9), low=(0.0, 0.0, 0.0), high=(1.0, 1.0, 1.0))
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_choices_work_on_tuples():
|
|
185
|
+
Value((1, 2), choices=((1, 2), (3, 4)))
|
|
186
|
+
with pytest.raises(ValueError):
|
|
187
|
+
Value((5, 6), choices=((1, 2), (3, 4)))
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def test_tuple_is_immutable_and_survives_copy_and_pickle():
|
|
191
|
+
xyz = Value((1.0, 2.0, 3.0), "m", low=(0, 0, 0), high=(5, 5, 5), source="s")
|
|
192
|
+
with pytest.raises(AttributeError):
|
|
193
|
+
xyz.unit = "cm"
|
|
194
|
+
for y in (copy.copy(xyz), copy.deepcopy(xyz), pickle.loads(pickle.dumps(xyz))):
|
|
195
|
+
assert type(y) is TupleValue and y == (1.0, 2.0, 3.0) and y.meta() == xyz.meta()
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def test_plain_returns_a_bare_tuple_inside_a_dataclass():
|
|
199
|
+
@dataclass(frozen=True)
|
|
200
|
+
class Rig:
|
|
201
|
+
position_m: tuple
|
|
202
|
+
gains: tuple = (1.0, 0.1)
|
|
203
|
+
|
|
204
|
+
rig = Rig(Value((0.0, 0.0, 0.3), "m"))
|
|
205
|
+
assert type(plain(rig).position_m) is tuple
|
|
206
|
+
assert type(plain(rig.position_m)) is tuple
|
|
207
|
+
assert plain(rig).position_m == (0.0, 0.0, 0.3)
|