ts-backend-check 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ts_backend_check/__init__.py +7 -0
- ts_backend_check/checker.py +185 -0
- ts_backend_check/cli/__init__.py +8 -0
- ts_backend_check/cli/main.py +58 -0
- ts_backend_check/django_parser.py +113 -0
- ts_backend_check/typescript_parser.py +105 -0
- ts_backend_check/utils.py +22 -0
- ts_backend_check-1.0.0.data/data/requirements.txt +6 -0
- ts_backend_check-1.0.0.dist-info/METADATA +192 -0
- ts_backend_check-1.0.0.dist-info/RECORD +14 -0
- ts_backend_check-1.0.0.dist-info/WHEEL +5 -0
- ts_backend_check-1.0.0.dist-info/entry_points.txt +2 -0
- ts_backend_check-1.0.0.dist-info/licenses/LICENSE.txt +674 -0
- ts_backend_check-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""
|
|
3
|
+
Main module for checking Django models against TypeScript types.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import Dict, List, Set
|
|
7
|
+
|
|
8
|
+
from .django_parser import extract_model_fields
|
|
9
|
+
from .typescript_parser import TypeScriptParser
|
|
10
|
+
from .utils import snake_to_camel
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TypeChecker:
|
|
14
|
+
"""
|
|
15
|
+
Main class for checking Django models against TypeScript types.
|
|
16
|
+
|
|
17
|
+
Parameters
|
|
18
|
+
----------
|
|
19
|
+
models_file : str
|
|
20
|
+
The file path for the models file to check.
|
|
21
|
+
|
|
22
|
+
types_file : str
|
|
23
|
+
The file path for the TypeScript file to check.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, models_file: str, types_file: str) -> None:
|
|
27
|
+
self.models_file = models_file
|
|
28
|
+
self.types_file = types_file
|
|
29
|
+
self.model_fields = extract_model_fields(models_file)
|
|
30
|
+
self.ts_parser = TypeScriptParser(types_file)
|
|
31
|
+
self.ts_interfaces = self.ts_parser.parse_interfaces()
|
|
32
|
+
self.backend_only = self.ts_parser.get_backend_only_fields()
|
|
33
|
+
|
|
34
|
+
def check(self) -> List[str]:
|
|
35
|
+
"""
|
|
36
|
+
Check models against TypeScript types.
|
|
37
|
+
|
|
38
|
+
Returns
|
|
39
|
+
-------
|
|
40
|
+
list
|
|
41
|
+
A list of fields missing from the TypeScript file.
|
|
42
|
+
"""
|
|
43
|
+
missing_fields = []
|
|
44
|
+
|
|
45
|
+
for model_name, fields in self.model_fields.items():
|
|
46
|
+
interfaces = self._find_matching_interfaces(model_name)
|
|
47
|
+
|
|
48
|
+
if not interfaces:
|
|
49
|
+
missing_fields.append(
|
|
50
|
+
self._format_missing_interface_message(model_name)
|
|
51
|
+
)
|
|
52
|
+
continue
|
|
53
|
+
|
|
54
|
+
missing_fields.extend(
|
|
55
|
+
self._format_missing_field_message(field, model_name, interfaces)
|
|
56
|
+
for field in fields
|
|
57
|
+
if not self._is_field_accounted_for(field, interfaces)
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
return missing_fields
|
|
61
|
+
|
|
62
|
+
def _find_matching_interfaces(self, model_name: str) -> Dict[str, Set[str]]:
|
|
63
|
+
"""
|
|
64
|
+
Find matching TypeScript interfaces for a model.
|
|
65
|
+
|
|
66
|
+
Parameters
|
|
67
|
+
----------
|
|
68
|
+
model_name : str
|
|
69
|
+
The name of the model to check the frontend TypeScript file for.
|
|
70
|
+
|
|
71
|
+
Returns
|
|
72
|
+
-------
|
|
73
|
+
Dict[str, Set[str]]
|
|
74
|
+
Interfaces that match a model name.
|
|
75
|
+
"""
|
|
76
|
+
potential_names = self._generate_potential_names(model_name)
|
|
77
|
+
return {
|
|
78
|
+
name: interface.fields
|
|
79
|
+
for name, interface in self.ts_interfaces.items()
|
|
80
|
+
if any(potential == name for potential in potential_names)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
@staticmethod
|
|
84
|
+
def _generate_potential_names(model_name: str) -> List[str]:
|
|
85
|
+
"""
|
|
86
|
+
Generate potential TypeScript interface names for a model.
|
|
87
|
+
|
|
88
|
+
Parameters
|
|
89
|
+
----------
|
|
90
|
+
model_name : str
|
|
91
|
+
The name of the model to check the frontend TypeScript file for.
|
|
92
|
+
|
|
93
|
+
Returns
|
|
94
|
+
-------
|
|
95
|
+
List[str]
|
|
96
|
+
Possible names for the model to check for.
|
|
97
|
+
"""
|
|
98
|
+
base_names = [
|
|
99
|
+
model_name,
|
|
100
|
+
model_name.replace("Model", ""),
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
if "_" in model_name:
|
|
104
|
+
base_names.append(snake_to_camel(input_str=model_name))
|
|
105
|
+
|
|
106
|
+
suffixes = ["", "Base", "Response", "Type"]
|
|
107
|
+
return [f"{base}{suffix}" for base in base_names for suffix in suffixes]
|
|
108
|
+
|
|
109
|
+
def _is_field_accounted_for(
|
|
110
|
+
self, field: str, interfaces: Dict[str, Set[str]]
|
|
111
|
+
) -> bool:
|
|
112
|
+
"""
|
|
113
|
+
Check if a field is accounted for in TypeScript.
|
|
114
|
+
|
|
115
|
+
Parameters
|
|
116
|
+
----------
|
|
117
|
+
field : str
|
|
118
|
+
The field that should be used in the frontend TypeScript file.
|
|
119
|
+
|
|
120
|
+
interfaces : Dict[str, Set[str]]
|
|
121
|
+
The interfaces from the frontend TypeScript file.
|
|
122
|
+
|
|
123
|
+
Returns
|
|
124
|
+
-------
|
|
125
|
+
Bool
|
|
126
|
+
Whether the field is accounted for in the frontend TypeScript file.
|
|
127
|
+
"""
|
|
128
|
+
camel_field = snake_to_camel(input_str=field)
|
|
129
|
+
return (
|
|
130
|
+
camel_field in self.backend_only
|
|
131
|
+
or field in self.backend_only
|
|
132
|
+
or any(camel_field in fields for fields in interfaces.values())
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
@staticmethod
|
|
136
|
+
def _format_missing_interface_message(model_name: str) -> str:
|
|
137
|
+
"""
|
|
138
|
+
Format message for missing interface.
|
|
139
|
+
|
|
140
|
+
Parameters
|
|
141
|
+
----------
|
|
142
|
+
model_name : str
|
|
143
|
+
The name of the model that an interface is missing from.
|
|
144
|
+
|
|
145
|
+
Returns
|
|
146
|
+
-------
|
|
147
|
+
str
|
|
148
|
+
The message displayed to the user when missing interfaces are found.
|
|
149
|
+
"""
|
|
150
|
+
potential_names = TypeChecker._generate_potential_names(model_name)
|
|
151
|
+
return (
|
|
152
|
+
f"\nNo matching TypeScript interface found for model: {model_name}\n"
|
|
153
|
+
f"Searched for interfaces: {', '.join(potential_names)}"
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
@staticmethod
|
|
157
|
+
def _format_missing_field_message(
|
|
158
|
+
field: str, model_name: str, interfaces: Dict[str, Set[str]]
|
|
159
|
+
) -> str:
|
|
160
|
+
"""
|
|
161
|
+
Format message for missing field.
|
|
162
|
+
|
|
163
|
+
Parameters
|
|
164
|
+
----------
|
|
165
|
+
field : str
|
|
166
|
+
The model field that's missing.
|
|
167
|
+
|
|
168
|
+
model_name : str
|
|
169
|
+
The name of the model that the field is missing from.
|
|
170
|
+
|
|
171
|
+
interfaces : Dict[str, Set[str]]
|
|
172
|
+
The interfaces that have been searched.
|
|
173
|
+
|
|
174
|
+
Returns
|
|
175
|
+
-------
|
|
176
|
+
str
|
|
177
|
+
The message displayed to the user when missing fields are found.
|
|
178
|
+
"""
|
|
179
|
+
camel_field = snake_to_camel(input_str=field)
|
|
180
|
+
return (
|
|
181
|
+
f"\nField '{field}' (camelCase: '{camel_field}') from model '{model_name}' "
|
|
182
|
+
f"is missing in TypeScript types.\n"
|
|
183
|
+
f"Expected to find in interface(s): {', '.join(interfaces.keys())}\n"
|
|
184
|
+
f"To ignore this field, add a comment that references it like: '// Note: {camel_field} is backend only'"
|
|
185
|
+
)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""
|
|
3
|
+
Setup and commands for the ts-backend-check command line interface.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
from ..checker import TypeChecker
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@click.group()
|
|
14
|
+
@click.version_option()
|
|
15
|
+
def cli():
|
|
16
|
+
"""
|
|
17
|
+
TS Backend Check is a Python package used to check TypeScript types against their corresponding backend models.
|
|
18
|
+
"""
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@cli.command()
|
|
23
|
+
@click.argument("backend_model", type=click.Path(exists=True))
|
|
24
|
+
@click.argument("typescript_file", type=click.Path(exists=True))
|
|
25
|
+
def check(backend_model: str, typescript_file: str):
|
|
26
|
+
"""
|
|
27
|
+
Check TypeScript types against backend models.
|
|
28
|
+
|
|
29
|
+
This command checks if all fields from the backend model are properly represented
|
|
30
|
+
in the TypeScript types file. It supports marking fields as backend-only using
|
|
31
|
+
special comments in the TypeScript file.
|
|
32
|
+
|
|
33
|
+
Parameters
|
|
34
|
+
----------
|
|
35
|
+
backend_model : str
|
|
36
|
+
The path to the backend model file (e.g. Python class).
|
|
37
|
+
|
|
38
|
+
typescript_file : str
|
|
39
|
+
The path to the TypeScript interface/type file.
|
|
40
|
+
|
|
41
|
+
Examples
|
|
42
|
+
--------
|
|
43
|
+
ts-backend-check check src/models/user.py src/types/user.ts
|
|
44
|
+
"""
|
|
45
|
+
checker = TypeChecker(models_file=backend_model, types_file=typescript_file)
|
|
46
|
+
if missing := checker.check():
|
|
47
|
+
click.echo("Missing TypeScript fields found:")
|
|
48
|
+
click.echo("\n".join(missing))
|
|
49
|
+
click.echo(
|
|
50
|
+
f"\nPlease correct the {len(missing)} fields above to have backend models and frontend types fully synced."
|
|
51
|
+
)
|
|
52
|
+
sys.exit(1)
|
|
53
|
+
|
|
54
|
+
click.echo("All model fields are properly typed in TypeScript!")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
if __name__ == "__main__":
|
|
58
|
+
cli()
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""
|
|
3
|
+
Module for parsing Django models and extracting field information.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import ast
|
|
7
|
+
from typing import Dict, Set
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DjangoModelVisitor(ast.NodeVisitor):
|
|
11
|
+
"""
|
|
12
|
+
AST visitor to extract fields from Django models.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
DJANGO_FIELD_TYPES = {
|
|
16
|
+
"Field",
|
|
17
|
+
"CharField",
|
|
18
|
+
"TextField",
|
|
19
|
+
"IntegerField",
|
|
20
|
+
"BooleanField",
|
|
21
|
+
"DateTimeField",
|
|
22
|
+
"ForeignKey",
|
|
23
|
+
"ManyToManyField",
|
|
24
|
+
"OneToOneField",
|
|
25
|
+
"EmailField",
|
|
26
|
+
"URLField",
|
|
27
|
+
"FileField",
|
|
28
|
+
"ImageField",
|
|
29
|
+
"DecimalField",
|
|
30
|
+
"AutoField",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
def __init__(self) -> None:
|
|
34
|
+
self.models: Dict[str, Set[str]] = {}
|
|
35
|
+
self.current_model: str | None = None
|
|
36
|
+
|
|
37
|
+
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
|
38
|
+
"""
|
|
39
|
+
Check class definitions, specifically those that inherit from other classes.
|
|
40
|
+
|
|
41
|
+
Parameters
|
|
42
|
+
----------
|
|
43
|
+
node : ast.ClassDef
|
|
44
|
+
A class definition from Python AST (Abstract Syntax Tree).
|
|
45
|
+
It contains information about the class, such as its name, base classes, body, decorators, etc.
|
|
46
|
+
"""
|
|
47
|
+
# Only process classes that inherit from something.
|
|
48
|
+
if node.bases:
|
|
49
|
+
self.current_model = node.name
|
|
50
|
+
if self.current_model not in self.models:
|
|
51
|
+
self.models[self.current_model] = set()
|
|
52
|
+
|
|
53
|
+
self.generic_visit(node)
|
|
54
|
+
|
|
55
|
+
self.current_model = None
|
|
56
|
+
|
|
57
|
+
def visit_Assign(self, node: ast.Assign) -> None:
|
|
58
|
+
"""
|
|
59
|
+
Check assignment statements within a class.
|
|
60
|
+
|
|
61
|
+
Parameters
|
|
62
|
+
----------
|
|
63
|
+
node : ast.Assign
|
|
64
|
+
An assignment definition from Python AST (Abstract Syntax Tree).
|
|
65
|
+
It represents an assignment statement (e.g., x = 42).
|
|
66
|
+
"""
|
|
67
|
+
if not self.current_model:
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
for target in node.targets:
|
|
71
|
+
if (
|
|
72
|
+
isinstance(target, ast.Name)
|
|
73
|
+
and not target.id.startswith("_")
|
|
74
|
+
and isinstance(node.value, ast.Call)
|
|
75
|
+
and hasattr(node.value.func, "attr")
|
|
76
|
+
) and any(
|
|
77
|
+
field_type in node.value.func.attr
|
|
78
|
+
for field_type in self.DJANGO_FIELD_TYPES
|
|
79
|
+
):
|
|
80
|
+
self.models[self.current_model].add(target.id)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def extract_model_fields(models_file: str) -> Dict[str, Set[str]]:
|
|
84
|
+
"""
|
|
85
|
+
Extract fields from Django models file.
|
|
86
|
+
|
|
87
|
+
Parameters
|
|
88
|
+
----------
|
|
89
|
+
models_file : str
|
|
90
|
+
A models.py file that defines Django models.
|
|
91
|
+
|
|
92
|
+
Returns
|
|
93
|
+
-------
|
|
94
|
+
Dict[str, Set[str]]
|
|
95
|
+
The fields from the models file extracted into a dictionary for future processing.
|
|
96
|
+
"""
|
|
97
|
+
with open(models_file, "r", encoding="utf-8") as f:
|
|
98
|
+
content = f.read().strip()
|
|
99
|
+
# Skip any empty lines at the beginning
|
|
100
|
+
while content.startswith("\n"):
|
|
101
|
+
content = content[1:]
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
tree = ast.parse(content)
|
|
105
|
+
except SyntaxError as e:
|
|
106
|
+
raise SyntaxError(
|
|
107
|
+
f"Failed to parse {models_file}. Make sure it's a valid Python file. Error: {str(e)}"
|
|
108
|
+
) from e
|
|
109
|
+
|
|
110
|
+
visitor = DjangoModelVisitor()
|
|
111
|
+
visitor.visit(tree)
|
|
112
|
+
|
|
113
|
+
return visitor.models
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""
|
|
3
|
+
Module for parsing TypeScript interfaces and types.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Dict, List, Set
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class TypeScriptInterface:
|
|
13
|
+
"""
|
|
14
|
+
Represents a TypeScript interface with its fields and parent interfaces.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
name: str
|
|
18
|
+
fields: Set[str]
|
|
19
|
+
parents: List[str]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TypeScriptParser:
|
|
23
|
+
"""
|
|
24
|
+
Parser for TypeScript interface files.
|
|
25
|
+
|
|
26
|
+
Parameters
|
|
27
|
+
----------
|
|
28
|
+
file_path : str
|
|
29
|
+
The file path for the TypeScript file to parse.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, file_path: str) -> None:
|
|
33
|
+
self.file_path = file_path
|
|
34
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
35
|
+
self.content = f.read()
|
|
36
|
+
|
|
37
|
+
def parse_interfaces(self) -> Dict[str, TypeScriptInterface]:
|
|
38
|
+
"""
|
|
39
|
+
Parse TypeScript interfaces from the file.
|
|
40
|
+
|
|
41
|
+
Returns
|
|
42
|
+
-------
|
|
43
|
+
Dict[str, TypeScriptInterface]
|
|
44
|
+
The interface parsed into a dictionary for future processing.
|
|
45
|
+
"""
|
|
46
|
+
interfaces = {}
|
|
47
|
+
interface_pattern = (
|
|
48
|
+
r"(?:export\s+|declare\s+)?interface\s+(\w+)"
|
|
49
|
+
r"(?:\s+extends\s+([^{]+))?\s*{([\s\S]*?)}"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
for match in re.finditer(interface_pattern, self.content):
|
|
53
|
+
name = match.group(1)
|
|
54
|
+
parents = (
|
|
55
|
+
[p.strip() for p in match.group(2).split(",")] if match.group(2) else []
|
|
56
|
+
)
|
|
57
|
+
fields = self._extract_fields(match.group(3))
|
|
58
|
+
|
|
59
|
+
interfaces[name] = TypeScriptInterface(name, fields, parents)
|
|
60
|
+
|
|
61
|
+
return interfaces
|
|
62
|
+
|
|
63
|
+
def get_backend_only_fields(self) -> Set[str]:
|
|
64
|
+
"""
|
|
65
|
+
Extract fields marked as backend-only in comments.
|
|
66
|
+
|
|
67
|
+
Returns
|
|
68
|
+
-------
|
|
69
|
+
Set[str]
|
|
70
|
+
The field names that are marked with a backend-only identifier to ignore them.
|
|
71
|
+
"""
|
|
72
|
+
patterns = [
|
|
73
|
+
r"//.*?Note:\s*(\w+)\s+is\s+backend\s+only",
|
|
74
|
+
r"//.*?(\w+)\s+is\s+backend\s+only",
|
|
75
|
+
r"//\s*@backend-only\s+(\w+)",
|
|
76
|
+
r"//.*?backend-only:\s*(\w+)",
|
|
77
|
+
]
|
|
78
|
+
return {
|
|
79
|
+
match for pattern in patterns for match in re.findall(pattern, self.content)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
@staticmethod
|
|
83
|
+
def _extract_fields(interface_body: str) -> Set[str]:
|
|
84
|
+
"""
|
|
85
|
+
Extract field names from interface body.
|
|
86
|
+
|
|
87
|
+
Parameters
|
|
88
|
+
----------
|
|
89
|
+
interface_body : str
|
|
90
|
+
A string representation of the interface body of the model.
|
|
91
|
+
|
|
92
|
+
Returns
|
|
93
|
+
-------
|
|
94
|
+
Set[str]
|
|
95
|
+
The field names from the model interface body.
|
|
96
|
+
"""
|
|
97
|
+
fields = set()
|
|
98
|
+
|
|
99
|
+
# Regular fields
|
|
100
|
+
fields.update(re.findall(r"(?://[^\n]*\n)*\s*(\w+)\s*[?]?\s*:", interface_body))
|
|
101
|
+
|
|
102
|
+
# Readonly fields
|
|
103
|
+
fields.update(re.findall(r"readonly\s+(\w+)\s*[?]?\s*:", interface_body))
|
|
104
|
+
|
|
105
|
+
return fields
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""
|
|
3
|
+
Utility functions for ts-backend-check.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def snake_to_camel(input_str: str) -> str:
|
|
8
|
+
"""
|
|
9
|
+
Convert snake_case to camelCase.
|
|
10
|
+
|
|
11
|
+
Parameters
|
|
12
|
+
----------
|
|
13
|
+
input_str : str
|
|
14
|
+
The snake_case string to convert.
|
|
15
|
+
|
|
16
|
+
Returns
|
|
17
|
+
-------
|
|
18
|
+
str
|
|
19
|
+
The camelCase version of the input string.
|
|
20
|
+
"""
|
|
21
|
+
components = input_str.split("_")
|
|
22
|
+
return components[0] + "".join(x.title() for x in components[1:])
|