pyspiral 0.3.1__cp310-abi3-macosx_11_0_arm64.whl → 0.4.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 (109) hide show
  1. {pyspiral-0.3.1.dist-info → pyspiral-0.4.0.dist-info}/METADATA +9 -13
  2. pyspiral-0.4.0.dist-info/RECORD +98 -0
  3. {pyspiral-0.3.1.dist-info → pyspiral-0.4.0.dist-info}/WHEEL +1 -1
  4. spiral/__init__.py +6 -9
  5. spiral/_lib.abi3.so +0 -0
  6. spiral/adbc.py +21 -14
  7. spiral/api/__init__.py +14 -175
  8. spiral/api/admin.py +12 -26
  9. spiral/api/client.py +160 -0
  10. spiral/api/filesystems.py +100 -72
  11. spiral/api/organizations.py +45 -58
  12. spiral/api/projects.py +171 -134
  13. spiral/api/telemetry.py +19 -0
  14. spiral/api/types.py +20 -0
  15. spiral/api/workloads.py +32 -25
  16. spiral/{arrow.py → arrow_.py} +12 -0
  17. spiral/cli/__init__.py +2 -5
  18. spiral/cli/admin.py +7 -12
  19. spiral/cli/app.py +23 -6
  20. spiral/cli/console.py +1 -1
  21. spiral/cli/fs.py +82 -17
  22. spiral/cli/iceberg/__init__.py +7 -0
  23. spiral/cli/iceberg/namespaces.py +47 -0
  24. spiral/cli/iceberg/tables.py +60 -0
  25. spiral/cli/indexes/__init__.py +19 -0
  26. spiral/cli/login.py +14 -5
  27. spiral/cli/orgs.py +90 -0
  28. spiral/cli/printer.py +9 -1
  29. spiral/cli/projects.py +136 -0
  30. spiral/cli/state.py +2 -0
  31. spiral/cli/tables/__init__.py +121 -0
  32. spiral/cli/telemetry.py +18 -0
  33. spiral/cli/types.py +8 -10
  34. spiral/cli/{workload.py → workloads.py} +11 -11
  35. spiral/{catalog.py → client.py} +23 -37
  36. spiral/core/client/__init__.pyi +117 -0
  37. spiral/core/index/__init__.pyi +15 -0
  38. spiral/core/{core → table}/__init__.pyi +44 -17
  39. spiral/core/{manifests → table/manifests}/__init__.pyi +5 -23
  40. spiral/core/table/metastore/__init__.pyi +62 -0
  41. spiral/core/{spec → table/spec}/__init__.pyi +41 -66
  42. spiral/datetime_.py +27 -0
  43. spiral/expressions/__init__.py +26 -18
  44. spiral/expressions/base.py +5 -5
  45. spiral/expressions/list_.py +1 -1
  46. spiral/expressions/mp4.py +2 -9
  47. spiral/expressions/png.py +1 -1
  48. spiral/expressions/qoi.py +1 -1
  49. spiral/expressions/refs.py +3 -9
  50. spiral/expressions/struct.py +7 -5
  51. spiral/expressions/text.py +62 -0
  52. spiral/expressions/udf.py +3 -3
  53. spiral/iceberg/__init__.py +3 -0
  54. spiral/iceberg/client.py +33 -0
  55. spiral/indexes/__init__.py +5 -0
  56. spiral/indexes/client.py +137 -0
  57. spiral/indexes/index.py +34 -0
  58. spiral/indexes/scan.py +22 -0
  59. spiral/project.py +19 -110
  60. spiral/{proto → protogen}/_/scandal/__init__.py +23 -135
  61. spiral/protogen/_/spiral/table/__init__.py +22 -0
  62. spiral/protogen/substrait/__init__.py +3399 -0
  63. spiral/protogen/substrait/extensions/__init__.py +115 -0
  64. spiral/server.py +17 -0
  65. spiral/settings.py +29 -91
  66. spiral/substrait_.py +9 -5
  67. spiral/tables/__init__.py +12 -0
  68. spiral/tables/client.py +130 -0
  69. spiral/{dataset.py → tables/dataset.py} +9 -199
  70. spiral/tables/debug/manifests.py +70 -0
  71. spiral/tables/debug/metrics.py +56 -0
  72. spiral/{debug.py → tables/debug/scan.py} +6 -9
  73. spiral/{maintenance.py → tables/maintenance.py} +1 -1
  74. spiral/{scan_.py → tables/scan.py} +63 -89
  75. spiral/tables/snapshot.py +78 -0
  76. spiral/{table.py → tables/table.py} +59 -73
  77. spiral/{txn.py → tables/transaction.py} +7 -3
  78. pyspiral-0.3.1.dist-info/RECORD +0 -85
  79. spiral/api/tables.py +0 -91
  80. spiral/api/tokens.py +0 -56
  81. spiral/authn/authn.py +0 -89
  82. spiral/authn/device.py +0 -206
  83. spiral/authn/github_.py +0 -33
  84. spiral/authn/modal_.py +0 -18
  85. spiral/cli/org.py +0 -90
  86. spiral/cli/project.py +0 -109
  87. spiral/cli/table.py +0 -20
  88. spiral/cli/token.py +0 -27
  89. spiral/core/metastore/__init__.pyi +0 -91
  90. spiral/proto/_/spfs/__init__.py +0 -36
  91. spiral/proto/_/spiral/table/__init__.py +0 -276
  92. spiral/proto/_/spiraldb/metastore/__init__.py +0 -499
  93. spiral/proto/__init__.py +0 -0
  94. spiral/proto/scandal/__init__.py +0 -45
  95. spiral/proto/spiral/__init__.py +0 -0
  96. spiral/proto/spiral/table/__init__.py +0 -96
  97. {pyspiral-0.3.1.dist-info → pyspiral-0.4.0.dist-info}/entry_points.txt +0 -0
  98. /spiral/{authn/__init__.py → core/__init__.pyi} +0 -0
  99. /spiral/{core → protogen/_}/__init__.py +0 -0
  100. /spiral/{proto/_ → protogen/_/arrow}/__init__.py +0 -0
  101. /spiral/{proto/_/arrow → protogen/_/arrow/flight}/__init__.py +0 -0
  102. /spiral/{proto/_/arrow/flight → protogen/_/arrow/flight/protocol}/__init__.py +0 -0
  103. /spiral/{proto → protogen}/_/arrow/flight/protocol/sql/__init__.py +0 -0
  104. /spiral/{proto/_/arrow/flight/protocol → protogen/_/spiral}/__init__.py +0 -0
  105. /spiral/{proto → protogen/_}/substrait/__init__.py +0 -0
  106. /spiral/{proto → protogen/_}/substrait/extensions/__init__.py +0 -0
  107. /spiral/{proto/_/spiral → protogen}/__init__.py +0 -0
  108. /spiral/{proto → protogen}/util.py +0 -0
  109. /spiral/{proto/_/spiraldb → tables/debug}/__init__.py +0 -0
spiral/cli/fs.py CHANGED
@@ -2,46 +2,111 @@ from typing import Annotated
2
2
 
3
3
  import questionary
4
4
  import rich
5
+ from pydantic import SecretStr
5
6
  from typer import Option
6
7
 
7
- from spiral.api.filesystems import BuiltinFileSystem, GetFileSystem, UpdateFileSystem
8
+ from spiral.api.filesystems import (
9
+ AWSSecretAccessKey,
10
+ BuiltinFileSystem,
11
+ GCPServiceAccount,
12
+ UpdateGCSFileSystem,
13
+ UpdateS3FileSystem,
14
+ UpstreamFileSystem,
15
+ )
8
16
  from spiral.cli import AsyncTyper, state
9
- from spiral.cli.types import ProjectArg
17
+ from spiral.cli.types import ProjectArg, ask_project
10
18
 
11
- app = AsyncTyper()
19
+ app = AsyncTyper(short_help="File Systems.")
12
20
 
13
21
 
14
22
  @app.command(help="Show the file system configured for project.")
15
23
  def show(project: ProjectArg):
16
- res = state.settings.api.file_system.get_file_system(GetFileSystem.Request(project_id=project))
17
- match res.file_system:
24
+ file_system = state.settings.api.file_system.get_file_system(project)
25
+ match file_system:
18
26
  case BuiltinFileSystem(provider=provider):
19
27
  rich.print(f"provider: {provider}")
20
28
  case _:
21
- rich.print(res.file_system)
29
+ rich.print(file_system)
22
30
 
23
31
 
24
- def _provider_default():
32
+ def ask_provider():
25
33
  res = state.settings.api.file_system.list_providers()
26
- return questionary.select("Select a file system provider", choices=res.providers).ask()
34
+ return questionary.select("Select a file system provider", choices=res).ask()
27
35
 
28
36
 
29
- ProviderOpt = Annotated[
37
+ BuiltinProviderOpt = Annotated[
30
38
  str,
31
- Option(help="Built-in provider to use for the file system.", show_default=False, default_factory=_provider_default),
39
+ Option(help="Built-in provider to use for the file system.", show_default=False, default_factory=ask_provider),
32
40
  ]
33
41
 
34
42
 
35
- @app.command(help="Update a project's file system.")
36
- def update(project: ProjectArg, provider: ProviderOpt):
37
- res = state.settings.api.file_system.update_file_system(
38
- UpdateFileSystem.Request(project_id=project, file_system=BuiltinFileSystem(provider=provider))
39
- )
43
+ @app.command(help="Update a project's default file system.")
44
+ def update(
45
+ project: ProjectArg,
46
+ builtin: bool = Option(False, help="Use a built-in file system provider."),
47
+ upstream: bool = Option(
48
+ False, help="Use another project as default file system. Only if another project is an external provider."
49
+ ),
50
+ s3: bool = Option(False, help="Use S3 compatible provider."),
51
+ gcs: bool = Option(False, help="Use GCS provider."),
52
+ provider: str = Option(None, help="Built-in provider to use for the file system."),
53
+ endpoint: str = Option(None, help="Endpoint for S3 provider."),
54
+ region: str = Option(None, help="Region for S3 or GCS provider. Required for GCS."),
55
+ bucket: str = Option(None, help="Bucket name for S3 or GCS provider."),
56
+ directory: str = Option(None, help="Directory for S3 or GCS provider."),
57
+ access_key_id: str = Option(None, help="Access key ID for S3 provider. Required for S3."),
58
+ secret_access_key: str = Option(None, help="Secret access key for S3 provider. Required for S3."),
59
+ credentials_path: str = Option(
60
+ None, help="Path to service account credentials file for GCS provider. Required for GCS."
61
+ ),
62
+ ):
63
+ if not any([builtin, s3, gcs, upstream]):
64
+ raise ValueError("Must specify one of --builtin, --upstream, --s3, or --gcs.")
65
+
66
+ if builtin:
67
+ provider = provider or ask_provider()
68
+ file_system = BuiltinFileSystem(provider=provider)
69
+
70
+ elif upstream:
71
+ upstream_project = ask_project(title="Select a project to use as file system.")
72
+ file_system = UpstreamFileSystem(project_id=upstream_project)
73
+
74
+ elif s3:
75
+ if access_key_id is None or secret_access_key is None:
76
+ raise ValueError("--access-key-id and --secret-access-key are required for S3 provider.")
77
+ credentials = AWSSecretAccessKey(access_key_id=access_key_id, secret_access_key=secret_access_key)
78
+
79
+ if bucket is None:
80
+ raise ValueError("--bucket is required for S3 provider.")
81
+ file_system = UpdateS3FileSystem(bucket=bucket, credentials=credentials)
82
+ if endpoint:
83
+ file_system.endpoint = endpoint
84
+ if region:
85
+ file_system.region = region
86
+ if directory:
87
+ file_system.directory = directory
88
+
89
+ elif gcs:
90
+ if credentials_path is None:
91
+ raise ValueError("--credentials-path is required for GCS provider.")
92
+ with open(credentials_path) as f:
93
+ service_account = f.read()
94
+ credentials = GCPServiceAccount(credentials=SecretStr(service_account))
95
+
96
+ if region is None or bucket is None:
97
+ raise ValueError("--region and --bucket is required for GCS provider.")
98
+ file_system = UpdateGCSFileSystem(bucket=bucket, region=region, credentials=credentials)
99
+ if directory:
100
+ file_system.directory = directory
101
+
102
+ else:
103
+ raise ValueError("Must specify either --s3 or --gcs.")
104
+
105
+ res = state.settings.api.file_system.update_file_system(project, file_system)
40
106
  rich.print(res.file_system)
41
107
 
42
108
 
43
109
  @app.command(help="Lists the available built-in file system providers.")
44
110
  def list_providers():
45
- res = state.settings.api.file_system.list_providers()
46
- for provider in res.providers:
111
+ for provider in state.settings.api.file_system.list_providers():
47
112
  rich.print(provider)
@@ -0,0 +1,7 @@
1
+ from spiral.cli import AsyncTyper
2
+
3
+ from . import namespaces, tables
4
+
5
+ app = AsyncTyper(short_help="Apache Iceberg Catalog.")
6
+ app.add_typer(tables.app, name="tables")
7
+ app.add_typer(namespaces.app, name="namespaces")
@@ -0,0 +1,47 @@
1
+ import sys
2
+ from typing import Annotated
3
+
4
+ import pyiceberg.exceptions
5
+ import rich
6
+ import typer
7
+ from typer import Argument
8
+
9
+ from spiral.cli import AsyncTyper, state
10
+ from spiral.cli.types import ProjectArg
11
+
12
+ app = AsyncTyper(short_help="Apache Iceberg Namespaces.")
13
+
14
+
15
+ @app.command(help="List namespaces.")
16
+ def ls(
17
+ project: ProjectArg,
18
+ namespace: Annotated[str | None, Argument(help="List only namespaces under this namespace.")] = None,
19
+ ):
20
+ """List Iceberg namespaces."""
21
+ catalog = state.spiral.iceberg.catalog()
22
+
23
+ if namespace is None:
24
+ try:
25
+ namespaces = catalog.list_namespaces(project)
26
+ except pyiceberg.exceptions.ForbiddenError:
27
+ print(
28
+ f"The project, {repr(project)}, does not exist or you lack the "
29
+ f"`iceberg:view` permission to list namespaces in it.",
30
+ file=sys.stderr,
31
+ )
32
+ raise typer.Exit(code=1)
33
+ else:
34
+ try:
35
+ namespaces = catalog.list_namespaces((project, namespace))
36
+ except pyiceberg.exceptions.ForbiddenError:
37
+ print(
38
+ f"The namespace, {repr(project)}.{repr(namespace)}, does not exist or you lack the "
39
+ f"`iceberg:view` permission to list namespaces in it.",
40
+ file=sys.stderr,
41
+ )
42
+ raise typer.Exit(code=1)
43
+
44
+ table = rich.table.Table("Namespace ID", title="Iceberg namespaces")
45
+ for ns in namespaces:
46
+ table.add_row(".".join(ns))
47
+ rich.print(table)
@@ -0,0 +1,60 @@
1
+ import sys
2
+ from typing import Annotated
3
+
4
+ import pyiceberg.exceptions
5
+ import rich
6
+ import typer
7
+ from typer import Argument
8
+
9
+ from spiral.cli import AsyncTyper, state
10
+ from spiral.cli.types import ProjectArg
11
+
12
+ app = AsyncTyper(short_help="Apache Iceberg Tables.")
13
+
14
+
15
+ @app.command(help="List tables.")
16
+ def ls(
17
+ project: ProjectArg,
18
+ namespace: Annotated[str | None, Argument(help="Show only tables in the given namespace.")] = None,
19
+ ):
20
+ catalog = state.spiral.iceberg.catalog()
21
+
22
+ try:
23
+ if namespace is None:
24
+ tables = catalog.list_tables(project)
25
+ else:
26
+ tables = catalog.list_tables((project, namespace))
27
+ except pyiceberg.exceptions.ForbiddenError:
28
+ print(
29
+ f"The namespace, {repr(project)}.{repr(namespace)}, does not exist or you lack the "
30
+ f"`iceberg:view` permission to list tables in it.",
31
+ file=sys.stderr,
32
+ )
33
+ raise typer.Exit(code=1)
34
+
35
+ rich_table = rich.table.Table("table id", title="Iceberg tables")
36
+ for table in tables:
37
+ rich_table.add_row(".".join(table))
38
+ rich.print(rich_table)
39
+
40
+
41
+ @app.command(help="Show the table schema.")
42
+ def schema(
43
+ project: ProjectArg,
44
+ namespace: Annotated[str, Argument(help="Table namespace.")],
45
+ table: Annotated[str, Argument(help="Table name.")],
46
+ ):
47
+ catalog = state.spiral.iceberg.catalog()
48
+
49
+ try:
50
+ tbl = catalog.load_table((project, namespace, table))
51
+ except pyiceberg.exceptions.NoSuchTableError:
52
+ print(f"No table {repr(table)} found in {repr(project)}.{repr(namespace)}", file=sys.stderr)
53
+ raise typer.Exit(code=1)
54
+
55
+ rich_table = rich.table.Table(
56
+ "Field ID", "Field name", "Type", "Required", "Doc", title=f"{project}.{namespace}.{table}"
57
+ )
58
+ for col in tbl.schema().columns:
59
+ rich_table.add_row(str(col.field_id), col.name, str(col.field_type), str(col.required), col.doc)
60
+ rich.print(rich_table)
@@ -0,0 +1,19 @@
1
+ import rich
2
+
3
+ from spiral.cli import AsyncTyper, state
4
+ from spiral.cli.types import ProjectArg
5
+
6
+ app = AsyncTyper(short_help="Indexes.")
7
+
8
+
9
+ @app.command(help="List indexes.")
10
+ def ls(
11
+ project: ProjectArg,
12
+ ):
13
+ """List indexes."""
14
+ indexes = state.spiral.project(project).indexes.list_indexes()
15
+
16
+ rich_table = rich.table.Table("id", "name", title="Indexes")
17
+ for index in indexes:
18
+ rich_table.add_row(index.id, index.name)
19
+ rich.print(rich_table)
spiral/cli/login.py CHANGED
@@ -1,13 +1,22 @@
1
+ import jwt
1
2
  from rich import print
2
3
 
3
- from spiral.cli import OptionalStr, state
4
+ from spiral.cli import state
4
5
 
5
6
 
6
- def command(org_id: OptionalStr = None, force: bool = False, refresh: bool = False):
7
- tokens = state.settings.spiraldb.device_auth().authenticate(force=force, refresh=refresh, organization_id=org_id)
8
- print(tokens)
7
+ def command(org_id: str | None = None, force: bool = False):
8
+ token = state.settings.device_code_auth.authenticate(force=force, org_id=org_id)
9
+ print("Successfully logged in.")
10
+ print(token.expose_secret())
11
+
12
+
13
+ def whoami():
14
+ """Display the current user's information."""
15
+ payload = jwt.decode(state.settings.authn.token().expose_secret(), options={"verify_signature": False})
16
+ print(f"{payload['org_id']}")
17
+ print(f"{payload['sub']}")
9
18
 
10
19
 
11
20
  def logout():
12
- state.settings.spiraldb.device_auth().logout()
21
+ state.settings.device_code_auth.logout()
13
22
  print("Logged out.")
spiral/cli/orgs.py ADDED
@@ -0,0 +1,90 @@
1
+ import webbrowser
2
+ from typing import Annotated
3
+
4
+ import jwt
5
+ import rich
6
+ import typer
7
+ from rich.table import Table
8
+ from typer import Option
9
+
10
+ from spiral.api.organizations import CreateOrgRequest, InviteUserRequest, OrgRole, PortalLinkIntent, PortalLinkRequest
11
+ from spiral.cli import AsyncTyper, state
12
+ from spiral.cli.types import OrganizationArg
13
+
14
+ app = AsyncTyper(short_help="Org admin.")
15
+
16
+
17
+ @app.command(help="Switch the active organization.")
18
+ def switch(org_id: OrganizationArg):
19
+ state.settings.device_code_auth.authenticate(org_id=org_id)
20
+ rich.print(f"Switched to organization: {org_id}")
21
+
22
+
23
+ @app.command(help="Create a new organization.")
24
+ def create(
25
+ name: Annotated[str | None, Option(help="The human-readable name of the organization.")] = None,
26
+ ):
27
+ res = state.settings.api.organization.create(CreateOrgRequest(name=name))
28
+
29
+ # Authenticate to the new organization
30
+ state.settings.device_code_auth.authenticate(org_id=res.org.id)
31
+
32
+ rich.print(f"{res.org.name} [dim]{res.org.id}[/dim]")
33
+
34
+
35
+ @app.command(help="List organizations.")
36
+ def ls():
37
+ org_id = current_org_id()
38
+
39
+ table = Table("", "id", "name", "role", title="Organizations")
40
+ for m in state.settings.api.organization.list_memberships():
41
+ table.add_row("👉" if m.org.id == org_id else "", m.org.id, m.org.name, m.role)
42
+
43
+ rich.print(table)
44
+
45
+
46
+ @app.command(help="Invite a user to the organization.")
47
+ def invite(email: str, role: OrgRole = OrgRole.MEMBER, expires_in_days: int = 7):
48
+ state.settings.api.organization.invite_user(
49
+ InviteUserRequest(email=email, role=role, expires_in_days=expires_in_days)
50
+ )
51
+ rich.print(f"Invited {email} as a {role.value}.")
52
+
53
+
54
+ @app.command(help="Configure single sign-on for your organization.")
55
+ def sso():
56
+ _do_action(PortalLinkIntent.SSO)
57
+
58
+
59
+ @app.command(help="Configure directory services for your organization.")
60
+ def directory():
61
+ _do_action(PortalLinkIntent.DIRECTORY_SYNC)
62
+
63
+
64
+ @app.command(help="Configure audit logs for your organization.")
65
+ def audit_logs():
66
+ _do_action(PortalLinkIntent.AUDIT_LOGS)
67
+
68
+
69
+ @app.command(help="Configure log streams for your organization.")
70
+ def log_streams():
71
+ _do_action(PortalLinkIntent.LOG_STREAMS)
72
+
73
+
74
+ @app.command(help="Configure domains for your organization.")
75
+ def domains():
76
+ _do_action(PortalLinkIntent.DOMAIN_VERIFICATION)
77
+
78
+
79
+ def _do_action(intent: PortalLinkIntent):
80
+ res = state.settings.api.organization.portal_link(PortalLinkRequest(intent=intent))
81
+ rich.print(f"Opening the configuration portal:\n{res.url}")
82
+ webbrowser.open(res.url)
83
+
84
+
85
+ def current_org_id():
86
+ if token := state.settings.authn.token():
87
+ if org_id := jwt.decode(token.expose_secret(), options={"verify_signature": False}).get("org_id"):
88
+ return org_id
89
+ rich.print("[red]You are not logged in to an organization.[/red]")
90
+ raise typer.Exit(1)
spiral/cli/printer.py CHANGED
@@ -1,3 +1,4 @@
1
+ import json
1
2
  from collections.abc import Iterable
2
3
  from typing import TypeVar
3
4
 
@@ -17,10 +18,17 @@ def table_of_models(cls: type[M], messages: Iterable[M], fields: list[str] = Non
17
18
  cols = fields or cls.model_fields.keys()
18
19
  table = Table(*cols, title=title or f"{cls.__name__}s")
19
20
  for msg in messages:
20
- table.add_row(*[getattr(msg, col, "") for col in cols])
21
+ table.add_row(*[_renderable(msg, col) for col in cols])
21
22
  return table
22
23
 
23
24
 
25
+ def _renderable(msg, col):
26
+ attr = getattr(msg, col, "")
27
+ if isinstance(attr, dict):
28
+ return json.dumps(attr)
29
+ return attr
30
+
31
+
24
32
  def table_of_protos(cls: type[T], messages: Iterable[T], fields: list[str] = None, title: str = None) -> Table:
25
33
  """Centralized logic for printing tables of proto messages.
26
34
 
spiral/cli/projects.py ADDED
@@ -0,0 +1,136 @@
1
+ from typing import Annotated
2
+
3
+ import rich
4
+ import typer
5
+ from typer import Option
6
+
7
+ from spiral.api.organizations import OrgRole
8
+ from spiral.api.projects import (
9
+ CreateProjectRequest,
10
+ GCPPrincipalConditions,
11
+ GitHubConditions,
12
+ GitHubPrincipalConditions,
13
+ Grant,
14
+ GrantRoleRequest,
15
+ ModalConditions,
16
+ ModalPrincipalConditions,
17
+ OrgRolePrincipalConditions,
18
+ OrgUserPrincipalConditions,
19
+ Project,
20
+ WorkloadPrincipalConditions,
21
+ )
22
+ from spiral.cli import AsyncTyper, printer, state
23
+ from spiral.cli.types import ProjectArg
24
+
25
+ app = AsyncTyper(short_help="Projects and grants.")
26
+
27
+
28
+ @app.command(help="List projects.")
29
+ def ls():
30
+ projects = list(state.settings.api.project.list())
31
+ rich.print(printer.table_of_models(Project, projects))
32
+
33
+
34
+ @app.command(help="Create a new project.")
35
+ def create(
36
+ id_prefix: Annotated[
37
+ str | None, Option(help="An optional ID prefix to which a random number will be appended.")
38
+ ] = None,
39
+ name: Annotated[str | None, Option(help="Friendly name for the project.")] = None,
40
+ ):
41
+ res = state.settings.api.project.create(CreateProjectRequest(id_prefix=id_prefix, name=name))
42
+ rich.print(f"Created project {res.project.id}")
43
+
44
+
45
+ @app.command(help="Grant a role on a project.")
46
+ def grant(
47
+ project: ProjectArg,
48
+ role: Annotated[str, Option(help="Role to grant.")],
49
+ org_id: Annotated[
50
+ str | None, Option(help="Pass an organization ID to grant a role to an organization user(s).")
51
+ ] = None,
52
+ user_id: Annotated[
53
+ str | None, Option(help="Pass a user ID when using --org-id to grant a role to grant a role to a user.")
54
+ ] = None,
55
+ org_role: Annotated[
56
+ str | None,
57
+ Option(help="Pass an organization role when using --org-id to grant a role to all users with that role."),
58
+ ] = None,
59
+ workload_id: Annotated[str | None, Option(help="Pass a workload ID to grant a role to a workload.")] = None,
60
+ github: Annotated[
61
+ str | None, Option(help="Pass an `<org>/<repo>` string to grant a role to a job running in GitHub Actions.")
62
+ ] = None,
63
+ modal: Annotated[
64
+ str | None,
65
+ Option(help="Pass a `<workspace_id>/<env_name>` string to grant a role to a job running in Modal environment."),
66
+ ] = None,
67
+ gcp: Annotated[
68
+ str | None,
69
+ Option(help="Pass a `<service_account_email>/<unique_id>` to grant a role to a GCP service account."),
70
+ ] = None,
71
+ conditions: list[str] | None = Option(
72
+ default=None,
73
+ help="`<key>=<value>` token conditions to apply to the grant when using --github or --modal.",
74
+ ),
75
+ ):
76
+ # Check mutual exclusion
77
+ if sum(int(bool(opt)) for opt in {org_id, workload_id, github, modal, gcp}) != 1:
78
+ raise typer.BadParameter("Only one of --org-id, --github or --modal may be specified.")
79
+
80
+ if github:
81
+ org, repo = github.split("/", 1)
82
+ github_conditions = None
83
+ if conditions is not None:
84
+ github_conditions = GitHubConditions()
85
+ for k, v in dict(c.split("=", 1) for c in conditions).items():
86
+ github_conditions = github_conditions.model_copy(update={k: v})
87
+ principal = GitHubPrincipalConditions(org=org, repo=repo, conditions=github_conditions)
88
+
89
+ elif modal:
90
+ workspace_id, environment_name = modal.split("/", 1)
91
+ modal_conditions = None
92
+ if conditions is not None:
93
+ modal_conditions = ModalConditions()
94
+ for k, v in dict(c.split("=", 1) for c in conditions).items():
95
+ modal_conditions = modal_conditions.model_copy(update={k: v})
96
+ principal = ModalPrincipalConditions(
97
+ workspace_id=workspace_id, environment_name=environment_name, conditions=modal_conditions
98
+ )
99
+
100
+ elif org_id:
101
+ # Check mutual exclusion
102
+ if sum(int(bool(opt)) for opt in {user_id, org_role}) != 1:
103
+ raise typer.BadParameter("Only one of --user-id or --org-role may be specified.")
104
+
105
+ if user_id is not None:
106
+ principal = OrgUserPrincipalConditions(org_id=org_id, user_id=user_id)
107
+ elif org_role is not None:
108
+ principal = OrgRolePrincipalConditions(org_id=org_id, role=OrgRole(org_role))
109
+ else:
110
+ raise NotImplementedError("Only user or role principal is supported at this time.")
111
+
112
+ elif workload_id:
113
+ principal = WorkloadPrincipalConditions(workload_id=workload_id)
114
+
115
+ elif gcp:
116
+ service_account, unique_id = gcp.split("/", 1)
117
+ principal = GCPPrincipalConditions(service_account=service_account, unique_id=unique_id)
118
+
119
+ else:
120
+ raise ValueError("Invalid grant principal")
121
+
122
+ state.settings.api.project.grant_role(
123
+ project,
124
+ GrantRoleRequest(
125
+ role_id=role,
126
+ principal=principal,
127
+ ),
128
+ )
129
+
130
+ rich.print(f"Granted role {role} on project {project}")
131
+
132
+
133
+ @app.command(help="List project grants.")
134
+ def grants(project: ProjectArg):
135
+ project_grants = list(state.settings.api.project.list_grants(project))
136
+ rich.print(printer.table_of_models(Grant, project_grants, title="Project Grants"))
spiral/cli/state.py CHANGED
@@ -1,3 +1,5 @@
1
+ from spiral import Spiral
1
2
  from spiral.settings import Settings
2
3
 
3
4
  settings: Settings = Settings()
5
+ spiral: Spiral = Spiral(settings)
@@ -0,0 +1,121 @@
1
+ from typing import Annotated
2
+
3
+ import questionary
4
+ import rich
5
+ import typer
6
+ from questionary import Choice
7
+ from typer import Argument, Option
8
+
9
+ from spiral import Spiral
10
+ from spiral.cli import AsyncTyper, state
11
+ from spiral.cli.types import ProjectArg
12
+ from spiral.tables import Table
13
+
14
+ app = AsyncTyper(short_help="Spiral Tables.")
15
+
16
+
17
+ def ask_table(project_id, title="Select a table"):
18
+ tables = list(state.spiral.project(project_id).tables.list_tables())
19
+
20
+ if not tables:
21
+ rich.print("[red]No tables found[/red]")
22
+ raise typer.Exit(1)
23
+
24
+ return questionary.select(
25
+ title,
26
+ choices=[
27
+ Choice(title=f"{table.dataset}.{table.table}", value=f"{table.project_id}.{table.dataset}.{table.table}")
28
+ for table in tables
29
+ ],
30
+ ).ask()
31
+
32
+
33
+ @app.command(help="List tables.")
34
+ def ls(
35
+ project: ProjectArg,
36
+ ):
37
+ tables = Spiral().project(project).tables.list_tables()
38
+
39
+ rich_table = rich.table.Table("id", "dataset", "name", title="Spiral tables")
40
+ for table in tables:
41
+ rich_table.add_row(table.id, table.dataset, table.table)
42
+ rich.print(rich_table)
43
+
44
+
45
+ @app.command(help="Show the table key schema.")
46
+ def key_schema(
47
+ project: ProjectArg,
48
+ table: Annotated[str | None, Option(help="Table name.")] = None,
49
+ dataset: Annotated[str | None, Option(help="Dataset name.")] = None,
50
+ ):
51
+ _, table = _get_table(project, table, dataset)
52
+ rich.print(table.key_schema)
53
+
54
+
55
+ @app.command(help="Compute the full table schema.")
56
+ def schema(
57
+ project: ProjectArg,
58
+ table: Annotated[str | None, Option(help="Table name.")] = None,
59
+ dataset: Annotated[str | None, Option(help="Dataset name.")] = None,
60
+ ):
61
+ _, table = _get_table(project, table, dataset)
62
+ rich.print(table.schema)
63
+
64
+
65
+ @app.command(help="Flush Write-Ahead-Log.")
66
+ def flush(
67
+ project: ProjectArg,
68
+ table: Annotated[str | None, Option(help="Table name.")] = None,
69
+ dataset: Annotated[str | None, Option(help="Dataset name.")] = None,
70
+ ):
71
+ identifier, table = _get_table(project, table, dataset)
72
+ table.maintenance().flush_wal()
73
+ print(f"Flushed WAL for table {identifier} in project {project}.")
74
+
75
+
76
+ @app.command(help="Display scan.")
77
+ def debug(
78
+ project: ProjectArg,
79
+ table: Annotated[str | None, Option(help="Table name.")] = None,
80
+ dataset: Annotated[str | None, Option(help="Dataset name.")] = None,
81
+ column_group: Annotated[str, Argument(help="Dot-separated column group path.")] = ".",
82
+ ):
83
+ _, table = _get_table(project, table, dataset)
84
+ if column_group != ".":
85
+ projection = table[column_group]
86
+ else:
87
+ projection = table
88
+ scan = table.scan(projection)
89
+
90
+ scan._debug()
91
+
92
+
93
+ @app.command(help="Display manifests.")
94
+ def manifests(
95
+ project: ProjectArg,
96
+ table: Annotated[str | None, Option(help="Table name.")] = None,
97
+ dataset: Annotated[str | None, Option(help="Dataset name.")] = None,
98
+ column_group: Annotated[str, Argument(help="Dot-separated column group path.")] = ".",
99
+ ):
100
+ _, table = _get_table(project, table, dataset)
101
+ if column_group != ".":
102
+ projection = table[column_group]
103
+ else:
104
+ projection = table
105
+ scan = projection.scan()
106
+
107
+ scan._dump_manifests()
108
+
109
+
110
+ def _get_table(
111
+ project: ProjectArg,
112
+ table: Annotated[str | None, Option(help="Table name.")] = None,
113
+ dataset: Annotated[str | None, Option(help="Dataset name.")] = None,
114
+ ) -> (str, Table):
115
+ if table is None:
116
+ identifier = ask_table(project)
117
+ else:
118
+ identifier = table
119
+ if dataset is not None:
120
+ identifier = f"{dataset}.{table}"
121
+ return identifier, state.spiral.project(project).tables.table(identifier)