Last active
July 21, 2026 11:52
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| """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