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,174 @@
|
|
|
1
|
+
"""Captured Mekari mobile endpoints and request constants."""
|
|
2
|
+
|
|
3
|
+
from tlnt.core.config import get_setting
|
|
4
|
+
from tlnt.core.user_agent import random_device_headers
|
|
5
|
+
|
|
6
|
+
# Mekari domain constants
|
|
7
|
+
MEKARI_DOMAIN = "mekari.com"
|
|
8
|
+
TALENTA_DOMAIN = "talenta.co"
|
|
9
|
+
|
|
10
|
+
# Mekari URLs
|
|
11
|
+
MEKARI_AUTH_URL = f"https://account.{MEKARI_DOMAIN}"
|
|
12
|
+
MEKARI_API_URL = f"https://api.{MEKARI_DOMAIN}"
|
|
13
|
+
DASHBOARD_TALENTA_URL = f"https://hr.{TALENTA_DOMAIN}/employee/dashboard"
|
|
14
|
+
|
|
15
|
+
# Mekari OAuth constants
|
|
16
|
+
OAUTH_SCOPE = "sso:profile"
|
|
17
|
+
|
|
18
|
+
# Mekari Subdomain URLs
|
|
19
|
+
OAUTH_AUTHORIZE_URL = f"{MEKARI_AUTH_URL}/auth"
|
|
20
|
+
OAUTH2_TOKEN_URL = f"{MEKARI_API_URL}/oauth2/token"
|
|
21
|
+
TOKEN_REDIRECT_URL = f"{MEKARI_API_URL}/mobile-platform-oauth2-redirection"
|
|
22
|
+
|
|
23
|
+
# Mekari GPS constants
|
|
24
|
+
GPS_ACCURACY = "5.0"
|
|
25
|
+
|
|
26
|
+
# Mekari API endpoints
|
|
27
|
+
DASHBOARD_USER_ENDPOINT = (
|
|
28
|
+
f"{MEKARI_API_URL}/internal/talenta-mobile/v1/dashboard/user"
|
|
29
|
+
)
|
|
30
|
+
LOGOUT_ENDPOINT = f"{MEKARI_API_URL}/internal/talenta-mobile/v1/logout"
|
|
31
|
+
INBOX_NOTIFICATION_ENDPOINT = (
|
|
32
|
+
f"{MEKARI_API_URL}/internal/talenta-mobile/v1/inbox-notification"
|
|
33
|
+
)
|
|
34
|
+
INBOX_ENDPOINT = f"{MEKARI_API_URL}/internal/talenta-mobile/v1/inbox"
|
|
35
|
+
INBOX_DETAIL_ENDPOINT = (
|
|
36
|
+
f"{MEKARI_API_URL}/internal/talenta-mobile/v1/inbox-detail"
|
|
37
|
+
)
|
|
38
|
+
ATTENDANCE_APPROVE_ENDPOINT = (
|
|
39
|
+
f"{MEKARI_API_URL}/internal/talenta-mobile/v1/"
|
|
40
|
+
"approval-request/live-attendance"
|
|
41
|
+
)
|
|
42
|
+
TIMEOFF_APPROVE_ENDPOINT = (
|
|
43
|
+
f"{MEKARI_API_URL}/internal/talenta-mobile/v1/"
|
|
44
|
+
"approval-request/timeoff"
|
|
45
|
+
)
|
|
46
|
+
TIMEOFF_REQUEST_ENDPOINT = (
|
|
47
|
+
f"{MEKARI_API_URL}/internal/talenta-mobile/v1/time-off-request"
|
|
48
|
+
)
|
|
49
|
+
TIMEOFF_CANCEL_REQUEST_ENDPOINT = (
|
|
50
|
+
f"{MEKARI_API_URL}/internal/talenta-mobile/v1/cancel-request"
|
|
51
|
+
)
|
|
52
|
+
TIMEOFF_DELEGATION_LIST_ENDPOINT = f"{TIMEOFF_REQUEST_ENDPOINT}/delegation-list"
|
|
53
|
+
TIMEOFF_HISTORY_ENDPOINT = (
|
|
54
|
+
f"{MEKARI_API_URL}/internal/talenta-mobile/v1/history-request/timeoff"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
CALENDAR_HOLIDAY_ENDPOINT = (
|
|
60
|
+
f"{MEKARI_API_URL}/internal/talenta-mobile/v1/calendar/holiday"
|
|
61
|
+
)
|
|
62
|
+
CALENDAR_TIMEOFF_ENDPOINT = (
|
|
63
|
+
f"{MEKARI_API_URL}/internal/talenta-mobile/v1/calendar/timeoff"
|
|
64
|
+
)
|
|
65
|
+
CALENDAR_INDEX_ENDPOINT = (
|
|
66
|
+
f"{MEKARI_API_URL}/internal/talenta-mobile/v2/calendar/index"
|
|
67
|
+
)
|
|
68
|
+
CALENDAR_MY_BIRTHDAY_ENDPOINT = (
|
|
69
|
+
f"{MEKARI_API_URL}/internal/talenta-mobile/v2/calendar/birthday"
|
|
70
|
+
)
|
|
71
|
+
CALENDAR_BIRTHDAY_ENDPOINT = (
|
|
72
|
+
f"{MEKARI_API_URL}/internal/talenta-mobile/v1/calendar/birthday"
|
|
73
|
+
)
|
|
74
|
+
CALENDAR_ACTIVITY_ENDPOINT = (
|
|
75
|
+
f"{MEKARI_API_URL}/internal/talenta-mobile/v1/calendar/activity"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def inbox_endpoint(inbox_type: int) -> str:
|
|
79
|
+
return f"{INBOX_ENDPOINT}/{inbox_type}"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def notification_detail_endpoint(inbox_id: int) -> str:
|
|
83
|
+
return f"{INBOX_DETAIL_ENDPOINT}/{inbox_id}"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def schedule_endpoint(company_id: str) -> str:
|
|
87
|
+
return (
|
|
88
|
+
f"{MEKARI_API_URL}/internal/talenta-mobile/v1/attendance/companies/"
|
|
89
|
+
f"{company_id}/attendance_schedules/active"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def clock_endpoint(company_id: str) -> str:
|
|
93
|
+
return (
|
|
94
|
+
f"{MEKARI_API_URL}/internal/talenta-mobile/v3/attendance/organisations/"
|
|
95
|
+
f"{company_id}/attendance_clocks"
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def summary_attendance_clocks_endpoint(company_id: str) -> str:
|
|
101
|
+
return (
|
|
102
|
+
f"{MEKARI_API_URL}/internal/talenta-mobile/v2/attendance/"
|
|
103
|
+
f"organisations/{company_id}/summary_attendance_clocks"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def summary_attendance_metrics_endpoint(company_id: str) -> str:
|
|
108
|
+
return f"{summary_attendance_clocks_endpoint(company_id)}/metrics_agg"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def timeoff_balance_endpoint(company_id: str, user_id: str) -> str:
|
|
112
|
+
return (
|
|
113
|
+
f"{MEKARI_API_URL}/internal/talenta-time-off/v1/companies/"
|
|
114
|
+
f"{company_id}/users/{user_id}/balances"
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def timeoff_detail_endpoint(request_id: int) -> str:
|
|
119
|
+
return f"{TIMEOFF_HISTORY_ENDPOINT}/{request_id}"
|
|
120
|
+
|
|
121
|
+
# Headers
|
|
122
|
+
DEFAULT_USER_AGENT_MOBILE = "Dart/3.6 (dart:io)"
|
|
123
|
+
HEADER_ACCEPT_LANGUAGE = "en-EN"
|
|
124
|
+
# Fallback when no config sets app_version; .github/workflows/app-version.yml bumps it.
|
|
125
|
+
DEFAULT_APP_VERSION = "2.125.0"
|
|
126
|
+
HEADER_X_PORTAL_VERSION = "2"
|
|
127
|
+
HEADER_X_TL_LEGACY_RESPONSE = "true"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# Settings are read when used, so a --config chosen after import still applies.
|
|
131
|
+
def client_id() -> str:
|
|
132
|
+
return get_setting("TALENTA_CLIENT_ID", "client_id", "")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def app_version() -> str:
|
|
136
|
+
return get_setting("TALENTA_APP_VERSION", "app_version", DEFAULT_APP_VERSION)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def user_agent_mobile() -> str:
|
|
140
|
+
return get_setting(
|
|
141
|
+
"TALENTA_USER_AGENT_MOBILE", "user_agent_mobile", DEFAULT_USER_AGENT_MOBILE
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def request_headers() -> dict[str, str]:
|
|
146
|
+
return {
|
|
147
|
+
**random_device_headers(),
|
|
148
|
+
"Accept-Language": HEADER_ACCEPT_LANGUAGE,
|
|
149
|
+
"X-App-Version": app_version(),
|
|
150
|
+
"X-Portal-Version": HEADER_X_PORTAL_VERSION,
|
|
151
|
+
"X-TL-Legacy-Response": HEADER_X_TL_LEGACY_RESPONSE,
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def token_exchange_headers() -> dict[str, str]:
|
|
156
|
+
return {
|
|
157
|
+
**request_headers(),
|
|
158
|
+
"User-Agent": user_agent_mobile(),
|
|
159
|
+
"Accept": "application/json",
|
|
160
|
+
"Content-Type": "application/json",
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# Mekari Static asset suffixes
|
|
165
|
+
STATIC_ASSET_SUFFIXES = (
|
|
166
|
+
".css",
|
|
167
|
+
".js",
|
|
168
|
+
".svg",
|
|
169
|
+
".png",
|
|
170
|
+
".ico",
|
|
171
|
+
".ttf",
|
|
172
|
+
".woff",
|
|
173
|
+
".woff2",
|
|
174
|
+
)
|
tlnt/core/config.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Runtime configuration for withheld Mekari identifiers.
|
|
2
|
+
|
|
3
|
+
Live values (the mobile OAuth client id) are NOT shipped in source. They
|
|
4
|
+
come from the TALENTA_* environment variables or a local config.json kept
|
|
5
|
+
outside the repo in the platformdirs user config directory.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from functools import lru_cache
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from platformdirs import user_config_path
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
_config_path_override: Path | None = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _config_path() -> Path:
|
|
22
|
+
if _config_path_override is not None:
|
|
23
|
+
return _config_path_override
|
|
24
|
+
return user_config_path("tlnt", appauthor=False) / "config.json"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_config_path() -> Path:
|
|
28
|
+
"""Return the selected config path or the default OS config path."""
|
|
29
|
+
return _config_path()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def set_config_path(path: Path | None) -> None:
|
|
33
|
+
"""Select the config file for this process."""
|
|
34
|
+
global _config_path_override
|
|
35
|
+
_config_path_override = path.expanduser() if path is not None else None
|
|
36
|
+
_file_config.cache_clear()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@lru_cache(maxsize=1) # ponytail: cached per-process; config.json edits need a fresh run
|
|
40
|
+
def _file_config() -> dict[str, str]:
|
|
41
|
+
try:
|
|
42
|
+
raw = json.loads(_config_path().read_text(encoding="utf-8"))
|
|
43
|
+
except (OSError, ValueError):
|
|
44
|
+
return {}
|
|
45
|
+
return raw if isinstance(raw, dict) else {}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def get_setting(env_var: str, config_key: str, default: str = "") -> str:
|
|
49
|
+
value = os.environ.get(env_var)
|
|
50
|
+
if value:
|
|
51
|
+
return value
|
|
52
|
+
file_value = _file_config().get(config_key)
|
|
53
|
+
return file_value if isinstance(file_value, str) and file_value else default
|
tlnt/core/http_client.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Shared HTTPX request behavior."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from tlnt.core.logs import logger
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
_RETRYABLE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
|
|
15
|
+
_RETRY_DELAY_SECONDS = 0.5
|
|
16
|
+
_MAX_ATTEMPTS = 2
|
|
17
|
+
_log = logger.bind(component="http")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def send_request(
|
|
21
|
+
client: httpx.Client,
|
|
22
|
+
method: str,
|
|
23
|
+
url: str,
|
|
24
|
+
*,
|
|
25
|
+
headers: Mapping[str, str] | None = None,
|
|
26
|
+
params: Mapping[str, Any] | None = None,
|
|
27
|
+
json_body: Any = None,
|
|
28
|
+
data: Any = None,
|
|
29
|
+
files: Any = None,
|
|
30
|
+
) -> httpx.Response:
|
|
31
|
+
"""Send one HTTPX request with safe logging and retries."""
|
|
32
|
+
normalized_method = method.upper()
|
|
33
|
+
safe_url = url.split("?", 1)[0]
|
|
34
|
+
retryable_method = normalized_method in _RETRYABLE_METHODS
|
|
35
|
+
|
|
36
|
+
for attempt in range(_MAX_ATTEMPTS):
|
|
37
|
+
started_at = time.monotonic()
|
|
38
|
+
_log.bind(
|
|
39
|
+
event="http_request",
|
|
40
|
+
method=normalized_method,
|
|
41
|
+
path=safe_url,
|
|
42
|
+
attempt=attempt + 1,
|
|
43
|
+
).debug("request started")
|
|
44
|
+
try:
|
|
45
|
+
response = client.request(
|
|
46
|
+
method=normalized_method,
|
|
47
|
+
url=url,
|
|
48
|
+
headers=headers,
|
|
49
|
+
params=params,
|
|
50
|
+
json=json_body,
|
|
51
|
+
data=data,
|
|
52
|
+
files=files,
|
|
53
|
+
)
|
|
54
|
+
except httpx.RequestError as error:
|
|
55
|
+
if retryable_method and attempt == 0:
|
|
56
|
+
_log.bind(
|
|
57
|
+
event="http_retry",
|
|
58
|
+
method=normalized_method,
|
|
59
|
+
path=safe_url,
|
|
60
|
+
attempt=attempt + 1,
|
|
61
|
+
error_type=type(error).__name__,
|
|
62
|
+
).debug("retrying request")
|
|
63
|
+
time.sleep(_RETRY_DELAY_SECONDS)
|
|
64
|
+
continue
|
|
65
|
+
_log.bind(
|
|
66
|
+
event="failure",
|
|
67
|
+
method=normalized_method,
|
|
68
|
+
path=safe_url,
|
|
69
|
+
attempt=attempt + 1,
|
|
70
|
+
error_type=type(error).__name__,
|
|
71
|
+
).warning("request failed")
|
|
72
|
+
raise
|
|
73
|
+
|
|
74
|
+
duration_ms = round((time.monotonic() - started_at) * 1000, 2)
|
|
75
|
+
_log.bind(
|
|
76
|
+
event="http_response",
|
|
77
|
+
method=normalized_method,
|
|
78
|
+
path=safe_url,
|
|
79
|
+
attempt=attempt + 1,
|
|
80
|
+
status=response.status_code,
|
|
81
|
+
duration_ms=duration_ms,
|
|
82
|
+
).debug("response received")
|
|
83
|
+
|
|
84
|
+
if (
|
|
85
|
+
retryable_method
|
|
86
|
+
and attempt == 0
|
|
87
|
+
and (response.status_code == 429 or response.status_code >= 500)
|
|
88
|
+
):
|
|
89
|
+
_log.bind(
|
|
90
|
+
event="http_retry",
|
|
91
|
+
method=normalized_method,
|
|
92
|
+
path=safe_url,
|
|
93
|
+
attempt=attempt + 1,
|
|
94
|
+
status=response.status_code,
|
|
95
|
+
).debug("retrying response")
|
|
96
|
+
time.sleep(_RETRY_DELAY_SECONDS)
|
|
97
|
+
continue
|
|
98
|
+
|
|
99
|
+
if response.is_error:
|
|
100
|
+
_log.bind(
|
|
101
|
+
event="failure",
|
|
102
|
+
method=normalized_method,
|
|
103
|
+
path=safe_url,
|
|
104
|
+
attempt=attempt + 1,
|
|
105
|
+
status=response.status_code,
|
|
106
|
+
).warning("request failed")
|
|
107
|
+
response.raise_for_status()
|
|
108
|
+
return response
|
|
109
|
+
|
|
110
|
+
raise RuntimeError("HTTP request attempt loop exhausted unexpectedly")
|
tlnt/core/logs.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Shared structured logging configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from loguru import logger
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _format_record(record: dict[str, Any]) -> str:
|
|
13
|
+
timestamp: datetime = record["time"].astimezone(timezone.utc)
|
|
14
|
+
fields = " ".join(
|
|
15
|
+
f"{key}={value}" for key, value in record["extra"].items()
|
|
16
|
+
)
|
|
17
|
+
return (
|
|
18
|
+
f"{timestamp.isoformat(timespec='milliseconds')} | "
|
|
19
|
+
f"{record['level'].name:<8} | {fields} | {record['message']}"
|
|
20
|
+
) + "\n"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def configure_logging(verbose: bool = False) -> None:
|
|
24
|
+
"""Configure application diagnostics for the current process."""
|
|
25
|
+
logger.remove()
|
|
26
|
+
logger.configure(extra={"component": "tlnt", "event": "-"})
|
|
27
|
+
|
|
28
|
+
logger.add(
|
|
29
|
+
sys.stderr,
|
|
30
|
+
level="DEBUG" if verbose else "INFO",
|
|
31
|
+
format=_format_record,
|
|
32
|
+
backtrace=False,
|
|
33
|
+
diagnose=False,
|
|
34
|
+
)
|
tlnt/core/models.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Shared domain values."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class Profile:
|
|
12
|
+
name: str
|
|
13
|
+
email: str
|
|
14
|
+
talenta_user_id: str
|
|
15
|
+
company_id: str
|
|
16
|
+
company_name: str | None = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class MobileToken:
|
|
21
|
+
access_token: str
|
|
22
|
+
captured_at: str
|
|
23
|
+
expires_at: str | None = None
|
|
24
|
+
|
|
25
|
+
def is_expired(self, now: datetime) -> bool:
|
|
26
|
+
"""True only for a parseable, timezone-aware expiry at or before now."""
|
|
27
|
+
if self.expires_at is None:
|
|
28
|
+
return False
|
|
29
|
+
try:
|
|
30
|
+
expires = datetime.fromisoformat(self.expires_at)
|
|
31
|
+
except ValueError:
|
|
32
|
+
return False
|
|
33
|
+
return expires.tzinfo is not None and expires <= now
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class ActiveSchedule:
|
|
38
|
+
schedule_date: str
|
|
39
|
+
office_hour_id: str
|
|
40
|
+
office_hour_setting_id: str
|
|
41
|
+
clock_type: str
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class SessionState:
|
|
46
|
+
version: int
|
|
47
|
+
saved_at: str
|
|
48
|
+
web_storage: dict[str, Any]
|
|
49
|
+
profile: Profile | None
|
|
50
|
+
mobile: MobileToken | None = None
|
|
51
|
+
credential_email: str | None = None
|
|
52
|
+
keyring_ref: str | None = None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class Result:
|
|
57
|
+
ok: bool
|
|
58
|
+
code: str
|
|
59
|
+
message: str
|
|
60
|
+
status_code: int | None = None
|
|
61
|
+
data: Any = None
|
|
62
|
+
|
|
63
|
+
def as_dict(self) -> dict[str, Any]:
|
|
64
|
+
result: dict[str, Any] = {
|
|
65
|
+
"ok": self.ok,
|
|
66
|
+
"code": self.code,
|
|
67
|
+
"message": self.message,
|
|
68
|
+
}
|
|
69
|
+
if self.status_code is not None:
|
|
70
|
+
result["status_code"] = self.status_code
|
|
71
|
+
if self.data is not None:
|
|
72
|
+
result["data"] = self.data
|
|
73
|
+
return result
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def dig(value: Any, path: str) -> Any:
|
|
77
|
+
"""Dotted-key lookup; an empty path returns value unchanged."""
|
|
78
|
+
if not path:
|
|
79
|
+
return value
|
|
80
|
+
for part in path.split("."):
|
|
81
|
+
if not isinstance(value, dict):
|
|
82
|
+
return None
|
|
83
|
+
value = value.get(part)
|
|
84
|
+
return value
|
tlnt/core/play_store.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Latest Talenta app version from Google Play, cached for 24 hours."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
from platformdirs import user_cache_path
|
|
12
|
+
|
|
13
|
+
from tlnt.core import config
|
|
14
|
+
|
|
15
|
+
PLAY_URL = "https://play.google.com/store/apps/details?id=co.talenta&hl=id"
|
|
16
|
+
_VERSION_RE = re.compile(r'\[\[\["(\d+\.\d+\.\d+)"\]\]')
|
|
17
|
+
_FETCH_TIMEOUT_SECONDS = 3
|
|
18
|
+
_CACHE_TTL_SECONDS = 24 * 60 * 60
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def parse_play_version(html: str) -> str | None:
|
|
22
|
+
match = _VERSION_RE.search(html)
|
|
23
|
+
return match.group(1) if match else None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def fetch_play_version() -> str | None:
|
|
27
|
+
try:
|
|
28
|
+
response = httpx.get(PLAY_URL, timeout=_FETCH_TIMEOUT_SECONDS, follow_redirects=True)
|
|
29
|
+
response.raise_for_status()
|
|
30
|
+
except httpx.HTTPError:
|
|
31
|
+
return None
|
|
32
|
+
return parse_play_version(response.text)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _cache_path(name: str):
|
|
36
|
+
return user_cache_path("tlnt", appauthor=False) / name
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def cached_version(
|
|
40
|
+
cache_name: str,
|
|
41
|
+
fetch: Callable[[], str | None],
|
|
42
|
+
now: float | None = None,
|
|
43
|
+
) -> str | None:
|
|
44
|
+
"""Return a version cached for 24 hours in the user cache dir, else fetch it.
|
|
45
|
+
|
|
46
|
+
A failed lookup is cached too, so a blocked or slow site costs one
|
|
47
|
+
timeout a day instead of one per command.
|
|
48
|
+
"""
|
|
49
|
+
now = time.time() if now is None else now
|
|
50
|
+
path = _cache_path(cache_name)
|
|
51
|
+
try:
|
|
52
|
+
cached = json.loads(path.read_text(encoding="utf-8"))
|
|
53
|
+
version = cached["version"]
|
|
54
|
+
if (version is None or isinstance(version, str)) and (
|
|
55
|
+
now - float(cached["checked_at"]) < _CACHE_TTL_SECONDS
|
|
56
|
+
):
|
|
57
|
+
return version
|
|
58
|
+
except (OSError, ValueError, KeyError, TypeError):
|
|
59
|
+
pass # missing, corrupt, or stale cache: fall through to a fresh fetch
|
|
60
|
+
|
|
61
|
+
version = fetch() or None
|
|
62
|
+
try:
|
|
63
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
64
|
+
path.write_text(
|
|
65
|
+
json.dumps({"version": version, "checked_at": now}),
|
|
66
|
+
encoding="utf-8",
|
|
67
|
+
)
|
|
68
|
+
except OSError:
|
|
69
|
+
pass # cache is an optimisation; never fail a command over it
|
|
70
|
+
return version
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def latest_play_version(now: float | None = None) -> str | None:
|
|
74
|
+
return cached_version("play_version.json", fetch_play_version, now)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def app_version_warning(current: str) -> str | None:
|
|
78
|
+
latest = latest_play_version()
|
|
79
|
+
if not latest or latest == current:
|
|
80
|
+
return None
|
|
81
|
+
return (
|
|
82
|
+
f"⚠️ Talenta app v{current} is configured, but v{latest} is on Google Play. "
|
|
83
|
+
f'Set "app_version" to "{latest}" in {config.get_config_path()} '
|
|
84
|
+
"(or TALENTA_APP_VERSION), then run `tlnt logout` and `tlnt login`."
|
|
85
|
+
)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Shared service helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Iterator
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from typing import TypeVar
|
|
8
|
+
|
|
9
|
+
from tlnt.core.clients.mekari.client import MekariClient
|
|
10
|
+
from tlnt.core.models import Result
|
|
11
|
+
from tlnt.core.session import SessionStore, mobile_token
|
|
12
|
+
|
|
13
|
+
T = TypeVar("T")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@contextmanager
|
|
17
|
+
def owned_client(client: MekariClient | None = None) -> Iterator[MekariClient]:
|
|
18
|
+
"""Yield the given client untouched, or a new one that is closed on exit."""
|
|
19
|
+
if client is not None:
|
|
20
|
+
yield client
|
|
21
|
+
return
|
|
22
|
+
with MekariClient() as created:
|
|
23
|
+
yield created
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def with_client(
|
|
27
|
+
call: Callable[[MekariClient, str], T],
|
|
28
|
+
store: SessionStore | None = None,
|
|
29
|
+
client: MekariClient | None = None,
|
|
30
|
+
) -> T | Result:
|
|
31
|
+
"""Run call(client, token); a missing token returns its Result, no client made."""
|
|
32
|
+
token = mobile_token(store)
|
|
33
|
+
if isinstance(token, Result):
|
|
34
|
+
return token
|
|
35
|
+
with owned_client(client) as active:
|
|
36
|
+
return call(active, token)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def positive_id(value: object) -> str | None:
|
|
40
|
+
"""A Mekari id as a positive ASCII decimal string, else None."""
|
|
41
|
+
if isinstance(value, bool):
|
|
42
|
+
return None
|
|
43
|
+
if isinstance(value, float) and value.is_integer():
|
|
44
|
+
value = int(value)
|
|
45
|
+
text = str(value).strip()
|
|
46
|
+
return text if text.isascii() and text.isdecimal() and text.strip("0") else None
|