Skip to content

Commit 4282f02

Browse files
committed
feat: Profiles can now be enabled or disabled, also with a condition. Profiles can now also be selected with a wildcard pattern.
1 parent c008005 commit 4282f02

File tree

13 files changed

+516
-23
lines changed

13 files changed

+516
-23
lines changed

Diff for: etc/robot.toml.json

+33-1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,22 @@
66
}
77
],
88
"definitions": {
9+
"Condition": {
10+
"additionalProperties": false,
11+
"description": "Condition(if_: 'str')",
12+
"properties": {
13+
"if": {
14+
"description": "Condition to evaluate. This must be a Python \"eval\" expression.\nFor security reasons, only certain expressions and functions are allowed.\n\nExamples:\n```toml\nif = \"re.match(r'^\\d+$', environ.get('TEST_VAR', ''))\"\nif = \"platform.system() == 'Linux'\"\n```\n",
15+
"title": "If ",
16+
"type": "string"
17+
}
18+
},
19+
"required": [
20+
"if"
21+
],
22+
"title": "Condition",
23+
"type": "object"
24+
},
925
"LibDocProfile": {
1026
"additionalProperties": false,
1127
"description": "LibDocProfile(extra_python_path: 'Optional[List[str]]' = None, doc_format: \"Optional[Literal['ROBOT', 'HTML', 'TEXT', 'REST']]\" = None, format: \"Optional[Literal['HTML', 'XML', 'JSON', 'LIBSPEC']]\" = None, name: 'Optional[str]' = None, python_path: 'Optional[List[str]]' = None, quiet: 'Union[bool, Flag, None]' = None, spec_doc_format: \"Optional[Literal['RAW', 'HTML']]\" = None, theme: \"Optional[Literal['DARK', 'LIGHT', 'NONE']]\" = None)",
@@ -2267,7 +2283,7 @@
22672283
},
22682284
"detached": {
22692285
"default": null,
2270-
"description": "If the profile should be detached.\"\nDetached means it is not inherited from the main profile.\n",
2286+
"description": "The profile should be detached.\"\nDetached means it is not inherited from the main profile.\n",
22712287
"title": "Detached",
22722288
"type": [
22732289
"boolean",
@@ -2325,6 +2341,22 @@
23252341
"description": "Verifies test data and runs tests so that library\nkeywords are not executed.\n\n---\ncorresponds to the `--dryrun` option of _robot_\n",
23262342
"title": "Dry run"
23272343
},
2344+
"enabled": {
2345+
"anyOf": [
2346+
{
2347+
"type": "boolean"
2348+
},
2349+
{
2350+
"$ref": "#/definitions/Condition"
2351+
},
2352+
{
2353+
"type": "null"
2354+
}
2355+
],
2356+
"default": null,
2357+
"description": "If enabled the profile is used. You can also use and `if` condition\nto calculate the enabled state.\n\nExamples:\n```toml\n# alway disabled\nenabled = false\n```\n\n```toml\n# enabled if TEST_VAR is set\nenabled = { if = 'env.get(\"CI\") == \"true\"' }\n```\n",
2358+
"title": "Enabled"
2359+
},
23282360
"env": {
23292361
"additionalProperties": {
23302362
"type": "string"

Diff for: packages/core/robotcode/core/utils/glob_path.py

+7-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import functools
34
import os
45
import re
56
from pathlib import Path
@@ -61,10 +62,15 @@ def _glob_pattern_to_re(pattern: str) -> str:
6162
return result
6263

6364

65+
@functools.lru_cache(maxsize=256)
66+
def _compile_glob_pattern(pattern: str) -> re.Pattern[str]:
67+
return re.compile(_glob_pattern_to_re(pattern))
68+
69+
6470
class Pattern:
6571
def __init__(self, pattern: str) -> None:
6672
self.pattern = pattern
67-
self._re_pattern = re.compile(_glob_pattern_to_re(pattern))
73+
self._re_pattern = _compile_glob_pattern(pattern)
6874

6975
def matches(self, path: Union[Path, str, os.PathLike[Any]]) -> bool:
7076
if not isinstance(path, Path):

Diff for: packages/core/robotcode/core/utils/safe_eval.py

+62
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import ast
2+
from typing import Any, Dict, Optional, Sequence
3+
4+
5+
class Transformer(ast.NodeTransformer):
6+
STD_ALLOWED_NAMES = (
7+
"None",
8+
"False",
9+
"True",
10+
"str",
11+
"bool",
12+
"int",
13+
"float",
14+
"list",
15+
"dict",
16+
"tuple",
17+
"bytes",
18+
"complex",
19+
"set",
20+
"bin",
21+
"pow",
22+
"min",
23+
"max",
24+
"sum",
25+
"ord",
26+
"hex",
27+
"oct",
28+
"round",
29+
"sorted",
30+
"reversed",
31+
"zip",
32+
"divmod",
33+
"range",
34+
"reprs",
35+
"enumerate",
36+
"all",
37+
"any",
38+
"filter",
39+
"abs",
40+
"map",
41+
"chr",
42+
"format",
43+
"len",
44+
"ascii",
45+
)
46+
47+
def __init__(self, allowed_names: Optional[Sequence[str]]) -> None:
48+
self.allowed_names = (*self.STD_ALLOWED_NAMES, *(allowed_names or []))
49+
50+
def visit_Name(self, node: ast.Name) -> ast.AST: # noqa: N802
51+
if node.id not in self.allowed_names:
52+
raise NameError(f"Name access to '{node.id}' is not allowed")
53+
54+
return self.generic_visit(node)
55+
56+
57+
def safe_eval(source: str, globals: Dict[str, Any] = {}, filename: str = "<expression>") -> Any:
58+
transformer = Transformer(list(globals.keys()))
59+
tree = ast.parse(source, mode="eval")
60+
tree = transformer.visit(tree)
61+
clause = compile(tree, filename, "eval", dont_inherit=True)
62+
return eval(clause, globals, {})

Diff for: packages/robot/robotcode/robot/config/model.py

+101-14
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
from __future__ import annotations
22

33
import dataclasses
4+
import fnmatch
5+
import os
6+
import platform
7+
import re
48
from dataclasses import dataclass
59
from enum import Enum
610
from typing import Any, Callable, Dict, List, Literal, Optional, Tuple, Union, get_type_hints
711

812
from robotcode.core.dataclasses import TypeValidationError, ValidateMixin, validate_types
13+
from robotcode.core.utils.safe_eval import safe_eval
914

1015

1116
class Flag(str, Enum):
@@ -31,7 +36,9 @@ def field(
3136
robot_is_flag: Optional[bool] = None,
3237
robot_flag_default: Optional[bool] = None,
3338
robot_priority: Optional[int] = None,
39+
alias: Optional[str] = None,
3440
convert: Optional[Callable[[Any, Any], Any]] = None,
41+
no_default: bool = False,
3542
**kwargs: Any,
3643
) -> Any:
3744
metadata = kwargs.get("metadata", {})
@@ -56,17 +63,29 @@ def field(
5663
if robot_priority is not None:
5764
metadata["robot_priority"] = robot_priority
5865

66+
if alias is not None:
67+
metadata["alias"] = alias
68+
metadata["_apischema_alias"] = alias
69+
5970
if metadata:
6071
kwargs["metadata"] = metadata
6172

62-
if "default_factory" not in kwargs:
73+
if "default_factory" not in kwargs and not no_default:
6374
kwargs["default"] = None
6475

6576
return dataclasses.field(*args, **kwargs)
6677

6778

6879
@dataclass
6980
class BaseOptions(ValidateMixin):
81+
@classmethod
82+
def _encode_case(cls, s: str) -> str:
83+
return s.replace("_", "-")
84+
85+
@classmethod
86+
def _decode_case(cls, s: str) -> str:
87+
return s.replace("-", "_")
88+
7089
"""Base class for all options."""
7190

7291
def build_command_line(self) -> List[str]:
@@ -1907,14 +1926,6 @@ class LibDocProfile(LibDocOptions, LibDocExtraOptions):
19071926
class RobotBaseProfile(CommonOptions, CommonExtraOptions, RobotOptions, RobotExtraOptions):
19081927
"""Base profile for Robot Framework."""
19091928

1910-
@classmethod
1911-
def _encode_case(cls, s: str) -> str:
1912-
return s.replace("_", "-")
1913-
1914-
@classmethod
1915-
def _decode_case(cls, s: str) -> str:
1916-
return s.replace("-", "_")
1917-
19181929
args: Optional[List[str]] = field(
19191930
description="""\
19201931
Arguments to be passed to _robot_.
@@ -1994,18 +2005,69 @@ class RobotExtraBaseProfile(RobotBaseProfile):
19942005
)
19952006

19962007

2008+
class EvaluationError(Exception):
2009+
"""Evaluation error."""
2010+
2011+
def __init__(self, expression: str, message: str):
2012+
super().__init__(f"Evaluation of '{expression}' failed: {message}")
2013+
self.expr = expression
2014+
2015+
2016+
@dataclass
2017+
class Condition:
2018+
if_: str = field(
2019+
description="""\
2020+
Condition to evaluate. This must be a Python "eval" expression.
2021+
For security reasons, only certain expressions and functions are allowed.
2022+
2023+
Examples:
2024+
```toml
2025+
if = "re.match(r'^\\d+$', environ.get('TEST_VAR', ''))"
2026+
if = "platform.system() == 'Linux'"
2027+
```
2028+
""",
2029+
alias="if",
2030+
no_default=True,
2031+
)
2032+
2033+
def evaluate(self) -> bool:
2034+
try:
2035+
return bool(safe_eval(self.if_, {"env": os.environ, "re": re, "platform": platform}))
2036+
except Exception as e:
2037+
raise EvaluationError(self.if_, str(e)) from e
2038+
2039+
19972040
@dataclass
19982041
class RobotProfile(RobotExtraBaseProfile):
19992042
"""Robot Framework configuration profile."""
20002043

20012044
description: Optional[str] = field(description="Description of the profile.")
2045+
20022046
detached: Optional[bool] = field(
20032047
description="""\
2004-
If the profile should be detached."
2048+
The profile should be detached."
20052049
Detached means it is not inherited from the main profile.
20062050
""",
20072051
)
20082052

2053+
enabled: Union[bool, Condition, None] = field(
2054+
description="""\
2055+
If enabled the profile is used. You can also use and `if` condition
2056+
to calculate the enabled state.
2057+
2058+
Examples:
2059+
```toml
2060+
# alway disabled
2061+
enabled = false
2062+
```
2063+
2064+
```toml
2065+
# enabled if TEST_VAR is set
2066+
enabled = { if = 'env.get("CI") == "true"' }
2067+
```
2068+
"""
2069+
)
2070+
20092071

20102072
@dataclass
20112073
class RobotConfig(RobotExtraBaseProfile):
@@ -2036,7 +2098,7 @@ class RobotConfig(RobotExtraBaseProfile):
20362098
metadata={"description": "Tool configuration."},
20372099
)
20382100

2039-
def get_profile(self, *names: str, verbose_callback: Callable[..., None] = None) -> RobotBaseProfile:
2101+
def combine_profiles(self, *names: str, verbose_callback: Callable[..., None] = None) -> RobotBaseProfile:
20402102
type_hints = get_type_hints(RobotBaseProfile)
20412103
base_field_names = [f.name for f in dataclasses.fields(RobotBaseProfile)]
20422104

@@ -2061,12 +2123,37 @@ def get_profile(self, *names: str, verbose_callback: Callable[..., None] = None)
20612123

20622124
names = (*(default_profile or ()),)
20632125

2064-
for profile_name in names:
2065-
if profile_name not in profiles:
2066-
raise ValueError(f'Profile "{profile_name}" is not defined.')
2126+
selected_profiles: List[str] = []
2127+
2128+
for name in names:
2129+
profile_names = [p for p in profiles.keys() if fnmatch.fnmatchcase(p, name)]
2130+
2131+
if not profile_names:
2132+
raise ValueError(f"Can't find any profiles matching the pattern '{name}''.")
20672133

2134+
for v in profile_names:
2135+
if v in selected_profiles:
2136+
continue
2137+
selected_profiles.append(v)
2138+
2139+
if verbose_callback:
2140+
verbose_callback(f"Select profiles {', '.join(selected_profiles)}")
2141+
2142+
for profile_name in selected_profiles:
20682143
profile = profiles[profile_name]
20692144

2145+
try:
2146+
if profile.enabled is not None and (
2147+
isinstance(profile.enabled, Condition) and not profile.enabled.evaluate() or not profile.enabled
2148+
):
2149+
if verbose_callback:
2150+
verbose_callback(f'Skipping profile "{profile_name}" because it\'s disabled.')
2151+
continue
2152+
except EvaluationError as e:
2153+
if verbose_callback:
2154+
verbose_callback(f'Skipping profile "{profile_name}" because: {e}')
2155+
continue
2156+
20702157
if verbose_callback:
20712158
verbose_callback(f'Using profile "{profile_name}".')
20722159

Diff for: packages/runner/robotcode/runner/cli/libdoc.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ def libdoc(
8282
if config_files:
8383
app.verbose(lambda: f"Found configuration files:\n {', '.join(str(f[0]) for f in config_files)}")
8484
try:
85-
profile = load_config_from_path(*config_files).get_profile(
85+
profile = load_config_from_path(*config_files).combine_profiles(
8686
*app.config.profiles if app.config.profiles else [],
8787
verbose_callback=app.verbose if app.config.verbose else None,
8888
)

Diff for: packages/runner/robotcode/runner/cli/rebot.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ def rebot(
8383
if config_files:
8484
app.verbose(lambda: f"Found configuration files:\n {', '.join(str(f[0]) for f in config_files)}")
8585
try:
86-
profile = load_config_from_path(*config_files).get_profile(
86+
profile = load_config_from_path(*config_files).combine_profiles(
8787
*app.config.profiles if app.config.profiles else [],
8888
verbose_callback=app.verbose if app.config.verbose else None,
8989
)

Diff for: packages/runner/robotcode/runner/cli/robot.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ def robot(
9696
if config_files:
9797
app.verbose(lambda: f"Found configuration files:\n {', '.join(str(f[0]) for f in config_files)}")
9898
try:
99-
profile = load_config_from_path(*config_files).get_profile(
99+
profile = load_config_from_path(*config_files).combine_profiles(
100100
*app.config.profiles if app.config.profiles else [],
101101
verbose_callback=app.verbose if app.config.verbose else None,
102102
)

Diff for: robotcode/cli/commands/profiles.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,9 @@ def show(
3939
raise click.ClickException("Cannot find any configuration file. 😥")
4040

4141
try:
42-
profile = load_config_from_path(*config_files).get_profile(*(app.config.profiles or []))
42+
profile = load_config_from_path(*config_files).combine_profiles(
43+
*(app.config.profiles or []), verbose_callback=app.verbose
44+
)
4345

4446
app.print_dict(as_dict(profile, remove_defaults=True), format)
4547

Diff for: scripts/create_robot_toml_json_schema.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ def type_base_schema(tp: Any) -> Optional[apischema.schemas.Schema]:
1919
)
2020

2121
def field_base_schema(tp: Any, name: str, alias: str) -> Optional[apischema.schemas.Schema]:
22-
title = alias.replace("_", " ").capitalize()
22+
title = name.replace("_", " ").capitalize()
2323
tp = typing.get_origin(tp) or tp # tp can be generic
2424
if is_dataclass(tp):
2525
field = next((x for x in fields(tp) if x.name == name), None)

0 commit comments

Comments
 (0)