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 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
@@ -0,0 +1,8 @@
1
+ """Allow ``python -m hmh`` as an alternative to the ``hmh`` console script."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ raise SystemExit(main())
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)