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,230 @@
|
|
|
1
|
+
"""QueryType construction for Midpoint REST object searches.
|
|
2
|
+
|
|
3
|
+
Name lookups, searches, filtered listings and completion all go through
|
|
4
|
+
``POST ws/rest/<collection>/search`` with a ``<q:query>`` body built here, so the
|
|
5
|
+
server filters and pages instead of the CLI downloading whole collections.
|
|
6
|
+
|
|
7
|
+
The constructs used are part of the query language of every supported release
|
|
8
|
+
(Midpoint 4.4 to 4.11, generic and native repository alike): ``equal``,
|
|
9
|
+
``substring`` (with ``anchorStart``), ``inOid``, ``ref``, ``and``/``or``, ``text`` (a
|
|
10
|
+
Midpoint Query Language filter, parsed by the server), the
|
|
11
|
+
``polyStringOrig``/``polyStringNorm``/``stringIgnoreCase`` matching rules, and
|
|
12
|
+
``paging`` with ``orderBy``/``maxSize``.
|
|
13
|
+
|
|
14
|
+
Every value that may come from the command line is XML-escaped; paths and matching
|
|
15
|
+
rule names are checked against a strict pattern because they are written verbatim.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
from collections.abc import Iterable, Sequence
|
|
20
|
+
from dataclasses import dataclass
|
|
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, QUERY_NS, ResultStatus, TaskExecutionState
|
|
25
|
+
|
|
26
|
+
# Item paths and matching rule names are written unescaped, so only plain names are allowed.
|
|
27
|
+
_SAFE_TOKEN = re.compile(r'[A-Za-z][A-Za-z0-9]*(/[A-Za-z][A-Za-z0-9]*)*')
|
|
28
|
+
|
|
29
|
+
# Midpoint's canonical OID form. A reference in this form is taken as an OID without a name search.
|
|
30
|
+
OID_RE = re.compile(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', re.IGNORECASE)
|
|
31
|
+
|
|
32
|
+
# Exact, case-sensitive name equality: what the CLI compared before searching on the server.
|
|
33
|
+
NAME_EXACT = 'polyStringOrig'
|
|
34
|
+
# Case- and accent-insensitive comparison of a PolyString; the server normalizes the query value too.
|
|
35
|
+
POLYSTRING_NORM = 'polyStringNorm'
|
|
36
|
+
STRING_IGNORE_CASE = 'stringIgnoreCase'
|
|
37
|
+
|
|
38
|
+
# (path, matching rule) pairs that `<type> search` terms are matched against.
|
|
39
|
+
USER_SEARCH_PATHS: tuple[tuple[str, str], ...] = (
|
|
40
|
+
('name', POLYSTRING_NORM),
|
|
41
|
+
('fullName', POLYSTRING_NORM),
|
|
42
|
+
('emailAddress', STRING_IGNORE_CASE),
|
|
43
|
+
)
|
|
44
|
+
ROLE_SEARCH_PATHS: tuple[tuple[str, str], ...] = (
|
|
45
|
+
('name', POLYSTRING_NORM),
|
|
46
|
+
('displayName', POLYSTRING_NORM),
|
|
47
|
+
)
|
|
48
|
+
ORG_SEARCH_PATHS = ROLE_SEARCH_PATHS
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def is_oid(reference: str) -> bool:
|
|
52
|
+
"""True if reference has Midpoint's canonical (UUID) OID form."""
|
|
53
|
+
return OID_RE.match(reference) is not None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _token(value: str, what: str) -> str:
|
|
57
|
+
if _SAFE_TOKEN.fullmatch(value) is None:
|
|
58
|
+
raise ValueError(f'Invalid query {what}: {value!r}')
|
|
59
|
+
return value
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _value_filter(kind: str, path: str, value: str, matching: str | None = None, extra: str = '') -> str:
|
|
63
|
+
matching_xml = f'<q:matching>{_token(matching, "matching rule")}</q:matching>' if matching else ''
|
|
64
|
+
return (
|
|
65
|
+
f'<q:{kind}>{matching_xml}<q:path>{_token(path, "path")}</q:path>'
|
|
66
|
+
f'<q:value>{xml_escape(value)}</q:value>{extra}</q:{kind}>'
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def equal_filter(path: str, value: str, matching: str | None = None) -> str:
|
|
71
|
+
"""An ``equal`` filter on a single-valued item."""
|
|
72
|
+
return _value_filter('equal', path, value, matching)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def substring_filter(path: str, value: str, matching: str | None = None, anchor_start: bool = False) -> str:
|
|
76
|
+
"""A ``substring`` filter; with anchor_start, a prefix match."""
|
|
77
|
+
extra = '<q:anchorStart>true</q:anchorStart>' if anchor_start else ''
|
|
78
|
+
return _value_filter('substring', path, value, matching, extra)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def ref_filter(path: str, oid: str) -> str:
|
|
82
|
+
"""A ``ref`` filter: the reference at path (e.g. resourceRef) points to the object with this OID."""
|
|
83
|
+
return f'<q:ref><q:path>{_token(path, "path")}</q:path><q:value oid={quoteattr(oid)}/></q:ref>'
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def in_oid_filter(oids: Iterable[str]) -> str:
|
|
87
|
+
"""An ``inOid`` filter matching any of the given OIDs."""
|
|
88
|
+
values = ''.join(f'<q:value>{xml_escape(oid)}</q:value>' for oid in oids)
|
|
89
|
+
return f'<q:inOid>{values}</q:inOid>'
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def text_filter(text: str) -> str:
|
|
93
|
+
"""A filter written in the Midpoint Query Language (Axiom), parsed by the server.
|
|
94
|
+
|
|
95
|
+
The ``text`` element of SearchFilterType is part of the query schema of 4.4 to 4.11. The
|
|
96
|
+
text is escaped like any value: the server, not the CLI, interprets it.
|
|
97
|
+
"""
|
|
98
|
+
return f'<q:text>{xml_escape(text)}</q:text>'
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# `task ls --status` values: the task item each one tests and the values it accepts. Execution
|
|
102
|
+
# states are TaskType/executionState (4.4+, values running, runnable, waiting, suspended, closed);
|
|
103
|
+
# results are TaskType/resultStatus, the result of the last run.
|
|
104
|
+
TASK_STATUS_FILTERS: dict[str, tuple[str, tuple[str, ...]]] = {
|
|
105
|
+
TaskExecutionState.RUNNING: ('executionState', (TaskExecutionState.RUNNING,)),
|
|
106
|
+
TaskExecutionState.RUNNABLE: ('executionState', (TaskExecutionState.RUNNABLE,)),
|
|
107
|
+
TaskExecutionState.WAITING: ('executionState', (TaskExecutionState.WAITING,)),
|
|
108
|
+
TaskExecutionState.SUSPENDED: ('executionState', (TaskExecutionState.SUSPENDED,)),
|
|
109
|
+
TaskExecutionState.CLOSED: ('executionState', (TaskExecutionState.CLOSED,)),
|
|
110
|
+
ResultStatus.IN_PROGRESS: ('resultStatus', (ResultStatus.IN_PROGRESS,)),
|
|
111
|
+
ResultStatus.SUCCESS: ('resultStatus', (ResultStatus.SUCCESS,)),
|
|
112
|
+
ResultStatus.WARNING: ('resultStatus', (ResultStatus.WARNING,)),
|
|
113
|
+
'failed': ('resultStatus', (ResultStatus.FATAL_ERROR, ResultStatus.PARTIAL_ERROR)),
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def task_status_filter(statuses: Sequence[str]) -> str | None:
|
|
118
|
+
"""Filter for `task ls --status`: tasks in any of the given statuses (see TASK_STATUS_FILTERS)."""
|
|
119
|
+
filters: list[str] = []
|
|
120
|
+
for status in statuses:
|
|
121
|
+
path, values = TASK_STATUS_FILTERS[status]
|
|
122
|
+
filters.extend(equal_filter(path, value) for value in values)
|
|
123
|
+
return or_filter(filters)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _combine(operator: str, filters: Sequence[str]) -> str | None:
|
|
127
|
+
if not filters:
|
|
128
|
+
return None
|
|
129
|
+
if len(filters) == 1:
|
|
130
|
+
return filters[0]
|
|
131
|
+
return f'<q:{operator}>' + ''.join(filters) + f'</q:{operator}>'
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def and_filter(filters: Sequence[str]) -> str | None:
|
|
135
|
+
"""Combine filters with AND; a single filter is returned as is, none gives None."""
|
|
136
|
+
return _combine('and', filters)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def or_filter(filters: Sequence[str]) -> str | None:
|
|
140
|
+
"""Combine filters with OR; a single filter is returned as is, none gives None."""
|
|
141
|
+
return _combine('or', filters)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def search_terms_filter(terms: Sequence[str], paths: Sequence[tuple[str, str]]) -> str | None:
|
|
145
|
+
"""Filter for `<type> search` terms: each term is a substring of any of paths.
|
|
146
|
+
|
|
147
|
+
Terms prefixed with '+' are mandatory (AND); plain terms are optional (at least one must match).
|
|
148
|
+
"""
|
|
149
|
+
mandatory = [t[1:] for t in terms if t.startswith('+') and t[1:]]
|
|
150
|
+
optional = [t for t in terms if t and not t.startswith('+')]
|
|
151
|
+
|
|
152
|
+
def path_filters(term: str) -> list[str]:
|
|
153
|
+
return [substring_filter(path, term, matching) for path, matching in paths]
|
|
154
|
+
|
|
155
|
+
def any_path(term: str) -> str:
|
|
156
|
+
combined = or_filter(path_filters(term))
|
|
157
|
+
if combined is None:
|
|
158
|
+
raise AssertionError
|
|
159
|
+
return combined
|
|
160
|
+
|
|
161
|
+
parts = [any_path(t) for t in mandatory]
|
|
162
|
+
# One flat OR for the optional terms: the 4.8 generic (H2) repository fails with a
|
|
163
|
+
# SQL error on an OR nested in an OR when one of the paths is emailAddress.
|
|
164
|
+
optional_part = or_filter([f for t in optional for f in path_filters(t)])
|
|
165
|
+
if optional_part is not None:
|
|
166
|
+
parts.append(optional_part)
|
|
167
|
+
return and_filter(parts)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@dataclass
|
|
171
|
+
class ObjectQuery:
|
|
172
|
+
"""A Midpoint QueryType for an object search.
|
|
173
|
+
|
|
174
|
+
``filter`` is a filter fragment built with the helpers of this module, which escape
|
|
175
|
+
every value; it is never assembled from raw user input.
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
filter: str | None = None
|
|
179
|
+
limit: int | None = None
|
|
180
|
+
order_by: str | None = None
|
|
181
|
+
|
|
182
|
+
def to_xml(self) -> str:
|
|
183
|
+
"""Render the query as a Midpoint ``<q:query>`` XML document."""
|
|
184
|
+
filter_block = f'<q:filter>{self.filter}</q:filter>' if self.filter else ''
|
|
185
|
+
|
|
186
|
+
paging_items = ''
|
|
187
|
+
if self.order_by:
|
|
188
|
+
paging_items += f'<q:orderBy>{_token(self.order_by, "path")}</q:orderBy>'
|
|
189
|
+
if self.limit is not None and self.limit > 0:
|
|
190
|
+
paging_items += f'<q:maxSize>{int(self.limit)}</q:maxSize>'
|
|
191
|
+
paging = f'<q:paging>{paging_items}</q:paging>' if paging_items else ''
|
|
192
|
+
|
|
193
|
+
return f'<q:query xmlns:q="{QUERY_NS}" xmlns:c="{COMMON_NS}">{filter_block}{paging}</q:query>'
|
|
194
|
+
|
|
195
|
+
@classmethod
|
|
196
|
+
def name_equals(cls, name: str) -> 'ObjectQuery':
|
|
197
|
+
"""Objects whose name is exactly name (case-sensitive)."""
|
|
198
|
+
return cls(filter=equal_filter('name', name, NAME_EXACT))
|
|
199
|
+
|
|
200
|
+
@classmethod
|
|
201
|
+
def listing(
|
|
202
|
+
cls, limit: int | None = None, name_filter: str | None = None, filters: Sequence[str] = ()
|
|
203
|
+
) -> 'ObjectQuery':
|
|
204
|
+
"""An ls listing: names containing name_filter (case-insensitive), at most limit objects, by name.
|
|
205
|
+
|
|
206
|
+
filters are further filter fragments (built with this module), all of which must match.
|
|
207
|
+
"""
|
|
208
|
+
name_part = [substring_filter('name', name_filter, POLYSTRING_NORM)] if name_filter else []
|
|
209
|
+
return cls(filter=and_filter([*name_part, *filters]), limit=limit, order_by='name')
|
|
210
|
+
|
|
211
|
+
@classmethod
|
|
212
|
+
def text(cls, text: str, limit: int | None = None) -> 'ObjectQuery':
|
|
213
|
+
"""A `search` query: a Midpoint Query Language filter, at most limit objects, by name."""
|
|
214
|
+
return cls(filter=text_filter(text), limit=limit, order_by='name')
|
|
215
|
+
|
|
216
|
+
@classmethod
|
|
217
|
+
def name_prefix(cls, prefix: str, limit: int) -> 'ObjectQuery':
|
|
218
|
+
"""Completion candidates: names starting with prefix (case-insensitive), at most limit, by name."""
|
|
219
|
+
object_filter = substring_filter('name', prefix, POLYSTRING_NORM, anchor_start=True) if prefix else None
|
|
220
|
+
return cls(filter=object_filter, limit=limit, order_by='name')
|
|
221
|
+
|
|
222
|
+
@classmethod
|
|
223
|
+
def terms(cls, terms: Sequence[str], paths: Sequence[tuple[str, str]]) -> 'ObjectQuery':
|
|
224
|
+
"""A `<type> search` query, see search_terms_filter."""
|
|
225
|
+
return cls(filter=search_terms_filter(terms, paths), order_by='name')
|
|
226
|
+
|
|
227
|
+
@classmethod
|
|
228
|
+
def oids(cls, oids: Iterable[str]) -> 'ObjectQuery':
|
|
229
|
+
"""Objects with any of the given OIDs."""
|
|
230
|
+
return cls(filter=in_oid_filter(oids))
|