lapsed 2.7.2__tar.gz → 2.9.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.
Files changed (33) hide show
  1. {lapsed-2.7.2 → lapsed-2.9.0}/PKG-INFO +1 -1
  2. {lapsed-2.7.2 → lapsed-2.9.0}/deadcode/actions/find_python_filenames.py +4 -2
  3. {lapsed-2.7.2 → lapsed-2.9.0}/deadcode/actions/fix_or_show_unused_code.py +4 -1
  4. {lapsed-2.7.2 → lapsed-2.9.0}/deadcode/actions/parse_tach_config.py +13 -1
  5. {lapsed-2.7.2 → lapsed-2.9.0}/deadcode/cli.py +13 -2
  6. {lapsed-2.7.2 → lapsed-2.9.0}/deadcode/utils/nested_scopes.py +24 -8
  7. {lapsed-2.7.2 → lapsed-2.9.0}/deadcode/visitor/code_item.py +3 -1
  8. {lapsed-2.7.2 → lapsed-2.9.0}/deadcode/visitor/dead_code_visitor.py +12 -2
  9. {lapsed-2.7.2 → lapsed-2.9.0}/deadcode/visitor/ignore.py +9 -1
  10. {lapsed-2.7.2 → lapsed-2.9.0}/pyproject.toml +1 -1
  11. {lapsed-2.7.2 → lapsed-2.9.0}/.gitignore +0 -0
  12. {lapsed-2.7.2 → lapsed-2.9.0}/LICENSE +0 -0
  13. {lapsed-2.7.2 → lapsed-2.9.0}/README.md +0 -0
  14. {lapsed-2.7.2 → lapsed-2.9.0}/deadcode/__init__.py +0 -0
  15. {lapsed-2.7.2 → lapsed-2.9.0}/deadcode/actions/__init__.py +0 -0
  16. {lapsed-2.7.2 → lapsed-2.9.0}/deadcode/actions/find_unused_names.py +0 -0
  17. {lapsed-2.7.2 → lapsed-2.9.0}/deadcode/actions/get_unused_names_error_message.py +0 -0
  18. {lapsed-2.7.2 → lapsed-2.9.0}/deadcode/actions/merge_overlaping_file_parts.py +0 -0
  19. {lapsed-2.7.2 → lapsed-2.9.0}/deadcode/actions/parse_abstract_syntax_tree.py +0 -0
  20. {lapsed-2.7.2 → lapsed-2.9.0}/deadcode/actions/parse_arguments.py +0 -0
  21. {lapsed-2.7.2 → lapsed-2.9.0}/deadcode/actions/remove_file_parts_from_content.py +0 -0
  22. {lapsed-2.7.2 → lapsed-2.9.0}/deadcode/constants.py +0 -0
  23. {lapsed-2.7.2 → lapsed-2.9.0}/deadcode/data_types.py +0 -0
  24. {lapsed-2.7.2 → lapsed-2.9.0}/deadcode/utils/__init__.py +0 -0
  25. {lapsed-2.7.2 → lapsed-2.9.0}/deadcode/utils/add_colors_to_diff.py +0 -0
  26. {lapsed-2.7.2 → lapsed-2.9.0}/deadcode/utils/base_test_case.py +0 -0
  27. {lapsed-2.7.2 → lapsed-2.9.0}/deadcode/utils/fix_indent.py +0 -0
  28. {lapsed-2.7.2 → lapsed-2.9.0}/deadcode/utils/flatten_lists.py +0 -0
  29. {lapsed-2.7.2 → lapsed-2.9.0}/deadcode/utils/print_ast.py +0 -0
  30. {lapsed-2.7.2 → lapsed-2.9.0}/deadcode/visitor/__init__.py +0 -0
  31. {lapsed-2.7.2 → lapsed-2.9.0}/deadcode/visitor/lines.py +0 -0
  32. {lapsed-2.7.2 → lapsed-2.9.0}/deadcode/visitor/noqa.py +0 -0
  33. {lapsed-2.7.2 → lapsed-2.9.0}/deadcode/visitor/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lapsed
3
- Version: 2.7.2
3
+ Version: 2.9.0
4
4
  Summary: Find and remove dead code.
5
5
  Project-URL: homepage, https://github.com/albertas/deadcode
6
6
  Project-URL: documentation, https://deadcode.readthedocs.io/
@@ -33,9 +33,11 @@ def find_python_filenames(args: Args) -> List[str]:
33
33
  continue
34
34
 
35
35
  if path.is_file() and path.suffix == '.py':
36
- filenames.append(str(path))
36
+ # .as_posix() (not str()) so filenames stay forward-slash on every
37
+ # OS, matching the paths given on the command line.
38
+ filenames.append(path.as_posix())
37
39
  elif path.is_dir():
38
- paths += list([str(p) for p in path.glob('*')])
40
+ paths += list([p.as_posix() for p in path.glob('*')])
39
41
  elif not path.exists():
40
42
  # TODO: unify error logging and reporting
41
43
  # Maybe a cli flag could be added to stop on error
@@ -23,7 +23,10 @@ def fix_or_show_unused_code(unused_items: Iterable[CodeItem], args: Args) -> str
23
23
 
24
24
  filename_to_unused_items = defaultdict(list)
25
25
  for unused_item in unused_items:
26
- filename_to_unused_items[str(unused_item.filename)].append(unused_item)
26
+ # .as_posix() (not str()) to match the forward-slash filenames
27
+ # find_python_filenames() produces, which the mocked/real `open()`
28
+ # calls below key off of.
29
+ filename_to_unused_items[unused_item.filename.as_posix()].append(unused_item)
27
30
 
28
31
  result = []
29
32
 
@@ -1,9 +1,10 @@
1
1
  import re
2
2
  import sys
3
3
  from dataclasses import dataclass, field
4
+ from functools import lru_cache
4
5
  from logging import getLogger
5
6
  from pathlib import Path
6
- from typing import Any, Dict, Iterable, List, Optional
7
+ from typing import Any, Dict, Iterable, List, Optional, Tuple
7
8
 
8
9
  if sys.version_info >= (3, 11):
9
10
  import tomllib
@@ -46,6 +47,9 @@ class TachIndex:
46
47
  every other tach project it might also belong to. Paths unrelated to any tach project
47
48
  (e.g. an explicitly provided directory that lives elsewhere entirely) are unaffected.
48
49
  """
50
+ if not self._configs:
51
+ return False
52
+
49
53
  path = path.resolve()
50
54
 
51
55
  related_to_any_config = False
@@ -89,6 +93,14 @@ class TachIndex:
89
93
 
90
94
 
91
95
  def load_tach_index(tach_config_paths: Iterable[str]) -> TachIndex:
96
+ # find_python_filenames() and DeadCodeVisitor.__init__() both load the
97
+ # tach index from the same args.tach_config, so cache it per path set to
98
+ # avoid re-parsing (and re-globbing for tach.domain.toml files) twice.
99
+ return _load_tach_index_cached(tuple(tach_config_paths))
100
+
101
+
102
+ @lru_cache(maxsize=None)
103
+ def _load_tach_index_cached(tach_config_paths: Tuple[str, ...]) -> TachIndex:
92
104
  configs = []
93
105
  for raw_path in tach_config_paths:
94
106
  path = Path(raw_path).resolve()
@@ -33,14 +33,25 @@ def main(
33
33
  return error_message + ('\n\n' + file_diff if file_diff else '')
34
34
 
35
35
  if not args.count and not args.quiet:
36
- print('\033[1mWell done!\033[0m ✨ 🚀 ✨')
36
+ try:
37
+ print('\033[1mWell done!\033[0m ✨ 🚀 ✨')
38
+ except UnicodeEncodeError:
39
+ # Some terminals (e.g. Windows' default cp1252 console codepage) cannot
40
+ # encode emoji. Fall back to a plain message rather than crashing.
41
+ print('\033[1mWell done!\033[0m')
37
42
  return None
38
43
 
39
44
 
40
45
  def print_main() -> None:
41
- if result := main():
46
+ is_version_request = '--version' in sys.argv
47
+
48
+ result = main()
49
+ if result:
42
50
  print(result)
43
51
 
52
+ if not is_version_request and result is not None:
53
+ sys.exit(1)
54
+
44
55
 
45
56
  if __name__ == '__main__':
46
57
  print_main()
@@ -2,6 +2,15 @@ from typing import Any, Dict, List, Optional, Union
2
2
 
3
3
  from deadcode.visitor.code_item import CodeItem
4
4
 
5
+ # Sentinel key used to store a scope's own CodeItem definitions in a dict
6
+ # separate from its nested child-scope dicts. A scope-part name (e.g. a class
7
+ # or function name) and a CodeItem's name can be the same string (a class
8
+ # named `Bar` nested under module `foo` is both a child scope `foo.Bar` and a
9
+ # CodeItem named `Bar` defined in scope `foo`), so they can't share one dict's
10
+ # key space without ambiguity. `object()` can't equal any string or CodeItem,
11
+ # so it can't collide with either.
12
+ _ITEMS = object()
13
+
5
14
 
6
15
  class NestedScope:
7
16
  """This data structure is used to track what types are defined in each scope.
@@ -15,7 +24,7 @@ class NestedScope:
15
24
  """
16
25
 
17
26
  def __init__(self) -> None:
18
- self._scopes: Dict[Union[str, CodeItem], Any] = {}
27
+ self._scopes: Dict[Union[str, CodeItem, object], Any] = {}
19
28
 
20
29
  def add(self, code_item: CodeItem) -> None:
21
30
  """Adds code item to nested scope."""
@@ -31,8 +40,12 @@ class NestedScope:
31
40
  current_scope[scope_part] = {} # Could use None if type cannot have scope
32
41
  current_scope = current_scope[scope_part]
33
42
 
34
- # > TODO: leaf should be replaced. Is it replaced with new code item?
35
- current_scope[code_item] = {}
43
+ # Store this scope's own definitions in their own dict (keyed by
44
+ # _ITEMS) instead of mixing them into current_scope's child-scope
45
+ # keys, so get() below can do a direct O(1) dict lookup by name
46
+ # instead of scanning current_scope.keys() for a match.
47
+ items: Dict[Union[str, CodeItem], CodeItem] = current_scope.setdefault(_ITEMS, {})
48
+ items[code_item] = code_item
36
49
 
37
50
  def get(self, name: str, scope: str) -> Optional[Union[CodeItem, str]]:
38
51
  """Returns CodeItem which matches scoped_name (e.g. package.class.method.variable)
@@ -45,7 +58,7 @@ class NestedScope:
45
58
  # projects.models, billing.models, auth.models: only one root scope called models would be registered.
46
59
 
47
60
  # Create a stack of scopes begining from nearest and following with parent one
48
- scopes: List[Dict[Union[CodeItem, str], Dict[Any, Any]]] = []
61
+ scopes: List[Dict[Union[str, CodeItem, object], Any]] = []
49
62
  next_scope = self._scopes
50
63
  for scope_part in scope.split('.'):
51
64
  if scope_part not in next_scope:
@@ -53,11 +66,14 @@ class NestedScope:
53
66
  next_scope = next_scope[scope_part]
54
67
  scopes.insert(0, next_scope)
55
68
 
56
- # Search for definition with provided name in scopes
69
+ # Search for definition with provided name in scopes.
70
+ # CodeItem.__hash__/__eq__ accept plain strings, so a dict.get() with
71
+ # the string name directly finds the matching CodeItem key's value in
72
+ # O(1) average time.
57
73
  for current_scope in scopes:
58
- if name in current_scope:
59
- current_scope_keys = list(current_scope.keys())
60
- return current_scope_keys[current_scope_keys.index(name)]
74
+ found_items: Optional[Dict[Union[str, CodeItem], CodeItem]] = current_scope.get(_ITEMS)
75
+ if found_items and (code_item := found_items.get(name)) is not None:
76
+ return code_item
61
77
 
62
78
  return None
63
79
 
@@ -92,7 +92,9 @@ class CodeItem: # TODO: This should also be a dataclass, because hash and tuple
92
92
 
93
93
  @property
94
94
  def filename_with_position(self) -> str:
95
- filename_with_position = str(self.filename)
95
+ # .as_posix() (not str()) so output is forward-slash on every OS,
96
+ # matching how the path was originally given on the command line.
97
+ filename_with_position = self.filename.as_posix()
96
98
  if self.name_line is not None:
97
99
  filename_with_position += f':{self.name_line}'
98
100
  if self.name_column is not None:
@@ -87,6 +87,12 @@ class DeadCodeVisitor(ast.NodeVisitor):
87
87
  self.scopes = NestedScope()
88
88
  self.tach_index = load_tach_index(args.tach_config)
89
89
 
90
+ # Caches getattr(self, 'visit_' + node type name, None) per AST node
91
+ # type, since visit() below is called once per AST node (potentially
92
+ # millions of times) and both the string concatenation and the
93
+ # getattr MRO walk are otherwise repeated for every single node.
94
+ self._visitor_cache: Dict[type, Optional[Callable[[ast.AST], None]]] = {}
95
+
90
96
  @property
91
97
  def scope(self) -> str:
92
98
  return '.'.join(self.scope_parts)
@@ -510,8 +516,12 @@ class DeadCodeVisitor(ast.NodeVisitor):
510
516
  self.should_ignore_new_definitions = True
511
517
  should_turn_off_ignore_new_definitions = True
512
518
 
513
- method_name = 'visit_' + node.__class__.__name__
514
- visitor = getattr(self, method_name, None)
519
+ node_type = node.__class__
520
+ try:
521
+ visitor = self._visitor_cache[node_type]
522
+ except KeyError:
523
+ visitor = getattr(self, 'visit_' + node_type.__name__, None)
524
+ self._visitor_cache[node_type] = visitor
515
525
 
516
526
  if visitor:
517
527
  visitor(node)
@@ -1,5 +1,6 @@
1
1
  import ast
2
2
  from fnmatch import fnmatch, fnmatchcase
3
+ from functools import lru_cache
3
4
  from pathlib import Path
4
5
  from typing import Iterable, Set, Union
5
6
 
@@ -63,14 +64,21 @@ def _is_special_name(name: str) -> bool:
63
64
 
64
65
  def _match(name: Union[str, Path], patterns: Iterable[str], case: bool = True) -> bool:
65
66
  func = fnmatchcase if case else fnmatch
66
- return any(func(str(name), pattern) for pattern in patterns)
67
+ # .as_posix() (not str()) for Path names, so a pattern like '*/tests/*'
68
+ # matches consistently regardless of the OS's native path separator.
69
+ name_str = name.as_posix() if isinstance(name, Path) else name
70
+ return any(func(name_str, pattern) for pattern in patterns)
67
71
 
68
72
 
69
73
  def _match_many(names: Union[Iterable[str], Iterable[Path]], patterns: Iterable[str], case: bool = True) -> bool:
70
74
  return any(_match(name, patterns, case) for name in names)
71
75
 
72
76
 
77
+ @lru_cache(maxsize=None)
73
78
  def _is_test_file(filename: Path) -> bool:
79
+ # Called once per definition (function/class/method) in a file, so cache
80
+ # per-filename: `filename.resolve()` is a filesystem syscall and the
81
+ # answer never changes for repeated calls with the same file.
74
82
  return _match(
75
83
  filename.resolve(),
76
84
  ['*/test/*', '*/tests/*', '*/test*.py', '*[-_]test.py'],
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lapsed"
3
- version = "2.7.2"
3
+ version = "2.9.0"
4
4
  authors = [
5
5
  {name = "Albertas Gimbutas", email = "albertasgim@gmail.com"},
6
6
  ]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes