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.
- bear_utils/__init__.py +24 -1
- bear_utils/ai/__init__.py +5 -5
- bear_utils/ai/ai_helpers/__init__.py +24 -18
- bear_utils/ai/ai_helpers/_parsers.py +27 -21
- bear_utils/ai/ai_helpers/_types.py +2 -7
- bear_utils/cache/__init__.py +35 -23
- bear_utils/cli/__init__.py +13 -0
- bear_utils/cli/commands.py +14 -8
- bear_utils/cli/prompt_helpers.py +40 -34
- bear_utils/cli/shell/__init__.py +1 -0
- bear_utils/cli/shell/_base_command.py +18 -18
- bear_utils/cli/shell/_base_shell.py +37 -34
- bear_utils/config/__init__.py +4 -2
- bear_utils/config/config_manager.py +193 -56
- bear_utils/config/dir_manager.py +8 -3
- bear_utils/config/settings_manager.py +94 -171
- bear_utils/constants/__init__.py +2 -1
- bear_utils/constants/_exceptions.py +6 -1
- bear_utils/constants/date_related.py +2 -0
- bear_utils/constants/logger_protocol.py +28 -0
- bear_utils/constants/time_related.py +2 -0
- bear_utils/database/__init__.py +2 -0
- bear_utils/database/_db_manager.py +10 -11
- bear_utils/events/__init__.py +3 -1
- bear_utils/events/events_class.py +11 -11
- bear_utils/events/events_module.py +17 -8
- bear_utils/extras/__init__.py +8 -6
- bear_utils/extras/_async_helpers.py +2 -3
- bear_utils/extras/_tools.py +62 -52
- bear_utils/extras/platform_utils.py +5 -1
- bear_utils/extras/responses/__init__.py +1 -0
- bear_utils/extras/responses/function_response.py +301 -0
- bear_utils/extras/wrappers/__init__.py +1 -0
- bear_utils/extras/wrappers/add_methods.py +17 -15
- bear_utils/files/__init__.py +3 -1
- bear_utils/files/file_handlers/__init__.py +2 -0
- bear_utils/files/file_handlers/_base_file_handler.py +23 -3
- bear_utils/files/file_handlers/file_handler_factory.py +38 -38
- bear_utils/files/file_handlers/json_file_handler.py +49 -22
- bear_utils/files/file_handlers/log_file_handler.py +19 -12
- bear_utils/files/file_handlers/toml_file_handler.py +13 -5
- bear_utils/files/file_handlers/txt_file_handler.py +56 -14
- bear_utils/files/file_handlers/yaml_file_handler.py +19 -13
- bear_utils/files/ignore_parser.py +52 -57
- bear_utils/graphics/__init__.py +3 -1
- bear_utils/graphics/bear_gradient.py +17 -12
- bear_utils/graphics/image_helpers.py +11 -5
- bear_utils/gui/__init__.py +3 -1
- bear_utils/gui/gui_tools/__init__.py +3 -1
- bear_utils/gui/gui_tools/_settings.py +0 -1
- bear_utils/gui/gui_tools/qt_app.py +16 -11
- bear_utils/gui/gui_tools/qt_color_picker.py +24 -13
- bear_utils/gui/gui_tools/qt_file_handler.py +30 -38
- bear_utils/gui/gui_tools/qt_input_dialog.py +11 -14
- bear_utils/logging/__init__.py +6 -4
- bear_utils/logging/logger_manager/__init__.py +1 -0
- bear_utils/logging/logger_manager/_common.py +0 -1
- bear_utils/logging/logger_manager/_console_junk.py +14 -10
- bear_utils/logging/logger_manager/_styles.py +1 -2
- bear_utils/logging/logger_manager/loggers/__init__.py +1 -0
- bear_utils/logging/logger_manager/loggers/_base_logger.py +33 -36
- bear_utils/logging/logger_manager/loggers/_base_logger.pyi +6 -5
- bear_utils/logging/logger_manager/loggers/_buffer_logger.py +2 -3
- bear_utils/logging/logger_manager/loggers/_console_logger.py +52 -26
- bear_utils/logging/logger_manager/loggers/_console_logger.pyi +7 -21
- bear_utils/logging/logger_manager/loggers/_file_logger.py +20 -13
- bear_utils/logging/logger_manager/loggers/_level_sin.py +15 -15
- bear_utils/logging/logger_manager/loggers/_logger.py +4 -6
- bear_utils/logging/logger_manager/loggers/_sub_logger.py +16 -23
- bear_utils/logging/logger_manager/loggers/_sub_logger.pyi +4 -19
- bear_utils/logging/loggers.py +9 -13
- bear_utils/monitoring/__init__.py +7 -4
- bear_utils/monitoring/_common.py +28 -0
- bear_utils/monitoring/host_monitor.py +44 -48
- bear_utils/time/__init__.py +13 -6
- {bear_utils-0.7.21.dist-info → bear_utils-0.7.23.dist-info}/METADATA +50 -6
- bear_utils-0.7.23.dist-info/RECORD +83 -0
- bear_utils-0.7.21.dist-info/RECORD +0 -79
- {bear_utils-0.7.21.dist-info → bear_utils-0.7.23.dist-info}/WHEEL +0 -0
bear_utils/cli/shell/__init__.py
CHANGED
@@ -0,0 +1 @@
|
|
1
|
+
"""Shell utilities for bear_utils CLI."""
|
@@ -1,21 +1,20 @@
|
|
1
|
-
from
|
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
|
-
|
3
|
+
if TYPE_CHECKING:
|
4
|
+
from subprocess import CompletedProcess
|
6
5
|
|
7
6
|
|
8
|
-
class BaseShellCommand
|
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
|
-
|
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(
|
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
|
56
|
-
|
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
|
-
|
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
|
-
|
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(
|
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
|
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
|
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
|
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
|
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
|
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:
|
bear_utils/config/__init__.py
CHANGED
@@ -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
|
-
"
|
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
|
-
|
7
|
+
import tomllib
|
8
|
+
from typing import Any
|
9
|
+
|
10
|
+
from pydantic import BaseModel, ValidationError, field_validator
|
11
|
+
|
5
12
|
|
6
|
-
|
13
|
+
def nullable_string_validator(field_name: str) -> Callable[..., str | None]:
|
14
|
+
"""Create a validator that converts 'null' strings to None."""
|
7
15
|
|
8
|
-
|
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
|
-
|
23
|
+
return _validate
|
11
24
|
|
12
25
|
|
13
|
-
class ConfigManager
|
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
|
-
|
16
|
-
self.
|
17
|
-
self.
|
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
|
-
|
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
|
-
|
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.
|
43
|
-
# 2. {env}.
|
44
|
-
# 3. local.
|
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
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
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
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
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
|
bear_utils/config/dir_manager.py
CHANGED
@@ -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
|
-
|
8
|
-
|
9
|
-
|
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."""
|