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.
Files changed (81) hide show
  1. pyspiral-0.1.0.dist-info/METADATA +48 -0
  2. pyspiral-0.1.0.dist-info/RECORD +81 -0
  3. pyspiral-0.1.0.dist-info/WHEEL +4 -0
  4. pyspiral-0.1.0.dist-info/entry_points.txt +2 -0
  5. spiral/__init__.py +11 -0
  6. spiral/_lib.abi3.so +0 -0
  7. spiral/adbc.py +386 -0
  8. spiral/api/__init__.py +221 -0
  9. spiral/api/admin.py +29 -0
  10. spiral/api/filesystems.py +125 -0
  11. spiral/api/organizations.py +90 -0
  12. spiral/api/projects.py +160 -0
  13. spiral/api/tables.py +94 -0
  14. spiral/api/tokens.py +56 -0
  15. spiral/api/workloads.py +45 -0
  16. spiral/arrow.py +209 -0
  17. spiral/authn/__init__.py +0 -0
  18. spiral/authn/authn.py +89 -0
  19. spiral/authn/device.py +206 -0
  20. spiral/authn/github_.py +33 -0
  21. spiral/authn/modal_.py +18 -0
  22. spiral/catalog.py +78 -0
  23. spiral/cli/__init__.py +82 -0
  24. spiral/cli/__main__.py +4 -0
  25. spiral/cli/admin.py +21 -0
  26. spiral/cli/app.py +48 -0
  27. spiral/cli/console.py +95 -0
  28. spiral/cli/fs.py +47 -0
  29. spiral/cli/login.py +13 -0
  30. spiral/cli/org.py +90 -0
  31. spiral/cli/printer.py +45 -0
  32. spiral/cli/project.py +107 -0
  33. spiral/cli/state.py +3 -0
  34. spiral/cli/table.py +20 -0
  35. spiral/cli/token.py +27 -0
  36. spiral/cli/types.py +53 -0
  37. spiral/cli/workload.py +59 -0
  38. spiral/config.py +26 -0
  39. spiral/core/__init__.py +0 -0
  40. spiral/core/core/__init__.pyi +53 -0
  41. spiral/core/manifests/__init__.pyi +53 -0
  42. spiral/core/metastore/__init__.pyi +91 -0
  43. spiral/core/spec/__init__.pyi +257 -0
  44. spiral/dataset.py +239 -0
  45. spiral/debug.py +251 -0
  46. spiral/expressions/__init__.py +222 -0
  47. spiral/expressions/base.py +149 -0
  48. spiral/expressions/http.py +86 -0
  49. spiral/expressions/io.py +100 -0
  50. spiral/expressions/list_.py +68 -0
  51. spiral/expressions/refs.py +44 -0
  52. spiral/expressions/str_.py +39 -0
  53. spiral/expressions/struct.py +57 -0
  54. spiral/expressions/tiff.py +223 -0
  55. spiral/expressions/udf.py +46 -0
  56. spiral/grpc_.py +32 -0
  57. spiral/project.py +137 -0
  58. spiral/proto/_/__init__.py +0 -0
  59. spiral/proto/_/arrow/__init__.py +0 -0
  60. spiral/proto/_/arrow/flight/__init__.py +0 -0
  61. spiral/proto/_/arrow/flight/protocol/__init__.py +0 -0
  62. spiral/proto/_/arrow/flight/protocol/sql/__init__.py +1990 -0
  63. spiral/proto/_/scandal/__init__.py +223 -0
  64. spiral/proto/_/spfs/__init__.py +36 -0
  65. spiral/proto/_/spiral/__init__.py +0 -0
  66. spiral/proto/_/spiral/table/__init__.py +225 -0
  67. spiral/proto/_/spiraldb/__init__.py +0 -0
  68. spiral/proto/_/spiraldb/metastore/__init__.py +499 -0
  69. spiral/proto/__init__.py +0 -0
  70. spiral/proto/scandal/__init__.py +45 -0
  71. spiral/proto/spiral/__init__.py +0 -0
  72. spiral/proto/spiral/table/__init__.py +96 -0
  73. spiral/proto/substrait/__init__.py +3399 -0
  74. spiral/proto/substrait/extensions/__init__.py +115 -0
  75. spiral/proto/util.py +41 -0
  76. spiral/py.typed +0 -0
  77. spiral/scan_.py +168 -0
  78. spiral/settings.py +157 -0
  79. spiral/substrait_.py +275 -0
  80. spiral/table.py +157 -0
  81. 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)