pyspiral 0.6.8__cp312-abi3-manylinux_2_28_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.6.8.dist-info/METADATA +51 -0
- pyspiral-0.6.8.dist-info/RECORD +102 -0
- pyspiral-0.6.8.dist-info/WHEEL +4 -0
- pyspiral-0.6.8.dist-info/entry_points.txt +2 -0
- spiral/__init__.py +35 -0
- spiral/_lib.abi3.so +0 -0
- spiral/adbc.py +411 -0
- spiral/api/__init__.py +78 -0
- spiral/api/admin.py +15 -0
- spiral/api/client.py +164 -0
- spiral/api/filesystems.py +134 -0
- spiral/api/key_space_indexes.py +23 -0
- spiral/api/organizations.py +77 -0
- spiral/api/projects.py +219 -0
- spiral/api/telemetry.py +19 -0
- spiral/api/text_indexes.py +56 -0
- spiral/api/types.py +22 -0
- spiral/api/workers.py +40 -0
- spiral/api/workloads.py +52 -0
- spiral/arrow_.py +216 -0
- spiral/cli/__init__.py +88 -0
- spiral/cli/__main__.py +4 -0
- spiral/cli/admin.py +14 -0
- spiral/cli/app.py +104 -0
- spiral/cli/console.py +95 -0
- spiral/cli/fs.py +76 -0
- spiral/cli/iceberg.py +97 -0
- spiral/cli/key_spaces.py +89 -0
- spiral/cli/login.py +24 -0
- spiral/cli/orgs.py +89 -0
- spiral/cli/printer.py +53 -0
- spiral/cli/projects.py +147 -0
- spiral/cli/state.py +5 -0
- spiral/cli/tables.py +174 -0
- spiral/cli/telemetry.py +17 -0
- spiral/cli/text.py +115 -0
- spiral/cli/types.py +50 -0
- spiral/cli/workloads.py +58 -0
- spiral/client.py +178 -0
- spiral/core/__init__.pyi +0 -0
- spiral/core/_tools/__init__.pyi +5 -0
- spiral/core/authn/__init__.pyi +27 -0
- spiral/core/client/__init__.pyi +237 -0
- spiral/core/table/__init__.pyi +101 -0
- spiral/core/table/manifests/__init__.pyi +35 -0
- spiral/core/table/metastore/__init__.pyi +58 -0
- spiral/core/table/spec/__init__.pyi +213 -0
- spiral/dataloader.py +285 -0
- spiral/dataset.py +255 -0
- spiral/datetime_.py +27 -0
- spiral/debug/__init__.py +0 -0
- spiral/debug/manifests.py +87 -0
- spiral/debug/metrics.py +56 -0
- spiral/debug/scan.py +266 -0
- spiral/expressions/__init__.py +276 -0
- spiral/expressions/base.py +157 -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.py +31 -0
- spiral/iterable_dataset.py +106 -0
- spiral/key_space_index.py +44 -0
- spiral/project.py +199 -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 +2548 -0
- spiral/protogen/_/google/__init__.py +0 -0
- spiral/protogen/_/google/protobuf/__init__.py +2310 -0
- spiral/protogen/_/message_pool.py +3 -0
- spiral/protogen/_/py.typed +0 -0
- spiral/protogen/_/scandal/__init__.py +190 -0
- spiral/protogen/_/spfs/__init__.py +72 -0
- spiral/protogen/_/spql/__init__.py +61 -0
- spiral/protogen/_/substrait/__init__.py +6196 -0
- spiral/protogen/_/substrait/extensions/__init__.py +169 -0
- spiral/protogen/__init__.py +0 -0
- spiral/protogen/util.py +41 -0
- spiral/py.typed +0 -0
- spiral/scan.py +285 -0
- spiral/server.py +17 -0
- spiral/settings.py +114 -0
- spiral/snapshot.py +56 -0
- spiral/streaming_/__init__.py +3 -0
- spiral/streaming_/reader.py +133 -0
- spiral/streaming_/stream.py +157 -0
- spiral/substrait_.py +274 -0
- spiral/table.py +293 -0
- spiral/text_index.py +17 -0
- spiral/transaction.py +58 -0
- spiral/types_.py +6 -0
spiral/cli/__init__.py
ADDED
@@ -0,0 +1,88 @@
|
|
1
|
+
import asyncio
|
2
|
+
import functools
|
3
|
+
import inspect
|
4
|
+
from collections.abc import Callable
|
5
|
+
from typing import IO, Generic, ParamSpec, TypeVar, override
|
6
|
+
|
7
|
+
import typer
|
8
|
+
from click import ClickException
|
9
|
+
from grpclib import GRPCError
|
10
|
+
from httpx import HTTPStatusError
|
11
|
+
from rich.console import Console
|
12
|
+
|
13
|
+
P = ParamSpec("P")
|
14
|
+
T = TypeVar("T")
|
15
|
+
|
16
|
+
CONSOLE = Console()
|
17
|
+
ERR_CONSOLE = Console(stderr=True, style="red")
|
18
|
+
|
19
|
+
|
20
|
+
class AsyncTyper(typer.Typer, Generic[P]):
|
21
|
+
"""Wrapper to allow async functions to be used as commands.
|
22
|
+
|
23
|
+
We also pre-bake some configuration.
|
24
|
+
|
25
|
+
Per https://github.com/tiangolo/typer/issues/88#issuecomment-1732469681
|
26
|
+
"""
|
27
|
+
|
28
|
+
def __init__(self, **kwargs):
|
29
|
+
super().__init__(
|
30
|
+
no_args_is_help=True,
|
31
|
+
pretty_exceptions_enable=False,
|
32
|
+
**kwargs,
|
33
|
+
)
|
34
|
+
|
35
|
+
@override
|
36
|
+
def callback(self, *args, **kwargs) -> Callable[[Callable[P, T]], Callable[P, T]]:
|
37
|
+
decorator = super().callback(*args, **kwargs)
|
38
|
+
for wrapper in (_wrap_exceptions, _maybe_run_async):
|
39
|
+
decorator = functools.partial(wrapper, decorator)
|
40
|
+
return decorator
|
41
|
+
|
42
|
+
@override
|
43
|
+
def command(self, *args, **kwargs) -> Callable[[Callable[P, T]], Callable[P, T]]:
|
44
|
+
decorator = super().command(*args, **kwargs)
|
45
|
+
for wrapper in (_wrap_exceptions, _maybe_run_async):
|
46
|
+
decorator = functools.partial(wrapper, decorator)
|
47
|
+
return decorator
|
48
|
+
|
49
|
+
|
50
|
+
class _ClickGRPCException(ClickException):
|
51
|
+
def __init__(self, err: GRPCError):
|
52
|
+
super().__init__(err.message or "GRPCError message was None.")
|
53
|
+
self.err = err
|
54
|
+
self.exit_code = 1
|
55
|
+
|
56
|
+
def format_message(self) -> str:
|
57
|
+
if self.err.details:
|
58
|
+
return f"{self.message}: {self.err.details}"
|
59
|
+
return self.message
|
60
|
+
|
61
|
+
def show(self, file: IO[str] | None = None) -> None:
|
62
|
+
ERR_CONSOLE.print(f"Error: {self.format_message()}")
|
63
|
+
|
64
|
+
|
65
|
+
def _maybe_run_async(decorator, f):
|
66
|
+
if inspect.iscoroutinefunction(f):
|
67
|
+
|
68
|
+
@functools.wraps(f)
|
69
|
+
def runner(*args, **kwargs):
|
70
|
+
return asyncio.run(f(*args, **kwargs))
|
71
|
+
|
72
|
+
decorator(runner)
|
73
|
+
else:
|
74
|
+
decorator(f)
|
75
|
+
return f
|
76
|
+
|
77
|
+
|
78
|
+
def _wrap_exceptions(decorator, f):
|
79
|
+
@functools.wraps(f)
|
80
|
+
def runner(*args, **kwargs):
|
81
|
+
try:
|
82
|
+
return f(*args, **kwargs)
|
83
|
+
except HTTPStatusError as e:
|
84
|
+
raise ClickException(str(e))
|
85
|
+
except GRPCError as e:
|
86
|
+
raise _ClickGRPCException(e)
|
87
|
+
|
88
|
+
return decorator(runner)
|
spiral/cli/__main__.py
ADDED
spiral/cli/admin.py
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
from spiral.api.types import OrgId
|
2
|
+
from spiral.cli import CONSOLE, AsyncTyper, state
|
3
|
+
|
4
|
+
app = AsyncTyper()
|
5
|
+
|
6
|
+
|
7
|
+
@app.command()
|
8
|
+
def sync(
|
9
|
+
org_id: OrgId | None = None,
|
10
|
+
):
|
11
|
+
state.settings.api._admin.sync_orgs()
|
12
|
+
|
13
|
+
for membership in state.settings.api._admin.sync_memberships(org_id):
|
14
|
+
CONSOLE.print(membership)
|
spiral/cli/app.py
ADDED
@@ -0,0 +1,104 @@
|
|
1
|
+
import logging
|
2
|
+
import os
|
3
|
+
from importlib import metadata
|
4
|
+
from logging.handlers import RotatingFileHandler
|
5
|
+
from typing import Annotated
|
6
|
+
|
7
|
+
import typer
|
8
|
+
|
9
|
+
from spiral.cli import (
|
10
|
+
AsyncTyper,
|
11
|
+
admin,
|
12
|
+
console,
|
13
|
+
fs,
|
14
|
+
iceberg,
|
15
|
+
key_spaces,
|
16
|
+
login,
|
17
|
+
orgs,
|
18
|
+
projects,
|
19
|
+
state,
|
20
|
+
tables,
|
21
|
+
telemetry,
|
22
|
+
text,
|
23
|
+
workloads,
|
24
|
+
)
|
25
|
+
from spiral.settings import LOG_DIR, PACKAGE_NAME, Settings
|
26
|
+
|
27
|
+
app = AsyncTyper(name="spiral")
|
28
|
+
|
29
|
+
|
30
|
+
def version_callback(ctx: typer.Context, value: bool):
|
31
|
+
"""
|
32
|
+
Display the version of the Spiral CLI.
|
33
|
+
"""
|
34
|
+
# True when generating completion, we can just return
|
35
|
+
if ctx.resilient_parsing:
|
36
|
+
return
|
37
|
+
|
38
|
+
if value:
|
39
|
+
ver = metadata.version(PACKAGE_NAME)
|
40
|
+
print(f"spiral {ver}")
|
41
|
+
raise typer.Exit()
|
42
|
+
|
43
|
+
|
44
|
+
def verbose_callback(ctx: typer.Context, value: bool):
|
45
|
+
"""
|
46
|
+
Use more verbose output.
|
47
|
+
"""
|
48
|
+
# True when generating completion, we can just return
|
49
|
+
if ctx.resilient_parsing:
|
50
|
+
return
|
51
|
+
|
52
|
+
if value:
|
53
|
+
logging.getLogger().setLevel(level=logging.INFO)
|
54
|
+
|
55
|
+
|
56
|
+
@app.callback(invoke_without_command=True)
|
57
|
+
def _callback(
|
58
|
+
ctx: typer.Context,
|
59
|
+
version: Annotated[
|
60
|
+
bool | None,
|
61
|
+
typer.Option("--version", callback=version_callback, help=version_callback.__doc__, is_eager=True),
|
62
|
+
] = None,
|
63
|
+
verbose: Annotated[
|
64
|
+
bool | None, typer.Option("--verbose", callback=verbose_callback, help=verbose_callback.__doc__)
|
65
|
+
] = None,
|
66
|
+
):
|
67
|
+
# Load the settings (we reload in the callback to support testing under different env vars)
|
68
|
+
state.settings = Settings()
|
69
|
+
|
70
|
+
|
71
|
+
app.add_typer(orgs.app, name="orgs")
|
72
|
+
app.add_typer(projects.app, name="projects")
|
73
|
+
app.add_typer(fs.app, name="fs")
|
74
|
+
app.add_typer(tables.app, name="tables")
|
75
|
+
app.add_typer(key_spaces.app, name="ks")
|
76
|
+
app.add_typer(text.app, name="text")
|
77
|
+
app.add_typer(telemetry.app, name="telemetry")
|
78
|
+
app.add_typer(iceberg.app, name="iceberg")
|
79
|
+
app.command("login")(login.command)
|
80
|
+
app.command("console")(console.command)
|
81
|
+
|
82
|
+
|
83
|
+
# Register unless we're building docs. Because Typer docs command does not skip hidden commands...
|
84
|
+
if not bool(os.environ.get("SPIRAL_DOCS", False)):
|
85
|
+
app.add_typer(admin.app, name="admin", hidden=True)
|
86
|
+
app.add_typer(workloads.app, name="workloads", hidden=True)
|
87
|
+
app.command("whoami", hidden=True)(login.whoami)
|
88
|
+
app.command("logout", hidden=True)(login.logout)
|
89
|
+
|
90
|
+
|
91
|
+
def main():
|
92
|
+
# Setup rotating CLI logging.
|
93
|
+
# NOTE(ngates): we should do the same for the Spiral client? Maybe move this logic elsewhere?
|
94
|
+
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
95
|
+
logging.basicConfig(
|
96
|
+
level=logging.DEBUG,
|
97
|
+
handlers=[RotatingFileHandler(LOG_DIR / "cli.log", maxBytes=2**20, backupCount=10)],
|
98
|
+
)
|
99
|
+
|
100
|
+
app()
|
101
|
+
|
102
|
+
|
103
|
+
if __name__ == "__main__":
|
104
|
+
main()
|
spiral/cli/console.py
ADDED
@@ -0,0 +1,95 @@
|
|
1
|
+
import os
|
2
|
+
import subprocess
|
3
|
+
|
4
|
+
from spiral import Spiral
|
5
|
+
from spiral.adbc import ADBCFlightServer, SpiralADBCServer
|
6
|
+
from spiral.server import wait_for_port
|
7
|
+
|
8
|
+
|
9
|
+
def command():
|
10
|
+
"""Launch a SQL console to query Spiral tables."""
|
11
|
+
|
12
|
+
# To avoid taking a dependency on Harlequin, we install it on-demand using
|
13
|
+
# either uvx or pipx.
|
14
|
+
harlequin_args = _uvx()
|
15
|
+
if harlequin_args is None:
|
16
|
+
harlequin_args = _pipx()
|
17
|
+
if harlequin_args is None:
|
18
|
+
raise ValueError("Please install pipx to continue\n\tSee https://github.com/pypa/pipx")
|
19
|
+
|
20
|
+
# Set up a pipe to send the server port to the child process.
|
21
|
+
r, w = os.pipe()
|
22
|
+
|
23
|
+
pid = os.fork()
|
24
|
+
if pid == 0: # In the child
|
25
|
+
os.close(w)
|
26
|
+
port = int.from_bytes(os.read(r, 4), "big")
|
27
|
+
|
28
|
+
# Wait for the server to be up.
|
29
|
+
wait_for_port(port)
|
30
|
+
|
31
|
+
os.execv(
|
32
|
+
harlequin_args[0],
|
33
|
+
harlequin_args
|
34
|
+
+ [
|
35
|
+
"-a",
|
36
|
+
"adbc",
|
37
|
+
"--driver-type",
|
38
|
+
"flightsql",
|
39
|
+
f"grpc://localhost:{port}",
|
40
|
+
],
|
41
|
+
)
|
42
|
+
else:
|
43
|
+
os.close(r)
|
44
|
+
|
45
|
+
# I can't get the Flight server to stop writing to stdout. So we need to spawn a new process I think and
|
46
|
+
# then hope we can kill it?
|
47
|
+
fd = os.open("/dev/null", os.O_WRONLY)
|
48
|
+
os.dup2(fd, 1)
|
49
|
+
os.dup2(fd, 2)
|
50
|
+
|
51
|
+
# In the parent, we launch the Flight SQL server and send the port to the child
|
52
|
+
server = ADBCFlightServer(SpiralADBCServer(Spiral()))
|
53
|
+
os.write(w, server.port.to_bytes(4, "big"))
|
54
|
+
|
55
|
+
# Then wait for the console app to exit
|
56
|
+
os.waitpid(pid, 0)
|
57
|
+
|
58
|
+
|
59
|
+
def _pipx() -> list[str] | None:
|
60
|
+
"""Run harlequin via pipx."""
|
61
|
+
res = subprocess.run(["which", "pipx"], stdout=subprocess.PIPE)
|
62
|
+
if res.returncode != 0:
|
63
|
+
return None
|
64
|
+
# raise ValueError("Please install pipx to continue\n\tSee https://github.com/pypa/pipx")
|
65
|
+
pipx = res.stdout.strip()
|
66
|
+
|
67
|
+
return [
|
68
|
+
pipx,
|
69
|
+
"run",
|
70
|
+
"--pip-args",
|
71
|
+
"adbc_driver_flightsql",
|
72
|
+
"--pip-args",
|
73
|
+
# for now, we pin rich
|
74
|
+
"rich<=13.9.1",
|
75
|
+
"harlequin[adbc]",
|
76
|
+
]
|
77
|
+
|
78
|
+
|
79
|
+
def _uvx() -> list[str] | None:
|
80
|
+
"""Run harlequin via uvx."""
|
81
|
+
res = subprocess.run(["which", "uvx"], stdout=subprocess.PIPE)
|
82
|
+
if res.returncode != 0:
|
83
|
+
return None
|
84
|
+
uvx = res.stdout.strip()
|
85
|
+
|
86
|
+
return [
|
87
|
+
uvx,
|
88
|
+
"--with",
|
89
|
+
"adbc_driver_flightsql",
|
90
|
+
"--with",
|
91
|
+
"rich<=13.9.1",
|
92
|
+
"--from",
|
93
|
+
"harlequin[adbc]",
|
94
|
+
"harlequin",
|
95
|
+
]
|
spiral/cli/fs.py
ADDED
@@ -0,0 +1,76 @@
|
|
1
|
+
from typing import Literal
|
2
|
+
|
3
|
+
import questionary
|
4
|
+
from typer import Option
|
5
|
+
|
6
|
+
from spiral.api.filesystems import (
|
7
|
+
BuiltinFileSystem,
|
8
|
+
GCSFileSystem,
|
9
|
+
S3FileSystem,
|
10
|
+
UpstreamFileSystem,
|
11
|
+
)
|
12
|
+
from spiral.cli import CONSOLE, AsyncTyper, state
|
13
|
+
from spiral.cli.types import ProjectArg, ask_project
|
14
|
+
|
15
|
+
app = AsyncTyper(short_help="File Systems.")
|
16
|
+
|
17
|
+
|
18
|
+
@app.command(help="Show the file system configured for project.")
|
19
|
+
def show(project: ProjectArg):
|
20
|
+
file_system = state.settings.api.file_system.get_file_system(project)
|
21
|
+
CONSOLE.print(file_system)
|
22
|
+
|
23
|
+
|
24
|
+
def ask_provider():
|
25
|
+
res = state.settings.api.file_system.list_providers()
|
26
|
+
return questionary.select("Select a file system provider", choices=res).ask()
|
27
|
+
|
28
|
+
|
29
|
+
@app.command(help="Update a project's default file system.")
|
30
|
+
def update(
|
31
|
+
project: ProjectArg,
|
32
|
+
type_: Literal["builtin", "s3", "gcs", "upstream"] = Option(None, "--type", help="Type of the file system."),
|
33
|
+
provider: str = Option(None, help="Provider, when using `builtin` type."),
|
34
|
+
endpoint: str = Option(None, help="Endpoint, when using `s3` type."),
|
35
|
+
region: str = Option(
|
36
|
+
None, help="Region, when using `s3` or `gcs` type (defaults to `auto` for `s3` when `endpoint` is set)."
|
37
|
+
),
|
38
|
+
bucket: str = Option(None, help="Bucket, when using `s3` or `gcs` type."),
|
39
|
+
role_arn: str = Option(None, help="Role ARN to assume, when using `s3` type."),
|
40
|
+
):
|
41
|
+
if type_ == "builtin":
|
42
|
+
provider = provider or ask_provider()
|
43
|
+
file_system = BuiltinFileSystem(provider=provider)
|
44
|
+
|
45
|
+
elif type_ == "upstream":
|
46
|
+
upstream_project = ask_project(title="Select a project to use as file system.")
|
47
|
+
file_system = UpstreamFileSystem(project_id=upstream_project)
|
48
|
+
|
49
|
+
elif type_ == "s3":
|
50
|
+
if role_arn is None:
|
51
|
+
raise ValueError("--role-arn is required for S3 provider.")
|
52
|
+
if not role_arn.startswith("arn:aws:iam::") or ":role/" not in role_arn:
|
53
|
+
raise ValueError("Invalid role ARN format. Expected `arn:aws:iam::<account>:role/<role_name>`")
|
54
|
+
if bucket is None:
|
55
|
+
raise ValueError("--bucket is required for S3 provider.")
|
56
|
+
region = region or ("auto" if endpoint else None)
|
57
|
+
file_system = S3FileSystem(bucket=bucket, role_arn=role_arn, region=region)
|
58
|
+
if endpoint:
|
59
|
+
file_system.endpoint = endpoint
|
60
|
+
|
61
|
+
elif type_ == "gcs":
|
62
|
+
if region is None or bucket is None:
|
63
|
+
raise ValueError("--region and --bucket is required for GCS provider.")
|
64
|
+
file_system = GCSFileSystem(bucket=bucket, region=region)
|
65
|
+
|
66
|
+
else:
|
67
|
+
raise ValueError(f"Unknown file system type: {type_}")
|
68
|
+
|
69
|
+
fs = state.settings.api.file_system.update_file_system(project, file_system)
|
70
|
+
CONSOLE.print(fs)
|
71
|
+
|
72
|
+
|
73
|
+
@app.command(help="Lists the available built-in file system providers.")
|
74
|
+
def list_providers():
|
75
|
+
for provider in state.settings.api.file_system.list_providers():
|
76
|
+
CONSOLE.print(provider)
|
spiral/cli/iceberg.py
ADDED
@@ -0,0 +1,97 @@
|
|
1
|
+
import sys
|
2
|
+
from typing import Annotated
|
3
|
+
|
4
|
+
import rich
|
5
|
+
import typer
|
6
|
+
from typer import Argument
|
7
|
+
|
8
|
+
from spiral.cli import CONSOLE, ERR_CONSOLE, AsyncTyper, state
|
9
|
+
from spiral.cli.types import ProjectArg
|
10
|
+
|
11
|
+
app = AsyncTyper(short_help="Apache Iceberg Catalog")
|
12
|
+
|
13
|
+
|
14
|
+
@app.command(help="List namespaces.")
|
15
|
+
def namespaces(
|
16
|
+
project: ProjectArg,
|
17
|
+
namespace: Annotated[str | None, Argument(help="List only namespaces under this namespace.")] = None,
|
18
|
+
):
|
19
|
+
"""List Iceberg namespaces."""
|
20
|
+
import pyiceberg.exceptions
|
21
|
+
|
22
|
+
catalog = state.spiral.iceberg.catalog()
|
23
|
+
|
24
|
+
if namespace is None:
|
25
|
+
try:
|
26
|
+
namespaces = catalog.list_namespaces(project)
|
27
|
+
except pyiceberg.exceptions.ForbiddenError:
|
28
|
+
ERR_CONSOLE.print(
|
29
|
+
f"The project, {repr(project)}, does not exist or you lack the "
|
30
|
+
f"`iceberg:view` permission to list namespaces in it.",
|
31
|
+
)
|
32
|
+
raise typer.Exit(code=1)
|
33
|
+
else:
|
34
|
+
try:
|
35
|
+
namespaces = catalog.list_namespaces((project, namespace))
|
36
|
+
except pyiceberg.exceptions.ForbiddenError:
|
37
|
+
ERR_CONSOLE.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
|
+
)
|
41
|
+
raise typer.Exit(code=1)
|
42
|
+
|
43
|
+
table = CONSOLE.table.Table("Namespace ID", title="Iceberg namespaces")
|
44
|
+
for ns in namespaces:
|
45
|
+
table.add_row(".".join(ns))
|
46
|
+
CONSOLE.print(table)
|
47
|
+
|
48
|
+
|
49
|
+
@app.command(help="List tables.")
|
50
|
+
def tables(
|
51
|
+
project: ProjectArg,
|
52
|
+
namespace: Annotated[str | None, Argument(help="Show only tables in the given namespace.")] = None,
|
53
|
+
):
|
54
|
+
import pyiceberg.exceptions
|
55
|
+
|
56
|
+
catalog = state.spiral.iceberg.catalog()
|
57
|
+
|
58
|
+
try:
|
59
|
+
if namespace is None:
|
60
|
+
tables = catalog.list_tables(project)
|
61
|
+
else:
|
62
|
+
tables = catalog.list_tables((project, namespace))
|
63
|
+
except pyiceberg.exceptions.ForbiddenError:
|
64
|
+
ERR_CONSOLE.print(
|
65
|
+
f"The namespace, {repr(project)}.{repr(namespace)}, does not exist or you lack the "
|
66
|
+
f"`iceberg:view` permission to list tables in it.",
|
67
|
+
)
|
68
|
+
raise typer.Exit(code=1)
|
69
|
+
|
70
|
+
rich_table = rich.table.Table("table id", title="Iceberg tables")
|
71
|
+
for table in tables:
|
72
|
+
rich_table.add_row(".".join(table))
|
73
|
+
CONSOLE.print(rich_table)
|
74
|
+
|
75
|
+
|
76
|
+
@app.command(help="Show the table schema.")
|
77
|
+
def schema(
|
78
|
+
project: ProjectArg,
|
79
|
+
namespace: Annotated[str, Argument(help="Table namespace.")],
|
80
|
+
table: Annotated[str, Argument(help="Table name.")],
|
81
|
+
):
|
82
|
+
import pyiceberg.exceptions
|
83
|
+
|
84
|
+
catalog = state.spiral.iceberg.catalog()
|
85
|
+
|
86
|
+
try:
|
87
|
+
tbl = catalog.load_table((project, namespace, table))
|
88
|
+
except pyiceberg.exceptions.NoSuchTableError:
|
89
|
+
ERR_CONSOLE.print(f"No table {repr(table)} found in {repr(project)}.{repr(namespace)}", file=sys.stderr)
|
90
|
+
raise typer.Exit(code=1)
|
91
|
+
|
92
|
+
rich_table = rich.table.Table(
|
93
|
+
"Field ID", "Field name", "Type", "Required", "Doc", title=f"{project}.{namespace}.{table}"
|
94
|
+
)
|
95
|
+
for col in tbl.schema().columns:
|
96
|
+
rich_table.add_row(str(col.field_id), col.name, str(col.field_type), str(col.required), col.doc)
|
97
|
+
CONSOLE.print(rich_table)
|
spiral/cli/key_spaces.py
ADDED
@@ -0,0 +1,89 @@
|
|
1
|
+
from typing import Annotated
|
2
|
+
|
3
|
+
import questionary
|
4
|
+
import rich
|
5
|
+
import typer
|
6
|
+
from questionary import Choice
|
7
|
+
from typer import Option
|
8
|
+
|
9
|
+
from spiral.api.key_space_indexes import SyncIndexRequest
|
10
|
+
from spiral.api.projects import KeySpaceIndexResource
|
11
|
+
from spiral.api.types import IndexId
|
12
|
+
from spiral.api.workers import ResourceClass
|
13
|
+
from spiral.cli import CONSOLE, AsyncTyper, state
|
14
|
+
from spiral.cli.types import ProjectArg
|
15
|
+
|
16
|
+
app = AsyncTyper(short_help="Key Space Indexes.")
|
17
|
+
|
18
|
+
|
19
|
+
def ask_index(project_id, title="Select an index"):
|
20
|
+
indexes: list[KeySpaceIndexResource] = list(state.spiral.project(project_id).list_key_space_indexes())
|
21
|
+
|
22
|
+
if not indexes:
|
23
|
+
CONSOLE.print("[red]No indexes found[/red]")
|
24
|
+
raise typer.Exit(1)
|
25
|
+
|
26
|
+
return questionary.select(
|
27
|
+
title,
|
28
|
+
choices=[Choice(title=index.name, value=index.id) for index in sorted(indexes, key=lambda t: (t.name, t.id))],
|
29
|
+
).ask()
|
30
|
+
|
31
|
+
|
32
|
+
def get_index_id(
|
33
|
+
project: ProjectArg,
|
34
|
+
name: Annotated[str | None, Option(help="Index name.")] = None,
|
35
|
+
) -> IndexId:
|
36
|
+
if name is None:
|
37
|
+
return ask_index(project)
|
38
|
+
|
39
|
+
indexes: list[KeySpaceIndexResource] = list(state.spiral.project(project).list_key_space_indexes())
|
40
|
+
for index in indexes:
|
41
|
+
if index.name == name:
|
42
|
+
return index.id
|
43
|
+
raise ValueError(f"Index not found: {name}")
|
44
|
+
|
45
|
+
|
46
|
+
@app.command(help="List indexes.")
|
47
|
+
def ls(
|
48
|
+
project: ProjectArg,
|
49
|
+
):
|
50
|
+
"""List indexes."""
|
51
|
+
indexes = state.spiral.project(project).list_key_space_indexes()
|
52
|
+
|
53
|
+
rich_table = rich.table.Table("id", "name", title="Key Space Indexes")
|
54
|
+
for index in indexes:
|
55
|
+
rich_table.add_row(index.id, index.name)
|
56
|
+
CONSOLE.print(rich_table)
|
57
|
+
|
58
|
+
|
59
|
+
@app.command(help="Show index partitions.")
|
60
|
+
def show(
|
61
|
+
project: ProjectArg,
|
62
|
+
name: Annotated[str | None, Option(help="Index name.")] = None,
|
63
|
+
):
|
64
|
+
"""Show index partitions."""
|
65
|
+
index_id = get_index_id(project, name)
|
66
|
+
index = state.spiral.key_space_index(index_id)
|
67
|
+
shards = state.spiral._ops().compute_shards(index.core)
|
68
|
+
|
69
|
+
rich_table = rich.table.Table("Begin", "End", "Cardinality", title=f"Index {index.name} Partitions")
|
70
|
+
for partition in shards:
|
71
|
+
rich_table.add_row(
|
72
|
+
# TODO(marko): This isn't really pretty...
|
73
|
+
repr(partition.key_range.begin),
|
74
|
+
repr(partition.key_range.end),
|
75
|
+
str(partition.cardinality),
|
76
|
+
)
|
77
|
+
CONSOLE.print(rich_table)
|
78
|
+
|
79
|
+
|
80
|
+
@app.command(help="Trigger a sync job for an index.")
|
81
|
+
def sync(
|
82
|
+
project: ProjectArg,
|
83
|
+
name: Annotated[str | None, Option(help="Index name.")] = None,
|
84
|
+
resources: Annotated[ResourceClass, Option(help="Resources to use for the sync job.")] = ResourceClass.SMALL,
|
85
|
+
):
|
86
|
+
"""Trigger a sync job."""
|
87
|
+
index_id = get_index_id(project, name)
|
88
|
+
response = state.spiral.api.key_space_indexes.sync_index(index_id, SyncIndexRequest(resources=resources))
|
89
|
+
CONSOLE.print(f"Triggered sync job {response.worker_id} for index {index_id}.")
|
spiral/cli/login.py
ADDED
@@ -0,0 +1,24 @@
|
|
1
|
+
import jwt
|
2
|
+
|
3
|
+
from spiral.cli import CONSOLE, state
|
4
|
+
|
5
|
+
|
6
|
+
def command(org_id: str | None = None, force: bool = False, show_token: bool = False):
|
7
|
+
token = state.settings.device_code_auth.authenticate(force=force, org_id=org_id)
|
8
|
+
CONSOLE.print("Successfully logged in.")
|
9
|
+
if show_token:
|
10
|
+
CONSOLE.print(token.expose_secret(), soft_wrap=True)
|
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
|
+
|
17
|
+
if "org_id" in payload:
|
18
|
+
CONSOLE.print(f"{payload['org_id']}")
|
19
|
+
CONSOLE.print(f"{payload['sub']}")
|
20
|
+
|
21
|
+
|
22
|
+
def logout():
|
23
|
+
state.settings.device_code_auth.logout()
|
24
|
+
CONSOLE.print("Logged out.")
|
spiral/cli/orgs.py
ADDED
@@ -0,0 +1,89 @@
|
|
1
|
+
import webbrowser
|
2
|
+
from typing import Annotated
|
3
|
+
|
4
|
+
import jwt
|
5
|
+
import typer
|
6
|
+
from rich.table import Table
|
7
|
+
from typer import Option
|
8
|
+
|
9
|
+
from spiral.api.organizations import CreateOrgRequest, InviteUserRequest, OrgRole, PortalLinkIntent, PortalLinkRequest
|
10
|
+
from spiral.cli import CONSOLE, ERR_CONSOLE, AsyncTyper, state
|
11
|
+
from spiral.cli.types import OrganizationArg
|
12
|
+
|
13
|
+
app = AsyncTyper(short_help="Org admin.")
|
14
|
+
|
15
|
+
|
16
|
+
@app.command(help="Switch the active organization.")
|
17
|
+
def switch(org_id: OrganizationArg):
|
18
|
+
state.settings.device_code_auth.authenticate(org_id=org_id)
|
19
|
+
CONSOLE.print(f"Switched to organization: {org_id}")
|
20
|
+
|
21
|
+
|
22
|
+
@app.command(help="Create a new organization.")
|
23
|
+
def create(
|
24
|
+
name: Annotated[str | None, Option(help="The human-readable name of the organization.")] = None,
|
25
|
+
):
|
26
|
+
res = state.settings.api.organization.create(CreateOrgRequest(name=name))
|
27
|
+
|
28
|
+
# Authenticate to the new organization
|
29
|
+
state.settings.device_code_auth.authenticate(org_id=res.org.id)
|
30
|
+
|
31
|
+
CONSOLE.print(f"{res.org.name} [dim]{res.org.id}[/dim]")
|
32
|
+
|
33
|
+
|
34
|
+
@app.command(help="List organizations.")
|
35
|
+
def ls():
|
36
|
+
org_id = current_org_id()
|
37
|
+
|
38
|
+
table = Table("", "id", "name", "role", title="Organizations")
|
39
|
+
for m in state.settings.api.organization.list_memberships():
|
40
|
+
table.add_row("👉" if m.org.id == org_id else "", m.org.id, m.org.name, m.role)
|
41
|
+
|
42
|
+
CONSOLE.print(table)
|
43
|
+
|
44
|
+
|
45
|
+
@app.command(help="Invite a user to the organization.")
|
46
|
+
def invite(email: str, role: OrgRole = OrgRole.MEMBER, expires_in_days: int = 7):
|
47
|
+
state.settings.api.organization.invite_user(
|
48
|
+
InviteUserRequest(email=email, role=role, expires_in_days=expires_in_days)
|
49
|
+
)
|
50
|
+
CONSOLE.print(f"Invited {email} as a {role.value}.")
|
51
|
+
|
52
|
+
|
53
|
+
@app.command(help="Configure single sign-on for your organization.")
|
54
|
+
def sso():
|
55
|
+
_do_action(PortalLinkIntent.SSO)
|
56
|
+
|
57
|
+
|
58
|
+
@app.command(help="Configure directory services for your organization.")
|
59
|
+
def directory():
|
60
|
+
_do_action(PortalLinkIntent.DIRECTORY_SYNC)
|
61
|
+
|
62
|
+
|
63
|
+
@app.command(help="Configure audit logs for your organization.")
|
64
|
+
def audit_logs():
|
65
|
+
_do_action(PortalLinkIntent.AUDIT_LOGS)
|
66
|
+
|
67
|
+
|
68
|
+
@app.command(help="Configure log streams for your organization.")
|
69
|
+
def log_streams():
|
70
|
+
_do_action(PortalLinkIntent.LOG_STREAMS)
|
71
|
+
|
72
|
+
|
73
|
+
@app.command(help="Configure domains for your organization.")
|
74
|
+
def domains():
|
75
|
+
_do_action(PortalLinkIntent.DOMAIN_VERIFICATION)
|
76
|
+
|
77
|
+
|
78
|
+
def _do_action(intent: PortalLinkIntent):
|
79
|
+
res = state.settings.api.organization.portal_link(PortalLinkRequest(intent=intent))
|
80
|
+
CONSOLE.print(f"Opening the configuration portal:\n{res.url}")
|
81
|
+
webbrowser.open(res.url)
|
82
|
+
|
83
|
+
|
84
|
+
def current_org_id():
|
85
|
+
if token := state.settings.authn.token():
|
86
|
+
if org_id := jwt.decode(token.expose_secret(), options={"verify_signature": False}).get("org_id"):
|
87
|
+
return org_id
|
88
|
+
ERR_CONSOLE.print("You are not logged in to an organization.")
|
89
|
+
raise typer.Exit(1)
|