oaknut-basic 12.11.0__tar.gz → 12.13.1__tar.gz
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.
- {oaknut_basic-12.11.0 → oaknut_basic-12.13.1}/PKG-INFO +1 -1
- {oaknut_basic-12.11.0 → oaknut_basic-12.13.1}/src/oaknut/basic/__init__.py +5 -1
- {oaknut_basic-12.11.0 → oaknut_basic-12.13.1}/src/oaknut/basic/cli.py +52 -0
- oaknut_basic-12.13.1/src/oaknut/basic/detect.py +212 -0
- {oaknut_basic-12.11.0 → oaknut_basic-12.13.1}/src/oaknut_basic.egg-info/PKG-INFO +1 -1
- {oaknut_basic-12.11.0 → oaknut_basic-12.13.1}/src/oaknut_basic.egg-info/SOURCES.txt +2 -0
- {oaknut_basic-12.11.0 → oaknut_basic-12.13.1}/tests/test_cli.py +53 -0
- oaknut_basic-12.13.1/tests/test_detect.py +152 -0
- {oaknut_basic-12.11.0 → oaknut_basic-12.13.1}/LICENSE +0 -0
- {oaknut_basic-12.11.0 → oaknut_basic-12.13.1}/README.md +0 -0
- {oaknut_basic-12.11.0 → oaknut_basic-12.13.1}/pyproject.toml +0 -0
- {oaknut_basic-12.11.0 → oaknut_basic-12.13.1}/setup.cfg +0 -0
- {oaknut_basic-12.11.0 → oaknut_basic-12.13.1}/src/oaknut/basic/datafile.py +0 -0
- {oaknut_basic-12.11.0 → oaknut_basic-12.13.1}/src/oaknut/basic/detokeniser.py +0 -0
- {oaknut_basic-12.11.0 → oaknut_basic-12.13.1}/src/oaknut/basic/dialect.py +0 -0
- {oaknut_basic-12.11.0 → oaknut_basic-12.13.1}/src/oaknut/basic/exceptions.py +0 -0
- {oaknut_basic-12.11.0 → oaknut_basic-12.13.1}/src/oaknut/basic/float5.py +0 -0
- {oaknut_basic-12.11.0 → oaknut_basic-12.13.1}/src/oaknut/basic/linenumber.py +0 -0
- {oaknut_basic-12.11.0 → oaknut_basic-12.13.1}/src/oaknut/basic/numbering.py +0 -0
- {oaknut_basic-12.11.0 → oaknut_basic-12.13.1}/src/oaknut/basic/scanner.py +0 -0
- {oaknut_basic-12.11.0 → oaknut_basic-12.13.1}/src/oaknut/basic/tokeniser.py +0 -0
- {oaknut_basic-12.11.0 → oaknut_basic-12.13.1}/src/oaknut/basic/tokens.py +0 -0
- {oaknut_basic-12.11.0 → oaknut_basic-12.13.1}/src/oaknut_basic.egg-info/dependency_links.txt +0 -0
- {oaknut_basic-12.11.0 → oaknut_basic-12.13.1}/src/oaknut_basic.egg-info/entry_points.txt +0 -0
- {oaknut_basic-12.11.0 → oaknut_basic-12.13.1}/src/oaknut_basic.egg-info/requires.txt +0 -0
- {oaknut_basic-12.11.0 → oaknut_basic-12.13.1}/src/oaknut_basic.egg-info/top_level.txt +0 -0
- {oaknut_basic-12.11.0 → oaknut_basic-12.13.1}/tests/test_basic.py +0 -0
- {oaknut_basic-12.11.0 → oaknut_basic-12.13.1}/tests/test_basic_v.py +0 -0
- {oaknut_basic-12.11.0 → oaknut_basic-12.13.1}/tests/test_crunch_rules.py +0 -0
- {oaknut_basic-12.11.0 → oaknut_basic-12.13.1}/tests/test_data_cli.py +0 -0
- {oaknut_basic-12.11.0 → oaknut_basic-12.13.1}/tests/test_datafile.py +0 -0
- {oaknut_basic-12.11.0 → oaknut_basic-12.13.1}/tests/test_detokeniser.py +0 -0
- {oaknut_basic-12.11.0 → oaknut_basic-12.13.1}/tests/test_float5.py +0 -0
- {oaknut_basic-12.11.0 → oaknut_basic-12.13.1}/tests/test_keyword_coverage.py +0 -0
- {oaknut_basic-12.11.0 → oaknut_basic-12.13.1}/tests/test_linenumber.py +0 -0
- {oaknut_basic-12.11.0 → oaknut_basic-12.13.1}/tests/test_numbering.py +0 -0
- {oaknut_basic-12.11.0 → oaknut_basic-12.13.1}/tests/test_rom_golden.py +0 -0
- {oaknut_basic-12.11.0 → oaknut_basic-12.13.1}/tests/test_rom_golden_detokenise.py +0 -0
- {oaknut_basic-12.11.0 → oaknut_basic-12.13.1}/tests/test_scanner.py +0 -0
- {oaknut_basic-12.11.0 → oaknut_basic-12.13.1}/tests/test_tokeniser.py +0 -0
- {oaknut_basic-12.11.0 → oaknut_basic-12.13.1}/tests/test_tokens.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: oaknut-basic
|
|
3
|
-
Version: 12.
|
|
3
|
+
Version: 12.13.1
|
|
4
4
|
Summary: BBC BASIC tools: program tokeniser/de-tokeniser and PRINT#/INPUT# data-file reader/writer
|
|
5
5
|
Author-email: Robert Smallshire <robert@smallshire.org.uk>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -30,6 +30,7 @@ from oaknut.basic.datafile import (
|
|
|
30
30
|
BbcBasicDataReader,
|
|
31
31
|
BbcBasicDataWriter,
|
|
32
32
|
)
|
|
33
|
+
from oaknut.basic.detect import Detection, Verdict, detect
|
|
33
34
|
from oaknut.basic.detokeniser import detokenise, detokenise_body
|
|
34
35
|
from oaknut.basic.dialect import BASIC_II, BASIC_V, Dialect
|
|
35
36
|
from oaknut.basic.exceptions import (
|
|
@@ -80,7 +81,7 @@ from oaknut.basic.tokens import (
|
|
|
80
81
|
TOKEN_TO_KEYWORD,
|
|
81
82
|
)
|
|
82
83
|
|
|
83
|
-
__version__ = "12.
|
|
84
|
+
__version__ = "12.13.1"
|
|
84
85
|
|
|
85
86
|
# Canonical load addresses for BBC BASIC programs on each host.
|
|
86
87
|
# Programs saved by *SAVE on a real machine use these by default.
|
|
@@ -112,6 +113,7 @@ __all__ = [
|
|
|
112
113
|
"BbcBasicDataWriter",
|
|
113
114
|
"DataFileError",
|
|
114
115
|
"DataFileTypeMismatchError",
|
|
116
|
+
"Detection",
|
|
115
117
|
"DetokeniseError",
|
|
116
118
|
"Dialect",
|
|
117
119
|
"Float5RangeError",
|
|
@@ -130,7 +132,9 @@ __all__ = [
|
|
|
130
132
|
"TruncatedRecordError",
|
|
131
133
|
"UnknownTagError",
|
|
132
134
|
"UnnumberedLineError",
|
|
135
|
+
"Verdict",
|
|
133
136
|
"decode_line_number",
|
|
137
|
+
"detect",
|
|
134
138
|
"detokenise",
|
|
135
139
|
"detokenise_body",
|
|
136
140
|
"number_lines",
|
|
@@ -296,6 +296,58 @@ def detokenise(input_stream, output_stream, encoding: str, dialect: str) -> None
|
|
|
296
296
|
output_stream.write(_listing_to_bytes(listing, encoding))
|
|
297
297
|
|
|
298
298
|
|
|
299
|
+
@cli.command()
|
|
300
|
+
@click.argument(
|
|
301
|
+
"inputs",
|
|
302
|
+
metavar="[FILE]...",
|
|
303
|
+
type=click.File("rb"),
|
|
304
|
+
nargs=-1,
|
|
305
|
+
)
|
|
306
|
+
@click.option(
|
|
307
|
+
"--quiet",
|
|
308
|
+
"-q",
|
|
309
|
+
is_flag=True,
|
|
310
|
+
help="Suppress per-file output; report the verdict through the exit code only.",
|
|
311
|
+
)
|
|
312
|
+
@click.pass_context
|
|
313
|
+
def detect(ctx: click.Context, inputs, quiet: bool) -> None:
|
|
314
|
+
"""Identify whether each FILE is a tokenised BBC BASIC program.
|
|
315
|
+
|
|
316
|
+
Classifies each FILE by its byte structure alone — the length-driven
|
|
317
|
+
line walk the BBC BASIC ROM performs to ``LIST`` a program — without
|
|
318
|
+
de-tokenising, so unlabelled files harvested from disc images can be
|
|
319
|
+
filtered. With no FILE, or ``-``, reads a single blob from standard
|
|
320
|
+
input ::
|
|
321
|
+
|
|
322
|
+
oaknut-basic detect *
|
|
323
|
+
disc get game.ssd MENU - | oaknut-basic detect
|
|
324
|
+
|
|
325
|
+
Each line reports a ``BASIC`` flag (blank when not), the verdict, the
|
|
326
|
+
path and the reason; non-fatal observations follow as indented notes.
|
|
327
|
+
The exit code is non-zero if any input is not a BASIC program, so it
|
|
328
|
+
composes as a shell filter. The verdict is one of ``basic`` (a clean
|
|
329
|
+
program), ``basic+`` (a program with data appended), ``maybe`` (begins
|
|
330
|
+
as BASIC, then the structure breaks) or ``not-basic``.
|
|
331
|
+
"""
|
|
332
|
+
from oaknut.basic import detect as detect_blob
|
|
333
|
+
|
|
334
|
+
streams = inputs if inputs else (click.get_binary_stream("stdin"),)
|
|
335
|
+
all_basic = True
|
|
336
|
+
for stream in streams:
|
|
337
|
+
result = detect_blob(stream.read())
|
|
338
|
+
all_basic = all_basic and result.is_basic
|
|
339
|
+
if quiet:
|
|
340
|
+
continue
|
|
341
|
+
name = getattr(stream, "name", "<stdin>")
|
|
342
|
+
flag = "BASIC" if result.is_basic else " "
|
|
343
|
+
click.echo(f"{flag} {result.verdict.value:9} {name} -- {result.reason}")
|
|
344
|
+
for note in result.notes:
|
|
345
|
+
click.echo(f" note: {note}")
|
|
346
|
+
|
|
347
|
+
if not all_basic:
|
|
348
|
+
ctx.exit(1)
|
|
349
|
+
|
|
350
|
+
|
|
299
351
|
@cli.group()
|
|
300
352
|
def data() -> None:
|
|
301
353
|
"""Read and write BBC BASIC data files (PRINT#/INPUT#/BPUT#/BGET#).
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Identify tokenised BBC BASIC programs by structure alone.
|
|
2
|
+
|
|
3
|
+
Files harvested from Acorn disc images (DFS ``.ssd``, ADFS ``.adl``)
|
|
4
|
+
carry no filetype or extension, so a blob's nature has to be inferred
|
|
5
|
+
from its bytes. A stored BBC BASIC program — BASIC I, II, IV and V all
|
|
6
|
+
share the same line framing — is a run of lines::
|
|
7
|
+
|
|
8
|
+
&0D <line-hi> <line-lo> <len> <len-4 tokenised body bytes>
|
|
9
|
+
|
|
10
|
+
repeated, then a terminator: ``&0D`` followed by a byte with its top bit
|
|
11
|
+
set. BASIC writes ``&0D &FF``, but the ROM only tests the top bit, and
|
|
12
|
+
programs with data appended sometimes tamper with the second byte, so
|
|
13
|
+
``&0D &80``-``&FF`` all terminate — :func:`detect` matches the ROM, not
|
|
14
|
+
the literal ``&FF``, and merely notes a non-``&FF`` terminator.
|
|
15
|
+
|
|
16
|
+
The walk is length-driven: each line's own ``<len>`` byte says where the
|
|
17
|
+
next ``&0D`` must be, so tokenised body bytes that happen to look like
|
|
18
|
+
``&0D`` never trip the scan. It is exactly what the ROM does to ``LIST``
|
|
19
|
+
a program, which makes it about as faithful a classifier as is possible
|
|
20
|
+
without a full de-tokenise. Because the framing is identical across all
|
|
21
|
+
BASIC versions, detection is purely structural and needs no
|
|
22
|
+
:class:`~oaknut.basic.Dialect` — it sits well below the token table.
|
|
23
|
+
|
|
24
|
+
Unlike :func:`~oaknut.basic.scan_program`, which raises
|
|
25
|
+
:class:`~oaknut.basic.DetokeniseError` on malformed input, :func:`detect`
|
|
26
|
+
is **total**: it classifies clean programs, program-plus-data, truncated
|
|
27
|
+
fragments and arbitrary garbage into a :class:`Verdict` with evidence,
|
|
28
|
+
and never raises. Classification is deliberately conservative and
|
|
29
|
+
reason-bearing so a corpus filter can explain every accept and reject.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import enum
|
|
35
|
+
from dataclasses import dataclass, field
|
|
36
|
+
|
|
37
|
+
from oaknut.basic.tokens import HEADER_LENGTH
|
|
38
|
+
|
|
39
|
+
_CR = 0x0D
|
|
40
|
+
|
|
41
|
+
# A stored line number is 0-32767 (see oaknut.basic.linenumber.MAX_LINE_NUMBER),
|
|
42
|
+
# so its high byte is always <= &7F — top bit clear. The end-of-program
|
|
43
|
+
# terminator's second byte has the top bit set (&FF, or a tampered &80-&FE).
|
|
44
|
+
# Testing the top bit therefore tells a terminator from a line-number high
|
|
45
|
+
# byte, exactly as the ROM's LIST loop does.
|
|
46
|
+
_TERMINATOR_TOP_BIT = 0x80
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Verdict(enum.Enum):
|
|
50
|
+
"""How confident :func:`detect` is that a blob is tokenised BBC BASIC."""
|
|
51
|
+
|
|
52
|
+
#: A clean walk all the way to a proper terminator, no trailing bytes.
|
|
53
|
+
BASIC = "basic"
|
|
54
|
+
#: A valid program followed by extra bytes (data appended after the end).
|
|
55
|
+
BASIC_TRAILING = "basic+"
|
|
56
|
+
#: Begins as one or more well-formed lines, then the structure breaks.
|
|
57
|
+
MAYBE = "maybe"
|
|
58
|
+
#: Does not begin as a tokenised line at all.
|
|
59
|
+
NOT_BASIC = "not-basic"
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def is_basic(self) -> bool:
|
|
63
|
+
"""True for the verdicts a corpus filter would extract as BASIC."""
|
|
64
|
+
return self in (Verdict.BASIC, Verdict.BASIC_TRAILING)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class Detection:
|
|
69
|
+
"""The outcome of inspecting one blob, with the evidence behind it.
|
|
70
|
+
|
|
71
|
+
Attributes:
|
|
72
|
+
verdict: The :class:`Verdict` reached.
|
|
73
|
+
reason: A human-readable explanation of the decision.
|
|
74
|
+
line_count: Well-formed lines walked before the terminator or break.
|
|
75
|
+
program_length: Bytes up to and including the terminator (0 if none
|
|
76
|
+
was reached).
|
|
77
|
+
trailing_length: Bytes after the terminator (non-zero only for
|
|
78
|
+
:attr:`Verdict.BASIC_TRAILING`).
|
|
79
|
+
first_line: The first line's number, or ``None`` if no line parsed.
|
|
80
|
+
last_line: The last line's number, or ``None`` if no line parsed.
|
|
81
|
+
ascending: Whether the line numbers were strictly ascending.
|
|
82
|
+
notes: Non-fatal observations (e.g. a non-``&FF`` terminator).
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
verdict: Verdict
|
|
86
|
+
reason: str
|
|
87
|
+
line_count: int = 0
|
|
88
|
+
program_length: int = 0
|
|
89
|
+
trailing_length: int = 0
|
|
90
|
+
first_line: int | None = None
|
|
91
|
+
last_line: int | None = None
|
|
92
|
+
ascending: bool = True
|
|
93
|
+
notes: tuple[str, ...] = field(default_factory=tuple)
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def is_basic(self) -> bool:
|
|
97
|
+
"""True when the verdict is :attr:`Verdict.BASIC` or ``BASIC_TRAILING``."""
|
|
98
|
+
return self.verdict.is_basic
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def detect(data: bytes | bytearray) -> Detection:
|
|
102
|
+
"""Classify *data* as tokenised BBC BASIC (or not) by walking its lines.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
data: The raw bytes to classify.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
A :class:`Detection` carrying the :class:`Verdict` and the
|
|
109
|
+
evidence gathered during the walk. Always returns; never raises.
|
|
110
|
+
"""
|
|
111
|
+
n = len(data)
|
|
112
|
+
if n < 2:
|
|
113
|
+
return Detection(Verdict.NOT_BASIC, "too short to be a BASIC line")
|
|
114
|
+
if data[0] != _CR:
|
|
115
|
+
return Detection(
|
|
116
|
+
Verdict.NOT_BASIC,
|
|
117
|
+
f"first byte is &{data[0]:02X}, not a &0D line marker",
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
pos = 0
|
|
121
|
+
line_count = 0
|
|
122
|
+
first_line: int | None = None
|
|
123
|
+
last_line: int | None = None
|
|
124
|
+
prev_line: int | None = None
|
|
125
|
+
ascending = True
|
|
126
|
+
notes: list[str] = []
|
|
127
|
+
|
|
128
|
+
def broke(reason: str) -> Detection:
|
|
129
|
+
"""A broken walk is MAYBE if it parsed >=1 clean line, else NOT_BASIC.
|
|
130
|
+
|
|
131
|
+
One or more well-formed lines before the break means the blob
|
|
132
|
+
really does begin as a tokenised program (truncated, or BASIC
|
|
133
|
+
followed by data without a proper terminator); zero clean lines
|
|
134
|
+
means the leading ``&0D`` was a coincidence.
|
|
135
|
+
"""
|
|
136
|
+
return Detection(
|
|
137
|
+
Verdict.MAYBE if line_count > 0 else Verdict.NOT_BASIC,
|
|
138
|
+
reason,
|
|
139
|
+
line_count=line_count,
|
|
140
|
+
first_line=first_line,
|
|
141
|
+
last_line=last_line,
|
|
142
|
+
ascending=ascending,
|
|
143
|
+
notes=tuple(notes),
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
while True:
|
|
147
|
+
# Every iteration must land on a line marker.
|
|
148
|
+
if pos >= n or data[pos] != _CR:
|
|
149
|
+
found = f"&{data[pos]:02X}" if pos < n else "end of data"
|
|
150
|
+
return broke(f"expected &0D at offset {pos}, found {found}")
|
|
151
|
+
|
|
152
|
+
# The byte after &0D is either a terminator (top bit set) or a
|
|
153
|
+
# line-number high byte.
|
|
154
|
+
if pos + 1 >= n:
|
|
155
|
+
return broke(f"truncated after &0D at offset {pos}")
|
|
156
|
+
|
|
157
|
+
marker = data[pos + 1]
|
|
158
|
+
if marker & _TERMINATOR_TOP_BIT:
|
|
159
|
+
# A terminator before any line parsed means the leading &0D was
|
|
160
|
+
# a coincidence (e.g. a View document opening &0D &80), not an
|
|
161
|
+
# empty program — real type-ins always have at least one line.
|
|
162
|
+
if line_count == 0:
|
|
163
|
+
return Detection(
|
|
164
|
+
Verdict.NOT_BASIC,
|
|
165
|
+
f"&0D &{marker:02X} at offset 0 — terminator with no lines, "
|
|
166
|
+
"not a program",
|
|
167
|
+
)
|
|
168
|
+
program_length = pos + 2
|
|
169
|
+
trailing = n - program_length
|
|
170
|
+
if marker != 0xFF:
|
|
171
|
+
notes.append(
|
|
172
|
+
f"terminator second byte &{marker:02X} (not &FF, top bit "
|
|
173
|
+
"only) — data may follow"
|
|
174
|
+
)
|
|
175
|
+
verdict = Verdict.BASIC if trailing == 0 else Verdict.BASIC_TRAILING
|
|
176
|
+
reason = f"walked {line_count} line(s) to a terminator" + (
|
|
177
|
+
f", {trailing} trailing byte(s)" if trailing else ""
|
|
178
|
+
)
|
|
179
|
+
return Detection(
|
|
180
|
+
verdict=verdict,
|
|
181
|
+
reason=reason,
|
|
182
|
+
line_count=line_count,
|
|
183
|
+
program_length=program_length,
|
|
184
|
+
trailing_length=trailing,
|
|
185
|
+
first_line=first_line,
|
|
186
|
+
last_line=last_line,
|
|
187
|
+
ascending=ascending,
|
|
188
|
+
notes=tuple(notes),
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
# A real line: need the full 4-byte header.
|
|
192
|
+
if pos + HEADER_LENGTH > n:
|
|
193
|
+
return broke(f"truncated line header at offset {pos}")
|
|
194
|
+
|
|
195
|
+
line_no = (marker << 8) | data[pos + 2]
|
|
196
|
+
length = data[pos + 3]
|
|
197
|
+
if length < HEADER_LENGTH:
|
|
198
|
+
return broke(
|
|
199
|
+
f"line {line_no} at offset {pos} has length {length} "
|
|
200
|
+
f"(< {HEADER_LENGTH})"
|
|
201
|
+
)
|
|
202
|
+
if pos + length > n:
|
|
203
|
+
return broke(f"line {line_no} at offset {pos} runs {length} bytes past end of data")
|
|
204
|
+
|
|
205
|
+
if first_line is None:
|
|
206
|
+
first_line = line_no
|
|
207
|
+
if prev_line is not None and line_no <= prev_line:
|
|
208
|
+
ascending = False
|
|
209
|
+
prev_line = line_no
|
|
210
|
+
last_line = line_no
|
|
211
|
+
line_count += 1
|
|
212
|
+
pos += length
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: oaknut-basic
|
|
3
|
-
Version: 12.
|
|
3
|
+
Version: 12.13.1
|
|
4
4
|
Summary: BBC BASIC tools: program tokeniser/de-tokeniser and PRINT#/INPUT# data-file reader/writer
|
|
5
5
|
Author-email: Robert Smallshire <robert@smallshire.org.uk>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -4,6 +4,7 @@ pyproject.toml
|
|
|
4
4
|
src/oaknut/basic/__init__.py
|
|
5
5
|
src/oaknut/basic/cli.py
|
|
6
6
|
src/oaknut/basic/datafile.py
|
|
7
|
+
src/oaknut/basic/detect.py
|
|
7
8
|
src/oaknut/basic/detokeniser.py
|
|
8
9
|
src/oaknut/basic/dialect.py
|
|
9
10
|
src/oaknut/basic/exceptions.py
|
|
@@ -25,6 +26,7 @@ tests/test_cli.py
|
|
|
25
26
|
tests/test_crunch_rules.py
|
|
26
27
|
tests/test_data_cli.py
|
|
27
28
|
tests/test_datafile.py
|
|
29
|
+
tests/test_detect.py
|
|
28
30
|
tests/test_detokeniser.py
|
|
29
31
|
tests/test_float5.py
|
|
30
32
|
tests/test_keyword_coverage.py
|
|
@@ -257,6 +257,59 @@ class TestDetokeniseCommand:
|
|
|
257
257
|
assert result.stdout_bytes == b"140LOADTIME 640,512\n"
|
|
258
258
|
|
|
259
259
|
|
|
260
|
+
class TestDetectCommand:
|
|
261
|
+
def test_stdin_basic_program_exits_zero(self):
|
|
262
|
+
program = basic.tokenise("10 PRINT")
|
|
263
|
+
runner = CliRunner()
|
|
264
|
+
result = runner.invoke(cli, ["detect"], input=program)
|
|
265
|
+
assert result.exit_code == 0
|
|
266
|
+
assert "BASIC" in result.output
|
|
267
|
+
assert "basic" in result.output
|
|
268
|
+
|
|
269
|
+
def test_stdin_non_basic_exits_nonzero(self):
|
|
270
|
+
runner = CliRunner()
|
|
271
|
+
result = runner.invoke(cli, ["detect"], input=b"just some text")
|
|
272
|
+
assert result.exit_code == 1
|
|
273
|
+
assert "not-basic" in result.output
|
|
274
|
+
|
|
275
|
+
def test_files_mixed_reports_each_and_exits_nonzero(self, tmp_path):
|
|
276
|
+
good = tmp_path / "PROG"
|
|
277
|
+
good.write_bytes(basic.tokenise("10 END"))
|
|
278
|
+
bad = tmp_path / "DATA"
|
|
279
|
+
bad.write_bytes(b"not a program")
|
|
280
|
+
runner = CliRunner()
|
|
281
|
+
result = runner.invoke(cli, ["detect", str(good), str(bad)])
|
|
282
|
+
assert result.exit_code == 1
|
|
283
|
+
assert str(good) in result.output
|
|
284
|
+
assert str(bad) in result.output
|
|
285
|
+
# One line per file.
|
|
286
|
+
assert result.output.count("PROG") == 1
|
|
287
|
+
assert result.output.count("DATA") == 1
|
|
288
|
+
|
|
289
|
+
def test_all_basic_files_exit_zero(self, tmp_path):
|
|
290
|
+
first = tmp_path / "A"
|
|
291
|
+
first.write_bytes(basic.tokenise("10 END"))
|
|
292
|
+
second = tmp_path / "B"
|
|
293
|
+
second.write_bytes(basic.tokenise("20 PRINT"))
|
|
294
|
+
runner = CliRunner()
|
|
295
|
+
result = runner.invoke(cli, ["detect", str(first), str(second)])
|
|
296
|
+
assert result.exit_code == 0
|
|
297
|
+
|
|
298
|
+
def test_quiet_suppresses_output_but_keeps_exit_code(self):
|
|
299
|
+
runner = CliRunner()
|
|
300
|
+
result = runner.invoke(cli, ["detect", "--quiet"], input=b"not basic")
|
|
301
|
+
assert result.exit_code == 1
|
|
302
|
+
assert result.output == ""
|
|
303
|
+
|
|
304
|
+
def test_non_ff_terminator_notes_are_shown(self):
|
|
305
|
+
program = bytearray(basic.tokenise("10 PRINT"))
|
|
306
|
+
program[-1] = 0x80 # top bit still set, so still a terminator
|
|
307
|
+
runner = CliRunner()
|
|
308
|
+
result = runner.invoke(cli, ["detect"], input=bytes(program))
|
|
309
|
+
assert result.exit_code == 0
|
|
310
|
+
assert "note:" in result.output
|
|
311
|
+
|
|
312
|
+
|
|
260
313
|
class TestTokeniseDetokeniseRoundTrip:
|
|
261
314
|
def test_cli_round_trip(self):
|
|
262
315
|
runner = CliRunner()
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Tests for the structural tokenised-BBC-BASIC detector.
|
|
2
|
+
|
|
3
|
+
The detector walks the &0D-framed line structure the same way the ROM's
|
|
4
|
+
``LIST`` does, but is *tolerant*: unlike :func:`scan_program`, which
|
|
5
|
+
raises on malformed input, :func:`detect` classifies any blob — clean
|
|
6
|
+
program, program-plus-data, truncated fragment, or arbitrary garbage —
|
|
7
|
+
into a :class:`Verdict` with evidence, and never raises.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
from oaknut.basic import Detection, Verdict, detect, tokenise
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _program(*lines: tuple[int, bytes]) -> bytes:
|
|
15
|
+
"""Frame (line_number, body) pairs into a tokenised program."""
|
|
16
|
+
out = bytearray()
|
|
17
|
+
for line_number, body in lines:
|
|
18
|
+
out += bytes((0x0D, (line_number >> 8) & 0xFF, line_number & 0xFF, 4 + len(body)))
|
|
19
|
+
out += body
|
|
20
|
+
out += b"\x0d\xff"
|
|
21
|
+
return bytes(out)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TestClean:
|
|
25
|
+
def test_tokenised_program_is_basic(self):
|
|
26
|
+
program = tokenise("10 PRINT \"HELLO\"\n20 GOTO 10")
|
|
27
|
+
result = detect(program)
|
|
28
|
+
assert result.verdict is Verdict.BASIC
|
|
29
|
+
assert result.is_basic
|
|
30
|
+
assert result.line_count == 2
|
|
31
|
+
assert result.first_line == 10
|
|
32
|
+
assert result.last_line == 20
|
|
33
|
+
assert result.trailing_length == 0
|
|
34
|
+
assert result.program_length == len(program)
|
|
35
|
+
assert result.ascending
|
|
36
|
+
|
|
37
|
+
def test_reason_reports_the_line_count(self):
|
|
38
|
+
result = detect(tokenise("10 END"))
|
|
39
|
+
assert "1 line" in result.reason
|
|
40
|
+
|
|
41
|
+
def test_single_bodyless_line_is_basic(self):
|
|
42
|
+
# A line with only its 4-byte header (no body) is well-formed.
|
|
43
|
+
result = detect(_program((10, b"")))
|
|
44
|
+
assert result.verdict is Verdict.BASIC
|
|
45
|
+
assert result.line_count == 1
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class TestTrailing:
|
|
49
|
+
def test_program_with_appended_data_is_basic_trailing(self):
|
|
50
|
+
program = tokenise("10 PRINT")
|
|
51
|
+
blob = program + b"junk appended after the terminator"
|
|
52
|
+
result = detect(blob)
|
|
53
|
+
assert result.verdict is Verdict.BASIC_TRAILING
|
|
54
|
+
assert result.is_basic
|
|
55
|
+
assert result.trailing_length == len(b"junk appended after the terminator")
|
|
56
|
+
assert result.program_length == len(program)
|
|
57
|
+
assert "trailing" in result.reason
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class TestMaybe:
|
|
61
|
+
def test_truncated_mid_line_is_maybe(self):
|
|
62
|
+
# One clean line, then a second line header whose length runs off
|
|
63
|
+
# the end: it begins as BASIC but the structure breaks.
|
|
64
|
+
blob = _program((10, b"PRINT"))[:-2] # drop the terminator
|
|
65
|
+
blob = blob + b"\x0d\x00\x14\x40" # a line claiming 0x40 body bytes
|
|
66
|
+
result = detect(blob)
|
|
67
|
+
assert result.verdict is Verdict.MAYBE
|
|
68
|
+
assert not result.is_basic
|
|
69
|
+
assert result.line_count == 1
|
|
70
|
+
|
|
71
|
+
def test_first_line_length_too_small_after_one_good_line(self):
|
|
72
|
+
good = _program((10, b"AB"))[:-2] # first line, no terminator
|
|
73
|
+
blob = good + b"\x0d\x00\x14\x03" # second line header, length 3 (< 4)
|
|
74
|
+
result = detect(blob)
|
|
75
|
+
assert result.verdict is Verdict.MAYBE
|
|
76
|
+
assert "length 3" in result.reason
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class TestNotBasic:
|
|
80
|
+
def test_not_cr_led_is_not_basic(self):
|
|
81
|
+
result = detect(b"This is just some text.")
|
|
82
|
+
assert result.verdict is Verdict.NOT_BASIC
|
|
83
|
+
assert not result.is_basic
|
|
84
|
+
assert "not a &0D line marker" in result.reason
|
|
85
|
+
|
|
86
|
+
def test_leading_cr_terminator_with_no_lines_is_not_basic(self):
|
|
87
|
+
# A View document opening 0x0D 0x80: a terminator before any line.
|
|
88
|
+
result = detect(b"\x0d\x80the rest of a view document")
|
|
89
|
+
assert result.verdict is Verdict.NOT_BASIC
|
|
90
|
+
assert result.line_count == 0
|
|
91
|
+
|
|
92
|
+
def test_too_short_is_not_basic(self):
|
|
93
|
+
assert detect(b"").verdict is Verdict.NOT_BASIC
|
|
94
|
+
assert detect(b"\x0d").verdict is Verdict.NOT_BASIC
|
|
95
|
+
|
|
96
|
+
def test_first_header_malformed_is_not_basic(self):
|
|
97
|
+
# 0x0D, a line-number high byte, then it runs out: zero clean lines.
|
|
98
|
+
result = detect(b"\x0d\x00\x0a") # truncated first header
|
|
99
|
+
assert result.verdict is Verdict.NOT_BASIC
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class TestTerminatorTolerance:
|
|
103
|
+
def test_non_ff_top_bit_terminator_is_basic_with_note(self):
|
|
104
|
+
program = bytearray(tokenise("10 PRINT"))
|
|
105
|
+
# Tamper with the terminator's second byte: top bit still set.
|
|
106
|
+
assert program[-1] == 0xFF
|
|
107
|
+
program[-1] = 0x80
|
|
108
|
+
result = detect(bytes(program))
|
|
109
|
+
assert result.verdict is Verdict.BASIC
|
|
110
|
+
assert result.is_basic
|
|
111
|
+
assert any("not &FF" in note for note in result.notes)
|
|
112
|
+
|
|
113
|
+
def test_ff_terminator_has_no_note(self):
|
|
114
|
+
assert detect(tokenise("10 PRINT")).notes == ()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class TestEvidence:
|
|
118
|
+
def test_descending_line_numbers_flagged_not_ascending(self):
|
|
119
|
+
# Structurally valid, but line numbers go down: still BASIC.
|
|
120
|
+
blob = _program((20, b"A"), (10, b"B"))
|
|
121
|
+
result = detect(blob)
|
|
122
|
+
assert result.verdict is Verdict.BASIC
|
|
123
|
+
assert result.ascending is False
|
|
124
|
+
assert result.first_line == 20
|
|
125
|
+
assert result.last_line == 10
|
|
126
|
+
|
|
127
|
+
def test_notes_is_immutable_sequence(self):
|
|
128
|
+
# Evidence is a frozen result; notes must not be a mutable list a
|
|
129
|
+
# caller could accidentally extend.
|
|
130
|
+
result = detect(tokenise("10 END"))
|
|
131
|
+
assert isinstance(result, Detection)
|
|
132
|
+
with pytest.raises((AttributeError, TypeError)):
|
|
133
|
+
result.verdict = Verdict.NOT_BASIC # frozen dataclass
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class TestNeverRaises:
|
|
137
|
+
@pytest.mark.parametrize(
|
|
138
|
+
"blob",
|
|
139
|
+
[
|
|
140
|
+
b"",
|
|
141
|
+
b"\x0d",
|
|
142
|
+
b"\x0d\x0d\x0d\x0d",
|
|
143
|
+
b"\x0d\x00\x0a\xff", # header length byte 0xFF
|
|
144
|
+
b"\x0d\x00\x0a\x04", # bodyless-length line then nothing
|
|
145
|
+
bytes(range(256)),
|
|
146
|
+
b"\x0d" + bytes(range(256)),
|
|
147
|
+
],
|
|
148
|
+
)
|
|
149
|
+
def test_detect_is_total(self, blob: bytes):
|
|
150
|
+
# Must classify, never throw, whatever the input.
|
|
151
|
+
result = detect(blob)
|
|
152
|
+
assert isinstance(result.verdict, Verdict)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{oaknut_basic-12.11.0 → oaknut_basic-12.13.1}/src/oaknut_basic.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|