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.
- json_castle-0.1.0/LICENSE +21 -0
- json_castle-0.1.0/PKG-INFO +178 -0
- json_castle-0.1.0/README.md +160 -0
- json_castle-0.1.0/pyproject.toml +3 -0
- json_castle-0.1.0/setup.cfg +22 -0
- json_castle-0.1.0/src/json_castle/__init__.py +1 -0
- json_castle-0.1.0/src/json_castle/core.py +235 -0
- json_castle-0.1.0/src/json_castle.egg-info/PKG-INFO +178 -0
- json_castle-0.1.0/src/json_castle.egg-info/SOURCES.txt +11 -0
- json_castle-0.1.0/src/json_castle.egg-info/dependency_links.txt +1 -0
- json_castle-0.1.0/src/json_castle.egg-info/top_level.txt +1 -0
- json_castle-0.1.0/tests/test_json_castle.py +361 -0
|
@@ -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,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
|
+
|
|
@@ -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()
|