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 +23 -0
- lepisma/__main__.py +5 -0
- lepisma/cli.py +291 -0
- lepisma/client.py +210 -0
- lepisma/config.py +261 -0
- lepisma/engine.py +96 -0
- lepisma/guide/setup.md +68 -0
- lepisma/guide/triage.md +51 -0
- lepisma/guide/tune.md +76 -0
- lepisma/guide.py +43 -0
- lepisma/markdown.py +325 -0
- lepisma/measure.py +348 -0
- lepisma/measure_report.py +343 -0
- lepisma/models.py +70 -0
- lepisma/py.typed +0 -0
- lepisma/report.py +80 -0
- lepisma/rules.py +505 -0
- lepisma/server.py +125 -0
- lepisma/spacy_model.py +103 -0
- lepisma-0.1.1.dist-info/METADATA +308 -0
- lepisma-0.1.1.dist-info/RECORD +24 -0
- lepisma-0.1.1.dist-info/WHEEL +4 -0
- lepisma-0.1.1.dist-info/entry_points.txt +2 -0
- lepisma-0.1.1.dist-info/licenses/LICENSE +21 -0
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
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
|