Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ dependencies = [
"sentence-transformers>=2.2.0",
"rich>=13.0.0",
"httpx>=0.25.0",
"tomli_w>=1.0.0",
"tomli>=2.0.0; python_version < '3.11'",
]

[project.optional-dependencies]
Expand Down
13 changes: 12 additions & 1 deletion src/tpuff/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@
from tpuff import __version__
from tpuff.commands.delete import delete
from tpuff.commands.edit import edit
from tpuff.commands.env import env
from tpuff.commands.export import export
from tpuff.commands.get import get
from tpuff.commands.list import list_cmd
from tpuff.commands.scan import scan
from tpuff.commands.schema import schema
from tpuff.commands.search import search
from tpuff.utils.output import resolve_output_mode
Expand All @@ -26,12 +28,19 @@
default=None,
help="Output format: human (rich tables) or plain (pipe-delimited). Auto-detects TTY if omitted.",
)
@click.option(
"--env",
"env_name",
default=None,
help="Use a specific named environment from ~/.tpuff/config.toml.",
)
@click.pass_context
def cli(ctx: click.Context, debug: bool, output: str | None) -> None:
def cli(ctx: click.Context, debug: bool, output: str | None, env_name: str | None) -> None:
"""tpuff - CLI tool for Turbopuffer vector database."""
ctx.ensure_object(dict)
ctx.obj["debug"] = debug
ctx.obj["output_mode"] = resolve_output_mode(output)
ctx.obj["env"] = env_name


# Register commands
Expand All @@ -44,7 +53,9 @@ def cli(ctx: click.Context, debug: bool, output: str | None) -> None:
cli.add_command(get)
cli.add_command(export)
cli.add_command(export, name="metrics") # alias
cli.add_command(scan)
cli.add_command(schema)
cli.add_command(env)


def main() -> None:
Expand Down
55 changes: 50 additions & 5 deletions src/tpuff/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,33 +3,78 @@
import os
import sys

import click
from turbopuffer import Turbopuffer

from tpuff.config import get_active_env, get_env
from tpuff.utils.debug import debug_log
from tpuff.utils.regions import DEFAULT_REGION

# Global client cache to avoid re-creating clients for the same region
_client_cache: dict[str, Turbopuffer] = {}


def _resolve_config(env_name: str | None = None) -> tuple[str | None, str | None, str | None]:
"""Resolve api_key, region, base_url from config file.

Args:
env_name: Optional specific environment name to use.

Returns:
(api_key, region, base_url) from config, any may be None.
"""
if env_name:
env_dict = get_env(env_name)
if not env_dict:
print(f"Error: environment '{env_name}' not found in config", file=sys.stderr)
print("Run 'tpuff env list' to see available environments.", file=sys.stderr)
sys.exit(1)
return env_dict.get("api_key"), env_dict.get("region"), env_dict.get("base_url")

active = get_active_env()
if active:
_, env_dict = active
return env_dict.get("api_key"), env_dict.get("region"), env_dict.get("base_url")

return None, None, None


def get_turbopuffer_client(region_override: str | None = None) -> Turbopuffer:
"""Get a Turbopuffer client configured with the appropriate region.

Resolution order:
1. Environment variables (always win if set)
2. Config file (~/.tpuff/config.toml) active or --env specified environment
3. Error with helpful message

Args:
region_override: Optional region to use instead of environment variable.

Returns:
Configured Turbopuffer client.
"""
api_key = os.environ.get("TURBOPUFFER_API_KEY")
# Get --env flag from Click context if available
env_name = None
ctx = click.get_current_context(silent=True)
if ctx and ctx.obj:
env_name = ctx.obj.get("env")

# Resolve from config file
cfg_api_key, cfg_region, cfg_base_url = _resolve_config(env_name)

# Environment variables take priority, then config
api_key = os.environ.get("TURBOPUFFER_API_KEY") or cfg_api_key

if not api_key:
print("Error: TURBOPUFFER_API_KEY environment variable is not set", file=sys.stderr)
print(
"Error: No API key found. Set TURBOPUFFER_API_KEY or run 'tpuff env add <name>'.",
file=sys.stderr,
)
sys.exit(1)

# Determine the region
base_url = os.environ.get("TURBOPUFFER_BASE_URL")
region = region_override or os.environ.get("TURBOPUFFER_REGION") or DEFAULT_REGION
# Determine the region and base_url
base_url = os.environ.get("TURBOPUFFER_BASE_URL") or cfg_base_url or None
region = region_override or os.environ.get("TURBOPUFFER_REGION") or cfg_region or DEFAULT_REGION

# Cache key is the region or base_url
cache_key = base_url or region
Expand Down
6 changes: 6 additions & 0 deletions src/tpuff/commands/delete.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from rich.console import Console

from tpuff.client import get_turbopuffer_client
from tpuff.config import CONFIG_FILE, deletes_allowed
from tpuff.utils.debug import debug_log

console = Console()
Expand All @@ -30,6 +31,11 @@ def delete(
region: str | None,
) -> None:
"""Delete namespace(s)."""
if not deletes_allowed():
console.print("[red]Error: Deletes are disabled.[/red]")
console.print(f"[dim]To enable, add [bold]allow_deletes = true[/bold] to {CONFIG_FILE}[/dim]")
sys.exit(1)

client = get_turbopuffer_client(region)

try:
Expand Down
127 changes: 127 additions & 0 deletions src/tpuff/commands/env.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
"""Environment management commands for tpuff."""

import click
from rich.console import Console
from rich.table import Table

from tpuff.config import add_env, get_active_env, list_envs, remove_env, set_active
from tpuff.utils.output import is_plain, print_table_plain
from tpuff.utils.regions import DEFAULT_REGION, TURBOPUFFER_REGIONS, is_valid_region

console = Console()


def mask_key(api_key: str) -> str:
"""Mask an API key, showing only the first 8 characters."""
if len(api_key) <= 8:
return api_key
return api_key[:8] + "..."


@click.group("env", context_settings={"help_option_names": ["-h", "--help"]})
def env():
"""Manage tpuff environments."""
pass


@env.command("add")
@click.argument("name")
@click.pass_context
def env_add(ctx: click.Context, name: str) -> None:
"""Add a new environment (interactive setup)."""
api_key = click.prompt("API key")
region = click.prompt("Region", default=DEFAULT_REGION)
if not is_valid_region(region):
raise click.BadParameter(
f"Unknown region '{region}'. Valid regions:\n "
+ "\n ".join(TURBOPUFFER_REGIONS),
param_hint="'region'",
)
base_url = click.prompt("Base URL (optional, press Enter to skip)", default="")

add_env(name, api_key, region, base_url)
click.echo(f"Environment '{name}' added.")

# Show if it was set as active
active = get_active_env()
if active and active[0] == name:
click.echo("Set as active environment.")


@env.command("use")
@click.argument("name")
def env_use(name: str) -> None:
"""Switch active environment."""
set_active(name)
click.echo(f"Switched to environment '{name}'.")


@env.command("list")
@click.pass_context
def env_list(ctx: click.Context) -> None:
"""List all environments."""
envs = list_envs()
if not envs:
click.echo("No environments configured. Run 'tpuff env add <name>' to add one.")
return

plain = is_plain(ctx)
headers = ["", "Name", "Region", "API Key"]
rows = []
for name, env_dict, is_active in envs:
marker = "*" if is_active else ""
rows.append([
marker,
name,
env_dict.get("region", DEFAULT_REGION),
mask_key(env_dict.get("api_key", "")),
])

if plain:
print_table_plain(headers, rows)
else:
table = Table(show_header=True, header_style="cyan")
for h in headers:
table.add_column(h)
for r in rows:
# Highlight active env
if r[0] == "*":
table.add_row(
"[green]*[/green]",
f"[bold]{r[1]}[/bold]",
r[2],
f"[dim]{r[3]}[/dim]",
)
else:
table.add_row("", r[1], r[2], f"[dim]{r[3]}[/dim]")
console.print(table)


# Alias
env.add_command(env_list, name="ls")


@env.command("rm")
@click.argument("name")
@click.confirmation_option(prompt="Are you sure you want to remove this environment?")
def env_rm(name: str) -> None:
"""Remove an environment."""
remove_env(name)
click.echo(f"Environment '{name}' removed.")


@env.command("show")
def env_show() -> None:
"""Show the current active environment."""
active = get_active_env()
if not active:
click.echo("No active environment. Run 'tpuff env add <name>' to add one.")
return

name, env_dict = active
click.echo(f"Active environment: {name}")
click.echo(f" Region: {env_dict.get('region', DEFAULT_REGION)}")
click.echo(f" API Key: {mask_key(env_dict.get('api_key', ''))}")
base_url = env_dict.get("base_url", "")
if base_url:
click.echo(f" Base URL: {base_url}")
Loading
Loading