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.
@@ -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,6 @@
1
+ __pycache__/
2
+ *.egg-info/
3
+ .venv/
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ dist/
@@ -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)