midpoint-cli 1.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. midpoint_cli/__init__.py +234 -0
  2. midpoint_cli/client/__init__.py +199 -0
  3. midpoint_cli/client/audit.py +178 -0
  4. midpoint_cli/client/audit_query.py +247 -0
  5. midpoint_cli/client/configuration.py +15 -0
  6. midpoint_cli/client/constants.py +37 -0
  7. midpoint_cli/client/errors.py +74 -0
  8. midpoint_cli/client/logging_config.py +122 -0
  9. midpoint_cli/client/midpoint_client.py +866 -0
  10. midpoint_cli/client/object_query.py +230 -0
  11. midpoint_cli/client/objects.py +814 -0
  12. midpoint_cli/client/observer.py +29 -0
  13. midpoint_cli/client/patch.py +113 -0
  14. midpoint_cli/client/progress.py +80 -0
  15. midpoint_cli/client/rest.py +490 -0
  16. midpoint_cli/client/safe_xml.py +24 -0
  17. midpoint_cli/client/server_info.py +130 -0
  18. midpoint_cli/client/server_version.py +74 -0
  19. midpoint_cli/client/session.py +142 -0
  20. midpoint_cli/client/task_definitions.py +110 -0
  21. midpoint_cli/prompt/__init__.py +196 -0
  22. midpoint_cli/prompt/audit.py +253 -0
  23. midpoint_cli/prompt/base.py +218 -0
  24. midpoint_cli/prompt/complete.py +291 -0
  25. midpoint_cli/prompt/configuration.py +258 -0
  26. midpoint_cli/prompt/connector.py +45 -0
  27. midpoint_cli/prompt/console.py +818 -0
  28. midpoint_cli/prompt/delete.py +89 -0
  29. midpoint_cli/prompt/get.py +66 -0
  30. midpoint_cli/prompt/info.py +53 -0
  31. midpoint_cli/prompt/log.py +98 -0
  32. midpoint_cli/prompt/org.py +122 -0
  33. midpoint_cli/prompt/output.py +33 -0
  34. midpoint_cli/prompt/put.py +71 -0
  35. midpoint_cli/prompt/report.py +69 -0
  36. midpoint_cli/prompt/resource.py +217 -0
  37. midpoint_cli/prompt/role.py +96 -0
  38. midpoint_cli/prompt/script.py +154 -0
  39. midpoint_cli/prompt/search.py +51 -0
  40. midpoint_cli/prompt/shadow.py +87 -0
  41. midpoint_cli/prompt/task.py +296 -0
  42. midpoint_cli/prompt/user.py +406 -0
  43. midpoint_cli-1.6.0.dist-info/METADATA +1334 -0
  44. midpoint_cli-1.6.0.dist-info/RECORD +47 -0
  45. midpoint_cli-1.6.0.dist-info/WHEEL +4 -0
  46. midpoint_cli-1.6.0.dist-info/entry_points.txt +3 -0
  47. midpoint_cli-1.6.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,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)