Skip to content

Commit fff9a03

Browse files
authored
Merge pull request #39 from bogdandm/yaml-ini-parsers
Add yaml and ini parsers
2 parents d00e80d + 9895ea7 commit fff9a03

File tree

11 files changed

+2240
-42
lines changed

11 files changed

+2240
-42
lines changed

Diff for: README.md

+139-11
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,125 @@ class Definition_Schema:
286286
</p>
287287
</details>
288288

289+
### Github-actions config files
290+
291+
<details><summary>----- Show -----</summary>
292+
<p>
293+
294+
Github-actions model based on files from [starter-workflows](https://github.com/actions/starter-workflows/tree/main/ci)
295+
296+
```
297+
json2models -m Actions "./starter-workflows/ci/*.yml" -s flat -f pydantic -i yaml --dkf env with jobs
298+
```
299+
300+
```python
301+
r"""
302+
generated by json2python-models v0.2.3 at Tue Jul 13 19:52:43 2021
303+
command: /opt/projects/json2python-models/venv/bin/json2models -m Actions ./starter-workflows/ci/*.yml -s flat -f pydantic -i yaml --dkf env with jobs
304+
"""
305+
from pydantic import BaseModel, Field
306+
from typing import Dict, List, Optional, Union
307+
from typing_extensions import Literal
308+
309+
310+
class Actions(BaseModel):
311+
on: Union['On', List[Literal["push"]]]
312+
jobs: Dict[str, 'Job']
313+
name: Optional[str] = None
314+
env: Optional[Dict[str, Union[int, str]]] = {}
315+
316+
317+
class On(BaseModel):
318+
push: Optional['Push'] = None
319+
pull_request: Optional['PullRequest'] = None
320+
release: Optional['Release'] = None
321+
schedule: Optional[List['Schedule']] = []
322+
workflow_dispatch: Optional[None] = None
323+
324+
325+
class Push(BaseModel):
326+
branches: List[Literal["$default-branch"]]
327+
tags: Optional[List[Literal["v*.*.*"]]] = []
328+
329+
330+
class PullRequest(BaseModel):
331+
branches: List[Literal["$default-branch"]]
332+
333+
334+
class Release(BaseModel):
335+
types: List[Literal["created", "published"]]
336+
337+
338+
class Schedule(BaseModel):
339+
cron: Literal["$cron-daily"]
340+
341+
342+
class Job(BaseModel):
343+
runson: Literal["${{ matrix.os }}", "macOS-latest", "macos-latest", "ubuntu-18.04", "ubuntu-latest", "windows-latest"] = Field(..., alias="runs-on")
344+
steps: List['Step']
345+
name: Optional[str] = None
346+
environment: Optional[Literal["production"]] = None
347+
outputs: Optional['Output'] = None
348+
container: Optional['Container'] = None
349+
needs: Optional[Literal["build"]] = None
350+
permissions: Optional['Permission'] = None
351+
strategy: Optional['Strategy'] = None
352+
defaults: Optional['Default'] = None
353+
env: Optional[Dict[str, str]] = {}
354+
355+
356+
class Step(BaseModel):
357+
uses: Optional[str] = None
358+
name: Optional[str] = None
359+
with_: Optional[Dict[str, Union[bool, float, str]]] = Field({}, alias="with")
360+
run: Optional[str] = None
361+
env: Optional[Dict[str, str]] = {}
362+
workingdirectory: Optional[str] = Field(None, alias="working-directory")
363+
id_: Optional[Literal["build-image", "composer-cache", "deploy-and-expose", "image-build", "login-ecr", "meta", "push-to-registry", "task-def"]] = Field(None, alias="id")
364+
if_: Optional[str] = Field(None, alias="if")
365+
shell: Optional[Literal["Rscript {0}"]] = None
366+
367+
368+
class Output(BaseModel):
369+
route: str = Field(..., alias="ROUTE")
370+
selector: str = Field(..., alias="SELECTOR")
371+
372+
373+
class Container(BaseModel):
374+
image: Literal["crystallang/crystal", "erlang:22.0.7"]
375+
376+
377+
class Permission(BaseModel):
378+
contents: Literal["read"]
379+
packages: Literal["write"]
380+
381+
382+
class Strategy(BaseModel):
383+
matrix: Optional['Matrix'] = None
384+
maxparallel: Optional[int] = Field(None, alias="max-parallel")
385+
failfast: Optional[bool] = Field(None, alias="fail-fast")
386+
387+
388+
class Matrix(BaseModel):
389+
rversion: Optional[List[float]] = Field([], alias="r-version")
390+
pythonversion: Optional[List[float]] = Field([], alias="python-version")
391+
deno: Optional[List[Literal["canary", "v1.x"]]] = []
392+
os: Optional[List[Literal["macOS-latest", "ubuntu-latest", "windows-latest"]]] = []
393+
rubyversion: Optional[List[float]] = Field([], alias="ruby-version")
394+
nodeversion: Optional[List[Literal["12.x", "14.x", "16.x"]]] = Field([], alias="node-version")
395+
configuration: Optional[List[Literal["Debug", "Release"]]] = []
396+
397+
398+
class Default(BaseModel):
399+
run: 'Run'
400+
401+
402+
class Run(BaseModel):
403+
shell: Literal["bash"]
404+
```
405+
406+
</p></details>
407+
289408
## Installation
290409

291410
| **Be ware**: this project supports only `python3.7` and higher. |
@@ -315,24 +434,33 @@ json2models -m Car car_*.json -f attrs > car.py
315434

316435
Arguments:
317436
* `-h`, `--help` - Show help message and exit
318-
319-
* `-m`, `--model` - Model name and its JSON data as path or unix-like path pattern. `*`, `**` or `?` patterns symbols are supported.
437+
438+
* `-m`, `--model` - Model name and its JSON data as path or unix-like path pattern. `*`, `**` or `?` patterns symbols
439+
are supported.
320440
* **Format**: `-m <Model name> [<JSON files> ...]`
321441
* **Example**: `-m Car audi.json reno.json` or `-m Car audi.json -m Car reno.json` (results will be the same)
322-
323-
* `-l`, `--list` - Like `-m` but given json file should contain list of model data (dataset).
324-
If this file contains dict with nested list than you can pass `<JSON key>` to lookup.
325-
Deep lookups are supported by dot-separated path. If no lookup needed pass `-` as `<JSON key>`.
442+
443+
* `-l`, `--list` - Like `-m` but given json file should contain list of model data (dataset). If this file contains dict
444+
with nested list than you can pass `<JSON key>` to lookup. Deep lookups are supported by dot-separated path. If no
445+
lookup needed pass `-` as `<JSON key>`.
326446
* **Format**: `-l <Model name> <JSON key> <JSON file>`
327447
* **Example**: `-l Car - cars.json -l Person fetch_results.items.persons result.json`
328-
* **Note**: Models names under this arguments should be unique.
329-
448+
* **Note**: Models names under these arguments should be unique.
449+
450+
* `-i`, `--input-format` - Input file format (parser). Default is JSON parser. Yaml parser requires PyYaml or
451+
ruamel.yaml to be installed. Ini parser uses
452+
builtin [configparser](https://docs.python.org/3/library/configparser.html). To implement new one - add new method
453+
to `cli.FileLoaders` (and create pull request :) )
454+
* **Format**: `-i {json, yaml, ini}`
455+
* **Example**: `-i yaml`
456+
* **Default**: `-i json`
457+
330458
* `-o`, `--output` - Output file
331459
* **Format**: `-o <FILE>`
332460
* **Example**: `-o car_model.py`
333-
334-
* `-f`, `--framework` - Model framework for which python code is generated.
335-
`base` (default) mean no framework so code will be generated without any decorators and additional meta-data.
461+
462+
* `-f`, `--framework` - Model framework for which python code is generated.
463+
`base` (default) mean no framework so code will be generated without any decorators and additional meta-data.
336464
* **Format**: `-f {base, pydantic, attrs, dataclasses, custom}`
337465
* **Example**: `-f pydantic`
338466
* **Default**: `-f base`

Diff for: json_to_models/cli.py

+52-18
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import argparse
2+
import configparser
23
import importlib
34
import itertools
45
import json
@@ -10,6 +11,14 @@
1011
from pathlib import Path
1112
from typing import Any, Callable, Dict, Generator, Iterable, List, Tuple, Type, Union
1213

14+
try:
15+
import ruamel.yaml as yaml
16+
except ImportError:
17+
try:
18+
import yaml
19+
except ImportError:
20+
yaml = None
21+
1322
from . import __version__ as VERSION
1423
from .dynamic_typing import ModelMeta, register_datetime_classes
1524
from .generator import MetadataGenerator
@@ -80,6 +89,7 @@ def parse_args(self, args: List[str] = None):
8089
(model_name, (lookup, Path(path)))
8190
for model_name, lookup, path in namespace.list or ()
8291
]
92+
parser = getattr(FileLoaders, namespace.input_format)
8393
self.output_file = namespace.output
8494
self.enable_datetime = namespace.datetime
8595
disable_unicode_conversion = namespace.disable_unicode_conversion
@@ -94,7 +104,7 @@ def parse_args(self, args: List[str] = None):
94104
dict_keys_fields: List[str] = namespace.dict_keys_fields
95105

96106
self.validate(models, models_lists, merge_policy, framework, code_generator)
97-
self.setup_models_data(models, models_lists)
107+
self.setup_models_data(models, models_lists, parser)
98108
self.set_args(merge_policy, structure, framework, code_generator, code_generator_kwargs_raw,
99109
dict_keys_regex, dict_keys_fields, disable_unicode_conversion)
100110

@@ -157,16 +167,20 @@ def validate(self, models, models_list, merge_policy, framework, code_generator)
157167
elif framework != 'custom' and code_generator is not None:
158168
raise ValueError("--code-generator argument has no effect without '--framework custom' argument")
159169

160-
def setup_models_data(self, models: Iterable[Tuple[str, Iterable[Path]]],
161-
models_lists: Iterable[Tuple[str, Tuple[str, Path]]]):
170+
def setup_models_data(
171+
self,
172+
models: Iterable[Tuple[str, Iterable[Path]]],
173+
models_lists: Iterable[Tuple[str, Tuple[str, Path]]],
174+
parser: 'FileLoaders.T'
175+
):
162176
"""
163177
Initialize lazy loaders for models data
164178
"""
165179
models_dict: Dict[str, List[Iterable[dict]]] = defaultdict(list)
166180
for model_name, paths in models:
167-
models_dict[model_name].append(map(safe_json_load, paths))
181+
models_dict[model_name].append(parser(path) for path in paths)
168182
for model_name, (lookup, path) in models_lists:
169-
models_dict[model_name].append(iter_json_file(path, lookup))
183+
models_dict[model_name].append(iter_json_file(parser(path), lookup))
170184

171185
self.models_data = {
172186
model_name: itertools.chain(*list_of_gen)
@@ -252,6 +266,12 @@ def _create_argparser(cls) -> argparse.ArgumentParser:
252266
"I.e. for file that contains dict {\"a\": {\"b\": [model_data, ...]}} you should\n"
253267
"pass 'a.b' as <JSON key>.\n\n"
254268
)
269+
parser.add_argument(
270+
"-i", "--input-format",
271+
default="json",
272+
choices=['json', 'yaml', 'ini'],
273+
help="Input files parser ('PyYaml' is required to parse yaml files)\n\n"
274+
)
255275
parser.add_argument(
256276
"-o", "--output",
257277
metavar="FILE", default="",
@@ -385,7 +405,31 @@ def path_split(path: str) -> List[str]:
385405
return folders
386406

387407

388-
def dict_lookup(d: dict, lookup: str) -> Union[dict, list]:
408+
class FileLoaders:
409+
T = Callable[[Path], Union[dict, list]]
410+
411+
@staticmethod
412+
def json(path: Path) -> Union[dict, list]:
413+
with path.open() as fp:
414+
return json.load(fp)
415+
416+
@staticmethod
417+
def yaml(path: Path) -> Union[dict, list]:
418+
if yaml is None:
419+
print('Yaml parser is not installed. To parse yaml files PyYaml (or ruamel.yaml) is required.')
420+
raise ImportError('yaml')
421+
with path.open() as fp:
422+
return yaml.safe_load(fp)
423+
424+
@staticmethod
425+
def ini(path: Path) -> dict:
426+
config = configparser.ConfigParser()
427+
with path.open() as fp:
428+
config.read_file(fp)
429+
return {s: dict(config.items(s)) for s in config.sections()}
430+
431+
432+
def dict_lookup(d: Union[dict, list], lookup: str) -> Union[dict, list]:
389433
"""
390434
Extract nested dictionary value from key path.
391435
If lookup is "-" returns dict as is.
@@ -403,7 +447,7 @@ def dict_lookup(d: dict, lookup: str) -> Union[dict, list]:
403447
return d
404448

405449

406-
def iter_json_file(path: Path, lookup: str) -> Generator[Union[dict, list], Any, None]:
450+
def iter_json_file(data: Union[dict, list], lookup: str) -> Generator[Union[dict, list], Any, None]:
407451
"""
408452
Loads given 'path' file, perform lookup and return generator over json list.
409453
Does not open file until iteration is started.
@@ -412,21 +456,11 @@ def iter_json_file(path: Path, lookup: str) -> Generator[Union[dict, list], Any,
412456
:param lookup: Dot separated lookup path
413457
:return:
414458
"""
415-
with path.open() as f:
416-
l = json.load(f)
417-
l = dict_lookup(l, lookup)
459+
l = dict_lookup(data, lookup)
418460
assert isinstance(l, list), f"Dict lookup return {type(l)} but list is expected, check your lookup path"
419461
yield from l
420462

421463

422-
def safe_json_load(path: Path) -> Union[dict, list]:
423-
"""
424-
Open file, load json and close it.
425-
"""
426-
with path.open(encoding="utf-8") as f:
427-
return json.load(f)
428-
429-
430464
def _process_path(path: str) -> Iterable[Path]:
431465
"""
432466
Convert path pattern into path iterable.

Diff for: json_to_models/dynamic_typing/complex.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,10 @@ def to_typing_code(self, types_style: Dict[Union['BaseType', Type['BaseType']],
265265
if options.get(self.TypeStyle.use_literals):
266266
limit = options.get(self.TypeStyle.max_literals)
267267
if limit is None or len(self.literals) < limit:
268-
parts = ', '.join(f'"{s}"' for s in sorted(self.literals))
268+
parts = ', '.join(
269+
'"{}"'.format(s.replace('\\', '\\\\').replace('"', '\\"'))
270+
for s in sorted(self.literals)
271+
)
269272
return [(Literal.__module__, 'Literal')], f"Literal[{parts}]"
270273

271274
return [], 'str'

Diff for: json_to_models/generator.py

+5
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,11 @@ def _convert(self, data: dict):
5656
"""
5757
fields = dict()
5858
for key, value in data.items():
59+
if not isinstance(key, str):
60+
raise TypeError(f'You probably using some not JSON-compatible parser and have some {type(key)} as dict key. '
61+
f'This is not supported.\n'
62+
f'Context: {data}\n'
63+
f'(If you parsing yaml try to replace PyYaml with ruamel.yaml)')
5964
convert_dict = key not in self.dict_keys_fields
6065
fields[key] = self._detect_type(value, convert_dict)
6166
return fields

Diff for: json_to_models/models/base.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323

2424
keywords_set = set(keyword.kwlist)
2525
builtins_set = set(__builtins__.keys())
26-
other_common_names_set = {'datetime', 'time', 'date', 'defaultdict'}
26+
other_common_names_set = {'datetime', 'time', 'date', 'defaultdict', 'schema'}
2727
blacklist_words = frozenset(keywords_set | builtins_set | other_common_names_set)
2828
ones = ['', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine']
2929

Diff for: setup.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,6 @@ def run_tests(self):
5050
},
5151
install_requires=required,
5252
cmdclass={"test": PyTest},
53-
tests_require=["pytest>=4.4.0", "pytest-xdist", "requests", "attrs", "pydantic>=1.3"],
53+
tests_require=["pytest>=4.4.0", "pytest-xdist", "requests", "attrs", "pydantic>=1.3", "ruamel.yaml"],
5454
data_files=[('', ['requirements.txt', 'pytest.ini', '.coveragerc', 'LICENSE', 'README.md', 'CHANGELOG.md'])]
5555
)

Diff for: test/test_cli/data/file.ini

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
[owner]
2+
name = John Doe
3+
organization = Acme Widgets Inc.
4+
5+
[database]
6+
; use IP address in case network name resolution is not working
7+
server = 192.0.2.62
8+
port = 143
9+
file = "payroll.dat"

0 commit comments

Comments
 (0)