todex 1.0.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.
- todex-1.0.0/LICENSE +21 -0
- todex-1.0.0/PKG-INFO +23 -0
- todex-1.0.0/README.md +11 -0
- todex-1.0.0/pyproject.toml +41 -0
- todex-1.0.0/todex/__init__.py +0 -0
- todex-1.0.0/todex/__main__.py +145 -0
- todex-1.0.0/todex/extractor.py +181 -0
- todex-1.0.0/todex/writer.py +28 -0
todex-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kaloyan Ivanov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
todex-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: todex
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: TODO extractor
|
|
5
|
+
Keywords: TODO extractor,cli tool
|
|
6
|
+
Author-Email: kaliv0 <kaliv.zero@gmail.com>
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Project-URL: repository, https://github.com/kaliv0/todex.git
|
|
9
|
+
Requires-Python: >=3.12
|
|
10
|
+
Requires-Dist: pathspec>=1.1.1
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
<div align="center">
|
|
14
|
+
<img src="https://github.com/kaliv0/todex/blob/main/assets/truck.jpg?raw=true" width="400" alt="Todex">
|
|
15
|
+
</div>
|
|
16
|
+
|
|
17
|
+
# todex
|
|
18
|
+
|
|
19
|
+

|
|
20
|
+
[](https://pypi.org/project/todex/)
|
|
21
|
+
[](https://pepy.tech/projects/todex)
|
|
22
|
+
|
|
23
|
+
Peak mud digger and TODO extractor
|
todex-1.0.0/README.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
<img src="https://github.com/kaliv0/todex/blob/main/assets/truck.jpg?raw=true" width="400" alt="Todex">
|
|
3
|
+
</div>
|
|
4
|
+
|
|
5
|
+
# todex
|
|
6
|
+
|
|
7
|
+

|
|
8
|
+
[](https://pypi.org/project/todex/)
|
|
9
|
+
[](https://pepy.tech/projects/todex)
|
|
10
|
+
|
|
11
|
+
Peak mud digger and TODO extractor
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "todex"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "TODO extractor"
|
|
5
|
+
keywords = [
|
|
6
|
+
"TODO extractor",
|
|
7
|
+
"cli tool",
|
|
8
|
+
]
|
|
9
|
+
authors = [
|
|
10
|
+
{ name = "kaliv0", email = "kaliv.zero@gmail.com" },
|
|
11
|
+
]
|
|
12
|
+
license-files = [
|
|
13
|
+
"LICENSE",
|
|
14
|
+
]
|
|
15
|
+
readme = "README.md"
|
|
16
|
+
requires-python = ">=3.12"
|
|
17
|
+
dependencies = [
|
|
18
|
+
"pathspec>=1.1.1",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[project.urls]
|
|
22
|
+
repository = "https://github.com/kaliv0/todex.git"
|
|
23
|
+
|
|
24
|
+
[project.scripts]
|
|
25
|
+
todex = "todex.__main__:main"
|
|
26
|
+
|
|
27
|
+
[dependency-groups]
|
|
28
|
+
dev = [
|
|
29
|
+
"mypy>=2.3.1",
|
|
30
|
+
"ruff>=0.16.8",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[build-system]
|
|
34
|
+
build-backend = "pdm.backend"
|
|
35
|
+
requires = [
|
|
36
|
+
"pdm-backend",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
[tool.ruff]
|
|
40
|
+
fix = true
|
|
41
|
+
line-length = 100
|
|
File without changes
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from argparse import ArgumentParser, Namespace, RawTextHelpFormatter
|
|
3
|
+
|
|
4
|
+
from todex.extractor import DEFAULT_MAX_DEPTH, DEFAULT_OUT, Extractor
|
|
5
|
+
|
|
6
|
+
__version__ = "1.0.0"
|
|
7
|
+
|
|
8
|
+
TRASH = r"""
|
|
9
|
+
________________ ___/-\___ ___/-\___ ___/-\___
|
|
10
|
+
/ / || |---------| |---------| |---------|
|
|
11
|
+
/ / || | | | | | | | | | |
|
|
12
|
+
/ / __|| | | | | | | | | | | | |
|
|
13
|
+
/ / \\ I || | | | | | | | | | | | |
|
|
14
|
+
(-------------------|| | | | | | | | | | | | | | | |
|
|
15
|
+
|| == || |_______| |_______| |_______|
|
|
16
|
+
|| TODEX | =============================================
|
|
17
|
+
|| ____ | ____ |
|
|
18
|
+
( | o / ____ \ / ____ \ |)
|
|
19
|
+
|| / / . . \ \ / / . . \ \ |
|
|
20
|
+
[ |_____| | . . | |____________________________| | . . | |__]
|
|
21
|
+
| . . | | . . |
|
|
22
|
+
\_____/ \_____/
|
|
23
|
+
|
|
24
|
+
Peak trash sniffer and TODO extractor
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class NoUsageFormatter(RawTextHelpFormatter):
|
|
29
|
+
def _format_usage(self, usage, actions, groups, prefix):
|
|
30
|
+
return ""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ArgValidator:
|
|
34
|
+
def __init__(self, parser: ArgumentParser, args: Namespace) -> None:
|
|
35
|
+
self.parser = parser
|
|
36
|
+
self.args = args
|
|
37
|
+
|
|
38
|
+
def require(self, flag_attr: str, needs_attr: str, flag: str, needs: str) -> None:
|
|
39
|
+
if getattr(self.args, flag_attr) and not getattr(self.args, needs_attr):
|
|
40
|
+
self.parser.error(f"{flag} requires {needs}")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def main() -> None:
|
|
44
|
+
parser = ArgumentParser(
|
|
45
|
+
prog="todex",
|
|
46
|
+
formatter_class=NoUsageFormatter,
|
|
47
|
+
description=TRASH,
|
|
48
|
+
)
|
|
49
|
+
parser.add_argument("path", metavar="PATH", help="path to file or dir to process")
|
|
50
|
+
parser.add_argument(
|
|
51
|
+
"-x",
|
|
52
|
+
"--exclude",
|
|
53
|
+
nargs="*",
|
|
54
|
+
default=[],
|
|
55
|
+
metavar="PATTERN",
|
|
56
|
+
help="""git-like patterns to skip (paths, names, or globs together with default .* / __*), e.g.
|
|
57
|
+
|
|
58
|
+
bar.py any file named bar.py
|
|
59
|
+
/bar.py only top-level
|
|
60
|
+
foo/bar.py path under the scan root
|
|
61
|
+
vendor/ directory and its contents
|
|
62
|
+
**/*.pyc nested matches
|
|
63
|
+
!fizz.md re-include after a broader exclude
|
|
64
|
+
|
|
65
|
+
""",
|
|
66
|
+
)
|
|
67
|
+
parser.add_argument(
|
|
68
|
+
"-H",
|
|
69
|
+
"--include-hidden",
|
|
70
|
+
action="store_true",
|
|
71
|
+
help="do not apply default excludes for hidden names (.* / __*)",
|
|
72
|
+
)
|
|
73
|
+
parser.add_argument(
|
|
74
|
+
"-t",
|
|
75
|
+
"--tokens",
|
|
76
|
+
nargs="*",
|
|
77
|
+
default=[],
|
|
78
|
+
metavar="TOKEN",
|
|
79
|
+
help="""list of tokens to search for (together with default TODO, FIXME) e.g. WARN, REVISIT.
|
|
80
|
+
If the token is followed by {lines-count} e.g. #FIXME{3}
|
|
81
|
+
the extractor will include multiline snippet with the length specified between the curly braces:
|
|
82
|
+
|
|
83
|
+
#FIXME{3} - revist after release
|
|
84
|
+
if self.foo == "bar":
|
|
85
|
+
return f"fizz{buzz}"
|
|
86
|
+
|
|
87
|
+
""",
|
|
88
|
+
)
|
|
89
|
+
parser.add_argument(
|
|
90
|
+
"-i",
|
|
91
|
+
"--ignore-default",
|
|
92
|
+
action="store_true",
|
|
93
|
+
help="use only -t tokens (skip default TODO, FIXME)",
|
|
94
|
+
)
|
|
95
|
+
parser.add_argument(
|
|
96
|
+
"-f", "--full-path", action="store_true", help="display absolute dir/file path"
|
|
97
|
+
)
|
|
98
|
+
parser.add_argument(
|
|
99
|
+
"-s",
|
|
100
|
+
"--short",
|
|
101
|
+
action="store_true",
|
|
102
|
+
help="display only token messages e.g. '#TODO: after release', ignore longer snippets",
|
|
103
|
+
)
|
|
104
|
+
parser.add_argument(
|
|
105
|
+
"-r",
|
|
106
|
+
"--recursive",
|
|
107
|
+
action="store_true",
|
|
108
|
+
help="traverse given dir path recursively",
|
|
109
|
+
)
|
|
110
|
+
parser.add_argument(
|
|
111
|
+
"-m",
|
|
112
|
+
"--max-depth",
|
|
113
|
+
type=int,
|
|
114
|
+
metavar="N",
|
|
115
|
+
default=DEFAULT_MAX_DEPTH,
|
|
116
|
+
help="maximum depth of dir traversal - used with -r/--recursive flag",
|
|
117
|
+
)
|
|
118
|
+
parser.add_argument(
|
|
119
|
+
"-d",
|
|
120
|
+
"--debug",
|
|
121
|
+
action="store_true",
|
|
122
|
+
help="enable debug mode",
|
|
123
|
+
)
|
|
124
|
+
parser.add_argument(
|
|
125
|
+
"-o",
|
|
126
|
+
"--out",
|
|
127
|
+
default=DEFAULT_OUT,
|
|
128
|
+
help="path to output file, if existing dir is passed instead - TODO out file will be saved inside",
|
|
129
|
+
)
|
|
130
|
+
parser.add_argument("-v", "--version", action="version", version=f"%(prog)s {__version__}")
|
|
131
|
+
args = parser.parse_args()
|
|
132
|
+
|
|
133
|
+
validator = ArgValidator(parser, args)
|
|
134
|
+
validator.require("ignore_default", "tokens", "-i/--ignore-default", "-t/--tokens")
|
|
135
|
+
try:
|
|
136
|
+
Extractor(**vars(args)).run()
|
|
137
|
+
except KeyboardInterrupt:
|
|
138
|
+
sys.exit(130)
|
|
139
|
+
except (OSError, TypeError, ValueError) as e:
|
|
140
|
+
print(e, file=sys.stderr)
|
|
141
|
+
sys.exit(1)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
if __name__ == "__main__":
|
|
145
|
+
main()
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import sys
|
|
3
|
+
from functools import cached_property
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from re import Pattern
|
|
6
|
+
from textwrap import dedent, indent
|
|
7
|
+
|
|
8
|
+
from pathspec import PathSpec
|
|
9
|
+
|
|
10
|
+
from todex.writer import Writer
|
|
11
|
+
|
|
12
|
+
DEFAULT_MAX_DEPTH = float("inf")
|
|
13
|
+
DEFAULT_OUT = "TODO"
|
|
14
|
+
DEFAULT_GLOBS = [".*", "__*"]
|
|
15
|
+
DEFAULT_TOKENS = ["TODO", "FIXME"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Extractor:
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
path,
|
|
22
|
+
exclude: list[str] | None = None,
|
|
23
|
+
include_hidden: bool = False,
|
|
24
|
+
tokens: list[str] | None = None,
|
|
25
|
+
ignore_default: bool = False,
|
|
26
|
+
out: str = DEFAULT_OUT,
|
|
27
|
+
full_path: bool = False,
|
|
28
|
+
short: bool = False,
|
|
29
|
+
recursive: bool = False,
|
|
30
|
+
max_depth: float = DEFAULT_MAX_DEPTH,
|
|
31
|
+
debug: bool = False,
|
|
32
|
+
) -> None:
|
|
33
|
+
self.root = Path(path)
|
|
34
|
+
self.exclude_spec = self.prepare_exclude_spec(exclude or [], include_hidden)
|
|
35
|
+
self.token_pattern = self.prepare_token_pattern(tokens or [], ignore_default)
|
|
36
|
+
self.out = self.prepare_out(out)
|
|
37
|
+
self.full_path = full_path
|
|
38
|
+
self.short = short
|
|
39
|
+
self.recursive = recursive
|
|
40
|
+
self.max_depth = max_depth
|
|
41
|
+
self.debug = __debug__ and debug
|
|
42
|
+
|
|
43
|
+
@staticmethod
|
|
44
|
+
def prepare_exclude_spec(exclude: list[str], include_hidden: bool) -> PathSpec:
|
|
45
|
+
if not include_hidden:
|
|
46
|
+
exclude = [*DEFAULT_GLOBS, *exclude]
|
|
47
|
+
exclude = list(dict.fromkeys(exclude)) # dedupe + keep order
|
|
48
|
+
return PathSpec.from_lines("gitignore", exclude)
|
|
49
|
+
|
|
50
|
+
@staticmethod
|
|
51
|
+
def prepare_token_pattern(tokens: list[str], ignore_default: bool) -> Pattern:
|
|
52
|
+
if not ignore_default:
|
|
53
|
+
tokens = [*DEFAULT_TOKENS, *tokens]
|
|
54
|
+
if not tokens:
|
|
55
|
+
# prevented by CLI (-i without -t) but you can never be too carefull
|
|
56
|
+
raise ValueError("no tokens to search for")
|
|
57
|
+
tokens = sorted(dict.fromkeys(tokens), key=len, reverse=True)
|
|
58
|
+
alternations = "|".join(re.escape(tok) for tok in tokens)
|
|
59
|
+
return re.compile(rf"(?<!\w)(?:{alternations})(?!\w)", re.IGNORECASE)
|
|
60
|
+
|
|
61
|
+
@staticmethod
|
|
62
|
+
def prepare_out(name: str) -> Writer:
|
|
63
|
+
if (path := Path(name)).is_dir():
|
|
64
|
+
path = path / DEFAULT_OUT
|
|
65
|
+
return Writer(path)
|
|
66
|
+
|
|
67
|
+
@cached_property
|
|
68
|
+
def out_path(self) -> Path:
|
|
69
|
+
return self.out.path.absolute()
|
|
70
|
+
|
|
71
|
+
def run(self) -> None:
|
|
72
|
+
if not self.root.exists():
|
|
73
|
+
raise FileNotFoundError(f"{self.root} not found")
|
|
74
|
+
|
|
75
|
+
if self.debug:
|
|
76
|
+
print("processing:")
|
|
77
|
+
try:
|
|
78
|
+
if self.root.is_file():
|
|
79
|
+
self.process_file(self.root)
|
|
80
|
+
elif self.root.is_dir():
|
|
81
|
+
self.process_dir(self.root, depth=0)
|
|
82
|
+
else:
|
|
83
|
+
raise ValueError(f"{self.root} must be path to file or dir")
|
|
84
|
+
finally:
|
|
85
|
+
self.out.close()
|
|
86
|
+
|
|
87
|
+
def process_file(self, path: Path) -> None:
|
|
88
|
+
if self.is_excluded(path):
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
if self.debug:
|
|
92
|
+
print(f"\t{self.prepare_path_name(path)}")
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
with path.open() as f:
|
|
96
|
+
lines_count = 0
|
|
97
|
+
header_added = False
|
|
98
|
+
while line := f.readline():
|
|
99
|
+
lines_count += 1
|
|
100
|
+
|
|
101
|
+
if not (match := self.token_pattern.search(line)):
|
|
102
|
+
continue
|
|
103
|
+
token = match.group()
|
|
104
|
+
idx = match.start()
|
|
105
|
+
|
|
106
|
+
if not header_added:
|
|
107
|
+
self.out.write_header(self.prepare_path_name(path))
|
|
108
|
+
header_added = True
|
|
109
|
+
|
|
110
|
+
if self.short:
|
|
111
|
+
line = line[idx:]
|
|
112
|
+
self.out.write_entry(lines_count, line)
|
|
113
|
+
continue
|
|
114
|
+
|
|
115
|
+
display_line_num = lines_count
|
|
116
|
+
for _ in range(1, self.get_snippet_count(line, start=idx + len(token))):
|
|
117
|
+
if not (next_line := f.readline()):
|
|
118
|
+
break
|
|
119
|
+
line += next_line
|
|
120
|
+
lines_count += 1
|
|
121
|
+
|
|
122
|
+
if not line.endswith("\n"):
|
|
123
|
+
line += "\n" # this is the last or only line in the file
|
|
124
|
+
|
|
125
|
+
if display_line_num != lines_count: # add full snippets
|
|
126
|
+
line = indent(dedent("\n" + line), "\t\t")
|
|
127
|
+
else:
|
|
128
|
+
line = dedent(line)
|
|
129
|
+
self.out.write_entry(display_line_num, line)
|
|
130
|
+
except UnicodeDecodeError as e:
|
|
131
|
+
print(f"skipping {self.prepare_path_name(path)}:\n{e}", file=sys.stderr)
|
|
132
|
+
|
|
133
|
+
@staticmethod
|
|
134
|
+
def get_snippet_count(line: str, start: int) -> int:
|
|
135
|
+
if not line.startswith("{", start):
|
|
136
|
+
return 1
|
|
137
|
+
|
|
138
|
+
# advance pointer to actual count
|
|
139
|
+
start = start + 1
|
|
140
|
+
if (end := line[start:].find("}")) == -1:
|
|
141
|
+
raise ValueError("invalid snippet count")
|
|
142
|
+
# cut snippet line count and try parse to int -> if it fails global error handler will log the error
|
|
143
|
+
return int(line[start : start + end])
|
|
144
|
+
|
|
145
|
+
def prepare_path_name(self, path: Path) -> str:
|
|
146
|
+
if self.full_path:
|
|
147
|
+
return str(path.absolute())
|
|
148
|
+
if self.recursive:
|
|
149
|
+
return str(path)
|
|
150
|
+
return path.name
|
|
151
|
+
|
|
152
|
+
def process_dir(self, path: Path, depth: int) -> None:
|
|
153
|
+
if self.is_excluded(path, is_dir=True):
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
if depth > self.max_depth:
|
|
157
|
+
return
|
|
158
|
+
|
|
159
|
+
dirs = []
|
|
160
|
+
for entry in path.iterdir():
|
|
161
|
+
if entry.is_file():
|
|
162
|
+
self.process_file(entry)
|
|
163
|
+
elif self.recursive and entry.is_dir():
|
|
164
|
+
dirs.append(entry)
|
|
165
|
+
|
|
166
|
+
for dir_ in dirs:
|
|
167
|
+
self.process_dir(dir_, depth + 1)
|
|
168
|
+
|
|
169
|
+
def is_excluded(self, path: Path, is_dir: bool = False) -> bool:
|
|
170
|
+
# always process scan root
|
|
171
|
+
if path == self.root:
|
|
172
|
+
return False
|
|
173
|
+
|
|
174
|
+
# skip summary file
|
|
175
|
+
if path.absolute() == self.out_path:
|
|
176
|
+
return True
|
|
177
|
+
|
|
178
|
+
rel_path = path.relative_to(self.root).as_posix()
|
|
179
|
+
if is_dir:
|
|
180
|
+
return any(self.exclude_spec.match_files([rel_path, rel_path + "/"]))
|
|
181
|
+
return self.exclude_spec.match_file(rel_path)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import TextIO
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class Writer:
|
|
6
|
+
def __init__(self, path: Path) -> None:
|
|
7
|
+
self.path = path
|
|
8
|
+
self.file: TextIO | None = None
|
|
9
|
+
|
|
10
|
+
def ensure_open(self) -> TextIO:
|
|
11
|
+
if self.file is None:
|
|
12
|
+
self.file = self.path.open("w")
|
|
13
|
+
return self.file
|
|
14
|
+
|
|
15
|
+
def close(self) -> None:
|
|
16
|
+
if self.file:
|
|
17
|
+
self.file.close()
|
|
18
|
+
self.file = None
|
|
19
|
+
|
|
20
|
+
def write_header(self, path: str) -> None:
|
|
21
|
+
f = self.ensure_open()
|
|
22
|
+
f.write("======================\n")
|
|
23
|
+
f.write(f"{path}\n")
|
|
24
|
+
|
|
25
|
+
def write_entry(self, lines_count: int, line: str) -> None:
|
|
26
|
+
f = self.ensure_open()
|
|
27
|
+
f.write(f"\tline {lines_count}: ")
|
|
28
|
+
f.write(line)
|