oaknut-basic 12.10.1__tar.gz → 12.12.0__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.10.1/src/oaknut_basic.egg-info → oaknut_basic-12.12.0}/PKG-INFO +1 -1
- {oaknut_basic-12.10.1 → oaknut_basic-12.12.0}/src/oaknut/basic/__init__.py +9 -1
- {oaknut_basic-12.10.1 → oaknut_basic-12.12.0}/src/oaknut/basic/cli.py +70 -2
- oaknut_basic-12.12.0/src/oaknut/basic/detect.py +212 -0
- {oaknut_basic-12.10.1 → oaknut_basic-12.12.0}/src/oaknut/basic/detokeniser.py +14 -5
- oaknut_basic-12.12.0/src/oaknut/basic/dialect.py +164 -0
- {oaknut_basic-12.10.1 → oaknut_basic-12.12.0}/src/oaknut/basic/scanner.py +27 -9
- {oaknut_basic-12.10.1 → oaknut_basic-12.12.0/src/oaknut_basic.egg-info}/PKG-INFO +1 -1
- {oaknut_basic-12.10.1 → oaknut_basic-12.12.0}/src/oaknut_basic.egg-info/SOURCES.txt +4 -0
- oaknut_basic-12.12.0/tests/test_basic_v.py +195 -0
- {oaknut_basic-12.10.1 → oaknut_basic-12.12.0}/tests/test_cli.py +79 -0
- oaknut_basic-12.12.0/tests/test_detect.py +152 -0
- {oaknut_basic-12.10.1 → oaknut_basic-12.12.0}/LICENSE +0 -0
- {oaknut_basic-12.10.1 → oaknut_basic-12.12.0}/README.md +0 -0
- {oaknut_basic-12.10.1 → oaknut_basic-12.12.0}/pyproject.toml +0 -0
- {oaknut_basic-12.10.1 → oaknut_basic-12.12.0}/setup.cfg +0 -0
- {oaknut_basic-12.10.1 → oaknut_basic-12.12.0}/src/oaknut/basic/datafile.py +0 -0
- {oaknut_basic-12.10.1 → oaknut_basic-12.12.0}/src/oaknut/basic/exceptions.py +0 -0
- {oaknut_basic-12.10.1 → oaknut_basic-12.12.0}/src/oaknut/basic/float5.py +0 -0
- {oaknut_basic-12.10.1 → oaknut_basic-12.12.0}/src/oaknut/basic/linenumber.py +0 -0
- {oaknut_basic-12.10.1 → oaknut_basic-12.12.0}/src/oaknut/basic/numbering.py +0 -0
- {oaknut_basic-12.10.1 → oaknut_basic-12.12.0}/src/oaknut/basic/tokeniser.py +0 -0
- {oaknut_basic-12.10.1 → oaknut_basic-12.12.0}/src/oaknut/basic/tokens.py +0 -0
- {oaknut_basic-12.10.1 → oaknut_basic-12.12.0}/src/oaknut_basic.egg-info/dependency_links.txt +0 -0
- {oaknut_basic-12.10.1 → oaknut_basic-12.12.0}/src/oaknut_basic.egg-info/entry_points.txt +0 -0
- {oaknut_basic-12.10.1 → oaknut_basic-12.12.0}/src/oaknut_basic.egg-info/requires.txt +0 -0
- {oaknut_basic-12.10.1 → oaknut_basic-12.12.0}/src/oaknut_basic.egg-info/top_level.txt +0 -0
- {oaknut_basic-12.10.1 → oaknut_basic-12.12.0}/tests/test_basic.py +0 -0
- {oaknut_basic-12.10.1 → oaknut_basic-12.12.0}/tests/test_crunch_rules.py +0 -0
- {oaknut_basic-12.10.1 → oaknut_basic-12.12.0}/tests/test_data_cli.py +0 -0
- {oaknut_basic-12.10.1 → oaknut_basic-12.12.0}/tests/test_datafile.py +0 -0
- {oaknut_basic-12.10.1 → oaknut_basic-12.12.0}/tests/test_detokeniser.py +0 -0
- {oaknut_basic-12.10.1 → oaknut_basic-12.12.0}/tests/test_float5.py +0 -0
- {oaknut_basic-12.10.1 → oaknut_basic-12.12.0}/tests/test_keyword_coverage.py +0 -0
- {oaknut_basic-12.10.1 → oaknut_basic-12.12.0}/tests/test_linenumber.py +0 -0
- {oaknut_basic-12.10.1 → oaknut_basic-12.12.0}/tests/test_numbering.py +0 -0
- {oaknut_basic-12.10.1 → oaknut_basic-12.12.0}/tests/test_rom_golden.py +0 -0
- {oaknut_basic-12.10.1 → oaknut_basic-12.12.0}/tests/test_rom_golden_detokenise.py +0 -0
- {oaknut_basic-12.10.1 → oaknut_basic-12.12.0}/tests/test_scanner.py +0 -0
- {oaknut_basic-12.10.1 → oaknut_basic-12.12.0}/tests/test_tokeniser.py +0 -0
- {oaknut_basic-12.10.1 → oaknut_basic-12.12.0}/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.12.0
|
|
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,7 +30,9 @@ 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
|
|
35
|
+
from oaknut.basic.dialect import BASIC_II, BASIC_V, Dialect
|
|
34
36
|
from oaknut.basic.exceptions import (
|
|
35
37
|
AlreadyNumberedError,
|
|
36
38
|
BASICError,
|
|
@@ -79,7 +81,7 @@ from oaknut.basic.tokens import (
|
|
|
79
81
|
TOKEN_TO_KEYWORD,
|
|
80
82
|
)
|
|
81
83
|
|
|
82
|
-
__version__ = "12.
|
|
84
|
+
__version__ = "12.12.0"
|
|
83
85
|
|
|
84
86
|
# Canonical load addresses for BBC BASIC programs on each host.
|
|
85
87
|
# Programs saved by *SAVE on a real machine use these by default.
|
|
@@ -87,6 +89,8 @@ BBC_BASIC_LOAD_ADDRESS = 0x1900
|
|
|
87
89
|
ELECTRON_BASIC_LOAD_ADDRESS = 0x0E00
|
|
88
90
|
|
|
89
91
|
__all__ = [
|
|
92
|
+
"BASIC_II",
|
|
93
|
+
"BASIC_V",
|
|
90
94
|
"BBC_BASIC_LOAD_ADDRESS",
|
|
91
95
|
"DEFAULT_LINE_NUMBER",
|
|
92
96
|
"DEFAULT_LINE_STEP",
|
|
@@ -109,7 +113,9 @@ __all__ = [
|
|
|
109
113
|
"BbcBasicDataWriter",
|
|
110
114
|
"DataFileError",
|
|
111
115
|
"DataFileTypeMismatchError",
|
|
116
|
+
"Detection",
|
|
112
117
|
"DetokeniseError",
|
|
118
|
+
"Dialect",
|
|
113
119
|
"Float5RangeError",
|
|
114
120
|
"IntegerRangeError",
|
|
115
121
|
"InvalidLineLengthError",
|
|
@@ -126,7 +132,9 @@ __all__ = [
|
|
|
126
132
|
"TruncatedRecordError",
|
|
127
133
|
"UnknownTagError",
|
|
128
134
|
"UnnumberedLineError",
|
|
135
|
+
"Verdict",
|
|
129
136
|
"decode_line_number",
|
|
137
|
+
"detect",
|
|
130
138
|
"detokenise",
|
|
131
139
|
"detokenise_body",
|
|
132
140
|
"number_lines",
|
|
@@ -260,7 +260,16 @@ def tokenise(
|
|
|
260
260
|
help='Text encoding for the OUTPUT source. Use "acorn" for the BBC '
|
|
261
261
|
"character set with CR line endings (e.g. writing back to a disc image).",
|
|
262
262
|
)
|
|
263
|
-
|
|
263
|
+
@click.option(
|
|
264
|
+
"--dialect",
|
|
265
|
+
type=click.Choice(["ii", "2", "v", "5"]),
|
|
266
|
+
default="ii",
|
|
267
|
+
show_default=True,
|
|
268
|
+
help='BBC BASIC dialect, as a Roman or Arabic numeral. Use "v" (or "5") for '
|
|
269
|
+
"Archimedes / RISC OS programs, so the &C6/&C7/&C8 escape tokens "
|
|
270
|
+
"(CASE, SYS, ORIGIN, ...) decode correctly.",
|
|
271
|
+
)
|
|
272
|
+
def detokenise(input_stream, output_stream, encoding: str, dialect: str) -> None:
|
|
264
273
|
"""De-tokenise a stored BBC BASIC program into source text.
|
|
265
274
|
|
|
266
275
|
Reads a tokenised program from INPUT and writes numbered source text
|
|
@@ -273,13 +282,72 @@ def detokenise(input_stream, output_stream, encoding: str) -> None:
|
|
|
273
282
|
OUTPUT is written in --encoding (``utf-8`` with host-native ``LF`` line
|
|
274
283
|
endings by default, for a host text file; pass ``acorn`` for the BBC
|
|
275
284
|
character set with ``CR`` endings, e.g. writing back to a disc image).
|
|
285
|
+
|
|
286
|
+
Pass ``--dialect v`` (or ``--dialect 5``) for Archimedes / RISC OS
|
|
287
|
+
programs (BBC BASIC V), whose extended keywords use the &C6/&C7/&C8
|
|
288
|
+
two-byte escape tokens. The Roman (``ii``/``v``) and Arabic
|
|
289
|
+
(``2``/``5``) numerals are interchangeable.
|
|
276
290
|
"""
|
|
291
|
+
from oaknut.basic import BASIC_II, BASIC_V
|
|
277
292
|
from oaknut.basic import detokenise as detokenise_program
|
|
278
293
|
|
|
279
|
-
|
|
294
|
+
selected_dialect = BASIC_V if dialect in ("v", "5") else BASIC_II
|
|
295
|
+
listing = detokenise_program(input_stream.read(), dialect=selected_dialect)
|
|
280
296
|
output_stream.write(_listing_to_bytes(listing, encoding))
|
|
281
297
|
|
|
282
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
|
+
|
|
283
351
|
@cli.group()
|
|
284
352
|
def data() -> None:
|
|
285
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
|
|
@@ -26,15 +26,21 @@ including) the next one, so ``length = 4 + len(body)``.
|
|
|
26
26
|
|
|
27
27
|
from __future__ import annotations
|
|
28
28
|
|
|
29
|
+
from oaknut.basic.dialect import BASIC_II, Dialect
|
|
29
30
|
from oaknut.basic.scanner import scan, scan_program
|
|
30
31
|
|
|
31
32
|
|
|
32
|
-
def detokenise(data: bytes) -> str:
|
|
33
|
-
"""De-tokenise a BBC BASIC
|
|
33
|
+
def detokenise(data: bytes, *, dialect: Dialect = BASIC_II) -> str:
|
|
34
|
+
"""De-tokenise a stored BBC BASIC program into source text.
|
|
34
35
|
|
|
35
36
|
Args:
|
|
36
37
|
data: A tokenised program, as stored on disc or in memory,
|
|
37
38
|
terminated by the ``&0D &FF`` end marker.
|
|
39
|
+
dialect: The BBC BASIC variant whose token tables to use.
|
|
40
|
+
Defaults to :data:`~oaknut.basic.BASIC_II`; pass
|
|
41
|
+
:data:`~oaknut.basic.BASIC_V` for Archimedes / RISC OS
|
|
42
|
+
programs so the ``&C6``/``&C7``/``&C8`` escape tokens and the
|
|
43
|
+
re-purposed single-byte tokens decode correctly.
|
|
38
44
|
|
|
39
45
|
Returns:
|
|
40
46
|
The program as numbered source text, lines separated by ``"\\n"``
|
|
@@ -47,11 +53,11 @@ def detokenise(data: bytes) -> str:
|
|
|
47
53
|
"""
|
|
48
54
|
return "".join(
|
|
49
55
|
f"{record.line_number}{''.join(token.value for token in record.tokens)}\n"
|
|
50
|
-
for record in scan_program(data)
|
|
56
|
+
for record in scan_program(data, dialect=dialect)
|
|
51
57
|
)
|
|
52
58
|
|
|
53
59
|
|
|
54
|
-
def detokenise_body(data: bytes) -> str:
|
|
60
|
+
def detokenise_body(data: bytes, *, dialect: Dialect = BASIC_II) -> str:
|
|
55
61
|
"""De-tokenise an unframed line body into text.
|
|
56
62
|
|
|
57
63
|
Unlike :func:`detokenise`, this expects a line *body* — inline token
|
|
@@ -62,6 +68,9 @@ def detokenise_body(data: bytes) -> str:
|
|
|
62
68
|
|
|
63
69
|
Args:
|
|
64
70
|
data: A line body of inline token bytes.
|
|
71
|
+
dialect: The BBC BASIC variant whose token tables to use, passed
|
|
72
|
+
through to :func:`scan`. Defaults to
|
|
73
|
+
:data:`~oaknut.basic.BASIC_II`.
|
|
65
74
|
|
|
66
75
|
Returns:
|
|
67
76
|
The body as source text, using latin-1/code-point semantics so it
|
|
@@ -70,4 +79,4 @@ def detokenise_body(data: bytes) -> str:
|
|
|
70
79
|
Raises:
|
|
71
80
|
DetokeniseError: A ``&8D`` line-number reference is truncated.
|
|
72
81
|
"""
|
|
73
|
-
return "".join(token.value for token in scan(data))
|
|
82
|
+
return "".join(token.value for token in scan(data, dialect=dialect))
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""BBC BASIC dialects for de-tokenisation.
|
|
2
|
+
|
|
3
|
+
The :mod:`~oaknut.basic.tokens` table is **BBC BASIC II** — the BBC
|
|
4
|
+
Micro's 8-bit language ROM. BBC BASIC V (the Archimedes / RISC OS ARM
|
|
5
|
+
BASIC) keeps that table but extends it two ways, and de-tokenising a
|
|
6
|
+
BASIC V program with the BASIC II table corrupts every extended keyword:
|
|
7
|
+
|
|
8
|
+
- Three BASIC II command tokens — ``&C6``, ``&C7``, ``&C8`` (``AUTO``,
|
|
9
|
+
``DELETE``, ``LOAD``) — become **two-byte escape prefixes**. The byte
|
|
10
|
+
that follows selects an extended keyword, with the second byte counting
|
|
11
|
+
from ``&8E`` in each table: ``&C6`` for the extended *functions*
|
|
12
|
+
(``SUM``, ``BEAT``), ``&C7`` for the extended *commands* (``APPEND`` …
|
|
13
|
+
``RENUMBER`` … ``INSTALL``), and ``&C8`` for the extended *statements*
|
|
14
|
+
(``CASE``, ``CIRCLE``, ``ORIGIN``, ``SYS`` …).
|
|
15
|
+
|
|
16
|
+
- Several single-byte slots are re-purposed. ``&7F`` becomes
|
|
17
|
+
``OTHERWISE``, and ``&C9``-``&CE`` — ``LIST``/``NEW``/``OLD``/
|
|
18
|
+
``RENUMBER``/``SAVE`` and an unused gap in BASIC II — become the block
|
|
19
|
+
keywords ``WHEN``/``OF``/``ENDCASE``/``ELSE``/``ENDIF``/``ENDWHILE``
|
|
20
|
+
(the command spellings move into the ``&C7`` escape table).
|
|
21
|
+
|
|
22
|
+
A :class:`Dialect` bundles the single-byte token map with the escape
|
|
23
|
+
tables, so the scanner and de-tokeniser can target either language with
|
|
24
|
+
one parameter. :data:`BASIC_II` is the default everywhere — code that
|
|
25
|
+
does not name a dialect keeps its existing BASIC II behaviour.
|
|
26
|
+
|
|
27
|
+
The token values were cross-checked against two independent
|
|
28
|
+
implementations: Justin Fletcher's de-tokeniser tables (the
|
|
29
|
+
``gerph/riscos-basic-detokenise`` ``c/basic`` source) and Steve Fryatt's
|
|
30
|
+
``tokenize`` (``src/parse.c``), which agree byte-for-byte.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
from collections.abc import Mapping
|
|
36
|
+
from dataclasses import dataclass
|
|
37
|
+
from types import MappingProxyType
|
|
38
|
+
|
|
39
|
+
from oaknut.basic.tokens import TOKEN_TO_KEYWORD
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class Dialect:
|
|
44
|
+
"""A BBC BASIC variant's token-to-keyword mapping.
|
|
45
|
+
|
|
46
|
+
Attributes:
|
|
47
|
+
name: Human-readable dialect name (e.g. ``"BBC BASIC V"``).
|
|
48
|
+
single_byte: Maps a single token byte to its keyword spelling.
|
|
49
|
+
Covers the whole one-byte keyword range, including the few
|
|
50
|
+
sub-``&80`` tokens such as ``&7F`` (``OTHERWISE``).
|
|
51
|
+
escape: Maps a two-byte escape *prefix* (``&C6``/``&C7``/``&C8``
|
|
52
|
+
in BASIC V) to a table from the *following* byte to its
|
|
53
|
+
extended keyword. Empty for dialects without escape tokens.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
name: str
|
|
57
|
+
single_byte: Mapping[int, str]
|
|
58
|
+
escape: Mapping[int, Mapping[int, str]]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# --- BBC BASIC II -----------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
#: BBC BASIC II — the BBC Micro's 8-bit language ROM. No escape tokens;
|
|
64
|
+
#: the single-byte map is the canonical :data:`TOKEN_TO_KEYWORD` table.
|
|
65
|
+
BASIC_II = Dialect(name="BBC BASIC II", single_byte=TOKEN_TO_KEYWORD, escape=MappingProxyType({}))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# --- BBC BASIC V ------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
# The extended-token tables, keyed by the byte that follows the prefix.
|
|
71
|
+
# Each runs from &8E upward, in ROM order.
|
|
72
|
+
_EXTENDED_FUNCTIONS = ("SUM", "BEAT")
|
|
73
|
+
_EXTENDED_COMMANDS = (
|
|
74
|
+
"APPEND",
|
|
75
|
+
"AUTO",
|
|
76
|
+
"CRUNCH",
|
|
77
|
+
"DELETE",
|
|
78
|
+
"EDIT",
|
|
79
|
+
"HELP",
|
|
80
|
+
"LIST",
|
|
81
|
+
"LOAD",
|
|
82
|
+
"LVAR",
|
|
83
|
+
"NEW",
|
|
84
|
+
"OLD",
|
|
85
|
+
"RENUMBER",
|
|
86
|
+
"SAVE",
|
|
87
|
+
"TEXTLOAD",
|
|
88
|
+
"TEXTSAVE",
|
|
89
|
+
"TWIN",
|
|
90
|
+
"TWINO",
|
|
91
|
+
"INSTALL",
|
|
92
|
+
)
|
|
93
|
+
_EXTENDED_STATEMENTS = (
|
|
94
|
+
"CASE",
|
|
95
|
+
"CIRCLE",
|
|
96
|
+
"FILL",
|
|
97
|
+
"ORIGIN",
|
|
98
|
+
"POINT",
|
|
99
|
+
"RECTANGLE",
|
|
100
|
+
"SWAP",
|
|
101
|
+
"WHILE",
|
|
102
|
+
"WAIT",
|
|
103
|
+
"MOUSE",
|
|
104
|
+
"QUIT",
|
|
105
|
+
"SYS",
|
|
106
|
+
"INSTALL",
|
|
107
|
+
"LIBRARY",
|
|
108
|
+
"TINT",
|
|
109
|
+
"ELLIPSE",
|
|
110
|
+
"BEATS",
|
|
111
|
+
"TEMPO",
|
|
112
|
+
"VOICES",
|
|
113
|
+
"VOICE",
|
|
114
|
+
"STEREO",
|
|
115
|
+
"OVERLAY",
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
_EXTENDED_TOKEN_BASE = 0x8E
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _extended_table(keywords: tuple[str, ...]) -> Mapping[int, str]:
|
|
122
|
+
"""Build a {second byte -> keyword} table counting from ``&8E``."""
|
|
123
|
+
return MappingProxyType(
|
|
124
|
+
{_EXTENDED_TOKEN_BASE + offset: keyword for offset, keyword in enumerate(keywords)}
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# Single-byte tokens BASIC V re-purposes from BASIC II. &7F is new;
|
|
129
|
+
# &C9-&CE displace the LIST/NEW/OLD/RENUMBER/SAVE command tokens (whose
|
|
130
|
+
# spellings move into the &C7 escape table) and the unused &CE gap.
|
|
131
|
+
_BASIC_V_SINGLE_BYTE_OVERRIDES = {
|
|
132
|
+
0x7F: "OTHERWISE",
|
|
133
|
+
0xC9: "WHEN",
|
|
134
|
+
0xCA: "OF",
|
|
135
|
+
0xCB: "ENDCASE",
|
|
136
|
+
0xCC: "ELSE",
|
|
137
|
+
0xCD: "ENDIF",
|
|
138
|
+
0xCE: "ENDWHILE",
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
# Start from BASIC II, drop the three bytes that are now escape prefixes
|
|
142
|
+
# (so a bare prefix never resolves to AUTO/DELETE/LOAD), then apply the
|
|
143
|
+
# re-purposed single-byte slots.
|
|
144
|
+
_basic_v_single_byte = {
|
|
145
|
+
token: keyword
|
|
146
|
+
for token, keyword in TOKEN_TO_KEYWORD.items()
|
|
147
|
+
if token not in (0xC6, 0xC7, 0xC8)
|
|
148
|
+
}
|
|
149
|
+
_basic_v_single_byte.update(_BASIC_V_SINGLE_BYTE_OVERRIDES)
|
|
150
|
+
|
|
151
|
+
#: BBC BASIC V — the Archimedes / RISC OS ARM BASIC. Adds the
|
|
152
|
+
#: ``&C6``/``&C7``/``&C8`` escape tables and the re-purposed single-byte
|
|
153
|
+
#: tokens described in this module's docstring.
|
|
154
|
+
BASIC_V = Dialect(
|
|
155
|
+
name="BBC BASIC V",
|
|
156
|
+
single_byte=MappingProxyType(_basic_v_single_byte),
|
|
157
|
+
escape=MappingProxyType(
|
|
158
|
+
{
|
|
159
|
+
0xC6: _extended_table(_EXTENDED_FUNCTIONS),
|
|
160
|
+
0xC7: _extended_table(_EXTENDED_COMMANDS),
|
|
161
|
+
0xC8: _extended_table(_EXTENDED_STATEMENTS),
|
|
162
|
+
}
|
|
163
|
+
),
|
|
164
|
+
)
|
|
@@ -26,13 +26,14 @@ import enum
|
|
|
26
26
|
from collections.abc import Iterator
|
|
27
27
|
from dataclasses import dataclass
|
|
28
28
|
|
|
29
|
+
from oaknut.basic.dialect import BASIC_II, Dialect
|
|
29
30
|
from oaknut.basic.exceptions import (
|
|
30
31
|
InvalidLineLengthError,
|
|
31
32
|
MissingLineMarkerError,
|
|
32
33
|
TruncatedProgramError,
|
|
33
34
|
)
|
|
34
35
|
from oaknut.basic.linenumber import decode_line_number
|
|
35
|
-
from oaknut.basic.tokens import HEADER_LENGTH, LINE_NUMBER_TOKEN
|
|
36
|
+
from oaknut.basic.tokens import HEADER_LENGTH, LINE_NUMBER_TOKEN
|
|
36
37
|
|
|
37
38
|
_CR = 0x0D
|
|
38
39
|
_END_MARKER = 0xFF
|
|
@@ -90,7 +91,7 @@ class LineRecord:
|
|
|
90
91
|
start: int
|
|
91
92
|
|
|
92
93
|
|
|
93
|
-
def scan(data: bytes, *, base_offset: int = 0) -> Iterator[Token]:
|
|
94
|
+
def scan(data: bytes, *, base_offset: int = 0, dialect: Dialect = BASIC_II) -> Iterator[Token]:
|
|
94
95
|
"""Scan an unframed line body into a stream of :class:`Token`.
|
|
95
96
|
|
|
96
97
|
Args:
|
|
@@ -99,10 +100,17 @@ def scan(data: bytes, *, base_offset: int = 0) -> Iterator[Token]:
|
|
|
99
100
|
base_offset: Added to every token's :attr:`~Token.start`, so
|
|
100
101
|
offsets can be reported against a larger buffer (for example
|
|
101
102
|
the line body's position within a whole program).
|
|
103
|
+
dialect: The BBC BASIC variant whose token tables to use.
|
|
104
|
+
Defaults to :data:`~oaknut.basic.BASIC_II`; pass
|
|
105
|
+
:data:`~oaknut.basic.BASIC_V` to resolve the
|
|
106
|
+
``&C6``/``&C7``/``&C8`` two-byte escape tokens and the
|
|
107
|
+
re-purposed single-byte tokens of the Archimedes language.
|
|
102
108
|
|
|
103
109
|
Yields:
|
|
104
110
|
One :class:`Token` per keyword, literal run, string literal, and
|
|
105
|
-
``&8D`` line-number reference, in source order.
|
|
111
|
+
``&8D`` line-number reference, in source order. For a two-byte
|
|
112
|
+
escape keyword the token's :attr:`~Token.token` is the prefix
|
|
113
|
+
byte and its :attr:`~Token.start` is the prefix's offset.
|
|
106
114
|
|
|
107
115
|
Raises:
|
|
108
116
|
DetokeniseError: A ``&8D`` reference is truncated.
|
|
@@ -141,13 +149,20 @@ def scan(data: bytes, *, base_offset: int = 0) -> Iterator[Token]:
|
|
|
141
149
|
yield Token(TokenKind.LINENUM, str(decode_line_number(payload)), base_offset + i)
|
|
142
150
|
i += 4
|
|
143
151
|
continue
|
|
144
|
-
if b
|
|
152
|
+
if b in dialect.escape and i + 1 < n and data[i + 1] in dialect.escape[b]:
|
|
145
153
|
yield from flush()
|
|
146
|
-
|
|
154
|
+
keyword = dialect.escape[b][data[i + 1]]
|
|
155
|
+
yield Token(TokenKind.KEYWORD, keyword, base_offset + i, b)
|
|
156
|
+
i += 2
|
|
157
|
+
continue
|
|
158
|
+
if b in dialect.single_byte:
|
|
159
|
+
yield from flush()
|
|
160
|
+
yield Token(TokenKind.KEYWORD, dialect.single_byte[b], base_offset + i, b)
|
|
147
161
|
i += 1
|
|
148
162
|
continue
|
|
149
|
-
# A literal byte
|
|
150
|
-
#
|
|
163
|
+
# A literal byte, an unknown high byte, or an escape prefix whose
|
|
164
|
+
# following byte has no extended-token meaning: coalesce into a
|
|
165
|
+
# TEXT run so the byte still round-trips.
|
|
151
166
|
if text_start < 0:
|
|
152
167
|
text_start = i
|
|
153
168
|
text_chars.append(chr(b))
|
|
@@ -155,12 +170,15 @@ def scan(data: bytes, *, base_offset: int = 0) -> Iterator[Token]:
|
|
|
155
170
|
yield from flush()
|
|
156
171
|
|
|
157
172
|
|
|
158
|
-
def scan_program(data: bytes) -> Iterator[LineRecord]:
|
|
173
|
+
def scan_program(data: bytes, *, dialect: Dialect = BASIC_II) -> Iterator[LineRecord]:
|
|
159
174
|
"""Walk a ``&0D``-framed stored program, scanning each line body.
|
|
160
175
|
|
|
161
176
|
Args:
|
|
162
177
|
data: A tokenised program as stored on disc or in memory,
|
|
163
178
|
terminated by the ``&0D &FF`` end marker.
|
|
179
|
+
dialect: The BBC BASIC variant whose token tables to use, passed
|
|
180
|
+
through to :func:`scan`. Defaults to
|
|
181
|
+
:data:`~oaknut.basic.BASIC_II`.
|
|
164
182
|
|
|
165
183
|
Yields:
|
|
166
184
|
One :class:`LineRecord` per line, in storage order, each carrying
|
|
@@ -187,6 +205,6 @@ def scan_program(data: bytes) -> Iterator[LineRecord]:
|
|
|
187
205
|
if length < HEADER_LENGTH or i + length > n:
|
|
188
206
|
raise InvalidLineLengthError(offset=i, length=length)
|
|
189
207
|
body = data[i + HEADER_LENGTH : i + length]
|
|
190
|
-
tokens = tuple(scan(body, base_offset=i + HEADER_LENGTH))
|
|
208
|
+
tokens = tuple(scan(body, base_offset=i + HEADER_LENGTH, dialect=dialect))
|
|
191
209
|
yield LineRecord(line_number=line_number, tokens=tokens, start=i)
|
|
192
210
|
i += length
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: oaknut-basic
|
|
3
|
-
Version: 12.
|
|
3
|
+
Version: 12.12.0
|
|
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,7 +4,9 @@ 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
|
|
9
|
+
src/oaknut/basic/dialect.py
|
|
8
10
|
src/oaknut/basic/exceptions.py
|
|
9
11
|
src/oaknut/basic/float5.py
|
|
10
12
|
src/oaknut/basic/linenumber.py
|
|
@@ -19,10 +21,12 @@ src/oaknut_basic.egg-info/entry_points.txt
|
|
|
19
21
|
src/oaknut_basic.egg-info/requires.txt
|
|
20
22
|
src/oaknut_basic.egg-info/top_level.txt
|
|
21
23
|
tests/test_basic.py
|
|
24
|
+
tests/test_basic_v.py
|
|
22
25
|
tests/test_cli.py
|
|
23
26
|
tests/test_crunch_rules.py
|
|
24
27
|
tests/test_data_cli.py
|
|
25
28
|
tests/test_datafile.py
|
|
29
|
+
tests/test_detect.py
|
|
26
30
|
tests/test_detokeniser.py
|
|
27
31
|
tests/test_float5.py
|
|
28
32
|
tests/test_keyword_coverage.py
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Tests for the BBC BASIC V (Archimedes/RISC OS) detokenising dialect.
|
|
2
|
+
|
|
3
|
+
BASIC V re-uses three BASIC II command tokens — ``&C6``/``&C7``/``&C8`` —
|
|
4
|
+
as two-byte *escape prefixes*: the following byte selects an extended
|
|
5
|
+
keyword (``SUM``, ``RENUMBER``, ``CASE``/``SYS``/``ORIGIN`` ...). It also
|
|
6
|
+
re-purposes several single-byte slots: ``&7F`` becomes ``OTHERWISE`` and
|
|
7
|
+
``&C9``-``&CE`` — ``LIST``/``NEW``/``OLD``/``RENUMBER``/``SAVE`` in
|
|
8
|
+
BASIC II — become the block keywords ``WHEN``/``OF``/``ENDCASE``/
|
|
9
|
+
``ELSE``/``ENDIF``/``ENDWHILE``. The default dialect stays BASIC II, so
|
|
10
|
+
the escape bytes still decode to ``AUTO``/``DELETE``/``LOAD`` unless the
|
|
11
|
+
caller opts into :data:`BASIC_V`.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
from oaknut.basic import (
|
|
16
|
+
BASIC_II,
|
|
17
|
+
BASIC_V,
|
|
18
|
+
Dialect,
|
|
19
|
+
TokenKind,
|
|
20
|
+
detokenise,
|
|
21
|
+
detokenise_body,
|
|
22
|
+
scan,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _program(*lines: tuple[int, bytes]) -> bytes:
|
|
27
|
+
"""Frame (line_number, body) pairs into a tokenised program."""
|
|
28
|
+
out = bytearray()
|
|
29
|
+
for line_number, body in lines:
|
|
30
|
+
out += bytes((0x0D, (line_number >> 8) & 0xFF, line_number & 0xFF, 4 + len(body)))
|
|
31
|
+
out += body
|
|
32
|
+
out += b"\x0d\xff"
|
|
33
|
+
return bytes(out)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class TestEscapeStatements:
|
|
37
|
+
"""``&C8`` <byte> — extended statement tokens, second byte from &8E."""
|
|
38
|
+
|
|
39
|
+
@pytest.mark.parametrize(
|
|
40
|
+
("second", "keyword"),
|
|
41
|
+
[
|
|
42
|
+
(0x8E, "CASE"),
|
|
43
|
+
(0x8F, "CIRCLE"),
|
|
44
|
+
(0x90, "FILL"),
|
|
45
|
+
(0x91, "ORIGIN"),
|
|
46
|
+
(0x92, "POINT"),
|
|
47
|
+
(0x93, "RECTANGLE"),
|
|
48
|
+
(0x94, "SWAP"),
|
|
49
|
+
(0x95, "WHILE"),
|
|
50
|
+
(0x96, "WAIT"),
|
|
51
|
+
(0x97, "MOUSE"),
|
|
52
|
+
(0x98, "QUIT"),
|
|
53
|
+
(0x99, "SYS"),
|
|
54
|
+
(0x9A, "INSTALL"),
|
|
55
|
+
(0x9B, "LIBRARY"),
|
|
56
|
+
(0x9C, "TINT"),
|
|
57
|
+
(0x9D, "ELLIPSE"),
|
|
58
|
+
(0x9E, "BEATS"),
|
|
59
|
+
(0x9F, "TEMPO"),
|
|
60
|
+
(0xA0, "VOICES"),
|
|
61
|
+
(0xA1, "VOICE"),
|
|
62
|
+
(0xA2, "STEREO"),
|
|
63
|
+
(0xA3, "OVERLAY"),
|
|
64
|
+
],
|
|
65
|
+
)
|
|
66
|
+
def test_c8_escape_decodes_to_statement(self, second: int, keyword: str):
|
|
67
|
+
assert detokenise_body(bytes((0xC8, second)), dialect=BASIC_V) == keyword
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class TestEscapeCommands:
|
|
71
|
+
"""``&C7`` <byte> — extended command tokens, second byte from &8E."""
|
|
72
|
+
|
|
73
|
+
@pytest.mark.parametrize(
|
|
74
|
+
("second", "keyword"),
|
|
75
|
+
[
|
|
76
|
+
(0x8E, "APPEND"),
|
|
77
|
+
(0x8F, "AUTO"),
|
|
78
|
+
(0x90, "CRUNCH"),
|
|
79
|
+
(0x91, "DELETE"),
|
|
80
|
+
(0x92, "EDIT"),
|
|
81
|
+
(0x93, "HELP"),
|
|
82
|
+
(0x94, "LIST"),
|
|
83
|
+
(0x95, "LOAD"),
|
|
84
|
+
(0x96, "LVAR"),
|
|
85
|
+
(0x97, "NEW"),
|
|
86
|
+
(0x98, "OLD"),
|
|
87
|
+
(0x99, "RENUMBER"),
|
|
88
|
+
(0x9A, "SAVE"),
|
|
89
|
+
(0x9B, "TEXTLOAD"),
|
|
90
|
+
(0x9C, "TEXTSAVE"),
|
|
91
|
+
(0x9D, "TWIN"),
|
|
92
|
+
(0x9E, "TWINO"),
|
|
93
|
+
(0x9F, "INSTALL"),
|
|
94
|
+
],
|
|
95
|
+
)
|
|
96
|
+
def test_c7_escape_decodes_to_command(self, second: int, keyword: str):
|
|
97
|
+
assert detokenise_body(bytes((0xC7, second)), dialect=BASIC_V) == keyword
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class TestEscapeFunctions:
|
|
101
|
+
"""``&C6`` <byte> — the two extended function tokens."""
|
|
102
|
+
|
|
103
|
+
@pytest.mark.parametrize(("second", "keyword"), [(0x8E, "SUM"), (0x8F, "BEAT")])
|
|
104
|
+
def test_c6_escape_decodes_to_function(self, second: int, keyword: str):
|
|
105
|
+
assert detokenise_body(bytes((0xC6, second)), dialect=BASIC_V) == keyword
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class TestSingleByteDifferences:
|
|
109
|
+
"""Single-byte tokens BASIC V re-purposes from BASIC II."""
|
|
110
|
+
|
|
111
|
+
@pytest.mark.parametrize(
|
|
112
|
+
("token", "keyword"),
|
|
113
|
+
[
|
|
114
|
+
(0x7F, "OTHERWISE"),
|
|
115
|
+
(0xC9, "WHEN"),
|
|
116
|
+
(0xCA, "OF"),
|
|
117
|
+
(0xCB, "ENDCASE"),
|
|
118
|
+
(0xCC, "ELSE"),
|
|
119
|
+
(0xCD, "ENDIF"),
|
|
120
|
+
(0xCE, "ENDWHILE"),
|
|
121
|
+
],
|
|
122
|
+
)
|
|
123
|
+
def test_single_byte_token_decodes_to_basic_v_keyword(self, token: int, keyword: str):
|
|
124
|
+
assert detokenise_body(bytes((token,)), dialect=BASIC_V) == keyword
|
|
125
|
+
|
|
126
|
+
def test_shared_single_byte_tokens_are_unchanged(self):
|
|
127
|
+
# Tokens common to both dialects still decode the same way.
|
|
128
|
+
assert detokenise_body(b"\xf1", dialect=BASIC_V) == "PRINT"
|
|
129
|
+
assert detokenise_body(b"\x8f", dialect=BASIC_V) == "PTR"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class TestIssue44Reproduction:
|
|
133
|
+
def test_origin_statement_is_not_loadtime(self):
|
|
134
|
+
# Line 140 of "Herding (RR1)", Acorn User June 1991:
|
|
135
|
+
# C8 91 " 640,512" -> ORIGIN 640,512 (was: LOADTIME 640,512)
|
|
136
|
+
body = bytes((0xC8, 0x91)) + b" 640,512"
|
|
137
|
+
assert detokenise_body(body, dialect=BASIC_V) == "ORIGIN 640,512"
|
|
138
|
+
|
|
139
|
+
def test_program_round_through_detokenise(self):
|
|
140
|
+
program = _program((140, bytes((0xC8, 0x91)) + b" 640,512"))
|
|
141
|
+
assert detokenise(program, dialect=BASIC_V) == "140ORIGIN 640,512\n"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class TestDefaultDialectUnchanged:
|
|
145
|
+
"""Without opting in, the escape bytes keep their BASIC II meaning."""
|
|
146
|
+
|
|
147
|
+
def test_c8_defaults_to_load_plus_token(self):
|
|
148
|
+
body = bytes((0xC8, 0x91)) + b" 640,512"
|
|
149
|
+
assert detokenise_body(body) == "LOADTIME 640,512"
|
|
150
|
+
|
|
151
|
+
def test_default_dialect_is_basic_ii(self):
|
|
152
|
+
body = bytes((0xC8, 0x91))
|
|
153
|
+
assert detokenise_body(body, dialect=BASIC_II) == detokenise_body(body)
|
|
154
|
+
|
|
155
|
+
def test_basic_v_c9_is_list_under_basic_ii(self):
|
|
156
|
+
assert detokenise_body(b"\xc9") == "LIST"
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class TestScanStream:
|
|
160
|
+
def test_escape_token_is_one_keyword_advancing_two_bytes(self):
|
|
161
|
+
body = bytes((0xC8, 0x99)) + b"X" # SYS then a literal X
|
|
162
|
+
tokens = list(scan(body, dialect=BASIC_V))
|
|
163
|
+
assert tokens[0].kind is TokenKind.KEYWORD
|
|
164
|
+
assert tokens[0].value == "SYS"
|
|
165
|
+
assert tokens[0].start == 0
|
|
166
|
+
assert tokens[1].kind is TokenKind.TEXT
|
|
167
|
+
assert tokens[1].value == "X"
|
|
168
|
+
assert tokens[1].start == 2
|
|
169
|
+
|
|
170
|
+
def test_escape_byte_inside_string_stays_literal(self):
|
|
171
|
+
body = b'"' + bytes((0xC8, 0x91)) + b'"'
|
|
172
|
+
tokens = list(scan(body, dialect=BASIC_V))
|
|
173
|
+
assert len(tokens) == 1
|
|
174
|
+
assert tokens[0].kind is TokenKind.STRING
|
|
175
|
+
assert tokens[0].value == '"\xc8\x91"'
|
|
176
|
+
|
|
177
|
+
def test_unterminated_escape_prefix_stays_literal(self):
|
|
178
|
+
# A trailing &C8 with no following byte must not over-read.
|
|
179
|
+
tokens = list(scan(b"\xc8", dialect=BASIC_V))
|
|
180
|
+
assert tokens[0].kind is TokenKind.TEXT
|
|
181
|
+
assert tokens[0].value == "\xc8"
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class TestDialectObject:
|
|
185
|
+
def test_basic_v_has_a_name(self):
|
|
186
|
+
assert isinstance(BASIC_V, Dialect)
|
|
187
|
+
assert "V" in BASIC_V.name
|
|
188
|
+
|
|
189
|
+
def test_unknown_escape_second_byte_is_left_literal(self):
|
|
190
|
+
# &C8 followed by a byte with no extended-token meaning: emit the
|
|
191
|
+
# prefix as a literal rather than inventing a keyword or over-reading.
|
|
192
|
+
body = bytes((0xC8, 0x41)) # &41 = 'A', not an extended statement
|
|
193
|
+
tokens = list(scan(body, dialect=BASIC_V))
|
|
194
|
+
assert tokens[0].kind is TokenKind.TEXT
|
|
195
|
+
assert tokens[0].value.startswith("\xc8")
|
|
@@ -9,6 +9,7 @@ rather than re-testing the numbering or tokenising logic itself.
|
|
|
9
9
|
from pathlib import Path
|
|
10
10
|
|
|
11
11
|
import oaknut.basic as basic
|
|
12
|
+
import pytest
|
|
12
13
|
from click.testing import CliRunner
|
|
13
14
|
from oaknut.basic.cli import cli
|
|
14
15
|
|
|
@@ -230,6 +231,84 @@ class TestDetokeniseCommand:
|
|
|
230
231
|
assert result.exit_code != 0
|
|
231
232
|
assert "offset 0" in result.output
|
|
232
233
|
|
|
234
|
+
@pytest.mark.parametrize("dialect", ["v", "5"])
|
|
235
|
+
def test_dialect_v_decodes_escape_tokens(self, dialect: str):
|
|
236
|
+
# &C8 &91 is the BASIC V ORIGIN statement, not "LOADTIME".
|
|
237
|
+
# The Roman "v" and Arabic "5" numerals are interchangeable.
|
|
238
|
+
program = b"\x0d\x00\x8c\x0e\xc8\x91 640,512\x0d\xff"
|
|
239
|
+
runner = CliRunner()
|
|
240
|
+
result = runner.invoke(cli, ["detokenise", "--dialect", dialect], input=program)
|
|
241
|
+
assert result.exit_code == 0
|
|
242
|
+
assert result.stdout_bytes == b"140ORIGIN 640,512\n"
|
|
243
|
+
|
|
244
|
+
@pytest.mark.parametrize("dialect", ["ii", "2"])
|
|
245
|
+
def test_dialect_ii_keeps_basic_ii_meaning(self, dialect: str):
|
|
246
|
+
program = b"\x0d\x00\x8c\x0e\xc8\x91 640,512\x0d\xff"
|
|
247
|
+
runner = CliRunner()
|
|
248
|
+
result = runner.invoke(cli, ["detokenise", "--dialect", dialect], input=program)
|
|
249
|
+
assert result.exit_code == 0
|
|
250
|
+
assert result.stdout_bytes == b"140LOADTIME 640,512\n"
|
|
251
|
+
|
|
252
|
+
def test_default_dialect_is_basic_ii(self):
|
|
253
|
+
program = b"\x0d\x00\x8c\x0e\xc8\x91 640,512\x0d\xff"
|
|
254
|
+
runner = CliRunner()
|
|
255
|
+
result = runner.invoke(cli, ["detokenise"], input=program)
|
|
256
|
+
assert result.exit_code == 0
|
|
257
|
+
assert result.stdout_bytes == b"140LOADTIME 640,512\n"
|
|
258
|
+
|
|
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
|
+
|
|
233
312
|
|
|
234
313
|
class TestTokeniseDetokeniseRoundTrip:
|
|
235
314
|
def test_cli_round_trip(self):
|
|
@@ -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
|
{oaknut_basic-12.10.1 → oaknut_basic-12.12.0}/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
|