İzle
1
1
Çatalla
mt5-manager deposunu zaten çatalladınız
1
şundan çatallanmış antekov/mt5-manager
mt5-manager/terminal_manager.py
877 satır
50 KiloByte
Python
'''Создание экземпляров и настройка счёта без Python API MetaTrader5.'''
__version__ = '0.9.0'
import copy
import ctypes
import errno
import hashlib
import json
import os
import re
import shutil
import subprocess
import tempfile
import threading
import time
import uuid
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
import psutil
from pydantic import ValidationError
from config import ConfigStore, instance_directory, validate_folder
from deploy_service import (compile_service, service_build_paths,
services_ini_update, write_services_ini)
from mt5_control import MT5_Control
from project_updates import ProjectUpdates
PROJECT_DIR = Path(__file__).resolve().parent
SERVICE_REL = Path('MQL5/Services/Shared Projects/mt5-manager/ServiceStateWriter.ex5')
UPLOAD_NAMES = {'terminal': 'terminal64.exe', 'editor': 'MetaEditor64.exe'}
MAX_SERVERS_SIZE = 16 * 1024 * 1024
GLOBAL_ACTIONS = ('check_updates', 'update_project', 'deploy_service', 'move_settings')
def account_values(record: dict, *, complete: bool = False) -> tuple:
login = str(record.get('login') or '').strip()
server = str(record.get('server') or '').strip()
password = record.get('password') or ''
if not isinstance(password, str) or any(c in password + server for c in '\r\n\x00'):
raise ValueError('В реквизитах счёта недопустимы переводы строк.')
if not login and not server and not password:
return '', '', ''
if not login.isascii() or not login.isdecimal() or int(login) <= 0 or not server:
raise ValueError('Укажите положительный номер счёта и имя сервера либо очистите реквизиты.')
if complete and not password:
raise ValueError('Для первого подключения укажите пароль счёта.')
return int(login), server, password
def graceful_close(process: psutil.Process, timeout: int = 30) -> None:
'''WM_CLOSE даёт терминалу возможность сохранить параметры счёта.'''
if os.name != 'nt':
raise RuntimeError('Штатное закрытие MetaTrader реализовано для Windows.')
from ctypes import wintypes
user32 = ctypes.WinDLL('user32', use_last_error=True)
callback_type = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)
user32.EnumWindows.argtypes = [callback_type, wintypes.LPARAM]
user32.GetWindowThreadProcessId.argtypes = [wintypes.HWND, ctypes.POINTER(wintypes.DWORD)]
user32.PostMessageW.argtypes = [wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM]
user32.GetWindow.argtypes = [wintypes.HWND, wintypes.UINT]
user32.GetWindow.restype = wintypes.HWND
user32.IsWindowVisible.argtypes = [wintypes.HWND]
user32.GetWindowRect.argtypes = [wintypes.HWND, ctypes.POINTER(wintypes.RECT)]
candidates = []
@callback_type
def close_window(hwnd, _):
pid = wintypes.DWORD()
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
if (pid.value == process.pid and not user32.GetWindow(hwnd, 4)
and user32.IsWindowVisible(hwnd)):
rect = wintypes.RECT()
user32.GetWindowRect(hwnd, ctypes.byref(rect))
candidates.append(((rect.right - rect.left) * (rect.bottom - rect.top), hwnd))
return True
user32.EnumWindows(close_window, 0)
if not candidates:
raise RuntimeError('Не найдено окно для штатного закрытия терминала.')
# Только главное видимое окно, а не скрытые служебные окна процесса.
hwnd = max(candidates, key=lambda item: item[0])[1]
if not user32.PostMessageW(hwnd, 0x0010, 0, 0): # WM_CLOSE
raise RuntimeError('Не удалось запросить штатное закрытие терминала.')
try:
process.wait(timeout)
except psutil.TimeoutExpired:
raise RuntimeError('Терминал не закрылся штатно. Проверьте его диалоговые окна.') from None
class TerminalManager:
'''Один рабочий поток последовательно выполняет длительные операции.
Короткие запросы состояния продолжают обслуживаться во время ожидания.
Пароли не копируются в задачи, карточки и ответы настроек.
'''
def __init__(self, store: ConfigStore):
self.store = store
self.lock = threading.RLock()
self.pool = ThreadPoolExecutor(max_workers=1, thread_name_prefix='terminals')
self.jobs = {}
self.closing = threading.Event()
self.verified_accounts = {}
self.updates = ProjectUpdates(PROJECT_DIR)
self.control = MT5_Control(store.data['terminals'], store.data['mt5_folder'],
store.data['database'], store.data['mt5_exe'])
def close(self):
self.closing.set()
self.pool.shutdown(wait=True, cancel_futures=True)
def reload_control(self):
data = self.store.data
self.control.configure(data['terminals'], data['mt5_folder'], data['database'])
def busy(self):
return any(job['status'] in ('queued', 'running') for job in self.jobs.values())
def snapshot(self):
with self.lock:
self.control.load_instances()
return copy.deepcopy(self.control.instances)
def info(self, folder):
with self.lock:
self.control.find_instances()
return copy.deepcopy(self.control.instance_info(folder))
def suggest_name(self):
with self.lock:
root = Path(self.store.data['mt5_folder'])
names = {name.casefold() for name in self.store.data['terminals']}
if root.exists():
names.update(child.name.casefold() for child in root.iterdir())
index = 1
while f'MetaTrader5.{index}'.casefold() in names:
index += 1
return f'MetaTrader5.{index}'
def template_source(self, kind):
return Path(self.store.data['terminal_template']) / UPLOAD_NAMES[kind]
def service_source(self):
return service_build_paths(PROJECT_DIR, self.store.data)[2]
def prepare_service(self):
if not compile_service(*service_build_paths(PROJECT_DIR, self.store.data)):
raise RuntimeError('Не удалось скомпилировать ServiceStateWriter.mq5. '
'Проверьте MetaEditor64.exe в папке исходной установки MT5 и журнал компиляции.')
return self.service_source()
def settings(self):
with self.lock:
data = copy.deepcopy(self.store.data)
for folder, record in data['terminals'].items():
record['password_set'] = bool(record.pop('password', ''))
# Вычисленный путь нужен только для отображения в интерфейсе.
record['path'] = str(instance_directory(data['mt5_folder'], folder))
record['running'] = bool(self.processes(folder))
files = {}
for kind, relative in UPLOAD_NAMES.items():
path = self.template_source(kind)
files[kind] = {'name': Path(relative).name, 'exists': path.is_file()}
if path.is_file():
stat = path.stat()
files[kind].update(size=stat.st_size, modified=int(stat.st_mtime))
return {'config': data, 'files': files, 'suggested_name': self.suggest_name(),
'base_folder_lock': self.base_folder_lock()}
def base_folder_lock(self):
if PROJECT_DIR.resolve().is_relative_to(Path(self.store.data['mt5_folder'])):
return ('Сейчас изменение базовой папки невозможно: менеджер запущен из папки внутри неё. '
'Для переноса терминалов разместите проект менеджера вне базовой папки и запустите его оттуда.')
return ''
def settings_candidate(self, values):
data = copy.deepcopy(self.store.data)
for key in ('mt5_folder', 'terminal_template', 'startup_timeout_sec', 'disk_warning_mb'):
if key in values:
data[key] = values[key]
return self.store._validate(data)
def settings_move_plan(self, values, *, during_job=False):
with self.lock:
if self.busy() and not during_job:
raise ValueError('Дождитесь завершения операций с терминалами.')
data = self.settings_candidate(values)
source = Path(self.store.data['mt5_folder'])
target = Path(data['mt5_folder'])
folders, missing, running = [], [], []
if source != target:
if self.base_folder_lock():
raise ValueError(self.base_folder_lock())
if target.exists() and not target.is_dir():
raise ValueError('По новому пути базовой папки находится файл.')
for folder in data['terminals']:
old = instance_directory(source, folder)
new = instance_directory(target, folder)
if old.is_symlink() or old.is_junction() or old.resolve() != old:
raise ValueError(f'{folder}: перенос ссылки вместо папки терминала не поддерживается.')
if not old.exists():
missing.append(folder)
continue
if not old.is_dir():
raise ValueError(f'{folder}: по пути экземпляра находится файл.')
if target.is_relative_to(old):
raise ValueError('Новая базовая папка не может находиться внутри перемещаемого экземпляра.')
if new.exists() or new.is_symlink() or new.is_junction():
raise ValueError(f'Папка назначения уже занята: {new}. Объединение папок не выполняется.')
for path in (PROJECT_DIR, self.store.path, Path(data['database'])):
if path.resolve().is_relative_to(old):
raise ValueError(f'{folder}: внутри находится проект, конфигурация или база менеджера. '
'Перед переносом разместите менеджер и его данные вне папок экземпляров.')
folders.append(folder)
if self.processes(folder):
running.append(folder)
identity = json.dumps([str(source), str(target), folders, missing])
return {'required': bool(folders), 'source': str(source), 'destination': str(target),
'folders': folders, 'missing': missing, 'running': running,
'token': hashlib.sha256(identity.encode()).hexdigest()}
def save_settings(self, values, *, move_token='', stop_running=False):
with self.lock:
plan = self.settings_move_plan(values)
if plan['required']:
if move_token != plan['token']:
raise ValueError('Подтвердите перенос папок терминалов при изменении базовой папки.')
if plan['running'] and not stop_running:
raise ValueError('Перед переносом нужно остановить все терминалы. Подтвердите их остановку.')
return self.submit('move_settings', '',
{'values': values, 'token': move_token, 'stop_running': stop_running})
data = self.settings_candidate(values)
old_root = self.store.data['mt5_folder']
self.store.save(data)
self.reload_control()
if data['mt5_folder'] != old_root:
self.verified_accounts.clear()
@staticmethod
def remove_move_temporary(path, root):
# Удаляем только собственную временную папку в проверенном родителе.
if (path.parent != root or path.resolve() != path or path.is_symlink() or path.is_junction()
or not path.name.startswith('.mt5-manager-move-')):
raise ValueError(f'Небезопасный путь временной папки переноса: {path}')
if path.exists():
shutil.rmtree(path)
def move_settings(self, record, job_id):
# Повторная проверка после очереди: пути могли измениться вне менеджера.
with self.lock:
plan = self.settings_move_plan(record['values'], during_job=True)
data = self.settings_candidate(record['values'])
source = Path(self.store.data['mt5_folder'])
target = Path(data['mt5_folder'])
folders, missing = plan['folders'], plan['missing']
if plan['token'] != record['token']:
raise ValueError('Список папок изменился. Повторите сохранение и подтверждение переноса.')
for folder in data['terminals']:
if self.processes(folder):
if not record['stop_running']:
raise ValueError('Терминал был запущен после подтверждения. Повторите сохранение настроек.')
self.stage(job_id, 'Штатная остановка: ' + folder)
self.stop(folder, graceful=True)
if any(self.processes(folder) for folder in data['terminals']):
raise RuntimeError('Не все терминалы остановлены. Базовая папка не изменена.')
moved = []
current = None
try:
if target.resolve() != target:
raise ValueError('Путь новой базовой папки изменился после подтверждения.')
target.mkdir(parents=True, exist_ok=True)
for folder in folders:
old, new = instance_directory(source, folder), instance_directory(target, folder)
# Проверяем абсолютные пути непосредственно перед перемещением.
if (old.resolve() != old or old.is_symlink() or old.is_junction()
or not old.is_dir() or new.exists() or new.is_symlink() or new.is_junction()
or target.is_relative_to(old) or self.processes(folder)):
raise ValueError(f'{folder}: папки или состояние терминала изменились. Перенос отменён.')
self.stage(job_id, 'Перенос: ' + folder)
try:
old.rename(new)
moved.append([old, new, None, True])
except OSError as error:
if error.errno != errno.EXDEV and getattr(error, 'winerror', None) != 17:
raise
# Между дисками сначала копируем; оригинал держим до сохранения JSON.
temporary = target / ('.mt5-manager-move-' + uuid.uuid4().hex)
current = temporary
shutil.copytree(old, temporary, symlinks=True)
backup = source / ('.mt5-manager-move-' + uuid.uuid4().hex)
old.rename(backup)
moved.append([old, new, backup, False])
temporary.rename(new)
moved[-1][3] = True
current = None
template = Path(data['terminal_template'])
if template.is_relative_to(old):
data['terminal_template'] = str(new / template.relative_to(old))
self.stage(job_id, 'Сохранение новой базовой папки')
with self.lock:
self.store.save(data)
except Exception as error:
failures = []
for old, new, backup, published in reversed(moved):
try:
if backup is None:
new.rename(old)
else:
backup.rename(old)
if published:
cleanup = target / ('.mt5-manager-move-' + uuid.uuid4().hex)
new.rename(cleanup)
self.remove_move_temporary(cleanup, target)
except (OSError, ValueError):
failures.append(str(old) + ' ↔ ' + str(backup or new))
if current:
try:
self.remove_move_temporary(current, target)
except (OSError, ValueError):
failures.append(str(current))
if failures:
raise RuntimeError('Перенос не завершён, конфигурация осталась прежней. '
'Проверьте оставшиеся папки: ' + '; '.join(failures)) from error
raise RuntimeError('Перенос отменён, папки возвращены на место, конфигурация не изменена. '
'Терминалы остаются остановленными. Причина: ' + str(error)) from error
with self.lock:
self.reload_control()
self.verified_accounts.clear()
self.jobs[job_id]['result'] = {'moved': folders, 'missing': missing, 'mt5_folder': str(target)}
for old, new, backup, published in moved:
if backup:
try:
self.remove_move_temporary(backup, source)
except (OSError, ValueError):
self.jobs[job_id]['warnings'].append('Перенос выполнен, но осталась резервная папка: ' + str(backup))
if missing:
self.jobs[job_id]['warnings'].append('Не найдены в исходной папке: ' + ', '.join(missing))
def upload_path(self, kind):
if kind not in UPLOAD_NAMES:
raise ValueError('Неизвестный тип файла.')
return Path(self.store.data['terminal_template']) / UPLOAD_NAMES[kind]
def install_upload(self, kind, source):
with self.lock:
if self.busy():
raise ValueError('Дождитесь завершения операций с терминалами.')
target = self.upload_path(kind)
if target.exists():
raise ValueError('Файл уже существует в исходной установке MT5. Загрузка нужна только для отсутствующих EXE.')
with open(source, 'rb') as file:
if kind in ('terminal', 'editor') and file.read(2) != b'MZ':
raise ValueError('Файл не похож на исполняемый файл Windows.')
target.parent.mkdir(parents=True, exist_ok=True)
temporary = target.with_name(target.name + '.' + uuid.uuid4().hex + '.tmp')
try:
shutil.copyfile(source, temporary)
os.replace(temporary, target)
finally:
temporary.unlink(missing_ok=True)
def submit(self, action, folder, record=None):
with self.lock:
if self.closing.is_set():
raise ValueError('Приложение завершает работу.')
project_actions = GLOBAL_ACTIONS
if action not in ('create', 'start', 'stop', 'account', 'test_connection', 'delete', *project_actions):
raise ValueError('Неизвестная операция.')
project_action = action in project_actions
if project_action:
if self.busy():
raise ValueError('Дождитесь завершения текущих операций.')
folder = ''
elif any(j['action'] in project_actions and j['status'] in ('queued', 'running')
for j in self.jobs.values()):
raise ValueError('Дождитесь завершения обновления проекта или сервиса.')
if action == 'create':
validate_folder(folder)
account_values(record or {}, complete=True)
servers = (record or {}).get('_servers_dat')
if servers is not None and (not isinstance(servers, bytes) or not 0 < len(servers) <= MAX_SERVERS_SIZE):
raise ValueError('Выберите непустой servers.dat размером не более 16 МиБ либо создайте экземпляр без файла.')
elif not project_action and folder not in self.store.data['terminals']:
raise ValueError('Экземпляр не найден.')
if any(j['folder'] == folder and j['status'] in ('queued', 'running')
for j in self.jobs.values()):
raise ValueError('Операция с этим экземпляром уже выполняется.')
if action == 'account':
self.account_record(folder, record or {})
self.validate_instance_edit(folder, record or {})
if action == 'test_connection':
self.connection_record(folder, record)
if action == 'delete':
self.deletion_directory(folder)
job_id = uuid.uuid4().hex
job = {'id': job_id, 'folder': folder, 'action': action,
'status': 'queued', 'stage': 'Ожидание', 'warnings': []}
self.jobs[job_id] = job
finished = [key for key, value in self.jobs.items()
if value['status'] in ('done', 'error')]
for key in finished[:-50]:
del self.jobs[key]
self.pool.submit(self._run, job_id, record)
return copy.deepcopy(job)
def job(self, job_id):
with self.lock:
return copy.deepcopy(self.jobs[job_id])
def stage(self, job_id, text):
with self.lock:
self.jobs[job_id]['stage'] = text
def _run(self, job_id, record):
job = self.jobs[job_id]
with self.lock:
job['status'] = 'running'
try:
if job['action'] == 'move_settings':
self.move_settings(record, job_id)
elif job['action'] in ('check_updates', 'update_project', 'deploy_service'):
action = job['action']
if action == 'check_updates':
self.stage(job_id, 'Проверка удалённого репозитория')
result = self.updates.check()
elif action == 'update_project':
self.stage(job_id, 'Загрузка обновления проекта')
result = self.updates.update()
if result['updated']:
job['warnings'].append('Код проекта обновлён. Перезапустите менеджер для загрузки новой версии Python.')
else:
self.stage(job_id, 'Компиляция и распространение сервиса')
result = self.updates.deploy(copy.deepcopy(self.store.data), self.processes)
if result['restart_required']:
job['warnings'].append('Для загрузки нового сервиса перезапустите: ' + ', '.join(result['restart_required']))
job['result'] = result
if result['failed']:
details = ' '.join(f'{folder}: {message}'
for folder, message in result.get('errors', {}).items())
raise RuntimeError('Не удалось завершить развёртывание для: '
+ ', '.join(result['failed'])
+ f'. Успешно обработано: {len(result["copied"])}. '
+ details)
job['result'] = result
elif job['action'] == 'create':
self.create_mt5(job['folder'], record or {}, job_id)
elif job['action'] == 'stop':
self.stage(job_id, 'Остановка')
self.stop(job['folder'])
elif job['action'] == 'account':
folder = self.edit_instance(job['folder'], record or {}, job_id)
job['result'] = {'folder': folder}
elif job['action'] == 'test_connection':
candidate = self.connection_record(job['folder'], record)
self.test_connection(job['folder'], job_id, candidate)
job['result'] = {'connected': True}
elif job['action'] == 'delete':
self.delete_instance(job['folder'], job_id)
else:
self.start(job['folder'], job_id)
with self.lock:
job.update(status='done', stage='Готово')
except Exception as error:
# Известные сообщения не содержат реквизитов. Не отдаём traceback
# или представление исходного запроса в браузер.
message = str(error) if isinstance(error, (ValueError, RuntimeError, OSError)) and not isinstance(error, ValidationError) else 'Внутренняя ошибка операции.'
with self.lock:
job.update(status='error', stage='Ошибка', error=message)
def deletion_directory(self, folder):
directory = instance_directory(Path(self.store.data['mt5_folder']).resolve(), folder)
if self.processes(folder):
raise ValueError('Терминал запущен. Остановите его перед удалением экземпляра.')
if directory.is_symlink() or directory.is_junction() or directory.resolve() != directory:
raise ValueError('Удаление ссылки или папки за пределами базовой папки запрещено.')
if directory.exists() and not directory.is_dir():
raise ValueError('По пути экземпляра находится файл вместо папки.')
protected = ((PROJECT_DIR, 'В этой папке находится работающий проект менеджера.'),
(self.store.path, 'В этой папке находится конфигурация менеджера.'),
(Path(self.store.data['database']), 'В этой папке находится общая база менеджера.'),
(Path(self.store.data['terminal_template']),
'Эта папка используется как исходная установка MT5. Сначала выберите другую установку в настройках.'))
for path, message in protected:
if path.resolve().is_relative_to(directory):
raise ValueError('Нельзя удалить экземпляр. ' + message)
return directory
def delete_instance(self, folder, job_id):
self.stage(job_id, 'Подготовка удаления экземпляра')
with self.lock:
directory = self.deletion_directory(folder)
root = directory.parent
pending = root / ('.mt5-manager-delete-' + uuid.uuid4().hex)
moved = False
try:
if directory.exists():
directory.rename(pending)
moved = True
data = copy.deepcopy(self.store.data)
del data['terminals'][folder]
self.store.save(data)
except Exception as error:
if moved:
try:
pending.rename(directory)
except OSError:
raise RuntimeError('Запись экземпляра не удалена, но вернуть имя папки не удалось. '
f'Проверьте папку: {pending}.') from error
raise
self.verified_accounts.pop(folder, None)
self.reload_control()
self.jobs[job_id]['result'] = {'folder': folder, 'deleted': True}
if not moved:
self.jobs[job_id]['warnings'].append('Папка экземпляра отсутствовала; удалена запись из конфигурации.')
return
self.stage(job_id, 'Удаление файлов экземпляра')
try:
# Окончательная проверка непосредственно перед рекурсивным удалением.
if (pending.is_symlink() or pending.is_junction() or pending.resolve() != pending
or pending.parent != root or not pending.name.startswith('.mt5-manager-delete-')):
raise ValueError('Путь папки изменился; удаление файлов остановлено.')
shutil.rmtree(pending)
except (OSError, ValueError):
with self.lock:
self.jobs[job_id]['result']['remaining_path'] = str(pending)
self.jobs[job_id]['warnings'].append(
'Запись экземпляра удалена, но не все файлы удалось удалить. '
f'Оставшиеся файлы: {pending}.')
def account_record(self, folder, changes):
result = copy.deepcopy(self.store.data['terminals'][folder])
result['name'] = str(changes.get('name', result.get('name', folder)))
if not result['name'] or result['name'] == folder:
result['name'] = changes.get('folder') or folder
result['login'] = changes.get('login', '')
result['server'] = changes.get('server', '')
if changes.get('clear_account'):
result.update(login='', server='', password='')
elif changes.get('password'):
result['password'] = changes['password']
login, server, password = account_values(result)
result.update(login=login, server=server, password=password)
return result
def validate_instance_edit(self, folder, changes):
name = changes.get('folder')
name = folder if name is None else validate_folder(name)
servers = changes.get('_servers_dat')
if servers is not None and (not isinstance(servers, bytes) or not 0 < len(servers) <= MAX_SERVERS_SIZE):
raise ValueError('Выберите непустой servers.dat размером не более 16 МиБ.')
if servers is not None and self.processes(folder):
raise ValueError('Закройте терминал перед заменой servers.dat. Сохранение настроек не останавливает терминал.')
if name != folder:
if self.processes(folder):
raise ValueError('Терминал запущен, переименование папки пока недоступно. Закройте терминал, чтобы переименовать папку.')
data = self.store.data
if any(key != folder and key.casefold() == name.casefold() for key in data['terminals']):
raise ValueError('Экземпляр с таким именем уже существует.')
root = Path(data['mt5_folder']).resolve()
source, target = root / folder, root / name
if not source.is_dir() or source.resolve() != source:
raise ValueError('Не найдена обычная папка экземпляра внутри базовой папки.')
if target.exists() and not target.samefile(source):
raise ValueError('Папка с таким именем уже существует.')
return name
def edit_instance(self, folder, changes, job_id):
# Сохранение настроек не запускает, не останавливает и не авторизует MT5.
servers = changes.get('_servers_dat')
with self.lock:
name = self.validate_instance_edit(folder, changes)
data = copy.deepcopy(self.store.data)
updated = self.account_record(folder, changes)
source = instance_directory(data['mt5_folder'], folder)
target = instance_directory(data['mt5_folder'], name)
renamed = False
replaced = False
previous = None
temporary = None
server_path = target / 'config/servers.dat'
try:
if name != folder:
self.stage(job_id, 'Переименование папки экземпляра')
source.rename(target)
renamed = True
template = Path(data['terminal_template'])
if template.is_relative_to(source):
data['terminal_template'] = str(target / template.relative_to(source))
if servers is not None:
self.stage(job_id, 'Обновление servers.dat')
server_path.parent.mkdir(parents=True, exist_ok=True)
previous = server_path.read_bytes() if server_path.exists() else None
temporary = server_path.with_name('servers.' + uuid.uuid4().hex + '.tmp')
temporary.write_bytes(servers)
os.replace(temporary, server_path)
replaced = True
data['terminals'] = {name if key == folder else key: updated if key == folder else value
for key, value in data['terminals'].items()}
self.store.save(data)
except Exception:
if replaced:
if previous is None:
server_path.unlink()
else:
temporary.write_bytes(previous)
os.replace(temporary, server_path)
if renamed:
target.rename(source)
raise
finally:
if temporary:
temporary.unlink(missing_ok=True)
if renamed or servers is not None:
self.verified_accounts.pop(folder, None)
self.reload_control()
return name
def connection_record(self, folder, changes):
if changes is None:
candidate = copy.deepcopy(self.store.data['terminals'][folder])
else:
if changes.get('folder') not in (None, folder):
raise ValueError('Сначала сохраните новое имя папки, затем проверьте подключение.')
candidate = self.account_record(folder, changes)
if not account_values(candidate)[0]:
raise ValueError('Для проверки подключения укажите логин и торговый сервер.')
return candidate
def processes(self, folder):
expected = self.control.normalized_path(self.control.instance_path(folder))
result = []
for process in psutil.process_iter(['exe']):
try:
if process.info['exe'] and self.control.normalized_path(process.info['exe']) == expected:
result.append(process)
except (psutil.NoSuchProcess, psutil.AccessDenied):
continue
return result
def stop(self, folder, *, graceful=False):
for process in self.processes(folder):
try:
if graceful:
graceful_close(process)
else:
process.terminate()
process.wait(10)
except psutil.NoSuchProcess:
pass
def launch(self, folder, config=None):
args = [self.control.instance_path(folder), '/portable']
if config:
# MT5 разбирает ключ сам: при пробелах кавычки должны окружать
# значение после двоеточия, а не весь аргумент /config:путь.
command = subprocess.list2cmdline(args) + f' /config:"{config}"'
return subprocess.Popen(command, cwd=str(Path(args[0]).parent))
return subprocess.Popen(args, cwd=str(Path(args[0]).parent))
@staticmethod
def prepare_account_defaults(directory, account):
'''Сохраняет выбор счёта и KeepPrivate в основной конфигурации.
Параметры /config не переносят выбор счёта в common.ini, поэтому
отдельно задаём логин и сервер для последующего обычного запуска.
Пароль передаётся только временным файлом.
Вызывается только при остановленном терминале.
'''
path = directory / 'config/common.ini'
path.parent.mkdir(parents=True, exist_ok=True)
text = path.read_text(encoding='utf-16') if path.exists() else ''
section = re.search(r'(?ims)^\[Common\][^\r\n]*\r?\n(.*?)(?=^\[|\Z)', text)
body = section.group(1) if section else ''
body = re.sub(r'(?im)^[ \t]*Password[ \t]*=.*\n?', '', body)
for key, value in {'Login': account[0], 'Server': account[1], 'KeepPrivate': 1}.items():
pattern = rf'(?im)^[ \t]*{key}[ \t]*=.*$'
line = f'{key}={value}'
if re.search(pattern, body):
body = re.sub(pattern, lambda _: line, body)
else:
body = line + '\n' + body
if section:
text = text[:section.start(1)] + body + text[section.end(1):]
else:
text = '[Common]\n' + body + text
temporary = path.with_name('common.' + uuid.uuid4().hex + '.tmp')
try:
temporary.write_text(text, encoding='utf-16')
os.replace(temporary, path)
finally:
temporary.unlink(missing_ok=True)
def wait_state(self, folder, since, account=None):
deadline = time.monotonic() + self.store.data['startup_timeout_sec']
directory = self.control.normalized_path(str(Path(self.control.instance_path(folder)).parent))
while time.monotonic() < deadline:
if self.closing.is_set():
raise RuntimeError('Операция прервана завершением приложения.')
row = self.control.read_state(folder)
fresh = (row is not None and row['updated_at'] is not None
and row['updated_at'] >= since
and time.time() - row['updated_at'] < self.control.FRESHNESS_THRESHOLD_SEC
and self.control.normalized_path(row['terminal_path']) == directory)
if fresh and self.processes(folder):
if account is None or (row['connected'] and row['login'] == account[0]
and row['server'] == account[1]):
return row
# При LiveUpdate процесс может смениться; не считаем этот разрыв
# немедленной ошибкой и ждём до общего срока.
self.closing.wait(1)
if account:
raise RuntimeError('Не подтверждено подключение к заданному счёту. Проверьте реквизиты и наличие сервера в servers.dat.')
raise RuntimeError('Нет свежих данных сервиса. Проверьте запуск терминала, журнал сервиса '
f'и совпадение его базы с database={self.store.data["database"]}.')
def test_connection(self, folder, job_id, record):
was_running = bool(self.processes(folder))
try:
self.start(folder, job_id, record)
finally:
self.stage(job_id, 'Восстановление состояния терминала')
try:
if was_running:
if not self.processes(folder):
self.launch(folder)
elif self.processes(folder):
try:
self.stop(folder, graceful=True)
except Exception:
# При неудачном штатном закрытии всё равно завершаем
# процесс, который был запущен только для проверки.
self.stop(folder)
if bool(self.processes(folder)) != was_running:
raise RuntimeError('Состояние процесса не восстановлено.')
except Exception:
state = 'запущенное' if was_running else 'остановленное'
raise RuntimeError(f'После проверки не удалось вернуть терминал в {state} состояние. '
'Проверьте его состояние вручную.') from None
def start(self, folder, job_id, record=None):
if record is None:
record = self.store.data['terminals'][folder]
account = account_values(record)
desired = account if account[0] else None
complete = bool(desired and desired[2])
# После перезапуска менеджера один раз подтверждаем полные реквизиты
# заново: пароль невозможно проверить по login/server в states.
needs_auth = complete and self.verified_accounts.get(folder) != account
if not needs_auth:
self.stage(job_id, 'Запуск и проверка состояния')
since = time.time()
if not self.processes(folder):
self.launch(folder)
row = self.wait_state(folder, since)
if desired is None or (row['connected'] and row['login'] == desired[0]
and row['server'] == desired[1]):
return
if not complete:
raise RuntimeError('Терминал не подключился к заданному счёту. Укажите пароль в настройках экземпляра.')
self.stage(job_id, 'Настройка подключения к счёту')
if self.processes(folder):
self.stop(folder, graceful=True)
directory = Path(self.control.instance_path(folder)).parent
self.prepare_account_defaults(directory, account)
temporary = None
try:
with tempfile.NamedTemporaryFile(mode='w', encoding='utf-16',
dir=directory, prefix='manager-login-',
suffix='.ini', delete=False) as out:
temporary = Path(out.name)
out.write(f'[Common]\nLogin={account[0]}\nServer={account[1]}\n'
f'Password={account[2]}\nKeepPrivate=1\n')
since = time.time()
self.launch(folder, temporary)
self.wait_state(folder, since, account)
self.stage(job_id, 'Сохранение параметров счёта')
self.stop(folder, graceful=True)
except Exception:
self.stop(folder)
raise
finally:
if temporary:
temporary.unlink(missing_ok=True)
self.stage(job_id, 'Повторный запуск без файла авторизации')
since = time.time()
self.launch(folder)
self.wait_state(folder, since, account)
self.verified_accounts[folder] = account
def create_mt5(self, folder, record, job_id):
self.stage(job_id, 'Проверка и копирование файлов')
with self.lock:
data = self.store.data
root = Path(data['mt5_folder']).resolve()
root.mkdir(parents=True, exist_ok=True)
target = instance_directory(root, folder)
occupied = {name.casefold() for name in data['terminals']}
occupied.update(child.name.casefold() for child in root.iterdir())
if folder.casefold() in occupied:
raise ValueError('Экземпляр или папка с таким именем уже существует.')
sources = {UPLOAD_NAMES[kind]: self.template_source(kind)
for kind in ('terminal', 'editor')}
for source in sources.values():
if not source.is_file() or not source.stat().st_size:
raise ValueError(f'Не найден исходный файл: {source.name}. Загрузите его в настройках.')
# MetaEditor может работать долго: не блокируем запросы состояния.
self.stage(job_id, 'Подготовка сервиса')
sources[str(SERVICE_REL)] = self.prepare_service()
for source in sources.values():
if not source.is_file() or not source.stat().st_size:
raise ValueError(f'Не найден исходный файл: {source.name}. Загрузите его в настройках.')
servers = record.get('_servers_dat')
required = sum(path.stat().st_size for path in sources.values()) + (len(servers) if servers is not None else 0)
free = shutil.disk_usage(root).free
if free < required:
raise ValueError('На диске недостаточно места даже для копирования исходных файлов.')
if free - required < data['disk_warning_mb'] * 1024 * 1024:
with self.lock:
self.jobs[job_id]['warnings'].append('После копирования останется меньше заданного запаса места. История и журналы будут занимать дополнительное место.')
self.stage(job_id, 'Копирование файлов')
# Только mkdir без exist_ok: чужую папку нельзя принять за свою
# и затем удалить при откате.
target.mkdir()
registered = False
try:
for relative, source in sources.items():
destination = target / relative
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(source, destination)
(target / 'config').mkdir(parents=True, exist_ok=True)
if servers is not None:
(target / 'config/servers.dat').write_bytes(servers)
if data['mt5_exe'] != 'terminal64.exe':
raise ValueError('Создание экземпляров поддерживает имя terminal64.exe.')
ini_path, ini_text, _ = services_ini_update(target, PROJECT_DIR / 'static/services.ini', data['database'])
write_services_ini(ini_path, ini_text)
login, server, password = account_values(record, complete=True)
with self.lock:
updated = copy.deepcopy(self.store.data)
updated['terminals'][folder] = {'name': record.get('name') or folder,
'login': login,
'server': server, 'password': password}
self.store.save(updated)
registered = True
self.reload_control()
self.start(folder, job_id)
except Exception as error:
self.stage(job_id, 'Откат создания')
try:
if registered:
self.stop(folder)
# Первый запуск может оставить отдельный процесс компилятора.
for process in psutil.process_iter(['exe']):
try:
executable = process.info['exe']
if executable and Path(executable).resolve().is_relative_to(target.resolve()):
process.terminate()
process.wait(10)
except (psutil.NoSuchProcess, psutil.AccessDenied):
continue
# Сверяем окончательный путь перед рекурсивным удалением.
if target.is_symlink() or target.resolve() != target or target.resolve().parent != root:
raise RuntimeError('Путь папки изменился; автоматическое удаление остановлено.')
shutil.rmtree(target)
if registered:
with self.lock:
updated = copy.deepcopy(self.store.data)
updated['terminals'].pop(folder, None)
self.store.save(updated)
self.reload_control()
self.verified_accounts.pop(folder, None)
except Exception:
raise RuntimeError('Создание не завершено, автоматический откат не удался. Проверьте папку экземпляра и конфигурацию вручную.') from error
raise