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,337 @@
1
+ import gzip
2
+ import re
3
+ from dataclasses import dataclass, field
4
+ from datetime import datetime, timezone
5
+ from enum import Enum
6
+ from io import IOBase, TextIOBase
7
+ from typing import IO, AsyncIterator, Dict, Iterator, Optional, Union, TypedDict
8
+
9
+ import httpx
10
+
11
+ from loopix.envd.filesystem import filesystem_pb2
12
+ from loopix.exceptions import InvalidArgumentException
13
+ from loopix.io_utils import agzip_iter, aiter_io_chunks, gzip_iter, iter_io_chunks
14
+
15
+
16
+ class FileType(Enum):
17
+ """
18
+ Enum representing the type of filesystem object.
19
+ """
20
+
21
+ FILE = "file"
22
+ """
23
+ Filesystem object is a file.
24
+ """
25
+ DIR = "dir"
26
+ """
27
+ Filesystem object is a directory.
28
+ """
29
+
30
+
31
+ def map_file_type(ft: filesystem_pb2.FileType):
32
+ if ft == filesystem_pb2.FileType.FILE_TYPE_FILE:
33
+ return FileType.FILE
34
+ elif ft == filesystem_pb2.FileType.FILE_TYPE_DIRECTORY:
35
+ return FileType.DIR
36
+
37
+
38
+ def map_file_type_str(value: Optional[str]) -> Optional[FileType]:
39
+ """Map a `/files` API type string to `FileType`, `None` when unknown."""
40
+ if value == FileType.FILE.value:
41
+ return FileType.FILE
42
+ elif value == FileType.DIR.value:
43
+ return FileType.DIR
44
+ return None
45
+
46
+
47
+ @dataclass
48
+ class WriteInfo:
49
+ """
50
+ Sandbox filesystem object information.
51
+ """
52
+
53
+ name: str
54
+ """
55
+ Name of the filesystem object.
56
+ """
57
+ type: Optional[FileType]
58
+ """
59
+ Type of the filesystem object.
60
+ """
61
+ path: str
62
+ """
63
+ Path to the filesystem object.
64
+ """
65
+ metadata: Optional[Dict[str, str]] = field(default=None, kw_only=True)
66
+ """
67
+ User-defined metadata stored on the file as `user.loopix.*` extended
68
+ attributes. On writes this reflects the metadata supplied on upload; on
69
+ reads (`get_info`, `list`, `rename`) it reflects any `user.loopix.*` xattr on
70
+ the file, including ones set out-of-band. `None` when none is set.
71
+ """
72
+
73
+ @classmethod
74
+ def from_dict(cls, payload: Dict) -> "WriteInfo":
75
+ """Build a `WriteInfo` from a `/files` upload response entry."""
76
+ return cls(
77
+ name=payload["name"],
78
+ type=map_file_type_str(payload.get("type")),
79
+ path=payload["path"],
80
+ metadata=map_metadata(payload.get("metadata")),
81
+ )
82
+
83
+
84
+ @dataclass
85
+ class EntryInfo(WriteInfo):
86
+ """
87
+ Extended sandbox filesystem object information.
88
+ """
89
+
90
+ size: int
91
+ """
92
+ Size of the filesystem object in bytes.
93
+ """
94
+ mode: int
95
+ """
96
+ File mode and permission bits.
97
+ """
98
+ permissions: str
99
+ """
100
+ String representation of file permissions (e.g. 'rwxr-xr-x').
101
+ """
102
+ owner: str
103
+ """
104
+ Owner of the filesystem object.
105
+ """
106
+ group: str
107
+ """
108
+ Group owner of the filesystem object.
109
+ """
110
+ modified_time: datetime
111
+ """
112
+ Last modification time of the filesystem object.
113
+ """
114
+ symlink_target: Optional[str] = None
115
+ """
116
+ Target of the symlink if the filesystem object is a symlink.
117
+ If the filesystem object is not a symlink, this field is None.
118
+ """
119
+
120
+
121
+ def map_entry_info(entry: filesystem_pb2.EntryInfo) -> EntryInfo:
122
+ return EntryInfo(
123
+ name=entry.name,
124
+ type=map_file_type(entry.type),
125
+ path=entry.path,
126
+ size=entry.size,
127
+ mode=entry.mode,
128
+ permissions=entry.permissions,
129
+ owner=entry.owner,
130
+ group=entry.group,
131
+ modified_time=entry.modified_time.ToDatetime(tzinfo=timezone.utc),
132
+ # Optional, we can't directly access symlink_target otherwise it will be "" instead of None
133
+ symlink_target=(
134
+ entry.symlink_target if entry.HasField("symlink_target") else None
135
+ ),
136
+ metadata=map_metadata(entry.metadata),
137
+ )
138
+
139
+
140
+ class WriteEntry(TypedDict):
141
+ """
142
+ Contains path and data of the file to be written to the filesystem.
143
+ """
144
+
145
+ path: str
146
+ data: Union[str, bytes, IO]
147
+
148
+
149
+ class FileStreamReader(Iterator[bytes]):
150
+ """Iterator over a streamed file download.
151
+
152
+ Returned by ``Sandbox.files.read(format="stream")``. It owns the underlying
153
+ HTTP response and releases its pooled connection as soon as the stream is
154
+ fully consumed, an error is raised while reading (including the idle-read
155
+ timeout, which raises ``httpx.ReadTimeout``), or the reader is closed.
156
+
157
+ There is no garbage-collection safety net, so always consume it fully, use
158
+ it as a context manager, or call :meth:`close`::
159
+
160
+ with sandbox.files.read(path, format="stream") as stream:
161
+ for chunk in stream:
162
+ ...
163
+ """
164
+
165
+ def __init__(self, response: httpx.Response):
166
+ self._response = response
167
+ self._iterator = response.iter_bytes()
168
+ self._closed = False
169
+
170
+ def __iter__(self) -> Iterator[bytes]:
171
+ return self
172
+
173
+ def __next__(self) -> bytes:
174
+ try:
175
+ return next(self._iterator)
176
+ except BaseException:
177
+ # Covers normal end (StopIteration) and read errors alike.
178
+ self.close()
179
+ raise
180
+
181
+ def close(self) -> None:
182
+ """Release the underlying HTTP connection. Safe to call multiple times."""
183
+ if self._closed:
184
+ return
185
+ self._closed = True
186
+ self._response.close()
187
+
188
+ def __enter__(self) -> "FileStreamReader":
189
+ return self
190
+
191
+ def __exit__(self, *exc_info) -> None:
192
+ self.close()
193
+
194
+
195
+ class AsyncFileStreamReader(AsyncIterator[bytes]):
196
+ """Async iterator over a streamed file download.
197
+
198
+ Returned by ``AsyncSandbox.files.read(format="stream")``. It owns the
199
+ underlying HTTP response and releases its pooled connection as soon as the
200
+ stream is fully consumed, an error is raised while reading (including the
201
+ idle-read timeout, which raises ``httpx.ReadTimeout``), or the reader is
202
+ closed.
203
+
204
+ There is no garbage-collection safety net (releasing an async connection
205
+ requires awaiting ``aclose()``, which a finalizer cannot do reliably), so
206
+ always consume it fully, use it as an async context manager, or call
207
+ :meth:`aclose`::
208
+
209
+ async with await sandbox.files.read(path, format="stream") as stream:
210
+ async for chunk in stream:
211
+ ...
212
+ """
213
+
214
+ def __init__(self, response: httpx.Response):
215
+ self._response = response
216
+ self._iterator = response.aiter_bytes()
217
+ self._closed = False
218
+
219
+ def __aiter__(self) -> AsyncIterator[bytes]:
220
+ return self
221
+
222
+ async def __anext__(self) -> bytes:
223
+ try:
224
+ return await self._iterator.__anext__()
225
+ except BaseException:
226
+ # Covers normal end (StopAsyncIteration) and read errors alike.
227
+ await self.aclose()
228
+ raise
229
+
230
+ async def aclose(self) -> None:
231
+ """Release the underlying HTTP connection. Safe to call multiple times."""
232
+ if self._closed:
233
+ return
234
+ self._closed = True
235
+ await self._response.aclose()
236
+
237
+ async def __aenter__(self) -> "AsyncFileStreamReader":
238
+ return self
239
+
240
+ async def __aexit__(self, *exc_info) -> None:
241
+ await self.aclose()
242
+
243
+
244
+ def _to_httpx_file(file_path: str, file_data: Union[str, bytes, IO]):
245
+ """Build an httpx multipart `("file", (name, data))` tuple for the upload."""
246
+ if isinstance(file_data, (str, bytes)):
247
+ return ("file", (file_path, file_data))
248
+ elif isinstance(file_data, TextIOBase):
249
+ return ("file", (file_path, file_data.read()))
250
+ elif isinstance(file_data, IOBase):
251
+ return ("file", (file_path, file_data))
252
+ else:
253
+ raise InvalidArgumentException(f"Unsupported data type for file {file_path}")
254
+
255
+
256
+ def to_upload_body(
257
+ data: Union[str, bytes, IO],
258
+ use_gzip: bool = False,
259
+ ) -> Union[bytes, IO, Iterator[bytes]]:
260
+ """Prepare file data for upload, optionally gzip-compressed.
261
+
262
+ File-like objects are streamed in chunks instead of being buffered in
263
+ memory.
264
+ """
265
+ if isinstance(data, (str, bytes)):
266
+ raw = data.encode("utf-8") if isinstance(data, str) else data
267
+ return gzip.compress(raw) if use_gzip else raw
268
+ elif isinstance(data, (TextIOBase, IOBase)):
269
+ if use_gzip:
270
+ return gzip_iter(iter_io_chunks(data))
271
+ if isinstance(data, TextIOBase):
272
+ # Text-mode IO yields str chunks—encode them while streaming.
273
+ return iter_io_chunks(data)
274
+ # httpx streams binary file-like objects in chunks without buffering.
275
+ return data
276
+ else:
277
+ raise InvalidArgumentException(f"Unsupported data type: {type(data)}")
278
+
279
+
280
+ def to_upload_body_async(
281
+ data: Union[str, bytes, IO],
282
+ use_gzip: bool = False,
283
+ ) -> Union[bytes, AsyncIterator[bytes]]:
284
+ """Prepare file data for upload with async httpx, optionally gzip-compressed.
285
+
286
+ File-like objects are streamed in chunks instead of being buffered in
287
+ memory. Async httpx requires an async iterable for streamed request bodies.
288
+ """
289
+ if isinstance(data, (str, bytes)):
290
+ raw = data.encode("utf-8") if isinstance(data, str) else data
291
+ return gzip.compress(raw) if use_gzip else raw
292
+ elif isinstance(data, (TextIOBase, IOBase)):
293
+ chunks = aiter_io_chunks(data)
294
+ return agzip_iter(chunks) if use_gzip else chunks
295
+ else:
296
+ raise InvalidArgumentException(f"Unsupported data type: {type(data)}")
297
+
298
+
299
+ METADATA_HEADER_PREFIX = "X-Metadata-"
300
+
301
+ # Metadata keys travel as `X-Metadata-<key>` HTTP header names, so they must be
302
+ # valid header tokens (RFC 7230); values travel as header values, restricted to
303
+ # printable US-ASCII.
304
+ _METADATA_KEY_REGEX = re.compile(r"\A[A-Za-z0-9!#$%&'*+\-.^_`|~]+\Z")
305
+ _METADATA_VALUE_REGEX = re.compile(r"\A[\x20-\x7e]*\Z")
306
+
307
+
308
+ def validate_metadata(metadata: Optional[Dict[str, str]]) -> None:
309
+ """Validate metadata keys/values before they are sent as upload headers."""
310
+ if not metadata:
311
+ return
312
+ for key, value in metadata.items():
313
+ if not _METADATA_KEY_REGEX.match(key):
314
+ raise InvalidArgumentException(
315
+ f"Invalid metadata key {key!r}: keys must be non-empty and use only "
316
+ "HTTP token characters (letters, digits and !#$%&'*+-.^_`|~)."
317
+ )
318
+ if not _METADATA_VALUE_REGEX.match(value):
319
+ raise InvalidArgumentException(
320
+ f"Invalid metadata value for key {key!r}: values must be printable US-ASCII."
321
+ )
322
+
323
+
324
+ def metadata_to_headers(
325
+ metadata: Optional[Dict[str, str]],
326
+ ) -> Dict[str, str]:
327
+ """Translate user metadata into the `X-Metadata-*` upload headers envd reads."""
328
+ if not metadata:
329
+ return {}
330
+ return {f"{METADATA_HEADER_PREFIX}{key}": value for key, value in metadata.items()}
331
+
332
+
333
+ def map_metadata(metadata) -> Optional[Dict[str, str]]:
334
+ """Normalize a proto/HTTP metadata map: drop empties and return a plain dict or None."""
335
+ if not metadata:
336
+ return None
337
+ return dict(metadata)
@@ -0,0 +1,70 @@
1
+ from dataclasses import dataclass
2
+ from enum import Enum
3
+ from typing import Optional
4
+
5
+ from loopix.envd.filesystem.filesystem_pb2 import EventType
6
+ from loopix.sandbox.filesystem.filesystem import EntryInfo
7
+
8
+
9
+ class FilesystemEventType(Enum):
10
+ """
11
+ Enum representing the type of filesystem event.
12
+ """
13
+
14
+ CHMOD = "chmod"
15
+ """
16
+ Filesystem object permissions were changed.
17
+ """
18
+ CREATE = "create"
19
+ """
20
+ Filesystem object was created.
21
+ """
22
+ REMOVE = "remove"
23
+ """
24
+ Filesystem object was removed.
25
+ """
26
+ RENAME = "rename"
27
+ """
28
+ Filesystem object was renamed.
29
+ """
30
+ WRITE = "write"
31
+ """
32
+ Filesystem object was written to.
33
+ """
34
+
35
+
36
+ def map_event_type(event: EventType):
37
+ if event == EventType.EVENT_TYPE_CHMOD:
38
+ return FilesystemEventType.CHMOD
39
+ elif event == EventType.EVENT_TYPE_CREATE:
40
+ return FilesystemEventType.CREATE
41
+ elif event == EventType.EVENT_TYPE_REMOVE:
42
+ return FilesystemEventType.REMOVE
43
+ elif event == EventType.EVENT_TYPE_RENAME:
44
+ return FilesystemEventType.RENAME
45
+ elif event == EventType.EVENT_TYPE_WRITE:
46
+ return FilesystemEventType.WRITE
47
+
48
+
49
+ @dataclass
50
+ class FilesystemEvent:
51
+ """
52
+ Contains information about the filesystem event - the name of the file and the type of the event.
53
+ """
54
+
55
+ name: str
56
+ """
57
+ Relative path to the filesystem object.
58
+ """
59
+ type: FilesystemEventType
60
+ """
61
+ Filesystem operation event type.
62
+ """
63
+ entry: Optional[EntryInfo] = None
64
+ """
65
+ Information about the entry that triggered the event.
66
+
67
+ Only populated when the watch was started with `include_entry=True` and the
68
+ sandbox's envd version supports it. It may be `None` for events where the entry
69
+ no longer exists at the path (e.g. remove or rename-away events).
70
+ """
loopix/sandbox/main.py ADDED
@@ -0,0 +1,227 @@
1
+ import urllib.parse
2
+ from typing import Optional, TypedDict
3
+
4
+ from packaging.version import Version
5
+
6
+ from loopix.connection_config import ConnectionConfig, default_username
7
+ from loopix.envd.api import ENVD_API_FILES_ROUTE
8
+ from loopix.envd.versions import ENVD_DEFAULT_USER
9
+ from loopix.exceptions import InvalidArgumentException
10
+ from loopix.sandbox.signature import get_signature
11
+
12
+
13
+ class SandboxOpts(TypedDict):
14
+ sandbox_id: str
15
+ sandbox_domain: Optional[str]
16
+ envd_version: Version
17
+ envd_access_token: Optional[str]
18
+ traffic_access_token: Optional[str]
19
+ connection_config: ConnectionConfig
20
+
21
+
22
+ class SandboxBase:
23
+ mcp_port = 50005
24
+
25
+ default_sandbox_timeout = 300
26
+
27
+ default_template = "base"
28
+ default_mcp_template = "mcp-gateway"
29
+
30
+ def __init__(
31
+ self,
32
+ sandbox_id: str,
33
+ envd_version: Version,
34
+ envd_access_token: Optional[str],
35
+ sandbox_domain: Optional[str],
36
+ connection_config: ConnectionConfig,
37
+ traffic_access_token: Optional[str] = None,
38
+ ):
39
+ self.__connection_config = connection_config
40
+ self.__sandbox_id = sandbox_id
41
+ self.__sandbox_domain = sandbox_domain or self.connection_config.domain
42
+ self.__envd_version = envd_version
43
+ self.__envd_access_token = envd_access_token
44
+ self.__traffic_access_token = traffic_access_token
45
+ self.__envd_api_url = self.connection_config.get_sandbox_url(
46
+ self.sandbox_id, self.sandbox_domain
47
+ )
48
+ self.__envd_direct_url = self.connection_config.get_sandbox_direct_url(
49
+ self.sandbox_id, self.sandbox_domain
50
+ )
51
+ self.__mcp_token: Optional[str] = None
52
+
53
+ @property
54
+ def _envd_access_token(self) -> Optional[str]:
55
+ """Private property to access the envd token"""
56
+ return self.__envd_access_token
57
+
58
+ @property
59
+ def _mcp_token(self) -> Optional[str]:
60
+ return self.__mcp_token
61
+
62
+ @_mcp_token.setter
63
+ def _mcp_token(self, token: str) -> None:
64
+ self.__mcp_token = token
65
+
66
+ @property
67
+ def connection_config(self) -> ConnectionConfig:
68
+ return self.__connection_config
69
+
70
+ @property
71
+ def _envd_version(self) -> Version:
72
+ return self.__envd_version
73
+
74
+ @property
75
+ def traffic_access_token(self) -> Optional[str]:
76
+ return self.__traffic_access_token
77
+
78
+ @property
79
+ def sandbox_domain(self) -> str:
80
+ return self.__sandbox_domain
81
+
82
+ @property
83
+ def envd_api_url(self) -> str:
84
+ return self.__envd_api_url
85
+
86
+ @property
87
+ def envd_direct_url(self) -> str:
88
+ return self.__envd_direct_url
89
+
90
+ @property
91
+ def sandbox_id(self) -> str:
92
+ """
93
+ Unique identifier of the sandbox.
94
+ """
95
+ return self.__sandbox_id
96
+
97
+ def _file_url(
98
+ self,
99
+ path: str,
100
+ user: Optional[str] = None,
101
+ signature: Optional[str] = None,
102
+ signature_expiration: Optional[int] = None,
103
+ ) -> str:
104
+ url = urllib.parse.urljoin(self.envd_direct_url, ENVD_API_FILES_ROUTE)
105
+ query = {"path": path} if path else {}
106
+
107
+ if user:
108
+ query["username"] = user
109
+
110
+ if signature:
111
+ query["signature"] = signature
112
+
113
+ if signature_expiration:
114
+ if signature is None:
115
+ raise ValueError("signature_expiration requires signature to be set")
116
+ query["signature_expiration"] = str(signature_expiration)
117
+
118
+ params = urllib.parse.urlencode(
119
+ query,
120
+ quote_via=urllib.parse.quote,
121
+ )
122
+ url = urllib.parse.urljoin(url, f"?{params}")
123
+
124
+ return url
125
+
126
+ def download_url(
127
+ self,
128
+ path: str,
129
+ user: Optional[str] = None,
130
+ use_signature_expiration: Optional[int] = None,
131
+ ) -> str:
132
+ """
133
+ Get the URL to download a file from the sandbox.
134
+
135
+ :param path: Path to the file to download
136
+ :param user: User to download the file as
137
+ :param use_signature_expiration: Expiration time for the signed URL in seconds
138
+
139
+ :return: URL for downloading file
140
+ """
141
+
142
+ use_signature = self._envd_access_token is not None
143
+ if not use_signature and use_signature_expiration is not None:
144
+ raise InvalidArgumentException(
145
+ "Signature expiration can be used only when sandbox is created as secured."
146
+ )
147
+
148
+ username = user
149
+ if username is None and self._envd_version < ENVD_DEFAULT_USER:
150
+ username = default_username
151
+
152
+ if use_signature:
153
+ signature = get_signature(
154
+ path,
155
+ "read",
156
+ username,
157
+ self._envd_access_token,
158
+ use_signature_expiration,
159
+ )
160
+ return self._file_url(
161
+ path, username, signature["signature"], signature["expiration"]
162
+ )
163
+ else:
164
+ return self._file_url(path, username)
165
+
166
+ def upload_url(
167
+ self,
168
+ path: str,
169
+ user: Optional[str] = None,
170
+ use_signature_expiration: Optional[int] = None,
171
+ ) -> str:
172
+ """
173
+ Get the URL to upload a file to the sandbox.
174
+
175
+ You have to send a POST request to this URL with the file as multipart/form-data.
176
+
177
+ :param path: Path to the file to upload
178
+ :param user: User to upload the file as
179
+ :param use_signature_expiration: Expiration time for the signed URL in seconds
180
+
181
+ :return: URL for uploading file
182
+ """
183
+
184
+ use_signature = self._envd_access_token is not None
185
+ if not use_signature and use_signature_expiration is not None:
186
+ raise InvalidArgumentException(
187
+ "Signature expiration can be used only when sandbox is created as secured."
188
+ )
189
+
190
+ username = user
191
+ if username is None and self._envd_version < ENVD_DEFAULT_USER:
192
+ username = default_username
193
+
194
+ if use_signature:
195
+ signature = get_signature(
196
+ path,
197
+ "write",
198
+ username,
199
+ self._envd_access_token,
200
+ use_signature_expiration,
201
+ )
202
+ return self._file_url(
203
+ path, username, signature["signature"], signature["expiration"]
204
+ )
205
+ else:
206
+ return self._file_url(path, username)
207
+
208
+ def get_host(self, port: int) -> str:
209
+ """
210
+ Get the host address to connect to the sandbox.
211
+ You can then use this address to connect to the sandbox port from outside the sandbox via HTTP or WebSocket.
212
+
213
+ :param port: Port to connect to
214
+
215
+ :return: Host address to connect to
216
+ """
217
+ return self.connection_config.get_host(
218
+ self.sandbox_id, self.sandbox_domain, port
219
+ )
220
+
221
+ def get_mcp_url(self) -> str:
222
+ """
223
+ Get the MCP URL for the sandbox.
224
+
225
+ :returns MCP URL for the sandbox.
226
+ """
227
+ return f"https://{self.get_host(self.mcp_port)}/mcp"