diff --git a/owilix/cli/__init__.py b/owilix/cli/__init__.py new file mode 100644 index 0000000..2e791cd --- /dev/null +++ b/owilix/cli/__init__.py @@ -0,0 +1,74 @@ +""" +OWILIX CLI Package + +Modern Typer-based CLI for OWILIX operations. +Replaces the legacy Click-based cli.py with a modular, extensible architecture. + +Usage: + owilix remote ls all + owilix remote ls lrz:latest --format json + owilix query less --remote main:latest --output results.jsonl +""" +from typing import Optional +import typer +from rich.console import Console + +from ._common.context import CLIContext, create_context + +# Create main app +app = typer.Typer( + name="owi", + help="OWILIX - Open Web Index Client", + no_args_is_help=True, +) + + +@app.callback() +def main( + ctx: typer.Context, + verbose: bool = typer.Option(False, "-v", "--verbose", help="Enable verbose output"), + config: Optional[str] = typer.Option(None, "-c", "--config", help="Configuration file path"), + output_format: str = typer.Option( + "table", "-f", "--format", + help="Output format: table, json, jsonl" + ), + output_file: Optional[str] = typer.Option( + None, "-o", "--output", + help="Write output to file (enables streaming)" + ), + no_display: bool = typer.Option( + False, "--no-display", + help="Suppress console output (for piping)" + ), + target: Optional[str] = typer.Option( + None, "-t", "--target", + help="Target directory for data storage" + ), +): + """ + OWILIX - Open Web Index Client + + A CLI tool for managing Open Web Index datasets across local and remote repositories. + """ + ctx.obj = create_context( + verbose=verbose, + config_path=config, + output_format=output_format, + output_file=output_file, + no_display=no_display, + target=target, + ) + + +# Import and register sub-apps +from .remote import app as remote_app +app.add_typer(remote_app, name="remote") + + +def cli_main(): + """Entry point for the CLI.""" + app() + + +if __name__ == "__main__": + cli_main() diff --git a/owilix/cli/__main__.py b/owilix/cli/__main__.py new file mode 100644 index 0000000..b1fd74b --- /dev/null +++ b/owilix/cli/__main__.py @@ -0,0 +1,5 @@ +"""Allow running CLI as: python -m owilix.cli""" +from . import cli_main + +if __name__ == "__main__": + cli_main() diff --git a/owilix/cli/_common/__init__.py b/owilix/cli/_common/__init__.py new file mode 100644 index 0000000..86cbec7 --- /dev/null +++ b/owilix/cli/_common/__init__.py @@ -0,0 +1,11 @@ +"""Common utilities for OWILIX CLI.""" +from .context import CLIContext, create_context, get_context +from .output import OutputFormat, OutputWriter + +__all__ = [ + "CLIContext", + "create_context", + "get_context", + "OutputFormat", + "OutputWriter", +] diff --git a/owilix/cli/_common/context.py b/owilix/cli/_common/context.py new file mode 100644 index 0000000..1286ca8 --- /dev/null +++ b/owilix/cli/_common/context.py @@ -0,0 +1,101 @@ +""" +CLI Context - Pydantic-based context for all CLI commands. + +Provides strongly-typed configuration for the CLI, including: +- OWIlixManager instance +- Console for output +- Output format preferences +- Logging settings +""" +from typing import Optional, Any +from pydantic import BaseModel, Field, ConfigDict +from rich.console import Console +import logging +import os + + +class CLIContext(BaseModel): + """ + Strongly-typed CLI context passed to all commands. + + Attributes: + owi: OWIlixManager instance for dataset operations + console: Rich console for formatted output + verbose: Enable verbose output logging + output_format: Output format (table, json, jsonl) + output_file: Optional file path for output (None = stdout) + no_display: Suppress console output (for piping) + target: Target directory for local data storage + """ + model_config = ConfigDict(arbitrary_types_allowed=True) + + owi: Any = Field(default=None, description="OWIlixManager instance") + console: Console = Field(default_factory=Console, description="Rich console for output") + verbose: bool = Field(default=False, description="Enable verbose output") + output_format: str = Field(default="table", description="Output format: table, json, jsonl") + output_file: Optional[str] = Field(default=None, description="Write output to file") + no_display: bool = Field(default=False, description="Suppress console output") + target: Optional[str] = Field(default=None, description="Target directory") + + # Additional context from legacy CLI + showfields: Optional[str] = Field(default=None, description="Fields to show in display") + nodisplay: Optional[str] = Field(default=None, description="Fields to exclude") + auto_yes: bool = Field(default=False, description="Auto-confirm prompts") + + +def create_context( + verbose: bool = False, + config_path: Optional[str] = None, + output_format: str = "table", + output_file: Optional[str] = None, + no_display: bool = False, + target: Optional[str] = None, +) -> CLIContext: + """ + Create a CLI context with initialized OWIlixManager. + + Args: + verbose: Enable verbose logging + config_path: Path to configuration file + output_format: Output format (table, json, jsonl) + output_file: Optional output file path + no_display: Suppress console output + target: Target directory for data + + Returns: + Initialized CLIContext + """ + from owilix.core import OWIlixManager + from owilix.core.manager import OWIlixConfig, OWILIXEnv + + # Determine target path + owi_path = target or OWILIXEnv.values.owi_path + + # Load configuration + config_file = config_path or os.path.join(owi_path, "owilix.cfg") + config = OWIlixConfig(config_file) + + # Configure logging + log_level = logging.DEBUG if verbose else logging.WARNING + logging.getLogger("owilix").setLevel(log_level) + + # Create manager with owi_path and config + owi = OWIlixManager(owi_path=owi_path, config=config) + + # Create console with theme + console = Console() + + return CLIContext( + owi=owi, + console=console, + verbose=verbose, + output_format=output_format, + output_file=output_file, + no_display=no_display, + target=owi_path, + ) + + +def get_context(ctx) -> CLIContext: + """Get CLIContext from Typer context.""" + return ctx.obj diff --git a/owilix/cli/_common/output.py b/owilix/cli/_common/output.py new file mode 100644 index 0000000..d32be67 --- /dev/null +++ b/owilix/cli/_common/output.py @@ -0,0 +1,152 @@ +""" +Output Writer - Unified output handling for all CLI commands. + +Supports multiple output formats: +- TABLE: Rich tables for human-readable output (default) +- JSON: Single JSON array +- JSONL: JSON Lines for streaming/piping +- Future: AVRO via plugins +""" +from enum import Enum +from typing import Iterator, List, Dict, Any, Optional, Union +import json +import sys + +from rich.console import Console +from rich.table import Table + + +class OutputFormat(str, Enum): + """Supported output formats.""" + TABLE = "table" # Rich tables (default, human-readable) + JSON = "json" # Single JSON array + JSONL = "jsonl" # JSON Lines (streaming) + # Future formats via plugins + # AVRO = "avro" + # PARQUET = "parquet" + + +class OutputWriter: + """ + Unified output handler for all commands. + + Handles output format, destination (file or stdout), and streaming. + Commands use this to ensure consistent output behavior. + + Usage: + writer = OutputWriter(ctx) + writer.write_records(datasets) # Iterator of dicts + writer.close() + """ + + def __init__(self, ctx: "CLIContext"): + """ + Initialize output writer from CLI context. + + Args: + ctx: CLI context with output preferences + """ + self.ctx = ctx + self.format = OutputFormat(ctx.output_format) + self.console = ctx.console + self._file = None + + # Open output file if specified + if ctx.output_file: + self._file = open(ctx.output_file, 'w', encoding='utf-8') + + def write_records( + self, + records: Union[Iterator[Dict[str, Any]], List[Dict[str, Any]]], + title: Optional[str] = None, + columns: Optional[List[str]] = None, + ): + """ + Write records to output in the configured format. + + Args: + records: Iterator or list of dictionaries to output + title: Optional title for table output + columns: Optional column order (auto-detected if None) + """ + # Convert iterator to list if needed for non-streaming formats + if self.format == OutputFormat.JSONL: + self._write_jsonl(records) + elif self.format == OutputFormat.JSON: + self._write_json(list(records) if not isinstance(records, list) else records) + elif self.format == OutputFormat.TABLE: + self._write_table( + list(records) if not isinstance(records, list) else records, + title=title, + columns=columns + ) + + def write_message(self, message: str, style: Optional[str] = None): + """Write a simple message (only in non-JSON modes).""" + if self.format == OutputFormat.TABLE: + if style: + self.console.print(message, style=style) + else: + self.console.print(message) + + def _write_jsonl(self, records: Iterator[Dict[str, Any]]): + """Write records as JSON Lines (streaming).""" + for record in records: + line = json.dumps(record, ensure_ascii=False, default=str) + self._write_line(line) + + def _write_json(self, records: List[Dict[str, Any]]): + """Write records as single JSON array.""" + output = json.dumps(records, indent=2, ensure_ascii=False, default=str) + self._write_line(output) + + def _write_table( + self, + records: List[Dict[str, Any]], + title: Optional[str] = None, + columns: Optional[List[str]] = None, + ): + """Write records as Rich table.""" + if not records: + self.console.print("[dim]No results[/dim]") + return + + # Auto-detect columns from first record if not specified + if columns is None: + columns = list(records[0].keys()) + + # Create table + table = Table(title=title, show_header=True, header_style="bold") + for col in columns: + table.add_column(col) + + # Add rows + for record in records: + row = [str(record.get(col, "")) for col in columns] + table.add_row(*row) + + # Output + if not self.ctx.no_display: + self.console.print(table) + + def _write_line(self, line: str): + """Write a line to file or stdout.""" + if self._file: + self._file.write(line + '\n') + elif not self.ctx.no_display: + # Write to stdout for piping + sys.stdout.write(line + '\n') + sys.stdout.flush() + + def close(self): + """Close output file if opened.""" + if self._file: + self._file.close() + self._file = None + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.close() + return False diff --git a/owilix/cli/remote.py b/owilix/cli/remote.py new file mode 100644 index 0000000..0d33c37 --- /dev/null +++ b/owilix/cli/remote.py @@ -0,0 +1,142 @@ +""" +Remote Commands - Typer CLI for remote repository operations. + +Commands: +- ls: List remote datasets +- pull: Download datasets (TODO) +- push: Upload datasets (TODO) +- doctor: Check connection status (TODO) +""" +from typing import Optional +import typer + +from ._common.context import CLIContext, get_context +from ._common.output import OutputWriter + +# Create sub-app for remote commands +app = typer.Typer( + name="remote", + help="Remote repository operations", + no_args_is_help=True, +) + + +@app.command() +def ls( + ctx: typer.Context, + specifier: str = typer.Argument("all", help="Dataset specifier (e.g., 'lrz:latest')"), + details: bool = typer.Option(False, "-l", "--details", help="Show detailed information"), + files: Optional[str] = typer.Option(None, "--files", help="File glob pattern"), + groups: Optional[int] = typer.Option(None, "--groups", help="Group by size"), + no_summary: bool = typer.Option(False, "--no-summary", help="Skip summary table"), +): + """ + List remote datasets matching SPECIFIER. + + Specifier format: :#/= + + Examples: + owi remote ls all + owi remote ls lrz:latest + owi remote ls it4i:2024-01#7/access=public + """ + cli_ctx: CLIContext = ctx.obj + + with OutputWriter(cli_ctx) as writer: + # Parse specifier and list datasets using manager API + spec = cli_ctx.owi.parse_specifier(specifier) + datasets_list = cli_ctx.owi.remote_data.list( + datacenter=spec.get("data_center"), + access=spec.get("query", {}).get("access", "public"), + day=spec.get("day"), + duration=spec.get("duration") or 0, + query={k: v for k, v in spec.get("query", {}).items() if k != "access"}, + ) + + if cli_ctx.output_format == "table": + # Use table display + if not datasets_list: + writer.write_message("[dim]No datasets found[/dim]") + return + + # Access attributes via metadata + def get_id(ds): + return ds.metadata.get('internalID') or ds.metadata.get('id') or '' + + records = [ + { + "id": get_id(ds)[:8] + "..." if len(get_id(ds)) > 8 else get_id(ds), + "title": ds.metadata.get('title', '') or "", + "collection": ds.metadata.get('collectionName', '') or "", + "dc": getattr(ds, 'dataCenter', '') or "", + "dates": f"{ds.metadata.startDate.strftime('%Y-%m-%d') if ds.metadata.startDate else '?'}-{ds.metadata.endDate.strftime('%Y-%m-%d') if ds.metadata.endDate else '?'}", + "size": f"{ds.metadata.get('totalSize', 0) / 1e9:.1f}G" if ds.metadata.get('totalSize') else "?", + "files": ds.metadata.get('fileCount', 0) or 0, + } + for ds in datasets_list + ] + + if details: + # Show full IDs and more details + records = [ + { + "id": get_id(ds), + "title": ds.metadata.get('title', '') or "", + "collectionName": ds.metadata.get('collectionName', '') or "", + "dataCenter": getattr(ds, 'dataCenter', '') or "", + "startDate": str(ds.metadata.startDate) if ds.metadata.startDate else None, + "endDate": str(ds.metadata.endDate) if ds.metadata.endDate else None, + "size": ds.metadata.get('totalSize'), + "fileCount": ds.metadata.get('fileCount'), + "access": getattr(ds, 'access', '') or "", + } + for ds in datasets_list + ] + + writer.write_records(records, title=f"Remote Datasets ({len(records)})") + + if not no_summary: + total_size = sum(ds.metadata.get('totalSize', 0) or 0 for ds in datasets_list) + total_files = sum(ds.metadata.get('fileCount', 0) or 0 for ds in datasets_list) + writer.write_message(f"\n[bold]Summary:[/bold] {len(datasets_list)} datasets, {total_size/1e9:.1f} GB, {total_files:,} files") + else: + # JSON/JSONL output + def get_id(ds): + return ds.metadata.get('internalID') or ds.metadata.get('id') + + records = [ + { + "id": get_id(ds), + "title": ds.metadata.get('title'), + "collectionName": ds.metadata.get('collectionName'), + "dataCenter": getattr(ds, 'dataCenter', None), + "startDate": str(ds.metadata.startDate) if ds.metadata.startDate else None, + "endDate": str(ds.metadata.endDate) if ds.metadata.endDate else None, + "size": ds.metadata.get('totalSize'), + "fileCount": ds.metadata.get('fileCount'), + "access": getattr(ds, 'access', None), + } + for ds in datasets_list + ] + writer.write_records(records) + + +@app.command() +def doctor( + ctx: typer.Context, +): + """ + Check connection status of configured remotes. + """ + cli_ctx: CLIContext = ctx.obj + + with OutputWriter(cli_ctx) as writer: + from owilix.cmd.remote import RemoteCommands + + remote_cmd = RemoteCommands( + cli_ctx.owi, + CONSOLE=cli_ctx.console, + ) + + # Call existing doctor command + remote_cmd.do("doctor")