rust2rpm/rust2rpm/__main__.py
Igor Gnatenko 47c75b2078
Add --no-dynamic-buildrequires options
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>
2019-12-03 19:23:18 +01:00

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()