lapsed 2.4.1__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.
@@ -0,0 +1,207 @@
1
+ import re
2
+ import sys
3
+ from dataclasses import dataclass, field
4
+ from logging import getLogger
5
+ from pathlib import Path
6
+ from typing import Any, Dict, Iterable, List, Optional
7
+
8
+ if sys.version_info >= (3, 11):
9
+ import tomllib
10
+ else:
11
+ import tomli as tomllib
12
+
13
+ logger = getLogger()
14
+
15
+
16
+ @dataclass
17
+ class TachInterface:
18
+ expose: List[str]
19
+ from_patterns: Optional[List[str]] = None
20
+
21
+
22
+ @dataclass
23
+ class TachModule:
24
+ path_patterns: List[str]
25
+ unchecked: bool = False
26
+
27
+
28
+ @dataclass
29
+ class TachConfig:
30
+ source_roots: List[Path]
31
+ modules: List[TachModule] = field(default_factory=list)
32
+ interfaces: List[TachInterface] = field(default_factory=list)
33
+
34
+
35
+ class TachIndex:
36
+ """Answers dead-code-relevant questions derived from one or more tach.toml files."""
37
+
38
+ def __init__(self, configs: List[TachConfig]) -> None:
39
+ self._configs = configs
40
+
41
+ def is_unchecked(self, file: Path) -> bool:
42
+ for config in self._configs:
43
+ module_path = _dotted_module_path(file, config.source_roots)
44
+ if module_path is None:
45
+ continue
46
+ for module in config.modules:
47
+ if module.unchecked and _match_any_dotted_glob(module.path_patterns, module_path):
48
+ return True
49
+ return False
50
+
51
+ def is_exposed(self, file: Path, name: str) -> bool:
52
+ for config in self._configs:
53
+ module_path = _dotted_module_path(file, config.source_roots)
54
+ if module_path is None:
55
+ continue
56
+ for interface in config.interfaces:
57
+ adopts_interface = interface.from_patterns is None or _match_any_regex(
58
+ interface.from_patterns, module_path
59
+ )
60
+ if adopts_interface and _match_any_regex(interface.expose, name):
61
+ return True
62
+ return False
63
+
64
+
65
+ def load_tach_index(tach_config_paths: Iterable[str]) -> TachIndex:
66
+ configs = []
67
+ for raw_path in tach_config_paths:
68
+ path = Path(raw_path).resolve()
69
+ if not path.is_file():
70
+ logger.error(f'Error: tach config {path} could not be found.')
71
+ continue
72
+ configs.append(_parse_tach_toml(path))
73
+ return TachIndex(configs)
74
+
75
+
76
+ def _parse_tach_toml(path: Path) -> TachConfig:
77
+ with open(path, 'rb') as f:
78
+ data = tomllib.load(f)
79
+
80
+ project_root = path.parent
81
+ raw_source_roots = data.get('source_roots') or ['.']
82
+ source_roots = [(project_root / root).resolve() for root in raw_source_roots]
83
+
84
+ config = TachConfig(source_roots=source_roots)
85
+ config.modules.extend(_extract_modules(data))
86
+ config.interfaces.extend(_extract_interfaces(data))
87
+
88
+ for source_root in source_roots:
89
+ if not source_root.is_dir():
90
+ continue
91
+ for domain_file in sorted(source_root.rglob('tach.domain.toml')):
92
+ _merge_domain_toml(config, domain_file, source_root)
93
+
94
+ return config
95
+
96
+
97
+ def _extract_modules(data: Dict[str, Any]) -> List[TachModule]:
98
+ modules = []
99
+ for raw in data.get('modules', []):
100
+ if 'paths' in raw:
101
+ patterns = list(raw['paths'])
102
+ elif 'path' in raw:
103
+ patterns = [raw['path']]
104
+ else:
105
+ continue
106
+ modules.append(TachModule(path_patterns=patterns, unchecked=bool(raw.get('unchecked', False))))
107
+ return modules
108
+
109
+
110
+ def _extract_interfaces(data: Dict[str, Any]) -> List[TachInterface]:
111
+ interfaces = []
112
+ for raw in data.get('interfaces', []):
113
+ expose = list(raw.get('expose', []))
114
+ if not expose:
115
+ continue
116
+ from_patterns = list(raw['from']) if 'from' in raw else None
117
+ interfaces.append(TachInterface(expose=expose, from_patterns=from_patterns))
118
+ return interfaces
119
+
120
+
121
+ def _merge_domain_toml(config: TachConfig, domain_file: Path, source_root: Path) -> None:
122
+ with open(domain_file, 'rb') as f:
123
+ data = tomllib.load(f)
124
+
125
+ domain_root_dotted = _dotted_dir_path(domain_file.parent, source_root)
126
+
127
+ def resolve(entry: str) -> str:
128
+ if entry.startswith('//'):
129
+ return entry[2:]
130
+ if entry == '':
131
+ return domain_root_dotted
132
+ if domain_root_dotted:
133
+ return f'{domain_root_dotted}.{entry}'
134
+ return entry
135
+
136
+ modules = _extract_modules(data)
137
+ for module in modules:
138
+ module.path_patterns = [resolve(pattern) for pattern in module.path_patterns]
139
+ config.modules.extend(modules)
140
+
141
+ interfaces = _extract_interfaces(data)
142
+ for interface in interfaces:
143
+ if interface.from_patterns is not None:
144
+ interface.from_patterns = [resolve(pattern) for pattern in interface.from_patterns]
145
+ config.interfaces.extend(interfaces)
146
+
147
+ root_table = data.get('root')
148
+ if isinstance(root_table, dict) and root_table.get('unchecked'):
149
+ config.modules.append(TachModule(path_patterns=[domain_root_dotted], unchecked=True))
150
+
151
+
152
+ def _dotted_dir_path(directory: Path, source_root: Path) -> str:
153
+ directory = directory.resolve()
154
+ source_root = source_root.resolve()
155
+ if directory == source_root:
156
+ return ''
157
+ return '.'.join(directory.relative_to(source_root).parts)
158
+
159
+
160
+ def _dotted_module_path(file: Path, source_roots: List[Path]) -> Optional[str]:
161
+ file = file.resolve()
162
+
163
+ best_root: Optional[Path] = None
164
+ for source_root in source_roots:
165
+ source_root = source_root.resolve()
166
+ if source_root in file.parents and (best_root is None or len(source_root.parts) > len(best_root.parts)):
167
+ best_root = source_root
168
+
169
+ if best_root is None:
170
+ return None
171
+
172
+ parts = list(file.relative_to(best_root).parts)
173
+ if parts and parts[-1] == '__init__.py':
174
+ parts = parts[:-1]
175
+ elif parts and parts[-1].endswith('.py'):
176
+ parts[-1] = parts[-1][: -len('.py')]
177
+ else:
178
+ return None
179
+
180
+ return '.'.join(parts) if parts else None
181
+
182
+
183
+ def _dotted_glob_to_regex(pattern: str) -> re.Pattern[str]:
184
+ regex_segments = [
185
+ '.*' if segment == '**' else re.escape(segment).replace(r'\*', '[^.]*') for segment in pattern.split('.')
186
+ ]
187
+ return re.compile(r'\.'.join(regex_segments))
188
+
189
+
190
+ def _match_any_dotted_glob(patterns: Iterable[str], dotted_path: str) -> bool:
191
+ return any(_dotted_glob_to_regex(pattern).fullmatch(dotted_path) for pattern in patterns)
192
+
193
+
194
+ def _try_compile_regex(pattern: str) -> Optional[re.Pattern[str]]:
195
+ try:
196
+ return re.compile(pattern)
197
+ except re.error:
198
+ logger.exception(f'Error: invalid tach interface regex pattern {pattern!r}.')
199
+ return None
200
+
201
+
202
+ def _match_any_regex(patterns: Iterable[str], value: str) -> bool:
203
+ return any(
204
+ compiled.fullmatch(value)
205
+ for pattern in patterns
206
+ if (compiled := _try_compile_regex(pattern)) is not None
207
+ )
@@ -0,0 +1,166 @@
1
+ import re
2
+ from typing import List, Optional, TypeVar
3
+
4
+ from deadcode.data_types import Part
5
+
6
+ T = TypeVar('T')
7
+
8
+
9
+ def list_get(list_: List[T], index: int) -> Optional[T]:
10
+ if len(list_) > index:
11
+ return list_[index]
12
+ return None
13
+
14
+
15
+ def ends_with_semicolon(line: bytes) -> bool:
16
+ return line.strip().endswith(b':')
17
+
18
+
19
+ def indentation_is_not_childs(previous_line: bytes, current_line: bytes) -> bool:
20
+ return len(get_indentation(previous_line)) >= len(get_indentation(current_line))
21
+
22
+
23
+ def get_indentation(line: bytes) -> bytes:
24
+ return bytes(re.findall(b'^\\s*', line)[0])
25
+
26
+
27
+ def remove_as_from_end(line: bytes) -> bytes:
28
+ if not (line_rstrip := line.rstrip()).endswith(b'as'):
29
+ return line
30
+
31
+ # Is there white spaces before as
32
+ if line_rstrip[:-2] != (line_with_removed_as := line_rstrip[:-2].rstrip()):
33
+ return line_with_removed_as
34
+ return line
35
+
36
+
37
+ def remove_comma_from_begining(line: bytes) -> bytes:
38
+ if not line.lstrip().startswith(b','):
39
+ return line
40
+
41
+ return line.lstrip()[1:].lstrip()
42
+
43
+
44
+ def remove_file_parts_from_content(content_lines: List[bytes], unused_file_parts: List[Part]) -> List[bytes]:
45
+ """ """
46
+ # How should move through the lines of content?
47
+ updated_content_lines = []
48
+
49
+ # TODO: iterate by line and crop that line if needed.
50
+ unused_part_index = 0
51
+
52
+ previous_non_removed_line = b''
53
+ was_block_removed = False
54
+ next_line_after_removed_block = None
55
+ indentation_of_first_removed_line = b''
56
+ empty_lines_in_a_row_list: List[bytes] = []
57
+ empty_lines_before_removed_block_list: List[bytes] = []
58
+
59
+ for current_lineno, line in enumerate(content_lines, start=1):
60
+ from_line, to_line, from_col, to_col = 0, 0, 0, 0
61
+ if unused_part := list_get(unused_file_parts, unused_part_index):
62
+ from_line, to_line, from_col, to_col = unused_part
63
+
64
+ # Skip lines, which have to be ignored.
65
+ if current_lineno > from_line and current_lineno < to_line:
66
+ continue
67
+
68
+ # Is it first line, which have to be ignored?
69
+ elif current_lineno == from_line:
70
+ indentation_of_first_removed_line = get_indentation(line)
71
+
72
+ if from_line == to_line:
73
+ # TODO: this check is a workaround for an assignment expression.
74
+ # Clean-ups have to be applied based on removable expression type.
75
+ if (line[:from_col] + line[to_col:]).strip().startswith(b'='):
76
+ line = line[:from_col]
77
+ else:
78
+ # TODO: should apply `as` removal rule only for particular expression type only
79
+ # as well as comma removal.
80
+ line = remove_as_from_end(line[:from_col]) + remove_comma_from_begining(line[to_col:])
81
+
82
+ unused_part_index += 1
83
+
84
+ if not line.strip():
85
+ empty_lines_before_removed_block_list, empty_lines_in_a_row_list = (
86
+ empty_lines_in_a_row_list,
87
+ empty_lines_before_removed_block_list,
88
+ )
89
+
90
+ was_block_removed = True
91
+ else:
92
+ line = line[:from_col]
93
+
94
+ if line.strip() and not line.startswith(b'#'):
95
+ previous_non_removed_line = line
96
+ updated_content_lines.append(line)
97
+
98
+ # Is it last line, from the block, which have to be ignored?
99
+ elif current_lineno == to_line:
100
+ line = line[to_col:]
101
+
102
+ # Remove trailing comma, if we have removed something.
103
+ # TODO: this should be applied only for specific expression types only.
104
+ if line.lstrip().startswith(b','):
105
+ line = line.lstrip()[1:].lstrip()
106
+
107
+ unused_part_index += 1
108
+ # TODO: Add tests for case, when comments are added at the start or end of the removed block
109
+ if line.strip() and not line.startswith(b'#'):
110
+ updated_content_lines.append(line)
111
+
112
+ empty_lines_before_removed_block_list, empty_lines_in_a_row_list = (
113
+ empty_lines_in_a_row_list,
114
+ empty_lines_before_removed_block_list,
115
+ )
116
+ was_block_removed = True
117
+
118
+ # Do not remove the line
119
+ else:
120
+ if not line.strip():
121
+ empty_lines_in_a_row_list.append(line)
122
+ continue
123
+
124
+ # if was_block_removed: # These lines will be added when first non-empty line is found
125
+ # continue
126
+ elif not was_block_removed:
127
+ updated_content_lines.extend(empty_lines_in_a_row_list)
128
+ empty_lines_in_a_row_list.clear()
129
+
130
+ else:
131
+ # Add pass if needed - if its a file end we should also check if pass have to be added.
132
+ next_line_after_removed_block = line
133
+
134
+ if ends_with_semicolon(previous_non_removed_line):
135
+ if indentation_is_not_childs(
136
+ previous_line=previous_non_removed_line, current_line=next_line_after_removed_block
137
+ ):
138
+ updated_content_lines.append(indentation_of_first_removed_line + b'pass\n')
139
+
140
+ # Add empty lines
141
+ if indentation_is_not_childs(
142
+ previous_line=previous_non_removed_line, current_line=next_line_after_removed_block
143
+ ):
144
+ # Add lines after
145
+ updated_content_lines.extend(empty_lines_in_a_row_list)
146
+ empty_lines_in_a_row_list.clear()
147
+ empty_lines_before_removed_block_list.clear()
148
+ else:
149
+ updated_content_lines.extend(empty_lines_before_removed_block_list)
150
+ empty_lines_before_removed_block_list.clear()
151
+ empty_lines_in_a_row_list.clear()
152
+
153
+ else:
154
+ updated_content_lines.extend(empty_lines_before_removed_block_list)
155
+ empty_lines_before_removed_block_list.clear()
156
+ empty_lines_in_a_row_list.clear()
157
+
158
+ was_block_removed = False
159
+ previous_non_removed_line = line
160
+ updated_content_lines.append(line)
161
+
162
+ if was_block_removed:
163
+ if ends_with_semicolon(previous_non_removed_line):
164
+ updated_content_lines.append(indentation_of_first_removed_line + b'pass\n')
165
+
166
+ return updated_content_lines
deadcode/cli.py ADDED
@@ -0,0 +1,46 @@
1
+ from typing import List, Optional
2
+ import sys
3
+
4
+ from deadcode import __version__
5
+ from deadcode.actions.find_python_filenames import find_python_filenames
6
+ from deadcode.actions.find_unused_names import find_unused_names
7
+ from deadcode.actions.fix_or_show_unused_code import fix_or_show_unused_code
8
+ from deadcode.actions.parse_arguments import parse_arguments
9
+ from deadcode.actions.get_unused_names_error_message import (
10
+ get_unused_names_error_message,
11
+ )
12
+
13
+
14
+ def main(
15
+ command_line_args: Optional[List[str]] = None,
16
+ ) -> Optional[str]:
17
+
18
+ if command_line_args and '--version' in command_line_args or '--version' in sys.argv:
19
+ return __version__
20
+
21
+ args = parse_arguments(command_line_args)
22
+
23
+ filenames = find_python_filenames(args=args)
24
+
25
+ # TODO: rename unused_names to unused_code_items
26
+ unused_names = find_unused_names(filenames=filenames, args=args)
27
+
28
+ file_diff = None
29
+ if (args.fix or args.dry) and unused_names:
30
+ file_diff = fix_or_show_unused_code(unused_names, args=args)
31
+
32
+ if (error_message := get_unused_names_error_message(unused_names, args=args)) is not None:
33
+ return error_message + ('\n\n' + file_diff if file_diff else '')
34
+
35
+ if not args.count and not args.quiet:
36
+ print('\033[1mWell done!\033[0m ✨ 🚀 ✨')
37
+ return None
38
+
39
+
40
+ def print_main() -> None:
41
+ if result := main():
42
+ print(result)
43
+
44
+
45
+ if __name__ == '__main__':
46
+ print_main()
deadcode/constants.py ADDED
@@ -0,0 +1,49 @@
1
+ from typing import Dict, Literal
2
+
3
+
4
+ UnusedCodeType = Literal[
5
+ 'attribute',
6
+ 'class',
7
+ 'function',
8
+ 'import',
9
+ 'method',
10
+ 'property',
11
+ 'variable',
12
+ 'unreachable_code',
13
+ 'name',
14
+ 'unused_file',
15
+ 'commented_out_code',
16
+ 'ignore_expression',
17
+ ]
18
+
19
+
20
+ UnusedCodeErrorCode = Literal[
21
+ 'DC01',
22
+ 'DC02',
23
+ 'DC03',
24
+ 'DC04',
25
+ 'DC05',
26
+ 'DC06',
27
+ 'DC07',
28
+ 'DC08',
29
+ 'DC09',
30
+ 'DC11',
31
+ 'DC12',
32
+ 'DC13',
33
+ ]
34
+
35
+
36
+ ERROR_TYPE_TO_ERROR_CODE: Dict[UnusedCodeType, UnusedCodeErrorCode] = {
37
+ 'variable': 'DC01',
38
+ 'function': 'DC02',
39
+ 'class': 'DC03',
40
+ 'method': 'DC04',
41
+ 'attribute': 'DC05',
42
+ 'name': 'DC06',
43
+ 'import': 'DC07',
44
+ 'property': 'DC08',
45
+ 'unreachable_code': 'DC09',
46
+ 'unused_file': 'DC11',
47
+ 'commented_out_code': 'DC12',
48
+ 'ignore_expression': 'DC13',
49
+ }
deadcode/data_types.py ADDED
@@ -0,0 +1,46 @@
1
+ import ast
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Iterable, NamedTuple
5
+
6
+
7
+ AbstractSyntaxTree = ast.Module # Should be module instead of ast
8
+ FileContent = bytes
9
+ Filename = str # Contains full path to existing file
10
+ Pathname = str # Can contain wildewards
11
+
12
+
13
+ @dataclass
14
+ class Args:
15
+ fix: bool = False
16
+ verbose: bool = False
17
+ version: bool = False
18
+ dry: bool = False
19
+ only: Iterable[Pathname] = ()
20
+ paths: Iterable[Pathname] = ()
21
+ exclude: Iterable[Pathname] = ()
22
+ ignore_definitions: Iterable[Pathname] = ()
23
+ ignore_definitions_if_decorated_with: Iterable[Pathname] = ()
24
+ ignore_definitions_if_inherits_from: Iterable[Pathname] = ()
25
+ ignore_bodies_of: Iterable[Pathname] = ()
26
+ ignore_bodies_if_decorated_with: Iterable[Pathname] = ()
27
+ ignore_bodies_if_inherits_from: Iterable[Pathname] = ()
28
+ ignore_if_decorated_with: Iterable[Pathname] = ()
29
+ ignore_if_inherits_from: Iterable[Pathname] = ()
30
+ ignore_names: Iterable[Pathname] = ()
31
+ ignore_names_if_decorated_with: Iterable[Pathname] = ()
32
+ ignore_names_if_inherits_from: Iterable[Pathname] = ()
33
+ ignore_names_in_files: Iterable[Pathname] = ()
34
+ tach_config: Iterable[Pathname] = ()
35
+ no_color: bool = False
36
+ quiet: bool = False
37
+ count: bool = False
38
+
39
+
40
+ class Part(NamedTuple):
41
+ """Code file part"""
42
+
43
+ line_start: int
44
+ line_end: int
45
+ col_start: int
46
+ col_end: int
File without changes
@@ -0,0 +1,10 @@
1
+ def add_colors_to_diff(diff: bytes) -> bytes:
2
+ colorful_lines = []
3
+ for line in diff.split(b'\n'):
4
+ colorful_line = line
5
+ if line.startswith(b'-'):
6
+ colorful_line = b'\033[31m' + colorful_line + b'\033[0m'
7
+ if line.startswith(b'+'):
8
+ colorful_line = b'\033[32m' + colorful_line + b'\033[0m'
9
+ colorful_lines.append(colorful_line)
10
+ return b'\n'.join(colorful_lines)
@@ -0,0 +1,88 @@
1
+ from pathlib import Path
2
+ from typing import Any, Dict, List, Optional, Union
3
+ from unittest import TestCase
4
+ from unittest.mock import MagicMock, patch
5
+
6
+ from deadcode.utils.fix_indent import fix_indent
7
+ from deadcode.data_types import Args
8
+
9
+
10
+ class BaseTestCase(TestCase):
11
+ files: Dict[str, bytes] = {}
12
+
13
+ maxDiff = None
14
+
15
+ def patch(self, path: str) -> MagicMock:
16
+ patcher = patch(path)
17
+ self.addCleanup(patcher.stop)
18
+ return patcher.start()
19
+
20
+ def _get_filenames(self, *args: Any, **kwargs: Any) -> List[str]:
21
+ return list(self.files.keys())
22
+
23
+ def _read_file_side_effect(self, filename: Union[str, Path], *args: Any, **kwargs: Any) -> MagicMock:
24
+ mock = MagicMock()
25
+ mock.filename = str(filename)
26
+
27
+ def cache_file_content(file_content: bytes) -> int:
28
+ self.updated_files[mock.filename] = file_content
29
+ return len(file_content)
30
+
31
+ if file_content := fix_indent(self.files[mock.filename]):
32
+ mock.__enter__().read.return_value = file_content
33
+ mock.__enter__().readlines.return_value = [line + b'\n' for line in file_content.split(b'\n')]
34
+ mock.__enter__().write.side_effect = cache_file_content
35
+ return mock
36
+
37
+ def setUp(self) -> None:
38
+ self.updated_files: Dict[str, bytes] = {}
39
+
40
+ self.find_python_filenames_mock = self.patch('deadcode.cli.find_python_filenames')
41
+ self.find_python_filenames_mock.side_effect = self._get_filenames
42
+
43
+ self.read_file_mock = self.patch('deadcode.visitor.dead_code_visitor.open')
44
+ self.read_file_mock.side_effect = self._read_file_side_effect
45
+
46
+ self.fix_file_mock = self.patch('deadcode.actions.fix_or_show_unused_code.open')
47
+ self.fix_file_mock.side_effect = self._read_file_side_effect
48
+
49
+ self.os_remove = self.patch('deadcode.actions.fix_or_show_unused_code.os.remove')
50
+
51
+ self.args = Args()
52
+
53
+ def assertFiles(self, files: Dict[str, bytes], removed: Optional[List[str]] = None) -> None:
54
+ expected_removed_files = removed
55
+ expected_files = files
56
+
57
+ # Check if removed files match
58
+ removed_filenames = [call.args[0] for call in self.os_remove.mock_calls]
59
+ if expected_removed_files:
60
+ self.assertEqual(removed_filenames, expected_removed_files)
61
+
62
+ # Check if non removed files are the same
63
+ unchanged_files = {
64
+ filename: file_content
65
+ for filename, file_content in self.files.items()
66
+ if filename not in self.updated_files and filename not in removed_filenames
67
+ }
68
+ self.assertListEqual(
69
+ list(expected_files.keys()), list(self.updated_files.keys()) + list(unchanged_files.keys())
70
+ )
71
+
72
+ # Check if non removed file contents match (both changed and unchanged)
73
+ for filename, content in expected_files.items():
74
+ self.assertEqual(
75
+ fix_indent(content),
76
+ fix_indent(self.updated_files.get(filename) or unchanged_files.get(filename) or ''),
77
+ )
78
+
79
+ def assertUpdatedFiles(self, expected_updated_files: Dict[str, bytes]) -> None:
80
+ """Checks if updated files match expected updated files."""
81
+
82
+ self.assertListEqual(list(expected_updated_files.keys()), list(self.updated_files.keys()))
83
+
84
+ for filename, content in expected_updated_files.items():
85
+ self.assertEqual(
86
+ fix_indent(content),
87
+ self.updated_files[filename],
88
+ )
@@ -0,0 +1,70 @@
1
+ import sys
2
+ from typing import Optional, TypeVar
3
+
4
+ T = TypeVar('T')
5
+
6
+
7
+ def fix_indent(doc: T) -> Optional[T]:
8
+ """Finds indentation of a first line and removes it from all following lines.
9
+
10
+ Implemented based on inspect.cleandoc by keeping trailing lines.
11
+ """
12
+ if isinstance(doc, bytes):
13
+ try:
14
+ byte_lines = doc.expandtabs().split(b'\n')
15
+ except UnicodeError:
16
+ return None
17
+ else:
18
+ # Find minimum indentation of any non-blank lines after first line.
19
+ margin = sys.maxsize
20
+ for byte_line in byte_lines[0:]:
21
+ content = len(byte_line.lstrip())
22
+ if content:
23
+ indent = len(byte_line) - content
24
+ margin = min(margin, indent)
25
+ # Remove indentation.
26
+ if byte_lines:
27
+ byte_lines[0] = byte_lines[0].lstrip()
28
+ if margin < sys.maxsize:
29
+ for i in range(1, len(byte_lines)):
30
+ byte_lines[i] = byte_lines[i][margin:]
31
+
32
+ ## Do not remove trailing lines
33
+ # while lines and not lines[-1]:
34
+ # lines.pop()
35
+
36
+ ## Remove any leading blank lines.
37
+ while byte_lines and not byte_lines[0]:
38
+ byte_lines.pop(0)
39
+ return b'\n'.join(byte_lines) # type: ignore
40
+
41
+ if isinstance(doc, str):
42
+ try:
43
+ lines = doc.expandtabs().split('\n')
44
+ except UnicodeError:
45
+ return None
46
+ else:
47
+ # Find minimum indentation of any non-blank lines after first line.
48
+ margin = sys.maxsize
49
+ for line in lines[0:]:
50
+ content = len(line.lstrip())
51
+ if content:
52
+ indent = len(line) - content
53
+ margin = min(margin, indent)
54
+ # Remove indentation.
55
+ if lines:
56
+ lines[0] = lines[0].lstrip()
57
+ if margin < sys.maxsize:
58
+ for i in range(1, len(lines)):
59
+ lines[i] = lines[i][margin:]
60
+
61
+ ## Do not remove trailing lines
62
+ # while lines and not lines[-1]:
63
+ # lines.pop()
64
+
65
+ ## Remove any leading blank lines.
66
+ while lines and not lines[0]:
67
+ lines.pop(0)
68
+ return '\n'.join(lines) # type: ignore
69
+
70
+ return None