vs-common 0.1.0__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.
- vs_common/__init__.py +0 -0
- vs_common/cache/__init__.py +0 -0
- vs_common/cache/in_memory_cache.py +63 -0
- vs_common/cache/redis_cache.py +47 -0
- vs_common/cache/vs_base_cache.py +25 -0
- vs_common/cache/vs_cache_manager.py +82 -0
- vs_common/config/__init__.py +0 -0
- vs_common/config/vs_base_config.py +22 -0
- vs_common/config/vs_config_loader.py +110 -0
- vs_common/config/vs_ini_config.py +48 -0
- vs_common/decorator/__init__.py +0 -0
- vs_common/decorator/vs_cache_decorator.py +42 -0
- vs_common/decorator/vs_logger_decorator.py +10 -0
- vs_common/log/__init__.py +0 -0
- vs_common/log/vs_log_manager.py +160 -0
- vs_common/log/vs_logger.py +28 -0
- vs_common/schema/__init__.py +0 -0
- vs_common/schema/vs_cache_config.py +13 -0
- vs_common/schema/vs_log_config.py +15 -0
- vs_common-0.1.0.dist-info/METADATA +683 -0
- vs_common-0.1.0.dist-info/RECORD +24 -0
- vs_common-0.1.0.dist-info/WHEEL +5 -0
- vs_common-0.1.0.dist-info/licenses/LICENSE.txt +181 -0
- vs_common-0.1.0.dist-info/top_level.txt +1 -0
vs_common/__init__.py
ADDED
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import fnmatch
|
|
3
|
+
import time
|
|
4
|
+
from typing import Any, Dict, Optional, Tuple
|
|
5
|
+
|
|
6
|
+
from vs_common.cache.vs_base_cache import VsBaseCache
|
|
7
|
+
|
|
8
|
+
_CLEANUP_INTERVAL = 100 # evict expired entries every N writes
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class InMemoryCache(VsBaseCache):
|
|
12
|
+
|
|
13
|
+
def __init__(self):
|
|
14
|
+
self._store: Dict[str, Tuple[Any, Optional[float]]] = {}
|
|
15
|
+
self._lock = asyncio.Lock()
|
|
16
|
+
self._write_count = 0
|
|
17
|
+
|
|
18
|
+
def _is_expired(self, expires_at: Optional[float]) -> bool:
|
|
19
|
+
return expires_at is not None and time.monotonic() > expires_at
|
|
20
|
+
|
|
21
|
+
def _evict_expired(self) -> None:
|
|
22
|
+
now = time.monotonic()
|
|
23
|
+
expired = [k for k, (_, exp) in self._store.items() if exp is not None and now > exp]
|
|
24
|
+
for k in expired:
|
|
25
|
+
del self._store[k]
|
|
26
|
+
|
|
27
|
+
async def get(self, key: str) -> Optional[Any]:
|
|
28
|
+
async with self._lock:
|
|
29
|
+
entry = self._store.get(key)
|
|
30
|
+
if entry is None:
|
|
31
|
+
return None
|
|
32
|
+
value, expires_at = entry
|
|
33
|
+
if self._is_expired(expires_at):
|
|
34
|
+
del self._store[key]
|
|
35
|
+
return None
|
|
36
|
+
return value
|
|
37
|
+
|
|
38
|
+
async def set(self, key: str, value: Any, ttl: Optional[int] = None) -> bool:
|
|
39
|
+
expires_at = time.monotonic() + ttl if ttl is not None else None
|
|
40
|
+
async with self._lock:
|
|
41
|
+
self._store[key] = (value, expires_at)
|
|
42
|
+
self._write_count += 1
|
|
43
|
+
if self._write_count >= _CLEANUP_INTERVAL:
|
|
44
|
+
self._evict_expired()
|
|
45
|
+
self._write_count = 0
|
|
46
|
+
return True
|
|
47
|
+
|
|
48
|
+
async def delete(self, key: str) -> bool:
|
|
49
|
+
async with self._lock:
|
|
50
|
+
if key in self._store:
|
|
51
|
+
del self._store[key]
|
|
52
|
+
return True
|
|
53
|
+
return False
|
|
54
|
+
|
|
55
|
+
async def delete_pattern(self, pattern: str) -> int:
|
|
56
|
+
async with self._lock:
|
|
57
|
+
matched = [k for k in self._store if fnmatch.fnmatch(k, pattern)]
|
|
58
|
+
for k in matched:
|
|
59
|
+
del self._store[k]
|
|
60
|
+
return len(matched)
|
|
61
|
+
|
|
62
|
+
async def ping(self) -> bool:
|
|
63
|
+
return True
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from typing import Any, Optional
|
|
2
|
+
|
|
3
|
+
import redis.asyncio as aioredis
|
|
4
|
+
|
|
5
|
+
from vs_common.cache.vs_base_cache import VsBaseCache
|
|
6
|
+
from vs_common.schema.vs_cache_config import VsCacheConfig
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class RedisCache(VsBaseCache):
|
|
10
|
+
|
|
11
|
+
def __init__(self, config: VsCacheConfig):
|
|
12
|
+
self._default_ttl = config.default_ttl
|
|
13
|
+
self._client = aioredis.Redis(
|
|
14
|
+
host=config.host,
|
|
15
|
+
port=config.port,
|
|
16
|
+
db=config.db,
|
|
17
|
+
password=config.password,
|
|
18
|
+
decode_responses=False,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
async def get(self, key: str) -> Optional[Any]:
|
|
22
|
+
import pickle
|
|
23
|
+
value = await self._client.get(key)
|
|
24
|
+
return pickle.loads(value) if value is not None else None
|
|
25
|
+
|
|
26
|
+
async def set(self, key: str, value: Any, ttl: Optional[int] = None) -> bool:
|
|
27
|
+
import pickle
|
|
28
|
+
ex = ttl if ttl is not None else self._default_ttl
|
|
29
|
+
return await self._client.set(key, pickle.dumps(value), ex=ex)
|
|
30
|
+
|
|
31
|
+
async def delete(self, key: str) -> bool:
|
|
32
|
+
return await self._client.delete(key) > 0
|
|
33
|
+
|
|
34
|
+
async def delete_pattern(self, pattern: str) -> int:
|
|
35
|
+
keys = await self._client.keys(pattern)
|
|
36
|
+
if not keys:
|
|
37
|
+
return 0
|
|
38
|
+
return await self._client.delete(*keys)
|
|
39
|
+
|
|
40
|
+
async def ping(self) -> bool:
|
|
41
|
+
try:
|
|
42
|
+
return await self._client.ping()
|
|
43
|
+
except Exception:
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
async def close(self) -> None:
|
|
47
|
+
await self._client.aclose()
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import Any, Optional
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class VsBaseCache(ABC):
|
|
6
|
+
|
|
7
|
+
@abstractmethod
|
|
8
|
+
async def get(self, key: str) -> Optional[Any]:
|
|
9
|
+
"""Return the cached value, or None if missing or cache is disabled."""
|
|
10
|
+
|
|
11
|
+
@abstractmethod
|
|
12
|
+
async def set(self, key: str, value: Any, ttl: Optional[int] = None) -> bool:
|
|
13
|
+
"""Store value under key with TTL (seconds). Returns True on success."""
|
|
14
|
+
|
|
15
|
+
@abstractmethod
|
|
16
|
+
async def delete(self, key: str) -> bool:
|
|
17
|
+
"""Remove a single key. Returns True if the key existed."""
|
|
18
|
+
|
|
19
|
+
@abstractmethod
|
|
20
|
+
async def delete_pattern(self, pattern: str) -> int:
|
|
21
|
+
"""Remove all keys matching a glob pattern. Returns count deleted."""
|
|
22
|
+
|
|
23
|
+
@abstractmethod
|
|
24
|
+
async def ping(self) -> bool:
|
|
25
|
+
"""Health check — True if the cache backend is reachable."""
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
from typing import Any, Optional
|
|
3
|
+
|
|
4
|
+
from vs_common.cache.in_memory_cache import InMemoryCache
|
|
5
|
+
from vs_common.cache.redis_cache import RedisCache
|
|
6
|
+
from vs_common.cache.vs_base_cache import VsBaseCache
|
|
7
|
+
from vs_common.decorator.vs_logger_decorator import vs_logger
|
|
8
|
+
from vs_common.schema.vs_cache_config import VsCacheConfig
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@vs_logger("VsCacheManager")
|
|
12
|
+
class VsCacheManager:
|
|
13
|
+
|
|
14
|
+
_service: Optional[VsBaseCache] = None
|
|
15
|
+
_lock: threading.RLock = threading.RLock()
|
|
16
|
+
|
|
17
|
+
@classmethod
|
|
18
|
+
def init(cls, config: VsCacheConfig) -> None:
|
|
19
|
+
if not config.enabled or config.backend == "memory":
|
|
20
|
+
cls.register(InMemoryCache())
|
|
21
|
+
elif config.backend == "redis":
|
|
22
|
+
cls.register(RedisCache(config))
|
|
23
|
+
else:
|
|
24
|
+
raise NotImplementedError(
|
|
25
|
+
f"Cache backend '{config.backend}' is not yet supported. "
|
|
26
|
+
"Register a service manually via VsCacheManager.register()."
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def register(cls, service: VsBaseCache) -> None:
|
|
31
|
+
with cls._lock:
|
|
32
|
+
cls._service = service
|
|
33
|
+
cls.vs_logger.info(f"VsCacheManager registered: {type(service).__name__}")
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
def _get_service(cls) -> VsBaseCache:
|
|
37
|
+
if cls._service is None:
|
|
38
|
+
with cls._lock:
|
|
39
|
+
if cls._service is None:
|
|
40
|
+
cls._service = InMemoryCache()
|
|
41
|
+
cls.vs_logger.info("VsCacheManager defaulting to InMemoryCache")
|
|
42
|
+
return cls._service
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
async def get(cls, key: str) -> Optional[Any]:
|
|
46
|
+
try:
|
|
47
|
+
return await cls._get_service().get(key)
|
|
48
|
+
except Exception as e:
|
|
49
|
+
cls.vs_logger.error(f"Cache GET failed for '{key}': {e}")
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
@classmethod
|
|
53
|
+
async def set(cls, key: str, value: Any, ttl: Optional[int] = None) -> bool:
|
|
54
|
+
try:
|
|
55
|
+
return await cls._get_service().set(key, value, ttl)
|
|
56
|
+
except Exception as e:
|
|
57
|
+
cls.vs_logger.error(f"Cache SET failed for '{key}': {e}")
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
async def delete(cls, key: str) -> bool:
|
|
62
|
+
try:
|
|
63
|
+
return await cls._get_service().delete(key)
|
|
64
|
+
except Exception as e:
|
|
65
|
+
cls.vs_logger.error(f"Cache DELETE failed for '{key}': {e}")
|
|
66
|
+
return False
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
async def delete_pattern(cls, pattern: str) -> int:
|
|
70
|
+
try:
|
|
71
|
+
return await cls._get_service().delete_pattern(pattern)
|
|
72
|
+
except Exception as e:
|
|
73
|
+
cls.vs_logger.error(f"Cache DELETE_PATTERN failed for '{pattern}': {e}")
|
|
74
|
+
return 0
|
|
75
|
+
|
|
76
|
+
@classmethod
|
|
77
|
+
async def ping(cls) -> bool:
|
|
78
|
+
try:
|
|
79
|
+
return await cls._get_service().ping()
|
|
80
|
+
except Exception as e:
|
|
81
|
+
cls.vs_logger.error(f"Cache PING failed: {e}")
|
|
82
|
+
return False
|
|
File without changes
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import Any, Dict, Type
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class VsBaseConfig(ABC):
|
|
6
|
+
|
|
7
|
+
@abstractmethod
|
|
8
|
+
def get(self, key: str, default: Any = None, data_type: Type = str) -> Any:
|
|
9
|
+
"""
|
|
10
|
+
Get a configuration value by key.
|
|
11
|
+
|
|
12
|
+
Args:
|
|
13
|
+
key: Dotted key format — "section.key" (e.g. "database.url")
|
|
14
|
+
default: Value returned if key is not found
|
|
15
|
+
data_type: Type to cast the value to (str, int, float, bool, list)
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
Configuration value cast to data_type, or default if not found
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def get_section(self, section: str) -> Dict[str, str]:
|
|
22
|
+
return {}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""
|
|
2
|
+
A Config file loader that is used to load the values from
|
|
3
|
+
a config.ini
|
|
4
|
+
"""
|
|
5
|
+
import configparser
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Dict, Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class VsConfigLoader:
|
|
11
|
+
"""
|
|
12
|
+
Low-level configuration loader.
|
|
13
|
+
|
|
14
|
+
Handles reading config.ini files and accessing environment variables.
|
|
15
|
+
"""
|
|
16
|
+
def __init__(self, config_path: str = "config.ini"):
|
|
17
|
+
"""
|
|
18
|
+
Initialize the config loader.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
config_path: Path to config.ini file. Defaults to "config.ini" in current directory.
|
|
22
|
+
"""
|
|
23
|
+
self.config_path = Path(config_path)
|
|
24
|
+
self._config_data: Dict[str, Dict[str, str]] = {}
|
|
25
|
+
self._load_ini_file()
|
|
26
|
+
|
|
27
|
+
def _load_ini_file(self) -> None:
|
|
28
|
+
"""
|
|
29
|
+
Load config.ini file into memory.
|
|
30
|
+
|
|
31
|
+
Reads the INI file and stores all sections and keys in _config_data.
|
|
32
|
+
Does not raise error if file not found - just logs and continues.
|
|
33
|
+
"""
|
|
34
|
+
if not self.config_path.exists():
|
|
35
|
+
print(f"Config file not found: {self.config_path}. Will rely on environment variables and defaults.")
|
|
36
|
+
return
|
|
37
|
+
try:
|
|
38
|
+
parser = configparser.ConfigParser()
|
|
39
|
+
parser.read(self.config_path)
|
|
40
|
+
|
|
41
|
+
# Convert to nested dictionary
|
|
42
|
+
for section in parser.sections():
|
|
43
|
+
self._config_data[section] = {}
|
|
44
|
+
for key, value in parser.items(section):
|
|
45
|
+
self._config_data[section][key] = value
|
|
46
|
+
|
|
47
|
+
except Exception as e:
|
|
48
|
+
print(f"Warning: Failed to load config file {self.config_path}: {e}")
|
|
49
|
+
|
|
50
|
+
def get_value(self, section: str, key: str) -> Optional[str]:
|
|
51
|
+
"""
|
|
52
|
+
Get value from loaded config.ini data.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
section: Section name from config.ini (e.g., "database")
|
|
56
|
+
key: Key name within section (e.g., "url")
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
Value as string or None if section/key not found
|
|
60
|
+
"""
|
|
61
|
+
if section not in self._config_data:
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
return self._config_data[section].get(key)
|
|
65
|
+
|
|
66
|
+
def get_section(self, section: str) -> Dict[str, str]:
|
|
67
|
+
"""
|
|
68
|
+
Get all key-value pairs from a section.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
section: Section name from config.ini
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
Dictionary of all keys and values in the section, empty dict if not found
|
|
75
|
+
"""
|
|
76
|
+
return self._config_data.get(section, {}).copy()
|
|
77
|
+
|
|
78
|
+
def reload(self) -> None:
|
|
79
|
+
"""
|
|
80
|
+
Reload config.ini from disk.
|
|
81
|
+
|
|
82
|
+
Useful if config file changes during runtime.
|
|
83
|
+
"""
|
|
84
|
+
self._config_data.clear()
|
|
85
|
+
self._load_ini_file()
|
|
86
|
+
|
|
87
|
+
def has_section(self, section: str) -> bool:
|
|
88
|
+
"""
|
|
89
|
+
Check if section exists in config.ini.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
section: Section name
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
True if section exists, False otherwise
|
|
96
|
+
"""
|
|
97
|
+
return section in self._config_data
|
|
98
|
+
|
|
99
|
+
def has_key(self, section: str, key: str) -> bool:
|
|
100
|
+
"""
|
|
101
|
+
Check if key exists in section.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
section: Section name
|
|
105
|
+
key: Key name
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
True if key exists in section, False otherwise
|
|
109
|
+
"""
|
|
110
|
+
return section in self._config_data and key in self._config_data[section]
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from typing import Any, Dict, Type
|
|
3
|
+
|
|
4
|
+
from vs_common.config.vs_base_config import VsBaseConfig
|
|
5
|
+
from vs_common.config.vs_config_loader import VsConfigLoader
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class VsIniConfig(VsBaseConfig):
|
|
9
|
+
|
|
10
|
+
def __init__(self, config_path: str = "config.ini"):
|
|
11
|
+
self._loader = VsConfigLoader(config_path)
|
|
12
|
+
|
|
13
|
+
def get(self, key: str, default: Any = None, data_type: Type = str) -> Any:
|
|
14
|
+
section, config_key = self._parse_key(key)
|
|
15
|
+
|
|
16
|
+
env_value = os.getenv(key.upper().replace(".", "_"))
|
|
17
|
+
if env_value is not None:
|
|
18
|
+
return self._convert(env_value, data_type)
|
|
19
|
+
|
|
20
|
+
ini_value = self._loader.get_value(section, config_key)
|
|
21
|
+
if ini_value is not None:
|
|
22
|
+
return self._convert(ini_value, data_type)
|
|
23
|
+
|
|
24
|
+
return default
|
|
25
|
+
|
|
26
|
+
def get_section(self, section: str) -> Dict[str, str]:
|
|
27
|
+
return self._loader.get_section(section)
|
|
28
|
+
|
|
29
|
+
def reload(self) -> None:
|
|
30
|
+
self._loader.reload()
|
|
31
|
+
|
|
32
|
+
def _parse_key(self, key: str):
|
|
33
|
+
if "." not in key:
|
|
34
|
+
raise ValueError(f"Invalid key format: '{key}'. Expected 'section.key'.")
|
|
35
|
+
return key.split(".", 1)
|
|
36
|
+
|
|
37
|
+
def _convert(self, value: str, target_type: Type) -> Any:
|
|
38
|
+
if target_type == str:
|
|
39
|
+
return value
|
|
40
|
+
if target_type == int:
|
|
41
|
+
return int(value)
|
|
42
|
+
if target_type == float:
|
|
43
|
+
return float(value)
|
|
44
|
+
if target_type == bool:
|
|
45
|
+
return value.lower().strip() in ("true", "1", "yes", "on")
|
|
46
|
+
if target_type == list:
|
|
47
|
+
return [item.strip() for item in value.split(",") if item.strip()]
|
|
48
|
+
raise ValueError(f"Unsupported type: {target_type}")
|
|
File without changes
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import functools
|
|
2
|
+
from typing import Callable
|
|
3
|
+
|
|
4
|
+
from vs_common.cache.vs_cache_manager import VsCacheManager
|
|
5
|
+
from vs_common.log.vs_log_manager import VsLogManager
|
|
6
|
+
|
|
7
|
+
_logger = VsLogManager.get_instance(__name__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def cacheable(key_prefix: str, ttl: int = 3600):
|
|
11
|
+
def decorator(func: Callable) -> Callable:
|
|
12
|
+
@functools.wraps(func)
|
|
13
|
+
async def wrapper(self, *args, **kwargs):
|
|
14
|
+
cache_key_suffix = ":".join(str(a) for a in args)
|
|
15
|
+
if kwargs:
|
|
16
|
+
cache_key_suffix += ":" + ":".join(f"{k}={v}" for k, v in sorted(kwargs.items()))
|
|
17
|
+
cache_key = f"{key_prefix}:{cache_key_suffix or 'default'}"
|
|
18
|
+
|
|
19
|
+
cached_value = await VsCacheManager.get(cache_key)
|
|
20
|
+
if cached_value is not None:
|
|
21
|
+
_logger.debug(f"Cache HIT: {cache_key}")
|
|
22
|
+
return cached_value
|
|
23
|
+
|
|
24
|
+
result = await func(self, *args, **kwargs)
|
|
25
|
+
if result is not None:
|
|
26
|
+
await VsCacheManager.set(cache_key, result, ttl=ttl)
|
|
27
|
+
return result
|
|
28
|
+
|
|
29
|
+
return wrapper
|
|
30
|
+
return decorator
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def cache_evict(key_prefix: str):
|
|
34
|
+
def decorator(func: Callable) -> Callable:
|
|
35
|
+
@functools.wraps(func)
|
|
36
|
+
async def wrapper(self, *args, **kwargs):
|
|
37
|
+
result = await func(self, *args, **kwargs)
|
|
38
|
+
await VsCacheManager.delete_pattern(f"{key_prefix}:*")
|
|
39
|
+
return result
|
|
40
|
+
|
|
41
|
+
return wrapper
|
|
42
|
+
return decorator
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from typing import Callable, Type
|
|
2
|
+
|
|
3
|
+
from vs_common.log.vs_log_manager import VsLogManager
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def vs_logger(name: str) -> Callable[[Type], Type]:
|
|
7
|
+
def decorator(cls: Type) -> Type:
|
|
8
|
+
cls.vs_logger = VsLogManager.get_instance(name)
|
|
9
|
+
return cls
|
|
10
|
+
return decorator
|
|
File without changes
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import logging.handlers
|
|
3
|
+
import sys
|
|
4
|
+
import threading
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional, Dict
|
|
7
|
+
|
|
8
|
+
from vs_common.schema.vs_log_config import VsLogConfig
|
|
9
|
+
from vs_common.log.vs_logger import VsLogger
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class VsLogManager:
|
|
13
|
+
"""
|
|
14
|
+
Centralized logging manager for Viveka AI (Singleton).
|
|
15
|
+
|
|
16
|
+
VivekaLogManager is responsible for:
|
|
17
|
+
- Initializing logging configuration from ConfigService
|
|
18
|
+
- Setting up handlers: ConsoleHandler, FileHandler
|
|
19
|
+
- Providing logger instances to classes via get_instance()
|
|
20
|
+
- Managing log formatting and levels
|
|
21
|
+
|
|
22
|
+
This class implements the Singleton pattern to ensure consistent logging
|
|
23
|
+
configuration across the entire application.
|
|
24
|
+
|
|
25
|
+
Initialization:
|
|
26
|
+
VivekaLogManager must be initialized once at application startup by calling
|
|
27
|
+
VivekaLogManager.init(). This reads configuration from ConfigService and sets
|
|
28
|
+
up all handlers.
|
|
29
|
+
"""
|
|
30
|
+
_instance: Optional['VsLogManager'] = None
|
|
31
|
+
_initialized: bool = False
|
|
32
|
+
_lock: threading.RLock = threading.RLock()
|
|
33
|
+
_loggers: Dict[str, VsLogger] = {}
|
|
34
|
+
_config: VsLogConfig = VsLogConfig()
|
|
35
|
+
|
|
36
|
+
def __new__(cls):
|
|
37
|
+
"""Singleton implementation."""
|
|
38
|
+
if cls._instance is None:
|
|
39
|
+
with cls._lock:
|
|
40
|
+
if cls._instance is None:
|
|
41
|
+
cls._instance = super().__new__(cls)
|
|
42
|
+
return cls._instance
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def init(cls, config: Optional[VsLogConfig] = None) -> None:
|
|
46
|
+
if cls._initialized:
|
|
47
|
+
if config is not None:
|
|
48
|
+
# An explicit config was provided after auto-init (e.g. VivekaApp startup).
|
|
49
|
+
# Re-apply the level so the app's configured level overrides the
|
|
50
|
+
# INFO default that get_instance() uses when it auto-initialises.
|
|
51
|
+
instance = cls()
|
|
52
|
+
instance._config = config
|
|
53
|
+
target_level = instance._parse_log_level(config.level or "INFO")
|
|
54
|
+
root = logging.getLogger()
|
|
55
|
+
root.setLevel(target_level)
|
|
56
|
+
for handler in root.handlers:
|
|
57
|
+
handler.setLevel(target_level)
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
with cls._lock:
|
|
61
|
+
if cls._initialized:
|
|
62
|
+
return
|
|
63
|
+
instance = cls()
|
|
64
|
+
instance._config = config or VsLogConfig()
|
|
65
|
+
instance._setup_logging()
|
|
66
|
+
cls._initialized = True
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def get_instance(cls, name: str) -> VsLogger:
|
|
70
|
+
if not cls._initialized:
|
|
71
|
+
config = VsLogConfig()
|
|
72
|
+
cls.init(config)
|
|
73
|
+
|
|
74
|
+
if name in cls._loggers:
|
|
75
|
+
return cls._loggers[name]
|
|
76
|
+
|
|
77
|
+
# Create new logger
|
|
78
|
+
python_logger = logging.getLogger(name)
|
|
79
|
+
vs_logger = VsLogger(name, python_logger)
|
|
80
|
+
cls._loggers[name] = vs_logger
|
|
81
|
+
|
|
82
|
+
return vs_logger
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _setup_logging(self) -> None:
|
|
86
|
+
log_level = self._config.level or "INFO"
|
|
87
|
+
log_format = self._config.format or "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
|
88
|
+
|
|
89
|
+
# Configure root logger
|
|
90
|
+
root_logger = logging.getLogger()
|
|
91
|
+
root_logger.setLevel(self._parse_log_level(log_level))
|
|
92
|
+
|
|
93
|
+
# Clear existing handlers to avoid duplicates
|
|
94
|
+
root_logger.handlers.clear()
|
|
95
|
+
|
|
96
|
+
# Create formatter
|
|
97
|
+
formatter = logging.Formatter(log_format)
|
|
98
|
+
|
|
99
|
+
# Setup console handler
|
|
100
|
+
if self._config.console_enabled:
|
|
101
|
+
console_handler = self._create_console_handler(formatter)
|
|
102
|
+
if console_handler:
|
|
103
|
+
root_logger.addHandler(console_handler)
|
|
104
|
+
|
|
105
|
+
# Setup file handler
|
|
106
|
+
if self._config.file_enabled:
|
|
107
|
+
file_handler = self._create_file_handler(formatter)
|
|
108
|
+
if file_handler:
|
|
109
|
+
root_logger.addHandler(file_handler)
|
|
110
|
+
|
|
111
|
+
# Log initialization message
|
|
112
|
+
root_logger.info("VivekaLogManager initialized")
|
|
113
|
+
|
|
114
|
+
def _create_file_handler(
|
|
115
|
+
self,
|
|
116
|
+
formatter: logging.Formatter
|
|
117
|
+
) -> Optional[logging.Handler]:
|
|
118
|
+
try:
|
|
119
|
+
file_path = self._config.file_path or "logs/viveka.log"
|
|
120
|
+
file_level = self._config.file_level or self._config.level or "INFO"
|
|
121
|
+
max_bytes = self._config.file_max_bytes
|
|
122
|
+
backup_count = self._config.file_backup_count
|
|
123
|
+
|
|
124
|
+
# Create log directory if it doesn't exist
|
|
125
|
+
log_file = Path(file_path)
|
|
126
|
+
log_file.parent.mkdir(parents=True, exist_ok=True)
|
|
127
|
+
|
|
128
|
+
# Create rotating file handler
|
|
129
|
+
handler = logging.handlers.RotatingFileHandler(
|
|
130
|
+
filename=log_file,
|
|
131
|
+
maxBytes=max_bytes,
|
|
132
|
+
backupCount=backup_count,
|
|
133
|
+
encoding='utf-8'
|
|
134
|
+
)
|
|
135
|
+
handler.setLevel(self._parse_log_level(file_level))
|
|
136
|
+
handler.setFormatter(formatter)
|
|
137
|
+
return handler
|
|
138
|
+
except Exception as e:
|
|
139
|
+
print(f"Failed to create file handler: {e}", file=sys.stderr)
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _create_console_handler(
|
|
144
|
+
self,
|
|
145
|
+
formatter: logging.Formatter
|
|
146
|
+
) -> Optional[logging.Handler]:
|
|
147
|
+
try:
|
|
148
|
+
console_level = self._config.console_level or self._config.level or "INFO"
|
|
149
|
+
handler = logging.StreamHandler(sys.stdout)
|
|
150
|
+
handler.setLevel(self._parse_log_level(console_level))
|
|
151
|
+
handler.setFormatter(formatter)
|
|
152
|
+
return handler
|
|
153
|
+
except Exception as e:
|
|
154
|
+
print(f"Failed to create console handler: {e}", file=sys.stderr)
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
def _parse_log_level(self, level: str) -> int:
|
|
158
|
+
normalized = "WARNING" if level.upper() == "WARN" else level.upper()
|
|
159
|
+
numeric = logging.getLevelName(normalized)
|
|
160
|
+
return numeric if isinstance(numeric, int) else logging.INFO
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import Optional, Dict, Any
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class VsLogger:
|
|
6
|
+
|
|
7
|
+
def __init__(self, name: str, logger: logging.Logger):
|
|
8
|
+
self.name = name
|
|
9
|
+
self._logger = logger
|
|
10
|
+
|
|
11
|
+
def debug(self, message: str, extra: Optional[Dict[str, Any]] = None) -> None:
|
|
12
|
+
self._logger.debug(message, extra=extra or {})
|
|
13
|
+
|
|
14
|
+
def info(self, message: str, extra: Optional[Dict[str, Any]] = None) -> None:
|
|
15
|
+
self._logger.info(message, extra=extra or {})
|
|
16
|
+
|
|
17
|
+
def warn(self, message: str, extra: Optional[Dict[str, Any]] = None) -> None:
|
|
18
|
+
self._logger.warning(message, extra=extra or {})
|
|
19
|
+
|
|
20
|
+
def warning(self, message: str, extra: Optional[Dict[str, Any]] = None) -> None:
|
|
21
|
+
self.warn(message, extra=extra)
|
|
22
|
+
|
|
23
|
+
def error(self, message: str, extra: Optional[Dict[str, Any]] = None,
|
|
24
|
+
exc_info: bool = False) -> None:
|
|
25
|
+
self._logger.error(message, extra=extra or {}, exc_info=exc_info)
|
|
26
|
+
|
|
27
|
+
def exception(self, message: str, extra: Optional[Dict[str, Any]] = None) -> None:
|
|
28
|
+
self._logger.exception(message, extra=extra or {})
|
|
File without changes
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass(frozen=True)
|
|
6
|
+
class VsCacheConfig:
|
|
7
|
+
enabled: bool = False
|
|
8
|
+
backend: str = "memory"
|
|
9
|
+
default_ttl: int = 3600
|
|
10
|
+
host: str = "localhost"
|
|
11
|
+
port: int = 6379
|
|
12
|
+
db: int = 0
|
|
13
|
+
password: Optional[str] = None
|