pyspiral 0.4.0__pp310-pypy310_pp73-macosx_10_12_x86_64.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.4.0.dist-info/METADATA +46 -0
- pyspiral-0.4.0.dist-info/RECORD +98 -0
- pyspiral-0.4.0.dist-info/WHEEL +4 -0
- pyspiral-0.4.0.dist-info/entry_points.txt +2 -0
- spiral/__init__.py +10 -0
- spiral/_lib.pypy310-pp73-darwin.so +0 -0
- spiral/adbc.py +393 -0
- spiral/api/__init__.py +64 -0
- spiral/api/admin.py +15 -0
- spiral/api/client.py +160 -0
- spiral/api/filesystems.py +153 -0
- spiral/api/organizations.py +77 -0
- spiral/api/projects.py +197 -0
- spiral/api/telemetry.py +19 -0
- spiral/api/types.py +20 -0
- spiral/api/workloads.py +52 -0
- spiral/arrow_.py +221 -0
- spiral/cli/__init__.py +79 -0
- spiral/cli/__main__.py +4 -0
- spiral/cli/admin.py +16 -0
- spiral/cli/app.py +65 -0
- spiral/cli/console.py +95 -0
- spiral/cli/fs.py +112 -0
- spiral/cli/iceberg/__init__.py +7 -0
- spiral/cli/iceberg/namespaces.py +47 -0
- spiral/cli/iceberg/tables.py +60 -0
- spiral/cli/indexes/__init__.py +19 -0
- spiral/cli/login.py +22 -0
- spiral/cli/orgs.py +90 -0
- spiral/cli/printer.py +53 -0
- spiral/cli/projects.py +136 -0
- spiral/cli/state.py +5 -0
- spiral/cli/tables/__init__.py +121 -0
- spiral/cli/telemetry.py +18 -0
- spiral/cli/types.py +51 -0
- spiral/cli/workloads.py +59 -0
- spiral/client.py +79 -0
- spiral/core/__init__.pyi +0 -0
- spiral/core/client/__init__.pyi +117 -0
- spiral/core/index/__init__.pyi +15 -0
- spiral/core/table/__init__.pyi +108 -0
- spiral/core/table/manifests/__init__.pyi +35 -0
- spiral/core/table/metastore/__init__.pyi +62 -0
- spiral/core/table/spec/__init__.pyi +214 -0
- spiral/datetime_.py +27 -0
- spiral/expressions/__init__.py +245 -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/mp4.py +62 -0
- spiral/expressions/png.py +18 -0
- spiral/expressions/qoi.py +18 -0
- spiral/expressions/refs.py +58 -0
- spiral/expressions/str_.py +39 -0
- spiral/expressions/struct.py +59 -0
- spiral/expressions/text.py +62 -0
- spiral/expressions/tiff.py +223 -0
- spiral/expressions/udf.py +46 -0
- spiral/grpc_.py +32 -0
- spiral/iceberg/__init__.py +3 -0
- spiral/iceberg/client.py +33 -0
- spiral/indexes/__init__.py +5 -0
- spiral/indexes/client.py +137 -0
- spiral/indexes/index.py +34 -0
- spiral/indexes/scan.py +22 -0
- spiral/project.py +46 -0
- spiral/protogen/_/__init__.py +0 -0
- spiral/protogen/_/arrow/__init__.py +0 -0
- spiral/protogen/_/arrow/flight/__init__.py +0 -0
- spiral/protogen/_/arrow/flight/protocol/__init__.py +0 -0
- spiral/protogen/_/arrow/flight/protocol/sql/__init__.py +1990 -0
- spiral/protogen/_/scandal/__init__.py +178 -0
- spiral/protogen/_/spiral/__init__.py +0 -0
- spiral/protogen/_/spiral/table/__init__.py +22 -0
- spiral/protogen/_/substrait/__init__.py +3399 -0
- spiral/protogen/_/substrait/extensions/__init__.py +115 -0
- spiral/protogen/__init__.py +0 -0
- spiral/protogen/substrait/__init__.py +3399 -0
- spiral/protogen/substrait/extensions/__init__.py +115 -0
- spiral/protogen/util.py +41 -0
- spiral/py.typed +0 -0
- spiral/server.py +17 -0
- spiral/settings.py +101 -0
- spiral/substrait_.py +279 -0
- spiral/tables/__init__.py +12 -0
- spiral/tables/client.py +130 -0
- spiral/tables/dataset.py +250 -0
- spiral/tables/debug/__init__.py +0 -0
- spiral/tables/debug/manifests.py +70 -0
- spiral/tables/debug/metrics.py +56 -0
- spiral/tables/debug/scan.py +248 -0
- spiral/tables/maintenance.py +12 -0
- spiral/tables/scan.py +193 -0
- spiral/tables/snapshot.py +78 -0
- spiral/tables/table.py +157 -0
- spiral/tables/transaction.py +52 -0
- spiral/types_.py +6 -0
@@ -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
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
import jwt
|
2
|
+
from rich import print
|
3
|
+
|
4
|
+
from spiral.cli import state
|
5
|
+
|
6
|
+
|
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']}")
|
18
|
+
|
19
|
+
|
20
|
+
def logout():
|
21
|
+
state.settings.device_code_auth.logout()
|
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
ADDED
@@ -0,0 +1,53 @@
|
|
1
|
+
import json
|
2
|
+
from collections.abc import Iterable
|
3
|
+
from typing import TypeVar
|
4
|
+
|
5
|
+
import betterproto
|
6
|
+
from pydantic import BaseModel
|
7
|
+
from rich.console import ConsoleRenderable, Group
|
8
|
+
from rich.padding import Padding
|
9
|
+
from rich.pretty import Pretty
|
10
|
+
from rich.table import Table
|
11
|
+
|
12
|
+
T = TypeVar("T", bound=betterproto.Message)
|
13
|
+
M = TypeVar("M", bound=BaseModel)
|
14
|
+
|
15
|
+
|
16
|
+
def table_of_models(cls: type[M], messages: Iterable[M], fields: list[str] = None, title: str = None) -> Table:
|
17
|
+
"""Centralized logic for printing tables of Pydantic models."""
|
18
|
+
cols = fields or cls.model_fields.keys()
|
19
|
+
table = Table(*cols, title=title or f"{cls.__name__}s")
|
20
|
+
for msg in messages:
|
21
|
+
table.add_row(*[_renderable(msg, col) for col in cols])
|
22
|
+
return table
|
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
|
+
|
32
|
+
def table_of_protos(cls: type[T], messages: Iterable[T], fields: list[str] = None, title: str = None) -> Table:
|
33
|
+
"""Centralized logic for printing tables of proto messages.
|
34
|
+
|
35
|
+
TODO(ngates): add a CLI switch to emit JSON results instead of tables.
|
36
|
+
"""
|
37
|
+
cols = fields or cls()._betterproto.sorted_field_names
|
38
|
+
table = Table(*cols, title=title or f"{cls.__name__}s")
|
39
|
+
for msg in messages:
|
40
|
+
table.add_row(*[getattr(msg, col, "") for col in cols])
|
41
|
+
return table
|
42
|
+
|
43
|
+
|
44
|
+
def proto(message: T, title: str = None, fields: list[str] = None) -> ConsoleRenderable:
|
45
|
+
"""Centralized logic for printing a single proto message."""
|
46
|
+
value = Pretty({k: v for k, v in message.to_dict().items() if not fields or k in fields})
|
47
|
+
if title:
|
48
|
+
return Group(
|
49
|
+
f"[bold]{title}[/bold]",
|
50
|
+
Padding.indent(value, level=2),
|
51
|
+
)
|
52
|
+
else:
|
53
|
+
return value
|
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
ADDED
@@ -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)
|
spiral/cli/telemetry.py
ADDED
@@ -0,0 +1,18 @@
|
|
1
|
+
import pyperclip
|
2
|
+
import rich
|
3
|
+
|
4
|
+
from spiral.api.telemetry import IssueExportTokenResponse
|
5
|
+
from spiral.cli import AsyncTyper, state
|
6
|
+
|
7
|
+
app = AsyncTyper(short_help="Client-side telemetry.")
|
8
|
+
|
9
|
+
|
10
|
+
@app.command(help="Issue new telemetry export token.")
|
11
|
+
def export():
|
12
|
+
res: IssueExportTokenResponse = state.settings.api.telemetry.issue_export_token()
|
13
|
+
|
14
|
+
command = f"export SPIRAL_OTEL_TOKEN={res.token}"
|
15
|
+
pyperclip.copy(command)
|
16
|
+
|
17
|
+
rich.print("Export command copied to clipboard! Paste and run to set [green]SPIRAL_OTEL_TOKEN[/green].")
|
18
|
+
rich.print("[dim]Token is valid for 1h.[/dim]")
|
spiral/cli/types.py
ADDED
@@ -0,0 +1,51 @@
|
|
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
|
8
|
+
|
9
|
+
from spiral.api.types import OrgId, ProjectId
|
10
|
+
from spiral.cli import state
|
11
|
+
|
12
|
+
|
13
|
+
def ask_project(title="Select a project"):
|
14
|
+
projects = list(state.settings.api.project.list())
|
15
|
+
|
16
|
+
if not projects:
|
17
|
+
rich.print("[red]No projects found[/red]")
|
18
|
+
raise typer.Exit(1)
|
19
|
+
|
20
|
+
return questionary.select(
|
21
|
+
title,
|
22
|
+
choices=[
|
23
|
+
Choice(title=f"{project.id} - {project.name}" if project.name else project.id, value=project.id)
|
24
|
+
for project in projects
|
25
|
+
],
|
26
|
+
).ask()
|
27
|
+
|
28
|
+
|
29
|
+
ProjectArg = Annotated[ProjectId, Argument(help="Project ID", show_default=False, default_factory=ask_project)]
|
30
|
+
|
31
|
+
|
32
|
+
def _org_default():
|
33
|
+
memberships = list(state.settings.api.organization.list_memberships())
|
34
|
+
|
35
|
+
if not memberships:
|
36
|
+
rich.print("[red]No organizations found[/red]")
|
37
|
+
raise typer.Exit(1)
|
38
|
+
|
39
|
+
return questionary.select(
|
40
|
+
"Select an organization",
|
41
|
+
choices=[
|
42
|
+
Choice(
|
43
|
+
title=f"{m.org.id} - {m.org.name}" if m.org.name else m.org.id,
|
44
|
+
value=m.org.id,
|
45
|
+
)
|
46
|
+
for m in memberships
|
47
|
+
],
|
48
|
+
).ask()
|
49
|
+
|
50
|
+
|
51
|
+
OrganizationArg = Annotated[OrgId, Argument(help="Organization ID", show_default=False, default_factory=_org_default)]
|
spiral/cli/workloads.py
ADDED
@@ -0,0 +1,59 @@
|
|
1
|
+
from typing import Annotated
|
2
|
+
|
3
|
+
import pyperclip
|
4
|
+
import questionary
|
5
|
+
import rich
|
6
|
+
from questionary import Choice
|
7
|
+
from typer import Argument, Option
|
8
|
+
|
9
|
+
from spiral.api.workloads import CreateWorkloadRequest, IssueWorkloadCredentialsResponse, Workload
|
10
|
+
from spiral.cli import AsyncTyper, printer, state
|
11
|
+
from spiral.cli.types import ProjectArg
|
12
|
+
|
13
|
+
app = AsyncTyper()
|
14
|
+
|
15
|
+
|
16
|
+
@app.command(help="Create a new workload.")
|
17
|
+
def create(
|
18
|
+
project: ProjectArg,
|
19
|
+
name: Annotated[str | None, Option(help="Friendly name for the workload.")] = None,
|
20
|
+
):
|
21
|
+
res = state.settings.api.workload.create(project, CreateWorkloadRequest(name=name))
|
22
|
+
rich.print(f"Created workload {res.workload.id}")
|
23
|
+
|
24
|
+
|
25
|
+
@app.command(help="List workloads.")
|
26
|
+
def ls(
|
27
|
+
project: ProjectArg,
|
28
|
+
):
|
29
|
+
workloads = list(state.settings.api.workload.list(project))
|
30
|
+
rich.print(printer.table_of_models(Workload, workloads, fields=["id", "project_id", "name"]))
|
31
|
+
|
32
|
+
|
33
|
+
@app.command(help="Issue new workflow credentials.")
|
34
|
+
def issue_credentials(workload_id: Annotated[str, Argument(help="Workload ID.")]):
|
35
|
+
res: IssueWorkloadCredentialsResponse = state.settings.api.workload.issue_credentials(workload_id)
|
36
|
+
|
37
|
+
while True:
|
38
|
+
choice = questionary.select(
|
39
|
+
"What would you like to do with the secret? You will not be able to see this secret again!",
|
40
|
+
choices=[
|
41
|
+
Choice(title="Copy to clipboard", value=1),
|
42
|
+
Choice(title="Print to console", value=2),
|
43
|
+
Choice(title="Exit", value=3),
|
44
|
+
],
|
45
|
+
).ask()
|
46
|
+
|
47
|
+
if choice == 1:
|
48
|
+
pyperclip.copy(res.client_secret)
|
49
|
+
rich.print("[green]Secret copied to clipboard![/green]")
|
50
|
+
break
|
51
|
+
elif choice == 2:
|
52
|
+
rich.print(f"[green]Token Secret:[/green] {res.client_secret}")
|
53
|
+
break
|
54
|
+
elif choice == 3:
|
55
|
+
break
|
56
|
+
else:
|
57
|
+
rich.print("[red]Invalid choice. Please try again.[/red]")
|
58
|
+
|
59
|
+
rich.print(f"[green]Token ID:[/green] {res.client_id}")
|