reposnap 0.6.2__tar.gz → 0.6.3__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.
Files changed (36) hide show
  1. {reposnap-0.6.2 → reposnap-0.6.3}/.gitignore +3 -0
  2. reposnap-0.6.3/.vscode/launch.json +13 -0
  3. {reposnap-0.6.2 → reposnap-0.6.3}/PKG-INFO +1 -1
  4. {reposnap-0.6.2 → reposnap-0.6.3}/pyproject.toml +1 -1
  5. {reposnap-0.6.2 → reposnap-0.6.3}/src/reposnap/controllers/project_controller.py +47 -57
  6. {reposnap-0.6.2 → reposnap-0.6.3}/src/reposnap/models/file_tree.py +2 -1
  7. reposnap-0.6.3/tests/reposnap/test_collected_tree.py +133 -0
  8. {reposnap-0.6.2 → reposnap-0.6.3}/tests/reposnap/test_project_controller.py +22 -24
  9. {reposnap-0.6.2 → reposnap-0.6.3}/.python-version +0 -0
  10. {reposnap-0.6.2 → reposnap-0.6.3}/LICENSE +0 -0
  11. {reposnap-0.6.2 → reposnap-0.6.3}/README.md +0 -0
  12. {reposnap-0.6.2 → reposnap-0.6.3}/requirements-dev.lock +0 -0
  13. {reposnap-0.6.2 → reposnap-0.6.3}/requirements.lock +0 -0
  14. {reposnap-0.6.2 → reposnap-0.6.3}/src/reposnap/__init__.py +0 -0
  15. {reposnap-0.6.2 → reposnap-0.6.3}/src/reposnap/controllers/__init__.py +0 -0
  16. {reposnap-0.6.2 → reposnap-0.6.3}/src/reposnap/core/__init__.py +0 -0
  17. {reposnap-0.6.2 → reposnap-0.6.3}/src/reposnap/core/file_system.py +0 -0
  18. {reposnap-0.6.2 → reposnap-0.6.3}/src/reposnap/core/git_repo.py +0 -0
  19. {reposnap-0.6.2 → reposnap-0.6.3}/src/reposnap/core/markdown_generator.py +0 -0
  20. {reposnap-0.6.2 → reposnap-0.6.3}/src/reposnap/interfaces/__init__.py +0 -0
  21. {reposnap-0.6.2 → reposnap-0.6.3}/src/reposnap/interfaces/cli.py +0 -0
  22. {reposnap-0.6.2 → reposnap-0.6.3}/src/reposnap/interfaces/gui.py +0 -0
  23. {reposnap-0.6.2 → reposnap-0.6.3}/src/reposnap/models/__init__.py +0 -0
  24. {reposnap-0.6.2 → reposnap-0.6.3}/src/reposnap/utils/__init__.py +0 -0
  25. {reposnap-0.6.2 → reposnap-0.6.3}/src/reposnap/utils/path_utils.py +0 -0
  26. {reposnap-0.6.2 → reposnap-0.6.3}/tests/__init__.py +0 -0
  27. {reposnap-0.6.2 → reposnap-0.6.3}/tests/reposnap/__init__.py +0 -0
  28. {reposnap-0.6.2 → reposnap-0.6.3}/tests/reposnap/test_cli.py +0 -0
  29. {reposnap-0.6.2 → reposnap-0.6.3}/tests/reposnap/test_file_system.py +0 -0
  30. {reposnap-0.6.2 → reposnap-0.6.3}/tests/reposnap/test_file_tree.py +0 -0
  31. {reposnap-0.6.2 → reposnap-0.6.3}/tests/reposnap/test_git_repo.py +0 -0
  32. {reposnap-0.6.2 → reposnap-0.6.3}/tests/reposnap/test_gui.py +0 -0
  33. {reposnap-0.6.2 → reposnap-0.6.3}/tests/reposnap/test_markdown_generator.py +0 -0
  34. {reposnap-0.6.2 → reposnap-0.6.3}/tests/reposnap/test_path_utils.py +0 -0
  35. {reposnap-0.6.2 → reposnap-0.6.3}/tests/resources/another_existing_file.py +0 -0
  36. {reposnap-0.6.2 → reposnap-0.6.3}/tests/resources/existing_file.py +0 -0
@@ -12,3 +12,6 @@ wheels/
12
12
  output.md
13
13
  pytest.ini
14
14
  .envrc
15
+
16
+ # pytest
17
+ .pytest_cache/
@@ -0,0 +1,13 @@
1
+ {
2
+ "version": "0.2.0",
3
+ "configurations": [
4
+ {
5
+ "name": "Python Debugger: Run reposnap",
6
+ "type": "debugpy",
7
+ "request": "launch",
8
+ "module": "reposnap.interfaces.cli",
9
+ "console": "integratedTerminal",
10
+ "args": ["src/reposnap/controllers", "src/reposnap/interfaces", "README.md", "tests/reposnap"]
11
+ }
12
+ ]
13
+ }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: reposnap
3
- Version: 0.6.2
3
+ Version: 0.6.3
4
4
  Summary: Generate a Markdown file with all contents of your project
5
5
  Author: agoloborodko
6
6
  License-File: LICENSE
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "reposnap"
3
- version = "0.6.2"
3
+ version = "0.6.3"
4
4
  description = "Generate a Markdown file with all contents of your project"
5
5
  authors = [
6
6
  { name = "agoloborodko" }
@@ -1,9 +1,6 @@
1
- # src/reposnap/controllers/project_controller.py
2
-
3
1
  import logging
4
2
  from pathlib import Path
5
3
  from reposnap.core.file_system import FileSystem
6
- from reposnap.core.markdown_generator import MarkdownGenerator
7
4
  from reposnap.models.file_tree import FileTree
8
5
  import pathspec
9
6
  from typing import List, Optional
@@ -11,35 +8,33 @@ from typing import List, Optional
11
8
  class ProjectController:
12
9
  def __init__(self, args: Optional[object] = None):
13
10
  self.logger = logging.getLogger(__name__)
11
+ # Always determine repository root using Git (or cwd)
12
+ self.root_dir = self._get_repo_root().resolve()
14
13
  if args:
15
- # Support both new 'paths' (multiple paths) and legacy 'path'
16
- if hasattr(args, 'paths'):
17
- input_paths = [Path(p) for p in args.paths]
18
- else:
19
- input_paths = [Path(args.path)]
20
14
  self.args = args
21
- # Determine repository root (using provided paths’ common parent if available)
22
- self.root_dir = self._get_repo_root().resolve()
23
- # Convert provided paths to be relative to the repository root.
15
+ # Treat positional arguments as literal file/directory names.
16
+ input_paths = [Path(p) for p in (args.paths if hasattr(args, 'paths') else [args.path])]
24
17
  self.input_paths = []
25
18
  for p in input_paths:
26
- try:
27
- candidate = (self.root_dir / p).resolve()
28
- rel = candidate.relative_to(self.root_dir)
29
- if rel != Path('.'):
30
- self.input_paths.append(rel)
31
- except ValueError:
32
- self.logger.warning(f"Path {p} is not under repository root {self.root_dir}. Ignoring.")
19
+ candidate = (self.root_dir / p).resolve()
20
+ if candidate.exists():
21
+ try:
22
+ rel = candidate.relative_to(self.root_dir)
23
+ if rel != Path('.'):
24
+ self.input_paths.append(rel)
25
+ except ValueError:
26
+ self.logger.warning(f"Path {p} is not under repository root {self.root_dir}. Ignoring.")
27
+ else:
28
+ self.logger.warning(f"Path {p} does not exist relative to repository root {self.root_dir}.")
33
29
  self.output_file: Path = Path(args.output).resolve() if args.output else self.root_dir / 'output.md'
34
30
  self.structure_only: bool = args.structure_only if hasattr(args, 'structure_only') else False
35
31
  self.include_patterns: List[str] = args.include if hasattr(args, 'include') else []
36
32
  self.exclude_patterns: List[str] = args.exclude if hasattr(args, 'exclude') else []
37
33
  else:
38
- self.root_dir = Path('.').resolve()
34
+ self.args = None
39
35
  self.input_paths = []
40
- self.output_file = Path('output.md').resolve()
36
+ self.output_file = self.root_dir / 'output.md'
41
37
  self.structure_only = False
42
- self.args = None
43
38
  self.include_patterns = []
44
39
  self.exclude_patterns = []
45
40
  self.file_tree: Optional[FileTree] = None
@@ -49,25 +44,9 @@ class ProjectController:
49
44
 
50
45
  def _get_repo_root(self) -> Path:
51
46
  """
52
- Determine the repository root. If arguments were provided and those paths exist,
53
- use the common parent directory of all provided paths. Otherwise, fall back to the
54
- git repository working tree directory (or current directory if not a git repo).
47
+ Determine the repository root using Git if available,
48
+ otherwise use the current directory.
55
49
  """
56
- if self.args is not None:
57
- candidate_paths = []
58
- if hasattr(self.args, 'paths'):
59
- for p in self.args.paths:
60
- candidate = Path(p).resolve()
61
- if candidate.exists():
62
- candidate_paths.append(candidate)
63
- elif hasattr(self.args, 'path'):
64
- candidate = Path(self.args.path).resolve()
65
- if candidate.exists():
66
- candidate_paths.append(candidate)
67
- if candidate_paths:
68
- from os.path import commonpath
69
- common = Path(commonpath([str(p) for p in candidate_paths]))
70
- return common
71
50
  from git import Repo, InvalidGitRepositoryError
72
51
  try:
73
52
  repo = Repo(Path.cwd(), search_parent_directories=True)
@@ -104,25 +83,33 @@ class ProjectController:
104
83
  return files
105
84
 
106
85
  def collect_file_tree(self) -> None:
107
- self.logger.info("Collecting files by walking the repository root.")
108
- all_files = []
109
- for path in self.root_dir.rglob("*"):
110
- if path.is_file():
111
- try:
112
- rel = path.relative_to(self.root_dir)
113
- all_files.append(rel)
114
- except ValueError:
115
- continue
116
- # Apply include/exclude filtering.
86
+ self.logger.info("Collecting files from Git tracked files if available.")
87
+ try:
88
+ from reposnap.core.git_repo import GitRepo
89
+ git_repo = GitRepo(self.root_dir)
90
+ all_files = git_repo.get_git_files()
91
+ self.logger.debug(f"Git tracked files: {all_files}")
92
+ except Exception as e:
93
+ self.logger.warning(f"Error obtaining Git tracked files: {e}.")
94
+ all_files = []
95
+ # If Git returns an empty list but files exist on disk, fall back to filesystem scan.
96
+ if not all_files:
97
+ file_list = [p for p in self.root_dir.rglob("*") if p.is_file()]
98
+ if file_list:
99
+ self.logger.info("Git tracked files empty, using filesystem scan fallback.")
100
+ all_files = []
101
+ for path in file_list:
102
+ try:
103
+ rel = path.relative_to(self.root_dir)
104
+ all_files.append(rel)
105
+ except ValueError:
106
+ continue
117
107
  all_files = self._apply_include_exclude(all_files)
118
- self.logger.debug(f"All files after include/exclude filtering: {all_files}")
108
+ self.logger.debug(f"All files after applying include/exclude: {all_files}")
119
109
  if self.input_paths:
120
110
  trees = []
121
111
  for input_path in self.input_paths:
122
- subset = [
123
- f for f in all_files
124
- if f == input_path or f.parts[:len(input_path.parts)] == input_path.parts
125
- ]
112
+ subset = [f for f in all_files if f == input_path or list(f.parts[:len(input_path.parts)]) == list(input_path.parts)]
126
113
  self.logger.debug(f"Files for input path '{input_path}': {subset}")
127
114
  if subset:
128
115
  tree = FileSystem(self.root_dir).build_tree_structure(subset)
@@ -164,6 +151,7 @@ class ProjectController:
164
151
 
165
152
  def generate_output(self) -> None:
166
153
  self.logger.info("Starting Markdown generation.")
154
+ from reposnap.core.markdown_generator import MarkdownGenerator
167
155
  markdown_generator = MarkdownGenerator(
168
156
  root_dir=self.root_dir,
169
157
  output_file=self.output_file,
@@ -175,6 +163,7 @@ class ProjectController:
175
163
  def generate_output_from_selected(self, selected_files: set) -> None:
176
164
  self.logger.info("Generating Markdown from selected files.")
177
165
  pruned_tree = self.file_tree.prune_tree(selected_files)
166
+ from reposnap.core.markdown_generator import MarkdownGenerator
178
167
  markdown_generator = MarkdownGenerator(
179
168
  root_dir=self.root_dir,
180
169
  output_file=self.output_file,
@@ -194,14 +183,15 @@ class ProjectController:
194
183
  gitignore_path = self.root_dir / '.gitignore'
195
184
  if not gitignore_path.exists():
196
185
  for parent in self.root_dir.parents:
197
- gitignore_path = parent / '.gitignore'
198
- if gitignore_path.exists():
186
+ candidate = parent / '.gitignore'
187
+ if candidate.exists():
188
+ gitignore_path = candidate
199
189
  break
200
190
  else:
201
191
  gitignore_path = None
202
192
  if gitignore_path and gitignore_path.exists():
203
193
  with gitignore_path.open('r') as gitignore:
204
- patterns = gitignore.readlines()
194
+ patterns = [line.strip() for line in gitignore if line.strip() and not line.strip().startswith('#')]
205
195
  self.logger.debug(f"Loaded .gitignore patterns from {gitignore_path.parent}: {patterns}")
206
196
  return patterns
207
197
  else:
@@ -48,7 +48,8 @@ class FileTree:
48
48
  if filtered_value:
49
49
  filtered_subtree[key] = filtered_value
50
50
  else:
51
- if not spec.match_file(current_path):
51
+ # Exclude the file if either the full path OR its basename matches a .gitignore pattern.
52
+ if not spec.match_file(current_path) and not spec.match_file(Path(current_path).name):
52
53
  filtered_subtree[key] = value
53
54
  return filtered_subtree
54
55
 
@@ -0,0 +1,133 @@
1
+ # tests/reposnap/test_collected_tree.py
2
+
3
+ import os
4
+ import tempfile
5
+ from pathlib import Path
6
+ import pytest
7
+ from reposnap.controllers.project_controller import ProjectController
8
+ from unittest.mock import patch
9
+
10
+ def create_directory_structure(base_dir: str, structure: dict):
11
+ """
12
+ Recursively creates directories and files based on the provided structure.
13
+ """
14
+ for name, content in structure.items():
15
+ path = os.path.join(base_dir, name)
16
+ if isinstance(content, dict):
17
+ os.makedirs(path, exist_ok=True)
18
+ create_directory_structure(path, content)
19
+ else:
20
+ with open(path, 'w') as f:
21
+ f.write(content)
22
+
23
+ def traverse_tree(tree: dict, path=''):
24
+ files = []
25
+ for name, node in tree.items():
26
+ current_path = os.path.join(path, name)
27
+ if isinstance(node, dict):
28
+ files.extend(traverse_tree(node, current_path))
29
+ else:
30
+ files.append(current_path)
31
+ return files
32
+
33
+ def test_collect_tree_all_files():
34
+ with tempfile.TemporaryDirectory() as temp_dir:
35
+ structure = {
36
+ 'src': {
37
+ 'module': {
38
+ 'a.py': 'print("a")',
39
+ 'b.txt': 'text',
40
+ }
41
+ },
42
+ 'tests': {
43
+ 'test_a.py': 'print("test")'
44
+ },
45
+ 'README.md': '# Readme'
46
+ }
47
+ create_directory_structure(temp_dir, structure)
48
+ args = type('Args', (object,), {
49
+ 'path': temp_dir,
50
+ 'output': os.path.join(temp_dir, 'output.md'),
51
+ 'structure_only': True,
52
+ 'debug': False,
53
+ 'include': [],
54
+ 'exclude': []
55
+ })
56
+ with patch('reposnap.controllers.project_controller.ProjectController._get_repo_root', return_value=Path(temp_dir)):
57
+ controller = ProjectController(args)
58
+ controller.collect_file_tree()
59
+ collected = traverse_tree(controller.file_tree.structure)
60
+ expected = [
61
+ 'README.md',
62
+ os.path.join('src', 'module', 'a.py'),
63
+ os.path.join('src', 'module', 'b.txt'),
64
+ os.path.join('tests', 'test_a.py')
65
+ ]
66
+ assert sorted(collected) == sorted(expected)
67
+
68
+ def test_collect_tree_literal_path():
69
+ with tempfile.TemporaryDirectory() as temp_dir:
70
+ structure = {
71
+ 'src': {
72
+ 'module': {
73
+ 'a.py': 'print("a")',
74
+ 'b.txt': 'text',
75
+ }
76
+ },
77
+ 'tests': {
78
+ 'test_a.py': 'print("test")'
79
+ },
80
+ 'README.md': '# Readme'
81
+ }
82
+ create_directory_structure(temp_dir, structure)
83
+ # Request only the 'src' directory.
84
+ args = type('Args', (object,), {
85
+ 'paths': ['src'],
86
+ 'output': os.path.join(temp_dir, 'output.md'),
87
+ 'structure_only': True,
88
+ 'debug': False,
89
+ 'include': [],
90
+ 'exclude': []
91
+ })
92
+ with patch('reposnap.controllers.project_controller.ProjectController._get_repo_root', return_value=Path(temp_dir)):
93
+ controller = ProjectController(args)
94
+ controller.collect_file_tree()
95
+ collected = traverse_tree(controller.file_tree.structure)
96
+ expected = [
97
+ os.path.join('src', 'module', 'a.py'),
98
+ os.path.join('src', 'module', 'b.txt')
99
+ ]
100
+ assert sorted(collected) == sorted(expected)
101
+
102
+ def test_collect_tree_multiple_paths():
103
+ with tempfile.TemporaryDirectory() as temp_dir:
104
+ structure = {
105
+ 'src': {
106
+ 'module': {
107
+ 'a.py': 'print("a")',
108
+ }
109
+ },
110
+ 'tests': {
111
+ 'test_a.py': 'print("test")'
112
+ },
113
+ 'README.md': '# Readme'
114
+ }
115
+ create_directory_structure(temp_dir, structure)
116
+ # Request multiple literal paths.
117
+ args = type('Args', (object,), {
118
+ 'paths': ['README.md', 'tests'],
119
+ 'output': os.path.join(temp_dir, 'output.md'),
120
+ 'structure_only': True,
121
+ 'debug': False,
122
+ 'include': [],
123
+ 'exclude': []
124
+ })
125
+ with patch('reposnap.controllers.project_controller.ProjectController._get_repo_root', return_value=Path(temp_dir)):
126
+ controller = ProjectController(args)
127
+ controller.collect_file_tree()
128
+ collected = traverse_tree(controller.file_tree.structure)
129
+ expected = [
130
+ 'README.md',
131
+ os.path.join('tests', 'test_a.py')
132
+ ]
133
+ assert sorted(collected) == sorted(expected)
@@ -49,9 +49,10 @@ def test_project_controller_includes_py_files():
49
49
  'structure_only': False,
50
50
  'debug': False
51
51
  })
52
-
53
- controller = ProjectController(args)
54
- controller.run()
52
+ # Force repository root to be our temporary directory.
53
+ with patch('reposnap.controllers.project_controller.ProjectController._get_repo_root', return_value=Path(temp_dir)):
54
+ controller = ProjectController(args)
55
+ controller.run()
55
56
 
56
57
  with open(args.output, 'r') as f:
57
58
  output_content = f.read()
@@ -64,7 +65,6 @@ def test_project_controller_includes_py_files():
64
65
 
65
66
 
66
67
  def test_project_controller_run():
67
- # This test patches only MarkdownGenerator to verify that generate_markdown is called.
68
68
  with tempfile.TemporaryDirectory() as temp_dir:
69
69
  structure = {
70
70
  'file1.txt': 'content',
@@ -79,14 +79,17 @@ def test_project_controller_run():
79
79
  'include': [],
80
80
  'exclude': []
81
81
  })
82
- with patch('reposnap.controllers.project_controller.MarkdownGenerator') as MockMarkdownGenerator:
83
- mock_instance = MagicMock()
84
- MockMarkdownGenerator.return_value = mock_instance
82
+ # Patch _get_repo_root to force temp_dir as repo root.
83
+ with patch('reposnap.controllers.project_controller.ProjectController._get_repo_root', return_value=Path(temp_dir)):
84
+ # Patch the MarkdownGenerator in its actual module.
85
+ with patch('reposnap.core.markdown_generator.MarkdownGenerator') as MockMarkdownGenerator:
86
+ mock_instance = MagicMock()
87
+ MockMarkdownGenerator.return_value = mock_instance
85
88
 
86
- controller = ProjectController(args)
87
- controller.run()
89
+ controller = ProjectController(args)
90
+ controller.run()
88
91
 
89
- mock_instance.generate_markdown.assert_called_once()
92
+ mock_instance.generate_markdown.assert_called_once()
90
93
 
91
94
 
92
95
  def test_include_pattern():
@@ -115,8 +118,9 @@ def test_include_pattern():
115
118
  'include': ['*.py'],
116
119
  'exclude': []
117
120
  })
118
- controller = ProjectController(args)
119
- controller.collect_file_tree()
121
+ with patch('reposnap.controllers.project_controller.ProjectController._get_repo_root', return_value=Path(temp_dir)):
122
+ controller = ProjectController(args)
123
+ controller.collect_file_tree()
120
124
 
121
125
  # Traverse the merged tree and collect file paths.
122
126
  included_files = []
@@ -165,11 +169,10 @@ def test_exclude_pattern():
165
169
  'include': [],
166
170
  'exclude': ['*.md', '*.txt']
167
171
  })
168
- controller = ProjectController(args)
169
- controller.collect_file_tree()
170
-
172
+ with patch('reposnap.controllers.project_controller.ProjectController._get_repo_root', return_value=Path(temp_dir)):
173
+ controller = ProjectController(args)
174
+ controller.collect_file_tree()
171
175
  included_files = []
172
-
173
176
  def traverse(tree, path=''):
174
177
  for name, node in tree.items():
175
178
  current_path = os.path.join(path, name)
@@ -177,9 +180,7 @@ def test_exclude_pattern():
177
180
  traverse(node, current_path)
178
181
  else:
179
182
  included_files.append(current_path)
180
-
181
183
  traverse(controller.file_tree.structure)
182
-
183
184
  expected_files = [
184
185
  os.path.join('src', 'module', 'file1.py'),
185
186
  os.path.join('src', 'module', 'submodule', 'file3.py'),
@@ -187,7 +188,6 @@ def test_exclude_pattern():
187
188
  ]
188
189
  assert sorted(included_files) == sorted(expected_files)
189
190
 
190
-
191
191
  def test_include_and_exclude_patterns():
192
192
  with tempfile.TemporaryDirectory() as temp_dir:
193
193
  structure = {
@@ -217,11 +217,10 @@ def test_include_and_exclude_patterns():
217
217
  'include': ['*foo*'],
218
218
  'exclude': ['*submodule*']
219
219
  })
220
- controller = ProjectController(args)
221
- controller.collect_file_tree()
222
-
220
+ with patch('reposnap.controllers.project_controller.ProjectController._get_repo_root', return_value=Path(temp_dir)):
221
+ controller = ProjectController(args)
222
+ controller.collect_file_tree()
223
223
  collected = []
224
-
225
224
  def traverse(tree, path=''):
226
225
  for name, node in tree.items():
227
226
  current_path = os.path.join(path, name)
@@ -230,7 +229,6 @@ def test_include_and_exclude_patterns():
230
229
  traverse(node, current_path)
231
230
  else:
232
231
  collected.append(current_path)
233
-
234
232
  traverse(controller.file_tree.structure)
235
233
  expected = [
236
234
  os.path.join('src'),
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes