bear-utils 0.7.21__py3-none-any.whl → 0.7.23__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 (79) hide show
  1. bear_utils/__init__.py +24 -1
  2. bear_utils/ai/__init__.py +5 -5
  3. bear_utils/ai/ai_helpers/__init__.py +24 -18
  4. bear_utils/ai/ai_helpers/_parsers.py +27 -21
  5. bear_utils/ai/ai_helpers/_types.py +2 -7
  6. bear_utils/cache/__init__.py +35 -23
  7. bear_utils/cli/__init__.py +13 -0
  8. bear_utils/cli/commands.py +14 -8
  9. bear_utils/cli/prompt_helpers.py +40 -34
  10. bear_utils/cli/shell/__init__.py +1 -0
  11. bear_utils/cli/shell/_base_command.py +18 -18
  12. bear_utils/cli/shell/_base_shell.py +37 -34
  13. bear_utils/config/__init__.py +4 -2
  14. bear_utils/config/config_manager.py +193 -56
  15. bear_utils/config/dir_manager.py +8 -3
  16. bear_utils/config/settings_manager.py +94 -171
  17. bear_utils/constants/__init__.py +2 -1
  18. bear_utils/constants/_exceptions.py +6 -1
  19. bear_utils/constants/date_related.py +2 -0
  20. bear_utils/constants/logger_protocol.py +28 -0
  21. bear_utils/constants/time_related.py +2 -0
  22. bear_utils/database/__init__.py +2 -0
  23. bear_utils/database/_db_manager.py +10 -11
  24. bear_utils/events/__init__.py +3 -1
  25. bear_utils/events/events_class.py +11 -11
  26. bear_utils/events/events_module.py +17 -8
  27. bear_utils/extras/__init__.py +8 -6
  28. bear_utils/extras/_async_helpers.py +2 -3
  29. bear_utils/extras/_tools.py +62 -52
  30. bear_utils/extras/platform_utils.py +5 -1
  31. bear_utils/extras/responses/__init__.py +1 -0
  32. bear_utils/extras/responses/function_response.py +301 -0
  33. bear_utils/extras/wrappers/__init__.py +1 -0
  34. bear_utils/extras/wrappers/add_methods.py +17 -15
  35. bear_utils/files/__init__.py +3 -1
  36. bear_utils/files/file_handlers/__init__.py +2 -0
  37. bear_utils/files/file_handlers/_base_file_handler.py +23 -3
  38. bear_utils/files/file_handlers/file_handler_factory.py +38 -38
  39. bear_utils/files/file_handlers/json_file_handler.py +49 -22
  40. bear_utils/files/file_handlers/log_file_handler.py +19 -12
  41. bear_utils/files/file_handlers/toml_file_handler.py +13 -5
  42. bear_utils/files/file_handlers/txt_file_handler.py +56 -14
  43. bear_utils/files/file_handlers/yaml_file_handler.py +19 -13
  44. bear_utils/files/ignore_parser.py +52 -57
  45. bear_utils/graphics/__init__.py +3 -1
  46. bear_utils/graphics/bear_gradient.py +17 -12
  47. bear_utils/graphics/image_helpers.py +11 -5
  48. bear_utils/gui/__init__.py +3 -1
  49. bear_utils/gui/gui_tools/__init__.py +3 -1
  50. bear_utils/gui/gui_tools/_settings.py +0 -1
  51. bear_utils/gui/gui_tools/qt_app.py +16 -11
  52. bear_utils/gui/gui_tools/qt_color_picker.py +24 -13
  53. bear_utils/gui/gui_tools/qt_file_handler.py +30 -38
  54. bear_utils/gui/gui_tools/qt_input_dialog.py +11 -14
  55. bear_utils/logging/__init__.py +6 -4
  56. bear_utils/logging/logger_manager/__init__.py +1 -0
  57. bear_utils/logging/logger_manager/_common.py +0 -1
  58. bear_utils/logging/logger_manager/_console_junk.py +14 -10
  59. bear_utils/logging/logger_manager/_styles.py +1 -2
  60. bear_utils/logging/logger_manager/loggers/__init__.py +1 -0
  61. bear_utils/logging/logger_manager/loggers/_base_logger.py +33 -36
  62. bear_utils/logging/logger_manager/loggers/_base_logger.pyi +6 -5
  63. bear_utils/logging/logger_manager/loggers/_buffer_logger.py +2 -3
  64. bear_utils/logging/logger_manager/loggers/_console_logger.py +52 -26
  65. bear_utils/logging/logger_manager/loggers/_console_logger.pyi +7 -21
  66. bear_utils/logging/logger_manager/loggers/_file_logger.py +20 -13
  67. bear_utils/logging/logger_manager/loggers/_level_sin.py +15 -15
  68. bear_utils/logging/logger_manager/loggers/_logger.py +4 -6
  69. bear_utils/logging/logger_manager/loggers/_sub_logger.py +16 -23
  70. bear_utils/logging/logger_manager/loggers/_sub_logger.pyi +4 -19
  71. bear_utils/logging/loggers.py +9 -13
  72. bear_utils/monitoring/__init__.py +7 -4
  73. bear_utils/monitoring/_common.py +28 -0
  74. bear_utils/monitoring/host_monitor.py +44 -48
  75. bear_utils/time/__init__.py +13 -6
  76. {bear_utils-0.7.21.dist-info → bear_utils-0.7.23.dist-info}/METADATA +50 -6
  77. bear_utils-0.7.23.dist-info/RECORD +83 -0
  78. bear_utils-0.7.21.dist-info/RECORD +0 -79
  79. {bear_utils-0.7.21.dist-info → bear_utils-0.7.23.dist-info}/WHEEL +0 -0
@@ -0,0 +1 @@
1
+ """Shell utilities for bear_utils CLI."""
@@ -1,21 +1,20 @@
1
- from asyncio.subprocess import Process
2
- from subprocess import CompletedProcess
3
- from typing import ClassVar, Generic, Self, TypeVar
1
+ from typing import TYPE_CHECKING, Any, ClassVar, LiteralString, Self, cast
4
2
 
5
- T = TypeVar("T", bound=str)
3
+ if TYPE_CHECKING:
4
+ from subprocess import CompletedProcess
6
5
 
7
6
 
8
- class BaseShellCommand(Generic[T]):
7
+ class BaseShellCommand[T: str]:
9
8
  """Base class for typed shell commands compatible with session systems"""
10
9
 
11
10
  command_name: ClassVar[str] = ""
12
11
 
13
- def __init__(self, *args, **kwargs):
12
+ def __init__(self, *args, **kwargs) -> None:
14
13
  self.sub_command: str = kwargs.get("sub_command", "")
15
14
  self.args = args
16
- self.kwargs = kwargs
15
+ self.kwargs: dict[str, Any] = kwargs
17
16
  self.suffix = kwargs.get("suffix", "")
18
- self.result = None
17
+ self.result: CompletedProcess[str] | None = None
19
18
 
20
19
  def __str__(self) -> str:
21
20
  """String representation of the command"""
@@ -28,8 +27,7 @@ class BaseShellCommand(Generic[T]):
28
27
 
29
28
  @classmethod
30
29
  def adhoc(cls, name: T, *args, **kwargs) -> "BaseShellCommand[T]":
31
- """
32
- Create an ad-hoc command class for a specific command
30
+ """Create an ad-hoc command class for a specific command
33
31
 
34
32
  Args:
35
33
  name (str): The name of the command to create
@@ -37,33 +35,35 @@ class BaseShellCommand(Generic[T]):
37
35
  Returns:
38
36
  BaseShellCommand: An instance of the ad-hoc command class.
39
37
  """
40
- AdHocCommand = type(
38
+ return type(
41
39
  f"AdHoc{name.title()}Command",
42
40
  (cls,),
43
41
  {"command_name": name},
44
- )
45
- return AdHocCommand(*args, **kwargs)
42
+ )(*args, **kwargs)
46
43
 
47
44
  @classmethod
48
45
  def sub(cls, s: str, *args, **kwargs) -> Self:
49
46
  """Set a sub-command for the shell command"""
50
- return cls(sub_command=s, *args, **kwargs)
47
+ return cls(s, *args, **kwargs)
51
48
 
52
49
  @property
53
50
  def cmd(self) -> str:
54
51
  """Return the full command as a string"""
55
- cmd_parts = [self.command_name, self.sub_command] + list(self.args)
56
- joined = " ".join(cmd_parts).strip()
52
+ cmd_parts = [self.command_name, self.sub_command, *self.args]
53
+ cmd_parts = [part for part in cmd_parts if part]
54
+ joined: LiteralString = " ".join(cmd_parts).strip()
57
55
  if self.suffix:
58
56
  return f"{joined} {self.suffix}"
59
57
  return joined
60
58
 
61
59
  def do(self, **kwargs) -> Self:
62
60
  """Run the command using subprocess"""
63
- from ._base_shell import shell_session
61
+ from ._base_shell import shell_session # noqa: PLC0415
64
62
 
65
63
  with shell_session(**kwargs) as session:
66
- self.result: CompletedProcess[str] | Process = session.add(self.cmd).run()
64
+ result = session.add(self.cmd).run()
65
+ if result is not None:
66
+ self.result = cast("CompletedProcess[str]", result)
67
67
  return self
68
68
 
69
69
  def get(self) -> str:
@@ -1,7 +1,4 @@
1
1
  import asyncio
2
- import os
3
- import shlex
4
- import subprocess
5
2
  from asyncio.streams import StreamReader
6
3
  from asyncio.subprocess import Process
7
4
  from collections import deque
@@ -9,10 +6,16 @@ from collections.abc import AsyncGenerator, Callable, Generator
9
6
  from contextlib import asynccontextmanager, contextmanager
10
7
  from io import StringIO
11
8
  from logging import INFO
9
+ import os
12
10
  from pathlib import Path
11
+ import shlex
12
+ import subprocess
13
13
  from subprocess import CompletedProcess
14
14
  from typing import Self, override
15
15
 
16
+ from bear_utils.constants.logger_protocol import LoggerProtocol
17
+ from bear_utils.logging import VERBOSE, BaseLogger, SubConsoleLogger
18
+
16
19
  from ._base_command import BaseShellCommand
17
20
  from ._common import DEFAULT_SHELL
18
21
 
@@ -32,11 +35,13 @@ EXIT_CODES: dict[int, str] = {
32
35
 
33
36
 
34
37
  class FancyCompletedProcess(CompletedProcess[str]):
35
- def __init__(self, args, returncode, stdout=None, stderr=None):
38
+ def __init__(self, args: list[str], returncode: int, stdout: str | None = None, stderr: str | None = None) -> None:
39
+ """Initialize with custom attributes for better readability"""
36
40
  super().__init__(args=args, returncode=returncode, stdout=stdout, stderr=stderr)
37
41
 
38
- def __repr__(self):
39
- args = [
42
+ def __repr__(self) -> str:
43
+ """Custom representation for better readability"""
44
+ args: list[str] = [
40
45
  f"args={self.args!r}",
41
46
  f"returncode={self.returncode!r}",
42
47
  f"exit_message={self.exit_message!r}",
@@ -54,8 +59,8 @@ class FancyCompletedProcess(CompletedProcess[str]):
54
59
  class CommandList(deque[CompletedProcess[str]]):
55
60
  """A list to hold previous commands with their timestamps and results"""
56
61
 
57
- def __init__(self, maxlen=10, *args, **kwargs):
58
- super().__init__(maxlen=maxlen, *args, **kwargs)
62
+ def __init__(self, maxlen: int = 10, *args, **kwargs) -> None:
63
+ super().__init__(maxlen=maxlen, *args, **kwargs) # noqa: B026
59
64
 
60
65
  def add(self, command: CompletedProcess[str]) -> None:
61
66
  """Add a command to the list"""
@@ -75,10 +80,10 @@ class SimpleShellSession:
75
80
 
76
81
  def __init__(
77
82
  self,
78
- env=None,
79
- cwd=None,
83
+ env: dict | None = None,
84
+ cwd: Path | str | None = None,
80
85
  shell: str = DEFAULT_SHELL,
81
- logger=None,
86
+ logger: LoggerProtocol | BaseLogger | None = None,
82
87
  verbose: bool = False,
83
88
  use_shell: bool = True,
84
89
  ) -> None:
@@ -90,12 +95,12 @@ class SimpleShellSession:
90
95
  self.result: CompletedProcess[str] | None = None
91
96
  self.verbose: bool = verbose
92
97
  self.use_shell: bool = use_shell
93
- self.logger = self.set_logger(logger)
98
+ self.logger: LoggerProtocol | BaseLogger | SubConsoleLogger[BaseLogger] = self.set_logger(logger)
94
99
 
95
- def set_logger(self, passed_logger=None):
100
+ def set_logger(
101
+ self, passed_logger: LoggerProtocol | BaseLogger | None = None
102
+ ) -> LoggerProtocol | BaseLogger | SubConsoleLogger[BaseLogger]:
96
103
  """Set the logger for the session, defaulting to a base logger if none is provided"""
97
- from ...logging.loggers import VERBOSE, BaseLogger, SubConsoleLogger
98
-
99
104
  if passed_logger is not None:
100
105
  return passed_logger
101
106
 
@@ -110,7 +115,7 @@ class SimpleShellSession:
110
115
  logger.set_sub_level(INFO)
111
116
  return logger
112
117
 
113
- def add_to_env(self, env: dict[str, str], key: str | None = None, value=None) -> Self:
118
+ def add_to_env(self, env: dict[str, str], key: str | None = None, value: str | None = None) -> Self:
114
119
  """Populate the environment for the session"""
115
120
  _env = {}
116
121
  if isinstance(env, str) and key is not None and value is not None:
@@ -149,6 +154,7 @@ class SimpleShellSession:
149
154
 
150
155
  if self.use_shell:
151
156
  self.result = subprocess.run(
157
+ check=False,
152
158
  args=command,
153
159
  shell=True,
154
160
  cwd=self.cwd,
@@ -159,6 +165,7 @@ class SimpleShellSession:
159
165
  else:
160
166
  command_args: list[str] = shlex.split(command)
161
167
  self.result = subprocess.run(
168
+ check=False,
162
169
  args=command_args,
163
170
  shell=False,
164
171
  cwd=self.cwd,
@@ -279,7 +286,7 @@ class SimpleShellSession:
279
286
  """Enter the context manager"""
280
287
  return self
281
288
 
282
- def __exit__(self, exc_type, exc_value, traceback) -> None:
289
+ def __exit__(self, exc_type: object, exc_value: object, traceback: object) -> None:
283
290
  """Exit the context manager"""
284
291
  self.reset()
285
292
  self.reset_buffer()
@@ -290,10 +297,10 @@ class AsyncShellSession(SimpleShellSession):
290
297
 
291
298
  def __init__(
292
299
  self,
293
- env=None,
294
- cwd=None,
300
+ env: dict[str, str] | None = None,
301
+ cwd: str | None = None,
295
302
  shell: str = DEFAULT_SHELL,
296
- logger=None,
303
+ logger: LoggerProtocol | BaseLogger | None = None,
297
304
  verbose: bool = False,
298
305
  use_shell: bool = True,
299
306
  ) -> None:
@@ -326,7 +333,6 @@ class AsyncShellSession(SimpleShellSession):
326
333
  env=self.env,
327
334
  **kwargs,
328
335
  )
329
-
330
336
  return self.process
331
337
 
332
338
  @override
@@ -338,12 +344,12 @@ class AsyncShellSession(SimpleShellSession):
338
344
  if self.has_history and cmd is not None:
339
345
  raise ValueError("Use `amp` to chain commands, not `run`")
340
346
  if self.has_history and cmd is None:
341
- command = self.cmd
347
+ command: str = self.cmd
342
348
  elif self.empty_history and cmd is not None:
343
349
  self.cmd_buffer.write(f"{cmd}")
344
350
  if args:
345
351
  self.cmd_buffer.write(" ".join(map(str, args)))
346
- command = self.cmd
352
+ command: str = self.cmd
347
353
  else:
348
354
  raise ValueError("Unexpected state")
349
355
  process: Process = await self._run(command, **kwargs)
@@ -359,23 +365,20 @@ class AsyncShellSession(SimpleShellSession):
359
365
  return_code: int = await self.process.wait()
360
366
 
361
367
  self.result = FancyCompletedProcess(
362
- args=self.cmd,
368
+ args=self.cmd, # type: ignore FIXME: should be a list[str] not str?
363
369
  returncode=return_code,
364
370
  stdout=stdout.decode() if stdout else "",
365
371
  stderr=stderr.decode() if stderr else "",
366
372
  )
367
-
368
373
  if return_code != 0:
369
374
  self.logger.error(f"Command failed with return code {return_code} {stderr.strip()}")
370
-
371
375
  for callback in self._callbacks:
372
376
  callback(self.result)
373
-
374
377
  await self.after_process()
375
378
  return self.result
376
379
 
377
380
  @staticmethod
378
- async def read_stream(stream: StreamReader) -> AsyncGenerator[str, None]:
381
+ async def read_stream(stream: StreamReader) -> AsyncGenerator[str]:
379
382
  while True:
380
383
  try:
381
384
  line: bytes = await stream.readline()
@@ -385,7 +388,7 @@ class AsyncShellSession(SimpleShellSession):
385
388
  except Exception:
386
389
  break
387
390
 
388
- async def stream_stdout(self) -> AsyncGenerator[str, None]:
391
+ async def stream_stdout(self) -> AsyncGenerator[str]:
389
392
  """Stream output line by line as it comes"""
390
393
  if self.process is None:
391
394
  raise ValueError("No process has been started yet")
@@ -395,7 +398,7 @@ class AsyncShellSession(SimpleShellSession):
395
398
  async for line in self.read_stream(self.process.stdout):
396
399
  yield line
397
400
 
398
- async def stream_stderr(self) -> AsyncGenerator[str, None]:
401
+ async def stream_stderr(self) -> AsyncGenerator[str]:
399
402
  """Stream error output line by line as it comes"""
400
403
  if self.process is None:
401
404
  raise ValueError("No process has been started yet")
@@ -404,13 +407,13 @@ class AsyncShellSession(SimpleShellSession):
404
407
  async for line in self.read_stream(self.process.stderr):
405
408
  yield line
406
409
 
407
- async def after_process(self):
410
+ async def after_process(self) -> None:
408
411
  """Run after process completion, can be overridden for custom behavior"""
409
412
  self.process = None
410
413
  self._callbacks.clear()
411
414
  self.reset_buffer()
412
415
 
413
- def on_completion(self, callback):
416
+ def on_completion(self, callback: Callable[[CompletedProcess[str]], None]) -> None:
414
417
  """Add callback for when process completes"""
415
418
  self._callbacks.append(callback)
416
419
 
@@ -421,7 +424,7 @@ class AsyncShellSession(SimpleShellSession):
421
424
 
422
425
 
423
426
  @contextmanager
424
- def shell_session(shell: str = DEFAULT_SHELL, **kwargs) -> Generator[SimpleShellSession, None, None]:
427
+ def shell_session(shell: str = DEFAULT_SHELL, **kwargs) -> Generator[SimpleShellSession]:
425
428
  """Context manager for simple shell sessions"""
426
429
  session = SimpleShellSession(shell=shell, **kwargs)
427
430
  try:
@@ -431,7 +434,7 @@ def shell_session(shell: str = DEFAULT_SHELL, **kwargs) -> Generator[SimpleShell
431
434
 
432
435
 
433
436
  @asynccontextmanager
434
- async def async_shell_session(shell: str = DEFAULT_SHELL, **kwargs) -> AsyncGenerator[AsyncShellSession, None]:
437
+ async def async_shell_session(shell: str = DEFAULT_SHELL, **kwargs) -> AsyncGenerator[AsyncShellSession]:
435
438
  """Asynchronous context manager for shell sessions"""
436
439
  session = AsyncShellSession(shell=shell, **kwargs)
437
440
  try:
@@ -1,11 +1,13 @@
1
+ """Config and settings management utilities for Bear Utils."""
2
+
1
3
  from .config_manager import ConfigManager
2
4
  from .dir_manager import DirectoryManager
3
5
  from .settings_manager import SettingsManager, get_settings_manager, settings
4
6
 
5
7
  __all__ = [
6
8
  "ConfigManager",
9
+ "DirectoryManager",
7
10
  "SettingsManager",
8
- "settings",
9
11
  "get_settings_manager",
10
- "DirectoryManager",
12
+ "settings",
11
13
  ]
@@ -1,21 +1,38 @@
1
+ """Config Manager Module for Bear Utils."""
2
+
3
+ from collections.abc import Callable
4
+ from functools import cached_property
1
5
  import os
2
- from functools import lru_cache
3
6
  from pathlib import Path
4
- from typing import Any, Generic, TypeVar
7
+ import tomllib
8
+ from typing import Any
9
+
10
+ from pydantic import BaseModel, ValidationError, field_validator
11
+
5
12
 
6
- from pydantic import BaseModel
13
+ def nullable_string_validator(field_name: str) -> Callable[..., str | None]:
14
+ """Create a validator that converts 'null' strings to None."""
7
15
 
8
- ConfigType = TypeVar("ConfigType", bound=BaseModel)
16
+ @field_validator(field_name)
17
+ @classmethod
18
+ def _validate(cls: object, v: str | None) -> str | None: # noqa: ARG001
19
+ if isinstance(v, str) and v.lower() in ("null", "none", ""):
20
+ return None
21
+ return v
9
22
 
10
- # TODO: Get around to potentially using this, right now it is just masturbation
23
+ return _validate
11
24
 
12
25
 
13
- class ConfigManager(Generic[ConfigType]):
26
+ class ConfigManager[ConfigType: BaseModel]:
27
+ """A generic configuration manager with environment-based overrides."""
28
+
14
29
  def __init__(self, config_model: type[ConfigType], config_path: Path | None = None, env: str = "dev") -> None:
15
- self._model = config_model
16
- self._config_path = config_path or Path("config")
17
- self._env = env
30
+ """Initialize the ConfigManager with a Pydantic model and configuration path."""
31
+ self._model: type[ConfigType] = config_model
32
+ self._config_path: Path = config_path or Path("config")
33
+ self._env: str = env
18
34
  self._config: ConfigType | None = None
35
+ self._config_path.mkdir(parents=True, exist_ok=True)
19
36
 
20
37
  def _get_env_overrides(self) -> dict[str, Any]:
21
38
  """Convert environment variables to nested dictionary structure."""
@@ -26,67 +43,187 @@ class ConfigManager(Generic[ConfigType]):
26
43
  continue
27
44
 
28
45
  # Convert APP_DATABASE_HOST to ['database', 'host']
29
- parts = key.lower().replace("app_", "").split("_")
46
+ parts: list[str] = key.lower().replace("app_", "").split("_")
30
47
 
31
- # Build nested dictionary
32
- current = env_config
48
+ current: dict[str, Any] = env_config
33
49
  for part in parts[:-1]:
34
50
  current = current.setdefault(part, {})
35
- current[parts[-1]] = value
36
51
 
52
+ final_value: Any = self._convert_env_value(value)
53
+ current[parts[-1]] = final_value
37
54
  return env_config
38
55
 
39
- @lru_cache
56
+ def _convert_env_value(self, value: str) -> Any:
57
+ """Convert string environment variables to appropriate types."""
58
+ if value.lower() in ("true", "false"):
59
+ return value.lower() == "true"
60
+
61
+ if value.isdigit():
62
+ return int(value)
63
+
64
+ try:
65
+ if "." in value:
66
+ return float(value)
67
+ except ValueError:
68
+ pass
69
+
70
+ if "," in value:
71
+ return [item.strip() for item in value.split(",")]
72
+
73
+ return value
74
+
75
+ def _load_toml_file(self, file_path: Path) -> dict[str, Any]:
76
+ """Load a TOML file and return its contents."""
77
+ try:
78
+ with open(file_path, "rb") as f:
79
+ return tomllib.load(f)
80
+ except (FileNotFoundError, tomllib.TOMLDecodeError):
81
+ return {}
82
+
83
+ @cached_property
40
84
  def load(self) -> ConfigType:
85
+ """Load configuration from files and environment variables."""
41
86
  # Load order (later overrides earlier):
42
- # 1. default.yaml
43
- # 2. {env}.yaml
44
- # 3. local.yaml (gitignored)
87
+ # 1. default.toml
88
+ # 2. {env}.toml
89
+ # 3. local.toml (gitignored)
45
90
  # 4. environment variables
46
91
  config_data: dict[str, Any] = {}
47
92
 
48
- # for config_file in [
49
- # self._config_path / "default.yaml",
50
- # self._config_path / f"{self._env}.yaml",
51
- # self._config_path / "local.yaml",
52
- # ]:
53
- # if config_file.exists():
54
- # with open(config_file) as f:
55
- # config_data.update(yaml.safe_load(f))
56
-
57
- config_data.update(self._get_env_overrides())
58
-
59
- return self._model.model_validate(config_data)
93
+ # TODO: Update this so it looks for it in more than one place
94
+ config_files: list[Path] = [
95
+ self._config_path / "default.toml",
96
+ self._config_path / f"{self._env}.toml",
97
+ self._config_path / "local.toml",
98
+ ]
99
+
100
+ for config_file in config_files:
101
+ if config_file.exists():
102
+ file_data = self._load_toml_file(config_file)
103
+ config_data = self._deep_merge(config_data, file_data)
104
+
105
+ env_overrides: dict[str, Any] = self._get_env_overrides()
106
+ config_data = self._deep_merge(config_data, env_overrides)
107
+
108
+ try:
109
+ return self._model.model_validate(config_data)
110
+ except ValidationError as e:
111
+ raise ValueError(f"Configuration validation failed: {e}") from e
112
+
113
+ def _deep_merge(self, base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
114
+ """Deep merge two dictionaries."""
115
+ result: dict[str, Any] = base.copy()
116
+ for key, value in override.items():
117
+ if key in result and isinstance(result[key], dict) and isinstance(value, dict):
118
+ result[key] = self._deep_merge(result[key], value)
119
+ else:
120
+ result[key] = value
121
+ return result
60
122
 
61
123
  @property
62
124
  def config(self) -> ConfigType:
125
+ """Get the loaded configuration."""
63
126
  if self._config is None:
64
- self._config = self.load()
127
+ self._config = self.load
65
128
  return self._config
66
129
 
67
-
68
- # Example usage:
69
- # class DatabaseConfig(BaseModel):
70
- # host: str
71
- # port: int
72
- # username: str
73
- # password: str
74
- # database: str
75
-
76
-
77
- # class AppConfig(BaseModel):
78
- # database: DatabaseConfig
79
- # environment: str
80
- # debug: bool = False
81
- # logging_mode: str = "console"
82
- # log_level: str = "INFO"
83
-
84
-
85
- # config_manager = ConfigManager[AppConfig](
86
- # config_model=AppConfig,
87
- # config_path=Path("config"),
88
- # env="development",
89
- # )
90
-
91
- # Access config
92
- # db_config = config_manager.config.database
130
+ def reload(self) -> ConfigType:
131
+ """Force reload the configuration."""
132
+ if "config" in self.__dict__:
133
+ del self.__dict__["config"]
134
+ return self.config
135
+
136
+ def create_default_config(self) -> None:
137
+ """Create a default.toml file with example values."""
138
+ default_path = self._config_path / "default.toml"
139
+ if default_path.exists():
140
+ return
141
+
142
+ try:
143
+ default_instance: ConfigType = self._model()
144
+ toml_content: str = self._model_to_toml(default_instance)
145
+ default_path.write_text(toml_content)
146
+ except Exception as e:
147
+ print(f"Could not create default config: {e}")
148
+
149
+ def _model_to_toml(self, instance: ConfigType) -> str:
150
+ """Convert a Pydantic model to TOML format."""
151
+ lines: list[str] = ["# Default configuration"]
152
+
153
+ def _dict_to_toml(data: dict[str, Any], prefix: str = "") -> None:
154
+ for key, value in data.items():
155
+ full_key: str = f"{prefix}.{key}" if prefix else key
156
+
157
+ if isinstance(value, dict):
158
+ lines.append(f"\n[{full_key}]")
159
+ for sub_key, sub_value in value.items():
160
+ lines.append(f"{sub_key} = {self._format_toml_value(sub_value)}")
161
+ elif not prefix:
162
+ lines.append(f"{key} = {self._format_toml_value(value)}")
163
+
164
+ _dict_to_toml(instance.model_dump())
165
+ return "\n".join(lines)
166
+
167
+ def _format_toml_value(self, value: Any) -> str:
168
+ """Format a value for TOML output."""
169
+ if isinstance(value, str):
170
+ return f'"{value}"'
171
+ if isinstance(value, bool):
172
+ return str(value).lower()
173
+ if isinstance(value, list):
174
+ formatted_items = [self._format_toml_value(item) for item in value]
175
+ return f"[{', '.join(formatted_items)}]"
176
+ if value is None:
177
+ return '"null"'
178
+ return str(value)
179
+
180
+
181
+ if __name__ == "__main__":
182
+ # Example usage and models
183
+ class DatabaseConfig(BaseModel):
184
+ """Configuration for an example database connection."""
185
+
186
+ host: str = "localhost"
187
+ port: int = 5432
188
+ username: str = "app"
189
+ password: str = "secret" # noqa: S105 This is just an example
190
+ database: str = "myapp"
191
+
192
+ class LoggingConfig(BaseModel):
193
+ """Configuration for an example logging setup."""
194
+
195
+ level: str = "INFO"
196
+ format: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
197
+ file: str | None = None
198
+
199
+ _validate_file = nullable_string_validator("file")
200
+
201
+ class AppConfig(BaseModel):
202
+ """Example application configuration model."""
203
+
204
+ database: DatabaseConfig = DatabaseConfig()
205
+ logging: LoggingConfig = LoggingConfig()
206
+ environment: str = "development"
207
+ debug: bool = False
208
+ api_key: str = "your-api-key-here"
209
+ allowed_hosts: list[str] = ["localhost", "127.0.0.1"]
210
+
211
+ def get_config_manager(env: str = "dev") -> ConfigManager[AppConfig]:
212
+ """Get a configured ConfigManager instance."""
213
+ return ConfigManager[AppConfig](
214
+ config_model=AppConfig,
215
+ config_path=Path("config"),
216
+ env=env,
217
+ )
218
+
219
+ config_manager: ConfigManager[AppConfig] = get_config_manager("development")
220
+ config_manager.create_default_config()
221
+ config: AppConfig = config_manager.config
222
+
223
+ print(f"Database host: {config.database.host}")
224
+ print(f"Debug mode: {config.debug}")
225
+ print(f"Environment: {config.environment}")
226
+
227
+ # Test environment variable override
228
+ # Set: APP_DATABASE_HOST=production-db.example.com
229
+ # Set: APP_DEBUG=true
@@ -1,12 +1,17 @@
1
+ """Directory Manager Module for Bear Utils."""
2
+
1
3
  from dataclasses import dataclass
2
4
  from pathlib import Path
5
+ from typing import ClassVar
3
6
 
4
7
 
5
8
  @dataclass
6
9
  class DirectoryManager:
7
- _base_path: Path = Path.home() / ".config" / "bear_utils"
8
- _settings_path: Path = _base_path / "settings"
9
- _temp_path: Path = _base_path / "temp"
10
+ """A class to manage directories for bear_utils."""
11
+
12
+ _base_path: ClassVar[Path] = Path.home() / ".config" / "bear_utils"
13
+ _settings_path: ClassVar[Path] = _base_path / "settings"
14
+ _temp_path: ClassVar[Path] = _base_path / "temp"
10
15
 
11
16
  def setup(self) -> None:
12
17
  """Ensure the base, settings, and temp directories exist."""