modpkgbuild 0.1.0__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.
@@ -0,0 +1,90 @@
1
+ Metadata-Version: 2.5
2
+ Name: modpkgbuild
3
+ Version: 0.1.0
4
+ Summary: Creates a new PKGBUILD by modifying another
5
+ Author-email: Russell Wright Helder <rwh@russellhelder.net>
6
+ Requires-Python: >=3.12
7
+ Description-Content-Type: text/markdown
8
+ License-Expression: MIT
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Environment :: Console
11
+ Classifier: Operating System :: POSIX :: Linux
12
+ Classifier: Programming Language :: Python :: 3 :: Only
13
+ Classifier: Topic :: Software Development :: Build Tools
14
+ Classifier: Topic :: Software Development :: Code Generators
15
+ Classifier: Topic :: System :: Archiving :: Packaging
16
+ Classifier: Topic :: System :: Software Distribution
17
+ Classifier: Topic :: Text Processing :: Filters
18
+ License-File: LICENSE
19
+ Requires-Dist: pyyaml
20
+ Project-URL: Homepage, https://github.com/rhelder/modpkgbuild
21
+ Project-URL: Issues, https://github.com/rhelder/modpkgbuild/issues
22
+ Import-Name: modpkgbuild
23
+
24
+ # modpkgbuild
25
+
26
+ ## Description
27
+
28
+ Creates one PKGBUILD from another PKGBUILD by applying a series of changes
29
+ specified in a YAML file. Designed to automatically generate alternative
30
+ versions of Arch Linux packages (e.g., enabling SELinux support, applying
31
+ security-related patches, etc.). This makes it possible to e.g. implement a
32
+ CI/CD pipeline that checks for updates to the relevant Arch package,
33
+ automatically generates an alternative PKGBUILD when there has been an update,
34
+ builds the alternative package in a clean container, and deploys it to a
35
+ custom Pacman repo.
36
+
37
+ The PKGBUILD is written to the current working directory. A diff between the
38
+ old PKGBUILD and the new PKGBUILD is written to stderr on success.
39
+
40
+ ## Installation
41
+
42
+ This script is available on PyPI as `modpkgbuild`:
43
+
44
+ ```
45
+ pip install modpkgbuild
46
+ ```
47
+
48
+ ## modpkgbuild.yml
49
+
50
+ `modpkgbuild` must be run in the same directory as a `modpkgbuild.yml`. The
51
+ `modpkgbuild.yml` must contain the following two fields:
52
+
53
+ | Key | Value |
54
+ | ------------ | -------------------------------
55
+ | repo | The URL to the git repository that contains the PKGBUILD you want to modify |
56
+ | replacements | A list of replacement objects |
57
+
58
+ A replacement object must contain three fields:
59
+
60
+ | Key | Value |
61
+ | ----------- | ---------------------------------------------------------
62
+ | description | A description of the purpose of the replacement |
63
+ | old | A literal string you want to replace (may be multiline) |
64
+ | new | A literal string you want to replace `old` with (may be multiline) |
65
+
66
+ Or, if the optional field `regex` is set to true, the fields mean the
67
+ following:
68
+
69
+ | Key | Value |
70
+ | ----------- | ---------------------------------------------------------
71
+ | description | A description of the purpose of the replacement |
72
+ | old | A regular expression matching text you want to replace (may be multiline, and may contain captures) |
73
+ | new | A replacement string (may be multiline, may contain backreferences) |
74
+
75
+ All replacements must be specified **in order**. Any failure to match is
76
+ considered an error. `modpkgbuild` is intentionally conservative in these ways
77
+ so that it can be relied on in an automated packaging pipeline to not produce
78
+ an incorrect PKGBUILD without an error.
79
+
80
+ Accordingly, the user should use literal strings in `modpkgbuild.yml` whenever
81
+ possible. Setting `regex` to true is to be discouraged unless there is a good
82
+ reason – for example, you may need to capture part of the match and reference
83
+ it in the replacement text.
84
+
85
+ ## Project Status
86
+
87
+ This package should be considered to be alpha software. Until `modpkgbuild`
88
+ has been more extensively tested, the user should manually review generated
89
+ PKGBUILDs before building or deploying packages.
90
+
@@ -0,0 +1,6 @@
1
+ modpkgbuild.py,sha256=sKTwCw2012XkQe0TzPT4I7SnhtGn55BlMGWjkwBzNek,8955
2
+ modpkgbuild-0.1.0.dist-info/entry_points.txt,sha256=ie8ZfD45Kz0Q8HuYHpJVlyU89sEl0XtZn_HAJrAsFzA,48
3
+ modpkgbuild-0.1.0.dist-info/licenses/LICENSE,sha256=EQJaGGeBeSD0jhbWKGQp1bCxUr5M5Ty_FlNpaf_RIDw,1078
4
+ modpkgbuild-0.1.0.dist-info/WHEEL,sha256=rLJE8OLPBZbz7tqxs1yD-wA-ADa1Ec5PuYU-Y_PNWDI,81
5
+ modpkgbuild-0.1.0.dist-info/METADATA,sha256=Gr0dm5NwO8rOav5TmNe0AVKeYWbCOSSi9oSSlufXptU,3823
6
+ modpkgbuild-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: flit 4.2.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ modpkgbuild=modpkgbuild:main
3
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Russell Wright Helder
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
modpkgbuild.py ADDED
@@ -0,0 +1,293 @@
1
+ """Creates one PKGBUILD from another by applying changes specified in a YAML file.
2
+
3
+ Designed to automatically generate alternative versions of Arch Linux packages
4
+ (e.g., enabling SELinux support, applying security-related patches, etc.).
5
+ Must be run in the same directory as a modpkgbuild.yml. The PKGBUILD is
6
+ written to the current working directory. A diff between the old PKGBUILD and
7
+ the new PKGBUILD is written to stderr on success.
8
+ """
9
+
10
+ import argparse
11
+ import difflib
12
+ from pathlib import Path
13
+ import re
14
+ import subprocess
15
+ import sys
16
+ import tempfile
17
+
18
+ import yaml
19
+
20
+ try:
21
+ from yaml import CSafeLoader as SafeLoader
22
+ except ImportError:
23
+ from yaml import SafeLoader
24
+
25
+
26
+ class ReplacementError(Exception):
27
+ """An error in applying a replacement."""
28
+
29
+
30
+ class SpecError(Exception):
31
+ """An error in modpkgbuild.yml."""
32
+
33
+
34
+ def write_new(new: str):
35
+ """Writes the new PKGBUILD to `./PKGBUILD`.
36
+
37
+ Args:
38
+ new (string):
39
+ The content of the new PKGBUILD.
40
+ """
41
+ tmpdir = tempfile.TemporaryDirectory(dir='.')
42
+ pkgbuild_path = Path(tmpdir.name) / 'PKGBUILD'
43
+
44
+ pkgbuild_path.write_text(new)
45
+
46
+ target_path = Path('PKGBUILD')
47
+
48
+ if target_path.is_file():
49
+ message('warning: PKGBUILD exists. Saving as PKGBUILD.orig...')
50
+
51
+ orig_path = Path('PKGBUILD.orig')
52
+
53
+ if orig_path.is_file():
54
+ raise FileExistsError('PKGBUILD.orig also exists. Aborting.')
55
+ else:
56
+ target_path.rename(orig_path)
57
+
58
+ pkgbuild_path.rename(target_path)
59
+
60
+
61
+ def gen_new(old: str, spec: dict) -> str:
62
+ """Applies the changes specified in `modpkgbuild.yml`.
63
+
64
+ Args:
65
+ old (string):
66
+ The content of the upstream PKGBUILD.
67
+ spec (dictionary):
68
+ The data loaded from modpkgbuild.yml.
69
+
70
+ Returns:
71
+ (string) The content of the new PKGBUILD.
72
+ """
73
+ new = []
74
+ pos = 0
75
+
76
+ for r in spec['replacements']:
77
+ # Save the position of the end of the last match in the old PKGBUILD
78
+ prev_pos = pos
79
+
80
+ if not r.get('regex', False):
81
+ # Search for the next match in the old PKGBUILD, starting at the
82
+ # end of the last match, so that the changes specified in
83
+ # modpkgbuild.yml are applied in order. Advance the position
84
+ # tracker to the position of the beginning of the next match. It
85
+ # is an error if a match cannot be found.
86
+ try:
87
+ pos += old[prev_pos:].index(r['old'])
88
+ except ValueError:
89
+ raise ReplacementError(
90
+ f"substring '{r['old']}' "
91
+ f"in replacement '{r['description']}' not found"
92
+ )
93
+
94
+ # The text between the end of the last match and the start of the
95
+ # next match are the same in the old PKGBUILD and in the new
96
+ # PKGBUILD. Append it to the new PKGBUILD.
97
+ new += old[prev_pos:pos]
98
+ # At this position, append the replacement text to the new
99
+ # PKGBUILD
100
+ new += r['new']
101
+ # Advance the position tracker, which tracks our position in the
102
+ # old PKGBUILD, by the length of the matched and the replaced
103
+ # text
104
+ pos += len(r['old'])
105
+ else:
106
+ try:
107
+ pattern = re.compile(r['old'])
108
+ except re.PatternError as e:
109
+ raise SpecError(
110
+ f"invalid regex in replacement '{r['description']}': {e}"
111
+ )
112
+
113
+ # Search for the next match in the old PKGBUILD, starting at the
114
+ # end of the last match, so that the changes specified in
115
+ # modpkgbuild.yml are applied in order
116
+ match = re.search(pattern, old[prev_pos:])
117
+
118
+ # It is an error if a match cannot be found
119
+ if not match:
120
+ raise ReplacementError(
121
+ f"pattern '{pattern.pattern}' "
122
+ f"in replacement '{r['description']}' not matched"
123
+ )
124
+
125
+ # Advance the position tracker to the position of the beginning of
126
+ # the next match
127
+ pos += match.start()
128
+ # The text between the end of the last match and the start of the
129
+ # next match are the same in the old PKGBUILD and in the new
130
+ # PKGBUILD. Append it to the new PKGBUILD.
131
+ new += old[prev_pos:pos]
132
+ # At this position, append the replacement text to the new
133
+ # PKGBUILD
134
+ new += match.expand(r['new'])
135
+ # Advance the position tracker, which tracks our position in the
136
+ # old PKGBUILD, by the length of the matched and the replaced
137
+ # text
138
+ pos += len(match[0])
139
+
140
+ # Append any remaining text in the old PKGBUILD to the new PKGBUILD
141
+ new += old[pos:]
142
+ return ''.join(new)
143
+
144
+
145
+ def read_old(repo: str) -> str:
146
+ """Clones the upstream git repo and reads its PKGBUILD.
147
+
148
+ Args:
149
+ repo (string):
150
+ The URL to the upstream git repository.
151
+
152
+ Returns:
153
+ (string) The content of the upstream PKGBUILD.
154
+ """
155
+ tmpdir = tempfile.TemporaryDirectory()
156
+
157
+ subprocess.run(
158
+ ['git', 'clone', repo, tmpdir.name],
159
+ check=True,
160
+ capture_output=True,
161
+ text=True,
162
+ )
163
+
164
+ with open(Path(tmpdir.name) / 'PKGBUILD') as f:
165
+ return f.read()
166
+
167
+
168
+ def parse_spec() -> dict:
169
+ """Loads and validates modpkgbuild.yml.
170
+
171
+ Returns:
172
+ (dictionary) The data loaded from modpkgbuild.yml.
173
+ """
174
+ spec = load_spec()
175
+
176
+ validate_spec(spec)
177
+ return spec
178
+
179
+
180
+ def load_spec() -> dict:
181
+ """Loads modpkgbuild.yml.
182
+
183
+ Returns:
184
+ (dictionary) The data loaded from modpkgbuild.yml.
185
+ """
186
+ with open('./modpkgbuild.yml') as f:
187
+ spec = yaml.load(f.read(), Loader=SafeLoader)
188
+
189
+ return spec
190
+
191
+
192
+ def validate_spec(spec: dict):
193
+ """Validates the data loaded from modpkgbuild.yml.
194
+
195
+ Args:
196
+ spec (dictionary):
197
+ The data loaded from modpkgbuild.yml.
198
+ """
199
+ if not isinstance(spec, dict):
200
+ raise SpecError('expected a YAML mapping')
201
+
202
+ try:
203
+ if not isinstance(spec['repo'], str):
204
+ raise SpecError("'repo' must be a string")
205
+ elif not isinstance(spec['replacements'], list):
206
+ raise SpecError("'replacements' must be a list")
207
+ except KeyError as e:
208
+ raise SpecError(f'missing required field {e}')
209
+
210
+ for i, r in enumerate(spec['replacements']):
211
+ try:
212
+ if not isinstance(r['description'], str):
213
+ raise SpecError(
214
+ f"'description' in replacement {i + 1} must be a string"
215
+ )
216
+ elif not isinstance(r['old'], str):
217
+ raise SpecError(
218
+ f"'old' in replacement '{r['description']}' must be a string"
219
+ )
220
+ elif not isinstance(r['new'], str):
221
+ raise SpecError(
222
+ f"'new' in replacement '{r['description']}' must be a string"
223
+ )
224
+ except KeyError as e:
225
+ raise SpecError(
226
+ 'missing required field {} in replacement {}'.format(
227
+ e,
228
+ f"'{r['description']}'" if r.get('description') else i + 1,
229
+ )
230
+ )
231
+ raise
232
+
233
+
234
+ def parse_args():
235
+ """Prints help message if necessary."""
236
+ parser = argparse.ArgumentParser(
237
+ description="""
238
+ Create one PKGBUILD from another by applying changes specified in a YAML file.
239
+ """,
240
+ epilog="""
241
+ Must be run in the same directory as a modpkgbuild.yml.
242
+ The PKGBUILD is written to the current working directory.
243
+ Prints a diff to stderr on success.
244
+ """,
245
+ )
246
+ return parser.parse_args()
247
+
248
+
249
+ def message(*args: str, **kwargs):
250
+ """Prints messages to stderr."""
251
+ print('modpkgbuild.py:', *args, **kwargs, file=sys.stderr)
252
+
253
+
254
+ def main():
255
+ parse_args()
256
+
257
+ try:
258
+ spec = parse_spec()
259
+ old = read_old(spec['repo'])
260
+ new = gen_new(old, spec)
261
+ write_new(new)
262
+ except OSError as e:
263
+ message(
264
+ f' {e.filename}:' if e.filename else ' ',
265
+ e.strerror or str(e),
266
+ sep='',
267
+ )
268
+ return 1
269
+ except yaml.YAMLError as e:
270
+ message('failed to parse modpkgbuild.yml:', str(e))
271
+ return 2
272
+ except SpecError as e:
273
+ message('invalid modpkgbuild.yml:', str(e))
274
+ return 2
275
+ except subprocess.CalledProcessError as e:
276
+ message(e.stderr)
277
+ return e.returncode
278
+ except ReplacementError as e:
279
+ message(str(e))
280
+ return 1
281
+
282
+ message(
283
+ 'new PKGBUILD successfully written to ./PKGBUILD,',
284
+ 'with the following changes from the old PKGBUILD:',
285
+ )
286
+ sys.stderr.writelines(
287
+ difflib.unified_diff(
288
+ old.splitlines(keepends=True),
289
+ new.splitlines(keepends=True),
290
+ fromfile=f'{spec["repo"]}:PKGBUILD',
291
+ tofile='PKGBUILD',
292
+ )
293
+ )