codendium 1.0.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.
- codendium-1.0.0.dist-info/METADATA +332 -0
- codendium-1.0.0.dist-info/RECORD +45 -0
- codendium-1.0.0.dist-info/WHEEL +5 -0
- codendium-1.0.0.dist-info/entry_points.txt +6 -0
- codendium-1.0.0.dist-info/licenses/LICENSE +201 -0
- codendium-1.0.0.dist-info/top_level.txt +1 -0
- copyright_deposit/__init__.py +15 -0
- copyright_deposit/__main__.py +34 -0
- copyright_deposit/assets/__init__.py +5 -0
- copyright_deposit/assets/fonts/README.md +31 -0
- copyright_deposit/assets/logo.svg +26 -0
- copyright_deposit/cli.py +314 -0
- copyright_deposit/config.py +269 -0
- copyright_deposit/core/__init__.py +1 -0
- copyright_deposit/core/deposit.py +117 -0
- copyright_deposit/core/discovery.py +260 -0
- copyright_deposit/core/encoding.py +136 -0
- copyright_deposit/core/languages.py +190 -0
- copyright_deposit/core/layout.py +349 -0
- copyright_deposit/core/lineranges.py +219 -0
- copyright_deposit/core/manifest.py +282 -0
- copyright_deposit/core/metrics.py +279 -0
- copyright_deposit/core/ordering.py +349 -0
- copyright_deposit/core/pipeline.py +386 -0
- copyright_deposit/core/redaction.py +162 -0
- copyright_deposit/core/render.py +242 -0
- copyright_deposit/core/scanning/__init__.py +61 -0
- copyright_deposit/core/scanning/secrets.py +181 -0
- copyright_deposit/core/scanning/thirdparty.py +190 -0
- copyright_deposit/core/strip/__init__.py +337 -0
- copyright_deposit/core/strip/cfamily_strip.py +235 -0
- copyright_deposit/core/strip/pygments_strip.py +85 -0
- copyright_deposit/core/strip/python_strip.py +131 -0
- copyright_deposit/gui/__init__.py +1 -0
- copyright_deposit/gui/app.py +34 -0
- copyright_deposit/gui/branding.py +83 -0
- copyright_deposit/gui/history.py +192 -0
- copyright_deposit/gui/main_window.py +617 -0
- copyright_deposit/gui/panels/__init__.py +1 -0
- copyright_deposit/gui/panels/estimate.py +166 -0
- copyright_deposit/gui/panels/files.py +635 -0
- copyright_deposit/gui/panels/identification.py +193 -0
- copyright_deposit/gui/panels/options.py +445 -0
- copyright_deposit/gui/panels/preflight.py +260 -0
- copyright_deposit/gui/workers.py +96 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Python comment and docstring classification.
|
|
2
|
+
|
|
3
|
+
``tokenize`` is authoritative for comments: it cannot be fooled by a ``#``
|
|
4
|
+
inside a string literal, which is exactly where regex-based strippers go
|
|
5
|
+
wrong. ``ast`` supplies docstring positions.
|
|
6
|
+
|
|
7
|
+
One rule deserves emphasis: a docstring that is the *only* statement in a
|
|
8
|
+
function or class is never removed, because deleting it would leave an
|
|
9
|
+
empty suite and turn valid source into a syntax error. The deposit must
|
|
10
|
+
still be the program.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import ast
|
|
16
|
+
import io
|
|
17
|
+
import tokenize
|
|
18
|
+
|
|
19
|
+
from . import KIND_COMMENT, KIND_DOCSTRING, KIND_SHEBANG, Span, line_start_offsets
|
|
20
|
+
|
|
21
|
+
_DOCSTRING_PARENTS = (ast.Module, ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def find_spans(text: str) -> tuple[list[Span] | None, list[str]]:
|
|
25
|
+
"""Classify comments and docstrings.
|
|
26
|
+
|
|
27
|
+
Returns ``(None, warnings)`` if the file cannot be tokenised, so the
|
|
28
|
+
caller can fall back to a more forgiving lexer.
|
|
29
|
+
"""
|
|
30
|
+
warnings: list[str] = []
|
|
31
|
+
starts = line_start_offsets(text)
|
|
32
|
+
spans: list[Span] = []
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
tokens = list(tokenize.generate_tokens(io.StringIO(text).readline))
|
|
36
|
+
except (tokenize.TokenError, IndentationError, SyntaxError, ValueError) as exc:
|
|
37
|
+
return None, [f"Python tokenizer failed ({exc.__class__.__name__}); using fallback lexer."]
|
|
38
|
+
|
|
39
|
+
for tok in tokens:
|
|
40
|
+
if tok.type != tokenize.COMMENT:
|
|
41
|
+
continue
|
|
42
|
+
start = starts[tok.start[0]] + tok.start[1]
|
|
43
|
+
end = starts[tok.end[0]] + tok.end[1]
|
|
44
|
+
kind = KIND_SHEBANG if tok.start[0] == 1 and tok.string.startswith("#!") else KIND_COMMENT
|
|
45
|
+
spans.append((start, end, kind))
|
|
46
|
+
|
|
47
|
+
doc_spans, doc_warnings = _docstring_spans(text, starts)
|
|
48
|
+
spans.extend(doc_spans)
|
|
49
|
+
warnings.extend(doc_warnings)
|
|
50
|
+
return spans, warnings
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _docstring_spans(text: str, starts: list[int]) -> tuple[list[Span], list[str]]:
|
|
54
|
+
try:
|
|
55
|
+
tree = ast.parse(text)
|
|
56
|
+
except SyntaxError as exc:
|
|
57
|
+
return [], [f"Could not parse for docstrings ({exc.msg}); docstrings kept."]
|
|
58
|
+
|
|
59
|
+
spans: list[Span] = []
|
|
60
|
+
for node in ast.walk(tree):
|
|
61
|
+
if not isinstance(node, _DOCSTRING_PARENTS):
|
|
62
|
+
continue
|
|
63
|
+
body = getattr(node, "body", None)
|
|
64
|
+
if not body:
|
|
65
|
+
continue
|
|
66
|
+
first = body[0]
|
|
67
|
+
if not (
|
|
68
|
+
isinstance(first, ast.Expr)
|
|
69
|
+
and isinstance(first.value, ast.Constant)
|
|
70
|
+
and isinstance(first.value.value, str)
|
|
71
|
+
):
|
|
72
|
+
continue
|
|
73
|
+
# Removing the sole statement of a suite would break the syntax.
|
|
74
|
+
if len(body) == 1 and not isinstance(node, ast.Module):
|
|
75
|
+
continue
|
|
76
|
+
if first.end_lineno is None or first.end_col_offset is None:
|
|
77
|
+
continue
|
|
78
|
+
start = starts[first.lineno] + first.col_offset
|
|
79
|
+
end = starts[first.end_lineno] + first.end_col_offset
|
|
80
|
+
spans.append((start, end, KIND_DOCSTRING))
|
|
81
|
+
return spans, []
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
# Verification
|
|
86
|
+
# ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _strip_all_docstrings(tree: ast.AST) -> ast.AST:
|
|
90
|
+
for node in ast.walk(tree):
|
|
91
|
+
if not isinstance(node, _DOCSTRING_PARENTS):
|
|
92
|
+
continue
|
|
93
|
+
body = getattr(node, "body", None)
|
|
94
|
+
if not body:
|
|
95
|
+
continue
|
|
96
|
+
first = body[0]
|
|
97
|
+
if (
|
|
98
|
+
isinstance(first, ast.Expr)
|
|
99
|
+
and isinstance(first.value, ast.Constant)
|
|
100
|
+
and isinstance(first.value.value, str)
|
|
101
|
+
):
|
|
102
|
+
rest = body[1:]
|
|
103
|
+
# A module may legitimately become empty; a suite may not, so it
|
|
104
|
+
# gets a Pass. Both sides of the comparison normalise the same
|
|
105
|
+
# way, which keeps a docstring-only module from looking changed.
|
|
106
|
+
node.body = rest if isinstance(node, ast.Module) else (rest or [ast.Pass()])
|
|
107
|
+
return tree
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def verify_equivalent(original: str, stripped: str, options) -> tuple[bool, str]:
|
|
111
|
+
"""Confirm stripping did not change the program.
|
|
112
|
+
|
|
113
|
+
Compares ASTs with all docstrings normalised away, so the check is
|
|
114
|
+
independent of the docstring policy. If the *original* does not parse
|
|
115
|
+
there is nothing to verify against and the check passes.
|
|
116
|
+
"""
|
|
117
|
+
try:
|
|
118
|
+
original_tree = ast.parse(original)
|
|
119
|
+
except SyntaxError:
|
|
120
|
+
return True, "original not parseable; nothing to verify"
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
stripped_tree = ast.parse(stripped)
|
|
124
|
+
except SyntaxError as exc:
|
|
125
|
+
return False, f"stripped source no longer parses: line {exc.lineno}"
|
|
126
|
+
|
|
127
|
+
left = ast.dump(_strip_all_docstrings(original_tree), include_attributes=False)
|
|
128
|
+
right = ast.dump(_strip_all_docstrings(stripped_tree), include_attributes=False)
|
|
129
|
+
if left != right:
|
|
130
|
+
return False, "abstract syntax trees differ"
|
|
131
|
+
return True, "ok"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""PySide6 front end. A thin shell over `core.pipeline`."""
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""QApplication bootstrap."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from PySide6.QtWidgets import QApplication
|
|
8
|
+
|
|
9
|
+
from .. import APP_NAME, DISPLAY_NAME, __version__
|
|
10
|
+
from .branding import app_icon, claim_taskbar_identity
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def run(argv: list[str] | None = None) -> int:
|
|
14
|
+
# Must happen before the first window exists, or Windows has already
|
|
15
|
+
# decided which taskbar group this process belongs to.
|
|
16
|
+
claim_taskbar_identity()
|
|
17
|
+
|
|
18
|
+
app = QApplication(argv if argv is not None else sys.argv)
|
|
19
|
+
app.setApplicationName(APP_NAME)
|
|
20
|
+
app.setApplicationDisplayName(DISPLAY_NAME)
|
|
21
|
+
app.setApplicationVersion(__version__)
|
|
22
|
+
app.setOrganizationName(APP_NAME)
|
|
23
|
+
# Applies to every window and dialog, not just the main one.
|
|
24
|
+
app.setWindowIcon(app_icon())
|
|
25
|
+
|
|
26
|
+
from .main_window import MainWindow
|
|
27
|
+
|
|
28
|
+
window = MainWindow()
|
|
29
|
+
window.show()
|
|
30
|
+
return app.exec()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
if __name__ == "__main__":
|
|
34
|
+
raise SystemExit(run())
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""The application icon.
|
|
2
|
+
|
|
3
|
+
The logo is rasterised at a fixed set of sizes rather than handed to Qt as
|
|
4
|
+
an SVG file, for two reasons: it works when the package is imported from a
|
|
5
|
+
zip or a wheel (where there is no filesystem path to give Qt), and it lets
|
|
6
|
+
the window manager pick a crisp pre-rendered size instead of scaling one
|
|
7
|
+
bitmap for the title bar, the alt-tab switcher and the taskbar alike.
|
|
8
|
+
|
|
9
|
+
Everything here degrades quietly. A missing or unreadable logo costs the
|
|
10
|
+
window its icon; it must never stop the application from starting.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import sys
|
|
16
|
+
|
|
17
|
+
from PySide6.QtCore import QByteArray, QRectF, Qt
|
|
18
|
+
from PySide6.QtGui import QIcon, QImage, QPainter, QPixmap
|
|
19
|
+
|
|
20
|
+
from .. import APP_NAME
|
|
21
|
+
|
|
22
|
+
#: Sizes Windows, macOS and common Linux desktops actually ask for.
|
|
23
|
+
ICON_SIZES = (16, 20, 24, 32, 48, 64, 128, 256)
|
|
24
|
+
|
|
25
|
+
_cached_icon: QIcon | None = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def logo_bytes() -> bytes | None:
|
|
29
|
+
"""Read the packaged logo, whether installed as files or in a zip."""
|
|
30
|
+
try:
|
|
31
|
+
from importlib.resources import files
|
|
32
|
+
|
|
33
|
+
return (files("copyright_deposit.assets") / "logo.svg").read_bytes()
|
|
34
|
+
except Exception:
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def app_icon() -> QIcon:
|
|
39
|
+
"""The Codendium mark as a multi-resolution icon."""
|
|
40
|
+
global _cached_icon
|
|
41
|
+
if _cached_icon is not None:
|
|
42
|
+
return _cached_icon
|
|
43
|
+
|
|
44
|
+
icon = QIcon()
|
|
45
|
+
data = logo_bytes()
|
|
46
|
+
if data:
|
|
47
|
+
try:
|
|
48
|
+
from PySide6.QtSvg import QSvgRenderer
|
|
49
|
+
|
|
50
|
+
renderer = QSvgRenderer(QByteArray(data))
|
|
51
|
+
if renderer.isValid():
|
|
52
|
+
for size in ICON_SIZES:
|
|
53
|
+
image = QImage(size, size, QImage.Format.Format_ARGB32)
|
|
54
|
+
image.fill(Qt.GlobalColor.transparent)
|
|
55
|
+
painter = QPainter(image)
|
|
56
|
+
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
|
57
|
+
renderer.render(painter, QRectF(0, 0, size, size))
|
|
58
|
+
painter.end()
|
|
59
|
+
icon.addPixmap(QPixmap.fromImage(image))
|
|
60
|
+
except Exception:
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
_cached_icon = icon
|
|
64
|
+
return icon
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def claim_taskbar_identity() -> None:
|
|
68
|
+
"""Let Windows show our icon in the taskbar instead of Python's.
|
|
69
|
+
|
|
70
|
+
Without an explicit AppUserModelID, Windows groups the process under
|
|
71
|
+
the host interpreter and shows python.exe's icon on the taskbar, no
|
|
72
|
+
matter what window icon is set. Harmless and skipped elsewhere.
|
|
73
|
+
"""
|
|
74
|
+
if not sys.platform.startswith("win"):
|
|
75
|
+
return
|
|
76
|
+
try:
|
|
77
|
+
import ctypes
|
|
78
|
+
|
|
79
|
+
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(
|
|
80
|
+
f"{APP_NAME}.desktop.1"
|
|
81
|
+
)
|
|
82
|
+
except Exception:
|
|
83
|
+
pass
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Persistent history and reusable profiles.
|
|
2
|
+
|
|
3
|
+
Two things are remembered: named *profiles* (a complete settings snapshot
|
|
4
|
+
for one program, so the next version's registration starts where the last
|
|
5
|
+
one finished) and a log of *runs* (what was built, when, and with which
|
|
6
|
+
settings, so a filing can be reproduced or explained later).
|
|
7
|
+
|
|
8
|
+
Settings are stored as JSON and reloaded tolerantly, so a database written
|
|
9
|
+
by an older version of the tool still opens.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import sqlite3
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from .. import APP_NAME
|
|
22
|
+
from ..config import BuildSettings
|
|
23
|
+
|
|
24
|
+
_SCHEMA = """
|
|
25
|
+
CREATE TABLE IF NOT EXISTS profiles (
|
|
26
|
+
name TEXT PRIMARY KEY,
|
|
27
|
+
settings TEXT NOT NULL,
|
|
28
|
+
updated_utc TEXT NOT NULL
|
|
29
|
+
);
|
|
30
|
+
CREATE TABLE IF NOT EXISTS runs (
|
|
31
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
32
|
+
started_utc TEXT NOT NULL,
|
|
33
|
+
program_name TEXT NOT NULL DEFAULT '',
|
|
34
|
+
source_root TEXT NOT NULL DEFAULT '',
|
|
35
|
+
settings TEXT NOT NULL,
|
|
36
|
+
total_pages INTEGER NOT NULL DEFAULT 0,
|
|
37
|
+
deposit_pages INTEGER NOT NULL DEFAULT 0,
|
|
38
|
+
deposit_mode TEXT NOT NULL DEFAULT '',
|
|
39
|
+
outputs TEXT NOT NULL DEFAULT '[]',
|
|
40
|
+
fingerprint TEXT NOT NULL DEFAULT '',
|
|
41
|
+
warning_count INTEGER NOT NULL DEFAULT 0
|
|
42
|
+
);
|
|
43
|
+
CREATE INDEX IF NOT EXISTS runs_started ON runs (started_utc DESC);
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class RunRecord:
|
|
49
|
+
id: int
|
|
50
|
+
started_utc: str
|
|
51
|
+
program_name: str
|
|
52
|
+
source_root: str
|
|
53
|
+
total_pages: int
|
|
54
|
+
deposit_pages: int
|
|
55
|
+
deposit_mode: str
|
|
56
|
+
outputs: list[str]
|
|
57
|
+
fingerprint: str
|
|
58
|
+
warning_count: int
|
|
59
|
+
settings_json: str
|
|
60
|
+
|
|
61
|
+
def settings(self) -> BuildSettings:
|
|
62
|
+
return BuildSettings.from_json(self.settings_json)
|
|
63
|
+
|
|
64
|
+
def label(self) -> str:
|
|
65
|
+
stamp = self.started_utc.replace("T", " ")[:16]
|
|
66
|
+
name = self.program_name or Path(self.source_root).name or "(unnamed)"
|
|
67
|
+
return f"{stamp} - {name} - {self.total_pages}p"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def data_dir() -> Path:
|
|
71
|
+
base = os.environ.get("APPDATA") or os.environ.get("XDG_DATA_HOME")
|
|
72
|
+
root = Path(base) if base else Path.home() / ".local" / "share"
|
|
73
|
+
path = root / APP_NAME
|
|
74
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
return path
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class HistoryStore:
|
|
79
|
+
def __init__(self, path: str | Path | None = None) -> None:
|
|
80
|
+
self.path = Path(path) if path else data_dir() / "history.db"
|
|
81
|
+
self._connection = sqlite3.connect(str(self.path))
|
|
82
|
+
self._connection.row_factory = sqlite3.Row
|
|
83
|
+
self._connection.executescript(_SCHEMA)
|
|
84
|
+
self._connection.commit()
|
|
85
|
+
|
|
86
|
+
def close(self) -> None:
|
|
87
|
+
self._connection.close()
|
|
88
|
+
|
|
89
|
+
# -- profiles ---------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
def save_profile(self, name: str, settings: BuildSettings) -> None:
|
|
92
|
+
self._connection.execute(
|
|
93
|
+
"INSERT INTO profiles (name, settings, updated_utc) VALUES (?, ?, ?) "
|
|
94
|
+
"ON CONFLICT(name) DO UPDATE SET settings=excluded.settings, "
|
|
95
|
+
"updated_utc=excluded.updated_utc",
|
|
96
|
+
(name, settings.to_json(indent=None), _now()),
|
|
97
|
+
)
|
|
98
|
+
self._connection.commit()
|
|
99
|
+
|
|
100
|
+
def profile_names(self) -> list[str]:
|
|
101
|
+
rows = self._connection.execute(
|
|
102
|
+
"SELECT name FROM profiles ORDER BY updated_utc DESC"
|
|
103
|
+
).fetchall()
|
|
104
|
+
return [row["name"] for row in rows]
|
|
105
|
+
|
|
106
|
+
def load_profile(self, name: str) -> BuildSettings | None:
|
|
107
|
+
row = self._connection.execute(
|
|
108
|
+
"SELECT settings FROM profiles WHERE name = ?", (name,)
|
|
109
|
+
).fetchone()
|
|
110
|
+
if row is None:
|
|
111
|
+
return None
|
|
112
|
+
try:
|
|
113
|
+
return BuildSettings.from_json(row["settings"])
|
|
114
|
+
except (ValueError, TypeError):
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
def delete_profile(self, name: str) -> None:
|
|
118
|
+
self._connection.execute("DELETE FROM profiles WHERE name = ?", (name,))
|
|
119
|
+
self._connection.commit()
|
|
120
|
+
|
|
121
|
+
# -- runs -------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
def record_run(
|
|
124
|
+
self,
|
|
125
|
+
settings: BuildSettings,
|
|
126
|
+
total_pages: int,
|
|
127
|
+
deposit_pages: int,
|
|
128
|
+
deposit_mode: str,
|
|
129
|
+
outputs: list[str],
|
|
130
|
+
warning_count: int,
|
|
131
|
+
) -> int:
|
|
132
|
+
cursor = self._connection.execute(
|
|
133
|
+
"INSERT INTO runs (started_utc, program_name, source_root, settings, "
|
|
134
|
+
"total_pages, deposit_pages, deposit_mode, outputs, fingerprint, warning_count) "
|
|
135
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
136
|
+
(
|
|
137
|
+
_now(),
|
|
138
|
+
settings.header.program_name,
|
|
139
|
+
settings.source_root,
|
|
140
|
+
settings.to_json(indent=None),
|
|
141
|
+
total_pages,
|
|
142
|
+
deposit_pages,
|
|
143
|
+
deposit_mode,
|
|
144
|
+
json.dumps(outputs),
|
|
145
|
+
settings.content_fingerprint(),
|
|
146
|
+
warning_count,
|
|
147
|
+
),
|
|
148
|
+
)
|
|
149
|
+
self._connection.commit()
|
|
150
|
+
return int(cursor.lastrowid or 0)
|
|
151
|
+
|
|
152
|
+
def recent_runs(self, limit: int = 50) -> list[RunRecord]:
|
|
153
|
+
rows = self._connection.execute(
|
|
154
|
+
"SELECT * FROM runs ORDER BY id DESC LIMIT ?", (limit,)
|
|
155
|
+
).fetchall()
|
|
156
|
+
return [_to_record(row) for row in rows]
|
|
157
|
+
|
|
158
|
+
def delete_run(self, run_id: int) -> None:
|
|
159
|
+
self._connection.execute("DELETE FROM runs WHERE id = ?", (run_id,))
|
|
160
|
+
self._connection.commit()
|
|
161
|
+
|
|
162
|
+
def recent_folders(self, limit: int = 10) -> list[str]:
|
|
163
|
+
rows = self._connection.execute(
|
|
164
|
+
"SELECT source_root, MAX(id) AS latest FROM runs "
|
|
165
|
+
"WHERE source_root <> '' GROUP BY source_root ORDER BY latest DESC LIMIT ?",
|
|
166
|
+
(limit,),
|
|
167
|
+
).fetchall()
|
|
168
|
+
return [row["source_root"] for row in rows]
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _now() -> str:
|
|
172
|
+
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _to_record(row: sqlite3.Row) -> RunRecord:
|
|
176
|
+
try:
|
|
177
|
+
outputs = json.loads(row["outputs"])
|
|
178
|
+
except (ValueError, TypeError):
|
|
179
|
+
outputs = []
|
|
180
|
+
return RunRecord(
|
|
181
|
+
id=row["id"],
|
|
182
|
+
started_utc=row["started_utc"],
|
|
183
|
+
program_name=row["program_name"],
|
|
184
|
+
source_root=row["source_root"],
|
|
185
|
+
total_pages=row["total_pages"],
|
|
186
|
+
deposit_pages=row["deposit_pages"],
|
|
187
|
+
deposit_mode=row["deposit_mode"],
|
|
188
|
+
outputs=outputs,
|
|
189
|
+
fingerprint=row["fingerprint"],
|
|
190
|
+
warning_count=row["warning_count"],
|
|
191
|
+
settings_json=row["settings"],
|
|
192
|
+
)
|