kopipasta 0.34.0__tar.gz → 0.35.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.34.0/kopipasta.egg-info → kopipasta-0.35.0}/PKG-INFO +1 -1
  2. {kopipasta-0.34.0 → kopipasta-0.35.0}/kopipasta/tree_selector.py +80 -30
  3. {kopipasta-0.34.0 → kopipasta-0.35.0/kopipasta.egg-info}/PKG-INFO +1 -1
  4. {kopipasta-0.34.0 → kopipasta-0.35.0}/setup.py +1 -1
  5. kopipasta-0.35.0/tests/test_tree_selector.py +112 -0
  6. kopipasta-0.34.0/tests/test_tree_selector.py +0 -47
  7. {kopipasta-0.34.0 → kopipasta-0.35.0}/LICENSE +0 -0
  8. {kopipasta-0.34.0 → kopipasta-0.35.0}/MANIFEST.in +0 -0
  9. {kopipasta-0.34.0 → kopipasta-0.35.0}/README.md +0 -0
  10. {kopipasta-0.34.0 → kopipasta-0.35.0}/kopipasta/__init__.py +0 -0
  11. {kopipasta-0.34.0 → kopipasta-0.35.0}/kopipasta/cache.py +0 -0
  12. {kopipasta-0.34.0 → kopipasta-0.35.0}/kopipasta/file.py +0 -0
  13. {kopipasta-0.34.0 → kopipasta-0.35.0}/kopipasta/import_parser.py +0 -0
  14. {kopipasta-0.34.0 → kopipasta-0.35.0}/kopipasta/main.py +0 -0
  15. {kopipasta-0.34.0 → kopipasta-0.35.0}/kopipasta/prompt.py +0 -0
  16. {kopipasta-0.34.0 → kopipasta-0.35.0}/kopipasta.egg-info/SOURCES.txt +0 -0
  17. {kopipasta-0.34.0 → kopipasta-0.35.0}/kopipasta.egg-info/dependency_links.txt +0 -0
  18. {kopipasta-0.34.0 → kopipasta-0.35.0}/kopipasta.egg-info/entry_points.txt +0 -0
  19. {kopipasta-0.34.0 → kopipasta-0.35.0}/kopipasta.egg-info/requires.txt +0 -0
  20. {kopipasta-0.34.0 → kopipasta-0.35.0}/kopipasta.egg-info/top_level.txt +0 -0
  21. {kopipasta-0.34.0 → kopipasta-0.35.0}/requirements.txt +0 -0
  22. {kopipasta-0.34.0 → kopipasta-0.35.0}/setup.cfg +0 -0
  23. {kopipasta-0.34.0 → kopipasta-0.35.0}/tests/test_file.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: kopipasta
3
- Version: 0.34.0
3
+ Version: 0.35.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
@@ -20,9 +20,12 @@ class FileNode:
20
20
  self.parent = parent
21
21
  self.children: List['FileNode'] = []
22
22
  self.expanded = False
23
- # This flag marks the invisible root of the file tree, which is not meant to be displayed.
24
23
  self.is_scan_root = is_scan_root
24
+ # Base size (for files) or initial placeholder (for dirs)
25
25
  self.size = 0 if is_dir else os.path.getsize(self.path)
26
+ # New attributes for caching the results of a deep scan
27
+ self.total_size: int = self.size
28
+ self.is_scanned: bool = not self.is_dir
26
29
 
27
30
  @property
28
31
  def name(self):
@@ -48,7 +51,41 @@ class TreeSelector:
48
51
  self.char_count = 0
49
52
  self.quit_selection = False
50
53
  self.viewport_offset = 0 # First visible item index
54
+ self._metrics_cache: Dict[str, Tuple[int, int]] = {}
55
+
56
+ def _calculate_directory_metrics(self, node: FileNode) -> Tuple[int, int]:
57
+ """Recursively calculate total and selected size for a directory."""
58
+ if not node.is_dir:
59
+ return 0, 0
51
60
 
61
+ # Use instance cache for this render cycle
62
+ if node.path in self._metrics_cache:
63
+ return self._metrics_cache[node.path]
64
+
65
+ total_size = 0
66
+ selected_size = 0
67
+
68
+ # Ensure directory is scanned
69
+ if not node.children:
70
+ self._deep_scan_directory_and_calc_size(node.path, node)
71
+
72
+ for child in node.children:
73
+ if child.is_dir:
74
+ child_total, child_selected = self._calculate_directory_metrics(child)
75
+ total_size += child_total
76
+ selected_size += child_selected
77
+ else: # It's a file
78
+ total_size += child.size
79
+ if child.path in self.selected_files:
80
+ is_snippet, _ = self.selected_files[child.path]
81
+ if is_snippet:
82
+ selected_size += len(get_file_snippet(child.path))
83
+ else:
84
+ selected_size += child.size
85
+
86
+ self._metrics_cache[node.path] = (total_size, selected_size)
87
+ return total_size, selected_size
88
+
52
89
  def build_tree(self, paths: List[str]) -> FileNode:
53
90
  """Build tree structure from given paths."""
54
91
  # If one directory is given, make its contents the top level of the tree.
@@ -56,7 +93,7 @@ class TreeSelector:
56
93
  root_path = os.path.abspath(paths[0])
57
94
  root = FileNode(root_path, True, is_scan_root=True)
58
95
  root.expanded = True
59
- self._scan_directory(root_path, root)
96
+ self._deep_scan_directory_and_calc_size(root_path, root)
60
97
  return root
61
98
 
62
99
  # Otherwise, create a virtual root to hold multiple items (e.g., `kopipasta file.py dir/`).
@@ -79,7 +116,7 @@ class TreeSelector:
79
116
 
80
117
  return root
81
118
 
82
- def _scan_directory(self, dir_path: str, parent_node: FileNode):
119
+ def _deep_scan_directory_and_calc_size(self, dir_path: str, parent_node: FileNode):
83
120
  """Recursively scan directory and build tree"""
84
121
  abs_dir_path = os.path.abspath(dir_path)
85
122
 
@@ -138,7 +175,7 @@ class TreeSelector:
138
175
  result.append((node, level))
139
176
  if node.is_dir and node.expanded:
140
177
  if not node.children:
141
- self._scan_directory(node.path, node)
178
+ self._deep_scan_directory_and_calc_size(node.path, node)
142
179
  for child in node.children:
143
180
  result.extend(self._flatten_tree(child, level + 1))
144
181
 
@@ -146,6 +183,8 @@ class TreeSelector:
146
183
 
147
184
  def _build_display_tree(self) -> Tree:
148
185
  """Build Rich tree for display with viewport"""
186
+ self._metrics_cache = {} # Clear cache for each new render
187
+
149
188
  # Get terminal size
150
189
  _, term_height = shutil.get_terminal_size()
151
190
 
@@ -177,10 +216,9 @@ class TreeSelector:
177
216
  if self.viewport_offset > 0:
178
217
  tree_title += f" ↑ ({self.viewport_offset} more)"
179
218
 
180
- tree = Tree(tree_title, guide_style="dim")
219
+ tree = Tree(tree_title)
181
220
 
182
221
  # Build tree structure - only for visible portion
183
- node_map = {}
184
222
  viewport_end = min(len(flat_tree), self.viewport_offset + available_height)
185
223
 
186
224
  # Track what level each visible item is at for proper tree structure
@@ -193,30 +231,35 @@ class TreeSelector:
193
231
  is_current = i == self.current_index
194
232
  style = "bold cyan" if is_current else ""
195
233
 
234
+ label = Text()
235
+
196
236
  if node.is_dir:
197
237
  icon = "📂" if node.expanded else "📁"
198
- size_str = f" ({len(node.children)} items)" if node.children else ""
199
- else:
238
+ total_size, selected_size = self._calculate_directory_metrics(node)
239
+ if total_size > 0:
240
+ size_str = f" ({get_human_readable_size(selected_size)} / {get_human_readable_size(total_size)})"
241
+ else:
242
+ size_str = "" # Don't show size for empty dirs
243
+
244
+ # Omit the selection circle for directories
245
+ label.append(f"{icon} {node.name}{size_str}", style=style)
246
+
247
+ else: # It's a file
200
248
  icon = "📄"
201
249
  size_str = f" ({get_human_readable_size(node.size)})"
202
250
 
203
- # Selection indicator
204
- abs_path = os.path.abspath(node.path)
205
- if abs_path in self.selected_files:
206
- is_snippet = self.selected_files[abs_path][0]
207
- if is_snippet:
208
- selection = "" # Half-selected (snippet)
251
+ # File selection indicator
252
+ abs_path = os.path.abspath(node.path)
253
+ if abs_path in self.selected_files:
254
+ is_snippet, _ = self.selected_files[abs_path]
255
+ selection = "◐" if is_snippet else "●"
256
+ style = "green " + style
209
257
  else:
210
- selection = "" # Fully selected
211
- style = "green " + style
212
- else:
213
- selection = "○"
258
+ selection = ""
214
259
 
215
- # Build label
216
- label = Text()
217
- label.append(f"{selection} ", style="dim")
218
- label.append(f"{icon} {node.name}{size_str}", style=style)
219
-
260
+ label.append(f"{selection} ", style="dim")
261
+ label.append(f"{icon} {node.name}{size_str}", style=style)
262
+
220
263
  # Add to tree at correct level
221
264
  if level == 0:
222
265
  tree_node = tree.add(label)
@@ -230,10 +273,17 @@ class TreeSelector:
230
273
  level_stacks[level] = tree_node
231
274
  else:
232
275
  # Fallback - add to root with indentation indicator
233
- indent_label = Text()
234
- indent_label.append(" " * level + f"{selection} ", style="dim")
235
- indent_label.append(f"{icon} {node.name}{size_str}", style=style)
236
- tree_node = tree.add(indent_label)
276
+ indent_text = " " * level
277
+ if not node.is_dir:
278
+ # Re-add file selection marker for indented fallback
279
+ selection_char = "○"
280
+ if node.path in self.selected_files:
281
+ selection_char = "◐" if self.selected_files[node.path][0] else "●"
282
+ indent_text += f"{selection_char} "
283
+
284
+ # Create a new label with proper indentation for this edge case
285
+ fallback_label_text = f"{indent_text}{label.plain}"
286
+ tree_node = tree.add(Text(fallback_label_text, style=style))
237
287
  level_stacks[level] = tree_node
238
288
 
239
289
  # Add scroll indicator at bottom if needed
@@ -344,7 +394,7 @@ q: Quit and finalize"""
344
394
 
345
395
  # Ensure children are loaded
346
396
  if not node.children:
347
- self._scan_directory(node.path, node)
397
+ self._deep_scan_directory_and_calc_size(node.path, node)
348
398
 
349
399
  # Collect all files recursively
350
400
  all_files = []
@@ -353,7 +403,7 @@ q: Quit and finalize"""
353
403
  if n.is_dir:
354
404
  # CRITICAL FIX: Ensure sub-directory children are loaded before recursing
355
405
  if not n.children:
356
- self._scan_directory(n.path, n)
406
+ self._deep_scan_directory_and_calc_size(n.path, n)
357
407
  for child in n.children:
358
408
  collect_files(child)
359
409
  else:
@@ -479,7 +529,7 @@ q: Quit and finalize"""
479
529
  node.expanded = True
480
530
  # Ensure children are loaded
481
531
  if not node.children:
482
- self._scan_directory(node.path, node)
532
+ self._deep_scan_directory_and_calc_size(node.path, node)
483
533
  found = True
484
534
  break
485
535
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: kopipasta
3
- Version: 0.34.0
3
+ Version: 0.35.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
@@ -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.34.0",
13
+ version="0.35.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,112 @@
1
+ import os
2
+ import pytest
3
+ from pathlib import Path
4
+ from rich.text import Text
5
+ from kopipasta.file import get_human_readable_size
6
+ from kopipasta.tree_selector import FileNode, TreeSelector
7
+
8
+ @pytest.fixture
9
+ def mock_project(tmp_path: Path) -> Path:
10
+ """Creates a mock project structure for testing TreeSelector."""
11
+ proj = tmp_path / "selector_project"
12
+ proj.mkdir()
13
+ (proj / "main.py").write_text("a" * 100) # 100 bytes
14
+ (proj / "README.md").write_text("b" * 200) # 200 bytes
15
+
16
+ sub = proj / "src"
17
+ sub.mkdir()
18
+ (sub / "component.js").write_text("c" * 1024) # 1 KB
19
+
20
+ nested_sub = sub / "utils"
21
+ nested_sub.mkdir()
22
+ (nested_sub / "helpers.py").write_text("d" * 2048) # 2 KB
23
+
24
+ # Change CWD into the mock project for the duration of the test
25
+ original_cwd = os.getcwd()
26
+ os.chdir(proj)
27
+ yield proj
28
+ os.chdir(original_cwd)
29
+
30
+ def test_preselects_files_from_command_line(mock_project: Path):
31
+ """
32
+ Tests that TreeSelector correctly pre-selects files passed to it.
33
+ """
34
+ main_py_abs = os.path.abspath("main.py")
35
+ component_js_abs = os.path.abspath("src/component.js")
36
+
37
+ files_to_preselect = [main_py_abs, component_js_abs]
38
+
39
+ # Instantiate the selector and manually run the pre-selection logic
40
+ selector = TreeSelector(ignore_patterns=[], project_root_abs=str(mock_project))
41
+
42
+ # We pass all potential paths to build_tree
43
+ selector.root = selector.build_tree(["."])
44
+ selector._preselect_files(files_to_preselect)
45
+
46
+ # Assertions
47
+ assert len(selector.selected_files) == 2
48
+ assert main_py_abs in selector.selected_files
49
+ assert component_js_abs in selector.selected_files
50
+
51
+ assert not selector.selected_files[main_py_abs][0]
52
+ assert not selector.selected_files[component_js_abs][0]
53
+
54
+ expected_char_count = os.path.getsize(main_py_abs) + os.path.getsize(component_js_abs)
55
+ assert selector.char_count == expected_char_count
56
+
57
+
58
+ def test_directory_label_shows_recursive_size_metrics(mock_project: Path):
59
+ """
60
+ Tests that directory labels correctly display the total size of selected files
61
+ and the total size of all files within that directory, recursively.
62
+ It also checks that the directory selector '○' is removed.
63
+ """
64
+ selector = TreeSelector(ignore_patterns=[], project_root_abs=str(mock_project))
65
+ selector.root = selector.build_tree(["."])
66
+ selector.root.expanded = True # Expand root to see 'src'
67
+
68
+ # Find the 'src' node and expand it
69
+ src_node = next(child for child in selector.root.children if child.name == 'src')
70
+ src_node.expanded = True
71
+
72
+ # Pre-select 'main.py' (at root) and 'helpers.py' (nested in src/utils)
73
+ main_py_abs = os.path.abspath("main.py")
74
+ helpers_py_abs = os.path.abspath("src/utils/helpers.py")
75
+ selector.selected_files = {
76
+ main_py_abs: (False, None),
77
+ helpers_py_abs: (False, None),
78
+ }
79
+
80
+ # Generate the visible nodes and their labels for testing
81
+ flat_tree = selector._flatten_tree(selector.root)
82
+ visible_nodes = [node for node, _ in flat_tree]
83
+
84
+ def get_node_label(node: FileNode) -> str:
85
+ # This is a simplified version of the label generation logic in _build_display_tree
86
+ # It helps us test the output without a full render cycle.
87
+ if node.is_dir:
88
+ total_size, selected_size = selector._calculate_directory_metrics(node)
89
+ size_str = f" ({get_human_readable_size(selected_size)} / {get_human_readable_size(total_size)})"
90
+ icon = "📂" if node.expanded else "📁"
91
+ label = Text()
92
+ label.append(f"{icon} {node.name}{size_str}")
93
+ return label.plain
94
+ return "" # We only care about directory labels for this test
95
+
96
+ # Find the nodes we want to test
97
+ root_node_proxy = visible_nodes[0] # This will be 'src' since we built tree from '.'
98
+ utils_node = next(n for n in visible_nodes if n.name == 'utils')
99
+
100
+ # Test the 'src' directory label
101
+ # Total: component.js (1024) + helpers.py (2048) = 3072 bytes
102
+ # Selected: helpers.py (2048) = 2048 bytes
103
+ src_label = get_node_label(src_node)
104
+ assert "2.00 KB / 3.00 KB" in src_label
105
+ assert not src_label.startswith("○")
106
+
107
+ # Test the 'utils' directory label
108
+ # Total: helpers.py (2048) = 2048 bytes
109
+ # Selected: helpers.py (2048) = 2048 bytes
110
+ utils_label = get_node_label(utils_node)
111
+ assert "2.00 KB / 2.00 KB" in utils_label
112
+ assert not utils_label.startswith("○")
@@ -1,47 +0,0 @@
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
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes