skylos 1.0.11__tar.gz → 1.1.11__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 skylos might be problematic. Click here for more details.

Files changed (41) hide show
  1. {skylos-1.0.11 → skylos-1.1.11}/PKG-INFO +1 -1
  2. {skylos-1.0.11 → skylos-1.1.11}/README.md +34 -12
  3. {skylos-1.0.11 → skylos-1.1.11}/pyproject.toml +1 -1
  4. {skylos-1.0.11 → skylos-1.1.11}/setup.py +1 -1
  5. {skylos-1.0.11 → skylos-1.1.11}/skylos/__init__.py +1 -1
  6. {skylos-1.0.11 → skylos-1.1.11}/skylos/analyzer.py +108 -9
  7. {skylos-1.0.11 → skylos-1.1.11}/skylos/cli.py +63 -4
  8. {skylos-1.0.11 → skylos-1.1.11}/skylos/visitor.py +5 -7
  9. {skylos-1.0.11 → skylos-1.1.11}/skylos.egg-info/PKG-INFO +1 -1
  10. {skylos-1.0.11 → skylos-1.1.11}/skylos.egg-info/SOURCES.txt +4 -9
  11. skylos-1.1.11/test/conftest.py +212 -0
  12. skylos-1.1.11/test/test_analyzer.py +584 -0
  13. skylos-1.1.11/test/test_cli.py +353 -0
  14. skylos-1.1.11/test/test_integration.py +320 -0
  15. skylos-1.1.11/test/test_visitor.py +714 -0
  16. skylos-1.0.11/test/pykomodo/command_line.py +0 -176
  17. skylos-1.0.11/test/pykomodo/config.py +0 -20
  18. skylos-1.0.11/test/pykomodo/core.py +0 -121
  19. skylos-1.0.11/test/pykomodo/dashboard.py +0 -608
  20. skylos-1.0.11/test/pykomodo/enhanced_chunker.py +0 -304
  21. skylos-1.0.11/test/pykomodo/multi_dirs_chunker.py +0 -783
  22. skylos-1.0.11/test/pykomodo/pykomodo_config.py +0 -68
  23. skylos-1.0.11/test/pykomodo/token_chunker.py +0 -470
  24. skylos-1.0.11/test/sample_repo/sample_repo/__init__.py +0 -0
  25. skylos-1.0.11/test/test_visitor.py +0 -220
  26. {skylos-1.0.11 → skylos-1.1.11}/setup.cfg +0 -0
  27. {skylos-1.0.11 → skylos-1.1.11}/skylos.egg-info/dependency_links.txt +0 -0
  28. {skylos-1.0.11 → skylos-1.1.11}/skylos.egg-info/entry_points.txt +0 -0
  29. {skylos-1.0.11 → skylos-1.1.11}/skylos.egg-info/requires.txt +0 -0
  30. {skylos-1.0.11 → skylos-1.1.11}/skylos.egg-info/top_level.txt +0 -0
  31. {skylos-1.0.11 → skylos-1.1.11}/test/__init__.py +0 -0
  32. {skylos-1.0.11 → skylos-1.1.11}/test/compare_tools.py +0 -0
  33. {skylos-1.0.11 → skylos-1.1.11}/test/diagnostics.py +0 -0
  34. {skylos-1.0.11/test/pykomodo → skylos-1.1.11/test/sample_repo}/__init__.py +0 -0
  35. {skylos-1.0.11 → skylos-1.1.11}/test/sample_repo/app.py +0 -0
  36. {skylos-1.0.11/test → skylos-1.1.11/test/sample_repo}/sample_repo/__init__.py +0 -0
  37. {skylos-1.0.11 → skylos-1.1.11}/test/sample_repo/sample_repo/commands.py +0 -0
  38. {skylos-1.0.11 → skylos-1.1.11}/test/sample_repo/sample_repo/models.py +0 -0
  39. {skylos-1.0.11 → skylos-1.1.11}/test/sample_repo/sample_repo/routes.py +0 -0
  40. {skylos-1.0.11 → skylos-1.1.11}/test/sample_repo/sample_repo/utils.py +0 -0
  41. {skylos-1.0.11 → skylos-1.1.11}/test/test_skylos.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: skylos
3
- Version: 1.0.11
3
+ Version: 1.1.11
4
4
  Summary: A static analysis tool for Python codebases
5
5
  Author-email: oha <aaronoh2015@gmail.com>
6
6
  Requires-Python: >=3.9
@@ -72,6 +72,28 @@ skylos --interactive --dry-run /path/to/your/project
72
72
  skylos --json /path/to/your/project
73
73
  ```
74
74
 
75
+ ## Folder Management
76
+
77
+ ### Default Exclusions
78
+ By default, Skylos excludes common folders: `__pycache__`, `.git`, `.pytest_cache`, `.mypy_cache`, `.tox`, `htmlcov`, `.coverage`, `build`, `dist`, `*.egg-info`, `venv`, `.venv`
79
+
80
+ ### Folder Options
81
+ ```bash
82
+ # List default excluded folders
83
+ skylos --list-default-excludes
84
+
85
+ # Exclude single folder (The example here will be venv)
86
+ skylos /path/to/your/project --exclude-folder venv
87
+
88
+ # Exclude multiple folders
89
+ skylos /path/to/your/project --exclude-folder venv --exclude-folder build
90
+
91
+ # Force include normally excluded folders
92
+ skylos /path/to/your/project --include-folder venv
93
+
94
+ # Scan everything (no exclusions)
95
+ skylos path/to/your/project --no-default-excludes
96
+
75
97
  ## CLI Options
76
98
 
77
99
  ```
@@ -81,12 +103,16 @@ Arguments:
81
103
  PATH Path to the Python project to analyze
82
104
 
83
105
  Options:
84
- -h, --help Show this help message and exit
85
- -j, --json Output raw JSON instead of formatted text
86
- -o, --output FILE Write output to file instead of stdout
87
- -v, --verbose Enable verbose output
88
- -i, --interactive Interactively select items to remove
89
- --dry-run Show what would be removed without modifying files
106
+ -h, --help Show this help message and exit
107
+ -j, --json Output raw JSON instead of formatted text
108
+ -o, --output FILE Write output to file instead of stdout
109
+ -v, --verbose Enable verbose output
110
+ -i, --interactive Interactively select items to remove
111
+ --dry-run Show what would be removed without modifying files
112
+ --exclude-folder FOLDER Exclude a folder from analysis (can be used multiple times)
113
+ --include-folder FOLDER Force include a folder that would otherwise be excluded
114
+ --no-default-excludes Don't exclude default folders (__pycache__, .git, venv, etc.)
115
+ --list-default-excludes List the default excluded folders and
90
116
  ```
91
117
 
92
118
  ## Example Output
@@ -124,8 +150,6 @@ Next steps:
124
150
 
125
151
  The interactive mode lets you select specific functions and imports to remove:
126
152
 
127
- ![Interactive Demo](docs/interactive-demo.gif)
128
-
129
153
  1. **Select items**: Use arrow keys and space to select/deselect
130
154
  2. **Confirm changes**: Review selected items before applying
131
155
  3. **Auto-cleanup**: Files are automatically updated
@@ -213,13 +237,11 @@ We welcome contributions! Please read our [Contributing Guidelines](CONTRIBUTING
213
237
 
214
238
  ## Roadmap
215
239
  - [ ] Add a production flag, to include dead codes that are used in test but not in the actual execution
216
- - [ ] Expand our test cases
240
+ - [x] Expand our test cases
217
241
  - [ ] Configuration file support
218
- - [ ] Custom analysis rules
219
242
  - [ ] Git hooks integration
220
243
  - [ ] CI/CD integration examples
221
- - [ ] Web interface
222
- - [ ] Support for other languages
244
+ ~~- [] Support for other languages (unless I have contributors, this ain't possible)~~
223
245
  - [ ] Further optimization
224
246
 
225
247
  ## License
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "skylos"
7
- version = "1.0.11"
7
+ version = "1.1.11"
8
8
  requires-python = ">=3.9"
9
9
  description = "A static analysis tool for Python codebases"
10
10
  authors = [{name = "oha", email = "aaronoh2015@gmail.com"}]
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
2
2
 
3
3
  setup(
4
4
  name="skylos",
5
- version="1.0.11",
5
+ version="1.1.11",
6
6
  packages=find_packages(),
7
7
  python_requires=">=3.9",
8
8
  install_requires=["inquirer>=3.0.0"],
@@ -1,6 +1,6 @@
1
1
  from skylos.analyzer import analyze
2
2
 
3
- __version__ = "1.0.11"
3
+ __version__ = "1.0.22"
4
4
 
5
5
  def debug_test():
6
6
  return "debug-ok"
@@ -8,10 +8,39 @@ logging.basicConfig(level=logging.INFO,format='%(asctime)s - %(levelname)s - %(m
8
8
  logger=logging.getLogger('Skylos')
9
9
 
10
10
  AUTO_CALLED={"__init__","__enter__","__exit__"}
11
- TEST_BASE_CLASSES = {"TestCase", "AsyncioTestCase", "unittest.TestCase", "unittest.AsyncioTestCase"}
12
11
  TEST_METHOD_PATTERN = re.compile(r"^test_\w+$")
13
12
  MAGIC_METHODS={f"__{n}__"for n in["init","new","call","getattr","getattribute","enter","exit","str","repr","hash","eq","ne","lt","gt","le","ge","iter","next","contains","len","getitem","setitem","delitem","iadd","isub","imul","itruediv","ifloordiv","imod","ipow","ilshift","irshift","iand","ixor","ior","round","format","dir","abs","complex","int","float","bool","bytes","reduce","await","aiter","anext","add","sub","mul","truediv","floordiv","mod","divmod","pow","lshift","rshift","and","or","xor","radd","rsub","rmul","rtruediv","rfloordiv","rmod","rdivmod","rpow","rlshift","rrshift","rand","ror","rxor"]}
14
13
 
14
+ DEFAULT_EXCLUDE_FOLDERS = {
15
+ "__pycache__",
16
+ ".git",
17
+ ".pytest_cache",
18
+ ".mypy_cache",
19
+ ".tox",
20
+ "htmlcov",
21
+ ".coverage",
22
+ "build",
23
+ "dist",
24
+ "*.egg-info",
25
+ "venv",
26
+ ".venv"
27
+ }
28
+
29
+ def parse_exclude_folders(user_exclude_folders, use_defaults=True, include_folders=None):
30
+ exclude_set = set()
31
+
32
+ if use_defaults:
33
+ exclude_set.update(DEFAULT_EXCLUDE_FOLDERS)
34
+
35
+ if user_exclude_folders:
36
+ exclude_set.update(user_exclude_folders)
37
+
38
+ if include_folders:
39
+ for folder in include_folders:
40
+ exclude_set.discard(folder)
41
+
42
+ return exclude_set
43
+
15
44
  class Skylos:
16
45
  def __init__(self):
17
46
  self.defs={}
@@ -25,6 +54,54 @@ class Skylos:
25
54
  if p[-1]=="__init__":p.pop()
26
55
  return".".join(p)
27
56
 
57
+ def _should_exclude_file(self, file_path, root_path, exclude_folders):
58
+ if not exclude_folders:
59
+ return False
60
+
61
+ try:
62
+ rel_path = file_path.relative_to(root_path)
63
+ except ValueError:
64
+ return False
65
+
66
+ path_parts = rel_path.parts
67
+
68
+ for exclude_folder in exclude_folders:
69
+ if "*" in exclude_folder:
70
+ for part in path_parts:
71
+ if part.endswith(exclude_folder.replace("*", "")):
72
+ return True
73
+ else:
74
+ if exclude_folder in path_parts:
75
+ return True
76
+
77
+ return False
78
+
79
+ def _get_python_files(self, path, exclude_folders=None):
80
+ p = Path(path).resolve()
81
+
82
+ if p.is_file():
83
+ return [p], p.parent
84
+
85
+ root = p
86
+ all_files = list(p.glob("**/*.py"))
87
+
88
+ if exclude_folders:
89
+ filtered_files = []
90
+ excluded_count = 0
91
+
92
+ for file_path in all_files:
93
+ if self._should_exclude_file(file_path, root, exclude_folders):
94
+ excluded_count += 1
95
+ continue
96
+ filtered_files.append(file_path)
97
+
98
+ if excluded_count > 0:
99
+ logger.info(f"Excluded {excluded_count} files from analysis")
100
+
101
+ return filtered_files, root
102
+
103
+ return all_files, root
104
+
28
105
  def _mark_exports(self):
29
106
  for name, d in self.defs.items():
30
107
  if d.in_init and not d.simple_name.startswith('_'):
@@ -55,7 +132,7 @@ class Skylos:
55
132
  for d in self.defs.values():
56
133
  simple_name_lookup[d.simple_name].append(d)
57
134
 
58
- for ref, file in self.refs:
135
+ for ref, _ in self.refs:
59
136
  if ref in self.defs:
60
137
  self.defs[ref].references += 1
61
138
 
@@ -118,13 +195,30 @@ class Skylos:
118
195
  if d.type == "method" and TEST_METHOD_PATTERN.match(d.simple_name):
119
196
  class_name = d.name.rsplit(".", 1)[0]
120
197
  class_simple_name = class_name.split(".")[-1]
121
- if "Test" in class_simple_name or class_simple_name.endswith("TestCase"):
198
+ if (class_simple_name.startswith("Test") or
199
+ class_simple_name.endswith("Test") or
200
+ class_simple_name.endswith("TestCase")):
122
201
  d.confidence = 0
123
202
 
124
- def analyze(self, path, thr=60):
125
- p = Path(path).resolve()
126
- files = [p] if p.is_file() else list(p.glob("**/*.py"))
127
- root = p.parent if p.is_file() else p
203
+ def analyze(self, path, thr=60, exclude_folders=None):
204
+
205
+ files, root = self._get_python_files(path, exclude_folders)
206
+
207
+ if not files:
208
+ logger.warning(f"No Python files found in {path}")
209
+ return json.dumps({
210
+ "unused_functions": [],
211
+ "unused_imports": [],
212
+ "unused_classes": [],
213
+ "unused_variables": [],
214
+ "unused_parameters": [],
215
+ "analysis_summary": {
216
+ "total_files": 0,
217
+ "excluded_folders": exclude_folders if exclude_folders else []
218
+ }
219
+ })
220
+
221
+ logger.info(f"Analyzing {len(files)} Python files...")
128
222
 
129
223
  modmap = {}
130
224
  for f in files:
@@ -156,7 +250,11 @@ class Skylos:
156
250
  "unused_imports": [],
157
251
  "unused_classes": [],
158
252
  "unused_variables": [],
159
- "unused_parameters": []
253
+ "unused_parameters": [],
254
+ "analysis_summary": {
255
+ "total_files": len(files),
256
+ "excluded_folders": exclude_folders if exclude_folders else [],
257
+ }
160
258
  }
161
259
 
162
260
  for u in unused:
@@ -188,7 +286,8 @@ def proc_file(file_or_args, mod=None):
188
286
  logger.error(f"{file}: {e}")
189
287
  return [], [], set(), set()
190
288
 
191
- def analyze(path,conf=60):return Skylos().analyze(path,conf)
289
+ def analyze(path,conf=60, exclude_folders=None):
290
+ return Skylos().analyze(path,conf, exclude_folders)
192
291
 
193
292
  if __name__=="__main__":
194
293
  if len(sys.argv)>1:
@@ -4,6 +4,7 @@ import sys
4
4
  import logging
5
5
  import ast
6
6
  import skylos
7
+ from skylos.analyzer import parse_exclude_folders, DEFAULT_EXCLUDE_FOLDERS
7
8
 
8
9
  try:
9
10
  import inquirer
@@ -89,6 +90,7 @@ def remove_unused_import(file_path: str, import_name: str, line_number: int) ->
89
90
  return False
90
91
 
91
92
  def remove_unused_function(file_path: str, function_name: str, line_number: int) -> bool:
93
+ # remove the entire def from the source code
92
94
  try:
93
95
  with open(file_path, 'r') as f:
94
96
  content = f.read()
@@ -226,17 +228,74 @@ def main() -> None:
226
228
  action="store_true",
227
229
  help="Show what would be removed without actually modifying files"
228
230
  )
231
+
232
+ parser.add_argument(
233
+ "--exclude-folder",
234
+ action="append",
235
+ dest="exclude_folders",
236
+ help="Exclude a folder from analysis (can be used multiple times). "
237
+ "By default, common folders like __pycache__, .git, venv are excluded. "
238
+ "Use --no-default-excludes to disable default exclusions."
239
+ )
240
+
241
+ parser.add_argument(
242
+ "--include-folder",
243
+ action="append",
244
+ dest="include_folders",
245
+ help="Force include a folder that would otherwise be excluded "
246
+ "(overrides both default and custom exclusions). "
247
+ "Example: --include-folder venv to scan your venv folder."
248
+ )
249
+
250
+ parser.add_argument(
251
+ "--no-default-excludes",
252
+ action="store_true",
253
+ help="Don't exclude default folders (__pycache__, .git, venv, etc.). "
254
+ "Only exclude folders specified with --exclude-folder."
255
+ )
256
+
257
+ parser.add_argument(
258
+ "--list-default-excludes",
259
+ action="store_true",
260
+ help="List the default excluded folders and exit."
261
+ )
229
262
 
230
263
  args = parser.parse_args()
264
+
265
+ if args.list_default_excludes:
266
+ print("Default excluded folders:")
267
+ for folder in sorted(DEFAULT_EXCLUDE_FOLDERS):
268
+ print(f" {folder}")
269
+ print(f"\nTotal: {len(DEFAULT_EXCLUDE_FOLDERS)} folders")
270
+ print("\nUse --no-default-excludes to disable these exclusions")
271
+ print("Use --include-folder <folder> to force include specific folders")
272
+ return
273
+
231
274
  logger = setup_logger(args.output)
232
275
 
233
276
  if args.verbose:
234
277
  logger.setLevel(logging.DEBUG)
235
278
  logger.debug(f"Analyzing path: {args.path}")
279
+ if args.exclude_folders:
280
+ logger.debug(f"Excluding folders: {args.exclude_folders}")
281
+
282
+ use_defaults = not args.no_default_excludes
283
+ final_exclude_folders = parse_exclude_folders(
284
+ user_exclude_folders=args.exclude_folders,
285
+ use_defaults=use_defaults,
286
+ include_folders=args.include_folders
287
+ )
288
+
289
+ if not args.json:
290
+ if final_exclude_folders:
291
+ logger.info(f"{Colors.YELLOW}📁 Excluding: {', '.join(sorted(final_exclude_folders))}{Colors.RESET}")
292
+ else:
293
+ logger.info(f"{Colors.GREEN}📁 No folders excluded{Colors.RESET}")
236
294
 
237
295
  try:
238
- result_json = skylos.analyze(args.path)
296
+ result_json = skylos.analyze(args.path, exclude_folders=list(final_exclude_folders))
239
297
  result = json.loads(result_json)
298
+
240
299
  except Exception as e:
241
300
  logger.error(f"Error during analysis: {e}")
242
301
  sys.exit(1)
@@ -259,7 +318,6 @@ def main() -> None:
259
318
  logger.info(f" • Unused parameters: {Colors.YELLOW}{len(unused_parameters)}{Colors.RESET}")
260
319
  logger.info(f" • Unused variables: {Colors.YELLOW}{len(unused_variables)}{Colors.RESET}")
261
320
 
262
-
263
321
  if args.interactive and (unused_functions or unused_imports):
264
322
  logger.info(f"\n{Colors.BOLD}Interactive Mode:{Colors.RESET}")
265
323
  selected_functions, selected_imports = interactive_selection(logger, unused_functions, unused_imports)
@@ -352,8 +410,9 @@ def main() -> None:
352
410
 
353
411
  if unused_functions or unused_imports:
354
412
  logger.info(f"\n{Colors.BOLD}Next steps:{Colors.RESET}")
355
- logger.info(f" • Use --interactive to select specific items to remove")
356
- logger.info(f" • Use --dry-run to preview changes before applying them")
413
+ logger.info(f" • Use --interactive to select specific items to remove")
414
+ logger.info(f" • Use --dry-run to preview changes before applying them")
415
+ logger.info(f" • Use --exclude-folder to skip directories like node_modules, .git")
357
416
 
358
417
  if __name__ == "__main__":
359
418
  main()
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env python3
2
- import ast,re
2
+ import ast
3
3
  from pathlib import Path
4
4
 
5
5
  PYTHON_BUILTINS={"print","len","str","int","float","list","dict","set","tuple","range","open","super","object","type","enumerate","zip","map","filter","sorted","reversed","sum","min","max","all","any","next","iter","repr","chr","ord","bytes","bytearray","memoryview","format","round","abs","pow","divmod","complex","hash","id","bool","callable","getattr","setattr","delattr","hasattr","isinstance","issubclass","globals","locals","vars","dir","property","classmethod","staticmethod"}
@@ -98,16 +98,13 @@ class Visitor(ast.NodeVisitor):
98
98
  base = ".".join(parts[:-node.level]) + (f".{node.module}" if node.module else "")
99
99
 
100
100
  full = f"{base}.{a.name}"
101
-
102
101
  if a.asname:
103
- alias_full = f"{self.mod}.{a.asname}" if self.mod else a.asname
104
- self.add_def(alias_full, "import", node.lineno)
105
102
  self.alias[a.asname] = full
106
- self.add_ref(full)
103
+ self.add_def(full, "import", node.lineno)
107
104
  else:
108
105
  self.alias[a.name] = full
109
106
  self.add_def(full, "import", node.lineno)
110
-
107
+
111
108
  def visit_arguments(self, args):
112
109
  for arg in args.args:
113
110
  self.visit_annotation(arg.annotation)
@@ -271,7 +268,8 @@ class Visitor(ast.NodeVisitor):
271
268
  break
272
269
  else:
273
270
  # not parameter, handle normally
274
- self.add_ref(self.qual(node.id))
271
+ qualified = self.qual(node.id)
272
+ self.add_ref(qualified)
275
273
  if node.id in DYNAMIC_PATTERNS:
276
274
  self.dyn.add(self.mod.split(".")[0])
277
275
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: skylos
3
- Version: 1.0.11
3
+ Version: 1.1.11
4
4
  Summary: A static analysis tool for Python codebases
5
5
  Author-email: oha <aaronoh2015@gmail.com>
6
6
  Requires-Python: >=3.9
@@ -13,18 +13,13 @@ skylos.egg-info/requires.txt
13
13
  skylos.egg-info/top_level.txt
14
14
  test/__init__.py
15
15
  test/compare_tools.py
16
+ test/conftest.py
16
17
  test/diagnostics.py
18
+ test/test_analyzer.py
19
+ test/test_cli.py
20
+ test/test_integration.py
17
21
  test/test_skylos.py
18
22
  test/test_visitor.py
19
- test/pykomodo/__init__.py
20
- test/pykomodo/command_line.py
21
- test/pykomodo/config.py
22
- test/pykomodo/core.py
23
- test/pykomodo/dashboard.py
24
- test/pykomodo/enhanced_chunker.py
25
- test/pykomodo/multi_dirs_chunker.py
26
- test/pykomodo/pykomodo_config.py
27
- test/pykomodo/token_chunker.py
28
23
  test/sample_repo/__init__.py
29
24
  test/sample_repo/app.py
30
25
  test/sample_repo/sample_repo/__init__.py
@@ -0,0 +1,212 @@
1
+ import pytest
2
+ import tempfile
3
+ from pathlib import Path
4
+ from textwrap import dedent
5
+
6
+ @pytest.fixture(scope="session")
7
+ def sample_project():
8
+ with tempfile.TemporaryDirectory() as temp_dir:
9
+ project_path = Path(temp_dir)
10
+
11
+ create_sample_project(project_path)
12
+ yield project_path
13
+
14
+ def create_sample_project(base_path: Path):
15
+
16
+ (base_path / "app").mkdir()
17
+
18
+ app_init = base_path / "app" / "__init__.py"
19
+ app_init.write_text(dedent("""
20
+ from .core import main_function
21
+ from .utils import helper_function
22
+
23
+ __all__ = ['main_function', 'helper_function']
24
+ """))
25
+
26
+ core_py = base_path / "app" / "core.py"
27
+ core_py.write_text(dedent("""
28
+ import os
29
+ import sys
30
+ from typing import Dict, List # List is unused here
31
+ from collections import defaultdict
32
+
33
+ def main_function():
34
+ '''Main entry point - should not be flagged'''
35
+ data = defaultdict(list)
36
+ return process_data(data)
37
+
38
+ def process_data(data: Dict):
39
+ '''Used by main_function'''
40
+ return len(data)
41
+
42
+ def deprecated_function():
43
+ '''This function is never called'''
44
+ unused_var = "should be flagged"
45
+ return unused_var
46
+
47
+ def _private_helper():
48
+ '''Private function, might be unused'''
49
+ return "private"
50
+ """))
51
+
52
+ utils_py = base_path / "app" / "utils.py"
53
+ utils_py.write_text(dedent("""
54
+ import json # unused
55
+ import re
56
+
57
+ def helper_function():
58
+ '''Exported function'''
59
+ return validate_input("test")
60
+
61
+ def validate_input(text: str):
62
+ '''Used by helper_function'''
63
+ return re.match(r"^[a-z]+$", text) is not None
64
+
65
+ def unused_utility():
66
+ '''Never called utility'''
67
+ return "utility"
68
+
69
+ class ConfigManager:
70
+ '''Used class'''
71
+ def __init__(self):
72
+ self.config = {}
73
+
74
+ def get(self, key):
75
+ return self.config.get(key)
76
+
77
+ class LegacyProcessor:
78
+ '''Unused class'''
79
+ def process(self, data):
80
+ return data
81
+ """))
82
+
83
+ tests_dir = base_path / "tests"
84
+ tests_dir.mkdir()
85
+
86
+ test_core = tests_dir / "test_core.py"
87
+ test_core.write_text(dedent("""
88
+ import unittest
89
+ from app.core import main_function, process_data
90
+
91
+ class TestCore(unittest.TestCase):
92
+ def test_main_function(self):
93
+ result = main_function()
94
+ self.assertIsInstance(result, int)
95
+
96
+ def test_process_data(self):
97
+ data = {'key': ['value']}
98
+ result = process_data(data)
99
+ self.assertEqual(result, 1)
100
+
101
+ def test_edge_case(self):
102
+ '''Test methods should not be flagged'''
103
+ pass
104
+ """))
105
+
106
+ config_dir = base_path / "config"
107
+ config_dir.mkdir()
108
+
109
+ config_py = config_dir / "settings.py"
110
+ config_py.write_text(dedent("""
111
+ # Configuration file with potentially unused imports
112
+ import os
113
+ import logging
114
+
115
+ DEBUG = True
116
+ DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///app.db")
117
+
118
+ def setup_logging():
119
+ '''Might be called externally'''
120
+ logging.basicConfig(level=logging.INFO)
121
+ """))
122
+
123
+ pycache_dir = base_path / "__pycache__"
124
+ pycache_dir.mkdir()
125
+
126
+ cache_file = pycache_dir / "cached.pyc"
127
+ cache_file.write_bytes(b"fake compiled python")
128
+
129
+
130
+ @pytest.fixture
131
+ def simple_python_file():
132
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f:
133
+ f.write(dedent("""
134
+ import os # unused
135
+ import sys
136
+
137
+ def used_function():
138
+ print("Hello", file=sys.stderr)
139
+
140
+ def unused_function():
141
+ return "never called"
142
+
143
+ if __name__ == "__main__":
144
+ used_function()
145
+ """))
146
+ f.flush()
147
+ yield Path(f.name)
148
+ Path(f.name).unlink()
149
+
150
+
151
+ @pytest.fixture
152
+ def empty_project():
153
+ with tempfile.TemporaryDirectory() as temp_dir:
154
+ yield Path(temp_dir)
155
+
156
+ @pytest.fixture
157
+ def project_with_syntax_error():
158
+ with tempfile.TemporaryDirectory() as temp_dir:
159
+ project_path = Path(temp_dir)
160
+
161
+ valid_py = project_path / "valid.py"
162
+ valid_py.write_text("def valid_function():\n return True\n")
163
+
164
+ invalid_py = project_path / "invalid.py"
165
+ invalid_py.write_text("def invalid_function(\n # Missing closing parenthesis\n return False\n")
166
+
167
+ yield project_path
168
+
169
+ @pytest.fixture(autouse=True)
170
+ def cleanup_temp_files():
171
+ """auto cleanup any temporary files after each test"""
172
+ yield
173
+
174
+ pytest_plugins = []
175
+
176
+ def pytest_configure(config):
177
+ config.addinivalue_line(
178
+ "markers", "integration: mark test as an integration test"
179
+ )
180
+ config.addinivalue_line(
181
+ "markers", "unit: mark test as a unit test"
182
+ )
183
+ config.addinivalue_line(
184
+ "markers", "slow: mark test as slow running"
185
+ )
186
+ config.addinivalue_line(
187
+ "markers", "cli: mark test as CLI interface test"
188
+ )
189
+
190
+ @pytest.fixture
191
+ def mock_git_repo():
192
+ with tempfile.TemporaryDirectory() as temp_dir:
193
+ repo_path = Path(temp_dir)
194
+
195
+ try:
196
+ import subprocess
197
+ subprocess.run(["git", "init"], cwd=repo_path, check=True,
198
+ capture_output=True)
199
+ subprocess.run(["git", "config", "user.email", "test@example.com"],
200
+ cwd=repo_path, check=True)
201
+ subprocess.run(["git", "config", "user.name", "Test User"],
202
+ cwd=repo_path, check=True)
203
+
204
+ test_file = repo_path / "test.py"
205
+ test_file.write_text("def test(): pass\n")
206
+ subprocess.run(["git", "add", "."], cwd=repo_path, check=True)
207
+ subprocess.run(["git", "commit", "-m", "Initial commit"],
208
+ cwd=repo_path, check=True)
209
+ except (subprocess.CalledProcessError, FileNotFoundError):
210
+ pass
211
+
212
+ yield repo_path