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,349 @@
1
+ """Pagination onto the fixed page grid.
2
+
3
+ This module is pure: it takes text and returns pages. It performs no I/O
4
+ and never touches reportlab, which is why the page count it produces can
5
+ be shown to the user instantly and is guaranteed to equal the page count
6
+ of the PDF the renderer later writes from the very same result.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+
13
+ from ..config import LayoutOptions
14
+ from .metrics import PageGeometry
15
+ from .strip import SourceLine
16
+
17
+ KIND_HEADER = "header"
18
+ KIND_RULE = "rule"
19
+ KIND_BANNER = "banner"
20
+ KIND_CODE = "code"
21
+ KIND_BLANK = "blank"
22
+ KIND_NOTICE = "notice"
23
+ KIND_ELISION = "elision"
24
+
25
+ Span = tuple[int, int]
26
+
27
+
28
+ @dataclass
29
+ class RenderLine:
30
+ text: str = ""
31
+ kind: str = KIND_BLANK
32
+ number: int | None = None # original source line number, for the gutter
33
+ bold: bool = False
34
+ redactions: tuple[Span, ...] = ()
35
+ file_path: str = ""
36
+ continuation: bool = False
37
+
38
+
39
+ @dataclass
40
+ class Page:
41
+ number: int # 1-based
42
+ lines: list[RenderLine] = field(default_factory=list)
43
+
44
+
45
+ @dataclass
46
+ class FileRange:
47
+ rel_path: str
48
+ first_page: int
49
+ last_page: int
50
+ source_lines: int
51
+ rendered_lines: int
52
+
53
+
54
+ @dataclass
55
+ class FileBlock:
56
+ """One file's contribution to the deposit."""
57
+
58
+ rel_path: str
59
+ lines: list[SourceLine]
60
+ language: str = ""
61
+ # Redaction column spans, keyed by index into `lines`.
62
+ redactions: dict[int, list[Span]] = field(default_factory=dict)
63
+ # Line-range selection. `elisions` maps an index into `lines` to the
64
+ # (first, last) source lines omitted immediately before it; `trailing`
65
+ # covers lines dropped from the end of the file.
66
+ elisions: dict[int, tuple[int, int]] = field(default_factory=dict)
67
+ trailing_elision: tuple[int, int] | None = None
68
+ # Non-empty only when part of the file was selected, e.g.
69
+ # "1-50, 120-200 of 380". Drives the PARTIAL FILE banner.
70
+ range_label: str = ""
71
+
72
+
73
+ @dataclass
74
+ class LayoutResult:
75
+ pages: list[Page] = field(default_factory=list)
76
+ file_ranges: list[FileRange] = field(default_factory=list)
77
+ geometry: PageGeometry | None = None
78
+ warnings: list[str] = field(default_factory=list)
79
+ source_lines: int = 0
80
+ rendered_lines: int = 0
81
+ wrapped_lines: int = 0
82
+ glyph_replacements: int = 0
83
+ redacted_chars: int = 0
84
+ total_chars: int = 0
85
+
86
+ @property
87
+ def page_count(self) -> int:
88
+ return len(self.pages)
89
+
90
+ @property
91
+ def redaction_ratio(self) -> float:
92
+ return self.redacted_chars / self.total_chars if self.total_chars else 0.0
93
+
94
+ def range_for(self, rel_path: str) -> FileRange | None:
95
+ return next((r for r in self.file_ranges if r.rel_path == rel_path), None)
96
+
97
+
98
+ # ---------------------------------------------------------------------------
99
+ # Wrapping
100
+ # ---------------------------------------------------------------------------
101
+
102
+
103
+ def wrap_source_line(text: str, width: int, marker: str) -> list[tuple[int, str, str]]:
104
+ """Split an overlong line into (original column, prefix, body) chunks.
105
+
106
+ Overlong lines are wrapped, never truncated: the deposit has to be the
107
+ complete text of the pages it contains.
108
+ """
109
+ width = max(1, width)
110
+ if len(text) <= width:
111
+ return [(0, "", text)]
112
+
113
+ marker = marker if len(marker) < width else ""
114
+ body_width = max(1, width - len(marker))
115
+
116
+ chunks: list[tuple[int, str, str]] = [(0, "", text[:width])]
117
+ position = width
118
+ while position < len(text):
119
+ chunks.append((position, marker, text[position : position + body_width]))
120
+ position += body_width
121
+ return chunks
122
+
123
+
124
+ def _remap_spans(spans: list[Span], start: int, length: int, offset: int) -> tuple[Span, ...]:
125
+ """Move redaction spans into a wrapped chunk's coordinate space."""
126
+ out: list[Span] = []
127
+ end = start + length
128
+ for a, b in spans:
129
+ lo, hi = max(a, start), min(b, end)
130
+ if lo < hi:
131
+ out.append((lo - start + offset, hi - start + offset))
132
+ return tuple(out)
133
+
134
+
135
+ # ---------------------------------------------------------------------------
136
+ # Emitter
137
+ # ---------------------------------------------------------------------------
138
+
139
+
140
+ class _Emitter:
141
+ def __init__(self, geometry: PageGeometry) -> None:
142
+ self.geometry = geometry
143
+ self.pages: list[Page] = []
144
+ self._current: Page | None = None
145
+
146
+ @property
147
+ def rows_left(self) -> int:
148
+ if self._current is None:
149
+ return 0
150
+ return self.geometry.lines_per_page - len(self._current.lines)
151
+
152
+ @property
153
+ def current_page_number(self) -> int:
154
+ return len(self.pages) if self._current is not None else len(self.pages) + 1
155
+
156
+ def _new_page(self) -> Page:
157
+ page = Page(number=len(self.pages) + 1)
158
+ self.pages.append(page)
159
+ self._current = page
160
+ return page
161
+
162
+ def emit(self, line: RenderLine) -> None:
163
+ if self._current is None or self.rows_left == 0:
164
+ self._new_page()
165
+ assert self._current is not None
166
+ self._current.lines.append(line)
167
+
168
+ def blank(self, count: int = 1) -> None:
169
+ for _ in range(count):
170
+ self.emit(RenderLine())
171
+
172
+ def page_break(self) -> None:
173
+ """Finish the current page so the next emit starts a fresh one."""
174
+ if self._current is None or not self._current.lines:
175
+ return
176
+ self._current = None
177
+
178
+ def ensure_rows(self, needed: int) -> None:
179
+ """Start a new page unless `needed` rows remain (widow control)."""
180
+ if self._current is not None and 0 < self.rows_left < needed:
181
+ self.page_break()
182
+
183
+ def finish(self) -> list[Page]:
184
+ return self.pages
185
+
186
+
187
+ # ---------------------------------------------------------------------------
188
+ # Layout
189
+ # ---------------------------------------------------------------------------
190
+
191
+
192
+ def _banner_lines(block: FileBlock, geometry: PageGeometry) -> list[RenderLine]:
193
+ """The FILE: header, plus a PARTIAL FILE notice when only part is included.
194
+
195
+ A reader must never be able to mistake an extract for a whole file, so
196
+ the fact is stated at the boundary as well as at each cut.
197
+ """
198
+ rel_path = block.rel_path
199
+ rule = "-" * min(60, geometry.code_columns)
200
+ lines = [
201
+ RenderLine(text=f"FILE: {rel_path}", kind=KIND_BANNER, bold=True, file_path=rel_path)
202
+ ]
203
+ if block.range_label:
204
+ lines.append(
205
+ RenderLine(
206
+ text=f"PARTIAL FILE - lines {block.range_label} included",
207
+ kind=KIND_BANNER,
208
+ bold=True,
209
+ file_path=rel_path,
210
+ )
211
+ )
212
+ lines.append(RenderLine(text=rule, kind=KIND_RULE, file_path=rel_path))
213
+ return lines
214
+
215
+
216
+ def _dashes(count: int) -> str:
217
+ return ("- " * (count // 2 + 1))[:count] if count > 0 else ""
218
+
219
+
220
+ def elision_text(first: int, last: int, width: int) -> str:
221
+ """A dashed rule naming exactly which source lines are missing."""
222
+ if first == last:
223
+ label = f" [ line {first} omitted ] "
224
+ else:
225
+ label = f" [ lines {first}-{last} omitted ({last - first + 1} lines) ] "
226
+ if len(label) >= width:
227
+ return label.strip()[:width]
228
+ remaining = width - len(label)
229
+ left = remaining // 2
230
+ return _dashes(left) + label + _dashes(remaining - left)
231
+
232
+
233
+ def build_layout(
234
+ blocks: list[FileBlock],
235
+ header_lines: list[str],
236
+ options: LayoutOptions,
237
+ geometry: PageGeometry,
238
+ ) -> LayoutResult:
239
+ """Lay the identification block and every file onto the page grid."""
240
+ result = LayoutResult(geometry=geometry)
241
+ emitter = _Emitter(geometry)
242
+ font = geometry.font
243
+ width = geometry.code_columns
244
+
245
+ # -- identification block, top of page 1 ------------------------------
246
+ if header_lines:
247
+ for index, raw in enumerate(header_lines):
248
+ text, replaced = font.sanitize(raw)
249
+ result.glyph_replacements += replaced
250
+ for _start, prefix, body in wrap_source_line(text, width, options.wrap_marker):
251
+ emitter.emit(
252
+ RenderLine(
253
+ text=prefix + body,
254
+ kind=KIND_HEADER,
255
+ bold=(index == 0),
256
+ )
257
+ )
258
+ emitter.emit(RenderLine(text="=" * min(60, width), kind=KIND_RULE))
259
+ emitter.blank()
260
+
261
+ # -- files -------------------------------------------------------------
262
+ for position, block in enumerate(blocks):
263
+ if options.start_files_on_new_page and position > 0:
264
+ emitter.page_break()
265
+ elif position > 0 or header_lines:
266
+ if options.file_gap_lines and emitter.rows_left not in (0, geometry.lines_per_page):
267
+ emitter.blank(options.file_gap_lines)
268
+
269
+ if options.show_file_banners:
270
+ # Never leave a banner stranded at the foot of a page.
271
+ banner = _banner_lines(block, geometry)
272
+ emitter.ensure_rows(len(banner) + 1)
273
+ for line in banner:
274
+ emitter.emit(line)
275
+
276
+ first_page = emitter.current_page_number
277
+ rendered = 0
278
+
279
+ def emit_elision(gap: tuple[int, int]) -> None:
280
+ nonlocal rendered
281
+ emitter.emit(
282
+ RenderLine(
283
+ text=elision_text(gap[0], gap[1], width),
284
+ kind=KIND_ELISION,
285
+ file_path=block.rel_path,
286
+ )
287
+ )
288
+ rendered += 1
289
+
290
+ for index, source_line in enumerate(block.lines):
291
+ gap = block.elisions.get(index)
292
+ if gap is not None:
293
+ emit_elision(gap)
294
+ text, replaced = font.sanitize(source_line.text)
295
+ result.glyph_replacements += replaced
296
+ spans = block.redactions.get(index, [])
297
+ chunks = wrap_source_line(text, width, options.wrap_marker)
298
+ if len(chunks) > 1:
299
+ result.wrapped_lines += 1
300
+
301
+ for chunk_index, (start, prefix, body) in enumerate(chunks):
302
+ mapped = _remap_spans(spans, start, len(body), len(prefix))
303
+ emitter.emit(
304
+ RenderLine(
305
+ text=prefix + body,
306
+ kind=KIND_CODE if body.strip() or spans else KIND_BLANK,
307
+ number=source_line.number if chunk_index == 0 else None,
308
+ redactions=mapped,
309
+ file_path=block.rel_path,
310
+ continuation=chunk_index > 0,
311
+ )
312
+ )
313
+ rendered += 1
314
+ result.total_chars += len(body)
315
+ result.redacted_chars += sum(b - a for a, b in mapped)
316
+
317
+ if block.trailing_elision is not None:
318
+ emit_elision(block.trailing_elision)
319
+
320
+ result.source_lines += len(block.lines)
321
+ result.rendered_lines += rendered
322
+ last_page = emitter.current_page_number
323
+ result.file_ranges.append(
324
+ FileRange(
325
+ rel_path=block.rel_path,
326
+ first_page=first_page,
327
+ last_page=max(first_page, last_page),
328
+ source_lines=len(block.lines),
329
+ rendered_lines=rendered,
330
+ )
331
+ )
332
+
333
+ result.pages = emitter.finish()
334
+ if result.glyph_replacements:
335
+ result.warnings.append(
336
+ f"{result.glyph_replacements} character(s) had no glyph in "
337
+ f"{font.regular} and were replaced with '?'."
338
+ )
339
+ return result
340
+
341
+
342
+ def max_source_line_number(blocks: list[FileBlock]) -> int:
343
+ """Widest gutter needed, so the column width is stable across the PDF."""
344
+ best = 0
345
+ for block in blocks:
346
+ for line in block.lines:
347
+ if line.number > best:
348
+ best = line.number
349
+ return best
@@ -0,0 +1,219 @@
1
+ """Per-file line selection.
2
+
3
+ Sometimes only part of a file belongs in the deposit. This module parses a
4
+ range spec ("1-50, 120-200, 305-"), applies it, and works out where the
5
+ elision markers go.
6
+
7
+ Two rules govern the design:
8
+
9
+ * **Original line numbers.** A range means lines as they appear in the
10
+ editor, not positions after comments were stripped. ``SourceLine.number``
11
+ carries the original number through the transform, so selection stays
12
+ intuitive whatever the comment policy is.
13
+
14
+ * **Gaps come from the spec, not from what survived.** If comment removal
15
+ deleted lines 1-2, the file must not claim "lines 1-2 omitted" - the
16
+ operator did not omit them, the policy did. Elisions are therefore the
17
+ complement of the requested ranges over the file's true length.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import re
23
+ from dataclasses import dataclass, field
24
+
25
+ from .strip import SourceLine
26
+
27
+ # Stand-in for "to the end of the file" before the total is known.
28
+ OPEN_END = 10**9
29
+
30
+ Range = tuple[int, int]
31
+
32
+ # "12", "12-40", "12-" (to EOF), "-40" (from the start)
33
+ _TOKEN = re.compile(r"^(?:(\d+)\s*-\s*(\d+)?|-\s*(\d+)|(\d+))$")
34
+
35
+
36
+ @dataclass
37
+ class Selection:
38
+ """The result of applying a range spec to one file."""
39
+
40
+ lines: list[SourceLine] = field(default_factory=list)
41
+ # index into `lines` -> the (first, last) source lines omitted before it
42
+ elisions: dict[int, Range] = field(default_factory=dict)
43
+ trailing: Range | None = None
44
+ omitted_lines: int = 0
45
+ partial: bool = False
46
+ label: str = ""
47
+ warnings: list[str] = field(default_factory=list)
48
+
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # Parsing
52
+ # ---------------------------------------------------------------------------
53
+
54
+
55
+ def merge(ranges: list[Range]) -> list[Range]:
56
+ """Sort and coalesce overlapping or touching ranges."""
57
+ if not ranges:
58
+ return []
59
+ ordered = sorted(ranges)
60
+ merged = [ordered[0]]
61
+ for start, end in ordered[1:]:
62
+ last_start, last_end = merged[-1]
63
+ if start <= last_end + 1:
64
+ merged[-1] = (last_start, max(last_end, end))
65
+ else:
66
+ merged.append((start, end))
67
+ return merged
68
+
69
+
70
+ def parse_ranges(spec: str, total: int | None = None) -> tuple[list[Range], list[str]]:
71
+ """Parse a spec into sorted, merged, clamped ranges.
72
+
73
+ An empty spec returns an empty list, which means "the whole file".
74
+ """
75
+ warnings: list[str] = []
76
+ if not spec or not spec.strip():
77
+ return [], warnings
78
+
79
+ ranges: list[Range] = []
80
+ for raw in re.split(r"[,;]", spec):
81
+ token = raw.strip()
82
+ if not token:
83
+ continue
84
+ match = _TOKEN.match(token)
85
+ if match is None:
86
+ warnings.append(f"Ignored '{token}': expected a line number or a range like 10-40.")
87
+ continue
88
+
89
+ start_text, end_text, upto_text, single_text = match.groups()
90
+ if single_text is not None:
91
+ start = end = int(single_text)
92
+ elif upto_text is not None:
93
+ start, end = 1, int(upto_text)
94
+ else:
95
+ start = int(start_text)
96
+ end = int(end_text) if end_text else OPEN_END
97
+
98
+ if start < 1:
99
+ start = 1
100
+ if end < start:
101
+ warnings.append(f"Ignored '{token}': it ends before it starts.")
102
+ continue
103
+ ranges.append((start, end))
104
+
105
+ ranges = merge(ranges)
106
+
107
+ if total is not None and total > 0:
108
+ clamped: list[Range] = []
109
+ for start, end in ranges:
110
+ if start > total:
111
+ warnings.append(
112
+ f"Ignored lines {start}-{'end' if end >= OPEN_END else end}: "
113
+ f"the file has only {total} line(s)."
114
+ )
115
+ continue
116
+ clamped.append((start, min(end, total)))
117
+ ranges = merge(clamped)
118
+
119
+ return ranges, warnings
120
+
121
+
122
+ def validate(spec: str, total: int | None = None) -> str | None:
123
+ """Return an error message for a bad spec, or None if it is usable."""
124
+ if not spec or not spec.strip():
125
+ return None
126
+ ranges, warnings = parse_ranges(spec, total)
127
+ if warnings:
128
+ return warnings[0]
129
+ if not ranges:
130
+ return "That selects no lines."
131
+ return None
132
+
133
+
134
+ def format_ranges(ranges: list[Range], total: int | None = None) -> str:
135
+ parts: list[str] = []
136
+ for start, end in ranges:
137
+ if total is not None and end >= total:
138
+ end = total
139
+ parts.append(str(start) if start == end else f"{start}-{end}")
140
+ return ", ".join(parts)
141
+
142
+
143
+ def compute_gaps(ranges: list[Range], total: int) -> list[Range]:
144
+ """The complement of `ranges` within 1..total - i.e. what is left out."""
145
+ if not ranges or total <= 0:
146
+ return []
147
+ gaps: list[Range] = []
148
+ cursor = 1
149
+ for start, end in ranges:
150
+ if start > cursor:
151
+ gaps.append((cursor, start - 1))
152
+ cursor = max(cursor, min(end, total) + 1)
153
+ if cursor <= total:
154
+ gaps.append((cursor, total))
155
+ return gaps
156
+
157
+
158
+ # ---------------------------------------------------------------------------
159
+ # Application
160
+ # ---------------------------------------------------------------------------
161
+
162
+
163
+ def apply_selection(
164
+ lines: list[SourceLine],
165
+ ranges: list[Range],
166
+ total: int,
167
+ ) -> Selection:
168
+ """Keep only the requested lines and place the elision markers."""
169
+ if not ranges:
170
+ return Selection(lines=lines)
171
+
172
+ kept = [line for line in lines if _in_ranges(line.number, ranges)]
173
+
174
+ gaps = compute_gaps(ranges, total)
175
+ elisions: dict[int, Range] = {}
176
+ trailing: Range | None = None
177
+
178
+ for gap_start, gap_end in gaps:
179
+ index = next((i for i, line in enumerate(kept) if line.number > gap_end), None)
180
+ if index is None:
181
+ # Nothing kept after this gap: it belongs at the end of the file.
182
+ trailing = (
183
+ (min(trailing[0], gap_start), max(trailing[1], gap_end))
184
+ if trailing
185
+ else (gap_start, gap_end)
186
+ )
187
+ continue
188
+ existing = elisions.get(index)
189
+ elisions[index] = (
190
+ (min(existing[0], gap_start), max(existing[1], gap_end))
191
+ if existing
192
+ else (gap_start, gap_end)
193
+ )
194
+
195
+ # A spec that happens to cover the whole file is not a partial deposit,
196
+ # so it earns no PARTIAL banner and no elisions.
197
+ selection = Selection(
198
+ lines=kept,
199
+ elisions=elisions,
200
+ trailing=trailing,
201
+ omitted_lines=sum(end - start + 1 for start, end in gaps),
202
+ partial=bool(gaps),
203
+ label=f"{format_ranges(ranges, total)} of {total}" if gaps else "",
204
+ )
205
+ if not kept:
206
+ selection.warnings.append(
207
+ "The selected line range contains no code once the comment policy "
208
+ "has been applied; only the omission notice will be printed."
209
+ )
210
+ return selection
211
+
212
+
213
+ def _in_ranges(number: int, ranges: list[Range]) -> bool:
214
+ for start, end in ranges:
215
+ if start <= number <= end:
216
+ return True
217
+ if start > number:
218
+ break # ranges are sorted
219
+ return False