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 +68 -0
- tmaudit/__main__.py +5 -0
- tmaudit/cache.py +259 -0
- tmaudit/cli.py +479 -0
- tmaudit/configs/__init__.py +18 -0
- tmaudit/configs/compile_configs.py +296 -0
- tmaudit/configs/paper_configs.py +686 -0
- tmaudit/detector_config/__init__.py +21 -0
- tmaudit/detector_config/detector_min.py +113 -0
- tmaudit/forge.py +320 -0
- tmaudit/llm_fallback.py +328 -0
- tmaudit/plugins.py +464 -0
- tmaudit/report.py +353 -0
- tmaudit/templates/__init__.py +1 -0
- tmaudit/templates/compile_check_TEMPLATE.py +131 -0
- tmaudit/templates/fix_abstract_unicode_TEMPLATE.py +136 -0
- tmaudit/templates/verify_TEMPLATE.py +1719 -0
- tmaudit-0.5.0.dist-info/METADATA +386 -0
- tmaudit-0.5.0.dist-info/RECORD +23 -0
- tmaudit-0.5.0.dist-info/WHEEL +5 -0
- tmaudit-0.5.0.dist-info/entry_points.txt +2 -0
- tmaudit-0.5.0.dist-info/licenses/LICENSE +24 -0
- tmaudit-0.5.0.dist-info/top_level.txt +1 -0
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
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
|
+
}
|