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.
@@ -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 src/tomlrange/__init__.py
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, __init__.py, or uv.lock" >&2
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.1
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,6 +1,6 @@
1
1
  [project]
2
2
  name = "tomlrange"
3
- version = "0.2.1"
3
+ version = "0.2.2"
4
4
  description = "Parse and validate { from, to } tables from decoded TOML."
5
5
  readme = "README.md"
6
6
  license = { file = "LICENSE" }
@@ -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
- raise TomlRangeError(path, "expected a table { from = …, to = … }", raw)
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
- @property
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
- return self.stop - self.start + 1
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
- if type(self.start) is not int or type(self.stop) is not int:
62
- raise TypeError(f"{self.domain.name} as_range() is only defined for int")
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
@@ -198,7 +198,7 @@ wheels = [
198
198
 
199
199
  [[package]]
200
200
  name = "tomlrange"
201
- version = "0.2.1"
201
+ version = "0.2.2"
202
202
  source = { editable = "." }
203
203
 
204
204
  [package.dev-dependencies]
File without changes
File without changes