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
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ErrorMessages:
|
|
5
|
+
"""Error messages in Chinese for session service."""
|
|
6
|
+
|
|
7
|
+
# Session errors
|
|
8
|
+
SESSION_NOT_FOUND = "未找到指定的会话"
|
|
9
|
+
SESSION_CREATE_FAILED = "创建会话失败,请稍后重试"
|
|
10
|
+
SESSION_UPDATE_FAILED = "更新会话失败,请稍后重试"
|
|
11
|
+
SESSION_DELETE_FAILED = "删除会话失败,请稍后重试"
|
|
12
|
+
|
|
13
|
+
# Recording errors
|
|
14
|
+
RECORDING_START_FAILED = "启动录音失败"
|
|
15
|
+
RECORDING_STOP_FAILED = "停止录音失败"
|
|
16
|
+
RECORDING_IN_PROGRESS = "录音正在进行中,请先停止当前录音"
|
|
17
|
+
|
|
18
|
+
# Segment errors
|
|
19
|
+
SEGMENT_NOT_FOUND = "未找到音频片段"
|
|
20
|
+
SEGMENT_CREATE_FAILED = "保存音频片段失败"
|
|
21
|
+
|
|
22
|
+
# Utterance errors
|
|
23
|
+
UTTERANCE_STORE_FAILED = "保存对话记录失败"
|
|
24
|
+
|
|
25
|
+
# Transcription errors
|
|
26
|
+
TRANSCRIPTION_START_FAILED = "启动语音识别失败"
|
|
27
|
+
TRANSCRIPTION_FAILED = "语音识别失败"
|
|
28
|
+
|
|
29
|
+
# VPR errors
|
|
30
|
+
SPEAKER_VERIFICATION_FAILED = "说话人识别失败"
|
|
31
|
+
NO_VOICEPRINT_FOUND = "未找到声纹注册,请先注册声纹"
|
|
32
|
+
|
|
33
|
+
# Session errors
|
|
34
|
+
NO_ACTIVE_SESSION = "登录状态已过期,请重新登录"
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typing as t
|
|
4
|
+
|
|
5
|
+
from audex.exceptions import AudexError
|
|
6
|
+
from audex.exceptions import InternalError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SessionServiceError(AudexError):
|
|
10
|
+
"""Base exception for session service errors."""
|
|
11
|
+
|
|
12
|
+
default_message = "Session service error"
|
|
13
|
+
code: t.ClassVar[int] = 0x40
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class InternalSessionServiceError(InternalError):
|
|
17
|
+
"""Internal error in session service."""
|
|
18
|
+
|
|
19
|
+
default_message = "Internal session service error"
|
|
20
|
+
code: t.ClassVar[int] = 0x41
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SessionNotFoundError(SessionServiceError):
|
|
24
|
+
"""Raised when session is not found."""
|
|
25
|
+
|
|
26
|
+
__slots__ = ("message", "session_id")
|
|
27
|
+
|
|
28
|
+
default_message = "Session not found"
|
|
29
|
+
code: t.ClassVar[int] = 0x42
|
|
30
|
+
|
|
31
|
+
def __init__(self, message: str | None = None, *, session_id: str) -> None:
|
|
32
|
+
"""Initialize the exception.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
message: Error message. If None, uses default_message with session_id.
|
|
36
|
+
session_id: The ID of the session that was not found.
|
|
37
|
+
"""
|
|
38
|
+
self.session_id = session_id
|
|
39
|
+
full_message = message or f"{self.default_message}: {session_id}"
|
|
40
|
+
super().__init__(full_message)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class SegmentNotFoundError(SessionServiceError):
|
|
44
|
+
"""Raised when segment is not found."""
|
|
45
|
+
|
|
46
|
+
__slots__ = ("message", "segment_id")
|
|
47
|
+
|
|
48
|
+
default_message = "Segment not found"
|
|
49
|
+
code: t.ClassVar[int] = 0x43
|
|
50
|
+
|
|
51
|
+
def __init__(self, message: str | None = None, *, segment_id: str) -> None:
|
|
52
|
+
"""Initialize the exception.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
message: Error message. If None, uses default_message with segment_id.
|
|
56
|
+
segment_id: The ID of the segment that was not found.
|
|
57
|
+
"""
|
|
58
|
+
self.segment_id = segment_id
|
|
59
|
+
full_message = message or f"{self.default_message}: {segment_id}"
|
|
60
|
+
super().__init__(full_message)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class RecordingError(SessionServiceError):
|
|
64
|
+
"""Raised for recording-related errors."""
|
|
65
|
+
|
|
66
|
+
default_message = "Recording operation failed"
|
|
67
|
+
code: t.ClassVar[int] = 0x44
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typing as t
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class CreateSessionCommand(t.NamedTuple):
|
|
7
|
+
"""Command for creating a new session."""
|
|
8
|
+
|
|
9
|
+
doctor_id: str
|
|
10
|
+
patient_name: str | None = None
|
|
11
|
+
clinic_number: str | None = None
|
|
12
|
+
medical_record_number: str | None = None
|
|
13
|
+
diagnosis: str | None = None
|
|
14
|
+
notes: str | None = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class UpdateSessionCommand(t.NamedTuple):
|
|
18
|
+
"""Command for updating an existing session."""
|
|
19
|
+
|
|
20
|
+
session_id: str
|
|
21
|
+
patient_name: str | None = None
|
|
22
|
+
clinic_number: str | None = None
|
|
23
|
+
medical_record_number: str | None = None
|
|
24
|
+
diagnosis: str | None = None
|
|
25
|
+
notes: str | None = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SessionStats(t.TypedDict):
|
|
29
|
+
sessions_count_in_this_month: int
|
|
30
|
+
total_sessions_count: int
|
|
31
|
+
total_duration_in_minutes: int
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Start:
|
|
35
|
+
"""Transcription started event."""
|
|
36
|
+
|
|
37
|
+
__slots__ = ("session_id",)
|
|
38
|
+
|
|
39
|
+
def __init__(self, *, session_id: str):
|
|
40
|
+
self.session_id = session_id
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Delta:
|
|
44
|
+
"""Transcription delta event with optional sequence number.
|
|
45
|
+
|
|
46
|
+
Attributes:
|
|
47
|
+
session_id: Session ID.
|
|
48
|
+
from_at: Start timestamp.
|
|
49
|
+
to_at: End timestamp.
|
|
50
|
+
text: Transcribed text.
|
|
51
|
+
interim: Whether this is an interim result.
|
|
52
|
+
sequence: Utterance sequence number (None for interim results).
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
__slots__ = ("from_at", "interim", "sequence", "session_id", "text", "to_at")
|
|
56
|
+
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
*,
|
|
60
|
+
session_id: str,
|
|
61
|
+
from_at: float,
|
|
62
|
+
to_at: float,
|
|
63
|
+
text: str,
|
|
64
|
+
interim: bool,
|
|
65
|
+
sequence: int | None = None,
|
|
66
|
+
):
|
|
67
|
+
self.session_id = session_id
|
|
68
|
+
self.from_at = from_at
|
|
69
|
+
self.to_at = to_at
|
|
70
|
+
self.text = text
|
|
71
|
+
self.interim = interim
|
|
72
|
+
self.sequence = sequence
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class Done:
|
|
76
|
+
"""Transcription completed event with speaker identification.
|
|
77
|
+
|
|
78
|
+
Attributes:
|
|
79
|
+
session_id: Session ID.
|
|
80
|
+
is_doctor: Whether the speaker is the doctor.
|
|
81
|
+
full_text: Full transcribed text.
|
|
82
|
+
sequence: Utterance sequence number.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
__slots__ = ("full_text", "is_doctor", "sequence", "session_id")
|
|
86
|
+
|
|
87
|
+
def __init__(self, *, session_id: str, is_doctor: bool, full_text: str, sequence: int):
|
|
88
|
+
self.session_id = session_id
|
|
89
|
+
self.is_doctor = is_doctor
|
|
90
|
+
self.full_text = full_text
|
|
91
|
+
self.sequence = sequence
|
audex/types.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import abc
|
|
4
|
+
import typing as t
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class AbstractSession(abc.ABC):
|
|
8
|
+
@abc.abstractmethod
|
|
9
|
+
async def start(self) -> None: ...
|
|
10
|
+
|
|
11
|
+
@abc.abstractmethod
|
|
12
|
+
async def close(self) -> t.Any: ...
|
|
13
|
+
|
|
14
|
+
async def __aenter__(self) -> AbstractSession:
|
|
15
|
+
await self.start()
|
|
16
|
+
return self
|
|
17
|
+
|
|
18
|
+
async def __aexit__(
|
|
19
|
+
self,
|
|
20
|
+
exc_type: type[BaseException] | None,
|
|
21
|
+
exc_value: BaseException | None,
|
|
22
|
+
traceback: t.Any | None,
|
|
23
|
+
) -> None:
|
|
24
|
+
await self.close()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
S = t.TypeVar("S")
|
|
28
|
+
R = t.TypeVar("R")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class DuplexAbstractSession(AbstractSession, t.Generic[S, R], abc.ABC):
|
|
32
|
+
@abc.abstractmethod
|
|
33
|
+
async def finish(self) -> None: ...
|
|
34
|
+
|
|
35
|
+
@abc.abstractmethod
|
|
36
|
+
async def send(self, message: S) -> None: ...
|
|
37
|
+
|
|
38
|
+
@abc.abstractmethod
|
|
39
|
+
def receive(self) -> t.AsyncIterable[R]: ...
|
audex/utils.py
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import datetime
|
|
4
|
+
import enum
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import typing as t
|
|
8
|
+
import uuid
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel
|
|
11
|
+
from pydantic import GetCoreSchemaHandler
|
|
12
|
+
from pydantic_core import CoreSchema
|
|
13
|
+
from pydantic_core.core_schema import no_info_plain_validator_function
|
|
14
|
+
from pydantic_core.core_schema import plain_serializer_function_ser_schema
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def gen_id(prefix: str = "", suffix: str = "", without_hyphen: bool = True, digis: int = 32) -> str:
|
|
18
|
+
"""Generate a unique identifier (UUID) with optional prefix and
|
|
19
|
+
suffix.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
prefix: A string to prepend to the generated UUID (default: "").
|
|
23
|
+
suffix: A string to append to the generated UUID (default: "").
|
|
24
|
+
without_hyphen: Whether to remove hyphens from the UUID
|
|
25
|
+
(default: True).
|
|
26
|
+
digis: Number of digits to include from the UUID (default: 32).
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
A unique identifier string with the specified prefix and suffix.
|
|
30
|
+
"""
|
|
31
|
+
uid = uuid.uuid4()
|
|
32
|
+
uid_str = uid.hex if without_hyphen else str(uid)
|
|
33
|
+
uid_str = uid_str[:digis]
|
|
34
|
+
return f"{prefix}{uid_str}{suffix}"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def utcnow() -> datetime.datetime:
|
|
38
|
+
"""Get the current UTC datetime with timezone info.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
The current UTC datetime with timezone info.
|
|
42
|
+
"""
|
|
43
|
+
return datetime.datetime.now(datetime.UTC)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ANSI:
|
|
47
|
+
"""Enhanced formatter for ANSI color and style formatting in console
|
|
48
|
+
output.
|
|
49
|
+
|
|
50
|
+
Provides organized color constants, helper methods, and context
|
|
51
|
+
managers for applying consistent styling to terminal output.
|
|
52
|
+
Automatically detects color support in the terminal environment.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
class FG(enum.StrEnum):
|
|
56
|
+
"""Foreground colors."""
|
|
57
|
+
|
|
58
|
+
BLACK = "\033[30m"
|
|
59
|
+
RED = "\033[31m"
|
|
60
|
+
GREEN = "\033[32m"
|
|
61
|
+
YELLOW = "\033[33m"
|
|
62
|
+
BLUE = "\033[34m"
|
|
63
|
+
MAGENTA = "\033[35m"
|
|
64
|
+
CYAN = "\033[36m"
|
|
65
|
+
WHITE = "\033[37m"
|
|
66
|
+
GRAY = "\033[90m"
|
|
67
|
+
BRIGHT_RED = "\033[91m"
|
|
68
|
+
BRIGHT_GREEN = "\033[92m"
|
|
69
|
+
BRIGHT_YELLOW = "\033[93m"
|
|
70
|
+
BRIGHT_BLUE = "\033[94m"
|
|
71
|
+
BRIGHT_MAGENTA = "\033[95m"
|
|
72
|
+
BRIGHT_CYAN = "\033[96m"
|
|
73
|
+
BRIGHT_WHITE = "\033[97m"
|
|
74
|
+
|
|
75
|
+
class BG(enum.StrEnum):
|
|
76
|
+
"""Background colors."""
|
|
77
|
+
|
|
78
|
+
BLACK = "\033[40m"
|
|
79
|
+
RED = "\033[41m"
|
|
80
|
+
GREEN = "\033[42m"
|
|
81
|
+
YELLOW = "\033[43m"
|
|
82
|
+
BLUE = "\033[44m"
|
|
83
|
+
MAGENTA = "\033[45m"
|
|
84
|
+
CYAN = "\033[46m"
|
|
85
|
+
WHITE = "\033[47m"
|
|
86
|
+
GRAY = "\033[100m"
|
|
87
|
+
BRIGHT_RED = "\033[101m"
|
|
88
|
+
BRIGHT_GREEN = "\033[102m"
|
|
89
|
+
BRIGHT_YELLOW = "\033[103m"
|
|
90
|
+
BRIGHT_BLUE = "\033[104m"
|
|
91
|
+
BRIGHT_MAGENTA = "\033[105m"
|
|
92
|
+
BRIGHT_CYAN = "\033[106m"
|
|
93
|
+
BRIGHT_WHITE = "\033[107m"
|
|
94
|
+
|
|
95
|
+
class STYLE(enum.StrEnum):
|
|
96
|
+
"""Text styles."""
|
|
97
|
+
|
|
98
|
+
RESET = "\033[0m"
|
|
99
|
+
BOLD = "\033[1m"
|
|
100
|
+
DIM = "\033[2m"
|
|
101
|
+
ITALIC = "\033[3m"
|
|
102
|
+
UNDERLINE = "\033[4m"
|
|
103
|
+
BLINK = "\033[5m"
|
|
104
|
+
REVERSE = "\033[7m"
|
|
105
|
+
HIDDEN = "\033[8m"
|
|
106
|
+
STRIKETHROUGH = "\033[9m"
|
|
107
|
+
|
|
108
|
+
# For backward compatibility
|
|
109
|
+
RESET = STYLE.RESET
|
|
110
|
+
BOLD = STYLE.BOLD
|
|
111
|
+
UNDERLINE = STYLE.UNDERLINE
|
|
112
|
+
REVERSED = STYLE.REVERSE
|
|
113
|
+
RED = FG.BRIGHT_RED
|
|
114
|
+
GREEN = FG.BRIGHT_GREEN
|
|
115
|
+
YELLOW = FG.BRIGHT_YELLOW
|
|
116
|
+
BLUE = FG.BRIGHT_BLUE
|
|
117
|
+
MAGENTA = FG.BRIGHT_MAGENTA
|
|
118
|
+
CYAN = FG.BRIGHT_CYAN
|
|
119
|
+
WHITE = FG.BRIGHT_WHITE
|
|
120
|
+
|
|
121
|
+
# Control whether ANSI colors are enabled
|
|
122
|
+
_enabled = True
|
|
123
|
+
|
|
124
|
+
@classmethod
|
|
125
|
+
def supports_color(cls) -> bool:
|
|
126
|
+
"""Determine if the current terminal supports colors.
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
bool: True if the terminal supports colors, False otherwise.
|
|
130
|
+
"""
|
|
131
|
+
# Check for NO_COLOR environment variable (https://no-color.org/)
|
|
132
|
+
if os.environ.get("NO_COLOR", ""):
|
|
133
|
+
return False
|
|
134
|
+
|
|
135
|
+
# Check for explicit color control
|
|
136
|
+
if os.environ.get("FORCE_COLOR", ""):
|
|
137
|
+
return True
|
|
138
|
+
|
|
139
|
+
# Check if stdout is a TTY
|
|
140
|
+
return hasattr(sys.stdout, "isatty") or sys.stdout.isatty()
|
|
141
|
+
|
|
142
|
+
@classmethod
|
|
143
|
+
def enable(cls, enabled: bool = True) -> None:
|
|
144
|
+
"""Enable or disable ANSI formatting.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
enabled: True to enable colors, False to disable.
|
|
148
|
+
"""
|
|
149
|
+
cls._enabled = enabled and cls.supports_color()
|
|
150
|
+
|
|
151
|
+
@classmethod
|
|
152
|
+
def format(cls, text: str, /, *styles: STYLE | FG | BG | None) -> str:
|
|
153
|
+
"""Format text with the specified ANSI styles.
|
|
154
|
+
|
|
155
|
+
Intelligently reapplies styles after any reset sequences in the text.
|
|
156
|
+
If colors are disabled, returns the original text without formatting.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
text: The text to format.
|
|
160
|
+
*styles: One or more ANSI style codes to apply.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
The formatted text with ANSI styles applied.
|
|
164
|
+
"""
|
|
165
|
+
if not cls._enabled or not styles:
|
|
166
|
+
return text
|
|
167
|
+
if any(s is None for s in styles):
|
|
168
|
+
styles = tuple(s for s in styles if s is not None)
|
|
169
|
+
|
|
170
|
+
style_str = "".join(styles) # type: ignore[arg-type]
|
|
171
|
+
|
|
172
|
+
# Handle text that already contains reset codes
|
|
173
|
+
if cls.STYLE.RESET in text:
|
|
174
|
+
text = text.replace(cls.STYLE.RESET, f"{cls.STYLE.RESET}{style_str}")
|
|
175
|
+
|
|
176
|
+
return f"{style_str}{text}{cls.STYLE.RESET}"
|
|
177
|
+
|
|
178
|
+
@classmethod
|
|
179
|
+
def success(cls, text: str, /) -> str:
|
|
180
|
+
"""Format text as a success message (green, bold)."""
|
|
181
|
+
return cls.format(text, cls.FG.BRIGHT_GREEN, cls.STYLE.BOLD)
|
|
182
|
+
|
|
183
|
+
@classmethod
|
|
184
|
+
def error(cls, text: str, /) -> str:
|
|
185
|
+
"""Format text as an error message (red, bold)."""
|
|
186
|
+
return cls.format(text, cls.FG.BRIGHT_RED, cls.STYLE.BOLD)
|
|
187
|
+
|
|
188
|
+
@classmethod
|
|
189
|
+
def warning(cls, text: str, /) -> str:
|
|
190
|
+
"""Format text as a warning message (yellow, bold)."""
|
|
191
|
+
return cls.format(text, cls.FG.BRIGHT_YELLOW, cls.STYLE.BOLD)
|
|
192
|
+
|
|
193
|
+
@classmethod
|
|
194
|
+
def info(cls, text: str, /) -> str:
|
|
195
|
+
"""Format text as an info message (cyan)."""
|
|
196
|
+
return cls.format(text, cls.FG.BRIGHT_CYAN)
|
|
197
|
+
|
|
198
|
+
@classmethod
|
|
199
|
+
def highlight(cls, text: str, /) -> str:
|
|
200
|
+
"""Format text as highlighted (magenta, bold)."""
|
|
201
|
+
return cls.format(text, cls.FG.BRIGHT_MAGENTA, cls.STYLE.BOLD)
|
|
202
|
+
|
|
203
|
+
@classmethod
|
|
204
|
+
def rgb(cls, text: str, /, r: int, g: int, b: int, background: bool = False) -> str:
|
|
205
|
+
"""Format text with a specific RGB color.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
text: The text to format
|
|
209
|
+
r: R
|
|
210
|
+
g: G
|
|
211
|
+
b: B
|
|
212
|
+
background: If True, set as background color instead of foreground
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
Formatted text with the specified RGB color
|
|
216
|
+
"""
|
|
217
|
+
if not cls._enabled:
|
|
218
|
+
return text
|
|
219
|
+
|
|
220
|
+
code = 48 if background else 38
|
|
221
|
+
color_seq = f"\033[{code};2;{r};{g};{b}m"
|
|
222
|
+
return f"{color_seq}{text}{cls.STYLE.RESET}"
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def flatten_dict(
|
|
226
|
+
m: t.Mapping[str, t.Any],
|
|
227
|
+
/,
|
|
228
|
+
sep: str = ".",
|
|
229
|
+
_parent: str = "",
|
|
230
|
+
) -> dict[str, t.Any]:
|
|
231
|
+
"""Flatten a nested dictionary into a single-level dictionary with
|
|
232
|
+
dot-separated keys.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
m: The nested dictionary to flatten.
|
|
236
|
+
sep: The separator to use between keys (default: '.').
|
|
237
|
+
_parent: The parent key prefix (used for recursion).
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
A flattened dictionary with dot-separated keys.
|
|
241
|
+
"""
|
|
242
|
+
items = [] # type: list[tuple[str, t.Any]]
|
|
243
|
+
for k, v in m.items():
|
|
244
|
+
key = f"{_parent}{sep}{k}" if _parent else k
|
|
245
|
+
if isinstance(v, t.Mapping):
|
|
246
|
+
items.extend(flatten_dict(v, _parent=key, sep=sep).items())
|
|
247
|
+
else:
|
|
248
|
+
items.append((key, v))
|
|
249
|
+
return dict(items)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
class Unset:
|
|
253
|
+
"""A singleton class representing an unset value, distinct from
|
|
254
|
+
None.
|
|
255
|
+
|
|
256
|
+
This class is used to indicate that a value has not been set or
|
|
257
|
+
provided, allowing differentiation between an explicit None value
|
|
258
|
+
and an unset state.
|
|
259
|
+
"""
|
|
260
|
+
|
|
261
|
+
__slots__ = ()
|
|
262
|
+
|
|
263
|
+
def __repr__(self) -> str:
|
|
264
|
+
return "<UNSET>"
|
|
265
|
+
|
|
266
|
+
__str__ = __repr__
|
|
267
|
+
|
|
268
|
+
def __bool__(self) -> bool:
|
|
269
|
+
return False
|
|
270
|
+
|
|
271
|
+
def __eq__(self, other: object) -> bool:
|
|
272
|
+
return isinstance(other, Unset)
|
|
273
|
+
|
|
274
|
+
def __hash__(self) -> int:
|
|
275
|
+
return hash("UNSET")
|
|
276
|
+
|
|
277
|
+
@classmethod
|
|
278
|
+
def __get_pydantic_core_schema__(
|
|
279
|
+
cls, source: type[BaseModel], handler: GetCoreSchemaHandler, /
|
|
280
|
+
) -> CoreSchema:
|
|
281
|
+
return no_info_plain_validator_function(
|
|
282
|
+
lambda v: v if isinstance(v, Unset) else UNSET,
|
|
283
|
+
serialization=plain_serializer_function_ser_schema(lambda v: str(v)),
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
UNSET = Unset()
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import abc
|
|
4
|
+
import builtins
|
|
5
|
+
import enum
|
|
6
|
+
import typing as t
|
|
7
|
+
|
|
8
|
+
import pydantic as pyd
|
|
9
|
+
|
|
10
|
+
from audex.exceptions import ValidationError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class BaseValueObject(pyd.BaseModel, abc.ABC):
|
|
14
|
+
model_config: t.ClassVar[pyd.ConfigDict] = pyd.ConfigDict(
|
|
15
|
+
validate_assignment=True, # Validate on assignment
|
|
16
|
+
extra="forbid", # Disallow extra fields
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
@pyd.model_validator(mode="wrap")
|
|
20
|
+
@classmethod
|
|
21
|
+
def reraise(cls, data: t.Any, handler: pyd.ModelWrapValidatorHandler[t.Self]) -> t.Self:
|
|
22
|
+
try:
|
|
23
|
+
return handler(data)
|
|
24
|
+
except pyd.ValidationError as e:
|
|
25
|
+
raise ValidationError.from_pydantic_validation_err(e) from e
|
|
26
|
+
|
|
27
|
+
def __repr__(self) -> str:
|
|
28
|
+
field_reprs = ", ".join(
|
|
29
|
+
f"{field_name}={getattr(self, field_name)!r}"
|
|
30
|
+
for field_name in self.model_fields # type: ignore
|
|
31
|
+
)
|
|
32
|
+
return f"VALUEOBJECT <{self.__class__.__name__}({field_reprs})>"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
T = t.TypeVar("T")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class SingleValueObject(BaseValueObject, t.Generic[T]):
|
|
39
|
+
value: T
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def parse(cls, value: T, *, validate: bool = True) -> t.Self:
|
|
43
|
+
return cls(value=value) if validate else cls.model_construct(value=value)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class PaginationParams(BaseValueObject):
|
|
47
|
+
page: int = pyd.Field(
|
|
48
|
+
default=1,
|
|
49
|
+
ge=1,
|
|
50
|
+
description="Page number for pagination, starting from 1",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
limit: int = pyd.Field(
|
|
54
|
+
default=10,
|
|
55
|
+
ge=1,
|
|
56
|
+
le=100,
|
|
57
|
+
description="Number of items per page, between 1 and 100",
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class EnumValueObject(enum.Enum):
|
|
62
|
+
@classmethod
|
|
63
|
+
def parse(cls, value: str) -> t.Self:
|
|
64
|
+
try:
|
|
65
|
+
return cls(value)
|
|
66
|
+
except ValueError as e:
|
|
67
|
+
raise ValidationError(
|
|
68
|
+
f"Invalid value '{value}' for enum '{cls.__name__}'. "
|
|
69
|
+
f"Allowed values are: {cls.list()}",
|
|
70
|
+
reason="invalid_enum_value",
|
|
71
|
+
) from e
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def list(cls) -> builtins.list[str]:
|
|
75
|
+
return [member.value for member in cls]
|
|
76
|
+
|
|
77
|
+
def __repr__(self) -> str:
|
|
78
|
+
return f"ENUM VALUEOBJECT <{self.__class__.__name__}.{self.name}>"
|
|
79
|
+
|
|
80
|
+
def __str__(self) -> str:
|
|
81
|
+
return str(self.value)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
import string
|
|
5
|
+
import typing as t
|
|
6
|
+
|
|
7
|
+
from pydantic import Field
|
|
8
|
+
from pydantic import field_validator
|
|
9
|
+
|
|
10
|
+
from audex.helper import hash
|
|
11
|
+
from audex.valueobj import SingleValueObject
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Password(SingleValueObject[str]):
|
|
15
|
+
value: str = Field(
|
|
16
|
+
min_length=8,
|
|
17
|
+
max_length=64,
|
|
18
|
+
pattern=r"^[A-Za-z\d]{8,20}$",
|
|
19
|
+
description="Plain text password, 8-64 characters.",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
@field_validator("value")
|
|
23
|
+
@classmethod
|
|
24
|
+
def validate_password(cls, v: str) -> str:
|
|
25
|
+
if not any(c.islower() for c in v):
|
|
26
|
+
raise ValueError("Password must contain at least one lowercase letter.")
|
|
27
|
+
if not any(c.isupper() for c in v):
|
|
28
|
+
raise ValueError("Password must contain at least one uppercase letter.")
|
|
29
|
+
if not any(c.isdigit() for c in v):
|
|
30
|
+
raise ValueError("Password must contain at least one digit.")
|
|
31
|
+
return v
|
|
32
|
+
|
|
33
|
+
def entropy(self) -> float:
|
|
34
|
+
charset_size = 0
|
|
35
|
+
v = self.value
|
|
36
|
+
if any(c.islower() for c in v):
|
|
37
|
+
charset_size += 26
|
|
38
|
+
if any(c.isupper() for c in v):
|
|
39
|
+
charset_size += 26
|
|
40
|
+
if any(c.isdigit() for c in v):
|
|
41
|
+
charset_size += 10
|
|
42
|
+
if any(c in string.punctuation for c in v):
|
|
43
|
+
charset_size += len(string.punctuation)
|
|
44
|
+
if charset_size == 0:
|
|
45
|
+
charset_size = 1
|
|
46
|
+
return len(v) * math.log2(charset_size)
|
|
47
|
+
|
|
48
|
+
@staticmethod
|
|
49
|
+
def entropy_to_strength_level(
|
|
50
|
+
entropy: float,
|
|
51
|
+
) -> t.Literal["weak", "medium", "strong", "very_strong"]:
|
|
52
|
+
if entropy < 40:
|
|
53
|
+
return "weak"
|
|
54
|
+
if entropy < 60:
|
|
55
|
+
return "medium"
|
|
56
|
+
if entropy < 80:
|
|
57
|
+
return "strong"
|
|
58
|
+
return "very_strong"
|
|
59
|
+
|
|
60
|
+
def strength_level(self) -> t.Literal["weak", "medium", "strong", "very_strong"]:
|
|
61
|
+
e = self.entropy()
|
|
62
|
+
return self.entropy_to_strength_level(e)
|
|
63
|
+
|
|
64
|
+
def hash(self) -> HashedPassword:
|
|
65
|
+
return HashedPassword(value=hash.argon2_hash(self.value))
|
|
66
|
+
|
|
67
|
+
def __eq__(self, other: object) -> bool:
|
|
68
|
+
if not isinstance(other, Password):
|
|
69
|
+
return NotImplemented
|
|
70
|
+
return self.value == other.value
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class HashedPassword(SingleValueObject[str]):
|
|
74
|
+
value: str = Field(
|
|
75
|
+
description="Password hashed by Argon2.",
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def verify(self, password: Password) -> bool:
|
|
79
|
+
return hash.argon2_verify(password.value, self.value)
|
|
80
|
+
|
|
81
|
+
def __eq__(self, other: object) -> bool:
|
|
82
|
+
if not isinstance(other, Password):
|
|
83
|
+
return NotImplemented
|
|
84
|
+
return hash.argon2_verify(other.value, self.value)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pydantic import Field
|
|
4
|
+
|
|
5
|
+
from audex.valueobj import SingleValueObject
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Email(SingleValueObject[str]):
|
|
9
|
+
value: str = Field(
|
|
10
|
+
...,
|
|
11
|
+
description="A valid email address",
|
|
12
|
+
pattern=r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$",
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
def __str__(self) -> str:
|
|
16
|
+
return self.value
|