kitecli 0.2.4__tar.gz → 0.2.5__tar.gz
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.
- {kitecli-0.2.4 → kitecli-0.2.5}/PKG-INFO +1 -1
- {kitecli-0.2.4 → kitecli-0.2.5}/cli/api_client.py +46 -1
- {kitecli-0.2.4 → kitecli-0.2.5}/cli/config.py +48 -1
- {kitecli-0.2.4 → kitecli-0.2.5}/cli/kite_manager.py +1 -23
- {kitecli-0.2.4 → kitecli-0.2.5}/cli/kotak_manager.py +32 -44
- {kitecli-0.2.4 → kitecli-0.2.5}/cli/live_session.py +1 -22
- {kitecli-0.2.4 → kitecli-0.2.5}/kitecli.egg-info/PKG-INFO +1 -1
- {kitecli-0.2.4 → kitecli-0.2.5}/pyproject.toml +1 -1
- {kitecli-0.2.4 → kitecli-0.2.5}/README.md +0 -0
- {kitecli-0.2.4 → kitecli-0.2.5}/cli/__init__.py +0 -0
- {kitecli-0.2.4 → kitecli-0.2.5}/cli/advisor.py +0 -0
- {kitecli-0.2.4 → kitecli-0.2.5}/cli/base_manager.py +0 -0
- {kitecli-0.2.4 → kitecli-0.2.5}/cli/display.py +0 -0
- {kitecli-0.2.4 → kitecli-0.2.5}/cli/executor.py +0 -0
- {kitecli-0.2.4 → kitecli-0.2.5}/cli/main.py +0 -0
- {kitecli-0.2.4 → kitecli-0.2.5}/cli/nli.py +0 -0
- {kitecli-0.2.4 → kitecli-0.2.5}/cli/parser.py +0 -0
- {kitecli-0.2.4 → kitecli-0.2.5}/cli/recorder.py +0 -0
- {kitecli-0.2.4 → kitecli-0.2.5}/cli/telegram_bot.py +0 -0
- {kitecli-0.2.4 → kitecli-0.2.5}/kitecli.egg-info/SOURCES.txt +0 -0
- {kitecli-0.2.4 → kitecli-0.2.5}/kitecli.egg-info/dependency_links.txt +0 -0
- {kitecli-0.2.4 → kitecli-0.2.5}/kitecli.egg-info/entry_points.txt +0 -0
- {kitecli-0.2.4 → kitecli-0.2.5}/kitecli.egg-info/requires.txt +0 -0
- {kitecli-0.2.4 → kitecli-0.2.5}/kitecli.egg-info/top_level.txt +0 -0
- {kitecli-0.2.4 → kitecli-0.2.5}/setup.cfg +0 -0
- {kitecli-0.2.4 → kitecli-0.2.5}/tests/test_multi_broker.py +0 -0
- {kitecli-0.2.4 → kitecli-0.2.5}/tests/test_nli.py +0 -0
- {kitecli-0.2.4 → kitecli-0.2.5}/tests/test_parser.py +0 -0
- {kitecli-0.2.4 → kitecli-0.2.5}/tests/test_telegram.py +0 -0
- {kitecli-0.2.4 → kitecli-0.2.5}/tests/test_ui.py +0 -0
|
@@ -7,8 +7,12 @@ identical to the previous single-broker version so that cli/main.py and
|
|
|
7
7
|
cli/live_session.py require zero changes.
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
|
+
import logging
|
|
10
11
|
from concurrent.futures import ThreadPoolExecutor
|
|
11
12
|
from cli.kite_manager import KiteAccountManager
|
|
13
|
+
from cli.config import remove_session
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
12
16
|
|
|
13
17
|
|
|
14
18
|
class KCLIClientError(Exception):
|
|
@@ -132,8 +136,27 @@ class KCLIClient:
|
|
|
132
136
|
)
|
|
133
137
|
_account_manager_map[account_key] = mgr
|
|
134
138
|
acct.setdefault("api_key", account_key)
|
|
139
|
+
|
|
140
|
+
# Validation: verify token with a REST query (limits)
|
|
141
|
+
is_valid = False
|
|
142
|
+
if mgr.is_authenticated(account_key):
|
|
143
|
+
try:
|
|
144
|
+
mgr._clients[account_key].limits()
|
|
145
|
+
is_valid = True
|
|
146
|
+
except Exception as exc:
|
|
147
|
+
msg = str(exc).lower()
|
|
148
|
+
is_auth_error = any(x in msg for x in ["100008", "unauthorized", "invalid token", "session expired", "session has been closed", "session closed"])
|
|
149
|
+
if is_auth_error:
|
|
150
|
+
logger.info("Kotak validation failed (expired/invalid) for %s: %s", name, exc)
|
|
151
|
+
mgr._authenticated[account_key] = False
|
|
152
|
+
remove_session(f"kotak:{account_key}")
|
|
153
|
+
else:
|
|
154
|
+
# Network/timeout error: fallback to assuming token is valid
|
|
155
|
+
logger.warning("Kotak validation query failed due to network/other issue. Assuming valid. Error: %s", exc)
|
|
156
|
+
is_valid = True
|
|
157
|
+
|
|
135
158
|
# Attempt auto-login for Kotak
|
|
136
|
-
if not
|
|
159
|
+
if not is_valid:
|
|
137
160
|
success = mgr.auto_login(account_key)
|
|
138
161
|
return {
|
|
139
162
|
"name": name, "api_key": account_key,
|
|
@@ -158,7 +181,29 @@ class KCLIClient:
|
|
|
158
181
|
)
|
|
159
182
|
_account_manager_map[api_key] = _kite_manager
|
|
160
183
|
|
|
184
|
+
# Validation: verify token with a REST query (profile)
|
|
185
|
+
is_valid = False
|
|
161
186
|
if _kite_manager.is_authenticated(api_key):
|
|
187
|
+
try:
|
|
188
|
+
_kite_manager._clients[api_key].profile()
|
|
189
|
+
is_valid = True
|
|
190
|
+
except Exception as exc:
|
|
191
|
+
try:
|
|
192
|
+
from kiteconnect.exceptions import TokenException, PermissionException
|
|
193
|
+
except Exception:
|
|
194
|
+
TokenException = PermissionException = ()
|
|
195
|
+
msg = str(exc).lower()
|
|
196
|
+
is_auth_error = isinstance(exc, (TokenException, PermissionException)) or any(x in msg for x in ["403", "401", "unauthorized", "token", "session"])
|
|
197
|
+
if is_auth_error:
|
|
198
|
+
logger.info("Zerodha validation failed (expired/invalid) for %s: %s", name, exc)
|
|
199
|
+
_kite_manager._authenticated[api_key] = False
|
|
200
|
+
remove_session(api_key)
|
|
201
|
+
else:
|
|
202
|
+
# Network/timeout error: fallback to assuming token is valid
|
|
203
|
+
logger.warning("Zerodha validation query failed due to network/other issue. Assuming valid. Error: %s", exc)
|
|
204
|
+
is_valid = True
|
|
205
|
+
|
|
206
|
+
if is_valid:
|
|
162
207
|
return {
|
|
163
208
|
"name": name, "api_key": api_key,
|
|
164
209
|
"login_url": login_url, "auto_logged_in": True,
|
|
@@ -6,12 +6,19 @@ stored at ~/.kcli/config.yaml.
|
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
from pathlib import Path
|
|
9
|
-
from typing import Optional
|
|
9
|
+
from typing import Optional, Any, Dict
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import threading
|
|
10
13
|
|
|
11
14
|
import yaml
|
|
12
15
|
|
|
13
16
|
CONFIG_DIR = Path.home() / ".kcli"
|
|
14
17
|
CONFIG_FILE = CONFIG_DIR / "config.yaml"
|
|
18
|
+
SESSIONS_FILE = CONFIG_DIR / "sessions.json"
|
|
19
|
+
|
|
20
|
+
sessions_lock = threading.RLock()
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
15
22
|
|
|
16
23
|
DEFAULT_CONFIG = {
|
|
17
24
|
"accounts": [
|
|
@@ -94,3 +101,43 @@ def create_default_config() -> dict:
|
|
|
94
101
|
"""
|
|
95
102
|
save_config(DEFAULT_CONFIG)
|
|
96
103
|
return DEFAULT_CONFIG
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def load_sessions() -> Dict[str, Any]:
|
|
107
|
+
"""Load the shared sessions from ~/.kcli/sessions.json thread-safely."""
|
|
108
|
+
with sessions_lock:
|
|
109
|
+
if not SESSIONS_FILE.exists():
|
|
110
|
+
return {}
|
|
111
|
+
try:
|
|
112
|
+
with open(SESSIONS_FILE, "r") as f:
|
|
113
|
+
return json.load(f)
|
|
114
|
+
except Exception as exc:
|
|
115
|
+
logger.error("Failed to load sessions: %s", exc)
|
|
116
|
+
return {}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def save_session(key: str, value: Any) -> None:
|
|
120
|
+
"""Save a session value thread-safely to ~/.kcli/sessions.json."""
|
|
121
|
+
with sessions_lock:
|
|
122
|
+
sessions = load_sessions()
|
|
123
|
+
sessions[key] = value
|
|
124
|
+
try:
|
|
125
|
+
SESSIONS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
126
|
+
with open(SESSIONS_FILE, "w") as f:
|
|
127
|
+
json.dump(sessions, f, indent=2)
|
|
128
|
+
except Exception as exc:
|
|
129
|
+
logger.error("Failed to save session for %s: %s", key, exc)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def remove_session(key: str) -> None:
|
|
133
|
+
"""Remove a session key thread-safely from ~/.kcli/sessions.json."""
|
|
134
|
+
with sessions_lock:
|
|
135
|
+
sessions = load_sessions()
|
|
136
|
+
if key in sessions:
|
|
137
|
+
del sessions[key]
|
|
138
|
+
try:
|
|
139
|
+
SESSIONS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
140
|
+
with open(SESSIONS_FILE, "w") as f:
|
|
141
|
+
json.dump(sessions, f, indent=2)
|
|
142
|
+
except Exception as exc:
|
|
143
|
+
logger.error("Failed to remove session for %s: %s", key, exc)
|
|
@@ -12,29 +12,7 @@ from cli.base_manager import BaseBrokerManager
|
|
|
12
12
|
|
|
13
13
|
logger = logging.getLogger(__name__)
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def _load_sessions() -> dict[str, str]:
|
|
19
|
-
if not SESSIONS_FILE.exists():
|
|
20
|
-
return {}
|
|
21
|
-
try:
|
|
22
|
-
with open(SESSIONS_FILE, "r") as f:
|
|
23
|
-
return json.load(f)
|
|
24
|
-
except Exception as exc:
|
|
25
|
-
logger.error("Failed to load sessions: %s", exc)
|
|
26
|
-
return {}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
def _save_session(api_key: str, access_token: str) -> None:
|
|
30
|
-
sessions = _load_sessions()
|
|
31
|
-
sessions[api_key] = access_token
|
|
32
|
-
try:
|
|
33
|
-
SESSIONS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
34
|
-
with open(SESSIONS_FILE, "w") as f:
|
|
35
|
-
json.dump(sessions, f, indent=2)
|
|
36
|
-
except Exception as exc:
|
|
37
|
-
logger.error("Failed to save session for %s: %s", api_key[:8], exc)
|
|
15
|
+
from cli.config import load_sessions as _load_sessions, save_session as _save_session
|
|
38
16
|
|
|
39
17
|
|
|
40
18
|
class KiteAccountManager(BaseBrokerManager):
|
|
@@ -98,26 +98,7 @@ def _check_neo_error(resp: Any) -> None:
|
|
|
98
98
|
|
|
99
99
|
# ── session persistence (shared file with Zerodha tokens) ─────────────────────
|
|
100
100
|
|
|
101
|
-
|
|
102
|
-
if not SESSIONS_FILE.exists():
|
|
103
|
-
return {}
|
|
104
|
-
try:
|
|
105
|
-
with open(SESSIONS_FILE, "r") as f:
|
|
106
|
-
return json.load(f)
|
|
107
|
-
except Exception as exc:
|
|
108
|
-
logger.error("Failed to load sessions: %s", exc)
|
|
109
|
-
return {}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
def _save_session(key: str, token: str) -> None:
|
|
113
|
-
sessions = _load_sessions()
|
|
114
|
-
sessions[key] = token
|
|
115
|
-
try:
|
|
116
|
-
SESSIONS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
117
|
-
with open(SESSIONS_FILE, "w") as f:
|
|
118
|
-
json.dump(sessions, f, indent=2)
|
|
119
|
-
except Exception as exc:
|
|
120
|
-
logger.error("Failed to save session for %s: %s", key, exc)
|
|
101
|
+
from cli.config import load_sessions as _load_sessions, save_session as _save_session
|
|
121
102
|
|
|
122
103
|
|
|
123
104
|
# ── KotakAccountManager ────────────────────────────────────────────────────────
|
|
@@ -320,30 +301,7 @@ class KotakAccountManager(BaseBrokerManager):
|
|
|
320
301
|
|
|
321
302
|
client.api_client.rest_client.request = _proxied_request
|
|
322
303
|
if proxy:
|
|
323
|
-
logger.info("Kotak account '%s': HTTP proxy enabled (%s…)", name, proxy[:20])
|
|
324
|
-
|
|
325
|
-
# Also monkeypatch websocket-client (used by Kotak Neo WebSocket) to route over the proxy.
|
|
326
|
-
try:
|
|
327
|
-
import websocket as _ws_lib
|
|
328
|
-
from urllib.parse import urlparse as _urlparse
|
|
329
|
-
_parsed = _urlparse(proxy)
|
|
330
|
-
_proxy_host = _parsed.hostname
|
|
331
|
-
_proxy_port = _parsed.port
|
|
332
|
-
_proxy_auth = (_parsed.username, _parsed.password) if _parsed.username and _parsed.password else None
|
|
333
|
-
|
|
334
|
-
_orig_run_forever = _ws_lib.WebSocketApp.run_forever
|
|
335
|
-
def _proxied_run_forever(self, *args, **kwargs):
|
|
336
|
-
kwargs.setdefault("http_proxy_host", _proxy_host)
|
|
337
|
-
kwargs.setdefault("http_proxy_port", _proxy_port)
|
|
338
|
-
kwargs.setdefault("proxy_type", "http")
|
|
339
|
-
if _proxy_auth:
|
|
340
|
-
kwargs.setdefault("http_proxy_auth", _proxy_auth)
|
|
341
|
-
return _orig_run_forever(self, *args, **kwargs)
|
|
342
|
-
|
|
343
|
-
_ws_lib.WebSocketApp.run_forever = _proxied_run_forever
|
|
344
|
-
logger.info("Kotak account '%s': WebSocket proxy monkeypatch applied", name)
|
|
345
|
-
except Exception as e:
|
|
346
|
-
logger.error("Failed to apply WebSocket proxy patch for Kotak: %s", e)
|
|
304
|
+
logger.info("Kotak account '%s': HTTP proxy enabled for order APIs (%s…)", name, proxy[:20])
|
|
347
305
|
self._clients[consumer_key] = client
|
|
348
306
|
self._account_names[consumer_key] = name or consumer_key
|
|
349
307
|
self._authenticated[consumer_key] = False
|
|
@@ -936,6 +894,10 @@ class KotakAccountManager(BaseBrokerManager):
|
|
|
936
894
|
return KotakTicker(account_key, self.get_access_token(account_key), client)
|
|
937
895
|
|
|
938
896
|
|
|
897
|
+
def _dummy_callback(*args, **kwargs):
|
|
898
|
+
pass
|
|
899
|
+
|
|
900
|
+
|
|
939
901
|
# ── KotakTicker WebSocket Wrapper ─────────────────────────────────────────────
|
|
940
902
|
|
|
941
903
|
class KotakTicker:
|
|
@@ -990,6 +952,13 @@ class KotakTicker:
|
|
|
990
952
|
self._reconnect_timer.cancel()
|
|
991
953
|
self._reconnect_timer = None
|
|
992
954
|
logger.info("Closing Kotak Neo WebSocket (api_key=%s…)...", self.api_key[:8])
|
|
955
|
+
|
|
956
|
+
# Clear client callbacks to avoid recursive callback noise during close
|
|
957
|
+
self.client.on_open = _dummy_callback
|
|
958
|
+
self.client.on_message = _dummy_callback
|
|
959
|
+
self.client.on_error = _dummy_callback
|
|
960
|
+
self.client.on_close = _dummy_callback
|
|
961
|
+
|
|
993
962
|
neo_ws = getattr(self.client, "NeoWebSocket", None)
|
|
994
963
|
if neo_ws:
|
|
995
964
|
if getattr(neo_ws, "hsWebsocket", None):
|
|
@@ -1027,6 +996,16 @@ class KotakTicker:
|
|
|
1027
996
|
|
|
1028
997
|
def _on_close(self, message: Any = None) -> None:
|
|
1029
998
|
logger.info("Kotak Neo WebSocket closed: %s", message or "Session closed")
|
|
999
|
+
msg_str = str(message or "").lower()
|
|
1000
|
+
is_auth_error = any(x in msg_str for x in ["session has been closed", "unauthorized", "invalid token"])
|
|
1001
|
+
|
|
1002
|
+
if is_auth_error:
|
|
1003
|
+
logger.error("Kotak Neo WebSocket closed due to session expiry/auth failure: %s (mapped code=403)", message)
|
|
1004
|
+
if self.on_error:
|
|
1005
|
+
# Map to 403 so live_session stops reconnecting and prompts user to re-init
|
|
1006
|
+
self.on_error(self, 403, str(message or "Session closed"))
|
|
1007
|
+
return
|
|
1008
|
+
|
|
1030
1009
|
if self.on_close:
|
|
1031
1010
|
self.on_close(self, 1000, str(message or "Closed"))
|
|
1032
1011
|
if self.reconnect and not self._stop_reconnect:
|
|
@@ -1072,6 +1051,10 @@ class KotakTicker:
|
|
|
1072
1051
|
|
|
1073
1052
|
logger.info("Kotak Neo WS: Attempting reconnection now...")
|
|
1074
1053
|
try:
|
|
1054
|
+
# Temporarily stub callbacks to avoid recursive on_error/on_close triggers during cleanup
|
|
1055
|
+
self.client.on_error = _dummy_callback
|
|
1056
|
+
self.client.on_close = _dummy_callback
|
|
1057
|
+
|
|
1075
1058
|
# First, clean up sockets
|
|
1076
1059
|
neo_ws = getattr(self.client, "NeoWebSocket", None)
|
|
1077
1060
|
if neo_ws:
|
|
@@ -1098,6 +1081,11 @@ class KotakTicker:
|
|
|
1098
1081
|
self.client.subscribe_to_orderfeed()
|
|
1099
1082
|
except Exception as e:
|
|
1100
1083
|
logger.error("Kotak Neo WS: Reconnection attempt failed: %s", e)
|
|
1084
|
+
# Re-register open/message/error/close hooks on client
|
|
1085
|
+
self.client.on_open = self._on_open
|
|
1086
|
+
self.client.on_message = self._on_message
|
|
1087
|
+
self.client.on_error = self._on_error
|
|
1088
|
+
self.client.on_close = self._on_close
|
|
1101
1089
|
self._trigger_reconnect()
|
|
1102
1090
|
|
|
1103
1091
|
def _on_message(self, message: Any) -> None:
|
|
@@ -1228,29 +1228,8 @@ class KCLILiveSession:
|
|
|
1228
1228
|
ticker.on_close = self._make_on_close(api_key)
|
|
1229
1229
|
ticker.on_error = self._make_on_error(api_key)
|
|
1230
1230
|
|
|
1231
|
-
#
|
|
1232
|
-
proxy_str = acct.get("proxy")
|
|
1233
|
-
proxy_dict = None
|
|
1234
|
-
if broker != "zerodha" and proxy_str:
|
|
1235
|
-
from urllib.parse import urlparse
|
|
1236
|
-
try:
|
|
1237
|
-
p_str = proxy_str if "://" in proxy_str else f"http://{proxy_str}"
|
|
1238
|
-
parsed = urlparse(p_str)
|
|
1239
|
-
if parsed.hostname and parsed.port:
|
|
1240
|
-
proxy_dict = {
|
|
1241
|
-
"host": parsed.hostname,
|
|
1242
|
-
"port": int(parsed.port),
|
|
1243
|
-
}
|
|
1244
|
-
if parsed.username and parsed.password:
|
|
1245
|
-
proxy_dict["username"] = parsed.username
|
|
1246
|
-
proxy_dict["password"] = parsed.password
|
|
1247
|
-
except Exception as p_err:
|
|
1248
|
-
self.log_message(f"[#ff8700]Failed to parse proxy for {acct.get('name')}:[/#] {p_err}")
|
|
1249
|
-
|
|
1250
|
-
# connect() only accepts threaded + proxy
|
|
1231
|
+
# connect() only accepts threaded
|
|
1251
1232
|
connect_kwargs = dict(threaded=True)
|
|
1252
|
-
if proxy_dict:
|
|
1253
|
-
connect_kwargs["proxy"] = proxy_dict
|
|
1254
1233
|
ticker.connect(**connect_kwargs)
|
|
1255
1234
|
self.tickers[api_key] = ticker
|
|
1256
1235
|
except Exception as exc:
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "kitecli"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.5"
|
|
8
8
|
description = "KiteCLI — Multi-account, multi-broker trading positions viewer (Zerodha + Kotak Neo)"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|