jsonology 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """jsonology — evaluate a strict, tiny JSON DSL against a ``$``-rooted context object."""
2
+
3
+ from jsonology.core import EvaluationError, ExpressionSyntaxError, evaluate
4
+
5
+ __all__ = ["EvaluationError", "ExpressionSyntaxError", "evaluate"]
6
+ __version__ = "0.1.0"
jsonology/core.py ADDED
@@ -0,0 +1,232 @@
1
+ """Core evaluator for the jsonology expression DSL.
2
+
3
+ Implements the confirmed spec (see SPEC.md): a strict, tiny JSON DSL with
4
+ ``$``-rooted paths, six comparisons, a lazy ``if``, and short-circuiting
5
+ ``and``/``or``/``not``. Two exceptions, and only two: ``ExpressionSyntaxError``
6
+ for malformed expressions, ``EvaluationError`` for undefined operations.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import math
13
+ from collections.abc import Mapping
14
+ from typing import Any
15
+
16
+ __all__ = ["EvaluationError", "ExpressionSyntaxError", "evaluate"]
17
+
18
+
19
+ class ExpressionSyntaxError(Exception):
20
+ """The expression is not well-formed: structure, arity, unknown operator, bad JSON text."""
21
+
22
+
23
+ class EvaluationError(Exception):
24
+ """The expression is well-formed but an operation is undefined (e.g. cross-type ordering)."""
25
+
26
+
27
+ _COMPARISONS = {"==", "!=", "<", "<=", ">", ">="}
28
+ _LOGICAL = {"if", "and", "or", "not"}
29
+ _OPERATORS = _COMPARISONS | _LOGICAL
30
+
31
+ # operator -> (minimum arity, maximum arity); math.inf means "no maximum"
32
+ _ARITY: dict[str, tuple[int, float]] = {op: (2, 2) for op in _COMPARISONS}
33
+ _ARITY.update({"if": (3, 3), "not": (1, 1), "and": (1, math.inf), "or": (1, math.inf)})
34
+
35
+
36
+ def evaluate(expression: Any, data: Any) -> Any:
37
+ """Evaluate a jsonology expression against ``data``.
38
+
39
+ ``expression`` may be an already-parsed Python object or a raw JSON string.
40
+ A string that is ``"$"`` or starts with ``"$."`` is a path, not JSON text;
41
+ any other string is parsed as JSON, and a parse failure raises
42
+ :class:`ExpressionSyntaxError`.
43
+
44
+ Neither ``expression`` nor ``data`` is mutated; the function is stateless
45
+ and thread-safe. Raises :class:`ExpressionSyntaxError` for malformed
46
+ expressions and :class:`EvaluationError` for undefined operations
47
+ (ordering across types, with null, or between booleans).
48
+ """
49
+ if isinstance(expression, str) and expression != "$" and not expression.startswith("$."):
50
+ try:
51
+ expression = json.loads(expression)
52
+ except ValueError as exc:
53
+ raise ExpressionSyntaxError(f"expression string is not valid JSON: {exc}") from exc
54
+ try:
55
+ return _eval(expression, data, "$")
56
+ except RecursionError:
57
+ raise ExpressionSyntaxError("expression too deeply nested") from None
58
+
59
+
60
+ def _eval(node: Any, data: Any, path: str) -> Any:
61
+ """Recursively evaluate one expression node, located at ``path`` in the tree."""
62
+ if node is None or isinstance(node, (bool, int, float)):
63
+ return node
64
+ if isinstance(node, str):
65
+ return _resolve_path(node, data) if node == "$" or node.startswith("$.") else node
66
+ if isinstance(node, Mapping):
67
+ if len(node) != 1:
68
+ raise ExpressionSyntaxError(
69
+ f"operator object must have exactly one key, got {len(node)}: "
70
+ f"{_snippet(node)} (at {path})"
71
+ )
72
+ op, args = next(iter(node.items()))
73
+ if op not in _OPERATORS:
74
+ raise ExpressionSyntaxError(f"unknown operator {op!r} (at {path})")
75
+ if not isinstance(args, (list, tuple)):
76
+ raise ExpressionSyntaxError(
77
+ f"arguments of {op!r} must be a list, got {_snippet(args)} (at {path})"
78
+ )
79
+ low, high = _ARITY[op]
80
+ if not low <= len(args) <= high:
81
+ expectation = f"exactly {low}" if low == high else f"at least {low}"
82
+ raise ExpressionSyntaxError(
83
+ f"operator {op!r} takes {expectation} argument(s), got {len(args)} (at {path})"
84
+ )
85
+ if op in _COMPARISONS:
86
+ left = _eval(args[0], data, f"{path}.{op}[0]")
87
+ right = _eval(args[1], data, f"{path}.{op}[1]")
88
+ return _compare(op, left, right, f"{path}.{op}")
89
+ if op == "if":
90
+ branch = 1 if _truthy(_eval(args[0], data, f"{path}.if[0]")) else 2
91
+ return _eval(args[branch], data, f"{path}.if[{branch}]")
92
+ if op in ("and", "or"):
93
+ want_truthy = op == "or"
94
+ for i, arg in enumerate(args):
95
+ if _truthy(_eval(arg, data, f"{path}.{op}[{i}]")) is want_truthy:
96
+ return want_truthy
97
+ return not want_truthy
98
+ return not _truthy(_eval(args[0], data, f"{path}.not[0]"))
99
+ raise ExpressionSyntaxError(
100
+ f"expression must be a JSON scalar, a '$.'-prefixed path string, or a "
101
+ f"single-key operator object, got {_snippet(node)} (at {path})"
102
+ )
103
+
104
+
105
+ def _resolve_path(path: str, data: Any) -> Any:
106
+ """Resolve a ``$``-rooted path; any missing segment evaluates to None."""
107
+ if path == "$":
108
+ return data
109
+ current = data
110
+ for segment in path[2:].split("."):
111
+ if isinstance(current, Mapping):
112
+ if segment in current:
113
+ current = current[segment]
114
+ else:
115
+ return None
116
+ elif isinstance(current, (list, tuple)):
117
+ # isdecimal, not isdigit: isdigit() admits characters int() rejects
118
+ # (e.g. superscript '²'), which would crash. Decimal digits are
119
+ # exactly the segments int() can parse.
120
+ if segment.isdecimal() and int(segment) < len(current):
121
+ current = current[int(segment)]
122
+ else:
123
+ return None
124
+ else:
125
+ return None
126
+ return current
127
+
128
+
129
+ def _truthy(value: Any) -> bool:
130
+ """Python-style truthiness: false, null, 0, "", [], {} are falsy; else truthy."""
131
+ if value is None or isinstance(value, bool):
132
+ return bool(value)
133
+ if isinstance(value, (int, float)):
134
+ return value != 0
135
+ if isinstance(value, (str, list, tuple, Mapping)):
136
+ return len(value) > 0
137
+ return bool(value)
138
+
139
+
140
+ def _compare(op: str, left: Any, right: Any, path: str) -> bool:
141
+ if op == "==":
142
+ return _equal(left, right)
143
+ if op == "!=":
144
+ return not _equal(left, right)
145
+ order = _ordered(left, right, op, path)
146
+ if op == "<":
147
+ return order < 0
148
+ if op == "<=":
149
+ return order <= 0
150
+ if op == ">":
151
+ return order > 0
152
+ return order >= 0
153
+
154
+
155
+ def _ordered(left: Any, right: Any, op: str, path: str) -> int:
156
+ """Return -1/0/1 for two numbers or two strings; raise for anything else."""
157
+ if left is None or right is None or isinstance(left, bool) or isinstance(right, bool):
158
+ raise EvaluationError(
159
+ f"operator {op!r} is undefined for {_snippet(left)} and {_snippet(right)}: "
160
+ f"ordering is undefined for booleans and null (at {path})"
161
+ )
162
+ both_numbers = isinstance(left, (int, float)) and isinstance(right, (int, float))
163
+ both_strings = isinstance(left, str) and isinstance(right, str)
164
+ # Out-of-contract, same-type pairs (e.g. two datetimes) fall through to
165
+ # Python semantics. In-contract families that Python would happily order
166
+ # (lists compare lexicographically!) must NOT slip through: arrays,
167
+ # objects, booleans and null always raise.
168
+ both_other = (
169
+ _family(left) == "other" and _family(right) == "other" and type(left) is type(right)
170
+ )
171
+ if not (both_numbers or both_strings or both_other):
172
+ raise EvaluationError(
173
+ f"operator {op!r} is undefined for {_snippet(left)} and {_snippet(right)}: "
174
+ f"ordering requires two numbers or two strings (at {path})"
175
+ )
176
+ try:
177
+ if left < right:
178
+ return -1
179
+ if right < left:
180
+ return 1
181
+ return 0
182
+ except TypeError as exc:
183
+ raise EvaluationError(
184
+ f"operator {op!r} is undefined for {_snippet(left)} and {_snippet(right)}: "
185
+ f"{exc} (at {path})"
186
+ ) from exc
187
+
188
+
189
+ def _family(value: Any) -> str:
190
+ """The JSON type family of a value; ``bool`` is deliberately not ``number``."""
191
+ if value is None:
192
+ return "null"
193
+ if isinstance(value, bool):
194
+ return "boolean"
195
+ if isinstance(value, (int, float)):
196
+ return "number"
197
+ if isinstance(value, str):
198
+ return "string"
199
+ if isinstance(value, (list, tuple)):
200
+ return "array"
201
+ if isinstance(value, Mapping):
202
+ return "object"
203
+ return "other"
204
+
205
+
206
+ def _equal(left: Any, right: Any) -> bool:
207
+ """Strict, deep, structural equality; cross-type (incl. bool vs number) is False."""
208
+ left_family, right_family = _family(left), _family(right)
209
+ if left_family != right_family:
210
+ return False
211
+ if left_family == "array":
212
+ return len(left) == len(right) and all(
213
+ _equal(a, b) for a, b in zip(left, right, strict=True)
214
+ )
215
+ if left_family == "object":
216
+ return left.keys() == right.keys() and all(_equal(v, right[k]) for k, v in left.items())
217
+ if left_family == "other":
218
+ return type(left) is type(right) and bool(left == right)
219
+ return bool(left == right)
220
+
221
+
222
+ def _snippet(value: Any, limit: int = 40) -> str:
223
+ """A short, crash-proof rendering of a value for error messages."""
224
+ try:
225
+ text = json.dumps(value, default=repr)
226
+ except RecursionError:
227
+ return "<deeply nested value>"
228
+ except (TypeError, ValueError):
229
+ text = repr(value)
230
+ if len(text) > limit:
231
+ text = text[: limit - 1] + "…"
232
+ return text
jsonology/py.typed ADDED
File without changes
@@ -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,7 @@
1
+ jsonology/__init__.py,sha256=k9z_2XqnN-0zn23TWux9JSJElAcFMXbFmWrZVkLP4Ww,259
2
+ jsonology/core.py,sha256=T2LEdqJYXecg46BVuliuM2x5MFSz2nzsNRF1cj98LTU,9147
3
+ jsonology/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ jsonology-0.1.0.dist-info/METADATA,sha256=OpLt5JSkzDruGapDO7jIRIraWDEODGLjRw4ug9RZ-co,7059
5
+ jsonology-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
6
+ jsonology-0.1.0.dist-info/licenses/LICENSE,sha256=84ed0FiUtfdroKBIs9yusK2POq4hk_FqkZS2OyqDi6U,1078
7
+ jsonology-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.