tomlrange 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,121 @@
1
+ name: Publish
2
+ run-name: Publish ${{ github.event_name == 'workflow_dispatch' && inputs.target || (github.event.release.prerelease && 'testpypi' || 'testpypi+pypi') }} ${{ inputs.release_tag || github.ref_name }}
3
+
4
+ on:
5
+ release:
6
+ types: [published]
7
+ workflow_dispatch:
8
+ inputs:
9
+ target:
10
+ description: "Publish target"
11
+ required: true
12
+ type: choice
13
+ options:
14
+ - testpypi
15
+ - pypi
16
+ release_tag:
17
+ description: "Release tag to attach dist files (empty = skip attach)"
18
+ required: false
19
+ type: string
20
+
21
+ permissions:
22
+ contents: read
23
+
24
+ jobs:
25
+ build:
26
+ runs-on: ubuntu-latest
27
+ steps:
28
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
29
+ with:
30
+ persist-credentials: false
31
+ - uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
32
+ with:
33
+ enable-cache: false
34
+ - run: uv build
35
+ - name: Tag matches pyproject version
36
+ if: github.event_name == 'release'
37
+ run: |
38
+ tag="${GITHUB_REF_NAME#v}"
39
+ ver="$(uv run python -c 'import tomllib, pathlib; print(tomllib.loads(pathlib.Path("pyproject.toml").read_text())["project"]["version"])')"
40
+ test "$tag" = "$ver"
41
+ - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
42
+ with:
43
+ name: dist
44
+ path: dist/
45
+
46
+ pypi:
47
+ needs: build
48
+ if: >-
49
+ (github.event_name == 'release' && github.event.release.prerelease == false)
50
+ || (github.event_name == 'workflow_dispatch' && inputs.target == 'pypi')
51
+ runs-on: ubuntu-latest
52
+ environment:
53
+ name: pypi
54
+ url: https://pypi.org/p/tomlrange
55
+ permissions:
56
+ id-token: write
57
+ steps:
58
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
59
+ with:
60
+ persist-credentials: false
61
+ - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
62
+ with:
63
+ name: dist
64
+ path: dist
65
+ - uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
66
+ with:
67
+ enable-cache: false
68
+ - run: uv publish --index pypi --trusted-publishing always
69
+
70
+ testpypi:
71
+ needs: build
72
+ if: >-
73
+ github.event_name == 'release'
74
+ || (github.event_name == 'workflow_dispatch' && inputs.target == 'testpypi')
75
+ runs-on: ubuntu-latest
76
+ environment:
77
+ name: testpypi
78
+ url: https://test.pypi.org/p/tomlrange
79
+ permissions:
80
+ id-token: write
81
+ steps:
82
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
83
+ with:
84
+ persist-credentials: false
85
+ - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
86
+ with:
87
+ name: dist
88
+ path: dist
89
+ - uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
90
+ with:
91
+ enable-cache: false
92
+ - run: uv publish --index testpypi --trusted-publishing always
93
+
94
+ attach:
95
+ needs: build
96
+ if: >-
97
+ github.event_name == 'release'
98
+ || (github.event_name == 'workflow_dispatch' && inputs.release_tag != '')
99
+ runs-on: ubuntu-latest
100
+ permissions:
101
+ contents: write
102
+ steps:
103
+ - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
104
+ with:
105
+ name: dist
106
+ path: dist
107
+ - name: Attach missing dist files
108
+ env:
109
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
110
+ GH_REPO: ${{ github.repository }}
111
+ RELEASE_TAG: ${{ github.event_name == 'release' && github.ref_name || inputs.release_tag }}
112
+ run: |
113
+ existing="$(gh release view "$RELEASE_TAG" --json assets --jq '.assets[].name')"
114
+ for f in dist/*; do
115
+ name="${f##*/}"
116
+ if printf '%s\n' "$existing" | grep -Fxq "$name"; then
117
+ echo "skip $name (already on release)"
118
+ continue
119
+ fi
120
+ err="$(gh release upload "$RELEASE_TAG" "$f" 2>&1)" || { printf '%s\n' "$err"; echo "$err" | grep -qi 'already exists'; }
121
+ done
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .pytest_cache/
4
+ .ruff_cache/
5
+ dist/
6
+ *.egg-info/
7
+ .venv/
8
+ venv/
@@ -0,0 +1,20 @@
1
+ # Hook rev should stay in step with the ruff pin in [dependency-groups] dev.
2
+ repos:
3
+ - repo: https://github.com/pre-commit/pre-commit-hooks
4
+ rev: v6.0.0
5
+ hooks:
6
+ - id: trailing-whitespace
7
+ - id: end-of-file-fixer
8
+ - id: check-yaml
9
+ - id: check-toml
10
+ - id: check-merge-conflict
11
+ - id: check-added-large-files
12
+ - id: debug-statements
13
+ - id: mixed-line-ending
14
+ args: [--fix=lf]
15
+ - repo: https://github.com/astral-sh/ruff-pre-commit
16
+ rev: v0.16.8
17
+ hooks:
18
+ - id: ruff-check
19
+ args: [--fix]
20
+ - id: ruff-format
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Joseph Chiocchi
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.
@@ -0,0 +1,152 @@
1
+ Metadata-Version: 2.5
2
+ Name: tomlrange
3
+ Version: 0.1.0
4
+ Summary: Parse and validate { from, to } tables from decoded TOML.
5
+ Project-URL: Homepage, https://github.com/yyolk/tomlrange
6
+ Project-URL: Repository, https://github.com/yyolk/tomlrange
7
+ Project-URL: Issues, https://github.com/yyolk/tomlrange/issues
8
+ Author-email: Joseph Chiocchi <joe@yolk.cc>
9
+ License: MIT License
10
+
11
+ Copyright (c) 2026 Joseph Chiocchi
12
+
13
+ Permission is hereby granted, free of charge, to any person obtaining a copy
14
+ of this software and associated documentation files (the "Software"), to deal
15
+ in the Software without restriction, including without limitation the rights
16
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17
+ copies of the Software, and to permit persons to whom the Software is
18
+ furnished to do so, subject to the following conditions:
19
+
20
+ The above copyright notice and this permission notice shall be included in all
21
+ copies or substantial portions of the Software.
22
+
23
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29
+ SOFTWARE.
30
+ License-File: LICENSE
31
+ Keywords: config,range,toml,validation
32
+ Classifier: Development Status :: 3 - Alpha
33
+ Classifier: License :: OSI Approved :: MIT License
34
+ Classifier: Programming Language :: Python
35
+ Classifier: Programming Language :: Python :: 3
36
+ Classifier: Programming Language :: Python :: 3.12
37
+ Classifier: Programming Language :: Python :: 3.13
38
+ Classifier: Programming Language :: Python :: 3.14
39
+ Classifier: Topic :: Software Development :: Libraries
40
+ Requires-Python: >=3.12
41
+ Description-Content-Type: text/markdown
42
+
43
+ # tomlrange
44
+
45
+ Parse and validate `{ from = …, to = … }` tables. Not a TOML parser —
46
+ feed it the dicts `tomllib` already produced.
47
+
48
+ The four spellings below are alternatives, not one document — a
49
+ `[table]` header owns every following key until the next header.
50
+ After `tomllib`, they collapse to two Python values. `Bound` accepts
51
+ a single table. `Bounds` accepts a table or a list of tables.
52
+
53
+ ```toml
54
+ # one table
55
+ months = { from = 1, to = 12 }
56
+
57
+ [months]
58
+ from = 1
59
+ to = 12
60
+
61
+ # many tables
62
+ windows = [
63
+ { from = 1, to = 4 },
64
+ { from = 6, to = 10 },
65
+ ]
66
+
67
+ [[windows]]
68
+ from = 1
69
+ to = 4
70
+
71
+ [[windows]]
72
+ from = 6
73
+ to = 10
74
+ ```
75
+
76
+ ```python
77
+ import tomllib
78
+ from tomlrange import Domain
79
+
80
+ Month = Domain(int, lo=1, hi=12, name="month")
81
+
82
+ data = tomllib.loads(config)
83
+ year = Month.bound(data["months"]) # 1..12
84
+ windows = Month.bounds(data["windows"]) # 1..4 and 6..10
85
+
86
+ assert list(year) == list(range(1, 13))
87
+ assert 5 not in windows
88
+ ```
89
+
90
+ No runtime dependencies. Endpoints stay the type `tomllib` gave you.
91
+
92
+ ## Domain as schema
93
+
94
+ A `Domain` is the constraint object. `Bound` / `Bounds` are the values.
95
+
96
+ ```python
97
+ from tomlrange import Domain
98
+
99
+ Hour = Domain(int, lo=0, hi=23, name="hour")
100
+ Hour.bound({"from": 7, "to": 16})
101
+ Hour.full() # requires lo and hi
102
+ ```
103
+
104
+ Or name the domain by subclassing `Spec` — the class body is the schema:
105
+
106
+ ```python
107
+ from tomlrange import Spec
108
+
109
+ class Month(Spec):
110
+ typ = int
111
+ lo = 1
112
+ hi = 12
113
+
114
+ Month.parse({"from": 1, "to": 4})
115
+ Month.parse_many([{ "from": 1, "to": 4 }, { "from": 6, "to": 10 }])
116
+ ```
117
+
118
+ ## Validation
119
+
120
+ On one table:
121
+
122
+ - value is a mapping
123
+ - keys are exactly `from` and `to` (override with `Domain(..., keys=("start", "end"))`)
124
+ - each endpoint is `type(raw) is domain.typ` (so `1.0` and `True` are not `int`)
125
+ - endpoints sit inside `lo` / `hi` when those are set
126
+ - `from <= to` (a singleton is `{ from = 3, to = 3 }`)
127
+
128
+ On a list, `overlap=` is `"reject"` (default), `"allow"`, or `"merge"`.
129
+ Reject treats a shared endpoint as overlap (`1–4` and `4–6` fail).
130
+ Adjacent integers (`1–4` then `5–8`) are fine; `merge()` will coalesce them.
131
+
132
+ Errors carry a path:
133
+
134
+ ```
135
+ months_ranges[1].to: 13 is above month 12
136
+ ```
137
+
138
+ ## What this is not
139
+
140
+ - Not a file loader. Call `tomllib` yourself.
141
+ - Not string ranges (`"1-12"`, `"Jan–Apr"`).
142
+ - Not a two-element array (`[1, 12]`). After TOML decode that is a list, not a table.
143
+ - Not wrap-around (`from = 11, to = 2`). Write two spans.
144
+ - Not `step`. A range table is a closed interval.
145
+
146
+ ## Install
147
+
148
+ ```
149
+ pip install tomlrange
150
+ ```
151
+
152
+ Python 3.12+.
@@ -0,0 +1,110 @@
1
+ # tomlrange
2
+
3
+ Parse and validate `{ from = …, to = … }` tables. Not a TOML parser —
4
+ feed it the dicts `tomllib` already produced.
5
+
6
+ The four spellings below are alternatives, not one document — a
7
+ `[table]` header owns every following key until the next header.
8
+ After `tomllib`, they collapse to two Python values. `Bound` accepts
9
+ a single table. `Bounds` accepts a table or a list of tables.
10
+
11
+ ```toml
12
+ # one table
13
+ months = { from = 1, to = 12 }
14
+
15
+ [months]
16
+ from = 1
17
+ to = 12
18
+
19
+ # many tables
20
+ windows = [
21
+ { from = 1, to = 4 },
22
+ { from = 6, to = 10 },
23
+ ]
24
+
25
+ [[windows]]
26
+ from = 1
27
+ to = 4
28
+
29
+ [[windows]]
30
+ from = 6
31
+ to = 10
32
+ ```
33
+
34
+ ```python
35
+ import tomllib
36
+ from tomlrange import Domain
37
+
38
+ Month = Domain(int, lo=1, hi=12, name="month")
39
+
40
+ data = tomllib.loads(config)
41
+ year = Month.bound(data["months"]) # 1..12
42
+ windows = Month.bounds(data["windows"]) # 1..4 and 6..10
43
+
44
+ assert list(year) == list(range(1, 13))
45
+ assert 5 not in windows
46
+ ```
47
+
48
+ No runtime dependencies. Endpoints stay the type `tomllib` gave you.
49
+
50
+ ## Domain as schema
51
+
52
+ A `Domain` is the constraint object. `Bound` / `Bounds` are the values.
53
+
54
+ ```python
55
+ from tomlrange import Domain
56
+
57
+ Hour = Domain(int, lo=0, hi=23, name="hour")
58
+ Hour.bound({"from": 7, "to": 16})
59
+ Hour.full() # requires lo and hi
60
+ ```
61
+
62
+ Or name the domain by subclassing `Spec` — the class body is the schema:
63
+
64
+ ```python
65
+ from tomlrange import Spec
66
+
67
+ class Month(Spec):
68
+ typ = int
69
+ lo = 1
70
+ hi = 12
71
+
72
+ Month.parse({"from": 1, "to": 4})
73
+ Month.parse_many([{ "from": 1, "to": 4 }, { "from": 6, "to": 10 }])
74
+ ```
75
+
76
+ ## Validation
77
+
78
+ On one table:
79
+
80
+ - value is a mapping
81
+ - keys are exactly `from` and `to` (override with `Domain(..., keys=("start", "end"))`)
82
+ - each endpoint is `type(raw) is domain.typ` (so `1.0` and `True` are not `int`)
83
+ - endpoints sit inside `lo` / `hi` when those are set
84
+ - `from <= to` (a singleton is `{ from = 3, to = 3 }`)
85
+
86
+ On a list, `overlap=` is `"reject"` (default), `"allow"`, or `"merge"`.
87
+ Reject treats a shared endpoint as overlap (`1–4` and `4–6` fail).
88
+ Adjacent integers (`1–4` then `5–8`) are fine; `merge()` will coalesce them.
89
+
90
+ Errors carry a path:
91
+
92
+ ```
93
+ months_ranges[1].to: 13 is above month 12
94
+ ```
95
+
96
+ ## What this is not
97
+
98
+ - Not a file loader. Call `tomllib` yourself.
99
+ - Not string ranges (`"1-12"`, `"Jan–Apr"`).
100
+ - Not a two-element array (`[1, 12]`). After TOML decode that is a list, not a table.
101
+ - Not wrap-around (`from = 11, to = 2`). Write two spans.
102
+ - Not `step`. A range table is a closed interval.
103
+
104
+ ## Install
105
+
106
+ ```
107
+ pip install tomlrange
108
+ ```
109
+
110
+ Python 3.12+.
@@ -0,0 +1,78 @@
1
+ [project]
2
+ name = "tomlrange"
3
+ version = "0.1.0"
4
+ description = "Parse and validate { from, to } tables from decoded TOML."
5
+ readme = "README.md"
6
+ license = { file = "LICENSE" }
7
+ requires-python = ">=3.12"
8
+ authors = [
9
+ { name = "Joseph Chiocchi", email = "joe@yolk.cc" },
10
+ ]
11
+ keywords = ["toml", "range", "validation", "config"]
12
+ classifiers = [
13
+ "Development Status :: 3 - Alpha",
14
+ "License :: OSI Approved :: MIT License",
15
+ "Programming Language :: Python",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3.12",
18
+ "Programming Language :: Python :: 3.13",
19
+ "Programming Language :: Python :: 3.14",
20
+ "Topic :: Software Development :: Libraries",
21
+ ]
22
+ dependencies = []
23
+
24
+ [project.urls]
25
+ Homepage = "https://github.com/yyolk/tomlrange"
26
+ Repository = "https://github.com/yyolk/tomlrange"
27
+ Issues = "https://github.com/yyolk/tomlrange/issues"
28
+
29
+ [build-system]
30
+ requires = ["hatchling"]
31
+ build-backend = "hatchling.build"
32
+
33
+ [tool.hatch.build.targets.wheel]
34
+ packages = ["src/tomlrange"]
35
+
36
+ [tool.uv]
37
+ package = true
38
+
39
+ [[tool.uv.index]]
40
+ name = "pypi"
41
+ url = "https://pypi.org/simple/"
42
+ publish-url = "https://upload.pypi.org/legacy/"
43
+
44
+ [[tool.uv.index]]
45
+ name = "testpypi"
46
+ url = "https://test.pypi.org/simple/"
47
+ publish-url = "https://test.pypi.org/legacy/"
48
+ explicit = true
49
+
50
+ [dependency-groups]
51
+ dev = [
52
+ "pytest>=8.0",
53
+ "ruff>=0.16.8",
54
+ ]
55
+
56
+ [tool.pytest.ini_options]
57
+ testpaths = ["tests"]
58
+ pythonpath = ["src"]
59
+
60
+ [tool.ruff]
61
+ target-version = "py312"
62
+ src = ["src"]
63
+
64
+ [tool.ruff.lint]
65
+ select = [
66
+ "E",
67
+ "W",
68
+ "F",
69
+ "I",
70
+ "UP",
71
+ "B",
72
+ "C4",
73
+ "PT",
74
+ "RUF",
75
+ "SIM",
76
+ "TC",
77
+ ]
78
+ ignore = ["E501"]
@@ -0,0 +1,14 @@
1
+ """Parse and validate `{ from = …, to = … }` tables from decoded TOML."""
2
+
3
+ from tomlrange.bound import Bound, Bounds
4
+ from tomlrange.domain import Domain, Spec
5
+ from tomlrange.error import TomlRangeError
6
+
7
+ __all__ = [
8
+ "Bound",
9
+ "Bounds",
10
+ "Domain",
11
+ "Spec",
12
+ "TomlRangeError",
13
+ ]
14
+ __version__ = "0.1.0"
@@ -0,0 +1,187 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterator, Mapping
4
+ from dataclasses import dataclass
5
+ from typing import Any, Generic, TypeVar
6
+
7
+ from tomlrange.domain import Domain
8
+ from tomlrange.error import TomlRangeError
9
+ from tomlrange.paths import Overlap, index_path, join_path
10
+
11
+ T = TypeVar("T")
12
+
13
+
14
+ def _as_table(raw: Any, *, path: str) -> Mapping[str, Any]:
15
+ if isinstance(raw, Mapping):
16
+ return raw
17
+ raise TomlRangeError(path, "expected a table { from = …, to = … }", raw)
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class Bound(Generic[T]):
22
+ """One inclusive interval parsed from a `{ from, to }` table."""
23
+
24
+ start: T
25
+ stop: T
26
+ domain: Domain[T]
27
+
28
+ @classmethod
29
+ def parse(cls, raw: Any, domain: Domain[T], *, path: str = ".") -> Bound[T]:
30
+ table = _as_table(raw, path=path)
31
+ start_key, stop_key = domain.keys
32
+ extra = set(table) - {start_key, stop_key}
33
+ if extra:
34
+ raise TomlRangeError(
35
+ path,
36
+ f"unknown keys {sorted(extra)}",
37
+ raw,
38
+ )
39
+ if start_key not in table or stop_key not in table:
40
+ raise TomlRangeError(
41
+ path,
42
+ f"table must have keys {start_key!r} and {stop_key!r}",
43
+ raw,
44
+ )
45
+ start = domain.convert(table[start_key], path=join_path(path, start_key))
46
+ stop = domain.convert(table[stop_key], path=join_path(path, stop_key))
47
+ if start > stop: # type: ignore[operator]
48
+ raise TomlRangeError(
49
+ path,
50
+ f"{start_key} ({start}) is after {stop_key} ({stop})",
51
+ raw,
52
+ )
53
+ return cls(start, stop, domain)
54
+
55
+ @property
56
+ def width(self) -> int:
57
+ if type(self.start) is not int or type(self.stop) is not int:
58
+ raise TypeError(f"{self.domain.name} width is only defined for int")
59
+ return self.stop - self.start + 1
60
+
61
+ def as_range(self) -> range:
62
+ if type(self.start) is not int or type(self.stop) is not int:
63
+ raise TypeError(f"{self.domain.name} as_range() is only defined for int")
64
+ return range(self.start, self.stop + 1)
65
+
66
+ def as_tuple(self) -> tuple[T, T]:
67
+ return (self.start, self.stop)
68
+
69
+ def as_table(self) -> dict[str, T]:
70
+ start_key, stop_key = self.domain.keys
71
+ return {start_key: self.start, stop_key: self.stop}
72
+
73
+ def __contains__(self, item: object) -> bool:
74
+ if type(item) is not self.domain.typ:
75
+ return False
76
+ return self.start <= item <= self.stop # type: ignore[operator]
77
+
78
+ def __iter__(self) -> Iterator[T]:
79
+ yield from self.as_range() # type: ignore[misc]
80
+
81
+ def __len__(self) -> int:
82
+ return self.width
83
+
84
+ def overlaps(self, other: Bound[T]) -> bool:
85
+ return self.start <= other.stop and other.start <= self.stop # type: ignore[operator]
86
+
87
+ def adjacent_to(self, other: Bound[T]) -> bool:
88
+ if self.domain.typ is not int:
89
+ return False
90
+ return self.stop + 1 == other.start or other.stop + 1 == self.start # type: ignore[operator, return-value]
91
+
92
+ def __repr__(self) -> str:
93
+ return f"Bound({self.start!r}, {self.stop!r}, {self.domain.name})"
94
+
95
+
96
+ @dataclass(frozen=True, slots=True)
97
+ class Bounds(Generic[T]):
98
+ """One table or an array of `{ from, to }` tables."""
99
+
100
+ spans: tuple[Bound[T], ...]
101
+ domain: Domain[T]
102
+
103
+ @classmethod
104
+ def parse(
105
+ cls,
106
+ raw: Any,
107
+ domain: Domain[T],
108
+ *,
109
+ path: str = ".",
110
+ overlap: Overlap = "reject",
111
+ ) -> Bounds[T]:
112
+ if isinstance(raw, Mapping):
113
+ return cls((Bound.parse(raw, domain, path=path),), domain)
114
+ if isinstance(raw, list):
115
+ spans = tuple(
116
+ Bound.parse(item, domain, path=index_path(path, i))
117
+ for i, item in enumerate(raw)
118
+ )
119
+ return cls._apply_overlap(spans, domain, path=path, overlap=overlap)
120
+ raise TomlRangeError(path, "expected a table or an array of tables", raw)
121
+
122
+ @classmethod
123
+ def _apply_overlap(
124
+ cls,
125
+ spans: tuple[Bound[T], ...],
126
+ domain: Domain[T],
127
+ *,
128
+ path: str,
129
+ overlap: Overlap,
130
+ ) -> Bounds[T]:
131
+ if overlap == "allow":
132
+ return cls(spans, domain)
133
+ if overlap == "merge":
134
+ return cls(_coalesce(spans, domain), domain)
135
+ if overlap != "reject":
136
+ raise ValueError(f"unknown overlap policy {overlap!r}")
137
+ for i, left in enumerate(spans):
138
+ for j, right in enumerate(spans):
139
+ if j <= i:
140
+ continue
141
+ if left.overlaps(right):
142
+ raise TomlRangeError(
143
+ path,
144
+ f"{index_path(path, i)} overlaps {index_path(path, j)}",
145
+ (left.as_tuple(), right.as_tuple()),
146
+ )
147
+ return cls(spans, domain)
148
+
149
+ def merge(self) -> Bounds[T]:
150
+ return Bounds(_coalesce(self.spans, self.domain), self.domain)
151
+
152
+ def covers(self, item: object) -> bool:
153
+ return any(item in span for span in self.spans)
154
+
155
+ def __contains__(self, item: object) -> bool:
156
+ return self.covers(item)
157
+
158
+ def __iter__(self) -> Iterator[T]:
159
+ seen: set[T] = set()
160
+ for span in self.spans:
161
+ for item in span:
162
+ if item in seen:
163
+ continue
164
+ seen.add(item)
165
+ yield item
166
+
167
+ def __len__(self) -> int:
168
+ return sum(1 for _ in self)
169
+
170
+ def __repr__(self) -> str:
171
+ inner = ", ".join(f"{s.start!r}..{s.stop!r}" for s in self.spans)
172
+ return f"Bounds({inner}, {self.domain.name})"
173
+
174
+
175
+ def _coalesce(spans: tuple[Bound[T], ...], domain: Domain[T]) -> tuple[Bound[T], ...]:
176
+ if not spans:
177
+ return ()
178
+ ordered = sorted(spans, key=lambda s: (s.start, s.stop))
179
+ out = [ordered[0]]
180
+ for cur in ordered[1:]:
181
+ prev = out[-1]
182
+ if prev.overlaps(cur) or prev.adjacent_to(cur):
183
+ stop = prev.stop if prev.stop >= cur.stop else cur.stop # type: ignore[operator]
184
+ out[-1] = Bound(prev.start, stop, domain)
185
+ else:
186
+ out.append(cur)
187
+ return tuple(out)
@@ -0,0 +1,160 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import TYPE_CHECKING, Any, Generic, TypeVar
5
+
6
+ from tomlrange.error import TomlRangeError
7
+ from tomlrange.paths import Overlap
8
+
9
+ if TYPE_CHECKING:
10
+ from tomlrange.bound import Bound, Bounds
11
+
12
+ T = TypeVar("T")
13
+
14
+ _FROM = "from"
15
+ _TO = "to"
16
+
17
+
18
+ @dataclass(frozen=True, slots=True)
19
+ class Domain(Generic[T]):
20
+ """Closed interval domain for a `{ from, to }` table.
21
+
22
+ `typ` is matched with `type(raw) is typ` — `bool` is not an `int`,
23
+ and a TOML float is not an `int`.
24
+ """
25
+
26
+ typ: type[T]
27
+ lo: T | None = None
28
+ hi: T | None = None
29
+ name: str = "value"
30
+ keys: tuple[str, str] = (_FROM, _TO)
31
+
32
+ def __post_init__(self) -> None:
33
+ if len(self.keys) != 2 or self.keys[0] == self.keys[1]:
34
+ raise ValueError("keys must be two distinct TOML key names")
35
+ if self.lo is not None and type(self.lo) is not self.typ:
36
+ raise TypeError(f"lo must be {self.typ.__name__}")
37
+ if self.hi is not None and type(self.hi) is not self.typ:
38
+ raise TypeError(f"hi must be {self.typ.__name__}")
39
+ if self.lo is not None and self.hi is not None and self.lo > self.hi: # type: ignore[operator]
40
+ raise ValueError("domain lo is after hi")
41
+
42
+ @property
43
+ def start_key(self) -> str:
44
+ return self.keys[0]
45
+
46
+ @property
47
+ def stop_key(self) -> str:
48
+ return self.keys[1]
49
+
50
+ def convert(self, raw: Any, *, path: str) -> T:
51
+ if type(raw) is not self.typ:
52
+ raise TomlRangeError(
53
+ path,
54
+ f"expected {self.typ.__name__}, got {type(raw).__name__}",
55
+ raw,
56
+ )
57
+ if self.lo is not None and raw < self.lo: # type: ignore[operator]
58
+ raise TomlRangeError(
59
+ path,
60
+ f"{raw!r} is below {self.name} {self.lo}",
61
+ raw,
62
+ )
63
+ if self.hi is not None and raw > self.hi: # type: ignore[operator]
64
+ raise TomlRangeError(
65
+ path,
66
+ f"{raw!r} is above {self.name} {self.hi}",
67
+ raw,
68
+ )
69
+ return raw
70
+
71
+ def bound(self, raw: Any, *, path: str = ".") -> Bound[T]:
72
+ from tomlrange.bound import Bound
73
+
74
+ return Bound.parse(raw, self, path=path)
75
+
76
+ def bounds(
77
+ self,
78
+ raw: Any,
79
+ *,
80
+ path: str = ".",
81
+ overlap: Overlap = "reject",
82
+ ) -> Bounds[T]:
83
+ from tomlrange.bound import Bounds
84
+
85
+ return Bounds.parse(raw, self, path=path, overlap=overlap)
86
+
87
+ def full(self) -> Bound[T]:
88
+ from tomlrange.bound import Bound
89
+
90
+ if self.lo is None or self.hi is None:
91
+ raise TomlRangeError(".", f"{self.name} domain has no closed lo/hi")
92
+ return Bound(self.lo, self.hi, self)
93
+
94
+
95
+ class Spec:
96
+ """Subclass to name a domain. The class body is the schema.
97
+
98
+ ```
99
+ class Month(Spec):
100
+ typ = int
101
+ lo = 1
102
+ hi = 12
103
+
104
+ Month.parse({"from": 1, "to": 4})
105
+ Month.parse_many([{ "from": 1, "to": 4 }, { "from": 6, "to": 10 }])
106
+ ```
107
+ """
108
+
109
+ typ: type = int
110
+ lo: Any = None
111
+ hi: Any = None
112
+ name: str | None = None
113
+ keys: tuple[str, str] = (_FROM, _TO)
114
+ overlap: Overlap = "reject"
115
+ domain: Domain[Any]
116
+
117
+ def __init_subclass__(cls, **kwargs: Any) -> None:
118
+ super().__init_subclass__(**kwargs)
119
+ name = cls.name
120
+ if name is None:
121
+ name = cls.__name__
122
+ for suffix in ("Bounds", "Range", "Spec"):
123
+ if name.endswith(suffix) and name != suffix:
124
+ name = name[: -len(suffix)]
125
+ break
126
+ name = name.lower()
127
+ cls.domain = Domain(
128
+ typ=cls.typ,
129
+ lo=cls.lo,
130
+ hi=cls.hi,
131
+ name=name,
132
+ keys=cls.keys,
133
+ )
134
+
135
+ @classmethod
136
+ def parse(cls, raw: Any, *, path: str = ".") -> Bound[Any]:
137
+ from tomlrange.bound import Bound
138
+
139
+ return Bound.parse(raw, cls.domain, path=path)
140
+
141
+ @classmethod
142
+ def parse_many(
143
+ cls,
144
+ raw: Any,
145
+ *,
146
+ path: str = ".",
147
+ overlap: Overlap | None = None,
148
+ ) -> Bounds[Any]:
149
+ from tomlrange.bound import Bounds
150
+
151
+ return Bounds.parse(
152
+ raw,
153
+ cls.domain,
154
+ path=path,
155
+ overlap=cls.overlap if overlap is None else overlap,
156
+ )
157
+
158
+ @classmethod
159
+ def full(cls) -> Bound[Any]:
160
+ return cls.domain.full()
@@ -0,0 +1,14 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+
6
+ class TomlRangeError(ValueError):
7
+ """A `{ from, to }` table failed to parse or validate."""
8
+
9
+ def __init__(self, path: str, message: str, value: Any = None) -> None:
10
+ self.path = path
11
+ self.message = message
12
+ self.value = value
13
+ loc = path if path not in {"", "."} else "range"
14
+ super().__init__(f"{loc}: {message}")
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Literal
4
+
5
+ Overlap = Literal["reject", "allow", "merge"]
6
+
7
+
8
+ def join_path(parent: str, child: str) -> str:
9
+ if parent in {"", "."}:
10
+ return child
11
+ return f"{parent}.{child}"
12
+
13
+
14
+ def index_path(parent: str, i: int) -> str:
15
+ if parent in {"", "."}:
16
+ return f"[{i}]"
17
+ return f"{parent}[{i}]"
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,135 @@
1
+ from __future__ import annotations
2
+
3
+ import tomllib
4
+
5
+ import pytest
6
+
7
+ from tomlrange import Bound, Domain, Spec, TomlRangeError
8
+
9
+ MONTH = Domain(int, lo=1, hi=12, name="month")
10
+
11
+ INLINE = "months = { from = 1, to = 12 }\n"
12
+ STANDARD = """
13
+ [months_copy]
14
+ from = 1
15
+ to = 12
16
+ """
17
+ ARRAY_INLINE = """
18
+ months_ranges = [
19
+ { from = 1, to = 4 },
20
+ { from = 6, to = 10 }
21
+ ]
22
+ """
23
+ ARRAY_TABLES = """
24
+ [[months_ranges_copy]]
25
+ from = 1
26
+ to = 4
27
+
28
+ [[months_ranges_copy]]
29
+ from = 6
30
+ to = 10
31
+ """
32
+
33
+
34
+ def test_inline_table_and_standard_table_are_the_same() -> None:
35
+ a = MONTH.bound(tomllib.loads(INLINE)["months"])
36
+ b = MONTH.bound(tomllib.loads(STANDARD)["months_copy"])
37
+ assert a == b
38
+ assert a.as_tuple() == (1, 12)
39
+ assert a.as_table() == {"from": 1, "to": 12}
40
+ assert list(a) == list(range(1, 13))
41
+ assert 12 in a
42
+ assert 0 not in a
43
+
44
+
45
+ def test_array_of_inline_tables_and_array_of_tables_are_the_same() -> None:
46
+ a = MONTH.bounds(tomllib.loads(ARRAY_INLINE)["months_ranges"])
47
+ b = MONTH.bounds(tomllib.loads(ARRAY_TABLES)["months_ranges_copy"])
48
+ assert a == b
49
+ assert list(a) == [1, 2, 3, 4, 6, 7, 8, 9, 10]
50
+ assert 4 in a
51
+ assert 5 not in a
52
+
53
+
54
+ def test_bounds_accepts_a_single_table() -> None:
55
+ one = MONTH.bounds({"from": 2, "to": 3})
56
+ assert one.spans == (Bound.parse({"from": 2, "to": 3}, MONTH),)
57
+
58
+
59
+ def test_singleton_and_full() -> None:
60
+ one = MONTH.bound({"from": 7, "to": 7})
61
+ assert list(one) == [7]
62
+ assert MONTH.full().as_tuple() == (1, 12)
63
+
64
+
65
+ def test_rejects_inverted_and_out_of_domain() -> None:
66
+ with pytest.raises(TomlRangeError, match="from \\(5\\) is after to \\(1\\)"):
67
+ MONTH.bound({"from": 5, "to": 1})
68
+ with pytest.raises(TomlRangeError, match="above month 12"):
69
+ MONTH.bound({"from": 1, "to": 13})
70
+ with pytest.raises(TomlRangeError, match="below month 1"):
71
+ MONTH.bound({"from": 0, "to": 3})
72
+
73
+
74
+ def test_rejects_wrong_shape_and_unknown_keys() -> None:
75
+ with pytest.raises(TomlRangeError, match="expected a table"):
76
+ MONTH.bound([1, 12])
77
+ with pytest.raises(TomlRangeError, match="unknown keys"):
78
+ MONTH.bound({"from": 1, "to": 2, "step": 1})
79
+ with pytest.raises(TomlRangeError, match="must have keys"):
80
+ MONTH.bound({"from": 1})
81
+ with pytest.raises(TomlRangeError, match="expected int, got float"):
82
+ MONTH.bound({"from": 1.0, "to": 2})
83
+ with pytest.raises(TomlRangeError, match="expected int, got bool"):
84
+ MONTH.bound({"from": True, "to": 2})
85
+
86
+
87
+ def test_overlap_policies() -> None:
88
+ raw = [{"from": 1, "to": 4}, {"from": 4, "to": 6}]
89
+ with pytest.raises(TomlRangeError, match="overlaps"):
90
+ MONTH.bounds(raw)
91
+ allowed = MONTH.bounds(raw, overlap="allow")
92
+ assert list(allowed) == [1, 2, 3, 4, 5, 6]
93
+ merged = MONTH.bounds(raw, overlap="merge")
94
+ assert merged.spans == (Bound(1, 6, MONTH),)
95
+
96
+ adjacent = [{"from": 1, "to": 4}, {"from": 5, "to": 6}]
97
+ kept = MONTH.bounds(adjacent)
98
+ assert len(kept.spans) == 2
99
+ assert kept.merge().spans == (Bound(1, 6, MONTH),)
100
+
101
+
102
+ def test_spec_metaprogramming() -> None:
103
+ class Month(Spec):
104
+ typ = int
105
+ lo = 1
106
+ hi = 12
107
+
108
+ bound = Month.parse({"from": 3, "to": 5})
109
+ assert bound.domain.name == "month"
110
+ assert list(Month.parse_many([{"from": 1, "to": 2}, {"from": 11, "to": 12}])) == [
111
+ 1,
112
+ 2,
113
+ 11,
114
+ 12,
115
+ ]
116
+ assert Month.full().width == 12
117
+
118
+
119
+ def test_custom_keys() -> None:
120
+ hours = Domain(int, lo=0, hi=23, name="hour", keys=("start", "end"))
121
+ bound = hours.bound({"start": 7, "end": 16})
122
+ assert bound.as_table() == {"start": 7, "end": 16}
123
+ with pytest.raises(TomlRangeError, match="unknown keys"):
124
+ hours.bound({"from": 7, "to": 16})
125
+ with pytest.raises(TomlRangeError, match="must have keys"):
126
+ hours.bound({"start": 7})
127
+
128
+
129
+ def test_error_paths() -> None:
130
+ with pytest.raises(TomlRangeError, match=r"months_ranges\[1\].to:") as exc:
131
+ MONTH.bounds(
132
+ [{"from": 1, "to": 2}, {"from": 3, "to": 13}],
133
+ path="months_ranges",
134
+ )
135
+ assert exc.value.path == "months_ranges[1].to"