Three separate field notes here describe builds on a Mac going quietly wrong. Read side by side, they’re chapters of one story. If you build automation on a Mac - or ask an AI assistant to - this is the shelf to read first.

The Python you didn’t choose is 3.9

macOS ships Python 3.9 with its developer tools. Background jobs on a Mac often have to run without a virtual environment - so they run on that system Python. 3.9 is missing things modern code reaches for:

  • no match statements (3.10+)
  • no str | None union syntax (3.10+); use Optional[str] or the future-annotations import
  • no tomllib (3.11+); use the tomli package instead
  • some packages have formally dropped 3.9, though several still install and work

A script an assistant writes in today’s idiom can be correct everywhere except on the machine it has to run on.

TrapLooks likeCheck before it ships
Language ageModern Python syntax fails only when the job runs laterRun the script with the exact Python the job will use
Package locationImports work in the project shell, then fail in the backgroundAsk that Python where its user packages live
Framework timingCode that is valid at the prompt returns nothing at startupDelay app-framework calls until the event loop exists

The table is the whole note in miniature: the local test, the background job, and the Mac runtime can all be different machines wearing the same face.

Where pip actually puts things

Outside a virtual environment, pip install --user lands packages in the user site under ~/Library/Python/3.9/. A background job running on system Python can only import what’s in that user site or on the system path.

Dependencies installed into a project’s virtual environment don’t exist as far as the job is concerned. The import error arrives at 3 a.m., in a log nobody’s watching.

The two traps with their own notes

The other two quirks earned full write-ups, so here they’re cross-references:

  • Background services can’t reach iCloud Drive at all. A job whose code lives under ~/Documents (which iCloud quietly relocates) loads with status 0 and never runs. The silent-failure note has the fix: move the runtime files to a plain home folder.
  • Apple’s application object doesn’t exist until the event loop starts. Ask for it at the top of a file and Python hands you None. The timing-trap note has the working pattern: a 0.1-second timer.

Take the checklist

Before a Python background job ships on a Mac, four questions:

  1. Does it run on Python 3.9 exactly, or does it pin its own Python?
  2. Are its dependencies importable by the Python that will actually run it?
  3. Does its code live outside iCloud-managed folders?
  4. Does anything touch Apple’s frameworks before the event loop is up?

Each one cost a builder most of an evening. I shelved them together so the next builder falls once and reads once.