Skip to content

Commit

Permalink
Merge pull request #21 from bogdandm/post_init_converters_in_dataclasses
Browse files Browse the repository at this point in the history
Post init converters for string types
  • Loading branch information
bogdandm committed Apr 24, 2019
2 parents 8056c3d + aca90a8 commit fd7927a
Show file tree
Hide file tree
Showing 23 changed files with 825 additions and 337 deletions.
6 changes: 4 additions & 2 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@
- [X] typing code generation
- [ ] (Maybe in future) Extract to another module (by serializers for each dynamic typing class)
- [X] attrs
- complex StringSerializable converters (based on dataclass post_init converter)
- [x] post_init converters for StringSerializable types
- [X] dataclasses
- [ ] post_init converters for StringSerializable types
- [x] post_init converters for StringSerializable types
- [ ] generate from_json/to_json converters
- [ ] Model class -> Meta format converter
- [ ] attrs
Expand All @@ -23,7 +25,7 @@
- [ ] dataclasses
- [ ] Decorator to mark class as exclude from models merge
- Other features
- [ ] Nesting models generation
- [X] Nesting models generation
- [X] Cascade (default)
- [X] Flat
- [ ] OptionalFieldsPolicy
Expand Down
62 changes: 35 additions & 27 deletions json_to_models/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@
import json_to_models
from json_to_models.dynamic_typing import ModelMeta, register_datetime_classes
from json_to_models.generator import MetadataGenerator
from json_to_models.models import ModelsStructureType, compose_models, compose_models_flat
from json_to_models.models import ModelsStructureType
from json_to_models.models.attr import AttrsModelCodeGenerator
from json_to_models.models.base import GenericModelCodeGenerator, generate_code
from json_to_models.models.dataclasses import DataclassModelCodeGenerator
from json_to_models.models.structure import compose_models, compose_models_flat
from json_to_models.registry import (
ModelCmp, ModelFieldsEquals, ModelFieldsNumberMatch, ModelFieldsPercentMatch, ModelRegistry
)
Expand Down Expand Up @@ -44,9 +45,10 @@ class Cli:
}

def __init__(self):
self.initialize = False
self.initialized = False
self.models_data: Dict[str, Iterable[dict]] = {} # -m/-l
self.enable_datetime: bool = False # --datetime
self.strings_converters: bool = False # --strings-converters
self.merge_policy: List[ModelCmp] = [] # --merge
self.structure_fn: STRUCTURE_FN_TYPE = None # -s
self.model_generator: Type[GenericModelCodeGenerator] = None # -f & --code-generator
Expand Down Expand Up @@ -74,6 +76,7 @@ def parse_args(self, args: List[str] = None):
for model_name, lookup, path in namespace.list or ()
]
self.enable_datetime = namespace.datetime
self.strings_converters = namespace.strings_converters
merge_policy = [m.split("_") if "_" in m else m for m in namespace.merge]
structure = namespace.structure
framework = namespace.framework
Expand Down Expand Up @@ -171,7 +174,7 @@ def set_args(self, merge_policy: List[Union[List[str], str]],
m = importlib.import_module(module)
self.model_generator = getattr(m, cls)

self.model_generator_kwargs = {}
self.model_generator_kwargs = {} if not self.strings_converters else {'post_init_converters': True}
if code_generator_kwargs_raw:
for item in code_generator_kwargs_raw:
if item[0] == '"':
Expand All @@ -184,7 +187,7 @@ def set_args(self, merge_policy: List[Union[List[str], str]],
self.dict_keys_regex = [re.compile(rf"^{r}$") for r in dict_keys_regex] if dict_keys_regex else ()
self.dict_keys_fields = dict_keys_fields or ()

self.initialize = True
self.initialized = True

@classmethod
def _create_argparser(cls) -> argparse.ArgumentParser:
Expand Down Expand Up @@ -213,6 +216,21 @@ def _create_argparser(cls) -> argparse.ArgumentParser:
"I.e. for file that contains dict {\"a\": {\"b\": [model_data, ...]}} you should\n"
"pass 'a.b' as <JSON key>.\n\n"
)
parser.add_argument(
"-f", "--framework",
default="base",
choices=list(cls.MODEL_GENERATOR_MAPPING.keys()) + ["custom"],
help="Model framework for which python code is generated.\n"
"'base' (default) mean no framework so code will be generated without any decorators\n"
"and additional meta-data.\n"
"If you pass 'custom' you should specify --code-generator argument\n\n"
)
parser.add_argument(
"-s", "--structure",
default="nested",
choices=list(cls.STRUCTURE_FN_MAPPING.keys()),
help="Models composition style. By default nested models become nested Python classes.\n\n"
)
parser.add_argument(
"--datetime",
action="store_true",
Expand All @@ -221,18 +239,9 @@ def _create_argparser(cls) -> argparse.ArgumentParser:
" Be sure that you really need this option.\n\n"
)
parser.add_argument(
"--dict-keys-regex", "--dkr",
nargs="+", metavar="RegEx",
help="List of regular expressions (Python syntax).\n"
"If all keys of some dict are match one of them\n"
"then this dict will be marked as dict field but not nested model.\n"
"Note: ^ and $ tokens will be added automatically but you have to\n"
"escape other special characters manually.\n"
)
parser.add_argument(
"--dict-keys-fields", "--dkf",
nargs="+", metavar="FIELD NAME",
help="List of model fields names that will be marked as dict fields\n\n"
"--strings-converters",
action="store_true",
help="Enable generation of string types converters (i.e. IsoDatetimeString or BooleanString).\n\n"
)

default_percent = f"{ModelFieldsPercentMatch.DEFAULT * 100:.0f}"
Expand All @@ -252,19 +261,18 @@ def _create_argparser(cls) -> argparse.ArgumentParser:
"'exact' - two models should have exact same field names to merge.\n\n"
)
parser.add_argument(
"-s", "--structure",
default="nested",
choices=list(cls.STRUCTURE_FN_MAPPING.keys()),
help="Models composition style. By default nested models become nested Python classes.\n\n"
"--dict-keys-regex", "--dkr",
nargs="+", metavar="RegEx",
help="List of regular expressions (Python syntax).\n"
"If all keys of some dict are match one of them\n"
"then this dict will be marked as dict field but not nested model.\n"
"Note: ^ and $ tokens will be added automatically but you have to\n"
"escape other special characters manually.\n"
)
parser.add_argument(
"-f", "--framework",
default="base",
choices=list(cls.MODEL_GENERATOR_MAPPING.keys()) + ["custom"],
help="Model framework for which python code is generated.\n"
"'base' (default) mean no framework so code will be generated without any decorators\n"
"and additional meta-data.\n"
"If you pass 'custom' you should specify --code-generator argument\n\n"
"--dict-keys-fields", "--dkf",
nargs="+", metavar="FIELD NAME",
help="List of model fields names that will be marked as dict fields\n\n"
)
parser.add_argument(
"--code-generator",
Expand Down
3 changes: 3 additions & 0 deletions json_to_models/dynamic_typing/string_serializable.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ def to_typing_code(cls) -> Tuple[ImportPathList, str]:
cls_name = cls.__name__
return [('json_to_models.dynamic_typing', cls_name)], cls_name

def __iter__(self):
return iter(())


T_StringSerializable = Type[StringSerializable]

Expand Down
226 changes: 6 additions & 220 deletions json_to_models/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,229 +1,15 @@
from collections import defaultdict
from typing import Dict, Generic, Iterable, List, Set, Tuple, TypeVar, Union
from enum import Enum
from typing import Dict, List, Tuple

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

Index = str
ModelsStructureType = Tuple[List[dict], Dict[ModelMeta, ModelMeta]]

T = TypeVar('T')


class ListEx(list, Generic[T]):
"""
Extended list with shortcut methods
"""

def safe_index(self, value: T):
try:
return self.index(value)
except ValueError:
return None

def _safe_indexes(self, *values: T):
return [i for i in map(self.safe_index, values) if i is not None]

def insert_before(self, value: T, *before: T):
ix = self._safe_indexes(*before)
if not ix:
raise ValueError
pos = min(ix)
self.insert(pos, value)
return pos

def insert_after(self, value: T, *after: T):
ix = self._safe_indexes(*after)
if not ix:
raise ValueError
pos = max(ix) + 1
self.insert(pos, value)
return pos


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

def __init__(self, default_factory=int, **kwargs):
super().__init__(default_factory, **kwargs)

def update_position(self, key: str, value: Union[object, int]):
"""
Shift all elements which are placed after updated one
:param key: Index or "root"
:param value: Could be position or PositionsDict.INC to perform quick increment (x+=1)
:return:
"""
if value is self.INC:
value = self[key] + 1
if key in self:
old_value = self[key]
delta = value - old_value
else:
old_value = value
delta = 1
for k, v in self.items():
if k != key and v >= old_value:
self[k] += delta
self[key] = value


def compose_models(models_map: Dict[str, ModelMeta]) -> ModelsStructureType:
"""
Generate nested sorted models structure for internal usage.
:return: List of root models data, Map(child model -> root model) for absolute ref generation
"""
root_models = ListEx()
root_nested_ix = 0
structure_hash_table: Dict[Index, dict] = {
key: {
"model": model,
"nested": ListEx(),
"roots": list(extract_root(model)), # Indexes of root level models
} for key, model in models_map.items()
}
# TODO: Test path_injections
path_injections: Dict[ModelMeta, ModelMeta] = {}

for key, model in models_map.items():
pointers = list(filter_pointers(model))
has_root_pointers = len(pointers) != len(model.pointers)
if not pointers:
# Root level model
if not has_root_pointers:
raise Exception(f'Model {model.name} has no pointers')
root_models.append(structure_hash_table[key])
else:
parents = {ptr.parent.index for ptr in pointers}
struct = structure_hash_table[key]
# Model is using by other models
if has_root_pointers or len(parents) > 1 and len(struct["roots"]) > 1:
# Model is using by different root models
try:
root_models.insert_before(
struct,
*(structure_hash_table[parent_key] for parent_key in struct["roots"])
)
except ValueError:
root_models.insert(root_nested_ix, struct)
root_nested_ix += 1
elif len(parents) > 1 and len(struct["roots"]) == 1:
# Model is using by single root model
parent = structure_hash_table[struct["roots"][0]]
parent["nested"].insert(0, struct)
path_injections[struct["model"]] = parent["model"]
else:
# Model is using by only one model
parent = structure_hash_table[next(iter(parents))]
struct = structure_hash_table[key]
parent["nested"].append(struct)

return root_models, path_injections


def compose_models_flat(models_map: Dict[Index, ModelMeta]) -> ModelsStructureType:
"""
Generate flat sorted (by nesting level, ASC) models structure for internal usage.
:param models_map: Mapping (model index -> model meta instance).
:return: List of root models data, Map(child model -> root model) for absolute ref generation
"""
root_models = ListEx()
positions: PositionsDict[Index, int] = PositionsDict()
top_level_models: Set[Index] = set()
structure_hash_table: Dict[Index, dict] = {
key: {
"model": model,
"nested": ListEx(),
"roots": list(extract_root(model)), # Indexes of root level models
} for key, model in models_map.items()
}

for key, model in models_map.items():
pointers = list(filter_pointers(model))
has_root_pointers = len(pointers) != len(model.pointers)
if not pointers:
# Root level model
if not has_root_pointers:
raise Exception(f'Model {model.name} has no pointers')
root_models.insert(positions["root"], structure_hash_table[key])
top_level_models.add(key)
positions.update_position("root", PositionsDict.INC)
else:
parents = {ptr.parent.index for ptr in pointers}
struct = structure_hash_table[key]
# Model is using by other models
if has_root_pointers or len(parents) > 1 and len(struct["roots"]) >= 1:
# Model is using by different root models
if parents & top_level_models:
parents.add("root")
parents_positions = {positions[parent_key] for parent_key in parents
if parent_key in positions}
parents_joined = "#".join(sorted(parents))
if parents_joined in positions:
parents_positions.add(positions[parents_joined])
pos = max(parents_positions) if parents_positions else len(root_models)
positions.update_position(parents_joined, pos + 1)
else:
# Model is using by only one model
parent = next(iter(parents))
pos = positions.get(parent, len(root_models))
positions.update_position(parent, pos + 1)
positions.update_position(key, pos + 1)
root_models.insert(pos, struct)

return root_models, {}


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


def extract_root(model: ModelMeta) -> Set[Index]:
"""
Return set of indexes of root models that are use given ``model`` directly or through another nested model.
"""
seen: Set[Index] = set()
nodes: List[ModelPtr] = list(filter_pointers(model))
roots: Set[Index] = set()
while nodes:
node = nodes.pop()
seen.add(node.type.index)
filtered = list(filter_pointers(node.parent))
nodes.extend(ptr for ptr in filtered if ptr.type.index not in seen)
if not filtered:
roots.add(node.parent.index)
return roots


def sort_fields(model_meta: ModelMeta) -> Tuple[List[str], List[str]]:
"""
Split fields into required and optional groups
:return: two list of fields names: required fields, optional fields
"""
fields = model_meta.type
required = []
optional = []
for key, meta in fields.items():
if isinstance(meta, DOptional):
optional.append(key)
else:
required.append(key)
return required, optional


INDENT = " " * 4
OBJECTS_DELIMITER = "\n" * 3 # 2 blank lines


def indent(string: str, lvl: int = 1, indent: str = INDENT) -> str:
"""
Indent all lines of string by ``indent * lvl``
"""
return "\n".join(indent * lvl + line for line in string.split("\n"))
class ClassType(Enum):
Dataclass = "dataclass"
Attrs = "attrs"
Loading

0 comments on commit fd7927a

Please sign in to comment.