Skip to content

Commit 25fb3e0

Browse files
authored
Merge pull request #15 from bogdandm/flat_structure
Flat models structure generation
2 parents 099e16d + cddbf9d commit 25fb3e0

File tree

11 files changed

+493
-82
lines changed

11 files changed

+493
-82
lines changed

json_to_models/cli.py

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
import json_to_models
1313
from json_to_models.dynamic_typing import ModelMeta, register_datetime_classes
1414
from json_to_models.generator import MetadataGenerator
15-
from json_to_models.models import ModelsStructureType, compose_models
15+
from json_to_models.models import ModelsStructureType, compose_models, compose_models_flat
1616
from json_to_models.models.attr import AttrsModelCodeGenerator
1717
from json_to_models.models.base import GenericModelCodeGenerator, generate_code
1818
from json_to_models.models.dataclasses import DataclassModelCodeGenerator
@@ -34,8 +34,7 @@ class Cli:
3434

3535
STRUCTURE_FN_MAPPING: Dict[str, STRUCTURE_FN_TYPE] = {
3636
"nested": compose_models,
37-
# TODO: vvvvvvvvvvvv
38-
"flat": lambda *args, **kwargs: None
37+
"flat": compose_models_flat
3938
}
4039

4140
MODEL_GENERATOR_MAPPING: Dict[str, Type[GenericModelCodeGenerator]] = {
@@ -187,8 +186,8 @@ def set_args(self, merge_policy: List[Union[List[str], str]],
187186

188187
self.initialize = True
189188

190-
@staticmethod
191-
def _create_argparser() -> argparse.ArgumentParser:
189+
@classmethod
190+
def _create_argparser(cls) -> argparse.ArgumentParser:
192191
"""
193192
ArgParser factory
194193
"""
@@ -255,13 +254,13 @@ def _create_argparser() -> argparse.ArgumentParser:
255254
parser.add_argument(
256255
"-s", "--structure",
257256
default="nested",
258-
choices=["nested", "flat"],
257+
choices=list(cls.STRUCTURE_FN_MAPPING.keys()),
259258
help="Models composition style. By default nested models become nested Python classes.\n\n"
260259
)
261260
parser.add_argument(
262261
"-f", "--framework",
263262
default="base",
264-
choices=["base", "attrs", "dataclasses", "custom"],
263+
choices=list(cls.MODEL_GENERATOR_MAPPING.keys()) + ["custom"],
265264
help="Model framework for which python code is generated.\n"
266265
"'base' (default) mean no framework so code will be generated without any decorators\n"
267266
"and additional meta-data.\n"

json_to_models/dynamic_typing/models_meta.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ def __init__(self, t: MetaData, index, _original_fields=None):
2323
def __str__(self):
2424
return f"Model#{self.index}" + ("-" + self._name if self._name else "")
2525

26+
def __repr__(self):
27+
return f"<{self}>"
28+
2629
def __eq__(self, other):
2730
if isinstance(other, dict):
2831
return self.type == other

json_to_models/generator.py

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
StringSerializable, StringSerializableRegistry, Unknown, registry)
1010

1111
keywords_set = set(keyword.kwlist)
12-
12+
_static_types = {float, bool, int}
1313

1414
class MetadataGenerator:
1515
CONVERTER_TYPE = Optional[Callable[[str], Any]]
@@ -61,15 +61,12 @@ def _detect_type(self, value, convert_dict=True) -> MetaData:
6161
Converts json value to metadata
6262
"""
6363
# Simple types
64-
if isinstance(value, float):
65-
return float
66-
elif isinstance(value, bool):
67-
return bool
68-
elif isinstance(value, int):
69-
return int
64+
t = type(value)
65+
if t in _static_types:
66+
return t
7067

7168
# List trying to yield nested type
72-
elif isinstance(value, list):
69+
elif t is list:
7370
if value:
7471
types = [self._detect_type(item) for item in value]
7572
if len(types) > 1:

json_to_models/models/__init__.py

Lines changed: 110 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1-
from typing import Dict, Generic, Iterable, List, Set, Tuple, TypeVar
1+
from collections import defaultdict
2+
from typing import Dict, Generic, Iterable, List, Set, Tuple, TypeVar, Union
23

34
from ..dynamic_typing import DOptional, ModelMeta, ModelPtr
45

56
Index = str
7+
ModelsStructureType = Tuple[List[dict], Dict[ModelMeta, ModelMeta]]
8+
69
T = TypeVar('T')
710

811

@@ -26,40 +29,44 @@ def insert_before(self, value: T, *before: T):
2629
raise ValueError
2730
pos = min(ix)
2831
self.insert(pos, value)
32+
return pos
2933

3034
def insert_after(self, value: T, *after: T):
3135
ix = self._safe_indexes(*after)
3236
if not ix:
3337
raise ValueError
34-
pos = max(ix)
35-
self.insert(pos + 1, value)
36-
38+
pos = max(ix) + 1
39+
self.insert(pos, value)
40+
return pos
3741

38-
def filter_pointers(model: ModelMeta) -> Iterable[ModelPtr]:
39-
"""
40-
Return iterator over pointers with not None parent
41-
"""
42-
return (ptr for ptr in model.pointers if ptr.parent)
4342

43+
class PositionsDict(defaultdict):
44+
# Dict contains mapping Index -> position, where position is list index to insert nested element of Index
45+
INC = object()
4446

45-
def extract_root(model: ModelMeta) -> Set[Index]:
46-
"""
47-
Return set of indexes of root models that are use given ``model`` directly or through another nested model.
48-
"""
49-
seen: Set[Index] = set()
50-
nodes: List[ModelPtr] = list(filter_pointers(model))
51-
roots: Set[Index] = set()
52-
while nodes:
53-
node = nodes.pop()
54-
seen.add(node.type.index)
55-
filtered = list(filter_pointers(node.parent))
56-
nodes.extend(ptr for ptr in filtered if ptr.type.index not in seen)
57-
if not filtered:
58-
roots.add(node.parent.index)
59-
return roots
47+
def __init__(self, default_factory=int, **kwargs):
48+
super().__init__(default_factory, **kwargs)
6049

50+
def update_position(self, key: str, value: Union[object, int]):
51+
"""
52+
Shift all elements which are placed after updated one
6153
62-
ModelsStructureType = Tuple[List[dict], Dict[ModelMeta, ModelMeta]]
54+
:param key: Index or "root"
55+
:param value: Could be position or PositionsDict.INC to perform quick increment (x+=1)
56+
:return:
57+
"""
58+
if value is self.INC:
59+
value = self[key] + 1
60+
if key in self:
61+
old_value = self[key]
62+
delta = value - old_value
63+
else:
64+
old_value = value
65+
delta = 1
66+
for k, v in self.items():
67+
if k != key and v >= old_value:
68+
self[k] += delta
69+
self[key] = value
6370

6471

6572
def compose_models(models_map: Dict[str, ModelMeta]) -> ModelsStructureType:
@@ -116,6 +123,84 @@ def compose_models(models_map: Dict[str, ModelMeta]) -> ModelsStructureType:
116123
return root_models, path_injections
117124

118125

126+
def compose_models_flat(models_map: Dict[Index, ModelMeta]) -> ModelsStructureType:
127+
"""
128+
Generate flat sorted (by nesting level, ASC) models structure for internal usage.
129+
130+
:param models_map: Mapping (model index -> model meta instance).
131+
:return: List of root models data, Map(child model -> root model) for absolute ref generation
132+
"""
133+
root_models = ListEx()
134+
positions: PositionsDict[Index, int] = PositionsDict()
135+
top_level_models: Set[Index] = set()
136+
structure_hash_table: Dict[Index, dict] = {
137+
key: {
138+
"model": model,
139+
"nested": ListEx(),
140+
"roots": list(extract_root(model)), # Indexes of root level models
141+
} for key, model in models_map.items()
142+
}
143+
144+
for key, model in models_map.items():
145+
pointers = list(filter_pointers(model))
146+
has_root_pointers = len(pointers) != len(model.pointers)
147+
if not pointers:
148+
# Root level model
149+
if not has_root_pointers:
150+
raise Exception(f'Model {model.name} has no pointers')
151+
root_models.insert(positions["root"], structure_hash_table[key])
152+
top_level_models.add(key)
153+
positions.update_position("root", PositionsDict.INC)
154+
else:
155+
parents = {ptr.parent.index for ptr in pointers}
156+
struct = structure_hash_table[key]
157+
# Model is using by other models
158+
if has_root_pointers or len(parents) > 1 and len(struct["roots"]) >= 1:
159+
# Model is using by different root models
160+
if parents & top_level_models:
161+
parents.add("root")
162+
parents_positions = {positions[parent_key] for parent_key in parents
163+
if parent_key in positions}
164+
parents_joined = "#".join(sorted(parents))
165+
if parents_joined in positions:
166+
parents_positions.add(positions[parents_joined])
167+
pos = max(parents_positions) if parents_positions else len(root_models)
168+
positions.update_position(parents_joined, pos + 1)
169+
else:
170+
# Model is using by only one model
171+
parent = next(iter(parents))
172+
pos = positions.get(parent, len(root_models))
173+
positions.update_position(parent, pos + 1)
174+
positions.update_position(key, pos + 1)
175+
root_models.insert(pos, struct)
176+
177+
return root_models, {}
178+
179+
180+
def filter_pointers(model: ModelMeta) -> Iterable[ModelPtr]:
181+
"""
182+
Return iterator over pointers with not None parent
183+
"""
184+
return (ptr for ptr in model.pointers if ptr.parent)
185+
186+
187+
def extract_root(model: ModelMeta) -> Set[Index]:
188+
"""
189+
Return set of indexes of root models that are use given ``model`` directly or through another nested model.
190+
"""
191+
seen: Set[Index] = set()
192+
nodes: List[ModelPtr] = list(filter_pointers(model))
193+
roots: Set[Index] = set()
194+
while nodes:
195+
node = nodes.pop()
196+
seen.add(node.type.index)
197+
filtered = list(filter_pointers(node.parent))
198+
nodes.extend(ptr for ptr in filtered if ptr.type.index not in seen)
199+
if not filtered:
200+
roots.add(node.parent.index)
201+
return roots
202+
203+
119204
def sort_fields(model_meta: ModelMeta) -> Tuple[List[str], List[str]]:
120205
"""
121206
Split fields into required and optional groups

json_to_models/registry.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
from itertools import chain, combinations
33
from typing import Dict, List, Set, Tuple
44

5+
from ordered_set import OrderedSet
6+
57
from .dynamic_typing import BaseType, MetaData, ModelMeta, ModelPtr
68
from .utils import Index, distinct_words
79

@@ -151,7 +153,7 @@ def merge_models(self, generator, strict=False) -> List[Tuple[ModelMeta, Set[Mod
151153
flag = True
152154
while flag:
153155
flag = False
154-
new_groups: Set[Set[ModelMeta]] = set()
156+
new_groups: OrderedSet[Set[ModelMeta]] = OrderedSet()
155157
for gr1, gr2 in combinations(groups, 2):
156158
if gr1 & gr2:
157159
old_len = len(new_groups)

requirements.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
python-dateutil>=2.7.*
22
inflection>=0.3.*
33
unidecode>=1.0.*
4-
Jinja2>=2.10.*
4+
Jinja2>=2.10.*
5+
ordered-set==3.*

setup.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import multiprocessing
12
import sys
23

34
from setuptools import find_packages, setup
@@ -9,6 +10,8 @@
910
required = f.read().splitlines()
1011
URL = "https://github.com/bogdandm/json2python-models"
1112

13+
CPU_N = multiprocessing.cpu_count()
14+
1215

1316
class PyTest(TestCommand):
1417
user_options = [("pytest-args=", "a", "Arguments to pass to pytest")]
@@ -20,8 +23,10 @@ def initialize_options(self):
2023
def run_tests(self):
2124
import shlex
2225
import pytest
23-
24-
errno = pytest.main(shlex.split(self.pytest_args + ' -m "not slow_http"'))
26+
args = self.pytest_args
27+
if CPU_N > 1 and "-n " not in args:
28+
args += f" -n {CPU_N}"
29+
errno = pytest.main(shlex.split(args))
2530
sys.exit(errno)
2631

2732

@@ -31,6 +36,7 @@ def run_tests(self):
3136
python_requires=">=3.7",
3237
url=URL,
3338
author="bogdandm (Bogdan Kalashnikov)",
39+
author_email="bogdan.dm1995@yandex.ru",
3440
description="Python models (attrs, dataclasses or custom) generator from JSON data with typing module support",
3541
license="MIT",
3642
packages=find_packages(exclude=['test', 'testing_tools']),
@@ -39,9 +45,6 @@ def run_tests(self):
3945
},
4046
install_requires=required,
4147
cmdclass={"test": PyTest},
42-
tests_require=["pytest", "requests", "attrs"],
43-
project_urls={
44-
'Source': URL
45-
},
48+
tests_require=["pytest", "pytest-xdist", "requests", "attrs"],
4649
data_files=[('', ['pytest.ini', '.coveragerc', 'LICENSE'])]
4750
)

test/test_cli/test_script.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,14 @@ def test_script(command):
9292
print(stdout)
9393

9494

95+
@pytest.mark.parametrize("command", test_commands)
96+
def test_script_flat(command):
97+
command += " -s flat"
98+
proc = subprocess.Popen(command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
99+
stdout, stderr = _validate_result(proc)
100+
print(stdout)
101+
102+
95103
@pytest.mark.parametrize("command", test_commands)
96104
def test_script_attrs(command):
97105
command += " -f attrs"

0 commit comments

Comments
 (0)