json-object-mapper 2.0.0__tar.gz → 2.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.
Files changed (21) hide show
  1. {json_object_mapper-2.0.0/src/json_object_mapper.egg-info → json_object_mapper-2.1.0}/PKG-INFO +32 -1
  2. {json_object_mapper-2.0.0 → json_object_mapper-2.1.0}/README.md +31 -0
  3. {json_object_mapper-2.0.0 → json_object_mapper-2.1.0}/pyproject.toml +11 -2
  4. {json_object_mapper-2.0.0 → json_object_mapper-2.1.0}/src/json_object_mapper/__init__.py +2 -1
  5. {json_object_mapper-2.0.0 → json_object_mapper-2.1.0}/src/json_object_mapper/core.py +50 -2
  6. {json_object_mapper-2.0.0 → json_object_mapper-2.1.0}/src/json_object_mapper/helpers.py +12 -8
  7. json_object_mapper-2.1.0/src/json_object_mapper/py.typed +0 -0
  8. json_object_mapper-2.1.0/src/json_object_mapper/query.py +149 -0
  9. json_object_mapper-2.1.0/src/json_object_mapper/queryable.py +80 -0
  10. {json_object_mapper-2.0.0 → json_object_mapper-2.1.0/src/json_object_mapper.egg-info}/PKG-INFO +32 -1
  11. {json_object_mapper-2.0.0 → json_object_mapper-2.1.0}/src/json_object_mapper.egg-info/SOURCES.txt +7 -1
  12. json_object_mapper-2.1.0/tests/test_query_engine.py +165 -0
  13. json_object_mapper-2.1.0/tests/test_query_parser.py +70 -0
  14. json_object_mapper-2.1.0/tests/test_queryable_list.py +79 -0
  15. {json_object_mapper-2.0.0 → json_object_mapper-2.1.0}/LICENSE +0 -0
  16. {json_object_mapper-2.0.0 → json_object_mapper-2.1.0}/setup.cfg +0 -0
  17. {json_object_mapper-2.0.0 → json_object_mapper-2.1.0}/setup.py +0 -0
  18. {json_object_mapper-2.0.0 → json_object_mapper-2.1.0}/src/json_object_mapper/exceptions.py +0 -0
  19. {json_object_mapper-2.0.0 → json_object_mapper-2.1.0}/src/json_object_mapper.egg-info/dependency_links.txt +0 -0
  20. {json_object_mapper-2.0.0 → json_object_mapper-2.1.0}/src/json_object_mapper.egg-info/top_level.txt +0 -0
  21. {json_object_mapper-2.0.0 → json_object_mapper-2.1.0}/tests/test_json_object_mapper.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: json-object-mapper
3
- Version: 2.0.0
3
+ Version: 2.1.0
4
4
  Summary: Attribute-style access wrapper for JSON-like data with dot-path set/delete and safe defaults
5
5
  Author-email: Kobby Owen <dev@kobbyowen.com>
6
6
  License-Expression: MIT
@@ -37,12 +37,41 @@ print(obj.to_json(indent=2))
37
37
 
38
38
  - Attribute-style access (`obj.key`) for dict keys
39
39
  - Recursive wrapping for nested dicts and lists
40
+ - Query engine (`query`, `exists`, `count`, `compile`) with wildcard and filter support
41
+ - ORM-like list helpers via `QueryableList` (`filter`, `get`, `first`, `last`, `count`)
40
42
  - Read-only mode (immutability enforced)
41
43
  - Dot/bracket path lookups (`obj.get_path("a.b[0].c")`)
42
44
  - **New:** `set_path()` / `del_path()` for dot paths
43
45
  - **New:** `default_factory` + `autocreate_missing` for safe defaults
44
46
  - Utility methods: `to_dict`, `to_json`, `from_json`, `merge`
45
47
 
48
+ ## Release Notes
49
+
50
+ ### v2.1.0
51
+
52
+ - Renamed import path to `json_object_mapper` and aligned package metadata for the new distribution name.
53
+ - Added JSONPath-like query API:
54
+ - `obj.query(expression, first=False, default=None)`
55
+ - `obj.exists(expression)`
56
+ - `obj.count(expression)`
57
+ - `obj.compile(expression)`
58
+ - Implemented query features: property access, nested access, list indexing, wildcards, nested wildcard flattening, and basic filters (`==`, `!=`, `>`, `<`, `>=`, `<=`).
59
+ - Isolated query implementation into a dedicated module for maintainability.
60
+ - Added ORM-style list querying through `QueryableList`:
61
+ - `filter(...)`, `get(...)`, `first()`, `last()`, `count()`
62
+ - Supports nested field filters such as `profile__age__gt=20`.
63
+ - Expanded test coverage significantly across mapper, query parser, query engine, and queryable list behavior (including deep nested JSON scenarios and edge cases).
64
+ - Added strict type-checking support:
65
+ - mypy configuration in project metadata
66
+ - `py.typed` marker for typed package support
67
+ - type fixes in mapper and queryable helpers
68
+ - CI improvements:
69
+ - Ruff linting
70
+ - mypy checks
71
+ - multi-version unit tests
72
+ - build and package validation
73
+ - Publishing workflow configured for PyPI release automation.
74
+
46
75
  ## Install
47
76
 
48
77
  ```bash
@@ -102,6 +131,8 @@ except AttributeError:
102
131
  ```bash
103
132
  python -m pip install -e .
104
133
  python -m unittest discover -s tests -v
134
+ venv/bin/ruff check .
135
+ venv/bin/mypy .
105
136
  ```
106
137
 
107
138
  ## Publishing
@@ -22,12 +22,41 @@ print(obj.to_json(indent=2))
22
22
 
23
23
  - Attribute-style access (`obj.key`) for dict keys
24
24
  - Recursive wrapping for nested dicts and lists
25
+ - Query engine (`query`, `exists`, `count`, `compile`) with wildcard and filter support
26
+ - ORM-like list helpers via `QueryableList` (`filter`, `get`, `first`, `last`, `count`)
25
27
  - Read-only mode (immutability enforced)
26
28
  - Dot/bracket path lookups (`obj.get_path("a.b[0].c")`)
27
29
  - **New:** `set_path()` / `del_path()` for dot paths
28
30
  - **New:** `default_factory` + `autocreate_missing` for safe defaults
29
31
  - Utility methods: `to_dict`, `to_json`, `from_json`, `merge`
30
32
 
33
+ ## Release Notes
34
+
35
+ ### v2.1.0
36
+
37
+ - Renamed import path to `json_object_mapper` and aligned package metadata for the new distribution name.
38
+ - Added JSONPath-like query API:
39
+ - `obj.query(expression, first=False, default=None)`
40
+ - `obj.exists(expression)`
41
+ - `obj.count(expression)`
42
+ - `obj.compile(expression)`
43
+ - Implemented query features: property access, nested access, list indexing, wildcards, nested wildcard flattening, and basic filters (`==`, `!=`, `>`, `<`, `>=`, `<=`).
44
+ - Isolated query implementation into a dedicated module for maintainability.
45
+ - Added ORM-style list querying through `QueryableList`:
46
+ - `filter(...)`, `get(...)`, `first()`, `last()`, `count()`
47
+ - Supports nested field filters such as `profile__age__gt=20`.
48
+ - Expanded test coverage significantly across mapper, query parser, query engine, and queryable list behavior (including deep nested JSON scenarios and edge cases).
49
+ - Added strict type-checking support:
50
+ - mypy configuration in project metadata
51
+ - `py.typed` marker for typed package support
52
+ - type fixes in mapper and queryable helpers
53
+ - CI improvements:
54
+ - Ruff linting
55
+ - mypy checks
56
+ - multi-version unit tests
57
+ - build and package validation
58
+ - Publishing workflow configured for PyPI release automation.
59
+
31
60
  ## Install
32
61
 
33
62
  ```bash
@@ -87,6 +116,8 @@ except AttributeError:
87
116
  ```bash
88
117
  python -m pip install -e .
89
118
  python -m unittest discover -s tests -v
119
+ venv/bin/ruff check .
120
+ venv/bin/mypy .
90
121
  ```
91
122
 
92
123
  ## Publishing
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "json-object-mapper"
7
- version = "2.0.0"
7
+ version = "2.1.0"
8
8
  description = "Attribute-style access wrapper for JSON-like data with dot-path set/delete and safe defaults"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -27,6 +27,9 @@ include-package-data = true
27
27
  [tool.setuptools.packages.find]
28
28
  where = ["src"]
29
29
 
30
+ [tool.setuptools.package-data]
31
+ json_object_mapper = ["py.typed"]
32
+
30
33
  [tool.pytest.ini_options]
31
34
  addopts = "-q"
32
35
 
@@ -45,4 +48,10 @@ per-file-ignores = { "tests/*" = ["PLR2004"] } # allow magic numbers in tests
45
48
  [tool.ruff.format] # only used if you run "ruff format"
46
49
  quote-style = "double"
47
50
  indent-style = "space"
48
- skip-magic-trailing-comma = false
51
+ skip-magic-trailing-comma = false
52
+
53
+ [tool.mypy]
54
+ python_version = "3.9"
55
+ files = ["src", "tests"]
56
+ mypy_path = "src"
57
+ exclude = '(^build/|^dist/|^venv/|^\.venv/|^\.mypy_cache/|^setup\.py$)'
@@ -2,5 +2,6 @@
2
2
 
3
3
  from .core import JSONObjectMapper
4
4
  from .exceptions import JSONAccessError
5
+ from .queryable import QueryableList
5
6
 
6
- __all__ = ["JSONAccessError", "JSONObjectMapper"]
7
+ __all__ = ["JSONAccessError", "JSONObjectMapper", "QueryableList"]
@@ -26,6 +26,7 @@ from .helpers import (
26
26
  traverse_parent,
27
27
  wrap_value,
28
28
  )
29
+ from .query import evaluate_query, evaluate_tokens, tokenize
29
30
 
30
31
  JSONScalar = Union[str, int, float, bool, None]
31
32
  JSONType = Union[JSONScalar, "JSONObjectMapper", List["JSONObjectMapper"], Dict[str, Any]]
@@ -121,7 +122,10 @@ class JSONObjectMapper:
121
122
  raise AttributeError("Cannot set attributes on a list")
122
123
  if not is_identifier(attribute_name):
123
124
  raise AttributeError(f"'{attribute_name}' is not a valid attribute name")
124
- self.__json[attribute_name] = value
125
+ data = self.__json
126
+ if not isinstance(data, dict):
127
+ raise AttributeError("Cannot set attributes on a non-dict root")
128
+ data[attribute_name] = value
125
129
 
126
130
  # ----------------------------- mapping access -----------------------------
127
131
 
@@ -135,7 +139,18 @@ class JSONObjectMapper:
135
139
  def __setitem__(self, key: Union[int, str], value: Any) -> None:
136
140
  if self.__readonly:
137
141
  raise AttributeError("Mapper is read-only")
138
- self.__json[key] = value # type: ignore[index]
142
+ data = self.__json
143
+ if isinstance(data, dict):
144
+ if not isinstance(key, str):
145
+ raise TypeError("Dict roots require string keys")
146
+ data[key] = value
147
+ return
148
+ if isinstance(data, list):
149
+ if not isinstance(key, int):
150
+ raise TypeError("List roots require integer indexes")
151
+ data[key] = value
152
+ return
153
+ raise TypeError("Unsupported root type for item assignment")
139
154
 
140
155
  def __iter__(self) -> Iterator:
141
156
  return iter(self.__json)
@@ -165,6 +180,39 @@ class JSONObjectMapper:
165
180
  return (self._wrap(value) for value in self.__json.values())
166
181
  raise TypeError("values() only valid for dict roots")
167
182
 
183
+ # -------------------------------- query ops --------------------------------
184
+
185
+ def query(self, expression: str, *, first: bool = False, default: Any = None) -> Any:
186
+ results = self._evaluate_query(expression)
187
+ if not results:
188
+ if default is not None:
189
+ return default
190
+ return None if first else []
191
+ return results[0] if first else results
192
+
193
+ def exists(self, expression: str) -> bool:
194
+ return bool(self._evaluate_query(expression))
195
+
196
+ def count(self, expression: str) -> int:
197
+ return len(self._evaluate_query(expression))
198
+
199
+ def compile(self, expression: str):
200
+ tokens = tokenize(expression)
201
+
202
+ def runner(obj: "JSONObjectMapper") -> List[Any]:
203
+ if not isinstance(obj, JSONObjectMapper):
204
+ raise TypeError("Compiled query expects a JSONObjectMapper instance")
205
+ raw_data = object.__getattribute__(obj, "_JSONObjectMapper__json")
206
+ return evaluate_tokens(raw_data, tokens, obj._wrap)
207
+
208
+ return runner
209
+
210
+ def _evaluate_query(self, expression: str) -> List[Any]:
211
+ return evaluate_query(self.__json, expression, self._wrap)
212
+
213
+ def _evaluate_tokens(self, tokens: List[Any]) -> List[Any]:
214
+ return evaluate_tokens(self.__json, tokens, self._wrap)
215
+
168
216
  # -------------------------------- path ops --------------------------------
169
217
 
170
218
  def get_path(self, path: str, default: Any = None) -> Any:
@@ -1,6 +1,8 @@
1
1
  import re
2
2
  from typing import Any, Callable, List, Optional
3
3
 
4
+ from .queryable import QueryableList
5
+
4
6
 
5
7
  def is_identifier(key: str) -> bool:
6
8
  return bool(re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", key))
@@ -132,12 +134,14 @@ def wrap_value(
132
134
  _no_copy=True,
133
135
  )
134
136
  if isinstance(value, list):
135
- return [
136
- (
137
- wrap_value(item, readonly, default_factory, autocreate_missing, factory_object)
138
- if isinstance(item, (dict, list))
139
- else item
140
- )
141
- for item in value
142
- ]
137
+ return QueryableList(
138
+ [
139
+ (
140
+ wrap_value(item, readonly, default_factory, autocreate_missing, factory_object)
141
+ if isinstance(item, (dict, list))
142
+ else item
143
+ )
144
+ for item in value
145
+ ]
146
+ )
143
147
  return value
@@ -0,0 +1,149 @@
1
+ import re
2
+ from typing import Any, Callable, List, Tuple
3
+
4
+ QUERY_OPERATORS = ("==", "!=", ">=", "<=", ">", "<")
5
+
6
+
7
+ def tokenize(expr: str) -> List[Any]:
8
+ tokens: List[Any] = []
9
+ index = 0
10
+ source = expr.strip()
11
+
12
+ while index < len(source):
13
+ char = source[index]
14
+ if char == ".":
15
+ index += 1
16
+ continue
17
+
18
+ if char == "[":
19
+ close = source.find("]", index)
20
+ if close == -1:
21
+ raise ValueError(f"Unclosed bracket in query expression: {expr!r}")
22
+ content = source[index + 1 : close].strip()
23
+ if content == "*":
24
+ tokens.append("*")
25
+ elif content.startswith("?"):
26
+ condition = content[1:].strip()
27
+ if not condition:
28
+ raise ValueError(f"Empty filter condition in query expression: {expr!r}")
29
+ tokens.append({"filter": condition})
30
+ else:
31
+ try:
32
+ tokens.append(int(content))
33
+ except ValueError as error:
34
+ raise ValueError(
35
+ f"Unsupported bracket token [{content}] in {expr!r}"
36
+ ) from error
37
+ index = close + 1
38
+ continue
39
+
40
+ start = index
41
+ while index < len(source) and source[index] not in ".[":
42
+ index += 1
43
+ name = source[start:index].strip()
44
+ if name:
45
+ tokens.append(name)
46
+
47
+ return tokens
48
+
49
+
50
+ def evaluate_query(root: Any, expression: str, wrap: Callable[[Any], Any]) -> List[Any]:
51
+ return evaluate_tokens(root, tokenize(expression), wrap)
52
+
53
+
54
+ def evaluate_tokens(root: Any, tokens: List[Any], wrap: Callable[[Any], Any]) -> List[Any]:
55
+ results: List[Any] = [root]
56
+ for token in tokens:
57
+ next_results: List[Any] = []
58
+ for item in results:
59
+ if isinstance(token, str):
60
+ if token == "*":
61
+ if isinstance(item, list):
62
+ next_results.extend(item)
63
+ elif isinstance(item, dict):
64
+ next_results.extend(item.values())
65
+ continue
66
+ if isinstance(item, dict) and token in item:
67
+ next_results.append(item[token])
68
+ continue
69
+
70
+ if isinstance(token, int):
71
+ if isinstance(item, list) and 0 <= token < len(item):
72
+ next_results.append(item[token])
73
+ continue
74
+
75
+ if isinstance(token, dict) and "filter" in token:
76
+ if isinstance(item, list):
77
+ try:
78
+ next_results.extend(apply_filter(item, token["filter"]))
79
+ except ValueError:
80
+ continue
81
+ continue
82
+ results = next_results
83
+
84
+ return [wrap(value) for value in results]
85
+
86
+
87
+ def parse_condition(condition: str) -> Tuple[str, str, Any]:
88
+ text = condition.strip()
89
+ for operator in QUERY_OPERATORS:
90
+ if operator in text:
91
+ left, right = text.split(operator, 1)
92
+ field = left.strip()
93
+ value_text = right.strip()
94
+ if not field or not value_text:
95
+ raise ValueError(f"Invalid filter condition: {condition!r}")
96
+ return field, operator, coerce_condition_value(value_text)
97
+ raise ValueError(f"Unsupported filter condition: {condition!r}")
98
+
99
+
100
+ def coerce_condition_value(value: str) -> Any:
101
+ result: Any = value
102
+ if (value.startswith('"') and value.endswith('"')) or (
103
+ value.startswith("'") and value.endswith("'")
104
+ ):
105
+ result = value[1:-1]
106
+ elif re.fullmatch(r"-?\d+", value):
107
+ result = int(value)
108
+ elif re.fullmatch(r"-?\d+\.\d+", value):
109
+ result = float(value)
110
+ else:
111
+ lowered = value.lower()
112
+ if lowered == "true":
113
+ result = True
114
+ elif lowered == "false":
115
+ result = False
116
+ elif lowered == "null":
117
+ result = None
118
+ return result
119
+
120
+
121
+ def compare(left: Any, operator: str, right: Any) -> bool:
122
+ operations = {
123
+ "==": lambda a, b: a == b,
124
+ "!=": lambda a, b: a != b,
125
+ ">": lambda a, b: a > b,
126
+ "<": lambda a, b: a < b,
127
+ ">=": lambda a, b: a >= b,
128
+ "<=": lambda a, b: a <= b,
129
+ }
130
+ fn = operations.get(operator)
131
+ if fn is None:
132
+ return False
133
+ try:
134
+ return fn(left, right)
135
+ except TypeError:
136
+ return False
137
+
138
+
139
+ def apply_filter(items: List[Any], condition: str) -> List[Any]:
140
+ field, operator, expected = parse_condition(condition)
141
+ matches: List[Any] = []
142
+ for item in items:
143
+ if not isinstance(item, dict):
144
+ continue
145
+ if field not in item:
146
+ continue
147
+ if compare(item[field], operator, expected):
148
+ matches.append(item)
149
+ return matches
@@ -0,0 +1,80 @@
1
+ from typing import Any, Dict, Iterable
2
+
3
+ OPS = {"eq", "ne", "gt", "lt", "gte", "lte"}
4
+ _COUNT_SENTINEL = object()
5
+
6
+
7
+ class QueryableList(list):
8
+ """List wrapper with ORM-like helper methods."""
9
+
10
+ def __init__(self, data: Iterable[Any]):
11
+ super().__init__(data)
12
+
13
+ def filter(self, **conditions: Any) -> "QueryableList":
14
+ results = [item for item in self if _matches(item, conditions)]
15
+ return QueryableList(results)
16
+
17
+ def get(self, **conditions: Any) -> Any:
18
+ results = self.filter(**conditions)
19
+ if len(results) == 0:
20
+ raise ValueError("No matching item found")
21
+ if len(results) > 1:
22
+ raise ValueError("Multiple items found")
23
+ return results[0]
24
+
25
+ def first(self) -> Any:
26
+ return self[0] if self else None
27
+
28
+ def last(self) -> Any:
29
+ return self[-1] if self else None
30
+
31
+ def count(self, value: Any = _COUNT_SENTINEL, /) -> int:
32
+ if value is _COUNT_SENTINEL:
33
+ return len(self)
34
+ return super().count(value)
35
+
36
+
37
+ def _matches(item: Any, conditions: Dict[str, Any]) -> bool:
38
+ for key, expected in conditions.items():
39
+ field_parts = key.split("__")
40
+ if len(field_parts) > 1 and field_parts[-1] in OPS:
41
+ op = field_parts[-1]
42
+ field_path = "__".join(field_parts[:-1])
43
+ else:
44
+ op = "eq"
45
+ field_path = key
46
+
47
+ actual = _get_nested_attr(item, field_path)
48
+ if not _compare(actual, op, expected):
49
+ return False
50
+ return True
51
+
52
+
53
+ def _get_nested_attr(obj: Any, path: str) -> Any:
54
+ value = obj
55
+ for part in path.split("__"):
56
+ if value is None:
57
+ return None
58
+ if isinstance(value, dict):
59
+ value = value.get(part)
60
+ continue
61
+ value = getattr(value, part, None)
62
+ return value
63
+
64
+
65
+ def _compare(a: Any, op: str, b: Any) -> bool:
66
+ operations = {
67
+ "eq": lambda left, right: left == right,
68
+ "ne": lambda left, right: left != right,
69
+ "gt": lambda left, right: left > right,
70
+ "lt": lambda left, right: left < right,
71
+ "gte": lambda left, right: left >= right,
72
+ "lte": lambda left, right: left <= right,
73
+ }
74
+ fn = operations.get(op)
75
+ if fn is None:
76
+ return False
77
+ try:
78
+ return fn(a, b)
79
+ except TypeError:
80
+ return False
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: json-object-mapper
3
- Version: 2.0.0
3
+ Version: 2.1.0
4
4
  Summary: Attribute-style access wrapper for JSON-like data with dot-path set/delete and safe defaults
5
5
  Author-email: Kobby Owen <dev@kobbyowen.com>
6
6
  License-Expression: MIT
@@ -37,12 +37,41 @@ print(obj.to_json(indent=2))
37
37
 
38
38
  - Attribute-style access (`obj.key`) for dict keys
39
39
  - Recursive wrapping for nested dicts and lists
40
+ - Query engine (`query`, `exists`, `count`, `compile`) with wildcard and filter support
41
+ - ORM-like list helpers via `QueryableList` (`filter`, `get`, `first`, `last`, `count`)
40
42
  - Read-only mode (immutability enforced)
41
43
  - Dot/bracket path lookups (`obj.get_path("a.b[0].c")`)
42
44
  - **New:** `set_path()` / `del_path()` for dot paths
43
45
  - **New:** `default_factory` + `autocreate_missing` for safe defaults
44
46
  - Utility methods: `to_dict`, `to_json`, `from_json`, `merge`
45
47
 
48
+ ## Release Notes
49
+
50
+ ### v2.1.0
51
+
52
+ - Renamed import path to `json_object_mapper` and aligned package metadata for the new distribution name.
53
+ - Added JSONPath-like query API:
54
+ - `obj.query(expression, first=False, default=None)`
55
+ - `obj.exists(expression)`
56
+ - `obj.count(expression)`
57
+ - `obj.compile(expression)`
58
+ - Implemented query features: property access, nested access, list indexing, wildcards, nested wildcard flattening, and basic filters (`==`, `!=`, `>`, `<`, `>=`, `<=`).
59
+ - Isolated query implementation into a dedicated module for maintainability.
60
+ - Added ORM-style list querying through `QueryableList`:
61
+ - `filter(...)`, `get(...)`, `first()`, `last()`, `count()`
62
+ - Supports nested field filters such as `profile__age__gt=20`.
63
+ - Expanded test coverage significantly across mapper, query parser, query engine, and queryable list behavior (including deep nested JSON scenarios and edge cases).
64
+ - Added strict type-checking support:
65
+ - mypy configuration in project metadata
66
+ - `py.typed` marker for typed package support
67
+ - type fixes in mapper and queryable helpers
68
+ - CI improvements:
69
+ - Ruff linting
70
+ - mypy checks
71
+ - multi-version unit tests
72
+ - build and package validation
73
+ - Publishing workflow configured for PyPI release automation.
74
+
46
75
  ## Install
47
76
 
48
77
  ```bash
@@ -102,6 +131,8 @@ except AttributeError:
102
131
  ```bash
103
132
  python -m pip install -e .
104
133
  python -m unittest discover -s tests -v
134
+ venv/bin/ruff check .
135
+ venv/bin/mypy .
105
136
  ```
106
137
 
107
138
  ## Publishing
@@ -6,8 +6,14 @@ src/json_object_mapper/__init__.py
6
6
  src/json_object_mapper/core.py
7
7
  src/json_object_mapper/exceptions.py
8
8
  src/json_object_mapper/helpers.py
9
+ src/json_object_mapper/py.typed
10
+ src/json_object_mapper/query.py
11
+ src/json_object_mapper/queryable.py
9
12
  src/json_object_mapper.egg-info/PKG-INFO
10
13
  src/json_object_mapper.egg-info/SOURCES.txt
11
14
  src/json_object_mapper.egg-info/dependency_links.txt
12
15
  src/json_object_mapper.egg-info/top_level.txt
13
- tests/test_json_object_mapper.py
16
+ tests/test_json_object_mapper.py
17
+ tests/test_query_engine.py
18
+ tests/test_query_parser.py
19
+ tests/test_queryable_list.py
@@ -0,0 +1,165 @@
1
+ import unittest
2
+
3
+ from json_object_mapper import JSONObjectMapper
4
+
5
+
6
+ class TestQueryEngine(unittest.TestCase):
7
+ def setUp(self):
8
+ self.data = {
9
+ "users": [
10
+ {
11
+ "name": "Kobby",
12
+ "age": 29,
13
+ "skills": ["python", "aws"],
14
+ "score": 91.5,
15
+ "active": True,
16
+ "nickname": None,
17
+ "profile": {
18
+ "preferences": {
19
+ "theme": "dark",
20
+ "notifications": {"email": True, "sms": False},
21
+ },
22
+ "addresses": [
23
+ {"city": "Accra", "geo": {"lat": 5.6037, "lng": -0.187}},
24
+ {"city": "Kumasi", "geo": {"lat": 6.6885, "lng": -1.6244}},
25
+ ],
26
+ },
27
+ "projects": [
28
+ {
29
+ "name": "atlas",
30
+ "repos": [
31
+ {"name": "api", "stars": 120},
32
+ {"name": "worker", "stars": 55},
33
+ ],
34
+ },
35
+ {
36
+ "name": "pulse",
37
+ "repos": [{"name": "dashboard", "stars": 80}],
38
+ },
39
+ ],
40
+ },
41
+ {
42
+ "name": "Ama",
43
+ "age": 22,
44
+ "skills": ["design"],
45
+ "score": 88.0,
46
+ "active": False,
47
+ "nickname": "A",
48
+ "profile": {
49
+ "preferences": {
50
+ "theme": "light",
51
+ "notifications": {"email": False, "sms": True},
52
+ },
53
+ "addresses": [{"city": "Tamale", "geo": {"lat": 9.4075, "lng": -0.8533}}],
54
+ },
55
+ "projects": [
56
+ {
57
+ "name": "canvas",
58
+ "repos": [{"name": "ui-kit", "stars": 65}],
59
+ }
60
+ ],
61
+ },
62
+ {
63
+ "name": "Jo",
64
+ "skills": [],
65
+ "active": True,
66
+ "profile": {"preferences": {"theme": "dark"}},
67
+ "projects": [],
68
+ },
69
+ ],
70
+ "teams": {
71
+ "backend": [{"name": "Kobby"}],
72
+ "design": [{"name": "Ama"}],
73
+ },
74
+ }
75
+ self.obj = JSONObjectMapper(self.data)
76
+
77
+ def test_query_wildcard_names(self):
78
+ self.assertEqual(self.obj.query("users[*].name"), ["Kobby", "Ama", "Jo"])
79
+
80
+ def test_query_filter(self):
81
+ self.assertEqual(self.obj.query("users[?age > 25].name"), ["Kobby"])
82
+
83
+ def test_query_nested_wildcard_flatten(self):
84
+ self.assertEqual(self.obj.query("users[*].skills[*]"), ["python", "aws", "design"])
85
+
86
+ def test_query_first(self):
87
+ self.assertEqual(self.obj.query("users[0].name", first=True), "Kobby")
88
+
89
+ def test_query_default(self):
90
+ self.assertEqual(self.obj.query("invalid.path", default=[]), [])
91
+
92
+ def test_exists(self):
93
+ self.assertTrue(self.obj.exists("users[0].name"))
94
+
95
+ def test_count(self):
96
+ self.assertEqual(self.obj.count("users[*]"), 3)
97
+
98
+ def test_compile(self):
99
+ compiled = self.obj.compile("users[*].name")
100
+ self.assertEqual(compiled(self.obj), ["Kobby", "Ama", "Jo"])
101
+
102
+ def test_query_safe_missing_branch(self):
103
+ self.assertEqual(self.obj.query("users[*].profile.preferences.locale.timezone"), [])
104
+
105
+ def test_query_out_of_range_index(self):
106
+ self.assertEqual(self.obj.query("users[99].name"), [])
107
+
108
+ def test_query_filter_string_equals(self):
109
+ self.assertEqual(self.obj.query("users[?name == 'Ama'].name"), ["Ama"])
110
+
111
+ def test_query_filter_not_equals(self):
112
+ self.assertEqual(self.obj.query("users[?name != 'Ama'].name"), ["Kobby", "Jo"])
113
+
114
+ def test_query_filter_boolean(self):
115
+ self.assertEqual(self.obj.query("users[?active == true].name"), ["Kobby", "Jo"])
116
+
117
+ def test_query_filter_null(self):
118
+ self.assertEqual(self.obj.query("users[?nickname == null].name"), ["Kobby"])
119
+
120
+ def test_query_filter_float(self):
121
+ self.assertEqual(self.obj.query("users[?score >= 90.0].name"), ["Kobby"])
122
+
123
+ def test_query_invalid_filter_is_safe(self):
124
+ self.assertEqual(self.obj.query("users[?age ~~ 10].name"), [])
125
+
126
+ def test_query_root_dict_wildcard(self):
127
+ self.assertEqual(self.obj.query("teams.*[*].name"), ["Kobby", "Ama"])
128
+
129
+ def test_query_first_without_default(self):
130
+ self.assertIsNone(self.obj.query("users[99].name", first=True))
131
+
132
+ def test_query_first_with_default(self):
133
+ self.assertEqual(self.obj.query("users[99].name", first=True, default="missing"), "missing")
134
+
135
+ def test_exists_false(self):
136
+ self.assertFalse(self.obj.exists("users[?age < 0].name"))
137
+
138
+ def test_compile_rejects_non_mapper(self):
139
+ compiled = self.obj.compile("users[*].name")
140
+ with self.assertRaises(TypeError):
141
+ compiled({"users": []})
142
+
143
+ def test_compile_reusable_on_another_mapper(self):
144
+ compiled = self.obj.compile("users[*].name")
145
+ another = JSONObjectMapper({"users": [{"name": "Esi"}]})
146
+ self.assertEqual(compiled(another), ["Esi"])
147
+
148
+ def test_query_deep_property_path(self):
149
+ self.assertEqual(self.obj.query("users[0].profile.preferences.theme"), ["dark"])
150
+
151
+ def test_query_deep_index_access(self):
152
+ self.assertEqual(self.obj.query("users[0].profile.addresses[1].geo.lat"), [6.6885])
153
+
154
+ def test_query_multi_wildcard_deep_flatten(self):
155
+ self.assertEqual(
156
+ self.obj.query("users[*].projects[*].repos[*].name"),
157
+ ["api", "worker", "dashboard", "ui-kit"],
158
+ )
159
+
160
+ def test_query_filter_then_deep_path(self):
161
+ self.assertEqual(self.obj.query("users[?age >= 25].profile.preferences.theme"), ["dark"])
162
+
163
+
164
+ if __name__ == "__main__":
165
+ unittest.main()
@@ -0,0 +1,70 @@
1
+ import unittest
2
+
3
+ from json_object_mapper.query import (
4
+ apply_filter,
5
+ coerce_condition_value,
6
+ compare,
7
+ parse_condition,
8
+ tokenize,
9
+ )
10
+
11
+
12
+ class TestQueryParser(unittest.TestCase):
13
+ def test_tokenize_index_and_property(self):
14
+ self.assertEqual(tokenize("users[0].name"), ["users", 0, "name"])
15
+
16
+ def test_tokenize_wildcard(self):
17
+ self.assertEqual(tokenize("users[*].name"), ["users", "*", "name"])
18
+
19
+ def test_tokenize_filter(self):
20
+ self.assertEqual(tokenize("users[?age > 25]"), ["users", {"filter": "age > 25"}])
21
+
22
+ def test_parse_condition(self):
23
+ self.assertEqual(parse_condition("age >= 25"), ("age", ">=", 25))
24
+
25
+ def test_apply_filter(self):
26
+ items = [
27
+ {"name": "Kobby", "age": 29},
28
+ {"name": "Ama", "age": 22},
29
+ ]
30
+ self.assertEqual(apply_filter(items, "age > 25"), [{"name": "Kobby", "age": 29}])
31
+
32
+ def test_tokenize_unclosed_bracket_raises(self):
33
+ with self.assertRaises(ValueError):
34
+ tokenize("users[0")
35
+
36
+ def test_tokenize_empty_filter_raises(self):
37
+ with self.assertRaises(ValueError):
38
+ tokenize("users[?]")
39
+
40
+ def test_tokenize_unsupported_bracket_token_raises(self):
41
+ with self.assertRaises(ValueError):
42
+ tokenize("users[abc]")
43
+
44
+ def test_parse_condition_with_string_value(self):
45
+ self.assertEqual(parse_condition("name == 'Ama'"), ("name", "==", "Ama"))
46
+
47
+ def test_parse_condition_with_float_value(self):
48
+ self.assertEqual(parse_condition("score >= 91.5"), ("score", ">=", 91.5))
49
+
50
+ def test_parse_condition_with_bool_and_null(self):
51
+ self.assertEqual(parse_condition("active == true"), ("active", "==", True))
52
+ self.assertEqual(parse_condition("nickname == null"), ("nickname", "==", None))
53
+
54
+ def test_parse_condition_invalid_operator_raises(self):
55
+ with self.assertRaises(ValueError):
56
+ parse_condition("age ~~ 10")
57
+
58
+ def test_coerce_condition_value_negative_int(self):
59
+ self.assertEqual(coerce_condition_value("-42"), -42)
60
+
61
+ def test_compare_type_error_is_false(self):
62
+ self.assertFalse(compare("abc", ">", 10))
63
+
64
+ def test_apply_filter_skips_non_dict_items(self):
65
+ items = [{"age": 10}, "invalid", 2, {"age": 30}]
66
+ self.assertEqual(apply_filter(items, "age >= 20"), [{"age": 30}])
67
+
68
+
69
+ if __name__ == "__main__":
70
+ unittest.main()
@@ -0,0 +1,79 @@
1
+ import unittest
2
+
3
+ from json_object_mapper import JSONObjectMapper, QueryableList
4
+
5
+
6
+ class TestQueryableList(unittest.TestCase):
7
+ def setUp(self):
8
+ self.data = {
9
+ "users": [
10
+ {"name": "Kobby", "age": 29, "profile": {"age": 29, "city": "Accra"}},
11
+ {"name": "Ama", "age": 22, "profile": {"age": 22, "city": "Tamale"}},
12
+ {"name": "Kobby", "age": 35, "profile": {"age": 35, "city": "Kumasi"}},
13
+ ]
14
+ }
15
+ self.obj = JSONObjectMapper(self.data)
16
+
17
+ def test_users_is_queryable_list(self):
18
+ self.assertIsInstance(self.obj.users, QueryableList)
19
+
20
+ def test_filter_eq(self):
21
+ results = self.obj.users.filter(name="Ama")
22
+ self.assertEqual(results.count(), 1)
23
+ self.assertEqual(results.first().name, "Ama")
24
+
25
+ def test_filter_operator_gt(self):
26
+ results = self.obj.users.filter(age__gt=25)
27
+ self.assertEqual([item.name for item in results], ["Kobby", "Kobby"])
28
+
29
+ def test_filter_operator_lte(self):
30
+ results = self.obj.users.filter(age__lte=29)
31
+ self.assertEqual([item.name for item in results], ["Kobby", "Ama"])
32
+
33
+ def test_filter_multiple_conditions(self):
34
+ results = self.obj.users.filter(name="Kobby", age__gte=30)
35
+ self.assertEqual(results.count(), 1)
36
+ self.assertEqual(results.first().age, 35)
37
+
38
+ def test_filter_chainable(self):
39
+ result = self.obj.users.filter(age__gt=20).filter(name="Kobby")
40
+ self.assertIsInstance(result, QueryableList)
41
+ self.assertEqual([item.age for item in result], [29, 35])
42
+
43
+ def test_get_single(self):
44
+ result = self.obj.users.get(name="Ama")
45
+ self.assertEqual(result.age, 22)
46
+
47
+ def test_get_no_match_raises(self):
48
+ with self.assertRaises(ValueError):
49
+ self.obj.users.get(name="Missing")
50
+
51
+ def test_get_multiple_match_raises(self):
52
+ with self.assertRaises(ValueError):
53
+ self.obj.users.get(name="Kobby")
54
+
55
+ def test_first_last(self):
56
+ self.assertEqual(self.obj.users.first().name, "Kobby")
57
+ self.assertEqual(self.obj.users.last().name, "Kobby")
58
+
59
+ def test_count(self):
60
+ self.assertEqual(self.obj.users.count(), 3)
61
+
62
+ def test_nested_field_support(self):
63
+ results = self.obj.users.filter(profile__age__gt=30)
64
+ self.assertEqual(results.count(), 1)
65
+ self.assertEqual(results.first().name, "Kobby")
66
+
67
+ def test_nested_field_missing_is_safe(self):
68
+ results = self.obj.users.filter(profile__country="GH")
69
+ self.assertEqual(results.count(), 0)
70
+
71
+ def test_queryable_list_on_nested_list(self):
72
+ mapped = JSONObjectMapper({"groups": [{"members": [{"name": "A"}, {"name": "B"}]}]})
73
+ members = mapped.groups[0].members
74
+ self.assertIsInstance(members, QueryableList)
75
+ self.assertEqual(members.filter(name="B").first().name, "B")
76
+
77
+
78
+ if __name__ == "__main__":
79
+ unittest.main()