srcloc 0.1.0__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.
srcloc/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Count doc, comment, config, testdata, test and code lines in source trees."""
srcloc/cli.py ADDED
@@ -0,0 +1,269 @@
1
+ """The command line: argument parsing and the two main modes."""
2
+
3
+ import argparse
4
+ import contextlib
5
+ import json
6
+ import os
7
+ import shlex
8
+ import subprocess
9
+ import sys
10
+ from collections import Counter
11
+
12
+ from .collect import count_changed, count_file, discover, looks_like_diff, parse_diff
13
+ from .langs import CATEGORIES, LANGS, file_lang
14
+ from .views import (
15
+ counts_json,
16
+ diff_json,
17
+ print_category_counts,
18
+ print_counts,
19
+ print_diff_counts,
20
+ print_diff_files,
21
+ print_files,
22
+ print_language_counts,
23
+ print_share_files,
24
+ print_unknown,
25
+ )
26
+
27
+ BASH_COMPLETION = """\
28
+ _srcloc() {{
29
+ local cur=${{COMP_WORDS[COMP_CWORD]}}
30
+ COMPREPLY=()
31
+ if [[ $cur == -* ]]; then
32
+ COMPREPLY=($(compgen -W "{flags}" -- "$cur"))
33
+ fi
34
+ }}
35
+ complete -o default -o bashdefault -F _srcloc srcloc"""
36
+
37
+
38
+ def output_pager():
39
+ """A git-like pager on a terminal:
40
+ $PAGER, defaulting to less, which quits
41
+ by itself when one screen suffices (-F)."""
42
+ if not getattr(sys.stdout, "isatty", lambda: False)():
43
+ return None
44
+ command = shlex.split(os.environ.get("PAGER") or "less")
45
+ if command in ([], ["cat"]):
46
+ return None
47
+ env = dict(os.environ)
48
+ env.setdefault("LESS", "FRX")
49
+ try:
50
+ return subprocess.Popen(command, stdin=subprocess.PIPE, text=True, env=env)
51
+ except OSError:
52
+ return None
53
+
54
+
55
+ class Paged:
56
+ """stdout writing into the pager, still a terminal
57
+ to the styler so colors survive into less -R."""
58
+
59
+ def __init__(self, proc):
60
+ self.proc = proc
61
+
62
+ def write(self, text):
63
+ return self.proc.stdin.write(text)
64
+
65
+ def flush(self):
66
+ self.proc.stdin.flush()
67
+
68
+ def isatty(self):
69
+ return True
70
+
71
+
72
+ def read_stdin():
73
+ if sys.stdin is None or sys.stdin.isatty():
74
+ return ""
75
+ try:
76
+ return sys.stdin.read()
77
+ except OSError:
78
+ return ""
79
+
80
+
81
+ def selected_cats(args):
82
+ return [cat for cat in CATEGORIES if getattr(args, cat)]
83
+
84
+
85
+ def shown_cats(args):
86
+ """Those a flag chose, else all but empty, which needs --with-empty."""
87
+ return selected_cats(args) or [
88
+ cat for cat in CATEGORIES if cat != "empty" or args.with_empty
89
+ ]
90
+
91
+
92
+ def share_mode(args):
93
+ return "row" if args.rows else "col" if args.columns else None
94
+
95
+
96
+ def selected_langs(args):
97
+ return [lang for lang in LANGS if getattr(args, f"lang_{lang}", False)]
98
+
99
+
100
+ def diff_main(text, args, out):
101
+ added = Counter()
102
+ removed = Counter()
103
+ unknown = []
104
+ per_file = []
105
+ cats = shown_cats(args)
106
+ langs = selected_langs(args)
107
+ for old_path, new_path, old_doc, new_doc in parse_diff(text.splitlines()):
108
+ rel = new_path or old_path
109
+ lang = file_lang(rel)
110
+ if lang is None:
111
+ unknown.append(str(rel))
112
+ continue
113
+ if langs and lang not in langs:
114
+ continue
115
+ file_added = Counter()
116
+ file_removed = Counter()
117
+ if old_path is not None:
118
+ count_changed(old_path, old_doc, file_removed)
119
+ if new_path is not None:
120
+ count_changed(new_path, new_doc, file_added)
121
+ added.update(file_added)
122
+ removed.update(file_removed)
123
+ if args.verbose:
124
+ per_file.append((str(rel), file_added, file_removed))
125
+ if args.json:
126
+ print(
127
+ json.dumps(diff_json(added, removed, per_file, unknown, cats, args)),
128
+ file=out,
129
+ )
130
+ return
131
+ if per_file and print_diff_files(per_file, cats, out):
132
+ print(file=out)
133
+ print_diff_counts(added, removed, cats, out)
134
+ if args.verbose > 1 and unknown:
135
+ print_unknown(unknown, "unknown files in diff", out)
136
+
137
+
138
+ def count_main(args, out):
139
+ counts = Counter()
140
+ unknown = []
141
+ per_file = []
142
+ selected = selected_cats(args)
143
+ cats = shown_cats(args)
144
+ langs = selected_langs(args)
145
+ for path, rel in discover(args.paths or ["."]):
146
+ kinds = count_file(path, rel, counts)
147
+ if kinds is None:
148
+ unknown.append(str(path))
149
+ elif args.verbose and (not langs or file_lang(rel) in langs):
150
+ per_file.append((path, rel, kinds))
151
+ if args.json:
152
+ print(json.dumps(counts_json(counts, per_file, unknown, cats, args)), file=out)
153
+ return
154
+ if per_file:
155
+ shown = (
156
+ print_share_files(per_file, cats, out)
157
+ if selected
158
+ else print_files(per_file, cats, out, share_mode(args))
159
+ )
160
+ if shown:
161
+ print(file=out)
162
+ if langs:
163
+ print_language_counts(counts, langs, cats, out)
164
+ elif selected:
165
+ print_category_counts(counts, cats, out)
166
+ else:
167
+ print_counts(counts, cats, out, share_mode(args))
168
+ if args.verbose > 1 and unknown:
169
+ print_unknown(unknown, "unknown files", out)
170
+
171
+
172
+ def main(argv=None):
173
+ parser = argparse.ArgumentParser(
174
+ prog="srcloc",
175
+ description="count doc, comment, config, testdata, test and code"
176
+ " lines in source trees",
177
+ epilog="a git diff piped on stdin is compared instead of counted;"
178
+ " every language row name is a flag too: --python, --rust,"
179
+ " --toml, ... shows that language with the categories as rows",
180
+ )
181
+ parser.add_argument(
182
+ "paths",
183
+ nargs="*",
184
+ metavar="PATH",
185
+ help="files or directories to count (default: current directory);"
186
+ " directories under git only count versioned and new files",
187
+ )
188
+ for cat in CATEGORIES:
189
+ parser.add_argument(
190
+ f"--{cat}",
191
+ action="store_true",
192
+ help=f"show only the {cat} distribution across languages",
193
+ )
194
+ for lang in LANGS:
195
+ if lang != "json": # --json is the output mode
196
+ parser.add_argument(
197
+ f"--{lang}",
198
+ dest=f"lang_{lang}",
199
+ action="store_true",
200
+ help=argparse.SUPPRESS,
201
+ )
202
+ parser.add_argument(
203
+ "--json",
204
+ action="store_true",
205
+ help="print one JSON object instead of tables; schema in the README",
206
+ )
207
+ shares = parser.add_mutually_exclusive_group()
208
+ shares.add_argument(
209
+ "-r",
210
+ "--rows",
211
+ action="store_true",
212
+ help="show each count's percent of its row's SUM",
213
+ )
214
+ shares.add_argument(
215
+ "-c",
216
+ "--columns",
217
+ action="store_true",
218
+ help="show each count's percent of its column's total",
219
+ )
220
+ parser.add_argument(
221
+ "--with-empty",
222
+ action="store_true",
223
+ help="count empty lines as an own category",
224
+ )
225
+ parser.add_argument(
226
+ "-v",
227
+ "--verbose",
228
+ action="count",
229
+ default=0,
230
+ help="show more: -v the per-file table, -vv also the unknown files",
231
+ )
232
+ parser.add_argument(
233
+ "--completion",
234
+ action="store_true",
235
+ help='print the bash completion script; eval "$(srcloc --completion)"',
236
+ )
237
+ args = parser.parse_args(argv)
238
+ pager = None
239
+ try:
240
+ if args.completion:
241
+ flags = [f for action in parser._actions for f in action.option_strings]
242
+ print(BASH_COMPLETION.format(flags=" ".join(flags)))
243
+ sys.stdout.flush()
244
+ return
245
+ text = "" if args.paths else read_stdin()
246
+ if text.strip() and not looks_like_diff(text):
247
+ raise SystemExit(
248
+ "srcloc: stdin is not a git diff; pass PATH arguments to count files"
249
+ )
250
+ pager = output_pager()
251
+ out = Paged(pager) if pager else sys.stdout
252
+ if text.strip():
253
+ diff_main(text, args, out)
254
+ else:
255
+ count_main(args, out)
256
+ out.flush()
257
+ if pager:
258
+ pager.stdin.close()
259
+ pager.wait()
260
+ except BrokenPipeError:
261
+ if pager is not None:
262
+ # quitting the pager early is a clean exit
263
+ with contextlib.suppress(BrokenPipeError):
264
+ pager.stdin.close()
265
+ pager.wait()
266
+ return
267
+ # `srcloc | head` closes stdout early; exit like other tools do.
268
+ os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno())
269
+ raise SystemExit(1) from None
srcloc/collect.py ADDED
@@ -0,0 +1,180 @@
1
+ """Input gathering: tree walking, file counting and diff parsing."""
2
+
3
+ import os
4
+ import re
5
+ import subprocess
6
+ from collections import Counter
7
+ from pathlib import Path
8
+
9
+ from .kinds import line_kinds
10
+ from .langs import MEDIA, file_lang
11
+
12
+ DIFF_HEADER = re.compile(r"^diff --git a/(.+) b/(.+)$")
13
+
14
+
15
+ def count_file(path, rel, counts):
16
+ """Count one file, None when it stays uncounted."""
17
+ lang = file_lang(rel)
18
+ if lang is None:
19
+ counts["unknown", "files"] += 1
20
+ return None
21
+ if lang in MEDIA:
22
+ counts[lang, "files"] += 1
23
+ return Counter()
24
+ try:
25
+ text = path.read_text(encoding="utf-8", errors="replace")
26
+ except OSError:
27
+ counts["unknown", "files"] += 1
28
+ return None
29
+ counts[lang, "files"] += 1
30
+ kinds = Counter(line_kinds(rel, text))
31
+ counts.update({(lang, kind): count for kind, count in kinds.items()})
32
+ return kinds
33
+
34
+
35
+ def excluded(
36
+ rel,
37
+ names=frozenset(
38
+ {"venv", "env", "node_modules", "target", "build", "dist", "__pycache__"}
39
+ ),
40
+ ):
41
+ """Ephemeral state, never counted, also where a repository
42
+ tracks it; of the dot directories only .github counts."""
43
+ return any(
44
+ part in names
45
+ or (part.startswith(".") and part != ".github")
46
+ or part.endswith(".egg-info")
47
+ for part in rel.parts
48
+ )
49
+
50
+
51
+ def iter_dir(directory):
52
+ """Yield paths relative to directory, from git where it answers.
53
+
54
+ ls-files also reports untracked but unignored files,
55
+ so work in progress counts while ignored state does not.
56
+ """
57
+ ls_files = "ls-files -z --cached --others --exclude-standard".split()
58
+ try:
59
+ listed = subprocess.run(
60
+ ["git", "-C", str(directory), *ls_files],
61
+ capture_output=True,
62
+ text=True,
63
+ check=False,
64
+ )
65
+ except OSError:
66
+ listed = None
67
+ if listed and not listed.returncode:
68
+ for name in listed.stdout.split("\0"):
69
+ if name:
70
+ yield Path(name)
71
+ return
72
+ for parent, dirnames, filenames in os.walk(directory):
73
+ rel_parent = Path(parent).relative_to(directory)
74
+ dirnames[:] = sorted(
75
+ name for name in dirnames if not excluded(rel_parent / name)
76
+ )
77
+ for name in sorted(filenames):
78
+ yield rel_parent / name
79
+
80
+
81
+ def seems_file(path):
82
+ """stat may be forbidden, as on TLS keys in a root-only directory;
83
+ keep such paths and let the failing read count them as unknown."""
84
+ try:
85
+ return path.is_file()
86
+ except OSError:
87
+ return True
88
+
89
+
90
+ def discover(targets):
91
+ """Yield (path on disk, path for classification) pairs."""
92
+ for target in targets:
93
+ top = Path(target)
94
+ if seems_file(top):
95
+ yield top, Path(top.name) if top.is_absolute() else top
96
+ elif top.is_dir():
97
+ for rel in iter_dir(top):
98
+ path = top / rel
99
+ if not excluded(rel) and seems_file(path):
100
+ yield path, rel
101
+ else:
102
+ raise SystemExit(f"srcloc: no such file or directory: {target}")
103
+
104
+
105
+ def parse_diff(lines):
106
+ """Split a unified diff into per-file old and new documents.
107
+
108
+ Yields (old path, new path, old doc, new doc),
109
+ where a doc pairs each line with a changed flag:
110
+ the context plus one side's changed lines in order,
111
+ so the classifiers see each version as a whole.
112
+ """
113
+
114
+ def strip_prefix(name):
115
+ if name in ("/dev/null", ""):
116
+ return None
117
+ if name.startswith(("a/", "b/")):
118
+ name = name[2:]
119
+ return Path(name)
120
+
121
+ header = old_path = new_path = None
122
+ old_doc = []
123
+ new_doc = []
124
+ in_hunk = False
125
+
126
+ def flush():
127
+ old, new = old_path, new_path
128
+ if old is None and new is None and header is not None:
129
+ match = DIFF_HEADER.match(header) # an entry without hunks, binary say
130
+ if match:
131
+ old, new = Path(match.group(1)), Path(match.group(2))
132
+ if old is not None or new is not None:
133
+ yield old, new, old_doc, new_doc
134
+
135
+ for line in lines:
136
+ if line.startswith("diff "):
137
+ yield from flush()
138
+ header = line
139
+ old_path = new_path = None
140
+ old_doc = []
141
+ new_doc = []
142
+ in_hunk = False
143
+ elif line.startswith("--- ") and not in_hunk:
144
+ old_path = strip_prefix(line[4:].split("\t")[0])
145
+ elif line.startswith("+++ ") and not in_hunk:
146
+ new_path = strip_prefix(line[4:].split("\t")[0])
147
+ elif line.startswith("@@"):
148
+ in_hunk = True
149
+ elif in_hunk and (not line or line.startswith(" ")):
150
+ old_doc.append((line[1:], False))
151
+ new_doc.append((line[1:], False))
152
+ elif in_hunk and line.startswith("-"):
153
+ old_doc.append((line[1:], True))
154
+ elif in_hunk and line.startswith("+"):
155
+ new_doc.append((line[1:], True))
156
+ elif in_hunk and line.startswith("\\"):
157
+ pass # "No newline at end of file"
158
+ else:
159
+ in_hunk = False
160
+ yield from flush()
161
+
162
+
163
+ def count_changed(rel, doc, counts):
164
+ """Add the document's changed lines to counts."""
165
+ kinds = line_kinds(rel, "\n".join(line for line, _ in doc))
166
+ kinds += ["empty"] * (len(doc) - len(kinds)) # the join lost trailing empties
167
+ lang = file_lang(rel)
168
+ counts.update((lang, kind) for kind, (_, changed) in zip(kinds, doc) if changed)
169
+
170
+
171
+ def looks_like_diff(text):
172
+ """Whether piped input is a unified diff:
173
+ a git per-file header, or the ---/+++ pair
174
+ a headerless `diff -u` opens with."""
175
+ lines = text.splitlines()[:400]
176
+ return any(
177
+ line.startswith("diff ")
178
+ or (line.startswith("--- ") and later.startswith("+++ "))
179
+ for line, later in zip(lines, lines[1:] + [""])
180
+ )