tmaudit 0.5.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.
tmaudit/__init__.py ADDED
@@ -0,0 +1,68 @@
1
+ """tmaudit — config-driven TMLR paper audit + LaTeX compile pipeline.
2
+
3
+ This package bundles:
4
+ * The generic 6-category audit template
5
+ * The generic 4-pass LaTeX compile script
6
+ * The generic abstract-Unicode -> LaTeX-math fixer
7
+ * Per-paper PAPER_CONFIGS for all five TMLR papers
8
+ * A plugin API (added in v0.4.0) for user-written audit checks
9
+
10
+ and exposes a single CLI:
11
+
12
+ tmaudit --list
13
+ tmaudit verify --paper N [--paper-dir PATH]
14
+ tmaudit compile --paper N
15
+ tmaudit fix-unicode --paper N
16
+ tmaudit audit-all
17
+ tmaudit plugins list
18
+
19
+ The package can be installed with `pip install tmaudit/` (or
20
+ `pip install -e .` for editable mode) and shipped as a single
21
+ executable .pyz file with `python -m zipapp`.
22
+ """
23
+ __version__ = '0.5.0'
24
+
25
+ # Re-export the plugin API at the package level so users can
26
+ # `from tmaudit import Finding, check` instead of
27
+ # `from tmaudit.plugins import ...`. The submodule is still
28
+ # the canonical home for the implementation.
29
+ from .plugins import (
30
+ Finding,
31
+ CheckFn,
32
+ PLUGIN_GROUP,
33
+ VALID_SEVERITIES,
34
+ audit_plugins,
35
+ check,
36
+ filter_active,
37
+ load_plugins,
38
+ reset_loader_cache,
39
+ run_all_plugins,
40
+ run_plugin,
41
+ )
42
+
43
+ __all__ = [
44
+ "__version__",
45
+ "Finding",
46
+ "CheckFn",
47
+ "PLUGIN_GROUP",
48
+ "VALID_SEVERITIES",
49
+ "audit_plugins",
50
+ "check",
51
+ "filter_active",
52
+ "load_plugins",
53
+ "reset_loader_cache",
54
+ "run_all_plugins",
55
+ "run_plugin",
56
+ # Re-exported for plugin authors. CHANGELOG.md records them as public API, so
57
+ # listing them here is what tells pyflakes they are not dead imports.
58
+ "CacheDB",
59
+ "cache_key",
60
+ "plugin_cache_key",
61
+ ] # type: ignore
62
+
63
+ # Re-export cache helpers that plugin authors will need.
64
+ from .cache import (
65
+ CacheDB,
66
+ cache_key,
67
+ plugin_cache_key,
68
+ )
tmaudit/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """tmaudit.__main__ — allow `python -m tmaudit ...`."""
2
+ import sys
3
+ from .cli import main
4
+
5
+ sys.exit(main())
tmaudit/cache.py ADDED
@@ -0,0 +1,259 @@
1
+ """tmaudit.cache — content-addressed SQLite cache for audit results.
2
+
3
+ When `tmaudit verify --paper N` is run twice on the same
4
+ main.tex + refs.bib content, the second run is **instant**
5
+ (returns the cached result). The cache is invalidated
6
+ automatically when:
7
+
8
+ 1. The content of main.tex or refs.bib changes (sha256
9
+ mismatch).
10
+ 2. The tmaudit version changes (so a bug fix in the
11
+ audit logic invalidates old results).
12
+ 3. The user explicitly clears the cache (--clear-cache).
13
+
14
+ The cache is stored at:
15
+ Linux/Mac: ~/.cache/tmaudit/cache.db
16
+ Windows: %LOCALAPPDATA%\\tmaudit\\cache.db
17
+
18
+ Public API:
19
+ - CacheDB: SQLite wrapper, persistent cache.
20
+ - cache_key(main_tex, refs_bib, version): compute the
21
+ cache key from inputs.
22
+ - CacheEntry: the cached value type (a dict with keys
23
+ 'findings', 'exit_code', 'summary', 'timestamp').
24
+ - DEFAULT_CACHE_DIR: where the cache lives (overridable
25
+ via TMAUDIT_CACHE_DIR env var).
26
+
27
+ Usage:
28
+ db = CacheDB()
29
+ key = cache_key(tex, bib, tmaudit.__version__)
30
+ entry = db.get(key)
31
+ if entry is None:
32
+ # cache miss: run the audit
33
+ findings = run_audit(tex, bib)
34
+ entry = {'findings': findings, 'exit_code': 0,
35
+ 'summary': '...', 'timestamp': now()}
36
+ db.put(key, entry)
37
+ # else: cache hit, use entry['findings'] directly
38
+ """
39
+ from __future__ import annotations
40
+
41
+ import hashlib
42
+ import json
43
+ import os
44
+ import platform
45
+ import sqlite3
46
+ import time
47
+ from pathlib import Path
48
+ from typing import Optional
49
+
50
+
51
+ # Default cache directory: ~/.cache/tmaudit on Linux/Mac,
52
+ # %LOCALAPPDATA%\\tmaudit on Windows. Override with
53
+ # TMAUDIT_CACHE_DIR env var.
54
+ def _default_cache_dir() -> Path:
55
+ env = os.environ.get('TMAUDIT_CACHE_DIR')
56
+ if env:
57
+ return Path(env)
58
+ if platform.system() == 'Windows':
59
+ appdata = os.environ.get('LOCALAPPDATA', 'C:\\Users\\Public\\AppData\\Local')
60
+ return Path(appdata) / 'tmaudit'
61
+ return Path.home() / '.cache' / 'tmaudit'
62
+
63
+
64
+ DEFAULT_CACHE_DIR: Path = _default_cache_dir()
65
+ DEFAULT_CACHE_FILE: Path = DEFAULT_CACHE_DIR / 'cache.db'
66
+ DEFAULT_CACHE_VERSION: str = '1'
67
+
68
+
69
+ def cache_key(
70
+ main_tex: str,
71
+ refs_bib: str,
72
+ tmaudit_version: str = DEFAULT_CACHE_VERSION,
73
+ ) -> str:
74
+ """Compute the cache key from input content + version.
75
+
76
+ The key is the SHA-256 of the concatenation of the
77
+ input content and the tmaudit version. Any change in
78
+ input or version produces a new key, invalidating the
79
+ old cache entry.
80
+ """
81
+ h = hashlib.sha256()
82
+ h.update(main_tex.encode('utf-8', errors='replace'))
83
+ h.update(b'\x00') # separator
84
+ h.update(refs_bib.encode('utf-8', errors='replace'))
85
+ h.update(b'\x00') # separator
86
+ h.update(tmaudit_version.encode('utf-8', errors='replace'))
87
+ return h.hexdigest()
88
+
89
+
90
+ def plugin_cache_key(
91
+ paper_n: int,
92
+ plugin_name: str,
93
+ plugin_version: str,
94
+ main_tex: str,
95
+ ) -> str:
96
+ """Compute the cache key for a single plugin's findings.
97
+
98
+ Added in v0.4.0 for the plugin API. The key is a
99
+ SHA-256 of:
100
+
101
+ - paper_n (which paper the plugin was run on),
102
+ - plugin_name + plugin_version (so an updated plugin
103
+ invalidates stale cached findings),
104
+ - main_tex content (so editing the paper invalidates
105
+ the cached findings).
106
+
107
+ The leading ``plugin:`` prefix separates these entries
108
+ from the legacy ``cache_key()`` entries.
109
+ """
110
+ tex_hash = hashlib.sha256(
111
+ main_tex.encode('utf-8', errors='replace')
112
+ ).hexdigest()
113
+ raw = f"plugin:{paper_n}:{plugin_name}:{plugin_version}:{tex_hash}"
114
+ return hashlib.sha256(raw.encode('utf-8')).hexdigest()
115
+
116
+
117
+ class CacheDB:
118
+ """SQLite-backed cache for audit results.
119
+
120
+ Schema:
121
+ CREATE TABLE IF NOT EXISTS entries (
122
+ key TEXT PRIMARY KEY,
123
+ value TEXT NOT NULL, -- JSON-serialized CacheEntry
124
+ created_at REAL NOT NULL,
125
+ size_bytes INTEGER NOT NULL
126
+ );
127
+ CREATE INDEX IF NOT EXISTS idx_created_at
128
+ ON entries(created_at);
129
+ """
130
+
131
+ SCHEMA = """
132
+ CREATE TABLE IF NOT EXISTS entries (
133
+ key TEXT PRIMARY KEY,
134
+ value TEXT NOT NULL,
135
+ created_at REAL NOT NULL,
136
+ size_bytes INTEGER NOT NULL
137
+ );
138
+ CREATE INDEX IF NOT EXISTS idx_created_at
139
+ ON entries(created_at);
140
+ """
141
+
142
+ def __init__(self, path: Optional[Path] = None) -> None:
143
+ """Open (or create) the cache at the given path.
144
+
145
+ Default: DEFAULT_CACHE_FILE (~/.cache/tmaudit/cache.db
146
+ on Linux/Mac).
147
+ """
148
+ self.path = Path(path) if path is not None else DEFAULT_CACHE_FILE
149
+ self.path.parent.mkdir(parents=True, exist_ok=True)
150
+ self._conn: Optional[sqlite3.Connection] = None
151
+
152
+ def _get_conn(self) -> sqlite3.Connection:
153
+ if self._conn is None:
154
+ self._conn = sqlite3.connect(str(self.path))
155
+ self._conn.executescript(self.SCHEMA)
156
+ return self._conn
157
+
158
+ def get(self, key: str) -> Optional[dict]:
159
+ """Fetch the entry for `key`, or None if not present.
160
+
161
+ Returns the deserialized dict on hit, None on miss.
162
+ """
163
+ conn = self._get_conn()
164
+ row = conn.execute(
165
+ 'SELECT value FROM entries WHERE key = ?', (key,)
166
+ ).fetchone()
167
+ if row is None:
168
+ return None
169
+ try:
170
+ return json.loads(row[0])
171
+ except (json.JSONDecodeError, TypeError):
172
+ # Corrupt entry; treat as miss and remove it.
173
+ conn.execute('DELETE FROM entries WHERE key = ?', (key,))
174
+ conn.commit()
175
+ return None
176
+
177
+ def put(self, key: str, value: dict) -> None:
178
+ """Store `value` (a dict) under `key`.
179
+
180
+ The dict is JSON-serialized. The entry's size in
181
+ bytes is also stored for statistics.
182
+ """
183
+ serialized = json.dumps(value, ensure_ascii=False)
184
+ size_bytes = len(serialized.encode('utf-8'))
185
+ conn = self._get_conn()
186
+ conn.execute(
187
+ 'INSERT OR REPLACE INTO entries (key, value, created_at, size_bytes) '
188
+ 'VALUES (?, ?, ?, ?)',
189
+ (key, serialized, time.time(), size_bytes),
190
+ )
191
+ conn.commit()
192
+
193
+ def clear(self) -> int:
194
+ """Remove all entries. Returns the number removed."""
195
+ conn = self._get_conn()
196
+ cur = conn.execute('DELETE FROM entries')
197
+ conn.commit()
198
+ return cur.rowcount
199
+
200
+ def stats(self) -> dict:
201
+ """Return cache statistics as a dict.
202
+
203
+ Keys:
204
+ - n_entries: number of entries
205
+ - total_bytes: total size of all values (uncompressed)
206
+ - oldest: timestamp of oldest entry (or None)
207
+ - newest: timestamp of newest entry (or None)
208
+ - path: cache file path
209
+ """
210
+ conn = self._get_conn()
211
+ row = conn.execute(
212
+ 'SELECT COUNT(*), COALESCE(SUM(size_bytes), 0), '
213
+ 'MIN(created_at), MAX(created_at) FROM entries'
214
+ ).fetchone()
215
+ return {
216
+ 'n_entries': int(row[0]),
217
+ 'total_bytes': int(row[1]),
218
+ 'oldest': float(row[2]) if row[2] is not None else None,
219
+ 'newest': float(row[3]) if row[3] is not None else None,
220
+ 'path': str(self.path),
221
+ }
222
+
223
+ def close(self) -> None:
224
+ if self._conn is not None:
225
+ self._conn.close()
226
+ self._conn = None
227
+
228
+
229
+ # Default version is a module-level constant, but tests may
230
+ # override it. The cache_key function takes version as an
231
+ # argument to make this easy.
232
+ def tmaudit_version() -> str:
233
+ """Get the current tmaudit version.
234
+
235
+ Default: 'dev'. Tests may monkey-patch this.
236
+ """
237
+ try:
238
+ from . import __version__
239
+ return __version__
240
+ except ImportError:
241
+ return 'dev'
242
+
243
+
244
+ def make_entry(
245
+ findings: list,
246
+ exit_code: int,
247
+ summary: str = '',
248
+ ) -> dict:
249
+ """Build a CacheEntry dict from raw audit results.
250
+
251
+ Helper for the call site. The dict has keys: findings,
252
+ exit_code, summary, timestamp.
253
+ """
254
+ return {
255
+ 'findings': findings,
256
+ 'exit_code': int(exit_code),
257
+ 'summary': str(summary),
258
+ 'timestamp': time.time(),
259
+ }