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.
@@ -0,0 +1,7 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ __version__ = "1.0.0"
3
+
4
+ from .checker import TypeChecker
5
+ from .cli import cli
6
+
7
+ __all__ = ["cli", "TypeChecker"]
@@ -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,8 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ """
3
+ CLI package for ts-backend-check.
4
+ """
5
+
6
+ from .main import cli
7
+
8
+ __all__ = ["cli"]
@@ -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:])
@@ -0,0 +1,6 @@
1
+ #
2
+ # This file is autogenerated by pip-compile with Python 3.13
3
+ # by the following command:
4
+ #
5
+ # pip-compile requirements.in
6
+ #