jsonology 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.
- jsonology-0.1.0/.gitignore +12 -0
- jsonology-0.1.0/LICENSE +21 -0
- jsonology-0.1.0/PKG-INFO +185 -0
- jsonology-0.1.0/README.md +158 -0
- jsonology-0.1.0/SPEC.md +151 -0
- jsonology-0.1.0/pyproject.toml +49 -0
- jsonology-0.1.0/src/jsonology/__init__.py +6 -0
- jsonology-0.1.0/src/jsonology/core.py +232 -0
- jsonology-0.1.0/src/jsonology/py.typed +0 -0
- jsonology-0.1.0/tests/test_errors.py +89 -0
- jsonology-0.1.0/tests/test_golden.py +125 -0
- jsonology-0.1.0/tests/test_properties.py +302 -0
- jsonology-0.1.0/tests/test_purity.py +25 -0
- jsonology-0.1.0/tests/test_string_input.py +45 -0
jsonology-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 The jsonology authors
|
|
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.
|
jsonology-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: jsonology
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Evaluate a strict, tiny JSON DSL (comparisons, if, and/or/not) against a $-rooted context object
|
|
5
|
+
Author: The jsonology authors
|
|
6
|
+
License: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: dsl,evaluator,expression,json,jsonlogic,rules
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: hypothesis>=6.100; extra == 'dev'
|
|
23
|
+
Requires-Dist: mypy>=1.11; extra == 'dev'
|
|
24
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
25
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# jsonology
|
|
29
|
+
|
|
30
|
+
Evaluate a strict, tiny JSON DSL against a context object.
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
>>> from jsonology import evaluate
|
|
34
|
+
>>> evaluate({"==": ["$.operation", "edit"]}, {"operation": "edit"})
|
|
35
|
+
True
|
|
36
|
+
>>> evaluate({"if": [{"==": ["$.operation", "edit"]}, "an edit!", "not an edit"]},
|
|
37
|
+
... {"operation": "delete"})
|
|
38
|
+
'not an edit'
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Expressions are JSON values, authored by trusted teammates, stored in rules or
|
|
42
|
+
config. `jsonology` interprets them strictly and errors loudly. Zero runtime
|
|
43
|
+
dependencies. Python ≥ 3.10, fully typed.
|
|
44
|
+
|
|
45
|
+
## Install
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install jsonology
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## The language
|
|
52
|
+
|
|
53
|
+
An **expression** is one of:
|
|
54
|
+
|
|
55
|
+
- a JSON scalar — a literal (`5`, `5.5`, `"edit"`, `true`, `false`, `null`)
|
|
56
|
+
- a string starting with `$.` — a path into the context
|
|
57
|
+
- an operator object `{op: [args...]}` — where every argument is itself an expression
|
|
58
|
+
|
|
59
|
+
### Operators
|
|
60
|
+
|
|
61
|
+
| Operator | Arity | Semantics |
|
|
62
|
+
| --- | --- | --- |
|
|
63
|
+
| `==`, `!=` | exactly 2 | strict equality, no coercion |
|
|
64
|
+
| `<`, `<=`, `>`, `>=` | exactly 2 | ordering: two numbers or two strings only |
|
|
65
|
+
| `if` | exactly 3 | `[condition, if_true, if_false]`, **lazy** |
|
|
66
|
+
| `and`, `or` | 1 or more | short-circuit, return strict booleans |
|
|
67
|
+
| `not` | exactly 1 | negation of truthiness |
|
|
68
|
+
|
|
69
|
+
Operator objects must have exactly one key. Bare lists are not expressions.
|
|
70
|
+
Unknown operators are rejected with an error naming them.
|
|
71
|
+
|
|
72
|
+
### Paths
|
|
73
|
+
|
|
74
|
+
- `$.user.address.city` — dot-nested object access
|
|
75
|
+
- `$.items.0` — numeric segments index lists (no negative indices)
|
|
76
|
+
- a missing path evaluates to `null`, so `{"==": ["$.nickname", null]}` is the "is unset" idiom
|
|
77
|
+
- `$` alone is the entire context (which may itself be a list)
|
|
78
|
+
|
|
79
|
+
### Truthiness
|
|
80
|
+
|
|
81
|
+
`if` conditions and `and`/`or`/`not` operands are interpreted Python-style:
|
|
82
|
+
|
|
83
|
+
| Value | Truthy? |
|
|
84
|
+
| --- | --- |
|
|
85
|
+
| `false`, `null` | no |
|
|
86
|
+
| `0` | no |
|
|
87
|
+
| `""` | no |
|
|
88
|
+
| `[]`, `{}` | no |
|
|
89
|
+
| everything else | yes |
|
|
90
|
+
|
|
91
|
+
### Strict typing
|
|
92
|
+
|
|
93
|
+
- No coercion: `{"==": ["5", 5]}` is `false`.
|
|
94
|
+
- `int` and `float` unify: `5 == 5.0` is `true`.
|
|
95
|
+
- `bool` is its own type: `true == 1` is `false`, and ordering booleans is an error.
|
|
96
|
+
- Cross-type equality is always decided, never an error: `{"==": [1, "1"]}` → `false`.
|
|
97
|
+
- Ordering is only defined for two numbers or two strings; anything else —
|
|
98
|
+
cross-type, `null` on either side, booleans, lists — raises `EvaluationError`.
|
|
99
|
+
Note `{"<": ["$.missing", 5]}` raises, because a missing path is `null`.
|
|
100
|
+
- Strings order lexicographically: `{"<": ["10", "9"]}` is `true`.
|
|
101
|
+
- Equality is deep and structural over arrays and objects.
|
|
102
|
+
|
|
103
|
+
### Laziness and short-circuiting
|
|
104
|
+
|
|
105
|
+
`if` evaluates only the taken branch. `and` stops at the first falsy operand,
|
|
106
|
+
`or` at the first truthy one — later operands are never evaluated (or errored):
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
evaluate({"and": [False, {"<": [None, 1]}]}, {}) # False — the broken branch never runs
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Because of this laziness, syntax errors inside dead branches stay silent —
|
|
113
|
+
`evaluate({"if": [True, 1, {"frobnicate": [1]}]}, {})` is `1`.
|
|
114
|
+
|
|
115
|
+
## API
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
evaluate(expression, data) -> Any
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
- `expression`: an already-parsed Python object **or** a raw JSON string.
|
|
122
|
+
A string that is `"$"` or starts with `"$."` is treated as a path, not JSON
|
|
123
|
+
text; any other string is parsed as JSON and failures raise
|
|
124
|
+
`ExpressionSyntaxError`. There is **no escape hatch** for literal strings
|
|
125
|
+
that start with `$.` — they are always paths.
|
|
126
|
+
- `data`: the context object (a mapping, or a list if your paths start `$.0` …).
|
|
127
|
+
- Pure, stateless, thread-safe: neither `expression` nor `data` is mutated.
|
|
128
|
+
|
|
129
|
+
Two exceptions, and only two:
|
|
130
|
+
|
|
131
|
+
| Exception | Raised for |
|
|
132
|
+
| --- | --- |
|
|
133
|
+
| `ExpressionSyntaxError` | malformed DSL: structure, arity, unknown operator, invalid JSON text, nesting too deep |
|
|
134
|
+
| `EvaluationError` | undefined operation during evaluation (ordering rules above) |
|
|
135
|
+
|
|
136
|
+
Error messages name the offending operator and locate the failing
|
|
137
|
+
subexpression within the expression tree, e.g. `$.if[0].==[1]` — "argument 1
|
|
138
|
+
of the `==` at argument 0 of the `if`".
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
>>> from jsonology import evaluate, ExpressionSyntaxError
|
|
142
|
+
>>> try:
|
|
143
|
+
... evaluate('{"if": [{"==": ["$.a", {"x": 1, "y": 2}]}, 1, 2]}', {})
|
|
144
|
+
... except ExpressionSyntaxError as e:
|
|
145
|
+
... print(e)
|
|
146
|
+
operator object must have exactly one key, got 2: {"x": 1, "y": 2} (at $.if[0].==[1])
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
## Limitations (deliberate, v1)
|
|
150
|
+
|
|
151
|
+
- No literal escape hatch for strings starting with `$.`.
|
|
152
|
+
- No array or object literals: every operand is a scalar, a path, or a nested
|
|
153
|
+
operator. `{"==": ["$.tags", ["a", "b"]]}` is a syntax error (compare two
|
|
154
|
+
paths instead: `{"==": ["$.tags", "$.want"]}`), and an object literal like
|
|
155
|
+
`{"x": 1}` in operand position fails as an unknown operator `'x'`.
|
|
156
|
+
- Dead `if` branches and short-circuited `and`/`or` operands are never
|
|
157
|
+
evaluated, so their syntax is not validated.
|
|
158
|
+
- Context keys containing dots cannot be addressed by paths.
|
|
159
|
+
- The contract is JSON types. Non-JSON Python values in `data` (datetime,
|
|
160
|
+
Decimal, …) are out of contract — same-type pairs fall through to Python
|
|
161
|
+
semantics, everything else is unspecified. For time comparisons store
|
|
162
|
+
ISO-8601 strings (lexicographic `<` does the right thing) or epoch integers.
|
|
163
|
+
- No custom operators, no depth or size limits (trusted-input model).
|
|
164
|
+
- No `compile()` step — expressions are interpreted directly; reach for one
|
|
165
|
+
only if profiling demands it.
|
|
166
|
+
|
|
167
|
+
## Development
|
|
168
|
+
|
|
169
|
+
```bash
|
|
170
|
+
uv venv && uv pip install -e '.[dev]'
|
|
171
|
+
python -m pytest # 149 cases: table-driven goldens + property-based invariants
|
|
172
|
+
python -m ruff check .
|
|
173
|
+
python -m mypy
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
The property suite (Hypothesis) checks the contract as invariants: only the
|
|
177
|
+
two documented exceptions ever escape, `==`/ordering obey the algebraic laws
|
|
178
|
+
(reflexivity, symmetry, transitivity, connexity), truthiness matches the
|
|
179
|
+
reference table, path resolution matches an independent reference walk, dead
|
|
180
|
+
branches never evaluate, inputs are never mutated, and JSON-text input agrees
|
|
181
|
+
with object input.
|
|
182
|
+
|
|
183
|
+
## License
|
|
184
|
+
|
|
185
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# jsonology
|
|
2
|
+
|
|
3
|
+
Evaluate a strict, tiny JSON DSL against a context object.
|
|
4
|
+
|
|
5
|
+
```python
|
|
6
|
+
>>> from jsonology import evaluate
|
|
7
|
+
>>> evaluate({"==": ["$.operation", "edit"]}, {"operation": "edit"})
|
|
8
|
+
True
|
|
9
|
+
>>> evaluate({"if": [{"==": ["$.operation", "edit"]}, "an edit!", "not an edit"]},
|
|
10
|
+
... {"operation": "delete"})
|
|
11
|
+
'not an edit'
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Expressions are JSON values, authored by trusted teammates, stored in rules or
|
|
15
|
+
config. `jsonology` interprets them strictly and errors loudly. Zero runtime
|
|
16
|
+
dependencies. Python ≥ 3.10, fully typed.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install jsonology
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## The language
|
|
25
|
+
|
|
26
|
+
An **expression** is one of:
|
|
27
|
+
|
|
28
|
+
- a JSON scalar — a literal (`5`, `5.5`, `"edit"`, `true`, `false`, `null`)
|
|
29
|
+
- a string starting with `$.` — a path into the context
|
|
30
|
+
- an operator object `{op: [args...]}` — where every argument is itself an expression
|
|
31
|
+
|
|
32
|
+
### Operators
|
|
33
|
+
|
|
34
|
+
| Operator | Arity | Semantics |
|
|
35
|
+
| --- | --- | --- |
|
|
36
|
+
| `==`, `!=` | exactly 2 | strict equality, no coercion |
|
|
37
|
+
| `<`, `<=`, `>`, `>=` | exactly 2 | ordering: two numbers or two strings only |
|
|
38
|
+
| `if` | exactly 3 | `[condition, if_true, if_false]`, **lazy** |
|
|
39
|
+
| `and`, `or` | 1 or more | short-circuit, return strict booleans |
|
|
40
|
+
| `not` | exactly 1 | negation of truthiness |
|
|
41
|
+
|
|
42
|
+
Operator objects must have exactly one key. Bare lists are not expressions.
|
|
43
|
+
Unknown operators are rejected with an error naming them.
|
|
44
|
+
|
|
45
|
+
### Paths
|
|
46
|
+
|
|
47
|
+
- `$.user.address.city` — dot-nested object access
|
|
48
|
+
- `$.items.0` — numeric segments index lists (no negative indices)
|
|
49
|
+
- a missing path evaluates to `null`, so `{"==": ["$.nickname", null]}` is the "is unset" idiom
|
|
50
|
+
- `$` alone is the entire context (which may itself be a list)
|
|
51
|
+
|
|
52
|
+
### Truthiness
|
|
53
|
+
|
|
54
|
+
`if` conditions and `and`/`or`/`not` operands are interpreted Python-style:
|
|
55
|
+
|
|
56
|
+
| Value | Truthy? |
|
|
57
|
+
| --- | --- |
|
|
58
|
+
| `false`, `null` | no |
|
|
59
|
+
| `0` | no |
|
|
60
|
+
| `""` | no |
|
|
61
|
+
| `[]`, `{}` | no |
|
|
62
|
+
| everything else | yes |
|
|
63
|
+
|
|
64
|
+
### Strict typing
|
|
65
|
+
|
|
66
|
+
- No coercion: `{"==": ["5", 5]}` is `false`.
|
|
67
|
+
- `int` and `float` unify: `5 == 5.0` is `true`.
|
|
68
|
+
- `bool` is its own type: `true == 1` is `false`, and ordering booleans is an error.
|
|
69
|
+
- Cross-type equality is always decided, never an error: `{"==": [1, "1"]}` → `false`.
|
|
70
|
+
- Ordering is only defined for two numbers or two strings; anything else —
|
|
71
|
+
cross-type, `null` on either side, booleans, lists — raises `EvaluationError`.
|
|
72
|
+
Note `{"<": ["$.missing", 5]}` raises, because a missing path is `null`.
|
|
73
|
+
- Strings order lexicographically: `{"<": ["10", "9"]}` is `true`.
|
|
74
|
+
- Equality is deep and structural over arrays and objects.
|
|
75
|
+
|
|
76
|
+
### Laziness and short-circuiting
|
|
77
|
+
|
|
78
|
+
`if` evaluates only the taken branch. `and` stops at the first falsy operand,
|
|
79
|
+
`or` at the first truthy one — later operands are never evaluated (or errored):
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
evaluate({"and": [False, {"<": [None, 1]}]}, {}) # False — the broken branch never runs
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Because of this laziness, syntax errors inside dead branches stay silent —
|
|
86
|
+
`evaluate({"if": [True, 1, {"frobnicate": [1]}]}, {})` is `1`.
|
|
87
|
+
|
|
88
|
+
## API
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
evaluate(expression, data) -> Any
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
- `expression`: an already-parsed Python object **or** a raw JSON string.
|
|
95
|
+
A string that is `"$"` or starts with `"$."` is treated as a path, not JSON
|
|
96
|
+
text; any other string is parsed as JSON and failures raise
|
|
97
|
+
`ExpressionSyntaxError`. There is **no escape hatch** for literal strings
|
|
98
|
+
that start with `$.` — they are always paths.
|
|
99
|
+
- `data`: the context object (a mapping, or a list if your paths start `$.0` …).
|
|
100
|
+
- Pure, stateless, thread-safe: neither `expression` nor `data` is mutated.
|
|
101
|
+
|
|
102
|
+
Two exceptions, and only two:
|
|
103
|
+
|
|
104
|
+
| Exception | Raised for |
|
|
105
|
+
| --- | --- |
|
|
106
|
+
| `ExpressionSyntaxError` | malformed DSL: structure, arity, unknown operator, invalid JSON text, nesting too deep |
|
|
107
|
+
| `EvaluationError` | undefined operation during evaluation (ordering rules above) |
|
|
108
|
+
|
|
109
|
+
Error messages name the offending operator and locate the failing
|
|
110
|
+
subexpression within the expression tree, e.g. `$.if[0].==[1]` — "argument 1
|
|
111
|
+
of the `==` at argument 0 of the `if`".
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
>>> from jsonology import evaluate, ExpressionSyntaxError
|
|
115
|
+
>>> try:
|
|
116
|
+
... evaluate('{"if": [{"==": ["$.a", {"x": 1, "y": 2}]}, 1, 2]}', {})
|
|
117
|
+
... except ExpressionSyntaxError as e:
|
|
118
|
+
... print(e)
|
|
119
|
+
operator object must have exactly one key, got 2: {"x": 1, "y": 2} (at $.if[0].==[1])
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Limitations (deliberate, v1)
|
|
123
|
+
|
|
124
|
+
- No literal escape hatch for strings starting with `$.`.
|
|
125
|
+
- No array or object literals: every operand is a scalar, a path, or a nested
|
|
126
|
+
operator. `{"==": ["$.tags", ["a", "b"]]}` is a syntax error (compare two
|
|
127
|
+
paths instead: `{"==": ["$.tags", "$.want"]}`), and an object literal like
|
|
128
|
+
`{"x": 1}` in operand position fails as an unknown operator `'x'`.
|
|
129
|
+
- Dead `if` branches and short-circuited `and`/`or` operands are never
|
|
130
|
+
evaluated, so their syntax is not validated.
|
|
131
|
+
- Context keys containing dots cannot be addressed by paths.
|
|
132
|
+
- The contract is JSON types. Non-JSON Python values in `data` (datetime,
|
|
133
|
+
Decimal, …) are out of contract — same-type pairs fall through to Python
|
|
134
|
+
semantics, everything else is unspecified. For time comparisons store
|
|
135
|
+
ISO-8601 strings (lexicographic `<` does the right thing) or epoch integers.
|
|
136
|
+
- No custom operators, no depth or size limits (trusted-input model).
|
|
137
|
+
- No `compile()` step — expressions are interpreted directly; reach for one
|
|
138
|
+
only if profiling demands it.
|
|
139
|
+
|
|
140
|
+
## Development
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
uv venv && uv pip install -e '.[dev]'
|
|
144
|
+
python -m pytest # 149 cases: table-driven goldens + property-based invariants
|
|
145
|
+
python -m ruff check .
|
|
146
|
+
python -m mypy
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
The property suite (Hypothesis) checks the contract as invariants: only the
|
|
150
|
+
two documented exceptions ever escape, `==`/ordering obey the algebraic laws
|
|
151
|
+
(reflexivity, symmetry, transitivity, connexity), truthiness matches the
|
|
152
|
+
reference table, path resolution matches an independent reference walk, dead
|
|
153
|
+
branches never evaluate, inputs are never mutated, and JSON-text input agrees
|
|
154
|
+
with object input.
|
|
155
|
+
|
|
156
|
+
## License
|
|
157
|
+
|
|
158
|
+
MIT — see [LICENSE](LICENSE).
|
jsonology-0.1.0/SPEC.md
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# jsonology — Specification (v1)
|
|
2
|
+
|
|
3
|
+
Status: confirmed by owner after design review (3 grilling rounds, 2026).
|
|
4
|
+
|
|
5
|
+
## Purpose
|
|
6
|
+
|
|
7
|
+
A from-scratch, zero-dependency Python library that evaluates a JSON DSL
|
|
8
|
+
against a context object. Expressions are authored by trusted teammates and
|
|
9
|
+
stored in rules/config; the library errors loudly and applies no artificial
|
|
10
|
+
input limits.
|
|
11
|
+
|
|
12
|
+
Package name `jsonology` (verified free on PyPI). Originally `jsonlogi`,
|
|
13
|
+
renamed before first release: the old name was one typo away from
|
|
14
|
+
`jsonlogic` / `jsonlogic-py`, which are different tools.
|
|
15
|
+
|
|
16
|
+
## Grammar
|
|
17
|
+
|
|
18
|
+
An **expression** is one of:
|
|
19
|
+
|
|
20
|
+
- a JSON scalar (number, string, `true`, `false`, `null`) — a literal
|
|
21
|
+
- a string starting with `$.` — a path into the context
|
|
22
|
+
- an operator object: `{op: [args...]}` where every arg is itself an expression
|
|
23
|
+
- any of the above nested recursively
|
|
24
|
+
|
|
25
|
+
Rules:
|
|
26
|
+
|
|
27
|
+
- Operator objects have **exactly one key**; otherwise `ExpressionSyntaxError`.
|
|
28
|
+
- Bare lists are not expressions → `ExpressionSyntaxError`.
|
|
29
|
+
- Consequently there are **no array or object literals**: a bare list operand
|
|
30
|
+
is a syntax error, and a single-key dict in operand position is always an
|
|
31
|
+
operator object (`{"x": 1}` there fails as an unknown operator). Values of
|
|
32
|
+
any shape enter expressions only through paths.
|
|
33
|
+
- Unknown operator → `ExpressionSyntaxError` naming the operator.
|
|
34
|
+
- **Strict arity**: comparisons exactly 2 args · `if` exactly 3 · `and`/`or`
|
|
35
|
+
≥ 1 · `not` exactly 1.
|
|
36
|
+
- No escape hatch for literal strings that start with `$.` (documented
|
|
37
|
+
limitation). Context keys containing dots are unsupported (documented
|
|
38
|
+
limitation).
|
|
39
|
+
- No custom-operator registration in v1.
|
|
40
|
+
|
|
41
|
+
## Paths
|
|
42
|
+
|
|
43
|
+
- `$.user.address.city` — dot-nested object access.
|
|
44
|
+
- `$.items.0` — numeric segments index lists.
|
|
45
|
+
- Missing path evaluates to `null`, so `{"==": ["$.nickname", null]}` is the
|
|
46
|
+
"is unset" idiom.
|
|
47
|
+
- Bare `$` evaluates to the entire context; the context may itself be a list
|
|
48
|
+
(`evaluate("$.0", ["a", "b"])` → `"a"`).
|
|
49
|
+
|
|
50
|
+
## Operators (10)
|
|
51
|
+
|
|
52
|
+
`==` `!=` `>` `<` `>=` `<=` `if` `and` `or` `not`
|
|
53
|
+
|
|
54
|
+
### Comparisons — strict, no coercion
|
|
55
|
+
|
|
56
|
+
- `"5" == 5` → `false` (and `!=` → `true`); cross-type equality is always
|
|
57
|
+
`false`/`true`, never raises.
|
|
58
|
+
- `5 == 5.0` → `true` (int/float unify).
|
|
59
|
+
- `null == null` → `true`.
|
|
60
|
+
- `true == 1` → `false`: `bool` is a **distinct type** from numbers.
|
|
61
|
+
- Ordering (`>` `<` `>=` `<=`):
|
|
62
|
+
- numbers with numbers, strings with strings (lexicographic) — defined.
|
|
63
|
+
- any cross-type pair, or any operand `null`, or two booleans →
|
|
64
|
+
`EvaluationError`.
|
|
65
|
+
- `!=` is exactly the negation of `==` in every case.
|
|
66
|
+
|
|
67
|
+
### `if` — `[condition, if_true, if_false]`
|
|
68
|
+
|
|
69
|
+
Lazy: only the taken branch is evaluated. Strict 3-arity. Corollary: dead
|
|
70
|
+
branches (and short-circuited `and`/`or` operands) are never validated, so
|
|
71
|
+
syntax errors inside them stay silent.
|
|
72
|
+
|
|
73
|
+
### `and` / `or` / `not`
|
|
74
|
+
|
|
75
|
+
- `and`/`or`: n-ary (≥ 1), **short-circuit** (evaluation stops at the first
|
|
76
|
+
decisive operand), return strict `true`/`false`.
|
|
77
|
+
- `not`: unary, returns strict `true`/`false`.
|
|
78
|
+
|
|
79
|
+
### Truthiness (Python-style)
|
|
80
|
+
|
|
81
|
+
Applies to `if` conditions and `and`/`or`/`not` operands:
|
|
82
|
+
|
|
83
|
+
| value | truthy? |
|
|
84
|
+
| ---------------- | ------- |
|
|
85
|
+
| `false`, `null` | no |
|
|
86
|
+
| `0` | no |
|
|
87
|
+
| `""` | no |
|
|
88
|
+
| `[]`, `{}` | no |
|
|
89
|
+
| everything else | yes |
|
|
90
|
+
|
|
91
|
+
## Errors
|
|
92
|
+
|
|
93
|
+
Exactly two exception types:
|
|
94
|
+
|
|
95
|
+
- `ExpressionSyntaxError` — malformed DSL (structure, arity, unknown operator,
|
|
96
|
+
unparseable JSON string input, nesting too deep).
|
|
97
|
+
- `EvaluationError` — type trouble during evaluation (undefined ordering).
|
|
98
|
+
|
|
99
|
+
Error messages carry the offending operator, a snippet of its arguments, and
|
|
100
|
+
the path of the failing subexpression within the expression tree
|
|
101
|
+
(e.g. `$.if[0].==[1]`). Python `RecursionError` is wrapped as
|
|
102
|
+
`ExpressionSyntaxError("expression too deeply nested")`.
|
|
103
|
+
|
|
104
|
+
## API
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
evaluate(expression, data: Mapping) -> Any
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
- Accepts an already-parsed Python object **or** a raw JSON string; string
|
|
111
|
+
parse failures raise `ExpressionSyntaxError`.
|
|
112
|
+
- Pure, stateless, thread-safe; never mutates `expression` or `data`.
|
|
113
|
+
- Returns any JSON value.
|
|
114
|
+
|
|
115
|
+
Non-JSON Python types in `data` (datetime, Decimal, …) are out of contract.
|
|
116
|
+
README recommends ISO-8601 strings for time comparisons (lexicographic `<`
|
|
117
|
+
works); NaN and other non-JSON floats are likewise out of contract.
|
|
118
|
+
|
|
119
|
+
## Build
|
|
120
|
+
|
|
121
|
+
- Direct interpretation — walk the JSON each call; no compile step. Add
|
|
122
|
+
`compile()` only if profiling demands.
|
|
123
|
+
- MIT · Python ≥ 3.10 · `src/jsonology/` layout · `py.typed` · hatchling ·
|
|
124
|
+
zero runtime dependencies · publish to PyPI once tests + README exist.
|
|
125
|
+
|
|
126
|
+
## Tests & docs
|
|
127
|
+
|
|
128
|
+
- pytest, table-driven golden tests (~90 cases): every operator × every edge
|
|
129
|
+
(missing paths, cross-type, nesting, laziness, short-circuit) plus
|
|
130
|
+
error-message assertions.
|
|
131
|
+
- Property-based tests (Hypothesis): totality of the two-exception contract
|
|
132
|
+
over fuzzed expressions; equality and ordering algebraic laws; truthiness
|
|
133
|
+
against a reference table; differential path resolution; laziness; purity
|
|
134
|
+
and determinism; JSON-text vs object input agreement.
|
|
135
|
+
- Docs: a thorough README only (operator table, truthiness table, missing-path
|
|
136
|
+
rules, the `$.` literal caveat). No doc site until a second consumer exists.
|
|
137
|
+
|
|
138
|
+
## Decision log (rejections worth remembering)
|
|
139
|
+
|
|
140
|
+
- **Reuse**: rejected — JsonLogic's Python ecosystem is alpha-grade and the
|
|
141
|
+
syntax differs (`$.x` vs `{"var": "x"}`).
|
|
142
|
+
- **No coercion anywhere**: `"5" != 5`; strict typing chosen over JS-style
|
|
143
|
+
coercion.
|
|
144
|
+
- **No variadic `if`**: else-chains expressed by nesting; strict arity
|
|
145
|
+
throughout.
|
|
146
|
+
- **`and`/`or` return strict booleans**, not the decisive operand — the
|
|
147
|
+
default-value idiom gets a dedicated `coalesce` operator someday, if wanted.
|
|
148
|
+
- **Python-style truthiness** (`[]`/`{}` falsy), not JS-style.
|
|
149
|
+
- **`bool` distinct from `int`**: guards against `$.count` matching `true`.
|
|
150
|
+
- **No operator extensibility, no depth limits, no input size limits** in v1
|
|
151
|
+
(trusted-input model).
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "jsonology"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Evaluate a strict, tiny JSON DSL (comparisons, if, and/or/not) against a $-rooted context object"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
authors = [{ name = "The jsonology authors" }]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
keywords = ["json", "dsl", "expression", "rules", "evaluator", "jsonlogic"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
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
|
+
"Programming Language :: Python :: 3.14",
|
|
24
|
+
"Topic :: Software Development :: Libraries",
|
|
25
|
+
"Typing :: Typed",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.optional-dependencies]
|
|
29
|
+
dev = ["pytest>=8", "ruff>=0.6", "mypy>=1.11", "hypothesis>=6.100"]
|
|
30
|
+
|
|
31
|
+
[tool.hatch.build.targets.wheel]
|
|
32
|
+
packages = ["src/jsonology"]
|
|
33
|
+
|
|
34
|
+
[tool.pytest.ini_options]
|
|
35
|
+
testpaths = ["tests"]
|
|
36
|
+
addopts = "-q"
|
|
37
|
+
|
|
38
|
+
[tool.ruff]
|
|
39
|
+
line-length = 100
|
|
40
|
+
target-version = "py310"
|
|
41
|
+
src = ["src", "tests"]
|
|
42
|
+
|
|
43
|
+
[tool.ruff.lint]
|
|
44
|
+
select = ["E", "F", "W", "I", "UP", "B", "SIM", "RUF"]
|
|
45
|
+
|
|
46
|
+
[tool.mypy]
|
|
47
|
+
python_version = "3.10"
|
|
48
|
+
strict = true
|
|
49
|
+
files = ["src"]
|