pyspiral 0.1.0__cp310-abi3-macosx_11_0_arm64.whl
Sign up to get free protection for your applications and to get access to all the features.
- pyspiral-0.1.0.dist-info/METADATA +48 -0
- pyspiral-0.1.0.dist-info/RECORD +81 -0
- pyspiral-0.1.0.dist-info/WHEEL +4 -0
- pyspiral-0.1.0.dist-info/entry_points.txt +2 -0
- spiral/__init__.py +11 -0
- spiral/_lib.abi3.so +0 -0
- spiral/adbc.py +386 -0
- spiral/api/__init__.py +221 -0
- spiral/api/admin.py +29 -0
- spiral/api/filesystems.py +125 -0
- spiral/api/organizations.py +90 -0
- spiral/api/projects.py +160 -0
- spiral/api/tables.py +94 -0
- spiral/api/tokens.py +56 -0
- spiral/api/workloads.py +45 -0
- spiral/arrow.py +209 -0
- spiral/authn/__init__.py +0 -0
- spiral/authn/authn.py +89 -0
- spiral/authn/device.py +206 -0
- spiral/authn/github_.py +33 -0
- spiral/authn/modal_.py +18 -0
- spiral/catalog.py +78 -0
- spiral/cli/__init__.py +82 -0
- spiral/cli/__main__.py +4 -0
- spiral/cli/admin.py +21 -0
- spiral/cli/app.py +48 -0
- spiral/cli/console.py +95 -0
- spiral/cli/fs.py +47 -0
- spiral/cli/login.py +13 -0
- spiral/cli/org.py +90 -0
- spiral/cli/printer.py +45 -0
- spiral/cli/project.py +107 -0
- spiral/cli/state.py +3 -0
- spiral/cli/table.py +20 -0
- spiral/cli/token.py +27 -0
- spiral/cli/types.py +53 -0
- spiral/cli/workload.py +59 -0
- spiral/config.py +26 -0
- spiral/core/__init__.py +0 -0
- spiral/core/core/__init__.pyi +53 -0
- spiral/core/manifests/__init__.pyi +53 -0
- spiral/core/metastore/__init__.pyi +91 -0
- spiral/core/spec/__init__.pyi +257 -0
- spiral/dataset.py +239 -0
- spiral/debug.py +251 -0
- spiral/expressions/__init__.py +222 -0
- spiral/expressions/base.py +149 -0
- spiral/expressions/http.py +86 -0
- spiral/expressions/io.py +100 -0
- spiral/expressions/list_.py +68 -0
- spiral/expressions/refs.py +44 -0
- spiral/expressions/str_.py +39 -0
- spiral/expressions/struct.py +57 -0
- spiral/expressions/tiff.py +223 -0
- spiral/expressions/udf.py +46 -0
- spiral/grpc_.py +32 -0
- spiral/project.py +137 -0
- spiral/proto/_/__init__.py +0 -0
- spiral/proto/_/arrow/__init__.py +0 -0
- spiral/proto/_/arrow/flight/__init__.py +0 -0
- spiral/proto/_/arrow/flight/protocol/__init__.py +0 -0
- spiral/proto/_/arrow/flight/protocol/sql/__init__.py +1990 -0
- spiral/proto/_/scandal/__init__.py +223 -0
- spiral/proto/_/spfs/__init__.py +36 -0
- spiral/proto/_/spiral/__init__.py +0 -0
- spiral/proto/_/spiral/table/__init__.py +225 -0
- spiral/proto/_/spiraldb/__init__.py +0 -0
- spiral/proto/_/spiraldb/metastore/__init__.py +499 -0
- spiral/proto/__init__.py +0 -0
- spiral/proto/scandal/__init__.py +45 -0
- spiral/proto/spiral/__init__.py +0 -0
- spiral/proto/spiral/table/__init__.py +96 -0
- spiral/proto/substrait/__init__.py +3399 -0
- spiral/proto/substrait/extensions/__init__.py +115 -0
- spiral/proto/util.py +41 -0
- spiral/py.typed +0 -0
- spiral/scan_.py +168 -0
- spiral/settings.py +157 -0
- spiral/substrait_.py +275 -0
- spiral/table.py +157 -0
- spiral/types_.py +6 -0
spiral/api/__init__.py
ADDED
@@ -0,0 +1,221 @@
|
|
1
|
+
import abc
|
2
|
+
import base64
|
3
|
+
import logging
|
4
|
+
import os
|
5
|
+
import re
|
6
|
+
import socket
|
7
|
+
import time
|
8
|
+
from collections.abc import Iterable, Iterator
|
9
|
+
from typing import TYPE_CHECKING, Annotated, Generic, TypeVar
|
10
|
+
|
11
|
+
import betterproto
|
12
|
+
import httpx
|
13
|
+
import pyarrow as pa
|
14
|
+
from httpx import HTTPStatusError
|
15
|
+
from pydantic import (
|
16
|
+
BaseModel,
|
17
|
+
BeforeValidator,
|
18
|
+
Field,
|
19
|
+
GetPydanticSchema,
|
20
|
+
PlainSerializer,
|
21
|
+
StringConstraints,
|
22
|
+
TypeAdapter,
|
23
|
+
)
|
24
|
+
|
25
|
+
if TYPE_CHECKING:
|
26
|
+
from .admin import AdminService
|
27
|
+
from .filesystems import FileSystemService
|
28
|
+
from .organizations import OrganizationService
|
29
|
+
from .projects import ProjectService
|
30
|
+
from .tables import TableService
|
31
|
+
from .tokens import TokenService
|
32
|
+
from .workloads import WorkloadService
|
33
|
+
|
34
|
+
log = logging.getLogger(__name__)
|
35
|
+
|
36
|
+
RE_ID = re.compile("[a-zA-Z0-9-]+")
|
37
|
+
|
38
|
+
OrganizationId = Annotated[str, StringConstraints(min_length=1, max_length=64)] # , pattern=RE_ID)]
|
39
|
+
ProjectId = Annotated[str, StringConstraints(min_length=1, max_length=64)] # , pattern=RE_ID)]
|
40
|
+
RoleId = str
|
41
|
+
|
42
|
+
#: Annotations to implement pa.Schema serde with byte arrays
|
43
|
+
ArrowSchema = Annotated[
|
44
|
+
pa.Schema,
|
45
|
+
GetPydanticSchema(lambda tp, handler: handler(object)),
|
46
|
+
BeforeValidator(
|
47
|
+
lambda v: v
|
48
|
+
if isinstance(v, pa.Schema)
|
49
|
+
else pa.ipc.read_schema(pa.ipc.read_message(base64.urlsafe_b64decode(v)))
|
50
|
+
),
|
51
|
+
PlainSerializer(lambda schema: base64.urlsafe_b64encode(schema.serialize().to_pybytes())),
|
52
|
+
]
|
53
|
+
|
54
|
+
E = TypeVar("E")
|
55
|
+
|
56
|
+
|
57
|
+
class PagedRequest(BaseModel):
|
58
|
+
page_token: str | None = None
|
59
|
+
page_size: int = 50
|
60
|
+
|
61
|
+
|
62
|
+
class PagedResponse(BaseModel, Generic[E]):
|
63
|
+
items: list[E] = Field(default_factory=list)
|
64
|
+
next_page_token: str | None = None
|
65
|
+
|
66
|
+
|
67
|
+
PagedReqT = TypeVar("PagedReqT", bound=PagedRequest)
|
68
|
+
|
69
|
+
|
70
|
+
class Paged(Iterable[E], Generic[E]):
|
71
|
+
def __init__(
|
72
|
+
self,
|
73
|
+
client: "_Client",
|
74
|
+
path: str,
|
75
|
+
request: PagedRequest,
|
76
|
+
response_cls: type[PagedResponse[E]],
|
77
|
+
):
|
78
|
+
self._client = client
|
79
|
+
self._path = path
|
80
|
+
self._request: PagedRequest = request
|
81
|
+
self._response_cls = response_cls
|
82
|
+
|
83
|
+
self._response: PagedResponse[E] = client.put(path, request, response_cls)
|
84
|
+
|
85
|
+
@property
|
86
|
+
def page(self) -> PagedResponse[E]:
|
87
|
+
return self._response
|
88
|
+
|
89
|
+
def __iter__(self) -> Iterator[E]:
|
90
|
+
while True:
|
91
|
+
yield from self._response.items
|
92
|
+
|
93
|
+
if self._response.next_page_token is None:
|
94
|
+
break
|
95
|
+
|
96
|
+
self._request = self._request.model_copy(update=dict(page_token=self._response.next_page_token))
|
97
|
+
self._response = self._client.put(self._path, self._request, self._response_cls)
|
98
|
+
|
99
|
+
|
100
|
+
class ServiceBase:
|
101
|
+
def __init__(self, client: "_Client"):
|
102
|
+
self.client = client
|
103
|
+
|
104
|
+
|
105
|
+
class Authn:
|
106
|
+
"""An abstract class for credential providers."""
|
107
|
+
|
108
|
+
@abc.abstractmethod
|
109
|
+
def token(self) -> str | None:
|
110
|
+
"""Return a token, if available."""
|
111
|
+
|
112
|
+
|
113
|
+
class _Client:
|
114
|
+
RequestT = TypeVar("RequestT")
|
115
|
+
ResponseT = TypeVar("ResponseT")
|
116
|
+
|
117
|
+
def __init__(self, http: httpx.Client, authn: Authn):
|
118
|
+
self.http = http
|
119
|
+
self.authn = authn
|
120
|
+
|
121
|
+
def post(self, path: str, req: RequestT, response_cls: type[ResponseT]) -> ResponseT:
|
122
|
+
return self.request("POST", path, req, response_cls)
|
123
|
+
|
124
|
+
def put(self, path: str, req: RequestT, response_cls: type[ResponseT]) -> ResponseT:
|
125
|
+
return self.request("PUT", path, req, response_cls)
|
126
|
+
|
127
|
+
def request(self, method: str, path: str, req: RequestT, response_cls: type[ResponseT]) -> ResponseT:
|
128
|
+
if isinstance(req, betterproto.Message):
|
129
|
+
try:
|
130
|
+
req = dict(content=bytes(req))
|
131
|
+
except:
|
132
|
+
raise
|
133
|
+
else:
|
134
|
+
req = dict(json=TypeAdapter(req.__class__).dump_python(req, mode="json") if req is not None else None)
|
135
|
+
|
136
|
+
token = self.authn.token()
|
137
|
+
resp = self.http.request(method, path, headers={"authorization": f"Bearer {token}" if token else None}, **req)
|
138
|
+
|
139
|
+
try:
|
140
|
+
resp.raise_for_status()
|
141
|
+
except HTTPStatusError as e:
|
142
|
+
# Enrich the exception with the response body
|
143
|
+
raise HTTPStatusError(f"{str(e)}: {resp.text}", request=e.request, response=e.response)
|
144
|
+
|
145
|
+
if issubclass(response_cls, betterproto.Message):
|
146
|
+
return response_cls().parse(resp.content)
|
147
|
+
else:
|
148
|
+
return TypeAdapter(response_cls).validate_python(resp.json())
|
149
|
+
|
150
|
+
def paged(self, path: str, req: PagedRequest, response_cls: type[PagedResponse[E]]) -> Paged[E]:
|
151
|
+
return Paged(self, path, req, response_cls)
|
152
|
+
|
153
|
+
|
154
|
+
class SpiralAPI:
|
155
|
+
def __init__(self, authn: Authn, base_url: str | None = None):
|
156
|
+
self.base_url = base_url or os.environ.get("SPIRAL_URL", "https://api.spiraldb.com")
|
157
|
+
self.client = _Client(
|
158
|
+
httpx.Client(
|
159
|
+
base_url=self.base_url,
|
160
|
+
timeout=60,
|
161
|
+
# timeout=None if ("PYTEST_VERSION" in os.environ or bool(os.environ.get("SPIRAL_DEV", None))) else 60,
|
162
|
+
),
|
163
|
+
authn,
|
164
|
+
)
|
165
|
+
|
166
|
+
@property
|
167
|
+
def _admin(self) -> "AdminService":
|
168
|
+
from .admin import AdminService
|
169
|
+
|
170
|
+
return AdminService(self.client)
|
171
|
+
|
172
|
+
@property
|
173
|
+
def file_system(self) -> "FileSystemService":
|
174
|
+
from .filesystems import FileSystemService
|
175
|
+
|
176
|
+
return FileSystemService(self.client)
|
177
|
+
|
178
|
+
@property
|
179
|
+
def organization(self) -> "OrganizationService":
|
180
|
+
from .organizations import OrganizationService
|
181
|
+
|
182
|
+
return OrganizationService(self.client)
|
183
|
+
|
184
|
+
@property
|
185
|
+
def token(self) -> "TokenService":
|
186
|
+
from .tokens import TokenService
|
187
|
+
|
188
|
+
return TokenService(self.client)
|
189
|
+
|
190
|
+
@property
|
191
|
+
def project(self) -> "ProjectService":
|
192
|
+
from .projects import ProjectService
|
193
|
+
|
194
|
+
return ProjectService(self.client)
|
195
|
+
|
196
|
+
@property
|
197
|
+
def table(self) -> "TableService":
|
198
|
+
from .tables import TableService
|
199
|
+
|
200
|
+
return TableService(self.client)
|
201
|
+
|
202
|
+
@property
|
203
|
+
def workload(self) -> "WorkloadService":
|
204
|
+
from .workloads import WorkloadService
|
205
|
+
|
206
|
+
return WorkloadService(self.client)
|
207
|
+
|
208
|
+
|
209
|
+
def wait_for_port(port: int, host: str = "localhost", timeout: float = 5.0):
|
210
|
+
"""Wait until a port starts accepting TCP connections."""
|
211
|
+
start_time = time.time()
|
212
|
+
while True:
|
213
|
+
try:
|
214
|
+
with socket.create_connection((host, port), timeout=timeout):
|
215
|
+
break
|
216
|
+
except OSError as ex:
|
217
|
+
time.sleep(0.01)
|
218
|
+
if time.time() - start_time >= timeout:
|
219
|
+
raise TimeoutError(
|
220
|
+
f"Waited too long for the port {port} on host {host} to start accepting " "connections."
|
221
|
+
) from ex
|
spiral/api/admin.py
ADDED
@@ -0,0 +1,29 @@
|
|
1
|
+
from pydantic import BaseModel
|
2
|
+
|
3
|
+
from . import OrganizationId, Paged, PagedRequest, PagedResponse, ServiceBase
|
4
|
+
|
5
|
+
|
6
|
+
class SyncOrgs:
|
7
|
+
class Request(PagedRequest): ...
|
8
|
+
|
9
|
+
class Response(PagedResponse[OrganizationId]): ...
|
10
|
+
|
11
|
+
|
12
|
+
class Membership(BaseModel):
|
13
|
+
user_id: str
|
14
|
+
organization_id: str
|
15
|
+
|
16
|
+
|
17
|
+
class SyncMemberships:
|
18
|
+
class Request(PagedRequest):
|
19
|
+
organization_id: OrganizationId | None = None
|
20
|
+
|
21
|
+
class Response(PagedResponse[Membership]): ...
|
22
|
+
|
23
|
+
|
24
|
+
class AdminService(ServiceBase):
|
25
|
+
def sync_orgs(self, request: SyncOrgs.Request) -> Paged[SyncOrgs.Response]:
|
26
|
+
return self.client.paged("/admin/sync-orgs", request, SyncOrgs.Response)
|
27
|
+
|
28
|
+
def sync_memberships(self, request: SyncMemberships.Request) -> Paged[Membership]:
|
29
|
+
return self.client.paged("/admin/sync-memberships", request, SyncMemberships.Response)
|
@@ -0,0 +1,125 @@
|
|
1
|
+
from datetime import timedelta
|
2
|
+
from enum import Enum
|
3
|
+
from typing import Annotated, Literal
|
4
|
+
|
5
|
+
from pydantic import AfterValidator, BaseModel, Field
|
6
|
+
|
7
|
+
from . import ProjectId, ServiceBase
|
8
|
+
|
9
|
+
|
10
|
+
class BuiltinFileSystem(BaseModel):
|
11
|
+
type: Literal["builtin"] = "builtin"
|
12
|
+
provider: str
|
13
|
+
|
14
|
+
|
15
|
+
FileSystem = Annotated[BuiltinFileSystem, Field(discriminator="type")]
|
16
|
+
|
17
|
+
|
18
|
+
def _validate_file_path(path: str) -> str:
|
19
|
+
if "//" in path:
|
20
|
+
raise ValueError("FilePath must not contain multiple slashes")
|
21
|
+
if not path.startswith("/"):
|
22
|
+
raise ValueError("FilePath must start with /")
|
23
|
+
if path.endswith("/"):
|
24
|
+
raise ValueError("FilePath must not end with /")
|
25
|
+
return path
|
26
|
+
|
27
|
+
|
28
|
+
FilePath = Annotated[
|
29
|
+
str,
|
30
|
+
AfterValidator(_validate_file_path),
|
31
|
+
Field(description="A path to an individual file in the file system. Must not end with a slash."),
|
32
|
+
]
|
33
|
+
|
34
|
+
|
35
|
+
def _validate_prefix(path: str) -> str:
|
36
|
+
if "//" in path:
|
37
|
+
raise ValueError("Prefix must not contain multiple slashes")
|
38
|
+
if not path.startswith("/"):
|
39
|
+
raise ValueError("Prefix must start with /")
|
40
|
+
if not path.endswith("/"):
|
41
|
+
raise ValueError("Prefix must end with /")
|
42
|
+
return path
|
43
|
+
|
44
|
+
|
45
|
+
Prefix = Annotated[
|
46
|
+
str,
|
47
|
+
AfterValidator(_validate_prefix),
|
48
|
+
Field(description="A prefix in a file system. Must end with a slash."),
|
49
|
+
]
|
50
|
+
|
51
|
+
|
52
|
+
class Mode(Enum):
|
53
|
+
READ_ONLY = "ro"
|
54
|
+
READ_WRITE = "rw"
|
55
|
+
|
56
|
+
|
57
|
+
class Mount(BaseModel):
|
58
|
+
id: str
|
59
|
+
project_id: ProjectId
|
60
|
+
prefix: Prefix
|
61
|
+
mode: Mode
|
62
|
+
principal: str
|
63
|
+
|
64
|
+
|
65
|
+
class GetFileSystem:
|
66
|
+
class Request(BaseModel):
|
67
|
+
project_id: ProjectId
|
68
|
+
|
69
|
+
class Response(BaseModel):
|
70
|
+
file_system: FileSystem
|
71
|
+
|
72
|
+
|
73
|
+
class UpdateFileSystem:
|
74
|
+
class Request(BaseModel):
|
75
|
+
project_id: ProjectId
|
76
|
+
file_system: FileSystem
|
77
|
+
|
78
|
+
class Response(BaseModel):
|
79
|
+
file_system: FileSystem
|
80
|
+
|
81
|
+
|
82
|
+
class ListProviders:
|
83
|
+
class Response(BaseModel):
|
84
|
+
providers: list[str]
|
85
|
+
|
86
|
+
|
87
|
+
class CreateMount:
|
88
|
+
class Request(BaseModel):
|
89
|
+
project_id: ProjectId
|
90
|
+
prefix: Prefix
|
91
|
+
mode: Mode
|
92
|
+
principal: str
|
93
|
+
|
94
|
+
class Response(BaseModel):
|
95
|
+
mount: Mount
|
96
|
+
|
97
|
+
|
98
|
+
class CreateMountToken:
|
99
|
+
class Request(BaseModel):
|
100
|
+
mount_id: str
|
101
|
+
mode: Mode
|
102
|
+
path: FilePath | Prefix
|
103
|
+
ttl: int = Field(default_factory=lambda: int(timedelta(hours=1).total_seconds()))
|
104
|
+
|
105
|
+
class Response(BaseModel):
|
106
|
+
token: str
|
107
|
+
|
108
|
+
|
109
|
+
class FileSystemService(ServiceBase):
|
110
|
+
def get_file_system(self, request: GetFileSystem.Request) -> GetFileSystem.Response:
|
111
|
+
"""Get the file system currently configured for a project."""
|
112
|
+
return self.client.put("/file-system/get", request, GetFileSystem.Response)
|
113
|
+
|
114
|
+
def update_file_system(self, request: UpdateFileSystem.Request) -> UpdateFileSystem.Response:
|
115
|
+
"""Update the file system for a project."""
|
116
|
+
return self.client.put("/file-system/update", request, UpdateFileSystem.Response)
|
117
|
+
|
118
|
+
def list_providers(self) -> ListProviders.Response:
|
119
|
+
return self.client.put("/file-system/list-providers", None, ListProviders.Response)
|
120
|
+
|
121
|
+
def create_mount(self, request: CreateMount.Request) -> CreateMount.Response:
|
122
|
+
return self.client.post("/file-system/create-mount", request, CreateMount.Response)
|
123
|
+
|
124
|
+
def create_mount_token(self, request: CreateMountToken.Request) -> CreateMountToken.Response:
|
125
|
+
return self.client.post("/file-system/create-mount-token", request, CreateMountToken.Response)
|
@@ -0,0 +1,90 @@
|
|
1
|
+
from enum import Enum
|
2
|
+
|
3
|
+
from pydantic import BaseModel, EmailStr, Field
|
4
|
+
|
5
|
+
from . import OrganizationId, Paged, PagedRequest, PagedResponse, ServiceBase
|
6
|
+
|
7
|
+
|
8
|
+
class OrganizationRole(Enum):
|
9
|
+
OWNER = "owner"
|
10
|
+
MEMBER = "member"
|
11
|
+
GUEST = "guest"
|
12
|
+
|
13
|
+
|
14
|
+
class Organization(BaseModel):
|
15
|
+
id: OrganizationId
|
16
|
+
name: str | None = Field(
|
17
|
+
default=None,
|
18
|
+
description="Optional human-readable name for the organization",
|
19
|
+
)
|
20
|
+
|
21
|
+
|
22
|
+
class OrganizationMembership(BaseModel):
|
23
|
+
organization: Organization
|
24
|
+
role: str = Field(description="The user's role in the organization")
|
25
|
+
|
26
|
+
|
27
|
+
class CreateOrganization:
|
28
|
+
class Request(BaseModel):
|
29
|
+
name: str | None = Field(
|
30
|
+
default=None,
|
31
|
+
description="Optional human-readable name for the organization",
|
32
|
+
)
|
33
|
+
|
34
|
+
class Response(BaseModel):
|
35
|
+
organization: Organization
|
36
|
+
|
37
|
+
|
38
|
+
class ListUserMemberships:
|
39
|
+
class Request(PagedRequest):
|
40
|
+
"""List the organization memberships of the current user."""
|
41
|
+
|
42
|
+
class Response(PagedResponse[OrganizationMembership]):
|
43
|
+
"""The user's organization memberships."""
|
44
|
+
|
45
|
+
|
46
|
+
class PortalLink:
|
47
|
+
class Intent(Enum):
|
48
|
+
SSO = "sso"
|
49
|
+
DIRECTORY = "directory"
|
50
|
+
AUDIT_LOGS = "audit-logs"
|
51
|
+
LOG_STREAMS = "log-streams"
|
52
|
+
DOMAIN_VERIFICATION = "domain-verification"
|
53
|
+
|
54
|
+
class Request(BaseModel):
|
55
|
+
intent: "PortalLink.Intent"
|
56
|
+
|
57
|
+
class Response(BaseModel):
|
58
|
+
url: str
|
59
|
+
|
60
|
+
|
61
|
+
class InviteUser:
|
62
|
+
class Request(BaseModel):
|
63
|
+
email: EmailStr
|
64
|
+
role: OrganizationRole
|
65
|
+
expires_in_days: int = 7
|
66
|
+
|
67
|
+
class Response(BaseModel):
|
68
|
+
invite_id: str
|
69
|
+
|
70
|
+
|
71
|
+
class OrganizationService(ServiceBase):
|
72
|
+
def create_organization(self, request: CreateOrganization.Request) -> CreateOrganization.Response:
|
73
|
+
"""Create a new organization."""
|
74
|
+
return self.client.post("/organization/create", request, CreateOrganization.Response)
|
75
|
+
|
76
|
+
def list_user_memberships(self) -> Paged[OrganizationMembership]:
|
77
|
+
"""List organizations that the user is a member of."""
|
78
|
+
return self.client.paged(
|
79
|
+
"/organization/list-user-memberships",
|
80
|
+
ListUserMemberships.Request(),
|
81
|
+
ListUserMemberships.Response,
|
82
|
+
)
|
83
|
+
|
84
|
+
def portal_link(self, request: PortalLink.Request) -> PortalLink.Response:
|
85
|
+
"""Get a link to the organization configuration portal."""
|
86
|
+
return self.client.put("/organization/portal-link", request, PortalLink.Response)
|
87
|
+
|
88
|
+
def invite_user(self, request: InviteUser.Request) -> InviteUser.Response:
|
89
|
+
"""Invite a user to the organization."""
|
90
|
+
return self.client.post("/organization/invite-user", request, InviteUser.Response)
|
spiral/api/projects.py
ADDED
@@ -0,0 +1,160 @@
|
|
1
|
+
from enum import Enum
|
2
|
+
from typing import Annotated, Literal, Union
|
3
|
+
|
4
|
+
from pydantic import BaseModel, Field
|
5
|
+
|
6
|
+
from . import OrganizationId, Paged, PagedRequest, PagedResponse, ProjectId, RoleId, ServiceBase
|
7
|
+
from .organizations import OrganizationRole
|
8
|
+
|
9
|
+
|
10
|
+
class Project(BaseModel):
|
11
|
+
id: ProjectId
|
12
|
+
organization_id: OrganizationId
|
13
|
+
name: str | None = None
|
14
|
+
|
15
|
+
|
16
|
+
class Grant(BaseModel):
|
17
|
+
id: str
|
18
|
+
project_id: ProjectId
|
19
|
+
role_id: RoleId
|
20
|
+
principal: str
|
21
|
+
|
22
|
+
|
23
|
+
class CreateProject:
|
24
|
+
class Request(BaseModel):
|
25
|
+
organization_id: OrganizationId
|
26
|
+
id_prefix: str | None = Field(default=None, description="Optional prefix for a random project ID")
|
27
|
+
name: str | None = Field(default=None, description="Optional human-readable name for the project")
|
28
|
+
|
29
|
+
id: str | None = Field(
|
30
|
+
default=None,
|
31
|
+
description="Exact project ID to use. Requires elevated permissions.",
|
32
|
+
)
|
33
|
+
|
34
|
+
class Response(BaseModel):
|
35
|
+
project: Project
|
36
|
+
|
37
|
+
|
38
|
+
class GetProject:
|
39
|
+
class Request(BaseModel):
|
40
|
+
id: ProjectId
|
41
|
+
|
42
|
+
class Response(BaseModel):
|
43
|
+
project: Project
|
44
|
+
|
45
|
+
|
46
|
+
class ListProjects:
|
47
|
+
class Request(PagedRequest): ...
|
48
|
+
|
49
|
+
class Response(PagedResponse[Project]): ...
|
50
|
+
|
51
|
+
|
52
|
+
class ListGrants:
|
53
|
+
class Request(PagedRequest):
|
54
|
+
project_id: ProjectId
|
55
|
+
|
56
|
+
class Response(PagedResponse[Grant]): ...
|
57
|
+
|
58
|
+
|
59
|
+
class GrantRole:
|
60
|
+
class Request(BaseModel):
|
61
|
+
project_id: ProjectId
|
62
|
+
role_id: RoleId
|
63
|
+
principal: Annotated[
|
64
|
+
Union[
|
65
|
+
"GrantRole.OrgRolePrincipal",
|
66
|
+
"GrantRole.OrgUserPrincipal",
|
67
|
+
"GrantRole.WorkloadPrincipal",
|
68
|
+
"GrantRole.GitHubPrincipal",
|
69
|
+
"GrantRole.ModalPrincipal",
|
70
|
+
],
|
71
|
+
Field(discriminator="type"),
|
72
|
+
]
|
73
|
+
|
74
|
+
class Response(BaseModel): ...
|
75
|
+
|
76
|
+
class OrgRolePrincipal(BaseModel):
|
77
|
+
type: Literal["org"] = "org"
|
78
|
+
|
79
|
+
org_id: OrganizationId
|
80
|
+
role: OrganizationRole
|
81
|
+
|
82
|
+
class OrgUserPrincipal(BaseModel):
|
83
|
+
type: Literal["org_user"] = "org_user"
|
84
|
+
|
85
|
+
org_id: OrganizationId
|
86
|
+
user_id: str
|
87
|
+
|
88
|
+
class WorkloadPrincipal(BaseModel):
|
89
|
+
type: Literal["workload"] = "workload"
|
90
|
+
|
91
|
+
workload_id: str
|
92
|
+
|
93
|
+
class GitHubClaim(Enum):
|
94
|
+
environment = "environment"
|
95
|
+
ref = "ref"
|
96
|
+
sha = "sha"
|
97
|
+
repository = "repository"
|
98
|
+
repository_owner = "repository_owner"
|
99
|
+
actor_id = "actor_id"
|
100
|
+
repository_visibility = "repository_visibility"
|
101
|
+
repository_id = "repository_id"
|
102
|
+
repository_owner_id = "repository_owner_id"
|
103
|
+
run_id = "run_id"
|
104
|
+
run_number = "run_number"
|
105
|
+
run_attempt = "run_attempt"
|
106
|
+
runner_environment = "runner_environment"
|
107
|
+
actor = "actor"
|
108
|
+
workflow = "workflow"
|
109
|
+
head_ref = "head_ref"
|
110
|
+
base_ref = "base_ref"
|
111
|
+
event_name = "event_name"
|
112
|
+
ref_type = "ref_type"
|
113
|
+
job_workflow_ref = "job_workflow_ref"
|
114
|
+
|
115
|
+
class GitHubPrincipal(BaseModel):
|
116
|
+
type: Literal["github"] = "github"
|
117
|
+
|
118
|
+
org: str
|
119
|
+
repo: str
|
120
|
+
conditions: dict["GrantRole.GitHubClaim", str] | None = None
|
121
|
+
|
122
|
+
class ModalClaim(Enum):
|
123
|
+
workspace_id = "workspace_id"
|
124
|
+
environment_id = "environment_id"
|
125
|
+
environment_name = "environment_name"
|
126
|
+
app_id = "app_id"
|
127
|
+
app_name = "app_name"
|
128
|
+
function_id = "function_id"
|
129
|
+
function_name = "function_name"
|
130
|
+
container_id = "container_id"
|
131
|
+
|
132
|
+
class ModalPrincipal(BaseModel):
|
133
|
+
type: Literal["modal"] = "modal"
|
134
|
+
|
135
|
+
workspace_id: str
|
136
|
+
# Environments are sub-divisions of workspaces. Name is unique within a workspace.
|
137
|
+
# See https://modal.com/docs/guide/environments
|
138
|
+
environment_name: str
|
139
|
+
# A Modal App is a group of functions and classes that are deployed together.
|
140
|
+
# See https://modal.com/docs/guide/apps. Nick and Marko discussed having an app_name
|
141
|
+
# here as well and decided to leave it out for now with the assumption that people
|
142
|
+
# will want to authorize the whole Modal environment to access Spiral (their data).
|
143
|
+
conditions: dict["GrantRole.ModalClaim", str] | None = None
|
144
|
+
|
145
|
+
|
146
|
+
class ProjectService(ServiceBase):
|
147
|
+
def get(self, request: GetProject.Request) -> GetProject.Response:
|
148
|
+
return self.client.put("/project/get", request, GetProject.Response)
|
149
|
+
|
150
|
+
def create(self, request: CreateProject.Request) -> CreateProject.Response:
|
151
|
+
return self.client.post("/project/create", request, CreateProject.Response)
|
152
|
+
|
153
|
+
def list(self) -> Paged[Project]:
|
154
|
+
return self.client.paged("/project/list", ListProjects.Request(), ListProjects.Response)
|
155
|
+
|
156
|
+
def list_grants(self, request: ListGrants.Request) -> Paged[Grant]:
|
157
|
+
return self.client.paged("/project/list-grants", request, ListGrants.Response)
|
158
|
+
|
159
|
+
def grant_role(self, request: GrantRole.Request) -> GrantRole.Response:
|
160
|
+
return self.client.post("/project/grant-role", request, GrantRole.Response)
|