Source code for findfmt.cli

"""Command-line interface for findfmt."""

from __future__ import annotations

import copy
import sys
from collections import Counter
from importlib.metadata import PackageNotFoundError, version
from pathlib import Path
from typing import TYPE_CHECKING, Annotated, Any

import typer

from findfmt.classifier import get_known_tags
from findfmt.models import TraversalConfig
from findfmt.traversal import find_files

if TYPE_CHECKING:
    from collections.abc import Sequence

    from findfmt.models import FileInfo

__all__ = [
    "app",
    "get_version",
    "main",
    "main_findfilefmt",
    "main_findfilemime",
    "main_findfiles",
    "main_findfmt0",
    "main_findshebang",
    "main_findsummary",
]


[docs] def get_version() -> str: """Retrieve package version or fallback string. Returns: Version string. """ try: return version("findfmt") except PackageNotFoundError: return "0.1.1.dev0"
def version_callback(value: bool) -> None: """Display the version of findfmt and exit. Args: value: Boolean flag indicating if version flag was passed. Raises: typer.Exit: Upon printing version. """ if value: sys.stdout.write(f"findfmt {get_version()}\n") raise typer.Exit(code=0) def known_tags_callback(value: bool) -> None: """List all known classification tags supported by the engine and exit. Args: value: Boolean flag indicating if known-tags flag was passed. Raises: typer.Exit: Upon printing known tags. """ if value: for tag in sorted(get_known_tags()): sys.stdout.write(f"{tag}\n") raise typer.Exit(code=0) def parse_tag_arguments(tag_args: Sequence[str] | None) -> frozenset[str]: """Parse repeatable or comma-delimited tag arguments into a normalized frozenset. Args: tag_args: Raw arguments provided to tag filter flags. Returns: frozenset of lowercase tag strings. """ if not tag_args: return frozenset[str]() result: set[str] = set() for arg in tag_args: for tag in arg.split(","): cleaned = tag.strip().lower() if cleaned: result.add(cleaned) return frozenset(result) def _format_match(file_info: FileInfo, *, absolute: bool, show_tags: bool, delimiter: str) -> str: """Format matching file information for stdout output. Args: file_info: Classified file information. absolute: Whether to format using absolute path. show_tags: Whether to append comma-separated tags. delimiter: End of line delimiter string. Returns: Formatted string for output. """ path_str = str(file_info.path if absolute else file_info.relative_path) if show_tags: tags_repr = ", ".join(sorted(file_info.tags)) return f"{path_str} [{tags_repr}]{delimiter}" return f"{path_str}{delimiter}" def _write_summary(match_count: int, tag_counter: Counter[str]) -> None: """Write execution summary to stderr. Args: match_count: Total number of files matched. tag_counter: Frequency counter of tags matched. """ sys.stderr.write(f"\n--- findfmt summary ---\nMatched files: {match_count}\n") if tag_counter: sys.stderr.write("Top tags:\n") for tag, count in tag_counter.most_common(10): sys.stderr.write(f" {tag}: {count}\n") app = typer.Typer( name="findfmt", help=( "A .gitignore-aware file discovery and classification suite that locates " "files by content format, shebang, and MIME tag." ), add_completion=False, no_args_is_help=False, context_settings={"help_option_names": ["-h", "--help"]}, ) @app.command( name="findfmt", help=( "A .gitignore-aware file discovery and classification suite that locates " "files by content format, shebang, and MIME tag." ), ) def findfmt( paths: Annotated[ list[Path] | None, typer.Argument( help="One or more directory or file paths to inspect (default: current directory).", ), ] = None, tags: Annotated[ list[str] | None, typer.Option( "--type", "-t", "--tag", help="Tag or comma-separated tags to match (e.g. 'python', 'yaml,json', 'executable').", ), ] = None, exclude_tags: Annotated[ list[str] | None, typer.Option( "--exclude", "-e", "--exclude-tag", help="Tag or comma-separated tags to exclude.", ), ] = None, all_tags: Annotated[ bool, typer.Option( "--all-tags", help="Require matching files to have ALL specified tags rather than ANY tag.", ), ] = False, shebang: Annotated[ str | None, typer.Option( "--shebang", help="Filter files whose shebang contains this interpreter or pattern.", ), ] = None, no_ignore: Annotated[ bool, typer.Option( "--no-ignore", help="Do not respect .gitignore rules during traversal.", ), ] = False, hidden: Annotated[ bool, typer.Option( "--hidden", help="Include hidden files and directories.", ), ] = False, follow_symlinks: Annotated[ bool, typer.Option( "--follow-symlinks", "-L", help="Follow symbolic links during traversal.", ), ] = False, absolute: Annotated[ bool, typer.Option( "--absolute", help="Output absolute paths rather than paths relative to the traversal root.", ), ] = False, print0: Annotated[ bool, typer.Option( "--print0", "-0", help=r"Delimit path outputs with a NUL (\0) character instead of a newline.", ), ] = False, list_tags: Annotated[ bool, typer.Option( "--list-tags", "-l", help="Display identified tags alongside each matched path.", ), ] = False, summary: Annotated[ bool, typer.Option( "--summary", "-s", help="Print summary match statistics to stderr.", ), ] = False, known_tags: Annotated[ bool, typer.Option( "--known-tags", is_eager=True, callback=known_tags_callback, help="List all known classification tags supported by the engine and exit.", ), ] = False, version: Annotated[ bool | None, typer.Option( "--version", "-v", is_eager=True, callback=version_callback, help="Display the version of findfmt and exit.", ), ] = None, ) -> None: """Execute file discovery and classification matching.""" root_paths = tuple(paths) if paths else (Path(),) config = TraversalConfig( root_paths=root_paths, include_tags=parse_tag_arguments(tags), exclude_tags=parse_tag_arguments(exclude_tags), all_tags=all_tags, shebang_filter=shebang, respect_gitignore=not no_ignore, include_hidden=hidden, follow_symlinks=follow_symlinks, relative_paths=not absolute, null_delimited=print0, show_tags=list_tags, show_summary=summary, ) tag_counter: Counter[str] = Counter() match_count = 0 delimiter = "\0" if config.null_delimited else "\n" for file_info in find_files(config): match_count += 1 if config.show_summary: tag_counter.update(file_info.tags) formatted = _format_match( file_info, absolute=absolute, show_tags=config.show_tags, delimiter=delimiter, ) sys.stdout.write(formatted) if config.show_summary: _write_summary(match_count, tag_counter) _OPTIONS_WITH_VALUE: frozenset[str] = frozenset( { "-t", "--type", "--tag", "-e", "--exclude", "--exclude-tag", "--shebang", }, ) def _has_option(args: Sequence[str], option_names: set[str] | frozenset[str]) -> bool: """Check if any option name or prefix is present in args. Args: args: Sequence of command-line arguments. option_names: Set of option flag names to check. Returns: True if any option matches, False otherwise. """ return any( arg in option_names or any(arg.startswith(f"{opt}=") for opt in option_names) for arg in args ) def _consume_option(arg: str, next_arg: str | None) -> int: """Return number of arguments consumed by this option flag. Args: arg: Current argument string. next_arg: Subsequent argument string, if available. Returns: Number of arguments consumed (1 or 2). """ if "=" in arg or arg not in _OPTIONS_WITH_VALUE: return 1 return 2 if next_arg is not None else 1 def _extract_first_positional(args: Sequence[str]) -> tuple[str | None, list[str]]: """Extract the first positional argument from args, preserving option structure. Args: args: Sequence of raw command-line tokens. Returns: Tuple of (first positional argument or None, remaining arguments). """ first_pos: str | None = None remaining: list[str] = [] i = 0 passthrough = False while i < len(args): arg = args[i] if not passthrough and arg.startswith("-") and arg != "-": if arg == "--": passthrough = True remaining.append(arg) i += 1 else: next_arg = args[i + 1] if i + 1 < len(args) else None count = _consume_option(arg, next_arg) remaining.extend(args[i : i + count]) i += count continue if first_pos is None: first_pos = arg else: remaining.append(arg) i += 1 return first_pos, remaining def _invoke_with_defaults( info_name: str, defaults: dict[str, Any], argv: Sequence[str] | None = None, help_text: str | None = None, ) -> int: """Helper to invoke the main Typer command with injected default options. Args: info_name: Command name to display in usage and help. defaults: Default options to inject into Click context. argv: Optional command-line arguments (defaults to sys.argv[1:]). help_text: Optional custom help text for the command. Returns: Integer exit code (0 for success, non-zero on error). """ cmd = typer.main.get_command(app) if help_text is not None: cmd = copy.copy(cmd) cmd.help = help_text try: cmd.main( args=list(argv) if argv is not None else None, prog_name=info_name, default_map=defaults, ) except SystemExit as exc: return exc.code if isinstance(exc.code, int) else 0 return 0 # pragma: no cover
[docs] def main(argv: Sequence[str] | None = None) -> int: """Main CLI entrypoint for findfmt. Args: argv: Optional command-line arguments (defaults to sys.argv[1:]). Returns: Integer exit code (0 for success, non-zero on error). """ return _invoke_with_defaults("findfmt", {}, argv=argv)
[docs] def main_findfiles(argv: Sequence[str] | None = None) -> int: """Entry point for 'findfiles' (findfmt --hidden). Args: argv: Optional command-line arguments (defaults to sys.argv[1:]). Returns: Integer exit code (0 for success, non-zero on error). """ return _invoke_with_defaults( "findfiles", {"hidden": True}, argv=argv, help_text="Find all files and directories, including hidden files respecting .gitignore.", )
[docs] def main_findfilemime(argv: Sequence[str] | None = None) -> int: """Entry point for 'findfilemime' (findfmt --hidden --list-tags). Args: argv: Optional command-line arguments (defaults to sys.argv[1:]). Returns: Integer exit code (0 for success, non-zero on error). """ return _invoke_with_defaults( "findfilemime", {"hidden": True, "list_tags": True}, argv=argv, help_text=( "Find files and list detected format and MIME tags, " "including hidden files respecting .gitignore." ), )
[docs] def main_findfilefmt(argv: Sequence[str] | None = None) -> int: """Entry point for 'findfilefmt' (findfmt --hidden [--tag TAG]). Args: argv: Optional command-line arguments (defaults to sys.argv[1:]). Returns: Integer exit code (0 for success, non-zero on error). """ raw_args = list(argv) if argv is not None else list(sys.argv[1:]) defaults: dict[str, Any] = {"hidden": True} if not _has_option(raw_args, {"-t", "--type", "--tag"}): first_pos, remaining = _extract_first_positional(raw_args) if first_pos is not None: raw_args = ["--tag", first_pos, *remaining] return _invoke_with_defaults( "findfilefmt", defaults, argv=raw_args, help_text=( "Find files by format tag, including hidden files respecting .gitignore.\n\n" "Optionally provide TAG as the first positional argument (e.g. 'findfilefmt python')." ), )
[docs] def main_findshebang(argv: Sequence[str] | None = None) -> int: """Entry point for 'findshebang' (findfmt --hidden [--shebang INTERPRETER]). Args: argv: Optional command-line arguments (defaults to sys.argv[1:]). Returns: Integer exit code (0 for success, non-zero on error). """ raw_args = list(argv) if argv is not None else list(sys.argv[1:]) defaults: dict[str, Any] = {"hidden": True} if not _has_option(raw_args, {"--shebang"}): first_pos, remaining = _extract_first_positional(raw_args) if first_pos is not None: raw_args = ["--shebang", first_pos, *remaining] return _invoke_with_defaults( "findshebang", defaults, argv=raw_args, help_text=( "Find files by shebang interpreter pattern, including hidden files respecting " ".gitignore.\n\n" "Optionally provide INTERPRETER as the first positional argument " "(e.g. 'findshebang bash')." ), )
[docs] def main_findfmt0(argv: Sequence[str] | None = None) -> int: """Entry point for 'findfmt0' (findfmt --hidden --print0). Args: argv: Optional command-line arguments (defaults to sys.argv[1:]). Returns: Integer exit code (0 for success, non-zero on error). """ return _invoke_with_defaults( "findfmt0", {"hidden": True, "print0": True}, argv=argv, help_text=( "Find files and output NUL-delimited paths (--print0), " "including hidden files respecting .gitignore." ), )
[docs] def main_findsummary(argv: Sequence[str] | None = None) -> int: """Entry point for 'findsummary' (findfmt --hidden --summary). Args: argv: Optional command-line arguments (defaults to sys.argv[1:]). Returns: Integer exit code (0 for success, non-zero on error). """ return _invoke_with_defaults( "findsummary", {"hidden": True, "summary": True}, argv=argv, help_text=( "Find files and print summary statistics to stderr, " "including hidden files respecting .gitignore." ), )
if __name__ == "__main__": # pragma: no cover app()