loopix-sdk 2.30.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 (238) hide show
  1. loopix/__init__.py +260 -0
  2. loopix/api/__init__.py +287 -0
  3. loopix/api/client/__init__.py +8 -0
  4. loopix/api/client/api/__init__.py +1 -0
  5. loopix/api/client/api/sandboxes/__init__.py +1 -0
  6. loopix/api/client/api/sandboxes/delete_sandboxes_sandbox_id.py +161 -0
  7. loopix/api/client/api/sandboxes/get_sandboxes.py +176 -0
  8. loopix/api/client/api/sandboxes/get_sandboxes_metrics.py +173 -0
  9. loopix/api/client/api/sandboxes/get_sandboxes_sandbox_id.py +163 -0
  10. loopix/api/client/api/sandboxes/get_sandboxes_sandbox_id_logs.py +199 -0
  11. loopix/api/client/api/sandboxes/get_sandboxes_sandbox_id_metrics.py +212 -0
  12. loopix/api/client/api/sandboxes/get_v2_sandboxes.py +230 -0
  13. loopix/api/client/api/sandboxes/get_v_2_sandboxes_sandbox_id_logs.py +254 -0
  14. loopix/api/client/api/sandboxes/post_sandboxes.py +172 -0
  15. loopix/api/client/api/sandboxes/post_sandboxes_sandbox_id_connect.py +193 -0
  16. loopix/api/client/api/sandboxes/post_sandboxes_sandbox_id_pause.py +187 -0
  17. loopix/api/client/api/sandboxes/post_sandboxes_sandbox_id_refreshes.py +181 -0
  18. loopix/api/client/api/sandboxes/post_sandboxes_sandbox_id_resume.py +189 -0
  19. loopix/api/client/api/sandboxes/post_sandboxes_sandbox_id_snapshots.py +195 -0
  20. loopix/api/client/api/sandboxes/post_sandboxes_sandbox_id_timeout.py +193 -0
  21. loopix/api/client/api/sandboxes/put_sandboxes_sandbox_id_network.py +199 -0
  22. loopix/api/client/api/snapshots/__init__.py +1 -0
  23. loopix/api/client/api/snapshots/get_snapshots.py +202 -0
  24. loopix/api/client/api/tags/__init__.py +1 -0
  25. loopix/api/client/api/tags/delete_templates_tags.py +174 -0
  26. loopix/api/client/api/tags/get_templates_template_id_tags.py +172 -0
  27. loopix/api/client/api/tags/post_templates_tags.py +176 -0
  28. loopix/api/client/api/templates/__init__.py +1 -0
  29. loopix/api/client/api/templates/delete_templates_template_id.py +157 -0
  30. loopix/api/client/api/templates/get_templates.py +172 -0
  31. loopix/api/client/api/templates/get_templates_aliases_alias.py +167 -0
  32. loopix/api/client/api/templates/get_templates_template_id.py +195 -0
  33. loopix/api/client/api/templates/get_templates_template_id_builds_build_id_logs.py +272 -0
  34. loopix/api/client/api/templates/get_templates_template_id_builds_build_id_status.py +232 -0
  35. loopix/api/client/api/templates/get_templates_template_id_files_hash.py +180 -0
  36. loopix/api/client/api/templates/patch_templates_template_id.py +183 -0
  37. loopix/api/client/api/templates/patch_v_2_templates_template_id.py +185 -0
  38. loopix/api/client/api/templates/post_templates.py +172 -0
  39. loopix/api/client/api/templates/post_templates_template_id.py +181 -0
  40. loopix/api/client/api/templates/post_templates_template_id_builds_build_id.py +170 -0
  41. loopix/api/client/api/templates/post_v2_templates.py +172 -0
  42. loopix/api/client/api/templates/post_v3_templates.py +176 -0
  43. loopix/api/client/api/templates/post_v_2_templates_template_id_builds_build_id.py +192 -0
  44. loopix/api/client/api/volumes/__init__.py +1 -0
  45. loopix/api/client/api/volumes/delete_volumes_volume_id.py +161 -0
  46. loopix/api/client/api/volumes/get_volumes.py +140 -0
  47. loopix/api/client/api/volumes/get_volumes_volume_id.py +163 -0
  48. loopix/api/client/api/volumes/post_volumes.py +172 -0
  49. loopix/api/client/client.py +286 -0
  50. loopix/api/client/errors.py +16 -0
  51. loopix/api/client/models/__init__.py +185 -0
  52. loopix/api/client/models/admin_build_cancel_result.py +67 -0
  53. loopix/api/client/models/admin_sandbox_kill_result.py +67 -0
  54. loopix/api/client/models/assign_template_tags_request.py +67 -0
  55. loopix/api/client/models/assigned_template_tags.py +68 -0
  56. loopix/api/client/models/aws_registry.py +85 -0
  57. loopix/api/client/models/aws_registry_type.py +8 -0
  58. loopix/api/client/models/build_log_entry.py +89 -0
  59. loopix/api/client/models/build_status_reason.py +95 -0
  60. loopix/api/client/models/connect_sandbox.py +59 -0
  61. loopix/api/client/models/created_access_token.py +100 -0
  62. loopix/api/client/models/created_team_api_key.py +166 -0
  63. loopix/api/client/models/delete_template_tags_request.py +67 -0
  64. loopix/api/client/models/disk_metrics.py +91 -0
  65. loopix/api/client/models/error.py +67 -0
  66. loopix/api/client/models/gcp_registry.py +69 -0
  67. loopix/api/client/models/gcp_registry_type.py +8 -0
  68. loopix/api/client/models/general_registry.py +77 -0
  69. loopix/api/client/models/general_registry_type.py +8 -0
  70. loopix/api/client/models/identifier_masking_details.py +83 -0
  71. loopix/api/client/models/listed_sandbox.py +179 -0
  72. loopix/api/client/models/log_level.py +11 -0
  73. loopix/api/client/models/logs_direction.py +9 -0
  74. loopix/api/client/models/logs_source.py +9 -0
  75. loopix/api/client/models/machine_info.py +83 -0
  76. loopix/api/client/models/max_team_metric.py +78 -0
  77. loopix/api/client/models/mcp_type_0.py +44 -0
  78. loopix/api/client/models/new_access_token.py +59 -0
  79. loopix/api/client/models/new_sandbox.py +224 -0
  80. loopix/api/client/models/new_team_api_key.py +59 -0
  81. loopix/api/client/models/new_volume.py +59 -0
  82. loopix/api/client/models/node.py +160 -0
  83. loopix/api/client/models/node_detail.py +160 -0
  84. loopix/api/client/models/node_metrics.py +122 -0
  85. loopix/api/client/models/node_status.py +12 -0
  86. loopix/api/client/models/node_status_change.py +82 -0
  87. loopix/api/client/models/post_sandboxes_sandbox_id_refreshes_body.py +59 -0
  88. loopix/api/client/models/post_sandboxes_sandbox_id_snapshots_body.py +60 -0
  89. loopix/api/client/models/post_sandboxes_sandbox_id_timeout_body.py +59 -0
  90. loopix/api/client/models/resumed_sandbox.py +68 -0
  91. loopix/api/client/models/sandbox.py +145 -0
  92. loopix/api/client/models/sandbox_auto_resume_config.py +60 -0
  93. loopix/api/client/models/sandbox_detail.py +267 -0
  94. loopix/api/client/models/sandbox_lifecycle.py +70 -0
  95. loopix/api/client/models/sandbox_log.py +70 -0
  96. loopix/api/client/models/sandbox_log_entry.py +93 -0
  97. loopix/api/client/models/sandbox_log_entry_fields.py +44 -0
  98. loopix/api/client/models/sandbox_logs.py +91 -0
  99. loopix/api/client/models/sandbox_logs_v2_response.py +73 -0
  100. loopix/api/client/models/sandbox_metric.py +126 -0
  101. loopix/api/client/models/sandbox_network_config.py +118 -0
  102. loopix/api/client/models/sandbox_network_config_rules.py +72 -0
  103. loopix/api/client/models/sandbox_network_rule.py +74 -0
  104. loopix/api/client/models/sandbox_network_transform.py +79 -0
  105. loopix/api/client/models/sandbox_network_transform_headers.py +47 -0
  106. loopix/api/client/models/sandbox_network_update_config.py +114 -0
  107. loopix/api/client/models/sandbox_network_update_config_rules.py +71 -0
  108. loopix/api/client/models/sandbox_on_timeout.py +9 -0
  109. loopix/api/client/models/sandbox_pause_request.py +62 -0
  110. loopix/api/client/models/sandbox_state.py +9 -0
  111. loopix/api/client/models/sandbox_volume_mount.py +67 -0
  112. loopix/api/client/models/sandboxes_with_metrics.py +59 -0
  113. loopix/api/client/models/snapshot_info.py +70 -0
  114. loopix/api/client/models/team.py +83 -0
  115. loopix/api/client/models/team_api_key.py +158 -0
  116. loopix/api/client/models/team_metric.py +86 -0
  117. loopix/api/client/models/team_user.py +75 -0
  118. loopix/api/client/models/template.py +225 -0
  119. loopix/api/client/models/template_alias_response.py +67 -0
  120. loopix/api/client/models/template_build.py +139 -0
  121. loopix/api/client/models/template_build_file_upload.py +70 -0
  122. loopix/api/client/models/template_build_info.py +126 -0
  123. loopix/api/client/models/template_build_logs_response.py +73 -0
  124. loopix/api/client/models/template_build_request.py +115 -0
  125. loopix/api/client/models/template_build_request_v2.py +88 -0
  126. loopix/api/client/models/template_build_request_v3.py +107 -0
  127. loopix/api/client/models/template_build_start_v2.py +184 -0
  128. loopix/api/client/models/template_build_status.py +11 -0
  129. loopix/api/client/models/template_legacy.py +207 -0
  130. loopix/api/client/models/template_request_response_v3.py +99 -0
  131. loopix/api/client/models/template_step.py +91 -0
  132. loopix/api/client/models/template_tag.py +78 -0
  133. loopix/api/client/models/template_update_request.py +59 -0
  134. loopix/api/client/models/template_update_response.py +59 -0
  135. loopix/api/client/models/template_with_builds.py +156 -0
  136. loopix/api/client/models/update_team_api_key.py +59 -0
  137. loopix/api/client/models/volume.py +67 -0
  138. loopix/api/client/models/volume_and_token.py +75 -0
  139. loopix/api/client/models/volume_token.py +59 -0
  140. loopix/api/client/py.typed +1 -0
  141. loopix/api/client/types.py +54 -0
  142. loopix/api/client_async/__init__.py +74 -0
  143. loopix/api/client_sync/__init__.py +73 -0
  144. loopix/api/metadata.py +14 -0
  145. loopix/connection_config.py +309 -0
  146. loopix/envd/api.py +170 -0
  147. loopix/envd/filesystem/filesystem_connect.py +193 -0
  148. loopix/envd/filesystem/filesystem_pb2.py +80 -0
  149. loopix/envd/filesystem/filesystem_pb2.pyi +272 -0
  150. loopix/envd/process/process_connect.py +174 -0
  151. loopix/envd/process/process_pb2.py +96 -0
  152. loopix/envd/process/process_pb2.pyi +316 -0
  153. loopix/envd/rpc.py +139 -0
  154. loopix/envd/versions.py +11 -0
  155. loopix/exceptions.py +133 -0
  156. loopix/io_utils.py +57 -0
  157. loopix/paginator.py +52 -0
  158. loopix/py.typed +0 -0
  159. loopix/sandbox/_git/__init__.py +85 -0
  160. loopix/sandbox/_git/args.py +363 -0
  161. loopix/sandbox/_git/auth.py +132 -0
  162. loopix/sandbox/_git/config.py +32 -0
  163. loopix/sandbox/_git/parse.py +222 -0
  164. loopix/sandbox/_git/types.py +149 -0
  165. loopix/sandbox/commands/command_handle.py +69 -0
  166. loopix/sandbox/commands/main.py +39 -0
  167. loopix/sandbox/filesystem/filesystem.py +337 -0
  168. loopix/sandbox/filesystem/watch_handle.py +70 -0
  169. loopix/sandbox/main.py +227 -0
  170. loopix/sandbox/mcp.py +1949 -0
  171. loopix/sandbox/network.py +8 -0
  172. loopix/sandbox/sandbox_api.py +624 -0
  173. loopix/sandbox/signature.py +47 -0
  174. loopix/sandbox/utils.py +34 -0
  175. loopix/sandbox_async/commands/command.py +396 -0
  176. loopix/sandbox_async/commands/command_handle.py +298 -0
  177. loopix/sandbox_async/commands/pty.py +257 -0
  178. loopix/sandbox_async/filesystem/filesystem.py +720 -0
  179. loopix/sandbox_async/filesystem/watch_handle.py +97 -0
  180. loopix/sandbox_async/git.py +1100 -0
  181. loopix/sandbox_async/main.py +987 -0
  182. loopix/sandbox_async/paginator.py +140 -0
  183. loopix/sandbox_async/sandbox_api.py +504 -0
  184. loopix/sandbox_async/utils.py +7 -0
  185. loopix/sandbox_domains.py +5 -0
  186. loopix/sandbox_sync/commands/command.py +420 -0
  187. loopix/sandbox_sync/commands/command_handle.py +239 -0
  188. loopix/sandbox_sync/commands/pty.py +279 -0
  189. loopix/sandbox_sync/filesystem/filesystem.py +710 -0
  190. loopix/sandbox_sync/filesystem/watch_handle.py +102 -0
  191. loopix/sandbox_sync/git.py +1077 -0
  192. loopix/sandbox_sync/main.py +975 -0
  193. loopix/sandbox_sync/paginator.py +140 -0
  194. loopix/sandbox_sync/sandbox_api.py +491 -0
  195. loopix/template/consts.py +45 -0
  196. loopix/template/dockerfile_parser.py +286 -0
  197. loopix/template/logger.py +232 -0
  198. loopix/template/main.py +1368 -0
  199. loopix/template/readycmd.py +144 -0
  200. loopix/template/types.py +194 -0
  201. loopix/template/utils.py +426 -0
  202. loopix/template_async/build_api.py +419 -0
  203. loopix/template_async/main.py +528 -0
  204. loopix/template_sync/build_api.py +409 -0
  205. loopix/template_sync/main.py +529 -0
  206. loopix/volume/client/__init__.py +8 -0
  207. loopix/volume/client/api/__init__.py +1 -0
  208. loopix/volume/client/api/volumes/__init__.py +1 -0
  209. loopix/volume/client/api/volumes/delete_volumecontent_volume_id_path.py +174 -0
  210. loopix/volume/client/api/volumes/get_volumecontent_volume_id_dir.py +204 -0
  211. loopix/volume/client/api/volumes/get_volumecontent_volume_id_file.py +179 -0
  212. loopix/volume/client/api/volumes/get_volumecontent_volume_id_path.py +176 -0
  213. loopix/volume/client/api/volumes/patch_volumecontent_volume_id_path.py +203 -0
  214. loopix/volume/client/api/volumes/post_volumecontent_volume_id_dir.py +239 -0
  215. loopix/volume/client/api/volumes/put_volumecontent_volume_id_file.py +259 -0
  216. loopix/volume/client/client.py +286 -0
  217. loopix/volume/client/errors.py +16 -0
  218. loopix/volume/client/models/__init__.py +13 -0
  219. loopix/volume/client/models/error.py +67 -0
  220. loopix/volume/client/models/patch_volumecontent_volume_id_path_body.py +77 -0
  221. loopix/volume/client/models/volume_entry_stat.py +145 -0
  222. loopix/volume/client/models/volume_entry_stat_type.py +11 -0
  223. loopix/volume/client/py.typed +1 -0
  224. loopix/volume/client/types.py +54 -0
  225. loopix/volume/client_async/__init__.py +88 -0
  226. loopix/volume/client_sync/__init__.py +80 -0
  227. loopix/volume/connection_config.py +145 -0
  228. loopix/volume/types.py +62 -0
  229. loopix/volume/utils.py +52 -0
  230. loopix/volume/volume_async.py +639 -0
  231. loopix/volume/volume_sync.py +639 -0
  232. loopix_connect/__init__.py +1 -0
  233. loopix_connect/client.py +534 -0
  234. loopix_connect/py.typed +0 -0
  235. loopix_sdk-2.30.0.dist-info/METADATA +98 -0
  236. loopix_sdk-2.30.0.dist-info/RECORD +238 -0
  237. loopix_sdk-2.30.0.dist-info/WHEEL +4 -0
  238. loopix_sdk-2.30.0.dist-info/licenses/LICENSE +9 -0
@@ -0,0 +1,1368 @@
1
+ import json
2
+ import shlex
3
+ from typing import Dict, List, Optional, Union, Literal
4
+ from pathlib import Path
5
+
6
+
7
+ from loopix.exceptions import BuildException, InvalidArgumentException
8
+ from loopix.template.consts import STACK_TRACE_DEPTH, RESOLVE_SYMLINKS
9
+ from loopix.template.dockerfile_parser import parse_dockerfile
10
+ from loopix.template.readycmd import ReadyCmd, wait_for_file
11
+ from loopix.template.types import (
12
+ CopyItem,
13
+ Instruction,
14
+ TemplateType,
15
+ RegistryConfig,
16
+ InstructionType,
17
+ )
18
+ from loopix.template.utils import (
19
+ calculate_files_hash,
20
+ get_caller_directory,
21
+ make_traceback,
22
+ pad_octal,
23
+ read_dockerignore,
24
+ read_gcp_service_account_json,
25
+ get_caller_frame,
26
+ validate_relative_path,
27
+ )
28
+ from types import TracebackType
29
+
30
+
31
+ class TemplateBuilder:
32
+ """
33
+ Builder class for adding instructions to an Loopix template.
34
+
35
+ All methods return self to allow method chaining.
36
+ """
37
+
38
+ def __init__(self, template: "TemplateBase"):
39
+ self._template = template
40
+
41
+ def copy(
42
+ self,
43
+ src: Union[Union[str, Path], List[Union[str, Path]]],
44
+ dest: Union[str, Path],
45
+ force_upload: Optional[Literal[True]] = None,
46
+ user: Optional[str] = None,
47
+ mode: Optional[int] = None,
48
+ resolve_symlinks: Optional[bool] = None,
49
+ gzip: Optional[bool] = None,
50
+ ) -> "TemplateBuilder":
51
+ """
52
+ Copy files or directories from the local filesystem into the template.
53
+
54
+ :param src: Source file(s) or directory path(s) to copy
55
+ :param dest: Destination path in the template
56
+ :param force_upload: Force upload even if files are cached
57
+ :param user: User and optionally group (user:group) to own the files
58
+ :param mode: File permissions in octal format (e.g., 0o755)
59
+ :param resolve_symlinks: Whether to resolve symlinks
60
+ :param gzip: Whether to gzip the files before upload (default: True)
61
+
62
+ :return: `TemplateBuilder` class
63
+
64
+ Example
65
+ ```python
66
+ template.copy('requirements.txt', '/home/user/')
67
+ template.copy(['app.py', 'config.py'], '/app/', mode=0o755)
68
+ ```
69
+ """
70
+ srcs = [src] if isinstance(src, (str, Path)) else src
71
+
72
+ # Get the caller frame for stack trace in validation errors
73
+ caller_frame = get_caller_frame(STACK_TRACE_DEPTH - 1)
74
+ stack_trace = make_traceback(caller_frame)
75
+
76
+ for src_item in srcs:
77
+ src_string = str(src_item)
78
+
79
+ # Validate that the source path is a relative path within the context directory
80
+ validate_relative_path(src_string, stack_trace)
81
+
82
+ args = [
83
+ src_string,
84
+ str(dest),
85
+ user or "",
86
+ pad_octal(mode) if mode else "",
87
+ ]
88
+
89
+ instruction: Instruction = {
90
+ "type": InstructionType.COPY,
91
+ "args": args,
92
+ "force": force_upload or self._template._force_next_layer,
93
+ "forceUpload": force_upload,
94
+ "resolveSymlinks": resolve_symlinks,
95
+ "gzip": gzip,
96
+ }
97
+
98
+ self._template._instructions.append(instruction)
99
+
100
+ # Collect one stack trace per pushed instruction so build steps
101
+ # stay aligned with their stack traces when copying multiple
102
+ # sources
103
+ self._template._collect_stack_trace()
104
+
105
+ return self
106
+
107
+ def copy_items(self, items: List[CopyItem]) -> "TemplateBuilder":
108
+ """
109
+ Copy multiple files or directories using a list of copy items.
110
+
111
+ :param items: List of CopyItem dictionaries with src, dest, and optional parameters
112
+
113
+ :return: `TemplateBuilder` class
114
+
115
+ Example
116
+ ```python
117
+ template.copy_items([
118
+ {'src': 'app.py', 'dest': '/app/'},
119
+ {'src': 'config.py', 'dest': '/app/', 'mode': 0o644}
120
+ ])
121
+ ```
122
+ """
123
+ # Get the stack trace at the copy_items call site
124
+ caller_frame = get_caller_frame(STACK_TRACE_DEPTH - 1)
125
+ stack_trace = make_traceback(caller_frame)
126
+
127
+ def _copy_items():
128
+ for item in items:
129
+ try:
130
+ self.copy(
131
+ item["src"],
132
+ item["dest"],
133
+ item.get("forceUpload"),
134
+ item.get("user"),
135
+ item.get("mode"),
136
+ item.get("resolveSymlinks"),
137
+ item.get("gzip"),
138
+ )
139
+ except Exception as error:
140
+ # Re-raise the error with the captured stack trace
141
+ if stack_trace is not None:
142
+ raise error.with_traceback(stack_trace)
143
+ raise
144
+
145
+ # Use the override so each copied item collects this stack trace,
146
+ # keeping build steps aligned with their stack traces
147
+ self._template._run_in_stack_trace_override_context(_copy_items, stack_trace)
148
+ return self
149
+
150
+ def remove(
151
+ self,
152
+ path: Union[Union[str, Path], List[Union[str, Path]]],
153
+ force: bool = False,
154
+ recursive: bool = False,
155
+ user: Optional[str] = None,
156
+ ) -> "TemplateBuilder":
157
+ """
158
+ Remove files or directories in the template.
159
+
160
+ :param path: File(s) or directory path(s) to remove
161
+ :param force: Force removal without prompting
162
+ :param recursive: Remove directories recursively
163
+ :param user: User to run the command as
164
+
165
+ :return: `TemplateBuilder` class
166
+
167
+ Example
168
+ ```python
169
+ template.remove('/tmp/cache', recursive=True, force=True)
170
+ template.remove('/tmp/cache', recursive=True, force=True, user='root')
171
+ ```
172
+ """
173
+ paths = [path] if isinstance(path, (str, Path)) else path
174
+ args = ["rm"]
175
+ if recursive:
176
+ args.append("-r")
177
+ if force:
178
+ args.append("-f")
179
+ args.extend([shlex.quote(str(p)) for p in paths])
180
+
181
+ return self._template._run_in_new_stack_trace_context(
182
+ lambda: self.run_cmd(" ".join(args), user=user)
183
+ )
184
+
185
+ def rename(
186
+ self,
187
+ src: Union[str, Path],
188
+ dest: Union[str, Path],
189
+ force: bool = False,
190
+ user: Optional[str] = None,
191
+ ) -> "TemplateBuilder":
192
+ """
193
+ Rename or move a file or directory in the template.
194
+
195
+ :param src: Source path
196
+ :param dest: Destination path
197
+ :param force: Force rename without prompting
198
+ :param user: User to run the command as
199
+
200
+ :return: `TemplateBuilder` class
201
+
202
+ Example
203
+ ```python
204
+ template.rename('/tmp/old.txt', '/tmp/new.txt')
205
+ template.rename('/tmp/old.txt', '/tmp/new.txt', user='root')
206
+ ```
207
+ """
208
+ args = ["mv", shlex.quote(str(src)), shlex.quote(str(dest))]
209
+ if force:
210
+ args.append("-f")
211
+
212
+ return self._template._run_in_new_stack_trace_context(
213
+ lambda: self.run_cmd(" ".join(args), user=user)
214
+ )
215
+
216
+ def make_dir(
217
+ self,
218
+ path: Union[Union[str, Path], List[Union[str, Path]]],
219
+ mode: Optional[int] = None,
220
+ user: Optional[str] = None,
221
+ ) -> "TemplateBuilder":
222
+ """
223
+ Create directory(ies) in the template.
224
+
225
+ :param path: Directory path(s) to create
226
+ :param mode: Directory permissions in octal format (e.g., 0o755)
227
+ :param user: User to run the command as
228
+
229
+ :return: `TemplateBuilder` class
230
+
231
+ Example
232
+ ```python
233
+ template.make_dir('/app/data', mode=0o755)
234
+ template.make_dir(['/app/logs', '/app/cache'])
235
+ template.make_dir('/app/data', mode=0o755, user='root')
236
+ ```
237
+ """
238
+ path_list = [path] if isinstance(path, (str, Path)) else path
239
+ args = ["mkdir", "-p"]
240
+ if mode:
241
+ args.append(f"-m {pad_octal(mode)}")
242
+ args.extend([shlex.quote(str(p)) for p in path_list])
243
+
244
+ return self._template._run_in_new_stack_trace_context(
245
+ lambda: self.run_cmd(" ".join(args), user=user)
246
+ )
247
+
248
+ def make_symlink(
249
+ self,
250
+ src: Union[str, Path],
251
+ dest: Union[str, Path],
252
+ user: Optional[str] = None,
253
+ force: bool = False,
254
+ ) -> "TemplateBuilder":
255
+ """
256
+ Create a symbolic link in the template.
257
+
258
+ :param src: Source path (target of the symlink)
259
+ :param dest: Destination path (location of the symlink)
260
+ :param user: User to run the command as
261
+ :param force: Force symlink without prompting
262
+
263
+ :return: `TemplateBuilder` class
264
+
265
+ Example
266
+ ```python
267
+ template.make_symlink('/usr/bin/python3', '/usr/bin/python')
268
+ template.make_symlink('/usr/bin/python3', '/usr/bin/python', user='root')
269
+ template.make_symlink('/usr/bin/python3', '/usr/bin/python', force=True)
270
+ ```
271
+ """
272
+ args = ["ln", "-s"]
273
+ if force:
274
+ args.append("-f")
275
+ args.extend([shlex.quote(str(src)), shlex.quote(str(dest))])
276
+ return self._template._run_in_new_stack_trace_context(
277
+ lambda: self.run_cmd(" ".join(args), user=user)
278
+ )
279
+
280
+ def run_cmd(
281
+ self, command: Union[str, List[str]], user: Optional[str] = None
282
+ ) -> "TemplateBuilder":
283
+ """
284
+ Run a shell command during template build.
285
+
286
+ :param command: Command string or list of commands to run (joined with &&)
287
+ :param user: User to run the command as
288
+
289
+ :return: `TemplateBuilder` class
290
+
291
+ Example
292
+ ```python
293
+ template.run_cmd('apt-get update')
294
+ template.run_cmd(['pip install numpy', 'pip install pandas'])
295
+ template.run_cmd('apt-get install vim', user='root')
296
+ ```
297
+ """
298
+ commands = [command] if isinstance(command, str) else command
299
+ args = [" && ".join(commands)]
300
+
301
+ if user:
302
+ args.append(user)
303
+
304
+ instruction: Instruction = {
305
+ "type": InstructionType.RUN,
306
+ "args": args,
307
+ "force": self._template._force_next_layer,
308
+ "forceUpload": None,
309
+ }
310
+ self._template._instructions.append(instruction)
311
+ self._template._collect_stack_trace()
312
+ return self
313
+
314
+ def set_workdir(self, workdir: Union[str, Path]) -> "TemplateBuilder":
315
+ """
316
+ Set the working directory for subsequent commands in the template.
317
+
318
+ :param workdir: Path to set as the working directory
319
+
320
+ :return: `TemplateBuilder` class
321
+
322
+ Example
323
+ ```python
324
+ template.set_workdir('/app')
325
+ ```
326
+ """
327
+ instruction: Instruction = {
328
+ "type": InstructionType.WORKDIR,
329
+ "args": [str(workdir)],
330
+ "force": self._template._force_next_layer,
331
+ "forceUpload": None,
332
+ }
333
+ self._template._instructions.append(instruction)
334
+ self._template._collect_stack_trace()
335
+ return self
336
+
337
+ def set_user(self, user: str) -> "TemplateBuilder":
338
+ """
339
+ Set the user for subsequent commands in the template.
340
+
341
+ :param user: Username to set
342
+
343
+ :return: `TemplateBuilder` class
344
+
345
+ Example
346
+ ```python
347
+ template.set_user('root')
348
+ ```
349
+ """
350
+ instruction: Instruction = {
351
+ "type": InstructionType.USER,
352
+ "args": [user],
353
+ "force": self._template._force_next_layer,
354
+ "forceUpload": None,
355
+ }
356
+ self._template._instructions.append(instruction)
357
+ self._template._collect_stack_trace()
358
+ return self
359
+
360
+ def pip_install(
361
+ self, packages: Optional[Union[str, List[str]]] = None, g: bool = True
362
+ ) -> "TemplateBuilder":
363
+ """
364
+ Install Python packages using pip.
365
+
366
+ :param packages: Package name(s) to install. If None, runs 'pip install .' in the current directory
367
+ :param g: Install packages globally (default: True). If False, installs for user only
368
+
369
+ :return: `TemplateBuilder` class
370
+
371
+ Example
372
+ ```python
373
+ template.pip_install('numpy')
374
+ template.pip_install(['pandas', 'scikit-learn'])
375
+ template.pip_install('numpy', g=False) # Install for user only
376
+ template.pip_install() # Installs from current directory
377
+ ```
378
+ """
379
+ if isinstance(packages, str):
380
+ packages = [packages]
381
+
382
+ args = ["pip", "install"]
383
+ if not g:
384
+ args.append("--user")
385
+ if packages:
386
+ args.extend(packages)
387
+ else:
388
+ args.append(".")
389
+
390
+ return self._template._run_in_new_stack_trace_context(
391
+ lambda: self.run_cmd(" ".join(args), user="root" if g else None)
392
+ )
393
+
394
+ def npm_install(
395
+ self,
396
+ packages: Optional[Union[str, List[str]]] = None,
397
+ g: Optional[bool] = False,
398
+ dev: Optional[bool] = False,
399
+ ) -> "TemplateBuilder":
400
+ """
401
+ Install Node.js packages using npm.
402
+
403
+ :param packages: Package name(s) to install. If None, installs from package.json
404
+ :param g: Install packages globally
405
+ :param dev: Install packages as dev dependencies
406
+
407
+ :return: `TemplateBuilder` class
408
+
409
+ Example
410
+ ```python
411
+ template.npm_install('express')
412
+ template.npm_install(['lodash', 'axios'])
413
+ template.npm_install('typescript', g=True)
414
+ template.npm_install() # Installs from package.json
415
+ ```
416
+ """
417
+ if isinstance(packages, str):
418
+ packages = [packages]
419
+
420
+ args = ["npm", "install"]
421
+ if g:
422
+ args.append("-g")
423
+ if dev:
424
+ args.append("--save-dev")
425
+ if packages:
426
+ args.extend(packages)
427
+
428
+ return self._template._run_in_new_stack_trace_context(
429
+ lambda: self.run_cmd(" ".join(args), user="root" if g else None)
430
+ )
431
+
432
+ def bun_install(
433
+ self,
434
+ packages: Optional[Union[str, List[str]]] = None,
435
+ g: Optional[bool] = False,
436
+ dev: Optional[bool] = False,
437
+ ) -> "TemplateBuilder":
438
+ """
439
+ Install Bun packages using bun.
440
+
441
+ :param packages: Package name(s) to install. If None, installs from package.json
442
+ :param g: Install packages globally
443
+ :param dev: Install packages as dev dependencies
444
+
445
+ :return: `TemplateBuilder` class
446
+
447
+ Example
448
+ ```python
449
+ template.bun_install('express')
450
+ template.bun_install(['lodash', 'axios'])
451
+ template.bun_install('tsx', g=True)
452
+ template.bun_install('typescript', dev=True)
453
+ template.bun_install() // Installs from package.json
454
+ ```
455
+ """
456
+ if isinstance(packages, str):
457
+ packages = [packages]
458
+
459
+ args = ["bun", "install"]
460
+ if g:
461
+ args.append("-g")
462
+ if dev:
463
+ args.append("--dev")
464
+ if packages:
465
+ args.extend(packages)
466
+
467
+ return self._template._run_in_new_stack_trace_context(
468
+ lambda: self.run_cmd(" ".join(args), user="root" if g else None)
469
+ )
470
+
471
+ def apt_install(
472
+ self,
473
+ packages: Union[str, List[str]],
474
+ no_install_recommends: bool = False,
475
+ fix_missing: bool = False,
476
+ ) -> "TemplateBuilder":
477
+ """
478
+ Install system packages using apt-get.
479
+
480
+ :param packages: Package name(s) to install
481
+ :param no_install_recommends: Whether to install recommended packages
482
+ :param fix_missing: Whether to fix missing packages
483
+
484
+ :return: `TemplateBuilder` class
485
+
486
+ Example
487
+ ```python
488
+ template.apt_install('vim')
489
+ template.apt_install(['git', 'curl', 'wget'])
490
+ template.apt_install('vim', fix_missing=True)
491
+ ```
492
+ """
493
+ if isinstance(packages, str):
494
+ packages = [packages]
495
+
496
+ return self._template._run_in_new_stack_trace_context(
497
+ lambda: self.run_cmd(
498
+ [
499
+ "apt-get update",
500
+ f"DEBIAN_FRONTEND=noninteractive DEBCONF_NOWARNINGS=yes apt-get install -y {'--no-install-recommends ' if no_install_recommends else ''}{'--fix-missing ' if fix_missing else ''}{' '.join(packages)}",
501
+ ],
502
+ user="root",
503
+ )
504
+ )
505
+
506
+ def add_mcp_server(self, servers: Union[str, List[str]]) -> "TemplateBuilder":
507
+ """
508
+ Install MCP servers using mcp-gateway.
509
+
510
+ Note: Requires a base image with mcp-gateway pre-installed (e.g., mcp-gateway).
511
+
512
+ :param servers: MCP server name(s)
513
+
514
+ :return: `TemplateBuilder` class
515
+
516
+ Example
517
+ ```python
518
+ template.add_mcp_server('exa')
519
+ template.add_mcp_server(['brave', 'firecrawl', 'duckduckgo'])
520
+ ```
521
+ """
522
+ if self._template._base_template != "mcp-gateway":
523
+ caller_frame = get_caller_frame(STACK_TRACE_DEPTH - 1)
524
+ stack_trace = make_traceback(caller_frame)
525
+ raise BuildException(
526
+ "MCP servers can only be added to mcp-gateway template"
527
+ ).with_traceback(stack_trace)
528
+
529
+ if isinstance(servers, str):
530
+ servers = [servers]
531
+
532
+ return self._template._run_in_new_stack_trace_context(
533
+ lambda: self.run_cmd(f"mcp-gateway pull {' '.join(servers)}", user="root")
534
+ )
535
+
536
+ def git_clone(
537
+ self,
538
+ url: str,
539
+ path: Optional[Union[str, Path]] = None,
540
+ branch: Optional[str] = None,
541
+ depth: Optional[int] = None,
542
+ user: Optional[str] = None,
543
+ ) -> "TemplateBuilder":
544
+ """
545
+ Clone a git repository into the template.
546
+
547
+ :param url: Git repository URL
548
+ :param path: Destination path for the clone
549
+ :param branch: Branch to clone
550
+ :param depth: Clone depth for shallow clones
551
+ :param user: User to run the command as
552
+
553
+ :return: `TemplateBuilder` class
554
+
555
+ Example
556
+ ```python
557
+ template.git_clone('https://github.com/user/repo.git', '/app/repo')
558
+ template.git_clone('https://github.com/user/repo.git', branch='main', depth=1)
559
+ template.git_clone('https://github.com/user/repo.git', '/app/repo', user='root')
560
+ ```
561
+ """
562
+ args = ["git", "clone", shlex.quote(url)]
563
+ if branch:
564
+ args.append(f"--branch {shlex.quote(branch)}")
565
+ args.append("--single-branch")
566
+ if depth:
567
+ args.append(f"--depth {depth}")
568
+ if path:
569
+ args.append(shlex.quote(str(path)))
570
+ return self._template._run_in_new_stack_trace_context(
571
+ lambda: self.run_cmd(" ".join(args), user=user)
572
+ )
573
+
574
+ def beta_dev_container_prebuild(
575
+ self,
576
+ devcontainer_directory: Union[str, Path],
577
+ ) -> "TemplateBuilder":
578
+ """
579
+ Prebuild a devcontainer from the specified directory during the build process.
580
+
581
+ :param devcontainer_directory: Path to the devcontainer directory
582
+
583
+ :return: `TemplateBuilder` class
584
+
585
+ Example
586
+ ```python
587
+ template.git_clone('https://myrepo.com/project.git', '/my-devcontainer')
588
+ template.beta_dev_container_prebuild('/my-devcontainer')
589
+ ```
590
+ """
591
+ if self._template._base_template != "devcontainer":
592
+ caller_frame = get_caller_frame(STACK_TRACE_DEPTH - 1)
593
+ stack_trace = make_traceback(caller_frame)
594
+ raise BuildException(
595
+ "Devcontainers can only used in the devcontainer template"
596
+ ).with_traceback(stack_trace)
597
+
598
+ return self._template._run_in_new_stack_trace_context(
599
+ lambda: self.run_cmd(
600
+ f"devcontainer build --workspace-folder {shlex.quote(str(devcontainer_directory))}",
601
+ user="root",
602
+ )
603
+ )
604
+
605
+ def beta_set_dev_container_start(
606
+ self,
607
+ devcontainer_directory: Union[str, Path],
608
+ ) -> "TemplateFinal":
609
+ """
610
+ Start a devcontainer from the specified directory and set it as the start command.
611
+
612
+ This method returns `TemplateFinal`, which means it must be the last method in the chain.
613
+
614
+ :param devcontainer_directory: Path to the devcontainer directory
615
+
616
+ :return: `TemplateFinal` class
617
+
618
+ Example
619
+ ```python
620
+ # Simple start
621
+ template.git_clone('https://myrepo.com/project.git', '/my-devcontainer')
622
+ template.beta_set_devcontainer_start('/my-devcontainer')
623
+
624
+ # With prebuild
625
+ template.git_clone('https://myrepo.com/project.git', '/my-devcontainer')
626
+ template.beta_dev_container_prebuild('/my-devcontainer')
627
+ template.beta_set_dev_container_start('/my-devcontainer')
628
+ ```
629
+ """
630
+ if self._template._base_template != "devcontainer":
631
+ caller_frame = get_caller_frame(STACK_TRACE_DEPTH - 1)
632
+ stack_trace = make_traceback(caller_frame)
633
+ raise BuildException(
634
+ "Devcontainers can only used in the devcontainer template"
635
+ ).with_traceback(stack_trace)
636
+
637
+ def _set_start():
638
+ dir_ = shlex.quote(str(devcontainer_directory))
639
+ return self.set_start_cmd(
640
+ "sudo devcontainer up --workspace-folder "
641
+ + dir_
642
+ + " && sudo /prepare-exec.sh "
643
+ + dir_
644
+ + " | sudo tee /devcontainer.sh > /dev/null && sudo chmod +x /devcontainer.sh && sudo touch /devcontainer.up",
645
+ wait_for_file("/devcontainer.up"),
646
+ )
647
+
648
+ return self._template._run_in_new_stack_trace_context(_set_start)
649
+
650
+ def set_envs(self, envs: Dict[str, str]) -> "TemplateBuilder":
651
+ """
652
+ Set environment variables.
653
+ Note: Environment variables defined here are available only during template build.
654
+
655
+ :param envs: Dictionary of environment variable names and values
656
+
657
+ :return: `TemplateBuilder` class
658
+
659
+ Example
660
+ ```python
661
+ template.set_envs({'NODE_ENV': 'production', 'PORT': '8080'})
662
+ ```
663
+ """
664
+ if len(envs) == 0:
665
+ return self
666
+
667
+ instruction: Instruction = {
668
+ "type": InstructionType.ENV,
669
+ "args": [item for key, value in envs.items() for item in [key, value]],
670
+ "force": self._template._force_next_layer,
671
+ "forceUpload": None,
672
+ }
673
+ self._template._instructions.append(instruction)
674
+ self._template._collect_stack_trace()
675
+ return self
676
+
677
+ def skip_cache(self) -> "TemplateBuilder":
678
+ """
679
+ Skip cache for all subsequent build instructions from this point.
680
+
681
+ Call this before any instruction to force it and all following layers
682
+ to be rebuilt, ignoring any cached layers.
683
+
684
+ :return: `TemplateBuilder` class
685
+
686
+ Example
687
+ ```python
688
+ template.skip_cache().run_cmd('apt-get update')
689
+ ```
690
+ """
691
+ self._template._force_next_layer = True
692
+ return self
693
+
694
+ def set_start_cmd(
695
+ self, start_cmd: str, ready_cmd: Union[str, ReadyCmd]
696
+ ) -> "TemplateFinal":
697
+ """
698
+ Set the command to start when the sandbox launches and the ready check command.
699
+
700
+ :param start_cmd: Command to run when the sandbox starts
701
+ :param ready_cmd: Command or ReadyCmd to check if the sandbox is ready
702
+
703
+ :return: `TemplateFinal` class
704
+
705
+ Example
706
+ ```python
707
+ # Using a string command
708
+ template.set_start_cmd(
709
+ 'python app.py',
710
+ 'curl http://localhost:8000/health'
711
+ )
712
+
713
+ # Using ReadyCmd helpers
714
+ from loopix import wait_for_port, wait_for_url
715
+
716
+ template.set_start_cmd(
717
+ 'python -m http.server 8000',
718
+ wait_for_port(8000)
719
+ )
720
+
721
+ template.set_start_cmd(
722
+ 'npm start',
723
+ wait_for_url('http://localhost:3000/health', 200)
724
+ )
725
+ ```
726
+ """
727
+ self._template._start_cmd = start_cmd
728
+
729
+ if isinstance(ready_cmd, ReadyCmd):
730
+ ready_cmd = ready_cmd.get_cmd()
731
+
732
+ self._template._ready_cmd = ready_cmd
733
+ self._template._collect_stack_trace()
734
+ return TemplateFinal(self._template)
735
+
736
+ def set_ready_cmd(self, ready_cmd: Union[str, ReadyCmd]) -> "TemplateFinal":
737
+ """
738
+ Set the command to check if the sandbox is ready.
739
+
740
+ :param ready_cmd: Command or ReadyCmd to check if the sandbox is ready
741
+
742
+ :return: `TemplateFinal` class
743
+
744
+ Example
745
+ ```python
746
+ # Using a string command
747
+ template.set_ready_cmd('curl http://localhost:8000/health')
748
+
749
+ # Using ReadyCmd helpers
750
+ from loopix import wait_for_port, wait_for_file, wait_for_process
751
+
752
+ template.set_ready_cmd(wait_for_port(3000))
753
+
754
+ template.set_ready_cmd(wait_for_file('/tmp/ready'))
755
+
756
+ template.set_ready_cmd(wait_for_process('nginx'))
757
+ ```
758
+ """
759
+ if isinstance(ready_cmd, ReadyCmd):
760
+ ready_cmd = ready_cmd.get_cmd()
761
+
762
+ self._template._ready_cmd = ready_cmd
763
+ self._template._collect_stack_trace()
764
+ return TemplateFinal(self._template)
765
+
766
+
767
+ class TemplateFinal:
768
+ """
769
+ Final template state after start/ready commands are set.
770
+ """
771
+
772
+ def __init__(self, template: "TemplateBase"):
773
+ self._template = template
774
+
775
+
776
+ class TemplateBase:
777
+ """
778
+ Base class for building Loopix sandbox templates.
779
+ """
780
+
781
+ _logs_refresh_frequency = 0.2
782
+
783
+ def __init__(
784
+ self,
785
+ file_context_path: Optional[Union[str, Path]] = None,
786
+ file_ignore_patterns: Optional[List[str]] = None,
787
+ ):
788
+ """
789
+ Create a new template builder instance.
790
+
791
+ :param file_context_path: Base path for resolving relative file paths in copy operations
792
+ :param file_ignore_patterns: List of glob patterns to ignore when copying files
793
+ """
794
+ self._default_base_image: str = "loopix/base"
795
+ self._base_image: Optional[str] = self._default_base_image
796
+ self._base_template: Optional[str] = None
797
+ self._registry_config: Optional[RegistryConfig] = None
798
+ self._start_cmd: Optional[str] = None
799
+ self._ready_cmd: Optional[str] = None
800
+ # Force the whole template to be rebuilt
801
+ self._force: bool = False
802
+ # Force the next layer to be rebuilt
803
+ self._force_next_layer: bool = False
804
+ self._instructions: List[Instruction] = []
805
+ # If no file_context_path is provided, use the caller's directory
806
+ self._file_context_path = (
807
+ file_context_path.as_posix()
808
+ if isinstance(file_context_path, Path)
809
+ else (file_context_path or get_caller_directory(STACK_TRACE_DEPTH) or ".")
810
+ )
811
+ self._file_ignore_patterns: List[str] = file_ignore_patterns or []
812
+ self._stack_traces: List[Union[TracebackType, None]] = []
813
+ self._stack_traces_enabled: bool = True
814
+ self._stack_traces_override: Optional[Union[TracebackType, None]] = None
815
+
816
+ def skip_cache(self) -> "TemplateBase":
817
+ """
818
+ Skip cache for all subsequent build instructions from this point.
819
+
820
+ :return: `TemplateBase` class
821
+
822
+ Example
823
+ ```python
824
+ template.skip_cache().from_python_image('3.11')
825
+ ```
826
+ """
827
+ self._force_next_layer = True
828
+ return self
829
+
830
+ def _collect_stack_trace(
831
+ self, stack_traces_depth: int = STACK_TRACE_DEPTH
832
+ ) -> "TemplateBase":
833
+ """
834
+ Collect the current stack trace for debugging purposes.
835
+
836
+ :param stack_traces_depth: Depth to traverse in the call stack
837
+
838
+ :return: `TemplateBase` class
839
+ """
840
+ if not self._stack_traces_enabled:
841
+ return self
842
+
843
+ # Use the override if set, otherwise get the caller frame
844
+ if self._stack_traces_override is not None:
845
+ self._stack_traces.append(self._stack_traces_override)
846
+ return self
847
+
848
+ stack = get_caller_frame(stack_traces_depth)
849
+ self._stack_traces.append(make_traceback(stack))
850
+ return self
851
+
852
+ def _disable_stack_trace(self) -> "TemplateBase":
853
+ """
854
+ Temporarily disable stack trace collection.
855
+
856
+ :return: `TemplateBase` class
857
+ """
858
+ self._stack_traces_enabled = False
859
+ return self
860
+
861
+ def _enable_stack_trace(self) -> "TemplateBase":
862
+ """
863
+ Re-enable stack trace collection.
864
+
865
+ :return: `TemplateBase` class
866
+ """
867
+ self._stack_traces_enabled = True
868
+ return self
869
+
870
+ def _run_in_new_stack_trace_context(self, fn):
871
+ """
872
+ Execute a function in a clean stack trace context.
873
+
874
+ :param fn: Function to execute
875
+
876
+ :return: The result of the function
877
+ """
878
+ self._disable_stack_trace()
879
+ try:
880
+ result = fn()
881
+ finally:
882
+ self._enable_stack_trace()
883
+ self._collect_stack_trace(STACK_TRACE_DEPTH + 1)
884
+ return result
885
+
886
+ def _run_in_stack_trace_override_context(
887
+ self, fn, stack_trace_override: Optional[Union[TracebackType, None]]
888
+ ):
889
+ """
890
+ Execute a function with a manual stack trace override.
891
+
892
+ :param fn: Function to execute
893
+ :param stack_trace_override: Stack trace to use instead of auto-collecting
894
+
895
+ :return: The result of the function
896
+ """
897
+ self._stack_traces_override = stack_trace_override
898
+ try:
899
+ return fn()
900
+ finally:
901
+ self._stack_traces_override = None
902
+
903
+ # Built-in image mixins
904
+ def from_debian_image(self, variant: str = "stable") -> TemplateBuilder:
905
+ """
906
+ Start template from a Debian base image.
907
+
908
+ :param variant: Debian image variant
909
+
910
+ :return: `TemplateBuilder` class
911
+
912
+ Example
913
+ ```python
914
+ Template().from_debian_image('bookworm')
915
+ ```
916
+ """
917
+ return self._run_in_new_stack_trace_context(
918
+ lambda: self.from_image(f"debian:{variant}")
919
+ )
920
+
921
+ def from_ubuntu_image(self, variant: str = "latest") -> TemplateBuilder:
922
+ """
923
+ Start template from an Ubuntu base image.
924
+
925
+ :param variant: Ubuntu image variant (default: 'latest')
926
+
927
+ :return: `TemplateBuilder` class
928
+
929
+ Example
930
+ ```python
931
+ Template().from_ubuntu_image('24.04')
932
+ ```
933
+ """
934
+ return self._run_in_new_stack_trace_context(
935
+ lambda: self.from_image(f"ubuntu:{variant}")
936
+ )
937
+
938
+ def from_python_image(self, version: str = "3") -> TemplateBuilder:
939
+ """
940
+ Start template from a Python base image.
941
+
942
+ :param version: Python version (default: '3')
943
+
944
+ :return: `TemplateBuilder` class
945
+
946
+ Example
947
+ ```python
948
+ Template().from_python_image('3')
949
+ ```
950
+ """
951
+ return self._run_in_new_stack_trace_context(
952
+ lambda: self.from_image(f"python:{version}")
953
+ )
954
+
955
+ def from_node_image(self, variant: str = "lts") -> TemplateBuilder:
956
+ """
957
+ Start template from a Node.js base image.
958
+
959
+ :param variant: Node.js image variant (default: 'lts')
960
+
961
+ :return: `TemplateBuilder` class
962
+
963
+ Example
964
+ ```python
965
+ Template().from_node_image('24')
966
+ ```
967
+ """
968
+ return self._run_in_new_stack_trace_context(
969
+ lambda: self.from_image(f"node:{variant}")
970
+ )
971
+
972
+ def from_bun_image(self, variant: str = "latest") -> TemplateBuilder:
973
+ """
974
+ Start template from a Bun base image.
975
+
976
+ :param variant: Bun image variant (default: 'latest')
977
+
978
+ :return: `TemplateBuilder` class
979
+ """
980
+ return self._run_in_new_stack_trace_context(
981
+ lambda: self.from_image(f"oven/bun:{variant}")
982
+ )
983
+
984
+ def from_base_image(self) -> TemplateBuilder:
985
+ """
986
+ Start template from the Loopix base image (loopix/base:latest).
987
+
988
+ :return: `TemplateBuilder` class
989
+
990
+ Example
991
+ ```python
992
+ Template().from_base_image()
993
+ ```
994
+ """
995
+ return self._run_in_new_stack_trace_context(
996
+ lambda: self.from_image(self._default_base_image)
997
+ )
998
+
999
+ def from_image(
1000
+ self,
1001
+ image: str,
1002
+ username: Optional[str] = None,
1003
+ password: Optional[str] = None,
1004
+ ) -> TemplateBuilder:
1005
+ """
1006
+ Start template from a Docker image.
1007
+
1008
+ :param image: Docker image name (e.g., 'ubuntu:24.04')
1009
+ :param username: Username for private registry authentication
1010
+ :param password: Password for private registry authentication
1011
+
1012
+ :return: `TemplateBuilder` class
1013
+
1014
+ Example
1015
+ ```python
1016
+ Template().from_image('python:3')
1017
+
1018
+ # With credentials (optional)
1019
+ Template().from_image('myregistry.com/myimage:latest', username='user', password='pass')
1020
+ ```
1021
+ """
1022
+ # Validate (and resolve the registry config) before mutating the builder.
1023
+ if username is not None or password is not None:
1024
+ if not username or not password:
1025
+ caller_frame = get_caller_frame(STACK_TRACE_DEPTH - 1)
1026
+ stack_trace = make_traceback(caller_frame)
1027
+ raise InvalidArgumentException(
1028
+ "Both username and password are required when providing registry credentials"
1029
+ ).with_traceback(stack_trace)
1030
+
1031
+ self._registry_config = {
1032
+ "type": "registry",
1033
+ "username": username,
1034
+ "password": password,
1035
+ }
1036
+
1037
+ self._base_image = image
1038
+ self._base_template = None
1039
+
1040
+ # If we should force the next layer and it's a FROM command, invalidate whole template
1041
+ if self._force_next_layer:
1042
+ self._force = True
1043
+
1044
+ self._collect_stack_trace()
1045
+ return TemplateBuilder(self)
1046
+
1047
+ def from_template(self, template: str) -> TemplateBuilder:
1048
+ """
1049
+ Start template from an existing Loopix template.
1050
+
1051
+ :param template: Loopix template ID or alias
1052
+
1053
+ :return: `TemplateBuilder` class
1054
+
1055
+ Example
1056
+ ```python
1057
+ Template().from_template('my-base-template')
1058
+ ```
1059
+ """
1060
+ self._base_template = template
1061
+ self._base_image = None
1062
+
1063
+ # If we should force the next layer and it's a FROM command, invalidate whole template
1064
+ if self._force_next_layer:
1065
+ self._force = True
1066
+
1067
+ self._collect_stack_trace()
1068
+ return TemplateBuilder(self)
1069
+
1070
+ def from_dockerfile(self, dockerfile_content_or_path: str) -> TemplateBuilder:
1071
+ """
1072
+ Parse a Dockerfile and convert it to Template SDK format.
1073
+
1074
+ :param dockerfile_content_or_path: Either the Dockerfile content as a string, or a path to a Dockerfile file
1075
+
1076
+ :return: `TemplateBuilder` class
1077
+
1078
+ Example
1079
+ ```python
1080
+ Template().from_dockerfile('Dockerfile')
1081
+ Template().from_dockerfile('FROM python:3\\nRUN pip install numpy')
1082
+ ```
1083
+ """
1084
+ # Create a TemplateBuilder first to use its methods
1085
+ builder = TemplateBuilder(self)
1086
+
1087
+ # Get the caller frame to use for stack trace override
1088
+ # -1 as we're going up the call stack from the parse_dockerfile function
1089
+ caller_frame = get_caller_frame(STACK_TRACE_DEPTH - 1)
1090
+ stack_trace_override = make_traceback(caller_frame)
1091
+
1092
+ # Parse the dockerfile using the builder as the interface
1093
+ base_image = self._run_in_stack_trace_override_context(
1094
+ lambda: parse_dockerfile(dockerfile_content_or_path, builder),
1095
+ stack_trace_override,
1096
+ )
1097
+ self._base_image = base_image
1098
+
1099
+ # If we should force the next layer and it's a FROM command, invalidate whole template
1100
+ if self._force_next_layer:
1101
+ self._force = True
1102
+
1103
+ self._collect_stack_trace()
1104
+ return builder
1105
+
1106
+ def from_aws_registry(
1107
+ self,
1108
+ image: str,
1109
+ access_key_id: str,
1110
+ secret_access_key: str,
1111
+ region: str,
1112
+ ) -> TemplateBuilder:
1113
+ """
1114
+ Start template from an AWS ECR registry image.
1115
+
1116
+ :param image: Docker image name from AWS ECR
1117
+ :param access_key_id: AWS access key ID
1118
+ :param secret_access_key: AWS secret access key
1119
+ :param region: AWS region
1120
+
1121
+ :return: `TemplateBuilder` class
1122
+
1123
+ Example
1124
+ ```python
1125
+ Template().from_aws_registry(
1126
+ '123456789.dkr.ecr.us-west-2.amazonaws.com/myimage:latest',
1127
+ access_key_id='AKIA...',
1128
+ secret_access_key='...',
1129
+ region='us-west-2'
1130
+ )
1131
+ ```
1132
+ """
1133
+ self._base_image = image
1134
+ self._base_template = None
1135
+
1136
+ # Set the registry config if provided
1137
+ self._registry_config = {
1138
+ "type": "aws",
1139
+ "awsAccessKeyId": access_key_id,
1140
+ "awsSecretAccessKey": secret_access_key,
1141
+ "awsRegion": region,
1142
+ }
1143
+
1144
+ # If we should force the next layer and it's a FROM command, invalidate whole template
1145
+ if self._force_next_layer:
1146
+ self._force = True
1147
+
1148
+ self._collect_stack_trace()
1149
+ return TemplateBuilder(self)
1150
+
1151
+ def from_gcp_registry(
1152
+ self, image: str, service_account_json: Union[str, dict]
1153
+ ) -> TemplateBuilder:
1154
+ """
1155
+ Start template from a GCP Artifact Registry or Container Registry image.
1156
+
1157
+ :param image: Docker image name from GCP registry
1158
+ :param service_account_json: Service account JSON string, dict, or path to JSON file
1159
+
1160
+ :return: `TemplateBuilder` class
1161
+
1162
+ Example
1163
+ ```python
1164
+ Template().from_gcp_registry(
1165
+ 'gcr.io/myproject/myimage:latest',
1166
+ service_account_json='path/to/service-account.json'
1167
+ )
1168
+ ```
1169
+ """
1170
+ self._base_image = image
1171
+ self._base_template = None
1172
+
1173
+ # Set the registry config if provided
1174
+ self._registry_config = {
1175
+ "type": "gcp",
1176
+ "serviceAccountJson": read_gcp_service_account_json(
1177
+ self._file_context_path, service_account_json
1178
+ ),
1179
+ }
1180
+
1181
+ # If we should force the next layer and it's a FROM command, invalidate whole template
1182
+ if self._force_next_layer:
1183
+ self._force = True
1184
+
1185
+ self._collect_stack_trace()
1186
+ return TemplateBuilder(self)
1187
+
1188
+ @staticmethod
1189
+ def to_json(template: "TemplateClass") -> str:
1190
+ """
1191
+ Convert a template to JSON representation.
1192
+
1193
+ :param template: The template to convert (TemplateBuilder or TemplateFinal instance)
1194
+
1195
+ :return: JSON string representation of the template
1196
+
1197
+ Example
1198
+ ```python
1199
+ template = Template().from_python_image('3').copy('app.py', '/app/')
1200
+ json_str = TemplateBase.to_json(template)
1201
+ ```
1202
+ """
1203
+ return json.dumps(
1204
+ template._template._serialize(
1205
+ template._template._instructions_with_hashes()
1206
+ ),
1207
+ indent=2,
1208
+ )
1209
+
1210
+ @staticmethod
1211
+ def to_dockerfile(template: "TemplateClass") -> str:
1212
+ """
1213
+ Convert a template to Dockerfile format.
1214
+
1215
+ Note: Templates based on other Loopix templates cannot be converted to Dockerfile.
1216
+
1217
+ :param template: The template to convert (TemplateBuilder or TemplateFinal instance)
1218
+
1219
+ :return: Dockerfile string representation
1220
+
1221
+ :raises ValueError: If the template is based on another Loopix template or has no base image
1222
+
1223
+ Example
1224
+ ```python
1225
+ template = Template().from_python_image('3').copy('app.py', '/app/')
1226
+ dockerfile = TemplateBase.to_dockerfile(template)
1227
+ ```
1228
+ """
1229
+ if template._template._base_template is not None:
1230
+ raise ValueError(
1231
+ "Cannot convert template built from another template to Dockerfile. "
1232
+ "Templates based on other templates can only be built using the Loopix API."
1233
+ )
1234
+
1235
+ if template._template._base_image is None:
1236
+ raise ValueError("No base image specified for template")
1237
+
1238
+ dockerfile = f"FROM {template._template._base_image}\n"
1239
+
1240
+ for instruction in template._template._instructions:
1241
+ if instruction["type"] == InstructionType.RUN:
1242
+ dockerfile += f"RUN {instruction['args'][0]}\n"
1243
+ continue
1244
+
1245
+ if instruction["type"] == InstructionType.COPY:
1246
+ dockerfile += (
1247
+ f"COPY {instruction['args'][0]} {instruction['args'][1]}\n"
1248
+ )
1249
+ continue
1250
+
1251
+ if instruction["type"] == InstructionType.ENV:
1252
+ args = instruction["args"]
1253
+ values = []
1254
+ for i in range(0, len(args), 2):
1255
+ values.append(f"{args[i]}={args[i + 1]}")
1256
+ dockerfile += f"ENV {' '.join(values)}\n"
1257
+ continue
1258
+
1259
+ dockerfile += (
1260
+ f"{instruction['type'].value} {' '.join(instruction['args'])}\n"
1261
+ )
1262
+
1263
+ if template._template._start_cmd:
1264
+ dockerfile += f"ENTRYPOINT {template._template._start_cmd}\n"
1265
+
1266
+ return dockerfile
1267
+
1268
+ def _instructions_with_hashes(
1269
+ self,
1270
+ ) -> List[Instruction]:
1271
+ """
1272
+ Add file hashes to COPY instructions for cache invalidation.
1273
+
1274
+ :return: Copy of instructions list with filesHash added to COPY instructions
1275
+ """
1276
+ steps: List[Instruction] = []
1277
+
1278
+ for index, instruction in enumerate(self._instructions):
1279
+ step: Instruction = {
1280
+ "type": instruction["type"],
1281
+ "args": instruction["args"],
1282
+ "force": instruction["force"],
1283
+ "forceUpload": instruction.get("forceUpload"),
1284
+ "resolveSymlinks": instruction.get("resolveSymlinks"),
1285
+ "gzip": instruction.get("gzip"),
1286
+ }
1287
+
1288
+ if instruction["type"] == InstructionType.COPY:
1289
+ stack_trace = None
1290
+ if index + 1 < len(self._stack_traces):
1291
+ stack_trace = self._stack_traces[index + 1]
1292
+
1293
+ args = instruction.get("args", [])
1294
+ src = args[0] if len(args) > 0 else None
1295
+ dest = args[1] if len(args) > 1 else None
1296
+ if src is None or dest is None:
1297
+ raise ValueError("Source path and destination path are required")
1298
+
1299
+ resolve_symlinks = instruction.get("resolveSymlinks")
1300
+ step["filesHash"] = calculate_files_hash(
1301
+ src,
1302
+ dest,
1303
+ self._file_context_path,
1304
+ [
1305
+ *self._file_ignore_patterns,
1306
+ *read_dockerignore(self._file_context_path),
1307
+ ],
1308
+ resolve_symlinks
1309
+ if resolve_symlinks is not None
1310
+ else RESOLVE_SYMLINKS,
1311
+ stack_trace,
1312
+ )
1313
+
1314
+ steps.append(step)
1315
+
1316
+ return steps
1317
+
1318
+ def _serialize(self, steps: List[Instruction]) -> TemplateType:
1319
+ """
1320
+ Serialize the template to the API request format.
1321
+
1322
+ :param steps: List of build instructions with file hashes
1323
+
1324
+ :return: Template data formatted for the API
1325
+ """
1326
+ _steps: List[Instruction] = []
1327
+
1328
+ for _, instruction in enumerate(steps):
1329
+ step: Instruction = {
1330
+ "type": instruction.get("type"),
1331
+ "args": instruction.get("args"),
1332
+ "force": instruction.get("force"),
1333
+ }
1334
+
1335
+ files_hash = instruction.get("filesHash")
1336
+ if files_hash is not None:
1337
+ step["filesHash"] = files_hash
1338
+
1339
+ force_upload = instruction.get("forceUpload")
1340
+ if force_upload is not None:
1341
+ step["forceUpload"] = force_upload
1342
+
1343
+ _steps.append(step)
1344
+
1345
+ template_data: TemplateType = {
1346
+ "steps": _steps,
1347
+ "force": self._force,
1348
+ }
1349
+
1350
+ if self._base_image is not None:
1351
+ template_data["fromImage"] = self._base_image
1352
+
1353
+ if self._base_template is not None:
1354
+ template_data["fromTemplate"] = self._base_template
1355
+
1356
+ if self._registry_config is not None:
1357
+ template_data["fromImageRegistry"] = self._registry_config
1358
+
1359
+ if self._start_cmd is not None:
1360
+ template_data["startCmd"] = self._start_cmd
1361
+
1362
+ if self._ready_cmd is not None:
1363
+ template_data["readyCmd"] = self._ready_cmd
1364
+
1365
+ return template_data
1366
+
1367
+
1368
+ TemplateClass = Union[TemplateFinal, TemplateBuilder]