Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add conan graph outdated command #15838

Merged
merged 16 commits into from
Mar 19, 2024
Merged
4 changes: 2 additions & 2 deletions conan/api/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

from conans.client.graph.graph import RECIPE_EDITABLE, RECIPE_CONSUMER, RECIPE_PLATFORM, \
RECIPE_VIRTUAL, BINARY_SKIP, BINARY_MISSING, BINARY_INVALID
from conans.errors import ConanException
from conans.errors import ConanException, NotFoundException
from conans.model.package_ref import PkgReference
from conans.model.recipe_ref import RecipeReference
from conans.util.files import load
Expand Down Expand Up @@ -278,7 +278,7 @@ def is_latest_prev(self):

def check_refs(self, refs):
if not refs and self.ref and "*" not in self.ref:
raise ConanException(f"Recipe '{self.ref}' not found")
raise NotFoundException(f"Recipe '{self.ref}' not found")

def filter_rrevs(self, rrevs):
if self._only_latest(self.rrev):
Expand Down
121 changes: 120 additions & 1 deletion conan/cli/commands/graph.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import json
import os

from conan.api.model import ListPattern
from conan.api.output import ConanOutput, cli_out_write, Color
from conan.cli import make_abs_path
from conan.cli.args import common_graph_args, validate_common_graph_args
Expand All @@ -13,7 +14,9 @@
from conan.internal.deploy import do_deploys
from conans.client.graph.graph import BINARY_MISSING
from conans.client.graph.install_graph import InstallGraph
from conans.model.recipe_ref import ref_matches
from conans.errors import ConanConnectionError, NotFoundException
from conans.model.recipe_ref import ref_matches, RecipeReference
from conans.model.version_range import VersionRange


def explain_formatter_text(data):
Expand Down Expand Up @@ -298,3 +301,119 @@ def graph_explain(conan_api, parser, subparser, *args):

ConanOutput().title("Closest binaries")
return {"closest_binaries": pkglist.serialize()}


def outdated_text_formatter(result):
cli_out_write("======== Outdated dependencies ========", fg=Color.BRIGHT_MAGENTA)

if len(result) == 0:
cli_out_write("No outdated dependencies in graph", fg=Color.BRIGHT_YELLOW)

for key, value in result.items():
current_versions_set = list({str(v) for v in value["cache_refs"]})
cli_out_write(key, fg=Color.BRIGHT_YELLOW)
cli_out_write(
f' Current versions: {", ".join(current_versions_set) if value["cache_refs"] else "No version found in cache"}', fg=Color.BRIGHT_CYAN)
cli_out_write(
f' Latest in remote(s): {value["latest_remote"]["ref"]} - {value["latest_remote"]["remote"]}',
fg=Color.BRIGHT_CYAN)
if value["version_ranges"]:
cli_out_write(f' Version ranges: ' + str(value["version_ranges"])[1:-1], fg=Color.BRIGHT_CYAN)


def outdated_json_formatter(result):
output = {key: {"current_versions": list({str(v) for v in value["cache_refs"]}),
"version_ranges": [str(r) for r in value["version_ranges"]],
"latest_remote": [] if value["latest_remote"] is None
else {"ref": str(value["latest_remote"]["ref"]),
"remote": str(value["latest_remote"]["remote"])}}
for key, value in result.items()}
cli_out_write(json.dumps(output))


@conan_subcommand(formatters={"text": outdated_text_formatter, "json": outdated_json_formatter})
def graph_outdated(conan_api, parser, subparser, *args):
"""
List the dependencies in the graph and it's newer versions in the remote
"""
common_graph_args(subparser)
subparser.add_argument("--check-updates", default=False, action="store_true",
help="Check if there are recipe updates")
subparser.add_argument("--build-require", action='store_true', default=False,
help='Whether the provided reference is a build-require')
args = parser.parse_args(*args)
# parameter validation
validate_common_graph_args(args)
cwd = os.getcwd()
path = conan_api.local.get_conanfile_path(args.path, cwd, py=None) if args.path else None

# Basic collaborators, remotes, lockfile, profiles
remotes = conan_api.remotes.list(args.remote) if not args.no_remote else []
overrides = eval(args.lockfile_overrides) if args.lockfile_overrides else None
lockfile = conan_api.lockfile.get_lockfile(lockfile=args.lockfile,
conanfile_path=path,
cwd=cwd,
partial=args.lockfile_partial,
overrides=overrides)
profile_host, profile_build = conan_api.profiles.get_profiles_from_args(args)

if path:
deps_graph = conan_api.graph.load_graph_consumer(path, args.name, args.version,
args.user, args.channel,
profile_host, profile_build, lockfile,
remotes, args.update,
check_updates=args.check_updates,
is_build_require=args.build_require)
else:
deps_graph = conan_api.graph.load_graph_requires(args.requires, args.tool_requires,
profile_host, profile_build, lockfile,
remotes, args.update,
check_updates=args.check_updates)
print_graph_basic(deps_graph)
memsharded marked this conversation as resolved.
Show resolved Hide resolved

# Data structure to store info per library
dependencies = deps_graph.nodes[1:]
dict_nodes = {}

# When there are no dependencies command should stop
if len(dependencies) == 0:
return dict_nodes

ConanOutput().title("Checking remotes")

for node in dependencies:
juansblanco marked this conversation as resolved.
Show resolved Hide resolved
dict_nodes.setdefault(node.name, {"cache_refs": set(), "version_ranges": [],
"latest_remote": None})["cache_refs"].add(node.ref)

for version_range in deps_graph.resolved_ranges.keys():
dict_nodes[version_range.name]["version_ranges"].append(version_range)

# find in remotes
_find_in_remotes(conan_api, dict_nodes, remotes)

# Filter nodes with no outdated versions
filtered_nodes = {}
for node_name, node in dict_nodes.items():
if node['latest_remote'] is not None and sorted(list(node['cache_refs']))[0] < \
node['latest_remote']['ref']:
filtered_nodes[node_name] = node

return filtered_nodes


def _find_in_remotes(conan_api, dict_nodes, remotes):
for node_name, node_info in dict_nodes.items():
ref_pattern = ListPattern(node_name, rrev=None, prev=None)
for remote in remotes:
try:
remote_ref_list = conan_api.list.select(ref_pattern, package_query=None,
remote=remote)
except NotFoundException:
continue
if not remote_ref_list.recipes:
continue
str_latest_ref = list(remote_ref_list.recipes.keys())[-1]
recipe_ref = RecipeReference.loads(str_latest_ref)
if (node_info["latest_remote"] is None
or node_info["latest_remote"]["ref"] < recipe_ref):
node_info["latest_remote"] = {"ref": recipe_ref, "remote": remote.name}
187 changes: 187 additions & 0 deletions conans/test/integration/command_v2/test_outdated.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import json
from collections import OrderedDict

import pytest

from conans.test.assets.genconanfile import GenConanfile
from conans.test.utils.tools import TestClient, TestServer


@pytest.fixture
def create_libs():
tc = TestClient(default_server_user=True)
tc.save({"conanfile.py": GenConanfile()})
tc.run("create . --name=zlib --version=1.0")
tc.run("create . --name=zlib --version=2.0")
tc.run("create . --name=foo --version=1.0")
tc.run("create . --name=foo --version=2.0")

tc.save({"conanfile.py": GenConanfile().with_requires("foo/[>=1.0]")})
tc.run("create . --name=libcurl --version=1.0")

# Upload all created libs to the remote
tc.run("upload * -c -r=default")

tc.save({"conanfile.py": GenConanfile("app", "1.0")
.with_requires("zlib/1.0", "libcurl/[>=1.0]")})
return tc


def test_outdated_command(create_libs):
tc = create_libs

# Remove versions from cache so they are found as outdated
tc.run("remove foo/2.0 -c")
tc.run("remove zlib/2.0 -c")

tc.run("graph outdated . --format=json")
output = json.loads(tc.stdout)
assert "zlib" in output
assert "foo" in output
assert "libcurl" not in output
assert output["zlib"]["current_versions"] == ["zlib/1.0"]
assert output["zlib"]["version_ranges"] == []
assert output["zlib"]["latest_remote"]["ref"] == "zlib/2.0"
assert output["zlib"]["latest_remote"]["remote"].startswith("default")
assert output["foo"]["current_versions"] == ["foo/1.0"]
assert output["foo"]["version_ranges"] == ["foo/[>=1.0]"]
assert output["foo"]["latest_remote"]["ref"] == "foo/2.0"
assert output["foo"]["latest_remote"]["remote"].startswith("default")


def test_recipe_with_lockfile(create_libs):
tc = create_libs

# Remove versions from cache so they are found as outdated
tc.run("remove foo/2.0 -c")
tc.run("remove zlib/2.0 -c")

# Creating the lockfile sets foo/1.0 as only valid version for the recipe
tc.run("lock create .")
tc.run("graph outdated . --format=json")
output = json.loads(tc.stdout)
assert "foo" in output
assert output["foo"]["current_versions"] == ["foo/1.0"]
# Creating the lockfile makes the previous range obsolete
assert output["foo"]["version_ranges"] == []

# Adding foo/2.0 to the lockfile forces the download so foo is no longer outdated
tc.run("lock add --requires=foo/2.0")
tc.run("graph outdated . --format=json")
output = json.loads(tc.stdout)
assert "foo" not in output


def test_recipe_with_no_remote_ref(create_libs):
tc = create_libs

# Remove versions from cache so they are found as outdated
tc.run("remove foo/2.0 -c")
tc.run("remove zlib/2.0 -c")
tc.run("remove libcurl -c")

tc.run("graph outdated . --format=json")
output = json.loads(tc.stdout)
# Check that libcurl doesn't appear as there is no version in the remotes
assert "zlib" in output
assert "foo" in output
assert "libcurl" not in output


def test_cache_ref_newer_than_latest_in_remote(create_libs):
tc = create_libs

tc.run("remove foo/2.0 -c -r=default")

tc.run("graph outdated . --format=json")
output = json.loads(tc.stdout)
# Check that foo doesn't appear because the version in cache is higher than the latest version
# in remote
assert "zlib" in output
assert "libcurl" not in output
assert "foo" not in output


def test_two_remotes():
# remote1: zlib/1.0, libcurl/2.0, foo/1.0
# remote2: zlib/[1.0, 2.0], libcurl/1.0, foo/1.0
# local cache: zlib/1.0, libcurl/1.0, foo/1.0
servers = OrderedDict()
for i in [1, 2]:
test_server = TestServer()
servers["remote%d" % i] = test_server

tc = TestClient(servers=servers, inputs=2 * ["admin", "password"], light=True)

tc.save({"conanfile.py": GenConanfile()})
tc.run("create . --name=zlib --version=1.0")
tc.run("create . --name=foo --version=1.0")
tc.run("create . --name=zlib --version=2.0")

tc.save({"conanfile.py": GenConanfile().with_requires("foo/[>=1.0]")})
tc.run("create . --name=libcurl --version=1.0")
tc.run("create . --name=libcurl --version=2.0")

# Upload the created libraries 1.0 to remotes
tc.run("upload zlib/1.0 -c -r=remote1")
tc.run("upload libcurl/2.0 -c -r=remote1")
tc.run("upload foo/1.0 -c -r=remote1")

tc.run("upload zlib/* -c -r=remote2")
tc.run("upload libcurl/1.0 -c -r=remote2")
tc.run("upload foo/1.0 -c -r=remote2")

# Remove from cache the 2.0 libraries
tc.run("remove libcurl/2.0 -c")
tc.run("remove zlib/2.0 -c")

tc.save({"conanfile.py": GenConanfile("app", "1.0")
.with_requires("zlib/1.0", "libcurl/[>=1.0]")})

tc.run("graph outdated . --format=json")
output = json.loads(tc.stdout)
assert "zlib" in output
assert "libcurl" in output
assert "foo" not in output
assert output["libcurl"]["latest_remote"]["ref"].startswith("libcurl/2.0")
assert output["libcurl"]["latest_remote"]["remote"].startswith("remote1")
assert output["zlib"]["latest_remote"]["ref"].startswith("zlib/2.0")
assert output["zlib"]["latest_remote"]["remote"].startswith("remote2")


def test_duplicated_tool_requires():
tc = TestClient(default_server_user=True)
# Create libraries needed to generate the dependency graph
tc.save({"conanfile.py": GenConanfile()})
tc.run("create . --name=cmake --version=1.0")
tc.run("create . --name=cmake --version=2.0")
tc.run("create . --name=cmake --version=3.0")
tc.save({"conanfile.py": GenConanfile().with_tool_requires("cmake/1.0")})
tc.run("create . --name=foo --version=1.0")
tc.save({"conanfile.py": GenConanfile().with_tool_requires("cmake/[>=1.0]")})
tc.run("create . --name=bar --version=1.0")

# Upload the created libraries to remote
tc.run("upload * -c -r=default")

tc.save({"conanfile.py": GenConanfile("app", "1.0")
.with_requires("foo/1.0", "bar/1.0").with_tool_requires("cmake/[<=2.0]")})

tc.run("graph outdated . --format=json")
output = json.loads(tc.stdout)

assert sorted(output["cmake"]["current_versions"]) == ["cmake/1.0", "cmake/2.0", "cmake/3.0"]
assert sorted(output["cmake"]["version_ranges"]) == ["cmake/[<=2.0]", "cmake/[>=1.0]"]
assert output["cmake"]["latest_remote"]["ref"] == "cmake/3.0"


def test_no_outdated_dependencies():
tc = TestClient(default_server_user=True)

tc.save({"conanfile.py": GenConanfile()})
tc.run("create . --name=foo --version=1.0")
tc.run("upload * -c -r=default")

tc.run("graph outdated .")

assert "No outdated dependencies in graph" in tc.stdout