Repository: conan-io/conan

Issue:
[question] check for a new package version
Hi,

Is it possible to check for a new version of a package without installing it?

So for example:

available packages on artifactory
```
some_package/0.9.0
some_package/1.0.0
some_package/1.0.2
```

my conanfile.txt
```
[requires]
some_package/1.0.0
```

Now what I looking for is a command which I can see that *some_package/1.0.0* has an update to version *1.0.2* without using wildcard versions.

Is this possible?

If not do you have a suggestion how I can build something like this?

- [x] I've read the [CONTRIBUTING guide](https://github.com/conan-io/conan/blob/develop/.github/CONTRIBUTING.md).


Discussion:
Hi @snailcatcher 

For now, as far as I know, it does not exist such command.
You have `conan info . -u` which is showing if there is a new binary package in the remote but this one is not related to a new version at all but changes in the `conanmanifest.txt`.
Hi @franramirez688 
Thank you, but this is not exactly what I'm looking for.
I look for something which implements a versioning standard like [semantic versioning](https://semver.org/lang/de/) or something similar.
Is there a plan for a feature like this in conan 1.x or 2.x?

Semver standard and version ranges are supported and implemented in Conan. You only need to define ``requires = "pkg/[version range]"`` in your conanfile for it to resolve. You can install with ``conan install "zlib/[>1 <2]@"`` Or you can query with ``conan info "zlib/[>0]@"``. What could be missing is a ``conan search`` query that supports the semver syntax, and it is wildcard based.
Hi @memsharded 
The definition of a wildcard version in the conanfile is not a good solution in my case (safety levels).
Yeah you kind of get the point.

The best solution I know for a similar problem is [npm-check-updates](https://www.npmjs.com/package/npm-check-updates) for the [NPM (NodeJS Package Manager)](https://www.npmjs.com/).

It basically scans all your dependencies in the conanfile (so the names of your packages) and checks if the repository has a newer (higher version) package available. Then it just prints a list of all available updates and the user can decide himself which of them he wants to update. If you want to update a package *npm-check-updates* rewrites the version of this package in the *conanfile* and you have to do a *conan install .* afterwards.



Patch:
diff --git a/conan/api/model.py b/conan/api/model.py
index 70416a2c57b..5bc5ce4bbcd 100644
--- a/conan/api/model.py
+++ b/conan/api/model.py
@@ -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
@@ -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):
diff --git a/conan/cli/commands/graph.py b/conan/cli/commands/graph.py
index b8fb4a36bda..9283d68a665 100644
--- a/conan/cli/commands/graph.py
+++ b/conan/cli/commands/graph.py
@@ -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
@@ -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):
@@ -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)
+
+    # 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:
+        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}


Test patch:
diff --git a/conans/test/integration/command_v2/test_outdated.py b/conans/test/integration/command_v2/test_outdated.py
new file mode 100644
index 00000000000..1e05ac32938
--- /dev/null
+++ b/conans/test/integration/command_v2/test_outdated.py
@@ -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