makers-sdk 0.1.0b1__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.
makers_sdk/__init__.py ADDED
@@ -0,0 +1,25 @@
1
+ from .client import Client
2
+ from .errors import (
3
+ AuthError,
4
+ ConflictError,
5
+ DeploymentTimeoutError,
6
+ MakersError,
7
+ NotFoundError,
8
+ RateLimitError,
9
+ TimeoutError,
10
+ UploadError,
11
+ ValidationError,
12
+ )
13
+
14
+ __all__ = [
15
+ "AuthError",
16
+ "Client",
17
+ "ConflictError",
18
+ "DeploymentTimeoutError",
19
+ "MakersError",
20
+ "NotFoundError",
21
+ "RateLimitError",
22
+ "TimeoutError",
23
+ "UploadError",
24
+ "ValidationError",
25
+ ]
@@ -0,0 +1,283 @@
1
+ from __future__ import annotations
2
+
3
+ import fnmatch
4
+ import os
5
+ import stat
6
+ import tempfile
7
+ import zipfile
8
+ from contextlib import contextmanager
9
+ from dataclasses import dataclass
10
+ from functools import lru_cache
11
+ from pathlib import Path, PurePosixPath
12
+ from typing import Any, Iterator, Mapping
13
+
14
+ from .errors import ValidationError
15
+
16
+
17
+ _DEFAULT_EXCLUDED_DIRECTORIES = {".git", "node_modules", ".edgeone"}
18
+ _DEFAULT_EXCLUDED_FILES = {".env", ".DS_Store", "Thumbs.db", "desktop.ini"}
19
+ _DEFAULT_EXCLUDED_SUFFIXES = (".log", ".tmp", ".temp", ".swp", ".swo")
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class PreparedArtifact:
24
+ archive_path: Path
25
+ total_bytes: int
26
+ total_files: int
27
+
28
+
29
+ def _zip_entry_name(info: zipfile.ZipInfo) -> str:
30
+ return (info.orig_filename or info.filename).split("\x00", 1)[0]
31
+
32
+
33
+ def _normalized_path(path: str, *, label: str) -> str:
34
+ if (
35
+ not path
36
+ or "\\" in path
37
+ or path.startswith("/")
38
+ or (
39
+ len(path) >= 2
40
+ and path[0] in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
41
+ and path[1] == ":"
42
+ )
43
+ ):
44
+ raise ValidationError(
45
+ f"{label} paths must be relative POSIX paths: {path}."
46
+ )
47
+ parts = PurePosixPath(path).parts
48
+ if ".." in parts:
49
+ raise ValidationError(
50
+ f"{label} paths must not contain parent traversal: {path}."
51
+ )
52
+ normalized = "/".join(part for part in parts if part != ".")
53
+ if not normalized:
54
+ raise ValidationError(
55
+ f"{label} paths must be relative POSIX paths: {path}."
56
+ )
57
+ return normalized
58
+
59
+
60
+ def _is_excluded(path: str, patterns: list[str]) -> bool:
61
+ parts = PurePosixPath(path).parts
62
+ if any(part in _DEFAULT_EXCLUDED_DIRECTORIES for part in parts[:-1]):
63
+ return True
64
+ name = parts[-1]
65
+ if name in _DEFAULT_EXCLUDED_FILES or name.endswith(
66
+ _DEFAULT_EXCLUDED_SUFFIXES
67
+ ):
68
+ return True
69
+ return any(_matches_posix_glob(path, pattern) for pattern in patterns)
70
+
71
+
72
+ def _matches_posix_glob(path: str, pattern: str) -> bool:
73
+ path_parts = path.split("/")
74
+ pattern_parts = pattern.split("/")
75
+
76
+ @lru_cache(maxsize=None)
77
+ def match(path_index: int, pattern_index: int) -> bool:
78
+ if pattern_index == len(pattern_parts):
79
+ return path_index == len(path_parts)
80
+ current = pattern_parts[pattern_index]
81
+ if current == "**":
82
+ return match(path_index, pattern_index + 1) or (
83
+ path_index < len(path_parts)
84
+ and match(path_index + 1, pattern_index)
85
+ )
86
+ return (
87
+ path_index < len(path_parts)
88
+ and fnmatch.fnmatchcase(path_parts[path_index], current)
89
+ and match(path_index + 1, pattern_index + 1)
90
+ )
91
+
92
+ return match(0, 0)
93
+
94
+
95
+ def _validate_patterns(patterns: Any) -> list[str]:
96
+ if patterns is None:
97
+ return []
98
+ if not isinstance(patterns, list) or not all(
99
+ isinstance(pattern, str) for pattern in patterns
100
+ ):
101
+ raise ValidationError("exclude_patterns must be a list of POSIX globs.")
102
+ for pattern in patterns:
103
+ if (
104
+ not pattern
105
+ or "\\" in pattern
106
+ or pattern.startswith("/")
107
+ or ".." in PurePosixPath(pattern).parts
108
+ ):
109
+ raise ValidationError(
110
+ "excludePatterns must contain relative POSIX globs."
111
+ )
112
+ if pattern.startswith("!"):
113
+ raise ValidationError(
114
+ f"excludePatterns does not support negation: {pattern}."
115
+ )
116
+ return patterns
117
+
118
+
119
+ def _assert_not_symlink(path: Path, relative: str) -> None:
120
+ if path.is_symlink():
121
+ raise ValidationError(
122
+ "Directory artifacts must not contain symbolic links: "
123
+ f"{relative}."
124
+ )
125
+
126
+
127
+ def _should_descend_directory(
128
+ path: Path, relative: str, patterns: list[str]
129
+ ) -> bool:
130
+ if _is_excluded(relative, patterns) or _is_excluded(
131
+ f"{relative}/placeholder", patterns
132
+ ):
133
+ return False
134
+ _assert_not_symlink(path, relative)
135
+ return True
136
+
137
+
138
+ def _directory_entries(
139
+ directory: Path, patterns: list[str]
140
+ ) -> list[tuple[str, Path]]:
141
+ entries: list[tuple[str, Path]] = []
142
+ for root, directory_names, file_names in os.walk(
143
+ directory, followlinks=False
144
+ ):
145
+ root_path = Path(root)
146
+ kept_directories: list[str] = []
147
+ for name in sorted(directory_names):
148
+ path = root_path / name
149
+ relative = path.relative_to(directory).as_posix()
150
+ if _should_descend_directory(path, relative, patterns):
151
+ kept_directories.append(name)
152
+ directory_names[:] = kept_directories
153
+ for name in sorted(file_names):
154
+ path = root_path / name
155
+ relative = path.relative_to(directory).as_posix()
156
+ if _is_excluded(relative, patterns):
157
+ continue
158
+ _assert_not_symlink(path, relative)
159
+ entries.append((relative, path))
160
+ return sorted(entries)
161
+
162
+
163
+ def _write_zip(
164
+ archive_path: Path, entries: list[tuple[str, bytes]]
165
+ ) -> PreparedArtifact:
166
+ with zipfile.ZipFile(
167
+ archive_path, "w", compression=zipfile.ZIP_DEFLATED
168
+ ) as archive:
169
+ for path, content in entries:
170
+ archive.writestr(path, content)
171
+ return PreparedArtifact(
172
+ archive_path=archive_path,
173
+ total_bytes=sum(len(content) for _, content in entries),
174
+ total_files=len(entries),
175
+ )
176
+
177
+
178
+ def _validate_archive(archive_path: Path) -> PreparedArtifact:
179
+ try:
180
+ with zipfile.ZipFile(archive_path) as archive:
181
+ seen: set[str] = set()
182
+ total_bytes = 0
183
+ total_files = 0
184
+ for info in archive.infolist():
185
+ path = _zip_entry_name(info)
186
+ normalized = _normalized_path(
187
+ path, label="Archive entry"
188
+ ).rstrip("/")
189
+ if normalized in seen:
190
+ raise ValidationError(
191
+ "Archive contains a duplicate normalized path: "
192
+ f"{normalized}."
193
+ )
194
+ seen.add(normalized)
195
+ mode = info.external_attr >> 16
196
+ if stat.S_ISLNK(mode):
197
+ raise ValidationError(
198
+ "Archive artifacts must not contain symbolic links: "
199
+ f"{normalized}."
200
+ )
201
+ if not info.is_dir():
202
+ total_bytes += info.file_size
203
+ total_files += 1
204
+ return PreparedArtifact(
205
+ archive_path=archive_path,
206
+ total_bytes=total_bytes,
207
+ total_files=total_files,
208
+ )
209
+ except (FileNotFoundError, IsADirectoryError, zipfile.BadZipFile) as error:
210
+ raise ValidationError(f"Invalid Zip archive: {archive_path}.") from error
211
+
212
+
213
+ def _directory_zip_entries(artifact: Mapping[str, Any]) -> list[tuple[str, bytes]]:
214
+ directory = Path(str(artifact["directory"]))
215
+ if directory.is_symlink():
216
+ raise ValidationError(
217
+ "Directory artifact path must not be a symbolic link."
218
+ )
219
+ if not directory.is_dir():
220
+ raise ValidationError("Directory artifact path must be a directory.")
221
+ patterns = _validate_patterns(
222
+ artifact.get("exclude_patterns", artifact.get("excludePatterns"))
223
+ )
224
+ return [
225
+ (path, source.read_bytes())
226
+ for path, source in _directory_entries(directory, patterns)
227
+ ]
228
+
229
+
230
+ def _inline_file_content(value: Any) -> bytes:
231
+ if isinstance(value, str):
232
+ return value.encode()
233
+ if isinstance(value, bytes):
234
+ return value
235
+ raise ValidationError("Inline file contents must be str or bytes.")
236
+
237
+
238
+ def _inline_zip_entries(files: Any) -> list[tuple[str, bytes]]:
239
+ if not isinstance(files, Mapping):
240
+ raise ValidationError("Artifact files must be a mapping.")
241
+ seen: set[str] = set()
242
+ entries: list[tuple[str, bytes]] = []
243
+ for path, value in files.items():
244
+ normalized = _normalized_path(str(path), label="Inline file")
245
+ if normalized in seen:
246
+ raise ValidationError(
247
+ "Inline files contain a duplicate normalized path: "
248
+ f"{normalized}."
249
+ )
250
+ seen.add(normalized)
251
+ entries.append((normalized, _inline_file_content(value)))
252
+ entries.sort(key=lambda entry: entry[0])
253
+ return entries
254
+
255
+
256
+ # sdk-v1.CONTRACT.8 sdk-v1.CONTRACT.17 sdk-v1.CONTRACT.26
257
+ # sdk-v1.CONTRACT.36 sdk-v1.CONTRACT.37 sdk-v1.CONTRACT.52
258
+ # sdk-v1.CONTRACT.59 sdk-v1.CONTRACT.60 sdk-v1.CONTRACT.61
259
+ # sdk-v1.CONTRACT.90 sdk-v1.CONTRACT.108 sdk-v1.CONTRACT.111
260
+ # sdk-v1.CONTRACT.114 sdk-v1.CONTRACT.117
261
+ @contextmanager
262
+ def prepare_artifact(
263
+ artifact: Mapping[str, Any],
264
+ ) -> Iterator[PreparedArtifact]:
265
+ variants = [name for name in ("directory", "archive", "files") if name in artifact]
266
+ if len(variants) != 1:
267
+ raise ValidationError(
268
+ "Artifact must contain exactly one of directory, archive, or files."
269
+ )
270
+
271
+ variant = variants[0]
272
+ if variant == "archive":
273
+ yield _validate_archive(Path(str(artifact["archive"])))
274
+ return
275
+
276
+ with tempfile.TemporaryDirectory(prefix="edgeone-makers-sdk-") as temporary:
277
+ archive_path = Path(temporary) / "artifact.zip"
278
+ entries = (
279
+ _directory_zip_entries(artifact)
280
+ if variant == "directory"
281
+ else _inline_zip_entries(artifact["files"])
282
+ )
283
+ yield _write_zip(archive_path, entries)