onesearch-cli 0.12.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,142 @@
1
+ # Copyright (C) 2025 demigodmode
2
+ # SPDX-License-Identifier: AGPL-3.0-only
3
+
4
+ """Search command."""
5
+
6
+ import json
7
+
8
+ import click
9
+ from rich.markup import escape
10
+ from rich.text import Text
11
+
12
+ from onesearch.api import APIError
13
+ from onesearch.context import Context, console, err_console, pass_context
14
+ from onesearch.main import cli
15
+
16
+
17
+ def format_size(size_bytes: int | None) -> str:
18
+ """Format bytes as human-readable size."""
19
+ if size_bytes is None:
20
+ return "-"
21
+ size: float = float(size_bytes)
22
+ for unit in ["B", "KB", "MB", "GB"]:
23
+ if abs(size) < 1024:
24
+ return f"{size:.1f} {unit}"
25
+ size /= 1024
26
+ return f"{size:.1f} TB"
27
+
28
+
29
+ def highlight_snippet(text: str, query: str) -> Text:
30
+ """Create a Rich Text with query terms highlighted."""
31
+ result = Text()
32
+ text_lower = text.lower()
33
+ query_terms = query.lower().split()
34
+
35
+ i = 0
36
+ while i < len(text):
37
+ matched = False
38
+ for term in query_terms:
39
+ if text_lower[i:].startswith(term):
40
+ result.append(text[i:i + len(term)], style="bold yellow")
41
+ i += len(term)
42
+ matched = True
43
+ break
44
+ if not matched:
45
+ result.append(text[i])
46
+ i += 1
47
+
48
+ return result
49
+
50
+
51
+ @cli.command()
52
+ @click.argument("query")
53
+ @click.option("--source", "-s", "source_id", help="Filter by source ID.")
54
+ @click.option("--type", "-t", "file_type", type=click.Choice(["text", "markdown", "pdf"]), help="Filter by file type.")
55
+ @click.option("--limit", "-l", default=20, help="Max results to return.", show_default=True)
56
+ @click.option("--offset", "-o", default=0, help="Result offset for pagination.")
57
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON for scripting.")
58
+ @pass_context
59
+ def search(
60
+ ctx: Context,
61
+ query: str,
62
+ source_id: str | None,
63
+ file_type: str | None,
64
+ limit: int,
65
+ offset: int,
66
+ as_json: bool,
67
+ ):
68
+ """Search indexed documents.
69
+
70
+ \b
71
+ Arguments:
72
+ QUERY Search query string
73
+
74
+ \b
75
+ Examples:
76
+ onesearch search "kubernetes deployment"
77
+ onesearch search "python" --source 1 --type pdf --limit 10
78
+ onesearch search "error" --json | jq '.results[].path'
79
+ """
80
+ api = ctx.get_api()
81
+ try:
82
+ result = api.search(
83
+ query=query,
84
+ source_id=source_id,
85
+ file_type=file_type,
86
+ limit=limit,
87
+ offset=offset,
88
+ )
89
+
90
+ out = ctx.get_console()
91
+
92
+ if as_json:
93
+ console.print(json.dumps(result, indent=2))
94
+ return
95
+
96
+ # Backend returns: {results, total, limit, offset, processing_time_ms}
97
+ results = result.get("results", [])
98
+ total = result.get("total", len(results))
99
+ processing_time = result.get("processing_time_ms", 0)
100
+
101
+ if not results:
102
+ out.print(f"[dim]No results found for:[/dim] [cyan]{escape(query)}[/cyan]")
103
+ return
104
+
105
+ out.print(f"\nFound [green]{total}[/green] results in [dim]{processing_time}ms[/dim]\n")
106
+
107
+ for i, hit in enumerate(results, start=offset + 1):
108
+ # Title line - backend returns: basename, source_name
109
+ filename = hit.get("basename", hit.get("path", "Unknown").split("/")[-1])
110
+ source_name = hit.get("source_name", "Unknown")
111
+ console.print(f"[bold][{i}][/bold] [cyan]{escape(filename)}[/cyan] [dim]({source_name})[/dim]")
112
+
113
+ # Metadata line - backend returns: type, size_bytes, modified_at
114
+ file_type_display = hit.get("type", "unknown")
115
+ size = format_size(hit.get("size_bytes"))
116
+ modified = hit.get("modified_at", "-")
117
+ # modified_at is Unix timestamp
118
+ if isinstance(modified, int):
119
+ from datetime import datetime
120
+ modified = datetime.fromtimestamp(modified).strftime("%Y-%m-%d")
121
+ console.print(f" Path: {escape(hit.get('path', '-'))}")
122
+ console.print(f" Type: {file_type_display} | Size: {size} | Modified: {modified}")
123
+
124
+ # Snippet - backend returns pre-formatted snippet
125
+ snippet = hit.get("snippet", "")
126
+ if snippet:
127
+ snippet = snippet.replace("\n", " ").strip()
128
+ highlighted = highlight_snippet(snippet, query)
129
+ console.print(" ", end="")
130
+ console.print(highlighted)
131
+
132
+ console.print()
133
+
134
+ # Pagination hint (only in non-quiet mode)
135
+ if offset + len(results) < total:
136
+ next_offset = offset + limit
137
+ out.print(f"[dim]Showing {offset + 1}-{offset + len(results)} of {total}. ")
138
+ out.print(f"Use --offset {next_offset} for more results.[/dim]")
139
+
140
+ except APIError as e:
141
+ err_console.print(f"[red]Error:[/red] {e.message}")
142
+ raise SystemExit(1) from e
@@ -0,0 +1,232 @@
1
+ # Copyright (C) 2025 demigodmode
2
+ # SPDX-License-Identifier: AGPL-3.0-only
3
+
4
+ """Source management commands."""
5
+
6
+ import click
7
+ from rich.table import Table
8
+
9
+ from onesearch.api import APIError
10
+ from onesearch.context import Context, console, err_console, pass_context
11
+ from onesearch.main import cli
12
+
13
+
14
+ def parse_patterns(patterns: str | None) -> list[str] | None:
15
+ """Parse comma-separated patterns into a list."""
16
+ if not patterns:
17
+ return None
18
+ return [p.strip() for p in patterns.split(",") if p.strip()]
19
+
20
+
21
+ def format_patterns(patterns: list[str] | None) -> str:
22
+ """Format a list of patterns for display."""
23
+ if not patterns:
24
+ return "*"
25
+ return ", ".join(patterns)
26
+
27
+
28
+ @cli.group()
29
+ def source():
30
+ """Manage search sources.
31
+
32
+ \b
33
+ Examples:
34
+ onesearch source list
35
+ onesearch source add "Documents" /data/docs
36
+ onesearch source show 1
37
+ onesearch source reindex 1
38
+ onesearch source delete 1
39
+ """
40
+ pass
41
+
42
+
43
+ @source.command("list")
44
+ @pass_context
45
+ def source_list(ctx: Context):
46
+ """List all configured sources."""
47
+ api = ctx.get_api()
48
+ try:
49
+ sources = api.list_sources()
50
+
51
+ if not sources:
52
+ console.print("[dim]No sources configured.[/dim]")
53
+ console.print("\nAdd a source with: [cyan]onesearch source add <name> <path>[/cyan]")
54
+ return
55
+
56
+ table = Table(title="Sources")
57
+ table.add_column("ID", style="cyan", no_wrap=True)
58
+ table.add_column("Name", style="green")
59
+ table.add_column("Root Path")
60
+ table.add_column("Include Patterns", style="dim")
61
+ table.add_column("Exclude Patterns", style="dim")
62
+
63
+ for s in sources:
64
+ table.add_row(
65
+ str(s["id"]),
66
+ s["name"],
67
+ s["root_path"],
68
+ format_patterns(s.get("include_patterns")),
69
+ format_patterns(s.get("exclude_patterns")) if s.get("exclude_patterns") else "-",
70
+ )
71
+
72
+ console.print(table)
73
+ except APIError as e:
74
+ err_console.print(f"[red]Error:[/red] {e.message}")
75
+ raise SystemExit(1) from e
76
+
77
+
78
+ @source.command("add")
79
+ @click.argument("name")
80
+ @click.argument("path", type=click.Path())
81
+ @click.option("--include", "-i", help="Include patterns (comma-separated globs).")
82
+ @click.option("--exclude", "-e", help="Exclude patterns (comma-separated globs).")
83
+ @click.option("--no-validate", is_flag=True, help="Skip path validation (for remote/container paths).")
84
+ @pass_context
85
+ def source_add(ctx: Context, name: str, path: str, include: str | None, exclude: str | None, no_validate: bool):
86
+ """Add a new source.
87
+
88
+ \b
89
+ Arguments:
90
+ NAME Friendly name for the source
91
+ PATH Root path to index
92
+
93
+ \b
94
+ Examples:
95
+ onesearch source add "Documents" /data/docs
96
+ onesearch source add "Notes" /data/notes --include "**/*.md,**/*.txt"
97
+ onesearch source add "Code" /data/code --exclude "**/node_modules/**,**/.git/**"
98
+
99
+ \b
100
+ Note: Use --no-validate for paths that exist only inside Docker containers.
101
+ """
102
+ # Validate path exists locally (unless skipped)
103
+ if not no_validate:
104
+ from pathlib import Path as PathLib
105
+ path_obj = PathLib(path)
106
+ if not path_obj.exists():
107
+ err_console.print(f"[yellow]Warning:[/yellow] Path does not exist locally: {path}")
108
+ err_console.print("[dim]If this path exists inside a Docker container, use --no-validate[/dim]")
109
+ if not click.confirm("Continue anyway?"):
110
+ err_console.print("[dim]Aborted.[/dim]")
111
+ raise SystemExit(1)
112
+ elif not path_obj.is_dir():
113
+ err_console.print(f"[red]Error:[/red] Path is not a directory: {path}")
114
+ raise SystemExit(1)
115
+
116
+ api = ctx.get_api()
117
+ try:
118
+ result = api.create_source(
119
+ name=name,
120
+ root_path=path,
121
+ include_patterns=parse_patterns(include),
122
+ exclude_patterns=parse_patterns(exclude),
123
+ )
124
+ out = ctx.get_console()
125
+ out.print(f"[green]✓[/green] Created source [cyan]{result['name']}[/cyan] (ID: {result['id']})")
126
+ out.print(f" Path: {result['root_path']}")
127
+ if result.get("include_patterns"):
128
+ out.print(f" Include: {format_patterns(result['include_patterns'])}")
129
+ if result.get("exclude_patterns"):
130
+ out.print(f" Exclude: {format_patterns(result['exclude_patterns'])}")
131
+ out.print("\nRun [cyan]onesearch source reindex {id}[/cyan] to start indexing.".format(id=result['id']))
132
+ except APIError as e:
133
+ err_console.print(f"[red]Error:[/red] {e.message}")
134
+ raise SystemExit(1) from e
135
+
136
+
137
+ @source.command("show")
138
+ @click.argument("source_id")
139
+ @pass_context
140
+ def source_show(ctx: Context, source_id: str):
141
+ """Show details for a source.
142
+
143
+ \b
144
+ Arguments:
145
+ SOURCE_ID The source ID to show
146
+ """
147
+ api = ctx.get_api()
148
+ try:
149
+ s = api.get_source(source_id)
150
+ console.print(f"\n[bold cyan]{s['name']}[/bold cyan] (ID: {s['id']})")
151
+ console.print(f" Root Path: {s['root_path']}")
152
+ console.print(f" Include Patterns: {format_patterns(s.get('include_patterns'))}")
153
+ console.print(f" Exclude Patterns: {format_patterns(s.get('exclude_patterns')) if s.get('exclude_patterns') else '-'}")
154
+ if s.get("created_at"):
155
+ console.print(f" Created: {s['created_at']}")
156
+ if s.get("updated_at"):
157
+ console.print(f" Updated: {s['updated_at']}")
158
+ except APIError as e:
159
+ err_console.print(f"[red]Error:[/red] {e.message}")
160
+ raise SystemExit(1) from e
161
+
162
+
163
+ @source.command("delete")
164
+ @click.argument("source_id")
165
+ @click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt.")
166
+ @pass_context
167
+ def source_delete(ctx: Context, source_id: str, yes: bool):
168
+ """Delete a source.
169
+
170
+ \b
171
+ Arguments:
172
+ SOURCE_ID The source ID to delete
173
+
174
+ This will remove the source configuration and all indexed documents
175
+ from this source.
176
+ """
177
+ api = ctx.get_api()
178
+ try:
179
+ # Get source info for confirmation
180
+ s = api.get_source(source_id)
181
+
182
+ if not yes:
183
+ console.print(f"[yellow]Warning:[/yellow] This will delete source [cyan]{s['name']}[/cyan] (ID: {source_id})")
184
+ console.print(f" Path: {s['root_path']}")
185
+ console.print("\nAll indexed documents from this source will be removed.")
186
+ if not click.confirm("Are you sure?"):
187
+ console.print("[dim]Aborted.[/dim]")
188
+ return
189
+
190
+ api.delete_source(source_id)
191
+ ctx.get_console().print(f"[green]✓[/green] Deleted source [cyan]{s['name']}[/cyan]")
192
+ except APIError as e:
193
+ err_console.print(f"[red]Error:[/red] {e.message}")
194
+ raise SystemExit(1) from e
195
+
196
+
197
+ @source.command("reindex")
198
+ @click.argument("source_id")
199
+ @pass_context
200
+ def source_reindex(ctx: Context, source_id: str):
201
+ """Trigger reindex for a source.
202
+
203
+ \b
204
+ Arguments:
205
+ SOURCE_ID The source ID to reindex
206
+
207
+ This will scan the source path and index all matching files.
208
+ """
209
+ api = ctx.get_api()
210
+ out = ctx.get_console()
211
+ try:
212
+ s = api.get_source(source_id)
213
+ out.print(f"[dim]Reindexing source [cyan]{s['name']}[/cyan]...[/dim]")
214
+
215
+ result = api.reindex_source(source_id)
216
+
217
+ # Backend returns: {"message": ..., "stats": {...}}
218
+ stats = result.get("stats", {})
219
+
220
+ out.print(f"\n[green]✓[/green] Reindex completed for [cyan]{s['name']}[/cyan]")
221
+ out.print(f" Files scanned: {stats.get('total_scanned', 0)}")
222
+ out.print(f" New files: {stats.get('new_files', 0)}")
223
+ out.print(f" Modified: {stats.get('modified_files', 0)}")
224
+ out.print(f" Unchanged: {stats.get('unchanged_files', 0)}")
225
+ out.print(f" [green]Successful: {stats.get('successful', 0)}[/green]")
226
+ if stats.get("failed", 0) > 0:
227
+ out.print(f" [yellow]Failed: {stats.get('failed', 0)}[/yellow]")
228
+ if stats.get("skipped", 0) > 0:
229
+ out.print(f" Skipped: {stats.get('skipped', 0)}")
230
+ except APIError as e:
231
+ err_console.print(f"[red]Error:[/red] {e.message}")
232
+ raise SystemExit(1) from e
@@ -0,0 +1,221 @@
1
+ # Copyright (C) 2025 demigodmode
2
+ # SPDX-License-Identifier: AGPL-3.0-only
3
+
4
+ """Status and health commands."""
5
+
6
+ import json
7
+ from datetime import datetime
8
+
9
+ import click
10
+ from rich.table import Table
11
+
12
+ from onesearch.api import APIError
13
+ from onesearch.config import get_config_path
14
+ from onesearch.context import Context, console, err_console, pass_context
15
+ from onesearch.main import cli
16
+
17
+
18
+ def format_timestamp(ts) -> str:
19
+ """Format a timestamp for display."""
20
+ if not ts:
21
+ return "-"
22
+ if isinstance(ts, str):
23
+ # ISO format string
24
+ if "T" in ts:
25
+ return ts.replace("T", " ").split(".")[0]
26
+ return ts
27
+ if isinstance(ts, (int, float)):
28
+ # Unix timestamp
29
+ return datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M")
30
+ return str(ts)
31
+
32
+
33
+ @cli.command()
34
+ @click.argument("source_id", required=False)
35
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON for scripting.")
36
+ @pass_context
37
+ def status(ctx: Context, source_id: str | None, as_json: bool):
38
+ """Show indexing status for sources.
39
+
40
+ \b
41
+ Arguments:
42
+ SOURCE_ID Optional source ID for detailed status
43
+
44
+ \b
45
+ Examples:
46
+ onesearch status # All sources
47
+ onesearch status my-docs # Specific source
48
+ onesearch status --json # JSON output
49
+ """
50
+ api = ctx.get_api()
51
+ try:
52
+ if source_id is not None:
53
+ # Detailed status for specific source
54
+ # Backend returns flat: {source_id, source_name, total_files, successful, failed, skipped, last_indexed_at, failed_files}
55
+ result = api.source_status(source_id)
56
+ if as_json:
57
+ console.print(json.dumps(result, indent=2, default=str))
58
+ return
59
+
60
+ console.print(f"\n[bold cyan]{result.get('source_name', 'Unknown')}[/bold cyan] (ID: {result.get('source_id', source_id)})")
61
+ console.print()
62
+
63
+ console.print("[bold]Indexing Statistics:[/bold]")
64
+ console.print(f" Total Files: {result.get('total_files', 0)}")
65
+ console.print(f" [green]Successful: {result.get('successful', 0)} ✓[/green]")
66
+
67
+ failed = result.get("failed", 0)
68
+ if failed > 0:
69
+ console.print(f" [red]Failed: {failed} ✗[/red]")
70
+ else:
71
+ console.print(" Failed: 0")
72
+
73
+ skipped = result.get("skipped", 0)
74
+ if skipped > 0:
75
+ console.print(f" [yellow]Skipped: {skipped}[/yellow]")
76
+
77
+ last_indexed = format_timestamp(result.get("last_indexed_at"))
78
+ console.print(f" Last Index: {last_indexed}")
79
+
80
+ # Show failed files if any
81
+ failed_files = result.get("failed_files", [])
82
+ if failed_files:
83
+ console.print(f"\n[bold red]Failed Files ({len(failed_files)}):[/bold red]")
84
+ for f in failed_files[:10]: # Show first 10
85
+ console.print(f" • {f.get('path', 'Unknown')}")
86
+ if f.get("error"):
87
+ console.print(f" [dim]{f['error']}[/dim]")
88
+ if len(failed_files) > 10:
89
+ console.print(f" [dim]... and {len(failed_files) - 10} more[/dim]")
90
+ else:
91
+ # Overview status for all sources
92
+ # Backend returns: {sources: [{source_id, source_name, total_files, successful, failed, skipped, last_indexed_at, ...}]}
93
+ result = api.status()
94
+ if as_json:
95
+ console.print(json.dumps(result, indent=2, default=str))
96
+ return
97
+
98
+ sources = result.get("sources", [])
99
+ if not sources:
100
+ console.print("[dim]No sources configured.[/dim]")
101
+ console.print("\nAdd a source with: [cyan]onesearch source add <name> <path>[/cyan]")
102
+ return
103
+
104
+ table = Table(title="Source Status")
105
+ table.add_column("Source", style="cyan")
106
+ table.add_column("Total", justify="right")
107
+ table.add_column("Success", justify="right", style="green")
108
+ table.add_column("Failed", justify="right")
109
+ table.add_column("Skipped", justify="right")
110
+ table.add_column("Last Indexed")
111
+
112
+ for s in sources:
113
+ # Check for error status
114
+ if s.get("error"):
115
+ table.add_row(
116
+ f"{s.get('source_name', 'Unknown')} ({s.get('source_id', '?')})",
117
+ "-",
118
+ "-",
119
+ "[red]Error[/red]",
120
+ "-",
121
+ "-",
122
+ )
123
+ continue
124
+
125
+ failed = s.get("failed", 0)
126
+ failed_str = f"[red]{failed} ✗[/red]" if failed > 0 else "0"
127
+
128
+ last_indexed = format_timestamp(s.get("last_indexed_at"))
129
+
130
+ table.add_row(
131
+ f"{s.get('source_name', 'Unknown')} ({s.get('source_id', '?')})",
132
+ str(s.get("total_files", 0)),
133
+ f"{s.get('successful', 0)} ✓",
134
+ failed_str,
135
+ str(s.get("skipped", 0)),
136
+ last_indexed,
137
+ )
138
+
139
+ console.print()
140
+ console.print(table)
141
+ console.print()
142
+
143
+ except APIError as e:
144
+ err_console.print(f"[red]Error:[/red] {e.message}")
145
+ raise SystemExit(1) from e
146
+
147
+
148
+ @cli.command()
149
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON for scripting.")
150
+ @pass_context
151
+ def health(ctx: Context, as_json: bool):
152
+ """Check system health.
153
+
154
+ Verifies connectivity to the backend and Meilisearch,
155
+ and displays current configuration.
156
+
157
+ \b
158
+ Examples:
159
+ onesearch health
160
+ onesearch health --json
161
+ """
162
+ api = ctx.get_api()
163
+ config_path = get_config_path()
164
+
165
+ try:
166
+ result = api.health()
167
+
168
+ if as_json:
169
+ # Include config info in JSON output
170
+ result["config"] = {
171
+ "config_file": str(config_path),
172
+ "config_exists": config_path.exists(),
173
+ "backend_url": ctx.url,
174
+ }
175
+ console.print(json.dumps(result, indent=2))
176
+ return
177
+
178
+ # Essential status always shown (even in quiet mode)
179
+ overall = result.get("status", "unknown")
180
+ if overall == "healthy":
181
+ console.print("[bold green]✓ System Healthy[/bold green]")
182
+ else:
183
+ console.print(f"[bold yellow]⚠ System Status: {overall}[/bold yellow]")
184
+
185
+ # Detailed info only in non-quiet mode
186
+ out = ctx.get_console()
187
+ out.print()
188
+ out.print("[bold]Services:[/bold]")
189
+ out.print(f" Backend: {ctx.url} [green]✓[/green]")
190
+ out.print(f" Service: {result.get('service', 'onesearch-backend')} v{result.get('version', '?')}")
191
+
192
+ # Meilisearch status - backend returns {status: "available"|"degraded"|...}
193
+ meili = result.get("meilisearch", {})
194
+ meili_status = meili.get("status", "unknown")
195
+ if meili_status in ("available", "healthy"):
196
+ out.print(" Meilisearch: Connected [green]✓[/green]")
197
+ else:
198
+ out.print(f" Meilisearch: [yellow]{meili_status}[/yellow]")
199
+
200
+ out.print()
201
+ out.print("[bold]Configuration:[/bold]")
202
+ out.print(f" Config file: {config_path}")
203
+ if config_path.exists():
204
+ out.print(" Config: [green]Loaded[/green]")
205
+ else:
206
+ out.print(" Config: [dim]Using defaults[/dim]")
207
+ out.print()
208
+
209
+ except APIError as e:
210
+ err_console.print("[red]✗ System Unhealthy[/red]")
211
+ err_console.print(f"\n Backend: {ctx.url} [red]✗[/red]")
212
+ err_console.print(f" Error: {e.message}")
213
+ err_console.print()
214
+ err_console.print("[bold]Configuration:[/bold]")
215
+ err_console.print(f" Config file: {config_path}")
216
+ if config_path.exists():
217
+ err_console.print(" Config: [green]Loaded[/green]")
218
+ else:
219
+ err_console.print(" Config: [dim]Using defaults[/dim]")
220
+ console.print()
221
+ raise SystemExit(1) from e