"""
Platform Configuration Loader
Loads configuration from multiple .env files in order:
1. config/.env.platform - Global platform settings
2. projects/{name}/.env - Project-specific settings
3. .env.local - Local overrides (gitignored)
Usage:
from system.lib.config import Config
# Load platform config only
cfg = Config()
print(cfg.NOCODB_URL)
# Load with project config
cfg = Config(project="pirotehnika")
print(cfg.OZON_O1_API_KEY)
# Get OZON accounts as dict
accounts = cfg.get_ozon_accounts()
# Get path with DATASPACE
path = cfg.data_path("prices/import")
Version: 1.1.0
Date: 2025-12-16
"""
import os
from pathlib import Path
from typing import Optional, Dict, Any
from dataclasses import dataclass, field
# Markers that identify workspace root (must be unique to root, not subdirs)
WORKSPACE_MARKERS = ['config/.env.platform', 'system/lib/config.py', 'architect/']
def find_workspace_root(start_path: Path = None) -> Optional[Path]:
"""
Find workspace root by walking up from start_path.
Looks for marker files/directories.
Returns None if not found (will use env var or default).
"""
if start_path is None:
start_path = Path(__file__).resolve()
current = start_path if start_path.is_dir() else start_path.parent
# Walk up max 10 levels
for _ in range(10):
for marker in WORKSPACE_MARKERS:
if (current / marker).exists():
return current
parent = current.parent
if parent == current: # reached root
break
current = parent
return None
def get_workspace() -> str:
"""Get workspace path from env, auto-detection, or default"""
# 1. Environment variable (highest priority)
if os.environ.get('WORKSPACE'):
return os.environ['WORKSPACE']
# 2. Auto-detect from this file's location
detected = find_workspace_root()
if detected:
return str(detected)
# 3. Default fallback
return '/opt/claude-workspace'
def get_dataspace() -> str:
"""Get dataspace path from env or derive from workspace"""
if os.environ.get('DATASPACE'):
return os.environ['DATASPACE']
# Derive: /opt/claude-workspace -> /opt/claude-dataspace
ws = get_workspace()
if 'workspace' in ws:
return ws.replace('workspace', 'dataspace')
return '/opt/claude-dataspace'
def _load_env_file(filepath: Path) -> Dict[str, str]:
"""Load .env file into dict"""
result = {}
if not filepath.exists():
return result
with open(filepath, 'r', encoding='utf-8') as f:
for line in f:
line = line.strip()
# Skip comments and empty lines
if not line or line.startswith('#'):
continue
# Parse KEY=VALUE
if '=' in line:
key, _, value = line.partition('=')
key = key.strip()
value = value.strip()
# Remove quotes if present
if value and value[0] in '"\'':
value = value[1:-1] if len(value) > 1 and value[-1] == value[0] else value[1:]
result[key] = value
return result
@dataclass
class Config:
"""Platform configuration with layered loading"""
project: Optional[str] = None
_data: Dict[str, str] = field(default_factory=dict, repr=False)
def __post_init__(self):
# Determine workspace root (auto-detection)
self._workspace = get_workspace()
self._dataspace = get_dataspace()
# Load configs in order (later overrides earlier)
self._load_configs()
def _load_configs(self):
"""Load all config files"""
workspace = Path(self._workspace)
# 1. Platform config
platform_env = workspace / 'config' / '.env.platform'
self._data.update(_load_env_file(platform_env))
# 2. Project config (if specified)
if self.project:
project_env = workspace / 'projects' / self.project / '.env'
self._data.update(_load_env_file(project_env))
# 3. Local overrides
local_env = workspace / '.env.local'
self._data.update(_load_env_file(local_env))
# Always set paths from environment or defaults
self._data['WORKSPACE'] = self._workspace
self._data['DATASPACE'] = self._dataspace
def __getattr__(self, name: str) -> str:
"""Get config value as attribute"""
if name.startswith('_'):
return object.__getattribute__(self, name)
value = self._data.get(name)
if value is None:
# Also check environment
value = os.environ.get(name)
if value is None:
raise AttributeError(f"Config key not found: {name}")
return value
def get(self, key: str, default: str = None) -> Optional[str]:
"""Get config value with default"""
return self._data.get(key, os.environ.get(key, default))
def get_int(self, key: str, default: int = 0) -> int:
"""Get config value as int"""
value = self.get(key)
return int(value) if value else default
def get_bool(self, key: str, default: bool = False) -> bool:
"""Get config value as bool"""
value = self.get(key, '').lower()
if value in ('true', '1', 'yes', 'on'):
return True
if value in ('false', '0', 'no', 'off'):
return False
return default
# ==========================================
# Convenience methods
# ==========================================
def workspace_path(self, *parts) -> Path:
"""Get path relative to WORKSPACE"""
return Path(self._workspace).joinpath(*parts)
def data_path(self, *parts) -> Path:
"""Get path relative to DATASPACE"""
return Path(self._dataspace).joinpath(*parts)
def project_path(self, *parts) -> Path:
"""Get path relative to project in WORKSPACE"""
if not self.project:
raise ValueError("Project not specified")
return self.workspace_path('projects', self.project, *parts)
def project_data_path(self, *parts) -> Path:
"""Get path relative to project in DATASPACE"""
if not self.project:
raise ValueError("Project not specified")
return self.data_path('projects', self.project, *parts)
def get_ozon_accounts(self) -> Dict[str, Dict[str, str]]:
"""Get all OZON accounts as dict"""
accounts = {}
for acc_id in ['O1', 'O2', 'O3']:
client_id = self.get(f'OZON_{acc_id}_CLIENT_ID')
api_key = self.get(f'OZON_{acc_id}_API_KEY')
name = self.get(f'OZON_{acc_id}_NAME', acc_id)
if client_id and api_key:
accounts[acc_id] = {
'client_id': client_id,
'api_key': api_key,
'name': name
}
return accounts
def get_nocodb_config(self) -> Dict[str, str]:
"""Get NocoDB connection config"""
return {
'base_url': self.get('NOCODB_URL', 'http://localhost:8080'),
'email': self.get('NOCODB_EMAIL', 'admin@0kt.ru'),
'password': self.get('NOCODB_PASSWORD', ''),
'base_id': self.get('NOCODB_BASE_ID', ''),
}
def get_postgres_config(self) -> Dict[str, Any]:
"""Get PostgreSQL connection config"""
return {
'host': self.get('POSTGRES_HOST', 'localhost'),
'port': self.get_int('POSTGRES_PORT', 5432),
'user': self.get('POSTGRES_USER', 'postgres'),
'password': self.get('POSTGRES_PASSWORD', ''),
'database': self.get('POSTGRES_DB', 'nocodb'),
}
def to_dict(self) -> Dict[str, str]:
"""Export all config as dict"""
return dict(self._data)
# Singleton for convenience
_default_config: Optional[Config] = None
def get_config(project: str = None) -> Config:
"""Get config instance (cached if no project specified)"""
global _default_config
if project:
return Config(project=project)
if _default_config is None:
_default_config = Config()
return _default_config
# Shortcut functions
def workspace_path(*parts) -> Path:
"""Get path relative to WORKSPACE"""
return get_config().workspace_path(*parts)
def data_path(*parts) -> Path:
"""Get path relative to DATASPACE"""
return get_config().data_path(*parts)