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