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.
Files changed (45) hide show
  1. codendium-1.0.0.dist-info/METADATA +332 -0
  2. codendium-1.0.0.dist-info/RECORD +45 -0
  3. codendium-1.0.0.dist-info/WHEEL +5 -0
  4. codendium-1.0.0.dist-info/entry_points.txt +6 -0
  5. codendium-1.0.0.dist-info/licenses/LICENSE +201 -0
  6. codendium-1.0.0.dist-info/top_level.txt +1 -0
  7. copyright_deposit/__init__.py +15 -0
  8. copyright_deposit/__main__.py +34 -0
  9. copyright_deposit/assets/__init__.py +5 -0
  10. copyright_deposit/assets/fonts/README.md +31 -0
  11. copyright_deposit/assets/logo.svg +26 -0
  12. copyright_deposit/cli.py +314 -0
  13. copyright_deposit/config.py +269 -0
  14. copyright_deposit/core/__init__.py +1 -0
  15. copyright_deposit/core/deposit.py +117 -0
  16. copyright_deposit/core/discovery.py +260 -0
  17. copyright_deposit/core/encoding.py +136 -0
  18. copyright_deposit/core/languages.py +190 -0
  19. copyright_deposit/core/layout.py +349 -0
  20. copyright_deposit/core/lineranges.py +219 -0
  21. copyright_deposit/core/manifest.py +282 -0
  22. copyright_deposit/core/metrics.py +279 -0
  23. copyright_deposit/core/ordering.py +349 -0
  24. copyright_deposit/core/pipeline.py +386 -0
  25. copyright_deposit/core/redaction.py +162 -0
  26. copyright_deposit/core/render.py +242 -0
  27. copyright_deposit/core/scanning/__init__.py +61 -0
  28. copyright_deposit/core/scanning/secrets.py +181 -0
  29. copyright_deposit/core/scanning/thirdparty.py +190 -0
  30. copyright_deposit/core/strip/__init__.py +337 -0
  31. copyright_deposit/core/strip/cfamily_strip.py +235 -0
  32. copyright_deposit/core/strip/pygments_strip.py +85 -0
  33. copyright_deposit/core/strip/python_strip.py +131 -0
  34. copyright_deposit/gui/__init__.py +1 -0
  35. copyright_deposit/gui/app.py +34 -0
  36. copyright_deposit/gui/branding.py +83 -0
  37. copyright_deposit/gui/history.py +192 -0
  38. copyright_deposit/gui/main_window.py +617 -0
  39. copyright_deposit/gui/panels/__init__.py +1 -0
  40. copyright_deposit/gui/panels/estimate.py +166 -0
  41. copyright_deposit/gui/panels/files.py +635 -0
  42. copyright_deposit/gui/panels/identification.py +193 -0
  43. copyright_deposit/gui/panels/options.py +445 -0
  44. copyright_deposit/gui/panels/preflight.py +260 -0
  45. 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
+ )