pyspiral 0.8.9__cp311-abi3-macosx_11_0_arm64.whl → 0.9.9__cp311-abi3-macosx_11_0_arm64.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.
spiral/cli/app.py CHANGED
@@ -20,6 +20,7 @@ from spiral.cli import (
20
20
  tables,
21
21
  telemetry,
22
22
  text,
23
+ transactions,
23
24
  workloads,
24
25
  )
25
26
  from spiral.settings import LOG_DIR, PACKAGE_NAME
@@ -77,18 +78,23 @@ app.add_typer(projects.app, name="projects")
77
78
  app.add_typer(fs.app, name="fs")
78
79
  app.add_typer(workloads.app, name="workloads")
79
80
  app.add_typer(tables.app, name="tables")
80
- app.add_typer(key_spaces.app, name="ks")
81
- app.add_typer(text.app, name="text")
82
- app.add_typer(telemetry.app, name="telemetry")
83
81
  app.add_typer(iceberg.app, name="iceberg")
82
+ app.add_typer(telemetry.app, name="telemetry")
84
83
  app.command("login")(login.command)
84
+ app.command("whoami")(login.whoami)
85
85
  app.command("console")(console.command)
86
86
 
87
87
 
88
88
  # Register unless we're building docs. Because Typer docs command does not skip hidden commands...
89
89
  if not bool(os.environ.get("SPIRAL_DOCS", False)):
90
90
  app.add_typer(admin.app, name="admin", hidden=True)
91
- app.command("whoami", hidden=True)(login.whoami)
91
+
92
+ # Hide some "beta" commands.
93
+ app.add_typer(transactions.app, name="txn", hidden=True)
94
+ app.add_typer(key_spaces.app, name="ks", hidden=True)
95
+ app.add_typer(text.app, name="text", hidden=True)
96
+
97
+ # Hidden because there isn't really a logout. This just removes the stored credentials.
92
98
  app.command("logout", hidden=True)(login.logout)
93
99
 
94
100
 
spiral/cli/chooser.py ADDED
@@ -0,0 +1,30 @@
1
+ from collections.abc import Callable
2
+ from typing import Any, cast
3
+
4
+ import questionary
5
+ import typer
6
+ from questionary import Choice
7
+
8
+
9
+ def choose(title: str, choices: list[Choice] | list[str]) -> Any:
10
+ """Interactively select one of the choices exiting the process if ctrl-c is pressed."""
11
+ if choices and isinstance(choices[0], Choice):
12
+ for choice in choices:
13
+ assert isinstance(choice, Choice)
14
+ choice.value = (choice.value,)
15
+ else:
16
+ choices = cast(list[str], choices)
17
+ choices = [Choice(choice, value=(choice,)) for choice in choices]
18
+
19
+ maybe_selection = questionary.select(title, choices=choices).ask()
20
+ if maybe_selection is None:
21
+ raise typer.Exit(2)
22
+ return maybe_selection[0]
23
+
24
+
25
+ def input_text(message: str, validate: Callable[[str], bool | str]) -> str:
26
+ """Interactively receive string input which passes the validation exiting the process if ctrl-c is pressed."""
27
+ maybe_text = questionary.text(message, validate=validate).ask()
28
+ if maybe_text is None:
29
+ raise typer.Exit(2)
30
+ return maybe_text[0]
spiral/cli/fs.py CHANGED
@@ -13,7 +13,8 @@ from spiral.api.filesystems import (
13
13
  UpstreamFileSystem,
14
14
  )
15
15
  from spiral.cli import CONSOLE, AsyncTyper, state
16
- from spiral.cli.types import ProjectArg, ask_project
16
+ from spiral.cli.chooser import choose
17
+ from spiral.cli.types_ import ProjectArg, ask_project
17
18
 
18
19
  app = AsyncTyper(short_help="File Systems.")
19
20
 
@@ -26,7 +27,7 @@ def show(project: ProjectArg):
26
27
 
27
28
  def ask_provider():
28
29
  res = state.spiral.api.file_systems.list_providers()
29
- return questionary.select("Select a file system provider", choices=res).ask()
30
+ return choose("Select a file system provider", choices=res)
30
31
 
31
32
 
32
33
  @app.command(help="Update a project's default file system.")
spiral/cli/iceberg.py CHANGED
@@ -6,7 +6,7 @@ import typer
6
6
  from typer import Argument
7
7
 
8
8
  from spiral.cli import CONSOLE, ERR_CONSOLE, AsyncTyper, state
9
- from spiral.cli.types import ProjectArg
9
+ from spiral.cli.types_ import ProjectArg
10
10
 
11
11
  app = AsyncTyper(short_help="Apache Iceberg Catalog.")
12
12
 
spiral/cli/key_spaces.py CHANGED
@@ -1,6 +1,5 @@
1
1
  from typing import Annotated
2
2
 
3
- import questionary
4
3
  import rich
5
4
  import typer
6
5
  from questionary import Choice
@@ -11,7 +10,8 @@ from spiral.api.projects import KeySpaceIndexResource
11
10
  from spiral.api.types import IndexId
12
11
  from spiral.api.workers import ResourceClass
13
12
  from spiral.cli import CONSOLE, AsyncTyper, state
14
- from spiral.cli.types import ProjectArg
13
+ from spiral.cli.chooser import choose
14
+ from spiral.cli.types_ import ProjectArg
15
15
 
16
16
  app = AsyncTyper(short_help="Key Space Indexes.")
17
17
 
@@ -23,10 +23,10 @@ def ask_index(project_id, title="Select an index"):
23
23
  CONSOLE.print("[red]No indexes found[/red]")
24
24
  raise typer.Exit(1)
25
25
 
26
- return questionary.select(
26
+ return choose(
27
27
  title,
28
28
  choices=[Choice(title=index.name, value=index.id) for index in sorted(indexes, key=lambda t: (t.name, t.id))],
29
- ).ask()
29
+ )
30
30
 
31
31
 
32
32
  def get_index_id(
spiral/cli/orgs.py CHANGED
@@ -6,7 +6,7 @@ from rich.table import Table
6
6
 
7
7
  from spiral.api.organizations import InviteUserRequest, OrgRole, PortalLinkIntent, PortalLinkRequest
8
8
  from spiral.cli import CONSOLE, ERR_CONSOLE, AsyncTyper, state
9
- from spiral.cli.types import OrganizationArg
9
+ from spiral.cli.types_ import OrganizationArg
10
10
  from spiral.core.authn import DeviceCodeAuth
11
11
 
12
12
  app = AsyncTyper(short_help="Org admin.")
spiral/cli/projects.py CHANGED
@@ -21,14 +21,14 @@ from spiral.api.projects import (
21
21
  WorkloadPrincipalConditions,
22
22
  )
23
23
  from spiral.cli import CONSOLE, AsyncTyper, printer, state
24
- from spiral.cli.types import ProjectArg
24
+ from spiral.cli.types_ import ProjectArg
25
25
 
26
26
  app = AsyncTyper(short_help="Projects and grants.")
27
27
 
28
28
 
29
29
  @app.command(help="List projects.")
30
30
  def ls():
31
- projects = list(state.spiral.api.projects.list())
31
+ projects = list(sorted(state.spiral.api.projects.list(), key=lambda p: p.id))
32
32
  CONSOLE.print(printer.table_of_models(Project, projects))
33
33
 
34
34
 
spiral/cli/tables.py CHANGED
@@ -11,8 +11,9 @@ from typer import Argument, Option
11
11
  from spiral import Spiral
12
12
  from spiral.api.projects import TableResource
13
13
  from spiral.cli import CONSOLE, ERR_CONSOLE, AsyncTyper, state
14
- from spiral.cli.types import ProjectArg
15
- from spiral.debug.manifests import display_manifests
14
+ from spiral.cli.chooser import choose, input_text
15
+ from spiral.cli.types_ import ProjectArg
16
+ from spiral.debug.manifests import display_manifests, display_scan_manifests
16
17
  from spiral.table import Table
17
18
 
18
19
  app = AsyncTyper(short_help="Spiral Tables.")
@@ -25,13 +26,13 @@ def ask_table(project_id: str, title: str = "Select a table") -> str:
25
26
  ERR_CONSOLE.print("No tables found")
26
27
  raise typer.Exit(1)
27
28
 
28
- return questionary.select( # pyright: ignore[reportAny]
29
+ return choose( # pyright: ignore[reportAny]
29
30
  title,
30
31
  choices=[
31
32
  Choice(title=f"{table.dataset}.{table.table}", value=f"{table.dataset}.{table.table}")
32
33
  for table in sorted(tables, key=lambda t: (t.dataset, t.table))
33
34
  ],
34
- ).ask()
35
+ )
35
36
 
36
37
 
37
38
  def get_table(
@@ -89,7 +90,7 @@ def validate_non_empty_str(text: str) -> bool | str:
89
90
 
90
91
 
91
92
  def get_string(message: str, validate: Callable[[str], bool | str] = validate_non_empty_str) -> str:
92
- return questionary.text(message, validate=validate).ask()
93
+ return input_text(message, validate=validate)
93
94
 
94
95
 
95
96
  @app.command(help="Move table to a different dataset.")
@@ -182,27 +183,53 @@ def flush(
182
183
  CONSOLE.print(f"Flushed WAL for table {identifier} in project {project}.")
183
184
 
184
185
 
185
- @app.command(help="Display all manifests.")
186
+ @app.command(help="Truncate column group metadata.")
187
+ def truncate(
188
+ project: ProjectArg,
189
+ table: Annotated[str | None, Option(help="Table name.")] = None,
190
+ dataset: Annotated[str | None, Option(help="Dataset name.")] = None,
191
+ ):
192
+ identifier, t = get_table(project, table, dataset)
193
+
194
+ # Ask for confirmation
195
+ confirm = questionary.confirm(
196
+ f"Are you sure you want to truncate metadata for table '{identifier}'? This will break as-of queries."
197
+ ).ask()
198
+ if not confirm:
199
+ CONSOLE.print("Aborted.")
200
+ raise typer.Exit(0)
201
+
202
+ state.spiral.internal.truncate_metadata(t.core)
203
+ CONSOLE.print(f"Truncated metadata for table {identifier} in project {project}.")
204
+
205
+
206
+ @app.command(help="Display all fragments from metadata.", deprecated="Use 'fragments' instead.")
186
207
  def manifests(
187
208
  project: ProjectArg,
188
209
  table: Annotated[str | None, Option(help="Table name.")] = None,
189
210
  dataset: Annotated[str | None, Option(help="Dataset name.")] = None,
190
211
  max_rows: Annotated[int | None, Option(help="Maximum number of rows to show per manifest.")] = None,
191
212
  ):
192
- _, t = get_table(project, table, dataset)
193
- s = t.snapshot()
213
+ fragments(project, table, dataset, max_rows)
194
214
 
195
- key_space_state = state.spiral.internal.key_space_state(s.core) # pyright: ignore[reportPrivateUsage]
196
- key_space_manifest = key_space_state.manifest
197
215
 
198
- column_groups_states = state.spiral.internal.column_groups_states(s.core, key_space_state) # pyright: ignore[reportPrivateUsage]
199
- display_manifests(
200
- key_space_manifest, [(x.column_group, x.manifest) for x in column_groups_states], t.key_schema, max_rows
201
- )
216
+ @app.command(help="Display all fragments from metadata.")
217
+ def fragments(
218
+ project: ProjectArg,
219
+ table: Annotated[str | None, Option(help="Table name.")] = None,
220
+ dataset: Annotated[str | None, Option(help="Dataset name.")] = None,
221
+ max_rows: Annotated[int | None, Option(help="Maximum number of fragments to show per manifest.")] = None,
222
+ ):
223
+ _, t = get_table(project, table, dataset)
224
+ s = t.snapshot()
225
+ cgs = s.core.column_groups()
226
+ key_space_manifest = state.spiral.internal.key_space_manifest(s.core)
227
+ column_group_manifests = [(cg, state.spiral.internal.column_group_manifest(s.core, cg)) for cg in cgs]
228
+ display_manifests(key_space_manifest, column_group_manifests, t.key_schema, max_rows)
202
229
 
203
230
 
204
- @app.command(help="Visualize the scan of a given column group.")
205
- def debug_scan(
231
+ @app.command(help="Display the fragments used in a scan of a given column group.")
232
+ def fragments_scan(
206
233
  project: ProjectArg,
207
234
  table: Annotated[str | None, Option(help="Table name.")] = None,
208
235
  dataset: Annotated[str | None, Option(help="Dataset name.")] = None,
@@ -210,11 +237,11 @@ def debug_scan(
210
237
  ):
211
238
  _, t = get_table(project, table, dataset)
212
239
  scan = state.spiral.scan(t[column_group] if column_group != "." else t)
213
- scan._debug() # pyright: ignore[reportPrivateUsage]
240
+ display_scan_manifests(scan.core)
214
241
 
215
242
 
216
- @app.command(help="Display the manifests for a scan of a given column group.")
217
- def dump_scan(
243
+ @app.command(help="Visualize the scan of a given column group.")
244
+ def debug_scan(
218
245
  project: ProjectArg,
219
246
  table: Annotated[str | None, Option(help="Table name.")] = None,
220
247
  dataset: Annotated[str | None, Option(help="Dataset name.")] = None,
@@ -222,4 +249,4 @@ def dump_scan(
222
249
  ):
223
250
  _, t = get_table(project, table, dataset)
224
251
  scan = state.spiral.scan(t[column_group] if column_group != "." else t)
225
- scan._dump_manifests() # pyright: ignore[reportPrivateUsage]
252
+ scan._debug() # pyright: ignore[reportPrivateUsage]
spiral/cli/telemetry.py CHANGED
@@ -1,4 +1,4 @@
1
- import pyperclip
1
+ import typer
2
2
 
3
3
  from spiral.api.telemetry import IssueExportTokenResponse
4
4
  from spiral.cli import CONSOLE, AsyncTyper, state
@@ -7,11 +7,18 @@ app = AsyncTyper(short_help="Client-side telemetry.")
7
7
 
8
8
 
9
9
  @app.command(help="Issue new telemetry export token.")
10
- def export():
10
+ def export(
11
+ print_token: bool = typer.Option(False, "--print", help="Print the token instead of copying to clipboard."),
12
+ ):
11
13
  res: IssueExportTokenResponse = state.spiral.api.telemetry.issue_export_token()
12
14
 
13
- command = f"export SPIRAL_OTEL_TOKEN={res.token}"
14
- pyperclip.copy(command)
15
+ if print_token:
16
+ print(res.token)
17
+ else:
18
+ import pyperclip
15
19
 
16
- CONSOLE.print("Export command copied to clipboard! Paste and run to set [green]SPIRAL_OTEL_TOKEN[/green].")
17
- CONSOLE.print("[dim]Token is valid for 1h.[/dim]")
20
+ command = f"export SPIRAL_OTEL_TOKEN={res.token}"
21
+ pyperclip.copy(command)
22
+
23
+ CONSOLE.print("Export command copied to clipboard! Paste and run to set [green]SPIRAL_OTEL_TOKEN[/green].")
24
+ CONSOLE.print("[dim]Token is valid for 1h.[/dim]")
spiral/cli/text.py CHANGED
@@ -1,6 +1,5 @@
1
1
  from typing import Annotated
2
2
 
3
- import questionary
4
3
  import rich
5
4
  import typer
6
5
  from questionary import Choice
@@ -11,7 +10,8 @@ from spiral.api.text_indexes import CreateWorkerRequest, SyncIndexRequest
11
10
  from spiral.api.types import IndexId
12
11
  from spiral.api.workers import CPU, GcpRegion, Memory, ResourceClass
13
12
  from spiral.cli import CONSOLE, ERR_CONSOLE, AsyncTyper, state
14
- from spiral.cli.types import ProjectArg
13
+ from spiral.cli.chooser import choose
14
+ from spiral.cli.types_ import ProjectArg
15
15
 
16
16
  app = AsyncTyper(short_help="Text Indexes.")
17
17
 
@@ -23,10 +23,10 @@ def ask_index(project_id, title="Select an index"):
23
23
  ERR_CONSOLE.print("No indexes found")
24
24
  raise typer.Exit(1)
25
25
 
26
- return questionary.select(
26
+ return choose(
27
27
  title,
28
28
  choices=[Choice(title=index.name, value=index.id) for index in sorted(indexes, key=lambda t: (t.name, t.id))],
29
- ).ask()
29
+ )
30
30
 
31
31
 
32
32
  def get_index_id(
@@ -0,0 +1,84 @@
1
+ from datetime import UTC
2
+ from typing import Annotated
3
+
4
+ import questionary
5
+ import rich
6
+ import rich.table
7
+ import typer
8
+ from typer import Option
9
+
10
+ from spiral.cli import CONSOLE, AsyncTyper, state
11
+ from spiral.cli.tables import get_table
12
+ from spiral.cli.types_ import ProjectArg
13
+
14
+ app = AsyncTyper(short_help="Table Transactions.")
15
+
16
+
17
+ @app.command("ls", help="List transactions for a table.")
18
+ def ls(
19
+ project: ProjectArg,
20
+ table: Annotated[str | None, Option(help="Table name.")] = None,
21
+ dataset: Annotated[str | None, Option(help="Dataset name.")] = None,
22
+ since: Annotated[
23
+ int | None, Option(help="List transactions committed after this timestamp (microseconds).")
24
+ ] = None,
25
+ ):
26
+ from datetime import datetime
27
+
28
+ identifier, t = get_table(project, table, dataset)
29
+
30
+ # Get transactions from the API
31
+ transactions = state.spiral.api.tables.list_transactions(
32
+ table_id=t.table_id,
33
+ since=since,
34
+ )
35
+
36
+ if not transactions:
37
+ CONSOLE.print("No transactions found.")
38
+ return
39
+
40
+ # Create a rich table to display transactions
41
+ rich_table = rich.table.Table(
42
+ "Table ID", "Tx Index", "Committed At", "Operations", title=f"Transactions for {identifier}"
43
+ )
44
+
45
+ for txn in transactions:
46
+ # Convert timestamp to readable format
47
+ dt = datetime.fromtimestamp(txn.committed_at / 1_000_000, tz=UTC)
48
+ committed_str = dt.strftime("%Y-%m-%d %H:%M:%S UTC")
49
+
50
+ # Count operations by type
51
+ op_counts: dict[str, int] = {}
52
+ for op in txn.operations:
53
+ # Operation is a dict with a single key indicating the type
54
+ op_type = op["type"]
55
+ op_counts[op_type] = op_counts.get(op_type, 0) + 1
56
+
57
+ op_summary = ", ".join(f"{count}x {op_type}" for op_type, count in op_counts.items())
58
+
59
+ rich_table.add_row(t.table_id, str(txn.txn_idx), committed_str, op_summary)
60
+
61
+ CONSOLE.print(rich_table)
62
+
63
+
64
+ @app.command("revert", help="Revert a transaction by table ID and transaction index.")
65
+ def revert(
66
+ table_id: str,
67
+ txn_idx: int,
68
+ ):
69
+ # Ask for confirmation
70
+ CONSOLE.print(
71
+ "[yellow]Only transactions still in WAL can be reverted. "
72
+ "It is recommended to only revert the latest transaction. "
73
+ "Reverting historical transactions may break a table.[/yellow]"
74
+ )
75
+ confirm = questionary.confirm(
76
+ f"Are you sure you want to revert transaction {txn_idx} for table '{table_id}'?\n"
77
+ ).ask()
78
+ if not confirm:
79
+ CONSOLE.print("Aborted.")
80
+ raise typer.Exit(0)
81
+
82
+ # Revert the transaction
83
+ state.spiral.api.tables.revert_transaction(table_id=table_id, txn_idx=txn_idx)
84
+ CONSOLE.print(f"Successfully reverted transaction {txn_idx} for table {table_id}.")
@@ -1,12 +1,12 @@
1
1
  from typing import Annotated
2
2
 
3
- import questionary
4
3
  import typer
5
4
  from questionary import Choice
6
5
  from typer import Argument
7
6
 
8
7
  from spiral.api.types import OrgId, ProjectId
9
8
  from spiral.cli import ERR_CONSOLE, state
9
+ from spiral.cli.chooser import choose
10
10
 
11
11
 
12
12
  def ask_project(title="Select a project"):
@@ -16,13 +16,13 @@ def ask_project(title="Select a project"):
16
16
  ERR_CONSOLE.print("No projects found")
17
17
  raise typer.Exit(1)
18
18
 
19
- return questionary.select(
19
+ return choose(
20
20
  title,
21
- choices=[
21
+ [
22
22
  Choice(title=f"{project.id} - {project.name}" if project.name else project.id, value=project.id)
23
23
  for project in projects
24
24
  ],
25
- ).ask()
25
+ )
26
26
 
27
27
 
28
28
  ProjectArg = Annotated[ProjectId, Argument(help="Project ID", show_default=False, default_factory=ask_project)]
@@ -35,7 +35,7 @@ def _org_default():
35
35
  ERR_CONSOLE.print("No organizations found")
36
36
  raise typer.Exit(1)
37
37
 
38
- return questionary.select(
38
+ return choose(
39
39
  "Select an organization",
40
40
  choices=[
41
41
  Choice(
@@ -44,7 +44,7 @@ def _org_default():
44
44
  )
45
45
  for m in memberships
46
46
  ],
47
- ).ask()
47
+ )
48
48
 
49
49
 
50
50
  OrganizationArg = Annotated[OrgId, Argument(help="Organization ID", show_default=False, default_factory=_org_default)]
spiral/cli/workloads.py CHANGED
@@ -1,7 +1,6 @@
1
1
  from typing import Annotated
2
2
 
3
3
  import pyperclip
4
- import questionary
5
4
  from questionary import Choice
6
5
  from typer import Argument, Option
7
6
 
@@ -12,7 +11,8 @@ from spiral.api.workloads import (
12
11
  Workload,
13
12
  )
14
13
  from spiral.cli import CONSOLE, ERR_CONSOLE, AsyncTyper, printer, state
15
- from spiral.cli.types import ProjectArg
14
+ from spiral.cli.chooser import choose
15
+ from spiral.cli.types_ import ProjectArg
16
16
 
17
17
  app = AsyncTyper()
18
18
 
@@ -54,14 +54,14 @@ def issue_creds(
54
54
  CONSOLE.print(f"[green]SPIRAL_CLIENT_SECRET[/green] {res.client_secret}")
55
55
  else:
56
56
  while True:
57
- choice = questionary.select(
57
+ choice = choose(
58
58
  "What would you like to do with the secret? You will not be able to see this secret again!",
59
59
  choices=[
60
60
  Choice(title="Copy to clipboard", value=1),
61
61
  Choice(title="Print to console", value=2),
62
62
  Choice(title="Exit", value=3),
63
63
  ],
64
- ).ask()
64
+ )
65
65
 
66
66
  if choice == 1:
67
67
  pyperclip.copy(res.client_secret)
spiral/client.py CHANGED
@@ -8,7 +8,7 @@ import pyarrow as pa
8
8
  from spiral.api import SpiralAPI
9
9
  from spiral.api.projects import CreateProjectRequest, CreateProjectResponse
10
10
  from spiral.core.authn import Authn
11
- from spiral.core.client import Internal, Shard
11
+ from spiral.core.client import Internal, KeyColumns, Shard
12
12
  from spiral.core.client import Spiral as CoreSpiral
13
13
  from spiral.core.config import ClientSettings
14
14
  from spiral.datetime_ import timestamp_micros
@@ -157,6 +157,7 @@ class Spiral:
157
157
  where: ExprLike | None = None,
158
158
  asof: datetime | int | None = None,
159
159
  shard: Shard | None = None,
160
+ hide_progress_bar: bool = False,
160
161
  ) -> Scan:
161
162
  """Starts a read transaction on the Spiral.
162
163
 
@@ -167,7 +168,61 @@ class Spiral:
167
168
  shard: if provided, opens the scan only for the given shard.
168
169
  While shards can be provided when executing the scan, providing a shard here
169
170
  optimizes the scan planning phase and can significantly reduce metadata download.
171
+ hide_progress_bar: if True, disables the progress bar during scan building.
170
172
  """
173
+ return self._scan_internal(
174
+ *projections,
175
+ where=where,
176
+ asof=asof,
177
+ shard=shard,
178
+ hide_progress_bar=hide_progress_bar,
179
+ )
180
+
181
+ def scan_keys(
182
+ self,
183
+ *projections: ExprLike,
184
+ where: ExprLike | None = None,
185
+ asof: datetime | int | None = None,
186
+ shard: Shard | None = None,
187
+ hide_progress_bar: bool = False,
188
+ ) -> Scan:
189
+ """Starts a keys-only read transaction on the Spiral.
190
+
191
+ To determine which keys are present in at least one column group of the table, key scan the
192
+ table itself:
193
+
194
+ ```
195
+ sp.scan_keys(table)
196
+ ```
197
+
198
+ Args:
199
+ projections: scan the keys of the column groups referenced by these expressions.
200
+ where: a query expression to apply to the data.
201
+ asof: execute the scan on the version of the table as of the given timestamp.
202
+ shard: if provided, opens the scan only for the given shard.
203
+ While shards can be provided when executing the scan, providing a shard here
204
+ optimizes the scan planning phase and can significantly reduce metadata download.
205
+ hide_progress_bar: if True, disables the progress bar during scan building.
206
+
207
+ """
208
+ return self._scan_internal(
209
+ *projections,
210
+ where=where,
211
+ asof=asof,
212
+ shard=shard,
213
+ hide_progress_bar=hide_progress_bar,
214
+ key_columns=KeyColumns.Only,
215
+ )
216
+
217
+ def _scan_internal(
218
+ self,
219
+ *projections: ExprLike,
220
+ where: ExprLike | None = None,
221
+ asof: datetime | int | None = None,
222
+ shard: Shard | None = None,
223
+ key_columns: KeyColumns | None = None,
224
+ hide_progress_bar: bool = False,
225
+ ) -> Scan:
171
226
  from spiral import expressions as se
172
227
 
173
228
  if isinstance(asof, datetime):
@@ -182,7 +237,14 @@ class Spiral:
182
237
 
183
238
  return Scan(
184
239
  self,
185
- self.core.scan(projection.__expr__, filter=where.__expr__ if where else None, asof=asof, shard=shard),
240
+ self.core.scan(
241
+ projection.__expr__,
242
+ filter=where.__expr__ if where else None,
243
+ asof=asof,
244
+ shard=shard,
245
+ key_columns=key_columns,
246
+ progress=(not hide_progress_bar),
247
+ ),
186
248
  )
187
249
 
188
250
  # TODO(marko): This should be query, and search should be query + scan.
@@ -223,16 +285,16 @@ class Spiral:
223
285
  freshness_window_s=freshness_window_s,
224
286
  )
225
287
 
226
- def resume_scan(self, state_json: str) -> Scan:
227
- """Resumes a previously started scan using its scan state.
288
+ def resume_scan(self, context_bytes: bytes) -> Scan:
289
+ """Resumes a previously started scan using its scan context.
228
290
 
229
291
  Args:
230
- state_json: The scan state returned by a previous scan.
292
+ context_bytes: The compressed scan context returned by a previous scan.
231
293
  """
232
- from spiral.core.table import ScanState
294
+ from spiral.core.table import ScanContext
233
295
 
234
- state = ScanState.from_json(state_json)
235
- return Scan(self, self.core.load_scan(state))
296
+ context = ScanContext.from_bytes_compressed(context_bytes)
297
+ return Scan(self, self.core.load_scan(context))
236
298
 
237
299
  def compute_shards(
238
300
  self,