Skip to content

Commit 857ea3f

Browse files
authored
Add central interface for defining feature flags (#640)
The intended use for colcon feature flags is to ship pre-production and prototype features in a disabled state, which can be enabled by specifying a particular environment variable value. By using an environment variable, these possibly dangerous or unstable features are hidden from common users but are enabled in a way which can be audited.
1 parent 37dc1dd commit 857ea3f

File tree

4 files changed

+185
-0
lines changed

4 files changed

+185
-0
lines changed

colcon_core/command.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
from colcon_core.argument_parser import decorate_argument_parser # noqa: E402 E501 I100 I202
5353
from colcon_core.argument_parser import SuppressUsageOutput # noqa: E402
5454
from colcon_core.extension_point import load_extension_points # noqa: E402
55+
from colcon_core.feature_flags import check_implemented_flags # noqa: E402
5556
from colcon_core.location import create_log_path # noqa: E402
5657
from colcon_core.location import get_log_path # noqa: E402
5758
from colcon_core.location import set_default_config_path # noqa: E402
@@ -140,6 +141,9 @@ def _main(
140141
'Command line arguments: {argv}'
141142
.format(argv=argv if argv is not None else sys.argv))
142143

144+
# warn about any specified feature flags that are already implemented
145+
check_implemented_flags()
146+
143147
# set default locations for config files, for searchability: COLCON_HOME
144148
set_default_config_path(
145149
path=(Path('~') / f'.{command_name}').expanduser(),

colcon_core/feature_flags.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
# Copyright 2024 Open Source Robotics Foundation, Inc.
2+
# Licensed under the Apache License, Version 2.0
3+
4+
import os
5+
6+
from colcon_core.environment_variable import EnvironmentVariable
7+
from colcon_core.logging import colcon_logger
8+
9+
logger = colcon_logger.getChild(__name__)
10+
11+
"""Environment variable to enable feature flags"""
12+
FEATURE_FLAGS_ENVIRONMENT_VARIABLE = EnvironmentVariable(
13+
'COLCON_FEATURE_FLAGS',
14+
'Enable pre-production features and behaviors')
15+
16+
_REPORTED_USES = set()
17+
18+
IMPLEMENTED_FLAGS = set()
19+
20+
21+
def check_implemented_flags():
22+
"""Check for and warn about flags which have been implemented."""
23+
implemented = IMPLEMENTED_FLAGS.intersection(get_feature_flags())
24+
if implemented:
25+
logger.warning(
26+
'The following feature flags have been implemented and should no '
27+
'longer be specified in '
28+
f'{FEATURE_FLAGS_ENVIRONMENT_VARIABLE.name}: '
29+
f"{','.join(implemented)}")
30+
31+
32+
def get_feature_flags():
33+
"""
34+
Retrieve all enabled feature flags.
35+
36+
:returns: List of enabled flags
37+
:rtype: list
38+
"""
39+
return [
40+
flag for flag in (
41+
os.environ.get(FEATURE_FLAGS_ENVIRONMENT_VARIABLE.name) or ''
42+
).split(os.pathsep) if flag
43+
]
44+
45+
46+
def is_feature_flag_set(flag):
47+
"""
48+
Determine if a specific feature flag is enabled.
49+
50+
Feature flags are case-sensitive and separated by the os-specific path
51+
separator character.
52+
53+
:param str flag: Name of the flag to search for
54+
55+
:returns: True if the flag is set
56+
:rtype: bool
57+
"""
58+
if flag in IMPLEMENTED_FLAGS:
59+
return True
60+
elif flag in get_feature_flags():
61+
if flag not in _REPORTED_USES:
62+
if not _REPORTED_USES:
63+
logger.warning(
64+
'One or more feature flags have been enabled using the '
65+
f'{FEATURE_FLAGS_ENVIRONMENT_VARIABLE.name} environment '
66+
'variable. These features may be unstable and may change '
67+
'API or behavior at any time.')
68+
logger.warning(f'Enabling feature: {flag}')
69+
_REPORTED_USES.add(flag)
70+
return True
71+
return False

test/spell_check.words

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
addfinalizer
12
addopts
23
apache
34
argparse
@@ -35,8 +36,10 @@ docstring
3536
executables
3637
exitstatus
3738
fdopen
39+
ffoo
3840
filterwarnings
3941
foobar
42+
fooo
4043
fromhex
4144
functools
4245
getcategory
@@ -140,5 +143,6 @@ unittest
140143
unittests
141144
unlinking
142145
unrenamed
146+
usefixtures
143147
wildcards
144148
workaround

test/test_feature_flags.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
# Copyright 2024 Open Source Robotics Foundation, Inc.
2+
# Licensed under the Apache License, Version 2.0
3+
4+
import os
5+
from unittest.mock import patch
6+
7+
from colcon_core.feature_flags import check_implemented_flags
8+
from colcon_core.feature_flags import FEATURE_FLAGS_ENVIRONMENT_VARIABLE
9+
from colcon_core.feature_flags import get_feature_flags
10+
from colcon_core.feature_flags import is_feature_flag_set
11+
import pytest
12+
13+
14+
_FLAGS_TO_TEST = (
15+
('foo',),
16+
('foo', 'foo'),
17+
('foo', ''),
18+
('', 'foo'),
19+
('', 'foo', ''),
20+
('foo', 'bar'),
21+
('bar', 'foo'),
22+
('bar', 'foo', 'baz'),
23+
)
24+
25+
26+
@pytest.fixture
27+
def feature_flags_value(request):
28+
env = dict(os.environ)
29+
if request.param is not None:
30+
env[FEATURE_FLAGS_ENVIRONMENT_VARIABLE.name] = os.pathsep.join(
31+
request.param)
32+
else:
33+
env.pop(FEATURE_FLAGS_ENVIRONMENT_VARIABLE.name, None)
34+
35+
mock_env = patch('colcon_core.feature_flags.os.environ', env)
36+
request.addfinalizer(mock_env.stop)
37+
mock_env.start()
38+
return request.param
39+
40+
41+
@pytest.fixture
42+
def feature_flag_reports(request):
43+
reported_uses = patch('colcon_core.feature_flags._REPORTED_USES', set())
44+
request.addfinalizer(reported_uses.stop)
45+
reported_uses.start()
46+
return reported_uses
47+
48+
49+
@pytest.mark.parametrize(
50+
'feature_flags_value',
51+
_FLAGS_TO_TEST,
52+
indirect=True)
53+
@pytest.mark.usefixtures('feature_flags_value', 'feature_flag_reports')
54+
def test_flag_is_set():
55+
with patch('colcon_core.feature_flags.logger.warning') as warn:
56+
assert is_feature_flag_set('foo')
57+
assert warn.call_count == 2
58+
assert is_feature_flag_set('foo')
59+
assert warn.call_count == 2
60+
61+
62+
@pytest.mark.parametrize(
63+
'feature_flags_value',
64+
(None, *_FLAGS_TO_TEST),
65+
indirect=True)
66+
@pytest.mark.usefixtures('feature_flags_value', 'feature_flag_reports')
67+
def test_flag_not_set():
68+
with patch('colcon_core.feature_flags.logger.warning') as warn:
69+
assert not is_feature_flag_set('')
70+
assert not is_feature_flag_set('fo')
71+
assert not is_feature_flag_set('oo')
72+
assert not is_feature_flag_set('fooo')
73+
assert not is_feature_flag_set('ffoo')
74+
assert not is_feature_flag_set('qux')
75+
assert warn.call_count == 0
76+
77+
78+
@pytest.mark.parametrize(
79+
'feature_flags_value',
80+
(None, *_FLAGS_TO_TEST),
81+
indirect=True)
82+
@pytest.mark.usefixtures('feature_flags_value')
83+
def test_get_flags(feature_flags_value):
84+
assert [
85+
flag for flag in (feature_flags_value or ()) if flag
86+
] == get_feature_flags()
87+
88+
89+
@pytest.mark.parametrize('feature_flags_value', (('baz',),), indirect=True)
90+
@pytest.mark.usefixtures('feature_flags_value')
91+
def test_implemented():
92+
with patch('colcon_core.feature_flags.IMPLEMENTED_FLAGS', {'foo'}):
93+
with patch('colcon_core.feature_flags.logger.warning') as warn:
94+
assert not is_feature_flag_set('bar')
95+
assert warn.call_count == 0
96+
assert is_feature_flag_set('baz')
97+
assert warn.call_count == 2
98+
assert is_feature_flag_set('foo')
99+
assert warn.call_count == 2
100+
check_implemented_flags()
101+
assert warn.call_count == 2
102+
103+
with patch('colcon_core.feature_flags.IMPLEMENTED_FLAGS', {'baz'}):
104+
with patch('colcon_core.feature_flags.logger.warning') as warn:
105+
check_implemented_flags()
106+
assert warn.call_count == 1

0 commit comments

Comments
 (0)