-
Notifications
You must be signed in to change notification settings - Fork 7
Expand file tree
/
Copy pathbase.py
More file actions
244 lines (186 loc) · 8.15 KB
/
base.py
File metadata and controls
244 lines (186 loc) · 8.15 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
# SPDX-FileCopyrightText: 2022 German Aerospace Center (DLR)
#
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileContributor: Michael Meinel
import abc
import argparse
import logging
import pathlib
from importlib import metadata
from typing import Dict, Optional, Type
import toml
from pydantic import BaseModel
from pydantic_settings import BaseSettings, SettingsConfigDict
class _HermesSettings(BaseSettings):
"""Root class for HERMES configuration model."""
model_config = SettingsConfigDict(env_file_encoding='utf-8')
logging: Dict = {}
class HermesCommand(abc.ABC):
"""Base class for a HERMES workflow command.
:cvar NAME: The name of the sub-command that is defined here.
"""
command_name: str = ""
settings_class: Type = _HermesSettings
def __init__(self, parser: argparse.ArgumentParser):
"""Initialize a new instance of any HERMES command.
:param parser: The command line parser used for reading command line arguments.
"""
self.parser = parser
self.plugins = self.init_plugins()
self.settings = None
self.log = logging.getLogger(f"hermes.{self.command_name}")
self.errors = []
@classmethod
def init_plugins(cls):
"""Collect and initialize the plugins available for the HERMES command."""
# Collect all entry points for this group (i.e., all valid plug-ins for the step)
entry_point_group = f"hermes.{cls.command_name}"
group_plugins = {
entry_point.name: entry_point.load()
for entry_point in metadata.entry_points(group=entry_point_group)
}
# Collect the plug-in specific configurations
cls.derive_settings_class({
plugin_name: plugin_class.settings_class
for plugin_name, plugin_class in group_plugins.items()
if hasattr(plugin_class, "settings_class") and plugin_class.settings_class is not None
})
return group_plugins
@classmethod
def derive_settings_class(cls, setting_types: Dict[str, Type]) -> None:
"""Build a new Pydantic data model class for configuration.
This will create a new class that includes all settings from the plugins available.
"""
if cls.settings_class is not None:
# Derive a new settings model class that contains all the plug-in extensions
cls.settings_class = type(
f"{cls.__name__}Settings",
(cls.settings_class, ),
{
**{
plugin_name: plugin_settings()
for plugin_name, plugin_settings in setting_types.items()
if plugin_settings is not None
},
"__annotations__": setting_types,
},
)
elif setting_types:
raise ValueError(f"Command {cls.command_name} has no settings, hence plugin must not have settings, too.")
def init_common_parser(self, parser: argparse.ArgumentParser) -> None:
"""Initialize the common command line arguments available for all HERMES sub-commands.
:param parser: The base command line parser used as entry point when reading command line arguments.
"""
parser.add_argument(
"--path", default=pathlib.Path(), type=pathlib.Path, help="Working path"
)
parser.add_argument(
"--config",
default=pathlib.Path("hermes.toml"),
type=pathlib.Path,
help="Configuration file in TOML format",
)
# Add a new argument to accept a URL for harvesting (in harvest command)
parser.add_argument(
"--url",
type=str,
help="URL from which to extract metadata (GitHub or GitLab))"
)
# Add a new argument to accept a token (from GitHub or GitLab) for harvesting (in harvest command)
parser.add_argument(
"--token",
type=str,
required=False,
help="Access token for GitHub/GitLab (optional, only needed for private repos or GitHub/GitLab API plugin)"
)
plugin_args = parser.add_argument_group("Extra options")
plugin_args.add_argument(
"-O",
nargs=2,
action="append",
default=[],
metavar=("NAME", "VALUE"),
dest="options",
help="Configuration values to override hermes.toml options. "
"NAME is the dotted name / path to the option in the TOML file, "
"VALUE is the actual value.",
)
def init_command_parser(self, command_parser: argparse.ArgumentParser) -> None:
"""Initialize the command line arguments available for this specific HERMES sub-commands.
You should override this method to add your custom arguments to the command line parser of
the respective sub-command.
:param command_parser: The command line sub-parser responsible for the HERMES sub-command.
"""
pass
def load_settings(self, args: argparse.Namespace):
"""Load settings from the configuration file (passed in from command line)."""
try:
toml_data = toml.load(args.path / args.config)
self.root_settings = HermesCommand.settings_class.model_validate(toml_data)
self.settings = getattr(self.root_settings, self.command_name)
except FileNotFoundError as e:
self.log.error("hermes.toml was not found. Try to run 'hermes init' first or create one manually.")
raise e # This will lead to our default error message & sys.exit
def patch_settings(self, args: argparse.Namespace):
"""Process command line options for the settings."""
for key, value in args.options:
target = self.settings
sub_keys = key.split('.')
for sub_key in sub_keys[:-1]:
target = getattr(target, sub_key)
# TODO: Transform the value accordingly before setting it
setattr(target, sub_keys[-1], value)
@abc.abstractmethod
def __call__(self, args: argparse.Namespace):
"""Execute the HERMES sub-command.
:param args: The namespace that was returned by the command line parser when reading the arguments.
"""
pass
class HermesPlugin(abc.ABC):
"""Base class for all HERMES plugins.
Objects of this class are callables.
"""
settings_class: Optional[Type] = None
@abc.abstractmethod
def __call__(self, command: HermesCommand) -> None:
"""Execute the plugin.
:param command: The command that triggered this plugin to run.
"""
pass
class HermesHelpSettings(BaseModel):
pass
class HermesHelpCommand(HermesCommand):
"""Show help page and exit."""
command_name = "help"
settings_class = HermesHelpSettings
def init_command_parser(self, command_parser: argparse.ArgumentParser) -> None:
command_parser.add_argument(
"subcommand",
nargs="?",
metavar="COMMAND",
help="The HERMES sub-command to get help for.",
)
def __call__(self, args: argparse.Namespace) -> None:
if args.subcommand:
# When a sub-command is given, show its help page (i.e., by "running" the command with "-h" flag).
self.parser.parse_args([args.subcommand, "-h"])
else:
# Otherwise, simply show the general help and exit (cleanly).
self.parser.print_help()
self.parser.exit()
def load_settings(self, args: argparse.Namespace):
"""No settings are needed for the help command."""
pass
class HermesVersionSettings(BaseModel):
"""Intentionally empty settings class for the version command."""
pass
class HermesVersionCommand(HermesCommand):
"""Show HERMES version and exit."""
command_name = "version"
settings_class = HermesVersionSettings
def load_settings(self, args: argparse.Namespace):
"""Pass loading settings as not necessary for this command."""
pass
def __call__(self, args: argparse.Namespace) -> None:
self.log.info(metadata.version("hermes"))
self.parser.exit()