From 1b003007ea82bfb609fa38b896813c1a070900fa Mon Sep 17 00:00:00 2001 From: Richard Macias Date: Fri, 27 Feb 2026 18:13:17 -0600 Subject: [PATCH] add justfile as primary task runner, slim Makefile to CI-only. add styled argparse help formatter, parallel test execution (-j8 default), argparse for test-apps.py. release.py accepts --dryrun alias --- Makefile | 78 ++++++++------------------ justfile | 38 +++++++++++++ scripts/help_formatter.py | 114 +++++++++++++++++++++++++++++++++++++ scripts/release.py | 6 +- scripts/test-apps.py | 115 +++++++++++++++++++++++--------------- utility.just | 38 +++++++++++++ 6 files changed, 287 insertions(+), 102 deletions(-) create mode 100644 justfile create mode 100644 scripts/help_formatter.py create mode 100644 utility.just diff --git a/Makefile b/Makefile index 6fcb596..5ed470f 100644 --- a/Makefile +++ b/Makefile @@ -1,71 +1,39 @@ -.PHONY: help readme validate add-app normalize build publish publish-dry-run publish-from-file test test-app test-apks test-verbose -default: help +# These targets exist for CI (GitHub Actions) only. +# For local development, use the justfile: run `just` to see all commands. -help: # Show help for each of the makefile recipes. - @width=$$(grep -E '^[a-zA-Z0-9 -]+:.*#' Makefile | cut -f1 -d':' | awk '{print length}' | sort -rn | head -1); \ - grep -E '^[a-zA-Z0-9 -]+:.*#' Makefile | sort | while read -r l; \ - do printf "\033[1;32m%-$${width}s\033[00m %s\n" "$$(echo $$l | cut -f 1 -d':')" "$$(echo $$l | cut -f 2- -d'#')"; done +.PHONY: help validate test build normalize table readme minify minify-dual-screen +.DEFAULT_GOAL := help -# --------------------------------------------------------------------------- -# Apps -# --------------------------------------------------------------------------- +help: ## Show available targets + @echo "CI-only build targets. For local development, run 'just' to see all commands." + @echo "" + @grep -E '^[a-z].*:.*##' Makefile | sed 's/:.*## /\t/' | while IFS=$$'\t' read -r target desc; do \ + printf "\033[1;32m%-20s\033[0m %s\n" "$$target" "$$desc"; \ + done -add-app: # Interactive CLI to add a new app - @python scripts/add-app.py - -validate: # Validate applications.json for errors (structure, regex syntax, source types) +validate: ## Validate applications.json for errors @python scripts/validate-json.py src/applications.json -normalize: # Normalize key order and add missing defaults in applications.json +test: ## Live-test all app configs resolve to downloadable APKs + @python scripts/test-apps.py + +build: validate normalize readme minify minify-dual-screen ## Build all artifacts + +normalize: ## Normalize key order and add missing defaults @python scripts/normalize-json.py src/applications.json -test: # Live-test that all app configs can resolve to downloadable APKs - @python scripts/test-apps.py src/applications.json - -test-app: # Live-test a single app by name (e.g. make test-app APP=Dolphin) - @python scripts/test-apps.py src/applications.json --verbose --apks $(APP) - -test-apks: # Live-test all apps and show numbered APK list for index selection - @python scripts/test-apps.py src/applications.json --apks - -test-verbose: # Live-test with APK URL details shown - @python scripts/test-apps.py src/applications.json --verbose - -# --------------------------------------------------------------------------- -# Build -# --------------------------------------------------------------------------- - -build: validate normalize readme minify minify-dual-screen # Build all artifacts: validate, normalize, readme, and both release JSONs. - -minify: # Generate standard release JSON - @python scripts/minify-json.py src/applications.json obtainium-emulation-pack-latest.json --variant standard - -minify-dual-screen: # Generate dual screen release JSON - @python scripts/minify-json.py src/applications.json obtainium-emulation-pack-dual-screen-latest.json --variant dual-screen - -# --------------------------------------------------------------------------- -# Docs -# --------------------------------------------------------------------------- - -table: # Generate a table of obtainium links for the README. +table: ## Generate markdown table for the README @python scripts/generate-table.py src/applications.json ./pages/table.md -readme: table # Generate the readme file. Why? Because editing that table every change is tedious. +readme: table ## Generate the README from page sections @python scripts/generate-readme.py \ ./pages/header.md \ ./pages/table.md \ ./pages/faq.md \ ./pages/footer.md -# --------------------------------------------------------------------------- -# Publish -# --------------------------------------------------------------------------- +minify: ## Generate standard release JSON + @python scripts/minify-json.py src/applications.json obtainium-emulation-pack-latest.json --variant standard -publish: # Tag, push, and create a GitHub release (opens $EDITOR for notes) - @python scripts/release.py - -publish-dry-run: # Preview release notes as a markdown file without publishing - @python scripts/release.py --dry-run - -publish-from-file: # Publish using a previously edited release notes file (e.g. from publish-dry) - @python scripts/release.py --notes-file $(FILE) +minify-dual-screen: ## Generate dual-screen release JSON + @python scripts/minify-json.py src/applications.json obtainium-emulation-pack-dual-screen-latest.json --variant dual-screen diff --git a/justfile b/justfile new file mode 100644 index 0000000..7615597 --- /dev/null +++ b/justfile @@ -0,0 +1,38 @@ +import 'utility.just' + +[private] +default: + @echo '{{YELLOW}}Tip:{{NORMAL}} Recipes with {{BOLD}}*args{{NORMAL}} accept {{BOLD}}-h{{NORMAL}} for help.' + @just --list + +# Interactive CLI to add a new app +[group('CLI Tools')] +add-app: + @python scripts/add-app.py + +# Test, validate, normalize, and generate all output files +[group('Release')] +build: test validate normalize generate + +# Tag, push, and create a GitHub release +[group('Release')] +release *args: + @python scripts/release.py {{ args }} + +# Validate applications.json for errors (structure, regex syntax, source types) +[group('Formatting')] +validate: + @python scripts/validate-json.py src/applications.json + +# Normalize key order and add missing defaults in applications.json +[group('Formatting')] +normalize: + @python scripts/normalize-json.py src/applications.json + +# Live-test app configs +test *args: + @python scripts/test-apps.py {{ args }} + +# Generate output files +generate *args: + @{{ if args == "help" { "just _generate-help" } else if args == "-h" { "just _generate-help" } else if args == "--help" { "just _generate-help" } else if args == "table" { "just _generate-table" } else if args == "readme" { "just _generate-readme" } else if args == "standard" { "just _generate-standard" } else if args == "dual-screen" { "just _generate-dual-screen" } else { "just _generate-all" } }} diff --git a/scripts/help_formatter.py b/scripts/help_formatter.py new file mode 100644 index 0000000..606e007 --- /dev/null +++ b/scripts/help_formatter.py @@ -0,0 +1,114 @@ +"""Styled argparse help formatter with ANSI colors.""" + +import argparse +import re +import sys + +BOLD = "\033[1m" +DIM = "\033[2m" +YELLOW = "\033[33m" +CYAN = "\033[36m" +GREEN = "\033[32m" +RESET = "\033[0m" + +ANSI_ESCAPE = re.compile(r"\033\[[0-9;]*m") + + +def _supports_color() -> bool: + return hasattr(sys.stdout, "isatty") and sys.stdout.isatty() + + +def _visible_len(s: str) -> int: + return len(ANSI_ESCAPE.sub("", s)) + + +class StyledHelpFormatter(argparse.HelpFormatter): + + def __init__(self, prog: str, **kwargs) -> None: + kwargs.setdefault("max_help_position", 36) + super().__init__(prog, **kwargs) + self._color = _supports_color() + + def _format_usage(self, usage, actions, groups, prefix): + if prefix is None: + prefix = "usage: " + if self._color: + prefix = f"{YELLOW}{prefix}{RESET}" + return super()._format_usage(usage, actions, groups, prefix) + + def start_section(self, heading): + if self._color and heading: + heading = f"{BOLD}{heading}{RESET}" + super().start_section(heading) + + def _format_action_invocation(self, action): + if not action.option_strings: + # Positional arg: just the metavar + result = self._metavar_formatter(action, action.dest)(1)[0] + if self._color: + result = f"{CYAN}{result}{RESET}" + return result + + # Sort: short flags (-v) before long flags (--version) + short = [s for s in action.option_strings if not s.startswith("--")] + long = [s for s in action.option_strings if s.startswith("--")] + parts = short + long + + # Append metavar once at the end (not after each flag) + if action.nargs != 0: + metavar = self._metavar_formatter(action, action.dest.upper())(1)[0] + result = ", ".join(parts) + " " + metavar + else: + result = ", ".join(parts) + + # Pad with leading spaces when no short flag, to align with those that have one + if not short: + result = " " + result + + if self._color: + result = f"{GREEN}{result}{RESET}" + return result + + def _format_action(self, action): + help_position = min(self._action_max_length + 2, + self._max_help_position) + help_width = max(self._width - help_position, 11) + action_width = help_position - self._current_indent - 2 + action_header = self._format_action_invocation(action) + + # Use visible length (ignoring ANSI codes) for layout decisions + visible = _visible_len(action_header) if self._color else len(action_header) + + indent_first = 0 + if not action.help: + tup = self._current_indent, '', action_header + action_header = '%*s%s\n' % tup + elif visible <= action_width: + # Pad based on visible width so columns align despite ANSI codes + ansi_pad = len(action_header) - visible + tup = self._current_indent, '', action_width + ansi_pad, action_header + action_header = '%*s%-*s ' % tup + indent_first = 0 + else: + tup = self._current_indent, '', action_header + action_header = '%*s%s\n' % tup + indent_first = help_position + + parts = [action_header] + + if action.help and action.help.strip(): + help_text = self._expand_help(action) + if help_text: + if self._color: + help_text = f"{DIM}{help_text}{RESET}" + help_lines = self._split_lines(help_text, help_width) + parts.append('%*s%s\n' % (indent_first, '', help_lines[0])) + for line in help_lines[1:]: + parts.append('%*s%s\n' % (help_position, '', line)) + elif not action_header.endswith('\n'): + parts.append('\n') + + for subaction in self._iter_indented_subactions(action): + parts.append(self._format_action(subaction)) + + return self._join_parts(parts) diff --git a/scripts/release.py b/scripts/release.py index 4adb81b..a8ee5de 100644 --- a/scripts/release.py +++ b/scripts/release.py @@ -31,6 +31,7 @@ from pathlib import Path from typing import Any from constants import GITHUB_NOREPLY_SUFFIX +from help_formatter import StyledHelpFormatter from utils import get_application_url, get_display_name, load_dotenv, make_obtainium_link, should_include_app REPO_ROOT = Path(__file__).resolve().parent.parent @@ -434,7 +435,8 @@ def get_app_count(json_path: Path) -> int: def main() -> None: parser = argparse.ArgumentParser( - description="Create a GitHub release for Obtainium Emulation Pack" + description="Create a GitHub release for Obtainium Emulation Pack", + formatter_class=StyledHelpFormatter, ) parser.add_argument( "--version", "-v", @@ -449,7 +451,7 @@ def main() -> None: help="Path to a file containing release notes. Skips generation and editor.", ) parser.add_argument( - "--dry-run", + "--dry-run", "--dryrun", action="store_true", help="Show what would happen without making changes.", ) diff --git a/scripts/test-apps.py b/scripts/test-apps.py index a323598..615c20b 100644 --- a/scripts/test-apps.py +++ b/scripts/test-apps.py @@ -1,25 +1,23 @@ #!/usr/bin/env python3 """Live validation that app configs can resolve to downloadable APKs. -Usage: - python scripts/test-apps.py src/applications.json - python scripts/test-apps.py src/applications.json Dolphin - python scripts/test-apps.py src/applications.json --id org.dolphinemu.dolphinemu - Set GITHUB_TOKEN in .env or environment to avoid API rate limits. """ +import argparse import json import os import re import ssl import sys import time +from concurrent.futures import ThreadPoolExecutor, as_completed from html.parser import HTMLParser from typing import Any from urllib.parse import urljoin, urlparse from urllib.request import Request, urlopen +from help_formatter import StyledHelpFormatter from utils import load_dotenv USER_AGENT = ( @@ -560,39 +558,49 @@ def print_result( def main() -> int: load_dotenv() - if len(sys.argv) < 2: - print("Usage: python test-apps.py [name_filter] [--id ] [--verbose] [--apks]") - print() - print("Examples:") - print(" python test-apps.py src/applications.json # test all apps") - print(" python test-apps.py src/applications.json Dolphin # filter by name") - print(" python test-apps.py src/applications.json --id org.dolphinemu.dolphinemu") - print(" python test-apps.py src/applications.json --verbose # show APK URLs") - print(" python test-apps.py src/applications.json --apks # show numbered APK list") - return 1 + parser = argparse.ArgumentParser( + description="Live-test app configs resolve to downloadable APKs.", + formatter_class=StyledHelpFormatter, + ) + parser.add_argument( + "name", + nargs="?", + help="Filter by app name (case-insensitive substring match)", + ) + parser.add_argument( + "-f", "--file", + default="src/applications.json", + help="Path to applications.json (default: src/applications.json)", + ) + parser.add_argument( + "--id", + dest="id_filter", + help="Filter by exact app ID", + ) + parser.add_argument( + "--verbose", + action="store_true", + help="Show APK download URLs", + ) + parser.add_argument( + "--apks", + action="store_true", + help="Show numbered APK list with preferredApkIndex marker", + ) + parser.add_argument( + "-j", "--jobs", + type=int, + default=8, + help="Number of parallel workers (default: 8, use 1 for serial)", + ) + args = parser.parse_args() - json_file = sys.argv[1] - args = sys.argv[2:] - - verbose = "--verbose" in args - if verbose: - args.remove("--verbose") - - show_apks = "--apks" in args - if show_apks: - args.remove("--apks") - - id_filter = None - if "--id" in args: - idx = args.index("--id") - if idx + 1 < len(args): - id_filter = args[idx + 1] - args = args[:idx] + args[idx + 2:] - else: - print("Error: --id requires an argument") - return 1 - - name_filter = " ".join(args).lower() if args else None + json_file = args.file + name_filter = args.name.lower() if args.name else None + id_filter = args.id_filter + verbose = args.verbose + show_apks = args.apks + workers = max(args.jobs, 1) try: with open(json_file, "r", encoding="utf-8") as f: @@ -620,22 +628,39 @@ def main() -> int: " Set it with: export GITHUB_TOKEN=\n" ) - print(f"Testing {len(apps)} app(s)...\n") + serial = workers == 1 or len(apps) == 1 + mode = "serial" if serial else f"{workers} workers" + print(f"Testing {len(apps)} app(s) ({mode})...\n") - results = [] - for app in apps: - result = test_app(app) - results.append(result) - print_result(result, verbose=verbose, show_apks=show_apks) + wall_start = time.monotonic() + if serial: + results = [] + for app in apps: + result = test_app(app) + results.append(result) + print_result(result, verbose=verbose, show_apks=show_apks) + else: + result_map: dict[str, TestResult] = {} + with ThreadPoolExecutor(max_workers=workers) as pool: + futures = {pool.submit(test_app, app): app for app in apps} + for future in as_completed(futures): + result = future.result() + result_map[result.app_id] = result + # Print in original order + results = [result_map[app["id"]] for app in apps] + for result in results: + print_result(result, verbose=verbose, show_apks=show_apks) + + wall_ms = int((time.monotonic() - wall_start) * 1000) passed = sum(1 for r in results if r.passed) failed = sum(1 for r in results if not r.passed) warned = sum(1 for r in results if r.warnings) - total_time = sum(r.duration_ms for r in results) + sum_time = sum(r.duration_ms for r in results) print(f"\n{'=' * 60}") print(f"Results: {passed} passed, {failed} failed, {warned} with warnings") - print(f"Time: {total_time / 1000:.1f}s total") + print(f"Time: {wall_ms / 1000:.1f}s wall, {sum_time / 1000:.1f}s cumulative") if failed > 0: print(f"\nFailed apps:") diff --git a/utility.just b/utility.just new file mode 100644 index 0000000..8b70081 --- /dev/null +++ b/utility.just @@ -0,0 +1,38 @@ +DIM := "\u{1B}[2m" + +[private] +_generate-help: + @echo '{{YELLOW}}usage:{{NORMAL}} just generate [subcommand]' + @echo '' + @echo 'Generate release artifacts and documentation from applications.json.' + @echo '' + @echo '{{BOLD}}subcommands:{{NORMAL}}' + @echo ' {{GREEN}}just generate{{NORMAL}} {{DIM}}Generate all output files{{NORMAL}}' + @echo ' {{GREEN}}just generate help{{NORMAL}} {{DIM}}Show this help{{NORMAL}}' + @echo ' {{GREEN}}just generate table{{NORMAL}} {{DIM}}Generate markdown table{{NORMAL}}' + @echo ' {{GREEN}}just generate readme{{NORMAL}} {{DIM}}Generate README (includes table){{NORMAL}}' + @echo ' {{GREEN}}just generate standard{{NORMAL}} {{DIM}}Generate standard release JSON{{NORMAL}}' + @echo ' {{GREEN}}just generate dual-screen{{NORMAL}} {{DIM}}Generate dual-screen release JSON{{NORMAL}}' + +[private] +_generate-all: _generate-readme _generate-standard _generate-dual-screen + +[private] +_generate-table: + @python scripts/generate-table.py src/applications.json ./pages/table.md + +[private] +_generate-readme: _generate-table + @python scripts/generate-readme.py \ + ./pages/header.md \ + ./pages/table.md \ + ./pages/faq.md \ + ./pages/footer.md + +[private] +_generate-standard: + @python scripts/minify-json.py src/applications.json obtainium-emulation-pack-latest.json --variant standard + +[private] +_generate-dual-screen: + @python scripts/minify-json.py src/applications.json obtainium-emulation-pack-dual-screen-latest.json --variant dual-screen