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.
- tomlrange-0.1.0/.github/workflows/publish.yml +121 -0
- tomlrange-0.1.0/.gitignore +8 -0
- tomlrange-0.1.0/.pre-commit-config.yaml +20 -0
- tomlrange-0.1.0/LICENSE +21 -0
- tomlrange-0.1.0/PKG-INFO +152 -0
- tomlrange-0.1.0/README.md +110 -0
- tomlrange-0.1.0/pyproject.toml +78 -0
- tomlrange-0.1.0/src/tomlrange/__init__.py +14 -0
- tomlrange-0.1.0/src/tomlrange/bound.py +187 -0
- tomlrange-0.1.0/src/tomlrange/domain.py +160 -0
- tomlrange-0.1.0/src/tomlrange/error.py +14 -0
- tomlrange-0.1.0/src/tomlrange/paths.py +17 -0
- tomlrange-0.1.0/src/tomlrange/py.typed +1 -0
- tomlrange-0.1.0/tests/test_tomlrange.py +135 -0
|
@@ -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,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
|
tomlrange-0.1.0/LICENSE
ADDED
|
@@ -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.
|
tomlrange-0.1.0/PKG-INFO
ADDED
|
@@ -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"
|