maind 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.
- mai/__init__.py +38 -0
- mai/__main__.py +6 -0
- mai/_core/__init__.py +1 -0
- mai/_core/api_response.py +10 -0
- mai/_core/cli_output.py +101 -0
- mai/_core/fvm_provider.py +47 -0
- mai/_core/image_copy.py +59 -0
- mai/_core/operations/__init__.py +1 -0
- mai/_core/operations/auth.py +49 -0
- mai/_core/operations/config_center.py +84 -0
- mai/_core/operations/files.py +87 -0
- mai/_core/operations/fvm.py +175 -0
- mai/_core/operations/images.py +134 -0
- mai/_core/operations/projects.py +74 -0
- mai/_core/operations/resources.py +222 -0
- mai/_core/operations/seed.py +314 -0
- mai/_core/operations/sync.py +274 -0
- mai/_core/resource_target.py +57 -0
- mai/_core/routes.py +97 -0
- mai/_core/settings.py +52 -0
- mai/_core/tools.py +136 -0
- mai/_core/transport.py +163 -0
- mai/_core/vcs.py +927 -0
- mai/_core/workspace.py +685 -0
- mai/app.py +106 -0
- mai/async_client.py +193 -0
- mai/cli/__init__.py +1 -0
- mai/cli/config_file.py +39 -0
- mai/client.py +233 -0
- mai/commands/__init__.py +1 -0
- mai/commands/_workspace_common.py +26 -0
- mai/commands/config_center.py +59 -0
- mai/commands/connect.py +37 -0
- mai/commands/create.py +80 -0
- mai/commands/download.py +175 -0
- mai/commands/file.py +26 -0
- mai/commands/image.py +135 -0
- mai/commands/install.py +28 -0
- mai/commands/login.py +39 -0
- mai/commands/project.py +67 -0
- mai/commands/publish.py +48 -0
- mai/commands/release.py +169 -0
- mai/commands/repo.py +107 -0
- mai/commands/seed.py +603 -0
- mai/commands/status.py +38 -0
- mai/commands/sync.py +67 -0
- mai/errors.py +47 -0
- mai/models.py +48 -0
- maind-0.1.0.dist-info/METADATA +320 -0
- maind-0.1.0.dist-info/RECORD +52 -0
- maind-0.1.0.dist-info/WHEEL +4 -0
- maind-0.1.0.dist-info/entry_points.txt +2 -0
mai/__init__.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Mai SDK + CLI."""
|
|
2
|
+
|
|
3
|
+
from mai._core.workspace import PublishResult, Workspace
|
|
4
|
+
from mai.async_client import AsyncMaiClient
|
|
5
|
+
from mai.client import MaiClient
|
|
6
|
+
from mai.errors import (
|
|
7
|
+
ApiError,
|
|
8
|
+
AuthError,
|
|
9
|
+
DependencyMissingError,
|
|
10
|
+
MaiError,
|
|
11
|
+
ResourceNotFoundError,
|
|
12
|
+
ValidationError,
|
|
13
|
+
WorkspaceError,
|
|
14
|
+
)
|
|
15
|
+
from mai.models import BuildTask, FileInfo, LoginResult, Project, ProjectContext, Resource
|
|
16
|
+
|
|
17
|
+
__version__ = "0.1.0"
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"AsyncMaiClient",
|
|
21
|
+
"MaiClient",
|
|
22
|
+
"LoginResult",
|
|
23
|
+
"Project",
|
|
24
|
+
"ProjectContext",
|
|
25
|
+
"Resource",
|
|
26
|
+
"FileInfo",
|
|
27
|
+
"BuildTask",
|
|
28
|
+
"Workspace",
|
|
29
|
+
"PublishResult",
|
|
30
|
+
"MaiError",
|
|
31
|
+
"ValidationError",
|
|
32
|
+
"AuthError",
|
|
33
|
+
"ApiError",
|
|
34
|
+
"ResourceNotFoundError",
|
|
35
|
+
"WorkspaceError",
|
|
36
|
+
"DependencyMissingError",
|
|
37
|
+
"__version__",
|
|
38
|
+
]
|
mai/__main__.py
ADDED
mai/_core/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Internal synchronous implementation. Not part of the public API."""
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def unwrap_data(response: Any) -> Any:
|
|
7
|
+
"""Return the nested ``data`` field when present, otherwise the response itself."""
|
|
8
|
+
if isinstance(response, dict) and "data" in response:
|
|
9
|
+
return response["data"]
|
|
10
|
+
return response
|
mai/_core/cli_output.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from collections.abc import Callable, Iterator
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
_Echo = Callable[[str], None]
|
|
12
|
+
|
|
13
|
+
_NOISY_LOGGERS = (
|
|
14
|
+
"modelscope_hub",
|
|
15
|
+
"modelscope",
|
|
16
|
+
"dvc",
|
|
17
|
+
"dulwich",
|
|
18
|
+
"scmrepo",
|
|
19
|
+
"fsspec",
|
|
20
|
+
"urllib3",
|
|
21
|
+
"botocore",
|
|
22
|
+
"boto3",
|
|
23
|
+
"s3transfer",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
_PROVIDER_LABELS = {
|
|
27
|
+
"hf": "Hugging Face",
|
|
28
|
+
"ms": "ModelScope",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def provider_display_name(provider: str) -> str:
|
|
33
|
+
return _PROVIDER_LABELS.get(provider.lower(), provider)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@contextlib.contextmanager
|
|
37
|
+
def quiet_external_output(*, keep_stderr: bool = False) -> Iterator[None]:
|
|
38
|
+
"""Reduce noisy third-party logging and stdout chatter during CLI orchestration."""
|
|
39
|
+
previous_levels: dict[str, int] = {}
|
|
40
|
+
for name in _NOISY_LOGGERS:
|
|
41
|
+
logger = logging.getLogger(name)
|
|
42
|
+
previous_levels[name] = logger.level
|
|
43
|
+
logger.setLevel(logging.WARNING)
|
|
44
|
+
|
|
45
|
+
old_stdout = sys.stdout
|
|
46
|
+
old_stderr = sys.stderr if not keep_stderr else None
|
|
47
|
+
try:
|
|
48
|
+
with open(os.devnull, "w", encoding="utf-8") as devnull:
|
|
49
|
+
sys.stdout = devnull
|
|
50
|
+
if old_stderr is not None:
|
|
51
|
+
sys.stderr = devnull
|
|
52
|
+
try:
|
|
53
|
+
yield
|
|
54
|
+
finally:
|
|
55
|
+
sys.stdout = old_stdout
|
|
56
|
+
if old_stderr is not None:
|
|
57
|
+
sys.stderr = old_stderr
|
|
58
|
+
finally:
|
|
59
|
+
for name, level in previous_levels.items():
|
|
60
|
+
logging.getLogger(name).setLevel(level)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@contextlib.contextmanager
|
|
64
|
+
def quiet_stdio() -> Iterator[None]:
|
|
65
|
+
"""Redirect both stdout and stderr to devnull."""
|
|
66
|
+
old_stdout = sys.stdout
|
|
67
|
+
old_stderr = sys.stderr
|
|
68
|
+
with open(os.devnull, "w", encoding="utf-8") as devnull:
|
|
69
|
+
sys.stdout = devnull
|
|
70
|
+
sys.stderr = devnull
|
|
71
|
+
try:
|
|
72
|
+
yield
|
|
73
|
+
finally:
|
|
74
|
+
sys.stdout = old_stdout
|
|
75
|
+
sys.stderr = old_stderr
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class OperationReporter:
|
|
80
|
+
echo: _Echo
|
|
81
|
+
|
|
82
|
+
def phase(self, message: str) -> None:
|
|
83
|
+
self.echo(message)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True)
|
|
87
|
+
class SyncSummary:
|
|
88
|
+
resource_type: str
|
|
89
|
+
name: str
|
|
90
|
+
version: str
|
|
91
|
+
source_uri: str
|
|
92
|
+
path: Path
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def format_sync_summary(summary: SyncSummary) -> list[str]:
|
|
96
|
+
return [
|
|
97
|
+
"Sync completed successfully.",
|
|
98
|
+
f" Resource : {summary.resource_type} {summary.name}@{summary.version}",
|
|
99
|
+
f" Source : {summary.source_uri}",
|
|
100
|
+
f" Local : {summary.path}",
|
|
101
|
+
]
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from mai._core.operations import fvm
|
|
6
|
+
from mai._core.transport import Transport
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TransportFvmProvider:
|
|
10
|
+
"""Adapt a :class:`Transport` to the workspace ``FvmProvider`` protocol."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, transport: Transport, *, env: dict[str, str] | None = None) -> None:
|
|
13
|
+
self._transport = transport
|
|
14
|
+
self._env = env
|
|
15
|
+
|
|
16
|
+
def lookup_fvs(self, bind_type: str, bind_name: str) -> dict[str, Any]:
|
|
17
|
+
return fvm.lookup_fvs(self._transport, bind_type, bind_name, env=self._env)
|
|
18
|
+
|
|
19
|
+
def wait_for_repo_ready(self, fvs_id: str, label: str | None = None) -> dict[str, Any]:
|
|
20
|
+
return fvm.wait_for_repo_ready(
|
|
21
|
+
self._transport, fvs_id, label=label, env=self._env
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
def get_fvs_credentials(self, fvs_id: str) -> dict[str, Any]:
|
|
25
|
+
return fvm.get_fvs_credentials(self._transport, fvs_id, env=self._env)
|
|
26
|
+
|
|
27
|
+
def build_git_url(self, fvs_info: dict[str, Any], fallback_name: str) -> str:
|
|
28
|
+
return fvm.build_git_url(
|
|
29
|
+
self._transport, fvs_info, fallback_name, env=self._env
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
def notify_branch_pushed(
|
|
33
|
+
self,
|
|
34
|
+
fvs_id: str,
|
|
35
|
+
branch: str,
|
|
36
|
+
*,
|
|
37
|
+
message: str = "",
|
|
38
|
+
paths: list[str] | None = None,
|
|
39
|
+
) -> dict[str, Any]:
|
|
40
|
+
return fvm.notify_branch_pushed(
|
|
41
|
+
self._transport,
|
|
42
|
+
fvs_id,
|
|
43
|
+
branch,
|
|
44
|
+
message=message,
|
|
45
|
+
paths=paths,
|
|
46
|
+
env=self._env,
|
|
47
|
+
)
|
mai/_core/image_copy.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from mai._core.operations.images import normalize_image_ref
|
|
8
|
+
from mai.errors import DependencyMissingError, MaiError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class ImageCopyPlan:
|
|
13
|
+
args: list[str]
|
|
14
|
+
|
|
15
|
+
@property
|
|
16
|
+
def command_text(self) -> str:
|
|
17
|
+
return "skopeo " + " ".join(self.args)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def plan_image_copy(
|
|
21
|
+
source: str,
|
|
22
|
+
target: str,
|
|
23
|
+
*,
|
|
24
|
+
src_tls_verify: bool = False,
|
|
25
|
+
dest_tls_verify: bool = False,
|
|
26
|
+
) -> ImageCopyPlan:
|
|
27
|
+
return ImageCopyPlan(
|
|
28
|
+
args=[
|
|
29
|
+
"copy",
|
|
30
|
+
"--all",
|
|
31
|
+
f"--src-tls-verify={str(src_tls_verify).lower()}",
|
|
32
|
+
f"--dest-tls-verify={str(dest_tls_verify).lower()}",
|
|
33
|
+
normalize_image_ref(source, "Source"),
|
|
34
|
+
normalize_image_ref(target, "Destination"),
|
|
35
|
+
]
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def execute_image_copy(plan: ImageCopyPlan) -> None:
|
|
40
|
+
if shutil.which("skopeo") is None:
|
|
41
|
+
raise DependencyMissingError(
|
|
42
|
+
"skopeo is required for image copy. Please install skopeo first.",
|
|
43
|
+
tool="skopeo",
|
|
44
|
+
)
|
|
45
|
+
try:
|
|
46
|
+
completed = subprocess.run(
|
|
47
|
+
["skopeo", *plan.args],
|
|
48
|
+
check=False,
|
|
49
|
+
text=True,
|
|
50
|
+
capture_output=True,
|
|
51
|
+
)
|
|
52
|
+
except OSError as exc:
|
|
53
|
+
raise MaiError(f"Failed to run skopeo: {exc}") from exc
|
|
54
|
+
if completed.returncode != 0:
|
|
55
|
+
detail = (completed.stderr or completed.stdout or "").strip()
|
|
56
|
+
message = "skopeo image copy failed"
|
|
57
|
+
if detail:
|
|
58
|
+
message = f"{message}: {detail}"
|
|
59
|
+
raise MaiError(message)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Per-capability synchronous operations."""
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from mai._core.transport import Transport
|
|
6
|
+
from mai.errors import AuthError
|
|
7
|
+
from mai.models import LoginResult
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _extract_token(response: Any) -> str | None:
|
|
11
|
+
if not isinstance(response, dict):
|
|
12
|
+
return None
|
|
13
|
+
token = response.get("token") or response.get("accessToken")
|
|
14
|
+
if token:
|
|
15
|
+
return str(token)
|
|
16
|
+
data = response.get("data")
|
|
17
|
+
if isinstance(data, dict):
|
|
18
|
+
token = data.get("token") or data.get("accessToken")
|
|
19
|
+
if token:
|
|
20
|
+
return str(token)
|
|
21
|
+
return None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _extract_error_message(response: Any) -> str:
|
|
25
|
+
if isinstance(response, dict):
|
|
26
|
+
message = response.get("msg") or response.get("message") or response.get("error")
|
|
27
|
+
if message:
|
|
28
|
+
return str(message)
|
|
29
|
+
return "Login response did not contain a token."
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def login(
|
|
33
|
+
transport: Transport,
|
|
34
|
+
username: str,
|
|
35
|
+
password: str,
|
|
36
|
+
tenant: str | None = None,
|
|
37
|
+
*,
|
|
38
|
+
env: dict[str, str] | None = None,
|
|
39
|
+
) -> LoginResult:
|
|
40
|
+
body: dict[str, str] = {"username": username, "password": password}
|
|
41
|
+
if tenant:
|
|
42
|
+
body["tenant"] = tenant
|
|
43
|
+
response = transport.request_service(
|
|
44
|
+
"iam", "/api/v1/auth/login", method="POST", body=body, env=env
|
|
45
|
+
)
|
|
46
|
+
token = _extract_token(response)
|
|
47
|
+
if not token:
|
|
48
|
+
raise AuthError(_extract_error_message(response))
|
|
49
|
+
return LoginResult(token=token, username=username, tenant=tenant)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, cast
|
|
4
|
+
from urllib.parse import urlencode
|
|
5
|
+
|
|
6
|
+
from mai._core.api_response import unwrap_data
|
|
7
|
+
from mai._core.transport import Transport
|
|
8
|
+
from mai.errors import ValidationError
|
|
9
|
+
from mai.models import JsonData
|
|
10
|
+
|
|
11
|
+
_PATHS = {
|
|
12
|
+
"runtime-config": "/api/v1/runtime-configs",
|
|
13
|
+
"parameter-template": "/api/v1/parameter-templates",
|
|
14
|
+
}
|
|
15
|
+
_SOURCES = {"custom", "preset"}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _path_for_kind(kind: str) -> str:
|
|
19
|
+
try:
|
|
20
|
+
return _PATHS[kind]
|
|
21
|
+
except KeyError as exc:
|
|
22
|
+
raise ValidationError(
|
|
23
|
+
"Unsupported config center resource kind. "
|
|
24
|
+
"Use runtime-config or parameter-template."
|
|
25
|
+
) from exc
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _validate_name(name: str, kind: str) -> str:
|
|
29
|
+
normalized = name.strip()
|
|
30
|
+
if not normalized:
|
|
31
|
+
raise ValidationError(f"{kind} name is required.")
|
|
32
|
+
return normalized
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _validate_source(source: str | None) -> str | None:
|
|
36
|
+
if source is None:
|
|
37
|
+
return None
|
|
38
|
+
normalized = source.strip()
|
|
39
|
+
if normalized and normalized not in _SOURCES:
|
|
40
|
+
raise ValidationError('--source must be "custom" or "preset".')
|
|
41
|
+
return normalized or None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def resource_exists(
|
|
45
|
+
transport: Transport,
|
|
46
|
+
kind: str,
|
|
47
|
+
*,
|
|
48
|
+
name: str,
|
|
49
|
+
source: str | None = None,
|
|
50
|
+
env: dict[str, str] | None = None,
|
|
51
|
+
) -> bool:
|
|
52
|
+
path = _path_for_kind(kind)
|
|
53
|
+
normalized_name = _validate_name(name, kind)
|
|
54
|
+
normalized_source = _validate_source(source)
|
|
55
|
+
params = {"name": normalized_name}
|
|
56
|
+
if normalized_source:
|
|
57
|
+
params["source"] = normalized_source
|
|
58
|
+
response = transport.request_service(
|
|
59
|
+
"ai-nexus",
|
|
60
|
+
f"{path}/exists?{urlencode(params)}",
|
|
61
|
+
env=env,
|
|
62
|
+
)
|
|
63
|
+
data = unwrap_data(response)
|
|
64
|
+
return bool(isinstance(data, dict) and data.get("exists") is True)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def create_config_center_resource(
|
|
68
|
+
transport: Transport,
|
|
69
|
+
kind: str,
|
|
70
|
+
body: dict[str, Any],
|
|
71
|
+
*,
|
|
72
|
+
env: dict[str, str] | None = None,
|
|
73
|
+
) -> JsonData:
|
|
74
|
+
path = _path_for_kind(kind)
|
|
75
|
+
name = body.get("name") if isinstance(body, dict) else None
|
|
76
|
+
_validate_name(str(name or ""), kind)
|
|
77
|
+
response = transport.request_service(
|
|
78
|
+
"ai-nexus",
|
|
79
|
+
path,
|
|
80
|
+
method="POST",
|
|
81
|
+
body=body,
|
|
82
|
+
env=env,
|
|
83
|
+
)
|
|
84
|
+
return cast(JsonData, unwrap_data(response))
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from urllib.parse import quote, urlsplit
|
|
6
|
+
|
|
7
|
+
from mai._core.routes import get_service_prefix, resolve_service_base_url
|
|
8
|
+
from mai._core.transport import Transport
|
|
9
|
+
from mai.errors import ApiError
|
|
10
|
+
from mai.models import FileInfo
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def build_tus_metadata(pairs: dict[str, str]) -> str:
|
|
14
|
+
return ",".join(
|
|
15
|
+
f"{key} {base64.b64encode(str(value).encode()).decode()}"
|
|
16
|
+
for key, value in pairs.items()
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _patch_url(base_url: str, prefix: str, raw_location: str) -> str:
|
|
21
|
+
if raw_location.startswith(("http://", "https://")):
|
|
22
|
+
parsed = urlsplit(raw_location)
|
|
23
|
+
if parsed.path.startswith(prefix):
|
|
24
|
+
return raw_location
|
|
25
|
+
return f"{base_url}{prefix}{parsed.path}"
|
|
26
|
+
return f"{base_url}{raw_location}"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def upload_file(
|
|
30
|
+
transport: Transport,
|
|
31
|
+
local_path: Path | str,
|
|
32
|
+
*,
|
|
33
|
+
remote_path: str | None = None,
|
|
34
|
+
scope: str = "personal",
|
|
35
|
+
env: dict[str, str] | None = None,
|
|
36
|
+
) -> FileInfo:
|
|
37
|
+
path = Path(local_path)
|
|
38
|
+
size = path.stat().st_size
|
|
39
|
+
destination = remote_path or f"/{path.name}"
|
|
40
|
+
base_url = resolve_service_base_url("file-center", fallback_url=transport.base_url, env=env)
|
|
41
|
+
prefix = get_service_prefix("file-center", base_url, env=env)
|
|
42
|
+
meta = build_tus_metadata(
|
|
43
|
+
{"scope": scope, "relativePath": destination, "override": "true"}
|
|
44
|
+
)
|
|
45
|
+
headers = {
|
|
46
|
+
"Tus-Resumable": "1.0.0",
|
|
47
|
+
"Upload-Length": str(size),
|
|
48
|
+
"Upload-Metadata": meta,
|
|
49
|
+
"Content-Length": "0",
|
|
50
|
+
}
|
|
51
|
+
create_response = transport.request_raw(
|
|
52
|
+
f"{prefix}/api/v1/tus/",
|
|
53
|
+
method="POST",
|
|
54
|
+
headers=headers,
|
|
55
|
+
base_url=base_url,
|
|
56
|
+
)
|
|
57
|
+
if create_response.status_code != 201:
|
|
58
|
+
raise ApiError(
|
|
59
|
+
f"TUS create failed ({create_response.status_code}): {create_response.text[:200]}",
|
|
60
|
+
status=create_response.status_code,
|
|
61
|
+
data=create_response.text,
|
|
62
|
+
)
|
|
63
|
+
patch_url = _patch_url(base_url, prefix, create_response.headers.get("location", ""))
|
|
64
|
+
patch_headers = {
|
|
65
|
+
"Tus-Resumable": "1.0.0",
|
|
66
|
+
"Upload-Offset": "0",
|
|
67
|
+
"Content-Type": "application/offset+octet-stream",
|
|
68
|
+
"Content-Length": str(size),
|
|
69
|
+
}
|
|
70
|
+
with path.open("rb") as handle:
|
|
71
|
+
patch_response = transport.request_raw(
|
|
72
|
+
patch_url,
|
|
73
|
+
method="PATCH",
|
|
74
|
+
headers=patch_headers,
|
|
75
|
+
content=handle,
|
|
76
|
+
)
|
|
77
|
+
if patch_response.status_code != 204:
|
|
78
|
+
raise ApiError(
|
|
79
|
+
f"TUS patch failed ({patch_response.status_code}): {patch_response.text[:200]}",
|
|
80
|
+
status=patch_response.status_code,
|
|
81
|
+
data=patch_response.text,
|
|
82
|
+
)
|
|
83
|
+
url = (
|
|
84
|
+
f"{base_url}{prefix}/api/v1/resources/download"
|
|
85
|
+
f"?scope={scope}&paths={quote(destination, safe='')}"
|
|
86
|
+
)
|
|
87
|
+
return FileInfo(url=url, path=destination, scope=scope)
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import time
|
|
5
|
+
from typing import Any
|
|
6
|
+
from urllib.parse import quote, urlencode, urlsplit
|
|
7
|
+
|
|
8
|
+
from mai._core.api_response import unwrap_data
|
|
9
|
+
from mai._core.routes import (
|
|
10
|
+
get_service_prefix,
|
|
11
|
+
resolve_service_base_url,
|
|
12
|
+
should_use_gateway_prefix,
|
|
13
|
+
)
|
|
14
|
+
from mai._core.transport import Transport
|
|
15
|
+
from mai.errors import MaiError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _fvm_push_notify_timeout(env: dict[str, str] | None = None) -> float:
|
|
19
|
+
value = str(
|
|
20
|
+
(env or {}).get("MAI_FVM_PUSH_NOTIFY_TIMEOUT")
|
|
21
|
+
or os.environ.get("MAI_FVM_PUSH_NOTIFY_TIMEOUT")
|
|
22
|
+
or ""
|
|
23
|
+
).strip()
|
|
24
|
+
if value:
|
|
25
|
+
try:
|
|
26
|
+
return max(1.0, float(value))
|
|
27
|
+
except ValueError:
|
|
28
|
+
return 600.0
|
|
29
|
+
return 600.0
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _truthy(value: str) -> bool | None:
|
|
33
|
+
normalized = value.strip().lower()
|
|
34
|
+
if normalized in {"true", "1", "yes", "y", "on"}:
|
|
35
|
+
return True
|
|
36
|
+
if normalized in {"false", "0", "no", "n", "off"}:
|
|
37
|
+
return False
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def should_request_external_credentials(
|
|
42
|
+
transport: Transport, *, env: dict[str, str] | None = None
|
|
43
|
+
) -> bool:
|
|
44
|
+
resolved_env = env or os.environ
|
|
45
|
+
for key in (
|
|
46
|
+
"MAI_FVM_EXTERNAL",
|
|
47
|
+
"MAI_EXTERNAL",
|
|
48
|
+
):
|
|
49
|
+
value = resolved_env.get(key)
|
|
50
|
+
if isinstance(value, str):
|
|
51
|
+
parsed = _truthy(value)
|
|
52
|
+
if parsed is not None:
|
|
53
|
+
return parsed
|
|
54
|
+
base_url = resolve_service_base_url("fvm", fallback_url=transport.base_url, env=env)
|
|
55
|
+
return should_use_gateway_prefix(base_url, env=resolved_env)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def lookup_fvs(
|
|
59
|
+
transport: Transport, bind_type: str, name: str, *, env: dict[str, str] | None = None
|
|
60
|
+
) -> dict[str, Any]:
|
|
61
|
+
query = urlencode({"bindType": bind_type, "name": name})
|
|
62
|
+
data = unwrap_data(transport.request_service("fvm", f"/api/v1/fvs/lookup?{query}", env=env))
|
|
63
|
+
if not isinstance(data, dict):
|
|
64
|
+
raise MaiError(f"Unable to resolve FVS for {bind_type} '{name}'.")
|
|
65
|
+
return data
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def get_fvs(
|
|
69
|
+
transport: Transport, fvs_id: str, *, env: dict[str, str] | None = None
|
|
70
|
+
) -> dict[str, Any]:
|
|
71
|
+
data = unwrap_data(transport.request_service("fvm", f"/api/v1/fvs/{quote(fvs_id)}", env=env))
|
|
72
|
+
if not isinstance(data, dict):
|
|
73
|
+
raise MaiError(f"Unable to fetch FVS '{fvs_id}'.")
|
|
74
|
+
return data
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def wait_for_repo_ready(
|
|
78
|
+
transport: Transport,
|
|
79
|
+
fvs_id: str,
|
|
80
|
+
*,
|
|
81
|
+
label: str | None = None,
|
|
82
|
+
env: dict[str, str] | None = None,
|
|
83
|
+
attempts: int = 10,
|
|
84
|
+
sleep_seconds: float = 2.0,
|
|
85
|
+
) -> dict[str, Any]:
|
|
86
|
+
max_attempts = max(1, attempts)
|
|
87
|
+
info: dict[str, Any] = {}
|
|
88
|
+
status = ""
|
|
89
|
+
for attempt in range(max_attempts):
|
|
90
|
+
info = get_fvs(transport, fvs_id, env=env)
|
|
91
|
+
status = str(info.get("repoStatus") or "").strip().lower()
|
|
92
|
+
if status in {"", "ready"}:
|
|
93
|
+
return info
|
|
94
|
+
if attempt < max_attempts - 1:
|
|
95
|
+
time.sleep(sleep_seconds)
|
|
96
|
+
status_message = str(info.get("repoStatusMessage") or "").strip()
|
|
97
|
+
display = label or f"FVS '{fvs_id}'"
|
|
98
|
+
detail = f"{status}: {status_message}" if status_message else status
|
|
99
|
+
raise MaiError(f"FVS repository is not ready for {display}. Status: {detail}")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def get_fvs_credentials(
|
|
103
|
+
transport: Transport,
|
|
104
|
+
fvs_id: str,
|
|
105
|
+
*,
|
|
106
|
+
external: bool | None = None,
|
|
107
|
+
env: dict[str, str] | None = None,
|
|
108
|
+
) -> dict[str, Any]:
|
|
109
|
+
use_external = (
|
|
110
|
+
should_request_external_credentials(transport, env=env) if external is None else external
|
|
111
|
+
)
|
|
112
|
+
suffix = "?external=true" if use_external else ""
|
|
113
|
+
data = unwrap_data(
|
|
114
|
+
transport.request_service(
|
|
115
|
+
"fvm", f"/api/v1/fvs/{quote(fvs_id)}/credentials{suffix}", env=env
|
|
116
|
+
)
|
|
117
|
+
)
|
|
118
|
+
if not isinstance(data, dict):
|
|
119
|
+
raise MaiError(f"Unable to fetch credentials for FVS '{fvs_id}'.")
|
|
120
|
+
return data
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def notify_branch_pushed(
|
|
124
|
+
transport: Transport,
|
|
125
|
+
fvs_id: str,
|
|
126
|
+
branch: str,
|
|
127
|
+
*,
|
|
128
|
+
message: str = "",
|
|
129
|
+
paths: list[str] | None = None,
|
|
130
|
+
env: dict[str, str] | None = None,
|
|
131
|
+
) -> dict[str, Any]:
|
|
132
|
+
body: dict[str, Any] = {}
|
|
133
|
+
if message:
|
|
134
|
+
body["message"] = message
|
|
135
|
+
if paths is not None:
|
|
136
|
+
body["paths"] = paths
|
|
137
|
+
data = unwrap_data(
|
|
138
|
+
transport.request_service(
|
|
139
|
+
"fvm",
|
|
140
|
+
f"/api/v1/fvs/{quote(fvs_id)}/branches/{quote(branch)}/push",
|
|
141
|
+
method="POST",
|
|
142
|
+
body=body,
|
|
143
|
+
env=env,
|
|
144
|
+
timeout=_fvm_push_notify_timeout(env),
|
|
145
|
+
)
|
|
146
|
+
)
|
|
147
|
+
return data if isinstance(data, dict) else {}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def get_git_access_token(transport: Transport, *, env: dict[str, str] | None = None) -> str:
|
|
151
|
+
data = unwrap_data(transport.request_service("fvm", "/api/auth/git-token", env=env))
|
|
152
|
+
token = ""
|
|
153
|
+
if isinstance(data, dict):
|
|
154
|
+
token = data.get("token") or data.get("accessToken") or data.get("gitToken") or ""
|
|
155
|
+
if not token:
|
|
156
|
+
raise MaiError("Failed to exchange Git access token from FVM.")
|
|
157
|
+
return str(token)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def build_git_url(
|
|
161
|
+
transport: Transport,
|
|
162
|
+
fvs_info: dict[str, Any],
|
|
163
|
+
fallback_name: str,
|
|
164
|
+
*,
|
|
165
|
+
env: dict[str, str] | None = None,
|
|
166
|
+
) -> str:
|
|
167
|
+
fvs_id = fvs_info.get("id") or fvs_info.get("fvsId") or fvs_info.get("repoId") or fallback_name
|
|
168
|
+
if not fvs_id:
|
|
169
|
+
raise MaiError("Unable to resolve Git repository identifier from FVS.")
|
|
170
|
+
token = quote(get_git_access_token(transport, env=env), safe="")
|
|
171
|
+
base_url = resolve_service_base_url("fvm", fallback_url=transport.base_url, env=env)
|
|
172
|
+
prefix = get_service_prefix("fvm", base_url, env=env)
|
|
173
|
+
parsed = urlsplit(base_url)
|
|
174
|
+
base_path = parsed.path.rstrip("/")
|
|
175
|
+
return f"{parsed.scheme}://oauth2:{token}@{parsed.netloc}{base_path}{prefix}/{quote(str(fvs_id))}.git"
|