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,286 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
import tempfile
|
|
5
|
+
from typing import Dict, List, Optional, Protocol, Union, Literal
|
|
6
|
+
|
|
7
|
+
from dockerfile_parse import DockerfileParser
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DockerfFileFinalParserInterface(Protocol):
|
|
11
|
+
"""Protocol defining the final interface for Dockerfile parsing callbacks."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DockerfileParserInterface(Protocol):
|
|
15
|
+
"""Protocol defining the interface for Dockerfile parsing callbacks."""
|
|
16
|
+
|
|
17
|
+
def run_cmd(
|
|
18
|
+
self, command: Union[str, List[str]], user: Optional[str] = None
|
|
19
|
+
) -> "DockerfileParserInterface":
|
|
20
|
+
"""Handle RUN instruction."""
|
|
21
|
+
...
|
|
22
|
+
|
|
23
|
+
def copy(
|
|
24
|
+
self,
|
|
25
|
+
src: str,
|
|
26
|
+
dest: str,
|
|
27
|
+
force_upload: Optional[Literal[True]] = None,
|
|
28
|
+
user: Optional[str] = None,
|
|
29
|
+
mode: Optional[int] = None,
|
|
30
|
+
resolve_symlinks: Optional[bool] = None,
|
|
31
|
+
gzip: Optional[bool] = None,
|
|
32
|
+
) -> "DockerfileParserInterface":
|
|
33
|
+
"""Handle COPY instruction."""
|
|
34
|
+
...
|
|
35
|
+
|
|
36
|
+
def set_workdir(self, workdir: str) -> "DockerfileParserInterface":
|
|
37
|
+
"""Handle WORKDIR instruction."""
|
|
38
|
+
...
|
|
39
|
+
|
|
40
|
+
def set_user(self, user: str) -> "DockerfileParserInterface":
|
|
41
|
+
"""Handle USER instruction."""
|
|
42
|
+
...
|
|
43
|
+
|
|
44
|
+
def set_envs(self, envs: Dict[str, str]) -> "DockerfileParserInterface":
|
|
45
|
+
"""Handle ENV instruction."""
|
|
46
|
+
...
|
|
47
|
+
|
|
48
|
+
def set_start_cmd(
|
|
49
|
+
self, start_cmd: str, ready_cmd: str
|
|
50
|
+
) -> "DockerfFileFinalParserInterface":
|
|
51
|
+
"""Handle CMD/ENTRYPOINT instruction."""
|
|
52
|
+
...
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def parse_dockerfile(
|
|
56
|
+
dockerfile_content_or_path: str, template_builder: DockerfileParserInterface
|
|
57
|
+
) -> str:
|
|
58
|
+
"""
|
|
59
|
+
Parse a Dockerfile and convert it to Template SDK format.
|
|
60
|
+
|
|
61
|
+
:param dockerfile_content_or_path: Either the Dockerfile content as a string, or a path to a Dockerfile file
|
|
62
|
+
:param template_builder: Interface providing template builder methods
|
|
63
|
+
|
|
64
|
+
:return: The base image from the Dockerfile
|
|
65
|
+
|
|
66
|
+
:raises ValueError: If the Dockerfile is invalid or unsupported
|
|
67
|
+
"""
|
|
68
|
+
# Check if input is a file path that exists
|
|
69
|
+
if os.path.isfile(dockerfile_content_or_path):
|
|
70
|
+
# Read the file content
|
|
71
|
+
with open(dockerfile_content_or_path, "r", encoding="utf-8") as f:
|
|
72
|
+
dockerfile_content = f.read()
|
|
73
|
+
else:
|
|
74
|
+
# Treat as content directly
|
|
75
|
+
dockerfile_content = dockerfile_content_or_path
|
|
76
|
+
|
|
77
|
+
# Use a temporary directory to avoid creating files in the current directory
|
|
78
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
79
|
+
# Create a temporary Dockerfile
|
|
80
|
+
dockerfile_path = os.path.join(temp_dir, "Dockerfile")
|
|
81
|
+
with open(dockerfile_path, "w") as f:
|
|
82
|
+
f.write(dockerfile_content)
|
|
83
|
+
|
|
84
|
+
dfp = DockerfileParser(path=temp_dir)
|
|
85
|
+
|
|
86
|
+
# Check for multi-stage builds
|
|
87
|
+
from_instructions = [
|
|
88
|
+
instruction
|
|
89
|
+
for instruction in dfp.structure
|
|
90
|
+
if instruction["instruction"] == "FROM"
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
if len(from_instructions) > 1:
|
|
94
|
+
raise ValueError("Multi-stage Dockerfiles are not supported")
|
|
95
|
+
|
|
96
|
+
if len(from_instructions) == 0:
|
|
97
|
+
raise ValueError("Dockerfile must contain a FROM instruction")
|
|
98
|
+
|
|
99
|
+
# Set the base image from the first FROM instruction
|
|
100
|
+
base_image = from_instructions[0]["value"]
|
|
101
|
+
# Remove AS alias if present (e.g., "node:18 AS builder" -> "node:18")
|
|
102
|
+
if " as " in base_image.lower():
|
|
103
|
+
base_image = base_image.split(" as ")[0].strip()
|
|
104
|
+
|
|
105
|
+
user_changed = False
|
|
106
|
+
workdir_changed = False
|
|
107
|
+
|
|
108
|
+
# Set the user and workdir to the Docker defaults
|
|
109
|
+
template_builder.set_user("root")
|
|
110
|
+
template_builder.set_workdir("/")
|
|
111
|
+
|
|
112
|
+
# Process all other instructions
|
|
113
|
+
for instruction_data in dfp.structure:
|
|
114
|
+
instruction = instruction_data["instruction"]
|
|
115
|
+
value = instruction_data["value"]
|
|
116
|
+
|
|
117
|
+
if instruction == "FROM":
|
|
118
|
+
# Already handled above
|
|
119
|
+
continue
|
|
120
|
+
elif instruction == "RUN":
|
|
121
|
+
_handle_run_instruction(value, template_builder)
|
|
122
|
+
elif instruction in ["COPY", "ADD"]:
|
|
123
|
+
_handle_copy_instruction(value, template_builder)
|
|
124
|
+
elif instruction == "WORKDIR":
|
|
125
|
+
_handle_workdir_instruction(value, template_builder)
|
|
126
|
+
workdir_changed = True
|
|
127
|
+
elif instruction == "USER":
|
|
128
|
+
_handle_user_instruction(value, template_builder)
|
|
129
|
+
user_changed = True
|
|
130
|
+
elif instruction in ["ENV", "ARG"]:
|
|
131
|
+
_handle_env_instruction(value, instruction, template_builder)
|
|
132
|
+
elif instruction in ["CMD", "ENTRYPOINT"]:
|
|
133
|
+
_handle_cmd_entrypoint_instruction(value, template_builder)
|
|
134
|
+
else:
|
|
135
|
+
print(f"Unsupported instruction: {instruction}")
|
|
136
|
+
continue
|
|
137
|
+
|
|
138
|
+
# Set the user and workdir to the Loopix defaults
|
|
139
|
+
if not user_changed:
|
|
140
|
+
template_builder.set_user("user")
|
|
141
|
+
if not workdir_changed:
|
|
142
|
+
template_builder.set_workdir("/home/user")
|
|
143
|
+
|
|
144
|
+
return base_image
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _handle_run_instruction(
|
|
148
|
+
value: str, template_builder: DockerfileParserInterface
|
|
149
|
+
) -> None:
|
|
150
|
+
"""Handle RUN instruction"""
|
|
151
|
+
if not value.strip():
|
|
152
|
+
return
|
|
153
|
+
# Remove line continuations and normalize whitespace
|
|
154
|
+
command = re.sub(r"\\\s*\n\s*", " ", value).strip()
|
|
155
|
+
template_builder.run_cmd(command)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _handle_copy_instruction(
|
|
159
|
+
value: str, template_builder: DockerfileParserInterface
|
|
160
|
+
) -> None:
|
|
161
|
+
"""Handle COPY/ADD instruction"""
|
|
162
|
+
if not value.strip():
|
|
163
|
+
return
|
|
164
|
+
# Parse source and destination from COPY/ADD command
|
|
165
|
+
# Handle both quoted and unquoted paths
|
|
166
|
+
parts = []
|
|
167
|
+
current_part = ""
|
|
168
|
+
in_quotes = False
|
|
169
|
+
quote_char = None
|
|
170
|
+
|
|
171
|
+
i = 0
|
|
172
|
+
while i < len(value):
|
|
173
|
+
char = value[i]
|
|
174
|
+
if char in ['"', "'"] and (i == 0 or value[i - 1] != "\\"):
|
|
175
|
+
if not in_quotes:
|
|
176
|
+
in_quotes = True
|
|
177
|
+
quote_char = char
|
|
178
|
+
elif char == quote_char:
|
|
179
|
+
in_quotes = False
|
|
180
|
+
quote_char = None
|
|
181
|
+
else:
|
|
182
|
+
current_part += char
|
|
183
|
+
elif char == " " and not in_quotes:
|
|
184
|
+
if current_part:
|
|
185
|
+
parts.append(current_part)
|
|
186
|
+
current_part = ""
|
|
187
|
+
else:
|
|
188
|
+
current_part += char
|
|
189
|
+
i += 1
|
|
190
|
+
|
|
191
|
+
if current_part:
|
|
192
|
+
parts.append(current_part)
|
|
193
|
+
|
|
194
|
+
# Extract --chown flag and separate from paths
|
|
195
|
+
user = None
|
|
196
|
+
non_flag_parts = []
|
|
197
|
+
for part in parts:
|
|
198
|
+
if part.startswith("--chown="):
|
|
199
|
+
user = part[8:] # Extract value after "--chown="
|
|
200
|
+
elif not part.startswith("--"):
|
|
201
|
+
non_flag_parts.append(part)
|
|
202
|
+
|
|
203
|
+
if len(non_flag_parts) >= 2:
|
|
204
|
+
dest = non_flag_parts[-1] # Last part is destination
|
|
205
|
+
sources = non_flag_parts[:-1]
|
|
206
|
+
|
|
207
|
+
for src in sources:
|
|
208
|
+
template_builder.copy(src, dest, user=user)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _handle_workdir_instruction(
|
|
212
|
+
value: str, template_builder: DockerfileParserInterface
|
|
213
|
+
) -> None:
|
|
214
|
+
"""Handle WORKDIR instruction"""
|
|
215
|
+
if not value.strip():
|
|
216
|
+
return
|
|
217
|
+
workdir = value.strip()
|
|
218
|
+
template_builder.set_workdir(workdir)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _handle_user_instruction(
|
|
222
|
+
value: str, template_builder: DockerfileParserInterface
|
|
223
|
+
) -> None:
|
|
224
|
+
"""Handle USER instruction"""
|
|
225
|
+
if not value.strip():
|
|
226
|
+
return
|
|
227
|
+
user = value.strip()
|
|
228
|
+
template_builder.set_user(user)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _handle_env_instruction(
|
|
232
|
+
value: str, instruction_type: str, template_builder: DockerfileParserInterface
|
|
233
|
+
) -> None:
|
|
234
|
+
"""Handle ENV/ARG instruction"""
|
|
235
|
+
if not value.strip():
|
|
236
|
+
return
|
|
237
|
+
|
|
238
|
+
# Parse environment variables from the value
|
|
239
|
+
# Handle both "KEY=value" and "KEY value" formats
|
|
240
|
+
env_vars = {}
|
|
241
|
+
|
|
242
|
+
# First try to split on = for KEY=value format
|
|
243
|
+
if "=" in value:
|
|
244
|
+
# Handle multiple KEY=value pairs on one line
|
|
245
|
+
pairs = re.findall(r"(\w+)=([^\s]*(?:\s+(?!\w+=)[^\s]*)*)", value)
|
|
246
|
+
for key, val in pairs:
|
|
247
|
+
env_vars[key] = val.strip("\"'")
|
|
248
|
+
else:
|
|
249
|
+
# Handle "KEY value" format
|
|
250
|
+
parts = value.split(None, 1)
|
|
251
|
+
if len(parts) == 2:
|
|
252
|
+
key, val = parts
|
|
253
|
+
env_vars[key] = val.strip("\"'")
|
|
254
|
+
elif len(parts) == 1 and instruction_type == "ARG":
|
|
255
|
+
# ARG without default value
|
|
256
|
+
key = parts[0]
|
|
257
|
+
env_vars[key] = ""
|
|
258
|
+
|
|
259
|
+
# Add each environment variable
|
|
260
|
+
if env_vars:
|
|
261
|
+
template_builder.set_envs(env_vars)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _handle_cmd_entrypoint_instruction(
|
|
265
|
+
value: str, template_builder: DockerfileParserInterface
|
|
266
|
+
) -> None:
|
|
267
|
+
"""Handle CMD/ENTRYPOINT instruction - convert to set_start_cmd with 20s timeout"""
|
|
268
|
+
if not value.strip():
|
|
269
|
+
return
|
|
270
|
+
command = value.strip()
|
|
271
|
+
|
|
272
|
+
# Try to parse as JSON (for array format like CMD ["sleep", "infinity"])
|
|
273
|
+
try:
|
|
274
|
+
parsed_command = json.loads(command)
|
|
275
|
+
if isinstance(parsed_command, list):
|
|
276
|
+
command = " ".join(str(item) for item in parsed_command)
|
|
277
|
+
except Exception:
|
|
278
|
+
pass
|
|
279
|
+
|
|
280
|
+
# Import wait_for_timeout locally to avoid circular dependency
|
|
281
|
+
def wait_for_timeout(timeout: int) -> str:
|
|
282
|
+
# convert to seconds, but ensure minimum of 1 second
|
|
283
|
+
seconds = max(1, timeout // 1000)
|
|
284
|
+
return f"sleep {seconds}"
|
|
285
|
+
|
|
286
|
+
template_builder.set_start_cmd(command, wait_for_timeout(20_000))
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import threading
|
|
3
|
+
import time
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Optional, TypedDict, Callable, Dict, Literal
|
|
7
|
+
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.style import Style
|
|
10
|
+
from rich.text import Text
|
|
11
|
+
|
|
12
|
+
from loopix.template.utils import strip_ansi_escape_codes
|
|
13
|
+
|
|
14
|
+
"""Log entry severity levels."""
|
|
15
|
+
LogEntryLevel = Literal["debug", "info", "warn", "error"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class LogEntry:
|
|
20
|
+
"""
|
|
21
|
+
Represents a single log entry from the template build process.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
timestamp: datetime
|
|
25
|
+
level: LogEntryLevel
|
|
26
|
+
message: str
|
|
27
|
+
|
|
28
|
+
def __post_init__(self):
|
|
29
|
+
self.message = strip_ansi_escape_codes(self.message)
|
|
30
|
+
|
|
31
|
+
def __str__(self) -> str:
|
|
32
|
+
return f"[{self.timestamp.isoformat()}] [{self.level}] {self.message}"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class LogEntryStart(LogEntry):
|
|
37
|
+
"""
|
|
38
|
+
Special log entry indicating the start of a build process.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
level: LogEntryLevel = field(default="debug", init=False)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class LogEntryEnd(LogEntry):
|
|
46
|
+
"""
|
|
47
|
+
Special log entry indicating the end of a build process.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
level: LogEntryLevel = field(default="debug", init=False)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
"""
|
|
54
|
+
Interval in milliseconds for updating the build timer display.
|
|
55
|
+
"""
|
|
56
|
+
TIMER_UPDATE_INTERVAL_MS = 150
|
|
57
|
+
|
|
58
|
+
"""
|
|
59
|
+
Default minimum log level to display.
|
|
60
|
+
"""
|
|
61
|
+
DEFAULT_LEVEL: LogEntryLevel = "info"
|
|
62
|
+
|
|
63
|
+
"""
|
|
64
|
+
Colored labels for each log level.
|
|
65
|
+
"""
|
|
66
|
+
levels: Dict[LogEntryLevel, tuple[str, Style]] = {
|
|
67
|
+
"error": ("ERROR", Style(color="red")),
|
|
68
|
+
"warn": ("WARN ", Style(color="#FF4400")),
|
|
69
|
+
"info": ("INFO ", Style(color="#FF8800")),
|
|
70
|
+
"debug": ("DEBUG", Style(color="bright_black")),
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
"""
|
|
74
|
+
Numeric ordering of log levels for comparison (lower = less severe).
|
|
75
|
+
"""
|
|
76
|
+
level_order = {
|
|
77
|
+
"debug": 0,
|
|
78
|
+
"info": 1,
|
|
79
|
+
"warn": 2,
|
|
80
|
+
"error": 3,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def set_interval(func, interval):
|
|
85
|
+
"""
|
|
86
|
+
Returns a stop function that can be called to cancel the interval.
|
|
87
|
+
|
|
88
|
+
Similar to JavaScript's setInterval.
|
|
89
|
+
|
|
90
|
+
:param func: Function to execute at each interval
|
|
91
|
+
:param interval: Interval duration in **seconds**
|
|
92
|
+
|
|
93
|
+
:return: Stop function that can be called to cancel the interval
|
|
94
|
+
"""
|
|
95
|
+
stopped = threading.Event()
|
|
96
|
+
|
|
97
|
+
def loop():
|
|
98
|
+
while not stopped.is_set():
|
|
99
|
+
if stopped.wait(interval): # wait returns True if stopped
|
|
100
|
+
break
|
|
101
|
+
if not stopped.is_set(): # Double-check before executing
|
|
102
|
+
func()
|
|
103
|
+
|
|
104
|
+
threading.Thread(target=loop, daemon=True).start()
|
|
105
|
+
return stopped.set # Return the stop function
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class DefaultBuildLoggerInitialState(TypedDict):
|
|
109
|
+
start_time: float
|
|
110
|
+
animation_frame: int
|
|
111
|
+
timer: Optional[Callable[[], None]]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class DefaultBuildLogger:
|
|
115
|
+
__console = Console()
|
|
116
|
+
|
|
117
|
+
__min_level: LogEntryLevel
|
|
118
|
+
__state: DefaultBuildLoggerInitialState
|
|
119
|
+
|
|
120
|
+
def __init__(self, min_level: Optional[LogEntryLevel] = None):
|
|
121
|
+
self.__min_level = min_level if min_level is not None else DEFAULT_LEVEL
|
|
122
|
+
self.__reset_initial_state()
|
|
123
|
+
|
|
124
|
+
def logger(self, log):
|
|
125
|
+
if isinstance(log, LogEntryStart):
|
|
126
|
+
self.__start_timer()
|
|
127
|
+
return
|
|
128
|
+
|
|
129
|
+
if isinstance(log, LogEntryEnd):
|
|
130
|
+
if self.__state["timer"] is not None:
|
|
131
|
+
self.__state["timer"]()
|
|
132
|
+
return
|
|
133
|
+
|
|
134
|
+
# Filter by minimum level
|
|
135
|
+
if level_order[log.level] < level_order[self.__min_level]:
|
|
136
|
+
return
|
|
137
|
+
|
|
138
|
+
formatted_line = self.__format_log_line(log)
|
|
139
|
+
self.__console.print(formatted_line)
|
|
140
|
+
|
|
141
|
+
# Redraw the timer line
|
|
142
|
+
self.__update_timer()
|
|
143
|
+
|
|
144
|
+
def __reset_initial_state(self, timer: Optional[Callable[[], None]] = None):
|
|
145
|
+
self.__state = {
|
|
146
|
+
"start_time": time.time(),
|
|
147
|
+
"animation_frame": 0,
|
|
148
|
+
"timer": timer,
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
def __format_timer_line(self) -> str:
|
|
152
|
+
elapsed_seconds = time.time() - self.__state["start_time"]
|
|
153
|
+
return f"{elapsed_seconds:.1f}s"
|
|
154
|
+
|
|
155
|
+
def __animate_status(self) -> str:
|
|
156
|
+
frames = ["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"]
|
|
157
|
+
idx = self.__state["animation_frame"] % len(frames)
|
|
158
|
+
return frames[idx]
|
|
159
|
+
|
|
160
|
+
def __format_log_line(self, line: LogEntry) -> Text:
|
|
161
|
+
timer = self.__format_timer_line().ljust(5)
|
|
162
|
+
timestamp = line.timestamp.strftime("%H:%M:%S")
|
|
163
|
+
level_text, level_style = levels.get(line.level, levels[DEFAULT_LEVEL])
|
|
164
|
+
|
|
165
|
+
# Build a rich Text object
|
|
166
|
+
text = Text.assemble(
|
|
167
|
+
timer,
|
|
168
|
+
" | ",
|
|
169
|
+
(timestamp, "dim"),
|
|
170
|
+
" ",
|
|
171
|
+
(level_text, level_style),
|
|
172
|
+
" ",
|
|
173
|
+
line.message,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
return text
|
|
177
|
+
|
|
178
|
+
def __start_timer(self):
|
|
179
|
+
if not sys.stdout.isatty():
|
|
180
|
+
return
|
|
181
|
+
|
|
182
|
+
# Start the timer interval
|
|
183
|
+
stop_timer = set_interval(
|
|
184
|
+
self.__update_timer, TIMER_UPDATE_INTERVAL_MS / 1000.0
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
self.__reset_initial_state(stop_timer)
|
|
188
|
+
|
|
189
|
+
# Initial timer display
|
|
190
|
+
self.__update_timer()
|
|
191
|
+
|
|
192
|
+
def __update_timer(self):
|
|
193
|
+
if not sys.stdout.isatty():
|
|
194
|
+
return
|
|
195
|
+
|
|
196
|
+
self.__state["animation_frame"] += 1
|
|
197
|
+
jumping_squares = self.__animate_status()
|
|
198
|
+
|
|
199
|
+
timer_text = Text.assemble(
|
|
200
|
+
jumping_squares, " Building ", self.__format_timer_line()
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
# Print with carriage return
|
|
204
|
+
self.__console.print(timer_text, end="\r")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def default_build_logger(
|
|
208
|
+
min_level: Optional[LogEntryLevel] = None,
|
|
209
|
+
) -> Callable[[LogEntry], None]:
|
|
210
|
+
"""
|
|
211
|
+
Create a default build logger with animated timer display.
|
|
212
|
+
|
|
213
|
+
:param min_level: Minimum log level to display (default: 'info')
|
|
214
|
+
|
|
215
|
+
:return: Logger function that accepts LogEntry instances
|
|
216
|
+
|
|
217
|
+
Example
|
|
218
|
+
```python
|
|
219
|
+
from loopix import Template, default_build_logger
|
|
220
|
+
|
|
221
|
+
template = Template().from_python_image()
|
|
222
|
+
|
|
223
|
+
# Use with build - implementation would be in build_async module
|
|
224
|
+
# await Template.build(template,
|
|
225
|
+
# alias='my-template',
|
|
226
|
+
# on_build_logs=default_build_logger(min_level='debug')
|
|
227
|
+
# )
|
|
228
|
+
```
|
|
229
|
+
"""
|
|
230
|
+
build_logger = DefaultBuildLogger(min_level)
|
|
231
|
+
|
|
232
|
+
return build_logger.logger
|