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.
- tlnt/__init__.py +3 -0
- tlnt/__main__.py +4 -0
- tlnt/browser/__init__.py +0 -0
- tlnt/browser/login.py +414 -0
- tlnt/browser/oauth.py +50 -0
- tlnt/browser/scripted.py +183 -0
- tlnt/cli/__init__.py +0 -0
- tlnt/cli/avatars.py +51 -0
- tlnt/cli/commands/__init__.py +0 -0
- tlnt/cli/commands/approvals.py +98 -0
- tlnt/cli/commands/attendance.py +283 -0
- tlnt/cli/commands/calendar.py +108 -0
- tlnt/cli/commands/login.py +225 -0
- tlnt/cli/commands/logout.py +12 -0
- tlnt/cli/commands/mcp.py +8 -0
- tlnt/cli/commands/notifications.py +102 -0
- tlnt/cli/commands/profile.py +13 -0
- tlnt/cli/commands/setup.py +12 -0
- tlnt/cli/commands/status.py +12 -0
- tlnt/cli/commands/timeoff.py +491 -0
- tlnt/cli/commands/update.py +86 -0
- tlnt/cli/commands/version.py +15 -0
- tlnt/cli/main.py +163 -0
- tlnt/cli/options.py +56 -0
- tlnt/cli/output.py +321 -0
- tlnt/cli/prompts.py +76 -0
- tlnt/core/__init__.py +0 -0
- tlnt/core/clients/__init__.py +0 -0
- tlnt/core/clients/mekari/__init__.py +0 -0
- tlnt/core/clients/mekari/client.py +826 -0
- tlnt/core/clients/mekari/constants.py +174 -0
- tlnt/core/config.py +53 -0
- tlnt/core/http_client.py +110 -0
- tlnt/core/logs.py +34 -0
- tlnt/core/models.py +84 -0
- tlnt/core/play_store.py +85 -0
- tlnt/core/services/__init__.py +46 -0
- tlnt/core/services/attendance.py +213 -0
- tlnt/core/services/calendar.py +54 -0
- tlnt/core/services/login.py +100 -0
- tlnt/core/services/logout.py +38 -0
- tlnt/core/services/notifications.py +97 -0
- tlnt/core/services/profile.py +11 -0
- tlnt/core/services/setup.py +85 -0
- tlnt/core/services/status.py +35 -0
- tlnt/core/services/timeoff.py +146 -0
- tlnt/core/session.py +189 -0
- tlnt/core/user_agent.py +71 -0
- tlnt/mcp/__init__.py +0 -0
- tlnt/mcp/main.py +176 -0
- tlnt_cli-1.5.1.dist-info/METADATA +315 -0
- tlnt_cli-1.5.1.dist-info/RECORD +55 -0
- tlnt_cli-1.5.1.dist-info/WHEEL +4 -0
- tlnt_cli-1.5.1.dist-info/entry_points.txt +3 -0
- 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
|