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.
@@ -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
+ )