audex 1.0.7a3__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.
- audex/__init__.py +9 -0
- audex/__main__.py +7 -0
- audex/cli/__init__.py +189 -0
- audex/cli/apis/__init__.py +12 -0
- audex/cli/apis/init/__init__.py +34 -0
- audex/cli/apis/init/gencfg.py +130 -0
- audex/cli/apis/init/setup.py +330 -0
- audex/cli/apis/init/vprgroup.py +125 -0
- audex/cli/apis/serve.py +141 -0
- audex/cli/args.py +356 -0
- audex/cli/exceptions.py +44 -0
- audex/cli/helper/__init__.py +0 -0
- audex/cli/helper/ansi.py +193 -0
- audex/cli/helper/display.py +288 -0
- audex/config/__init__.py +64 -0
- audex/config/core/__init__.py +30 -0
- audex/config/core/app.py +29 -0
- audex/config/core/audio.py +45 -0
- audex/config/core/logging.py +163 -0
- audex/config/core/session.py +11 -0
- audex/config/helper/__init__.py +1 -0
- audex/config/helper/client/__init__.py +1 -0
- audex/config/helper/client/http.py +28 -0
- audex/config/helper/client/websocket.py +21 -0
- audex/config/helper/provider/__init__.py +1 -0
- audex/config/helper/provider/dashscope.py +13 -0
- audex/config/helper/provider/unisound.py +18 -0
- audex/config/helper/provider/xfyun.py +23 -0
- audex/config/infrastructure/__init__.py +31 -0
- audex/config/infrastructure/cache.py +51 -0
- audex/config/infrastructure/database.py +48 -0
- audex/config/infrastructure/recorder.py +32 -0
- audex/config/infrastructure/store.py +19 -0
- audex/config/provider/__init__.py +18 -0
- audex/config/provider/transcription.py +109 -0
- audex/config/provider/vpr.py +99 -0
- audex/container.py +40 -0
- audex/entity/__init__.py +468 -0
- audex/entity/doctor.py +109 -0
- audex/entity/doctor.pyi +51 -0
- audex/entity/fields.py +401 -0
- audex/entity/segment.py +115 -0
- audex/entity/segment.pyi +38 -0
- audex/entity/session.py +133 -0
- audex/entity/session.pyi +47 -0
- audex/entity/utterance.py +142 -0
- audex/entity/utterance.pyi +48 -0
- audex/entity/vp.py +68 -0
- audex/entity/vp.pyi +35 -0
- audex/exceptions.py +157 -0
- audex/filters/__init__.py +692 -0
- audex/filters/generated/__init__.py +21 -0
- audex/filters/generated/doctor.py +987 -0
- audex/filters/generated/segment.py +723 -0
- audex/filters/generated/session.py +978 -0
- audex/filters/generated/utterance.py +939 -0
- audex/filters/generated/vp.py +815 -0
- audex/helper/__init__.py +1 -0
- audex/helper/hash.py +33 -0
- audex/helper/mixin.py +65 -0
- audex/helper/net.py +19 -0
- audex/helper/settings/__init__.py +830 -0
- audex/helper/settings/fields.py +317 -0
- audex/helper/stream.py +153 -0
- audex/injectors/__init__.py +1 -0
- audex/injectors/config.py +12 -0
- audex/injectors/lifespan.py +7 -0
- audex/lib/__init__.py +1 -0
- audex/lib/cache/__init__.py +383 -0
- audex/lib/cache/inmemory.py +513 -0
- audex/lib/database/__init__.py +83 -0
- audex/lib/database/sqlite.py +406 -0
- audex/lib/exporter.py +189 -0
- audex/lib/injectors/__init__.py +1 -0
- audex/lib/injectors/cache.py +25 -0
- audex/lib/injectors/container.py +47 -0
- audex/lib/injectors/exporter.py +26 -0
- audex/lib/injectors/recorder.py +33 -0
- audex/lib/injectors/server.py +17 -0
- audex/lib/injectors/session.py +18 -0
- audex/lib/injectors/sqlite.py +24 -0
- audex/lib/injectors/store.py +13 -0
- audex/lib/injectors/transcription.py +42 -0
- audex/lib/injectors/usb.py +12 -0
- audex/lib/injectors/vpr.py +65 -0
- audex/lib/injectors/wifi.py +7 -0
- audex/lib/recorder.py +844 -0
- audex/lib/repos/__init__.py +149 -0
- audex/lib/repos/container.py +23 -0
- audex/lib/repos/database/__init__.py +1 -0
- audex/lib/repos/database/sqlite.py +672 -0
- audex/lib/repos/decorators.py +74 -0
- audex/lib/repos/doctor.py +286 -0
- audex/lib/repos/segment.py +302 -0
- audex/lib/repos/session.py +285 -0
- audex/lib/repos/tables/__init__.py +70 -0
- audex/lib/repos/tables/doctor.py +137 -0
- audex/lib/repos/tables/segment.py +113 -0
- audex/lib/repos/tables/session.py +140 -0
- audex/lib/repos/tables/utterance.py +131 -0
- audex/lib/repos/tables/vp.py +102 -0
- audex/lib/repos/utterance.py +288 -0
- audex/lib/repos/vp.py +286 -0
- audex/lib/restful.py +251 -0
- audex/lib/server/__init__.py +97 -0
- audex/lib/server/auth.py +98 -0
- audex/lib/server/handlers.py +248 -0
- audex/lib/server/templates/index.html.j2 +226 -0
- audex/lib/server/templates/login.html.j2 +111 -0
- audex/lib/server/templates/static/script.js +68 -0
- audex/lib/server/templates/static/style.css +579 -0
- audex/lib/server/types.py +123 -0
- audex/lib/session.py +503 -0
- audex/lib/store/__init__.py +238 -0
- audex/lib/store/localfile.py +411 -0
- audex/lib/transcription/__init__.py +33 -0
- audex/lib/transcription/dashscope.py +525 -0
- audex/lib/transcription/events.py +62 -0
- audex/lib/usb.py +554 -0
- audex/lib/vpr/__init__.py +38 -0
- audex/lib/vpr/unisound/__init__.py +185 -0
- audex/lib/vpr/unisound/types.py +469 -0
- audex/lib/vpr/xfyun/__init__.py +483 -0
- audex/lib/vpr/xfyun/types.py +679 -0
- audex/lib/websocket/__init__.py +8 -0
- audex/lib/websocket/connection.py +485 -0
- audex/lib/websocket/pool.py +991 -0
- audex/lib/wifi.py +1146 -0
- audex/lifespan.py +75 -0
- audex/service/__init__.py +27 -0
- audex/service/decorators.py +73 -0
- audex/service/doctor/__init__.py +652 -0
- audex/service/doctor/const.py +36 -0
- audex/service/doctor/exceptions.py +96 -0
- audex/service/doctor/types.py +54 -0
- audex/service/export/__init__.py +236 -0
- audex/service/export/const.py +17 -0
- audex/service/export/exceptions.py +34 -0
- audex/service/export/types.py +21 -0
- audex/service/injectors/__init__.py +1 -0
- audex/service/injectors/container.py +53 -0
- audex/service/injectors/doctor.py +34 -0
- audex/service/injectors/export.py +27 -0
- audex/service/injectors/session.py +49 -0
- audex/service/session/__init__.py +754 -0
- audex/service/session/const.py +34 -0
- audex/service/session/exceptions.py +67 -0
- audex/service/session/types.py +91 -0
- audex/types.py +39 -0
- audex/utils.py +287 -0
- audex/valueobj/__init__.py +81 -0
- audex/valueobj/common/__init__.py +1 -0
- audex/valueobj/common/auth.py +84 -0
- audex/valueobj/common/email.py +16 -0
- audex/valueobj/common/ops.py +22 -0
- audex/valueobj/common/phone.py +84 -0
- audex/valueobj/common/version.py +72 -0
- audex/valueobj/session.py +19 -0
- audex/valueobj/utterance.py +15 -0
- audex/view/__init__.py +51 -0
- audex/view/container.py +17 -0
- audex/view/decorators.py +303 -0
- audex/view/pages/__init__.py +1 -0
- audex/view/pages/dashboard/__init__.py +286 -0
- audex/view/pages/dashboard/wifi.py +407 -0
- audex/view/pages/login.py +110 -0
- audex/view/pages/recording.py +348 -0
- audex/view/pages/register.py +202 -0
- audex/view/pages/sessions/__init__.py +196 -0
- audex/view/pages/sessions/details.py +224 -0
- audex/view/pages/sessions/export.py +443 -0
- audex/view/pages/settings.py +374 -0
- audex/view/pages/voiceprint/__init__.py +1 -0
- audex/view/pages/voiceprint/enroll.py +195 -0
- audex/view/pages/voiceprint/update.py +195 -0
- audex/view/static/css/dashboard.css +452 -0
- audex/view/static/css/glass.css +22 -0
- audex/view/static/css/global.css +541 -0
- audex/view/static/css/login.css +386 -0
- audex/view/static/css/recording.css +439 -0
- audex/view/static/css/register.css +293 -0
- audex/view/static/css/sessions/styles.css +501 -0
- audex/view/static/css/settings.css +186 -0
- audex/view/static/css/voiceprint/enroll.css +43 -0
- audex/view/static/css/voiceprint/styles.css +209 -0
- audex/view/static/css/voiceprint/update.css +44 -0
- audex/view/static/images/logo.svg +95 -0
- audex/view/static/js/recording.js +42 -0
- audex-1.0.7a3.dist-info/METADATA +361 -0
- audex-1.0.7a3.dist-info/RECORD +192 -0
- audex-1.0.7a3.dist-info/WHEEL +4 -0
- audex-1.0.7a3.dist-info/entry_points.txt +3 -0
audex/lib/repos/vp.py
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import builtins
|
|
4
|
+
import typing as t
|
|
5
|
+
|
|
6
|
+
import sqlalchemy as sa
|
|
7
|
+
import sqlmodel as sqlm
|
|
8
|
+
|
|
9
|
+
from audex.entity.vp import VP
|
|
10
|
+
from audex.filters import Filter
|
|
11
|
+
from audex.lib.database.sqlite import SQLite
|
|
12
|
+
from audex.lib.repos.database.sqlite import SQLiteRepository
|
|
13
|
+
from audex.lib.repos.tables.vp import VPTable
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class VPRepository(SQLiteRepository[VP]):
|
|
17
|
+
"""SQLite implementation of VP repository.
|
|
18
|
+
|
|
19
|
+
Provides CRUD operations for VP entities with additional specialized
|
|
20
|
+
query methods for utterance management by session and segment.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
__table__ = VPTable
|
|
24
|
+
__tablename__ = VPTable.__tablename__
|
|
25
|
+
|
|
26
|
+
def __init__(self, sqlite: SQLite) -> None:
|
|
27
|
+
super().__init__(sqlite)
|
|
28
|
+
|
|
29
|
+
async def create(self, data: VP, /) -> str:
|
|
30
|
+
"""Create a new utterance in the database.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
data: The utterance entity to create.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
The ID of the created utterance.
|
|
37
|
+
"""
|
|
38
|
+
async with self.sqlite.session() as session:
|
|
39
|
+
utterance_table = VPTable.from_entity(data)
|
|
40
|
+
session.add(utterance_table)
|
|
41
|
+
await session.commit()
|
|
42
|
+
await session.refresh(utterance_table)
|
|
43
|
+
return utterance_table.id
|
|
44
|
+
|
|
45
|
+
async def read(self, id: str, /) -> VP | None:
|
|
46
|
+
"""Read an utterance by ID.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
id: The ID (id) of the utterance to retrieve.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
The utterance entity if found, None otherwise.
|
|
53
|
+
"""
|
|
54
|
+
async with self.sqlite.session() as session:
|
|
55
|
+
stmt = sqlm.select(VPTable).where(VPTable.id == id)
|
|
56
|
+
result = await session.execute(stmt)
|
|
57
|
+
utterance_obj = result.scalar_one_or_none()
|
|
58
|
+
|
|
59
|
+
if utterance_obj is None:
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
return utterance_obj.to_entity()
|
|
63
|
+
|
|
64
|
+
async def first(self, filter: Filter) -> VP | None:
|
|
65
|
+
"""Retrieve the first utterance matching the filter.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
filter: Filter to apply when searching for the utterance.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
The first utterance entity matching the filter, or None if no match.
|
|
72
|
+
"""
|
|
73
|
+
spec = self.build_query_spec(filter)
|
|
74
|
+
|
|
75
|
+
async with self.sqlite.session() as session:
|
|
76
|
+
stmt = sqlm.select(VPTable)
|
|
77
|
+
|
|
78
|
+
for clause in spec.where:
|
|
79
|
+
stmt = stmt.where(clause)
|
|
80
|
+
|
|
81
|
+
for order in spec.order_by:
|
|
82
|
+
stmt = stmt.order_by(order)
|
|
83
|
+
|
|
84
|
+
stmt = stmt.limit(1)
|
|
85
|
+
|
|
86
|
+
result = await session.execute(stmt)
|
|
87
|
+
utterance_obj = result.scalar_one_or_none()
|
|
88
|
+
|
|
89
|
+
if utterance_obj is None:
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
return utterance_obj.to_entity()
|
|
93
|
+
|
|
94
|
+
async def list(
|
|
95
|
+
self,
|
|
96
|
+
arg: builtins.list[str] | t.Optional[Filter] = None, # noqa
|
|
97
|
+
*,
|
|
98
|
+
page_index: int = 0,
|
|
99
|
+
page_size: int = 100,
|
|
100
|
+
) -> builtins.list[VP]:
|
|
101
|
+
"""List utterances by IDs or with optional filtering and
|
|
102
|
+
pagination.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
arg: Either a list of IDs to retrieve, or an optional filter.
|
|
106
|
+
page_index: Zero-based page index for pagination.
|
|
107
|
+
page_size: Number of items per page.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
List of utterance entities matching the criteria.
|
|
111
|
+
"""
|
|
112
|
+
async with self.sqlite.session() as session:
|
|
113
|
+
if isinstance(arg, list):
|
|
114
|
+
if not arg:
|
|
115
|
+
return []
|
|
116
|
+
|
|
117
|
+
stmt = sqlm.select(VPTable).where(sqlm.col(VPTable.id).in_(arg))
|
|
118
|
+
result = await session.execute(stmt)
|
|
119
|
+
utterance_objs = result.scalars().all()
|
|
120
|
+
return [obj.to_entity() for obj in utterance_objs]
|
|
121
|
+
|
|
122
|
+
spec = self.build_query_spec(arg)
|
|
123
|
+
stmt = sqlm.select(VPTable)
|
|
124
|
+
|
|
125
|
+
for clause in spec.where:
|
|
126
|
+
stmt = stmt.where(clause)
|
|
127
|
+
|
|
128
|
+
for order in spec.order_by:
|
|
129
|
+
stmt = stmt.order_by(order)
|
|
130
|
+
|
|
131
|
+
stmt = stmt.offset(page_index * page_size).limit(page_size)
|
|
132
|
+
|
|
133
|
+
result = await session.execute(stmt)
|
|
134
|
+
utterance_objs = result.scalars().all()
|
|
135
|
+
return [obj.to_entity() for obj in utterance_objs]
|
|
136
|
+
|
|
137
|
+
async def update(self, data: VP, /) -> str:
|
|
138
|
+
"""Update an existing utterance.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
data: The utterance entity with updated values.
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
The ID of the updated utterance.
|
|
145
|
+
|
|
146
|
+
Raises:
|
|
147
|
+
ValueError: If the utterance with the given ID does not exist.
|
|
148
|
+
"""
|
|
149
|
+
async with self.sqlite.session() as session:
|
|
150
|
+
stmt = sqlm.select(VPTable).where(VPTable.id == data.id)
|
|
151
|
+
result = await session.execute(stmt)
|
|
152
|
+
utterance_obj = result.scalar_one_or_none()
|
|
153
|
+
|
|
154
|
+
if utterance_obj is None:
|
|
155
|
+
raise ValueError(f"VP with id {data.id} not found")
|
|
156
|
+
|
|
157
|
+
utterance_obj.update(data)
|
|
158
|
+
session.add(utterance_obj)
|
|
159
|
+
await session.commit()
|
|
160
|
+
await session.refresh(utterance_obj)
|
|
161
|
+
return utterance_obj.id
|
|
162
|
+
|
|
163
|
+
async def update_many(self, datas: builtins.list[VP]) -> builtins.list[str]:
|
|
164
|
+
"""Update multiple utterances in the database.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
datas: List of utterance entities with updated values.
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
List of IDs of the updated utterances.
|
|
171
|
+
|
|
172
|
+
Raises:
|
|
173
|
+
ValueError: If any utterance with the given ID does not exist.
|
|
174
|
+
"""
|
|
175
|
+
if not datas:
|
|
176
|
+
return []
|
|
177
|
+
|
|
178
|
+
updated_ids: builtins.list[str] = []
|
|
179
|
+
async with self.sqlite.session() as session:
|
|
180
|
+
ids = [data.id for data in datas]
|
|
181
|
+
stmt = sqlm.select(VPTable).where(sqlm.col(VPTable.id).in_(ids))
|
|
182
|
+
result = await session.execute(stmt)
|
|
183
|
+
table_objs = {obj.id: obj for obj in result.scalars().all()}
|
|
184
|
+
|
|
185
|
+
missing_ids = set(ids) - set(table_objs.keys())
|
|
186
|
+
if missing_ids:
|
|
187
|
+
raise ValueError(f"VPs with IDs {missing_ids} not found")
|
|
188
|
+
|
|
189
|
+
for data in datas:
|
|
190
|
+
utterance_obj = table_objs[data.id]
|
|
191
|
+
utterance_obj.update(data)
|
|
192
|
+
session.add(utterance_obj)
|
|
193
|
+
updated_ids.append(utterance_obj.id)
|
|
194
|
+
|
|
195
|
+
await session.commit()
|
|
196
|
+
return updated_ids
|
|
197
|
+
|
|
198
|
+
async def delete(self, id: str, /) -> bool:
|
|
199
|
+
"""Delete an utterance by ID.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
id: The ID (id) of the utterance to delete.
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
True if the utterance was deleted, False if not found.
|
|
206
|
+
"""
|
|
207
|
+
async with self.sqlite.session() as session:
|
|
208
|
+
stmt = sqlm.select(VPTable).where(VPTable.id == id)
|
|
209
|
+
result = await session.execute(stmt)
|
|
210
|
+
utterance_obj = result.scalar_one_or_none()
|
|
211
|
+
|
|
212
|
+
if utterance_obj is None:
|
|
213
|
+
return False
|
|
214
|
+
|
|
215
|
+
await session.delete(utterance_obj)
|
|
216
|
+
await session.commit()
|
|
217
|
+
return True
|
|
218
|
+
|
|
219
|
+
async def delete_many(
|
|
220
|
+
self,
|
|
221
|
+
arg: builtins.list[str] | t.Optional[Filter] = None, # noqa
|
|
222
|
+
) -> builtins.list[str]:
|
|
223
|
+
"""Delete multiple utterances by IDs or matching a filter.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
arg: Either a list of IDs to delete, or an optional filter.
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
If deleting by IDs, returns list of deleted IDs.
|
|
230
|
+
If deleting by filter, returns count of deleted records.
|
|
231
|
+
"""
|
|
232
|
+
async with self.sqlite.session() as session:
|
|
233
|
+
if isinstance(arg, list):
|
|
234
|
+
if not arg:
|
|
235
|
+
return []
|
|
236
|
+
|
|
237
|
+
stmt = sqlm.select(VPTable).where(sqlm.col(VPTable.id).in_(arg))
|
|
238
|
+
result = await session.execute(stmt)
|
|
239
|
+
utterance_objs = result.scalars().all()
|
|
240
|
+
|
|
241
|
+
if not utterance_objs:
|
|
242
|
+
return []
|
|
243
|
+
|
|
244
|
+
utterance_ids = [obj.id for obj in utterance_objs]
|
|
245
|
+
for obj in utterance_objs:
|
|
246
|
+
await session.delete(obj)
|
|
247
|
+
|
|
248
|
+
await session.commit()
|
|
249
|
+
return utterance_ids
|
|
250
|
+
|
|
251
|
+
spec = self.build_query_spec(arg)
|
|
252
|
+
stmt = sqlm.select(VPTable.id) # type: ignore
|
|
253
|
+
for clause in spec.where:
|
|
254
|
+
stmt = stmt.where(clause)
|
|
255
|
+
|
|
256
|
+
result = await session.execute(stmt)
|
|
257
|
+
utterance_ids = [row[0] for row in result.all()]
|
|
258
|
+
|
|
259
|
+
if not utterance_ids:
|
|
260
|
+
return []
|
|
261
|
+
|
|
262
|
+
delete_stmt = sa.delete(VPTable).where(sqlm.col(VPTable.id).in_(utterance_ids))
|
|
263
|
+
await session.execute(delete_stmt)
|
|
264
|
+
await session.commit()
|
|
265
|
+
|
|
266
|
+
return utterance_ids
|
|
267
|
+
|
|
268
|
+
async def count(self, filter: t.Optional[Filter] = None) -> int: # noqa
|
|
269
|
+
"""Count utterances matching the filter.
|
|
270
|
+
|
|
271
|
+
Args:
|
|
272
|
+
filter: Optional filter to apply. If None, counts all utterances.
|
|
273
|
+
|
|
274
|
+
Returns:
|
|
275
|
+
Number of utterances matching the filter.
|
|
276
|
+
"""
|
|
277
|
+
spec = self.build_query_spec(filter)
|
|
278
|
+
|
|
279
|
+
async with self.sqlite.session() as session:
|
|
280
|
+
stmt = sqlm.select(sa.func.count()).select_from(VPTable)
|
|
281
|
+
|
|
282
|
+
for clause in spec.where:
|
|
283
|
+
stmt = stmt.where(clause)
|
|
284
|
+
|
|
285
|
+
result = await session.execute(stmt)
|
|
286
|
+
return result.scalar_one()
|
audex/lib/restful.py
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typing as t
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
import pydantic as pyd
|
|
7
|
+
import tenacity
|
|
8
|
+
|
|
9
|
+
from audex.helper.mixin import AsyncContextMixin
|
|
10
|
+
|
|
11
|
+
RespT = t.TypeVar("RespT", bound=pyd.BaseModel)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class BearerAuth(httpx.Auth):
|
|
15
|
+
"""HTTP Bearer token authentication handler for httpx.
|
|
16
|
+
|
|
17
|
+
Implements the httpx.Auth interface to automatically add Bearer token
|
|
18
|
+
authentication to requests.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
token: The bearer token to use for authentication.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, token: str):
|
|
25
|
+
self.token = token
|
|
26
|
+
|
|
27
|
+
def auth_flow(self, request: httpx.Request) -> t.Generator[httpx.Request, httpx.Response, None]:
|
|
28
|
+
"""Add Bearer token to request Authorization header.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
request: The HTTP request to authenticate.
|
|
32
|
+
|
|
33
|
+
Yields:
|
|
34
|
+
The authenticated request with Authorization header.
|
|
35
|
+
"""
|
|
36
|
+
request.headers["Authorization"] = f"Bearer {self.token}"
|
|
37
|
+
yield request
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class RESTfulMixin(AsyncContextMixin):
|
|
41
|
+
"""Abstract base class for RESTful API clients with async support.
|
|
42
|
+
|
|
43
|
+
Provides a high-level interface for making HTTP requests to RESTful APIs
|
|
44
|
+
with automatic retry logic, response validation using Pydantic models,
|
|
45
|
+
and built-in error handling.
|
|
46
|
+
|
|
47
|
+
Features:
|
|
48
|
+
- Automatic retry with configurable attempts and wait time
|
|
49
|
+
- Response validation and parsing using Pydantic models
|
|
50
|
+
- Support for Bearer token and custom authentication
|
|
51
|
+
- Proxy support
|
|
52
|
+
- Configurable timeouts and default headers/params
|
|
53
|
+
- Async context manager support
|
|
54
|
+
|
|
55
|
+
Attributes:
|
|
56
|
+
url: The base URL for the API.
|
|
57
|
+
http_client: The underlying httpx AsyncClient instance.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
base_url: Base URL for all API requests.
|
|
61
|
+
auth: Optional httpx authentication handler.
|
|
62
|
+
proxy: Optional proxy URL.
|
|
63
|
+
timeout: Request timeout in seconds. Defaults to 10.0.
|
|
64
|
+
http_client: Optional pre-configured httpx.AsyncClient. If not provided,
|
|
65
|
+
a new client will be created.
|
|
66
|
+
default_headers: Optional default headers for all requests.
|
|
67
|
+
default_params: Optional default query parameters for all requests.
|
|
68
|
+
|
|
69
|
+
Example:
|
|
70
|
+
```python
|
|
71
|
+
class MyAPIResponse(pyd.BaseModel):
|
|
72
|
+
id: str
|
|
73
|
+
name: str
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class MyAPI(RESTful):
|
|
77
|
+
async def get_item(self, item_id: str) -> MyAPIResponse:
|
|
78
|
+
return await self.request(
|
|
79
|
+
f"/items/{item_id}",
|
|
80
|
+
method="GET",
|
|
81
|
+
cast_to=MyAPIResponse,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# Usage
|
|
86
|
+
async with MyAPI(
|
|
87
|
+
"https://api.example.com", auth=BearerAuth("token")
|
|
88
|
+
) as api:
|
|
89
|
+
item = await api.get_item("123")
|
|
90
|
+
print(item.name)
|
|
91
|
+
```
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
def __init__(
|
|
95
|
+
self,
|
|
96
|
+
base_url: str,
|
|
97
|
+
*,
|
|
98
|
+
auth: httpx.Auth | None = None,
|
|
99
|
+
proxy: str | httpx.URL | None = None,
|
|
100
|
+
timeout: float = 10.0,
|
|
101
|
+
http_client: httpx.AsyncClient | None = None,
|
|
102
|
+
default_headers: dict[str, str] | None = None,
|
|
103
|
+
default_params: dict[str, t.Any] | None = None,
|
|
104
|
+
):
|
|
105
|
+
self.url = base_url
|
|
106
|
+
self.http_client = http_client or httpx.AsyncClient(
|
|
107
|
+
base_url=base_url,
|
|
108
|
+
auth=auth,
|
|
109
|
+
proxy=proxy,
|
|
110
|
+
timeout=timeout,
|
|
111
|
+
headers=default_headers,
|
|
112
|
+
params=default_params,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
async def request(
|
|
116
|
+
self,
|
|
117
|
+
endpoint: str,
|
|
118
|
+
*,
|
|
119
|
+
method: t.Literal["GET", "POST", "PUT", "DELETE"] = "GET",
|
|
120
|
+
headers: t.Mapping[str, str] | None = None,
|
|
121
|
+
params: t.Mapping[str, t.Any] | None = None,
|
|
122
|
+
json: t.Mapping[str, t.Any] | None = None,
|
|
123
|
+
cast_to: type[RespT],
|
|
124
|
+
validate: bool = True,
|
|
125
|
+
strict: bool = True,
|
|
126
|
+
raise_for_status: bool = True,
|
|
127
|
+
max_retries: int = 3,
|
|
128
|
+
retry_wait: float = 2.0,
|
|
129
|
+
) -> RespT:
|
|
130
|
+
"""Make an HTTP request with automatic retry and response
|
|
131
|
+
validation.
|
|
132
|
+
|
|
133
|
+
Sends an HTTP request to the specified endpoint and parses the response
|
|
134
|
+
into the specified Pydantic model. Automatically retries on failure and
|
|
135
|
+
validates the response data.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
endpoint: API endpoint path (relative to base_url).
|
|
139
|
+
method: HTTP method to use. Defaults to "GET".
|
|
140
|
+
headers: Optional request headers to merge with default headers.
|
|
141
|
+
params: Optional query parameters to merge with default params.
|
|
142
|
+
json: Optional JSON body for POST/PUT requests.
|
|
143
|
+
cast_to: Pydantic model class to parse the response into.
|
|
144
|
+
validate: Whether to validate response data. Defaults to True.
|
|
145
|
+
strict: Whether to use strict validation (reject extra fields).
|
|
146
|
+
Defaults to True.
|
|
147
|
+
raise_for_status: Whether to raise an exception for HTTP error
|
|
148
|
+
status codes. Defaults to True.
|
|
149
|
+
max_retries: Maximum number of retry attempts. Defaults to 3.
|
|
150
|
+
retry_wait: Wait time in seconds between retries. Defaults to 2.0.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
Parsed response as an instance of cast_to model.
|
|
154
|
+
|
|
155
|
+
Raises:
|
|
156
|
+
httpx.HTTPStatusError: If the response status indicates an error
|
|
157
|
+
after all retries.
|
|
158
|
+
pyd.ValidationError: If response validation fails.
|
|
159
|
+
tenacity.RetryError: If all retry attempts are exhausted.
|
|
160
|
+
|
|
161
|
+
Example:
|
|
162
|
+
```python
|
|
163
|
+
class UserResponse(pyd.BaseModel):
|
|
164
|
+
user_id: str
|
|
165
|
+
username: str
|
|
166
|
+
email: str
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# GET request
|
|
170
|
+
user = await api.request(
|
|
171
|
+
"/users/123", method="GET", cast_to=UserResponse
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
# POST request with retry
|
|
175
|
+
new_user = await api.request(
|
|
176
|
+
"/users",
|
|
177
|
+
method="POST",
|
|
178
|
+
json={"username": "john", "email": "john@example.com"},
|
|
179
|
+
cast_to=UserResponse,
|
|
180
|
+
max_retries=5,
|
|
181
|
+
retry_wait=3.0,
|
|
182
|
+
)
|
|
183
|
+
```
|
|
184
|
+
"""
|
|
185
|
+
|
|
186
|
+
@tenacity.retry(
|
|
187
|
+
stop=tenacity.stop_after_attempt(max_retries),
|
|
188
|
+
wait=tenacity.wait_fixed(retry_wait),
|
|
189
|
+
reraise=True,
|
|
190
|
+
)
|
|
191
|
+
async def _do_request(
|
|
192
|
+
method: str,
|
|
193
|
+
endpoint: str,
|
|
194
|
+
headers: t.Mapping[str, str] | None,
|
|
195
|
+
params: t.Mapping[str, t.Any] | None,
|
|
196
|
+
json: t.Mapping[str, t.Any] | None,
|
|
197
|
+
) -> httpx.Response:
|
|
198
|
+
response = await self.http_client.request(
|
|
199
|
+
method,
|
|
200
|
+
endpoint,
|
|
201
|
+
headers=headers,
|
|
202
|
+
params=params,
|
|
203
|
+
json=json,
|
|
204
|
+
)
|
|
205
|
+
if raise_for_status:
|
|
206
|
+
response.raise_for_status()
|
|
207
|
+
return response
|
|
208
|
+
|
|
209
|
+
response = await _do_request(method, endpoint, headers, params, json)
|
|
210
|
+
if validate:
|
|
211
|
+
return cast_to.model_validate(response.json(), strict=strict)
|
|
212
|
+
return cast_to.model_construct(**response.json())
|
|
213
|
+
|
|
214
|
+
async def close(self) -> None:
|
|
215
|
+
"""Close the HTTP client and cleanup resources.
|
|
216
|
+
|
|
217
|
+
Should be called when the API client is no longer needed to properly
|
|
218
|
+
close all connections. Automatically called when using as a context manager.
|
|
219
|
+
|
|
220
|
+
Example:
|
|
221
|
+
```python
|
|
222
|
+
api = MyAPI("https://api.example.com")
|
|
223
|
+
try:
|
|
224
|
+
await api.get_data()
|
|
225
|
+
finally:
|
|
226
|
+
await api.close()
|
|
227
|
+
|
|
228
|
+
# Or use as context manager
|
|
229
|
+
async with MyAPI("https://api.example.com") as api:
|
|
230
|
+
await api.get_data()
|
|
231
|
+
```
|
|
232
|
+
"""
|
|
233
|
+
await self.http_client.aclose()
|
|
234
|
+
|
|
235
|
+
def __repr__(self) -> str:
|
|
236
|
+
return f"RESTFUL <{self.__class__.__name__} base_url={self.url}>"
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
class BaseModel(pyd.BaseModel):
|
|
240
|
+
"""Base Pydantic model with common configuration.
|
|
241
|
+
|
|
242
|
+
Sets common configurations for all derived models, such as allowing
|
|
243
|
+
arbitrary types and enabling ORM mode.
|
|
244
|
+
"""
|
|
245
|
+
|
|
246
|
+
model_config = pyd.ConfigDict(
|
|
247
|
+
arbitrary_types_allowed=True, # Allow arbitrary types
|
|
248
|
+
serialize_by_alias=True, # Use alias names when serializing
|
|
249
|
+
validate_by_alias=True, # Use alias names when validating
|
|
250
|
+
extra="ignore",
|
|
251
|
+
)
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib.resources
|
|
4
|
+
import pathlib
|
|
5
|
+
import typing as t
|
|
6
|
+
|
|
7
|
+
from starlette.applications import Starlette
|
|
8
|
+
from starlette.middleware import Middleware
|
|
9
|
+
from starlette.routing import Mount
|
|
10
|
+
from starlette.routing import Route
|
|
11
|
+
from starlette.staticfiles import StaticFiles
|
|
12
|
+
from starlette.templating import Jinja2Templates
|
|
13
|
+
|
|
14
|
+
from audex.helper.mixin import LoggingMixin
|
|
15
|
+
from audex.lib.server.auth import AuthMiddleware
|
|
16
|
+
from audex.lib.server.handlers import RequestHandlers
|
|
17
|
+
|
|
18
|
+
if t.TYPE_CHECKING:
|
|
19
|
+
from audex.lib.exporter import Exporter
|
|
20
|
+
from audex.lib.repos.doctor import DoctorRepository
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Server(LoggingMixin):
|
|
24
|
+
__logtag__ = "audex.lib.http"
|
|
25
|
+
|
|
26
|
+
def __init__(self, doctor_repo: DoctorRepository, exporter: Exporter):
|
|
27
|
+
super().__init__()
|
|
28
|
+
self.doctor_repo = doctor_repo
|
|
29
|
+
self.exporter = exporter
|
|
30
|
+
|
|
31
|
+
# Get template directory (relative to this file)
|
|
32
|
+
template_dir = importlib.resources.files("audex.lib.server").joinpath("templates")
|
|
33
|
+
self.templates = Jinja2Templates(directory=str(template_dir))
|
|
34
|
+
|
|
35
|
+
# Create handlers
|
|
36
|
+
self.handlers = RequestHandlers(
|
|
37
|
+
templates=self.templates,
|
|
38
|
+
doctor_repo=doctor_repo,
|
|
39
|
+
exporter=exporter,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# Create app
|
|
43
|
+
self.app = self._create_app()
|
|
44
|
+
self.server: t.Any = None
|
|
45
|
+
|
|
46
|
+
def _create_app(self) -> Starlette:
|
|
47
|
+
"""Create Starlette application."""
|
|
48
|
+
routes = [
|
|
49
|
+
# Pages
|
|
50
|
+
Route("/login", self.handlers.login_page, methods=["GET"]),
|
|
51
|
+
Route("/", self.handlers.index_page, methods=["GET"]),
|
|
52
|
+
# API
|
|
53
|
+
Route("/api/login", self.handlers.api_login, methods=["POST"]),
|
|
54
|
+
Route("/api/logout", self.handlers.api_logout, methods=["POST"]),
|
|
55
|
+
Route("/api/sessions", self.handlers.api_list_sessions, methods=["GET"]),
|
|
56
|
+
Route(
|
|
57
|
+
"/api/sessions/{session_id}/export",
|
|
58
|
+
self.handlers.api_export_session,
|
|
59
|
+
methods=["GET"],
|
|
60
|
+
),
|
|
61
|
+
Route(
|
|
62
|
+
"/api/sessions/export-multiple",
|
|
63
|
+
self.handlers.api_export_multiple,
|
|
64
|
+
methods=["POST"],
|
|
65
|
+
),
|
|
66
|
+
# Static files
|
|
67
|
+
Mount(
|
|
68
|
+
"/static",
|
|
69
|
+
StaticFiles(directory=str(pathlib.Path(__file__).parent / "templates" / "static")),
|
|
70
|
+
name="static",
|
|
71
|
+
),
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
middleware = [
|
|
75
|
+
Middleware(AuthMiddleware, doctor_repo=self.doctor_repo),
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
return Starlette(
|
|
79
|
+
debug=False,
|
|
80
|
+
routes=routes,
|
|
81
|
+
middleware=middleware,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
async def start(self, host: str, port: int) -> None:
|
|
85
|
+
import uvicorn
|
|
86
|
+
|
|
87
|
+
self.logger.info(f"Starting HTTP server on {host}:{port}")
|
|
88
|
+
|
|
89
|
+
config = uvicorn.Config(self.app, host=host, port=port, log_level="info")
|
|
90
|
+
|
|
91
|
+
self.server = uvicorn.Server(config)
|
|
92
|
+
await self.server.serve()
|
|
93
|
+
|
|
94
|
+
async def close(self) -> None:
|
|
95
|
+
if self.server:
|
|
96
|
+
self.server.should_exit = True
|
|
97
|
+
self.logger.info("HTTP server stopped")
|