Skip to content

Commit ad61139

Browse files
cfriedtkartben
authored andcommitted
scripts: west_commands: add the west patch command
In smaller projects and organizations, forking Zephyr is usually a tenable solution for development continuity, in the case that bug-fixes or enhancements need to be applied to Zephyr to unblock development. In larger organizations, perhaps in the absence of healthy patch management, technical debt management, and open-source policies, forking and in-tree changes can quickly get out of hand. In other organizations, it may simply be preferable to have a zero-forking / upstream-first policy. Regardless of the reason, this change adds a `west patch` command that enables users to manage patches locally in their modules, under version control, with complete transparence. The format of the YAML file (detailed in a previous comit) includes fields for filename, checksum, author, email, dates, along with pr and issue links. There are fields indicating whether the patch is upstreamble or whether it has been merged upstream already. There is a custom field that is not validated and can be used for any purpose. Workflows can be created to notify maintainers when a merged patch may be discarded after a version or a commit bump. In Zephyr modules, the file resides conventionally under `zephyr/patches.yml`, and patch files reside under `zephyr/patches/`. Sample usage applying patches (the `-v` argument for additional detail): ```shell west -v patch apply reading patch file zephyr/run-tests-with-rtt-console.patch checking patch integrity... OK patching zephyr... OK reading patch file zephyr/twister-rtt-support.patch checking patch integrity... OK patching zephyr... OK reading patch file zephyr/multiple_icntl.patch checking patch integrity... OK patching zephyr... OK reading patch file zephyr/move-bss-to-end.patch checking patch integrity... OK patching zephyr... OK 4 patches applied successfully \o/ ``` Cleaning previously applied patches ```shell west patch clean ``` After manually corrupting a patch file (the `-r` option will automatically roll-back all changes if one patch fails) ```shell west -v patch apply -r reading patch file zephyr/run-tests-with-rtt-console.patch checking patch integrity... OK patching zephyr... OK reading patch file zephyr/twister-rtt-support.patch checking patch integrity... OK patching zephyr... OK reading patch file zephyr/multiple_icntl.patch checking patch integrity... OK patching zephyr... OK reading patch file zephyr/move-bss-to-end.patch checking patch integrity... FAIL ERROR: sha256 mismatch for zephyr/move-bss-to-end.patch: expect: 00e42e5d89f68f8b07e355821cfcf492faa2f96b506bbe87a9b35a823fd719cb actual: b9900e0c9472a0aaae975370b478bb26945c068497fa63ff409b21d677e5b89f Cleaning zephyr FATAL ERROR: failed to apply patch zephyr/move-bss-to-end.patch ``` Signed-off-by: Chris Friedt <cfriedt@tenstorrent.com>
1 parent 39588e7 commit ad61139

File tree

1 file changed

+351
-0
lines changed

1 file changed

+351
-0
lines changed

scripts/west_commands/patch.py

Lines changed: 351 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,351 @@
1+
# Copyright (c) 2024 Tenstorrent AI ULC
2+
#
3+
# SPDX-License-Identifier: Apache-2.0
4+
5+
import argparse
6+
import hashlib
7+
import os
8+
import shlex
9+
import subprocess
10+
import textwrap
11+
from pathlib import Path
12+
13+
import pykwalify.core
14+
import yaml
15+
from west.commands import WestCommand
16+
17+
try:
18+
from yaml import CSafeLoader as SafeLoader
19+
except ImportError:
20+
from yaml import SafeLoader
21+
22+
WEST_PATCH_SCHEMA_PATH = Path(__file__).parents[1] / "schemas" / "patch-schema.yml"
23+
with open(WEST_PATCH_SCHEMA_PATH) as f:
24+
patches_schema = yaml.load(f, Loader=SafeLoader)
25+
26+
WEST_PATCH_BASE = Path("zephyr") / "patches"
27+
WEST_PATCH_YAML = Path("zephyr") / "patches.yml"
28+
29+
_WEST_MANIFEST_DIR = Path("WEST_MANIFEST_DIR")
30+
_WEST_TOPDIR = Path("WEST_TOPDIR")
31+
32+
33+
class Patch(WestCommand):
34+
def __init__(self):
35+
super().__init__(
36+
"patch",
37+
"apply patches to the west workspace",
38+
"Apply patches to the west workspace",
39+
accepts_unknown_args=False,
40+
)
41+
42+
def do_add_parser(self, parser_adder):
43+
parser = parser_adder.add_parser(
44+
self.name,
45+
help=self.help,
46+
formatter_class=argparse.RawDescriptionHelpFormatter,
47+
description=self.description,
48+
epilog=textwrap.dedent("""\
49+
Applying Patches:
50+
51+
Run "west patch apply" to apply patches.
52+
See "west patch apply --help" for details.
53+
54+
Cleaning Patches:
55+
56+
Run "west patch clean" to clean patches.
57+
See "west patch clean --help" for details.
58+
59+
Listing Patches:
60+
61+
Run "west patch list" to list patches.
62+
See "west patch list --help" for details.
63+
64+
YAML File Format:
65+
66+
The patches.yml syntax is described in "scripts/schemas/patch-schema.yml".
67+
68+
patches:
69+
- path: zephyr/kernel-pipe-fix-not-k-no-wait-and-ge-min-xfer-bytes.patch
70+
sha256sum: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
71+
module: zephyr
72+
author: Kermit D. Frog
73+
email: itsnoteasy@being.gr
74+
date: 2020-04-20
75+
upstreamable: true
76+
merge-pr: https://github.com/zephyrproject-rtos/zephyr/pull/24486
77+
issue: https://github.com/zephyrproject-rtos/zephyr/issues/24485
78+
merge-status: true
79+
merge-commit: af926ae728c78affa89cbc1de811ab4211ed0f69
80+
merge-date: 2020-04-27
81+
apply-command: git apply
82+
comments: |
83+
Songs about rainbows - why are there so many??
84+
custom:
85+
possible-muppets-to-ask-for-clarification-with-the-above-question:
86+
- Miss Piggy
87+
- Gonzo
88+
- Fozzie Bear
89+
- Animal
90+
"""),
91+
)
92+
93+
parser.add_argument(
94+
"-b",
95+
"--patch-base",
96+
help="Directory containing patch files",
97+
metavar="DIR",
98+
default=_WEST_MANIFEST_DIR / WEST_PATCH_BASE,
99+
)
100+
parser.add_argument(
101+
"-l",
102+
"--patch-yml",
103+
help="Path to patches.yml file",
104+
metavar="FILE",
105+
default=_WEST_MANIFEST_DIR / WEST_PATCH_YAML,
106+
)
107+
parser.add_argument(
108+
"-w",
109+
"--west-workspace",
110+
help="West workspace",
111+
metavar="DIR",
112+
default=_WEST_TOPDIR,
113+
)
114+
115+
subparsers = parser.add_subparsers(
116+
dest="subcommand",
117+
metavar="<subcommand>",
118+
help="select a subcommand. If omitted treat it as 'list'",
119+
)
120+
121+
apply_arg_parser = subparsers.add_parser(
122+
"apply",
123+
help="Apply patches",
124+
formatter_class=argparse.RawDescriptionHelpFormatter,
125+
epilog=textwrap.dedent(
126+
"""
127+
Applying Patches:
128+
129+
Run "west patch apply" to apply patches.
130+
"""
131+
),
132+
)
133+
apply_arg_parser.add_argument(
134+
"-r",
135+
"--roll-back",
136+
help="Roll back if any patch fails to apply",
137+
action="store_true",
138+
default=False,
139+
)
140+
141+
subparsers.add_parser(
142+
"clean",
143+
help="Clean patches",
144+
formatter_class=argparse.RawDescriptionHelpFormatter,
145+
epilog=textwrap.dedent(
146+
"""
147+
Cleaning Patches:
148+
149+
Run "west patch clean" to clean patches.
150+
"""
151+
),
152+
)
153+
154+
subparsers.add_parser(
155+
"list",
156+
help="List patches",
157+
formatter_class=argparse.RawDescriptionHelpFormatter,
158+
epilog=textwrap.dedent(
159+
"""
160+
Listing Patches:
161+
162+
Run "west patch list" to list patches.
163+
"""
164+
),
165+
)
166+
167+
return parser
168+
169+
def filter_args(self, args):
170+
try:
171+
manifest_path = self.config.get("manifest.path")
172+
except BaseException:
173+
self.die("could not retrieve manifest path from west configuration")
174+
175+
topdir = Path(self.topdir)
176+
manifest_dir = topdir / manifest_path
177+
178+
if args.patch_base.is_relative_to(_WEST_MANIFEST_DIR):
179+
args.patch_base = manifest_dir / args.patch_base.relative_to(_WEST_MANIFEST_DIR)
180+
if args.patch_yml.is_relative_to(_WEST_MANIFEST_DIR):
181+
args.patch_yml = manifest_dir / args.patch_yml.relative_to(_WEST_MANIFEST_DIR)
182+
if args.west_workspace.is_relative_to(_WEST_TOPDIR):
183+
args.west_workspace = topdir / args.west_workspace.relative_to(_WEST_TOPDIR)
184+
185+
def do_run(self, args, _):
186+
self.filter_args(args)
187+
188+
if not os.path.isfile(args.patch_yml):
189+
self.inf(f"no patches to apply: {args.patch_yml} not found")
190+
return
191+
192+
west_config = Path(args.west_workspace) / ".west" / "config"
193+
if not os.path.isfile(west_config):
194+
self.die(f"{args.west_workspace} is not a valid west workspace")
195+
196+
try:
197+
with open(args.patch_yml) as f:
198+
yml = yaml.load(f, Loader=SafeLoader)
199+
if not yml:
200+
self.inf(f"{args.patch_yml} is empty")
201+
return
202+
pykwalify.core.Core(source_data=yml, schema_data=patches_schema).validate()
203+
except (yaml.YAMLError, pykwalify.errors.SchemaError) as e:
204+
self.die(f"ERROR: Malformed yaml {args.patch_yml}: {e}")
205+
206+
if not args.subcommand:
207+
args.subcommand = "list"
208+
209+
method = {
210+
"apply": self.apply,
211+
"clean": self.clean,
212+
"list": self.list,
213+
}
214+
215+
method[args.subcommand](args, yml)
216+
217+
def apply(self, args, yml):
218+
patches = yml.get("patches", [])
219+
if not patches:
220+
return
221+
222+
patch_count = 0
223+
failed_patch = None
224+
patched_mods = set()
225+
226+
for patch_info in patches:
227+
pth = patch_info["path"]
228+
patch_path = os.path.realpath(Path(args.patch_base) / pth)
229+
230+
apply_cmd = patch_info["apply-command"]
231+
apply_cmd_list = shlex.split(apply_cmd)
232+
233+
self.dbg(f"reading patch file {pth}")
234+
patch_file_data = None
235+
236+
try:
237+
with open(patch_path, "rb") as pf:
238+
patch_file_data = pf.read()
239+
except Exception as e:
240+
self.err(f"failed to read {pth}: {e}")
241+
failed_patch = pth
242+
break
243+
244+
self.dbg("checking patch integrity... ", end="")
245+
expect_sha256 = patch_info["sha256sum"]
246+
hasher = hashlib.sha256()
247+
hasher.update(patch_file_data)
248+
actual_sha256 = hasher.hexdigest()
249+
if actual_sha256 != expect_sha256:
250+
self.dbg("FAIL")
251+
self.err(
252+
f"sha256 mismatch for {pth}:\n"
253+
f"expect: {expect_sha256}\n"
254+
f"actual: {actual_sha256}"
255+
)
256+
failed_patch = pth
257+
break
258+
self.dbg("OK")
259+
patch_count += 1
260+
patch_file_data = None
261+
262+
mod = patch_info["module"]
263+
mod_path = Path(args.west_workspace) / mod
264+
patched_mods.add(mod)
265+
266+
self.dbg(f"patching {mod}... ", end="")
267+
origdir = os.getcwd()
268+
os.chdir(mod_path)
269+
apply_cmd += patch_path
270+
apply_cmd_list.extend([patch_path])
271+
proc = subprocess.run(apply_cmd_list)
272+
if proc.returncode:
273+
self.dbg("FAIL")
274+
self.err(proc.stderr)
275+
failed_patch = pth
276+
break
277+
self.dbg("OK")
278+
os.chdir(origdir)
279+
280+
if not failed_patch:
281+
self.inf(f"{patch_count} patches applied successfully \\o/")
282+
return
283+
284+
if args.roll_back:
285+
self.clean(args, yml, patched_mods)
286+
287+
self.die(f"failed to apply patch {pth}")
288+
289+
def clean(self, args, yml, mods=None):
290+
clean_cmd = yml["clean-command"]
291+
checkout_cmd = yml["checkout-command"]
292+
293+
if not clean_cmd and not checkout_cmd:
294+
self.dbg("no clean or checkout commands specified")
295+
return
296+
297+
clean_cmd_list = shlex.split(clean_cmd)
298+
checkout_cmd_list = shlex.split(checkout_cmd)
299+
300+
origdir = os.getcwd()
301+
for mod, mod_path in Patch.get_mod_paths(args, yml).items():
302+
if mods and mod not in mods:
303+
continue
304+
try:
305+
os.chdir(mod_path)
306+
307+
if checkout_cmd:
308+
self.dbg(f"Running '{checkout_cmd}' in {mod}.. ", end="")
309+
proc = subprocess.run(checkout_cmd_list, capture_output=True)
310+
if proc.returncode:
311+
self.dbg("FAIL")
312+
self.err(f"{checkout_cmd} failed for {mod}\n{proc.stderr}")
313+
else:
314+
self.dbg("OK")
315+
316+
if clean_cmd:
317+
self.dbg(f"Running '{clean_cmd}' in {mod}.. ", end="")
318+
proc = subprocess.run(clean_cmd_list, capture_output=True)
319+
if proc.returncode:
320+
self.dbg("FAIL")
321+
self.err(f"{clean_cmd} failed for {mod}\n{proc.stderr}")
322+
else:
323+
self.dbg("OK")
324+
325+
except Exception as e:
326+
# If this fails for some reason, just log it and continue
327+
self.err(f"failed to clean up {mod}: {e}")
328+
329+
os.chdir(origdir)
330+
331+
def list(self, args, yml):
332+
patches = yml.get("patches", [])
333+
if not patches:
334+
return
335+
336+
for patch_info in patches:
337+
self.inf(patch_info)
338+
339+
@staticmethod
340+
def get_mod_paths(args, yml):
341+
patches = yml.get("patches", [])
342+
if not patches:
343+
return {}
344+
345+
mod_paths = {}
346+
for patch_info in patches:
347+
mod = patch_info["module"]
348+
mod_path = os.path.realpath(Path(args.west_workspace) / mod)
349+
mod_paths[mod] = mod_path
350+
351+
return mod_paths

0 commit comments

Comments
 (0)