pyspiral 0.6.6__cp312-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.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 (102) hide show
  1. pyspiral-0.6.6.dist-info/METADATA +51 -0
  2. pyspiral-0.6.6.dist-info/RECORD +102 -0
  3. pyspiral-0.6.6.dist-info/WHEEL +4 -0
  4. pyspiral-0.6.6.dist-info/entry_points.txt +2 -0
  5. spiral/__init__.py +35 -0
  6. spiral/_lib.abi3.so +0 -0
  7. spiral/adbc.py +411 -0
  8. spiral/api/__init__.py +78 -0
  9. spiral/api/admin.py +15 -0
  10. spiral/api/client.py +164 -0
  11. spiral/api/filesystems.py +134 -0
  12. spiral/api/key_space_indexes.py +23 -0
  13. spiral/api/organizations.py +77 -0
  14. spiral/api/projects.py +219 -0
  15. spiral/api/telemetry.py +19 -0
  16. spiral/api/text_indexes.py +56 -0
  17. spiral/api/types.py +22 -0
  18. spiral/api/workers.py +40 -0
  19. spiral/api/workloads.py +52 -0
  20. spiral/arrow_.py +216 -0
  21. spiral/cli/__init__.py +88 -0
  22. spiral/cli/__main__.py +4 -0
  23. spiral/cli/admin.py +14 -0
  24. spiral/cli/app.py +104 -0
  25. spiral/cli/console.py +95 -0
  26. spiral/cli/fs.py +76 -0
  27. spiral/cli/iceberg.py +97 -0
  28. spiral/cli/key_spaces.py +89 -0
  29. spiral/cli/login.py +24 -0
  30. spiral/cli/orgs.py +89 -0
  31. spiral/cli/printer.py +53 -0
  32. spiral/cli/projects.py +147 -0
  33. spiral/cli/state.py +5 -0
  34. spiral/cli/tables.py +174 -0
  35. spiral/cli/telemetry.py +17 -0
  36. spiral/cli/text.py +115 -0
  37. spiral/cli/types.py +50 -0
  38. spiral/cli/workloads.py +58 -0
  39. spiral/client.py +178 -0
  40. spiral/core/__init__.pyi +0 -0
  41. spiral/core/_tools/__init__.pyi +5 -0
  42. spiral/core/authn/__init__.pyi +27 -0
  43. spiral/core/client/__init__.pyi +237 -0
  44. spiral/core/table/__init__.pyi +101 -0
  45. spiral/core/table/manifests/__init__.pyi +35 -0
  46. spiral/core/table/metastore/__init__.pyi +58 -0
  47. spiral/core/table/spec/__init__.pyi +213 -0
  48. spiral/dataloader.py +285 -0
  49. spiral/dataset.py +255 -0
  50. spiral/datetime_.py +27 -0
  51. spiral/debug/__init__.py +0 -0
  52. spiral/debug/manifests.py +87 -0
  53. spiral/debug/metrics.py +56 -0
  54. spiral/debug/scan.py +266 -0
  55. spiral/expressions/__init__.py +276 -0
  56. spiral/expressions/base.py +157 -0
  57. spiral/expressions/http.py +86 -0
  58. spiral/expressions/io.py +100 -0
  59. spiral/expressions/list_.py +68 -0
  60. spiral/expressions/mp4.py +62 -0
  61. spiral/expressions/png.py +18 -0
  62. spiral/expressions/qoi.py +18 -0
  63. spiral/expressions/refs.py +58 -0
  64. spiral/expressions/str_.py +39 -0
  65. spiral/expressions/struct.py +59 -0
  66. spiral/expressions/text.py +62 -0
  67. spiral/expressions/tiff.py +223 -0
  68. spiral/expressions/udf.py +46 -0
  69. spiral/grpc_.py +32 -0
  70. spiral/iceberg.py +31 -0
  71. spiral/iterable_dataset.py +106 -0
  72. spiral/key_space_index.py +44 -0
  73. spiral/project.py +199 -0
  74. spiral/protogen/_/__init__.py +0 -0
  75. spiral/protogen/_/arrow/__init__.py +0 -0
  76. spiral/protogen/_/arrow/flight/__init__.py +0 -0
  77. spiral/protogen/_/arrow/flight/protocol/__init__.py +0 -0
  78. spiral/protogen/_/arrow/flight/protocol/sql/__init__.py +2548 -0
  79. spiral/protogen/_/google/__init__.py +0 -0
  80. spiral/protogen/_/google/protobuf/__init__.py +2310 -0
  81. spiral/protogen/_/message_pool.py +3 -0
  82. spiral/protogen/_/py.typed +0 -0
  83. spiral/protogen/_/scandal/__init__.py +190 -0
  84. spiral/protogen/_/spfs/__init__.py +72 -0
  85. spiral/protogen/_/spql/__init__.py +61 -0
  86. spiral/protogen/_/substrait/__init__.py +6196 -0
  87. spiral/protogen/_/substrait/extensions/__init__.py +169 -0
  88. spiral/protogen/__init__.py +0 -0
  89. spiral/protogen/util.py +41 -0
  90. spiral/py.typed +0 -0
  91. spiral/scan.py +285 -0
  92. spiral/server.py +17 -0
  93. spiral/settings.py +114 -0
  94. spiral/snapshot.py +56 -0
  95. spiral/streaming_/__init__.py +3 -0
  96. spiral/streaming_/reader.py +133 -0
  97. spiral/streaming_/stream.py +157 -0
  98. spiral/substrait_.py +274 -0
  99. spiral/table.py +293 -0
  100. spiral/text_index.py +17 -0
  101. spiral/transaction.py +58 -0
  102. 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
@@ -0,0 +1,4 @@
1
+ from spiral.cli.app import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
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)
@@ -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)