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.
- midpoint_cli/__init__.py +234 -0
- midpoint_cli/client/__init__.py +199 -0
- midpoint_cli/client/audit.py +178 -0
- midpoint_cli/client/audit_query.py +247 -0
- midpoint_cli/client/configuration.py +15 -0
- midpoint_cli/client/constants.py +37 -0
- midpoint_cli/client/errors.py +74 -0
- midpoint_cli/client/logging_config.py +122 -0
- midpoint_cli/client/midpoint_client.py +866 -0
- midpoint_cli/client/object_query.py +230 -0
- midpoint_cli/client/objects.py +814 -0
- midpoint_cli/client/observer.py +29 -0
- midpoint_cli/client/patch.py +113 -0
- midpoint_cli/client/progress.py +80 -0
- midpoint_cli/client/rest.py +490 -0
- midpoint_cli/client/safe_xml.py +24 -0
- midpoint_cli/client/server_info.py +130 -0
- midpoint_cli/client/server_version.py +74 -0
- midpoint_cli/client/session.py +142 -0
- midpoint_cli/client/task_definitions.py +110 -0
- midpoint_cli/prompt/__init__.py +196 -0
- midpoint_cli/prompt/audit.py +253 -0
- midpoint_cli/prompt/base.py +218 -0
- midpoint_cli/prompt/complete.py +291 -0
- midpoint_cli/prompt/configuration.py +258 -0
- midpoint_cli/prompt/connector.py +45 -0
- midpoint_cli/prompt/console.py +818 -0
- midpoint_cli/prompt/delete.py +89 -0
- midpoint_cli/prompt/get.py +66 -0
- midpoint_cli/prompt/info.py +53 -0
- midpoint_cli/prompt/log.py +98 -0
- midpoint_cli/prompt/org.py +122 -0
- midpoint_cli/prompt/output.py +33 -0
- midpoint_cli/prompt/put.py +71 -0
- midpoint_cli/prompt/report.py +69 -0
- midpoint_cli/prompt/resource.py +217 -0
- midpoint_cli/prompt/role.py +96 -0
- midpoint_cli/prompt/script.py +154 -0
- midpoint_cli/prompt/search.py +51 -0
- midpoint_cli/prompt/shadow.py +87 -0
- midpoint_cli/prompt/task.py +296 -0
- midpoint_cli/prompt/user.py +406 -0
- midpoint_cli-1.6.0.dist-info/METADATA +1334 -0
- midpoint_cli-1.6.0.dist-info/RECORD +47 -0
- midpoint_cli-1.6.0.dist-info/WHEEL +4 -0
- midpoint_cli-1.6.0.dist-info/entry_points.txt +3 -0
- midpoint_cli-1.6.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Midpoint server version, and the version-dependent choices the client makes.
|
|
2
|
+
|
|
3
|
+
The version comes from the ``version`` field of ``actuator/info``, which every
|
|
4
|
+
supported release (4.4 to 4.11) fills from the Maven project version, e.g.
|
|
5
|
+
``4.8.14``, ``4.11.0`` or ``4.10-SNAPSHOT``. When it cannot be read, callers
|
|
6
|
+
pass ``None`` and fall back to the older heuristics.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
|
|
12
|
+
_VERSION_RE = re.compile(r'^\s*v?(\d+)\.(\d+)(?:\.(\d+))?')
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True, order=True)
|
|
16
|
+
class MidpointVersion:
|
|
17
|
+
major: int
|
|
18
|
+
minor: int
|
|
19
|
+
patch: int = 0
|
|
20
|
+
text: str = field(default='', compare=False)
|
|
21
|
+
|
|
22
|
+
def at_least(self, major: int, minor: int, patch: int = 0) -> bool:
|
|
23
|
+
return (self.major, self.minor, self.patch) >= (major, minor, patch)
|
|
24
|
+
|
|
25
|
+
def __str__(self) -> str:
|
|
26
|
+
return self.text or f'{self.major}.{self.minor}.{self.patch}'
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def parse_server_version(text: object) -> MidpointVersion | None:
|
|
30
|
+
"""Parse a Midpoint version string such as ``4.8.14`` or ``4.11-SNAPSHOT``; None if it is not one."""
|
|
31
|
+
if not isinstance(text, str):
|
|
32
|
+
return None
|
|
33
|
+
match = _VERSION_RE.match(text)
|
|
34
|
+
if match is None:
|
|
35
|
+
return None
|
|
36
|
+
major, minor, patch = match.groups()
|
|
37
|
+
return MidpointVersion(int(major), int(minor), int(patch or 0), text.strip())
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def describe_version(version: MidpointVersion | None) -> str:
|
|
41
|
+
"""Name the server version in a message, e.g. 'Midpoint 4.8.14' or 'Midpoint (version unknown)'."""
|
|
42
|
+
return f'Midpoint {version}' if version is not None else 'Midpoint (version unknown)'
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# TaskType/executionState replaced executionStatus in 4.4. The 4.4.11 to 4.11.0 schemas only
|
|
46
|
+
# know executionState, and the task's last run result is TaskType/resultStatus in all of them.
|
|
47
|
+
TASK_EXECUTION_STATE_SINCE = (4, 4)
|
|
48
|
+
|
|
49
|
+
# Midpoint 4.11 makes MEL the default script language and refuses Groovy until the server
|
|
50
|
+
# opts in, so earlier versions never answer with a script refusal.
|
|
51
|
+
GROOVY_REFUSED_BY_DEFAULT_SINCE = (4, 11)
|
|
52
|
+
|
|
53
|
+
# The audit script reaches Spring beans through SpringApplicationContextHolder. 4.5 added it to
|
|
54
|
+
# model-impl; 4.4 only has the workflow-impl copy (checked in the v4.4.11 and v4.5 sources).
|
|
55
|
+
_CONTEXT_HOLDER_MODEL_IMPL = 'com.evolveum.midpoint.model.impl.expr.SpringApplicationContextHolder'
|
|
56
|
+
_CONTEXT_HOLDER_WORKFLOW_IMPL = 'com.evolveum.midpoint.wf.impl.processes.common.SpringApplicationContextHolder'
|
|
57
|
+
_CONTEXT_HOLDER_MODEL_IMPL_SINCE = (4, 5)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def task_uses_execution_state(version: MidpointVersion) -> bool:
|
|
61
|
+
"""Whether the server names the task state executionState (4.4+) rather than executionStatus."""
|
|
62
|
+
return version.at_least(*TASK_EXECUTION_STATE_SINCE)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def groovy_refused_by_default(version: MidpointVersion | None) -> bool:
|
|
66
|
+
"""Whether a Groovy refusal is possible: on 4.11+ servers, or when the version is unknown."""
|
|
67
|
+
return version is None or version.at_least(*GROOVY_REFUSED_BY_DEFAULT_SINCE)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def audit_context_holder_class(version: MidpointVersion | None) -> str:
|
|
71
|
+
"""Fully qualified SpringApplicationContextHolder class the audit script imports on this version."""
|
|
72
|
+
if version is not None and not version.at_least(*_CONTEXT_HOLDER_MODEL_IMPL_SINCE):
|
|
73
|
+
return _CONTEXT_HOLDER_WORKFLOW_IMPL
|
|
74
|
+
return _CONTEXT_HOLDER_MODEL_IMPL
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import math
|
|
3
|
+
import os
|
|
4
|
+
import time
|
|
5
|
+
import typing
|
|
6
|
+
|
|
7
|
+
from requests import PreparedRequest, Response
|
|
8
|
+
from requests.adapters import HTTPAdapter
|
|
9
|
+
from urllib3 import Retry
|
|
10
|
+
from urllib3.exceptions import MaxRetryError, ReadTimeoutError, SSLError
|
|
11
|
+
|
|
12
|
+
from midpoint_cli.client.observer import MidpointCommunicationObserver
|
|
13
|
+
|
|
14
|
+
# Connection failures are retried with a fixed pause between attempts, within an overall
|
|
15
|
+
# wall-clock budget counted from the first failure of a request. Retries only fire when no
|
|
16
|
+
# answer was received, so an error status from the server is never retried. Callers that
|
|
17
|
+
# start a Midpoint server and invoke the CLI straight away (integration and sandbox
|
|
18
|
+
# scripts) should wait for the server themselves, or raise the budget with MIDPOINT_TIMEOUT.
|
|
19
|
+
RETRY_TIMEOUT_SECONDS = 120.0
|
|
20
|
+
RETRY_TIMEOUT_ENV_VAR = 'MIDPOINT_TIMEOUT'
|
|
21
|
+
RETRY_BACKOFF_SECONDS = 2
|
|
22
|
+
# Attempt cap derived from the time budget: the deadline is the real limit, the count only
|
|
23
|
+
# keeps urllib3's own bookkeeping consistent with it.
|
|
24
|
+
RETRY_ATTEMPTS = math.ceil(RETRY_TIMEOUT_SECONDS / RETRY_BACKOFF_SECONDS)
|
|
25
|
+
|
|
26
|
+
# Read timeout: how long one REST call waits for the server's answer once connected. Some
|
|
27
|
+
# calls (large searches, resource tests, synchronous task runs) legitimately take minutes.
|
|
28
|
+
# A read timeout is never retried: the server may still be processing the request.
|
|
29
|
+
READ_TIMEOUT_SECONDS = 300.0
|
|
30
|
+
READ_TIMEOUT_ENV_VAR = 'MIDPOINT_READ_TIMEOUT'
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def parse_retry_timeout(raw: str) -> float | None:
|
|
36
|
+
"""Parse a retry budget in seconds; None unless *raw* holds a finite positive number."""
|
|
37
|
+
try:
|
|
38
|
+
value = float(raw)
|
|
39
|
+
except ValueError:
|
|
40
|
+
return None
|
|
41
|
+
if not math.isfinite(value) or value <= 0:
|
|
42
|
+
return None
|
|
43
|
+
return value
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _timeout_from_environment(variable: str, default: float, environ: typing.Mapping[str, str] | None) -> float:
|
|
47
|
+
if environ is None:
|
|
48
|
+
environ = os.environ
|
|
49
|
+
raw = environ.get(variable)
|
|
50
|
+
if raw is None or raw.strip() == '':
|
|
51
|
+
return default
|
|
52
|
+
value = parse_retry_timeout(raw)
|
|
53
|
+
if value is None:
|
|
54
|
+
logger.warning(
|
|
55
|
+
'Ignoring %s=%r: expected a positive number of seconds; using %s s.',
|
|
56
|
+
variable,
|
|
57
|
+
raw,
|
|
58
|
+
default,
|
|
59
|
+
)
|
|
60
|
+
return default
|
|
61
|
+
return value
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def retry_timeout_seconds(environ: typing.Mapping[str, str] | None = None) -> float:
|
|
65
|
+
"""Return the retry budget in seconds, from MIDPOINT_TIMEOUT when it holds a positive number."""
|
|
66
|
+
return _timeout_from_environment(RETRY_TIMEOUT_ENV_VAR, RETRY_TIMEOUT_SECONDS, environ)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def read_timeout_seconds(environ: typing.Mapping[str, str] | None = None) -> float:
|
|
70
|
+
"""Return the read timeout in seconds, from MIDPOINT_READ_TIMEOUT when it holds a positive number."""
|
|
71
|
+
return _timeout_from_environment(READ_TIMEOUT_ENV_VAR, READ_TIMEOUT_SECONDS, environ)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def retry_attempts_for(timeout_seconds: float) -> int:
|
|
75
|
+
"""Number of retries that fits in the given budget at RETRY_BACKOFF_SECONDS per pause."""
|
|
76
|
+
return max(1, math.ceil(timeout_seconds / RETRY_BACKOFF_SECONDS))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class CustomRetryManager(Retry):
|
|
80
|
+
def __init__(
|
|
81
|
+
self,
|
|
82
|
+
observer: MidpointCommunicationObserver,
|
|
83
|
+
timeout_seconds: float = RETRY_TIMEOUT_SECONDS,
|
|
84
|
+
deadline: float | None = None,
|
|
85
|
+
**kwargs,
|
|
86
|
+
):
|
|
87
|
+
self._observer = observer
|
|
88
|
+
self.timeout_seconds = timeout_seconds
|
|
89
|
+
# time.monotonic() value after which no further retry is attempted. None until the
|
|
90
|
+
# first failure of a request, so the mounted template never starts the clock.
|
|
91
|
+
self.deadline = deadline
|
|
92
|
+
super().__init__(**kwargs)
|
|
93
|
+
|
|
94
|
+
def get_backoff_time(self):
|
|
95
|
+
return RETRY_BACKOFF_SECONDS
|
|
96
|
+
|
|
97
|
+
def new(self, **kw: typing.Any) -> 'CustomRetryManager':
|
|
98
|
+
kw.setdefault('timeout_seconds', self.timeout_seconds)
|
|
99
|
+
kw.setdefault('deadline', self.deadline)
|
|
100
|
+
result = super().new(observer=self._observer, **kw)
|
|
101
|
+
return result
|
|
102
|
+
|
|
103
|
+
def increment(self, *args, **kwargs) -> 'CustomRetryManager':
|
|
104
|
+
self._observer.on_http_error()
|
|
105
|
+
error = kwargs.get('error')
|
|
106
|
+
url = kwargs.get('url', args[1] if len(args) > 1 else None)
|
|
107
|
+
# A TLS failure (bad certificate, protocol mismatch) will not fix itself while the
|
|
108
|
+
# server boots, so give up at once instead of spending the whole retry budget on it.
|
|
109
|
+
if isinstance(error, SSLError):
|
|
110
|
+
raise MaxRetryError(kwargs.get('_pool'), url, error) from error # type: ignore[arg-type] # as in urllib3
|
|
111
|
+
# The request reached the server, which may still be working on it: repeating it would
|
|
112
|
+
# at best wait another full read timeout and at worst apply a PUT or DELETE twice.
|
|
113
|
+
# Raised as is, like urllib3 does for read=False, so requests reports a ReadTimeout.
|
|
114
|
+
if isinstance(error, ReadTimeoutError):
|
|
115
|
+
raise error
|
|
116
|
+
|
|
117
|
+
now = time.monotonic()
|
|
118
|
+
deadline = self.deadline if self.deadline is not None else now + self.timeout_seconds
|
|
119
|
+
# Stop when the next pause would end past the deadline, so the overall wait is the
|
|
120
|
+
# budget plus at most one connect timeout of the attempt already in flight.
|
|
121
|
+
if error is not None and now + RETRY_BACKOFF_SECONDS > deadline:
|
|
122
|
+
raise MaxRetryError(kwargs.get('_pool'), url, error) from error # type: ignore[arg-type] # as in urllib3
|
|
123
|
+
|
|
124
|
+
result = super().increment(*args, **kwargs)
|
|
125
|
+
result.deadline = deadline
|
|
126
|
+
return result
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class CustomHTTPAdapter(HTTPAdapter):
|
|
130
|
+
def __init__(self, observer: MidpointCommunicationObserver, **kwargs):
|
|
131
|
+
super().__init__(**kwargs)
|
|
132
|
+
self._observer = observer
|
|
133
|
+
|
|
134
|
+
def send(self, request: PreparedRequest, *args: typing.Any, **kwargs: typing.Any) -> Response:
|
|
135
|
+
self._observer.on_http_call()
|
|
136
|
+
try:
|
|
137
|
+
response = super().send(request, *args, **kwargs)
|
|
138
|
+
except Exception:
|
|
139
|
+
self._observer.on_http_failure()
|
|
140
|
+
raise
|
|
141
|
+
self._observer.on_http_success()
|
|
142
|
+
return response
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""TaskType documents for the tasks the CLI creates: resource import and reconciliation, report export.
|
|
2
|
+
|
|
3
|
+
Midpoint has no REST operation that reconciles a resource or runs a report, and the one that
|
|
4
|
+
imports from a resource (``POST resources/{oid}/import/{objectClass}``) only takes an object
|
|
5
|
+
class. The CLI therefore creates the same activity-based task the GUI creates, by POSTing it to
|
|
6
|
+
``ws/rest/tasks``; the server starts a ``runnable`` task on its own.
|
|
7
|
+
|
|
8
|
+
Every construct used is in the task schema of 4.4.11, 4.8.14 and 4.11.0 (common-tasks-3.xsd):
|
|
9
|
+
``activity/work`` with ``import``, ``reconciliation`` (``resourceObjects`` of type
|
|
10
|
+
ResourceObjectSetType: ``resourceRef``, ``kind``, ``intent``, ``objectclass``) and
|
|
11
|
+
``reportExport`` (``reportRef``). The archetypes assigned are initial objects of those releases
|
|
12
|
+
(501 Reconciliation task, 503 Import task, 511 Report export task), so the GUI lists the tasks
|
|
13
|
+
with the ones it creates. ``ownerRef`` and ``schedulingState`` are left out: the task manager
|
|
14
|
+
sets the owner to the logged-in user and derives the scheduling state from ``executionState``.
|
|
15
|
+
|
|
16
|
+
Every value that comes from the command line is XML-escaped.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import re
|
|
20
|
+
from datetime import UTC, datetime
|
|
21
|
+
from xml.sax.saxutils import escape as xml_escape
|
|
22
|
+
from xml.sax.saxutils import quoteattr
|
|
23
|
+
|
|
24
|
+
from midpoint_cli.client.constants import COMMON_NS, RESOURCE_INSTANCE_NS
|
|
25
|
+
|
|
26
|
+
ARCHETYPE_RECONCILIATION_TASK = '00000000-0000-0000-0000-000000000501'
|
|
27
|
+
ARCHETYPE_IMPORT_TASK = '00000000-0000-0000-0000-000000000503'
|
|
28
|
+
ARCHETYPE_REPORT_EXPORT_TASK = '00000000-0000-0000-0000-000000000511'
|
|
29
|
+
|
|
30
|
+
# ShadowKindType values that designate resource objects (unknown is not a valid selection).
|
|
31
|
+
SHADOW_KINDS = ('account', 'entitlement', 'generic')
|
|
32
|
+
|
|
33
|
+
# An object class is written as a QName in the resource instance namespace, so only a plain
|
|
34
|
+
# local name (e.g. AccountObjectClass, inetOrgPerson) is accepted.
|
|
35
|
+
_OBJECT_CLASS = re.compile(r'[A-Za-z_][A-Za-z0-9_.-]*')
|
|
36
|
+
|
|
37
|
+
# The activity work item of each resource task kind, with its archetype and name prefix.
|
|
38
|
+
RESOURCE_TASKS: dict[str, tuple[str, str, str]] = {
|
|
39
|
+
'import': ('import', ARCHETYPE_IMPORT_TASK, 'Import from'),
|
|
40
|
+
'reconcile': ('reconciliation', ARCHETYPE_RECONCILIATION_TASK, 'Reconciliation of'),
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _timestamp(now: datetime | None = None) -> str:
|
|
45
|
+
"""A UTC timestamp for task names, so two runs do not create tasks with the same name."""
|
|
46
|
+
return (now or datetime.now(UTC)).strftime('%Y-%m-%d %H:%M:%S UTC')
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _task(name: str, archetype_oid: str, object_ref: str, work: str) -> str:
|
|
50
|
+
return (
|
|
51
|
+
f'<task xmlns="{COMMON_NS}" xmlns:c="{COMMON_NS}" xmlns:ri="{RESOURCE_INSTANCE_NS}">'
|
|
52
|
+
f'<name>{xml_escape(name)}</name>'
|
|
53
|
+
f'<assignment><targetRef oid="{archetype_oid}" type="c:ArchetypeType"/></assignment>'
|
|
54
|
+
'<executionState>runnable</executionState>'
|
|
55
|
+
f'{object_ref}'
|
|
56
|
+
f'<activity><work>{work}</work></activity>'
|
|
57
|
+
'</task>'
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def resource_task_name(
|
|
62
|
+
operation: str, resource_name: str, kind: str | None, intent: str | None, object_class: str | None
|
|
63
|
+
) -> str:
|
|
64
|
+
"""The name of a resource import or reconciliation task, e.g. 'Import from HR (account/default) 2026-...'."""
|
|
65
|
+
_, _, prefix = RESOURCE_TASKS[operation]
|
|
66
|
+
selection = object_class or '/'.join(part for part in (kind, intent) if part)
|
|
67
|
+
suffix = f' ({selection})' if selection else ''
|
|
68
|
+
return f'{prefix} {resource_name}{suffix} {_timestamp()}'
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def resource_task_xml(
|
|
72
|
+
operation: str,
|
|
73
|
+
name: str,
|
|
74
|
+
resource_oid: str,
|
|
75
|
+
kind: str | None = None,
|
|
76
|
+
intent: str | None = None,
|
|
77
|
+
object_class: str | None = None,
|
|
78
|
+
) -> str:
|
|
79
|
+
"""A runnable import ('import') or reconciliation ('reconcile') task for objects of one resource.
|
|
80
|
+
|
|
81
|
+
The objects are selected by kind and intent, by object class, or both, as in the GUI.
|
|
82
|
+
"""
|
|
83
|
+
work_item, archetype_oid, _ = RESOURCE_TASKS[operation]
|
|
84
|
+
if kind is not None and kind not in SHADOW_KINDS:
|
|
85
|
+
raise ValueError(f'Invalid kind {kind!r}: expected one of {", ".join(SHADOW_KINDS)}')
|
|
86
|
+
if object_class is not None and _OBJECT_CLASS.fullmatch(object_class) is None:
|
|
87
|
+
raise ValueError(f'Invalid object class {object_class!r}: expected a local name such as AccountObjectClass')
|
|
88
|
+
|
|
89
|
+
objects = f'<resourceRef oid={quoteattr(resource_oid)} type="c:ResourceType"/>'
|
|
90
|
+
if kind:
|
|
91
|
+
objects += f'<kind>{kind}</kind>'
|
|
92
|
+
if intent:
|
|
93
|
+
objects += f'<intent>{xml_escape(intent)}</intent>'
|
|
94
|
+
if object_class:
|
|
95
|
+
objects += f'<objectclass>ri:{object_class}</objectclass>'
|
|
96
|
+
work = f'<{work_item}><resourceObjects>{objects}</resourceObjects></{work_item}>'
|
|
97
|
+
object_ref = f'<objectRef oid={quoteattr(resource_oid)} type="c:ResourceType"/>'
|
|
98
|
+
return _task(name, archetype_oid, object_ref, work)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def report_task_name(report_name: str) -> str:
|
|
102
|
+
"""The name of a report export task, e.g. 'Export of report Users 2026-...'."""
|
|
103
|
+
return f'Export of report {report_name} {_timestamp()}'
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def report_task_xml(name: str, report_oid: str) -> str:
|
|
107
|
+
"""A runnable classic report export task: the server writes the output as a ReportData object."""
|
|
108
|
+
work = f'<reportExport><reportRef oid={quoteattr(report_oid)} type="c:ReportType"/></reportExport>'
|
|
109
|
+
object_ref = f'<objectRef oid={quoteattr(report_oid)} type="c:ReportType"/>'
|
|
110
|
+
return _task(name, ARCHETYPE_REPORT_EXPORT_TASK, object_ref, work)
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import contextlib
|
|
2
|
+
import shlex
|
|
3
|
+
import sys
|
|
4
|
+
from cmd import Cmd
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from prompt_toolkit import PromptSession
|
|
8
|
+
from prompt_toolkit.history import FileHistory
|
|
9
|
+
from prompt_toolkit.styles import Style
|
|
10
|
+
|
|
11
|
+
from midpoint_cli.client import MidpointClient
|
|
12
|
+
from midpoint_cli.prompt.audit import AuditClientPrompt
|
|
13
|
+
from midpoint_cli.prompt.complete import MidpointCompleter
|
|
14
|
+
from midpoint_cli.prompt.configuration import ResolvedConfiguration
|
|
15
|
+
from midpoint_cli.prompt.connector import ConnectorClientPrompt
|
|
16
|
+
from midpoint_cli.prompt.console import OUTPUT_TABLE, get_console, print_error, set_output_format
|
|
17
|
+
from midpoint_cli.prompt.delete import DeleteClientPrompt
|
|
18
|
+
from midpoint_cli.prompt.get import GetClientPrompt
|
|
19
|
+
from midpoint_cli.prompt.info import InfoClientPrompt
|
|
20
|
+
from midpoint_cli.prompt.log import LogClientPrompt
|
|
21
|
+
from midpoint_cli.prompt.org import OrgClientPrompt
|
|
22
|
+
from midpoint_cli.prompt.output import OutputClientPrompt
|
|
23
|
+
from midpoint_cli.prompt.put import PutClientPrompt
|
|
24
|
+
from midpoint_cli.prompt.report import ReportClientPrompt
|
|
25
|
+
from midpoint_cli.prompt.resource import ResourceClientPrompt
|
|
26
|
+
from midpoint_cli.prompt.role import RoleClientPrompt
|
|
27
|
+
from midpoint_cli.prompt.script import ScriptClientPrompt
|
|
28
|
+
from midpoint_cli.prompt.search import SearchClientPrompt
|
|
29
|
+
from midpoint_cli.prompt.shadow import ShadowClientPrompt
|
|
30
|
+
from midpoint_cli.prompt.task import TaskClientPrompt
|
|
31
|
+
from midpoint_cli.prompt.user import UserClientPrompt
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def open_history(history_file: Path) -> FileHistory:
|
|
35
|
+
"""Open the command history, keeping it readable by the owner only.
|
|
36
|
+
|
|
37
|
+
History lines can carry OIDs, user names and anything else typed at the prompt, so the
|
|
38
|
+
file is created with mode 0600 and an existing file is tightened to it. prompt_toolkit
|
|
39
|
+
would otherwise create it with the process umask (commonly 0644).
|
|
40
|
+
"""
|
|
41
|
+
history_file.touch(mode=0o600, exist_ok=True)
|
|
42
|
+
with contextlib.suppress(OSError):
|
|
43
|
+
history_file.chmod(0o600)
|
|
44
|
+
return FileHistory(str(history_file))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
__all__ = ['MidpointClientPrompt', 'MidpointCompleter', 'open_history']
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class MidpointClientPrompt(
|
|
51
|
+
Cmd,
|
|
52
|
+
TaskClientPrompt,
|
|
53
|
+
GetClientPrompt,
|
|
54
|
+
PutClientPrompt,
|
|
55
|
+
DeleteClientPrompt,
|
|
56
|
+
ResourceClientPrompt,
|
|
57
|
+
UserClientPrompt,
|
|
58
|
+
OrgClientPrompt,
|
|
59
|
+
RoleClientPrompt,
|
|
60
|
+
ScriptClientPrompt,
|
|
61
|
+
InfoClientPrompt,
|
|
62
|
+
LogClientPrompt,
|
|
63
|
+
AuditClientPrompt,
|
|
64
|
+
ShadowClientPrompt,
|
|
65
|
+
ConnectorClientPrompt,
|
|
66
|
+
ReportClientPrompt,
|
|
67
|
+
SearchClientPrompt,
|
|
68
|
+
OutputClientPrompt,
|
|
69
|
+
):
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
client: MidpointClient,
|
|
73
|
+
configuration: ResolvedConfiguration | None = None,
|
|
74
|
+
output_format: str = OUTPUT_TABLE,
|
|
75
|
+
):
|
|
76
|
+
Cmd.__init__(self)
|
|
77
|
+
set_output_format(output_format)
|
|
78
|
+
is_a_tty = hasattr(sys.stdin, 'isatty') and sys.stdin.isatty()
|
|
79
|
+
self.client = client
|
|
80
|
+
self.configuration = configuration
|
|
81
|
+
self.error_code = 0
|
|
82
|
+
self.error_message = None
|
|
83
|
+
# Sticky across commands: the exit code of the first failed line, so a piped script
|
|
84
|
+
# fails if any line failed, with that line's code (e.g. a `script run` sys.exit(3)).
|
|
85
|
+
self.first_error_code = 0
|
|
86
|
+
self.prompt = '\033[32mmidpoint\033[0m> ' if is_a_tty else ''
|
|
87
|
+
self.intro = 'Welcome to Midpoint client ! Type ? for a list of commands' if is_a_tty else None
|
|
88
|
+
self._is_tty = is_a_tty
|
|
89
|
+
|
|
90
|
+
# Initialize prompt-toolkit session for interactive mode
|
|
91
|
+
self._session: PromptSession | None = None
|
|
92
|
+
if is_a_tty:
|
|
93
|
+
prompt_style = Style.from_dict({'prompt': 'fg:green bold'})
|
|
94
|
+
self._session = PromptSession(
|
|
95
|
+
history=open_history(Path.home() / '.midpoint-cli-history'),
|
|
96
|
+
completer=MidpointCompleter(type(self), client=self.client),
|
|
97
|
+
style=prompt_style,
|
|
98
|
+
complete_while_typing=True,
|
|
99
|
+
enable_history_search=True,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
def default(self, line: str) -> None:
|
|
103
|
+
"""Report an unrecognized command instead of letting it pass with exit 0.
|
|
104
|
+
|
|
105
|
+
cmd.Cmd.default only prints to stdout, so a typo in a script went unnoticed.
|
|
106
|
+
"""
|
|
107
|
+
command = line.split()[0] if line.split() else line
|
|
108
|
+
self.error_code = 1
|
|
109
|
+
self.error_message = f'Unknown command: {command}'
|
|
110
|
+
|
|
111
|
+
def _reset_error(self) -> None:
|
|
112
|
+
self.error_code = 0
|
|
113
|
+
self.error_message = None
|
|
114
|
+
|
|
115
|
+
def _log_error(self) -> None:
|
|
116
|
+
if self.error_message:
|
|
117
|
+
print_error(self.error_message)
|
|
118
|
+
|
|
119
|
+
def onecmd(self, line: str) -> bool:
|
|
120
|
+
self._reset_error()
|
|
121
|
+
|
|
122
|
+
# True stops the command loop (exit, EOF); a blank or rejected line never does.
|
|
123
|
+
res = False
|
|
124
|
+
if line.strip() == '':
|
|
125
|
+
pass
|
|
126
|
+
elif self._check_quoting(line):
|
|
127
|
+
try:
|
|
128
|
+
res = Cmd.onecmd(self, line)
|
|
129
|
+
except SystemExit as e:
|
|
130
|
+
# argparse exits 0 for -h and 2 for a usage error, which it has
|
|
131
|
+
# already printed; only the exit code is left to set.
|
|
132
|
+
self.error_code = 1 if e.code else 0
|
|
133
|
+
|
|
134
|
+
if self.error_code and not self.first_error_code:
|
|
135
|
+
self.first_error_code = self.error_code
|
|
136
|
+
self._log_error()
|
|
137
|
+
return res
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def any_error(self) -> bool:
|
|
141
|
+
"""True once any command of this session has failed."""
|
|
142
|
+
return bool(self.first_error_code)
|
|
143
|
+
|
|
144
|
+
def _check_quoting(self, line: str) -> bool:
|
|
145
|
+
"""Reject a line shlex cannot split (e.g. an unbalanced quote) before any command runs."""
|
|
146
|
+
try:
|
|
147
|
+
shlex.split(line)
|
|
148
|
+
except ValueError as e:
|
|
149
|
+
self.error_code = 1
|
|
150
|
+
self.error_message = f'Invalid quoting: {e}'
|
|
151
|
+
return False
|
|
152
|
+
return True
|
|
153
|
+
|
|
154
|
+
def can_exit(self) -> bool:
|
|
155
|
+
return True
|
|
156
|
+
|
|
157
|
+
def do_EOF(self, inp: str) -> bool:
|
|
158
|
+
console = get_console()
|
|
159
|
+
console.print()
|
|
160
|
+
return self.do_exit(inp)
|
|
161
|
+
|
|
162
|
+
def do_exit(self, inp: str) -> bool:
|
|
163
|
+
return True
|
|
164
|
+
|
|
165
|
+
def help_exit(self) -> None:
|
|
166
|
+
print('Exit the shell')
|
|
167
|
+
|
|
168
|
+
def cmdloop(self, intro: object | None = None) -> None:
|
|
169
|
+
"""Enhanced cmdloop using prompt-toolkit for interactive mode."""
|
|
170
|
+
if self._session and self._is_tty:
|
|
171
|
+
# Use prompt-toolkit for enhanced interactive experience
|
|
172
|
+
console = get_console()
|
|
173
|
+
if intro or self.intro:
|
|
174
|
+
console.print(intro or self.intro, style='cyan')
|
|
175
|
+
|
|
176
|
+
stop = None
|
|
177
|
+
while not stop:
|
|
178
|
+
try:
|
|
179
|
+
line = self._session.prompt([('class:prompt', 'midpoint> ')])
|
|
180
|
+
line = self.precmd(line)
|
|
181
|
+
stop = self.onecmd(line)
|
|
182
|
+
stop = self.postcmd(stop, line)
|
|
183
|
+
except KeyboardInterrupt:
|
|
184
|
+
console.print('^C')
|
|
185
|
+
continue
|
|
186
|
+
except EOFError:
|
|
187
|
+
console.print()
|
|
188
|
+
break
|
|
189
|
+
except Exception as e:
|
|
190
|
+
print_error(f'Unexpected error: {e}')
|
|
191
|
+
continue
|
|
192
|
+
|
|
193
|
+
self.postloop()
|
|
194
|
+
else:
|
|
195
|
+
# Fall back to standard cmd.Cmd for non-TTY
|
|
196
|
+
Cmd.cmdloop(self, intro)
|