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 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
+ ![Python 3.X](https://img.shields.io/badge/python-^3.12-blue?style=flat-square&logo=Python&logoColor=white)
20
+ [![PyPI](https://img.shields.io/pypi/v/todex.svg)](https://pypi.org/project/todex/)
21
+ [![Downloads](https://static.pepy.tech/badge/todex)](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
+ ![Python 3.X](https://img.shields.io/badge/python-^3.12-blue?style=flat-square&logo=Python&logoColor=white)
8
+ [![PyPI](https://img.shields.io/pypi/v/todex.svg)](https://pypi.org/project/todex/)
9
+ [![Downloads](https://static.pepy.tech/badge/todex)](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)