47c75b2078
And set Fedora's default behavior is to enable DynamicBuildRequires. All Fedora crates use dynamic BuildRequires and are built only in Rawhide and is unlikely that anybody is building hundreds of crates outside of Fedora infrastructure. Closes: https://pagure.io/fedora-rust/rust2rpm/issue/97 Signed-off-by: Igor Gnatenko <ignatenkobrain@fedoraproject.org>
374 lines
14 KiB
Python
374 lines
14 KiB
Python
import argparse
|
|
import configparser
|
|
import contextlib
|
|
from datetime import datetime, timezone
|
|
import difflib
|
|
import itertools
|
|
import os
|
|
import shlex
|
|
import shutil
|
|
import sys
|
|
import tarfile
|
|
import tempfile
|
|
import time
|
|
import subprocess
|
|
|
|
import jinja2
|
|
import requests
|
|
import tqdm
|
|
|
|
from . import Metadata, licensing, __version__
|
|
from .metadata import normalize_deps
|
|
|
|
DEFAULT_EDITOR = "vi"
|
|
XDG_CACHE_HOME = os.getenv("XDG_CACHE_HOME", os.path.expanduser("~/.cache"))
|
|
CACHEDIR = os.path.join(XDG_CACHE_HOME, "rust2rpm")
|
|
API_URL = "https://crates.io/api/v1/"
|
|
JINJA_ENV = jinja2.Environment(
|
|
loader=jinja2.ChoiceLoader([
|
|
jinja2.FileSystemLoader(["/"]),
|
|
jinja2.PackageLoader("rust2rpm", "templates"),
|
|
]),
|
|
extensions=["jinja2.ext.do"],
|
|
trim_blocks=True,
|
|
lstrip_blocks=True)
|
|
|
|
def get_default_target():
|
|
try:
|
|
os_release_file = open('/etc/os-release')
|
|
except FileNotFoundError:
|
|
os_release_file = open('/usr/lib/os-release')
|
|
with os_release_file:
|
|
conf = configparser.ConfigParser()
|
|
conf.read_file(itertools.chain(["[os-release]"], os_release_file))
|
|
os_release = conf["os-release"]
|
|
os_id = os_release.get("ID")
|
|
os_like = os_release.get("ID_LIKE")
|
|
if os_like is not None:
|
|
os_like = shlex.split(os_like)
|
|
else:
|
|
os_like = []
|
|
|
|
# Order matters here!
|
|
if os_id == "mageia" or ("mageia" in os_like):
|
|
return "mageia"
|
|
elif os_id == "fedora" or ("fedora" in os_like):
|
|
return "fedora"
|
|
elif "suse" in os_like:
|
|
return "opensuse"
|
|
else:
|
|
return "plain"
|
|
|
|
def detect_editor():
|
|
terminal = os.getenv("TERM")
|
|
terminal_is_dumb = terminal is None or terminal == "dumb"
|
|
editor = None
|
|
if not terminal_is_dumb:
|
|
editor = os.getenv("VISUAL")
|
|
if editor is None:
|
|
editor = os.getenv("EDITOR")
|
|
if editor is None:
|
|
if terminal_is_dumb:
|
|
raise Exception("Terminal is dumb, but EDITOR unset")
|
|
else:
|
|
editor = DEFAULT_EDITOR
|
|
return editor
|
|
|
|
def detect_packager():
|
|
# If we're forcing the fallback...
|
|
if os.getenv("RUST2RPM_NO_DETECT_PACKAGER") is not None:
|
|
return None
|
|
|
|
# If we're supplying packager identity through an environment variable...
|
|
packager_identity = os.getenv("RUST2RPM_PACKAGER")
|
|
if packager_identity is not None:
|
|
return packager_identity
|
|
|
|
# If we're detecting packager identity through rpmdev-packager...
|
|
rpmdev_packager = shutil.which("rpmdev-packager")
|
|
if rpmdev_packager is not None:
|
|
return subprocess.check_output(rpmdev_packager, universal_newlines=True).strip()
|
|
|
|
# If we're detecting packager identity through git configuration...
|
|
git = shutil.which("git")
|
|
if git is not None:
|
|
name = subprocess.check_output([git, "config", "user.name"], universal_newlines=True).strip()
|
|
email = subprocess.check_output([git, "config", "user.email"], universal_newlines=True).strip()
|
|
return f"{name} <{email}>"
|
|
|
|
return None
|
|
|
|
def file_mtime(path):
|
|
return datetime.fromtimestamp(os.stat(path).st_mtime, timezone.utc).isoformat()
|
|
|
|
@contextlib.contextmanager
|
|
def remove_on_error(path):
|
|
try:
|
|
yield
|
|
except: # this is supposed to include ^C
|
|
os.unlink(path)
|
|
raise
|
|
|
|
def local_toml(toml, version):
|
|
if os.path.isdir(toml):
|
|
toml = os.path.join(toml, "Cargo.toml")
|
|
|
|
return toml, None, version
|
|
|
|
def local_crate(crate, version):
|
|
cratename, version = os.path.basename(crate)[:-6].rsplit("-", 1)
|
|
return crate, cratename, version
|
|
|
|
def download(crate, version):
|
|
if version is None:
|
|
# Now we need to get latest version
|
|
url = requests.compat.urljoin(API_URL, f"crates/{crate}/versions")
|
|
req = requests.get(url)
|
|
req.raise_for_status()
|
|
versions = req.json()["versions"]
|
|
version = next(version["num"] for version in versions if not version["yanked"])
|
|
|
|
os.makedirs(CACHEDIR, exist_ok=True)
|
|
cratef_base = f"{crate}-{version}.crate"
|
|
cratef = os.path.join(CACHEDIR, cratef_base)
|
|
if not os.path.isfile(cratef):
|
|
url = requests.compat.urljoin(API_URL, f"crates/{crate}/{version}/download#")
|
|
req = requests.get(url, stream=True)
|
|
req.raise_for_status()
|
|
total = int(req.headers["Content-Length"])
|
|
with remove_on_error(cratef), open(cratef, "wb") as f:
|
|
for chunk in tqdm.tqdm(req.iter_content(), f"Downloading {cratef_base}".format(cratef_base),
|
|
total=total, unit="B", unit_scale=True):
|
|
f.write(chunk)
|
|
return cratef, crate, version
|
|
|
|
@contextlib.contextmanager
|
|
def toml_from_crate(cratef, crate, version):
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
target_dir = f"{tmpdir}/"
|
|
with tarfile.open(cratef, "r") as archive:
|
|
for n in archive.getnames():
|
|
if not os.path.abspath(os.path.join(target_dir, n)).startswith(target_dir):
|
|
raise Exception("Unsafe filenames!")
|
|
archive.extractall(target_dir)
|
|
toml_relpath = f"{crate}-{version}/Cargo.toml"
|
|
toml = f"{tmpdir}/{toml_relpath}"
|
|
if not os.path.isfile(toml):
|
|
raise IOError("crate does not contain Cargo.toml file")
|
|
yield toml
|
|
|
|
def make_patch(toml, enabled=True, tmpfile=False):
|
|
if not enabled:
|
|
return []
|
|
|
|
editor = detect_editor()
|
|
|
|
mtime_before = file_mtime(toml)
|
|
toml_before = open(toml).readlines()
|
|
|
|
# When we are editing a git checkout, we should not modify the real file.
|
|
# When we are editing an unpacked crate, we are free to edit anything.
|
|
# Let's keep the file name as close as possible to make editing easier.
|
|
if tmpfile:
|
|
tmpfile = tempfile.NamedTemporaryFile("w+t", dir=os.path.dirname(toml),
|
|
prefix="Cargo.", suffix=".toml")
|
|
tmpfile.writelines(toml_before)
|
|
tmpfile.flush()
|
|
fname = tmpfile.name
|
|
else:
|
|
fname = toml
|
|
subprocess.check_call([editor, fname])
|
|
mtime_after = file_mtime(toml)
|
|
toml_after = open(fname).readlines()
|
|
toml_relpath = "/".join(toml.split("/")[-2:])
|
|
diff = list(difflib.unified_diff(toml_before, toml_after,
|
|
fromfile=toml_relpath, tofile=toml_relpath,
|
|
fromfiledate=mtime_before, tofiledate=mtime_after))
|
|
return diff
|
|
|
|
def _is_path(path):
|
|
return "/" in path or path in {".", ".."}
|
|
|
|
def make_diff_metadata(crate, version, patch=False, store=False):
|
|
if _is_path(crate):
|
|
# Only things that look like a paths are considered local arguments
|
|
if crate.endswith(".crate"):
|
|
cratef, crate, version = local_crate(crate, version)
|
|
else:
|
|
if store:
|
|
raise ValueError("--store-crate can only be used for a crate")
|
|
|
|
toml, crate, version = local_toml(crate, version)
|
|
diff = make_patch(toml, enabled=patch, tmpfile=True)
|
|
metadata = Metadata.from_file(toml)
|
|
return metadata.name, diff, metadata
|
|
else:
|
|
cratef, crate, version = download(crate, version)
|
|
|
|
with toml_from_crate(cratef, crate, version) as toml:
|
|
diff = make_patch(toml, enabled=patch)
|
|
metadata = Metadata.from_file(toml)
|
|
if store:
|
|
shutil.copy2(cratef, os.path.join(os.getcwd(), f"{metadata.name}-{version}.crate"))
|
|
return crate, diff, metadata
|
|
|
|
def to_list(s):
|
|
if not s:
|
|
return []
|
|
return list(filter(None, (l.strip() for l in s.splitlines())))
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser("rust2rpm",
|
|
formatter_class=argparse.RawTextHelpFormatter)
|
|
parser.add_argument("--show-license-map", action="store_true",
|
|
help="Print license mappings and exit")
|
|
parser.add_argument("--translate-license", action="store_true",
|
|
help="Print mapping for specified license and exit")
|
|
parser.add_argument("--no-auto-changelog-entry", action="store_true",
|
|
help="Do not generate a changelog entry")
|
|
parser.add_argument("-", "--stdout", action="store_true",
|
|
help="Print spec and patches into stdout")
|
|
parser.add_argument("-t", "--target", action="store",
|
|
choices=("plain", "fedora", "mageia", "opensuse"), default=get_default_target(),
|
|
help="Distribution target")
|
|
parser.add_argument("-p", "--patch", action="store_true",
|
|
help="Do initial patching of Cargo.toml")
|
|
parser.add_argument("-s", "--store-crate", action="store_true",
|
|
help="Store crate in current directory")
|
|
parser.add_argument("--all-features", action="store_true",
|
|
help="Activate all available features")
|
|
parser.add_argument("--dynamic-buildrequires", action="store_true",
|
|
help="Use dynamic BuildRequires feature")
|
|
parser.add_argument("--no-dynamic-buildrequires", action="store_true",
|
|
help="Do not use dynamic BuildRequires feature")
|
|
parser.add_argument("--suffix", action="store",
|
|
help="Package suffix")
|
|
parser.add_argument("crate", help="crates.io name\n"
|
|
"path/to/local.crate\n"
|
|
"path/to/project/",
|
|
nargs="?")
|
|
parser.add_argument("version", nargs="?", help="crates.io version")
|
|
args = parser.parse_args()
|
|
|
|
if args.show_license_map:
|
|
licensing.dump_sdpx_to_fedora_map(sys.stdout)
|
|
return
|
|
|
|
if args.translate_license:
|
|
license, comments = licensing.translate_license(args.target, args.crate)
|
|
if comments:
|
|
print(comments)
|
|
print(license)
|
|
return
|
|
|
|
if args.crate is None:
|
|
parser.error("required crate/path argument missing")
|
|
|
|
crate, diff, metadata = make_diff_metadata(args.crate, args.version,
|
|
patch=args.patch,
|
|
store=args.store_crate)
|
|
|
|
JINJA_ENV.globals["normalize_deps"] = normalize_deps
|
|
JINJA_ENV.globals["to_list"] = to_list
|
|
template = JINJA_ENV.get_template("main.spec")
|
|
|
|
if args.patch and len(diff) > 0:
|
|
patch_file = f"{metadata.name}-fix-metadata.diff"
|
|
else:
|
|
patch_file = None
|
|
|
|
kwargs = {}
|
|
kwargs["generator_version"] = __version__
|
|
kwargs["crate"] = crate
|
|
kwargs["target"] = args.target
|
|
kwargs["all_features"] = args.all_features
|
|
bins = [tgt for tgt in metadata.targets if tgt.kind == "bin"]
|
|
libs = [tgt for tgt in metadata.targets if tgt.kind in {"lib", "rlib", "proc-macro"}]
|
|
is_bin = len(bins) > 0
|
|
is_lib = len(libs) > 0
|
|
if is_bin:
|
|
kwargs["include_main"] = True
|
|
kwargs["bins"] = bins
|
|
elif is_lib:
|
|
kwargs["include_main"] = False
|
|
else:
|
|
raise ValueError("No bins and no libs")
|
|
kwargs["include_devel"] = is_lib
|
|
|
|
if args.no_auto_changelog_entry:
|
|
kwargs["auto_changelog_entry"] = False
|
|
else:
|
|
kwargs["auto_changelog_entry"] = True
|
|
|
|
if args.target in {"fedora", "mageia", "opensuse"}:
|
|
kwargs["include_build_requires"] = True
|
|
kwargs["include_provides"] = False
|
|
kwargs["include_requires"] = False
|
|
elif args.target == "plain":
|
|
kwargs["include_build_requires"] = True
|
|
kwargs["include_provides"] = True
|
|
kwargs["include_requires"] = True
|
|
else:
|
|
assert False, f"Unknown target {args.target!r}"
|
|
|
|
if args.target == "mageia":
|
|
kwargs["pkg_release"] = "%mkrel 1"
|
|
kwargs["rust_group"] = "Development/Rust"
|
|
elif args.target == "opensuse":
|
|
kwargs["spec_copyright_year"] = time.strftime("%Y")
|
|
kwargs["pkg_release"] = "0"
|
|
kwargs["rust_group"] = "Development/Libraries/Rust"
|
|
else:
|
|
kwargs["pkg_release"] = "1%{?dist}"
|
|
|
|
if args.target == "fedora" and not args.no_dynamic_buildrequires:
|
|
args.dynamic_buildrequires = True
|
|
|
|
kwargs["generate_buildrequires"] = args.dynamic_buildrequires
|
|
|
|
if args.target in {"opensuse", "fedora"}:
|
|
kwargs["date"] = time.strftime("%a %b %d %T %Z %Y")
|
|
else:
|
|
kwargs["date"] = time.strftime("%a %b %d %Y")
|
|
packager_identity = detect_packager()
|
|
if packager_identity is not None:
|
|
kwargs["packager"] = packager_identity
|
|
|
|
if metadata.license is not None:
|
|
license, comments = licensing.translate_license(args.target, metadata.license)
|
|
kwargs["license"] = license
|
|
kwargs["license_comments"] = comments
|
|
|
|
conf = configparser.ConfigParser(interpolation=configparser.ExtendedInterpolation())
|
|
conf.read(["_rust2rpm.conf", ".rust2rpm.conf"])
|
|
if args.target not in conf:
|
|
conf.add_section(args.target)
|
|
|
|
kwargs["distconf"] = conf[args.target]
|
|
|
|
if args.suffix is not None:
|
|
if metadata.name[-1].isdigit():
|
|
suffix = f'-{args.suffix}'
|
|
else:
|
|
suffix = args.suffix
|
|
else:
|
|
suffix = ""
|
|
kwargs["pkg_suffix"] = suffix
|
|
spec_file = f"rust-{metadata.name}{suffix}.spec"
|
|
spec_contents = template.render(md=metadata, patch_file=patch_file, **kwargs)
|
|
if args.stdout:
|
|
print(f"# {spec_file}")
|
|
print(spec_contents)
|
|
if patch_file is not None:
|
|
print(f"# {patch_file}")
|
|
print("".join(diff), end="")
|
|
else:
|
|
with open(spec_file, "w") as fobj:
|
|
fobj.write(spec_contents)
|
|
fobj.write("\n")
|
|
if patch_file is not None:
|
|
with open(patch_file, "w") as fobj:
|
|
fobj.writelines(diff)
|
|
|
|
if __name__ == "__main__":
|
|
main()
|