Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .flake8
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ max-line-length = 88
# W504: line break after binary operator
# E203: Whitespace before ':'
# W291/W293: trailing/blank-line whitespace
ignore = E501, W503, W504, E203, W291, W293
ignore = W503, W504, E203

# A list of files and directories to exclude from linting.
exclude =
Expand Down
6 changes: 2 additions & 4 deletions generate_version_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,9 @@

import sys

try:
# Try to use the built-in tomllib (Python 3.11+)
if sys.version_info >= (3, 11):
import tomllib
except ImportError:
# If not available, fall back to tomli (for Python <3.11)
else:
try:
import tomli as tomllib # type: ignore[import]
except ImportError:
Expand Down
6 changes: 3 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,11 @@ where = ["src"]

[tool.black]
line-length = 88
target-version = ['py310', 'py311', 'py312']
target-version = ['py310']

[tool.pytest.ini_options]
minversion = "7.0"
addopts = "-ra -q --color=yes --cov=src/codecat --cov-report=term-missing"
addopts = "-ra -q --color=yes --cov-report=term-missing"
testpaths = ["tests"]
python_files = "test_*.py"
python_classes = "Test*"
Expand All @@ -70,7 +70,7 @@ filterwarnings = ["ignore::DeprecationWarning"]
include = ["src", "tests"]
exclude = ["**/__pycache__", ".venv", ".venv312", "dist", "build"]
typeCheckingMode = "basic"
pythonVersion = "3.11"
pythonVersion = "3.10"
pythonPlatform = "All"

reportMissingImports = "warning"
Expand Down
5 changes: 3 additions & 2 deletions src/codecat/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ def main():
it's assumed to be a double-click. A user-friendly message is shown, and
the window is paused to prevent it from closing immediately.
"""
# This check is the most reliable way to detect a double-click on a frozen executable.
# This check is the most reliable way to detect
# a double-click on a frozen executable.
# It runs before Typer, preventing Typer's default error message from appearing.
if len(sys.argv) == 1 and getattr(sys, "frozen", False) and os.name == "nt":
from rich.console import Console
Expand All @@ -40,7 +41,7 @@ def main():
"Example Usage:\n"
" [cyan]codecat --help[/cyan]\n\n"
"For more information, visit the GitHub README:\n"
" [cyan][link=https://github.com/exonymos/codecat]https://github.com/exonymos/codecat[/link][/cyan]",
" [cyan][link=https://github.com/exonymos/codecat]https://github.com/exonymos/codecat[/link][/cyan]", # noqa: E501
title="Usage Error",
border_style="red",
padding=(1, 2),
Expand Down
64 changes: 38 additions & 26 deletions src/codecat/cli_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
# Initialize the Typer CLI app with custom help and settings.
app = typer.Typer(
name="codecat",
help="🐾 [bold]Codecat CLI[/bold] — A powerful tool to aggregate source code and text into a single Markdown file.",
help="🐾 [bold]Codecat CLI[/bold] — A powerful tool to aggregate source code and text into a single Markdown file.", # noqa: E501
add_completion=False,
rich_markup_mode="rich",
context_settings={"help_option_names": ["-h", "--help"]},
Expand Down Expand Up @@ -70,7 +70,7 @@ def version_callback(value: bool):
typer.Option(
"--config",
"-c",
help=f"Path to a custom config file. If not provided, looks for [cyan]{DEFAULT_CONFIG_FILENAME}[/cyan] in the project path.",
help=f"Path to a custom config file. If not provided, looks for [cyan]{DEFAULT_CONFIG_FILENAME}[/cyan] in the project path.", # noqa: E501
resolve_path=True,
file_okay=True,
dir_okay=False,
Expand All @@ -83,7 +83,7 @@ def version_callback(value: bool):
typer.Option(
"--include",
"-i",
help='Glob pattern for files to include. [bold]Use multiple times for multiple patterns[/bold] (e.g., -i "*.py" -i "*.js").',
help='Glob pattern for files to include. [bold]Use multiple times for multiple patterns[/bold] (e.g., -i "*.py" -i "*.js").', # noqa: E501
),
]

Expand All @@ -92,7 +92,7 @@ def version_callback(value: bool):
typer.Option(
"--exclude",
"-e",
help='Glob pattern to exclude files or directories. [bold]Use multiple times for multiple patterns[/bold] (e.g., -e "dist/*").',
help='Glob pattern to exclude files or directories. [bold]Use multiple times for multiple patterns[/bold] (e.g., -e "dist/*").', # noqa: E501
),
]

Expand Down Expand Up @@ -146,7 +146,8 @@ def _log_initial_info(
"""Prints the initial startup panel and configuration if verbose is enabled."""
console.print(
Panel(
f"🐾 [bold]Codecat v{__version__}[/bold] | Processing: [cyan]'{project_path.resolve()}'[/cyan]",
f"🐾 [bold]Codecat v{__version__}[/bold] | "
f"Processing: [cyan]'{project_path.resolve()}'[/cyan]",
border_style="blue",
)
)
Expand Down Expand Up @@ -178,7 +179,8 @@ def _scan_project_files(

if not files_to_scan:
console.print(
"\n[yellow]No files found to aggregate based on the current configuration.[/yellow]"
"\n[yellow]No files found to aggregate based on the "
"current configuration.[/yellow]"
)
raise typer.Exit()

Expand Down Expand Up @@ -249,12 +251,14 @@ def _write_markdown_output(
output_file_path.write_text(markdown_content, encoding="utf-8")
if not silent:
console.print(
f"\n[bold green]✔ Success![/bold green] Aggregated {num_files} files into:"
f"\n[bold green]✔ Success![/bold green] "
f"Aggregated {num_files} files into:"
)
console.print(f"[cyan]{output_file_path.resolve()}[/cyan]")
except IOError as e:
console.print(
f"\n[bold red]Error writing to output file '{output_file_path.resolve()}': {e}[/bold red]"
f"\n[bold red]Error writing to output file "
f"'{output_file_path.resolve()}': {e}[/bold red]"
)
raise typer.Exit(code=1)

Expand Down Expand Up @@ -294,7 +298,7 @@ def run(
typer.Option(
"--output-file",
"-o",
help="Name for the output Markdown file. [bold]Overrides config setting.[/bold]",
help="Name for the output Markdown file. [bold]Overrides config setting.[/bold]", # noqa: E501
),
] = None,
include_patterns_override: IncludePatterns = None,
Expand All @@ -312,14 +316,14 @@ def run(
typer.Option(
"--silent",
"-s",
help="Suppress all informational output. Only critical errors will be shown.",
help="Suppress all informational output. Only critical errors will be shown.", # noqa: E501
),
] = False,
dry_run: Annotated[
bool,
typer.Option(
"--dry-run",
help="Scan and process files, but [bold]do not write the output file.[/bold] Useful for previews.",
help="Scan and process files, but [bold]do not write the output file.[/bold] Useful for previews.", # noqa: E501
),
] = False,
no_header: Annotated[
Expand All @@ -333,16 +337,17 @@ def run(
Optional[int],
typer.Option(
"--max-workers",
help="Set the max number of parallel threads. Defaults to an optimal number based on your system's cores.",
help="Set the max number of parallel threads. Defaults to an optimal number based on your system's cores.", # noqa: E501
),
] = None,
):
"""
Scans a project, aggregates files, and compiles them into a single Markdown file.
"""
is_verbose = verbose and not silent
# NOTE: Checking for pytest in sys.modules avoids the Rich Status spinner conflicting
# with the test runner's output capture. This is a deliberate practical trade-off;
# NOTE: Checking for pytest in sys.modules avoids the Rich Status spinner
# conflicting with the test runner's output capture. This is a deliberate
# practical trade-off;
is_testing = "pytest" in sys.modules
show_ui = not is_verbose and not silent and not is_testing

Expand All @@ -368,7 +373,8 @@ def run(

if dry_run:
console.print(
"\n[bold yellow]--dry-run enabled. No output file will be written.[/bold yellow]"
"\n[bold yellow]--dry-run enabled. "
"No output file will be written.[/bold yellow]"
)
raise typer.Exit()

Expand All @@ -392,7 +398,7 @@ def stats(
Optional[int],
typer.Option(
"--max-workers",
help="Set the max number of parallel threads. Defaults to an optimal number based on your system's cores.",
help="Set the max number of parallel threads. Defaults to an optimal number based on your system's cores.", # noqa: E501
),
] = None,
):
Expand All @@ -414,7 +420,8 @@ def stats(

console.print(
Panel(
f"📊 [bold]Codecat Stats[/bold] | Analyzing: [cyan]'{project_path.resolve()}'[/cyan]",
f"📊 [bold]Codecat Stats[/bold] | "
f"Analyzing: [cyan]'{project_path.resolve()}'[/cyan]",
border_style="blue",
)
)
Expand Down Expand Up @@ -474,7 +481,7 @@ def generate_config(
typer.Option(
"--output-dir",
"-o",
help="Directory to generate the config file in. Defaults to the current directory.",
help="Directory to generate the config file in. Defaults to the current directory.", # noqa: E501
resolve_path=True,
file_okay=False,
dir_okay=True,
Expand All @@ -485,7 +492,7 @@ def generate_config(
str,
typer.Option(
"--config-file-name",
help=f"Name of the config file. Defaults to [cyan]{DEFAULT_CONFIG_FILENAME}[/cyan].",
help=f"Name of the config file. Defaults to [cyan]{DEFAULT_CONFIG_FILENAME}[/cyan].", # noqa: E501
),
] = DEFAULT_CONFIG_FILENAME,
):
Expand All @@ -498,20 +505,23 @@ def generate_config(
console.print(f"Created directory: [cyan]{output_dir.resolve()}[/cyan]")
except Exception as e:
console.print(
f"[bold red]Error:[/bold red] Could not create directory '{output_dir.resolve()}'. {e}"
f"[bold red]Error:[/bold red] Could not create directory "
f"'{output_dir.resolve()}'. {e}"
)
raise typer.Exit(code=1)
elif not output_dir.is_dir():
console.print(
f"[bold red]Error:[/bold red] Output path '{output_dir.resolve()}' exists but is not a directory."
f"[bold red]Error:[/bold red] Output path "
f"'{output_dir.resolve()}' exists but is not a directory."
)
raise typer.Exit(code=1)

config_file_path = output_dir / config_filename

if config_file_path.exists():
console.print(
f"[yellow]Config file '{config_file_path.resolve()}' already exists.[/yellow]"
f"[yellow]Config file '{config_file_path.resolve()}' "
"already exists.[/yellow]"
)
overwrite = typer.confirm("Do you want to overwrite it?", default=False)
if not overwrite:
Expand All @@ -523,11 +533,13 @@ def generate_config(
json.dumps(DEFAULT_CONFIG, indent=4), encoding="utf-8"
)
console.print(
f"Successfully generated config file: [green]{config_file_path.resolve()}[/green]"
"Successfully generated config file: "
f"[green]{config_file_path.resolve()}[/green]"
)
except IOError as e:
console.print(
f"[bold red]Error writing config file '{config_file_path.resolve()}': {e}[/bold red]"
f"[bold red]Error writing config file "
f"'{config_file_path.resolve()}': {e}[/bold red]"
)
raise typer.Exit(code=1)

Expand All @@ -540,7 +552,7 @@ def web(
typer.Option(
"--port",
"-p",
help="The port to bind the web server to. Defaults to 8080. If in use, it will find the next available port.",
help="The port to bind the web server to. Defaults to 8080. If in use, it will find the next available port.", # noqa: E501
),
] = 8080,
):
Expand All @@ -554,7 +566,7 @@ def web(
border_style="magenta",
)
)
start_web_app(port=port, project_path=project_path)
start_web_app(port=port, project_path=project_path) # pragma: no cover


@app.callback()
Expand Down
6 changes: 3 additions & 3 deletions src/codecat/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@
# Default configuration for Codecat.
# Keys starting with "_" are treated as comments and ignored by the parser.
DEFAULT_CONFIG: dict[str, Any] = {
"_comment_main": "This is the default configuration for Codecat. You can customize it for your project.",
"_comment_main": "This is the default configuration for Codecat. You can customize it for your project.", # noqa: E501
"output_file": DEFAULT_OUTPUT_FILENAME,
"_comment_patterns": "Use glob patterns (like *.py, src/*) to control which files are included or excluded.",
"_comment_patterns": "Use glob patterns (like *.py, src/*) to control which files are included or excluded.", # noqa: E501
"include_patterns": [
"*.py",
"*.pyw",
Expand Down Expand Up @@ -234,7 +234,7 @@
"max_file_size_kb": 1024,
"stop_on_error": False,
"generate_header": True,
"_comment_languages": "Map file extensions to language hints for Markdown code blocks.",
"_comment_languages": "Map file extensions to language hints for Markdown code blocks.", # noqa: E501
"language_hints": {
".py": "python",
".pyw": "python",
Expand Down
8 changes: 5 additions & 3 deletions src/codecat/file_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,10 +109,12 @@ def process_file(
config: dict[str, Any],
) -> ProcessedFileData:
"""
Processes a single file: reads its content, detects if it's binary, or handles errors.
Processes a single file: reads its content, detects if it's binary,
or handles errors.

This function is designed to be a self-contained worker that does not perform
any direct console output. It returns a structured result for the main thread to handle.
This function is designed to be a self-contained worker that does not
perform any direct console output. It returns a structured result for the
main thread to handle.
"""
stop_on_error = config.get("stop_on_error", False)

Expand Down
7 changes: 4 additions & 3 deletions src/codecat/file_scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
def _is_path_excluded_by_pattern(
relative_path_str: str, patterns: List[str], case_sensitive: bool = False
) -> bool:
"""Determines if a given relative path string matches any exclusion glob patterns."""
"""Determines if a relative path string matches any exclusion glob patterns."""
path_to_match = relative_path_str if case_sensitive else relative_path_str.lower()
for pattern_item in patterns:
pattern_to_match = pattern_item if case_sensitive else pattern_item.lower()
Expand All @@ -46,7 +46,7 @@ def _is_path_excluded_by_pattern(
def _is_path_included_by_pattern(
relative_path_str: str, patterns: List[str], case_sensitive: bool = False
) -> bool:
"""Checks if a given relative path string matches any of the inclusion glob patterns."""
"""Checks if a relative path string matches any of the inclusion glob patterns."""
if not patterns: # If no include patterns, everything is implicitly included
return True

Expand Down Expand Up @@ -106,7 +106,8 @@ def scan_project(
status_indicator: Optional[Status] = None,
) -> List[Path]:
"""
Scans the project directory using os.walk for efficiency and returns a list of files.
Scans the project directory using os.walk for efficiency and returns
a list of files.
"""
included_files_set: Set[Path] = set()

Expand Down
9 changes: 6 additions & 3 deletions src/codecat/markdown_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,8 @@ def generate_markdown(
project_path_str = project_root_path.as_posix()
main_parts.append(f"# Codecat: Aggregated Code for '{project_root_path.name}'")
main_parts.append(
f"Generated from `{len(processed_files)}` files found in `{project_path_str}`.\n"
f"Generated from `{len(processed_files)}` files found "
f"in `{project_path_str}`.\n"
)

file_blocks: List[str] = []
Expand All @@ -77,12 +78,14 @@ def generate_markdown(
block_parts.append(f"{fence}{lang_hint}\n{file_data.content}\n{fence}")
elif file_data.status == "binary_file":
block_parts.append(
f"`[INFO] Binary file detected at '{relative_path_str}'. Content not included.`"
f"`[INFO] Binary file detected at '{relative_path_str}'. "
"Content not included.`"
)
elif file_data.status in ["read_error", "skipped_access_error"]:
error_msg = file_data.error_message or "An unknown error occurred."
block_parts.append(
f"`[WARNING] Could not process file '{relative_path_str}'. Error: {error_msg}`"
f"`[WARNING] Could not process file '{relative_path_str}'. "
f"Error: {error_msg}`"
)

file_blocks.append("\n".join(block_parts))
Expand Down
Loading