diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bae4f47 --- /dev/null +++ b/.gitignore @@ -0,0 +1,217 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[codz] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py.cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +# Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +# poetry.lock +# poetry.toml + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# https://pdm-project.org/en/latest/usage/project/#working-with-version-control +# pdm.lock +# pdm.toml +.pdm-python +.pdm-build/ + +# pixi +# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. +# pixi.lock +# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one +# in the .venv directory. It is recommended not to include this directory in version control. +.pixi + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# Redis +*.rdb +*.aof +*.pid + +# RabbitMQ +mnesia/ +rabbitmq/ +rabbitmq-data/ + +# ActiveMQ +activemq-data/ + +# SageMath parsed files +*.sage.py + +# Environments +.env +.envrc +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +# .idea/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the entire vscode folder +# .vscode/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ + +# Streamlit +.streamlit/secrets.toml +.serena/ diff --git a/README.md b/README.md index e69de29..fb61b8d 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,65 @@ +# CodeContexter + +CodeContexter is a command-line tool that walks a source tree, filters files through `.gitignore` rules, and emits a single Markdown report (`code_summary.md`) with file metadata, statistics, and inline source listings. + +## Features +- Reuses project ignore rules, combines them with the built-in `ALWAYS_IGNORE_PATTERNS`, and supports nested `.gitignore` files discovered during traversal. +- Detects file language and category using the tables in `codecontexter/constants.py`, then reports totals by both dimensions. +- Shows progress with `tqdm` when available; falls back to a simple iterator when the dependency is missing. +- Optionally appends per-file SHA-256 hashes for audit trails. +- Ships a `codecontexter` console script for Python 3.12+ via the `pyproject.toml` entry point. + +## Installation +```bash +pip install codecontexter +# or +uv tool install codecontexter +``` + +Runtime dependency: `pathspec`. Optional dependency: `tqdm` for progress bars. + +## Command-line usage +```bash +codecontexter DIRECTORY \ + --output OUTPUT_PATH \ + [--verbose] \ + [--no-metadata-table] \ + [--include-hash] +``` + +| Option | Description | +| --- | --- | +| `DIRECTORY` | Directory to scan (required positional argument). | +| `-o, --output` | Output Markdown path. Defaults to `code_summary.md`. | +| `-v, --verbose` | Print each processed file with size and line count. | +| `--no-metadata-table` | Skip the per-file overview table. | +| `--include-hash` | Compute SHA-256 for each file before writing the report. | + +The CLI reports the resolved project root, the `.gitignore` file in use, and displays progress for scanning and writing when `tqdm` is present. + +## Report layout +- **Header** – Repository name, generation timestamp, and source directory path. +- **Statistics** – Total files, total lines, total size, plus breakdowns by category and language. +- **File Metadata table** *(optional)* – File path, size, lines, language, category, and last modification timestamp. +- **Table of Contents** – Links to each file section using GitHub-Flavoured Markdown anchors. +- **File sections** – For each file: language label, size, line count, category, optional SHA-256, and a fenced code block with the exact file contents. + +## Configuration points +- Language detection and categorisation live in `codecontexter/constants.py` (`LANGUAGE_MAP` and `FILE_CATEGORIES`). Extend these tables if your project relies on additional file types. +- Permanent ignore rules are defined in `ALWAYS_IGNORE_PATTERNS`. Add directories or patterns there to exclude them globally. +- Hashing is disabled by default; enable `--include-hash` when you need verifiable snapshots. + +## Development +```bash +uv sync +source .venv/bin/activate +ruff check +python -m codecontexter.cli . +``` + +The project currently ships without automated tests. Use the CLI against a fixture project when verifying changes. `pytest` is listed in the `dev` extra for adding coverage. + +## Roadmap ideas +- Allow size limits per file to avoid embedding large binaries. +- Offer an HTML writer alongside the Markdown generator. +- Group files by package or module to provide higher-level structure summaries. diff --git a/app.py b/app.py deleted file mode 100644 index 10caf6f..0000000 --- a/app.py +++ /dev/null @@ -1,595 +0,0 @@ -#!/usr/bin/env python3 - -import os -import argparse -import pathlib -import pathspec -import sys -import mimetypes -import hashlib -from typing import Iterator, Dict, Set, Optional, Tuple, List -from datetime import datetime -from concurrent.futures import ProcessPoolExecutor, as_completed -from dataclasses import dataclass -from collections import defaultdict - -# For better user experience -try: - import tqdm -except ImportError: - class tqdm: - def __init__(self, iterable=None, **kwargs): - self.iterable = iterable or [] - def __iter__(self): - return iter(self.iterable) - def __enter__(self): - return self - def __exit__(self, *args): - pass - def update(self, n=1): - pass - def close(self): - pass - -# --- Enhanced Configuration --- - -LANGUAGE_MAP: Dict[str, Optional[str]] = { - # Python - '.py': 'python', '.pyi': 'python', '.pyx': 'python', - '.ipynb': 'json', 'pyproject.toml': 'toml', 'setup.py': 'python', - 'requirements.txt': 'text', 'requirements-dev.txt': 'text', - 'pipfile': 'toml', 'pipfile.lock': 'json', 'poetry.lock': 'toml', - 'setup.cfg': 'ini', 'tox.ini': 'ini', 'pytest.ini': 'ini', - - # JavaScript/TypeScript/Node - '.js': 'javascript', '.mjs': 'javascript', '.cjs': 'javascript', - '.jsx': 'jsx', '.ts': 'typescript', '.tsx': 'tsx', - 'package.json': 'json', 'package-lock.json': 'json', - 'tsconfig.json': 'json', 'jsconfig.json': 'json', - '.eslintrc': 'json', '.eslintrc.json': 'json', '.eslintrc.js': 'javascript', - '.prettierrc': 'json', '.babelrc': 'json', 'babel.config.js': 'javascript', - 'webpack.config.js': 'javascript', 'vite.config.js': 'javascript', - 'vite.config.ts': 'typescript', 'next.config.js': 'javascript', - 'nuxt.config.js': 'javascript', 'vue.config.js': 'javascript', - - # Web - '.html': 'html', '.htm': 'html', '.css': 'css', - '.scss': 'scss', '.sass': 'sass', '.less': 'less', - '.vue': 'vue', '.svelte': 'svelte', - - # Java & JVM - '.java': 'java', '.kt': 'kotlin', '.kts': 'kotlin', - '.groovy': 'groovy', '.gradle': 'groovy', '.scala': 'scala', - 'pom.xml': 'xml', 'build.gradle': 'groovy', 'build.gradle.kts': 'kotlin', - 'settings.gradle': 'groovy', 'gradlew': 'bash', - 'application.properties': 'properties', 'application.yml': 'yaml', - 'application.yaml': 'yaml', - - # C-family - '.c': 'c', '.h': 'c', '.cpp': 'cpp', '.hpp': 'cpp', '.cc': 'cpp', - '.cxx': 'cpp', '.hxx': 'cpp', '.cs': 'csharp', - '.m': 'objective-c', '.mm': 'objective-c', - 'CMakeLists.txt': 'cmake', '.cmake': 'cmake', - - # Other Languages - '.go': 'go', 'go.mod': 'go', 'go.sum': 'text', - '.rs': 'rust', 'Cargo.toml': 'toml', 'Cargo.lock': 'toml', - '.rb': 'ruby', 'Gemfile': 'ruby', 'Gemfile.lock': 'text', 'Rakefile': 'ruby', - '.php': 'php', 'composer.json': 'json', 'composer.lock': 'json', - '.swift': 'swift', 'Package.swift': 'swift', - '.dart': 'dart', 'pubspec.yaml': 'yaml', 'pubspec.lock': 'yaml', - '.lua': 'lua', '.pl': 'perl', '.pm': 'perl', - '.r': 'r', '.R': 'r', '.jl': 'julia', - '.ex': 'elixir', '.exs': 'elixir', '.erl': 'erlang', - '.clj': 'clojure', '.cljs': 'clojure', - - # Shell & Scripts - '.sh': 'bash', '.bash': 'bash', '.zsh': 'zsh', '.fish': 'fish', - '.ps1': 'powershell', '.psm1': 'powershell', - '.bat': 'batch', '.cmd': 'batch', - - # Config & Data - '.json': 'json', '.json5': 'json', '.jsonc': 'json', - '.yaml': 'yaml', '.yml': 'yaml', - '.xml': 'xml', '.toml': 'toml', '.ini': 'ini', - '.cfg': 'ini', '.conf': 'ini', '.config': 'ini', - '.properties': 'properties', - - # Docker & Containers - 'dockerfile': 'dockerfile', 'Dockerfile': 'dockerfile', - '.dockerfile': 'dockerfile', 'Dockerfile.dev': 'dockerfile', - 'Dockerfile.prod': 'dockerfile', 'Dockerfile.test': 'dockerfile', - 'docker-compose.yml': 'yaml', 'docker-compose.yaml': 'yaml', - 'docker-compose.dev.yml': 'yaml', 'docker-compose.prod.yml': 'yaml', - 'docker-compose.override.yml': 'yaml', - '.dockerignore': 'text', 'compose.yml': 'yaml', 'compose.yaml': 'yaml', - - # Kubernetes & Orchestration - 'deployment.yaml': 'yaml', 'deployment.yml': 'yaml', - 'service.yaml': 'yaml', 'service.yml': 'yaml', - 'ingress.yaml': 'yaml', 'ingress.yml': 'yaml', - 'configmap.yaml': 'yaml', 'configmap.yml': 'yaml', - 'secret.yaml': 'yaml', 'secret.yml': 'yaml', - 'kustomization.yaml': 'yaml', 'kustomization.yml': 'yaml', - 'helmfile.yaml': 'yaml', 'Chart.yaml': 'yaml', - 'values.yaml': 'yaml', 'values.yml': 'yaml', - - # Infrastructure as Code - '.tf': 'hcl', '.tfvars': 'hcl', '.hcl': 'hcl', - 'terraform.tfvars': 'hcl', 'variables.tf': 'hcl', - 'outputs.tf': 'hcl', 'main.tf': 'hcl', - 'Vagrantfile': 'ruby', - - # CI/CD - '.gitlab-ci.yml': 'yaml', 'gitlab-ci.yml': 'yaml', - '.travis.yml': 'yaml', 'travis.yml': 'yaml', - 'circle.yml': 'yaml', '.circleci': 'yaml', - 'Jenkinsfile': 'groovy', 'jenkinsfile': 'groovy', - 'azure-pipelines.yml': 'yaml', 'azure-pipelines.yaml': 'yaml', - '.drone.yml': 'yaml', 'bitbucket-pipelines.yml': 'yaml', - 'appveyor.yml': 'yaml', '.appveyor.yml': 'yaml', - - # GitHub Actions - 'action.yml': 'yaml', 'action.yaml': 'yaml', - - # Ansible - 'playbook.yml': 'yaml', 'playbook.yaml': 'yaml', - 'ansible.cfg': 'ini', 'hosts': 'ini', 'inventory': 'ini', - - # Database - '.sql': 'sql', '.psql': 'sql', '.mysql': 'sql', - '.prisma': 'prisma', - - # Docs & Text - '.md': 'markdown', '.markdown': 'markdown', - '.txt': 'text', '.rst': 'rst', '.adoc': 'asciidoc', - 'README': 'markdown', 'CHANGELOG': 'markdown', - 'LICENSE': 'text', 'CONTRIBUTING': 'markdown', - - # Build & Make - 'makefile': 'makefile', 'Makefile': 'makefile', - 'GNUmakefile': 'makefile', 'makefile.am': 'makefile', - - # Environment & Config - '.env': 'bash', '.env.example': 'bash', '.env.local': 'bash', - '.env.development': 'bash', '.env.production': 'bash', - '.env.test': 'bash', '.env.sample': 'bash', - '.envrc': 'bash', '.flaskenv': 'bash', - - # Git & VCS - '.gitignore': 'text', '.gitattributes': 'text', - '.gitmodules': 'text', '.dockerignore': 'text', - '.npmignore': 'text', '.eslintignore': 'text', - - # Editor Config - '.editorconfig': 'ini', '.vimrc': 'vim', '.nvimrc': 'vim', - - # GraphQL & API - '.graphql': 'graphql', '.gql': 'graphql', - '.proto': 'protobuf', '.avro': 'json', '.thrift': 'thrift', - 'openapi.yaml': 'yaml', 'openapi.yml': 'yaml', - 'swagger.yaml': 'yaml', 'swagger.yml': 'yaml', -} - -ALWAYS_IGNORE_PATTERNS: Set[str] = { - # Version Control - '.git/', '.svn/', '.hg/', '.bzr/', - - # Dependencies - 'node_modules/', 'bower_components/', 'jspm_packages/', - 'vendor/', 'vendors/', - - # Build outputs - '.next/', '.nuxt/', '.output/', 'out/', 'dist/', 'build/', - 'target/', '_site/', '.cache/', '.parcel-cache/', - '.swc/', '.turbo/', '.vercel/', - - # Python - '__pycache__/', '*.pyc', '*.pyo', '*.pyd', '.Python', - '.venv/', 'venv/', 'env/', 'ENV/', 'virtualenv/', '.pytest_cache/', - '.mypy_cache/', '.ruff_cache/', '.hypothesis/', '*.egg-info/', - '.tox/', '.coverage', 'htmlcov/', '.eggs/', '*.egg', - - # Java/JVM - '*.class', '*.jar', '*.war', '*.ear', 'hs_err_pid*', - - # Rust - 'target/', - - # Go - 'bin/', 'pkg/', - - # Ruby - '.bundle/', - - # IDEs - '.idea/', '.vscode/', '*.swp', '*.swo', '*~', - '.DS_Store', 'Thumbs.db', '.project', '.classpath', - '.settings/', '*.sublime-workspace', '*.sublime-project', - - # Logs & Databases - '*.log', '*.sqlite', '*.sqlite3', '*.db', - 'npm-debug.log*', 'yarn-debug.log*', 'yarn-error.log*', - - # OS - '.DS_Store', 'Thumbs.db', 'desktop.ini', - - # Misc - '.env.local', '.env.*.local', '*.lock', - 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', - 'composer.lock', 'Gemfile.lock', 'poetry.lock', - 'public/', 'static/', 'assets/', 'media/', 'uploads/', -} - -# File categories for better organization -FILE_CATEGORIES = { - 'source': {'.py', '.js', '.ts', '.java', '.go', '.rs', '.rb', '.php', '.cpp', '.c', '.cs'}, - 'config': {'.json', '.yaml', '.yml', '.toml', '.ini', '.env', '.config'}, - 'docker': {'dockerfile', 'docker-compose.yml', 'docker-compose.yaml', '.dockerignore'}, - 'iac': {'.tf', '.tfvars', '.hcl'}, - 'ci_cd': {'.gitlab-ci.yml', 'Jenkinsfile', 'azure-pipelines.yml'}, - 'build': {'makefile', 'CMakeLists.txt', 'build.gradle', 'pom.xml'}, - 'docs': {'.md', '.rst', '.txt'}, -} - -@dataclass -class FileMetadata: - """Metadata for a single file""" - path: pathlib.Path - relative_path: pathlib.Path - language: str - size: int - lines: int - modified: datetime - category: str - hash_md5: Optional[str] = None - -# --- Performance Optimizations --- - -def get_file_category(file_path: pathlib.Path) -> str: - """Categorize file by its purpose""" - name_lower = file_path.name.lower() - suffix_lower = file_path.suffix.lower() - - for category, extensions in FILE_CATEGORIES.items(): - if name_lower in extensions or suffix_lower in extensions: - return category - return 'other' - -def count_lines(file_path: pathlib.Path) -> int: - """Efficiently count lines in a file""" - try: - with open(file_path, 'rb') as f: - return sum(1 for _ in f) - except: - return 0 - -def get_file_hash(file_path: pathlib.Path, hash_files: bool = False) -> Optional[str]: - """Get MD5 hash of file for verification""" - if not hash_files: - return None - try: - hash_md5 = hashlib.md5() - with open(file_path, "rb") as f: - for chunk in iter(lambda: f.read(4096), b""): - hash_md5.update(chunk) - return hash_md5.hexdigest() - except: - return None - -def process_file(file_path: pathlib.Path, start_path: pathlib.Path, - include_hash: bool = False) -> Optional[FileMetadata]: - """Process a single file and extract metadata""" - try: - lang = get_language_from_path(file_path) - if lang is None: - return None - - stat = file_path.stat() - return FileMetadata( - path=file_path, - relative_path=file_path.relative_to(start_path), - language=lang or 'text', - size=stat.st_size, - lines=count_lines(file_path), - modified=datetime.fromtimestamp(stat.st_mtime), - category=get_file_category(file_path), - hash_md5=get_file_hash(file_path, include_hash) - ) - except Exception as e: - print(f"Warning: Error processing {file_path}: {e}", file=sys.stderr) - return None - -def find_project_root(start_dir: pathlib.Path) -> pathlib.Path: - """Find the nearest parent directory containing .git or return start_dir.""" - current = start_dir.resolve() - while True: - if (current / '.git').is_dir(): - return current - parent = current.parent - if parent == current: - print("Warning: .git directory not found. Using starting directory as project root.", - file=sys.stderr) - return start_dir.resolve() - current = parent - -def get_combined_spec(root_dir: pathlib.Path) -> pathspec.PathSpec: - """Combines ALWAYS_IGNORE_PATTERNS with patterns from .gitignore files.""" - all_patterns = list(ALWAYS_IGNORE_PATTERNS) - gitignore_path = root_dir / '.gitignore' - if gitignore_path.is_file(): - try: - with open(gitignore_path, 'r', encoding='utf-8', errors='ignore') as f: - all_patterns.extend(f.readlines()) - except Exception as e: - print(f"Warning: Could not read .gitignore at {gitignore_path}: {e}", file=sys.stderr) - - return pathspec.PathSpec.from_lines(pathspec.patterns.GitWildMatchPattern, all_patterns) - -def get_language_from_path(file_path: pathlib.Path) -> Optional[str]: - """Determines the language hint from the file path based on LANGUAGE_MAP.""" - name_lower = file_path.name.lower() - - # Check exact name first (case-insensitive) - if name_lower in LANGUAGE_MAP: - return LANGUAGE_MAP[name_lower] - - # Check exact name (case-sensitive) for files like Makefile - if file_path.name in LANGUAGE_MAP: - return LANGUAGE_MAP[file_path.name] - - # Check by extension - if file_path.suffix.lower() in LANGUAGE_MAP: - return LANGUAGE_MAP[file_path.suffix.lower()] - - # Special handling for files in .github/workflows - if '.github' in file_path.parts and 'workflows' in file_path.parts: - if file_path.suffix in {'.yml', '.yaml'}: - return 'yaml' - - # Include files with no extension as plain text if they appear to be text - if not file_path.suffix: - try: - with open(file_path, 'r', encoding='utf-8') as f: - f.read(512) - return 'text' - except (UnicodeDecodeError, IOError): - return None - - return None - -def collect_files(start_path: pathlib.Path, project_root: pathlib.Path, - combined_spec: pathspec.PathSpec, output_path: pathlib.Path, - script_path: pathlib.Path) -> List[pathlib.Path]: - """Efficiently collect all files to process""" - files_to_process = [] - - for root, dirs, files in os.walk(start_path, topdown=True): - root_path = pathlib.Path(root) - - # Prune ignored directories - for d in list(dirs): - dir_path = root_path / d - try: - relative_dir = dir_path.relative_to(project_root) - relative_dir_str = str(relative_dir).replace(os.sep, '/') - if combined_spec.match_file(relative_dir_str): - dirs.remove(d) - except ValueError: - pass - - for filename in files: - file_path = root_path / filename - - # Skip output and script - try: - if file_path.resolve() in (output_path.resolve(), script_path.resolve()): - continue - except: - pass - - # Check against ignore patterns - try: - relative_file = file_path.relative_to(project_root) - relative_file_str = str(relative_file).replace(os.sep, '/') - if combined_spec.match_file(relative_file_str): - continue - except ValueError: - pass - - # Check if file type is supported - if get_language_from_path(file_path) is not None: - files_to_process.append(file_path) - - return files_to_process - -def generate_metadata_table(files_metadata: List[FileMetadata]) -> str: - """Generate a markdown table with file metadata""" - table = "| File | Size | Lines | Type | Category | Last Modified |\n" - table += "|------|------|-------|------|----------|---------------|\n" - - for meta in sorted(files_metadata, key=lambda x: str(x.relative_path)): - size_str = format_size(meta.size) - modified_str = meta.modified.strftime("%Y-%m-%d %H:%M") - table += f"| `{meta.relative_path}` | {size_str} | {meta.lines} | {meta.language} | {meta.category} | {modified_str} |\n" - - return table - -def format_size(size_bytes: int) -> str: - """Format file size in human-readable format""" - for unit in ['B', 'KB', 'MB', 'GB']: - if size_bytes < 1024.0: - return f"{size_bytes:.1f} {unit}" - size_bytes /= 1024.0 - return f"{size_bytes:.1f} TB" - -def generate_statistics(files_metadata: List[FileMetadata]) -> str: - """Generate statistics summary""" - total_files = len(files_metadata) - total_lines = sum(m.lines for m in files_metadata) - total_size = sum(m.size for m in files_metadata) - - # Group by category - by_category = defaultdict(list) - for meta in files_metadata: - by_category[meta.category].append(meta) - - # Group by language - by_language = defaultdict(list) - for meta in files_metadata: - by_language[meta.language].append(meta) - - stats = f"## 📊 Statistics\n\n" - stats += f"- **Total Files:** {total_files}\n" - stats += f"- **Total Lines of Code:** {total_lines:,}\n" - stats += f"- **Total Size:** {format_size(total_size)}\n\n" - - stats += "### By Category\n\n" - for category, metas in sorted(by_category.items(), key=lambda x: len(x[1]), reverse=True): - count = len(metas) - lines = sum(m.lines for m in metas) - stats += f"- **{category}:** {count} files, {lines:,} lines\n" - - stats += "\n### By Language\n\n" - for language, metas in sorted(by_language.items(), key=lambda x: len(x[1]), reverse=True): - count = len(metas) - lines = sum(m.lines for m in metas) - stats += f"- **{language}:** {count} files, {lines:,} lines\n" - - return stats - -def create_markdown(target_dir: str, output_file: str, verbose: bool, - include_metadata_table: bool, include_hash: bool, - max_workers: int): - """Generates the enhanced Markdown file.""" - start_path = pathlib.Path(target_dir).resolve() - output_path = pathlib.Path(output_file).resolve() - - try: - script_path = pathlib.Path(__file__).resolve() - except NameError: - script_path = pathlib.Path.cwd() / "code_summarizer.py" - - if not start_path.is_dir(): - print(f"Error: Directory not found: {target_dir}", file=sys.stderr) - sys.exit(1) - - project_root = find_project_root(start_path) - combined_spec = get_combined_spec(project_root) - - print(f"📂 Scanning directory: {start_path}") - print(f"🔍 Project root: {project_root}") - if (project_root / '.gitignore').exists(): - print(f"✓ Using .gitignore from: {project_root / '.gitignore'}") - - # Collect files - print("📑 Collecting files...") - file_paths = collect_files(start_path, project_root, combined_spec, output_path, script_path) - print(f"✓ Found {len(file_paths)} files to process") - - # Process files with metadata - print("🔄 Processing files and extracting metadata...") - files_metadata: List[FileMetadata] = [] - - with tqdm(total=len(file_paths), desc="Processing", unit="file") as pbar: - for file_path in file_paths: - meta = process_file(file_path, start_path, include_hash) - if meta: - files_metadata.append(meta) - if verbose: - print(f" ✓ {meta.relative_path} ({meta.lines} lines, {format_size(meta.size)})") - pbar.update(1) - - # Write markdown - print(f"📝 Writing to {output_path}...") - try: - output_path.parent.mkdir(parents=True, exist_ok=True) - with open(output_path, 'w', encoding='utf-8') as md_file: - # Header - md_file.write(f"# 📦 Code Summary: {start_path.name}\n\n") - md_file.write(f"**Generated:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n") - md_file.write(f"**Source Directory:** `{start_path}`\n\n") - md_file.write("---\n\n") - - # Statistics - md_file.write(generate_statistics(files_metadata)) - md_file.write("\n---\n\n") - - # Metadata table - if include_metadata_table: - md_file.write("## 📋 File Metadata\n\n") - md_file.write(generate_metadata_table(files_metadata)) - md_file.write("\n---\n\n") - - # Table of Contents - md_file.write("## 📑 Table of Contents\n\n") - for meta in sorted(files_metadata, key=lambda x: str(x.relative_path)): - anchor = str(meta.relative_path).replace('/', '').replace('.', '').replace(' ', '-') - md_file.write(f"- [`{meta.relative_path}`](#{anchor})\n") - md_file.write("\n---\n\n") - - # File contents - md_file.write("## 📄 File Contents\n\n") - with tqdm(total=len(files_metadata), desc="Writing", unit="file") as pbar: - for meta in sorted(files_metadata, key=lambda x: str(x.relative_path)): - try: - with open(meta.path, 'r', encoding='utf-8', errors='ignore') as code_file: - content = code_file.read() - - md_file.write(f"### File: `{meta.relative_path}`\n\n") - md_file.write(f"**Language:** {meta.language} | ") - md_file.write(f"**Size:** {format_size(meta.size)} | ") - md_file.write(f"**Lines:** {meta.lines} | ") - md_file.write(f"**Category:** {meta.category}\n\n") - - if meta.hash_md5: - md_file.write(f"**MD5:** `{meta.hash_md5}`\n\n") - - md_file.write(f"```") - md_file.write(content.strip() + "\n") - md_file.write("```\n\n") - md_file.write("---\n\n") - except Exception as e: - print(f"⚠ Warning: Could not read {meta.relative_path}: {e}", file=sys.stderr) - - pbar.update(1) - - print(f"\n✅ Success! Processed {len(files_metadata)} files") - print(f"📊 Total lines: {sum(m.lines for m in files_metadata):,}") - print(f"💾 Total size: {format_size(sum(m.size for m in files_metadata))}") - print(f"📄 Output: {output_path}") - - except IOError as e: - print(f"\n❌ Error: Could not write to {output_path}: {e}", file=sys.stderr) - sys.exit(1) - -def main(): - parser = argparse.ArgumentParser( - description="Create an enhanced Markdown summary of code files with rich metadata and Docker support.", - formatter_class=argparse.ArgumentDefaultsHelpFormatter - ) - parser.add_argument("directory", help="The target directory to scan for code files.") - parser.add_argument("-o", "--output", default="code_summary.md", - help="The path for the output Markdown file.") - parser.add_argument("-v", "--verbose", action="store_true", - help="Show detailed processing information.") - parser.add_argument("--no-metadata-table", action="store_true", - help="Skip generating the metadata table.") - parser.add_argument("--include-hash", action="store_true", - help="Include MD5 hash for each file (slower).") - parser.add_argument("-j", "--jobs", type=int, default=4, - help="Number of parallel workers (currently not used, reserved for future).") - - args = parser.parse_args() - - create_markdown( - args.directory, - args.output, - args.verbose, - not args.no_metadata_table, - args.include_hash, - args.jobs - ) - -if __name__ == "__main__": - main() diff --git a/pyproject.toml b/pyproject.toml index c043e12..8af5bd7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,42 @@ [project] name = "codecontexter" version = "0.1.0" -description = "Add your description here" +description = "Create enhanced Markdown summaries of code files with rich metadata" readme = "README.md" requires-python = ">=3.12" dependencies = [ - "django-widget-tweaks>=1.5.0", "pathspec>=0.12.1", ] + +[project.optional-dependencies] +dev = [ + "ruff>=0.8.0", + "pytest>=8.0.0", +] + +[project.scripts] +codecontexter = "codecontexter.cli:main" + +[tool.uv] +package = true + +[tool.ruff] +line-length = 100 +target-version = "py312" + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "F", # pyflakes + "I", # isort + "N", # pep8-naming + "W", # pycodestyle warnings + "UP", # pyupgrade + "B", # flake8-bugbear + "C4", # flake8-comprehensions +] +ignore = [] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/codecontexter/__init__.py b/src/codecontexter/__init__.py new file mode 100644 index 0000000..710379a --- /dev/null +++ b/src/codecontexter/__init__.py @@ -0,0 +1,11 @@ +"""CodeContexter: A tool for creating enhanced Markdown summaries of code files. + +This package provides utilities to scan directories, analyze code files, +and generate comprehensive documentation in Markdown format. +""" + +from codecontexter.cli import main +from codecontexter.models import FileMetadata + +__version__ = "0.1.0" +__all__ = ["main", "FileMetadata"] diff --git a/src/codecontexter/cli.py b/src/codecontexter/cli.py new file mode 100644 index 0000000..be1d449 --- /dev/null +++ b/src/codecontexter/cli.py @@ -0,0 +1,53 @@ +"""Command-line interface for codecontexter.""" + +import argparse + +from codecontexter.output_generators import create_markdown + + +def main(): + """Main entry point for the codecontexter CLI.""" + parser = argparse.ArgumentParser( + description=( + "Create an enhanced Markdown summary of code files " + "with rich metadata and Docker support." + ), + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + parser.add_argument("directory", help="The target directory to scan for code files.") + parser.add_argument( + "-o", + "--output", + default="code_summary.md", + help="The path for the output Markdown file.", + ) + parser.add_argument( + "-v", + "--verbose", + action="store_true", + help="Show detailed processing information.", + ) + parser.add_argument( + "--no-metadata-table", + action="store_true", + help="Skip generating the metadata table.", + ) + parser.add_argument( + "--include-hash", + action="store_true", + help="Include SHA-256 hash for each file (slower).", + ) + + args = parser.parse_args() + + create_markdown( + args.directory, + args.output, + args.verbose, + not args.no_metadata_table, + args.include_hash, + ) + + +if __name__ == "__main__": + main() diff --git a/src/codecontexter/constants.py b/src/codecontexter/constants.py new file mode 100644 index 0000000..e35f80c --- /dev/null +++ b/src/codecontexter/constants.py @@ -0,0 +1,375 @@ +"""Configuration constants for codecontexter.""" + +# Language mapping from file extension/name to language name +LANGUAGE_MAP: dict[str, str | None] = { + # Python + ".py": "python", + ".pyi": "python", + ".pyx": "python", + ".ipynb": "json", + "pyproject.toml": "toml", + "setup.py": "python", + "requirements.txt": "text", + "requirements-dev.txt": "text", + "pipfile": "toml", + "pipfile.lock": "json", + "poetry.lock": "toml", + "setup.cfg": "ini", + "tox.ini": "ini", + "pytest.ini": "ini", + # JavaScript/TypeScript/Node + ".js": "javascript", + ".mjs": "javascript", + ".cjs": "javascript", + ".jsx": "jsx", + ".ts": "typescript", + ".tsx": "tsx", + "package.json": "json", + "package-lock.json": "json", + "tsconfig.json": "json", + "jsconfig.json": "json", + ".eslintrc": "json", + ".eslintrc.json": "json", + ".eslintrc.js": "javascript", + ".prettierrc": "json", + ".babelrc": "json", + "babel.config.js": "javascript", + "webpack.config.js": "javascript", + "vite.config.js": "javascript", + "vite.config.ts": "typescript", + "next.config.js": "javascript", + "nuxt.config.js": "javascript", + "vue.config.js": "javascript", + # Web + ".html": "html", + ".htm": "html", + ".css": "css", + ".scss": "scss", + ".sass": "sass", + ".less": "less", + ".vue": "vue", + ".svelte": "svelte", + # Java & JVM + ".java": "java", + ".kt": "kotlin", + ".kts": "kotlin", + ".groovy": "groovy", + ".gradle": "groovy", + ".scala": "scala", + "pom.xml": "xml", + "build.gradle": "groovy", + "build.gradle.kts": "kotlin", + "settings.gradle": "groovy", + "gradlew": "bash", + "application.properties": "properties", + "application.yml": "yaml", + "application.yaml": "yaml", + # C-family + ".c": "c", + ".h": "c", + ".cpp": "cpp", + ".hpp": "cpp", + ".cc": "cpp", + ".cxx": "cpp", + ".hxx": "cpp", + ".cs": "csharp", + ".m": "objective-c", + ".mm": "objective-c", + "CMakeLists.txt": "cmake", + ".cmake": "cmake", + # Other Languages + ".go": "go", + "go.mod": "go", + "go.sum": "text", + ".rs": "rust", + "Cargo.toml": "toml", + "Cargo.lock": "toml", + ".rb": "ruby", + "Gemfile": "ruby", + "Gemfile.lock": "text", + "Rakefile": "ruby", + ".php": "php", + "composer.json": "json", + "composer.lock": "json", + ".swift": "swift", + "Package.swift": "swift", + ".dart": "dart", + "pubspec.yaml": "yaml", + "pubspec.lock": "yaml", + ".lua": "lua", + ".pl": "perl", + ".pm": "perl", + ".r": "r", + ".R": "r", + ".jl": "julia", + ".ex": "elixir", + ".exs": "elixir", + ".erl": "erlang", + ".clj": "clojure", + ".cljs": "clojure", + # Shell & Scripts + ".sh": "bash", + ".bash": "bash", + ".zsh": "zsh", + ".fish": "fish", + ".ps1": "powershell", + ".psm1": "powershell", + ".bat": "batch", + ".cmd": "batch", + # Config & Data + ".json": "json", + ".json5": "json", + ".jsonc": "json", + ".yaml": "yaml", + ".yml": "yaml", + ".xml": "xml", + ".toml": "toml", + ".ini": "ini", + ".cfg": "ini", + ".conf": "ini", + ".config": "ini", + ".properties": "properties", + # Docker & Containers + "dockerfile": "dockerfile", + "Dockerfile": "dockerfile", + ".dockerfile": "dockerfile", + "Dockerfile.dev": "dockerfile", + "Dockerfile.prod": "dockerfile", + "Dockerfile.test": "dockerfile", + "docker-compose.yml": "yaml", + "docker-compose.yaml": "yaml", + "docker-compose.dev.yml": "yaml", + "docker-compose.prod.yml": "yaml", + "docker-compose.override.yml": "yaml", + ".dockerignore": "text", + "compose.yml": "yaml", + "compose.yaml": "yaml", + # Kubernetes & Orchestration + "deployment.yaml": "yaml", + "deployment.yml": "yaml", + "service.yaml": "yaml", + "service.yml": "yaml", + "ingress.yaml": "yaml", + "ingress.yml": "yaml", + "configmap.yaml": "yaml", + "configmap.yml": "yaml", + "secret.yaml": "yaml", + "secret.yml": "yaml", + "kustomization.yaml": "yaml", + "kustomization.yml": "yaml", + "helmfile.yaml": "yaml", + "Chart.yaml": "yaml", + "values.yaml": "yaml", + "values.yml": "yaml", + # Infrastructure as Code + ".tf": "hcl", + ".tfvars": "hcl", + ".hcl": "hcl", + "terraform.tfvars": "hcl", + "variables.tf": "hcl", + "outputs.tf": "hcl", + "main.tf": "hcl", + "Vagrantfile": "ruby", + # CI/CD + ".gitlab-ci.yml": "yaml", + "gitlab-ci.yml": "yaml", + ".travis.yml": "yaml", + "travis.yml": "yaml", + "circle.yml": "yaml", + ".circleci": "yaml", + "Jenkinsfile": "groovy", + "jenkinsfile": "groovy", + "azure-pipelines.yml": "yaml", + "azure-pipelines.yaml": "yaml", + ".drone.yml": "yaml", + "bitbucket-pipelines.yml": "yaml", + "appveyor.yml": "yaml", + ".appveyor.yml": "yaml", + # GitHub Actions + "action.yml": "yaml", + "action.yaml": "yaml", + # Ansible + "playbook.yml": "yaml", + "playbook.yaml": "yaml", + "ansible.cfg": "ini", + "hosts": "ini", + "inventory": "ini", + # Database + ".sql": "sql", + ".psql": "sql", + ".mysql": "sql", + ".prisma": "prisma", + # Docs & Text + ".md": "markdown", + ".markdown": "markdown", + ".txt": "text", + ".rst": "rst", + ".adoc": "asciidoc", + "README": "markdown", + "CHANGELOG": "markdown", + "LICENSE": "text", + "CONTRIBUTING": "markdown", + # Build & Make + "makefile": "makefile", + "Makefile": "makefile", + "GNUmakefile": "makefile", + "makefile.am": "makefile", + # Environment & Config + ".env": "bash", + ".env.example": "bash", + ".env.local": "bash", + ".env.development": "bash", + ".env.production": "bash", + ".env.test": "bash", + ".env.sample": "bash", + ".envrc": "bash", + ".flaskenv": "bash", + # Git & VCS + ".gitignore": "text", + ".gitattributes": "text", + ".gitmodules": "text", + ".npmignore": "text", + ".eslintignore": "text", + # Editor Config + ".editorconfig": "ini", + ".vimrc": "vim", + ".nvimrc": "vim", + # GraphQL & API + ".graphql": "graphql", + ".gql": "graphql", + ".proto": "protobuf", + ".avro": "json", + ".thrift": "thrift", + "openapi.yaml": "yaml", + "openapi.yml": "yaml", + "swagger.yaml": "yaml", + "swagger.yml": "yaml", +} + +# Patterns to always ignore during file scanning +ALWAYS_IGNORE_PATTERNS: set[str] = { + # Version Control + ".git/", + ".svn/", + ".hg/", + ".bzr/", + # Dependencies + "node_modules/", + "bower_components/", + "jspm_packages/", + "vendor/", + "vendors/", + # Build outputs + ".next/", + ".nuxt/", + ".output/", + "out/", + "dist/", + "build/", + "target/", + "_site/", + ".cache/", + ".parcel-cache/", + ".swc/", + ".turbo/", + ".vercel/", + # Python + "__pycache__/", + "*.pyc", + "*.pyo", + "*.pyd", + ".Python", + ".venv/", + "venv/", + "env/", + "ENV/", + "virtualenv/", + ".pytest_cache/", + ".mypy_cache/", + ".ruff_cache/", + ".hypothesis/", + "*.egg-info/", + ".tox/", + ".coverage", + "htmlcov/", + ".eggs/", + "*.egg", + # Java/JVM + "*.class", + "*.jar", + "*.war", + "*.ear", + "hs_err_pid*", + # Rust + # Go + "bin/", + "pkg/", + # Ruby + ".bundle/", + # IDEs + ".idea/", + ".vscode/", + "*.swp", + "*.swo", + "*~", + ".DS_Store", + "Thumbs.db", + ".project", + ".classpath", + ".settings/", + "*.sublime-workspace", + "*.sublime-project", + # Logs & Databases + "*.log", + "*.sqlite", + "*.sqlite3", + "*.db", + "npm-debug.log*", + "yarn-debug.log*", + "yarn-error.log*", + # OS + "desktop.ini", + # Misc + ".env.local", + ".env.*.local", + "*.lock", + "package-lock.json", + "yarn.lock", + "pnpm-lock.yaml", + "composer.lock", + "Gemfile.lock", + "poetry.lock", + "public/", + "static/", + "assets/", + "media/", + "uploads/", +} + +# File categories for better organization +FILE_CATEGORIES = { + "source": { + ".py", + ".js", + ".ts", + ".java", + ".go", + ".rs", + ".rb", + ".php", + ".cpp", + ".c", + ".cs", + }, + "config": {".json", ".yaml", ".yml", ".toml", ".ini", ".env", ".config"}, + "docker": { + "dockerfile", + "docker-compose.yml", + "docker-compose.yaml", + ".dockerignore", + }, + "iac": {".tf", ".tfvars", ".hcl"}, + "ci_cd": {".gitlab-ci.yml", "Jenkinsfile", "azure-pipelines.yml"}, + "build": {"makefile", "CMakeLists.txt", "build.gradle", "pom.xml"}, + "docs": {".md", ".rst", ".txt"}, +} diff --git a/src/codecontexter/file_operations.py b/src/codecontexter/file_operations.py new file mode 100644 index 0000000..4a91a74 --- /dev/null +++ b/src/codecontexter/file_operations.py @@ -0,0 +1,270 @@ +"""File system operations and file processing utilities.""" + +import hashlib +import os +import pathlib +import sys +from datetime import datetime + +import pathspec + +from codecontexter.constants import ALWAYS_IGNORE_PATTERNS +from codecontexter.language_detection import get_file_category, get_language_from_path +from codecontexter.models import FileMetadata + +# For better user experience with progress tracking +try: + import tqdm +except ImportError: + + class tqdm: # type: ignore # noqa: N801 + """Fallback tqdm class if tqdm is not installed.""" + + def __init__(self, iterable=None, **kwargs): + self.iterable = iterable or [] + + def __iter__(self): + return iter(self.iterable) + + def __enter__(self): + return self + + def __exit__(self, *args): + pass + + def update(self, n=1): + pass + + def close(self): + pass + + +def count_lines(file_path: pathlib.Path) -> int: + """Efficiently count lines in a file. + + Args: + file_path: Path to the file + + Returns: + Number of lines in the file, or 0 if error + """ + try: + with open(file_path, "rb") as f: + return sum(1 for _ in f) + except OSError: + return 0 + + +def get_file_hash(file_path: pathlib.Path, hash_files: bool = False) -> str | None: + """Get SHA-256 hash of file for verification. + + Args: + file_path: Path to the file + hash_files: Whether to compute hash (expensive operation) + + Returns: + SHA-256 hash hex string if hash_files is True, None otherwise + """ + if not hash_files: + return None + try: + hash_sha256 = hashlib.sha256() + with open(file_path, "rb") as f: + for chunk in iter(lambda: f.read(4096), b""): + hash_sha256.update(chunk) + return hash_sha256.hexdigest() + except OSError: + return None + + +def process_file( + file_path: pathlib.Path, start_path: pathlib.Path, include_hash: bool = False +) -> FileMetadata | None: + """Process a single file and extract metadata. + + Args: + file_path: Path to the file to process + start_path: Root path for calculating relative paths + include_hash: Whether to compute SHA-256 hash + + Returns: + FileMetadata object if successful, None if file cannot be processed + """ + try: + lang = get_language_from_path(file_path) + if lang is None: + return None + + stat = file_path.stat() + return FileMetadata( + path=file_path, + relative_path=file_path.relative_to(start_path), + language=lang or "text", + size=stat.st_size, + lines=count_lines(file_path), + modified=datetime.fromtimestamp(stat.st_mtime), + category=get_file_category(file_path), + file_hash=get_file_hash(file_path, include_hash), + ) + except Exception as e: + print(f"Warning: Error processing {file_path}: {e}", file=sys.stderr) + return None + + +def find_project_root(start_dir: pathlib.Path) -> pathlib.Path: + """Find the nearest parent directory containing .git or return start_dir. + + Args: + start_dir: Starting directory for search + + Returns: + Path to project root (directory containing .git) or start_dir if not found + """ + current = start_dir.resolve() + while True: + if (current / ".git").is_dir(): + return current + parent = current.parent + if parent == current: + print( + "Warning: .git directory not found. Using starting directory as project root.", + file=sys.stderr, + ) + return start_dir.resolve() + current = parent + + +def get_combined_spec(root_dir: pathlib.Path) -> pathspec.PathSpec: + """Combines ALWAYS_IGNORE_PATTERNS with patterns from .gitignore files. + + Loads .gitignore from project root and also checks .git/info/exclude. + + Args: + root_dir: Project root directory + + Returns: + PathSpec object combining hardcoded patterns and .gitignore patterns + """ + all_patterns = list(ALWAYS_IGNORE_PATTERNS) + + # Load root .gitignore + gitignore_path = root_dir / ".gitignore" + if gitignore_path.is_file(): + try: + with open(gitignore_path, encoding="utf-8", errors="ignore") as f: + all_patterns.extend(f.readlines()) + except Exception as e: + print( + f"Warning: Could not read .gitignore at {gitignore_path}: {e}", + file=sys.stderr, + ) + + # Load .git/info/exclude if it exists + git_exclude_path = root_dir / ".git" / "info" / "exclude" + if git_exclude_path.is_file(): + try: + with open(git_exclude_path, encoding="utf-8", errors="ignore") as f: + all_patterns.extend(f.readlines()) + except Exception as e: + print( + f"Warning: Could not read .git/info/exclude: {e}", + file=sys.stderr, + ) + + return pathspec.PathSpec.from_lines(pathspec.patterns.GitWildMatchPattern, all_patterns) + + +def load_nested_gitignore(directory: pathlib.Path) -> list[str]: + """Load .gitignore patterns from a specific directory. + + Args: + directory: Directory to check for .gitignore + + Returns: + List of pattern lines from the .gitignore file, or empty list if not found + """ + gitignore_path = directory / ".gitignore" + if gitignore_path.is_file(): + try: + with open(gitignore_path, encoding="utf-8", errors="ignore") as f: + return f.readlines() + except OSError: + pass + return [] + + +def collect_files( + start_path: pathlib.Path, + project_root: pathlib.Path, + combined_spec: pathspec.PathSpec, + output_path: pathlib.Path, + script_path: pathlib.Path, +) -> list[pathlib.Path]: + """Efficiently collect all files to process. + + Args: + start_path: Directory to start scanning from + project_root: Project root for calculating relative paths + combined_spec: PathSpec with ignore patterns + output_path: Output file path to exclude + script_path: Script file path to exclude + + Returns: + List of file paths that should be processed + """ + files_to_process = [] + + for root, dirs, files in os.walk(start_path, topdown=True): + root_path = pathlib.Path(root) + + # Check for nested .gitignore and merge with base patterns + nested_patterns = load_nested_gitignore(root_path) + if nested_patterns: + # Create a combined spec with nested patterns + base_patterns = list(combined_spec.patterns) + local_spec = pathspec.PathSpec.from_lines( + pathspec.patterns.GitWildMatchPattern, + [str(p) for p in base_patterns] + nested_patterns, + ) + else: + local_spec = combined_spec + + # Prune ignored directories + for d in list(dirs): + dir_path = root_path / d + try: + relative_dir = dir_path.relative_to(project_root) + # Add trailing slash to match directory patterns like "node_modules/" + relative_dir_str = str(relative_dir).replace(os.sep, "/") + "/" + if local_spec.match_file(relative_dir_str): + dirs.remove(d) + except ValueError: + pass + + for filename in files: + file_path = root_path / filename + + # Skip output and script + try: + if file_path.resolve() in ( + output_path.resolve(), + script_path.resolve(), + ): + continue + except (OSError, ValueError): + pass + + # Check against ignore patterns (including nested .gitignore) + try: + relative_file = file_path.relative_to(project_root) + relative_file_str = str(relative_file).replace(os.sep, "/") + if local_spec.match_file(relative_file_str): + continue + except ValueError: + pass + + # Check if file type is supported + if get_language_from_path(file_path) is not None: + files_to_process.append(file_path) + + return files_to_process diff --git a/src/codecontexter/language_detection.py b/src/codecontexter/language_detection.py new file mode 100644 index 0000000..f4b438f --- /dev/null +++ b/src/codecontexter/language_detection.py @@ -0,0 +1,75 @@ +"""Language and file category detection utilities.""" + +import pathlib + +from codecontexter.constants import FILE_CATEGORIES, LANGUAGE_MAP + + +def get_language_from_path(file_path: pathlib.Path) -> str | None: + """Determines the language hint from the file path based on LANGUAGE_MAP. + + Args: + file_path: Path to the file + + Returns: + Language name string if detected, None otherwise + + Examples: + >>> get_language_from_path(Path("test.py")) + 'python' + >>> get_language_from_path(Path("Dockerfile")) + 'dockerfile' + """ + name_lower = file_path.name.lower() + + # Check exact name first (case-insensitive) + if name_lower in LANGUAGE_MAP: + return LANGUAGE_MAP[name_lower] + + # Check exact name (case-sensitive) for files like Makefile + if file_path.name in LANGUAGE_MAP: + return LANGUAGE_MAP[file_path.name] + + # Check by extension + if file_path.suffix.lower() in LANGUAGE_MAP: + return LANGUAGE_MAP[file_path.suffix.lower()] + + # Special handling for files in .github/workflows + if ".github" in file_path.parts and "workflows" in file_path.parts: + if file_path.suffix in {".yml", ".yaml"}: + return "yaml" + + # Include files with no extension as plain text if they appear to be text + if not file_path.suffix: + try: + with open(file_path, encoding="utf-8") as f: + f.read(512) + return "text" + except (OSError, UnicodeDecodeError): + return None + + return None + + +def get_file_category(file_path: pathlib.Path) -> str: + """Categorize file by its purpose. + + Args: + file_path: Path to the file + + Returns: + Category name ('source', 'config', 'docker', 'iac', 'ci_cd', 'build', 'docs', or 'other') + + Examples: + >>> get_file_category(Path("main.py")) + 'source' + >>> get_file_category(Path("config.json")) + 'config' + """ + name_lower = file_path.name.lower() + suffix_lower = file_path.suffix.lower() + + for category, extensions in FILE_CATEGORIES.items(): + if name_lower in extensions or suffix_lower in extensions: + return category + return "other" diff --git a/src/codecontexter/models.py b/src/codecontexter/models.py new file mode 100644 index 0000000..3366c03 --- /dev/null +++ b/src/codecontexter/models.py @@ -0,0 +1,30 @@ +"""Data models for codecontexter.""" + +import pathlib +from dataclasses import dataclass +from datetime import datetime + + +@dataclass +class FileMetadata: + """Metadata for a single file. + + Attributes: + path: Absolute path to the file + relative_path: Path relative to the scan root + language: Detected programming language + size: File size in bytes + lines: Number of lines in the file + modified: Last modification timestamp (local time, no timezone) + category: File category (source, config, etc.) + file_hash: Optional SHA-256 hash of file contents + """ + + path: pathlib.Path + relative_path: pathlib.Path + language: str + size: int + lines: int + modified: datetime + category: str + file_hash: str | None = None diff --git a/src/codecontexter/output_generators.py b/src/codecontexter/output_generators.py new file mode 100644 index 0000000..c36c313 --- /dev/null +++ b/src/codecontexter/output_generators.py @@ -0,0 +1,249 @@ +"""Markdown output generation utilities.""" + +import pathlib +import re +import sys +from collections import defaultdict +from datetime import datetime + +from codecontexter.file_operations import ( + collect_files, + find_project_root, + get_combined_spec, + process_file, + tqdm, +) +from codecontexter.models import FileMetadata + + +def generate_gfm_anchor(heading_text: str) -> str: + """Generate a GitHub-Flavored Markdown anchor from heading text. + + Args: + heading_text: The heading text (without # prefix) + + Returns: + Anchor slug matching GFM behavior + + Examples: + >>> generate_gfm_anchor("File: src/main.py") + 'file-srcmainpy' + """ + # Remove backticks and lowercase + slug = heading_text.replace("`", "").lower() + # Replace spaces and special chars with hyphens + slug = re.sub(r"[^\w\s-]", "", slug) + slug = re.sub(r"[\s_]+", "-", slug) + # Remove leading/trailing hyphens + slug = slug.strip("-") + return slug + + +def format_size(size_bytes: int) -> str: + """Format file size in human-readable format. + + Args: + size_bytes: Size in bytes + + Returns: + Formatted string like "1.5 MB" + """ + for unit in ["B", "KB", "MB", "GB"]: + if size_bytes < 1024.0: + return f"{size_bytes:.1f} {unit}" + size_bytes /= 1024.0 + return f"{size_bytes:.1f} TB" + + +def generate_metadata_table(files_metadata: list[FileMetadata]) -> str: + """Generate a markdown table with file metadata. + + Args: + files_metadata: List of FileMetadata objects + + Returns: + Markdown-formatted table as a string + """ + table = "| File | Size | Lines | Type | Category | Last Modified |\n" + table += "|------|------|-------|------|----------|---------------|\n" + + for meta in sorted(files_metadata, key=lambda x: str(x.relative_path)): + size_str = format_size(meta.size) + modified_str = meta.modified.strftime("%Y-%m-%d %H:%M") + table += ( + f"| `{meta.relative_path}` | {size_str} | {meta.lines} | " + f"{meta.language} | {meta.category} | {modified_str} |\n" + ) + + return table + + +def generate_statistics(files_metadata: list[FileMetadata]) -> str: + """Generate statistics summary. + + Args: + files_metadata: List of FileMetadata objects + + Returns: + Markdown-formatted statistics section + """ + total_files = len(files_metadata) + total_lines = sum(m.lines for m in files_metadata) + total_size = sum(m.size for m in files_metadata) + + # Group by category + by_category = defaultdict(list) + for meta in files_metadata: + by_category[meta.category].append(meta) + + # Group by language + by_language = defaultdict(list) + for meta in files_metadata: + by_language[meta.language].append(meta) + + stats = "## 📊 Statistics\n\n" + stats += f"- **Total Files:** {total_files}\n" + stats += f"- **Total Lines of Code:** {total_lines:,}\n" + stats += f"- **Total Size:** {format_size(total_size)}\n\n" + + stats += "### By Category\n\n" + for category, metas in sorted(by_category.items(), key=lambda x: len(x[1]), reverse=True): + count = len(metas) + lines = sum(m.lines for m in metas) + stats += f"- **{category}:** {count} files, {lines:,} lines\n" + + stats += "\n### By Language\n\n" + for language, metas in sorted(by_language.items(), key=lambda x: len(x[1]), reverse=True): + count = len(metas) + lines = sum(m.lines for m in metas) + stats += f"- **{language}:** {count} files, {lines:,} lines\n" + + return stats + + +def create_markdown( + target_dir: str, + output_file: str, + verbose: bool, + include_metadata_table: bool, + include_hash: bool, +): + """Generates the enhanced Markdown file. + + Args: + target_dir: Directory to scan for code files + output_file: Path to output markdown file + verbose: Whether to print detailed progress + include_metadata_table: Whether to include metadata table + include_hash: Whether to compute SHA-256 hashes + """ + start_path = pathlib.Path(target_dir).resolve() + output_path = pathlib.Path(output_file).resolve() + + try: + script_path = pathlib.Path(__file__).resolve() + except NameError: + script_path = pathlib.Path.cwd() / "code_summarizer.py" + + if not start_path.is_dir(): + print(f"Error: Directory not found: {target_dir}", file=sys.stderr) + sys.exit(1) + + project_root = find_project_root(start_path) + combined_spec = get_combined_spec(project_root) + + print(f"📂 Scanning directory: {start_path}") + print(f"🔍 Project root: {project_root}") + if (project_root / ".gitignore").exists(): + print(f"✓ Using .gitignore from: {project_root / '.gitignore'}") + + # Collect files + print("📑 Collecting files...") + file_paths = collect_files(start_path, project_root, combined_spec, output_path, script_path) + print(f"✓ Found {len(file_paths)} files to process") + + # Process files with metadata + print("🔄 Processing files and extracting metadata...") + files_metadata: list[FileMetadata] = [] + + with tqdm(total=len(file_paths), desc="Processing", unit="file") as pbar: + for file_path in file_paths: + meta = process_file(file_path, start_path, include_hash) + if meta: + files_metadata.append(meta) + if verbose: + print( + f" ✓ {meta.relative_path} ({meta.lines} lines, {format_size(meta.size)})" + ) + pbar.update(1) + + # Write markdown + print(f"📝 Writing to {output_path}...") + try: + output_path.parent.mkdir(parents=True, exist_ok=True) + with open(output_path, "w", encoding="utf-8") as md_file: + # Header + md_file.write(f"# 📦 Code Summary: {start_path.name}\n\n") + md_file.write(f"**Generated:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n") + md_file.write(f"**Source Directory:** `{start_path}`\n\n") + md_file.write("---\n\n") + + # Statistics + md_file.write(generate_statistics(files_metadata)) + md_file.write("\n---\n\n") + + # Metadata table + if include_metadata_table: + md_file.write("## 📋 File Metadata\n\n") + md_file.write(generate_metadata_table(files_metadata)) + md_file.write("\n---\n\n") + + # Table of Contents + md_file.write("## 📑 Table of Contents\n\n") + for meta in sorted(files_metadata, key=lambda x: str(x.relative_path)): + # Generate anchor matching the actual heading format + heading_text = f"File: {meta.relative_path}" + anchor = generate_gfm_anchor(heading_text) + md_file.write(f"- [`{meta.relative_path}`](#{anchor})\n") + md_file.write("\n---\n\n") + + # File contents + md_file.write("## 📄 File Contents\n\n") + with tqdm(total=len(files_metadata), desc="Writing", unit="file") as pbar: + for meta in sorted(files_metadata, key=lambda x: str(x.relative_path)): + try: + with open(meta.path, encoding="utf-8", errors="ignore") as code_file: + content = code_file.read() + + md_file.write(f"### File: `{meta.relative_path}`\n\n") + md_file.write(f"**Language:** {meta.language} | ") + md_file.write(f"**Size:** {format_size(meta.size)} | ") + md_file.write(f"**Lines:** {meta.lines} | ") + md_file.write(f"**Category:** {meta.category}\n\n") + + if meta.file_hash: + md_file.write(f"**Hash (SHA-256):** `{meta.file_hash}`\n\n") + + # Write code fence with language hint for syntax highlighting + md_file.write(f"```{meta.language}\n") + md_file.write(content) + if not content.endswith("\n"): + md_file.write("\n") + md_file.write("```\n\n") + md_file.write("---\n\n") + except (OSError, UnicodeDecodeError) as e: + print( + f"⚠ Warning: Could not read {meta.relative_path}: {e}", + file=sys.stderr, + ) + + pbar.update(1) + + print(f"\n✅ Success! Processed {len(files_metadata)} files") + print(f"📊 Total lines: {sum(m.lines for m in files_metadata):,}") + print(f"💾 Total size: {format_size(sum(m.size for m in files_metadata))}") + print(f"📄 Output: {output_path}") + + except OSError as e: + print(f"\n❌ Error: Could not write to {output_path}: {e}", file=sys.stderr) + sys.exit(1) diff --git a/uv.lock b/uv.lock index cee197e..e069f42 100644 --- a/uv.lock +++ b/uv.lock @@ -1,35 +1,121 @@ version = 1 +revision = 3 requires-python = ">=3.12" [[package]] name = "codecontexter" version = "0.1.0" -source = { virtual = "." } +source = { editable = "." } dependencies = [ - { name = "django-widget-tweaks" }, { name = "pathspec" }, ] +[package.optional-dependencies] +dev = [ + { name = "pytest" }, + { name = "ruff" }, +] + [package.metadata] requires-dist = [ - { name = "django-widget-tweaks", specifier = ">=1.5.0" }, { name = "pathspec", specifier = ">=0.12.1" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.8.0" }, ] +provides-extras = ["dev"] [[package]] -name = "django-widget-tweaks" -version = "1.5.0" +name = "colorama" +version = "0.4.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a5/fe/26eb92fba83844e71bbec0ced7fc2e843e5990020e3cc676925204031654/django-widget-tweaks-1.5.0.tar.gz", hash = "sha256:1c2180681ebb994e922c754804c7ffebbe1245014777ac47897a81f57cc629c7", size = 14767 } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/46/6a/6cb6deb5c38b785c77c3ba66f53051eada49205979c407323eb666930915/django_widget_tweaks-1.5.0-py3-none-any.whl", hash = "sha256:a41b7b2f05bd44d673d11ebd6c09a96f1d013ee98121cb98c384fe84e33b881e", size = 8960 }, + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] [[package]] name = "pathspec" version = "0.12.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043 } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "ruff" +version = "0.14.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/75/62/50b7727004dfe361104dfbf898c45a9a2fdfad8c72c04ae62900224d6ecf/ruff-0.14.3.tar.gz", hash = "sha256:4ff876d2ab2b161b6de0aa1f5bd714e8e9b4033dc122ee006925fbacc4f62153", size = 5558687, upload-time = "2025-10-31T00:26:26.878Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191 }, + { url = "https://files.pythonhosted.org/packages/ce/8e/0c10ff1ea5d4360ab8bfca4cb2c9d979101a391f3e79d2616c9bf348cd26/ruff-0.14.3-py3-none-linux_armv6l.whl", hash = "sha256:876b21e6c824f519446715c1342b8e60f97f93264012de9d8d10314f8a79c371", size = 12535613, upload-time = "2025-10-31T00:25:44.302Z" }, + { url = "https://files.pythonhosted.org/packages/d3/c8/6724f4634c1daf52409fbf13fefda64aa9c8f81e44727a378b7b73dc590b/ruff-0.14.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b6fd8c79b457bedd2abf2702b9b472147cd860ed7855c73a5247fa55c9117654", size = 12855812, upload-time = "2025-10-31T00:25:47.793Z" }, + { url = "https://files.pythonhosted.org/packages/de/03/db1bce591d55fd5f8a08bb02517fa0b5097b2ccabd4ea1ee29aa72b67d96/ruff-0.14.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:71ff6edca490c308f083156938c0c1a66907151263c4abdcb588602c6e696a14", size = 11944026, upload-time = "2025-10-31T00:25:49.657Z" }, + { url = "https://files.pythonhosted.org/packages/0b/75/4f8dbd48e03272715d12c87dc4fcaaf21b913f0affa5f12a4e9c6f8a0582/ruff-0.14.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:786ee3ce6139772ff9272aaf43296d975c0217ee1b97538a98171bf0d21f87ed", size = 12356818, upload-time = "2025-10-31T00:25:51.949Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9b/506ec5b140c11d44a9a4f284ea7c14ebf6f8b01e6e8917734a3325bff787/ruff-0.14.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cd6291d0061811c52b8e392f946889916757610d45d004e41140d81fb6cd5ddc", size = 12336745, upload-time = "2025-10-31T00:25:54.248Z" }, + { url = "https://files.pythonhosted.org/packages/c7/e1/c560d254048c147f35e7f8131d30bc1f63a008ac61595cf3078a3e93533d/ruff-0.14.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a497ec0c3d2c88561b6d90f9c29f5ae68221ac00d471f306fa21fa4264ce5fcd", size = 13101684, upload-time = "2025-10-31T00:25:56.253Z" }, + { url = "https://files.pythonhosted.org/packages/a5/32/e310133f8af5cd11f8cc30f52522a3ebccc5ea5bff4b492f94faceaca7a8/ruff-0.14.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:e231e1be58fc568950a04fbe6887c8e4b85310e7889727e2b81db205c45059eb", size = 14535000, upload-time = "2025-10-31T00:25:58.397Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a1/7b0470a22158c6d8501eabc5e9b6043c99bede40fa1994cadf6b5c2a61c7/ruff-0.14.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:469e35872a09c0e45fecf48dd960bfbce056b5db2d5e6b50eca329b4f853ae20", size = 14156450, upload-time = "2025-10-31T00:26:00.889Z" }, + { url = "https://files.pythonhosted.org/packages/0a/96/24bfd9d1a7f532b560dcee1a87096332e461354d3882124219bcaff65c09/ruff-0.14.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d6bc90307c469cb9d28b7cfad90aaa600b10d67c6e22026869f585e1e8a2db0", size = 13568414, upload-time = "2025-10-31T00:26:03.291Z" }, + { url = "https://files.pythonhosted.org/packages/a7/e7/138b883f0dfe4ad5b76b58bf4ae675f4d2176ac2b24bdd81b4d966b28c61/ruff-0.14.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2f8a0bbcffcfd895df39c9a4ecd59bb80dca03dc43f7fb63e647ed176b741e", size = 13315293, upload-time = "2025-10-31T00:26:05.708Z" }, + { url = "https://files.pythonhosted.org/packages/33/f4/c09bb898be97b2eb18476b7c950df8815ef14cf956074177e9fbd40b7719/ruff-0.14.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:678fdd7c7d2d94851597c23ee6336d25f9930b460b55f8598e011b57c74fd8c5", size = 13539444, upload-time = "2025-10-31T00:26:08.09Z" }, + { url = "https://files.pythonhosted.org/packages/9c/aa/b30a1db25fc6128b1dd6ff0741fa4abf969ded161599d07ca7edd0739cc0/ruff-0.14.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1ec1ac071e7e37e0221d2f2dbaf90897a988c531a8592a6a5959f0603a1ecf5e", size = 12252581, upload-time = "2025-10-31T00:26:10.297Z" }, + { url = "https://files.pythonhosted.org/packages/da/13/21096308f384d796ffe3f2960b17054110a9c3828d223ca540c2b7cc670b/ruff-0.14.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:afcdc4b5335ef440d19e7df9e8ae2ad9f749352190e96d481dc501b753f0733e", size = 12307503, upload-time = "2025-10-31T00:26:12.646Z" }, + { url = "https://files.pythonhosted.org/packages/cb/cc/a350bac23f03b7dbcde3c81b154706e80c6f16b06ff1ce28ed07dc7b07b0/ruff-0.14.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:7bfc42f81862749a7136267a343990f865e71fe2f99cf8d2958f684d23ce3dfa", size = 12675457, upload-time = "2025-10-31T00:26:15.044Z" }, + { url = "https://files.pythonhosted.org/packages/cb/76/46346029fa2f2078826bc88ef7167e8c198e58fe3126636e52f77488cbba/ruff-0.14.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a65e448cfd7e9c59fae8cf37f9221585d3354febaad9a07f29158af1528e165f", size = 13403980, upload-time = "2025-10-31T00:26:17.81Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a4/35f1ef68c4e7b236d4a5204e3669efdeefaef21f0ff6a456792b3d8be438/ruff-0.14.3-py3-none-win32.whl", hash = "sha256:f3d91857d023ba93e14ed2d462ab62c3428f9bbf2b4fbac50a03ca66d31991f7", size = 12500045, upload-time = "2025-10-31T00:26:20.503Z" }, + { url = "https://files.pythonhosted.org/packages/03/15/51960ae340823c9859fb60c63301d977308735403e2134e17d1d2858c7fb/ruff-0.14.3-py3-none-win_amd64.whl", hash = "sha256:d7b7006ac0756306db212fd37116cce2bd307e1e109375e1c6c106002df0ae5f", size = 13594005, upload-time = "2025-10-31T00:26:22.533Z" }, + { url = "https://files.pythonhosted.org/packages/b7/73/4de6579bac8e979fca0a77e54dec1f1e011a0d268165eb8a9bc0982a6564/ruff-0.14.3-py3-none-win_arm64.whl", hash = "sha256:26eb477ede6d399d898791d01961e16b86f02bc2486d0d1a7a9bb2379d055dc1", size = 12590017, upload-time = "2025-10-31T00:26:24.52Z" }, ]