Coverage for rust2rpm/patching.py: 53%
75 statements
« prev ^ index » next coverage.py v7.6.7, created at 2024-11-26 22:50 +0100
« prev ^ index » next coverage.py v7.6.7, created at 2024-11-26 22:50 +0100
1"""Module containing functionality for patching Cargo.toml files."""
3import shutil
4import subprocess
5import tempfile
6import time
7from datetime import UTC, datetime
8from difflib import unified_diff
9from pathlib import Path
11from rust2rpm import log
12from rust2rpm.cli import Options
13from rust2rpm.utils import detect_editor
16class PatchError(ValueError):
17 """Raised when re-applying an existing patch fails."""
20def file_mtime_in_iso_format(path: Path) -> str:
21 """Format file modification timestamp in the format expected by patch files."""
22 mtime = path.stat().st_mtime
23 return datetime.fromtimestamp(mtime, UTC).isoformat()
26def make_diff_from_lines(
27 diff_path: str,
28 lines_before: list[str],
29 mtime_before: str,
30 lines_after: list[str],
31 mtime_after: str,
32) -> list[str]:
33 """Format a patch file based on the "before" and "after" states.
35 Arguments:
36 diff_path: Path of the patched file.
37 lines_before: File contents (as list of lines) before changes were applied.
38 mtime_before: File modification timestamp in the expected format.
39 lines_after: File contents (as list of lines) after changes were applied.
40 mtime_after: File modification timestamp in the expected format.
42 Returns:
43 Patch in "unified diff" format (as list of lines).
45 """
46 return list(
47 unified_diff(
48 lines_before,
49 lines_after,
50 fromfile=diff_path,
51 tofile=diff_path,
52 fromfiledate=mtime_before,
53 tofiledate=mtime_after,
54 lineterm="",
55 ),
56 )
59def preprocess_cargo_toml(contents: str) -> str | None:
60 """Automatically pre-process contents of a Cargo.toml file.
62 Arguments:
63 contents: Original contents of the Cargo.toml file.
65 Returns:
66 Modified Cargo.toml contents or `None` if no modifications were made.
68 """
69 if shutil.which("rust2rpm-helper"): 69 ↛ 72line 69 didn't jump to line 72 because the condition on line 69 was always true
70 return preprocess_cargo_toml_helper(contents)
72 log.warn("rust2rpm-helper is not installed. Skipping automatic patching of Cargo.toml.")
73 return None
76def preprocess_cargo_toml_helper(contents: str) -> str | None:
77 """Call rust2rpm-helper executable to pre-process a Cargo.toml file.
79 Arguments:
80 contents: Original contents of the Cargo.toml file.
82 Returns:
83 Modified Cargo.toml contents or `None` if no modifications were made.
85 """
86 # S603: rust2rpm-helper validates input itself
87 # S607: allowing use of "rust2rpm-helper" from $PATH is intentional
89 ret1 = subprocess.run( # noqa: S603
90 ["rust2rpm-helper", "normalize-version", "-"], # noqa: S607
91 input=contents,
92 text=True,
93 check=True,
94 stdout=subprocess.PIPE,
95 )
96 patched1 = ret1.stdout or contents
98 ret2 = subprocess.run( # noqa: S603
99 ["rust2rpm-helper", "strip-foreign", "-"], # noqa: S607
100 input=patched1,
101 text=True,
102 check=True,
103 stdout=subprocess.PIPE,
104 )
105 patched2 = ret2.stdout or patched1
107 if patched2 == contents:
108 return None
110 return patched2
113def reapply_patch(toml_path: Path, reapply_path: Path) -> bool:
114 """Attempt to re-apply an existing patch file.
116 Arguments:
117 toml_path: Path to the Cargo.toml file.
118 reapply_path: Path to the patch file.
120 Returns:
121 Boolean flag indicating whether the patch applied successfully.
123 """
124 patch = shutil.which("patch")
126 if patch is None:
127 msg = "No 'patch' executable found."
128 raise PatchError(msg)
130 if not reapply_path.exists():
131 msg = "No applicable patch file found."
132 raise PatchError(msg)
134 # copy file to a temporary directory to work around weird behaviour of "patch"
135 with tempfile.TemporaryDirectory() as temp_dir:
136 temp_toml = Path(temp_dir) / toml_path.name
137 shutil.copy2(toml_path, temp_toml)
139 try:
140 subprocess.run( # noqa: S603
141 [patch, "-p1", str(temp_toml), str(reapply_path)],
142 check=True,
143 capture_output=True,
144 )
145 except subprocess.CalledProcessError:
146 return False
147 else:
148 shutil.copy2(temp_toml, toml_path)
149 return True
152def make_patches(
153 name: str,
154 version: str,
155 toml_path: Path,
156 reapply_path: Path | None,
157 options: Options,
158) -> tuple[list[str] | None, list[str] | None]:
159 """Prepare patches for Cargo.toml (automatic and manual changes).
161 Any automatically applied changes to Cargo.toml are returned as the first
162 patch. Manual changes to Cargo.toml are either based on changes made
163 interactively in the opened editor, or based on changes from a successfully
164 re-applied existing patch, or both.
166 Arguments:
167 name: Name of the crate (used to construct the file path in the patch).
168 version: Version of the crate (used to construct the file path in the patch).
169 toml_path: Path to the Cargo.toml file that will be patched.
170 reapply_path: Optional path to an existing patch file that is attempted
171 to be reapplied.
172 options: Options and flags derived from command-line arguments.
174 Returns:
175 Tuple of (automatic, manual) patch file contents as lists of lines,
176 or `None` in case no changes were made.
178 """
179 mtime_before = file_mtime_in_iso_format(toml_path)
181 with toml_path.open() as file:
182 toml_before = file.read()
184 diff1 = diff2 = None
185 diff_path = f"{name}-{version}/Cargo.toml"
187 # patching Cargo.toml involves multiple steps:
188 #
189 # 1) attempt to automatically drop "foreign" dependencies
190 # If this succeeded, remove the original Cargo.toml file, write the
191 # changed contents back to disk, and generate a diff.
192 # 2) if requested, open Cargo.toml in an editor for further, manual changes
193 #
194 # The changes from *both* steps must be reflected in the Cargo.toml file
195 # that ends up on disk after this function returns. Otherwise, the
196 # calculated metadata and generated spec file will not reflect the patches
197 # to Cargo.toml that were generated here.
199 if toml_after := options.patch_foreign and preprocess_cargo_toml(toml_before):
200 # remove original Cargo.toml file
201 toml_path.unlink()
203 # write auto-patched Cargo.toml to disk
204 with toml_path.open("w") as file:
205 file.write(toml_after)
207 mtime_after1 = file_mtime_in_iso_format(toml_path)
208 diff1 = make_diff_from_lines(
209 diff_path,
210 toml_before.splitlines(),
211 mtime_before,
212 toml_after.splitlines(),
213 mtime_after1,
214 )
215 else:
216 toml_after = toml_before
218 # early exit
219 if not (options.patch or reapply_path): 219 ↛ 223line 219 didn't jump to line 223 because the condition on line 219 was always true
220 return diff1, diff2
222 # attempt to reapply existing patch
223 if reapply_path and not reapply_patch(toml_path, reapply_path):
224 if options.patch:
225 log.warn("Re-applying existing patch failed, opening editor instead.")
226 # sleep so the user has an actual chance to see this log message
227 time.sleep(2)
228 else:
229 msg = "Failed to re-apply existing patch."
230 raise PatchError(msg)
232 # open editor for Cargo.toml
233 if options.patch:
234 editor = detect_editor()
235 subprocess.check_call([editor, toml_path]) # noqa: S603
237 with toml_path.open() as file:
238 toml_after2 = file.read()
240 mtime_after2 = file_mtime_in_iso_format(toml_path)
241 diff2 = make_diff_from_lines(
242 diff_path,
243 toml_after.splitlines(),
244 mtime_before,
245 toml_after2.splitlines(),
246 mtime_after2,
247 )
249 return diff1, diff2