pyspiral 0.7.18__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.
Files changed (110) hide show
  1. pyspiral-0.7.18.dist-info/METADATA +52 -0
  2. pyspiral-0.7.18.dist-info/RECORD +110 -0
  3. pyspiral-0.7.18.dist-info/WHEEL +4 -0
  4. pyspiral-0.7.18.dist-info/entry_points.txt +3 -0
  5. spiral/__init__.py +55 -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 +23 -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 +108 -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 +103 -0
  29. spiral/cli/login.py +25 -0
  30. spiral/cli/orgs.py +90 -0
  31. spiral/cli/printer.py +53 -0
  32. spiral/cli/projects.py +147 -0
  33. spiral/cli/state.py +7 -0
  34. spiral/cli/tables.py +197 -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 +256 -0
  40. spiral/core/__init__.pyi +0 -0
  41. spiral/core/_tools/__init__.pyi +5 -0
  42. spiral/core/authn/__init__.pyi +21 -0
  43. spiral/core/client/__init__.pyi +285 -0
  44. spiral/core/config/__init__.pyi +35 -0
  45. spiral/core/expr/__init__.pyi +15 -0
  46. spiral/core/expr/images/__init__.pyi +3 -0
  47. spiral/core/expr/list_/__init__.pyi +4 -0
  48. spiral/core/expr/refs/__init__.pyi +4 -0
  49. spiral/core/expr/str_/__init__.pyi +3 -0
  50. spiral/core/expr/struct_/__init__.pyi +6 -0
  51. spiral/core/expr/text/__init__.pyi +5 -0
  52. spiral/core/expr/udf/__init__.pyi +14 -0
  53. spiral/core/expr/video/__init__.pyi +3 -0
  54. spiral/core/table/__init__.pyi +141 -0
  55. spiral/core/table/manifests/__init__.pyi +35 -0
  56. spiral/core/table/metastore/__init__.pyi +58 -0
  57. spiral/core/table/spec/__init__.pyi +215 -0
  58. spiral/dataloader.py +299 -0
  59. spiral/dataset.py +264 -0
  60. spiral/datetime_.py +27 -0
  61. spiral/debug/__init__.py +0 -0
  62. spiral/debug/manifests.py +87 -0
  63. spiral/debug/metrics.py +56 -0
  64. spiral/debug/scan.py +266 -0
  65. spiral/enrichment.py +306 -0
  66. spiral/expressions/__init__.py +274 -0
  67. spiral/expressions/base.py +167 -0
  68. spiral/expressions/file.py +17 -0
  69. spiral/expressions/http.py +17 -0
  70. spiral/expressions/list_.py +68 -0
  71. spiral/expressions/s3.py +16 -0
  72. spiral/expressions/str_.py +39 -0
  73. spiral/expressions/struct.py +59 -0
  74. spiral/expressions/text.py +62 -0
  75. spiral/expressions/tiff.py +222 -0
  76. spiral/expressions/udf.py +60 -0
  77. spiral/grpc_.py +32 -0
  78. spiral/iceberg.py +31 -0
  79. spiral/iterable_dataset.py +106 -0
  80. spiral/key_space_index.py +44 -0
  81. spiral/project.py +227 -0
  82. spiral/protogen/_/__init__.py +0 -0
  83. spiral/protogen/_/arrow/__init__.py +0 -0
  84. spiral/protogen/_/arrow/flight/__init__.py +0 -0
  85. spiral/protogen/_/arrow/flight/protocol/__init__.py +0 -0
  86. spiral/protogen/_/arrow/flight/protocol/sql/__init__.py +2548 -0
  87. spiral/protogen/_/google/__init__.py +0 -0
  88. spiral/protogen/_/google/protobuf/__init__.py +2310 -0
  89. spiral/protogen/_/message_pool.py +3 -0
  90. spiral/protogen/_/py.typed +0 -0
  91. spiral/protogen/_/scandal/__init__.py +190 -0
  92. spiral/protogen/_/spfs/__init__.py +72 -0
  93. spiral/protogen/_/spql/__init__.py +61 -0
  94. spiral/protogen/_/substrait/__init__.py +6196 -0
  95. spiral/protogen/_/substrait/extensions/__init__.py +169 -0
  96. spiral/protogen/__init__.py +0 -0
  97. spiral/protogen/util.py +41 -0
  98. spiral/py.typed +0 -0
  99. spiral/scan.py +363 -0
  100. spiral/server.py +17 -0
  101. spiral/settings.py +36 -0
  102. spiral/snapshot.py +56 -0
  103. spiral/streaming_/__init__.py +3 -0
  104. spiral/streaming_/reader.py +133 -0
  105. spiral/streaming_/stream.py +157 -0
  106. spiral/substrait_.py +274 -0
  107. spiral/table.py +224 -0
  108. spiral/text_index.py +17 -0
  109. spiral/transaction.py +155 -0
  110. 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.spiral.api._admin.sync_orgs()
12
+
13
+ for membership in state.spiral.api._admin.sync_memberships(org_id):
14
+ CONSOLE.print(membership)
spiral/cli/app.py ADDED
@@ -0,0 +1,108 @@
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
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
+ ) -> None:
67
+ # Reload the spiral client to support testing under different env vars
68
+ from spiral import Spiral
69
+ from spiral.settings import settings
70
+
71
+ config = settings()
72
+ state.spiral = Spiral(config=config)
73
+
74
+
75
+ app.add_typer(orgs.app, name="orgs")
76
+ app.add_typer(projects.app, name="projects")
77
+ app.add_typer(fs.app, name="fs")
78
+ app.add_typer(tables.app, name="tables")
79
+ app.add_typer(key_spaces.app, name="ks")
80
+ app.add_typer(text.app, name="text")
81
+ app.add_typer(telemetry.app, name="telemetry")
82
+ app.add_typer(iceberg.app, name="iceberg")
83
+ app.command("login")(login.command)
84
+ app.command("console")(console.command)
85
+
86
+
87
+ # Register unless we're building docs. Because Typer docs command does not skip hidden commands...
88
+ if not bool(os.environ.get("SPIRAL_DOCS", False)):
89
+ app.add_typer(admin.app, name="admin", hidden=True)
90
+ app.add_typer(workloads.app, name="workloads", hidden=True)
91
+ app.command("whoami", hidden=True)(login.whoami)
92
+ app.command("logout", hidden=True)(login.logout)
93
+
94
+
95
+ def main():
96
+ # Setup rotating CLI logging.
97
+ # NOTE(ngates): we should do the same for the Spiral client? Maybe move this logic elsewhere?
98
+ LOG_DIR.mkdir(parents=True, exist_ok=True)
99
+ logging.basicConfig(
100
+ level=logging.DEBUG,
101
+ handlers=[RotatingFileHandler(LOG_DIR / "cli.log", maxBytes=2**20, backupCount=10)],
102
+ )
103
+
104
+ app()
105
+
106
+
107
+ if __name__ == "__main__":
108
+ 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.spiral.api.file_system.get_file_system(project)
21
+ CONSOLE.print(file_system)
22
+
23
+
24
+ def ask_provider():
25
+ res = state.spiral.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.spiral.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.spiral.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,103 @@
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.internal.key_space_index_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}.")
90
+
91
+
92
+ # TODO(marko): This will be removed.
93
+ @app.command(help="Run a sync and wait for it to complete.")
94
+ def sync_local(
95
+ project: ProjectArg,
96
+ name: Annotated[str | None, Option(help="Index name.")] = None,
97
+ ):
98
+ """Run a sync and wait for it to complete."""
99
+ index_id = get_index_id(project, name)
100
+ index = state.spiral.key_space_index(index_id)
101
+ snapshot = state.spiral.table(index.table_id).snapshot()
102
+ state.spiral.internal.update_key_space_index(index.core, snapshot.core)
103
+ CONSOLE.print(f"Index {index.name} is up to date as-of {snapshot.asof}.")
spiral/cli/login.py ADDED
@@ -0,0 +1,25 @@
1
+ import jwt
2
+
3
+ from spiral.cli import CONSOLE, state
4
+ from spiral.core.authn import DeviceCodeAuth
5
+
6
+
7
+ def command(org_id: str | None = None, force: bool = False, show_token: bool = False):
8
+ token = DeviceCodeAuth.default().authenticate(force=force, org_id=org_id)
9
+ CONSOLE.print("Successfully logged in.")
10
+ if show_token:
11
+ CONSOLE.print(token.expose_secret(), soft_wrap=True)
12
+
13
+
14
+ def whoami():
15
+ """Display the current user's information."""
16
+ payload = jwt.decode(state.spiral.authn.token().expose_secret(), options={"verify_signature": False})
17
+
18
+ if "org_id" in payload:
19
+ CONSOLE.print(f"{payload['org_id']}")
20
+ CONSOLE.print(f"{payload['sub']}")
21
+
22
+
23
+ def logout():
24
+ DeviceCodeAuth.default().logout()
25
+ CONSOLE.print("Logged out.")