this repo has no description
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

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>

+1092 -4
+2
pyproject.toml
··· 40 40 "platformdirs>=4.0.0", 41 41 "pyyaml>=6.0.0", 42 42 "email_validator", 43 + "typesense>=1.1.1", 43 44 ] 44 45 45 46 [project.optional-dependencies] ··· 169 170 170 171 [dependency-groups] 171 172 dev = [ 173 + "mypy>=1.17.0", 172 174 "pytest>=8.4.1", 173 175 ]
+2 -2
src/thicket/cli/commands/__init__.py
··· 1 1 """CLI commands for thicket.""" 2 2 3 3 # Import all commands to register them with the main app 4 - from . import add, duplicates, info_cmd, init, list_cmd, sync 4 + from . import add, duplicates, info_cmd, init, list_cmd, search, sync, upload 5 5 6 - __all__ = ["add", "duplicates", "info_cmd", "init", "list_cmd", "sync"] 6 + __all__ = ["add", "duplicates", "info_cmd", "init", "list_cmd", "search", "sync", "upload"]
+298
src/thicket/cli/commands/search.py
··· 1 + """Search command for thicket CLI.""" 2 + 3 + import logging 4 + from pathlib import Path 5 + from typing import Optional 6 + 7 + import typer 8 + from rich.console import Console 9 + from rich.table import Table 10 + from rich.text import Text 11 + 12 + from ...core.typesense_client import TypesenseClient, TypesenseConfig 13 + from ..main import app 14 + from ..utils import load_config 15 + 16 + console = Console() 17 + logger = logging.getLogger(__name__) 18 + 19 + 20 + def _load_typesense_config() -> tuple[Optional[str], Optional[str]]: 21 + """Load Typesense URL and API key from ~/.typesense directory.""" 22 + typesense_dir = Path.home() / ".typesense" 23 + url_file = typesense_dir / "url" 24 + key_file = typesense_dir / "api_key" 25 + 26 + url = None 27 + api_key = None 28 + 29 + try: 30 + if url_file.exists(): 31 + url = url_file.read_text().strip() 32 + except Exception as e: 33 + logger.debug(f"Could not read Typesense URL from {url_file}: {e}") 34 + 35 + try: 36 + if key_file.exists(): 37 + api_key = key_file.read_text().strip() 38 + except Exception as e: 39 + logger.debug(f"Could not read Typesense API key from {key_file}: {e}") 40 + 41 + return url, api_key 42 + 43 + 44 + @app.command("search") 45 + def search_command( 46 + query: str = typer.Argument(..., help="Search query"), 47 + typesense_url: Optional[str] = typer.Option( 48 + None, 49 + "--typesense-url", 50 + "-u", 51 + help="Typesense server URL (e.g., http://localhost:8108). Defaults to ~/.typesense/url", 52 + ), 53 + api_key: Optional[str] = typer.Option( 54 + None, 55 + "--api-key", 56 + "-k", 57 + help="Typesense API key. Defaults to ~/.typesense/api_key", 58 + hide_input=True, 59 + ), 60 + collection_name: str = typer.Option( 61 + "thicket", 62 + "--collection", 63 + "-c", 64 + help="Typesense collection name", 65 + ), 66 + config_path: Optional[str] = typer.Option( 67 + None, 68 + "--config", 69 + "-C", 70 + help="Path to thicket configuration file", 71 + ), 72 + limit: int = typer.Option( 73 + 20, 74 + "--limit", 75 + "-l", 76 + help="Maximum number of results to display", 77 + ), 78 + user: Optional[str] = typer.Option( 79 + None, 80 + "--user", 81 + help="Filter results by specific user", 82 + ), 83 + timeout: int = typer.Option( 84 + 10, 85 + "--timeout", 86 + "-t", 87 + help="Connection timeout in seconds", 88 + ), 89 + raw: bool = typer.Option( 90 + False, 91 + "--raw", 92 + help="Display raw JSON output instead of formatted table", 93 + ), 94 + ) -> None: 95 + """Search thicket entries using Typesense full-text and semantic search. 96 + 97 + This command searches through all entries in the Typesense collection 98 + using the provided query. The search covers entry titles, content, 99 + summaries, user information, and metadata. 100 + 101 + Examples: 102 + 103 + # Basic search 104 + thicket search "machine learning" 105 + 106 + # Search with user filter 107 + thicket search "python programming" --user avsm 108 + 109 + # Limit results 110 + thicket search "web development" --limit 10 111 + 112 + # Get raw JSON output 113 + thicket search "database" --raw 114 + """ 115 + try: 116 + # Load Typesense configuration from defaults if not provided 117 + default_url, default_api_key = _load_typesense_config() 118 + 119 + # Use provided values or defaults 120 + final_url = typesense_url or default_url 121 + final_api_key = api_key or default_api_key 122 + 123 + # Check that we have required configuration 124 + if not final_url: 125 + console.print("[red]Error: Typesense URL is required[/red]") 126 + console.print("Either provide --typesense-url or create ~/.typesense/url file") 127 + raise typer.Exit(1) 128 + 129 + if not final_api_key: 130 + console.print("[red]Error: Typesense API key is required[/red]") 131 + console.print("Either provide --api-key or create ~/.typesense/api_key file") 132 + raise typer.Exit(1) 133 + 134 + # Create Typesense configuration 135 + typesense_config = TypesenseConfig.from_url( 136 + final_url, 137 + final_api_key, 138 + collection_name 139 + ) 140 + typesense_config.connection_timeout = timeout 141 + 142 + console.print(f"[bold blue]Searching thicket entries[/bold blue]") 143 + console.print(f"Query: [cyan]{query}[/cyan]") 144 + if user: 145 + console.print(f"User filter: [yellow]{user}[/yellow]") 146 + 147 + # Initialize Typesense client 148 + typesense_client = TypesenseClient(typesense_config) 149 + 150 + # Prepare search parameters 151 + search_params = { 152 + 'per_page': limit, 153 + } 154 + 155 + # Add user filter if specified 156 + if user: 157 + search_params['filter_by'] = f'username:{user}' 158 + 159 + # Perform search 160 + try: 161 + results = typesense_client.search(query, search_params) 162 + 163 + if raw: 164 + import json 165 + console.print(json.dumps(results, indent=2)) 166 + return 167 + 168 + # Display results 169 + _display_search_results(results, query) 170 + 171 + except Exception as e: 172 + console.print(f"[red]❌ Search failed: {e}[/red]") 173 + raise typer.Exit(1) from e 174 + 175 + except Exception as e: 176 + logger.error(f"Search failed: {e}") 177 + console.print(f"[red]Error: {e}[/red]") 178 + raise typer.Exit(1) from e 179 + 180 + 181 + def _display_search_results(results: dict, query: str) -> None: 182 + """Display search results in a formatted table.""" 183 + hits = results.get('hits', []) 184 + found = results.get('found', 0) 185 + search_time = results.get('search_time_ms', 0) 186 + 187 + if not hits: 188 + console.print("\n[yellow]No results found.[/yellow]") 189 + return 190 + 191 + console.print(f"\n[green]Found {found} results in {search_time}ms[/green]") 192 + 193 + table = Table(title=f"Search Results for '{query}'", show_lines=True) 194 + table.add_column("Score", style="green", width=8, no_wrap=True) 195 + table.add_column("User", style="cyan", width=15, no_wrap=True) 196 + table.add_column("Title", style="bold", width=45) 197 + table.add_column("Updated", style="blue", width=12, no_wrap=True) 198 + table.add_column("Summary", style="dim", width=50) 199 + 200 + for hit in hits: 201 + doc = hit['document'] 202 + 203 + # Format score 204 + score = f"{hit.get('text_match', 0):.2f}" 205 + 206 + # Format user 207 + user_display = doc.get('user_display_name', doc.get('username', 'Unknown')) 208 + if len(user_display) > 12: 209 + user_display = user_display[:9] + "..." 210 + 211 + # Format title 212 + title = doc.get('title', 'Untitled') 213 + if len(title) > 40: 214 + title = title[:37] + "..." 215 + 216 + # Format date 217 + updated_timestamp = doc.get('updated', 0) 218 + if updated_timestamp: 219 + from datetime import datetime 220 + updated_date = datetime.fromtimestamp(updated_timestamp) 221 + updated_str = updated_date.strftime("%Y-%m-%d") 222 + else: 223 + updated_str = "Unknown" 224 + 225 + # Format summary 226 + summary = doc.get('summary') or doc.get('content', '') 227 + if summary: 228 + # Remove HTML tags and truncate 229 + import re 230 + summary = re.sub(r'<[^>]+>', '', summary) 231 + summary = summary.strip() 232 + if len(summary) > 60: 233 + summary = summary[:57] + "..." 234 + else: 235 + summary = "" 236 + 237 + table.add_row( 238 + score, 239 + user_display, 240 + title, 241 + updated_str, 242 + summary 243 + ) 244 + 245 + console.print(table) 246 + 247 + # Show additional info 248 + console.print(f"\n[dim]Showing {len(hits)} of {found} results[/dim]") 249 + if len(hits) < found: 250 + console.print(f"[dim]Use --limit to see more results (current limit: {len(hits)})[/dim]") 251 + 252 + 253 + def _display_compact_results(results: dict, query: str) -> None: 254 + """Display search results in a compact format.""" 255 + hits = results.get('hits', []) 256 + found = results.get('found', 0) 257 + 258 + if not hits: 259 + console.print("\n[yellow]No results found.[/yellow]") 260 + return 261 + 262 + console.print(f"\n[green]Found {found} results[/green]\n") 263 + 264 + for i, hit in enumerate(hits, 1): 265 + doc = hit['document'] 266 + score = hit.get('text_match', 0) 267 + 268 + # Header with score and user 269 + user = doc.get('user_display_name', doc.get('username', 'Unknown')) 270 + console.print(f"[green]{i:2d}.[/green] [cyan]{user}[/cyan] [dim](score: {score:.2f})[/dim]") 271 + 272 + # Title 273 + title = doc.get('title', 'Untitled') 274 + console.print(f" [bold]{title}[/bold]") 275 + 276 + # Date and link 277 + updated_timestamp = doc.get('updated', 0) 278 + if updated_timestamp: 279 + from datetime import datetime 280 + updated_date = datetime.fromtimestamp(updated_timestamp) 281 + updated_str = updated_date.strftime("%Y-%m-%d %H:%M") 282 + else: 283 + updated_str = "Unknown date" 284 + 285 + link = doc.get('link', '') 286 + console.print(f" [blue]{updated_str}[/blue] - [link={link}]{link}[/link]") 287 + 288 + # Summary 289 + summary = doc.get('summary') or doc.get('content', '') 290 + if summary: 291 + import re 292 + summary = re.sub(r'<[^>]+>', '', summary) 293 + summary = summary.strip() 294 + if len(summary) > 150: 295 + summary = summary[:147] + "..." 296 + console.print(f" [dim]{summary}[/dim]") 297 + 298 + console.print() # Empty line between results
+299
src/thicket/cli/commands/upload.py
··· 1 + """Upload command for thicket CLI.""" 2 + 3 + import logging 4 + from pathlib import Path 5 + from typing import Optional 6 + 7 + import typer 8 + from rich.console import Console 9 + from rich.progress import Progress, SpinnerColumn, TextColumn 10 + 11 + from ...core.git_store import GitStore 12 + from ...core.typesense_client import TypesenseClient, TypesenseConfig 13 + from ...models.config import ThicketConfig 14 + from ..main import app 15 + from ..utils import load_config 16 + 17 + console = Console() 18 + logger = logging.getLogger(__name__) 19 + 20 + 21 + def _load_typesense_config() -> tuple[Optional[str], Optional[str]]: 22 + """Load Typesense URL and API key from ~/.typesense directory.""" 23 + typesense_dir = Path.home() / ".typesense" 24 + url_file = typesense_dir / "url" 25 + key_file = typesense_dir / "api_key" 26 + 27 + url = None 28 + api_key = None 29 + 30 + try: 31 + if url_file.exists(): 32 + url = url_file.read_text().strip() 33 + except Exception as e: 34 + logger.debug(f"Could not read Typesense URL from {url_file}: {e}") 35 + 36 + try: 37 + if key_file.exists(): 38 + api_key = key_file.read_text().strip() 39 + except Exception as e: 40 + logger.debug(f"Could not read Typesense API key from {key_file}: {e}") 41 + 42 + return url, api_key 43 + 44 + 45 + def _save_typesense_config(url: Optional[str] = None, api_key: Optional[str] = None) -> None: 46 + """Save Typesense URL and API key to ~/.typesense directory.""" 47 + typesense_dir = Path.home() / ".typesense" 48 + typesense_dir.mkdir(exist_ok=True, mode=0o700) # Secure permissions 49 + 50 + if url: 51 + url_file = typesense_dir / "url" 52 + url_file.write_text(url) 53 + url_file.chmod(0o600) 54 + 55 + if api_key: 56 + key_file = typesense_dir / "api_key" 57 + key_file.write_text(api_key) 58 + key_file.chmod(0o600) # Keep API key secure 59 + 60 + 61 + @app.command("upload") 62 + def upload_command( 63 + typesense_url: Optional[str] = typer.Option( 64 + None, 65 + "--typesense-url", 66 + "-u", 67 + help="Typesense server URL (e.g., http://localhost:8108). Defaults to ~/.typesense/url", 68 + ), 69 + api_key: Optional[str] = typer.Option( 70 + None, 71 + "--api-key", 72 + "-k", 73 + help="Typesense API key. Defaults to ~/.typesense/api_key", 74 + hide_input=True, 75 + ), 76 + collection_name: str = typer.Option( 77 + "thicket_entries", 78 + "--collection", 79 + "-c", 80 + help="Typesense collection name", 81 + ), 82 + config_path: Optional[str] = typer.Option( 83 + None, 84 + "--config", 85 + "-C", 86 + help="Path to thicket configuration file", 87 + ), 88 + git_store_path: Optional[str] = typer.Option( 89 + None, 90 + "--git-store", 91 + "-g", 92 + help="Path to Git store (overrides config)", 93 + ), 94 + timeout: int = typer.Option( 95 + 10, 96 + "--timeout", 97 + "-t", 98 + help="Connection timeout in seconds", 99 + ), 100 + dry_run: bool = typer.Option( 101 + False, 102 + "--dry-run", 103 + help="Show what would be uploaded without actually uploading", 104 + ), 105 + ) -> None: 106 + """Upload thicket entries to a Typesense search engine. 107 + 108 + This command uploads all entries from the Git store to a Typesense server 109 + for full-text and semantic search capabilities. The uploaded data includes 110 + entry content, metadata, user information, and searchable text fields 111 + optimized for embedding-based queries. 112 + 113 + Configuration defaults can be stored in ~/.typesense/ directory: 114 + - URL in ~/.typesense/url 115 + - API key in ~/.typesense/api_key 116 + 117 + Examples: 118 + 119 + # Upload using saved defaults (first run will save config) 120 + thicket upload -u http://localhost:8108 -k your-api-key 121 + 122 + # Subsequent runs can omit URL and key if saved 123 + thicket upload 124 + 125 + # Upload to remote server with custom collection name 126 + thicket upload -u https://search.example.com -k api-key -c my_blog_entries 127 + 128 + # Dry run to see what would be uploaded 129 + thicket upload --dry-run 130 + """ 131 + try: 132 + # Load Typesense configuration from defaults if not provided 133 + default_url, default_api_key = _load_typesense_config() 134 + 135 + # Use provided values or defaults 136 + final_url = typesense_url or default_url 137 + final_api_key = api_key or default_api_key 138 + 139 + # Check that we have required configuration 140 + if not final_url: 141 + console.print("[red]Error: Typesense URL is required[/red]") 142 + console.print("Either provide --typesense-url or create ~/.typesense/url file") 143 + raise typer.Exit(1) 144 + 145 + if not final_api_key: 146 + console.print("[red]Error: Typesense API key is required[/red]") 147 + console.print("Either provide --api-key or create ~/.typesense/api_key file") 148 + raise typer.Exit(1) 149 + 150 + # Save configuration if provided via command line (for future use) 151 + if typesense_url or api_key: 152 + _save_typesense_config(typesense_url, api_key) 153 + 154 + # Load thicket configuration 155 + config_path_obj = Path(config_path) if config_path else None 156 + config = load_config(config_path_obj) 157 + 158 + # Override git store path if provided 159 + if git_store_path: 160 + config.git_store = Path(git_store_path) 161 + 162 + console.print("[bold blue]Thicket Typesense Upload[/bold blue]") 163 + console.print(f"Git store: {config.git_store}") 164 + console.print(f"Typesense URL: {final_url}") 165 + 166 + # Show where config is loaded from 167 + if not typesense_url and default_url: 168 + console.print("[dim] (URL loaded from ~/.typesense/url)[/dim]") 169 + if not api_key and default_api_key: 170 + console.print("[dim] (API key loaded from ~/.typesense/api_key)[/dim]") 171 + 172 + console.print(f"Collection: {collection_name}") 173 + 174 + if dry_run: 175 + console.print("[yellow]DRY RUN MODE - No data will be uploaded[/yellow]") 176 + 177 + # Initialize Git store 178 + git_store = GitStore(config.git_store) 179 + if not git_store.repo or not config.git_store.exists(): 180 + console.print("[red]Error: Git store is not valid or not initialized[/red]") 181 + console.print("Run 'thicket init' first to set up the Git store.") 182 + raise typer.Exit(1) 183 + 184 + # Create Typesense configuration 185 + typesense_config = TypesenseConfig.from_url( 186 + final_url, 187 + final_api_key, 188 + collection_name 189 + ) 190 + typesense_config.connection_timeout = timeout 191 + 192 + if dry_run: 193 + _dry_run_upload(git_store, config, typesense_config) 194 + else: 195 + _perform_upload(git_store, config, typesense_config) 196 + 197 + except Exception as e: 198 + logger.error(f"Upload failed: {e}") 199 + console.print(f"[red]Error: {e}[/red]") 200 + raise typer.Exit(1) from e 201 + 202 + 203 + def _dry_run_upload(git_store: GitStore, config: ThicketConfig, typesense_config: TypesenseConfig) -> None: 204 + """Perform a dry run showing what would be uploaded.""" 205 + console.print("\n[bold]Dry run analysis:[/bold]") 206 + 207 + index = git_store._load_index() 208 + total_entries = 0 209 + 210 + for username, user_metadata in index.users.items(): 211 + try: 212 + user_dir = git_store.repo_path / user_metadata.directory 213 + if not user_dir.exists(): 214 + console.print(f" ⚠️ User {username}: Directory not found") 215 + continue 216 + 217 + entry_files = list(user_dir.glob("*.json")) 218 + total_entries += len(entry_files) 219 + console.print(f" ✅ User {username}: {len(entry_files)} entries would be uploaded") 220 + except Exception as e: 221 + console.print(f" ❌ User {username}: Error loading entries - {e}") 222 + 223 + console.print("\n[bold]Summary:[/bold]") 224 + console.print(f" • Total users: {len(index.users)}") 225 + console.print(f" • Total entries to upload: {total_entries}") 226 + console.print(f" • Target collection: {typesense_config.collection_name}") 227 + console.print(f" • Typesense server: {typesense_config.protocol}://{typesense_config.host}:{typesense_config.port}") 228 + 229 + if total_entries > 0: 230 + console.print("\n[green]Ready to upload! Remove --dry-run to proceed.[/green]") 231 + else: 232 + console.print("\n[yellow]No entries found to upload.[/yellow]") 233 + 234 + 235 + def _perform_upload(git_store: GitStore, config: ThicketConfig, typesense_config: TypesenseConfig) -> None: 236 + """Perform the actual upload to Typesense.""" 237 + with Progress( 238 + SpinnerColumn(), 239 + TextColumn("[progress.description]{task.description}"), 240 + console=console, 241 + ) as progress: 242 + 243 + # Test connection 244 + progress.add_task("Testing Typesense connection...", total=None) 245 + 246 + try: 247 + typesense_client = TypesenseClient(typesense_config) 248 + # Test connection by attempting to list collections 249 + typesense_client.client.collections.retrieve() 250 + progress.stop() 251 + console.print("[green]✅ Connected to Typesense server[/green]") 252 + except Exception as e: 253 + progress.stop() 254 + console.print(f"[red]❌ Failed to connect to Typesense: {e}[/red]") 255 + raise typer.Exit(1) from e 256 + 257 + # Perform upload 258 + with Progress( 259 + SpinnerColumn(), 260 + TextColumn("[progress.description]{task.description}"), 261 + console=console, 262 + ) as upload_progress: 263 + 264 + upload_progress.add_task("Uploading entries to Typesense...", total=None) 265 + 266 + try: 267 + result = typesense_client.upload_from_git_store(git_store, config) 268 + upload_progress.stop() 269 + 270 + # Parse results if available 271 + if result: 272 + if isinstance(result, list): 273 + # Batch import results 274 + success_count = sum(1 for r in result if r.get("success")) 275 + total_count = len(result) 276 + console.print(f"[green]✅ Upload completed: {success_count}/{total_count} documents uploaded successfully[/green]") 277 + 278 + # Show any errors 279 + errors = [r for r in result if not r.get("success")] 280 + if errors: 281 + console.print(f"[yellow]⚠️ {len(errors)} documents had errors[/yellow]") 282 + for i, error in enumerate(errors[:5]): # Show first 5 errors 283 + console.print(f" Error {i+1}: {error}") 284 + if len(errors) > 5: 285 + console.print(f" ... and {len(errors) - 5} more errors") 286 + else: 287 + console.print("[green]✅ Upload completed successfully[/green]") 288 + else: 289 + console.print("[yellow]⚠️ Upload completed but no result data available[/yellow]") 290 + 291 + console.print("\n[bold]Collection information:[/bold]") 292 + console.print(f" • Server: {typesense_config.protocol}://{typesense_config.host}:{typesense_config.port}") 293 + console.print(f" • Collection: {typesense_config.collection_name}") 294 + console.print("\n[dim]You can now search your entries using the Typesense API or dashboard.[/dim]") 295 + 296 + except Exception as e: 297 + upload_progress.stop() 298 + console.print(f"[red]❌ Upload failed: {e}[/red]") 299 + raise typer.Exit(1) from e
+1 -1
src/thicket/cli/main.py
··· 47 47 48 48 49 49 # Import commands to register them 50 - from .commands import add, duplicates, info_cmd, init, list_cmd, sync # noqa: F401 50 + from .commands import add, duplicates, info_cmd, init, list_cmd, sync, upload # noqa: F401 51 51 52 52 if __name__ == "__main__": 53 53 app()
+372
src/thicket/core/typesense_client.py
··· 1 + """Typesense integration for thicket.""" 2 + 3 + import json 4 + import logging 5 + from datetime import datetime 6 + from typing import Any, Optional 7 + from urllib.parse import urlparse 8 + 9 + import typesense 10 + from pydantic import BaseModel, ConfigDict 11 + 12 + from ..models.config import ThicketConfig, UserConfig 13 + from ..models.feed import AtomEntry 14 + from ..models.user import UserMetadata 15 + from .git_store import GitStore 16 + 17 + logger = logging.getLogger(__name__) 18 + 19 + 20 + class TypesenseConfig(BaseModel): 21 + """Configuration for Typesense connection.""" 22 + 23 + model_config = ConfigDict(str_strip_whitespace=True) 24 + 25 + host: str 26 + port: int = 8108 27 + protocol: str = "http" 28 + api_key: str 29 + connection_timeout: int = 5 30 + collection_name: str = "thicket_entries" 31 + 32 + @classmethod 33 + def from_url(cls, url: str, api_key: str, collection_name: str = "thicket_entries") -> "TypesenseConfig": 34 + """Create config from Typesense URL.""" 35 + parsed = urlparse(url) 36 + return cls( 37 + host=parsed.hostname or "localhost", 38 + port=parsed.port or (443 if parsed.scheme == "https" else 8108), 39 + protocol=parsed.scheme or "http", 40 + api_key=api_key, 41 + collection_name=collection_name, 42 + ) 43 + 44 + 45 + class TypesenseDocument(BaseModel): 46 + """Document model for Typesense indexing.""" 47 + 48 + model_config = ConfigDict( 49 + json_encoders={datetime: lambda v: int(v.timestamp())}, 50 + str_strip_whitespace=True, 51 + ) 52 + 53 + # Primary fields from AtomEntry 54 + id: str # Sanitized entry ID 55 + original_id: str # Original Atom ID 56 + title: str 57 + link: str 58 + updated: int # Unix timestamp 59 + published: Optional[int] = None # Unix timestamp 60 + summary: Optional[str] = None 61 + content: Optional[str] = None 62 + content_type: str = "html" 63 + categories: list[str] = [] 64 + rights: Optional[str] = None 65 + source: Optional[str] = None 66 + 67 + # User/feed metadata 68 + username: str 69 + user_display_name: Optional[str] = None 70 + user_email: Optional[str] = None 71 + user_homepage: Optional[str] = None 72 + user_icon: Optional[str] = None 73 + 74 + # Author information from entry 75 + author_name: Optional[str] = None 76 + author_email: Optional[str] = None 77 + author_uri: Optional[str] = None 78 + 79 + # Searchable text fields for embedding/semantic search 80 + searchable_content: str # Combined title + summary + content 81 + searchable_metadata: str # Combined user info + categories + author 82 + 83 + @classmethod 84 + def from_atom_entry_with_metadata( 85 + cls, 86 + entry: AtomEntry, 87 + sanitized_id: str, 88 + user_metadata: "UserMetadata", # Import will be added at top 89 + ) -> "TypesenseDocument": 90 + """Create TypesenseDocument from AtomEntry and UserMetadata from git store.""" 91 + # Extract author information if available 92 + author_name = None 93 + author_email = None 94 + author_uri = None 95 + if entry.author: 96 + author_name = entry.author.get("name") 97 + author_email = entry.author.get("email") 98 + author_uri = entry.author.get("uri") 99 + 100 + # Create searchable content combining all text fields 101 + content_parts = [entry.title] 102 + if entry.summary: 103 + content_parts.append(entry.summary) 104 + if entry.content: 105 + content_parts.append(entry.content) 106 + searchable_content = " ".join(content_parts) 107 + 108 + # Create searchable metadata 109 + metadata_parts = [user_metadata.username] 110 + if user_metadata.display_name: 111 + metadata_parts.append(user_metadata.display_name) 112 + if author_name: 113 + metadata_parts.append(author_name) 114 + if entry.categories: 115 + metadata_parts.extend(entry.categories) 116 + searchable_metadata = " ".join(metadata_parts) 117 + 118 + return cls( 119 + id=sanitized_id, 120 + original_id=entry.id, 121 + title=entry.title, 122 + link=str(entry.link), 123 + updated=int(entry.updated.timestamp()), 124 + published=int(entry.published.timestamp()) if entry.published else None, 125 + summary=entry.summary, 126 + content=entry.content, 127 + content_type=entry.content_type or "html", 128 + categories=entry.categories, 129 + rights=entry.rights, 130 + source=entry.source, 131 + username=user_metadata.username, 132 + user_display_name=user_metadata.display_name, 133 + user_email=user_metadata.email, 134 + user_homepage=user_metadata.homepage, 135 + user_icon=user_metadata.icon if user_metadata.icon != "None" else None, 136 + author_name=author_name, 137 + author_email=author_email, 138 + author_uri=author_uri, 139 + searchable_content=searchable_content, 140 + searchable_metadata=searchable_metadata, 141 + ) 142 + 143 + @classmethod 144 + def from_atom_entry( 145 + cls, 146 + entry: AtomEntry, 147 + sanitized_id: str, 148 + user_config: UserConfig, 149 + ) -> "TypesenseDocument": 150 + """Create TypesenseDocument from AtomEntry and UserConfig.""" 151 + # Extract author information if available 152 + author_name = None 153 + author_email = None 154 + author_uri = None 155 + if entry.author: 156 + author_name = entry.author.get("name") 157 + author_email = entry.author.get("email") 158 + author_uri = entry.author.get("uri") 159 + 160 + # Create searchable content combining all text fields 161 + content_parts = [entry.title] 162 + if entry.summary: 163 + content_parts.append(entry.summary) 164 + if entry.content: 165 + content_parts.append(entry.content) 166 + searchable_content = " ".join(content_parts) 167 + 168 + # Create searchable metadata 169 + metadata_parts = [user_config.username] 170 + if user_config.display_name: 171 + metadata_parts.append(user_config.display_name) 172 + if author_name: 173 + metadata_parts.append(author_name) 174 + if entry.categories: 175 + metadata_parts.extend(entry.categories) 176 + searchable_metadata = " ".join(metadata_parts) 177 + 178 + return cls( 179 + id=sanitized_id, 180 + original_id=entry.id, 181 + title=entry.title, 182 + link=str(entry.link), 183 + updated=int(entry.updated.timestamp()), 184 + published=int(entry.published.timestamp()) if entry.published else None, 185 + summary=entry.summary, 186 + content=entry.content, 187 + content_type=entry.content_type or "html", 188 + categories=entry.categories, 189 + rights=entry.rights, 190 + source=entry.source, 191 + username=user_config.username, 192 + user_display_name=user_config.display_name, 193 + user_email=str(user_config.email) if user_config.email else None, 194 + user_homepage=str(user_config.homepage) if user_config.homepage else None, 195 + user_icon=str(user_config.icon) if user_config.icon else None, 196 + author_name=author_name, 197 + author_email=author_email, 198 + author_uri=author_uri, 199 + searchable_content=searchable_content, 200 + searchable_metadata=searchable_metadata, 201 + ) 202 + 203 + 204 + class TypesenseClient: 205 + """Client for interacting with Typesense search engine.""" 206 + 207 + def __init__(self, config: TypesenseConfig): 208 + """Initialize Typesense client.""" 209 + self.config = config 210 + self.client = typesense.Client({ 211 + 'nodes': [{ 212 + 'host': config.host, 213 + 'port': config.port, 214 + 'protocol': config.protocol, 215 + }], 216 + 'api_key': config.api_key, 217 + 'connection_timeout_seconds': config.connection_timeout, 218 + }) 219 + 220 + def get_collection_schema(self) -> dict[str, Any]: 221 + """Get the Typesense collection schema for thicket entries.""" 222 + return { 223 + 'name': self.config.collection_name, 224 + 'fields': [ 225 + # Primary identifiers 226 + {'name': 'id', 'type': 'string', 'facet': False}, 227 + {'name': 'original_id', 'type': 'string', 'facet': False}, 228 + 229 + # Content fields - optimized for search 230 + {'name': 'title', 'type': 'string', 'facet': False}, 231 + {'name': 'summary', 'type': 'string', 'optional': True, 'facet': False}, 232 + {'name': 'content', 'type': 'string', 'optional': True, 'facet': False}, 233 + {'name': 'content_type', 'type': 'string', 'facet': True}, 234 + 235 + # Searchable combined fields for embeddings/semantic search 236 + {'name': 'searchable_content', 'type': 'string', 'facet': False}, 237 + {'name': 'searchable_metadata', 'type': 'string', 'facet': False}, 238 + 239 + # Temporal fields 240 + {'name': 'updated', 'type': 'int64', 'facet': False, 'sort': True}, 241 + {'name': 'published', 'type': 'int64', 'optional': True, 'facet': False, 'sort': True}, 242 + 243 + # Link and source 244 + {'name': 'link', 'type': 'string', 'facet': False}, 245 + {'name': 'source', 'type': 'string', 'optional': True, 'facet': False}, 246 + 247 + # Categories and classification 248 + {'name': 'categories', 'type': 'string[]', 'facet': True, 'optional': True}, 249 + {'name': 'rights', 'type': 'string', 'optional': True, 'facet': False}, 250 + 251 + # User/feed metadata - facetable for filtering 252 + {'name': 'username', 'type': 'string', 'facet': True}, 253 + {'name': 'user_display_name', 'type': 'string', 'optional': True, 'facet': True}, 254 + {'name': 'user_email', 'type': 'string', 'optional': True, 'facet': False}, 255 + {'name': 'user_homepage', 'type': 'string', 'optional': True, 'facet': False}, 256 + {'name': 'user_icon', 'type': 'string', 'optional': True, 'facet': False}, 257 + 258 + # Author information from entries 259 + {'name': 'author_name', 'type': 'string', 'optional': True, 'facet': True}, 260 + {'name': 'author_email', 'type': 'string', 'optional': True, 'facet': False}, 261 + {'name': 'author_uri', 'type': 'string', 'optional': True, 'facet': False}, 262 + ], 263 + 'default_sorting_field': 'updated', 264 + } 265 + 266 + def create_collection(self) -> dict[str, Any]: 267 + """Create the Typesense collection with the appropriate schema.""" 268 + try: 269 + # Try to delete existing collection first 270 + try: 271 + self.client.collections[self.config.collection_name].delete() 272 + logger.info(f"Deleted existing collection: {self.config.collection_name}") 273 + except typesense.exceptions.ObjectNotFound: 274 + logger.info(f"Collection {self.config.collection_name} does not exist, creating new one") 275 + 276 + # Create new collection 277 + schema = self.get_collection_schema() 278 + result = self.client.collections.create(schema) 279 + logger.info(f"Created collection: {self.config.collection_name}") 280 + return result 281 + 282 + except Exception as e: 283 + logger.error(f"Failed to create collection: {e}") 284 + raise 285 + 286 + def index_documents(self, documents: list[TypesenseDocument]) -> dict[str, Any]: 287 + """Index a batch of documents in Typesense.""" 288 + try: 289 + # Convert documents to dict format for Typesense 290 + document_dicts = [doc.model_dump() for doc in documents] 291 + 292 + # Use import endpoint for batch indexing 293 + result = self.client.collections[self.config.collection_name].documents.import_( 294 + document_dicts, 295 + {'action': 'upsert'} # Update if exists, insert if not 296 + ) 297 + 298 + logger.info(f"Indexed {len(documents)} documents") 299 + return result 300 + 301 + except Exception as e: 302 + logger.error(f"Failed to index documents: {e}") 303 + raise 304 + 305 + def upload_from_git_store(self, git_store: GitStore, config: ThicketConfig) -> dict[str, Any]: 306 + """Upload all entries from the Git store to Typesense.""" 307 + logger.info("Starting Typesense upload from Git store") 308 + 309 + # Create collection 310 + self.create_collection() 311 + 312 + documents = [] 313 + index = git_store._load_index() 314 + 315 + for username, user_metadata in index.users.items(): 316 + logger.info(f"Processing entries for user: {username}") 317 + 318 + # Load user entries from directory 319 + try: 320 + user_dir = git_store.repo_path / user_metadata.directory 321 + if not user_dir.exists(): 322 + logger.warning(f"Directory not found for user {username}: {user_dir}") 323 + continue 324 + 325 + entry_files = list(user_dir.glob("*.json")) 326 + logger.info(f"Found {len(entry_files)} entry files for {username}") 327 + 328 + for entry_file in entry_files: 329 + try: 330 + with open(entry_file) as f: 331 + data = json.load(f) 332 + 333 + entry = AtomEntry(**data) 334 + sanitized_id = entry_file.stem # filename without extension 335 + 336 + doc = TypesenseDocument.from_atom_entry_with_metadata( 337 + entry, sanitized_id, user_metadata 338 + ) 339 + documents.append(doc) 340 + except Exception as e: 341 + logger.error(f"Failed to convert entry {entry_file} to document: {e}") 342 + 343 + except Exception as e: 344 + logger.error(f"Failed to load entries for user {username}: {e}") 345 + 346 + if documents: 347 + logger.info(f"Uploading {len(documents)} documents to Typesense") 348 + result = self.index_documents(documents) 349 + logger.info("Upload completed successfully") 350 + return result 351 + else: 352 + logger.warning("No documents to upload") 353 + return {} 354 + 355 + def search( 356 + self, 357 + query: str, 358 + search_parameters: Optional[dict[str, Any]] = None 359 + ) -> dict[str, Any]: 360 + """Search the collection.""" 361 + default_params = { 362 + 'q': query, 363 + 'query_by': 'title,searchable_content,searchable_metadata', 364 + 'sort_by': 'updated:desc', 365 + 'per_page': 20, 366 + } 367 + 368 + if search_parameters: 369 + default_params.update(search_parameters) 370 + 371 + return self.client.collections[self.config.collection_name].documents.search(default_params) 372 +
+118 -1
uv.lock
··· 91 91 ] 92 92 93 93 [[package]] 94 + name = "charset-normalizer" 95 + version = "3.4.3" 96 + source = { registry = "https://pypi.org/simple" } 97 + 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" } 98 + wheels = [ 99 + { 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" }, 100 + { 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" }, 101 + { 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" }, 102 + { 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" }, 103 + { 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" }, 104 + { 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" }, 105 + { 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" }, 106 + { 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" }, 107 + { 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" }, 108 + { 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" }, 109 + { 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" }, 110 + { 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" }, 111 + { 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" }, 112 + { 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" }, 113 + { 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" }, 114 + { 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" }, 115 + { 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" }, 116 + { 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" }, 117 + { 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" }, 118 + { 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" }, 119 + { 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" }, 120 + { 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" }, 121 + { 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" }, 122 + { 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" }, 123 + { 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" }, 124 + { 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" }, 125 + { 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" }, 126 + { 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" }, 127 + { 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" }, 128 + { 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" }, 129 + { 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" }, 130 + { 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" }, 131 + { 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" }, 132 + { 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" }, 133 + { 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" }, 134 + { 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" }, 135 + { 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" }, 136 + { 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" }, 137 + { 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" }, 138 + { 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" }, 139 + { 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" }, 140 + { 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" }, 141 + { 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" }, 142 + { 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" }, 143 + { 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" }, 144 + { 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" }, 145 + { 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" }, 146 + { 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" }, 147 + { 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" }, 148 + { 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" }, 149 + { 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" }, 150 + { 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" }, 151 + { 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" }, 152 + { 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" }, 153 + { 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" }, 154 + { 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" }, 155 + { 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" }, 156 + { 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" }, 157 + { 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" }, 158 + { 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" }, 159 + { 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" }, 160 + { 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" }, 161 + { 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" }, 162 + { 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" }, 163 + { 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" }, 164 + { 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" }, 165 + { 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" }, 166 + ] 167 + 168 + [[package]] 94 169 name = "click" 95 170 version = "8.1.8" 96 171 source = { registry = "https://pypi.org/simple" } ··· 786 861 ] 787 862 788 863 [[package]] 864 + name = "requests" 865 + version = "2.32.4" 866 + source = { registry = "https://pypi.org/simple" } 867 + dependencies = [ 868 + { name = "certifi" }, 869 + { name = "charset-normalizer" }, 870 + { name = "idna" }, 871 + { name = "urllib3" }, 872 + ] 873 + 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" } 874 + wheels = [ 875 + { 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" }, 876 + ] 877 + 878 + [[package]] 789 879 name = "rich" 790 880 version = "14.0.0" 791 881 source = { registry = "https://pypi.org/simple" } ··· 882 972 { name = "pyyaml" }, 883 973 { name = "rich" }, 884 974 { name = "typer" }, 975 + { name = "typesense" }, 885 976 ] 886 977 887 978 [package.optional-dependencies] ··· 897 988 898 989 [package.dev-dependencies] 899 990 dev = [ 991 + { name = "mypy" }, 900 992 { name = "pytest" }, 901 993 ] 902 994 ··· 921 1013 { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.8.0" }, 922 1014 { name = "typer", specifier = ">=0.15.0" }, 923 1015 { name = "types-pyyaml", marker = "extra == 'dev'", specifier = ">=6.0.0" }, 1016 + { name = "typesense", specifier = ">=1.1.1" }, 924 1017 ] 925 1018 provides-extras = ["dev"] 926 1019 927 1020 [package.metadata.requires-dev] 928 - dev = [{ name = "pytest", specifier = ">=8.4.1" }] 1021 + dev = [ 1022 + { name = "mypy", specifier = ">=1.17.0" }, 1023 + { name = "pytest", specifier = ">=8.4.1" }, 1024 + ] 929 1025 930 1026 [[package]] 931 1027 name = "tomli" ··· 992 1088 ] 993 1089 994 1090 [[package]] 1091 + name = "typesense" 1092 + version = "1.1.1" 1093 + source = { registry = "https://pypi.org/simple" } 1094 + dependencies = [ 1095 + { name = "requests" }, 1096 + ] 1097 + 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" } 1098 + wheels = [ 1099 + { 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" }, 1100 + ] 1101 + 1102 + [[package]] 995 1103 name = "typing-extensions" 996 1104 version = "4.14.1" 997 1105 source = { registry = "https://pypi.org/simple" } ··· 1019 1127 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" } 1020 1128 wheels = [ 1021 1129 { 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" }, 1130 + ] 1131 + 1132 + [[package]] 1133 + name = "urllib3" 1134 + version = "2.5.0" 1135 + source = { registry = "https://pypi.org/simple" } 1136 + 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" } 1137 + wheels = [ 1138 + { 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" }, 1022 1139 ] 1023 1140 1024 1141 [[package]]