Manage Atom feeds in a persistent git repository

Add Typesense integration and new CLI commands

- Add Typesense client library for search functionality
- Implement search command for querying indexed content
- Add upload command for publishing data to external services
- Update dependencies with typesense and mypy packages
- Register new commands in CLI module imports

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

Changed files
+1092 -4
src
+2
pyproject.toml
···
"platformdirs>=4.0.0",
"pyyaml>=6.0.0",
"email_validator",
]
[project.optional-dependencies]
···
[dependency-groups]
dev = [
"pytest>=8.4.1",
]
···
"platformdirs>=4.0.0",
"pyyaml>=6.0.0",
"email_validator",
+
"typesense>=1.1.1",
]
[project.optional-dependencies]
···
[dependency-groups]
dev = [
+
"mypy>=1.17.0",
"pytest>=8.4.1",
]
+2 -2
src/thicket/cli/commands/__init__.py
···
"""CLI commands for thicket."""
# Import all commands to register them with the main app
-
from . import add, duplicates, info_cmd, init, list_cmd, sync
-
__all__ = ["add", "duplicates", "info_cmd", "init", "list_cmd", "sync"]
···
"""CLI commands for thicket."""
# Import all commands to register them with the main app
+
from . import add, duplicates, info_cmd, init, list_cmd, search, sync, upload
+
__all__ = ["add", "duplicates", "info_cmd", "init", "list_cmd", "search", "sync", "upload"]
+298
src/thicket/cli/commands/search.py
···
···
+
"""Search command for thicket CLI."""
+
+
import logging
+
from pathlib import Path
+
from typing import Optional
+
+
import typer
+
from rich.console import Console
+
from rich.table import Table
+
from rich.text import Text
+
+
from ...core.typesense_client import TypesenseClient, TypesenseConfig
+
from ..main import app
+
from ..utils import load_config
+
+
console = Console()
+
logger = logging.getLogger(__name__)
+
+
+
def _load_typesense_config() -> tuple[Optional[str], Optional[str]]:
+
"""Load Typesense URL and API key from ~/.typesense directory."""
+
typesense_dir = Path.home() / ".typesense"
+
url_file = typesense_dir / "url"
+
key_file = typesense_dir / "api_key"
+
+
url = None
+
api_key = None
+
+
try:
+
if url_file.exists():
+
url = url_file.read_text().strip()
+
except Exception as e:
+
logger.debug(f"Could not read Typesense URL from {url_file}: {e}")
+
+
try:
+
if key_file.exists():
+
api_key = key_file.read_text().strip()
+
except Exception as e:
+
logger.debug(f"Could not read Typesense API key from {key_file}: {e}")
+
+
return url, api_key
+
+
+
@app.command("search")
+
def search_command(
+
query: str = typer.Argument(..., help="Search query"),
+
typesense_url: Optional[str] = typer.Option(
+
None,
+
"--typesense-url",
+
"-u",
+
help="Typesense server URL (e.g., http://localhost:8108). Defaults to ~/.typesense/url",
+
),
+
api_key: Optional[str] = typer.Option(
+
None,
+
"--api-key",
+
"-k",
+
help="Typesense API key. Defaults to ~/.typesense/api_key",
+
hide_input=True,
+
),
+
collection_name: str = typer.Option(
+
"thicket",
+
"--collection",
+
"-c",
+
help="Typesense collection name",
+
),
+
config_path: Optional[str] = typer.Option(
+
None,
+
"--config",
+
"-C",
+
help="Path to thicket configuration file",
+
),
+
limit: int = typer.Option(
+
20,
+
"--limit",
+
"-l",
+
help="Maximum number of results to display",
+
),
+
user: Optional[str] = typer.Option(
+
None,
+
"--user",
+
help="Filter results by specific user",
+
),
+
timeout: int = typer.Option(
+
10,
+
"--timeout",
+
"-t",
+
help="Connection timeout in seconds",
+
),
+
raw: bool = typer.Option(
+
False,
+
"--raw",
+
help="Display raw JSON output instead of formatted table",
+
),
+
) -> None:
+
"""Search thicket entries using Typesense full-text and semantic search.
+
+
This command searches through all entries in the Typesense collection
+
using the provided query. The search covers entry titles, content,
+
summaries, user information, and metadata.
+
+
Examples:
+
+
# Basic search
+
thicket search "machine learning"
+
+
# Search with user filter
+
thicket search "python programming" --user avsm
+
+
# Limit results
+
thicket search "web development" --limit 10
+
+
# Get raw JSON output
+
thicket search "database" --raw
+
"""
+
try:
+
# Load Typesense configuration from defaults if not provided
+
default_url, default_api_key = _load_typesense_config()
+
+
# Use provided values or defaults
+
final_url = typesense_url or default_url
+
final_api_key = api_key or default_api_key
+
+
# Check that we have required configuration
+
if not final_url:
+
console.print("[red]Error: Typesense URL is required[/red]")
+
console.print("Either provide --typesense-url or create ~/.typesense/url file")
+
raise typer.Exit(1)
+
+
if not final_api_key:
+
console.print("[red]Error: Typesense API key is required[/red]")
+
console.print("Either provide --api-key or create ~/.typesense/api_key file")
+
raise typer.Exit(1)
+
+
# Create Typesense configuration
+
typesense_config = TypesenseConfig.from_url(
+
final_url,
+
final_api_key,
+
collection_name
+
)
+
typesense_config.connection_timeout = timeout
+
+
console.print(f"[bold blue]Searching thicket entries[/bold blue]")
+
console.print(f"Query: [cyan]{query}[/cyan]")
+
if user:
+
console.print(f"User filter: [yellow]{user}[/yellow]")
+
+
# Initialize Typesense client
+
typesense_client = TypesenseClient(typesense_config)
+
+
# Prepare search parameters
+
search_params = {
+
'per_page': limit,
+
}
+
+
# Add user filter if specified
+
if user:
+
search_params['filter_by'] = f'username:{user}'
+
+
# Perform search
+
try:
+
results = typesense_client.search(query, search_params)
+
+
if raw:
+
import json
+
console.print(json.dumps(results, indent=2))
+
return
+
+
# Display results
+
_display_search_results(results, query)
+
+
except Exception as e:
+
console.print(f"[red]❌ Search failed: {e}[/red]")
+
raise typer.Exit(1) from e
+
+
except Exception as e:
+
logger.error(f"Search failed: {e}")
+
console.print(f"[red]Error: {e}[/red]")
+
raise typer.Exit(1) from e
+
+
+
def _display_search_results(results: dict, query: str) -> None:
+
"""Display search results in a formatted table."""
+
hits = results.get('hits', [])
+
found = results.get('found', 0)
+
search_time = results.get('search_time_ms', 0)
+
+
if not hits:
+
console.print("\n[yellow]No results found.[/yellow]")
+
return
+
+
console.print(f"\n[green]Found {found} results in {search_time}ms[/green]")
+
+
table = Table(title=f"Search Results for '{query}'", show_lines=True)
+
table.add_column("Score", style="green", width=8, no_wrap=True)
+
table.add_column("User", style="cyan", width=15, no_wrap=True)
+
table.add_column("Title", style="bold", width=45)
+
table.add_column("Updated", style="blue", width=12, no_wrap=True)
+
table.add_column("Summary", style="dim", width=50)
+
+
for hit in hits:
+
doc = hit['document']
+
+
# Format score
+
score = f"{hit.get('text_match', 0):.2f}"
+
+
# Format user
+
user_display = doc.get('user_display_name', doc.get('username', 'Unknown'))
+
if len(user_display) > 12:
+
user_display = user_display[:9] + "..."
+
+
# Format title
+
title = doc.get('title', 'Untitled')
+
if len(title) > 40:
+
title = title[:37] + "..."
+
+
# Format date
+
updated_timestamp = doc.get('updated', 0)
+
if updated_timestamp:
+
from datetime import datetime
+
updated_date = datetime.fromtimestamp(updated_timestamp)
+
updated_str = updated_date.strftime("%Y-%m-%d")
+
else:
+
updated_str = "Unknown"
+
+
# Format summary
+
summary = doc.get('summary') or doc.get('content', '')
+
if summary:
+
# Remove HTML tags and truncate
+
import re
+
summary = re.sub(r'<[^>]+>', '', summary)
+
summary = summary.strip()
+
if len(summary) > 60:
+
summary = summary[:57] + "..."
+
else:
+
summary = ""
+
+
table.add_row(
+
score,
+
user_display,
+
title,
+
updated_str,
+
summary
+
)
+
+
console.print(table)
+
+
# Show additional info
+
console.print(f"\n[dim]Showing {len(hits)} of {found} results[/dim]")
+
if len(hits) < found:
+
console.print(f"[dim]Use --limit to see more results (current limit: {len(hits)})[/dim]")
+
+
+
def _display_compact_results(results: dict, query: str) -> None:
+
"""Display search results in a compact format."""
+
hits = results.get('hits', [])
+
found = results.get('found', 0)
+
+
if not hits:
+
console.print("\n[yellow]No results found.[/yellow]")
+
return
+
+
console.print(f"\n[green]Found {found} results[/green]\n")
+
+
for i, hit in enumerate(hits, 1):
+
doc = hit['document']
+
score = hit.get('text_match', 0)
+
+
# Header with score and user
+
user = doc.get('user_display_name', doc.get('username', 'Unknown'))
+
console.print(f"[green]{i:2d}.[/green] [cyan]{user}[/cyan] [dim](score: {score:.2f})[/dim]")
+
+
# Title
+
title = doc.get('title', 'Untitled')
+
console.print(f" [bold]{title}[/bold]")
+
+
# Date and link
+
updated_timestamp = doc.get('updated', 0)
+
if updated_timestamp:
+
from datetime import datetime
+
updated_date = datetime.fromtimestamp(updated_timestamp)
+
updated_str = updated_date.strftime("%Y-%m-%d %H:%M")
+
else:
+
updated_str = "Unknown date"
+
+
link = doc.get('link', '')
+
console.print(f" [blue]{updated_str}[/blue] - [link={link}]{link}[/link]")
+
+
# Summary
+
summary = doc.get('summary') or doc.get('content', '')
+
if summary:
+
import re
+
summary = re.sub(r'<[^>]+>', '', summary)
+
summary = summary.strip()
+
if len(summary) > 150:
+
summary = summary[:147] + "..."
+
console.print(f" [dim]{summary}[/dim]")
+
+
console.print() # Empty line between results
+299
src/thicket/cli/commands/upload.py
···
···
+
"""Upload command for thicket CLI."""
+
+
import logging
+
from pathlib import Path
+
from typing import Optional
+
+
import typer
+
from rich.console import Console
+
from rich.progress import Progress, SpinnerColumn, TextColumn
+
+
from ...core.git_store import GitStore
+
from ...core.typesense_client import TypesenseClient, TypesenseConfig
+
from ...models.config import ThicketConfig
+
from ..main import app
+
from ..utils import load_config
+
+
console = Console()
+
logger = logging.getLogger(__name__)
+
+
+
def _load_typesense_config() -> tuple[Optional[str], Optional[str]]:
+
"""Load Typesense URL and API key from ~/.typesense directory."""
+
typesense_dir = Path.home() / ".typesense"
+
url_file = typesense_dir / "url"
+
key_file = typesense_dir / "api_key"
+
+
url = None
+
api_key = None
+
+
try:
+
if url_file.exists():
+
url = url_file.read_text().strip()
+
except Exception as e:
+
logger.debug(f"Could not read Typesense URL from {url_file}: {e}")
+
+
try:
+
if key_file.exists():
+
api_key = key_file.read_text().strip()
+
except Exception as e:
+
logger.debug(f"Could not read Typesense API key from {key_file}: {e}")
+
+
return url, api_key
+
+
+
def _save_typesense_config(url: Optional[str] = None, api_key: Optional[str] = None) -> None:
+
"""Save Typesense URL and API key to ~/.typesense directory."""
+
typesense_dir = Path.home() / ".typesense"
+
typesense_dir.mkdir(exist_ok=True, mode=0o700) # Secure permissions
+
+
if url:
+
url_file = typesense_dir / "url"
+
url_file.write_text(url)
+
url_file.chmod(0o600)
+
+
if api_key:
+
key_file = typesense_dir / "api_key"
+
key_file.write_text(api_key)
+
key_file.chmod(0o600) # Keep API key secure
+
+
+
@app.command("upload")
+
def upload_command(
+
typesense_url: Optional[str] = typer.Option(
+
None,
+
"--typesense-url",
+
"-u",
+
help="Typesense server URL (e.g., http://localhost:8108). Defaults to ~/.typesense/url",
+
),
+
api_key: Optional[str] = typer.Option(
+
None,
+
"--api-key",
+
"-k",
+
help="Typesense API key. Defaults to ~/.typesense/api_key",
+
hide_input=True,
+
),
+
collection_name: str = typer.Option(
+
"thicket_entries",
+
"--collection",
+
"-c",
+
help="Typesense collection name",
+
),
+
config_path: Optional[str] = typer.Option(
+
None,
+
"--config",
+
"-C",
+
help="Path to thicket configuration file",
+
),
+
git_store_path: Optional[str] = typer.Option(
+
None,
+
"--git-store",
+
"-g",
+
help="Path to Git store (overrides config)",
+
),
+
timeout: int = typer.Option(
+
10,
+
"--timeout",
+
"-t",
+
help="Connection timeout in seconds",
+
),
+
dry_run: bool = typer.Option(
+
False,
+
"--dry-run",
+
help="Show what would be uploaded without actually uploading",
+
),
+
) -> None:
+
"""Upload thicket entries to a Typesense search engine.
+
+
This command uploads all entries from the Git store to a Typesense server
+
for full-text and semantic search capabilities. The uploaded data includes
+
entry content, metadata, user information, and searchable text fields
+
optimized for embedding-based queries.
+
+
Configuration defaults can be stored in ~/.typesense/ directory:
+
- URL in ~/.typesense/url
+
- API key in ~/.typesense/api_key
+
+
Examples:
+
+
# Upload using saved defaults (first run will save config)
+
thicket upload -u http://localhost:8108 -k your-api-key
+
+
# Subsequent runs can omit URL and key if saved
+
thicket upload
+
+
# Upload to remote server with custom collection name
+
thicket upload -u https://search.example.com -k api-key -c my_blog_entries
+
+
# Dry run to see what would be uploaded
+
thicket upload --dry-run
+
"""
+
try:
+
# Load Typesense configuration from defaults if not provided
+
default_url, default_api_key = _load_typesense_config()
+
+
# Use provided values or defaults
+
final_url = typesense_url or default_url
+
final_api_key = api_key or default_api_key
+
+
# Check that we have required configuration
+
if not final_url:
+
console.print("[red]Error: Typesense URL is required[/red]")
+
console.print("Either provide --typesense-url or create ~/.typesense/url file")
+
raise typer.Exit(1)
+
+
if not final_api_key:
+
console.print("[red]Error: Typesense API key is required[/red]")
+
console.print("Either provide --api-key or create ~/.typesense/api_key file")
+
raise typer.Exit(1)
+
+
# Save configuration if provided via command line (for future use)
+
if typesense_url or api_key:
+
_save_typesense_config(typesense_url, api_key)
+
+
# Load thicket configuration
+
config_path_obj = Path(config_path) if config_path else None
+
config = load_config(config_path_obj)
+
+
# Override git store path if provided
+
if git_store_path:
+
config.git_store = Path(git_store_path)
+
+
console.print("[bold blue]Thicket Typesense Upload[/bold blue]")
+
console.print(f"Git store: {config.git_store}")
+
console.print(f"Typesense URL: {final_url}")
+
+
# Show where config is loaded from
+
if not typesense_url and default_url:
+
console.print("[dim] (URL loaded from ~/.typesense/url)[/dim]")
+
if not api_key and default_api_key:
+
console.print("[dim] (API key loaded from ~/.typesense/api_key)[/dim]")
+
+
console.print(f"Collection: {collection_name}")
+
+
if dry_run:
+
console.print("[yellow]DRY RUN MODE - No data will be uploaded[/yellow]")
+
+
# Initialize Git store
+
git_store = GitStore(config.git_store)
+
if not git_store.repo or not config.git_store.exists():
+
console.print("[red]Error: Git store is not valid or not initialized[/red]")
+
console.print("Run 'thicket init' first to set up the Git store.")
+
raise typer.Exit(1)
+
+
# Create Typesense configuration
+
typesense_config = TypesenseConfig.from_url(
+
final_url,
+
final_api_key,
+
collection_name
+
)
+
typesense_config.connection_timeout = timeout
+
+
if dry_run:
+
_dry_run_upload(git_store, config, typesense_config)
+
else:
+
_perform_upload(git_store, config, typesense_config)
+
+
except Exception as e:
+
logger.error(f"Upload failed: {e}")
+
console.print(f"[red]Error: {e}[/red]")
+
raise typer.Exit(1) from e
+
+
+
def _dry_run_upload(git_store: GitStore, config: ThicketConfig, typesense_config: TypesenseConfig) -> None:
+
"""Perform a dry run showing what would be uploaded."""
+
console.print("\n[bold]Dry run analysis:[/bold]")
+
+
index = git_store._load_index()
+
total_entries = 0
+
+
for username, user_metadata in index.users.items():
+
try:
+
user_dir = git_store.repo_path / user_metadata.directory
+
if not user_dir.exists():
+
console.print(f" ⚠️ User {username}: Directory not found")
+
continue
+
+
entry_files = list(user_dir.glob("*.json"))
+
total_entries += len(entry_files)
+
console.print(f" ✅ User {username}: {len(entry_files)} entries would be uploaded")
+
except Exception as e:
+
console.print(f" ❌ User {username}: Error loading entries - {e}")
+
+
console.print("\n[bold]Summary:[/bold]")
+
console.print(f" • Total users: {len(index.users)}")
+
console.print(f" • Total entries to upload: {total_entries}")
+
console.print(f" • Target collection: {typesense_config.collection_name}")
+
console.print(f" • Typesense server: {typesense_config.protocol}://{typesense_config.host}:{typesense_config.port}")
+
+
if total_entries > 0:
+
console.print("\n[green]Ready to upload! Remove --dry-run to proceed.[/green]")
+
else:
+
console.print("\n[yellow]No entries found to upload.[/yellow]")
+
+
+
def _perform_upload(git_store: GitStore, config: ThicketConfig, typesense_config: TypesenseConfig) -> None:
+
"""Perform the actual upload to Typesense."""
+
with Progress(
+
SpinnerColumn(),
+
TextColumn("[progress.description]{task.description}"),
+
console=console,
+
) as progress:
+
+
# Test connection
+
progress.add_task("Testing Typesense connection...", total=None)
+
+
try:
+
typesense_client = TypesenseClient(typesense_config)
+
# Test connection by attempting to list collections
+
typesense_client.client.collections.retrieve()
+
progress.stop()
+
console.print("[green]✅ Connected to Typesense server[/green]")
+
except Exception as e:
+
progress.stop()
+
console.print(f"[red]❌ Failed to connect to Typesense: {e}[/red]")
+
raise typer.Exit(1) from e
+
+
# Perform upload
+
with Progress(
+
SpinnerColumn(),
+
TextColumn("[progress.description]{task.description}"),
+
console=console,
+
) as upload_progress:
+
+
upload_progress.add_task("Uploading entries to Typesense...", total=None)
+
+
try:
+
result = typesense_client.upload_from_git_store(git_store, config)
+
upload_progress.stop()
+
+
# Parse results if available
+
if result:
+
if isinstance(result, list):
+
# Batch import results
+
success_count = sum(1 for r in result if r.get("success"))
+
total_count = len(result)
+
console.print(f"[green]✅ Upload completed: {success_count}/{total_count} documents uploaded successfully[/green]")
+
+
# Show any errors
+
errors = [r for r in result if not r.get("success")]
+
if errors:
+
console.print(f"[yellow]⚠️ {len(errors)} documents had errors[/yellow]")
+
for i, error in enumerate(errors[:5]): # Show first 5 errors
+
console.print(f" Error {i+1}: {error}")
+
if len(errors) > 5:
+
console.print(f" ... and {len(errors) - 5} more errors")
+
else:
+
console.print("[green]✅ Upload completed successfully[/green]")
+
else:
+
console.print("[yellow]⚠️ Upload completed but no result data available[/yellow]")
+
+
console.print("\n[bold]Collection information:[/bold]")
+
console.print(f" • Server: {typesense_config.protocol}://{typesense_config.host}:{typesense_config.port}")
+
console.print(f" • Collection: {typesense_config.collection_name}")
+
console.print("\n[dim]You can now search your entries using the Typesense API or dashboard.[/dim]")
+
+
except Exception as e:
+
upload_progress.stop()
+
console.print(f"[red]❌ Upload failed: {e}[/red]")
+
raise typer.Exit(1) from e
+1 -1
src/thicket/cli/main.py
···
# Import commands to register them
-
from .commands import add, duplicates, info_cmd, init, list_cmd, sync # noqa: F401
if __name__ == "__main__":
app()
···
# Import commands to register them
+
from .commands import add, duplicates, info_cmd, init, list_cmd, sync, upload # noqa: F401
if __name__ == "__main__":
app()
+372
src/thicket/core/typesense_client.py
···
···
+
"""Typesense integration for thicket."""
+
+
import json
+
import logging
+
from datetime import datetime
+
from typing import Any, Optional
+
from urllib.parse import urlparse
+
+
import typesense
+
from pydantic import BaseModel, ConfigDict
+
+
from ..models.config import ThicketConfig, UserConfig
+
from ..models.feed import AtomEntry
+
from ..models.user import UserMetadata
+
from .git_store import GitStore
+
+
logger = logging.getLogger(__name__)
+
+
+
class TypesenseConfig(BaseModel):
+
"""Configuration for Typesense connection."""
+
+
model_config = ConfigDict(str_strip_whitespace=True)
+
+
host: str
+
port: int = 8108
+
protocol: str = "http"
+
api_key: str
+
connection_timeout: int = 5
+
collection_name: str = "thicket_entries"
+
+
@classmethod
+
def from_url(cls, url: str, api_key: str, collection_name: str = "thicket_entries") -> "TypesenseConfig":
+
"""Create config from Typesense URL."""
+
parsed = urlparse(url)
+
return cls(
+
host=parsed.hostname or "localhost",
+
port=parsed.port or (443 if parsed.scheme == "https" else 8108),
+
protocol=parsed.scheme or "http",
+
api_key=api_key,
+
collection_name=collection_name,
+
)
+
+
+
class TypesenseDocument(BaseModel):
+
"""Document model for Typesense indexing."""
+
+
model_config = ConfigDict(
+
json_encoders={datetime: lambda v: int(v.timestamp())},
+
str_strip_whitespace=True,
+
)
+
+
# Primary fields from AtomEntry
+
id: str # Sanitized entry ID
+
original_id: str # Original Atom ID
+
title: str
+
link: str
+
updated: int # Unix timestamp
+
published: Optional[int] = None # Unix timestamp
+
summary: Optional[str] = None
+
content: Optional[str] = None
+
content_type: str = "html"
+
categories: list[str] = []
+
rights: Optional[str] = None
+
source: Optional[str] = None
+
+
# User/feed metadata
+
username: str
+
user_display_name: Optional[str] = None
+
user_email: Optional[str] = None
+
user_homepage: Optional[str] = None
+
user_icon: Optional[str] = None
+
+
# Author information from entry
+
author_name: Optional[str] = None
+
author_email: Optional[str] = None
+
author_uri: Optional[str] = None
+
+
# Searchable text fields for embedding/semantic search
+
searchable_content: str # Combined title + summary + content
+
searchable_metadata: str # Combined user info + categories + author
+
+
@classmethod
+
def from_atom_entry_with_metadata(
+
cls,
+
entry: AtomEntry,
+
sanitized_id: str,
+
user_metadata: "UserMetadata", # Import will be added at top
+
) -> "TypesenseDocument":
+
"""Create TypesenseDocument from AtomEntry and UserMetadata from git store."""
+
# Extract author information if available
+
author_name = None
+
author_email = None
+
author_uri = None
+
if entry.author:
+
author_name = entry.author.get("name")
+
author_email = entry.author.get("email")
+
author_uri = entry.author.get("uri")
+
+
# Create searchable content combining all text fields
+
content_parts = [entry.title]
+
if entry.summary:
+
content_parts.append(entry.summary)
+
if entry.content:
+
content_parts.append(entry.content)
+
searchable_content = " ".join(content_parts)
+
+
# Create searchable metadata
+
metadata_parts = [user_metadata.username]
+
if user_metadata.display_name:
+
metadata_parts.append(user_metadata.display_name)
+
if author_name:
+
metadata_parts.append(author_name)
+
if entry.categories:
+
metadata_parts.extend(entry.categories)
+
searchable_metadata = " ".join(metadata_parts)
+
+
return cls(
+
id=sanitized_id,
+
original_id=entry.id,
+
title=entry.title,
+
link=str(entry.link),
+
updated=int(entry.updated.timestamp()),
+
published=int(entry.published.timestamp()) if entry.published else None,
+
summary=entry.summary,
+
content=entry.content,
+
content_type=entry.content_type or "html",
+
categories=entry.categories,
+
rights=entry.rights,
+
source=entry.source,
+
username=user_metadata.username,
+
user_display_name=user_metadata.display_name,
+
user_email=user_metadata.email,
+
user_homepage=user_metadata.homepage,
+
user_icon=user_metadata.icon if user_metadata.icon != "None" else None,
+
author_name=author_name,
+
author_email=author_email,
+
author_uri=author_uri,
+
searchable_content=searchable_content,
+
searchable_metadata=searchable_metadata,
+
)
+
+
@classmethod
+
def from_atom_entry(
+
cls,
+
entry: AtomEntry,
+
sanitized_id: str,
+
user_config: UserConfig,
+
) -> "TypesenseDocument":
+
"""Create TypesenseDocument from AtomEntry and UserConfig."""
+
# Extract author information if available
+
author_name = None
+
author_email = None
+
author_uri = None
+
if entry.author:
+
author_name = entry.author.get("name")
+
author_email = entry.author.get("email")
+
author_uri = entry.author.get("uri")
+
+
# Create searchable content combining all text fields
+
content_parts = [entry.title]
+
if entry.summary:
+
content_parts.append(entry.summary)
+
if entry.content:
+
content_parts.append(entry.content)
+
searchable_content = " ".join(content_parts)
+
+
# Create searchable metadata
+
metadata_parts = [user_config.username]
+
if user_config.display_name:
+
metadata_parts.append(user_config.display_name)
+
if author_name:
+
metadata_parts.append(author_name)
+
if entry.categories:
+
metadata_parts.extend(entry.categories)
+
searchable_metadata = " ".join(metadata_parts)
+
+
return cls(
+
id=sanitized_id,
+
original_id=entry.id,
+
title=entry.title,
+
link=str(entry.link),
+
updated=int(entry.updated.timestamp()),
+
published=int(entry.published.timestamp()) if entry.published else None,
+
summary=entry.summary,
+
content=entry.content,
+
content_type=entry.content_type or "html",
+
categories=entry.categories,
+
rights=entry.rights,
+
source=entry.source,
+
username=user_config.username,
+
user_display_name=user_config.display_name,
+
user_email=str(user_config.email) if user_config.email else None,
+
user_homepage=str(user_config.homepage) if user_config.homepage else None,
+
user_icon=str(user_config.icon) if user_config.icon else None,
+
author_name=author_name,
+
author_email=author_email,
+
author_uri=author_uri,
+
searchable_content=searchable_content,
+
searchable_metadata=searchable_metadata,
+
)
+
+
+
class TypesenseClient:
+
"""Client for interacting with Typesense search engine."""
+
+
def __init__(self, config: TypesenseConfig):
+
"""Initialize Typesense client."""
+
self.config = config
+
self.client = typesense.Client({
+
'nodes': [{
+
'host': config.host,
+
'port': config.port,
+
'protocol': config.protocol,
+
}],
+
'api_key': config.api_key,
+
'connection_timeout_seconds': config.connection_timeout,
+
})
+
+
def get_collection_schema(self) -> dict[str, Any]:
+
"""Get the Typesense collection schema for thicket entries."""
+
return {
+
'name': self.config.collection_name,
+
'fields': [
+
# Primary identifiers
+
{'name': 'id', 'type': 'string', 'facet': False},
+
{'name': 'original_id', 'type': 'string', 'facet': False},
+
+
# Content fields - optimized for search
+
{'name': 'title', 'type': 'string', 'facet': False},
+
{'name': 'summary', 'type': 'string', 'optional': True, 'facet': False},
+
{'name': 'content', 'type': 'string', 'optional': True, 'facet': False},
+
{'name': 'content_type', 'type': 'string', 'facet': True},
+
+
# Searchable combined fields for embeddings/semantic search
+
{'name': 'searchable_content', 'type': 'string', 'facet': False},
+
{'name': 'searchable_metadata', 'type': 'string', 'facet': False},
+
+
# Temporal fields
+
{'name': 'updated', 'type': 'int64', 'facet': False, 'sort': True},
+
{'name': 'published', 'type': 'int64', 'optional': True, 'facet': False, 'sort': True},
+
+
# Link and source
+
{'name': 'link', 'type': 'string', 'facet': False},
+
{'name': 'source', 'type': 'string', 'optional': True, 'facet': False},
+
+
# Categories and classification
+
{'name': 'categories', 'type': 'string[]', 'facet': True, 'optional': True},
+
{'name': 'rights', 'type': 'string', 'optional': True, 'facet': False},
+
+
# User/feed metadata - facetable for filtering
+
{'name': 'username', 'type': 'string', 'facet': True},
+
{'name': 'user_display_name', 'type': 'string', 'optional': True, 'facet': True},
+
{'name': 'user_email', 'type': 'string', 'optional': True, 'facet': False},
+
{'name': 'user_homepage', 'type': 'string', 'optional': True, 'facet': False},
+
{'name': 'user_icon', 'type': 'string', 'optional': True, 'facet': False},
+
+
# Author information from entries
+
{'name': 'author_name', 'type': 'string', 'optional': True, 'facet': True},
+
{'name': 'author_email', 'type': 'string', 'optional': True, 'facet': False},
+
{'name': 'author_uri', 'type': 'string', 'optional': True, 'facet': False},
+
],
+
'default_sorting_field': 'updated',
+
}
+
+
def create_collection(self) -> dict[str, Any]:
+
"""Create the Typesense collection with the appropriate schema."""
+
try:
+
# Try to delete existing collection first
+
try:
+
self.client.collections[self.config.collection_name].delete()
+
logger.info(f"Deleted existing collection: {self.config.collection_name}")
+
except typesense.exceptions.ObjectNotFound:
+
logger.info(f"Collection {self.config.collection_name} does not exist, creating new one")
+
+
# Create new collection
+
schema = self.get_collection_schema()
+
result = self.client.collections.create(schema)
+
logger.info(f"Created collection: {self.config.collection_name}")
+
return result
+
+
except Exception as e:
+
logger.error(f"Failed to create collection: {e}")
+
raise
+
+
def index_documents(self, documents: list[TypesenseDocument]) -> dict[str, Any]:
+
"""Index a batch of documents in Typesense."""
+
try:
+
# Convert documents to dict format for Typesense
+
document_dicts = [doc.model_dump() for doc in documents]
+
+
# Use import endpoint for batch indexing
+
result = self.client.collections[self.config.collection_name].documents.import_(
+
document_dicts,
+
{'action': 'upsert'} # Update if exists, insert if not
+
)
+
+
logger.info(f"Indexed {len(documents)} documents")
+
return result
+
+
except Exception as e:
+
logger.error(f"Failed to index documents: {e}")
+
raise
+
+
def upload_from_git_store(self, git_store: GitStore, config: ThicketConfig) -> dict[str, Any]:
+
"""Upload all entries from the Git store to Typesense."""
+
logger.info("Starting Typesense upload from Git store")
+
+
# Create collection
+
self.create_collection()
+
+
documents = []
+
index = git_store._load_index()
+
+
for username, user_metadata in index.users.items():
+
logger.info(f"Processing entries for user: {username}")
+
+
# Load user entries from directory
+
try:
+
user_dir = git_store.repo_path / user_metadata.directory
+
if not user_dir.exists():
+
logger.warning(f"Directory not found for user {username}: {user_dir}")
+
continue
+
+
entry_files = list(user_dir.glob("*.json"))
+
logger.info(f"Found {len(entry_files)} entry files for {username}")
+
+
for entry_file in entry_files:
+
try:
+
with open(entry_file) as f:
+
data = json.load(f)
+
+
entry = AtomEntry(**data)
+
sanitized_id = entry_file.stem # filename without extension
+
+
doc = TypesenseDocument.from_atom_entry_with_metadata(
+
entry, sanitized_id, user_metadata
+
)
+
documents.append(doc)
+
except Exception as e:
+
logger.error(f"Failed to convert entry {entry_file} to document: {e}")
+
+
except Exception as e:
+
logger.error(f"Failed to load entries for user {username}: {e}")
+
+
if documents:
+
logger.info(f"Uploading {len(documents)} documents to Typesense")
+
result = self.index_documents(documents)
+
logger.info("Upload completed successfully")
+
return result
+
else:
+
logger.warning("No documents to upload")
+
return {}
+
+
def search(
+
self,
+
query: str,
+
search_parameters: Optional[dict[str, Any]] = None
+
) -> dict[str, Any]:
+
"""Search the collection."""
+
default_params = {
+
'q': query,
+
'query_by': 'title,searchable_content,searchable_metadata',
+
'sort_by': 'updated:desc',
+
'per_page': 20,
+
}
+
+
if search_parameters:
+
default_params.update(search_parameters)
+
+
return self.client.collections[self.config.collection_name].documents.search(default_params)
+
+118 -1
uv.lock
···
]
[[package]]
name = "click"
version = "8.1.8"
source = { registry = "https://pypi.org/simple" }
···
]
[[package]]
name = "rich"
version = "14.0.0"
source = { registry = "https://pypi.org/simple" }
···
{ name = "pyyaml" },
{ name = "rich" },
{ name = "typer" },
]
[package.optional-dependencies]
···
[package.dev-dependencies]
dev = [
{ name = "pytest" },
]
···
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.8.0" },
{ name = "typer", specifier = ">=0.15.0" },
{ name = "types-pyyaml", marker = "extra == 'dev'", specifier = ">=6.0.0" },
]
provides-extras = ["dev"]
[package.metadata.requires-dev]
-
dev = [{ name = "pytest", specifier = ">=8.4.1" }]
[[package]]
name = "tomli"
···
]
[[package]]
name = "typing-extensions"
version = "4.14.1"
source = { registry = "https://pypi.org/simple" }
···
sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" },
]
[[package]]
···
]
[[package]]
+
name = "charset-normalizer"
+
version = "3.4.3"
+
source = { registry = "https://pypi.org/simple" }
+
sdist = { url = "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", size = 122371, upload-time = "2025-08-09T07:57:28.46Z" }
+
wheels = [
+
{ url = "https://files.pythonhosted.org/packages/d6/98/f3b8013223728a99b908c9344da3aa04ee6e3fa235f19409033eda92fb78/charset_normalizer-3.4.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fb7f67a1bfa6e40b438170ebdc8158b78dc465a5a67b6dde178a46987b244a72", size = 207695, upload-time = "2025-08-09T07:55:36.452Z" },
+
{ url = "https://files.pythonhosted.org/packages/21/40/5188be1e3118c82dcb7c2a5ba101b783822cfb413a0268ed3be0468532de/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc9370a2da1ac13f0153780040f465839e6cccb4a1e44810124b4e22483c93fe", size = 147153, upload-time = "2025-08-09T07:55:38.467Z" },
+
{ url = "https://files.pythonhosted.org/packages/37/60/5d0d74bc1e1380f0b72c327948d9c2aca14b46a9efd87604e724260f384c/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:07a0eae9e2787b586e129fdcbe1af6997f8d0e5abaa0bc98c0e20e124d67e601", size = 160428, upload-time = "2025-08-09T07:55:40.072Z" },
+
{ url = "https://files.pythonhosted.org/packages/85/9a/d891f63722d9158688de58d050c59dc3da560ea7f04f4c53e769de5140f5/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:74d77e25adda8581ffc1c720f1c81ca082921329452eba58b16233ab1842141c", size = 157627, upload-time = "2025-08-09T07:55:41.706Z" },
+
{ url = "https://files.pythonhosted.org/packages/65/1a/7425c952944a6521a9cfa7e675343f83fd82085b8af2b1373a2409c683dc/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0e909868420b7049dafd3a31d45125b31143eec59235311fc4c57ea26a4acd2", size = 152388, upload-time = "2025-08-09T07:55:43.262Z" },
+
{ url = "https://files.pythonhosted.org/packages/f0/c9/a2c9c2a355a8594ce2446085e2ec97fd44d323c684ff32042e2a6b718e1d/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c6f162aabe9a91a309510d74eeb6507fab5fff92337a15acbe77753d88d9dcf0", size = 150077, upload-time = "2025-08-09T07:55:44.903Z" },
+
{ url = "https://files.pythonhosted.org/packages/3b/38/20a1f44e4851aa1c9105d6e7110c9d020e093dfa5836d712a5f074a12bf7/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca4c094de7771a98d7fbd67d9e5dbf1eb73efa4f744a730437d8a3a5cf994f0", size = 161631, upload-time = "2025-08-09T07:55:46.346Z" },
+
{ url = "https://files.pythonhosted.org/packages/a4/fa/384d2c0f57edad03d7bec3ebefb462090d8905b4ff5a2d2525f3bb711fac/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:02425242e96bcf29a49711b0ca9f37e451da7c70562bc10e8ed992a5a7a25cc0", size = 159210, upload-time = "2025-08-09T07:55:47.539Z" },
+
{ url = "https://files.pythonhosted.org/packages/33/9e/eca49d35867ca2db336b6ca27617deed4653b97ebf45dfc21311ce473c37/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:78deba4d8f9590fe4dae384aeff04082510a709957e968753ff3c48399f6f92a", size = 153739, upload-time = "2025-08-09T07:55:48.744Z" },
+
{ url = "https://files.pythonhosted.org/packages/2a/91/26c3036e62dfe8de8061182d33be5025e2424002125c9500faff74a6735e/charset_normalizer-3.4.3-cp310-cp310-win32.whl", hash = "sha256:d79c198e27580c8e958906f803e63cddb77653731be08851c7df0b1a14a8fc0f", size = 99825, upload-time = "2025-08-09T07:55:50.305Z" },
+
{ url = "https://files.pythonhosted.org/packages/e2/c6/f05db471f81af1fa01839d44ae2a8bfeec8d2a8b4590f16c4e7393afd323/charset_normalizer-3.4.3-cp310-cp310-win_amd64.whl", hash = "sha256:c6e490913a46fa054e03699c70019ab869e990270597018cef1d8562132c2669", size = 107452, upload-time = "2025-08-09T07:55:51.461Z" },
+
{ url = "https://files.pythonhosted.org/packages/7f/b5/991245018615474a60965a7c9cd2b4efbaabd16d582a5547c47ee1c7730b/charset_normalizer-3.4.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b256ee2e749283ef3ddcff51a675ff43798d92d746d1a6e4631bf8c707d22d0b", size = 204483, upload-time = "2025-08-09T07:55:53.12Z" },
+
{ url = "https://files.pythonhosted.org/packages/c7/2a/ae245c41c06299ec18262825c1569c5d3298fc920e4ddf56ab011b417efd/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13faeacfe61784e2559e690fc53fa4c5ae97c6fcedb8eb6fb8d0a15b475d2c64", size = 145520, upload-time = "2025-08-09T07:55:54.712Z" },
+
{ url = "https://files.pythonhosted.org/packages/3a/a4/b3b6c76e7a635748c4421d2b92c7b8f90a432f98bda5082049af37ffc8e3/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:00237675befef519d9af72169d8604a067d92755e84fe76492fef5441db05b91", size = 158876, upload-time = "2025-08-09T07:55:56.024Z" },
+
{ url = "https://files.pythonhosted.org/packages/e2/e6/63bb0e10f90a8243c5def74b5b105b3bbbfb3e7bb753915fe333fb0c11ea/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:585f3b2a80fbd26b048a0be90c5aae8f06605d3c92615911c3a2b03a8a3b796f", size = 156083, upload-time = "2025-08-09T07:55:57.582Z" },
+
{ url = "https://files.pythonhosted.org/packages/87/df/b7737ff046c974b183ea9aa111b74185ac8c3a326c6262d413bd5a1b8c69/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e78314bdc32fa80696f72fa16dc61168fda4d6a0c014e0380f9d02f0e5d8a07", size = 150295, upload-time = "2025-08-09T07:55:59.147Z" },
+
{ url = "https://files.pythonhosted.org/packages/61/f1/190d9977e0084d3f1dc169acd060d479bbbc71b90bf3e7bf7b9927dec3eb/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:96b2b3d1a83ad55310de8c7b4a2d04d9277d5591f40761274856635acc5fcb30", size = 148379, upload-time = "2025-08-09T07:56:00.364Z" },
+
{ url = "https://files.pythonhosted.org/packages/4c/92/27dbe365d34c68cfe0ca76f1edd70e8705d82b378cb54ebbaeabc2e3029d/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:939578d9d8fd4299220161fdd76e86c6a251987476f5243e8864a7844476ba14", size = 160018, upload-time = "2025-08-09T07:56:01.678Z" },
+
{ url = "https://files.pythonhosted.org/packages/99/04/baae2a1ea1893a01635d475b9261c889a18fd48393634b6270827869fa34/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:fd10de089bcdcd1be95a2f73dbe6254798ec1bda9f450d5828c96f93e2536b9c", size = 157430, upload-time = "2025-08-09T07:56:02.87Z" },
+
{ url = "https://files.pythonhosted.org/packages/2f/36/77da9c6a328c54d17b960c89eccacfab8271fdaaa228305330915b88afa9/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1e8ac75d72fa3775e0b7cb7e4629cec13b7514d928d15ef8ea06bca03ef01cae", size = 151600, upload-time = "2025-08-09T07:56:04.089Z" },
+
{ url = "https://files.pythonhosted.org/packages/64/d4/9eb4ff2c167edbbf08cdd28e19078bf195762e9bd63371689cab5ecd3d0d/charset_normalizer-3.4.3-cp311-cp311-win32.whl", hash = "sha256:6cf8fd4c04756b6b60146d98cd8a77d0cdae0e1ca20329da2ac85eed779b6849", size = 99616, upload-time = "2025-08-09T07:56:05.658Z" },
+
{ url = "https://files.pythonhosted.org/packages/f4/9c/996a4a028222e7761a96634d1820de8a744ff4327a00ada9c8942033089b/charset_normalizer-3.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:31a9a6f775f9bcd865d88ee350f0ffb0e25936a7f930ca98995c05abf1faf21c", size = 107108, upload-time = "2025-08-09T07:56:07.176Z" },
+
{ url = "https://files.pythonhosted.org/packages/e9/5e/14c94999e418d9b87682734589404a25854d5f5d0408df68bc15b6ff54bb/charset_normalizer-3.4.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1", size = 205655, upload-time = "2025-08-09T07:56:08.475Z" },
+
{ url = "https://files.pythonhosted.org/packages/7d/a8/c6ec5d389672521f644505a257f50544c074cf5fc292d5390331cd6fc9c3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884", size = 146223, upload-time = "2025-08-09T07:56:09.708Z" },
+
{ url = "https://files.pythonhosted.org/packages/fc/eb/a2ffb08547f4e1e5415fb69eb7db25932c52a52bed371429648db4d84fb1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018", size = 159366, upload-time = "2025-08-09T07:56:11.326Z" },
+
{ url = "https://files.pythonhosted.org/packages/82/10/0fd19f20c624b278dddaf83b8464dcddc2456cb4b02bb902a6da126b87a1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392", size = 157104, upload-time = "2025-08-09T07:56:13.014Z" },
+
{ url = "https://files.pythonhosted.org/packages/16/ab/0233c3231af734f5dfcf0844aa9582d5a1466c985bbed6cedab85af9bfe3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f", size = 151830, upload-time = "2025-08-09T07:56:14.428Z" },
+
{ url = "https://files.pythonhosted.org/packages/ae/02/e29e22b4e02839a0e4a06557b1999d0a47db3567e82989b5bb21f3fbbd9f/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154", size = 148854, upload-time = "2025-08-09T07:56:16.051Z" },
+
{ url = "https://files.pythonhosted.org/packages/05/6b/e2539a0a4be302b481e8cafb5af8792da8093b486885a1ae4d15d452bcec/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491", size = 160670, upload-time = "2025-08-09T07:56:17.314Z" },
+
{ url = "https://files.pythonhosted.org/packages/31/e7/883ee5676a2ef217a40ce0bffcc3d0dfbf9e64cbcfbdf822c52981c3304b/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93", size = 158501, upload-time = "2025-08-09T07:56:18.641Z" },
+
{ url = "https://files.pythonhosted.org/packages/c1/35/6525b21aa0db614cf8b5792d232021dca3df7f90a1944db934efa5d20bb1/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f", size = 153173, upload-time = "2025-08-09T07:56:20.289Z" },
+
{ url = "https://files.pythonhosted.org/packages/50/ee/f4704bad8201de513fdc8aac1cabc87e38c5818c93857140e06e772b5892/charset_normalizer-3.4.3-cp312-cp312-win32.whl", hash = "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37", size = 99822, upload-time = "2025-08-09T07:56:21.551Z" },
+
{ url = "https://files.pythonhosted.org/packages/39/f5/3b3836ca6064d0992c58c7561c6b6eee1b3892e9665d650c803bd5614522/charset_normalizer-3.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc", size = 107543, upload-time = "2025-08-09T07:56:23.115Z" },
+
{ url = "https://files.pythonhosted.org/packages/65/ca/2135ac97709b400c7654b4b764daf5c5567c2da45a30cdd20f9eefe2d658/charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe", size = 205326, upload-time = "2025-08-09T07:56:24.721Z" },
+
{ url = "https://files.pythonhosted.org/packages/71/11/98a04c3c97dd34e49c7d247083af03645ca3730809a5509443f3c37f7c99/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8", size = 146008, upload-time = "2025-08-09T07:56:26.004Z" },
+
{ url = "https://files.pythonhosted.org/packages/60/f5/4659a4cb3c4ec146bec80c32d8bb16033752574c20b1252ee842a95d1a1e/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9", size = 159196, upload-time = "2025-08-09T07:56:27.25Z" },
+
{ url = "https://files.pythonhosted.org/packages/86/9e/f552f7a00611f168b9a5865a1414179b2c6de8235a4fa40189f6f79a1753/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31", size = 156819, upload-time = "2025-08-09T07:56:28.515Z" },
+
{ url = "https://files.pythonhosted.org/packages/7e/95/42aa2156235cbc8fa61208aded06ef46111c4d3f0de233107b3f38631803/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f", size = 151350, upload-time = "2025-08-09T07:56:29.716Z" },
+
{ url = "https://files.pythonhosted.org/packages/c2/a9/3865b02c56f300a6f94fc631ef54f0a8a29da74fb45a773dfd3dcd380af7/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927", size = 148644, upload-time = "2025-08-09T07:56:30.984Z" },
+
{ url = "https://files.pythonhosted.org/packages/77/d9/cbcf1a2a5c7d7856f11e7ac2d782aec12bdfea60d104e60e0aa1c97849dc/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9", size = 160468, upload-time = "2025-08-09T07:56:32.252Z" },
+
{ url = "https://files.pythonhosted.org/packages/f6/42/6f45efee8697b89fda4d50580f292b8f7f9306cb2971d4b53f8914e4d890/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5", size = 158187, upload-time = "2025-08-09T07:56:33.481Z" },
+
{ url = "https://files.pythonhosted.org/packages/70/99/f1c3bdcfaa9c45b3ce96f70b14f070411366fa19549c1d4832c935d8e2c3/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc", size = 152699, upload-time = "2025-08-09T07:56:34.739Z" },
+
{ url = "https://files.pythonhosted.org/packages/a3/ad/b0081f2f99a4b194bcbb1934ef3b12aa4d9702ced80a37026b7607c72e58/charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", size = 99580, upload-time = "2025-08-09T07:56:35.981Z" },
+
{ url = "https://files.pythonhosted.org/packages/9a/8f/ae790790c7b64f925e5c953b924aaa42a243fb778fed9e41f147b2a5715a/charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", size = 107366, upload-time = "2025-08-09T07:56:37.339Z" },
+
{ url = "https://files.pythonhosted.org/packages/8e/91/b5a06ad970ddc7a0e513112d40113e834638f4ca1120eb727a249fb2715e/charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15", size = 204342, upload-time = "2025-08-09T07:56:38.687Z" },
+
{ url = "https://files.pythonhosted.org/packages/ce/ec/1edc30a377f0a02689342f214455c3f6c2fbedd896a1d2f856c002fc3062/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db", size = 145995, upload-time = "2025-08-09T07:56:40.048Z" },
+
{ url = "https://files.pythonhosted.org/packages/17/e5/5e67ab85e6d22b04641acb5399c8684f4d37caf7558a53859f0283a650e9/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d", size = 158640, upload-time = "2025-08-09T07:56:41.311Z" },
+
{ url = "https://files.pythonhosted.org/packages/f1/e5/38421987f6c697ee3722981289d554957c4be652f963d71c5e46a262e135/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096", size = 156636, upload-time = "2025-08-09T07:56:43.195Z" },
+
{ url = "https://files.pythonhosted.org/packages/a0/e4/5a075de8daa3ec0745a9a3b54467e0c2967daaaf2cec04c845f73493e9a1/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa", size = 150939, upload-time = "2025-08-09T07:56:44.819Z" },
+
{ url = "https://files.pythonhosted.org/packages/02/f7/3611b32318b30974131db62b4043f335861d4d9b49adc6d57c1149cc49d4/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049", size = 148580, upload-time = "2025-08-09T07:56:46.684Z" },
+
{ url = "https://files.pythonhosted.org/packages/7e/61/19b36f4bd67f2793ab6a99b979b4e4f3d8fc754cbdffb805335df4337126/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0", size = 159870, upload-time = "2025-08-09T07:56:47.941Z" },
+
{ url = "https://files.pythonhosted.org/packages/06/57/84722eefdd338c04cf3030ada66889298eaedf3e7a30a624201e0cbe424a/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92", size = 157797, upload-time = "2025-08-09T07:56:49.756Z" },
+
{ url = "https://files.pythonhosted.org/packages/72/2a/aff5dd112b2f14bcc3462c312dce5445806bfc8ab3a7328555da95330e4b/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16", size = 152224, upload-time = "2025-08-09T07:56:51.369Z" },
+
{ url = "https://files.pythonhosted.org/packages/b7/8c/9839225320046ed279c6e839d51f028342eb77c91c89b8ef2549f951f3ec/charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce", size = 100086, upload-time = "2025-08-09T07:56:52.722Z" },
+
{ url = "https://files.pythonhosted.org/packages/ee/7a/36fbcf646e41f710ce0a563c1c9a343c6edf9be80786edeb15b6f62e17db/charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c", size = 107400, upload-time = "2025-08-09T07:56:55.172Z" },
+
{ url = "https://files.pythonhosted.org/packages/c2/ca/9a0983dd5c8e9733565cf3db4df2b0a2e9a82659fd8aa2a868ac6e4a991f/charset_normalizer-3.4.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:70bfc5f2c318afece2f5838ea5e4c3febada0be750fcf4775641052bbba14d05", size = 207520, upload-time = "2025-08-09T07:57:11.026Z" },
+
{ url = "https://files.pythonhosted.org/packages/39/c6/99271dc37243a4f925b09090493fb96c9333d7992c6187f5cfe5312008d2/charset_normalizer-3.4.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:23b6b24d74478dc833444cbd927c338349d6ae852ba53a0d02a2de1fce45b96e", size = 147307, upload-time = "2025-08-09T07:57:12.4Z" },
+
{ url = "https://files.pythonhosted.org/packages/e4/69/132eab043356bba06eb333cc2cc60c6340857d0a2e4ca6dc2b51312886b3/charset_normalizer-3.4.3-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:34a7f768e3f985abdb42841e20e17b330ad3aaf4bb7e7aeeb73db2e70f077b99", size = 160448, upload-time = "2025-08-09T07:57:13.712Z" },
+
{ url = "https://files.pythonhosted.org/packages/04/9a/914d294daa4809c57667b77470533e65def9c0be1ef8b4c1183a99170e9d/charset_normalizer-3.4.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb731e5deb0c7ef82d698b0f4c5bb724633ee2a489401594c5c88b02e6cb15f7", size = 157758, upload-time = "2025-08-09T07:57:14.979Z" },
+
{ url = "https://files.pythonhosted.org/packages/b0/a8/6f5bcf1bcf63cb45625f7c5cadca026121ff8a6c8a3256d8d8cd59302663/charset_normalizer-3.4.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:257f26fed7d7ff59921b78244f3cd93ed2af1800ff048c33f624c87475819dd7", size = 152487, upload-time = "2025-08-09T07:57:16.332Z" },
+
{ url = "https://files.pythonhosted.org/packages/c4/72/d3d0e9592f4e504f9dea08b8db270821c909558c353dc3b457ed2509f2fb/charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1ef99f0456d3d46a50945c98de1774da86f8e992ab5c77865ea8b8195341fc19", size = 150054, upload-time = "2025-08-09T07:57:17.576Z" },
+
{ url = "https://files.pythonhosted.org/packages/20/30/5f64fe3981677fe63fa987b80e6c01042eb5ff653ff7cec1b7bd9268e54e/charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:2c322db9c8c89009a990ef07c3bcc9f011a3269bc06782f916cd3d9eed7c9312", size = 161703, upload-time = "2025-08-09T07:57:20.012Z" },
+
{ url = "https://files.pythonhosted.org/packages/e1/ef/dd08b2cac9284fd59e70f7d97382c33a3d0a926e45b15fc21b3308324ffd/charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:511729f456829ef86ac41ca78c63a5cb55240ed23b4b737faca0eb1abb1c41bc", size = 159096, upload-time = "2025-08-09T07:57:21.329Z" },
+
{ url = "https://files.pythonhosted.org/packages/45/8c/dcef87cfc2b3f002a6478f38906f9040302c68aebe21468090e39cde1445/charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:88ab34806dea0671532d3f82d82b85e8fc23d7b2dd12fa837978dad9bb392a34", size = 153852, upload-time = "2025-08-09T07:57:22.608Z" },
+
{ url = "https://files.pythonhosted.org/packages/63/86/9cbd533bd37883d467fcd1bd491b3547a3532d0fbb46de2b99feeebf185e/charset_normalizer-3.4.3-cp39-cp39-win32.whl", hash = "sha256:16a8770207946ac75703458e2c743631c79c59c5890c80011d536248f8eaa432", size = 99840, upload-time = "2025-08-09T07:57:23.883Z" },
+
{ url = "https://files.pythonhosted.org/packages/ce/d6/7e805c8e5c46ff9729c49950acc4ee0aeb55efb8b3a56687658ad10c3216/charset_normalizer-3.4.3-cp39-cp39-win_amd64.whl", hash = "sha256:d22dbedd33326a4a5190dd4fe9e9e693ef12160c77382d9e87919bce54f3d4ca", size = 107438, upload-time = "2025-08-09T07:57:25.287Z" },
+
{ url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175, upload-time = "2025-08-09T07:57:26.864Z" },
+
]
+
+
[[package]]
name = "click"
version = "8.1.8"
source = { registry = "https://pypi.org/simple" }
···
]
[[package]]
+
name = "requests"
+
version = "2.32.4"
+
source = { registry = "https://pypi.org/simple" }
+
dependencies = [
+
{ name = "certifi" },
+
{ name = "charset-normalizer" },
+
{ name = "idna" },
+
{ name = "urllib3" },
+
]
+
sdist = { url = "https://files.pythonhosted.org/packages/e1/0a/929373653770d8a0d7ea76c37de6e41f11eb07559b103b1c02cafb3f7cf8/requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422", size = 135258, upload-time = "2025-06-09T16:43:07.34Z" }
+
wheels = [
+
{ url = "https://files.pythonhosted.org/packages/7c/e4/56027c4a6b4ae70ca9de302488c5ca95ad4a39e190093d6c1a8ace08341b/requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c", size = 64847, upload-time = "2025-06-09T16:43:05.728Z" },
+
]
+
+
[[package]]
name = "rich"
version = "14.0.0"
source = { registry = "https://pypi.org/simple" }
···
{ name = "pyyaml" },
{ name = "rich" },
{ name = "typer" },
+
{ name = "typesense" },
]
[package.optional-dependencies]
···
[package.dev-dependencies]
dev = [
+
{ name = "mypy" },
{ name = "pytest" },
]
···
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.8.0" },
{ name = "typer", specifier = ">=0.15.0" },
{ name = "types-pyyaml", marker = "extra == 'dev'", specifier = ">=6.0.0" },
+
{ name = "typesense", specifier = ">=1.1.1" },
]
provides-extras = ["dev"]
[package.metadata.requires-dev]
+
dev = [
+
{ name = "mypy", specifier = ">=1.17.0" },
+
{ name = "pytest", specifier = ">=8.4.1" },
+
]
[[package]]
name = "tomli"
···
]
[[package]]
+
name = "typesense"
+
version = "1.1.1"
+
source = { registry = "https://pypi.org/simple" }
+
dependencies = [
+
{ name = "requests" },
+
]
+
sdist = { url = "https://files.pythonhosted.org/packages/9b/2c/6f012a17934d50f73d20f1138b3bc42cfb7ec465052bd8e56c0dcf8ce92d/typesense-1.1.1.tar.gz", hash = "sha256:876280e5f2bb8a4a24ae427863ee8216d2e9e76cfe96e0a87a379e66078dc591", size = 45214, upload-time = "2025-05-20T18:13:32.865Z" }
+
wheels = [
+
{ url = "https://files.pythonhosted.org/packages/1b/8f/6306446e5ce28ddddd8babf407597b9afa3fff521794fe2dcfb16f12e16a/typesense-1.1.1-py3-none-any.whl", hash = "sha256:633aeb26c24e17be654ea22f20d3f76f87c804f259d0a560b7e0ae817f24077a", size = 70604, upload-time = "2025-05-20T18:13:30.975Z" },
+
]
+
+
[[package]]
name = "typing-extensions"
version = "4.14.1"
source = { registry = "https://pypi.org/simple" }
···
sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" },
+
]
+
+
[[package]]
+
name = "urllib3"
+
version = "2.5.0"
+
source = { registry = "https://pypi.org/simple" }
+
sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" }
+
wheels = [
+
{ url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" },
]
[[package]]