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,247 @@
1
+ """Audit query construction for Midpoint REST audit search.
2
+
3
+ Midpoint (up to 4.11 at least) does not expose a dedicated REST endpoint for
4
+ audit search. We tunnel the search through the bulk-actions ``executeScript``
5
+ endpoint: a tiny Groovy snippet looks up the ``AuditService`` Spring bean, parses
6
+ the QueryType XML built here, runs ``searchObjects``, and returns each
7
+ record as a serialized XML string. The CLI then decodes that string and
8
+ parses each ``<aud:auditEventRecord>`` back into ``MidpointAuditEvent``.
9
+
10
+ Midpoint 4.11 made MEL the default script language and denies Groovy to every
11
+ expression profile out of the box, and MEL cannot reach the audit service. The
12
+ script therefore names Groovy explicitly, and 4.11 servers must opt in to it.
13
+
14
+ The script uses Midpoint-internal classes, not a public API, so a release that
15
+ moves or changes them breaks it: the client reports such compile and linkage
16
+ errors with the server version (see ``MidpointAuditScriptIncompatible``).
17
+ """
18
+
19
+ import base64
20
+ import re
21
+ from dataclasses import dataclass, field
22
+ from datetime import UTC, datetime, timedelta
23
+ from xml.sax.saxutils import escape as xml_escape
24
+
25
+ from midpoint_cli.client.constants import COMMON_NS, QUERY_NS, SCRIPTING_NS
26
+ from midpoint_cli.client.server_version import MidpointVersion, audit_context_holder_class
27
+
28
+ AUDIT_EXEC_SCRIPT_PATH = 'ws/rest/rpc/executeScript'
29
+ GROOVY_LANGUAGE_URI = 'http://midpoint.evolveum.com/xml/ns/public/expression/language#Groovy'
30
+
31
+ _DURATION_RE = re.compile(r'^\s*(\d+)\s*([smhdw])\s*$', re.IGNORECASE)
32
+ _UNIT_SECONDS = {'s': 1, 'm': 60, 'h': 3600, 'd': 86400, 'w': 604800}
33
+
34
+
35
+ def parse_duration(spec: str) -> datetime:
36
+ """Resolve a duration spec or absolute ISO-8601 timestamp to an aware UTC datetime.
37
+
38
+ Relative forms ``Ns``/``Nm``/``Nh``/``Nd``/``Nw`` are subtracted from now.
39
+ """
40
+ if not spec:
41
+ raise ValueError('Empty duration specification')
42
+
43
+ match = _DURATION_RE.match(spec)
44
+ if match:
45
+ value = int(match.group(1))
46
+ unit = match.group(2).lower()
47
+ delta = timedelta(seconds=value * _UNIT_SECONDS[unit])
48
+ return datetime.now(UTC) - delta
49
+
50
+ iso = spec.strip()
51
+ if iso.endswith('Z'):
52
+ iso = iso[:-1] + '+00:00'
53
+ try:
54
+ dt = datetime.fromisoformat(iso)
55
+ except ValueError as exc:
56
+ raise ValueError(
57
+ f'Invalid duration or timestamp: {spec!r}. Expected forms like "1h", "30m", "2d", "1w" '
58
+ 'or an ISO-8601 timestamp such as "2026-05-09T08:00:00Z".'
59
+ ) from exc
60
+
61
+ if dt.tzinfo is None:
62
+ dt = dt.replace(tzinfo=UTC)
63
+ return dt.astimezone(UTC)
64
+
65
+
66
+ def _format_xsd_datetime(dt: datetime) -> str:
67
+ aware = dt if dt.tzinfo is not None else dt.replace(tzinfo=UTC)
68
+ aware = aware.astimezone(UTC)
69
+ return aware.strftime('%Y-%m-%dT%H:%M:%S.') + f'{aware.microsecond // 1000:03d}Z'
70
+
71
+
72
+ @dataclass
73
+ class AuditQuery:
74
+ """Filter parameters for an audit search request.
75
+
76
+ ``since`` is exclusive (``q:greater``) unless ``since_inclusive`` is set, which
77
+ switches it to ``q:greaterOrEqual``. Streaming cursors need the inclusive form:
78
+ an event committed later with the same timestamp as the newest one already
79
+ seen must still match, and the caller drops the repeats by event identifier.
80
+ """
81
+
82
+ since: datetime | None = None
83
+ since_inclusive: bool = False
84
+ until: datetime | None = None
85
+ initiator_oid: str | None = None
86
+ target_oid: str | None = None
87
+ target_type: str | None = None
88
+ channel: str | None = None
89
+ outcome: str | None = None
90
+ event_type: str | None = None
91
+ event_stage: str | None = None
92
+ event_identifier: str | None = None
93
+ task_identifier: str | None = None
94
+ limit: int = 50
95
+ _equal_filters: list[tuple[str, str]] = field(default_factory=list, init=False, repr=False)
96
+
97
+ def to_xml(self) -> str:
98
+ """Render the query as a Midpoint ``<q:query>`` XML document."""
99
+ filters: list[str] = []
100
+
101
+ if self.since is not None:
102
+ since = _format_xsd_datetime(self.since)
103
+ if self.since_inclusive:
104
+ filters.append(self._greater_or_equal_filter('timestamp', since))
105
+ else:
106
+ filters.append(self._greater_filter('timestamp', since))
107
+ if self.until is not None:
108
+ filters.append(self._less_filter('timestamp', _format_xsd_datetime(self.until)))
109
+ if self.event_identifier is not None:
110
+ filters.append(self._equal_filter('eventIdentifier', self.event_identifier))
111
+ if self.task_identifier is not None:
112
+ filters.append(self._equal_filter('taskIdentifier', self.task_identifier))
113
+ if self.event_type is not None:
114
+ filters.append(self._equal_filter('eventType', self.event_type))
115
+ if self.event_stage is not None:
116
+ filters.append(self._equal_filter('eventStage', self.event_stage))
117
+ if self.channel is not None:
118
+ filters.append(self._equal_filter('channel', self.channel))
119
+ if self.outcome is not None:
120
+ filters.append(self._equal_filter('outcome', self.outcome))
121
+ if self.initiator_oid is not None:
122
+ filters.append(self._ref_filter('initiatorRef', self.initiator_oid))
123
+ if self.target_oid is not None:
124
+ filters.append(self._ref_filter('targetRef', self.target_oid, self.target_type))
125
+ elif self.target_type is not None:
126
+ filters.append(self._ref_filter('targetRef', None, self.target_type))
127
+
128
+ for path, value in self._equal_filters:
129
+ filters.append(self._equal_filter(path, value))
130
+
131
+ if not filters:
132
+ filter_block = ''
133
+ elif len(filters) == 1:
134
+ filter_block = f'<q:filter>{filters[0]}</q:filter>'
135
+ else:
136
+ filter_block = '<q:filter><q:and>' + ''.join(filters) + '</q:and></q:filter>'
137
+
138
+ if self.limit and self.limit > 0:
139
+ paging = (
140
+ '<q:paging>'
141
+ f'<q:maxSize>{int(self.limit)}</q:maxSize>'
142
+ '<q:orderBy>timestamp</q:orderBy>'
143
+ '<q:orderDirection>descending</q:orderDirection>'
144
+ '</q:paging>'
145
+ )
146
+ else:
147
+ paging = (
148
+ '<q:paging><q:orderBy>timestamp</q:orderBy><q:orderDirection>descending</q:orderDirection></q:paging>'
149
+ )
150
+
151
+ return f'<q:query xmlns:q="{QUERY_NS}" xmlns:c="{COMMON_NS}">{filter_block}{paging}</q:query>'
152
+
153
+ @staticmethod
154
+ def _equal_filter(path: str, value: str) -> str:
155
+ return f'<q:equal><q:path>{path}</q:path><q:value>{xml_escape(value)}</q:value></q:equal>'
156
+
157
+ @staticmethod
158
+ def _greater_filter(path: str, value: str) -> str:
159
+ return f'<q:greater><q:path>{path}</q:path><q:value>{xml_escape(value)}</q:value></q:greater>'
160
+
161
+ @staticmethod
162
+ def _greater_or_equal_filter(path: str, value: str) -> str:
163
+ return f'<q:greaterOrEqual><q:path>{path}</q:path><q:value>{xml_escape(value)}</q:value></q:greaterOrEqual>'
164
+
165
+ @staticmethod
166
+ def _less_filter(path: str, value: str) -> str:
167
+ return f'<q:less><q:path>{path}</q:path><q:value>{xml_escape(value)}</q:value></q:less>'
168
+
169
+ @staticmethod
170
+ def _ref_filter(path: str, oid: str | None, type_name: str | None = None) -> str:
171
+ attrs = ''
172
+ if oid:
173
+ attrs += f' oid="{xml_escape(oid, {chr(34): "&quot;"})}"'
174
+ if type_name:
175
+ attrs += f' type="c:{xml_escape(type_name, {chr(34): "&quot;"})}"'
176
+ return f'<q:ref><q:path>{path}</q:path><q:value{attrs}/></q:ref>'
177
+
178
+
179
+ _GROOVY_TEMPLATE = """\
180
+ import com.evolveum.midpoint.audit.api.AuditService
181
+ import com.evolveum.midpoint.schema.result.OperationResult
182
+ import com.evolveum.midpoint.prism.PrismContext
183
+ import com.evolveum.midpoint.xml.ns._public.common.audit_3.AuditEventRecordType
184
+ import com.evolveum.prism.xml.ns._public.query_3.QueryType
185
+ import __CONTEXT_HOLDER_CLASS__
186
+ import javax.xml.namespace.QName
187
+ import java.nio.charset.StandardCharsets
188
+ import java.util.Base64
189
+
190
+ def QUERY_XML = new String(Base64.getDecoder().decode('__QUERY_B64__'), StandardCharsets.UTF_8)
191
+ def ctx = SpringApplicationContextHolder.getApplicationContext()
192
+ def audit = ctx.getBean(AuditService)
193
+ def prismCtx = ctx.getBean(PrismContext)
194
+ def result = new OperationResult("midpoint-cli-audit-search")
195
+ def queryType = prismCtx.parserFor(QUERY_XML).xml().parseRealValue(QueryType.class)
196
+ def query = prismCtx.getQueryConverter().createObjectQuery(AuditEventRecordType.class, queryType)
197
+ def records = audit.searchObjects(query, null, result)
198
+ def serializer = prismCtx.xmlSerializer()
199
+ def buf = new StringBuilder()
200
+ buf.append("<auditEventRecords>")
201
+ records.each { r ->
202
+ buf.append(serializer.serializeRealValue(r,
203
+ new QName("http://midpoint.evolveum.com/xml/ns/public/common/audit-3", "auditEventRecord")))
204
+ }
205
+ buf.append("</auditEventRecords>")
206
+ return buf.toString()
207
+ """
208
+
209
+
210
+ def build_execute_script_body(query: AuditQuery, server_version: MidpointVersion | None = None) -> str:
211
+ """Wrap the AuditQuery in the Groovy ``executeScript`` envelope used by the CLI.
212
+
213
+ Filter values come from the command line and may contain anything, so the
214
+ query XML never appears in the Groovy source: it is embedded as a base64
215
+ string, whose alphabet cannot close a Groovy literal or the surrounding
216
+ CDATA section, and decoded on the server. The Midpoint-internal class used
217
+ to reach the Spring context moved in 4.5, so the import follows the server
218
+ version (the 4.5+ location when the version is unknown).
219
+ """
220
+ query_b64 = base64.b64encode(query.to_xml().encode('utf-8')).decode('ascii')
221
+ groovy = _GROOVY_TEMPLATE.replace('__CONTEXT_HOLDER_CLASS__', audit_context_holder_class(server_version))
222
+ groovy = groovy.replace('__QUERY_B64__', query_b64)
223
+
224
+ return (
225
+ '<?xml version="1.0" encoding="UTF-8"?>'
226
+ f'<s:executeScript xmlns:s="{SCRIPTING_NS}"'
227
+ f' xmlns:c="{COMMON_NS}"'
228
+ ' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">'
229
+ '<s:pipeline>'
230
+ '<s:action>'
231
+ '<s:type>execute-script</s:type>'
232
+ '<s:parameter>'
233
+ '<s:name>script</s:name>'
234
+ '<c:value xsi:type="c:ScriptExpressionEvaluatorType">'
235
+ # Groovy was the implicit default until Midpoint 4.11 switched to MEL
236
+ f'<c:language>{GROOVY_LANGUAGE_URI}</c:language>'
237
+ f'<c:code><![CDATA[\n{groovy}]]></c:code>'
238
+ '</c:value>'
239
+ '</s:parameter>'
240
+ '<s:parameter>'
241
+ '<s:name>forWholeInput</s:name>'
242
+ '<c:value>true</c:value>'
243
+ '</s:parameter>'
244
+ '</s:action>'
245
+ '</s:pipeline>'
246
+ '</s:executeScript>'
247
+ )
@@ -0,0 +1,15 @@
1
+ """Connection settings of a Midpoint client."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass
7
+ class MidpointClientConfiguration:
8
+ url: str = 'http://localhost:8080/midpoint/'
9
+ username: str = 'administrator'
10
+ # No default: a client without a password refuses to call the server (MidpointMissingPassword).
11
+ password: str = ''
12
+ # Seconds to keep retrying a request that gets no answer; None reads MIDPOINT_TIMEOUT (default 120).
13
+ timeout: float | None = None
14
+ # Seconds to wait for the answer to one REST call; None reads MIDPOINT_READ_TIMEOUT (default 300).
15
+ read_timeout: float | None = None
@@ -0,0 +1,37 @@
1
+ """Schema constants shared by the client modules: XML namespaces and status values.
2
+
3
+ Kept free of imports so every client module, including the dependency-free query builders,
4
+ can use it.
5
+ """
6
+
7
+ from typing import Final
8
+
9
+ # XML namespaces of the Midpoint schemas.
10
+ COMMON_NS: Final = 'http://midpoint.evolveum.com/xml/ns/public/common/common-3'
11
+ AUDIT_NS: Final = 'http://midpoint.evolveum.com/xml/ns/public/common/audit-3'
12
+ API_TYPES_NS: Final = 'http://midpoint.evolveum.com/xml/ns/public/common/api-types-3'
13
+ SCRIPTING_NS: Final = 'http://midpoint.evolveum.com/xml/ns/public/model/scripting-3'
14
+ RESOURCE_INSTANCE_NS: Final = 'http://midpoint.evolveum.com/xml/ns/public/resource/instance-3'
15
+ QUERY_NS: Final = 'http://prism.evolveum.com/xml/ns/public/query-3'
16
+ TYPES_NS: Final = 'http://prism.evolveum.com/xml/ns/public/types-3'
17
+
18
+
19
+ class ResultStatus:
20
+ """Values of OperationResultStatusType: a task's last run result, an operation's outcome."""
21
+
22
+ SUCCESS: Final = 'success'
23
+ WARNING: Final = 'warning'
24
+ PARTIAL_ERROR: Final = 'partial_error'
25
+ HANDLED_ERROR: Final = 'handled_error'
26
+ FATAL_ERROR: Final = 'fatal_error'
27
+ IN_PROGRESS: Final = 'in_progress'
28
+
29
+
30
+ class TaskExecutionState:
31
+ """Values of TaskExecutionStateType, a task's executionState (Midpoint 4.4+)."""
32
+
33
+ RUNNING: Final = 'running'
34
+ RUNNABLE: Final = 'runnable'
35
+ WAITING: Final = 'waiting'
36
+ SUSPENDED: Final = 'suspended'
37
+ CLOSED: Final = 'closed'
@@ -0,0 +1,74 @@
1
+ """Exceptions raised by the Midpoint client."""
2
+
3
+ from midpoint_cli.client.objects import MidpointObjectType, MidpointTask
4
+
5
+
6
+ class MidpointServerError(Exception):
7
+ # HTTP status of the failed REST call, when the server answered one.
8
+ status_code: int | None = None
9
+
10
+
11
+ class MidpointUnsupportedOperation(Exception):
12
+ pass
13
+
14
+
15
+ class MidpointInvalidOid(MidpointServerError):
16
+ """An OID or path segment was refused before being joined into a REST URL.
17
+
18
+ Subclasses MidpointServerError so command handlers report it like any other failed call.
19
+ """
20
+
21
+
22
+ class MidpointObjectNotFound(MidpointServerError):
23
+ """The server answered 404: the requested object does not exist (or is not visible to the user)."""
24
+
25
+
26
+ class MidpointAmbiguousName(MidpointServerError):
27
+ """A name given instead of an OID matches several objects, so the CLI refuses to pick one.
28
+
29
+ Names are only unique per type for some types: task names, for instance, often repeat.
30
+ Subclasses MidpointServerError so command handlers report it like any other failed lookup.
31
+ """
32
+
33
+ def __init__(self, object_type: MidpointObjectType, name: str, oids: list[str]):
34
+ self.name = name
35
+ self.oids = oids
36
+ super().__init__(
37
+ f'Ambiguous {object_type.tagname} name {name!r}: {len(oids)} objects have it '
38
+ f'(OIDs: {", ".join(oids)}). Use an OID instead.'
39
+ )
40
+
41
+
42
+ class MidpointMissingPassword(MidpointServerError):
43
+ """The client configuration holds no password, so no REST call is sent.
44
+
45
+ Subclasses MidpointServerError so command handlers report it like any other failed call.
46
+ """
47
+
48
+
49
+ class MidpointClientNotConfigured(Exception):
50
+ """Raised when a MidpointClient built without a configuration or REST client tries to talk to a server."""
51
+
52
+
53
+ class MidpointAuditScriptRefused(MidpointServerError):
54
+ """The server refused the Groovy bulk action that audit search relies on (Midpoint 4.11+ defaults)."""
55
+
56
+
57
+ class MidpointAuditScriptIncompatible(MidpointServerError):
58
+ """The audit Groovy script did not compile or link against the server's internal classes."""
59
+
60
+
61
+ class TaskExecutionFailure(Exception):
62
+ """A task run that ended with a result other than success.
63
+
64
+ ``task`` is the task as last read from the server, so its statistics (error counts,
65
+ executed actions) can be shown with the failure; None when it is not known.
66
+ """
67
+
68
+ def __init__(self, message: str, task: MidpointTask | None = None):
69
+ super().__init__(message)
70
+ self.message = message
71
+ self.task = task
72
+
73
+ def __repr__(self):
74
+ return self.message
@@ -0,0 +1,122 @@
1
+ """Class loggers of the system configuration: reading them, and the REST patch that sets one.
2
+
3
+ The logging configuration lives in SystemConfigurationType/logging. Its shape is the same in
4
+ the 4.4.11 to 4.11.0 schemas (common-core-3.xsd):
5
+
6
+ * ``logging/rootLoggerLevel``: the level of the root logger;
7
+ * ``logging/classLogger`` (multi-valued container, one value per logger, each with an ``id``):
8
+ ``level`` (LoggingLevelType), ``appender`` (zero or more names) and ``package``, the logger
9
+ name (a package or a class).
10
+
11
+ A level is set with a PATCH carrying an ObjectModificationType, so the rest of the system
12
+ configuration is left alone: a ``replace`` of ``logging/classLogger[<id>]/level`` for an
13
+ existing logger, an ``add`` of a ``logging/classLogger`` value for a new one. Midpoint applies
14
+ a changed logging configuration as soon as the system configuration is saved.
15
+ """
16
+
17
+ from dataclasses import dataclass, field
18
+ from xml.etree.ElementTree import Element
19
+ from xml.sax.saxutils import escape as xml_escape
20
+
21
+ from midpoint_cli.client.constants import API_TYPES_NS, COMMON_NS, TYPES_NS
22
+ from midpoint_cli.client.objects import namespaces
23
+
24
+ # LoggingLevelType values, the same from 4.4.11 to 4.11.0.
25
+ LOGGING_LEVELS = ('ALL', 'OFF', 'ERROR', 'WARN', 'INFO', 'DEBUG', 'TRACE')
26
+
27
+
28
+ @dataclass
29
+ class ClassLogger:
30
+ package: str
31
+ level: str | None = None
32
+ appenders: list[str] = field(default_factory=list)
33
+ # Container value id, which a patch needs to address this logger.
34
+ container_id: str | None = None
35
+
36
+
37
+ @dataclass
38
+ class LoggingConfiguration:
39
+ root_level: str | None = None
40
+ class_loggers: list[ClassLogger] = field(default_factory=list)
41
+
42
+ def loggers_of(self, package: str) -> list[ClassLogger]:
43
+ """The class loggers configured for exactly this logger name (normally at most one)."""
44
+ return [logger for logger in self.class_loggers if logger.package == package]
45
+
46
+
47
+ def normalize_level(level: str) -> str:
48
+ """The LoggingLevelType value for level, ignoring case; ValueError for an unknown level."""
49
+ normalized = level.strip().upper()
50
+ if normalized not in LOGGING_LEVELS:
51
+ raise ValueError(f'Invalid logging level {level!r}, expected one of: {", ".join(LOGGING_LEVELS)}')
52
+ return normalized
53
+
54
+
55
+ def check_logger_name(package: str) -> str:
56
+ """A logger name as given, refusing an empty one or one containing whitespace."""
57
+ if not package or any(char.isspace() for char in package):
58
+ raise ValueError(f'Invalid logger name {package!r}: expected a package or class name such as com.example')
59
+ return package
60
+
61
+
62
+ def _text(parent: Element, path: str) -> str | None:
63
+ node = parent.find(path, namespaces)
64
+ text = node.text.strip() if node is not None and node.text is not None else ''
65
+ return text or None
66
+
67
+
68
+ def parse_logging(system_configuration: Element) -> LoggingConfiguration:
69
+ """The root level and class loggers of a SystemConfigurationType document."""
70
+ logging = system_configuration.find('c:logging', namespaces)
71
+ if logging is None:
72
+ return LoggingConfiguration()
73
+ loggers = []
74
+ for node in logging.findall('c:classLogger', namespaces):
75
+ package = _text(node, 'c:package')
76
+ if package is None:
77
+ continue
78
+ loggers.append(
79
+ ClassLogger(
80
+ package=package,
81
+ level=_text(node, 'c:level'),
82
+ appenders=[a.text.strip() for a in node.findall('c:appender', namespaces) if a.text and a.text.strip()],
83
+ container_id=node.attrib.get('id'),
84
+ )
85
+ )
86
+ return LoggingConfiguration(root_level=_text(logging, 'c:rootLoggerLevel'), class_loggers=loggers)
87
+
88
+
89
+ _MODIFICATION_START = f"<objectModification xmlns='{API_TYPES_NS}' xmlns:c='{COMMON_NS}' xmlns:t='{TYPES_NS}'>"
90
+
91
+
92
+ def class_logger_modification(existing: list[ClassLogger], package: str, level: str) -> str:
93
+ """The ObjectModificationType setting package to level.
94
+
95
+ existing are the class loggers already configured for package: each gets its level
96
+ replaced, addressed by its container id. With none, a new class logger is added.
97
+ """
98
+ if existing:
99
+ deltas = []
100
+ for logger in existing:
101
+ if logger.container_id is None or not logger.container_id.isdigit():
102
+ raise ValueError(f'The class logger of {package} has no container id, so it cannot be modified')
103
+ deltas.append(
104
+ '<itemDelta>'
105
+ '<t:modificationType>replace</t:modificationType>'
106
+ f'<t:path>c:logging/c:classLogger[{logger.container_id}]/c:level</t:path>'
107
+ f'<t:value>{level}</t:value>'
108
+ '</itemDelta>'
109
+ )
110
+ body = ''.join(deltas)
111
+ else:
112
+ body = (
113
+ '<itemDelta>'
114
+ '<t:modificationType>add</t:modificationType>'
115
+ '<t:path>c:logging/c:classLogger</t:path>'
116
+ '<t:value>'
117
+ f'<c:level>{level}</c:level>'
118
+ f'<c:package>{xml_escape(package)}</c:package>'
119
+ '</t:value>'
120
+ '</itemDelta>'
121
+ )
122
+ return _MODIFICATION_START + body + '</objectModification>'