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,193 @@
1
+ """Source/output locations and the identification block.
2
+
3
+ The Copyright Office requires the program title and version on the first
4
+ page of the deposit, so these fields are not optional decoration - they are
5
+ part of what makes the filing acceptable.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from datetime import date
11
+ from pathlib import Path
12
+
13
+ from PySide6.QtCore import Signal
14
+ from PySide6.QtWidgets import (
15
+ QFileDialog,
16
+ QFormLayout,
17
+ QGroupBox,
18
+ QHBoxLayout,
19
+ QLabel,
20
+ QLineEdit,
21
+ QPushButton,
22
+ QVBoxLayout,
23
+ QWidget,
24
+ )
25
+
26
+ from ...config import BuildSettings
27
+ from ...core.discovery import git_revision
28
+
29
+
30
+ class _PathRow(QWidget):
31
+ """A line edit with a Browse button."""
32
+
33
+ changed = Signal(str)
34
+
35
+ def __init__(self, placeholder: str, parent=None) -> None:
36
+ super().__init__(parent)
37
+ layout = QHBoxLayout(self)
38
+ layout.setContentsMargins(0, 0, 0, 0)
39
+ self.edit = QLineEdit()
40
+ self.edit.setPlaceholderText(placeholder)
41
+ self.edit.textChanged.connect(self.changed.emit)
42
+ self.button = QPushButton("Browse...")
43
+ self.button.clicked.connect(self._browse)
44
+ layout.addWidget(self.edit, 1)
45
+ layout.addWidget(self.button)
46
+
47
+ def _browse(self) -> None:
48
+ start = self.edit.text() or str(Path.home())
49
+ chosen = QFileDialog.getExistingDirectory(self, "Select folder", start)
50
+ if chosen:
51
+ self.edit.setText(chosen)
52
+
53
+ def text(self) -> str:
54
+ return self.edit.text().strip()
55
+
56
+ def setText(self, value: str) -> None:
57
+ self.edit.setText(value)
58
+
59
+
60
+ class IdentificationPanel(QWidget):
61
+ sourceChanged = Signal(str)
62
+ changed = Signal()
63
+
64
+ def __init__(self, parent=None) -> None:
65
+ super().__init__(parent)
66
+ outer = QVBoxLayout(self)
67
+
68
+ locations = QGroupBox("Locations")
69
+ form = QFormLayout(locations)
70
+ self.source = _PathRow("folder containing the source code")
71
+ self.source.changed.connect(self._on_source_changed)
72
+ self.output = _PathRow("where the PDFs are written")
73
+ self.output.changed.connect(lambda _: self.changed.emit())
74
+ self.basename = QLineEdit("deposit")
75
+ self.basename.textChanged.connect(lambda _: self.changed.emit())
76
+ form.addRow("Source folder", self.source)
77
+ form.addRow("Output folder", self.output)
78
+ form.addRow("File name prefix", self.basename)
79
+
80
+ identification = QGroupBox("Identification block (printed on page 1)")
81
+ id_form = QFormLayout(identification)
82
+
83
+ self.program_name = QLineEdit()
84
+ self.program_name.setPlaceholderText("e.g. Acme Route Planner")
85
+ self.version = QLineEdit("1.0.0")
86
+ self.release_date = QLineEdit(date.today().isoformat())
87
+ self.release_date.setPlaceholderText("YYYY-MM-DD")
88
+
89
+ revision_row = QWidget()
90
+ revision_layout = QHBoxLayout(revision_row)
91
+ revision_layout.setContentsMargins(0, 0, 0, 0)
92
+ self.revision = QLineEdit()
93
+ self.revision.setPlaceholderText("optional; pins the exact code deposited")
94
+ self.git_button = QPushButton("From git")
95
+ self.git_button.setToolTip("Read the short commit hash of the source folder")
96
+ self.git_button.clicked.connect(self._fill_revision)
97
+ revision_layout.addWidget(self.revision, 1)
98
+ revision_layout.addWidget(self.git_button)
99
+
100
+ self.owner = QLineEdit()
101
+ self.owner.setPlaceholderText("the person or company claiming copyright")
102
+ self.year = QLineEdit(str(date.today().year))
103
+ self.deposit_label = QLineEdit("Deposit Copy - Identifying Portions of Source Code")
104
+
105
+ for widget in (
106
+ self.program_name, self.version, self.release_date, self.revision,
107
+ self.owner, self.year, self.deposit_label,
108
+ ):
109
+ widget.textChanged.connect(lambda _: self.changed.emit())
110
+
111
+ id_form.addRow("Program name", self.program_name)
112
+ id_form.addRow("Version", self.version)
113
+ id_form.addRow("Release/build date", self.release_date)
114
+ id_form.addRow("Revision/commit", revision_row)
115
+ id_form.addRow("Copyright owner", self.owner)
116
+ id_form.addRow("Copyright year", self.year)
117
+ id_form.addRow("Deposit label", self.deposit_label)
118
+
119
+ self.preview = QLabel()
120
+ self.preview.setWordWrap(True)
121
+ self.preview.setStyleSheet(
122
+ "QLabel { font-family: 'Courier New', monospace; background: palette(base); "
123
+ "border: 1px solid palette(mid); padding: 8px; }"
124
+ )
125
+ id_form.addRow("Preview", self.preview)
126
+ self.changed.connect(self._refresh_preview)
127
+
128
+ outer.addWidget(locations)
129
+ outer.addWidget(identification)
130
+ outer.addStretch(1)
131
+ self._refresh_preview()
132
+
133
+ # -- helpers -----------------------------------------------------------
134
+
135
+ def _on_source_changed(self, value: str) -> None:
136
+ if value and not self.output.text():
137
+ self.output.setText(str(Path(value) / "copyright_deposit_output"))
138
+ if value and not self.program_name.text():
139
+ self.program_name.setText(Path(value).name)
140
+ self.sourceChanged.emit(value)
141
+ self.changed.emit()
142
+
143
+ def _fill_revision(self) -> None:
144
+ revision = git_revision(self.source.text())
145
+ self.revision.setText(revision or "")
146
+ if not revision:
147
+ self.revision.setPlaceholderText("no git revision found for this folder")
148
+
149
+ def _refresh_preview(self) -> None:
150
+ settings = BuildSettings()
151
+ self.collect(settings)
152
+ self.preview.setText("\n".join(settings.header.lines()))
153
+
154
+ # -- settings ----------------------------------------------------------
155
+
156
+ def collect(self, settings: BuildSettings) -> None:
157
+ settings.source_root = self.source.text()
158
+ settings.output_dir = self.output.text()
159
+ settings.output_basename = self.basename.text().strip() or "deposit"
160
+ header = settings.header
161
+ header.program_name = self.program_name.text().strip()
162
+ header.version = self.version.text().strip()
163
+ header.release_date = self.release_date.text().strip()
164
+ header.revision = self.revision.text().strip()
165
+ header.copyright_owner = self.owner.text().strip()
166
+ header.copyright_year = self.year.text().strip()
167
+ header.deposit_label = self.deposit_label.text().strip()
168
+
169
+ def apply(self, settings: BuildSettings) -> None:
170
+ blockers = [
171
+ self.source.edit, self.output.edit, self.basename, self.program_name,
172
+ self.version, self.release_date, self.revision, self.owner,
173
+ self.year, self.deposit_label,
174
+ ]
175
+ for widget in blockers:
176
+ widget.blockSignals(True)
177
+ try:
178
+ self.source.setText(settings.source_root)
179
+ self.output.setText(settings.output_dir)
180
+ self.basename.setText(settings.output_basename)
181
+ header = settings.header
182
+ self.program_name.setText(header.program_name)
183
+ self.version.setText(header.version)
184
+ self.release_date.setText(header.release_date)
185
+ self.revision.setText(header.revision)
186
+ self.owner.setText(header.copyright_owner)
187
+ self.year.setText(header.copyright_year)
188
+ self.deposit_label.setText(header.deposit_label)
189
+ finally:
190
+ for widget in blockers:
191
+ widget.blockSignals(False)
192
+ self._refresh_preview()
193
+ self.changed.emit()
@@ -0,0 +1,445 @@
1
+ """Every policy toggle, grouped by what it affects.
2
+
3
+ The comment policy defaults to removing everything, with independent
4
+ switches for comments and docstrings so the operator can trade page count
5
+ against disclosure without editing any source.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from PySide6.QtCore import Signal
11
+ from PySide6.QtWidgets import (
12
+ QCheckBox,
13
+ QComboBox,
14
+ QDoubleSpinBox,
15
+ QFileDialog,
16
+ QFormLayout,
17
+ QGroupBox,
18
+ QHBoxLayout,
19
+ QLabel,
20
+ QLineEdit,
21
+ QPlainTextEdit,
22
+ QPushButton,
23
+ QScrollArea,
24
+ QSpinBox,
25
+ QVBoxLayout,
26
+ QWidget,
27
+ )
28
+
29
+ from ...config import DEFAULT_EXTENSIONS, BuildSettings
30
+
31
+
32
+ class OptionsPanel(QWidget):
33
+ changed = Signal()
34
+
35
+ def __init__(self, parent=None) -> None:
36
+ super().__init__(parent)
37
+ outer = QVBoxLayout(self)
38
+ outer.setContentsMargins(0, 0, 0, 0)
39
+
40
+ scroll = QScrollArea()
41
+ scroll.setWidgetResizable(True)
42
+ inner = QWidget()
43
+ layout = QVBoxLayout(inner)
44
+
45
+ layout.addWidget(self._comment_group())
46
+ layout.addWidget(self._grid_group())
47
+ layout.addWidget(self._deposit_group())
48
+ layout.addWidget(self._discovery_group())
49
+ layout.addWidget(self._redaction_group())
50
+ layout.addWidget(self._scanning_group())
51
+ layout.addStretch(1)
52
+
53
+ scroll.setWidget(inner)
54
+ outer.addWidget(scroll)
55
+ self._wire()
56
+
57
+ # -- groups ------------------------------------------------------------
58
+
59
+ def _comment_group(self) -> QGroupBox:
60
+ box = QGroupBox("Comment policy")
61
+ form = QVBoxLayout(box)
62
+
63
+ self.strip_comments = QCheckBox("Remove comments")
64
+ self.strip_docstrings = QCheckBox("Remove docstrings")
65
+ self.preserve_legal = QCheckBox(
66
+ "Keep leading comment blocks that carry a copyright or licence notice"
67
+ )
68
+ self.preserve_shebang = QCheckBox("Keep the #! line")
69
+ self.trim_trailing = QCheckBox("Trim trailing whitespace")
70
+ self.expand_tabs = QCheckBox("Expand tabs")
71
+
72
+ self.strip_comments.setChecked(True)
73
+ self.strip_docstrings.setChecked(True)
74
+ self.preserve_shebang.setChecked(True)
75
+ self.trim_trailing.setChecked(True)
76
+ self.expand_tabs.setChecked(True)
77
+
78
+ self.tab_width = QSpinBox()
79
+ self.tab_width.setRange(1, 16)
80
+ self.tab_width.setValue(4)
81
+
82
+ self.collapse_blanks = QCheckBox("Collapse runs of blank lines to at most")
83
+ self.collapse_blanks.setChecked(True)
84
+ self.max_blank_run = QSpinBox()
85
+ self.max_blank_run.setRange(0, 10)
86
+ self.max_blank_run.setValue(1)
87
+
88
+ form.addWidget(self.strip_comments)
89
+ form.addWidget(self.strip_docstrings)
90
+ form.addWidget(self.preserve_legal)
91
+ form.addWidget(self.preserve_shebang)
92
+
93
+ blank_row = QHBoxLayout()
94
+ blank_row.addWidget(self.collapse_blanks)
95
+ blank_row.addWidget(self.max_blank_run)
96
+ blank_row.addWidget(QLabel("line(s)"))
97
+ blank_row.addStretch(1)
98
+ form.addLayout(blank_row)
99
+
100
+ tab_row = QHBoxLayout()
101
+ tab_row.addWidget(self.expand_tabs)
102
+ tab_row.addWidget(QLabel("to"))
103
+ tab_row.addWidget(self.tab_width)
104
+ tab_row.addWidget(QLabel("spaces"))
105
+ tab_row.addStretch(1)
106
+ form.addLayout(tab_row)
107
+ form.addWidget(self.trim_trailing)
108
+
109
+ note = QLabel(
110
+ "Python stripping is verified: if removing comments would change the "
111
+ "parse tree, that file is kept verbatim and a warning is raised."
112
+ )
113
+ note.setWordWrap(True)
114
+ note.setStyleSheet("color: palette(placeholderText);")
115
+ form.addWidget(note)
116
+ return box
117
+
118
+ def _grid_group(self) -> QGroupBox:
119
+ box = QGroupBox("Page grid")
120
+ form = QFormLayout(box)
121
+
122
+ self.page_size = QComboBox()
123
+ self.page_size.addItems(["letter", "a4"])
124
+
125
+ font_row = QWidget()
126
+ font_layout = QHBoxLayout(font_row)
127
+ font_layout.setContentsMargins(0, 0, 0, 0)
128
+ self.font_name = QLineEdit("Courier")
129
+ self.font_name.setToolTip(
130
+ "'Courier' is a built-in PDF font: always monospaced, no embedding "
131
+ "needed. You may also point at a monospaced .ttf file."
132
+ )
133
+ font_button = QPushButton("Choose .ttf...")
134
+ font_button.clicked.connect(self._choose_font)
135
+ font_layout.addWidget(self.font_name, 1)
136
+ font_layout.addWidget(font_button)
137
+
138
+ self.font_size = QDoubleSpinBox()
139
+ self.font_size.setRange(6.0, 14.0)
140
+ self.font_size.setSingleStep(0.5)
141
+ self.font_size.setValue(9.5)
142
+
143
+ self.lines_per_page = QSpinBox()
144
+ self.lines_per_page.setRange(20, 80)
145
+ self.lines_per_page.setValue(40)
146
+
147
+ self.margin = QDoubleSpinBox()
148
+ self.margin.setRange(18.0, 108.0)
149
+ self.margin.setValue(54.0)
150
+
151
+ self.show_line_numbers = QCheckBox("Show original source line numbers")
152
+ self.show_file_banners = QCheckBox("Print a FILE: banner at each boundary")
153
+ self.show_file_banners.setChecked(True)
154
+ self.running_header = QCheckBox("Running header with program name")
155
+ self.running_header.setChecked(True)
156
+ self.page_numbers = QCheckBox("Continuous page numbers")
157
+ self.page_numbers.setChecked(True)
158
+ self.new_page_per_file = QCheckBox("Start each file on a new page")
159
+
160
+ form.addRow("Page size", self.page_size)
161
+ form.addRow("Font", font_row)
162
+ form.addRow("Font size (pt)", self.font_size)
163
+ form.addRow("Lines per page", self.lines_per_page)
164
+ form.addRow("Margin (pt)", self.margin)
165
+ form.addRow("", self.show_line_numbers)
166
+ form.addRow("", self.show_file_banners)
167
+ form.addRow("", self.running_header)
168
+ form.addRow("", self.page_numbers)
169
+ form.addRow("", self.new_page_per_file)
170
+
171
+ guidance = QLabel(
172
+ "The Office asks for a monospaced face at roughly 9-10 pt and about "
173
+ "40 lines per page. Compressing more lines onto a page to disclose "
174
+ "less is not permitted."
175
+ )
176
+ guidance.setWordWrap(True)
177
+ guidance.setStyleSheet("color: palette(placeholderText);")
178
+ form.addRow(guidance)
179
+ return box
180
+
181
+ def _deposit_group(self) -> QGroupBox:
182
+ box = QGroupBox("Deposit rule (Compendium sec. 721.6)")
183
+ form = QFormLayout(box)
184
+
185
+ self.apply_rule = QCheckBox(
186
+ "Apply the rule: whole program at or under the threshold, otherwise first/last pages"
187
+ )
188
+ self.apply_rule.setChecked(True)
189
+ self.threshold = QSpinBox()
190
+ self.threshold.setRange(1, 500)
191
+ self.threshold.setValue(50)
192
+ self.head_pages = QSpinBox()
193
+ self.head_pages.setRange(0, 250)
194
+ self.head_pages.setValue(25)
195
+ self.tail_pages = QSpinBox()
196
+ self.tail_pages.setRange(0, 250)
197
+ self.tail_pages.setValue(25)
198
+ self.separator_page = QCheckBox("Insert a sheet noting which pages were omitted")
199
+ self.separator_page.setChecked(True)
200
+
201
+ form.addRow(self.apply_rule)
202
+ form.addRow("Threshold (pages)", self.threshold)
203
+ form.addRow("First pages", self.head_pages)
204
+ form.addRow("Last pages", self.tail_pages)
205
+ form.addRow("", self.separator_page)
206
+ return box
207
+
208
+ def _discovery_group(self) -> QGroupBox:
209
+ box = QGroupBox("Which files to include")
210
+ form = QFormLayout(box)
211
+
212
+ self.extensions = QLineEdit(" ".join(DEFAULT_EXTENSIONS))
213
+ self.extensions.setToolTip("Space-separated list of file extensions")
214
+ self.respect_gitignore = QCheckBox("Respect .gitignore")
215
+ self.respect_gitignore.setChecked(True)
216
+ self.skip_minified = QCheckBox("Skip minified and generated files")
217
+ self.skip_minified.setChecked(True)
218
+ self.max_size = QSpinBox()
219
+ self.max_size.setRange(1, 64)
220
+ self.max_size.setValue(2)
221
+ self.max_size.setSuffix(" MB")
222
+ self.ignore_dirs = QPlainTextEdit()
223
+ self.ignore_dirs.setPlaceholderText("one directory name per line")
224
+ self.ignore_dirs.setMaximumHeight(90)
225
+
226
+ form.addRow("Extensions", self.extensions)
227
+ form.addRow("", self.respect_gitignore)
228
+ form.addRow("", self.skip_minified)
229
+ form.addRow("Maximum file size", self.max_size)
230
+ form.addRow("Ignored folders", self.ignore_dirs)
231
+ return box
232
+
233
+ def _redaction_group(self) -> QGroupBox:
234
+ box = QGroupBox("Trade-secret redaction (Compendium sec. 721.7)")
235
+ form = QFormLayout(box)
236
+
237
+ self.redaction_enabled = QCheckBox("Black out marked material")
238
+ self.begin_marker = QLineEdit("COPYRIGHT-REDACT-BEGIN")
239
+ self.end_marker = QLineEdit("COPYRIGHT-REDACT-END")
240
+ self.redaction_regexes = QPlainTextEdit()
241
+ self.redaction_regexes.setPlaceholderText("one regular expression per line")
242
+ self.redaction_regexes.setMaximumHeight(80)
243
+ self.manual_lines = QPlainTextEdit()
244
+ self.manual_lines.setPlaceholderText("path/to/file.py:42 - added from the Pre-flight tab")
245
+ self.manual_lines.setMaximumHeight(80)
246
+
247
+ form.addRow(self.redaction_enabled)
248
+ form.addRow("Begin marker", self.begin_marker)
249
+ form.addRow("End marker", self.end_marker)
250
+ form.addRow("Patterns", self.redaction_regexes)
251
+ form.addRow("Specific lines", self.manual_lines)
252
+
253
+ note = QLabel(
254
+ "Redacted text is never written into the PDF, so it cannot be copied "
255
+ "or extracted. Blocked-out material must stay at or under 49% of the "
256
+ "deposited text."
257
+ )
258
+ note.setWordWrap(True)
259
+ note.setStyleSheet("color: palette(placeholderText);")
260
+ form.addRow(note)
261
+ return box
262
+
263
+ def _scanning_group(self) -> QGroupBox:
264
+ box = QGroupBox("Pre-flight checks")
265
+ layout = QVBoxLayout(box)
266
+ self.scan_secrets = QCheckBox("Scan for credentials and personal data")
267
+ self.scan_secrets.setChecked(True)
268
+ self.scan_third_party = QCheckBox("Scan for third-party or generated code")
269
+ self.scan_third_party.setChecked(True)
270
+ self.block_on_secrets = QCheckBox("Refuse to build while credentials are unresolved")
271
+ self.block_on_secrets.setChecked(True)
272
+ layout.addWidget(self.scan_secrets)
273
+ layout.addWidget(self.scan_third_party)
274
+ layout.addWidget(self.block_on_secrets)
275
+
276
+ note = QLabel(
277
+ "A deposit becomes a public record. Anything left in the code can be "
278
+ "read by anyone who inspects the filing."
279
+ )
280
+ note.setWordWrap(True)
281
+ note.setStyleSheet("color: palette(placeholderText);")
282
+ layout.addWidget(note)
283
+ return box
284
+
285
+ # -- wiring ------------------------------------------------------------
286
+
287
+ def _wire(self) -> None:
288
+ checkboxes = [
289
+ self.strip_comments, self.strip_docstrings, self.preserve_legal,
290
+ self.preserve_shebang, self.trim_trailing, self.expand_tabs,
291
+ self.collapse_blanks, self.show_line_numbers, self.show_file_banners,
292
+ self.running_header, self.page_numbers, self.new_page_per_file,
293
+ self.apply_rule, self.separator_page, self.respect_gitignore,
294
+ self.skip_minified, self.redaction_enabled, self.scan_secrets,
295
+ self.scan_third_party, self.block_on_secrets,
296
+ ]
297
+ for box in checkboxes:
298
+ box.toggled.connect(lambda _: self.changed.emit())
299
+ for spin in (
300
+ self.tab_width, self.max_blank_run, self.lines_per_page,
301
+ self.threshold, self.head_pages, self.tail_pages, self.max_size,
302
+ ):
303
+ spin.valueChanged.connect(lambda _: self.changed.emit())
304
+ for spin in (self.font_size, self.margin):
305
+ spin.valueChanged.connect(lambda _: self.changed.emit())
306
+ for edit in (self.font_name, self.extensions, self.begin_marker, self.end_marker):
307
+ edit.textChanged.connect(lambda _: self.changed.emit())
308
+ for text in (self.redaction_regexes, self.manual_lines, self.ignore_dirs):
309
+ text.textChanged.connect(self.changed.emit)
310
+ self.page_size.currentTextChanged.connect(lambda _: self.changed.emit())
311
+
312
+ self.collapse_blanks.toggled.connect(self.max_blank_run.setEnabled)
313
+ self.expand_tabs.toggled.connect(self.tab_width.setEnabled)
314
+
315
+ def _choose_font(self) -> None:
316
+ path, _ = QFileDialog.getOpenFileName(
317
+ self, "Choose a monospaced TrueType font", "", "TrueType fonts (*.ttf)"
318
+ )
319
+ if path:
320
+ self.font_name.setText(path)
321
+
322
+ # -- settings ----------------------------------------------------------
323
+
324
+ def collect(self, settings: BuildSettings) -> None:
325
+ transform = settings.transform
326
+ transform.strip_comments = self.strip_comments.isChecked()
327
+ transform.strip_docstrings = self.strip_docstrings.isChecked()
328
+ transform.preserve_legal_headers = self.preserve_legal.isChecked()
329
+ transform.preserve_shebang = self.preserve_shebang.isChecked()
330
+ transform.collapse_blank_runs = self.collapse_blanks.isChecked()
331
+ transform.max_blank_run = self.max_blank_run.value()
332
+ transform.trim_trailing_whitespace = self.trim_trailing.isChecked()
333
+ transform.expand_tabs = self.expand_tabs.isChecked()
334
+ transform.tab_width = self.tab_width.value()
335
+
336
+ layout = settings.layout
337
+ layout.page_size = self.page_size.currentText()
338
+ layout.font_name = self.font_name.text().strip() or "Courier"
339
+ layout.font_size = self.font_size.value()
340
+ layout.lines_per_page = self.lines_per_page.value()
341
+ layout.margin = self.margin.value()
342
+ layout.show_line_numbers = self.show_line_numbers.isChecked()
343
+ layout.show_file_banners = self.show_file_banners.isChecked()
344
+ layout.running_header = self.running_header.isChecked()
345
+ layout.page_numbers = self.page_numbers.isChecked()
346
+ layout.start_files_on_new_page = self.new_page_per_file.isChecked()
347
+
348
+ deposit = settings.deposit
349
+ deposit.apply_rule = self.apply_rule.isChecked()
350
+ deposit.threshold = self.threshold.value()
351
+ deposit.head_pages = self.head_pages.value()
352
+ deposit.tail_pages = self.tail_pages.value()
353
+ deposit.separator_page = self.separator_page.isChecked()
354
+
355
+ discovery = settings.discovery
356
+ extensions = [e if e.startswith(".") else f".{e}" for e in self.extensions.text().split()]
357
+ discovery.extensions = extensions or list(DEFAULT_EXTENSIONS)
358
+ discovery.respect_gitignore = self.respect_gitignore.isChecked()
359
+ discovery.skip_minified = self.skip_minified.isChecked()
360
+ discovery.max_file_bytes = self.max_size.value() * 1024 * 1024
361
+ ignore_text = self.ignore_dirs.toPlainText().strip()
362
+ if ignore_text:
363
+ discovery.ignore_dirs = [line.strip() for line in ignore_text.splitlines() if line.strip()]
364
+
365
+ redaction = settings.redaction
366
+ redaction.enabled = self.redaction_enabled.isChecked()
367
+ redaction.begin_marker = self.begin_marker.text().strip()
368
+ redaction.end_marker = self.end_marker.text().strip()
369
+ redaction.regexes = [
370
+ line.strip() for line in self.redaction_regexes.toPlainText().splitlines() if line.strip()
371
+ ]
372
+ redaction.manual_lines = [
373
+ line.strip() for line in self.manual_lines.toPlainText().splitlines() if line.strip()
374
+ ]
375
+
376
+ scan = settings.scan
377
+ scan.scan_secrets = self.scan_secrets.isChecked()
378
+ scan.scan_third_party = self.scan_third_party.isChecked()
379
+ scan.block_on_secrets = self.block_on_secrets.isChecked()
380
+
381
+ def apply(self, settings: BuildSettings) -> None:
382
+ self.blockSignals(True)
383
+ try:
384
+ transform = settings.transform
385
+ self.strip_comments.setChecked(transform.strip_comments)
386
+ self.strip_docstrings.setChecked(transform.strip_docstrings)
387
+ self.preserve_legal.setChecked(transform.preserve_legal_headers)
388
+ self.preserve_shebang.setChecked(transform.preserve_shebang)
389
+ self.collapse_blanks.setChecked(transform.collapse_blank_runs)
390
+ self.max_blank_run.setValue(transform.max_blank_run)
391
+ self.trim_trailing.setChecked(transform.trim_trailing_whitespace)
392
+ self.expand_tabs.setChecked(transform.expand_tabs)
393
+ self.tab_width.setValue(transform.tab_width)
394
+
395
+ layout = settings.layout
396
+ self.page_size.setCurrentText(layout.page_size)
397
+ self.font_name.setText(layout.font_name)
398
+ self.font_size.setValue(layout.font_size)
399
+ self.lines_per_page.setValue(layout.lines_per_page)
400
+ self.margin.setValue(layout.margin)
401
+ self.show_line_numbers.setChecked(layout.show_line_numbers)
402
+ self.show_file_banners.setChecked(layout.show_file_banners)
403
+ self.running_header.setChecked(layout.running_header)
404
+ self.page_numbers.setChecked(layout.page_numbers)
405
+ self.new_page_per_file.setChecked(layout.start_files_on_new_page)
406
+
407
+ deposit = settings.deposit
408
+ self.apply_rule.setChecked(deposit.apply_rule)
409
+ self.threshold.setValue(deposit.threshold)
410
+ self.head_pages.setValue(deposit.head_pages)
411
+ self.tail_pages.setValue(deposit.tail_pages)
412
+ self.separator_page.setChecked(deposit.separator_page)
413
+
414
+ discovery = settings.discovery
415
+ self.extensions.setText(" ".join(discovery.extensions))
416
+ self.respect_gitignore.setChecked(discovery.respect_gitignore)
417
+ self.skip_minified.setChecked(discovery.skip_minified)
418
+ self.max_size.setValue(max(1, discovery.max_file_bytes // (1024 * 1024)))
419
+ self.ignore_dirs.setPlainText("\n".join(discovery.ignore_dirs))
420
+
421
+ redaction = settings.redaction
422
+ self.redaction_enabled.setChecked(redaction.enabled)
423
+ self.begin_marker.setText(redaction.begin_marker)
424
+ self.end_marker.setText(redaction.end_marker)
425
+ self.redaction_regexes.setPlainText("\n".join(redaction.regexes))
426
+ self.manual_lines.setPlainText("\n".join(redaction.manual_lines))
427
+
428
+ scan = settings.scan
429
+ self.scan_secrets.setChecked(scan.scan_secrets)
430
+ self.scan_third_party.setChecked(scan.scan_third_party)
431
+ self.block_on_secrets.setChecked(scan.block_on_secrets)
432
+ finally:
433
+ self.blockSignals(False)
434
+ self.changed.emit()
435
+
436
+ def queue_redactions(self, locations: list[str]) -> None:
437
+ """Add 'path:line' entries from the Pre-flight tab."""
438
+ existing = [
439
+ line.strip() for line in self.manual_lines.toPlainText().splitlines() if line.strip()
440
+ ]
441
+ for location in locations:
442
+ if location not in existing:
443
+ existing.append(location)
444
+ self.manual_lines.setPlainText("\n".join(existing))
445
+ self.redaction_enabled.setChecked(True)