tomlrange 0.2.1__tar.gz → 0.2.2__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.
- {tomlrange-0.2.1 → tomlrange-0.2.2}/.github/workflows/bump-version.yml +2 -13
- tomlrange-0.2.2/.github/workflows/ci.yml +26 -0
- {tomlrange-0.2.1 → tomlrange-0.2.2}/PKG-INFO +6 -1
- {tomlrange-0.2.1 → tomlrange-0.2.2}/README.md +5 -0
- {tomlrange-0.2.1 → tomlrange-0.2.2}/pyproject.toml +1 -1
- {tomlrange-0.2.1 → tomlrange-0.2.2}/src/tomlrange/__init__.py +7 -1
- {tomlrange-0.2.1 → tomlrange-0.2.2}/src/tomlrange/bound.py +24 -9
- {tomlrange-0.2.1 → tomlrange-0.2.2}/src/tomlrange/domain.py +5 -5
- {tomlrange-0.2.1 → tomlrange-0.2.2}/tests/test_tomlrange.py +102 -0
- {tomlrange-0.2.1 → tomlrange-0.2.2}/uv.lock +1 -1
- {tomlrange-0.2.1 → tomlrange-0.2.2}/.github/dependabot.yml +0 -0
- {tomlrange-0.2.1 → tomlrange-0.2.2}/.github/workflows/cut-release.yml +0 -0
- {tomlrange-0.2.1 → tomlrange-0.2.2}/.github/workflows/publish.yml +0 -0
- {tomlrange-0.2.1 → tomlrange-0.2.2}/.gitignore +0 -0
- {tomlrange-0.2.1 → tomlrange-0.2.2}/.pre-commit-config.yaml +0 -0
- {tomlrange-0.2.1 → tomlrange-0.2.2}/LICENSE +0 -0
- {tomlrange-0.2.1 → tomlrange-0.2.2}/src/tomlrange/error.py +0 -0
- {tomlrange-0.2.1 → tomlrange-0.2.2}/src/tomlrange/paths.py +0 -0
- {tomlrange-0.2.1 → tomlrange-0.2.2}/src/tomlrange/py.typed +0 -0
|
@@ -95,25 +95,14 @@ jobs:
|
|
|
95
95
|
exit 1
|
|
96
96
|
fi
|
|
97
97
|
|
|
98
|
-
python3 -c '
|
|
99
|
-
import os, pathlib, re, sys
|
|
100
|
-
path = pathlib.Path("src/tomlrange/__init__.py")
|
|
101
|
-
text = path.read_text()
|
|
102
|
-
ver = os.environ["NEW_VER"]
|
|
103
|
-
new, n = re.subn(r"__version__ = \"[^\"]+\"", f"__version__ = \"{ver}\"", text, count=1)
|
|
104
|
-
if n != 1:
|
|
105
|
-
sys.exit("did not replace src/tomlrange/__init__.py __version__")
|
|
106
|
-
path.write_text(new)
|
|
107
|
-
'
|
|
108
|
-
|
|
109
98
|
git checkout -b "bump/v${NEW_VER}"
|
|
110
99
|
|
|
111
|
-
git add pyproject.toml
|
|
100
|
+
git add pyproject.toml
|
|
112
101
|
if ! git diff --quiet -- uv.lock; then
|
|
113
102
|
git add uv.lock
|
|
114
103
|
fi
|
|
115
104
|
if git diff --cached --quiet; then
|
|
116
|
-
echo "uv version did not change pyproject.toml
|
|
105
|
+
echo "uv version did not change pyproject.toml or uv.lock" >&2
|
|
117
106
|
exit 1
|
|
118
107
|
fi
|
|
119
108
|
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
pull_request:
|
|
6
|
+
workflow_dispatch:
|
|
7
|
+
|
|
8
|
+
concurrency:
|
|
9
|
+
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
|
10
|
+
cancel-in-progress: true
|
|
11
|
+
|
|
12
|
+
permissions:
|
|
13
|
+
contents: read
|
|
14
|
+
|
|
15
|
+
jobs:
|
|
16
|
+
test:
|
|
17
|
+
runs-on: ubuntu-latest
|
|
18
|
+
steps:
|
|
19
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
|
20
|
+
with:
|
|
21
|
+
persist-credentials: false
|
|
22
|
+
- uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
|
|
23
|
+
- run: uv sync --group dev
|
|
24
|
+
- run: uv run ruff check
|
|
25
|
+
- run: uv run ruff format --check
|
|
26
|
+
- run: uv run pytest
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: tomlrange
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Parse and validate { from, to } tables from decoded TOML.
|
|
5
5
|
Project-URL: Homepage, https://github.com/yyolk/tomlrange
|
|
6
6
|
Project-URL: Repository, https://github.com/yyolk/tomlrange
|
|
@@ -129,6 +129,10 @@ On a list, `overlap=` is `"reject"` (default), `"allow"`, or `"merge"`.
|
|
|
129
129
|
Reject treats a shared endpoint as overlap (`1–4` and `4–6` fail).
|
|
130
130
|
Adjacent integers (`1–4` then `5–8`) are fine; `merge()` will coalesce them.
|
|
131
131
|
|
|
132
|
+
`list()`, `len()`, `width`, and `as_range()` are int-only — they raise
|
|
133
|
+
`TypeError` (`"{name} width is only defined for int"`). Membership,
|
|
134
|
+
`as_tuple()`, and `as_table()` work for any ordered `typ`.
|
|
135
|
+
|
|
132
136
|
Errors carry a path:
|
|
133
137
|
|
|
134
138
|
```
|
|
@@ -142,6 +146,7 @@ months_ranges[1].to: 13 is above month 12
|
|
|
142
146
|
- Not a two-element array (`[1, 12]`). After TOML decode that is a list, not a table.
|
|
143
147
|
- Not wrap-around (`from = 11, to = 2`). Write two spans.
|
|
144
148
|
- Not `step`. A range table is a closed interval.
|
|
149
|
+
- Not iteration over a non-int domain. Walk dates or strings yourself.
|
|
145
150
|
|
|
146
151
|
## Install
|
|
147
152
|
|
|
@@ -89,6 +89,10 @@ On a list, `overlap=` is `"reject"` (default), `"allow"`, or `"merge"`.
|
|
|
89
89
|
Reject treats a shared endpoint as overlap (`1–4` and `4–6` fail).
|
|
90
90
|
Adjacent integers (`1–4` then `5–8`) are fine; `merge()` will coalesce them.
|
|
91
91
|
|
|
92
|
+
`list()`, `len()`, `width`, and `as_range()` are int-only — they raise
|
|
93
|
+
`TypeError` (`"{name} width is only defined for int"`). Membership,
|
|
94
|
+
`as_tuple()`, and `as_table()` work for any ordered `typ`.
|
|
95
|
+
|
|
92
96
|
Errors carry a path:
|
|
93
97
|
|
|
94
98
|
```
|
|
@@ -102,6 +106,7 @@ months_ranges[1].to: 13 is above month 12
|
|
|
102
106
|
- Not a two-element array (`[1, 12]`). After TOML decode that is a list, not a table.
|
|
103
107
|
- Not wrap-around (`from = 11, to = 2`). Write two spans.
|
|
104
108
|
- Not `step`. A range table is a closed interval.
|
|
109
|
+
- Not iteration over a non-int domain. Walk dates or strings yourself.
|
|
105
110
|
|
|
106
111
|
## Install
|
|
107
112
|
|
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
"""Parse and validate `{ from = …, to = … }` tables from decoded TOML."""
|
|
2
2
|
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
3
5
|
from tomlrange.bound import Bound, Bounds
|
|
4
6
|
from tomlrange.domain import Domain, Spec
|
|
5
7
|
from tomlrange.error import TomlRangeError
|
|
6
8
|
|
|
9
|
+
try:
|
|
10
|
+
__version__ = version("tomlrange")
|
|
11
|
+
except PackageNotFoundError:
|
|
12
|
+
__version__ = "unknown"
|
|
13
|
+
|
|
7
14
|
__all__ = [
|
|
8
15
|
"Bound",
|
|
9
16
|
"Bounds",
|
|
@@ -11,4 +18,3 @@ __all__ = [
|
|
|
11
18
|
"Spec",
|
|
12
19
|
"TomlRangeError",
|
|
13
20
|
]
|
|
14
|
-
__version__ = "0.2.1"
|
|
@@ -10,10 +10,15 @@ if TYPE_CHECKING:
|
|
|
10
10
|
from tomlrange.paths import Overlap
|
|
11
11
|
|
|
12
12
|
|
|
13
|
-
def _as_table(raw: Any, *, path: str) -> Mapping[str, Any]:
|
|
13
|
+
def _as_table(raw: Any, *, path: str, keys: tuple[str, str]) -> Mapping[str, Any]:
|
|
14
14
|
if isinstance(raw, Mapping):
|
|
15
15
|
return raw
|
|
16
|
-
|
|
16
|
+
start_key, stop_key = keys
|
|
17
|
+
raise TomlRangeError(
|
|
18
|
+
path,
|
|
19
|
+
f"expected a table {{ {start_key} = …, {stop_key} = … }}",
|
|
20
|
+
raw,
|
|
21
|
+
)
|
|
17
22
|
|
|
18
23
|
|
|
19
24
|
@dataclass(frozen=True, slots=True)
|
|
@@ -26,8 +31,8 @@ class Bound[T]:
|
|
|
26
31
|
|
|
27
32
|
@classmethod
|
|
28
33
|
def parse(cls, raw: Any, domain: Domain[T], *, path: str = ".") -> Bound[T]:
|
|
29
|
-
table = _as_table(raw, path=path)
|
|
30
34
|
start_key, stop_key = domain.keys
|
|
35
|
+
table = _as_table(raw, path=path, keys=domain.keys)
|
|
31
36
|
extra = set(table) - {start_key, stop_key}
|
|
32
37
|
if extra:
|
|
33
38
|
raise TomlRangeError(
|
|
@@ -51,16 +56,18 @@ class Bound[T]:
|
|
|
51
56
|
)
|
|
52
57
|
return cls(start, stop, domain)
|
|
53
58
|
|
|
54
|
-
|
|
55
|
-
def width(self) -> int:
|
|
59
|
+
def _require_int(self) -> None:
|
|
56
60
|
if type(self.start) is not int or type(self.stop) is not int:
|
|
57
61
|
raise TypeError(f"{self.domain.name} width is only defined for int")
|
|
58
|
-
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def width(self) -> int:
|
|
65
|
+
self._require_int()
|
|
66
|
+
return self.stop - self.start + 1 # type: ignore[operator]
|
|
59
67
|
|
|
60
68
|
def as_range(self) -> range:
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
return range(self.start, self.stop + 1)
|
|
69
|
+
self._require_int()
|
|
70
|
+
return range(self.start, self.stop + 1) # type: ignore[arg-type]
|
|
64
71
|
|
|
65
72
|
def as_tuple(self) -> tuple[T, T]:
|
|
66
73
|
return (self.start, self.stop)
|
|
@@ -75,9 +82,11 @@ class Bound[T]:
|
|
|
75
82
|
return self.start <= item <= self.stop # type: ignore[operator]
|
|
76
83
|
|
|
77
84
|
def __iter__(self) -> Iterator[T]:
|
|
85
|
+
self._require_int()
|
|
78
86
|
yield from self.as_range() # type: ignore[misc]
|
|
79
87
|
|
|
80
88
|
def __len__(self) -> int:
|
|
89
|
+
self._require_int()
|
|
81
90
|
return self.width
|
|
82
91
|
|
|
83
92
|
def overlaps(self, other: Bound[T]) -> bool:
|
|
@@ -148,6 +157,10 @@ class Bounds[T]:
|
|
|
148
157
|
def merge(self) -> Bounds[T]:
|
|
149
158
|
return Bounds(_coalesce(self.spans, self.domain), self.domain)
|
|
150
159
|
|
|
160
|
+
def _require_int(self) -> None:
|
|
161
|
+
if self.domain.typ is not int:
|
|
162
|
+
raise TypeError(f"{self.domain.name} width is only defined for int")
|
|
163
|
+
|
|
151
164
|
def covers(self, item: object) -> bool:
|
|
152
165
|
return any(item in span for span in self.spans)
|
|
153
166
|
|
|
@@ -155,6 +168,7 @@ class Bounds[T]:
|
|
|
155
168
|
return self.covers(item)
|
|
156
169
|
|
|
157
170
|
def __iter__(self) -> Iterator[T]:
|
|
171
|
+
self._require_int()
|
|
158
172
|
seen: set[T] = set()
|
|
159
173
|
for span in self.spans:
|
|
160
174
|
for item in span:
|
|
@@ -164,6 +178,7 @@ class Bounds[T]:
|
|
|
164
178
|
yield item
|
|
165
179
|
|
|
166
180
|
def __len__(self) -> int:
|
|
181
|
+
self._require_int()
|
|
167
182
|
return sum(1 for _ in self)
|
|
168
183
|
|
|
169
184
|
def __repr__(self) -> str:
|
|
@@ -65,7 +65,7 @@ class Domain[T]:
|
|
|
65
65
|
return raw
|
|
66
66
|
|
|
67
67
|
def bound(self, raw: Any, *, path: str = ".") -> Bound[T]:
|
|
68
|
-
from tomlrange.bound import Bound
|
|
68
|
+
from tomlrange.bound import Bound # cycle: Domain ↔ Bound
|
|
69
69
|
|
|
70
70
|
return Bound.parse(raw, self, path=path)
|
|
71
71
|
|
|
@@ -76,12 +76,12 @@ class Domain[T]:
|
|
|
76
76
|
path: str = ".",
|
|
77
77
|
overlap: Overlap = "reject",
|
|
78
78
|
) -> Bounds[T]:
|
|
79
|
-
from tomlrange.bound import Bounds
|
|
79
|
+
from tomlrange.bound import Bounds # cycle: Domain ↔ Bound
|
|
80
80
|
|
|
81
81
|
return Bounds.parse(raw, self, path=path, overlap=overlap)
|
|
82
82
|
|
|
83
83
|
def full(self) -> Bound[T]:
|
|
84
|
-
from tomlrange.bound import Bound
|
|
84
|
+
from tomlrange.bound import Bound # cycle: Domain ↔ Bound
|
|
85
85
|
|
|
86
86
|
if self.lo is None or self.hi is None:
|
|
87
87
|
raise TomlRangeError(".", f"{self.name} domain has no closed lo/hi")
|
|
@@ -130,7 +130,7 @@ class Spec:
|
|
|
130
130
|
|
|
131
131
|
@classmethod
|
|
132
132
|
def parse(cls, raw: Any, *, path: str = ".") -> Bound[Any]:
|
|
133
|
-
from tomlrange.bound import Bound
|
|
133
|
+
from tomlrange.bound import Bound # cycle: Domain ↔ Bound
|
|
134
134
|
|
|
135
135
|
return Bound.parse(raw, cls.domain, path=path)
|
|
136
136
|
|
|
@@ -142,7 +142,7 @@ class Spec:
|
|
|
142
142
|
path: str = ".",
|
|
143
143
|
overlap: Overlap | None = None,
|
|
144
144
|
) -> Bounds[Any]:
|
|
145
|
-
from tomlrange.bound import Bounds
|
|
145
|
+
from tomlrange.bound import Bounds # cycle: Domain ↔ Bound
|
|
146
146
|
|
|
147
147
|
return Bounds.parse(
|
|
148
148
|
raw,
|
|
@@ -122,6 +122,31 @@ def test_custom_keys() -> None:
|
|
|
122
122
|
hours.bound({"from": 7, "to": 16})
|
|
123
123
|
with pytest.raises(TomlRangeError, match="must have keys"):
|
|
124
124
|
hours.bound({"start": 7})
|
|
125
|
+
with pytest.raises(
|
|
126
|
+
TomlRangeError, match=r"expected a table \{ start = …, end = … \}"
|
|
127
|
+
):
|
|
128
|
+
hours.bound([7, 16])
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_int_only_iteration() -> None:
|
|
132
|
+
labels = Domain(str, name="label")
|
|
133
|
+
bound = labels.bound({"from": "a", "to": "c"})
|
|
134
|
+
assert bound.as_tuple() == ("a", "c")
|
|
135
|
+
assert "b" in bound
|
|
136
|
+
with pytest.raises(TypeError, match="label width is only defined for int"):
|
|
137
|
+
list(bound)
|
|
138
|
+
with pytest.raises(TypeError, match="label width is only defined for int"):
|
|
139
|
+
len(bound)
|
|
140
|
+
with pytest.raises(TypeError, match="label width is only defined for int"):
|
|
141
|
+
_ = bound.width
|
|
142
|
+
with pytest.raises(TypeError, match="label width is only defined for int"):
|
|
143
|
+
bound.as_range()
|
|
144
|
+
spans = labels.bounds([{"from": "a", "to": "b"}, {"from": "d", "to": "e"}])
|
|
145
|
+
assert "a" in spans
|
|
146
|
+
with pytest.raises(TypeError, match="label width is only defined for int"):
|
|
147
|
+
list(spans)
|
|
148
|
+
with pytest.raises(TypeError, match="label width is only defined for int"):
|
|
149
|
+
len(spans)
|
|
125
150
|
|
|
126
151
|
|
|
127
152
|
def test_error_paths() -> None:
|
|
@@ -131,3 +156,80 @@ def test_error_paths() -> None:
|
|
|
131
156
|
path="months_ranges",
|
|
132
157
|
)
|
|
133
158
|
assert exc.value.path == "months_ranges[1].to"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def test_open_int_domain_parse() -> None:
|
|
162
|
+
open_int = Domain(int, name="n")
|
|
163
|
+
bound = open_int.bound({"from": -3, "to": 99})
|
|
164
|
+
assert bound.as_tuple() == (-3, 99)
|
|
165
|
+
assert -3 in bound
|
|
166
|
+
assert 99 in bound
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_full_requires_closed_lo_hi() -> None:
|
|
170
|
+
open_int = Domain(int, name="n")
|
|
171
|
+
with pytest.raises(TomlRangeError, match="n domain has no closed lo/hi"):
|
|
172
|
+
open_int.full()
|
|
173
|
+
half = Domain(int, lo=1, name="n")
|
|
174
|
+
with pytest.raises(TomlRangeError, match="n domain has no closed lo/hi"):
|
|
175
|
+
half.full()
|
|
176
|
+
|
|
177
|
+
class Open(Spec):
|
|
178
|
+
typ = int
|
|
179
|
+
name = "open"
|
|
180
|
+
|
|
181
|
+
with pytest.raises(TomlRangeError, match="open domain has no closed lo/hi"):
|
|
182
|
+
Open.full()
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def test_bounds_empty_list() -> None:
|
|
186
|
+
empty = MONTH.bounds([])
|
|
187
|
+
assert empty.spans == ()
|
|
188
|
+
assert list(empty) == []
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def test_unknown_overlap_policy() -> None:
|
|
192
|
+
with pytest.raises(ValueError, match="unknown overlap policy 'bogus'"):
|
|
193
|
+
MONTH.bounds([{"from": 1, "to": 2}], overlap="bogus")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def test_float_domain_parse() -> None:
|
|
197
|
+
ratio = Domain(float, lo=0.0, hi=1.0, name="ratio")
|
|
198
|
+
bound = ratio.bound({"from": 0.25, "to": 0.75})
|
|
199
|
+
assert bound.as_tuple() == (0.25, 0.75)
|
|
200
|
+
assert 0.5 in bound
|
|
201
|
+
assert bound.as_table() == {"from": 0.25, "to": 0.75}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def test_spec_name_suffix_stripping() -> None:
|
|
205
|
+
class MonthBounds(Spec):
|
|
206
|
+
typ = int
|
|
207
|
+
lo = 1
|
|
208
|
+
hi = 12
|
|
209
|
+
|
|
210
|
+
class MonthRange(Spec):
|
|
211
|
+
typ = int
|
|
212
|
+
lo = 1
|
|
213
|
+
hi = 12
|
|
214
|
+
|
|
215
|
+
class MonthSpec(Spec):
|
|
216
|
+
typ = int
|
|
217
|
+
lo = 1
|
|
218
|
+
hi = 12
|
|
219
|
+
|
|
220
|
+
assert MonthBounds.domain.name == "month"
|
|
221
|
+
assert MonthRange.domain.name == "month"
|
|
222
|
+
assert MonthSpec.domain.name == "month"
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def test_spec_overlap_class_default() -> None:
|
|
226
|
+
class AllowingMonth(Spec):
|
|
227
|
+
typ = int
|
|
228
|
+
lo = 1
|
|
229
|
+
hi = 12
|
|
230
|
+
overlap = "allow"
|
|
231
|
+
|
|
232
|
+
raw = [{"from": 1, "to": 4}, {"from": 4, "to": 6}]
|
|
233
|
+
allowed = AllowingMonth.parse_many(raw)
|
|
234
|
+
assert list(allowed) == [1, 2, 3, 4, 5, 6]
|
|
235
|
+
assert len(allowed.spans) == 2
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|