nested-config 2.0.3__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.
- nested_config-2.0.3/CHANGELOG.md +57 -0
- nested_config-2.0.3/LICENSE +21 -0
- nested_config-2.0.3/PKG-INFO +208 -0
- nested_config-2.0.3/README.md +173 -0
- nested_config-2.0.3/pyproject.toml +58 -0
- nested_config-2.0.3/setup.py +41 -0
- nested_config-2.0.3/src/nested_config/__init__.py +31 -0
- nested_config-2.0.3/src/nested_config/_compat.py +61 -0
- nested_config-2.0.3/src/nested_config/_types.py +26 -0
- nested_config-2.0.3/src/nested_config/_validators.py +51 -0
- nested_config-2.0.3/src/nested_config/base_model.py +58 -0
- nested_config-2.0.3/src/nested_config/json.py +13 -0
- nested_config-2.0.3/src/nested_config/loaders.py +115 -0
- nested_config-2.0.3/src/nested_config/parsing.py +167 -0
- nested_config-2.0.3/src/nested_config/version.py +10 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [2.0.3] - 2024-04-15
|
|
11
|
+
|
|
12
|
+
- Fix typing issue regression for Pydantic < 2.0 introduced in last release
|
|
13
|
+
- Move package to `src` directory
|
|
14
|
+
|
|
15
|
+
## [2.0.2] - 2024-04-12
|
|
16
|
+
|
|
17
|
+
- Generalize handling of lists and dicts such that if the source config value and the
|
|
18
|
+
model annotation are both lists, recursively evaluate each item. This addresses the
|
|
19
|
+
situation where there may be a dict in the source config that corresponds to a Pydantic
|
|
20
|
+
model and that dict contains paths to other configs.
|
|
21
|
+
|
|
22
|
+
## [2.0.1] - 2024-04-10
|
|
23
|
+
|
|
24
|
+
- Make dependency specifications more generous
|
|
25
|
+
- Use `yaml.safe_load`
|
|
26
|
+
- Test minimum dependency versions in CI
|
|
27
|
+
|
|
28
|
+
## [2.0.0] - 2024-04-09
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
|
|
32
|
+
- Project renamed from **pydantic-plus** to **nested-config**
|
|
33
|
+
|
|
34
|
+
### Added
|
|
35
|
+
|
|
36
|
+
- Can find paths to other config files and parse them using their respective Pydantic
|
|
37
|
+
models using `validate_config` or `BaseModel` (this is the main functionality now).
|
|
38
|
+
- Pydantic 2.0 compatibility.
|
|
39
|
+
- Can validate any config file. TOML and JSON built in, YAML optional, others can be
|
|
40
|
+
added.
|
|
41
|
+
- Validators for `PurePath` and `PureWindowsPath`
|
|
42
|
+
- Simplify JSON encoder specification to work for all `PurePaths`
|
|
43
|
+
- pytest and mypy checks, checked with GitLab CI/CD
|
|
44
|
+
|
|
45
|
+
## [1.1.3] - 2021-07-30
|
|
46
|
+
|
|
47
|
+
- Add README
|
|
48
|
+
- Simplify PurePosixPath validator
|
|
49
|
+
- Export `TomlParsingError` from rtoml for downstream exception handling (without needing to explicitly
|
|
50
|
+
import rtoml).
|
|
51
|
+
|
|
52
|
+
[Unreleased]: https://gitlab.com/osu-nrsg/nested-config/-/compare/v2.0.3...master
|
|
53
|
+
[2.0.3]: https://gitlab.com/osu-nrsg/nested-config/-/compare/v2.0.2...v2.0.3
|
|
54
|
+
[2.0.2]: https://gitlab.com/osu-nrsg/nested-config/-/compare/v2.0.1...v2.0.2
|
|
55
|
+
[2.0.1]: https://gitlab.com/osu-nrsg/nested-config/-/compare/v2.0.0...v2.0.1
|
|
56
|
+
[2.0.0]: https://gitlab.com/osu-nrsg/nested-config/-/compare/v1.1.3...v2.0.0
|
|
57
|
+
[1.1.3]: https://gitlab.com/osu-nrsg/nested-config/-/tags/v1.1.3
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Randall Pittman, Oregon State University
|
|
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,208 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: nested-config
|
|
3
|
+
Version: 2.0.3
|
|
4
|
+
Summary: Parse configuration files that include paths to other config files into Pydantic modelinstances. Also support pathlib.PurePath on Pydantic 1.8+.
|
|
5
|
+
Home-page: https://gitlab.com/osu-nrsg/nested-config
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: pydantic,config,configuration files
|
|
8
|
+
Author: Randall Pittman
|
|
9
|
+
Author-email: pittmara@oregonstate.edu
|
|
10
|
+
Requires-Python: >=3.8,<4.0
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Framework :: Pydantic
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
25
|
+
Provides-Extra: yaml
|
|
26
|
+
Requires-Dist: pydantic (>=1.8,<3.0.0)
|
|
27
|
+
Requires-Dist: pyyaml (>=5.1.0,<7.0.0) ; extra == "yaml"
|
|
28
|
+
Requires-Dist: single-version (>=1.6.0,<2.0.0)
|
|
29
|
+
Requires-Dist: tomli (>=2.0.0,<3.0.0) ; python_version < "3.11"
|
|
30
|
+
Requires-Dist: typing-extensions (>=4.6.0,<5.0.0)
|
|
31
|
+
Project-URL: Changes, https://gitlab.com/osu-nrsg/nested-config/-/blob/master/CHANGELOG.md
|
|
32
|
+
Project-URL: Repository, https://gitlab.com/osu-nrsg/nested-config
|
|
33
|
+
Description-Content-Type: text/markdown
|
|
34
|
+
|
|
35
|
+
# nested-config README
|
|
36
|
+
|
|
37
|
+
**nested-config** provides for parsing configuration files that include paths to other
|
|
38
|
+
config files into [Pydantic](https://github.com/samuelcolvin/pydantic/) model instances.
|
|
39
|
+
It also supports validating and JSON-encoding `pathlib.PurePath` on Pydantic 1.8+.
|
|
40
|
+
|
|
41
|
+
## Usage
|
|
42
|
+
|
|
43
|
+
### Config loading
|
|
44
|
+
|
|
45
|
+
**nested-config** may be used in your project in two main ways.
|
|
46
|
+
|
|
47
|
+
1. You may simply call `nested_config.validate_config()` with a config file path and a
|
|
48
|
+
Pydantic model which may or may not include nested Pydantic models. If there are nested
|
|
49
|
+
models and the config file has string values for those fields, those values are
|
|
50
|
+
interpreted as paths to other config files and those are recursively read into their
|
|
51
|
+
respective Pydantic models using `validate_config()`. The `default_suffix` kwarg allows
|
|
52
|
+
for specifying the file suffix (extension) to assume if the config file has no suffix
|
|
53
|
+
or its suffix is not in the `nested_config.config_dict_loaders` dict.
|
|
54
|
+
|
|
55
|
+
Example including mixed configuration file types and `default_suffix` (Note that PyYAML
|
|
56
|
+
is an extra dependency required for parsing yaml files):
|
|
57
|
+
|
|
58
|
+
**house.yaml**
|
|
59
|
+
|
|
60
|
+
```yaml
|
|
61
|
+
name: my house
|
|
62
|
+
dimensions: dimensions
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
**dimensions** (TOML type)
|
|
66
|
+
|
|
67
|
+
```toml
|
|
68
|
+
length = 10
|
|
69
|
+
width = 20
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
**parse_house.py**
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
import pydantic
|
|
76
|
+
import yaml
|
|
77
|
+
|
|
78
|
+
from nested_config import validate_config
|
|
79
|
+
|
|
80
|
+
class Dimensions(pydantic.BaseModel):
|
|
81
|
+
length: int
|
|
82
|
+
width: int
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class House(pydantic.BaseModel):
|
|
86
|
+
name: str
|
|
87
|
+
dimensions: Dimensions
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
house = validate_config("house.yaml", House, default_suffix=".toml")
|
|
91
|
+
house # House(name='my house', dimensions=Dimensions(length=10, width=20))
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
2. Alternatively, you can use `nested_config.BaseModel` which subclasses
|
|
95
|
+
`pydantic.BaseModel` and adds a `from_config` classmethod:
|
|
96
|
+
|
|
97
|
+
**house.toml**
|
|
98
|
+
|
|
99
|
+
```toml
|
|
100
|
+
name = "my house"
|
|
101
|
+
dimensions = "dimensions.toml"
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
**dimensions.toml**
|
|
105
|
+
|
|
106
|
+
```toml
|
|
107
|
+
length = 12.6
|
|
108
|
+
width = 25.3
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
**parse_house.py**
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
import nested_config
|
|
115
|
+
|
|
116
|
+
class Dimensions(nested_config.BaseModel):
|
|
117
|
+
length: float
|
|
118
|
+
width: float
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class House(nested_config.BaseModel):
|
|
122
|
+
name: str
|
|
123
|
+
dimensions: Dimensions
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
house = House.from_config("house.toml", House)
|
|
127
|
+
house # House(name='my house', dimensions=Dimensions(length=12.6, width=25.3))
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
In this case, if you need to specify a default loader, just use
|
|
131
|
+
`nested_config.set_default_loader(suffix)` before using `BaseModel.from_config()`.
|
|
132
|
+
|
|
133
|
+
See [tests](https://gitlab.com/osu-nrsg/nested-config/-/tree/master/tests) for more
|
|
134
|
+
detailed use-cases.
|
|
135
|
+
|
|
136
|
+
### Included loaders
|
|
137
|
+
|
|
138
|
+
**nested-config** automatically loads the following files based on extension:
|
|
139
|
+
|
|
140
|
+
| Format | Extensions(s) | Library |
|
|
141
|
+
| ------ | ------------- | ------------------------------------------ |
|
|
142
|
+
| JSON | .json | `json` (stdlib) |
|
|
143
|
+
| TOML | .toml | `tomllib` (Python 3.11+ stdlib) or `tomli` |
|
|
144
|
+
| YAML | .yaml, .yml | `pyyaml` (extra dependency[^yaml-extra]) |
|
|
145
|
+
|
|
146
|
+
### Adding loaders
|
|
147
|
+
|
|
148
|
+
To add a loader for another file extension, simply update the `config_dict_loaders` dict:
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
import nested_config
|
|
152
|
+
from nested_config import ConfigDict # alias for dict[str, Any]
|
|
153
|
+
|
|
154
|
+
def dummy_loader(config_path: Path) -> ConfigDict:
|
|
155
|
+
return {"a": 1, "b": 2}
|
|
156
|
+
|
|
157
|
+
nested_config.config_dict_loaders[".dmy"] = dummy_loader
|
|
158
|
+
|
|
159
|
+
# or add another extension for an existing loader
|
|
160
|
+
nested_config.config_dict_loaders[".jsn"] = nested_config.config_dict_loaders[".json"]
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### `PurePath` handling
|
|
164
|
+
|
|
165
|
+
A bonus feature of **nested-config** is that it provides for validation and JSON encoding
|
|
166
|
+
of `pathlib.PurePath` and its subclasses in Pydantic <2.0 (this is built into Pydantic
|
|
167
|
+
2.0+). All that is needed is an import of `nested_config`. Example:
|
|
168
|
+
|
|
169
|
+
```python
|
|
170
|
+
from pathlib import PurePosixPath
|
|
171
|
+
|
|
172
|
+
import nested_config
|
|
173
|
+
import pydantic
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class RsyncDestination(pydantic.BaseModel):
|
|
177
|
+
remote_server: str
|
|
178
|
+
remote_path: PurePosixPath
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
dest = RsyncDestination(remote_server="rsync.example.com", remote_path="/data/incoming")
|
|
182
|
+
|
|
183
|
+
dest # RsyncDestination(remote_server='rsync.example.com', remote_path=PurePosixPath('/data/incoming'))
|
|
184
|
+
dest.json() # '{"remote_server":"rsync.example.com","remote_path":"/data/incoming"}'
|
|
185
|
+
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
## Pydantic 1.0/2.0 Compatibility
|
|
189
|
+
|
|
190
|
+
nested-config is runtime compatible with Pydantic 1.8+ and Pydantic 2.0.
|
|
191
|
+
|
|
192
|
+
The follow table gives info on how to configure the [mypy](https://www.mypy-lang.org/) and
|
|
193
|
+
[Pyright](https://microsoft.github.io/pyright) type checkers to properly work, depending
|
|
194
|
+
on the version of Pydantic you are using.
|
|
195
|
+
|
|
196
|
+
| Pydantic Version | [mypy config][1] | mypy cli | [Pyright config][2] |
|
|
197
|
+
|------------------|-----------------------------|-----------------------------|---------------------------------------------|
|
|
198
|
+
| 2.0+ | `always_false = PYDANTIC_1` | `--always-false PYDANTIC_1` | `defineConstant = { "PYDANTIC_1" = false }` |
|
|
199
|
+
| 1.8-1.10 | `always_true = PYDANTIC_1` | `--always-true PYDANTIC_1` | `defineConstant = { "PYDANTIC_1" = true }` |
|
|
200
|
+
|
|
201
|
+
## Footnotes
|
|
202
|
+
|
|
203
|
+
[^yaml-extra]: Install `pyyaml` separately with `pip` or install **nested-config** with
|
|
204
|
+
`pip install nested-config[yaml]`.
|
|
205
|
+
|
|
206
|
+
[1]: https://mypy.readthedocs.io/en/latest/config_file.html
|
|
207
|
+
[2]: https://microsoft.github.io/pyright/#/configuration
|
|
208
|
+
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# nested-config README
|
|
2
|
+
|
|
3
|
+
**nested-config** provides for parsing configuration files that include paths to other
|
|
4
|
+
config files into [Pydantic](https://github.com/samuelcolvin/pydantic/) model instances.
|
|
5
|
+
It also supports validating and JSON-encoding `pathlib.PurePath` on Pydantic 1.8+.
|
|
6
|
+
|
|
7
|
+
## Usage
|
|
8
|
+
|
|
9
|
+
### Config loading
|
|
10
|
+
|
|
11
|
+
**nested-config** may be used in your project in two main ways.
|
|
12
|
+
|
|
13
|
+
1. You may simply call `nested_config.validate_config()` with a config file path and a
|
|
14
|
+
Pydantic model which may or may not include nested Pydantic models. If there are nested
|
|
15
|
+
models and the config file has string values for those fields, those values are
|
|
16
|
+
interpreted as paths to other config files and those are recursively read into their
|
|
17
|
+
respective Pydantic models using `validate_config()`. The `default_suffix` kwarg allows
|
|
18
|
+
for specifying the file suffix (extension) to assume if the config file has no suffix
|
|
19
|
+
or its suffix is not in the `nested_config.config_dict_loaders` dict.
|
|
20
|
+
|
|
21
|
+
Example including mixed configuration file types and `default_suffix` (Note that PyYAML
|
|
22
|
+
is an extra dependency required for parsing yaml files):
|
|
23
|
+
|
|
24
|
+
**house.yaml**
|
|
25
|
+
|
|
26
|
+
```yaml
|
|
27
|
+
name: my house
|
|
28
|
+
dimensions: dimensions
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
**dimensions** (TOML type)
|
|
32
|
+
|
|
33
|
+
```toml
|
|
34
|
+
length = 10
|
|
35
|
+
width = 20
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
**parse_house.py**
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
import pydantic
|
|
42
|
+
import yaml
|
|
43
|
+
|
|
44
|
+
from nested_config import validate_config
|
|
45
|
+
|
|
46
|
+
class Dimensions(pydantic.BaseModel):
|
|
47
|
+
length: int
|
|
48
|
+
width: int
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class House(pydantic.BaseModel):
|
|
52
|
+
name: str
|
|
53
|
+
dimensions: Dimensions
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
house = validate_config("house.yaml", House, default_suffix=".toml")
|
|
57
|
+
house # House(name='my house', dimensions=Dimensions(length=10, width=20))
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
2. Alternatively, you can use `nested_config.BaseModel` which subclasses
|
|
61
|
+
`pydantic.BaseModel` and adds a `from_config` classmethod:
|
|
62
|
+
|
|
63
|
+
**house.toml**
|
|
64
|
+
|
|
65
|
+
```toml
|
|
66
|
+
name = "my house"
|
|
67
|
+
dimensions = "dimensions.toml"
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
**dimensions.toml**
|
|
71
|
+
|
|
72
|
+
```toml
|
|
73
|
+
length = 12.6
|
|
74
|
+
width = 25.3
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
**parse_house.py**
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
import nested_config
|
|
81
|
+
|
|
82
|
+
class Dimensions(nested_config.BaseModel):
|
|
83
|
+
length: float
|
|
84
|
+
width: float
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class House(nested_config.BaseModel):
|
|
88
|
+
name: str
|
|
89
|
+
dimensions: Dimensions
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
house = House.from_config("house.toml", House)
|
|
93
|
+
house # House(name='my house', dimensions=Dimensions(length=12.6, width=25.3))
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
In this case, if you need to specify a default loader, just use
|
|
97
|
+
`nested_config.set_default_loader(suffix)` before using `BaseModel.from_config()`.
|
|
98
|
+
|
|
99
|
+
See [tests](https://gitlab.com/osu-nrsg/nested-config/-/tree/master/tests) for more
|
|
100
|
+
detailed use-cases.
|
|
101
|
+
|
|
102
|
+
### Included loaders
|
|
103
|
+
|
|
104
|
+
**nested-config** automatically loads the following files based on extension:
|
|
105
|
+
|
|
106
|
+
| Format | Extensions(s) | Library |
|
|
107
|
+
| ------ | ------------- | ------------------------------------------ |
|
|
108
|
+
| JSON | .json | `json` (stdlib) |
|
|
109
|
+
| TOML | .toml | `tomllib` (Python 3.11+ stdlib) or `tomli` |
|
|
110
|
+
| YAML | .yaml, .yml | `pyyaml` (extra dependency[^yaml-extra]) |
|
|
111
|
+
|
|
112
|
+
### Adding loaders
|
|
113
|
+
|
|
114
|
+
To add a loader for another file extension, simply update the `config_dict_loaders` dict:
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
import nested_config
|
|
118
|
+
from nested_config import ConfigDict # alias for dict[str, Any]
|
|
119
|
+
|
|
120
|
+
def dummy_loader(config_path: Path) -> ConfigDict:
|
|
121
|
+
return {"a": 1, "b": 2}
|
|
122
|
+
|
|
123
|
+
nested_config.config_dict_loaders[".dmy"] = dummy_loader
|
|
124
|
+
|
|
125
|
+
# or add another extension for an existing loader
|
|
126
|
+
nested_config.config_dict_loaders[".jsn"] = nested_config.config_dict_loaders[".json"]
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### `PurePath` handling
|
|
130
|
+
|
|
131
|
+
A bonus feature of **nested-config** is that it provides for validation and JSON encoding
|
|
132
|
+
of `pathlib.PurePath` and its subclasses in Pydantic <2.0 (this is built into Pydantic
|
|
133
|
+
2.0+). All that is needed is an import of `nested_config`. Example:
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
from pathlib import PurePosixPath
|
|
137
|
+
|
|
138
|
+
import nested_config
|
|
139
|
+
import pydantic
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class RsyncDestination(pydantic.BaseModel):
|
|
143
|
+
remote_server: str
|
|
144
|
+
remote_path: PurePosixPath
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
dest = RsyncDestination(remote_server="rsync.example.com", remote_path="/data/incoming")
|
|
148
|
+
|
|
149
|
+
dest # RsyncDestination(remote_server='rsync.example.com', remote_path=PurePosixPath('/data/incoming'))
|
|
150
|
+
dest.json() # '{"remote_server":"rsync.example.com","remote_path":"/data/incoming"}'
|
|
151
|
+
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## Pydantic 1.0/2.0 Compatibility
|
|
155
|
+
|
|
156
|
+
nested-config is runtime compatible with Pydantic 1.8+ and Pydantic 2.0.
|
|
157
|
+
|
|
158
|
+
The follow table gives info on how to configure the [mypy](https://www.mypy-lang.org/) and
|
|
159
|
+
[Pyright](https://microsoft.github.io/pyright) type checkers to properly work, depending
|
|
160
|
+
on the version of Pydantic you are using.
|
|
161
|
+
|
|
162
|
+
| Pydantic Version | [mypy config][1] | mypy cli | [Pyright config][2] |
|
|
163
|
+
|------------------|-----------------------------|-----------------------------|---------------------------------------------|
|
|
164
|
+
| 2.0+ | `always_false = PYDANTIC_1` | `--always-false PYDANTIC_1` | `defineConstant = { "PYDANTIC_1" = false }` |
|
|
165
|
+
| 1.8-1.10 | `always_true = PYDANTIC_1` | `--always-true PYDANTIC_1` | `defineConstant = { "PYDANTIC_1" = true }` |
|
|
166
|
+
|
|
167
|
+
## Footnotes
|
|
168
|
+
|
|
169
|
+
[^yaml-extra]: Install `pyyaml` separately with `pip` or install **nested-config** with
|
|
170
|
+
`pip install nested-config[yaml]`.
|
|
171
|
+
|
|
172
|
+
[1]: https://mypy.readthedocs.io/en/latest/config_file.html
|
|
173
|
+
[2]: https://microsoft.github.io/pyright/#/configuration
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "nested-config"
|
|
3
|
+
version = "2.0.3"
|
|
4
|
+
description = """\
|
|
5
|
+
Parse configuration files that include paths to other config files into Pydantic model\
|
|
6
|
+
instances. Also support pathlib.PurePath on Pydantic 1.8+.\
|
|
7
|
+
"""
|
|
8
|
+
authors = ["Randall Pittman <pittmara@oregonstate.edu>"]
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
repository = "https://gitlab.com/osu-nrsg/nested-config"
|
|
12
|
+
keywords = ["pydantic", "config", "configuration files"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 4 - Beta",
|
|
15
|
+
"Framework :: Pydantic",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3.8",
|
|
19
|
+
"Programming Language :: Python :: 3.9",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
]
|
|
24
|
+
include = ["CHANGELOG.md"]
|
|
25
|
+
|
|
26
|
+
[tool.poetry.urls]
|
|
27
|
+
Changes = "https://gitlab.com/osu-nrsg/nested-config/-/blob/master/CHANGELOG.md"
|
|
28
|
+
|
|
29
|
+
[tool.poetry.dependencies]
|
|
30
|
+
python = "^3.8"
|
|
31
|
+
pydantic = ">=1.8,<3.0.0"
|
|
32
|
+
single-version = "^1.6.0"
|
|
33
|
+
tomli = {version = "^2.0.0", python = "<3.11"}
|
|
34
|
+
typing-extensions = "^4.6.0"
|
|
35
|
+
pyyaml = {version = ">=5.1.0,<7.0.0", optional = true}
|
|
36
|
+
|
|
37
|
+
[tool.poetry.group.dev.dependencies]
|
|
38
|
+
ipython = "^7.22.0"
|
|
39
|
+
ruff = "^0.3.5"
|
|
40
|
+
pytest = "^8.1.1"
|
|
41
|
+
mypy = "^1.9.0"
|
|
42
|
+
types-pyyaml = ">=5.1.0"
|
|
43
|
+
|
|
44
|
+
[tool.poetry.extras]
|
|
45
|
+
yaml = ["pyyaml"]
|
|
46
|
+
|
|
47
|
+
[build-system]
|
|
48
|
+
requires = ["poetry-core>=1.0.0"]
|
|
49
|
+
build-backend = "poetry.core.masonry.api"
|
|
50
|
+
|
|
51
|
+
[tool.ruff]
|
|
52
|
+
line-length = 90
|
|
53
|
+
|
|
54
|
+
[tool.ruff.lint.extend-per-file-ignores]
|
|
55
|
+
"__init__.py" = ["F401"]
|
|
56
|
+
|
|
57
|
+
[tool.pyright]
|
|
58
|
+
defineConstant = { "PYDANTIC_1" = false } # change to true for Pydantic <2.0
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
from setuptools import setup
|
|
3
|
+
|
|
4
|
+
package_dir = \
|
|
5
|
+
{'': 'src'}
|
|
6
|
+
|
|
7
|
+
packages = \
|
|
8
|
+
['nested_config']
|
|
9
|
+
|
|
10
|
+
package_data = \
|
|
11
|
+
{'': ['*']}
|
|
12
|
+
|
|
13
|
+
install_requires = \
|
|
14
|
+
['pydantic>=1.8,<3.0.0',
|
|
15
|
+
'single-version>=1.6.0,<2.0.0',
|
|
16
|
+
'typing-extensions>=4.6.0,<5.0.0']
|
|
17
|
+
|
|
18
|
+
extras_require = \
|
|
19
|
+
{':python_version < "3.11"': ['tomli>=2.0.0,<3.0.0'],
|
|
20
|
+
'yaml': ['pyyaml>=5.1.0,<7.0.0']}
|
|
21
|
+
|
|
22
|
+
setup_kwargs = {
|
|
23
|
+
'name': 'nested-config',
|
|
24
|
+
'version': '2.0.3',
|
|
25
|
+
'description': 'Parse configuration files that include paths to other config files into Pydantic modelinstances. Also support pathlib.PurePath on Pydantic 1.8+.',
|
|
26
|
+
'long_description': '# nested-config README\n\n**nested-config** provides for parsing configuration files that include paths to other\nconfig files into [Pydantic](https://github.com/samuelcolvin/pydantic/) model instances.\nIt also supports validating and JSON-encoding `pathlib.PurePath` on Pydantic 1.8+.\n\n## Usage\n\n### Config loading\n\n**nested-config** may be used in your project in two main ways.\n\n1. You may simply call `nested_config.validate_config()` with a config file path and a\n Pydantic model which may or may not include nested Pydantic models. If there are nested\n models and the config file has string values for those fields, those values are\n interpreted as paths to other config files and those are recursively read into their\n respective Pydantic models using `validate_config()`. The `default_suffix` kwarg allows\n for specifying the file suffix (extension) to assume if the config file has no suffix\n or its suffix is not in the `nested_config.config_dict_loaders` dict.\n\n Example including mixed configuration file types and `default_suffix` (Note that PyYAML\n is an extra dependency required for parsing yaml files):\n\n **house.yaml**\n\n ```yaml\n name: my house\n dimensions: dimensions\n ```\n\n **dimensions** (TOML type)\n\n ```toml\n length = 10\n width = 20\n ```\n\n **parse_house.py**\n\n ```python\n import pydantic\n import yaml\n\n from nested_config import validate_config\n\n class Dimensions(pydantic.BaseModel):\n length: int\n width: int\n\n\n class House(pydantic.BaseModel):\n name: str\n dimensions: Dimensions\n\n\n house = validate_config("house.yaml", House, default_suffix=".toml")\n house # House(name=\'my house\', dimensions=Dimensions(length=10, width=20))\n ```\n\n2. Alternatively, you can use `nested_config.BaseModel` which subclasses\n `pydantic.BaseModel` and adds a `from_config` classmethod:\n\n **house.toml**\n\n ```toml\n name = "my house"\n dimensions = "dimensions.toml"\n ```\n\n **dimensions.toml**\n\n ```toml\n length = 12.6\n width = 25.3\n ```\n\n **parse_house.py**\n\n ```python\n import nested_config\n\n class Dimensions(nested_config.BaseModel):\n length: float\n width: float\n\n\n class House(nested_config.BaseModel):\n name: str\n dimensions: Dimensions\n\n\n house = House.from_config("house.toml", House)\n house # House(name=\'my house\', dimensions=Dimensions(length=12.6, width=25.3))\n ```\n\n In this case, if you need to specify a default loader, just use\n `nested_config.set_default_loader(suffix)` before using `BaseModel.from_config()`.\n\nSee [tests](https://gitlab.com/osu-nrsg/nested-config/-/tree/master/tests) for more\ndetailed use-cases.\n\n### Included loaders\n\n**nested-config** automatically loads the following files based on extension:\n\n| Format | Extensions(s) | Library |\n| ------ | ------------- | ------------------------------------------ |\n| JSON | .json | `json` (stdlib) |\n| TOML | .toml | `tomllib` (Python 3.11+ stdlib) or `tomli` |\n| YAML | .yaml, .yml | `pyyaml` (extra dependency[^yaml-extra]) |\n\n### Adding loaders\n\nTo add a loader for another file extension, simply update the `config_dict_loaders` dict:\n\n```python\nimport nested_config\nfrom nested_config import ConfigDict # alias for dict[str, Any]\n\ndef dummy_loader(config_path: Path) -> ConfigDict:\n return {"a": 1, "b": 2}\n\nnested_config.config_dict_loaders[".dmy"] = dummy_loader\n\n# or add another extension for an existing loader\nnested_config.config_dict_loaders[".jsn"] = nested_config.config_dict_loaders[".json"]\n```\n\n### `PurePath` handling\n\nA bonus feature of **nested-config** is that it provides for validation and JSON encoding\nof `pathlib.PurePath` and its subclasses in Pydantic <2.0 (this is built into Pydantic\n2.0+). All that is needed is an import of `nested_config`. Example:\n\n```python\nfrom pathlib import PurePosixPath\n\nimport nested_config\nimport pydantic\n\n\nclass RsyncDestination(pydantic.BaseModel):\n remote_server: str\n remote_path: PurePosixPath\n\n\ndest = RsyncDestination(remote_server="rsync.example.com", remote_path="/data/incoming")\n\ndest # RsyncDestination(remote_server=\'rsync.example.com\', remote_path=PurePosixPath(\'/data/incoming\'))\ndest.json() # \'{"remote_server":"rsync.example.com","remote_path":"/data/incoming"}\'\n\n```\n\n## Pydantic 1.0/2.0 Compatibility\n\nnested-config is runtime compatible with Pydantic 1.8+ and Pydantic 2.0.\n\nThe follow table gives info on how to configure the [mypy](https://www.mypy-lang.org/) and\n[Pyright](https://microsoft.github.io/pyright) type checkers to properly work, depending\non the version of Pydantic you are using.\n\n| Pydantic Version | [mypy config][1] | mypy cli | [Pyright config][2] |\n|------------------|-----------------------------|-----------------------------|---------------------------------------------|\n| 2.0+ | `always_false = PYDANTIC_1` | `--always-false PYDANTIC_1` | `defineConstant = { "PYDANTIC_1" = false }` |\n| 1.8-1.10 | `always_true = PYDANTIC_1` | `--always-true PYDANTIC_1` | `defineConstant = { "PYDANTIC_1" = true }` |\n\n## Footnotes\n\n[^yaml-extra]: Install `pyyaml` separately with `pip` or install **nested-config** with\n `pip install nested-config[yaml]`.\n\n[1]: https://mypy.readthedocs.io/en/latest/config_file.html\n[2]: https://microsoft.github.io/pyright/#/configuration\n',
|
|
27
|
+
'author': 'Randall Pittman',
|
|
28
|
+
'author_email': 'pittmara@oregonstate.edu',
|
|
29
|
+
'maintainer': 'None',
|
|
30
|
+
'maintainer_email': 'None',
|
|
31
|
+
'url': 'https://gitlab.com/osu-nrsg/nested-config',
|
|
32
|
+
'package_dir': package_dir,
|
|
33
|
+
'packages': packages,
|
|
34
|
+
'package_data': package_data,
|
|
35
|
+
'install_requires': install_requires,
|
|
36
|
+
'extras_require': extras_require,
|
|
37
|
+
'python_requires': '>=3.8,<4.0',
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
setup(**setup_kwargs)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""nested_config - This package does two things:
|
|
2
|
+
|
|
3
|
+
1. It adds the ability to parse config files into Pydantic model instances, including
|
|
4
|
+
config files that include string path references to other config files in place of
|
|
5
|
+
sub-model instances.
|
|
6
|
+
|
|
7
|
+
my_obj = validate_config("my_config.toml", MyConfigModel, loader=toml.load)
|
|
8
|
+
|
|
9
|
+
2. It adds PurePath, PurePosixPath, and PureWindowsPath validation and JSON-encoding to
|
|
10
|
+
Pydantic v1 (these are already included in Pydantic 2.)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from nested_config._validators import (
|
|
14
|
+
patch_pydantic_validators as _patch_pydantic_validators,
|
|
15
|
+
)
|
|
16
|
+
from nested_config.base_model import BaseModel
|
|
17
|
+
from nested_config.json import (
|
|
18
|
+
patch_pydantic_json_encoders as _patch_pydantic_json_encoders,
|
|
19
|
+
)
|
|
20
|
+
from nested_config.loaders import (
|
|
21
|
+
ConfigLoaderError,
|
|
22
|
+
NoLoaderError,
|
|
23
|
+
config_dict_loaders,
|
|
24
|
+
set_default_loader,
|
|
25
|
+
)
|
|
26
|
+
from nested_config.parsing import ispydmodel, validate_config
|
|
27
|
+
from nested_config.version import __version__
|
|
28
|
+
|
|
29
|
+
# We always patch the validators, but in the future this may be made optional
|
|
30
|
+
_patch_pydantic_validators()
|
|
31
|
+
_patch_pydantic_json_encoders()
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""_compat.py - Functions and types to assist with Pydantic 1/2 compatibility"""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, Optional, Protocol, Type
|
|
4
|
+
|
|
5
|
+
import pydantic
|
|
6
|
+
import pydantic.fields
|
|
7
|
+
from setuptools._vendor.packaging.version import Version # type: ignore
|
|
8
|
+
from typing_extensions import TypeAlias
|
|
9
|
+
|
|
10
|
+
from nested_config._types import PydModelT
|
|
11
|
+
|
|
12
|
+
PYDANTIC_1 = Version(pydantic.VERSION) < Version("2.0")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class HasAnnotation(Protocol):
|
|
16
|
+
"""Protocol will allow some Pydantic 2.0 compatibility down the road"""
|
|
17
|
+
|
|
18
|
+
annotation: Optional[Type[Any]]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
if PYDANTIC_1:
|
|
22
|
+
FieldInfo_: TypeAlias = pydantic.fields.ModelField
|
|
23
|
+
else:
|
|
24
|
+
FieldInfo_: TypeAlias = pydantic.fields.FieldInfo
|
|
25
|
+
ModelFields: TypeAlias = Dict[str, FieldInfo_]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_modelfield_annotation(model: Type[pydantic.BaseModel], field_name: str):
|
|
29
|
+
# "annotation" exists in pydantic 1.10, but not 1.8 or 1.9
|
|
30
|
+
field = get_model_fields(model)[field_name]
|
|
31
|
+
return get_field_annotation(field)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def get_field_annotation(field: FieldInfo_):
|
|
35
|
+
# "annotation" exists in pydantic 1.10, but not 1.8 or 1.9
|
|
36
|
+
if PYDANTIC_1:
|
|
37
|
+
return field.outer_type_
|
|
38
|
+
else:
|
|
39
|
+
return field.annotation
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_model_fields(model: Type[pydantic.BaseModel]) -> ModelFields:
|
|
43
|
+
if PYDANTIC_1:
|
|
44
|
+
return model.__fields__
|
|
45
|
+
else:
|
|
46
|
+
return model.model_fields
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def parse_obj(model: Type[PydModelT], obj: Any) -> PydModelT:
|
|
50
|
+
if PYDANTIC_1:
|
|
51
|
+
return model.parse_obj(obj)
|
|
52
|
+
else:
|
|
53
|
+
return model.model_validate(obj)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def dump_json(model: pydantic.BaseModel) -> str:
|
|
57
|
+
"""Compatibility of json dump function for testing"""
|
|
58
|
+
if PYDANTIC_1:
|
|
59
|
+
return model.json()
|
|
60
|
+
else:
|
|
61
|
+
return model.model_dump_json()
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""_types.py - Type aliases and type-checking functions"""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Callable, Dict, Type, TypeVar, Union
|
|
6
|
+
|
|
7
|
+
import pydantic
|
|
8
|
+
from typing_extensions import TypeAlias, TypeGuard
|
|
9
|
+
|
|
10
|
+
ConfigDict: TypeAlias = Dict[str, Any]
|
|
11
|
+
PathLike: TypeAlias = Union[Path, str]
|
|
12
|
+
PydModelT = TypeVar("PydModelT", bound=pydantic.BaseModel)
|
|
13
|
+
ConfigDictLoader: TypeAlias = Callable[[Path], ConfigDict]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
if sys.version_info >= (3, 10):
|
|
17
|
+
from types import UnionType
|
|
18
|
+
|
|
19
|
+
UNION_TYPES = [Union, UnionType]
|
|
20
|
+
else:
|
|
21
|
+
UNION_TYPES = [Union]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def ispydmodel(klass, cls: Type[PydModelT]) -> TypeGuard[Type[PydModelT]]:
|
|
25
|
+
"""Exception-safe issubclass for pydantic BaseModel types"""
|
|
26
|
+
return isinstance(klass, type) and issubclass(klass, cls)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""_validators.py - For Pydantic 1.8-1.10, extends the built-in validators to include
|
|
2
|
+
PurePath & subclasses"""
|
|
3
|
+
|
|
4
|
+
from pathlib import PurePath, PurePosixPath, PureWindowsPath
|
|
5
|
+
from typing import Any, Type, TypeVar
|
|
6
|
+
|
|
7
|
+
import pydantic
|
|
8
|
+
import pydantic.errors
|
|
9
|
+
import pydantic.validators
|
|
10
|
+
|
|
11
|
+
from nested_config._compat import PYDANTIC_1
|
|
12
|
+
|
|
13
|
+
PathT = TypeVar("PathT", bound=PurePath)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _path_validator(v: Any, type: Type[PathT]) -> PathT:
|
|
17
|
+
"""Attempt to convert a value to a PurePosixPath"""
|
|
18
|
+
if isinstance(v, type):
|
|
19
|
+
return v
|
|
20
|
+
try:
|
|
21
|
+
return type(v)
|
|
22
|
+
except TypeError:
|
|
23
|
+
# n.b. this error only exists in Pydantic < 2.0
|
|
24
|
+
raise pydantic.errors.PathError from None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def pure_path_validator(v: Any):
|
|
28
|
+
return _path_validator(v, type=PurePath)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def pure_posix_path_validator(v: Any):
|
|
32
|
+
return _path_validator(v, type=PurePosixPath)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def pure_windows_path_validator(v: Any):
|
|
36
|
+
return _path_validator(v, type=PureWindowsPath)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def patch_pydantic_validators():
|
|
40
|
+
if PYDANTIC_1:
|
|
41
|
+
# These are already included in pydantic 2+
|
|
42
|
+
pydantic.validators._VALIDATORS.extend(
|
|
43
|
+
[
|
|
44
|
+
(PurePosixPath, [pure_posix_path_validator]),
|
|
45
|
+
(PureWindowsPath, [pure_windows_path_validator]),
|
|
46
|
+
(
|
|
47
|
+
PurePath,
|
|
48
|
+
[pure_path_validator],
|
|
49
|
+
), # last because others are more specific
|
|
50
|
+
]
|
|
51
|
+
)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""base_model.py
|
|
2
|
+
|
|
3
|
+
Pydantic BaseModel extended a bit:
|
|
4
|
+
- PurePosixPath json encoding and validation
|
|
5
|
+
- from_toml and from_tomls classmethods
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Type
|
|
10
|
+
|
|
11
|
+
import pydantic
|
|
12
|
+
|
|
13
|
+
from nested_config import parsing
|
|
14
|
+
from nested_config._compat import parse_obj
|
|
15
|
+
from nested_config._types import PathLike, PydModelT
|
|
16
|
+
from nested_config.loaders import load_config
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class BaseModel(pydantic.BaseModel):
|
|
20
|
+
"""Extends pydantic.BaseModel with from_config classmethod to load a config file into
|
|
21
|
+
the model."""
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def from_config(
|
|
25
|
+
cls: Type[PydModelT], toml_path: PathLike, convert_strpaths=True
|
|
26
|
+
) -> PydModelT:
|
|
27
|
+
"""Create Pydantic model from a TOML file
|
|
28
|
+
|
|
29
|
+
Parameters
|
|
30
|
+
----------
|
|
31
|
+
toml_path
|
|
32
|
+
Path to the TOML file
|
|
33
|
+
convert_strpaths
|
|
34
|
+
If True, every string value [a] in the dict from the parsed TOML file that
|
|
35
|
+
corresponds to a Pydantic model field [b] in the base model will be
|
|
36
|
+
interpreted as a path to another TOML file and an attempt will be made to
|
|
37
|
+
parse that TOML file [a] and make it into an object of that [b] model type,
|
|
38
|
+
and so on, recursively.
|
|
39
|
+
|
|
40
|
+
Returns
|
|
41
|
+
-------
|
|
42
|
+
An object of this class
|
|
43
|
+
|
|
44
|
+
Raises
|
|
45
|
+
-------
|
|
46
|
+
NoLoaderError
|
|
47
|
+
No loader is available for the config file extension
|
|
48
|
+
ConfigLoaderError
|
|
49
|
+
There was a problem loading a config file with its loader
|
|
50
|
+
pydantic.ValidationError
|
|
51
|
+
The data fields or types in the file do not match the model.
|
|
52
|
+
"""
|
|
53
|
+
toml_path = Path(toml_path)
|
|
54
|
+
if convert_strpaths:
|
|
55
|
+
return parsing.validate_config(toml_path, cls)
|
|
56
|
+
# otherwise just load the config as-is
|
|
57
|
+
config_dict = load_config(toml_path)
|
|
58
|
+
return parse_obj(cls, config_dict)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""json.py - For PYDANTIC_1, adds a json encoder for PurePath objects"""
|
|
2
|
+
|
|
3
|
+
from pathlib import PurePath
|
|
4
|
+
|
|
5
|
+
import pydantic.json
|
|
6
|
+
|
|
7
|
+
from nested_config._compat import PYDANTIC_1
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def patch_pydantic_json_encoders():
|
|
11
|
+
if PYDANTIC_1:
|
|
12
|
+
# These are already in pydantic 2+
|
|
13
|
+
pydantic.json.ENCODERS_BY_TYPE[PurePath] = str
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""loaders.py - Manage config file loaders"""
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Dict, Optional
|
|
8
|
+
|
|
9
|
+
if sys.version_info < (3, 11):
|
|
10
|
+
from tomli import load as toml_load_fobj
|
|
11
|
+
else:
|
|
12
|
+
from tomllib import load as toml_load_fobj
|
|
13
|
+
|
|
14
|
+
from nested_config._types import ConfigDict, ConfigDictLoader, PathLike
|
|
15
|
+
|
|
16
|
+
YAML_INSTALLED = False
|
|
17
|
+
with contextlib.suppress(ImportError):
|
|
18
|
+
import yaml # type: ignore
|
|
19
|
+
|
|
20
|
+
YAML_INSTALLED = True
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class NoLoaderError(Exception):
|
|
24
|
+
def __init__(self, suffix: str):
|
|
25
|
+
super().__init__(f"There is no loader for file extension {suffix}")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ConfigLoaderError(Exception):
|
|
29
|
+
def __init__(self, config_path: Path) -> None:
|
|
30
|
+
super().__init__(f"There was a problem loading config file {config_path}")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def toml_load(path: PathLike) -> ConfigDict:
|
|
34
|
+
"""Load a TOML config file"""
|
|
35
|
+
with open(path, "rb") as fobj:
|
|
36
|
+
return toml_load_fobj(fobj)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def json_load(path: PathLike) -> ConfigDict:
|
|
40
|
+
"""Load a JSON config file"""
|
|
41
|
+
with open(path, "rb") as fobj:
|
|
42
|
+
return json.load(fobj)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
config_dict_loaders: Dict[str, ConfigDictLoader] = {
|
|
46
|
+
".toml": toml_load,
|
|
47
|
+
".json": json_load,
|
|
48
|
+
}
|
|
49
|
+
"""Mapping of config file extension to config file loader"""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
if YAML_INSTALLED:
|
|
53
|
+
|
|
54
|
+
def yaml_load(path: PathLike) -> ConfigDict:
|
|
55
|
+
"""Load a YAML config file (safely)"""
|
|
56
|
+
with open(path, "r") as fobj:
|
|
57
|
+
return yaml.safe_load(fobj)
|
|
58
|
+
|
|
59
|
+
config_dict_loaders[".yaml"] = yaml_load
|
|
60
|
+
config_dict_loaders[".yml"] = yaml_load
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _get_loader(config_path: Path):
|
|
64
|
+
"""Get the loader for the specified suffix, or a loader from default suffix"""
|
|
65
|
+
try:
|
|
66
|
+
try:
|
|
67
|
+
return config_dict_loaders[config_path.suffix]
|
|
68
|
+
except KeyError:
|
|
69
|
+
if default_loader := _get_default_loader():
|
|
70
|
+
return default_loader
|
|
71
|
+
raise
|
|
72
|
+
except KeyError:
|
|
73
|
+
raise NoLoaderError(config_path.suffix) from None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _get_default_loader() -> Optional[ConfigDictLoader]:
|
|
77
|
+
try:
|
|
78
|
+
return config_dict_loaders["DEFAULT"]
|
|
79
|
+
except KeyError:
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def set_default_loader(default_suffix: str):
|
|
84
|
+
try:
|
|
85
|
+
config_dict_loaders["DEFAULT"] = config_dict_loaders[default_suffix]
|
|
86
|
+
except KeyError:
|
|
87
|
+
raise NoLoaderError(default_suffix) from None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def load_config(config_path: Path) -> ConfigDict:
|
|
91
|
+
"""Select a loader based on the suffix (extension) of the config file and try to load
|
|
92
|
+
the config using that loader. E.g. for .toml, use the TOML loader.
|
|
93
|
+
|
|
94
|
+
Inputs
|
|
95
|
+
------
|
|
96
|
+
config_path
|
|
97
|
+
Path to the config file
|
|
98
|
+
|
|
99
|
+
Returns
|
|
100
|
+
-------
|
|
101
|
+
ConfigDict
|
|
102
|
+
A mapping of the data stored in the config file
|
|
103
|
+
|
|
104
|
+
Raises
|
|
105
|
+
------
|
|
106
|
+
NoLoaderError (via _get_loader)
|
|
107
|
+
No loader could be found for the suffix (or default suffix, if provided)
|
|
108
|
+
ConfigLoaderError
|
|
109
|
+
There was an error running the loader (e.g. in tomllib or yaml or json)
|
|
110
|
+
"""
|
|
111
|
+
loader = _get_loader(config_path)
|
|
112
|
+
try:
|
|
113
|
+
return loader(config_path)
|
|
114
|
+
except Exception as ex:
|
|
115
|
+
raise ConfigLoaderError(config_path) from ex
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""parsing.py - Functions to parse config files (e.g. TOML) into Pydantic model instances,
|
|
2
|
+
possibly with nested models specified by string paths."""
|
|
3
|
+
|
|
4
|
+
import typing
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional, Type
|
|
7
|
+
|
|
8
|
+
import pydantic
|
|
9
|
+
|
|
10
|
+
from nested_config import _compat
|
|
11
|
+
from nested_config._types import (
|
|
12
|
+
UNION_TYPES,
|
|
13
|
+
ConfigDict,
|
|
14
|
+
PathLike,
|
|
15
|
+
PydModelT,
|
|
16
|
+
ispydmodel,
|
|
17
|
+
)
|
|
18
|
+
from nested_config.loaders import load_config, set_default_loader
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def validate_config(
|
|
22
|
+
config_path: PathLike,
|
|
23
|
+
model: Type[PydModelT],
|
|
24
|
+
*,
|
|
25
|
+
default_suffix: Optional[str] = None,
|
|
26
|
+
) -> PydModelT:
|
|
27
|
+
"""Load a config file into a Pydantic model. The config file may contain string paths
|
|
28
|
+
where nested models would be expected. These are preparsed into their respective
|
|
29
|
+
models.
|
|
30
|
+
|
|
31
|
+
If paths to nested models are relative, they are assumed to be relative to the path of
|
|
32
|
+
their parent config file.
|
|
33
|
+
|
|
34
|
+
Input
|
|
35
|
+
-----
|
|
36
|
+
config_path
|
|
37
|
+
A string or pathlib.Path to the config file to parse
|
|
38
|
+
model
|
|
39
|
+
The Pydantic model to use for creating the config object
|
|
40
|
+
default_suffix
|
|
41
|
+
If there is no loader for the config file suffix (or the config file has no
|
|
42
|
+
suffix) try to load the config with the loader specified by this extension, e.g.
|
|
43
|
+
'.toml' or '.yml'
|
|
44
|
+
Returns
|
|
45
|
+
-------
|
|
46
|
+
A Pydantic object of the type specified by the model input.
|
|
47
|
+
|
|
48
|
+
Raises
|
|
49
|
+
------
|
|
50
|
+
NoLoaderError
|
|
51
|
+
No loader is available for the config file extension
|
|
52
|
+
ConfigLoaderError
|
|
53
|
+
There was a problem loading a config file with its loader
|
|
54
|
+
pydantic.ValidationError
|
|
55
|
+
The data fields or types in the file do not match the model.
|
|
56
|
+
|
|
57
|
+
"""
|
|
58
|
+
if default_suffix:
|
|
59
|
+
set_default_loader(default_suffix)
|
|
60
|
+
# Input arg coercion
|
|
61
|
+
config_path = Path(config_path)
|
|
62
|
+
# Get the config dict and the model fields
|
|
63
|
+
config_dict = load_config(config_path)
|
|
64
|
+
# preparse the config (possibly loading nested configs)
|
|
65
|
+
config_dict = _preparse_config_dict(config_dict, model, config_path)
|
|
66
|
+
# Create and validate the config object
|
|
67
|
+
return _compat.parse_obj(model, config_dict)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _preparse_config_dict(
|
|
71
|
+
config_dict: ConfigDict, model: Type[pydantic.BaseModel], config_path: Path
|
|
72
|
+
):
|
|
73
|
+
return {
|
|
74
|
+
key: _preparse_config_value(
|
|
75
|
+
value, _compat.get_modelfield_annotation(model, key), config_path
|
|
76
|
+
)
|
|
77
|
+
for key, value in config_dict.items()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _preparse_config_value(field_value, field_annotation, config_path: Path):
|
|
82
|
+
"""Check if a model field contains a path to another model and parse it accordingly"""
|
|
83
|
+
# If the annotation is optional, get the enclosed annotation
|
|
84
|
+
field_annotation = _get_optional_ann(field_annotation)
|
|
85
|
+
# ###
|
|
86
|
+
# N cases:
|
|
87
|
+
# 1. Config value is not a string, list, or dict
|
|
88
|
+
# 2. Config value is a dict, model expects a model
|
|
89
|
+
# 3. Config value is a string, model expects a model
|
|
90
|
+
# 4. Config value is a list, model expects a list of some type
|
|
91
|
+
# 5. Config value is a dict, model expects a dict with values of a particular model
|
|
92
|
+
# type
|
|
93
|
+
# 6. A string, list, or dict that doesn't match cases 2-5
|
|
94
|
+
# ###
|
|
95
|
+
|
|
96
|
+
# 1.
|
|
97
|
+
if not isinstance(field_value, (str, list, dict)):
|
|
98
|
+
return field_value
|
|
99
|
+
# 2.
|
|
100
|
+
if isinstance(field_value, dict) and ispydmodel(field_annotation, pydantic.BaseModel):
|
|
101
|
+
return _preparse_config_dict(field_value, field_annotation, config_path)
|
|
102
|
+
# 3.
|
|
103
|
+
if isinstance(field_value, str) and ispydmodel(field_annotation, pydantic.BaseModel):
|
|
104
|
+
return _parse_path_str_into_pydmodel(field_value, field_annotation, config_path)
|
|
105
|
+
# 4.
|
|
106
|
+
if isinstance(field_value, list) and (
|
|
107
|
+
listval_annotation := _get_list_value_ann(field_annotation)
|
|
108
|
+
):
|
|
109
|
+
return [
|
|
110
|
+
_preparse_config_value(li, listval_annotation, config_path)
|
|
111
|
+
for li in field_value
|
|
112
|
+
]
|
|
113
|
+
# 5.
|
|
114
|
+
if isinstance(field_value, dict) and (
|
|
115
|
+
dictval_annotation := _get_dict_value_ann(field_annotation)
|
|
116
|
+
):
|
|
117
|
+
return {
|
|
118
|
+
key: _preparse_config_value(value, dictval_annotation, config_path)
|
|
119
|
+
for key, value in field_value.items()
|
|
120
|
+
}
|
|
121
|
+
# 6.
|
|
122
|
+
return field_value
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _parse_path_str_into_pydmodel(
|
|
126
|
+
path_str: str, model: Type[PydModelT], parent_path: Path
|
|
127
|
+
) -> PydModelT:
|
|
128
|
+
"""Convert a path string to a path (possibly relative to a parent config path) and
|
|
129
|
+
create an instance of a Pydantic model"""
|
|
130
|
+
path = Path(path_str)
|
|
131
|
+
if not path.is_absolute():
|
|
132
|
+
# Assume it's relative to the parent config path
|
|
133
|
+
path = parent_path.parent / path
|
|
134
|
+
if not path.is_file():
|
|
135
|
+
raise FileNotFoundError(
|
|
136
|
+
f"Config file '{parent_path}' contains a path to another config file"
|
|
137
|
+
f" '{path_str}' that could not be found."
|
|
138
|
+
)
|
|
139
|
+
return validate_config(path, model)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _get_optional_ann(annotation):
|
|
143
|
+
"""Convert a possibly Optional annotation to its underlying annotation"""
|
|
144
|
+
annotation_origin = typing.get_origin(annotation)
|
|
145
|
+
annotation_args = typing.get_args(annotation)
|
|
146
|
+
if annotation_origin in UNION_TYPES and annotation_args[1] is type(None):
|
|
147
|
+
return annotation_args[0]
|
|
148
|
+
return annotation
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _get_list_value_ann(annotation):
|
|
152
|
+
"""Get the internal annotation of a typed list, if any. Otherwise return None."""
|
|
153
|
+
annotation_origin = typing.get_origin(annotation)
|
|
154
|
+
annotation_args = typing.get_args(annotation)
|
|
155
|
+
if annotation_origin is list and len(annotation_args) > 0:
|
|
156
|
+
return annotation_args[0]
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _get_dict_value_ann(annotation):
|
|
161
|
+
"""Get the internal annotation of a dict's value type, if any. Otherwise return
|
|
162
|
+
None."""
|
|
163
|
+
annotation_origin = typing.get_origin(annotation)
|
|
164
|
+
annotation_args = typing.get_args(annotation)
|
|
165
|
+
if annotation_origin is dict and len(annotation_args) > 1:
|
|
166
|
+
return annotation_args[1]
|
|
167
|
+
return None
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""version.py - Get __version__ from Poetry-generated pyproject.toml or installed
|
|
2
|
+
package version"""
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import single_version # type: ignore
|
|
7
|
+
|
|
8
|
+
__version__ = single_version.get_version(
|
|
9
|
+
__name__.split(".")[0], Path(__file__).parent.parent
|
|
10
|
+
)
|