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,531 @@
1
+ import httpcore
2
+ import httpx
3
+ from io import IOBase
4
+ from packaging.version import Version
5
+ from typing import AsyncIterator, IO, List, Literal, Optional, overload, Union
6
+ from moru.sandbox.filesystem.filesystem import WriteEntry
7
+ import moru_connect as connect
8
+ from moru.connection_config import (
9
+ ConnectionConfig,
10
+ Username,
11
+ default_username,
12
+ KEEPALIVE_PING_HEADER,
13
+ KEEPALIVE_PING_INTERVAL_SEC,
14
+ )
15
+ from moru.envd.api import ENVD_API_FILES_ROUTE, ahandle_envd_api_exception
16
+ from moru.envd.filesystem import filesystem_connect, filesystem_pb2
17
+ from moru.envd.rpc import authentication_header, handle_rpc_exception
18
+ from moru.envd.versions import ENVD_VERSION_RECURSIVE_WATCH, ENVD_DEFAULT_USER
19
+ from moru.exceptions import SandboxException, TemplateException, InvalidArgumentException
20
+ from moru.sandbox.filesystem.filesystem import (
21
+ WriteInfo,
22
+ EntryInfo,
23
+ map_file_type,
24
+ )
25
+ from moru.sandbox.filesystem.watch_handle import FilesystemEvent
26
+ from moru.sandbox_async.filesystem.watch_handle import AsyncWatchHandle
27
+ from moru.sandbox_async.utils import OutputHandler
28
+
29
+
30
+ class Filesystem:
31
+ """
32
+ Module for interacting with the filesystem in the sandbox.
33
+ """
34
+
35
+ def __init__(
36
+ self,
37
+ envd_api_url: str,
38
+ envd_version: Version,
39
+ connection_config: ConnectionConfig,
40
+ pool: httpcore.AsyncConnectionPool,
41
+ envd_api: httpx.AsyncClient,
42
+ ) -> None:
43
+ self._envd_api_url = envd_api_url
44
+ self._envd_version = envd_version
45
+ self._connection_config = connection_config
46
+ self._pool = pool
47
+ self._envd_api = envd_api
48
+
49
+ self._rpc = filesystem_connect.FilesystemClient(
50
+ envd_api_url,
51
+ # TODO: Fix and enable compression again — the headers compression is not solved for streaming.
52
+ # compressor=moru_connect.GzipCompressor,
53
+ async_pool=pool,
54
+ json=True,
55
+ headers=connection_config.sandbox_headers,
56
+ )
57
+
58
+ @overload
59
+ async def read(
60
+ self,
61
+ path: str,
62
+ format: Literal["text"] = "text",
63
+ user: Optional[Username] = None,
64
+ request_timeout: Optional[float] = None,
65
+ ) -> str:
66
+ """
67
+ Read file content as a `str`.
68
+
69
+ :param path: Path to the file
70
+ :param user: Run the operation as this user
71
+ :param format: Format of the file content—`text` by default
72
+ :param request_timeout: Timeout for the request in **seconds**
73
+
74
+ :return: File content as a `str`
75
+ """
76
+ ...
77
+
78
+ @overload
79
+ async def read(
80
+ self,
81
+ path: str,
82
+ format: Literal["bytes"],
83
+ user: Optional[Username] = None,
84
+ request_timeout: Optional[float] = None,
85
+ ) -> bytearray:
86
+ """
87
+ Read file content as a `bytearray`.
88
+
89
+ :param path: Path to the file
90
+ :param user: Run the operation as this user
91
+ :param format: Format of the file content—`bytes`
92
+ :param request_timeout: Timeout for the request in **seconds**
93
+
94
+ :return: File content as a `bytearray`
95
+ """
96
+ ...
97
+
98
+ @overload
99
+ async def read(
100
+ self,
101
+ path: str,
102
+ format: Literal["stream"],
103
+ user: Optional[Username] = None,
104
+ request_timeout: Optional[float] = None,
105
+ ) -> AsyncIterator[bytes]:
106
+ """
107
+ Read file content as a `AsyncIterator[bytes]`.
108
+
109
+ :param path: Path to the file
110
+ :param user: Run the operation as this user
111
+ :param format: Format of the file content—`stream`
112
+ :param request_timeout: Timeout for the request in **seconds**
113
+
114
+ :return: File content as an `AsyncIterator[bytes]`
115
+ """
116
+ ...
117
+
118
+ async def read(
119
+ self,
120
+ path: str,
121
+ format: Literal["text", "bytes", "stream"] = "text",
122
+ user: Optional[Username] = None,
123
+ request_timeout: Optional[float] = None,
124
+ ):
125
+ username = user
126
+ if username is None and self._envd_version < ENVD_DEFAULT_USER:
127
+ username = default_username
128
+
129
+ params = {"path": path}
130
+ if username:
131
+ params["username"] = username
132
+
133
+ r = await self._envd_api.get(
134
+ ENVD_API_FILES_ROUTE,
135
+ params=params,
136
+ timeout=self._connection_config.get_request_timeout(request_timeout),
137
+ )
138
+
139
+ err = await ahandle_envd_api_exception(r)
140
+ if err:
141
+ raise err
142
+
143
+ if format == "text":
144
+ return r.text
145
+ elif format == "bytes":
146
+ return bytearray(r.content)
147
+ elif format == "stream":
148
+ return r.aiter_bytes()
149
+
150
+ async def write(
151
+ self,
152
+ path: str,
153
+ data: Union[str, bytes, IO],
154
+ user: Optional[Username] = None,
155
+ request_timeout: Optional[float] = None,
156
+ ) -> WriteInfo:
157
+ """
158
+ Write content to a file on the path.
159
+ Writing to a file that doesn't exist creates the file.
160
+ Writing to a file that already exists overwrites the file.
161
+ Writing to a file at path that doesn't exist creates the necessary directories.
162
+
163
+ :param path: Path to the file
164
+ :param data: Data to write to the file, can be a `str`, `bytes`, or `IO`.
165
+ :param user: Run the operation as this user
166
+ :param request_timeout: Timeout for the request in **seconds**
167
+
168
+ :return: Information about the written file
169
+ """
170
+ result = await self.write_files(
171
+ [WriteEntry(path=path, data=data)], user, request_timeout
172
+ )
173
+
174
+ if len(result) != 1:
175
+ raise SandboxException("Received unexpected response from write operation")
176
+
177
+ return result[0]
178
+
179
+ async def write_files(
180
+ self,
181
+ files: List[WriteEntry],
182
+ user: Optional[Username] = None,
183
+ request_timeout: Optional[float] = None,
184
+ ) -> List[WriteInfo]:
185
+ """
186
+ Writes multiple files.
187
+
188
+ Writes a list of files to the filesystem.
189
+ When writing to a file that doesn't exist, the file will get created.
190
+ When writing to a file that already exists, the file will get overwritten.
191
+ When writing to a file that's in a directory that doesn't exist, you'll get an error.
192
+
193
+ :param files: list of files to write as `WriteEntry` objects, each containing `path` and `data`
194
+ :param user: Run the operation as this user
195
+ :param request_timeout: Timeout for the request
196
+ :return: Information about the written files
197
+ """
198
+ username = user
199
+ if username is None and self._envd_version < ENVD_DEFAULT_USER:
200
+ username = default_username
201
+
202
+ params = {}
203
+ if username:
204
+ params["username"] = username
205
+ if len(files) == 1:
206
+ params["path"] = files[0]["path"]
207
+
208
+ # Prepare the files for the multipart/form-data request
209
+ httpx_files = []
210
+ for file in files:
211
+ file_path, file_data = file["path"], file["data"]
212
+ if isinstance(file_data, str) or isinstance(file_data, bytes):
213
+ httpx_files.append(("file", (file_path, file_data)))
214
+ elif isinstance(file_data, IOBase):
215
+ httpx_files.append(("file", (file_path, file_data.read())))
216
+ else:
217
+ raise InvalidArgumentException(
218
+ f"Unsupported data type for file {file_path}"
219
+ )
220
+
221
+ # Allow passing empty list of files
222
+ if len(httpx_files) == 0:
223
+ return []
224
+
225
+ r = await self._envd_api.post(
226
+ ENVD_API_FILES_ROUTE,
227
+ files=httpx_files,
228
+ params=params,
229
+ timeout=self._connection_config.get_request_timeout(request_timeout),
230
+ )
231
+
232
+ err = await ahandle_envd_api_exception(r)
233
+ if err:
234
+ raise err
235
+
236
+ write_files = r.json()
237
+
238
+ if not isinstance(write_files, list) or len(write_files) == 0:
239
+ raise SandboxException("Expected to receive information about written file")
240
+
241
+ return [WriteInfo(**file) for file in write_files]
242
+
243
+ async def list(
244
+ self,
245
+ path: str,
246
+ depth: Optional[int] = 1,
247
+ user: Optional[Username] = None,
248
+ request_timeout: Optional[float] = None,
249
+ ) -> List[EntryInfo]:
250
+ """
251
+ List entries in a directory.
252
+
253
+ :param path: Path to the directory
254
+ :param depth: Depth of the directory to list
255
+ :param user: Run the operation as this user
256
+ :param request_timeout: Timeout for the request in **seconds**
257
+
258
+ :return: List of entries in the directory
259
+ """
260
+ if depth is not None and depth < 1:
261
+ raise InvalidArgumentException("depth should be at least 1")
262
+
263
+ try:
264
+ res = await self._rpc.alist_dir(
265
+ filesystem_pb2.ListDirRequest(path=path, depth=depth),
266
+ request_timeout=self._connection_config.get_request_timeout(
267
+ request_timeout
268
+ ),
269
+ headers=authentication_header(self._envd_version, user),
270
+ )
271
+
272
+ entries: List[EntryInfo] = []
273
+ for entry in res.entries:
274
+ event_type = map_file_type(entry.type)
275
+
276
+ if event_type:
277
+ entries.append(
278
+ EntryInfo(
279
+ name=entry.name,
280
+ type=event_type,
281
+ path=entry.path,
282
+ size=entry.size,
283
+ mode=entry.mode,
284
+ permissions=entry.permissions,
285
+ owner=entry.owner,
286
+ group=entry.group,
287
+ modified_time=entry.modified_time.ToDatetime(),
288
+ # Optional, we can't directly access symlink_target otherwise if will be "" instead of None
289
+ symlink_target=(
290
+ entry.symlink_target
291
+ if entry.HasField("symlink_target")
292
+ else None
293
+ ),
294
+ )
295
+ )
296
+
297
+ return entries
298
+ except Exception as e:
299
+ raise handle_rpc_exception(e)
300
+
301
+ async def exists(
302
+ self,
303
+ path: str,
304
+ user: Optional[Username] = None,
305
+ request_timeout: Optional[float] = None,
306
+ ) -> bool:
307
+ """
308
+ Check if a file or a directory exists.
309
+
310
+ :param path: Path to a file or a directory
311
+ :param user: Run the operation as this user
312
+ :param request_timeout: Timeout for the request in **seconds**
313
+
314
+ :return: `True` if the file or directory exists, `False` otherwise
315
+ """
316
+ try:
317
+ await self._rpc.astat(
318
+ filesystem_pb2.StatRequest(path=path),
319
+ request_timeout=self._connection_config.get_request_timeout(
320
+ request_timeout
321
+ ),
322
+ headers=authentication_header(self._envd_version, user),
323
+ )
324
+
325
+ return True
326
+
327
+ except Exception as e:
328
+ if isinstance(e, connect.ConnectException):
329
+ if e.status == connect.Code.not_found:
330
+ return False
331
+ raise handle_rpc_exception(e)
332
+
333
+ async def get_info(
334
+ self,
335
+ path: str,
336
+ user: Optional[Username] = None,
337
+ request_timeout: Optional[float] = None,
338
+ ) -> EntryInfo:
339
+ """
340
+ Get information about a file or directory.
341
+
342
+ :param path: Path to a file or a directory
343
+ :param user: Run the operation as this user
344
+ :param request_timeout: Timeout for the request in **seconds**
345
+
346
+ :return: Information about the file or directory like name, type, and path
347
+ """
348
+ try:
349
+ r = await self._rpc.astat(
350
+ filesystem_pb2.StatRequest(path=path),
351
+ request_timeout=self._connection_config.get_request_timeout(
352
+ request_timeout
353
+ ),
354
+ headers=authentication_header(self._envd_version, user),
355
+ )
356
+
357
+ return EntryInfo(
358
+ name=r.entry.name,
359
+ type=map_file_type(r.entry.type),
360
+ path=r.entry.path,
361
+ size=r.entry.size,
362
+ mode=r.entry.mode,
363
+ permissions=r.entry.permissions,
364
+ owner=r.entry.owner,
365
+ group=r.entry.group,
366
+ modified_time=r.entry.modified_time.ToDatetime(),
367
+ symlink_target=(
368
+ r.entry.symlink_target
369
+ if r.entry.HasField("symlink_target")
370
+ else None
371
+ ),
372
+ )
373
+ except Exception as e:
374
+ raise handle_rpc_exception(e)
375
+
376
+ async def remove(
377
+ self,
378
+ path: str,
379
+ user: Optional[Username] = None,
380
+ request_timeout: Optional[float] = None,
381
+ ) -> None:
382
+ """
383
+ Remove a file or a directory.
384
+
385
+ :param path: Path to a file or a directory
386
+ :param user: Run the operation as this user
387
+ :param request_timeout: Timeout for the request in **seconds**
388
+ """
389
+ try:
390
+ await self._rpc.aremove(
391
+ filesystem_pb2.RemoveRequest(path=path),
392
+ request_timeout=self._connection_config.get_request_timeout(
393
+ request_timeout
394
+ ),
395
+ headers=authentication_header(self._envd_version, user),
396
+ )
397
+ except Exception as e:
398
+ raise handle_rpc_exception(e)
399
+
400
+ async def rename(
401
+ self,
402
+ old_path: str,
403
+ new_path: str,
404
+ user: Optional[Username] = None,
405
+ request_timeout: Optional[float] = None,
406
+ ) -> EntryInfo:
407
+ """
408
+ Rename a file or directory.
409
+
410
+ :param old_path: Path to the file or directory to rename
411
+ :param new_path: New path to the file or directory
412
+ :param user: Run the operation as this user
413
+ :param request_timeout: Timeout for the request in **seconds**
414
+
415
+ :return: Information about the renamed file or directory
416
+ """
417
+ try:
418
+ r = await self._rpc.amove(
419
+ filesystem_pb2.MoveRequest(
420
+ source=old_path,
421
+ destination=new_path,
422
+ ),
423
+ request_timeout=self._connection_config.get_request_timeout(
424
+ request_timeout
425
+ ),
426
+ headers=authentication_header(self._envd_version, user),
427
+ )
428
+
429
+ return EntryInfo(
430
+ name=r.entry.name,
431
+ type=map_file_type(r.entry.type),
432
+ path=r.entry.path,
433
+ size=r.entry.size,
434
+ mode=r.entry.mode,
435
+ permissions=r.entry.permissions,
436
+ owner=r.entry.owner,
437
+ group=r.entry.group,
438
+ modified_time=r.entry.modified_time.ToDatetime(),
439
+ # Optional, we can't directly access symlink_target otherwise if will be "" instead of None
440
+ symlink_target=(
441
+ r.entry.symlink_target
442
+ if r.entry.HasField("symlink_target")
443
+ else None
444
+ ),
445
+ )
446
+ except Exception as e:
447
+ raise handle_rpc_exception(e)
448
+
449
+ async def make_dir(
450
+ self,
451
+ path: str,
452
+ user: Optional[Username] = None,
453
+ request_timeout: Optional[float] = None,
454
+ ) -> bool:
455
+ """
456
+ Create a new directory and all directories along the way if needed on the specified path.
457
+
458
+ :param path: Path to a new directory. For example '/dirA/dirB' when creating 'dirB'.
459
+ :param user: Run the operation as this user
460
+ :param request_timeout: Timeout for the request in **seconds**
461
+
462
+ :return: `True` if the directory was created, `False` if the directory already exists
463
+ """
464
+ try:
465
+ await self._rpc.amake_dir(
466
+ filesystem_pb2.MakeDirRequest(path=path),
467
+ request_timeout=self._connection_config.get_request_timeout(
468
+ request_timeout
469
+ ),
470
+ headers=authentication_header(self._envd_version, user),
471
+ )
472
+
473
+ return True
474
+ except Exception as e:
475
+ if isinstance(e, connect.ConnectException):
476
+ if e.status == connect.Code.already_exists:
477
+ return False
478
+ raise handle_rpc_exception(e)
479
+
480
+ async def watch_dir(
481
+ self,
482
+ path: str,
483
+ on_event: OutputHandler[FilesystemEvent],
484
+ on_exit: Optional[OutputHandler[Exception]] = None,
485
+ user: Optional[Username] = None,
486
+ request_timeout: Optional[float] = None,
487
+ timeout: Optional[float] = 60,
488
+ recursive: bool = False,
489
+ ) -> AsyncWatchHandle:
490
+ """
491
+ Watch directory for filesystem events.
492
+
493
+ :param path: Path to a directory to watch
494
+ :param on_event: Callback to call on each event in the directory
495
+ :param on_exit: Callback to call when the watching ends
496
+ :param user: Run the operation as this user
497
+ :param request_timeout: Timeout for the request in **seconds**
498
+ :param timeout: Timeout for the watch operation in **seconds**. Using `0` will not limit the watch time
499
+ :param recursive: Watch directory recursively
500
+
501
+ :return: `AsyncWatchHandle` object for stopping watching directory
502
+ """
503
+ if recursive and self._envd_version < ENVD_VERSION_RECURSIVE_WATCH:
504
+ raise TemplateException(
505
+ "You need to update the template to use recursive watching. "
506
+ "You can do this by running `moru template build` in the directory with the template."
507
+ )
508
+
509
+ events = self._rpc.awatch_dir(
510
+ filesystem_pb2.WatchDirRequest(path=path, recursive=recursive),
511
+ request_timeout=self._connection_config.get_request_timeout(
512
+ request_timeout
513
+ ),
514
+ timeout=timeout,
515
+ headers={
516
+ **authentication_header(self._envd_version, user),
517
+ KEEPALIVE_PING_HEADER: str(KEEPALIVE_PING_INTERVAL_SEC),
518
+ },
519
+ )
520
+
521
+ try:
522
+ start_event = await events.__anext__()
523
+
524
+ if not start_event.HasField("start"):
525
+ raise SandboxException(
526
+ f"Failed to start watch: expected start event, got {start_event}",
527
+ )
528
+
529
+ return AsyncWatchHandle(events=events, on_event=on_event, on_exit=on_exit)
530
+ except Exception as e:
531
+ raise handle_rpc_exception(e)
@@ -0,0 +1,62 @@
1
+ import asyncio
2
+ import inspect
3
+
4
+ from typing import Any, AsyncGenerator, Optional
5
+
6
+ from moru.envd.rpc import handle_rpc_exception
7
+ from moru.envd.filesystem.filesystem_pb2 import WatchDirResponse
8
+ from moru.sandbox.filesystem.watch_handle import FilesystemEvent, map_event_type
9
+ from moru.sandbox_async.utils import OutputHandler
10
+
11
+
12
+ class AsyncWatchHandle:
13
+ """
14
+ Handle for watching a directory in the sandbox filesystem.
15
+
16
+ Use `.stop()` to stop watching the directory.
17
+ """
18
+
19
+ def __init__(
20
+ self,
21
+ events: AsyncGenerator[WatchDirResponse, Any],
22
+ on_event: OutputHandler[FilesystemEvent],
23
+ on_exit: Optional[OutputHandler[Exception]] = None,
24
+ ):
25
+ self._events = events
26
+ self._on_event = on_event
27
+ self._on_exit = on_exit
28
+
29
+ self._wait = asyncio.create_task(self._handle_events())
30
+
31
+ async def stop(self):
32
+ """
33
+ Stop watching the directory.
34
+ """
35
+ self._wait.cancel()
36
+ # BUG: In Python 3.8 closing async generator can throw RuntimeError.
37
+ # await self._events.aclose()
38
+
39
+ async def _iterate_events(self):
40
+ try:
41
+ async for event in self._events:
42
+ if event.HasField("filesystem"):
43
+ event_type = map_event_type(event.filesystem.type)
44
+ if event_type:
45
+ yield FilesystemEvent(
46
+ name=event.filesystem.name,
47
+ type=event_type,
48
+ )
49
+ except Exception as e:
50
+ raise handle_rpc_exception(e)
51
+
52
+ async def _handle_events(self):
53
+ try:
54
+ async for event in self._iterate_events():
55
+ cb = self._on_event(event)
56
+ if inspect.isawaitable(cb):
57
+ await cb
58
+ except Exception as e:
59
+ if self._on_exit:
60
+ cb = self._on_exit(e)
61
+ if inspect.isawaitable(cb):
62
+ await cb