Files

851 lines
29 KiB
Python

#!/usr/bin/env python3
"""Interactive Play Store screenshot capture tool for Hermes-Relay.
Replaces the old PowerShell/batch implementations, which hit too many
Windows shell landmines (UTF-8 parsing, ANSI code pages, .NET Framework
API gaps, positional parameter binding). Python + rich sidesteps all of
them: subprocess.run passes args as an explicit list, rich handles
Unicode/colors natively on Windows Terminal, conhost, and *nix.
Usage:
python scripts/screenshots.py
# or via the thin shim:
scripts\\screenshots.bat
"""
from __future__ import annotations
import os
import argparse
import json
import shutil
import subprocess
import sys
from datetime import datetime
from pathlib import Path
from typing import Any
# Auto-install rich on first run — one-time cost, zero-friction UX.
try:
from rich.console import Console
from rich.markup import escape
from rich.panel import Panel
from rich.prompt import Confirm, Prompt
from rich.table import Table
from rich.text import Text
except ImportError:
print("Installing 'rich' (one-time setup)...", flush=True)
subprocess.check_call(
[sys.executable, "-m", "pip", "install", "--quiet", "rich"]
)
from rich.console import Console
from rich.markup import escape
from rich.panel import Panel
from rich.prompt import Confirm, Prompt
from rich.table import Table
from rich.text import Text
REPO_ROOT = Path(__file__).resolve().parent.parent
OUT_DIR = REPO_ROOT / "assets" / "screenshots"
DEFAULT_PLAN = REPO_ROOT / "docs" / "media" / "screenshots.json"
DEVICE_TMP = "/sdcard/hermes_screenshot.png"
console = Console()
# ── adb helpers ──────────────────────────────────────────────────────────
def adb(*args: str) -> tuple[int, str]:
"""Run adb with the given args. Returns (exit_code, stdout)."""
try:
result = subprocess.run(
["adb", *args],
capture_output=True,
text=True,
check=False,
encoding="utf-8",
errors="replace",
)
return result.returncode, (result.stdout or "").strip()
except FileNotFoundError:
console.print(
"[bold red]adb not found on PATH.[/] "
"Install Android platform-tools and retry."
)
sys.exit(1)
def device_connected() -> bool:
code, _ = adb("get-state")
return code == 0
def device_model() -> str:
_, model = adb("shell", "getprop", "ro.product.model")
return model or "(unknown)"
def count_screenshots() -> int:
return len(list(OUT_DIR.glob("*.png")))
# ── plan + image helpers ─────────────────────────────────────────────────
def repo_path(value: str | Path) -> Path:
path = Path(value)
return path if path.is_absolute() else REPO_ROOT / path
def display_path(path: Path) -> str:
try:
return str(path.relative_to(REPO_ROOT))
except ValueError:
return str(path)
def load_plan(path: Path = DEFAULT_PLAN) -> dict[str, Any]:
plan_path = repo_path(path)
if not plan_path.exists():
console.print(f"[bold red]Plan not found:[/] {escape(str(plan_path))}")
sys.exit(1)
try:
data = json.loads(plan_path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
console.print(f"[bold red]Invalid JSON plan:[/] {exc}")
sys.exit(1)
scenes = data.get("scenes")
if not isinstance(scenes, list) or not scenes:
console.print("[bold red]Plan must contain a non-empty scenes list.[/]")
sys.exit(1)
return data
def find_scene(plan: dict[str, Any], scene_id: str) -> dict[str, Any]:
for scene in plan["scenes"]:
if scene.get("id") == scene_id:
return scene
console.print(f"[bold red]Unknown scene:[/] {escape(scene_id)}")
sys.exit(1)
def scene_source(scene: dict[str, Any]) -> Path:
source = scene.get("source")
if not isinstance(source, str) or not source:
console.print(f"[bold red]Scene {escape(str(scene.get('id')))} has no source.[/]")
sys.exit(1)
return repo_path(source)
def ensure_pillow():
try:
from PIL import Image
except ImportError:
console.print("Installing 'Pillow' (one-time setup)...", flush=True)
subprocess.check_call(
[sys.executable, "-m", "pip", "install", "--quiet", "Pillow"]
)
from PIL import Image
return Image
def crop_for_play(image: Any, crop_config: dict[str, Any]) -> Any:
max_ratio = float(crop_config.get("maxAspectRatio", 2.0))
width, height = image.size
target_width = crop_config.get("width")
target_height = crop_config.get("height")
if isinstance(target_width, int) and isinstance(target_height, int):
if target_width > width or target_height > height:
raise ValueError(
f"cannot crop {width}x{height} to larger {target_width}x{target_height}"
)
elif height >= width and height / width > max_ratio:
target_width = width
target_height = int(width * max_ratio)
elif width > height and width / height > max_ratio:
target_width = int(height * max_ratio)
target_height = height
else:
return image.copy()
anchor = str(crop_config.get("anchor", "center")).lower()
if anchor == "top":
left = (width - target_width) // 2
top = 0
elif anchor == "bottom":
left = (width - target_width) // 2
top = height - target_height
else:
left = (width - target_width) // 2
top = (height - target_height) // 2
return image.crop((left, top, left + target_width, top + target_height))
def normalize_for_png(image: Any) -> Any:
if image.mode in ("RGBA", "LA"):
background = ensure_pillow().new("RGB", image.size, (8, 9, 13))
alpha = image.getchannel("A")
background.paste(image, mask=alpha)
return background
if image.mode != "RGB":
return image.convert("RGB")
return image.copy()
def write_png(image: Any, dest: Path) -> None:
dest.parent.mkdir(parents=True, exist_ok=True)
image = normalize_for_png(image)
image.save(dest, "PNG", optimize=True)
def image_matches(path: Path, expected: Any) -> bool:
image_mod = ensure_pillow()
expected = normalize_for_png(expected)
with image_mod.open(path) as actual:
actual = normalize_for_png(actual)
return actual.size == expected.size and actual.tobytes() == expected.tobytes()
def capture_to(dest: Path) -> bool:
dest.parent.mkdir(parents=True, exist_ok=True)
with console.status("[dim]Capturing...[/]"):
adb("shell", "screencap", "-p", DEVICE_TMP)
adb("pull", DEVICE_TMP, str(dest))
adb("shell", "rm", "-f", DEVICE_TMP)
return dest.exists()
def export_plan(plan: dict[str, Any], targets: set[str]) -> list[Path]:
outputs = plan.get("outputs", {})
changed: list[Path] = []
if "readme" in targets:
for scene in plan["scenes"]:
readme_path = scene.get("readme")
if not readme_path:
continue
source = scene_source(scene)
dest = repo_path(readme_path)
if source.resolve() == dest.resolve():
continue
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(source, dest)
changed.append(dest)
if "play" in targets:
play = outputs.get("play", {})
graphics_dir = repo_path(play.get(
"graphicsDir",
"app/src/googlePlay/play/listings/en-US/graphics",
))
crop_config = dict(play.get("screenshotCrop", {}))
crop_config.setdefault("maxAspectRatio", play.get("maxAspectRatio", 2.0))
phone_dir = repo_path(play.get(
"phoneScreenshotsDir",
graphics_dir / "phone-screenshots",
))
image_mod = ensure_pillow()
assets = play.get("assets", {})
if isinstance(assets, dict):
for media_type, asset in assets.items():
if not isinstance(asset, dict):
continue
source = repo_path(asset["source"])
dest = graphics_dir / media_type / asset.get("file", "1.png")
with image_mod.open(source) as image:
write_png(image.copy(), dest)
changed.append(dest)
for index, scene in enumerate(plan["scenes"], start=1):
source = scene_source(scene)
dest = phone_dir / str(scene.get("play", f"{index}.png"))
with image_mod.open(source) as image:
cropped = crop_for_play(image, crop_config)
write_png(cropped, dest)
changed.append(dest)
return changed
def image_size(path: Path) -> tuple[int, int]:
image_mod = ensure_pillow()
with image_mod.open(path) as image:
return image.size
def validate_plan(plan: dict[str, Any]) -> tuple[list[str], list[str]]:
errors: list[str] = []
warnings: list[str] = []
outputs = plan.get("outputs", {})
play = outputs.get("play", {})
play_dir = repo_path(play.get("playDir", "app/src/googlePlay/play"))
graphics_dir = repo_path(play.get(
"graphicsDir",
"app/src/googlePlay/play/listings/en-US/graphics",
))
phone_dir = repo_path(play.get(
"phoneScreenshotsDir",
graphics_dir / "phone-screenshots",
))
image_mod = ensure_pillow()
if len(plan["scenes"]) > 8:
errors.append("Google Play allows at most 8 phone screenshots.")
for index, scene in enumerate(plan["scenes"], start=1):
source = scene_source(scene)
if not source.exists():
errors.append(f"missing scene source: {display_path(source)}")
continue
width, height = image_size(source)
if min(width, height) < 320 or max(width, height) > 3840:
errors.append(
f"source {display_path(source)} is outside Play bounds: {width}x{height}"
)
if max(width, height) / min(width, height) > 2:
warnings.append(
f"source {display_path(source)} exceeds 2:1; Play export crops it"
)
play_name = scene.get("play", f"{index}.png")
play_path = phone_dir / str(play_name)
if not play_path.exists():
errors.append(f"missing Play screenshot export: {display_path(play_path)}")
continue
play_w, play_h = image_size(play_path)
if min(play_w, play_h) < 320 or max(play_w, play_h) > 3840:
errors.append(
f"Play screenshot {display_path(play_path)} outside bounds: "
f"{play_w}x{play_h}"
)
if max(play_w, play_h) / min(play_w, play_h) > 2:
errors.append(
f"Play screenshot {display_path(play_path)} exceeds 2:1: "
f"{play_w}x{play_h}"
)
assets = play.get("assets", {})
expected_sizes = {
"icon": (512, 512),
"feature-graphic": (1024, 500),
}
if isinstance(assets, dict):
for media_type, asset in assets.items():
if not isinstance(asset, dict):
continue
source = repo_path(asset["source"])
if not source.exists():
errors.append(f"missing Play graphic source: {display_path(source)}")
continue
dest = graphics_dir / media_type / asset.get("file", "1.png")
if not dest.exists():
errors.append(f"missing Play graphic export: {display_path(dest)}")
continue
size = image_size(dest)
expected = expected_sizes.get(media_type)
if expected and size != expected:
errors.append(
f"{display_path(dest)} is {size[0]}x{size[1]}, expected "
f"{expected[0]}x{expected[1]}"
)
with image_mod.open(source) as source_image:
if not image_matches(dest, source_image):
errors.append(
f"stale Play graphic export: {display_path(dest)} "
"(run python scripts/screenshots.py export --target play)"
)
crop_config = dict(play.get("screenshotCrop", {}))
crop_config.setdefault("maxAspectRatio", play.get("maxAspectRatio", 2.0))
for index, scene in enumerate(plan["scenes"], start=1):
source = scene_source(scene)
play_name = scene.get("play", f"{index}.png")
play_path = phone_dir / str(play_name)
if not source.exists() or not play_path.exists():
continue
with image_mod.open(source) as source_image:
expected_export = crop_for_play(source_image, crop_config)
if not image_matches(play_path, expected_export):
errors.append(
f"stale Play screenshot export: {display_path(play_path)} "
"(run python scripts/screenshots.py export --target play)"
)
listing_limits = {
"listings/en-US/title.txt": 30,
"listings/en-US/short-description.txt": 80,
"listings/en-US/full-description.txt": 4000,
}
default_language = play_dir / "default-language.txt"
if not default_language.exists():
errors.append(f"missing Play default language: {display_path(default_language)}")
elif default_language.read_text(encoding="utf-8").strip() != "en-US":
errors.append(
f"{display_path(default_language)} must be en-US to match listing paths"
)
for rel_path, limit in listing_limits.items():
path = play_dir / rel_path
if not path.exists():
errors.append(f"missing Play listing text: {display_path(path)}")
continue
text = path.read_text(encoding="utf-8").strip()
if not text:
errors.append(f"empty Play listing text: {display_path(path)}")
if len(text) > limit:
errors.append(
f"{display_path(path)} is {len(text)} chars, limit is {limit}"
)
return errors, warnings
# ── rendering ────────────────────────────────────────────────────────────
def render_header(device: str, demo_on: bool) -> None:
rel = str(OUT_DIR.relative_to(REPO_ROOT))
status = (
"[bold green]DEMO MODE (clean)[/]"
if demo_on
else "[bold yellow]normal[/]"
)
# NOTE: os.sep (backslash on Windows) must live OUTSIDE the [bold]…[/]
# span and must NOT be passed through rich.markup.escape(). Two rich
# quirks to work around:
# 1. `\[` inside a span is parsed as a literal bracket escape, so
# ending with `...\[/]` breaks the closing tag.
# 2. rich.markup.escape('\\') returns '\\\\' — it doubles backslashes
# so they survive a second markup parse. We don't want that here
# because os.sep is already a literal, not user markup input.
body = Text.from_markup(
f"Device: [bold]{escape(device)}[/]\n"
f"Output: [bold]{escape(rel)}[/]{os.sep}\n"
f"Screenshots: [bold]{count_screenshots()}[/]\n"
f"Status bar: {status}"
)
console.print(
Panel(
body,
title="[bold cyan]Hermes-Relay — Screenshot Capture[/]",
border_style="cyan",
padding=(0, 2),
)
)
def render_menu() -> None:
menu = Text.from_markup(
"[bold cyan]D[/] Demo mode on "
"[bold cyan]R[/] Restore status bar\n"
"[bold cyan]S[/] Snap screenshot "
"[bold cyan]L[/] List screenshots\n"
"[bold cyan]O[/] Open folder "
"[bold cyan]Q[/] Quit"
)
console.print(Panel(menu, border_style="dim", padding=(0, 2)))
# ── actions ──────────────────────────────────────────────────────────────
DEMO_BROADCASTS: list[tuple[str, list[str]]] = [
("clock", ["--es", "hhmm", "1200"]),
("battery", ["--es", "level", "100", "--es", "plugged", "false"]),
("network", ["--es", "wifi", "show", "--es", "level", "4"]),
(
"network",
["--es", "mobile", "show", "--es", "level", "4",
"--es", "datatype", "none"],
),
("notifications", ["--es", "visible", "false"]),
]
def enable_demo() -> None:
console.print("[dim]Enabling demo mode...[/]")
adb("shell", "settings", "put", "global", "sysui_demo_allowed", "1")
for cmd, extras in DEMO_BROADCASTS:
adb(
"shell", "am", "broadcast",
"-a", "com.android.systemui.demo",
"--es", "command", cmd,
*extras,
)
console.print(
"[green]✓[/] Demo mode active "
"(12:00, full battery, no notifications)"
)
console.print(
"[yellow]TIP:[/] If the status bar didn't change, enable "
"[italic]Demo Mode[/] under Settings > Developer Options first."
)
def disable_demo() -> None:
console.print("[dim]Restoring status bar...[/]")
adb(
"shell", "am", "broadcast",
"-a", "com.android.systemui.demo",
"--es", "command", "exit",
)
adb("shell", "settings", "put", "global", "sysui_demo_allowed", "0")
console.print("[green]✓[/] Status bar restored")
def snap() -> None:
# Snap FIRST with a timestamp name so timing-sensitive shots (sphere
# animation states, ambient mode transitions, etc.) fire the moment
# S is pressed. Renaming happens after — if the user skips the rename
# prompt or Ctrl-Cs out, the shot is already safely on disk under the
# timestamp name.
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
temp_name = f"shot_{timestamp}"
temp_dest = OUT_DIR / f"{temp_name}.png"
if not capture_to(temp_dest):
console.print(f"[red]✗ Failed to pull[/] {escape(str(temp_dest))}")
return
size = temp_dest.stat().st_size
console.print(
f"[green]✓ Captured[/] — [bold]{size:,}[/] bytes "
f"[dim](saved as {escape(temp_name)}.png)[/]"
)
console.print("\n[dim]Suggested names:[/]")
console.print("[dim] 01_startup 05_commands[/]")
console.print("[dim] 02_chat 06_manage[/]")
console.print("[dim] 03_voice 07_connections[/]")
console.print("[dim] 04_sessions 08_settings[/]")
try:
new_name = Prompt.ask(
"[bold]Rename?[/] [dim](Enter = keep timestamp)[/]",
default="",
).strip()
except (KeyboardInterrupt, EOFError):
console.print()
new_name = ""
if not new_name:
console.print(
f"[green]✓[/] Kept as [bold]{escape(temp_name)}.png[/]"
)
else:
# Strip any accidental extension the user typed.
if new_name.lower().endswith(".png"):
new_name = new_name[:-4]
final_dest = OUT_DIR / f"{new_name}.png"
if final_dest.exists() and final_dest.resolve() != temp_dest.resolve():
if not Confirm.ask(
f"[yellow]{escape(new_name)}.png already exists. Overwrite?[/]",
default=False,
):
console.print(
f"[yellow]Kept original name:[/] "
f"[bold]{escape(temp_name)}.png[/]"
)
console.print(
f"Total: [bold]{count_screenshots()}[/] screenshots"
)
return
final_dest.unlink()
temp_dest.rename(final_dest)
console.print(
f"[green]✓ Renamed to[/] [bold]{escape(new_name)}.png[/]"
)
console.print(f"Total: [bold]{count_screenshots()}[/] screenshots")
def show_list() -> None:
files = sorted(OUT_DIR.glob("*.png"))
if not files:
console.print("[dim](no screenshots yet)[/]")
return
table = Table(
title="Captured Screenshots",
border_style="dim",
title_style="bold",
show_header=True,
header_style="bold cyan",
)
table.add_column("Name", style="bold")
table.add_column("Size", justify="right", style="dim")
for f in files:
table.add_row(f.name, f"{f.stat().st_size:,} bytes")
console.print(table)
def open_folder() -> None:
OUT_DIR.mkdir(parents=True, exist_ok=True)
try:
if sys.platform == "win32":
os.startfile(OUT_DIR) # type: ignore[attr-defined]
elif sys.platform == "darwin":
subprocess.Popen(["open", str(OUT_DIR)])
else:
subprocess.Popen(["xdg-open", str(OUT_DIR)])
console.print(f"[green]✓[/] Opened {escape(str(OUT_DIR))}")
except OSError as e:
console.print(f"[red]✗ Failed to open folder:[/] {e}")
# ── input helpers ────────────────────────────────────────────────────────
def read_key() -> str:
"""Read a single keypress without requiring Enter. Returns lowercase."""
if sys.platform == "win32":
import msvcrt
ch = msvcrt.getwch()
# Function/arrow keys send two characters — swallow the second
# so we don't return a control byte.
if ch in ("\x00", "\xe0"):
msvcrt.getwch()
return ""
return ch.lower()
import termios
import tty
fd = sys.stdin.fileno()
old = termios.tcgetattr(fd)
try:
tty.setraw(fd)
ch = sys.stdin.read(1)
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old)
return ch.lower()
def pause() -> None:
console.print("\n[dim]Press any key to continue...[/]", end="")
read_key()
console.print()
# ── non-interactive commands ─────────────────────────────────────────────
def command_list(args: argparse.Namespace) -> int:
plan = load_plan(args.plan)
table = Table(
title=f"Screenshot plan — {display_path(repo_path(args.plan))}",
border_style="dim",
title_style="bold",
show_header=True,
header_style="bold cyan",
)
table.add_column("Scene", style="bold")
table.add_column("Title")
table.add_column("Source", style="dim")
table.add_column("Play", style="dim")
for scene in plan["scenes"]:
table.add_row(
str(scene.get("id", "")),
str(scene.get("title", "")),
display_path(scene_source(scene)),
str(scene.get("play", "")),
)
console.print(table)
return 0
def command_capture(args: argparse.Namespace) -> int:
plan = load_plan(args.plan)
scene = find_scene(plan, args.scene)
dest = scene_source(scene)
if not device_connected():
console.print("[bold red]✗ No device connected.[/] Plug in via ADB and retry.")
return 1
if dest.exists() and not args.force:
if not Confirm.ask(
f"[yellow]{escape(display_path(dest))} exists. Overwrite?[/]",
default=False,
):
return 1
demo_enabled = False
try:
if args.demo:
enable_demo()
demo_enabled = True
if not capture_to(dest):
console.print(f"[bold red]✗ Failed to capture[/] {escape(display_path(dest))}")
return 1
console.print(f"[green]OK Captured[/] {escape(display_path(dest))}")
finally:
if demo_enabled and args.restore_demo:
disable_demo()
return 0
def command_export(args: argparse.Namespace) -> int:
plan = load_plan(args.plan)
targets = set(args.target or ["play"])
changed = export_plan(plan, targets)
if not changed:
console.print("[green]OK[/] Nothing to export.")
return 0
for path in changed:
console.print(f"[green]OK[/] {escape(display_path(path))}")
console.print(f"\nExported [bold]{len(changed)}[/] file(s).")
return 0
def command_validate(args: argparse.Namespace) -> int:
plan = load_plan(args.plan)
errors, warnings = validate_plan(plan)
for warning in warnings:
console.print(f"[yellow]warning:[/] {escape(warning)}")
for error in errors:
console.print(f"[bold red]error:[/] {escape(error)}")
if errors:
return 1
console.print("[green]OK[/] Screenshot plan and exported Play assets are valid.")
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Capture and export Hermes-Relay screenshots.",
)
parser.add_argument(
"--plan",
type=Path,
default=DEFAULT_PLAN,
help=f"Screenshot plan JSON (default: {display_path(DEFAULT_PLAN)})",
)
subparsers = parser.add_subparsers(dest="command")
list_parser = subparsers.add_parser("list", help="List planned screenshot scenes")
list_parser.set_defaults(func=command_list)
capture_parser = subparsers.add_parser("capture", help="Capture one planned scene")
capture_parser.add_argument("scene", help="Scene id from the plan")
capture_parser.add_argument("--force", action="store_true", help="Overwrite without prompting")
capture_parser.add_argument("--demo", action="store_true", help="Enable clean Android demo mode first")
capture_parser.add_argument(
"--no-restore-demo",
dest="restore_demo",
action="store_false",
help="Leave Android demo mode enabled after capture",
)
capture_parser.set_defaults(func=command_capture, restore_demo=True)
export_parser = subparsers.add_parser(
"export",
help="Export planned screenshots to README and/or Play directories",
)
export_parser.add_argument(
"--target",
choices=("readme", "play"),
action="append",
default=None,
help="Export target; repeat for multiple targets (default: play)",
)
export_parser.set_defaults(func=command_export)
validate_parser = subparsers.add_parser(
"validate",
help="Validate scene sources and exported Play graphics",
)
validate_parser.set_defaults(func=command_validate)
interactive_parser = subparsers.add_parser(
"interactive",
help="Open the original key-driven capture helper",
)
interactive_parser.set_defaults(func=lambda _args: interactive_main())
return parser
# ── main loop ────────────────────────────────────────────────────────────
def interactive_main() -> int:
OUT_DIR.mkdir(parents=True, exist_ok=True)
if not device_connected():
console.print(
"[bold red]✗ No device connected.[/] "
"Plug in via ADB and retry."
)
return 1
device = device_model()
demo_on = False
try:
while True:
console.clear()
render_header(device, demo_on)
render_menu()
# Brackets around the key list would be parsed as a rich
# markup tag, so escape the literal chars via rich's own helper.
choices = escape("[D/R/S/L/O/Q]")
console.print(
f"Press a key [bold cyan]{choices}[/]: ", end=""
)
sys.stdout.flush()
key = read_key()
console.print(f"[bold]{escape(key) or '?'}[/]")
console.print()
if key == "d":
enable_demo()
demo_on = True
pause()
elif key == "r":
disable_demo()
demo_on = False
pause()
elif key == "s":
snap()
pause()
elif key == "l":
show_list()
pause()
elif key == "o":
open_folder()
pause()
elif key == "q":
break
else:
console.print(f"[yellow]Unknown key: {escape(repr(key))}[/]")
pause()
except KeyboardInterrupt:
console.print("\n[yellow]Interrupted.[/]")
finally:
if demo_on:
console.print()
try:
if Confirm.ask(
"Demo mode is still on. Restore before quitting?",
default=True,
):
disable_demo()
except (KeyboardInterrupt, EOFError):
pass
console.print(
f"\n[bold cyan]Done.[/] Screenshots in "
f"{escape(str(OUT_DIR))}"
)
return 0
def main() -> int:
if len(sys.argv) == 1:
return interactive_main()
parser = build_parser()
args = parser.parse_args()
if not hasattr(args, "func"):
parser.print_help()
return 1
return args.func(args)
if __name__ == "__main__":
sys.exit(main())