vecshift 0.2.0__tar.gz → 0.3.0__tar.gz

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 (122) hide show
  1. {vecshift-0.2.0 → vecshift-0.3.0}/CHANGELOG.md +14 -1
  2. {vecshift-0.2.0 → vecshift-0.3.0}/PKG-INFO +3 -1
  3. {vecshift-0.2.0 → vecshift-0.3.0}/pyproject.toml +2 -0
  4. {vecshift-0.2.0 → vecshift-0.3.0}/src/vecshift/__init__.py +1 -1
  5. {vecshift-0.2.0 → vecshift-0.3.0}/src/vecshift/cli.py +36 -2
  6. {vecshift-0.2.0 → vecshift-0.3.0}/src/vecshift/cli_apply.py +158 -2
  7. {vecshift-0.2.0 → vecshift-0.3.0}/src/vecshift/cli_bench.py +65 -10
  8. {vecshift-0.2.0 → vecshift-0.3.0}/src/vecshift/cli_cutover.py +47 -26
  9. {vecshift-0.2.0 → vecshift-0.3.0}/src/vecshift/cli_eval.py +166 -3
  10. {vecshift-0.2.0 → vecshift-0.3.0}/src/vecshift/cli_init.py +84 -99
  11. {vecshift-0.2.0 → vecshift-0.3.0}/src/vecshift/cli_plan.py +83 -26
  12. {vecshift-0.2.0 → vecshift-0.3.0}/src/vecshift/cli_status.py +102 -1
  13. {vecshift-0.2.0 → vecshift-0.3.0}/src/vecshift/cli_style.py +5 -29
  14. vecshift-0.3.0/src/vecshift/ui.py +432 -0
  15. {vecshift-0.2.0 → vecshift-0.3.0}/tests/test_security.py +8 -3
  16. vecshift-0.3.0/tests/test_ui.py +139 -0
  17. {vecshift-0.2.0 → vecshift-0.3.0}/.github/workflows/ci.yml +0 -0
  18. {vecshift-0.2.0 → vecshift-0.3.0}/.github/workflows/release.yml +0 -0
  19. {vecshift-0.2.0 → vecshift-0.3.0}/.gitignore +0 -0
  20. {vecshift-0.2.0 → vecshift-0.3.0}/Dockerfile +0 -0
  21. {vecshift-0.2.0 → vecshift-0.3.0}/LICENSE +0 -0
  22. {vecshift-0.2.0 → vecshift-0.3.0}/NOTICE +0 -0
  23. {vecshift-0.2.0 → vecshift-0.3.0}/README.md +0 -0
  24. {vecshift-0.2.0 → vecshift-0.3.0}/RELEASING.md +0 -0
  25. {vecshift-0.2.0 → vecshift-0.3.0}/SECURITY.md +0 -0
  26. {vecshift-0.2.0 → vecshift-0.3.0}/demo/compose.yaml +0 -0
  27. {vecshift-0.2.0 → vecshift-0.3.0}/demo/out/.gitignore +0 -0
  28. {vecshift-0.2.0 → vecshift-0.3.0}/demo/run.sh +0 -0
  29. {vecshift-0.2.0 → vecshift-0.3.0}/demo/seed.py +0 -0
  30. {vecshift-0.2.0 → vecshift-0.3.0}/docs/architecture.md +0 -0
  31. {vecshift-0.2.0 → vecshift-0.3.0}/docs/bench.md +0 -0
  32. {vecshift-0.2.0 → vecshift-0.3.0}/docs/connectors/pgvector.md +0 -0
  33. {vecshift-0.2.0 → vecshift-0.3.0}/docs/demo.md +0 -0
  34. {vecshift-0.2.0 → vecshift-0.3.0}/docs/eval.md +0 -0
  35. {vecshift-0.2.0 → vecshift-0.3.0}/docs/images/doctor-report.png +0 -0
  36. {vecshift-0.2.0 → vecshift-0.3.0}/docs/images/eval-report.png +0 -0
  37. {vecshift-0.2.0 → vecshift-0.3.0}/docs/images/logo/vecshift-dark.svg +0 -0
  38. {vecshift-0.2.0 → vecshift-0.3.0}/docs/images/logo/vecshift-light.svg +0 -0
  39. {vecshift-0.2.0 → vecshift-0.3.0}/docs/images/logo/vecshift-mark-dark.svg +0 -0
  40. {vecshift-0.2.0 → vecshift-0.3.0}/docs/images/logo/vecshift-mark-light.svg +0 -0
  41. {vecshift-0.2.0 → vecshift-0.3.0}/docs/images/logo/vecshift-mark.svg +0 -0
  42. {vecshift-0.2.0 → vecshift-0.3.0}/docs/images/social-preview.png +0 -0
  43. {vecshift-0.2.0 → vecshift-0.3.0}/docs/migrations.md +0 -0
  44. {vecshift-0.2.0 → vecshift-0.3.0}/docs/prior-art.md +0 -0
  45. {vecshift-0.2.0 → vecshift-0.3.0}/docs/roadmap.md +0 -0
  46. {vecshift-0.2.0 → vecshift-0.3.0}/docs/security.md +0 -0
  47. {vecshift-0.2.0 → vecshift-0.3.0}/scripts/release_notes.py +0 -0
  48. {vecshift-0.2.0 → vecshift-0.3.0}/src/vecshift/assets/bench.css +0 -0
  49. {vecshift-0.2.0 → vecshift-0.3.0}/src/vecshift/assets/eval.css +0 -0
  50. {vecshift-0.2.0 → vecshift-0.3.0}/src/vecshift/assets/report.css +0 -0
  51. {vecshift-0.2.0 → vecshift-0.3.0}/src/vecshift/assets/report.js +0 -0
  52. {vecshift-0.2.0 → vecshift-0.3.0}/src/vecshift/bench/__init__.py +0 -0
  53. {vecshift-0.2.0 → vecshift-0.3.0}/src/vecshift/bench/corpus.py +0 -0
  54. {vecshift-0.2.0 → vecshift-0.3.0}/src/vecshift/bench/generate.py +0 -0
  55. {vecshift-0.2.0 → vecshift-0.3.0}/src/vecshift/bench/html.py +0 -0
  56. {vecshift-0.2.0 → vecshift-0.3.0}/src/vecshift/bench/metrics.py +0 -0
  57. {vecshift-0.2.0 → vecshift-0.3.0}/src/vecshift/bench/runner.py +0 -0
  58. {vecshift-0.2.0 → vecshift-0.3.0}/src/vecshift/connectors/__init__.py +0 -0
  59. {vecshift-0.2.0 → vecshift-0.3.0}/src/vecshift/connectors/pgvector/__init__.py +0 -0
  60. {vecshift-0.2.0 → vecshift-0.3.0}/src/vecshift/connectors/pgvector/connection.py +0 -0
  61. {vecshift-0.2.0 → vecshift-0.3.0}/src/vecshift/connectors/pgvector/documents.py +0 -0
  62. {vecshift-0.2.0 → vecshift-0.3.0}/src/vecshift/connectors/pgvector/inspect.py +0 -0
  63. {vecshift-0.2.0 → vecshift-0.3.0}/src/vecshift/connectors/pgvector/search.py +0 -0
  64. {vecshift-0.2.0 → vecshift-0.3.0}/src/vecshift/connectors/pgvector/switch.py +0 -0
  65. {vecshift-0.2.0 → vecshift-0.3.0}/src/vecshift/connectors/pgvector/target.py +0 -0
  66. {vecshift-0.2.0 → vecshift-0.3.0}/src/vecshift/connectors/pgvector/writer.py +0 -0
  67. {vecshift-0.2.0 → vecshift-0.3.0}/src/vecshift/core/__init__.py +0 -0
  68. {vecshift-0.2.0 → vecshift-0.3.0}/src/vecshift/core/capabilities.py +0 -0
  69. {vecshift-0.2.0 → vecshift-0.3.0}/src/vecshift/core/contracts.py +0 -0
  70. {vecshift-0.2.0 → vecshift-0.3.0}/src/vecshift/core/fingerprint.py +0 -0
  71. {vecshift-0.2.0 → vecshift-0.3.0}/src/vecshift/core/record.py +0 -0
  72. {vecshift-0.2.0 → vecshift-0.3.0}/src/vecshift/doctor/__init__.py +0 -0
  73. {vecshift-0.2.0 → vecshift-0.3.0}/src/vecshift/doctor/checks.py +0 -0
  74. {vecshift-0.2.0 → vecshift-0.3.0}/src/vecshift/doctor/findings.py +0 -0
  75. {vecshift-0.2.0 → vecshift-0.3.0}/src/vecshift/doctor/html.py +0 -0
  76. {vecshift-0.2.0 → vecshift-0.3.0}/src/vecshift/doctor/profile.py +0 -0
  77. {vecshift-0.2.0 → vecshift-0.3.0}/src/vecshift/embeddings/__init__.py +0 -0
  78. {vecshift-0.2.0 → vecshift-0.3.0}/src/vecshift/embeddings/cache.py +0 -0
  79. {vecshift-0.2.0 → vecshift-0.3.0}/src/vecshift/embeddings/providers.py +0 -0
  80. {vecshift-0.2.0 → vecshift-0.3.0}/src/vecshift/embeddings/spec.py +0 -0
  81. {vecshift-0.2.0 → vecshift-0.3.0}/src/vecshift/envfile.py +0 -0
  82. {vecshift-0.2.0 → vecshift-0.3.0}/src/vecshift/eval/__init__.py +0 -0
  83. {vecshift-0.2.0 → vecshift-0.3.0}/src/vecshift/eval/html.py +0 -0
  84. {vecshift-0.2.0 → vecshift-0.3.0}/src/vecshift/eval/metrics.py +0 -0
  85. {vecshift-0.2.0 → vecshift-0.3.0}/src/vecshift/eval/queries.py +0 -0
  86. {vecshift-0.2.0 → vecshift-0.3.0}/src/vecshift/eval/runner.py +0 -0
  87. {vecshift-0.2.0 → vecshift-0.3.0}/src/vecshift/html_kit.py +0 -0
  88. {vecshift-0.2.0 → vecshift-0.3.0}/src/vecshift/jobs/__init__.py +0 -0
  89. {vecshift-0.2.0 → vecshift-0.3.0}/src/vecshift/jobs/spec.py +0 -0
  90. {vecshift-0.2.0 → vecshift-0.3.0}/src/vecshift/migrate/__init__.py +0 -0
  91. {vecshift-0.2.0 → vecshift-0.3.0}/src/vecshift/migrate/engine.py +0 -0
  92. {vecshift-0.2.0 → vecshift-0.3.0}/src/vecshift/migrate/state.py +0 -0
  93. {vecshift-0.2.0 → vecshift-0.3.0}/src/vecshift/planning/__init__.py +0 -0
  94. {vecshift-0.2.0 → vecshift-0.3.0}/src/vecshift/planning/plan.py +0 -0
  95. {vecshift-0.2.0 → vecshift-0.3.0}/src/vecshift/planning/planner.py +0 -0
  96. {vecshift-0.2.0 → vecshift-0.3.0}/src/vecshift/py.typed +0 -0
  97. {vecshift-0.2.0 → vecshift-0.3.0}/tests/__init__.py +0 -0
  98. {vecshift-0.2.0 → vecshift-0.3.0}/tests/integration/__init__.py +0 -0
  99. {vecshift-0.2.0 → vecshift-0.3.0}/tests/integration/conftest.py +0 -0
  100. {vecshift-0.2.0 → vecshift-0.3.0}/tests/integration/test_pgvector_apply.py +0 -0
  101. {vecshift-0.2.0 → vecshift-0.3.0}/tests/integration/test_pgvector_bench.py +0 -0
  102. {vecshift-0.2.0 → vecshift-0.3.0}/tests/integration/test_pgvector_cutover.py +0 -0
  103. {vecshift-0.2.0 → vecshift-0.3.0}/tests/integration/test_pgvector_doctor.py +0 -0
  104. {vecshift-0.2.0 → vecshift-0.3.0}/tests/integration/test_pgvector_eval.py +0 -0
  105. {vecshift-0.2.0 → vecshift-0.3.0}/tests/integration/test_pgvector_init_status.py +0 -0
  106. {vecshift-0.2.0 → vecshift-0.3.0}/tests/integration/test_pgvector_plan.py +0 -0
  107. {vecshift-0.2.0 → vecshift-0.3.0}/tests/test_bench.py +0 -0
  108. {vecshift-0.2.0 → vecshift-0.3.0}/tests/test_cli.py +0 -0
  109. {vecshift-0.2.0 → vecshift-0.3.0}/tests/test_contracts.py +0 -0
  110. {vecshift-0.2.0 → vecshift-0.3.0}/tests/test_doctor_checks.py +0 -0
  111. {vecshift-0.2.0 → vecshift-0.3.0}/tests/test_doctor_html.py +0 -0
  112. {vecshift-0.2.0 → vecshift-0.3.0}/tests/test_embeddings.py +0 -0
  113. {vecshift-0.2.0 → vecshift-0.3.0}/tests/test_envfile.py +0 -0
  114. {vecshift-0.2.0 → vecshift-0.3.0}/tests/test_eval.py +0 -0
  115. {vecshift-0.2.0 → vecshift-0.3.0}/tests/test_eval_html.py +0 -0
  116. {vecshift-0.2.0 → vecshift-0.3.0}/tests/test_fingerprint.py +0 -0
  117. {vecshift-0.2.0 → vecshift-0.3.0}/tests/test_jobs.py +0 -0
  118. {vecshift-0.2.0 → vecshift-0.3.0}/tests/test_migrate.py +0 -0
  119. {vecshift-0.2.0 → vecshift-0.3.0}/tests/test_pgvector_connection.py +0 -0
  120. {vecshift-0.2.0 → vecshift-0.3.0}/tests/test_planner.py +0 -0
  121. {vecshift-0.2.0 → vecshift-0.3.0}/tests/test_record.py +0 -0
  122. {vecshift-0.2.0 → vecshift-0.3.0}/tests/test_release.py +0 -0
@@ -7,6 +7,18 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.0] - 2026-10-10
11
+
12
+ ### Changed
13
+
14
+ - A new look at the terminal, across every command: a gradient block wordmark, numbered
15
+ steps and arrow-key menus in `vecshift init` (with hidden input for secrets), a panel
16
+ with the migration's progress in `vecshift status`, a live progress bar in `apply`,
17
+ highlighted SQL in `plan`, tables in `eval` and `bench`, and consistent headers,
18
+ findings, verdicts, and next steps everywhere. Off a terminal, output stays plain and
19
+ prompts fall back to typed answers, as before.
20
+ - New dependency: `questionary`, for the arrow-key menus.
21
+
10
22
  ## [0.2.0] - 2026-10-10
11
23
 
12
24
  ### Added
@@ -149,6 +161,7 @@ The first release.
149
161
  - `Capability` flags and plugin contracts for sources, targets, and embedding providers.
150
162
  - `vecshift fingerprint` and `vecshift --version` commands.
151
163
 
152
- [Unreleased]: https://github.com/Osamamu64/vecshift/compare/v0.2.0...HEAD
164
+ [Unreleased]: https://github.com/Osamamu64/vecshift/compare/v0.3.0...HEAD
165
+ [0.3.0]: https://github.com/Osamamu64/vecshift/compare/v0.2.0...v0.3.0
153
166
  [0.2.0]: https://github.com/Osamamu64/vecshift/compare/v0.1.0...v0.2.0
154
167
  [0.1.0]: https://github.com/Osamamu64/vecshift/releases/tag/v0.1.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: vecshift
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Zero-downtime embedding model migrations for pgvector and Supabase: plan, re-embed, evaluate, and cut over safely.
5
5
  Project-URL: Homepage, https://github.com/Osamamu64/vecshift
6
6
  Project-URL: Repository, https://github.com/Osamamu64/vecshift
@@ -31,6 +31,8 @@ Requires-Dist: numpy>=1.26
31
31
  Requires-Dist: psycopg[binary]>=3.2
32
32
  Requires-Dist: pydantic>=2.6
33
33
  Requires-Dist: pyyaml>=6
34
+ Requires-Dist: questionary>=2.0
35
+ Requires-Dist: rich>=13
34
36
  Requires-Dist: typer>=0.12
35
37
  Description-Content-Type: text/markdown
36
38
 
@@ -32,6 +32,8 @@ dependencies = [
32
32
  "psycopg[binary]>=3.2",
33
33
  "pydantic>=2.6",
34
34
  "pyyaml>=6",
35
+ "questionary>=2.0",
36
+ "rich>=13",
35
37
  "typer>=0.12",
36
38
  ]
37
39
 
@@ -4,7 +4,7 @@ from vecshift.core.capabilities import Capability
4
4
  from vecshift.core.fingerprint import EmbeddingFingerprint
5
5
  from vecshift.core.record import Record, SparseVector
6
6
 
7
- __version__ = "0.2.0"
7
+ __version__ = "0.3.0"
8
8
 
9
9
  __all__ = [
10
10
  "Capability",
@@ -11,7 +11,7 @@ import typer
11
11
 
12
12
  from vecshift import __version__
13
13
  from vecshift.cli_style import STYLE as _STYLE
14
- from vecshift.cli_style import banner, warn_if_password_on_command_line
14
+ from vecshift.cli_style import warn_if_password_on_command_line
15
15
  from vecshift.cli_style import wrap as _wrap
16
16
  from vecshift.core.fingerprint import EmbeddingFingerprint
17
17
  from vecshift.doctor import Report, Severity, run_checks
@@ -80,7 +80,9 @@ def main(
80
80
  """Safe, observable embedding migrations for any vector store."""
81
81
  _load_env_file(None if no_env_file else env_file)
82
82
  if ctx.invoked_subcommand is None:
83
- banner(__version__, TAGLINE)
83
+ from vecshift import ui
84
+
85
+ ui.banner(__version__, TAGLINE)
84
86
  typer.echo(ctx.get_help())
85
87
 
86
88
 
@@ -119,7 +121,39 @@ class FailOn(StrEnum):
119
121
  ERROR = "error"
120
122
 
121
123
 
124
+ def _render_fancy(report: Report, connection: str) -> None:
125
+ from rich.text import Text
126
+
127
+ from vecshift import ui
128
+
129
+ dims = f"({report.declared_dimensions})" if report.declared_dimensions else ""
130
+ rows = f"~{report.estimated_rows:,}" if report.estimated_rows is not None else "unknown"
131
+ ui.title("doctor", f"{report.store} via {connection}")
132
+ ui.kv(
133
+ [
134
+ ("Target", Text.assemble(report.target, (f" {report.vector_type}{dims}", "dim")), ""),
135
+ ("Rows", rows, f"inspected {report.sample_rows:,}, {report.sample_method}"),
136
+ ]
137
+ )
138
+ ui.section("Findings")
139
+ ui.findings(report.sorted_findings())
140
+ summary = Text(" ")
141
+ for severity, (_, _, name) in _STYLE.items():
142
+ n = report.count(severity)
143
+ icon, colour, _ = ui.SEVERITY_STYLE[severity.value]
144
+ plural = "s" if n != 1 and name in ("error", "warning") else ""
145
+ summary.append(f"{icon} {n} {name}{plural} ", style=colour if n else "dim")
146
+ ui.console.print()
147
+ ui.console.print(summary)
148
+ ui.console.print()
149
+
150
+
122
151
  def _render(report: Report, connection: str) -> None:
152
+ from vecshift import ui
153
+
154
+ if ui.fancy():
155
+ _render_fancy(report, connection)
156
+ return
123
157
  dims = f"({report.declared_dimensions})" if report.declared_dimensions else ""
124
158
  rows = f"~{report.estimated_rows:,}" if report.estimated_rows is not None else "unknown"
125
159
  typer.secho(f"vecshift doctor · {report.store} via {connection}", bold=True)
@@ -83,6 +83,82 @@ class _Progress:
83
83
  self.last_print = time.monotonic()
84
84
 
85
85
 
86
+ class _FancyProgress:
87
+ """A live progress bar and spinners, for a person at a colour terminal."""
88
+
89
+ def __init__(self) -> None:
90
+ from rich.progress import (
91
+ BarColumn,
92
+ MofNCompleteColumn,
93
+ Progress,
94
+ SpinnerColumn,
95
+ TextColumn,
96
+ TimeRemainingColumn,
97
+ )
98
+
99
+ from vecshift import ui
100
+
101
+ self.ui = ui
102
+ self.started = time.monotonic()
103
+ self.progress = Progress(
104
+ SpinnerColumn(style=ui.ACCENT),
105
+ TextColumn("{task.description}"),
106
+ BarColumn(complete_style=ui.ACCENT, finished_style="green", bar_width=32),
107
+ MofNCompleteColumn(),
108
+ TextColumn("[dim]{task.fields[rate]}"),
109
+ TextColumn("[dim]{task.fields[cost]}"),
110
+ TimeRemainingColumn(compact=True),
111
+ console=ui.console,
112
+ transient=False,
113
+ )
114
+ self.task: Any = None
115
+ self.status: Any = None
116
+ self.done = 0
117
+
118
+ def __call__(self, event: Event) -> None:
119
+ ui, d = self.ui, event.data
120
+ if event.kind == "column" and d["added"]:
121
+ ui.success("Added the new column")
122
+ elif event.kind == "trigger" and d["added"]:
123
+ ui.success("Added the sync trigger")
124
+ elif event.kind == "start":
125
+ ui.note(f" {d['pending']:,} rows need a new vector")
126
+ self.progress.start()
127
+ self.task = self.progress.add_task("Embedding", total=d["pending"], rate="", cost="")
128
+ elif event.kind == "batch" and self.task is not None:
129
+ self.done = d["rows_written"]
130
+ rate = self.done / max(time.monotonic() - self.started, 1e-6)
131
+ cost = _money(d["spent_usd"]) if d["spent_usd"] is not None else ""
132
+ self.progress.update(
133
+ self.task, completed=self.done, rate=f"{rate:,.0f} rows/s", cost=cost
134
+ )
135
+ elif event.kind == "pass" and d["number"] > 1 and self.task is not None:
136
+ self.progress.update(
137
+ self.task, description="Catching up", total=self.done + d["remaining"]
138
+ )
139
+ elif event.kind == "index":
140
+ self.close()
141
+ if d["state"] == "building":
142
+ self.status = ui.console.status(
143
+ f"Building the {d['method']} index concurrently. Writes continue…",
144
+ spinner="dots",
145
+ spinner_style=ui.ACCENT,
146
+ )
147
+ self.status.start()
148
+ else:
149
+ ui.success(f"Index {d['state']}")
150
+ elif event.kind == "done":
151
+ self.close()
152
+
153
+ def close(self) -> None:
154
+ if self.task is not None:
155
+ self.progress.stop()
156
+ self.task = None
157
+ if self.status is not None:
158
+ self.status.stop()
159
+ self.status = None
160
+
161
+
86
162
  def _eta(seconds: float) -> str:
87
163
  if seconds < 90:
88
164
  return f"{max(1, round(seconds))} s"
@@ -91,7 +167,44 @@ def _eta(seconds: float) -> str:
91
167
  return f"{seconds / 3600:.1f} h"
92
168
 
93
169
 
170
+ def _summary_fancy(result: ApplyResult) -> None:
171
+ from vecshift import ui
172
+
173
+ kind = {"complete": "ok", "failed": "error"}.get(result.status, "warn")
174
+ ui.verdict(f"Apply {result.status}.", kind)
175
+ if result.message:
176
+ ui.detail(result.message)
177
+ spent = _money(result.spent_usd) if result.spent_usd is not None else "unknown"
178
+ total = _money(result.total_spent_usd) if result.total_spent_usd is not None else ""
179
+ ui.console.print()
180
+ ui.kv(
181
+ [
182
+ ("Rows written", f"{result.rows_written:,}", ""),
183
+ ("Tokens", f"{result.tokens:,}", ""),
184
+ ("Spent", spent, f"{total} across runs" if total else ""),
185
+ ("Time", f"{result.seconds:,.0f} s", ""),
186
+ ]
187
+ + (
188
+ [("Rejected", f"{result.rows_failed:,} rows", "retried on the next run")]
189
+ if result.rows_failed
190
+ else []
191
+ )
192
+ )
193
+ if result.status == "complete":
194
+ ui.next_step(
195
+ "check it's safe to switch with vecshift cutover --check, then run vecshift "
196
+ "cutover. It embeds rows added or edited since this run first."
197
+ )
198
+ elif result.status in {"stopped", "budget"}:
199
+ ui.next_step("run vecshift apply again to continue where it stopped.")
200
+
201
+
94
202
  def _summary(result: ApplyResult) -> None:
203
+ from vecshift import ui
204
+
205
+ if ui.fancy():
206
+ _summary_fancy(result)
207
+ return
95
208
  color = {"complete": typer.colors.GREEN, "failed": typer.colors.RED}.get(
96
209
  result.status, typer.colors.YELLOW
97
210
  )
@@ -169,7 +282,39 @@ def apply(
169
282
  state = JobState.for_job(job_file, job.name)
170
283
  est = plan.estimates
171
284
  warnings = [f for f in plan.findings if f.severity.rank == 2]
172
- if not output_json:
285
+ from vecshift import ui
286
+
287
+ fancy = ui.fancy() and not output_json
288
+ if fancy:
289
+ from rich.text import Text
290
+
291
+ from vecshift.cli_plan import CHANGE_MARKS
292
+
293
+ ui.title("apply", job.name)
294
+ for change in plan.changes:
295
+ if change.kind != "cutover":
296
+ mark, colour = CHANGE_MARKS.get(change.kind, ("·", "dim"))
297
+ ui.console.print(Text.assemble((f" {mark} ", f"bold {colour}"), change.summary))
298
+ ui.console.print()
299
+ rows: list[tuple[str, str, str]] = [
300
+ ("Estimated cost", _money(est.cost_usd), ""),
301
+ ("New column", _size(est.new_bytes), ""),
302
+ ]
303
+ if job.limits.budget_usd:
304
+ rows.append(("Budget", f"${job.limits.budget_usd:,.2f}", "apply stops there"))
305
+ if state.runs:
306
+ rows.append(
307
+ (
308
+ "Resuming",
309
+ f"{state.rows_written:,} rows",
310
+ f"written in earlier runs, {_money(state.spent_usd)} spent",
311
+ )
312
+ )
313
+ ui.kv(rows)
314
+ if warnings:
315
+ ui.console.print()
316
+ ui.findings(warnings)
317
+ elif not output_json:
173
318
  typer.secho(f"vecshift apply · {job.name}", bold=True)
174
319
  for change in plan.changes:
175
320
  if change.kind != "cutover":
@@ -184,7 +329,14 @@ def apply(
184
329
  )
185
330
  if warnings:
186
331
  finding_lines(warnings)
187
- if not yes:
332
+ if not yes and fancy:
333
+ where = "your machine" if spec.is_local else spec.url
334
+ ui.console.print()
335
+ ui.note(f" This changes {plan.source.split()[0]} and sends row text to {where}.")
336
+ if not ui.confirm("Apply?", default=False):
337
+ raise typer.Exit(1)
338
+ ui.console.print()
339
+ elif not yes:
188
340
  where = "your machine" if spec.is_local else spec.url
189
341
  typer.echo(
190
342
  f"\nThis changes {plan.source.split()[0]} and sends row text to {where}.",
@@ -221,6 +373,8 @@ def apply(
221
373
  def on_event(event: Event) -> None:
222
374
  typer.echo(json.dumps({"event": event.kind, **event.data}))
223
375
 
376
+ elif fancy:
377
+ on_event = _FancyProgress()
224
378
  else:
225
379
  on_event = _Progress(live=sys.stdout.isatty())
226
380
  try:
@@ -255,6 +409,8 @@ def apply(
255
409
  )
256
410
  raise _fail(f"The database reported an error: {detail}", hint) from exc
257
411
  finally:
412
+ if isinstance(on_event, _FancyProgress):
413
+ on_event.close()
258
414
  signal.signal(signal.SIGINT, previous)
259
415
  conn.close()
260
416
 
@@ -6,7 +6,7 @@ import asyncio
6
6
  import json
7
7
  import sys
8
8
  from pathlib import Path
9
- from typing import TYPE_CHECKING, Annotated
9
+ from typing import TYPE_CHECKING, Annotated, Any
10
10
 
11
11
  import typer
12
12
 
@@ -28,7 +28,12 @@ def _fail(message: str, hint: str | None = None) -> typer.Exit:
28
28
 
29
29
 
30
30
  def _note(message: str) -> None:
31
- typer.secho(message, err=True, dim=True)
31
+ from vecshift import ui
32
+
33
+ if ui.fancy():
34
+ ui.note(f" · {message}")
35
+ else:
36
+ typer.secho(message, err=True, dim=True)
32
37
 
33
38
 
34
39
  def money(value: float | None) -> str:
@@ -48,8 +53,30 @@ def _ms(value: float | None) -> str:
48
53
 
49
54
 
50
55
  def _confirm(items: list[PlanItem], generator: ModelSpec | None, n_docs: int, yes: bool) -> None:
56
+ from vecshift import ui
57
+
51
58
  remote = [i for i in items if not i.spec.is_local]
52
59
  remote_gen = generator is not None and not generator.is_local
60
+ if ui.fancy():
61
+ ui.title("bench")
62
+ rows: list[list[str | Any]] = [
63
+ [
64
+ item.spec.name,
65
+ f"~{item.est_tokens:,}",
66
+ money(item.est_cost) if item.est_cost is not None else "unknown price",
67
+ "local" if item.spec.is_local else (item.spec.url or ""),
68
+ ]
69
+ for item in items
70
+ ]
71
+ if generator is not None:
72
+ rows.append([generator.name, "generates queries", "", generator.url or ""])
73
+ ui.table(["Model", "Tokens", "Cost", "Where"], rows, right=frozenset({1, 2}))
74
+ if not remote and not remote_gen:
75
+ return
76
+ ui.warn(f" This sends the text of {n_docs:,} sampled documents to the services above.")
77
+ if not yes and not ui.confirm("Continue?", default=False):
78
+ raise typer.Exit(1)
79
+ return
53
80
  typer.echo("Plan", err=True)
54
81
  for item in items:
55
82
  where = "local" if item.spec.is_local else (item.spec.url or "")
@@ -74,13 +101,17 @@ def _confirm(items: list[PlanItem], generator: ModelSpec | None, n_docs: int, ye
74
101
 
75
102
 
76
103
  def _render(result: BenchResult) -> None:
104
+ from vecshift import ui
77
105
  from vecshift.bench.runner import ModelResult
78
106
 
79
- typer.secho(
80
- f"vecshift bench · {result.documents:,} documents, {result.queries:,} queries "
81
- f"({result.query_source}) from {result.source}",
82
- bold=True,
83
- )
107
+ fancy = ui.fancy()
108
+
109
+ if not fancy:
110
+ typer.secho(
111
+ f"vecshift bench · {result.documents:,} documents, {result.queries:,} queries "
112
+ f"({result.query_source}) from {result.source}",
113
+ bold=True,
114
+ )
84
115
  rows: list[tuple[str, ...]] = []
85
116
  ranked = result.ranked()
86
117
  for rank, m in enumerate(ranked, 1):
@@ -115,7 +146,23 @@ def _render(result: BenchResult) -> None:
115
146
  "Per 1M docs",
116
147
  "Per 1M vecs",
117
148
  )
118
- if rows:
149
+ if rows and fancy:
150
+ from rich.text import Text
151
+
152
+ ui.section(
153
+ f"Results · {result.documents:,} documents, {result.queries:,} queries "
154
+ f"({result.query_source}) from {result.source}"
155
+ )
156
+ styled: list[list[str | Text]] = [
157
+ [
158
+ Text(r[0], style="bold green" if r[0] == "1" else "dim"),
159
+ Text(r[1], style="bold"),
160
+ *r[2:],
161
+ ]
162
+ for r in rows
163
+ ]
164
+ ui.table(header, styled, right=frozenset({0, 2, 3, 4, 5, 6, 7, 8}))
165
+ elif rows:
119
166
  widths = [max(len(r[i]) for r in [header, *rows]) for i in range(len(header))]
120
167
  right = {0, 2, 3, 4, 5, 6, 7, 8}
121
168
 
@@ -132,9 +179,17 @@ def _render(result: BenchResult) -> None:
132
179
 
133
180
  failed: list[ModelResult] = [m for m in ranked if m.error]
134
181
  for m in failed:
135
- typer.secho(f"\n✖ {m.name} failed: {m.error}", fg=typer.colors.RED)
182
+ if fancy:
183
+ ui.error(f"{m.name} failed: {m.error}")
184
+ else:
185
+ typer.secho(f"\n✖ {m.name} failed: {m.error}", fg=typer.colors.RED)
136
186
  for note in result.notes:
137
- typer.secho(f"\n{note}", dim=True)
187
+ if fancy:
188
+ ui.note(f" {note}")
189
+ else:
190
+ typer.secho(f"\n{note}", dim=True)
191
+ if fancy:
192
+ ui.console.print()
138
193
 
139
194
 
140
195
  def bench(
@@ -12,6 +12,7 @@ from typing import TYPE_CHECKING, Annotated, Any
12
12
 
13
13
  import typer
14
14
 
15
+ from vecshift import ui
15
16
  from vecshift.cli_plan import DEFAULT_JOB, _fail, load
16
17
  from vecshift.cli_style import finding_lines
17
18
 
@@ -85,8 +86,16 @@ def _locate(job_file: Path) -> _Located:
85
86
 
86
87
 
87
88
  def _confirm(message: str, yes: bool) -> None:
89
+ from vecshift import ui
90
+
88
91
  if yes:
89
92
  return
93
+ if ui.fancy():
94
+ ui.console.print()
95
+ ui.note(" " + " ".join(message.split()))
96
+ if not ui.confirm("Continue?", default=False):
97
+ raise typer.Exit(1)
98
+ return
90
99
  typer.echo(message, err=True)
91
100
  if not sys.stdin.isatty():
92
101
  raise _fail("Not changing the database without confirmation.", "Pass --yes to proceed.")
@@ -219,7 +228,7 @@ def cutover(
219
228
  if check or blocked:
220
229
  return {"status": "ready" if ready.ok else "blocked", "readiness": ready}
221
230
  if not output_json:
222
- typer.secho(f"vecshift cutover · {found.job.name}", bold=True)
231
+ ui.title("cutover", found.job.name)
223
232
  _show([f for f in ready.findings if f.severity.rank >= 1], output_json)
224
233
  where = "your machine" if found.spec.is_local else found.spec.url
225
234
  sends = (
@@ -284,24 +293,24 @@ def cutover(
284
293
  )
285
294
  )
286
295
  elif outcome["status"] in {"ready", "blocked"}:
287
- typer.secho(f"vecshift cutover · {found.job.name}", bold=True)
296
+ ui.title("cutover", found.job.name)
288
297
  finding_lines(ready.findings)
289
- verdict = (
290
- ("Ready to cut over.", typer.colors.GREEN)
291
- if ready.ok
292
- else ("Not ready to cut over.", typer.colors.RED)
293
- )
294
- typer.secho(f"\n{verdict[0]}", fg=verdict[1], bold=True)
298
+ if ready.ok:
299
+ ui.verdict("Ready to cut over.")
300
+ if ui.fancy():
301
+ ui.next_step("vecshift cutover")
302
+ else:
303
+ ui.verdict("Not ready to cut over.", "error")
295
304
  else:
296
- typer.secho("\nCut over.", fg=typer.colors.GREEN, bold=True)
297
- typer.echo(
298
- f" {lay.table}.{lay.live} now holds {found.spec.name} vectors; the old ones are "
305
+ ui.verdict("Cut over.")
306
+ ui.detail(
307
+ f"{lay.table}.{lay.live} now holds {found.spec.name} vectors; the old ones are "
299
308
  f"kept in {lay.previous}."
300
309
  )
301
310
  if outcome.get("caught_up"):
302
- typer.echo(f" Embedded {outcome['caught_up']:,} late rows first.")
303
- typer.echo(
304
- f"\nNext: make sure your application embeds queries and new rows with "
311
+ ui.detail(f"Embedded {outcome['caught_up']:,} late rows first.")
312
+ ui.next_step(
313
+ f"make sure your application embeds queries and new rows with "
305
314
  f"{found.spec.name}.\nUndo with: vecshift rollback. When you're sure, drop "
306
315
  f"{lay.previous} to free its space."
307
316
  )
@@ -363,16 +372,20 @@ def rollback(
363
372
  )
364
373
  )
365
374
  return
366
- typer.secho("\nRolled back.", fg=typer.colors.GREEN, bold=True)
367
- typer.echo(
368
- f" {lay.table}.{lay.live} holds the old vectors again; the new ones are back in "
375
+ if ui.fancy():
376
+ ui.title("rollback", found.job.name)
377
+ ui.verdict("Rolled back.")
378
+ ui.detail(
379
+ f"{lay.table}.{lay.live} holds the old vectors again; the new ones are back in "
369
380
  f"{lay.target}, kept in sync for another cutover."
370
381
  )
371
382
  if missing:
372
- typer.echo(
373
- f" {missing:,} rows were added or edited after cutover and have no old-model "
383
+ ui.detail(
384
+ f"{missing:,} rows were added or edited after cutover and have no old-model "
374
385
  "vector. Your application needs to embed them with the old model."
375
386
  )
387
+ if ui.fancy():
388
+ ui.console.print()
376
389
 
377
390
 
378
391
  def cleanup(
@@ -406,14 +419,18 @@ def cleanup(
406
419
  "Drop or change them first.",
407
420
  )
408
421
  if not yes:
409
- typer.echo(
422
+ warning = (
410
423
  f"Cleanup drops {lay.schema}.{lay.table}.{lay.previous} and its index. The "
411
- "old vectors are gone for good, and rollback is no longer possible.",
412
- err=True,
424
+ "old vectors are gone for good, and rollback is no longer possible."
413
425
  )
414
- if not sys.stdin.isatty():
415
- raise _fail("Not dropping anything without confirmation.", "Pass --yes.")
416
- typed = typer.prompt(f"Type {lay.previous} to confirm", err=True, default="")
426
+ if ui.fancy():
427
+ ui.warn(warning)
428
+ typed = ui.text(f"Type {lay.previous} to confirm")
429
+ else:
430
+ typer.echo(warning, err=True)
431
+ if not sys.stdin.isatty():
432
+ raise _fail("Not dropping anything without confirmation.", "Pass --yes.")
433
+ typed = typer.prompt(f"Type {lay.previous} to confirm", err=True, default="")
417
434
  if typed != lay.previous:
418
435
  raise _fail("That didn't match, so nothing was dropped.")
419
436
  switch.cleanup()
@@ -428,4 +445,8 @@ def cleanup(
428
445
  if output_json:
429
446
  typer.echo(json.dumps({"status": "cleaned_up", "dropped": lay.previous}))
430
447
  else:
431
- typer.secho(f"\nDropped {lay.previous}. The migration is complete.", fg=typer.colors.GREEN)
448
+ if ui.fancy():
449
+ ui.title("cleanup", found.job.name)
450
+ ui.verdict(f"Dropped {lay.previous}. The migration is complete.")
451
+ if ui.fancy():
452
+ ui.console.print()