Skip to content

Instantly share code, notes, and snippets.

@hugovk
Last active July 21, 2026 11:52
Show Gist options
  • Select an option

  • Save hugovk/c923fa2209caf4588f2155ea64ea59d6 to your computer and use it in GitHub Desktop.

Select an option

Save hugovk/c923fa2209caf4588f2155ea64ea59d6 to your computer and use it in GitHub Desktop.
Update macOS installer screenshots at https://docs.python.org/3/using/mac.html
"""Regenerate the macOS installer screenshots used in Doc/using/mac.rst.
Walks the python.org macOS installer UI with System Events and captures
each screen with screencapture(1), writing the mac_installer_*.png files
into Doc/using/. Semi-automated: the admin password is entered by hand,
and any UI click that fails falls back to asking you to click it
yourself. The Finder (07) and Terminal certificate (08) screenshots
are not captured; take those by hand if needed.
Requirements:
- A GUI session, with your terminal app granted both Accessibility and
Screen Recording permission (System Settings -> Privacy & Security).
- Installer is forced into light mode for the run, so the system
appearance does not matter; a US English UI for Installer still does
(System Settings -> General -> Language & Region -> Applications).
- The installer really installs, so run on a machine/VM where that is OK.
Usage:
uv run make_mac_installer_screenshots.py python-3.15.0-macos11.pkg
uv run make_mac_installer_screenshots.py --out-dir Doc/using python-3.15.0-macos11.pkg
"""
# /// script
# requires-python = ">=3.10"
# dependencies = [
# "pillow",
# ]
# ///
import argparse
import atexit
import subprocess
import sys
import time
from pathlib import Path
from PIL import Image
TARGET_WIDTH = 800 # the existing screenshot convention in Doc/using/
def osascript(script, language="AppleScript", timeout=None):
"""Run a script with osascript and return its output."""
cmd = ["osascript"]
if language != "AppleScript":
cmd += ["-l", language]
cmd += ["-e", script]
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
except subprocess.TimeoutExpired:
raise TimeoutError(f"osascript timed out after {timeout}s") from None
if result.returncode != 0:
raise RuntimeError(result.stderr.strip())
return result.stdout.strip()
def installer_ui(statement, timeout=None):
"""Run one System Events statement against the Installer process."""
return osascript(
'tell application "System Events" to tell process "Installer"\n'
f" {statement}\n"
"end tell",
timeout=timeout,
)
def wait_for(condition, timeout=120, what=""):
"""Poll a System Events boolean expression until it is true."""
print(f" waiting for {what or condition}...", flush=True)
deadline = time.monotonic() + timeout
reported = None
while time.monotonic() < deadline:
try:
if installer_ui(f"return ({condition})") == "true":
return
except RuntimeError as exc:
# Transient errors are normal while a pane is (re)building,
# but surface each distinct one so a bad condition (which
# errors on every poll) is visible instead of a silent hang.
if str(exc) != reported:
reported = str(exc)
print(f" (poll error: {reported})", file=sys.stderr, flush=True)
time.sleep(0.5)
raise TimeoutError(f"timed out waiting for {what or condition}")
def try_click(button_names, target="window 1", timeout=None):
"""Click the first button that works; otherwise ask the user to.
System Events' click can block until a modal that the click opened
is dismissed; pass a timeout for buttons that open sheets/dialogs,
and a timed-out click is treated as delivered.
"""
for name in button_names:
try:
installer_ui(f'click button "{name}" of {target}', timeout=timeout)
print(f" clicked {name}", flush=True)
return
except TimeoutError:
print(f" clicked {name} (blocked on a dialog; continuing)", flush=True)
return
except RuntimeError:
continue
input(
f" -> Could not click {button_names[0]!r} automatically. "
"Please click it yourself, then press Enter... "
)
# The package list in the customize pane, found by walking the
# accessibility hierarchy of a live Installer.
PACKAGE_OUTLINE = (
"outline 1 of scroll area 1 of group 1 "
"of splitter group 1 of group 1 of window 1"
)
def select_package(name):
"""Select a customize-pane row so its description text shows."""
try:
installer_ui(
f"select (first row of {PACKAGE_OUTLINE} "
f'whose name of checkbox 1 is "{name}")'
)
print(f" selected {name}", flush=True)
except RuntimeError:
input(
f" -> Could not select the {name!r} row automatically. "
"Please click it yourself, then press Enter... "
)
def ensure_permissions():
"""Fail early if the terminal lacks the TCC permissions we need."""
if (
osascript(
'ObjC.import("ApplicationServices");' "String($.AXIsProcessTrusted());",
language="JavaScript",
)
!= "true"
):
print(
"Warning: no Accessibility permission; automated clicks will "
"fall back to asking you to click. Grant it to your terminal "
"in System Settings -> Privacy & Security -> Accessibility.",
file=sys.stderr,
)
if (
osascript(
'ObjC.bindFunction("CGPreflightScreenCaptureAccess", ["bool", []]);'
"String($.CGPreflightScreenCaptureAccess());",
language="JavaScript",
)
!= "true"
):
# Register in the Screen Recording pane / trigger the prompt.
osascript(
'ObjC.bindFunction("CGRequestScreenCaptureAccess",'
' ["bool", []]);'
"String($.CGRequestScreenCaptureAccess());",
language="JavaScript",
)
sys.exit(
"No Screen Recording permission, so screencapture cannot "
"image other apps' windows.\n"
"Grant it to your terminal in System Settings -> "
"Privacy & Security -> Screen & System Audio Recording, "
"then quit and reopen the terminal and re-run this script."
)
def window_id(owner_name):
"""Return the CGWindowID of *owner_name*'s frontmost normal window."""
jxa = f"""
ObjC.import("CoreGraphics");
const info = $.CGWindowListCopyWindowInfo(
$.kCGWindowListOptionOnScreenOnly
| $.kCGWindowListExcludeDesktopElements,
$.kCGNullWindowID);
const wins = ObjC.deepUnwrap(ObjC.castRefToObject(info));
const win = wins.find(w =>
w.kCGWindowOwnerName === {owner_name!r} && w.kCGWindowLayer === 0);
win ? String(win.kCGWindowNumber) : "";
"""
result = osascript(jxa, language="JavaScript")
if not result:
raise RuntimeError(f"no on-screen window found for {owner_name}")
return result
def capture(name, out_dir, owner="Installer"):
"""Screenshot the app's front window into out_dir/name."""
osascript(f'tell application "{owner}" to activate')
time.sleep(0.5)
path = out_dir / name
result = subprocess.run(
# No -o: keep the window drop shadow on a transparent canvas,
# like an interactive Cmd-Shift-4 + Space capture.
["screencapture", "-x", "-l", window_id(owner), str(path)],
capture_output=True,
text=True,
)
if result.returncode != 0:
raise RuntimeError(
f"screencapture failed for {name}: "
f"{result.stderr.strip() or result.stdout.strip()}"
)
# Retina displays capture at 2x; downscale to the 800px convention.
with Image.open(path) as im:
if im.width > TARGET_WIDTH:
im.resize(
(TARGET_WIDTH, round(im.height * TARGET_WIDTH / im.width)),
Image.Resampling.LANCZOS,
).save(path)
print(f" captured {path}")
def main():
parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
parser.add_argument("pkg", type=Path, help="python.org installer .pkg")
parser.add_argument(
"--out-dir",
type=Path,
default=Path(__file__).parent / "Doc" / "using",
help="where to write the PNGs (default: Doc/using)",
)
args = parser.parse_args()
out_dir = args.out_dir
if not out_dir.is_dir():
sys.exit(f"not a directory: {out_dir}")
ensure_permissions()
# Force Installer alone into light mode so captures match the docs,
# whatever the system appearance; undo it again on exit. Only takes
# effect on launch, so make sure Installer starts fresh.
subprocess.run(
[
"defaults",
"write",
"com.apple.installer",
"NSRequiresAquaSystemAppearance",
"-bool",
"yes",
],
check=True,
)
atexit.register(
subprocess.run,
["defaults", "delete", "com.apple.installer", "NSRequiresAquaSystemAppearance"],
capture_output=True,
)
subprocess.run(["pkill", "-x", "Installer"], capture_output=True)
time.sleep(1)
print(f"Opening {args.pkg}...")
subprocess.run(["open", str(args.pkg)], check=True)
# 01 Introduction
wait_for('exists button "Continue" of window 1', what="Introduction")
time.sleep(1) # let the pane finish rendering
capture("mac_installer_01_introduction.png", out_dir)
try_click(["Continue"])
# 02 Read Me
time.sleep(1)
capture("mac_installer_02_readme.png", out_dir)
try_click(["Continue"])
# 03 License, captured with the Agree/Disagree sheet showing, as in
# the docs ("You will then need to Agree..."). The Continue click
# can block while the sheet is up, hence the timeout.
time.sleep(1)
try_click(["Continue"], timeout=10)
# NB: each "exists" needs its own parentheses, or AppleScript parses
# "exists A or exists B" as "exists (A or exists B)" and errors.
wait_for(
"(exists sheet 1 of window 1) " 'or (exists button "Install" of window 1)',
what="license sheet",
)
if installer_ui("return (exists sheet 1 of window 1)") == "true":
time.sleep(0.5) # let the sheet animation settle
capture("mac_installer_03_license.png", out_dir)
try_click(["Agree"], target="sheet 1 of window 1")
else:
print(
" !! the license sheet never appeared; "
"mac_installer_03_license.png was NOT captured",
file=sys.stderr,
)
# A "Select a Destination" pane may appear; it is not documented,
# so just continue past it if so.
wait_for(
'(exists button "Install" of window 1) '
'or (exists button "Continue" of window 1)',
what="Installation Type",
)
if installer_ui('return (exists button "Install" of window 1)') != "true":
try_click(["Continue"])
wait_for('exists button "Install" of window 1', what="Installation Type")
# 04 Installation Type
time.sleep(1)
capture("mac_installer_04_installation_type.png", out_dir)
# 05 Custom install, with the pip row selected so its description
# shows (matching the previous screenshot)
try_click(["Customize", "Customise"])
time.sleep(1)
select_package("Install or upgrade pip")
time.sleep(0.5)
capture("mac_installer_05_custom_install.png", out_dir)
# 09 The same pane with the free-threaded Python row selected and
# its description showing. The checkbox is ticked by default since
# 3.15; on older installers, tick it by hand before this capture.
select_package("Free-threaded Python")
time.sleep(0.5)
capture("mac_installer_09_custom_install_free_threaded.png", out_dir)
# Install (needs your admin password)
try_click(["Install"], timeout=10)
print(
" -> Enter your admin password in the dialog if prompted; "
"waiting for the installation to finish..."
)
# 06 Summary
wait_for('exists button "Close" of window 1', timeout=600, what="Summary")
time.sleep(1)
capture("mac_installer_06_summary.png", out_dir)
try_click(["Close"])
# Installer may offer to move the .pkg to the trash.
time.sleep(1)
try:
installer_ui('click button "Keep" of window 1')
except RuntimeError:
pass
print(
f"Done. Review the PNGs in {out_dir} and git diff before "
"committing; consider compressing them with:\n"
" oxipng --zopfli -o max --strip all --alpha "
f"{out_dir}/mac_installer_*.png"
)
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment