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.
- tactus/__init__.py +49 -0
- tactus/adapters/__init__.py +9 -0
- tactus/adapters/broker_log.py +76 -0
- tactus/adapters/cli_hitl.py +189 -0
- tactus/adapters/cli_log.py +223 -0
- tactus/adapters/cost_collector_log.py +56 -0
- tactus/adapters/file_storage.py +367 -0
- tactus/adapters/http_callback_log.py +109 -0
- tactus/adapters/ide_log.py +71 -0
- tactus/adapters/lua_tools.py +336 -0
- tactus/adapters/mcp.py +289 -0
- tactus/adapters/mcp_manager.py +196 -0
- tactus/adapters/memory.py +53 -0
- tactus/adapters/plugins.py +419 -0
- tactus/backends/http_backend.py +58 -0
- tactus/backends/model_backend.py +35 -0
- tactus/backends/pytorch_backend.py +110 -0
- tactus/broker/__init__.py +12 -0
- tactus/broker/client.py +247 -0
- tactus/broker/protocol.py +183 -0
- tactus/broker/server.py +1123 -0
- tactus/broker/stdio.py +12 -0
- tactus/cli/__init__.py +7 -0
- tactus/cli/app.py +2245 -0
- tactus/cli/commands/__init__.py +0 -0
- tactus/core/__init__.py +32 -0
- tactus/core/config_manager.py +790 -0
- tactus/core/dependencies/__init__.py +14 -0
- tactus/core/dependencies/registry.py +180 -0
- tactus/core/dsl_stubs.py +2117 -0
- tactus/core/exceptions.py +66 -0
- tactus/core/execution_context.py +480 -0
- tactus/core/lua_sandbox.py +508 -0
- tactus/core/message_history_manager.py +236 -0
- tactus/core/mocking.py +286 -0
- tactus/core/output_validator.py +291 -0
- tactus/core/registry.py +499 -0
- tactus/core/runtime.py +2907 -0
- tactus/core/template_resolver.py +142 -0
- tactus/core/yaml_parser.py +301 -0
- tactus/docker/Dockerfile +61 -0
- tactus/docker/entrypoint.sh +69 -0
- tactus/dspy/__init__.py +39 -0
- tactus/dspy/agent.py +1144 -0
- tactus/dspy/broker_lm.py +181 -0
- tactus/dspy/config.py +212 -0
- tactus/dspy/history.py +196 -0
- tactus/dspy/module.py +405 -0
- tactus/dspy/prediction.py +318 -0
- tactus/dspy/signature.py +185 -0
- tactus/formatting/__init__.py +7 -0
- tactus/formatting/formatter.py +437 -0
- tactus/ide/__init__.py +9 -0
- tactus/ide/coding_assistant.py +343 -0
- tactus/ide/server.py +2223 -0
- tactus/primitives/__init__.py +49 -0
- tactus/primitives/control.py +168 -0
- tactus/primitives/file.py +229 -0
- tactus/primitives/handles.py +378 -0
- tactus/primitives/host.py +94 -0
- tactus/primitives/human.py +342 -0
- tactus/primitives/json.py +189 -0
- tactus/primitives/log.py +187 -0
- tactus/primitives/message_history.py +157 -0
- tactus/primitives/model.py +163 -0
- tactus/primitives/procedure.py +564 -0
- tactus/primitives/procedure_callable.py +318 -0
- tactus/primitives/retry.py +155 -0
- tactus/primitives/session.py +152 -0
- tactus/primitives/state.py +182 -0
- tactus/primitives/step.py +209 -0
- tactus/primitives/system.py +93 -0
- tactus/primitives/tool.py +375 -0
- tactus/primitives/tool_handle.py +279 -0
- tactus/primitives/toolset.py +229 -0
- tactus/protocols/__init__.py +38 -0
- tactus/protocols/chat_recorder.py +81 -0
- tactus/protocols/config.py +97 -0
- tactus/protocols/cost.py +31 -0
- tactus/protocols/hitl.py +71 -0
- tactus/protocols/log_handler.py +27 -0
- tactus/protocols/models.py +355 -0
- tactus/protocols/result.py +33 -0
- tactus/protocols/storage.py +90 -0
- tactus/providers/__init__.py +13 -0
- tactus/providers/base.py +92 -0
- tactus/providers/bedrock.py +117 -0
- tactus/providers/google.py +105 -0
- tactus/providers/openai.py +98 -0
- tactus/sandbox/__init__.py +63 -0
- tactus/sandbox/config.py +171 -0
- tactus/sandbox/container_runner.py +1099 -0
- tactus/sandbox/docker_manager.py +433 -0
- tactus/sandbox/entrypoint.py +227 -0
- tactus/sandbox/protocol.py +213 -0
- tactus/stdlib/__init__.py +10 -0
- tactus/stdlib/io/__init__.py +13 -0
- tactus/stdlib/io/csv.py +88 -0
- tactus/stdlib/io/excel.py +136 -0
- tactus/stdlib/io/file.py +90 -0
- tactus/stdlib/io/fs.py +154 -0
- tactus/stdlib/io/hdf5.py +121 -0
- tactus/stdlib/io/json.py +109 -0
- tactus/stdlib/io/parquet.py +83 -0
- tactus/stdlib/io/tsv.py +88 -0
- tactus/stdlib/loader.py +274 -0
- tactus/stdlib/tac/tactus/tools/done.tac +33 -0
- tactus/stdlib/tac/tactus/tools/log.tac +50 -0
- tactus/testing/README.md +273 -0
- tactus/testing/__init__.py +61 -0
- tactus/testing/behave_integration.py +380 -0
- tactus/testing/context.py +486 -0
- tactus/testing/eval_models.py +114 -0
- tactus/testing/evaluation_runner.py +222 -0
- tactus/testing/evaluators.py +634 -0
- tactus/testing/events.py +94 -0
- tactus/testing/gherkin_parser.py +134 -0
- tactus/testing/mock_agent.py +315 -0
- tactus/testing/mock_dependencies.py +234 -0
- tactus/testing/mock_hitl.py +171 -0
- tactus/testing/mock_registry.py +168 -0
- tactus/testing/mock_tools.py +133 -0
- tactus/testing/models.py +115 -0
- tactus/testing/pydantic_eval_runner.py +508 -0
- tactus/testing/steps/__init__.py +13 -0
- tactus/testing/steps/builtin.py +902 -0
- tactus/testing/steps/custom.py +69 -0
- tactus/testing/steps/registry.py +68 -0
- tactus/testing/test_runner.py +489 -0
- tactus/tracing/__init__.py +5 -0
- tactus/tracing/trace_manager.py +417 -0
- tactus/utils/__init__.py +1 -0
- tactus/utils/cost_calculator.py +72 -0
- tactus/utils/model_pricing.py +132 -0
- tactus/utils/safe_file_library.py +502 -0
- tactus/utils/safe_libraries.py +234 -0
- tactus/validation/LuaLexerBase.py +66 -0
- tactus/validation/LuaParserBase.py +23 -0
- tactus/validation/README.md +224 -0
- tactus/validation/__init__.py +7 -0
- tactus/validation/error_listener.py +21 -0
- tactus/validation/generated/LuaLexer.interp +231 -0
- tactus/validation/generated/LuaLexer.py +5548 -0
- tactus/validation/generated/LuaLexer.tokens +124 -0
- tactus/validation/generated/LuaLexerBase.py +66 -0
- tactus/validation/generated/LuaParser.interp +173 -0
- tactus/validation/generated/LuaParser.py +6439 -0
- tactus/validation/generated/LuaParser.tokens +124 -0
- tactus/validation/generated/LuaParserBase.py +23 -0
- tactus/validation/generated/LuaParserVisitor.py +118 -0
- tactus/validation/generated/__init__.py +7 -0
- tactus/validation/grammar/LuaLexer.g4 +123 -0
- tactus/validation/grammar/LuaParser.g4 +178 -0
- tactus/validation/semantic_visitor.py +817 -0
- tactus/validation/validator.py +157 -0
- tactus-0.31.2.dist-info/METADATA +1809 -0
- tactus-0.31.2.dist-info/RECORD +160 -0
- tactus-0.31.2.dist-info/WHEEL +4 -0
- tactus-0.31.2.dist-info/entry_points.txt +2 -0
- tactus-0.31.2.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Communication protocol between host and container processes.
|
|
3
|
+
|
|
4
|
+
Defines the data structures for serializing execution requests and results
|
|
5
|
+
over stdio between the host process and the sandboxed container.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from dataclasses import dataclass, field, asdict
|
|
10
|
+
from typing import Any, Dict, List, Optional
|
|
11
|
+
from enum import Enum
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _json_serializer(obj: Any) -> Any:
|
|
17
|
+
"""Custom JSON serializer for objects not natively serializable."""
|
|
18
|
+
if isinstance(obj, BaseModel):
|
|
19
|
+
return obj.model_dump(mode="json")
|
|
20
|
+
if hasattr(obj, "__dict__"):
|
|
21
|
+
return obj.__dict__
|
|
22
|
+
raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ExecutionStatus(str, Enum):
|
|
26
|
+
"""Status of sandbox execution."""
|
|
27
|
+
|
|
28
|
+
SUCCESS = "success"
|
|
29
|
+
ERROR = "error"
|
|
30
|
+
TIMEOUT = "timeout"
|
|
31
|
+
CANCELLED = "cancelled"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class ExecutionRequest:
|
|
36
|
+
"""
|
|
37
|
+
Request sent from host to container for procedure execution.
|
|
38
|
+
|
|
39
|
+
Serialized as JSON over stdin to the container process.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
# Procedure source code (.tac file content)
|
|
43
|
+
source: str
|
|
44
|
+
|
|
45
|
+
# Working directory path (inside container)
|
|
46
|
+
working_dir: str = "/workspace"
|
|
47
|
+
|
|
48
|
+
# Input parameters for the procedure
|
|
49
|
+
params: Dict[str, Any] = field(default_factory=dict)
|
|
50
|
+
|
|
51
|
+
# Unique execution ID for tracking
|
|
52
|
+
execution_id: Optional[str] = None
|
|
53
|
+
|
|
54
|
+
# Source file path (for error messages)
|
|
55
|
+
source_file_path: Optional[str] = None
|
|
56
|
+
|
|
57
|
+
# Source format: "lua" for .tac files, "yaml" for legacy YAML format
|
|
58
|
+
format: str = "lua"
|
|
59
|
+
|
|
60
|
+
def to_json(self) -> str:
|
|
61
|
+
"""Serialize to JSON string."""
|
|
62
|
+
return json.dumps(asdict(self), indent=None, separators=(",", ":"))
|
|
63
|
+
|
|
64
|
+
@classmethod
|
|
65
|
+
def from_json(cls, json_str: str) -> "ExecutionRequest":
|
|
66
|
+
"""Deserialize from JSON string."""
|
|
67
|
+
data = json.loads(json_str)
|
|
68
|
+
return cls(**data)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class ExecutionResult:
|
|
73
|
+
"""
|
|
74
|
+
Result returned from container to host after execution.
|
|
75
|
+
|
|
76
|
+
Serialized as JSON over stdout from the container process.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
# Execution status
|
|
80
|
+
status: ExecutionStatus
|
|
81
|
+
|
|
82
|
+
# Result value (if successful)
|
|
83
|
+
result: Optional[Any] = None
|
|
84
|
+
|
|
85
|
+
# Error message (if failed)
|
|
86
|
+
error: Optional[str] = None
|
|
87
|
+
|
|
88
|
+
# Error type/class name
|
|
89
|
+
error_type: Optional[str] = None
|
|
90
|
+
|
|
91
|
+
# Stack trace (if failed)
|
|
92
|
+
traceback: Optional[str] = None
|
|
93
|
+
|
|
94
|
+
# Execution duration in seconds
|
|
95
|
+
duration_seconds: float = 0.0
|
|
96
|
+
|
|
97
|
+
# Exit code suggestion
|
|
98
|
+
exit_code: int = 0
|
|
99
|
+
|
|
100
|
+
# Structured logs from execution
|
|
101
|
+
logs: List[Dict[str, Any]] = field(default_factory=list)
|
|
102
|
+
|
|
103
|
+
# Metadata about the execution
|
|
104
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
105
|
+
|
|
106
|
+
def to_json(self) -> str:
|
|
107
|
+
"""Serialize to JSON string."""
|
|
108
|
+
data = asdict(self)
|
|
109
|
+
# Convert enum to string for JSON
|
|
110
|
+
data["status"] = self.status.value
|
|
111
|
+
return json.dumps(data, indent=None, separators=(",", ":"), default=_json_serializer)
|
|
112
|
+
|
|
113
|
+
@classmethod
|
|
114
|
+
def from_json(cls, json_str: str) -> "ExecutionResult":
|
|
115
|
+
"""Deserialize from JSON string."""
|
|
116
|
+
data = json.loads(json_str)
|
|
117
|
+
# Convert string back to enum
|
|
118
|
+
data["status"] = ExecutionStatus(data["status"])
|
|
119
|
+
return cls(**data)
|
|
120
|
+
|
|
121
|
+
@classmethod
|
|
122
|
+
def success(
|
|
123
|
+
cls,
|
|
124
|
+
result: Any,
|
|
125
|
+
duration_seconds: float = 0.0,
|
|
126
|
+
logs: Optional[List[Dict[str, Any]]] = None,
|
|
127
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
128
|
+
) -> "ExecutionResult":
|
|
129
|
+
"""Create a successful result."""
|
|
130
|
+
return cls(
|
|
131
|
+
status=ExecutionStatus.SUCCESS,
|
|
132
|
+
result=result,
|
|
133
|
+
duration_seconds=duration_seconds,
|
|
134
|
+
exit_code=0,
|
|
135
|
+
logs=logs or [],
|
|
136
|
+
metadata=metadata or {},
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
@classmethod
|
|
140
|
+
def failure(
|
|
141
|
+
cls,
|
|
142
|
+
error: str,
|
|
143
|
+
error_type: Optional[str] = None,
|
|
144
|
+
traceback: Optional[str] = None,
|
|
145
|
+
duration_seconds: float = 0.0,
|
|
146
|
+
exit_code: int = 1,
|
|
147
|
+
logs: Optional[List[Dict[str, Any]]] = None,
|
|
148
|
+
) -> "ExecutionResult":
|
|
149
|
+
"""Create a failed result."""
|
|
150
|
+
return cls(
|
|
151
|
+
status=ExecutionStatus.ERROR,
|
|
152
|
+
error=error,
|
|
153
|
+
error_type=error_type,
|
|
154
|
+
traceback=traceback,
|
|
155
|
+
duration_seconds=duration_seconds,
|
|
156
|
+
exit_code=exit_code,
|
|
157
|
+
logs=logs or [],
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
@classmethod
|
|
161
|
+
def timeout(
|
|
162
|
+
cls,
|
|
163
|
+
duration_seconds: float,
|
|
164
|
+
logs: Optional[List[Dict[str, Any]]] = None,
|
|
165
|
+
) -> "ExecutionResult":
|
|
166
|
+
"""Create a timeout result."""
|
|
167
|
+
return cls(
|
|
168
|
+
status=ExecutionStatus.TIMEOUT,
|
|
169
|
+
error="Execution timed out",
|
|
170
|
+
duration_seconds=duration_seconds,
|
|
171
|
+
exit_code=124, # Standard timeout exit code
|
|
172
|
+
logs=logs or [],
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# Protocol markers for parsing stdout
|
|
177
|
+
# The result JSON is wrapped in markers to distinguish it from other output
|
|
178
|
+
RESULT_START_MARKER = "<<<TACTUS_RESULT_START>>>"
|
|
179
|
+
RESULT_END_MARKER = "<<<TACTUS_RESULT_END>>>"
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def wrap_result_for_stdout(result: ExecutionResult) -> str:
|
|
183
|
+
"""
|
|
184
|
+
Wrap a result in markers for stdout transmission.
|
|
185
|
+
|
|
186
|
+
This allows the host to distinguish the structured result
|
|
187
|
+
from any other output (logs, debug prints, etc.)
|
|
188
|
+
"""
|
|
189
|
+
return f"{RESULT_START_MARKER}\n{result.to_json()}\n{RESULT_END_MARKER}\n"
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def extract_result_from_stdout(stdout: str) -> Optional[ExecutionResult]:
|
|
193
|
+
"""
|
|
194
|
+
Extract the result from stdout, looking for markers.
|
|
195
|
+
|
|
196
|
+
Returns None if no valid result is found.
|
|
197
|
+
"""
|
|
198
|
+
start_idx = stdout.find(RESULT_START_MARKER)
|
|
199
|
+
if start_idx == -1:
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
end_idx = stdout.find(RESULT_END_MARKER, start_idx)
|
|
203
|
+
if end_idx == -1:
|
|
204
|
+
return None
|
|
205
|
+
|
|
206
|
+
# Extract JSON between markers
|
|
207
|
+
json_start = start_idx + len(RESULT_START_MARKER)
|
|
208
|
+
json_str = stdout[json_start:end_idx].strip()
|
|
209
|
+
|
|
210
|
+
try:
|
|
211
|
+
return ExecutionResult.from_json(json_str)
|
|
212
|
+
except (json.JSONDecodeError, TypeError, KeyError):
|
|
213
|
+
return None
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Tactus Standard Library.
|
|
2
|
+
|
|
3
|
+
The standard library consists of .tac files in the tac/ subdirectory.
|
|
4
|
+
These are loaded via Lua's require() function:
|
|
5
|
+
|
|
6
|
+
local done = require("tactus.tools.done")
|
|
7
|
+
local log = require("tactus.tools.log")
|
|
8
|
+
|
|
9
|
+
See tactus/stdlib/tac/ for available modules.
|
|
10
|
+
"""
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""
|
|
2
|
+
tactus.stdlib.io - File I/O operations for Tactus procedures.
|
|
3
|
+
|
|
4
|
+
This package provides Python-backed file I/O modules that can be
|
|
5
|
+
imported via require() in .tac files.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
local json = require("tactus.io.json")
|
|
9
|
+
local csv = require("tactus.io.csv")
|
|
10
|
+
local file = require("tactus.io.file")
|
|
11
|
+
|
|
12
|
+
All file operations are sandboxed to the procedure's base directory.
|
|
13
|
+
"""
|
tactus/stdlib/io/csv.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""
|
|
2
|
+
tactus.io.csv - CSV file operations for Tactus.
|
|
3
|
+
|
|
4
|
+
Provides CSV read/write operations with automatic header handling
|
|
5
|
+
and sandboxing to the procedure's base directory.
|
|
6
|
+
|
|
7
|
+
Usage in .tac files:
|
|
8
|
+
local csv = require("tactus.io.csv")
|
|
9
|
+
|
|
10
|
+
-- Read CSV file
|
|
11
|
+
local data = csv.read("data.csv")
|
|
12
|
+
|
|
13
|
+
-- Write CSV file
|
|
14
|
+
csv.write("output.csv", {
|
|
15
|
+
{name = "Alice", age = 30},
|
|
16
|
+
{name = "Bob", age = 25}
|
|
17
|
+
})
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import csv
|
|
21
|
+
import os
|
|
22
|
+
import sys
|
|
23
|
+
from typing import Any, Dict, List, Optional
|
|
24
|
+
|
|
25
|
+
# Get context (injected by loader)
|
|
26
|
+
_ctx = getattr(sys.modules[__name__], "__tactus_context__", None)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def read(filepath: str) -> List[Dict[str, Any]]:
|
|
30
|
+
"""
|
|
31
|
+
Read CSV file, returning list of dictionaries with headers as keys.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
filepath: Path to CSV file (relative to working directory)
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
List of dictionaries, one per row
|
|
38
|
+
|
|
39
|
+
Raises:
|
|
40
|
+
FileNotFoundError: If file does not exist
|
|
41
|
+
PermissionError: If path is outside working directory
|
|
42
|
+
"""
|
|
43
|
+
if _ctx:
|
|
44
|
+
filepath = _ctx.validate_path(filepath)
|
|
45
|
+
|
|
46
|
+
with open(filepath, "r", encoding="utf-8", newline="") as f:
|
|
47
|
+
reader = csv.DictReader(f)
|
|
48
|
+
return list(reader)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def write(
|
|
52
|
+
filepath: str, data: List[Dict[str, Any]], options: Optional[Dict[str, Any]] = None
|
|
53
|
+
) -> None:
|
|
54
|
+
"""
|
|
55
|
+
Write list of dictionaries to CSV file.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
filepath: Path to CSV file
|
|
59
|
+
data: List of dictionaries to write
|
|
60
|
+
options: Optional dict with 'headers' key for custom header order
|
|
61
|
+
|
|
62
|
+
Raises:
|
|
63
|
+
PermissionError: If path is outside working directory
|
|
64
|
+
ValueError: If data is empty or cannot determine headers
|
|
65
|
+
"""
|
|
66
|
+
if _ctx:
|
|
67
|
+
filepath = _ctx.validate_path(filepath)
|
|
68
|
+
|
|
69
|
+
if not data:
|
|
70
|
+
raise ValueError("Cannot write empty data to CSV")
|
|
71
|
+
|
|
72
|
+
# Determine headers
|
|
73
|
+
options = options or {}
|
|
74
|
+
headers = options.get("headers")
|
|
75
|
+
if not headers:
|
|
76
|
+
headers = list(data[0].keys())
|
|
77
|
+
|
|
78
|
+
# Create parent directories
|
|
79
|
+
os.makedirs(os.path.dirname(filepath) or ".", exist_ok=True)
|
|
80
|
+
|
|
81
|
+
with open(filepath, "w", encoding="utf-8", newline="") as f:
|
|
82
|
+
writer = csv.DictWriter(f, fieldnames=headers)
|
|
83
|
+
writer.writeheader()
|
|
84
|
+
writer.writerows(data)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# Explicit exports
|
|
88
|
+
__tactus_exports__ = ["read", "write"]
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""
|
|
2
|
+
tactus.io.excel - Excel spreadsheet operations for Tactus.
|
|
3
|
+
|
|
4
|
+
Provides Excel read/write operations with sandboxing
|
|
5
|
+
to the procedure's base directory.
|
|
6
|
+
|
|
7
|
+
Requires: openpyxl
|
|
8
|
+
|
|
9
|
+
Usage in .tac files:
|
|
10
|
+
local excel = require("tactus.io.excel")
|
|
11
|
+
|
|
12
|
+
-- Read Excel file
|
|
13
|
+
local data = excel.read("data.xlsx")
|
|
14
|
+
|
|
15
|
+
-- Read specific sheet
|
|
16
|
+
local data = excel.read("data.xlsx", {sheet = "Sheet2"})
|
|
17
|
+
|
|
18
|
+
-- Write Excel file
|
|
19
|
+
excel.write("output.xlsx", {
|
|
20
|
+
{name = "Alice", score = 95},
|
|
21
|
+
{name = "Bob", score = 87}
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
-- List sheet names
|
|
25
|
+
local sheets = excel.sheets("data.xlsx")
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
import os
|
|
29
|
+
import sys
|
|
30
|
+
from typing import Any, Dict, List, Optional
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
from openpyxl import Workbook, load_workbook
|
|
34
|
+
except ImportError:
|
|
35
|
+
raise ImportError("openpyxl is required for Excel support. Install with: pip install openpyxl")
|
|
36
|
+
|
|
37
|
+
# Get context (injected by loader)
|
|
38
|
+
_ctx = getattr(sys.modules[__name__], "__tactus_context__", None)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def read(filepath: str, options: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]:
|
|
42
|
+
"""
|
|
43
|
+
Read Excel file, returning list of dictionaries.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
filepath: Path to Excel file (relative to working directory)
|
|
47
|
+
options: Optional dict with 'sheet' key for sheet name
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
List of dictionaries, one per row (excluding header row)
|
|
51
|
+
|
|
52
|
+
Raises:
|
|
53
|
+
FileNotFoundError: If file does not exist
|
|
54
|
+
PermissionError: If path is outside working directory
|
|
55
|
+
"""
|
|
56
|
+
if _ctx:
|
|
57
|
+
filepath = _ctx.validate_path(filepath)
|
|
58
|
+
|
|
59
|
+
options = options or {}
|
|
60
|
+
sheet = options.get("sheet") if options else None
|
|
61
|
+
|
|
62
|
+
wb = load_workbook(filepath, read_only=True, data_only=True)
|
|
63
|
+
ws = wb[sheet] if sheet else wb.active
|
|
64
|
+
|
|
65
|
+
rows = list(ws.iter_rows(values_only=True))
|
|
66
|
+
if not rows:
|
|
67
|
+
return []
|
|
68
|
+
|
|
69
|
+
# First row is headers
|
|
70
|
+
headers = [str(h) if h is not None else f"col_{i}" for i, h in enumerate(rows[0])]
|
|
71
|
+
return [dict(zip(headers, row)) for row in rows[1:]]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def write(
|
|
75
|
+
filepath: str, data: List[Dict[str, Any]], options: Optional[Dict[str, Any]] = None
|
|
76
|
+
) -> None:
|
|
77
|
+
"""
|
|
78
|
+
Write list of dictionaries to Excel file.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
filepath: Path to Excel file
|
|
82
|
+
data: List of dictionaries to write
|
|
83
|
+
options: Optional dict with 'sheet' key for sheet name (default "Sheet1")
|
|
84
|
+
|
|
85
|
+
Raises:
|
|
86
|
+
PermissionError: If path is outside working directory
|
|
87
|
+
ValueError: If data is empty
|
|
88
|
+
"""
|
|
89
|
+
if _ctx:
|
|
90
|
+
filepath = _ctx.validate_path(filepath)
|
|
91
|
+
|
|
92
|
+
if not data:
|
|
93
|
+
raise ValueError("Cannot write empty data to Excel")
|
|
94
|
+
|
|
95
|
+
options = options or {}
|
|
96
|
+
sheet = options.get("sheet", "Sheet1")
|
|
97
|
+
|
|
98
|
+
# Create parent directories
|
|
99
|
+
os.makedirs(os.path.dirname(filepath) or ".", exist_ok=True)
|
|
100
|
+
|
|
101
|
+
wb = Workbook()
|
|
102
|
+
ws = wb.active
|
|
103
|
+
ws.title = sheet
|
|
104
|
+
|
|
105
|
+
# Write headers and data
|
|
106
|
+
headers = list(data[0].keys())
|
|
107
|
+
ws.append(headers)
|
|
108
|
+
for row in data:
|
|
109
|
+
ws.append([row.get(h) for h in headers])
|
|
110
|
+
|
|
111
|
+
wb.save(filepath)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def sheets(filepath: str) -> List[str]:
|
|
115
|
+
"""
|
|
116
|
+
List sheet names in Excel file.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
filepath: Path to Excel file
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
List of sheet names
|
|
123
|
+
|
|
124
|
+
Raises:
|
|
125
|
+
FileNotFoundError: If file does not exist
|
|
126
|
+
PermissionError: If path is outside working directory
|
|
127
|
+
"""
|
|
128
|
+
if _ctx:
|
|
129
|
+
filepath = _ctx.validate_path(filepath)
|
|
130
|
+
|
|
131
|
+
wb = load_workbook(filepath, read_only=True)
|
|
132
|
+
return wb.sheetnames
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# Explicit exports
|
|
136
|
+
__tactus_exports__ = ["read", "write", "sheets"]
|
tactus/stdlib/io/file.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""
|
|
2
|
+
tactus.io.file - Raw text file operations for Tactus.
|
|
3
|
+
|
|
4
|
+
Provides basic text file read/write operations with sandboxing
|
|
5
|
+
to the procedure's base directory.
|
|
6
|
+
|
|
7
|
+
Usage in .tac files:
|
|
8
|
+
local file = require("tactus.io.file")
|
|
9
|
+
|
|
10
|
+
-- Read text file
|
|
11
|
+
local content = file.read("data.txt")
|
|
12
|
+
|
|
13
|
+
-- Write text file
|
|
14
|
+
file.write("output.txt", "Hello, world!")
|
|
15
|
+
|
|
16
|
+
-- Check if file exists
|
|
17
|
+
if file.exists("config.txt") then
|
|
18
|
+
-- do something
|
|
19
|
+
end
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import os
|
|
23
|
+
import sys
|
|
24
|
+
|
|
25
|
+
# Get context (injected by loader)
|
|
26
|
+
_ctx = getattr(sys.modules[__name__], "__tactus_context__", None)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def read(filepath: str) -> str:
|
|
30
|
+
"""
|
|
31
|
+
Read entire file as text.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
filepath: Path to text file (relative to working directory)
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
File contents as string
|
|
38
|
+
|
|
39
|
+
Raises:
|
|
40
|
+
FileNotFoundError: If file does not exist
|
|
41
|
+
PermissionError: If path is outside working directory
|
|
42
|
+
"""
|
|
43
|
+
if _ctx:
|
|
44
|
+
filepath = _ctx.validate_path(filepath)
|
|
45
|
+
|
|
46
|
+
with open(filepath, "r", encoding="utf-8") as f:
|
|
47
|
+
return f.read()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def write(filepath: str, content: str) -> None:
|
|
51
|
+
"""
|
|
52
|
+
Write text to file.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
filepath: Path to text file
|
|
56
|
+
content: Text content to write
|
|
57
|
+
|
|
58
|
+
Raises:
|
|
59
|
+
PermissionError: If path is outside working directory
|
|
60
|
+
"""
|
|
61
|
+
if _ctx:
|
|
62
|
+
filepath = _ctx.validate_path(filepath)
|
|
63
|
+
|
|
64
|
+
# Create parent directories
|
|
65
|
+
os.makedirs(os.path.dirname(filepath) or ".", exist_ok=True)
|
|
66
|
+
|
|
67
|
+
with open(filepath, "w", encoding="utf-8") as f:
|
|
68
|
+
f.write(content)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def exists(filepath: str) -> bool:
|
|
72
|
+
"""
|
|
73
|
+
Check if file exists.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
filepath: Path to file
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
True if file exists and is accessible, False otherwise
|
|
80
|
+
"""
|
|
81
|
+
try:
|
|
82
|
+
if _ctx:
|
|
83
|
+
filepath = _ctx.validate_path(filepath)
|
|
84
|
+
return os.path.exists(filepath)
|
|
85
|
+
except PermissionError:
|
|
86
|
+
return False
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# Explicit exports
|
|
90
|
+
__tactus_exports__ = ["read", "write", "exists"]
|
tactus/stdlib/io/fs.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""
|
|
2
|
+
tactus.io.fs - Filesystem helpers for Tactus.
|
|
3
|
+
|
|
4
|
+
Provides safe directory listing and globbing, sandboxed to the procedure's base directory.
|
|
5
|
+
|
|
6
|
+
Usage in .tac files:
|
|
7
|
+
local fs = require("tactus.io.fs")
|
|
8
|
+
|
|
9
|
+
-- List files in a directory
|
|
10
|
+
local entries = fs.list_dir("chapters")
|
|
11
|
+
|
|
12
|
+
-- Glob files (sorted by default)
|
|
13
|
+
local qmd_files = fs.glob("chapters/*.qmd")
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import glob as _glob
|
|
19
|
+
import os
|
|
20
|
+
import sys
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any, Dict, List, Optional
|
|
23
|
+
|
|
24
|
+
# Get context (injected by loader)
|
|
25
|
+
_ctx = getattr(sys.modules[__name__], "__tactus_context__", None)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _base_path() -> str:
|
|
29
|
+
if _ctx and getattr(_ctx, "base_path", None):
|
|
30
|
+
return str(_ctx.base_path)
|
|
31
|
+
return os.getcwd()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _validate_relative_path(path: str) -> None:
|
|
35
|
+
# Keep semantics consistent with other stdlib IO modules:
|
|
36
|
+
# - no absolute paths
|
|
37
|
+
# - no traversal segments
|
|
38
|
+
p = Path(path)
|
|
39
|
+
if p.is_absolute():
|
|
40
|
+
raise PermissionError(f"Absolute paths not allowed: {path}")
|
|
41
|
+
if any(part == ".." for part in p.parts):
|
|
42
|
+
raise PermissionError(f"Path traversal not allowed: {path}")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def list_dir(dirpath: str, options: Optional[Dict[str, Any]] = None) -> List[str]:
|
|
46
|
+
"""
|
|
47
|
+
List entries in a directory.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
dirpath: Directory path (relative to working directory)
|
|
51
|
+
options:
|
|
52
|
+
- files_only: bool (default True) - include only files
|
|
53
|
+
- dirs_only: bool (default False) - include only directories
|
|
54
|
+
- sort: bool (default True) - sort results
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
List of entry paths (relative to working directory)
|
|
58
|
+
"""
|
|
59
|
+
options = options or {}
|
|
60
|
+
files_only = bool(options.get("files_only", True))
|
|
61
|
+
dirs_only = bool(options.get("dirs_only", False))
|
|
62
|
+
sort = bool(options.get("sort", True))
|
|
63
|
+
|
|
64
|
+
_validate_relative_path(dirpath)
|
|
65
|
+
|
|
66
|
+
base = os.path.realpath(_base_path())
|
|
67
|
+
abs_dir = os.path.realpath(os.path.join(base, dirpath))
|
|
68
|
+
|
|
69
|
+
# Ensure the directory itself is within base path (symlink-safe via realpath)
|
|
70
|
+
if abs_dir != base and not abs_dir.startswith(base + os.sep):
|
|
71
|
+
raise PermissionError(f"Access denied: path outside working directory: {dirpath}")
|
|
72
|
+
|
|
73
|
+
if not os.path.isdir(abs_dir):
|
|
74
|
+
raise FileNotFoundError(f"Directory not found: {dirpath}")
|
|
75
|
+
|
|
76
|
+
entries: List[str] = []
|
|
77
|
+
for name in os.listdir(abs_dir):
|
|
78
|
+
abs_child = os.path.realpath(os.path.join(abs_dir, name))
|
|
79
|
+
if abs_child != base and not abs_child.startswith(base + os.sep):
|
|
80
|
+
# Skip symlink escapes
|
|
81
|
+
continue
|
|
82
|
+
|
|
83
|
+
is_dir = os.path.isdir(abs_child)
|
|
84
|
+
is_file = os.path.isfile(abs_child)
|
|
85
|
+
|
|
86
|
+
if dirs_only and not is_dir:
|
|
87
|
+
continue
|
|
88
|
+
if files_only and not is_file:
|
|
89
|
+
continue
|
|
90
|
+
|
|
91
|
+
rel = os.path.relpath(abs_child, base).replace("\\", "/")
|
|
92
|
+
entries.append(rel)
|
|
93
|
+
|
|
94
|
+
if sort:
|
|
95
|
+
entries.sort()
|
|
96
|
+
|
|
97
|
+
return entries
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def glob(pattern: str, options: Optional[Dict[str, Any]] = None) -> List[str]:
|
|
101
|
+
"""
|
|
102
|
+
Glob files within the working directory.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
pattern: Glob pattern (relative to working directory), e.g. "chapters/*.qmd"
|
|
106
|
+
options:
|
|
107
|
+
- recursive: bool (default False) - enable ** patterns
|
|
108
|
+
- files_only: bool (default True) - include only files
|
|
109
|
+
- dirs_only: bool (default False) - include only directories
|
|
110
|
+
- sort: bool (default True) - sort results
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
List of matched paths (relative to working directory)
|
|
114
|
+
"""
|
|
115
|
+
options = options or {}
|
|
116
|
+
recursive = bool(options.get("recursive", False))
|
|
117
|
+
files_only = bool(options.get("files_only", True))
|
|
118
|
+
dirs_only = bool(options.get("dirs_only", False))
|
|
119
|
+
sort = bool(options.get("sort", True))
|
|
120
|
+
|
|
121
|
+
_validate_relative_path(pattern)
|
|
122
|
+
|
|
123
|
+
base = os.path.realpath(_base_path())
|
|
124
|
+
abs_pattern = os.path.realpath(os.path.join(base, pattern))
|
|
125
|
+
|
|
126
|
+
# Ensure the pattern root is within base path (symlink-safe via realpath)
|
|
127
|
+
if abs_pattern != base and not abs_pattern.startswith(base + os.sep):
|
|
128
|
+
raise PermissionError(f"Access denied: path outside working directory: {pattern}")
|
|
129
|
+
|
|
130
|
+
matches: List[str] = []
|
|
131
|
+
for match in _glob.glob(abs_pattern, recursive=recursive):
|
|
132
|
+
abs_match = os.path.realpath(match)
|
|
133
|
+
if abs_match != base and not abs_match.startswith(base + os.sep):
|
|
134
|
+
# Skip symlink escapes
|
|
135
|
+
continue
|
|
136
|
+
|
|
137
|
+
is_dir = os.path.isdir(abs_match)
|
|
138
|
+
is_file = os.path.isfile(abs_match)
|
|
139
|
+
|
|
140
|
+
if dirs_only and not is_dir:
|
|
141
|
+
continue
|
|
142
|
+
if files_only and not is_file:
|
|
143
|
+
continue
|
|
144
|
+
|
|
145
|
+
rel = os.path.relpath(abs_match, base).replace("\\", "/")
|
|
146
|
+
matches.append(rel)
|
|
147
|
+
|
|
148
|
+
if sort:
|
|
149
|
+
matches.sort()
|
|
150
|
+
|
|
151
|
+
return matches
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
__tactus_exports__ = ["list_dir", "glob"]
|