Blob Blame History Raw
From 83a9b92d68e8c5d6f2ab75b287be7ea69275fb90 Mon Sep 17 00:00:00 2001
From: Igor Gnatenko <ignatenkobrain@fedoraproject.org>
Date: Fri, 26 Oct 2018 11:20:13 +0200
Subject: [PATCH 7/7] split features into subpackages

References: https://discussion.fedoraproject.org/t/rfc-new-crates-packaging-design-features-have-their-own-subpackages/563?u=ignatenkobrain
Signed-off-by: Igor Gnatenko <ignatenkobrain@fedoraproject.org>
---
 data/cargo.attr              |   4 +-
 data/macros.cargo            |  10 ++
 rust2rpm/__main__.py         |  11 +-
 rust2rpm/inspector.py        |  22 ++-
 rust2rpm/metadata.py         | 315 +++++++++++++++++------------------
 rust2rpm/templates/main.spec | 141 ++++++++--------
 6 files changed, 259 insertions(+), 244 deletions(-)

diff --git a/data/cargo.attr b/data/cargo.attr
index 392a72b..4910b5c 100644
--- a/data/cargo.attr
+++ b/data/cargo.attr
@@ -1,3 +1,3 @@
-%__cargo_provides  %{_bindir}/cargo-inspector --provides
-%__cargo_requires  %{_bindir}/cargo-inspector --requires
+%__cargo_provides  %{_bindir}/cargo-inspector --provides --feature=%{__cargo_feature_from_name -n %{name}}
+%__cargo_requires  %{_bindir}/cargo-inspector --requires --feature=%{__cargo_feature_from_name -n %{name}}
 %__cargo_path      ^%{cargo_registry}/[^/]+/Cargo\\.toml$
diff --git a/data/macros.cargo b/data/macros.cargo
index a0c456a..7fb025b 100644
--- a/data/macros.cargo
+++ b/data/macros.cargo
@@ -84,3 +84,13 @@ if %__cargo_is_bin; then                                            \
   %{__rm} %{buildroot}%{_prefix}/.crates.toml                       \
 fi \
 )
+
+%__cargo_feature_from_name(n:) %{lua:
+local name = rpm.expand("%{-n*}")
+local feature = string.match(name, "^.+%+(.+)-devel$")
+if feature == nil then
+  print()
+else
+  print(feature)
+end
+}
diff --git a/rust2rpm/__main__.py b/rust2rpm/__main__.py
index f23ebbc..d19cb47 100644
--- a/rust2rpm/__main__.py
+++ b/rust2rpm/__main__.py
@@ -18,15 +18,19 @@ import requests
 import tqdm
 
 from . import Metadata, licensing
+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"), ]),
-                               trim_blocks=True, lstrip_blocks=True)
+                                   jinja2.FileSystemLoader(["/"]),
+                                   jinja2.PackageLoader("rust2rpm", "templates"),
+                               ]),
+                               extensions=["jinja2.ext.do"],
+                               trim_blocks=True,
+                               lstrip_blocks=True)
 
 def get_default_target():
     # TODO: add fallback for /usr/lib/os-release
@@ -227,6 +231,7 @@ def main():
                                                patch=args.patch,
                                                store=args.store_crate)
 
+    JINJA_ENV.globals["normalize_deps"] = normalize_deps
     template = JINJA_ENV.get_template("main.spec")
 
     if args.patch and len(diff) > 0:
diff --git a/rust2rpm/inspector.py b/rust2rpm/inspector.py
index 2d488b2..9e79e88 100644
--- a/rust2rpm/inspector.py
+++ b/rust2rpm/inspector.py
@@ -1,8 +1,8 @@
 import argparse
-import itertools
 import sys
 
 from . import Metadata
+from .metadata import normalize_deps
 
 def main():
     parser = argparse.ArgumentParser()
@@ -10,18 +10,23 @@ def main():
     group.add_argument("-n", "--name", action="store_true", help="Print name")
     group.add_argument("-v", "--version", action="store_true", help="Print version")
     group.add_argument("-t", "--target-kinds", action="store_true", help="Print target kinds")
+    group.add_argument("-l", "--list-features", action="store_true", help="Print features")
     group.add_argument("-P", "--provides", action="store_true", help="Print Provides")
     group.add_argument("-R", "--requires", action="store_true", help="Print Requires")
     group.add_argument("-BR", "--build-requires", action="store_true", help="Print BuildRequires")
     group.add_argument("-TR", "--test-requires", action="store_true", help="Print TestRequires")
+    parser.add_argument("-f", "--feature", help="Feature to work on")
     parser.add_argument("file", nargs="*", help="Path(s) to Cargo.toml")
     args = parser.parse_args()
 
     files = args.file or sys.stdin.readlines()
 
+    if not args.feature:
+        args.feature = None
+
     def print_deps(deps):
         if len(deps) > 0:
-            print("\n".join(str(dep) for dep in deps))
+            print("\n".join(sorted(normalize_deps(deps))))
 
     for f in files:
         f = f.rstrip()
@@ -32,17 +37,20 @@ def main():
             print(md.version)
         if args.target_kinds:
             print("\n".join(set(tgt.kind for tgt in md.targets)))
+        if args.list_features:
+            for f in sorted(f for f in md.dependencies if f is not None):
+                print(f)
         if args.provides:
-            print_deps(md.provides)
-        if args.requires or args.build_requires:
-            print_deps(list(itertools.chain(md.requires, md.build_requires)))
-        if args.test_requires:
-            print_deps(md.test_requires)
+            print(md.provides(args.feature))
         if args.requires:
             # Someone should own /usr/share/cargo/registry
             print("cargo")
+            print_deps(md.requires(args.feature))
         if args.build_requires:
             print("rust-packaging")
+            print_deps(md.requires(args.feature or "default", resolve=True))
+        if args.test_requires:
+            print_deps(md.dev_dependencies)
 
 if __name__ == "__main__":
     main()
diff --git a/rust2rpm/metadata.py b/rust2rpm/metadata.py
index 5adeb65..95aa142 100644
--- a/rust2rpm/metadata.py
+++ b/rust2rpm/metadata.py
@@ -1,208 +1,205 @@
 __all__ = ["Dependency", "Metadata"]
 
-import itertools
+import copy
 import json
 import subprocess
-import sys
 
 import semantic_version as semver
 import rustcfg
 
-class Target(object):
-    def __init__(self, kind, name):
-        self.kind = kind
+class Target:
+    def __init__(self, name, kind):
         self.name = name
+        self.kind = kind
 
     def __repr__(self):
-        return "<Target {self.kind}|{self.name}>".format(self=self)
-
-
-def _req_to_str(name, spec=None, feature=None):
-    f_part = "/{}".format(feature) if feature is not None else ""
-    basestr = "crate({}{})".format(name, f_part)
-    if spec is None:
-        return basestr
-    if spec.kind == spec.KIND_EQUAL:
-        spec.kind = spec.KIND_SHORTEQ
-    if spec.kind == spec.KIND_ANY:
-        if spec.spec == "":
-            # Just wildcard
-            return basestr
-        else:
-            # Wildcard in string
-            assert False, spec.spec
-    version = str(spec.spec).replace("-", "~")
-    return "{} {} {}".format(basestr, spec.kind, version)
+        return f"<Target {self.name} ({self.kind})>"
 
-class Dependency(object):
-    def __init__(self, name, req, features=(), provides=False):
+class Dependency:
+    def __init__(self, name, req=None, features=(), optional=False):
         self.name = name
-        self.spec = self._parse_req(req)
+        self.req = req
         self.features = features
-        self.provides = provides
-        if self.provides:
-            if len(self.spec.specs) > 1 or \
-               (len(self.spec.specs) == 1 and self.spec.specs[0].kind != self.spec.specs[0].KIND_EQUAL):
-                raise Exception("Provides can't be applied to ranged version, {!r}".format(self.spec))
-
-    def __repr__(self):
-        if self.provides:
-            spec = self.spec.specs[0]
-            provs = [_req_to_str(self.name, spec)]
-            for feature in self.features:
-                provs.append(_req_to_str(self.name, spec, feature))
-            return " and ".join(provs)
-
-        reqs = [_req_to_str(self.name, spec=req) for req in self.spec.specs]
-        features = [_req_to_str(self.name, feature=feature) for feature in self.features]
+        self.optional = optional
 
-        use_rich = False
-        if len(reqs) > 1:
-            reqstr = "({})".format(" with ".join(reqs))
-            use_rich = True
-        elif len(reqs) == 1:
-            reqstr = reqs[0]
-        else:
-            reqstr = ""
-        if len(features) > 0:
-            featurestr = " with ".join(features)
-            use_rich = True
-        else:
-            featurestr = ""
-
-        if use_rich:
-            if reqstr and featurestr:
-                return "({} with {})".format(reqstr, featurestr)
-            elif reqstr and not featurestr:
-                return reqstr
-            elif not reqstr and featurestr:
-                return "({})".format(featurestr)
-            else:
-                assert False
-        else:
-            return reqstr
+    @classmethod
+    def from_json(cls, metadata):
+        features = set(metadata['features'])
+        if metadata['uses_default_features']:
+            features.add('default')
+        kwargs = {'name': metadata['name'],
+                  'req': metadata['req'],
+                  'optional': metadata['optional'],
+                  'features': features}
+        return cls(**kwargs)
 
     @staticmethod
-    def _parse_req(s):
-        if "*" in s and s != "*":
-            # XXX: https://github.com/rbarrois/python-semanticversion/issues/51
-            s = "~{}".format(s.replace(".*", "", 1))
-            if ".*" in s:
-                s = s.replace(".*", "")
-        spec = semver.Spec(s.replace(" ", ""))
-        parsed = []
+    def _normalize_req(req):
+        if "*" in req:
+            return NotImplemented
+        spec = semver.Spec(req)
+        reqs = []
         for req in spec.specs:
-            ver = req.spec
             if req.kind == req.KIND_ANY:
-                parsed.append("*")
+                # Any means any
                 continue
+            ver = req.spec
+            if ver.prerelease or req.kind in (req.KIND_NEQ, req.KIND_EMPTY):
+                return NotImplemented
             coerced = semver.Version.coerce(str(ver))
-            if req.kind in (req.KIND_CARET, req.KIND_TILDE):
-                if ver.prerelease:
-                    # pre-release versions only match the same x.y.z
-                    if ver.patch is not None:
-                        upper = ver.next_patch()
-                    elif ver.minor is not None:
-                        upper = ver.next_minor()
-                    else:
-                        upper = ver.next_major()
-                elif req.kind == req.KIND_CARET:
-                    if ver.major == 0:
-                        if ver.minor is not None:
-                            if ver.patch is None or ver.minor != 0:
-                                upper = ver.next_minor()
-                            else:
-                                upper = ver.next_patch()
+            if req.kind == req.KIND_EQUAL:
+                req.kind = req.KIND_SHORTEQ
+            if req.kind in (req.KIND_CARET, req.KIND_COMPATIBLE):
+                if ver.major == 0:
+                    if ver.minor is not None:
+                        if ver.minor != 0 or ver.patch is None:
+                            upper = ver.next_minor()
                         else:
-                            upper = ver.next_major()
+                            upper = ver.next_patch()
                     else:
                         upper = ver.next_major()
-                elif req.kind == req.KIND_TILDE:
-                    if ver.minor is None:
-                        upper = ver.next_major()
-                    else:
-                        upper = ver.next_minor()
                 else:
-                    assert False
-                parsed.append(">={}".format(coerced))
-                parsed.append("<{}".format(upper))
-            elif req.kind == req.KIND_NEQ:
-                parsed.append(">{}".format(coerced))
-                parsed.append("<{}".format(coerced))
-            elif req.kind in (req.KIND_EQUAL, req.KIND_GT, req.KIND_GTE, req.KIND_LT, req.KIND_LTE):
-                parsed.append("{}{}".format(req.kind, coerced))
+                    upper = ver.next_major()
+                reqs.append((">=", coerced))
+                reqs.append(("<", upper))
+            elif req.kind == req.KIND_TILDE:
+                if ver.minor is None:
+                    upper = ver.next_major()
+                else:
+                    upper = ver.next_minor()
+                reqs.append((">=", coerced))
+                reqs.append(("<", upper))
+            elif req.kind in (req.KIND_SHORTEQ,
+                              req.KIND_GT,
+                              req.KIND_GTE,
+                              req.KIND_LT,
+                              req.KIND_LTE):
+                reqs.append((str(req.kind), coerced))
             else:
-                assert False, req.kind
-        return semver.Spec(",".join(parsed))
+                raise AssertionError(f"Found unhandled kind: {req.kind}")
+        return reqs
 
-class Metadata(object):
-    def __init__(self):
-        self.name = None
+    @staticmethod
+    def _apply_reqs(name, reqs, feature=None):
+        fstr = f"/{feature}" if feature is not None else ""
+        cap = f"crate({name}{fstr})"
+        if not reqs:
+            return cap
+        deps = " with ".join(f"{cap} {op} {version}" for op, version in reqs)
+        if len(reqs) > 1:
+            return f"({deps})"
+        else:
+            return deps
+
+    def normalize(self):
+        return [self._apply_reqs(self.name, self._normalize_req(self.req), feature)
+                for feature in self.features or (None,)]
+
+    def __repr__(self):
+        return f"<Dependency: {self.name} {self.req} ({', '.join(sorted(self.features))})>"
+
+    def __str__(self):
+        return "\n".join(self.normalize())
+
+class Metadata:
+    def __init__(self, name, version):
+        self.name = name
+        self.version = version
         self.license = None
         self.license_file = None
         self.readme = None
         self.description = None
-        self.version = None
-        self._targets = []
-        self.provides = []
-        self.requires = []
-        self.build_requires = []
-        self.test_requires = []
+        self.targets = set()
+        self.dependencies = {}
+        self.dev_dependencies = set()
 
     @classmethod
     def from_json(cls, metadata):
-        self = cls()
-
         md = metadata
-        self.name = md["name"]
+        self = cls(md["name"], md["version"])
+
         self.license = md["license"]
         self.license_file = md["license_file"]
         self.readme = md["readme"]
         self.description = md.get("description")
-        self.version = md["version"]
-        version = "={}".format(self.version)
-
-        # Targets
-        self.targets = [Target(tgt["kind"][0], tgt["name"]) for tgt in md["targets"]]
-
-        # Provides
-        # All optional dependencies are also features
-        # https://github.com/rust-lang/cargo/issues/4911
-        features = itertools.chain((x["name"] for x in md["dependencies"] if x["optional"]),
-                                   md["features"])
-        provides = Dependency(self.name, version, features=features, provides=True)
-        self.provides = str(provides).split(" and ")
-
-        ev = rustcfg.Evaluator.platform()
-
-        # Dependencies
-        for dep in md["dependencies"]:
-            kind = dep["kind"]
-            if kind is None:
-                requires = self.requires
-            elif kind == "build":
-                requires = self.build_requires
-            elif kind == "dev":
-                requires = self.test_requires
-            else:
-                raise ValueError("Unknown kind: {!r}, please report bug.".format(kind))
 
-            target = dep["target"]
-            if target is None:
-                pass
+        # dependencies + build-dependencies → runtime
+        deps_by_name = {dep["name"]: Dependency.from_json(dep)
+                        for dep in md["dependencies"]
+                        if dep["kind"] != "dev"}
+
+        deps_by_feature = {}
+        for feature, f_deps in md["features"].items():
+            features = {None}
+            deps = set()
+            for dep in f_deps:
+                if dep in md["features"]:
+                    features.add(dep)
+                else:
+                    pkg, _, f = dep.partition("/")
+                    dep = copy.deepcopy(deps_by_name[pkg])
+                    if f:
+                        dep.features = {f}
+                    deps.add(dep)
+            deps_by_feature[feature] = (features, deps)
+
+        mandatory_deps = set()
+        for dep in deps_by_name.values():
+            if dep.optional:
+                deps_by_feature[dep.name] = ({None}, {copy.deepcopy(dep)})
             else:
-                cond = ev.parse_and_eval(target)
-                if not cond:
-                    print(f'Dependency {dep["name"]} for target {target!r} is not needed, ignoring.',
-                          file=sys.stderr)
-                    continue
+                mandatory_deps.add(copy.deepcopy(dep))
+        deps_by_feature[None] = (set(), mandatory_deps)
+
+        if "default" not in deps_by_feature:
+            deps_by_feature["default"] = ({None}, set())
 
-            requires.append(Dependency(dep["name"], dep["req"], features=dep["features"]))
+        self.dependencies = deps_by_feature
+        self.dev_dependencies = {Dependency.from_json(dep)
+                                 for dep in md["dependencies"]
+                                 if dep["kind"] == "dev"}
+
+        self.targets = {Target(tgt["name"], tgt["kind"][0])
+                        for tgt in md["targets"]}
 
         return self
 
     @classmethod
     def from_file(cls, path):
         metadata = subprocess.check_output(["cargo", "read-manifest",
-                                            "--manifest-path={}".format(path)])
+                                            f"--manifest-path={path}"])
         return cls.from_json(json.loads(metadata))
+
+    @property
+    def all_dependencies(self):
+        return set().union(*(x[1] for x in self.dependencies.values()))
+
+    def provides(self, feature=None):
+        if feature not in self.dependencies:
+            raise KeyError(f"Feature {feature!r} doesn't exist")
+        return Dependency(self.name, f"={self.version}", features={feature})
+
+    @classmethod
+    def _resolve(cls, deps_by_feature, feature):
+        all_features = set()
+        all_deps = set()
+        ff, dd = copy.deepcopy(deps_by_feature[feature])
+        all_features |= ff
+        all_deps |= dd
+        for f in ff:
+            ff1, dd1 = cls._resolve(deps_by_feature, f)
+            all_features |= ff1
+            all_deps |= dd1
+        return all_features, all_deps
+
+    def requires(self, feature=None, resolve=False):
+        if resolve:
+            return self._resolve(self.dependencies, feature)[1]
+        else:
+            features, deps = self.dependencies[feature]
+            fdeps = set(Dependency(self.name, f"={self.version}", features={feature})
+                        for feature in features)
+            return fdeps | deps
+
+def normalize_deps(deps):
+    return set().union(*(d.normalize() for d in deps))
diff --git a/rust2rpm/templates/main.spec b/rust2rpm/templates/main.spec
index 7dbcc3f..4acfab7 100644
--- a/rust2rpm/templates/main.spec
+++ b/rust2rpm/templates/main.spec
@@ -48,82 +48,101 @@ Patch0:         {{ patch_file }}
 ExclusiveArch:  %{rust_arches}
 
 BuildRequires:  rust-packaging
-{% if include_build_requires %}
-{% if md.requires|length > 0 %}
-# [dependencies]
-{% for req in md.requires|sort(attribute="name") %}
+{# We will put all non-optional and optional dependencies until
+   https://github.com/rust-lang/cargo/issues/5133
+   is solved
+{% set buildrequires = normalize_deps(md.requires("default", resolve=True))|sort %}
+#}
+{% set buildrequires = normalize_deps(md.all_dependencies)|sort %}
+{% for req in buildrequires %}
 BuildRequires:  {{ req }}
 {% endfor %}
-{% endif %}
-{% if md.build_requires|length > 0 %}
-# [build-dependencies]
-{% for req in md.build_requires|sort(attribute="name") %}
-BuildRequires:  {{ req }}
-{% endfor %}
-{% endif %}
-{% if md.test_requires|length > 0 %}
+{% set testrequires = normalize_deps(md.dev_dependencies)|sort %}
+{% if testrequires|length > 0 %}
 %if %{with check}
-# [dev-dependencies]
-{% for req in md.test_requires|sort(attribute="name") %}
+  {% for req in testrequires %}
 BuildRequires:  {{ req }}
-{% endfor %}
+  {% endfor %}
 %endif
 {% endif %}
-{% endif %}
 
-%description
+%global _description \
+{% if md.description is none %}
 %{summary}.
+{% else %}
+{{ md.description|wordwrap(wrapstring="\\\n")|trim }}
+{% endif %}
+
+%description %{_description}
 
 {% if include_main %}
 %package     -n %{crate}
 Summary:        %{summary}
-{% if rust_group is defined %}
+  {% if rust_group is defined %}
 Group:          # FIXME
-{% endif %}
+  {% endif %}
 
 %description -n %{crate}
 %{summary}.
 
-{% endif %}
+%files       -n %{crate}
+  {% if md.license_file is not none %}
+%license {{ md.license_file }}
+  {% endif %}
+  {% if md.readme is not none %}
+%doc {{ md.readme }}
+  {% endif %}
+  {% for bin in bins %}
+%{_bindir}/{{ bin.name }}
+  {% endfor %}
+{% endif -%}
+
 {% if include_devel %}
-%package        devel
+  {% set features = md.dependencies.keys()|list %}
+  {% do features.remove(None) %}
+  {% do features.remove("default") %}
+  {% set features = features|sort %}
+  {% do features.insert(0, None) %}
+  {% do features.insert(1, "default") %}
+  {% for feature in features %}
+    {% set pkg = "-n %%{name}+%s-devel"|format(feature) if feature is not none else "   devel" %}
+%package     {{ pkg }}
 Summary:        %{summary}
-{% if rust_group is defined %}
+    {% if rust_group is defined %}
 Group:          {{ rust_group }}
-{% endif %}
+    {% endif %}
 BuildArch:      noarch
-{% if include_provides %}
-{% for prv in md.provides %}
-Provides:       {{ prv }}
-{% endfor %}
-{% endif %}
-{% if include_requires %}
+    {% if include_provides %}
+Provides:       {{ md.provides() }}
+    {% endif %}
+    {% if include_requires %}
 Requires:       cargo
-{% if md.requires|length > 0 %}
-# [dependencies]
-{% for req in md.requires|sort(attribute="name") %}
-Requires:       {{ req }}
-{% endfor %}
-{% endif %}
-{% if md.build_requires|length > 0 %}
-# [build-dependencies]
-{% for req in md.build_requires|sort(attribute="name") %}
+      {% for req in md.requires(feature)|map("string")|sort %}
 Requires:       {{ req }}
-{% endfor %}
-{% endif %}
-{% endif %}
+      {% endfor %}
+    {% endif %}
 
-%description    devel
-{% if md.description is none %}
-%{summary}.
-{% else %}
-{{ md.description|wordwrap|trim }}
-{% endif %}
+%description {{ pkg }} %{_description}
 
 This package contains library source intended for building other packages
-which use %{crate} from crates.io.
+which use {% if feature is not none %}"{{ feature }}" of {% endif %}"%{crate}" crate.
+
+%files       {{ pkg }}
+    {% if feature is none %}
+      {% if md.license_file is not none %}
+%license {{ md.license_file }}
+      {% endif %}
+      {% if md.readme is not none %}
+%doc {{ md.readme }}
+      {% endif %}
+%{cargo_registry}/%{crate}-%{version}/
+    {% else %}
+%ghost %{cargo_registry}/%{crate}-%{version}/Cargo.toml
+    {% endif %}
+
+  {% endfor %}
+{% endif -%}
 
-{% endif %}
 %prep
 {% if md.name != crate %}
 %autosetup -n %{real_crate}-%{version} -p1
@@ -143,29 +162,5 @@ which use %{crate} from crates.io.
 %cargo_test
 %endif
 
-{% if include_main %}
-%files       -n %{crate}
-{% if md.license_file is not none %}
-%license {{ md.license_file }}
-{% endif %}
-{% if md.readme is not none %}
-%doc {{ md.readme }}
-{% endif %}
-{% for bin in bins %}
-%{_bindir}/{{ bin.name }}
-{% endfor %}
-
-{% endif %}
-{% if include_devel %}
-%files          devel
-{% if md.license_file is not none %}
-%license {{ md.license_file }}
-{% endif %}
-{% if md.readme is not none %}
-%doc {{ md.readme }}
-{% endif %}
-%{cargo_registry}/%{crate}-%{version}/
-
-{% endif %}
 %changelog
 {% include target ~ "-changelog.spec.inc" %}
-- 
2.19.1