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.
@@ -0,0 +1,4 @@
1
+ from fastapi_streams.router import StreamRouter
2
+
3
+ __all__ = ["StreamRouter"]
4
+ __version__ = "0.0.1"
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))