tactus 0.31.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 (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.0.dist-info/METADATA +1809 -0
  157. tactus-0.31.0.dist-info/RECORD +160 -0
  158. tactus-0.31.0.dist-info/WHEEL +4 -0
  159. tactus-0.31.0.dist-info/entry_points.txt +2 -0
  160. tactus-0.31.0.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
+ """
@@ -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"]
@@ -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"]