Analyze

Changed in version 3.0: FilterCriteria and TrendCriteria enums, plus the Analyzer wrapper and BaseEnum.from_cli helper, were replaced by TitleFilter and TrendAnalysis. Each former enum value is now a method on the respective class with its own signature.

class TitleFilter(titles: list[PatchTitle])[source]

Apply named filters over a list of PatchTitle.

Each filter is a method on the class. Library callers can chain construction and method call: TitleFilter(titles).most_installed(top_n=10). The CLI and patcher.core.patcher_client.PatcherClient.analyze() route through apply(), which maps a CLI-style string (e.g. "most-installed") onto the matching method.

Changed in version 3.0: Replaces the FilterCriteria enum and the Analyzer.filter_titles dispatch table. Each former enum value is now its own method with its own signature (e.g. below_threshold() accepts threshold; zero_completion() accepts no extra arguments).

Parameters:

titles (list[PatchTitle]) – PatchTitle objects to filter.

classmethod criteria() list[str][source]

List of CLI-flag-style names for every filter method on this class.

Return type:

list[str]

classmethod apply(titles: list[PatchTitle], criterion: str, *, threshold: float | None = None, top_n: int | None = None, where: dict | None = None) list[PatchTitle][source]

Resolve a CLI-style criterion string (e.g. "most-installed", "below-threshold") to the corresponding filter method and invoke it with whichever of threshold / top_n the method accepts.

When where is provided, the criterion is dispatched against a pre-filtered TitleFilter built via where(). Unknown keys in the where dict raise PatcherError cleanly.

Used by the CLI’s analyze subcommand and by patcher.core.patcher_client.PatcherClient.analyze(). Library callers that already know which filter they want should construct directly: TitleFilter(titles).most_installed(top_n=10).

Parameters:
Return type:

list[PatchTitle]

most_installed(top_n: int | None = None) list[PatchTitle][source]

Sort by total_hosts descending. top_n caps the result.

Parameters:

top_n (int | None)

Return type:

list[PatchTitle]

least_installed(top_n: int | None = None) list[PatchTitle][source]

Sort by total_hosts ascending. top_n caps the result.

Parameters:

top_n (int | None)

Return type:

list[PatchTitle]

oldest_least_complete(top_n: int | None = None) list[PatchTitle][source]

Sort by released then completion_percent ascending.

Parameters:

top_n (int | None)

Return type:

list[PatchTitle]

below_threshold(threshold: float = 70.0) list[PatchTitle][source]

Titles with completion_percent strictly below threshold. Sorted by completion ascending. All matches are returned (no top_n cap).

Parameters:

threshold (float)

Return type:

list[PatchTitle]

high_missing(top_n: int | None = None) list[PatchTitle][source]

Titles where missing_patch exceeds 50% of total_hosts.

Parameters:

top_n (int | None)

Return type:

list[PatchTitle]

recent_release(top_n: int | None = None) list[PatchTitle][source]

Titles released within the last week, sorted newest first.

Parameters:

top_n (int | None)

Return type:

list[PatchTitle]

zero_completion() list[PatchTitle][source]

Titles with completion_percent exactly zero. No top_n cap.

Return type:

list[PatchTitle]

top_performers(top_n: int | None = None) list[PatchTitle][source]

Titles with completion_percent above 90, sorted descending.

Parameters:

top_n (int | None)

Return type:

list[PatchTitle]

installomator(top_n: int | None = None) list[PatchTitle][source]

Titles that carry one or more Installomator labels.

Parameters:

top_n (int | None)

Return type:

list[PatchTitle]

impact_weighted_risk(top_n: int | None = None) list[PatchTitle][source]

Rank titles by missing_patch * days_since_release descending.

A title that’s been out for months and still has many unpatched hosts scores higher than a fresh release with the same gap, since the older title represents accumulated exposure. Titles whose released cannot be parsed are skipped with a debug log.

Parameters:

top_n (int | None) – Optional cap on result length.

Returns:

Titles sorted by score descending, capped to top_n.

Return type:

list[PatchTitle]

coverage_gaps(top_n: int | None = None) list[PatchTitle][source]

Titles with no Installomator label and no Homebrew cask match, sorted by missing_patch descending (worst non-covered first).

Parameters:

top_n (int | None) – Optional cap on result length.

Returns:

Uncovered titles sorted by missing-patch count.

Return type:

list[PatchTitle]

where(*, min_compliance: float | None = None, min_hosts: int | None = None, released_after: str | None = None) TitleFilter[source]

Return a new TitleFilter whose titles satisfy every supplied constraint. Chainable: TitleFilter(titles).where(min_hosts=10).coverage_gaps().

All kwargs are optional and compose as AND. released_after accepts an ISO date string (YYYY-MM-DD); titles whose released cannot be parsed are skipped with a debug log when this kwarg is set.

Parameters:
  • min_compliance (float | None) – Keep titles with completion_percent >= min_compliance.

  • min_hosts (int | None) – Keep titles with total_hosts >= min_hosts.

  • released_after (str | None) – ISO date string; keep titles released on or after this date.

Returns:

A new TitleFilter instance. The caller is not mutated.

Return type:

TitleFilter

class TrendAnalysis(datasets: list[DataFrame | Path | str])[source]

Compose trend analyses across multiple cached patch datasets.

Combines datasets on construction and exposes one method per trend criterion. Library callers can chain: TrendAnalysis(datasets).patch_adoption(). The CLI and patcher.core.patcher_client.PatcherClient.analyze_trend() route through apply(), which maps a CLI-style string onto the matching method.

Changed in version 3.0: Replaces the TrendCriteria enum and Analyzer.timelapse. Each former enum value is now its own method.

Parameters:

datasets (list[DataFrame | Path | str]) – Pickle / Excel paths or pre-loaded DataFrames. Must contain at least two datasets to produce a meaningful trend.

Raises:

PatcherError – If fewer than two datasets are provided, or if a dataset has an unsupported file type.

classmethod from_cache(data_manager: DataManager) TrendAnalysis[source]

Construct from every cached snapshot the DataManager has on disk.

Parameters:

data_manager (DataManager)

Return type:

TrendAnalysis

classmethod criteria() list[str][source]

List of CLI-flag-style names for every trend method on this class.

Return type:

list[str]

classmethod apply(datasets: list[DataFrame | Path | str], criterion: str, *, sort_by: str | None = None, ascending: bool = True) DataFrame[source]

Resolve a CLI-style criterion string to the corresponding trend method and invoke it.

Used by the CLI and patcher.core.patcher_client.PatcherClient.analyze_trend(). Library callers that already know which trend they want should construct directly: TrendAnalysis(datasets).patch_adoption().

Parameters:
Return type:

DataFrame

patch_adoption(sort_by: str | None = None, ascending: bool = True) DataFrame[source]

Per-title average completion plus the most recent release date.

Returns:

DataFrame with columns Title, Average Completion, Most Recent Release.

Parameters:
  • sort_by (str | None)

  • ascending (bool)

Return type:

DataFrame

release_frequency(sort_by: str | None = None, ascending: bool = True) DataFrame[source]

Count of distinct release dates per title across the snapshots.

Returns:

DataFrame with columns Title, Release Count.

Parameters:
  • sort_by (str | None)

  • ascending (bool)

Return type:

DataFrame

Per-release-date average completion across the snapshots.

Returns:

DataFrame with columns Release Date, Title, Average Completion.

Parameters:
  • sort_by (str | None)

  • ascending (bool)

Return type:

DataFrame

time_to_patch(threshold: float = 80.0, sort_by: str | None = None, ascending: bool = True) DataFrame[source]

For each title, average the days between release and first snapshot in which completion_percent crosses threshold.

Walks snapshots in chronological order. A title’s measurement is the gap (in days) between its parsed released date and the snapshot mtime when the threshold is first met. Titles that never crossed the threshold across any snapshot are excluded.

Parameters:
  • threshold (float) – Completion-percent value the title must reach.

  • sort_by (str | None) – Optional column name to sort the result by.

  • ascending (bool) – Sort direction for sort_by.

Returns:

DataFrame with columns Title, Avg Days to Threshold, Sample Size, Threshold.

Return type:

DataFrame

stale_apps(min_snapshots: int = 3, sort_by: str | None = None, ascending: bool = True) DataFrame[source]

Identify titles whose latest_version and completion_percent haven’t moved across the last min_snapshots snapshots.

A title needs to appear in at least min_snapshots snapshots to qualify. Completion percent is rounded to two decimals before the identity check. “Days Stale” measures the span between the oldest and newest of the considered snapshots.

Parameters:
  • min_snapshots (int) – Lookback length and minimum samples required.

  • sort_by (str | None) – Optional column name to sort the result by.

  • ascending (bool) – Sort direction for sort_by.

Returns:

DataFrame with columns Title, Latest Version, Completion %, Days Stale.

Return type:

DataFrame

class Diff(from_snapshot: DataFrame | list[PatchTitle] | Path | str, to_snapshot: DataFrame | list[PatchTitle] | Path | str, *, from_label: str | None = None, to_label: str | None = None)[source]

Pairwise comparison between two patch-state snapshots.

Each side can be a list of PatchTitle (typical for “live” data from fetch_patches()), a pandas DataFrame, or a path to a cached pickle / Excel file. The comparison joins by title_id and surfaces titles added, removed, or changed (completion percent, hosts patched, total hosts, latest version).

Added in version 3.1.

Parameters:
  • from_snapshot (~pandas.DataFrame | list[PatchTitle] | ~pathlib.Path | str) – The earlier snapshot (the “before” side).

  • to_snapshot (~pandas.DataFrame | list[PatchTitle] | ~pathlib.Path | str) – The later snapshot (the “after” side).

  • from_label (str | None) – Optional human-readable label for the from-side (e.g. "snapshot-2026-05-20T04:00:00"). Derived from the input type when omitted.

  • to_label (str | None) – Optional label for the to-side (e.g. "live"). Derived from the input type when omitted.

compute() DiffResult[source]

Run the comparison and return a DiffResult.

Return type:

DiffResult

classmethod from_cache(data_manager: DataManager, *, since: timedelta | None = None, all_time: bool = False, between: tuple[date, date] | None = None) Diff[source]

Construct a Diff from the local cache only (no live fetch).

With no overrides, defaults to the two most recent cached snapshots (from is second-most-recent, to is most-recent).

Parameters:
  • data_manager (DataManager) – The DataManager whose cache to read.

  • since (timedelta | None) – When set, from is the earliest snapshot in the trailing window. to stays the most recent.

  • all_time (bool) – When True, from is the earliest snapshot ever cached. to stays the most recent.

  • between (tuple[date, date] | None) – When set, both sides come from cached snapshots chosen as the closest mtime to each given date.

Raises:

PatcherError – If no cached snapshots exist, or fewer than 2 snapshots are available for the no-fetch case.

Return type:

Diff

classmethod live_vs_cache(live_titles: list[PatchTitle], data_manager: DataManager, *, since: timedelta | None = None, all_time: bool = False) Diff[source]

Construct a Diff comparing a freshly-fetched live state against a cached snapshot.

With no overrides, from is the most-recent cached snapshot.

Parameters:
  • live_titles (list[PatchTitle]) – PatchTitle objects from a recent fetch_patches call.

  • data_manager (DataManager) – The DataManager whose cache to read.

  • since (timedelta | None) – When set, from is the earliest snapshot in the trailing window.

  • all_time (bool) – When True, from is the earliest snapshot ever cached.

Raises:

PatcherError – If no cached snapshots are available, or no snapshots fall within the requested window.

Return type:

Diff

class DiffResult(*, from_label: str, to_label: str, from_count: int, to_count: int, added: list[PatchTitle], removed: list[PatchTitle], changed: list[TitleChange], unchanged_count: int, avg_completion_delta: float | None = None, version_bumps: list[TitleChange] = [])[source]

Pairwise comparison between two patch-state snapshots.

Captures titles added/removed/changed and aggregate deltas. The unchanged_count is a count rather than a list because unchanged titles are typically the majority and the list would balloon the result without adding signal. Callers needing the full unchanged set still have access to the underlying snapshots.

Create a new model by parsing and validating input data from keyword arguments.

Raises [ValidationError][pydantic_core.ValidationError] if the input data cannot be validated to form a valid model.

self is explicitly positional-only to allow self as a field name.

Parameters:
model_config = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class TitleChange(*, title: str, title_id: str, from_completion_percent: float, to_completion_percent: float, completion_delta: float, from_hosts_patched: int, to_hosts_patched: int, from_total_hosts: int, to_total_hosts: int, from_latest_version: str | None = None, to_latest_version: str | None = None, version_changed: bool)[source]

A patch title present in both snapshots with at least one field different.

Create a new model by parsing and validating input data from keyword arguments.

Raises [ValidationError][pydantic_core.ValidationError] if the input data cannot be validated to form a valid model.

self is explicitly positional-only to allow self as a field name.

Parameters:
  • title (str)

  • title_id (str)

  • from_completion_percent (float)

  • to_completion_percent (float)

  • completion_delta (float)

  • from_hosts_patched (int)

  • to_hosts_patched (int)

  • from_total_hosts (int)

  • to_total_hosts (int)

  • from_latest_version (str | None)

  • to_latest_version (str | None)

  • version_changed (bool)

model_config = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

async sort_titles(titles: list[PatchTitle], sort_key: str) list[PatchTitle][source]

Sort titles by the named attribute (case-insensitive, spaces tolerated).

Offloads the sort to a thread so the event loop stays responsive on large lists.

Parameters:
  • titles (list[PatchTitle]) – PatchTitle objects to sort.

  • sort_key (str) – Attribute name to sort by (e.g. "released", "completion percent"). Normalized to lowercase + underscores.

Raises:

PatcherError – If the attribute does not exist on PatchTitle.

Return type:

list[PatchTitle]

async omit_recent(titles: list[PatchTitle], hours: int = 48) list[PatchTitle][source]

Return titles with any released within the past hours hours dropped.

Parameters:
  • titles (list[PatchTitle]) – PatchTitle objects to filter.

  • hours (int) – Lookback window in hours. Defaults to 48.

Return type:

list[PatchTitle]

async append_ios_status(titles: list[PatchTitle], api: JamfClient) list[PatchTitle][source]

Fetch iOS device/version data via api + the SOFA feed and append per-iOS-version PatchTitle summaries to titles.

Parameters:
  • titles (list[PatchTitle]) – Existing PatchTitle list to extend.

  • api (JamfClient) – Configured JamfClient used for the device/version and SOFA feed calls.

Raises:

PatcherError – If the device list, OS versions, or SOFA feed cannot be fetched.

Return type:

list[PatchTitle]

async match_titles(patch_titles: list[PatchTitle], jamf: JamfClient, api: PatcherAPIClient, *, threshold: int = 85, review_file: Path | None = PosixPath('/home/docs/Library/Application Support/Patcher/unmatched_apps.json'), include_homebrew: bool = False) None[source]

Match each PatchTitle against the API catalog and populate coverage stubs for matches.

Installomator-sourced matches populate install_label with Label stubs. When include_homebrew is set, the candidate slug set also includes Homebrew Cask-sourced entries, and matches against those populate homebrew_cask with CaskMatch stubs; a dual-source slug populates both fields.

Mutates the input list in place. Titles that pattern-match the module’s _IGNORED_TITLES list are skipped silently.

Parameters:
  • patch_titles (list[PatchTitle]) – The list of PatchTitle objects to match.

  • jamf (JamfClient) – Configured JamfClient. Used for get_app_names() to retrieve per-title Jamf app-name lists.

  • api (PatcherAPIClient) – PatcherAPIClient pointed at the catalog. Pages GET /apps for the installomator source (and homebrew_cask when include_homebrew is set).

  • threshold (int) – Fuzzy-match score cutoff (rapidfuzz ratio, 0–100). Defaults to 85, matching InstallomatorClient’s historical value.

  • review_file (Path | None) – Path to write a JSON file of unmatched patch titles for manual review. None disables the review-file write. Defaults to ~/Library/Application Support/Patcher/unmatched_apps.json.

  • include_homebrew (bool) – If True, widen matching to the Homebrew Cask source and populate PatchTitle.homebrew_cask for Cask matches. Defaults to False (Installomator-only, the historical behavior).

Return type:

None