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
@@ -0,0 +1,138 @@
1
+ class ReadyCmd:
2
+ """
3
+ Wrapper class for ready check commands.
4
+ """
5
+
6
+ def __init__(self, cmd: str):
7
+ self.__cmd = cmd
8
+
9
+ def get_cmd(self):
10
+ return self.__cmd
11
+
12
+
13
+ def wait_for_port(port: int):
14
+ """
15
+ Wait for a port to be listening.
16
+
17
+ Uses `ss` command to check if a port is open and listening.
18
+
19
+ :param port: Port number to wait for
20
+
21
+ :return: ReadyCmd that checks for the port
22
+
23
+ Example
24
+ ```python
25
+ from moru import Template, wait_for_port
26
+
27
+ template = (
28
+ Template()
29
+ .from_python_image()
30
+ .set_start_cmd('python -m http.server 8000', wait_for_port(8000))
31
+ )
32
+ ```
33
+ """
34
+ cmd = f"ss -tuln | grep :{port}"
35
+ return ReadyCmd(cmd)
36
+
37
+
38
+ def wait_for_url(url: str, status_code: int = 200):
39
+ """
40
+ Wait for a URL to return a specific HTTP status code.
41
+
42
+ Uses `curl` to make HTTP requests and check the response status.
43
+
44
+ :param url: URL to check (e.g., 'http://localhost:3000/health')
45
+ :param status_code: Expected HTTP status code (default: 200)
46
+
47
+ :return: ReadyCmd that checks the URL
48
+
49
+ Example
50
+ ```python
51
+ from moru import Template, wait_for_url
52
+
53
+ template = (
54
+ Template()
55
+ .from_node_image()
56
+ .set_start_cmd('npm start', wait_for_url('http://localhost:3000/health'))
57
+ )
58
+ ```
59
+ """
60
+ cmd = f'curl -s -o /dev/null -w "%{{http_code}}" {url} | grep -q "{status_code}"'
61
+ return ReadyCmd(cmd)
62
+
63
+
64
+ def wait_for_process(process_name: str):
65
+ """
66
+ Wait for a process with a specific name to be running.
67
+
68
+ Uses `pgrep` to check if a process exists.
69
+
70
+ :param process_name: Name of the process to wait for
71
+
72
+ :return: ReadyCmd that checks for the process
73
+
74
+ Example
75
+ ```python
76
+ from moru import Template, wait_for_process
77
+
78
+ template = (
79
+ Template()
80
+ .from_base_image()
81
+ .set_start_cmd('./my-daemon', wait_for_process('my-daemon'))
82
+ )
83
+ ```
84
+ """
85
+ cmd = f"pgrep {process_name} > /dev/null"
86
+ return ReadyCmd(cmd)
87
+
88
+
89
+ def wait_for_file(filename: str):
90
+ """
91
+ Wait for a file to exist.
92
+
93
+ Uses shell test command to check file existence.
94
+
95
+ :param filename: Path to the file to wait for
96
+
97
+ :return: ReadyCmd that checks for the file
98
+
99
+ Example
100
+ ```python
101
+ from moru import Template, wait_for_file
102
+
103
+ template = (
104
+ Template()
105
+ .from_base_image()
106
+ .set_start_cmd('./init.sh', wait_for_file('/tmp/ready'))
107
+ )
108
+ ```
109
+ """
110
+ cmd = f"[ -f {filename} ]"
111
+ return ReadyCmd(cmd)
112
+
113
+
114
+ def wait_for_timeout(timeout: int):
115
+ """
116
+ Wait for a specified timeout before considering the sandbox ready.
117
+
118
+ Uses `sleep` command to wait for a fixed duration.
119
+
120
+ :param timeout: Time to wait in **milliseconds** (minimum: 1000ms / 1 second)
121
+
122
+ :return: ReadyCmd that waits for the specified duration
123
+
124
+ Example
125
+ ```python
126
+ from moru import Template, wait_for_timeout
127
+
128
+ template = (
129
+ Template()
130
+ .from_node_image()
131
+ .set_start_cmd('npm start', wait_for_timeout(5000)) # Wait 5 seconds
132
+ )
133
+ ```
134
+ """
135
+ # convert to seconds, but ensure minimum of 1 second
136
+ seconds = max(1, timeout // 1000)
137
+ cmd = f"sleep {seconds}"
138
+ return ReadyCmd(cmd)
moru/template/types.py ADDED
@@ -0,0 +1,105 @@
1
+ from dataclasses import dataclass
2
+ from enum import Enum
3
+ from pathlib import Path
4
+ from typing import List, Literal, Optional, TypedDict, Union
5
+
6
+ from typing_extensions import NotRequired
7
+
8
+
9
+ class InstructionType(str, Enum):
10
+ """
11
+ Types of instructions that can be used in a template.
12
+ """
13
+
14
+ COPY = "COPY"
15
+ ENV = "ENV"
16
+ RUN = "RUN"
17
+ WORKDIR = "WORKDIR"
18
+ USER = "USER"
19
+
20
+
21
+ class CopyItem(TypedDict):
22
+ """
23
+ Configuration for a single file/directory copy operation.
24
+ """
25
+
26
+ src: Union[Union[str, Path], List[Union[str, Path]]]
27
+ dest: Union[str, Path]
28
+ forceUpload: NotRequired[Optional[Literal[True]]]
29
+ user: NotRequired[Optional[str]]
30
+ mode: NotRequired[Optional[int]]
31
+ resolveSymlinks: NotRequired[Optional[bool]]
32
+
33
+
34
+ class Instruction(TypedDict):
35
+ """
36
+ Represents a single instruction in the template build process.
37
+ """
38
+
39
+ type: InstructionType
40
+ args: List[str]
41
+ force: bool
42
+ forceUpload: NotRequired[Optional[Literal[True]]]
43
+ filesHash: NotRequired[Optional[str]]
44
+ resolveSymlinks: NotRequired[Optional[bool]]
45
+
46
+
47
+ class GenericDockerRegistry(TypedDict):
48
+ """
49
+ Configuration for a generic Docker registry with basic authentication.
50
+ """
51
+
52
+ type: Literal["registry"]
53
+ username: str
54
+ password: str
55
+
56
+
57
+ class AWSRegistry(TypedDict):
58
+ """
59
+ Configuration for AWS Elastic Container Registry (ECR).
60
+ """
61
+
62
+ type: Literal["aws"]
63
+ awsAccessKeyId: str
64
+ awsSecretAccessKey: str
65
+ awsRegion: str
66
+
67
+
68
+ class GCPRegistry(TypedDict):
69
+ """
70
+ Configuration for Google Container Registry (GCR) or Artifact Registry.
71
+ """
72
+
73
+ type: Literal["gcp"]
74
+ serviceAccountJson: str
75
+
76
+
77
+ """
78
+ Union type for all supported container registry configurations.
79
+ """
80
+ RegistryConfig = Union[GenericDockerRegistry, AWSRegistry, GCPRegistry]
81
+
82
+
83
+ class TemplateType(TypedDict):
84
+ """
85
+ Internal representation of a template for the Moru build API.
86
+ """
87
+
88
+ fromImage: NotRequired[str]
89
+ fromTemplate: NotRequired[str]
90
+ fromImageRegistry: NotRequired[RegistryConfig]
91
+ startCmd: NotRequired[str]
92
+ readyCmd: NotRequired[str]
93
+ steps: List[Instruction]
94
+ force: bool
95
+
96
+
97
+ @dataclass
98
+ class BuildInfo:
99
+ """
100
+ Information about a built template.
101
+ """
102
+
103
+ alias: str
104
+ template_id: str
105
+ build_id: str
moru/template/utils.py ADDED
@@ -0,0 +1,320 @@
1
+ import hashlib
2
+ import os
3
+ import io
4
+ import tarfile
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 List, Optional, Union
12
+
13
+ from moru.template.consts import BASE_STEP_NAME, FINALIZE_STEP_NAME
14
+
15
+
16
+ def read_dockerignore(context_path: str) -> List[str]:
17
+ """
18
+ Read and parse a .dockerignore file.
19
+
20
+ :param context_path: Directory path containing the .dockerignore file
21
+
22
+ :return: Array of ignore patterns (empty lines and comments are filtered out)
23
+ """
24
+ dockerignore_path = os.path.join(context_path, ".dockerignore")
25
+ if not os.path.exists(dockerignore_path):
26
+ return []
27
+
28
+ with open(dockerignore_path, "r", encoding="utf-8") as f:
29
+ content = f.read()
30
+
31
+ return [
32
+ line.strip()
33
+ for line in content.split("\n")
34
+ if line.strip() and not line.strip().startswith("#")
35
+ ]
36
+
37
+
38
+ def normalize_path(path: str) -> str:
39
+ """
40
+ Normalize path separators to forward slashes for glob patterns (glob expects / even on Windows).
41
+
42
+ :param path: The path to normalize
43
+ :return: The normalized path
44
+ """
45
+ return path.replace(os.sep, "/")
46
+
47
+
48
+ def get_all_files_in_path(
49
+ src: str,
50
+ context_path: str,
51
+ ignore_patterns: List[str],
52
+ include_directories: bool = True,
53
+ ) -> List[str]:
54
+ """
55
+ Get all files for a given path and ignore patterns.
56
+
57
+ :param src: Path to the source directory
58
+ :param context_path: Base directory for resolving relative paths
59
+ :param ignore_patterns: Ignore patterns
60
+ :param include_directories: Whether to include directories
61
+ :return: Array of files
62
+ """
63
+ files = set()
64
+
65
+ # Use glob to find all files/directories matching the pattern under context_path
66
+ abs_context_path = os.path.abspath(context_path)
67
+ files_glob = glob.glob(
68
+ src,
69
+ flags=glob.GLOBSTAR,
70
+ root_dir=abs_context_path,
71
+ exclude=ignore_patterns,
72
+ )
73
+
74
+ for file in files_glob:
75
+ # Join it with abs_context_path to get the absolute path
76
+ file_path = os.path.join(abs_context_path, file)
77
+
78
+ if os.path.isdir(file_path):
79
+ # If it's a directory, add the directory and all entries recursively
80
+ if include_directories:
81
+ files.add(file_path)
82
+ dir_files = glob.glob(
83
+ normalize_path(file) + "/**/*",
84
+ flags=glob.GLOBSTAR,
85
+ root_dir=abs_context_path,
86
+ exclude=ignore_patterns,
87
+ )
88
+ for dir_file in dir_files:
89
+ dir_file_path = os.path.join(abs_context_path, dir_file)
90
+ files.add(dir_file_path)
91
+ else:
92
+ files.add(file_path)
93
+
94
+ return sorted(list(files))
95
+
96
+
97
+ def calculate_files_hash(
98
+ src: str,
99
+ dest: str,
100
+ context_path: str,
101
+ ignore_patterns: List[str],
102
+ resolve_symlinks: bool,
103
+ stack_trace: Optional[TracebackType],
104
+ ) -> str:
105
+ """
106
+ Calculate a hash of files being copied to detect changes for cache invalidation.
107
+
108
+ The hash includes file content, metadata (mode, size), and relative paths.
109
+ Note: uid, gid, and mtime are excluded to ensure stable hashes across environments.
110
+
111
+ :param src: Source path pattern for files to copy
112
+ :param dest: Destination path where files will be copied
113
+ :param context_path: Base directory for resolving relative paths
114
+ :param ignore_patterns: Glob patterns to ignore
115
+ :param resolve_symlinks: Whether to resolve symbolic links when hashing
116
+ :param stack_trace: Optional stack trace for error reporting
117
+
118
+ :return: Hex string hash of all files
119
+
120
+ :raises ValueError: If no files match the source pattern
121
+ """
122
+ src_path = os.path.join(context_path, src)
123
+ hash_obj = hashlib.sha256()
124
+ content = f"COPY {src} {dest}"
125
+
126
+ hash_obj.update(content.encode())
127
+
128
+ files = get_all_files_in_path(src, context_path, ignore_patterns, True)
129
+
130
+ if len(files) == 0:
131
+ raise ValueError(f"No files found in {src_path}").with_traceback(stack_trace)
132
+
133
+ def hash_stats(stat_info: os.stat_result) -> None:
134
+ # Only include stable metadata (mode, size)
135
+ # Exclude uid, gid, and mtime to ensure consistent hashes across environments
136
+ hash_obj.update(str(stat_info.st_mode).encode())
137
+ hash_obj.update(str(stat_info.st_size).encode())
138
+
139
+ for file in files:
140
+ # Hash the relative path
141
+ relative_path = os.path.relpath(file, context_path)
142
+ hash_obj.update(relative_path.encode())
143
+
144
+ # Add stat information to hash calculation
145
+ if os.path.islink(file):
146
+ stats = os.lstat(file)
147
+ should_follow = resolve_symlinks and (
148
+ os.path.isfile(file) or os.path.isdir(file)
149
+ )
150
+
151
+ if not should_follow:
152
+ hash_stats(stats)
153
+
154
+ content = os.readlink(file)
155
+ hash_obj.update(content.encode())
156
+ continue
157
+
158
+ stats = os.stat(file)
159
+ hash_stats(stats)
160
+
161
+ if stat.S_ISREG(stats.st_mode):
162
+ with open(file, "rb") as f:
163
+ hash_obj.update(f.read())
164
+
165
+ return hash_obj.hexdigest()
166
+
167
+
168
+ def tar_file_stream(
169
+ file_name: str,
170
+ file_context_path: str,
171
+ ignore_patterns: List[str],
172
+ resolve_symlinks: bool,
173
+ ) -> io.BytesIO:
174
+ """
175
+ Create a tar stream of files matching a pattern.
176
+
177
+ :param file_name: Glob pattern for files to include
178
+ :param file_context_path: Base directory for resolving file paths
179
+ :param ignore_patterns: Ignore patterns
180
+ :param resolve_symlinks: Whether to resolve symbolic links
181
+
182
+ :return: Tar stream
183
+ """
184
+ tar_buffer = io.BytesIO()
185
+ with tarfile.open(
186
+ fileobj=tar_buffer,
187
+ mode="w:gz",
188
+ dereference=resolve_symlinks,
189
+ ) as tar:
190
+ files = get_all_files_in_path(
191
+ file_name, file_context_path, ignore_patterns, True
192
+ )
193
+ for file in files:
194
+ tar.add(
195
+ file, arcname=os.path.relpath(file, file_context_path), recursive=False
196
+ )
197
+
198
+ return tar_buffer
199
+
200
+
201
+ def strip_ansi_escape_codes(text: str) -> str:
202
+ """
203
+ Strip ANSI escape codes from a string.
204
+
205
+ Source: https://github.com/chalk/ansi-regex/blob/main/index.js
206
+
207
+ :param text: String with ANSI escape codes
208
+
209
+ :return: String without ANSI escape codes
210
+ """
211
+ # Valid string terminator sequences are BEL, ESC\, and 0x9c
212
+ st = r"(?:\u0007|\u001B\u005C|\u009C)"
213
+ pattern = [
214
+ rf"[\u001B\u009B][\[\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\d/#&.:=?%@~_]+)*|[a-zA-Z\d]+(?:;[-a-zA-Z\d/#&.:=?%@~_]*)*)?{st})",
215
+ r"(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]))",
216
+ ]
217
+ ansi_escape = re.compile("|".join(pattern), re.UNICODE)
218
+ return ansi_escape.sub("", text)
219
+
220
+
221
+ def get_caller_frame(depth: int) -> Optional[FrameType]:
222
+ """
223
+ Get the caller's stack frame at a specific depth.
224
+
225
+ This is used to provide better error messages and debugging information
226
+ by tracking where template methods were called from in user code.
227
+
228
+ :param depth: The depth of the stack trace to retrieve
229
+
230
+ :return: The caller frame, or None if not available
231
+ """
232
+ stack = inspect.stack()[1:]
233
+ if len(stack) < depth + 1:
234
+ return None
235
+ return stack[depth].frame
236
+
237
+
238
+ def get_caller_directory(depth: int) -> Optional[str]:
239
+ """
240
+ Get the directory of the caller at a specific stack depth.
241
+
242
+ This is used to determine the file_context_path when creating a template,
243
+ so file paths are resolved relative to the user's template file location.
244
+
245
+ :param depth: The depth of the stack trace
246
+
247
+ :return: The caller's directory path, or None if not available
248
+ """
249
+ try:
250
+ # Get the stack trace
251
+ caller_frame = get_caller_frame(depth)
252
+ if caller_frame is None:
253
+ return None
254
+
255
+ caller_file = caller_frame.f_code.co_filename
256
+
257
+ # Return the directory of the caller file
258
+ return os.path.dirname(os.path.abspath(caller_file))
259
+ except Exception:
260
+ return None
261
+
262
+
263
+ def pad_octal(mode: int) -> str:
264
+ """
265
+ Convert a numeric file mode to a zero-padded octal string.
266
+
267
+ :param mode: File mode as a number (e.g., 493 for 0o755)
268
+
269
+ :return: Zero-padded 4-digit octal string (e.g., "0755")
270
+
271
+ Example
272
+ ```python
273
+ pad_octal(0o755) # Returns "0755"
274
+ pad_octal(0o644) # Returns "0644"
275
+ ```
276
+ """
277
+ return f"{mode:04o}"
278
+
279
+
280
+ def get_build_step_index(step: str, stack_traces_length: int) -> int:
281
+ """
282
+ Get the array index for a build step based on its name.
283
+
284
+ Special steps:
285
+ - BASE_STEP_NAME: Returns 0 (first step)
286
+ - FINALIZE_STEP_NAME: Returns the last index
287
+ - Numeric strings: Converted to number
288
+
289
+ :param step: Build step name or number as string
290
+ :param stack_traces_length: Total number of stack traces (used for FINALIZE_STEP_NAME)
291
+
292
+ :return: Index for the build step
293
+ """
294
+ if step == BASE_STEP_NAME:
295
+ return 0
296
+
297
+ if step == FINALIZE_STEP_NAME:
298
+ return stack_traces_length - 1
299
+
300
+ return int(step)
301
+
302
+
303
+ def read_gcp_service_account_json(
304
+ context_path: str, path_or_content: Union[str, dict]
305
+ ) -> str:
306
+ """
307
+ Read GCP service account JSON from a file or object.
308
+
309
+ :param context_path: Base directory for resolving relative file paths
310
+ :param path_or_content: Either a path to a JSON file or a service account object
311
+
312
+ :return: Service account JSON as a string
313
+ """
314
+ if isinstance(path_or_content, str):
315
+ with open(
316
+ os.path.join(context_path, path_or_content), "r", encoding="utf-8"
317
+ ) as f:
318
+ return f.read()
319
+ else:
320
+ return json.dumps(path_or_content)