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,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>
|
copyright_deposit/cli.py
ADDED
|
@@ -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."""
|