midpoint-cli 1.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. midpoint_cli/__init__.py +234 -0
  2. midpoint_cli/client/__init__.py +199 -0
  3. midpoint_cli/client/audit.py +178 -0
  4. midpoint_cli/client/audit_query.py +247 -0
  5. midpoint_cli/client/configuration.py +15 -0
  6. midpoint_cli/client/constants.py +37 -0
  7. midpoint_cli/client/errors.py +74 -0
  8. midpoint_cli/client/logging_config.py +122 -0
  9. midpoint_cli/client/midpoint_client.py +866 -0
  10. midpoint_cli/client/object_query.py +230 -0
  11. midpoint_cli/client/objects.py +814 -0
  12. midpoint_cli/client/observer.py +29 -0
  13. midpoint_cli/client/patch.py +113 -0
  14. midpoint_cli/client/progress.py +80 -0
  15. midpoint_cli/client/rest.py +490 -0
  16. midpoint_cli/client/safe_xml.py +24 -0
  17. midpoint_cli/client/server_info.py +130 -0
  18. midpoint_cli/client/server_version.py +74 -0
  19. midpoint_cli/client/session.py +142 -0
  20. midpoint_cli/client/task_definitions.py +110 -0
  21. midpoint_cli/prompt/__init__.py +196 -0
  22. midpoint_cli/prompt/audit.py +253 -0
  23. midpoint_cli/prompt/base.py +218 -0
  24. midpoint_cli/prompt/complete.py +291 -0
  25. midpoint_cli/prompt/configuration.py +258 -0
  26. midpoint_cli/prompt/connector.py +45 -0
  27. midpoint_cli/prompt/console.py +818 -0
  28. midpoint_cli/prompt/delete.py +89 -0
  29. midpoint_cli/prompt/get.py +66 -0
  30. midpoint_cli/prompt/info.py +53 -0
  31. midpoint_cli/prompt/log.py +98 -0
  32. midpoint_cli/prompt/org.py +122 -0
  33. midpoint_cli/prompt/output.py +33 -0
  34. midpoint_cli/prompt/put.py +71 -0
  35. midpoint_cli/prompt/report.py +69 -0
  36. midpoint_cli/prompt/resource.py +217 -0
  37. midpoint_cli/prompt/role.py +96 -0
  38. midpoint_cli/prompt/script.py +154 -0
  39. midpoint_cli/prompt/search.py +51 -0
  40. midpoint_cli/prompt/shadow.py +87 -0
  41. midpoint_cli/prompt/task.py +296 -0
  42. midpoint_cli/prompt/user.py +406 -0
  43. midpoint_cli-1.6.0.dist-info/METADATA +1334 -0
  44. midpoint_cli-1.6.0.dist-info/RECORD +47 -0
  45. midpoint_cli-1.6.0.dist-info/WHEEL +4 -0
  46. midpoint_cli-1.6.0.dist-info/entry_points.txt +3 -0
  47. midpoint_cli-1.6.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,291 @@
1
+ """Tab completion for the interactive prompt.
2
+
3
+ The completer derives everything from the command classes and their argparse parsers, so
4
+ completion cannot drift from what the commands accept:
5
+
6
+ * command names and descriptions come from the ``do_*`` methods and their docstrings;
7
+ * subcommands come from the ``subparsers_<command>`` mapping each command dispatches with;
8
+ * options come from each parser's option strings;
9
+ * argument values (object types, task/user/role/org/resource/report names, file paths) come from
10
+ the ``completer`` kind attached to a parser argument with :func:`completes`, or from its
11
+ ``choices`` (e.g. logging levels); an ``OBJECT_OF_TYPE`` argument completes the names of the
12
+ type given in the ``OBJECT_TYPE`` argument before it (``get user <name>``).
13
+
14
+ Object names are searched on the server by name prefix, at most :data:`COMPLETION_LIMIT` at a
15
+ time, so completion does not download whole collections.
16
+ """
17
+
18
+ import glob
19
+ import os
20
+ import shlex
21
+ import time
22
+ from argparse import Action, ArgumentParser
23
+ from collections.abc import Iterator
24
+
25
+ from prompt_toolkit.completion import CompleteEvent, Completer, Completion
26
+ from prompt_toolkit.document import Document
27
+
28
+ from midpoint_cli.client import MidpointClient
29
+ from midpoint_cli.client.objects import MidpointObjectList, MidpointObjectTypes, MidpointTypeNotFound
30
+
31
+ # Argument value kinds understood by MidpointCompleter.
32
+ OBJECT_TYPE = 'object type'
33
+ TASK = 'task'
34
+ USER = 'user'
35
+ ROLE = 'role'
36
+ ORG = 'org'
37
+ RESOURCE = 'resource'
38
+ REPORT = 'report'
39
+ FILE = 'file'
40
+ # The name of an object whose type is the value of the OBJECT_TYPE argument (get, delete).
41
+ OBJECT_OF_TYPE = 'object of type'
42
+
43
+ # The object type whose names complete each object kind.
44
+ _OBJECT_KIND_TYPES = {
45
+ TASK: MidpointObjectTypes.TASK,
46
+ USER: MidpointObjectTypes.USER,
47
+ ROLE: MidpointObjectTypes.ROLE,
48
+ ORG: MidpointObjectTypes.ORG,
49
+ RESOURCE: MidpointObjectTypes.RESOURCE,
50
+ REPORT: MidpointObjectTypes.REPORT,
51
+ }
52
+ _OBJECT_KINDS = tuple(_OBJECT_KIND_TYPES)
53
+
54
+ # Most names offered per completion request: the server returns the first ones by name.
55
+ COMPLETION_LIMIT = 50
56
+
57
+
58
+ def completes(action: Action, kind: str) -> Action:
59
+ """Tag a parser argument with the kind of value the completer should offer for it."""
60
+ action.completer = kind # type: ignore[attr-defined]
61
+ return action
62
+
63
+
64
+ def _append_slash_if_dir(p: str) -> str:
65
+ if p and os.path.isdir(p) and p[-1] != os.sep:
66
+ return p + os.sep
67
+ else:
68
+ return p
69
+
70
+
71
+ def split_command_line(text: str) -> tuple[list[str], str, int]:
72
+ """Split the text before the cursor the way shlex would, tolerating an unclosed quote.
73
+
74
+ Returns the completed words, the (unquoted) word under the cursor, and the length of
75
+ that word as typed, quotes included, so a completion can replace it.
76
+ """
77
+ words: list[str] = []
78
+ current = ''
79
+ in_word = False
80
+ quote: str | None = None
81
+ start = len(text)
82
+ for index, char in enumerate(text):
83
+ if quote is not None:
84
+ if char == quote:
85
+ quote = None
86
+ else:
87
+ current += char
88
+ elif char.isspace():
89
+ if in_word:
90
+ words.append(current)
91
+ current = ''
92
+ in_word = False
93
+ else:
94
+ if not in_word:
95
+ in_word = True
96
+ start = index
97
+ if char in '\'"':
98
+ quote = char
99
+ else:
100
+ current += char
101
+ if not in_word:
102
+ return words, '', 0
103
+ return words, current, len(text) - start
104
+
105
+
106
+ def _object_kind_of_type(type_name: str) -> str | None:
107
+ """The object kind completing names of the type the user typed, or None for other types."""
108
+ try:
109
+ object_type = MidpointObjectTypes.find_by_name(type_name)
110
+ except MidpointTypeNotFound:
111
+ return None
112
+ for kind, kind_type in _OBJECT_KIND_TYPES.items():
113
+ if kind_type.value == object_type:
114
+ return kind
115
+ return None
116
+
117
+
118
+ def _typed_object_type(parser: ArgumentParser, args: list[str]) -> str | None:
119
+ """The word given for the parser's OBJECT_TYPE positional, if already typed."""
120
+ positionals: list[str] = []
121
+ pending = False
122
+ for word in args:
123
+ if pending:
124
+ pending = False
125
+ elif word.startswith('-') and word != '-':
126
+ action = parser._option_string_actions.get(word)
127
+ pending = action is not None and action.nargs != 0
128
+ else:
129
+ positionals.append(word)
130
+
131
+ index = 0
132
+ for action in parser._actions:
133
+ if action.option_strings:
134
+ continue
135
+ if getattr(action, 'completer', None) == OBJECT_TYPE:
136
+ return positionals[index] if index < len(positionals) else None
137
+ index += action.nargs if isinstance(action.nargs, int) else 1
138
+ return None
139
+
140
+
141
+ def _positional_for(parser: ArgumentParser, args: list[str]) -> Action | None:
142
+ """Return the action that the next word completes: an option value or a positional."""
143
+ pending: Action | None = None
144
+ index = 0
145
+ for word in args:
146
+ if pending is not None:
147
+ pending = None
148
+ elif word.startswith('-') and word != '-':
149
+ action = parser._option_string_actions.get(word)
150
+ if action is not None and action.nargs != 0:
151
+ pending = action
152
+ else:
153
+ index += 1
154
+ if pending is not None:
155
+ return pending
156
+
157
+ for action in parser._actions:
158
+ if action.option_strings:
159
+ continue
160
+ if action.nargs in ('*', '+'):
161
+ return action
162
+ count = action.nargs if isinstance(action.nargs, int) else 1
163
+ if index < count:
164
+ return action
165
+ index -= count
166
+ return None
167
+
168
+
169
+ class MidpointCompleter(Completer):
170
+ """prompt_toolkit completer for the commands of ``command_class``."""
171
+
172
+ cache_ttl = 5 # Seconds a search result is reused, to avoid a server call per keystroke.
173
+
174
+ def __init__(self, command_class: type, client: MidpointClient | None = None) -> None:
175
+ self.command_class = command_class
176
+ self.client = client
177
+
178
+ # Main commands, with the first docstring line as description.
179
+ self.commands: dict[str, str] = {}
180
+ for name in sorted(dir(command_class)):
181
+ if name.startswith('do_') and name != 'do_EOF':
182
+ doc = getattr(command_class, name).__doc__
183
+ self.commands[name[3:]] = doc.strip().splitlines()[0] if doc else ''
184
+
185
+ # Completion candidates per (kind, name prefix), searched on the server.
186
+ self._cache: dict[tuple[str, str], tuple[float, MidpointObjectList]] = {}
187
+
188
+ def _get_objects(self, kind: str, prefix: str = '') -> MidpointObjectList:
189
+ """The objects of one kind whose name starts with prefix (case-insensitive), at most
190
+ COMPLETION_LIMIT, cached per (kind, prefix) for ``cache_ttl`` seconds; empty on any failure."""
191
+ if self.client is None:
192
+ return MidpointObjectList()
193
+
194
+ key = (kind, prefix)
195
+ now = time.time()
196
+ cached = self._cache.get(key)
197
+ if cached is not None and now - cached[0] < self.cache_ttl:
198
+ return cached[1]
199
+
200
+ try:
201
+ objects: MidpointObjectList = self.client.complete_names(
202
+ _OBJECT_KIND_TYPES[kind].value, prefix, COMPLETION_LIMIT
203
+ )
204
+ except Exception:
205
+ # A failing server must not break the prompt, and the failure is not cached:
206
+ # the next keystroke retries.
207
+ return MidpointObjectList()
208
+ self._cache[key] = (now, objects)
209
+ return objects
210
+
211
+ def get_completions(self, document: Document, complete_event: CompleteEvent) -> Iterator[Completion]:
212
+ words, current, typed = split_command_line(document.text_before_cursor)
213
+
214
+ if not words:
215
+ yield from self._complete_words(self.commands, current, typed)
216
+ return
217
+
218
+ command = words[0]
219
+ if command == 'help':
220
+ if len(words) == 1:
221
+ yield from self._complete_words(self.commands, current, typed)
222
+ return
223
+
224
+ subparsers = getattr(self.command_class, f'subparsers_{command}', None)
225
+ if subparsers is not None:
226
+ if len(words) == 1:
227
+ yield from self._complete_words(dict.fromkeys(subparsers, ''), current, typed)
228
+ return
229
+ parser = subparsers.get(words[1])
230
+ args = words[2:]
231
+ else:
232
+ parser = getattr(self.command_class, f'parser_{command}', None)
233
+ args = words[1:]
234
+
235
+ if parser is not None:
236
+ yield from self._complete_arguments(parser, args, current, typed)
237
+
238
+ @staticmethod
239
+ def _complete_words(candidates: dict[str, str], current: str, typed: int) -> Iterator[Completion]:
240
+ for word, description in candidates.items():
241
+ if word.startswith(current.lower()):
242
+ yield Completion(word, start_position=-typed, display_meta=description or None)
243
+
244
+ def _complete_arguments(
245
+ self, parser: ArgumentParser, args: list[str], current: str, typed: int
246
+ ) -> Iterator[Completion]:
247
+ if current.startswith('-'):
248
+ for option, option_action in parser._option_string_actions.items():
249
+ if option.startswith(current):
250
+ yield Completion(option, start_position=-typed, display_meta=option_action.help)
251
+ return
252
+
253
+ action = _positional_for(parser, args)
254
+ kind = getattr(action, 'completer', None)
255
+ if kind == OBJECT_TYPE:
256
+ yield from self._complete_object_types(current, typed)
257
+ elif kind == FILE:
258
+ yield from self._complete_files(current, typed)
259
+ elif kind in _OBJECT_KINDS:
260
+ yield from self._complete_objects(kind, current, typed)
261
+ elif kind == OBJECT_OF_TYPE:
262
+ type_name = _typed_object_type(parser, args)
263
+ object_kind = _object_kind_of_type(type_name) if type_name else None
264
+ if object_kind is not None:
265
+ yield from self._complete_objects(object_kind, current, typed)
266
+ elif action is not None and action.choices:
267
+ # Choices such as logging levels, matched ignoring case.
268
+ for choice in map(str, action.choices):
269
+ if choice.lower().startswith(current.lower()):
270
+ yield Completion(choice, start_position=-typed)
271
+
272
+ @staticmethod
273
+ def _complete_object_types(current: str, typed: int) -> Iterator[Completion]:
274
+ for object_type in MidpointObjectTypes:
275
+ tagname = object_type.value.tagname
276
+ if object_type.value.endpoint is not None and tagname.lower().startswith(current.lower()):
277
+ yield Completion(tagname, start_position=-typed, display_meta=object_type.value.object_type)
278
+
279
+ @staticmethod
280
+ def _complete_files(current: str, typed: int) -> Iterator[Completion]:
281
+ for path in sorted(glob.glob(glob.escape(current) + '*')):
282
+ path = _append_slash_if_dir(path)
283
+ yield Completion(shlex.quote(path), start_position=-typed, display=os.path.basename(path.rstrip(os.sep)))
284
+
285
+ def _complete_objects(self, kind: str, current: str, typed: int) -> Iterator[Completion]:
286
+ """Offer the names the server finds starting with the word, with the OID as metadata."""
287
+ for obj in self._get_objects(kind, current):
288
+ name = obj.get_name()
289
+ oid = obj.get_oid()
290
+ if name:
291
+ yield Completion(shlex.quote(name), start_position=-typed, display=name, display_meta=f'OID: {oid}')
@@ -0,0 +1,258 @@
1
+ import ipaddress
2
+ import logging
3
+ import os
4
+ import shlex
5
+ from argparse import Namespace
6
+ from configparser import ConfigParser
7
+ from configparser import Error as ConfigParserError
8
+ from dataclasses import dataclass, field
9
+ from urllib.parse import urlsplit
10
+
11
+ from midpoint_cli.client import MidpointClientConfiguration
12
+ from midpoint_cli.client.session import (
13
+ READ_TIMEOUT_ENV_VAR,
14
+ READ_TIMEOUT_SECONDS,
15
+ RETRY_TIMEOUT_ENV_VAR,
16
+ RETRY_TIMEOUT_SECONDS,
17
+ parse_retry_timeout,
18
+ )
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ _LOOPBACK_HOSTNAMES = frozenset({'localhost', 'localhost.localdomain'})
23
+
24
+ HOME_CONFIGURATION_FILE = '~/.midpoint-cli.cfg'
25
+ CONFIGURATION_FILE_VARIABLE = 'MIDPOINT_CLI_CONFIG'
26
+ LEGACY_WORKING_DIRECTORY_FILE = 'midpoint-cli.cfg'
27
+
28
+
29
+ class ConfigurationFileError(Exception):
30
+ """An explicitly requested configuration file is missing, unreadable or malformed."""
31
+
32
+
33
+ def locate_configuration_file(cli_path: str | None, env_path: str | None) -> tuple[str | None, bool]:
34
+ """Return the configuration file to read and whether it was requested explicitly.
35
+
36
+ ``--config`` wins over ``MIDPOINT_CLI_CONFIG``; without either, only the home file is
37
+ read. A ``midpoint-cli.cfg`` in the working directory is never picked up implicitly: it
38
+ could come from any checkout and redirect the server URL that credentials are sent to.
39
+ """
40
+ explicit_path = cli_path or env_path
41
+ if explicit_path:
42
+ return os.path.expanduser(explicit_path), True
43
+
44
+ home_path = os.path.expanduser(HOME_CONFIGURATION_FILE)
45
+ if os.path.isfile(home_path):
46
+ return home_path, False
47
+ return None, False
48
+
49
+
50
+ def load_configuration_file(path: str | None) -> ConfigParser:
51
+ """Parse *path* into a ConfigParser; an empty parser when *path* is None.
52
+
53
+ Raises ConfigurationFileError when the file cannot be read or parsed.
54
+ """
55
+ parser = ConfigParser()
56
+ if path is None:
57
+ return parser
58
+
59
+ if not os.path.isfile(path):
60
+ raise ConfigurationFileError(f'configuration file not found: {path}')
61
+
62
+ try:
63
+ read = parser.read(path)
64
+ except (ConfigParserError, UnicodeDecodeError) as e:
65
+ raise ConfigurationFileError(f'cannot parse configuration file {path}: {e}') from e
66
+
67
+ if not read:
68
+ raise ConfigurationFileError(f'cannot read configuration file: {path}')
69
+ return parser
70
+
71
+
72
+ def is_unencrypted_remote_url(url: str) -> bool:
73
+ """True when *url* would carry HTTP Basic credentials in clear text off the local machine.
74
+
75
+ Only plain ``http://`` to a non-loopback host qualifies: ``https://`` is encrypted and
76
+ a loopback address never leaves the host, which is the normal sandbox setup.
77
+ """
78
+ try:
79
+ parts = urlsplit(url)
80
+ except ValueError:
81
+ return False
82
+
83
+ if parts.scheme.lower() != 'http':
84
+ return False
85
+
86
+ host = (parts.hostname or '').lower()
87
+ if not host:
88
+ return False
89
+ return not _is_loopback_host(host)
90
+
91
+
92
+ def _is_loopback_host(host: str) -> bool:
93
+ if host in _LOOPBACK_HOSTNAMES:
94
+ return True
95
+ try:
96
+ return ipaddress.ip_address(host).is_loopback
97
+ except ValueError:
98
+ return False
99
+
100
+
101
+ def is_loopback_url(url: str) -> bool:
102
+ """True when *url* points at this machine (``localhost`` or a loopback address)."""
103
+ try:
104
+ host = (urlsplit(url).hostname or '').lower()
105
+ except ValueError:
106
+ return False
107
+ return bool(host) and _is_loopback_host(host)
108
+
109
+
110
+ def configuration_file_permission_warning(path: str | None, config: ConfigParser, url: str) -> str | None:
111
+ """Warn, SSH-style, when *path* holds a password and other local users can access it.
112
+
113
+ A file without a password exposes nothing, and a password for a server on this machine
114
+ is the sandbox case (the committed midpoint-cli.cfg is 0644 by design), so both are
115
+ skipped to keep the warning meaningful. POSIX permission bits do not apply on Windows.
116
+ """
117
+ if path is None or os.name == 'nt':
118
+ return None
119
+ if 'Midpoint' not in config or 'password' not in config['Midpoint']:
120
+ return None
121
+ if is_loopback_url(url):
122
+ return None
123
+
124
+ try:
125
+ mode = os.stat(path).st_mode & 0o777
126
+ except OSError:
127
+ return None
128
+ if not mode & 0o077:
129
+ return None
130
+
131
+ return (
132
+ f'Configuration file {path} holds a password and is accessible by other users '
133
+ f'(mode {mode:04o}). Restrict it with: chmod 600 {shlex.quote(path)}'
134
+ )
135
+
136
+
137
+ def ignored_working_directory_file(config_path: str | None) -> str | None:
138
+ """Name ./midpoint-cli.cfg when it exists but is not the configuration file in use."""
139
+ if not os.path.isfile(LEGACY_WORKING_DIRECTORY_FILE):
140
+ return None
141
+ if (
142
+ config_path is not None
143
+ and os.path.isfile(config_path)
144
+ and os.path.samefile(config_path, LEGACY_WORKING_DIRECTORY_FILE)
145
+ ):
146
+ return None
147
+ return LEGACY_WORKING_DIRECTORY_FILE
148
+
149
+
150
+ SOURCE_DEFAULT = 'default'
151
+
152
+ _SETTINGS = ('url', 'username', 'password', 'timeout', 'read_timeout')
153
+ _ENVIRONMENT_VARIABLES = (
154
+ 'MIDPOINT_URL',
155
+ 'MIDPOINT_USERNAME',
156
+ 'MIDPOINT_PASSWORD',
157
+ RETRY_TIMEOUT_ENV_VAR,
158
+ READ_TIMEOUT_ENV_VAR,
159
+ )
160
+ _OPTIONS = ('--url', '--username', '--password', '--timeout', '--read-timeout')
161
+ _TIMEOUT_SETTINGS = frozenset({'timeout', 'read_timeout'})
162
+
163
+
164
+ @dataclass
165
+ class ResolvedConfiguration:
166
+ """The effective client configuration and, for each setting, where its value came from."""
167
+
168
+ configuration: MidpointClientConfiguration
169
+ sources: dict[str, str] = field(default_factory=lambda: dict.fromkeys(_SETTINGS, SOURCE_DEFAULT))
170
+
171
+ def set(self, setting: str, value: str | float | None, source: str) -> None:
172
+ """Use *value* for *setting*, recording *source*; an empty value leaves the setting unchanged."""
173
+ if value:
174
+ setattr(self.configuration, setting, value)
175
+ self.sources[setting] = source
176
+
177
+ def describe(self) -> str:
178
+ """One line naming the server, the user, the timeouts and where each setting came from."""
179
+ timeout = self.configuration.timeout if self.configuration.timeout is not None else RETRY_TIMEOUT_SECONDS
180
+ read_timeout = self.configuration.read_timeout
181
+ if read_timeout is None:
182
+ read_timeout = READ_TIMEOUT_SECONDS
183
+ return (
184
+ f'Server {self.configuration.url} (from {self.sources["url"]}), '
185
+ f'user {self.configuration.username} (from {self.sources["username"]}), '
186
+ f'password from {self.sources["password"]}, '
187
+ f'retry timeout {timeout:g} s (from {self.sources["timeout"]}), '
188
+ f'read timeout {read_timeout:g} s (from {self.sources["read_timeout"]})'
189
+ )
190
+
191
+
192
+ def _file_timeout(setting: str, raw: str, file_source: str) -> float:
193
+ value = parse_retry_timeout(raw)
194
+ if value is None:
195
+ raise ConfigurationFileError(
196
+ f'invalid {setting} {raw!r} in {file_source}: expected a positive number of seconds'
197
+ )
198
+ return value
199
+
200
+
201
+ def _environment_timeout(variable: str, raw: str | None) -> float | None:
202
+ if raw is None or raw.strip() == '':
203
+ return None
204
+ value = parse_retry_timeout(raw)
205
+ if value is None:
206
+ logger.warning('Ignoring %s=%r: expected a positive number of seconds.', variable, raw)
207
+ return value
208
+
209
+
210
+ def resolve_client_configuration(
211
+ ns: Namespace, config: ConfigParser, env: list[str | None], config_file: str | None = None
212
+ ) -> ResolvedConfiguration:
213
+ """Merge defaults, the configuration file, the environment and the command line, in that order.
214
+
215
+ *env* holds the values of MIDPOINT_URL, MIDPOINT_USERNAME, MIDPOINT_PASSWORD,
216
+ MIDPOINT_TIMEOUT and MIDPOINT_READ_TIMEOUT, in that order; missing trailing values count
217
+ as unset. *config_file* only names the file in the recorded sources.
218
+
219
+ Raises ConfigurationFileError when the configuration file holds an invalid timeout; an
220
+ invalid MIDPOINT_TIMEOUT or MIDPOINT_READ_TIMEOUT is ignored with a warning.
221
+ """
222
+ if len(env) > len(_ENVIRONMENT_VARIABLES):
223
+ raise ValueError(f'expected at most {len(_ENVIRONMENT_VARIABLES)} environment values, got {len(env)}')
224
+ env = [*env, *[None] * (len(_ENVIRONMENT_VARIABLES) - len(env))]
225
+ resolved = ResolvedConfiguration(
226
+ MidpointClientConfiguration(timeout=RETRY_TIMEOUT_SECONDS, read_timeout=READ_TIMEOUT_SECONDS)
227
+ )
228
+ apply = resolved.set
229
+
230
+ # Configuration file
231
+
232
+ if 'Midpoint' in config:
233
+ section_mp = config['Midpoint']
234
+ file_source = f'configuration file {config_file}' if config_file else 'configuration file'
235
+ for setting in _SETTINGS:
236
+ if setting in section_mp:
237
+ raw = section_mp[setting]
238
+ file_value: float | str = (
239
+ _file_timeout(setting, raw, file_source) if setting in _TIMEOUT_SETTINGS else raw
240
+ )
241
+ apply(setting, file_value, file_source)
242
+
243
+ # Environment variables
244
+
245
+ for setting, variable, raw_value in zip(_SETTINGS, _ENVIRONMENT_VARIABLES, env, strict=True):
246
+ value = _environment_timeout(variable, raw_value) if setting in _TIMEOUT_SETTINGS else raw_value
247
+ apply(setting, value, f'environment variable {variable}')
248
+
249
+ # Command-line parameters (argparse has already validated --timeout and --read-timeout)
250
+
251
+ for setting, option in zip(_SETTINGS, _OPTIONS, strict=True):
252
+ if setting == 'password' and getattr(ns, 'ask_password', False):
253
+ source = 'password prompt'
254
+ else:
255
+ source = f'command-line option {option}'
256
+ apply(setting, getattr(ns, setting, None), source)
257
+
258
+ return resolved
@@ -0,0 +1,45 @@
1
+ from argparse import ArgumentParser, RawTextHelpFormatter
2
+
3
+ from midpoint_cli.prompt.base import PromptBase, add_listing_arguments
4
+ from midpoint_cli.prompt.console import print_rows
5
+
6
+ connector_parser = ArgumentParser(
7
+ formatter_class=RawTextHelpFormatter,
8
+ prog='connector',
9
+ description='Inspect the connectors known to the server.',
10
+ epilog="""
11
+ Available commands:
12
+ ls List connectors with their type, version and bundle.
13
+ """,
14
+ )
15
+ connector_parser.add_argument('command', help='Connector command to execute.')
16
+ connector_parser.add_argument('arg', help='Optional command arguments.', nargs='*')
17
+
18
+ connector_ls_parser = add_listing_arguments(
19
+ ArgumentParser(
20
+ formatter_class=RawTextHelpFormatter,
21
+ prog='connector ls',
22
+ description='List the connectors known to the server.',
23
+ ),
24
+ 'connectors',
25
+ )
26
+
27
+ # Each subcommand with its parser; None for those taking no arguments.
28
+ connector_subparsers: dict[str, ArgumentParser | None] = {'ls': connector_ls_parser}
29
+
30
+
31
+ class ConnectorClientPrompt(PromptBase):
32
+ subparsers_connector = connector_subparsers
33
+
34
+ def do_connector(self, inp: str) -> None:
35
+ """Inspect connectors (subcommand: ls)."""
36
+ self._dispatch_subcommand(inp, connector_parser, connector_subparsers, {'ls': self._connector_ls})
37
+
38
+ def _connector_ls(self, args: list[str]) -> None:
39
+ ls_ns = connector_ls_parser.parse_args(args)
40
+ connectors = self.client.get_connectors(limit=ls_ns.limit, name_filter=ls_ns.filter)
41
+ print_rows(connectors, title='Connectors', noun='connectors')
42
+
43
+ @staticmethod
44
+ def help_connector() -> None:
45
+ connector_parser.print_help()