pyspiral 0.1.0__cp310-abi3-macosx_11_0_arm64.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.
- 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)
|