codeimprint 0.5.0a11__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.
@@ -0,0 +1,340 @@
1
+ """The line count of a seal (docs/line-count.md): the size a project is priced by.
2
+
3
+ Non-blank lines in the files Git tracks at the sealed commit, without binary, vendored and
4
+ generated files (path and content rules below, then ``linguist-vendored`` and
5
+ ``linguist-generated`` from ``.gitattributes``, read by ``git check-attr``). The count is sent
6
+ with the seal and kept in the Imprint's public header, so the digest you sign covers it.
7
+
8
+ This is counting only: nothing here looks at what the code means. It stays fast on large
9
+ repositories: one ``git check-attr`` and one ``git cat-file --batch`` process per count, path
10
+ rules before any content is read, and a per-blob cache in the Git directory (blobs are
11
+ content-addressed, so a count never goes stale).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ import re
19
+ import subprocess
20
+ import threading
21
+ from dataclasses import dataclass, field
22
+ from pathlib import Path, PurePosixPath
23
+
24
+ RULES = "lines/1"
25
+
26
+ # -- the rules (the same as the engine's spot-check) -------------------------------------------
27
+ VENDORED_DIRS = frozenset({
28
+ "node_modules", "bower_components", "jspm_packages", "vendor", "vendors", "third_party",
29
+ "third-party", "thirdparty", "Pods", "Carthage", ".yarn", ".pnpm-store", "dist", "build",
30
+ "__pycache__", ".venv", "venv", "site-packages",
31
+ })
32
+ GENERATED_NAMES = frozenset({
33
+ "package-lock.json", "npm-shrinkwrap.json", "yarn.lock", "pnpm-lock.yaml", "bun.lock",
34
+ "bun.lockb", "deno.lock", "poetry.lock", "Pipfile.lock", "uv.lock", "pdm.lock",
35
+ "Cargo.lock", "Gemfile.lock", "composer.lock", "go.sum", "mix.lock", "pubspec.lock",
36
+ "Podfile.lock", "Package.resolved", "packages.lock.json", "flake.lock", "gradle.lockfile",
37
+ "conan.lock", "paket.lock",
38
+ })
39
+ GENERATED_SUFFIXES = (
40
+ ".min.js", ".min.css", ".min.mjs", ".min.cjs", ".js.map", ".css.map", ".map",
41
+ "_pb2.py", "_pb2.pyi", "_pb2_grpc.py", ".pb.go", ".pb.cc", ".pb.h", "_pb.js", "_pb.ts",
42
+ "_grpc_pb.js", "_grpc_pb.ts", ".g.dart", ".freezed.dart", ".designer.cs",
43
+ )
44
+ _MINIFIED = re.compile(r"\.min\.[A-Za-z0-9]+$")
45
+ _GENERATED_MARK = re.compile(rb"(?:Code generated .{0,200}DO NOT EDIT|@generated\b)")
46
+ BINARY_SUFFIXES = frozenset({
47
+ ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ico", ".icns", ".webp", ".avif", ".tif",
48
+ ".tiff", ".psd", ".pdf", ".zip", ".gz", ".tgz", ".bz2", ".xz", ".zst", ".7z", ".rar",
49
+ ".tar", ".jar", ".war", ".ear", ".whl", ".egg", ".exe", ".dll", ".so", ".dylib", ".a",
50
+ ".lib", ".o", ".obj", ".class", ".pyc", ".pyo", ".wasm", ".bin", ".dat", ".db",
51
+ ".sqlite", ".sqlite3", ".woff", ".woff2", ".ttf", ".otf", ".eot", ".mp3", ".mp4", ".m4a",
52
+ ".wav", ".ogg", ".flac", ".mov", ".avi", ".mkv", ".webm", ".iso", ".dmg", ".pkg",
53
+ ".deb", ".rpm", ".apk", ".ipa", ".keystore", ".jks", ".p12", ".pfx", ".der",
54
+ })
55
+ BINARY_SNIFF = 8000
56
+ GENERATED_SNIFF = 1024
57
+ MAX_FILE_BYTES = 10 * 1024 * 1024 # larger files are data, not code: not counted
58
+
59
+ # Languages by file name, then by extension (case-insensitive); anything else is "Other".
60
+ FILENAMES = {"Dockerfile": "Dockerfile", "Makefile": "Makefile", "makefile": "Makefile",
61
+ "GNUmakefile": "Makefile", "CMakeLists.txt": "CMake", "Jenkinsfile": "Groovy",
62
+ "Rakefile": "Ruby", "Gemfile": "Ruby", "Vagrantfile": "Ruby", "BUILD": "Starlark",
63
+ "WORKSPACE": "Starlark", "BUILD.bazel": "Starlark"}
64
+ EXTENSIONS = {
65
+ ".py": "Python", ".pyi": "Python", ".pyx": "Cython", ".ipynb": "Jupyter Notebook",
66
+ ".ts": "TypeScript", ".tsx": "TypeScript", ".mts": "TypeScript", ".cts": "TypeScript",
67
+ ".js": "JavaScript", ".jsx": "JavaScript", ".mjs": "JavaScript", ".cjs": "JavaScript",
68
+ ".vue": "Vue", ".svelte": "Svelte", ".astro": "Astro",
69
+ ".go": "Go", ".rs": "Rust", ".java": "Java", ".kt": "Kotlin", ".kts": "Kotlin",
70
+ ".scala": "Scala", ".groovy": "Groovy", ".gradle": "Groovy", ".clj": "Clojure",
71
+ ".c": "C", ".h": "C", ".cc": "C++", ".cpp": "C++", ".cxx": "C++", ".hh": "C++",
72
+ ".hpp": "C++", ".hxx": "C++", ".m": "Objective-C", ".mm": "Objective-C++",
73
+ ".cs": "C#", ".fs": "F#", ".vb": "Visual Basic", ".swift": "Swift", ".dart": "Dart",
74
+ ".rb": "Ruby", ".php": "PHP", ".pl": "Perl", ".pm": "Perl", ".lua": "Lua", ".r": "R",
75
+ ".jl": "Julia", ".ex": "Elixir", ".exs": "Elixir", ".erl": "Erlang", ".hs": "Haskell",
76
+ ".ml": "OCaml", ".elm": "Elm", ".zig": "Zig", ".nim": "Nim", ".sol": "Solidity",
77
+ ".sh": "Shell", ".bash": "Shell", ".zsh": "Shell", ".fish": "Shell", ".ps1": "PowerShell",
78
+ ".psm1": "PowerShell", ".bat": "Batchfile", ".cmd": "Batchfile",
79
+ ".sql": "SQL", ".graphql": "GraphQL", ".gql": "GraphQL", ".proto": "Protocol Buffers",
80
+ ".html": "HTML", ".htm": "HTML", ".css": "CSS", ".scss": "SCSS", ".sass": "Sass",
81
+ ".less": "Less", ".md": "Markdown", ".mdx": "MDX", ".rst": "reStructuredText",
82
+ ".tex": "TeX", ".json": "JSON", ".jsonc": "JSON", ".yaml": "YAML", ".yml": "YAML",
83
+ ".toml": "TOML", ".xml": "XML", ".ini": "INI", ".cfg": "INI", ".tf": "HCL",
84
+ ".hcl": "HCL", ".nix": "Nix", ".cmake": "CMake", ".txt": "Text",
85
+ }
86
+
87
+
88
+ def path_excluded(path: str) -> bool:
89
+ """Vendored or generated by its path alone (``.gitattributes`` can override)."""
90
+ p = PurePosixPath(path)
91
+ if any(part in VENDORED_DIRS for part in p.parts[:-1]):
92
+ return True
93
+ name = p.name
94
+ return (name in GENERATED_NAMES or name.endswith(GENERATED_SUFFIXES)
95
+ or bool(_MINIFIED.search(name)))
96
+
97
+
98
+ def binary_path(path: str) -> bool:
99
+ return PurePosixPath(path).suffix.lower() in BINARY_SUFFIXES
100
+
101
+
102
+ def nonblank(data: bytes) -> int:
103
+ """Lines (split on LF) with any byte other than ASCII whitespace (space, tab, CR, VT, FF)."""
104
+ return sum(1 for line in data.split(b"\n") if line.strip())
105
+
106
+
107
+ def measure(data: bytes) -> tuple[int, str]:
108
+ """(non-blank lines, kind): kind is "text", "binary" or "generated" (a marker in the
109
+ first 1 KiB: Go's "Code generated ... DO NOT EDIT", or @generated)."""
110
+ if len(data) > MAX_FILE_BYTES or b"\0" in data[:BINARY_SNIFF]:
111
+ return 0, "binary"
112
+ if _GENERATED_MARK.search(data[:GENERATED_SNIFF]):
113
+ return nonblank(data), "generated"
114
+ return nonblank(data), "text"
115
+
116
+
117
+ def language(path: str) -> str:
118
+ p = PurePosixPath(path)
119
+ return FILENAMES.get(p.name) or EXTENSIONS.get(p.suffix.lower(), "Other")
120
+
121
+
122
+ # -- the result ------------------------------------------------------------------------------
123
+ @dataclass
124
+ class Count:
125
+ total: int = 0
126
+ files: int = 0
127
+ languages: dict[str, int] = field(default_factory=dict)
128
+
129
+ def add(self, path: str, lines: int) -> None:
130
+ self.files += 1
131
+ self.total += lines
132
+ name = language(path)
133
+ self.languages[name] = self.languages.get(name, 0) + lines
134
+
135
+ def payload(self) -> dict:
136
+ """What is sent with the seal (and kept in the Imprint's public header)."""
137
+ return {"rules": RULES, "total": self.total, "files": self.files,
138
+ "languages": {k: v for k, v in sorted(self.languages.items()) if v}}
139
+
140
+
141
+ def summary(lines: dict) -> str:
142
+ """'123,456 (Python 80%, TypeScript 20%)': the three largest languages, then the rest."""
143
+ total = int(lines.get("total") or 0)
144
+ if not total:
145
+ return "0"
146
+ ranked = sorted(((n, k) for k, n in (lines.get("languages") or {}).items() if n),
147
+ key=lambda t: (-t[0], t[1]))
148
+ parts = [f"{k} {round(100 * n / total)}%" for n, k in ranked[:3]
149
+ if round(100 * n / total) >= 1]
150
+ rest = total - sum(n for n, _ in ranked[:3])
151
+ if len(ranked) > 3 and rest:
152
+ parts.append(f"other {max(1, round(100 * rest / total))}%")
153
+ return f"{total:,}" + (f" ({', '.join(parts)})" if parts else "")
154
+
155
+
156
+ # -- .gitattributes ---------------------------------------------------------------------------
157
+ _ATTRS = ("linguist-vendored", "linguist-generated")
158
+
159
+
160
+ def _attr_value(value: str) -> bool | None:
161
+ """set/true: excluded; unset/false: counted even if the path rules would exclude it."""
162
+ if value in ("set", "true"):
163
+ return True
164
+ if value in ("unset", "false"):
165
+ return False
166
+ return None
167
+
168
+
169
+ def _git_attrs(top: Path, commit: str | None, paths: list[str]) -> dict[str, dict]:
170
+ """{path: {attr: True/False}} from ``git check-attr`` (at ``commit`` with Git 2.40+,
171
+ else from the index)."""
172
+ if not paths:
173
+ return {}
174
+ data = b"\0".join(p.encode("utf-8", "surrogateescape") for p in paths) + b"\0"
175
+ attempts = ([["--source", commit]] if commit else []) + [["--cached"]]
176
+ out = None
177
+ for extra in attempts:
178
+ try:
179
+ out = subprocess.run(["git", "-C", str(top), "check-attr", "-z", "--stdin", *extra,
180
+ *_ATTRS], input=data, capture_output=True, check=True).stdout
181
+ break
182
+ except subprocess.CalledProcessError:
183
+ continue
184
+ found: dict[str, dict] = {}
185
+ if out is None:
186
+ return found
187
+ parts = out.split(b"\0")
188
+ for i in range(0, len(parts) - 2, 3):
189
+ value = _attr_value(parts[i + 2].decode())
190
+ if value is not None:
191
+ found.setdefault(parts[i].decode("utf-8", "surrogateescape"), {})[
192
+ parts[i + 1].decode()] = value
193
+ return found
194
+
195
+
196
+ def _folder_attrs(root: Path):
197
+ """``linguist-vendored`` / ``linguist-generated`` from the folder's own .gitattributes
198
+ (outside Git: the top-level file only). Returns path -> {attr: bool}."""
199
+ import pathspec
200
+
201
+ rules = []
202
+ f = root / ".gitattributes"
203
+ if f.is_file():
204
+ for line in f.read_text(encoding="utf-8", errors="replace").splitlines():
205
+ bits = line.strip().split()
206
+ if not bits or bits[0].startswith("#"):
207
+ continue
208
+ attrs = {}
209
+ for a in bits[1:]:
210
+ name, _, val = a.lstrip("-!").partition("=")
211
+ if name in _ATTRS:
212
+ attrs[name] = (False if a.startswith("-") or val == "false" else
213
+ None if a.startswith("!") else True)
214
+ if attrs:
215
+ rules.append((pathspec.GitIgnoreSpec.from_lines([bits[0]]), attrs))
216
+
217
+ def lookup(path: str) -> dict:
218
+ out: dict = {}
219
+ for spec, attrs in rules: # the last matching line wins
220
+ if spec.match_file(path):
221
+ out.update(attrs)
222
+ return {k: v for k, v in out.items() if v is not None}
223
+ return lookup
224
+
225
+
226
+ def _excluded(path: str, attrs: dict) -> bool:
227
+ if any(attrs.get(a) is True for a in _ATTRS):
228
+ return True
229
+ if any(attrs.get(a) is False for a in _ATTRS):
230
+ return binary_path(path) # explicitly counted, unless binary
231
+ return binary_path(path) or path_excluded(path)
232
+
233
+
234
+ def _counted(path: str, attrs: dict, lines: int, kind: str) -> bool:
235
+ if kind == "binary":
236
+ return False
237
+ return not (kind == "generated" and attrs.get("linguist-generated") is not False)
238
+
239
+
240
+ # -- counting a commit ----------------------------------------------------------------------
241
+ def _cache_path(top: Path) -> Path | None:
242
+ try:
243
+ out = subprocess.run(["git", "-C", str(top), "rev-parse", "--git-dir"],
244
+ capture_output=True, check=True).stdout.decode().strip()
245
+ except (subprocess.CalledProcessError, FileNotFoundError):
246
+ return None
247
+ git_dir = Path(out) if Path(out).is_absolute() else top / out
248
+ return git_dir / "codeimprint-lines.json"
249
+
250
+
251
+ def _load_cache(path: Path | None) -> dict:
252
+ try:
253
+ doc = json.loads(path.read_text()) if path else {}
254
+ return doc.get("blobs", {}) if doc.get("rules") == RULES else {}
255
+ except (OSError, ValueError, AttributeError):
256
+ return {}
257
+
258
+
259
+ def _save_cache(path: Path | None, blobs: dict) -> None:
260
+ if not path:
261
+ return
262
+ try:
263
+ tmp = path.with_suffix(".tmp")
264
+ tmp.write_text(json.dumps({"rules": RULES, "blobs": blobs}, separators=(",", ":")))
265
+ os.replace(tmp, path)
266
+ except OSError:
267
+ pass # a cache only: never fails a seal
268
+
269
+
270
+ def _stream_blobs(top: Path, blob_ids: list[str]):
271
+ """(blob, data) for each ID through one ``git cat-file --batch``, written by a thread so
272
+ a large repository never waits on a round trip per file."""
273
+ proc = subprocess.Popen(["git", "-C", str(top), "cat-file", "--batch"],
274
+ stdin=subprocess.PIPE, stdout=subprocess.PIPE)
275
+
276
+ def feed():
277
+ try:
278
+ for blob in blob_ids:
279
+ proc.stdin.write(blob.encode() + b"\n")
280
+ finally:
281
+ proc.stdin.close()
282
+
283
+ writer = threading.Thread(target=feed, daemon=True)
284
+ writer.start()
285
+ try:
286
+ for blob in blob_ids:
287
+ header = proc.stdout.readline().split()
288
+ if len(header) != 3:
289
+ raise RuntimeError(f"blob {blob} is missing from the repository")
290
+ data = proc.stdout.read(int(header[2]))
291
+ proc.stdout.read(1)
292
+ yield blob, data
293
+ finally:
294
+ writer.join()
295
+ proc.stdout.close()
296
+ proc.wait()
297
+
298
+
299
+ def count_commit(top: Path, commit: str, files: list[dict], under: str = "") -> Count:
300
+ """Count the files of a commit (``gitrepo.ls_tree`` entries, paths relative to ``under``,
301
+ the sealed folder inside the repository)."""
302
+ full = [under + f["path"] for f in files]
303
+ attrs = _git_attrs(top, commit, full)
304
+ candidates = [(f, path) for f, path in zip(files, full)
305
+ if not _excluded(path, attrs.get(path, {}))
306
+ and int(f.get("size") or 0) <= MAX_FILE_BYTES]
307
+ cache_file = _cache_path(top)
308
+ cache = _load_cache(cache_file)
309
+ wanted = sorted({f["blob"] for f, _ in candidates if f["blob"] not in cache})
310
+ for blob, data in _stream_blobs(top, wanted) if wanted else ():
311
+ cache[blob] = list(measure(data))
312
+ count = Count()
313
+ for f, path in candidates:
314
+ lines, kind = cache[f["blob"]]
315
+ if _counted(path, attrs.get(path, {}), lines, kind):
316
+ count.add(path, lines)
317
+ used = {f["blob"] for f in files}
318
+ _save_cache(cache_file, {b: v for b, v in cache.items() if b in used})
319
+ return count
320
+
321
+
322
+ def count_folder(root: Path, paths: list[str]) -> Count:
323
+ """Count the files of a folder outside Git (or sealed with --zip): ``paths`` are the
324
+ folder-relative files the client would send, every type (``project.select_files``)."""
325
+ lookup = _folder_attrs(root)
326
+ count = Count()
327
+ for path in paths:
328
+ attrs = lookup(path)
329
+ if _excluded(path, attrs):
330
+ continue
331
+ file = root / path
332
+ try:
333
+ if file.stat().st_size > MAX_FILE_BYTES:
334
+ continue
335
+ lines, kind = measure(file.read_bytes())
336
+ except OSError:
337
+ continue
338
+ if _counted(path, attrs, lines, kind):
339
+ count.add(path, lines)
340
+ return count