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,818 @@
|
|
|
1
|
+
import csv
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
import time
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from collections import deque
|
|
8
|
+
from collections.abc import Callable, Iterable, Mapping, Sequence
|
|
9
|
+
from datetime import UTC, datetime
|
|
10
|
+
from types import TracebackType
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from rich.console import Console as RichConsole
|
|
14
|
+
from rich.console import OverflowMethod, RenderableType
|
|
15
|
+
from rich.errors import LiveError
|
|
16
|
+
from rich.markup import escape
|
|
17
|
+
from rich.progress import BarColumn, Progress, ProgressColumn, SpinnerColumn, Task, TaskID
|
|
18
|
+
from rich.status import Status
|
|
19
|
+
from rich.style import StyleType
|
|
20
|
+
from rich.syntax import Syntax
|
|
21
|
+
from rich.table import Column, Table
|
|
22
|
+
from rich.text import Text
|
|
23
|
+
from rich.theme import Theme
|
|
24
|
+
|
|
25
|
+
from midpoint_cli.client.observer import MidpointCommunicationObserver
|
|
26
|
+
from midpoint_cli.client.progress import ProgressMonitor
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def is_tty() -> bool:
|
|
30
|
+
"""Check if stdout is connected to a TTY (terminal)."""
|
|
31
|
+
return hasattr(sys.stdout, 'isatty') and sys.stdout.isatty()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def is_stderr_tty() -> bool:
|
|
35
|
+
"""Check if stderr, where messages and progress go, is connected to a TTY (terminal)."""
|
|
36
|
+
return hasattr(sys.stderr, 'isatty') and sys.stderr.isatty()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# Shared outcome → Rich style map. Imported by both `audit ls` rendering
|
|
40
|
+
# and the live audit stream emitted during `task run` / `task wait`.
|
|
41
|
+
OUTCOME_STYLE = {
|
|
42
|
+
'success': 'green',
|
|
43
|
+
'fatalerror': 'red',
|
|
44
|
+
'fatal_error': 'red',
|
|
45
|
+
'partialerror': 'yellow',
|
|
46
|
+
'partial_error': 'yellow',
|
|
47
|
+
'warning': 'yellow',
|
|
48
|
+
'unknown': 'white',
|
|
49
|
+
'inprogress': 'cyan',
|
|
50
|
+
'in_progress': 'cyan',
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _outcome_style(outcome: str) -> str:
|
|
55
|
+
return OUTCOME_STYLE.get(outcome.replace('-', '_').lower(), 'white')
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def format_audit_event_line(event: Mapping[str, Any]) -> Text:
|
|
59
|
+
"""Render an audit event as a single colored Rich Text line.
|
|
60
|
+
|
|
61
|
+
Used by the live stream above the progress bar in `task run`/`task wait`.
|
|
62
|
+
Plain-text consumers can call ``str(...)`` on the result.
|
|
63
|
+
"""
|
|
64
|
+
timestamp = (event.get('Timestamp') or '')[11:19] # HH:MM:SS slice of ISO-8601
|
|
65
|
+
event_type = event.get('EventType') or ''
|
|
66
|
+
target = event.get('Target') or ''
|
|
67
|
+
outcome = event.get('Outcome') or ''
|
|
68
|
+
|
|
69
|
+
line = Text()
|
|
70
|
+
line.append(f'[{timestamp}] ' if timestamp else '')
|
|
71
|
+
line.append(f'{event_type:<14} ', style='bright_cyan')
|
|
72
|
+
line.append(f'{target:<38} ', style='blue')
|
|
73
|
+
if outcome:
|
|
74
|
+
line.append(outcome, style=_outcome_style(outcome))
|
|
75
|
+
return line
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _get_now_matching_timezone(reference_time: datetime | None) -> datetime:
|
|
79
|
+
"""
|
|
80
|
+
Get current time matching the timezone awareness of reference_time.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
reference_time: A datetime to match timezone awareness with
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
Current time as timezone-aware if reference is aware, naive otherwise
|
|
87
|
+
"""
|
|
88
|
+
if reference_time is None:
|
|
89
|
+
return datetime.now(UTC).replace(tzinfo=None)
|
|
90
|
+
|
|
91
|
+
if reference_time.tzinfo is not None and reference_time.tzinfo.utcoffset(reference_time) is not None:
|
|
92
|
+
# Reference is timezone-aware, return aware current time
|
|
93
|
+
return datetime.now(UTC)
|
|
94
|
+
else:
|
|
95
|
+
# Reference is naive, return naive current time
|
|
96
|
+
return datetime.now(UTC).replace(tzinfo=None)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
_THEME = Theme(
|
|
100
|
+
{
|
|
101
|
+
'info': 'cyan',
|
|
102
|
+
'warning': 'yellow',
|
|
103
|
+
'error': 'bold red',
|
|
104
|
+
'success': 'bold green',
|
|
105
|
+
'oid': 'blue',
|
|
106
|
+
'name': 'bright_cyan',
|
|
107
|
+
'status_running': 'yellow',
|
|
108
|
+
'status_success': 'green',
|
|
109
|
+
'status_error': 'red',
|
|
110
|
+
}
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# Width of a stdout that is not a terminal: wide enough that piped tables are never cut
|
|
114
|
+
# at 80 columns (OIDs shown as "0000…"). Rich tables only take the width their content needs.
|
|
115
|
+
PIPE_WIDTH = 10_000
|
|
116
|
+
|
|
117
|
+
# Rich Console singletons: data goes to stdout, messages and progress to stderr.
|
|
118
|
+
_console_instance: RichConsole | None = None
|
|
119
|
+
_error_console_instance: RichConsole | None = None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def get_console() -> RichConsole:
|
|
123
|
+
"""Get or create the Rich Console for data (tables, object details), writing to stdout."""
|
|
124
|
+
global _console_instance
|
|
125
|
+
if _console_instance is None:
|
|
126
|
+
width = None if is_tty() or os.environ.get('COLUMNS') else PIPE_WIDTH
|
|
127
|
+
_console_instance = RichConsole(theme=_THEME, highlight=False, width=width)
|
|
128
|
+
return _console_instance
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def get_error_console() -> RichConsole:
|
|
132
|
+
"""Get or create the Rich Console for errors, warnings, status and progress, writing to stderr.
|
|
133
|
+
|
|
134
|
+
Keeping these off stdout means a redirected or piped command captures data only.
|
|
135
|
+
"""
|
|
136
|
+
global _error_console_instance
|
|
137
|
+
if _error_console_instance is None:
|
|
138
|
+
_error_console_instance = RichConsole(theme=_THEME, highlight=False, stderr=True)
|
|
139
|
+
return _error_console_instance
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def print_error(message: str) -> None:
|
|
143
|
+
"""Print an error message on stderr; the message is never read as Rich markup."""
|
|
144
|
+
get_error_console().print(f'[error]Error:[/error] {escape(message)}', soft_wrap=True)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def print_success(message: str) -> None:
|
|
148
|
+
"""Print a success message on stderr."""
|
|
149
|
+
get_error_console().print(f'[success]✓[/success] {escape(message)}', soft_wrap=True)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def print_info(message: str) -> None:
|
|
153
|
+
"""Print an info message on stderr."""
|
|
154
|
+
get_error_console().print(f'[info]ℹ[/info] {escape(message)}', soft_wrap=True)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def print_warning(message: str) -> None:
|
|
158
|
+
"""Print a warning message on stderr."""
|
|
159
|
+
get_error_console().print(f'[warning]⚠[/warning] {escape(message)}', soft_wrap=True)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# Output formats of list commands, selected with the global -o/--output option.
|
|
163
|
+
OUTPUT_TABLE = 'table'
|
|
164
|
+
OUTPUT_JSON = 'json'
|
|
165
|
+
OUTPUT_CSV = 'csv'
|
|
166
|
+
OUTPUT_TSV = 'tsv'
|
|
167
|
+
OUTPUT_FORMATS = (OUTPUT_TABLE, OUTPUT_JSON, OUTPUT_CSV, OUTPUT_TSV)
|
|
168
|
+
|
|
169
|
+
_output_format = OUTPUT_TABLE
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def default_output_format() -> str:
|
|
173
|
+
"""A table on a terminal, TSV when stdout is redirected or piped."""
|
|
174
|
+
return OUTPUT_TABLE if is_tty() else OUTPUT_TSV
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def set_output_format(output_format: str) -> None:
|
|
178
|
+
"""Select how list commands render their rows."""
|
|
179
|
+
global _output_format
|
|
180
|
+
if output_format not in OUTPUT_FORMATS:
|
|
181
|
+
raise ValueError(f'Unknown output format {output_format!r}, expected one of: {", ".join(OUTPUT_FORMATS)}')
|
|
182
|
+
_output_format = output_format
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def get_output_format() -> str:
|
|
186
|
+
return _output_format
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def is_machine_output() -> bool:
|
|
190
|
+
"""True when list commands write json, csv or tsv instead of a table."""
|
|
191
|
+
return _output_format != OUTPUT_TABLE
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _column_style(header: str) -> str:
|
|
195
|
+
lowered = header.lower()
|
|
196
|
+
if 'oid' in lowered:
|
|
197
|
+
return 'blue'
|
|
198
|
+
if 'name' in lowered:
|
|
199
|
+
return 'bright_cyan'
|
|
200
|
+
if 'status' in lowered:
|
|
201
|
+
return 'yellow'
|
|
202
|
+
return 'white'
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _is_identifier_column(header: str) -> bool:
|
|
206
|
+
"""True for columns holding values a user copies into another command (OIDs, event identifiers)."""
|
|
207
|
+
lowered = header.lower()
|
|
208
|
+
return 'oid' in lowered or 'identifier' in lowered
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _cell(value: Any) -> str:
|
|
212
|
+
return '' if value is None else str(value).strip()
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
_TSV_ESCAPES = str.maketrans({'\\': '\\\\', '\t': '\\t', '\n': '\\n', '\r': '\\r'})
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def print_rows(
|
|
219
|
+
rows: Sequence[Mapping[str, Any]],
|
|
220
|
+
*,
|
|
221
|
+
title: str,
|
|
222
|
+
noun: str,
|
|
223
|
+
columns: Sequence[str] | None = None,
|
|
224
|
+
cell_style: Callable[[str, str], str | None] | None = None,
|
|
225
|
+
) -> None:
|
|
226
|
+
"""Render the rows of a list command in the selected output format.
|
|
227
|
+
|
|
228
|
+
Every list command goes through here, so all of them honour -o and report totals the
|
|
229
|
+
same way. Columns default to the keys of the first row. Values are written in full:
|
|
230
|
+
never parsed as Rich markup, and OIDs are never truncated.
|
|
231
|
+
|
|
232
|
+
* table: a Rich table on stdout, then ``Total: N <noun>`` (or ``No <noun> found``) on
|
|
233
|
+
stderr. ``cell_style(column, value)`` may return a style for a single cell.
|
|
234
|
+
* json: an array of objects keyed by column name.
|
|
235
|
+
* csv: RFC 4180, with a header line.
|
|
236
|
+
* tsv: a header line, then one line per row; backslash, tab, newline and carriage return
|
|
237
|
+
in a value are written as ``\\\\``, ``\\t``, ``\\n`` and ``\\r``.
|
|
238
|
+
|
|
239
|
+
Machine formats write nothing on stderr: an empty result is ``[]`` or no data lines.
|
|
240
|
+
"""
|
|
241
|
+
headers = list(columns) if columns is not None else (list(rows[0].keys()) if rows else [])
|
|
242
|
+
values = [[_cell(row.get(h)) for h in headers] for row in rows]
|
|
243
|
+
output_format = get_output_format()
|
|
244
|
+
|
|
245
|
+
if output_format == OUTPUT_JSON:
|
|
246
|
+
records = [dict(zip(headers, row, strict=True)) for row in values]
|
|
247
|
+
sys.stdout.write(json.dumps(records, indent=2, ensure_ascii=False) + '\n')
|
|
248
|
+
elif output_format == OUTPUT_CSV:
|
|
249
|
+
writer = csv.writer(sys.stdout, lineterminator='\n')
|
|
250
|
+
if headers:
|
|
251
|
+
writer.writerow(headers)
|
|
252
|
+
writer.writerows(values)
|
|
253
|
+
elif output_format == OUTPUT_TSV:
|
|
254
|
+
for line in [headers, *values] if headers else values:
|
|
255
|
+
sys.stdout.write('\t'.join(value.translate(_TSV_ESCAPES) for value in line) + '\n')
|
|
256
|
+
elif not rows:
|
|
257
|
+
print_info(f'No {noun} found')
|
|
258
|
+
else:
|
|
259
|
+
table = create_table(title=title)
|
|
260
|
+
for header in headers:
|
|
261
|
+
# Fold rather than truncate, so a narrow terminal still shows whole OIDs and identifiers.
|
|
262
|
+
overflow: OverflowMethod = 'fold' if _is_identifier_column(header) else 'ellipsis'
|
|
263
|
+
table.add_column(header, style=_column_style(header), overflow=overflow)
|
|
264
|
+
for row in values:
|
|
265
|
+
cells: list[RenderableType] = []
|
|
266
|
+
for header, value in zip(headers, row, strict=True):
|
|
267
|
+
style = cell_style(header, value) if cell_style is not None else None
|
|
268
|
+
cells.append(Text(value, style=style) if style else value)
|
|
269
|
+
table.add_row(*cells)
|
|
270
|
+
get_console().print(table)
|
|
271
|
+
print_info(f'Total: {len(rows)} {noun}')
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
# Columns of a detail view (and of `info`) in csv and tsv.
|
|
275
|
+
DETAIL_COLUMNS = ('Property', 'Value')
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _detail_value(value: Any) -> Any:
|
|
279
|
+
"""A detail value for JSON: strings, and lists and objects of them, like print_rows cells."""
|
|
280
|
+
if isinstance(value, Mapping):
|
|
281
|
+
return {str(key): _detail_value(item) for key, item in value.items()}
|
|
282
|
+
if isinstance(value, list | tuple):
|
|
283
|
+
return [_detail_value(item) for item in value]
|
|
284
|
+
return _cell(value)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _flatten_detail(path: str, value: Any, rows: list[dict[str, str]]) -> None:
|
|
288
|
+
"""Append one Property/Value row per scalar of value, named by its path (Roles[0].Name)."""
|
|
289
|
+
if isinstance(value, Mapping):
|
|
290
|
+
for key, item in value.items():
|
|
291
|
+
_flatten_detail(f'{path}.{key}' if path else str(key), item, rows)
|
|
292
|
+
elif isinstance(value, list | tuple):
|
|
293
|
+
for index, item in enumerate(value):
|
|
294
|
+
_flatten_detail(f'{path}[{index}]', item, rows)
|
|
295
|
+
else:
|
|
296
|
+
rows.append({DETAIL_COLUMNS[0]: path, DETAIL_COLUMNS[1]: _cell(value)})
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def print_details(details: Sequence[Mapping[str, Any]], *, as_list: bool) -> None:
|
|
300
|
+
"""Render the objects of a detail view (user get, task get, ...) in a machine format.
|
|
301
|
+
|
|
302
|
+
Detail views print their own Rich tables; this is for json, csv and tsv only.
|
|
303
|
+
|
|
304
|
+
* json: one object, or an array of objects when ``as_list`` (the command took several
|
|
305
|
+
arguments). Values are strings; related records (a user's roles, a task's actions) are
|
|
306
|
+
arrays of objects under their own key.
|
|
307
|
+
* csv, tsv: ``Property``/``Value`` rows through print_rows, one per value, the objects one
|
|
308
|
+
after the other. Values of related records are named by their path, e.g.
|
|
309
|
+
``Roles[0].Name``; a repeated property is ``name[0]``, ``name[1]``.
|
|
310
|
+
|
|
311
|
+
Nothing is written for a single object that could not be read.
|
|
312
|
+
"""
|
|
313
|
+
if get_output_format() == OUTPUT_TABLE:
|
|
314
|
+
raise ValueError('print_details renders machine formats only')
|
|
315
|
+
if not details and not as_list:
|
|
316
|
+
return
|
|
317
|
+
if get_output_format() == OUTPUT_JSON:
|
|
318
|
+
records = [_detail_value(detail) for detail in details]
|
|
319
|
+
payload = records if as_list else records[0]
|
|
320
|
+
sys.stdout.write(json.dumps(payload, indent=2, ensure_ascii=False) + '\n')
|
|
321
|
+
return
|
|
322
|
+
rows: list[dict[str, str]] = []
|
|
323
|
+
for detail in details:
|
|
324
|
+
_flatten_detail('', detail, rows)
|
|
325
|
+
print_rows(rows, title='', noun='properties', columns=DETAIL_COLUMNS)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def print_xml(xml_text: str, line_numbers: bool = False) -> None:
|
|
329
|
+
"""Print XML with syntax highlighting."""
|
|
330
|
+
if is_tty():
|
|
331
|
+
syntax = Syntax(xml_text, 'xml', theme='monokai', line_numbers=line_numbers)
|
|
332
|
+
get_console().print(syntax, markup=False, soft_wrap=True)
|
|
333
|
+
else:
|
|
334
|
+
# Piped output must be byte-identical to what the server returned:
|
|
335
|
+
# no markup interpretation, no wrapping at the console width.
|
|
336
|
+
sys.stdout.write(xml_text if xml_text.endswith('\n') else xml_text + '\n')
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
class PlainTextTable(Table):
|
|
340
|
+
"""Rich table whose plain string cells are rendered literally.
|
|
341
|
+
|
|
342
|
+
Cell values usually come from the server (names, descriptions, ...) and may
|
|
343
|
+
contain square brackets; they must never be interpreted as Rich markup.
|
|
344
|
+
Callers wanting styled cells pass a ``rich.text.Text`` instead.
|
|
345
|
+
"""
|
|
346
|
+
|
|
347
|
+
def add_row(
|
|
348
|
+
self,
|
|
349
|
+
*renderables: RenderableType | None,
|
|
350
|
+
style: StyleType | None = None,
|
|
351
|
+
end_section: bool = False,
|
|
352
|
+
) -> None:
|
|
353
|
+
cells = [Text(cell) if isinstance(cell, str) else cell for cell in renderables]
|
|
354
|
+
super().add_row(*cells, style=style, end_section=end_section)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def create_table(title: str | None = None, show_header: bool = True) -> Table:
|
|
358
|
+
"""Create a Rich table with consistent styling; string cells are not parsed as markup."""
|
|
359
|
+
table = PlainTextTable(title=title, show_header=show_header, header_style='bold cyan', border_style='blue')
|
|
360
|
+
return table
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _format_clock(seconds: float) -> str:
|
|
364
|
+
"""Format a duration as H:MM:SS, or M:SS under an hour."""
|
|
365
|
+
hours, remainder = divmod(int(seconds), 3600)
|
|
366
|
+
minutes, secs = divmod(remainder, 60)
|
|
367
|
+
return f'{hours:d}:{minutes:02d}:{secs:02d}' if hours > 0 else f'{minutes:d}:{secs:02d}'
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _status_print(*values: object, end: str = '\n', flush: bool = False) -> None:
|
|
371
|
+
"""print() for waiting indicators and progress, which belong on stderr with the other messages."""
|
|
372
|
+
print(*values, end=end, flush=flush, file=sys.stderr)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
class Spinner:
|
|
376
|
+
"""Reusable animated spinner using Rich for TTY/console output."""
|
|
377
|
+
|
|
378
|
+
def __init__(self) -> None:
|
|
379
|
+
self._console = get_error_console()
|
|
380
|
+
self._status: Status | None = None
|
|
381
|
+
self._message = ''
|
|
382
|
+
|
|
383
|
+
def start(self, message_callback: Callable[[str], str]) -> None:
|
|
384
|
+
"""
|
|
385
|
+
Start spinner animation using Rich status.
|
|
386
|
+
|
|
387
|
+
Args:
|
|
388
|
+
message_callback: Function that receives spinner frame and returns full message to display
|
|
389
|
+
"""
|
|
390
|
+
# Rich handles the spinner frame internally, so we just need the base message
|
|
391
|
+
self._message = message_callback('')
|
|
392
|
+
if is_stderr_tty():
|
|
393
|
+
status = self._console.status(self._message, spinner='dots')
|
|
394
|
+
try:
|
|
395
|
+
status.__enter__()
|
|
396
|
+
except LiveError:
|
|
397
|
+
# Rich before 14 allows one live display per console: while a task progress
|
|
398
|
+
# bar is shown, the bar's own spinner keeps moving instead.
|
|
399
|
+
return
|
|
400
|
+
self._status = status
|
|
401
|
+
else:
|
|
402
|
+
# For non-TTY, just print the message
|
|
403
|
+
_status_print(self._message, end='', flush=True)
|
|
404
|
+
|
|
405
|
+
def update(self, message: str) -> None:
|
|
406
|
+
"""Change the message shown next to the spinner (TTY only)."""
|
|
407
|
+
self._message = message
|
|
408
|
+
if self._status:
|
|
409
|
+
self._status.update(message)
|
|
410
|
+
|
|
411
|
+
def stop(self, clear_length: int = 80) -> None:
|
|
412
|
+
"""
|
|
413
|
+
Stop the spinner and clear the line.
|
|
414
|
+
|
|
415
|
+
Args:
|
|
416
|
+
clear_length: Number of characters to clear (default 80)
|
|
417
|
+
"""
|
|
418
|
+
if self._status:
|
|
419
|
+
self._status.__exit__(None, None, None)
|
|
420
|
+
self._status = None
|
|
421
|
+
elif not is_stderr_tty():
|
|
422
|
+
# Clear the non-TTY message
|
|
423
|
+
_status_print('\r' + ' ' * clear_length + '\r', end='', flush=True)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
class WaitingIndicator(ABC):
|
|
427
|
+
"""Abstract base class for waiting indicators."""
|
|
428
|
+
|
|
429
|
+
@abstractmethod
|
|
430
|
+
def start(self) -> None:
|
|
431
|
+
"""Start displaying the waiting indicator."""
|
|
432
|
+
pass
|
|
433
|
+
|
|
434
|
+
@abstractmethod
|
|
435
|
+
def update(self) -> None:
|
|
436
|
+
"""Update the waiting indicator (called on each retry)."""
|
|
437
|
+
pass
|
|
438
|
+
|
|
439
|
+
@abstractmethod
|
|
440
|
+
def stop(self) -> None:
|
|
441
|
+
"""Stop displaying the waiting indicator."""
|
|
442
|
+
pass
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
class SpinnerWaitingIndicator(WaitingIndicator):
|
|
446
|
+
"""Animated spinner indicator for TTY/console output, with the retry count and time waited."""
|
|
447
|
+
|
|
448
|
+
MESSAGE = 'Waiting for http service'
|
|
449
|
+
|
|
450
|
+
def __init__(self) -> None:
|
|
451
|
+
self._spinner = Spinner()
|
|
452
|
+
self._retries = 0
|
|
453
|
+
self._started_at = 0.0
|
|
454
|
+
|
|
455
|
+
def start(self) -> None:
|
|
456
|
+
"""Start the animated spinner."""
|
|
457
|
+
self._retries = 0
|
|
458
|
+
self._started_at = time.monotonic()
|
|
459
|
+
self._spinner.start(lambda frame: f'[info]{self.MESSAGE}...[/info]')
|
|
460
|
+
|
|
461
|
+
def update(self) -> None:
|
|
462
|
+
"""Count a retry and show it with the time waited so far."""
|
|
463
|
+
self._retries += 1
|
|
464
|
+
waited = _format_clock(time.monotonic() - self._started_at)
|
|
465
|
+
self._spinner.update(f'[info]{self.MESSAGE}...[/info] [dim]retry {self._retries} · {waited}[/dim]')
|
|
466
|
+
|
|
467
|
+
def stop(self) -> None:
|
|
468
|
+
"""Stop the animated spinner and clear the line."""
|
|
469
|
+
self._spinner.stop(clear_length=40)
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
class DottedWaitingIndicator(WaitingIndicator):
|
|
473
|
+
"""Classic dotted indicator for file redirection/non-TTY output."""
|
|
474
|
+
|
|
475
|
+
def start(self) -> None:
|
|
476
|
+
"""Print initial waiting message."""
|
|
477
|
+
_status_print('Waiting for http service...', end='', flush=True)
|
|
478
|
+
|
|
479
|
+
def update(self) -> None:
|
|
480
|
+
"""Print a dot on each update."""
|
|
481
|
+
_status_print('.', end='', flush=True)
|
|
482
|
+
|
|
483
|
+
def stop(self) -> None:
|
|
484
|
+
"""Print newline to finish the line."""
|
|
485
|
+
_status_print('', flush=True)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
class ConsoleDisplay(MidpointCommunicationObserver):
|
|
489
|
+
def __init__(self) -> None:
|
|
490
|
+
self._waiting = False
|
|
491
|
+
# Detect if output is a TTY (console) or file redirection
|
|
492
|
+
self._indicator = SpinnerWaitingIndicator() if is_stderr_tty() else DottedWaitingIndicator()
|
|
493
|
+
|
|
494
|
+
def on_http_error(self) -> None:
|
|
495
|
+
if not self._waiting:
|
|
496
|
+
self._indicator.start()
|
|
497
|
+
self._waiting = True
|
|
498
|
+
else:
|
|
499
|
+
self._indicator.update()
|
|
500
|
+
|
|
501
|
+
def on_http_success(self) -> None:
|
|
502
|
+
if self._waiting:
|
|
503
|
+
self._indicator.stop()
|
|
504
|
+
self._waiting = False
|
|
505
|
+
|
|
506
|
+
def on_http_failure(self) -> None:
|
|
507
|
+
# Retries gave up: end the indicator so the error that follows stands on its own line.
|
|
508
|
+
self.on_http_success()
|
|
509
|
+
|
|
510
|
+
def on_http_call(self) -> None:
|
|
511
|
+
pass
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
# Progress Monitors
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
class CustomTimeElapsedColumn(ProgressColumn):
|
|
518
|
+
"""Custom time elapsed column that supports a start time offset."""
|
|
519
|
+
|
|
520
|
+
def __init__(self, task_start_time: datetime | None = None) -> None:
|
|
521
|
+
super().__init__()
|
|
522
|
+
self._task_start_time = task_start_time
|
|
523
|
+
self._offset_seconds = 0.0
|
|
524
|
+
if task_start_time:
|
|
525
|
+
# Calculate how many seconds have elapsed since task started
|
|
526
|
+
now = _get_now_matching_timezone(task_start_time)
|
|
527
|
+
self._offset_seconds = (now - task_start_time).total_seconds()
|
|
528
|
+
|
|
529
|
+
def render(self, task: Task) -> Text:
|
|
530
|
+
"""Render elapsed time including the offset from task start."""
|
|
531
|
+
elapsed = task.finished_time if task.finished else task.elapsed
|
|
532
|
+
if elapsed is None:
|
|
533
|
+
elapsed = 0.0
|
|
534
|
+
|
|
535
|
+
# Add the offset to show total time since task started
|
|
536
|
+
return Text(_format_clock(elapsed + self._offset_seconds), style='progress.elapsed')
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
# Progress marks: one per processed item, a distinct one for each failure.
|
|
540
|
+
SUCCESS_MARK = '.'
|
|
541
|
+
FAILURE_MARK = 'F'
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
class OutcomeMarks:
|
|
545
|
+
"""Turn progress and failure counts, polled from the server, into one mark per processed item.
|
|
546
|
+
|
|
547
|
+
The server only reports totals: the progress (items processed) and the failed actions of
|
|
548
|
+
the task statistics. The items processed since the last poll are marked as failures up to
|
|
549
|
+
the number of new failures, the others as successes. Failures beyond the new items (one
|
|
550
|
+
item may fail several actions) are marked on later items, so no failure goes unmarked
|
|
551
|
+
while items keep coming.
|
|
552
|
+
"""
|
|
553
|
+
|
|
554
|
+
def __init__(self) -> None:
|
|
555
|
+
self.progress = 0
|
|
556
|
+
self.failures_marked = 0
|
|
557
|
+
|
|
558
|
+
def advance_to(self, progress: int, failure_count: int) -> list[bool]:
|
|
559
|
+
"""The marks of the items processed since the last call, True for a failure."""
|
|
560
|
+
new_items = max(progress - self.progress, 0)
|
|
561
|
+
failures = min(max(failure_count - self.failures_marked, 0), new_items)
|
|
562
|
+
self.progress += new_items
|
|
563
|
+
self.failures_marked += failures
|
|
564
|
+
return [False] * (new_items - failures) + [True] * failures
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
class DottedProgressMonitor(ProgressMonitor):
|
|
568
|
+
"""Dotted progress monitor for file redirection/non-TTY output.
|
|
569
|
+
|
|
570
|
+
Prints one mark per processed item: ``icon`` for a success, ``failure_icon`` for a failure.
|
|
571
|
+
"""
|
|
572
|
+
|
|
573
|
+
def __init__(self, width: int = 80, icon: str = SUCCESS_MARK, failure_icon: str = FAILURE_MARK) -> None:
|
|
574
|
+
self._progress = 0
|
|
575
|
+
self._success_count = 0
|
|
576
|
+
self._failure_count = 0
|
|
577
|
+
self._width = width
|
|
578
|
+
self._icon = icon
|
|
579
|
+
self._failure_icon = failure_icon
|
|
580
|
+
self._marks = OutcomeMarks()
|
|
581
|
+
self._task_start_time: datetime | None = None
|
|
582
|
+
self._start_time = time.time()
|
|
583
|
+
# Set when log_event has interrupted the dot row; next advance must
|
|
584
|
+
# re-emit the "Progress: " prefix on a new line.
|
|
585
|
+
self._needs_progress_prefix = False
|
|
586
|
+
|
|
587
|
+
def set_task_start_time(self, start_time: datetime | None) -> None:
|
|
588
|
+
"""Set the task's original start time for elapsed time calculation."""
|
|
589
|
+
self._task_start_time = start_time
|
|
590
|
+
|
|
591
|
+
def update(self, progress: int) -> None:
|
|
592
|
+
self.update_with_stats(progress, self._success_count, self._failure_count)
|
|
593
|
+
|
|
594
|
+
def update_with_stats(self, progress: int, success_count: int = 0, failure_count: int = 0) -> None:
|
|
595
|
+
"""Update with progress and success/failure statistics, marking each new item."""
|
|
596
|
+
self._success_count = success_count
|
|
597
|
+
self._failure_count = failure_count
|
|
598
|
+
self._marks.progress = self._progress
|
|
599
|
+
for failed in self._marks.advance_to(progress, failure_count):
|
|
600
|
+
self.advance(self._failure_icon if failed else self._icon)
|
|
601
|
+
|
|
602
|
+
def log_event(self, event: Mapping[str, Any]) -> None:
|
|
603
|
+
# Break out of the in-flight dot row so the event line stands on its own.
|
|
604
|
+
if self._progress > 0 and self._progress % self._width != 0:
|
|
605
|
+
_status_print()
|
|
606
|
+
_status_print(str(format_audit_event_line(event)), flush=True)
|
|
607
|
+
if self._progress > 0:
|
|
608
|
+
self._needs_progress_prefix = True
|
|
609
|
+
|
|
610
|
+
def advance(self, icon: str | None = None) -> None:
|
|
611
|
+
if self._needs_progress_prefix:
|
|
612
|
+
_status_print('Progress: ', end='', flush=True)
|
|
613
|
+
self._needs_progress_prefix = False
|
|
614
|
+
elif self._progress % self._width == 0:
|
|
615
|
+
if self._progress > 0:
|
|
616
|
+
# Include stats in the progress line if available
|
|
617
|
+
if self._success_count > 0 or self._failure_count > 0:
|
|
618
|
+
_status_print(
|
|
619
|
+
f' {self._progress:7d} (Progress: {self._progress}, Success: {self._success_count}, Errors: {self._failure_count})'
|
|
620
|
+
)
|
|
621
|
+
else:
|
|
622
|
+
_status_print(f' {self._progress:7d}')
|
|
623
|
+
|
|
624
|
+
_status_print('Progress: ', end='', flush=True)
|
|
625
|
+
|
|
626
|
+
_status_print(icon or self._icon, end='', flush=True)
|
|
627
|
+
self._progress += 1
|
|
628
|
+
|
|
629
|
+
def __enter__(self) -> 'DottedProgressMonitor':
|
|
630
|
+
self._start_time = time.time()
|
|
631
|
+
return self
|
|
632
|
+
|
|
633
|
+
def __exit__(
|
|
634
|
+
self,
|
|
635
|
+
exc_type: type[BaseException] | None,
|
|
636
|
+
exc_val: BaseException | None,
|
|
637
|
+
exc_tb: TracebackType | None,
|
|
638
|
+
) -> None:
|
|
639
|
+
_status_print()
|
|
640
|
+
# Build summary message with statistics if available
|
|
641
|
+
if self._success_count > 0 or self._failure_count > 0:
|
|
642
|
+
summary = (
|
|
643
|
+
f'Total progress: {self._progress} (Success: {self._success_count}, Errors: {self._failure_count})'
|
|
644
|
+
)
|
|
645
|
+
else:
|
|
646
|
+
summary = f'Total progress: {self._progress}'
|
|
647
|
+
|
|
648
|
+
# Add elapsed time if we have a task start time
|
|
649
|
+
if self._task_start_time:
|
|
650
|
+
# Calculate total time since task started
|
|
651
|
+
now = _get_now_matching_timezone(self._task_start_time)
|
|
652
|
+
total_elapsed = (now - self._task_start_time).total_seconds()
|
|
653
|
+
if self._success_count > 0 or self._failure_count > 0:
|
|
654
|
+
summary = f'Total progress: {self._progress} (Success: {self._success_count}, Errors: {self._failure_count}, Task running for {self._format_elapsed(total_elapsed)})'
|
|
655
|
+
else:
|
|
656
|
+
summary = f'Total progress: {self._progress} (Task running for {self._format_elapsed(total_elapsed)})'
|
|
657
|
+
|
|
658
|
+
_status_print(summary)
|
|
659
|
+
|
|
660
|
+
def _format_elapsed(self, seconds: float) -> str:
|
|
661
|
+
"""Format elapsed time as HH:MM:SS or MM:SS."""
|
|
662
|
+
return _format_clock(seconds)
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def outcome_marks_text(marks: Iterable[bool]) -> Text:
|
|
666
|
+
"""Render progress marks: a green dot per success, a bold red F per failure."""
|
|
667
|
+
text = Text()
|
|
668
|
+
for failed in marks:
|
|
669
|
+
if failed:
|
|
670
|
+
text.append(FAILURE_MARK, style='bold red')
|
|
671
|
+
else:
|
|
672
|
+
text.append(SUCCESS_MARK, style='green')
|
|
673
|
+
return text
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
class RecentOutcomesColumn(ProgressColumn):
|
|
677
|
+
"""The marks of the most recently processed items, read from the ``marks`` task field."""
|
|
678
|
+
|
|
679
|
+
def __init__(self, width: int) -> None:
|
|
680
|
+
super().__init__(table_column=Column(no_wrap=True, min_width=width))
|
|
681
|
+
|
|
682
|
+
def render(self, task: Task) -> Text:
|
|
683
|
+
return outcome_marks_text(task.fields.get('marks', ()))
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
class ProcessedCountColumn(ProgressColumn):
|
|
687
|
+
"""Items processed: ``42``, or ``42/100 42%`` when the task knows how many to expect."""
|
|
688
|
+
|
|
689
|
+
def render(self, task: Task) -> Text:
|
|
690
|
+
completed = int(task.completed)
|
|
691
|
+
if task.total:
|
|
692
|
+
percent = min(100, int(completed * 100 / task.total))
|
|
693
|
+
return Text(f'{completed}/{int(task.total)} {percent:>3d}%', style='progress.download')
|
|
694
|
+
return Text(str(completed), style='progress.download')
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
class OutcomeCountsColumn(ProgressColumn):
|
|
698
|
+
"""Successes and failures so far, from the ``success`` and ``failure`` task fields."""
|
|
699
|
+
|
|
700
|
+
def render(self, task: Task) -> Text:
|
|
701
|
+
return outcome_counts_text(task.fields.get('success', 0), task.fields.get('failure', 0))
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
def outcome_counts_text(success_count: int, failure_count: int) -> Text:
|
|
705
|
+
"""``✓ 40 ✗ 2``: successes in green, failures in red, or dimmed while there are none."""
|
|
706
|
+
text = Text()
|
|
707
|
+
if success_count or failure_count:
|
|
708
|
+
text.append(f'✓ {success_count}', style='green')
|
|
709
|
+
text.append(' ')
|
|
710
|
+
text.append(f'✗ {failure_count}', style='bold red' if failure_count else 'dim')
|
|
711
|
+
return text
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
class AnimatedProgressMonitor(ProgressMonitor):
|
|
715
|
+
"""Animated progress monitor using Rich Progress for TTY/console output.
|
|
716
|
+
|
|
717
|
+
One line on stderr: a spinner, a bar (filled against the expected total when the task
|
|
718
|
+
has one, pulsing otherwise), the items processed, the success and failure counts, the
|
|
719
|
+
marks of the latest items and the time since the task started. It is replaced by a
|
|
720
|
+
summary line when the task ends.
|
|
721
|
+
"""
|
|
722
|
+
|
|
723
|
+
# Number of recent item marks shown next to the bar.
|
|
724
|
+
RECENT_MARKS = 16
|
|
725
|
+
BAR_WIDTH = 24
|
|
726
|
+
|
|
727
|
+
def __init__(self) -> None:
|
|
728
|
+
self._progress_value = 0
|
|
729
|
+
self._success_count = 0
|
|
730
|
+
self._failure_count = 0
|
|
731
|
+
self._expected_total: int | None = None
|
|
732
|
+
self._progress: Progress | None = None
|
|
733
|
+
self._task: TaskID | None = None
|
|
734
|
+
self._console = get_error_console()
|
|
735
|
+
self._task_start_time: datetime | None = None
|
|
736
|
+
self._marks = OutcomeMarks()
|
|
737
|
+
self._recent_marks: deque[bool] = deque(maxlen=self.RECENT_MARKS)
|
|
738
|
+
|
|
739
|
+
def set_task_start_time(self, start_time: datetime | None) -> None:
|
|
740
|
+
"""Set the task's original start time for elapsed time calculation."""
|
|
741
|
+
self._task_start_time = start_time
|
|
742
|
+
|
|
743
|
+
def set_expected_total(self, total: int | None) -> None:
|
|
744
|
+
self._expected_total = total
|
|
745
|
+
if self._progress is not None and self._task is not None:
|
|
746
|
+
self._progress.update(self._task, total=total)
|
|
747
|
+
|
|
748
|
+
def update(self, progress: int) -> None:
|
|
749
|
+
self.update_with_stats(progress, self._success_count, self._failure_count)
|
|
750
|
+
|
|
751
|
+
def update_with_stats(self, progress: int, success_count: int = 0, failure_count: int = 0) -> None:
|
|
752
|
+
"""Update with progress and success/failure statistics."""
|
|
753
|
+
self._progress_value = progress
|
|
754
|
+
self._success_count = success_count
|
|
755
|
+
self._failure_count = failure_count
|
|
756
|
+
self._recent_marks.extend(self._marks.advance_to(progress, failure_count))
|
|
757
|
+
if self._progress is not None and self._task is not None:
|
|
758
|
+
self._progress.update(
|
|
759
|
+
self._task,
|
|
760
|
+
completed=progress,
|
|
761
|
+
success=success_count,
|
|
762
|
+
failure=failure_count,
|
|
763
|
+
marks=tuple(self._recent_marks),
|
|
764
|
+
)
|
|
765
|
+
|
|
766
|
+
def log_event(self, event: Mapping[str, Any]) -> None:
|
|
767
|
+
# Rich Progress.console.print weaves the line above the live progress bar
|
|
768
|
+
# without breaking the spinner's redraw.
|
|
769
|
+
if self._progress is not None:
|
|
770
|
+
self._progress.console.print(format_audit_event_line(event))
|
|
771
|
+
else:
|
|
772
|
+
# Without a live display the line is a plain stderr message: never wrapped.
|
|
773
|
+
self._console.print(format_audit_event_line(event), soft_wrap=True)
|
|
774
|
+
|
|
775
|
+
def __enter__(self) -> 'AnimatedProgressMonitor':
|
|
776
|
+
if is_stderr_tty():
|
|
777
|
+
self._progress = Progress(
|
|
778
|
+
SpinnerColumn(),
|
|
779
|
+
BarColumn(bar_width=self.BAR_WIDTH),
|
|
780
|
+
ProcessedCountColumn(),
|
|
781
|
+
OutcomeCountsColumn(),
|
|
782
|
+
RecentOutcomesColumn(self.RECENT_MARKS),
|
|
783
|
+
CustomTimeElapsedColumn(self._task_start_time),
|
|
784
|
+
console=self._console,
|
|
785
|
+
transient=True,
|
|
786
|
+
)
|
|
787
|
+
self._progress.__enter__()
|
|
788
|
+
self._task = self._progress.add_task('Processing', total=self._expected_total)
|
|
789
|
+
return self
|
|
790
|
+
|
|
791
|
+
def __exit__(
|
|
792
|
+
self,
|
|
793
|
+
exc_type: type[BaseException] | None,
|
|
794
|
+
exc_val: BaseException | None,
|
|
795
|
+
exc_tb: TracebackType | None,
|
|
796
|
+
) -> None:
|
|
797
|
+
if self._progress:
|
|
798
|
+
self._progress.__exit__(exc_type, exc_val, exc_tb)
|
|
799
|
+
self._console.print(self._summary(), soft_wrap=True)
|
|
800
|
+
|
|
801
|
+
def _summary(self) -> Text:
|
|
802
|
+
"""``Total progress: 40 (✓ 38 ✗ 2) (Task running for 0:31)``."""
|
|
803
|
+
summary = Text(f'Total progress: {self._progress_value}')
|
|
804
|
+
if self._expected_total:
|
|
805
|
+
summary.append(f'/{self._expected_total}')
|
|
806
|
+
if self._success_count > 0 or self._failure_count > 0:
|
|
807
|
+
summary.append(' (')
|
|
808
|
+
summary.append_text(outcome_counts_text(self._success_count, self._failure_count))
|
|
809
|
+
summary.append(')')
|
|
810
|
+
if self._task_start_time:
|
|
811
|
+
now = _get_now_matching_timezone(self._task_start_time)
|
|
812
|
+
summary.append(f' (Task running for {_format_clock((now - self._task_start_time).total_seconds())})')
|
|
813
|
+
return summary
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
def create_progress_monitor() -> ProgressMonitor:
|
|
817
|
+
"""The progress monitor of a task followed to completion: animated on a terminal, dotted otherwise."""
|
|
818
|
+
return AnimatedProgressMonitor() if is_stderr_tty() else DottedProgressMonitor()
|