Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
be1a753
Initial sketch for conan run command
AbrilRBS Sep 22, 2025
709d1f5
Sketch install built-in in run command
AbrilRBS Sep 22, 2025
a2b04d1
Fix test
AbrilRBS Sep 22, 2025
6967d6b
Use conanfile run method which handles context and subprocess
perseoGI Sep 23, 2025
49785fd
Add test assert
perseoGI Sep 23, 2025
8253f02
Added test case for conan run with conanfile
perseoGI Sep 24, 2025
0110713
Add missing os setting for tests
AbrilRBS Sep 24, 2025
fd72c3d
Updated default options according to Look Intos meet
perseoGI Sep 30, 2025
c2975b9
Merge branch 'develop2' into ar/conan-run-command
AbrilRBS Sep 30, 2025
9d78a1b
Sketch output handling, might be reverted later
AbrilRBS Oct 1, 2025
8d204f7
Sketch output handling, might be reverted later
AbrilRBS Oct 1, 2025
58c02e1
Merge branch 'ar/conan-run-command' of github.com:AbrilRBS/conan into…
AbrilRBS Oct 20, 2025
d36d3d3
Warning for installation taking a while
AbrilRBS Oct 20, 2025
6ca752f
Colour, make info so warnings as errors does not trigger
AbrilRBS Oct 20, 2025
e476de0
Bring back build-require, be consistent with conan build/install comm…
AbrilRBS Oct 20, 2025
e08cb7b
TODO for --no-remote
AbrilRBS Oct 20, 2025
75f0f3f
This command is experimental
AbrilRBS Oct 20, 2025
30edc55
use both envs by default
AbrilRBS Oct 23, 2025
a6fdc6f
--no-remote proposal
AbrilRBS Oct 23, 2025
bb6dc70
We have decided against it
AbrilRBS Oct 23, 2025
bcc7d1f
Test that host context has priority
AbrilRBS Oct 23, 2025
180b016
Test cleanup
AbrilRBS Oct 23, 2025
2383f5f
Update conan/cli/commands/run.py
AbrilRBS Oct 23, 2025
b95c4d9
Fix tests
AbrilRBS Oct 24, 2025
3c109cd
Merge branch 'ar/conan-run-command' of github.com:AbrilRBS/conan into…
AbrilRBS Oct 24, 2025
eb6312c
Move files to temp
AbrilRBS Oct 24, 2025
d04f796
better output msgs
AbrilRBS Oct 28, 2025
e197c12
Format
AbrilRBS Oct 28, 2025
4a1620f
Format
AbrilRBS Oct 28, 2025
6d0960d
Shhh
AbrilRBS Oct 28, 2025
e256b1c
Shhh
AbrilRBS Oct 28, 2025
c165352
Cleanup APIs, dont rewrite for now
AbrilRBS Nov 6, 2025
961c2a5
Public api
AbrilRBS Nov 6, 2025
04d180a
Output error message
AbrilRBS Nov 6, 2025
f1544e6
Missing space
AbrilRBS Nov 6, 2025
8832423
Merge branch 'develop2' into ar/conan-run-command
AbrilRBS Nov 13, 2025
1c53422
Apply suggestion from @AbrilRBS
AbrilRBS Nov 13, 2025
46005d4
Fix syntax
AbrilRBS Nov 13, 2025
6b82b83
Add missing classmethod decorator
AbrilRBS Nov 13, 2025
a8b4982
Fix none assignation for log level
AbrilRBS Nov 13, 2025
66f78d7
fix
AbrilRBS Nov 13, 2025
7b57e54
Simplify command argument
AbrilRBS Nov 13, 2025
0e8512e
Simplify output
AbrilRBS Nov 19, 2025
7103087
Merge branch 'develop2' into ar/conan-run-command
AbrilRBS Nov 19, 2025
2360264
Tests, fix merge
AbrilRBS Nov 19, 2025
b66796b
Reorder
AbrilRBS Nov 19, 2025
9dc7a66
Better error check
AbrilRBS Nov 19, 2025
03b28b9
Improve install apis and internal install-error propagation
AbrilRBS Nov 24, 2025
5277cf0
Fix quiet usage in system package
AbrilRBS Nov 25, 2025
69a98e3
Merge branch 'develop2' into ar/conan-run-command
AbrilRBS Nov 25, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion conan/api/output.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,14 @@ def define_silence_warnings(cls, warnings):
def set_warnings_as_errors(cls, value):
cls._warnings_as_errors = value

@classmethod
def get_output_level(cls):
return cls._conan_output_level

@classmethod
def set_output_level(cls, level):
cls._conan_output_level = level

@classmethod
def valid_log_levels(cls):
return {"quiet": LEVEL_QUIET, # -vquiet 80
Expand Down Expand Up @@ -151,7 +159,7 @@ def define_log_level(cls, v):
vals = "quiet, error, warning, notice, status, verbose, debug(v), trace(vv)"
raise ConanException(f"Invalid argument '-v{v}'{msg}.\nAllowed values: {vals}")
else:
cls._conan_output_level = level
cls.set_output_level(level)

@classmethod
def level_allowed(cls, level):
Expand Down
41 changes: 28 additions & 13 deletions conan/cli/commands/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,29 @@ def install(conan_api, parser, *args):
help="Generation strategy for virtual environment files for the root")
args = parser.parse_args(*args)
validate_common_graph_args(args)
# basic paths
cwd = os.getcwd()

deps_graph, lockfile, install_error = _run_install_command(conan_api, args, cwd)

# Update lockfile if necessary
lockfile = conan_api.lockfile.update_lockfile(lockfile, deps_graph, args.lockfile_packages,
clean=args.lockfile_clean)
conan_api.lockfile.save_lockfile(lockfile, args.lockfile_out, cwd)
return {"graph": deps_graph,
"conan_api": conan_api,
"conan_error": install_error}


def _run_install_command(conan_api, args, cwd, return_install_error=True):
"""
This method should not be imported as-is, it is internal to the installation process and
its signature might change without warning.

Users are however free to copy its code and adapt it to their needs, as an example of
using the Conan API to perform an installation
"""
# basic paths

path = conan_api.local.get_conanfile_path(args.path, cwd, py=None) if args.path else None
source_folder = os.path.dirname(path) if args.path else cwd
output_folder = make_abs_path(args.output_folder, cwd) if args.output_folder else None
Expand Down Expand Up @@ -73,20 +94,14 @@ def install(conan_api, parser, *args):

# Installation of binaries and consumer generators
install_error = conan_api.install.install_binaries(deps_graph=deps_graph, remotes=remotes,
return_install_error=True)
return_install_error=return_install_error)
if not install_error:
ConanOutput().title("Finalizing install (deploy, generators)")
conan_api.install.install_consumer(deps_graph, args.generator, source_folder, output_folder,
deploy=args.deployer,
deploy_package=args.deployer_package,
deploy_folder=args.deployer_folder,
envs_generation=args.envs_generation)
deploy=getattr(args, "deployer", None),
deploy_package=getattr(args, "deployer_package", None),
deploy_folder=getattr(args, "deployer_folder", None),
envs_generation=getattr(args, "envs_generation", None))
ConanOutput().success("Install finished successfully")

# Update lockfile if necessary
lockfile = conan_api.lockfile.update_lockfile(lockfile, deps_graph, args.lockfile_packages,
clean=args.lockfile_clean)
conan_api.lockfile.save_lockfile(lockfile, args.lockfile_out, cwd)
return {"graph": deps_graph,
"conan_api": conan_api,
"conan_error": install_error}
return deps_graph, lockfile, install_error
55 changes: 55 additions & 0 deletions conan/cli/commands/run.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import os
import tempfile

from conan.api.output import ConanOutput, LEVEL_STATUS, Color, LEVEL_ERROR, LEVEL_QUIET
from conan.cli.args import common_graph_args, validate_common_graph_args
from conan.cli.command import conan_command
from conan.cli.commands.install import _run_install_command
from conan.errors import ConanException


@conan_command(group="Consumer")
def run(conan_api, parser, *args):
"""
(Experimental) Run a command given a set of requirements from a recipe or from command line.
"""
common_graph_args(parser)
parser.add_argument("command", help="Command to run")
parser.add_argument("--context", help="Context to use, by default both contexts are activated "
"if not specified",
choices=["host", "build"], default=None)
parser.add_argument("--build-require", action='store_true', default=False,
help='Whether the provided path is a build-require')
args = parser.parse_args(*args)
validate_common_graph_args(args)
cwd = os.getcwd()

ConanOutput().info("Installing and building dependencies, this might take a while...",
fg=Color.BRIGHT_MAGENTA)
previous_log_level = ConanOutput.get_output_level()
if previous_log_level == LEVEL_STATUS:
ConanOutput.set_output_level(LEVEL_QUIET)

with tempfile.TemporaryDirectory("conanrun") as tmpdir:
# Default values for install
setattr(args, "output_folder", tmpdir)
setattr(args, "generator", [])
try:
deps_graph, lockfile, _ = _run_install_command(conan_api, args, cwd,
return_install_error=False)
except ConanException as e:
ConanOutput.set_output_level(previous_log_level)
ConanOutput().error("Error installing the dependencies. To debug this, you can either:\n"
" - Re-run the command with increased verbosity (-v, -vv)\n"
" - Run 'conan install' first to ensure dependencies are installed, "
"or to see errors during installation\n")
raise e

context_env_map = {
"build": "conanbuild",
"host": "conanrun",
}
envfiles = list(context_env_map.values()) if args.context is None \
else [context_env_map.get(args.context)]
ConanOutput.set_output_level(LEVEL_ERROR)
deps_graph.root.conanfile.run(args.command, cwd=cwd, env=envfiles)
1 change: 1 addition & 0 deletions conan/internal/loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@ def load_virtual(self, requires=None, tool_requires=None, python_requires=None,
# If user don't specify namespace in options, assume that it is
# for the reference (keep compatibility)
conanfile = ConanFile(display_name="cli")
conanfile._conan_helpers = self._conanfile_helpers

if tool_requires:
for reference in tool_requires:
Expand Down
8 changes: 6 additions & 2 deletions conan/internal/model/conan_file.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import os
import subprocess
from pathlib import Path

from conan.api.output import ConanOutput, Color
from conan.api.output import ConanOutput, Color, LEVEL_QUIET
from conan.internal.subsystems import command_env_wrapper
from conan.errors import ConanException
from conan.internal.model.cpp_info import MockInfoProperty
Expand Down Expand Up @@ -386,8 +387,11 @@ def run(self, command: str, stdout=None, cwd=None, ignore_errors=False, env="",
wrapped_cmd = command_env_wrapper(self, command, env, envfiles_folder=envfiles_folder)
from conan.internal.util.runners import conan_run
if not quiet:
ConanOutput().writeln(f"{self.display_name}: RUN: {command}", fg=Color.BRIGHT_BLUE)
ConanOutput().info(f"{self.display_name}: RUN: {command}", fg=Color.BRIGHT_BLUE)
ConanOutput().debug(f"{self.display_name}: Full command: {wrapped_cmd}")
if quiet or ConanOutput.get_output_level() == LEVEL_QUIET:
stdout = subprocess.DEVNULL if stdout is None else stdout
stderr = subprocess.DEVNULL if stderr is None else stderr
retcode = conan_run(wrapped_cmd, cwd=cwd, stdout=stdout, stderr=stderr, shell=shell)
if not quiet:
ConanOutput().writeln("")
Expand Down
8 changes: 4 additions & 4 deletions conan/tools/system/package_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,9 +105,9 @@ def run(self, method, *args, **kwargs):
if self._active_tool == self.__class__.tool_name:
return method(*args, **kwargs)

def _conanfile_run(self, command, accepted_returns):
def _conanfile_run(self, command, accepted_returns, quiet=True):
# When checking multiple packages, this is too noisy
ret = self._conanfile.run(command, ignore_errors=True, quiet=True)
ret = self._conanfile.run(command, ignore_errors=True, quiet=quiet)
if ret not in accepted_returns:
raise ConanException("Command '%s' failed" % command)
return ret
Expand Down Expand Up @@ -209,7 +209,7 @@ def _install(self, packages, update=False, check=True, host_package=True, **kwar
tool=self.tool_name,
packages=" ".join(packages_arch),
**kwargs)
return self._conanfile_run(command, self.accepted_install_codes)
return self._conanfile_run(command, self.accepted_install_codes, quiet=False)
else:
self._conanfile.output.info("System requirements: {} already "
"installed".format(" ".join(packages)))
Expand All @@ -219,7 +219,7 @@ def _update(self):
# in case we are in check mode just ignore
if self._mode == self.mode_install:
command = self.update_command.format(sudo=self.sudo_str, tool=self.tool_name)
return self._conanfile_run(command, self.accepted_update_codes)
return self._conanfile_run(command, self.accepted_update_codes, quiet=False)

def _check(self, packages, host_package=True):
missing = [pkg for pkg in packages if self.check_package(pkg, host_package) != 0]
Expand Down
105 changes: 105 additions & 0 deletions test/integration/command/test_run.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import textwrap

import pytest
import platform

from conan.test.assets.genconanfile import GenConanfile
from conan.test.utils.tools import TestClient


executable = "myapp.bat" if platform.system() == "Windows" else "myapp.sh"


@pytest.fixture(scope="module")
def client():
tc = TestClient(default_server_user=True)
conanfile = textwrap.dedent("""
from conan import ConanFile
from conan.tools.files import save
import os

class Pkg(ConanFile):
name = "pkg"
version = "0.1"
# So that the requirement is run=True even for --requires
package_type = "application"
options = {"foo": [True, False, "bar"]}
default_options = {"foo": True}
settings = "os"

def package(self):
executable = os.path.join(self.package_folder, "bin", '""" + executable + """')
save(self, executable, f"echo Hello World! foo={self.options.foo}")
# Make it executable
os.chmod(executable, 0o755)
""")
tc.save({"pkg/conanfile.py": conanfile})
tc.run("create pkg")
return tc


@pytest.mark.parametrize("context_flag", ["host", "build", None])
@pytest.mark.parametrize("requires_context", ["host", "build",])
@pytest.mark.parametrize("use_conanfile", [True, False])
def test_run(client, context_flag, requires_context, use_conanfile):
context_arg = {
"host": "--context=host",
"build": "--context=build",
None: "",
}.get(context_flag)
should_find_binary = (context_flag == requires_context) or (context_flag is None)
if use_conanfile:
conanfile_consumer = GenConanfile("consumer", "1.0").with_settings("os")
if requires_context == "host":
conanfile_consumer.with_requires("pkg/0.1")
else:
conanfile_consumer.with_tool_requires("pkg/0.1")

client.save({"conanfile.py": conanfile_consumer})
client.run(f"run {executable} {context_arg}", assert_error=not should_find_binary)
else:
requires = "requires" if requires_context == "host" else "tool-requires"
client.run(f"run {executable} --{requires}=pkg/0.1 {context_arg}",
assert_error=not should_find_binary)
if should_find_binary:
assert "Hello World!" in client.out
else:
if platform.system() == "Windows":
assert "not recognized as an internal or external command" in client.out
else:
assert "Error 127 while executing" in client.out


def test_run_context_priority(client):
client.run("create pkg -o=pkg/*:foo=False")

client.run(f"run {executable} --requires=pkg/0.1 --tool-requires=pkg/0.1 -o:b=pkg/*:foo=False")
# True is host, False is build, run gives priority to host
assert "Hello World! foo=True" in client.out


def test_run_missing_executable(client):
client.run(f"run a-binary-name-that-does-not-exist --requires=pkg/0.1", assert_error=True)
if platform.system() == "Windows":
assert "not recognized as an internal or external command" in client.out
else:
assert "Error 127 while executing" in client.out


def test_run_missing_binary(client):
client.run("run foo --requires=pkg/0.1 -o=pkg/*:foo=bar", assert_error=True)
assert "Error installing the dependencies" in client.out
assert "Missing prebuilt package for 'pkg/0.1'" in client.out


def test_run_missing_package(client):
client.run("run foo --requires=pkg/2.1", assert_error=True)
assert "Error installing the dependencies" in client.out
assert "Package 'pkg/2.1' not resolved" in client.out


@pytest.mark.skipif(platform.system() == "Windows", reason="Unix only")
def test_run_status_is_propagated(client):
client.run("run false --requires=pkg/0.1", assert_error=True)
assert "Error installing the dependencies" not in client.out
assert "ERROR: Error 1 while executing" in client.out
Loading