patcherctl 1.4.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
patcher/__about__.py ADDED
@@ -0,0 +1,2 @@
1
+ __title__ = "patcher"
2
+ __version__ = "1.4.2"
patcher/__init__.py ADDED
File without changes
patcher/cli.py ADDED
@@ -0,0 +1,141 @@
1
+ import asyncio
2
+ from typing import Optional
3
+
4
+ import asyncclick as click
5
+
6
+ from .__about__ import __version__
7
+ from .client.api_client import ApiClient
8
+ from .client.config_manager import ConfigManager
9
+ from .client.report_manager import ReportManager
10
+ from .client.setup import Setup
11
+ from .client.token_manager import TokenManager
12
+ from .client.ui_manager import UIConfigManager
13
+ from .models.reports.excel_report import ExcelReport
14
+ from .models.reports.pdf_report import PDFReport
15
+ from .utils.animation import Animation
16
+ from .utils.logger import LogMe
17
+
18
+ DATE_FORMATS = {
19
+ "Month-Year": "%B %Y", # April 2024
20
+ "Month-Day-Year": "%B %d %Y", # April 21 2024
21
+ "Year-Month-Day": "%Y %B %d", # 2024 April 21
22
+ "Day-Month-Year": "%d %B %Y", # 16 April 2024
23
+ "Full": "%A %B %d %Y", # Thursday September 26 2013
24
+ }
25
+
26
+
27
+ @click.command()
28
+ @click.version_option(version=__version__)
29
+ @click.option(
30
+ "--path",
31
+ "-p",
32
+ type=click.Path(),
33
+ required=False, # Defaulting to false in favor of `--reset`
34
+ help="Path to save the report(s)",
35
+ )
36
+ @click.option(
37
+ "--pdf",
38
+ "-f",
39
+ is_flag=True,
40
+ help="Generate a PDF report along with Excel spreadsheet",
41
+ )
42
+ @click.option(
43
+ "--sort",
44
+ "-s",
45
+ type=click.STRING,
46
+ required=False,
47
+ help="Sort patch reports by a specified column.",
48
+ )
49
+ @click.option(
50
+ "--omit",
51
+ "-o",
52
+ is_flag=True,
53
+ help="Omit software titles with patches released in last 48 hours",
54
+ )
55
+ @click.option(
56
+ "--date-format",
57
+ "-d",
58
+ type=click.Choice(list(DATE_FORMATS.keys()), case_sensitive=False),
59
+ default="Month-Day-Year",
60
+ help="Specify the date format for the PDF header from predefined choices.",
61
+ )
62
+ @click.option(
63
+ "--ios",
64
+ "-m",
65
+ is_flag=True,
66
+ help="Include the amount of enrolled mobile devices on the latest version of their respective OS.",
67
+ )
68
+ @click.option(
69
+ "--concurrency",
70
+ type=click.INT,
71
+ default=5,
72
+ help="Set the maximum concurrency level for API calls.",
73
+ )
74
+ @click.option(
75
+ "--debug",
76
+ "-x",
77
+ is_flag=True,
78
+ default=False,
79
+ help="Enable debug logging to see detailed debug messages.",
80
+ )
81
+ @click.option(
82
+ "--reset",
83
+ "-r",
84
+ is_flag=True,
85
+ default=False,
86
+ help="Resets the setup process and triggers the setup assistant again.",
87
+ )
88
+ @click.pass_context
89
+ async def main(
90
+ ctx: click.Context,
91
+ path: str,
92
+ pdf: bool,
93
+ sort: Optional[str],
94
+ omit: bool,
95
+ date_format: str,
96
+ ios: bool,
97
+ concurrency: int,
98
+ debug: bool,
99
+ reset: bool,
100
+ ) -> None:
101
+ if not ctx.params["reset"] and not ctx.params["path"]:
102
+ raise click.UsageError("The --path option is required unless --reset is specified.")
103
+
104
+ log = LogMe(__name__, debug=debug)
105
+ animation = Animation(enable_animation=not debug)
106
+
107
+ config = ConfigManager()
108
+ ui_config = UIConfigManager()
109
+
110
+ setup = Setup(config=config, ui_config=ui_config)
111
+
112
+ async with animation.error_handling(log):
113
+ if not setup.completed:
114
+ await setup.prompt_method(animator=animation)
115
+ click.echo(click.style(text="Setup has completed successfully!", fg="green", bold=True))
116
+ click.echo("Patcher is now ready for use.")
117
+ click.echo("You can use the --help flag to view available options.")
118
+ click.echo("For more information, visit the project docs: https://patcher.liquidzoo.io")
119
+ return
120
+ elif reset:
121
+ await animation.update_msg("Resetting elements...")
122
+ await setup.reset()
123
+ click.echo(click.style(text="Reset has completed as expected!", fg="green", bold=True))
124
+ return
125
+
126
+ api_client = ApiClient(config, concurrency)
127
+ token_manager = TokenManager(config)
128
+ excel_report = ExcelReport()
129
+ pdf_report = PDFReport(ui_config)
130
+ api_client.set_concurrency(concurrency=concurrency)
131
+
132
+ patcher = ReportManager(
133
+ config, token_manager, api_client, excel_report, pdf_report, ui_config, debug
134
+ )
135
+
136
+ actual_format = DATE_FORMATS[date_format]
137
+ await patcher.process_reports(path, pdf, sort, omit, ios, actual_format)
138
+
139
+
140
+ if __name__ == "__main__":
141
+ asyncio.run(main())
@@ -0,0 +1,335 @@
1
+ import asyncio
2
+ import json
3
+ import subprocess
4
+ from typing import Dict, List, Optional, Tuple, Union
5
+
6
+ from ..models.jamf_client import ApiClientModel, ApiRoleModel
7
+ from ..utils import exceptions, logger
8
+
9
+
10
+ class BaseAPIClient:
11
+ """
12
+ The BaseAPIClient class controls concurrency settings and secure connections for *all* API calls.
13
+
14
+ This class forms the backbone of Patcher's ability to interact with external APIs.
15
+ It manages the number of API requests that can be made simultaneously, ensuring the tool is both
16
+ efficient and does not overload any servers.
17
+
18
+ .. warning::
19
+ Changing the max_concurrency value could lead to your Jamf server being unable to perform other basic tasks.
20
+ It is **strongly recommended** to limit API call concurrency to no more than 5 connections.
21
+ See `Jamf Developer Guide <https://developer.jamf.com/developer-guide/docs/jamf-pro-api-scalability-best-practices>`_ for more information.
22
+
23
+ :param max_concurrency: The maximum number of API requests that can be sent at once. Defaults to 5.
24
+ :type max_concurrency: int
25
+ """
26
+
27
+ def __init__(self, max_concurrency: int = 5):
28
+ self.max_concurrency = max_concurrency
29
+ self.semaphore = asyncio.Semaphore(max_concurrency)
30
+ self.default_headers = {"accept": "application/json", "Content-Type": "application/json"}
31
+ self.log = logger.LogMe(self.__class__.__name__)
32
+
33
+ @property
34
+ def concurrency(self) -> int:
35
+ """
36
+ Gets the current concurrency setting used by Patcher.
37
+
38
+ :return: The maximum number of concurrent API requests that can be made.
39
+ :rtype: int
40
+ """
41
+ return self.max_concurrency
42
+
43
+ def set_concurrency(self, concurrency: int) -> None:
44
+ """
45
+ Sets the maximum concurrency level for API calls.
46
+
47
+ This method allows you to set the maximum number of concurrent API calls
48
+ that can be made by the Jamf client. It is recommended to limit this value
49
+ to 5 connections to avoid overloading the Jamf server.
50
+
51
+ :param concurrency: The new maximum concurrency level.
52
+ :type concurrency: int
53
+ :raises ValueError: If the concurrency level is less than 1.
54
+ """
55
+ if concurrency < 1:
56
+ raise ValueError("Concurrency level must be at least 1.")
57
+ self.max_concurrency = concurrency
58
+
59
+ def _handle_status_code(
60
+ self, status_code: int, response_json: Optional[Dict]
61
+ ) -> Optional[Dict]:
62
+ """
63
+ Handles HTTP status codes and returns the appropriate response or raises errors.
64
+
65
+ :param status_code: The HTTP status code to evaluate.
66
+ :type status_code: int
67
+ :param response_json: The parsed JSON response from the API.
68
+ :type response_json: Optional[Dict]
69
+ :return: The response if JSON is successful, otherwise raises an exception.
70
+ :rtype: Optional[Dict]
71
+ """
72
+ if 200 <= status_code < 300:
73
+ return response_json
74
+ elif 400 <= status_code < 500:
75
+ self.log.error(
76
+ f"Client error ({status_code}): {response_json.get('errors', 'Unknown error')}"
77
+ )
78
+ raise exceptions.APIResponseError(
79
+ f"Client error ({status_code}): {response_json.get('errors', 'Unknown error')}"
80
+ )
81
+ elif 500 <= status_code < 600:
82
+ self.log.error(
83
+ f"Server error ({status_code}): {response_json.get('errors', 'Unknown error')}"
84
+ )
85
+ raise exceptions.APIResponseError(
86
+ f"Server error ({status_code}): {response_json.get('errors', 'Unknown error')}"
87
+ )
88
+ else:
89
+ self.log.error(f"Unexpected HTTP status code {status_code}: {response_json}")
90
+ raise exceptions.APIResponseError(
91
+ f"Unexpected HTTP status code {status_code}: {response_json}"
92
+ )
93
+
94
+ @staticmethod
95
+ def _format_headers(headers: Dict[str, str]) -> List[str]:
96
+ """
97
+ Formats headers properly for curl commands.
98
+
99
+ :param headers: Dictionary of headers to format.
100
+ :type headers: Dict[str, str]
101
+ :return: List of formatted headers.
102
+ :rtype: List[str]
103
+ """
104
+ formatted_headers = []
105
+ for k, v in headers.items():
106
+ formatted_headers.extend(["-H", f"{k}: {v}"])
107
+ return formatted_headers
108
+
109
+ async def execute(self, command: List[str]) -> Optional[Union[Dict, str]]:
110
+ """
111
+ Asynchronously executes a shell command using subprocess and returns the output.
112
+
113
+ This method leverages asyncio to run a command in a new subprocess. If the
114
+ command execution is unsuccessful (non-zero return code), an exception is raised.
115
+
116
+ .. note::
117
+ This method should be used for executing shell commands that are essential to the
118
+ functionality of the API client, such as invoking cURL commands for API calls.
119
+
120
+ :param command: A list representing the command and its arguments to be executed in the shell.
121
+ :type command: List[str]
122
+ :return: The standard output of the executed command decoded as a string, or None if there is an error.
123
+ :rtype: Optional[Union[Dict, str]]
124
+ :raises exceptions.ShellCommandError: If the command execution fails (returns a non-zero exit code).
125
+ """
126
+ process = await asyncio.create_subprocess_exec(
127
+ *command, stdout=subprocess.PIPE, stderr=subprocess.PIPE
128
+ )
129
+ stdout, stderr = await process.communicate()
130
+ if process.returncode != 0:
131
+ self.log.error(f"Error executing subprocess command: {stderr.decode()}")
132
+ raise exceptions.ShellCommandError(
133
+ reason=f"Error executing subprocess command: {stderr.decode()}"
134
+ )
135
+
136
+ return stdout.decode()
137
+
138
+ async def fetch_json(
139
+ self,
140
+ url: str,
141
+ headers: Optional[Dict[str, str]] = None,
142
+ method: str = "GET",
143
+ data: Optional[Dict[str, str]] = None,
144
+ ) -> Dict:
145
+ """
146
+ Asynchronously fetches JSON data from the specified URL using a specified HTTP method.
147
+
148
+ :param url: The URL to fetch data from.
149
+ :type url: str
150
+ :param headers: Optional headers to include in the request.
151
+ :type headers: Optional[Dict[str, str]]
152
+ :param method: HTTP method to use ("GET" or "POST"). Defaults to "GET".
153
+ :type method: str
154
+ :param data: Optional form data to include for POST request.
155
+ :type data: Optional[Dict[str, str]]
156
+ :return: The fetched JSON data as a dictionary, or None if the request fails.
157
+ :rtype: Optional[Dict]
158
+ """
159
+ final_headers = headers if headers else self.default_headers
160
+ header_string = self._format_headers(final_headers)
161
+
162
+ # By using the -w parameter with %{http_code}, we are appending the status code
163
+ # to the end of the API response. This is to handle cases where responses
164
+ # do not have 'httpStatus' keys by default.
165
+ command = [
166
+ "/usr/bin/curl",
167
+ "-s",
168
+ "-X",
169
+ method,
170
+ url,
171
+ *header_string,
172
+ "-w",
173
+ "\nSTATUS:%{http_code}",
174
+ ]
175
+
176
+ # Add form data for POST requests
177
+ if method.upper() == "POST" and data:
178
+ if final_headers.get("Content-Type") == "application/x-www-form-urlencoded":
179
+ # Format each item separately instead
180
+ form_data = [item for k, v in data.items() for item in ["-d", f"{k}={v}"]]
181
+ command.extend(form_data)
182
+ else:
183
+ # JSON is assumed for other content types
184
+ json_payload = json.dumps(data)
185
+ command.extend(["-d", json_payload])
186
+
187
+ async with self.semaphore:
188
+ output = await self.execute(command)
189
+
190
+ # Separate status code from body of response
191
+ try:
192
+ response_body, status_line = output.rsplit("\nSTATUS:", 1)
193
+ status_code = int(status_line.strip())
194
+ response_json = json.loads(response_body) # Re-parse body as JSON
195
+ except (json.JSONDecodeError, ValueError) as e:
196
+ self.log.error(f"Failed to decode JSON or parse status code from response: {e}")
197
+ raise exceptions.APIResponseError(
198
+ f"Failed to decode JSON or parse status code from response: {e}"
199
+ )
200
+
201
+ # Handle status code from response
202
+ return self._handle_status_code(status_code, response_json)
203
+
204
+ async def fetch_batch(
205
+ self, urls: List[str], headers: Optional[Dict[str, str]] = None
206
+ ) -> List[Optional[Dict]]:
207
+ """
208
+ Fetches JSON data in batches to respect the concurrency limit. Data is fetched
209
+ from each URL in the provided list, ensuring that no more than ``max_concurrency``
210
+ requests are sent concurrently.
211
+
212
+ :param urls: List of URLs to fetch data from.
213
+ :type urls: List[str]
214
+ :param headers:
215
+ :type headers: Optional[Dict[str, str]] = None
216
+ :return: A list of JSON dictionaries or None for URLs that fail to retrieve data.
217
+ :rtype: List[Optional[Dict]]
218
+ """
219
+ results = []
220
+ for i in range(0, len(urls), self.max_concurrency):
221
+ batch = urls[i : i + self.max_concurrency]
222
+ tasks = [self.fetch_json(url, headers=headers) for url in batch]
223
+ batch_results = await asyncio.gather(*tasks)
224
+ results.extend(batch_results)
225
+ return results
226
+
227
+ # API calls for client setup
228
+ async def fetch_basic_token(self, username: str, password: str, jamf_url: str) -> Optional[str]:
229
+ """
230
+ Asynchronously retrieves a bearer token using basic authentication.
231
+
232
+ This method is intended for initial setup to obtain client credentials for API clients and roles.
233
+ It should not be used for regular token retrieval after setup.
234
+
235
+ :param username: Username of admin Jamf Pro account for authentication. Not permanently stored, only used for initial token retrieval.
236
+ :type username: str
237
+ :param password: Password of admin Jamf Pro account. Not permanently stored, only used for initial token retrieval.
238
+ :type password: str
239
+ :param jamf_url: Jamf Server URL (same as ``server_url`` in :mod:`patcher.models.jamf_client` class).
240
+ :type jamf_url: str
241
+ :raises exceptions.TokenFetchError: If the call is unauthorized or unsuccessful.
242
+ :returns: True if the basic token was successfully retrieved, False if unauthorized (e.g., due to SSO).
243
+ :rtype: bool
244
+ """
245
+ token_url = f"{jamf_url}/api/v1/auth/token"
246
+ command = [
247
+ "/usr/bin/curl",
248
+ "-s",
249
+ "-u",
250
+ f"{username}:{password}",
251
+ "-H",
252
+ "accept: application/json",
253
+ "-X",
254
+ "POST",
255
+ token_url,
256
+ ]
257
+ async with self.semaphore:
258
+ resp = await self.execute(command)
259
+ response = json.loads(resp)
260
+ if response and "token" in response:
261
+ return response.get("token")
262
+ else:
263
+ raise exceptions.TokenFetchError(
264
+ f"Unable to retrieve basic token with provided username ({username}) and password"
265
+ )
266
+
267
+ async def create_roles(self, token: str, jamf_url: str) -> bool:
268
+ """
269
+ Creates the necessary API roles using the provided bearer token.
270
+
271
+ :param token: The bearer token to use for authentication. Defaults to the stored token if not provided.
272
+ :type token: Optional[str]
273
+ :param jamf_url: Jamf Server URL
274
+ :type jamf_url: str
275
+ :return: True if roles were successfully created, False otherwise.
276
+ :rtype: bool
277
+ """
278
+ role = ApiRoleModel()
279
+ payload = {
280
+ "displayName": role.display_name,
281
+ "privileges": role.privileges,
282
+ }
283
+
284
+ role_url = f"{jamf_url}/api/v1/api-roles"
285
+ headers = {
286
+ "accept": "application/json",
287
+ "Content-Type": "application/json",
288
+ "Authorization": f"Bearer {token}",
289
+ }
290
+ response = await self.fetch_json(url=role_url, headers=headers, method="POST", data=payload)
291
+ return response.get("displayName") == role.display_name
292
+
293
+ async def create_client(self, token: str, jamf_url: str) -> Optional[Tuple[str, str]]:
294
+ """
295
+ Creates an API client and retrieves its client ID and client secret.
296
+
297
+ :param token: The bearer token to use for authentication. Defaults to the stored token if not provided.
298
+ :type token: Optional[str]
299
+ :param jamf_url: Jamf Server URL
300
+ :type jamf_url: str
301
+ :return: A tuple containing the client ID and client secret.
302
+ :rtype: Optional[Tuple[str, str]]
303
+ """
304
+ client = ApiClientModel()
305
+ client_url = f"{jamf_url}/api/v1/api-integrations"
306
+ payload = {
307
+ "authorizationScopes": client.auth_scopes,
308
+ "displayName": client.display_name,
309
+ "enabled": client.enabled,
310
+ "accessTokenLifetimeSeconds": client.token_lifetime, # 30 minutes in seconds
311
+ }
312
+
313
+ headers = {
314
+ "accept": "application/json",
315
+ "Content-Type": "application/json",
316
+ "Authorization": f"Bearer {token}",
317
+ }
318
+
319
+ response = await self.fetch_json(
320
+ url=client_url, method="POST", data=payload, headers=headers
321
+ )
322
+ if not response.get("clientId"):
323
+ raise exceptions.SetupError("Failed creating client ID!")
324
+
325
+ client_id = response.get("clientId")
326
+ integration_id = response.get("id")
327
+
328
+ secret_url = f"{jamf_url}/api/v1/api-integrations/{integration_id}/client-credentials"
329
+ secret_response = await self.fetch_json(url=secret_url, method="POST", headers=headers)
330
+
331
+ if not secret_response.get("clientSecret"):
332
+ raise exceptions.SetupError(f"Failed creating client secret for {client_id}")
333
+
334
+ client_secret = secret_response.get("clientSecret")
335
+ return client_id, client_secret
@@ -0,0 +1,133 @@
1
+ from pathlib import Path
2
+ from typing import Callable, List, Optional, Union
3
+
4
+ import pandas as pd
5
+
6
+ from ..models.patch import PatchTitle
7
+ from ..utils import exceptions, logger
8
+
9
+ # TODO
10
+ # 3. Analysis based on PatchTitle criteria (completion percent, release date, etc.)
11
+
12
+
13
+ class Analyzer:
14
+ def __init__(self, csv_path: Union[Path, str]):
15
+ self.log = logger.LogMe(self.__class__.__name__)
16
+ self.patch_titles: List[PatchTitle] = []
17
+ self.df = self.initialize_dataframe(csv_path)
18
+
19
+ def initialize_dataframe(self, csv_path: Union[Path, str]) -> pd.DataFrame:
20
+ """
21
+ Initializes a DataFrame by reading the CSV file from the provided path.
22
+
23
+ :param csv_path: The path to the CSV file, either as a string or a pathlib.Path object.
24
+ :type csv_path: Union[Path, str]
25
+ :return: A pandas DataFrame loaded from the CSV file.
26
+ :rtype: pd.DatFrame
27
+ """
28
+ csv_path = Path(csv_path)
29
+
30
+ if not csv_path.exists():
31
+ raise exceptions.PatcherError(message=f"The file at path {csv_path} does not exist.")
32
+ if not csv_path.is_file():
33
+ raise exceptions.PatcherError(message=f"The path {csv_path} is not a file.")
34
+
35
+ try:
36
+ df = pd.read_csv(csv_path)
37
+ self.log.info(f"DataFrame successfully initialized from {csv_path}.")
38
+ return df
39
+ except PermissionError as e:
40
+ raise exceptions.DataframeError(
41
+ reason=f"Permission denied when trying to read {csv_path}: {e}"
42
+ )
43
+ except pd.errors.EmptyDataError as e:
44
+ raise exceptions.DataframeError(reason=f"The file at {csv_path} is empty. Details: {e}")
45
+ except pd.errors.ParserError as e:
46
+ raise exceptions.DataframeError(
47
+ reason=f"Failed to parse the CSV file at {csv_path}: {e}"
48
+ )
49
+
50
+ @staticmethod
51
+ def format_table(data: List[List[str]], headers: Optional[List[str]] = None) -> str:
52
+ """
53
+ Formats the data passed into a table for CLI output.
54
+
55
+ :param data: The data to display in the table.
56
+ :type data: List[List[str]]
57
+ :param headers: Header names for the columns of the tables.
58
+ :type headers: Optional[List[str]]
59
+ :return: The formatted table as a string.
60
+ :rtype: str
61
+ """
62
+ if headers:
63
+ data = [headers] + data
64
+
65
+ column_widths = [max(len(str(item)) for item in column) for column in zip(*data)]
66
+ format_string = " | ".join([f"{{:<{width}}}" for width in column_widths])
67
+ table = [format_string.format(*row) for row in data]
68
+
69
+ if headers:
70
+ header_separator = "-+-".join("-" * width for width in column_widths)
71
+ table.insert(1, header_separator)
72
+
73
+ return "\n".join(table)
74
+
75
+ @classmethod
76
+ async def print_table(
77
+ cls,
78
+ patch_titles: List[PatchTitle],
79
+ criteria: str,
80
+ ) -> str:
81
+ # TODO
82
+ pass
83
+
84
+ @classmethod
85
+ def filter_titles(
86
+ cls,
87
+ patch_titles: List[PatchTitle],
88
+ criteria: str,
89
+ threshold: Optional[float] = 70.0,
90
+ top_n: Optional[int] = 3,
91
+ ) -> List[PatchTitle]:
92
+ """
93
+ Filters and sorts PatchTitle objects based on specified criteria.
94
+
95
+ :param patch_titles: A list of PatchTitle objects.
96
+ :type patch_titles: List[PatchTitle]
97
+ :param criteria: The criteria to filter and sort by. Options are:
98
+ - 'most_installed': Returns the top N most installed software by total_hosts.
99
+ - 'least_installed': Returns the top N least installed software by total_hosts.
100
+ - 'oldest_least_complete': Returns the top N oldest patches with the least completion percent.
101
+ - 'below_threshold': Returns patches below a certain completion percentage.
102
+ :type criteria: str
103
+ :param threshold: The threshold for filtering completion percentages, default is 70.0.
104
+ :type threshold: Optional[float]
105
+ :param top_n: The number of results to return, default is 3.
106
+ :type top_n: Optional[int]
107
+ :return: A list of filtered and sorted PatchTitle objects.
108
+ :rtype: List[PatchTitle]
109
+ """
110
+ sort_criteria: dict[str, Callable[[List[PatchTitle]], List[PatchTitle]]] = {
111
+ "most_installed": lambda patches: sorted(
112
+ patches, key=lambda pt: pt.total_hosts, reverse=True
113
+ )[:top_n],
114
+ "least_installed": lambda patches: sorted(patches, key=lambda pt: pt.total_hosts)[
115
+ :top_n
116
+ ],
117
+ "oldest_least_complete": lambda patches: sorted(
118
+ patches, key=lambda pt: (pt.released, pt.completion_percent)
119
+ )[:top_n],
120
+ "below_threshold": lambda patches: sorted(
121
+ [pt for pt in patches if pt.completion_percent < threshold],
122
+ key=lambda pt: pt.completion_percent,
123
+ ),
124
+ }
125
+
126
+ # Check for valid criteria
127
+ if criteria not in sort_criteria:
128
+ raise ValueError(
129
+ f"Invalid criteria '{criteria}'. Supported criteria: {', '.join(sort_criteria.keys())}."
130
+ )
131
+
132
+ # Apply sorting/filtering strategy
133
+ return sort_criteria[criteria](patch_titles)