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 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