Skip to content

Commit 4274ae5

Browse files
feat: dynamic runner parameters (#345)
context: codecov/engineering-team#407 Consider this a 1st iteration of this feature. A compromise between "this is easy to implement and helpful to users" and "users don't have to think to use this". The benefit is that now you can add dynamic params to your runners, the downside is that you need to know the runner configuration AND format these params accordingly. Otherwise it would be very hard to figure out exactly what parameter group a given parameter is. So, compromise. More details on whoe to actually use the feature are documented in the code.
1 parent 1918649 commit 4274ae5

File tree

6 files changed

+157
-17
lines changed

6 files changed

+157
-17
lines changed

codecov_cli/commands/labelanalysis.py

+41-2
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import logging
33
import pathlib
44
import time
5-
from typing import List, Optional
5+
from typing import Dict, List, Optional
66

77
import click
88
import requests
@@ -70,6 +70,11 @@
7070
help="Format in which --dry-run data is printed. Default is JSON.",
7171
default="json",
7272
)
73+
@click.option(
74+
"--runner-param",
75+
"runner_params",
76+
multiple=True,
77+
)
7378
@click.pass_context
7479
def label_analysis(
7580
ctx: click.Context,
@@ -80,6 +85,7 @@ def label_analysis(
8085
max_wait_time: str,
8186
dry_run: bool,
8287
dry_run_format: str,
88+
runner_params: List[str],
8389
):
8490
enterprise_url = ctx.obj.get("enterprise_url")
8591
logger.debug(
@@ -113,7 +119,8 @@ def label_analysis(
113119
codecov_yaml = ctx.obj["codecov_yaml"] or {}
114120
cli_config = codecov_yaml.get("cli", {})
115121
# Raises error if no runner is found
116-
runner = get_runner(cli_config, runner_name)
122+
parsed_runner_params = _parse_runner_params(runner_params)
123+
runner = get_runner(cli_config, runner_name, parsed_runner_params)
117124
logger.debug(
118125
f"Selected runner: {runner}",
119126
extra=dict(extra_log_attributes=dict(config=runner.params)),
@@ -232,6 +239,38 @@ def label_analysis(
232239
time.sleep(5)
233240

234241

242+
def _parse_runner_params(runner_params: List[str]) -> Dict[str, str]:
243+
"""Parses the structured list of dynamic runner params into a dictionary.
244+
Structure is `key=value`. If value is a list make it comma-separated.
245+
If the list item doesn't have '=' we consider it the key and set to None.
246+
247+
EXAMPLE:
248+
runner_params = ['key=value', 'null_item', 'list=item1,item2,item3']
249+
_parse_runner_params(runner_params) == {
250+
'key': 'value',
251+
'null_item': None,
252+
'list': ['item1', 'item2', 'item3']
253+
}
254+
"""
255+
final_params = {}
256+
for param in runner_params:
257+
# Emit warning if param is not well formatted
258+
# Using == 0 rather than != 1 because there might be
259+
# a good reason for the param to include '=' in the value.
260+
if param.count("=") == 0:
261+
logger.warning(
262+
f"Runner param {param} is not well formated. Setting value to None. Use '--runner-param key=value' to set value"
263+
)
264+
final_params[param] = None
265+
else:
266+
key, value = param.split("=", 1)
267+
# For list values we need to split the list too
268+
if "," in value:
269+
value = value.split(",")
270+
final_params[key] = value
271+
return final_params
272+
273+
235274
def _potentially_calculate_absent_labels(
236275
request_result, requested_labels
237276
) -> LabelAnalysisRequestResult:

codecov_cli/runners/__init__.py

+18-7
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ class UnableToFindRunner(Exception):
1515
pass
1616

1717

18-
def _load_runner_from_yaml(plugin_dict: typing.Dict) -> LabelAnalysisRunnerInterface:
18+
def _load_runner_from_yaml(
19+
plugin_dict: typing.Dict, dynamic_params: typing.Dict
20+
) -> LabelAnalysisRunnerInterface:
1921
try:
2022
module_obj = import_module(plugin_dict["module"])
2123
class_obj = getattr(module_obj, plugin_dict["class"])
@@ -32,16 +34,21 @@ def _load_runner_from_yaml(plugin_dict: typing.Dict) -> LabelAnalysisRunnerInter
3234
)
3335
raise
3436
try:
35-
return class_obj(**plugin_dict["params"])
37+
final_params = {**plugin_dict["params"], **dynamic_params}
38+
return class_obj(**final_params)
3639
except TypeError:
3740
click.secho(
38-
f"Unable to instantiate {class_obj} with parameters {plugin_dict['params']}",
41+
f"Unable to instantiate {class_obj} with parameters {final_params}",
3942
err=True,
4043
)
4144
raise
4245

4346

44-
def get_runner(cli_config, runner_name) -> LabelAnalysisRunnerInterface:
47+
def get_runner(
48+
cli_config, runner_name: str, dynamic_params: typing.Dict = None
49+
) -> LabelAnalysisRunnerInterface:
50+
if dynamic_params is None:
51+
dynamic_params = {}
4552
if runner_name == "pytest":
4653
config_params = cli_config.get("runners", {}).get("pytest", {})
4754
# This is for backwards compatibility with versions <= 0.3.4
@@ -52,10 +59,12 @@ def get_runner(cli_config, runner_name) -> LabelAnalysisRunnerInterface:
5259
logger.warning(
5360
"Using 'python' to configure the PytestStandardRunner is deprecated. Please change to 'pytest'"
5461
)
55-
return PytestStandardRunner(config_params)
62+
final_params = {**config_params, **dynamic_params}
63+
return PytestStandardRunner(final_params)
5664
elif runner_name == "dan":
5765
config_params = cli_config.get("runners", {}).get("dan", {})
58-
return DoAnythingNowRunner(config_params)
66+
final_params = {**config_params, **dynamic_params}
67+
return DoAnythingNowRunner(final_params)
5968
logger.debug(
6069
f"Trying to load runner {runner_name}",
6170
extra=dict(
@@ -65,5 +74,7 @@ def get_runner(cli_config, runner_name) -> LabelAnalysisRunnerInterface:
6574
),
6675
)
6776
if cli_config and runner_name in cli_config.get("runners", {}):
68-
return _load_runner_from_yaml(cli_config["runners"][runner_name])
77+
return _load_runner_from_yaml(
78+
cli_config["runners"][runner_name], dynamic_params=dynamic_params
79+
)
6980
raise UnableToFindRunner(f"Can't find runner {runner_name}")

codecov_cli/runners/pytest_standard_runner.py

+25
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import inspect
12
import logging
23
import random
34
import subprocess
@@ -42,6 +43,18 @@ def coverage_root(self) -> str:
4243
"""
4344
return self.get("coverage_root", "./")
4445

46+
@classmethod
47+
def get_available_params(cls) -> List[str]:
48+
"""Lists all the @property attribute names of this class.
49+
These attributes are considered the 'valid config options'
50+
"""
51+
klass_methods = [
52+
x
53+
for x in dir(cls)
54+
if (inspect.isdatadescriptor(getattr(cls, x)) and not x.startswith("__"))
55+
]
56+
return klass_methods
57+
4558

4659
class PytestStandardRunner(LabelAnalysisRunnerInterface):
4760

@@ -52,8 +65,20 @@ def __init__(self, config_params: Optional[dict] = None) -> None:
5265
super().__init__()
5366
if config_params is None:
5467
config_params = {}
68+
# Before we create the config params we emit warnings if any param is unknown
69+
# So the user knows something is wrong with their config
70+
self._possibly_warn_bad_config(config_params)
5571
self.params = PytestStandardRunnerConfigParams(config_params)
5672

73+
def _possibly_warn_bad_config(self, config_params: dict):
74+
available_config_params = (
75+
PytestStandardRunnerConfigParams.get_available_params()
76+
)
77+
provided_config_params = config_params.keys()
78+
for provided_param in provided_config_params:
79+
if provided_param not in available_config_params:
80+
logger.warning(f"Config parameter '{provided_param}' is unknonw.")
81+
5782
def parse_captured_output_error(self, exp: CalledProcessError) -> str:
5883
result = ""
5984
for out_stream in [exp.stdout, exp.stderr]:

tests/commands/test_invoke_labelanalysis.py

+15
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
_dry_run_json_output,
1414
_dry_run_list_output,
1515
_fallback_to_collected_labels,
16+
_parse_runner_params,
1617
_potentially_calculate_absent_labels,
1718
_send_labelanalysis_request,
1819
)
@@ -169,6 +170,20 @@ def test__dry_run_space_separated_list_output(self):
169170
== "TESTS_TO_RUN='--option=1' '--option=2' 'label_1' 'label_2'\nTESTS_TO_SKIP='--option=1' '--option=2' 'label_3' 'label_4'\n"
170171
)
171172

173+
def test_parse_dynamic_runner_options(self):
174+
params = [
175+
"wrong_param",
176+
"key=value",
177+
"list_key=val1,val2,val3",
178+
"point=somethingwith=sign",
179+
]
180+
assert _parse_runner_params(params) == {
181+
"wrong_param": None,
182+
"key": "value",
183+
"list_key": ["val1", "val2", "val3"],
184+
"point": "somethingwith=sign",
185+
}
186+
172187

173188
class TestLabelAnalysisCommand(object):
174189
def test_invoke_label_analysis_missing_token(self, mocker, fake_ci_provider):

tests/runners/test_pytest_standard_runner.py

+29-2
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@
55
import pytest
66
from pytest import ExitCode
77

8-
from codecov_cli.runners.pytest_standard_runner import PytestStandardRunner
8+
from codecov_cli.runners.pytest_standard_runner import (
9+
PytestStandardRunner,
10+
PytestStandardRunnerConfigParams,
11+
)
912
from codecov_cli.runners.pytest_standard_runner import logger as runner_logger
1013
from codecov_cli.runners.pytest_standard_runner import stdout as pyrunner_stdout
1114
from codecov_cli.runners.types import LabelAnalysisRequestResult
@@ -39,9 +42,33 @@ def test_execute_pytest(self, mock_subprocess):
3942
)
4043
assert result == output
4144

45+
@patch("codecov_cli.runners.pytest_standard_runner.logger.warning")
46+
def test_warning_bad_config(self, mock_warning):
47+
available_config = PytestStandardRunnerConfigParams.get_available_params()
48+
assert "python_path" in available_config
49+
assert "collect_tests_options" in available_config
50+
assert "some_missing_option" not in available_config
51+
params = dict(
52+
python_path="path_to_python",
53+
collect_tests_options=["option1", "option2"],
54+
some_missing_option="option",
55+
)
56+
runner = PytestStandardRunner(params)
57+
# Adding invalid config options emits a warning
58+
assert mock_warning.called_with(
59+
"Config parameter 'some_missing_option' is unknonw."
60+
)
61+
# Warnings don't change the config
62+
assert runner.params == {**params, "some_missing_option": "option"}
63+
# And we can still access the config as usual
64+
assert runner.params.python_path == "path_to_python"
65+
assert runner.params.collect_tests_options == ["option1", "option2"]
66+
4267
@pytest.mark.parametrize("python_path", ["/usr/bin/python", "venv/bin/python"])
4368
@patch("codecov_cli.runners.pytest_standard_runner.subprocess")
44-
def test_execute_pytest_user_provided_command(self, mock_subprocess, python_path):
69+
def test_execute_pytest_user_provided_python_path(
70+
self, mock_subprocess, python_path
71+
):
4572
output = "Output in stdout"
4673
return_value = MagicMock(stdout=output.encode("utf-8"))
4774
mock_subprocess.run.return_value = return_value

tests/runners/test_runners.py

+29-6
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ def test_get_standard_runners(self):
1414
assert isinstance(get_runner({}, "dan"), DoAnythingNowRunner)
1515
# TODO: Extend with other standard runners once we create them (e.g. JS)
1616

17-
def test_pytest_standard_runner_with_options_backwards_compatible(self):
17+
def test_pytest_standard_runner_with_options(self):
1818
config_params = dict(
1919
collect_tests_options=["--option=value", "-option"],
2020
)
@@ -26,6 +26,23 @@ def test_pytest_standard_runner_with_options_backwards_compatible(self):
2626
)
2727
assert runner_instance.params.coverage_root == "./"
2828

29+
def test_pytest_standard_runner_with_options_and_dynamic_options(self):
30+
config_params = dict(
31+
collect_tests_options=["--option=value", "-option"],
32+
)
33+
runner_instance = get_runner(
34+
{"runners": {"pytest": config_params}},
35+
"pytest",
36+
{"python_path": "path/to/python"},
37+
)
38+
assert isinstance(runner_instance, PytestStandardRunner)
39+
assert (
40+
runner_instance.params.collect_tests_options
41+
== config_params["collect_tests_options"]
42+
)
43+
assert runner_instance.params.python_path == "path/to/python"
44+
assert runner_instance.params.coverage_root == "./"
45+
2946
def test_pytest_standard_runner_with_options_backwards_compatible(self):
3047
config_params = dict(
3148
collect_tests_options=["--option=value", "-option"],
@@ -56,7 +73,9 @@ def test_get_runner_from_yaml(self, mock_load_runner):
5673
config = {"runners": {"my_runner": {"path": "path_to_my_runner"}}}
5774
mock_load_runner.return_value = "MyRunner()"
5875
assert get_runner(config, "my_runner") == "MyRunner()"
59-
mock_load_runner.assert_called_with({"path": "path_to_my_runner"})
76+
mock_load_runner.assert_called_with(
77+
{"path": "path_to_my_runner"}, dynamic_params={}
78+
)
6079

6180
def test_load_runner_from_yaml(self, mocker):
6281
fake_module = mocker.MagicMock(FakeRunner=FakeRunner)
@@ -66,7 +85,8 @@ def test_load_runner_from_yaml(self, mocker):
6685
"module": "mymodule.runner",
6786
"class": "FakeRunner",
6887
"params": {"collect_tests_response": ["list", "of", "labels"]},
69-
}
88+
},
89+
{},
7090
)
7191
assert isinstance(res, FakeRunner)
7292
assert res.collect_tests() == ["list", "of", "labels"]
@@ -83,7 +103,8 @@ def side_effect(*args, **kwargs):
83103
"module": "mymodule.runner",
84104
"class": "FakeRunner",
85105
"params": {"collect_tests_response": ["list", "of", "labels"]},
86-
}
106+
},
107+
{},
87108
)
88109

89110
def test_load_runner_from_yaml_class_not_found(self, mocker):
@@ -97,7 +118,8 @@ def test_load_runner_from_yaml_class_not_found(self, mocker):
97118
"module": "mymodule.runner",
98119
"class": "WrongClassName",
99120
"params": {"collect_tests_response": ["list", "of", "labels"]},
100-
}
121+
},
122+
{},
101123
)
102124

103125
def test_load_runner_from_yaml_fail_instantiate_class(self, mocker):
@@ -109,5 +131,6 @@ def test_load_runner_from_yaml_fail_instantiate_class(self, mocker):
109131
"module": "mymodule.runner",
110132
"class": "FakeRunner",
111133
"params": {"wrong_params": ["list", "of", "labels"]},
112-
}
134+
},
135+
{},
113136
)

0 commit comments

Comments
 (0)