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,420 @@
1
+ import threading
2
+ from typing import Callable, Dict, List, Literal, Optional, Union, overload
3
+
4
+ import loopix_connect
5
+ import httpx
6
+ from packaging.version import Version
7
+ from loopix.api import make_logging_event_hooks
8
+ from loopix.api.client_sync import get_envd_transport
9
+ from loopix.connection_config import (
10
+ ConnectionConfig,
11
+ Username,
12
+ KEEPALIVE_PING_HEADER,
13
+ KEEPALIVE_PING_INTERVAL_SEC,
14
+ )
15
+ from loopix.envd.process import process_connect, process_pb2
16
+ from loopix.envd.api import check_sandbox_health
17
+ from loopix.envd.rpc import authentication_header, handle_rpc_exception_with_health
18
+ from loopix.envd.versions import ENVD_COMMANDS_STDIN, ENVD_ENVD_CLOSE
19
+ from loopix.exceptions import SandboxException
20
+ from loopix.sandbox.commands.main import ProcessInfo
21
+ from loopix.sandbox.commands.command_handle import CommandResult
22
+ from loopix.sandbox_sync.commands.command_handle import CommandHandle
23
+
24
+
25
+ class Commands:
26
+ """
27
+ Module for executing commands in the sandbox.
28
+ """
29
+
30
+ def __init__(
31
+ self,
32
+ envd_api_url: str,
33
+ connection_config: ConnectionConfig,
34
+ envd_version: Version,
35
+ ) -> None:
36
+ self._envd_api_url = envd_api_url
37
+ self._connection_config = connection_config
38
+ self._envd_version = envd_version
39
+ self._thread_local = threading.local()
40
+
41
+ def _create_envd_api(self) -> httpx.Client:
42
+ transport = get_envd_transport(self._connection_config)
43
+ return httpx.Client(
44
+ base_url=self._envd_api_url,
45
+ transport=transport,
46
+ headers=self._connection_config.sandbox_headers,
47
+ event_hooks=make_logging_event_hooks(self._connection_config.logger),
48
+ )
49
+
50
+ def _create_rpc(self) -> process_connect.ProcessClient:
51
+ transport = get_envd_transport(self._connection_config)
52
+ return process_connect.ProcessClient(
53
+ self._envd_api_url,
54
+ # TODO: Fix and enable compression again — the headers compression is not solved for streaming.
55
+ # compressor=loopix_connect.GzipCompressor,
56
+ pool=transport.pool,
57
+ json=True,
58
+ headers=self._connection_config.sandbox_headers,
59
+ logger=self._connection_config.logger,
60
+ )
61
+
62
+ @property
63
+ def _envd_api(self) -> httpx.Client:
64
+ envd_api = getattr(self._thread_local, "envd_api", None)
65
+ if envd_api is None:
66
+ envd_api = self._create_envd_api()
67
+ self._thread_local.envd_api = envd_api
68
+ return envd_api
69
+
70
+ @property
71
+ def _rpc(self) -> process_connect.ProcessClient:
72
+ rpc = getattr(self._thread_local, "rpc", None)
73
+ if rpc is None:
74
+ rpc = self._create_rpc()
75
+ self._thread_local.rpc = rpc
76
+ return rpc
77
+
78
+ def _check_health(self) -> Optional[bool]:
79
+ return check_sandbox_health(self._envd_api)
80
+
81
+ def list(
82
+ self,
83
+ request_timeout: Optional[float] = None,
84
+ ) -> List[ProcessInfo]:
85
+ """
86
+ Lists all running commands and PTY sessions.
87
+
88
+ :param request_timeout: Timeout for the request in **seconds**
89
+
90
+ :return: List of running commands and PTY sessions
91
+ """
92
+ try:
93
+ res = self._rpc.list(
94
+ process_pb2.ListRequest(),
95
+ request_timeout=self._connection_config.get_request_timeout(
96
+ request_timeout
97
+ ),
98
+ )
99
+ return [
100
+ ProcessInfo(
101
+ pid=p.pid,
102
+ tag=p.tag if p.HasField("tag") else None,
103
+ cmd=p.config.cmd,
104
+ args=list(p.config.args),
105
+ envs=dict(p.config.envs),
106
+ cwd=p.config.cwd if p.config.HasField("cwd") else None,
107
+ )
108
+ for p in res.processes
109
+ ]
110
+ except Exception as e:
111
+ raise handle_rpc_exception_with_health(e, self._check_health)
112
+
113
+ def kill(
114
+ self,
115
+ pid: int,
116
+ request_timeout: Optional[float] = None,
117
+ ) -> bool:
118
+ """
119
+ Kill a running command specified by its process ID.
120
+ It uses `SIGKILL` signal to kill the command.
121
+
122
+ :param pid: Process ID of the command. You can get the list of processes using `sandbox.commands.list()`
123
+ :param request_timeout: Timeout for the request in **seconds**
124
+
125
+ :return: `True` if the command was killed, `False` if the command was not found
126
+ """
127
+ try:
128
+ self._rpc.send_signal(
129
+ process_pb2.SendSignalRequest(
130
+ process=process_pb2.ProcessSelector(pid=pid),
131
+ signal=process_pb2.Signal.SIGNAL_SIGKILL,
132
+ ),
133
+ request_timeout=self._connection_config.get_request_timeout(
134
+ request_timeout
135
+ ),
136
+ )
137
+ return True
138
+ except Exception as e:
139
+ if isinstance(e, loopix_connect.ConnectException):
140
+ if e.status == loopix_connect.Code.not_found:
141
+ return False
142
+ raise handle_rpc_exception_with_health(e, self._check_health)
143
+
144
+ def send_stdin(
145
+ self,
146
+ pid: int,
147
+ data: Union[str, bytes],
148
+ request_timeout: Optional[float] = None,
149
+ ):
150
+ """
151
+ Send data to command stdin.
152
+
153
+ :param pid Process ID of the command. You can get the list of processes using `sandbox.commands.list()`.
154
+ :param data: Data to send to the command
155
+ :param request_timeout: Timeout for the request in **seconds**
156
+ """
157
+ try:
158
+ self._rpc.send_input(
159
+ process_pb2.SendInputRequest(
160
+ process=process_pb2.ProcessSelector(pid=pid),
161
+ input=process_pb2.ProcessInput(
162
+ stdin=data.encode() if isinstance(data, str) else data,
163
+ ),
164
+ ),
165
+ request_timeout=self._connection_config.get_request_timeout(
166
+ request_timeout
167
+ ),
168
+ )
169
+ except Exception as e:
170
+ raise handle_rpc_exception_with_health(e, self._check_health)
171
+
172
+ def close_stdin(
173
+ self,
174
+ pid: int,
175
+ request_timeout: Optional[float] = None,
176
+ ) -> None:
177
+ """
178
+ Close the command stdin.
179
+
180
+ This signals EOF to the command. The command must have been started with `stdin=True`.
181
+
182
+ :param pid Process ID of the command. You can get the list of processes using `sandbox.commands.list()`.
183
+ :param request_timeout: Timeout for the request in **seconds**
184
+ """
185
+ if self._envd_version < ENVD_ENVD_CLOSE:
186
+ raise SandboxException(
187
+ f"Sandbox envd version {self._envd_version} doesn't support closing stdin. "
188
+ f"Please rebuild your template to pick up the latest sandbox version."
189
+ )
190
+
191
+ try:
192
+ self._rpc.close_stdin(
193
+ process_pb2.CloseStdinRequest(
194
+ process=process_pb2.ProcessSelector(pid=pid),
195
+ ),
196
+ request_timeout=self._connection_config.get_request_timeout(
197
+ request_timeout
198
+ ),
199
+ )
200
+ except Exception as e:
201
+ raise handle_rpc_exception_with_health(e, self._check_health)
202
+
203
+ @overload
204
+ def run(
205
+ self,
206
+ cmd: str,
207
+ background: Union[Literal[False], None] = None,
208
+ envs: Optional[Dict[str, str]] = None,
209
+ user: Optional[Username] = None,
210
+ cwd: Optional[str] = None,
211
+ on_stdout: Optional[Callable[[str], None]] = None,
212
+ on_stderr: Optional[Callable[[str], None]] = None,
213
+ stdin: Optional[bool] = None,
214
+ timeout: Optional[float] = 60,
215
+ request_timeout: Optional[float] = None,
216
+ ) -> CommandResult:
217
+ """
218
+ Start a new command and wait until it finishes executing.
219
+
220
+ :param cmd: Command to execute
221
+ :param background: **`False` if the command should be executed in the foreground**, `True` if the command should be executed in the background
222
+ :param envs: Environment variables used for the command
223
+ :param user: User to run the command as
224
+ :param cwd: Working directory to run the command
225
+ :param on_stdout: Callback for command stdout output
226
+ :param on_stderr: Callback for command stderr output
227
+ :param stdin: If `True`, the command will have a stdin stream that you can send data to using `sandbox.commands.send_stdin()`
228
+ :param timeout: Timeout for the command connection in **seconds**. Using `0` will not limit the command connection time
229
+ :param request_timeout: Timeout for the request in **seconds**
230
+
231
+ :return: `CommandResult` result of the command execution
232
+ """
233
+ ...
234
+
235
+ @overload
236
+ def run(
237
+ self,
238
+ cmd: str,
239
+ background: Literal[True],
240
+ envs: Optional[Dict[str, str]] = None,
241
+ user: Optional[Username] = None,
242
+ cwd: Optional[str] = None,
243
+ on_stdout: None = None,
244
+ on_stderr: None = None,
245
+ stdin: Optional[bool] = None,
246
+ timeout: Optional[float] = 60,
247
+ request_timeout: Optional[float] = None,
248
+ ) -> CommandHandle:
249
+ """
250
+ Start a new command and return a handle to interact with it.
251
+
252
+ :param cmd: Command to execute
253
+ :param background: `False` if the command should be executed in the foreground, **`True` if the command should be executed in the background**
254
+ :param envs: Environment variables used for the command
255
+ :param user: User to run the command as
256
+ :param cwd: Working directory to run the command
257
+ :param stdin: If `True`, the command will have a stdin stream that you can send data to using `sandbox.commands.send_stdin()`
258
+ :param timeout: Timeout for the command connection in **seconds**. Using `0` will not limit the command connection time
259
+ :param request_timeout: Timeout for the request in **seconds**
260
+
261
+ :return: `CommandHandle` handle to interact with the running command
262
+ """
263
+ ...
264
+
265
+ def run(
266
+ self,
267
+ cmd: str,
268
+ background: Union[bool, None] = None,
269
+ envs: Optional[Dict[str, str]] = None,
270
+ user: Optional[Username] = None,
271
+ cwd: Optional[str] = None,
272
+ on_stdout: Optional[Callable[[str], None]] = None,
273
+ on_stderr: Optional[Callable[[str], None]] = None,
274
+ stdin: Optional[bool] = None,
275
+ timeout: Optional[float] = 60,
276
+ request_timeout: Optional[float] = None,
277
+ ):
278
+ # Check version for stdin support
279
+ if stdin is False and self._envd_version < ENVD_COMMANDS_STDIN:
280
+ raise SandboxException(
281
+ f"Sandbox envd version {self._envd_version} can't specify stdin, it's always turned on. "
282
+ f"Please rebuild your template if you need this feature."
283
+ )
284
+
285
+ # Default to `False`
286
+ stdin = stdin or False
287
+
288
+ proc = self._start(
289
+ cmd,
290
+ envs,
291
+ user,
292
+ cwd,
293
+ stdin,
294
+ timeout,
295
+ request_timeout,
296
+ )
297
+
298
+ return (
299
+ proc
300
+ if background
301
+ else proc.wait(
302
+ on_stdout=on_stdout,
303
+ on_stderr=on_stderr,
304
+ )
305
+ )
306
+
307
+ def _start(
308
+ self,
309
+ cmd: str,
310
+ envs: Optional[Dict[str, str]],
311
+ user: Optional[Username],
312
+ cwd: Optional[str],
313
+ stdin: bool,
314
+ timeout: Optional[float],
315
+ request_timeout: Optional[float],
316
+ ):
317
+ events = self._rpc.start(
318
+ process_pb2.StartRequest(
319
+ process=process_pb2.ProcessConfig(
320
+ cmd="/bin/bash",
321
+ envs=envs,
322
+ args=["-l", "-c", cmd],
323
+ cwd=cwd,
324
+ ),
325
+ stdin=stdin,
326
+ ),
327
+ headers={
328
+ **authentication_header(self._envd_version, user),
329
+ KEEPALIVE_PING_HEADER: str(KEEPALIVE_PING_INTERVAL_SEC),
330
+ },
331
+ timeout=timeout,
332
+ request_timeout=self._connection_config.get_request_timeout(
333
+ request_timeout
334
+ ),
335
+ )
336
+
337
+ try:
338
+ start_event = events.__next__()
339
+
340
+ if not start_event.HasField("event"):
341
+ raise SandboxException(
342
+ f"Failed to start process: expected start event, got {start_event}"
343
+ )
344
+
345
+ pid = start_event.event.start.pid
346
+ return CommandHandle(
347
+ pid=pid,
348
+ handle_kill=lambda: self.kill(pid),
349
+ events=events,
350
+ handle_send_stdin=lambda data, request_timeout=None: self.send_stdin(
351
+ pid, data, request_timeout
352
+ ),
353
+ handle_close_stdin=lambda request_timeout=None: self.close_stdin(
354
+ pid, request_timeout
355
+ ),
356
+ check_health=self._check_health,
357
+ )
358
+ except Exception as e:
359
+ try:
360
+ events.close()
361
+ except Exception:
362
+ pass
363
+ raise handle_rpc_exception_with_health(e, self._check_health)
364
+
365
+ def connect(
366
+ self,
367
+ pid: int,
368
+ timeout: Optional[float] = 60,
369
+ request_timeout: Optional[float] = None,
370
+ ):
371
+ """
372
+ Connects to a running command.
373
+ You can use `CommandHandle.wait()` to wait for the command to finish and get execution results.
374
+
375
+ :param pid: Process ID of the command to connect to. You can get the list of processes using `sandbox.commands.list()`
376
+ :param timeout: Timeout for the connection in **seconds**. Using `0` will not limit the connection time
377
+ :param request_timeout: Timeout for the request in **seconds**
378
+
379
+ :return: `CommandHandle` handle to interact with the running command
380
+ """
381
+ events = self._rpc.connect(
382
+ process_pb2.ConnectRequest(
383
+ process=process_pb2.ProcessSelector(pid=pid),
384
+ ),
385
+ headers={
386
+ KEEPALIVE_PING_HEADER: str(KEEPALIVE_PING_INTERVAL_SEC),
387
+ },
388
+ timeout=timeout,
389
+ request_timeout=self._connection_config.get_request_timeout(
390
+ request_timeout
391
+ ),
392
+ )
393
+
394
+ try:
395
+ start_event = events.__next__()
396
+
397
+ if not start_event.HasField("event"):
398
+ raise SandboxException(
399
+ f"Failed to connect to process: expected start event, got {start_event}"
400
+ )
401
+
402
+ pid = start_event.event.start.pid
403
+ return CommandHandle(
404
+ pid=pid,
405
+ handle_kill=lambda: self.kill(pid),
406
+ events=events,
407
+ handle_send_stdin=lambda data, request_timeout=None: self.send_stdin(
408
+ pid, data, request_timeout
409
+ ),
410
+ handle_close_stdin=lambda request_timeout=None: self.close_stdin(
411
+ pid, request_timeout
412
+ ),
413
+ check_health=self._check_health,
414
+ )
415
+ except Exception as e:
416
+ try:
417
+ events.close()
418
+ except Exception:
419
+ pass
420
+ raise handle_rpc_exception_with_health(e, self._check_health)
@@ -0,0 +1,239 @@
1
+ import codecs
2
+
3
+ from typing import Optional, Callable, Any, Generator, List, Union, Tuple
4
+
5
+ from loopix.envd.rpc import handle_rpc_exception_with_health
6
+ from loopix.envd.process import process_pb2
7
+ from loopix.exceptions import SandboxException
8
+ from loopix.sandbox.commands.command_handle import (
9
+ CommandExitException,
10
+ CommandResult,
11
+ Stderr,
12
+ Stdout,
13
+ PtyOutput,
14
+ )
15
+
16
+
17
+ class CommandHandle:
18
+ """
19
+ Command execution handle.
20
+
21
+ It provides methods for waiting for the command to finish, retrieving stdout/stderr, and killing the command.
22
+ """
23
+
24
+ @property
25
+ def pid(self):
26
+ """
27
+ Command process ID.
28
+ """
29
+ return self._pid
30
+
31
+ def __init__(
32
+ self,
33
+ pid: int,
34
+ handle_kill: Callable[[], bool],
35
+ events: Generator[
36
+ Union[process_pb2.StartResponse, process_pb2.ConnectResponse], Any, None
37
+ ],
38
+ handle_send_stdin: Optional[
39
+ Callable[[Union[str, bytes], Optional[float]], None]
40
+ ] = None,
41
+ handle_close_stdin: Optional[Callable[[Optional[float]], None]] = None,
42
+ check_health: Optional[Callable[[], Optional[bool]]] = None,
43
+ ):
44
+ self._pid = pid
45
+ self._handle_kill = handle_kill
46
+ self._handle_send_stdin = handle_send_stdin
47
+ self._handle_close_stdin = handle_close_stdin
48
+ self._check_health = check_health
49
+ self._events = events
50
+
51
+ self._stdout_chunks: List[str] = []
52
+ self._stderr_chunks: List[str] = []
53
+
54
+ self._stdout_decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
55
+ self._stderr_decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
56
+
57
+ self._result: Optional[CommandResult] = None
58
+ self._iteration_exception: Optional[Exception] = None
59
+
60
+ def __iter__(self):
61
+ """
62
+ Iterate over the command output.
63
+
64
+ :return: Generator of command outputs
65
+ """
66
+ return self._handle_events()
67
+
68
+ def _flush_decoders(
69
+ self,
70
+ ) -> List[Union[Tuple[Stdout, None, None], Tuple[None, Stderr, None]]]:
71
+ """
72
+ Flush any bytes still buffered in the stream decoders.
73
+
74
+ Incomplete trailing UTF-8 sequences are emitted as replacement
75
+ characters, matching the per-chunk decoding behavior.
76
+ """
77
+ events: List[Union[Tuple[Stdout, None, None], Tuple[None, Stderr, None]]] = []
78
+ out = self._stdout_decoder.decode(b"", final=True)
79
+ if out:
80
+ self._stdout_chunks.append(out)
81
+ events.append((out, None, None))
82
+ err = self._stderr_decoder.decode(b"", final=True)
83
+ if err:
84
+ self._stderr_chunks.append(err)
85
+ events.append((None, err, None))
86
+ return events
87
+
88
+ def _handle_events(
89
+ self,
90
+ ) -> Generator[
91
+ Union[
92
+ Tuple[Stdout, None, None],
93
+ Tuple[None, Stderr, None],
94
+ Tuple[None, None, PtyOutput],
95
+ ],
96
+ None,
97
+ None,
98
+ ]:
99
+ try:
100
+ for event in self._events:
101
+ if event.event.HasField("data"):
102
+ if event.event.data.stdout:
103
+ out = self._stdout_decoder.decode(event.event.data.stdout)
104
+ if out:
105
+ self._stdout_chunks.append(out)
106
+ yield out, None, None
107
+ if event.event.data.stderr:
108
+ out = self._stderr_decoder.decode(event.event.data.stderr)
109
+ if out:
110
+ self._stderr_chunks.append(out)
111
+ yield None, out, None
112
+ if event.event.data.pty:
113
+ yield None, None, event.event.data.pty
114
+ if event.event.HasField("end"):
115
+ # Flush trailing decoder bytes into the accumulators and
116
+ # record the result before yielding the flushed chunks, so a
117
+ # consumer that stops iterating on the first flushed chunk
118
+ # still observes the exit code.
119
+ flushed = list(self._flush_decoders())
120
+ self._result = CommandResult(
121
+ stdout="".join(self._stdout_chunks),
122
+ stderr="".join(self._stderr_chunks),
123
+ exit_code=event.event.end.exit_code,
124
+ error=event.event.end.error,
125
+ )
126
+ yield from flushed
127
+
128
+ # If the stream closed without an end event (e.g. disconnect or a
129
+ # dropped connection), flush any bytes still buffered in the
130
+ # decoders so incomplete trailing sequences surface as replacement
131
+ # characters instead of being silently dropped.
132
+ if self._result is None:
133
+ yield from self._flush_decoders()
134
+ except Exception as e:
135
+ # The stream raised before an end event (e.g. disconnect or RPC
136
+ # failure). Flush any bytes still buffered in the decoders so
137
+ # incomplete trailing sequences surface as replacement characters
138
+ # instead of being silently dropped, then surface the error.
139
+ yield from self._flush_decoders()
140
+ raise handle_rpc_exception_with_health(e, self._check_health)
141
+
142
+ def disconnect(self) -> None:
143
+ """
144
+ Disconnect from the command.
145
+
146
+ The command is not killed, but SDK stops receiving events from the command.
147
+ You can reconnect to the command using `sandbox.commands.connect` method.
148
+ """
149
+ self._events.close()
150
+
151
+ def wait(
152
+ self,
153
+ on_pty: Optional[Callable[[PtyOutput], None]] = None,
154
+ on_stdout: Optional[Callable[[str], None]] = None,
155
+ on_stderr: Optional[Callable[[str], None]] = None,
156
+ ) -> CommandResult:
157
+ """
158
+ Wait for the command to finish and returns the result.
159
+ If the command exits with a non-zero exit code, it throws a `CommandExitException`.
160
+
161
+ :param on_pty: Callback for pty output
162
+ :param on_stdout: Callback for stdout output
163
+ :param on_stderr: Callback for stderr output
164
+
165
+ :return: `CommandResult` result of command execution
166
+ """
167
+ try:
168
+ for stdout, stderr, pty in self:
169
+ if stdout is not None and on_stdout:
170
+ on_stdout(stdout)
171
+ elif stderr is not None and on_stderr:
172
+ on_stderr(stderr)
173
+ elif pty is not None and on_pty:
174
+ on_pty(pty)
175
+ except StopIteration:
176
+ pass
177
+ except Exception as e:
178
+ self._iteration_exception = handle_rpc_exception_with_health(
179
+ e, self._check_health
180
+ )
181
+
182
+ if self._iteration_exception:
183
+ raise self._iteration_exception
184
+
185
+ if self._result is None:
186
+ raise Exception("Command ended without an end event")
187
+
188
+ if self._result.exit_code != 0:
189
+ raise CommandExitException(
190
+ stdout="".join(self._stdout_chunks),
191
+ stderr="".join(self._stderr_chunks),
192
+ exit_code=self._result.exit_code,
193
+ error=self._result.error,
194
+ )
195
+
196
+ return self._result
197
+
198
+ def kill(self) -> bool:
199
+ """
200
+ Kills the command.
201
+
202
+ It uses `SIGKILL` signal to kill the command.
203
+
204
+ :return: Whether the command was killed successfully
205
+ """
206
+ return self._handle_kill()
207
+
208
+ def send_stdin(
209
+ self,
210
+ data: Union[str, bytes],
211
+ request_timeout: Optional[float] = None,
212
+ ) -> None:
213
+ """
214
+ Send data to the command stdin.
215
+
216
+ The command must have been started with `stdin=True`.
217
+
218
+ :param data: Data to send to the command
219
+ :param request_timeout: Timeout for the request in **seconds**
220
+ """
221
+ if self._handle_send_stdin is None:
222
+ raise SandboxException(
223
+ "Sending stdin is not supported for this command handle."
224
+ )
225
+ self._handle_send_stdin(data, request_timeout)
226
+
227
+ def close_stdin(self, request_timeout: Optional[float] = None) -> None:
228
+ """
229
+ Close the command stdin.
230
+
231
+ This signals EOF to the command. The command must have been started with `stdin=True`.
232
+
233
+ :param request_timeout: Timeout for the request in **seconds**
234
+ """
235
+ if self._handle_close_stdin is None:
236
+ raise SandboxException(
237
+ "Closing stdin is not supported for this command handle."
238
+ )
239
+ self._handle_close_stdin(request_timeout)