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.
- loopix/__init__.py +260 -0
- loopix/api/__init__.py +287 -0
- loopix/api/client/__init__.py +8 -0
- loopix/api/client/api/__init__.py +1 -0
- loopix/api/client/api/sandboxes/__init__.py +1 -0
- loopix/api/client/api/sandboxes/delete_sandboxes_sandbox_id.py +161 -0
- loopix/api/client/api/sandboxes/get_sandboxes.py +176 -0
- loopix/api/client/api/sandboxes/get_sandboxes_metrics.py +173 -0
- loopix/api/client/api/sandboxes/get_sandboxes_sandbox_id.py +163 -0
- loopix/api/client/api/sandboxes/get_sandboxes_sandbox_id_logs.py +199 -0
- loopix/api/client/api/sandboxes/get_sandboxes_sandbox_id_metrics.py +212 -0
- loopix/api/client/api/sandboxes/get_v2_sandboxes.py +230 -0
- loopix/api/client/api/sandboxes/get_v_2_sandboxes_sandbox_id_logs.py +254 -0
- loopix/api/client/api/sandboxes/post_sandboxes.py +172 -0
- loopix/api/client/api/sandboxes/post_sandboxes_sandbox_id_connect.py +193 -0
- loopix/api/client/api/sandboxes/post_sandboxes_sandbox_id_pause.py +187 -0
- loopix/api/client/api/sandboxes/post_sandboxes_sandbox_id_refreshes.py +181 -0
- loopix/api/client/api/sandboxes/post_sandboxes_sandbox_id_resume.py +189 -0
- loopix/api/client/api/sandboxes/post_sandboxes_sandbox_id_snapshots.py +195 -0
- loopix/api/client/api/sandboxes/post_sandboxes_sandbox_id_timeout.py +193 -0
- loopix/api/client/api/sandboxes/put_sandboxes_sandbox_id_network.py +199 -0
- loopix/api/client/api/snapshots/__init__.py +1 -0
- loopix/api/client/api/snapshots/get_snapshots.py +202 -0
- loopix/api/client/api/tags/__init__.py +1 -0
- loopix/api/client/api/tags/delete_templates_tags.py +174 -0
- loopix/api/client/api/tags/get_templates_template_id_tags.py +172 -0
- loopix/api/client/api/tags/post_templates_tags.py +176 -0
- loopix/api/client/api/templates/__init__.py +1 -0
- loopix/api/client/api/templates/delete_templates_template_id.py +157 -0
- loopix/api/client/api/templates/get_templates.py +172 -0
- loopix/api/client/api/templates/get_templates_aliases_alias.py +167 -0
- loopix/api/client/api/templates/get_templates_template_id.py +195 -0
- loopix/api/client/api/templates/get_templates_template_id_builds_build_id_logs.py +272 -0
- loopix/api/client/api/templates/get_templates_template_id_builds_build_id_status.py +232 -0
- loopix/api/client/api/templates/get_templates_template_id_files_hash.py +180 -0
- loopix/api/client/api/templates/patch_templates_template_id.py +183 -0
- loopix/api/client/api/templates/patch_v_2_templates_template_id.py +185 -0
- loopix/api/client/api/templates/post_templates.py +172 -0
- loopix/api/client/api/templates/post_templates_template_id.py +181 -0
- loopix/api/client/api/templates/post_templates_template_id_builds_build_id.py +170 -0
- loopix/api/client/api/templates/post_v2_templates.py +172 -0
- loopix/api/client/api/templates/post_v3_templates.py +176 -0
- loopix/api/client/api/templates/post_v_2_templates_template_id_builds_build_id.py +192 -0
- loopix/api/client/api/volumes/__init__.py +1 -0
- loopix/api/client/api/volumes/delete_volumes_volume_id.py +161 -0
- loopix/api/client/api/volumes/get_volumes.py +140 -0
- loopix/api/client/api/volumes/get_volumes_volume_id.py +163 -0
- loopix/api/client/api/volumes/post_volumes.py +172 -0
- loopix/api/client/client.py +286 -0
- loopix/api/client/errors.py +16 -0
- loopix/api/client/models/__init__.py +185 -0
- loopix/api/client/models/admin_build_cancel_result.py +67 -0
- loopix/api/client/models/admin_sandbox_kill_result.py +67 -0
- loopix/api/client/models/assign_template_tags_request.py +67 -0
- loopix/api/client/models/assigned_template_tags.py +68 -0
- loopix/api/client/models/aws_registry.py +85 -0
- loopix/api/client/models/aws_registry_type.py +8 -0
- loopix/api/client/models/build_log_entry.py +89 -0
- loopix/api/client/models/build_status_reason.py +95 -0
- loopix/api/client/models/connect_sandbox.py +59 -0
- loopix/api/client/models/created_access_token.py +100 -0
- loopix/api/client/models/created_team_api_key.py +166 -0
- loopix/api/client/models/delete_template_tags_request.py +67 -0
- loopix/api/client/models/disk_metrics.py +91 -0
- loopix/api/client/models/error.py +67 -0
- loopix/api/client/models/gcp_registry.py +69 -0
- loopix/api/client/models/gcp_registry_type.py +8 -0
- loopix/api/client/models/general_registry.py +77 -0
- loopix/api/client/models/general_registry_type.py +8 -0
- loopix/api/client/models/identifier_masking_details.py +83 -0
- loopix/api/client/models/listed_sandbox.py +179 -0
- loopix/api/client/models/log_level.py +11 -0
- loopix/api/client/models/logs_direction.py +9 -0
- loopix/api/client/models/logs_source.py +9 -0
- loopix/api/client/models/machine_info.py +83 -0
- loopix/api/client/models/max_team_metric.py +78 -0
- loopix/api/client/models/mcp_type_0.py +44 -0
- loopix/api/client/models/new_access_token.py +59 -0
- loopix/api/client/models/new_sandbox.py +224 -0
- loopix/api/client/models/new_team_api_key.py +59 -0
- loopix/api/client/models/new_volume.py +59 -0
- loopix/api/client/models/node.py +160 -0
- loopix/api/client/models/node_detail.py +160 -0
- loopix/api/client/models/node_metrics.py +122 -0
- loopix/api/client/models/node_status.py +12 -0
- loopix/api/client/models/node_status_change.py +82 -0
- loopix/api/client/models/post_sandboxes_sandbox_id_refreshes_body.py +59 -0
- loopix/api/client/models/post_sandboxes_sandbox_id_snapshots_body.py +60 -0
- loopix/api/client/models/post_sandboxes_sandbox_id_timeout_body.py +59 -0
- loopix/api/client/models/resumed_sandbox.py +68 -0
- loopix/api/client/models/sandbox.py +145 -0
- loopix/api/client/models/sandbox_auto_resume_config.py +60 -0
- loopix/api/client/models/sandbox_detail.py +267 -0
- loopix/api/client/models/sandbox_lifecycle.py +70 -0
- loopix/api/client/models/sandbox_log.py +70 -0
- loopix/api/client/models/sandbox_log_entry.py +93 -0
- loopix/api/client/models/sandbox_log_entry_fields.py +44 -0
- loopix/api/client/models/sandbox_logs.py +91 -0
- loopix/api/client/models/sandbox_logs_v2_response.py +73 -0
- loopix/api/client/models/sandbox_metric.py +126 -0
- loopix/api/client/models/sandbox_network_config.py +118 -0
- loopix/api/client/models/sandbox_network_config_rules.py +72 -0
- loopix/api/client/models/sandbox_network_rule.py +74 -0
- loopix/api/client/models/sandbox_network_transform.py +79 -0
- loopix/api/client/models/sandbox_network_transform_headers.py +47 -0
- loopix/api/client/models/sandbox_network_update_config.py +114 -0
- loopix/api/client/models/sandbox_network_update_config_rules.py +71 -0
- loopix/api/client/models/sandbox_on_timeout.py +9 -0
- loopix/api/client/models/sandbox_pause_request.py +62 -0
- loopix/api/client/models/sandbox_state.py +9 -0
- loopix/api/client/models/sandbox_volume_mount.py +67 -0
- loopix/api/client/models/sandboxes_with_metrics.py +59 -0
- loopix/api/client/models/snapshot_info.py +70 -0
- loopix/api/client/models/team.py +83 -0
- loopix/api/client/models/team_api_key.py +158 -0
- loopix/api/client/models/team_metric.py +86 -0
- loopix/api/client/models/team_user.py +75 -0
- loopix/api/client/models/template.py +225 -0
- loopix/api/client/models/template_alias_response.py +67 -0
- loopix/api/client/models/template_build.py +139 -0
- loopix/api/client/models/template_build_file_upload.py +70 -0
- loopix/api/client/models/template_build_info.py +126 -0
- loopix/api/client/models/template_build_logs_response.py +73 -0
- loopix/api/client/models/template_build_request.py +115 -0
- loopix/api/client/models/template_build_request_v2.py +88 -0
- loopix/api/client/models/template_build_request_v3.py +107 -0
- loopix/api/client/models/template_build_start_v2.py +184 -0
- loopix/api/client/models/template_build_status.py +11 -0
- loopix/api/client/models/template_legacy.py +207 -0
- loopix/api/client/models/template_request_response_v3.py +99 -0
- loopix/api/client/models/template_step.py +91 -0
- loopix/api/client/models/template_tag.py +78 -0
- loopix/api/client/models/template_update_request.py +59 -0
- loopix/api/client/models/template_update_response.py +59 -0
- loopix/api/client/models/template_with_builds.py +156 -0
- loopix/api/client/models/update_team_api_key.py +59 -0
- loopix/api/client/models/volume.py +67 -0
- loopix/api/client/models/volume_and_token.py +75 -0
- loopix/api/client/models/volume_token.py +59 -0
- loopix/api/client/py.typed +1 -0
- loopix/api/client/types.py +54 -0
- loopix/api/client_async/__init__.py +74 -0
- loopix/api/client_sync/__init__.py +73 -0
- loopix/api/metadata.py +14 -0
- loopix/connection_config.py +309 -0
- loopix/envd/api.py +170 -0
- loopix/envd/filesystem/filesystem_connect.py +193 -0
- loopix/envd/filesystem/filesystem_pb2.py +80 -0
- loopix/envd/filesystem/filesystem_pb2.pyi +272 -0
- loopix/envd/process/process_connect.py +174 -0
- loopix/envd/process/process_pb2.py +96 -0
- loopix/envd/process/process_pb2.pyi +316 -0
- loopix/envd/rpc.py +139 -0
- loopix/envd/versions.py +11 -0
- loopix/exceptions.py +133 -0
- loopix/io_utils.py +57 -0
- loopix/paginator.py +52 -0
- loopix/py.typed +0 -0
- loopix/sandbox/_git/__init__.py +85 -0
- loopix/sandbox/_git/args.py +363 -0
- loopix/sandbox/_git/auth.py +132 -0
- loopix/sandbox/_git/config.py +32 -0
- loopix/sandbox/_git/parse.py +222 -0
- loopix/sandbox/_git/types.py +149 -0
- loopix/sandbox/commands/command_handle.py +69 -0
- loopix/sandbox/commands/main.py +39 -0
- loopix/sandbox/filesystem/filesystem.py +337 -0
- loopix/sandbox/filesystem/watch_handle.py +70 -0
- loopix/sandbox/main.py +227 -0
- loopix/sandbox/mcp.py +1949 -0
- loopix/sandbox/network.py +8 -0
- loopix/sandbox/sandbox_api.py +624 -0
- loopix/sandbox/signature.py +47 -0
- loopix/sandbox/utils.py +34 -0
- loopix/sandbox_async/commands/command.py +396 -0
- loopix/sandbox_async/commands/command_handle.py +298 -0
- loopix/sandbox_async/commands/pty.py +257 -0
- loopix/sandbox_async/filesystem/filesystem.py +720 -0
- loopix/sandbox_async/filesystem/watch_handle.py +97 -0
- loopix/sandbox_async/git.py +1100 -0
- loopix/sandbox_async/main.py +987 -0
- loopix/sandbox_async/paginator.py +140 -0
- loopix/sandbox_async/sandbox_api.py +504 -0
- loopix/sandbox_async/utils.py +7 -0
- loopix/sandbox_domains.py +5 -0
- loopix/sandbox_sync/commands/command.py +420 -0
- loopix/sandbox_sync/commands/command_handle.py +239 -0
- loopix/sandbox_sync/commands/pty.py +279 -0
- loopix/sandbox_sync/filesystem/filesystem.py +710 -0
- loopix/sandbox_sync/filesystem/watch_handle.py +102 -0
- loopix/sandbox_sync/git.py +1077 -0
- loopix/sandbox_sync/main.py +975 -0
- loopix/sandbox_sync/paginator.py +140 -0
- loopix/sandbox_sync/sandbox_api.py +491 -0
- loopix/template/consts.py +45 -0
- loopix/template/dockerfile_parser.py +286 -0
- loopix/template/logger.py +232 -0
- loopix/template/main.py +1368 -0
- loopix/template/readycmd.py +144 -0
- loopix/template/types.py +194 -0
- loopix/template/utils.py +426 -0
- loopix/template_async/build_api.py +419 -0
- loopix/template_async/main.py +528 -0
- loopix/template_sync/build_api.py +409 -0
- loopix/template_sync/main.py +529 -0
- loopix/volume/client/__init__.py +8 -0
- loopix/volume/client/api/__init__.py +1 -0
- loopix/volume/client/api/volumes/__init__.py +1 -0
- loopix/volume/client/api/volumes/delete_volumecontent_volume_id_path.py +174 -0
- loopix/volume/client/api/volumes/get_volumecontent_volume_id_dir.py +204 -0
- loopix/volume/client/api/volumes/get_volumecontent_volume_id_file.py +179 -0
- loopix/volume/client/api/volumes/get_volumecontent_volume_id_path.py +176 -0
- loopix/volume/client/api/volumes/patch_volumecontent_volume_id_path.py +203 -0
- loopix/volume/client/api/volumes/post_volumecontent_volume_id_dir.py +239 -0
- loopix/volume/client/api/volumes/put_volumecontent_volume_id_file.py +259 -0
- loopix/volume/client/client.py +286 -0
- loopix/volume/client/errors.py +16 -0
- loopix/volume/client/models/__init__.py +13 -0
- loopix/volume/client/models/error.py +67 -0
- loopix/volume/client/models/patch_volumecontent_volume_id_path_body.py +77 -0
- loopix/volume/client/models/volume_entry_stat.py +145 -0
- loopix/volume/client/models/volume_entry_stat_type.py +11 -0
- loopix/volume/client/py.typed +1 -0
- loopix/volume/client/types.py +54 -0
- loopix/volume/client_async/__init__.py +88 -0
- loopix/volume/client_sync/__init__.py +80 -0
- loopix/volume/connection_config.py +145 -0
- loopix/volume/types.py +62 -0
- loopix/volume/utils.py +52 -0
- loopix/volume/volume_async.py +639 -0
- loopix/volume/volume_sync.py +639 -0
- loopix_connect/__init__.py +1 -0
- loopix_connect/client.py +534 -0
- loopix_connect/py.typed +0 -0
- loopix_sdk-2.30.0.dist-info/METADATA +98 -0
- loopix_sdk-2.30.0.dist-info/RECORD +238 -0
- loopix_sdk-2.30.0.dist-info/WHEEL +4 -0
- 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"
|