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,146 @@
1
+ from __future__ import annotations
2
+
3
+ from tlnt.core.clients.mekari.client import MekariClient
4
+ from tlnt.core.models import Result
5
+ from tlnt.core.services import positive_id, with_client
6
+ from tlnt.core.session import SessionStore
7
+
8
+
9
+ def timeoff_types_list(
10
+ store: SessionStore | None = None,
11
+ client: MekariClient | None = None,
12
+ ) -> Result:
13
+ return with_client(lambda c, token: c.timeoff_types_list(token), store, client)
14
+
15
+
16
+ def timeoff_balance(
17
+ date: str = "",
18
+ keyword: str = "",
19
+ show_unlimited_balance: bool = False,
20
+ policy_id: str = "",
21
+ limited_by_date: str = "",
22
+ refresh: bool = True,
23
+ show_special_leave: bool = False,
24
+ store: SessionStore | None = None,
25
+ client: MekariClient | None = None,
26
+ ) -> Result:
27
+ def balance(c: MekariClient, token: str) -> Result:
28
+ profile = c.employment_info(token)
29
+ if not profile.ok:
30
+ return profile
31
+ data = profile.data if isinstance(profile.data, dict) else {}
32
+ company_id = positive_id(data.get("company_id"))
33
+ user_id = positive_id(data.get("id"))
34
+ if company_id is None or user_id is None:
35
+ return Result(
36
+ False,
37
+ "remote_error",
38
+ "Mekari profile is missing or has invalid time-off balance identifiers",
39
+ )
40
+ return c.timeoff_balance(
41
+ token,
42
+ company_id,
43
+ user_id,
44
+ date=date,
45
+ keyword=keyword,
46
+ show_unlimited_balance=show_unlimited_balance,
47
+ policy_id=policy_id,
48
+ limited_by_date=limited_by_date,
49
+ refresh=refresh,
50
+ show_special_leave=show_special_leave,
51
+ )
52
+
53
+ return with_client(balance, store, client)
54
+
55
+
56
+ def timeoff_delegation_list(
57
+ page: int = 1,
58
+ search: str = "",
59
+ store: SessionStore | None = None,
60
+ client: MekariClient | None = None,
61
+ ) -> Result:
62
+ return with_client(
63
+ lambda c, token: c.timeoff_delegation_list(token, page, search), store, client
64
+ )
65
+
66
+
67
+ def timeoff_list(
68
+ page: int = 1,
69
+ limit: int = 30,
70
+ date: str = "",
71
+ end_date: str = "",
72
+ status: int = 0,
73
+ policy_id: str = "",
74
+ all_period: bool = True,
75
+ show_metrics: bool = False,
76
+ store: SessionStore | None = None,
77
+ client: MekariClient | None = None,
78
+ ) -> Result:
79
+ return with_client(
80
+ lambda c, token: c.timeoff_list(
81
+ token,
82
+ page,
83
+ limit,
84
+ date,
85
+ end_date,
86
+ status,
87
+ policy_id,
88
+ all_period,
89
+ show_metrics,
90
+ ),
91
+ store,
92
+ client,
93
+ )
94
+
95
+
96
+ def timeoff_detail(
97
+ request_id: int,
98
+ store: SessionStore | None = None,
99
+ client: MekariClient | None = None,
100
+ ) -> Result:
101
+ return with_client(lambda c, token: c.timeoff_detail(token, request_id), store, client)
102
+
103
+
104
+ def timeoff_request(
105
+ date: str,
106
+ policy_id: str,
107
+ reason: str,
108
+ *,
109
+ half_day_after_break: bool = False,
110
+ half_day_before_break: bool = False,
111
+ delegate_to: str = "",
112
+ file_path: str | None = None,
113
+ schedule_in: str = "",
114
+ schedule_out: str = "",
115
+ store: SessionStore | None = None,
116
+ client: MekariClient | None = None,
117
+ ) -> Result:
118
+ return with_client(
119
+ lambda c, token: c.timeoff_request(
120
+ token,
121
+ date,
122
+ policy_id,
123
+ reason,
124
+ half_day_after_break=half_day_after_break,
125
+ half_day_before_break=half_day_before_break,
126
+ delegate_to=delegate_to,
127
+ file_path=file_path,
128
+ schedule_in=schedule_in,
129
+ schedule_out=schedule_out,
130
+ ),
131
+ store,
132
+ client,
133
+ )
134
+
135
+
136
+ def timeoff_cancel(
137
+ request_id: int,
138
+ request_type: str = "1002",
139
+ store: SessionStore | None = None,
140
+ client: MekariClient | None = None,
141
+ ) -> Result:
142
+ return with_client(
143
+ lambda c, token: c.timeoff_cancel(token, request_id, request_type),
144
+ store,
145
+ client,
146
+ )
tlnt/core/session.py ADDED
@@ -0,0 +1,189 @@
1
+ """Secure session persistence and access."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import tempfile
8
+ from dataclasses import asdict
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import keyring
13
+ from keyring.errors import PasswordDeleteError
14
+ from platformdirs import user_data_path
15
+
16
+ from tlnt.core.config import get_setting
17
+ from tlnt.core.models import MobileToken, Profile, Result, SessionState
18
+
19
+ KEYRING_SERVICE = "tlnt"
20
+
21
+
22
+ class SessionMissing(FileNotFoundError):
23
+ pass
24
+
25
+
26
+ class InvalidSession(ValueError):
27
+ pass
28
+
29
+
30
+ class SessionStore:
31
+ def __init__(self, path: Path | None = None) -> None:
32
+ configured_path = get_setting(
33
+ "TALENTA_SESSION_PATH",
34
+ "session_path",
35
+ "",
36
+ )
37
+ self.path = (
38
+ path
39
+ if path is not None
40
+ else (
41
+ Path(configured_path).expanduser()
42
+ if configured_path
43
+ else user_data_path("tlnt", appauthor=False) / "session.json"
44
+ )
45
+ )
46
+
47
+ def load(self) -> SessionState:
48
+ try:
49
+ payload = json.loads(self.path.read_text(encoding="utf-8"))
50
+ except FileNotFoundError as error:
51
+ raise SessionMissing("no saved session; run `tlnt login`") from error
52
+ except (json.JSONDecodeError, OSError) as error:
53
+ raise InvalidSession("session file is unreadable") from error
54
+ return self._parse(payload)
55
+
56
+ def save(self, session: SessionState) -> None:
57
+ parent = self.path.parent
58
+ # Tighten only a folder tlnt creates or its own data folder, never a
59
+ # shared one like /tmp or the home folder.
60
+ owned = not parent.exists() or parent == user_data_path("tlnt", appauthor=False)
61
+ parent.mkdir(parents=True, exist_ok=True)
62
+ if owned:
63
+ parent.chmod(0o700)
64
+ temporary_path: Path | None = None
65
+ try:
66
+ with tempfile.NamedTemporaryFile(
67
+ "w",
68
+ encoding="utf-8",
69
+ dir=self.path.parent,
70
+ delete=False,
71
+ ) as temporary:
72
+ temporary_path = Path(temporary.name)
73
+ temporary_path.chmod(0o600)
74
+ json.dump(
75
+ asdict(session),
76
+ temporary,
77
+ ensure_ascii=False,
78
+ separators=(",", ":"),
79
+ )
80
+ temporary.flush()
81
+ os.fsync(temporary.fileno())
82
+ os.replace(temporary_path, self.path)
83
+ self.path.chmod(0o600)
84
+ finally:
85
+ if temporary_path is not None and temporary_path.exists():
86
+ temporary_path.unlink()
87
+
88
+ def delete(self) -> None:
89
+ self.path.unlink(missing_ok=True)
90
+
91
+ @staticmethod
92
+ def delete_password(keyring_ref: str) -> None:
93
+ try:
94
+ keyring.delete_password(KEYRING_SERVICE, keyring_ref)
95
+ except PasswordDeleteError:
96
+ pass
97
+
98
+ @staticmethod
99
+ def _parse(payload: Any) -> SessionState:
100
+ if not isinstance(payload, dict) or payload.get("version") != 1:
101
+ raise InvalidSession("unsupported session version")
102
+
103
+ profile = payload.get("profile")
104
+ mobile = payload.get("mobile")
105
+ web_storage = payload.get("web_storage")
106
+ saved_at = payload.get("saved_at")
107
+ if (
108
+ (profile is not None and not isinstance(profile, dict))
109
+ or (mobile is not None and not isinstance(mobile, dict))
110
+ or not isinstance(web_storage, dict)
111
+ or not isinstance(saved_at, str)
112
+ ):
113
+ raise InvalidSession("session file has an invalid shape")
114
+
115
+ try:
116
+ return SessionState(
117
+ version=1,
118
+ saved_at=saved_at,
119
+ web_storage=web_storage,
120
+ profile=(
121
+ Profile(
122
+ name=str(profile["name"]),
123
+ email=str(profile["email"]),
124
+ talenta_user_id=str(profile["talenta_user_id"]),
125
+ company_id=str(profile["company_id"]),
126
+ company_name=(
127
+ str(profile["company_name"])
128
+ if profile.get("company_name") is not None
129
+ else None
130
+ ),
131
+ )
132
+ if profile is not None
133
+ else None
134
+ ),
135
+ mobile=(
136
+ MobileToken(
137
+ access_token=str(mobile["access_token"]),
138
+ captured_at=str(mobile["captured_at"]),
139
+ expires_at=(
140
+ str(mobile["expires_at"])
141
+ if mobile.get("expires_at") is not None
142
+ else None
143
+ ),
144
+ )
145
+ if mobile is not None
146
+ else None
147
+ ),
148
+ credential_email=(
149
+ str(payload["credential_email"])
150
+ if payload.get("credential_email") is not None
151
+ else None
152
+ ),
153
+ keyring_ref=(
154
+ str(payload["keyring_ref"])
155
+ if payload.get("keyring_ref") is not None
156
+ else None
157
+ ),
158
+ )
159
+ except KeyError as error:
160
+ raise InvalidSession("session profile is incomplete") from error
161
+
162
+
163
+ def load_session(store: SessionStore | None = None) -> SessionState | Result:
164
+ try:
165
+ return (store or SessionStore()).load()
166
+ except SessionMissing as error:
167
+ return Result(False, "session_missing", str(error))
168
+ except InvalidSession as error:
169
+ return Result(False, "session_invalid", str(error))
170
+
171
+
172
+ def mobile_token(
173
+ store: SessionStore | None = None,
174
+ session: SessionState | None = None,
175
+ ) -> str | Result:
176
+ env_token = os.environ.get("TALENTA_BEARER")
177
+ if env_token:
178
+ return env_token
179
+
180
+ loaded = session or load_session(store)
181
+ if isinstance(loaded, Result):
182
+ return loaded
183
+ if loaded.mobile is not None:
184
+ return loaded.mobile.access_token
185
+ return Result(
186
+ False,
187
+ "mobile_token_missing",
188
+ "No mobile token; run `tlnt login` or set TALENTA_BEARER",
189
+ )
@@ -0,0 +1,71 @@
1
+ """Random Android device headers, fixed for the life of the process."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import random
6
+ import secrets
7
+ from functools import lru_cache
8
+
9
+
10
+ _ANDROID_VERSIONS = ("10.0", "11.0", "12.0", "13.0", "14.0", "15.0", "16.0")
11
+ _ANDROID_DEVICES = (
12
+ "SM-G960F",
13
+ "Pixel 5",
14
+ "SM-A505F",
15
+ "Pixel 4a",
16
+ "Pixel 6 Pro",
17
+ "SM-N975F",
18
+ "SM-G973F",
19
+ "Pixel 3",
20
+ "SM-G980F",
21
+ "Pixel 5a",
22
+ "SM-G998B",
23
+ "Pixel 4",
24
+ "SM-G991B",
25
+ "SM-G996B",
26
+ "SM-F711B",
27
+ "SM-F916B",
28
+ "SM-G781B",
29
+ "SM-N986B",
30
+ "SM-N981B",
31
+ "Pixel 2",
32
+ "Pixel 2 XL",
33
+ "Pixel 3 XL",
34
+ "Pixel 4 XL",
35
+ "Pixel 5 XL",
36
+ "Pixel 6",
37
+ "Pixel 6 XL",
38
+ "Pixel 6a",
39
+ "Pixel 7",
40
+ "Pixel 7 Pro",
41
+ "OnePlus 8",
42
+ "OnePlus 8 Pro",
43
+ "OnePlus 9",
44
+ )
45
+
46
+
47
+ @lru_cache(maxsize=1)
48
+ def random_device_headers() -> dict[str, str]:
49
+ """Headers for one random Android device; every call in a process agrees."""
50
+ device_model = random.choice(_ANDROID_DEVICES)
51
+ os_version = random.choice(_ANDROID_VERSIONS)
52
+ chrome_version = ".".join(
53
+ str(part)
54
+ for part in (
55
+ random.randrange(125, 138),
56
+ random.randrange(10),
57
+ random.randrange(1000, 10000),
58
+ random.randrange(100),
59
+ )
60
+ )
61
+ return {
62
+ "User-Agent": (
63
+ f"Mozilla/5.0 (Linux; Android {os_version}; {device_model}) "
64
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
65
+ f"Chrome/{chrome_version} Mobile Safari/537.36"
66
+ ),
67
+ "X-Device-Fingerprint": secrets.token_hex(32),
68
+ "X-Device-ID": secrets.token_hex(8),
69
+ "X-Device-Model": device_model,
70
+ "X-OS-Version": os_version,
71
+ }
tlnt/mcp/__init__.py ADDED
File without changes
tlnt/mcp/main.py ADDED
@@ -0,0 +1,176 @@
1
+ """Read-only MCP adapter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import errno
6
+ import time
7
+ from collections.abc import Callable
8
+ from importlib import metadata
9
+ from typing import Any
10
+
11
+ from mcp.server import MCPServer
12
+ from mcp.types import ToolAnnotations
13
+
14
+ from tlnt import DISTRIBUTION
15
+ from tlnt.core.logs import logger
16
+ from tlnt.core.models import Result
17
+ from tlnt.core.services.attendance import (
18
+ preview_clock,
19
+ validate_coordinates,
20
+ validate_notes,
21
+ )
22
+ from tlnt.core.services.profile import profile
23
+ from tlnt.core.services.status import status
24
+
25
+ _log = logger.bind(component="mcp")
26
+
27
+
28
+ def _read_only(*, open_world: bool) -> ToolAnnotations:
29
+ return ToolAnnotations(
30
+ readOnlyHint=True,
31
+ destructiveHint=False,
32
+ idempotentHint=True,
33
+ openWorldHint=open_world,
34
+ )
35
+
36
+
37
+ def _call_tool(
38
+ name: str,
39
+ action: Callable[..., Result],
40
+ *args: Any,
41
+ argument_keys: tuple[str, ...] = (),
42
+ **kwargs: Any,
43
+ ) -> dict[str, Any]:
44
+ started_at = time.monotonic()
45
+ _log.bind(
46
+ event="tool_started",
47
+ tool=name,
48
+ argument_keys=argument_keys,
49
+ ).info("tool started")
50
+ try:
51
+ result = action(*args, **kwargs)
52
+ except Exception as error:
53
+ _log.bind(
54
+ event="tool_failed",
55
+ tool=name,
56
+ error_type=type(error).__name__,
57
+ duration_ms=round((time.monotonic() - started_at) * 1000, 2),
58
+ ).warning("tool failed")
59
+ raise
60
+
61
+ finished_log = _log.bind(
62
+ event="tool_finished",
63
+ tool=name,
64
+ ok=result.ok,
65
+ code=result.code,
66
+ duration_ms=round((time.monotonic() - started_at) * 1000, 2),
67
+ data_type=type(result.data).__name__,
68
+ data_keys=(
69
+ tuple(sorted(str(key) for key in result.data))
70
+ if isinstance(result.data, dict)
71
+ else ()
72
+ ),
73
+ )
74
+ if result.status_code is not None:
75
+ finished_log = finished_log.bind(status_code=result.status_code)
76
+ finished_log.info("tool finished")
77
+ return result.as_dict()
78
+
79
+
80
+ def create_server(
81
+ *,
82
+ status_action: Callable[[], Result] = status,
83
+ profile_action: Callable[[], Result] = profile,
84
+ preview_action: Callable[..., Result] = preview_clock,
85
+ ) -> MCPServer:
86
+ server = MCPServer(
87
+ "tlnt",
88
+ version=metadata.version(DISTRIBUTION),
89
+ instructions="Read-only Talenta session, profile, and clock-in preview tools.",
90
+ )
91
+
92
+ @server.tool(annotations=_read_only(open_world=False))
93
+ def get_session_status() -> dict[str, Any]:
94
+ """Check whether a saved, unexpired Talenta mobile token is available."""
95
+ return _call_tool(
96
+ "get_session_status",
97
+ status_action,
98
+ argument_keys=(),
99
+ )
100
+
101
+ @server.tool(annotations=_read_only(open_world=True))
102
+ def get_profile() -> dict[str, Any]:
103
+ """Read Talenta employment information with the mobile token."""
104
+ return _call_tool(
105
+ "get_profile",
106
+ profile_action,
107
+ argument_keys=(),
108
+ )
109
+
110
+ @server.tool(annotations=_read_only(open_world=True))
111
+ def preview_clockin(
112
+ event_type: str,
113
+ latitude: str,
114
+ longitude: str,
115
+ notes: str = "",
116
+ location_id: str | None = None,
117
+ ) -> dict[str, Any]:
118
+ """Preview a mobile attendance request without submitting it."""
119
+ validation = validate_notes(notes) or validate_coordinates(latitude, longitude)
120
+ if validation is not None:
121
+ return validation.as_dict()
122
+ return _call_tool(
123
+ "preview_clockin",
124
+ preview_action,
125
+ event_type,
126
+ notes,
127
+ argument_keys=(
128
+ "event_type",
129
+ "latitude",
130
+ "longitude",
131
+ "notes",
132
+ "location_id",
133
+ ),
134
+ latitude=latitude,
135
+ longitude=longitude,
136
+ location_id=location_id,
137
+ )
138
+
139
+ return server
140
+
141
+
142
+ def _is_expected_stdio_shutdown(error: BaseException) -> bool:
143
+ if isinstance(error, OSError):
144
+ return error.errno == errno.EIO
145
+
146
+ nested = getattr(error, "exceptions", ())
147
+ return isinstance(nested, tuple) and bool(nested) and all(
148
+ _is_expected_stdio_shutdown(item) for item in nested
149
+ )
150
+
151
+
152
+ def _log_server_stopped(reason: str) -> None:
153
+ _log.bind(
154
+ event="server_stopped",
155
+ transport="stdio",
156
+ reason=reason,
157
+ ).info("MCP server stopped")
158
+
159
+
160
+ def run_server() -> None:
161
+ _log.bind(
162
+ event="server_starting",
163
+ transport="stdio",
164
+ ).info("MCP server starting")
165
+ try:
166
+ create_server().run()
167
+ except KeyboardInterrupt:
168
+ _log_server_stopped("interrupt")
169
+ return
170
+ except Exception as error:
171
+ if _is_expected_stdio_shutdown(error):
172
+ _log_server_stopped("interrupt")
173
+ return
174
+ _log.exception("unhandled MCP server error")
175
+ raise
176
+ _log_server_stopped("completed")