ArchitectureΒΆ

How Patcher is put together, and why.


Three internal packages, with the classes you’d actually reach for re-exported at the top level. patcherctl and PatcherClient run on the same domain code, so the CLI and the library always have the same features.

The Three Internal PackagesΒΆ

src/patcher/clients/

HTTP boundary. One wrapper per external service

src/patcher/core/

Domain logic. No HTTP, no I/O outside of data manager

src/patcher/cli/

Interactive surface and CLI entry point

Package structure
src/patcher/
β”œβ”€β”€ clients/  
β”‚   β”œβ”€β”€ __init__.py         # HTTPClient (httpx + truststore base)
β”‚   β”œβ”€β”€ jamf.py             # JamfClient (Jamf Pro API)
β”‚   β”œβ”€β”€ patcher_api.py      # PatcherAPIClient (api.patcherctl.dev catalog)
β”‚   β”œβ”€β”€ installomator.py    # InstallomatorClient (label fetcher, standalone)
β”‚   └── token_manager.py    # OAuth token lifecycle for Jamf
β”‚
β”œβ”€β”€ core/  
β”‚   β”œβ”€β”€ patcher_client.py   # PatcherClient (the headline composer)
β”‚   β”œβ”€β”€ matching.py         # Jamf title β†’ catalog slug matching pipeline
β”‚   β”œβ”€β”€ analyze.py          # TitleFilter / TrendAnalysis
β”‚   β”œβ”€β”€ data_manager.py     # On-disk patch-data cache + export pipeline
β”‚   β”œβ”€β”€ pdf_report.py       # PDF generation
β”‚   β”œβ”€β”€ config_manager.py   # Credential resolution (keyring or in-memory)
β”‚   β”œβ”€β”€ plist_manager.py    # macOS plist read/write (no UI)
β”‚   β”œβ”€β”€ exceptions.py       # PatcherError + subclasses
β”‚   └── models/             # Pydantic 2 models (PatchTitle, Label, etc.)
β”‚
└── cli/  
    β”œβ”€β”€ __init__.py         # click group, subcommand registrations
    β”œβ”€β”€ setup.py            # Interactive setup wizard
    β”œβ”€β”€ report.py           # CLI orchestration around PatcherClient
    β”œβ”€β”€ _console.py         # Rich console singletons + status spinner
    └── ui_manager.py       # PDF UI config (header/footer/font/logo) + interactive prompts

Imports only flow in one direction. core/ never reaches into cli/, and clients/ never reaches into either. CLI-only pieces like the status spinner, interactive prompts, and Rich-styled output all live in cli/. That one-way flow is what lets the library run on its own, without dragging in any CLI code.

Note

cli/setup.py is the one exception to this rule as it must import from clients/ and core/ to drive the setup wizard.

Entry PointΒΆ

PatcherClient is the main entry point. It owns three helpers that do the real work:

Attribute

Type

Responsibility

patcher.jamf

JamfClient

All Jamf Pro API traffic

patcher.api

PatcherAPIClient

Patcher catalog reads (matching, label enrichment); None when enable_installomator=False

patcher.data

DataManager

On-disk patch-data cache + export pipeline

        graph LR
    PC[PatcherClient]
    PC --> JC[JamfClient]
    PC --> API[PatcherAPIClient]
    PC --> DM[DataManager]
    JC -.->|HTTPS| JAMF[(Jamf Pro)]
    API -.->|HTTPS| EXT[(api.patcherctl.dev)]
    DM -.->|read / write| FS[(~/Library/Caches/Patcher)]
    

Library ShortcutsΒΆ

from patcher import PatcherClient

async with PatcherClient(
    client_id="...",
    client_secret="...",
    server="https://myorg.jamfcloud.com",
) as patcher:
    titles = await patcher.fetch_patches()           # uses jamf + api
    filtered = await patcher.analyze(titles, ...)    # uses data
    await patcher.export(filtered, ...)              # uses data

The three top-level methods (fetch_patches, analyze, export) exist so library callers don’t have to remember which helper owns what. They’re shortcuts. The underlying objects are still right there if you want to wire things up by hand.

Matching PipelineΒΆ

The fetch_patches() runs match_titles() to enrich Jamf patch titles with Installomator labels via the Patcher API.

Fetch the slug set.

api.list_apps(source="installomator", limit=1000) returns every Installomator-tracked app in the stitched catalog.

Fetch per-title app names from Jamf.

Fetch per-title app names from Jamf via jamf.get_app_names.

Match against the slug set in three passes.

For each title, match its Jamf-side app names against the slug set in three passes: direct β†’ normalized (lowercase, dots stripped) β†’ fuzzy (rapidfuzz ratio, threshold 85).

Attach Label stubs to matched titles.

Attach name-only Label stubs to matched titles’ install_label list.

Run a second pass on unmatched titles.

Run a second pass on still-unmatched titles using the patch-title text itself.

Write out what never matched.

Write everything that never matched to ~/Library/Application Support/Patcher/unmatched_apps.json for review, and emit an InstallomatorWarning via Python’s warnings module so library callers can catch / escalate programmatically. The CLI installs warnings.simplefilter("always", InstallomatorWarning) at import time so end users always see the message.

        flowchart TD
    A[Jamf patch titles] --> P{For each title}
    SS[api.list_apps -> slug set] --> P
    AN[jamf.get_app_names -> app names] --> P
    P --> D[Direct match]
    D -- hit --> Z[Attach Label stub]
    D -- miss --> N[Normalized match]
    N -- hit --> Z
    N -- miss --> F[Fuzzy match]
    F -- hit --> Z
    F -- miss --> S[Second pass<br/>on patch title text]
    S -- hit --> Z
    S -- miss --> U[Write to unmatched_apps.json]
    

The slug set comes from the API in one HTTP call instead of pulling Labels.txt and a fragment per label straight from GitHub. Same match quality, lower latency, and fewer GitHub rate-limit headaches on a busy fleet.

CLI as a Thin WrapperΒΆ

patcherctl is built on asyncclick and lives entirely in cli/. Each subcommand follows the same shape:

Resolve config

credentials from keychain (or env vars in non-interactive mode), UI config from plist.

Construct a PatcherClient

typically with ConfigManager populated by step 1.

Call a top-level method

patcher.fetch_patches(), patcher.analyze(), etc.

Add CLI presentation

spinner via the status() helper in cli/_console.py, Rich-styled output, file persistence.

For example, patcherctl export resolves config, builds a PatcherClient, then delegates the actual fetch+export work to process_reports() in cli/report.py. The orchestration in cli/report.py is small. It threads CLI args into PatcherClient method calls and wraps the whole thing in a status spinner.

What this means in practice: if a feature can be expressed as β€œcall this method on PatcherClient,” it works the same from patcherctl and from a Python script. There’s no private CLI-only menu the library is locked out of.

Async-FirstΒΆ

Almost everything is asynchronous. Patcher’s HTTP transport is httpx backed by truststore. Per-object concurrency is capped at 5 by default (Jamf’s recommended ceiling). The concurrency cap is enforced at two layers:

asyncio.Semaphore(max_concurrency)

On every HTTPClient subclass, gates how many in-flight requests can be outstanding.

httpx.Limits(max_connections=max_concurrency)

On the underlying connection pool, bounds the actual TCP connection count.

In practice this means batch operations (e.g. fetching summaries for hundreds of titles) execute in flights of max_concurrency, with each batch waiting on the slowest request before kicking off the next.

Practical ConsequencesΒΆ

Prefer async with

Library callers should prefer async with PatcherClient(...) as patcher: so connection pools (Jamf, Patcher API) are released cleanly. If you can’t use async with (e.g. FastAPI startup hooks), call await patcher.aclose() manually.

The CLI stays on one event loop

The wizard prompts use asyncclick to stay inside the same event loop as the rest of Patcher.

Synchronous bridges

A few paths (e.g. the fonts download in UIConfigManager) use httpx.get directly with a truststore.SSLContext, so the same enterprise-CA story applies regardless of code path.

See also

For more about SSL verification and TLS trust, see SSL verification in the install guide.

Token LifecycleΒΆ

Jamf API access uses an OAuth2 bearer token issued by Jamf and TokenManager handles the full lifecycle:

On first call, exchanges credentials for a token.

On first call, exchanges client_id and client_secret for an access token. Then writes TOKEN and TOKEN_EXPIRATION to the macOS keychain.

On subsequent calls, checks expiration.

On subsequent calls, reads the cached token and checks its expiration with a 5-minute safety margin.

Refreshes proactively when the margin is hit.

Refreshes proactively when the margin is hit. Library callers never have to think about token expiry. The TokenManager handles the refresh before the token lapses.

Library callers using in_memory_credentials get the same flow without keychain writes. The token still gets cached on the JamfClient instance for the process lifetime.

Credential ResolutionΒΆ

ConfigManager resolves Jamf credentials from one of two places:

Keychain

The default for patcherctl.

Credentials live in the macOS login keychain under service name Patcher. The setup wizard writes them, and the TokenManager updates the bearer token and expiration automatically.

In-memory mode

Credentials are held only on the ConfigManager instance for the duration of the process.

Engaged automatically when the CLI is invoked with all three credentials or matching env vars are provided. See CI/CD & Non-Interactive Mode for the non-interactive flow.

Important

A single PatcherClient instance is bound to one Jamf instance via one credential set. If you need to hit two Jamf tenants from the same script, create two PatcherClient objects.

Public SurfaceΒΆ

The stable, importable surface is curated in patcherΒΆ
from patcher import (
    PatcherClient,         # top-level entry
    JamfClient,            # per-service clients
    InstallomatorClient,
    PatcherAPIClient,
    PatchTitle,            # return shapes
    PatchDevice,
    TitleFilter,           # analysis surface
    TrendAnalysis,
    PatcherError,          # exception base
    APIResponseError,      # ... and friends
    CredentialError,
    TokenError,
    InstallomatorWarning,
)

Anything reachable via deeper paths (patcher.cli.*, patcher.clients.HTTPClient, patcher.core.matching.match_titles, etc.) is importable but not considered part of the stable surface. Internal refactors may shift it. CLI-only objects (Setup, UIConfigManager) are intentionally not re-exported from patcher.

Patcher API ServiceΒΆ

patcher-api is a separate workspace member living under api/ in the monorepo. The service is reachable at https://api.patcherctl.dev. Catalog read endpoints are public.

Workspace layout
api/patcher_api/
β”œβ”€β”€ main.py                  # FastAPI app + lifespan + ETag middleware + /mcp mount
β”œβ”€β”€ config.py                # pydantic-settings, env-var-driven
β”œβ”€β”€ db.py                    # async SQLAlchemy engine + session, SQLite-tuned pragmas
β”œβ”€β”€ catalog.py               # SHA-256 of the catalog file for ETag derivation
β”œβ”€β”€ stitch.py                # canonical-row projection (see concept page)
β”œβ”€β”€ drift.py                 # cross-source version disagreement detection
β”œβ”€β”€ labels.py                # Installomator-shaped label fragment builder
β”œβ”€β”€ seed.py                  # smoke seed for empty DBs
β”œβ”€β”€ ingest/                  # per-source pullers (homebrew, autopkg, jamf, mas)
β”œβ”€β”€ installomator/           # parser, resolver, ingest (co-located subsystem)
β”œβ”€β”€ routes/
β”‚   β”œβ”€β”€ apps.py              # public catalog reads
β”‚   └── admin.py             # token-gated upserts from the macOS resolver runner
β”œβ”€β”€ mcp/                     # MCP server (Streamable HTTP, mounted at /mcp)
β”‚   β”œβ”€β”€ server.py
β”‚   β”œβ”€β”€ tools.py
β”‚   └── middleware.py        # Origin validation per MCP spec
β”œβ”€β”€ models/                  # SQLAlchemy ORM models
└── schemas/                 # Pydantic models for response serialization

The workspace exists so the catalog service can ship on its own schedule, independent of the patcherctl PyPI package. For library users, the API is what PatcherClient.fetch_patches queries through patcher.api. You don’t need to know about the workspace to use the library.

See also

Endpoints & Examples

For HTTP surface documentation.

Source Reference

For module-level autodocs.

Data FlowΒΆ

        flowchart LR
    subgraph EXT [Upstream sources]
      direction TB
      INST[Installomator]
      CASK[Homebrew Cask]
      AP[AutoPkg]
      MAS[Mac App Store]
      JAI[Jamf App Installers]
    end

    EXT --> ING[Ingest modules]
    ING --> SD[(app_source_details)]

    MACR[macOS GitHub<br/>Actions runner] -- POST /admin/labels/resolved --> ADM[Admin route]
    ADM --> SD

    SD --> ST[Stitch] --> APPS[(apps)]

    APPS --> APP_R[/apps* routes/]
    APPS --> MCP[/mcp tools/]

    APP_R -.->|JSON| CLIENT1[REST clients<br/>PatcherAPIClient]
    MCP -.->|JSON-RPC| CLIENT2[MCP clients<br/>Claude, Cursor, etc.]
    

Ingest LayerΒΆ

patcher_api/ingest/ has one module per upstream source. Each runs on the catalog-refresh schedule, pulls fresh data from its upstream, and writes rows into app_source_details for the stitch pipeline to consume. Adding a new source means writing one more ingest module that targets its own JSON column on app_source_details. No changes to the serving layer or the stitch logic structure required, just an additive entry in the per-field fallback chains.

See also

Stitching Pipeline

How source-detail rows become canonical app records via per-field fallbacks.

Resolution Pipeline

How Installomator’s dynamic shell fragments become concrete versions and URLs, across a Linux ingest server and a macOS runner.

Upstream Sources

How Patcher integrates with Installomator, Homebrew, AutoPkg, and Jamf App Installers.

Serving LayerΒΆ

A standard FastAPI app, with three nuances worth surfacing:

ETag middleware on /apps*

A weak ETag whose value is the SHA-256 of the underlying SQLite catalog file. The hash changes exactly when the catalog deploys (typically once per day, plus whenever a macOS runner pass uploads resolved values) and never otherwise. If-None-Match is parsed per RFC 7232, with both multi-value lists and the * wildcard honored. Revalidating clients short-circuit to 304 instantly between deploys, and Cloudflare absorbs the bulk of traffic in front of the origin.

Admin-route hardening

Deploy tokens used by the macOS resolver runner carry an expires_at column (90-day default for new tokens; legacy NULL means never expires) and the /admin/* routes apply a per-IP rate limit on top of the token check. The token gate is fail-closed: no token configured means the endpoint refuses every request, so a misconfigured host can’t accidentally expose a write surface.

Lifespan composition

The FastAPI lifespan owns DB initialization, optional seeding, and catalog SHA computation. It also enters the MCP session manager’s context so the mounted MCP sub-app’s task group is running during the serving window.

MCP LayerΒΆ

patcher_api/mcp/ is an ASGI sub-app mounted on the same FastAPI process at /mcp. It exposes the catalog over the Model Context Protocol so AI clients (Claude Desktop, Cursor, Claude Code, etc.) can query Patcher through natural-language tool calls. Same process, same SQLite, same lifespan.

See also

Working with Agents

Setup for AI clients

MCP Reference

Tool reference

Stitching

The conceptual relationship to the rest of the API (the underlying catalog the MCP tools read from)

Intentional Duplication: parse_fragmentΒΆ

The workspace keeps its own copy of parse_fragment at api/patcher_api/installomator/parser.py, deliberately duplicated from patcher.clients.installomator.parse_fragment so the API side can ship on its own schedule without pulling in the patcher library. The two copies are documented as intentional twins; if parsing behavior changes, update both on purpose. The Installomator subsystem is co-located under api/patcher_api/installomator/ for the same reason β€” keeping it in one place makes the divergence (or future re-convergence) easier to manage.