geforkt von antekov/mt5-manager
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()
|