kitecli 0.2.6__tar.gz → 0.2.9__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.6 → kitecli-0.2.9}/PKG-INFO +1 -1
  2. {kitecli-0.2.6 → kitecli-0.2.9}/cli/kite_manager.py +23 -9
  3. {kitecli-0.2.6 → kitecli-0.2.9}/cli/kotak_manager.py +51 -27
  4. {kitecli-0.2.6 → kitecli-0.2.9}/cli/live_session.py +110 -20
  5. {kitecli-0.2.6 → kitecli-0.2.9}/kitecli.egg-info/PKG-INFO +1 -1
  6. {kitecli-0.2.6 → kitecli-0.2.9}/pyproject.toml +1 -1
  7. {kitecli-0.2.6 → kitecli-0.2.9}/README.md +0 -0
  8. {kitecli-0.2.6 → kitecli-0.2.9}/cli/__init__.py +0 -0
  9. {kitecli-0.2.6 → kitecli-0.2.9}/cli/advisor.py +0 -0
  10. {kitecli-0.2.6 → kitecli-0.2.9}/cli/api_client.py +0 -0
  11. {kitecli-0.2.6 → kitecli-0.2.9}/cli/base_manager.py +0 -0
  12. {kitecli-0.2.6 → kitecli-0.2.9}/cli/config.py +0 -0
  13. {kitecli-0.2.6 → kitecli-0.2.9}/cli/display.py +0 -0
  14. {kitecli-0.2.6 → kitecli-0.2.9}/cli/executor.py +0 -0
  15. {kitecli-0.2.6 → kitecli-0.2.9}/cli/main.py +0 -0
  16. {kitecli-0.2.6 → kitecli-0.2.9}/cli/nli.py +0 -0
  17. {kitecli-0.2.6 → kitecli-0.2.9}/cli/parser.py +0 -0
  18. {kitecli-0.2.6 → kitecli-0.2.9}/cli/recorder.py +0 -0
  19. {kitecli-0.2.6 → kitecli-0.2.9}/cli/telegram_bot.py +0 -0
  20. {kitecli-0.2.6 → kitecli-0.2.9}/kitecli.egg-info/SOURCES.txt +0 -0
  21. {kitecli-0.2.6 → kitecli-0.2.9}/kitecli.egg-info/dependency_links.txt +0 -0
  22. {kitecli-0.2.6 → kitecli-0.2.9}/kitecli.egg-info/entry_points.txt +0 -0
  23. {kitecli-0.2.6 → kitecli-0.2.9}/kitecli.egg-info/requires.txt +0 -0
  24. {kitecli-0.2.6 → kitecli-0.2.9}/kitecli.egg-info/top_level.txt +0 -0
  25. {kitecli-0.2.6 → kitecli-0.2.9}/setup.cfg +0 -0
  26. {kitecli-0.2.6 → kitecli-0.2.9}/tests/test_multi_broker.py +0 -0
  27. {kitecli-0.2.6 → kitecli-0.2.9}/tests/test_nli.py +0 -0
  28. {kitecli-0.2.6 → kitecli-0.2.9}/tests/test_parser.py +0 -0
  29. {kitecli-0.2.6 → kitecli-0.2.9}/tests/test_telegram.py +0 -0
  30. {kitecli-0.2.6 → kitecli-0.2.9}/tests/test_ui.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kitecli
3
- Version: 0.2.6
3
+ Version: 0.2.9
4
4
  Summary: KiteCLI — Multi-account, multi-broker trading positions viewer (Zerodha + Kotak Neo)
5
5
  Author: KiteCLI Team
6
6
  License: MIT
@@ -1039,22 +1039,36 @@ class KiteAccountManager(BaseBrokerManager):
1039
1039
  return {}
1040
1040
 
1041
1041
  def get_market_indices(self) -> dict[str, Any]:
1042
- # 1. Try Kite first
1042
+ # 1. Try Kite ohlc first for last_price and yesterday's close
1043
1043
  for api_key in self.get_all_api_keys():
1044
1044
  if self.is_authenticated(api_key):
1045
1045
  kite = self._clients.get(api_key)
1046
1046
  if kite:
1047
1047
  try:
1048
- data = kite.ltp(["NSE:NIFTY 50", "BSE:SENSEX", "NSE:INDIA VIX"])
1049
- nifty = data.get("NSE:NIFTY 50", {}).get("last_price")
1050
- sensex = data.get("BSE:SENSEX", {}).get("last_price")
1051
- vix = data.get("NSE:INDIA VIX", {}).get("last_price")
1052
- if nifty or sensex or vix:
1048
+ data = kite.ohlc(["NSE:NIFTY 50", "BSE:SENSEX", "NSE:INDIA VIX"])
1049
+
1050
+ def parse_item(item_data):
1051
+ if not isinstance(item_data, dict):
1052
+ return None, None
1053
+ last = item_data.get("last_price")
1054
+ ohlc = item_data.get("ohlc", {})
1055
+ close = ohlc.get("close") if isinstance(ohlc, dict) else None
1056
+ change = (last - close) if (last is not None and close is not None) else None
1057
+ return last, change
1058
+
1059
+ nifty_last, nifty_change = parse_item(data.get("NSE:NIFTY 50"))
1060
+ sensex_last, sensex_change = parse_item(data.get("BSE:SENSEX"))
1061
+ vix_last, vix_change = parse_item(data.get("NSE:INDIA VIX"))
1062
+
1063
+ if nifty_last or sensex_last or vix_last:
1053
1064
  return {
1054
1065
  "status": "success",
1055
- "nifty": nifty,
1056
- "sensex": sensex,
1057
- "vix": vix,
1066
+ "nifty": nifty_last,
1067
+ "nifty_change": nifty_change,
1068
+ "sensex": sensex_last,
1069
+ "sensex_change": sensex_change,
1070
+ "vix": vix_last,
1071
+ "vix_change": vix_change,
1058
1072
  }
1059
1073
  except Exception as exc:
1060
1074
  logger.warning("Kite indices fetch failed for api_key=%s…: %s", api_key[:8], exc)
@@ -228,40 +228,46 @@ class KotakAccountManager(BaseBrokerManager):
228
228
  if 'Content-Type' not in headers:
229
229
  headers['Content-Type'] = 'application/json'
230
230
 
231
- u = url
232
- if query_params:
233
- u_with_params = u
234
- if '?' not in u:
235
- u_with_params += '?' + _urlencode(query_params)
231
+ def _do_req(h, max_proxy_retries: int = 2):
232
+ if query_params and isinstance(query_params, dict):
233
+ base_u = url.split('?')[0]
234
+ u_curr = base_u + '?' + _urlencode(query_params)
236
235
  else:
237
- pass
238
- else:
239
- u_with_params = u
236
+ u_curr = url
240
237
 
241
- def _do_req(h):
242
238
  req_kwargs = {}
243
239
  if _proxies:
244
- # Kotak Neo static IP whitelisting is only enforced on order APIs
245
- url_lower = u_with_params.lower()
246
- if "/order/" in url_lower and any(x in url_lower for x in ["/place", "/modify", "/cancel"]):
240
+ url_lower = u_curr.lower()
241
+ is_order_api = "/order/" in url_lower and any(x in url_lower for x in ["/place", "/modify", "/cancel"])
242
+ is_login_api = "/login/" in url_lower or "tradeapilogin" in url_lower or "tradeapivalidate" in url_lower
243
+ if is_order_api or is_login_api:
247
244
  req_kwargs["proxies"] = _proxies
248
245
 
249
- logger.debug("Kotak REST [%s] requesting: %s", method, u_with_params)
246
+ logger.debug("Kotak REST [%s] requesting: %s", method, u_curr)
250
247
  logger.debug("Kotak REST headers: %s", {k: v[:15] + '...' if len(str(v)) > 15 else v for k, v in h.items()})
251
248
 
252
- if method in ['POST', 'PUT', 'PATCH', 'DELETE']:
253
- if _re.search('json', h['Content-Type'], _re.IGNORECASE):
254
- request_body = _json.dumps(body) if body is not None else None
255
- return _req.post(url=u_with_params, headers=h, data=request_body, **req_kwargs)
256
- elif _re.search('x-www-form-urlencoded', h['Content-Type'], _re.IGNORECASE):
257
- request_body = {"jData": _json.dumps(body)} if body is not None else {}
258
- return _req.post(url=u_with_params, headers=h, data=request_body, **req_kwargs)
259
- elif method == 'GET':
260
- return _req.get(url=u_with_params, headers=h, **req_kwargs)
261
- return _orig_request(method, url, query_params=query_params, headers=h, body=body)
249
+ try:
250
+ if method in ['POST', 'PUT', 'PATCH', 'DELETE']:
251
+ if _re.search('json', h.get('Content-Type', ''), _re.IGNORECASE):
252
+ request_body = _json.dumps(body) if body is not None else None
253
+ return _req.post(url=u_curr, headers=h, data=request_body, **req_kwargs)
254
+ elif _re.search('x-www-form-urlencoded', h.get('Content-Type', ''), _re.IGNORECASE):
255
+ request_body = {"jData": _json.dumps(body)} if body is not None else {}
256
+ return _req.post(url=u_curr, headers=h, data=request_body, **req_kwargs)
257
+ elif method == 'GET':
258
+ return _req.get(url=u_curr, headers=h, **req_kwargs)
259
+ return _orig_request(method, url, query_params=query_params, headers=h, body=body)
260
+ except Exception as req_exc:
261
+ exc_str = str(req_exc).lower()
262
+ if max_proxy_retries > 0 and _proxies and ("proxy" in exc_str or "502" in exc_str or "tunnel" in exc_str or "connect" in exc_str):
263
+ import time
264
+ logger.warning("Kotak static proxy transient error (%s). Retrying via proxy in 1s...", req_exc)
265
+ time.sleep(1.0)
266
+ return _do_req(h, max_proxy_retries=max_proxy_retries - 1)
267
+ raise
262
268
 
263
269
  resp = _do_req(headers)
264
- logger.info("Kotak REST %s %s → status %s", method, u_with_params, resp.status_code)
270
+ logger.info("Kotak REST %s %s → status %s", method, url, resp.status_code)
265
271
  try:
266
272
  logger.debug("Kotak REST response body: %s", resp.json())
267
273
  except Exception:
@@ -284,16 +290,20 @@ class KotakAccountManager(BaseBrokerManager):
284
290
  logger.info("Kotak session invalid (stCode=%s, msg='%s') for account '%s'. Triggering auto-login...", st_code, resp_json.get("errMsg"), name)
285
291
  if self.auto_login(consumer_key):
286
292
  # Update headers with new credentials
293
+ new_sid = client.configuration.serverId or client.configuration.edit_sid
287
294
  headers["Sid"] = client.configuration.edit_sid
288
295
  headers["Auth"] = client.configuration.edit_token
296
+ if new_sid:
297
+ url = _re.sub(r'([?&]sId=)[^&]*', r'\g<1>' + str(new_sid), url)
298
+ url = _re.sub(r'([?&]sid=)[^&]*', r'\g<1>' + str(new_sid), url)
289
299
  if isinstance(query_params, dict):
290
300
  if "sId" in query_params:
291
- query_params["sId"] = client.configuration.serverId
301
+ query_params["sId"] = new_sid
292
302
  if "sid" in query_params:
293
- query_params["sid"] = client.configuration.edit_sid
303
+ query_params["sid"] = new_sid
294
304
  logger.info("Retrying request after successful auto-login...")
295
305
  resp = _do_req(headers)
296
- logger.info("Kotak REST retry %s %s → status %s", method, u_with_params, resp.status_code)
306
+ logger.info("Kotak REST retry %s %s → status %s", method, url, resp.status_code)
297
307
  try:
298
308
  logger.debug("Kotak REST retry response body: %s", resp.json())
299
309
  except Exception:
@@ -628,6 +638,18 @@ class KotakAccountManager(BaseBrokerManager):
628
638
  except ValueError:
629
639
  pending_quantity = 0
630
640
 
641
+ fld_qty_str = str(ord.get("fldQty", ord.get("filled_quantity", "0")))
642
+ try:
643
+ filled_quantity = int(fld_qty_str)
644
+ except ValueError:
645
+ filled_quantity = 0
646
+
647
+ avg_prc_str = str(ord.get("avgPrc", ord.get("average_price", "0")))
648
+ try:
649
+ average_price = float(avg_prc_str)
650
+ except ValueError:
651
+ average_price = 0.0
652
+
631
653
  price_str = str(ord.get("prc", ord.get("price", "0")))
632
654
  try:
633
655
  price = float(price_str)
@@ -639,8 +661,10 @@ class KotakAccountManager(BaseBrokerManager):
639
661
  "tradingsymbol": ord.get("trdSym", ord.get("tradingsymbol", "")),
640
662
  "transaction_type": str(ord.get("trnsTp", ord.get("transaction_type", ""))).upper(),
641
663
  "quantity": quantity,
664
+ "filled_quantity": filled_quantity,
642
665
  "pending_quantity": pending_quantity,
643
666
  "price": price,
667
+ "average_price": average_price,
644
668
  "order_type": str(ord.get("prcTp", ord.get("order_type", "MARKET"))).upper(),
645
669
  "status": status,
646
670
  "product": str(ord.get("prod", ord.get("product", "NRML"))).upper(),
@@ -312,6 +312,7 @@ class KCLILiveSession:
312
312
  # NSE:NIFTY 50 -> 256265, BSE:SENSEX -> 265, NSE:INDIA VIX -> 264969
313
313
  self.index_tokens = {256265: "nifty", 265: "sensex", 264969: "vix"}
314
314
  self.index_values = {"nifty": None, "sensex": None, "vix": None}
315
+ self.index_close_prices = {}
315
316
 
316
317
  # Designated primary streaming account. Index and option-chain ticks are
317
318
  # subscribed only on this account's ticker (chosen in
@@ -1329,7 +1330,9 @@ class KCLILiveSession:
1329
1330
  code == 403
1330
1331
  or "403" in reason_str
1331
1332
  or "auth_failed" in reason_str
1332
- or "token" in reason_str
1333
+ or "invalid token" in reason_str
1334
+ or "token expired" in reason_str
1335
+ or "unauthorized" in reason_str
1333
1336
  )
1334
1337
  if is_auth_failure:
1335
1338
  self.log_message(
@@ -1378,9 +1381,22 @@ class KCLILiveSession:
1378
1381
  if not token or ltp is None:
1379
1382
  continue
1380
1383
 
1381
- # Market index tick → update the header values.
1384
+ # Market index tick → update the header values & streaming change.
1382
1385
  if token in self.index_tokens:
1383
- self.index_values[self.index_tokens[token]] = ltp
1386
+ key = self.index_tokens[token]
1387
+ self.index_values[key] = ltp
1388
+
1389
+ # Extract close from tick OHLC if available, or use cached REST close price
1390
+ tick_close = None
1391
+ if isinstance(tick.get("ohlc"), dict):
1392
+ tick_close = tick["ohlc"].get("close")
1393
+ if tick_close:
1394
+ self.index_close_prices[key] = tick_close
1395
+
1396
+ close_val = self.index_close_prices.get(key)
1397
+ if close_val is not None:
1398
+ self.index_values[f"{key}_change"] = ltp - close_val
1399
+
1384
1400
  indices_updated = True
1385
1401
  continue
1386
1402
 
@@ -1458,18 +1474,82 @@ class KCLILiveSession:
1458
1474
  self.app.invalidate()
1459
1475
 
1460
1476
  def _render_indices_html(self) -> HTML:
1461
- """Build the formatted market-index header from the current values."""
1462
- def fmt(v):
1463
- return f"{v:,.2f}" if v else "N/A"
1464
-
1465
- nifty_str = fmt(self.index_values.get("nifty"))
1466
- sensex_str = fmt(self.index_values.get("sensex"))
1467
- vix_str = fmt(self.index_values.get("vix"))
1468
- return HTML(
1469
- f" <ansicyan><b>NIFTY 50:</b></ansicyan> <style fg='#ffffff'>{nifty_str}</style> "
1470
- f"│ <ansiyellow><b>SENSEX:</b></ansiyellow> <style fg='#ffffff'>{sensex_str}</style> "
1471
- f"│ <ansired><b>INDIA VIX:</b></ansired> <style fg='#ffffff'>{vix_str}</style>"
1472
- )
1477
+ """Build formatted market-index header tailored to the Right Pane width."""
1478
+ total_cols = 100
1479
+ if hasattr(self, "app") and self.app and hasattr(self.app, "output") and self.app.output:
1480
+ try:
1481
+ cols = self.app.output.get_size().columns
1482
+ if isinstance(cols, int):
1483
+ total_cols = cols
1484
+ except Exception:
1485
+ pass
1486
+
1487
+ # The indices bar is rendered inside the Right Pane, so compute right pane width
1488
+ lw = getattr(self, "left_pane_weight", 50)
1489
+ rw = getattr(self, "right_pane_weight", 50)
1490
+ pane_cols = int(total_cols * (rw / max(1, lw + rw)))
1491
+
1492
+ def fmt_item(val, chg, is_vix: bool = False, compact: bool = False):
1493
+ if val is None:
1494
+ return "<style fg='#ffffff'>N/A</style>"
1495
+ val_str = f"{val:,.2f}"
1496
+ if chg is None:
1497
+ return f"<style fg='#ffffff'>{val_str}</style>"
1498
+
1499
+ # For VIX: negative change (volatility drop) is green, positive (volatility spike) is red
1500
+ # For NIFTY/SENSEX: positive is green, negative is red
1501
+ if is_vix:
1502
+ color = "#00ff00" if chg <= 0 else "#ff5f5f"
1503
+ else:
1504
+ color = "#00ff00" if chg >= 0 else "#ff5f5f"
1505
+
1506
+ sign = "+" if chg >= 0 else ""
1507
+ if compact:
1508
+ # In tight right-pane spaces, round large changes to integers e.g. (+80) instead of (+80.50)
1509
+ chg_str = f"{sign}{int(round(chg)):,d}" if not is_vix else f"{sign}{chg:.2f}"
1510
+ else:
1511
+ chg_str = f"{sign}{chg:,.2f}"
1512
+ return f"<style fg='{color}'>{val_str} ({chg_str})</style>"
1513
+
1514
+ if pane_cols >= 75:
1515
+ # Full width: NIFTY 50: 24,234.85 (-99.80) │ SENSEX: 77,708.24 (-441.41) │ IV: 13.45 (+0.25)
1516
+ nifty_html = fmt_item(self.index_values.get("nifty"), self.index_values.get("nifty_change"))
1517
+ sensex_html = fmt_item(self.index_values.get("sensex"), self.index_values.get("sensex_change"))
1518
+ vix_html = fmt_item(self.index_values.get("vix"), self.index_values.get("vix_change"), is_vix=True)
1519
+ return HTML(
1520
+ f" <ansicyan><b>NIFTY 50:</b></ansicyan> {nifty_html} "
1521
+ f"│ <ansiyellow><b>SENSEX:</b></ansiyellow> {sensex_html} "
1522
+ f"│ <ansired><b>IV:</b></ansired> {vix_html}"
1523
+ )
1524
+ elif pane_cols >= 55:
1525
+ # Medium width: NIFTY: 24,234.85 (-100) │ SENSEX: 77,708.24 (-441) │ IV: 13.45 (+0.25)
1526
+ nifty_html = fmt_item(self.index_values.get("nifty"), self.index_values.get("nifty_change"), compact=True)
1527
+ sensex_html = fmt_item(self.index_values.get("sensex"), self.index_values.get("sensex_change"), compact=True)
1528
+ vix_html = fmt_item(self.index_values.get("vix"), self.index_values.get("vix_change"), is_vix=True, compact=True)
1529
+ return HTML(
1530
+ f"<ansicyan><b>NIFTY:</b></ansicyan> {nifty_html} "
1531
+ f"│ <ansiyellow><b>SENSEX:</b></ansiyellow> {sensex_html} "
1532
+ f"│ <ansired><b>IV:</b></ansired> {vix_html}"
1533
+ )
1534
+ else:
1535
+ # Ultra compact for narrow right pane (< 55 cols): N: 24,234.85 │ S: 77,708.24 │ IV: 13.45
1536
+ def fmt_val_only(val, chg, is_vix=False):
1537
+ if val is None:
1538
+ return "<style fg='#ffffff'>N/A</style>"
1539
+ val_str = f"{val:,.2f}"
1540
+ if chg is None:
1541
+ return f"<style fg='#ffffff'>{val_str}</style>"
1542
+ color = ("#00ff00" if chg <= 0 else "#ff5f5f") if is_vix else ("#00ff00" if chg >= 0 else "#ff5f5f")
1543
+ return f"<style fg='{color}'>{val_str}</style>"
1544
+
1545
+ nifty_html = fmt_val_only(self.index_values.get("nifty"), self.index_values.get("nifty_change"))
1546
+ sensex_html = fmt_val_only(self.index_values.get("sensex"), self.index_values.get("sensex_change"))
1547
+ vix_html = fmt_val_only(self.index_values.get("vix"), self.index_values.get("vix_change"), is_vix=True)
1548
+ return HTML(
1549
+ f"<ansicyan><b>N:</b></ansicyan> {nifty_html} "
1550
+ f"│ <ansiyellow><b>S:</b></ansiyellow> {sensex_html} "
1551
+ f"│ <ansired><b>IV:</b></ansired> {vix_html}"
1552
+ )
1473
1553
 
1474
1554
  def _get_active_ticker(self):
1475
1555
  """Get the primary ticker if it is connected, else fallback to any connected ticker."""
@@ -1730,9 +1810,20 @@ class KCLILiveSession:
1730
1810
  if self._ws_should_log("rest_err:indices", min_interval=30.0):
1731
1811
  self.log_message(f"[#ff8700]Indices fetch failed:[/#] {self._clean_error(str(indices_resp))}")
1732
1812
  elif isinstance(indices_resp, dict) and indices_resp.get("status") == "success":
1733
- self.index_values["nifty"] = indices_resp.get("nifty")
1734
- self.index_values["sensex"] = indices_resp.get("sensex")
1735
- self.index_values["vix"] = indices_resp.get("vix")
1813
+ self.index_values["nifty"] = indices_resp.get("nifty")
1814
+ self.index_values["nifty_change"] = indices_resp.get("nifty_change")
1815
+ self.index_values["sensex"] = indices_resp.get("sensex")
1816
+ self.index_values["sensex_change"] = indices_resp.get("sensex_change")
1817
+ self.index_values["vix"] = indices_resp.get("vix")
1818
+ self.index_values["vix_change"] = indices_resp.get("vix_change")
1819
+
1820
+ # Cache close prices (last - change) so live WebSocket ticks can dynamically recalculate change
1821
+ for key in ["nifty", "sensex", "vix"]:
1822
+ last_val = self.index_values.get(key)
1823
+ chg_val = self.index_values.get(f"{key}_change")
1824
+ if last_val is not None and chg_val is not None:
1825
+ self.index_close_prices[key] = last_val - chg_val
1826
+
1736
1827
  self.market_indices_control.text = self._render_indices_html()
1737
1828
  elif isinstance(indices_resp, dict):
1738
1829
  errors_this_cycle += 1
@@ -3892,7 +3983,7 @@ class KCLILiveSession:
3892
3983
  Ctrl + E -> Executed Orders
3893
3984
  Ctrl + O -> Option Chain
3894
3985
  Ctrl + G -> AI Advisor
3895
- Ctrl + H -> Help
3986
+ F5 / Esc+5 -> Help
3896
3987
 
3897
3988
  Function Keys / Alt:
3898
3989
  F1 / F2 / F3 / F4 / F5
@@ -4359,7 +4450,6 @@ class KCLILiveSession:
4359
4450
  event.app.invalidate()
4360
4451
 
4361
4452
  @kb.add("c-5")
4362
- @kb.add("c-h")
4363
4453
  @kb.add("escape", "5")
4364
4454
  @kb.add("f5")
4365
4455
  def _c5(event):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kitecli
3
- Version: 0.2.6
3
+ Version: 0.2.9
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.6"
7
+ version = "0.2.9"
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