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,286 @@
1
+ import json
2
+ import os
3
+ import re
4
+ import tempfile
5
+ from typing import Dict, List, Optional, Protocol, Union, Literal
6
+
7
+ from dockerfile_parse import DockerfileParser
8
+
9
+
10
+ class DockerfFileFinalParserInterface(Protocol):
11
+ """Protocol defining the final interface for Dockerfile parsing callbacks."""
12
+
13
+
14
+ class DockerfileParserInterface(Protocol):
15
+ """Protocol defining the interface for Dockerfile parsing callbacks."""
16
+
17
+ def run_cmd(
18
+ self, command: Union[str, List[str]], user: Optional[str] = None
19
+ ) -> "DockerfileParserInterface":
20
+ """Handle RUN instruction."""
21
+ ...
22
+
23
+ def copy(
24
+ self,
25
+ src: str,
26
+ dest: str,
27
+ force_upload: Optional[Literal[True]] = None,
28
+ user: Optional[str] = None,
29
+ mode: Optional[int] = None,
30
+ resolve_symlinks: Optional[bool] = None,
31
+ gzip: Optional[bool] = None,
32
+ ) -> "DockerfileParserInterface":
33
+ """Handle COPY instruction."""
34
+ ...
35
+
36
+ def set_workdir(self, workdir: str) -> "DockerfileParserInterface":
37
+ """Handle WORKDIR instruction."""
38
+ ...
39
+
40
+ def set_user(self, user: str) -> "DockerfileParserInterface":
41
+ """Handle USER instruction."""
42
+ ...
43
+
44
+ def set_envs(self, envs: Dict[str, str]) -> "DockerfileParserInterface":
45
+ """Handle ENV instruction."""
46
+ ...
47
+
48
+ def set_start_cmd(
49
+ self, start_cmd: str, ready_cmd: str
50
+ ) -> "DockerfFileFinalParserInterface":
51
+ """Handle CMD/ENTRYPOINT instruction."""
52
+ ...
53
+
54
+
55
+ def parse_dockerfile(
56
+ dockerfile_content_or_path: str, template_builder: DockerfileParserInterface
57
+ ) -> str:
58
+ """
59
+ Parse a Dockerfile and convert it to Template SDK format.
60
+
61
+ :param dockerfile_content_or_path: Either the Dockerfile content as a string, or a path to a Dockerfile file
62
+ :param template_builder: Interface providing template builder methods
63
+
64
+ :return: The base image from the Dockerfile
65
+
66
+ :raises ValueError: If the Dockerfile is invalid or unsupported
67
+ """
68
+ # Check if input is a file path that exists
69
+ if os.path.isfile(dockerfile_content_or_path):
70
+ # Read the file content
71
+ with open(dockerfile_content_or_path, "r", encoding="utf-8") as f:
72
+ dockerfile_content = f.read()
73
+ else:
74
+ # Treat as content directly
75
+ dockerfile_content = dockerfile_content_or_path
76
+
77
+ # Use a temporary directory to avoid creating files in the current directory
78
+ with tempfile.TemporaryDirectory() as temp_dir:
79
+ # Create a temporary Dockerfile
80
+ dockerfile_path = os.path.join(temp_dir, "Dockerfile")
81
+ with open(dockerfile_path, "w") as f:
82
+ f.write(dockerfile_content)
83
+
84
+ dfp = DockerfileParser(path=temp_dir)
85
+
86
+ # Check for multi-stage builds
87
+ from_instructions = [
88
+ instruction
89
+ for instruction in dfp.structure
90
+ if instruction["instruction"] == "FROM"
91
+ ]
92
+
93
+ if len(from_instructions) > 1:
94
+ raise ValueError("Multi-stage Dockerfiles are not supported")
95
+
96
+ if len(from_instructions) == 0:
97
+ raise ValueError("Dockerfile must contain a FROM instruction")
98
+
99
+ # Set the base image from the first FROM instruction
100
+ base_image = from_instructions[0]["value"]
101
+ # Remove AS alias if present (e.g., "node:18 AS builder" -> "node:18")
102
+ if " as " in base_image.lower():
103
+ base_image = base_image.split(" as ")[0].strip()
104
+
105
+ user_changed = False
106
+ workdir_changed = False
107
+
108
+ # Set the user and workdir to the Docker defaults
109
+ template_builder.set_user("root")
110
+ template_builder.set_workdir("/")
111
+
112
+ # Process all other instructions
113
+ for instruction_data in dfp.structure:
114
+ instruction = instruction_data["instruction"]
115
+ value = instruction_data["value"]
116
+
117
+ if instruction == "FROM":
118
+ # Already handled above
119
+ continue
120
+ elif instruction == "RUN":
121
+ _handle_run_instruction(value, template_builder)
122
+ elif instruction in ["COPY", "ADD"]:
123
+ _handle_copy_instruction(value, template_builder)
124
+ elif instruction == "WORKDIR":
125
+ _handle_workdir_instruction(value, template_builder)
126
+ workdir_changed = True
127
+ elif instruction == "USER":
128
+ _handle_user_instruction(value, template_builder)
129
+ user_changed = True
130
+ elif instruction in ["ENV", "ARG"]:
131
+ _handle_env_instruction(value, instruction, template_builder)
132
+ elif instruction in ["CMD", "ENTRYPOINT"]:
133
+ _handle_cmd_entrypoint_instruction(value, template_builder)
134
+ else:
135
+ print(f"Unsupported instruction: {instruction}")
136
+ continue
137
+
138
+ # Set the user and workdir to the Loopix defaults
139
+ if not user_changed:
140
+ template_builder.set_user("user")
141
+ if not workdir_changed:
142
+ template_builder.set_workdir("/home/user")
143
+
144
+ return base_image
145
+
146
+
147
+ def _handle_run_instruction(
148
+ value: str, template_builder: DockerfileParserInterface
149
+ ) -> None:
150
+ """Handle RUN instruction"""
151
+ if not value.strip():
152
+ return
153
+ # Remove line continuations and normalize whitespace
154
+ command = re.sub(r"\\\s*\n\s*", " ", value).strip()
155
+ template_builder.run_cmd(command)
156
+
157
+
158
+ def _handle_copy_instruction(
159
+ value: str, template_builder: DockerfileParserInterface
160
+ ) -> None:
161
+ """Handle COPY/ADD instruction"""
162
+ if not value.strip():
163
+ return
164
+ # Parse source and destination from COPY/ADD command
165
+ # Handle both quoted and unquoted paths
166
+ parts = []
167
+ current_part = ""
168
+ in_quotes = False
169
+ quote_char = None
170
+
171
+ i = 0
172
+ while i < len(value):
173
+ char = value[i]
174
+ if char in ['"', "'"] and (i == 0 or value[i - 1] != "\\"):
175
+ if not in_quotes:
176
+ in_quotes = True
177
+ quote_char = char
178
+ elif char == quote_char:
179
+ in_quotes = False
180
+ quote_char = None
181
+ else:
182
+ current_part += char
183
+ elif char == " " and not in_quotes:
184
+ if current_part:
185
+ parts.append(current_part)
186
+ current_part = ""
187
+ else:
188
+ current_part += char
189
+ i += 1
190
+
191
+ if current_part:
192
+ parts.append(current_part)
193
+
194
+ # Extract --chown flag and separate from paths
195
+ user = None
196
+ non_flag_parts = []
197
+ for part in parts:
198
+ if part.startswith("--chown="):
199
+ user = part[8:] # Extract value after "--chown="
200
+ elif not part.startswith("--"):
201
+ non_flag_parts.append(part)
202
+
203
+ if len(non_flag_parts) >= 2:
204
+ dest = non_flag_parts[-1] # Last part is destination
205
+ sources = non_flag_parts[:-1]
206
+
207
+ for src in sources:
208
+ template_builder.copy(src, dest, user=user)
209
+
210
+
211
+ def _handle_workdir_instruction(
212
+ value: str, template_builder: DockerfileParserInterface
213
+ ) -> None:
214
+ """Handle WORKDIR instruction"""
215
+ if not value.strip():
216
+ return
217
+ workdir = value.strip()
218
+ template_builder.set_workdir(workdir)
219
+
220
+
221
+ def _handle_user_instruction(
222
+ value: str, template_builder: DockerfileParserInterface
223
+ ) -> None:
224
+ """Handle USER instruction"""
225
+ if not value.strip():
226
+ return
227
+ user = value.strip()
228
+ template_builder.set_user(user)
229
+
230
+
231
+ def _handle_env_instruction(
232
+ value: str, instruction_type: str, template_builder: DockerfileParserInterface
233
+ ) -> None:
234
+ """Handle ENV/ARG instruction"""
235
+ if not value.strip():
236
+ return
237
+
238
+ # Parse environment variables from the value
239
+ # Handle both "KEY=value" and "KEY value" formats
240
+ env_vars = {}
241
+
242
+ # First try to split on = for KEY=value format
243
+ if "=" in value:
244
+ # Handle multiple KEY=value pairs on one line
245
+ pairs = re.findall(r"(\w+)=([^\s]*(?:\s+(?!\w+=)[^\s]*)*)", value)
246
+ for key, val in pairs:
247
+ env_vars[key] = val.strip("\"'")
248
+ else:
249
+ # Handle "KEY value" format
250
+ parts = value.split(None, 1)
251
+ if len(parts) == 2:
252
+ key, val = parts
253
+ env_vars[key] = val.strip("\"'")
254
+ elif len(parts) == 1 and instruction_type == "ARG":
255
+ # ARG without default value
256
+ key = parts[0]
257
+ env_vars[key] = ""
258
+
259
+ # Add each environment variable
260
+ if env_vars:
261
+ template_builder.set_envs(env_vars)
262
+
263
+
264
+ def _handle_cmd_entrypoint_instruction(
265
+ value: str, template_builder: DockerfileParserInterface
266
+ ) -> None:
267
+ """Handle CMD/ENTRYPOINT instruction - convert to set_start_cmd with 20s timeout"""
268
+ if not value.strip():
269
+ return
270
+ command = value.strip()
271
+
272
+ # Try to parse as JSON (for array format like CMD ["sleep", "infinity"])
273
+ try:
274
+ parsed_command = json.loads(command)
275
+ if isinstance(parsed_command, list):
276
+ command = " ".join(str(item) for item in parsed_command)
277
+ except Exception:
278
+ pass
279
+
280
+ # Import wait_for_timeout locally to avoid circular dependency
281
+ def wait_for_timeout(timeout: int) -> str:
282
+ # convert to seconds, but ensure minimum of 1 second
283
+ seconds = max(1, timeout // 1000)
284
+ return f"sleep {seconds}"
285
+
286
+ template_builder.set_start_cmd(command, wait_for_timeout(20_000))
@@ -0,0 +1,232 @@
1
+ import sys
2
+ import threading
3
+ import time
4
+ from dataclasses import dataclass, field
5
+ from datetime import datetime
6
+ from typing import Optional, TypedDict, Callable, Dict, Literal
7
+
8
+ from rich.console import Console
9
+ from rich.style import Style
10
+ from rich.text import Text
11
+
12
+ from loopix.template.utils import strip_ansi_escape_codes
13
+
14
+ """Log entry severity levels."""
15
+ LogEntryLevel = Literal["debug", "info", "warn", "error"]
16
+
17
+
18
+ @dataclass
19
+ class LogEntry:
20
+ """
21
+ Represents a single log entry from the template build process.
22
+ """
23
+
24
+ timestamp: datetime
25
+ level: LogEntryLevel
26
+ message: str
27
+
28
+ def __post_init__(self):
29
+ self.message = strip_ansi_escape_codes(self.message)
30
+
31
+ def __str__(self) -> str:
32
+ return f"[{self.timestamp.isoformat()}] [{self.level}] {self.message}"
33
+
34
+
35
+ @dataclass
36
+ class LogEntryStart(LogEntry):
37
+ """
38
+ Special log entry indicating the start of a build process.
39
+ """
40
+
41
+ level: LogEntryLevel = field(default="debug", init=False)
42
+
43
+
44
+ @dataclass
45
+ class LogEntryEnd(LogEntry):
46
+ """
47
+ Special log entry indicating the end of a build process.
48
+ """
49
+
50
+ level: LogEntryLevel = field(default="debug", init=False)
51
+
52
+
53
+ """
54
+ Interval in milliseconds for updating the build timer display.
55
+ """
56
+ TIMER_UPDATE_INTERVAL_MS = 150
57
+
58
+ """
59
+ Default minimum log level to display.
60
+ """
61
+ DEFAULT_LEVEL: LogEntryLevel = "info"
62
+
63
+ """
64
+ Colored labels for each log level.
65
+ """
66
+ levels: Dict[LogEntryLevel, tuple[str, Style]] = {
67
+ "error": ("ERROR", Style(color="red")),
68
+ "warn": ("WARN ", Style(color="#FF4400")),
69
+ "info": ("INFO ", Style(color="#FF8800")),
70
+ "debug": ("DEBUG", Style(color="bright_black")),
71
+ }
72
+
73
+ """
74
+ Numeric ordering of log levels for comparison (lower = less severe).
75
+ """
76
+ level_order = {
77
+ "debug": 0,
78
+ "info": 1,
79
+ "warn": 2,
80
+ "error": 3,
81
+ }
82
+
83
+
84
+ def set_interval(func, interval):
85
+ """
86
+ Returns a stop function that can be called to cancel the interval.
87
+
88
+ Similar to JavaScript's setInterval.
89
+
90
+ :param func: Function to execute at each interval
91
+ :param interval: Interval duration in **seconds**
92
+
93
+ :return: Stop function that can be called to cancel the interval
94
+ """
95
+ stopped = threading.Event()
96
+
97
+ def loop():
98
+ while not stopped.is_set():
99
+ if stopped.wait(interval): # wait returns True if stopped
100
+ break
101
+ if not stopped.is_set(): # Double-check before executing
102
+ func()
103
+
104
+ threading.Thread(target=loop, daemon=True).start()
105
+ return stopped.set # Return the stop function
106
+
107
+
108
+ class DefaultBuildLoggerInitialState(TypedDict):
109
+ start_time: float
110
+ animation_frame: int
111
+ timer: Optional[Callable[[], None]]
112
+
113
+
114
+ class DefaultBuildLogger:
115
+ __console = Console()
116
+
117
+ __min_level: LogEntryLevel
118
+ __state: DefaultBuildLoggerInitialState
119
+
120
+ def __init__(self, min_level: Optional[LogEntryLevel] = None):
121
+ self.__min_level = min_level if min_level is not None else DEFAULT_LEVEL
122
+ self.__reset_initial_state()
123
+
124
+ def logger(self, log):
125
+ if isinstance(log, LogEntryStart):
126
+ self.__start_timer()
127
+ return
128
+
129
+ if isinstance(log, LogEntryEnd):
130
+ if self.__state["timer"] is not None:
131
+ self.__state["timer"]()
132
+ return
133
+
134
+ # Filter by minimum level
135
+ if level_order[log.level] < level_order[self.__min_level]:
136
+ return
137
+
138
+ formatted_line = self.__format_log_line(log)
139
+ self.__console.print(formatted_line)
140
+
141
+ # Redraw the timer line
142
+ self.__update_timer()
143
+
144
+ def __reset_initial_state(self, timer: Optional[Callable[[], None]] = None):
145
+ self.__state = {
146
+ "start_time": time.time(),
147
+ "animation_frame": 0,
148
+ "timer": timer,
149
+ }
150
+
151
+ def __format_timer_line(self) -> str:
152
+ elapsed_seconds = time.time() - self.__state["start_time"]
153
+ return f"{elapsed_seconds:.1f}s"
154
+
155
+ def __animate_status(self) -> str:
156
+ frames = ["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"]
157
+ idx = self.__state["animation_frame"] % len(frames)
158
+ return frames[idx]
159
+
160
+ def __format_log_line(self, line: LogEntry) -> Text:
161
+ timer = self.__format_timer_line().ljust(5)
162
+ timestamp = line.timestamp.strftime("%H:%M:%S")
163
+ level_text, level_style = levels.get(line.level, levels[DEFAULT_LEVEL])
164
+
165
+ # Build a rich Text object
166
+ text = Text.assemble(
167
+ timer,
168
+ " | ",
169
+ (timestamp, "dim"),
170
+ " ",
171
+ (level_text, level_style),
172
+ " ",
173
+ line.message,
174
+ )
175
+
176
+ return text
177
+
178
+ def __start_timer(self):
179
+ if not sys.stdout.isatty():
180
+ return
181
+
182
+ # Start the timer interval
183
+ stop_timer = set_interval(
184
+ self.__update_timer, TIMER_UPDATE_INTERVAL_MS / 1000.0
185
+ )
186
+
187
+ self.__reset_initial_state(stop_timer)
188
+
189
+ # Initial timer display
190
+ self.__update_timer()
191
+
192
+ def __update_timer(self):
193
+ if not sys.stdout.isatty():
194
+ return
195
+
196
+ self.__state["animation_frame"] += 1
197
+ jumping_squares = self.__animate_status()
198
+
199
+ timer_text = Text.assemble(
200
+ jumping_squares, " Building ", self.__format_timer_line()
201
+ )
202
+
203
+ # Print with carriage return
204
+ self.__console.print(timer_text, end="\r")
205
+
206
+
207
+ def default_build_logger(
208
+ min_level: Optional[LogEntryLevel] = None,
209
+ ) -> Callable[[LogEntry], None]:
210
+ """
211
+ Create a default build logger with animated timer display.
212
+
213
+ :param min_level: Minimum log level to display (default: 'info')
214
+
215
+ :return: Logger function that accepts LogEntry instances
216
+
217
+ Example
218
+ ```python
219
+ from loopix import Template, default_build_logger
220
+
221
+ template = Template().from_python_image()
222
+
223
+ # Use with build - implementation would be in build_async module
224
+ # await Template.build(template,
225
+ # alias='my-template',
226
+ # on_build_logs=default_build_logger(min_level='debug')
227
+ # )
228
+ ```
229
+ """
230
+ build_logger = DefaultBuildLogger(min_level)
231
+
232
+ return build_logger.logger