midpoint-cli 1.6.0.dev3__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.dev3.dist-info/METADATA +1334 -0
  44. midpoint_cli-1.6.0.dev3.dist-info/RECORD +47 -0
  45. midpoint_cli-1.6.0.dev3.dist-info/WHEEL +4 -0
  46. midpoint_cli-1.6.0.dev3.dist-info/entry_points.txt +3 -0
  47. midpoint_cli-1.6.0.dev3.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,814 @@
1
+ import itertools
2
+ import re
3
+ from collections import OrderedDict
4
+ from collections.abc import Callable
5
+ from dataclasses import dataclass
6
+ from datetime import UTC, datetime
7
+ from enum import Enum
8
+ from typing import TypeVar
9
+ from xml.etree.ElementTree import Element
10
+
11
+ from rich.console import Group
12
+ from rich.table import Table
13
+ from rich.text import Text
14
+ from unidecode import unidecode
15
+
16
+ from midpoint_cli.client.constants import AUDIT_NS, COMMON_NS, ResultStatus, TaskExecutionState
17
+ from midpoint_cli.client.server_version import MidpointVersion, task_uses_execution_state
18
+
19
+
20
+ def safe_get_text(element: Element | None, default: str = '') -> str:
21
+ """Safely get text from an XML element, returning default if element is None."""
22
+ return element.text if element is not None and element.text is not None else default
23
+
24
+
25
+ def safe_get_int(element: Element | None, default: int = 0) -> int:
26
+ """Safely get integer from an XML element text, returning default if element is None or text is not a valid int."""
27
+ if element is None or element.text is None:
28
+ return default
29
+ try:
30
+ return int(element.text)
31
+ except (ValueError, TypeError):
32
+ return default
33
+
34
+
35
+ namespaces = {
36
+ 'c': COMMON_NS,
37
+ 'aud': AUDIT_NS,
38
+ }
39
+
40
+
41
+ _T = TypeVar('_T', bound='MidpointObject')
42
+
43
+
44
+ class MidpointObject(OrderedDict):
45
+ """A Midpoint object parsed for display: an ordered mapping of column names to values.
46
+
47
+ Every subclass sets at least ``OID`` and ``Name``; the other keys are the columns of its
48
+ listing, in display order.
49
+ """
50
+
51
+ def get_oid(self) -> str | None:
52
+ """The object's OID as a string, or None when the parsed object has none."""
53
+ oid = self.get('OID')
54
+ return str(oid) if oid is not None else None
55
+
56
+ def get_name(self) -> str | None:
57
+ """The object's name (``c:name``) as a string, or None when it has none."""
58
+ name = self.get('Name')
59
+ return str(name) if name is not None else None
60
+
61
+ @classmethod
62
+ def error_stub(cls: type[_T], oid: str) -> _T:
63
+ """A placeholder for an object of this class that could not be read.
64
+
65
+ Only the OID is set, with ``Status`` 'fetch error', so a listing shows the failure in place of the object.
66
+ """
67
+ obj = cls.__new__(cls)
68
+ OrderedDict.__init__(obj)
69
+ obj['OID'] = oid
70
+ obj['Status'] = 'fetch error'
71
+ return obj
72
+
73
+
74
+ class MidpointObjectList(list[MidpointObject]):
75
+ """A list of parsed Midpoint objects, as returned by the MidpointClient listing and search methods."""
76
+
77
+ def find_object(self, search_reference) -> MidpointObject | None:
78
+ """The first object whose OID or name equals search_reference exactly, or None."""
79
+ for mp_obj in self:
80
+ if search_reference in [mp_obj.get_oid(), mp_obj.get_name()]:
81
+ return mp_obj
82
+
83
+ return None
84
+
85
+ def filter(self, queryterms: list[str]) -> 'MidpointObjectList':
86
+ """A new MidpointObjectList of the objects whose values contain the query terms.
87
+
88
+ Matching is case- and accent-insensitive on every value. A term prefixed with ``+`` is
89
+ mandatory; among the other terms at least one must match (none is needed when every
90
+ term is mandatory).
91
+ """
92
+ selected_objects = MidpointObjectList()
93
+
94
+ mandatory_terms = [t[1:] for t in queryterms if t.startswith('+')]
95
+ optional_terms = [t for t in queryterms if not t.startswith('+')]
96
+
97
+ for obj in self:
98
+ values = [unidecode(str(v)).casefold() for v in obj.values() if v is not None]
99
+
100
+ mandatory_match = all(any(unidecode(term).casefold() in v for v in values) for term in mandatory_terms)
101
+ optional_match = not optional_terms or any(
102
+ unidecode(term).casefold() in v for term in optional_terms for v in values
103
+ )
104
+
105
+ if mandatory_match and optional_match:
106
+ selected_objects.append(obj)
107
+
108
+ return selected_objects
109
+
110
+
111
+ RUNNING_EXECUTION_STATES = frozenset({TaskExecutionState.RUNNING, TaskExecutionState.RUNNABLE})
112
+
113
+
114
+ def _utc_now() -> datetime:
115
+ """Current time as an aware UTC datetime; isolated so tests can freeze it."""
116
+ return datetime.now(UTC)
117
+
118
+
119
+ def _task_execution_state(xml_entity: Element, server_version: MidpointVersion | None) -> str:
120
+ """Read the task state under the name the server version uses; unknown versions try both names."""
121
+ if server_version is not None:
122
+ field = 'c:executionState' if task_uses_execution_state(server_version) else 'c:executionStatus'
123
+ state = xml_entity.find(field, namespaces)
124
+ else:
125
+ state = xml_entity.find('c:executionState', namespaces)
126
+ if state is None:
127
+ state = xml_entity.find('c:executionStatus', namespaces)
128
+ return state.text if state is not None and state.text is not None else 'n/a'
129
+
130
+
131
+ def _task_result_status(xml_entity: Element, server_version: MidpointVersion | None) -> str:
132
+ """Read the result of the task's last run, empty for a task that never ran.
133
+
134
+ On every supported version this is TaskType/resultStatus. operationExecution/status is the
135
+ result of the last change made to the task object, not of a run, so it is only a guess kept
136
+ for servers whose version is unknown or older than 4.4.
137
+ """
138
+ rs = xml_entity.find('c:resultStatus', namespaces)
139
+ if rs is None and (server_version is None or not task_uses_execution_state(server_version)):
140
+ rs = xml_entity.find('c:operationExecution/c:status', namespaces)
141
+ return rs.text if rs is not None and rs.text is not None else ''
142
+
143
+
144
+ def _holds_statistics(statistics: Element) -> bool:
145
+ return (
146
+ statistics.find('c:synchronization', namespaces) is not None
147
+ or statistics.find('c:actionsExecuted', namespaces) is not None
148
+ )
149
+
150
+
151
+ def _activity_statistics(activity: Element) -> list[Element]:
152
+ """Statistics of an activity state, or of its child activities when it holds none itself.
153
+
154
+ A reconciliation or composite activity keeps no synchronization or action statistics of
155
+ its own: each child activity (resourceObjects, remainingShadows, ...) has its own, as seen
156
+ on Midpoint 4.8.14 and 4.10.1. Reading a parent's statistics when it has some, and its
157
+ children's only otherwise, never counts an item twice.
158
+ """
159
+ statistics = activity.find('c:statistics', namespaces)
160
+ if statistics is not None and _holds_statistics(statistics):
161
+ return [statistics]
162
+ collected: list[Element] = []
163
+ for child in activity.findall('c:activity', namespaces):
164
+ collected.extend(_activity_statistics(child))
165
+ return collected
166
+
167
+
168
+ def _sum_transitions(statistics_list: list[Element]) -> list[tuple[str, str, int]]:
169
+ """Synchronization transitions as (from, to, count), summed over activities and outcomes.
170
+
171
+ An exclusion is listed as (reason, '', count).
172
+ """
173
+ totals: dict[tuple[str, str], int] = {}
174
+ for statistics in statistics_list:
175
+ for transition in statistics.findall('c:synchronization/c:transition', namespaces):
176
+ # One counter per outcome (success, failure, skip)
177
+ count = sum(
178
+ safe_get_int(c.find('c:count', namespaces)) for c in transition.findall('c:counter', namespaces)
179
+ )
180
+ keys = []
181
+ if transition.find('c:onSynchronizationStart', namespaces) is not None:
182
+ keys.append(
183
+ (
184
+ safe_get_text(transition.find('c:onSynchronizationStart', namespaces)),
185
+ safe_get_text(transition.find('c:onSynchronizationEnd', namespaces)),
186
+ )
187
+ )
188
+ if transition.find('c:exclusionReason', namespaces) is not None:
189
+ keys.append((safe_get_text(transition.find('c:exclusionReason', namespaces)), ''))
190
+ for key in keys:
191
+ totals[key] = totals.get(key, 0) + count
192
+ return [(start, end, count) for (start, end), count in totals.items()]
193
+
194
+
195
+ def _sum_actions(statistics_list: list[Element]) -> list[tuple[str, str, int, int]]:
196
+ """Executed actions as (object type, operation, successes, failures), summed over activities.
197
+
198
+ Only objectActionsEntry (all actions) is read: resultingObjectActionsEntry summarises the
199
+ same actions per object, so counting both would count them twice.
200
+ """
201
+ totals: dict[tuple[str, str], tuple[int, int]] = {}
202
+ for statistics in statistics_list:
203
+ for entry in statistics.findall('c:actionsExecuted/c:objectActionsEntry', namespaces):
204
+ key = (
205
+ safe_get_text(entry.find('c:objectType', namespaces)),
206
+ safe_get_text(entry.find('c:operation', namespaces)),
207
+ )
208
+ success, failure = totals.get(key, (0, 0))
209
+ totals[key] = (
210
+ success + safe_get_int(entry.find('c:totalSuccessCount', namespaces)),
211
+ failure + safe_get_int(entry.find('c:totalFailureCount', namespaces)),
212
+ )
213
+ return [(object_type, operation, s, f) for (object_type, operation), (s, f) in totals.items()]
214
+
215
+
216
+ # Task detail tables follow the look of the list tables (prompt.console.create_table).
217
+ _TABLE_BORDER_STYLE = 'blue'
218
+ _TABLE_HEADER_STYLE = 'bold cyan'
219
+ # Colors of a task result status (OperationResultStatusType values).
220
+ _RESULT_STATUS_STYLES = {
221
+ ResultStatus.SUCCESS: 'green',
222
+ ResultStatus.WARNING: 'yellow',
223
+ ResultStatus.PARTIAL_ERROR: 'yellow',
224
+ ResultStatus.HANDLED_ERROR: 'yellow',
225
+ ResultStatus.FATAL_ERROR: 'bold red',
226
+ ResultStatus.IN_PROGRESS: 'cyan',
227
+ }
228
+
229
+
230
+ # Timestamp elements of a task's last run, by preference: the names vary across Midpoint versions.
231
+ _TASK_START_FIELDS = ('c:lastRunStartTimestamp', 'c:startTimestamp')
232
+ _TASK_END_FIELDS = ('c:lastRunFinishTimestamp', 'c:endTimestamp', 'c:completionTimestamp')
233
+
234
+
235
+ def _first_text(xml_entity: Element, paths: tuple[str, ...]) -> str | None:
236
+ """The text of the first of paths present with a non-empty text in xml_entity, or None."""
237
+ for path in paths:
238
+ elem = xml_entity.find(path, namespaces)
239
+ if elem is not None and elem.text:
240
+ return elem.text
241
+ return None
242
+
243
+
244
+ class MidpointTask(MidpointObject):
245
+ def __init__(self, xml_entity: Element, server_version: MidpointVersion | None = None):
246
+ super().__init__()
247
+ # The keys are set in display order: identity, states, progress, then Duration.
248
+ self['OID'] = xml_entity.attrib['oid']
249
+ name_element = xml_entity.find('c:name', namespaces)
250
+ self['Name'] = name_element.text if name_element is not None else None
251
+ self['Execution Status'] = _task_execution_state(xml_entity, server_version)
252
+ self['Result Status'] = _task_result_status(xml_entity, server_version)
253
+ self._parse_progress(xml_entity)
254
+
255
+ # taskIdentifier is the join key into audit records — distinct from OID,
256
+ # regenerated per run. Not surfaced in the dict to keep the table view tidy.
257
+ task_identifier_el = xml_entity.find('c:taskIdentifier', namespaces)
258
+ self._task_identifier: str | None = task_identifier_el.text if task_identifier_el is not None else None
259
+
260
+ self._parse_timestamps(xml_entity)
261
+ self._parse_statistics(xml_entity)
262
+
263
+ def _parse_progress(self, xml_entity: Element) -> None:
264
+ """Set Progress and Expected Total, empty when the task does not report them."""
265
+ progress = xml_entity.find('c:progress', namespaces)
266
+ self['Progress'] = progress.text if progress is not None else ''
267
+ total = xml_entity.find('c:expectedTotal', namespaces)
268
+ self['Expected Total'] = total.text if total is not None else ''
269
+
270
+ def _parse_statistics(self, xml_entity: Element) -> None:
271
+ """Collect the transitions and actions of the task statistics, summed over the activities that hold them."""
272
+ root_activity = xml_entity.find('c:activityState/c:activity', namespaces)
273
+ statistics = _activity_statistics(root_activity) if root_activity is not None else []
274
+ self._transitions: list[tuple[str, str, int]] = _sum_transitions(statistics)
275
+ self._actions: list[tuple[str, str, int, int]] = _sum_actions(statistics)
276
+
277
+ def _parse_timestamps(self, xml_entity: Element) -> None:
278
+ """Read the start and end of the last run, and set Duration from them."""
279
+ start_timestamp = _first_text(xml_entity, _TASK_START_FIELDS)
280
+ end_timestamp = _first_text(xml_entity, _TASK_END_FIELDS)
281
+
282
+ # Store the parsed start datetime for use by get_start_time()
283
+ self._start_datetime: datetime | None = None
284
+ if start_timestamp:
285
+ self._start_datetime = self._parse_datetime(start_timestamp)
286
+
287
+ # Calculate duration if we have both timestamps
288
+ duration = self._calculate_duration(start_timestamp, end_timestamp)
289
+ self['Duration'] = duration if duration else ''
290
+
291
+ def _parse_datetime(self, timestamp: str) -> datetime | None:
292
+ """Parse a timestamp string into a datetime object."""
293
+ if not timestamp:
294
+ return None
295
+
296
+ # Parse timestamps - handle both with and without microseconds
297
+ timestamp_formats = [
298
+ '%Y-%m-%dT%H:%M:%S.%fZ',
299
+ '%Y-%m-%dT%H:%M:%SZ',
300
+ '%Y-%m-%dT%H:%M:%S.%f%z',
301
+ '%Y-%m-%dT%H:%M:%S%z',
302
+ ]
303
+
304
+ for fmt in timestamp_formats:
305
+ try:
306
+ return datetime.strptime(timestamp, fmt)
307
+ except ValueError:
308
+ continue
309
+
310
+ return None
311
+
312
+ def _calculate_duration(self, start_timestamp: str | None, end_timestamp: str | None) -> str | None:
313
+ """Calculate duration between two timestamps, or elapsed time for running tasks"""
314
+ if not start_timestamp:
315
+ return None
316
+
317
+ try:
318
+ start_dt = self._parse_datetime(start_timestamp)
319
+ if not start_dt:
320
+ return None
321
+
322
+ end_dt = self._parse_datetime(end_timestamp) if end_timestamp else None
323
+ running = self._is_running()
324
+
325
+ # Use the end timestamp when it belongs to the current run. A running task may still
326
+ # carry the finish timestamp of a previous run, which is earlier than the current start.
327
+ if end_dt is not None and (end_dt >= start_dt or not running):
328
+ return self._format_duration((end_dt - start_dt).total_seconds())
329
+
330
+ # Running task without a usable end timestamp: elapsed time since start
331
+ if running:
332
+ now = _utc_now()
333
+ if start_dt.tzinfo is None or start_dt.tzinfo.utcoffset(start_dt) is None:
334
+ now = now.replace(tzinfo=None)
335
+ return self._format_duration((now - start_dt).total_seconds())
336
+
337
+ except (ValueError, TypeError):
338
+ pass
339
+
340
+ return None
341
+
342
+ def _is_running(self) -> bool:
343
+ """Whether the execution state denotes a running task (Midpoint sends lowercase values)."""
344
+ status = self.get('Execution Status')
345
+ return isinstance(status, str) and status.lower() in RUNNING_EXECUTION_STATES
346
+
347
+ def _format_duration(self, seconds: float) -> str:
348
+ """Format duration in a human-readable format"""
349
+ if seconds < 0:
350
+ return '0s'
351
+
352
+ hours = int(seconds // 3600)
353
+ minutes = int((seconds % 3600) // 60)
354
+ remaining_seconds = int(seconds % 60)
355
+
356
+ if hours > 0:
357
+ return f'{hours}h {minutes}m {remaining_seconds}s'
358
+ elif minutes > 0:
359
+ return f'{minutes}m {remaining_seconds}s'
360
+ else:
361
+ return f'{remaining_seconds}s'
362
+
363
+ def get_task_identifier(self) -> str | None:
364
+ """Return the task's `taskIdentifier`, the field stamped on every audit event the task emits."""
365
+ return self._task_identifier
366
+
367
+ def get_start_time(self) -> datetime | None:
368
+ """Get the task's start time as a datetime object, if available."""
369
+ return getattr(self, '_start_datetime', None)
370
+
371
+ def get_transitions(self) -> list[tuple[str, str, int]]:
372
+ """Synchronization transitions in the task statistics, as (from situation, to situation, count) tuples.
373
+
374
+ Counts are summed over the task's activities and outcomes; an exclusion is listed as
375
+ (reason, '', count). Empty when the task holds no statistics.
376
+ """
377
+ return self._transitions
378
+
379
+ def get_actions(self) -> list[tuple[str, str, int, int]]:
380
+ """Executed actions in the task statistics, as (object type, operation, successes, failures) tuples.
381
+
382
+ Counts are summed over the task's activities. Empty when the task holds no statistics.
383
+ """
384
+ return self._actions
385
+
386
+ def get_success_count(self) -> int:
387
+ """Get the total count of successful operations from task statistics."""
388
+ return sum(count_success for _, _, count_success, _ in self._actions)
389
+
390
+ def get_failure_count(self) -> int:
391
+ """Get the total count of failed operations from task statistics."""
392
+ return sum(count_failure for _, _, _, count_failure in self._actions)
393
+
394
+ def get_full_description(self):
395
+ """Return a Rich renderable group containing task details as formatted tables.
396
+
397
+ Styled like the list tables (blue borders, bold cyan headers); the result status is
398
+ colored by outcome and failure counts stand out. Colors only, so the text is the
399
+ same on a terminal and in a pipe.
400
+ """
401
+ # Task Properties Table
402
+ properties_table = Table(title='Task Properties', show_header=False, border_style=_TABLE_BORDER_STYLE)
403
+ properties_table.add_column('Property', style='bold cyan', no_wrap=True)
404
+ properties_table.add_column('Value', style='white')
405
+ for key, value in self.items():
406
+ style = _RESULT_STATUS_STYLES.get(str(value)) if key == 'Result Status' else None
407
+ properties_table.add_row(Text(key), Text(str(value), style=style or ''))
408
+
409
+ # Transitions Table
410
+ transitions_table = Table(
411
+ title='Transitions', border_style=_TABLE_BORDER_STYLE, show_header=True, header_style=_TABLE_HEADER_STYLE
412
+ )
413
+ transitions_table.add_column('From State', style='yellow')
414
+ transitions_table.add_column('To State', style='yellow')
415
+ transitions_table.add_column('Count', style='white', justify='right')
416
+ for from_state, to_state, count in self.get_transitions():
417
+ transitions_table.add_row(Text(from_state or ''), Text(to_state or ''), str(count))
418
+
419
+ # Actions Table
420
+ actions_table = Table(
421
+ title='Actions', border_style=_TABLE_BORDER_STYLE, show_header=True, header_style=_TABLE_HEADER_STYLE
422
+ )
423
+ actions_table.add_column('Type', style='bright_cyan')
424
+ actions_table.add_column('Action', style='white')
425
+ actions_table.add_column('Success', style='green', justify='right')
426
+ actions_table.add_column('Failure', justify='right')
427
+ for obj_type, operation, count_success, count_failure in self.get_actions():
428
+ failure = Text(str(count_failure), style='bold red' if count_failure else 'dim')
429
+ actions_table.add_row(Text(obj_type or ''), Text(operation or ''), str(count_success), failure)
430
+
431
+ # Return a Group containing all tables
432
+ return Group(properties_table, transitions_table, actions_table)
433
+
434
+
435
+ class MidpointResource(MidpointObject):
436
+ def __init__(self, xml_entity: Element):
437
+ super().__init__()
438
+ self['OID'] = xml_entity.attrib['oid']
439
+ self['Name'] = safe_get_text(xml_entity.find('c:name', namespaces))
440
+ self['Availability Status'] = optional_text(
441
+ xml_entity.find('c:operationalState/c:lastAvailabilityStatus', namespaces)
442
+ )
443
+ connector_ref = xml_entity.find('c:connectorRef', namespaces)
444
+ self['connectorRef'] = connector_ref.attrib['oid'] if connector_ref is not None else None
445
+
446
+
447
+ class MidpointConnector(MidpointObject):
448
+ def __init__(self, xml_entity: Element):
449
+ super().__init__()
450
+ self['OID'] = xml_entity.attrib['oid']
451
+ self['Name'] = safe_get_text(xml_entity.find('c:name', namespaces))
452
+ self['Connector type'] = safe_get_text(xml_entity.find('c:connectorType', namespaces))
453
+ self['Version'] = safe_get_text(xml_entity.find('c:connectorVersion', namespaces))
454
+ self['Bundle'] = safe_get_text(xml_entity.find('c:connectorBundle', namespaces))
455
+
456
+
457
+ class MidpointUser(MidpointObject):
458
+ @staticmethod
459
+ def strip_namespace(tag: str) -> str:
460
+ """An ElementTree tag without its ``{namespace}`` prefix, e.g. 'fullName'."""
461
+ return re.sub(r'{.*}', '', tag)
462
+
463
+ def __init__(self, xml_entity: Element):
464
+ super().__init__()
465
+ self['OID'] = xml_entity.attrib['oid']
466
+ self['Name'] = safe_get_text(xml_entity.find('c:name', namespaces))
467
+ self['Title'] = optional_text(xml_entity.find('c:title', namespaces))
468
+ self['FullName'] = optional_text(xml_entity.find('c:fullName', namespaces))
469
+ self['Status'] = safe_get_text(xml_entity.find('c:activation/c:effectiveStatus', namespaces))
470
+ self['EmpNo'] = optional_text(xml_entity.find('c:employeeNumber', namespaces))
471
+ self['Email'] = optional_text(xml_entity.find('c:emailAddress', namespaces))
472
+ self['OU'] = optional_text(xml_entity.find('c:organizationalUnit', namespaces))
473
+
474
+ activation = xml_entity.find('c:activation', namespaces)
475
+ extfields = xml_entity.find('c:extension', namespaces)
476
+
477
+ self._all_attributes: list[tuple[str, str]] = []
478
+
479
+ for child in itertools.chain(
480
+ xml_entity,
481
+ activation if activation is not None else [],
482
+ extfields if extfields is not None else [],
483
+ ):
484
+ # Check if the child has no children and contains non-empty text
485
+ if len(child) == 0 and (child.text is not None and child.text.strip()):
486
+ self._all_attributes.append((self.strip_namespace(child.tag), child.text))
487
+
488
+ self._role_assignment_oids: list[str] = []
489
+ self._role_assignment_id_map: dict[str, str] = {}
490
+ self._org_assignment_oids: list[str] = []
491
+ for assignment in xml_entity.findall('c:assignment', namespaces):
492
+ target_ref = assignment.find('c:targetRef', namespaces)
493
+ if target_ref is not None:
494
+ ref_oid = target_ref.attrib.get('oid')
495
+ ref_type = target_ref.attrib.get('type', '')
496
+ if ref_oid:
497
+ if 'RoleType' in ref_type:
498
+ self._role_assignment_oids.append(ref_oid)
499
+ assignment_id = assignment.attrib.get('id')
500
+ if assignment_id:
501
+ self._role_assignment_id_map[ref_oid] = assignment_id
502
+ elif 'OrgType' in ref_type:
503
+ self._org_assignment_oids.append(ref_oid)
504
+
505
+ self._link_ref_oids: list[str] = []
506
+ for link_ref in xml_entity.findall('c:linkRef', namespaces):
507
+ ref_oid = link_ref.attrib.get('oid')
508
+ if ref_oid:
509
+ self._link_ref_oids.append(ref_oid)
510
+
511
+ def get_all_attributes(self) -> list[tuple[str, str]]:
512
+ """Every simple property of the user as (local name, text) pairs, in document order.
513
+
514
+ Includes the leaf elements with text directly under the user, under ``activation`` and
515
+ under ``extension``; structured items (assignments, credentials, ...) are left out.
516
+ """
517
+ return self._all_attributes
518
+
519
+ def get_role_assignment_oids(self) -> list[str]:
520
+ """OIDs of the roles the user is directly assigned (assignments targeting a RoleType)."""
521
+ return self._role_assignment_oids
522
+
523
+ def get_role_assignment_id(self, role_oid: str) -> str | None:
524
+ """The container id of the user's assignment of role_oid, or None if not assigned or without id."""
525
+ return self._role_assignment_id_map.get(role_oid)
526
+
527
+ def get_org_assignment_oids(self) -> list[str]:
528
+ """OIDs of the organizations the user is directly assigned (assignments targeting an OrgType)."""
529
+ return self._org_assignment_oids
530
+
531
+ def get_link_ref_oids(self) -> list[str]:
532
+ """OIDs of the user's projections: the shadows referenced by ``linkRef``."""
533
+ return self._link_ref_oids
534
+
535
+
536
+ class MidpointOrganization(MidpointObject):
537
+ def __init__(self, xml_entity: Element):
538
+ super().__init__()
539
+ self['OID'] = xml_entity.attrib['oid']
540
+ self['Name'] = safe_get_text(xml_entity.find('c:name', namespaces))
541
+ self['DisplayName'] = optional_text(xml_entity.find('c:displayName', namespaces))
542
+ self['Status'] = safe_get_text(xml_entity.find('c:activation/c:effectiveStatus', namespaces))
543
+ parentorg = xml_entity.find('c:parentOrgRef', namespaces)
544
+ self['Parent'] = None if parentorg is None else parentorg.attrib['oid']
545
+
546
+
547
+ class MidpointRole(MidpointObject):
548
+ def __init__(self, xml_entity: Element):
549
+ super().__init__()
550
+ self['OID'] = xml_entity.attrib['oid']
551
+ self['Name'] = safe_get_text(xml_entity.find('c:name', namespaces))
552
+ self['DisplayName'] = optional_text(xml_entity.find('c:displayName', namespaces))
553
+ self['Description'] = optional_text(xml_entity.find('c:description', namespaces))
554
+ self['Status'] = safe_get_text(xml_entity.find('c:activation/c:effectiveStatus', namespaces))
555
+
556
+
557
+ class MidpointAuditEvent(MidpointObject):
558
+ """One Midpoint AuditEventRecord, parsed for tabular display.
559
+
560
+ Raw <delta> XML elements are kept available via :meth:`get_deltas` so
561
+ callers can render them only when the user explicitly asks (e.g. with
562
+ ``--show-deltas``).
563
+ """
564
+
565
+ def __init__(self, xml_entity: Element):
566
+ super().__init__()
567
+ self['EventIdentifier'] = self._find_text(xml_entity, 'eventIdentifier')
568
+ self['Timestamp'] = self._find_text(xml_entity, 'timestamp')
569
+ self['Stage'] = self._find_text(xml_entity, 'eventStage')
570
+ self['EventType'] = self._find_text(xml_entity, 'eventType')
571
+ self['Initiator'] = self._format_ref(self._find(xml_entity, 'initiatorRef'))
572
+ self['Target'] = self._format_ref(self._find(xml_entity, 'targetRef'))
573
+ self['Channel'] = self._short_channel(self._find_text(xml_entity, 'channel'))
574
+ self['Outcome'] = self._find_text(xml_entity, 'outcome')
575
+ self['Message'] = self._find_text(xml_entity, 'message')
576
+
577
+ self._initiator_oid = self._ref_oid(self._find(xml_entity, 'initiatorRef'))
578
+ self._target_oid = self._ref_oid(self._find(xml_entity, 'targetRef'))
579
+ self._task_identifier = self._find_text(xml_entity, 'taskIdentifier')
580
+ self._session_identifier = self._find_text(xml_entity, 'sessionIdentifier')
581
+ self._request_identifier = self._find_text(xml_entity, 'requestIdentifier')
582
+ self._parameter = self._find_text(xml_entity, 'parameter')
583
+
584
+ self._deltas: list[Element] = self._find_all(xml_entity, 'delta')
585
+
586
+ @staticmethod
587
+ def _find(parent: Element, local_name: str) -> Element | None:
588
+ """Find a child element by local name across both audit-3 and common-3 namespaces."""
589
+ node = parent.find(f'aud:{local_name}', namespaces)
590
+ if node is None:
591
+ node = parent.find(f'c:{local_name}', namespaces)
592
+ return node
593
+
594
+ @classmethod
595
+ def _find_all(cls, parent: Element, local_name: str) -> list[Element]:
596
+ nodes = parent.findall(f'aud:{local_name}', namespaces)
597
+ if not nodes:
598
+ nodes = parent.findall(f'c:{local_name}', namespaces)
599
+ return list(nodes)
600
+
601
+ @classmethod
602
+ def _find_text(cls, parent: Element, local_name: str) -> str:
603
+ return safe_get_text(cls._find(parent, local_name))
604
+
605
+ @staticmethod
606
+ def _ref_oid(node: Element | None) -> str | None:
607
+ if node is None:
608
+ return None
609
+ return node.attrib.get('oid')
610
+
611
+ @staticmethod
612
+ def _format_ref(node: Element | None) -> str:
613
+ """Render a *Ref node as ``name (oid)`` when both are present, else whichever exists."""
614
+ if node is None:
615
+ return ''
616
+ oid = node.attrib.get('oid', '')
617
+ # Audit refs store the human-readable name in <aud:description>, regular
618
+ # object refs use <c:name>. Fall back through both.
619
+ name = ''
620
+ for path in ('aud:description', 'c:description', 'aud:name', 'c:name'):
621
+ child = node.find(path, namespaces)
622
+ if child is not None and child.text:
623
+ name = child.text
624
+ break
625
+ if name and oid:
626
+ return f'{name} ({oid})'
627
+ return name or oid
628
+
629
+ @staticmethod
630
+ def _short_channel(channel: str) -> str:
631
+ """Drop the URI prefix from a channel URI, keep just the trailing fragment."""
632
+ if not channel:
633
+ return ''
634
+ if '#' in channel:
635
+ return channel.rsplit('#', 1)[-1]
636
+ return channel.rsplit('/', 1)[-1]
637
+
638
+ def get_initiator_oid(self) -> str | None:
639
+ """OID of the object that initiated the event (``initiatorRef``), or None."""
640
+ return self._initiator_oid
641
+
642
+ def get_target_oid(self) -> str | None:
643
+ """OID of the event's target object (``targetRef``), or None."""
644
+ return self._target_oid
645
+
646
+ def get_task_identifier(self) -> str | None:
647
+ """The ``taskIdentifier`` of the task that emitted the event, or None."""
648
+ return self._task_identifier or None
649
+
650
+ def get_session_identifier(self) -> str | None:
651
+ """The event's ``sessionIdentifier``, or None when it has none."""
652
+ return self._session_identifier or None
653
+
654
+ def get_request_identifier(self) -> str | None:
655
+ """The event's ``requestIdentifier``, shared by the events of one operation, or None."""
656
+ return self._request_identifier or None
657
+
658
+ def get_parameter(self) -> str | None:
659
+ """The event's free-form ``parameter``, or None when it has none."""
660
+ return self._parameter or None
661
+
662
+ def get_deltas(self) -> list[Element]:
663
+ """The event's raw ``<delta>`` elements (ObjectDeltaOperationType), empty when it has none."""
664
+ return self._deltas
665
+
666
+
667
+ class MidpointShadow(MidpointObject):
668
+ def __init__(self, xml_entity: Element):
669
+ super().__init__()
670
+ self['OID'] = xml_entity.attrib['oid']
671
+ self['Name'] = safe_get_text(xml_entity.find('c:name', namespaces))
672
+ resource_ref = xml_entity.find('c:resourceRef', namespaces)
673
+ self['ResourceOID'] = resource_ref.attrib['oid'] if resource_ref is not None else None
674
+ self['Kind'] = optional_text(xml_entity.find('c:kind', namespaces))
675
+ self['Intent'] = optional_text(xml_entity.find('c:intent', namespaces))
676
+ self['Status'] = safe_get_text(xml_entity.find('c:activation/c:effectiveStatus', namespaces))
677
+ self['Situation'] = safe_get_text(xml_entity.find('c:synchronizationSituation', namespaces))
678
+ # A dead shadow stands for an object deleted from the resource, kept for a while by Midpoint.
679
+ self['Dead'] = safe_get_text(xml_entity.find('c:dead', namespaces))
680
+
681
+
682
+ @dataclass
683
+ class MidpointObjectType:
684
+ object_type: str | None
685
+ endpoint: str | None
686
+ tagname: str
687
+
688
+
689
+ class MidpointTypeNotFound(Exception):
690
+ pass
691
+
692
+
693
+ class MidpointObjectTypes(Enum):
694
+ CONNECTOR = MidpointObjectType('ConnectorType', 'connectors', 'connector')
695
+ CONNECTOR_HOST = MidpointObjectType('ConnectorHostType', 'connectorHosts', 'connectorHost')
696
+ GENERIC_OBJECT = MidpointObjectType('GenericObjectType', 'genericObjects', 'genericObject')
697
+ RESOURCE = MidpointObjectType('ResourceType', 'resources', 'resource')
698
+ USER = MidpointObjectType('UserType', 'users', 'user')
699
+ OBJECT_TEMPLATE = MidpointObjectType('ObjectTemplateType', 'objectTemplates', 'objectTemplate')
700
+ SYSTEM_CONFIGURATION = MidpointObjectType('SystemConfigurationType', 'systemConfigurations', 'systemConfiguration')
701
+ TASK = MidpointObjectType('TaskType', 'tasks', 'task')
702
+ SHADOW = MidpointObjectType('ShadowType', 'shadows', 'shadow')
703
+ ROLE = MidpointObjectType('RoleType', 'roles', 'role')
704
+ VALUE_POLICY = MidpointObjectType('ValuePolicyType', 'valuePolicies', 'valuePolicy')
705
+ ORG = MidpointObjectType('OrgType', 'orgs', 'org')
706
+ FUNCTION_LIBRARY = MidpointObjectType('FunctionLibraryType', 'functionLibraries', 'functionLibrary')
707
+ REPORT = MidpointObjectType('ReportType', 'reports', 'report')
708
+ POLICY = MidpointObjectType('PolicyType', 'policies', 'policy')
709
+ # The following REST endpoints are all present in Midpoint 4.4 through 4.11.
710
+ ARCHETYPE = MidpointObjectType('ArchetypeType', 'archetypes', 'archetype')
711
+ SERVICE = MidpointObjectType('ServiceType', 'services', 'service')
712
+ LOOKUP_TABLE = MidpointObjectType('LookupTableType', 'lookupTables', 'lookupTable')
713
+ CASE = MidpointObjectType('CaseType', 'cases', 'case')
714
+ NODE = MidpointObjectType('NodeType', 'nodes', 'node')
715
+ OBJECT_COLLECTION = MidpointObjectType('ObjectCollectionType', 'objectCollections', 'objectCollection')
716
+ DASHBOARD = MidpointObjectType('DashboardType', 'dashboards', 'dashboard')
717
+ SEQUENCE = MidpointObjectType('SequenceType', 'sequences', 'sequence')
718
+ OBJECTS = MidpointObjectType(None, None, 'objects')
719
+
720
+ @staticmethod
721
+ def __find(needle: str, getter: Callable) -> MidpointObjectType:
722
+ for type in MidpointObjectTypes:
723
+ if getter(type.value) == needle:
724
+ return type.value
725
+
726
+ raise MidpointTypeNotFound(f'No type found for value {needle}')
727
+
728
+ # The exact lookups below are not used by the CLI itself, which goes through find_by_name.
729
+ # They are kept as public API for `script run` scripts, which receive the client.
730
+
731
+ @staticmethod
732
+ def find_by_java_type(java_type: str) -> MidpointObjectType:
733
+ """The type whose schema type name is exactly java_type (e.g. 'UserType').
734
+
735
+ Raises MidpointTypeNotFound when no type has that name. See find_by_name for a lenient lookup.
736
+ """
737
+ return MidpointObjectTypes.__find(java_type, lambda t: t.object_type)
738
+
739
+ @staticmethod
740
+ def find_by_endpoint(endpoint: str) -> MidpointObjectType:
741
+ """The type whose REST collection is exactly endpoint (e.g. 'users' for ws/rest/users).
742
+
743
+ Raises MidpointTypeNotFound when no type has that endpoint.
744
+ """
745
+ return MidpointObjectTypes.__find(endpoint, lambda t: t.endpoint)
746
+
747
+ @staticmethod
748
+ def find_by_tagname(tagname: str) -> MidpointObjectType:
749
+ """The type whose XML element name is exactly tagname (e.g. 'user'), case-sensitive.
750
+
751
+ Raises MidpointTypeNotFound when no type has that element name. See find_by_name for a lenient lookup.
752
+ """
753
+ return MidpointObjectTypes.__find(tagname, lambda t: t.tagname)
754
+
755
+ @staticmethod
756
+ def find_by_name(name: str) -> MidpointObjectType:
757
+ """
758
+ Flexible type lookup that tries multiple matching strategies:
759
+ 1. Case-insensitive tagname match (e.g., "objectTemplate", "ObjectTemplate")
760
+ 2. Case-insensitive Java type match (e.g., "ObjectTemplateType")
761
+ 3. Partial name with Type suffix appended (e.g., "ObjectTemplate" → "ObjectTemplateType")
762
+ 4. Partial name with Type suffix removed (e.g., "ObjectTemplateType" → "ObjectTemplate")
763
+
764
+ Raises MidpointTypeNotFound with helpful suggestions if no match is found.
765
+ """
766
+ if not name:
767
+ raise MidpointTypeNotFound('Type name cannot be empty')
768
+
769
+ name_lower = name.lower()
770
+
771
+ # Strategy 1: Try case-insensitive tagname match
772
+ for mp_type in MidpointObjectTypes:
773
+ if mp_type.value.tagname and mp_type.value.tagname.lower() == name_lower:
774
+ return mp_type.value
775
+
776
+ # Strategy 2: Try case-insensitive Java type match
777
+ for mp_type in MidpointObjectTypes:
778
+ if mp_type.value.object_type and mp_type.value.object_type.lower() == name_lower:
779
+ return mp_type.value
780
+
781
+ # Strategy 3: Try appending "Type" suffix for partial matches
782
+ # (e.g., "ObjectTemplate" → "ObjectTemplateType")
783
+ if not name_lower.endswith('type'):
784
+ name_with_type = name + 'Type'
785
+ name_with_type_lower = name_with_type.lower()
786
+ for mp_type in MidpointObjectTypes:
787
+ if mp_type.value.object_type and mp_type.value.object_type.lower() == name_with_type_lower:
788
+ return mp_type.value
789
+
790
+ # Strategy 4: Try removing "Type" suffix
791
+ # (e.g., "ObjectTemplateType" → "objectTemplate")
792
+ if name_lower.endswith('type'):
793
+ name_without_type = name[:-4] # Remove "Type"
794
+ name_without_type_lower = name_without_type.lower()
795
+ for mp_type in MidpointObjectTypes:
796
+ if mp_type.value.tagname and mp_type.value.tagname.lower() == name_without_type_lower:
797
+ return mp_type.value
798
+
799
+ # No match found - create helpful error message
800
+ valid_types = []
801
+ for mp_type in MidpointObjectTypes:
802
+ if mp_type.value.tagname != 'objects': # Skip the generic "objects" type
803
+ if mp_type.value.object_type:
804
+ valid_types.append(f'{mp_type.value.tagname} ({mp_type.value.object_type})')
805
+ else:
806
+ valid_types.append(mp_type.value.tagname)
807
+
808
+ valid_types_str = ', '.join(sorted(valid_types))
809
+ raise MidpointTypeNotFound(f'No type found for value "{name}". Valid types are: {valid_types_str}')
810
+
811
+
812
+ def optional_text(node: Element | None) -> str | None:
813
+ """The text of node, or None when node is missing (unlike safe_get_text, which returns a default)."""
814
+ return node.text if node is not None else None