ts-backend-check 1.2.2__py3-none-any.whl → 1.3.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.
@@ -3,11 +3,14 @@
3
3
  Main module for checking Django models against TypeScript types.
4
4
  """
5
5
 
6
- from typing import Dict, List, Set
6
+ from typing import Dict, List, Tuple
7
7
 
8
- from ts_backend_check.parsers.django_parser import extract_model_fields
8
+ from ts_backend_check.parsers.django_parser import (
9
+ DjangoModelVisitor,
10
+ extract_model_fields,
11
+ )
9
12
  from ts_backend_check.parsers.typescript_parser import TypeScriptParser
10
- from ts_backend_check.utils import snake_to_camel
13
+ from ts_backend_check.utils import is_ordered_subset, snake_to_camel
11
14
 
12
15
 
13
16
  class TypeChecker:
@@ -20,13 +23,30 @@ class TypeChecker:
20
23
  The file path for the models file to check.
21
24
 
22
25
  types_file : str
23
- The file path for the TypeScript file to check.
26
+ The file path for the TypeScript interfaces file to check.
27
+
28
+ check_blank : bool, default=False
29
+ Whether to also check that fields marked 'blank=True' within Django models are optional (?) in the TypeScript interfaces.
30
+
31
+ model_name_conversions : dict[str: list[str]], default={}
32
+ A dictionary containing conversions of model names to their corresponding TypeScript interfaces.
24
33
  """
25
34
 
26
- def __init__(self, models_file: str, types_file: str) -> None:
35
+ def __init__(
36
+ self,
37
+ models_file: str,
38
+ types_file: str,
39
+ check_blank: bool = False,
40
+ model_name_conversions: dict[str, list[str]] = {},
41
+ ) -> None:
27
42
  self.models_file = models_file
28
43
  self.types_file = types_file
29
- self.model_fields = extract_model_fields(models_file)
44
+ self.check_blank = check_blank
45
+ self.model_name_conversions = model_name_conversions
46
+ self.django_model_visitor = DjangoModelVisitor
47
+ self.model_fields, self.models_and_blank_fields = extract_model_fields(
48
+ models_file
49
+ )
30
50
  self.ts_parser = TypeScriptParser(types_file)
31
51
  self.ts_interfaces = self.ts_parser.parse_interfaces()
32
52
  self.backend_only = self.ts_parser.get_ignored_fields()
@@ -40,26 +60,56 @@ class TypeChecker:
40
60
  list
41
61
  A list of fields missing from the TypeScript file.
42
62
  """
43
- missing_fields = []
63
+ error_fields: list[str] = []
44
64
 
45
65
  for model_name, fields in self.model_fields.items():
46
- interfaces = self._find_matching_interfaces(model_name)
66
+ missing_fields_exist = False
67
+ interfaces, _ = self._find_matching_interfaces(model_name=model_name)
47
68
 
48
69
  if not interfaces:
49
- missing_fields.append(
50
- self._format_missing_interface_message(model_name)
70
+ error_fields.append(
71
+ self._format_missing_interface_message(model_name=model_name)
51
72
  )
52
73
  continue
53
74
 
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
- )
75
+ for field in fields:
76
+ if not self._field_is_accounted_for(field=field, interfaces=interfaces):
77
+ error_fields.append(
78
+ self._format_missing_field_message(
79
+ field=field, model_name=model_name, interfaces=interfaces
80
+ )
81
+ )
82
+ missing_fields_exist = True
83
+
84
+ if self.check_blank and model_name in self.models_and_blank_fields:
85
+ error_fields.extend(
86
+ self._format_optional_properties_message(
87
+ field=field,
88
+ model_name=model_name,
89
+ models_file=self.models_file,
90
+ types_file=self.types_file,
91
+ )
92
+ for field in self.models_and_blank_fields[model_name]
93
+ if not self._property_is_optional_when_field_is_blank(
94
+ model_name=model_name,
95
+ field=field,
96
+ )
97
+ )
98
+
99
+ if not missing_fields_exist and not self._ts_interface_properties_ordered(
100
+ model_name=model_name, fields=fields
101
+ ):
102
+ error_fields.append(
103
+ self._format_unordered_interface_properties_message(
104
+ models_file=self.models_file, types_file=self.types_file
105
+ )
106
+ )
59
107
 
60
- return missing_fields
108
+ return error_fields
61
109
 
62
- def _find_matching_interfaces(self, model_name: str) -> Dict[str, Set[str]]:
110
+ def _find_matching_interfaces(
111
+ self, model_name: str
112
+ ) -> Tuple[Dict[str, List[str]], Dict[str, List[str]]]:
63
113
  """
64
114
  Find matching TypeScript interfaces for a model.
65
115
 
@@ -70,44 +120,30 @@ class TypeChecker:
70
120
 
71
121
  Returns
72
122
  -------
73
- Dict[str, Set[str]]
123
+ Tuple[Dict[str, List[str]], Dict[str, List[str]]]
74
124
  Interfaces that match a model name.
75
125
  """
76
- potential_names = self._generate_potential_names(model_name)
77
- return {
78
- name: interface.fields
126
+ if self.model_name_conversions and model_name in self.model_name_conversions:
127
+ potential_names = self.model_name_conversions[model_name]
128
+
129
+ else:
130
+ potential_names = [model_name]
131
+
132
+ interfaces = {
133
+ name: interface.properties
134
+ for name, interface in self.ts_interfaces.items()
135
+ if any(potential == name for potential in potential_names)
136
+ }
137
+ interfaces_with_optional_properties = {
138
+ name: interface.optional_properties
79
139
  for name, interface in self.ts_interfaces.items()
80
140
  if any(potential == name for potential in potential_names)
81
141
  }
82
142
 
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]
143
+ return interfaces, interfaces_with_optional_properties
108
144
 
109
- def _is_field_accounted_for(
110
- self, field: str, interfaces: Dict[str, Set[str]]
145
+ def _field_is_accounted_for(
146
+ self, field: str, interfaces: Dict[str, List[str]]
111
147
  ) -> bool:
112
148
  """
113
149
  Check if a field is accounted for in TypeScript.
@@ -117,7 +153,7 @@ class TypeChecker:
117
153
  field : str
118
154
  The field that should be used in the frontend TypeScript file.
119
155
 
120
- interfaces : Dict[str, Set[str]]
156
+ interfaces : Dict[str, List[str]]
121
157
  The interfaces from the frontend TypeScript file.
122
158
 
123
159
  Returns
@@ -132,6 +168,64 @@ class TypeChecker:
132
168
  or any(camel_field in fields for fields in interfaces.values())
133
169
  )
134
170
 
171
+ def _property_is_optional_when_field_is_blank(
172
+ self, model_name: str, field: str
173
+ ) -> bool:
174
+ """
175
+ Check that if the field is 'blank=True' that the corresponding interface property is optional (?).
176
+
177
+ Parameters
178
+ ----------
179
+ model_name : str
180
+ The name of the model to check the frontend TypeScript file for.
181
+
182
+ field : str
183
+ The field that should match the optional state of the property in the TypeScript file.
184
+
185
+ Returns
186
+ -------
187
+ Bool
188
+ Whether the blank status of the model field matches the optional status of the interface property.
189
+ """
190
+ camel_field = snake_to_camel(input_str=field)
191
+ _, interfaces_with_optional_properties = self._find_matching_interfaces(
192
+ model_name
193
+ )
194
+
195
+ return any(
196
+ camel_field in properties
197
+ for properties in interfaces_with_optional_properties.values()
198
+ )
199
+
200
+ def _ts_interface_properties_ordered(
201
+ self, model_name: str, fields: list[str]
202
+ ) -> bool:
203
+ """
204
+ Check if the order of the TypeScript interface properties exactly matches that of the backend model fields.
205
+
206
+ Parameters
207
+ ----------
208
+ model_name : str
209
+ The name of the model to check the frontend TypeScript file for.
210
+
211
+ fields : List[str]
212
+ The fields of the backend model.
213
+
214
+ Returns
215
+ -------
216
+ bool
217
+ Whether the order of the properties of the TypeScript interface file match that of the backend model fields.
218
+ """
219
+ camel_fields = [snake_to_camel(input_str=f) for f in fields]
220
+ interfaces, _ = self._find_matching_interfaces(model_name)
221
+
222
+ return all(
223
+ is_ordered_subset(
224
+ reference_list=camel_fields, candidate_sub_list=interfaces[i]
225
+ )
226
+ for i in interfaces
227
+ )
228
+
135
229
  @staticmethod
136
230
  def _format_missing_interface_message(model_name: str) -> str:
137
231
  """
@@ -147,15 +241,17 @@ class TypeChecker:
147
241
  str
148
242
  The message displayed to the user when missing interfaces are found.
149
243
  """
150
- potential_names = TypeChecker._generate_potential_names(model_name)
151
244
  return (
152
- f"\nNo matching TypeScript interface found for model: {model_name}"
153
- f"\nSearched for interfaces: {', '.join(potential_names)}"
245
+ f"\nNo matching TypeScript interface found for the model '{model_name}'."
246
+ "\nPlease name your TypeScript interfaces the same as the corresponding backend models."
247
+ "\nYou can also use the 'backend_to_ts_model_name_conversions' option within the configuration file."
248
+ "\nThe key is the backend model name and the value is a list of the corresponding interfaces."
249
+ "\nThis option is also how you can break larger backend models into multiple interfaces that extend one another."
154
250
  )
155
251
 
156
252
  @staticmethod
157
253
  def _format_missing_field_message(
158
- field: str, model_name: str, interfaces: Dict[str, Set[str]]
254
+ field: str, model_name: str, interfaces: Dict[str, List[str]]
159
255
  ) -> str:
160
256
  """
161
257
  Format message for missing field.
@@ -184,5 +280,64 @@ class TypeChecker:
184
280
  return (
185
281
  f"\nField '{field}' (camelCase: '{camel_field}') from model '{model_name}' is missing in the TypeScript interfaces."
186
282
  f"\nExpected to find this field in the frontend {interface_of_interfaces}: {', '.join(interfaces.keys())}"
187
- f"\nTo ignore this field, add the following comment to the TypeScript file: '// ts-backend-check: ignore field {camel_field}'"
283
+ f"\nTo ignore this field, add the following comment to the TypeScript file (in order based on the model fields): '// ts-backend-check: ignore field {camel_field}'"
284
+ )
285
+
286
+ @staticmethod
287
+ def _format_optional_properties_message(
288
+ field: str, model_name: str, models_file: str, types_file: str
289
+ ) -> str:
290
+ """
291
+ Format message for when the blank status of a model field doesn't match the optional status of the corresponding property.
292
+
293
+ Parameters
294
+ ----------
295
+ field : str
296
+ The model field that doesn't match blank and optional states.
297
+
298
+ model_name : str
299
+ The name of the model that the mismatch occurs in.
300
+
301
+ models_file : str
302
+ The file path for the models file to check.
303
+
304
+ types_file : str
305
+ The file path for the TypeScript interfaces file that was checked.
306
+
307
+ Returns
308
+ -------
309
+ str
310
+ The message displayed to the user when missing fields are found.
311
+ """
312
+ camel_field = snake_to_camel(input_str=field)
313
+
314
+ return (
315
+ f"\nField '{field}' (camelCase: '{camel_field}') from model '{model_name}' doesn't match the TypeScript interfaces based on blank to optional agreement."
316
+ f"\nPlease check '{models_file}' and '{types_file}' to make sure that all 'blank=True' fields are optional (?) in the TypeScript interfaces file."
317
+ )
318
+
319
+ @staticmethod
320
+ def _format_unordered_interface_properties_message(
321
+ models_file: str, types_file: str
322
+ ) -> str:
323
+ """
324
+ Format message for unordered interface properties.
325
+
326
+ Parameters
327
+ ----------
328
+ models_file : str
329
+ The file path for the models file to check.
330
+
331
+ types_file : str
332
+ The file path for the TypeScript interfaces file that was checked.
333
+
334
+ Returns
335
+ -------
336
+ str
337
+ The message displayed to the user when unordered interface properties are found.
338
+ """
339
+ return (
340
+ f"\nThe properties of the interface file '{types_file}' are unordered."
341
+ f"\nAll interface properties should exactly match the order of the corresponding fields in the '{models_file}' backend model."
342
+ "\nIf the model is synced with multiple interfaces, then their properties should follow the order prescribed by the model fields."
188
343
  )
@@ -0,0 +1,235 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """
3
+ Configure cli to run based on a YAML configuration file.
4
+ """
5
+
6
+ from pathlib import Path
7
+
8
+ from rich import print as rprint
9
+ from yaml import dump
10
+
11
+ YAML_CONFIG_FILE_PATH = (
12
+ Path(__file__).parent.parent.parent.parent / ".ts-backend-check.yaml"
13
+ )
14
+
15
+ PROJECT_ROOT_PATH = Path(__file__).parent.parent.parent.parent
16
+
17
+
18
+ def path_exists(path: str) -> bool:
19
+ """
20
+ Check if path entered by the user exists withing the filesystem.
21
+
22
+ Parameters
23
+ ----------
24
+ path : str
25
+ Path should be entered as a string from the user.
26
+
27
+ Returns
28
+ -------
29
+ bool
30
+ Return true or false based on if path exists.
31
+ """
32
+ full_path = Path(__file__).parent.parent.parent.parent / path
33
+ return bool(Path(full_path).is_file())
34
+
35
+
36
+ def write_config(config: dict[str, dict[str, object]]) -> None:
37
+ """
38
+ Function to write into .ts-backend-check.yaml file.
39
+
40
+ Parameters
41
+ ----------
42
+ config : dict[str, dict[str, object]]
43
+ Passing a dictionary as key str with another dict as value.
44
+ """
45
+ try:
46
+ options = f"""# Configuration file for ts-backend-check validation.
47
+ # See https://github.com/activist-org/ts-backend-check for details.
48
+
49
+ {dump(config, sort_keys=False)}"""
50
+
51
+ with open(YAML_CONFIG_FILE_PATH, "w") as file:
52
+ file.write(options)
53
+
54
+ except IOError as e:
55
+ print(f"Error while writing configuration file: {e}")
56
+
57
+
58
+ def configure_model_interface_arguments() -> None:
59
+ """
60
+ Function to receive paths from user.
61
+ """
62
+ config_options = {}
63
+ while True:
64
+ print(
65
+ "\nAdding new model-interface configuration. Please provide the information as directed:"
66
+ )
67
+
68
+ key = input(
69
+ "Enter a model-interface identifier (eg: auth, user, event): "
70
+ ).strip()
71
+ if not key:
72
+ rprint(
73
+ "\n[red]The model-interface identifier cannot be empty. Please try again.[/red]"
74
+ )
75
+ continue
76
+
77
+ # Get backend path.
78
+ while True:
79
+ backend_path = input("Enter the path for Django models.py file: ").strip()
80
+ if not backend_path:
81
+ rprint(
82
+ "[red]The path for the Django models.py file cannot be empty. Please try again.[/red]"
83
+ )
84
+ continue
85
+
86
+ if path_exists(backend_path):
87
+ break
88
+
89
+ rprint(f"[red]File not found: {PROJECT_ROOT_PATH / backend_path}[/red]")
90
+ rprint("[yellow]Please check the path and try again.[/yellow]")
91
+
92
+ # Get frontend path.
93
+ while True:
94
+ frontend_path = input(
95
+ "Enter the path to TypeScript interface file: "
96
+ ).strip()
97
+ if not frontend_path:
98
+ rprint(
99
+ "[red]The path for the TypeScript interface file cannot be empty. Please try again.[/red]"
100
+ )
101
+ continue
102
+
103
+ if path_exists(frontend_path):
104
+ break
105
+
106
+ rprint(f"[red]File not found: {PROJECT_ROOT_PATH / frontend_path}[/red]")
107
+ rprint("[yellow]Please check the path and try again.[/yellow]")
108
+
109
+ # Get whether to check blank model fields.
110
+ while True:
111
+ check_blank_model_fields_response = (
112
+ input(
113
+ "The check should assert that model fields that can be blank must also be optional in interfaces ([y]/n): "
114
+ )
115
+ .strip()
116
+ .lower()
117
+ )
118
+
119
+ if check_blank_model_fields_response in ["y", ""]:
120
+ check_blank_model_fields = True
121
+ break
122
+
123
+ elif check_blank_model_fields_response == "n":
124
+ check_blank_model_fields = False
125
+ break
126
+
127
+ else:
128
+ rprint("[red]Invalid response. Please try again.[/red]")
129
+
130
+ # Get model name conversions.
131
+ rprint(
132
+ "[yellow]💡 Note: You need model name conversions if your TypeScript interfaces are not named exactly the same as the corresponding models (i.e. UserModel in Django and User in TS).[/yellow]"
133
+ )
134
+
135
+ backend_to_ts_model_name_conversions = {}
136
+ while True:
137
+ name_conversions_needed = (
138
+ input("Model name conversions are needed (y/[n]): ").strip().lower()
139
+ )
140
+
141
+ if name_conversions_needed in ["n", ""]:
142
+ break
143
+
144
+ while True:
145
+ while True:
146
+ if backend_model_name := input(
147
+ "Enter the backend model name: "
148
+ ).strip():
149
+ break
150
+
151
+ else:
152
+ rprint("[red]Invalid response. Please try again.[/red]")
153
+
154
+ while True:
155
+ if ts_interface_name := input(
156
+ "Enter the TypeScript interface name: "
157
+ ).strip():
158
+ break
159
+
160
+ else:
161
+ rprint("[red]Invalid response. Please try again.[/red]")
162
+
163
+ backend_to_ts_model_name_conversions[backend_model_name] = (
164
+ ts_interface_name
165
+ )
166
+
167
+ further_name_conversions_needed = (
168
+ input("Add more model name conversions (y/[n]): ").strip().lower()
169
+ )
170
+ if further_name_conversions_needed in ["n", ""]:
171
+ break
172
+
173
+ break
174
+
175
+ config_options[key] = {
176
+ "backend_model_path": backend_path,
177
+ "ts_interface_path": frontend_path,
178
+ "check_blank_model_fields": check_blank_model_fields,
179
+ }
180
+
181
+ if backend_to_ts_model_name_conversions:
182
+ config_options[key]["backend_to_ts_model_name_conversions"] = (
183
+ backend_to_ts_model_name_conversions
184
+ )
185
+
186
+ write_config(config_options)
187
+ rprint(f"[green]✅ Added configuration for '{key}' check.[/green]")
188
+
189
+ continue_config = input(
190
+ "Add another model-interface configuration (y/[n]): "
191
+ ).strip()
192
+
193
+ if continue_config.lower() in ["n", ""]:
194
+ if config_options:
195
+ optional_s = "s" if len(config_options) > 1 else ""
196
+ rprint(
197
+ f"[green]✅ Configuration complete! Added {len(config_options)} configuration{optional_s} to check.[/green]"
198
+ )
199
+ break
200
+
201
+
202
+ def generate_config_file() -> None:
203
+ """
204
+ Main function to create or update configuration.
205
+ """
206
+ header = "ts-backend-check Configuration Setup"
207
+ print(header)
208
+ print("=" * len(header))
209
+
210
+ if YAML_CONFIG_FILE_PATH.is_file():
211
+ reconfigure_choice = input(
212
+ "Configuration file exists. Confirm if you want to re-configure your .ts-backend-check.yaml file (y/[n]): "
213
+ )
214
+ if reconfigure_choice.lower() in ["n", ""]:
215
+ print("Exiting without changes.")
216
+ return
217
+
218
+ print("Reconfiguring...")
219
+
220
+ else:
221
+ print("Creating new configuration file...")
222
+
223
+ try:
224
+ configure_model_interface_arguments()
225
+
226
+ except KeyboardInterrupt:
227
+ print("\n\nConfiguration cancelled by user.")
228
+
229
+ except Exception as e:
230
+ print(f"\nError during configuration: {e}")
231
+ print("Configuration cancelled.")
232
+
233
+
234
+ if __name__ == "__main__":
235
+ generate_config_file()
@@ -0,0 +1,54 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """
3
+ Functionality to copy the test project files from the package to the present working directory.
4
+ """
5
+
6
+ import os
7
+ import shutil
8
+ from pathlib import Path
9
+
10
+ # Check for Windows and derive directory path separator.
11
+ PATH_SEPARATOR = "\\" if os.name == "nt" else "/"
12
+ INTERNAL_TEST_PROJECT_DIR_PATH = Path(__file__).parent.parent / "test_project"
13
+
14
+
15
+ def generate_test_project() -> None:
16
+ """
17
+ Copy the ts_backend_check/test_project directory to the present working directory.
18
+ """
19
+ if not Path("./ts_backend_check_test_project/").is_dir():
20
+ print(
21
+ f"Generating testing project for ts-backend-check in .{PATH_SEPARATOR}ts_backend_check_test_project{PATH_SEPARATOR} ..."
22
+ )
23
+
24
+ shutil.copytree(
25
+ INTERNAL_TEST_PROJECT_DIR_PATH,
26
+ Path("./ts_backend_check_test_project/"),
27
+ dirs_exist_ok=True,
28
+ )
29
+
30
+ print("The test project has been successfully generated.")
31
+ print(
32
+ "Within the project there's one model that passes all checks and one that fails all checks."
33
+ )
34
+ if (
35
+ not Path(".ts-backend-check.yaml").is_file()
36
+ and not Path(".ts-backend-check.yml").is_file()
37
+ ):
38
+ print(
39
+ "You can set which one to test in an ts-backend-check configuration file."
40
+ )
41
+ print(
42
+ "Please generate one with the 'ts-backend-check --generate-config-file' command."
43
+ )
44
+
45
+ elif Path(".ts-backend-check.yaml").is_file():
46
+ print("You can set which one to test in the .ts-backend-check.yaml file.")
47
+
48
+ elif Path(".ts-backend-check.yml").is_file():
49
+ print("You can set which one to test in the .ts-backend-check.yml file.")
50
+
51
+ else:
52
+ print(
53
+ f"Test project for ts-backend-check already exist in .{PATH_SEPARATOR}ts_backend_check_test_project{PATH_SEPARATOR} and will not be regenerated."
54
+ )