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.
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)