msgflux 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 (165) hide show
  1. msgflux/__init__.py +104 -0
  2. msgflux/_private/__init__.py +0 -0
  3. msgflux/_private/client.py +105 -0
  4. msgflux/_private/core.py +14 -0
  5. msgflux/_private/executor.py +134 -0
  6. msgflux/_private/response.py +230 -0
  7. msgflux/auto/__init__.py +5 -0
  8. msgflux/auto/params.py +167 -0
  9. msgflux/cache.py +54 -0
  10. msgflux/core/__init__.py +5 -0
  11. msgflux/core/dotdict.py +264 -0
  12. msgflux/core/examples.py +169 -0
  13. msgflux/core/message.py +45 -0
  14. msgflux/data/__init__.py +0 -0
  15. msgflux/data/audio.py +1 -0
  16. msgflux/data/dbs/__init__.py +16 -0
  17. msgflux/data/dbs/base.py +124 -0
  18. msgflux/data/dbs/db.py +65 -0
  19. msgflux/data/dbs/providers/__init__.py +0 -0
  20. msgflux/data/dbs/providers/cachetools.py +57 -0
  21. msgflux/data/dbs/providers/diskcache.py +48 -0
  22. msgflux/data/dbs/providers/faiss.py +220 -0
  23. msgflux/data/dbs/registry.py +19 -0
  24. msgflux/data/dbs/response.py +10 -0
  25. msgflux/data/dbs/types.py +26 -0
  26. msgflux/data/parsers/__init__.py +16 -0
  27. msgflux/data/parsers/base.py +325 -0
  28. msgflux/data/parsers/parser.py +101 -0
  29. msgflux/data/parsers/providers/__init__.py +32 -0
  30. msgflux/data/parsers/providers/csv_parser.py +278 -0
  31. msgflux/data/parsers/providers/email_parser.py +264 -0
  32. msgflux/data/parsers/providers/html_parser.py +265 -0
  33. msgflux/data/parsers/providers/openpyxl.py +333 -0
  34. msgflux/data/parsers/providers/pypdf.py +186 -0
  35. msgflux/data/parsers/providers/python_docx.py +334 -0
  36. msgflux/data/parsers/providers/python_pptx.py +197 -0
  37. msgflux/data/parsers/registry.py +19 -0
  38. msgflux/data/parsers/response.py +32 -0
  39. msgflux/data/parsers/types.py +110 -0
  40. msgflux/data/retrievers/__init__.py +16 -0
  41. msgflux/data/retrievers/base.py +185 -0
  42. msgflux/data/retrievers/providers/__init__.py +0 -0
  43. msgflux/data/retrievers/providers/bm25.py +230 -0
  44. msgflux/data/retrievers/providers/bm25s.py +221 -0
  45. msgflux/data/retrievers/providers/rank_bm25.py +148 -0
  46. msgflux/data/retrievers/providers/rapidfuzz.py +125 -0
  47. msgflux/data/retrievers/providers/wikipedia.py +281 -0
  48. msgflux/data/retrievers/registry.py +19 -0
  49. msgflux/data/retrievers/retriever.py +83 -0
  50. msgflux/data/retrievers/types.py +24 -0
  51. msgflux/data/types/__init__.py +29 -0
  52. msgflux/data/types/audio.py +112 -0
  53. msgflux/data/types/base.py +70 -0
  54. msgflux/data/types/file.py +114 -0
  55. msgflux/data/types/image.py +134 -0
  56. msgflux/data/types/video.py +121 -0
  57. msgflux/dsl/__init__.py +0 -0
  58. msgflux/dsl/inline.py +484 -0
  59. msgflux/dsl/signature.py +393 -0
  60. msgflux/dsl/typed_parsers/__init__.py +19 -0
  61. msgflux/dsl/typed_parsers/base.py +24 -0
  62. msgflux/dsl/typed_parsers/providers/__init__.py +0 -0
  63. msgflux/dsl/typed_parsers/providers/xml.py +259 -0
  64. msgflux/dsl/typed_parsers/registry.py +14 -0
  65. msgflux/envs.py +100 -0
  66. msgflux/exceptions.py +84 -0
  67. msgflux/generation/__init__.py +0 -0
  68. msgflux/generation/control_flow.py +163 -0
  69. msgflux/generation/reasoning/__init__.py +5 -0
  70. msgflux/generation/reasoning/cot.py +7 -0
  71. msgflux/generation/reasoning/react.py +289 -0
  72. msgflux/generation/reasoning/self_consistency.py +16 -0
  73. msgflux/generation/templates.py +53 -0
  74. msgflux/logger.py +159 -0
  75. msgflux/models/__init__.py +16 -0
  76. msgflux/models/base.py +39 -0
  77. msgflux/models/cache.py +125 -0
  78. msgflux/models/gateway.py +466 -0
  79. msgflux/models/httpx.py +81 -0
  80. msgflux/models/model.py +122 -0
  81. msgflux/models/profiles/__init__.py +48 -0
  82. msgflux/models/profiles/base.py +122 -0
  83. msgflux/models/profiles/loader.py +264 -0
  84. msgflux/models/profiles/registry.py +207 -0
  85. msgflux/models/providers/__init__.py +0 -0
  86. msgflux/models/providers/cerebras.py +30 -0
  87. msgflux/models/providers/groq.py +52 -0
  88. msgflux/models/providers/imagerouter.py +33 -0
  89. msgflux/models/providers/jinaai.py +331 -0
  90. msgflux/models/providers/ollama.py +51 -0
  91. msgflux/models/providers/openai.py +1884 -0
  92. msgflux/models/providers/openrouter.py +64 -0
  93. msgflux/models/providers/replicate.py +218 -0
  94. msgflux/models/providers/sambanova.py +38 -0
  95. msgflux/models/providers/together.py +57 -0
  96. msgflux/models/providers/vllm.py +131 -0
  97. msgflux/models/registry.py +19 -0
  98. msgflux/models/response.py +26 -0
  99. msgflux/models/tool_call_agg.py +85 -0
  100. msgflux/models/types.py +196 -0
  101. msgflux/nn/__init__.py +57 -0
  102. msgflux/nn/functional.py +645 -0
  103. msgflux/nn/hooks/__init__.py +24 -0
  104. msgflux/nn/hooks/guard.py +109 -0
  105. msgflux/nn/hooks/hook.py +123 -0
  106. msgflux/nn/modules/__init__.py +68 -0
  107. msgflux/nn/modules/agent.py +1927 -0
  108. msgflux/nn/modules/container.py +672 -0
  109. msgflux/nn/modules/embedder.py +261 -0
  110. msgflux/nn/modules/generator.py +21 -0
  111. msgflux/nn/modules/mediamaker.py +285 -0
  112. msgflux/nn/modules/module.py +2106 -0
  113. msgflux/nn/modules/predictor.py +238 -0
  114. msgflux/nn/modules/searcher.py +383 -0
  115. msgflux/nn/modules/speaker.py +222 -0
  116. msgflux/nn/modules/tool.py +839 -0
  117. msgflux/nn/modules/transcriber.py +273 -0
  118. msgflux/nn/parameter.py +77 -0
  119. msgflux/protocols/__init__.py +1 -0
  120. msgflux/protocols/mcp/__init__.py +61 -0
  121. msgflux/protocols/mcp/auth/__init__.py +17 -0
  122. msgflux/protocols/mcp/auth/apikey.py +70 -0
  123. msgflux/protocols/mcp/auth/base.py +83 -0
  124. msgflux/protocols/mcp/auth/basic.py +57 -0
  125. msgflux/protocols/mcp/auth/bearer.py +97 -0
  126. msgflux/protocols/mcp/auth/custom.py +76 -0
  127. msgflux/protocols/mcp/auth/oauth2.py +133 -0
  128. msgflux/protocols/mcp/client.py +454 -0
  129. msgflux/protocols/mcp/exceptions.py +25 -0
  130. msgflux/protocols/mcp/integration.py +95 -0
  131. msgflux/protocols/mcp/loglevels.py +16 -0
  132. msgflux/protocols/mcp/transports.py +435 -0
  133. msgflux/protocols/mcp/types.py +51 -0
  134. msgflux/py.typed +0 -0
  135. msgflux/telemetry/__init__.py +22 -0
  136. msgflux/telemetry/config.py +104 -0
  137. msgflux/telemetry/span.py +245 -0
  138. msgflux/tools/__init__.py +10 -0
  139. msgflux/tools/builtin/__init__.py +5 -0
  140. msgflux/tools/builtin/web_fetch.py +121 -0
  141. msgflux/tools/config.py +196 -0
  142. msgflux/tools/definitions.py +11 -0
  143. msgflux/utils/__init__.py +0 -0
  144. msgflux/utils/chat.py +682 -0
  145. msgflux/utils/common.py +36 -0
  146. msgflux/utils/console.py +55 -0
  147. msgflux/utils/convert.py +20 -0
  148. msgflux/utils/encode.py +192 -0
  149. msgflux/utils/html.py +171 -0
  150. msgflux/utils/imports.py +115 -0
  151. msgflux/utils/inspect.py +81 -0
  152. msgflux/utils/logging.py +15 -0
  153. msgflux/utils/mermaid.py +20 -0
  154. msgflux/utils/msgspec.py +1116 -0
  155. msgflux/utils/pooling.py +51 -0
  156. msgflux/utils/templates.py +51 -0
  157. msgflux/utils/tenacity.py +38 -0
  158. msgflux/utils/torch.py +7 -0
  159. msgflux/utils/validation.py +31 -0
  160. msgflux/utils/xml.py +7 -0
  161. msgflux/version.py +1 -0
  162. msgflux-0.1.0.dist-info/METADATA +170 -0
  163. msgflux-0.1.0.dist-info/RECORD +165 -0
  164. msgflux-0.1.0.dist-info/WHEEL +4 -0
  165. msgflux-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,186 @@
1
+ from typing import Dict, Union
2
+
3
+ try:
4
+ from pypdf import PdfReader
5
+ except ImportError:
6
+ PdfReader = None
7
+
8
+ from msgflux.data.parsers.base import BaseParser
9
+ from msgflux.data.parsers.registry import register_parser
10
+ from msgflux.data.parsers.response import ParserResponse
11
+ from msgflux.data.parsers.types import PdfParser
12
+ from msgflux.core.dotdict import dotdict
13
+
14
+
15
+ @register_parser
16
+ class PyPDFPdfParser(BaseParser, PdfParser):
17
+ """PyPDF-based PDF Parser.
18
+
19
+ Converts PDF documents to Markdown format and extracts embedded images.
20
+ Uses pypdf library for PDF processing.
21
+
22
+ Features:
23
+ - Text extraction with layout preservation
24
+ - Image extraction from PDF pages
25
+ - Page-level organization
26
+ - Markdown output format
27
+
28
+ Example:
29
+ >>> parser = Parser.pdf("pypdf")
30
+ >>> response = parser("document.pdf")
31
+ >>> print(response.data["text"])
32
+ >>> print(response.data["images"])
33
+ """
34
+
35
+ provider = "pypdf"
36
+
37
+ def __init__(
38
+ self,
39
+ *,
40
+ extraction_mode: str = "layout",
41
+ encode_images_base64: bool = True,
42
+ ):
43
+ """Initialize PyPDF parser.
44
+
45
+ Args:
46
+ extraction_mode:
47
+ Text extraction mode. Options: "layout" or "plain".
48
+ "layout" preserves text positioning.
49
+ encode_images_base64:
50
+ If True, encode images as base64 strings.
51
+ If False, keep as raw bytes.
52
+ """
53
+ if PdfReader is None:
54
+ raise ImportError(
55
+ "`pypdf` is not available. Install with `pip install pypdf`"
56
+ )
57
+
58
+ self.extraction_mode = extraction_mode
59
+ self.encode_images_base64 = encode_images_base64
60
+ self._initialize()
61
+
62
+ def _initialize(self):
63
+ """Initialize parser state."""
64
+ pass
65
+
66
+ def __call__(self, data: Union[str, bytes], **kwargs) -> ParserResponse:
67
+ """Parse a PDF document.
68
+
69
+ Args:
70
+ data:
71
+ PDF file path, URL, or bytes.
72
+ **kwargs:
73
+ Additional parsing options (currently unused).
74
+
75
+ Returns:
76
+ ParserResponse containing:
77
+ - text: Markdown-formatted content
78
+ - images: Dictionary of extracted images
79
+ - metadata: Document metadata (num_pages, etc.)
80
+
81
+ Raises:
82
+ FileNotFoundError:
83
+ If file path doesn't exist.
84
+ ValueError:
85
+ If data type is not supported.
86
+ """
87
+ # Validate file type if it's a path
88
+ if isinstance(data, str) and not data.startswith(("http://", "https://")):
89
+ self._validate_file_type(data, ".pdf")
90
+
91
+ # Parse the document
92
+ result = self._parse(data)
93
+
94
+ # Create response
95
+ response = ParserResponse()
96
+ response.set_response_type("pdf_parse")
97
+ response.add(result)
98
+
99
+ return response
100
+
101
+ def _parse(self, data: Union[str, bytes]) -> Dict[str, any]:
102
+ """Parse PDF and extract content.
103
+
104
+ Args:
105
+ data:
106
+ PDF file path or bytes.
107
+
108
+ Returns:
109
+ Dictionary with:
110
+ - text: Markdown content
111
+ - images: Dictionary of images
112
+ - metadata: Document metadata
113
+ """
114
+ md_content = ""
115
+ images_dict = {}
116
+
117
+ # Load PDF
118
+ if isinstance(data, bytes):
119
+ from io import BytesIO
120
+
121
+ reader = PdfReader(BytesIO(data))
122
+ else:
123
+ reader = PdfReader(data)
124
+
125
+ num_pages = len(reader.pages)
126
+
127
+ # Extract content from each page
128
+ for idx in range(num_pages):
129
+ page = reader.pages[idx]
130
+
131
+ # Add page marker
132
+ md_content += f"\n\n<!-- Page number: {idx + 1} -->\n"
133
+
134
+ # Extract text (blank pages have no /Contents stream)
135
+ try:
136
+ text = page.extract_text(
137
+ extraction_mode=self.extraction_mode,
138
+ layout_mode_space_vertically=False,
139
+ )
140
+ except KeyError:
141
+ text = page.extract_text()
142
+ md_content += text.strip() + "\n"
143
+
144
+ # Extract images from page
145
+ for count, image_file_object in enumerate(page.images):
146
+ img_extension = image_file_object.name.split(".")[-1]
147
+ img_name = f"image_page{idx + 1}_{count}.{img_extension}"
148
+ img_data = image_file_object.data
149
+
150
+ images_dict[img_name] = img_data
151
+
152
+ # Add image reference in markdown
153
+ md_content += f"\n![{img_name}]({img_name})\n"
154
+
155
+ # Prepare images (optionally encode to base64)
156
+ images_dict = self._prepare_images_dict(
157
+ images_dict, encode_base64=self.encode_images_base64
158
+ )
159
+
160
+ # Prepare metadata
161
+ metadata = dotdict(
162
+ {
163
+ "num_pages": num_pages,
164
+ "extraction_mode": self.extraction_mode,
165
+ }
166
+ )
167
+
168
+ # Try to extract PDF metadata if available
169
+ if reader.metadata:
170
+ pdf_metadata = {}
171
+ if reader.metadata.title:
172
+ pdf_metadata["title"] = reader.metadata.title
173
+ if reader.metadata.author:
174
+ pdf_metadata["author"] = reader.metadata.author
175
+ if reader.metadata.creator:
176
+ pdf_metadata["creator"] = reader.metadata.creator
177
+ if reader.metadata.subject:
178
+ pdf_metadata["subject"] = reader.metadata.subject
179
+ if pdf_metadata:
180
+ metadata.pdf_info = pdf_metadata
181
+
182
+ return {
183
+ "text": md_content.strip(),
184
+ "images": images_dict,
185
+ "metadata": metadata,
186
+ }
@@ -0,0 +1,334 @@
1
+ from typing import Dict, List, Union
2
+
3
+ try:
4
+ from docx import Document
5
+ from docx.table import Table
6
+ from docx.text.paragraph import Paragraph
7
+ except ImportError:
8
+ Document = None
9
+ Paragraph = None
10
+ Table = None
11
+
12
+ from msgflux.data.parsers.base import BaseParser
13
+ from msgflux.data.parsers.registry import register_parser
14
+ from msgflux.data.parsers.response import ParserResponse
15
+ from msgflux.data.parsers.types import DocxParser
16
+ from msgflux.core.dotdict import dotdict
17
+
18
+
19
+ @register_parser
20
+ class PythonDocxDocxParser(BaseParser, DocxParser):
21
+ """Python-DOCX-based DOCX Parser.
22
+
23
+ Converts Word documents to Markdown format and extracts embedded images.
24
+ Uses python-docx library for DOCX processing.
25
+
26
+ Features:
27
+ - Paragraph and heading extraction
28
+ - Text formatting preservation
29
+ - Table extraction (converted to Markdown)
30
+ - Image extraction from document
31
+ - Markdown output format
32
+
33
+ Example:
34
+ >>> parser = Parser.docx("python_docx")
35
+ >>> response = parser("document.docx")
36
+ >>> print(response.data["text"])
37
+ >>> print(response.data["images"])
38
+ """
39
+
40
+ provider = "python_docx"
41
+
42
+ def __init__(
43
+ self,
44
+ *,
45
+ table_format: str = "markdown",
46
+ encode_images_base64: bool = True,
47
+ ):
48
+ """Initialize Python-DOCX parser.
49
+
50
+ Args:
51
+ table_format:
52
+ Format for table output. Options: "markdown" or "html".
53
+ encode_images_base64:
54
+ If True, encode images as base64 strings.
55
+ If False, keep as raw bytes.
56
+ """
57
+ if Document is None:
58
+ raise ImportError(
59
+ "`python-docx` is not available. Install with `pip install python-docx`"
60
+ )
61
+
62
+ self.table_format = table_format
63
+ self.encode_images_base64 = encode_images_base64
64
+ self._initialize()
65
+
66
+ def _initialize(self):
67
+ """Initialize parser state."""
68
+ pass
69
+
70
+ def __call__(self, data: Union[str, bytes], **kwargs) -> ParserResponse:
71
+ """Parse a DOCX document.
72
+
73
+ Args:
74
+ data:
75
+ DOCX file path, URL, or bytes.
76
+ **kwargs:
77
+ Additional parsing options (currently unused).
78
+
79
+ Returns:
80
+ ParserResponse containing:
81
+ - text: Markdown-formatted content
82
+ - images: Dictionary of extracted images
83
+ - metadata: Document metadata (num_paragraphs, num_tables, etc.)
84
+
85
+ Raises:
86
+ FileNotFoundError:
87
+ If file path doesn't exist.
88
+ ValueError:
89
+ If data type is not supported.
90
+ """
91
+ # Validate file type if it's a path
92
+ if isinstance(data, str) and not data.startswith(("http://", "https://")):
93
+ self._validate_file_type(data, ".docx")
94
+
95
+ # Parse the document
96
+ result = self._parse(data)
97
+
98
+ # Create response
99
+ response = ParserResponse()
100
+ response.set_response_type("docx_parse")
101
+ response.add(result)
102
+
103
+ return response
104
+
105
+ def _parse(self, data: Union[str, bytes]) -> Dict[str, any]:
106
+ """Parse DOCX and extract content.
107
+
108
+ Args:
109
+ data:
110
+ DOCX file path or bytes.
111
+
112
+ Returns:
113
+ Dictionary with:
114
+ - text: Markdown content
115
+ - images: Dictionary of images
116
+ - metadata: Document metadata
117
+ """
118
+ md_content = ""
119
+ images_dict = {}
120
+
121
+ # Load document
122
+ if isinstance(data, bytes):
123
+ from io import BytesIO
124
+
125
+ document = Document(BytesIO(data))
126
+ else:
127
+ document = Document(data)
128
+
129
+ # Extract images from document
130
+ image_counter = 0
131
+ for rel in document.part.rels.values():
132
+ if "image" in rel.target_ref:
133
+ try:
134
+ img_data = rel.target_part.blob
135
+ # Get image extension from content type
136
+ content_type = rel.target_part.content_type
137
+ ext = content_type.split("/")[-1]
138
+ if ext == "jpeg":
139
+ ext = "jpg"
140
+
141
+ img_name = f"image_{image_counter}.{ext}"
142
+ images_dict[img_name] = img_data
143
+ image_counter += 1
144
+ except Exception:
145
+ # Skip images that can't be extracted
146
+ pass
147
+
148
+ # Process document elements (paragraphs and tables)
149
+ for element in document.element.body:
150
+ # Check if it's a paragraph
151
+ if element.tag.endswith("p"):
152
+ # Find the paragraph object
153
+ for para in document.paragraphs:
154
+ if para._element == element:
155
+ para_text = self._process_paragraph(para)
156
+ if para_text:
157
+ md_content += para_text + "\n\n"
158
+ break
159
+
160
+ # Check if it's a table
161
+ elif element.tag.endswith("tbl"):
162
+ # Find the table object
163
+ for table in document.tables:
164
+ if table._element == element:
165
+ table_md = self._process_table(table)
166
+ if table_md:
167
+ md_content += table_md + "\n\n"
168
+ break
169
+
170
+ # Prepare images (optionally encode to base64)
171
+ images_dict = self._prepare_images_dict(
172
+ images_dict, encode_base64=self.encode_images_base64
173
+ )
174
+
175
+ # Prepare metadata
176
+ metadata = dotdict(
177
+ {
178
+ "num_paragraphs": len(document.paragraphs),
179
+ "num_tables": len(document.tables),
180
+ "num_images": len(images_dict),
181
+ "table_format": self.table_format,
182
+ }
183
+ )
184
+
185
+ # Try to extract document metadata if available
186
+ if hasattr(document, "core_properties"):
187
+ props = document.core_properties
188
+ docx_metadata = {}
189
+ if props.title:
190
+ docx_metadata["title"] = props.title
191
+ if props.author:
192
+ docx_metadata["author"] = props.author
193
+ if props.subject:
194
+ docx_metadata["subject"] = props.subject
195
+ if props.created:
196
+ docx_metadata["created"] = str(props.created)
197
+ if props.modified:
198
+ docx_metadata["modified"] = str(props.modified)
199
+ if docx_metadata:
200
+ metadata.docx_info = docx_metadata
201
+
202
+ return {
203
+ "text": md_content.strip(),
204
+ "images": images_dict,
205
+ "metadata": metadata,
206
+ }
207
+
208
+ def _process_paragraph(self, paragraph) -> str:
209
+ """Process a paragraph and convert to Markdown.
210
+
211
+ Args:
212
+ paragraph:
213
+ Python-docx paragraph object.
214
+
215
+ Returns:
216
+ Markdown-formatted paragraph text.
217
+ """
218
+ text = paragraph.text.strip()
219
+ if not text:
220
+ return ""
221
+
222
+ # Check paragraph style for headings
223
+ style_name = paragraph.style.name.lower()
224
+
225
+ if "heading 1" in style_name or style_name == "title":
226
+ return f"# {text}"
227
+ elif "heading 2" in style_name:
228
+ return f"## {text}"
229
+ elif "heading 3" in style_name:
230
+ return f"### {text}"
231
+ elif "heading 4" in style_name:
232
+ return f"#### {text}"
233
+ elif "heading 5" in style_name:
234
+ return f"##### {text}"
235
+ elif "heading 6" in style_name:
236
+ return f"###### {text}"
237
+ else:
238
+ return text
239
+
240
+ def _process_table(self, table) -> str:
241
+ """Process a table and convert to Markdown or HTML.
242
+
243
+ Args:
244
+ table:
245
+ Python-docx table object.
246
+
247
+ Returns:
248
+ Markdown or HTML formatted table.
249
+ """
250
+ # Extract table data
251
+ table_data = []
252
+ for row in table.rows:
253
+ row_data = [cell.text.strip() for cell in row.cells]
254
+ table_data.append(row_data)
255
+
256
+ if not table_data:
257
+ return ""
258
+
259
+ # Convert to appropriate format
260
+ if self.table_format == "html":
261
+ return self._table_to_html(table_data)
262
+ else: # markdown
263
+ return self._table_to_markdown(table_data)
264
+
265
+ def _table_to_markdown(self, table_data: List[List[str]]) -> str:
266
+ """Convert table data to Markdown format.
267
+
268
+ Args:
269
+ table_data:
270
+ 2D list of table data.
271
+
272
+ Returns:
273
+ Markdown table string.
274
+ """
275
+ if not table_data:
276
+ return ""
277
+
278
+ # First row is header
279
+ header = table_data[0]
280
+ rows = table_data[1:]
281
+
282
+ # Header line
283
+ header_line = (
284
+ "| " + " | ".join(str(cell) if cell else "" for cell in header) + " |"
285
+ )
286
+
287
+ # Separator line
288
+ separator_line = "| " + " | ".join(["---"] * len(header)) + " |"
289
+
290
+ # Data lines
291
+ data_lines = [
292
+ "| " + " | ".join(str(cell) if cell else "" for cell in row) + " |"
293
+ for row in rows
294
+ ]
295
+
296
+ return "\n".join([header_line, separator_line, *data_lines])
297
+
298
+ def _table_to_html(self, table_data: List[List[str]]) -> str:
299
+ """Convert table data to HTML format.
300
+
301
+ Args:
302
+ table_data:
303
+ 2D list of table data.
304
+
305
+ Returns:
306
+ HTML table string.
307
+ """
308
+ if not table_data:
309
+ return ""
310
+
311
+ # First row is header
312
+ header = table_data[0]
313
+ rows = table_data[1:]
314
+
315
+ # Start table
316
+ html = "<table>\n"
317
+
318
+ # Add header row
319
+ html += " <tr>\n"
320
+ for cell in header:
321
+ html += f" <th>{str(cell) if cell else ''}</th>\n"
322
+ html += " </tr>\n"
323
+
324
+ # Add data rows
325
+ for row in rows:
326
+ html += " <tr>\n"
327
+ for cell in row:
328
+ html += f" <td>{str(cell) if cell else ''}</td>\n"
329
+ html += " </tr>\n"
330
+
331
+ # Close table
332
+ html += "</table>"
333
+
334
+ return html