hmh 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.
- hmh/__init__.py +38 -0
- hmh/__main__.py +8 -0
- hmh/analyzer.py +257 -0
- hmh/cli.py +110 -0
- hmh/formatters.py +189 -0
- hmh/gitignore.py +136 -0
- hmh/languages.py +363 -0
- hmh/models.py +160 -0
- hmh/py.typed +0 -0
- hmh-0.1.0.dist-info/METADATA +280 -0
- hmh-0.1.0.dist-info/RECORD +14 -0
- hmh-0.1.0.dist-info/WHEEL +4 -0
- hmh-0.1.0.dist-info/entry_points.txt +2 -0
- hmh-0.1.0.dist-info/licenses/LICENSE +21 -0
hmh/__init__.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""hmh — a read-only project analyzer.
|
|
2
|
+
|
|
3
|
+
Point it at any folder and get back a full picture of what is inside: how many
|
|
4
|
+
lines, in how many languages, how big everything is — **without a single byte
|
|
5
|
+
ever being written, moved or modified**.
|
|
6
|
+
|
|
7
|
+
::
|
|
8
|
+
|
|
9
|
+
import hmh
|
|
10
|
+
|
|
11
|
+
report = hmh.analyze("~/projects/myapp")
|
|
12
|
+
print(f"{report.code_lines} code lines across {len(report.languages)} languages")
|
|
13
|
+
|
|
14
|
+
for lang in report.top(5):
|
|
15
|
+
print(f"{lang.name:<15} {lang.code:>7,}")
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from .analyzer import analyze, analyze_many
|
|
21
|
+
from .formatters import format_report
|
|
22
|
+
from .gitignore import GitIgnore
|
|
23
|
+
from .languages import known_languages
|
|
24
|
+
from .models import FileStat, LanguageStats, Report
|
|
25
|
+
|
|
26
|
+
__version__ = "0.1.0"
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"FileStat",
|
|
30
|
+
"GitIgnore",
|
|
31
|
+
"LanguageStats",
|
|
32
|
+
"Report",
|
|
33
|
+
"__version__",
|
|
34
|
+
"analyze",
|
|
35
|
+
"analyze_many",
|
|
36
|
+
"format_report",
|
|
37
|
+
"known_languages",
|
|
38
|
+
]
|
hmh/__main__.py
ADDED
hmh/analyzer.py
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""The read-only walker that turns a directory into a :class:`Report`.
|
|
2
|
+
|
|
3
|
+
**hmh never writes, moves or modifies anything it inspects.** Every file is
|
|
4
|
+
opened read-only, and binary blobs are only measured, never parsed.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import fnmatch
|
|
10
|
+
import os
|
|
11
|
+
import time
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Dict, Iterable, Iterator, List, Optional, Sequence, Tuple, Union
|
|
14
|
+
|
|
15
|
+
from .gitignore import GitIgnore
|
|
16
|
+
from .languages import (
|
|
17
|
+
BINARY_EXTENSIONS,
|
|
18
|
+
DEFAULT_EXCLUDE_DIRS,
|
|
19
|
+
DEFAULT_EXCLUDE_PATTERNS,
|
|
20
|
+
count_lines,
|
|
21
|
+
detect,
|
|
22
|
+
)
|
|
23
|
+
from .models import FileStat, LanguageStats, Report
|
|
24
|
+
|
|
25
|
+
PathLike = Union[str, Path]
|
|
26
|
+
|
|
27
|
+
#: Files never worth counting, wherever they live.
|
|
28
|
+
IGNORED_FILES = frozenset({".ds_store", "thumbs.db", "desktop.ini"})
|
|
29
|
+
|
|
30
|
+
#: How many leading bytes are sniffed for a NUL byte.
|
|
31
|
+
_SNIFF_BYTES = 8192
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _is_hidden(name: str) -> bool:
|
|
35
|
+
return name.startswith(".")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _excluded_dir(name: str) -> bool:
|
|
39
|
+
if name.lower() in DEFAULT_EXCLUDE_DIRS:
|
|
40
|
+
return True
|
|
41
|
+
return any(fnmatch.fnmatch(name, pattern) for pattern in DEFAULT_EXCLUDE_PATTERNS)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _read_text(path: str, size: int, max_file_bytes: int) -> Tuple[Optional[str], Optional[str]]:
|
|
45
|
+
"""Return ``(text, skip_reason)``. ``text is None`` means the file was skipped."""
|
|
46
|
+
if max_file_bytes > 0 and size > max_file_bytes:
|
|
47
|
+
return None, "too-large"
|
|
48
|
+
try:
|
|
49
|
+
with open(path, "rb") as handle:
|
|
50
|
+
raw = handle.read()
|
|
51
|
+
except OSError as exc:
|
|
52
|
+
return None, f"unreadable:{type(exc).__name__}"
|
|
53
|
+
if b"\x00" in raw[:_SNIFF_BYTES]:
|
|
54
|
+
return None, "binary-content"
|
|
55
|
+
for encoding in ("utf-8-sig", "cp1252", "latin-1"):
|
|
56
|
+
try:
|
|
57
|
+
return raw.decode(encoding), None
|
|
58
|
+
except UnicodeDecodeError:
|
|
59
|
+
continue
|
|
60
|
+
return raw.decode("utf-8", errors="replace"), None # pragma: no cover
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _iter_files(
|
|
64
|
+
root: Path,
|
|
65
|
+
*,
|
|
66
|
+
include_hidden: bool,
|
|
67
|
+
gitignore: GitIgnore,
|
|
68
|
+
follow_symlinks: bool,
|
|
69
|
+
) -> Iterator[Tuple[str, str]]:
|
|
70
|
+
"""Yield ``(abspath, relative_posix)`` for every file worth measuring."""
|
|
71
|
+
stack = [str(root)]
|
|
72
|
+
while stack:
|
|
73
|
+
current = stack.pop()
|
|
74
|
+
try:
|
|
75
|
+
entries = list(os.scandir(current))
|
|
76
|
+
except OSError:
|
|
77
|
+
continue
|
|
78
|
+
for entry in entries:
|
|
79
|
+
name = entry.name
|
|
80
|
+
if name.lower() in IGNORED_FILES:
|
|
81
|
+
continue
|
|
82
|
+
try:
|
|
83
|
+
is_dir = entry.is_dir(follow_symlinks=follow_symlinks)
|
|
84
|
+
except OSError:
|
|
85
|
+
continue
|
|
86
|
+
relative = os.path.relpath(entry.path, root).replace(os.sep, "/")
|
|
87
|
+
|
|
88
|
+
if is_dir:
|
|
89
|
+
if not include_hidden and _is_hidden(name):
|
|
90
|
+
continue
|
|
91
|
+
if _excluded_dir(name):
|
|
92
|
+
continue
|
|
93
|
+
if gitignore.matches(relative):
|
|
94
|
+
continue
|
|
95
|
+
stack.append(entry.path)
|
|
96
|
+
continue
|
|
97
|
+
|
|
98
|
+
# Dotfiles are meaningful (``.gitignore``, ``.editorconfig``) and are
|
|
99
|
+
# always counted; environment secrets are not.
|
|
100
|
+
if name.lower().startswith(".env"):
|
|
101
|
+
continue
|
|
102
|
+
if gitignore.matches(relative):
|
|
103
|
+
continue
|
|
104
|
+
yield entry.path, relative
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def analyze(
|
|
108
|
+
path: PathLike = ".",
|
|
109
|
+
*,
|
|
110
|
+
include_hidden: bool = False,
|
|
111
|
+
exclude: Sequence[str] = (),
|
|
112
|
+
respect_gitignore: bool = True,
|
|
113
|
+
follow_symlinks: bool = False,
|
|
114
|
+
max_file_bytes: int = 20_000_000,
|
|
115
|
+
extra_ignores: Sequence[str] = (),
|
|
116
|
+
) -> Report:
|
|
117
|
+
"""Analyse a project directory (or a single file) and return a :class:`Report`.
|
|
118
|
+
|
|
119
|
+
Parameters
|
|
120
|
+
----------
|
|
121
|
+
path:
|
|
122
|
+
Directory to walk, or a single file to measure.
|
|
123
|
+
include_hidden:
|
|
124
|
+
Also walk dot-directories such as ``.github`` or ``.venv``.
|
|
125
|
+
exclude:
|
|
126
|
+
Extra gitignore-style patterns to skip, e.g. ``("tests/*.snap",)``.
|
|
127
|
+
respect_gitignore:
|
|
128
|
+
Honour the project's ``.gitignore`` (on by default).
|
|
129
|
+
follow_symlinks:
|
|
130
|
+
Traverse symbolic links instead of skipping them.
|
|
131
|
+
max_file_bytes:
|
|
132
|
+
Files larger than this are counted but not line-scanned.
|
|
133
|
+
extra_ignores:
|
|
134
|
+
More directory names to skip, merged with the built-in defaults.
|
|
135
|
+
"""
|
|
136
|
+
started = time.perf_counter()
|
|
137
|
+
root = Path(path).expanduser()
|
|
138
|
+
|
|
139
|
+
if root.is_file():
|
|
140
|
+
return _analyze_single_file(root)
|
|
141
|
+
|
|
142
|
+
if not root.exists():
|
|
143
|
+
raise FileNotFoundError(f"no such path: {root}")
|
|
144
|
+
if not root.is_dir():
|
|
145
|
+
raise NotADirectoryError(f"not a directory: {root}")
|
|
146
|
+
|
|
147
|
+
root = root.resolve()
|
|
148
|
+
|
|
149
|
+
gitignore = GitIgnore.from_root(root) if respect_gitignore else GitIgnore.empty()
|
|
150
|
+
if exclude:
|
|
151
|
+
gitignore.extend(exclude)
|
|
152
|
+
if extra_ignores:
|
|
153
|
+
gitignore.extend(extra_ignores)
|
|
154
|
+
|
|
155
|
+
files: List[FileStat] = []
|
|
156
|
+
for abspath, relative in _iter_files(
|
|
157
|
+
root,
|
|
158
|
+
include_hidden=include_hidden,
|
|
159
|
+
gitignore=gitignore,
|
|
160
|
+
follow_symlinks=follow_symlinks,
|
|
161
|
+
):
|
|
162
|
+
files.append(_measure(abspath, relative, max_file_bytes))
|
|
163
|
+
|
|
164
|
+
files.sort(key=lambda item: item.path)
|
|
165
|
+
report = _summarize(str(root), files)
|
|
166
|
+
report.duration = time.perf_counter() - started
|
|
167
|
+
return report
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _analyze_single_file(path: Path) -> Report:
|
|
171
|
+
stat = path.stat()
|
|
172
|
+
files = [_measure(str(path), path.name, 0, forced_size=stat.st_size)]
|
|
173
|
+
report = _summarize(str(path.parent), files)
|
|
174
|
+
report.duration = 0.0
|
|
175
|
+
return report
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _measure(abspath: str, relative: str, max_file_bytes: int,
|
|
179
|
+
forced_size: Optional[int] = None) -> FileStat:
|
|
180
|
+
try:
|
|
181
|
+
size = forced_size if forced_size is not None else os.path.getsize(abspath)
|
|
182
|
+
except OSError:
|
|
183
|
+
return FileStat(path=relative, language=None, size=0,
|
|
184
|
+
is_binary=False, skipped="stat-failed")
|
|
185
|
+
|
|
186
|
+
suffix = Path(relative).suffix.lower()
|
|
187
|
+
if suffix in BINARY_EXTENSIONS:
|
|
188
|
+
return FileStat(path=relative, language=None, size=size, is_binary=True)
|
|
189
|
+
|
|
190
|
+
try:
|
|
191
|
+
with open(abspath, "rb") as handle:
|
|
192
|
+
head = handle.read(256)
|
|
193
|
+
except OSError:
|
|
194
|
+
head = b""
|
|
195
|
+
|
|
196
|
+
language = detect(relative, head)
|
|
197
|
+
text, reason = _read_text(abspath, size, max_file_bytes)
|
|
198
|
+
|
|
199
|
+
if text is None:
|
|
200
|
+
return FileStat(
|
|
201
|
+
path=relative,
|
|
202
|
+
language=language,
|
|
203
|
+
size=size,
|
|
204
|
+
is_binary=(reason == "binary-content"),
|
|
205
|
+
skipped=reason,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
code, comments, blank = count_lines(text, language)
|
|
209
|
+
total = code + comments + blank
|
|
210
|
+
return FileStat(
|
|
211
|
+
path=relative,
|
|
212
|
+
language=language,
|
|
213
|
+
size=size,
|
|
214
|
+
lines=total,
|
|
215
|
+
code=code,
|
|
216
|
+
comments=comments,
|
|
217
|
+
blank=blank,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _summarize(root: str, files: List[FileStat]) -> Report:
|
|
222
|
+
report = Report(root=root, files=files)
|
|
223
|
+
report.total_files = len(files)
|
|
224
|
+
|
|
225
|
+
per_language: Dict[str, LanguageStats] = {}
|
|
226
|
+
for item in files:
|
|
227
|
+
report.total_bytes += item.size
|
|
228
|
+
if item.is_binary:
|
|
229
|
+
report.binary_files += 1
|
|
230
|
+
continue
|
|
231
|
+
if item.language is None:
|
|
232
|
+
continue
|
|
233
|
+
|
|
234
|
+
report.source_files += 1
|
|
235
|
+
report.source_bytes += item.size
|
|
236
|
+
report.total_lines += item.lines
|
|
237
|
+
report.code_lines += item.code
|
|
238
|
+
report.comment_lines += item.comments
|
|
239
|
+
report.blank_lines += item.blank
|
|
240
|
+
|
|
241
|
+
stats = per_language.get(item.language)
|
|
242
|
+
if stats is None:
|
|
243
|
+
stats = per_language[item.language] = LanguageStats(name=item.language)
|
|
244
|
+
stats.files += 1
|
|
245
|
+
stats.bytes += item.size
|
|
246
|
+
stats.lines += item.lines
|
|
247
|
+
stats.code += item.code
|
|
248
|
+
stats.comments += item.comments
|
|
249
|
+
stats.blank += item.blank
|
|
250
|
+
|
|
251
|
+
report.languages = sorted(per_language.values(), key=lambda s: (-s.code, s.name))
|
|
252
|
+
return report
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def analyze_many(paths: Iterable[PathLike], **options: object) -> List[Report]:
|
|
256
|
+
"""Analyse several projects and return one :class:`Report` per path."""
|
|
257
|
+
return [analyze(entry, **options) for entry in paths] # type: ignore[arg-type]
|
hmh/cli.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Command line interface: ``hmh .`` or ``hmh --format json .``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Iterable, List, Optional, Sequence
|
|
9
|
+
|
|
10
|
+
from . import __version__
|
|
11
|
+
from .analyzer import analyze
|
|
12
|
+
from .formatters import FORMATS, format_report
|
|
13
|
+
from .languages import known_languages
|
|
14
|
+
|
|
15
|
+
SORTS = ("code", "lines", "files", "bytes", "name")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
19
|
+
parser = argparse.ArgumentParser(
|
|
20
|
+
prog="hmh",
|
|
21
|
+
description="Analyse a project without modifying a single byte:\n"
|
|
22
|
+
"lines of code, languages, sizes and structure.",
|
|
23
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
24
|
+
epilog=(
|
|
25
|
+
"examples:\n"
|
|
26
|
+
" hmh .\n"
|
|
27
|
+
" hmh ~/projects/myapp --top 10 --files 5\n"
|
|
28
|
+
" hmh . --format markdown -o REPORT.md\n"
|
|
29
|
+
" hmh . --format json | jq '.totals'\n"
|
|
30
|
+
" hmh --languages\n"
|
|
31
|
+
),
|
|
32
|
+
)
|
|
33
|
+
parser.add_argument(
|
|
34
|
+
"path", nargs="?", default=".",
|
|
35
|
+
help="directory (or single file) to analyse (default: current directory)",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
show = parser.add_argument_group("what to show")
|
|
39
|
+
show.add_argument("--format", choices=FORMATS, default="table")
|
|
40
|
+
show.add_argument("--top", type=int, metavar="N", help="only the N biggest languages")
|
|
41
|
+
show.add_argument("--sort", choices=SORTS, default="code")
|
|
42
|
+
show.add_argument("--files", type=int, default=0, metavar="N",
|
|
43
|
+
help="also list the N largest source files")
|
|
44
|
+
show.add_argument("--languages", action="store_true",
|
|
45
|
+
help="print every language hmh knows and exit")
|
|
46
|
+
|
|
47
|
+
walk = parser.add_argument_group("scanning")
|
|
48
|
+
walk.add_argument("--include-hidden", action="store_true",
|
|
49
|
+
help="also walk dot-directories (.github, .venv, …)")
|
|
50
|
+
walk.add_argument("--no-gitignore", action="store_true",
|
|
51
|
+
help="ignore the project's .gitignore and scan everything")
|
|
52
|
+
walk.add_argument("--exclude", action="append", default=[], metavar="PATTERN",
|
|
53
|
+
help="extra gitignore-style pattern to skip (repeatable)")
|
|
54
|
+
walk.add_argument("--follow-symlinks", action="store_true")
|
|
55
|
+
walk.add_argument("--max-file-mb", type=int, default=20, metavar="MB",
|
|
56
|
+
help="skip line-counting above this size (default: 20)")
|
|
57
|
+
|
|
58
|
+
out = parser.add_argument_group("output")
|
|
59
|
+
out.add_argument("-o", "--out", metavar="FILE", help="write the report to a file")
|
|
60
|
+
out.add_argument("-q", "--quiet", action="store_true", help="print nothing, only the exit code")
|
|
61
|
+
parser.add_argument("--version", action="version", version=f"hmh {__version__}")
|
|
62
|
+
return parser
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def main(argv: Optional[Iterable[str]] = None) -> int:
|
|
66
|
+
args = build_parser().parse_args(list(argv) if argv is not None else None)
|
|
67
|
+
try:
|
|
68
|
+
sys.stdout.reconfigure(encoding="utf-8") # Windows consoles
|
|
69
|
+
except (AttributeError, OSError):
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
if args.languages:
|
|
73
|
+
print("\n".join(known_languages()))
|
|
74
|
+
return 0
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
report = analyze(
|
|
78
|
+
args.path,
|
|
79
|
+
include_hidden=args.include_hidden,
|
|
80
|
+
exclude=args.exclude,
|
|
81
|
+
respect_gitignore=not args.no_gitignore,
|
|
82
|
+
follow_symlinks=args.follow_symlinks,
|
|
83
|
+
max_file_bytes=args.max_file_mb * 1_000_000,
|
|
84
|
+
)
|
|
85
|
+
except (FileNotFoundError, NotADirectoryError, PermissionError) as exc:
|
|
86
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
87
|
+
return 2
|
|
88
|
+
except OSError as exc: # pragma: no cover - defensive
|
|
89
|
+
print(f"error: {type(exc).__name__}: {exc}", file=sys.stderr)
|
|
90
|
+
return 2
|
|
91
|
+
|
|
92
|
+
rendered = format_report(
|
|
93
|
+
report, args.format, top=args.top, sort=args.sort, show_files=args.files
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
if args.out:
|
|
97
|
+
# `Path.write_text(newline=...)` needs Python 3.10+, so open explicitly
|
|
98
|
+
# to keep reports LF-identical on every platform.
|
|
99
|
+
with open(args.out, "w", encoding="utf-8", newline="\n") as handle:
|
|
100
|
+
handle.write(rendered + "\n")
|
|
101
|
+
if not args.quiet:
|
|
102
|
+
print(f"wrote {args.out}", file=sys.stderr)
|
|
103
|
+
elif not args.quiet:
|
|
104
|
+
print(rendered)
|
|
105
|
+
|
|
106
|
+
return 0
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
if __name__ == "__main__": # pragma: no cover
|
|
110
|
+
raise SystemExit(main())
|
hmh/formatters.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Render a :class:`~hmh.models.Report` as text, JSON, CSV or Markdown."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import io
|
|
7
|
+
import json
|
|
8
|
+
from typing import List, Optional, Sequence
|
|
9
|
+
|
|
10
|
+
from .models import LanguageStats, Report
|
|
11
|
+
|
|
12
|
+
FORMATS = ("table", "json", "csv", "markdown")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _number(value: int) -> str:
|
|
16
|
+
return f"{value:,}"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _columns(headers: Sequence[str], rows: Sequence[Sequence[str]],
|
|
20
|
+
right_from: int = 1) -> str:
|
|
21
|
+
"""Left-align the first ``right_from`` columns, right-align the rest."""
|
|
22
|
+
widths = [len(header) for header in headers]
|
|
23
|
+
for row in rows:
|
|
24
|
+
for index, cell in enumerate(row):
|
|
25
|
+
widths[index] = max(widths[index], len(cell))
|
|
26
|
+
|
|
27
|
+
def line(cells: Sequence[str]) -> str:
|
|
28
|
+
rendered = []
|
|
29
|
+
for index, cell in enumerate(cells):
|
|
30
|
+
rendered.append(
|
|
31
|
+
cell.ljust(widths[index]) if index < right_from
|
|
32
|
+
else cell.rjust(widths[index])
|
|
33
|
+
)
|
|
34
|
+
return " ".join(rendered).rstrip()
|
|
35
|
+
|
|
36
|
+
out = [line(headers), " ".join("-" * width for width in widths)]
|
|
37
|
+
out.extend(line(row) for row in rows)
|
|
38
|
+
return "\n".join(out)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _language_rows(report: Report, top: Optional[int],
|
|
42
|
+
sort: str) -> List[LanguageStats]:
|
|
43
|
+
languages = report.languages
|
|
44
|
+
if sort == "bytes":
|
|
45
|
+
languages = report.languages_by_bytes
|
|
46
|
+
elif sort == "lines":
|
|
47
|
+
languages = sorted(languages, key=lambda s: (-s.lines, s.name))
|
|
48
|
+
elif sort == "files":
|
|
49
|
+
languages = sorted(languages, key=lambda s: (-s.files, s.name))
|
|
50
|
+
elif sort == "name":
|
|
51
|
+
languages = sorted(languages, key=lambda s: s.name)
|
|
52
|
+
else:
|
|
53
|
+
languages = report.languages_by_code
|
|
54
|
+
return languages[:top] if top else languages
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _file_rows(report: Report, top: int) -> List[List[str]]:
|
|
58
|
+
return [
|
|
59
|
+
[item.path, item.language or ("binary" if item.is_binary else "unknown"),
|
|
60
|
+
_number(item.code), item.size_human]
|
|
61
|
+
for item in report.densest_files[:top]
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def format_table(report: Report, *, top: Optional[int] = None, sort: str = "code",
|
|
66
|
+
show_files: int = 0) -> str:
|
|
67
|
+
languages = _language_rows(report, top, sort)
|
|
68
|
+
lines: List[str] = []
|
|
69
|
+
|
|
70
|
+
lines.append("hmh · read-only project analysis")
|
|
71
|
+
lines.append(f"path {report.root}")
|
|
72
|
+
lines.append(
|
|
73
|
+
f"scanned {_number(report.total_files)} files "
|
|
74
|
+
f"({_number(report.source_files)} source · {_number(report.binary_files)} binary)"
|
|
75
|
+
+ (f" · {report.excluded_dirs} excluded" if report.excluded_dirs else "")
|
|
76
|
+
)
|
|
77
|
+
lines.append(f"size {report.size_human}")
|
|
78
|
+
lines.append(
|
|
79
|
+
f"lines {_number(report.total_lines)} "
|
|
80
|
+
f"= code {_number(report.code_lines)} · "
|
|
81
|
+
f"comments {_number(report.comment_lines)} · "
|
|
82
|
+
f"blank {_number(report.blank_lines)}"
|
|
83
|
+
)
|
|
84
|
+
lines.append(f"comments {report.comments_per_code} per code line")
|
|
85
|
+
lines.append(f"duration {report.duration * 1000:.0f} ms")
|
|
86
|
+
|
|
87
|
+
if languages:
|
|
88
|
+
lines.append("")
|
|
89
|
+
headers = ("LANGUAGE", "FILES", "CODE", "COMMENTS", "BLANK", "SHARE")
|
|
90
|
+
rows = [
|
|
91
|
+
[
|
|
92
|
+
stats.name,
|
|
93
|
+
_number(stats.files),
|
|
94
|
+
_number(stats.code),
|
|
95
|
+
_number(stats.comments),
|
|
96
|
+
_number(stats.blank),
|
|
97
|
+
f"{stats.share_of(report.code_lines):.1f}%",
|
|
98
|
+
]
|
|
99
|
+
for stats in languages
|
|
100
|
+
]
|
|
101
|
+
lines.append(_columns(headers, rows, right_from=1))
|
|
102
|
+
if top and len(report.languages) > len(languages):
|
|
103
|
+
lines.append(
|
|
104
|
+
f"... and {len(report.languages) - len(languages)} more languages"
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
if show_files:
|
|
108
|
+
files = _file_rows(report, show_files)
|
|
109
|
+
if files:
|
|
110
|
+
lines.append("")
|
|
111
|
+
lines.append(f"Largest source files (by code lines)")
|
|
112
|
+
lines.append(
|
|
113
|
+
_columns(("FILE", "LANGUAGE", "CODE", "SIZE"), files, right_from=2)
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
return "\n".join(lines)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def format_markdown(report: Report, *, top: Optional[int] = None,
|
|
120
|
+
sort: str = "code") -> str:
|
|
121
|
+
languages = _language_rows(report, top, sort)
|
|
122
|
+
out: List[str] = []
|
|
123
|
+
out.append(f"### Project analysis — `{report.root}`")
|
|
124
|
+
out.append("")
|
|
125
|
+
out.append(
|
|
126
|
+
f"> {_number(report.total_files)} files · {report.size_human} · "
|
|
127
|
+
f"**{_number(report.code_lines)}** code lines · "
|
|
128
|
+
f"{_number(report.comment_lines)} comments · "
|
|
129
|
+
f"{_number(report.blank_lines)} blank · "
|
|
130
|
+
f"{report.duration * 1000:.0f} ms"
|
|
131
|
+
)
|
|
132
|
+
out.append("")
|
|
133
|
+
if languages:
|
|
134
|
+
out.append("| Language | Files | Code | Comments | Blank | Share |")
|
|
135
|
+
out.append("| --- | ---: | ---: | ---: | ---: | ---: |")
|
|
136
|
+
for stats in languages:
|
|
137
|
+
out.append(
|
|
138
|
+
f"| {stats.name} | {stats.files} | {_number(stats.code)} | "
|
|
139
|
+
f"{_number(stats.comments)} | {_number(stats.blank)} | "
|
|
140
|
+
f"{stats.share_of(report.code_lines):.1f}% |"
|
|
141
|
+
)
|
|
142
|
+
out.append("")
|
|
143
|
+
out.append("_Generated by [hmh](https://pypi.org/project/hmh/) — read-only._")
|
|
144
|
+
return "\n".join(out)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def format_csv(report: Report, *, top: Optional[int] = None,
|
|
148
|
+
sort: str = "code") -> str:
|
|
149
|
+
buffer = io.StringIO(newline="")
|
|
150
|
+
writer = csv.writer(buffer)
|
|
151
|
+
writer.writerow(
|
|
152
|
+
["language", "files", "bytes", "lines", "code", "comments", "blank", "share_percent"]
|
|
153
|
+
)
|
|
154
|
+
for stats in _language_rows(report, top, sort):
|
|
155
|
+
writer.writerow([
|
|
156
|
+
stats.name, stats.files, stats.bytes, stats.lines,
|
|
157
|
+
stats.code, stats.comments, stats.blank,
|
|
158
|
+
stats.share_of(report.code_lines),
|
|
159
|
+
])
|
|
160
|
+
return buffer.getvalue()
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def format_json(report: Report, *, top: Optional[int] = None, sort: str = "code",
|
|
164
|
+
show_files: int = 0) -> str:
|
|
165
|
+
payload = report.to_dict()
|
|
166
|
+
payload["languages"] = [s.to_dict() for s in _language_rows(report, top, sort)]
|
|
167
|
+
if show_files:
|
|
168
|
+
payload["largest_files"] = [f.to_dict() for f in report.densest_files[:show_files]]
|
|
169
|
+
else:
|
|
170
|
+
payload.pop("files", None)
|
|
171
|
+
return json.dumps(payload, ensure_ascii=False, indent=2)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def format_report(
|
|
175
|
+
report: Report,
|
|
176
|
+
fmt: str = "table",
|
|
177
|
+
*,
|
|
178
|
+
top: Optional[int] = None,
|
|
179
|
+
sort: str = "code",
|
|
180
|
+
show_files: int = 0,
|
|
181
|
+
) -> str:
|
|
182
|
+
"""Render ``report`` in one of :data:`FORMATS`."""
|
|
183
|
+
if fmt == "json":
|
|
184
|
+
return format_json(report, top=top, sort=sort, show_files=show_files)
|
|
185
|
+
if fmt == "csv":
|
|
186
|
+
return format_csv(report, top=top, sort=sort)
|
|
187
|
+
if fmt == "markdown":
|
|
188
|
+
return format_markdown(report, top=top, sort=sort)
|
|
189
|
+
return format_table(report, top=top, sort=sort, show_files=show_files)
|