Skip to content

Commit f82084c

Browse files
added CLI tool to genearte stub files
1 parent 828f166 commit f82084c

File tree

8 files changed

+423
-297
lines changed

8 files changed

+423
-297
lines changed

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
- Automatically `merge` default and production configuration files
1818
- Convert keys in configuration files to `snake_case`
1919
- YAML validation with `Pydantic` models
20+
- Generate stub files for your dynamic configuration with `pyya` CLI tool.
2021

2122
## Installation
2223

@@ -60,6 +61,7 @@ database:
6061
Import configuration files in your Python code with `pyya`:
6162

6263
```python
64+
# config.py
6365
import json
6466
6567
from pyya import init_config, logger
@@ -138,6 +140,18 @@ allow_extra_sections=True
138140
warn_extra_sections=True
139141
```
140142

143+
By default autocompletion does not work with attribute-style configurations generated by `pyya`.
144+
However, you can generate special stub files with `.pyi` extension to make you LSP or `mypy` to properly suggest configuration fields.
145+
146+
With `pyya` it actually very straightforward (assuming you have `config.py` file like in the example above):
147+
148+
```shell
149+
# pyya is a CLI tool installed automatically when you run pip install
150+
pyya --input "default.config.yaml" --output "config.pyi"
151+
```
152+
153+
This will create a special file with type hints for `config.py` file (note that both files should have the same basename).
154+
141155
## Contributing
142156

143157
Are you a developer?

pyproject.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "pyya"
3-
version = "0.1.8"
3+
version = "0.1.9"
44
description = "Convert YAML configuration files to Python objects"
55
readme = "README.md"
66
requires-python = ">=3.8"
@@ -33,6 +33,9 @@ Homepage = "https://github.com/shadowy-pycoder/pyya"
3333
Issues = "https://github.com/shadowy-pycoder/pyya/issues"
3434
Repository = "https://github.com/shadowy-pycoder/pyya"
3535

36+
[project.scripts]
37+
pyya = "pyya.__main__:main"
38+
3639
[tool.mypy]
3740
python_version = "3.8"
3841
cache_dir = ".mypy_cache/strict"

pyya.egg-info/PKG-INFO

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
Metadata-Version: 2.1
22
Name: pyya
3-
Version: 0.1.8
3+
Version: 0.1.9
44
Summary: Convert YAML configuration files to Python objects
55
Author-email: shadowy-pycoder <[email protected]>
66
Project-URL: Homepage, https://github.com/shadowy-pycoder/pyya
@@ -44,6 +44,7 @@ Requires-Dist: types-pyyaml>=6.0.12.20240917
4444
- Automatically `merge` default and production configuration files
4545
- Convert keys in configuration files to `snake_case`
4646
- YAML validation with `Pydantic` models
47+
- Generate stub files for your dynamic configuration with `pyya` CLI tool.
4748

4849
## Installation
4950

@@ -87,6 +88,7 @@ database:
8788
Import configuration files in your Python code with `pyya`:
8889

8990
```python
91+
# config.py
9092
import json
9193

9294
from pyya import init_config, logger
@@ -165,6 +167,18 @@ allow_extra_sections=True
165167
warn_extra_sections=True
166168
```
167169

170+
By default autocompletion does not work with attribute-style configurations generated by `pyya`.
171+
However, you can generate special stub files with `.pyi` extension to make you LSP or `mypy` to properly suggest configuration fields.
172+
173+
With `pyya` it actually very straightforward (assuming you have `config.py` file like in the example above):
174+
175+
```shell
176+
# pyya is a CLI tool installed automatically when you run pip install
177+
pyya --input "default.config.yaml" --output "config.pyi"
178+
```
179+
180+
This will create a special file with type hints for `config.py` file (note that both files should have the same basename).
181+
168182
## Contributing
169183

170184
Are you a developer?

pyya.egg-info/SOURCES.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ LICENSE
22
README.md
33
pyproject.toml
44
pyya/__init__.py
5+
pyya/__main__.py
56
pyya/py.typed
67
pyya.egg-info/PKG-INFO
78
pyya.egg-info/SOURCES.txt
89
pyya.egg-info/dependency_links.txt
10+
pyya.egg-info/entry_points.txt
911
pyya.egg-info/requires.txt
1012
pyya.egg-info/top_level.txt

pyya.egg-info/entry_points.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
[console_scripts]
2+
pyya = pyya.__main__:main

pyya/__init__.py

Lines changed: 73 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from copy import deepcopy
44
from pathlib import Path
55
from pprint import pformat
6-
from typing import Any, Dict, List, Optional, Type, Union
6+
from typing import Any, Dict, List, Optional, Tuple, Type, Union
77

88
import yaml as _yaml
99
from camel_converter import to_snake as _to_snake
@@ -37,6 +37,8 @@ def init_config(
3737
validate_data_types: bool = True,
3838
allow_extra_sections: bool = True,
3939
warn_extra_sections: bool = True,
40+
_generate_stub: bool = False,
41+
_stub_variable_name: str = 'config',
4042
) -> PyyaConfig:
4143
"""Initialize attribute-stylish configuration from YAML file.
4244
@@ -154,24 +156,85 @@ def extra_flat(self) -> Any:
154156
extra_flat.update(data)
155157
return extra_flat
156158

157-
def _model_from_dict(name: str, data: Dict[str, Any]) -> Type[BaseModel]:
159+
def _model_and_stub_from_dict(
160+
name: str, data: Dict[str, Any], path: Optional[List[str]] = None
161+
) -> Tuple[Type[ExtraBase], str]:
158162
fields: Dict[Any, Any] = {}
163+
if path is None:
164+
path = []
165+
class_name = ''.join(part.capitalize() if i > 0 else part for i, part in enumerate(path + [name]))
166+
stub_lines = [f'class {class_name}:']
167+
nested_stubs = []
168+
py_type: Any
159169
for section, entry in data.items():
160170
if isinstance(entry, Dict):
161-
nested_model = _model_from_dict(section, entry)
171+
nested_model, nested_stub = _model_and_stub_from_dict(section, entry, path + [name])
172+
if not keyword.iskeyword(section) and section.isidentifier():
173+
stub_lines.append(f' {section}: {class_name + section.capitalize()}')
174+
nested_stubs.append(nested_stub)
162175
fields[section] = (nested_model, entry)
163176
elif isinstance(entry, list) and entry:
164177
first_item = entry[0]
165178
if isinstance(first_item, Dict):
166-
nested_model = _model_from_dict(f'{section.capitalize()}Item', first_item)
179+
nested_model, nested_stub = _model_and_stub_from_dict(
180+
f'{section.capitalize()}_item', first_item, path + [name]
181+
)
182+
if not keyword.iskeyword(section) and section.isidentifier():
183+
stub_lines.append(f' {section}: List[{class_name + section.capitalize()}_item]')
184+
nested_stubs.append(nested_stub)
167185
fields[section] = (List[nested_model], entry) # type: ignore
168186
else:
169-
fields[section] = (List[type(first_item)], entry) # type: ignore
187+
py_type = type(first_item)
188+
if not keyword.iskeyword(section) and section.isidentifier():
189+
stub_lines.append(f' {section}: List[{py_type.__name__}]')
190+
fields[section] = (List[py_type], entry)
170191
elif isinstance(entry, list):
192+
if not keyword.iskeyword(section) and section.isidentifier():
193+
stub_lines.append(f' {section}: List[Any]')
171194
fields[section] = (List[Any], entry)
172195
else:
173-
fields[section] = (type(entry), entry)
174-
return create_model(name, **fields, __base__=ExtraBase)
196+
py_type = type(entry)
197+
if not keyword.iskeyword(section) and section.isidentifier():
198+
stub_lines.append(f' {section}: {py_type.__name__}')
199+
fields[section] = (py_type, entry)
200+
stub_code = '\n\n'.join(nested_stubs + ['\n'.join(stub_lines)]).replace('-', '_')
201+
return create_model(name, **fields, __base__=ExtraBase), stub_code
202+
203+
def _get_default_raw_data() -> ConfigType:
204+
try:
205+
try:
206+
with open(Path(default_config)) as fstream:
207+
_default_raw_data: Optional[ConfigType] = _yaml.safe_load(fstream)
208+
except _yaml.YAMLError as e:
209+
err_msg = f'{default_config} file is corrupted: {e}'
210+
logger.error(err_msg)
211+
raise PyyaError(err_msg) from None
212+
if _default_raw_data is None:
213+
raise FileNotFoundError()
214+
except FileNotFoundError as e:
215+
logger.error(e)
216+
raise PyyaError(f'{default_config} file is missing or empty') from None
217+
_default_raw_data = _sanitize_keys(_default_raw_data)
218+
return _default_raw_data
219+
220+
if _generate_stub:
221+
output_file = Path(config)
222+
if output_file.exists():
223+
err_msg = f'{output_file} already exists'
224+
logger.error(err_msg)
225+
raise PyyaError(err_msg)
226+
_default_raw_data = _get_default_raw_data()
227+
_, stub = _model_and_stub_from_dict('Config', _default_raw_data)
228+
stub_full = (
229+
f'# {output_file} was autogenerated with pyya CLI tool, see `pyya -h`\nfrom typing import Any, List\n\n'
230+
f'{stub}\n\n'
231+
'# for type hints to work the variable name created with pyya.init_config\n'
232+
'# should have the same name (e.g. config = pyya.init_config())\n'
233+
f'{_stub_variable_name}: Config\n'
234+
)
235+
output_file.write_text(stub_full)
236+
logger.info(f'{output_file} created')
237+
return PyyaConfig()
175238

176239
try:
177240
with open(Path(config)) as fstream:
@@ -194,26 +257,13 @@ def _model_from_dict(name: str, data: Dict[str, Any]) -> Type[BaseModel]:
194257
err_msg = f'Failed parsing `sections_ignored_on_merge`: {e!r}'
195258
logger.error(err_msg)
196259
raise PyyaError(err_msg) from None
197-
try:
198-
try:
199-
with open(Path(default_config)) as fstream:
200-
_default_raw_data: Optional[ConfigType] = _yaml.safe_load(fstream)
201-
_default_raw_data = _sanitize_keys(_default_raw_data)
202-
except _yaml.YAMLError as e:
203-
err_msg = f'{default_config} file is corrupted: {e}'
204-
logger.error(err_msg)
205-
raise PyyaError(err_msg) from None
206-
if _default_raw_data is None:
207-
raise FileNotFoundError()
208-
except FileNotFoundError as e:
209-
logger.error(e)
210-
raise PyyaError(f'{default_config} file is missing or empty') from None
260+
_default_raw_data = _get_default_raw_data()
211261
# create copy for logging (only overwritten fields)
212262
_raw_data_copy = deepcopy(_raw_data)
213263
_merge_configs(_raw_data, _default_raw_data)
214264
logger.debug(f'Resulting config after merge:\n{pformat(_raw_data)}')
215265
if validate_data_types:
216-
ConfigModel = _model_from_dict('ConfigModel', _default_raw_data)
266+
ConfigModel, _ = _model_and_stub_from_dict('ConfigModel', _default_raw_data)
217267
try:
218268
validated_raw_data = ConfigModel.model_validate(_raw_data)
219269
if extra_sections := validated_raw_data.extra_flat: # type: ignore
@@ -231,7 +281,7 @@ def _model_from_dict(name: str, data: Dict[str, Any]) -> Type[BaseModel]:
231281
logger.info(f'The following sections were overwritten:\n{pformat(_raw_data_copy)}')
232282
try:
233283
logger.debug(f'Resulting config:\n{pformat(_raw_data)}')
234-
return _munchify(_raw_data)
284+
return PyyaConfig(_munchify(_raw_data))
235285
except Exception as e:
236286
err_msg = f'Failed parsing config file: {e!r}'
237287
logger.error(err_msg)

pyya/__main__.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import argparse
2+
import logging
3+
import sys
4+
5+
from pyya import PyyaError, init_config, logger
6+
7+
8+
def main() -> None:
9+
parser = argparse.ArgumentParser(description='stub generator for pyya')
10+
parser.add_argument(
11+
'-i', '--input', default='default.config.yaml', help='path to YAML file from which to generate stub file'
12+
)
13+
parser.add_argument('-o', '--output', default='config.pyi', help='path to resulting stub pyi file')
14+
parser.add_argument('--var-name', default='config', help='variable name to refer to config object')
15+
parser.add_argument('--to-snake', action='store_true', help='convert config section names to snake case')
16+
parser.add_argument('--add-prefix', action='store_true', help='add underscore prefix to Python keywords')
17+
parser.add_argument('--debug', action='store_true', help='print debug messages')
18+
args = parser.parse_args()
19+
logger.setLevel(logging.INFO)
20+
if args.debug:
21+
logger.setLevel(logging.DEBUG)
22+
try:
23+
init_config(
24+
args.output,
25+
args.input,
26+
convert_keys_to_snake_case=args.to_snake,
27+
add_underscore_prefix_to_keywords=args.add_prefix,
28+
_generate_stub=True,
29+
_stub_variable_name=args.var_name,
30+
)
31+
except PyyaError:
32+
parser.print_usage()
33+
sys.exit(2)
34+
except Exception as e:
35+
print(repr(e))
36+
parser.print_usage()
37+
sys.exit(2)
38+
39+
40+
if __name__ == '__main__':
41+
main()

0 commit comments

Comments
 (0)