Library¶
Everything patcherctl does, as importable async Python.
The same domain code that backs every patcherctl subcommand is reachable through one class, PatcherClient. Import it, hand it Jamf credentials, and call methods that fetch, analyze, and export patch data. Nothing the CLI does is off-limits to a script.
Wiring Patcher into a Python automation rather than shelling out to patcherctl.
You want typed return values to filter and transform in code.
Running on a host where you pass credentials directly instead of the setup wizard.
Instantiation¶
Library callers can pass Jamf credentials directly when constructing PatcherClient objects. These clients are async context managers, so to release the underlying API connections cleanly, be sure to use async with.
PatcherClient instance in async context¶import asyncio
from patcher import PatcherClient
async def main():
async with PatcherClient(
client_id="...",
client_secret="...",
server="https://yourorg.jamfcloud.com",
) as patcher:
... # work happens here; pools close on exit
asyncio.run(main())
Construction Options¶
client_id,client_secret,server(required)Jamf API credentials
concurrencyMax concurrent Jamf API requests (Default: 5)
enable_installomatorSet to
Falseto skip the catalog client entirely. (Default: True)disable_cacheDisable on-disk caching. Useful for stateless / CI runs (Default: False)
__init__ keyword can be passed as an override¶async with PatcherClient(
client_id="...",
client_secret="...",
server="https://yourorg.jamfcloud.com",
concurrency=10,
enable_installomator=False,
disable_cache=True,
) as patcher:
...
Export¶
The fetch_patches() method is the one call that gathers everything a report needs. It pulls policies and summaries from Jamf and returns a list of PatchTitle objects. The export() method then writes those titles to one or more formats and returns a mapping of format-to-output-path. To export all four formats (Excel, PDF, JSON, HTML), omit the formats argument entirely.
from pathlib import Path
from patcher import PatcherClient
async with PatcherClient.from_state() as patcher:
titles = await patcher.fetch_patches(
sort_by="Released",
omit_recent_hours=48,
include_ios=True,
)
await patcher.export(
titles,
output_dir=Path("~/reports").expanduser(),
formats={"pdf", "json"},
date_format="%B %Y",
)
Keyword arguments mirror the CLI’s flags.
include_ios=TrueAppend per-iOS-version summaries
sort_by="released"Order the result
omit_recent_hours=24Drop titles released in the last day
match_installomator=FalseSkip catalog match entirely
Customizing Report Appearance¶
PDF report styling (header text, footer text, custom font, logo, HTML header color) is configured via Patcher’s property list. UI configuration only applies to PDF and HTML formats; Excel and JSON exports render correctly without it. See Patcher’s property list file for the full plist schema and valid keys.
iOS Device Data¶
Passing include_ios=True appends iOS / mobile device data to the report so you can see what’s running on your fleet alongside the macOS patch coverage. Behind the scenes Patcher calls three Jamf APIs:
Pulls the IDs of all enrolled mobile devices.
Resolves each ID to its current OS version.
Fetches the latest released iOS/iPadOS versions from SOFA to determine version recency.
Homebrew Cask Matching¶
Constructing the client with enable_homebrew=True widens catalog matching to Homebrew Cask’s catalog, which covers apps that carry no Installomator label. Installomator matches are assigned to each title’s install_label attribute, Cask matches are assigned to each title’s homebrew_cask attribute.
from pathlib import Path
from patcher import PatcherClient
async with PatcherClient.from_state(enable_homebrew=True) as patcher:
titles = await patcher.fetch_patches()
# titles[n].homebrew_cask holds CaskMatch stubs for Cask-covered apps
await patcher.export(titles, output_dir=Path("~/reports").expanduser())
Disabling Installomator Matching¶
Note
Explicit keyword arguments take precedence over property list values.
PatcherClient with enable_installomator=False to turn the catalog client off entirely.¶patcher = PatcherClient(
client_id=...,
client_secret=...,
server=...,
enable_installomator=False,
)
With matching disabled, patch title fetching never calls the matching algorithm. install_label field on every patch title stays empty.
Analyze¶
analyze() filters and sorts the titles you fetched against a named criterion, the same criteria the CLI exposes (for example "most-installed" or "below-threshold"). For type-checked, autocomplete-friendly access, construct TitleFilter directly instead of passing a string.
from patcher import PatcherClient, TitleFilter
async with PatcherClient.from_state() as patcher:
titles = await patcher.fetch_patches()
# PatcherClient.analyze: kebab-case criterion string
below = await patcher.analyze(titles, criteria="below-threshold", threshold=50.0)
# Or call TitleFilter methods directly (same result, less indirection)
least = TitleFilter(titles).least_installed(top_n=5)
high_missing = TitleFilter(titles).high_missing(top_n=10)
# Filter a saved Excel report directly (skip fetching patches)
stale = await patcher.analyze_excel(
"/path/to/report.xlsx",
criteria="most-installed",
)
Criteria¶
Two criteria families drive analyze, used in different contexts.
Important
Panda’s Dataframes are returned when perform trend analysis.
async with PatcherClient.from_state() as patcher:
trend = await patcher.analyze_trend("patch-adoption")
print(trend.head())
most-installedSoftware titles with the highest number of total installations
least-installedTop N least-installed titles (default 5)
oldest-least-completeOldest patches with the lowest completion percent
below-thresholdTitles with completion below the configured threshold (default 70%)
recent-releasePatches released in the last week
zero-completionTitles with 0% completion
top-performersTitles with completion above 90%
high-missingTitles where missing patches are >50% of total hosts
installomatorTitles that match an Installomator label
patch-adoptionCompletion rates over time for each software title
release-frequencyFrequency of updates per software title
completion-trendsCorrelation between release dates and completion percentages
CLI criteria names are dash-flexible, but library method names use the underscore form (TitleFilter(titles).most_installed()).
See also
For full method signatures see TitleFilter and TrendAnalysis.
Generating a Summary¶
To write an HTML version of the analysis alongside the returned DataFrame, pass save_to=... when analyzing trends.
async with PatcherClient.from_state() as patcher:
trend = await patcher.analyze_trend(
"patch-adoption",
save_to="~/Reports/trend-adoption.html",
)
The DataFrame is returned regardless of whether a summary is generated or not. No file is written if the DataFrame is empty (e.g. no cached data matches the criterion).
Diff¶
Compare two patch-state snapshots against each other to determine differences between them. This method reuses the same cache snapshots, so it works against history Patcher has already been collecting.
from datetime import date, timedelta
from patcher import PatcherClient
async with PatcherClient.from_state() as patcher:
# Live vs. most recent cache
result = await patcher.diff()
# Trailing 30 days
result = await patcher.diff(since=timedelta(days=30))
# Two specific cached dates
result = await patcher.diff(
between=(date(2026, 4, 1), date(2026, 5, 1)),
)
# Cache-only
result = await patcher.diff(no_fetch=True, since=timedelta(days=7))
print(f"{result.from_label} → {result.to_label}")
print(f"added: {len(result.added)}, removed: {len(result.removed)}")
for change in result.changed:
print(f" {change.title}: {change.from_completion_percent:.1f}% → {change.to_completion_percent:.1f}%")
Tip
To compare two snapshots, construct a Diff directly. Each side can be a PatchTitle list, a DataFrame, or a path to a .pkl/.xlsx export, as long as it’s a Patcher-produced report. See Diff for full source reference.
What Gets Compared¶
A title is changed if completion percent, hosts patched, total hosts, or latest version differ between the two snapshots. Released date and Installomator label changes are intentionally ignored. A TitleChange row carries both before/after values plus the deltas, so consumers don’t need to recompute.
The flag arguments (since, all_time, between, no_fetch) select which two snapshots get compared. See the CLI’s snapshot-selection table for the full matrix.
Drift¶
detect_drift() reports where upstream catalog sources disagree on the current version. It works even when enable_installomator=False; it constructs the catalog client on demand. The catalog endpoints are public, so no credentials are required.
from patcher import PatcherClient
async with PatcherClient.from_state() as patcher:
# List drift across the catalog
response = await patcher.detect_drift()
print(f"{response.total_with_drift} of {response.total_scanned} apps drifted")
for entry in response.entries:
leader = entry.leader or "unparseable"
print(f" {entry.slug}: leader={leader}")
for v in entry.versions:
print(f" {v.source}: {v.version} ({'ok' if v.parsed_ok else '?'})")
# Inspect one app
one = await patcher.detect_drift(slug="slack")
if one is None:
print("Slack has no drift (or doesn't exist in the catalog).")
else:
print(f"Slack: {one.leader} is ahead of {one.laggard}")
What Gets Returned¶
Every catalog source independently reports a current version for each app (Installomator’s appNewVersion, Homebrew Cask’s version). When they disagree, one source is probably silently stuck. A DriftEntry carries the slug, name, vendor, every source’s reported version, and a leader/laggard pair (the highest and lowest parsed versions). Both are None when any version couldn’t be parsed, the raw versions are still in versions so you can render the disagreement without ordering it.
DriftEntry(
slug="slack",
name="Slack",
vendor="Slack",
versions=[
SourceVersion(source="installomator", version="4.32.0", parsed_ok=True),
SourceVersion(source="homebrew_cask", version="4.40.0", parsed_ok=True),
],
leader="homebrew_cask",
laggard="installomator",
)
The list endpoint returns a DriftResponse with total_scanned (apps with at least two versioned sources), total_with_drift (the filtered count of disagreements), and the page of entries.
Reset¶
The reset() method mirrors the CLI’s four reset kinds, but only cache is broadly useful for library usage. It empties the on-disk patch cache and works in any credential mode.
async with PatcherClient.from_state() as patcher:
await patcher.reset("cache")
The other three (UI, creds, full) clear keychain and property-list state, so they require keychain-backed credentials and raise PatcherError on a client built with in-memory credentials. Reach for those from the CLI.
End to End¶
Putting the pieces together: fetch, filter to the titles that are behind, and export just those to a PDF.
import asyncio
from patcher import PatcherClient
async def main():
async with PatcherClient(
client_id="...",
client_secret="...",
server="https://yourorg.jamfcloud.com",
) as patcher:
titles = await patcher.fetch_patches(sort_by="completion_percent")
behind = await patcher.analyze(titles, "below-threshold", threshold=70.0)
paths = await patcher.export(
behind,
output_dir="./reports",
formats={"pdf"},
report_title="Behind on Patching",
)
print(f"Wrote {paths['pdf']}")
asyncio.run(main())
Working with Individual Clients¶
If you only need a subset (Jamf without Installomator, or Installomator labels without Jamf credentials), instantiate the per-service clients directly.
JamfClient Standalone¶
from patcher import JamfClient
client = JamfClient.from_credentials(
client_id="...",
client_secret="...",
server="https://yourorg.jamfcloud.com",
)
try:
ids = await client.get_device_ids()
versions = await client.get_device_os_versions(ids)
finally:
await client.aclose()
JamfClient.from_credentials wraps credentials in an in-memory ConfigManager. No keyring backend, no disk I/O.
InstallomatorClient Standalone¶
Fetch and parse Installomator labels without any Jamf credentials:
from patcher import InstallomatorClient
iom = InstallomatorClient()
labels = await iom.get_labels()
firefox = await iom.get_label("firefox")
print(firefox.expected_team_id, firefox.download_url)
The client covers label discovery (list_available_labels), single-label fetch (get_label), and bulk fetch (get_labels). Matching Jamf patch titles against the Installomator catalog is a separate module-level function, patcher.core.matching.match_titles(), which PatcherClient.fetch_patches runs automatically against the public Patcher API catalog rather than fetching Labels.txt directly.