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
This commit is contained in:
78
Makefile
78
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
|
||||
|
||||
38
justfile
Normal file
38
justfile
Normal file
@@ -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" } }}
|
||||
114
scripts/help_formatter.py
Normal file
114
scripts/help_formatter.py
Normal file
@@ -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)
|
||||
@@ -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.",
|
||||
)
|
||||
|
||||
@@ -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 <json_file> [name_filter] [--id <app_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=<your_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")
|
||||
|
||||
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:")
|
||||
|
||||
38
utility.just
Normal file
38
utility.just
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user