tactus 0.34.1__py3-none-any.whl → 0.35.1__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.
- tactus/__init__.py +1 -1
- tactus/adapters/broker_log.py +17 -14
- tactus/adapters/channels/__init__.py +17 -15
- tactus/adapters/channels/base.py +16 -7
- tactus/adapters/channels/broker.py +43 -13
- tactus/adapters/channels/cli.py +19 -15
- tactus/adapters/channels/host.py +40 -25
- tactus/adapters/channels/ipc.py +82 -31
- tactus/adapters/channels/sse.py +41 -23
- tactus/adapters/cli_hitl.py +19 -19
- tactus/adapters/cli_log.py +4 -4
- tactus/adapters/control_loop.py +138 -99
- tactus/adapters/cost_collector_log.py +9 -9
- tactus/adapters/file_storage.py +56 -52
- tactus/adapters/http_callback_log.py +23 -13
- tactus/adapters/ide_log.py +17 -9
- tactus/adapters/lua_tools.py +4 -5
- tactus/adapters/mcp.py +16 -19
- tactus/adapters/mcp_manager.py +46 -30
- tactus/adapters/memory.py +9 -9
- tactus/adapters/plugins.py +42 -42
- tactus/broker/client.py +75 -78
- tactus/broker/protocol.py +57 -57
- tactus/broker/server.py +252 -197
- tactus/cli/app.py +3 -1
- tactus/cli/control.py +2 -2
- tactus/core/config_manager.py +181 -135
- tactus/core/dependencies/registry.py +66 -48
- tactus/core/dsl_stubs.py +222 -163
- tactus/core/exceptions.py +10 -1
- tactus/core/execution_context.py +152 -112
- tactus/core/lua_sandbox.py +72 -64
- tactus/core/message_history_manager.py +138 -43
- tactus/core/mocking.py +41 -27
- tactus/core/output_validator.py +49 -44
- tactus/core/registry.py +94 -80
- tactus/core/runtime.py +211 -176
- tactus/core/template_resolver.py +16 -16
- tactus/core/yaml_parser.py +55 -45
- tactus/docs/extractor.py +7 -6
- tactus/ide/server.py +119 -78
- tactus/primitives/control.py +10 -6
- tactus/primitives/file.py +48 -46
- tactus/primitives/handles.py +47 -35
- tactus/primitives/host.py +29 -27
- tactus/primitives/human.py +154 -137
- tactus/primitives/json.py +22 -23
- tactus/primitives/log.py +26 -26
- tactus/primitives/message_history.py +285 -31
- tactus/primitives/model.py +15 -9
- tactus/primitives/procedure.py +86 -64
- tactus/primitives/procedure_callable.py +58 -51
- tactus/primitives/retry.py +31 -29
- tactus/primitives/session.py +42 -29
- tactus/primitives/state.py +54 -43
- tactus/primitives/step.py +9 -13
- tactus/primitives/system.py +34 -21
- tactus/primitives/tool.py +44 -31
- tactus/primitives/tool_handle.py +76 -54
- tactus/primitives/toolset.py +25 -22
- tactus/sandbox/config.py +4 -4
- tactus/sandbox/container_runner.py +161 -107
- tactus/sandbox/docker_manager.py +20 -20
- tactus/sandbox/entrypoint.py +16 -14
- tactus/sandbox/protocol.py +15 -15
- tactus/stdlib/classify/llm.py +1 -3
- tactus/stdlib/core/validation.py +0 -3
- tactus/testing/pydantic_eval_runner.py +1 -1
- tactus/utils/asyncio_helpers.py +27 -0
- tactus/utils/cost_calculator.py +7 -7
- tactus/utils/model_pricing.py +11 -12
- tactus/utils/safe_file_library.py +156 -132
- tactus/utils/safe_libraries.py +27 -27
- tactus/validation/error_listener.py +18 -5
- tactus/validation/semantic_visitor.py +392 -333
- tactus/validation/validator.py +89 -49
- {tactus-0.34.1.dist-info → tactus-0.35.1.dist-info}/METADATA +15 -3
- {tactus-0.34.1.dist-info → tactus-0.35.1.dist-info}/RECORD +81 -80
- {tactus-0.34.1.dist-info → tactus-0.35.1.dist-info}/WHEEL +0 -0
- {tactus-0.34.1.dist-info → tactus-0.35.1.dist-info}/entry_points.txt +0 -0
- {tactus-0.34.1.dist-info → tactus-0.35.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -34,12 +34,12 @@ class PathValidator:
|
|
|
34
34
|
"""
|
|
35
35
|
self.base_path = os.path.realpath(base_path)
|
|
36
36
|
|
|
37
|
-
def validate(self,
|
|
37
|
+
def validate(self, file_path: str) -> str:
|
|
38
38
|
"""
|
|
39
39
|
Validate and resolve a file path.
|
|
40
40
|
|
|
41
41
|
Args:
|
|
42
|
-
|
|
42
|
+
file_path: Relative or absolute file path to validate.
|
|
43
43
|
|
|
44
44
|
Returns:
|
|
45
45
|
Resolved absolute path if valid.
|
|
@@ -48,14 +48,16 @@ class PathValidator:
|
|
|
48
48
|
PermissionError: If path is outside the base directory.
|
|
49
49
|
"""
|
|
50
50
|
# Join with base path and resolve to absolute
|
|
51
|
-
|
|
51
|
+
resolved_path = os.path.realpath(os.path.join(self.base_path, file_path))
|
|
52
52
|
|
|
53
53
|
# Check if resolved path is within base directory
|
|
54
54
|
# Allow exact match (base_path itself) or paths that start with base_path + separator
|
|
55
|
-
if
|
|
56
|
-
|
|
55
|
+
if resolved_path != self.base_path and not resolved_path.startswith(
|
|
56
|
+
self.base_path + os.sep
|
|
57
|
+
):
|
|
58
|
+
raise PermissionError(f"Access denied: path outside working directory: {file_path}")
|
|
57
59
|
|
|
58
|
-
return
|
|
60
|
+
return resolved_path
|
|
59
61
|
|
|
60
62
|
|
|
61
63
|
def create_safe_file_library(base_path: str) -> Dict[str, Any]:
|
|
@@ -70,25 +72,27 @@ def create_safe_file_library(base_path: str) -> Dict[str, Any]:
|
|
|
70
72
|
"""
|
|
71
73
|
validator = PathValidator(base_path)
|
|
72
74
|
|
|
73
|
-
def read(
|
|
75
|
+
def read(file_path: str) -> str:
|
|
74
76
|
"""Read entire file as text."""
|
|
75
|
-
|
|
76
|
-
with open(
|
|
77
|
-
return
|
|
77
|
+
validated_path = validator.validate(file_path)
|
|
78
|
+
with open(validated_path, "r", encoding="utf-8") as file_handle:
|
|
79
|
+
return file_handle.read()
|
|
78
80
|
|
|
79
|
-
def write(
|
|
81
|
+
def write(file_path: str, content: str) -> None:
|
|
80
82
|
"""Write text to file."""
|
|
81
|
-
|
|
83
|
+
validated_path = validator.validate(file_path)
|
|
82
84
|
# Ensure parent directory exists
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
85
|
+
parent_directory = os.path.dirname(validated_path)
|
|
86
|
+
if parent_directory:
|
|
87
|
+
os.makedirs(parent_directory, exist_ok=True)
|
|
88
|
+
with open(validated_path, "w", encoding="utf-8") as file_handle:
|
|
89
|
+
file_handle.write(content)
|
|
86
90
|
|
|
87
|
-
def exists(
|
|
91
|
+
def exists(file_path: str) -> bool:
|
|
88
92
|
"""Check if file exists."""
|
|
89
93
|
try:
|
|
90
|
-
|
|
91
|
-
return os.path.exists(
|
|
94
|
+
validated_path = validator.validate(file_path)
|
|
95
|
+
return os.path.exists(validated_path)
|
|
92
96
|
except PermissionError:
|
|
93
97
|
return False
|
|
94
98
|
|
|
@@ -106,20 +110,20 @@ class LuaList:
|
|
|
106
110
|
def __init__(self, data: List):
|
|
107
111
|
self._data = data
|
|
108
112
|
|
|
109
|
-
def __getitem__(self,
|
|
113
|
+
def __getitem__(self, key_or_index):
|
|
110
114
|
# Lua method access comes through __getitem__ with string keys
|
|
111
|
-
if isinstance(
|
|
115
|
+
if isinstance(key_or_index, str):
|
|
112
116
|
# Handle method access
|
|
113
|
-
if
|
|
117
|
+
if key_or_index == "len":
|
|
114
118
|
return self.len
|
|
115
|
-
elif
|
|
119
|
+
elif key_or_index == "get":
|
|
116
120
|
return self.get
|
|
117
121
|
else:
|
|
118
|
-
raise KeyError(f"Unknown method: {
|
|
122
|
+
raise KeyError(f"Unknown method: {key_or_index}")
|
|
119
123
|
# Lua numbers are floats, convert to int for indexing
|
|
120
|
-
if isinstance(
|
|
121
|
-
|
|
122
|
-
return self._data[
|
|
124
|
+
if isinstance(key_or_index, float):
|
|
125
|
+
key_or_index = int(key_or_index)
|
|
126
|
+
return self._data[key_or_index]
|
|
123
127
|
|
|
124
128
|
def __len__(self):
|
|
125
129
|
return len(self._data)
|
|
@@ -131,11 +135,11 @@ class LuaList:
|
|
|
131
135
|
"""Return length - callable from Lua as data:len()."""
|
|
132
136
|
return len(self._data)
|
|
133
137
|
|
|
134
|
-
def get(self,
|
|
138
|
+
def get(self, requested_index):
|
|
135
139
|
"""Alternative access method - data:get(0) instead of data[0]."""
|
|
136
|
-
if isinstance(
|
|
137
|
-
|
|
138
|
-
return self._data[
|
|
140
|
+
if isinstance(requested_index, float):
|
|
141
|
+
requested_index = int(requested_index)
|
|
142
|
+
return self._data[requested_index]
|
|
139
143
|
|
|
140
144
|
|
|
141
145
|
def create_safe_csv_library(base_path: str) -> Dict[str, Any]:
|
|
@@ -150,46 +154,48 @@ def create_safe_csv_library(base_path: str) -> Dict[str, Any]:
|
|
|
150
154
|
"""
|
|
151
155
|
validator = PathValidator(base_path)
|
|
152
156
|
|
|
153
|
-
def read(
|
|
157
|
+
def read(file_path: str) -> LuaList:
|
|
154
158
|
"""Read CSV file, returning list of dictionaries with headers as keys."""
|
|
155
|
-
|
|
156
|
-
with open(
|
|
157
|
-
reader = csv.DictReader(
|
|
159
|
+
validated_path = validator.validate(file_path)
|
|
160
|
+
with open(validated_path, "r", encoding="utf-8", newline="") as file_handle:
|
|
161
|
+
reader = csv.DictReader(file_handle)
|
|
158
162
|
return LuaList(list(reader))
|
|
159
163
|
|
|
160
|
-
def write(
|
|
164
|
+
def write(file_path: str, data: List[Dict], options: Optional[Dict] = None) -> None:
|
|
161
165
|
"""Write list of dictionaries to CSV file."""
|
|
162
|
-
|
|
166
|
+
validated_path = validator.validate(file_path)
|
|
163
167
|
|
|
164
168
|
# Convert Lua table options to Python dict if needed
|
|
165
169
|
if options and hasattr(options, "items"):
|
|
166
170
|
options = dict(options.items())
|
|
167
171
|
options = options or {}
|
|
168
|
-
|
|
172
|
+
header_row = options.get("headers")
|
|
169
173
|
|
|
170
174
|
# Convert headers from Lua table to Python list if needed
|
|
171
|
-
if
|
|
172
|
-
|
|
175
|
+
if header_row and hasattr(header_row, "values"):
|
|
176
|
+
header_row = list(header_row.values())
|
|
173
177
|
|
|
174
178
|
# Convert Lua table to Python list if needed
|
|
175
179
|
if hasattr(data, "values"):
|
|
176
180
|
data = list(data.values())
|
|
177
181
|
|
|
178
|
-
if not
|
|
182
|
+
if not header_row and data:
|
|
179
183
|
# Get headers from first row
|
|
180
184
|
first_row = data[0]
|
|
181
185
|
if hasattr(first_row, "keys"):
|
|
182
|
-
|
|
186
|
+
header_row = list(first_row.keys())
|
|
183
187
|
elif isinstance(first_row, dict):
|
|
184
|
-
|
|
188
|
+
header_row = list(first_row.keys())
|
|
185
189
|
else:
|
|
186
190
|
raise ValueError("Cannot determine headers from data")
|
|
187
191
|
|
|
188
192
|
# Ensure parent directory exists
|
|
189
|
-
|
|
193
|
+
parent_directory = os.path.dirname(validated_path)
|
|
194
|
+
if parent_directory:
|
|
195
|
+
os.makedirs(parent_directory, exist_ok=True)
|
|
190
196
|
|
|
191
|
-
with open(
|
|
192
|
-
writer = csv.DictWriter(
|
|
197
|
+
with open(validated_path, "w", encoding="utf-8", newline="") as file_handle:
|
|
198
|
+
writer = csv.DictWriter(file_handle, fieldnames=header_row)
|
|
193
199
|
writer.writeheader()
|
|
194
200
|
for row in data:
|
|
195
201
|
# Convert Lua table to dict if needed
|
|
@@ -212,45 +218,51 @@ def create_safe_tsv_library(base_path: str) -> Dict[str, Any]:
|
|
|
212
218
|
"""
|
|
213
219
|
validator = PathValidator(base_path)
|
|
214
220
|
|
|
215
|
-
def read(
|
|
221
|
+
def read(file_path: str) -> LuaList:
|
|
216
222
|
"""Read TSV file, returning list of dictionaries with headers as keys."""
|
|
217
|
-
|
|
218
|
-
with open(
|
|
219
|
-
reader = csv.DictReader(
|
|
223
|
+
validated_path = validator.validate(file_path)
|
|
224
|
+
with open(validated_path, "r", encoding="utf-8", newline="") as file_handle:
|
|
225
|
+
reader = csv.DictReader(file_handle, delimiter="\t")
|
|
220
226
|
return LuaList(list(reader))
|
|
221
227
|
|
|
222
|
-
def write(
|
|
228
|
+
def write(file_path: str, data: List[Dict], options: Optional[Dict] = None) -> None:
|
|
223
229
|
"""Write list of dictionaries to TSV file."""
|
|
224
|
-
|
|
230
|
+
validated_path = validator.validate(file_path)
|
|
225
231
|
|
|
226
232
|
# Convert Lua table options to Python dict if needed
|
|
227
233
|
if options and hasattr(options, "items"):
|
|
228
234
|
options = dict(options.items())
|
|
229
235
|
options = options or {}
|
|
230
|
-
|
|
236
|
+
header_row = options.get("headers")
|
|
231
237
|
|
|
232
238
|
# Convert headers from Lua table to Python list if needed
|
|
233
|
-
if
|
|
234
|
-
|
|
239
|
+
if header_row and hasattr(header_row, "values"):
|
|
240
|
+
header_row = list(header_row.values())
|
|
235
241
|
|
|
236
242
|
# Convert Lua table to Python list if needed
|
|
237
243
|
if hasattr(data, "values"):
|
|
238
244
|
data = list(data.values())
|
|
239
245
|
|
|
240
|
-
if not
|
|
246
|
+
if not header_row and data:
|
|
241
247
|
first_row = data[0]
|
|
242
248
|
if hasattr(first_row, "keys"):
|
|
243
|
-
|
|
249
|
+
header_row = list(first_row.keys())
|
|
244
250
|
elif isinstance(first_row, dict):
|
|
245
|
-
|
|
251
|
+
header_row = list(first_row.keys())
|
|
246
252
|
else:
|
|
247
253
|
raise ValueError("Cannot determine headers from data")
|
|
248
254
|
|
|
249
255
|
# Ensure parent directory exists
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
256
|
+
parent_directory = os.path.dirname(validated_path)
|
|
257
|
+
if parent_directory:
|
|
258
|
+
os.makedirs(parent_directory, exist_ok=True)
|
|
259
|
+
|
|
260
|
+
with open(validated_path, "w", encoding="utf-8", newline="") as file_handle:
|
|
261
|
+
writer = csv.DictWriter(
|
|
262
|
+
file_handle,
|
|
263
|
+
fieldnames=header_row,
|
|
264
|
+
delimiter="\t",
|
|
265
|
+
)
|
|
254
266
|
writer.writeheader()
|
|
255
267
|
for row in data:
|
|
256
268
|
if hasattr(row, "items"):
|
|
@@ -272,15 +284,15 @@ def create_safe_json_library(base_path: str) -> Dict[str, Any]:
|
|
|
272
284
|
"""
|
|
273
285
|
validator = PathValidator(base_path)
|
|
274
286
|
|
|
275
|
-
def read(
|
|
287
|
+
def read(file_path: str) -> Any:
|
|
276
288
|
"""Read JSON file and return parsed data."""
|
|
277
|
-
|
|
278
|
-
with open(
|
|
279
|
-
return json.load(
|
|
289
|
+
validated_path = validator.validate(file_path)
|
|
290
|
+
with open(validated_path, "r", encoding="utf-8") as file_handle:
|
|
291
|
+
return json.load(file_handle)
|
|
280
292
|
|
|
281
|
-
def write(
|
|
293
|
+
def write(file_path: str, data: Any, options: Optional[Dict] = None) -> None:
|
|
282
294
|
"""Write data to JSON file."""
|
|
283
|
-
|
|
295
|
+
validated_path = validator.validate(file_path)
|
|
284
296
|
|
|
285
297
|
# Convert Lua table options to Python dict if needed
|
|
286
298
|
if options and hasattr(options, "items"):
|
|
@@ -292,10 +304,12 @@ def create_safe_json_library(base_path: str) -> Dict[str, Any]:
|
|
|
292
304
|
data = _lua_to_python(data)
|
|
293
305
|
|
|
294
306
|
# Ensure parent directory exists
|
|
295
|
-
|
|
307
|
+
parent_directory = os.path.dirname(validated_path)
|
|
308
|
+
if parent_directory:
|
|
309
|
+
os.makedirs(parent_directory, exist_ok=True)
|
|
296
310
|
|
|
297
|
-
with open(
|
|
298
|
-
json.dump(data,
|
|
311
|
+
with open(validated_path, "w", encoding="utf-8") as file_handle:
|
|
312
|
+
json.dump(data, file_handle, indent=indent, default=str)
|
|
299
313
|
|
|
300
314
|
return {"read": read, "write": write}
|
|
301
315
|
|
|
@@ -315,24 +329,26 @@ def create_safe_parquet_library(base_path: str) -> Dict[str, Any]:
|
|
|
315
329
|
|
|
316
330
|
validator = PathValidator(base_path)
|
|
317
331
|
|
|
318
|
-
def read(
|
|
332
|
+
def read(file_path: str) -> LuaList:
|
|
319
333
|
"""Read Parquet file, returning list of dictionaries."""
|
|
320
|
-
|
|
321
|
-
table = pq.read_table(
|
|
334
|
+
validated_path = validator.validate(file_path)
|
|
335
|
+
table = pq.read_table(validated_path)
|
|
322
336
|
return LuaList(table.to_pylist())
|
|
323
337
|
|
|
324
|
-
def write(
|
|
338
|
+
def write(file_path: str, data: List[Dict]) -> None:
|
|
325
339
|
"""Write list of dictionaries to Parquet file."""
|
|
326
|
-
|
|
340
|
+
validated_path = validator.validate(file_path)
|
|
327
341
|
|
|
328
342
|
# Convert Lua tables to Python
|
|
329
343
|
data = _lua_to_python(data)
|
|
330
344
|
|
|
331
345
|
# Ensure parent directory exists
|
|
332
|
-
|
|
346
|
+
parent_directory = os.path.dirname(validated_path)
|
|
347
|
+
if parent_directory:
|
|
348
|
+
os.makedirs(parent_directory, exist_ok=True)
|
|
333
349
|
|
|
334
350
|
table = pa.Table.from_pylist(data)
|
|
335
|
-
pq.write_table(table,
|
|
351
|
+
pq.write_table(table, validated_path)
|
|
336
352
|
|
|
337
353
|
return {"read": read, "write": write}
|
|
338
354
|
|
|
@@ -352,38 +368,40 @@ def create_safe_hdf5_library(base_path: str) -> Dict[str, Any]:
|
|
|
352
368
|
|
|
353
369
|
validator = PathValidator(base_path)
|
|
354
370
|
|
|
355
|
-
def read(
|
|
371
|
+
def read(file_path: str, dataset: str) -> List[Any]:
|
|
356
372
|
"""Read dataset from HDF5 file."""
|
|
357
|
-
|
|
358
|
-
with h5py.File(
|
|
359
|
-
return
|
|
373
|
+
validated_path = validator.validate(file_path)
|
|
374
|
+
with h5py.File(validated_path, "r") as hdf5_file:
|
|
375
|
+
return hdf5_file[dataset][:].tolist()
|
|
360
376
|
|
|
361
|
-
def write(
|
|
377
|
+
def write(file_path: str, dataset: str, data: List) -> None:
|
|
362
378
|
"""Write data to HDF5 dataset."""
|
|
363
|
-
|
|
379
|
+
validated_path = validator.validate(file_path)
|
|
364
380
|
|
|
365
381
|
# Convert Lua tables to Python
|
|
366
382
|
data = _lua_to_python(data)
|
|
367
383
|
|
|
368
384
|
# Ensure parent directory exists
|
|
369
|
-
|
|
385
|
+
parent_directory = os.path.dirname(validated_path)
|
|
386
|
+
if parent_directory:
|
|
387
|
+
os.makedirs(parent_directory, exist_ok=True)
|
|
370
388
|
|
|
371
|
-
with h5py.File(
|
|
372
|
-
if dataset in
|
|
373
|
-
del
|
|
374
|
-
|
|
389
|
+
with h5py.File(validated_path, "a") as hdf5_file:
|
|
390
|
+
if dataset in hdf5_file:
|
|
391
|
+
del hdf5_file[dataset]
|
|
392
|
+
hdf5_file.create_dataset(dataset, data=np.array(data))
|
|
375
393
|
|
|
376
|
-
def list_datasets(
|
|
394
|
+
def list_datasets(file_path: str) -> List[str]:
|
|
377
395
|
"""List all datasets in HDF5 file."""
|
|
378
|
-
|
|
396
|
+
validated_path = validator.validate(file_path)
|
|
379
397
|
datasets = []
|
|
380
|
-
with h5py.File(
|
|
398
|
+
with h5py.File(validated_path, "r") as hdf5_file:
|
|
381
399
|
|
|
382
|
-
def visitor(name,
|
|
383
|
-
if isinstance(
|
|
400
|
+
def visitor(name: str, hdf5_object: Any) -> None:
|
|
401
|
+
if isinstance(hdf5_object, h5py.Dataset):
|
|
384
402
|
datasets.append(name)
|
|
385
403
|
|
|
386
|
-
|
|
404
|
+
hdf5_file.visititems(visitor)
|
|
387
405
|
return datasets
|
|
388
406
|
|
|
389
407
|
return {"read": read, "write": write, "list": list_datasets}
|
|
@@ -403,9 +421,9 @@ def create_safe_excel_library(base_path: str) -> Dict[str, Any]:
|
|
|
403
421
|
|
|
404
422
|
validator = PathValidator(base_path)
|
|
405
423
|
|
|
406
|
-
def read(
|
|
424
|
+
def read(file_path: str, options: Optional[Dict] = None) -> LuaList:
|
|
407
425
|
"""Read Excel file, returning list of dictionaries."""
|
|
408
|
-
|
|
426
|
+
validated_path = validator.validate(file_path)
|
|
409
427
|
|
|
410
428
|
# Convert Lua table options to Python dict if needed
|
|
411
429
|
if options and hasattr(options, "items"):
|
|
@@ -413,20 +431,24 @@ def create_safe_excel_library(base_path: str) -> Dict[str, Any]:
|
|
|
413
431
|
options = options or {}
|
|
414
432
|
sheet_name = options.get("sheet")
|
|
415
433
|
|
|
416
|
-
|
|
417
|
-
|
|
434
|
+
workbook = load_workbook(validated_path, read_only=True, data_only=True)
|
|
435
|
+
worksheet = workbook[sheet_name] if sheet_name else workbook.active
|
|
418
436
|
|
|
419
|
-
rows = list(
|
|
437
|
+
rows = list(worksheet.iter_rows(values_only=True))
|
|
420
438
|
if not rows:
|
|
421
439
|
return LuaList([])
|
|
422
440
|
|
|
423
441
|
# First row is headers
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
442
|
+
header_row = [
|
|
443
|
+
str(header_value) if header_value is not None else f"col_{index}"
|
|
444
|
+
for index, header_value in enumerate(rows[0])
|
|
445
|
+
]
|
|
446
|
+
data_rows = rows[1:]
|
|
447
|
+
return LuaList([dict(zip(header_row, row_values)) for row_values in data_rows])
|
|
448
|
+
|
|
449
|
+
def write(file_path: str, data: List[Dict], options: Optional[Dict] = None) -> None:
|
|
428
450
|
"""Write list of dictionaries to Excel file."""
|
|
429
|
-
|
|
451
|
+
validated_path = validator.validate(file_path)
|
|
430
452
|
|
|
431
453
|
# Convert Lua table options to Python dict if needed
|
|
432
454
|
if options and hasattr(options, "items"):
|
|
@@ -438,65 +460,67 @@ def create_safe_excel_library(base_path: str) -> Dict[str, Any]:
|
|
|
438
460
|
data = _lua_to_python(data)
|
|
439
461
|
|
|
440
462
|
# Ensure parent directory exists
|
|
441
|
-
|
|
463
|
+
parent_directory = os.path.dirname(validated_path)
|
|
464
|
+
if parent_directory:
|
|
465
|
+
os.makedirs(parent_directory, exist_ok=True)
|
|
442
466
|
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
467
|
+
workbook = Workbook()
|
|
468
|
+
worksheet = workbook.active
|
|
469
|
+
worksheet.title = sheet_name
|
|
446
470
|
|
|
447
471
|
if data:
|
|
448
|
-
|
|
449
|
-
|
|
472
|
+
header_row = list(data[0].keys())
|
|
473
|
+
worksheet.append(header_row)
|
|
450
474
|
for row in data:
|
|
451
|
-
|
|
475
|
+
worksheet.append([row.get(header) for header in header_row])
|
|
452
476
|
|
|
453
|
-
|
|
477
|
+
workbook.save(validated_path)
|
|
454
478
|
|
|
455
|
-
def sheets(
|
|
479
|
+
def sheets(file_path: str) -> List[str]:
|
|
456
480
|
"""List sheet names in Excel file."""
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
return
|
|
481
|
+
validated_path = validator.validate(file_path)
|
|
482
|
+
workbook = load_workbook(validated_path, read_only=True)
|
|
483
|
+
return workbook.sheetnames
|
|
460
484
|
|
|
461
485
|
return {"read": read, "write": write, "sheets": sheets}
|
|
462
486
|
|
|
463
487
|
|
|
464
|
-
def _lua_to_python(
|
|
488
|
+
def _lua_to_python(value: Any) -> Any:
|
|
465
489
|
"""
|
|
466
490
|
Recursively convert Lua table-like objects to Python dicts/lists.
|
|
467
491
|
|
|
468
492
|
Args:
|
|
469
|
-
|
|
493
|
+
value: Object to convert (may be Lua table or Python object).
|
|
470
494
|
|
|
471
495
|
Returns:
|
|
472
496
|
Python dict, list, or original value.
|
|
473
497
|
"""
|
|
474
498
|
# Check if it's a Lua table (has values() or items() method)
|
|
475
|
-
if hasattr(
|
|
499
|
+
if hasattr(value, "items"):
|
|
476
500
|
# Could be a dict-like Lua table
|
|
477
501
|
try:
|
|
478
|
-
|
|
502
|
+
lua_items = list(value.items())
|
|
479
503
|
# Check if it's array-like (all integer keys starting from 1)
|
|
480
|
-
if
|
|
481
|
-
|
|
482
|
-
if
|
|
504
|
+
if lua_items and all(isinstance(key, (int, float)) for key, _ in lua_items):
|
|
505
|
+
lua_keys = [int(key) for key, _ in lua_items]
|
|
506
|
+
if lua_keys == list(range(1, len(lua_keys) + 1)):
|
|
483
507
|
# It's an array-like table, convert to list
|
|
484
|
-
return [_lua_to_python(
|
|
508
|
+
return [_lua_to_python(value[key]) for key in range(1, len(lua_keys) + 1)]
|
|
485
509
|
# It's a dict-like table
|
|
486
|
-
return {
|
|
510
|
+
return {key: _lua_to_python(item) for key, item in lua_items}
|
|
487
511
|
except (TypeError, AttributeError):
|
|
488
512
|
pass
|
|
489
513
|
|
|
490
|
-
if hasattr(
|
|
514
|
+
if hasattr(value, "values") and not isinstance(value, (dict, str)):
|
|
491
515
|
try:
|
|
492
|
-
return [_lua_to_python(
|
|
516
|
+
return [_lua_to_python(item) for item in value.values()]
|
|
493
517
|
except (TypeError, AttributeError):
|
|
494
518
|
pass
|
|
495
519
|
|
|
496
|
-
if isinstance(
|
|
497
|
-
return {
|
|
520
|
+
if isinstance(value, dict):
|
|
521
|
+
return {key: _lua_to_python(item) for key, item in value.items()}
|
|
498
522
|
|
|
499
|
-
if isinstance(
|
|
500
|
-
return [_lua_to_python(item) for item in
|
|
523
|
+
if isinstance(value, list):
|
|
524
|
+
return [_lua_to_python(item) for item in value]
|
|
501
525
|
|
|
502
|
-
return
|
|
526
|
+
return value
|