kitecli 0.2.1__tar.gz → 0.2.2__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.1 → kitecli-0.2.2}/PKG-INFO +1 -1
- {kitecli-0.2.1 → kitecli-0.2.2}/cli/kite_manager.py +28 -65
- {kitecli-0.2.1 → kitecli-0.2.2}/cli/kotak_manager.py +82 -85
- {kitecli-0.2.1 → kitecli-0.2.2}/cli/live_session.py +175 -112
- {kitecli-0.2.1 → kitecli-0.2.2}/kitecli.egg-info/PKG-INFO +1 -1
- {kitecli-0.2.1 → kitecli-0.2.2}/pyproject.toml +1 -1
- {kitecli-0.2.1 → kitecli-0.2.2}/tests/test_ui.py +22 -0
- {kitecli-0.2.1 → kitecli-0.2.2}/README.md +0 -0
- {kitecli-0.2.1 → kitecli-0.2.2}/cli/__init__.py +0 -0
- {kitecli-0.2.1 → kitecli-0.2.2}/cli/advisor.py +0 -0
- {kitecli-0.2.1 → kitecli-0.2.2}/cli/api_client.py +0 -0
- {kitecli-0.2.1 → kitecli-0.2.2}/cli/base_manager.py +0 -0
- {kitecli-0.2.1 → kitecli-0.2.2}/cli/config.py +0 -0
- {kitecli-0.2.1 → kitecli-0.2.2}/cli/display.py +0 -0
- {kitecli-0.2.1 → kitecli-0.2.2}/cli/executor.py +0 -0
- {kitecli-0.2.1 → kitecli-0.2.2}/cli/main.py +0 -0
- {kitecli-0.2.1 → kitecli-0.2.2}/cli/nli.py +0 -0
- {kitecli-0.2.1 → kitecli-0.2.2}/cli/parser.py +0 -0
- {kitecli-0.2.1 → kitecli-0.2.2}/cli/recorder.py +0 -0
- {kitecli-0.2.1 → kitecli-0.2.2}/cli/telegram_bot.py +0 -0
- {kitecli-0.2.1 → kitecli-0.2.2}/kitecli.egg-info/SOURCES.txt +0 -0
- {kitecli-0.2.1 → kitecli-0.2.2}/kitecli.egg-info/dependency_links.txt +0 -0
- {kitecli-0.2.1 → kitecli-0.2.2}/kitecli.egg-info/entry_points.txt +0 -0
- {kitecli-0.2.1 → kitecli-0.2.2}/kitecli.egg-info/requires.txt +0 -0
- {kitecli-0.2.1 → kitecli-0.2.2}/kitecli.egg-info/top_level.txt +0 -0
- {kitecli-0.2.1 → kitecli-0.2.2}/setup.cfg +0 -0
- {kitecli-0.2.1 → kitecli-0.2.2}/tests/test_multi_broker.py +0 -0
- {kitecli-0.2.1 → kitecli-0.2.2}/tests/test_nli.py +0 -0
- {kitecli-0.2.1 → kitecli-0.2.2}/tests/test_parser.py +0 -0
- {kitecli-0.2.1 → kitecli-0.2.2}/tests/test_telegram.py +0 -0
|
@@ -97,6 +97,14 @@ class KiteAccountManager(BaseBrokerManager):
|
|
|
97
97
|
"""Core account initialisation (used both directly and via ABC)."""
|
|
98
98
|
proxies = {"http": proxy, "https": proxy} if proxy else None
|
|
99
99
|
kite = KiteConnect(api_key=api_key, proxies=proxies)
|
|
100
|
+
|
|
101
|
+
# Increase connection pool size and timeouts to support slow proxies
|
|
102
|
+
from requests.adapters import HTTPAdapter
|
|
103
|
+
adapter = HTTPAdapter(pool_connections=100, pool_maxsize=100)
|
|
104
|
+
kite.reqsession.mount("https://", adapter)
|
|
105
|
+
kite.reqsession.mount("http://", adapter)
|
|
106
|
+
kite.timeout = 25
|
|
107
|
+
|
|
100
108
|
self._clients[api_key] = kite
|
|
101
109
|
self._api_secrets[api_key] = api_secret
|
|
102
110
|
self._account_names[api_key] = name or api_key
|
|
@@ -106,18 +114,9 @@ class KiteAccountManager(BaseBrokerManager):
|
|
|
106
114
|
sessions = _load_sessions()
|
|
107
115
|
saved_token = sessions.get(api_key)
|
|
108
116
|
if saved_token:
|
|
109
|
-
logger.
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
kite.profile()
|
|
113
|
-
self._authenticated[api_key] = True
|
|
114
|
-
logger.info("Successfully restored valid session for '%s' (api_key=%s…)", name, api_key[:8])
|
|
115
|
-
except Exception as exc:
|
|
116
|
-
logger.info(
|
|
117
|
-
"Saved session token for '%s' (api_key=%s…) is invalid or expired: %s",
|
|
118
|
-
name, api_key[:8], exc,
|
|
119
|
-
)
|
|
120
|
-
self._authenticated[api_key] = False
|
|
117
|
+
logger.debug("Found saved session token for '%s' (api_key=%s…). Assuming valid.", name, api_key[:8])
|
|
118
|
+
kite.set_access_token(saved_token)
|
|
119
|
+
self._authenticated[api_key] = True
|
|
121
120
|
|
|
122
121
|
login_url = f"https://kite.zerodha.com/connect/login?v=3&api_key={api_key}"
|
|
123
122
|
logger.info("Initialized account '%s' (api_key=%s…)", name, api_key[:8])
|
|
@@ -374,6 +373,11 @@ class KiteAccountManager(BaseBrokerManager):
|
|
|
374
373
|
# Build a requests.Session and apply the per-account proxy so that
|
|
375
374
|
# login and 2FA calls also flow through the proxy (not just KiteConnect SDK calls).
|
|
376
375
|
session = http_requests.Session()
|
|
376
|
+
from requests.adapters import HTTPAdapter
|
|
377
|
+
adapter = HTTPAdapter(pool_connections=20, pool_maxsize=20)
|
|
378
|
+
session.mount("https://", adapter)
|
|
379
|
+
session.mount("http://", adapter)
|
|
380
|
+
|
|
377
381
|
account_proxies = self._proxies.get(api_key, {})
|
|
378
382
|
if account_proxies:
|
|
379
383
|
session.proxies.update(account_proxies)
|
|
@@ -384,6 +388,7 @@ class KiteAccountManager(BaseBrokerManager):
|
|
|
384
388
|
login_resp = session.post(
|
|
385
389
|
"https://kite.zerodha.com/api/login",
|
|
386
390
|
data={"user_id": user_id, "password": password},
|
|
391
|
+
timeout=25,
|
|
387
392
|
)
|
|
388
393
|
login_data = login_resp.json()
|
|
389
394
|
|
|
@@ -411,6 +416,7 @@ class KiteAccountManager(BaseBrokerManager):
|
|
|
411
416
|
"user_id": user_id,
|
|
412
417
|
"twofa_type": "totp",
|
|
413
418
|
},
|
|
419
|
+
timeout=25,
|
|
414
420
|
)
|
|
415
421
|
twofa_data = twofa_resp.json()
|
|
416
422
|
|
|
@@ -434,19 +440,8 @@ class KiteAccountManager(BaseBrokerManager):
|
|
|
434
440
|
|
|
435
441
|
for hop in range(1, 11):
|
|
436
442
|
logger.info("Auto-login hop %d: %s", hop, current_url)
|
|
437
|
-
try:
|
|
438
|
-
resp = session.get(current_url, allow_redirects=False)
|
|
439
|
-
except http_requests.exceptions.RequestException as exc:
|
|
440
|
-
logger.warning("Network error/unreachable during redirect hop %d: %s", hop, exc)
|
|
441
|
-
# Check if the url itself contained the token before network error
|
|
442
|
-
parsed = urlparse(current_url)
|
|
443
|
-
query_params = parse_qs(parsed.query)
|
|
444
|
-
token = query_params.get("request_token", [None])[0]
|
|
445
|
-
if token:
|
|
446
|
-
request_token = token
|
|
447
|
-
break
|
|
448
443
|
|
|
449
|
-
# Check if current_url already contains the request_token
|
|
444
|
+
# Check if current_url already contains the request_token BEFORE requesting it
|
|
450
445
|
parsed = urlparse(current_url)
|
|
451
446
|
query_params = parse_qs(parsed.query)
|
|
452
447
|
token = query_params.get("request_token", [None])[0]
|
|
@@ -454,6 +449,12 @@ class KiteAccountManager(BaseBrokerManager):
|
|
|
454
449
|
request_token = token
|
|
455
450
|
break
|
|
456
451
|
|
|
452
|
+
try:
|
|
453
|
+
resp = session.get(current_url, allow_redirects=False, timeout=25)
|
|
454
|
+
except http_requests.exceptions.RequestException as exc:
|
|
455
|
+
logger.warning("Network error/unreachable during redirect hop %d: %s", hop, exc)
|
|
456
|
+
break
|
|
457
|
+
|
|
457
458
|
location = resp.headers.get("Location")
|
|
458
459
|
if not location:
|
|
459
460
|
break
|
|
@@ -465,21 +466,14 @@ class KiteAccountManager(BaseBrokerManager):
|
|
|
465
466
|
|
|
466
467
|
current_url = location
|
|
467
468
|
|
|
468
|
-
# Check if target redirect URL contains the request_token
|
|
469
|
-
parsed = urlparse(current_url)
|
|
470
|
-
query_params = parse_qs(parsed.query)
|
|
471
|
-
token = query_params.get("request_token", [None])[0]
|
|
472
|
-
if token:
|
|
473
|
-
request_token = token
|
|
474
|
-
break
|
|
475
|
-
|
|
476
469
|
# If target URL is not a Zerodha domain (e.g. localhost or client app domain),
|
|
477
470
|
# do not actually request it (it may not be reachable or could trigger external side-effects),
|
|
478
471
|
# and we've already checked if it has the request_token.
|
|
479
472
|
parsed_loc = urlparse(current_url)
|
|
480
473
|
if parsed_loc.netloc and not parsed_loc.netloc.endswith(".zerodha.com") and "zerodha.com" not in parsed_loc.netloc:
|
|
481
474
|
logger.info("Reached non-Zerodha redirect target: %s", current_url)
|
|
482
|
-
|
|
475
|
+
# We will extract the token at the start of the next iteration
|
|
476
|
+
pass
|
|
483
477
|
|
|
484
478
|
if not request_token:
|
|
485
479
|
logger.error("Could not extract request_token from redirect chain for %s", user_id)
|
|
@@ -1064,38 +1058,7 @@ class KiteAccountManager(BaseBrokerManager):
|
|
|
1064
1058
|
except Exception as exc:
|
|
1065
1059
|
logger.warning("Kite indices fetch failed for api_key=%s…: %s", api_key[:8], exc)
|
|
1066
1060
|
|
|
1067
|
-
|
|
1068
|
-
try:
|
|
1069
|
-
logger.info("Fetching indices from Yahoo Finance fallback...")
|
|
1070
|
-
import requests as http_requests
|
|
1071
|
-
headers = {"User-Agent": "Mozilla/5.0"}
|
|
1072
|
-
from concurrent.futures import ThreadPoolExecutor
|
|
1073
|
-
|
|
1074
|
-
symbols = {
|
|
1075
|
-
"nifty": "^NSEI",
|
|
1076
|
-
"sensex": "^BSESN",
|
|
1077
|
-
"vix": "^INDIAVIX",
|
|
1078
|
-
}
|
|
1079
|
-
|
|
1080
|
-
def fetch_symbol_price(item):
|
|
1081
|
-
name, code = item
|
|
1082
|
-
url = f"https://query1.finance.yahoo.com/v8/finance/chart/{code}?interval=1m&range=1d"
|
|
1083
|
-
resp = http_requests.get(url, headers=headers, timeout=5)
|
|
1084
|
-
price = resp.json()["chart"]["result"][0]["meta"]["regularMarketPrice"]
|
|
1085
|
-
return name, float(price)
|
|
1086
|
-
|
|
1087
|
-
with ThreadPoolExecutor(max_workers=3) as executor:
|
|
1088
|
-
results = dict(executor.map(fetch_symbol_price, symbols.items()))
|
|
1089
|
-
|
|
1090
|
-
return {
|
|
1091
|
-
"status": "success",
|
|
1092
|
-
"nifty": results["nifty"],
|
|
1093
|
-
"sensex": results["sensex"],
|
|
1094
|
-
"vix": results["vix"],
|
|
1095
|
-
}
|
|
1096
|
-
except Exception as exc:
|
|
1097
|
-
logger.error("Yahoo Finance fallback failed: %s", exc)
|
|
1098
|
-
return {"status": "error", "message": f"Kite call had insufficient permission, and Yahoo fallback failed: {exc}"}
|
|
1061
|
+
return {"status": "error", "message": "All authenticated Zerodha account indices fetches failed."}
|
|
1099
1062
|
|
|
1100
1063
|
def get_ltp_and_tokens(self, api_key: str, symbols: list[str]) -> dict:
|
|
1101
1064
|
"""Fetch LTP and instrument tokens for the given symbols using Zerodha ltp()."""
|
|
@@ -148,6 +148,9 @@ class KotakAccountManager(BaseBrokerManager):
|
|
|
148
148
|
self._authenticated: dict[str, bool] = {}
|
|
149
149
|
# consumer_key → stored credentials for re-auth
|
|
150
150
|
self._credentials: dict[str, dict] = {}
|
|
151
|
+
import threading as _threading
|
|
152
|
+
self._auth_lock = _threading.Lock()
|
|
153
|
+
self._last_login_time: dict[str, float] = {}
|
|
151
154
|
|
|
152
155
|
# ── account lifecycle ──────────────────────────────────────────────────────
|
|
153
156
|
|
|
@@ -255,7 +258,10 @@ class KotakAccountManager(BaseBrokerManager):
|
|
|
255
258
|
def _do_req(h):
|
|
256
259
|
req_kwargs = {}
|
|
257
260
|
if _proxies:
|
|
258
|
-
|
|
261
|
+
# Kotak Neo static IP whitelisting is only enforced on order APIs
|
|
262
|
+
url_lower = u_with_params.lower()
|
|
263
|
+
if "/order/" in url_lower and any(x in url_lower for x in ["/place", "/modify", "/cancel"]):
|
|
264
|
+
req_kwargs["proxies"] = _proxies
|
|
259
265
|
|
|
260
266
|
logger.debug("Kotak REST [%s] requesting: %s", method, u_with_params)
|
|
261
267
|
logger.debug("Kotak REST headers: %s", {k: v[:15] + '...' if len(str(v)) > 15 else v for k, v in h.items()})
|
|
@@ -355,100 +361,91 @@ class KotakAccountManager(BaseBrokerManager):
|
|
|
355
361
|
sessions = _load_sessions()
|
|
356
362
|
saved_session = sessions.get(f"kotak:{consumer_key}")
|
|
357
363
|
if saved_session and isinstance(saved_session, dict):
|
|
358
|
-
logger.
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
)
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
client.configuration.data_center = saved_session.get("data_center")
|
|
368
|
-
client.configuration.base_url = saved_session.get("base_url")
|
|
369
|
-
|
|
370
|
-
# Verify token liveness
|
|
371
|
-
limits_resp = client.limits()
|
|
372
|
-
_check_neo_error(limits_resp)
|
|
373
|
-
self._authenticated[consumer_key] = True
|
|
374
|
-
logger.info(
|
|
375
|
-
"Restored valid Kotak session for '%s' (key=%s…)", name, consumer_key[:8]
|
|
376
|
-
)
|
|
377
|
-
except Exception as exc:
|
|
378
|
-
logger.info(
|
|
379
|
-
"Saved Kotak token for '%s' (key=%s…) expired: %s. Will attempt auto-login.",
|
|
380
|
-
name, consumer_key[:8], exc,
|
|
381
|
-
)
|
|
382
|
-
self._authenticated[consumer_key] = False
|
|
364
|
+
logger.debug("Found saved Kotak session for '%s' (key=%s…). Assuming valid.", name, consumer_key[:8])
|
|
365
|
+
client.configuration.edit_token = saved_session.get("edit_token")
|
|
366
|
+
client.configuration.edit_sid = saved_session.get("edit_sid")
|
|
367
|
+
client.configuration.edit_rid = saved_session.get("edit_rid")
|
|
368
|
+
client.configuration.serverId = saved_session.get("serverId")
|
|
369
|
+
client.configuration.data_center = saved_session.get("data_center")
|
|
370
|
+
client.configuration.base_url = saved_session.get("base_url")
|
|
371
|
+
|
|
372
|
+
self._authenticated[consumer_key] = True
|
|
383
373
|
|
|
384
374
|
if not self._authenticated.get(consumer_key):
|
|
385
|
-
logger.
|
|
386
|
-
try:
|
|
387
|
-
self.auto_login(consumer_key)
|
|
388
|
-
except Exception as e:
|
|
389
|
-
logger.error("Auto-login failed for Kotak account '%s': %s", name, e)
|
|
375
|
+
logger.debug("No valid Kotak session found for '%s' (key=%s…).", name, consumer_key[:8])
|
|
390
376
|
|
|
391
377
|
return "" # no login URL for Kotak
|
|
392
378
|
|
|
393
379
|
def auto_login(self, account_key: str, **_kwargs) -> bool:
|
|
394
380
|
"""Perform TOTP-based auto-login for the Kotak account."""
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
)
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
381
|
+
import time
|
|
382
|
+
with self._auth_lock:
|
|
383
|
+
# Debounce: if we just logged in within the last 15 seconds, skip re-login.
|
|
384
|
+
# This prevents concurrent threads from hammering the login endpoint when
|
|
385
|
+
# multiple requests see a 100008 (session expired) error simultaneously.
|
|
386
|
+
last_login = self._last_login_time.get(account_key, 0.0)
|
|
387
|
+
if time.time() - last_login < 15.0:
|
|
388
|
+
logger.info("Kotak auto-login skipped for key=%s… (debounced)", account_key[:8])
|
|
389
|
+
return True
|
|
390
|
+
|
|
391
|
+
creds = self._credentials.get(account_key, {})
|
|
392
|
+
totp_secret = creds.get("totp_secret", "")
|
|
393
|
+
mobile_number = creds.get("mobile_number", "")
|
|
394
|
+
mpin = creds.get("mpin", "")
|
|
395
|
+
ucc = creds.get("ucc", "")
|
|
396
|
+
|
|
397
|
+
if not (mobile_number and ucc and mpin and totp_secret):
|
|
398
|
+
logger.warning(
|
|
399
|
+
"Kotak auto-login skipped for key=%s…: incomplete credentials",
|
|
400
|
+
account_key[:8],
|
|
401
|
+
)
|
|
402
|
+
return False
|
|
411
403
|
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
# Step 1: Initiate login
|
|
417
|
-
login_resp = client.totp_login(
|
|
418
|
-
mobile_number=mobile_number,
|
|
419
|
-
ucc=ucc,
|
|
420
|
-
totp=totp_code,
|
|
421
|
-
)
|
|
422
|
-
logger.debug("Kotak login resp: %s", login_resp)
|
|
423
|
-
if isinstance(login_resp, dict) and "Error" in login_resp:
|
|
424
|
-
raise RuntimeError(f"TOTP login failed: {login_resp}")
|
|
425
|
-
|
|
426
|
-
# Step 2: 2FA validation with MPIN
|
|
427
|
-
session_resp = client.totp_validate(mpin=mpin)
|
|
428
|
-
logger.debug("Kotak 2FA resp: %s", session_resp)
|
|
429
|
-
if isinstance(session_resp, dict) and "Error" in session_resp:
|
|
430
|
-
raise RuntimeError(f"MPIN validation failed: {session_resp}")
|
|
431
|
-
|
|
432
|
-
# Persist token
|
|
433
|
-
conf = client.configuration
|
|
434
|
-
if getattr(conf, "edit_token", None):
|
|
435
|
-
session_data = {
|
|
436
|
-
"edit_token": conf.edit_token,
|
|
437
|
-
"edit_sid": conf.edit_sid,
|
|
438
|
-
"edit_rid": conf.edit_rid,
|
|
439
|
-
"serverId": conf.serverId,
|
|
440
|
-
"data_center": conf.data_center,
|
|
441
|
-
"base_url": conf.base_url,
|
|
442
|
-
}
|
|
443
|
-
_save_session(f"kotak:{account_key}", session_data)
|
|
404
|
+
client = self._clients.get(account_key)
|
|
405
|
+
if not client:
|
|
406
|
+
return False
|
|
444
407
|
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
408
|
+
try:
|
|
409
|
+
totp_code = pyotp.TOTP(totp_secret).now()
|
|
410
|
+
logger.info("Kotak login: sending login request for key=%s…", account_key[:8])
|
|
411
|
+
|
|
412
|
+
# Step 1: Initiate login
|
|
413
|
+
login_resp = client.totp_login(
|
|
414
|
+
mobile_number=mobile_number,
|
|
415
|
+
ucc=ucc,
|
|
416
|
+
totp=totp_code,
|
|
417
|
+
)
|
|
418
|
+
logger.debug("Kotak login resp: %s", login_resp)
|
|
419
|
+
if isinstance(login_resp, dict) and "Error" in login_resp:
|
|
420
|
+
raise RuntimeError(f"TOTP login failed: {login_resp}")
|
|
421
|
+
|
|
422
|
+
# Step 2: 2FA validation with MPIN
|
|
423
|
+
session_resp = client.totp_validate(mpin=mpin)
|
|
424
|
+
logger.debug("Kotak 2FA resp: %s", session_resp)
|
|
425
|
+
if isinstance(session_resp, dict) and "Error" in session_resp:
|
|
426
|
+
raise RuntimeError(f"MPIN validation failed: {session_resp}")
|
|
427
|
+
|
|
428
|
+
# Persist token
|
|
429
|
+
conf = client.configuration
|
|
430
|
+
if getattr(conf, "edit_token", None):
|
|
431
|
+
session_data = {
|
|
432
|
+
"edit_token": conf.edit_token,
|
|
433
|
+
"edit_sid": conf.edit_sid,
|
|
434
|
+
"edit_rid": conf.edit_rid,
|
|
435
|
+
"serverId": conf.serverId,
|
|
436
|
+
"data_center": conf.data_center,
|
|
437
|
+
"base_url": conf.base_url,
|
|
438
|
+
}
|
|
439
|
+
_save_session(f"kotak:{account_key}", session_data)
|
|
440
|
+
|
|
441
|
+
self._authenticated[account_key] = True
|
|
442
|
+
self._last_login_time[account_key] = time.time()
|
|
443
|
+
logger.info("Kotak auto-login successful for key=%s…", account_key[:8])
|
|
444
|
+
return True
|
|
448
445
|
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
446
|
+
except Exception as exc:
|
|
447
|
+
logger.error(
|
|
448
|
+
"Kotak auto-login failed for key=%s…: %s", account_key[:8], exc, exc_info=True
|
|
452
449
|
)
|
|
453
450
|
self._authenticated[account_key] = False
|
|
454
451
|
return False
|
|
@@ -386,6 +386,10 @@ class KCLILiveSession:
|
|
|
386
386
|
"divider": "bg:#21262d fg:#30363d",
|
|
387
387
|
"divider.dragging": "bg:#0969da fg:#ffffff bold",
|
|
388
388
|
"market_indices": "bg:#161b22",
|
|
389
|
+
"tab_bar": "bg:#161b22",
|
|
390
|
+
"tab.active": "bg:#1f6feb fg:#ffffff bold",
|
|
391
|
+
"tab.inactive": "bg:#21262d fg:#8b949e",
|
|
392
|
+
"tab.separator": "fg:#30363d",
|
|
389
393
|
# Quick action bar
|
|
390
394
|
"quickaction": "bg:#161b22 fg:#8b949e",
|
|
391
395
|
"quickaction.hint": "bg:#161b22 fg:#484f58 italic",
|
|
@@ -946,6 +950,62 @@ class KCLILiveSession:
|
|
|
946
950
|
|
|
947
951
|
return [(status_style, status_label, _header_click_handler)]
|
|
948
952
|
|
|
953
|
+
def _get_tab_bar_text(self):
|
|
954
|
+
"""Build clickable, styled text fragments for the info pane tabs."""
|
|
955
|
+
def make_click_handler(mode):
|
|
956
|
+
def handler(*args, **kwargs):
|
|
957
|
+
from prompt_toolkit.mouse_events import MouseEventType
|
|
958
|
+
mouse_event = None
|
|
959
|
+
if len(args) == 2:
|
|
960
|
+
mouse_event = args[1]
|
|
961
|
+
elif len(args) == 1:
|
|
962
|
+
mouse_event = args[0]
|
|
963
|
+
|
|
964
|
+
if mouse_event and mouse_event.event_type == MouseEventType.MOUSE_UP:
|
|
965
|
+
self.info_mode = mode
|
|
966
|
+
self._update_info_buffer()
|
|
967
|
+
if hasattr(self, "app") and self.app:
|
|
968
|
+
self.app.invalidate()
|
|
969
|
+
return handler
|
|
970
|
+
|
|
971
|
+
fragments = []
|
|
972
|
+
|
|
973
|
+
# Tiny spacing for visual margin
|
|
974
|
+
fragments.append(("", " "))
|
|
975
|
+
|
|
976
|
+
# Tab 1: Pending
|
|
977
|
+
fragments.append((
|
|
978
|
+
"class:tab.active" if self.info_mode == "orders_pending" else "class:tab.inactive",
|
|
979
|
+
" Pending ",
|
|
980
|
+
make_click_handler("orders_pending")
|
|
981
|
+
))
|
|
982
|
+
fragments.append(("", " "))
|
|
983
|
+
|
|
984
|
+
# Tab 2: Executed
|
|
985
|
+
fragments.append((
|
|
986
|
+
"class:tab.active" if self.info_mode == "orders_executed" else "class:tab.inactive",
|
|
987
|
+
" Executed ",
|
|
988
|
+
make_click_handler("orders_executed")
|
|
989
|
+
))
|
|
990
|
+
fragments.append(("", " "))
|
|
991
|
+
|
|
992
|
+
# Tab 3: OC (Option Chain)
|
|
993
|
+
fragments.append((
|
|
994
|
+
"class:tab.active" if self.info_mode == "oc" else "class:tab.inactive",
|
|
995
|
+
" OC ",
|
|
996
|
+
make_click_handler("oc")
|
|
997
|
+
))
|
|
998
|
+
fragments.append(("", " "))
|
|
999
|
+
|
|
1000
|
+
# Tab 4: Advisor
|
|
1001
|
+
fragments.append((
|
|
1002
|
+
"class:tab.active" if self.info_mode == "advisor" else "class:tab.inactive",
|
|
1003
|
+
" Advisor ",
|
|
1004
|
+
make_click_handler("advisor")
|
|
1005
|
+
))
|
|
1006
|
+
|
|
1007
|
+
return fragments
|
|
1008
|
+
|
|
949
1009
|
async def reconnect_websockets(self) -> None:
|
|
950
1010
|
"""Close existing WebSocket connections and start fresh ones, updating the UI."""
|
|
951
1011
|
self.log_message("[#58a6ff]Closing existing WebSocket connections...[/#]")
|
|
@@ -983,72 +1043,85 @@ class KCLILiveSession:
|
|
|
983
1043
|
self.log_message("Running token diagnostics...")
|
|
984
1044
|
statuses: dict[str, str] = {}
|
|
985
1045
|
any_bad = False
|
|
986
|
-
|
|
1046
|
+
|
|
1047
|
+
async def diagnose_account(acct):
|
|
987
1048
|
api_key = acct.get("api_key")
|
|
1049
|
+
name = acct.get("name", api_key)
|
|
988
1050
|
if not api_key:
|
|
989
|
-
|
|
1051
|
+
return api_key, "no_token", name, "", acct, [], False
|
|
1052
|
+
|
|
1053
|
+
logs = []
|
|
1054
|
+
is_bad = False
|
|
1055
|
+
broker = acct.get("broker", "zerodha").lower()
|
|
1056
|
+
|
|
1057
|
+
# Fast path for Zerodha: try WebSocket auth first
|
|
1058
|
+
if broker == "zerodha":
|
|
1059
|
+
access_token = self.client.get_access_token(api_key)
|
|
1060
|
+
if not access_token:
|
|
1061
|
+
logs.append(f"[#ff8700]⚠ No token:[/#] @{name} — login required (run 'kcli init').")
|
|
1062
|
+
return api_key, "no_token", name, "no access token", acct, logs, True
|
|
1063
|
+
|
|
1064
|
+
proxy_str = acct.get("proxy")
|
|
1065
|
+
ws_status, ws_detail = await self._run_api_call(
|
|
1066
|
+
probe_ws_auth, api_key, access_token, proxy_str
|
|
1067
|
+
)
|
|
1068
|
+
|
|
1069
|
+
if ws_status == "ok":
|
|
1070
|
+
logs.append(f"[#00ff00]✓ Streaming OK:[/#] @{name}")
|
|
1071
|
+
return api_key, "valid", name, "", acct, logs, False
|
|
1072
|
+
elif ws_status == "proxy_blocked":
|
|
1073
|
+
logs.append(f"[#ff8700]⚠ Proxy blocked streaming:[/#] @{name} — proxy refused the WebSocket tunnel ({ws_detail}).")
|
|
1074
|
+
return api_key, "proxy_blocked", name, ws_detail, acct, logs, True
|
|
1075
|
+
|
|
1076
|
+
# If we are here, either it's Kotak, or Zerodha WS failed.
|
|
1077
|
+
# Fallback to the full REST check_token to find out exactly why.
|
|
990
1078
|
try:
|
|
991
1079
|
result = await self._run_api_call(self.client.check_token, api_key)
|
|
992
1080
|
except Exception as exc:
|
|
993
|
-
result = {"name":
|
|
994
|
-
|
|
1081
|
+
result = {"name": name, "status": "error", "detail": str(exc)}
|
|
1082
|
+
|
|
995
1083
|
status = result.get("status", "error")
|
|
996
|
-
statuses[api_key] = status
|
|
997
|
-
name = result.get("name", api_key)
|
|
998
1084
|
detail = result.get("detail", "")
|
|
1085
|
+
|
|
999
1086
|
if status == "valid":
|
|
1000
|
-
broker = acct.get("broker", "zerodha").lower()
|
|
1001
1087
|
if broker != "zerodha":
|
|
1002
|
-
#
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
proxy_str = acct.get("proxy")
|
|
1013
|
-
ws_status, ws_detail = await self._run_api_call(
|
|
1014
|
-
probe_ws_auth, api_key, access_token, proxy_str
|
|
1088
|
+
logs.append(f"[#00ff00]✓ Token OK:[/#] @{name}")
|
|
1089
|
+
else:
|
|
1090
|
+
# Zerodha REST is valid, but we already know WS failed from the fast-path above!
|
|
1091
|
+
is_bad = True
|
|
1092
|
+
status = "stream_forbidden"
|
|
1093
|
+
logs.append(
|
|
1094
|
+
f"[#ff0000]✗ Streaming rejected:[/#] @{name} — "
|
|
1095
|
+
f"WebSocket auth failed. REST works, so the "
|
|
1096
|
+
f"token is valid but this api_key lacks streaming access. "
|
|
1097
|
+
f"Check the app's subscription on the Kite Connect dashboard."
|
|
1015
1098
|
)
|
|
1016
|
-
if ws_status == "ok":
|
|
1017
|
-
self.log_message(f"[#00ff00]✓ Streaming OK:[/#] @{name}")
|
|
1018
|
-
elif ws_status == "auth_failed":
|
|
1019
|
-
any_bad = True
|
|
1020
|
-
statuses[api_key] = "stream_forbidden"
|
|
1021
|
-
self.log_message(
|
|
1022
|
-
f"[#ff0000]✗ Streaming rejected:[/#] @{name} — "
|
|
1023
|
-
f"WebSocket auth failed ({ws_detail}). REST works, so the "
|
|
1024
|
-
f"token is valid but this api_key lacks streaming access. "
|
|
1025
|
-
f"Check the app's subscription on the Kite Connect dashboard."
|
|
1026
|
-
)
|
|
1027
|
-
elif ws_status == "proxy_blocked":
|
|
1028
|
-
any_bad = True
|
|
1029
|
-
statuses[api_key] = "proxy_blocked"
|
|
1030
|
-
self.log_message(
|
|
1031
|
-
f"[#ff8700]⚠ Proxy blocked streaming:[/#] @{name} — "
|
|
1032
|
-
f"proxy refused the WebSocket tunnel ({ws_detail})."
|
|
1033
|
-
)
|
|
1034
|
-
else:
|
|
1035
|
-
# Inconclusive probe — let the ticker try anyway.
|
|
1036
|
-
self.log_message(
|
|
1037
|
-
f"[#ff8700]⚠ Streaming check inconclusive:[/#] @{name} "
|
|
1038
|
-
f"({ws_detail}). Will attempt to connect."
|
|
1039
|
-
)
|
|
1040
1099
|
elif status == "no_token":
|
|
1041
|
-
|
|
1042
|
-
|
|
1100
|
+
is_bad = True
|
|
1101
|
+
logs.append(f"[#ff8700]⚠ No token:[/#] @{name} — login required (run 'kcli init').")
|
|
1043
1102
|
elif status == "expired":
|
|
1044
|
-
|
|
1045
|
-
|
|
1103
|
+
is_bad = True
|
|
1104
|
+
logs.append(f"[#ff8700]⚠ Token expired:[/#] @{name} — re-login (run 'kcli init'). {detail}")
|
|
1046
1105
|
elif status == "forbidden":
|
|
1047
|
-
|
|
1048
|
-
|
|
1106
|
+
is_bad = True
|
|
1107
|
+
logs.append(f"[#ff0000]✗ Forbidden:[/#] @{name} — token rejected / no streaming entitlement. {detail}")
|
|
1049
1108
|
else:
|
|
1109
|
+
is_bad = True
|
|
1110
|
+
logs.append(f"[#ff0000]✗ Token check failed:[/#] @{name} — {detail}")
|
|
1111
|
+
|
|
1112
|
+
return api_key, status, name, detail, acct, logs, is_bad
|
|
1113
|
+
|
|
1114
|
+
tasks = [diagnose_account(acct) for acct in self.accounts]
|
|
1115
|
+
results = await asyncio.gather(*tasks)
|
|
1116
|
+
|
|
1117
|
+
for api_key, status, name, detail, acct, logs, is_bad in results:
|
|
1118
|
+
if not api_key:
|
|
1119
|
+
continue
|
|
1120
|
+
statuses[api_key] = status
|
|
1121
|
+
for log_msg in logs:
|
|
1122
|
+
self.log_message(log_msg)
|
|
1123
|
+
if is_bad:
|
|
1050
1124
|
any_bad = True
|
|
1051
|
-
self.log_message(f"[#ff0000]✗ Token check failed:[/#] @{name} — {detail}")
|
|
1052
1125
|
|
|
1053
1126
|
if any_bad:
|
|
1054
1127
|
self.log_message(
|
|
@@ -1090,15 +1163,16 @@ class KCLILiveSession:
|
|
|
1090
1163
|
async def _initial_fetch_and_connect(self) -> None:
|
|
1091
1164
|
"""Initial fetch of data and connect all WebSockets."""
|
|
1092
1165
|
self.log_message("Initializing connections and fetching data...")
|
|
1166
|
+
|
|
1167
|
+
# Fire off REST data fetch in the background so it doesn't block WebSockets
|
|
1168
|
+
if hasattr(self, "app") and self.app and self.app.loop:
|
|
1169
|
+
asyncio.run_coroutine_threadsafe(self._trigger_immediate_refresh(), self.app.loop)
|
|
1170
|
+
|
|
1093
1171
|
try:
|
|
1094
|
-
await self.
|
|
1095
|
-
except Exception as
|
|
1096
|
-
self.log_message(f"[#ff0000]Initial
|
|
1097
|
-
|
|
1098
|
-
# Diagnose token validity up front. The same api_key/access_token pair is
|
|
1099
|
-
# used for the WebSocket ticker, so a REST 403/expired here explains the
|
|
1100
|
-
# "1006 / 403 Forbidden" handshake failures.
|
|
1101
|
-
token_statuses = await self._diagnose_tokens()
|
|
1172
|
+
token_statuses = await self._diagnose_tokens()
|
|
1173
|
+
except Exception as e:
|
|
1174
|
+
self.log_message(f"[#ff0000]Initial task failed:[/#] {e}")
|
|
1175
|
+
token_statuses = {}
|
|
1102
1176
|
|
|
1103
1177
|
# Choose the primary streaming account (used for index + option-chain
|
|
1104
1178
|
# streaming). Index/OC ticks are subscribed only on this account.
|
|
@@ -1115,7 +1189,7 @@ class KCLILiveSession:
|
|
|
1115
1189
|
)
|
|
1116
1190
|
|
|
1117
1191
|
# Connect WebSockets for accounts that support it (Zerodha and Kotak).
|
|
1118
|
-
for acct in self.accounts:
|
|
1192
|
+
for idx, acct in enumerate(self.accounts):
|
|
1119
1193
|
api_key = acct.get("api_key")
|
|
1120
1194
|
broker = acct.get("broker", "zerodha").lower()
|
|
1121
1195
|
access_token = self.client.get_access_token(api_key)
|
|
@@ -1128,6 +1202,9 @@ class KCLILiveSession:
|
|
|
1128
1202
|
)
|
|
1129
1203
|
continue
|
|
1130
1204
|
|
|
1205
|
+
if idx > 0:
|
|
1206
|
+
await asyncio.sleep(0.2)
|
|
1207
|
+
|
|
1131
1208
|
if api_key and (access_token or broker == "kotak"):
|
|
1132
1209
|
try:
|
|
1133
1210
|
if broker == "kotak":
|
|
@@ -4021,6 +4098,18 @@ class KCLILiveSession:
|
|
|
4021
4098
|
get_vertical_scroll=lambda win: self._info_control.buffer.document.cursor_position_row,
|
|
4022
4099
|
)
|
|
4023
4100
|
|
|
4101
|
+
# Define tab bar control and window for the info pane
|
|
4102
|
+
self.tab_bar_control = FormattedTextControl(
|
|
4103
|
+
text=self._get_tab_bar_text,
|
|
4104
|
+
focusable=False,
|
|
4105
|
+
)
|
|
4106
|
+
self.tab_bar_window = Window(
|
|
4107
|
+
content=self.tab_bar_control,
|
|
4108
|
+
height=1,
|
|
4109
|
+
wrap_lines=False,
|
|
4110
|
+
style="class:tab_bar",
|
|
4111
|
+
)
|
|
4112
|
+
|
|
4024
4113
|
def make_scroll_handler(window, control):
|
|
4025
4114
|
original_handler = control.mouse_handler
|
|
4026
4115
|
def scroll_mouse_handler(mouse_event):
|
|
@@ -4136,12 +4225,11 @@ class KCLILiveSession:
|
|
|
4136
4225
|
|
|
4137
4226
|
@kb.add("tab")
|
|
4138
4227
|
def _tab(event):
|
|
4139
|
-
"""Cycle focus: input -> positions ->
|
|
4228
|
+
"""Cycle focus: input -> positions -> info pane -> input."""
|
|
4140
4229
|
cur = event.app.layout.current_control
|
|
4141
4230
|
cycle = [
|
|
4142
4231
|
self.input_field.control,
|
|
4143
4232
|
self.positions_control,
|
|
4144
|
-
self._logs_control,
|
|
4145
4233
|
self._info_control,
|
|
4146
4234
|
]
|
|
4147
4235
|
try:
|
|
@@ -4151,26 +4239,34 @@ class KCLILiveSession:
|
|
|
4151
4239
|
nxt = self.input_field.control
|
|
4152
4240
|
event.app.layout.focus(nxt)
|
|
4153
4241
|
|
|
4154
|
-
#
|
|
4242
|
+
# Ctrl+1/2/3/4 & F1-F4 — switch info pane content
|
|
4243
|
+
@kb.add("c-1")
|
|
4155
4244
|
@kb.add("f1")
|
|
4156
|
-
def
|
|
4245
|
+
def _c1(event):
|
|
4157
4246
|
self.info_mode = "orders_pending"
|
|
4158
4247
|
self._update_info_buffer()
|
|
4248
|
+
event.app.invalidate()
|
|
4159
4249
|
|
|
4250
|
+
@kb.add("c-2")
|
|
4160
4251
|
@kb.add("f2")
|
|
4161
|
-
def
|
|
4252
|
+
def _c2(event):
|
|
4162
4253
|
self.info_mode = "orders_executed"
|
|
4163
4254
|
self._update_info_buffer()
|
|
4255
|
+
event.app.invalidate()
|
|
4164
4256
|
|
|
4257
|
+
@kb.add("c-3")
|
|
4165
4258
|
@kb.add("f3")
|
|
4166
|
-
def
|
|
4259
|
+
def _c3(event):
|
|
4167
4260
|
self.info_mode = "oc"
|
|
4168
4261
|
self._update_info_buffer()
|
|
4262
|
+
event.app.invalidate()
|
|
4169
4263
|
|
|
4264
|
+
@kb.add("c-4")
|
|
4170
4265
|
@kb.add("f4")
|
|
4171
|
-
def
|
|
4266
|
+
def _c4(event):
|
|
4172
4267
|
self.info_mode = "advisor"
|
|
4173
4268
|
self._update_info_buffer()
|
|
4269
|
+
event.app.invalidate()
|
|
4174
4270
|
|
|
4175
4271
|
# Ctrl+Left/Right — adjust left/right pane split
|
|
4176
4272
|
@kb.add("c-left")
|
|
@@ -4183,17 +4279,6 @@ class KCLILiveSession:
|
|
|
4183
4279
|
self.left_width_pct = min(80, self.left_width_pct + 5)
|
|
4184
4280
|
event.app.invalidate()
|
|
4185
4281
|
|
|
4186
|
-
# Ctrl+Up/Down — adjust log pane height within left half
|
|
4187
|
-
@kb.add("c-up")
|
|
4188
|
-
def _shrink_log(event):
|
|
4189
|
-
self.log_height_lines = max(4, self.log_height_lines - 2)
|
|
4190
|
-
event.app.invalidate()
|
|
4191
|
-
|
|
4192
|
-
@kb.add("c-down")
|
|
4193
|
-
def _grow_log(event):
|
|
4194
|
-
self.log_height_lines = min(30, self.log_height_lines + 2)
|
|
4195
|
-
event.app.invalidate()
|
|
4196
|
-
|
|
4197
4282
|
# Build dividers
|
|
4198
4283
|
self.vertical_divider = Window(
|
|
4199
4284
|
content=FormattedTextControl(
|
|
@@ -4204,15 +4289,6 @@ class KCLILiveSession:
|
|
|
4204
4289
|
style="class:divider",
|
|
4205
4290
|
)
|
|
4206
4291
|
|
|
4207
|
-
self.horizontal_divider = Window(
|
|
4208
|
-
content=FormattedTextControl(
|
|
4209
|
-
text="━" * 250,
|
|
4210
|
-
focusable=False,
|
|
4211
|
-
),
|
|
4212
|
-
height=1,
|
|
4213
|
-
style="class:divider",
|
|
4214
|
-
)
|
|
4215
|
-
|
|
4216
4292
|
# Divider mouse handlers
|
|
4217
4293
|
def v_divider_mouse_handler(mouse_event):
|
|
4218
4294
|
from prompt_toolkit.mouse_events import MouseEventType
|
|
@@ -4221,15 +4297,7 @@ class KCLILiveSession:
|
|
|
4221
4297
|
self.vertical_divider.style = "class:divider.dragging"
|
|
4222
4298
|
self.app.invalidate()
|
|
4223
4299
|
|
|
4224
|
-
def h_divider_mouse_handler(mouse_event):
|
|
4225
|
-
from prompt_toolkit.mouse_events import MouseEventType
|
|
4226
|
-
if mouse_event.event_type == MouseEventType.MOUSE_DOWN:
|
|
4227
|
-
self.dragging_horizontal = True
|
|
4228
|
-
self.horizontal_divider.style = "class:divider.dragging"
|
|
4229
|
-
self.app.invalidate()
|
|
4230
|
-
|
|
4231
4300
|
self.vertical_divider.content.mouse_handler = v_divider_mouse_handler
|
|
4232
|
-
self.horizontal_divider.content.mouse_handler = h_divider_mouse_handler
|
|
4233
4301
|
|
|
4234
4302
|
# Monkey-patch MouseHandlers.__init__ to wrap with DragInterceptDict
|
|
4235
4303
|
original_init = MouseHandlers.__init__
|
|
@@ -4240,32 +4308,27 @@ class KCLILiveSession:
|
|
|
4240
4308
|
|
|
4241
4309
|
# ── Dynamic Layout ─────────────────────────────────────────
|
|
4242
4310
|
# DynamicContainer rebuilds the body on each render using current
|
|
4243
|
-
# self.left_width_pct
|
|
4311
|
+
# self.left_width_pct value.
|
|
4244
4312
|
def _build_body():
|
|
4245
4313
|
self._update_positions_display()
|
|
4246
4314
|
lw = self.left_width_pct # left weight (20-80)
|
|
4247
4315
|
rw = 100 - lw # right weight
|
|
4248
4316
|
|
|
4249
4317
|
return VSplit([
|
|
4250
|
-
# ── LEFT half: Positions (scrollable)
|
|
4251
|
-
|
|
4252
|
-
|
|
4253
|
-
|
|
4254
|
-
|
|
4255
|
-
|
|
4256
|
-
|
|
4257
|
-
self.horizontal_divider,
|
|
4258
|
-
Frame(
|
|
4259
|
-
title="Status Logs [Tab: focus] [Ctrl+↑↓: resize]",
|
|
4260
|
-
body=self.logs_window,
|
|
4261
|
-
style=self._get_frame_style(self.logs_window),
|
|
4262
|
-
),
|
|
4263
|
-
], width=D(weight=lw)),
|
|
4318
|
+
# ── LEFT half: Positions (scrollable) ──
|
|
4319
|
+
Frame(
|
|
4320
|
+
title="Active Positions (Live Ticks) [Tab: focus]",
|
|
4321
|
+
body=self.positions_window,
|
|
4322
|
+
style=self._get_frame_style(self.positions_window),
|
|
4323
|
+
width=D(weight=lw),
|
|
4324
|
+
),
|
|
4264
4325
|
self.vertical_divider,
|
|
4265
|
-
# ── RIGHT half: Info Pane (scrollable) ──
|
|
4326
|
+
# ── RIGHT half: Info Pane with clickable tabs (scrollable) ──
|
|
4266
4327
|
Frame(
|
|
4267
|
-
title="
|
|
4328
|
+
title="",
|
|
4268
4329
|
body=HSplit([
|
|
4330
|
+
self.tab_bar_window,
|
|
4331
|
+
Window(height=1, char="─", style="class:divider"),
|
|
4269
4332
|
Window(
|
|
4270
4333
|
content=self.market_indices_control,
|
|
4271
4334
|
height=1,
|
|
@@ -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.2"
|
|
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"
|
|
@@ -94,5 +94,27 @@ class TestUIComponents(unittest.IsolatedAsyncioTestCase):
|
|
|
94
94
|
mock_run_coroutine.assert_called_once()
|
|
95
95
|
self.session.log_message.assert_any_call("Triggering manual WebSocket and REST reconnection...")
|
|
96
96
|
|
|
97
|
+
@patch("cli.live_session.KCLILiveSession._update_info_buffer")
|
|
98
|
+
def test_info_pane_tabs(self, mock_update_buffer):
|
|
99
|
+
self.session.info_mode = "orders_pending"
|
|
100
|
+
fragments = self.session._get_tab_bar_text()
|
|
101
|
+
|
|
102
|
+
self.assertEqual(fragments[1][0], "class:tab.active")
|
|
103
|
+
self.assertEqual(fragments[1][1], " Pending ")
|
|
104
|
+
self.assertEqual(fragments[3][0], "class:tab.inactive")
|
|
105
|
+
self.assertEqual(fragments[3][1], " Executed ")
|
|
106
|
+
|
|
107
|
+
click_handler_oc = fragments[5][2]
|
|
108
|
+
|
|
109
|
+
from prompt_toolkit.mouse_events import MouseEventType
|
|
110
|
+
mock_event = MagicMock()
|
|
111
|
+
mock_event.event_type = MouseEventType.MOUSE_UP
|
|
112
|
+
|
|
113
|
+
click_handler_oc(mock_event)
|
|
114
|
+
|
|
115
|
+
self.assertEqual(self.session.info_mode, "oc")
|
|
116
|
+
mock_update_buffer.assert_called_once()
|
|
117
|
+
self.session.app.invalidate.assert_called_once()
|
|
118
|
+
|
|
97
119
|
if __name__ == "__main__":
|
|
98
120
|
unittest.main()
|
|
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
|
|
File without changes
|