diff --git a/conan/cli/commands/lock.py b/conan/cli/commands/lock.py index 6b4f2cf548f..efb623b8aa2 100644 --- a/conan/cli/commands/lock.py +++ b/conan/cli/commands/lock.py @@ -1,3 +1,4 @@ +from collections import defaultdict import os from conan.api.output import ConanOutput @@ -8,6 +9,9 @@ from conan.cli.printers.graph import print_graph_packages, print_graph_basic from conan.internal.model.lockfile import Lockfile, LOCKFILE from conan.internal.model.recipe_ref import RecipeReference +from conan.errors import ConanException +from conan.internal.model.version import Version +from conan.internal.model.version_range import VersionRange @conan_command(group="Consumer") @@ -179,3 +183,101 @@ def lock_update(conan_api, parser, subparser, *args): lockfile.update(requires=args.requires, build_requires=args.build_requires, python_requires=args.python_requires, config_requires=args.config_requires) conan_api.lockfile.save_lockfile(lockfile, args.lockfile_out) + + + + +@conan_subcommand() +def lock_upgrade(conan_api, parser, subparser, *args): + """ + (Experimental) Upgrade requires, build-requires or python-requires from an existing lockfile given a conanfile + or a reference. + """ + + """ + TODOs: + - [ ] args.update should always be True? + - [x] manage input version ranges + - [ ] Consider a new option to enable transitive dependencies on different kinds + (eg. --transitive=all, --transitive=python-requires, ...) + - [ ] Improve the detection of range versions + """ + + common_graph_args(subparser) + subparser.add_argument('--update-requires', action="append", help='Update requires from lockfile') + subparser.add_argument('--update-build-requires', action="append", help='Update build-requires from lockfile') + subparser.add_argument('--update-python-requires', action="append", help='Update python-requires from lockfile') + subparser.add_argument('--update-config-requires', action="append", help='Update config-requires from lockfile') + subparser.add_argument('--build-require', action='store_true', default=False, help='Whether the provided reference is a build-require') + subparser.add_argument('--transitives', action='store_true', default=False, help='Upgrade also transitive dependencies') + args = parser.parse_args(*args) + + # parameter validation + validate_common_graph_args(args) + + if not any([args.update_requires, args.update_build_requires, args.update_python_requires, args.update_config_requires]): + raise ConanException("At least one of --update-requires, --update-build-requires, " + "--update-python-requires or --update-config-requires should be specified") + + cwd = os.getcwd() + path = conan_api.local.get_conanfile_path(args.path, cwd, py=None) if args.path else None + 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=True, overrides=overrides) + profile_host, profile_build = conan_api.profiles.get_profiles_from_args(args) + + def expand_graph(): + if path: + return conan_api.graph.load_graph_consumer(path, args.name, args.version, + args.user, args.channel, + profile_host, profile_build, lockfile, + remotes, args.update, + is_build_require=args.build_require) + return conan_api.graph.load_graph_requires(args.requires, args.tool_requires, + profile_host, profile_build, lockfile, + remotes, args.update) + requested_update = {"requires": args.update_requires or [], + "build_requires": args.update_build_requires or [], + "python_requires": args.update_python_requires or [], + "config_requires": args.update_config_requires or []} + if args.transitives: + def is_version_range(ref: str): + # TODO: use conan's built in + return ref.find('[') != -1 or ref.find('*') != -1 + + def match_version(ref, node): + if is_version_range(ref): + return VersionRange(str(RecipeReference.loads(ref).version)).contains(Version(node.ref), resolve_prerelease=True) + else: + return node.ref.matches(ref, is_consumer=None) + updatable_deps = defaultdict(list) + for node in expand_graph().nodes: + if not node.ref: + continue + for kind, deps in requested_update.items(): + if any(match_version(ref, node) for ref in deps): + updatable_deps[kind].append(node.ref.repr_notime()) + for dep in node.conanfile.dependencies.values(): + # TODO: dependencies.build.values() -> + # dep.context # host or build require (?) + updatable_deps[kind].append(dep.ref.repr_notime()) + else: + updatable_deps = requested_update + # Remove the lockfile entries that will be updated + lockfile = conan_api.lockfile.remove_lockfile(lockfile, + requires=updatable_deps["requires"], + python_requires=updatable_deps["python_requires"], + build_requires=updatable_deps["build_requires"], + config_requires=updatable_deps["config_requires"]) + # Resolve new graph + graph = expand_graph() + print_graph_basic(graph) + graph.report_graph_error() + conan_api.graph.analyze_binaries(graph, args.build, remotes=remotes, update=args.update, + lockfile=lockfile) + print_graph_packages(graph) + + lockfile = conan_api.lockfile.update_lockfile(lockfile, graph, args.lockfile_packages, + clean=args.lockfile_clean) + conan_api.lockfile.save_lockfile(lockfile, args.lockfile_out or "conan.lock") diff --git a/test/integration/lockfile/test_user_overrides.py b/test/integration/lockfile/test_user_overrides.py index e3e033969a3..a48a695f1a5 100644 --- a/test/integration/lockfile/test_user_overrides.py +++ b/test/integration/lockfile/test_user_overrides.py @@ -4,7 +4,7 @@ import pytest from conan.test.assets.genconanfile import GenConanfile -from conan.test.utils.tools import TestClient +from conan.test.utils.tools import NO_SETTINGS_PACKAGE_ID, TestClient def test_user_overrides(): @@ -360,3 +360,172 @@ def test_lock_update(self, kind, old, new): lock = c.load("conan.lock") assert old not in lock assert new in lock + + +class TestLockUpgrade: + @pytest.mark.parametrize("kind, pkg, old, new", [ + ("requires", "math", "math/1.0", "math/1.1"), + ("build-requires", "cmake", "cmake/1.0", "cmake/1.1"), # TODO there is not a --build-requires + # ("python-requires", "mytool", "mytool/1.0", "mytool/1.1"), # TODO nor a --python-requires + ]) + def test_lock_upgrade(self, kind, pkg, old, new): + c = TestClient(light=True) + c.save({f"{pkg}/conanfile.py": GenConanfile(pkg)}) + + c.run(f"export {pkg} --version=1.0") + rev0 = c.exported_recipe_revision() + kind_create = "tool-requires" if "build-requires" == kind else kind + c.run(f"lock create --{kind_create}={pkg}/[*]") + lock = c.load("conan.lock") + assert f"{old}#{rev0}" in lock + + c.run(f"export {pkg} --version=1.1") + rev1 = c.exported_recipe_revision() + c.run(f"lock upgrade --{kind_create}={pkg}/[*] --update-{kind}={pkg}/[*]") + # c.run(f"lock upgrade --{kind_create}={pkg}/[*] --update-{kind}={pkg}/[*] --transitives") # TODO this is failing because of [] + # c.run(f"lock upgrade --{kind_create}={pkg}/[*] --update-{kind}={old} --transitives") + lock = c.load("conan.lock") + print(lock) + assert f"{old}#{rev0}" not in lock + assert f"{new}#{rev1}" in lock + + + def test_lock_upgrade_path(self): + c = TestClient(light=True) + c.save({"liba/conanfile.py": GenConanfile("liba"), + "libb/conanfile.py": GenConanfile("libb"), + "libc/conanfile.py": GenConanfile("libc"), + "libd/conanfile.py": GenConanfile("libd")}) + c.run(f"export liba --version=1.0") + c.run(f"export libb --version=1.0") + c.run(f"export libc --version=1.0") + c.run(f"export libd --version=1.0") + c.save( + { + f"conanfile.py": GenConanfile() + .with_requires(f"liba/[>=1.0 <2]") + .with_requires("libb/[<1.2]") + .with_tool_requires("libc/[>=1.0]") + .with_python_requires("libd/[>=1.0 <1.2]") + } + ) + + c.run("lock create .") + lock = c.load("conan.lock") + assert "liba/1.0" in lock + assert "libb/1.0" in lock + assert "libc/1.0" in lock + assert "libd/1.0" in lock + + # Check versions are updated accordingly + c.run(f"export liba --version=1.9") + c.run(f"export libb --version=1.1") + c.run(f"export libb --version=1.2") + c.run(f"export libc --version=1.1") + c.run(f"export libd --version=1.1") + c.run("lock upgrade . --update-requires=liba/1.0 --update-requires=libb/[*] --update-build-requires=libc/[*] --update-python-requires=libd/1.0") + lock = c.load("conan.lock") + assert "liba/1.9" in lock + assert "libb/1.1" in lock + assert "libc/1.1" in lock + assert "libd/1.1" in lock + + # Check version conanfile version range is respected + c.run(f"export libd --version=1.2") + c.run("lock upgrade . --update-python-requires=libd/*") + lock = c.load("conan.lock") + assert "libd/1.1" in lock + assert "libd/1.2" not in lock + + def test_lock_upgrade_transitives(self): + c = TestClient(light=True) + c.save({"liba/conanfile.py": GenConanfile("liba"), + "libb/conanfile.py": GenConanfile("libb").with_requires("libc/[<2.0]"), + "libc/conanfile.py": GenConanfile("libc")}) + c.run(f"export liba --version=1.0") + c.run(f"export libc --version=1.0") + c.run(f"create libb --version=1.0 --build=missing") + c.save( + { + f"conanfile.py": GenConanfile() + .with_requires(f"liba/[>=1.0 <2]") + .with_requires("libb/[<1.2]") + } + ) + c.run("lock create .") + lock = c.load("conan.lock") + assert "liba/1.0" in lock + assert "libb/1.0" in lock + assert "libc/1.0" in lock + + # Check if transitive dependencies of libb (libc) also gets updated + c.run(f"export libc --version=1.1") + c.run(f"create libb --version=1.1 --build=missing") + c.run("lock upgrade . --update-requires=libb/1.0 --transitives") + lock = c.load("conan.lock") + assert "liba/1.0" in lock + assert "libb/1.1" in lock + assert "libc/1.1" in lock + + # Check if new transitive dependency is added -> this should work even without --transitive + c.save({"libb/conanfile.py": GenConanfile("libb").with_requires("libc/[<2.0]").with_requires("libd/[<2.0]"), + "libd/conanfile.py": GenConanfile("libd")}) + c.run(f"export libd --version=1.0") + c.run(f"create libb --version=1.1 --build=missing") + c.run("lock upgrade . --update-requires=libb/1.1") + lock = c.load("conan.lock") + assert "liba/1.0" in lock + assert "libb/1.1" in lock + assert "libc/1.1" in lock + assert "libd/1.0" in lock + + # Check double transitive upgrade + c.save({"libb/conanfile.py": GenConanfile("libb").with_requires("libc/[<2.0]"), + "libc/conanfile.py": GenConanfile("libc").with_requires("libd/[<2.0]"), + "libd/conanfile.py": GenConanfile("libd")}) + c.run(f"export libd --version=1.0") + c.run(f"create libc --version=1.1 --build=missing") + c.run(f"create libb --version=1.1 --build=missing") + c.run("lock upgrade . --update-requires=libb/1.1 --transitives") + lock = c.load("conan.lock") + assert "liba/1.0" in lock + assert "libb/1.1" in lock + assert "libc/1.1" in lock + assert "libd/1.0" in lock + c.run(f"export libd --version=1.1") + c.run("lock upgrade . --update-requires=libb/1.1 --transitives") + lock = c.load("conan.lock") + assert "liba/1.0" in lock + assert "libb/1.1" in lock + assert "libc/1.1" in lock + assert "libd/1.1" in lock + + def test_lock_upgrade_build_transitives(self): + c = TestClient(light=True) + c.save({"liba/conanfile.py": GenConanfile("liba"), + "libb/conanfile.py": GenConanfile("libb").with_build_requires("libc/[<2.0]"), + "libc/conanfile.py": GenConanfile("libc").with_requires("libd/[<2.0]"), + "libd/conanfile.py": GenConanfile("libd")}) + c.run(f"export libd --version=1.0") + c.run(f"create libc --version=1.0 --build=missing") + c.run(f"create libb --version=1.0 --build=missing") + c.run(f"export liba --version=1.0") + c.save( + { + f"conanfile.py": GenConanfile() + .with_requires(f"liba/[>=1.0 <2]") + .with_build_requires("libb/[<1.2]") + } + ) + c.run("lock create .") + c.run(f"export libd --version=1.1") + c.run(f"create libc --version=1.1 --build=missing") + c.run(f"create libb --version=1.1 --build=missing") + c.run("lock upgrade . --update-build-requires=libb/1.0 --transitives") + lock = c.load("conan.lock") + print(c.out) + print(lock) + assert "liba/1.0" in lock + assert "libb/1.1" in lock + assert "libc/1.1" in lock + # assert "libd/1.1" in lock # TODO is failing -> we are not getting right the build_requires transitives