2025-11-20 14:02:48 +03:00 | | | '''
|
| | | File: config.py
|
| | | Description: Класс работы с конфигурацией приложения
|
| | | '''
|
| | |
|
2026-10-31 07:26:44 +03:00 | | | __version__ = '0.4.0'
|
2025-11-20 14:02:48 +03:00 | | |
|
2025-11-11 12:15:50 +03:00 | | | from pydantic_settings import BaseSettings, JsonConfigSettingsSource, SettingsConfigDict
|
2026-10-29 14:39:39 +03:00 | | | from pydantic import BaseModel
|
2025-11-11 12:15:50 +03:00 | | | import os
|
2026-10-29 14:39:39 +03:00 | | | import json
|
2026-10-31 07:26:44 +03:00 | | | import re
|
2026-10-29 14:39:39 +03:00 | | | import tempfile
|
| | | from pathlib import Path
|
| | | from threading import RLock
|
2025-11-11 12:15:50 +03:00 | | |
|
| | |
|
2026-10-31 07:26:44 +03:00 | | | def validate_folder(folder: str) -> str:
|
| | | '''Имя экземпляра — один компонент пути Windows внутри базовой папки.'''
|
| | | if (not isinstance(folder, str) or not folder or len(folder) > 80
|
| | | or folder != folder.strip() or folder.endswith('.')
|
| | | or re.search(r'[<>:"/\\|?*\x00-\x1f]', folder)
|
| | | or folder in ('.', '..')
|
| | | or re.fullmatch(r'(CON|PRN|AUX|NUL|COM[0-9]|LPT[0-9])(?:\..*)?', folder, re.I)):
|
| | | raise ValueError('Укажите допустимое имя папки Windows длиной до 80 символов.')
|
| | | return folder
|
| | |
|
| | |
|
| | | def instance_directory(mt5_folder: str | Path, folder: str) -> Path:
|
| | | return Path(mt5_folder) / validate_folder(folder)
|
| | |
|
| | |
|
2026-10-29 14:39:39 +03:00 | | | class ConfigData(BaseModel):
|
2025-11-20 14:02:48 +03:00 | | | # Словарь с информацией о терминалах
|
2025-11-11 12:15:50 +03:00 | | | terminals: dict
|
2025-11-20 14:02:48 +03:00 | | |
|
| | | # Путь к папке с экземплярами терминалов
|
2025-11-11 12:15:50 +03:00 | | | mt5_folder: str
|
2025-11-20 14:02:48 +03:00 | | |
|
| | | # Имя исполняемого файла терминала
|
2025-11-11 12:15:50 +03:00 | | | mt5_exe: str
|
| | |
|
2026-07-25 20:52:51 +03:00 | | | # Путь к общей базе SQLite (папка Common/Files, зависит от профиля Windows)
|
| | | database: str
|
| | |
|
2026-10-29 14:39:39 +03:00 | | | # Относительные пути считаются от папки config.json.
|
| | | terminal_template: str = 'terminal-template'
|
2026-10-31 06:48:02 +03:00 | | | mt5_installation: str = ''
|
2026-10-29 14:39:39 +03:00 | | | service_ex5: str = ''
|
| | | startup_timeout_sec: int = 180
|
| | | disk_warning_mb: int = 1024
|
| | |
|
2025-11-11 12:15:50 +03:00 | | | model_config = SettingsConfigDict()
|
| | |
|
2026-10-29 14:39:39 +03:00 | | |
|
| | | class Config(ConfigData, BaseSettings):
|
2025-11-11 12:15:50 +03:00 | | | @classmethod
|
| | | def settings_customise_sources(
|
| | | cls,
|
| | | settings_cls,
|
| | | init_settings,
|
| | | env_settings,
|
| | | dotenv_settings,
|
| | | file_secret_settings,
|
| | | ):
|
| | | # Получаем путь к файлу из переменной окружения
|
| | | config_file = os.getenv("MT5_MANAGER_CONFIG_FILE", "config.json")
|
| | | return (
|
| | | init_settings,
|
| | | JsonConfigSettingsSource(settings_cls, json_file=config_file),
|
| | | env_settings,
|
| | | dotenv_settings,
|
| | | file_secret_settings,
|
| | | )
|
2026-10-29 14:39:39 +03:00 | | |
|
| | |
|
| | | class ConfigStore:
|
| | | '''Читает настройки и атомарно заменяет JSON при сохранении.'''
|
| | |
|
| | | def __init__(self, path: str | Path | None = None):
|
| | | self.path = Path(path or os.getenv('MT5_MANAGER_CONFIG_FILE', 'config.json')).resolve()
|
| | | self.lock = RLock()
|
| | | self.data = self._validate(json.loads(self.path.read_text(encoding='utf-8-sig')))
|
| | |
|
| | | def resolve(self, value: str) -> Path:
|
| | | path = Path(value)
|
| | | return (self.path.parent / path).resolve() if not path.is_absolute() else path.resolve()
|
| | |
|
| | | def _validate(self, data: dict) -> dict:
|
| | | # Обычная модель проверяет именно этот документ. BaseSettings здесь
|
| | | # мог бы дополнить пустой terminals настройками из другого JSON.
|
| | | config = ConfigData.model_validate(data)
|
| | | if not 10 <= config.startup_timeout_sec <= 900:
|
| | | raise ValueError('Время ожидания должно быть от 10 до 900 секунд.')
|
| | | if config.disk_warning_mb < 0:
|
| | | raise ValueError('Запас свободного места не может быть отрицательным.')
|
| | | result = config.model_dump()
|
| | | root = self.resolve(config.mt5_folder)
|
| | | result['mt5_folder'] = str(root)
|
| | | result['database'] = str(self.resolve(config.database))
|
| | | result['terminal_template'] = str(self.resolve(config.terminal_template))
|
2026-10-31 06:48:02 +03:00 | | | if config.mt5_installation:
|
| | | result['mt5_installation'] = str(self.resolve(config.mt5_installation))
|
2026-10-29 14:39:39 +03:00 | | | if config.service_ex5:
|
| | | result['service_ex5'] = str(self.resolve(config.service_ex5))
|
| | | seen = set()
|
| | | for folder, record in result['terminals'].items():
|
2026-10-31 07:26:44 +03:00 | | | validate_folder(folder)
|
2026-10-29 14:39:39 +03:00 | | | if not isinstance(record, dict):
|
| | | raise ValueError('Настройки экземпляра должны быть объектом JSON.')
|
| | | key = folder.casefold()
|
| | | if key in seen:
|
| | | raise ValueError('Имена экземпляров различаются только регистром.')
|
| | | seen.add(key)
|
| | | record.setdefault('name', folder)
|
| | | record.setdefault('login', '')
|
| | | record.setdefault('server', '')
|
| | | record.setdefault('password', '')
|
2026-10-31 07:26:44 +03:00 | | | # Старые конфиги читаются, но path больше не определяет расположение.
|
| | | # При следующем сохранении устаревшее поле исчезнет из JSON.
|
| | | record.pop('path', None)
|
2026-10-29 14:39:39 +03:00 | | | return result
|
| | |
|
| | | def save(self, data: dict) -> None:
|
| | | with self.lock:
|
| | | validated = self._validate(data)
|
| | | temporary = None
|
| | | try:
|
| | | with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8',
|
| | | dir=self.path.parent, delete=False,
|
| | | prefix=self.path.name + '.', suffix='.tmp') as out:
|
| | | temporary = Path(out.name)
|
| | | json.dump(validated, out, ensure_ascii=False, indent=2)
|
| | | out.write('\n')
|
| | | out.flush()
|
| | | os.fsync(out.fileno())
|
| | | os.replace(temporary, self.path)
|
| | | self.data = validated
|
| | | finally:
|
| | | if temporary:
|
| | | temporary.unlink(missing_ok=True)
|