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.
Files changed (192) hide show
  1. audex/__init__.py +9 -0
  2. audex/__main__.py +7 -0
  3. audex/cli/__init__.py +189 -0
  4. audex/cli/apis/__init__.py +12 -0
  5. audex/cli/apis/init/__init__.py +34 -0
  6. audex/cli/apis/init/gencfg.py +130 -0
  7. audex/cli/apis/init/setup.py +330 -0
  8. audex/cli/apis/init/vprgroup.py +125 -0
  9. audex/cli/apis/serve.py +141 -0
  10. audex/cli/args.py +356 -0
  11. audex/cli/exceptions.py +44 -0
  12. audex/cli/helper/__init__.py +0 -0
  13. audex/cli/helper/ansi.py +193 -0
  14. audex/cli/helper/display.py +288 -0
  15. audex/config/__init__.py +64 -0
  16. audex/config/core/__init__.py +30 -0
  17. audex/config/core/app.py +29 -0
  18. audex/config/core/audio.py +45 -0
  19. audex/config/core/logging.py +163 -0
  20. audex/config/core/session.py +11 -0
  21. audex/config/helper/__init__.py +1 -0
  22. audex/config/helper/client/__init__.py +1 -0
  23. audex/config/helper/client/http.py +28 -0
  24. audex/config/helper/client/websocket.py +21 -0
  25. audex/config/helper/provider/__init__.py +1 -0
  26. audex/config/helper/provider/dashscope.py +13 -0
  27. audex/config/helper/provider/unisound.py +18 -0
  28. audex/config/helper/provider/xfyun.py +23 -0
  29. audex/config/infrastructure/__init__.py +31 -0
  30. audex/config/infrastructure/cache.py +51 -0
  31. audex/config/infrastructure/database.py +48 -0
  32. audex/config/infrastructure/recorder.py +32 -0
  33. audex/config/infrastructure/store.py +19 -0
  34. audex/config/provider/__init__.py +18 -0
  35. audex/config/provider/transcription.py +109 -0
  36. audex/config/provider/vpr.py +99 -0
  37. audex/container.py +40 -0
  38. audex/entity/__init__.py +468 -0
  39. audex/entity/doctor.py +109 -0
  40. audex/entity/doctor.pyi +51 -0
  41. audex/entity/fields.py +401 -0
  42. audex/entity/segment.py +115 -0
  43. audex/entity/segment.pyi +38 -0
  44. audex/entity/session.py +133 -0
  45. audex/entity/session.pyi +47 -0
  46. audex/entity/utterance.py +142 -0
  47. audex/entity/utterance.pyi +48 -0
  48. audex/entity/vp.py +68 -0
  49. audex/entity/vp.pyi +35 -0
  50. audex/exceptions.py +157 -0
  51. audex/filters/__init__.py +692 -0
  52. audex/filters/generated/__init__.py +21 -0
  53. audex/filters/generated/doctor.py +987 -0
  54. audex/filters/generated/segment.py +723 -0
  55. audex/filters/generated/session.py +978 -0
  56. audex/filters/generated/utterance.py +939 -0
  57. audex/filters/generated/vp.py +815 -0
  58. audex/helper/__init__.py +1 -0
  59. audex/helper/hash.py +33 -0
  60. audex/helper/mixin.py +65 -0
  61. audex/helper/net.py +19 -0
  62. audex/helper/settings/__init__.py +830 -0
  63. audex/helper/settings/fields.py +317 -0
  64. audex/helper/stream.py +153 -0
  65. audex/injectors/__init__.py +1 -0
  66. audex/injectors/config.py +12 -0
  67. audex/injectors/lifespan.py +7 -0
  68. audex/lib/__init__.py +1 -0
  69. audex/lib/cache/__init__.py +383 -0
  70. audex/lib/cache/inmemory.py +513 -0
  71. audex/lib/database/__init__.py +83 -0
  72. audex/lib/database/sqlite.py +406 -0
  73. audex/lib/exporter.py +189 -0
  74. audex/lib/injectors/__init__.py +1 -0
  75. audex/lib/injectors/cache.py +25 -0
  76. audex/lib/injectors/container.py +47 -0
  77. audex/lib/injectors/exporter.py +26 -0
  78. audex/lib/injectors/recorder.py +33 -0
  79. audex/lib/injectors/server.py +17 -0
  80. audex/lib/injectors/session.py +18 -0
  81. audex/lib/injectors/sqlite.py +24 -0
  82. audex/lib/injectors/store.py +13 -0
  83. audex/lib/injectors/transcription.py +42 -0
  84. audex/lib/injectors/usb.py +12 -0
  85. audex/lib/injectors/vpr.py +65 -0
  86. audex/lib/injectors/wifi.py +7 -0
  87. audex/lib/recorder.py +844 -0
  88. audex/lib/repos/__init__.py +149 -0
  89. audex/lib/repos/container.py +23 -0
  90. audex/lib/repos/database/__init__.py +1 -0
  91. audex/lib/repos/database/sqlite.py +672 -0
  92. audex/lib/repos/decorators.py +74 -0
  93. audex/lib/repos/doctor.py +286 -0
  94. audex/lib/repos/segment.py +302 -0
  95. audex/lib/repos/session.py +285 -0
  96. audex/lib/repos/tables/__init__.py +70 -0
  97. audex/lib/repos/tables/doctor.py +137 -0
  98. audex/lib/repos/tables/segment.py +113 -0
  99. audex/lib/repos/tables/session.py +140 -0
  100. audex/lib/repos/tables/utterance.py +131 -0
  101. audex/lib/repos/tables/vp.py +102 -0
  102. audex/lib/repos/utterance.py +288 -0
  103. audex/lib/repos/vp.py +286 -0
  104. audex/lib/restful.py +251 -0
  105. audex/lib/server/__init__.py +97 -0
  106. audex/lib/server/auth.py +98 -0
  107. audex/lib/server/handlers.py +248 -0
  108. audex/lib/server/templates/index.html.j2 +226 -0
  109. audex/lib/server/templates/login.html.j2 +111 -0
  110. audex/lib/server/templates/static/script.js +68 -0
  111. audex/lib/server/templates/static/style.css +579 -0
  112. audex/lib/server/types.py +123 -0
  113. audex/lib/session.py +503 -0
  114. audex/lib/store/__init__.py +238 -0
  115. audex/lib/store/localfile.py +411 -0
  116. audex/lib/transcription/__init__.py +33 -0
  117. audex/lib/transcription/dashscope.py +525 -0
  118. audex/lib/transcription/events.py +62 -0
  119. audex/lib/usb.py +554 -0
  120. audex/lib/vpr/__init__.py +38 -0
  121. audex/lib/vpr/unisound/__init__.py +185 -0
  122. audex/lib/vpr/unisound/types.py +469 -0
  123. audex/lib/vpr/xfyun/__init__.py +483 -0
  124. audex/lib/vpr/xfyun/types.py +679 -0
  125. audex/lib/websocket/__init__.py +8 -0
  126. audex/lib/websocket/connection.py +485 -0
  127. audex/lib/websocket/pool.py +991 -0
  128. audex/lib/wifi.py +1146 -0
  129. audex/lifespan.py +75 -0
  130. audex/service/__init__.py +27 -0
  131. audex/service/decorators.py +73 -0
  132. audex/service/doctor/__init__.py +652 -0
  133. audex/service/doctor/const.py +36 -0
  134. audex/service/doctor/exceptions.py +96 -0
  135. audex/service/doctor/types.py +54 -0
  136. audex/service/export/__init__.py +236 -0
  137. audex/service/export/const.py +17 -0
  138. audex/service/export/exceptions.py +34 -0
  139. audex/service/export/types.py +21 -0
  140. audex/service/injectors/__init__.py +1 -0
  141. audex/service/injectors/container.py +53 -0
  142. audex/service/injectors/doctor.py +34 -0
  143. audex/service/injectors/export.py +27 -0
  144. audex/service/injectors/session.py +49 -0
  145. audex/service/session/__init__.py +754 -0
  146. audex/service/session/const.py +34 -0
  147. audex/service/session/exceptions.py +67 -0
  148. audex/service/session/types.py +91 -0
  149. audex/types.py +39 -0
  150. audex/utils.py +287 -0
  151. audex/valueobj/__init__.py +81 -0
  152. audex/valueobj/common/__init__.py +1 -0
  153. audex/valueobj/common/auth.py +84 -0
  154. audex/valueobj/common/email.py +16 -0
  155. audex/valueobj/common/ops.py +22 -0
  156. audex/valueobj/common/phone.py +84 -0
  157. audex/valueobj/common/version.py +72 -0
  158. audex/valueobj/session.py +19 -0
  159. audex/valueobj/utterance.py +15 -0
  160. audex/view/__init__.py +51 -0
  161. audex/view/container.py +17 -0
  162. audex/view/decorators.py +303 -0
  163. audex/view/pages/__init__.py +1 -0
  164. audex/view/pages/dashboard/__init__.py +286 -0
  165. audex/view/pages/dashboard/wifi.py +407 -0
  166. audex/view/pages/login.py +110 -0
  167. audex/view/pages/recording.py +348 -0
  168. audex/view/pages/register.py +202 -0
  169. audex/view/pages/sessions/__init__.py +196 -0
  170. audex/view/pages/sessions/details.py +224 -0
  171. audex/view/pages/sessions/export.py +443 -0
  172. audex/view/pages/settings.py +374 -0
  173. audex/view/pages/voiceprint/__init__.py +1 -0
  174. audex/view/pages/voiceprint/enroll.py +195 -0
  175. audex/view/pages/voiceprint/update.py +195 -0
  176. audex/view/static/css/dashboard.css +452 -0
  177. audex/view/static/css/glass.css +22 -0
  178. audex/view/static/css/global.css +541 -0
  179. audex/view/static/css/login.css +386 -0
  180. audex/view/static/css/recording.css +439 -0
  181. audex/view/static/css/register.css +293 -0
  182. audex/view/static/css/sessions/styles.css +501 -0
  183. audex/view/static/css/settings.css +186 -0
  184. audex/view/static/css/voiceprint/enroll.css +43 -0
  185. audex/view/static/css/voiceprint/styles.css +209 -0
  186. audex/view/static/css/voiceprint/update.css +44 -0
  187. audex/view/static/images/logo.svg +95 -0
  188. audex/view/static/js/recording.js +42 -0
  189. audex-1.0.7a3.dist-info/METADATA +361 -0
  190. audex-1.0.7a3.dist-info/RECORD +192 -0
  191. audex-1.0.7a3.dist-info/WHEEL +4 -0
  192. 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