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.
@@ -8,16 +8,137 @@ import sys
8
8
  from argparse import ArgumentParser
9
9
  from pathlib import Path
10
10
 
11
+ import yaml
11
12
  from rich import print as rprint
12
13
  from rich.text import Text
13
14
 
14
15
  from ts_backend_check.checker import TypeChecker
15
- from ts_backend_check.cli.check_blank import check_blank
16
- from ts_backend_check.cli.config import create_config
16
+ from ts_backend_check.cli.generate_config_file import generate_config_file
17
+ from ts_backend_check.cli.generate_test_project import generate_test_project
17
18
  from ts_backend_check.cli.upgrade import upgrade_cli
18
19
  from ts_backend_check.cli.version import get_version_message
19
20
 
20
- ROOT_DIR = Path.cwd()
21
+ # MARK: Base Paths
22
+
23
+ CWD_PATH = Path.cwd()
24
+
25
+
26
+ def get_config_file_path() -> Path:
27
+ """
28
+ Get the path to the ts-backend-check configuration file.
29
+
30
+ Checks for both .yaml and .yml extensions, preferring .yaml if both exist.
31
+
32
+ Returns
33
+ -------
34
+ Path
35
+ The path to the configuration file (.yaml or .yml).
36
+ """
37
+ yaml_path = CWD_PATH / ".ts-backend-check.yaml"
38
+ yml_path = CWD_PATH / ".ts-backend-check.yml"
39
+
40
+ # Prefer .yaml if it exists, otherwise check for .yml.
41
+ if yaml_path.is_file():
42
+ return yaml_path
43
+
44
+ elif yml_path.is_file():
45
+ return yml_path
46
+
47
+ else:
48
+ # Default to .yaml for new files.
49
+ return yaml_path
50
+
51
+
52
+ YAML_CONFIG_FILE_PATH = get_config_file_path()
53
+
54
+
55
+ # MARK: CLI Vars
56
+
57
+ if not Path(YAML_CONFIG_FILE_PATH).is_file():
58
+ generate_config_file()
59
+
60
+ if not Path(YAML_CONFIG_FILE_PATH).is_file():
61
+ print(
62
+ "No configuration file. Please generate a configuration file (.ts-backend-check.yaml or .ts-backend-check.yml) with ts-backend-check -gcf."
63
+ )
64
+ exit(1)
65
+
66
+ with open(YAML_CONFIG_FILE_PATH, "r", encoding="utf-8") as file:
67
+ config = yaml.safe_load(file)
68
+
69
+
70
+ def check_files_and_print_results(
71
+ identifier: str,
72
+ backend_model_file_path: Path,
73
+ ts_interface_file_path: Path,
74
+ check_blank: bool = False,
75
+ model_name_conversions: dict[str, list[str]] = {},
76
+ ) -> bool:
77
+ """
78
+ Check the provided files for the given model and print the results.
79
+
80
+ Parameters
81
+ ----------
82
+ identifier : str
83
+ The model in the .ts-backend-check.yaml configuration file to check models and interfaces for.
84
+
85
+ backend_model_file_path : Path
86
+ The path to the backend models as defined in the .ts-backend-check.yaml configuration file.
87
+
88
+ ts_interface_file_path : Path
89
+ The path to the TypeScript interfaces as defined in the .ts-backend-check.yaml configuration file.
90
+
91
+ check_blank : bool, default=False
92
+ Whether to also check that fields marked 'blank=True' within Django models are optional (?) in the TypeScript interfaces.
93
+
94
+ model_name_conversions : dict[str, list[str]], default={}
95
+ A dictionary of backend model names to their corresponding TypeScript interfaces when snake to camel case isn't valid.
96
+
97
+ Returns
98
+ -------
99
+ bool
100
+ Whether the checks passed (True) or not (False).
101
+ """
102
+ if not backend_model_file_path.is_file():
103
+ rprint(
104
+ f"[red]❌ {backend_model_file_path} that should contain the '{identifier}' backend models does not exist. Please check the ts-backend-check configuration file and try again.[/red]"
105
+ )
106
+ return False
107
+
108
+ elif not ts_interface_file_path.is_file():
109
+ rprint(
110
+ f"[red]❌ {ts_interface_file_path} that should contain the '{identifier}' TypeScript types does not exist. Please check the ts-backend-check configuration file and try again.[/red]"
111
+ )
112
+ return False
113
+
114
+ checker = TypeChecker(
115
+ models_file=str(backend_model_file_path),
116
+ types_file=str(ts_interface_file_path),
117
+ model_name_conversions=model_name_conversions,
118
+ check_blank=check_blank,
119
+ )
120
+
121
+ if missing := checker.check():
122
+ rprint(
123
+ f"\n[red]❌ ts-backend-check error: There are inconsistencies between the provided '{identifier}' backend models and TypeScript interfaces. Please see the output below for details.[/red]"
124
+ )
125
+
126
+ for msg in missing:
127
+ rprint(Text.from_markup(f"[red]{msg}[/red]"))
128
+
129
+ error_or_errors = "errors" if len(missing) > 1 else "error"
130
+ rprint(
131
+ f"[red]\nPlease fix the {len(missing)} {error_or_errors} above to continue the sync of the backend models of {backend_model_file_path} and the TypeScript interfaces of {ts_interface_file_path}.[/red]"
132
+ )
133
+
134
+ return False
135
+
136
+ else:
137
+ rprint(
138
+ f"[green]✅ Success: All backend models are synced with their corresponding TypeScript interfaces for the provided '{identifier}' files.[/green]"
139
+ )
140
+
141
+ return True
21
142
 
22
143
 
23
144
  def main() -> None:
@@ -27,12 +148,19 @@ def main() -> None:
27
148
  Notes
28
149
  -----
29
150
  The available command line arguments are:
30
- - --backend-model-file (-bmf): Path to the backend model file (e.g. Python class)
31
- - --typescript-file (-tsf): Path to the TypeScript interface/type file
151
+ - --help (-h): Show this help message and exit.
152
+ - --version (-v): Show the version of the ts-backend-check CLI.
153
+ - --upgrade (-u): Upgrade the ts-backend-check CLI to the latest version.
154
+ - --generate-config-file (-gcf): Interactively generate a configuration file for ts-backend-check.
155
+ - --generate-test-project (-gtp): Generate project to test ts-backend-check functionalities.
156
+ - --model (-m): The model in the .ts-backend-check.yaml configuration file to check.
157
+ - --all (-a): Run checks of all backend models against their corresponding TypeScript interfaces.
32
158
 
33
159
  Examples
34
160
  --------
35
- >>> ts-backend-check -bmf <backend-model-file> -tsf <typescript-file>
161
+ >>> ts-backend-check --generate-config-file # -gcf
162
+ >>> ts-backend-check --model <ts-backend-check-config-file-model> # -m
163
+ >>> ts-backend-check --all # -a
36
164
  """
37
165
  # MARK: CLI Base
38
166
 
@@ -61,28 +189,30 @@ def main() -> None:
61
189
  )
62
190
 
63
191
  parser.add_argument(
64
- "-bmf",
65
- "--backend-model-file",
66
- help="Path to the backend model file (e.g. Python class).",
192
+ "-gcf",
193
+ "--generate-config-file",
194
+ action="store_true",
195
+ help="Interactively generate a configuration file for ts-backend-check.",
67
196
  )
68
197
 
69
198
  parser.add_argument(
70
- "-tsf",
71
- "--typescript-file",
72
- help="Path to the TypeScript interface/type file.",
199
+ "-gtp",
200
+ "--generate-test-project",
201
+ action="store_true",
202
+ help="Generate project to test ts-backend-check functionalities.",
73
203
  )
74
204
 
75
205
  parser.add_argument(
76
- "-c",
77
- "--configure",
78
- action="store_true",
79
- help="Configure a YAML file to simplify your checks.",
206
+ "-m",
207
+ "--model",
208
+ help="The model in the .ts-backend-check.yaml configuration file to check.",
80
209
  )
81
210
 
82
211
  parser.add_argument(
83
- "-cb",
84
- "--check-blank",
85
- help="Check for fields marked blank=True within Django models.",
212
+ "-a",
213
+ "--all",
214
+ action="store_true",
215
+ help="Run checks of all backend models against their corresponding TypeScript interfaces.",
86
216
  )
87
217
 
88
218
  # MARK: Setup CLI
@@ -93,53 +223,84 @@ def main() -> None:
93
223
  upgrade_cli()
94
224
  return
95
225
 
96
- if args.configure:
97
- create_config()
226
+ if args.generate_config_file:
227
+ generate_config_file()
98
228
  return
99
229
 
100
- if args.check_blank:
101
- check_blank(args.check_blank)
230
+ if args.generate_test_project:
231
+ generate_test_project()
102
232
  return
103
233
 
104
- # MARK: Run Check
234
+ # MARK: Run Checks
105
235
 
106
- backend_model_file_path = ROOT_DIR / args.backend_model_file
107
- ts_file_path = ROOT_DIR / args.typescript_file
236
+ results = []
108
237
 
109
- if not backend_model_file_path.is_file():
110
- rprint(
111
- f"[red]{args.backend_model_file} that should contain the backend models does not exist. Please check and try again.[/red]"
112
- )
238
+ if args.model:
239
+ model_config = config.get(args.model)
113
240
 
114
- elif not ts_file_path.is_file():
115
- rprint(
116
- f"[red]{args.typescript_file} file that should contain the TypeScript types does not exist. Please check and try again.[/red]"
117
- )
241
+ if not model_config:
242
+ rprint(
243
+ f"[red]{args.model} is not an index within the .ts-backend-check.yaml configuration file. Please check the defined models and try again.[/red]"
244
+ )
245
+ sys.exit(1)
118
246
 
119
- else:
120
- checker = TypeChecker(
121
- models_file=args.backend_model_file,
122
- types_file=args.typescript_file,
247
+ config_backend_model_file_path = Path(model_config["backend_model_path"])
248
+ config_ts_interface_file_path = Path(model_config["ts_interface_path"])
249
+ config_check_blank = (
250
+ model_config["check_blank_model_fields"]
251
+ if "check_blank_model_fields" in model_config
252
+ else False
253
+ )
254
+ config_model_name_conversions = (
255
+ model_config["backend_to_ts_model_name_conversions"]
256
+ if "backend_to_ts_model_name_conversions" in model_config
257
+ else {}
123
258
  )
124
259
 
125
- if missing := checker.check():
126
- rprint(
127
- "\n[red]❌ ts-backend-check error: There are inconsistencies between the provided backend models and TypeScript interfaces. Please see the output below for details.[/red]"
260
+ r = check_files_and_print_results(
261
+ identifier=args.model,
262
+ backend_model_file_path=config_backend_model_file_path,
263
+ ts_interface_file_path=config_ts_interface_file_path,
264
+ check_blank=config_check_blank,
265
+ model_name_conversions=config_model_name_conversions,
266
+ )
267
+ results.append(r)
268
+
269
+ if args.all:
270
+ for i in config.keys():
271
+ model_config = config.get(i)
272
+
273
+ config_backend_model_file_path = Path(model_config["backend_model_path"])
274
+ config_ts_interface_file_path = Path(model_config["ts_interface_path"])
275
+ config_check_blank = (
276
+ model_config["check_blank_model_fields"]
277
+ if "check_blank_model_fields" in model_config
278
+ else False
279
+ )
280
+ config_model_name_conversions = (
281
+ model_config["backend_to_ts_model_name_conversions"]
282
+ if "backend_to_ts_model_name_conversions" in model_config
283
+ else {}
128
284
  )
129
285
 
130
- # Print each error message in red.
131
- for msg in missing:
132
- rprint(Text.from_markup(f"[red]{msg}[/red]"))
133
-
134
- field_or_fields = "fields" if len(missing) > 1 else "field"
135
- rprint(
136
- f"[red]\nPlease fix the {len(missing)} {field_or_fields} above to have the backend models of {args.backend_model_file} synced with the typescript interfaces of {(args.typescript_file)}.[/red]"
286
+ r = check_files_and_print_results(
287
+ identifier=i,
288
+ backend_model_file_path=config_backend_model_file_path,
289
+ ts_interface_file_path=config_ts_interface_file_path,
290
+ check_blank=config_check_blank,
291
+ model_name_conversions=config_model_name_conversions,
137
292
  )
293
+ results.append(r)
294
+
295
+ if args.model or args.all:
296
+ if not all(results):
138
297
  sys.exit(1)
139
298
 
140
- rprint(
141
- "[green]✅ Success: All backend models are synced with their corresponding TypeScript interfaces for the provided files.[/green]"
142
- )
299
+ else:
300
+ return # exit 0
301
+
302
+ else:
303
+ parser.print_help()
143
304
 
144
305
 
145
306
  if __name__ == "__main__":
@@ -4,7 +4,7 @@ Module for parsing Django models and extracting field information.
4
4
  """
5
5
 
6
6
  import ast
7
- from typing import Dict, Set
7
+ from typing import Dict, List, Tuple
8
8
 
9
9
 
10
10
  class DjangoModelVisitor(ast.NodeVisitor):
@@ -31,8 +31,9 @@ class DjangoModelVisitor(ast.NodeVisitor):
31
31
  }
32
32
 
33
33
  def __init__(self) -> None:
34
- self.models: Dict[str, Set[str]] = {}
34
+ self.models: Dict[str, List[str]] = {}
35
35
  self.current_model: str | None = None
36
+ self.models_and_blank_fields: Dict[str, List[str]] = {}
36
37
 
37
38
  def visit_ClassDef(self, node: ast.ClassDef) -> None:
38
39
  """
@@ -48,7 +49,7 @@ class DjangoModelVisitor(ast.NodeVisitor):
48
49
  if node.bases:
49
50
  self.current_model = node.name
50
51
  if self.current_model not in self.models:
51
- self.models[self.current_model] = set()
52
+ self.models[self.current_model] = []
52
53
 
53
54
  self.generic_visit(node)
54
55
 
@@ -77,10 +78,23 @@ class DjangoModelVisitor(ast.NodeVisitor):
77
78
  field_type in node.value.func.attr
78
79
  for field_type in self.DJANGO_FIELD_TYPES
79
80
  ):
80
- self.models[self.current_model].add(target.id)
81
+ self.models[self.current_model].append(target.id)
81
82
 
83
+ if any(
84
+ kw.arg == "blank"
85
+ and isinstance(kw.value, ast.Constant)
86
+ and kw.value.value is True
87
+ for kw in node.value.keywords
88
+ ):
89
+ if self.current_model not in self.models_and_blank_fields:
90
+ self.models_and_blank_fields[self.current_model] = []
82
91
 
83
- def extract_model_fields(models_file: str) -> Dict[str, Set[str]]:
92
+ self.models_and_blank_fields[self.current_model].append(target.id)
93
+
94
+
95
+ def extract_model_fields(
96
+ models_file: str,
97
+ ) -> Tuple[Dict[str, List[str]], Dict[str, List[str]]]:
84
98
  """
85
99
  Extract fields from Django models file.
86
100
 
@@ -91,7 +105,7 @@ def extract_model_fields(models_file: str) -> Dict[str, Set[str]]:
91
105
 
92
106
  Returns
93
107
  -------
94
- Dict[str, Set[str]]
108
+ Tuple(Dict[str, List[str]], Dict[str, List[str]])
95
109
  The fields from the models file extracted into a dictionary for future processing.
96
110
  """
97
111
  with open(models_file, "r", encoding="utf-8") as f:
@@ -102,6 +116,7 @@ def extract_model_fields(models_file: str) -> Dict[str, Set[str]]:
102
116
 
103
117
  try:
104
118
  tree = ast.parse(content)
119
+
105
120
  except SyntaxError as e:
106
121
  raise SyntaxError(
107
122
  f"Failed to parse {models_file}. Make sure it's a valid Python file. Error: {str(e)}"
@@ -110,4 +125,4 @@ def extract_model_fields(models_file: str) -> Dict[str, Set[str]]:
110
125
  visitor = DjangoModelVisitor()
111
126
  visitor.visit(tree)
112
127
 
113
- return visitor.models
128
+ return visitor.models, visitor.models_and_blank_fields
@@ -11,11 +11,12 @@ from typing import Dict, List, Set
11
11
  @dataclass
12
12
  class TypeScriptInterface:
13
13
  """
14
- Represents a TypeScript interface with its fields and parent interfaces.
14
+ Represents a TypeScript interface with its properties and parent interfaces.
15
15
  """
16
16
 
17
17
  name: str
18
- fields: Set[str]
18
+ properties: List[str]
19
+ optional_properties: List[str]
19
20
  parents: List[str]
20
21
 
21
22
 
@@ -54,9 +55,12 @@ class TypeScriptParser:
54
55
  parents = (
55
56
  [p.strip() for p in match.group(2).split(",")] if match.group(2) else []
56
57
  )
57
- fields = self._extract_fields(match.group(3))
58
+ properties = self._extract_properties(match.group(3))
59
+ optional_properties = self._extract_optional_properties(match.group(3))
58
60
 
59
- interfaces[name] = TypeScriptInterface(name, fields, parents)
61
+ interfaces[name] = TypeScriptInterface(
62
+ name, properties, optional_properties, parents
63
+ )
60
64
 
61
65
  return interfaces
62
66
 
@@ -73,9 +77,9 @@ class TypeScriptParser:
73
77
  return set(re.findall(ignore_pattern, self.content))
74
78
 
75
79
  @staticmethod
76
- def _extract_fields(interface_body: str) -> Set[str]:
80
+ def _extract_properties(interface_body: str) -> List[str]:
77
81
  """
78
- Extract field names from interface body.
82
+ Extract both real properties and 'ignored' comment backend fields from interface bodies.
79
83
 
80
84
  Parameters
81
85
  ----------
@@ -84,15 +88,43 @@ class TypeScriptParser:
84
88
 
85
89
  Returns
86
90
  -------
87
- Set[str]
88
- The field names from the model interface body.
91
+ List[str]
92
+ The property names from the model interface body.
93
+ """
94
+ combined_pattern = (
95
+ r"^\s*(?:"
96
+ r"(?:readonly\s+)?(\w+)\s*\??\s*:|" # standard/readonly properties
97
+ r"//\s*ts-backend-check:\s*ignore\s+field\s+(\w+)" # ts-backend-check ignore comment properties
98
+ r")"
99
+ )
100
+
101
+ properties = []
102
+ for match in re.finditer(combined_pattern, interface_body, flags=re.MULTILINE):
103
+ if field_name := match.group(1) or match.group(2):
104
+ properties.append(field_name)
105
+
106
+ return properties
107
+
108
+ @staticmethod
109
+ def _extract_optional_properties(interface_body: str) -> List[str]:
89
110
  """
90
- fields = set()
111
+ Extract all optional properties from interface bodies.
91
112
 
92
- # Regular fields
93
- fields.update(re.findall(r"(?://[^\n]*\n)*\s*(\w+)\s*[?]?\s*:", interface_body))
113
+ Parameters
114
+ ----------
115
+ interface_body : str
116
+ A string representation of the interface body of the model.
117
+
118
+ Returns
119
+ -------
120
+ List[str]
121
+ The optional property names from the model interface body.
122
+ """
123
+ pattern = r"^\s*(?:readonly\s+)?(\w+)\s*\?:" # optional properties
94
124
 
95
- # Readonly fields
96
- fields.update(re.findall(r"readonly\s+(\w+)\s*[?]?\s*:", interface_body))
125
+ optional_properties = []
126
+ for match in re.finditer(pattern, interface_body, flags=re.MULTILINE):
127
+ if field_name := match.group(1):
128
+ optional_properties.append(field_name)
97
129
 
98
- return fields
130
+ return optional_properties
@@ -0,0 +1,31 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """
3
+ Example backend model file that has both valid and invalid TS interface files in test_project/frontend.
4
+ """
5
+
6
+ from django.db import models
7
+
8
+ # mypy: ignore-errors
9
+
10
+
11
+ class EventModel(models.Model):
12
+ """
13
+ Model for events that has both valid and invalid corresponding TS interface files.
14
+ """
15
+
16
+ title = models.CharField(max_length=200)
17
+ description = models.TextField()
18
+ organizer = models.ForeignKey("User", on_delete=models.CASCADE)
19
+ participants = models.ManyToManyField("User", related_name="events", blank=True)
20
+ is_private = models.BooleanField(default=True)
21
+ date = models.DateTimeField()
22
+ _private_field = models.CharField(max_length=100) # should be ignored
23
+
24
+
25
+ class UserModel(models.Model):
26
+ """
27
+ Model for users that has both valid and invalid corresponding TS interface files.
28
+ """
29
+
30
+ id = models.CharField(max_length=32)
31
+ name = models.CharField(max_length=50)
@@ -0,0 +1,21 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ // Attn: EventModel is missing a field from backend/models.py.
3
+ export interface Event {
4
+ // Note: EventModel is mapped to Event and EventExtended via backend_to_ts_model_name_conversions.
5
+ title: string;
6
+ organizer: User;
7
+ // Attn: participants is not optional.
8
+ participants: User[];
9
+ }
10
+
11
+ export interface EventExtended extends Event {
12
+ // Attn: date is out of order below, but this won't be reported as we have missing fields.
13
+ // ts-backend-check: ignore field date
14
+ isPrivate: boolean;
15
+ }
16
+
17
+ // Attn: User is not synced to UserModel via backend_to_ts_model_name_conversions.
18
+ export interface User {
19
+ id: string;
20
+ name: string;
21
+ }
@@ -0,0 +1,18 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ export interface Event {
3
+ // Note: EventModel is mapped to Event and EventExtended via backend_to_ts_model_name_conversions.
4
+ title: string;
5
+ description: string;
6
+ organizer: User;
7
+ participants?: User[];
8
+ }
9
+
10
+ export interface EventExtended extends Event {
11
+ isPrivate: boolean;
12
+ // ts-backend-check: ignore field date
13
+ }
14
+
15
+ export interface User {
16
+ id: string;
17
+ name: string;
18
+ }
ts_backend_check/utils.py CHANGED
@@ -3,6 +3,8 @@
3
3
  Utility functions for ts-backend-check.
4
4
  """
5
5
 
6
+ from typing import Any
7
+
6
8
 
7
9
  def snake_to_camel(input_str: str) -> str:
8
10
  """
@@ -20,7 +22,8 @@ def snake_to_camel(input_str: str) -> str:
20
22
 
21
23
  Examples
22
24
  --------
23
- hello_world -> helloWorld, alreadyCamelCase -> alreadyCamelCase
25
+ hello_world -> helloWorld
26
+ alreadyCamelCase -> alreadyCamelCase
24
27
  """
25
28
  if not input_str or input_str.startswith("_"):
26
29
  return input_str
@@ -37,3 +40,24 @@ def snake_to_camel(input_str: str) -> str:
37
40
  result += word[0].upper() + word[1:].lower()
38
41
 
39
42
  return result
43
+
44
+
45
+ def is_ordered_subset(reference_list: list[Any], candidate_sub_list: list[Any]) -> bool:
46
+ """
47
+ Return True if candidate elements appear in the same relative order as they do in the reference.
48
+
49
+ Parameters
50
+ ----------
51
+ reference_list : list
52
+ The original list to reference.
53
+
54
+ candidate_sub_list : list
55
+ A potential list that has elements that are in the same relative order to the reference.
56
+
57
+ Returns
58
+ -------
59
+ bool
60
+ Whether the candidate elements appear in the same relative order as they do in the reference.
61
+ """
62
+ it = iter(reference_list)
63
+ return all(item in it for item in candidate_sub_list)