pyspiral 0.8.1__cp311-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 (110) hide show
  1. pyspiral-0.8.1.dist-info/METADATA +53 -0
  2. pyspiral-0.8.1.dist-info/RECORD +110 -0
  3. pyspiral-0.8.1.dist-info/WHEEL +5 -0
  4. pyspiral-0.8.1.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 +166 -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 +202 -0
  21. spiral/cli/__init__.py +89 -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 +203 -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 +267 -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 +262 -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 +214 -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 +290 -0
  66. spiral/expressions/__init__.py +274 -0
  67. spiral/expressions/base.py +186 -0
  68. spiral/expressions/file.py +17 -0
  69. spiral/expressions/http.py +17 -0
  70. spiral/expressions/list_.py +77 -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 +225 -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 +359 -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 +156 -0
  106. spiral/substrait_.py +274 -0
  107. spiral/table.py +212 -0
  108. spiral/text_index.py +17 -0
  109. spiral/transaction.py +150 -0
  110. spiral/types_.py +6 -0
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.")
spiral/cli/orgs.py ADDED
@@ -0,0 +1,90 @@
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
+ from spiral.core.authn import DeviceCodeAuth
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
+ DeviceCodeAuth.default().authenticate(org_id=org_id)
20
+ CONSOLE.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.spiral.api.organization.create(CreateOrgRequest(name=name))
28
+
29
+ # Authenticate to the new organization
30
+ DeviceCodeAuth.default().authenticate(org_id=res.org.id)
31
+
32
+ CONSOLE.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.spiral.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
+ CONSOLE.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.spiral.api.organization.invite_user(
49
+ InviteUserRequest(email=email, role=role, expires_in_days=expires_in_days)
50
+ )
51
+ CONSOLE.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.spiral.api.organization.portal_link(PortalLinkRequest(intent=intent))
81
+ CONSOLE.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.spiral.authn.token():
87
+ if org_id := jwt.decode(token.expose_secret(), options={"verify_signature": False}).get("org_id"):
88
+ return org_id
89
+ ERR_CONSOLE.print("You are not logged in to an organization.")
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 betterproto2
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=betterproto2.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()._betterproto2.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