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,31 @@
1
+ # Optional deposit font
2
+
3
+ Codendium renders deposits in **Courier** by default. Courier is one of the PDF
4
+ base-14 faces, so it needs no embedding, is guaranteed monospaced, renders
5
+ identically in every viewer, and raises no font-redistribution question — which
6
+ is what you want in a document you are filing.
7
+
8
+ To use a different face, drop a monospaced TrueType file into this directory.
9
+ The first non-bold, non-italic `.ttf` here is picked up automatically
10
+ (`core.metrics._bundled_font`), embedded in the PDF, and used instead of
11
+ Courier. A `Foo-Bold.ttf` or `FooBd.ttf` beside `Foo.ttf` is used for the bold
12
+ banner lines.
13
+
14
+ You can also point at a font per build without copying anything here:
15
+
16
+ ```
17
+ py -m copyright_deposit build <folder> --font "C:/path/to/DejaVuSansMono.ttf"
18
+ ```
19
+
20
+ Two rules the tool enforces whichever font you choose:
21
+
22
+ - **It must be monospaced.** The page grid, the column count and therefore the
23
+ page estimate all assume a fixed advance width. A proportional font is
24
+ rejected at load time and Courier is used instead.
25
+ - **Missing glyphs are replaced, never dropped.** Characters the font cannot
26
+ draw become `?` and the count is reported as a build warning, so nothing
27
+ disappears from the deposit silently.
28
+
29
+ No font is committed here. Check the licence of any font you add — some
30
+ disallow embedding or redistribution, and this directory ships inside the
31
+ Python package.
@@ -0,0 +1,26 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256" role="img" aria-label="Codendium">
2
+ <title>Codendium</title>
3
+ <!--
4
+ Bracket monogram: a copyright mark held inside code brackets.
5
+
6
+ Deliberately monochrome in a single mid-blue. A two-tone mark with a
7
+ dark glyph would vanish on GitHub's dark theme, and a light one would
8
+ wash out on the light theme; this hue clears both, and at 16px the
9
+ bracket-and-circle silhouette still reads.
10
+
11
+ The (c) is drawn as a stroked circle plus an arc - never a <text>
12
+ glyph, which would depend on fonts the viewer may not have.
13
+ -->
14
+ <g fill="none" stroke="#2F6FEB" stroke-linecap="square" stroke-linejoin="miter">
15
+ <!-- left bracket -->
16
+ <path d="M 78 46 L 44 46 L 44 210 L 78 210" stroke-width="22"/>
17
+ <!-- right bracket -->
18
+ <path d="M 178 46 L 212 46 L 212 210 L 178 210" stroke-width="22"/>
19
+ <!-- Copyright ring. Sized to leave clear air between it and the
20
+ brackets: at 16px the C inside is never going to be legible, so
21
+ what has to survive is three distinct shapes rather than a blob. -->
22
+ <circle cx="128" cy="128" r="41" stroke-width="14"/>
23
+ <!-- the C inside, opening to the right -->
24
+ <path d="M 139.3 114.6 A 17.5 17.5 0 1 0 139.3 141.4" stroke-width="11" stroke-linecap="round"/>
25
+ </g>
26
+ </svg>
@@ -0,0 +1,314 @@
1
+ """Headless entry point.
2
+
3
+ Everything the GUI can do is available here, which keeps the pipeline
4
+ testable without Qt and makes builds reproducible from a script or CI.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ from . import __version__
14
+ from .config import BuildSettings
15
+ from .core import lineranges, ordering
16
+ from .core.deposit import MODE_ENTIRE
17
+ from .core.discovery import git_revision
18
+ from .core.pipeline import Pipeline
19
+
20
+
21
+ def _add_common(parser: argparse.ArgumentParser) -> None:
22
+ parser.add_argument("root", help="folder containing the source code")
23
+ parser.add_argument("-o", "--output", default="out", help="output folder (default: out)")
24
+ parser.add_argument("--basename", default="deposit", help="output file prefix")
25
+ parser.add_argument("--order-file", help="text file listing the deposit order, one path per line")
26
+ parser.add_argument("--settings", help="load a saved settings JSON file")
27
+
28
+ ident = parser.add_argument_group("identification block")
29
+ ident.add_argument("--name", help="program name")
30
+ ident.add_argument("--program-version", help="program version, e.g. 1.0.0")
31
+ ident.add_argument("--owner", help="copyright owner")
32
+ ident.add_argument("--year", help="copyright year")
33
+ ident.add_argument("--date", help="release/build date (YYYY-MM-DD)")
34
+ ident.add_argument("--revision", help="source revision; use 'auto' to read git HEAD")
35
+
36
+ policy = parser.add_argument_group("comment policy")
37
+ policy.add_argument("--keep-comments", action="store_true", help="do not strip comments")
38
+ policy.add_argument("--keep-docstrings", action="store_true", help="do not strip docstrings")
39
+ policy.add_argument(
40
+ "--preserve-legal-headers",
41
+ action="store_true",
42
+ help="keep leading comment blocks that carry a copyright or licence notice",
43
+ )
44
+ policy.add_argument("--no-collapse-blanks", action="store_true", help="keep blank-line runs")
45
+
46
+ page = parser.add_argument_group("page grid")
47
+ page.add_argument("--page-size", choices=["letter", "a4"], help="default: letter")
48
+ page.add_argument("--font-size", type=float, help="default: 9.5")
49
+ page.add_argument("--lines-per-page", type=int, help="default: 40")
50
+ page.add_argument("--line-numbers", action="store_true", help="show original source line numbers")
51
+ page.add_argument("--font", help="'Courier' or a path to a monospaced .ttf")
52
+
53
+ other = parser.add_argument_group("other")
54
+ other.add_argument("--exclude", action="append", default=[], help="relative path to exclude (repeatable)")
55
+ other.add_argument(
56
+ "--lines",
57
+ action="append",
58
+ default=[],
59
+ metavar="PATH=RANGES",
60
+ help="deposit only part of a file, e.g. --lines src/core.py=1-50,120-200 (repeatable)",
61
+ )
62
+ other.add_argument("--no-unlisted", action="store_true", help="include only files named in the order list")
63
+ other.add_argument("--redact", action="store_true", help="enable trade-secret redaction")
64
+ other.add_argument("--redact-regex", action="append", default=[], help="redaction pattern (repeatable)")
65
+ other.add_argument("--no-scan", action="store_true", help="skip the secret and third-party scans")
66
+
67
+
68
+ def _settings_from_args(args: argparse.Namespace) -> BuildSettings:
69
+ if getattr(args, "settings", None):
70
+ settings = BuildSettings.from_json(Path(args.settings).read_text(encoding="utf-8"))
71
+ else:
72
+ settings = BuildSettings()
73
+
74
+ settings.source_root = str(Path(args.root).resolve())
75
+ settings.output_dir = str(Path(args.output).resolve())
76
+ settings.output_basename = args.basename
77
+
78
+ if args.order_file:
79
+ settings.order_entries = ordering.parse_order_text(
80
+ Path(args.order_file).read_text(encoding="utf-8")
81
+ )
82
+
83
+ header = settings.header
84
+ if args.name:
85
+ header.program_name = args.name
86
+ if args.program_version:
87
+ header.version = args.program_version
88
+ if args.owner:
89
+ header.copyright_owner = args.owner
90
+ if args.year:
91
+ header.copyright_year = args.year
92
+ if args.date:
93
+ header.release_date = args.date
94
+ if args.revision:
95
+ header.revision = git_revision(settings.source_root) if args.revision == "auto" else args.revision
96
+
97
+ transform = settings.transform
98
+ if args.keep_comments:
99
+ transform.strip_comments = False
100
+ if args.keep_docstrings:
101
+ transform.strip_docstrings = False
102
+ if args.preserve_legal_headers:
103
+ transform.preserve_legal_headers = True
104
+ if args.no_collapse_blanks:
105
+ transform.collapse_blank_runs = False
106
+
107
+ layout = settings.layout
108
+ if args.page_size:
109
+ layout.page_size = args.page_size
110
+ if args.font_size:
111
+ layout.font_size = args.font_size
112
+ if args.lines_per_page:
113
+ layout.lines_per_page = args.lines_per_page
114
+ if args.line_numbers:
115
+ layout.show_line_numbers = True
116
+ if args.font:
117
+ layout.font_name = args.font
118
+
119
+ settings.excluded = list(args.exclude)
120
+ settings.include_unlisted = not args.no_unlisted
121
+
122
+ for entry in args.lines:
123
+ path, separator, spec = entry.partition("=")
124
+ if not separator:
125
+ raise SystemExit(f"error: --lines expects PATH=RANGES, got '{entry}'")
126
+ path = path.strip().replace("\\", "/").lstrip("./")
127
+ error = lineranges.validate(spec)
128
+ if error:
129
+ raise SystemExit(f"error: --lines {path}: {error}")
130
+ settings.line_ranges[path] = spec.strip()
131
+ if args.redact or args.redact_regex:
132
+ settings.redaction.enabled = True
133
+ settings.redaction.regexes.extend(args.redact_regex)
134
+ if args.no_scan:
135
+ settings.scan.scan_secrets = False
136
+ settings.scan.scan_third_party = False
137
+
138
+ return settings
139
+
140
+
141
+ def _progress(stage: str, current: int, total: int) -> None:
142
+ if total <= 1:
143
+ sys.stderr.write(f"\r{stage}...")
144
+ else:
145
+ sys.stderr.write(f"\r{stage}: {current}/{total} ")
146
+ sys.stderr.flush()
147
+
148
+
149
+ def _report(estimate, show_what_if: list | None = None) -> None:
150
+ selection = estimate.selection
151
+ print()
152
+ print(f"Files included : {len(estimate.prepared)}")
153
+ print(f"Page grid : {estimate.geometry_note}")
154
+ print(f"Complete program : {estimate.page_count} page(s)")
155
+ if selection.mode == MODE_ENTIRE:
156
+ print("Deposit rule : 50 pages or fewer - the entire program is deposited")
157
+ else:
158
+ first, last = selection.omitted or (0, 0)
159
+ print(
160
+ f"Deposit rule : over 50 pages - first {estimate.settings.deposit.head_pages} "
161
+ f"and last {estimate.settings.deposit.tail_pages} pages "
162
+ f"(pages {first}-{last} omitted)"
163
+ )
164
+ print(f"Pages deposited : {selection.deposited_pages}")
165
+
166
+ omitted_files = estimate.omitted_files()
167
+ if omitted_files:
168
+ print(f"\nFiles the Office will not see ({len(omitted_files)}):")
169
+ for path in omitted_files[:10]:
170
+ print(f" - {path}")
171
+ if len(omitted_files) > 10:
172
+ print(f" ... and {len(omitted_files) - 10} more")
173
+
174
+ if show_what_if:
175
+ print("\nPage count by comment policy:")
176
+ for label, pages, mode in show_what_if:
177
+ print(f" {label:<30} {pages:>5} pages ({mode})")
178
+
179
+ secrets = estimate.scan.sorted_secrets()
180
+ if secrets:
181
+ print(f"\nSecret/PII findings ({len(secrets)}):")
182
+ for finding in secrets[:10]:
183
+ print(f" [{finding.severity:>6}] {finding.location()} - {finding.detail} ({finding.excerpt})")
184
+ if len(secrets) > 10:
185
+ print(f" ... and {len(secrets) - 10} more")
186
+
187
+ third_party = estimate.scan.sorted_third_party()
188
+ if third_party:
189
+ print(f"\nThird-party indicators ({len(third_party)}):")
190
+ for finding in third_party[:10]:
191
+ print(f" [{finding.severity:>6}] {finding.location()} - {finding.detail}")
192
+ if len(third_party) > 10:
193
+ print(f" ... and {len(third_party) - 10} more")
194
+
195
+ if estimate.warnings:
196
+ print(f"\nWarnings ({len(estimate.warnings)}):")
197
+ for warning in estimate.warnings[:15]:
198
+ print(f" - {warning}")
199
+ if len(estimate.warnings) > 15:
200
+ print(f" ... and {len(estimate.warnings) - 15} more")
201
+
202
+
203
+ def cmd_estimate(args: argparse.Namespace) -> int:
204
+ settings = _settings_from_args(args)
205
+ pipeline = Pipeline()
206
+ estimate = pipeline.estimate(settings, progress=_progress)
207
+ what_if = pipeline.what_if(settings, progress=_progress) if args.what_if else None
208
+ _report(estimate, what_if)
209
+ return 0
210
+
211
+
212
+ def cmd_build(args: argparse.Namespace) -> int:
213
+ settings = _settings_from_args(args)
214
+ pipeline = Pipeline()
215
+ result = pipeline.build(settings, progress=_progress, force=args.force)
216
+ _report(result.estimate)
217
+
218
+ if result.blocked_by:
219
+ print("\nBUILD BLOCKED: possible credentials found in the selected files.")
220
+ for finding in result.blocked_by:
221
+ print(f" {finding.location()} - {finding.detail} ({finding.excerpt})")
222
+ print("\nRemove them, redact them with --redact, or re-run with --force.")
223
+ return 2
224
+
225
+ print("\nWritten:")
226
+ for path in result.outputs:
227
+ print(f" {path}")
228
+ if result.manifest_path:
229
+ print(f" {result.manifest_path}")
230
+ print(f" {result.summary_path}")
231
+ print(f"\nStatement for the application:\n {result.estimate.selection.filing_statement()}")
232
+ return 0
233
+
234
+
235
+ def cmd_suggest_order(args: argparse.Namespace) -> int:
236
+ settings = _settings_from_args(args)
237
+ pipeline = Pipeline()
238
+ found = pipeline.discover(settings)
239
+ suggested = ordering.suggest_order(found.files)
240
+ text = ordering.format_order_text(suggested)
241
+ if args.write:
242
+ Path(args.write).write_text(text, encoding="utf-8")
243
+ print(f"Wrote {len(suggested)} entries to {args.write}")
244
+ else:
245
+ sys.stdout.write(text)
246
+ return 0
247
+
248
+
249
+ def cmd_save_settings(args: argparse.Namespace) -> int:
250
+ settings = _settings_from_args(args)
251
+ Path(args.write).write_text(settings.to_json(), encoding="utf-8")
252
+ print(f"Wrote settings to {args.write}")
253
+ return 0
254
+
255
+
256
+ def _prog_name() -> str:
257
+ """Name this invocation by the command the user actually typed.
258
+
259
+ The same parser is reached through three entry points - ``codendium``,
260
+ ``copyright-deposit`` and ``python -m copyright_deposit`` - so a hardcoded
261
+ prog would print a command the reader did not run.
262
+ """
263
+ stem = Path(sys.argv[0]).stem
264
+ if stem in {"__main__", "-m", ""}:
265
+ return "python -m copyright_deposit"
266
+ return stem
267
+
268
+
269
+ def build_parser() -> argparse.ArgumentParser:
270
+ parser = argparse.ArgumentParser(
271
+ prog=_prog_name(),
272
+ description="Build US Copyright Office compliant source-code deposit PDFs.",
273
+ )
274
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
275
+ sub = parser.add_subparsers(dest="command", required=True)
276
+
277
+ estimate = sub.add_parser("estimate", help="report the page count without rendering a PDF")
278
+ _add_common(estimate)
279
+ estimate.add_argument("--what-if", action="store_true", help="compare page counts across comment policies")
280
+ estimate.set_defaults(func=cmd_estimate)
281
+
282
+ build = sub.add_parser("build", help="render the deposit PDFs")
283
+ _add_common(build)
284
+ build.add_argument("--force", action="store_true", help="build even if credentials were found")
285
+ build.set_defaults(func=cmd_build)
286
+
287
+ suggest = sub.add_parser("suggest-order", help="propose a deposit order from the code structure")
288
+ _add_common(suggest)
289
+ suggest.add_argument("--write", help="write the order list to this file")
290
+ suggest.set_defaults(func=cmd_suggest_order)
291
+
292
+ save = sub.add_parser("save-settings", help="write the resolved settings to JSON")
293
+ _add_common(save)
294
+ save.add_argument("--write", required=True, help="destination JSON file")
295
+ save.set_defaults(func=cmd_save_settings)
296
+
297
+ return parser
298
+
299
+
300
+ def main(argv: list[str] | None = None) -> int:
301
+ parser = build_parser()
302
+ args = parser.parse_args(argv)
303
+ try:
304
+ return args.func(args)
305
+ except KeyboardInterrupt:
306
+ print("\nInterrupted.", file=sys.stderr)
307
+ return 130
308
+ except FileNotFoundError as exc:
309
+ print(f"error: {exc}", file=sys.stderr)
310
+ return 1
311
+
312
+
313
+ if __name__ == "__main__":
314
+ raise SystemExit(main())
@@ -0,0 +1,269 @@
1
+ """Settings model.
2
+
3
+ Every knob the tool exposes lives here as a dataclass. The whole tree is
4
+ JSON round-trippable, so a build can be stored in history and reproduced
5
+ byte-for-byte later.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import dataclasses
11
+ import hashlib
12
+ import json
13
+ from dataclasses import dataclass, field
14
+ from datetime import date
15
+ from typing import Any
16
+
17
+ # ---------------------------------------------------------------------------
18
+ # Defaults
19
+ # ---------------------------------------------------------------------------
20
+
21
+ DEFAULT_EXTENSIONS: tuple[str, ...] = (
22
+ ".py", ".pyi", ".pyx",
23
+ ".c", ".h", ".cpp", ".cc", ".cxx", ".hpp", ".hh", ".hxx", ".inl",
24
+ ".java", ".cs", ".js", ".jsx", ".ts", ".tsx", ".go", ".rs", ".swift",
25
+ ".kt", ".kts", ".m", ".mm", ".scala", ".php", ".rb", ".pl", ".lua",
26
+ ".sql", ".sh", ".bash", ".ps1", ".r", ".jl", ".dart", ".vb", ".f90",
27
+ )
28
+
29
+ # Directory names never worth depositing: build output, dependencies,
30
+ # tool caches and vendored third-party code.
31
+ DEFAULT_IGNORE_DIRS: tuple[str, ...] = (
32
+ ".git", ".hg", ".svn", ".idea", ".vs", ".vscode", "__pycache__",
33
+ ".pytest_cache", ".mypy_cache", ".ruff_cache", ".tox", ".eggs",
34
+ "node_modules", "bower_components", "venv", ".venv", "env",
35
+ "site-packages", "dist-packages", "vendor", "third_party", "thirdparty",
36
+ "external", "extern", "build", "_build", "dist", "out", "bin", "obj",
37
+ "target", "cmake-build-debug", "cmake-build-release", "coverage",
38
+ "htmlcov", ".next", ".nuxt", ".gradle", "Pods", "DerivedData",
39
+ )
40
+
41
+ DEFAULT_IGNORE_GLOBS: tuple[str, ...] = (
42
+ "*.min.js", "*.min.css", "*_pb2.py", "*_pb2_grpc.py", "*.pb.go",
43
+ "*.pb.cc", "*.pb.h", "*.g.cs", "*.designer.cs", "*.generated.*",
44
+ "*.lock", "package-lock.json",
45
+ )
46
+
47
+ LEGAL_HEADER_PATTERN = (
48
+ r"copyright|\(c\)|©|licen[sc]e|SPDX-License-Identifier|all rights reserved"
49
+ )
50
+
51
+ MAX_FILE_BYTES = 2 * 1024 * 1024
52
+ MINIFIED_MEAN_LINE_LEN = 200
53
+
54
+ # Compendium section 721.6.
55
+ DEPOSIT_PAGE_THRESHOLD = 50
56
+ DEPOSIT_HEAD_PAGES = 25
57
+ DEPOSIT_TAIL_PAGES = 25
58
+
59
+ # Compendium section 721.7: blocked-out material may not exceed this share.
60
+ MAX_REDACTION_RATIO = 0.49
61
+
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # Dataclasses
65
+ # ---------------------------------------------------------------------------
66
+
67
+
68
+ @dataclass
69
+ class HeaderInfo:
70
+ """The identification block the Office requires on the first page."""
71
+
72
+ program_name: str = ""
73
+ version: str = "1.0.0"
74
+ release_date: str = field(default_factory=lambda: date.today().isoformat())
75
+ revision: str = "" # git short hash; optional but pins the exact deposit
76
+ copyright_owner: str = ""
77
+ copyright_year: str = field(default_factory=lambda: str(date.today().year))
78
+ deposit_label: str = "Deposit Copy - Identifying Portions of Source Code"
79
+ extra_notice: str = ""
80
+
81
+ def lines(self) -> list[str]:
82
+ """Render the block exactly as it appears at the top of page 1."""
83
+ out = [self.program_name.strip() or "[PROGRAM NAME]"]
84
+ out.append(f"Version: {self.version}".rstrip())
85
+ if self.release_date:
86
+ out.append(f"Release/build date: {self.release_date}")
87
+ if self.revision:
88
+ out.append(f"Source revision/commit: {self.revision}")
89
+ owner = self.copyright_owner.strip() or "[copyright owner]"
90
+ out.append(f"Copyright (c) {self.copyright_year} {owner}")
91
+ if self.deposit_label:
92
+ out.append(self.deposit_label)
93
+ if self.extra_notice:
94
+ out.extend(self.extra_notice.splitlines())
95
+ return out
96
+
97
+ def title_line(self) -> str:
98
+ name = self.program_name.strip() or "[PROGRAM NAME]"
99
+ return f"{name} v{self.version}" if self.version else name
100
+
101
+
102
+ @dataclass
103
+ class DiscoveryOptions:
104
+ extensions: list[str] = field(default_factory=lambda: list(DEFAULT_EXTENSIONS))
105
+ ignore_dirs: list[str] = field(default_factory=lambda: list(DEFAULT_IGNORE_DIRS))
106
+ ignore_globs: list[str] = field(default_factory=lambda: list(DEFAULT_IGNORE_GLOBS))
107
+ respect_gitignore: bool = True
108
+ max_file_bytes: int = MAX_FILE_BYTES
109
+ skip_minified: bool = True
110
+ skip_empty: bool = True
111
+ follow_symlinks: bool = False
112
+
113
+
114
+ @dataclass
115
+ class TransformOptions:
116
+ """Comment/whitespace policy. Default is 'strip everything'."""
117
+
118
+ strip_comments: bool = True
119
+ strip_docstrings: bool = True
120
+ preserve_legal_headers: bool = False
121
+ preserve_shebang: bool = True
122
+ collapse_blank_runs: bool = True
123
+ max_blank_run: int = 1
124
+ trim_trailing_whitespace: bool = True
125
+ expand_tabs: bool = True
126
+ tab_width: int = 4
127
+
128
+ def cache_key(self) -> str:
129
+ return json.dumps(dataclasses.asdict(self), sort_keys=True)
130
+
131
+
132
+ @dataclass
133
+ class LayoutOptions:
134
+ page_size: str = "letter" # "letter" | "a4"
135
+ font_name: str = "Courier" # base-14; or a .ttf path / bundled family name
136
+ font_size: float = 9.5
137
+ lines_per_page: int = 40
138
+ margin: float = 54.0 # 0.75 inch
139
+ show_line_numbers: bool = False
140
+ show_file_banners: bool = True
141
+ running_header: bool = True
142
+ page_numbers: bool = True
143
+ wrap_marker: str = ">> "
144
+ file_gap_lines: int = 1 # blank lines before a file banner
145
+ start_files_on_new_page: bool = False
146
+
147
+ def cache_key(self) -> str:
148
+ return json.dumps(dataclasses.asdict(self), sort_keys=True)
149
+
150
+
151
+ @dataclass
152
+ class RedactionRules:
153
+ """Compendium 721.7 blocked-out material."""
154
+
155
+ enabled: bool = False
156
+ begin_marker: str = "COPYRIGHT-REDACT-BEGIN"
157
+ end_marker: str = "COPYRIGHT-REDACT-END"
158
+ regexes: list[str] = field(default_factory=list)
159
+ # "relative/path.py:12" entries, typically queued from the secret scanner.
160
+ manual_lines: list[str] = field(default_factory=list)
161
+
162
+ def cache_key(self) -> str:
163
+ return json.dumps(dataclasses.asdict(self), sort_keys=True)
164
+
165
+
166
+ @dataclass
167
+ class ScanOptions:
168
+ scan_secrets: bool = True
169
+ scan_third_party: bool = True
170
+ block_on_secrets: bool = True
171
+ ignored_findings: list[str] = field(default_factory=list) # finding ids
172
+
173
+
174
+ @dataclass
175
+ class DepositOptions:
176
+ apply_rule: bool = True
177
+ threshold: int = DEPOSIT_PAGE_THRESHOLD
178
+ head_pages: int = DEPOSIT_HEAD_PAGES
179
+ tail_pages: int = DEPOSIT_TAIL_PAGES
180
+ separator_page: bool = True
181
+ keep_original_page_numbers: bool = True
182
+
183
+
184
+ @dataclass
185
+ class BuildSettings:
186
+ source_root: str = ""
187
+ output_dir: str = ""
188
+ output_basename: str = "deposit"
189
+ order_entries: list[str] = field(default_factory=list)
190
+ excluded: list[str] = field(default_factory=list) # relative paths
191
+ # Optional per-file line selection: {"src/core.py": "1-50, 120-200"}.
192
+ # An absent or empty entry means the whole file.
193
+ line_ranges: dict[str, str] = field(default_factory=dict)
194
+ include_unlisted: bool = True
195
+ write_full_pdf: bool = True
196
+ write_deposit_pdf: bool = True
197
+ write_manifest: bool = True
198
+
199
+ header: HeaderInfo = field(default_factory=HeaderInfo)
200
+ discovery: DiscoveryOptions = field(default_factory=DiscoveryOptions)
201
+ transform: TransformOptions = field(default_factory=TransformOptions)
202
+ layout: LayoutOptions = field(default_factory=LayoutOptions)
203
+ redaction: RedactionRules = field(default_factory=RedactionRules)
204
+ scan: ScanOptions = field(default_factory=ScanOptions)
205
+ deposit: DepositOptions = field(default_factory=DepositOptions)
206
+
207
+ # -- serialisation ----------------------------------------------------
208
+
209
+ def to_dict(self) -> dict[str, Any]:
210
+ return dataclasses.asdict(self)
211
+
212
+ def to_json(self, indent: int | None = 2) -> str:
213
+ return json.dumps(self.to_dict(), indent=indent, sort_keys=True)
214
+
215
+ @classmethod
216
+ def from_dict(cls, data: dict[str, Any]) -> "BuildSettings":
217
+ return _rebuild(cls, data)
218
+
219
+ @classmethod
220
+ def from_json(cls, text: str) -> "BuildSettings":
221
+ return cls.from_dict(json.loads(text))
222
+
223
+ def fingerprint(self) -> str:
224
+ """Stable hash of every setting; identifies a run in the history."""
225
+ return hashlib.sha256(self.to_json(indent=None).encode("utf-8")).hexdigest()
226
+
227
+ def content_fingerprint(self) -> str:
228
+ """Hash of only the settings that affect the rendered pages.
229
+
230
+ Where the PDF is written, and which artifacts are produced, do not
231
+ change a single glyph - so they must not change the fingerprint
232
+ stamped into the file. Excluding them is what makes two runs of the
233
+ same source byte-identical regardless of output folder.
234
+ """
235
+ data = self.to_dict()
236
+ for key in ("output_dir", "output_basename", "write_full_pdf",
237
+ "write_deposit_pdf", "write_manifest"):
238
+ data.pop(key, None)
239
+ payload = json.dumps(data, sort_keys=True)
240
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()
241
+
242
+
243
+ _NESTED: dict[str, type] = {
244
+ "header": HeaderInfo,
245
+ "discovery": DiscoveryOptions,
246
+ "transform": TransformOptions,
247
+ "layout": LayoutOptions,
248
+ "redaction": RedactionRules,
249
+ "scan": ScanOptions,
250
+ "deposit": DepositOptions,
251
+ }
252
+
253
+
254
+ def _rebuild(cls: type, data: Any) -> Any:
255
+ """Rebuild nested dataclasses, tolerating unknown or missing keys.
256
+
257
+ Tolerance matters: history rows written by an older version of the tool
258
+ must still load rather than crash the GUI.
259
+ """
260
+ if not isinstance(data, dict):
261
+ return data
262
+ kwargs: dict[str, Any] = {}
263
+ known = {f.name for f in dataclasses.fields(cls)}
264
+ for key, value in data.items():
265
+ if key not in known:
266
+ continue
267
+ nested = _NESTED.get(key)
268
+ kwargs[key] = _rebuild(nested, value) if nested is not None else value
269
+ return cls(**kwargs)
@@ -0,0 +1 @@
1
+ """Core pipeline: discovery -> ordering -> transform -> layout -> render."""