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,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
@@ -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
@@ -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