kopipasta 0.31.0__py3-none-any.whl → 0.33.0__py3-none-any.whl

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.

kopipasta/file.py CHANGED
@@ -1,10 +1,103 @@
1
1
  import fnmatch
2
2
  import os
3
3
  from typing import List, Optional, Tuple
4
-
4
+ from pathlib import Path
5
5
 
6
6
  FileTuple = Tuple[str, bool, Optional[List[str]], str]
7
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
8
101
 
9
102
  def read_file_contents(file_path):
10
103
  try:
@@ -14,20 +107,11 @@ def read_file_contents(file_path):
14
107
  print(f"Error reading {file_path}: {e}")
15
108
  return ""
16
109
 
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
110
  def is_binary(file_path):
27
111
  try:
28
112
  with open(file_path, 'rb') as file:
29
113
  chunk = file.read(1024)
30
- if b'\0' in chunk: # null bytes indicate binary file
114
+ if b'\0' in chunk:
31
115
  return True
32
116
  if file_path.lower().endswith(('.json', '.csv')):
33
117
  return False
@@ -35,13 +119,11 @@ def is_binary(file_path):
35
119
  except IOError:
36
120
  return False
37
121
 
38
-
39
122
  def get_human_readable_size(size):
40
123
  for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
41
124
  if size < 1024.0:
42
125
  return f"{size:.2f} {unit}"
43
126
  size /= 1024.0
44
127
 
45
-
46
- def is_large_file(file_path, threshold=102400): # 100 KB threshold
128
+ def is_large_file(file_path, threshold=102400):
47
129
  return os.path.getsize(file_path) > threshold
kopipasta/main.py CHANGED
@@ -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,11 @@ 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
+ reserved_space = 17
154
+ available_height = term_height - reserved_space
163
155
  available_height = max(5, available_height) # Minimum height
164
156
 
165
157
  # Flatten tree to get all visible nodes
@@ -338,10 +330,7 @@ q: Quit and finalize"""
338
330
  # Unselect
339
331
  is_snippet, _ = self.selected_files[abs_path]
340
332
  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
333
+ self.char_count -= len(get_file_snippet(node.path)) if is_snippet else node.size
345
334
  else:
346
335
  # Select
347
336
  if snippet_mode or (node.size > 102400 and not self._confirm_large_file(node)):
@@ -539,10 +528,32 @@ q: Quit and finalize"""
539
528
 
540
529
  self.char_count += deps_char_count
541
530
 
542
- def run(self, initial_paths: List[str]) -> Tuple[List[FileTuple], int]:
531
+ def _preselect_files(self, files_to_preselect: List[str]):
532
+ """Pre-selects a list of files passed from the command line."""
533
+ if not files_to_preselect:
534
+ return
535
+
536
+ added_count = 0
537
+ for file_path in files_to_preselect:
538
+ abs_path = os.path.abspath(file_path)
539
+ if abs_path in self.selected_files:
540
+ continue
541
+
542
+ # This check is simpler than a full tree walk and sufficient here
543
+ if os.path.isfile(abs_path) and not is_binary(abs_path):
544
+ file_size = os.path.getsize(abs_path)
545
+ self.selected_files[abs_path] = (False, None) # (is_snippet=False, chunks=None)
546
+ self.char_count += file_size
547
+ added_count += 1
548
+ self._ensure_path_visible(abs_path)
549
+
550
+ def run(self, initial_paths: List[str], files_to_preselect: Optional[List[str]] = None) -> Tuple[List[FileTuple], int]:
543
551
  """Run the interactive tree selector"""
544
552
  self.root = self.build_tree(initial_paths)
545
553
 
554
+ if files_to_preselect:
555
+ self._preselect_files(files_to_preselect)
556
+
546
557
  # Don't use Live mode, instead manually control the display
547
558
  while not self.quit_selection:
548
559
  # 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.33.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,13 @@
1
+ kopipasta/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ kopipasta/cache.py,sha256=rH52yX3g7p22_1NXmrBDcBEntm3ivMjtV8n8zxTLY1k,1454
3
+ kopipasta/file.py,sha256=tPCLNvSXHzIvAXLB6fgJswLpCFce7T1QnbNdSWHbIso,4829
4
+ kopipasta/import_parser.py,sha256=yLzkMlQm2avKjfqcpMY0PxbA_2ihV9gSYJplreWIPEQ,12424
5
+ kopipasta/main.py,sha256=YzzEnDph1v3NojHYIjvD3Z4blnIEX5zqj9W6gNLlA7E,50058
6
+ kopipasta/prompt.py,sha256=fOCuJVTLUfR0fjKf5qIlnl_3pNsKNKsvt3C8f4tsmxk,6889
7
+ kopipasta/tree_selector.py,sha256=VIBmPq5cTHU-xUJuXV3K58nn1zS-1X079WAFzfS2vCA,28247
8
+ kopipasta-0.33.0.dist-info/LICENSE,sha256=xw4C9TAU7LFu4r_MwSbky90uzkzNtRwAo3c51IWR8lk,1091
9
+ kopipasta-0.33.0.dist-info/METADATA,sha256=Fs44BJq3C5tqpIdzccr6APhyx97UU0JHjlcTb1uqzuI,4894
10
+ kopipasta-0.33.0.dist-info/WHEEL,sha256=GV9aMThwP_4oNCtvEC2ec3qUYutgWeAzklro_0m4WJQ,91
11
+ kopipasta-0.33.0.dist-info/entry_points.txt,sha256=but54qDNz1-F8fVvGstq_QID5tHjczP7bO7rWLFkc6Y,50
12
+ kopipasta-0.33.0.dist-info/top_level.txt,sha256=iXohixMuCdw8UjGDUp0ouICLYBDrx207sgZIJ9lxn0o,10
13
+ kopipasta-0.33.0.dist-info/RECORD,,
@@ -1,13 +0,0 @@
1
- kopipasta/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- kopipasta/cache.py,sha256=rH52yX3g7p22_1NXmrBDcBEntm3ivMjtV8n8zxTLY1k,1454
3
- kopipasta/file.py,sha256=2HEoNczbKH3TtLO0zYUcZfUoHqb0-o83xFUOgY9rG-Y,1244
4
- kopipasta/import_parser.py,sha256=yLzkMlQm2avKjfqcpMY0PxbA_2ihV9gSYJplreWIPEQ,12424
5
- kopipasta/main.py,sha256=3Sle6pFEko4HSCXClmEb1L4QsN41VEOd0ZuzxeUDFUk,49210
6
- kopipasta/prompt.py,sha256=fOCuJVTLUfR0fjKf5qIlnl_3pNsKNKsvt3C8f4tsmxk,6889
7
- kopipasta/tree_selector.py,sha256=Iov8KLFopb7OLvX-xhn0FmFy0Ee5rFpPYzV6JLxCR84,27614
8
- kopipasta-0.31.0.dist-info/LICENSE,sha256=xw4C9TAU7LFu4r_MwSbky90uzkzNtRwAo3c51IWR8lk,1091
9
- kopipasta-0.31.0.dist-info/METADATA,sha256=RO8xJpSYGdqzsr-A7a22wTJEu2EwF_xtzJaziMD9XUw,4894
10
- kopipasta-0.31.0.dist-info/WHEEL,sha256=GV9aMThwP_4oNCtvEC2ec3qUYutgWeAzklro_0m4WJQ,91
11
- kopipasta-0.31.0.dist-info/entry_points.txt,sha256=but54qDNz1-F8fVvGstq_QID5tHjczP7bO7rWLFkc6Y,50
12
- kopipasta-0.31.0.dist-info/top_level.txt,sha256=iXohixMuCdw8UjGDUp0ouICLYBDrx207sgZIJ9lxn0o,10
13
- kopipasta-0.31.0.dist-info/RECORD,,