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
@@ -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)
@@ -0,0 +1,8 @@
1
+ from tlnt.cli.options import VerboseOption
2
+
3
+
4
+ def mcp(verbose: VerboseOption = False) -> None:
5
+ """Run the read-only MCP server over stdio."""
6
+ from tlnt.mcp.main import run_server
7
+
8
+ run_server()
@@ -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)