mainsequence 2.0.0__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 (110) hide show
  1. mainsequence/__init__.py +0 -0
  2. mainsequence/__main__.py +9 -0
  3. mainsequence/cli/__init__.py +1 -0
  4. mainsequence/cli/api.py +157 -0
  5. mainsequence/cli/cli.py +442 -0
  6. mainsequence/cli/config.py +78 -0
  7. mainsequence/cli/ssh_utils.py +126 -0
  8. mainsequence/client/__init__.py +17 -0
  9. mainsequence/client/base.py +431 -0
  10. mainsequence/client/data_sources_interfaces/__init__.py +0 -0
  11. mainsequence/client/data_sources_interfaces/duckdb.py +1468 -0
  12. mainsequence/client/data_sources_interfaces/timescale.py +479 -0
  13. mainsequence/client/models_helpers.py +113 -0
  14. mainsequence/client/models_report_studio.py +412 -0
  15. mainsequence/client/models_tdag.py +2276 -0
  16. mainsequence/client/models_vam.py +1983 -0
  17. mainsequence/client/utils.py +387 -0
  18. mainsequence/dashboards/__init__.py +0 -0
  19. mainsequence/dashboards/streamlit/__init__.py +0 -0
  20. mainsequence/dashboards/streamlit/assets/config.toml +12 -0
  21. mainsequence/dashboards/streamlit/assets/favicon.png +0 -0
  22. mainsequence/dashboards/streamlit/assets/logo.png +0 -0
  23. mainsequence/dashboards/streamlit/core/__init__.py +0 -0
  24. mainsequence/dashboards/streamlit/core/theme.py +212 -0
  25. mainsequence/dashboards/streamlit/pages/__init__.py +0 -0
  26. mainsequence/dashboards/streamlit/scaffold.py +220 -0
  27. mainsequence/instrumentation/__init__.py +7 -0
  28. mainsequence/instrumentation/utils.py +101 -0
  29. mainsequence/instruments/__init__.py +1 -0
  30. mainsequence/instruments/data_interface/__init__.py +10 -0
  31. mainsequence/instruments/data_interface/data_interface.py +361 -0
  32. mainsequence/instruments/instruments/__init__.py +3 -0
  33. mainsequence/instruments/instruments/base_instrument.py +85 -0
  34. mainsequence/instruments/instruments/bond.py +447 -0
  35. mainsequence/instruments/instruments/european_option.py +74 -0
  36. mainsequence/instruments/instruments/interest_rate_swap.py +217 -0
  37. mainsequence/instruments/instruments/json_codec.py +585 -0
  38. mainsequence/instruments/instruments/knockout_fx_option.py +146 -0
  39. mainsequence/instruments/instruments/position.py +475 -0
  40. mainsequence/instruments/instruments/ql_fields.py +239 -0
  41. mainsequence/instruments/instruments/vanilla_fx_option.py +107 -0
  42. mainsequence/instruments/pricing_models/__init__.py +0 -0
  43. mainsequence/instruments/pricing_models/black_scholes.py +49 -0
  44. mainsequence/instruments/pricing_models/bond_pricer.py +182 -0
  45. mainsequence/instruments/pricing_models/fx_option_pricer.py +90 -0
  46. mainsequence/instruments/pricing_models/indices.py +350 -0
  47. mainsequence/instruments/pricing_models/knockout_fx_pricer.py +209 -0
  48. mainsequence/instruments/pricing_models/swap_pricer.py +502 -0
  49. mainsequence/instruments/settings.py +175 -0
  50. mainsequence/instruments/utils.py +29 -0
  51. mainsequence/logconf.py +284 -0
  52. mainsequence/reportbuilder/__init__.py +0 -0
  53. mainsequence/reportbuilder/__main__.py +0 -0
  54. mainsequence/reportbuilder/examples/ms_template_report.py +706 -0
  55. mainsequence/reportbuilder/model.py +713 -0
  56. mainsequence/reportbuilder/slide_templates.py +532 -0
  57. mainsequence/tdag/__init__.py +8 -0
  58. mainsequence/tdag/__main__.py +0 -0
  59. mainsequence/tdag/config.py +129 -0
  60. mainsequence/tdag/data_nodes/__init__.py +12 -0
  61. mainsequence/tdag/data_nodes/build_operations.py +751 -0
  62. mainsequence/tdag/data_nodes/data_nodes.py +1292 -0
  63. mainsequence/tdag/data_nodes/persist_managers.py +812 -0
  64. mainsequence/tdag/data_nodes/run_operations.py +543 -0
  65. mainsequence/tdag/data_nodes/utils.py +24 -0
  66. mainsequence/tdag/future_registry.py +25 -0
  67. mainsequence/tdag/utils.py +40 -0
  68. mainsequence/virtualfundbuilder/__init__.py +45 -0
  69. mainsequence/virtualfundbuilder/__main__.py +235 -0
  70. mainsequence/virtualfundbuilder/agent_interface.py +77 -0
  71. mainsequence/virtualfundbuilder/config_handling.py +86 -0
  72. mainsequence/virtualfundbuilder/contrib/__init__.py +0 -0
  73. mainsequence/virtualfundbuilder/contrib/apps/__init__.py +8 -0
  74. mainsequence/virtualfundbuilder/contrib/apps/etf_replicator_app.py +164 -0
  75. mainsequence/virtualfundbuilder/contrib/apps/generate_report.py +292 -0
  76. mainsequence/virtualfundbuilder/contrib/apps/load_external_portfolio.py +107 -0
  77. mainsequence/virtualfundbuilder/contrib/apps/news_app.py +437 -0
  78. mainsequence/virtualfundbuilder/contrib/apps/portfolio_report_app.py +91 -0
  79. mainsequence/virtualfundbuilder/contrib/apps/portfolio_table.py +95 -0
  80. mainsequence/virtualfundbuilder/contrib/apps/run_named_portfolio.py +45 -0
  81. mainsequence/virtualfundbuilder/contrib/apps/run_portfolio.py +40 -0
  82. mainsequence/virtualfundbuilder/contrib/apps/templates/base.html +147 -0
  83. mainsequence/virtualfundbuilder/contrib/apps/templates/report.html +77 -0
  84. mainsequence/virtualfundbuilder/contrib/data_nodes/__init__.py +5 -0
  85. mainsequence/virtualfundbuilder/contrib/data_nodes/external_weights.py +61 -0
  86. mainsequence/virtualfundbuilder/contrib/data_nodes/intraday_trend.py +149 -0
  87. mainsequence/virtualfundbuilder/contrib/data_nodes/market_cap.py +310 -0
  88. mainsequence/virtualfundbuilder/contrib/data_nodes/mock_signal.py +78 -0
  89. mainsequence/virtualfundbuilder/contrib/data_nodes/portfolio_replicator.py +269 -0
  90. mainsequence/virtualfundbuilder/contrib/prices/__init__.py +1 -0
  91. mainsequence/virtualfundbuilder/contrib/prices/data_nodes.py +810 -0
  92. mainsequence/virtualfundbuilder/contrib/prices/utils.py +11 -0
  93. mainsequence/virtualfundbuilder/contrib/rebalance_strategies/__init__.py +1 -0
  94. mainsequence/virtualfundbuilder/contrib/rebalance_strategies/rebalance_strategies.py +313 -0
  95. mainsequence/virtualfundbuilder/data_nodes.py +637 -0
  96. mainsequence/virtualfundbuilder/enums.py +23 -0
  97. mainsequence/virtualfundbuilder/models.py +282 -0
  98. mainsequence/virtualfundbuilder/notebook_handling.py +42 -0
  99. mainsequence/virtualfundbuilder/portfolio_interface.py +272 -0
  100. mainsequence/virtualfundbuilder/resource_factory/__init__.py +0 -0
  101. mainsequence/virtualfundbuilder/resource_factory/app_factory.py +170 -0
  102. mainsequence/virtualfundbuilder/resource_factory/base_factory.py +238 -0
  103. mainsequence/virtualfundbuilder/resource_factory/rebalance_factory.py +101 -0
  104. mainsequence/virtualfundbuilder/resource_factory/signal_factory.py +183 -0
  105. mainsequence/virtualfundbuilder/utils.py +381 -0
  106. mainsequence-2.0.0.dist-info/METADATA +105 -0
  107. mainsequence-2.0.0.dist-info/RECORD +110 -0
  108. mainsequence-2.0.0.dist-info/WHEEL +5 -0
  109. mainsequence-2.0.0.dist-info/licenses/LICENSE +40 -0
  110. mainsequence-2.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,387 @@
1
+ import copy
2
+ from socket import socket
3
+
4
+ import psutil
5
+ import requests
6
+ import os
7
+ from requests.structures import CaseInsensitiveDict
8
+ import datetime
9
+ import time
10
+ import pytz
11
+ from typing import Union, Optional, TypedDict, Dict
12
+ from mainsequence.logconf import logger
13
+ from enum import Enum
14
+
15
+ DATE_FORMAT = '%Y-%m-%dT%H:%M:%S.%fZ'
16
+
17
+
18
+ class DataFrequency(str, Enum):
19
+ one_m = "1m"
20
+ five_m = "5m"
21
+ one_d = "1d"
22
+ one_w = "1w"
23
+ one_year = "1y"
24
+ one_month = "1mo"
25
+ one_quarter = "1q"
26
+
27
+
28
+ class DateInfo(TypedDict, total=False):
29
+ start_date: Optional[datetime.datetime]
30
+ start_date_operand: Optional[str]
31
+ end_date: Optional[datetime.datetime]
32
+ end_date_operand: Optional[str]
33
+
34
+
35
+ UniqueIdentifierRangeMap = Dict[str, DateInfo]
36
+
37
+
38
+ def request_to_datetime(string_date: str):
39
+ if "+" in string_date:
40
+ string_date = datetime.datetime.fromisoformat(string_date.replace("T", " ")).replace(tzinfo=pytz.utc)
41
+ return string_date
42
+ try:
43
+ date = datetime.datetime.strptime(string_date, DATE_FORMAT).replace(
44
+ tzinfo=pytz.utc)
45
+ except ValueError:
46
+ date = datetime.datetime.strptime(string_date, "%Y-%m-%dT%H:%M:%SZ").replace(
47
+ tzinfo=pytz.utc)
48
+ return date
49
+
50
+
51
+ class DoesNotExist(Exception):
52
+ pass
53
+
54
+
55
+ class AuthLoaders:
56
+ @property
57
+ def auth_headers(self):
58
+ if not hasattr(self, "_auth_headers"):
59
+ self.refresh_headers()
60
+ return self._auth_headers
61
+
62
+ def refresh_headers(self):
63
+ logger.debug("Getting Auth Headers ASSETS_ORM")
64
+ self._auth_headers = get_authorization_headers()
65
+
66
+
67
+ def get_authorization_headers():
68
+ headers = get_rest_token_header()
69
+ return headers
70
+
71
+
72
+ def make_request(
73
+ s,
74
+ r_type: str,
75
+ url: str,
76
+ loaders: Union[AuthLoaders, None],
77
+ payload: Union[dict, None] = None,
78
+ time_out=None,
79
+ accept_gzip: bool = True
80
+ ):
81
+ from requests.models import Response
82
+
83
+ TIMEOFF = 0.25
84
+ TRIES = int(15 // TIMEOFF)
85
+ timeout = 120 if time_out is None else time_out
86
+ payload = {} if payload is None else payload
87
+
88
+ def get_req(session):
89
+ if r_type == "GET":
90
+ return session.get
91
+ elif r_type == "POST":
92
+ return session.post
93
+ elif r_type == "PATCH":
94
+ return session.patch
95
+ elif r_type == "DELETE":
96
+ return session.delete
97
+ else:
98
+ raise NotImplementedError(f"Unsupported method: {r_type}")
99
+
100
+ # --- Prepare kwargs for requests call ---
101
+ request_kwargs = {}
102
+ if r_type in ("POST", "PATCH") and "files" in payload:
103
+ # We have file uploads → use multipart form data
104
+ request_kwargs["data"] = payload.get("json", {}) # form fields
105
+ request_kwargs["files"] = payload["files"] # actual files
106
+ s.headers.pop("Content-Type", None)
107
+ else:
108
+ # Fallback: no files, no json → just form fields
109
+ request_kwargs = payload
110
+
111
+ req = get_req(session=s)
112
+ keep_request = True
113
+ counter = 0
114
+ headers_refreshed = False
115
+
116
+ if accept_gzip:
117
+ # Don't clobber other headers; just ensure the key exists.
118
+ # Keep it simple: gzip covers Django's GZipMiddleware.
119
+ s.headers.setdefault("Accept-Encoding", "gzip")
120
+
121
+ # Now loop with retry logic
122
+ while keep_request:
123
+ try:
124
+ start_time = time.perf_counter()
125
+ logger.debug(f"Requesting {r_type} from {url}")
126
+ r = req(url, timeout=timeout, **request_kwargs)
127
+ duration = time.perf_counter() - start_time
128
+ logger.debug(f"{url} took {duration:.4f} seconds.")
129
+
130
+ if r.status_code in [403, 401] and not headers_refreshed:
131
+ logger.warning(f"Error {r.status_code} Refreshing headers")
132
+ loaders.refresh_headers()
133
+ s.headers.update(loaders.auth_headers)
134
+ req = get_req(session=s)
135
+ headers_refreshed = True
136
+ else:
137
+ keep_request = False
138
+ break
139
+ except requests.exceptions.ConnectionError as errc:
140
+ logger.exception(f"Error connecting {url}")
141
+ except TypeError as e:
142
+ logger.exception(f"Type error for {url} exception {e}")
143
+ raise e
144
+ except Exception as e:
145
+ logger.exception(f"Error connecting {url} exception {e}")
146
+
147
+ counter += 1
148
+ if counter >= TRIES:
149
+ keep_request = False
150
+ r = Response()
151
+ r.code = "expired"
152
+ r.error_type = "expired"
153
+ r.status_code = 500
154
+ break
155
+
156
+ logger.debug(f"Trying request again after {TIMEOFF}s "
157
+ f"- Counter: {counter}/{TRIES} - URL: {url}")
158
+ time.sleep(TIMEOFF)
159
+ return r
160
+
161
+
162
+ def build_session():
163
+ from requests.adapters import HTTPAdapter, Retry
164
+ s = requests.Session()
165
+ retries = Retry(total=2, backoff_factor=2, )
166
+ s.mount('http://', HTTPAdapter(max_retries=retries))
167
+ return s
168
+
169
+
170
+ def get_constants_tdag():
171
+ url = f"{os.getenv('TDAG_ENDPOINT')}/orm/api/ts_manager/api/constants"
172
+ loaders = AuthLoaders()
173
+ s = build_session()
174
+ s.headers.update(loaders.auth_headers)
175
+ r = make_request(s=s, loaders=loaders, r_type="GET", url=url)
176
+ return r.json()
177
+
178
+
179
+ def get_constants_vam():
180
+ url = f"{os.getenv('TDAG_ENDPOINT')}/orm/api/assets/api/constants"
181
+ loaders = AuthLoaders()
182
+ s = build_session()
183
+ s.headers.update(loaders.auth_headers)
184
+ r = make_request(s=s, loaders=loaders, r_type="GET", url=url)
185
+ return r.json()
186
+
187
+
188
+ def get_binance_constants():
189
+ url = f"{os.getenv('TDAG_ENDPOINT')}/orm/api/binance/constants"
190
+ loaders = AuthLoaders()
191
+ s = build_session()
192
+ s.headers.update(loaders.auth_headers)
193
+ r = make_request(s=s, loaders=loaders, r_type="GET", url=url)
194
+ return r.json()
195
+
196
+
197
+ class LazyConstants(dict):
198
+ """
199
+ Class Method to load constants only once they are called. this minimizes the calls to the API
200
+ """
201
+
202
+ def __init__(self, constant_type: str):
203
+ if constant_type == "tdag":
204
+ self.CONSTANTS_METHOD = get_constants_tdag
205
+ elif constant_type == "vam":
206
+ self.CONSTANTS_METHOD = get_constants_vam
207
+ elif constant_type == "binance":
208
+ self.CONSTANTS_METHOD = get_binance_constants
209
+ else:
210
+ raise NotImplementedError(f"{constant_type} not implemented")
211
+ self._initialized = False
212
+
213
+ def __getattr__(self, key):
214
+ if not self._initialized:
215
+ self._load_constants()
216
+ return self.__dict__[key]
217
+
218
+ def _load_constants(self):
219
+ # 1) call the method that returns your top-level dict
220
+ raw_data = self.CONSTANTS_METHOD()
221
+ # 2) Convert nested dicts to an "object" style
222
+ nested = self.to_attr_dict(raw_data)
223
+ # 3) Dump everything into self.__dict__ so it's dot-accessible
224
+ for k, v in nested.items():
225
+ self.__dict__[k] = v
226
+ self._initialized = True
227
+
228
+ def to_attr_dict(self, data):
229
+ """
230
+ Recursively convert a Python dict into an object that allows dot-notation access.
231
+ Non-dict values (e.g., int, str, list) are returned as-is; dicts become _AttrDict.
232
+ """
233
+ if not isinstance(data, dict):
234
+ return data
235
+
236
+ class _AttrDict(dict):
237
+ def __getattr__(self, name):
238
+ return self[name]
239
+
240
+ def __setattr__(self, name, value):
241
+ self[name] = value
242
+
243
+ out = _AttrDict()
244
+ for k, v in data.items():
245
+ out[k] = self.to_attr_dict(v) # recursively transform
246
+ return out
247
+
248
+
249
+ if 'TDAG_CONSTANTS' not in locals():
250
+ TDAG_CONSTANTS = LazyConstants("tdag")
251
+
252
+ if 'MARKETS_CONSTANTS' not in locals():
253
+ MARKETS_CONSTANTS = LazyConstants("vam")
254
+
255
+ if "BINANCE_CONSTANTS" not in locals():
256
+ BINANCE_CONSTANTS = LazyConstants("binance")
257
+
258
+
259
+ def get_rest_token_header():
260
+ headers = CaseInsensitiveDict()
261
+ headers["Content-Type"] = "application/json"
262
+
263
+ if os.getenv("MAINSEQUENCE_TOKEN"):
264
+ headers["Authorization"] = "Token " + os.getenv("MAINSEQUENCE_TOKEN")
265
+ return headers
266
+ else:
267
+ raise Exception("MAINSEQUENCE_TOKEN is not set in env")
268
+
269
+
270
+ def get_network_ip():
271
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
272
+ # Connect to a well-known external host (Google DNS) on port 80
273
+ s.connect(("8.8.8.8", 80))
274
+ # Get the local IP address used to make the connection
275
+ network_ip = s.getsockname()[0]
276
+ return network_ip
277
+
278
+
279
+ def is_process_running(pid: int) -> bool:
280
+ """
281
+ Check if a process with the given PID is running.
282
+
283
+ Args:
284
+ pid (int): The process ID to check.
285
+
286
+ Returns:
287
+ bool: True if the process is running, False otherwise.
288
+ """
289
+ try:
290
+ # Check if the process with the given PID is running
291
+ process = psutil.Process(pid)
292
+ return process.is_running() and process.status() != psutil.STATUS_ZOMBIE
293
+ except psutil.NoSuchProcess:
294
+ # Process with the given PID does not exist
295
+ return False
296
+
297
+
298
+ def set_types_in_table(df, column_types):
299
+ index_cols = [name for name in df.index.names if name is not None]
300
+ if index_cols:
301
+ df = df.reset_index()
302
+
303
+ for c, col_type in column_types.items():
304
+ if c in df.columns:
305
+ if col_type == "object":
306
+ df[c] = df[c].astype(str)
307
+ else:
308
+ df[c] = df[c].astype(col_type)
309
+
310
+ if index_cols:
311
+ df = df.set_index(index_cols)
312
+ return df
313
+
314
+
315
+ def serialize_to_json(kwargs):
316
+ new_data = {}
317
+ for key, value in kwargs.items():
318
+ new_value = copy.deepcopy(value)
319
+ if isinstance(value, datetime.datetime):
320
+ new_value = str(value)
321
+
322
+ new_data[key] = new_value
323
+ return new_data
324
+
325
+
326
+ import os
327
+ import pathlib
328
+ import shutil
329
+ import subprocess
330
+ import uuid
331
+
332
+
333
+ def _linux_machine_id() -> Optional[str]:
334
+ """Return the OS machine‑id if readable (many distros make this 0644)."""
335
+ for p in ("/etc/machine-id", "/var/lib/dbus/machine-id"):
336
+ path = pathlib.Path(p)
337
+ if path.is_file():
338
+ try:
339
+ return path.read_text().strip().lower()
340
+ except PermissionError:
341
+ continue
342
+ return None
343
+
344
+
345
+ def bios_uuid() -> str:
346
+ """Best‑effort hardware/OS identifier that never returns None.
347
+
348
+ Order of preference
349
+ -------------------
350
+ 1. `/sys/class/dmi/id/product_uuid` (kernel‑exported, no root)
351
+ 2. `dmidecode -s system-uuid` (requires root *and* dmidecode)
352
+ 3. `/etc/machine-id` or `/var/lib/dbus/machine-id`
353
+ 4. `uuid.getnode()` (MAC address as 48‑bit int, zero‑padded hex)
354
+
355
+ The value is always lower‑case and stripped of whitespace.
356
+ """
357
+ # Tier 1 – kernel DMI file
358
+ path = pathlib.Path("/sys/class/dmi/id/product_uuid")
359
+ if path.is_file():
360
+ try:
361
+ val = path.read_text().strip().lower()
362
+ if val:
363
+ return val
364
+ except PermissionError:
365
+ pass
366
+
367
+ # Tier 2 – dmidecode, but only if available *and* running as root
368
+ if shutil.which("dmidecode") and os.geteuid() == 0:
369
+ try:
370
+ out = subprocess.check_output(
371
+ ["dmidecode", "-s", "system-uuid"],
372
+ text=True,
373
+ stderr=subprocess.DEVNULL,
374
+ )
375
+ val = out.splitlines()[0].strip().lower()
376
+ if val:
377
+ return val
378
+ except subprocess.SubprocessError:
379
+ pass
380
+
381
+ # Tier 3 – machine‑id
382
+ mid = _linux_machine_id()
383
+ if mid:
384
+ return mid
385
+
386
+ # Tier 4 – MAC address (uuid.getnode). Always available.
387
+ return f"{uuid.getnode():012x}"
File without changes
File without changes
@@ -0,0 +1,12 @@
1
+
2
+ [theme]
3
+ base="dark" # "light" or "dark"
4
+ primaryColor="#5DADE2" # buttons, widgets
5
+ backgroundColor="#0E1216" # page background
6
+ secondaryBackgroundColor="#121721"
7
+ textColor="#EAECEE"
8
+ font="sans serif" # or "serif" / "monospace"
9
+
10
+ [server]
11
+ enableXsrfProtection = true
12
+ headless = true
File without changes
@@ -0,0 +1,212 @@
1
+ from __future__ import annotations
2
+ import streamlit as st
3
+ from pathlib import Path
4
+
5
+
6
+ def inject_css_for_dark_accents():
7
+ st.markdown(
8
+ """
9
+ <style>
10
+ /* Subtle tweaks; Streamlit theme itself comes from .streamlit/config.toml */
11
+ .stMetric > div { background: rgba(255,255,255,0.04); border-radius: 6px; padding: .5rem .75rem; }
12
+ div[data-testid="stMetricDelta"] svg { display: none; } /* clean deltas, hide the arrow */
13
+ </style>
14
+ """,
15
+ unsafe_allow_html=True
16
+ )
17
+
18
+ def explain_theming():
19
+ st.info(
20
+ "Theme colors come from `.streamlit/config.toml`. "
21
+ "You can’t switch Streamlit’s theme at runtime, but you can tune Plotly’s colors and inject light CSS."
22
+ )
23
+
24
+
25
+
26
+
27
+ # --- Load spinner frames ONCE from two levels above, files: image_1_base64.txt ... image_5_base64.txt ---
28
+ def _load_spinner_frames_for_this_template() -> list[str]:
29
+ base_dir = Path(__file__).resolve().parent.parent.parent.parent # two levels above this module
30
+ frames: list[str] = []
31
+ for i in range(1, 6):
32
+ p = base_dir / f"image_{i}_base64.txt"
33
+ if not p.exists():
34
+ raise FileNotFoundError(f"Missing spinner frame file: {p}")
35
+ frames.append(p.read_text(encoding="utf-8").strip())
36
+ return frames
37
+
38
+
39
+ _SPINNER_FRAMES_RAW = _load_spinner_frames_for_this_template()
40
+
41
+
42
+ # Expose constants for the function (keeps the code below simple)
43
+ IMAGE_1_B64, IMAGE_2_B64, IMAGE_3_B64, IMAGE_4_B64, IMAGE_5_B64 = _SPINNER_FRAMES_RAW
44
+
45
+ def override_spinners(
46
+ hide_deploy_button: bool = False,
47
+ *,
48
+ # Sizes
49
+ top_px: int = 35, # top-right toolbar & st.status icon base size
50
+ inline_px: int = 288, # animation size when centered
51
+ # Timing
52
+ duration_ms: int = 900,
53
+ # Toolbar nudges / spacing
54
+ toolbar_nudge_px: int = -3,
55
+ toolbar_gap_left_px: int = 2,
56
+ toolbar_left_offset_px: int = 0,
57
+ # Centered overlay styling
58
+ center_non_toolbar: bool = True, # << keep True to center inline + status
59
+ dim_backdrop: bool = True, # << set False to hide the dark veil
60
+ overlay_blur_px: float = 1.5,
61
+ overlay_opacity: float = 0.35,
62
+ overlay_z_index: int = 9990, # keep below toolbar; we also lift toolbar above
63
+ ) -> None:
64
+ """Override Streamlit spinners with a 4-frame animation.
65
+ - Toolbar spinner stays in the toolbar (top-right).
66
+ - All other spinners (inline + st.status icon) are centered on screen.
67
+ """
68
+
69
+ def as_data_uri(s: str, mime="image/png") -> str:
70
+ s = s.strip()
71
+ return s if s.startswith("data:") else f"data:{mime};base64,{s}"
72
+
73
+ i1 = as_data_uri(IMAGE_1_B64)
74
+ i2 = as_data_uri(IMAGE_2_B64)
75
+ i3 = as_data_uri(IMAGE_3_B64)
76
+ i4 = as_data_uri(IMAGE_4_B64)
77
+ i5= as_data_uri(IMAGE_5_B64)
78
+
79
+ veil_bg = f"rgba(0,0,0,{overlay_opacity})"
80
+
81
+ st.markdown(f"""
82
+ <style>
83
+ /* ---- 4-frame animation ---- */
84
+ @keyframes st-fourframe {{
85
+ 0%% {{ background-image:url("{i1}"); }}
86
+ 20% {{ background-image:url("{i2}"); }}
87
+ 40% {{ background-image:url("{i3}"); }}
88
+ 60% {{ background-image:url("{i4}"); }}
89
+ 80% {{ background-image:url("{i5}"); }}
90
+ 100% {{ background-image:url("{i5}"); }}
91
+ }}
92
+
93
+ /* ---- CSS variables ---- */
94
+ :root {{
95
+ --st-spin-top:{top_px}px; /* toolbar/status base size */
96
+ --st-spin-inline:{inline_px}px; /* centered spinner size */
97
+ --st-spin-dur:{duration_ms}ms;
98
+
99
+ --st-spin-toolbar-nudge:{toolbar_nudge_px}px;
100
+ --st-spin-toolbar-gap:{toolbar_gap_left_px}px;
101
+ --st-spin-toolbar-left:{toolbar_left_offset_px}px;
102
+
103
+ --st-overlay-z:{overlay_z_index};
104
+ --st-overlay-bg:{veil_bg};
105
+ --st-overlay-blur:{overlay_blur_px}px;
106
+ }}
107
+
108
+ /* Lift toolbar above any overlay so Stop/Deploy remain clickable */
109
+ div[data-testid="stToolbar"],
110
+ [data-testid="stStatusWidget"] {{
111
+ position: relative;
112
+ z-index: calc(var(--st-overlay-z) + 5);
113
+ }}
114
+
115
+ /* =======================================================================
116
+ 1) Top-right toolbar widget (kept in place, not centered)
117
+ ======================================================================= */
118
+ [data-testid="stStatusWidget"] {{
119
+ position:relative;
120
+ padding-left: calc(var(--st-spin-top) + var(--st-spin-toolbar-gap));
121
+ }}
122
+ [data-testid="stStatusWidget"] svg,
123
+ [data-testid="stStatusWidget"] img {{ display:none !important; }}
124
+ [data-testid="stStatusWidget"]::before {{
125
+ content:"";
126
+ position:absolute;
127
+ left: var(--st-spin-toolbar-left);
128
+ top:50%;
129
+ transform:translateY(-50%) translateY(var(--st-spin-toolbar-nudge));
130
+ width:var(--st-spin-top);
131
+ height:var(--st-spin-top);
132
+ background:no-repeat center/contain;
133
+ animation:st-fourframe var(--st-spin-dur) linear infinite;
134
+ }}
135
+
136
+ /* Hide the entire toolbar if requested */
137
+ {"div[data-testid='stToolbar']{display:none !important;}" if hide_deploy_button else ""}
138
+
139
+ /* =======================================================================
140
+ 2) Inline spinner (st.spinner) — centered overlay
141
+ ======================================================================= */
142
+ [data-testid="stSpinner"] svg {{ display:none !important; }}
143
+ [data-testid="stSpinner"] {{
144
+ min-height: 0 !important; /* avoid layout jump, since we center globally */
145
+ }}
146
+ { "[data-testid='stSpinner']::after { content:''; position:fixed; inset:0; background:var(--st-overlay-bg); backdrop-filter: blur(var(--st-overlay-blur)); z-index: var(--st-overlay-z); pointer-events: none; }" if dim_backdrop else "" }
147
+ [data-testid="stSpinner"]::before {{
148
+ content:"";
149
+ position: fixed;
150
+ left: 50%;
151
+ top: 50%;
152
+ transform: translate(-50%,-50%);
153
+ width: var(--st-spin-inline);
154
+ height: var(--st-spin-inline);
155
+ background:no-repeat center/contain;
156
+ animation:st-fourframe var(--st-spin-dur) linear infinite;
157
+ z-index: calc(var(--st-overlay-z) + 1);
158
+ }}
159
+
160
+ /* Center the spinner message below the animation (works in sidebar or main) */
161
+ [data-testid="stSpinner"] [data-testid="stSpinnerMessage"],
162
+ [data-testid="stSpinner"] > div > div:last-child,
163
+ [data-testid="stSpinner"] > div > div:only-child {{
164
+ position: fixed !important;
165
+ left: 50% !important;
166
+ top: calc(50% + var(--st-spin-inline) / 2 + 12px) !important;
167
+ transform: translateX(-50%) !important;
168
+ z-index: calc(var(--st-overlay-z) + 2) !important;
169
+ text-align: center !important;
170
+ margin: 0 !important;
171
+ padding: .25rem .75rem !important;
172
+ max-width: min(80vw, 900px) !important; /* keeps long text from stretching off-screen */
173
+ white-space: normal !important; /* use `nowrap` if you prefer single-line */
174
+ font-weight: 500 !important;
175
+ }}
176
+
177
+ /* Kill the tiny default glyph wrapper so you don't get a stray dot in the sidebar */
178
+ [data-testid="stSpinner"] > div > div:first-child {{
179
+ display: none !important;
180
+ }}
181
+
182
+ /* We still hide the default SVG everywhere */
183
+ [data-testid="stSpinner"] svg {{
184
+ display: none !important;
185
+ }}
186
+
187
+ /* =======================================================================
188
+ 3) st.status(...) icon — centered overlay
189
+ ======================================================================= */
190
+ [data-testid="stStatus"] [data-testid="stStatusIcon"] svg,
191
+ [data-testid="stStatus"] [data-testid="stStatusIcon"] img {{ display:none !important; }}
192
+ {"[data-testid='stStatus']::after { content:''; position:fixed; inset:0; background:var(--st-overlay-bg); backdrop-filter: blur(var(--st-overlay-blur)); z-index: var(--st-overlay-z); pointer-events: none; }" if dim_backdrop else ""}
193
+ [data-testid="stStatus"] [data-testid="stStatusIcon"]::before {{
194
+ content:"";
195
+ position: fixed;
196
+ left: 50%;
197
+ top: 50%;
198
+ transform: translate(-50%,-50%);
199
+ width: var(--st-spin-inline); /* use same size as inline */
200
+ height: var(--st-spin-inline);
201
+ background:no-repeat center/contain;
202
+ animation:st-fourframe var(--st-spin-dur) linear infinite;
203
+ z-index: calc(var(--st-overlay-z) + 1);
204
+ }}
205
+
206
+ /* Optional: allow 'esc' feel without blocking clicks — achieved via pointer-events:none above. */
207
+ </style>
208
+ """, unsafe_allow_html=True)
209
+
210
+
211
+
212
+
File without changes