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,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
|