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.
Files changed (30) hide show
  1. {kitecli-0.2.4 → kitecli-0.2.5}/PKG-INFO +1 -1
  2. {kitecli-0.2.4 → kitecli-0.2.5}/cli/api_client.py +46 -1
  3. {kitecli-0.2.4 → kitecli-0.2.5}/cli/config.py +48 -1
  4. {kitecli-0.2.4 → kitecli-0.2.5}/cli/kite_manager.py +1 -23
  5. {kitecli-0.2.4 → kitecli-0.2.5}/cli/kotak_manager.py +32 -44
  6. {kitecli-0.2.4 → kitecli-0.2.5}/cli/live_session.py +1 -22
  7. {kitecli-0.2.4 → kitecli-0.2.5}/kitecli.egg-info/PKG-INFO +1 -1
  8. {kitecli-0.2.4 → kitecli-0.2.5}/pyproject.toml +1 -1
  9. {kitecli-0.2.4 → kitecli-0.2.5}/README.md +0 -0
  10. {kitecli-0.2.4 → kitecli-0.2.5}/cli/__init__.py +0 -0
  11. {kitecli-0.2.4 → kitecli-0.2.5}/cli/advisor.py +0 -0
  12. {kitecli-0.2.4 → kitecli-0.2.5}/cli/base_manager.py +0 -0
  13. {kitecli-0.2.4 → kitecli-0.2.5}/cli/display.py +0 -0
  14. {kitecli-0.2.4 → kitecli-0.2.5}/cli/executor.py +0 -0
  15. {kitecli-0.2.4 → kitecli-0.2.5}/cli/main.py +0 -0
  16. {kitecli-0.2.4 → kitecli-0.2.5}/cli/nli.py +0 -0
  17. {kitecli-0.2.4 → kitecli-0.2.5}/cli/parser.py +0 -0
  18. {kitecli-0.2.4 → kitecli-0.2.5}/cli/recorder.py +0 -0
  19. {kitecli-0.2.4 → kitecli-0.2.5}/cli/telegram_bot.py +0 -0
  20. {kitecli-0.2.4 → kitecli-0.2.5}/kitecli.egg-info/SOURCES.txt +0 -0
  21. {kitecli-0.2.4 → kitecli-0.2.5}/kitecli.egg-info/dependency_links.txt +0 -0
  22. {kitecli-0.2.4 → kitecli-0.2.5}/kitecli.egg-info/entry_points.txt +0 -0
  23. {kitecli-0.2.4 → kitecli-0.2.5}/kitecli.egg-info/requires.txt +0 -0
  24. {kitecli-0.2.4 → kitecli-0.2.5}/kitecli.egg-info/top_level.txt +0 -0
  25. {kitecli-0.2.4 → kitecli-0.2.5}/setup.cfg +0 -0
  26. {kitecli-0.2.4 → kitecli-0.2.5}/tests/test_multi_broker.py +0 -0
  27. {kitecli-0.2.4 → kitecli-0.2.5}/tests/test_nli.py +0 -0
  28. {kitecli-0.2.4 → kitecli-0.2.5}/tests/test_parser.py +0 -0
  29. {kitecli-0.2.4 → kitecli-0.2.5}/tests/test_telegram.py +0 -0
  30. {kitecli-0.2.4 → kitecli-0.2.5}/tests/test_ui.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kitecli
3
- Version: 0.2.4
3
+ Version: 0.2.5
4
4
  Summary: KiteCLI — Multi-account, multi-broker trading positions viewer (Zerodha + Kotak Neo)
5
5
  Author: KiteCLI Team
6
6
  License: MIT
@@ -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 mgr.is_authenticated(account_key):
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
- SESSIONS_FILE = Path.home() / ".kcli" / "sessions.json"
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
- def _load_sessions() -> dict[str, str]:
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
- # Parse proxy if configured for this account (Zerodha WS does not require IP whitelisting, direct is faster)
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:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kitecli
3
- Version: 0.2.4
3
+ Version: 0.2.5
4
4
  Summary: KiteCLI — Multi-account, multi-broker trading positions viewer (Zerodha + Kotak Neo)
5
5
  Author: KiteCLI Team
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "kitecli"
7
- version = "0.2.4"
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