exdrf-ts 0.1.17__tar.gz
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.
- exdrf_ts-0.1.17/PKG-INFO +68 -0
- exdrf_ts-0.1.17/README.md +39 -0
- exdrf_ts-0.1.17/exdrf_ts/__init__.py +17 -0
- exdrf_ts-0.1.17/exdrf_ts/__version__.py +24 -0
- exdrf_ts-0.1.17/exdrf_ts/json_schema_ts.py +238 -0
- exdrf_ts-0.1.17/exdrf_ts/mapping.py +159 -0
- exdrf_ts-0.1.17/exdrf_ts/py.typed +0 -0
- exdrf_ts-0.1.17/exdrf_ts.egg-info/PKG-INFO +68 -0
- exdrf_ts-0.1.17/exdrf_ts.egg-info/SOURCES.txt +14 -0
- exdrf_ts-0.1.17/exdrf_ts.egg-info/dependency_links.txt +1 -0
- exdrf_ts-0.1.17/exdrf_ts.egg-info/requires.txt +17 -0
- exdrf_ts-0.1.17/exdrf_ts.egg-info/top_level.txt +3 -0
- exdrf_ts-0.1.17/pyproject.toml +63 -0
- exdrf_ts-0.1.17/setup.cfg +4 -0
- exdrf_ts-0.1.17/tests/json_schema_ts_test.py +100 -0
- exdrf_ts-0.1.17/tests/mapping_test.py +58 -0
exdrf_ts-0.1.17/PKG-INFO
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: exdrf-ts
|
|
3
|
+
Version: 0.1.17
|
|
4
|
+
Summary: Map exdrf field and Python types to TypeScript for codegen.
|
|
5
|
+
Author-email: Nicu Tofan <nicu.tofan@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Classifier: Operating System :: OS Independent
|
|
8
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Typing :: Typed
|
|
11
|
+
Requires-Python: >=3.12.2
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
Requires-Dist: exdrf>=0.1.17
|
|
14
|
+
Requires-Dist: exdrf-pd>=0.1.18
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: autoflake; extra == "dev"
|
|
17
|
+
Requires-Dist: black; extra == "dev"
|
|
18
|
+
Requires-Dist: build; extra == "dev"
|
|
19
|
+
Requires-Dist: flake8; extra == "dev"
|
|
20
|
+
Requires-Dist: isort; extra == "dev"
|
|
21
|
+
Requires-Dist: mypy; extra == "dev"
|
|
22
|
+
Requires-Dist: pre-commit; extra == "dev"
|
|
23
|
+
Requires-Dist: pyproject-flake8; extra == "dev"
|
|
24
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
25
|
+
Requires-Dist: pytest-mock; extra == "dev"
|
|
26
|
+
Requires-Dist: pytest; extra == "dev"
|
|
27
|
+
Requires-Dist: twine; extra == "dev"
|
|
28
|
+
Requires-Dist: wheel; extra == "dev"
|
|
29
|
+
|
|
30
|
+
# exdrf-ts
|
|
31
|
+
|
|
32
|
+
**exdrf-ts** maps **exdrf** field-type constants and Python typing objects to
|
|
33
|
+
**TypeScript** strings and DARE field class names. Code generators
|
|
34
|
+
(**exdrf-gen-pd2dare**, **exdrf-gen-openapi2rtk**, app-specific pipelines)
|
|
35
|
+
import it when emitting TS from Pydantic-backed or OpenAPI-backed resources.
|
|
36
|
+
|
|
37
|
+
The PyPI distribution name is **`exdrf-ts`**. The import package is
|
|
38
|
+
**`exdrf_ts`**.
|
|
39
|
+
|
|
40
|
+
Python **3.12.2+** is required. Runtime dependencies: **exdrf** and
|
|
41
|
+
**exdrf-pd**.
|
|
42
|
+
|
|
43
|
+
## Installation
|
|
44
|
+
|
|
45
|
+
```text
|
|
46
|
+
pip install exdrf-ts
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Editable install from the exdrf monorepo root (with other packages present as
|
|
50
|
+
needed):
|
|
51
|
+
|
|
52
|
+
```text
|
|
53
|
+
pip install -e ./exdrf-ts
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## API
|
|
57
|
+
|
|
58
|
+
- **`py_type_to_ts`**: Python annotation or runtime type to TS type string.
|
|
59
|
+
- **`type_to_field_class`**: Maps **exdrf** `FIELD_TYPE_*` values to DARE
|
|
60
|
+
field class names (for example `StringField`).
|
|
61
|
+
- **`model_rel_import`**: Relative import path between two models (via
|
|
62
|
+
**`exdrf_pd.visitor.ExModelVisitor`**).
|
|
63
|
+
|
|
64
|
+
## Role in the stack
|
|
65
|
+
|
|
66
|
+
- **`exdrf`**: core dataset / field model.
|
|
67
|
+
- **`exdrf-pd`**: Pydantic **`ExModel`** and visitors.
|
|
68
|
+
- **`exdrf-ts`**: TypeScript-oriented views of those types.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# exdrf-ts
|
|
2
|
+
|
|
3
|
+
**exdrf-ts** maps **exdrf** field-type constants and Python typing objects to
|
|
4
|
+
**TypeScript** strings and DARE field class names. Code generators
|
|
5
|
+
(**exdrf-gen-pd2dare**, **exdrf-gen-openapi2rtk**, app-specific pipelines)
|
|
6
|
+
import it when emitting TS from Pydantic-backed or OpenAPI-backed resources.
|
|
7
|
+
|
|
8
|
+
The PyPI distribution name is **`exdrf-ts`**. The import package is
|
|
9
|
+
**`exdrf_ts`**.
|
|
10
|
+
|
|
11
|
+
Python **3.12.2+** is required. Runtime dependencies: **exdrf** and
|
|
12
|
+
**exdrf-pd**.
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
```text
|
|
17
|
+
pip install exdrf-ts
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Editable install from the exdrf monorepo root (with other packages present as
|
|
21
|
+
needed):
|
|
22
|
+
|
|
23
|
+
```text
|
|
24
|
+
pip install -e ./exdrf-ts
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## API
|
|
28
|
+
|
|
29
|
+
- **`py_type_to_ts`**: Python annotation or runtime type to TS type string.
|
|
30
|
+
- **`type_to_field_class`**: Maps **exdrf** `FIELD_TYPE_*` values to DARE
|
|
31
|
+
field class names (for example `StringField`).
|
|
32
|
+
- **`model_rel_import`**: Relative import path between two models (via
|
|
33
|
+
**`exdrf_pd.visitor.ExModelVisitor`**).
|
|
34
|
+
|
|
35
|
+
## Role in the stack
|
|
36
|
+
|
|
37
|
+
- **`exdrf`**: core dataset / field model.
|
|
38
|
+
- **`exdrf-pd`**: Pydantic **`ExModel`** and visitors.
|
|
39
|
+
- **`exdrf-ts`**: TypeScript-oriented views of those types.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""TypeScript-oriented type mapping for exdrf-backed codegen."""
|
|
2
|
+
|
|
3
|
+
from exdrf_ts.json_schema_ts import json_schema_to_ts
|
|
4
|
+
from exdrf_ts.mapping import (
|
|
5
|
+
model_rel_import,
|
|
6
|
+
py_type_to_ts,
|
|
7
|
+
py_type_to_ts_map,
|
|
8
|
+
type_to_field_class,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"json_schema_to_ts",
|
|
13
|
+
"model_rel_import",
|
|
14
|
+
"py_type_to_ts",
|
|
15
|
+
"py_type_to_ts_map",
|
|
16
|
+
"type_to_field_class",
|
|
17
|
+
]
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Package version from PEP 621 or installed metadata."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from exdrf.pep621_version import distribution_version, version_tuple_from_string
|
|
8
|
+
|
|
9
|
+
_PYPROJECT = Path(__file__).resolve().parents[1] / "pyproject.toml"
|
|
10
|
+
_DIST_NAME = "exdrf-ts"
|
|
11
|
+
|
|
12
|
+
__version__ = version = distribution_version(_DIST_NAME, _PYPROJECT)
|
|
13
|
+
__version_tuple__ = version_tuple = version_tuple_from_string(__version__)
|
|
14
|
+
|
|
15
|
+
__commit_id__ = commit_id = None
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"__version__",
|
|
19
|
+
"__version_tuple__",
|
|
20
|
+
"version",
|
|
21
|
+
"version_tuple",
|
|
22
|
+
"__commit_id__",
|
|
23
|
+
"commit_id",
|
|
24
|
+
]
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""Map OpenAPI / JSON Schema fragments to TypeScript type strings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from typing import Any, Mapping
|
|
8
|
+
|
|
9
|
+
_REF_RE = re.compile(r"^#/components/schemas/([A-Za-z0-9_.-]+)$")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _json_pointer_get(doc: Mapping[str, Any], pointer: str) -> Any:
|
|
13
|
+
"""Resolve a JSON Pointer (RFC 6901) against ``doc``.
|
|
14
|
+
|
|
15
|
+
Only supports absolute pointers starting with ``/`` (no URI fragment).
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
doc: JSON object to traverse.
|
|
19
|
+
pointer: Pointer such as ``/components/schemas/Foo``.
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
The value at ``pointer``, or ``None`` if a segment is missing.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
if not pointer.startswith("/"):
|
|
26
|
+
return None
|
|
27
|
+
cur: Any = doc
|
|
28
|
+
for raw in pointer.strip("/").split("/"):
|
|
29
|
+
key = raw.replace("~1", "/").replace("~0", "~")
|
|
30
|
+
if not isinstance(cur, Mapping) or key not in cur:
|
|
31
|
+
return None
|
|
32
|
+
cur = cur[key]
|
|
33
|
+
return cur
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _resolve_ref(
|
|
37
|
+
ref: str,
|
|
38
|
+
resolve_root: Mapping[str, Any],
|
|
39
|
+
seen_refs: frozenset[str],
|
|
40
|
+
) -> tuple[Any, frozenset[str]] | None:
|
|
41
|
+
"""Follow an OpenAPI ``#/components/schemas/...`` reference.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
ref: Reference string from ``"$ref"``.
|
|
45
|
+
resolve_root: Full OpenAPI document (or object containing
|
|
46
|
+
``components``).
|
|
47
|
+
seen_refs: References already followed (cycle guard).
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
``(target_schema, seen_refs_with_ref)`` or ``None`` if unsupported.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
m = _REF_RE.match(ref)
|
|
54
|
+
if not m:
|
|
55
|
+
return None
|
|
56
|
+
name = m.group(1)
|
|
57
|
+
if ref in seen_refs:
|
|
58
|
+
return None
|
|
59
|
+
pointer = f"/components/schemas/{name}"
|
|
60
|
+
target = _json_pointer_get(resolve_root, pointer)
|
|
61
|
+
if target is None:
|
|
62
|
+
return None
|
|
63
|
+
return target, frozenset((*seen_refs, ref))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def json_schema_to_ts(
|
|
67
|
+
schema: Any,
|
|
68
|
+
resolve_root: Mapping[str, Any] | None = None,
|
|
69
|
+
*,
|
|
70
|
+
seen_refs: frozenset[str] | None = None,
|
|
71
|
+
) -> str:
|
|
72
|
+
"""Convert a JSON Schema fragment to a TypeScript type string.
|
|
73
|
+
|
|
74
|
+
Intended for OpenAPI 3.x ``components.schemas`` and inline parameter /
|
|
75
|
+
response bodies. Unsupported or cyclic constructs fall back to
|
|
76
|
+
``unknown``.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
schema: Schema object (``dict``), ``True`` (accept-all schema in JSON
|
|
80
|
+
Schema drafts), or ``False`` (reject-all; emitted as ``never``).
|
|
81
|
+
resolve_root: Document used to resolve ``#/components/schemas/...``
|
|
82
|
+
``"$ref"`` values. When ``None``, ``"$ref"`` resolves to
|
|
83
|
+
``unknown``.
|
|
84
|
+
seen_refs: Internal recursion guard; do not pass from callers.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
TypeScript type text.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
stack = frozenset() if seen_refs is None else seen_refs
|
|
91
|
+
|
|
92
|
+
if schema is True:
|
|
93
|
+
return "unknown"
|
|
94
|
+
if schema is False:
|
|
95
|
+
return "never"
|
|
96
|
+
if not isinstance(schema, Mapping):
|
|
97
|
+
return "unknown"
|
|
98
|
+
|
|
99
|
+
ref = schema.get("$ref")
|
|
100
|
+
if isinstance(ref, str) and resolve_root is not None:
|
|
101
|
+
resolved = _resolve_ref(ref, resolve_root, stack)
|
|
102
|
+
if resolved is not None:
|
|
103
|
+
sub, new_stack = resolved
|
|
104
|
+
return json_schema_to_ts(sub, resolve_root, seen_refs=new_stack)
|
|
105
|
+
m = _REF_RE.match(ref)
|
|
106
|
+
if m:
|
|
107
|
+
return m.group(1)
|
|
108
|
+
return "unknown"
|
|
109
|
+
|
|
110
|
+
if "enum" in schema and isinstance(schema["enum"], list):
|
|
111
|
+
literals: list[str] = []
|
|
112
|
+
for v in schema["enum"]:
|
|
113
|
+
if isinstance(v, str):
|
|
114
|
+
literals.append(json.dumps(v))
|
|
115
|
+
elif isinstance(v, bool):
|
|
116
|
+
literals.append("true" if v else "false")
|
|
117
|
+
elif v is None:
|
|
118
|
+
literals.append("null")
|
|
119
|
+
elif isinstance(v, (int, float)):
|
|
120
|
+
literals.append(json.dumps(v))
|
|
121
|
+
if not literals:
|
|
122
|
+
return "unknown"
|
|
123
|
+
return " | ".join(literals)
|
|
124
|
+
|
|
125
|
+
if "oneOf" in schema and isinstance(schema["oneOf"], list):
|
|
126
|
+
parts = [
|
|
127
|
+
json_schema_to_ts(s, resolve_root, seen_refs=stack) for s in schema["oneOf"]
|
|
128
|
+
]
|
|
129
|
+
parts_u = [p for p in parts if p != "unknown"]
|
|
130
|
+
if not parts_u:
|
|
131
|
+
return "unknown"
|
|
132
|
+
return " | ".join(dict.fromkeys(parts_u))
|
|
133
|
+
|
|
134
|
+
if "anyOf" in schema and isinstance(schema["anyOf"], list):
|
|
135
|
+
parts = [
|
|
136
|
+
json_schema_to_ts(s, resolve_root, seen_refs=stack) for s in schema["anyOf"]
|
|
137
|
+
]
|
|
138
|
+
parts_u = [p for p in parts if p != "unknown"]
|
|
139
|
+
if not parts_u:
|
|
140
|
+
return "unknown"
|
|
141
|
+
return " | ".join(dict.fromkeys(parts_u))
|
|
142
|
+
|
|
143
|
+
if "allOf" in schema and isinstance(schema["allOf"], list):
|
|
144
|
+
parts = [
|
|
145
|
+
json_schema_to_ts(s, resolve_root, seen_refs=stack) for s in schema["allOf"]
|
|
146
|
+
]
|
|
147
|
+
if all(p == "unknown" for p in parts):
|
|
148
|
+
return "unknown"
|
|
149
|
+
|
|
150
|
+
def _part(p: str) -> str:
|
|
151
|
+
if " | " in p or " & " in p:
|
|
152
|
+
return f"({p})"
|
|
153
|
+
return p
|
|
154
|
+
|
|
155
|
+
return " & ".join(_part(p) for p in parts)
|
|
156
|
+
|
|
157
|
+
type_val = schema.get("type")
|
|
158
|
+
types: list[str] = []
|
|
159
|
+
if isinstance(type_val, list):
|
|
160
|
+
types = [t for t in type_val if isinstance(t, str)]
|
|
161
|
+
elif isinstance(type_val, str):
|
|
162
|
+
types = [type_val]
|
|
163
|
+
|
|
164
|
+
nullable = schema.get("nullable") is True
|
|
165
|
+
|
|
166
|
+
def _wrap_null(t: str) -> str:
|
|
167
|
+
if nullable:
|
|
168
|
+
return f"{t} | null"
|
|
169
|
+
return t
|
|
170
|
+
|
|
171
|
+
if not types:
|
|
172
|
+
if "properties" in schema or "additionalProperties" in schema:
|
|
173
|
+
return _wrap_null(_object_to_ts(schema, resolve_root, stack))
|
|
174
|
+
return "unknown"
|
|
175
|
+
|
|
176
|
+
out_parts: list[str] = []
|
|
177
|
+
for t in types:
|
|
178
|
+
if t == "string":
|
|
179
|
+
out_parts.append("string")
|
|
180
|
+
elif t == "integer":
|
|
181
|
+
out_parts.append("number")
|
|
182
|
+
elif t == "number":
|
|
183
|
+
out_parts.append("number")
|
|
184
|
+
elif t == "boolean":
|
|
185
|
+
out_parts.append("boolean")
|
|
186
|
+
elif t == "null":
|
|
187
|
+
out_parts.append("null")
|
|
188
|
+
elif t == "array":
|
|
189
|
+
items = schema.get("items", {})
|
|
190
|
+
inner = json_schema_to_ts(items, resolve_root, seen_refs=stack)
|
|
191
|
+
out_parts.append(f"{inner}[]")
|
|
192
|
+
elif t == "object":
|
|
193
|
+
out_parts.append(_object_to_ts(schema, resolve_root, stack))
|
|
194
|
+
else:
|
|
195
|
+
out_parts.append("unknown")
|
|
196
|
+
|
|
197
|
+
merged = " | ".join(dict.fromkeys(out_parts)) if out_parts else "unknown"
|
|
198
|
+
return _wrap_null(merged)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _object_to_ts(
|
|
202
|
+
schema: Mapping[str, Any],
|
|
203
|
+
resolve_root: Mapping[str, Any] | None,
|
|
204
|
+
stack: frozenset[str],
|
|
205
|
+
) -> str:
|
|
206
|
+
"""Build an inline object type from schema ``properties`` / ``additional``.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
schema: JSON Schema with ``type`` ``object`` (or implied).
|
|
210
|
+
resolve_root: Document for ``$ref`` inside nested schemas.
|
|
211
|
+
stack: Active ``$ref`` stack.
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
Inline TypeScript object type or ``Record<...>``.
|
|
215
|
+
"""
|
|
216
|
+
|
|
217
|
+
props = schema.get("properties")
|
|
218
|
+
required = set(schema.get("required", []) or [])
|
|
219
|
+
if isinstance(props, Mapping) and props:
|
|
220
|
+
lines: list[str] = []
|
|
221
|
+
for key, sub in props.items():
|
|
222
|
+
opt = "" if key in required else "?"
|
|
223
|
+
ts = json_schema_to_ts(sub, resolve_root, seen_refs=stack)
|
|
224
|
+
safe_key = (
|
|
225
|
+
key
|
|
226
|
+
if re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", str(key))
|
|
227
|
+
else json.dumps(str(key))
|
|
228
|
+
)
|
|
229
|
+
lines.append(f" {safe_key}{opt}: {ts};")
|
|
230
|
+
return "{\n" + "\n".join(lines) + "\n}"
|
|
231
|
+
|
|
232
|
+
addl = schema.get("additionalProperties")
|
|
233
|
+
if addl is True:
|
|
234
|
+
return "{ [key: string]: unknown }"
|
|
235
|
+
if isinstance(addl, Mapping):
|
|
236
|
+
inner = json_schema_to_ts(addl, resolve_root, seen_refs=stack)
|
|
237
|
+
return f"{{ [key: string]: {inner} }}"
|
|
238
|
+
return "{ [key: string]: unknown }"
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Map exdrf types and Python annotations to TypeScript."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import types
|
|
6
|
+
from typing import Any, Union, get_args, get_origin
|
|
7
|
+
|
|
8
|
+
from exdrf.constants import (
|
|
9
|
+
FIELD_TYPE_BLOB,
|
|
10
|
+
FIELD_TYPE_BOOL,
|
|
11
|
+
FIELD_TYPE_DATE,
|
|
12
|
+
FIELD_TYPE_DT,
|
|
13
|
+
FIELD_TYPE_DURATION,
|
|
14
|
+
FIELD_TYPE_ENUM,
|
|
15
|
+
FIELD_TYPE_FILTER,
|
|
16
|
+
FIELD_TYPE_FLOAT,
|
|
17
|
+
FIELD_TYPE_FLOAT_LIST,
|
|
18
|
+
FIELD_TYPE_FORMATTED,
|
|
19
|
+
FIELD_TYPE_INT_LIST,
|
|
20
|
+
FIELD_TYPE_INTEGER,
|
|
21
|
+
FIELD_TYPE_REF_MANY_TO_MANY,
|
|
22
|
+
FIELD_TYPE_REF_MANY_TO_ONE,
|
|
23
|
+
FIELD_TYPE_REF_ONE_TO_MANY,
|
|
24
|
+
FIELD_TYPE_REF_ONE_TO_ONE,
|
|
25
|
+
FIELD_TYPE_SORT,
|
|
26
|
+
FIELD_TYPE_STRING,
|
|
27
|
+
FIELD_TYPE_STRING_LIST,
|
|
28
|
+
)
|
|
29
|
+
from exdrf_pd.visitor import ExModelVisitor
|
|
30
|
+
|
|
31
|
+
py_type_to_ts_map = {
|
|
32
|
+
"str": "string",
|
|
33
|
+
"int": "number",
|
|
34
|
+
"float": "number",
|
|
35
|
+
"bool": "boolean",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type_to_field_class = {
|
|
39
|
+
FIELD_TYPE_BLOB: "BlobField",
|
|
40
|
+
FIELD_TYPE_BOOL: "BooleanField",
|
|
41
|
+
FIELD_TYPE_DATE: "DateField",
|
|
42
|
+
FIELD_TYPE_DT: "DateTimeField",
|
|
43
|
+
FIELD_TYPE_DURATION: "DurationField",
|
|
44
|
+
FIELD_TYPE_ENUM: "EnumField",
|
|
45
|
+
FIELD_TYPE_FLOAT: "FloatField",
|
|
46
|
+
FIELD_TYPE_INTEGER: "IntegerField",
|
|
47
|
+
FIELD_TYPE_STRING: "StringField",
|
|
48
|
+
FIELD_TYPE_FORMATTED: "FormattedField",
|
|
49
|
+
FIELD_TYPE_REF_ONE_TO_MANY: "RefOneToManyField",
|
|
50
|
+
FIELD_TYPE_REF_MANY_TO_ONE: "RefManyToOneField",
|
|
51
|
+
FIELD_TYPE_REF_ONE_TO_ONE: "RefOneToOneField",
|
|
52
|
+
FIELD_TYPE_REF_MANY_TO_MANY: "RefManyToManyField",
|
|
53
|
+
FIELD_TYPE_STRING_LIST: "StringListField",
|
|
54
|
+
FIELD_TYPE_INT_LIST: "IntListField",
|
|
55
|
+
FIELD_TYPE_FLOAT_LIST: "FloatListField",
|
|
56
|
+
FIELD_TYPE_FILTER: "FilterField",
|
|
57
|
+
FIELD_TYPE_SORT: "SortField",
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _py_type_to_ts_string(name: str) -> str:
|
|
62
|
+
"""Convert a stringified annotation to a TypeScript type.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
name: Annotation text (for example ``List[str]``).
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
Equivalent TypeScript type string.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
if name == "Any":
|
|
72
|
+
return "unknown"
|
|
73
|
+
|
|
74
|
+
if name.startswith("List[") and name.endswith("]"):
|
|
75
|
+
return f"{py_type_to_ts(name[5:-1])}[]"
|
|
76
|
+
|
|
77
|
+
if name.startswith("Dict[") and name.endswith("]"):
|
|
78
|
+
key, value = name[5:-1].split(", ")
|
|
79
|
+
if py_type_to_ts(key) == "str":
|
|
80
|
+
return f"{{ {key}: {py_type_to_ts(value)} }}"
|
|
81
|
+
return f"{{ [key: {py_type_to_ts(key)}]: {py_type_to_ts(value)} }}"
|
|
82
|
+
|
|
83
|
+
if name.startswith("Optional[") and name.endswith("]"):
|
|
84
|
+
return f"{py_type_to_ts(name[9:-1])} | undefined"
|
|
85
|
+
|
|
86
|
+
return py_type_to_ts_map.get(name, name)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def py_type_to_ts(name: Union[str, type, types.UnionType, Any]) -> str:
|
|
90
|
+
"""Convert a Python type or annotation string to a TypeScript type.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
name: A type object, string annotation, or typing construct.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
TypeScript type text suitable for emitted source.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
if isinstance(name, str):
|
|
100
|
+
return _py_type_to_ts_string(name)
|
|
101
|
+
|
|
102
|
+
args = get_args(name)
|
|
103
|
+
if args and type(None) in args:
|
|
104
|
+
non_none = [a for a in args if a is not type(None)]
|
|
105
|
+
if len(non_none) == 1:
|
|
106
|
+
return f"{py_type_to_ts(non_none[0])} | undefined"
|
|
107
|
+
|
|
108
|
+
origin = get_origin(name)
|
|
109
|
+
if origin is list:
|
|
110
|
+
if len(args) == 1:
|
|
111
|
+
return f"{py_type_to_ts(args[0])}[]"
|
|
112
|
+
|
|
113
|
+
if origin is dict:
|
|
114
|
+
if len(args) == 2:
|
|
115
|
+
key_t, val_t = args
|
|
116
|
+
if py_type_to_ts(key_t) == "string":
|
|
117
|
+
return f"{{ [key: string]: {py_type_to_ts(val_t)} }}"
|
|
118
|
+
return f"{{ [key: {py_type_to_ts(key_t)}]: {py_type_to_ts(val_t)} }}"
|
|
119
|
+
|
|
120
|
+
if hasattr(name, "__name__") and origin is None:
|
|
121
|
+
py_name = name.__name__
|
|
122
|
+
if py_name == "Any":
|
|
123
|
+
return "unknown"
|
|
124
|
+
mapped = py_type_to_ts_map.get(py_name)
|
|
125
|
+
if mapped is not None:
|
|
126
|
+
return mapped
|
|
127
|
+
return py_name
|
|
128
|
+
|
|
129
|
+
return "unknown"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def model_rel_import(model: Any, ref_model: Any) -> str:
|
|
133
|
+
"""Compute a model import path relative to another model.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
model: The resource or model to import.
|
|
137
|
+
ref_model: The reference model (import origin).
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
Relative path using ``/`` segments (for TS-style paths).
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
categories = ExModelVisitor.category(model)
|
|
144
|
+
ref_categories = ExModelVisitor.category(ref_model)
|
|
145
|
+
|
|
146
|
+
# Find the common prefix between category chains.
|
|
147
|
+
i = 0
|
|
148
|
+
while (
|
|
149
|
+
i < len(categories)
|
|
150
|
+
and i < len(ref_categories)
|
|
151
|
+
and categories[i] == ref_categories[i]
|
|
152
|
+
):
|
|
153
|
+
i += 1
|
|
154
|
+
|
|
155
|
+
# Walk up from the reference, then down through the remainder.
|
|
156
|
+
path = [".."] * (len(ref_categories) - i)
|
|
157
|
+
path.extend(categories[i:])
|
|
158
|
+
|
|
159
|
+
return "/".join(path)
|
|
File without changes
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: exdrf-ts
|
|
3
|
+
Version: 0.1.17
|
|
4
|
+
Summary: Map exdrf field and Python types to TypeScript for codegen.
|
|
5
|
+
Author-email: Nicu Tofan <nicu.tofan@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Classifier: Operating System :: OS Independent
|
|
8
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Typing :: Typed
|
|
11
|
+
Requires-Python: >=3.12.2
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
Requires-Dist: exdrf>=0.1.17
|
|
14
|
+
Requires-Dist: exdrf-pd>=0.1.18
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: autoflake; extra == "dev"
|
|
17
|
+
Requires-Dist: black; extra == "dev"
|
|
18
|
+
Requires-Dist: build; extra == "dev"
|
|
19
|
+
Requires-Dist: flake8; extra == "dev"
|
|
20
|
+
Requires-Dist: isort; extra == "dev"
|
|
21
|
+
Requires-Dist: mypy; extra == "dev"
|
|
22
|
+
Requires-Dist: pre-commit; extra == "dev"
|
|
23
|
+
Requires-Dist: pyproject-flake8; extra == "dev"
|
|
24
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
25
|
+
Requires-Dist: pytest-mock; extra == "dev"
|
|
26
|
+
Requires-Dist: pytest; extra == "dev"
|
|
27
|
+
Requires-Dist: twine; extra == "dev"
|
|
28
|
+
Requires-Dist: wheel; extra == "dev"
|
|
29
|
+
|
|
30
|
+
# exdrf-ts
|
|
31
|
+
|
|
32
|
+
**exdrf-ts** maps **exdrf** field-type constants and Python typing objects to
|
|
33
|
+
**TypeScript** strings and DARE field class names. Code generators
|
|
34
|
+
(**exdrf-gen-pd2dare**, **exdrf-gen-openapi2rtk**, app-specific pipelines)
|
|
35
|
+
import it when emitting TS from Pydantic-backed or OpenAPI-backed resources.
|
|
36
|
+
|
|
37
|
+
The PyPI distribution name is **`exdrf-ts`**. The import package is
|
|
38
|
+
**`exdrf_ts`**.
|
|
39
|
+
|
|
40
|
+
Python **3.12.2+** is required. Runtime dependencies: **exdrf** and
|
|
41
|
+
**exdrf-pd**.
|
|
42
|
+
|
|
43
|
+
## Installation
|
|
44
|
+
|
|
45
|
+
```text
|
|
46
|
+
pip install exdrf-ts
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Editable install from the exdrf monorepo root (with other packages present as
|
|
50
|
+
needed):
|
|
51
|
+
|
|
52
|
+
```text
|
|
53
|
+
pip install -e ./exdrf-ts
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## API
|
|
57
|
+
|
|
58
|
+
- **`py_type_to_ts`**: Python annotation or runtime type to TS type string.
|
|
59
|
+
- **`type_to_field_class`**: Maps **exdrf** `FIELD_TYPE_*` values to DARE
|
|
60
|
+
field class names (for example `StringField`).
|
|
61
|
+
- **`model_rel_import`**: Relative import path between two models (via
|
|
62
|
+
**`exdrf_pd.visitor.ExModelVisitor`**).
|
|
63
|
+
|
|
64
|
+
## Role in the stack
|
|
65
|
+
|
|
66
|
+
- **`exdrf`**: core dataset / field model.
|
|
67
|
+
- **`exdrf-pd`**: Pydantic **`ExModel`** and visitors.
|
|
68
|
+
- **`exdrf-ts`**: TypeScript-oriented views of those types.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
exdrf_ts/__init__.py
|
|
4
|
+
exdrf_ts/__version__.py
|
|
5
|
+
exdrf_ts/json_schema_ts.py
|
|
6
|
+
exdrf_ts/mapping.py
|
|
7
|
+
exdrf_ts/py.typed
|
|
8
|
+
exdrf_ts.egg-info/PKG-INFO
|
|
9
|
+
exdrf_ts.egg-info/SOURCES.txt
|
|
10
|
+
exdrf_ts.egg-info/dependency_links.txt
|
|
11
|
+
exdrf_ts.egg-info/requires.txt
|
|
12
|
+
exdrf_ts.egg-info/top_level.txt
|
|
13
|
+
tests/json_schema_ts_test.py
|
|
14
|
+
tests/mapping_test.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
authors = [
|
|
3
|
+
{ name = "Nicu Tofan", email = "nicu.tofan@gmail.com" },
|
|
4
|
+
]
|
|
5
|
+
license = "MIT"
|
|
6
|
+
classifiers = [
|
|
7
|
+
"Operating System :: OS Independent",
|
|
8
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
9
|
+
"Development Status :: 3 - Alpha",
|
|
10
|
+
"Typing :: Typed",
|
|
11
|
+
]
|
|
12
|
+
dependencies = [
|
|
13
|
+
"exdrf>=0.1.17",
|
|
14
|
+
"exdrf-pd>=0.1.18",
|
|
15
|
+
]
|
|
16
|
+
description = "Map exdrf field and Python types to TypeScript for codegen."
|
|
17
|
+
version = "0.1.17"
|
|
18
|
+
name = "exdrf-ts"
|
|
19
|
+
readme = "README.md"
|
|
20
|
+
requires-python = ">=3.12.2"
|
|
21
|
+
|
|
22
|
+
[project.optional-dependencies]
|
|
23
|
+
dev = [
|
|
24
|
+
"autoflake",
|
|
25
|
+
"black",
|
|
26
|
+
"build",
|
|
27
|
+
"flake8",
|
|
28
|
+
"isort",
|
|
29
|
+
"mypy",
|
|
30
|
+
"pre-commit",
|
|
31
|
+
"pyproject-flake8",
|
|
32
|
+
"pytest-cov",
|
|
33
|
+
"pytest-mock",
|
|
34
|
+
"pytest",
|
|
35
|
+
"twine",
|
|
36
|
+
"wheel",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
[build-system]
|
|
40
|
+
build-backend = "setuptools.build_meta"
|
|
41
|
+
requires = ["setuptools>=67.0"]
|
|
42
|
+
|
|
43
|
+
[tool.setuptools]
|
|
44
|
+
include-package-data = true
|
|
45
|
+
|
|
46
|
+
[tool.setuptools.package-data]
|
|
47
|
+
exdrf_ts = ["py.typed"]
|
|
48
|
+
|
|
49
|
+
[tool.setuptools.packages.find]
|
|
50
|
+
exclude = ["venv*", "playground*"]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
[tool.isort]
|
|
54
|
+
profile = "black"
|
|
55
|
+
|
|
56
|
+
[tool.black]
|
|
57
|
+
line-length = 80
|
|
58
|
+
target-version = ['py312']
|
|
59
|
+
|
|
60
|
+
[tool.flake8]
|
|
61
|
+
docstring-convention = "google"
|
|
62
|
+
max-line-length = 80
|
|
63
|
+
extend-ignore = ["E203", "E501", "W503"]
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Tests for :mod:`exdrf_ts.json_schema_ts`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from exdrf_ts.json_schema_ts import json_schema_to_ts
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TestJsonSchemaToTsPrimitives:
|
|
9
|
+
"""Primitive and array JSON Schema types."""
|
|
10
|
+
|
|
11
|
+
def test_string(self) -> None:
|
|
12
|
+
"""``type: string`` maps to TypeScript ``string``."""
|
|
13
|
+
|
|
14
|
+
assert json_schema_to_ts({"type": "string"}) == "string"
|
|
15
|
+
|
|
16
|
+
def test_integer_and_number(self) -> None:
|
|
17
|
+
"""Numeric JSON Schema types map to ``number``."""
|
|
18
|
+
|
|
19
|
+
assert json_schema_to_ts({"type": "integer"}) == "number"
|
|
20
|
+
assert json_schema_to_ts({"type": "number"}) == "number"
|
|
21
|
+
|
|
22
|
+
def test_boolean(self) -> None:
|
|
23
|
+
"""``type: boolean`` maps to ``boolean``."""
|
|
24
|
+
|
|
25
|
+
assert json_schema_to_ts({"type": "boolean"}) == "boolean"
|
|
26
|
+
|
|
27
|
+
def test_array_of_string(self) -> None:
|
|
28
|
+
"""``type: array`` with ``items`` maps to element type plus ``[]``."""
|
|
29
|
+
|
|
30
|
+
out = json_schema_to_ts(
|
|
31
|
+
{"type": "array", "items": {"type": "string"}},
|
|
32
|
+
)
|
|
33
|
+
assert out == "string[]"
|
|
34
|
+
|
|
35
|
+
def test_nullable_string(self) -> None:
|
|
36
|
+
"""OpenAPI 3 ``nullable: true`` adds ``| null``."""
|
|
37
|
+
|
|
38
|
+
assert (
|
|
39
|
+
json_schema_to_ts({"type": "string", "nullable": True}) == "string | null"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class TestJsonSchemaToTsRef:
|
|
44
|
+
"""``$ref`` resolution against a synthetic OpenAPI document."""
|
|
45
|
+
|
|
46
|
+
def test_components_schema_ref(self) -> None:
|
|
47
|
+
"""``#/components/schemas/Name`` emits the schema name."""
|
|
48
|
+
|
|
49
|
+
root = {
|
|
50
|
+
"components": {
|
|
51
|
+
"schemas": {
|
|
52
|
+
"Foo": {
|
|
53
|
+
"type": "object",
|
|
54
|
+
"properties": {"a": {"type": "integer"}},
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
out = json_schema_to_ts({"$ref": "#/components/schemas/Foo"}, root)
|
|
60
|
+
assert "a" in out
|
|
61
|
+
assert "number" in out
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class TestJsonSchemaToTsEnum:
|
|
65
|
+
"""Enum keyword."""
|
|
66
|
+
|
|
67
|
+
def test_string_enum(self) -> None:
|
|
68
|
+
"""String ``enum`` becomes a union of string literals."""
|
|
69
|
+
|
|
70
|
+
out = json_schema_to_ts({"type": "string", "enum": ["a", "b"]})
|
|
71
|
+
assert '"a"' in out
|
|
72
|
+
assert '"b"' in out
|
|
73
|
+
assert " | " in out
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class TestJsonSchemaToTsLogical:
|
|
77
|
+
"""``oneOf`` / ``anyOf`` / ``allOf``."""
|
|
78
|
+
|
|
79
|
+
def test_one_of_primitives(self) -> None:
|
|
80
|
+
"""``oneOf`` joins alternatives with ``|``."""
|
|
81
|
+
|
|
82
|
+
out = json_schema_to_ts(
|
|
83
|
+
{"oneOf": [{"type": "string"}, {"type": "integer"}]},
|
|
84
|
+
)
|
|
85
|
+
assert "string" in out
|
|
86
|
+
assert "number" in out
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class TestJsonSchemaToTsSpecial:
|
|
90
|
+
"""Boolean schema and empty shapes."""
|
|
91
|
+
|
|
92
|
+
def test_true_schema(self) -> None:
|
|
93
|
+
"""JSON Schema ``true`` accepts anything → ``unknown``."""
|
|
94
|
+
|
|
95
|
+
assert json_schema_to_ts(True) == "unknown"
|
|
96
|
+
|
|
97
|
+
def test_false_schema(self) -> None:
|
|
98
|
+
"""JSON Schema ``false`` is unsatisfiable → ``never``."""
|
|
99
|
+
|
|
100
|
+
assert json_schema_to_ts(False) == "never"
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Tests for ``exdrf_ts.mapping``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from exdrf_ts.mapping import model_rel_import, py_type_to_ts
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_py_type_to_ts_primitives() -> None:
|
|
9
|
+
"""Primitive names map to TS keywords."""
|
|
10
|
+
|
|
11
|
+
assert py_type_to_ts("str") == "string"
|
|
12
|
+
assert py_type_to_ts("int") == "number"
|
|
13
|
+
assert py_type_to_ts("float") == "number"
|
|
14
|
+
assert py_type_to_ts("bool") == "boolean"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_py_type_to_ts_containers_and_optional() -> None:
|
|
18
|
+
"""List, optional, and dict string forms map as expected."""
|
|
19
|
+
|
|
20
|
+
assert py_type_to_ts("List[str]") == "string[]"
|
|
21
|
+
assert py_type_to_ts("List[int]") == "number[]"
|
|
22
|
+
assert py_type_to_ts("Optional[str]") == "string | undefined"
|
|
23
|
+
assert py_type_to_ts("Optional[List[int]]") == "number[] | undefined"
|
|
24
|
+
assert py_type_to_ts("Dict[str, int]") == "{ [key: string]: number }"
|
|
25
|
+
assert py_type_to_ts("Dict[int, float]") == "{ [key: number]: number }"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_py_type_to_ts_runtime_pep604_and_generics() -> None:
|
|
29
|
+
"""Runtime ``type`` / union objects map like annotations."""
|
|
30
|
+
|
|
31
|
+
assert py_type_to_ts(str | None) == "string | undefined"
|
|
32
|
+
assert py_type_to_ts(list[int]) == "number[]"
|
|
33
|
+
assert py_type_to_ts(dict[str, int]) == "{ [key: string]: number }"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_model_rel_import_relative_paths() -> None:
|
|
37
|
+
"""Category segments from ``__module__`` yield stable relative paths."""
|
|
38
|
+
|
|
39
|
+
def _model_with_category_segments(segments: list[str]) -> type:
|
|
40
|
+
"""Build a type whose ``ExModelVisitor.category`` is ``segments``."""
|
|
41
|
+
|
|
42
|
+
class M:
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
M.__module__ = "pkg." + ".".join(segments) + ".resource"
|
|
46
|
+
return M
|
|
47
|
+
|
|
48
|
+
model = _model_with_category_segments(["Api", "Users", "Profiles"])
|
|
49
|
+
ref = _model_with_category_segments(["Api"])
|
|
50
|
+
assert model_rel_import(model, ref) == "Users/Profiles"
|
|
51
|
+
|
|
52
|
+
model = _model_with_category_segments(["Api", "Users"])
|
|
53
|
+
ref = _model_with_category_segments(["Api", "Posts"])
|
|
54
|
+
assert model_rel_import(model, ref) == "../Users"
|
|
55
|
+
|
|
56
|
+
model = _model_with_category_segments(["Api", "Users"])
|
|
57
|
+
ref = _model_with_category_segments(["Api", "Users"])
|
|
58
|
+
assert model_rel_import(model, ref) == ""
|