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 |
|---|---|---|
|
All Jamf Pro API traffic |
|
|
Patcher catalog reads (matching, label enrichment); |
|
|
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:
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.
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ΒΆ
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.