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.
Files changed (52) hide show
  1. mai/__init__.py +38 -0
  2. mai/__main__.py +6 -0
  3. mai/_core/__init__.py +1 -0
  4. mai/_core/api_response.py +10 -0
  5. mai/_core/cli_output.py +101 -0
  6. mai/_core/fvm_provider.py +47 -0
  7. mai/_core/image_copy.py +59 -0
  8. mai/_core/operations/__init__.py +1 -0
  9. mai/_core/operations/auth.py +49 -0
  10. mai/_core/operations/config_center.py +84 -0
  11. mai/_core/operations/files.py +87 -0
  12. mai/_core/operations/fvm.py +175 -0
  13. mai/_core/operations/images.py +134 -0
  14. mai/_core/operations/projects.py +74 -0
  15. mai/_core/operations/resources.py +222 -0
  16. mai/_core/operations/seed.py +314 -0
  17. mai/_core/operations/sync.py +274 -0
  18. mai/_core/resource_target.py +57 -0
  19. mai/_core/routes.py +97 -0
  20. mai/_core/settings.py +52 -0
  21. mai/_core/tools.py +136 -0
  22. mai/_core/transport.py +163 -0
  23. mai/_core/vcs.py +927 -0
  24. mai/_core/workspace.py +685 -0
  25. mai/app.py +106 -0
  26. mai/async_client.py +193 -0
  27. mai/cli/__init__.py +1 -0
  28. mai/cli/config_file.py +39 -0
  29. mai/client.py +233 -0
  30. mai/commands/__init__.py +1 -0
  31. mai/commands/_workspace_common.py +26 -0
  32. mai/commands/config_center.py +59 -0
  33. mai/commands/connect.py +37 -0
  34. mai/commands/create.py +80 -0
  35. mai/commands/download.py +175 -0
  36. mai/commands/file.py +26 -0
  37. mai/commands/image.py +135 -0
  38. mai/commands/install.py +28 -0
  39. mai/commands/login.py +39 -0
  40. mai/commands/project.py +67 -0
  41. mai/commands/publish.py +48 -0
  42. mai/commands/release.py +169 -0
  43. mai/commands/repo.py +107 -0
  44. mai/commands/seed.py +603 -0
  45. mai/commands/status.py +38 -0
  46. mai/commands/sync.py +67 -0
  47. mai/errors.py +47 -0
  48. mai/models.py +48 -0
  49. maind-0.1.0.dist-info/METADATA +320 -0
  50. maind-0.1.0.dist-info/RECORD +52 -0
  51. maind-0.1.0.dist-info/WHEEL +4 -0
  52. 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
@@ -0,0 +1,6 @@
1
+ """Enable ``python -m mai``."""
2
+
3
+ from .app import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
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
@@ -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
+ )
@@ -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"