nieve 1.0.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.
- nieve-1.0.0/.gitignore +16 -0
- nieve-1.0.0/LICENSE +21 -0
- nieve-1.0.0/PKG-INFO +175 -0
- nieve-1.0.0/README.md +148 -0
- nieve-1.0.0/pyproject.toml +64 -0
- nieve-1.0.0/src/nieve/__init__.py +46 -0
- nieve-1.0.0/src/nieve/_syntax.py +49 -0
- nieve-1.0.0/src/nieve/_verifier.py +55 -0
- nieve-1.0.0/src/nieve/clean.py +19 -0
- nieve-1.0.0/src/nieve/error.py +11 -0
- nieve-1.0.0/src/nieve/format.py +28 -0
- nieve-1.0.0/src/nieve/parse.py +158 -0
- nieve-1.0.0/src/nieve/py.typed +1 -0
- nieve-1.0.0/src/nieve/types.py +75 -0
nieve-1.0.0/.gitignore
ADDED
nieve-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dud-cl
|
|
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.
|
nieve-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: nieve
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Lean Chilean RUT validation and formatting for Python
|
|
5
|
+
Project-URL: Homepage, https://github.com/enemigos/nieve
|
|
6
|
+
Project-URL: Repository, https://github.com/enemigos/nieve
|
|
7
|
+
Project-URL: Issues, https://github.com/enemigos/nieve/issues
|
|
8
|
+
Author: enemigos
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: chile,format,rut,validate
|
|
12
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: mypy>=1.14; extra == 'dev'
|
|
24
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
25
|
+
Requires-Dist: ruff>=0.9; extra == 'dev'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# nieve
|
|
29
|
+
|
|
30
|
+
Validate and format Chilean RUT values in Python.
|
|
31
|
+
|
|
32
|
+
The core API matches [`nieve`](https://www.npmjs.com/package/nieve) for TypeScript and uses `snake_case` names. Both packages share one set of fixtures, including a generated conformance suite that fails CI when the two implementations disagree, and both are released with the same version number. The TypeScript-only `formatPartial` helper is for progressive browser input and is intentionally omitted here.
|
|
33
|
+
|
|
34
|
+
## Motivation
|
|
35
|
+
|
|
36
|
+
The last releases of the legacy JavaScript libraries [`rut.js`](https://github.com/jlobos/rut.js) and [`rutjs`](https://github.com/jeam/rut) were published in 2021 and 2013, respectively, and they still have open issues. This package brings strict validation and structured issues to Python. See the [agent reference](https://github.com/enemigos/nieve/blob/main/llms.txt) for complete contracts and recipes.
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install nieve
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Usage
|
|
45
|
+
|
|
46
|
+
Examples import the package as `rut` so each call reads on its own.
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
import nieve as rut
|
|
50
|
+
|
|
51
|
+
value = rut.parse("21.272.789-K")
|
|
52
|
+
# "21272789K"
|
|
53
|
+
|
|
54
|
+
rut.format(value) # "21.272.789-K"
|
|
55
|
+
rut.format(value, style="plain") # "21272789-K"
|
|
56
|
+
rut.format(value, verifier_case="lower") # "21.272.789-k"
|
|
57
|
+
|
|
58
|
+
rut.safe_parse("21.272.789-0", "en")
|
|
59
|
+
# SafeParseFailure(
|
|
60
|
+
# success=False,
|
|
61
|
+
# issue=VerifierIssue(
|
|
62
|
+
# kind="verifier",
|
|
63
|
+
# message='RUT verifier does not match. Replace "0" with "K".',
|
|
64
|
+
# input="21.272.789-0",
|
|
65
|
+
# expected="K",
|
|
66
|
+
# received="0",
|
|
67
|
+
# ),
|
|
68
|
+
# )
|
|
69
|
+
|
|
70
|
+
rut.is_rut("21272789k") # True (the TypeScript name is `is`)
|
|
71
|
+
|
|
72
|
+
rut.clean("0021.272.789-k") # "21272789K" (does not validate)
|
|
73
|
+
rut.get_verifier("21.272.789") # "K"
|
|
74
|
+
rut.compare("21.272.789-K", "21272789K") # True
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Accepted input
|
|
78
|
+
|
|
79
|
+
```text
|
|
80
|
+
21.272.789-K
|
|
81
|
+
21.272.789K
|
|
82
|
+
21272789-K
|
|
83
|
+
21272789K
|
|
84
|
+
21.272.789-k
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
- Input must be a string.
|
|
88
|
+
- Surrounding whitespace is ignored. Whitespace inside the value is not.
|
|
89
|
+
- The body contains 7 or 8 digits and does not start with zero.
|
|
90
|
+
- Dots are either all present in groups of three or entirely absent: `21.272789-K` is rejected.
|
|
91
|
+
- The hyphen before the verifier is optional.
|
|
92
|
+
- `k` is accepted in either case.
|
|
93
|
+
|
|
94
|
+
The 7-digit floor is deliberate: it rejects modulo-11 false positives in short input such as `17353`. It also rejects very low real RUT values and test values such as `1-9`, which report `length`.
|
|
95
|
+
|
|
96
|
+
### Issue kinds
|
|
97
|
+
|
|
98
|
+
`safe_parse` returns one issue, and each `kind` means one thing:
|
|
99
|
+
|
|
100
|
+
| `kind` | When |
|
|
101
|
+
|---|---|
|
|
102
|
+
| `type` | Input is not a string. |
|
|
103
|
+
| `format` | The syntax is not a RUT: stray characters, mixed or misplaced separators, a leading zero. |
|
|
104
|
+
| `length` | The syntax is valid but the body does not contain 7 or 8 digits. Carries `body_length`. |
|
|
105
|
+
| `verifier` | The size is valid but the verifier does not match. Carries `expected` and `received`. |
|
|
106
|
+
|
|
107
|
+
Narrow on the concrete dataclass or on `kind`:
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
from nieve import VerifierIssue, safe_parse
|
|
111
|
+
|
|
112
|
+
result = safe_parse("21.272.789-0")
|
|
113
|
+
|
|
114
|
+
if not result.success and isinstance(result.issue, VerifierIssue):
|
|
115
|
+
result.issue.expected # "K"
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### Stored values
|
|
119
|
+
|
|
120
|
+
`format` accepts anything `parse` accepts and raises `RutError` for anything else, so a canonical value read back from storage formats directly:
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
stored = "21272789K"
|
|
124
|
+
|
|
125
|
+
rut.format(stored) # "21.272.789-K"
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### With Pydantic
|
|
129
|
+
|
|
130
|
+
`parse` raises `RutError`, a `ValueError` subclass. Use it directly with `AfterValidator`:
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
from typing import Annotated
|
|
134
|
+
from pydantic import AfterValidator, BaseModel
|
|
135
|
+
from nieve import parse
|
|
136
|
+
|
|
137
|
+
class User(BaseModel):
|
|
138
|
+
national_id: Annotated[str, AfterValidator(parse)]
|
|
139
|
+
|
|
140
|
+
user = User(national_id="21.272.789-K")
|
|
141
|
+
user.national_id # "21272789K"
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## API
|
|
145
|
+
|
|
146
|
+
| API | Purpose |
|
|
147
|
+
|---|---|
|
|
148
|
+
| `parse(input, language="es")` | Validate input. Return the canonical RUT or raise `RutError`. |
|
|
149
|
+
| `safe_parse(input, language="es")` | Validate input without raising. Return a structured result. |
|
|
150
|
+
| `is_rut(input)` | Return whether the input is valid. |
|
|
151
|
+
| `format(value, *, style="dotted", verifier_case="upper")` | Format a RUT. Validates first and raises `RutError` for invalid input. |
|
|
152
|
+
| `clean(input)` | Normalize input without validating it. The output is untrusted. |
|
|
153
|
+
| `compare(left, right)` | Return whether two inputs are the same RUT. |
|
|
154
|
+
| `get_verifier(body)` | Calculate the verifier for a valid body. Return `None` for an invalid body. |
|
|
155
|
+
|
|
156
|
+
`style` is `"dotted"` or `"plain"`. `verifier_case` is `"upper"` or `"lower"`.
|
|
157
|
+
|
|
158
|
+
Errors use Spanish (`es`) by default. Pass `en` as the second argument to `parse` or `safe_parse` for English messages.
|
|
159
|
+
|
|
160
|
+
`compare` returns `False` when either input is invalid, so it cannot distinguish "different" from "invalid". Use `safe_parse` when that difference matters.
|
|
161
|
+
|
|
162
|
+
Exported names: `Rut`, `Language`, `RutError`, `RutIssue`, `RutIssueKind`, `TypeIssue`, `FormatIssue`, `LengthIssue`, `VerifierIssue`, `SafeParseResult`, `SafeParseSuccess`, `SafeParseFailure`, `Style`, `VerifierCase`.
|
|
163
|
+
|
|
164
|
+
## Development
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
uv sync --extra dev
|
|
168
|
+
uv run pytest
|
|
169
|
+
uv run mypy
|
|
170
|
+
uv run ruff check .
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
## License
|
|
174
|
+
|
|
175
|
+
MIT
|
nieve-1.0.0/README.md
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# nieve
|
|
2
|
+
|
|
3
|
+
Validate and format Chilean RUT values in Python.
|
|
4
|
+
|
|
5
|
+
The core API matches [`nieve`](https://www.npmjs.com/package/nieve) for TypeScript and uses `snake_case` names. Both packages share one set of fixtures, including a generated conformance suite that fails CI when the two implementations disagree, and both are released with the same version number. The TypeScript-only `formatPartial` helper is for progressive browser input and is intentionally omitted here.
|
|
6
|
+
|
|
7
|
+
## Motivation
|
|
8
|
+
|
|
9
|
+
The last releases of the legacy JavaScript libraries [`rut.js`](https://github.com/jlobos/rut.js) and [`rutjs`](https://github.com/jeam/rut) were published in 2021 and 2013, respectively, and they still have open issues. This package brings strict validation and structured issues to Python. See the [agent reference](https://github.com/enemigos/nieve/blob/main/llms.txt) for complete contracts and recipes.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install nieve
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
Examples import the package as `rut` so each call reads on its own.
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
import nieve as rut
|
|
23
|
+
|
|
24
|
+
value = rut.parse("21.272.789-K")
|
|
25
|
+
# "21272789K"
|
|
26
|
+
|
|
27
|
+
rut.format(value) # "21.272.789-K"
|
|
28
|
+
rut.format(value, style="plain") # "21272789-K"
|
|
29
|
+
rut.format(value, verifier_case="lower") # "21.272.789-k"
|
|
30
|
+
|
|
31
|
+
rut.safe_parse("21.272.789-0", "en")
|
|
32
|
+
# SafeParseFailure(
|
|
33
|
+
# success=False,
|
|
34
|
+
# issue=VerifierIssue(
|
|
35
|
+
# kind="verifier",
|
|
36
|
+
# message='RUT verifier does not match. Replace "0" with "K".',
|
|
37
|
+
# input="21.272.789-0",
|
|
38
|
+
# expected="K",
|
|
39
|
+
# received="0",
|
|
40
|
+
# ),
|
|
41
|
+
# )
|
|
42
|
+
|
|
43
|
+
rut.is_rut("21272789k") # True (the TypeScript name is `is`)
|
|
44
|
+
|
|
45
|
+
rut.clean("0021.272.789-k") # "21272789K" (does not validate)
|
|
46
|
+
rut.get_verifier("21.272.789") # "K"
|
|
47
|
+
rut.compare("21.272.789-K", "21272789K") # True
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### Accepted input
|
|
51
|
+
|
|
52
|
+
```text
|
|
53
|
+
21.272.789-K
|
|
54
|
+
21.272.789K
|
|
55
|
+
21272789-K
|
|
56
|
+
21272789K
|
|
57
|
+
21.272.789-k
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
- Input must be a string.
|
|
61
|
+
- Surrounding whitespace is ignored. Whitespace inside the value is not.
|
|
62
|
+
- The body contains 7 or 8 digits and does not start with zero.
|
|
63
|
+
- Dots are either all present in groups of three or entirely absent: `21.272789-K` is rejected.
|
|
64
|
+
- The hyphen before the verifier is optional.
|
|
65
|
+
- `k` is accepted in either case.
|
|
66
|
+
|
|
67
|
+
The 7-digit floor is deliberate: it rejects modulo-11 false positives in short input such as `17353`. It also rejects very low real RUT values and test values such as `1-9`, which report `length`.
|
|
68
|
+
|
|
69
|
+
### Issue kinds
|
|
70
|
+
|
|
71
|
+
`safe_parse` returns one issue, and each `kind` means one thing:
|
|
72
|
+
|
|
73
|
+
| `kind` | When |
|
|
74
|
+
|---|---|
|
|
75
|
+
| `type` | Input is not a string. |
|
|
76
|
+
| `format` | The syntax is not a RUT: stray characters, mixed or misplaced separators, a leading zero. |
|
|
77
|
+
| `length` | The syntax is valid but the body does not contain 7 or 8 digits. Carries `body_length`. |
|
|
78
|
+
| `verifier` | The size is valid but the verifier does not match. Carries `expected` and `received`. |
|
|
79
|
+
|
|
80
|
+
Narrow on the concrete dataclass or on `kind`:
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from nieve import VerifierIssue, safe_parse
|
|
84
|
+
|
|
85
|
+
result = safe_parse("21.272.789-0")
|
|
86
|
+
|
|
87
|
+
if not result.success and isinstance(result.issue, VerifierIssue):
|
|
88
|
+
result.issue.expected # "K"
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### Stored values
|
|
92
|
+
|
|
93
|
+
`format` accepts anything `parse` accepts and raises `RutError` for anything else, so a canonical value read back from storage formats directly:
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
stored = "21272789K"
|
|
97
|
+
|
|
98
|
+
rut.format(stored) # "21.272.789-K"
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### With Pydantic
|
|
102
|
+
|
|
103
|
+
`parse` raises `RutError`, a `ValueError` subclass. Use it directly with `AfterValidator`:
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
from typing import Annotated
|
|
107
|
+
from pydantic import AfterValidator, BaseModel
|
|
108
|
+
from nieve import parse
|
|
109
|
+
|
|
110
|
+
class User(BaseModel):
|
|
111
|
+
national_id: Annotated[str, AfterValidator(parse)]
|
|
112
|
+
|
|
113
|
+
user = User(national_id="21.272.789-K")
|
|
114
|
+
user.national_id # "21272789K"
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## API
|
|
118
|
+
|
|
119
|
+
| API | Purpose |
|
|
120
|
+
|---|---|
|
|
121
|
+
| `parse(input, language="es")` | Validate input. Return the canonical RUT or raise `RutError`. |
|
|
122
|
+
| `safe_parse(input, language="es")` | Validate input without raising. Return a structured result. |
|
|
123
|
+
| `is_rut(input)` | Return whether the input is valid. |
|
|
124
|
+
| `format(value, *, style="dotted", verifier_case="upper")` | Format a RUT. Validates first and raises `RutError` for invalid input. |
|
|
125
|
+
| `clean(input)` | Normalize input without validating it. The output is untrusted. |
|
|
126
|
+
| `compare(left, right)` | Return whether two inputs are the same RUT. |
|
|
127
|
+
| `get_verifier(body)` | Calculate the verifier for a valid body. Return `None` for an invalid body. |
|
|
128
|
+
|
|
129
|
+
`style` is `"dotted"` or `"plain"`. `verifier_case` is `"upper"` or `"lower"`.
|
|
130
|
+
|
|
131
|
+
Errors use Spanish (`es`) by default. Pass `en` as the second argument to `parse` or `safe_parse` for English messages.
|
|
132
|
+
|
|
133
|
+
`compare` returns `False` when either input is invalid, so it cannot distinguish "different" from "invalid". Use `safe_parse` when that difference matters.
|
|
134
|
+
|
|
135
|
+
Exported names: `Rut`, `Language`, `RutError`, `RutIssue`, `RutIssueKind`, `TypeIssue`, `FormatIssue`, `LengthIssue`, `VerifierIssue`, `SafeParseResult`, `SafeParseSuccess`, `SafeParseFailure`, `Style`, `VerifierCase`.
|
|
136
|
+
|
|
137
|
+
## Development
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
uv sync --extra dev
|
|
141
|
+
uv run pytest
|
|
142
|
+
uv run mypy
|
|
143
|
+
uv run ruff check .
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## License
|
|
147
|
+
|
|
148
|
+
MIT
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "nieve"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Lean Chilean RUT validation and formatting for Python"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [{ name = "enemigos" }]
|
|
13
|
+
keywords = ["rut", "chile", "validate", "format"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 5 - Production/Stable",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Typing :: Typed",
|
|
24
|
+
]
|
|
25
|
+
dependencies = []
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://github.com/enemigos/nieve"
|
|
29
|
+
Repository = "https://github.com/enemigos/nieve"
|
|
30
|
+
Issues = "https://github.com/enemigos/nieve/issues"
|
|
31
|
+
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
dev = ["pytest>=8.0", "mypy>=1.14", "ruff>=0.9"]
|
|
34
|
+
|
|
35
|
+
[tool.hatch.build.targets.sdist]
|
|
36
|
+
include = ["src/nieve", "README.md", "LICENSE"]
|
|
37
|
+
|
|
38
|
+
[tool.hatch.build.targets.wheel]
|
|
39
|
+
packages = ["src/nieve"]
|
|
40
|
+
|
|
41
|
+
[tool.pytest.ini_options]
|
|
42
|
+
testpaths = ["tests"]
|
|
43
|
+
|
|
44
|
+
[tool.ruff]
|
|
45
|
+
line-length = 88
|
|
46
|
+
target-version = "py310"
|
|
47
|
+
src = ["src", "tests"]
|
|
48
|
+
|
|
49
|
+
[tool.ruff.lint]
|
|
50
|
+
select = ["E", "F", "I", "UP", "B", "A", "RUF"]
|
|
51
|
+
# `input` and `format` shadow builtins on purpose: the public API mirrors the
|
|
52
|
+
# TypeScript package, where `format` and the `input` issue field are the names
|
|
53
|
+
# users already know.
|
|
54
|
+
ignore = ["A001", "A002", "A004"]
|
|
55
|
+
|
|
56
|
+
[tool.ruff.format]
|
|
57
|
+
# Documentation code blocks use aligned trailing comments on purpose.
|
|
58
|
+
exclude = ["*.md"]
|
|
59
|
+
|
|
60
|
+
[tool.mypy]
|
|
61
|
+
python_version = "3.10"
|
|
62
|
+
strict = true
|
|
63
|
+
mypy_path = "src"
|
|
64
|
+
packages = ["nieve", "tests"]
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Lean Chilean RUT validation and formatting."""
|
|
2
|
+
|
|
3
|
+
from ._verifier import get_verifier
|
|
4
|
+
from .clean import clean
|
|
5
|
+
from .error import RutError
|
|
6
|
+
from .format import format
|
|
7
|
+
from .parse import compare, is_rut, parse, safe_parse
|
|
8
|
+
from .types import (
|
|
9
|
+
FormatIssue,
|
|
10
|
+
Language,
|
|
11
|
+
LengthIssue,
|
|
12
|
+
Rut,
|
|
13
|
+
RutIssue,
|
|
14
|
+
RutIssueKind,
|
|
15
|
+
SafeParseFailure,
|
|
16
|
+
SafeParseResult,
|
|
17
|
+
SafeParseSuccess,
|
|
18
|
+
Style,
|
|
19
|
+
TypeIssue,
|
|
20
|
+
VerifierCase,
|
|
21
|
+
VerifierIssue,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"FormatIssue",
|
|
26
|
+
"Language",
|
|
27
|
+
"LengthIssue",
|
|
28
|
+
"Rut",
|
|
29
|
+
"RutError",
|
|
30
|
+
"RutIssue",
|
|
31
|
+
"RutIssueKind",
|
|
32
|
+
"SafeParseFailure",
|
|
33
|
+
"SafeParseResult",
|
|
34
|
+
"SafeParseSuccess",
|
|
35
|
+
"Style",
|
|
36
|
+
"TypeIssue",
|
|
37
|
+
"VerifierCase",
|
|
38
|
+
"VerifierIssue",
|
|
39
|
+
"clean",
|
|
40
|
+
"compare",
|
|
41
|
+
"format",
|
|
42
|
+
"get_verifier",
|
|
43
|
+
"is_rut",
|
|
44
|
+
"parse",
|
|
45
|
+
"safe_parse",
|
|
46
|
+
]
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Shared syntax rules. Keep these aligned with the TypeScript package.
|
|
2
|
+
|
|
3
|
+
A body is either bare digits (``21272789``) or dot separated groups of three
|
|
4
|
+
digits after a leading group of one to three digits (``21.272.789``). Mixing the
|
|
5
|
+
two styles is rejected, and a leading zero is rejected. Body size is checked
|
|
6
|
+
separately so that a well formed token of the wrong size reports ``length``
|
|
7
|
+
instead of ``format``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
|
|
14
|
+
_BODY = r"(?:[1-9][0-9]{0,2}(?:\.[0-9]{3})+|[1-9][0-9]*)"
|
|
15
|
+
|
|
16
|
+
# Optional body, optional hyphen, verifier.
|
|
17
|
+
RUT_SYNTAX = re.compile(rf"{_BODY}?-?[0-9kK]")
|
|
18
|
+
|
|
19
|
+
# A body on its own, with an optional trailing hyphen.
|
|
20
|
+
BODY_SYNTAX = re.compile(rf"{_BODY}-?")
|
|
21
|
+
|
|
22
|
+
MIN_BODY_LENGTH = 7
|
|
23
|
+
MAX_BODY_LENGTH = 8
|
|
24
|
+
|
|
25
|
+
# Surrounding whitespace that both packages ignore: ASCII whitespace plus the
|
|
26
|
+
# no-break space, which is common in values pasted from documents. The set is
|
|
27
|
+
# explicit so TypeScript and Python trim exactly the same characters.
|
|
28
|
+
_TRIMMED = " \t\n\r\v\f\u00a0"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def trim_whitespace(value: str) -> str:
|
|
32
|
+
return value.strip(_TRIMMED)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def strip_separators(value: str) -> str:
|
|
36
|
+
"""Remove the separators allowed by the syntax rules."""
|
|
37
|
+
return value.replace(".", "").replace("-", "")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def group_thousands(digits: str) -> str:
|
|
41
|
+
"""Insert dots every three digits from the right."""
|
|
42
|
+
result = digits[-3:]
|
|
43
|
+
rest = digits[:-3]
|
|
44
|
+
|
|
45
|
+
while rest:
|
|
46
|
+
result = f"{rest[-3:]}.{result}"
|
|
47
|
+
rest = rest[:-3]
|
|
48
|
+
|
|
49
|
+
return result
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from ._syntax import (
|
|
6
|
+
BODY_SYNTAX,
|
|
7
|
+
MAX_BODY_LENGTH,
|
|
8
|
+
MIN_BODY_LENGTH,
|
|
9
|
+
strip_separators,
|
|
10
|
+
trim_whitespace,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
_DIGITS = re.compile(r"[0-9]+")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _calculate_verifier(body: str) -> str:
|
|
17
|
+
"""Compute the modulo-11 verifier for a body of digits.
|
|
18
|
+
|
|
19
|
+
Internal: callers must pass a body that already matched the syntax rules.
|
|
20
|
+
"""
|
|
21
|
+
if _DIGITS.fullmatch(body) is None:
|
|
22
|
+
raise ValueError(f'"{body}" is not a RUT body')
|
|
23
|
+
|
|
24
|
+
total = sum(
|
|
25
|
+
int(char) * ((index % 6) + 2) for index, char in enumerate(reversed(body))
|
|
26
|
+
)
|
|
27
|
+
digit = 11 - (total % 11)
|
|
28
|
+
|
|
29
|
+
if digit == 10:
|
|
30
|
+
return "K"
|
|
31
|
+
if digit == 11:
|
|
32
|
+
return "0"
|
|
33
|
+
return str(digit)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_verifier(input: object) -> str | None:
|
|
37
|
+
"""Compute the verifier for a RUT body.
|
|
38
|
+
|
|
39
|
+
Accepts the same body syntax as ``parse``, with an optional trailing hyphen
|
|
40
|
+
and surrounding whitespace. Returns ``None`` for anything else.
|
|
41
|
+
"""
|
|
42
|
+
if not isinstance(input, str):
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
value = trim_whitespace(input)
|
|
46
|
+
|
|
47
|
+
if BODY_SYNTAX.fullmatch(value) is None:
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
body = strip_separators(value)
|
|
51
|
+
|
|
52
|
+
if not MIN_BODY_LENGTH <= len(body) <= MAX_BODY_LENGTH:
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
return _calculate_verifier(body)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
_UNRELATED = re.compile(r"[^0-9kK]+")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def clean(input: object) -> str:
|
|
9
|
+
"""Strip everything that is not a digit or ``K``, uppercase, drop leading zeros.
|
|
10
|
+
|
|
11
|
+
This is a lossy normalizer, not a validator: it happily turns unrelated text
|
|
12
|
+
into a RUT-looking string, and returns an empty string for non-string input.
|
|
13
|
+
Always pass the result through ``parse``, ``safe_parse``, or ``is_rut``
|
|
14
|
+
before using it.
|
|
15
|
+
"""
|
|
16
|
+
if not isinstance(input, str):
|
|
17
|
+
return ""
|
|
18
|
+
|
|
19
|
+
return _UNRELATED.sub("", input).lstrip("0").upper()
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from ._syntax import group_thousands
|
|
4
|
+
from .parse import parse
|
|
5
|
+
from .types import Rut, Style, VerifierCase
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def format(
|
|
9
|
+
value: Rut | str,
|
|
10
|
+
*,
|
|
11
|
+
style: Style = "dotted",
|
|
12
|
+
verifier_case: VerifierCase = "upper",
|
|
13
|
+
) -> str:
|
|
14
|
+
"""Format a RUT for display.
|
|
15
|
+
|
|
16
|
+
Accepts a parsed ``Rut`` or any string ``parse`` accepts, which makes stored
|
|
17
|
+
values usable without a cast. Raises ``RutError`` for invalid input, so it
|
|
18
|
+
never returns a formatted string that is not a real RUT. Use ``safe_parse``
|
|
19
|
+
for untrusted input.
|
|
20
|
+
"""
|
|
21
|
+
canonical = parse(value)
|
|
22
|
+
body = canonical[:-1]
|
|
23
|
+
verifier = canonical[-1].lower() if verifier_case == "lower" else canonical[-1]
|
|
24
|
+
|
|
25
|
+
if style == "plain":
|
|
26
|
+
return f"{body}-{verifier}"
|
|
27
|
+
|
|
28
|
+
return f"{group_thousands(body)}-{verifier}"
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TypedDict
|
|
4
|
+
|
|
5
|
+
from ._syntax import (
|
|
6
|
+
MAX_BODY_LENGTH,
|
|
7
|
+
MIN_BODY_LENGTH,
|
|
8
|
+
RUT_SYNTAX,
|
|
9
|
+
strip_separators,
|
|
10
|
+
trim_whitespace,
|
|
11
|
+
)
|
|
12
|
+
from ._verifier import _calculate_verifier
|
|
13
|
+
from .error import RutError
|
|
14
|
+
from .types import (
|
|
15
|
+
FormatIssue,
|
|
16
|
+
Language,
|
|
17
|
+
LengthIssue,
|
|
18
|
+
Rut,
|
|
19
|
+
SafeParseFailure,
|
|
20
|
+
SafeParseResult,
|
|
21
|
+
SafeParseSuccess,
|
|
22
|
+
TypeIssue,
|
|
23
|
+
VerifierIssue,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class _Messages(TypedDict):
|
|
28
|
+
type: str
|
|
29
|
+
format: str
|
|
30
|
+
length: str
|
|
31
|
+
verifier: str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
_MESSAGES: dict[Language, _Messages] = {
|
|
35
|
+
"es": {
|
|
36
|
+
"type": (
|
|
37
|
+
'El RUT debe ser una cadena de texto. Usa un valor como "21.272.789-K" '
|
|
38
|
+
"e intenta de nuevo."
|
|
39
|
+
),
|
|
40
|
+
"format": (
|
|
41
|
+
"El formato del RUT es incorrecto. Usa 7 u 8 d\u00edgitos y un "
|
|
42
|
+
'verificador, por ejemplo, "21.272.789-K".'
|
|
43
|
+
),
|
|
44
|
+
"length": (
|
|
45
|
+
"El cuerpo del RUT debe tener 7 u 8 d\u00edgitos antes del verificador; "
|
|
46
|
+
"tiene {body_length}. Corrige el cuerpo e intenta de nuevo."
|
|
47
|
+
),
|
|
48
|
+
"verifier": (
|
|
49
|
+
'El verificador no coincide. Reemplaza "{received}" por "{expected}".'
|
|
50
|
+
),
|
|
51
|
+
},
|
|
52
|
+
"en": {
|
|
53
|
+
"type": (
|
|
54
|
+
'RUT must be a string. Use a value such as "21.272.789-K", then try again.'
|
|
55
|
+
),
|
|
56
|
+
"format": (
|
|
57
|
+
"RUT format is incorrect. Use 7 or 8 digits and a verifier, "
|
|
58
|
+
'for example, "21.272.789-K".'
|
|
59
|
+
),
|
|
60
|
+
"length": (
|
|
61
|
+
"RUT body must contain 7 or 8 digits before the verifier; it contains "
|
|
62
|
+
"{body_length}. Correct the body, then try again."
|
|
63
|
+
),
|
|
64
|
+
"verifier": (
|
|
65
|
+
'RUT verifier does not match. Replace "{received}" with "{expected}".'
|
|
66
|
+
),
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def safe_parse(input: object, language: Language = "es") -> SafeParseResult:
|
|
72
|
+
"""Validate input without raising.
|
|
73
|
+
|
|
74
|
+
Surrounding whitespace is ignored. Everything else must already be a RUT:
|
|
75
|
+
dots are all present or all absent, the hyphen is optional, and ``k`` may be
|
|
76
|
+
written in either case. ``issue.input`` always reports the original input.
|
|
77
|
+
"""
|
|
78
|
+
messages = _MESSAGES[language]
|
|
79
|
+
|
|
80
|
+
if not isinstance(input, str):
|
|
81
|
+
return SafeParseFailure(
|
|
82
|
+
success=False,
|
|
83
|
+
issue=TypeIssue(kind="type", message=messages["type"], input=input),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
value = trim_whitespace(input)
|
|
87
|
+
|
|
88
|
+
if RUT_SYNTAX.fullmatch(value) is None:
|
|
89
|
+
return SafeParseFailure(
|
|
90
|
+
success=False,
|
|
91
|
+
issue=FormatIssue(kind="format", message=messages["format"], input=input),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
cleaned = strip_separators(value).upper()
|
|
95
|
+
body = cleaned[:-1]
|
|
96
|
+
received = cleaned[-1]
|
|
97
|
+
|
|
98
|
+
if not MIN_BODY_LENGTH <= len(body) <= MAX_BODY_LENGTH:
|
|
99
|
+
return SafeParseFailure(
|
|
100
|
+
success=False,
|
|
101
|
+
issue=LengthIssue(
|
|
102
|
+
kind="length",
|
|
103
|
+
message=messages["length"].format(body_length=len(body)),
|
|
104
|
+
input=input,
|
|
105
|
+
body_length=len(body),
|
|
106
|
+
),
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
expected = _calculate_verifier(body)
|
|
110
|
+
|
|
111
|
+
if expected != received:
|
|
112
|
+
return SafeParseFailure(
|
|
113
|
+
success=False,
|
|
114
|
+
issue=VerifierIssue(
|
|
115
|
+
kind="verifier",
|
|
116
|
+
message=messages["verifier"].format(
|
|
117
|
+
expected=expected,
|
|
118
|
+
received=received,
|
|
119
|
+
),
|
|
120
|
+
input=input,
|
|
121
|
+
expected=expected,
|
|
122
|
+
received=received,
|
|
123
|
+
),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
return SafeParseSuccess(success=True, output=Rut(cleaned))
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def parse(input: object, language: Language = "es") -> Rut:
|
|
130
|
+
"""Validate input. Return the canonical RUT or raise ``RutError``."""
|
|
131
|
+
result = safe_parse(input, language)
|
|
132
|
+
if not result.success:
|
|
133
|
+
raise RutError(result.issue)
|
|
134
|
+
return result.output
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def is_rut(input: object) -> bool:
|
|
138
|
+
"""Return whether input is a valid RUT.
|
|
139
|
+
|
|
140
|
+
Named ``is_rut`` because ``is`` is a Python keyword (the TypeScript export
|
|
141
|
+
is ``is``).
|
|
142
|
+
"""
|
|
143
|
+
return safe_parse(input).success
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def compare(left: object, right: object) -> bool:
|
|
147
|
+
"""Return whether two inputs are the same RUT.
|
|
148
|
+
|
|
149
|
+
Returns ``False`` when either input is invalid, so it cannot distinguish
|
|
150
|
+
"different" from "invalid". Validate with ``safe_parse`` when that matters.
|
|
151
|
+
"""
|
|
152
|
+
left_result = safe_parse(left)
|
|
153
|
+
right_result = safe_parse(right)
|
|
154
|
+
return (
|
|
155
|
+
left_result.success
|
|
156
|
+
and right_result.success
|
|
157
|
+
and left_result.output == right_result.output
|
|
158
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Literal, NewType, TypeAlias
|
|
5
|
+
|
|
6
|
+
Rut = NewType("Rut", str)
|
|
7
|
+
Language: TypeAlias = Literal["es", "en"]
|
|
8
|
+
|
|
9
|
+
#: ``dotted`` renders ``21.272.789-K``. ``plain`` renders ``21272789-K``.
|
|
10
|
+
Style: TypeAlias = Literal["dotted", "plain"]
|
|
11
|
+
|
|
12
|
+
#: Case of the ``K`` verifier. Digits are unaffected.
|
|
13
|
+
VerifierCase: TypeAlias = Literal["upper", "lower"]
|
|
14
|
+
|
|
15
|
+
RutIssueKind: TypeAlias = Literal["type", "format", "length", "verifier"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class TypeIssue:
|
|
20
|
+
"""Input is not a string."""
|
|
21
|
+
|
|
22
|
+
kind: Literal["type"]
|
|
23
|
+
message: str
|
|
24
|
+
input: object
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True, slots=True)
|
|
28
|
+
class FormatIssue:
|
|
29
|
+
"""Input is a string whose syntax is not a RUT.
|
|
30
|
+
|
|
31
|
+
Stray characters, mixed or misplaced separators, or a leading zero.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
kind: Literal["format"]
|
|
35
|
+
message: str
|
|
36
|
+
input: str
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True, slots=True)
|
|
40
|
+
class LengthIssue:
|
|
41
|
+
"""Syntax is valid but the body does not contain 7 or 8 digits."""
|
|
42
|
+
|
|
43
|
+
kind: Literal["length"]
|
|
44
|
+
message: str
|
|
45
|
+
input: str
|
|
46
|
+
body_length: int
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True, slots=True)
|
|
50
|
+
class VerifierIssue:
|
|
51
|
+
"""Syntax and length are valid but the verifier does not match."""
|
|
52
|
+
|
|
53
|
+
kind: Literal["verifier"]
|
|
54
|
+
message: str
|
|
55
|
+
input: str
|
|
56
|
+
expected: str
|
|
57
|
+
received: str
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
RutIssue: TypeAlias = TypeIssue | FormatIssue | LengthIssue | VerifierIssue
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True, slots=True)
|
|
64
|
+
class SafeParseSuccess:
|
|
65
|
+
success: Literal[True]
|
|
66
|
+
output: Rut
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True, slots=True)
|
|
70
|
+
class SafeParseFailure:
|
|
71
|
+
success: Literal[False]
|
|
72
|
+
issue: RutIssue
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
SafeParseResult: TypeAlias = SafeParseSuccess | SafeParseFailure
|