json-castle 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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Marian Pekár
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.
@@ -0,0 +1,178 @@
1
+ Metadata-Version: 2.4
2
+ Name: json-castle
3
+ Version: 0.1.0
4
+ Summary: Edescription = Built on top of native json module for deserialization from JSON to data classes with additional support for nested objects with variables, environment variables, and post-load overrides.
5
+ Home-page: https://github.com/marianpekar/json-castle
6
+ Author: Marian Pekár
7
+ Author-email: marian.pekar@gmail.com
8
+ License: MIT
9
+ Keywords: json,parser,deserializer,cicd,tool
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: File Formats :: JSON
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Development Status :: 3 - Alpha
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Dynamic: license-file
18
+
19
+ # JsonCastle
20
+
21
+ JsonCastle is a Python module built on top of the native json module for deserialization from JSON to data classes with additional support for:
22
+
23
+ - nested objects
24
+ - JSON nariables
25
+ - environment variables
26
+ - post-load overrides, including overriding, adding, and removing collection elements via special CLI argument syntax like `node.next_node.number=2`, `~node.tags[0]`, `+node.next_node.tags=foo`, `~node.next_node.tags=buzz`, etc.
27
+
28
+ With these features, you might find it especially useful in CI/CD pipelines.
29
+
30
+ ## Getting Started
31
+
32
+ Imagine you have a JSON configuration file for an automated process, but you want to run this process with slightly different variants of this file. Typically, you'd end up with multiple very similar configuration files, or you'd create an ad-hoc solution that would allow you to override certain parameters via CLI arguments. JsonCastle gives you such a solution out of the box for all properties of your data scheme, even for those in nested objects.
33
+
34
+ Since native json doesn't support variables, such configuration files might have a lot of repeated strings. Editing such files can be tedious and error-prone. You write a JSON file with variables and environment variables as the following example:
35
+
36
+ ```json
37
+ {
38
+ "$home_path": "%HOME%",
39
+ "$package_path": "${home_path}debug",
40
+ "exe_path": "${package_path}/mytool.exe",
41
+ "node": {
42
+ "number": 0,
43
+ "tags": ["foo", "bar"],
44
+ "next_node": {
45
+ "number": 1,
46
+ "tags": ["fizz", "buzz"]
47
+ }
48
+ }
49
+ }
50
+ ```
51
+
52
+ Assuming the `HOME` environment variable is set, for example, to `C:/users/cicd/`, then the value of the `$package_path` variable will be expanded from `${home_path}debug` to `C:/users/cicd/debug`, and the value of `exe_path` will be `C:/users/cicd/debug/mytool.exe`.
53
+
54
+ In Python, you'd define your data classes that match the scheme:
55
+
56
+ ```python
57
+ from dataclasses import dataclass
58
+
59
+ @dataclass
60
+ class Cfg:
61
+ exe_path: str = None
62
+ node: Node = None
63
+
64
+ @dataclass
65
+ class Node:
66
+ number: int
67
+ tags: list[str]
68
+ processed: False
69
+ next_node: Node = None
70
+ ```
71
+
72
+ Now you can use the `JsonCastle.load_from_file` method to read and parse `example.json` and get an instance of the `Cfg` class:
73
+
74
+ ```python
75
+ from pprint import pprint
76
+ import sys
77
+
78
+ from json_castle import JsonCastle
79
+
80
+ cfg = JsonCastle.load_from_file(Cfg, "example.json", **JsonCastle.parse_args(sys.argv))
81
+ pprint(cfg)
82
+ ```
83
+
84
+ If you run the Python script without any arguments, you’d see cfg printed as expected:
85
+
86
+ ```txt
87
+ Cfg(exe_path='C:/users/cicd/debug/mytool.exe',
88
+ node={'next_node': {'number': 1, 'tags': ['fizz', 'buzz']},
89
+ 'number': 0,
90
+ 'tags': ['foo', 'bar']})
91
+ ```
92
+
93
+ However, by passing `**JsonCastle.parse_args(sys.argv)` as the third argument, you can override any values post-load via CLI. Running the script again with the arguments `node.number=1 ~node.tags[0] node.next_node.number=2 +node.next_node.tags=foo ~node.next_node.tags=buzz` will give you a cfg instance like this:
94
+
95
+ ```txt
96
+ Cfg(exe_path='C:/users/cicd/debug/mytool.exe',
97
+ node={'next_node': {'number': 2, 'tags': ['fizz', 'foo']},
98
+ 'number': 1,
99
+ 'tags': ['bar']})
100
+ ```
101
+
102
+ If you want to deserialize your JSON from a string stream, you can use the static method `load(cls, stream: IO[str], **kwargs)`, the usage is the same as of the `load_from_file(cls, path: str, **kwargs)` in the example above, but you provide your stream as the second argument instead of filepath.
103
+
104
+ ## CLI Overrides Syntax
105
+
106
+ Although some of the syntax for post-load overrides via the CLI was introduced already in the [Getting Started](#getting-started) section, here's an overview of all options with examples.
107
+
108
+ ### Basic Syntax
109
+
110
+ `JsonCastle.parse_args` method accepts a list of strings, where each element is a `key=value` pair except the one for removing an item by index, that only has a key, in which case, in the dictionary the method returns, the value is `None`.
111
+
112
+ Of course, you don't have to use this method and construct a dictionary by yourself instead; the `JsonCastle.parse_args` just provides you a convenient way for post-load overriding via CLI, which is particularly useful in CI/CD pipelines. Here are some examples of CLI args for overriding top-level pairs:
113
+
114
+ ```txt
115
+ age=27 name=John full_name="John Smith" active=true balance=123.45
116
+ ```
117
+
118
+ ### Nested Objects
119
+
120
+ If you need to override a nested value, you specify a path, separating objects with `.`, as you can see in the [Getting Started](#getting-started). The following example shows how to override several pairs nested in the `person` object.
121
+
122
+ ```txt
123
+ person.age=27 person.name=John person.full_name="John Smith" person.active=true person.balance=123.45
124
+ ```
125
+
126
+ ### Working with Collections
127
+
128
+ When it comes to collections, you can change, add, or remove an item as well. If the collection is a nested object, the rules are the same as described in the [Nested Objects](#nested-objects) section.
129
+
130
+ #### Overriding an Item
131
+
132
+ To override an item, you provide a path to the collection, in square brackets an index of the item you wish to override, and a new value on the right side of the `=` symbol. The following example shows how to change the value of the first element of the `tags` collection nested in the `page` object to `programming`.
133
+
134
+ ```txt
135
+ page.tags[0]=programming
136
+ ```
137
+
138
+ #### Adding a new Item
139
+
140
+ When you wish to add a new item to a collection, you prefix the `key=value` pair with a `+` symbol. The following example shows how to add a new value of `python` to the `tags` collection nested in the `page` object.
141
+
142
+ ```txt
143
+ +page.tags=python
144
+ ```
145
+
146
+ #### Removing an Item by Index
147
+
148
+ If you wish to remove an item at specific index, you prefix a path to the collection with index in square brackets of the element you wish to remove with the `~` symbol. The following example shows how to remove the second item from the tags collection nested in the page object.
149
+
150
+ ```txt
151
+ ~page.tags[1]
152
+ ```
153
+
154
+ If any items follow the removed one, they will be pushed down, and the new length of the collection will be n-1.
155
+
156
+ #### Removing an Item by Value
157
+
158
+ If you wish to remove an item of a specific value, instead of the index as described in Removing an Item by Index, you provide a key=value pair prefixed with ~. The following example shows how to remove an item with a value of “programming” from the tags collection nested in the page object.
159
+
160
+ ```txt
161
+ ~page.tags=programming
162
+ ```
163
+
164
+ If there is more than one occurence if `programming` string in the collection, only the first one will be removed.
165
+
166
+ ## Unit Tests
167
+
168
+ Unit Tests are not just a great way to ensure nothing is broken when a new feature is added, but also for implementing new features using TDD, which is the recommended way for extending this library. These tests can also serve as an overview of what the library is capable of. You can find all tests in the unit_test.py file.
169
+
170
+ ## Future Ideas
171
+
172
+ * Add support for conditionals, loops, and mathematical expressions in JSON.
173
+ * Extend the `parse_args` method to allow adding a custom object to a collection.
174
+ * Add regex support for removing items by value.
175
+ * When removing an item from a collection by value, let the user decide whether they want to remove just one or all items that match the value (`~page.tags=programming` removes the first; `~!page.tags=programming` removes all).
176
+ * Add support for removing items from a numerical collection by conditions `>`, `<`, `<=` , or `=>`.
177
+ * Add support for removing items by range (i.e. `~items[1:4]` would remove items at indices 1, 2, 3 and 4).
178
+ * Add support for removing custom items by condition (i.e., `~people={age < 16}` would remove from the people collection all objects with the age key-value pair with age less than 16).
@@ -0,0 +1,160 @@
1
+ # JsonCastle
2
+
3
+ JsonCastle is a Python module built on top of the native json module for deserialization from JSON to data classes with additional support for:
4
+
5
+ - nested objects
6
+ - JSON nariables
7
+ - environment variables
8
+ - post-load overrides, including overriding, adding, and removing collection elements via special CLI argument syntax like `node.next_node.number=2`, `~node.tags[0]`, `+node.next_node.tags=foo`, `~node.next_node.tags=buzz`, etc.
9
+
10
+ With these features, you might find it especially useful in CI/CD pipelines.
11
+
12
+ ## Getting Started
13
+
14
+ Imagine you have a JSON configuration file for an automated process, but you want to run this process with slightly different variants of this file. Typically, you'd end up with multiple very similar configuration files, or you'd create an ad-hoc solution that would allow you to override certain parameters via CLI arguments. JsonCastle gives you such a solution out of the box for all properties of your data scheme, even for those in nested objects.
15
+
16
+ Since native json doesn't support variables, such configuration files might have a lot of repeated strings. Editing such files can be tedious and error-prone. You write a JSON file with variables and environment variables as the following example:
17
+
18
+ ```json
19
+ {
20
+ "$home_path": "%HOME%",
21
+ "$package_path": "${home_path}debug",
22
+ "exe_path": "${package_path}/mytool.exe",
23
+ "node": {
24
+ "number": 0,
25
+ "tags": ["foo", "bar"],
26
+ "next_node": {
27
+ "number": 1,
28
+ "tags": ["fizz", "buzz"]
29
+ }
30
+ }
31
+ }
32
+ ```
33
+
34
+ Assuming the `HOME` environment variable is set, for example, to `C:/users/cicd/`, then the value of the `$package_path` variable will be expanded from `${home_path}debug` to `C:/users/cicd/debug`, and the value of `exe_path` will be `C:/users/cicd/debug/mytool.exe`.
35
+
36
+ In Python, you'd define your data classes that match the scheme:
37
+
38
+ ```python
39
+ from dataclasses import dataclass
40
+
41
+ @dataclass
42
+ class Cfg:
43
+ exe_path: str = None
44
+ node: Node = None
45
+
46
+ @dataclass
47
+ class Node:
48
+ number: int
49
+ tags: list[str]
50
+ processed: False
51
+ next_node: Node = None
52
+ ```
53
+
54
+ Now you can use the `JsonCastle.load_from_file` method to read and parse `example.json` and get an instance of the `Cfg` class:
55
+
56
+ ```python
57
+ from pprint import pprint
58
+ import sys
59
+
60
+ from json_castle import JsonCastle
61
+
62
+ cfg = JsonCastle.load_from_file(Cfg, "example.json", **JsonCastle.parse_args(sys.argv))
63
+ pprint(cfg)
64
+ ```
65
+
66
+ If you run the Python script without any arguments, you’d see cfg printed as expected:
67
+
68
+ ```txt
69
+ Cfg(exe_path='C:/users/cicd/debug/mytool.exe',
70
+ node={'next_node': {'number': 1, 'tags': ['fizz', 'buzz']},
71
+ 'number': 0,
72
+ 'tags': ['foo', 'bar']})
73
+ ```
74
+
75
+ However, by passing `**JsonCastle.parse_args(sys.argv)` as the third argument, you can override any values post-load via CLI. Running the script again with the arguments `node.number=1 ~node.tags[0] node.next_node.number=2 +node.next_node.tags=foo ~node.next_node.tags=buzz` will give you a cfg instance like this:
76
+
77
+ ```txt
78
+ Cfg(exe_path='C:/users/cicd/debug/mytool.exe',
79
+ node={'next_node': {'number': 2, 'tags': ['fizz', 'foo']},
80
+ 'number': 1,
81
+ 'tags': ['bar']})
82
+ ```
83
+
84
+ If you want to deserialize your JSON from a string stream, you can use the static method `load(cls, stream: IO[str], **kwargs)`, the usage is the same as of the `load_from_file(cls, path: str, **kwargs)` in the example above, but you provide your stream as the second argument instead of filepath.
85
+
86
+ ## CLI Overrides Syntax
87
+
88
+ Although some of the syntax for post-load overrides via the CLI was introduced already in the [Getting Started](#getting-started) section, here's an overview of all options with examples.
89
+
90
+ ### Basic Syntax
91
+
92
+ `JsonCastle.parse_args` method accepts a list of strings, where each element is a `key=value` pair except the one for removing an item by index, that only has a key, in which case, in the dictionary the method returns, the value is `None`.
93
+
94
+ Of course, you don't have to use this method and construct a dictionary by yourself instead; the `JsonCastle.parse_args` just provides you a convenient way for post-load overriding via CLI, which is particularly useful in CI/CD pipelines. Here are some examples of CLI args for overriding top-level pairs:
95
+
96
+ ```txt
97
+ age=27 name=John full_name="John Smith" active=true balance=123.45
98
+ ```
99
+
100
+ ### Nested Objects
101
+
102
+ If you need to override a nested value, you specify a path, separating objects with `.`, as you can see in the [Getting Started](#getting-started). The following example shows how to override several pairs nested in the `person` object.
103
+
104
+ ```txt
105
+ person.age=27 person.name=John person.full_name="John Smith" person.active=true person.balance=123.45
106
+ ```
107
+
108
+ ### Working with Collections
109
+
110
+ When it comes to collections, you can change, add, or remove an item as well. If the collection is a nested object, the rules are the same as described in the [Nested Objects](#nested-objects) section.
111
+
112
+ #### Overriding an Item
113
+
114
+ To override an item, you provide a path to the collection, in square brackets an index of the item you wish to override, and a new value on the right side of the `=` symbol. The following example shows how to change the value of the first element of the `tags` collection nested in the `page` object to `programming`.
115
+
116
+ ```txt
117
+ page.tags[0]=programming
118
+ ```
119
+
120
+ #### Adding a new Item
121
+
122
+ When you wish to add a new item to a collection, you prefix the `key=value` pair with a `+` symbol. The following example shows how to add a new value of `python` to the `tags` collection nested in the `page` object.
123
+
124
+ ```txt
125
+ +page.tags=python
126
+ ```
127
+
128
+ #### Removing an Item by Index
129
+
130
+ If you wish to remove an item at specific index, you prefix a path to the collection with index in square brackets of the element you wish to remove with the `~` symbol. The following example shows how to remove the second item from the tags collection nested in the page object.
131
+
132
+ ```txt
133
+ ~page.tags[1]
134
+ ```
135
+
136
+ If any items follow the removed one, they will be pushed down, and the new length of the collection will be n-1.
137
+
138
+ #### Removing an Item by Value
139
+
140
+ If you wish to remove an item of a specific value, instead of the index as described in Removing an Item by Index, you provide a key=value pair prefixed with ~. The following example shows how to remove an item with a value of “programming” from the tags collection nested in the page object.
141
+
142
+ ```txt
143
+ ~page.tags=programming
144
+ ```
145
+
146
+ If there is more than one occurence if `programming` string in the collection, only the first one will be removed.
147
+
148
+ ## Unit Tests
149
+
150
+ Unit Tests are not just a great way to ensure nothing is broken when a new feature is added, but also for implementing new features using TDD, which is the recommended way for extending this library. These tests can also serve as an overview of what the library is capable of. You can find all tests in the unit_test.py file.
151
+
152
+ ## Future Ideas
153
+
154
+ * Add support for conditionals, loops, and mathematical expressions in JSON.
155
+ * Extend the `parse_args` method to allow adding a custom object to a collection.
156
+ * Add regex support for removing items by value.
157
+ * When removing an item from a collection by value, let the user decide whether they want to remove just one or all items that match the value (`~page.tags=programming` removes the first; `~!page.tags=programming` removes all).
158
+ * Add support for removing items from a numerical collection by conditions `>`, `<`, `<=` , or `=>`.
159
+ * Add support for removing items by range (i.e. `~items[1:4]` would remove items at indices 1, 2, 3 and 4).
160
+ * Add support for removing custom items by condition (i.e., `~people={age < 16}` would remove from the people collection all objects with the age key-value pair with age less than 16).
@@ -0,0 +1,3 @@
1
+ [build-system]
2
+ requires = ["setuptools"]
3
+ build-backend = "setuptools.build_meta"
@@ -0,0 +1,22 @@
1
+ [metadata]
2
+ name = json-castle
3
+ version = 0.1.0
4
+ author = Marian Pekár
5
+ author_email = marian.pekar@gmail.com
6
+ url = https://github.com/marianpekar/json-castle
7
+ description = Edescription = Built on top of native json module for deserialization from JSON to data classes with additional support for nested objects with variables, environment variables, and post-load overrides.
8
+ long_description = file: README.md
9
+ long_description_content_type = text/markdown
10
+ keywords = json, parser, deserializer, cicd, tool
11
+ license = MIT
12
+ classifiers =
13
+ License :: OSI Approved :: MIT License
14
+ Programming Language :: Python :: 3
15
+ Topic :: File Formats :: JSON
16
+ Intended Audience :: Developers
17
+ Development Status :: 3 - Alpha
18
+
19
+ [egg_info]
20
+ tag_build =
21
+ tag_date = 0
22
+
@@ -0,0 +1 @@
1
+ from json_castle.core import JsonCastle
@@ -0,0 +1,235 @@
1
+ from dataclasses import is_dataclass, fields
2
+ from typing import IO, get_origin, get_args, Union
3
+ from enum import Enum
4
+ import os
5
+ import re
6
+ import json
7
+
8
+ class JsonCastle:
9
+ """Built on top of the native json module for deserialization from JSON to data classes
10
+ with additional support for nested objects with variables, environment variables, and
11
+ post-load overrides."""
12
+
13
+ __VAR_PATTERN = re.compile(r"\$\{(\w+)\}")
14
+ __ENV_VAR_PATTERN = re.compile(r"%(\w+)%")
15
+ __INDEXER_PATTERN = re.compile(r"(\w+)\[(\d+)\]")
16
+
17
+ @staticmethod
18
+ def parse_args(argv):
19
+ """Parses command line arguments and returns a dictionary that can be passed
20
+ as **kwargs to load_from_file and load methods."""
21
+ dct = {}
22
+ for arg in argv[1:]:
23
+ if "=" in arg:
24
+ k, v = arg.split("=", 1)
25
+ dct[k] = v
26
+ else:
27
+ dct[arg] = None
28
+ return dct
29
+
30
+ @staticmethod
31
+ def load_from_file(cls, path: str, **kwargs):
32
+ """Read a JSON file at path and returns an instance of cls. Optionally you can
33
+ pass **kwargs to post-load overrides."""
34
+ with open(path, "r") as f:
35
+ return JsonCastle.load(cls, f, **kwargs)
36
+
37
+ @staticmethod
38
+ def load(cls, stream: IO[str], **kwargs):
39
+ """Parses a JSON stream and returns an instance of cls. Optionally you can
40
+ pass **kwargs to post-load overrides."""
41
+ dct = json.load(stream)
42
+ dct = JsonCastle.__substitute_variables(dct)
43
+
44
+ for k, v in kwargs.items():
45
+ if k.startswith("+"):
46
+ JsonCastle.__add_item(dct, k[1:].split("."), v)
47
+ elif k.startswith("~"):
48
+ JsonCastle.__remove_item(dct, k[1:].split("."), v)
49
+ else:
50
+ JsonCastle.__apply_overrides(dct, k.split("."), v)
51
+
52
+ return JsonCastle.__from_dict(cls, dct)
53
+
54
+ @staticmethod
55
+ def __substitute_variables(dct, vars=None):
56
+ if vars is None:
57
+ vars = {}
58
+
59
+ if isinstance(dct, dict):
60
+ result = {}
61
+ for k, v in dct.items():
62
+ if k.startswith("$"):
63
+ v = JsonCastle.__substitute_variables(v, vars)
64
+ vars[k[1:]] = v
65
+ else:
66
+ result[k] = JsonCastle.__substitute_variables(v, vars)
67
+ return result
68
+
69
+ elif isinstance(dct, list):
70
+ return [JsonCastle.__substitute_variables(item, vars) for item in dct]
71
+
72
+ elif isinstance(dct, str):
73
+ if dct.startswith("${") and dct.endswith("}"):
74
+ var_name = dct[2:-1]
75
+ return vars.get(var_name, dct)
76
+
77
+ def repl_var(match):
78
+ var_name = match.group(1)
79
+ return str(vars.get(var_name, match.group(0)))
80
+
81
+ dct = JsonCastle.__VAR_PATTERN.sub(repl_var, dct)
82
+
83
+ def repl_env(match):
84
+ env_name = match.group(1)
85
+ return os.environ.get(env_name, match.group(0))
86
+
87
+ return JsonCastle.__ENV_VAR_PATTERN.sub(repl_env, dct)
88
+
89
+ else:
90
+ return dct
91
+
92
+ @staticmethod
93
+ def __apply_overrides(dct, path, value):
94
+ current = dct
95
+
96
+ for i, part in enumerate(path):
97
+ match = JsonCastle.__INDEXER_PATTERN.fullmatch(part)
98
+
99
+ if match:
100
+ key, idx = match.group(1), int(match.group(2))
101
+
102
+ if key not in current or not isinstance(current[key], list):
103
+ current[key] = []
104
+
105
+ while len(current[key]) <= idx:
106
+ current[key].append({})
107
+
108
+ if i == len(path) - 1:
109
+ current[key][idx] = JsonCastle.__cast(value)
110
+ else:
111
+ current = current[key][idx]
112
+ else:
113
+ if i == len(path) - 1:
114
+ current[part] = JsonCastle.__cast(value)
115
+ else:
116
+ if part not in current or not isinstance(current[part], dict):
117
+ current[part] = {}
118
+ current = current[part]
119
+
120
+ @staticmethod
121
+ def __add_item(dct, path, new_item):
122
+ current = dct
123
+
124
+ for part in path[:-1]:
125
+ if part not in current or not isinstance(current[part], dict):
126
+ current[part] = {}
127
+ current = current[part]
128
+
129
+ last = path[-1]
130
+ if last not in current or not isinstance(current[last], list):
131
+ current[last] = []
132
+
133
+ current[last].append(JsonCastle.__cast(new_item))
134
+
135
+ @staticmethod
136
+ def __cast(value):
137
+ if not isinstance(value, str):
138
+ return value
139
+
140
+ if value.lower() in ("true", "false"):
141
+ return value.lower() == "true"
142
+
143
+ try:
144
+ if "." in value:
145
+ return float(value)
146
+ return int(value)
147
+ except ValueError:
148
+ return value
149
+
150
+ @staticmethod
151
+ def __remove_item(dct, path, value=None):
152
+ current = dct
153
+
154
+ for idx, part in enumerate(path):
155
+ match = JsonCastle.__INDEXER_PATTERN.fullmatch(part)
156
+
157
+ if match:
158
+ key, index = match.group(1), int(match.group(2))
159
+
160
+ if key not in current or not isinstance(current[key], list):
161
+ return
162
+
163
+ if idx == len(path) - 1:
164
+ if 0 <= index < len(current[key]):
165
+ current[key].pop(index)
166
+ return
167
+ else:
168
+ if 0 <= index < len(current[key]):
169
+ current = current[key][index]
170
+ else:
171
+ return
172
+ else:
173
+ if idx == len(path) - 1:
174
+ if value is None:
175
+ current.pop(part, None)
176
+ else:
177
+ current[part].remove(value)
178
+ return
179
+ else:
180
+ if part not in current or not isinstance(current[part], dict):
181
+ return
182
+ current = current[part]
183
+
184
+ @staticmethod
185
+ def __from_dict(cls, dct):
186
+ if not is_dataclass(cls) or dct is None:
187
+ return dct
188
+
189
+ kwargs = {}
190
+ for f in fields(cls):
191
+ field_value = dct.get(f.name)
192
+ field_type = f.type
193
+
194
+ if field_value is None:
195
+ kwargs[f.name] = None
196
+ continue
197
+
198
+ kwargs[f.name] = JsonCastle.__convert_value(field_type, field_value)
199
+
200
+ return cls(**kwargs)
201
+
202
+ @staticmethod
203
+ def __convert_value(field_type, value):
204
+ origin = get_origin(field_type)
205
+ args = get_args(field_type)
206
+
207
+ if origin is Union:
208
+ for arg in args:
209
+ if arg is type(None):
210
+ continue
211
+ try:
212
+ return JsonCastle.__convert_value(arg, value)
213
+ except Exception:
214
+ continue
215
+ return value
216
+
217
+ if isinstance(field_type, type) and issubclass(field_type, Enum):
218
+ return field_type(value)
219
+
220
+ if origin is list:
221
+ item_type = args[0] if args else object
222
+ return [JsonCastle.__convert_value(item_type, v) for v in value]
223
+
224
+ if origin is tuple:
225
+ item_type = args[0] if args else object
226
+ return tuple(JsonCastle.__convert_value(item_type, v) for v in value)
227
+
228
+ if origin is dict:
229
+ key_type, val_type = args if args else (object, object)
230
+ return {JsonCastle.__convert_value(key_type, k): JsonCastle.__convert_value(val_type, v) for k, v in value.items()}
231
+
232
+ if is_dataclass(field_type):
233
+ return JsonCastle.__from_dict(field_type, value)
234
+
235
+ return value
@@ -0,0 +1,178 @@
1
+ Metadata-Version: 2.4
2
+ Name: json-castle
3
+ Version: 0.1.0
4
+ Summary: Edescription = Built on top of native json module for deserialization from JSON to data classes with additional support for nested objects with variables, environment variables, and post-load overrides.
5
+ Home-page: https://github.com/marianpekar/json-castle
6
+ Author: Marian Pekár
7
+ Author-email: marian.pekar@gmail.com
8
+ License: MIT
9
+ Keywords: json,parser,deserializer,cicd,tool
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: File Formats :: JSON
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Development Status :: 3 - Alpha
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Dynamic: license-file
18
+
19
+ # JsonCastle
20
+
21
+ JsonCastle is a Python module built on top of the native json module for deserialization from JSON to data classes with additional support for:
22
+
23
+ - nested objects
24
+ - JSON nariables
25
+ - environment variables
26
+ - post-load overrides, including overriding, adding, and removing collection elements via special CLI argument syntax like `node.next_node.number=2`, `~node.tags[0]`, `+node.next_node.tags=foo`, `~node.next_node.tags=buzz`, etc.
27
+
28
+ With these features, you might find it especially useful in CI/CD pipelines.
29
+
30
+ ## Getting Started
31
+
32
+ Imagine you have a JSON configuration file for an automated process, but you want to run this process with slightly different variants of this file. Typically, you'd end up with multiple very similar configuration files, or you'd create an ad-hoc solution that would allow you to override certain parameters via CLI arguments. JsonCastle gives you such a solution out of the box for all properties of your data scheme, even for those in nested objects.
33
+
34
+ Since native json doesn't support variables, such configuration files might have a lot of repeated strings. Editing such files can be tedious and error-prone. You write a JSON file with variables and environment variables as the following example:
35
+
36
+ ```json
37
+ {
38
+ "$home_path": "%HOME%",
39
+ "$package_path": "${home_path}debug",
40
+ "exe_path": "${package_path}/mytool.exe",
41
+ "node": {
42
+ "number": 0,
43
+ "tags": ["foo", "bar"],
44
+ "next_node": {
45
+ "number": 1,
46
+ "tags": ["fizz", "buzz"]
47
+ }
48
+ }
49
+ }
50
+ ```
51
+
52
+ Assuming the `HOME` environment variable is set, for example, to `C:/users/cicd/`, then the value of the `$package_path` variable will be expanded from `${home_path}debug` to `C:/users/cicd/debug`, and the value of `exe_path` will be `C:/users/cicd/debug/mytool.exe`.
53
+
54
+ In Python, you'd define your data classes that match the scheme:
55
+
56
+ ```python
57
+ from dataclasses import dataclass
58
+
59
+ @dataclass
60
+ class Cfg:
61
+ exe_path: str = None
62
+ node: Node = None
63
+
64
+ @dataclass
65
+ class Node:
66
+ number: int
67
+ tags: list[str]
68
+ processed: False
69
+ next_node: Node = None
70
+ ```
71
+
72
+ Now you can use the `JsonCastle.load_from_file` method to read and parse `example.json` and get an instance of the `Cfg` class:
73
+
74
+ ```python
75
+ from pprint import pprint
76
+ import sys
77
+
78
+ from json_castle import JsonCastle
79
+
80
+ cfg = JsonCastle.load_from_file(Cfg, "example.json", **JsonCastle.parse_args(sys.argv))
81
+ pprint(cfg)
82
+ ```
83
+
84
+ If you run the Python script without any arguments, you’d see cfg printed as expected:
85
+
86
+ ```txt
87
+ Cfg(exe_path='C:/users/cicd/debug/mytool.exe',
88
+ node={'next_node': {'number': 1, 'tags': ['fizz', 'buzz']},
89
+ 'number': 0,
90
+ 'tags': ['foo', 'bar']})
91
+ ```
92
+
93
+ However, by passing `**JsonCastle.parse_args(sys.argv)` as the third argument, you can override any values post-load via CLI. Running the script again with the arguments `node.number=1 ~node.tags[0] node.next_node.number=2 +node.next_node.tags=foo ~node.next_node.tags=buzz` will give you a cfg instance like this:
94
+
95
+ ```txt
96
+ Cfg(exe_path='C:/users/cicd/debug/mytool.exe',
97
+ node={'next_node': {'number': 2, 'tags': ['fizz', 'foo']},
98
+ 'number': 1,
99
+ 'tags': ['bar']})
100
+ ```
101
+
102
+ If you want to deserialize your JSON from a string stream, you can use the static method `load(cls, stream: IO[str], **kwargs)`, the usage is the same as of the `load_from_file(cls, path: str, **kwargs)` in the example above, but you provide your stream as the second argument instead of filepath.
103
+
104
+ ## CLI Overrides Syntax
105
+
106
+ Although some of the syntax for post-load overrides via the CLI was introduced already in the [Getting Started](#getting-started) section, here's an overview of all options with examples.
107
+
108
+ ### Basic Syntax
109
+
110
+ `JsonCastle.parse_args` method accepts a list of strings, where each element is a `key=value` pair except the one for removing an item by index, that only has a key, in which case, in the dictionary the method returns, the value is `None`.
111
+
112
+ Of course, you don't have to use this method and construct a dictionary by yourself instead; the `JsonCastle.parse_args` just provides you a convenient way for post-load overriding via CLI, which is particularly useful in CI/CD pipelines. Here are some examples of CLI args for overriding top-level pairs:
113
+
114
+ ```txt
115
+ age=27 name=John full_name="John Smith" active=true balance=123.45
116
+ ```
117
+
118
+ ### Nested Objects
119
+
120
+ If you need to override a nested value, you specify a path, separating objects with `.`, as you can see in the [Getting Started](#getting-started). The following example shows how to override several pairs nested in the `person` object.
121
+
122
+ ```txt
123
+ person.age=27 person.name=John person.full_name="John Smith" person.active=true person.balance=123.45
124
+ ```
125
+
126
+ ### Working with Collections
127
+
128
+ When it comes to collections, you can change, add, or remove an item as well. If the collection is a nested object, the rules are the same as described in the [Nested Objects](#nested-objects) section.
129
+
130
+ #### Overriding an Item
131
+
132
+ To override an item, you provide a path to the collection, in square brackets an index of the item you wish to override, and a new value on the right side of the `=` symbol. The following example shows how to change the value of the first element of the `tags` collection nested in the `page` object to `programming`.
133
+
134
+ ```txt
135
+ page.tags[0]=programming
136
+ ```
137
+
138
+ #### Adding a new Item
139
+
140
+ When you wish to add a new item to a collection, you prefix the `key=value` pair with a `+` symbol. The following example shows how to add a new value of `python` to the `tags` collection nested in the `page` object.
141
+
142
+ ```txt
143
+ +page.tags=python
144
+ ```
145
+
146
+ #### Removing an Item by Index
147
+
148
+ If you wish to remove an item at specific index, you prefix a path to the collection with index in square brackets of the element you wish to remove with the `~` symbol. The following example shows how to remove the second item from the tags collection nested in the page object.
149
+
150
+ ```txt
151
+ ~page.tags[1]
152
+ ```
153
+
154
+ If any items follow the removed one, they will be pushed down, and the new length of the collection will be n-1.
155
+
156
+ #### Removing an Item by Value
157
+
158
+ If you wish to remove an item of a specific value, instead of the index as described in Removing an Item by Index, you provide a key=value pair prefixed with ~. The following example shows how to remove an item with a value of “programming” from the tags collection nested in the page object.
159
+
160
+ ```txt
161
+ ~page.tags=programming
162
+ ```
163
+
164
+ If there is more than one occurence if `programming` string in the collection, only the first one will be removed.
165
+
166
+ ## Unit Tests
167
+
168
+ Unit Tests are not just a great way to ensure nothing is broken when a new feature is added, but also for implementing new features using TDD, which is the recommended way for extending this library. These tests can also serve as an overview of what the library is capable of. You can find all tests in the unit_test.py file.
169
+
170
+ ## Future Ideas
171
+
172
+ * Add support for conditionals, loops, and mathematical expressions in JSON.
173
+ * Extend the `parse_args` method to allow adding a custom object to a collection.
174
+ * Add regex support for removing items by value.
175
+ * When removing an item from a collection by value, let the user decide whether they want to remove just one or all items that match the value (`~page.tags=programming` removes the first; `~!page.tags=programming` removes all).
176
+ * Add support for removing items from a numerical collection by conditions `>`, `<`, `<=` , or `=>`.
177
+ * Add support for removing items by range (i.e. `~items[1:4]` would remove items at indices 1, 2, 3 and 4).
178
+ * Add support for removing custom items by condition (i.e., `~people={age < 16}` would remove from the people collection all objects with the age key-value pair with age less than 16).
@@ -0,0 +1,11 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ setup.cfg
5
+ src/json_castle/__init__.py
6
+ src/json_castle/core.py
7
+ src/json_castle.egg-info/PKG-INFO
8
+ src/json_castle.egg-info/SOURCES.txt
9
+ src/json_castle.egg-info/dependency_links.txt
10
+ src/json_castle.egg-info/top_level.txt
11
+ tests/test_json_castle.py
@@ -0,0 +1 @@
1
+ json_castle
@@ -0,0 +1,361 @@
1
+ from json_castle import JsonCastle
2
+ from dataclasses import dataclass
3
+ import os
4
+ import json
5
+ import tempfile
6
+ import unittest
7
+ from enum import Enum
8
+ from typing import Dict, List, Optional, Tuple, Union
9
+
10
+ TEST_JSON = {
11
+ "$global_supplies": "Global Supplies",
12
+ "$euro": "Euro",
13
+ "$euro_trail": "${euro} Trail",
14
+ "$number": 4.7,
15
+ "$fo": "fo",
16
+ "name": "Foo",
17
+ "name_with_var": "${fo}o",
18
+ "unit_price": 12.3,
19
+ "quantity_on_hand": 10,
20
+ "category": "${fo}od",
21
+ "active": True,
22
+ "$bool": True,
23
+ "foreign": "${bool}",
24
+ "elements": [ "foo", "bar" ],
25
+ "tags": [ "fizz", "buzz" ],
26
+ "$bar": "bar",
27
+ "$fizz": "fizz",
28
+ "$buzz": "buzz",
29
+ "elements_with_vars": [ "${fo}o", "${bar}" ],
30
+ "tags_with_vars": [ "${fizz}", "${buzz}" ],
31
+ "supplier_name": "${euro_trail}",
32
+ "supplier": {
33
+ "name": "${global_supplies}",
34
+ "country": "USA",
35
+ "rating": "${number}",
36
+ "active": True,
37
+ "elements": [ "foo", "bar" ],
38
+ "tags": [ "fizz", "buzz" ],
39
+ },
40
+ "supplier2": {
41
+ "name": "${euro_trail}",
42
+ "country": "Germany",
43
+ "rating": "${number}",
44
+ "active": "${bool}",
45
+ "elements_with_vars": [ "${fo}o", "${bar}" ],
46
+ "tags_with_vars": [ "${fizz}", "${buzz}" ],
47
+ },
48
+ "suppliers": [
49
+ {
50
+ "name": "Global Supplies",
51
+ "country": "USA",
52
+ "rating": 4.7,
53
+ "active": True,
54
+ "elements": [ "foo", "bar" ],
55
+ "tags": [ "fizz", "buzz" ],
56
+ },
57
+ {
58
+ "name": "Euro Trade",
59
+ "country": "%COUNTRY%",
60
+ "rating": 4.3,
61
+ "active": False
62
+ },
63
+ {
64
+ "name": "${global_supplies}",
65
+ "country": "Germany",
66
+ "rating": "${number}",
67
+ "active": "${bool}",
68
+ "elements_with_vars": [ "${fo}o", "${bar}" ],
69
+ "tags_with_vars": [ "${fizz}", "${buzz}" ],
70
+ },
71
+ {
72
+ "name": "${euro_trail}",
73
+ "country": "Germany",
74
+ "rating": 4.3
75
+ },
76
+ ],
77
+ "system": "%SYSTEM_ENV_VAR%",
78
+ "path": "%ROOT_PATH%/src"
79
+ }
80
+
81
+ class ItemCategory(Enum):
82
+ FOOD = "food"
83
+ TOOL = "tool"
84
+ OTHER = "other"
85
+
86
+ @dataclass
87
+ class Supplier:
88
+ name: str
89
+ country: str
90
+ rating: float
91
+ active: bool = False
92
+ elements: List[str] = None
93
+ tags: Tuple[str, ...] = ()
94
+ elements_with_vars: List[str] = None
95
+ tags_with_vars: Tuple[str, ...] = ()
96
+
97
+ @dataclass
98
+ class InventoryItem:
99
+ name: str
100
+ name_with_var: str
101
+ unit_price: float
102
+ quantity_on_hand: int = 0
103
+ active: bool = True
104
+ foreign: bool = False
105
+ discount: Optional[float] = None
106
+ elements: List[str] = None
107
+ tags: Tuple[str, ...] = ()
108
+ elements_with_vars: List[str] = None
109
+ tags_with_vars: Tuple[str, ...] = ()
110
+ ratings: List[int] = None
111
+ category: ItemCategory = ItemCategory.OTHER
112
+ supplier_name: str = None
113
+ supplier: Optional[Supplier] = None
114
+ supplier2: Optional[Supplier] = None
115
+ suppliers: List[Supplier] = None
116
+ metadata: Dict[str, int] = None
117
+ related_items: Dict[str, InventoryItem] = None
118
+ mixed_value: Union[int, str] = None
119
+ system: str = None
120
+ path: str = None
121
+
122
+ def write_temp_json(data):
123
+ tmp = tempfile.NamedTemporaryFile(mode="w+", delete=False)
124
+ json.dump(data, tmp)
125
+ tmp.close()
126
+ return tmp.name
127
+
128
+ class UnitTests(unittest.TestCase):
129
+
130
+ def test_basic_load(self):
131
+ path = write_temp_json(TEST_JSON)
132
+ item = JsonCastle.load_from_file(InventoryItem, path)
133
+ self.assertEqual(item.name, "Foo")
134
+ self.assertEqual(item.category, ItemCategory.FOOD)
135
+ self.assertEqual(item.active, True)
136
+ self.assertEqual(item.elements, ["foo", "bar"])
137
+ self.assertEqual(item.tags, ("fizz", "buzz"))
138
+ self.assertEqual(item.suppliers[0].country, "USA")
139
+ self.assertEqual(item.suppliers[0].elements, ["foo", "bar"])
140
+ self.assertEqual(item.suppliers[0].tags, ("fizz", "buzz"))
141
+
142
+ def test_kwargs_override(self):
143
+ path = write_temp_json(TEST_JSON)
144
+ item = JsonCastle.load_from_file(InventoryItem, path,
145
+ unit_price="99.9",
146
+ active="false",
147
+ foreign="false",
148
+ **{"elements[0]": "Fizz",
149
+ "elements_with_vars[1]": "Foo"},)
150
+ self.assertEqual(item.unit_price, 99.9)
151
+ self.assertEqual(item.active, False)
152
+ self.assertEqual(item.foreign, False)
153
+ self.assertEqual(item.elements[0], "Fizz")
154
+ self.assertEqual(item.elements_with_vars[1], "Foo")
155
+
156
+ def test_kwargs_override_nested(self):
157
+ path = write_temp_json(TEST_JSON)
158
+ item = JsonCastle.load_from_file(
159
+ InventoryItem,
160
+ path,
161
+ **{"supplier.country": "Japan",
162
+ "supplier.active": "false"},
163
+ )
164
+ self.assertEqual(item.supplier.country, "Japan")
165
+ self.assertEqual(item.supplier.active, False)
166
+
167
+ def test_kwargs_override_list_items(self):
168
+ path = write_temp_json(TEST_JSON)
169
+ item = JsonCastle.load_from_file(
170
+ InventoryItem,
171
+ path,
172
+ **{"suppliers[0].country": "Japan",
173
+ "suppliers[0].active": "false",
174
+ "suppliers[2].active": "false"}
175
+ )
176
+ self.assertEqual(item.suppliers[0].country, "Japan")
177
+ self.assertEqual(item.suppliers[0].active, False)
178
+ self.assertEqual(item.suppliers[2].active, False)
179
+
180
+ def test_variable_substitutions(self):
181
+ path = write_temp_json(TEST_JSON)
182
+ item = JsonCastle.load_from_file(InventoryItem, path)
183
+ self.assertEqual(item.supplier.name, "Global Supplies")
184
+ self.assertEqual(item.foreign, True)
185
+ self.assertEqual(item.supplier.rating, 4.7)
186
+ self.assertEqual(item.elements_with_vars, ["foo", "bar"])
187
+ self.assertEqual(item.tags_with_vars, ("fizz", "buzz"))
188
+
189
+ def test_variable_substitutions_in_list(self):
190
+ path = write_temp_json(TEST_JSON)
191
+ item = JsonCastle.load_from_file(InventoryItem, path)
192
+ self.assertEqual(item.suppliers[2].name, "Global Supplies")
193
+ self.assertEqual(item.suppliers[2].rating, 4.7)
194
+ self.assertEqual(item.suppliers[2].elements_with_vars, ["foo", "bar"])
195
+ self.assertEqual(item.suppliers[2].tags_with_vars, ("fizz", "buzz"))
196
+
197
+ def test_partial_variable_substitution(self):
198
+ path = write_temp_json(TEST_JSON)
199
+ item = JsonCastle.load_from_file(InventoryItem, path)
200
+ self.assertEqual(item.name_with_var, "foo")
201
+
202
+ def test_partial_variable_substitution_in_enum(self):
203
+ path = write_temp_json(TEST_JSON)
204
+ item = JsonCastle.load_from_file(InventoryItem, path)
205
+ self.assertEqual(item.category, ItemCategory.FOOD)
206
+
207
+ def test_environment_variable_substitution(self):
208
+ path = write_temp_json(TEST_JSON)
209
+ os.environ["SYSTEM_ENV_VAR"] = "Linux"
210
+ item = JsonCastle().load_from_file(InventoryItem, path)
211
+ self.assertEqual(item.system, "Linux")
212
+
213
+ def test_partial_environment_variable_substitution(self):
214
+ path = write_temp_json(TEST_JSON)
215
+ os.environ["ROOT_PATH"] = "dev/cj"
216
+ item = JsonCastle().load_from_file(InventoryItem, path)
217
+ self.assertEqual(item.path, "dev/cj/src")
218
+
219
+ def test_environment_variable_substitution_in_list(self):
220
+ path = write_temp_json(TEST_JSON)
221
+ os.environ["COUNTRY"] = "Germany"
222
+ item = JsonCastle().load_from_file(InventoryItem, path)
223
+ self.assertEqual(item.suppliers[1].country, "Germany")
224
+
225
+ def test_variable_in_variable(self):
226
+ path = write_temp_json(TEST_JSON)
227
+ item = JsonCastle().load_from_file(InventoryItem, path)
228
+ self.assertEqual(item.supplier_name, "Euro Trail")
229
+
230
+ def test_variable_in_variable_nested(self):
231
+ path = write_temp_json(TEST_JSON)
232
+ item = JsonCastle().load_from_file(InventoryItem, path)
233
+ self.assertEqual(item.supplier2.name, "Euro Trail")
234
+
235
+ def test_variable_in_variable_in_list(self):
236
+ path = write_temp_json(TEST_JSON)
237
+ item = JsonCastle().load_from_file(InventoryItem, path)
238
+ self.assertEqual(item.suppliers[3].name, "Euro Trail")
239
+
240
+ def test_add_list_item(self):
241
+ path = write_temp_json(TEST_JSON)
242
+ item = JsonCastle.load_from_file(
243
+ InventoryItem,
244
+ path,
245
+ **{"+elements": "fizz"}
246
+ )
247
+ self.assertEqual(len(item.elements), 3)
248
+ self.assertEqual(item.elements[2], "fizz")
249
+
250
+ def test_add_tupple_item(self):
251
+ path = write_temp_json(TEST_JSON)
252
+ item = JsonCastle.load_from_file(
253
+ InventoryItem,
254
+ path,
255
+ **{"+tags": "bar"}
256
+ )
257
+ self.assertEqual(len(item.tags), 3)
258
+ self.assertEqual(item.tags[2], "bar")
259
+
260
+ def test_add_custom_item_from_list(self):
261
+ path = write_temp_json(TEST_JSON)
262
+ item = JsonCastle.load_from_file(
263
+ InventoryItem,
264
+ path,
265
+ **{
266
+ "+suppliers": {
267
+ "name": "New Supplier",
268
+ "country": "Japan",
269
+ "rating": 4.9,
270
+ "active": True
271
+ }
272
+ }
273
+ )
274
+ self.assertEqual(len(item.suppliers), 5)
275
+ self.assertEqual(item.suppliers[4].name, "New Supplier")
276
+ self.assertEqual(item.suppliers[4].country, "Japan")
277
+ self.assertEqual(item.suppliers[4].rating, 4.9)
278
+ self.assertEqual(item.suppliers[4].active, True)
279
+
280
+ def test_remove_custom_list_item(self):
281
+ path = write_temp_json(TEST_JSON)
282
+ item = JsonCastle.load_from_file(
283
+ InventoryItem,
284
+ path,
285
+ **{"~suppliers[0]": ""}
286
+ )
287
+ self.assertEqual(len(item.suppliers), 3)
288
+ self.assertEqual(item.suppliers[0].name, "Euro Trade")
289
+
290
+ def test_remove_tuple_item(self):
291
+ path = write_temp_json(TEST_JSON)
292
+ item = JsonCastle.load_from_file(
293
+ InventoryItem,
294
+ path,
295
+ **{"~tags[1]": ""}
296
+ )
297
+ self.assertEqual(len(item.tags), 1)
298
+ self.assertEqual(item.tags[0], "fizz")
299
+
300
+ def test_remove_list_item_by_val(self):
301
+ path = write_temp_json(TEST_JSON)
302
+ item = JsonCastle.load_from_file(
303
+ InventoryItem,
304
+ path,
305
+ **{"~elements": "foo"}
306
+ )
307
+ self.assertEqual(len(item.elements), 1)
308
+ self.assertEqual(item.elements[0], "bar")
309
+
310
+ def test_remove_tuple_item_by_val(self):
311
+ path = write_temp_json(TEST_JSON)
312
+ item = JsonCastle.load_from_file(
313
+ InventoryItem,
314
+ path,
315
+ **{"~tags": "fizz"}
316
+ )
317
+ self.assertEqual(len(item.tags), 1)
318
+ self.assertEqual(item.tags[0], "buzz")
319
+
320
+ def test_remove_list_item_by_val_nested(self):
321
+ path = write_temp_json(TEST_JSON)
322
+ item = JsonCastle.load_from_file(
323
+ InventoryItem,
324
+ path,
325
+ **{"~supplier.elements": "foo"}
326
+ )
327
+ self.assertEqual(len(item.supplier.elements), 1)
328
+ self.assertEqual(item.supplier.elements[0], "bar")
329
+
330
+ def test_remove_tuple_item_by_val_nested(self):
331
+ path = write_temp_json(TEST_JSON)
332
+ item = JsonCastle.load_from_file(
333
+ InventoryItem,
334
+ path,
335
+ **{"~supplier.tags": "fizz"}
336
+ )
337
+ self.assertEqual(len(item.supplier.tags), 1)
338
+ self.assertEqual(item.supplier.tags[0], "buzz")
339
+
340
+ def test_remove_list_item_by_val_in_list(self):
341
+ path = write_temp_json(TEST_JSON)
342
+ item = JsonCastle.load_from_file(
343
+ InventoryItem,
344
+ path,
345
+ **{"~suppliers[0].elements": "foo"}
346
+ )
347
+ self.assertEqual(len(item.suppliers[0].elements), 1)
348
+ self.assertEqual(item.suppliers[0].elements[0], "bar")
349
+
350
+ def test_remove_tuple_item_by_val_in_list(self):
351
+ path = write_temp_json(TEST_JSON)
352
+ item = JsonCastle.load_from_file(
353
+ InventoryItem,
354
+ path,
355
+ **{"~suppliers[0].tags": "fizz"}
356
+ )
357
+ self.assertEqual(len(item.suppliers[0].tags), 1)
358
+ self.assertEqual(item.suppliers[0].tags[0], "buzz")
359
+
360
+ if __name__ == '__main__':
361
+ unittest.main()