rust2rpm/rust2rpm/patching.py

228 lines
7.2 KiB
Python

import ast
from datetime import datetime, timezone
from difflib import unified_diff
import re
import os
import shutil
import subprocess
from typing import Optional
from rust2rpm import cfg, log
from rust2rpm.utils import detect_editor
# [target.'cfg(not(any(target_os="windows", target_os="macos")))'.dependencies]
# [target."cfg(windows)".dependencies.winapi]
# [target."cfg(target_arch = \"wasm32\")".dev-dependencies.wasm-bindgen-test]
TARGET_DEPENDENCY_LINE = re.compile(
r"""
^ \[ target\.(?P<cfg>(?P<quote>['"])cfg\(.*\)(?P=quote))
\.
(?P<type> dependencies|build-dependencies|dev-dependencies)
(?:\. (?P<feature>[a-zA-Z0-9_-]+) )?
] \s* $
""",
re.VERBOSE,
)
def filter_out_features_re(dropped_features: set[str]) -> re.Pattern:
# This is a bit simplistic. But it doesn't seem worth the trouble to write
# a grammar for this. Can be always done later. If we parse this using a
# grammar, we beget the question of how to preserve formatting idiosyncrasies.
# Regexp replacement makes it trivial to minimize changes.
match_features = "|".join(dropped_features)
match_suffix = f"(?:/[{cfg.IDENT_CHARS[1]}]+)?"
return re.compile(
rf"""
(?P<comma> ,)? \s* (?P<quote>['"])
({match_features}) {match_suffix}
(?P=quote) \s* (?(comma) |,?) \s*
""",
re.VERBOSE,
)
def file_mtime_in_iso_format(path: str) -> str:
mtime = os.stat(path).st_mtime
return datetime.fromtimestamp(mtime, timezone.utc).isoformat()
def make_diff_from_lines(
diff_path: str, lines_before: list[str], mtime_before: str, lines_after: list[str], mtime_after: str
) -> list[str]:
return list(
unified_diff(
lines_before,
lines_after,
fromfile=diff_path,
tofile=diff_path,
fromfiledate=mtime_before,
tofiledate=mtime_after,
lineterm="",
)
)
def preprocess_cargo_toml(contents: str, features: set[str]) -> Optional[str]:
if shutil.which("rust2rpm-helper"):
return preprocess_cargo_toml_helper(contents)
else:
log.warn(
"Falling back to broken built-in implementation for stripping non-applicable "
+ "target-specific depdendencies: rust2rpm-helper is not installed"
)
return preprocess_cargo_toml_fallback(contents, features)
def preprocess_cargo_toml_helper(contents: str) -> Optional[str]:
ret1 = subprocess.run(
["rust2rpm-helper", "normalize-version", "-"], input=contents, text=True, stdout=subprocess.PIPE
)
ret1.check_returncode()
patched1 = ret1.stdout or contents
ret2 = subprocess.run(["rust2rpm-helper", "strip-foreign", "-"], input=patched1, text=True, stdout=subprocess.PIPE)
ret2.check_returncode()
patched2 = ret2.stdout or patched1
if patched2 == contents:
return None
else:
return patched2
def preprocess_cargo_toml_fallback(contents: str, features: set[str]) -> Optional[str]:
lines = contents.splitlines()
kept_lines = []
dropped_lines = 0
dropped_optional_deps = set()
keep = True
feature = None
for line in lines:
if m := TARGET_DEPENDENCY_LINE.match(line):
expr = m.group("cfg")
expr = ast.literal_eval(expr)
try:
keep = cfg.parse_and_evaluate(expr)
except (ValueError, cfg.ParseException):
log.warn(f"Could not evaluate {expr!r}, treating as true.")
keep = True
if not keep:
feature = m.group("feature")
log.info(f"Dropping target-specific dependency on {feature!r}.")
elif line == "optional = true\n" and feature:
if not keep:
# dropped feature was optional:
# remove occurrences from feature dependencies
if feature in features:
dropped_optional_deps.add(feature)
else:
dropped_optional_deps.add(f"dep:{feature}")
else:
# optional dependency occurs in multiple targets:
# do not drop from feature dependencies
if feature in dropped_optional_deps:
dropped_optional_deps.remove(feature)
if f"dep:{feature}" in dropped_optional_deps:
dropped_optional_deps.remove(f"dep:{feature}")
elif line.startswith("["):
# previous section ended, let's keep printing lines again
keep = True
if keep:
kept_lines.append(line)
else:
dropped_lines += 1
if not dropped_lines:
# nothing to do, let's bail out
return None
output_lines = []
in_features = False
feature_filter = filter_out_features_re(dropped_optional_deps)
for line in kept_lines:
if line.rstrip() == "[features]":
in_features = True
elif line.startswith("["):
in_features = False
elif in_features:
line = re.sub(feature_filter, "", line)
if not line:
continue
output_lines += [line]
return "\n".join(output_lines)
def make_patches(
name: str, version: str, patch: bool, patch_foreign: bool, toml_path: str, features: set[str]
) -> tuple[Optional[list[str]], Optional[list[str]]]:
"""Returns up to two patches (automatic and manual).
For the manual patch, an editor is spawned to open the file at `toml_path`
and a diff is made after the editor returns.
"""
mtime_before = file_mtime_in_iso_format(toml_path)
with open(toml_path) as file:
toml_before = file.read()
diff1 = diff2 = None
diff_path = f"{name}-{version}/Cargo.toml"
# patching Cargo.toml involves multiple steps:
#
# 1) attempt to automatically drop "foreign" dependencies
# If this succeeded, remove the original Cargo.toml file, write the
# changed contents back to disk, and generate a diff.
# 2) if requested, open Cargo.toml in an editor for further, manual changes
#
# The changes from *both* steps must be reflected in the Cargo.toml file
# that ends up on disk after this function returns. Otherwise, the
# calculated metadata and generated spec file will not reflect the patches
# to Cargo.toml that were generated here.
if toml_after := patch_foreign and preprocess_cargo_toml(toml_before, features):
# remove original Cargo.toml file
os.remove(toml_path)
# write auto-patched Cargo.toml to disk
with open(toml_path, "w") as file:
file.write(toml_after)
mtime_after1 = file_mtime_in_iso_format(toml_path)
diff1 = make_diff_from_lines(
diff_path, toml_before.splitlines(), mtime_before, toml_after.splitlines(), mtime_after1
)
else:
toml_after = toml_before
if patch:
# open editor for Cargo.toml
editor = detect_editor()
subprocess.check_call([editor, toml_path])
with open(toml_path) as file:
toml_after2 = file.read()
mtime_after2 = file_mtime_in_iso_format(toml_path)
diff2 = make_diff_from_lines(
diff_path, toml_after.splitlines(), mtime_before, toml_after2.splitlines(), mtime_after2
)
return diff1, diff2