/
ump-team
/
ump-infra
Обзор
Документация
Войти
/
ump-team
/
ump-infra
Код
Запросы
1
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
dev/test
scripts/gen_progress.py
264 строки
10 KB
Dmitry Kochenov
Audit v0.0.2: security fixes
19 июл 2026, 19:19
19 июл 2026, 19:19
8a849eb
Код
Авторство
О чём код?
#!/usr/bin/env python3 """ Генератор прогресс-дашборда (универсальный, v0.0.1). Читает YAML-планы из plans/ (путь из ump-ui-config.yaml) и STEP_STATE.md, генерирует docs/progress.md с актуальной информацией о прогрессе. Запуск: python3 scripts/gen_progress.py python3 scripts/gen_progress.py --output docs/progress.md """ from __future__ import annotations import argparse import re import sys from datetime import datetime from pathlib import Path import yaml # ─── Определение путей ─────────────────────────────────────────────────────── SCRIPT_DIR = Path(__file__).resolve().parent PARENT = SCRIPT_DIR.parent # Если ump.py в .ump/scripts/ — BASE это .ump/, нужно подняться на уровень выше if PARENT.name == '.ump': BASE = PARENT.parent INFRA = PARENT else: BASE = PARENT INFRA = PARENT def _resolve_plans_dir() -> Path: """Определить путь к plans/ из ump-ui-config.yaml или fallback.""" config_path = BASE / 'ump-ui-config.yaml' if config_path.exists(): try: with open(config_path, encoding='utf-8') as f: cfg = yaml.safe_load(f) or {} plans_rel = (cfg.get('paths') or {}).get('plans', 'plans/') return BASE / plans_rel except (OSError, yaml.YAMLError): # P1-15 fix: точечный except — OSError для файла, YAMLError для парсинга. pass return BASE / 'plans' PLANS_DIR = _resolve_plans_dir() STEP_STATE = BASE / 'STEP_STATE.md' # В v0.0.1: вывод в docs/ проекта пользователя (BASE), не в infra (INFRA) DEFAULT_OUTPUT = BASE / 'docs' / 'progress.md' # ─── Загрузка этапов из YAML-планов ───────────────────────────────────────── def load_stages_from_plans() -> list[dict]: """ Загрузить этапы из всех YAML-планов в plans/. Возвращает список словарей: [{'stage': 1, 'name': 'Setup', 'steps_count': 3, 'tag': None}, ...] """ stages: dict[int, dict] = {} if not PLANS_DIR.exists(): return [] for plan_file in sorted(PLANS_DIR.glob('*.yaml')): if plan_file.name.startswith('99'): continue # шаблон, не этап try: with open(plan_file, encoding='utf-8') as f: data = yaml.safe_load(f) or {} except Exception: continue stage_num = data.get('stage') if stage_num is None: continue try: stage_num = int(stage_num) except (ValueError, TypeError): continue steps = data.get('steps', []) if not steps: continue # Тег версии — ищем в последнем шаге с generates_stage_summary или в шагах finalization tag = None for step in steps: if step.get('generates_stage_summary'): # Тег может быть в verify или expected_result expected = step.get('expected_result', '') tag_match = re.search(r'(v\d+\.\d+\.\d+)', expected) if tag_match: tag = tag_match.group(1) break stages[stage_num] = { 'stage': stage_num, 'name': data.get('name', f'Stage {stage_num}'), 'steps_count': len(steps), 'tag': tag, 'file': plan_file.name, } return sorted(stages.values(), key=lambda s: s['stage']) # ─── Парсинг STEP_STATE.md ────────────────────────────────────────────────── def parse_step_state() -> tuple[str | None, list[str], list[int]]: """ Парсит STEP_STATE.md. Возвращает (current_step, completed_steps, stage_archives). """ if not STEP_STATE.exists(): return None, [], [] content = STEP_STATE.read_text(encoding='utf-8') # CURRENT_STEP m = re.search(r'CURRENT_STEP[=:]\s*(\S+)', content) current_step = m.group(1) if m else None # COMPLETED_STEPS completed: list[str] = [] in_completed = False for line in content.split('\n'): if line.startswith('## COMPLETED_STEPS'): in_completed = True continue if in_completed: if line.startswith('## ') or line.startswith('# '): in_completed = False continue m = re.match(r'\|\s*([\d.]+[a-z]?)\s*\|', line) if m: completed.append(m.group(1)) # STAGE_ARCHIVES archives: list[int] = [] in_archives = False for line in content.split('\n'): if line.startswith('## STAGE_ARCHIVES'): in_archives = True continue if in_archives: if line.startswith('## ') or line.startswith('# '): in_archives = False continue m = re.match(r'\|\s*(\d+)\s*\|', line) if m: archives.append(int(m.group(1))) return current_step, completed, archives def determine_stage(step_id: str) -> int | None: """По step_id вида '1.0' или '9.4a' возвращает номер этапа.""" if not step_id: return None m = re.match(r'^(\d+)\.', step_id) return int(m.group(1)) if m else None # ─── Генерация progress.md ────────────────────────────────────────────────── def generate(output_path: Path = DEFAULT_OUTPUT) -> str: """Сгенерировать progress.md и вернуть содержимое.""" stages = load_stages_from_plans() current_step, completed, archives = parse_step_state() now = datetime.now().strftime('%Y-%m-%d %H:%M') total_steps = sum(s['steps_count'] for s in stages) lines: list[str] = [ '# Прогресс проекта', '', f'> Автогенерация: `scripts/gen_progress.py` • Обновлено: {now}', '', '## Текущий статус', '', ] if current_step: stage = determine_stage(current_step) stage_name = next((s['name'] for s in stages if s['stage'] == stage), '?') lines.append(f'- **Текущий шаг:** `{current_step}`') lines.append(f'- **Этап:** {stage} — {stage_name}') lines.append(f'- **Завершено шагов:** {len(completed)} из {total_steps}') lines.append('') else: lines.append('- **Текущий шаг:** не определён (`STEP_STATE.md` отсутствует)') lines.append(f'- **Всего шагов в плане:** {total_steps}') lines.append('') # Таблица этапов if stages: lines.append('## Этапы') lines.append('') lines.append('| # | Этап | Шагов | Завершено | Тег | Архив |') lines.append('|---|------|-------|-----------|-----|-------|') for s in stages: done = sum(1 for c in completed if determine_stage(c) == s['stage']) archive_mark = '✅' if s['stage'] in archives else '' tag_str = s['tag'] or '—' lines.append( f"| {s['stage']} | {s['name']} | {s['steps_count']} | " f"{done}/{s['steps_count']} | {tag_str} | {archive_mark} |" ) lines.append('') # Последние завершённые шаги if completed: lines.append('## Последние завершённые шаги') lines.append('') # P2-4 fix (mypy): используем другое имя переменной, чтобы не путать # с 's' из цикла выше (там s это Stage dict, тут s это str). for completed_step_id in completed[-10:]: lines.append(f'- `{completed_step_id}`') lines.append('') # Вехи (опционально — только если есть теги в этапах) tagged_stages = [s for s in stages if s['tag']] if tagged_stages: lines.append('## Вехи') lines.append('') lines.append('| Веха | Тег | Статус |') lines.append('|------|-----|--------|') for s in tagged_stages: status = '✅ Достигнута' if s['stage'] in archives else '⏳ В работе' lines.append(f"| {s['name']} | `{s['tag']}` | {status} |") lines.append('') output_path.parent.mkdir(parents=True, exist_ok=True) output_path.write_text('\n'.join(lines), encoding='utf-8') return '\n'.join(lines) def main() -> int: parser = argparse.ArgumentParser(description='Генератор progress.md из YAML-планов') parser.add_argument('--output', '-o', default=str(DEFAULT_OUTPUT), help=f'Выходной файл (по умолчанию: {DEFAULT_OUTPUT})') args = parser.parse_args() output = Path(args.output) generate(output) print(f'✅ {output} сгенерирован') print(f' Этапов: {len(load_stages_from_plans())}') return 0 if __name__ == '__main__': sys.exit(main())