Skip to content

Commit 2830cb5

Browse files
AbrilRBSperseoGImemsharded
authored
Add conan run command to run packages from Conan directly (#18972)
* Initial sketch for conan run command * Sketch install built-in in run command --------- Co-authored-by: PerseoGI <perseog@jfrog.com> * Fix test * Use conanfile run method which handles context and subprocess * Add test assert * Added test case for conan run with conanfile * Add missing os setting for tests * Updated default options according to Look Intos meet * Sketch output handling, might be reverted later * Sketch output handling, might be reverted later * Warning for installation taking a while * Colour, make info so warnings as errors does not trigger * Bring back build-require, be consistent with conan build/install commands * TODO for --no-remote * This command is experimental * use both envs by default * --no-remote proposal * We have decided against it * Test that host context has priority * Test cleanup * Update conan/cli/commands/run.py Co-authored-by: James <memsharded@gmail.com> * Fix tests * Move files to temp * better output msgs * Format * Format * Shhh * Shhh * Cleanup APIs, dont rewrite for now * Public api * Output error message * Missing space * Apply suggestion from @AbrilRBS * Fix syntax * Add missing classmethod decorator * Fix none assignation for log level * fix * Simplify command argument * Simplify output * Tests, fix merge * Reorder * Better error check * Improve install apis and internal install-error propagation * Fix quiet usage in system package --------- Co-authored-by: PerseoGI <perseog@jfrog.com> Co-authored-by: James <memsharded@gmail.com>
1 parent 3e679b7 commit 2830cb5

File tree

7 files changed

+208
-20
lines changed

7 files changed

+208
-20
lines changed

conan/api/output.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,14 @@ def define_silence_warnings(cls, warnings):
124124
def set_warnings_as_errors(cls, value):
125125
cls._warnings_as_errors = value
126126

127+
@classmethod
128+
def get_output_level(cls):
129+
return cls._conan_output_level
130+
131+
@classmethod
132+
def set_output_level(cls, level):
133+
cls._conan_output_level = level
134+
127135
@classmethod
128136
def valid_log_levels(cls):
129137
return {"quiet": LEVEL_QUIET, # -vquiet 80
@@ -151,7 +159,7 @@ def define_log_level(cls, v):
151159
vals = "quiet, error, warning, notice, status, verbose, debug(v), trace(vv)"
152160
raise ConanException(f"Invalid argument '-v{v}'{msg}.\nAllowed values: {vals}")
153161
else:
154-
cls._conan_output_level = level
162+
cls.set_output_level(level)
155163

156164
@classmethod
157165
def level_allowed(cls, level):

conan/cli/commands/install.py

Lines changed: 28 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,29 @@ def install(conan_api, parser, *args):
4343
help="Generation strategy for virtual environment files for the root")
4444
args = parser.parse_args(*args)
4545
validate_common_graph_args(args)
46-
# basic paths
4746
cwd = os.getcwd()
47+
48+
deps_graph, lockfile, install_error = _run_install_command(conan_api, args, cwd)
49+
50+
# Update lockfile if necessary
51+
lockfile = conan_api.lockfile.update_lockfile(lockfile, deps_graph, args.lockfile_packages,
52+
clean=args.lockfile_clean)
53+
conan_api.lockfile.save_lockfile(lockfile, args.lockfile_out, cwd)
54+
return {"graph": deps_graph,
55+
"conan_api": conan_api,
56+
"conan_error": install_error}
57+
58+
59+
def _run_install_command(conan_api, args, cwd, return_install_error=True):
60+
"""
61+
This method should not be imported as-is, it is internal to the installation process and
62+
its signature might change without warning.
63+
64+
Users are however free to copy its code and adapt it to their needs, as an example of
65+
using the Conan API to perform an installation
66+
"""
67+
# basic paths
68+
4869
path = conan_api.local.get_conanfile_path(args.path, cwd, py=None) if args.path else None
4970
source_folder = os.path.dirname(path) if args.path else cwd
5071
output_folder = make_abs_path(args.output_folder, cwd) if args.output_folder else None
@@ -73,20 +94,14 @@ def install(conan_api, parser, *args):
7394

7495
# Installation of binaries and consumer generators
7596
install_error = conan_api.install.install_binaries(deps_graph=deps_graph, remotes=remotes,
76-
return_install_error=True)
97+
return_install_error=return_install_error)
7798
if not install_error:
7899
ConanOutput().title("Finalizing install (deploy, generators)")
79100
conan_api.install.install_consumer(deps_graph, args.generator, source_folder, output_folder,
80-
deploy=args.deployer,
81-
deploy_package=args.deployer_package,
82-
deploy_folder=args.deployer_folder,
83-
envs_generation=args.envs_generation)
101+
deploy=getattr(args, "deployer", None),
102+
deploy_package=getattr(args, "deployer_package", None),
103+
deploy_folder=getattr(args, "deployer_folder", None),
104+
envs_generation=getattr(args, "envs_generation", None))
84105
ConanOutput().success("Install finished successfully")
85106

86-
# Update lockfile if necessary
87-
lockfile = conan_api.lockfile.update_lockfile(lockfile, deps_graph, args.lockfile_packages,
88-
clean=args.lockfile_clean)
89-
conan_api.lockfile.save_lockfile(lockfile, args.lockfile_out, cwd)
90-
return {"graph": deps_graph,
91-
"conan_api": conan_api,
92-
"conan_error": install_error}
107+
return deps_graph, lockfile, install_error

conan/cli/commands/run.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import os
2+
import tempfile
3+
4+
from conan.api.output import ConanOutput, LEVEL_STATUS, Color, LEVEL_ERROR, LEVEL_QUIET
5+
from conan.cli.args import common_graph_args, validate_common_graph_args
6+
from conan.cli.command import conan_command
7+
from conan.cli.commands.install import _run_install_command
8+
from conan.errors import ConanException
9+
10+
11+
@conan_command(group="Consumer")
12+
def run(conan_api, parser, *args):
13+
"""
14+
(Experimental) Run a command given a set of requirements from a recipe or from command line.
15+
"""
16+
common_graph_args(parser)
17+
parser.add_argument("command", help="Command to run")
18+
parser.add_argument("--context", help="Context to use, by default both contexts are activated "
19+
"if not specified",
20+
choices=["host", "build"], default=None)
21+
parser.add_argument("--build-require", action='store_true', default=False,
22+
help='Whether the provided path is a build-require')
23+
args = parser.parse_args(*args)
24+
validate_common_graph_args(args)
25+
cwd = os.getcwd()
26+
27+
ConanOutput().info("Installing and building dependencies, this might take a while...",
28+
fg=Color.BRIGHT_MAGENTA)
29+
previous_log_level = ConanOutput.get_output_level()
30+
if previous_log_level == LEVEL_STATUS:
31+
ConanOutput.set_output_level(LEVEL_QUIET)
32+
33+
with tempfile.TemporaryDirectory("conanrun") as tmpdir:
34+
# Default values for install
35+
setattr(args, "output_folder", tmpdir)
36+
setattr(args, "generator", [])
37+
try:
38+
deps_graph, lockfile, _ = _run_install_command(conan_api, args, cwd,
39+
return_install_error=False)
40+
except ConanException as e:
41+
ConanOutput.set_output_level(previous_log_level)
42+
ConanOutput().error("Error installing the dependencies. To debug this, you can either:\n"
43+
" - Re-run the command with increased verbosity (-v, -vv)\n"
44+
" - Run 'conan install' first to ensure dependencies are installed, "
45+
"or to see errors during installation\n")
46+
raise e
47+
48+
context_env_map = {
49+
"build": "conanbuild",
50+
"host": "conanrun",
51+
}
52+
envfiles = list(context_env_map.values()) if args.context is None \
53+
else [context_env_map.get(args.context)]
54+
ConanOutput.set_output_level(LEVEL_ERROR)
55+
deps_graph.root.conanfile.run(args.command, cwd=cwd, env=envfiles)

conan/internal/loader.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,7 @@ def load_virtual(self, requires=None, tool_requires=None, python_requires=None,
254254
# If user don't specify namespace in options, assume that it is
255255
# for the reference (keep compatibility)
256256
conanfile = ConanFile(display_name="cli")
257+
conanfile._conan_helpers = self._conanfile_helpers
257258

258259
if tool_requires:
259260
for reference in tool_requires:

conan/internal/model/conan_file.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import os
2+
import subprocess
23
from pathlib import Path
34

4-
from conan.api.output import ConanOutput, Color
5+
from conan.api.output import ConanOutput, Color, LEVEL_QUIET
56
from conan.internal.subsystems import command_env_wrapper
67
from conan.errors import ConanException
78
from conan.internal.model.cpp_info import MockInfoProperty
@@ -386,8 +387,11 @@ def run(self, command: str, stdout=None, cwd=None, ignore_errors=False, env="",
386387
wrapped_cmd = command_env_wrapper(self, command, env, envfiles_folder=envfiles_folder)
387388
from conan.internal.util.runners import conan_run
388389
if not quiet:
389-
ConanOutput().writeln(f"{self.display_name}: RUN: {command}", fg=Color.BRIGHT_BLUE)
390+
ConanOutput().info(f"{self.display_name}: RUN: {command}", fg=Color.BRIGHT_BLUE)
390391
ConanOutput().debug(f"{self.display_name}: Full command: {wrapped_cmd}")
392+
if quiet or ConanOutput.get_output_level() == LEVEL_QUIET:
393+
stdout = subprocess.DEVNULL if stdout is None else stdout
394+
stderr = subprocess.DEVNULL if stderr is None else stderr
391395
retcode = conan_run(wrapped_cmd, cwd=cwd, stdout=stdout, stderr=stderr, shell=shell)
392396
if not quiet:
393397
ConanOutput().writeln("")

conan/tools/system/package_manager.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -105,9 +105,9 @@ def run(self, method, *args, **kwargs):
105105
if self._active_tool == self.__class__.tool_name:
106106
return method(*args, **kwargs)
107107

108-
def _conanfile_run(self, command, accepted_returns):
108+
def _conanfile_run(self, command, accepted_returns, quiet=True):
109109
# When checking multiple packages, this is too noisy
110-
ret = self._conanfile.run(command, ignore_errors=True, quiet=True)
110+
ret = self._conanfile.run(command, ignore_errors=True, quiet=quiet)
111111
if ret not in accepted_returns:
112112
raise ConanException("Command '%s' failed" % command)
113113
return ret
@@ -209,7 +209,7 @@ def _install(self, packages, update=False, check=True, host_package=True, **kwar
209209
tool=self.tool_name,
210210
packages=" ".join(packages_arch),
211211
**kwargs)
212-
return self._conanfile_run(command, self.accepted_install_codes)
212+
return self._conanfile_run(command, self.accepted_install_codes, quiet=False)
213213
else:
214214
self._conanfile.output.info("System requirements: {} already "
215215
"installed".format(" ".join(packages)))
@@ -219,7 +219,7 @@ def _update(self):
219219
# in case we are in check mode just ignore
220220
if self._mode == self.mode_install:
221221
command = self.update_command.format(sudo=self.sudo_str, tool=self.tool_name)
222-
return self._conanfile_run(command, self.accepted_update_codes)
222+
return self._conanfile_run(command, self.accepted_update_codes, quiet=False)
223223

224224
def _check(self, packages, host_package=True):
225225
missing = [pkg for pkg in packages if self.check_package(pkg, host_package) != 0]
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import textwrap
2+
3+
import pytest
4+
import platform
5+
6+
from conan.test.assets.genconanfile import GenConanfile
7+
from conan.test.utils.tools import TestClient
8+
9+
10+
executable = "myapp.bat" if platform.system() == "Windows" else "myapp.sh"
11+
12+
13+
@pytest.fixture(scope="module")
14+
def client():
15+
tc = TestClient(default_server_user=True)
16+
conanfile = textwrap.dedent("""
17+
from conan import ConanFile
18+
from conan.tools.files import save
19+
import os
20+
21+
class Pkg(ConanFile):
22+
name = "pkg"
23+
version = "0.1"
24+
# So that the requirement is run=True even for --requires
25+
package_type = "application"
26+
options = {"foo": [True, False, "bar"]}
27+
default_options = {"foo": True}
28+
settings = "os"
29+
30+
def package(self):
31+
executable = os.path.join(self.package_folder, "bin", '""" + executable + """')
32+
save(self, executable, f"echo Hello World! foo={self.options.foo}")
33+
# Make it executable
34+
os.chmod(executable, 0o755)
35+
""")
36+
tc.save({"pkg/conanfile.py": conanfile})
37+
tc.run("create pkg")
38+
return tc
39+
40+
41+
@pytest.mark.parametrize("context_flag", ["host", "build", None])
42+
@pytest.mark.parametrize("requires_context", ["host", "build",])
43+
@pytest.mark.parametrize("use_conanfile", [True, False])
44+
def test_run(client, context_flag, requires_context, use_conanfile):
45+
context_arg = {
46+
"host": "--context=host",
47+
"build": "--context=build",
48+
None: "",
49+
}.get(context_flag)
50+
should_find_binary = (context_flag == requires_context) or (context_flag is None)
51+
if use_conanfile:
52+
conanfile_consumer = GenConanfile("consumer", "1.0").with_settings("os")
53+
if requires_context == "host":
54+
conanfile_consumer.with_requires("pkg/0.1")
55+
else:
56+
conanfile_consumer.with_tool_requires("pkg/0.1")
57+
58+
client.save({"conanfile.py": conanfile_consumer})
59+
client.run(f"run {executable} {context_arg}", assert_error=not should_find_binary)
60+
else:
61+
requires = "requires" if requires_context == "host" else "tool-requires"
62+
client.run(f"run {executable} --{requires}=pkg/0.1 {context_arg}",
63+
assert_error=not should_find_binary)
64+
if should_find_binary:
65+
assert "Hello World!" in client.out
66+
else:
67+
if platform.system() == "Windows":
68+
assert "not recognized as an internal or external command" in client.out
69+
else:
70+
assert "Error 127 while executing" in client.out
71+
72+
73+
def test_run_context_priority(client):
74+
client.run("create pkg -o=pkg/*:foo=False")
75+
76+
client.run(f"run {executable} --requires=pkg/0.1 --tool-requires=pkg/0.1 -o:b=pkg/*:foo=False")
77+
# True is host, False is build, run gives priority to host
78+
assert "Hello World! foo=True" in client.out
79+
80+
81+
def test_run_missing_executable(client):
82+
client.run(f"run a-binary-name-that-does-not-exist --requires=pkg/0.1", assert_error=True)
83+
if platform.system() == "Windows":
84+
assert "not recognized as an internal or external command" in client.out
85+
else:
86+
assert "Error 127 while executing" in client.out
87+
88+
89+
def test_run_missing_binary(client):
90+
client.run("run foo --requires=pkg/0.1 -o=pkg/*:foo=bar", assert_error=True)
91+
assert "Error installing the dependencies" in client.out
92+
assert "Missing prebuilt package for 'pkg/0.1'" in client.out
93+
94+
95+
def test_run_missing_package(client):
96+
client.run("run foo --requires=pkg/2.1", assert_error=True)
97+
assert "Error installing the dependencies" in client.out
98+
assert "Package 'pkg/2.1' not resolved" in client.out
99+
100+
101+
@pytest.mark.skipif(platform.system() == "Windows", reason="Unix only")
102+
def test_run_status_is_propagated(client):
103+
client.run("run false --requires=pkg/0.1", assert_error=True)
104+
assert "Error installing the dependencies" not in client.out
105+
assert "ERROR: Error 1 while executing" in client.out

0 commit comments

Comments
 (0)