Beobachten
1
1
Fork
Du hast bereits einen Fork von mt5-manager erstellt
1
mt5-manager/docs/articles/part-05/publish/build_archive.py
206 Zeilen
13 KiB
Python
'''Сборка архива исходников пятой части и согласованного дерева в статье.
Запуск: 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()