fastapi-streams 0.0.1__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.
- fastapi_streams/__init__.py +4 -0
- fastapi_streams/codegen/__init__.py +0 -0
- fastapi_streams/codegen/cli.py +120 -0
- fastapi_streams/codegen/enums.py +172 -0
- fastapi_streams/codegen/ir.py +524 -0
- fastapi_streams/codegen/names.py +88 -0
- fastapi_streams/codegen/openapi.py +154 -0
- fastapi_streams/codegen/python.py +473 -0
- fastapi_streams/codegen/schemas/LICENSE +201 -0
- fastapi_streams/codegen/schemas/README.md +8 -0
- fastapi_streams/codegen/schemas/openapi-3.2.json +1729 -0
- fastapi_streams/codegen/templates/errors.ts +26 -0
- fastapi_streams/codegen/templates/python/api.py.j2 +52 -0
- fastapi_streams/codegen/templates/python/client.py.j2 +40 -0
- fastapi_streams/codegen/templates/python/runtime.py +217 -0
- fastapi_streams/codegen/templates/sse.ts +264 -0
- fastapi_streams/codegen/templates/typescript/api.ts.j2 +52 -0
- fastapi_streams/codegen/templates/typescript/client.ts.j2 +15 -0
- fastapi_streams/codegen/templating.py +19 -0
- fastapi_streams/codegen/typescript.py +220 -0
- fastapi_streams/codegen/writer.py +64 -0
- fastapi_streams/py.typed +0 -0
- fastapi_streams/router.py +133 -0
- fastapi_streams-0.0.1.dist-info/METADATA +238 -0
- fastapi_streams-0.0.1.dist-info/RECORD +28 -0
- fastapi_streams-0.0.1.dist-info/WHEEL +4 -0
- fastapi_streams-0.0.1.dist-info/entry_points.txt +2 -0
- fastapi_streams-0.0.1.dist-info/licenses/LICENSE +21 -0
|
File without changes
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import importlib
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import cast
|
|
7
|
+
|
|
8
|
+
from fastapi import FastAPI
|
|
9
|
+
|
|
10
|
+
from fastapi_streams.codegen.openapi import ContractError, Document, export_schema
|
|
11
|
+
from fastapi_streams.codegen.writer import write_files
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def main(argv: list[str] | None = None) -> int:
|
|
15
|
+
parser = argparse.ArgumentParser(prog="fastapi-streams")
|
|
16
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
17
|
+
schema = commands.add_parser("schema", help="Export and validate OpenAPI 3.2")
|
|
18
|
+
schema.add_argument("--app", required=True)
|
|
19
|
+
schema.add_argument("--output", required=True, type=Path)
|
|
20
|
+
schema.add_argument("--check", action="store_true")
|
|
21
|
+
for name in ("generate", "check"):
|
|
22
|
+
command = commands.add_parser(
|
|
23
|
+
name,
|
|
24
|
+
help="Generate a client"
|
|
25
|
+
if name == "generate"
|
|
26
|
+
else "Check schema and generated-client drift",
|
|
27
|
+
)
|
|
28
|
+
command.add_argument("--schema", required=True, type=Path)
|
|
29
|
+
command.add_argument(
|
|
30
|
+
"--language", choices=["typescript", "python"], default="typescript"
|
|
31
|
+
)
|
|
32
|
+
command.add_argument("--output", required=True, type=Path)
|
|
33
|
+
command.add_argument(
|
|
34
|
+
"--python-http-client",
|
|
35
|
+
choices=["httpx", "httpx2"],
|
|
36
|
+
help="Python HTTP library (default: httpx; opt in with httpx2)",
|
|
37
|
+
)
|
|
38
|
+
if name == "generate":
|
|
39
|
+
command.add_argument("--check", action="store_true")
|
|
40
|
+
else:
|
|
41
|
+
command.set_defaults(check=True)
|
|
42
|
+
command.add_argument(
|
|
43
|
+
"--app", help="Also compare the app's contract with --schema"
|
|
44
|
+
)
|
|
45
|
+
args = parser.parse_args(argv)
|
|
46
|
+
try:
|
|
47
|
+
if args.command == "schema":
|
|
48
|
+
content = (
|
|
49
|
+
json.dumps(
|
|
50
|
+
export_schema(_load_app(args.app)),
|
|
51
|
+
indent=2,
|
|
52
|
+
sort_keys=True,
|
|
53
|
+
ensure_ascii=False,
|
|
54
|
+
)
|
|
55
|
+
+ "\n"
|
|
56
|
+
)
|
|
57
|
+
output = cast(Path, args.output)
|
|
58
|
+
if args.check:
|
|
59
|
+
if not output.is_file() or output.read_bytes() != content.encode(
|
|
60
|
+
"utf-8"
|
|
61
|
+
):
|
|
62
|
+
print(f"Out of date: {output}", file=sys.stderr)
|
|
63
|
+
return 1
|
|
64
|
+
else:
|
|
65
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
output.write_text(content, encoding="utf-8", newline="\n")
|
|
67
|
+
else:
|
|
68
|
+
if args.language != "python" and args.python_http_client is not None:
|
|
69
|
+
raise ContractError("--python-http-client requires --language python")
|
|
70
|
+
from fastapi_streams.codegen.ir import build_ir
|
|
71
|
+
from fastapi_streams.codegen.python import generate_python
|
|
72
|
+
from fastapi_streams.codegen.typescript import generate_typescript
|
|
73
|
+
|
|
74
|
+
document = json.loads(args.schema.read_text(encoding="utf-8"))
|
|
75
|
+
if not isinstance(document, dict):
|
|
76
|
+
raise ContractError("The OpenAPI document must be an object")
|
|
77
|
+
if args.command == "check" and args.app:
|
|
78
|
+
expected = export_schema(_load_app(args.app))
|
|
79
|
+
if expected != document:
|
|
80
|
+
print(
|
|
81
|
+
f"Out of date: {args.schema} (application contract changed)",
|
|
82
|
+
file=sys.stderr,
|
|
83
|
+
)
|
|
84
|
+
return 1
|
|
85
|
+
contract = build_ir(cast(Document, document))
|
|
86
|
+
generated = (
|
|
87
|
+
generate_python(
|
|
88
|
+
contract, http_client=args.python_http_client or "httpx"
|
|
89
|
+
)
|
|
90
|
+
if args.language == "python"
|
|
91
|
+
else generate_typescript(contract)
|
|
92
|
+
)
|
|
93
|
+
changed = write_files(args.output, generated, check=args.check)
|
|
94
|
+
if args.check and changed:
|
|
95
|
+
for path in changed:
|
|
96
|
+
print(f"Out of date: {path}", file=sys.stderr)
|
|
97
|
+
return 1
|
|
98
|
+
except (ContractError, OSError, ValueError, ImportError, AttributeError) as exc:
|
|
99
|
+
message = str(exc)
|
|
100
|
+
if isinstance(exc, ImportError) and exc.name in {"jinja2", "jsonschema"}:
|
|
101
|
+
message = "Install fastapi-streams[codegen] to generate clients"
|
|
102
|
+
print(f"fastapi-streams: {message}", file=sys.stderr)
|
|
103
|
+
return 2
|
|
104
|
+
return 0
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _load_app(value: str) -> FastAPI:
|
|
108
|
+
module, separator, attribute = value.partition(":")
|
|
109
|
+
if not separator or not module or not attribute:
|
|
110
|
+
raise ContractError("--app must use module:attribute syntax")
|
|
111
|
+
# Console scripts start with their installation directory on sys.path.
|
|
112
|
+
sys.path.insert(0, str(Path.cwd()))
|
|
113
|
+
app = getattr(importlib.import_module(module), attribute)
|
|
114
|
+
if not isinstance(app, FastAPI):
|
|
115
|
+
raise ContractError(f"{value} is not a FastAPI application")
|
|
116
|
+
return app
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
if __name__ == "__main__":
|
|
120
|
+
sys.exit(main())
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
from dataclasses import replace
|
|
2
|
+
|
|
3
|
+
from fastapi_streams.codegen.ir import (
|
|
4
|
+
Contract,
|
|
5
|
+
Declaration,
|
|
6
|
+
EnumAlias,
|
|
7
|
+
EnumMember,
|
|
8
|
+
EnumValue,
|
|
9
|
+
Field,
|
|
10
|
+
LiteralType,
|
|
11
|
+
Object,
|
|
12
|
+
Operation,
|
|
13
|
+
Reference,
|
|
14
|
+
StringEnum,
|
|
15
|
+
Type,
|
|
16
|
+
Union,
|
|
17
|
+
)
|
|
18
|
+
from fastapi_streams.codegen.names import identifier, python_identifier
|
|
19
|
+
from fastapi_streams.codegen.openapi import ContractError
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def with_enums(contract: Contract) -> Contract:
|
|
23
|
+
declarations = {
|
|
24
|
+
declaration.name: declaration.type for declaration in contract.declarations
|
|
25
|
+
}
|
|
26
|
+
operations: list[Operation] = []
|
|
27
|
+
for operation in contract.operations:
|
|
28
|
+
if isinstance(operation.event, Union) and operation.event.discriminator:
|
|
29
|
+
name = identifier(operation.operation_id, upper=True) + "Event"
|
|
30
|
+
if name in declarations:
|
|
31
|
+
raise ContractError(f"Event alias name collision: {name}")
|
|
32
|
+
declarations[name] = operation.event
|
|
33
|
+
operation = replace(operation, event=Reference(name))
|
|
34
|
+
operations.append(operation)
|
|
35
|
+
candidates: list[tuple[str, set[tuple[str, str]], set[str]]] = []
|
|
36
|
+
for name, type_ in sorted(declarations.items()):
|
|
37
|
+
if not isinstance(type_, Union):
|
|
38
|
+
continue
|
|
39
|
+
if all(
|
|
40
|
+
isinstance(member, LiteralType) and isinstance(member.value, str)
|
|
41
|
+
for member in type_.members
|
|
42
|
+
):
|
|
43
|
+
values = {
|
|
44
|
+
member.value
|
|
45
|
+
for member in type_.members
|
|
46
|
+
if isinstance(member, LiteralType) and isinstance(member.value, str)
|
|
47
|
+
}
|
|
48
|
+
declarations[name] = StringEnum(_members(values))
|
|
49
|
+
elif type_.discriminator:
|
|
50
|
+
fields: set[tuple[str, str]] = set()
|
|
51
|
+
values: set[str] = set()
|
|
52
|
+
for member in type_.members:
|
|
53
|
+
if not isinstance(member, Reference):
|
|
54
|
+
break
|
|
55
|
+
model = declarations.get(member.name)
|
|
56
|
+
if not isinstance(model, Object):
|
|
57
|
+
break
|
|
58
|
+
field = next(
|
|
59
|
+
(
|
|
60
|
+
field
|
|
61
|
+
for field in model.fields
|
|
62
|
+
if field.name == type_.discriminator
|
|
63
|
+
),
|
|
64
|
+
None,
|
|
65
|
+
)
|
|
66
|
+
literals = (
|
|
67
|
+
field.type.members
|
|
68
|
+
if field and isinstance(field.type, Union)
|
|
69
|
+
else (field.type,)
|
|
70
|
+
if field
|
|
71
|
+
else ()
|
|
72
|
+
)
|
|
73
|
+
if not literals or not all(
|
|
74
|
+
isinstance(part, LiteralType) and isinstance(part.value, str)
|
|
75
|
+
for part in literals
|
|
76
|
+
):
|
|
77
|
+
break
|
|
78
|
+
assert field is not None
|
|
79
|
+
fields.add((member.name, field.name))
|
|
80
|
+
values.update(
|
|
81
|
+
part.value
|
|
82
|
+
for part in literals
|
|
83
|
+
if isinstance(part, LiteralType) and isinstance(part.value, str)
|
|
84
|
+
)
|
|
85
|
+
else:
|
|
86
|
+
candidates.append(
|
|
87
|
+
(name + identifier(type_.discriminator, upper=True), fields, values)
|
|
88
|
+
)
|
|
89
|
+
# Overlapping unions share one enum class, keeping Python narrowing consistent.
|
|
90
|
+
while candidates:
|
|
91
|
+
name, fields, values = candidates.pop(0)
|
|
92
|
+
aliases = {name}
|
|
93
|
+
changed = True
|
|
94
|
+
while changed:
|
|
95
|
+
changed = False
|
|
96
|
+
for candidate in candidates[:]:
|
|
97
|
+
other_name, other_fields, other_values = candidate
|
|
98
|
+
if fields & other_fields:
|
|
99
|
+
candidates.remove(candidate)
|
|
100
|
+
fields |= other_fields
|
|
101
|
+
values |= other_values
|
|
102
|
+
aliases.add(other_name)
|
|
103
|
+
changed = True
|
|
104
|
+
canonical = min(aliases)
|
|
105
|
+
enum = StringEnum(_members(values))
|
|
106
|
+
for alias in sorted(aliases):
|
|
107
|
+
if alias in declarations and declarations[alias] != enum:
|
|
108
|
+
raise ContractError(f"Enum type name collision: {alias}")
|
|
109
|
+
declarations[alias] = enum if alias == canonical else EnumAlias(canonical)
|
|
110
|
+
by_value = {member.value: member.name for member in enum.members}
|
|
111
|
+
for model_name, field_name in fields:
|
|
112
|
+
model = declarations[model_name]
|
|
113
|
+
assert isinstance(model, Object)
|
|
114
|
+
declarations[model_name] = replace(
|
|
115
|
+
model,
|
|
116
|
+
fields=tuple(
|
|
117
|
+
replace(field, type=_enum_literals(field.type, canonical, by_value))
|
|
118
|
+
if field.name == field_name
|
|
119
|
+
else field
|
|
120
|
+
for field in model.fields
|
|
121
|
+
),
|
|
122
|
+
)
|
|
123
|
+
for name, type_ in list(declarations.items()):
|
|
124
|
+
if not isinstance(type_, Object):
|
|
125
|
+
continue
|
|
126
|
+
model_fields: list[Field] = []
|
|
127
|
+
for field in type_.fields:
|
|
128
|
+
literals = (
|
|
129
|
+
field.type.members if isinstance(field.type, Union) else (field.type,)
|
|
130
|
+
)
|
|
131
|
+
values = {
|
|
132
|
+
part.value
|
|
133
|
+
for part in literals
|
|
134
|
+
if isinstance(part, LiteralType) and isinstance(part.value, str)
|
|
135
|
+
}
|
|
136
|
+
if values and len(values) == len(literals):
|
|
137
|
+
enum_name = name + identifier(field.name, upper=True)
|
|
138
|
+
enum = StringEnum(_members(values))
|
|
139
|
+
if enum_name in declarations and declarations[enum_name] != enum:
|
|
140
|
+
raise ContractError(f"Enum type name collision: {enum_name}")
|
|
141
|
+
declarations[enum_name] = enum
|
|
142
|
+
field = replace(
|
|
143
|
+
field,
|
|
144
|
+
type=Reference(enum_name)
|
|
145
|
+
if len(values) > 1
|
|
146
|
+
else EnumValue(enum_name, enum.members[0].name),
|
|
147
|
+
)
|
|
148
|
+
model_fields.append(field)
|
|
149
|
+
declarations[name] = replace(type_, fields=tuple(model_fields))
|
|
150
|
+
return replace(
|
|
151
|
+
contract,
|
|
152
|
+
operations=tuple(operations),
|
|
153
|
+
declarations=tuple(
|
|
154
|
+
Declaration(name, type_) for name, type_ in sorted(declarations.items())
|
|
155
|
+
),
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _members(values: set[str]) -> tuple[EnumMember, ...]:
|
|
160
|
+
result = tuple(
|
|
161
|
+
EnumMember(python_identifier(value).upper(), value) for value in sorted(values)
|
|
162
|
+
)
|
|
163
|
+
if len({member.name for member in result}) != len(result):
|
|
164
|
+
raise ContractError(f"Enum member names collide: {sorted(values)}")
|
|
165
|
+
return result
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _enum_literals(type_: Type, enum: str, members: dict[str, str]) -> Type:
|
|
169
|
+
if isinstance(type_, LiteralType) and isinstance(type_.value, str):
|
|
170
|
+
return EnumValue(enum, members[type_.value])
|
|
171
|
+
assert isinstance(type_, Union)
|
|
172
|
+
return Union(tuple(_enum_literals(part, enum, members) for part in type_.members))
|