tlnt-cli 1.5.1__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 (55) hide show
  1. tlnt/__init__.py +3 -0
  2. tlnt/__main__.py +4 -0
  3. tlnt/browser/__init__.py +0 -0
  4. tlnt/browser/login.py +414 -0
  5. tlnt/browser/oauth.py +50 -0
  6. tlnt/browser/scripted.py +183 -0
  7. tlnt/cli/__init__.py +0 -0
  8. tlnt/cli/avatars.py +51 -0
  9. tlnt/cli/commands/__init__.py +0 -0
  10. tlnt/cli/commands/approvals.py +98 -0
  11. tlnt/cli/commands/attendance.py +283 -0
  12. tlnt/cli/commands/calendar.py +108 -0
  13. tlnt/cli/commands/login.py +225 -0
  14. tlnt/cli/commands/logout.py +12 -0
  15. tlnt/cli/commands/mcp.py +8 -0
  16. tlnt/cli/commands/notifications.py +102 -0
  17. tlnt/cli/commands/profile.py +13 -0
  18. tlnt/cli/commands/setup.py +12 -0
  19. tlnt/cli/commands/status.py +12 -0
  20. tlnt/cli/commands/timeoff.py +491 -0
  21. tlnt/cli/commands/update.py +86 -0
  22. tlnt/cli/commands/version.py +15 -0
  23. tlnt/cli/main.py +163 -0
  24. tlnt/cli/options.py +56 -0
  25. tlnt/cli/output.py +321 -0
  26. tlnt/cli/prompts.py +76 -0
  27. tlnt/core/__init__.py +0 -0
  28. tlnt/core/clients/__init__.py +0 -0
  29. tlnt/core/clients/mekari/__init__.py +0 -0
  30. tlnt/core/clients/mekari/client.py +826 -0
  31. tlnt/core/clients/mekari/constants.py +174 -0
  32. tlnt/core/config.py +53 -0
  33. tlnt/core/http_client.py +110 -0
  34. tlnt/core/logs.py +34 -0
  35. tlnt/core/models.py +84 -0
  36. tlnt/core/play_store.py +85 -0
  37. tlnt/core/services/__init__.py +46 -0
  38. tlnt/core/services/attendance.py +213 -0
  39. tlnt/core/services/calendar.py +54 -0
  40. tlnt/core/services/login.py +100 -0
  41. tlnt/core/services/logout.py +38 -0
  42. tlnt/core/services/notifications.py +97 -0
  43. tlnt/core/services/profile.py +11 -0
  44. tlnt/core/services/setup.py +85 -0
  45. tlnt/core/services/status.py +35 -0
  46. tlnt/core/services/timeoff.py +146 -0
  47. tlnt/core/session.py +189 -0
  48. tlnt/core/user_agent.py +71 -0
  49. tlnt/mcp/__init__.py +0 -0
  50. tlnt/mcp/main.py +176 -0
  51. tlnt_cli-1.5.1.dist-info/METADATA +315 -0
  52. tlnt_cli-1.5.1.dist-info/RECORD +55 -0
  53. tlnt_cli-1.5.1.dist-info/WHEEL +4 -0
  54. tlnt_cli-1.5.1.dist-info/entry_points.txt +3 -0
  55. tlnt_cli-1.5.1.dist-info/licenses/LICENSE +21 -0
tlnt/cli/main.py ADDED
@@ -0,0 +1,163 @@
1
+ """Typer command-line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Annotated
7
+
8
+ import typer
9
+
10
+ from tlnt.cli.commands.attendance import (
11
+ attendance_approve,
12
+ attendance_clockin,
13
+ attendance_clockout,
14
+ attendance_summary_list,
15
+ attendance_summary_metrics,
16
+ )
17
+ from tlnt.cli.commands.calendar import (
18
+ calendar_activity,
19
+ calendar_birthday,
20
+ calendar_holiday,
21
+ calendar_index,
22
+ calendar_my_birthday,
23
+ calendar_timeoff,
24
+ )
25
+ from tlnt.cli.commands.login import login
26
+ from tlnt.cli.commands.logout import logout
27
+ from tlnt.cli.commands.mcp import mcp
28
+ from tlnt.cli.commands.notifications import (
29
+ notification_attendance_list,
30
+ notification_count,
31
+ notification_detail,
32
+ notification_list,
33
+ notification_timeoff_list,
34
+ )
35
+ from tlnt.cli.commands.timeoff import (
36
+ timeoff_approve,
37
+ timeoff_balance,
38
+ timeoff_cancel,
39
+ timeoff_delegation_list,
40
+ timeoff_detail,
41
+ timeoff_list,
42
+ timeoff_request,
43
+ timeoff_types_list,
44
+ )
45
+ from tlnt.cli.commands.profile import profile
46
+ from tlnt.cli.commands.setup import setup
47
+ from tlnt.cli.commands.status import status
48
+ from tlnt.cli.commands import update as update_command
49
+ from tlnt.cli.commands.update import update
50
+ from tlnt.cli.commands.version import print_version
51
+ from tlnt.cli.options import VerboseOption
52
+ from tlnt.core import config, play_store
53
+ from tlnt.core.clients.mekari import constants as mekari_constants
54
+ from tlnt.core.logs import configure_logging
55
+ from tlnt.core.logs import logger
56
+
57
+ # Define the main app and sub-apps
58
+ app = typer.Typer(
59
+ no_args_is_help=True,
60
+ help=(
61
+ "[bold cyan]tlnt[/] — unofficial CLI for Mekari Talenta.\n\n"
62
+ "[yellow]Use at your own risk:[/] this tool signs in as you and can "
63
+ "submit real attendance and time-off requests on your behalf."
64
+ ),
65
+ epilog=(
66
+ "Docs: https://github.com/fathirazarthuro/tlnt — add [cyan]--json[/] for "
67
+ "machine output."
68
+ ),
69
+ )
70
+ attendance_app = typer.Typer(no_args_is_help=True, help="Manage attendance.")
71
+ notification_app = typer.Typer(no_args_is_help=True, help="Read notifications.")
72
+ timeoff_app = typer.Typer(no_args_is_help=True, help="Manage time-off data and requests.")
73
+ calendar_app = typer.Typer(no_args_is_help=True, help="Read calendar data.")
74
+
75
+ # Add root commands to the main app
76
+ app.command(rich_help_panel="Session")(setup)
77
+ app.command(rich_help_panel="Session")(login)
78
+ app.command(rich_help_panel="Session")(logout)
79
+ app.command(rich_help_panel="Session")(status)
80
+ app.command(rich_help_panel="Session")(profile)
81
+ app.command(rich_help_panel="Session")(update)
82
+ app.command(rich_help_panel="Integrations")(mcp)
83
+
84
+ # Add commands to the attendance sub-app
85
+ attendance_app.command(name="clock-in")(attendance_clockin)
86
+ attendance_app.command(name="clock-out")(attendance_clockout)
87
+ attendance_app.command(name="summary-list")(attendance_summary_list)
88
+ attendance_app.command(name="summary-metrics")(attendance_summary_metrics)
89
+ attendance_app.command(name="approve")(attendance_approve)
90
+ app.add_typer(attendance_app, name="attendance", rich_help_panel="Workflows")
91
+
92
+ # Add commands to the notification sub-app
93
+ notification_app.command(name="count")(notification_count)
94
+ notification_app.command(name="list")(notification_list)
95
+ notification_app.command(name="attendance-list")(notification_attendance_list)
96
+ notification_app.command(name="timeoff-list")(notification_timeoff_list)
97
+ notification_app.command(name="detail")(notification_detail)
98
+ app.add_typer(notification_app, name="notification", rich_help_panel="Workflows")
99
+
100
+ timeoff_app.command(name="types-list")(timeoff_types_list)
101
+ timeoff_app.command(name="balance")(timeoff_balance)
102
+ timeoff_app.command(name="delegation-list")(timeoff_delegation_list)
103
+ timeoff_app.command(name="list")(timeoff_list)
104
+ timeoff_app.command(name="detail")(timeoff_detail)
105
+ timeoff_app.command(name="request")(timeoff_request)
106
+ timeoff_app.command(name="cancel")(timeoff_cancel)
107
+ timeoff_app.command(name="approve")(timeoff_approve)
108
+ app.add_typer(timeoff_app, name="timeoff", rich_help_panel="Workflows")
109
+
110
+ calendar_app.command(name="holiday")(calendar_holiday)
111
+ calendar_app.command(name="timeoff")(calendar_timeoff)
112
+ calendar_app.command(name="index")(calendar_index)
113
+ calendar_app.command(name="my-birthday")(calendar_my_birthday)
114
+ calendar_app.command(name="birthday")(calendar_birthday)
115
+ calendar_app.command(name="activity")(calendar_activity)
116
+ app.add_typer(calendar_app, name="calendar", rich_help_panel="Workflows")
117
+
118
+
119
+ _log = logger.bind(component="cli")
120
+
121
+ @app.callback()
122
+ def main_callback(
123
+ ctx: typer.Context,
124
+ config_path: Annotated[
125
+ Path | None,
126
+ typer.Option(
127
+ "--config",
128
+ help="Use a specific config.json file.",
129
+ ),
130
+ ] = None,
131
+ _version: Annotated[
132
+ bool,
133
+ typer.Option(
134
+ "--version",
135
+ help="Show the installed tlnt version and exit.",
136
+ callback=print_version,
137
+ is_eager=True,
138
+ ),
139
+ ] = False,
140
+ verbose: VerboseOption = False,
141
+ ) -> None:
142
+ config.set_config_path(config_path)
143
+ configure_logging(verbose)
144
+ if ctx.invoked_subcommand not in (None, "mcp", "update"):
145
+ for warning in (
146
+ update_command.update_warning(),
147
+ play_store.app_version_warning(mekari_constants.app_version()),
148
+ ):
149
+ if warning:
150
+ typer.echo(warning, err=True)
151
+ if ctx.invoked_subcommand is not None:
152
+ _log.bind(
153
+ event="command_started",
154
+ command=ctx.invoked_subcommand,
155
+ ).debug("command started")
156
+
157
+
158
+ def main() -> None:
159
+ try:
160
+ app()
161
+ except Exception:
162
+ _log.exception("unhandled CLI error")
163
+ raise
tlnt/cli/options.py ADDED
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Annotated
4
+
5
+ import typer
6
+
7
+ from tlnt.core.logs import configure_logging
8
+
9
+
10
+ def _enable_verbose_logging(value: bool) -> bool:
11
+ if value:
12
+ configure_logging(True)
13
+ return value
14
+
15
+
16
+ VerboseOption = Annotated[
17
+ bool,
18
+ typer.Option(
19
+ "--verbose",
20
+ help="Emit structured diagnostic logs to stderr.",
21
+ callback=_enable_verbose_logging,
22
+ rich_help_panel="Diagnostics",
23
+ ),
24
+ ]
25
+
26
+ JsonOption = Annotated[
27
+ bool,
28
+ typer.Option("--json", help="Emit one JSON result.", rich_help_panel="Output"),
29
+ ]
30
+
31
+ TableOption = Annotated[
32
+ bool,
33
+ typer.Option(
34
+ "--table",
35
+ help="Accepted for compatibility; tables are now the default.",
36
+ rich_help_panel="Output",
37
+ ),
38
+ ]
39
+
40
+ AvatarsOption = Annotated[
41
+ bool,
42
+ typer.Option(
43
+ "--avatars",
44
+ help="Draw avatar image URLs as terminal thumbnails. Needs a TTY.",
45
+ rich_help_panel="Output",
46
+ ),
47
+ ]
48
+
49
+ InteractiveOption = Annotated[
50
+ bool,
51
+ typer.Option(
52
+ "--interactive",
53
+ help="Prompt for missing values. Needs a TTY; cannot combine with --json.",
54
+ rich_help_panel="Output",
55
+ ),
56
+ ]
tlnt/cli/output.py ADDED
@@ -0,0 +1,321 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ import threading
6
+ from contextlib import contextmanager
7
+ from typing import Any, Callable, Iterator
8
+
9
+ import typer
10
+ from rich import box
11
+ from rich.console import Console, RenderableType
12
+ from rich.highlighter import ReprHighlighter
13
+ from rich.panel import Panel
14
+ from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn, TimeElapsedColumn
15
+ from rich.prompt import Confirm
16
+ from rich.table import Table
17
+ from rich.text import Text
18
+ from rich.theme import Theme
19
+
20
+ from tlnt.cli.avatars import is_avatar_url, thumbnail
21
+ from tlnt.core.logs import logger
22
+ from tlnt.core.models import Result, dig
23
+
24
+
25
+ _log = logger.bind(component="cli")
26
+
27
+
28
+ class TlntHighlighter(ReprHighlighter):
29
+ """ReprHighlighter plus tlnt-specific value shapes (dates, times, codes)."""
30
+
31
+ highlights = [
32
+ *ReprHighlighter.highlights,
33
+ r"(?P<tlnt_date>\b\d{4}-\d{2}-\d{2}\b)",
34
+ r"(?P<tlnt_time>\b\d{2}:\d{2}(?::\d{2})?\b)",
35
+ r"(?P<tlnt_code>\b[a-z]+(?:_[a-z]+){2,}\b)",
36
+ ]
37
+
38
+
39
+ THEME = Theme(
40
+ {
41
+ "repr.tlnt_date": "bold cyan",
42
+ "repr.tlnt_time": "cyan",
43
+ "repr.tlnt_code": "magenta",
44
+ "success": "green",
45
+ "error": "red",
46
+ "warning": "yellow",
47
+ "field": "bold cyan",
48
+ }
49
+ )
50
+
51
+ console = Console(theme=THEME, highlighter=TlntHighlighter())
52
+ err_console = Console(stderr=True, theme=THEME, highlighter=TlntHighlighter())
53
+
54
+
55
+ def new_table(**kwargs: Any) -> Table:
56
+ """Table with the shared tlnt look."""
57
+ return Table(box=box.SIMPLE_HEAD, header_style="bold cyan", **kwargs)
58
+
59
+ # result.code -> (dotted path into result.data holding the row list, column keys)
60
+ TABLES: dict[str, tuple[str, tuple[str, ...]]] = {
61
+ "notification_list_loaded": ("inbox.data", ("id", "message")),
62
+ "notification_attendance_list_loaded": ("inbox.data", ("id", "message")),
63
+ "notification_timeoff_list_loaded": ("inbox.data", ("id", "message")),
64
+ "timeoff_types_list_loaded": ("timeOffPolicies", ("id", "policy_name")),
65
+ "timeoff_list_loaded": ("time_off_request.data", ("id", "request_id")),
66
+ "timeoff_delegation_list_loaded": ("data", ("value", "name", "job")),
67
+ "timeoff_balance_loaded": (
68
+ "",
69
+ ("id", "attributes.balance", "attributes.is_unlimited_balance"),
70
+ ),
71
+ }
72
+
73
+ _TAG_RE = re.compile(r"<[^>]+>")
74
+
75
+
76
+ def _cell(value: Any, avatars: bool = False) -> RenderableType:
77
+ if avatars and is_avatar_url(value):
78
+ rendered = thumbnail(value)
79
+ if rendered is not None:
80
+ return rendered
81
+ return " ".join(_TAG_RE.sub("", str(value)).split())
82
+
83
+
84
+ def build_table(
85
+ rows: list[dict[str, Any]],
86
+ columns: tuple[str, ...],
87
+ *,
88
+ index: bool = False,
89
+ avatars: bool = False,
90
+ ) -> Table | None:
91
+ resolved = (
92
+ list(columns)
93
+ if not rows
94
+ else [c for c in columns if any(dig(row, c) is not None for row in rows)]
95
+ )
96
+ if not resolved:
97
+ return None
98
+ table = new_table()
99
+ if index:
100
+ table.add_column("#")
101
+ for column in resolved:
102
+ table.add_column(column)
103
+ for position, row in enumerate(rows, start=1):
104
+ cells = [_cell(dig(row, column), avatars) for column in resolved]
105
+ table.add_row(str(position), *cells) if index else table.add_row(*cells)
106
+ return table
107
+
108
+
109
+ def _flatten(data: dict[str, Any], prefix: str = "") -> dict[str, Any]:
110
+ flat: dict[str, Any] = {}
111
+ for key, value in data.items():
112
+ path = f"{prefix}{key}"
113
+ if isinstance(value, dict):
114
+ flat.update(_flatten(value, f"{path}."))
115
+ else:
116
+ flat[path] = value
117
+ return flat
118
+
119
+
120
+ def _is_row_list(value: Any) -> bool:
121
+ """A list of dicts. Empty is allowed: registry columns are known up front."""
122
+ return isinstance(value, list) and all(isinstance(row, dict) for row in value)
123
+
124
+
125
+ def _is_discoverable_rows(value: Any) -> bool:
126
+ """Non-empty list of dicts: columns must be discovered from the rows."""
127
+ return _is_row_list(value) and bool(value)
128
+
129
+
130
+ def _auto_rows_table(rows: list[dict[str, Any]], avatars: bool = False) -> Table:
131
+ flat_rows = [_flatten(row) for row in rows]
132
+ columns = tuple(sorted({key for row in flat_rows for key in row}))
133
+ return build_table(flat_rows, columns, avatars=avatars)
134
+
135
+
136
+ def _field_table(data: dict[str, Any], avatars: bool = False) -> Table:
137
+ table = new_table()
138
+ table.add_column("Field", style="field")
139
+ table.add_column("Value")
140
+ for key, value in _flatten(data).items():
141
+ table.add_row(key, _cell(value, avatars))
142
+ return table
143
+
144
+
145
+ def _avatar_keys(rows: list[dict[str, Any]]) -> tuple[str, ...]:
146
+ """Row keys holding avatar URLs, first-seen order, de-duplicated."""
147
+ return tuple(
148
+ dict.fromkeys(
149
+ key for row in rows for key, value in row.items() if is_avatar_url(value)
150
+ )
151
+ )
152
+
153
+
154
+ def table_for(result: Result, avatars: bool = False) -> Table | None:
155
+ """Best table for this payload; None when JSON is the better shape."""
156
+ spec = TABLES.get(result.code)
157
+ if spec is not None:
158
+ path, columns = spec
159
+ rows = dig(result.data, path)
160
+ if not _is_row_list(rows):
161
+ return None
162
+ if avatars:
163
+ columns += tuple(k for k in _avatar_keys(rows) if k not in columns)
164
+ return build_table(rows, columns, avatars=avatars)
165
+
166
+ data = result.data
167
+ if _is_discoverable_rows(data):
168
+ return _auto_rows_table(data, avatars)
169
+ if isinstance(data, dict) and data:
170
+ if len(data) == 1:
171
+ only = next(iter(data.values()))
172
+ if _is_discoverable_rows(only):
173
+ return _auto_rows_table(only, avatars)
174
+ return _field_table(data, avatars)
175
+ return None
176
+
177
+
178
+ def _print_error(result: Result) -> None:
179
+ if err_console.is_terminal:
180
+ err_console.print(
181
+ Panel(
182
+ Text(result.message),
183
+ title=result.code,
184
+ border_style="error",
185
+ expand=False,
186
+ )
187
+ )
188
+ return
189
+ err_console.print(
190
+ f"{result.code}: {result.message}",
191
+ style="error",
192
+ markup=False,
193
+ highlight=True,
194
+ soft_wrap=True,
195
+ )
196
+
197
+
198
+ def emit(
199
+ result: Result,
200
+ json_output: bool,
201
+ avatars: bool = False,
202
+ ) -> None:
203
+ _log.bind(
204
+ event="command_finished",
205
+ code=result.code,
206
+ ok=result.ok,
207
+ json=json_output,
208
+ ).debug("command finished")
209
+ if json_output:
210
+ typer.echo(
211
+ json.dumps(result.as_dict(), ensure_ascii=False, separators=(",", ":"))
212
+ )
213
+ else:
214
+ out = console if result.ok else err_console
215
+ if result.ok:
216
+ out.print(
217
+ result.message,
218
+ style="success",
219
+ markup=False,
220
+ highlight=True,
221
+ soft_wrap=True,
222
+ )
223
+ else:
224
+ _print_error(result)
225
+ if result.data is not None:
226
+ rendered = table_for(result, avatars and out.is_terminal)
227
+ out.print(rendered) if rendered is not None else out.print_json(data=result.data)
228
+ if not result.ok:
229
+ raise typer.Exit(1)
230
+
231
+
232
+ def confirm(question: str, *, default: bool = False, err: bool = False) -> bool | None:
233
+ """Rich confirm on the stream typer.confirm used. None when stdin is at EOF."""
234
+ target = err_console if err else console
235
+ try:
236
+ return Confirm.ask(question, default=default, console=target)
237
+ except EOFError:
238
+ return None
239
+
240
+
241
+ def banner(title: str, *lines: str, enabled: bool = True) -> None:
242
+ if not enabled:
243
+ return
244
+ err_console.print(
245
+ Panel(
246
+ "\n".join(lines),
247
+ title=f"[bold cyan]{title}[/]",
248
+ border_style="cyan",
249
+ expand=False,
250
+ )
251
+ )
252
+
253
+
254
+ def action_banner(action: str, enabled: bool = True) -> None:
255
+ banner(
256
+ f"tlnt · {action}",
257
+ "This sends a real request to Talenta.",
258
+ "Review the details below before confirming.",
259
+ enabled=enabled,
260
+ )
261
+
262
+
263
+ @contextmanager
264
+ def spinner(label: str, enabled: bool = True) -> Iterator[None]:
265
+ if not enabled or not err_console.is_terminal:
266
+ yield
267
+ return
268
+ with Progress(
269
+ SpinnerColumn(),
270
+ TextColumn("[cyan]{task.description}"),
271
+ TimeElapsedColumn(),
272
+ console=err_console,
273
+ transient=True,
274
+ ) as progress:
275
+ progress.add_task(label, total=None)
276
+ yield
277
+
278
+
279
+ def run(
280
+ label: str,
281
+ action: Callable[[], Result],
282
+ json_output: bool,
283
+ verbose: bool,
284
+ *,
285
+ avatars: bool = False,
286
+ ) -> None:
287
+ """Run a read action behind a spinner (none with --json or --verbose), then emit."""
288
+ with spinner(label, enabled=not (json_output or verbose)):
289
+ result = action()
290
+ emit(result, json_output, avatars=avatars)
291
+
292
+
293
+ @contextmanager
294
+ def deadline_bar(label: str, seconds: int, enabled: bool = True) -> Iterator[None]:
295
+ if not enabled or not err_console.is_terminal:
296
+ yield
297
+ return
298
+ progress = Progress(
299
+ SpinnerColumn(),
300
+ TextColumn("[cyan]{task.description}"),
301
+ BarColumn(),
302
+ TimeElapsedColumn(),
303
+ TextColumn(f"/ {seconds // 60}:{seconds % 60:02d}"),
304
+ console=err_console,
305
+ transient=True,
306
+ )
307
+ task = progress.add_task(label, total=seconds)
308
+ stop = threading.Event()
309
+
310
+ # ponytail: 1s ticker against a known deadline; swap for a real callback
311
+ # if the browser layer ever reports progress.
312
+ def tick() -> None:
313
+ while not stop.wait(1.0):
314
+ progress.advance(task, 1)
315
+
316
+ with progress:
317
+ threading.Thread(target=tick, daemon=True).start()
318
+ try:
319
+ yield
320
+ finally:
321
+ stop.set()
tlnt/cli/prompts.py ADDED
@@ -0,0 +1,76 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from rich.prompt import IntPrompt, Prompt
8
+
9
+ from tlnt.cli.output import banner, build_table, console
10
+ from tlnt.core.models import Result
11
+
12
+
13
+ def stdin_is_tty() -> bool:
14
+ return sys.stdin.isatty()
15
+
16
+
17
+ def guard(json_output: bool, title: str = "") -> Result | None:
18
+ if json_output:
19
+ return Result(
20
+ False,
21
+ "interactive_unavailable",
22
+ "--interactive cannot be combined with --json",
23
+ )
24
+ if not stdin_is_tty():
25
+ return Result(
26
+ False,
27
+ "interactive_unavailable",
28
+ "--interactive needs an interactive terminal",
29
+ )
30
+ if title:
31
+ banner(
32
+ f"tlnt · {title}",
33
+ "Fill in the values below. Flags you already passed are kept.",
34
+ "Press Ctrl+C to abort.",
35
+ )
36
+ return None
37
+
38
+
39
+ def ask_required(label: str, current: str, default: str = "") -> str:
40
+ if current:
41
+ return current
42
+ value = ""
43
+ while not value:
44
+ value = Prompt.ask(label, default=default, console=console).strip()
45
+ return value
46
+
47
+
48
+ def ask_optional(label: str, current: str) -> str:
49
+ if current:
50
+ return current
51
+ return Prompt.ask(label, default="", console=console).strip()
52
+
53
+
54
+ def ask_path(label: str, current: Path | None) -> Path | None:
55
+ if current is not None:
56
+ return current
57
+ while True:
58
+ value = Prompt.ask(label, default="", console=console).strip()
59
+ if not value:
60
+ return None
61
+ path = Path(value)
62
+ if path.is_file():
63
+ return path
64
+ console.print(f"No file at {path}", style="error")
65
+
66
+
67
+ def pick(rows: list[dict[str, Any]], columns: tuple[str, ...], label: str) -> dict[str, Any]:
68
+ table = build_table(rows, columns, index=True)
69
+ console.print(table)
70
+ choice = IntPrompt.ask(
71
+ label,
72
+ choices=[str(n) for n in range(1, len(rows) + 1)],
73
+ show_choices=False,
74
+ console=console,
75
+ )
76
+ return rows[choice - 1]
tlnt/core/__init__.py ADDED
File without changes
File without changes
File without changes