autopkg-wrapper 2026.2.2__tar.gz → 2026.2.5__tar.gz
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.
- {autopkg_wrapper-2026.2.2 → autopkg_wrapper-2026.2.5}/.github/workflows/build-publish.yml +11 -13
- {autopkg_wrapper-2026.2.2 → autopkg_wrapper-2026.2.5}/PKG-INFO +3 -1
- {autopkg_wrapper-2026.2.2 → autopkg_wrapper-2026.2.5}/README.md +2 -0
- {autopkg_wrapper-2026.2.2 → autopkg_wrapper-2026.2.5}/autopkg_wrapper/autopkg_wrapper.py +49 -36
- {autopkg_wrapper-2026.2.2 → autopkg_wrapper-2026.2.5}/autopkg_wrapper/notifier/slack.py +1 -1
- {autopkg_wrapper-2026.2.2 → autopkg_wrapper-2026.2.5}/autopkg_wrapper/utils/args.py +27 -0
- {autopkg_wrapper-2026.2.2 → autopkg_wrapper-2026.2.5}/autopkg_wrapper/utils/git_functions.py +20 -5
- autopkg_wrapper-2026.2.5/autopkg_wrapper/utils/recipe_ordering.py +149 -0
- {autopkg_wrapper-2026.2.2 → autopkg_wrapper-2026.2.5}/autopkg_wrapper/utils/report_processor.py +1 -1
- autopkg_wrapper-2026.2.5/mise.toml +25 -0
- {autopkg_wrapper-2026.2.2 → autopkg_wrapper-2026.2.5}/pyproject.toml +1 -1
- autopkg_wrapper-2026.2.5/tests/test_args_utils.py +79 -0
- autopkg_wrapper-2026.2.5/tests/test_autopkg_commands.py +89 -0
- autopkg_wrapper-2026.2.5/tests/test_git_functions.py +81 -0
- autopkg_wrapper-2026.2.5/tests/test_order_recipe_list.py +86 -0
- autopkg_wrapper-2026.2.5/tests/test_parse_recipe_list.py +120 -0
- autopkg_wrapper-2026.2.5/tests/test_report_processor.py +121 -0
- autopkg_wrapper-2026.2.5/tests/test_setup_logger.py +26 -0
- autopkg_wrapper-2026.2.5/tests/test_slack_notifier.py +84 -0
- autopkg_wrapper-2026.2.5/uv.lock +418 -0
- autopkg_wrapper-2026.2.2/mise.toml +0 -3
- autopkg_wrapper-2026.2.2/uv.lock +0 -356
- {autopkg_wrapper-2026.2.2 → autopkg_wrapper-2026.2.5}/.github/dependabot.yml +0 -0
- {autopkg_wrapper-2026.2.2 → autopkg_wrapper-2026.2.5}/.github/workflows/codeql.yml +0 -0
- {autopkg_wrapper-2026.2.2 → autopkg_wrapper-2026.2.5}/.github/workflows/dependency-review.yml +0 -0
- {autopkg_wrapper-2026.2.2 → autopkg_wrapper-2026.2.5}/.gitignore +0 -0
- {autopkg_wrapper-2026.2.2 → autopkg_wrapper-2026.2.5}/.pre-commit-config.yaml +0 -0
- {autopkg_wrapper-2026.2.2 → autopkg_wrapper-2026.2.5}/CONTRIBUTING +0 -0
- {autopkg_wrapper-2026.2.2 → autopkg_wrapper-2026.2.5}/LICENSE +0 -0
- {autopkg_wrapper-2026.2.2 → autopkg_wrapper-2026.2.5}/actions-demo/.github/workflows/autopkg-wrapper-demo.yml +0 -0
- {autopkg_wrapper-2026.2.2 → autopkg_wrapper-2026.2.5}/actions-demo/overrides/Google_Chrome.pkg.recipe.yaml +0 -0
- {autopkg_wrapper-2026.2.2 → autopkg_wrapper-2026.2.5}/actions-demo/repo_list.txt +0 -0
- {autopkg_wrapper-2026.2.2 → autopkg_wrapper-2026.2.5}/actions-demo/requirements.txt +0 -0
- {autopkg_wrapper-2026.2.2 → autopkg_wrapper-2026.2.5}/autopkg_wrapper/__init__.py +0 -0
- {autopkg_wrapper-2026.2.2 → autopkg_wrapper-2026.2.5}/autopkg_wrapper/notifier/__init__.py +0 -0
- {autopkg_wrapper-2026.2.2 → autopkg_wrapper-2026.2.5}/autopkg_wrapper/utils/__init__.py +0 -0
- {autopkg_wrapper-2026.2.2 → autopkg_wrapper-2026.2.5}/autopkg_wrapper/utils/logging.py +0 -0
- {autopkg_wrapper-2026.2.2 → autopkg_wrapper-2026.2.5}/tests/__init__.py +0 -0
- {autopkg_wrapper-2026.2.2 → autopkg_wrapper-2026.2.5}/tests/prefs.json +0 -0
- {autopkg_wrapper-2026.2.2 → autopkg_wrapper-2026.2.5}/tests/prefs.plist +0 -0
- {autopkg_wrapper-2026.2.2 → autopkg_wrapper-2026.2.5}/tests/recipe_list.json +0 -0
- {autopkg_wrapper-2026.2.2 → autopkg_wrapper-2026.2.5}/tests/recipe_list.txt +0 -0
- {autopkg_wrapper-2026.2.2 → autopkg_wrapper-2026.2.5}/tests/recipe_list.yaml +0 -0
|
@@ -105,22 +105,20 @@ jobs:
|
|
|
105
105
|
steps:
|
|
106
106
|
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
|
107
107
|
|
|
108
|
-
- name: Setup
|
|
109
|
-
uses:
|
|
108
|
+
- name: Setup mise
|
|
109
|
+
uses: jdx/mise-action@6d1e696aa24c1aa1bcc1adea0212707c71ab78a8 # v3.6.1
|
|
110
110
|
with:
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
cache-dependency-glob: uv.lock
|
|
111
|
+
install: true
|
|
112
|
+
cache: true
|
|
114
113
|
|
|
115
|
-
- name:
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
114
|
+
- name: Run tests
|
|
115
|
+
run: mise run test
|
|
116
|
+
|
|
117
|
+
- name: Build package
|
|
118
|
+
env:
|
|
119
|
+
RELEASE_VERSION: ${{ needs.release.outputs.version }}
|
|
120
|
+
run: mise run build
|
|
119
121
|
|
|
120
|
-
- name: Build Package with UV
|
|
121
|
-
run: |
|
|
122
|
-
uv version ${{ needs.release.outputs.version }}
|
|
123
|
-
uv build
|
|
124
122
|
- name: Upload Package Artifacts
|
|
125
123
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
|
126
124
|
with:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: autopkg-wrapper
|
|
3
|
-
Version: 2026.2.
|
|
3
|
+
Version: 2026.2.5
|
|
4
4
|
Summary: A package used to execute some autopkg functions, primarily within the context of a GitHub Actions runner.
|
|
5
5
|
Project-URL: Repository, https://github.com/smithjw/autopkg-wrapper
|
|
6
6
|
Author-email: James Smith <james@smithjw.me>
|
|
@@ -33,6 +33,8 @@ pip install autopkg-wrapper
|
|
|
33
33
|
-h, --help Show this help message and exit
|
|
34
34
|
--recipe-file RECIPE_FILE Path to a list of recipes to run (cannot be run with --recipes)
|
|
35
35
|
--recipes [RECIPES ...] Recipes to run with autopkg (cannot be run with --recipe-file)
|
|
36
|
+
--recipe-processing-order [RECIPE_PROCESSING_ORDER ...]
|
|
37
|
+
Optional processing order for recipe "types" (suffix segments after the first '.'); supports partial tokens like upload/auto_update; env var AW_RECIPE_PROCESSING_ORDER expects comma-separated values
|
|
36
38
|
--debug Enable debug logging when running script
|
|
37
39
|
--disable-recipe-trust-check If this option is used, recipe trust verification will not be run prior to a recipe run.
|
|
38
40
|
--github-token GITHUB_TOKEN A token used to publish a PR to your GitHub repo if overrides require their trust to be updated
|
|
@@ -14,6 +14,8 @@ pip install autopkg-wrapper
|
|
|
14
14
|
-h, --help Show this help message and exit
|
|
15
15
|
--recipe-file RECIPE_FILE Path to a list of recipes to run (cannot be run with --recipes)
|
|
16
16
|
--recipes [RECIPES ...] Recipes to run with autopkg (cannot be run with --recipe-file)
|
|
17
|
+
--recipe-processing-order [RECIPE_PROCESSING_ORDER ...]
|
|
18
|
+
Optional processing order for recipe "types" (suffix segments after the first '.'); supports partial tokens like upload/auto_update; env var AW_RECIPE_PROCESSING_ORDER expects comma-separated values
|
|
17
19
|
--debug Enable debug logging when running script
|
|
18
20
|
--disable-recipe-trust-check If this option is used, recipe trust verification will not be run prior to a recipe run.
|
|
19
21
|
--github-token GITHUB_TOKEN A token used to publish a PR to your GitHub repo if overrides require their trust to be updated
|
|
@@ -13,6 +13,7 @@ import autopkg_wrapper.utils.git_functions as git
|
|
|
13
13
|
from autopkg_wrapper.notifier import slack
|
|
14
14
|
from autopkg_wrapper.utils.args import setup_args
|
|
15
15
|
from autopkg_wrapper.utils.logging import setup_logger
|
|
16
|
+
from autopkg_wrapper.utils.recipe_ordering import order_recipe_list
|
|
16
17
|
from autopkg_wrapper.utils.report_processor import process_reports
|
|
17
18
|
|
|
18
19
|
|
|
@@ -36,43 +37,39 @@ class Recipe(object):
|
|
|
36
37
|
return name
|
|
37
38
|
|
|
38
39
|
def verify_trust_info(self, args):
|
|
39
|
-
verbose_output = ["-vvvv"] if args.debug else
|
|
40
|
+
verbose_output = ["-vvvv"] if args.debug else []
|
|
40
41
|
prefs_file = (
|
|
41
|
-
["--prefs", args.autopkg_prefs.as_posix()] if args.autopkg_prefs else
|
|
42
|
+
["--prefs", args.autopkg_prefs.as_posix()] if args.autopkg_prefs else []
|
|
42
43
|
)
|
|
43
|
-
|
|
44
|
-
cmd =
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
p = subprocess.Popen(
|
|
50
|
-
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True
|
|
44
|
+
autopkg_bin = getattr(args, "autopkg_bin", "/usr/local/bin/autopkg")
|
|
45
|
+
cmd = (
|
|
46
|
+
[autopkg_bin, "verify-trust-info", self.filename]
|
|
47
|
+
+ verbose_output
|
|
48
|
+
+ prefs_file
|
|
51
49
|
)
|
|
52
|
-
(
|
|
53
|
-
|
|
54
|
-
|
|
50
|
+
logging.debug(f"cmd: {cmd}")
|
|
51
|
+
|
|
52
|
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
53
|
+
if result.returncode == 0:
|
|
55
54
|
self.verified = True
|
|
56
55
|
else:
|
|
57
|
-
|
|
58
|
-
self.results["message"] = err
|
|
56
|
+
self.results["message"] = (result.stderr or "").strip()
|
|
59
57
|
self.verified = False
|
|
60
58
|
return self.verified
|
|
61
59
|
|
|
62
60
|
def update_trust_info(self, args):
|
|
63
61
|
prefs_file = (
|
|
64
|
-
["--prefs", args.autopkg_prefs.as_posix()] if args.autopkg_prefs else
|
|
62
|
+
["--prefs", args.autopkg_prefs.as_posix()] if args.autopkg_prefs else []
|
|
65
63
|
)
|
|
66
|
-
|
|
67
|
-
cmd =
|
|
68
|
-
cmd
|
|
69
|
-
logging.debug(f"cmd: {str(cmd)}")
|
|
64
|
+
autopkg_bin = getattr(args, "autopkg_bin", "/usr/local/bin/autopkg")
|
|
65
|
+
cmd = [autopkg_bin, "update-trust-info", self.filename] + prefs_file
|
|
66
|
+
logging.debug(f"cmd: {cmd}")
|
|
70
67
|
|
|
71
68
|
# Fail loudly if this exits 0
|
|
72
69
|
try:
|
|
73
|
-
subprocess.check_call(cmd
|
|
70
|
+
subprocess.check_call(cmd)
|
|
74
71
|
except subprocess.CalledProcessError as e:
|
|
75
|
-
logging.error(e
|
|
72
|
+
logging.error(str(e))
|
|
76
73
|
raise e
|
|
77
74
|
|
|
78
75
|
def _parse_report(self, report):
|
|
@@ -108,9 +105,9 @@ class Recipe(object):
|
|
|
108
105
|
prefs_file = (
|
|
109
106
|
["--prefs", args.autopkg_prefs.as_posix()]
|
|
110
107
|
if args.autopkg_prefs
|
|
111
|
-
else
|
|
108
|
+
else []
|
|
112
109
|
)
|
|
113
|
-
verbose_output = ["-vvvv"] if args.debug else
|
|
110
|
+
verbose_output = ["-vvvv"] if args.debug else []
|
|
114
111
|
post_processor_cmd = (
|
|
115
112
|
list(
|
|
116
113
|
chain.from_iterable(
|
|
@@ -121,25 +118,25 @@ class Recipe(object):
|
|
|
121
118
|
)
|
|
122
119
|
)
|
|
123
120
|
if self.post_processors
|
|
124
|
-
else
|
|
121
|
+
else []
|
|
125
122
|
)
|
|
123
|
+
autopkg_bin = getattr(args, "autopkg_bin", "/usr/local/bin/autopkg")
|
|
126
124
|
cmd = [
|
|
127
|
-
|
|
125
|
+
autopkg_bin,
|
|
128
126
|
"run",
|
|
129
127
|
self.filename,
|
|
130
128
|
"--report-plist",
|
|
131
129
|
str(report),
|
|
132
130
|
]
|
|
133
|
-
cmd = cmd + post_processor_cmd
|
|
134
|
-
cmd = cmd + verbose_output if verbose_output else cmd
|
|
135
|
-
cmd = cmd + prefs_file if prefs_file else cmd
|
|
136
|
-
cmd = " ".join(cmd)
|
|
131
|
+
cmd = cmd + post_processor_cmd + verbose_output + prefs_file
|
|
137
132
|
|
|
138
|
-
logging.debug(f"cmd: {
|
|
133
|
+
logging.debug(f"cmd: {cmd}")
|
|
139
134
|
|
|
140
|
-
subprocess.
|
|
135
|
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
136
|
+
if result.returncode != 0:
|
|
137
|
+
self.error = True
|
|
141
138
|
|
|
142
|
-
except
|
|
139
|
+
except Exception:
|
|
143
140
|
self.error = True
|
|
144
141
|
|
|
145
142
|
self._has_run = True
|
|
@@ -246,7 +243,12 @@ def update_recipe_repo(recipe, git_info, disable_recipe_trust_check, args):
|
|
|
246
243
|
|
|
247
244
|
|
|
248
245
|
def parse_recipe_list(recipes, recipe_file, post_processors, args):
|
|
249
|
-
"""
|
|
246
|
+
"""Parse recipe inputs into a common list of recipe names.
|
|
247
|
+
|
|
248
|
+
The arguments assume that `recipes` and `recipe_file` are mutually exclusive.
|
|
249
|
+
If `args.recipe_processing_order` is provided, the list is re-ordered before
|
|
250
|
+
creating `Recipe` objects.
|
|
251
|
+
"""
|
|
250
252
|
recipe_list = None
|
|
251
253
|
|
|
252
254
|
logging.info(f"Recipes: {recipes}") if recipes else None
|
|
@@ -256,6 +258,12 @@ def parse_recipe_list(recipes, recipe_file, post_processors, args):
|
|
|
256
258
|
if recipe_file.suffix == ".json":
|
|
257
259
|
with open(recipe_file, "r") as f:
|
|
258
260
|
recipe_list = json.load(f)
|
|
261
|
+
elif recipe_file.suffix in {".yaml", ".yml"}:
|
|
262
|
+
from ruamel.yaml import YAML
|
|
263
|
+
|
|
264
|
+
yaml = YAML(typ="safe")
|
|
265
|
+
with open(recipe_file, "r", encoding="utf-8") as f:
|
|
266
|
+
recipe_list = yaml.load(f)
|
|
259
267
|
elif recipe_file.suffix == ".txt":
|
|
260
268
|
with open(recipe_file, "r") as f:
|
|
261
269
|
recipe_list = f.read().splitlines()
|
|
@@ -279,11 +287,16 @@ def parse_recipe_list(recipes, recipe_file, post_processors, args):
|
|
|
279
287
|
"""Please provide recipes to run via the following methods:
|
|
280
288
|
--recipes recipe_one.download recipe_two.download
|
|
281
289
|
--recipe-file path/to/recipe_list.json
|
|
282
|
-
Comma separated list in the
|
|
290
|
+
Comma separated list in the AW_RECIPES env variable"""
|
|
283
291
|
)
|
|
284
292
|
sys.exit(1)
|
|
285
293
|
|
|
286
|
-
|
|
294
|
+
if args.recipe_processing_order:
|
|
295
|
+
recipe_list = order_recipe_list(
|
|
296
|
+
recipe_list=recipe_list, order=args.recipe_processing_order
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
logging.info(f"Processing {len(recipe_list)} recipes.")
|
|
287
300
|
recipe_map = [Recipe(name, post_processors=post_processors) for name in recipe_list]
|
|
288
301
|
|
|
289
302
|
return recipe_map
|
|
@@ -5,7 +5,7 @@ import requests
|
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
def send_notification(recipe, token):
|
|
8
|
-
logging.debug("
|
|
8
|
+
logging.debug("Preparing Slack notification")
|
|
9
9
|
|
|
10
10
|
if token is None:
|
|
11
11
|
logging.error("Skipping Slack Notification as no SLACK_WEBHOOK_TOKEN defined!")
|
|
@@ -68,6 +68,33 @@ def setup_args():
|
|
|
68
68
|
`autopkg-wrapper`
|
|
69
69
|
""",
|
|
70
70
|
)
|
|
71
|
+
parser.add_argument(
|
|
72
|
+
"--recipe-processing-order",
|
|
73
|
+
nargs="*",
|
|
74
|
+
default=os.getenv("AW_RECIPE_PROCESSING_ORDER", None),
|
|
75
|
+
help="""
|
|
76
|
+
This option comes in handy if you include additional recipe type names in your overrides and wish them to be processed in a specific order.
|
|
77
|
+
We'll specifically look for these recipe types after the first period (.) in the recipe name.
|
|
78
|
+
Order items can be either a full type suffix (e.g. "upload.jamf") or a partial token (e.g. "upload", "auto_update").
|
|
79
|
+
Partial tokens are matched against the dot-separated segments after the first '.' so recipes like "Foo.epz.auto_update.jamf" will match "auto_update".
|
|
80
|
+
This can also be provided via the 'AW_RECIPE_PROCESSING_ORDER' environment variable as a comma-separated list (e.g. "upload,self_service,auto_update").
|
|
81
|
+
For example, if you have the following recipes to be processed:
|
|
82
|
+
ExampleApp.auto_install.jamf
|
|
83
|
+
ExampleApp.upload.jamf
|
|
84
|
+
ExampleApp.self_service.jamf
|
|
85
|
+
And you want to ensure that the .upload recipes are always processed first, followed by .auto_install, and finally .self_service, you would provide the following processing order:
|
|
86
|
+
`--recipe-processing-order upload.jamf auto_install.jamf self_service.jamf`
|
|
87
|
+
This would ensure that all .upload recipes are processed before any other recipe types.
|
|
88
|
+
Within each recipe type, the recipes will be ordered alphabetically.
|
|
89
|
+
We assume that no extensions are provided (but will strip them if needed - extensions that are stripped include .recipe or .recipe.yaml).
|
|
90
|
+
""",
|
|
91
|
+
)
|
|
92
|
+
parser.add_argument(
|
|
93
|
+
"--autopkg-bin",
|
|
94
|
+
default=os.getenv("AW_AUTOPKG_BIN", "/usr/local/bin/autopkg"),
|
|
95
|
+
help="Path to the autopkg binary (default: /usr/local/bin/autopkg). Can also be set via AW_AUTOPKG_BIN.",
|
|
96
|
+
)
|
|
97
|
+
|
|
71
98
|
parser.add_argument(
|
|
72
99
|
"--debug",
|
|
73
100
|
default=validate_bool(os.getenv("AW_DEBUG", False)),
|
{autopkg_wrapper-2026.2.2 → autopkg_wrapper-2026.2.5}/autopkg_wrapper/utils/git_functions.py
RENAMED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import logging
|
|
2
|
+
import re
|
|
2
3
|
import subprocess
|
|
3
4
|
from datetime import datetime
|
|
4
5
|
|
|
@@ -21,12 +22,26 @@ def git_run(*args):
|
|
|
21
22
|
|
|
22
23
|
|
|
23
24
|
def get_repo_info(override_repo_git_git_dir):
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
remote = git_run(
|
|
26
|
+
override_repo_git_git_dir, "config", "--get", "remote.origin.url"
|
|
27
|
+
).stdout.strip()
|
|
28
|
+
|
|
29
|
+
# Supports:
|
|
30
|
+
# - https://github.com/<owner>/<repo>.git
|
|
31
|
+
# - git@github.com:<owner>/<repo>.git
|
|
32
|
+
# - ssh://git@github.com/<owner>/<repo>.git
|
|
33
|
+
m = re.search(
|
|
34
|
+
r"github\.com[:/](?P<owner>[^/]+)/(?P<repo>[^\s/]+?)(?:\.git)?$",
|
|
35
|
+
remote,
|
|
36
|
+
flags=re.IGNORECASE,
|
|
28
37
|
)
|
|
29
|
-
|
|
38
|
+
if not m:
|
|
39
|
+
raise ValueError(f"Unsupported Git remote URL: {remote}")
|
|
40
|
+
|
|
41
|
+
owner = m.group("owner")
|
|
42
|
+
repo = m.group("repo")
|
|
43
|
+
remote_repo_ref = f"{owner}/{repo}"
|
|
44
|
+
repo_url = f"https://github.com/{remote_repo_ref}"
|
|
30
45
|
|
|
31
46
|
logging.debug(f"Repo URL: {repo_url}")
|
|
32
47
|
logging.debug(f"Remote Repo Ref: {remote_repo_ref}")
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def order_recipe_list(recipe_list, order):
|
|
5
|
+
# This option comes in handy if you include additional recipe type names in your overrides and wish them to be processed in a specific order.
|
|
6
|
+
# We'll specifically look for these recipe types after the first period (.) in the recipe name.
|
|
7
|
+
# For example, if you have the following recipes to be processed:
|
|
8
|
+
# ExampleApp.auto_install.jamf
|
|
9
|
+
# ExampleApp.upload.jamf
|
|
10
|
+
# ExampleApp.self_service.jamf
|
|
11
|
+
# And you want to ensure that the .upload recipes are always processed first, followed by .auto_install, and finally .self_service, you would provide the following processing order:
|
|
12
|
+
# `--recipe-processing-order upload.jamf auto_install.jamf self_service.jamf`
|
|
13
|
+
# This would ensure that all .upload recipes are processed before any other recipe types.
|
|
14
|
+
# Within each recipe type, the recipes will be ordered alphabetically.
|
|
15
|
+
# We assume that no extensions are provided (but will strip them if needed - extensions that are stripped include .recipe or .recipe.yaml).
|
|
16
|
+
|
|
17
|
+
def strip_known_extensions(value: str) -> str:
|
|
18
|
+
value = value.strip()
|
|
19
|
+
if value.endswith(".recipe.yaml"):
|
|
20
|
+
return value[: -len(".recipe.yaml")]
|
|
21
|
+
if value.endswith(".recipe"):
|
|
22
|
+
return value[: -len(".recipe")]
|
|
23
|
+
return value
|
|
24
|
+
|
|
25
|
+
def normalise_processing_order(value):
|
|
26
|
+
if not value:
|
|
27
|
+
return []
|
|
28
|
+
|
|
29
|
+
items: list[str] = []
|
|
30
|
+
if isinstance(value, str):
|
|
31
|
+
raw = value.strip()
|
|
32
|
+
if not raw:
|
|
33
|
+
return []
|
|
34
|
+
# String values generally come from env var defaults; treat as comma-separated.
|
|
35
|
+
items = [v.strip() for v in raw.split(",")]
|
|
36
|
+
else:
|
|
37
|
+
# argparse typically provides a list here, but env var defaults can leak through.
|
|
38
|
+
for v in value:
|
|
39
|
+
if v is None:
|
|
40
|
+
continue
|
|
41
|
+
v = str(v).strip()
|
|
42
|
+
if not v:
|
|
43
|
+
continue
|
|
44
|
+
if "," in v:
|
|
45
|
+
items.extend([p.strip() for p in v.split(",")])
|
|
46
|
+
else:
|
|
47
|
+
items.append(v)
|
|
48
|
+
|
|
49
|
+
normalised: list[str] = []
|
|
50
|
+
seen: set[str] = set()
|
|
51
|
+
for item in items:
|
|
52
|
+
if not item:
|
|
53
|
+
continue
|
|
54
|
+
item = item.lstrip(".")
|
|
55
|
+
item = strip_known_extensions(item)
|
|
56
|
+
if not item or item in seen:
|
|
57
|
+
continue
|
|
58
|
+
seen.add(item)
|
|
59
|
+
normalised.append(item)
|
|
60
|
+
return normalised
|
|
61
|
+
|
|
62
|
+
def recipe_type(recipe_name: str) -> str:
|
|
63
|
+
# Type is everything after the first '.' (e.g. Example.upload.jamf -> upload.jamf)
|
|
64
|
+
parts = recipe_name.split(".", 1)
|
|
65
|
+
return parts[1] if len(parts) == 2 else ""
|
|
66
|
+
|
|
67
|
+
def recipe_segments_after_first_dot(recipe_name: str) -> list[str]:
|
|
68
|
+
after_first = recipe_type(recipe_name)
|
|
69
|
+
return [p for p in after_first.split(".") if p] if after_first else []
|
|
70
|
+
|
|
71
|
+
def pattern_matches_segments(pattern: str, segments: list[str]) -> bool:
|
|
72
|
+
# Pattern can be a single token ("auto_update") or a dot-separated sequence
|
|
73
|
+
# ("upload.jamf", "auto_update.jamf", etc.).
|
|
74
|
+
if not pattern:
|
|
75
|
+
return False
|
|
76
|
+
pattern_parts = [p for p in pattern.split(".") if p]
|
|
77
|
+
if not pattern_parts:
|
|
78
|
+
return False
|
|
79
|
+
|
|
80
|
+
# Case-insensitive matching.
|
|
81
|
+
segments_norm = [s.casefold() for s in segments]
|
|
82
|
+
pattern_parts_norm = [p.casefold() for p in pattern_parts]
|
|
83
|
+
|
|
84
|
+
if len(pattern_parts_norm) == 1:
|
|
85
|
+
return pattern_parts_norm[0] in segments_norm
|
|
86
|
+
|
|
87
|
+
# Contiguous subsequence match.
|
|
88
|
+
for start in range(0, len(segments_norm) - len(pattern_parts_norm) + 1):
|
|
89
|
+
if (
|
|
90
|
+
segments_norm[start : start + len(pattern_parts_norm)]
|
|
91
|
+
== pattern_parts_norm
|
|
92
|
+
):
|
|
93
|
+
return True
|
|
94
|
+
return False
|
|
95
|
+
|
|
96
|
+
if not recipe_list:
|
|
97
|
+
return recipe_list
|
|
98
|
+
|
|
99
|
+
normalised_order = normalise_processing_order(order)
|
|
100
|
+
|
|
101
|
+
# If the provided order contains no usable tokens, do not re-order.
|
|
102
|
+
# (We still strip known extensions, which is order-preserving.)
|
|
103
|
+
if not normalised_order:
|
|
104
|
+
return [
|
|
105
|
+
strip_known_extensions(str(r).strip()) for r in recipe_list if r is not None
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
# First, normalise recipe names by stripping known extensions.
|
|
109
|
+
normalised_recipes: list[str] = []
|
|
110
|
+
for r in recipe_list:
|
|
111
|
+
if r is None:
|
|
112
|
+
continue
|
|
113
|
+
normalised_recipes.append(strip_known_extensions(str(r).strip()))
|
|
114
|
+
|
|
115
|
+
# If a processing order is supplied, match each recipe to the *first* pattern it satisfies.
|
|
116
|
+
# This supports both direct matches ("upload.jamf") and partial matches ("upload",
|
|
117
|
+
# "auto_update") against dot-separated segments after the first '.' in the recipe name.
|
|
118
|
+
pattern_groups: dict[str, list[str]] = {p: [] for p in normalised_order}
|
|
119
|
+
unmatched: list[str] = []
|
|
120
|
+
|
|
121
|
+
for r in normalised_recipes:
|
|
122
|
+
segments = recipe_segments_after_first_dot(r)
|
|
123
|
+
matched = False
|
|
124
|
+
for p in normalised_order:
|
|
125
|
+
if pattern_matches_segments(p, segments):
|
|
126
|
+
pattern_groups[p].append(r)
|
|
127
|
+
matched = True
|
|
128
|
+
break
|
|
129
|
+
if not matched:
|
|
130
|
+
unmatched.append(r)
|
|
131
|
+
|
|
132
|
+
ordered: list[str] = []
|
|
133
|
+
for p in normalised_order:
|
|
134
|
+
ordered.extend(sorted(pattern_groups[p], key=str.casefold))
|
|
135
|
+
|
|
136
|
+
# Remaining recipes: group by their full type string and order groups alphabetically,
|
|
137
|
+
# with empty-type last.
|
|
138
|
+
groups: dict[str, list[str]] = {}
|
|
139
|
+
for r in unmatched:
|
|
140
|
+
t = recipe_type(r)
|
|
141
|
+
groups.setdefault(t, []).append(r)
|
|
142
|
+
|
|
143
|
+
for t in sorted(groups.keys(), key=lambda x: (x == "", x.casefold())):
|
|
144
|
+
ordered.extend(sorted(groups[t], key=str.casefold))
|
|
145
|
+
|
|
146
|
+
logging.debug(f"Recipe processing order: {normalised_order}")
|
|
147
|
+
logging.debug(f"Ordered recipes: {ordered}")
|
|
148
|
+
|
|
149
|
+
return ordered
|
{autopkg_wrapper-2026.2.2 → autopkg_wrapper-2026.2.5}/autopkg_wrapper/utils/report_processor.py
RENAMED
|
@@ -53,7 +53,7 @@ def parse_text_file(path: str) -> Dict[str, List]:
|
|
|
53
53
|
|
|
54
54
|
re_error = re.compile(r"ERROR[:\s-]+(.+)", re.IGNORECASE)
|
|
55
55
|
re_upload = re.compile(
|
|
56
|
-
r"(Uploaded|Upload|Uploading)[^\n]*?(?P<name>[A-Za-z0-9 ._+\-]
|
|
56
|
+
r"(Uploaded|Upload|Uploading)[^\n]*?(?P<name>[A-Za-z0-9 ._+\-]+?)(?=(?:\s+\bversion\b)|$)(?:[^\n]*?\bversion\b[^\d]*(?P<version>\d+(?:\.\d+)+))?",
|
|
57
57
|
re.IGNORECASE,
|
|
58
58
|
)
|
|
59
59
|
re_policy = re.compile(r"Policy (created|updated):\s*(?P<name>.+)", re.IGNORECASE)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[tools]
|
|
2
|
+
prek = "latest"
|
|
3
|
+
python = "3.14"
|
|
4
|
+
uv = "latest"
|
|
5
|
+
|
|
6
|
+
[tasks.install]
|
|
7
|
+
description = "Install/sync Python dependencies (uv)"
|
|
8
|
+
run = "uv sync"
|
|
9
|
+
|
|
10
|
+
[tasks.upgrade]
|
|
11
|
+
description = "Upgrade all Python dependencies (updates uv.lock)"
|
|
12
|
+
run = ["uv lock --upgrade", "uv sync"]
|
|
13
|
+
|
|
14
|
+
[tasks.test]
|
|
15
|
+
description = "Run unit tests"
|
|
16
|
+
run = "uv run python -m unittest -v"
|
|
17
|
+
|
|
18
|
+
[tasks.build]
|
|
19
|
+
description = "Build sdist + wheel"
|
|
20
|
+
run = """
|
|
21
|
+
if [ -n "${RELEASE_VERSION:-}" ]; then
|
|
22
|
+
uv version "$RELEASE_VERSION"
|
|
23
|
+
fi
|
|
24
|
+
uv build
|
|
25
|
+
"""
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import tempfile
|
|
3
|
+
import unittest
|
|
4
|
+
from unittest.mock import patch
|
|
5
|
+
|
|
6
|
+
from autopkg_wrapper.utils import args as args_utils
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TestArgsUtils(unittest.TestCase):
|
|
10
|
+
def test_validate_bool(self):
|
|
11
|
+
self.assertTrue(args_utils.validate_bool(True))
|
|
12
|
+
self.assertFalse(args_utils.validate_bool(False))
|
|
13
|
+
|
|
14
|
+
self.assertFalse(args_utils.validate_bool("0"))
|
|
15
|
+
self.assertFalse(args_utils.validate_bool("false"))
|
|
16
|
+
self.assertFalse(args_utils.validate_bool("No"))
|
|
17
|
+
self.assertFalse(args_utils.validate_bool("F"))
|
|
18
|
+
|
|
19
|
+
self.assertTrue(args_utils.validate_bool("1"))
|
|
20
|
+
self.assertTrue(args_utils.validate_bool("true"))
|
|
21
|
+
self.assertTrue(args_utils.validate_bool("YES"))
|
|
22
|
+
self.assertTrue(args_utils.validate_bool("t"))
|
|
23
|
+
|
|
24
|
+
def test_validate_file_and_directory(self):
|
|
25
|
+
with tempfile.TemporaryDirectory() as td:
|
|
26
|
+
# directory
|
|
27
|
+
self.assertEqual(
|
|
28
|
+
args_utils.validate_directory(td).as_posix(),
|
|
29
|
+
os.path.realpath(td),
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
# file
|
|
33
|
+
fp = os.path.join(td, "example.txt")
|
|
34
|
+
with open(fp, "w", encoding="utf-8") as f:
|
|
35
|
+
f.write("hi")
|
|
36
|
+
self.assertEqual(
|
|
37
|
+
args_utils.validate_file(fp).as_posix(),
|
|
38
|
+
os.path.realpath(fp),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
def test_find_github_token_prefers_github_token(self):
|
|
42
|
+
with patch.dict(
|
|
43
|
+
os.environ, {"GITHUB_TOKEN": "aaa", "GH_TOKEN": "bbb"}, clear=True
|
|
44
|
+
):
|
|
45
|
+
self.assertEqual(args_utils.find_github_token(), "aaa")
|
|
46
|
+
|
|
47
|
+
def test_find_github_token_falls_back_to_gh_token(self):
|
|
48
|
+
with patch.dict(os.environ, {"GH_TOKEN": "bbb"}, clear=True):
|
|
49
|
+
self.assertEqual(args_utils.find_github_token(), "bbb")
|
|
50
|
+
|
|
51
|
+
def test_setup_args_reads_processing_order_env_var(self):
|
|
52
|
+
# Note: argparse default comes through as a string when provided via env var.
|
|
53
|
+
with patch.dict(
|
|
54
|
+
os.environ,
|
|
55
|
+
{"AW_RECIPE_PROCESSING_ORDER": "upload,self_service"},
|
|
56
|
+
clear=True,
|
|
57
|
+
):
|
|
58
|
+
with patch("sys.argv", ["autopkg_wrapper"]):
|
|
59
|
+
parsed = args_utils.setup_args()
|
|
60
|
+
self.assertEqual(parsed.recipe_processing_order, "upload,self_service")
|
|
61
|
+
|
|
62
|
+
def test_setup_args_reads_autopkg_bin_env_var(self):
|
|
63
|
+
with patch.dict(os.environ, {"AW_AUTOPKG_BIN": "/opt/bin/autopkg"}, clear=True):
|
|
64
|
+
with patch("sys.argv", ["autopkg_wrapper"]):
|
|
65
|
+
parsed = args_utils.setup_args()
|
|
66
|
+
self.assertEqual(parsed.autopkg_bin, "/opt/bin/autopkg")
|
|
67
|
+
|
|
68
|
+
def test_setup_args_cli_autopkg_bin_overrides_env_var(self):
|
|
69
|
+
with patch.dict(os.environ, {"AW_AUTOPKG_BIN": "/opt/bin/autopkg"}, clear=True):
|
|
70
|
+
with patch(
|
|
71
|
+
"sys.argv",
|
|
72
|
+
["autopkg_wrapper", "--autopkg-bin", "/custom/autopkg"],
|
|
73
|
+
):
|
|
74
|
+
parsed = args_utils.setup_args()
|
|
75
|
+
self.assertEqual(parsed.autopkg_bin, "/custom/autopkg")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
if __name__ == "__main__":
|
|
79
|
+
unittest.main()
|