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
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from enum import StrEnum
|
|
5
|
+
from importlib import metadata
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from tlnt import DISTRIBUTION
|
|
11
|
+
from tlnt.browser.login import (
|
|
12
|
+
DEFAULT_CDP_ENDPOINT,
|
|
13
|
+
LOGIN_TIMEOUT_SECONDS,
|
|
14
|
+
Credentials,
|
|
15
|
+
cdp_endpoint,
|
|
16
|
+
)
|
|
17
|
+
from tlnt.browser.scripted import LABELS
|
|
18
|
+
from tlnt.cli import prompts
|
|
19
|
+
from tlnt.cli.output import banner, deadline_bar, emit
|
|
20
|
+
from tlnt.cli.options import JsonOption, TableOption, VerboseOption
|
|
21
|
+
from tlnt.core import config
|
|
22
|
+
from tlnt.core.clients.mekari import constants as mekari_constants
|
|
23
|
+
from tlnt.core.clients.mekari.constants import DASHBOARD_TALENTA_URL
|
|
24
|
+
from tlnt.core.models import Result
|
|
25
|
+
from tlnt.core.services.login import login as login_action
|
|
26
|
+
from tlnt.core.services.status import status as status_action
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Browser(StrEnum):
|
|
30
|
+
chrome = "chrome"
|
|
31
|
+
cdp = "cdp"
|
|
32
|
+
ego = "ego"
|
|
33
|
+
yab = "yab"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _resolve_backend(
|
|
37
|
+
browser: Browser | None,
|
|
38
|
+
cdp: str | None,
|
|
39
|
+
) -> tuple[Browser, str | None]:
|
|
40
|
+
"""Pick the sign-in browser and, for cdp, its normalized endpoint."""
|
|
41
|
+
if cdp and browser not in (None, Browser.cdp):
|
|
42
|
+
raise typer.BadParameter(
|
|
43
|
+
"--cdp only works with --browser cdp", param_hint="--cdp"
|
|
44
|
+
)
|
|
45
|
+
endpoint = cdp or config.get_setting("TALENTA_CDP", "cdp")
|
|
46
|
+
backend = browser or (Browser.cdp if endpoint else Browser.chrome)
|
|
47
|
+
if backend is not Browser.cdp:
|
|
48
|
+
return backend, None
|
|
49
|
+
try:
|
|
50
|
+
return backend, cdp_endpoint(endpoint or DEFAULT_CDP_ENDPOINT)
|
|
51
|
+
except ValueError as error:
|
|
52
|
+
raise typer.BadParameter(str(error), param_hint="--cdp") from error
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _sign_in_texts(
|
|
56
|
+
backend: Browser, signing_in: bool = False, headless: bool = False
|
|
57
|
+
) -> tuple[str, str]:
|
|
58
|
+
"""Banner note and stderr message; ego and Yab open no window to sign in."""
|
|
59
|
+
if backend in (Browser.ego, Browser.yab):
|
|
60
|
+
label = LABELS[backend.value]
|
|
61
|
+
return (
|
|
62
|
+
f"Sign in to Talenta in {label} within five minutes.",
|
|
63
|
+
f"Checking {label} for a Talenta sign-in. If you are not signed "
|
|
64
|
+
f"in, open {DASHBOARD_TALENTA_URL} there and sign in. Login must "
|
|
65
|
+
"complete within five minutes.",
|
|
66
|
+
)
|
|
67
|
+
if signing_in and headless:
|
|
68
|
+
return (
|
|
69
|
+
"No browser window opens; tlnt signs in for you.",
|
|
70
|
+
"Signing in without a browser window. Login must complete within "
|
|
71
|
+
"five minutes.",
|
|
72
|
+
)
|
|
73
|
+
if signing_in:
|
|
74
|
+
return (
|
|
75
|
+
"A browser window will open and tlnt signs in for you.",
|
|
76
|
+
"Opening web browser and signing in with the email you gave. "
|
|
77
|
+
"Login must complete within five minutes.",
|
|
78
|
+
)
|
|
79
|
+
return (
|
|
80
|
+
"A browser window will open; sign in within five minutes.",
|
|
81
|
+
"Opening web browser. Sign in to your Talenta account in the opened "
|
|
82
|
+
"window. Login must complete within five minutes.",
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _credentials(email: str) -> Credentials | Result:
|
|
87
|
+
"""The password comes from TALENTA_PASSWORD, else a hidden prompt on a terminal."""
|
|
88
|
+
password = os.environ.get("TALENTA_PASSWORD")
|
|
89
|
+
if not password:
|
|
90
|
+
if not prompts.stdin_is_tty():
|
|
91
|
+
return Result(
|
|
92
|
+
False,
|
|
93
|
+
"interactive_unavailable",
|
|
94
|
+
"A password prompt needs an interactive terminal; run tlnt login "
|
|
95
|
+
"in a terminal, or set TALENTA_PASSWORD for unattended sign-in",
|
|
96
|
+
)
|
|
97
|
+
password = typer.prompt("Password", hide_input=True, err=True)
|
|
98
|
+
return Credentials(email, password)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def login(
|
|
102
|
+
json_output: JsonOption = False,
|
|
103
|
+
table: TableOption = False,
|
|
104
|
+
store_password: Annotated[
|
|
105
|
+
bool,
|
|
106
|
+
typer.Option("--store-password", hidden=True),
|
|
107
|
+
] = False,
|
|
108
|
+
browser: Annotated[
|
|
109
|
+
Browser | None,
|
|
110
|
+
typer.Option(
|
|
111
|
+
"--browser",
|
|
112
|
+
help="Browser to sign in with: chrome (default; installed "
|
|
113
|
+
"Chrome/Edge), cdp (a running browser), or ego / yab "
|
|
114
|
+
"(experimental).",
|
|
115
|
+
),
|
|
116
|
+
] = None,
|
|
117
|
+
cdp: Annotated[
|
|
118
|
+
str | None,
|
|
119
|
+
typer.Option(
|
|
120
|
+
"--cdp",
|
|
121
|
+
metavar="ENDPOINT",
|
|
122
|
+
help="DevTools endpoint of a running browser: a port, host:port or "
|
|
123
|
+
'URL. Falls back to TALENTA_CDP, then "cdp" in config.json. '
|
|
124
|
+
"Implies --browser cdp.",
|
|
125
|
+
),
|
|
126
|
+
] = None,
|
|
127
|
+
email: Annotated[
|
|
128
|
+
str | None,
|
|
129
|
+
typer.Option(
|
|
130
|
+
"--email",
|
|
131
|
+
metavar="EMAIL",
|
|
132
|
+
envvar="TALENTA_EMAIL",
|
|
133
|
+
help="Sign in with this Mekari email. The password comes from a "
|
|
134
|
+
"hidden prompt, or TALENTA_PASSWORD when there is no terminal.",
|
|
135
|
+
),
|
|
136
|
+
] = None,
|
|
137
|
+
headless: Annotated[
|
|
138
|
+
bool,
|
|
139
|
+
typer.Option(
|
|
140
|
+
"--headless",
|
|
141
|
+
help="Run Chrome without a window (needs --email; for hosts with no "
|
|
142
|
+
"display). Cannot pass a captcha or a code.",
|
|
143
|
+
),
|
|
144
|
+
] = False,
|
|
145
|
+
verbose: VerboseOption = False,
|
|
146
|
+
) -> None:
|
|
147
|
+
"""Reuse valid session or sign in through your browser."""
|
|
148
|
+
backend, endpoint = _resolve_backend(browser, cdp)
|
|
149
|
+
email = (email or "").strip() or None
|
|
150
|
+
if email and backend in (Browser.ego, Browser.yab):
|
|
151
|
+
raise typer.BadParameter(
|
|
152
|
+
"--email only works with --browser chrome or cdp", param_hint="--email"
|
|
153
|
+
)
|
|
154
|
+
if headless and backend is not Browser.chrome:
|
|
155
|
+
raise typer.BadParameter(
|
|
156
|
+
"--headless only works with --browser chrome", param_hint="--headless"
|
|
157
|
+
)
|
|
158
|
+
if headless and not email:
|
|
159
|
+
raise typer.BadParameter("--headless needs --email", param_hint="--headless")
|
|
160
|
+
if store_password:
|
|
161
|
+
typer.echo(
|
|
162
|
+
"--store-password is deprecated and does nothing; "
|
|
163
|
+
"it will be removed in 2.0.",
|
|
164
|
+
err=True,
|
|
165
|
+
)
|
|
166
|
+
current = status_action()
|
|
167
|
+
if current.ok:
|
|
168
|
+
expiry_known = isinstance(current.data, dict) and bool(
|
|
169
|
+
current.data.get("token_expires_at")
|
|
170
|
+
)
|
|
171
|
+
if expiry_known or os.environ.get("TALENTA_BEARER"):
|
|
172
|
+
emit(Result(True, "login_succeeded", "Already logged in"), json_output)
|
|
173
|
+
return
|
|
174
|
+
# Sessions saved before expiry tracking cannot prove their token works.
|
|
175
|
+
typer.echo(
|
|
176
|
+
"The saved session has no recorded token expiry; "
|
|
177
|
+
"signing in again to refresh it.",
|
|
178
|
+
err=True,
|
|
179
|
+
)
|
|
180
|
+
if not current.ok and current.code not in {
|
|
181
|
+
"session_missing",
|
|
182
|
+
"session_invalid",
|
|
183
|
+
"mobile_token_missing",
|
|
184
|
+
"mobile_token_expired",
|
|
185
|
+
}:
|
|
186
|
+
emit(current, json_output)
|
|
187
|
+
return
|
|
188
|
+
if not mekari_constants.client_id():
|
|
189
|
+
emit(
|
|
190
|
+
Result(
|
|
191
|
+
False,
|
|
192
|
+
"client_id_missing",
|
|
193
|
+
"TALENTA_CLIENT_ID is not set. Provide the Talenta mobile "
|
|
194
|
+
"OAuth client id via the TALENTA_CLIENT_ID environment variable "
|
|
195
|
+
f'or add "client_id" to {config.get_config_path()}.',
|
|
196
|
+
),
|
|
197
|
+
json_output,
|
|
198
|
+
)
|
|
199
|
+
return
|
|
200
|
+
credentials = _credentials(email) if email else None
|
|
201
|
+
if isinstance(credentials, Result):
|
|
202
|
+
emit(credentials, json_output)
|
|
203
|
+
return
|
|
204
|
+
note, message = _sign_in_texts(
|
|
205
|
+
backend, signing_in=credentials is not None, headless=headless
|
|
206
|
+
)
|
|
207
|
+
banner(
|
|
208
|
+
f"tlnt v{metadata.version(DISTRIBUTION)}",
|
|
209
|
+
"Unofficial CLI for Mekari Talenta — use at your own risk.",
|
|
210
|
+
note,
|
|
211
|
+
enabled=not json_output,
|
|
212
|
+
)
|
|
213
|
+
typer.echo(message, err=True)
|
|
214
|
+
with deadline_bar(
|
|
215
|
+
"Waiting for you to sign in",
|
|
216
|
+
LOGIN_TIMEOUT_SECONDS,
|
|
217
|
+
enabled=not (json_output or verbose),
|
|
218
|
+
):
|
|
219
|
+
result = login_action(
|
|
220
|
+
backend=backend.value,
|
|
221
|
+
**({"cdp": endpoint} if endpoint else {}),
|
|
222
|
+
**({"credentials": credentials} if credentials else {}),
|
|
223
|
+
**({"headless": True} if headless else {}),
|
|
224
|
+
)
|
|
225
|
+
emit(result, json_output)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from tlnt.cli.output import run
|
|
2
|
+
from tlnt.cli.options import JsonOption, TableOption, VerboseOption
|
|
3
|
+
from tlnt.core.services.logout import logout as logout_action
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def logout(
|
|
7
|
+
json_output: JsonOption = False,
|
|
8
|
+
table: TableOption = False,
|
|
9
|
+
verbose: VerboseOption = False,
|
|
10
|
+
) -> None:
|
|
11
|
+
"""Log out of Talenta and remove the saved session."""
|
|
12
|
+
run("Signing out...", logout_action, json_output, verbose)
|
tlnt/cli/commands/mcp.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from typing import Annotated
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
|
|
6
|
+
from tlnt.cli.output import run
|
|
7
|
+
from tlnt.cli.options import AvatarsOption, JsonOption, TableOption, VerboseOption
|
|
8
|
+
from tlnt.core.models import Result
|
|
9
|
+
from tlnt.core.services.notifications import (
|
|
10
|
+
notification_attendance_list as notification_attendance_list_action,
|
|
11
|
+
notification_count as notification_count_action,
|
|
12
|
+
notification_detail as notification_detail_action,
|
|
13
|
+
notification_list as notification_list_action,
|
|
14
|
+
notification_timeoff_list as notification_timeoff_list_action,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def notification_count(
|
|
19
|
+
json_output: JsonOption = False,
|
|
20
|
+
table: TableOption = False,
|
|
21
|
+
verbose: VerboseOption = False,
|
|
22
|
+
) -> None:
|
|
23
|
+
"""Read Talenta notification counts."""
|
|
24
|
+
run("Loading notification counts...", notification_count_action, json_output, verbose)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _inbox_list_command(
|
|
28
|
+
action: Callable[..., Result], label: str, doc: str
|
|
29
|
+
) -> Callable[..., None]:
|
|
30
|
+
"""One inbox list; the three commands differ only in the inbox they read."""
|
|
31
|
+
|
|
32
|
+
def command(
|
|
33
|
+
page: Annotated[
|
|
34
|
+
int,
|
|
35
|
+
typer.Option("--page", help="Notification page number."),
|
|
36
|
+
] = 1,
|
|
37
|
+
query: Annotated[
|
|
38
|
+
str,
|
|
39
|
+
typer.Option("--query", help="Notification search query."),
|
|
40
|
+
] = "",
|
|
41
|
+
status: Annotated[
|
|
42
|
+
int,
|
|
43
|
+
typer.Option("--status", help="Talenta notification status filter."),
|
|
44
|
+
] = 3,
|
|
45
|
+
json_output: JsonOption = False,
|
|
46
|
+
table: TableOption = False,
|
|
47
|
+
avatars: AvatarsOption = False,
|
|
48
|
+
verbose: VerboseOption = False,
|
|
49
|
+
) -> None:
|
|
50
|
+
if page < 1:
|
|
51
|
+
raise typer.BadParameter("page must be at least 1")
|
|
52
|
+
run(
|
|
53
|
+
label,
|
|
54
|
+
lambda: action(page, query, status),
|
|
55
|
+
json_output,
|
|
56
|
+
verbose,
|
|
57
|
+
avatars=avatars,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
command.__doc__ = doc
|
|
61
|
+
return command
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# Each action is a lambda, so the service is looked up per call and tests can
|
|
65
|
+
# patch it.
|
|
66
|
+
notification_list = _inbox_list_command(
|
|
67
|
+
lambda *filters: notification_list_action(*filters),
|
|
68
|
+
"Loading notifications...",
|
|
69
|
+
"Read Talenta inbox notifications.",
|
|
70
|
+
)
|
|
71
|
+
notification_attendance_list = _inbox_list_command(
|
|
72
|
+
lambda *filters: notification_attendance_list_action(*filters),
|
|
73
|
+
"Loading attendance notifications...",
|
|
74
|
+
"Read Talenta attendance notifications.",
|
|
75
|
+
)
|
|
76
|
+
notification_timeoff_list = _inbox_list_command(
|
|
77
|
+
lambda *filters: notification_timeoff_list_action(*filters),
|
|
78
|
+
"Loading time-off notifications...",
|
|
79
|
+
"Read Talenta time-off notifications.",
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def notification_detail(
|
|
84
|
+
inbox_id: Annotated[
|
|
85
|
+
int,
|
|
86
|
+
typer.Argument(help="Talenta inbox ID."),
|
|
87
|
+
],
|
|
88
|
+
json_output: JsonOption = False,
|
|
89
|
+
table: TableOption = False,
|
|
90
|
+
avatars: AvatarsOption = False,
|
|
91
|
+
verbose: VerboseOption = False,
|
|
92
|
+
) -> None:
|
|
93
|
+
"""Read one Talenta inbox notification detail."""
|
|
94
|
+
if inbox_id < 1:
|
|
95
|
+
raise typer.BadParameter("inbox_id must be at least 1")
|
|
96
|
+
run(
|
|
97
|
+
"Loading notification...",
|
|
98
|
+
lambda: notification_detail_action(inbox_id),
|
|
99
|
+
json_output,
|
|
100
|
+
verbose,
|
|
101
|
+
avatars=avatars,
|
|
102
|
+
)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from tlnt.cli.output import run
|
|
2
|
+
from tlnt.cli.options import AvatarsOption, JsonOption, TableOption, VerboseOption
|
|
3
|
+
from tlnt.core.services.profile import profile as profile_action
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def profile(
|
|
7
|
+
json_output: JsonOption = False,
|
|
8
|
+
table: TableOption = False,
|
|
9
|
+
avatars: AvatarsOption = False,
|
|
10
|
+
verbose: VerboseOption = False,
|
|
11
|
+
) -> None:
|
|
12
|
+
"""Read employment information with the saved mobile token."""
|
|
13
|
+
run("Loading profile...", profile_action, json_output, verbose, avatars=avatars)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from tlnt.cli.output import run
|
|
2
|
+
from tlnt.cli.options import JsonOption, TableOption, VerboseOption
|
|
3
|
+
from tlnt.core.services.setup import setup as setup_action
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def setup(
|
|
7
|
+
json_output: JsonOption = False,
|
|
8
|
+
table: TableOption = False,
|
|
9
|
+
verbose: VerboseOption = False,
|
|
10
|
+
) -> None:
|
|
11
|
+
"""Create config.json, or add new keys to it and drop old ones."""
|
|
12
|
+
run("Writing config...", setup_action, json_output, verbose)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from tlnt.cli.output import run
|
|
2
|
+
from tlnt.cli.options import JsonOption, TableOption, VerboseOption
|
|
3
|
+
from tlnt.core.services.status import status as status_action
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def status(
|
|
7
|
+
json_output: JsonOption = False,
|
|
8
|
+
table: TableOption = False,
|
|
9
|
+
verbose: VerboseOption = False,
|
|
10
|
+
) -> None:
|
|
11
|
+
"""Check the saved Talenta session and mobile token."""
|
|
12
|
+
run("Checking session...", status_action, json_output, verbose)
|