enforce-rules 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.
- enforce_rules-1.0.0/LICENSE +28 -0
- enforce_rules-1.0.0/PKG-INFO +329 -0
- enforce_rules-1.0.0/README.md +273 -0
- enforce_rules-1.0.0/pyproject.toml +39 -0
- enforce_rules-1.0.0/setup.cfg +4 -0
- enforce_rules-1.0.0/src/enforce_rules/__init__.py +1 -0
- enforce_rules-1.0.0/src/enforce_rules/enforce_rules.py +171 -0
- enforce_rules-1.0.0/src/enforce_rules.egg-info/PKG-INFO +329 -0
- enforce_rules-1.0.0/src/enforce_rules.egg-info/SOURCES.txt +10 -0
- enforce_rules-1.0.0/src/enforce_rules.egg-info/dependency_links.txt +1 -0
- enforce_rules-1.0.0/src/enforce_rules.egg-info/top_level.txt +1 -0
- enforce_rules-1.0.0/tests/test_enforce_rules.py +190 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, Loepker-James
|
|
4
|
+
|
|
5
|
+
Redistribution and use in source and binary forms, with or without
|
|
6
|
+
modification, are permitted provided that the following conditions are met:
|
|
7
|
+
|
|
8
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
9
|
+
list of conditions and the following disclaimer.
|
|
10
|
+
|
|
11
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
and/or other materials provided with the distribution.
|
|
14
|
+
|
|
15
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
16
|
+
contributors may be used to endorse or promote products derived from
|
|
17
|
+
this software without specific prior written permission.
|
|
18
|
+
|
|
19
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
20
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
21
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
22
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
23
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
24
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
25
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
26
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
27
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
28
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: enforce-rules
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Runtime rule validation for Python using dictionary-based constraints.
|
|
5
|
+
Author: Jake Loepker, Microsoft Copilot (AI Assistant)
|
|
6
|
+
License: BSD 3-Clause License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026, Loepker-James
|
|
9
|
+
|
|
10
|
+
Redistribution and use in source and binary forms, with or without
|
|
11
|
+
modification, are permitted provided that the following conditions are met:
|
|
12
|
+
|
|
13
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
14
|
+
list of conditions and the following disclaimer.
|
|
15
|
+
|
|
16
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
17
|
+
this list of conditions and the following disclaimer in the documentation
|
|
18
|
+
and/or other materials provided with the distribution.
|
|
19
|
+
|
|
20
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
21
|
+
contributors may be used to endorse or promote products derived from
|
|
22
|
+
this software without specific prior written permission.
|
|
23
|
+
|
|
24
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
25
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
26
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
27
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
28
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
29
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
30
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
31
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
32
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
33
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
34
|
+
|
|
35
|
+
Project-URL: Homepage, https://github.com/Loepker-James/enforce-rules
|
|
36
|
+
Project-URL: Issues, https://github.com/Loepker-James/enforce-rules/issues
|
|
37
|
+
Project-URL: Copilot, https://copilot.microsoft.com
|
|
38
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
39
|
+
Classifier: Intended Audience :: Developers
|
|
40
|
+
Classifier: License :: OSI Approved :: BSD License
|
|
41
|
+
Classifier: Programming Language :: Python
|
|
42
|
+
Classifier: Programming Language :: Python :: 3
|
|
43
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
44
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
45
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
46
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
47
|
+
Classifier: Topic :: Software Development
|
|
48
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
49
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
50
|
+
Classifier: Topic :: Utilities
|
|
51
|
+
Classifier: Typing :: Typed
|
|
52
|
+
Requires-Python: >=3.10
|
|
53
|
+
Description-Content-Type: text/markdown
|
|
54
|
+
License-File: LICENSE
|
|
55
|
+
Dynamic: license-file
|
|
56
|
+
|
|
57
|
+
Project Enforce Rules
|
|
58
|
+
Project Enforce Rules (or PER for short) expands the Python type system to allow more constraints.
|
|
59
|
+
|
|
60
|
+
Version #
|
|
61
|
+
----------------
|
|
62
|
+
MAJOR: 1
|
|
63
|
+
MINOR: 0
|
|
64
|
+
PATCH: 0
|
|
65
|
+
|
|
66
|
+
If you need to catch up, you can see the full version history in the [CHANGELOG](CHANGELOG.md).
|
|
67
|
+
|
|
68
|
+
It exists to let you use features Python doesn't already provide in the typing system — things you probably want, like min, max, length, all_same, and many more.
|
|
69
|
+
|
|
70
|
+
PER now supports runtime validation anywhere using:
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
validate(value: object, rules: Dict[str, typing.Any])
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
This enforces rule dictionaries and returns the original value if valid.
|
|
77
|
+
If invalid, it raises a descriptive error.
|
|
78
|
+
|
|
79
|
+
Why I Made It
|
|
80
|
+
I wanted type‑hint features Python didn’t give me. I thought dictionaries would work until I learned... well...
|
|
81
|
+
Python didn’t enforce them.
|
|
82
|
+
|
|
83
|
+
So with the help of Microsoft Copilot and my dad, we designed a module that enforces this type of stuff.
|
|
84
|
+
Then I realized everyone in the Python community could use this, so it became a project. Hence, creating PER.
|
|
85
|
+
|
|
86
|
+
PER uses rule dictionaries and validate() to enforce constraints at runtime.
|
|
87
|
+
|
|
88
|
+
Install it with pip:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
pip install enforce-rules
|
|
92
|
+
```
|
|
93
|
+
Then use it like:
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
from enforce_rules import validate
|
|
97
|
+
```
|
|
98
|
+
Features
|
|
99
|
+
1. Runtime enforcement of rule dictionaries
|
|
100
|
+
2. Dictionary‑based rule definitions
|
|
101
|
+
3. No extra objects required
|
|
102
|
+
4. Works anywhere in your code
|
|
103
|
+
5. Extensible via must_be_true
|
|
104
|
+
|
|
105
|
+
How It Works
|
|
106
|
+
PER validates values using:
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
validate(value, rules)
|
|
110
|
+
```
|
|
111
|
+
If the value violates a rule, PER raises an error.
|
|
112
|
+
If the value passes, PER returns the original value unchanged.
|
|
113
|
+
|
|
114
|
+
This means validated values behave exactly like normal Python values.
|
|
115
|
+
|
|
116
|
+
Keywords and Usage
|
|
117
|
+
Below are all supported keywords.
|
|
118
|
+
|
|
119
|
+
length
|
|
120
|
+
The length of the object must be exactly this.
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
lst = validate([1, 2, 3, 4, 5], {"length": 5})
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
min_length
|
|
127
|
+
Minimum length (inclusive).
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
lst = validate(['a', 'b', 'c', 'd', 'e'], {"min_length": 3})
|
|
131
|
+
```
|
|
132
|
+
max_length
|
|
133
|
+
Maximum length (inclusive).
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
lst = validate([1, 2, 3, 4, 5, 6], {"max_length": 7})
|
|
137
|
+
```
|
|
138
|
+
min
|
|
139
|
+
Minimum numeric value (inclusive).
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
number = validate(10, {"min": 0})
|
|
143
|
+
```
|
|
144
|
+
max
|
|
145
|
+
Maximum numeric value (inclusive).
|
|
146
|
+
|
|
147
|
+
```python
|
|
148
|
+
number = validate(10, {"max": 20})
|
|
149
|
+
```
|
|
150
|
+
allowed_values
|
|
151
|
+
Similar to Literal; value must be one of the allowed values.
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
val = validate("a", {"allowed_values": ("a", "b", "c", "d")})
|
|
155
|
+
```
|
|
156
|
+
invariant
|
|
157
|
+
Value must be truthy.
|
|
158
|
+
|
|
159
|
+
```python
|
|
160
|
+
val = validate((0 == 0), {"invariant": True})
|
|
161
|
+
```
|
|
162
|
+
all_same
|
|
163
|
+
All values in the collection must be the same.
|
|
164
|
+
|
|
165
|
+
```python
|
|
166
|
+
numbers = validate([1, 1, 1], {"all_same": True})
|
|
167
|
+
```
|
|
168
|
+
all_unique
|
|
169
|
+
All values in the collection must be unique.
|
|
170
|
+
|
|
171
|
+
```python
|
|
172
|
+
numbers = validate([1, 2, 3], {"all_unique": True})
|
|
173
|
+
```
|
|
174
|
+
non_empty
|
|
175
|
+
Collection must not be empty.
|
|
176
|
+
|
|
177
|
+
```python
|
|
178
|
+
my_strings = validate(['a', 'b', 'c'], {"non_empty": True})
|
|
179
|
+
```
|
|
180
|
+
no_nulls
|
|
181
|
+
Collection must not contain None.
|
|
182
|
+
|
|
183
|
+
```python
|
|
184
|
+
my_things = validate([1, 2, 3, "a", "b", "c"], {"no_nulls": True})
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
sorted
|
|
188
|
+
List must be sorted (increasing or decreasing).
|
|
189
|
+
|
|
190
|
+
```python
|
|
191
|
+
numbers = validate([1, 5, 9], {"sorted": True})
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
increasing
|
|
195
|
+
List must be strictly increasing.
|
|
196
|
+
|
|
197
|
+
```python
|
|
198
|
+
numbers = validate([1, 5, 9], {"increasing": True})
|
|
199
|
+
```
|
|
200
|
+
decreasing
|
|
201
|
+
List must be strictly decreasing.
|
|
202
|
+
|
|
203
|
+
```python
|
|
204
|
+
numbers = validate([9, 5, 1], {"decreasing": True})
|
|
205
|
+
```
|
|
206
|
+
sum_min
|
|
207
|
+
Minimum sum of the collection (inclusive).
|
|
208
|
+
|
|
209
|
+
```python
|
|
210
|
+
numbers = validate([10, 20, 30], {"sum_min": 50})
|
|
211
|
+
```
|
|
212
|
+
sum_max
|
|
213
|
+
Maximum sum of the collection (inclusive).
|
|
214
|
+
|
|
215
|
+
```python
|
|
216
|
+
numbers = validate([10, 20, 30], {"sum_max": 70})
|
|
217
|
+
```
|
|
218
|
+
element_min
|
|
219
|
+
Minimum value for any element (inclusive).
|
|
220
|
+
|
|
221
|
+
```python
|
|
222
|
+
numbers = validate([10, 20, 30], {"element_min": 5})
|
|
223
|
+
```
|
|
224
|
+
element_max
|
|
225
|
+
Maximum value for any element (inclusive).
|
|
226
|
+
|
|
227
|
+
```python
|
|
228
|
+
numbers = validate([10, 20, 30], {"element_max": 40})
|
|
229
|
+
```
|
|
230
|
+
regex
|
|
231
|
+
String must match the regex.
|
|
232
|
+
|
|
233
|
+
```python
|
|
234
|
+
cat_or_dog = validate("cat", {"regex": "cat|dog"})
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
must_be_true
|
|
239
|
+
Custom rule: a function that returns True for allowed values.
|
|
240
|
+
|
|
241
|
+
```python
|
|
242
|
+
def is_even(x: int) -> bool:
|
|
243
|
+
return x % 2 == 0
|
|
244
|
+
even_number = validate(8, {"must_be_true": is_even})
|
|
245
|
+
```
|
|
246
|
+
This calls:
|
|
247
|
+
|
|
248
|
+
```python
|
|
249
|
+
is_even(8)
|
|
250
|
+
```
|
|
251
|
+
If enough people use a must_be_true lambda, it may become an added keyword in a later version
|
|
252
|
+
|
|
253
|
+
Contributing
|
|
254
|
+
Contributions are welcome. Please open an issue or pull request.
|
|
255
|
+
When contributing, ensure backwards compatibility (you cannot remove keywords and/or features).
|
|
256
|
+
|
|
257
|
+
Please note, when using my module, that you will manually have to validate each time should you choose to mutate a variable.
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
Versioning Policy
|
|
261
|
+
-----------------
|
|
262
|
+
|
|
263
|
+
This project guarantees full backwards compatibility. Existing rule files, keyword meanings, validator behaviors, and metadata formats will continue to work exactly as before. No update will ever break existing configurations.
|
|
264
|
+
|
|
265
|
+
Version Numbering
|
|
266
|
+
-----------------
|
|
267
|
+
This project uses a non-breaking semantic versioning model:
|
|
268
|
+
|
|
269
|
+
MAJOR.MINOR.PATCH
|
|
270
|
+
|
|
271
|
+
MAJOR = large new feature families
|
|
272
|
+
MINOR = small additive keywords or enhancements
|
|
273
|
+
PATCH = bug fixes or internal improvements
|
|
274
|
+
|
|
275
|
+
Major bumps do not imply breaking changes. They only indicate that a significant new capability has been added.
|
|
276
|
+
|
|
277
|
+
Major bumps always reset MINOR and PATCH to 0. For example:
|
|
278
|
+
1.7.3 -> 2.0.0
|
|
279
|
+
1.12.0 -> 2.0.0
|
|
280
|
+
1.0.0 -> 2.0.0
|
|
281
|
+
|
|
282
|
+
Minor bumps always reset PATCH to 0. For example:
|
|
283
|
+
1.7.3 -> 1.8.0
|
|
284
|
+
1.12.9 -> 1.13.0
|
|
285
|
+
1.0.4 -> 1.1.0
|
|
286
|
+
|
|
287
|
+
Minor Version Bumps
|
|
288
|
+
-------------------
|
|
289
|
+
Minor bumps occur when adding small keywords. Examples include:
|
|
290
|
+
|
|
291
|
+
min_inclusive
|
|
292
|
+
max_inclusive
|
|
293
|
+
trim_whitespace
|
|
294
|
+
pattern_flags
|
|
295
|
+
|
|
296
|
+
These additions do not change the meaning of existing keywords, do not require users to modify rule files, and do not alter validator behavior. They are classified as minor updates.
|
|
297
|
+
|
|
298
|
+
Minor bumps always reset PATCH to 0
|
|
299
|
+
|
|
300
|
+
Major Version Bumps
|
|
301
|
+
-------------------
|
|
302
|
+
Major bumps occur only when adding large new keyword families or entire new capability domains. Examples include:
|
|
303
|
+
|
|
304
|
+
a full date-validation system
|
|
305
|
+
a full numeric-range system
|
|
306
|
+
a full conditional-rules system
|
|
307
|
+
a full schema-level rule system
|
|
308
|
+
|
|
309
|
+
These are major features, even though they remain fully backwards-compatible. Major bumps communicate that the release adds a significant new capability.
|
|
310
|
+
|
|
311
|
+
Major bumps always reset MINOR and PATCH to 0.
|
|
312
|
+
|
|
313
|
+
Patch Version Bumps
|
|
314
|
+
-------------------
|
|
315
|
+
Patch bumps occur when fixing bugs, improving internal logic, optimizing performance, correcting documentation, or adjusting error messages without changing meaning. Patch updates never add new keywords.
|
|
316
|
+
|
|
317
|
+
Summary
|
|
318
|
+
-------
|
|
319
|
+
MAJOR: large new feature families, backwards-compatible, resets MINOR and PATCH to 0
|
|
320
|
+
MINOR: small additive keywords, backwards-compatible
|
|
321
|
+
PATCH: fixes only, backwards-compatible
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
Credits
|
|
326
|
+
Microsoft Copilot — for helping me code it. It did a TON of the coding, and to be honest, I couldn't have made this project without it. It did ~~some~~ a lot of readme, in addition to it writing most of CONTRIBUTING.md
|
|
327
|
+
Dad — for helping me along the journey.
|
|
328
|
+
|
|
329
|
+
License Type: BSD 3-clause
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
Project Enforce Rules
|
|
2
|
+
Project Enforce Rules (or PER for short) expands the Python type system to allow more constraints.
|
|
3
|
+
|
|
4
|
+
Version #
|
|
5
|
+
----------------
|
|
6
|
+
MAJOR: 1
|
|
7
|
+
MINOR: 0
|
|
8
|
+
PATCH: 0
|
|
9
|
+
|
|
10
|
+
If you need to catch up, you can see the full version history in the [CHANGELOG](CHANGELOG.md).
|
|
11
|
+
|
|
12
|
+
It exists to let you use features Python doesn't already provide in the typing system — things you probably want, like min, max, length, all_same, and many more.
|
|
13
|
+
|
|
14
|
+
PER now supports runtime validation anywhere using:
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
validate(value: object, rules: Dict[str, typing.Any])
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
This enforces rule dictionaries and returns the original value if valid.
|
|
21
|
+
If invalid, it raises a descriptive error.
|
|
22
|
+
|
|
23
|
+
Why I Made It
|
|
24
|
+
I wanted type‑hint features Python didn’t give me. I thought dictionaries would work until I learned... well...
|
|
25
|
+
Python didn’t enforce them.
|
|
26
|
+
|
|
27
|
+
So with the help of Microsoft Copilot and my dad, we designed a module that enforces this type of stuff.
|
|
28
|
+
Then I realized everyone in the Python community could use this, so it became a project. Hence, creating PER.
|
|
29
|
+
|
|
30
|
+
PER uses rule dictionaries and validate() to enforce constraints at runtime.
|
|
31
|
+
|
|
32
|
+
Install it with pip:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install enforce-rules
|
|
36
|
+
```
|
|
37
|
+
Then use it like:
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from enforce_rules import validate
|
|
41
|
+
```
|
|
42
|
+
Features
|
|
43
|
+
1. Runtime enforcement of rule dictionaries
|
|
44
|
+
2. Dictionary‑based rule definitions
|
|
45
|
+
3. No extra objects required
|
|
46
|
+
4. Works anywhere in your code
|
|
47
|
+
5. Extensible via must_be_true
|
|
48
|
+
|
|
49
|
+
How It Works
|
|
50
|
+
PER validates values using:
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
validate(value, rules)
|
|
54
|
+
```
|
|
55
|
+
If the value violates a rule, PER raises an error.
|
|
56
|
+
If the value passes, PER returns the original value unchanged.
|
|
57
|
+
|
|
58
|
+
This means validated values behave exactly like normal Python values.
|
|
59
|
+
|
|
60
|
+
Keywords and Usage
|
|
61
|
+
Below are all supported keywords.
|
|
62
|
+
|
|
63
|
+
length
|
|
64
|
+
The length of the object must be exactly this.
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
lst = validate([1, 2, 3, 4, 5], {"length": 5})
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
min_length
|
|
71
|
+
Minimum length (inclusive).
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
lst = validate(['a', 'b', 'c', 'd', 'e'], {"min_length": 3})
|
|
75
|
+
```
|
|
76
|
+
max_length
|
|
77
|
+
Maximum length (inclusive).
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
lst = validate([1, 2, 3, 4, 5, 6], {"max_length": 7})
|
|
81
|
+
```
|
|
82
|
+
min
|
|
83
|
+
Minimum numeric value (inclusive).
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
number = validate(10, {"min": 0})
|
|
87
|
+
```
|
|
88
|
+
max
|
|
89
|
+
Maximum numeric value (inclusive).
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
number = validate(10, {"max": 20})
|
|
93
|
+
```
|
|
94
|
+
allowed_values
|
|
95
|
+
Similar to Literal; value must be one of the allowed values.
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
val = validate("a", {"allowed_values": ("a", "b", "c", "d")})
|
|
99
|
+
```
|
|
100
|
+
invariant
|
|
101
|
+
Value must be truthy.
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
val = validate((0 == 0), {"invariant": True})
|
|
105
|
+
```
|
|
106
|
+
all_same
|
|
107
|
+
All values in the collection must be the same.
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
numbers = validate([1, 1, 1], {"all_same": True})
|
|
111
|
+
```
|
|
112
|
+
all_unique
|
|
113
|
+
All values in the collection must be unique.
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
numbers = validate([1, 2, 3], {"all_unique": True})
|
|
117
|
+
```
|
|
118
|
+
non_empty
|
|
119
|
+
Collection must not be empty.
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
my_strings = validate(['a', 'b', 'c'], {"non_empty": True})
|
|
123
|
+
```
|
|
124
|
+
no_nulls
|
|
125
|
+
Collection must not contain None.
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
my_things = validate([1, 2, 3, "a", "b", "c"], {"no_nulls": True})
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
sorted
|
|
132
|
+
List must be sorted (increasing or decreasing).
|
|
133
|
+
|
|
134
|
+
```python
|
|
135
|
+
numbers = validate([1, 5, 9], {"sorted": True})
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
increasing
|
|
139
|
+
List must be strictly increasing.
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
numbers = validate([1, 5, 9], {"increasing": True})
|
|
143
|
+
```
|
|
144
|
+
decreasing
|
|
145
|
+
List must be strictly decreasing.
|
|
146
|
+
|
|
147
|
+
```python
|
|
148
|
+
numbers = validate([9, 5, 1], {"decreasing": True})
|
|
149
|
+
```
|
|
150
|
+
sum_min
|
|
151
|
+
Minimum sum of the collection (inclusive).
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
numbers = validate([10, 20, 30], {"sum_min": 50})
|
|
155
|
+
```
|
|
156
|
+
sum_max
|
|
157
|
+
Maximum sum of the collection (inclusive).
|
|
158
|
+
|
|
159
|
+
```python
|
|
160
|
+
numbers = validate([10, 20, 30], {"sum_max": 70})
|
|
161
|
+
```
|
|
162
|
+
element_min
|
|
163
|
+
Minimum value for any element (inclusive).
|
|
164
|
+
|
|
165
|
+
```python
|
|
166
|
+
numbers = validate([10, 20, 30], {"element_min": 5})
|
|
167
|
+
```
|
|
168
|
+
element_max
|
|
169
|
+
Maximum value for any element (inclusive).
|
|
170
|
+
|
|
171
|
+
```python
|
|
172
|
+
numbers = validate([10, 20, 30], {"element_max": 40})
|
|
173
|
+
```
|
|
174
|
+
regex
|
|
175
|
+
String must match the regex.
|
|
176
|
+
|
|
177
|
+
```python
|
|
178
|
+
cat_or_dog = validate("cat", {"regex": "cat|dog"})
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
must_be_true
|
|
183
|
+
Custom rule: a function that returns True for allowed values.
|
|
184
|
+
|
|
185
|
+
```python
|
|
186
|
+
def is_even(x: int) -> bool:
|
|
187
|
+
return x % 2 == 0
|
|
188
|
+
even_number = validate(8, {"must_be_true": is_even})
|
|
189
|
+
```
|
|
190
|
+
This calls:
|
|
191
|
+
|
|
192
|
+
```python
|
|
193
|
+
is_even(8)
|
|
194
|
+
```
|
|
195
|
+
If enough people use a must_be_true lambda, it may become an added keyword in a later version
|
|
196
|
+
|
|
197
|
+
Contributing
|
|
198
|
+
Contributions are welcome. Please open an issue or pull request.
|
|
199
|
+
When contributing, ensure backwards compatibility (you cannot remove keywords and/or features).
|
|
200
|
+
|
|
201
|
+
Please note, when using my module, that you will manually have to validate each time should you choose to mutate a variable.
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
Versioning Policy
|
|
205
|
+
-----------------
|
|
206
|
+
|
|
207
|
+
This project guarantees full backwards compatibility. Existing rule files, keyword meanings, validator behaviors, and metadata formats will continue to work exactly as before. No update will ever break existing configurations.
|
|
208
|
+
|
|
209
|
+
Version Numbering
|
|
210
|
+
-----------------
|
|
211
|
+
This project uses a non-breaking semantic versioning model:
|
|
212
|
+
|
|
213
|
+
MAJOR.MINOR.PATCH
|
|
214
|
+
|
|
215
|
+
MAJOR = large new feature families
|
|
216
|
+
MINOR = small additive keywords or enhancements
|
|
217
|
+
PATCH = bug fixes or internal improvements
|
|
218
|
+
|
|
219
|
+
Major bumps do not imply breaking changes. They only indicate that a significant new capability has been added.
|
|
220
|
+
|
|
221
|
+
Major bumps always reset MINOR and PATCH to 0. For example:
|
|
222
|
+
1.7.3 -> 2.0.0
|
|
223
|
+
1.12.0 -> 2.0.0
|
|
224
|
+
1.0.0 -> 2.0.0
|
|
225
|
+
|
|
226
|
+
Minor bumps always reset PATCH to 0. For example:
|
|
227
|
+
1.7.3 -> 1.8.0
|
|
228
|
+
1.12.9 -> 1.13.0
|
|
229
|
+
1.0.4 -> 1.1.0
|
|
230
|
+
|
|
231
|
+
Minor Version Bumps
|
|
232
|
+
-------------------
|
|
233
|
+
Minor bumps occur when adding small keywords. Examples include:
|
|
234
|
+
|
|
235
|
+
min_inclusive
|
|
236
|
+
max_inclusive
|
|
237
|
+
trim_whitespace
|
|
238
|
+
pattern_flags
|
|
239
|
+
|
|
240
|
+
These additions do not change the meaning of existing keywords, do not require users to modify rule files, and do not alter validator behavior. They are classified as minor updates.
|
|
241
|
+
|
|
242
|
+
Minor bumps always reset PATCH to 0
|
|
243
|
+
|
|
244
|
+
Major Version Bumps
|
|
245
|
+
-------------------
|
|
246
|
+
Major bumps occur only when adding large new keyword families or entire new capability domains. Examples include:
|
|
247
|
+
|
|
248
|
+
a full date-validation system
|
|
249
|
+
a full numeric-range system
|
|
250
|
+
a full conditional-rules system
|
|
251
|
+
a full schema-level rule system
|
|
252
|
+
|
|
253
|
+
These are major features, even though they remain fully backwards-compatible. Major bumps communicate that the release adds a significant new capability.
|
|
254
|
+
|
|
255
|
+
Major bumps always reset MINOR and PATCH to 0.
|
|
256
|
+
|
|
257
|
+
Patch Version Bumps
|
|
258
|
+
-------------------
|
|
259
|
+
Patch bumps occur when fixing bugs, improving internal logic, optimizing performance, correcting documentation, or adjusting error messages without changing meaning. Patch updates never add new keywords.
|
|
260
|
+
|
|
261
|
+
Summary
|
|
262
|
+
-------
|
|
263
|
+
MAJOR: large new feature families, backwards-compatible, resets MINOR and PATCH to 0
|
|
264
|
+
MINOR: small additive keywords, backwards-compatible
|
|
265
|
+
PATCH: fixes only, backwards-compatible
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
Credits
|
|
270
|
+
Microsoft Copilot — for helping me code it. It did a TON of the coding, and to be honest, I couldn't have made this project without it. It did ~~some~~ a lot of readme, in addition to it writing most of CONTRIBUTING.md
|
|
271
|
+
Dad — for helping me along the journey.
|
|
272
|
+
|
|
273
|
+
License Type: BSD 3-clause
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "enforce-rules"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "Runtime rule validation for Python using dictionary-based constraints."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = { file = "LICENSE" }
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "Jake Loepker" },
|
|
10
|
+
{ name = "Microsoft Copilot (AI Assistant)" }
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 5 - Production/Stable",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"License :: OSI Approved :: BSD License",
|
|
17
|
+
"Programming Language :: Python",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
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
|
+
"Topic :: Software Development",
|
|
24
|
+
"Topic :: Software Development :: Libraries",
|
|
25
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
26
|
+
"Topic :: Utilities",
|
|
27
|
+
"Typing :: Typed"
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://github.com/Loepker-James/enforce-rules"
|
|
32
|
+
Issues = "https://github.com/Loepker-James/enforce-rules/issues"
|
|
33
|
+
Copilot = "https://copilot.microsoft.com"
|
|
34
|
+
|
|
35
|
+
[build-system]
|
|
36
|
+
requires = ["setuptools>=61.0", "wheel"]
|
|
37
|
+
build-backend = "setuptools.build_meta"
|
|
38
|
+
|
|
39
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from . import enforce_rules
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
from typing import Any, Dict, Iterable, Callable, Sequence
|
|
2
|
+
import re
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
# -----------------------------
|
|
6
|
+
# VALIDATOR FUNCTIONS (DEFINED FIRST)
|
|
7
|
+
# -----------------------------
|
|
8
|
+
|
|
9
|
+
def _validate_length(value: Sequence[Any], expected: int) -> None:
|
|
10
|
+
if len(value) != expected:
|
|
11
|
+
raise ValueError(f"Expected length {expected}, got {len(value)}")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _validate_min_length(value: Sequence[Any], expected: int) -> None:
|
|
15
|
+
if len(value) < expected:
|
|
16
|
+
raise ValueError(f"Expected min length {expected}, got {len(value)}")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _validate_max_length(value: Sequence[Any], expected: int) -> None:
|
|
20
|
+
if len(value) > expected:
|
|
21
|
+
raise ValueError(f"Expected max length {expected}, got {len(value)}")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _validate_min(value: float | int, expected: float | int) -> None:
|
|
25
|
+
if value < expected:
|
|
26
|
+
raise ValueError(f"Expected value >= {expected}, got {value}")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _validate_max(value: float | int, expected: float | int) -> None:
|
|
30
|
+
if value > expected:
|
|
31
|
+
raise ValueError(f"Expected value <= {expected}, got {value}")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _validate_allowed_values(value: Any, allowed: Iterable[Any]) -> None:
|
|
35
|
+
if value not in allowed:
|
|
36
|
+
raise ValueError(f"Value {value} not in allowed values {allowed}")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _validate_invariant(value: Any, expected: bool) -> None:
|
|
40
|
+
if expected and not value:
|
|
41
|
+
raise ValueError("Invariant rule failed: value must be truthy")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _validate_all_same(value: Sequence[Any], expected: bool) -> None:
|
|
45
|
+
if expected and len(set(value)) != 1:
|
|
46
|
+
raise ValueError("All elements must be the same")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _validate_all_unique(value: Sequence[Any], expected: bool) -> None:
|
|
50
|
+
if expected and len(set(value)) != len(value):
|
|
51
|
+
raise ValueError("All elements must be unique")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _validate_non_empty(value: Sequence[Any], expected: bool) -> None:
|
|
55
|
+
if expected and len(value) == 0:
|
|
56
|
+
raise ValueError("Collection must not be empty")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _validate_no_nulls(value: Iterable[Any], expected: bool) -> None:
|
|
60
|
+
if expected and any(v is None for v in value):
|
|
61
|
+
raise ValueError("Collection must not contain None")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _validate_sorted(value: Sequence[Any], expected: bool) -> None:
|
|
65
|
+
if expected:
|
|
66
|
+
if value != sorted(value) and value != sorted(value, reverse=True):
|
|
67
|
+
raise ValueError("List must be sorted increasing or decreasing")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _validate_increasing(value: Sequence[Any], expected: bool) -> None:
|
|
71
|
+
if expected:
|
|
72
|
+
for a, b in zip(value, value[1:]):
|
|
73
|
+
if not (b > a):
|
|
74
|
+
raise ValueError("List must be strictly increasing")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _validate_decreasing(value: Sequence[Any], expected: bool) -> None:
|
|
78
|
+
if expected:
|
|
79
|
+
for a, b in zip(value, value[1:]):
|
|
80
|
+
if not (b < a):
|
|
81
|
+
raise ValueError("List must be strictly decreasing")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _validate_sum_min(value: Iterable[float | int], expected: float | int) -> None:
|
|
85
|
+
total = sum(value)
|
|
86
|
+
if total < expected:
|
|
87
|
+
raise ValueError(f"Sum must be >= {expected}, got {total}")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _validate_sum_max(value: Iterable[float | int], expected: float | int) -> None:
|
|
91
|
+
total = sum(value)
|
|
92
|
+
if total > expected:
|
|
93
|
+
raise ValueError(f"Sum must be <= {expected}, got {total}")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _validate_element_min(value: Iterable[float | int], expected: float | int) -> None:
|
|
97
|
+
if min(value) < expected:
|
|
98
|
+
raise ValueError(f"Elements must be >= {expected}")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _validate_element_max(value: Iterable[float | int], expected: float | int) -> None:
|
|
102
|
+
if max(value) > expected:
|
|
103
|
+
raise ValueError(f"Elements must be <= {expected}")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _validate_regex(value: str, pattern: str, flags: Any) -> None:
|
|
107
|
+
compiled = re.compile(pattern, flags or 0)
|
|
108
|
+
if not compiled.fullmatch(value):
|
|
109
|
+
raise ValueError(f"Value '{value}' does not match regex '{pattern}'")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _validate_must_be_true(value: Any, func: Callable[[Any], bool]) -> None:
|
|
113
|
+
if not func(value):
|
|
114
|
+
raise ValueError("must_be_true rule failed")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# -----------------------------
|
|
118
|
+
# VALIDATE() — DEFINED LAST
|
|
119
|
+
# -----------------------------
|
|
120
|
+
|
|
121
|
+
def validate(value: Any, rules: Dict[str, Any]) -> Any:
|
|
122
|
+
"""
|
|
123
|
+
Validate a value against a dictionary of rules.
|
|
124
|
+
Returns the original value if valid, otherwise raises ValueError.
|
|
125
|
+
"""
|
|
126
|
+
for key, rule in rules.items():
|
|
127
|
+
match key:
|
|
128
|
+
case "length":
|
|
129
|
+
_validate_length(value, int(rule))
|
|
130
|
+
case "min_length":
|
|
131
|
+
_validate_min_length(value, int(rule))
|
|
132
|
+
case "max_length":
|
|
133
|
+
_validate_max_length(value, int(rule))
|
|
134
|
+
case "min":
|
|
135
|
+
_validate_min(value, rule)
|
|
136
|
+
case "max":
|
|
137
|
+
_validate_max(value, rule)
|
|
138
|
+
case "allowed_values":
|
|
139
|
+
_validate_allowed_values(value, rule)
|
|
140
|
+
case "invariant":
|
|
141
|
+
_validate_invariant(value, bool(rule))
|
|
142
|
+
case "all_same":
|
|
143
|
+
_validate_all_same(value, bool(rule))
|
|
144
|
+
case "all_unique":
|
|
145
|
+
_validate_all_unique(value, bool(rule))
|
|
146
|
+
case "non_empty":
|
|
147
|
+
_validate_non_empty(value, bool(rule))
|
|
148
|
+
case "no_nulls":
|
|
149
|
+
_validate_no_nulls(value, bool(rule))
|
|
150
|
+
case "sorted":
|
|
151
|
+
_validate_sorted(value, bool(rule))
|
|
152
|
+
case "increasing":
|
|
153
|
+
_validate_increasing(value, bool(rule))
|
|
154
|
+
case "decreasing":
|
|
155
|
+
_validate_decreasing(value, bool(rule))
|
|
156
|
+
case "sum_min":
|
|
157
|
+
_validate_sum_min(value, rule)
|
|
158
|
+
case "sum_max":
|
|
159
|
+
_validate_sum_max(value, rule)
|
|
160
|
+
case "element_min":
|
|
161
|
+
_validate_element_min(value, rule)
|
|
162
|
+
case "element_max":
|
|
163
|
+
_validate_element_max(value, rule)
|
|
164
|
+
case "regex":
|
|
165
|
+
_validate_regex(value, str(rule), rules.get("regex_flags"))
|
|
166
|
+
case "must_be_true":
|
|
167
|
+
_validate_must_be_true(value, rule)
|
|
168
|
+
case _:
|
|
169
|
+
raise ValueError(f"Unknown rule: {key}")
|
|
170
|
+
|
|
171
|
+
return value
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: enforce-rules
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Runtime rule validation for Python using dictionary-based constraints.
|
|
5
|
+
Author: Jake Loepker, Microsoft Copilot (AI Assistant)
|
|
6
|
+
License: BSD 3-Clause License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026, Loepker-James
|
|
9
|
+
|
|
10
|
+
Redistribution and use in source and binary forms, with or without
|
|
11
|
+
modification, are permitted provided that the following conditions are met:
|
|
12
|
+
|
|
13
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
14
|
+
list of conditions and the following disclaimer.
|
|
15
|
+
|
|
16
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
17
|
+
this list of conditions and the following disclaimer in the documentation
|
|
18
|
+
and/or other materials provided with the distribution.
|
|
19
|
+
|
|
20
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
21
|
+
contributors may be used to endorse or promote products derived from
|
|
22
|
+
this software without specific prior written permission.
|
|
23
|
+
|
|
24
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
25
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
26
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
27
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
28
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
29
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
30
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
31
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
32
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
33
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
34
|
+
|
|
35
|
+
Project-URL: Homepage, https://github.com/Loepker-James/enforce-rules
|
|
36
|
+
Project-URL: Issues, https://github.com/Loepker-James/enforce-rules/issues
|
|
37
|
+
Project-URL: Copilot, https://copilot.microsoft.com
|
|
38
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
39
|
+
Classifier: Intended Audience :: Developers
|
|
40
|
+
Classifier: License :: OSI Approved :: BSD License
|
|
41
|
+
Classifier: Programming Language :: Python
|
|
42
|
+
Classifier: Programming Language :: Python :: 3
|
|
43
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
44
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
45
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
46
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
47
|
+
Classifier: Topic :: Software Development
|
|
48
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
49
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
50
|
+
Classifier: Topic :: Utilities
|
|
51
|
+
Classifier: Typing :: Typed
|
|
52
|
+
Requires-Python: >=3.10
|
|
53
|
+
Description-Content-Type: text/markdown
|
|
54
|
+
License-File: LICENSE
|
|
55
|
+
Dynamic: license-file
|
|
56
|
+
|
|
57
|
+
Project Enforce Rules
|
|
58
|
+
Project Enforce Rules (or PER for short) expands the Python type system to allow more constraints.
|
|
59
|
+
|
|
60
|
+
Version #
|
|
61
|
+
----------------
|
|
62
|
+
MAJOR: 1
|
|
63
|
+
MINOR: 0
|
|
64
|
+
PATCH: 0
|
|
65
|
+
|
|
66
|
+
If you need to catch up, you can see the full version history in the [CHANGELOG](CHANGELOG.md).
|
|
67
|
+
|
|
68
|
+
It exists to let you use features Python doesn't already provide in the typing system — things you probably want, like min, max, length, all_same, and many more.
|
|
69
|
+
|
|
70
|
+
PER now supports runtime validation anywhere using:
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
validate(value: object, rules: Dict[str, typing.Any])
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
This enforces rule dictionaries and returns the original value if valid.
|
|
77
|
+
If invalid, it raises a descriptive error.
|
|
78
|
+
|
|
79
|
+
Why I Made It
|
|
80
|
+
I wanted type‑hint features Python didn’t give me. I thought dictionaries would work until I learned... well...
|
|
81
|
+
Python didn’t enforce them.
|
|
82
|
+
|
|
83
|
+
So with the help of Microsoft Copilot and my dad, we designed a module that enforces this type of stuff.
|
|
84
|
+
Then I realized everyone in the Python community could use this, so it became a project. Hence, creating PER.
|
|
85
|
+
|
|
86
|
+
PER uses rule dictionaries and validate() to enforce constraints at runtime.
|
|
87
|
+
|
|
88
|
+
Install it with pip:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
pip install enforce-rules
|
|
92
|
+
```
|
|
93
|
+
Then use it like:
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
from enforce_rules import validate
|
|
97
|
+
```
|
|
98
|
+
Features
|
|
99
|
+
1. Runtime enforcement of rule dictionaries
|
|
100
|
+
2. Dictionary‑based rule definitions
|
|
101
|
+
3. No extra objects required
|
|
102
|
+
4. Works anywhere in your code
|
|
103
|
+
5. Extensible via must_be_true
|
|
104
|
+
|
|
105
|
+
How It Works
|
|
106
|
+
PER validates values using:
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
validate(value, rules)
|
|
110
|
+
```
|
|
111
|
+
If the value violates a rule, PER raises an error.
|
|
112
|
+
If the value passes, PER returns the original value unchanged.
|
|
113
|
+
|
|
114
|
+
This means validated values behave exactly like normal Python values.
|
|
115
|
+
|
|
116
|
+
Keywords and Usage
|
|
117
|
+
Below are all supported keywords.
|
|
118
|
+
|
|
119
|
+
length
|
|
120
|
+
The length of the object must be exactly this.
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
lst = validate([1, 2, 3, 4, 5], {"length": 5})
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
min_length
|
|
127
|
+
Minimum length (inclusive).
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
lst = validate(['a', 'b', 'c', 'd', 'e'], {"min_length": 3})
|
|
131
|
+
```
|
|
132
|
+
max_length
|
|
133
|
+
Maximum length (inclusive).
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
lst = validate([1, 2, 3, 4, 5, 6], {"max_length": 7})
|
|
137
|
+
```
|
|
138
|
+
min
|
|
139
|
+
Minimum numeric value (inclusive).
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
number = validate(10, {"min": 0})
|
|
143
|
+
```
|
|
144
|
+
max
|
|
145
|
+
Maximum numeric value (inclusive).
|
|
146
|
+
|
|
147
|
+
```python
|
|
148
|
+
number = validate(10, {"max": 20})
|
|
149
|
+
```
|
|
150
|
+
allowed_values
|
|
151
|
+
Similar to Literal; value must be one of the allowed values.
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
val = validate("a", {"allowed_values": ("a", "b", "c", "d")})
|
|
155
|
+
```
|
|
156
|
+
invariant
|
|
157
|
+
Value must be truthy.
|
|
158
|
+
|
|
159
|
+
```python
|
|
160
|
+
val = validate((0 == 0), {"invariant": True})
|
|
161
|
+
```
|
|
162
|
+
all_same
|
|
163
|
+
All values in the collection must be the same.
|
|
164
|
+
|
|
165
|
+
```python
|
|
166
|
+
numbers = validate([1, 1, 1], {"all_same": True})
|
|
167
|
+
```
|
|
168
|
+
all_unique
|
|
169
|
+
All values in the collection must be unique.
|
|
170
|
+
|
|
171
|
+
```python
|
|
172
|
+
numbers = validate([1, 2, 3], {"all_unique": True})
|
|
173
|
+
```
|
|
174
|
+
non_empty
|
|
175
|
+
Collection must not be empty.
|
|
176
|
+
|
|
177
|
+
```python
|
|
178
|
+
my_strings = validate(['a', 'b', 'c'], {"non_empty": True})
|
|
179
|
+
```
|
|
180
|
+
no_nulls
|
|
181
|
+
Collection must not contain None.
|
|
182
|
+
|
|
183
|
+
```python
|
|
184
|
+
my_things = validate([1, 2, 3, "a", "b", "c"], {"no_nulls": True})
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
sorted
|
|
188
|
+
List must be sorted (increasing or decreasing).
|
|
189
|
+
|
|
190
|
+
```python
|
|
191
|
+
numbers = validate([1, 5, 9], {"sorted": True})
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
increasing
|
|
195
|
+
List must be strictly increasing.
|
|
196
|
+
|
|
197
|
+
```python
|
|
198
|
+
numbers = validate([1, 5, 9], {"increasing": True})
|
|
199
|
+
```
|
|
200
|
+
decreasing
|
|
201
|
+
List must be strictly decreasing.
|
|
202
|
+
|
|
203
|
+
```python
|
|
204
|
+
numbers = validate([9, 5, 1], {"decreasing": True})
|
|
205
|
+
```
|
|
206
|
+
sum_min
|
|
207
|
+
Minimum sum of the collection (inclusive).
|
|
208
|
+
|
|
209
|
+
```python
|
|
210
|
+
numbers = validate([10, 20, 30], {"sum_min": 50})
|
|
211
|
+
```
|
|
212
|
+
sum_max
|
|
213
|
+
Maximum sum of the collection (inclusive).
|
|
214
|
+
|
|
215
|
+
```python
|
|
216
|
+
numbers = validate([10, 20, 30], {"sum_max": 70})
|
|
217
|
+
```
|
|
218
|
+
element_min
|
|
219
|
+
Minimum value for any element (inclusive).
|
|
220
|
+
|
|
221
|
+
```python
|
|
222
|
+
numbers = validate([10, 20, 30], {"element_min": 5})
|
|
223
|
+
```
|
|
224
|
+
element_max
|
|
225
|
+
Maximum value for any element (inclusive).
|
|
226
|
+
|
|
227
|
+
```python
|
|
228
|
+
numbers = validate([10, 20, 30], {"element_max": 40})
|
|
229
|
+
```
|
|
230
|
+
regex
|
|
231
|
+
String must match the regex.
|
|
232
|
+
|
|
233
|
+
```python
|
|
234
|
+
cat_or_dog = validate("cat", {"regex": "cat|dog"})
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
must_be_true
|
|
239
|
+
Custom rule: a function that returns True for allowed values.
|
|
240
|
+
|
|
241
|
+
```python
|
|
242
|
+
def is_even(x: int) -> bool:
|
|
243
|
+
return x % 2 == 0
|
|
244
|
+
even_number = validate(8, {"must_be_true": is_even})
|
|
245
|
+
```
|
|
246
|
+
This calls:
|
|
247
|
+
|
|
248
|
+
```python
|
|
249
|
+
is_even(8)
|
|
250
|
+
```
|
|
251
|
+
If enough people use a must_be_true lambda, it may become an added keyword in a later version
|
|
252
|
+
|
|
253
|
+
Contributing
|
|
254
|
+
Contributions are welcome. Please open an issue or pull request.
|
|
255
|
+
When contributing, ensure backwards compatibility (you cannot remove keywords and/or features).
|
|
256
|
+
|
|
257
|
+
Please note, when using my module, that you will manually have to validate each time should you choose to mutate a variable.
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
Versioning Policy
|
|
261
|
+
-----------------
|
|
262
|
+
|
|
263
|
+
This project guarantees full backwards compatibility. Existing rule files, keyword meanings, validator behaviors, and metadata formats will continue to work exactly as before. No update will ever break existing configurations.
|
|
264
|
+
|
|
265
|
+
Version Numbering
|
|
266
|
+
-----------------
|
|
267
|
+
This project uses a non-breaking semantic versioning model:
|
|
268
|
+
|
|
269
|
+
MAJOR.MINOR.PATCH
|
|
270
|
+
|
|
271
|
+
MAJOR = large new feature families
|
|
272
|
+
MINOR = small additive keywords or enhancements
|
|
273
|
+
PATCH = bug fixes or internal improvements
|
|
274
|
+
|
|
275
|
+
Major bumps do not imply breaking changes. They only indicate that a significant new capability has been added.
|
|
276
|
+
|
|
277
|
+
Major bumps always reset MINOR and PATCH to 0. For example:
|
|
278
|
+
1.7.3 -> 2.0.0
|
|
279
|
+
1.12.0 -> 2.0.0
|
|
280
|
+
1.0.0 -> 2.0.0
|
|
281
|
+
|
|
282
|
+
Minor bumps always reset PATCH to 0. For example:
|
|
283
|
+
1.7.3 -> 1.8.0
|
|
284
|
+
1.12.9 -> 1.13.0
|
|
285
|
+
1.0.4 -> 1.1.0
|
|
286
|
+
|
|
287
|
+
Minor Version Bumps
|
|
288
|
+
-------------------
|
|
289
|
+
Minor bumps occur when adding small keywords. Examples include:
|
|
290
|
+
|
|
291
|
+
min_inclusive
|
|
292
|
+
max_inclusive
|
|
293
|
+
trim_whitespace
|
|
294
|
+
pattern_flags
|
|
295
|
+
|
|
296
|
+
These additions do not change the meaning of existing keywords, do not require users to modify rule files, and do not alter validator behavior. They are classified as minor updates.
|
|
297
|
+
|
|
298
|
+
Minor bumps always reset PATCH to 0
|
|
299
|
+
|
|
300
|
+
Major Version Bumps
|
|
301
|
+
-------------------
|
|
302
|
+
Major bumps occur only when adding large new keyword families or entire new capability domains. Examples include:
|
|
303
|
+
|
|
304
|
+
a full date-validation system
|
|
305
|
+
a full numeric-range system
|
|
306
|
+
a full conditional-rules system
|
|
307
|
+
a full schema-level rule system
|
|
308
|
+
|
|
309
|
+
These are major features, even though they remain fully backwards-compatible. Major bumps communicate that the release adds a significant new capability.
|
|
310
|
+
|
|
311
|
+
Major bumps always reset MINOR and PATCH to 0.
|
|
312
|
+
|
|
313
|
+
Patch Version Bumps
|
|
314
|
+
-------------------
|
|
315
|
+
Patch bumps occur when fixing bugs, improving internal logic, optimizing performance, correcting documentation, or adjusting error messages without changing meaning. Patch updates never add new keywords.
|
|
316
|
+
|
|
317
|
+
Summary
|
|
318
|
+
-------
|
|
319
|
+
MAJOR: large new feature families, backwards-compatible, resets MINOR and PATCH to 0
|
|
320
|
+
MINOR: small additive keywords, backwards-compatible
|
|
321
|
+
PATCH: fixes only, backwards-compatible
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
Credits
|
|
326
|
+
Microsoft Copilot — for helping me code it. It did a TON of the coding, and to be honest, I couldn't have made this project without it. It did ~~some~~ a lot of readme, in addition to it writing most of CONTRIBUTING.md
|
|
327
|
+
Dad — for helping me along the journey.
|
|
328
|
+
|
|
329
|
+
License Type: BSD 3-clause
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/enforce_rules/__init__.py
|
|
5
|
+
src/enforce_rules/enforce_rules.py
|
|
6
|
+
src/enforce_rules.egg-info/PKG-INFO
|
|
7
|
+
src/enforce_rules.egg-info/SOURCES.txt
|
|
8
|
+
src/enforce_rules.egg-info/dependency_links.txt
|
|
9
|
+
src/enforce_rules.egg-info/top_level.txt
|
|
10
|
+
tests/test_enforce_rules.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
enforce_rules
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
from typing import Any, Dict
|
|
3
|
+
from enforce_rules import validate
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TestValidate(unittest.TestCase):
|
|
7
|
+
|
|
8
|
+
# -----------------------------
|
|
9
|
+
# LENGTH RULES
|
|
10
|
+
# -----------------------------
|
|
11
|
+
def test_length_valid(self):
|
|
12
|
+
self.assertEqual(validate([1, 2, 3], {"length": 3}), [1, 2, 3])
|
|
13
|
+
|
|
14
|
+
def test_length_invalid(self):
|
|
15
|
+
with self.assertRaises(ValueError):
|
|
16
|
+
validate([1, 2], {"length": 3})
|
|
17
|
+
|
|
18
|
+
def test_min_length_valid(self):
|
|
19
|
+
self.assertEqual(validate([1, 2, 3], {"min_length": 2}), [1, 2, 3])
|
|
20
|
+
|
|
21
|
+
def test_min_length_invalid(self):
|
|
22
|
+
with self.assertRaises(ValueError):
|
|
23
|
+
validate([1], {"min_length": 2})
|
|
24
|
+
|
|
25
|
+
def test_max_length_valid(self):
|
|
26
|
+
self.assertEqual(validate([1, 2], {"max_length": 3}), [1, 2])
|
|
27
|
+
|
|
28
|
+
def test_max_length_invalid(self):
|
|
29
|
+
with self.assertRaises(ValueError):
|
|
30
|
+
validate([1, 2, 3, 4], {"max_length": 3})
|
|
31
|
+
|
|
32
|
+
# -----------------------------
|
|
33
|
+
# MIN / MAX
|
|
34
|
+
# -----------------------------
|
|
35
|
+
def test_min_valid(self):
|
|
36
|
+
self.assertEqual(validate(5, {"min": 3}), 5)
|
|
37
|
+
|
|
38
|
+
def test_min_invalid(self):
|
|
39
|
+
with self.assertRaises(ValueError):
|
|
40
|
+
validate(2, {"min": 3})
|
|
41
|
+
|
|
42
|
+
def test_max_valid(self):
|
|
43
|
+
self.assertEqual(validate(5, {"max": 10}), 5)
|
|
44
|
+
|
|
45
|
+
def test_max_invalid(self):
|
|
46
|
+
with self.assertRaises(ValueError):
|
|
47
|
+
validate(20, {"max": 10})
|
|
48
|
+
|
|
49
|
+
# -----------------------------
|
|
50
|
+
# ALLOWED VALUES
|
|
51
|
+
# -----------------------------
|
|
52
|
+
def test_allowed_values_valid(self):
|
|
53
|
+
self.assertEqual(validate("b", {"allowed_values": ("a", "b", "c")}), "b")
|
|
54
|
+
|
|
55
|
+
def test_allowed_values_invalid(self):
|
|
56
|
+
with self.assertRaises(ValueError):
|
|
57
|
+
validate("x", {"allowed_values": ("a", "b", "c")})
|
|
58
|
+
|
|
59
|
+
# -----------------------------
|
|
60
|
+
# INVARIANT
|
|
61
|
+
# -----------------------------
|
|
62
|
+
def test_invariant_valid(self):
|
|
63
|
+
self.assertTrue(validate(True, {"invariant": True}))
|
|
64
|
+
|
|
65
|
+
def test_invariant_invalid(self):
|
|
66
|
+
with self.assertRaises(ValueError):
|
|
67
|
+
validate(False, {"invariant": True})
|
|
68
|
+
|
|
69
|
+
# -----------------------------
|
|
70
|
+
# ALL SAME / ALL UNIQUE
|
|
71
|
+
# -----------------------------
|
|
72
|
+
def test_all_same_valid(self):
|
|
73
|
+
self.assertEqual(validate([1, 1, 1], {"all_same": True}), [1, 1, 1])
|
|
74
|
+
|
|
75
|
+
def test_all_same_invalid(self):
|
|
76
|
+
with self.assertRaises(ValueError):
|
|
77
|
+
validate([1, 2, 1], {"all_same": True})
|
|
78
|
+
|
|
79
|
+
def test_all_unique_valid(self):
|
|
80
|
+
self.assertEqual(validate([1, 2, 3], {"all_unique": True}), [1, 2, 3])
|
|
81
|
+
|
|
82
|
+
def test_all_unique_invalid(self):
|
|
83
|
+
with self.assertRaises(ValueError):
|
|
84
|
+
validate([1, 2, 2], {"all_unique": True})
|
|
85
|
+
|
|
86
|
+
# -----------------------------
|
|
87
|
+
# NON EMPTY / NO NULLS
|
|
88
|
+
# -----------------------------
|
|
89
|
+
def test_non_empty_valid(self):
|
|
90
|
+
self.assertEqual(validate([1], {"non_empty": True}), [1])
|
|
91
|
+
|
|
92
|
+
def test_non_empty_invalid(self):
|
|
93
|
+
with self.assertRaises(ValueError):
|
|
94
|
+
validate([], {"non_empty": True})
|
|
95
|
+
|
|
96
|
+
def test_no_nulls_valid(self):
|
|
97
|
+
self.assertEqual(validate([1, 2, 3], {"no_nulls": True}), [1, 2, 3])
|
|
98
|
+
|
|
99
|
+
def test_no_nulls_invalid(self):
|
|
100
|
+
with self.assertRaises(ValueError):
|
|
101
|
+
validate([1, None, 3], {"no_nulls": True})
|
|
102
|
+
|
|
103
|
+
# -----------------------------
|
|
104
|
+
# SORTED / INCREASING / DECREASING
|
|
105
|
+
# -----------------------------
|
|
106
|
+
def test_sorted_valid(self):
|
|
107
|
+
self.assertEqual(validate([1, 2, 3], {"sorted": True}), [1, 2, 3])
|
|
108
|
+
|
|
109
|
+
def test_sorted_reverse_valid(self):
|
|
110
|
+
self.assertEqual(validate([3, 2, 1], {"sorted": True}), [3, 2, 1])
|
|
111
|
+
|
|
112
|
+
def test_sorted_invalid(self):
|
|
113
|
+
with self.assertRaises(ValueError):
|
|
114
|
+
validate([1, 3, 2], {"sorted": True})
|
|
115
|
+
|
|
116
|
+
def test_increasing_valid(self):
|
|
117
|
+
self.assertEqual(validate([1, 2, 3], {"increasing": True}), [1, 2, 3])
|
|
118
|
+
|
|
119
|
+
def test_increasing_invalid(self):
|
|
120
|
+
with self.assertRaises(ValueError):
|
|
121
|
+
validate([1, 3, 2], {"increasing": True})
|
|
122
|
+
|
|
123
|
+
def test_decreasing_valid(self):
|
|
124
|
+
self.assertEqual(validate([3, 2, 1], {"decreasing": True}), [3, 2, 1])
|
|
125
|
+
|
|
126
|
+
def test_decreasing_invalid(self):
|
|
127
|
+
with self.assertRaises(ValueError):
|
|
128
|
+
validate([3, 1, 2], {"decreasing": True})
|
|
129
|
+
|
|
130
|
+
# -----------------------------
|
|
131
|
+
# SUM MIN / SUM MAX
|
|
132
|
+
# -----------------------------
|
|
133
|
+
def test_sum_min_valid(self):
|
|
134
|
+
self.assertEqual(validate([10, 20, 30], {"sum_min": 50}), [10, 20, 30])
|
|
135
|
+
|
|
136
|
+
def test_sum_min_invalid(self):
|
|
137
|
+
with self.assertRaises(ValueError):
|
|
138
|
+
validate([10, 20], {"sum_min": 50})
|
|
139
|
+
|
|
140
|
+
def test_sum_max_valid(self):
|
|
141
|
+
self.assertEqual(validate([10, 20, 30], {"sum_max": 70}), [10, 20, 30])
|
|
142
|
+
|
|
143
|
+
def test_sum_max_invalid(self):
|
|
144
|
+
with self.assertRaises(ValueError):
|
|
145
|
+
validate([50, 50], {"sum_max": 70})
|
|
146
|
+
|
|
147
|
+
# -----------------------------
|
|
148
|
+
# ELEMENT MIN / ELEMENT MAX
|
|
149
|
+
# -----------------------------
|
|
150
|
+
def test_element_min_valid(self):
|
|
151
|
+
self.assertEqual(validate([10, 20, 30], {"element_min": 5}), [10, 20, 30])
|
|
152
|
+
|
|
153
|
+
def test_element_min_invalid(self):
|
|
154
|
+
with self.assertRaises(ValueError):
|
|
155
|
+
validate([1, 20, 30], {"element_min": 5})
|
|
156
|
+
|
|
157
|
+
def test_element_max_valid(self):
|
|
158
|
+
self.assertEqual(validate([10, 20, 30], {"element_max": 40}), [10, 20, 30])
|
|
159
|
+
|
|
160
|
+
def test_element_max_invalid(self):
|
|
161
|
+
with self.assertRaises(ValueError):
|
|
162
|
+
validate([10, 20, 100], {"element_max": 40})
|
|
163
|
+
|
|
164
|
+
# -----------------------------
|
|
165
|
+
# REGEX
|
|
166
|
+
# -----------------------------
|
|
167
|
+
def test_regex_valid(self):
|
|
168
|
+
self.assertEqual(validate("cat", {"regex": "cat|dog"}), "cat")
|
|
169
|
+
|
|
170
|
+
def test_regex_invalid(self):
|
|
171
|
+
with self.assertRaises(ValueError):
|
|
172
|
+
validate("bird", {"regex": "cat|dog"})
|
|
173
|
+
|
|
174
|
+
# -----------------------------
|
|
175
|
+
# MUST BE TRUE
|
|
176
|
+
# -----------------------------
|
|
177
|
+
def test_must_be_true_valid(self):
|
|
178
|
+
def is_even(x: int) -> bool:
|
|
179
|
+
return x % 2 == 0
|
|
180
|
+
self.assertEqual(validate(8, {"must_be_true": is_even}), 8)
|
|
181
|
+
|
|
182
|
+
def test_must_be_true_invalid(self):
|
|
183
|
+
def is_even(x: int) -> bool:
|
|
184
|
+
return x % 2 == 0
|
|
185
|
+
with self.assertRaises(ValueError):
|
|
186
|
+
validate(7, {"must_be_true": is_even})
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
if __name__ == "__main__":
|
|
190
|
+
unittest.main()
|