stackraise 0.1.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 (52) hide show
  1. stackraise/__init__.py +6 -0
  2. stackraise/ai/__init__.py +2 -0
  3. stackraise/ai/rpa.py +380 -0
  4. stackraise/ai/toolset.py +227 -0
  5. stackraise/app.py +23 -0
  6. stackraise/auth/__init__.py +2 -0
  7. stackraise/auth/model.py +24 -0
  8. stackraise/auth/service.py +240 -0
  9. stackraise/ctrl/__init__.py +4 -0
  10. stackraise/ctrl/change_stream.py +40 -0
  11. stackraise/ctrl/crud_controller.py +63 -0
  12. stackraise/ctrl/file_storage.py +68 -0
  13. stackraise/db/__init__.py +11 -0
  14. stackraise/db/adapter.py +60 -0
  15. stackraise/db/collection.py +292 -0
  16. stackraise/db/cursor.py +229 -0
  17. stackraise/db/document.py +282 -0
  18. stackraise/db/exceptions.py +9 -0
  19. stackraise/db/id.py +79 -0
  20. stackraise/db/index.py +84 -0
  21. stackraise/db/persistence.py +238 -0
  22. stackraise/db/pipeline.py +245 -0
  23. stackraise/db/protocols.py +141 -0
  24. stackraise/di.py +36 -0
  25. stackraise/event.py +150 -0
  26. stackraise/inflection.py +28 -0
  27. stackraise/io/__init__.py +3 -0
  28. stackraise/io/imap_client.py +400 -0
  29. stackraise/io/smtp_client.py +102 -0
  30. stackraise/logging.py +22 -0
  31. stackraise/model/__init__.py +11 -0
  32. stackraise/model/core.py +16 -0
  33. stackraise/model/dto.py +12 -0
  34. stackraise/model/email_message.py +88 -0
  35. stackraise/model/file.py +154 -0
  36. stackraise/model/name_email.py +45 -0
  37. stackraise/model/query_filters.py +231 -0
  38. stackraise/model/time_range.py +285 -0
  39. stackraise/model/validation.py +8 -0
  40. stackraise/templating/__init__.py +4 -0
  41. stackraise/templating/exceptions.py +23 -0
  42. stackraise/templating/image/__init__.py +2 -0
  43. stackraise/templating/image/model.py +51 -0
  44. stackraise/templating/image/processor.py +154 -0
  45. stackraise/templating/parser.py +156 -0
  46. stackraise/templating/pptx/__init__.py +3 -0
  47. stackraise/templating/pptx/pptx_engine.py +204 -0
  48. stackraise/templating/pptx/slide_renderer.py +181 -0
  49. stackraise/templating/tracer.py +57 -0
  50. stackraise-0.1.0.dist-info/METADATA +37 -0
  51. stackraise-0.1.0.dist-info/RECORD +52 -0
  52. stackraise-0.1.0.dist-info/WHEEL +4 -0
@@ -0,0 +1,285 @@
1
+ # %%
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from dataclasses import replace
6
+ from datetime import UTC
7
+ from datetime import date as Date
8
+ from datetime import datetime as DateTime
9
+ from datetime import time as Time
10
+ from datetime import timedelta as TimeDelta
11
+ from datetime import timezone as TimeZone
12
+ from typing import Optional, TypeAlias
13
+
14
+ from pydantic.dataclasses import dataclass
15
+
16
+ TimeDeltaParseable: TypeAlias = int | str | TimeDelta
17
+
18
+
19
+ DateTimeParseable: TypeAlias = int | str | Date | DateTime
20
+
21
+
22
+ @dataclass(frozen=True, slots=True)
23
+ class TimeRange:
24
+ start: DateTime
25
+ stop: DateTime
26
+
27
+ @property
28
+ def is_for_an_exact_day(self) -> Optional[Date]:
29
+ if _datetime_is_day_exact(self.start) and self.stop == _datetime_next_day(
30
+ self.start
31
+ ):
32
+ return self.start.date()
33
+
34
+ @property
35
+ def is_for_exact_days(self) -> bool:
36
+ return _datetime_is_day_exact(self.start) and _datetime_is_day_exact(self.stop)
37
+
38
+ @property
39
+ def is_for_an_exact_month(self) -> bool:
40
+ return _datetime_is_month_exact(self.start) and self.stop == _datetime_next_month(
41
+ self.start
42
+ )
43
+
44
+ @property
45
+ def is_for_exact_months(self) -> bool:
46
+ return _datetime_is_month_exact(self.start) and _datetime_is_month_exact(
47
+ self.stop
48
+ )
49
+
50
+ @property
51
+ def is_for_an_exact_year(self) -> bool:
52
+ return _datetime_is_year_exact(self.start) and self.stop == _datetime_next_year(
53
+ self.start
54
+ )
55
+
56
+ @property
57
+ def is_for_exact_years(self) -> bool:
58
+ return _datetime_is_year_exact(self.start) and _datetime_is_year_exact(self.stop)
59
+
60
+ @property
61
+ def extent(self) -> TimeDelta:
62
+ return self.stop - self.start
63
+
64
+ @classmethod
65
+ def of(cls, s: TimeRangeParseable):
66
+ if isinstance(s, TimeRange):
67
+ return s
68
+ if isinstance(s, str):
69
+ return _parse_timerange(s)
70
+
71
+ raise ValueError(f"Invalid TimeRange string: {s}")
72
+
73
+ def __str__(self):
74
+ if self.is_for_an_exact_year:
75
+ fmt = f"{self.start.year}"
76
+ elif self.is_for_an_exact_month:
77
+ fmt = f"{self.start.year}-{self.start.month:02}"
78
+ elif self.is_for_an_exact_day:
79
+ fmt = f"{self.start.date()}"
80
+ else:
81
+ fmt = f"{_repr_datetime(self.start)}..{_repr_datetime(self.stop)}"
82
+
83
+ return fmt
84
+
85
+ @property
86
+ def duration(self) -> TimeDelta:
87
+ return self.stop - self.start
88
+
89
+ def __contains__(self, dt: DateTime):
90
+ return self.start <= dt < self.stop
91
+
92
+ def replace(self, **kwargs):
93
+ return replace(self, **kwargs)
94
+
95
+ @classmethod
96
+ def overlap(cls, a: TimeRange, b: TimeRange) -> Optional[TimeRange]:
97
+ start = max(a.start, b.start)
98
+ stop = min(a.stop, b.stop)
99
+ if start < stop:
100
+ return cls(start=start, stop=stop)
101
+
102
+
103
+ TimeRangeParseable: TypeAlias = str | TimeRange
104
+
105
+ # Expresiones regulares
106
+ DELTA_REGEX = re.compile(
107
+ r"^(?:(?P<days>\d+)d)?\s?" # Días opcionales
108
+ r"(?:(?P<hours>\d+)h)?\s?" # Horas opcionales
109
+ r"(?:(?P<minutes>\d+)m)?\s?" # Minutos opcionales
110
+ r"(?:(?P<seconds>\d+)s)?$\s?" # Segundos opcionales
111
+ )
112
+
113
+
114
+ def _parse_timedelta(s: TimeDeltaParseable) -> TimeDelta:
115
+ if isinstance(s, TimeDelta):
116
+ return s
117
+ if isinstance(s, int):
118
+ return TimeDelta(milliseconds=s)
119
+
120
+ match = DELTA_REGEX.match(s)
121
+ if not match:
122
+ raise ValueError(f"Invalid timedelta string: {s}")
123
+
124
+ return TimeDelta(
125
+ **{key: int(value) for key, value in match.groupdict(default="0").items()}
126
+ )
127
+
128
+
129
+ DATE_REGEXES = [
130
+ re.compile(r"^(?P<year>\d{4})$"), # Solo año
131
+ re.compile(r"^(?P<year>\d{4})-(?P<month>\d{2})$"), # Año y mes
132
+ re.compile(r"^(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2})$"), # Año, mes y día
133
+ re.compile(
134
+ r"^(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2})T(?P<hour>\d{2}):(?P<minute>\d{2})(?::(?P<second>\d{2}))?$"
135
+ ), # Fecha y hora
136
+ ]
137
+
138
+
139
+ def _parse_datetime(dt: DateTimeParseable) -> DateTime:
140
+ if isinstance(dt, int):
141
+ return DateTime.fromtimestamp(dt).replace(tzinfo=TimeZone.utc)
142
+ elif isinstance(dt, str):
143
+ try:
144
+ for regex in DATE_REGEXES:
145
+ if match := regex.match(dt):
146
+ components = match.groupdict()
147
+ return DateTime(
148
+ year=int(components["year"]),
149
+ month=int(components.get("month", 1)),
150
+ day=int(components.get("day", 1)),
151
+ hour=int(components.get("hour", 0)),
152
+ minute=int(components.get("minute", 0)),
153
+ second=int(components.get("second", 0)),
154
+ tzinfo=UTC,
155
+ )
156
+ raise ValueError(f"Invalid date time string: {dt}")
157
+
158
+ except:
159
+ dt = Date.fromisoformat(dt)
160
+
161
+ if isinstance(dt, Date):
162
+ return DateTime.combine(dt, Time.min).replace(tzinfo=UTC)
163
+ elif isinstance(dt, DateTime):
164
+ return DateTime.fromtimestamp(dt.timestamp()).replace(tzinfo=UTC)
165
+ else:
166
+ raise ValueError(f"Invalid type {type(dt)}")
167
+
168
+
169
+ RANGE_REGEX = re.compile(
170
+ r"^"
171
+ # Rango explícito "start .. stop"
172
+ r"((?P<start>.+?)\s*\.\.\s*(?P<stop>.+?)(\s)?)"
173
+ r"$"
174
+ )
175
+
176
+ RANGE_SEP = re.compile(r"[\s_]+")
177
+
178
+
179
+ def _parse_timerange(input_str: str) -> TimeRange:
180
+ """
181
+ Parsea una cadena de entrada para determinar el rango de tiempo y el delta.
182
+ """
183
+ # Detectar rango explícito
184
+ range_match = RANGE_REGEX.match(input_str)
185
+ if range_match:
186
+ start = _parse_datetime(range_match.group("start"))
187
+ stop = _parse_datetime(range_match.group("stop"))
188
+ return TimeRange(start=start, stop=stop)
189
+
190
+ # Detectar rango implícito (un solo valor)
191
+ start = _parse_datetime(input_str)
192
+ if len(input_str) == 4: # Año
193
+ stop = _datetime_next_year(start)
194
+ elif len(input_str) == 7: # Año y mes
195
+ stop = _datetime_next_month(start)
196
+ elif len(input_str) == 10: # Año, mes y día
197
+ stop = _datetime_next_day(start)
198
+ else:
199
+ raise ValueError(f"Invalid TimeRange string: {input_str}")
200
+
201
+ return TimeRange(start=start, stop=stop)
202
+
203
+
204
+ def _repr_timedelta(time_delta: TimeDelta):
205
+ parts = []
206
+ if time_delta < TimeDelta():
207
+ time_delta = -time_delta
208
+ parts.append("-")
209
+
210
+ weeks, days = divmod(time_delta.days, 7)
211
+ hours, seconds = divmod(time_delta.seconds, 3600)
212
+ minutes, seconds = divmod(seconds, 60)
213
+
214
+ if weeks:
215
+ parts.append(f"{weeks}w")
216
+ if days:
217
+ parts.append(f"{days}d")
218
+ if hours:
219
+ parts.append(f"{hours}h")
220
+ if minutes:
221
+ parts.append(f"{minutes}m")
222
+ if seconds:
223
+ parts.append(f"{seconds}s")
224
+
225
+ return "".join(parts)
226
+
227
+
228
+ def _repr_datetime(dt: DateTime) -> str:
229
+ if _datetime_is_year_exact(dt):
230
+ return str(dt.year)
231
+ if _datetime_is_month_exact(dt):
232
+ return f"{dt.year}-{dt.month:02}"
233
+ if _datetime_is_day_exact(dt):
234
+ return str(dt.date())
235
+ return dt.isoformat()
236
+
237
+
238
+ def _datetime_is_day_exact(dt: DateTime) -> bool:
239
+ return dt.time() == Time.min
240
+
241
+ def _datetime_is_month_exact(dt: DateTime) -> bool:
242
+ return dt.day == 1 and _datetime_is_day_exact(dt)
243
+
244
+ def _datetime_is_year_exact(dt: DateTime) -> bool:
245
+ return dt.month == 1 and _datetime_is_month_exact(dt)
246
+
247
+ def _datetime_next_day(dt: DateTime) -> DateTime:
248
+ return dt + TimeDelta(days=1)
249
+
250
+ def _datetime_next_month(dt: DateTime) -> DateTime:
251
+ if dt.month == 12:
252
+ return dt.replace(year=dt.year + 1, month=1)
253
+ return dt.replace(month=dt.month + 1)
254
+
255
+ def _datetime_next_year(dt: DateTime) -> DateTime:
256
+ return dt.replace(year=dt.year + 1)
257
+
258
+
259
+ # Test cases
260
+ if __name__ == "__main__":
261
+ # assert _parse_timedelta("1d") == TimeDelta(days=1)
262
+ # assert _parse_timedelta("1d 2h 3m 4s") == TimeDelta(
263
+ # days=1, hours=2, minutes=3, seconds=4
264
+ # )
265
+ # assert _parse_timedelta("1d 2h 3m") == TimeDelta(days=1, hours=2, minutes=3)
266
+
267
+ assert TimeRange.of("2024").is_for_an_exact_year
268
+
269
+ examples = [
270
+ "2024", # Año completo, paso predeterminado (1 día)
271
+ "2024-01", # Mes completo, paso predeterminado (1 día)
272
+ "2024-01-01", # Día completo, paso predeterminado (1 día)
273
+ "2024-01-01T12:00:00", # Fecha y hora exacta
274
+ "2024-01 .. 2024-07", # Rango de un año, paso de 1 hora
275
+ "2024-01-01 .. 2024-07-01", # Rango explícito con delta
276
+ ]
277
+
278
+ for example in examples:
279
+ try:
280
+ result = TimeRange.of(example)
281
+ print(f"Input: {example}\nParsed: {result}\n")
282
+ except ValueError as e:
283
+ print(f"Input: {example}\nError: {e}\n")
284
+
285
+ # %%
@@ -0,0 +1,8 @@
1
+ from typing import NoReturn
2
+ from fastapi import HTTPException
3
+
4
+ def validation_error(msg: str) -> NoReturn:
5
+ """Convención con el frotend para lanzar excepciones de validación desde el backend"""
6
+ raise HTTPException(status_code=400, detail=msg)
7
+
8
+
@@ -0,0 +1,4 @@
1
+ from .pptx import *
2
+ from .parser import *
3
+ from .tracer import *
4
+ from .image import *
@@ -0,0 +1,23 @@
1
+ class PptxTemplateEngineError(Exception):
2
+ """Base para errores del motor de plantillas PPTX"""
3
+ pass
4
+
5
+
6
+ class TemplateSyntaxError(PptxTemplateEngineError):
7
+ pass
8
+
9
+
10
+ class UndefinedVariableError(PptxTemplateEngineError):
11
+ pass
12
+
13
+
14
+ class ContextError(PptxTemplateEngineError):
15
+ pass
16
+
17
+
18
+ class TemplateNotFoundError(PptxTemplateEngineError):
19
+ pass
20
+
21
+
22
+ class RenderError(PptxTemplateEngineError):
23
+ pass
@@ -0,0 +1,2 @@
1
+ from .model import *
2
+ from .processor import *
@@ -0,0 +1,51 @@
1
+ from pydantic import BaseModel
2
+ from typing import Union, Optional
3
+ from pathlib import Path
4
+ from io import BytesIO
5
+ from stackraise.model.file import File
6
+
7
+ class ImageData(BaseModel):
8
+ """Modelo para representar imágenes en el contexto de plantillas PPTX"""
9
+
10
+ # Datos de la imagen
11
+ data: Union[bytes, str, Path] # bytes, ruta como string, o Path object
12
+
13
+ # Metadatos opcionales
14
+ width: Optional[float] = None # Ancho en pulgadas
15
+ height: Optional[float] = None # Alto en pulgadas
16
+
17
+ # Para mantener aspect ratio
18
+ max_width: Optional[float] = None
19
+ max_height: Optional[float] = None
20
+
21
+ @classmethod
22
+ def from_bytes(cls, data: bytes, width: Optional[float] = None, height: Optional[float] = None):
23
+ """Crear ImageData desde bytes"""
24
+ return cls(data=data, width=width, height=height)
25
+
26
+ @classmethod
27
+ def from_path(cls, path: Union[str, Path], width: Optional[float] = None, height: Optional[float] = None):
28
+ """Crear ImageData desde ruta de archivo"""
29
+ return cls(data=Path(path), width=width, height=height)
30
+
31
+ @classmethod
32
+ def from_url(cls, url: str, width: Optional[float] = None, height: Optional[float] = None):
33
+ """Crear ImageData desde URL (requiere descarga previa)"""
34
+ # TODO: Implementar descarga de imagen desde URL
35
+ return cls(data=url, width=width, height=height)
36
+
37
+ @classmethod
38
+ async def from_file_ref(cls, file_ref: File.Ref):
39
+ file: File = await file_ref.fetch()
40
+ content = await file.content()
41
+ return cls.from_bytes(content)
42
+
43
+ def get_bytes(self) -> bytes:
44
+ """Obtener los bytes de la imagen"""
45
+ if isinstance(self.data, bytes):
46
+ return self.data
47
+ elif isinstance(self.data, (str, Path)):
48
+ with open(self.data, 'rb') as f:
49
+ return f.read()
50
+ else:
51
+ raise ValueError(f"Tipo de data no soportado: {type(self.data)}")
@@ -0,0 +1,154 @@
1
+ from pptx.slide import Slide
2
+ from pptx.util import Inches
3
+ from pptx.enum.shapes import MSO_SHAPE_TYPE
4
+ from io import BytesIO
5
+ from ..image import ImageData
6
+ from ..parser import extract_image_variables
7
+ from ..tracer import Tracer
8
+ import re
9
+
10
+ """
11
+ Procesa variables de tipo ImageData en slides de PPTX.
12
+ TODO: Refactor para otro tipo de archivos. El tracer y el parser deberian estar a nivel de templating
13
+ """
14
+
15
+ class ImageProcessor:
16
+ """Procesa variables de tipo ImageData en slides de PPTX"""
17
+
18
+ @staticmethod
19
+ def process_slide_images(slide: Slide, context: dict, tracer: Tracer):
20
+ """
21
+ Procesa todas las variables ImageData en una slide, reemplazando
22
+ los placeholders {{variable}} con las imágenes reales.
23
+ """
24
+ shapes_to_remove = []
25
+
26
+ for shape in slide.shapes:
27
+ if shape.has_text_frame:
28
+ # Buscar variables ImageData en texto
29
+ text_content = shape.text_frame.text
30
+ image_vars = extract_image_variables(text_content, context)
31
+
32
+ if image_vars:
33
+ # Si hay variables de imagen en este shape
34
+ for image_var in image_vars:
35
+ image_data = ImageProcessor._get_nested_value(context, image_var)
36
+ if isinstance(image_data, ImageData):
37
+ # Verificar si el shape contiene SOLO la variable de imagen
38
+ clean_text = text_content.strip()
39
+ var_pattern = r"""^\{\{\s*""" + re.escape(image_var) + r"""\s*\}\}$"""
40
+
41
+ if re.match(var_pattern, clean_text):
42
+ # El shape contiene SOLO la imagen, reemplazarlo completamente
43
+ ImageProcessor._replace_shape_with_image(
44
+ slide, shape, image_var, image_data, tracer
45
+ )
46
+ shapes_to_remove.append(shape)
47
+ else:
48
+ # El shape tiene texto mixto, insertar imagen cerca
49
+ ImageProcessor._insert_image_near_text(
50
+ slide, shape, image_var, image_data, tracer
51
+ )
52
+
53
+ # Remover shapes que fueron reemplazados por imágenes
54
+ for shape in shapes_to_remove:
55
+ sp = shape._element
56
+ sp.getparent().remove(sp)
57
+
58
+ @staticmethod
59
+ def _get_nested_value(context: dict, var_path: str):
60
+ """
61
+ Obtiene el valor de una variable anidada (ej: client.logo)
62
+ """
63
+ value = context
64
+ for part in var_path.split('.'):
65
+ value = value[part]
66
+ return value
67
+
68
+ @staticmethod
69
+ def _replace_shape_with_image(slide: Slide, text_shape, image_var: str, image_data: ImageData, tracer: Tracer):
70
+ """
71
+ Reemplaza completamente un shape de texto con una imagen.
72
+ """
73
+ try:
74
+ # Obtener posición y tamaño del shape de texto
75
+ left = text_shape.left
76
+ top = text_shape.top
77
+ # width = text_shape.width
78
+ # height = text_shape.height
79
+
80
+ # Obtener los bytes de la imagen
81
+ image_bytes = image_data.get_bytes()
82
+ image_stream = BytesIO(image_bytes)
83
+ image_stream.seek(0)
84
+
85
+ # Calcular dimensiones finales
86
+ final_width = Inches(image_data.width) if image_data.width else None
87
+ final_height = Inches(image_data.height) if image_data.height else None
88
+
89
+ # Si hay límites máximos, aplicarlos manteniendo aspect ratio
90
+ if image_data.max_width or image_data.max_height:
91
+ final_width, final_height = ImageProcessor._calculate_constrained_size(
92
+ final_width, final_height, image_data.max_width, image_data.max_height
93
+ )
94
+
95
+ # Añadir la imagen al slide
96
+ picture = slide.shapes.add_picture(
97
+ image_stream, left, top, final_width, final_height
98
+ )
99
+
100
+ tracer.log_variable(f"{image_var}", f"Imagen insertada ({final_width}x{final_height})")
101
+
102
+ except Exception as e:
103
+ tracer.log_error(f"Error insertando imagen '{image_var}': {e}")
104
+
105
+ @staticmethod
106
+ def _insert_image_near_text(slide: Slide, text_shape, image_var: str, image_data: ImageData, tracer: Tracer):
107
+ """
108
+ Inserta una imagen cerca de un shape de texto que contiene contenido mixto.
109
+ """
110
+ try:
111
+ # Calcular posición para la imagen (debajo del texto)
112
+ left = text_shape.left
113
+ top = text_shape.top + text_shape.height + Inches(0.1) # 0.1" de separación
114
+
115
+ # Obtener los bytes de la imagen
116
+ image_bytes = image_data.get_bytes()
117
+ image_stream = BytesIO(image_bytes)
118
+
119
+ # Usar dimensiones especificadas o tamaño por defecto
120
+ final_width = Inches(image_data.width) if image_data.width else Inches(2.0)
121
+ final_height = Inches(image_data.height) if image_data.height else Inches(1.5)
122
+
123
+ # Si hay límites máximos, aplicarlos
124
+ if image_data.max_width or image_data.max_height:
125
+ final_width, final_height = ImageProcessor._calculate_constrained_size(
126
+ final_width, final_height, image_data.max_width, image_data.max_height
127
+ )
128
+
129
+ # Añadir la imagen al slide
130
+ picture = slide.shapes.add_picture(
131
+ image_stream, left, top, final_width, final_height
132
+ )
133
+
134
+ tracer.log_variable(f"{image_var}", f"Imagen insertada cerca del texto ({final_width}x{final_height})")
135
+
136
+ except Exception as e:
137
+ tracer.log_error(f"Error insertando imagen '{image_var}': {e}")
138
+
139
+ @staticmethod
140
+ def _calculate_constrained_size(width, height, max_width=None, max_height=None):
141
+ """
142
+ Calcula el tamaño final manteniendo aspect ratio dentro de los límites.
143
+ """
144
+ if max_width:
145
+ max_width = Inches(max_width)
146
+ if max_height:
147
+ max_height = Inches(max_height)
148
+
149
+ # Calcular factor de escala
150
+ scale_x = max_width / width if max_width and width > max_width else 1
151
+ scale_y = max_height / height if max_height and height > max_height else 1
152
+ scale = min(scale_x, scale_y)
153
+
154
+ return width * scale, height * scale
@@ -0,0 +1,156 @@
1
+ from typing import Any, Dict, List, Optional
2
+ from jinja2 import Environment, meta, StrictUndefined
3
+ import re
4
+
5
+ def create_jinja_env():
6
+ return Environment(
7
+ undefined=StrictUndefined,
8
+ autoescape=False,
9
+ trim_blocks=True,
10
+ lstrip_blocks=True
11
+ )
12
+
13
+ def extract_variables_from_text(text: str, jinja_env: Environment):
14
+ ast = jinja_env.parse(text)
15
+ return meta.find_undeclared_variables(ast)
16
+
17
+ def extract_slide_loop(text: str) -> Optional[tuple[str, str, str]]:
18
+ #pattern = r"""\{%\s*for\s+(\w+)\s+(of|in)\s+(\w+)\s*%\}"""
19
+ pattern = r"""\{%\s*for\s+(\w+)\s+(of|in)\s+(.+?)\s*%\}"""
20
+ match = re.search(pattern, text)
21
+ if match:
22
+ var, tipo, lista = match.groups()
23
+ return var, lista, tipo
24
+ return None
25
+
26
+ def extract_image_variables(text: str, context: dict) -> List[str]:
27
+ """
28
+ Extrae variables que son de tipo ImageData del contexto
29
+ """
30
+
31
+ from .image.model import ImageData
32
+
33
+ # Buscar todas las variables en el texto
34
+ pattern = r"""\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)*)\s*\}\}"""
35
+ variables = re.findall(pattern, text)
36
+
37
+ # Filtrar solo las que son ImageData
38
+ image_vars = []
39
+ for var in variables:
40
+ # Navegar por variables anidadas (ej: client.logo)
41
+ value = context
42
+ try:
43
+ for part in var.split('.'):
44
+ value = value[part]
45
+ if isinstance(value, ImageData):
46
+ image_vars.append(var)
47
+ except (KeyError, TypeError):
48
+ continue
49
+
50
+ return image_vars
51
+
52
+ def has_image_variables(text: str, context: dict) -> bool:
53
+ """
54
+ Verifica si el texto contiene variables de tipo ImageData
55
+ """
56
+ return len(extract_image_variables(text, context)) > 0
57
+
58
+ def preprocess_text_for_jinja(text: str, context: dict = None) -> str:
59
+ """
60
+ Preprocesa el texto para convertir 'of' a 'in' en bucles de texto
61
+ y remover variables ImageData (se procesan por separado)
62
+ """
63
+ # Convertir {% for var of list %} a {% for var in list %}
64
+ #pattern = r"""(\{%\s*for\s+\w+\s+)of(\s+\w+\s*%\})"""
65
+ pattern = r"""(\{%\s*for\s+\w+\s+)of(\s+.+?\s*%\})"""
66
+ converted_text = re.sub(pattern, r'\1in\2', text)
67
+
68
+ # Si tenemos contexto, remover variables ImageData
69
+ if context:
70
+ image_vars = extract_image_variables(text, context)
71
+ for var in image_vars:
72
+ # Reemplazar {{imagen_var}} con placeholder vacío
73
+ var_pattern = r"""\{\{\s*""" + re.escape(var) + r"""\s*\}\}"""
74
+ converted_text = re.sub(var_pattern, "", converted_text)
75
+
76
+ return converted_text
77
+
78
+ def extract_slide_conditional_block(text: str) -> Optional[Dict[str, Any]]:
79
+ """
80
+ Extrae un bloque condicional completo incluyendo if/elif/else.
81
+ Retorna un diccionario con la estructura del condicional.
82
+ """
83
+ # Patrón para capturar bloques if completos con elif y else opcionales
84
+ pattern = r"""\{%\s*if\s+(.+?)\s*%\}(.*?)(?:\{%\s*elif\s+(.+?)\s*%\}(.*?))*(?:\{%\s*else\s*%\}(.*?))?\{%\s*endif\s*%\}"""
85
+
86
+ match = re.search(pattern, text, re.DOTALL)
87
+ if match:
88
+ if_condition = match.group(1).strip()
89
+ return {
90
+ 'type': 'conditional_block',
91
+ 'if_condition': if_condition,
92
+ 'has_block_structure': True
93
+ }
94
+
95
+ # Patrón más simple para detectar si hay una condición en la slide
96
+ simple_pattern = r"""\{%\s*if\s+(.+?)\s*%\}"""
97
+ simple_match = re.search(simple_pattern, text)
98
+ if simple_match:
99
+ condition = simple_match.group(1).strip()
100
+ return {
101
+ 'type': 'simple_conditional',
102
+ 'condition': condition,
103
+ 'has_block_structure': False
104
+ }
105
+
106
+ return None
107
+
108
+ def extract_slide_conditional(text: str) -> Optional[str]:
109
+ """
110
+ Extrae la condición de un bloque if en una slide.
111
+ Retorna la condición si encuentra un bloque {% if condition %}
112
+ """
113
+ pattern = r"""\{%\s*if\s+(.+?)\s*%\}"""
114
+ match = re.search(pattern, text)
115
+ if match:
116
+ condition = match.group(1).strip()
117
+ return condition
118
+ return None
119
+
120
+ def evaluate_condition(condition: str, context: dict, jinja_env: Environment) -> bool:
121
+ """
122
+ Evalúa una condición de Jinja2 contra el contexto dado.
123
+ """
124
+ try:
125
+ # Crear una plantilla simple que evalúe la condición
126
+ template_text = f"{{% if {condition} %}}TRUE{{% else %}}FALSE{{% endif %}}"
127
+ template = jinja_env.from_string(template_text)
128
+ result = template.render(context)
129
+ return result == "TRUE"
130
+ except Exception as e:
131
+ raise ValueError(f"Error evaluando condición '{condition}': {e}")
132
+
133
+ def evaluate_conditional_block(text: str, context: dict, jinja_env: Environment) -> bool:
134
+ """
135
+ Evalúa un bloque condicional completo con if/elif/else.
136
+ Retorna True si alguna condición se cumple o hay un else.
137
+ """
138
+ try:
139
+ # Preprocesar para convertir 'of' a 'in'
140
+ processed_text = preprocess_text_for_jinja(text)
141
+ # Renderizar el bloque completo y ver si produce contenido
142
+ template = jinja_env.from_string(processed_text)
143
+ result = template.render(context).strip()
144
+ # Si el resultado no está vacío, significa que alguna condición se cumplió
145
+ return bool(result)
146
+ except Exception as e:
147
+ raise ValueError(f"Error evaluando bloque condicional: {e}")
148
+
149
+ def render_text(text: str, context: dict, jinja_env: Environment) -> str:
150
+ try:
151
+ # Preprocesar para convertir 'of' a 'in' y remover ImageData
152
+ processed_text = preprocess_text_for_jinja(text, context)
153
+ template = jinja_env.from_string(processed_text)
154
+ return template.render(context)
155
+ except Exception as e:
156
+ raise
@@ -0,0 +1,3 @@
1
+ from ..exceptions import *
2
+ from .pptx_engine import *
3
+ from .slide_renderer import *