Skip to content

Commit 0adf688

Browse files
committed
Define a set of named features and logic to manage their dependencies.
The codebase has a number of features that, when selected, require certain dependencies. Thus far the handling for this hsa been split between this repository and the docker-migrid, which encodes a large amount of version information for the various features and in addition allows overriding these at build time. There is a desire to see these become per-feature requirements files expresed in this repository, but then to also retain the ability to override the package versions at the time of build. This commit introduces work that will allow that. The features are explicitly defined by name in a ini file that describes them. The added features tool reads this file and, after consulting a source for what is enabled (this can be the environment but also a .env file, as used within the docker builds, directly) will output the lines needed to install the packages. Any complexity here stems from the requirement to be able to override certain packages. Since a requirements file can easily contain more than one package, we must be able to override one of the set of specified dependencies.
1 parent 3b9f749 commit 0adf688

12 files changed

Lines changed: 572 additions & 0 deletions

FEATURES.ini

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
[CLOUD]
2+
3+
[MIGUX]
4+
default_on = True
5+
has_postinstall = True
6+
feature_url = http://foo.bar/baz

local-requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,5 @@ autopep8;python_version >= "3"
77
# NOTE: paramiko-3.0.0 dropped python2 and python3.6 support
88
paramiko;python_version >= "3.7"
99
paramiko<3;python_version < "3.7"
10+
python-dotenv
1011
werkzeug

mig/install/features.py

Lines changed: 336 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,336 @@
1+
#!/usr/bin/env python3
2+
3+
import argparse
4+
from collections import defaultdict
5+
from configparser import ConfigParser
6+
from enum import Enum
7+
import os
8+
import pip
9+
import sys
10+
from types import SimpleNamespace
11+
from pip._internal.req.req_file import parse_requirements
12+
13+
14+
_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
15+
_LOCAL_MIG_BASE = os.path.normpath(os.path.join(_SCRIPT_DIR, '../..'))
16+
17+
sys.path.append(_LOCAL_MIG_BASE)
18+
19+
FEATURES_FILE = os.path.join(_LOCAL_MIG_BASE, 'FEATURES.ini')
20+
FEATURES_REQUIREMENTS_DIR = os.path.join(_LOCAL_MIG_BASE, 'mig/install/requirements')
21+
PIP_OVERRIDES = {
22+
'CLOUD': {
23+
'openstacksdk': 'OPENSTACKSDK_VERSION_OVERRIDE',
24+
},
25+
'MIGUX': {
26+
'migux': 'MIGUX_VERSION_OVERRIDE',
27+
},
28+
}
29+
_VERSIONCHARS = ('=', '<', '>')
30+
_TRUTH_STRINGS = set(('True', 'true', 'yes', '1'))
31+
32+
33+
def warn(msg=''):
34+
print(msg, file=sys.stderr)
35+
36+
37+
class Features:
38+
"""
39+
Instances of this object represent a set of named features and their state.
40+
"""
41+
42+
def __init__(self, interpretation_by_feature_name, overrides_supported):
43+
self.feature_names = sorted(interpretation_by_feature_name.keys())
44+
self._enabled_by_feature = {}
45+
self._requirements_by_feature = {}
46+
self._requirements_file_by_feature = {}
47+
self._overrides_by_feature = {}
48+
self._overrides_supported = overrides_supported
49+
50+
for feature_name, interpretation in interpretation_by_feature_name.items():
51+
self._enabled_by_feature[feature_name] = interpretation.enabled
52+
self._requirements_by_feature[feature_name] = interpretation.requirements
53+
self._requirements_file_by_feature[feature_name] = interpretation.requirements_file
54+
55+
def apply_enabled(self, enabled_by_feature_name):
56+
"""
57+
Update the enabled state of features.
58+
"""
59+
60+
feature_keys = set(self.feature_names)
61+
present_keys = set(enabled_by_feature_name.keys())
62+
63+
missing_feature_keys = feature_keys - present_keys
64+
if missing_feature_keys:
65+
raise RuntimeError("supplied feature state incomplete")
66+
67+
self._enabled_by_feature = enabled_by_feature_name
68+
69+
def apply_overrides(self, overrides_by_feature_name):
70+
"""
71+
Update the overrides associated with features.
72+
"""
73+
74+
self._overrides_by_feature = overrides_by_feature_name
75+
76+
def feature_is_enabled(self, feature_name):
77+
"""
78+
Check if a named feature is enabled.
79+
"""
80+
81+
return self._enabled_by_feature[feature_name]
82+
83+
def generate_pip_args(self):
84+
"""
85+
Create pip arguments for each enabled feature.
86+
"""
87+
88+
per_package_args = []
89+
90+
for feature_name in self.list_enabled_features():
91+
per_package_args.append(self.generate_pip_args_for_feature(feature_name))
92+
93+
return per_package_args
94+
95+
def generate_pip_args_for_feature(self, feature_name):
96+
"""
97+
Create pip arguments for a particular feature.
98+
"""
99+
100+
overrides = self._overrides_by_feature.get(feature_name, None)
101+
102+
if not overrides:
103+
# no overiddes detected therefore we can install
104+
# by simply using the requirements file as-is
105+
return ['-r', self._requirements_file_by_feature[feature_name]]
106+
107+
package_args = []
108+
overridden_package_names = set(overrides.keys())
109+
110+
# add the overridden packages
111+
for package_name in overridden_package_names:
112+
package_args.append(f"{package_name}=={overrides[package_name]}")
113+
114+
# add the remaining packages based on the requirements file
115+
for entry in self._requirements_by_feature[feature_name]:
116+
package_name = Features._strip_version_if_present(entry.requirement)
117+
if package_name in overridden_package_names:
118+
continue
119+
package_args.append(entry.requirement)
120+
121+
return package_args
122+
123+
def list_enabled_features(self, return_as=list):
124+
"""
125+
Return the names of features recorded as enabled.
126+
"""
127+
128+
return return_as((feature_name for feature_name in self.feature_names
129+
if self._enabled_by_feature[feature_name]))
130+
131+
@staticmethod
132+
def _interpret_feature_definition(feature_name, feature_definition, requirements_dir):
133+
"""
134+
Convert a named feature section within the features file to a
135+
structured intepretation suitable for consumption by the logic.
136+
"""
137+
138+
enabled = feature_definition.getboolean('default_on', fallback=False)
139+
has_requirements = feature_definition.getboolean('has_requirements', fallback=True)
140+
141+
if has_requirements:
142+
requirements_file = os.path.join(requirements_dir, f"{feature_name.lower()}-requirements.txt")
143+
requirements = list(parse_requirements(requirements_file, session=None))
144+
else:
145+
requirements = []
146+
147+
return SimpleNamespace(
148+
enabled=enabled,
149+
requirements=requirements,
150+
requirements_file=requirements_file,
151+
)
152+
153+
@staticmethod
154+
def _strip_version_if_present(requirement):
155+
"""
156+
Return only the name of a package given a requirement specifier.
157+
"""
158+
159+
for char in _VERSIONCHARS:
160+
index = requirement.find(char)
161+
if index == -1:
162+
continue
163+
return requirement[:index]
164+
return requirement
165+
166+
@staticmethod
167+
def expand_definitions(definitions, requirements_dir):
168+
"""
169+
Generate a dictionary of features names and a structured interpretation
170+
based on their definition in the main features file.
171+
"""
172+
173+
definitions_iterator = iter(definitions.items())
174+
next(definitions_iterator) # skip default section
175+
176+
return {feature_name: Features._interpret_feature_definition(feature_name,
177+
feature_definition,
178+
requirements_dir)
179+
for feature_name, feature_definition in definitions_iterator}
180+
181+
@classmethod
182+
def from_definitions_file(cls, features_file, requirements_dir, overrides_supported={}):
183+
assert os.path.isabs(features_file)
184+
with open(features_file) as thefile:
185+
definitions = ConfigParser()
186+
definitions.read_file(thefile)
187+
return cls(Features.expand_definitions(definitions, requirements_dir), overrides_supported)
188+
189+
@staticmethod
190+
def match_env_dict(features, env_dict):
191+
def enabled_or_fallback(feature_name):
192+
try:
193+
enable_string = env_dict[f"ENABLE_{feature_name.upper()}"]
194+
return enable_string in _TRUTH_STRINGS
195+
except KeyError:
196+
return features.feature_is_enabled(feature_name)
197+
198+
enabled_by_feature_name = {}
199+
overrides_by_feature_name = defaultdict(dict)
200+
201+
for feature_name in features.feature_names:
202+
enabled_by_feature_name[feature_name] = enabled_or_fallback(feature_name)
203+
204+
env_override_flags = features._overrides_supported.get(feature_name, None)
205+
if not env_override_flags:
206+
continue
207+
208+
for package_name, flag_name in env_override_flags.items():
209+
override_version = env_dict.get(flag_name, None)
210+
if not override_version:
211+
continue
212+
overrides_by_feature_name[feature_name][package_name] = override_version
213+
214+
return enabled_by_feature_name, overrides_by_feature_name
215+
216+
@staticmethod
217+
def match_dotenv_file(features, dotenv_file):
218+
from dotenv import dotenv_values
219+
220+
assert os.path.isabs(dotenv_file)
221+
dotenv_dict = dotenv_values(dotenv_file)
222+
223+
return Features.match_env_dict(features, dotenv_dict)
224+
225+
@staticmethod
226+
def match_configuration_file(features, configuration_file):
227+
from mig.shared.conf import get_configuration_object
228+
configuration = get_configuration_object(configuration_file, skip_log=True, disable_auth_log=True)
229+
230+
def enabled_or_fallback(feature_name):
231+
try:
232+
return getattr(configuration, f"site_enable_{feature_name.lower()}")
233+
except AttributeError:
234+
return features.feature_is_enabled(feature_name)
235+
236+
enabled_by_feature_name = {feature_name: enabled_or_fallback(feature_name)
237+
for feature_name in features.feature_names}
238+
return enabled_by_feature_name, {}
239+
240+
241+
def main_enabled(features, args, print=print, warn=warn):
242+
if args.c:
243+
enabled_by_feature_name = Features.match_configuration_file(features, args.c)
244+
features.apply_enabled(enabled_by_feature_name)
245+
elif args.dotenv:
246+
enabled_by_feature_name, _ = Features.match_dotenv_file(features, args.dotenv)
247+
features.apply_enabled(enabled_by_feature_name)
248+
elif args.env:
249+
enabled_by_feature_name, overrides_by_feature_name = Features.match_env_dict(features, args.env)
250+
features.apply_enabled(enabled_by_feature_name)
251+
else:
252+
warn("no feature coniguration available; showing those enabled by default only")
253+
print(f"enabled features: {', '.join(features.list_enabled_features())}")
254+
255+
return 0
256+
257+
258+
def main_install(features, args, print=print, warn=warn):
259+
if args.c:
260+
enabled_by_feature_name = Features.match_configuration_file(features, args.c)
261+
features.apply_enabled(enabled_by_feature_name)
262+
elif args.dotenv:
263+
enabled_by_feature_name, overrides_by_feature_name = Features.match_dotenv_file(features, args.dotenv)
264+
features.apply_enabled(enabled_by_feature_name)
265+
elif args.env:
266+
enabled_by_feature_name, overrides_by_feature_name = Features.match_env_dict(features, args.env)
267+
features.apply_enabled(enabled_by_feature_name)
268+
features.apply_overrides(overrides_by_feature_name)
269+
else:
270+
warn("no feature coniguration available; showing those enabled by default only")
271+
warn()
272+
273+
all_pip_args = features.generate_pip_args()
274+
275+
if args.check:
276+
for pip_args in all_pip_args:
277+
print(f"pip install {' '.join(pip_args)}")
278+
return
279+
280+
raise NotImplementedError("install is not currently implemented")
281+
282+
283+
def main_show(features, args, print=print, warn=warn):
284+
print(f"available features: {', '.join(features.feature_names)}")
285+
286+
287+
_COMMAND_HANDLERS = dict(
288+
enabled=main_enabled,
289+
install=main_install,
290+
show=main_show,
291+
)
292+
293+
294+
def main(argv):
295+
parser = argparse.ArgumentParser()
296+
subparsers = parser.add_subparsers(dest='command')
297+
298+
show_command = subparsers.add_parser('show')
299+
300+
enabled_command = subparsers.add_parser('enabled')
301+
enabled_command.add_argument('-c', default=None)
302+
enabled_command.add_argument('--dotenv', default=None, type=os.path.abspath)
303+
enabled_command.add_argument('--env', action='store_const', const=os.environ)
304+
305+
install_command = subparsers.add_parser('install')
306+
install_command.add_argument('-c', default=None)
307+
install_command.add_argument('--check', action='store_true', default=False)
308+
install_command.add_argument('--dotenv', default=None, type=os.path.abspath)
309+
install_command.add_argument('--env', action='store_const', const=os.environ)
310+
311+
args = parser.parse_args(args=argv)
312+
313+
if not args.command:
314+
parser.print_usage()
315+
return 0
316+
317+
return args_main(parser.parse_args(args=argv))
318+
319+
def args_main(args, *, print=print, warn=warn, features=None):
320+
features = features or Features.from_definitions_file(
321+
FEATURES_FILE,
322+
FEATURES_REQUIREMENTS_DIR,
323+
PIP_OVERRIDES,
324+
)
325+
326+
command_handler = _COMMAND_HANDLERS[args.command]
327+
try:
328+
command_handler(features, args, print=print, warn=warn)
329+
return 0
330+
except Exception as exc:
331+
warn(exc)
332+
return 1
333+
334+
335+
if __name__ == '__main__':
336+
sys.exit(main(sys.argv[1:]))
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
openstacksdk==4.5.6
2+
some_other_thing
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
migux

requirements-feature-migux.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
migux
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
ENABLE_FOO = True
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
[FOO]
2+
3+
[BAR]
4+
5+
[BAZ]
6+
default_on = True

tests/data/features/basic/requirements/bar-requirements.txt

Whitespace-only changes.

tests/data/features/basic/requirements/baz-requirements.txt

Whitespace-only changes.

0 commit comments

Comments
 (0)