kopipasta 0.31.0__tar.gz → 0.32.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.

Potentially problematic release.


This version of kopipasta might be problematic. Click here for more details.

Files changed (23) hide show
  1. {kopipasta-0.31.0/kopipasta.egg-info → kopipasta-0.32.0}/PKG-INFO +1 -1
  2. kopipasta-0.32.0/kopipasta/file.py +129 -0
  3. {kopipasta-0.31.0 → kopipasta-0.32.0}/kopipasta/main.py +25 -5
  4. {kopipasta-0.31.0 → kopipasta-0.32.0}/kopipasta/tree_selector.py +58 -48
  5. {kopipasta-0.31.0 → kopipasta-0.32.0/kopipasta.egg-info}/PKG-INFO +1 -1
  6. {kopipasta-0.31.0 → kopipasta-0.32.0}/kopipasta.egg-info/SOURCES.txt +3 -1
  7. {kopipasta-0.31.0 → kopipasta-0.32.0}/setup.py +1 -1
  8. kopipasta-0.32.0/tests/test_file.py +65 -0
  9. kopipasta-0.32.0/tests/test_tree_selector.py +47 -0
  10. kopipasta-0.31.0/kopipasta/file.py +0 -47
  11. {kopipasta-0.31.0 → kopipasta-0.32.0}/LICENSE +0 -0
  12. {kopipasta-0.31.0 → kopipasta-0.32.0}/MANIFEST.in +0 -0
  13. {kopipasta-0.31.0 → kopipasta-0.32.0}/README.md +0 -0
  14. {kopipasta-0.31.0 → kopipasta-0.32.0}/kopipasta/__init__.py +0 -0
  15. {kopipasta-0.31.0 → kopipasta-0.32.0}/kopipasta/cache.py +0 -0
  16. {kopipasta-0.31.0 → kopipasta-0.32.0}/kopipasta/import_parser.py +0 -0
  17. {kopipasta-0.31.0 → kopipasta-0.32.0}/kopipasta/prompt.py +0 -0
  18. {kopipasta-0.31.0 → kopipasta-0.32.0}/kopipasta.egg-info/dependency_links.txt +0 -0
  19. {kopipasta-0.31.0 → kopipasta-0.32.0}/kopipasta.egg-info/entry_points.txt +0 -0
  20. {kopipasta-0.31.0 → kopipasta-0.32.0}/kopipasta.egg-info/requires.txt +0 -0
  21. {kopipasta-0.31.0 → kopipasta-0.32.0}/kopipasta.egg-info/top_level.txt +0 -0
  22. {kopipasta-0.31.0 → kopipasta-0.32.0}/requirements.txt +0 -0
  23. {kopipasta-0.31.0 → kopipasta-0.32.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: kopipasta
3
- Version: 0.31.0
3
+ Version: 0.32.0
4
4
  Summary: A CLI tool to generate prompts with project structure and file contents
5
5
  Home-page: https://github.com/mkorpela/kopipasta
6
6
  Author: Mikko Korpela
@@ -0,0 +1,129 @@
1
+ import fnmatch
2
+ import os
3
+ from typing import List, Optional, Tuple
4
+ from pathlib import Path
5
+
6
+ FileTuple = Tuple[str, bool, Optional[List[str]], str]
7
+
8
+ # --- Cache for .gitignore patterns ---
9
+ # Key: Directory path
10
+ # Value: List of patterns
11
+ _gitignore_cache: dict[str, list[str]] = {}
12
+
13
+ def _read_gitignore_patterns(gitignore_path: str) -> list[str]:
14
+ """Reads patterns from a single .gitignore file and caches them."""
15
+ if gitignore_path in _gitignore_cache:
16
+ return _gitignore_cache[gitignore_path]
17
+ if not os.path.isfile(gitignore_path):
18
+ _gitignore_cache[gitignore_path] = []
19
+ return []
20
+ patterns = []
21
+ try:
22
+ with open(gitignore_path, 'r', encoding='utf-8') as f:
23
+ for line in f:
24
+ stripped_line = line.strip()
25
+ if stripped_line and not stripped_line.startswith('#'):
26
+ patterns.append(stripped_line)
27
+ except IOError:
28
+ pass
29
+ _gitignore_cache[gitignore_path] = patterns
30
+ return patterns
31
+
32
+ def is_ignored(path: str, default_ignore_patterns: list[str], project_root: Optional[str] = None) -> bool:
33
+ """
34
+ Checks if a path should be ignored based on default patterns and .gitignore files.
35
+ Searches for .gitignore from the path's location up to the project_root.
36
+ """
37
+ path_abs = os.path.abspath(path)
38
+ if project_root is None:
39
+ project_root = os.getcwd()
40
+ project_root_abs = os.path.abspath(project_root)
41
+
42
+ # --- Step 1: Gather all patterns from all relevant .gitignore files ---
43
+ all_patterns = set(default_ignore_patterns)
44
+
45
+ # Determine the directory to start searching for .gitignore files
46
+ search_start_dir = path_abs if os.path.isdir(path_abs) else os.path.dirname(path_abs)
47
+
48
+ current_dir = search_start_dir
49
+ while True:
50
+ gitignore_path = os.path.join(current_dir, ".gitignore")
51
+ patterns_from_file = _read_gitignore_patterns(gitignore_path)
52
+
53
+ if patterns_from_file:
54
+ gitignore_dir_rel = os.path.relpath(current_dir, project_root_abs)
55
+ if gitignore_dir_rel == '.': gitignore_dir_rel = ''
56
+
57
+ for p in patterns_from_file:
58
+ # Patterns with a '/' are relative to the .gitignore file's location.
59
+ # We construct a new pattern relative to the project root.
60
+ if '/' in p:
61
+ all_patterns.add(os.path.join(gitignore_dir_rel, p.lstrip('/')))
62
+ else:
63
+ # Patterns without a '/' (e.g., `*.log`) can match anywhere.
64
+ all_patterns.add(p)
65
+
66
+ if not current_dir.startswith(project_root_abs) or current_dir == project_root_abs:
67
+ break
68
+ parent = os.path.dirname(current_dir)
69
+ if parent == current_dir: break
70
+ current_dir = parent
71
+
72
+ # --- Step 2: Check the path and its parents against the patterns ---
73
+ try:
74
+ path_rel_to_root = os.path.relpath(path_abs, project_root_abs)
75
+ except ValueError:
76
+ return False # Path is outside the project root
77
+
78
+ path_parts = Path(path_rel_to_root).parts
79
+
80
+ for pattern in all_patterns:
81
+ # Check against basename for simple wildcards (e.g., `*.log`, `__pycache__`)
82
+ # This is a primary matching mechanism.
83
+ if fnmatch.fnmatch(os.path.basename(path_abs), pattern):
84
+ return True
85
+
86
+ # Check the full path and its parent directories against the pattern.
87
+ # This handles directory ignores (`node_modules/`) and specific path ignores (`src/*.tmp`).
88
+ for i in range(len(path_parts)):
89
+ current_check_path = os.path.join(*path_parts[:i+1])
90
+
91
+ # Handle directory patterns like `node_modules/`
92
+ if pattern.endswith('/'):
93
+ if fnmatch.fnmatch(current_check_path, pattern.rstrip('/')):
94
+ return True
95
+ # Handle full path patterns
96
+ else:
97
+ if fnmatch.fnmatch(current_check_path, pattern):
98
+ return True
99
+
100
+ return False
101
+
102
+ def read_file_contents(file_path):
103
+ try:
104
+ with open(file_path, 'r') as file:
105
+ return file.read()
106
+ except Exception as e:
107
+ print(f"Error reading {file_path}: {e}")
108
+ return ""
109
+
110
+ def is_binary(file_path):
111
+ try:
112
+ with open(file_path, 'rb') as file:
113
+ chunk = file.read(1024)
114
+ if b'\0' in chunk:
115
+ return True
116
+ if file_path.lower().endswith(('.json', '.csv')):
117
+ return False
118
+ return False
119
+ except IOError:
120
+ return False
121
+
122
+ def get_human_readable_size(size):
123
+ for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
124
+ if size < 1024.0:
125
+ return f"{size:.2f} {unit}"
126
+ size /= 1024.0
127
+
128
+ def is_large_file(file_path, threshold=102400):
129
+ return os.path.getsize(file_path) > threshold
@@ -137,7 +137,7 @@ def read_gitignore():
137
137
  '.terraform', 'output', 'poetry.lock', 'package-lock.json', '.env',
138
138
  '*.log', '*.bak', '*.swp', '*.swo', '*.tmp', 'tmp', 'temp', 'logs',
139
139
  'build', 'target', '.DS_Store', 'Thumbs.db', '*.class', '*.jar',
140
- '*.war', '*.ear', '*.sqlite', '*.db', '.github', '.gitignore',
140
+ '*.war', '*.ear', '*.sqlite', '*.db',
141
141
  '*.jpg', '*.jpeg', '*.png', '*.gif', '*.bmp', '*.tiff',
142
142
  '*.ico', '*.svg', '*.webp', '*.mp3', '*.mp4', '*.avi',
143
143
  '*.mov', '*.wmv', '*.flv', '*.pdf', '*.doc', '*.docx',
@@ -490,7 +490,7 @@ def grep_files_in_directory(pattern: str, directory: str, ignore_patterns: List[
490
490
  grep_results = []
491
491
 
492
492
  for file in files:
493
- if is_ignored(file, ignore_patterns) or is_binary(file):
493
+ if is_ignored(file, ignore_patterns, directory) or is_binary(file):
494
494
  continue
495
495
 
496
496
  # Get match count and preview lines
@@ -1012,6 +1012,7 @@ def main():
1012
1012
 
1013
1013
  # Separate URLs from file/directory paths
1014
1014
  paths_for_tree = []
1015
+ files_to_preselect = []
1015
1016
 
1016
1017
  for input_path in args.inputs:
1017
1018
  if input_path.startswith(('http://', 'https://')):
@@ -1050,9 +1051,11 @@ def main():
1050
1051
  print(f"Added {'snippet of ' if is_snippet else ''}web content from: {input_path}")
1051
1052
  print_char_count(current_char_count)
1052
1053
  else:
1053
- # Add to paths for tree selector
1054
- if os.path.exists(input_path):
1054
+ abs_path = os.path.abspath(input_path)
1055
+ if os.path.exists(abs_path):
1055
1056
  paths_for_tree.append(input_path)
1057
+ if os.path.isfile(abs_path):
1058
+ files_to_preselect.append(abs_path)
1056
1059
  else:
1057
1060
  print(f"Warning: {input_path} does not exist. Skipping.")
1058
1061
 
@@ -1063,7 +1066,7 @@ def main():
1063
1066
 
1064
1067
  tree_selector = TreeSelector(ignore_patterns, project_root_abs)
1065
1068
  try:
1066
- selected_files, file_char_count = tree_selector.run(paths_for_tree)
1069
+ selected_files, file_char_count = tree_selector.run(paths_for_tree, files_to_preselect)
1067
1070
  files_to_include.extend(selected_files)
1068
1071
  current_char_count += file_char_count
1069
1072
  except KeyboardInterrupt:
@@ -1107,8 +1110,25 @@ def main():
1107
1110
 
1108
1111
  try:
1109
1112
  pyperclip.copy(final_prompt)
1113
+ print("\n--- Included Files & Content ---\n")
1114
+ for file_path, is_snippet, chunks, _ in sorted(files_to_include, key=lambda x: x[0]):
1115
+ details = []
1116
+ if is_snippet:
1117
+ details.append("snippet")
1118
+ if chunks is not None:
1119
+ details.append(f"{len(chunks)} patches")
1120
+
1121
+ detail_str = f" ({', '.join(details)})" if details else ""
1122
+ print(f"- {os.path.relpath(file_path)}{detail_str}")
1123
+
1124
+ for url, (file_tuple, _) in sorted(web_contents.items()):
1125
+ is_snippet = file_tuple[1]
1126
+ detail_str = " (snippet)" if is_snippet else ""
1127
+ print(f"- {url}{detail_str}")
1128
+
1110
1129
  separator = "\n" + "=" * 40 + "\n☕🍝 Kopipasta Complete! 🍝☕\n" + "=" * 40 + "\n"
1111
1130
  print(separator)
1131
+
1112
1132
  final_char_count = len(final_prompt)
1113
1133
  final_token_estimate = final_char_count // 4
1114
1134
  print(f"Prompt has been copied to clipboard. Final size: {final_char_count} characters (~ {final_token_estimate} tokens)")
@@ -14,27 +14,23 @@ from kopipasta.cache import load_selection_from_cache
14
14
 
15
15
  class FileNode:
16
16
  """Represents a file or directory in the tree"""
17
- def __init__(self, path: str, is_dir: bool, parent: Optional['FileNode'] = None):
18
- self.path = os.path.abspath(path) # Always store absolute paths
17
+ def __init__(self, path: str, is_dir: bool, parent: Optional['FileNode'] = None, is_scan_root: bool = False):
18
+ self.path = os.path.abspath(path)
19
19
  self.is_dir = is_dir
20
20
  self.parent = parent
21
21
  self.children: List['FileNode'] = []
22
22
  self.expanded = False
23
- self.selected = False
24
- self.selected_as_snippet = False
23
+ # This flag marks the invisible root of the file tree, which is not meant to be displayed.
24
+ self.is_scan_root = is_scan_root
25
25
  self.size = 0 if is_dir else os.path.getsize(self.path)
26
- self.is_root = path == "." # Mark if this is the root node
27
26
 
28
27
  @property
29
28
  def name(self):
30
- if self.is_root:
31
- return "." # Show root as "." instead of directory name
32
29
  return os.path.basename(self.path) or self.path
33
30
 
34
31
  @property
35
32
  def relative_path(self):
36
- if self.is_root:
37
- return "."
33
+ # os.path.relpath is relative to the current working directory by default
38
34
  return os.path.relpath(self.path)
39
35
 
40
36
 
@@ -54,33 +50,33 @@ class TreeSelector:
54
50
  self.viewport_offset = 0 # First visible item index
55
51
 
56
52
  def build_tree(self, paths: List[str]) -> FileNode:
57
- """Build tree structure from given paths"""
58
- # Use current directory as root
59
- root = FileNode(".", True)
60
- root.expanded = True # Always expand root
61
-
62
- # Process each input path
53
+ """Build tree structure from given paths."""
54
+ # If one directory is given, make its contents the top level of the tree.
55
+ if len(paths) == 1 and os.path.isdir(paths[0]):
56
+ root_path = os.path.abspath(paths[0])
57
+ root = FileNode(root_path, True, is_scan_root=True)
58
+ root.expanded = True
59
+ self._scan_directory(root_path, root)
60
+ return root
61
+
62
+ # Otherwise, create a virtual root to hold multiple items (e.g., `kopipasta file.py dir/`).
63
+ # This virtual root itself won't be displayed.
64
+ virtual_root_path = os.path.join(self.project_root_abs, "__kopipasta_virtual_root__")
65
+ root = FileNode(virtual_root_path, True, is_scan_root=True)
66
+ root.expanded = True
67
+
63
68
  for path in paths:
64
69
  abs_path = os.path.abspath(path)
65
-
70
+ node = None
66
71
  if os.path.isfile(abs_path):
67
- # Single file - add to root
68
- if not is_ignored(abs_path, self.ignore_patterns) and not is_binary(abs_path):
72
+ if not is_ignored(abs_path, self.ignore_patterns, self.project_root_abs) and not is_binary(abs_path):
69
73
  node = FileNode(abs_path, False, root)
70
- root.children.append(node)
71
74
  elif os.path.isdir(abs_path):
72
- # If the directory is the current directory, scan its contents directly
73
- if abs_path == os.path.abspath("."):
74
- self._scan_directory(abs_path, root)
75
- else:
76
- # Otherwise add the directory as a child
77
- dir_node = FileNode(abs_path, True, root)
78
- root.children.append(dir_node)
79
- # Auto-expand if it's the only child
80
- if len(paths) == 1:
81
- dir_node.expanded = True
82
- self._scan_directory(abs_path, dir_node)
83
-
75
+ node = FileNode(abs_path, True, root)
76
+
77
+ if node:
78
+ root.children.append(node)
79
+
84
80
  return root
85
81
 
86
82
  def _scan_directory(self, dir_path: str, parent_node: FileNode):
@@ -102,7 +98,7 @@ class TreeSelector:
102
98
 
103
99
  for item in items:
104
100
  item_path = os.path.join(abs_dir_path, item)
105
- if is_ignored(item_path, self.ignore_patterns):
101
+ if is_ignored(item_path, self.ignore_patterns, self.project_root_abs):
106
102
  continue
107
103
 
108
104
  if os.path.isdir(item_path):
@@ -131,23 +127,18 @@ class TreeSelector:
131
127
  parent_node.children.append(file_node)
132
128
 
133
129
  def _flatten_tree(self, node: FileNode, level: int = 0) -> List[Tuple[FileNode, int]]:
134
- """Flatten tree into a list of (node, level) tuples for display"""
130
+ """Flatten tree into a list of (node, level) tuples for display."""
135
131
  result = []
136
132
 
137
- # Special handling for root - show its children at top level
138
- if node.is_root:
139
- # Don't include the root node itself in the display
133
+ # If it's the special root node, don't display it. Display its children at the top level.
134
+ if node.is_scan_root:
140
135
  for child in node.children:
141
- result.extend(self._flatten_tree(child, 0)) # Start children at level 0
136
+ result.extend(self._flatten_tree(child, 0))
142
137
  else:
143
- # Include this node
144
138
  result.append((node, level))
145
-
146
139
  if node.is_dir and node.expanded:
147
- # Load children on demand if not loaded
148
140
  if not node.children:
149
141
  self._scan_directory(node.path, node)
150
-
151
142
  for child in node.children:
152
143
  result.extend(self._flatten_tree(child, level + 1))
153
144
 
@@ -156,10 +147,10 @@ class TreeSelector:
156
147
  def _build_display_tree(self) -> Tree:
157
148
  """Build Rich tree for display with viewport"""
158
149
  # Get terminal size
159
- term_width, term_height = shutil.get_terminal_size()
150
+ _, term_height = shutil.get_terminal_size()
160
151
 
161
152
  # Reserve space for header, help panel, and status
162
- available_height = term_height - 15 # Adjust based on your UI
153
+ available_height = term_height - 8
163
154
  available_height = max(5, available_height) # Minimum height
164
155
 
165
156
  # Flatten tree to get all visible nodes
@@ -338,10 +329,7 @@ q: Quit and finalize"""
338
329
  # Unselect
339
330
  is_snippet, _ = self.selected_files[abs_path]
340
331
  del self.selected_files[abs_path]
341
- if is_snippet:
342
- self.char_count -= len(get_file_snippet(node.path))
343
- else:
344
- self.char_count -= node.size
332
+ self.char_count -= len(get_file_snippet(node.path)) if is_snippet else node.size
345
333
  else:
346
334
  # Select
347
335
  if snippet_mode or (node.size > 102400 and not self._confirm_large_file(node)):
@@ -539,10 +527,32 @@ q: Quit and finalize"""
539
527
 
540
528
  self.char_count += deps_char_count
541
529
 
542
- def run(self, initial_paths: List[str]) -> Tuple[List[FileTuple], int]:
530
+ def _preselect_files(self, files_to_preselect: List[str]):
531
+ """Pre-selects a list of files passed from the command line."""
532
+ if not files_to_preselect:
533
+ return
534
+
535
+ added_count = 0
536
+ for file_path in files_to_preselect:
537
+ abs_path = os.path.abspath(file_path)
538
+ if abs_path in self.selected_files:
539
+ continue
540
+
541
+ # This check is simpler than a full tree walk and sufficient here
542
+ if os.path.isfile(abs_path) and not is_binary(abs_path):
543
+ file_size = os.path.getsize(abs_path)
544
+ self.selected_files[abs_path] = (False, None) # (is_snippet=False, chunks=None)
545
+ self.char_count += file_size
546
+ added_count += 1
547
+ self._ensure_path_visible(abs_path)
548
+
549
+ def run(self, initial_paths: List[str], files_to_preselect: Optional[List[str]] = None) -> Tuple[List[FileTuple], int]:
543
550
  """Run the interactive tree selector"""
544
551
  self.root = self.build_tree(initial_paths)
545
552
 
553
+ if files_to_preselect:
554
+ self._preselect_files(files_to_preselect)
555
+
546
556
  # Don't use Live mode, instead manually control the display
547
557
  while not self.quit_selection:
548
558
  # Clear and redraw
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: kopipasta
3
- Version: 0.31.0
3
+ Version: 0.32.0
4
4
  Summary: A CLI tool to generate prompts with project structure and file contents
5
5
  Home-page: https://github.com/mkorpela/kopipasta
6
6
  Author: Mikko Korpela
@@ -15,4 +15,6 @@ kopipasta.egg-info/SOURCES.txt
15
15
  kopipasta.egg-info/dependency_links.txt
16
16
  kopipasta.egg-info/entry_points.txt
17
17
  kopipasta.egg-info/requires.txt
18
- kopipasta.egg-info/top_level.txt
18
+ kopipasta.egg-info/top_level.txt
19
+ tests/test_file.py
20
+ tests/test_tree_selector.py
@@ -10,7 +10,7 @@ with open("requirements.txt", "r", encoding="utf-8") as f:
10
10
 
11
11
  setup(
12
12
  name="kopipasta",
13
- version="0.31.0",
13
+ version="0.32.0",
14
14
  author="Mikko Korpela",
15
15
  author_email="mikko.korpela@gmail.com",
16
16
  description="A CLI tool to generate prompts with project structure and file contents",
@@ -0,0 +1,65 @@
1
+ import pytest
2
+ from pathlib import Path
3
+ from kopipasta.file import is_ignored
4
+
5
+ @pytest.fixture
6
+ def project_root(tmp_path: Path) -> Path:
7
+ """Creates a mock project structure for testing ignore patterns."""
8
+ project_dir = tmp_path / "project"
9
+ project_dir.mkdir()
10
+
11
+ # Root .gitignore
12
+ (project_dir / ".gitignore").write_text("*.log\nnode_modules/\n")
13
+ (project_dir / "file.log").touch()
14
+ (project_dir / "main.py").touch()
15
+ (project_dir / "node_modules").mkdir()
16
+ (project_dir / "node_modules" / "some_lib").touch()
17
+
18
+ # Subdirectory with its own .gitignore
19
+ sub_dir = project_dir / "src"
20
+ sub_dir.mkdir()
21
+ (sub_dir / ".gitignore").write_text("*.tmp\n__pycache__/\n")
22
+ (sub_dir / "component.js").touch()
23
+ (sub_dir / "component.tmp").touch()
24
+ (sub_dir / "__pycache__").mkdir()
25
+ (sub_dir / "__pycache__" / "cache_file").touch()
26
+
27
+ # Nested subdirectory to test cascading
28
+ nested_dir = sub_dir / "api"
29
+ nested_dir.mkdir()
30
+ (nested_dir / "endpoint.py").touch()
31
+ (nested_dir / "endpoint.log").touch() # Should be ignored by root .gitignore
32
+ (nested_dir / "endpoint.tmp").touch() # Should be ignored by subdir .gitignore
33
+
34
+ return project_dir
35
+
36
+ def test_is_ignored_with_nested_gitignores(project_root: Path):
37
+ """
38
+ Tests that is_ignored correctly respects .gitignore files from the current
39
+ directory up to the project root.
40
+ """
41
+ # Test cases: path, expected_result
42
+ test_cases = [
43
+ # Root level ignores
44
+ ("file.log", True),
45
+ ("main.py", False),
46
+ ("node_modules/some_lib", True),
47
+ ("node_modules", True),
48
+
49
+ # Subdirectory level ignores
50
+ ("src/component.js", False),
51
+ ("src/component.tmp", True),
52
+ ("src/__pycache__/cache_file", True),
53
+ ("src/__pycache__", True),
54
+
55
+ # Nested subdirectory, checking cascading ignores
56
+ ("src/api/endpoint.py", False),
57
+ ("src/api/endpoint.log", True), # Ignored by root .gitignore
58
+ ("src/api/endpoint.tmp", True), # Ignored by src/.gitignore
59
+ ]
60
+
61
+ # The ignore patterns would be dynamically loaded by the new logic,
62
+ # so we pass an empty list and let the function handle discovery.
63
+ for rel_path, expected in test_cases:
64
+ full_path = project_root / rel_path
65
+ assert is_ignored(str(full_path), [], str(project_root)) == expected, f"Failed on path: {rel_path}"
@@ -0,0 +1,47 @@
1
+ import os
2
+ import pytest
3
+ from pathlib import Path
4
+ from kopipasta.tree_selector import TreeSelector
5
+
6
+ @pytest.fixture
7
+ def mock_project(tmp_path: Path) -> Path:
8
+ """Creates a mock project structure for testing TreeSelector."""
9
+ proj = tmp_path / "selector_project"
10
+ proj.mkdir()
11
+ (proj / "main.py").write_text("print('hello')")
12
+ (proj / "README.md").write_text("# Test Project")
13
+ sub = proj / "src"
14
+ sub.mkdir()
15
+ (sub / "component.js").write_text("console.log('test');")
16
+ # Change CWD into the mock project for the duration of the test
17
+ original_cwd = os.getcwd()
18
+ os.chdir(proj)
19
+ yield proj
20
+ os.chdir(original_cwd)
21
+
22
+ def test_preselects_files_from_command_line(mock_project: Path):
23
+ """
24
+ Tests that TreeSelector correctly pre-selects files passed to it.
25
+ """
26
+ main_py_abs = os.path.abspath("main.py")
27
+ component_js_abs = os.path.abspath("src/component.js")
28
+
29
+ files_to_preselect = [main_py_abs, component_js_abs]
30
+
31
+ # Instantiate the selector and manually run the pre-selection logic
32
+ selector = TreeSelector(ignore_patterns=[], project_root_abs=str(mock_project))
33
+
34
+ # We pass all potential paths to build_tree
35
+ selector.root = selector.build_tree(["."])
36
+ selector._preselect_files(files_to_preselect)
37
+
38
+ # Assertions
39
+ assert len(selector.selected_files) == 2
40
+ assert main_py_abs in selector.selected_files
41
+ assert component_js_abs in selector.selected_files
42
+
43
+ assert not selector.selected_files[main_py_abs][0]
44
+ assert not selector.selected_files[component_js_abs][0]
45
+
46
+ expected_char_count = os.path.getsize(main_py_abs) + os.path.getsize(component_js_abs)
47
+ assert selector.char_count == expected_char_count
@@ -1,47 +0,0 @@
1
- import fnmatch
2
- import os
3
- from typing import List, Optional, Tuple
4
-
5
-
6
- FileTuple = Tuple[str, bool, Optional[List[str]], str]
7
-
8
-
9
- def read_file_contents(file_path):
10
- try:
11
- with open(file_path, 'r') as file:
12
- return file.read()
13
- except Exception as e:
14
- print(f"Error reading {file_path}: {e}")
15
- return ""
16
-
17
-
18
- def is_ignored(path, ignore_patterns):
19
- path = os.path.normpath(path)
20
- for pattern in ignore_patterns:
21
- if fnmatch.fnmatch(os.path.basename(path), pattern) or fnmatch.fnmatch(path, pattern):
22
- return True
23
- return False
24
-
25
-
26
- def is_binary(file_path):
27
- try:
28
- with open(file_path, 'rb') as file:
29
- chunk = file.read(1024)
30
- if b'\0' in chunk: # null bytes indicate binary file
31
- return True
32
- if file_path.lower().endswith(('.json', '.csv')):
33
- return False
34
- return False
35
- except IOError:
36
- return False
37
-
38
-
39
- def get_human_readable_size(size):
40
- for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
41
- if size < 1024.0:
42
- return f"{size:.2f} {unit}"
43
- size /= 1024.0
44
-
45
-
46
- def is_large_file(file_path, threshold=102400): # 100 KB threshold
47
- return os.path.getsize(file_path) > threshold
File without changes
File without changes
File without changes
File without changes
File without changes