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
audex/cli/args.py ADDED
@@ -0,0 +1,356 @@
1
+ from __future__ import annotations
2
+
3
+ import abc
4
+ import argparse
5
+ import pathlib
6
+ import typing as t
7
+
8
+ from pydantic import BaseModel
9
+ from pydantic import ConfigDict
10
+ from pydantic import ModelWrapValidatorHandler
11
+ from pydantic import ValidationError
12
+ from pydantic import model_validator
13
+
14
+ from audex import __description__
15
+ from audex import __title__
16
+ from audex import __version__
17
+ from audex.cli.exceptions import InvalidArgumentError
18
+
19
+
20
+ class BaseArgs(BaseModel, abc.ABC):
21
+ """Base class for CLI arguments with automatic argparse integration.
22
+
23
+ Automatically generates argparse arguments from Pydantic fields with support for:
24
+ - Basic types (str, int, float, bool)
25
+ - Optional types (Optional[T])
26
+ - List types (list[T])
27
+ - Literal types for choices
28
+ - Union types
29
+ - Path types
30
+ - Nested subcommands
31
+
32
+ Example:
33
+ ```python
34
+ class MyArgs(BaseArgs):
35
+ name: str = Field(description="Your name")
36
+ age: int = Field(default=18, description="Your age")
37
+ verbose: bool = Field(
38
+ default=False, description="Verbose output"
39
+ )
40
+
41
+ def run(self) -> None:
42
+ print(f"Hello {self.name}, age {self.age}")
43
+
44
+
45
+ parser = argparse.ArgumentParser()
46
+ MyArgs.build_args(parser)
47
+ parser.set_defaults(func=MyArgs.func)
48
+ args = parser.parse_args()
49
+ args.func(args)
50
+ ```
51
+ """
52
+
53
+ @abc.abstractmethod
54
+ def run(self) -> None:
55
+ """Execute the command logic.
56
+
57
+ Must be implemented by subclasses.
58
+ """
59
+ pass
60
+
61
+ @classmethod
62
+ def func(cls, args: argparse.Namespace) -> None:
63
+ """Entry point called by argparse. Parses args and calls run().
64
+
65
+ Args:
66
+ args: The argparse Namespace containing parsed arguments.
67
+ """
68
+ instance = cls.parse_args(args)
69
+ instance.run()
70
+
71
+ @classmethod
72
+ def build_args(cls, parser: argparse.ArgumentParser) -> None:
73
+ """Build argparse arguments from Pydantic fields.
74
+
75
+ Automatically generates appropriate argparse arguments based on field types:
76
+ - bool: Uses store_true/store_false based on default value
77
+ - list[T]: Uses nargs='+' or nargs='*'
78
+ - Optional[T]: Makes argument optional with default None
79
+ - Literal[... ]: Uses choices parameter
80
+ - Path/pathlib.Path: Uses type=pathlib.Path
81
+
82
+ Args:
83
+ parser: The argparse parser to add arguments to.
84
+ """
85
+ for field, fieldinfo in cls.__pydantic_fields__.items():
86
+ # Skip internal fields
87
+ if field.startswith("_"):
88
+ continue
89
+
90
+ arg_name = f"--{field.replace('_', '-')}"
91
+ alias = fieldinfo.alias
92
+ flags = (arg_name, f"-{alias}") if alias else (arg_name,)
93
+ required = fieldinfo.is_required()
94
+ ann = fieldinfo.annotation
95
+ help_text = fieldinfo.description or ""
96
+ default = fieldinfo.default
97
+
98
+ # Unwrap the annotation to get the actual type
99
+ origin = t.get_origin(ann)
100
+ args_types = t.get_args(ann)
101
+
102
+ # Handle Optional[T] (Union[T, None])
103
+ if origin is t.Union:
104
+ # Filter out NoneType
105
+ non_none_types = [a for a in args_types if a is not type(None)]
106
+ if type(None) in args_types and non_none_types:
107
+ # This is Optional[T]
108
+ ann = (
109
+ non_none_types[0]
110
+ if len(non_none_types) == 1
111
+ else t.Union[tuple(non_none_types)] # noqa
112
+ )
113
+ required = False
114
+ origin = t.get_origin(ann)
115
+ args_types = t.get_args(ann)
116
+
117
+ # Handle Literal types
118
+ choices = None
119
+ if origin is t.Literal:
120
+ choices = list(args_types)
121
+ ann = type(choices[0]) if choices else str
122
+ origin = None # Reset origin since we've extracted the type
123
+
124
+ # Handle List types
125
+ if origin is list:
126
+ item_type = args_types[0] if args_types else str
127
+
128
+ # Handle List[Literal[...]]
129
+ if t.get_origin(item_type) is t.Literal:
130
+ choices = list(t.get_args(item_type))
131
+ item_type = type(choices[0]) if choices else str
132
+
133
+ # Determine nargs
134
+ nargs = "*" if not required else "+"
135
+
136
+ parser.add_argument(
137
+ *flags,
138
+ type=item_type,
139
+ nargs=nargs,
140
+ default=default if not required else None,
141
+ help=help_text,
142
+ choices=choices,
143
+ dest=field,
144
+ )
145
+ continue
146
+
147
+ # Handle Path types
148
+ if ann in (pathlib.Path, pathlib.PosixPath, pathlib.WindowsPath):
149
+ if required:
150
+ parser.add_argument(
151
+ *flags,
152
+ required=True,
153
+ type=pathlib.Path,
154
+ help=help_text,
155
+ dest=field,
156
+ )
157
+ else:
158
+ parser.add_argument(
159
+ *flags,
160
+ type=pathlib.Path,
161
+ default=default,
162
+ help=help_text,
163
+ dest=field,
164
+ )
165
+ continue
166
+
167
+ # Handle boolean types
168
+ is_bool = ann is bool or (origin is None and ann is bool)
169
+ if is_bool:
170
+ # Determine action based on default value
171
+ if default is True:
172
+ # If default is True, use store_false with --no-prefix
173
+ neg_flags = (
174
+ (f"--no-{field.replace('_', '-')}", f"-N{alias}")
175
+ if alias
176
+ else (f"--no-{field.replace('_', '-')}",)
177
+ )
178
+ parser.add_argument(
179
+ *neg_flags,
180
+ action="store_false",
181
+ help=help_text or f"Disable {field}",
182
+ dest=field,
183
+ default=True,
184
+ )
185
+ else:
186
+ # If default is False or not set, use store_true
187
+ parser.add_argument(
188
+ *flags,
189
+ action="store_true",
190
+ help=help_text,
191
+ dest=field,
192
+ default=False,
193
+ )
194
+ continue
195
+
196
+ # Handle regular types (str, int, float, etc.)
197
+ try:
198
+ type_func = ann if callable(ann) else str
199
+ except Exception:
200
+ type_func = str
201
+
202
+ if required:
203
+ parser.add_argument(
204
+ *flags,
205
+ required=True,
206
+ type=type_func,
207
+ help=help_text,
208
+ choices=choices,
209
+ dest=field,
210
+ )
211
+ else:
212
+ parser.add_argument(
213
+ *flags,
214
+ default=default,
215
+ type=type_func,
216
+ help=help_text,
217
+ choices=choices,
218
+ dest=field,
219
+ )
220
+
221
+ @classmethod
222
+ def parse_args(cls, args: argparse.Namespace) -> t.Self:
223
+ """Parse argparse Namespace into a Pydantic model instance.
224
+
225
+ Args:
226
+ args: The argparse Namespace to parse.
227
+
228
+ Returns:
229
+ An instance of the class with validated fields.
230
+
231
+ Raises:
232
+ pydantic.ValidationError: If validation fails.
233
+ """
234
+ # Convert Namespace to dict and filter out None values for optional fields
235
+ args_dict = vars(args)
236
+
237
+ # Remove non-field attributes (like 'func')
238
+ field_names = set(cls.__pydantic_fields__.keys())
239
+ filtered_dict = {k: v for k, v in args_dict.items() if k in field_names}
240
+
241
+ return cls.model_validate(filtered_dict, by_alias=False, by_name=True, strict=False)
242
+
243
+ @classmethod
244
+ def register_subparser(
245
+ cls,
246
+ subparsers: argparse._SubParsersAction,
247
+ name: str,
248
+ help_text: str | None = None,
249
+ aliases: list[str] | None = None,
250
+ ) -> argparse.ArgumentParser:
251
+ """Register this command as a subcommand.
252
+
253
+ Supports nested subcommands by returning the parser which can have
254
+ its own subparsers added.
255
+
256
+ Args:
257
+ subparsers: The subparsers action from parent parser.
258
+ name: The name of this subcommand.
259
+ help_text: Help text for this subcommand. If None, uses class docstring.
260
+ aliases: Alternative names for this subcommand.
261
+
262
+ Returns:
263
+ The argument parser for this subcommand, which can be used to
264
+ add nested subcommands.
265
+
266
+ Example:
267
+ ```python
268
+ # Create main parser
269
+ parser = argparse.ArgumentParser()
270
+ subparsers = parser.add_subparsers(
271
+ dest="command", required=True
272
+ )
273
+
274
+ # Register top-level command
275
+ db_parser = DBCommand.register_subparser(
276
+ subparsers, "db", "Database commands"
277
+ )
278
+
279
+ # Add nested subcommands
280
+ db_subparsers = db_parser.add_subparsers(
281
+ dest="db_command", required=True
282
+ )
283
+ MigrateCommand.register_subparser(
284
+ db_subparsers, "migrate", "Run migrations"
285
+ )
286
+ ```
287
+ """
288
+ if help_text is None:
289
+ help_text = cls.__doc__.strip() if cls.__doc__ else f"{name} command"
290
+
291
+ parser = subparsers.add_parser(
292
+ name,
293
+ help=help_text,
294
+ description=help_text,
295
+ aliases=aliases or [],
296
+ )
297
+ cls.build_args(parser)
298
+ parser.set_defaults(func=cls.func)
299
+ return parser
300
+
301
+ @model_validator(mode="wrap")
302
+ @classmethod
303
+ def reraise(cls, data: t.Any, handler: ModelWrapValidatorHandler[t.Self]) -> t.Self:
304
+ """Reraise validation errors with clearer messages.
305
+
306
+ Args:
307
+ data: The input data to validate.
308
+ handler: The model wrap validator handler.
309
+
310
+ Returns:
311
+ The validated model instance.
312
+
313
+ Raises:
314
+ InvalidArgumentError: If validation fails.
315
+ """
316
+ try:
317
+ return handler(data)
318
+ except ValidationError as e:
319
+ raise InvalidArgumentError.from_validation_error(e) from e
320
+
321
+ model_config = ConfigDict(
322
+ extra="ignore",
323
+ frozen=True,
324
+ arbitrary_types_allowed=True, # Allow types like pathlib.Path
325
+ populate_by_name=True, # Allow population by field name
326
+ )
327
+
328
+
329
+ def parser_with_version(
330
+ prog: str = __title__,
331
+ description: str = __description__,
332
+ ) -> argparse.ArgumentParser:
333
+ """Create an argument parser with --version and --help built-in.
334
+
335
+ Helper function to create a parser with common options.
336
+
337
+ Args:
338
+ prog: Program name.
339
+ description: Program description.
340
+
341
+ Returns:
342
+ Configured ArgumentParser with version info.
343
+ """
344
+ parser = argparse.ArgumentParser(
345
+ prog=prog,
346
+ description=description,
347
+ formatter_class=argparse.RawDescriptionHelpFormatter,
348
+ )
349
+ parser.add_argument(
350
+ "--version",
351
+ "-V",
352
+ action="version",
353
+ version=f"%(prog)s {__version__}",
354
+ help="Show the version number and exit.",
355
+ )
356
+ return parser
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ import typing as t
4
+
5
+ from pydantic import ValidationError
6
+
7
+ from audex.exceptions import AudexError
8
+
9
+
10
+ class CLIError(AudexError):
11
+ exit_code: t.ClassVar[int] = 1
12
+ code = 0x30
13
+ default_message = "An error occurred in Audex CLI."
14
+
15
+
16
+ class InvalidArgumentError(CLIError):
17
+ default_message = "Invalid argument provided: {arg}={value!r}\nReason: {reason}"
18
+
19
+ def __init__(self, message: str | None = None, *, arg: str, value: t.Any, reason: str) -> None:
20
+ if message is None:
21
+ message = self.default_message.format(arg=arg, value=value, reason=reason)
22
+ super().__init__(message)
23
+
24
+ @classmethod
25
+ def from_validation_error(cls, error: ValidationError) -> t.Self:
26
+ errors = "; ".join(
27
+ f"{'.'.join(str(loc) for loc in err['loc'])}: {err['msg']}" for err in error.errors()
28
+ )
29
+ return cls(
30
+ arg="; ".join(str(loc) for loc in error.errors()[0]["loc"]),
31
+ value="; ".join(
32
+ str(err["ctx"]["given"]) for err in error.errors() if "given" in err.get("ctx", {})
33
+ ),
34
+ reason=errors,
35
+ )
36
+
37
+
38
+ class IllegalOperationError(CLIError):
39
+ default_message = "Illegal operation: {operation}\nReason: {reason}"
40
+
41
+ def __init__(self, message: str | None = None, *, operation: str, reason: str) -> None:
42
+ if message is None:
43
+ message = self.default_message.format(operation=operation, reason=reason)
44
+ super().__init__(message)
File without changes
@@ -0,0 +1,193 @@
1
+ from __future__ import annotations
2
+
3
+ import enum
4
+ import os
5
+ import sys
6
+
7
+
8
+ class ANSI:
9
+ """Enhanced formatter for ANSI color and style formatting in console
10
+ output.
11
+
12
+ Provides organized color constants, helper methods, and context
13
+ managers for applying consistent styling to terminal output.
14
+ Automatically detects color support in the terminal environment.
15
+ """
16
+
17
+ class FG(enum.StrEnum):
18
+ """Foreground colors."""
19
+
20
+ BLACK = "\033[30m"
21
+ RED = "\033[31m"
22
+ GREEN = "\033[32m"
23
+ YELLOW = "\033[33m"
24
+ BLUE = "\033[34m"
25
+ MAGENTA = "\033[35m"
26
+ CYAN = "\033[36m"
27
+ WHITE = "\033[37m"
28
+ GRAY = "\033[90m"
29
+ BRIGHT_RED = "\033[91m"
30
+ BRIGHT_GREEN = "\033[92m"
31
+ BRIGHT_YELLOW = "\033[93m"
32
+ BRIGHT_BLUE = "\033[94m"
33
+ BRIGHT_MAGENTA = "\033[95m"
34
+ BRIGHT_CYAN = "\033[96m"
35
+ BRIGHT_WHITE = "\033[97m"
36
+
37
+ class BG(enum.StrEnum):
38
+ """Background colors."""
39
+
40
+ BLACK = "\033[40m"
41
+ RED = "\033[41m"
42
+ GREEN = "\033[42m"
43
+ YELLOW = "\033[43m"
44
+ BLUE = "\033[44m"
45
+ MAGENTA = "\033[45m"
46
+ CYAN = "\033[46m"
47
+ WHITE = "\033[47m"
48
+ GRAY = "\033[100m"
49
+ BRIGHT_RED = "\033[101m"
50
+ BRIGHT_GREEN = "\033[102m"
51
+ BRIGHT_YELLOW = "\033[103m"
52
+ BRIGHT_BLUE = "\033[104m"
53
+ BRIGHT_MAGENTA = "\033[105m"
54
+ BRIGHT_CYAN = "\033[106m"
55
+ BRIGHT_WHITE = "\033[107m"
56
+
57
+ class STYLE(enum.StrEnum):
58
+ """Text styles."""
59
+
60
+ RESET = "\033[0m"
61
+ BOLD = "\033[1m"
62
+ DIM = "\033[2m"
63
+ ITALIC = "\033[3m"
64
+ UNDERLINE = "\033[4m"
65
+ BLINK = "\033[5m"
66
+ REVERSE = "\033[7m"
67
+ HIDDEN = "\033[8m"
68
+ STRIKETHROUGH = "\033[9m"
69
+
70
+ # For backward compatibility
71
+ RESET = STYLE.RESET
72
+ BOLD = STYLE.BOLD
73
+ UNDERLINE = STYLE.UNDERLINE
74
+ REVERSED = STYLE.REVERSE
75
+ RED = FG.BRIGHT_RED
76
+ GREEN = FG.BRIGHT_GREEN
77
+ YELLOW = FG.BRIGHT_YELLOW
78
+ BLUE = FG.BRIGHT_BLUE
79
+ MAGENTA = FG.BRIGHT_MAGENTA
80
+ CYAN = FG.BRIGHT_CYAN
81
+ WHITE = FG.BRIGHT_WHITE
82
+
83
+ # Control whether ANSI colors are enabled
84
+ _enabled = True
85
+
86
+ @classmethod
87
+ def supports_color(cls) -> bool:
88
+ """Determine if the current terminal supports colors.
89
+
90
+ Returns:
91
+ bool: True if the terminal supports colors, False otherwise.
92
+ """
93
+ # Check for NO_COLOR environment variable (https://no-color.org/)
94
+ if os.environ.get("NO_COLOR", ""):
95
+ return False
96
+
97
+ # Check for explicit color control
98
+ if os.environ.get("FORCE_COLOR", ""):
99
+ return True
100
+
101
+ # Check if stdout is a TTY
102
+ return hasattr(sys.stdout, "isatty") and sys.stdout.isatty()
103
+
104
+ @classmethod
105
+ def enable(cls, enabled: bool = True) -> None:
106
+ """Enable or disable ANSI formatting.
107
+
108
+ Args:
109
+ enabled: True to enable colors, False to disable.
110
+ """
111
+ cls._enabled = enabled and cls.supports_color()
112
+
113
+ @classmethod
114
+ def format(cls, text: str, /, *styles: STYLE | FG | BG) -> str:
115
+ """Format text with the specified ANSI styles.
116
+
117
+ Intelligently reapplies styles after any reset sequences in the text.
118
+ If colors are disabled, returns the original text without formatting.
119
+
120
+ Args:
121
+ text: The text to format.
122
+ *styles: One or more ANSI style codes to apply.
123
+
124
+ Returns:
125
+ The formatted text with ANSI styles applied.
126
+ """
127
+ if not cls._enabled or not styles:
128
+ return text
129
+
130
+ # Filter out None values and get the actual string values from enums
131
+ valid_styles = [
132
+ str(s.value) if hasattr(s, "value") else str(s) for s in styles if s is not None
133
+ ]
134
+
135
+ if not valid_styles:
136
+ return text
137
+
138
+ style_str = "".join(valid_styles)
139
+
140
+ # Handle text that already contains reset codes
141
+ if cls.STYLE.RESET in text:
142
+ text = text.replace(cls.STYLE.RESET, f"{cls.STYLE.RESET}{style_str}")
143
+
144
+ return f"{style_str}{text}{cls.STYLE.RESET}"
145
+
146
+ @classmethod
147
+ def success(cls, text: str, /) -> str:
148
+ """Format text as a success message (green, bold)."""
149
+ return cls.format(text, cls.FG.BRIGHT_GREEN, cls.STYLE.BOLD)
150
+
151
+ @classmethod
152
+ def error(cls, text: str, /) -> str:
153
+ """Format text as an error message (red, bold)."""
154
+ return cls.format(text, cls.FG.BRIGHT_RED, cls.STYLE.BOLD)
155
+
156
+ @classmethod
157
+ def warning(cls, text: str, /) -> str:
158
+ """Format text as a warning message (yellow, bold)."""
159
+ return cls.format(text, cls.FG.BRIGHT_YELLOW, cls.STYLE.BOLD)
160
+
161
+ @classmethod
162
+ def info(cls, text: str, /) -> str:
163
+ """Format text as an info message (cyan)."""
164
+ return cls.format(text, cls.FG.BRIGHT_CYAN)
165
+
166
+ @classmethod
167
+ def highlight(cls, text: str, /) -> str:
168
+ """Format text as highlighted (magenta, bold)."""
169
+ return cls.format(text, cls.FG.BRIGHT_MAGENTA, cls.STYLE.BOLD)
170
+
171
+ @classmethod
172
+ def rgb(cls, text: str, /, r: int, g: int, b: int, background: bool = False) -> str:
173
+ """Format text with a specific RGB color.
174
+
175
+ Args:
176
+ text: The text to format
177
+ r: Red value (0-255)
178
+ g: Green value (0-255)
179
+ b: Blue value (0-255)
180
+ background: If True, set as background color instead of foreground
181
+
182
+ Returns:
183
+ Formatted text with the specified RGB color
184
+ """
185
+ if not cls._enabled:
186
+ return text
187
+
188
+ code = 48 if background else 38
189
+ color_seq = f"\033[{code};2;{r};{g};{b}m"
190
+ return f"{color_seq}{text}{cls.STYLE.RESET}"
191
+
192
+
193
+ ANSI.enable()