'''Сборка архива исходников пятой части и согласованного дерева в статье. Запуск: python docs/articles/part-05/publish/build_archive.py Список файлов явный: рабочая конфигурация, EX5, EXE и каталоги пользователя никогда не добавляются. config.json создаётся из config.example.json. ''' import hashlib import json import re import subprocess from pathlib import Path, PurePosixPath from zipfile import ZIP_DEFLATED, ZipFile ROOT = Path(__file__).resolve().parents[4] OUT = Path(__file__).resolve().parent ARTICLE = OUT.parent / 'draft.md' PREFIX = 'MQL5/Shared Projects/mt5-manager/' SERIES = 'https://www.mql5.com/ru/blogs/post/765539' # Таблица строится из этого списка, но показывает только основные рабочие файлы. FILES = [ ('README.md', 'Быстрый старт и ссылки на документацию', 5), ('LICENSE', 'Лицензия проекта', 1), ('config.example.json', 'Пример конфигурации без счетов', 5), ('config.json', 'Начальная конфигурация, созданная из примера', 5), ('config.py', 'Чтение, проверка и сохранение конфигурации', 5), ('terminal_manager.py', 'Создание экземпляров, фоновые задачи и настройка счёта', 5), ('project_updates.py', 'Обновление Git, сборка и распространение сервиса', 5), ('main.py', 'Маршруты настроек, файлов и операций с терминалами', 5), ('mt5_control.py', 'Процессы и чтение состояния без Python API MetaTrader5', 5), ('requirements.txt', 'Зависимости приложения', 5), ('deploy_service.py', 'Развёртывание и компиляция сервиса', 5), ('run.py', 'Запуск веб-приложения', 5), ('ServiceStateWriter.mq5', 'Сервис записи состояния в SQLite', 5), ('templates/base.html', 'Общая навигация и оформление страниц', 5), ('templates/index.html', 'Главная страница и создание экземпляра', 5), ('templates/settings.html', 'Страница настроек и загрузки файлов', 5), ('static/script.js', 'Карточки, поиск, фильтры и управление операциями', 5), ('static/manager.js', 'Запросы API, сообщения и диалоговые окна', 5), ('static/settings.js', 'Формы настроек, файлов и счетов', 5), ('static/styles.css', 'Общие стили и адаптивная компоновка', 5), ('static/services.ini', 'Образец автозапуска сервиса', 5), ('static/img/MetaQuotes-Demo.ico', 'Значок торгового сервера', 3), ('docs/README.md', 'Оглавление документации и текущее состояние', 5), ('docs/installation.md', 'Установка и первый запуск', 5), ('docs/configuration.md', 'Настройки и формат конфигурации', 5), ('docs/user-guide.md', 'Работа через веб-интерфейс', 5), ('docs/maintenance.md', 'Обновление и устранение неполадок', 5), ('docs/architecture.md', 'Архитектура, API и проверки', 5), ] ARCHIVE_NOTE = '''# Установка из архива пятой части Распакуйте MQL5.zip в папку данных установленного терминала. Проект окажется в MQL5/Shared Projects/mt5-manager. В portable-режиме папка данных совпадает с папкой установки. Откройте PowerShell в папке проекта и следуйте быстрому старту ниже, начиная с создания Python-окружения: клонировать ещё одну копию репозитория поверх распакованного проекта не нужно. config.json и config.example.json содержат одинаковый пример без счетов. Не используйте буквальный USERNAME в пути базы: команды быстрого старта подставляют путь текущего пользователя и создают личный config.local.json. EXE, EX5 и servers.dat не распространяются в архиве. Укажите исходную установку MT5 в настройках: из неё менеджер возьмёт оба EXE, а сервис соберёт из MQ5 проекта. Если EXE отсутствуют, загрузите их в выбранную папку через веб-интерфейс. servers.dat можно передать отдельно при создании экземпляра, если он необходим. В архиве нет каталога .git. Проверка и загрузка обновлений Git требуют клонирования репозитория с настроенным upstream; сборка и копирование сервиса доступны и без Git. Материалы статей остаются в репозитории и на MQL5, в архив включены руководства по текущему приложению. Каталоги tests и tools в архив не включены; проверки и вспомогательные инструменты доступны в репозитории проекта. --- ''' def file_version(path, data): if Path(path).suffix not in ('.py', '.mq5', '.js', '.css', '.html'): return '—' text = data.decode('utf-8-sig') for pattern in (r'__version__\s*=\s*[\'\"]([^\'\"]+)', r'#property\s+version\s+"([^"]+)"', r'@version\s+(\d[\d.]*)', r'version\s+(\d[\d.]*)'): match = re.search(pattern, text) if match: return match.group(1) return '—' def archive_styles(data): # Убираем из публикуемой копии правила для иконок, не входящих в архив. included = {name for name, _, _ in FILES} text = data.decode('utf-8-sig') def keep_rule(match): icons = re.findall(r'''url\(['"]?/static/img/([^)'"\s]+)['"]?\)''', match.group()) return match.group() if all('static/img/' + icon in included for icon in icons) else '' return re.sub(r'(?m)^\.server-[^\n{}]*\{[^\n{}]*\}\r?\n?', keep_rule, text).encode('utf-8') def build(): # Принципиально не читаем личный config.json из рабочего дерева. example = (ROOT / 'config.example.json').read_bytes() config = json.loads(example.decode('utf-8-sig')) if config.get('terminals') != {}: raise RuntimeError('Пример для публикации должен содержать пустой список счетов.') files, manifest = {}, [] for name, description, part in FILES: if Path(name).suffix.lower() in ('.ex5', '.exe', '.dll', '.dat'): raise RuntimeError('В манифесте обнаружен непубликуемый файл.') data = example if name == 'config.json' else (ROOT / name).read_bytes() version = file_version(name, data) if name == 'static/styles.css': data = archive_styles(data) if name == 'docs/architecture.md': text = data.decode('utf-8-sig').replace( '## Проверки разработки\n', '## Проверки разработки\n\nКаталоги `tests` и `tools` доступны ' 'в [репозитории проекта](https://forge.mql5.io/antekov/mt5-manager), ' 'но не входят в архив к статье. Следующие команды предназначены ' 'для рабочей копии репозитория.\n') data = text.encode('utf-8') if name in ('README.md', 'docs/README.md'): text = data.decode('utf-8-sig') # В ZIP нет каталога рукописей; ссылки ведут к циклу на сайте. text = text.replace('docs/articles/README.md', SERIES).replace('articles/README.md', SERIES) if name == 'README.md': text = ARCHIVE_NOTE + text data = text.encode('utf-8') files[name] = data manifest.append({'path': name, 'archive_path': PREFIX + name, 'version': version, 'description': description, 'part': part, 'sha256': hashlib.sha256(data).hexdigest()}) OUT.mkdir(parents=True, exist_ok=True) archive_path = OUT / 'MQL5.zip' with ZipFile(archive_path, 'w', compression=ZIP_DEFLATED) as archive: for name, data in files.items(): archive.writestr(PREFIX + name, data) with ZipFile(archive_path) as archive: if archive.testzip() is not None: raise RuntimeError('Архив не прошёл проверку целостности.') if set(archive.namelist()) != {PREFIX + name for name in files}: raise RuntimeError('Содержимое архива отличается от манифеста.') revision = subprocess.run(['git', 'rev-parse', 'HEAD'], cwd=ROOT, capture_output=True, text=True, check=True).stdout.strip() (OUT / 'archive-manifest.json').write_text(json.dumps( {'base_revision': revision, 'files': manifest}, ensure_ascii=False, indent=2) + '\n', encoding='utf-8', newline='\n') # Основные файлы в таблице получают версии из манифеста архива. tree = {} entries = {item['path']: item for item in manifest} for name in files: if name in ('README.md', 'LICENSE', 'config.example.json') or name.startswith('static/img/'): continue branch = tree parts = PurePosixPath(name).parts for component in parts[:-1]: branch = branch.setdefault(component, {}) branch[parts[-1]] = None tree['static'] = {'…': None, **tree['static']} rows = ['| # | Имя | Версия | Описание | Последние изменения |', '|---|---|---|---|---|', '| | **MQL5/Shared Projects/mt5-manager/** | | Рабочая папка проекта | |'] number = 0 def render(branch, parents=(), indent=''): nonlocal number for index, (name, children) in enumerate(branch.items()): last = index == len(branch) - 1 label = indent + ('└─ ' if last else '├─ ') + name label = label.replace(' ', ' ') if not parents and name == 'docs': rows.append(f'| | **{label}/** | — | Документация по установке, настройке и работе приложения | Часть 5 |') elif parents == ('static',) and name == '…': rows.append(f'| | {label} | — | Дополнительные статические ресурсы | |') elif children is not None: rows.append(f'| | **{label}/** | | | |') render(children, (*parents, name), indent + (' ' if last else '│ ')) else: number += 1 item = entries['/'.join((*parents, name))] part = item['part'] origin = 'Часть 5' if part == 5 else f'[Часть {part}](https://www.mql5.com/ru/articles/{20150 if part == 4 else 19946 if part == 3 else 19804})' rows.append(f'| {number} | {label} | {item["version"]} | {item["description"]} | {origin} |') render(tree) article = ARTICLE.read_text(encoding='utf-8') before = article.split('## Содержание архива', 1)[0] note = ('## Содержание архива\n\nАрхив `MQL5.zip` распаковывается в папку данных терминала: ' 'все файлы проекта находятся внутри `MQL5/Shared Projects/mt5-manager`. ' 'Скомпилированные EX5, исполняемые файлы терминала и личные настройки ' 'в него не входят. Файл `config.json` в архиве создан из безопасного примера ' '`config.example.json` с пустым списком счетов.\n\n') tail = '\n\nИсходный код проекта доступен в репозитории [mt5-manager](https://forge.mql5.io/antekov/mt5-manager).\n' ARTICLE.write_text(before + note + '\n'.join(rows) + tail, encoding='utf-8', newline='\n') print(f'MQL5.zip: {len(files)} files; article table: {number} main files and documentation directory.') if __name__ == '__main__': build()