bear-utils 0.0.1__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 +51 -0
- bear_utils/__main__.py +14 -0
- bear_utils/_internal/__init__.py +0 -0
- bear_utils/_internal/_version.py +1 -0
- bear_utils/_internal/cli.py +119 -0
- bear_utils/_internal/debug.py +174 -0
- bear_utils/ai/__init__.py +30 -0
- bear_utils/ai/ai_helpers/__init__.py +136 -0
- bear_utils/ai/ai_helpers/_common.py +19 -0
- bear_utils/ai/ai_helpers/_config.py +24 -0
- bear_utils/ai/ai_helpers/_parsers.py +194 -0
- bear_utils/ai/ai_helpers/_types.py +15 -0
- bear_utils/cache/__init__.py +131 -0
- bear_utils/cli/__init__.py +22 -0
- bear_utils/cli/_args.py +12 -0
- bear_utils/cli/_get_version.py +207 -0
- bear_utils/cli/commands.py +105 -0
- bear_utils/cli/prompt_helpers.py +186 -0
- bear_utils/cli/shell/__init__.py +1 -0
- bear_utils/cli/shell/_base_command.py +81 -0
- bear_utils/cli/shell/_base_shell.py +430 -0
- bear_utils/cli/shell/_common.py +19 -0
- bear_utils/cli/typer_bridge.py +90 -0
- bear_utils/config/__init__.py +13 -0
- bear_utils/config/config_manager.py +229 -0
- bear_utils/config/dir_manager.py +69 -0
- bear_utils/config/settings_manager.py +179 -0
- bear_utils/constants/__init__.py +90 -0
- bear_utils/constants/_exceptions.py +8 -0
- bear_utils/constants/_exit_code.py +60 -0
- bear_utils/constants/_http_status_code.py +37 -0
- bear_utils/constants/_lazy_typing.py +15 -0
- bear_utils/constants/_meta.py +196 -0
- bear_utils/constants/date_related.py +25 -0
- bear_utils/constants/time_related.py +24 -0
- bear_utils/database/__init__.py +8 -0
- bear_utils/database/_db_manager.py +98 -0
- bear_utils/events/__init__.py +18 -0
- bear_utils/events/events_class.py +52 -0
- bear_utils/events/events_module.py +74 -0
- bear_utils/extras/__init__.py +28 -0
- bear_utils/extras/_async_helpers.py +67 -0
- bear_utils/extras/_tools.py +185 -0
- bear_utils/extras/_zapper.py +399 -0
- bear_utils/extras/platform_utils.py +57 -0
- bear_utils/extras/responses/__init__.py +5 -0
- bear_utils/extras/responses/function_response.py +451 -0
- bear_utils/extras/wrappers/__init__.py +1 -0
- bear_utils/extras/wrappers/add_methods.py +100 -0
- bear_utils/extras/wrappers/string_io.py +46 -0
- bear_utils/files/__init__.py +6 -0
- bear_utils/files/file_handlers/__init__.py +5 -0
- bear_utils/files/file_handlers/_base_file_handler.py +107 -0
- bear_utils/files/file_handlers/file_handler_factory.py +280 -0
- bear_utils/files/file_handlers/json_file_handler.py +71 -0
- bear_utils/files/file_handlers/log_file_handler.py +40 -0
- bear_utils/files/file_handlers/toml_file_handler.py +76 -0
- bear_utils/files/file_handlers/txt_file_handler.py +76 -0
- bear_utils/files/file_handlers/yaml_file_handler.py +64 -0
- bear_utils/files/ignore_parser.py +293 -0
- bear_utils/graphics/__init__.py +6 -0
- bear_utils/graphics/bear_gradient.py +145 -0
- bear_utils/graphics/font/__init__.py +13 -0
- bear_utils/graphics/font/_raw_block_letters.py +463 -0
- bear_utils/graphics/font/_theme.py +31 -0
- bear_utils/graphics/font/_utils.py +220 -0
- bear_utils/graphics/font/block_font.py +192 -0
- bear_utils/graphics/font/glitch_font.py +63 -0
- bear_utils/graphics/image_helpers.py +45 -0
- bear_utils/gui/__init__.py +8 -0
- bear_utils/gui/gui_tools/__init__.py +10 -0
- bear_utils/gui/gui_tools/_settings.py +36 -0
- bear_utils/gui/gui_tools/_types.py +12 -0
- bear_utils/gui/gui_tools/qt_app.py +150 -0
- bear_utils/gui/gui_tools/qt_color_picker.py +130 -0
- bear_utils/gui/gui_tools/qt_file_handler.py +130 -0
- bear_utils/gui/gui_tools/qt_input_dialog.py +303 -0
- bear_utils/logger_manager/__init__.py +109 -0
- bear_utils/logger_manager/_common.py +63 -0
- bear_utils/logger_manager/_console_junk.py +135 -0
- bear_utils/logger_manager/_log_level.py +50 -0
- bear_utils/logger_manager/_styles.py +95 -0
- bear_utils/logger_manager/logger_protocol.py +42 -0
- bear_utils/logger_manager/loggers/__init__.py +1 -0
- bear_utils/logger_manager/loggers/_console.py +223 -0
- bear_utils/logger_manager/loggers/_level_sin.py +61 -0
- bear_utils/logger_manager/loggers/_logger.py +19 -0
- bear_utils/logger_manager/loggers/base_logger.py +244 -0
- bear_utils/logger_manager/loggers/base_logger.pyi +51 -0
- bear_utils/logger_manager/loggers/basic_logger/__init__.py +5 -0
- bear_utils/logger_manager/loggers/basic_logger/logger.py +80 -0
- bear_utils/logger_manager/loggers/basic_logger/logger.pyi +19 -0
- bear_utils/logger_manager/loggers/buffer_logger.py +57 -0
- bear_utils/logger_manager/loggers/console_logger.py +278 -0
- bear_utils/logger_manager/loggers/console_logger.pyi +50 -0
- bear_utils/logger_manager/loggers/fastapi_logger.py +333 -0
- bear_utils/logger_manager/loggers/file_logger.py +151 -0
- bear_utils/logger_manager/loggers/simple_logger.py +98 -0
- bear_utils/logger_manager/loggers/sub_logger.py +105 -0
- bear_utils/logger_manager/loggers/sub_logger.pyi +23 -0
- bear_utils/monitoring/__init__.py +13 -0
- bear_utils/monitoring/_common.py +28 -0
- bear_utils/monitoring/host_monitor.py +346 -0
- bear_utils/time/__init__.py +59 -0
- bear_utils-0.0.1.dist-info/METADATA +305 -0
- bear_utils-0.0.1.dist-info/RECORD +107 -0
- bear_utils-0.0.1.dist-info/WHEEL +4 -0
@@ -0,0 +1,19 @@
|
|
1
|
+
"""Shared shell utilities.
|
2
|
+
|
3
|
+
``DEFAULT_SHELL`` attempts to locate ``zsh`` on the host using :func:`shutil.which`.
|
4
|
+
If that fails, ``/bin/sh`` is used instead.
|
5
|
+
"""
|
6
|
+
|
7
|
+
from shutil import which
|
8
|
+
|
9
|
+
DEFAULT_SHELL: str = which("zsh") or which("bash") or "/bin/sh"
|
10
|
+
"""Dynamically detected shell path, falling back to ``/bin/sh``."""
|
11
|
+
|
12
|
+
BASH: str | None = which("bash")
|
13
|
+
"""Path to the Bash shell, falling back to ``/bin/bash`` if not found."""
|
14
|
+
|
15
|
+
ZSH: str | None = which("zsh") or which("/bin/zsh")
|
16
|
+
"""Path to the Zsh shell, falling back to ``/bin/zsh`` if not found."""
|
17
|
+
|
18
|
+
SH: str | None = which("sh") or "bin/sh"
|
19
|
+
"""Path to the Bourne shell, falling back to ``/bin/sh`` if not found."""
|
@@ -0,0 +1,90 @@
|
|
1
|
+
"""A simple bridge for augmenting Typer with alias support and command execution for interactive use."""
|
2
|
+
|
3
|
+
from collections.abc import Callable
|
4
|
+
import shlex
|
5
|
+
from typing import Any, TypedDict
|
6
|
+
|
7
|
+
from rich.console import Console
|
8
|
+
from singleton_base import SingletonBase
|
9
|
+
from typer import Exit, Typer
|
10
|
+
from typer.models import CommandInfo
|
11
|
+
|
12
|
+
from bear_utils.logger_manager import AsyncLoggerProtocol, LoggerProtocol
|
13
|
+
|
14
|
+
|
15
|
+
class CommandMeta(TypedDict):
|
16
|
+
"""Metadata for a Typer command."""
|
17
|
+
|
18
|
+
name: str
|
19
|
+
help: str
|
20
|
+
hidden: bool
|
21
|
+
|
22
|
+
|
23
|
+
def get_command_meta(command: CommandInfo) -> CommandMeta:
|
24
|
+
"""Extract metadata from a Typer command."""
|
25
|
+
return {
|
26
|
+
"name": command.name or (command.callback.__name__ if command.callback else "unknown"),
|
27
|
+
"help": (command.callback.__doc__ if command.callback else None) or "No description available",
|
28
|
+
"hidden": command.hidden,
|
29
|
+
}
|
30
|
+
|
31
|
+
|
32
|
+
# TODO: Add support for usage statements for a more robust help system
|
33
|
+
|
34
|
+
|
35
|
+
class TyperBridge(SingletonBase):
|
36
|
+
"""Simple bridge for Typer command execution."""
|
37
|
+
|
38
|
+
def __init__(self, typer_app: Typer, console: AsyncLoggerProtocol | LoggerProtocol | Console) -> None:
|
39
|
+
"""Initialize the TyperBridge with a Typer app instance."""
|
40
|
+
self.app: Typer = typer_app
|
41
|
+
self.console: AsyncLoggerProtocol | LoggerProtocol | Console = console or Console()
|
42
|
+
self.command_meta: dict[str, CommandMeta] = {}
|
43
|
+
|
44
|
+
def alias(self, *alias_names: str) -> Callable[..., Callable[..., Any]]:
|
45
|
+
"""Register aliases as hidden Typer commands."""
|
46
|
+
|
47
|
+
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
48
|
+
for alias in alias_names:
|
49
|
+
self.app.command(name=alias, hidden=True)(func)
|
50
|
+
return func
|
51
|
+
|
52
|
+
return decorator
|
53
|
+
|
54
|
+
def execute_command(self, command_string: str) -> bool:
|
55
|
+
"""Execute command via Typer. Return True if successful."""
|
56
|
+
try:
|
57
|
+
parts: list[str] = shlex.split(command_string.strip())
|
58
|
+
if not parts:
|
59
|
+
return False
|
60
|
+
self.app(parts, standalone_mode=False)
|
61
|
+
return True
|
62
|
+
except Exit:
|
63
|
+
return True
|
64
|
+
except Exception as e:
|
65
|
+
if isinstance(self.console, Console):
|
66
|
+
self.console.print(f"[red]Error executing command: {e}[/red]")
|
67
|
+
else:
|
68
|
+
self.console.error(f"Error executing command: {e}", exc_info=True)
|
69
|
+
return False
|
70
|
+
|
71
|
+
def bootstrap_command_meta(self) -> None:
|
72
|
+
"""Bootstrap command metadata from the Typer app."""
|
73
|
+
if not self.command_meta:
|
74
|
+
for cmd in self.app.registered_commands:
|
75
|
+
cmd_meta: CommandMeta = get_command_meta(command=cmd)
|
76
|
+
self.command_meta[cmd_meta["name"]] = cmd_meta
|
77
|
+
|
78
|
+
def get_all_command_info(self, show_hidden: bool = False) -> dict[str, CommandMeta]:
|
79
|
+
"""Get all command information from the Typer app."""
|
80
|
+
if not self.command_meta:
|
81
|
+
self.bootstrap_command_meta()
|
82
|
+
if not show_hidden:
|
83
|
+
return {name: meta for name, meta in self.command_meta.items() if not meta["hidden"]}
|
84
|
+
return self.command_meta
|
85
|
+
|
86
|
+
def get_command_info(self, command_name: str) -> CommandMeta | None:
|
87
|
+
"""Get metadata for a specific command."""
|
88
|
+
if not self.command_meta:
|
89
|
+
self.bootstrap_command_meta()
|
90
|
+
return self.command_meta.get(command_name)
|
@@ -0,0 +1,13 @@
|
|
1
|
+
"""Config and settings management utilities for Bear Utils."""
|
2
|
+
|
3
|
+
from .config_manager import ConfigManager
|
4
|
+
from .dir_manager import DirectoryManager
|
5
|
+
from .settings_manager import SettingsManager, get_settings_manager, settings
|
6
|
+
|
7
|
+
__all__ = [
|
8
|
+
"ConfigManager",
|
9
|
+
"DirectoryManager",
|
10
|
+
"SettingsManager",
|
11
|
+
"get_settings_manager",
|
12
|
+
"settings",
|
13
|
+
]
|
@@ -0,0 +1,229 @@
|
|
1
|
+
"""Config Manager Module for Bear Utils."""
|
2
|
+
|
3
|
+
from collections.abc import Callable
|
4
|
+
from functools import cached_property
|
5
|
+
import os
|
6
|
+
from pathlib import Path
|
7
|
+
import tomllib
|
8
|
+
from typing import Any
|
9
|
+
|
10
|
+
from pydantic import BaseModel, ValidationError, field_validator
|
11
|
+
|
12
|
+
|
13
|
+
def nullable_string_validator(field_name: str) -> Callable[..., str | None]:
|
14
|
+
"""Create a validator that converts 'null' strings to None."""
|
15
|
+
|
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
|
22
|
+
|
23
|
+
return _validate
|
24
|
+
|
25
|
+
|
26
|
+
class ConfigManager[ConfigType: BaseModel]:
|
27
|
+
"""A generic configuration manager with environment-based overrides."""
|
28
|
+
|
29
|
+
def __init__(self, config_model: type[ConfigType], config_path: Path | None = None, env: str = "dev") -> None:
|
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
|
34
|
+
self._config: ConfigType | None = None
|
35
|
+
self._config_path.mkdir(parents=True, exist_ok=True)
|
36
|
+
|
37
|
+
def _get_env_overrides(self) -> dict[str, Any]:
|
38
|
+
"""Convert environment variables to nested dictionary structure."""
|
39
|
+
env_config: dict[str, Any] = {}
|
40
|
+
|
41
|
+
for key, value in os.environ.items():
|
42
|
+
if not key.startswith("APP_"):
|
43
|
+
continue
|
44
|
+
|
45
|
+
# Convert APP_DATABASE_HOST to ['database', 'host']
|
46
|
+
parts: list[str] = key.lower().replace("app_", "").split("_")
|
47
|
+
|
48
|
+
current: dict[str, Any] = env_config
|
49
|
+
for part in parts[:-1]:
|
50
|
+
current = current.setdefault(part, {})
|
51
|
+
|
52
|
+
final_value: Any = self._convert_env_value(value)
|
53
|
+
current[parts[-1]] = final_value
|
54
|
+
return env_config
|
55
|
+
|
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
|
84
|
+
def load(self) -> ConfigType:
|
85
|
+
"""Load configuration from files and environment variables."""
|
86
|
+
# Load order (later overrides earlier):
|
87
|
+
# 1. default.toml
|
88
|
+
# 2. {env}.toml
|
89
|
+
# 3. local.toml (gitignored)
|
90
|
+
# 4. environment variables
|
91
|
+
config_data: dict[str, Any] = {}
|
92
|
+
|
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
|
122
|
+
|
123
|
+
@property
|
124
|
+
def config(self) -> ConfigType:
|
125
|
+
"""Get the loaded configuration."""
|
126
|
+
if self._config is None:
|
127
|
+
self._config = self.load
|
128
|
+
return self._config
|
129
|
+
|
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
|
@@ -0,0 +1,69 @@
|
|
1
|
+
"""Directory Manager Module for Bear Utils."""
|
2
|
+
|
3
|
+
from dataclasses import dataclass
|
4
|
+
from pathlib import Path
|
5
|
+
from typing import ClassVar
|
6
|
+
|
7
|
+
|
8
|
+
@dataclass
|
9
|
+
class DirectoryManager:
|
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"
|
15
|
+
|
16
|
+
def setup(self) -> None:
|
17
|
+
"""Ensure the base, settings, and temp directories exist."""
|
18
|
+
self._base_path.mkdir(parents=True, exist_ok=True)
|
19
|
+
self._settings_path.mkdir(parents=True, exist_ok=True)
|
20
|
+
self._temp_path.mkdir(parents=True, exist_ok=True)
|
21
|
+
|
22
|
+
def clear_temp(self) -> None:
|
23
|
+
"""Clear the temporary directory."""
|
24
|
+
if self.temp_path.exists():
|
25
|
+
for item in self.temp_path.iterdir():
|
26
|
+
if item.is_file() or item.is_symlink():
|
27
|
+
item.unlink()
|
28
|
+
elif item.is_dir():
|
29
|
+
item.rmdir()
|
30
|
+
|
31
|
+
@property
|
32
|
+
def base_path(self) -> Path:
|
33
|
+
"""Get the base path for bear_utils."""
|
34
|
+
return self._base_path
|
35
|
+
|
36
|
+
@property
|
37
|
+
def settings_path(self) -> Path:
|
38
|
+
"""Get the path to the settings directory."""
|
39
|
+
return self._settings_path
|
40
|
+
|
41
|
+
@property
|
42
|
+
def temp_path(self) -> Path:
|
43
|
+
"""Get the path to the temporary directory."""
|
44
|
+
return self._temp_path
|
45
|
+
|
46
|
+
|
47
|
+
def get_base_path() -> Path:
|
48
|
+
"""Get the base path for bear_utils."""
|
49
|
+
return DirectoryManager().base_path
|
50
|
+
|
51
|
+
|
52
|
+
def get_settings_path() -> Path:
|
53
|
+
"""Get the path to the settings directory."""
|
54
|
+
return DirectoryManager().settings_path
|
55
|
+
|
56
|
+
|
57
|
+
def get_temp_path() -> Path:
|
58
|
+
"""Get the path to the temporary directory."""
|
59
|
+
return DirectoryManager().temp_path
|
60
|
+
|
61
|
+
|
62
|
+
def setup_directories() -> None:
|
63
|
+
"""Set up the necessary directories for bear_utils."""
|
64
|
+
DirectoryManager().setup()
|
65
|
+
|
66
|
+
|
67
|
+
def clear_temp_directory() -> None:
|
68
|
+
"""Clear the temporary directory."""
|
69
|
+
DirectoryManager().clear_temp()
|
@@ -0,0 +1,179 @@
|
|
1
|
+
"""Settings Manager Module for Bear Utils."""
|
2
|
+
|
3
|
+
import atexit
|
4
|
+
from collections.abc import Generator
|
5
|
+
from contextlib import contextmanager
|
6
|
+
import hashlib
|
7
|
+
from pathlib import Path
|
8
|
+
from typing import Any, Self
|
9
|
+
|
10
|
+
from tinydb import Query, TinyDB
|
11
|
+
|
12
|
+
DEFAULT_PATH: Path = Path.home() / ".config" / "bear_utils"
|
13
|
+
|
14
|
+
|
15
|
+
def get_config_folder(path: str | Path | None = None) -> Path:
|
16
|
+
"""Get the path to the bear configuration directory."""
|
17
|
+
config_path: Path = Path(path) if isinstance(path, str) else path or DEFAULT_PATH
|
18
|
+
config_path.mkdir(parents=True, exist_ok=True)
|
19
|
+
return config_path
|
20
|
+
|
21
|
+
|
22
|
+
def get_file_hash(file_path: Path) -> str:
|
23
|
+
"""Return the blake2 hash of the file at the given path."""
|
24
|
+
hasher = hashlib.blake2b()
|
25
|
+
with file_path.open("rb") as file:
|
26
|
+
while chunk := file.read(8192):
|
27
|
+
hasher.update(chunk)
|
28
|
+
return hasher.hexdigest()
|
29
|
+
|
30
|
+
|
31
|
+
class SettingsManager:
|
32
|
+
"""A class to manage settings using TinyDB and an in-memory cache."""
|
33
|
+
|
34
|
+
__slots__ = ("cache", "db", "file_hash", "file_path", "settings_name")
|
35
|
+
|
36
|
+
def __init__(self, settings_name: str, folder_path: str | Path | None = None) -> None:
|
37
|
+
"""Initialize the SettingsManager with a specific settings name."""
|
38
|
+
self.settings_name: str = settings_name
|
39
|
+
self.cache: dict[str, Any] = {}
|
40
|
+
file_name: str = f"{settings_name}.json"
|
41
|
+
self.file_path: Path = get_config_folder(folder_path) / file_name
|
42
|
+
self.db: TinyDB = TinyDB(self.file_path, indent=4, ensure_ascii=False)
|
43
|
+
self.file_hash: str = get_file_hash(self.file_path) if self.file_path.exists() else ""
|
44
|
+
atexit.register(self.close)
|
45
|
+
self._load_cache()
|
46
|
+
|
47
|
+
def __getattr__(self, key: str) -> Any:
|
48
|
+
"""Handle dot notation access for settings."""
|
49
|
+
if key in self.__slots__:
|
50
|
+
raise AttributeError(f"'{key}' not initialized")
|
51
|
+
if key.startswith("_"):
|
52
|
+
raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{key}'")
|
53
|
+
return self.get(key)
|
54
|
+
|
55
|
+
def __setattr__(self, key: str, value: Any) -> None:
|
56
|
+
"""Handle dot notation assignment for settings."""
|
57
|
+
if key in self.__slots__:
|
58
|
+
object.__setattr__(self, key, value)
|
59
|
+
return
|
60
|
+
self.set(key=key, value=value)
|
61
|
+
|
62
|
+
def invalidate_cache(self) -> None:
|
63
|
+
"""Invalidate the in-memory cache."""
|
64
|
+
self.cache.clear()
|
65
|
+
self._load_cache()
|
66
|
+
|
67
|
+
def get(self, key: str, default: Any = None) -> Any:
|
68
|
+
"""Get a setting value."""
|
69
|
+
file_hash = get_file_hash(self.file_path)
|
70
|
+
|
71
|
+
if file_hash != self.file_hash:
|
72
|
+
self.invalidate_cache()
|
73
|
+
self.file_hash = file_hash
|
74
|
+
|
75
|
+
if key in self.cache:
|
76
|
+
return self.cache[key]
|
77
|
+
if result := self.db.search(Query().key == key):
|
78
|
+
value = result[0]["value"]
|
79
|
+
self.cache[key] = value
|
80
|
+
return value
|
81
|
+
return default
|
82
|
+
|
83
|
+
def set(self, key: str, value: Any) -> None:
|
84
|
+
"""Set a setting value."""
|
85
|
+
self.db.upsert({"key": key, "value": value}, Query().key == key)
|
86
|
+
self.cache[key] = value
|
87
|
+
|
88
|
+
def has(self, key: str) -> bool:
|
89
|
+
"""Check if a setting exists."""
|
90
|
+
return key in self.cache or self.db.contains(Query().key == key)
|
91
|
+
|
92
|
+
def _load_cache(self) -> None:
|
93
|
+
"""Load all settings into cache."""
|
94
|
+
for record in self.db.all():
|
95
|
+
self.cache[record["key"]] = record["value"]
|
96
|
+
|
97
|
+
def open(self) -> None:
|
98
|
+
"""Reopen the settings file after it's been closed/destroyed."""
|
99
|
+
self.db = TinyDB(self.file_path, indent=4, ensure_ascii=False)
|
100
|
+
self.cache = {}
|
101
|
+
self._load_cache()
|
102
|
+
|
103
|
+
def close(self) -> None:
|
104
|
+
"""Close the database."""
|
105
|
+
if hasattr(self, "db"):
|
106
|
+
self.db.close()
|
107
|
+
if hasattr(self, "cache"):
|
108
|
+
self.cache.clear()
|
109
|
+
|
110
|
+
def destroy_settings(self) -> bool:
|
111
|
+
"""Delete the settings file."""
|
112
|
+
if self.file_path.exists():
|
113
|
+
self.close()
|
114
|
+
self.file_path.unlink()
|
115
|
+
self.cache.clear()
|
116
|
+
return True
|
117
|
+
return False
|
118
|
+
|
119
|
+
def __contains__(self, key: str) -> bool:
|
120
|
+
return self.has(key)
|
121
|
+
|
122
|
+
def keys(self) -> list[str]:
|
123
|
+
"""Get all setting keys."""
|
124
|
+
return list(self.cache.keys())
|
125
|
+
|
126
|
+
def items(self) -> list[tuple[str, Any]]:
|
127
|
+
"""Get all setting key-value pairs."""
|
128
|
+
return list(self.cache.items())
|
129
|
+
|
130
|
+
def values(self) -> list[Any]:
|
131
|
+
"""Get all setting values."""
|
132
|
+
return list(self.cache.values())
|
133
|
+
|
134
|
+
def __len__(self):
|
135
|
+
return len(self.cache)
|
136
|
+
|
137
|
+
def __enter__(self) -> Self:
|
138
|
+
return self
|
139
|
+
|
140
|
+
def __exit__(self, exc_type: object, exc_val: object, exc_tb: object) -> None:
|
141
|
+
self.close()
|
142
|
+
|
143
|
+
def __repr__(self) -> str:
|
144
|
+
return f"<SettingsManager settings_name='{self.settings_name}'>"
|
145
|
+
|
146
|
+
def __str__(self) -> str:
|
147
|
+
return f"SettingsManager for '{self.settings_name}' with {len(self.keys())} settings."
|
148
|
+
|
149
|
+
|
150
|
+
_settings_managers: dict[str, SettingsManager] = {}
|
151
|
+
|
152
|
+
|
153
|
+
def get_settings_manager(settings_name: str) -> SettingsManager:
|
154
|
+
"""Get or create a SettingsManager instance."""
|
155
|
+
if settings_name not in _settings_managers:
|
156
|
+
_settings_managers[settings_name] = SettingsManager(settings_name=settings_name)
|
157
|
+
return _settings_managers[settings_name]
|
158
|
+
|
159
|
+
|
160
|
+
@contextmanager
|
161
|
+
def settings(settings_name: str) -> Generator[SettingsManager]:
|
162
|
+
"""Context manager for SettingsManager."""
|
163
|
+
sm: SettingsManager = get_settings_manager(settings_name)
|
164
|
+
try:
|
165
|
+
yield sm
|
166
|
+
finally:
|
167
|
+
sm.close()
|
168
|
+
|
169
|
+
|
170
|
+
if __name__ == "__main__":
|
171
|
+
# Example usage of the SettingsManager
|
172
|
+
with settings(settings_name="example_settings") as sm:
|
173
|
+
sm.sample_setting = "This is a sample setting"
|
174
|
+
print(sm.sample_setting)
|
175
|
+
print(sm.keys())
|
176
|
+
for key, value in sm.items():
|
177
|
+
print("This is items()")
|
178
|
+
print(f"Key: {key}, Value: {value}")
|
179
|
+
print(sm.file_path)
|
@@ -0,0 +1,90 @@
|
|
1
|
+
"""Constants Module for Bear Utils."""
|
2
|
+
|
3
|
+
from pathlib import Path
|
4
|
+
import sys
|
5
|
+
from typing import TextIO
|
6
|
+
|
7
|
+
from bear_utils.constants._exit_code import (
|
8
|
+
COMMAND_CANNOT_EXECUTE,
|
9
|
+
COMMAND_NOT_FOUND,
|
10
|
+
EXIT_STATUS_OUT_OF_RANGE,
|
11
|
+
FAIL,
|
12
|
+
FAILURE,
|
13
|
+
INVALID_ARGUMENT_TO_EXIT,
|
14
|
+
MISUSE_OF_SHELL_COMMAND,
|
15
|
+
PROCESS_KILLED_BY_SIGKILL,
|
16
|
+
PROCESS_TERMINATED_BY_SIGTERM,
|
17
|
+
SCRIPT_TERMINATED_BY_CONTROL_C,
|
18
|
+
SEGMENTATION_FAULT,
|
19
|
+
SUCCESS,
|
20
|
+
ExitCode,
|
21
|
+
)
|
22
|
+
from bear_utils.constants._http_status_code import (
|
23
|
+
BAD_REQUEST,
|
24
|
+
CONFLICT,
|
25
|
+
FORBIDDEN,
|
26
|
+
PAGE_NOT_FOUND,
|
27
|
+
SERVER_ERROR,
|
28
|
+
SERVER_OK,
|
29
|
+
UNAUTHORIZED,
|
30
|
+
HTTPStatusCode,
|
31
|
+
)
|
32
|
+
from bear_utils.constants._meta import IntValue, NullFile, RichIntEnum, RichStrEnum, StrValue
|
33
|
+
|
34
|
+
VIDEO_EXTS = [".mp4", ".mov", ".avi", ".mkv"]
|
35
|
+
"""Extensions for video files."""
|
36
|
+
IMAGE_EXTS = [".jpg", ".jpeg", ".png", ".gif"]
|
37
|
+
"""Extensions for image files."""
|
38
|
+
FILE_EXTS = IMAGE_EXTS + VIDEO_EXTS
|
39
|
+
"""Extensions for both image and video files."""
|
40
|
+
|
41
|
+
PATH_TO_DOWNLOADS = Path.home() / "Downloads"
|
42
|
+
"""Path to the Downloads folder."""
|
43
|
+
PATH_TO_PICTURES = Path.home() / "Pictures"
|
44
|
+
"""Path to the Pictures folder."""
|
45
|
+
GLOBAL_VENV = Path.home() / ".global_venv"
|
46
|
+
"""Path to the global virtual environment."""
|
47
|
+
|
48
|
+
STDOUT: TextIO = sys.stdout
|
49
|
+
"""Standard output stream."""
|
50
|
+
STDERR: TextIO = sys.stderr
|
51
|
+
"""Standard error stream."""
|
52
|
+
DEVNULL: TextIO = NullFile()
|
53
|
+
"""A null file that discards all writes."""
|
54
|
+
|
55
|
+
__all__ = [
|
56
|
+
"BAD_REQUEST",
|
57
|
+
"COMMAND_CANNOT_EXECUTE",
|
58
|
+
"COMMAND_NOT_FOUND",
|
59
|
+
"CONFLICT",
|
60
|
+
"EXIT_STATUS_OUT_OF_RANGE",
|
61
|
+
"FAIL",
|
62
|
+
"FAILURE",
|
63
|
+
"FILE_EXTS",
|
64
|
+
"FORBIDDEN",
|
65
|
+
"GLOBAL_VENV",
|
66
|
+
"IMAGE_EXTS",
|
67
|
+
"INVALID_ARGUMENT_TO_EXIT",
|
68
|
+
"MISUSE_OF_SHELL_COMMAND",
|
69
|
+
"PAGE_NOT_FOUND",
|
70
|
+
"PATH_TO_DOWNLOADS",
|
71
|
+
"PATH_TO_PICTURES",
|
72
|
+
"PROCESS_KILLED_BY_SIGKILL",
|
73
|
+
"PROCESS_TERMINATED_BY_SIGTERM",
|
74
|
+
"SCRIPT_TERMINATED_BY_CONTROL_C",
|
75
|
+
"SEGMENTATION_FAULT",
|
76
|
+
"SERVER_ERROR",
|
77
|
+
"SERVER_OK",
|
78
|
+
"STDERR",
|
79
|
+
"STDOUT",
|
80
|
+
"SUCCESS",
|
81
|
+
"UNAUTHORIZED",
|
82
|
+
"VIDEO_EXTS",
|
83
|
+
"ExitCode",
|
84
|
+
"HTTPStatusCode",
|
85
|
+
"IntValue",
|
86
|
+
"NullFile",
|
87
|
+
"RichIntEnum",
|
88
|
+
"RichStrEnum",
|
89
|
+
"StrValue",
|
90
|
+
]
|