Skip to content

Commit e0a6143

Browse files
committed
Add cli commands tooling
1 parent bc55869 commit e0a6143

File tree

20 files changed

+1234
-1
lines changed

20 files changed

+1234
-1
lines changed

docs/source/guides/cli.rst

Lines changed: 409 additions & 0 deletions
Large diffs are not rendered by default.

docs/source/guides/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,5 @@ Guides
1111
testing_debugging
1212
create_own_components
1313
frontend_validation
14+
cli
1415
compile

flask_inputfilter/cli/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from .cli import main
2+
3+
__all__ = ["main"]

flask_inputfilter/cli/cli.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from __future__ import annotations
2+
3+
import click
4+
5+
6+
@click.group()
7+
def main() -> None:
8+
"""Flask InputFilter CLI."""
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from .json_schema import JsonSchemaCodegen
2+
from .mapper import TypeMapper
3+
from .renderer import TemplateRenderer
4+
5+
__all__ = ["JsonSchemaCodegen", "TemplateRenderer", "TypeMapper"]
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
from __future__ import annotations
2+
3+
import json
4+
from pathlib import Path
5+
from typing import Any, Dict, Union
6+
7+
import jsonschema
8+
9+
from .mapper import TypeMapper
10+
from .renderer import TemplateRenderer
11+
12+
13+
class JsonSchemaCodegen:
14+
"""JSON Schema to InputFilter code generator using jsonschema library."""
15+
16+
@staticmethod
17+
def validate_schema(schema: Dict[str, Any]) -> None:
18+
"""Validate the JSON schema using jsonschema library."""
19+
try:
20+
jsonschema.Draft202012Validator.check_schema(schema)
21+
except jsonschema.SchemaError as e:
22+
raise ValueError(f"Invalid JSON Schema: {e}")
23+
24+
@staticmethod
25+
def build_context(
26+
schema: Dict[str, Any],
27+
class_name: str,
28+
base_class: str = "InputFilter",
29+
base_module: str = "flask_inputfilter",
30+
import_field_from: Union[str, None] = None,
31+
field_name: str = "field",
32+
strict: bool = False,
33+
docstring: bool = True,
34+
) -> Dict[str, Any]:
35+
"""Build template context from JSON schema."""
36+
mapper = TypeMapper()
37+
38+
if strict:
39+
JsonSchemaCodegen.validate_schema(schema)
40+
41+
required_fields = set(schema.get("required", []))
42+
properties: Dict[str, Any] = schema.get("properties", {})
43+
44+
if not properties and strict:
45+
raise ValueError(
46+
"No properties found in schema and strict mode is enabled"
47+
)
48+
49+
fields = []
50+
for name, spec in properties.items():
51+
field_def = mapper.map_field(name, spec, required_fields)
52+
fields.append(field_def)
53+
54+
global_validators = mapper.get_global_validators(schema)
55+
imports = mapper.get_imports()
56+
57+
return {
58+
"base_module": base_module,
59+
"base_class": base_class,
60+
"class_name": class_name,
61+
"schema_title": schema.get("title", ""),
62+
"schema_description": schema.get("description", ""),
63+
"docstring": docstring,
64+
"import_field_from": import_field_from,
65+
"field_name": field_name,
66+
"import_filters": imports["filters"],
67+
"import_validators": imports["validators"],
68+
"import_enums": imports["enums"],
69+
"fields": fields,
70+
"global_validators": global_validators,
71+
}
72+
73+
@staticmethod
74+
def generate_from_schema(
75+
schema: Dict[str, Any],
76+
class_name: str,
77+
template_path: str,
78+
**kwargs,
79+
) -> str:
80+
"""Generate InputFilter code from JSON schema."""
81+
context = JsonSchemaCodegen.build_context(schema, class_name, **kwargs)
82+
return TemplateRenderer().render_inputfilter(template_path, context)
83+
84+
@staticmethod
85+
def generate_from_file(
86+
schema_path: Union[str, Path],
87+
class_name: str,
88+
template_path: str,
89+
**kwargs,
90+
) -> str:
91+
"""Generate InputFilter code from JSON schema file."""
92+
schema = json.loads(Path(schema_path).read_text(encoding="utf-8"))
93+
return JsonSchemaCodegen.generate_from_schema(
94+
schema, class_name, template_path, **kwargs
95+
)
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
from __future__ import annotations
2+
3+
import json
4+
from typing import Any, Dict, List, Set
5+
6+
7+
class TypeMapper:
8+
"""Enhanced mapping from JSON Schema types to Flask InputFilter
9+
components."""
10+
11+
FILTERS = {
12+
"string": ["StringTrimFilter"],
13+
"integer": [],
14+
"number": ["ToFloatFilter"],
15+
"boolean": [],
16+
}
17+
18+
FORMAT_VALIDATORS = {
19+
"email": "RegexValidator(RegexEnum.EMAIL.value, 'Invalid email format.')",
20+
"uri": "RegexValidator(RegexEnum.URL.value, 'Invalid URL format.')",
21+
"uuid": "IsUUIDValidator()",
22+
"date": "IsDateValidator()",
23+
"date-time": "IsDateTimeValidator()",
24+
"ipv4": "RegexValidator(RegexEnum.IPV4.value, 'Invalid IPv4 address.')",
25+
"ipv6": "RegexValidator(RegexEnum.IPV6.value, 'Invalid IPv6 address.')",
26+
}
27+
28+
TYPE_VALIDATORS = {
29+
"string": "IsStringValidator()",
30+
"integer": "IsIntegerValidator()",
31+
"number": "IsFloatValidator()",
32+
"boolean": "IsBooleanValidator()",
33+
"array": "IsArrayValidator()",
34+
}
35+
36+
def __init__(self) -> None:
37+
self.import_filters: Set[str] = set()
38+
self.import_validators: Set[str] = set()
39+
self.import_enums: Set[str] = set()
40+
41+
def map_field(
42+
self, attr: str, spec: Dict[str, Any], required_fields: Set[str]
43+
) -> dict:
44+
"""Map a single field from JSON schema to InputFilter field
45+
definition."""
46+
filters: List[str] = []
47+
validators: List[str] = []
48+
49+
field_type = spec.get("type")
50+
field_format = spec.get("format")
51+
52+
# Add type-specific filters
53+
for f in self.FILTERS.get(field_type, []):
54+
filters.append(f"{f}()")
55+
self.import_filters.add(f)
56+
57+
# Add format-specific validators
58+
if field_format and field_format in self.FORMAT_VALIDATORS:
59+
validator_def = self.FORMAT_VALIDATORS[field_format]
60+
validators.append(validator_def)
61+
62+
# Extract validator class names for imports
63+
if "RegexValidator" in validator_def:
64+
self.import_validators.add("RegexValidator")
65+
self.import_enums.add("RegexEnum")
66+
elif "IsUUIDValidator" in validator_def:
67+
self.import_validators.add("IsUUIDValidator")
68+
elif "IsDateValidator" in validator_def:
69+
self.import_validators.add("IsDateValidator")
70+
elif "IsDateTimeValidator" in validator_def:
71+
self.import_validators.add("IsDateTimeValidator")
72+
73+
# Add basic type validator if no format validator
74+
elif field_type in self.TYPE_VALIDATORS and field_type not in [
75+
"array"
76+
]:
77+
validators.append(self.TYPE_VALIDATORS[field_type])
78+
validator_name = self.TYPE_VALIDATORS[field_type].split("(")[0]
79+
self.import_validators.add(validator_name)
80+
81+
# Handle numeric ranges
82+
if field_type in ("integer", "number"):
83+
minimum = spec.get("minimum")
84+
maximum = spec.get("maximum")
85+
if minimum is not None or maximum is not None:
86+
args = []
87+
if minimum is not None:
88+
args.append(f"min_value={minimum}")
89+
if maximum is not None:
90+
args.append(f"max_value={maximum}")
91+
validators.append(f"RangeValidator({', '.join(args)})")
92+
self.import_validators.add("RangeValidator")
93+
94+
# Handle string constraints
95+
if field_type == "string":
96+
min_length = spec.get("minLength")
97+
max_length = spec.get("maxLength")
98+
if min_length is not None or max_length is not None:
99+
args = []
100+
if min_length is not None:
101+
args.append(f"min_length={min_length}")
102+
if max_length is not None:
103+
args.append(f"max_length={max_length}")
104+
validators.append(f"LengthValidator({', '.join(args)})")
105+
self.import_validators.add("LengthValidator")
106+
107+
# Handle pattern validation
108+
pattern = spec.get("pattern")
109+
if pattern:
110+
validators.append(
111+
f"RegexValidator(r'{pattern}', 'Pattern validation failed.')"
112+
)
113+
self.import_validators.add("RegexValidator")
114+
115+
# Handle enum validation
116+
enum_values = spec.get("enum")
117+
if enum_values:
118+
validators.append(f"InArrayValidator({json.dumps(enum_values)})")
119+
self.import_validators.add("InArrayValidator")
120+
121+
# Handle arrays
122+
if field_type == "array":
123+
validators.append("IsArrayValidator()")
124+
self.import_validators.add("IsArrayValidator")
125+
126+
# Add array length validation if specified
127+
min_items = spec.get("minItems")
128+
max_items = spec.get("maxItems")
129+
if min_items is not None or max_items is not None:
130+
args = []
131+
if min_items is not None:
132+
args.append(f"min_length={min_items}")
133+
if max_items is not None:
134+
args.append(f"max_length={max_items}")
135+
validators.append(f"ArrayLengthValidator({', '.join(args)})")
136+
self.import_validators.add("ArrayLengthValidator")
137+
138+
return {
139+
"attr": attr,
140+
"type": field_type,
141+
"required": attr in required_fields,
142+
"default": spec.get("default"),
143+
"description": spec.get("description", ""),
144+
"filters": filters,
145+
"validators": validators,
146+
}
147+
148+
def get_global_validators(self, schema: Dict[str, Any]) -> List[str]:
149+
"""Get global validators based on schema properties."""
150+
global_validators = []
151+
152+
if schema.get("additionalProperties") is False:
153+
gv = "CustomJsonValidator({'additionalProperties': False})"
154+
global_validators.append(gv)
155+
self.import_validators.add("CustomJsonValidator")
156+
157+
return global_validators
158+
159+
def get_imports(self) -> dict:
160+
"""Get all required imports based on used components."""
161+
return {
162+
"filters": sorted(self.import_filters),
163+
"validators": sorted(self.import_validators),
164+
"enums": sorted(self.import_enums),
165+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
from __future__ import annotations
2+
3+
import json
4+
from pathlib import Path
5+
from typing import Any, Dict, Union
6+
7+
from jinja2 import Environment, FileSystemLoader, StrictUndefined
8+
9+
10+
class TemplateRenderer:
11+
"""Template rendering engine for code generation."""
12+
13+
def __init__(self, template_dir: Union[str, Path] = None):
14+
if template_dir is None:
15+
template_dir = Path(__file__).parent.parent / "templates"
16+
17+
self.env = Environment(
18+
loader=FileSystemLoader(str(template_dir)),
19+
undefined=StrictUndefined,
20+
trim_blocks=True,
21+
lstrip_blocks=True,
22+
)
23+
24+
self.env.filters["python_json"] = TemplateRenderer._python_json_filter
25+
26+
@staticmethod
27+
def _python_json_filter(value):
28+
"""Convert value to Python-compatible representation."""
29+
if value is None:
30+
return "None"
31+
if value is True:
32+
return "True"
33+
if value is False:
34+
return "False"
35+
if isinstance(value, str):
36+
return json.dumps(value)
37+
if isinstance(value, (int, float)):
38+
return str(value)
39+
return json.dumps(value)
40+
41+
def render_inputfilter(
42+
self, template_path: str, context: Dict[str, Any]
43+
) -> str:
44+
"""Render an InputFilter class from template."""
45+
template = self.env.get_template(Path(template_path).name)
46+
return template.render(**context)
47+
48+
def render_test_file(self, context: Dict[str, Any]) -> str:
49+
"""Render a test file for the generated InputFilter."""
50+
template = self.env.get_template("test.py.j2")
51+
return template.render(**context)
52+
53+
def render_config_file(self, context: Dict[str, Any]) -> str:
54+
"""Render a configuration file template."""
55+
template = self.env.get_template("config.yaml.j2")
56+
return template.render(**context)
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from . import generate_inputfilter, help
2+
3+
__all__ = ["generate_inputfilter", "help"]

0 commit comments

Comments
 (0)