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,830 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import pathlib
6
+ import typing as t
7
+
8
+ from pydantic import BaseModel as PydBaseModel
9
+ from pydantic import ConfigDict
10
+ from pydantic_core import PydanticUndefined
11
+ from pydantic_settings import BaseSettings
12
+
13
+ from audex import utils
14
+ from audex.exceptions import RequiredModuleNotFoundError
15
+
16
+
17
+ class BaseModel(PydBaseModel):
18
+ """Base model class with common configuration for all models.
19
+
20
+ This class sets default configurations for Pydantic models used in
21
+ the Audex project, ensuring consistent behavior across all derived
22
+ models.
23
+ """
24
+
25
+ model_config: t.ClassVar[ConfigDict] = ConfigDict(
26
+ validate_assignment=True,
27
+ extra="ignore",
28
+ arbitrary_types_allowed=True,
29
+ use_enum_values=True,
30
+ populate_by_name=True,
31
+ )
32
+
33
+ def __hash__(self) -> int:
34
+ """Generate a hash based on the model's serializable
35
+ representation.
36
+
37
+ Returns:
38
+ An integer hash value.
39
+ """
40
+ serl_dict = self.model_dump()
41
+ serl_json = json.dumps(serl_dict, sort_keys=True)
42
+ return hash(serl_json)
43
+
44
+ def __repr__(self) -> str:
45
+ field_reprs = ", ".join(
46
+ f"{field_name}={getattr(self, field_name)!r}"
47
+ for field_name in self.model_fields # type: ignore
48
+ )
49
+ return f"MODEL <{self.__class__.__name__}({field_reprs})>"
50
+
51
+
52
+ class Settings(BaseSettings):
53
+ """Base settings class with YAML/JSON/dotenv export capabilities."""
54
+
55
+ # ============================================================================
56
+ # Public Methods - Loading
57
+ # ============================================================================
58
+
59
+ @classmethod
60
+ def from_yaml(cls, path: str | pathlib.Path | os.PathLike[str]) -> t.Self:
61
+ """Load configuration from YAML file.
62
+
63
+ Args:
64
+ path: Path to YAML configuration file.
65
+
66
+ Returns:
67
+ Settings instance.
68
+
69
+ Raises:
70
+ RequiredModuleNotFoundError: If PyYAML is not installed.
71
+ """
72
+ try:
73
+ import yaml
74
+
75
+ data = yaml.safe_load(pathlib.Path(path).read_text(encoding="utf-8"))
76
+ return cls.model_validate(data, strict=False)
77
+ except ImportError as e:
78
+ raise RequiredModuleNotFoundError(
79
+ "`yaml` module is required to load configuration from YAML "
80
+ "files. Please install it using `pip install pyyaml`."
81
+ ) from e
82
+
83
+ # ============================================================================
84
+ # Public Methods - Export
85
+ # ============================================================================
86
+
87
+ def serl(self, include_none: bool = False) -> dict[str, t.Any]:
88
+ """Get a serializable version of the model dump.
89
+
90
+ Args:
91
+ include_none: If True, include fields with None values.
92
+
93
+ Returns:
94
+ Dictionary with only serializable values.
95
+ """
96
+ raw_dump = self.model_dump()
97
+ return self._clean_dict(raw_dump, include_none=include_none)
98
+
99
+ def to_yaml(
100
+ self,
101
+ fpath: str | pathlib.Path | os.PathLike[str],
102
+ exclude_unset: bool = False,
103
+ exclude_none: bool = False,
104
+ with_comments: bool = True,
105
+ ) -> None:
106
+ """Export configuration to YAML file with optional field
107
+ descriptions as comments.
108
+
109
+ Args:
110
+ fpath: Path to the output YAML file.
111
+ exclude_unset: If True, exclude fields with Unset values and empty nested models.
112
+ exclude_none: If True, exclude fields with None values.
113
+ with_comments: If True, include field descriptions as comments.
114
+ """
115
+ descriptions = self._collect_field_desc() if with_comments else {}
116
+ data = self.serl(include_none=True)
117
+
118
+ # Clean unset and none values
119
+ if exclude_unset:
120
+ data = self._clean_unset_recursive(data)
121
+ if exclude_none:
122
+ data = self._clean_none_recursive(data)
123
+
124
+ def write_yaml_value(
125
+ f: t.TextIO,
126
+ key: str,
127
+ value: t.Any,
128
+ field_path: str,
129
+ indent: int = 0,
130
+ ) -> None:
131
+ """Write a single YAML key-value pair with proper
132
+ formatting."""
133
+ indent_str = " " * indent
134
+ desc = descriptions.get(field_path, "")
135
+ comment = f" # {desc}" if desc and with_comments else ""
136
+
137
+ if value is None:
138
+ f.write(f"{indent_str}{key}: ~{comment}\n")
139
+ elif isinstance(value, dict):
140
+ f.write(f"{indent_str}{key}:{comment}\n")
141
+ write_yaml_dict(f, value, field_path, indent + 1)
142
+ elif isinstance(value, list):
143
+ f.write(f"{indent_str}{key}:{comment}\n")
144
+ write_yaml_list(f, value, field_path, indent + 1)
145
+ elif isinstance(value, str) and ("\n" in value or len(value) > 80):
146
+ f.write(f"{indent_str}{key}: |-{comment}\n")
147
+ for line in value.split("\n"):
148
+ f.write(f"{indent_str} {line}\n")
149
+ else:
150
+ yaml_value = self._yaml_repr(value)
151
+ f.write(f"{indent_str}{key}: {yaml_value}{comment}\n")
152
+
153
+ def write_yaml_dict(
154
+ f: t.TextIO,
155
+ obj: dict[str, t.Any],
156
+ prefix: str,
157
+ indent: int = 0,
158
+ ) -> None:
159
+ """Write a dictionary as YAML."""
160
+ for key, value in obj.items():
161
+ field_path = f"{prefix}.{key}" if prefix else key
162
+ write_yaml_value(f, key, value, field_path, indent)
163
+
164
+ def write_yaml_list(
165
+ f: t.TextIO,
166
+ items: list[t.Any],
167
+ prefix: str,
168
+ indent: int = 0,
169
+ ) -> None:
170
+ """Write a list as YAML."""
171
+ indent_str = " " * indent
172
+ for item in items:
173
+ if isinstance(item, dict):
174
+ items_list = list(item.items())
175
+ if items_list:
176
+ first_key, first_value = items_list[0]
177
+ first_field_path = f"{prefix}.{first_key}"
178
+ first_desc = descriptions.get(first_field_path, "")
179
+ first_comment = f" # {first_desc}" if first_desc and with_comments else ""
180
+
181
+ if first_value is None:
182
+ f.write(f"{indent_str}- {first_key}: ~{first_comment}\n")
183
+ elif isinstance(first_value, dict):
184
+ f.write(f"{indent_str}- {first_key}:{first_comment}\n")
185
+ write_yaml_dict(f, first_value, first_field_path, indent + 1)
186
+ elif isinstance(first_value, list):
187
+ f.write(f"{indent_str}- {first_key}:{first_comment}\n")
188
+ write_yaml_list(f, first_value, first_field_path, indent + 1)
189
+ elif isinstance(first_value, str) and (
190
+ "\n" in first_value or len(first_value) > 80
191
+ ):
192
+ f.write(f"{indent_str}- {first_key}: |-{first_comment}\n")
193
+ for line in first_value.split("\n"):
194
+ f.write(f"{indent_str} {line}\n")
195
+ else:
196
+ yaml_val = self._yaml_repr(first_value)
197
+ f.write(f"{indent_str}- {first_key}: {yaml_val}{first_comment}\n")
198
+
199
+ for sub_key, sub_value in items_list[1:]:
200
+ sub_field_path = f"{prefix}.{sub_key}"
201
+ write_yaml_value(f, sub_key, sub_value, sub_field_path, indent + 1)
202
+ elif isinstance(item, list):
203
+ f.write(f"{indent_str}-\n")
204
+ write_yaml_list(f, item, prefix, indent + 1)
205
+ else:
206
+ yaml_item = self._yaml_repr(item)
207
+ f.write(f"{indent_str}- {yaml_item}\n")
208
+
209
+ with pathlib.Path(fpath).open("w", encoding="utf-8") as f:
210
+ write_yaml_dict(f, data, "")
211
+
212
+ def to_system_yaml(
213
+ self,
214
+ fpath: str | pathlib.Path | os.PathLike[str],
215
+ platform: t.Literal["linux", "windows"] | None = None,
216
+ ) -> None:
217
+ """Export system configuration to YAML file with platform-
218
+ specific defaults.
219
+
220
+ This method generates a system configuration file using platform-specific
221
+ default values defined in AudexFieldInfo descriptors. Fields with Unset
222
+ values are omitted. Empty nested models (all fields Unset) are also omitted.
223
+
224
+ Args:
225
+ fpath: Path to the output YAML file
226
+ platform: Target platform ("linux" or "windows"). If None, uses current platform.
227
+ """
228
+ if platform is None:
229
+ platform = "linux" if os.name != "nt" else "windows"
230
+
231
+ # Build data with platform-specific defaults
232
+ data = self._build_platform_data(self.__class__, platform)
233
+
234
+ # Clean Unset values and empty dicts
235
+ data = self._clean_unset_values(data)
236
+
237
+ # Write YAML without comments
238
+ with pathlib.Path(fpath).open("w", encoding="utf-8") as f:
239
+ self._write_system_yaml_header(f, platform)
240
+ self._write_yaml_dict(f, data)
241
+
242
+ def to_dotenv(self, fpath: str | pathlib.Path | os.PathLike[str]) -> None:
243
+ """Export configuration to .env file with field descriptions as
244
+ comments.
245
+
246
+ Args:
247
+ fpath: Path to the output .env file.
248
+ """
249
+ sep = self.model_config.get("env_nested_delimiter") or "__"
250
+ prefix = self.model_config.get("env_prefix") or "AUDEX__"
251
+
252
+ if prefix.endswith(sep):
253
+ prefix = prefix[: -len(sep)]
254
+
255
+ serializable_data = self.serl(include_none=True)
256
+ descriptions = self._collect_field_desc()
257
+
258
+ grouped_keys = {}
259
+ for top_level_key in serializable_data:
260
+ section_data = {top_level_key: serializable_data[top_level_key]}
261
+ flattened = utils.flatten_dict(section_data, sep=sep)
262
+ grouped_keys[top_level_key] = flattened
263
+
264
+ with pathlib.Path(fpath).open("w", encoding="utf-8") as f:
265
+ f.write(
266
+ "# Description: Example environment configuration file for Audex application.\n"
267
+ )
268
+ f.write("# Note: Copy this file to '.env' and modify the values as needed.\n\n")
269
+ for top_key, flattened in grouped_keys.items():
270
+ f.write(f"# {'=' * 70}\n")
271
+
272
+ top_key_desc = descriptions.get(top_key)
273
+ if top_key_desc:
274
+ f.write(f"# {top_key.upper()}: {top_key_desc}\n")
275
+ f.write(f"# {'=' * 70}\n")
276
+ f.write("\n")
277
+
278
+ for key, value in flattened.items():
279
+ env_key = f"{prefix}{sep}{key.upper()}"
280
+
281
+ field_path = key.replace(sep, ".")
282
+ if field_path in descriptions:
283
+ f.write(f"# {descriptions[field_path]}\n")
284
+
285
+ if value is None:
286
+ f.write(f"# {env_key}=\n")
287
+ else:
288
+ formatted_value = self._format_env_value(value)
289
+ f.write(f"{env_key}={formatted_value}\n")
290
+ f.write("\n")
291
+
292
+ # ============================================================================
293
+ # Private Methods - Field Introspection
294
+ # ============================================================================
295
+
296
+ def _collect_field_desc(
297
+ self,
298
+ model: type[BaseModel] | None = None,
299
+ prefix: str = "",
300
+ ) -> dict[str, str]:
301
+ """Recursively collect field descriptions from the model and
302
+ nested models.
303
+
304
+ Args:
305
+ model: The pydantic model to collect descriptions from.
306
+ prefix: The prefix for nested field paths.
307
+
308
+ Returns:
309
+ A dictionary mapping field paths to their descriptions.
310
+ """
311
+ if model is None:
312
+ model = self.__class__
313
+
314
+ descriptions = {}
315
+ for field_name, field_info in model.model_fields.items():
316
+ field_path = f"{prefix}.{field_name}" if prefix else field_name
317
+
318
+ if field_info.description:
319
+ descriptions[field_path] = field_info.description
320
+
321
+ if hasattr(field_info.annotation, "model_fields"):
322
+ nested_descriptions = self._collect_field_desc(
323
+ field_info.annotation, prefix=field_path
324
+ )
325
+ descriptions.update(nested_descriptions)
326
+
327
+ return descriptions
328
+
329
+ # ============================================================================
330
+ # Private Methods - Serialization
331
+ # ============================================================================
332
+
333
+ def _serl_value(self, value: t.Any, include_none: bool = False) -> t.Any:
334
+ """Serialize a value, handling special cases like callables.
335
+
336
+ Args:
337
+ value: The value to serialize.
338
+ include_none: If True, preserve None values in nested structures.
339
+
340
+ Returns:
341
+ Serialized value or None if not serializable.
342
+ """
343
+ if value is None:
344
+ return value if include_none else None
345
+
346
+ if isinstance(value, os.PathLike):
347
+ try:
348
+ return os.fspath(value)
349
+ except Exception:
350
+ return None
351
+
352
+ if callable(value):
353
+ try:
354
+ return self._serl_value(value(), include_none=include_none)
355
+ except Exception:
356
+ return None
357
+
358
+ if isinstance(value, PydBaseModel):
359
+ return value.model_dump()
360
+
361
+ serialized: list[t.Any] | dict[str, t.Any]
362
+
363
+ if isinstance(value, (list, tuple)):
364
+ serialized = []
365
+ for item in value:
366
+ serialized_item = self._serl_value(item, include_none=include_none)
367
+ if include_none or serialized_item is not None:
368
+ serialized.append(serialized_item)
369
+ return serialized if serialized or include_none else None
370
+
371
+ if isinstance(value, dict):
372
+ serialized = {}
373
+ for k, v in value.items():
374
+ serialized_v = self._serl_value(v, include_none=include_none)
375
+ if include_none or serialized_v is not None:
376
+ serialized[k] = serialized_v
377
+ return serialized if serialized or include_none else None
378
+
379
+ if isinstance(value, (str, int, float, bool)):
380
+ return value
381
+
382
+ try:
383
+ json.dumps(value)
384
+ return value
385
+ except (TypeError, ValueError):
386
+ return None
387
+
388
+ def _clean_dict(self, data: dict[str, t.Any], include_none: bool = False) -> dict[str, t.Any]:
389
+ """Recursively clean a dictionary to remove non-serializable
390
+ values.
391
+
392
+ Args:
393
+ data: Dictionary to clean.
394
+ include_none: If True, include fields with None values.
395
+
396
+ Returns:
397
+ Cleaned dictionary.
398
+ """
399
+ cleaned: dict[str, t.Any] = {}
400
+ for key, value in data.items():
401
+ if value is None:
402
+ if include_none:
403
+ cleaned[key] = None
404
+ else:
405
+ serialized = self._serl_value(value, include_none=include_none)
406
+ if serialized is not None or include_none:
407
+ cleaned[key] = serialized
408
+ return cleaned
409
+
410
+ def _clean_unset_recursive(self, data: t.Any) -> t.Any:
411
+ """Recursively remove Unset values and empty nested structures.
412
+
413
+ Args:
414
+ data: Data structure to clean.
415
+
416
+ Returns:
417
+ Cleaned data structure with Unset values and empty dicts removed.
418
+ """
419
+ if isinstance(data, utils.Unset) or isinstance(data, str) and data == "<UNSET>":
420
+ return None
421
+
422
+ if isinstance(data, dict):
423
+ cleaned = {}
424
+ for key, value in data.items():
425
+ # Skip Unset values
426
+ if isinstance(value, utils.Unset) or isinstance(value, str) and value == "<UNSET>":
427
+ continue
428
+
429
+ cleaned_value = self._clean_unset_recursive(value)
430
+
431
+ # Skip None (which indicates Unset was found)
432
+ if cleaned_value is None:
433
+ continue
434
+
435
+ # Skip empty dicts (all nested fields were Unset)
436
+ if isinstance(cleaned_value, dict) and not cleaned_value:
437
+ continue
438
+
439
+ # Skip empty lists
440
+ if isinstance(cleaned_value, list) and not cleaned_value:
441
+ continue
442
+
443
+ cleaned[key] = cleaned_value
444
+
445
+ # Return None if dict is empty (all fields were Unset)
446
+ return cleaned if cleaned else None
447
+
448
+ if isinstance(data, list):
449
+ cleaned_list = []
450
+ for item in data:
451
+ if isinstance(item, utils.Unset) or isinstance(item, str) and item == "<UNSET>":
452
+ continue
453
+
454
+ cleaned_item = self._clean_unset_recursive(item)
455
+ if cleaned_item is not None:
456
+ # Don't add empty dicts or lists
457
+ if isinstance(cleaned_item, (dict, list)) and not cleaned_item:
458
+ continue
459
+ cleaned_list.append(cleaned_item)
460
+
461
+ return cleaned_list if cleaned_list else None
462
+
463
+ return data
464
+
465
+ def _clean_none_recursive(self, data: t.Any) -> t.Any:
466
+ """Recursively remove None values and empty nested structures.
467
+
468
+ Args:
469
+ data: Data structure to clean.
470
+
471
+ Returns:
472
+ Cleaned data structure with None values removed.
473
+ """
474
+ if data is None:
475
+ return None
476
+
477
+ if isinstance(data, dict):
478
+ cleaned = {}
479
+ for key, value in data.items():
480
+ if value is None:
481
+ continue
482
+
483
+ cleaned_value = self._clean_none_recursive(value)
484
+
485
+ if cleaned_value is None:
486
+ continue
487
+
488
+ # Skip empty dicts
489
+ if isinstance(cleaned_value, dict) and not cleaned_value:
490
+ continue
491
+
492
+ # Skip empty lists
493
+ if isinstance(cleaned_value, list) and not cleaned_value:
494
+ continue
495
+
496
+ cleaned[key] = cleaned_value
497
+
498
+ return cleaned if cleaned else None
499
+
500
+ if isinstance(data, list):
501
+ cleaned_list = []
502
+ for item in data:
503
+ if item is None:
504
+ continue
505
+
506
+ cleaned_item = self._clean_none_recursive(item)
507
+ if cleaned_item is not None:
508
+ if isinstance(cleaned_item, (dict, list)) and not cleaned_item:
509
+ continue
510
+ cleaned_list.append(cleaned_item)
511
+
512
+ return cleaned_list if cleaned_list else None
513
+
514
+ return data
515
+
516
+ # ============================================================================
517
+ # Private Methods - System YAML Generation
518
+ # ============================================================================
519
+
520
+ def _build_platform_data(
521
+ self,
522
+ model: type[PydBaseModel],
523
+ platform: str,
524
+ prefix: str = "",
525
+ ) -> dict[str, t.Any]:
526
+ """Recursively build configuration data with platform-specific
527
+ defaults.
528
+
529
+ Args:
530
+ model: Pydantic model to process.
531
+ platform: Target platform.
532
+ prefix: Field path prefix.
533
+
534
+ Returns:
535
+ Dictionary with platform-specific default values.
536
+ """
537
+ from audex.helper.settings.fields import AudexFieldInfo
538
+
539
+ result = {}
540
+
541
+ for field_name, field_info in model.model_fields.items():
542
+ field_path = f"{prefix}.{field_name}" if prefix else field_name
543
+
544
+ # Handle nested models
545
+ if hasattr(field_info.annotation, "model_fields"):
546
+ nested_data = self._build_platform_data(
547
+ field_info.annotation, # type: ignore
548
+ platform,
549
+ field_path,
550
+ )
551
+ # Only add if nested model has content
552
+ if nested_data:
553
+ result[field_name] = nested_data
554
+ continue
555
+
556
+ # Get value based on field type
557
+ if isinstance(field_info, AudexFieldInfo):
558
+ value = field_info.get_platform_default(platform)
559
+ else:
560
+ # Standard field - use current value or default
561
+ current_value = getattr(self, field_name, PydanticUndefined)
562
+ if current_value is not PydanticUndefined:
563
+ value = current_value
564
+ elif field_info.default is not PydanticUndefined:
565
+ value = field_info.default
566
+ elif field_info.default_factory is not None:
567
+ value = field_info.default_factory()
568
+ else:
569
+ value = PydanticUndefined
570
+
571
+ # Serialize the value (handle Pydantic models, lists, dicts, etc.)
572
+ if value is not PydanticUndefined:
573
+ serialized_value = self._serl_value(value, include_none=True)
574
+ result[field_name] = serialized_value
575
+ else:
576
+ result[field_name] = value
577
+
578
+ return result
579
+
580
+ def _clean_unset_values(self, data: t.Any) -> t.Any:
581
+ """Recursively remove Unset values, PydanticUndefined, and empty
582
+ dictionaries.
583
+
584
+ Args:
585
+ data: Data structure to clean.
586
+
587
+ Returns:
588
+ Cleaned data structure, or None if all values were Unset.
589
+ """
590
+ # Check for Unset or PydanticUndefined
591
+ if isinstance(data, utils.Unset) or data is PydanticUndefined:
592
+ return None
593
+
594
+ if isinstance(data, dict):
595
+ cleaned = {}
596
+ for key, value in data.items():
597
+ # Skip Unset and PydanticUndefined
598
+ if isinstance(value, utils.Unset) or value is PydanticUndefined:
599
+ continue
600
+
601
+ cleaned_value = self._clean_unset_values(value)
602
+
603
+ # Skip None values from recursive cleaning
604
+ if cleaned_value is None:
605
+ continue
606
+
607
+ # Skip empty dicts (all fields were Unset)
608
+ if isinstance(cleaned_value, dict) and not cleaned_value:
609
+ continue
610
+
611
+ cleaned[key] = cleaned_value
612
+
613
+ return cleaned if cleaned else None
614
+
615
+ if isinstance(data, list):
616
+ cleaned_list = []
617
+ for item in data:
618
+ if isinstance(item, utils.Unset) or item is PydanticUndefined:
619
+ continue
620
+
621
+ cleaned_item = self._clean_unset_values(item)
622
+ if cleaned_item is not None:
623
+ cleaned_list.append(cleaned_item)
624
+
625
+ return cleaned_list if cleaned_list else None
626
+
627
+ return data
628
+
629
+ def _write_system_yaml_header(self, f: t.TextIO, platform: str) -> None:
630
+ """Write system YAML file header.
631
+
632
+ Args:
633
+ f: File object.
634
+ platform: Target platform.
635
+ """
636
+ import audex
637
+
638
+ f.write("# Audex System Configuration\n")
639
+ f.write(f"# Platform: {platform}\n")
640
+ f.write(f"# Version: {audex.__version__}\n")
641
+ f.write("#\n")
642
+ f.write("# This file is generated automatically. Do not edit manually.\n")
643
+ f.write("# User configuration should be placed in ~/.config/audex/config.yml\n")
644
+ f.write("#\n")
645
+ f.write("# For configuration examples, see: /etc/audex/config.example.yml\n")
646
+ f.write("\n")
647
+
648
+ def _write_yaml_dict(
649
+ self,
650
+ f: t.TextIO,
651
+ obj: dict[str, t.Any],
652
+ indent: int = 0,
653
+ ) -> None:
654
+ """Write a dictionary as YAML without comments.
655
+
656
+ Args:
657
+ f: File object.
658
+ obj: Dictionary to write.
659
+ indent: Indentation level.
660
+ """
661
+ for key, value in obj.items():
662
+ self._write_yaml_value(f, key, value, indent)
663
+
664
+ def _write_yaml_value(
665
+ self,
666
+ f: t.TextIO,
667
+ key: str,
668
+ value: t.Any,
669
+ indent: int = 0,
670
+ ) -> None:
671
+ """Write a single YAML key-value pair without comments.
672
+
673
+ Args:
674
+ f: File object.
675
+ key: Key name.
676
+ value: Value to write.
677
+ indent: Indentation level.
678
+ """
679
+ indent_str = " " * indent
680
+
681
+ if value is None:
682
+ f.write(f"{indent_str}{key}: ~\n")
683
+ elif isinstance(value, dict):
684
+ f.write(f"{indent_str}{key}:\n")
685
+ self._write_yaml_dict(f, value, indent + 1)
686
+ elif isinstance(value, list):
687
+ f.write(f"{indent_str}{key}:\n")
688
+ self._write_yaml_list(f, value, indent + 1)
689
+ elif isinstance(value, str) and ("\n" in value or len(value) > 80):
690
+ f.write(f"{indent_str}{key}: |-\n")
691
+ for line in value.split("\n"):
692
+ f.write(f"{indent_str} {line}\n")
693
+ else:
694
+ yaml_value = self._yaml_repr(value)
695
+ f.write(f"{indent_str}{key}: {yaml_value}\n")
696
+
697
+ def _write_yaml_list(
698
+ self,
699
+ f: t.TextIO,
700
+ items: list[t.Any],
701
+ indent: int = 0,
702
+ ) -> None:
703
+ """Write a list as YAML without comments.
704
+
705
+ Args:
706
+ f: File object.
707
+ items: List items to write.
708
+ indent: Indentation level.
709
+ """
710
+ indent_str = " " * indent
711
+ for item in items:
712
+ if isinstance(item, dict):
713
+ items_list = list(item.items())
714
+ if items_list:
715
+ first_key, first_value = items_list[0]
716
+
717
+ if first_value is None:
718
+ f.write(f"{indent_str}- {first_key}: ~\n")
719
+ elif isinstance(first_value, dict):
720
+ f.write(f"{indent_str}- {first_key}:\n")
721
+ self._write_yaml_dict(f, first_value, indent + 1)
722
+ elif isinstance(first_value, list):
723
+ f.write(f"{indent_str}- {first_key}:\n")
724
+ self._write_yaml_list(f, first_value, indent + 1)
725
+ elif isinstance(first_value, str) and (
726
+ "\n" in first_value or len(first_value) > 80
727
+ ):
728
+ f.write(f"{indent_str}- {first_key}: |-\n")
729
+ for line in first_value.split("\n"):
730
+ f.write(f"{indent_str} {line}\n")
731
+ else:
732
+ yaml_val = self._yaml_repr(first_value)
733
+ f.write(f"{indent_str}- {first_key}: {yaml_val}\n")
734
+
735
+ for sub_key, sub_value in items_list[1:]:
736
+ self._write_yaml_value(f, sub_key, sub_value, indent + 1)
737
+ elif isinstance(item, list):
738
+ f.write(f"{indent_str}-\n")
739
+ self._write_yaml_list(f, item, indent + 1)
740
+ else:
741
+ yaml_item = self._yaml_repr(item)
742
+ f.write(f"{indent_str}- {yaml_item}\n")
743
+
744
+ # ============================================================================
745
+ # Private Methods - YAML Formatting
746
+ # ============================================================================
747
+
748
+ def _yaml_repr(self, value: t.Any) -> str:
749
+ """Convert a Python value to YAML representation string.
750
+
751
+ Args:
752
+ value: The value to convert.
753
+
754
+ Returns:
755
+ YAML string representation.
756
+ """
757
+ if value is None:
758
+ return "~"
759
+ if isinstance(value, bool):
760
+ return "true" if value else "false"
761
+ if isinstance(value, (int, float)):
762
+ return str(value)
763
+ if isinstance(value, str):
764
+ if (
765
+ not value
766
+ or value[0] in "-? :,[]{}#&*! |>'\"%@`"
767
+ or value in ("true", "false", "null", "yes", "no", "on", "off")
768
+ or ":" in value
769
+ or "#" in value
770
+ ):
771
+ escaped = value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
772
+ return f'"{escaped}"'
773
+ return value
774
+ return str(value)
775
+
776
+ # ============================================================================
777
+ # Private Methods - Dotenv Formatting
778
+ # ============================================================================
779
+
780
+ def _format_env_value(self, value: t.Any) -> str:
781
+ """Format a value for .env file with proper quoting.
782
+
783
+ Args:
784
+ value: The value to format.
785
+
786
+ Returns:
787
+ Formatted string value.
788
+ """
789
+ if value is None:
790
+ return ""
791
+
792
+ if isinstance(value, os.PathLike):
793
+ try:
794
+ return os.fspath(value)
795
+ except Exception:
796
+ return ""
797
+
798
+ if isinstance(value, bool):
799
+ return "true" if value else "false"
800
+
801
+ if isinstance(value, (int, float)):
802
+ return str(value)
803
+
804
+ if isinstance(value, str):
805
+ needs_quotes = (
806
+ " " in value
807
+ or "#" in value
808
+ or "=" in value
809
+ or "\n" in value
810
+ or "\t" in value
811
+ or "\r" in value
812
+ or value.startswith(('"', "'"))
813
+ or not value
814
+ )
815
+
816
+ if needs_quotes:
817
+ escaped = value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
818
+ return f'"{escaped}"'
819
+ return value
820
+
821
+ if isinstance(value, (list, dict)):
822
+ json_str = json.dumps(value, separators=(",", ":"), ensure_ascii=False)
823
+ escaped = json_str.replace("\\", "\\\\").replace('"', '\\"')
824
+ return f'"{escaped}"'
825
+
826
+ str_value = str(value)
827
+ if " " in str_value or "#" in str_value:
828
+ escaped = str_value.replace("\\", "\\\\").replace('"', '\\"')
829
+ return f'"{escaped}"'
830
+ return str_value