typesync 0.0.1a1__py3-none-any.whl → 0.0.1a2__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.
- typesync/__init__.py +3 -1
- typesync/annotations.py +27 -0
- typesync/argument_types.py +116 -0
- typesync/cli.py +75 -23
- typesync/codegen/__init__.py +2 -2
- typesync/codegen/extractor.py +122 -51
- typesync/codegen/inference.py +15 -5
- typesync/codegen/writer.py +101 -65
- typesync/misc.py +7 -0
- typesync/type_translators/__init__.py +4 -0
- typesync/type_translators/abstract.py +10 -3
- typesync/type_translators/annotations_translator.py +47 -0
- typesync/type_translators/base_translator.py +14 -7
- typesync/type_translators/context.py +16 -0
- typesync/type_translators/flask_translator.py +2 -7
- typesync/type_translators/type_node.py +28 -4
- {typesync-0.0.1a1.dist-info → typesync-0.0.1a2.dist-info}/METADATA +5 -6
- typesync-0.0.1a2.dist-info/RECORD +23 -0
- typesync-0.0.1a1.dist-info/RECORD +0 -18
- {typesync-0.0.1a1.dist-info → typesync-0.0.1a2.dist-info}/WHEEL +0 -0
- {typesync-0.0.1a1.dist-info → typesync-0.0.1a2.dist-info}/licenses/LICENSE +0 -0
- {typesync-0.0.1a1.dist-info → typesync-0.0.1a2.dist-info}/top_level.txt +0 -0
typesync/__init__.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
__all__ = [
|
|
2
|
+
"annotations",
|
|
2
3
|
"cli",
|
|
3
4
|
"extractor",
|
|
4
5
|
"ts_types",
|
|
@@ -6,8 +7,9 @@ __all__ = [
|
|
|
6
7
|
"utils",
|
|
7
8
|
]
|
|
8
9
|
|
|
9
|
-
from .
|
|
10
|
+
from . import annotations
|
|
10
11
|
from . import type_translators
|
|
11
12
|
from . import utils
|
|
12
13
|
from . import ts_types
|
|
13
14
|
from .cli import cli
|
|
15
|
+
from .codegen import extractor
|
typesync/annotations.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import typing
|
|
2
|
+
|
|
3
|
+
from typesync.misc import HTTPMethod
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TypesyncAnnotation: ...
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TypesyncSkipGenerationAnnotation(TypesyncAnnotation): ...
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TypesyncHTTPMethodAnnotation(TypesyncAnnotation):
|
|
13
|
+
def __init__(self, methods: set[HTTPMethod]) -> None:
|
|
14
|
+
self.methods = methods
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
type SkipGeneration[T] = typing.Annotated[T, TypesyncSkipGenerationAnnotation()]
|
|
18
|
+
|
|
19
|
+
type ForHTTPGet[T] = typing.Annotated[T, TypesyncHTTPMethodAnnotation({"GET"})]
|
|
20
|
+
type ForHTTPHead[T] = typing.Annotated[T, TypesyncHTTPMethodAnnotation({"HEAD"})]
|
|
21
|
+
type ForHTTPPost[T] = typing.Annotated[T, TypesyncHTTPMethodAnnotation({"POST"})]
|
|
22
|
+
type ForHTTPPut[T] = typing.Annotated[T, TypesyncHTTPMethodAnnotation({"PUT"})]
|
|
23
|
+
type ForHTTPDelete[T] = typing.Annotated[T, TypesyncHTTPMethodAnnotation({"DELETE"})]
|
|
24
|
+
type ForHTTPConnect[T] = typing.Annotated[T, TypesyncHTTPMethodAnnotation({"CONNECT"})]
|
|
25
|
+
type ForHTTPOptions[T] = typing.Annotated[T, TypesyncHTTPMethodAnnotation({"OPTIONS"})]
|
|
26
|
+
type ForHTTPTrace[T] = typing.Annotated[T, TypesyncHTTPMethodAnnotation({"TRACE"})]
|
|
27
|
+
type ForHTTPPatch[T] = typing.Annotated[T, TypesyncHTTPMethodAnnotation({"PATCH"})]
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import importlib.util
|
|
3
|
+
import pathlib
|
|
4
|
+
import sys
|
|
5
|
+
import types
|
|
6
|
+
|
|
7
|
+
from click import ParamType
|
|
8
|
+
|
|
9
|
+
from .type_translators import Translator
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PythonModuleParamType(ParamType):
|
|
13
|
+
name = "python_module"
|
|
14
|
+
|
|
15
|
+
def convert(self, value, param, ctx) -> types.ModuleType:
|
|
16
|
+
if isinstance(value, types.ModuleType):
|
|
17
|
+
return value
|
|
18
|
+
|
|
19
|
+
if not isinstance(value, str):
|
|
20
|
+
return self.fail(f"{value!r} is not a valid value", param, ctx)
|
|
21
|
+
|
|
22
|
+
path = pathlib.Path(value).resolve()
|
|
23
|
+
|
|
24
|
+
if not path.exists:
|
|
25
|
+
return self.fail(f"file {value!r} does not exist", param, ctx)
|
|
26
|
+
|
|
27
|
+
if not path.is_file():
|
|
28
|
+
return self.fail(f"{value!r} is not a file", param, ctx)
|
|
29
|
+
|
|
30
|
+
mod_name = f"module_{hashlib.sha256(str(path).encode()).hexdigest()[:16]}"
|
|
31
|
+
spec = importlib.util.spec_from_file_location(mod_name, str(path))
|
|
32
|
+
if spec is None or spec.loader is None:
|
|
33
|
+
return self.fail(f"{value!r} is not a valid python module", param, ctx)
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
module = importlib.util.module_from_spec(spec)
|
|
37
|
+
except ModuleNotFoundError:
|
|
38
|
+
return self.fail(f"{value!r} is not a valid python module", param, ctx)
|
|
39
|
+
|
|
40
|
+
sys.modules[mod_name] = module
|
|
41
|
+
try:
|
|
42
|
+
spec.loader.exec_module(module)
|
|
43
|
+
except Exception as e:
|
|
44
|
+
del sys.modules[mod_name]
|
|
45
|
+
return self.fail(f"could not load module at {value!r} ({e})", param, ctx)
|
|
46
|
+
|
|
47
|
+
return module
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class TranslatorPluginParamType(ParamType):
|
|
51
|
+
name = "translator_plugin"
|
|
52
|
+
|
|
53
|
+
def __init__(self, *args, **kwargs) -> None:
|
|
54
|
+
super().__init__(*args, **kwargs)
|
|
55
|
+
self.python_module_plugin_param_type = PythonModuleParamType()
|
|
56
|
+
|
|
57
|
+
def convert(self, value, param, ctx) -> type[Translator]:
|
|
58
|
+
module = self.python_module_plugin_param_type.convert(value, param, ctx)
|
|
59
|
+
|
|
60
|
+
translator_func = getattr(module, "translator", None)
|
|
61
|
+
if translator_func is None:
|
|
62
|
+
return self.fail(
|
|
63
|
+
f"{value!r} must define a function 'translator() -> type[Translator]'",
|
|
64
|
+
param,
|
|
65
|
+
ctx,
|
|
66
|
+
)
|
|
67
|
+
try:
|
|
68
|
+
translator = translator_func()
|
|
69
|
+
except Exception as e:
|
|
70
|
+
return self.fail(
|
|
71
|
+
f"failed to run the 'translator()' function defined by {value!r} ({e})",
|
|
72
|
+
param,
|
|
73
|
+
ctx,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
if not issubclass(translator, Translator):
|
|
77
|
+
return self.fail(
|
|
78
|
+
f"'translator()' function defined by {value!r} must return a class "
|
|
79
|
+
"that inherits from typesync.type_translators.Translator",
|
|
80
|
+
param,
|
|
81
|
+
ctx,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
return translator
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class TranslatorPriorityParamType(ParamType):
|
|
88
|
+
name = "translator_priority"
|
|
89
|
+
|
|
90
|
+
def convert(self, value, param, ctx) -> tuple[str, int]:
|
|
91
|
+
if isinstance(value, tuple):
|
|
92
|
+
return value
|
|
93
|
+
|
|
94
|
+
if not isinstance(value, str):
|
|
95
|
+
return self.fail(f"{value!r} is not a valid value", param, ctx)
|
|
96
|
+
|
|
97
|
+
id_and_priority = value.rsplit(":", 1)
|
|
98
|
+
if len(id_and_priority) != 2:
|
|
99
|
+
return self.fail(
|
|
100
|
+
f"must be of the form ID:PRIORITY (was {value!r})", param, ctx
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
id_, priority_str = id_and_priority
|
|
104
|
+
try:
|
|
105
|
+
priority = int(priority_str)
|
|
106
|
+
except ValueError:
|
|
107
|
+
return self.fail(
|
|
108
|
+
f"priority {priority_str!r} cannot be converted to an int", param, ctx
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
return id_, priority
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
PYTHON_MODULE = PythonModuleParamType()
|
|
115
|
+
TRANSLATOR_PLUGIN = TranslatorPluginParamType()
|
|
116
|
+
TRANSLATOR_PRIORITY = TranslatorPriorityParamType()
|
typesync/cli.py
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
import os
|
|
2
|
+
import typing
|
|
2
3
|
|
|
3
4
|
import click
|
|
4
5
|
from flask import current_app
|
|
5
6
|
from flask.cli import AppGroup
|
|
7
|
+
from prettytable import PrettyTable
|
|
6
8
|
from werkzeug.routing.rules import Rule
|
|
7
9
|
|
|
8
|
-
from .
|
|
10
|
+
from . import argument_types
|
|
11
|
+
from .codegen import CodeWriter, RouteTypeExtractor
|
|
12
|
+
|
|
13
|
+
if typing.TYPE_CHECKING:
|
|
14
|
+
from .type_translators import Translator
|
|
9
15
|
|
|
10
16
|
|
|
11
17
|
cli = AppGroup("typesync")
|
|
@@ -14,12 +20,37 @@ cli = AppGroup("typesync")
|
|
|
14
20
|
@cli.command(help="Generate Typescript types based on Flask routes.")
|
|
15
21
|
@click.argument("out_dir", type=click.Path(file_okay=False, resolve_path=True))
|
|
16
22
|
@click.option("--endpoint", "-E", help="The base endpoint.", default="")
|
|
17
|
-
@click.option("--samefile", "-S", help="Write types and apis to the same file")
|
|
23
|
+
@click.option("--samefile", "-S", help="Write types and apis to the same file.")
|
|
24
|
+
@click.option(
|
|
25
|
+
"--translator",
|
|
26
|
+
"-t",
|
|
27
|
+
help=(
|
|
28
|
+
"Path to a python script containing a additional type translators. "
|
|
29
|
+
"May be used multiple times."
|
|
30
|
+
),
|
|
31
|
+
type=argument_types.TRANSLATOR_PLUGIN,
|
|
32
|
+
multiple=True,
|
|
33
|
+
)
|
|
34
|
+
@click.option(
|
|
35
|
+
"--translator-priority",
|
|
36
|
+
help=("Set the priority of a translator.May be used multiple times."),
|
|
37
|
+
type=argument_types.TRANSLATOR_PRIORITY,
|
|
38
|
+
multiple=True,
|
|
39
|
+
)
|
|
40
|
+
@click.option(
|
|
41
|
+
"--skip-unannotated",
|
|
42
|
+
type=bool,
|
|
43
|
+
default=True,
|
|
44
|
+
help=(
|
|
45
|
+
"Whether to skip code generation for routes whose annotations are not specified"
|
|
46
|
+
" and could not be inferred. Defaults to True."
|
|
47
|
+
),
|
|
48
|
+
)
|
|
18
49
|
@click.option(
|
|
19
50
|
"--inference",
|
|
20
51
|
"-i",
|
|
21
52
|
is_flag=True,
|
|
22
|
-
help="Whether to use inference when type annotations cannot be resolved",
|
|
53
|
+
help="Whether to use inference when type annotations cannot be resolved.",
|
|
23
54
|
)
|
|
24
55
|
@click.option(
|
|
25
56
|
"--inference-can-eval",
|
|
@@ -31,42 +62,42 @@ cli = AppGroup("typesync")
|
|
|
31
62
|
)
|
|
32
63
|
@click.option(
|
|
33
64
|
"--types-file",
|
|
34
|
-
help="Name of output file containing type definitions (defaults to 'types.ts')",
|
|
65
|
+
help="Name of output file containing type definitions (defaults to 'types.ts').",
|
|
35
66
|
default="types.ts",
|
|
36
67
|
)
|
|
37
68
|
@click.option(
|
|
38
69
|
"--apis-file",
|
|
39
|
-
help="Name of output file containing API functions (defaults to 'apis.ts')",
|
|
70
|
+
help="Name of output file containing API functions (defaults to 'apis.ts').",
|
|
40
71
|
default="apis.ts",
|
|
41
72
|
)
|
|
42
73
|
@click.option(
|
|
43
74
|
"--return-type-format",
|
|
44
|
-
default="{
|
|
75
|
+
default="{r_pc}{m_uc}ReturnType",
|
|
45
76
|
help=(
|
|
46
77
|
"Format string used to generate return type names from the route name. "
|
|
47
78
|
"Available placeholders are: "
|
|
48
|
-
"{
|
|
49
|
-
"{
|
|
50
|
-
"{
|
|
51
|
-
"{
|
|
52
|
-
"{
|
|
53
|
-
"{
|
|
54
|
-
"Defaults to: '{
|
|
79
|
+
"{r_d} or {m_d} (default route name or HTTP method), "
|
|
80
|
+
"{r_cc} or {m_cc} (camelCase), "
|
|
81
|
+
"{r_pc} or {m_pc} (PascalCase), "
|
|
82
|
+
"{r_uc} or {m_uc} (UPPERCASE), "
|
|
83
|
+
"{r_lc} or {m_lc} (lowercase), "
|
|
84
|
+
"{r_sc} or {m_sc} (snake_case). "
|
|
85
|
+
"Defaults to: '{r_pc}{m_uc}ReturnType'."
|
|
55
86
|
),
|
|
56
87
|
)
|
|
57
88
|
@click.option(
|
|
58
89
|
"--args-type-format",
|
|
59
|
-
default="{
|
|
90
|
+
default="{r_pc}{m_uc}ArgsType",
|
|
60
91
|
help=(
|
|
61
92
|
"Format string used to generate argument type names from the route name. "
|
|
62
93
|
"Available placeholders are: "
|
|
63
|
-
"{
|
|
64
|
-
"{
|
|
65
|
-
"{
|
|
66
|
-
"{
|
|
67
|
-
"{
|
|
68
|
-
"{
|
|
69
|
-
"Defaults to: '{
|
|
94
|
+
"{r_d} or {m_d} (default route name or HTTP method), "
|
|
95
|
+
"{r_cc} or {m_cc} (camelCase), "
|
|
96
|
+
"{r_pc} or {m_pc} (PascalCase), "
|
|
97
|
+
"{r_uc} or {m_uc} (UPPERCASE), "
|
|
98
|
+
"{r_lc} or {m_lc} (lowercase), "
|
|
99
|
+
"{r_sc} or {m_sc} (snake_case). "
|
|
100
|
+
"Defaults to: '{r_pc}{m_uc}ArgsType'."
|
|
70
101
|
),
|
|
71
102
|
)
|
|
72
103
|
@click.option(
|
|
@@ -81,14 +112,17 @@ cli = AppGroup("typesync")
|
|
|
81
112
|
"{r_uc} or {m_uc} (UPPERCASE), "
|
|
82
113
|
"{r_lc} or {m_lc} (lowercase), "
|
|
83
114
|
"{r_sc} or {m_sc} (snake_case). "
|
|
84
|
-
"Defaults to: '{m_lc}{r_pc}'"
|
|
115
|
+
"Defaults to: '{m_lc}{r_pc}'."
|
|
85
116
|
),
|
|
86
117
|
)
|
|
87
118
|
def generate(
|
|
88
119
|
out_dir: str,
|
|
89
120
|
endpoint: str,
|
|
121
|
+
translator: tuple[type["Translator"], ...],
|
|
122
|
+
translator_priority: tuple[tuple[str, int], ...],
|
|
90
123
|
inference: bool,
|
|
91
124
|
inference_can_eval: bool,
|
|
125
|
+
skip_unannotated: bool,
|
|
92
126
|
types_file: str,
|
|
93
127
|
apis_file: str,
|
|
94
128
|
return_type_format: str,
|
|
@@ -114,13 +148,31 @@ def generate(
|
|
|
114
148
|
endpoint,
|
|
115
149
|
)
|
|
116
150
|
result = code_writer.write(
|
|
117
|
-
|
|
151
|
+
RouteTypeExtractor(
|
|
118
152
|
current_app,
|
|
119
153
|
rule,
|
|
154
|
+
translators=translator,
|
|
155
|
+
translator_priorities=dict(translator_priority),
|
|
120
156
|
inference_enabled=inference,
|
|
121
157
|
inference_can_eval=inference_can_eval,
|
|
158
|
+
skip_unannotated=skip_unannotated,
|
|
122
159
|
)
|
|
123
160
|
for rule in rules
|
|
124
161
|
)
|
|
125
162
|
if not result:
|
|
126
163
|
click.secho("Errors occurred during file generation", fg="red")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@cli.command(help="Show available translators and their default priorities.")
|
|
167
|
+
def list_translators():
|
|
168
|
+
translators = RouteTypeExtractor.sort_translators(
|
|
169
|
+
RouteTypeExtractor.default_translators(), {}
|
|
170
|
+
)
|
|
171
|
+
table = PrettyTable()
|
|
172
|
+
table.field_names = ["ID", "Priority"]
|
|
173
|
+
table.add_rows(
|
|
174
|
+
[[translator.ID, translator.DEFAULT_PRIORITY] for translator in translators]
|
|
175
|
+
)
|
|
176
|
+
table.align["ID"] = "l"
|
|
177
|
+
table.align["Priority"] = "r"
|
|
178
|
+
print(table)
|
typesync/codegen/__init__.py
CHANGED
typesync/codegen/extractor.py
CHANGED
|
@@ -12,9 +12,11 @@ from werkzeug.routing import (
|
|
|
12
12
|
)
|
|
13
13
|
from werkzeug.routing.rules import Rule
|
|
14
14
|
|
|
15
|
+
|
|
15
16
|
from .inference import infer_return_type
|
|
17
|
+
from typesync.misc import HTTPMethod
|
|
16
18
|
from typesync.ts_types import TSType, TSSimpleType, TSObject
|
|
17
|
-
from typesync.type_translators import TypeNode, to_type_node
|
|
19
|
+
from typesync.type_translators import TranslationContext, TypeNode, to_type_node
|
|
18
20
|
|
|
19
21
|
if typing.TYPE_CHECKING:
|
|
20
22
|
from typesync.type_translators import Translator
|
|
@@ -51,30 +53,50 @@ def get_type_hints(tp: typing.Any) -> dict[str, typing.Any]:
|
|
|
51
53
|
return getattr(tp, "__annotations__", {})
|
|
52
54
|
|
|
53
55
|
|
|
54
|
-
class
|
|
56
|
+
class RouteTypeExtractor:
|
|
55
57
|
def __init__(
|
|
56
58
|
self,
|
|
57
59
|
app: Flask,
|
|
58
60
|
rule: Rule,
|
|
59
|
-
translators: tuple[type["Translator"]] | None = None,
|
|
61
|
+
translators: tuple[type["Translator"], ...] | None = None,
|
|
62
|
+
translator_priorities: dict[str, int] | None = None,
|
|
60
63
|
inference_enabled: bool = False,
|
|
61
64
|
inference_can_eval: bool = False,
|
|
65
|
+
skip_unannotated: bool = True,
|
|
62
66
|
logger: Logger | None = None,
|
|
63
67
|
) -> None:
|
|
64
68
|
self.app = app
|
|
65
69
|
self.rule = rule
|
|
66
70
|
self.inference_enabled = inference_enabled
|
|
67
71
|
self.inference_can_eval = inference_can_eval
|
|
72
|
+
self.skip_unannotated = skip_unannotated
|
|
68
73
|
self.logger = ClickLogger() if logger is None else logger
|
|
69
|
-
self.
|
|
70
|
-
|
|
74
|
+
self.translator_priorities = (
|
|
75
|
+
{} if translator_priorities is None else translator_priorities
|
|
76
|
+
)
|
|
77
|
+
self.translators = self.sort_translators(
|
|
78
|
+
(*self.default_translators(), *(translators or ())),
|
|
79
|
+
self.translator_priorities,
|
|
71
80
|
)
|
|
72
81
|
|
|
73
82
|
@staticmethod
|
|
74
|
-
def
|
|
75
|
-
|
|
83
|
+
def sort_translators[T: type["Translator"] | "Translator"](
|
|
84
|
+
translators: typing.Iterable[T],
|
|
85
|
+
priorities: dict[str, int],
|
|
86
|
+
) -> tuple[T, ...]:
|
|
87
|
+
return tuple(
|
|
88
|
+
sorted(translators, key=lambda t: -priorities.get(t.ID, t.DEFAULT_PRIORITY))
|
|
89
|
+
)
|
|
76
90
|
|
|
77
|
-
|
|
91
|
+
@staticmethod
|
|
92
|
+
def default_translators() -> tuple[type["Translator"], ...]:
|
|
93
|
+
from typesync.type_translators import ( # noqa: PLC0415
|
|
94
|
+
AnnotationsTranslator,
|
|
95
|
+
BaseTranslator,
|
|
96
|
+
FlaskTranslator,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
return (AnnotationsTranslator, BaseTranslator, FlaskTranslator)
|
|
78
100
|
|
|
79
101
|
@property
|
|
80
102
|
def rule_name(self) -> str:
|
|
@@ -88,7 +110,7 @@ class FlaskRouteTypeExtractor:
|
|
|
88
110
|
]
|
|
89
111
|
)
|
|
90
112
|
|
|
91
|
-
def
|
|
113
|
+
def parse_args_types(self) -> dict[HTTPMethod, TSType]:
|
|
92
114
|
try:
|
|
93
115
|
used_converters: dict[str, BaseConverter] = {
|
|
94
116
|
arg: converter
|
|
@@ -98,22 +120,30 @@ class FlaskRouteTypeExtractor:
|
|
|
98
120
|
)
|
|
99
121
|
if converter is not None
|
|
100
122
|
}
|
|
101
|
-
types: list[tuple[str, TSType]] = [
|
|
102
|
-
(arg, self._get_converter_type(arg, converter))
|
|
103
|
-
for arg, converter in used_converters.items()
|
|
104
|
-
]
|
|
105
123
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
124
|
+
results: dict[HTTPMethod, TSType] = {}
|
|
125
|
+
for method in self.rule.methods or set():
|
|
126
|
+
types: list[tuple[str, TSType]] = [
|
|
127
|
+
(arg, self._get_converter_type(arg, converter, method))
|
|
128
|
+
for arg, converter in used_converters.items()
|
|
129
|
+
]
|
|
130
|
+
|
|
131
|
+
results[method] = (
|
|
132
|
+
TSObject([t[0] for t in types], [t[1] for t in types])
|
|
133
|
+
if len(types) > 0
|
|
134
|
+
else TSSimpleType("undefined")
|
|
135
|
+
)
|
|
111
136
|
|
|
112
137
|
except Exception as e:
|
|
113
138
|
self.logger.error(f"couldn't parse argument types ({e})")
|
|
114
|
-
return
|
|
139
|
+
return {}
|
|
140
|
+
|
|
141
|
+
else:
|
|
142
|
+
return results
|
|
115
143
|
|
|
116
|
-
def translate_type(
|
|
144
|
+
def translate_type(
|
|
145
|
+
self, type_: Type, ctx: TranslationContext
|
|
146
|
+
) -> tuple[TSType, str | None]:
|
|
117
147
|
warning = None
|
|
118
148
|
|
|
119
149
|
def translate(
|
|
@@ -131,52 +161,68 @@ class FlaskRouteTypeExtractor:
|
|
|
131
161
|
)
|
|
132
162
|
return TSSimpleType("any")
|
|
133
163
|
|
|
134
|
-
translators = [Translator(translate) for Translator in self.translators]
|
|
164
|
+
translators = [Translator(translate, ctx) for Translator in self.translators]
|
|
135
165
|
node = to_type_node(type_)
|
|
136
166
|
return translate(node, {}), warning
|
|
137
167
|
|
|
138
|
-
def
|
|
168
|
+
def parse_return_types(self, force_inference=False) -> dict[HTTPMethod, TSType]:
|
|
139
169
|
try:
|
|
140
170
|
function = self.app.view_functions[self.rule.endpoint]
|
|
141
171
|
annotations = get_type_hints(function)
|
|
142
|
-
if annotations is not None and "return" in annotations:
|
|
143
|
-
return_annotations = annotations["return"]
|
|
144
|
-
route_return_annotations = self._get_route_annotations(
|
|
145
|
-
return_annotations
|
|
146
|
-
)
|
|
147
|
-
return_type, warning = self.translate_type(route_return_annotations)
|
|
148
|
-
else:
|
|
149
|
-
return_type = warning = None
|
|
150
172
|
|
|
151
|
-
|
|
152
|
-
|
|
173
|
+
ctx = TranslationContext(
|
|
174
|
+
rule=self.rule,
|
|
175
|
+
view_function=function,
|
|
176
|
+
method="GET",
|
|
177
|
+
mode="RETURN",
|
|
178
|
+
inferred=False,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
return_annotations = None
|
|
182
|
+
if (
|
|
183
|
+
not force_inference
|
|
184
|
+
and annotations is not None
|
|
185
|
+
and "return" in annotations
|
|
186
|
+
):
|
|
187
|
+
return_annotations = annotations["return"]
|
|
188
|
+
elif self.inference_enabled:
|
|
189
|
+
return_annotations = infer_return_type(
|
|
153
190
|
function, self.logger, self.inference_can_eval
|
|
154
191
|
)
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
192
|
+
ctx.inferred = True
|
|
193
|
+
|
|
194
|
+
if return_annotations is None and self.skip_unannotated:
|
|
195
|
+
return {}
|
|
159
196
|
|
|
160
|
-
|
|
161
|
-
self.logger.warning(warning)
|
|
197
|
+
route_annotations = self._get_route_annotations(return_annotations)
|
|
162
198
|
|
|
163
|
-
|
|
199
|
+
results: dict[HTTPMethod, TSType] = {}
|
|
200
|
+
for method in self.rule.methods or set():
|
|
201
|
+
ctx.method = method
|
|
202
|
+
result, warning = self.translate_type(route_annotations, ctx)
|
|
203
|
+
|
|
204
|
+
if warning is not None and not ctx.inferred and self.inference_enabled:
|
|
205
|
+
return self.parse_return_types(force_inference=True)
|
|
206
|
+
|
|
207
|
+
results[method] = result or TSSimpleType("any")
|
|
208
|
+
if warning is not None:
|
|
209
|
+
self.logger.warning(warning)
|
|
164
210
|
|
|
165
211
|
except Exception as e:
|
|
166
212
|
self.logger.error(
|
|
167
213
|
f"couldn't parse return type of '{self.rule.endpoint}' ({e})"
|
|
168
214
|
)
|
|
169
|
-
return
|
|
215
|
+
return {}
|
|
170
216
|
|
|
171
217
|
else:
|
|
172
|
-
return
|
|
218
|
+
return results
|
|
173
219
|
|
|
174
|
-
def parse_json_body(self) -> TSType
|
|
220
|
+
def parse_json_body(self) -> dict[HTTPMethod, TSType]:
|
|
175
221
|
try:
|
|
176
222
|
function = self.app.view_functions[self.rule.endpoint]
|
|
177
223
|
json_key = getattr(function, "_typesync", None)
|
|
178
224
|
if json_key is None:
|
|
179
|
-
return
|
|
225
|
+
return {}
|
|
180
226
|
|
|
181
227
|
annotations = get_type_hints(function)
|
|
182
228
|
if json_key not in annotations:
|
|
@@ -184,21 +230,36 @@ class FlaskRouteTypeExtractor:
|
|
|
184
230
|
f"'{self.rule.endpoint}' expected to receive JSON body as keyword "
|
|
185
231
|
f"argument '{json_key}'"
|
|
186
232
|
)
|
|
187
|
-
return
|
|
233
|
+
return {}
|
|
188
234
|
|
|
189
235
|
json_body_annotations = annotations[json_key]
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
self.
|
|
236
|
+
|
|
237
|
+
ctx = TranslationContext(
|
|
238
|
+
rule=self.rule,
|
|
239
|
+
view_function=function,
|
|
240
|
+
method="GET",
|
|
241
|
+
mode="JSON",
|
|
242
|
+
inferred=False,
|
|
243
|
+
)
|
|
244
|
+
results: dict[HTTPMethod, TSType] = {}
|
|
245
|
+
|
|
246
|
+
for method in self.rule.methods or set():
|
|
247
|
+
json_body_type, warning = self.translate_type(
|
|
248
|
+
json_body_annotations, ctx
|
|
249
|
+
)
|
|
250
|
+
if warning is not None:
|
|
251
|
+
self.logger.warning(warning)
|
|
252
|
+
|
|
253
|
+
results[method] = json_body_type
|
|
193
254
|
|
|
194
255
|
except Exception as e:
|
|
195
256
|
self.logger.error(
|
|
196
257
|
f"couldn't parse JSON body type of '{self.rule.endpoint}' ({e})"
|
|
197
258
|
)
|
|
198
|
-
return
|
|
259
|
+
return {}
|
|
199
260
|
|
|
200
261
|
else:
|
|
201
|
-
return
|
|
262
|
+
return results
|
|
202
263
|
|
|
203
264
|
def _get_route_annotations_from_tuple(self, tp: typing.Any) -> Type:
|
|
204
265
|
args = typing.get_args(tp)
|
|
@@ -210,7 +271,9 @@ class FlaskRouteTypeExtractor:
|
|
|
210
271
|
return self._get_route_annotations_from_tuple(tp)
|
|
211
272
|
return tp
|
|
212
273
|
|
|
213
|
-
def _get_converter_type(
|
|
274
|
+
def _get_converter_type(
|
|
275
|
+
self, arg: str, converter: BaseConverter, method: HTTPMethod
|
|
276
|
+
) -> TSType:
|
|
214
277
|
if isinstance(converter, (FloatConverter, IntegerConverter)):
|
|
215
278
|
return TSSimpleType("number")
|
|
216
279
|
if isinstance(converter, (UUIDConverter, PathConverter, UnicodeConverter)):
|
|
@@ -225,8 +288,16 @@ class FlaskRouteTypeExtractor:
|
|
|
225
288
|
)
|
|
226
289
|
return TSSimpleType("string")
|
|
227
290
|
|
|
291
|
+
ctx = TranslationContext(
|
|
292
|
+
rule=self.rule,
|
|
293
|
+
view_function=self.app.view_functions[self.rule.endpoint],
|
|
294
|
+
method=method,
|
|
295
|
+
mode="ARGS",
|
|
296
|
+
inferred=False,
|
|
297
|
+
)
|
|
298
|
+
|
|
228
299
|
return_annotations = annotations["return"]
|
|
229
|
-
return_type, warning = self.translate_type(return_annotations)
|
|
300
|
+
return_type, warning = self.translate_type(return_annotations, ctx)
|
|
230
301
|
if warning is not None:
|
|
231
302
|
self.logger.warning(warning)
|
|
232
303
|
return return_type
|
typesync/codegen/inference.py
CHANGED
|
@@ -14,7 +14,7 @@ class ASTVisitor(ast.NodeVisitor):
|
|
|
14
14
|
def __init__(
|
|
15
15
|
self, function: typing.Callable, logger: "Logger", can_eval: bool = False
|
|
16
16
|
) -> None:
|
|
17
|
-
self.function = function
|
|
17
|
+
self.function: typing.Callable = function
|
|
18
18
|
self.logger = logger
|
|
19
19
|
self.can_eval = can_eval
|
|
20
20
|
self.locals: dict[str, typing.Any] = {}
|
|
@@ -24,7 +24,8 @@ class ASTVisitor(ast.NodeVisitor):
|
|
|
24
24
|
local_var = self.locals.get(name.id, None)
|
|
25
25
|
if local_var is not None:
|
|
26
26
|
return local_var
|
|
27
|
-
|
|
27
|
+
globals_dict = getattr(self.function, "__globals__", {})
|
|
28
|
+
global_var = globals_dict.get(name.id, None)
|
|
28
29
|
if global_var is not None:
|
|
29
30
|
return global_var
|
|
30
31
|
builtin = getattr(builtins, name.id, None)
|
|
@@ -68,7 +69,11 @@ class ASTVisitor(ast.NodeVisitor):
|
|
|
68
69
|
return tuple[types]
|
|
69
70
|
|
|
70
71
|
def get_dict(self, dict_: ast.Dict) -> typing.Any:
|
|
71
|
-
|
|
72
|
+
if None in dict_.keys:
|
|
73
|
+
# TODO: support unpacking
|
|
74
|
+
return dict
|
|
75
|
+
keys = typing.cast(list[ast.expr], dict_.keys)
|
|
76
|
+
keys_type = self.get_type_if_all_equal(keys)
|
|
72
77
|
values_type = self.get_type_if_all_equal(dict_.values)
|
|
73
78
|
|
|
74
79
|
if keys_type is None and values_type is None:
|
|
@@ -149,6 +154,10 @@ class ASTVisitor(ast.NodeVisitor):
|
|
|
149
154
|
self.generic_visit(node)
|
|
150
155
|
|
|
151
156
|
def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
|
|
157
|
+
target = node.target
|
|
158
|
+
if not isinstance(target, ast.Name):
|
|
159
|
+
# TODO
|
|
160
|
+
return
|
|
152
161
|
annotation = self.get_value(node.annotation)
|
|
153
162
|
if annotation is None and self.can_eval:
|
|
154
163
|
annotation_string = ast.unparse(node.annotation)
|
|
@@ -159,14 +168,15 @@ class ASTVisitor(ast.NodeVisitor):
|
|
|
159
168
|
0,
|
|
160
169
|
)
|
|
161
170
|
try:
|
|
162
|
-
|
|
171
|
+
function_globals = getattr(self.function, "__globals__", {})
|
|
172
|
+
annotation = eval(annotation_code, function_globals, {}) # noqa: S307
|
|
163
173
|
except Exception as e:
|
|
164
174
|
self.logger.warning(
|
|
165
175
|
f"failed to parse annotation {annotation_string!r}: {e!s}"
|
|
166
176
|
)
|
|
167
177
|
annotation = None
|
|
168
178
|
if annotation is not None:
|
|
169
|
-
self.locals[
|
|
179
|
+
self.locals[target.id] = annotation
|
|
170
180
|
self.generic_visit(node)
|
|
171
181
|
|
|
172
182
|
def visit_Assign(self, node: ast.Assign) -> None:
|
typesync/codegen/writer.py
CHANGED
|
@@ -2,10 +2,12 @@ import typing
|
|
|
2
2
|
|
|
3
3
|
import inflection
|
|
4
4
|
|
|
5
|
+
from typesync.ts_types import TSType, TSSimpleType
|
|
6
|
+
|
|
5
7
|
if typing.TYPE_CHECKING:
|
|
6
8
|
from io import TextIOBase
|
|
7
|
-
from typesync.
|
|
8
|
-
from .extractor import
|
|
9
|
+
from typesync.misc import HTTPMethod
|
|
10
|
+
from .extractor import RouteTypeExtractor
|
|
9
11
|
|
|
10
12
|
|
|
11
13
|
def make_rule_name_map(rule_name: str, prefix: str = "") -> dict[str, str]:
|
|
@@ -19,6 +21,12 @@ def make_rule_name_map(rule_name: str, prefix: str = "") -> dict[str, str]:
|
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
|
|
24
|
+
class TypesDict(typing.TypedDict):
|
|
25
|
+
return_type: TSType
|
|
26
|
+
args_type: TSType
|
|
27
|
+
json_body_type: TSType
|
|
28
|
+
|
|
29
|
+
|
|
22
30
|
class CodeWriter:
|
|
23
31
|
def __init__(
|
|
24
32
|
self,
|
|
@@ -45,41 +53,64 @@ class CodeWriter:
|
|
|
45
53
|
{**make_rule_name_map(rule_name, "r_"), **make_rule_name_map(method, "m_")}
|
|
46
54
|
)
|
|
47
55
|
|
|
48
|
-
def _return_type_name(self, rule_name: str) -> str:
|
|
49
|
-
return self.return_type_format.format_map(
|
|
56
|
+
def _return_type_name(self, rule_name: str, method: str) -> str:
|
|
57
|
+
return self.return_type_format.format_map(
|
|
58
|
+
{**make_rule_name_map(rule_name, "r_"), **make_rule_name_map(method, "m_")}
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def _params_type_name(self, rule_name: str, method: str) -> str:
|
|
62
|
+
return self.params_type_format.format_map(
|
|
63
|
+
{**make_rule_name_map(rule_name, "r_"), **make_rule_name_map(method, "m_")}
|
|
64
|
+
)
|
|
50
65
|
|
|
51
|
-
def
|
|
52
|
-
|
|
66
|
+
def _bundle_types_per_method(
|
|
67
|
+
self,
|
|
68
|
+
return_types: dict["HTTPMethod", TSType],
|
|
69
|
+
args_types: dict["HTTPMethod", TSType],
|
|
70
|
+
json_body_types: dict["HTTPMethod", TSType],
|
|
71
|
+
) -> dict["HTTPMethod", TypesDict]:
|
|
72
|
+
methods = {*return_types.keys(), *args_types.keys(), *json_body_types.keys()}
|
|
73
|
+
return {
|
|
74
|
+
method: TypesDict(
|
|
75
|
+
return_type=return_types.get(method, TSSimpleType("undefined")),
|
|
76
|
+
args_type=args_types.get(method, TSSimpleType("undefined")),
|
|
77
|
+
json_body_type=json_body_types.get(method, TSSimpleType("undefined")),
|
|
78
|
+
)
|
|
79
|
+
for method in methods
|
|
80
|
+
}
|
|
53
81
|
|
|
54
|
-
def write(self, parsers: typing.Iterable["
|
|
82
|
+
def write(self, parsers: typing.Iterable["RouteTypeExtractor"]) -> bool:
|
|
55
83
|
error = False
|
|
56
84
|
self._write_types_header()
|
|
57
85
|
self._write_api_header()
|
|
58
86
|
names: list[str] = []
|
|
59
87
|
for parser in parsers:
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
return False
|
|
67
|
-
continue
|
|
68
|
-
return_type_name, params_type_name, has_args, has_json = self._write_types(
|
|
69
|
-
parser.rule_name, return_type, args_type, json_body_type
|
|
88
|
+
return_types = parser.parse_return_types()
|
|
89
|
+
args_types = parser.parse_args_types()
|
|
90
|
+
json_body_types = parser.parse_json_body()
|
|
91
|
+
|
|
92
|
+
types_per_method = self._bundle_types_per_method(
|
|
93
|
+
return_types, args_types, json_body_types
|
|
70
94
|
)
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
95
|
+
|
|
96
|
+
for (
|
|
97
|
+
method,
|
|
98
|
+
return_type_name,
|
|
99
|
+
params_type_name,
|
|
100
|
+
has_args,
|
|
101
|
+
has_json,
|
|
102
|
+
) in self._write_types(parser.rule_name, types_per_method):
|
|
103
|
+
names.append(
|
|
104
|
+
self._write_api_function(
|
|
105
|
+
parser.rule_name,
|
|
106
|
+
parser.rule_url,
|
|
107
|
+
method,
|
|
108
|
+
return_type_name,
|
|
109
|
+
params_type_name,
|
|
110
|
+
has_args,
|
|
111
|
+
has_json,
|
|
112
|
+
)
|
|
80
113
|
)
|
|
81
|
-
for method in (parser.rule.methods or {})
|
|
82
|
-
)
|
|
83
114
|
self._write_api_footer(names)
|
|
84
115
|
return not error
|
|
85
116
|
|
|
@@ -153,43 +184,48 @@ class CodeWriter:
|
|
|
153
184
|
def _write_types(
|
|
154
185
|
self,
|
|
155
186
|
rule_name: str,
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
187
|
+
types_per_method: dict["HTTPMethod", TypesDict],
|
|
188
|
+
) -> typing.Generator[tuple[str, str, str, bool, bool], None, None]:
|
|
189
|
+
for method, types in types_per_method.items():
|
|
190
|
+
return_type_name = self._return_type_name(rule_name, method)
|
|
191
|
+
generated_return_type = types["return_type"].generate(return_type_name)
|
|
192
|
+
self.types_file.write(
|
|
193
|
+
f"export type {return_type_name} = {generated_return_type};\n"
|
|
194
|
+
)
|
|
195
|
+
params_type_name = self._params_type_name(rule_name, method)
|
|
196
|
+
optional_args = "?" if types["args_type"] == "undefined" else ""
|
|
197
|
+
json_body_type = types["json_body_type"]
|
|
198
|
+
optional_body = (
|
|
199
|
+
"?" if json_body_type is None or json_body_type == "undefined" else ""
|
|
200
|
+
)
|
|
170
201
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
202
|
+
internal_args_name = f"_{rule_name}{method}Args"
|
|
203
|
+
generated_args_type = types["args_type"].generate(internal_args_name)
|
|
204
|
+
self.types_file.write(
|
|
205
|
+
f"type {internal_args_name} = {generated_args_type};\n"
|
|
206
|
+
)
|
|
175
207
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
208
|
+
internal_body_name = f"_{rule_name}{method}Body"
|
|
209
|
+
string_json_body_type = (
|
|
210
|
+
"undefined"
|
|
211
|
+
if types["json_body_type"] is None
|
|
212
|
+
else types["json_body_type"].generate(internal_body_name)
|
|
213
|
+
)
|
|
214
|
+
self.types_file.write(
|
|
215
|
+
f"type {internal_body_name} = {string_json_body_type};\n"
|
|
216
|
+
)
|
|
183
217
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
218
|
+
self.types_file.write(
|
|
219
|
+
f"export interface {params_type_name} extends RequestArgs" + " {\n"
|
|
220
|
+
f" args{optional_args}: {internal_args_name};\n"
|
|
221
|
+
f" body{optional_body}: {internal_body_name};\n"
|
|
222
|
+
"}\n\n"
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
yield (
|
|
226
|
+
method,
|
|
227
|
+
return_type_name,
|
|
228
|
+
params_type_name,
|
|
229
|
+
optional_args == "",
|
|
230
|
+
optional_body == "",
|
|
231
|
+
)
|
typesync/misc.py
ADDED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
__all__ = [
|
|
2
|
+
"AnnotationsTranslator",
|
|
2
3
|
"BaseTranslator",
|
|
3
4
|
"FlaskTranslator",
|
|
5
|
+
"TranslationContext",
|
|
4
6
|
"Translator",
|
|
5
7
|
"TypeNode",
|
|
6
8
|
"to_type_node",
|
|
7
9
|
]
|
|
8
10
|
|
|
11
|
+
from .annotations_translator import AnnotationsTranslator
|
|
9
12
|
from .base_translator import BaseTranslator
|
|
10
13
|
from .flask_translator import FlaskTranslator
|
|
14
|
+
from .context import TranslationContext
|
|
11
15
|
from .abstract import Translator
|
|
12
16
|
from .type_node import TypeNode, to_type_node
|
|
@@ -1,19 +1,26 @@
|
|
|
1
1
|
import abc
|
|
2
2
|
import typing
|
|
3
3
|
|
|
4
|
+
|
|
4
5
|
if typing.TYPE_CHECKING:
|
|
5
6
|
from . import TypeNode
|
|
7
|
+
from .context import TranslationContext
|
|
6
8
|
from typesync.ts_types import TSType
|
|
7
9
|
|
|
8
10
|
|
|
9
11
|
class Translator(abc.ABC):
|
|
10
|
-
|
|
12
|
+
DEFAULT_PRIORITY: int = 0
|
|
13
|
+
ID: str
|
|
14
|
+
|
|
11
15
|
def __init__(
|
|
12
16
|
self,
|
|
13
|
-
|
|
17
|
+
translate: typing.Callable[
|
|
14
18
|
["TypeNode", dict[typing.TypeVar, "TSType"] | None], "TSType"
|
|
15
19
|
],
|
|
16
|
-
|
|
20
|
+
ctx: "TranslationContext",
|
|
21
|
+
) -> None:
|
|
22
|
+
self._translate = translate
|
|
23
|
+
self.ctx = ctx
|
|
17
24
|
|
|
18
25
|
@abc.abstractmethod
|
|
19
26
|
def translate(
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import typing
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
from .abstract import Translator
|
|
5
|
+
from .type_node import TypeNode
|
|
6
|
+
from typesync import annotations
|
|
7
|
+
from typesync.ts_types import TSSimpleType, TSType
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AnnotationsTranslator(Translator):
|
|
11
|
+
DEFAULT_PRIORITY = -100
|
|
12
|
+
ID = "typesync.AnnotationsTranslator"
|
|
13
|
+
|
|
14
|
+
def _translate_http_method_annotation(
|
|
15
|
+
self,
|
|
16
|
+
node: TypeNode,
|
|
17
|
+
annotation: annotations.TypesyncHTTPMethodAnnotation,
|
|
18
|
+
generics: dict[typing.TypeVar, TSType] | None,
|
|
19
|
+
) -> TSType | None:
|
|
20
|
+
if self.ctx.method in annotation.methods:
|
|
21
|
+
return self._translate(node, generics)
|
|
22
|
+
return TSSimpleType("never")
|
|
23
|
+
|
|
24
|
+
def _translate_annotation(
|
|
25
|
+
self,
|
|
26
|
+
node: TypeNode,
|
|
27
|
+
annotation: typing.Any,
|
|
28
|
+
generics: dict[typing.TypeVar, TSType] | None,
|
|
29
|
+
) -> TSType | None:
|
|
30
|
+
match annotation:
|
|
31
|
+
case annotations.TypesyncHTTPMethodAnnotation():
|
|
32
|
+
return self._translate_http_method_annotation(
|
|
33
|
+
node, annotation, generics
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
return self._translate(node, generics)
|
|
37
|
+
|
|
38
|
+
def translate(
|
|
39
|
+
self, node: TypeNode, generics: dict[typing.TypeVar, TSType] | None
|
|
40
|
+
) -> TSType | None:
|
|
41
|
+
if node.origin is not typing.Annotated:
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
if len(node.args) != 1:
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
return self._translate_annotation(node.args[0], node.annotation, generics)
|
|
@@ -17,13 +17,8 @@ from typesync.ts_types import (
|
|
|
17
17
|
|
|
18
18
|
|
|
19
19
|
class BaseTranslator(Translator):
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
translate: typing.Callable[
|
|
23
|
-
[TypeNode, dict[typing.TypeVar, TSType] | None], TSType
|
|
24
|
-
],
|
|
25
|
-
):
|
|
26
|
-
self._translate = translate
|
|
20
|
+
DEFAULT_PRIORITY = 0
|
|
21
|
+
ID = "typesync.BaseTranslator"
|
|
27
22
|
|
|
28
23
|
def _unwrap_generic(
|
|
29
24
|
self, node: TypeNode, generics: dict[typing.TypeVar, TSType]
|
|
@@ -71,6 +66,15 @@ class BaseTranslator(Translator):
|
|
|
71
66
|
def _translate_complex_type(
|
|
72
67
|
self, node: TypeNode, generics: dict[typing.TypeVar, TSType]
|
|
73
68
|
) -> TSType | None:
|
|
69
|
+
if node.origin not in {
|
|
70
|
+
dict,
|
|
71
|
+
list,
|
|
72
|
+
tuple,
|
|
73
|
+
types.UnionType,
|
|
74
|
+
typing.Union,
|
|
75
|
+
}:
|
|
76
|
+
return None
|
|
77
|
+
|
|
74
78
|
translated_args = self._translate_args(node.args, generics)
|
|
75
79
|
|
|
76
80
|
if node.origin is dict:
|
|
@@ -132,6 +136,9 @@ class BaseTranslator(Translator):
|
|
|
132
136
|
if node.origin is RecursiveCall:
|
|
133
137
|
return self._translate_recursive_call(node, generics)
|
|
134
138
|
|
|
139
|
+
if isinstance(node.origin, typing.TypeVar):
|
|
140
|
+
return generics.get(node.origin)
|
|
141
|
+
|
|
135
142
|
if isinstance(node.origin, typing.TypeAliasType):
|
|
136
143
|
return self._translate_type_alias(node, generics)
|
|
137
144
|
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import dataclasses
|
|
2
|
+
import typing
|
|
3
|
+
|
|
4
|
+
from flask.typing import RouteCallable
|
|
5
|
+
from werkzeug.routing.rules import Rule
|
|
6
|
+
|
|
7
|
+
from typesync.misc import HTTPMethod
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclasses.dataclass
|
|
11
|
+
class TranslationContext:
|
|
12
|
+
rule: Rule
|
|
13
|
+
view_function: RouteCallable
|
|
14
|
+
method: HTTPMethod
|
|
15
|
+
mode: typing.Literal["JSON", "RETURN", "ARGS"]
|
|
16
|
+
inferred: bool
|
|
@@ -7,13 +7,8 @@ from typesync.utils import Response
|
|
|
7
7
|
|
|
8
8
|
|
|
9
9
|
class FlaskTranslator(Translator):
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
translate: typing.Callable[
|
|
13
|
-
[TypeNode, dict[typing.TypeVar, TSType] | None], TSType
|
|
14
|
-
],
|
|
15
|
-
) -> None:
|
|
16
|
-
self._translate = translate
|
|
10
|
+
DEFAULT_PRIORITY = -10
|
|
11
|
+
ID = "typesync.FlaskTranslator"
|
|
17
12
|
|
|
18
13
|
def translate(
|
|
19
14
|
self, node: TypeNode, generics: dict[typing.TypeVar, TSType] | None
|
|
@@ -17,6 +17,7 @@ class TypeNode:
|
|
|
17
17
|
args: tuple["TypeNode", ...]
|
|
18
18
|
hints: dict[str, "TypeNode"]
|
|
19
19
|
value: "TypeNode | None"
|
|
20
|
+
annotation: typing.Any
|
|
20
21
|
|
|
21
22
|
def __repr__(self) -> str:
|
|
22
23
|
s = f"<TypeNode {getattr(self.origin, '__name__', self.origin)}"
|
|
@@ -28,6 +29,8 @@ class TypeNode:
|
|
|
28
29
|
s += f" hints={self.hints}"
|
|
29
30
|
if self.value is not None:
|
|
30
31
|
s += f" value={self.value}"
|
|
32
|
+
if self.annotation is not None:
|
|
33
|
+
s += f" annotation={self.annotation}"
|
|
31
34
|
return s + ">"
|
|
32
35
|
|
|
33
36
|
|
|
@@ -37,9 +40,17 @@ def to_type_node(
|
|
|
37
40
|
mapping = {} if mapping is None else mapping
|
|
38
41
|
origin = typing.get_origin(type_) or type_
|
|
39
42
|
params: tuple[typing.TypeVar, ...] = getattr(origin, "__type_params__", ())
|
|
40
|
-
args =
|
|
41
|
-
|
|
42
|
-
|
|
43
|
+
args = ()
|
|
44
|
+
annotations = ()
|
|
45
|
+
if origin is not typing.Annotated:
|
|
46
|
+
args = tuple(
|
|
47
|
+
to_type_node(arg, original_type, mapping) for arg in typing.get_args(type_)
|
|
48
|
+
)
|
|
49
|
+
else:
|
|
50
|
+
base_args = typing.get_args(type_)
|
|
51
|
+
if len(base_args) > 0:
|
|
52
|
+
args = (to_type_node(base_args[0], original_type, mapping),)
|
|
53
|
+
annotations = base_args[1:]
|
|
43
54
|
hints = {
|
|
44
55
|
key: to_type_node(hint, original_type, mapping)
|
|
45
56
|
for key, hint in getattr(origin, "__annotations__", {}).items()
|
|
@@ -61,11 +72,12 @@ def to_type_node(
|
|
|
61
72
|
):
|
|
62
73
|
origin = RecursiveCall
|
|
63
74
|
|
|
64
|
-
|
|
75
|
+
node = TypeNode(
|
|
65
76
|
origin=origin,
|
|
66
77
|
params=params,
|
|
67
78
|
args=args,
|
|
68
79
|
hints=hints,
|
|
80
|
+
annotation=annotations[0] if len(annotations) else None,
|
|
69
81
|
value=(
|
|
70
82
|
to_type_node(
|
|
71
83
|
origin.__value__,
|
|
@@ -75,6 +87,7 @@ def to_type_node(
|
|
|
75
87
|
args=patched_args,
|
|
76
88
|
hints=hints,
|
|
77
89
|
value=None,
|
|
90
|
+
annotation=(),
|
|
78
91
|
),
|
|
79
92
|
mapping=mapping,
|
|
80
93
|
)
|
|
@@ -82,3 +95,14 @@ def to_type_node(
|
|
|
82
95
|
else None
|
|
83
96
|
),
|
|
84
97
|
)
|
|
98
|
+
for annotation in annotations[1:]:
|
|
99
|
+
new_node = TypeNode(
|
|
100
|
+
origin=typing.Annotated,
|
|
101
|
+
params=params,
|
|
102
|
+
args=(node,),
|
|
103
|
+
hints={},
|
|
104
|
+
annotation=annotation,
|
|
105
|
+
value=None,
|
|
106
|
+
)
|
|
107
|
+
node = new_node
|
|
108
|
+
return node
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: typesync
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.1a2
|
|
4
4
|
Summary: Auto-generate TypeScript client code from Flask routes and Python type annotations.
|
|
5
5
|
Author-email: Francisco Rodrigues <francisco.rodrigues0908@gmail.com>
|
|
6
6
|
Project-URL: Homepage, https://github.com/ArmindoFlores/typesync
|
|
@@ -11,6 +11,7 @@ License-File: LICENSE
|
|
|
11
11
|
Requires-Dist: click
|
|
12
12
|
Requires-Dist: flask
|
|
13
13
|
Requires-Dist: inflection>=0.5.1
|
|
14
|
+
Requires-Dist: prettytable>=3.17.0
|
|
14
15
|
Dynamic: license-file
|
|
15
16
|
|
|
16
17
|
# Typesync
|
|
@@ -36,9 +37,9 @@ Some features are already implemented, others are planned.
|
|
|
36
37
|
- [x] Support for multiple HTTP methods (GET, POST, PUT, DELETE, etc.)
|
|
37
38
|
- [x] Support for JSON request bodies with typed parameters
|
|
38
39
|
- [ ] Support validators such as pydantic
|
|
39
|
-
- [
|
|
40
|
+
- [x] Support for `typing.Annotated` to:
|
|
40
41
|
- [ ] Ignore specific routes
|
|
41
|
-
- [
|
|
42
|
+
- [x] Customize generation behavior (naming, visibility, etc.)
|
|
42
43
|
- [ ] Improved error reporting for unsupported or ambiguous annotations
|
|
43
44
|
- [ ] Optional generation modes (types only, requests only)
|
|
44
45
|
- [ ] Configuration file support
|
|
@@ -52,9 +53,7 @@ Some features are already implemented, others are planned.
|
|
|
52
53
|
|
|
53
54
|
## Installation
|
|
54
55
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
Clone the repository and install dependencies:
|
|
56
|
+
You can install typesync using `pip install typesync`. Alternatively, you can do it directly from source, preferably using `uv`, by cloning the repository and installing dependencies:
|
|
58
57
|
|
|
59
58
|
```bash
|
|
60
59
|
git clone https://github.com/ArmindoFlores/typesync
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
typesync/__init__.py,sha256=cxlYkEhU2kEV8AB0elVJoS45IZkQ7FF-8el_NuY7c6o,267
|
|
2
|
+
typesync/annotations.py,sha256=ZGy_kcuXZMYV-Zv9CKcMJNH4y6fDpMS3BsnVvEedaAk,1135
|
|
3
|
+
typesync/argument_types.py,sha256=x_2kYlMq2fH7B_8wwmpWa_tcduU76fJmKlMH1s9txKQ,3687
|
|
4
|
+
typesync/cli.py,sha256=GdNDQYHBZ6lAbtYwMLFAZDdWIr73hbUmEA4TDY0beo8,5435
|
|
5
|
+
typesync/misc.py,sha256=ye2FQJC7cBU6RtEgGjMkZUXRAHGPOrQIYXwcuDVoNo0,182
|
|
6
|
+
typesync/ts_types.py,sha256=A5mJElZg7RyM58x9HGO9_XzXb9pznEPMDfO1yRUUzE8,3448
|
|
7
|
+
typesync/utils.py,sha256=0Mp8D9eLz12MJb5a3u2x0hqUffiLK-OUBpUhP4LuXv0,2131
|
|
8
|
+
typesync/codegen/__init__.py,sha256=mS7UpHXh6pQdRyXN3-KvRL0DgAulTE9hRDnqIvl6zQ8,132
|
|
9
|
+
typesync/codegen/extractor.py,sha256=Ho9hk2FnegNccXw3TN0-qmTdPNxMjg5blXXwwmyYsCA,10056
|
|
10
|
+
typesync/codegen/inference.py,sha256=U7t3a6LJldQPvpChOAtFffL0Tu2jB0063G3E4C81rbE,8320
|
|
11
|
+
typesync/codegen/writer.py,sha256=T_eliJ3d0Iu9B9bw3PmnsPajTN1sDOTag_t9-LKkuio,8500
|
|
12
|
+
typesync/type_translators/__init__.py,sha256=ugOt2XsDXc0B-Sp5HjbApZyyCmbAW4SKHKb2B6nCfz4,436
|
|
13
|
+
typesync/type_translators/abstract.py,sha256=WvMNcIMnzwy12eObck00GFklsMbr6bkPv1odQzkLCA4,652
|
|
14
|
+
typesync/type_translators/annotations_translator.py,sha256=LOFFBCK1-bIgscrtM7vVIaLFyM30fKC8CgUBf6_3AJE,1416
|
|
15
|
+
typesync/type_translators/base_translator.py,sha256=FSEt2mshN6QdUude65PvNfKgueRH6v2npLxNTlGSajk,5122
|
|
16
|
+
typesync/type_translators/context.py,sha256=qbffuT5UAmCbkN7KFzUPkYijQiuFl4PCCcv3uv1XpMc,343
|
|
17
|
+
typesync/type_translators/flask_translator.py,sha256=8CSg1VdszkfSksSDHEnXARHugExaov52hopqLhOzhEE,552
|
|
18
|
+
typesync/type_translators/type_node.py,sha256=ef02XyeHFn5EXJ59DYaQ8zA_MAfpoI1_1mF-CFHm1iM,3217
|
|
19
|
+
typesync-0.0.1a2.dist-info/licenses/LICENSE,sha256=7MsKRg4kFMMFoz-h2zVg6sZ1rxQU6HuvrECEz1evDAY,1059
|
|
20
|
+
typesync-0.0.1a2.dist-info/METADATA,sha256=jFoeoDYgxLMEAKN7quyj9TjDj9TPIHjEV6ScS844IL0,4376
|
|
21
|
+
typesync-0.0.1a2.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
22
|
+
typesync-0.0.1a2.dist-info/top_level.txt,sha256=clLTcTcqWa7iQrgM1b_v3GHC1Z7W1uqiLUzXeGInWRs,9
|
|
23
|
+
typesync-0.0.1a2.dist-info/RECORD,,
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
typesync/__init__.py,sha256=KhtqS2tUcczOFAMQIkuQdRi7bBY21IuwcMeR5V45g6g,222
|
|
2
|
-
typesync/cli.py,sha256=ATa0O1Gr2q9j1MxfmcBrIWij6-6iiAkIW1OC5mQLKM0,3655
|
|
3
|
-
typesync/ts_types.py,sha256=A5mJElZg7RyM58x9HGO9_XzXb9pznEPMDfO1yRUUzE8,3448
|
|
4
|
-
typesync/utils.py,sha256=0Mp8D9eLz12MJb5a3u2x0hqUffiLK-OUBpUhP4LuXv0,2131
|
|
5
|
-
typesync/codegen/__init__.py,sha256=TD41Nti-hXO7xJi9HHr4QTFKIY4Wv_3QJtLSl718lpw,142
|
|
6
|
-
typesync/codegen/extractor.py,sha256=HK8-FbmO-memDtZEbNsV3ukuBo2oC7GD9lspFZgYL3M,7938
|
|
7
|
-
typesync/codegen/inference.py,sha256=JZcictBq_jDGW6bJycQi53oQtJkkNBdk5cDp2WYXj60,7934
|
|
8
|
-
typesync/codegen/writer.py,sha256=a8mE3cdKmpBmXLnqM0IkpXwZlRc713E_AzQtTHnaEmY,7051
|
|
9
|
-
typesync/type_translators/__init__.py,sha256=mNpgJLMZW_p_7bp3l23gZ6BUrqSt_mrjXJixhOKZzeI,283
|
|
10
|
-
typesync/type_translators/abstract.py,sha256=nTg6sjJCxXGJD5hW9FAtgel8ZUWJPNgD6y7q7xUy54o,496
|
|
11
|
-
typesync/type_translators/base_translator.py,sha256=wD9PUkBLJP8gz5l5WoelP2m3evJyXgQrdUocUgT628Q,4976
|
|
12
|
-
typesync/type_translators/flask_translator.py,sha256=PezojREhL9GShJn4cXHrmoxs3Rrsgpk0miopqUB3nzQ,687
|
|
13
|
-
typesync/type_translators/type_node.py,sha256=wF60KWXuXtNExq_ZlcxHj99rEnIw0VyxtNGhSW6Vhrk,2428
|
|
14
|
-
typesync-0.0.1a1.dist-info/licenses/LICENSE,sha256=7MsKRg4kFMMFoz-h2zVg6sZ1rxQU6HuvrECEz1evDAY,1059
|
|
15
|
-
typesync-0.0.1a1.dist-info/METADATA,sha256=MPEuQMs0m8NUKDUATdI9IlNOHzLvxCNAV0sR8MQHcaw,4323
|
|
16
|
-
typesync-0.0.1a1.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
17
|
-
typesync-0.0.1a1.dist-info/top_level.txt,sha256=clLTcTcqWa7iQrgM1b_v3GHC1Z7W1uqiLUzXeGInWRs,9
|
|
18
|
-
typesync-0.0.1a1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|