diff --git a/conan/api/model.py b/conan/api/model.py index 2a3284a7a0e..9172526cfd9 100644 --- a/conan/api/model.py +++ b/conan/api/model.py @@ -69,7 +69,7 @@ def load(file): result[remote] = PackagesList.deserialize(pkglist) pkglist = MultiPackagesList() pkglist.lists = result - return result + return pkglist @staticmethod def load_graph(graphfile, graph_recipes=None, graph_binaries=None): diff --git a/conan/cli/commands/remove.py b/conan/cli/commands/remove.py index b4fe4fb4f29..c22baf2b329 100644 --- a/conan/cli/commands/remove.py +++ b/conan/cli/commands/remove.py @@ -1,11 +1,34 @@ from conan.api.conan_api import ConanAPI -from conan.api.model import ListPattern +from conan.api.model import ListPattern, MultiPackagesList +from conan.api.output import cli_out_write, ConanOutput +from conan.cli import make_abs_path from conan.cli.command import conan_command, OnceArgument +from conan.cli.commands.list import print_list_json, print_serial from conans.client.userio import UserInput from conans.errors import ConanException -@conan_command(group="Consumer") +def summary_remove_list(results): + """ Do litte format modification to serialized + list bundle so it looks prettier on text output + """ + cli_out_write("Remove summary:") + info = results["results"] + result = {} + for remote, remote_info in info.items(): + new_info = result.setdefault(remote, {}) + for ref, content in remote_info.items(): + for rev, rev_content in content.get("revisions", {}).items(): + pkgids = rev_content.get('packages') + if pkgids is None: + new_info.setdefault(f"{ref}#{rev}", "Removed recipe and all binaries") + else: + new_info.setdefault(f"{ref}#{rev}", f"Removed binaries: {list(pkgids)}") + print_serial(result) + + +@conan_command(group="Consumer", formatters={"text": summary_remove_list, + "json": print_list_json}) def remove(conan_api: ConanAPI, parser, *args): """ Remove recipes or packages from local cache or a remote. @@ -16,7 +39,7 @@ def remove(conan_api: ConanAPI, parser, *args): will be removed. - If a package reference is specified, it will remove only the package. """ - parser.add_argument('pattern', + parser.add_argument('pattern', nargs="?", help="A pattern in the form 'pkg/version#revision:package_id#revision', " "e.g: zlib/1.2.13:* means all binaries for zlib/1.2.13. " "If revision is not specified, it is assumed latest one.") @@ -27,26 +50,47 @@ def remove(conan_api: ConanAPI, parser, *args): "os=Windows AND (arch=x86 OR compiler=gcc)") parser.add_argument('-r', '--remote', action=OnceArgument, help='Will remove from the specified remote') + parser.add_argument("-l", "--list", help="Package list file") args = parser.parse_args(*args) + if args.pattern is None and args.list is None: + raise ConanException("Missing pattern or package list file") + if args.pattern and args.list: + raise ConanException("Cannot define both the pattern and the package list file") + if args.package_query and args.list: + raise ConanException("Cannot define package-query and the package list file") + ui = UserInput(conan_api.config.get("core:non_interactive")) remote = conan_api.remotes.get(args.remote) if args.remote else None def confirmation(message): return args.confirm or ui.request_boolean(message) - ref_pattern = ListPattern(args.pattern, rrev="*", prev="*") - select_bundle = conan_api.list.select(ref_pattern, args.package_query, remote) - - if ref_pattern.package_id is None: - if args.package_query is not None: + if args.list: + listfile = make_abs_path(args.list) + multi_package_list = MultiPackagesList.load(listfile) + package_list = multi_package_list["Local Cache" if not remote else remote.name] + refs_to_remove = package_list.refs() + if not refs_to_remove: # the package list might contain only refs, no revs + ConanOutput().warning("Nothing to remove, package list do not contain recipe revisions") + else: + ref_pattern = ListPattern(args.pattern, rrev="*", prev="*") + if ref_pattern.package_id is None and args.package_query is not None: raise ConanException('--package-query supplied but the pattern does not match packages') - for ref, _ in select_bundle.refs(): - if confirmation("Remove the recipe and all the packages of '{}'?" - "".format(ref.repr_notime())): + package_list = conan_api.list.select(ref_pattern, args.package_query, remote) + multi_package_list = MultiPackagesList() + multi_package_list.add("Local Cache" if not remote else remote.name, package_list) + + for ref, ref_bundle in package_list.refs(): + if ref_bundle.get("packages") is None: + if confirmation(f"Remove the recipe and all the packages of '{ref.repr_notime()}'?"): conan_api.remove.recipe(ref, remote=remote) - else: - for ref, ref_bundle in select_bundle.refs(): - for pref, _ in select_bundle.prefs(ref, ref_bundle): - if confirmation("Remove the package '{}'?".format(pref.repr_notime())): - conan_api.remove.package(pref, remote=remote) + continue + for pref, _ in package_list.prefs(ref, ref_bundle): + if confirmation(f"Remove the package '{pref.repr_notime()}'?"): + conan_api.remove.package(pref, remote=remote) + + return { + "results": multi_package_list.serialize(), + "conan_api": conan_api + } diff --git a/conan/cli/commands/upload.py b/conan/cli/commands/upload.py index cdb30963b7b..2795b9fb087 100644 --- a/conan/cli/commands/upload.py +++ b/conan/cli/commands/upload.py @@ -14,15 +14,15 @@ def summary_upload_list(results): """ cli_out_write("Upload summary:") info = results["results"] - new_info = {} + result = {} for remote, remote_info in info.items(): + new_info = result.setdefault(remote, {}) for ref, content in remote_info.items(): for rev, rev_content in content["revisions"].items(): - prevs = rev_content.get('packages') - if prevs: - new_info.setdefault(f"{ref}:{rev}", list(prevs)) - info = new_info - print_serial(info) + pkgids = rev_content.get('packages') + if pkgids: + new_info.setdefault(f"{ref}:{rev}", list(pkgids)) + print_serial(result) @conan_command(group="Creator", formatters={"text": summary_upload_list, @@ -71,6 +71,8 @@ def upload(conan_api: ConanAPI, parser, *args): raise ConanException("Missing pattern or package list file") if args.pattern and args.list: raise ConanException("Cannot define both the pattern and the package list file") + if args.package_query and args.list: + raise ConanException("Cannot define package-query and the package list file") if args.list: listfile = make_abs_path(args.list) multi_package_list = MultiPackagesList.load(listfile) diff --git a/conans/test/integration/command_v2/test_combined_pkglist_flows.py b/conans/test/integration/command_v2/test_combined_pkglist_flows.py index a0cc613b45d..36d3798b48e 100644 --- a/conans/test/integration/command_v2/test_combined_pkglist_flows.py +++ b/conans/test/integration/command_v2/test_combined_pkglist_flows.py @@ -162,3 +162,51 @@ def test_download_upload_only_recipes(self, client, prev_list): assert f"Uploading recipe 'zli/" not in client.out assert "Uploading package 'zlib/1.0.0" not in client.out assert "Uploading package 'zli/" not in client.out + + +class TestListRemove: + @pytest.fixture() + def client(self): + c = TestClient(default_server_user=True) + c.save({ + "zlib.py": GenConanfile("zlib"), + "zli.py": GenConanfile("zli", "1.0.0") + }) + c.run("create zli.py") + c.run("create zlib.py --version=1.0.0 --user=user --channel=channel") + c.run("upload * -r=default -c") + return c + + def test_remove_nothing_only_refs(self, client): + # It is necessary to do *#* for actually removing something + client.run(f"list * --format=json", redirect_stdout="pkglist.json") + client.run(f"remove --list=pkglist.json -c") + assert "Nothing to remove, package list do not contain recipe revisions" in client.out + + @pytest.mark.parametrize("remote", [False, True]) + def test_remove_all(self, client, remote): + # It is necessary to do *#* for actually removing something + remote = "-r=default" if remote else "" + client.run(f"list *#* {remote} --format=json", redirect_stdout="pkglist.json") + client.run(f"remove --list=pkglist.json {remote} -c") + assert "zli/1.0.0#f034dc90894493961d92dd32a9ee3b78:" \ + " Removed recipe and all binaries" in client.out + assert "zlib/1.0.0@user/channel#ffd4bc45820ddb320ab224685b9ba3fb:" \ + " Removed recipe and all binaries" in client.out + client.run(f"list * {remote}") + assert "There are no matching recipe references" in client.out + + @pytest.mark.parametrize("remote", [False, True]) + def test_remove_packages(self, client, remote): + # It is necessary to do *#* for actually removing something + remote = "-r=default" if remote else "" + client.run(f"list *#*:* {remote} --format=json", redirect_stdout="pkglist.json") + client.run(f"remove --list=pkglist.json {remote} -c") + assert "Removed recipe and all binaries" not in client.out + assert "zli/1.0.0#f034dc90894493961d92dd32a9ee3b78:" \ + " Removed binaries" in client.out + assert "zlib/1.0.0@user/channel#ffd4bc45820ddb320ab224685b9ba3fb:" \ + " Removed binaries" in client.out + client.run(f"list *:* {remote}") + assert "zli/1.0.0" in client.out + assert "zlib/1.0.0@user/channel" in client.out