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,426 @@
1
+ import hashlib
2
+ import os
3
+ import tarfile
4
+ import tempfile
5
+ import json
6
+ import stat
7
+ from wcmatch import glob
8
+ import re
9
+ import inspect
10
+ from types import TracebackType, FrameType
11
+ from typing import IO, List, Optional, Union
12
+
13
+ from loopix.exceptions import TemplateException
14
+ from loopix.template.consts import BASE_STEP_NAME, FINALIZE_STEP_NAME
15
+
16
+
17
+ def make_traceback(caller_frame: Optional[FrameType]) -> Optional[TracebackType]:
18
+ """
19
+ Create a TracebackType from a caller frame for error reporting.
20
+
21
+ :param caller_frame: The caller's frame object, or None
22
+ :return: A TracebackType object for use with exception.with_traceback(), or None
23
+ """
24
+ if caller_frame is None:
25
+ return None
26
+ return TracebackType(
27
+ tb_next=None,
28
+ tb_frame=caller_frame,
29
+ tb_lasti=caller_frame.f_lasti,
30
+ tb_lineno=caller_frame.f_lineno,
31
+ )
32
+
33
+
34
+ def validate_relative_path(
35
+ src: str,
36
+ stack_trace: Optional[TracebackType],
37
+ ) -> None:
38
+ """
39
+ Validate that a source path for copy operations is a relative path that stays
40
+ within the context directory. This prevents path traversal attacks and ensures
41
+ files are copied from within the expected directory.
42
+
43
+ :param src: The source path to validate
44
+ :param stack_trace: Optional stack trace for error reporting
45
+
46
+ :raises TemplateException: If the path is absolute or escapes the context directory
47
+
48
+ Invalid paths:
49
+ - Absolute paths: /absolute/path, C:\\Windows\\path
50
+ - Parent directory escapes: ../foo, foo/../../bar, ./foo/../../../bar
51
+
52
+ Valid paths:
53
+ - Simple relative: foo, foo/bar
54
+ - Current directory prefix: ./foo, ./foo/bar
55
+ - Internal parent refs that don't escape: foo/../bar (stays within context)
56
+ """
57
+ # Check for absolute paths using Python's cross-platform implementation
58
+ if os.path.isabs(src):
59
+ raise TemplateException(
60
+ f'Invalid source path "{src}": absolute paths are not allowed. '
61
+ "Use a relative path within the context directory."
62
+ ).with_traceback(stack_trace)
63
+
64
+ # Normalize the path and check if it escapes the context directory
65
+ normalized = os.path.normpath(src)
66
+
67
+ # After normalization, a path that escapes would be '..' or start with '../'
68
+ # We check for '..' followed by path separator to avoid false positives on filenames like '..myconfig'
69
+ # Examples:
70
+ # - '../foo' -> '../foo' (escapes)
71
+ # - 'foo/../../bar' -> '../bar' (escapes)
72
+ # - './foo/../../../bar' -> '../../bar' (escapes)
73
+ # - 'foo/../bar' -> 'bar' (doesn't escape)
74
+ # - './foo/bar' -> 'foo/bar' (doesn't escape)
75
+ # - '..myconfig' -> '..myconfig' (valid filename, doesn't escape)
76
+ escapes = normalized == ".." or normalized.startswith(".." + os.sep)
77
+
78
+ if escapes:
79
+ raise TemplateException(
80
+ f'Invalid source path "{src}": path escapes the context directory. '
81
+ "The path must stay within the context directory."
82
+ ).with_traceback(stack_trace)
83
+
84
+
85
+ def normalize_build_arguments(
86
+ name: Optional[str] = None,
87
+ alias: Optional[str] = None,
88
+ ) -> str:
89
+ """
90
+ Normalize build arguments from different parameter signatures.
91
+ Handles string name or legacy alias parameter.
92
+
93
+ :param name: Template name in 'name' or 'name:tag' format
94
+ :param alias: (Deprecated) Alias name for the template. Use name instead.
95
+ :return: Normalized template name
96
+ :raises TemplateException: If no template name is provided
97
+ """
98
+ if name and len(name) > 0:
99
+ return name
100
+ if alias and len(alias) > 0:
101
+ return alias
102
+ raise TemplateException("Name must be provided")
103
+
104
+
105
+ def read_dockerignore(context_path: str) -> List[str]:
106
+ """
107
+ Read and parse a .dockerignore file.
108
+
109
+ :param context_path: Directory path containing the .dockerignore file
110
+
111
+ :return: Array of ignore patterns (empty lines and comments are filtered out)
112
+ """
113
+ dockerignore_path = os.path.join(context_path, ".dockerignore")
114
+ if not os.path.exists(dockerignore_path):
115
+ return []
116
+
117
+ with open(dockerignore_path, "r", encoding="utf-8") as f:
118
+ content = f.read()
119
+
120
+ return [
121
+ line.strip()
122
+ for line in content.split("\n")
123
+ if line.strip() and not line.strip().startswith("#")
124
+ ]
125
+
126
+
127
+ def normalize_path(path: str) -> str:
128
+ """
129
+ Normalize path separators to forward slashes for glob patterns (glob expects / even on Windows).
130
+
131
+ :param path: The path to normalize
132
+ :return: The normalized path
133
+ """
134
+ return path.replace(os.sep, "/")
135
+
136
+
137
+ def get_all_files_in_path(
138
+ src: str,
139
+ context_path: str,
140
+ ignore_patterns: List[str],
141
+ include_directories: bool = True,
142
+ ) -> List[str]:
143
+ """
144
+ Get all files for a given path and ignore patterns.
145
+
146
+ :param src: Path to the source directory
147
+ :param context_path: Base directory for resolving relative paths
148
+ :param ignore_patterns: Ignore patterns
149
+ :param include_directories: Whether to include directories
150
+ :return: Array of files
151
+ """
152
+ files = set()
153
+
154
+ # Use glob to find all files/directories matching the pattern under context_path
155
+ abs_context_path = os.path.abspath(context_path)
156
+ files_glob = glob.glob(
157
+ src,
158
+ flags=glob.GLOBSTAR | glob.DOTMATCH,
159
+ root_dir=abs_context_path,
160
+ exclude=ignore_patterns,
161
+ )
162
+
163
+ for file in files_glob:
164
+ # Join it with abs_context_path to get the absolute path
165
+ file_path = os.path.join(abs_context_path, file)
166
+
167
+ if os.path.isdir(file_path):
168
+ # If it's a directory, add the directory and all entries recursively
169
+ if include_directories:
170
+ files.add(file_path)
171
+ dir_files = glob.glob(
172
+ normalize_path(file) + "/**/*",
173
+ flags=glob.GLOBSTAR | glob.DOTMATCH,
174
+ root_dir=abs_context_path,
175
+ exclude=ignore_patterns,
176
+ )
177
+ for dir_file in dir_files:
178
+ dir_file_path = os.path.join(abs_context_path, dir_file)
179
+ files.add(dir_file_path)
180
+ else:
181
+ files.add(file_path)
182
+
183
+ return sorted(list(files))
184
+
185
+
186
+ def calculate_files_hash(
187
+ src: str,
188
+ dest: str,
189
+ context_path: str,
190
+ ignore_patterns: List[str],
191
+ resolve_symlinks: bool,
192
+ stack_trace: Optional[TracebackType],
193
+ ) -> str:
194
+ """
195
+ Calculate a hash of files being copied to detect changes for cache invalidation.
196
+
197
+ The hash includes file content, metadata (mode, size), and relative paths.
198
+ Note: uid, gid, and mtime are excluded to ensure stable hashes across environments.
199
+
200
+ :param src: Source path pattern for files to copy
201
+ :param dest: Destination path where files will be copied
202
+ :param context_path: Base directory for resolving relative paths
203
+ :param ignore_patterns: Glob patterns to ignore
204
+ :param resolve_symlinks: Whether to resolve symbolic links when hashing
205
+ :param stack_trace: Optional stack trace for error reporting
206
+
207
+ :return: Hex string hash of all files
208
+
209
+ :raises ValueError: If no files match the source pattern
210
+ """
211
+ src_path = os.path.join(context_path, src)
212
+ hash_obj = hashlib.sha256()
213
+ content = f"COPY {src} {dest}"
214
+
215
+ hash_obj.update(content.encode())
216
+
217
+ files = get_all_files_in_path(src, context_path, ignore_patterns, True)
218
+
219
+ if len(files) == 0:
220
+ raise ValueError(f"No files found in {src_path}").with_traceback(stack_trace)
221
+
222
+ def hash_stats(stat_info: os.stat_result) -> None:
223
+ # Only include stable metadata (mode, size)
224
+ # Exclude uid, gid, and mtime to ensure consistent hashes across environments
225
+ hash_obj.update(str(stat_info.st_mode).encode())
226
+ hash_obj.update(str(stat_info.st_size).encode())
227
+
228
+ for file in files:
229
+ # Hash the relative path
230
+ relative_path = os.path.relpath(file, context_path)
231
+ hash_obj.update(relative_path.encode())
232
+
233
+ # Add stat information to hash calculation
234
+ if os.path.islink(file):
235
+ stats = os.lstat(file)
236
+ should_follow = resolve_symlinks and (
237
+ os.path.isfile(file) or os.path.isdir(file)
238
+ )
239
+
240
+ if not should_follow:
241
+ hash_stats(stats)
242
+
243
+ content = os.readlink(file)
244
+ hash_obj.update(content.encode())
245
+ continue
246
+
247
+ stats = os.stat(file)
248
+ hash_stats(stats)
249
+
250
+ if stat.S_ISREG(stats.st_mode):
251
+ with open(file, "rb") as f:
252
+ hash_obj.update(f.read())
253
+
254
+ return hash_obj.hexdigest()
255
+
256
+
257
+ def tar_file_stream(
258
+ file_name: str,
259
+ file_context_path: str,
260
+ ignore_patterns: List[str],
261
+ resolve_symlinks: bool,
262
+ gzip: bool,
263
+ ) -> IO[bytes]:
264
+ """
265
+ Create a tar archive of files matching a pattern in a temporary file.
266
+
267
+ The archive is spooled to disk so it can be uploaded as a stream instead
268
+ of being buffered in memory. The temporary file is deleted when closed.
269
+
270
+ :param file_name: Glob pattern for files to include
271
+ :param file_context_path: Base directory for resolving file paths
272
+ :param ignore_patterns: Ignore patterns
273
+ :param resolve_symlinks: Whether to resolve symbolic links
274
+ :param gzip: Whether to gzip the archive
275
+
276
+ :return: Binary file object positioned at the start of the archive
277
+ """
278
+ tar_file = tempfile.TemporaryFile()
279
+ try:
280
+ with tarfile.open(
281
+ fileobj=tar_file,
282
+ mode="w:gz" if gzip else "w",
283
+ dereference=resolve_symlinks,
284
+ ) as tar:
285
+ files = get_all_files_in_path(
286
+ file_name, file_context_path, ignore_patterns, True
287
+ )
288
+ for file in files:
289
+ tar.add(
290
+ file,
291
+ arcname=os.path.relpath(file, file_context_path),
292
+ recursive=False,
293
+ )
294
+
295
+ tar_file.seek(0)
296
+ return tar_file
297
+ except Exception:
298
+ # Best-effort cleanup: a close failure must not replace the real
299
+ # archive-creation error.
300
+ try:
301
+ tar_file.close()
302
+ except Exception:
303
+ pass
304
+ raise
305
+
306
+
307
+ def strip_ansi_escape_codes(text: str) -> str:
308
+ """
309
+ Strip ANSI escape codes from a string.
310
+
311
+ Source: https://github.com/chalk/ansi-regex/blob/main/index.js
312
+
313
+ :param text: String with ANSI escape codes
314
+
315
+ :return: String without ANSI escape codes
316
+ """
317
+ # Valid string terminator sequences are BEL, ESC\, and 0x9c
318
+ st = r"(?:\u0007|\u001B\u005C|\u009C)"
319
+ pattern = [
320
+ rf"[\u001B\u009B][\[\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\d/#&.:=?%@~_]+)*|[a-zA-Z\d]+(?:;[-a-zA-Z\d/#&.:=?%@~_]*)*)?{st})",
321
+ r"(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]))",
322
+ ]
323
+ ansi_escape = re.compile("|".join(pattern), re.UNICODE)
324
+ return ansi_escape.sub("", text)
325
+
326
+
327
+ def get_caller_frame(depth: int) -> Optional[FrameType]:
328
+ """
329
+ Get the caller's stack frame at a specific depth.
330
+
331
+ This is used to provide better error messages and debugging information
332
+ by tracking where template methods were called from in user code.
333
+
334
+ :param depth: The depth of the stack trace to retrieve
335
+
336
+ :return: The caller frame, or None if not available
337
+ """
338
+ stack = inspect.stack()[1:]
339
+ if len(stack) < depth + 1:
340
+ return None
341
+ return stack[depth].frame
342
+
343
+
344
+ def get_caller_directory(depth: int) -> Optional[str]:
345
+ """
346
+ Get the directory of the caller at a specific stack depth.
347
+
348
+ This is used to determine the file_context_path when creating a template,
349
+ so file paths are resolved relative to the user's template file location.
350
+
351
+ :param depth: The depth of the stack trace
352
+
353
+ :return: The caller's directory path, or None if not available
354
+ """
355
+ try:
356
+ # Get the stack trace
357
+ caller_frame = get_caller_frame(depth)
358
+ if caller_frame is None:
359
+ return None
360
+
361
+ caller_file = caller_frame.f_code.co_filename
362
+
363
+ # Return the directory of the caller file
364
+ return os.path.dirname(os.path.abspath(caller_file))
365
+ except Exception:
366
+ return None
367
+
368
+
369
+ def pad_octal(mode: int) -> str:
370
+ """
371
+ Convert a numeric file mode to a zero-padded octal string.
372
+
373
+ :param mode: File mode as a number (e.g., 493 for 0o755)
374
+
375
+ :return: Zero-padded 4-digit octal string (e.g., "0755")
376
+
377
+ Example
378
+ ```python
379
+ pad_octal(0o755) # Returns "0755"
380
+ pad_octal(0o644) # Returns "0644"
381
+ ```
382
+ """
383
+ return f"{mode:04o}"
384
+
385
+
386
+ def get_build_step_index(step: str, stack_traces_length: int) -> int:
387
+ """
388
+ Get the array index for a build step based on its name.
389
+
390
+ Special steps:
391
+ - BASE_STEP_NAME: Returns 0 (first step)
392
+ - FINALIZE_STEP_NAME: Returns the last index
393
+ - Numeric strings: Converted to number
394
+
395
+ :param step: Build step name or number as string
396
+ :param stack_traces_length: Total number of stack traces (used for FINALIZE_STEP_NAME)
397
+
398
+ :return: Index for the build step
399
+ """
400
+ if step == BASE_STEP_NAME:
401
+ return 0
402
+
403
+ if step == FINALIZE_STEP_NAME:
404
+ return stack_traces_length - 1
405
+
406
+ return int(step)
407
+
408
+
409
+ def read_gcp_service_account_json(
410
+ context_path: str, path_or_content: Union[str, dict]
411
+ ) -> str:
412
+ """
413
+ Read GCP service account JSON from a file or object.
414
+
415
+ :param context_path: Base directory for resolving relative file paths
416
+ :param path_or_content: Either a path to a JSON file or a service account object
417
+
418
+ :return: Service account JSON as a string
419
+ """
420
+ if isinstance(path_or_content, str):
421
+ with open(
422
+ os.path.join(context_path, path_or_content), "r", encoding="utf-8"
423
+ ) as f:
424
+ return f.read()
425
+ else:
426
+ return json.dumps(path_or_content)