From 04cd84710e3fc07636c521a97b8891bebc27f32d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Abril=20Rinc=C3=B3n=20Blanco?= Date: Fri, 14 Aug 2026 12:37:28 +0200 Subject: [PATCH 1/3] Add test for source command not using lockfiles --- .../lockfile/test_lock_pyrequires.py | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/test/integration/lockfile/test_lock_pyrequires.py b/test/integration/lockfile/test_lock_pyrequires.py index cf1ba0d698b..a4b90810305 100644 --- a/test/integration/lockfile/test_lock_pyrequires.py +++ b/test/integration/lockfile/test_lock_pyrequires.py @@ -40,6 +40,44 @@ class MyConanfileBase(ConanFile): assert "dep/0.1" not in client.out +def test_source_should_respect_lockfile_pyrequires(): + # "conan source" should resolve python_requires against a lockfile the same way + # "install"/"create"/"export"/"build" do, honoring even an auto-discovered "conan.lock" + # sitting right next to the conanfile. + # + # THIS TEST CURRENTLY FAILS: "conan source" never loads/forwards a lockfile at all (there + # isn't even a --lockfile argument for it), so python_requires are always resolved to the + # latest match instead. See the "Missing lockfile for python_requires" TODO in + # conan/cli/commands/source.py, and LocalAPI.source() hardcoding graph_lock=None. + client = TestClient(light=True) + consumer = textwrap.dedent(""" + from conan import ConanFile + class Consumer(ConanFile): + python_requires = "dep/[>0.0]@user/channel" + def source(self): + v = self.python_requires["dep"].ref.version + self.output.info("SOURCE DEP VERSION: {}".format(v)) + """) + client.save({"dep/conanfile.py": GenConanfile(), + "consumer/conanfile.py": consumer}) + + client.run("export dep --name=dep --version=0.1 --user=user --channel=channel") + client.run("lock create consumer/conanfile.py") # writes consumer/conan.lock, locks dep/0.1 + + client.run("export dep --name=dep --version=0.2 --user=user --channel=channel") + lockfile = os.path.join(client.current_folder, "consumer", "conan.lock") + assert os.path.isfile(lockfile) # the lock is indeed present when "source" runs below + + # "install" auto-discovers "consumer/conan.lock" and correctly keeps resolving dep/0.1 + client.run("install consumer/conanfile.py") + assert "dep/0.1@user/channel" in client.out + assert "dep/0.2" not in client.out + + # "source" should behave the same way and also resolve the locked dep/0.1 + client.run("source consumer/conanfile.py") + assert "SOURCE DEP VERSION: 0.1" in client.out + + def test_transitive_matching_ranges(): client = TestClient(light=True) tool = textwrap.dedent(""" From c64ecfdff5b0ebaa4daec8dbcbf61784f616ac9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Abril=20Rinc=C3=B3n=20Blanco?= Date: Fri, 14 Aug 2026 12:39:48 +0200 Subject: [PATCH 2/3] Simplify --- .../lockfile/test_lock_pyrequires.py | 23 ++++++++----------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/test/integration/lockfile/test_lock_pyrequires.py b/test/integration/lockfile/test_lock_pyrequires.py index a4b90810305..745731a46dc 100644 --- a/test/integration/lockfile/test_lock_pyrequires.py +++ b/test/integration/lockfile/test_lock_pyrequires.py @@ -41,14 +41,8 @@ class MyConanfileBase(ConanFile): def test_source_should_respect_lockfile_pyrequires(): - # "conan source" should resolve python_requires against a lockfile the same way - # "install"/"create"/"export"/"build" do, honoring even an auto-discovered "conan.lock" - # sitting right next to the conanfile. - # - # THIS TEST CURRENTLY FAILS: "conan source" never loads/forwards a lockfile at all (there - # isn't even a --lockfile argument for it), so python_requires are always resolved to the - # latest match instead. See the "Missing lockfile for python_requires" TODO in - # conan/cli/commands/source.py, and LocalAPI.source() hardcoding graph_lock=None. + # "conan source" currently does not resolve python_requires against a lockfile the same way + # "install"/"create"/"export"/"build" do client = TestClient(light=True) consumer = textwrap.dedent(""" from conan import ConanFile @@ -59,22 +53,23 @@ def source(self): self.output.info("SOURCE DEP VERSION: {}".format(v)) """) client.save({"dep/conanfile.py": GenConanfile(), - "consumer/conanfile.py": consumer}) + "conanfile.py": consumer}) client.run("export dep --name=dep --version=0.1 --user=user --channel=channel") - client.run("lock create consumer/conanfile.py") # writes consumer/conan.lock, locks dep/0.1 + client.run("lock create conanfile.py") # writes conan.lock, locks dep/0.1 client.run("export dep --name=dep --version=0.2 --user=user --channel=channel") - lockfile = os.path.join(client.current_folder, "consumer", "conan.lock") + lockfile = os.path.join(client.current_folder, "conan.lock") assert os.path.isfile(lockfile) # the lock is indeed present when "source" runs below - # "install" auto-discovers "consumer/conan.lock" and correctly keeps resolving dep/0.1 - client.run("install consumer/conanfile.py") + # "install" auto-discovers "conan.lock" and correctly keeps resolving dep/0.1 + client.run("install conanfile.py") assert "dep/0.1@user/channel" in client.out assert "dep/0.2" not in client.out # "source" should behave the same way and also resolve the locked dep/0.1 - client.run("source consumer/conanfile.py") + client.run("source conanfile.py") + # This fails, it currently resolves to dep/0.2 assert "SOURCE DEP VERSION: 0.1" in client.out From 1b8fa482a9ac06393ebdb121a75ba9ccd898cb65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Abril=20Rinc=C3=B3n=20Blanco?= Date: Mon, 17 Aug 2026 00:20:50 +0200 Subject: [PATCH 3/3] Simplest lockfile support approach for conan source --- conan/api/subapi/local.py | 5 +++-- conan/cli/commands/source.py | 14 +++++++++++--- test/integration/lockfile/test_lock_pyrequires.py | 4 ++-- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/conan/api/subapi/local.py b/conan/api/subapi/local.py index dbcc4ead3b0..fd597897db0 100644 --- a/conan/api/subapi/local.py +++ b/conan/api/subapi/local.py @@ -102,7 +102,7 @@ def editable_list(self): return self._helpers.editable_packages.edited_refs def source(self, path, name=None, version=None, user=None, channel=None, - remotes: List[Remote] = None): + remotes: List[Remote] = None, lockfile=None): """ Calls the ``source()`` method of the current (user folder) ``conanfile.py`` This method does not require computing a dependency graph, because the ``source()`` @@ -114,10 +114,11 @@ def source(self, path, name=None, version=None, user=None, channel=None, :param user: The user of the package. If not defined, it is taken from conanfile :param channel: The channel of the package. If not defined, it is taken from conanfile :param remotes: The remotes to resolve possible ``python-requires`` for this recipe if needed. + :param lockfile: The lockfile to use for the ``python-requires`` resolution, if needed. """ loader = self._helpers.loader conanfile = loader.load_consumer(path, name=name, version=version, - user=user, channel=channel, graph_lock=None, + user=user, channel=channel, graph_lock=lockfile, remotes=remotes) # This profile is empty, but with the conf from global.conf profile = self._conan_api.profiles.get_profile([]) diff --git a/conan/cli/commands/source.py b/conan/cli/commands/source.py index 4a694964b74..61dfdd6dca1 100644 --- a/conan/cli/commands/source.py +++ b/conan/cli/commands/source.py @@ -1,6 +1,6 @@ import os -from conan.cli.command import conan_command +from conan.cli.command import conan_command, OnceArgument from conan.cli.args import add_reference_args @@ -12,12 +12,20 @@ def source(conan_api, parser, *args): parser.add_argument("path", help="Path to a folder containing a conanfile.py. " "Defaults to current directory", default=".", nargs="?") + parser.add_argument("-l", "--lockfile", action=OnceArgument, + help="Path to a lockfile for python-requires resolution. Use " + "--lockfile=\"\" to avoid automatic use of existing 'conan.lock' file") + parser.add_argument("--lockfile-partial", action="store_true", + help="Do not raise an error if some dependency is not found in lockfile") add_reference_args(parser) args = parser.parse_args(*args) cwd = os.getcwd() path = conan_api.local.get_conanfile_path(args.path, cwd, py=True) enabled_remotes = conan_api.remotes.list() # for python_requires not local - # TODO: Missing lockfile for python_requires + lockfile = conan_api.lockfile.get_lockfile(lockfile=args.lockfile, + conanfile_path=path, + cwd=cwd, + partial=args.lockfile_partial) conan_api.local.source(path, name=args.name, version=args.version, user=args.user, - channel=args.channel, remotes=enabled_remotes) + channel=args.channel, remotes=enabled_remotes, lockfile=lockfile) diff --git a/test/integration/lockfile/test_lock_pyrequires.py b/test/integration/lockfile/test_lock_pyrequires.py index 745731a46dc..2f5b46c4f97 100644 --- a/test/integration/lockfile/test_lock_pyrequires.py +++ b/test/integration/lockfile/test_lock_pyrequires.py @@ -57,11 +57,11 @@ def source(self): client.run("export dep --name=dep --version=0.1 --user=user --channel=channel") client.run("lock create conanfile.py") # writes conan.lock, locks dep/0.1 - - client.run("export dep --name=dep --version=0.2 --user=user --channel=channel") lockfile = os.path.join(client.current_folder, "conan.lock") assert os.path.isfile(lockfile) # the lock is indeed present when "source" runs below + client.run("export dep --name=dep --version=0.2 --user=user --channel=channel") + # "install" auto-discovers "conan.lock" and correctly keeps resolving dep/0.1 client.run("install conanfile.py") assert "dep/0.1@user/channel" in client.out