pilepack 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pilepack-0.1.0/LICENSE ADDED
@@ -0,0 +1,19 @@
1
+ Copyright (c) 2026 Vasili S. Pribylov
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all
11
+ copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ SOFTWARE.
@@ -0,0 +1,148 @@
1
+ Metadata-Version: 2.4
2
+ Name: pilepack
3
+ Version: 0.1.0
4
+ Summary: Pack your codebase into a single file for AI analysis
5
+ Author-email: "Vasili S. Pribylov" <dartmew@yandex.com>
6
+ License: MIT
7
+ Requires-Python: >=3.8
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Requires-Dist: pathspec>=0.10.0
11
+ Provides-Extra: test
12
+ Requires-Dist: pytest>=7.0; extra == "test"
13
+ Requires-Dist: pytest-cov; extra == "test"
14
+ Dynamic: license-file
15
+
16
+ # PilePack
17
+
18
+ [![Python Version](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/downloads/)
19
+ [![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
20
+ [![PyPI version](https://img.shields.io/pypi/v/pilepack)](https://pypi.org/project/pilepack/)
21
+ [![Tests](https://img.shields.io/badge/tests-20%20passed-brightgreen)](tests/)
22
+ [![Coverage](https://img.shields.io/badge/coverage-84%25-yellowgreen)](tests/)
23
+ [![CLI](https://img.shields.io/badge/CLI-ready-blue)]()
24
+
25
+ **Pack your codebase into a single file for AI analysis**
26
+ Combine all your project files into one text file — perfect for sending to LLMs (ChatGPT, Claude, Copilot, Deepseek, etc.).
27
+
28
+ ---
29
+
30
+ ## ✨ Features
31
+
32
+ - 📁 **Recursive scanning** – walks through all files in a directory.
33
+ - 🚫 **Respects .gitignore** – optionally disable with `--no-gitignore`.
34
+ - 🌳 **Tree structure** – displays project hierarchy.
35
+ - 📄 **Embedded content** – each file is shown with its path header.
36
+ - 🔐 **Secrets masking** – hides passwords, tokens, keys (`--mask-secrets`).
37
+ - 🖨️ **Two output formats** – plain text (`txt`) or Markdown (`md`).
38
+ - 💾 **Save to file** – use `-o output.txt`.
39
+
40
+ ---
41
+
42
+ ## 📦 Installation
43
+
44
+ ```bash
45
+ pip install pilepack
46
+ ```
47
+ From source:
48
+
49
+ ```bash
50
+ git clone https://github.com/dartmew/pilepack.git
51
+ cd pilepack
52
+ pip install -e .
53
+ ```
54
+
55
+ ## 🚀 Usage
56
+ Basic command – pass a path to your project:
57
+ ```bash
58
+ pilepack /path/to/your/project
59
+ ```
60
+ Redirect output to a file:
61
+ ```bash
62
+ pilepack . > report.txt
63
+ ```
64
+ Example output (txt)
65
+ ```text
66
+ myproject
67
+ ├── main.py
68
+ ├── utils/
69
+ │ ├── helpers.py
70
+ │ └── __init__.py
71
+ └── README.md
72
+
73
+ ================================================================================
74
+
75
+ --- FILE: main.py ---
76
+ import utils.helpers
77
+
78
+ def main():
79
+ print("Hello")
80
+
81
+ --- FILE: utils/helpers.py ---
82
+ def greet(name):
83
+ return f"Hi {name}"
84
+ ```
85
+ Markdown format
86
+ ```bash
87
+ pilepack . -f md -o report.md
88
+ ```
89
+ Produces a Markdown file with syntax highlighting.
90
+
91
+ Show only structure (no file contents)
92
+ ```bash
93
+ pilepack . --no-content
94
+ ```
95
+ Mask secrets
96
+ ```bash
97
+ pilepack . --mask-secrets
98
+ ```
99
+ Replaces values of password=, api_key=, token=, and long strings (base64/hex) with ***.
100
+
101
+ Disable .gitignore
102
+ ```bash
103
+ pilepack . --no-gitignore
104
+ ```
105
+ ## 📋 CLI Options
106
+ | Option | Description |
107
+ |--------|-------------|
108
+ | `root` | Directory to scan (default: current directory) |
109
+ | `--no-content` | Show tree structure only, skip file contents |
110
+ | `--mask-secrets` | Mask passwords, tokens, API keys |
111
+ | `-o, --output` | Write report to a file instead of stdout |
112
+ | `--no-gitignore` | Do not respect `.gitignore` (include all files) |
113
+ | `-f, --format` | Output format: `txt` (default) or `md` |
114
+
115
+ ## 🧪 Testing
116
+ Install test dependencies and run:
117
+
118
+ ```bash
119
+ pip install -e .[test]
120
+ pytest
121
+ ```
122
+ With coverage:
123
+ ```bash
124
+ pytest --cov=pilepack
125
+ ```
126
+ Current coverage: 84% (20 tests, all passing).
127
+ ```bash
128
+ Name Stmts Miss Cover
129
+ -------------------------------------------
130
+ pilepack\__init__.py 0 0 100%
131
+ pilepack\__main__.py 3 3 0%
132
+ pilepack\cli.py 51 7 86%
133
+ pilepack\collector.py 30 1 97%
134
+ pilepack\formatter.py 44 3 93%
135
+ pilepack\ignorer.py 21 3 86%
136
+ pilepack\reader.py 31 12 61%
137
+ -------------------------------------------
138
+ TOTAL 180 29 84%
139
+ ```
140
+
141
+ ## 📄 License
142
+ [MIT](LICENSE) © 2026 Vasili S. Pribylov
143
+
144
+ ## 🤝 Contributing
145
+ Issues and pull requests are welcome! For major changes, please open an issue first to discuss.
146
+
147
+ ## 🙏 Acknowledgements
148
+ Inspired by the need to easily feed code into large language models.
@@ -0,0 +1,133 @@
1
+ # PilePack
2
+
3
+ [![Python Version](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/downloads/)
4
+ [![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
5
+ [![PyPI version](https://img.shields.io/pypi/v/pilepack)](https://pypi.org/project/pilepack/)
6
+ [![Tests](https://img.shields.io/badge/tests-20%20passed-brightgreen)](tests/)
7
+ [![Coverage](https://img.shields.io/badge/coverage-84%25-yellowgreen)](tests/)
8
+ [![CLI](https://img.shields.io/badge/CLI-ready-blue)]()
9
+
10
+ **Pack your codebase into a single file for AI analysis**
11
+ Combine all your project files into one text file — perfect for sending to LLMs (ChatGPT, Claude, Copilot, Deepseek, etc.).
12
+
13
+ ---
14
+
15
+ ## ✨ Features
16
+
17
+ - 📁 **Recursive scanning** – walks through all files in a directory.
18
+ - 🚫 **Respects .gitignore** – optionally disable with `--no-gitignore`.
19
+ - 🌳 **Tree structure** – displays project hierarchy.
20
+ - 📄 **Embedded content** – each file is shown with its path header.
21
+ - 🔐 **Secrets masking** – hides passwords, tokens, keys (`--mask-secrets`).
22
+ - 🖨️ **Two output formats** – plain text (`txt`) or Markdown (`md`).
23
+ - 💾 **Save to file** – use `-o output.txt`.
24
+
25
+ ---
26
+
27
+ ## 📦 Installation
28
+
29
+ ```bash
30
+ pip install pilepack
31
+ ```
32
+ From source:
33
+
34
+ ```bash
35
+ git clone https://github.com/dartmew/pilepack.git
36
+ cd pilepack
37
+ pip install -e .
38
+ ```
39
+
40
+ ## 🚀 Usage
41
+ Basic command – pass a path to your project:
42
+ ```bash
43
+ pilepack /path/to/your/project
44
+ ```
45
+ Redirect output to a file:
46
+ ```bash
47
+ pilepack . > report.txt
48
+ ```
49
+ Example output (txt)
50
+ ```text
51
+ myproject
52
+ ├── main.py
53
+ ├── utils/
54
+ │ ├── helpers.py
55
+ │ └── __init__.py
56
+ └── README.md
57
+
58
+ ================================================================================
59
+
60
+ --- FILE: main.py ---
61
+ import utils.helpers
62
+
63
+ def main():
64
+ print("Hello")
65
+
66
+ --- FILE: utils/helpers.py ---
67
+ def greet(name):
68
+ return f"Hi {name}"
69
+ ```
70
+ Markdown format
71
+ ```bash
72
+ pilepack . -f md -o report.md
73
+ ```
74
+ Produces a Markdown file with syntax highlighting.
75
+
76
+ Show only structure (no file contents)
77
+ ```bash
78
+ pilepack . --no-content
79
+ ```
80
+ Mask secrets
81
+ ```bash
82
+ pilepack . --mask-secrets
83
+ ```
84
+ Replaces values of password=, api_key=, token=, and long strings (base64/hex) with ***.
85
+
86
+ Disable .gitignore
87
+ ```bash
88
+ pilepack . --no-gitignore
89
+ ```
90
+ ## 📋 CLI Options
91
+ | Option | Description |
92
+ |--------|-------------|
93
+ | `root` | Directory to scan (default: current directory) |
94
+ | `--no-content` | Show tree structure only, skip file contents |
95
+ | `--mask-secrets` | Mask passwords, tokens, API keys |
96
+ | `-o, --output` | Write report to a file instead of stdout |
97
+ | `--no-gitignore` | Do not respect `.gitignore` (include all files) |
98
+ | `-f, --format` | Output format: `txt` (default) or `md` |
99
+
100
+ ## 🧪 Testing
101
+ Install test dependencies and run:
102
+
103
+ ```bash
104
+ pip install -e .[test]
105
+ pytest
106
+ ```
107
+ With coverage:
108
+ ```bash
109
+ pytest --cov=pilepack
110
+ ```
111
+ Current coverage: 84% (20 tests, all passing).
112
+ ```bash
113
+ Name Stmts Miss Cover
114
+ -------------------------------------------
115
+ pilepack\__init__.py 0 0 100%
116
+ pilepack\__main__.py 3 3 0%
117
+ pilepack\cli.py 51 7 86%
118
+ pilepack\collector.py 30 1 97%
119
+ pilepack\formatter.py 44 3 93%
120
+ pilepack\ignorer.py 21 3 86%
121
+ pilepack\reader.py 31 12 61%
122
+ -------------------------------------------
123
+ TOTAL 180 29 84%
124
+ ```
125
+
126
+ ## 📄 License
127
+ [MIT](LICENSE) © 2026 Vasili S. Pribylov
128
+
129
+ ## 🤝 Contributing
130
+ Issues and pull requests are welcome! For major changes, please open an issue first to discuss.
131
+
132
+ ## 🙏 Acknowledgements
133
+ Inspired by the need to easily feed code into large language models.
File without changes
@@ -0,0 +1,5 @@
1
+ from .cli import main
2
+
3
+
4
+ if __name__ == '__main__':
5
+ main()
@@ -0,0 +1,103 @@
1
+ import argparse
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ from .collector import collect_files, build_tree
6
+ from .reader import read_file
7
+ from .formatter import render
8
+
9
+
10
+ def _generate_report(root_path: Path, include_content=True, mask_secrets=False, follow_gitignore=True, fmt="txt"):
11
+ print("Scanning files...", file=sys.stderr, flush=True)
12
+ files = collect_files(root_path, follow_gitignore=follow_gitignore)
13
+ tree = build_tree(files)
14
+
15
+ files_content = []
16
+ if include_content:
17
+ total = len(files)
18
+ print(f"Reading {total} files...", file=sys.stderr, flush=True)
19
+ for rel_path in files:
20
+ abs_path = root_path / rel_path
21
+ content = read_file(abs_path, mask_secrets=mask_secrets)
22
+ files_content.append((rel_path, content))
23
+ print(f"Done. Read {total} files.", file=sys.stderr, flush=True)
24
+ else:
25
+ files_content = []
26
+
27
+ root_name = root_path.name or str(root_path)
28
+ report = render(root_name, tree, files_content, fmt=fmt)
29
+ return report
30
+
31
+
32
+ def main():
33
+ parser = argparse.ArgumentParser(
34
+ description="Pack your codebase into a single file for AI analysis"
35
+ )
36
+ parser.add_argument(
37
+ "root",
38
+ nargs="?",
39
+ default=".",
40
+ help="Root directory to scan (default: current directory)",
41
+ )
42
+ parser.add_argument(
43
+ "--no-content",
44
+ dest="include_content",
45
+ action="store_false",
46
+ help="Do not include file contents (only show tree structure)"
47
+ )
48
+ parser.add_argument(
49
+ "--mask-secrets",
50
+ action="store_true",
51
+ help="Mask sensitive information like passwords, tokens, etc."
52
+ )
53
+ parser.add_argument(
54
+ "-o", "--output",
55
+ type=Path,
56
+ help="Write output to a file instead of stdout"
57
+ )
58
+ parser.add_argument(
59
+ "--no-gitignore",
60
+ dest="follow_gitignore",
61
+ action="store_false",
62
+ help="Do NOT respect .gitignore rules (include all files)"
63
+ )
64
+ parser.add_argument(
65
+ "-f", "--format",
66
+ choices=["txt", "md"],
67
+ default="txt",
68
+ help="Output format: txt (default) or md",
69
+ metavar='FMT'
70
+ )
71
+
72
+ args = parser.parse_args()
73
+
74
+ root_path = Path(args.root).resolve()
75
+ if not root_path.is_dir():
76
+ print(f"Error: '{root_path}' is not a valid directory.", file=sys.stderr)
77
+ sys.exit(1)
78
+
79
+ try:
80
+ report = _generate_report(
81
+ root_path,
82
+ include_content=args.include_content,
83
+ mask_secrets=args.mask_secrets,
84
+ follow_gitignore=args.follow_gitignore,
85
+ fmt=args.format,
86
+ )
87
+ except Exception as e:
88
+ print(f"Error generating report: {e}", file=sys.stderr)
89
+ sys.exit(1)
90
+
91
+ if args.output:
92
+ try:
93
+ args.output.write_text(report, encoding='utf-8')
94
+ print(f"Report written to {args.output}")
95
+ except IOError as e:
96
+ print(f"Error writing to file: {e}", file=sys.stderr)
97
+ sys.exit(1)
98
+ else:
99
+ print(report)
100
+
101
+
102
+ if __name__ == "__main__":
103
+ main()
@@ -0,0 +1,37 @@
1
+ from .ignorer import load_gitignore, is_ignored
2
+ from pathlib import Path
3
+ from typing import Dict, List
4
+
5
+
6
+ def collect_files(root_path: Path, follow_gitignore: bool = True) -> list[Path]:
7
+ if not root_path.is_dir():
8
+ raise NotADirectoryError(f"{root_path} does not exist or is not a directory")
9
+
10
+ if follow_gitignore:
11
+ spec = load_gitignore(root_path)
12
+ else:
13
+ spec = None
14
+ collected = []
15
+
16
+ for item in root_path.rglob('*'):
17
+ if item.name == '.gitignore':
18
+ continue
19
+ if '.git' in item.parts:
20
+ continue
21
+ if spec and is_ignored(item, root_path, spec):
22
+ continue
23
+ if item.is_file():
24
+ rel = item.relative_to(root_path)
25
+ collected.append(rel)
26
+ return collected
27
+
28
+
29
+ def build_tree(files: List[Path]) -> Dict:
30
+ tree = {}
31
+ for path in files:
32
+ parts = path.parts
33
+ current = tree
34
+ for part in parts[:-1]:
35
+ current = current.setdefault(part, {})
36
+ current[parts[-1]] = None
37
+ return tree
@@ -0,0 +1,68 @@
1
+ from typing import Dict, List, Optional, Tuple
2
+ from pathlib import Path
3
+
4
+
5
+ def _format_tree(tree: Dict, prefix: str = '', is_last: bool = True) -> str:
6
+ lines = []
7
+ items = sorted(tree.items(), key=lambda x: (isinstance(x[1], dict), x[0].lower()), reverse=True)
8
+
9
+ for i, (name, subtree) in enumerate(items):
10
+ is_last_item = (i == len(items) - 1)
11
+ connector = '└── ' if is_last_item else '├── '
12
+ display_name = name + '/' if isinstance(subtree, dict) else name
13
+ lines.append(prefix + connector + display_name)
14
+
15
+ if isinstance(subtree, dict):
16
+ new_prefix = prefix + (' ' if is_last_item else '│ ')
17
+ lines.append(_format_tree(subtree, new_prefix, is_last_item))
18
+ return '\n'.join(lines)
19
+
20
+
21
+ def _render_txt(root_name: str, tree: Dict, files_content: List[Tuple[Path, Optional[str]]]) -> str:
22
+ tree_str = _format_tree(tree)
23
+ output = [f"{root_name}\n{tree_str}"]
24
+
25
+ if files_content:
26
+ output.append("\n" + "=" * 80 + "\n")
27
+
28
+ for rel_path, content in files_content:
29
+
30
+ if content is not None:
31
+ output.append(f"--- FILE: {rel_path} ---")
32
+ output.append(content)
33
+ output.append("")
34
+ else:
35
+ output.append(f"--- FILE: {rel_path} [BINARY OR UNREADABLE] ---\n")
36
+ return "\n".join(output)
37
+
38
+
39
+ def _render_md(root_name: str, tree: Dict, files_content: List[Tuple[Path, Optional[str]]]) -> str:
40
+ tree_str = _format_tree(tree)
41
+ output = [f"# {root_name}\n```\n{tree_str}\n```"]
42
+
43
+ if files_content:
44
+ output.append("\n---\n")
45
+
46
+ for rel_path, content in files_content:
47
+
48
+ if content is not None:
49
+ ext = rel_path.suffix.lower()
50
+ lang = {
51
+ '.py': 'python', '.js': 'javascript', '.ts': 'typescript',
52
+ '.html': 'html', '.css': 'css', '.json': 'json',
53
+ '.md': 'markdown', '.yaml': 'yaml', '.yml': 'yaml',
54
+ '.sh': 'bash', '.txt': 'text'
55
+ }.get(ext, 'text')
56
+ output.append(f"## `{rel_path}`\n```{lang}\n{content}\n```\n")
57
+ else:
58
+ output.append(f"## `{rel_path}`\n*[BINARY OR UNREADABLE]*\n")
59
+ return '\n'.join(output)
60
+
61
+
62
+ def render(root_name: str, tree: Dict, files_content: List[Tuple[Path, Optional[str]]], fmt: str = "txt") -> str:
63
+ if fmt == "txt":
64
+ return _render_txt(root_name, tree, files_content)
65
+ elif fmt == "md":
66
+ return _render_md(root_name, tree, files_content)
67
+ else:
68
+ raise ValueError(f"Unsupported format: {fmt}. Supported: 'txt', 'md'")
@@ -0,0 +1,28 @@
1
+ from pathlib import Path
2
+ from pathspec import PathSpec
3
+
4
+
5
+ def load_gitignore(root_path: Path) -> PathSpec:
6
+ gitignore_path = root_path / '.gitignore'
7
+ if not gitignore_path.exists():
8
+ return PathSpec.from_lines('gitignore', [])
9
+ with open(gitignore_path, 'r', encoding='utf-8') as file:
10
+ return PathSpec.from_lines('gitignore', file)
11
+
12
+
13
+ def is_ignored(path, root: Path, spec: PathSpec) -> bool:
14
+ original = str(path)
15
+ path_obj = Path(original)
16
+
17
+ if not path_obj.is_absolute():
18
+ path_obj = root / path_obj
19
+
20
+ try:
21
+ rel_path = path_obj.relative_to(root)
22
+ except ValueError:
23
+ return False
24
+ rel_str = rel_path.as_posix()
25
+
26
+ if original.endswith(('/', '\\')) or (path_obj.exists() and path_obj.is_dir()):
27
+ rel_str += '/'
28
+ return spec.match_file(rel_str)
@@ -0,0 +1,46 @@
1
+ import re
2
+ from pathlib import Path
3
+ from typing import Optional
4
+
5
+ SECRET_PATTERNS = [
6
+ (r'(password|passwd|pwd)\s*[=:]\s*["\']?([^"\'\s]+)["\']?', r'\1="***"'),
7
+ (r'(api_key|apikey)\s*[=:]\s*["\']?([^"\'\s]+)["\']?', r'\1="***"'),
8
+ (r'(token|access_token)\s*[=:]\s*["\']?([^"\'\s]+)["\']?', r'\1="***"'),
9
+ (r'(secret|private_key)\s*[=:]\s*["\']?([^"\'\s]+)["\']?', r'\1="***"'),
10
+ (r'(?:\\b[A-Za-z0-9+/]{40,}\\b)', '***'),
11
+ (r'(?:\\b[0-9a-f]{32,}\\b)', '***')
12
+ ]
13
+
14
+ def _mask_secrets_in_text(text: str) -> str:
15
+ for pattern, replacement in SECRET_PATTERNS:
16
+ text = re.sub(pattern, replacement, text, flags=re.IGNORECASE)
17
+ return text
18
+
19
+ def read_file(file_path: Path, mask_secrets: bool = False) -> Optional[str]:
20
+ try:
21
+ raw_data = file_path.read_bytes()
22
+ except (OSError, IOError) as e:
23
+ print(f"Failed to read {file_path}: {e}")
24
+ return None
25
+
26
+ if b'\x00' in raw_data:
27
+ return None
28
+ try:
29
+ text = raw_data.decode('utf-8-sig')
30
+ except UnicodeDecodeError:
31
+ for enc in ('utf-8', 'cp1251', 'latin1'):
32
+ try:
33
+ text = raw_data.decode(enc)
34
+ break
35
+ except UnicodeDecodeError:
36
+ continue
37
+ else:
38
+ return None
39
+
40
+ if text.startswith('\ufeff'):
41
+ text = text[1:]
42
+
43
+ if mask_secrets:
44
+ text = _mask_secrets_in_text(text)
45
+
46
+ return text
@@ -0,0 +1,148 @@
1
+ Metadata-Version: 2.4
2
+ Name: pilepack
3
+ Version: 0.1.0
4
+ Summary: Pack your codebase into a single file for AI analysis
5
+ Author-email: "Vasili S. Pribylov" <dartmew@yandex.com>
6
+ License: MIT
7
+ Requires-Python: >=3.8
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Requires-Dist: pathspec>=0.10.0
11
+ Provides-Extra: test
12
+ Requires-Dist: pytest>=7.0; extra == "test"
13
+ Requires-Dist: pytest-cov; extra == "test"
14
+ Dynamic: license-file
15
+
16
+ # PilePack
17
+
18
+ [![Python Version](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/downloads/)
19
+ [![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
20
+ [![PyPI version](https://img.shields.io/pypi/v/pilepack)](https://pypi.org/project/pilepack/)
21
+ [![Tests](https://img.shields.io/badge/tests-20%20passed-brightgreen)](tests/)
22
+ [![Coverage](https://img.shields.io/badge/coverage-84%25-yellowgreen)](tests/)
23
+ [![CLI](https://img.shields.io/badge/CLI-ready-blue)]()
24
+
25
+ **Pack your codebase into a single file for AI analysis**
26
+ Combine all your project files into one text file — perfect for sending to LLMs (ChatGPT, Claude, Copilot, Deepseek, etc.).
27
+
28
+ ---
29
+
30
+ ## ✨ Features
31
+
32
+ - 📁 **Recursive scanning** – walks through all files in a directory.
33
+ - 🚫 **Respects .gitignore** – optionally disable with `--no-gitignore`.
34
+ - 🌳 **Tree structure** – displays project hierarchy.
35
+ - 📄 **Embedded content** – each file is shown with its path header.
36
+ - 🔐 **Secrets masking** – hides passwords, tokens, keys (`--mask-secrets`).
37
+ - 🖨️ **Two output formats** – plain text (`txt`) or Markdown (`md`).
38
+ - 💾 **Save to file** – use `-o output.txt`.
39
+
40
+ ---
41
+
42
+ ## 📦 Installation
43
+
44
+ ```bash
45
+ pip install pilepack
46
+ ```
47
+ From source:
48
+
49
+ ```bash
50
+ git clone https://github.com/dartmew/pilepack.git
51
+ cd pilepack
52
+ pip install -e .
53
+ ```
54
+
55
+ ## 🚀 Usage
56
+ Basic command – pass a path to your project:
57
+ ```bash
58
+ pilepack /path/to/your/project
59
+ ```
60
+ Redirect output to a file:
61
+ ```bash
62
+ pilepack . > report.txt
63
+ ```
64
+ Example output (txt)
65
+ ```text
66
+ myproject
67
+ ├── main.py
68
+ ├── utils/
69
+ │ ├── helpers.py
70
+ │ └── __init__.py
71
+ └── README.md
72
+
73
+ ================================================================================
74
+
75
+ --- FILE: main.py ---
76
+ import utils.helpers
77
+
78
+ def main():
79
+ print("Hello")
80
+
81
+ --- FILE: utils/helpers.py ---
82
+ def greet(name):
83
+ return f"Hi {name}"
84
+ ```
85
+ Markdown format
86
+ ```bash
87
+ pilepack . -f md -o report.md
88
+ ```
89
+ Produces a Markdown file with syntax highlighting.
90
+
91
+ Show only structure (no file contents)
92
+ ```bash
93
+ pilepack . --no-content
94
+ ```
95
+ Mask secrets
96
+ ```bash
97
+ pilepack . --mask-secrets
98
+ ```
99
+ Replaces values of password=, api_key=, token=, and long strings (base64/hex) with ***.
100
+
101
+ Disable .gitignore
102
+ ```bash
103
+ pilepack . --no-gitignore
104
+ ```
105
+ ## 📋 CLI Options
106
+ | Option | Description |
107
+ |--------|-------------|
108
+ | `root` | Directory to scan (default: current directory) |
109
+ | `--no-content` | Show tree structure only, skip file contents |
110
+ | `--mask-secrets` | Mask passwords, tokens, API keys |
111
+ | `-o, --output` | Write report to a file instead of stdout |
112
+ | `--no-gitignore` | Do not respect `.gitignore` (include all files) |
113
+ | `-f, --format` | Output format: `txt` (default) or `md` |
114
+
115
+ ## 🧪 Testing
116
+ Install test dependencies and run:
117
+
118
+ ```bash
119
+ pip install -e .[test]
120
+ pytest
121
+ ```
122
+ With coverage:
123
+ ```bash
124
+ pytest --cov=pilepack
125
+ ```
126
+ Current coverage: 84% (20 tests, all passing).
127
+ ```bash
128
+ Name Stmts Miss Cover
129
+ -------------------------------------------
130
+ pilepack\__init__.py 0 0 100%
131
+ pilepack\__main__.py 3 3 0%
132
+ pilepack\cli.py 51 7 86%
133
+ pilepack\collector.py 30 1 97%
134
+ pilepack\formatter.py 44 3 93%
135
+ pilepack\ignorer.py 21 3 86%
136
+ pilepack\reader.py 31 12 61%
137
+ -------------------------------------------
138
+ TOTAL 180 29 84%
139
+ ```
140
+
141
+ ## 📄 License
142
+ [MIT](LICENSE) © 2026 Vasili S. Pribylov
143
+
144
+ ## 🤝 Contributing
145
+ Issues and pull requests are welcome! For major changes, please open an issue first to discuss.
146
+
147
+ ## 🙏 Acknowledgements
148
+ Inspired by the need to easily feed code into large language models.
@@ -0,0 +1,21 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ pilepack/__init__.py
5
+ pilepack/__main__.py
6
+ pilepack/cli.py
7
+ pilepack/collector.py
8
+ pilepack/formatter.py
9
+ pilepack/ignorer.py
10
+ pilepack/reader.py
11
+ pilepack.egg-info/PKG-INFO
12
+ pilepack.egg-info/SOURCES.txt
13
+ pilepack.egg-info/dependency_links.txt
14
+ pilepack.egg-info/entry_points.txt
15
+ pilepack.egg-info/requires.txt
16
+ pilepack.egg-info/top_level.txt
17
+ tests/test_cli.py
18
+ tests/test_collector.py
19
+ tests/test_formatter.py
20
+ tests/test_ignorer.py
21
+ tests/test_reader.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pilepack = pilepack.cli:main
@@ -0,0 +1,5 @@
1
+ pathspec>=0.10.0
2
+
3
+ [test]
4
+ pytest>=7.0
5
+ pytest-cov
@@ -0,0 +1 @@
1
+ pilepack
@@ -0,0 +1,29 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "pilepack"
7
+ version = "0.1.0"
8
+ description = "Pack your codebase into a single file for AI analysis"
9
+ authors = [{ name = "Vasili S. Pribylov", email = "dartmew@yandex.com" }]
10
+ license = { text = "MIT" }
11
+ readme = "README.md"
12
+ requires-python = ">=3.8"
13
+ dependencies = [
14
+ "pathspec>=0.10.0",
15
+ ]
16
+
17
+ [project.scripts]
18
+ pilepack = "pilepack.cli:main"
19
+
20
+ [tool.setuptools.packages.find]
21
+ where = ["."]
22
+ include = ["pilepack*"]
23
+
24
+ [project.optional-dependencies]
25
+ test = ["pytest>=7.0", "pytest-cov"]
26
+
27
+ [tool.pytest.ini_options]
28
+ testpaths = ["tests"]
29
+ python_files = "test_*.py"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,33 @@
1
+ import pytest
2
+ from pathlib import Path
3
+ from pilepack.cli import main, _generate_report
4
+ import sys
5
+
6
+ def test_generate_report_no_content(test_project):
7
+ report = _generate_report(test_project, include_content=False, fmt="txt")
8
+ assert "--- FILE:" not in report
9
+ assert "test_project" in report
10
+ assert "main.py" in report
11
+
12
+ def test_cli_basic(test_project, capsys, monkeypatch):
13
+ monkeypatch.setattr(sys, "argv", ["pilepack", str(test_project), "--no-content"])
14
+ main()
15
+ captured = capsys.readouterr()
16
+ assert "test_project" in captured.out
17
+ assert "main.py" in captured.out
18
+ assert "--- FILE:" not in captured.out
19
+
20
+ def test_cli_output_file(test_project, tmp_path, monkeypatch):
21
+ out_file = tmp_path / "report.txt"
22
+ monkeypatch.setattr(sys, "argv", ["pilepack", str(test_project), "-o", str(out_file)])
23
+ main()
24
+ assert out_file.exists()
25
+ content = out_file.read_text()
26
+ assert "main.py" in content
27
+
28
+ def test_cli_invalid_dir(capsys, monkeypatch):
29
+ monkeypatch.setattr(sys, "argv", ["pilepack", "/nonexistent"])
30
+ with pytest.raises(SystemExit):
31
+ main()
32
+ captured = capsys.readouterr()
33
+ assert "not a valid directory" in captured.err
@@ -0,0 +1,32 @@
1
+ import pytest
2
+ from pilepack.collector import collect_files, build_tree
3
+ from pathlib import Path
4
+
5
+ def test_collect_files_respect_gitignore(test_project):
6
+ (test_project / ".gitignore").write_text("utils/\n")
7
+ files = collect_files(test_project, follow_gitignore=True)
8
+ rel_paths = [str(p) for p in files]
9
+ assert "utils/helpers.py" not in rel_paths
10
+ assert "main.py" in rel_paths
11
+
12
+ def test_collect_files_ignore_gitignore(test_project):
13
+ (test_project / ".gitignore").write_text("main.py")
14
+ files = collect_files(test_project, follow_gitignore=False)
15
+ rel_paths = [str(p) for p in files]
16
+ assert "main.py" in rel_paths
17
+
18
+ def test_collect_files_excludes_git_and_gitignore(test_project):
19
+ (test_project / ".git").mkdir()
20
+ (test_project / ".git" / "config").touch()
21
+ (test_project / ".gitignore").touch()
22
+ files = collect_files(test_project, follow_gitignore=False)
23
+ rel_paths = [str(p) for p in files]
24
+ assert ".git" not in rel_paths
25
+ assert ".gitignore" not in rel_paths
26
+ assert "main.py" in rel_paths
27
+
28
+ def test_build_tree():
29
+ files = [Path("a/b/c.py"), Path("a/d.py"), Path("e.py")]
30
+ tree = build_tree(files)
31
+ expected = {"a": {"b": {"c.py": None}, "d.py": None}, "e.py": None}
32
+ assert tree == expected
@@ -0,0 +1,31 @@
1
+ import pytest
2
+ from pathlib import Path
3
+ from pilepack.formatter import render, _format_tree
4
+
5
+ def test_format_tree_simple():
6
+ tree = {"a.py": None, "b": {"c.py": None}}
7
+ result = _format_tree(tree)
8
+ assert "b/" in result
9
+ assert "a.py" in result
10
+
11
+ def test_render_txt(test_project, tmp_path):
12
+ from pilepack.collector import collect_files, build_tree
13
+ files = collect_files(test_project, follow_gitignore=False)
14
+ tree = build_tree(files)
15
+ content_list = []
16
+ for rel in files:
17
+ if rel.name == "main.py":
18
+ content = (test_project / rel).read_text()
19
+ content_list.append((rel, content))
20
+ output = render("test_project", tree, content_list, fmt="txt")
21
+ assert "--- FILE: main.py ---" in output
22
+ assert "def main():" in output
23
+
24
+ def test_render_md(test_project):
25
+ from pilepack.collector import collect_files, build_tree
26
+ files = collect_files(test_project, follow_gitignore=False)
27
+ tree = build_tree(files[:2])
28
+ content_list = [(files[0], (test_project / files[0]).read_text())]
29
+ output = render("test_project", tree, content_list, fmt="md")
30
+ assert "## `main.py`" in output
31
+ assert "```python" in output
@@ -0,0 +1,30 @@
1
+ import pytest
2
+ from pathlib import Path
3
+ from pilepack.ignorer import load_gitignore, is_ignored
4
+
5
+ def test_load_gitignore_missing(tmp_path):
6
+ spec = load_gitignore(tmp_path)
7
+ assert not spec.match_file("any.py")
8
+
9
+ def test_load_gitignore_existing(test_project):
10
+ gitignore = test_project / ".gitignore"
11
+ gitignore.write_text("*.log\ntemp/\n")
12
+ spec = load_gitignore(test_project)
13
+ assert spec.match_file("debug.log")
14
+ assert spec.match_file("temp/file.txt")
15
+ assert not spec.match_file("main.py")
16
+
17
+ def test_is_ignored_file(test_project):
18
+ gitignore = test_project / ".gitignore"
19
+ gitignore.write_text("*.log")
20
+ spec = load_gitignore(test_project)
21
+ log_file = test_project / "debug.log"
22
+ assert is_ignored(log_file, test_project, spec) is True
23
+ assert is_ignored(test_project / "main.py", test_project, spec) is False
24
+
25
+ def test_is_ignored_directory(test_project):
26
+ gitignore = test_project / ".gitignore"
27
+ gitignore.write_text("temp/")
28
+ spec = load_gitignore(test_project)
29
+ temp_dir = test_project / "temp"
30
+ assert is_ignored(temp_dir, test_project, spec) is True
@@ -0,0 +1,35 @@
1
+ import pytest
2
+ from pathlib import Path
3
+ from pilepack.reader import read_file, _mask_secrets_in_text
4
+
5
+ def test_read_text_utf8(tmp_path):
6
+ f = tmp_path / "test.txt"
7
+ f.write_text("hello world", encoding="utf-8")
8
+ content = read_file(f)
9
+ assert content == "hello world"
10
+
11
+ def test_read_binary(tmp_path):
12
+ f = tmp_path / "binary.bin"
13
+ f.write_bytes(b'\x00\x01\x02\x03')
14
+ assert read_file(f) is None
15
+
16
+ def test_read_with_bom(tmp_path):
17
+ f = tmp_path / "bom.txt"
18
+ f.write_bytes(b'\xef\xbb\xbfhello')
19
+ content = read_file(f)
20
+ assert content == "hello"
21
+ assert not content.startswith('\ufeff')
22
+
23
+ def test_mask_secrets():
24
+ text = "password=12345, API_KEY=abc123"
25
+ masked = _mask_secrets_in_text(text)
26
+ assert "password=\"***\"" in masked
27
+ assert "API_KEY=\"***\"" in masked
28
+ assert "12345" not in masked
29
+
30
+ def test_read_file_with_masking(tmp_path):
31
+ f = tmp_path / "secret.env"
32
+ f.write_text("TOKEN=super-secret")
33
+ content = read_file(f, mask_secrets=True)
34
+ assert "super-secret" not in content
35
+ assert "TOKEN=\"***\"" in content