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 +1 -0
- srcloc/cli.py +269 -0
- srcloc/collect.py +180 -0
- srcloc/kinds.py +276 -0
- srcloc/langs.py +202 -0
- srcloc/tables.py +100 -0
- srcloc/views.py +247 -0
- srcloc-0.1.0.dist-info/METADATA +258 -0
- srcloc-0.1.0.dist-info/RECORD +13 -0
- srcloc-0.1.0.dist-info/WHEEL +5 -0
- srcloc-0.1.0.dist-info/entry_points.txt +2 -0
- srcloc-0.1.0.dist-info/licenses/LICENSE +674 -0
- srcloc-0.1.0.dist-info/top_level.txt +1 -0
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
|
+
)
|