tactus 0.31.2__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 (160) hide show
  1. tactus/__init__.py +49 -0
  2. tactus/adapters/__init__.py +9 -0
  3. tactus/adapters/broker_log.py +76 -0
  4. tactus/adapters/cli_hitl.py +189 -0
  5. tactus/adapters/cli_log.py +223 -0
  6. tactus/adapters/cost_collector_log.py +56 -0
  7. tactus/adapters/file_storage.py +367 -0
  8. tactus/adapters/http_callback_log.py +109 -0
  9. tactus/adapters/ide_log.py +71 -0
  10. tactus/adapters/lua_tools.py +336 -0
  11. tactus/adapters/mcp.py +289 -0
  12. tactus/adapters/mcp_manager.py +196 -0
  13. tactus/adapters/memory.py +53 -0
  14. tactus/adapters/plugins.py +419 -0
  15. tactus/backends/http_backend.py +58 -0
  16. tactus/backends/model_backend.py +35 -0
  17. tactus/backends/pytorch_backend.py +110 -0
  18. tactus/broker/__init__.py +12 -0
  19. tactus/broker/client.py +247 -0
  20. tactus/broker/protocol.py +183 -0
  21. tactus/broker/server.py +1123 -0
  22. tactus/broker/stdio.py +12 -0
  23. tactus/cli/__init__.py +7 -0
  24. tactus/cli/app.py +2245 -0
  25. tactus/cli/commands/__init__.py +0 -0
  26. tactus/core/__init__.py +32 -0
  27. tactus/core/config_manager.py +790 -0
  28. tactus/core/dependencies/__init__.py +14 -0
  29. tactus/core/dependencies/registry.py +180 -0
  30. tactus/core/dsl_stubs.py +2117 -0
  31. tactus/core/exceptions.py +66 -0
  32. tactus/core/execution_context.py +480 -0
  33. tactus/core/lua_sandbox.py +508 -0
  34. tactus/core/message_history_manager.py +236 -0
  35. tactus/core/mocking.py +286 -0
  36. tactus/core/output_validator.py +291 -0
  37. tactus/core/registry.py +499 -0
  38. tactus/core/runtime.py +2907 -0
  39. tactus/core/template_resolver.py +142 -0
  40. tactus/core/yaml_parser.py +301 -0
  41. tactus/docker/Dockerfile +61 -0
  42. tactus/docker/entrypoint.sh +69 -0
  43. tactus/dspy/__init__.py +39 -0
  44. tactus/dspy/agent.py +1144 -0
  45. tactus/dspy/broker_lm.py +181 -0
  46. tactus/dspy/config.py +212 -0
  47. tactus/dspy/history.py +196 -0
  48. tactus/dspy/module.py +405 -0
  49. tactus/dspy/prediction.py +318 -0
  50. tactus/dspy/signature.py +185 -0
  51. tactus/formatting/__init__.py +7 -0
  52. tactus/formatting/formatter.py +437 -0
  53. tactus/ide/__init__.py +9 -0
  54. tactus/ide/coding_assistant.py +343 -0
  55. tactus/ide/server.py +2223 -0
  56. tactus/primitives/__init__.py +49 -0
  57. tactus/primitives/control.py +168 -0
  58. tactus/primitives/file.py +229 -0
  59. tactus/primitives/handles.py +378 -0
  60. tactus/primitives/host.py +94 -0
  61. tactus/primitives/human.py +342 -0
  62. tactus/primitives/json.py +189 -0
  63. tactus/primitives/log.py +187 -0
  64. tactus/primitives/message_history.py +157 -0
  65. tactus/primitives/model.py +163 -0
  66. tactus/primitives/procedure.py +564 -0
  67. tactus/primitives/procedure_callable.py +318 -0
  68. tactus/primitives/retry.py +155 -0
  69. tactus/primitives/session.py +152 -0
  70. tactus/primitives/state.py +182 -0
  71. tactus/primitives/step.py +209 -0
  72. tactus/primitives/system.py +93 -0
  73. tactus/primitives/tool.py +375 -0
  74. tactus/primitives/tool_handle.py +279 -0
  75. tactus/primitives/toolset.py +229 -0
  76. tactus/protocols/__init__.py +38 -0
  77. tactus/protocols/chat_recorder.py +81 -0
  78. tactus/protocols/config.py +97 -0
  79. tactus/protocols/cost.py +31 -0
  80. tactus/protocols/hitl.py +71 -0
  81. tactus/protocols/log_handler.py +27 -0
  82. tactus/protocols/models.py +355 -0
  83. tactus/protocols/result.py +33 -0
  84. tactus/protocols/storage.py +90 -0
  85. tactus/providers/__init__.py +13 -0
  86. tactus/providers/base.py +92 -0
  87. tactus/providers/bedrock.py +117 -0
  88. tactus/providers/google.py +105 -0
  89. tactus/providers/openai.py +98 -0
  90. tactus/sandbox/__init__.py +63 -0
  91. tactus/sandbox/config.py +171 -0
  92. tactus/sandbox/container_runner.py +1099 -0
  93. tactus/sandbox/docker_manager.py +433 -0
  94. tactus/sandbox/entrypoint.py +227 -0
  95. tactus/sandbox/protocol.py +213 -0
  96. tactus/stdlib/__init__.py +10 -0
  97. tactus/stdlib/io/__init__.py +13 -0
  98. tactus/stdlib/io/csv.py +88 -0
  99. tactus/stdlib/io/excel.py +136 -0
  100. tactus/stdlib/io/file.py +90 -0
  101. tactus/stdlib/io/fs.py +154 -0
  102. tactus/stdlib/io/hdf5.py +121 -0
  103. tactus/stdlib/io/json.py +109 -0
  104. tactus/stdlib/io/parquet.py +83 -0
  105. tactus/stdlib/io/tsv.py +88 -0
  106. tactus/stdlib/loader.py +274 -0
  107. tactus/stdlib/tac/tactus/tools/done.tac +33 -0
  108. tactus/stdlib/tac/tactus/tools/log.tac +50 -0
  109. tactus/testing/README.md +273 -0
  110. tactus/testing/__init__.py +61 -0
  111. tactus/testing/behave_integration.py +380 -0
  112. tactus/testing/context.py +486 -0
  113. tactus/testing/eval_models.py +114 -0
  114. tactus/testing/evaluation_runner.py +222 -0
  115. tactus/testing/evaluators.py +634 -0
  116. tactus/testing/events.py +94 -0
  117. tactus/testing/gherkin_parser.py +134 -0
  118. tactus/testing/mock_agent.py +315 -0
  119. tactus/testing/mock_dependencies.py +234 -0
  120. tactus/testing/mock_hitl.py +171 -0
  121. tactus/testing/mock_registry.py +168 -0
  122. tactus/testing/mock_tools.py +133 -0
  123. tactus/testing/models.py +115 -0
  124. tactus/testing/pydantic_eval_runner.py +508 -0
  125. tactus/testing/steps/__init__.py +13 -0
  126. tactus/testing/steps/builtin.py +902 -0
  127. tactus/testing/steps/custom.py +69 -0
  128. tactus/testing/steps/registry.py +68 -0
  129. tactus/testing/test_runner.py +489 -0
  130. tactus/tracing/__init__.py +5 -0
  131. tactus/tracing/trace_manager.py +417 -0
  132. tactus/utils/__init__.py +1 -0
  133. tactus/utils/cost_calculator.py +72 -0
  134. tactus/utils/model_pricing.py +132 -0
  135. tactus/utils/safe_file_library.py +502 -0
  136. tactus/utils/safe_libraries.py +234 -0
  137. tactus/validation/LuaLexerBase.py +66 -0
  138. tactus/validation/LuaParserBase.py +23 -0
  139. tactus/validation/README.md +224 -0
  140. tactus/validation/__init__.py +7 -0
  141. tactus/validation/error_listener.py +21 -0
  142. tactus/validation/generated/LuaLexer.interp +231 -0
  143. tactus/validation/generated/LuaLexer.py +5548 -0
  144. tactus/validation/generated/LuaLexer.tokens +124 -0
  145. tactus/validation/generated/LuaLexerBase.py +66 -0
  146. tactus/validation/generated/LuaParser.interp +173 -0
  147. tactus/validation/generated/LuaParser.py +6439 -0
  148. tactus/validation/generated/LuaParser.tokens +124 -0
  149. tactus/validation/generated/LuaParserBase.py +23 -0
  150. tactus/validation/generated/LuaParserVisitor.py +118 -0
  151. tactus/validation/generated/__init__.py +7 -0
  152. tactus/validation/grammar/LuaLexer.g4 +123 -0
  153. tactus/validation/grammar/LuaParser.g4 +178 -0
  154. tactus/validation/semantic_visitor.py +817 -0
  155. tactus/validation/validator.py +157 -0
  156. tactus-0.31.2.dist-info/METADATA +1809 -0
  157. tactus-0.31.2.dist-info/RECORD +160 -0
  158. tactus-0.31.2.dist-info/WHEEL +4 -0
  159. tactus-0.31.2.dist-info/entry_points.txt +2 -0
  160. tactus-0.31.2.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,502 @@
1
+ """Safe file I/O libraries for Lua sandbox.
2
+
3
+ Provides sandboxed file operations restricted to the working directory.
4
+ All paths are validated to prevent directory traversal attacks.
5
+
6
+ Supported formats:
7
+ - File: Raw text read/write
8
+ - Csv: CSV with automatic header handling
9
+ - Tsv: Tab-separated values
10
+ - Json: JSON read/write
11
+ - Parquet: Apache Parquet (via pyarrow)
12
+ - Hdf5: HDF5 datasets (via h5py)
13
+ - Excel: Excel spreadsheets (via openpyxl)
14
+ """
15
+
16
+ import csv
17
+ import json
18
+ import logging
19
+ import os
20
+ from typing import Any, Dict, List, Optional
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ class PathValidator:
26
+ """Validates file paths are within allowed base directory."""
27
+
28
+ def __init__(self, base_path: str):
29
+ """
30
+ Initialize path validator.
31
+
32
+ Args:
33
+ base_path: The base directory that all file operations are restricted to.
34
+ """
35
+ self.base_path = os.path.realpath(base_path)
36
+
37
+ def validate(self, filepath: str) -> str:
38
+ """
39
+ Validate and resolve a file path.
40
+
41
+ Args:
42
+ filepath: Relative or absolute file path to validate.
43
+
44
+ Returns:
45
+ Resolved absolute path if valid.
46
+
47
+ Raises:
48
+ PermissionError: If path is outside the base directory.
49
+ """
50
+ # Join with base path and resolve to absolute
51
+ resolved = os.path.realpath(os.path.join(self.base_path, filepath))
52
+
53
+ # Check if resolved path is within base directory
54
+ # Allow exact match (base_path itself) or paths that start with base_path + separator
55
+ if resolved != self.base_path and not resolved.startswith(self.base_path + os.sep):
56
+ raise PermissionError(f"Access denied: path outside working directory: {filepath}")
57
+
58
+ return resolved
59
+
60
+
61
+ def create_safe_file_library(base_path: str) -> Dict[str, Any]:
62
+ """
63
+ Create raw text file operations library.
64
+
65
+ Args:
66
+ base_path: Base directory for file operations.
67
+
68
+ Returns:
69
+ Dictionary of file operation functions.
70
+ """
71
+ validator = PathValidator(base_path)
72
+
73
+ def read(filepath: str) -> str:
74
+ """Read entire file as text."""
75
+ path = validator.validate(filepath)
76
+ with open(path, "r", encoding="utf-8") as f:
77
+ return f.read()
78
+
79
+ def write(filepath: str, content: str) -> None:
80
+ """Write text to file."""
81
+ path = validator.validate(filepath)
82
+ # Ensure parent directory exists
83
+ os.makedirs(os.path.dirname(path), exist_ok=True) if os.path.dirname(path) else None
84
+ with open(path, "w", encoding="utf-8") as f:
85
+ f.write(content)
86
+
87
+ def exists(filepath: str) -> bool:
88
+ """Check if file exists."""
89
+ try:
90
+ path = validator.validate(filepath)
91
+ return os.path.exists(path)
92
+ except PermissionError:
93
+ return False
94
+
95
+ return {"read": read, "write": write, "exists": exists}
96
+
97
+
98
+ class LuaList:
99
+ """
100
+ Wrapper for Python lists that works better with Lua via lupa.
101
+
102
+ Provides both 0-indexed access (Python style) and a len() method
103
+ that can be called from Lua.
104
+ """
105
+
106
+ def __init__(self, data: List):
107
+ self._data = data
108
+
109
+ def __getitem__(self, index):
110
+ # Lua method access comes through __getitem__ with string keys
111
+ if isinstance(index, str):
112
+ # Handle method access
113
+ if index == "len":
114
+ return self.len
115
+ elif index == "get":
116
+ return self.get
117
+ else:
118
+ raise KeyError(f"Unknown method: {index}")
119
+ # Lua numbers are floats, convert to int for indexing
120
+ if isinstance(index, float):
121
+ index = int(index)
122
+ return self._data[index]
123
+
124
+ def __len__(self):
125
+ return len(self._data)
126
+
127
+ def __iter__(self):
128
+ return iter(self._data)
129
+
130
+ def len(self):
131
+ """Return length - callable from Lua as data:len()."""
132
+ return len(self._data)
133
+
134
+ def get(self, index):
135
+ """Alternative access method - data:get(0) instead of data[0]."""
136
+ if isinstance(index, float):
137
+ index = int(index)
138
+ return self._data[index]
139
+
140
+
141
+ def create_safe_csv_library(base_path: str) -> Dict[str, Any]:
142
+ """
143
+ Create CSV file operations library.
144
+
145
+ Args:
146
+ base_path: Base directory for file operations.
147
+
148
+ Returns:
149
+ Dictionary of CSV operation functions.
150
+ """
151
+ validator = PathValidator(base_path)
152
+
153
+ def read(filepath: str) -> LuaList:
154
+ """Read CSV file, returning list of dictionaries with headers as keys."""
155
+ path = validator.validate(filepath)
156
+ with open(path, "r", encoding="utf-8", newline="") as f:
157
+ reader = csv.DictReader(f)
158
+ return LuaList(list(reader))
159
+
160
+ def write(filepath: str, data: List[Dict], options: Optional[Dict] = None) -> None:
161
+ """Write list of dictionaries to CSV file."""
162
+ path = validator.validate(filepath)
163
+
164
+ # Convert Lua table options to Python dict if needed
165
+ if options and hasattr(options, "items"):
166
+ options = dict(options.items())
167
+ options = options or {}
168
+ headers = options.get("headers")
169
+
170
+ # Convert headers from Lua table to Python list if needed
171
+ if headers and hasattr(headers, "values"):
172
+ headers = list(headers.values())
173
+
174
+ # Convert Lua table to Python list if needed
175
+ if hasattr(data, "values"):
176
+ data = list(data.values())
177
+
178
+ if not headers and data:
179
+ # Get headers from first row
180
+ first_row = data[0]
181
+ if hasattr(first_row, "keys"):
182
+ headers = list(first_row.keys())
183
+ elif isinstance(first_row, dict):
184
+ headers = list(first_row.keys())
185
+ else:
186
+ raise ValueError("Cannot determine headers from data")
187
+
188
+ # Ensure parent directory exists
189
+ os.makedirs(os.path.dirname(path), exist_ok=True) if os.path.dirname(path) else None
190
+
191
+ with open(path, "w", encoding="utf-8", newline="") as f:
192
+ writer = csv.DictWriter(f, fieldnames=headers)
193
+ writer.writeheader()
194
+ for row in data:
195
+ # Convert Lua table to dict if needed
196
+ if hasattr(row, "items"):
197
+ row = dict(row.items())
198
+ writer.writerow(row)
199
+
200
+ return {"read": read, "write": write}
201
+
202
+
203
+ def create_safe_tsv_library(base_path: str) -> Dict[str, Any]:
204
+ """
205
+ Create TSV (tab-separated values) file operations library.
206
+
207
+ Args:
208
+ base_path: Base directory for file operations.
209
+
210
+ Returns:
211
+ Dictionary of TSV operation functions.
212
+ """
213
+ validator = PathValidator(base_path)
214
+
215
+ def read(filepath: str) -> LuaList:
216
+ """Read TSV file, returning list of dictionaries with headers as keys."""
217
+ path = validator.validate(filepath)
218
+ with open(path, "r", encoding="utf-8", newline="") as f:
219
+ reader = csv.DictReader(f, delimiter="\t")
220
+ return LuaList(list(reader))
221
+
222
+ def write(filepath: str, data: List[Dict], options: Optional[Dict] = None) -> None:
223
+ """Write list of dictionaries to TSV file."""
224
+ path = validator.validate(filepath)
225
+
226
+ # Convert Lua table options to Python dict if needed
227
+ if options and hasattr(options, "items"):
228
+ options = dict(options.items())
229
+ options = options or {}
230
+ headers = options.get("headers")
231
+
232
+ # Convert headers from Lua table to Python list if needed
233
+ if headers and hasattr(headers, "values"):
234
+ headers = list(headers.values())
235
+
236
+ # Convert Lua table to Python list if needed
237
+ if hasattr(data, "values"):
238
+ data = list(data.values())
239
+
240
+ if not headers and data:
241
+ first_row = data[0]
242
+ if hasattr(first_row, "keys"):
243
+ headers = list(first_row.keys())
244
+ elif isinstance(first_row, dict):
245
+ headers = list(first_row.keys())
246
+ else:
247
+ raise ValueError("Cannot determine headers from data")
248
+
249
+ # Ensure parent directory exists
250
+ os.makedirs(os.path.dirname(path), exist_ok=True) if os.path.dirname(path) else None
251
+
252
+ with open(path, "w", encoding="utf-8", newline="") as f:
253
+ writer = csv.DictWriter(f, fieldnames=headers, delimiter="\t")
254
+ writer.writeheader()
255
+ for row in data:
256
+ if hasattr(row, "items"):
257
+ row = dict(row.items())
258
+ writer.writerow(row)
259
+
260
+ return {"read": read, "write": write}
261
+
262
+
263
+ def create_safe_json_library(base_path: str) -> Dict[str, Any]:
264
+ """
265
+ Create JSON file operations library.
266
+
267
+ Args:
268
+ base_path: Base directory for file operations.
269
+
270
+ Returns:
271
+ Dictionary of JSON operation functions.
272
+ """
273
+ validator = PathValidator(base_path)
274
+
275
+ def read(filepath: str) -> Any:
276
+ """Read JSON file and return parsed data."""
277
+ path = validator.validate(filepath)
278
+ with open(path, "r", encoding="utf-8") as f:
279
+ return json.load(f)
280
+
281
+ def write(filepath: str, data: Any, options: Optional[Dict] = None) -> None:
282
+ """Write data to JSON file."""
283
+ path = validator.validate(filepath)
284
+
285
+ # Convert Lua table options to Python dict if needed
286
+ if options and hasattr(options, "items"):
287
+ options = dict(options.items())
288
+ options = options or {}
289
+ indent = options.get("indent", 2)
290
+
291
+ # Convert Lua tables to Python dicts/lists
292
+ data = _lua_to_python(data)
293
+
294
+ # Ensure parent directory exists
295
+ os.makedirs(os.path.dirname(path), exist_ok=True) if os.path.dirname(path) else None
296
+
297
+ with open(path, "w", encoding="utf-8") as f:
298
+ json.dump(data, f, indent=indent, default=str)
299
+
300
+ return {"read": read, "write": write}
301
+
302
+
303
+ def create_safe_parquet_library(base_path: str) -> Dict[str, Any]:
304
+ """
305
+ Create Parquet file operations library.
306
+
307
+ Args:
308
+ base_path: Base directory for file operations.
309
+
310
+ Returns:
311
+ Dictionary of Parquet operation functions.
312
+ """
313
+ import pyarrow as pa
314
+ import pyarrow.parquet as pq
315
+
316
+ validator = PathValidator(base_path)
317
+
318
+ def read(filepath: str) -> LuaList:
319
+ """Read Parquet file, returning list of dictionaries."""
320
+ path = validator.validate(filepath)
321
+ table = pq.read_table(path)
322
+ return LuaList(table.to_pylist())
323
+
324
+ def write(filepath: str, data: List[Dict]) -> None:
325
+ """Write list of dictionaries to Parquet file."""
326
+ path = validator.validate(filepath)
327
+
328
+ # Convert Lua tables to Python
329
+ data = _lua_to_python(data)
330
+
331
+ # Ensure parent directory exists
332
+ os.makedirs(os.path.dirname(path), exist_ok=True) if os.path.dirname(path) else None
333
+
334
+ table = pa.Table.from_pylist(data)
335
+ pq.write_table(table, path)
336
+
337
+ return {"read": read, "write": write}
338
+
339
+
340
+ def create_safe_hdf5_library(base_path: str) -> Dict[str, Any]:
341
+ """
342
+ Create HDF5 file operations library.
343
+
344
+ Args:
345
+ base_path: Base directory for file operations.
346
+
347
+ Returns:
348
+ Dictionary of HDF5 operation functions.
349
+ """
350
+ import h5py
351
+ import numpy as np
352
+
353
+ validator = PathValidator(base_path)
354
+
355
+ def read(filepath: str, dataset: str) -> List[Any]:
356
+ """Read dataset from HDF5 file."""
357
+ path = validator.validate(filepath)
358
+ with h5py.File(path, "r") as f:
359
+ return f[dataset][:].tolist()
360
+
361
+ def write(filepath: str, dataset: str, data: List) -> None:
362
+ """Write data to HDF5 dataset."""
363
+ path = validator.validate(filepath)
364
+
365
+ # Convert Lua tables to Python
366
+ data = _lua_to_python(data)
367
+
368
+ # Ensure parent directory exists
369
+ os.makedirs(os.path.dirname(path), exist_ok=True) if os.path.dirname(path) else None
370
+
371
+ with h5py.File(path, "a") as f:
372
+ if dataset in f:
373
+ del f[dataset]
374
+ f.create_dataset(dataset, data=np.array(data))
375
+
376
+ def list_datasets(filepath: str) -> List[str]:
377
+ """List all datasets in HDF5 file."""
378
+ path = validator.validate(filepath)
379
+ datasets = []
380
+ with h5py.File(path, "r") as f:
381
+
382
+ def visitor(name, obj):
383
+ if isinstance(obj, h5py.Dataset):
384
+ datasets.append(name)
385
+
386
+ f.visititems(visitor)
387
+ return datasets
388
+
389
+ return {"read": read, "write": write, "list": list_datasets}
390
+
391
+
392
+ def create_safe_excel_library(base_path: str) -> Dict[str, Any]:
393
+ """
394
+ Create Excel file operations library.
395
+
396
+ Args:
397
+ base_path: Base directory for file operations.
398
+
399
+ Returns:
400
+ Dictionary of Excel operation functions.
401
+ """
402
+ from openpyxl import Workbook, load_workbook
403
+
404
+ validator = PathValidator(base_path)
405
+
406
+ def read(filepath: str, options: Optional[Dict] = None) -> LuaList:
407
+ """Read Excel file, returning list of dictionaries."""
408
+ path = validator.validate(filepath)
409
+
410
+ # Convert Lua table options to Python dict if needed
411
+ if options and hasattr(options, "items"):
412
+ options = dict(options.items())
413
+ options = options or {}
414
+ sheet_name = options.get("sheet")
415
+
416
+ wb = load_workbook(path, read_only=True, data_only=True)
417
+ ws = wb[sheet_name] if sheet_name else wb.active
418
+
419
+ rows = list(ws.iter_rows(values_only=True))
420
+ if not rows:
421
+ return LuaList([])
422
+
423
+ # First row is headers
424
+ headers = [str(h) if h is not None else f"col_{i}" for i, h in enumerate(rows[0])]
425
+ return LuaList([dict(zip(headers, row)) for row in rows[1:]])
426
+
427
+ def write(filepath: str, data: List[Dict], options: Optional[Dict] = None) -> None:
428
+ """Write list of dictionaries to Excel file."""
429
+ path = validator.validate(filepath)
430
+
431
+ # Convert Lua table options to Python dict if needed
432
+ if options and hasattr(options, "items"):
433
+ options = dict(options.items())
434
+ options = options or {}
435
+ sheet_name = options.get("sheet", "Sheet1")
436
+
437
+ # Convert Lua tables to Python
438
+ data = _lua_to_python(data)
439
+
440
+ # Ensure parent directory exists
441
+ os.makedirs(os.path.dirname(path), exist_ok=True) if os.path.dirname(path) else None
442
+
443
+ wb = Workbook()
444
+ ws = wb.active
445
+ ws.title = sheet_name
446
+
447
+ if data:
448
+ headers = list(data[0].keys())
449
+ ws.append(headers)
450
+ for row in data:
451
+ ws.append([row.get(h) for h in headers])
452
+
453
+ wb.save(path)
454
+
455
+ def sheets(filepath: str) -> List[str]:
456
+ """List sheet names in Excel file."""
457
+ path = validator.validate(filepath)
458
+ wb = load_workbook(path, read_only=True)
459
+ return wb.sheetnames
460
+
461
+ return {"read": read, "write": write, "sheets": sheets}
462
+
463
+
464
+ def _lua_to_python(obj: Any) -> Any:
465
+ """
466
+ Recursively convert Lua table-like objects to Python dicts/lists.
467
+
468
+ Args:
469
+ obj: Object to convert (may be Lua table or Python object).
470
+
471
+ Returns:
472
+ Python dict, list, or original value.
473
+ """
474
+ # Check if it's a Lua table (has values() or items() method)
475
+ if hasattr(obj, "items"):
476
+ # Could be a dict-like Lua table
477
+ try:
478
+ items = list(obj.items())
479
+ # Check if it's array-like (all integer keys starting from 1)
480
+ if items and all(isinstance(k, (int, float)) for k, v in items):
481
+ keys = [int(k) for k, v in items]
482
+ if keys == list(range(1, len(keys) + 1)):
483
+ # It's an array-like table, convert to list
484
+ return [_lua_to_python(obj[k]) for k in range(1, len(keys) + 1)]
485
+ # It's a dict-like table
486
+ return {k: _lua_to_python(v) for k, v in items}
487
+ except (TypeError, AttributeError):
488
+ pass
489
+
490
+ if hasattr(obj, "values") and not isinstance(obj, (dict, str)):
491
+ try:
492
+ return [_lua_to_python(v) for v in obj.values()]
493
+ except (TypeError, AttributeError):
494
+ pass
495
+
496
+ if isinstance(obj, dict):
497
+ return {k: _lua_to_python(v) for k, v in obj.items()}
498
+
499
+ if isinstance(obj, list):
500
+ return [_lua_to_python(item) for item in obj]
501
+
502
+ return obj