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,713 @@
1
+ from __future__ import annotations
2
+
3
+ from enum import Enum
4
+ from pathlib import Path
5
+ from typing import List, Optional, Union, Type, Any,Literal
6
+
7
+ from pydantic import BaseModel, Field, validator, ValidationError,root_validator
8
+ from jinja2 import Environment
9
+
10
+ from typing import Callable, Dict
11
+
12
+ from pydantic import HttpUrl
13
+
14
+
15
+ # ────────────────────────────── Common enums ──────────────────────────────
16
+
17
+ class HorizontalAlign(str, Enum):
18
+ left = "left"
19
+ center = "center"
20
+ right = "right"
21
+
22
+ class VerticalAlign(str, Enum):
23
+ top = "top"
24
+ center = "center"
25
+ bottom = "bottom"
26
+
27
+ class FontWeight(str, Enum):
28
+ normal = "normal"
29
+ bold = "bold"
30
+
31
+ class Anchor(str, Enum):
32
+ top_left = "top_left"
33
+ top_right = "top_right"
34
+ bottom_left = "bottom_left"
35
+ bottom_right = "bottom_right"
36
+ center = "center"
37
+
38
+ class Size(BaseModel):
39
+ width: Optional[str] = None # "300px", "40%", …
40
+ height: Optional[str] = None
41
+
42
+ @validator("width", "height", pre=True)
43
+ def _coerce_to_str(cls, v): # noqa: N805
44
+ if v is None:
45
+ return v
46
+ return f"{v}px" if isinstance(v, int) else str(v)
47
+
48
+ def css(self) -> str:
49
+ return "".join(
50
+ f"{dim}:{val};"
51
+ for dim, val in (("width", self.width), ("height", self.height))
52
+ if val is not None
53
+ )
54
+
55
+ class Position(BaseModel):
56
+ top: Optional[str] = None
57
+ left: Optional[str] = None
58
+ right: Optional[str] = None
59
+ bottom: Optional[str] = None
60
+ anchor: Optional[Anchor] = None
61
+
62
+ @validator("top", "left", "right", "bottom", pre=True)
63
+ def _coerce_to_str(cls, v):
64
+ if v is None:
65
+ return v
66
+ return f"{v}px" if isinstance(v, int) else str(v)
67
+
68
+ def css(self) -> str:
69
+ if self.anchor and any([self.top, self.left, self.right, self.bottom]):
70
+ raise ValueError("Specify either 'anchor' or explicit offsets - not both")
71
+ if self.anchor:
72
+ return {
73
+ Anchor.top_left: "top:0;left:0;",
74
+ Anchor.top_right: "top:0;right:0;",
75
+ Anchor.bottom_left: "bottom:0;left:0;",
76
+ Anchor.bottom_right:"bottom:0;right:0;",
77
+ Anchor.center: "top:50%;left:50%;transform:translate(-50%,-50%);",
78
+ }[self.anchor]
79
+
80
+ return "".join(
81
+ f"{side}:{val};"
82
+ for side, val in (
83
+ ("top", self.top), ("left", self.left),
84
+ ("right", self.right), ("bottom", self.bottom)
85
+ )
86
+ if val is not None
87
+ )
88
+
89
+ class ElementBase(BaseModel):
90
+ id: str = Field(default_factory=lambda: f"elem_{id(object())}")
91
+ z_index: int = 1
92
+ css_class: Optional[str] = None
93
+
94
+ class Config:
95
+ arbitrary_types_allowed = True
96
+
97
+ def render(self) -> str:
98
+ raise NotImplementedError
99
+
100
+ class TextElement(ElementBase):
101
+ text: str
102
+
103
+ # 1) semantic element type
104
+ element_type: Literal["h1", "h2", "h3", "h4", "h5", "h6", "p"] = "p"
105
+
106
+ font_weight: FontWeight = FontWeight.normal
107
+ h_align: HorizontalAlign = HorizontalAlign.left
108
+ v_align: VerticalAlign = VerticalAlign.top
109
+ color: Optional[str] = None
110
+ line_height: Optional[str] = None
111
+ size: Size = Field(default_factory=Size)
112
+ position: Optional[Position] = None
113
+
114
+ style_theme: Optional[ThemeMode] = None
115
+
116
+ def render(self, override_theme_mode_if_none) -> str:
117
+ if self.style_theme is None:
118
+ self.style_theme = override_theme_mode_if_none
119
+
120
+ settings = self.style_theme
121
+ style = []
122
+
123
+ if self.position:
124
+ style.append("position:absolute;")
125
+ style.append(self.position.css())
126
+ style.append(self.size.css())
127
+
128
+ # choose font-size and default color
129
+ if self.element_type.startswith("h"):
130
+ ff = settings.font_family_headings
131
+ default_color = settings.heading_color
132
+ else:
133
+ ff = settings.font_family_paragraphs
134
+ default_color = settings.paragraph_color
135
+
136
+ # use explicit color if given, else default
137
+ c = self.color if self.color is not None else default_color
138
+
139
+ style.append(f"font-weight:{self.font_weight.value};")
140
+ style.append(f"font-family:{ff};")
141
+ style.append(f"color:{c};")
142
+ style.append(f"text-align:{self.h_align.value};")
143
+
144
+ if self.line_height:
145
+ style.append(f"line-height:{self.line_height};")
146
+
147
+ class_attr = f'class="text-element-{self.element_type}"' if self.element_type else ""
148
+ tag = self.element_type
149
+
150
+ return (
151
+ f'<{tag} id="{self.id}" {class_attr} '
152
+ f'style="{"".join(style)}">{self.text}</{tag}>'
153
+ )
154
+
155
+ class TextH1(TextElement):
156
+ # force element_type to always be "h1" and disallow overrides
157
+ element_type: Literal["h1"] = Field("h1", literal=True)
158
+
159
+ def __init__(self, **data):
160
+ # Remove any attempt to pass element_type in data
161
+ data.pop("element_type", None)
162
+ super().__init__(**data)
163
+
164
+ class TextH2(TextElement):
165
+ # force element_type to always be "h1" and disallow overrides
166
+ element_type: Literal["h2"] = Field("h2", literal=True)
167
+
168
+ def __init__(self, **data):
169
+ # Remove any attempt to pass element_type in data
170
+ data.pop("element_type", None)
171
+ super().__init__(**data)
172
+
173
+ class ImageElement(ElementBase):
174
+ src: str
175
+ alt: str = ""
176
+ size: Size = Field(default_factory=lambda: Size(width="100%", height="auto"))
177
+ position: Optional[Position] = None
178
+ object_fit: str = "contain"
179
+ style_theme: Optional[ThemeMode]=None
180
+
181
+ def render(self,override_theme_mode_if_none) -> str:
182
+ if self.style_theme is None:
183
+ self.style_theme = override_theme_mode_if_none
184
+ style = []
185
+ if self.position:
186
+ style.append("position:absolute;")
187
+ style.append(self.position.css())
188
+ style.append(self.size.css())
189
+ style.append(f"object-fit:{self.object_fit};")
190
+
191
+ class_attr = f'class="{self.css_class}"' if self.css_class else ""
192
+ return (
193
+ f'<img id="{self.id}" {class_attr} src="{self.src}" alt="{self.alt}" '
194
+ f'style="{"".join(style)}" crossOrigin="anonymous" />'
195
+ )
196
+
197
+ class HtmlElement(ElementBase):
198
+ html: str
199
+ style_theme: Optional[ThemeMode]=None
200
+
201
+ def render(self, override_theme_mode_if_none) -> str:
202
+ if self.style_theme is None:
203
+ self.style_theme = override_theme_mode_if_none
204
+ class_attr = f'class="{self.css_class}"' if self.css_class else ""
205
+ return f'<div id="{self.id}" {class_attr}">{self.html}</div>'
206
+
207
+
208
+ BaseElements = Union[TextElement, ImageElement, HtmlElement]
209
+
210
+ class GridCell(BaseModel):
211
+ row: int
212
+ col: int
213
+ row_span: int = 1
214
+ col_span: int = 1
215
+ element: BaseElements
216
+ padding: Optional[str] = None
217
+ background_color: Optional[str] = None
218
+ align_self: Optional[str] = None
219
+ justify_self: Optional[str] = None
220
+ content_v_align: Optional[VerticalAlign] = None # For vertical alignment of content WITHIN the cell
221
+ content_h_align: Optional[HorizontalAlign] = None # For horizontal alignment of content WITHIN the cell
222
+
223
+ @validator("row", "col", "row_span", "col_span", pre=True)
224
+ def _positive(cls, v):
225
+ if isinstance(v, str) and v.isdigit():
226
+ v = int(v)
227
+ if not isinstance(v, int) or v < 1:
228
+ raise ValueError("row/col/row_span/col_span must be positive integers >= 1")
229
+ return v
230
+
231
+
232
+ class GridLayout(BaseModel):
233
+ row_definitions: List[str] = Field(default_factory=lambda: ["1fr"])
234
+ col_definitions: List[str] = Field(default_factory=lambda: ["1fr"])
235
+ gap: int = 10
236
+ cells: List[GridCell]
237
+ width: Optional[str] = "100%"
238
+ height: Optional[str] = "100%"
239
+ style_theme: Optional[ThemeMode] =None
240
+
241
+ @validator("gap",pre=True)
242
+ def _coerce_gap_to_int(cls,v):
243
+ if isinstance(v,str) and v.endswith("px"):
244
+ return int(v[:-2])
245
+ if isinstance(v,str) and v.isdigit():
246
+ return int(v)
247
+ if isinstance(v,int):
248
+ return v
249
+ raise ValueError("gap must be an int or string like '10px'")
250
+
251
+ @validator("cells", each_item=True)
252
+ def _within_grid(cls, cell: GridCell, values: Dict[str, Any]) -> GridCell:
253
+ row_defs = values.get("row_definitions")
254
+ col_defs = values.get("col_definitions")
255
+ if row_defs and cell.row + cell.row_span - 1 > len(row_defs):
256
+ raise ValueError(f"GridCell definition (row={cell.row}, row_span={cell.row_span}) exceeds row count ({len(row_defs)})")
257
+ if col_defs and cell.col + cell.col_span - 1 > len(col_defs):
258
+ raise ValueError(f"GridCell definition (col={cell.col}, col_span={cell.col_span}) exceeds column count ({len(col_defs)})")
259
+ return cell
260
+
261
+ def render(self,) -> str:
262
+ grid_style_parts = [
263
+ "display:grid;",
264
+ f"grid-template-columns:{' '.join(self.col_definitions)};",
265
+ f"grid-template-rows:{' '.join(self.row_definitions)};",
266
+ f"gap:{self.gap}px;",
267
+ "position:relative;"
268
+ ]
269
+ if self.width:
270
+ grid_style_parts.append(f"width:{self.width};")
271
+ if self.height:
272
+ grid_style_parts.append(f"height:{self.height};")
273
+ grid_style = "".join(grid_style_parts)
274
+
275
+ html_parts: List[str] = [f'<div class="slide-grid" style="{grid_style}">']
276
+ for cell in self.cells:
277
+ cell_styles_list = [
278
+ f"grid-column:{cell.col}/span {cell.col_span};",
279
+ f"grid-row:{cell.row}/span {cell.row_span};",
280
+ "position:relative;",
281
+ "display:flex;",
282
+ ]
283
+
284
+ align_items_css_value = "flex-start"
285
+ if cell.content_v_align:
286
+ if cell.content_v_align == VerticalAlign.center:
287
+ align_items_css_value = "center"
288
+ elif cell.content_v_align == VerticalAlign.bottom:
289
+ align_items_css_value = "flex-end"
290
+ elif cell.content_v_align == VerticalAlign.top:
291
+ align_items_css_value = "flex-start"
292
+ elif isinstance(cell.element, TextElement) and cell.element.v_align:
293
+ if cell.element.v_align == VerticalAlign.center:
294
+ align_items_css_value = "center"
295
+ elif cell.element.v_align == VerticalAlign.bottom:
296
+ align_items_css_value = "flex-end"
297
+
298
+ justify_content_css_value = "flex-start"
299
+ if cell.content_h_align:
300
+ if cell.content_h_align == HorizontalAlign.center:
301
+ justify_content_css_value = "center"
302
+ elif cell.content_h_align == HorizontalAlign.right:
303
+ justify_content_css_value = "flex-end"
304
+ elif cell.content_h_align == HorizontalAlign.left:
305
+ justify_content_css_value = "flex-start"
306
+ elif isinstance(cell.element, TextElement) and cell.element.h_align:
307
+ if cell.element.h_align == HorizontalAlign.center:
308
+ justify_content_css_value = "center"
309
+ elif cell.element.h_align == HorizontalAlign.right:
310
+ justify_content_css_value = "flex-end"
311
+
312
+ cell_styles_list.append(f"align-items: {align_items_css_value};")
313
+ cell_styles_list.append(f"justify-content: {justify_content_css_value};")
314
+
315
+ if cell.padding:
316
+ cell_styles_list.append(f"padding:{cell.padding};")
317
+ if cell.background_color:
318
+ cell_styles_list.append(f"background-color:{cell.background_color};")
319
+
320
+ if cell.align_self:
321
+ cell_styles_list.append(f"align-self:{cell.align_self};")
322
+ if cell.justify_self:
323
+ cell_styles_list.append(f"justify-self:{cell.justify_self};")
324
+
325
+ final_cell_style = "".join(cell_styles_list)
326
+ try:
327
+ html_parts.append(f'<div style="{final_cell_style}">{cell.element.render(self.style_theme)}</div>')
328
+ except Exception as e:
329
+ raise e
330
+ html_parts.append("</div>")
331
+ return "".join(html_parts)
332
+
333
+ class Slide(BaseModel):
334
+ title: str
335
+ layout: GridLayout
336
+ notes: Optional[str] = None
337
+ include_logo_in_header: bool = True
338
+ footer_info: str = ""
339
+ body_margin_top: int = 5
340
+
341
+ style_theme: Optional[StyleSettings] = None
342
+
343
+ def _section_style(self) -> str:
344
+ # only background color; size determined by container
345
+ return f"background-color:{self.style_theme.background_color};"
346
+
347
+ def _render_header(self) -> str:
348
+ title_class = "text-element-h2"
349
+ title_inline_style = f"color: {self.style_theme.heading_color};" # Only color here
350
+
351
+ logo_html = self.style_theme.logo_img_html() if self.include_logo_in_header else ""
352
+ return (
353
+ f'<div class="slide-header">'
354
+ f' <div class="slide-title {title_class} fw-bold" style="{title_inline_style}">'
355
+ f'{self.title}</div>'
356
+ f'{logo_html}'
357
+ f'</div>'
358
+ )
359
+
360
+ def _render_body(self) -> str:
361
+ style = (
362
+ f"flex:1; display:flex; flex-direction:column;"
363
+ f" margin-top:{self.body_margin_top}px;"
364
+ )
365
+ return (
366
+ f'<div class="slide-body" style="{style}">'
367
+ f'{self.layout.render()}'
368
+ f'</div>'
369
+ )
370
+
371
+ def _render_footer(self, slide_number: int, total: int, ) -> str:
372
+ text_style = f"color: {self.style_theme.light_paragraph_color};"
373
+ return (
374
+ f'<div class="slide-footer">'
375
+ f'<div class="slide-date" style="{text_style}">{self.footer_info}</div>'
376
+ f'<div class="slide-number" style="{text_style}">{slide_number} / {total}</div>'
377
+ f'</div>'
378
+ )
379
+
380
+ def _override_theme(self,theme_mode:ThemeMode):
381
+ if self.style_theme is None:
382
+ self.style_theme = theme_mode
383
+ if self.layout.style_theme is None:
384
+ self.layout.style_theme=theme_mode
385
+
386
+ def render(self, slide_number: int, total: int,
387
+ override_theme_mode_if_none:ThemeMode
388
+ ) -> str:
389
+ self._override_theme(override_theme_mode_if_none)
390
+ header = self._render_header()
391
+ body = self._render_body()
392
+ footer = self._render_footer(slide_number, total, )
393
+ section_style = self._section_style()
394
+
395
+ return (
396
+ f'<section class="slide" style="{section_style}">'
397
+ f'{header}{body}{footer}'
398
+ f'</section>'
399
+ )
400
+
401
+ class VerticalImageSlide(Slide):
402
+ image_url: HttpUrl = Field(
403
+ ..., description="URL for the right-column image"
404
+ )
405
+ image_width_pct: int = Field(
406
+ 50,
407
+ ge=0,
408
+ le=100,
409
+ description="Percentage width of the right-column image"
410
+ )
411
+ image_fit: Literal["cover", "contain"] = Field(
412
+ "cover",
413
+ description="How the image should fit its container"
414
+ )
415
+
416
+ def render(self, slide_number: int, total: int,
417
+ override_theme_mode_if_none: ThemeMode
418
+ ) -> str:
419
+ self._override_theme(override_theme_mode_if_none)
420
+ header = self._render_header()
421
+ body = self._render_body()
422
+ footer = self._render_footer(slide_number, total )
423
+
424
+ # Determine inline widths
425
+ left_pct = 100 - self.image_width_pct
426
+ left_style = f"width:{left_pct}%;"
427
+ right_style = f"width:{self.image_width_pct}%; padding:0;"
428
+ img_style = f"width:100%; height:100%; object-fit:{self.image_fit};"
429
+
430
+ # Compose columns
431
+ left_html = (
432
+ f'<div class="left-column" style="{left_style}">'
433
+ f'{body}'
434
+ f'</div>'
435
+ )
436
+ right_html = (
437
+ f'<div class="right-column" style="{right_style}">'
438
+ f' <img src="{self.image_url}" alt="" style="{img_style}" />'
439
+ f'</div>'
440
+ )
441
+
442
+ # Section tag uses both classes and background style
443
+ section_style = self._section_style()
444
+ return (
445
+ f'<section class="slide vertical-image-slide" style="{section_style}">'
446
+ f'{left_html}{right_html}'
447
+ f'</section>'
448
+ )
449
+
450
+ class ThemeMode(str, Enum):
451
+ light = "light"
452
+ dark = "dark"
453
+
454
+ class StyleSettings(BaseModel):
455
+ """
456
+ Pydantic model for theme-based style settings.
457
+ Provides a semantic typographic scale (h1–h6, p), separate font families for headings and paragraphs,
458
+ and chart palettes. Colors and palettes are auto-filled based on `mode`.
459
+ """
460
+ # theme switch
461
+ mode: ThemeMode = ThemeMode.light
462
+
463
+ # semantic typographic scale
464
+ font_size_h1: int = 32
465
+ font_size_h2: int = 28
466
+ font_size_h3: int = 24
467
+ font_size_h4: int = 20
468
+ font_size_h5: int = 16
469
+ font_size_h6: int = 14
470
+ font_size_p: int = 12
471
+
472
+ # default font families
473
+ font_family_headings: str = "Montserrat, sans-serif"
474
+ font_family_paragraphs: str = "Lato, Arial, Helvetica, sans-serif"
475
+
476
+ # layout
477
+ title_column_width: str = "150px"
478
+ chart_label_font_size: int = 12
479
+ logo_url: Optional[str] = None
480
+
481
+ # theme-driven colors (auto-filled)
482
+ primary_color: Optional[str] = Field(None)
483
+ secondary_color: Optional[str] = Field(None)
484
+ accent_color_1: Optional[str] = Field(None)
485
+ accent_color_2: Optional[str] = Field(None)
486
+ heading_color: Optional[str] = Field(None)
487
+ paragraph_color: Optional[str] = Field(None)
488
+ background_color: Optional[str] = Field(None)
489
+ light_paragraph_color: Optional[str] = Field(None, description="Paragraph text color on light backgrounds")
490
+
491
+ # chart color palettes
492
+ chart_palette_sequential: Optional[List[str]] = Field(None)
493
+ chart_palette_diverging: Optional[List[str]] = Field(None)
494
+ chart_palette_categorical: Optional[List[str]] = Field(None)
495
+
496
+ def logo_img_html(self, position: str = "slide-logo") -> str:
497
+ return f'<div class="{position}"><img src="{self.logo_url}" alt="logo" crossOrigin="anonymous"></div>' if self.logo_url else ""
498
+
499
+ @root_validator(pre=True)
500
+ def _fill_theme_defaults(cls, values: Dict) -> Dict:
501
+ palettes = {
502
+ ThemeMode.light: {
503
+ # base colors
504
+ "primary_color": "#c0d8fb",
505
+ "secondary_color": "#1254ff",
506
+ "accent_color_1": "#553ffe",
507
+ "accent_color_2": "#aea06c",
508
+ "heading_color": "#c0d8fb",
509
+ "paragraph_color": "#303238",
510
+ "background_color": "#FFFFFF",
511
+ "light_paragraph_color": "#303238",
512
+
513
+ # chart palettes
514
+ "chart_palette_sequential": ["#f7fbff","#deebf7","#9ecae1","#3182bd"],
515
+ "chart_palette_diverging": ["#d7191c","#fdae61","#ffffbf","#abdda4","#2b83ba"],
516
+ "chart_palette_categorical": ["#1b9e77","#d95f02","#7570b3","#e7298a","#66a61e"],
517
+ },
518
+ ThemeMode.dark: {
519
+ "primary_color": "#E0E0E0", # light gray for primary text
520
+ "secondary_color": "#BB86FC", # soft purple accent
521
+ "accent_color_1": "#03DAC6", # vibrant teal
522
+ "accent_color_2": "#CF6679", # warm pink/red
523
+ "heading_color": "#FFFFFF", # pure white for headings
524
+ "paragraph_color": "#E0E0E0", # slightly muted white for body text
525
+ "background_color": "#121212", # deep charcoal
526
+ "light_paragraph_color": "#E0E0E0",
527
+
528
+ "chart_palette_sequential": [
529
+ "#37474F", # slate blue-gray
530
+ "#455A64",
531
+ "#546E7A",
532
+ "#607D8B", # progressively lighter
533
+ "#78909C"
534
+ ],
535
+ "chart_palette_diverging": [
536
+ "#D32F2F", # strong red
537
+ "#F57C00", # orange
538
+ "#EEEEEE", # near-white neutral mid-point
539
+ "#0288D1", # bright blue
540
+ "#1976D2" # deeper blue
541
+ ],
542
+ "chart_palette_categorical": [
543
+ "#F94144", # red
544
+ "#F3722C", # orange
545
+ "#F9C74F", # yellow
546
+ "#90BE6D", # green
547
+ "#577590", # indigo
548
+ "#43AA8B", # teal
549
+ "#8E44AD" # purple
550
+ ],
551
+ }
552
+ }
553
+ mode = values.get("mode", ThemeMode.light)
554
+ for field, default in palettes.get(mode, {}).items():
555
+ values.setdefault(field, default)
556
+ return values
557
+
558
+
559
+ # ─── instantiate both themes ────────────────────────────────────────────
560
+ light_settings: StyleSettings = StyleSettings(mode=ThemeMode.light)
561
+ dark_settings: StyleSettings = StyleSettings(mode=ThemeMode.dark)
562
+
563
+
564
+ def get_theme_settings(mode: ThemeMode) -> StyleSettings:
565
+ """
566
+ Retrieve the global light or dark settings instance.
567
+ """
568
+ return light_settings if mode is ThemeMode.light else dark_settings
569
+
570
+
571
+ def update_settings_from_dict(overrides: dict, mode: ThemeMode) -> None:
572
+ """
573
+ Update either `light_settings` or `dark_settings` in-place from dict `overrides`.
574
+
575
+ - `overrides` may include any fields (colors, fonts, layout, palettes).
576
+ - `mode` selects which settings instance to modify.
577
+ """
578
+ # select global instance
579
+ instance = get_theme_settings(mode)
580
+ # merge current values with overrides
581
+ merged = instance.dict()
582
+ merged.update(overrides)
583
+ # create a temporary instance to re-apply root defaults and validation
584
+ temp = StyleSettings(**merged)
585
+ # mutate the existing settings instance so imports remain valid
586
+ for key, value in temp.dict().items():
587
+ setattr(instance, key, value)
588
+
589
+
590
+ class Presentation(BaseModel):
591
+ title: str
592
+ slides: List[Slide]
593
+ style_theme: ThemeMode = Field(default_factory=lambda: light_settings)
594
+
595
+ def render(self) -> str:
596
+ slides_html = []
597
+
598
+ # add the slide template
599
+ # self.slides.append(self._slide_template())
600
+ for slide in self.slides:
601
+ if slide.style_theme is None:
602
+ slide.style_theme = self.style_theme
603
+
604
+ total = len(self.slides)-1 # do not add the final template slide
605
+
606
+ slides_html += [s.render(i + 1, total,
607
+ override_theme_mode_if_none=s.style_theme
608
+ ) for i, s in enumerate(self.slides)]
609
+ return BASE_TEMPLATE.render(
610
+ title=self.title,
611
+ font_family=self.style_theme.font_family_paragraphs,
612
+ slides="".join(slides_html),
613
+ )
614
+
615
+ def _slide_template(self) -> Slide:
616
+
617
+ # 1) Four rows:
618
+ # - First row “auto” for our split tutorial
619
+ # - Then the three demo rows (100px, 2fr, 1fr)
620
+ row_definitions = ["auto", "100px", "2fr", "1fr"]
621
+
622
+ # 2) Twelve columns mixing px and fr
623
+ col_definitions = [
624
+ "50px", "1fr", "2fr", "100px", "3fr", "1fr",
625
+ "200px", "2fr", "1fr", "150px", "4fr", "1fr"
626
+ ]
627
+
628
+ # 3) Tutorial cells in row 1:
629
+ cells: List[GridCell] = [
630
+ # Left tutorial text (cols 1–6) with detailed fr explanation
631
+ GridCell(
632
+ row=1, col=1, col_span=6,
633
+ element=TextElement(
634
+ text=(
635
+ "<strong>Tutorial: How fr Units Are Calculated</strong><br><br>"
636
+ "1. <em>Start with total container height</em> (e.g. 800px).<br>"
637
+ "2. <em>Subtract auto/fixed rows</em>:<br>"
638
+ " • Row 1 (auto) → measured by content, say 200px<br>"
639
+ " • Row 2 (fixed) → exactly 100px<br>"
640
+ " → Used: 300px<br>"
641
+ "3. <em>Free space</em> = 800px − 300px = 500px<br>"
642
+ "4. <em>Total fr shares</em> = 2fr + 1fr = 3 shares<br>"
643
+ "5. <em>One share</em> = 500px ÷ 3 ≈ 166.67px<br>"
644
+ "6. <em>Allocate</em>:<br>"
645
+ " • Row 3 (2fr) → 2×166.67px ≈ 333.33px<br>"
646
+ " • Row 4 (1fr) → 1×166.67px ≈ 166.67px<br><br>"
647
+ "→ That’s how 2fr can take twice the free space and still leave one share for 1fr!"
648
+ ),
649
+ font_size=14,
650
+ h_align=HorizontalAlign.left,
651
+ v_align=VerticalAlign.top
652
+ ),
653
+ padding="12px",
654
+ background_color="#f9f9f9"
655
+ ),
656
+ # Right tutorial code (cols 7–12)
657
+ GridCell(
658
+ row=1, col=7, col_span=6,
659
+ element=TextElement(
660
+ text=(
661
+ "<pre style=\"font-size:12px; white-space:pre-wrap;\">"
662
+ "row_defs = ['auto', '100px', '2fr', '1fr']\n"
663
+ "col_defs = ['50px','1fr','2fr','100px','3fr','1fr',\n"
664
+ " '200px','2fr','1fr','150px','4fr','1fr']\n\n"
665
+ "slide = GridLayout(\n"
666
+ " row_definitions=row_defs,\n"
667
+ " col_definitions=col_defs,\n"
668
+ " gap='10px',\n"
669
+ " cells=... # see demo rows below\n"
670
+ ")\n"
671
+ "</pre>"
672
+ ),
673
+ font_size=12,
674
+ h_align=HorizontalAlign.left,
675
+ v_align=VerticalAlign.top
676
+ ),
677
+ padding="12px",
678
+ background_color="#ffffff"
679
+ )
680
+ ]
681
+
682
+ # 4) Demo cells for rows 2–4
683
+ for r in range(2, 5): # rows 2, 3, 4
684
+ for c in range(1, 13): # cols 1–12
685
+ label = f"R{r}({row_definitions[r - 1]}), C{c}({col_definitions[c - 1]})"
686
+ cells.append(
687
+ GridCell(
688
+ row=r,
689
+ col=c,
690
+ element=TextElement(
691
+ text=label,
692
+ font_size=12,
693
+ h_align=HorizontalAlign.center,
694
+ v_align=VerticalAlign.center
695
+ )
696
+ )
697
+ )
698
+
699
+ # 5) Build and render the layout
700
+ slide_layout = GridLayout(
701
+ row_definitions=row_definitions,
702
+ col_definitions=col_definitions,
703
+ gap="10px",
704
+ cells=cells,
705
+ width="100%",
706
+ height="100%"
707
+ )
708
+
709
+ return Slide(
710
+ title="Slide Template",
711
+ layout=slide_layout,
712
+
713
+ )