lepisma 0.1.1__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.
lepisma/__init__.py ADDED
@@ -0,0 +1,23 @@
1
+ """Soft prose warnings for Markdown docs."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("lepisma")
7
+ except PackageNotFoundError: # pragma: no cover - running from a bare checkout
8
+ __version__ = "0.0.0"
9
+
10
+
11
+ def build_id() -> str:
12
+ """Version plus a hash of this package's source.
13
+
14
+ The hook restarts a server whose build id differs from its own, so code
15
+ changes take effect without a version bump.
16
+ """
17
+ import hashlib # noqa: PLC0415
18
+ from pathlib import Path # noqa: PLC0415
19
+
20
+ digest = hashlib.sha256()
21
+ for path in sorted(Path(__file__).parent.glob("*.py")):
22
+ digest.update(path.read_bytes())
23
+ return f"{__version__}+{digest.hexdigest()[:12]}"
lepisma/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Allow ``python -m lepisma``."""
2
+
3
+ from lepisma.cli import main
4
+
5
+ raise SystemExit(main())
lepisma/cli.py ADDED
@@ -0,0 +1,291 @@
1
+ """Command line entry point. Heavy modules are imported inside each command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import TYPE_CHECKING
11
+
12
+ from lepisma.guide import TOPICS
13
+
14
+ if TYPE_CHECKING:
15
+ from lepisma.config import Config
16
+ from lepisma.models import FileResult
17
+
18
+ HOOK_MARKER = "lepisma hook"
19
+
20
+
21
+ def _walk(directory: Path, extensions: list[str]) -> list[Path]:
22
+ """Docs under ``directory``, skipping hidden folders like .git and .venv."""
23
+ return sorted(
24
+ f
25
+ for f in directory.rglob("*")
26
+ if f.is_file()
27
+ and f.suffix.lower() in extensions
28
+ and not any(part.startswith(".") for part in f.relative_to(directory).parts)
29
+ )
30
+
31
+
32
+ def _collect(paths: list[str], cfg: Config, *, use_ignore: bool) -> list[Path]:
33
+ """Files named in ``paths`` plus docs found in named folders. ``ignore`` globs
34
+ only filter files found by walking a folder."""
35
+ files: list[Path] = []
36
+ for raw in paths:
37
+ p = Path(raw)
38
+ if p.is_dir():
39
+ found = _walk(p, cfg.extensions)
40
+ if use_ignore:
41
+ found = [f for f in found if not cfg.is_ignored(str(f))]
42
+ files.extend(found)
43
+ elif p.is_file():
44
+ files.append(p)
45
+ else:
46
+ print(f"lepisma: no such file: {raw}", file=sys.stderr) # noqa: T201
47
+ return files
48
+
49
+
50
+ def _lint_paths(paths: list[str], *, use_ignore: bool = True) -> list[FileResult]:
51
+ """Lint files and folders."""
52
+ from lepisma.config import from_dict, load_raw # noqa: PLC0415
53
+ from lepisma.engine import lint_text # noqa: PLC0415
54
+
55
+ cfg = from_dict(load_raw(Path.cwd()))
56
+ files = _collect(paths, cfg, use_ignore=use_ignore)
57
+ return [lint_text(str(f), f.read_text(encoding="utf-8"), cfg) for f in files]
58
+
59
+
60
+ def cmd_check(args: argparse.Namespace) -> int:
61
+ """Lint files in-process (no server). Exit 1 on warnings only with --strict."""
62
+ from lepisma.report import print_results # noqa: PLC0415
63
+
64
+ results = _lint_paths(args.paths)
65
+ print_results(results, args.format)
66
+ has_warnings = any(r.warnings for r in results)
67
+ return 1 if args.strict and has_warnings else 0
68
+
69
+
70
+ def cmd_stats(args: argparse.Namespace) -> int:
71
+ """Warnings per 1,000 words, one group per argument. Ignores ``ignore``,
72
+ since stats are mostly run on the test corpus."""
73
+ from lepisma.report import print_stats # noqa: PLC0415
74
+
75
+ print_stats({p: _lint_paths([p], use_ignore=False) for p in args.paths})
76
+ return 0
77
+
78
+
79
+ def cmd_measure(args: argparse.Namespace) -> int:
80
+ """Readability stats for whole docs: Parquet tables plus report.md."""
81
+ from lepisma.config import from_dict, load_raw # noqa: PLC0415
82
+
83
+ try:
84
+ from lepisma.measure import Measurer # noqa: PLC0415
85
+ from lepisma.measure_report import print_summary, render # noqa: PLC0415
86
+ except ImportError as exc:
87
+ print( # noqa: T201
88
+ f"lepisma: measure needs the 'measure' extra ({exc.name} missing); "
89
+ 'install "lepisma[measure]"',
90
+ file=sys.stderr,
91
+ )
92
+ return 2
93
+ cfg = from_dict(load_raw(Path.cwd()))
94
+ files = _collect(args.paths, cfg, use_ignore=not args.no_ignore)
95
+ if not files:
96
+ print("lepisma: no docs found", file=sys.stderr) # noqa: T201
97
+ return 1
98
+ result = Measurer(cfg).measure_paths(files)
99
+ out = Path(args.out)
100
+ result.write(out)
101
+ (out / "report.md").write_text(render(result, args.min_words), encoding="utf-8")
102
+ print_summary(result, args.min_words, sys.stdout)
103
+ print(f"wrote {out}/report.md and {out}/*.parquet") # noqa: T201
104
+ return 0
105
+
106
+
107
+ def cmd_hook(args: argparse.Namespace) -> int:
108
+ """Pre-commit hook: lint staged docs via the server; never fails."""
109
+ from lepisma.client import run_hook # noqa: PLC0415
110
+
111
+ return run_hook(args.files, fmt=args.format, wait_s=args.wait)
112
+
113
+
114
+ def cmd_serve(args: argparse.Namespace) -> int:
115
+ """Run the local spaCy server in the foreground."""
116
+ from lepisma.client import DEFAULT_IDLE_TIMEOUT_S # noqa: PLC0415
117
+ from lepisma.config import from_dict, load_raw # noqa: PLC0415
118
+ from lepisma.server import serve # noqa: PLC0415
119
+
120
+ port = args.port or from_dict(load_raw(Path.cwd())).server.port
121
+ idle = DEFAULT_IDLE_TIMEOUT_S if args.idle_timeout is None else args.idle_timeout
122
+ serve(port, idle)
123
+ return 0
124
+
125
+
126
+ def cmd_stop(args: argparse.Namespace) -> int:
127
+ """Stop a running server."""
128
+ from lepisma.client import post # noqa: PLC0415
129
+
130
+ try:
131
+ post(args.port, "/shutdown", {}, 1.0)
132
+ except OSError:
133
+ print("lepisma: no server running", file=sys.stderr) # noqa: T201
134
+ return 1
135
+ return 0
136
+
137
+
138
+ def hook_command() -> str:
139
+ """Shell command the git hook should run."""
140
+ exe = shutil.which("lepisma")
141
+ if exe:
142
+ return f'"{exe}" hook'
143
+ return f'"{sys.executable}" -m lepisma hook'
144
+
145
+
146
+ def cmd_install_hook(_args: argparse.Namespace) -> int:
147
+ """Add the lepisma line to this repo's pre-commit hook."""
148
+ hooks = Path(
149
+ subprocess.run(
150
+ ["git", "rev-parse", "--git-path", "hooks"], # noqa: S607
151
+ capture_output=True,
152
+ text=True,
153
+ check=True,
154
+ ).stdout.strip()
155
+ )
156
+ hook = hooks / "pre-commit"
157
+ line = f"{hook_command()} || true # {HOOK_MARKER}: warn-only, never blocks\n"
158
+ if hook.exists():
159
+ text = hook.read_text()
160
+ if HOOK_MARKER in text:
161
+ print(f"lepisma: already in {hook}") # noqa: T201
162
+ return 0
163
+ lines = text.splitlines(keepends=True)
164
+ # Run before the existing hook body, which may end in `exec`.
165
+ at = 1 if lines and lines[0].startswith("#!") else 0
166
+ lines.insert(at, line)
167
+ hook.write_text("".join(lines))
168
+ else:
169
+ hooks.mkdir(parents=True, exist_ok=True)
170
+ hook.write_text("#!/bin/sh\n" + line)
171
+ hook.chmod(hook.stat().st_mode | 0o111)
172
+ print(f"lepisma: added to {hook}") # noqa: T201
173
+ return 0
174
+
175
+
176
+ def cmd_guide(args: argparse.Namespace) -> int:
177
+ """Print the agent guide for a topic, auto-picked when none is given."""
178
+ from lepisma.guide import detect, render # noqa: PLC0415
179
+
180
+ if args.topic:
181
+ topic, head = args.topic, f"# lepisma guide: {args.topic}"
182
+ else:
183
+ topic, reason = detect(Path.cwd())
184
+ head = f"# lepisma guide: {reason} -> {topic}"
185
+ try:
186
+ sys.stdout.write(f"{head}\n\n{render(topic)}")
187
+ sys.stdout.flush()
188
+ except OSError:
189
+ return 1
190
+ return 0
191
+
192
+
193
+ def build_parser() -> argparse.ArgumentParser:
194
+ """Argument parser for all subcommands."""
195
+ parser = argparse.ArgumentParser(
196
+ prog="lepisma",
197
+ description="Soft warnings for hard-to-read Markdown prose. "
198
+ "Run `lepisma guide` first.",
199
+ )
200
+ sub = parser.add_subparsers(dest="command", required=True)
201
+
202
+ p = sub.add_parser(
203
+ "guide",
204
+ help="instructions for an agent: run this first",
205
+ formatter_class=argparse.RawDescriptionHelpFormatter,
206
+ description="Print Markdown instructions for an agent and exit 0.\n\n"
207
+ "topics:\n"
208
+ + "".join(f" {name:<8}{line}\n" for name, line in TOPICS.items())
209
+ + "\nWith no topic, pick one from files: triage when lepisma.toml is\n"
210
+ "in the current directory or above, or pyproject.toml here has a\n"
211
+ "`lepisma hook` step; otherwise setup.",
212
+ )
213
+ p.add_argument("topic", nargs="?", choices=list(TOPICS))
214
+ p.set_defaults(func=cmd_guide)
215
+
216
+ p = sub.add_parser("check", help="lint files or directories in-process")
217
+ p.add_argument("paths", nargs="+")
218
+ p.add_argument("--format", choices=["text", "json"], default="text")
219
+ p.add_argument(
220
+ "--strict", action="store_true", help="exit 1 when there are warnings"
221
+ )
222
+ p.set_defaults(func=cmd_check)
223
+
224
+ p = sub.add_parser("stats", help="warnings per 1,000 words; one group per path")
225
+ p.add_argument("paths", nargs="+")
226
+ p.set_defaults(func=cmd_stats)
227
+
228
+ p = sub.add_parser(
229
+ "measure", help="readability stats for whole docs (needs the measure extra)"
230
+ )
231
+ p.add_argument("paths", nargs="+")
232
+ p.add_argument("--out", default="stats", help="output folder (default: stats)")
233
+ p.add_argument(
234
+ "--min-words",
235
+ type=int,
236
+ default=100,
237
+ help="smallest section or file to rank in the report (default: 100)",
238
+ )
239
+ p.add_argument(
240
+ "--no-ignore", action="store_true", help="also measure files matching ignore"
241
+ )
242
+ p.set_defaults(func=cmd_measure)
243
+
244
+ p = sub.add_parser("hook", help="pre-commit hook: lint staged docs via the server")
245
+ p.add_argument(
246
+ "files",
247
+ nargs="*",
248
+ help="staged docs to lint (madoqua passes these); default: ask git",
249
+ )
250
+ p.add_argument("--format", choices=["text", "json"], default="text")
251
+ p.add_argument(
252
+ "--wait",
253
+ type=float,
254
+ default=0.0,
255
+ metavar="SECONDS",
256
+ help="start the server and wait for it instead of skipping",
257
+ )
258
+ p.set_defaults(func=cmd_hook)
259
+
260
+ p = sub.add_parser("serve", help="run the spaCy server in the foreground")
261
+ p.add_argument("--port", type=int, default=None)
262
+ p.add_argument(
263
+ "--idle-timeout",
264
+ type=int,
265
+ default=None,
266
+ metavar="SECONDS",
267
+ help="exit after this long without requests (0 = never; default 4 h)",
268
+ )
269
+ p.set_defaults(func=cmd_serve)
270
+
271
+ p = sub.add_parser("stop", help="stop the running server")
272
+ p.add_argument("--port", type=int, default=8737)
273
+ p.set_defaults(func=cmd_stop)
274
+
275
+ p = sub.add_parser("install-hook", help="add lepisma to .git pre-commit hook")
276
+ p.set_defaults(func=cmd_install_hook)
277
+ return parser
278
+
279
+
280
+ def main(argv: list[str] | None = None) -> int:
281
+ """Run the CLI."""
282
+ args = build_parser().parse_args(argv)
283
+ try:
284
+ return args.func(args)
285
+ except RuntimeError as exc:
286
+ from lepisma.spacy_model import ModelError # noqa: PLC0415
287
+
288
+ if not isinstance(exc, ModelError):
289
+ raise
290
+ print(f"lepisma: {exc}", file=sys.stderr) # noqa: T201
291
+ return 2
lepisma/client.py ADDED
@@ -0,0 +1,210 @@
1
+ """Commit hook client. Stdlib only: it must start fast and never block a commit.
2
+
3
+ Sends staged Markdown files to the local server and prints the warnings. When the
4
+ server is down it starts one in the background and skips this commit; when the
5
+ server is slow it skips. Every failure path is silent unless
6
+ ``LEPISMA_VERBOSE=1``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import contextlib
12
+ import json
13
+ import os
14
+ import subprocess
15
+ import sys
16
+ import time
17
+ import urllib.error
18
+ import urllib.request
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ from lepisma import build_id
23
+ from lepisma.config import ConfigError, from_dict, load_raw
24
+ from lepisma.models import FileResult
25
+
26
+ SPAWN_COOLDOWN_S = 30
27
+ DEFAULT_IDLE_TIMEOUT_S = 4 * 3600
28
+
29
+
30
+ class Skip(Exception):
31
+ """Raised to skip quietly (fail open)."""
32
+
33
+
34
+ def _verbose() -> bool:
35
+ return os.environ.get("LEPISMA_VERBOSE") == "1"
36
+
37
+
38
+ def note(msg: str) -> None:
39
+ """Print a diagnostic to stderr, only in verbose mode."""
40
+ if _verbose():
41
+ print(f"lepisma: {msg}", file=sys.stderr) # noqa: T201
42
+
43
+
44
+ def cache_dir() -> Path:
45
+ """Directory for the server log and spawn stamp."""
46
+ base = os.environ.get("XDG_CACHE_HOME") or str(Path.home() / ".cache")
47
+ path = Path(base) / "lepisma"
48
+ path.mkdir(parents=True, exist_ok=True)
49
+ return path
50
+
51
+
52
+ def _git(*args: str, cwd: Path | None = None) -> str:
53
+ out = subprocess.run( # noqa: S603 - fixed argv
54
+ ["git", *args], # noqa: S607 - git from PATH, as every hook does
55
+ capture_output=True,
56
+ text=True,
57
+ check=True,
58
+ cwd=cwd,
59
+ )
60
+ return out.stdout
61
+
62
+
63
+ def staged_files(root: Path, cfg_extensions: list[str]) -> list[str]:
64
+ """Paths (relative to the repo root) of staged, added or modified docs."""
65
+ out = _git("diff", "--cached", "--name-only", "--diff-filter=ACMR", "-z", cwd=root)
66
+ return [
67
+ p for p in out.split("\0") if p and Path(p).suffix.lower() in cfg_extensions
68
+ ]
69
+
70
+
71
+ def staged_text(root: Path, path: str) -> str:
72
+ """The staged (index) version of ``path``, so line numbers match the commit."""
73
+ return _git("show", f":{path}", cwd=root)
74
+
75
+
76
+ def _url(port: int, route: str) -> str:
77
+ return f"http://127.0.0.1:{port}{route}"
78
+
79
+
80
+ def post(port: int, route: str, payload: dict[str, Any], timeout_s: float) -> Any:
81
+ """POST JSON to the local server and return the decoded reply."""
82
+ req = urllib.request.Request( # noqa: S310 - fixed http://127.0.0.1 URL
83
+ _url(port, route),
84
+ data=json.dumps(payload).encode(),
85
+ headers={"Content-Type": "application/json"},
86
+ method="POST",
87
+ )
88
+ with urllib.request.urlopen(req, timeout=timeout_s) as resp: # noqa: S310
89
+ return json.loads(resp.read())
90
+
91
+
92
+ def healthy(port: int, timeout_s: float = 0.2) -> bool:
93
+ """True when a lepisma server answers on ``port``."""
94
+ try:
95
+ with urllib.request.urlopen(_url(port, "/health"), timeout=timeout_s) as resp: # noqa: S310
96
+ return json.loads(resp.read()).get("status") == "ok"
97
+ except (OSError, ValueError):
98
+ return False
99
+
100
+
101
+ def spawn_server(port: int, *, force: bool = False) -> bool:
102
+ """Start the server in the background. False when one was started recently."""
103
+ stamp = cache_dir() / f"spawn-{port}"
104
+ recent = stamp.exists() and time.time() - stamp.stat().st_mtime < SPAWN_COOLDOWN_S
105
+ if recent and not force:
106
+ return False
107
+ stamp.touch()
108
+ log = (cache_dir() / "server.log").open("ab")
109
+ subprocess.Popen( # noqa: S603 - our own interpreter and module
110
+ [sys.executable, "-m", "lepisma", "serve", "--port", str(port)],
111
+ stdin=subprocess.DEVNULL,
112
+ stdout=log,
113
+ stderr=log,
114
+ start_new_session=True,
115
+ close_fds=True,
116
+ )
117
+ return True
118
+
119
+
120
+ def lint_via_server(
121
+ files: list[tuple[str, str]], raw_cfg: dict[str, Any], port: int, timeout_s: float
122
+ ) -> list[FileResult]:
123
+ """Lint through the server, raising Skip on any failure."""
124
+ try:
125
+ reply = post(
126
+ port,
127
+ "/lint",
128
+ {"files": [{"path": p, "text": t} for p, t in files], "config": raw_cfg},
129
+ timeout_s,
130
+ )
131
+ except urllib.error.HTTPError as exc:
132
+ raise Skip(
133
+ f"server error {exc.code}: {exc.read().decode(errors='replace')}"
134
+ ) from exc
135
+ except urllib.error.URLError as exc:
136
+ if isinstance(exc.reason, ConnectionRefusedError):
137
+ started = spawn_server(port)
138
+ raise Skip(
139
+ "server not running; "
140
+ + ("starting it" if started else "it is starting")
141
+ ) from exc
142
+ raise Skip(f"server unreachable ({exc.reason})") from exc
143
+ except (TimeoutError, OSError) as exc:
144
+ raise Skip(f"server too slow (>{timeout_s * 1000:.0f} ms)") from exc
145
+ current = build_id()
146
+ if reply.get("version") != current:
147
+ # The server runs older code: its results may be wrong. Replace it and skip.
148
+ # The new server loads spaCy before it binds, so the old one is gone by then.
149
+ with contextlib.suppress(OSError):
150
+ post(port, "/shutdown", {}, 0.2)
151
+ spawn_server(port, force=True)
152
+ raise Skip(f"server ran {reply.get('version')}, restarting as {current}")
153
+ return [FileResult.from_dict(r) for r in reply["results"]]
154
+
155
+
156
+ def wait_for_server(port: int, wait_s: float) -> None:
157
+ """Start the server if needed and block until it is healthy or ``wait_s`` passes."""
158
+ if healthy(port):
159
+ return
160
+ spawn_server(port)
161
+ deadline = time.monotonic() + wait_s
162
+ while time.monotonic() < deadline:
163
+ if healthy(port):
164
+ return
165
+ time.sleep(0.1)
166
+ raise Skip(f"server did not start within {wait_s:.0f} s")
167
+
168
+
169
+ def read_staged(root: Path, path: str) -> str:
170
+ """Staged text of ``path``; the working copy when it is not in the index."""
171
+ try:
172
+ return staged_text(root, path)
173
+ except subprocess.CalledProcessError:
174
+ return (root / path).read_text(encoding="utf-8")
175
+
176
+
177
+ def run_hook(
178
+ files: list[str] | None = None, *, fmt: str = "text", wait_s: float = 0.0
179
+ ) -> int:
180
+ """Entry point for the pre-commit hook. Always returns 0.
181
+
182
+ ``files`` are paths relative to the repo root, as madoqua passes them. Without
183
+ them the hook asks git for the staged docs itself.
184
+ """
185
+ if os.environ.get("LEPISMA_DISABLE") == "1":
186
+ return 0
187
+ try:
188
+ root = Path(_git("rev-parse", "--show-toplevel").strip())
189
+ raw_cfg = load_raw(root)
190
+ cfg = from_dict(raw_cfg)
191
+ candidates = files if files else staged_files(root, cfg.extensions)
192
+ paths = [p for p in candidates if not cfg.is_ignored(p)]
193
+ if not paths:
194
+ return 0
195
+ port = int(os.environ.get("LEPISMA_PORT", cfg.server.port))
196
+ timeout_ms = int(os.environ.get("LEPISMA_TIMEOUT_MS", cfg.server.timeout_ms))
197
+ if wait_s > 0:
198
+ wait_for_server(port, wait_s)
199
+ docs = [(p, read_staged(root, p)) for p in paths]
200
+ results = lint_via_server(docs, raw_cfg, port, timeout_ms / 1000)
201
+ except Skip as exc:
202
+ note(f"skipped: {exc}")
203
+ return 0
204
+ except (ConfigError, subprocess.CalledProcessError, OSError, ValueError) as exc:
205
+ note(f"skipped: {exc}")
206
+ return 0
207
+ from lepisma.report import print_results # noqa: PLC0415 - keep startup lean
208
+
209
+ print_results(results, fmt, footer=True)
210
+ return 0