Skip to content

Commit 1dc5778

Browse files
committed
Add --allow-words option to CLI for removing blacklisted field names
1 parent da2733c commit 1dc5778

4 files changed

Lines changed: 164 additions & 37 deletions

File tree

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -573,6 +573,13 @@ Arguments:
573573
* **Example**: `--disable-str-serializable-types float int BooleanString IsoDatetimeString`
574574
* **Optional**
575575

576+
* `--allow-words` - List of words to remove from the keyword blacklist.
577+
By default, field names that clash with Python keywords or built-ins (e.g. `id`, `type`, `hash`, `format`) are
578+
renamed by appending `_` (e.g. `id_`). This option whitelists specific names so they are kept as-is.
579+
* **Format**: `--allow-words WORD [WORD ...]`
580+
* **Example**: `--allow-words id type` — generates `id: int` and `type: str` instead of `id_: int` and `type_: str`
581+
* **Optional**
582+
576583
### Low level API
577584

578585
\-

json_to_models/cli.py

Lines changed: 35 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,8 @@ def __init__(self):
7171
self.max_literals: int = -1 # --max-strings-literals
7272
self.merge_policy: List[ModelCmp] = [] # --merge
7373
self.structure_fn: STRUCTURE_FN_TYPE = None # -s
74-
self.model_generator: Type[GenericModelCodeGenerator] = None # -f & --code-generator
74+
# -f & --code-generator
75+
self.model_generator: Type[GenericModelCodeGenerator] = None
7576
self.model_generator_kwargs: Dict[str, Any] = None
7677

7778
self.argparser = self._create_argparser()
@@ -93,22 +94,25 @@ def parse_args(self, args: List[str] = None):
9394
disable_unicode_conversion = namespace.disable_unicode_conversion
9495
self.strings_converters = namespace.strings_converters
9596
self.max_literals = namespace.max_strings_literals
96-
merge_policy = [m.split("_") if "_" in m else m for m in namespace.merge]
97+
merge_policy = [
98+
m.split("_") if "_" in m else m for m in namespace.merge]
9799
structure = namespace.structure
98100
framework = namespace.framework
99101
code_generator = namespace.code_generator
100102
code_generator_kwargs_raw: List[str] = namespace.code_generator_kwargs
101103
dict_keys_regex: List[str] = namespace.dict_keys_regex
102104
dict_keys_fields: List[str] = namespace.dict_keys_fields
103105
preamble: str = namespace.preamble
106+
allow_words: List[str] = namespace.allow_words
104107

105108
for name in namespace.disable_str_serializable_types:
106109
registry.remove_by_name(name)
107110

108-
self.setup_models_data(namespace.model or (), namespace.list or (), parser)
111+
self.setup_models_data(namespace.model or (),
112+
namespace.list or (), parser)
109113
self.validate(merge_policy, framework, code_generator)
110114
self.set_args(merge_policy, structure, framework, code_generator, code_generator_kwargs_raw,
111-
dict_keys_regex, dict_keys_fields, disable_unicode_conversion, preamble)
115+
dict_keys_regex, dict_keys_fields, disable_unicode_conversion, preamble, allow_words)
112116

113117
def run(self):
114118
if self.enable_datetime:
@@ -158,14 +162,18 @@ def validate(self, merge_policy, framework, code_generator):
158162
for m in merge_policy:
159163
if isinstance(m, list):
160164
if m[0] not in self.MODEL_CMP_MAPPING:
161-
raise ValueError(f"Invalid merge policy '{m[0]}', choices are {self.MODEL_CMP_MAPPING.keys()}")
165+
raise ValueError(
166+
f"Invalid merge policy '{m[0]}', choices are {self.MODEL_CMP_MAPPING.keys()}")
162167
elif m not in self.MODEL_CMP_MAPPING:
163-
raise ValueError(f"Invalid merge policy '{m}', choices are {self.MODEL_CMP_MAPPING.keys()}")
168+
raise ValueError(
169+
f"Invalid merge policy '{m}', choices are {self.MODEL_CMP_MAPPING.keys()}")
164170

165171
if framework == 'custom' and code_generator is None:
166-
raise ValueError("You should specify --code-generator to support custom generator")
172+
raise ValueError(
173+
"You should specify --code-generator to support custom generator")
167174
elif framework != 'custom' and code_generator is not None:
168-
raise ValueError("--code-generator argument has no effect without '--framework custom' argument")
175+
raise ValueError(
176+
"--code-generator argument has no effect without '--framework custom' argument")
169177

170178
def setup_models_data(
171179
self,
@@ -189,7 +197,8 @@ def setup_models_data(
189197
elif len(model_tuple) == 3:
190198
model_name, lookup, path_raw = model_tuple
191199
else:
192-
raise RuntimeError('`--model` argument should contain exactly 2 or 3 strings')
200+
raise RuntimeError(
201+
'`--model` argument should contain exactly 2 or 3 strings')
193202

194203
for real_path in process_path(path_raw):
195204
iterator = iter_json_file(parser(real_path), lookup)
@@ -208,6 +217,7 @@ def set_args(
208217
dict_keys_fields: List[str],
209218
disable_unicode_conversion: bool,
210219
preamble: str,
220+
allow_words: List[str] = (),
211221
):
212222
"""
213223
Convert CLI args to python representation and set them to appropriate object attributes
@@ -234,7 +244,8 @@ def set_args(
234244
self.model_generator_kwargs = dict(
235245
post_init_converters=self.strings_converters,
236246
convert_unicode=not disable_unicode_conversion,
237-
max_literals=self.max_literals
247+
max_literals=self.max_literals,
248+
allow_words=allow_words,
238249
)
239250
if code_generator_kwargs_raw:
240251
for item in code_generator_kwargs_raw:
@@ -245,7 +256,8 @@ def set_args(
245256
name, value = item.split("=", 1)
246257
self.model_generator_kwargs[name] = value
247258

248-
self.dict_keys_regex = [re.compile(rf"^{r}$") for r in dict_keys_regex] if dict_keys_regex else ()
259+
self.dict_keys_regex = [re.compile(
260+
rf"^{r}$") for r in dict_keys_regex] if dict_keys_regex else ()
249261
self.dict_keys_fields = dict_keys_fields or ()
250262
if preamble:
251263
preamble = preamble.strip()
@@ -392,6 +404,14 @@ def _create_argparser(cls) -> argparse.ArgumentParser:
392404
nargs=3, action="append", metavar=("<Model name>", "<JSON lookup>", "<JSON file>"),
393405
help="DEPRECATED, use --model argument instead"
394406
)
407+
parser.add_argument(
408+
"--allow-words",
409+
metavar="WORD",
410+
default=[],
411+
nargs="+", type=str,
412+
help="List of words to remove from the keyword blacklist.\n"
413+
"Prevents appending '_' to these specific field names (e.g., id, type, hash, format).\n\n"
414+
)
395415

396416
return parser
397417

@@ -421,7 +441,8 @@ def json(path: Path) -> Union[dict, list]:
421441
@staticmethod
422442
def yaml(path: Path) -> Union[dict, list]:
423443
if yaml_load is None:
424-
print('Yaml parser is not installed. To parse yaml files ruamel.yaml (or PyYaml) is required.')
444+
print(
445+
'Yaml parser is not installed. To parse yaml files ruamel.yaml (or PyYaml) is required.')
425446
raise ImportError('yaml')
426447
with path.open() as fp:
427448
return yaml_load(fp)
@@ -467,7 +488,8 @@ def iter_json_file(data: Union[dict, list], lookup: str) -> Generator[Union[dict
467488
elif isinstance(item, dict):
468489
yield item
469490
else:
470-
raise TypeError(f'dict or list is expected at {lookup if lookup != "-" else "JSON root"}, not {type(item)}')
491+
raise TypeError(
492+
f'dict or list is expected at {lookup if lookup != "-" else "JSON root"}, not {type(item)}')
471493

472494

473495
def process_path(path: str) -> Iterable[Path]:

json_to_models/models/base.py

Lines changed: 33 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,10 @@
2424
keywords_set = set(keyword.kwlist)
2525
builtins_set = set(__builtins__.keys())
2626
other_common_names_set = {'datetime', 'time', 'date', 'defaultdict', 'schema'}
27-
blacklist_words = frozenset(keywords_set | builtins_set | other_common_names_set)
28-
ones = ['', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine']
27+
blacklist_words = frozenset(
28+
keywords_set | builtins_set | other_common_names_set)
29+
ones = ['', 'one', 'two', 'three', 'four',
30+
'five', 'six', 'seven', 'eight', 'nine']
2931

3032

3133
def template(pattern: str, indent: str = INDENT) -> Template:
@@ -73,7 +75,8 @@ class {{ name }}{% if bases %}({{ bases }}){% endif %}:
7375

7476
STR_CONVERT_DECORATOR = template("convert_strings({{ str_fields }}{%% if kwargs %%}, %s{%% endif %%})"
7577
% KWAGRS_TEMPLATE)
76-
FIELD: Template = template("{{name}}: {{type}}{% if body %} = {{ body }}{% endif %}")
78+
FIELD: Template = template(
79+
"{{name}}: {{type}}{% if body %} = {{ body }}{% endif %}")
7780
DEFAULT_MAX_LITERALS = 10
7881
default_types_style = {
7982
StringLiteral: {
@@ -87,29 +90,36 @@ def __init__(
8790
max_literals=DEFAULT_MAX_LITERALS,
8891
post_init_converters=False,
8992
convert_unicode=True,
90-
types_style: Dict[Union['BaseType', Type['BaseType']], dict] = None
93+
types_style: Dict[Union['BaseType',
94+
Type['BaseType']], dict] = None,
95+
allow_words: Iterable[str] = (),
9196
):
9297
self.model = model
9398
self.post_init_converters = post_init_converters
9499
self.convert_unicode = convert_unicode
100+
self.allow_words = frozenset(allow_words)
95101

96102
resolved_types_style = copy.deepcopy(self.default_types_style)
97103
types_style = types_style or {}
98104
for t, style in types_style.items():
99105
resolved_types_style.setdefault(t, {})
100106
resolved_types_style[t].update(style)
101-
resolved_types_style[StringLiteral][StringLiteral.TypeStyle.max_literals] = int(max_literals)
107+
resolved_types_style[StringLiteral][StringLiteral.TypeStyle.max_literals] = int(
108+
max_literals)
102109
self.types_style = resolved_types_style
103110

104-
self.model.set_raw_name(self.convert_class_name(self.model.name), generated=self.model.is_name_generated)
111+
self.model.set_raw_name(self.convert_class_name(
112+
self.model.name), generated=self.model.is_name_generated)
105113

106114
@cached_method
107115
def convert_class_name(self, name):
108-
return prepare_label(name, convert_unicode=self.convert_unicode, to_snake_case=False)
116+
return prepare_label(name, convert_unicode=self.convert_unicode, to_snake_case=False,
117+
allow_words=self.allow_words)
109118

110119
@cached_method
111120
def convert_field_name(self, name):
112-
return prepare_label(name, convert_unicode=self.convert_unicode, to_snake_case=True)
121+
return prepare_label(name, convert_unicode=self.convert_unicode, to_snake_case=True,
122+
allow_words=self.allow_words)
113123

114124
def generate(self, nested_classes: List[str] = None, bases: str = None, extra: str = "") \
115125
-> Tuple[ImportPathList, str]:
@@ -142,9 +152,11 @@ def decorators(self) -> Tuple[ImportPathList, List[str]]:
142152
if str_fields and decorator_kwargs:
143153
imports.extend([
144154
*decorator_imports,
145-
('json_to_models.models.string_converters', ['convert_strings']),
155+
('json_to_models.models.string_converters',
156+
['convert_strings']),
146157
])
147-
decorators.append(self.STR_CONVERT_DECORATOR.render(str_fields=str_fields, kwargs=decorator_kwargs))
158+
decorators.append(self.STR_CONVERT_DECORATOR.render(
159+
str_fields=str_fields, kwargs=decorator_kwargs))
148160
return imports, decorators
149161

150162
def field_data(self, name: str, meta: MetaData, optional: bool) -> Tuple[ImportPathList, dict]:
@@ -156,7 +168,8 @@ def field_data(self, name: str, meta: MetaData, optional: bool) -> Tuple[ImportP
156168
:param optional: Is field optional
157169
:return: imports, field data
158170
"""
159-
imports, typing = metadata_to_typing(meta, types_style=self.types_style)
171+
imports, typing = metadata_to_typing(
172+
meta, types_style=self.types_style)
160173

161174
data = {
162175
"name": self.convert_field_name(name),
@@ -171,13 +184,15 @@ def fields(self) -> Tuple[ImportPathList, List[str]]:
171184
172185
:return: imports, list of fields as string
173186
"""
174-
required, optional = sort_fields(self.model, unicode_fix=not self.convert_unicode)
187+
required, optional = sort_fields(
188+
self.model, unicode_fix=not self.convert_unicode)
175189
imports: ImportPathList = []
176190
strings: List[str] = []
177191
for is_optional, fields in enumerate((required, optional)):
178192
fields = self._filter_fields(fields)
179193
for field in fields:
180-
field_imports, data = self.field_data(field, self.model.type[field], bool(is_optional))
194+
field_imports, data = self.field_data(
195+
field, self.model.type[field], bool(is_optional))
181196
imports.extend(field_imports)
182197
strings.append(self.FIELD.render(**data))
183198
return imports, strings
@@ -256,7 +271,8 @@ def generate_code(structure: ModelsStructureType, class_generator: Type[GenericM
256271
"""
257272
root, mapping = structure
258273
with AbsoluteModelRef.inject(mapping):
259-
imports, classes = _generate_code(root, class_generator, class_generator_kwargs or {})
274+
imports, classes = _generate_code(
275+
root, class_generator, class_generator_kwargs or {})
260276
imports_str = ""
261277
if imports:
262278
imports_str = compile_imports(imports) + objects_delimiter
@@ -284,7 +300,8 @@ def sort_kwargs(kwargs: dict, ordering: Iterable[Iterable[str]]) -> dict:
284300
return sorted_dict
285301

286302

287-
def prepare_label(s: str, convert_unicode: bool, to_snake_case: bool) -> str:
303+
def prepare_label(s: str, convert_unicode: bool, to_snake_case: bool,
304+
allow_words: frozenset = frozenset()) -> str:
288305
if convert_unicode:
289306
s = unidecode(s)
290307
s = re.sub(r"\W", "", s)
@@ -293,6 +310,6 @@ def prepare_label(s: str, convert_unicode: bool, to_snake_case: bool) -> str:
293310
s = ones[int(s[0])] + "_" + s[1:]
294311
if to_snake_case:
295312
s = inflection.underscore(s)
296-
if s in blacklist_words:
313+
if s in (blacklist_words - allow_words):
297314
s += "_"
298315
return s

0 commit comments

Comments
 (0)