diff --git a/conans/client/command.py b/conans/client/command.py index 47f2d3dc9cb..46db3204fda 100644 --- a/conans/client/command.py +++ b/conans/client/command.py @@ -40,7 +40,7 @@ class Extender(argparse.Action): settings = ['cucumber:true'] """ def __call__(self, parser, namespace, values, option_strings=None): # @UnusedVariable - # Need None here incase `argparse.SUPPRESS` was supplied for `dest` + # Need None here in case `argparse.SUPPRESS` was supplied for `dest` dest = getattr(namespace, self.dest, None) if not hasattr(dest, 'extend') or dest == self.default: dest = [] @@ -2282,6 +2282,8 @@ def settings_args(machine, short_suffix="", long_suffix=""): source. --build=[pattern] Build packages from source whose package reference matches the pattern. The pattern uses 'fnmatch' style wildcards. + --build=![pattern] Excluded packages, which will not be built from the source, whose package + reference matches the pattern. The pattern uses 'fnmatch' style wildcards. Default behavior: If you omit the '--build' option, the 'build_policy' attribute in conanfile.py will be used if it exists, otherwise the behavior is like '--build={}'. diff --git a/conans/client/graph/build_mode.py b/conans/client/graph/build_mode.py index f9b1d7f8918..ae406d105c1 100644 --- a/conans/client/graph/build_mode.py +++ b/conans/client/graph/build_mode.py @@ -10,6 +10,7 @@ class BuildMode(object): => False if user wrote "never" => True if user wrote "missing" => "outdated" if user wrote "--build outdated" + => ["!foo"] means exclude when building all from sources """ def __init__(self, params, output): self._out = output @@ -19,6 +20,7 @@ def __init__(self, params, output): self.cascade = False self.patterns = [] self._unused_patterns = [] + self._excluded_patterns = [] self.all = False if params is None: return @@ -40,13 +42,30 @@ def __init__(self, params, output): # Remove the @ at the end, to match for "conan install pkg/0.1@ --build=pkg/0.1@" clean_pattern = param[:-1] if param.endswith("@") else param clean_pattern = clean_pattern.replace("@#", "#") - self.patterns.append(clean_pattern) + if clean_pattern and clean_pattern[0] == "!": + self._excluded_patterns.append(clean_pattern[1:]) + else: + self.patterns.append(clean_pattern) if self.never and (self.outdated or self.missing or self.patterns or self.cascade): raise ConanException("--build=never not compatible with other options") - self._unused_patterns = list(self.patterns) + self._unused_patterns = list(self.patterns) + self._excluded_patterns def forced(self, conan_file, ref, with_deps_to_build=False): + def pattern_match(pattern_): + return (fnmatch.fnmatchcase(ref.name, pattern_) or + fnmatch.fnmatchcase(repr(ref.copy_clear_rev()), pattern_) or + fnmatch.fnmatchcase(repr(ref), pattern_)) + + for pattern in self._excluded_patterns: + if pattern_match(pattern): + try: + self._unused_patterns.remove(pattern) + except ValueError: + pass + conan_file.output.info("Excluded build from source") + return False + if self.never: return False if self.all: @@ -62,9 +81,7 @@ def forced(self, conan_file, ref, with_deps_to_build=False): # Patterns to match, if package matches pattern, build is forced for pattern in self.patterns: - if (fnmatch.fnmatchcase(ref.name, pattern) or - fnmatch.fnmatchcase(repr(ref.copy_clear_rev()), pattern) or - fnmatch.fnmatchcase(repr(ref), pattern)): + if pattern_match(pattern): try: self._unused_patterns.remove(pattern) except ValueError: @@ -83,4 +100,4 @@ def allowed(self, conan_file): def report_matches(self): for pattern in self._unused_patterns: - self._out.error("No package matching '%s' pattern" % pattern) + self._out.error("No package matching '%s' pattern found." % pattern) diff --git a/conans/test/functional/graph/test_graph_build_mode.py b/conans/test/functional/graph/test_graph_build_mode.py new file mode 100644 index 00000000000..fb7dce1501e --- /dev/null +++ b/conans/test/functional/graph/test_graph_build_mode.py @@ -0,0 +1,166 @@ +import pytest +from conans.test.assets.genconanfile import GenConanfile +from conans.test.utils.tools import TestClient + + +@pytest.fixture(scope="module") +def build_all(): + """ Build a simple graph to test --build option + + foobar <- bar <- foo + <--------| + + All packages are built from sources to keep a cache. + :return: TestClient instance + """ + client = TestClient() + client.save({"conanfile.py": GenConanfile().with_setting("build_type")}) + client.run("export . foo/1.0@user/testing") + client.save({"conanfile.py": GenConanfile().with_require("foo/1.0@user/testing") + .with_setting("build_type")}) + client.run("export . bar/1.0@user/testing") + client.save({"conanfile.py": GenConanfile().with_require("foo/1.0@user/testing") + .with_require("bar/1.0@user/testing") + .with_setting("build_type")}) + client.run("export . foobar/1.0@user/testing") + client.run("install foobar/1.0@user/testing --build") + + return client + + +def check_if_build_from_sources(refs_modes, output): + for ref, mode in refs_modes.items(): + if mode == "Build": + assert "{}/1.0@user/testing: Forced build from source".format(ref) in output + else: + assert "{}/1.0@user/testing: Forced build from source".format(ref) not in output + + +def test_install_build_single(build_all): + """ When only --build= is passed, only must be built + """ + build_all.run("install foobar/1.0@user/testing --build=foo") + + assert "bar/1.0@user/testing:7839863d5a059fc6579f28026763e1021268c55e - Cache" in build_all.out + assert "foo/1.0@user/testing:4024617540c4f240a6a5e8911b0de9ef38a11a72 - Build" in build_all.out + assert "foobar/1.0@user/testing:89636fbae346e3983af2dd63f2c5246505e74be7 - Cache" in build_all.out + assert "foo/1.0@user/testing: Forced build from source" in build_all.out + assert "bar/1.0@user/testing: Forced build from source" not in build_all.out + assert "foobar/1.0@user/testing: Forced build from source" not in build_all.out + assert "No package matching" not in build_all.out + + +def test_install_build_double(build_all): + """ When both --build= and --build= are passed, only both should be built + """ + build_all.run("install foobar/1.0@user/testing --build=foo --build=bar") + + assert "bar/1.0@user/testing:7839863d5a059fc6579f28026763e1021268c55e - Build" in build_all.out + assert "foo/1.0@user/testing:4024617540c4f240a6a5e8911b0de9ef38a11a72 - Build" in build_all.out + assert "foobar/1.0@user/testing:89636fbae346e3983af2dd63f2c5246505e74be7 - Cache" in build_all.out + assert "foo/1.0@user/testing: Forced build from source" in build_all.out + assert "bar/1.0@user/testing: Forced build from source" in build_all.out + assert "foobar/1.0@user/testing: Forced build from source" not in build_all.out + assert "No package matching" not in build_all.out + + +@pytest.mark.parametrize("build_arg,mode", [("--build", "Build"), + ("--build=", "Cache"), + ("--build=*", "Build")]) +def test_install_build_only(build_arg, mode, build_all): + """ When only --build is passed, all packages must be built from sources + When only --build= is passed, it's considered an error + When only --build=* is passed, all packages must be built from sources + """ + build_all.run("install foobar/1.0@user/testing {}".format(build_arg)) + + assert "bar/1.0@user/testing:7839863d5a059fc6579f28026763e1021268c55e - {}".format(mode) in build_all.out + assert "foo/1.0@user/testing:4024617540c4f240a6a5e8911b0de9ef38a11a72 - {}".format(mode) in build_all.out + assert "foobar/1.0@user/testing:89636fbae346e3983af2dd63f2c5246505e74be7 - {}".format(mode) in build_all.out + + if "Build" == mode: + assert "foo/1.0@user/testing: Forced build from source" in build_all.out + assert "bar/1.0@user/testing: Forced build from source" in build_all.out + assert "foobar/1.0@user/testing: Forced build from source" in build_all.out + assert "No package matching" not in build_all.out + else: + assert "foo/1.0@user/testing: Forced build from source" not in build_all.out + assert "bar/1.0@user/testing: Forced build from source" not in build_all.out + assert "foobar/1.0@user/testing: Forced build from source" not in build_all.out + assert "No package matching" in build_all.out + + +@pytest.mark.parametrize("build_arg,bar,foo,foobar", [("--build", "Cache", "Build", "Cache"), + ("--build=", "Cache", "Build", "Cache"), + ("--build=*", "Build", "Build", "Build")]) +def test_install_build_all_with_single(build_arg, bar, foo, foobar, build_all): + """ When --build is passed with another package, only the package must be built from sources. + When --build= is passed with another package, only the package must be built from sources. + When --build=* is passed with another package, all packages must be built from sources. + """ + build_all.run("install foobar/1.0@user/testing --build=foo {}".format(build_arg)) + + assert "bar/1.0@user/testing:7839863d5a059fc6579f28026763e1021268c55e - {}".format(bar) in build_all.out + assert "foo/1.0@user/testing:4024617540c4f240a6a5e8911b0de9ef38a11a72 - {}".format(foo) in build_all.out + assert "foobar/1.0@user/testing:89636fbae346e3983af2dd63f2c5246505e74be7 - {}".format(foobar) in build_all.out + check_if_build_from_sources({"foo": foo, "bar": bar, "foobar": foobar}, build_all.out) + + +@pytest.mark.parametrize("build_arg,bar,foo,foobar", [("--build", "Cache", "Cache", "Cache"), + ("--build=", "Cache", "Cache", "Cache"), + ("--build=*", "Build", "Cache", "Build")]) +def test_install_build_all_with_single_skip(build_arg, bar, foo, foobar, build_all): + """ When --build is passed with a skipped package, not all packages must be built from sources. + When --build= is passed with another package, only the package must be built from sources. + When --build=* is passed with another package, not all packages must be built from sources. + + The arguments order matter, that's why we need to run twice. + """ + for argument in ["--build=!foo {}".format(build_arg), + "{} --build=!foo".format(build_arg)]: + build_all.run("install foobar/1.0@user/testing {}".format(argument)) + assert "bar/1.0@user/testing:7839863d5a059fc6579f28026763e1021268c55e - {}".format(bar) in build_all.out + assert "foo/1.0@user/testing:4024617540c4f240a6a5e8911b0de9ef38a11a72 - {}".format(foo) in build_all.out + assert "foobar/1.0@user/testing:89636fbae346e3983af2dd63f2c5246505e74be7 - {}".format(foobar) in build_all.out + check_if_build_from_sources({"foo": foo, "bar": bar, "foobar": foobar}, build_all.out) + + +@pytest.mark.parametrize("build_arg,bar,foo,foobar", [("--build", "Cache", "Cache", "Cache"), + ("--build=", "Cache", "Cache", "Cache"), + ("--build=*", "Cache", "Cache", "Build")]) +def test_install_build_all_with_double_skip(build_arg, bar, foo, foobar, build_all): + """ When --build is passed with a skipped package, not all packages must be built from sources. + When --build= is passed with another package, only the package must be built from sources. + When --build=* is passed with another package, not all packages must be built from sources. + + The arguments order matter, that's why we need to run twice. + """ + for argument in ["--build=!foo --build=!bar {}".format(build_arg), + "{} --build=!foo --build=!bar".format(build_arg)]: + build_all.run("install foobar/1.0@user/testing {}".format(argument)) + + assert "bar/1.0@user/testing:7839863d5a059fc6579f28026763e1021268c55e - {}".format(bar) in build_all.out + assert "foo/1.0@user/testing:4024617540c4f240a6a5e8911b0de9ef38a11a72 - {}".format(foo) in build_all.out + assert "foobar/1.0@user/testing:89636fbae346e3983af2dd63f2c5246505e74be7 - {}".format(foobar) in build_all.out + + +def test_report_matches(build_all): + """ When a wrong reference is passed to be build, an error message should be shown + """ + build_all.run("install foobar/1.0@user/testing --build=* --build=baz") + assert "foobar/1.0@user/testing:89636fbae346e3983af2dd63f2c5246505e74be7 - Build" in build_all.out + assert "No package matching 'baz' pattern found." in build_all.out + + build_all.run("install foobar/1.0@user/testing --build=* --build=!baz") + assert "No package matching 'baz' pattern found." in build_all.out + assert "foobar/1.0@user/testing:89636fbae346e3983af2dd63f2c5246505e74be7 - Build" in build_all.out + + build_all.run("install foobar/1.0@user/testing --build=* --build=!baz --build=blah") + assert "No package matching 'blah' pattern found." in build_all.out + assert "No package matching 'baz' pattern found." in build_all.out + assert "foobar/1.0@user/testing:89636fbae346e3983af2dd63f2c5246505e74be7 - Build" in build_all.out + + build_all.run("install foobar/1.0@user/testing --build=* --build=!baz --build=!blah") + assert "No package matching 'blah' pattern found." in build_all.out + assert "No package matching 'baz' pattern found." in build_all.out + assert "foobar/1.0@user/testing:89636fbae346e3983af2dd63f2c5246505e74be7 - Build" in build_all.out