Coverage for tests\test_derivepassphrase_sequin.py: 99.123%

1# SPDX-FileCopyrightText: 2025 Marco Ricci <software@the13thletter.info>

2#

3# SPDX-License-Identifier: Zlib

4

5"""Test sequin.Sequin."""

6

7 from __future__ import annotations (empty)

8

9 import collections (empty)

10 import contextlib (empty)

11 import functools (empty)

12 import math (empty)

13 import operator (empty)

14 import types (empty)

15 from typing import TYPE_CHECKING, NamedTuple (empty)

16

17 import hypothesis (empty)

18 import pytest (empty)

19 from hypothesis import strategies (empty)

20

21 from derivepassphrase import sequin (empty)

22

23 if TYPE_CHECKING: (empty)

24 from collections.abc import Sequence

25

26

27 def bits(num: int, /, byte_width: int | None = None) -> list[int]: (empty)

28 """Return the list of bits of an integer, in big endian order.

29

30 Args:

31 num:

32 The number whose bits are to be returned.

33 byte_width:

34 Pad the returned list of bits to the given byte width if given,

35 else its natural byte width.

36

37 """

38 if num < 0: # pragma: no cover 3 ctx 1ceb

39 err_msg = 'Negative numbers are unsupported'

40 raise NotImplementedError(err_msg)

41 if byte_width is None: 3 ctx 1ceb

42 byte_width = math.ceil(math.log2(num) / 8) if num else 1 1 ctx 1c

43 seq: list[int] = [] 3 ctx 1ceb

44 while num: 3 ctx 1ceb

45 seq.append(num % 2) 3 ctx 1ceb

46 num >>= 1 3 ctx 1ceb

47 seq.reverse() 3 ctx 1ceb

48 missing_bit_count = 8 * byte_width - len(seq) 3 ctx 1ceb

49 seq[:0] = [0] * missing_bit_count 3 ctx 1ceb

50 return seq 3 ctx 1ceb

51

52

53 def bitseq(string: str) -> list[int]: (empty)

54 """Convert a 0/1-string into a list of bits."""

55 return [int(char, 2) for char in string] 4 ctx 1aehg

56

57

58 class Parametrize(types.SimpleNamespace): (empty)

59 BIG_ENDIAN_NUMBER_EXCEPTIONS = pytest.mark.parametrize( (empty)

60 ['exc_type', 'exc_pattern', 'sequence', 'base'],

61 [

62 (ValueError, 'invalid base 3 digit:', [-1], 3),

63 (ValueError, 'invalid base:', [0], 1),

64 (TypeError, 'not an integer:', [0.0, 1.0, 0.0, 1.0], 2),

65 ],

66 )

67 INVALID_SEQUIN_INPUTS = pytest.mark.parametrize( (empty)

68 ['sequence', 'is_bitstring', 'exc_type', 'exc_pattern'],

69 [

70 (

71 [0, 1, 2, 3, 4, 5, 6, 7],

72 True,

73 ValueError,

74 'sequence item out of range',

75 ),

76 ('こんにちは。', False, ValueError, 'sequence item out of range'),

77 ],

78 )

79

80

81 class TestStaticFunctionality: (empty)

82 """Test the static functionality in the `sequin` module."""

83

84 @hypothesis.given( (empty)

85 num=strategies.integers(min_value=0, max_value=0xFFFFFFFFFFFFFFFF),

86 )

87 def test_100_bits(self, num: int) -> None: (empty)

88 """Extract the bits from a number in big-endian format."""

89 seq1 = bits(num) 1 ctx 1c

90 n = len(seq1) 1 ctx 1c

91 seq2 = bits(num, byte_width=8) 1 ctx 1c

92 m = len(seq2) 1 ctx 1c

93 assert m == 64 1 ctx 1c

94 assert seq2[-n:] == seq1 1 ctx 1c

95 assert seq2[: m - n] == [0] * (m - n) 1 ctx 1c

96 text1 = ''.join(str(bit) for bit in seq1) 1 ctx 1c

97 text2 = ''.join(str(bit) for bit in seq2) 1 ctx 1c

98 assert text1.lstrip('0') == (f'{num: b}' if num else '') 1 ctx 1c

99 assert text2 == f'{num:064b}' 1 ctx 1c

100

101 @hypothesis.given( (empty)

102 num=strategies.integers(min_value=0, max_value=0xFFFFFFFFFFFFFFFF),

103 )

104 def test_101_bits(self, num: int) -> None: (empty)

105 """Extract the bits from a number in big-endian format."""

106 text1 = f'{num:064b}' 1 ctx 1e

107 seq1 = bitseq(text1) 1 ctx 1e

108 seq2 = bits(num, byte_width=8) 1 ctx 1e

109 assert seq1 == seq2 1 ctx 1e

110 text2 = ''.join(str(bit) for bit in seq1) 1 ctx 1e

111 assert int(text2, 2) == num 1 ctx 1e

112

113 class BigEndianNumberTest(NamedTuple): (empty)

114 """Test data for

115 [`TestStaticFunctionality.test_200_big_endian_number`][].

116

117 Attributes:

118 sequence: A sequence of integers.

119 base: The numeric base.

120 expected: The expected result.

121

122 """

123

124 sequence: list[int] (empty)

125 """""" (empty)

126 base: int (empty)

127 """""" (empty)

128 expected: int (empty)

129 """""" (empty)

130

131 @strategies.composite (empty)

132 @staticmethod (empty)

133 def strategy( (empty)

134 draw: strategies.DrawFn,

135 *,

136 base: int | None = None,

137 max_size: int | None = None,

138 ) -> TestStaticFunctionality.BigEndianNumberTest:

139 """Return a sample BigEndianNumberTest.

140

141 Args:

142 draw:

143 The `draw` function, as provided for by hypothesis.

144 base:

145 The numeric base, an integer between 2 and 65536 (inclusive).

146 max_size:

147 The maximum size of the sequence, up to 128.

148

149 Raises:

150 AssertionError:

151 `base` or `max_size` are invalid.

152

153 """

154 if base is None: # pragma: no cover 1 ctx 1f

155 base = 256 1 ctx 1f

156 assert isinstance(base, int) 1 ctx 1f

157 assert base in range(2, 65537) 1 ctx 1f

158 if max_size is None: # pragma: no cover 1 ctx 1f

159 max_size = 128 1 ctx 1f

160 assert isinstance(max_size, int) 1 ctx 1f

161 assert max_size in range(129) 1 ctx 1f

162 sequence = draw( 1 ctx 1f

163 strategies.lists(

164 strategies.integers(min_value=0, max_value=(base - 1)),

165 max_size=max_size,

166 ),

167 )

168 value = functools.reduce(lambda x, y: x * base + y, sequence, 0) 1 ctx 1f

169 return TestStaticFunctionality.BigEndianNumberTest( 1 ctx 1f

170 sequence, base, value

171 )

172

173 @hypothesis.given(test_case=BigEndianNumberTest.strategy()) (empty)

174 @hypothesis.example( 2 ctx 1af

175 BigEndianNumberTest([1, 2, 3, 4, 5, 6], 10, 123456)

176 ).via('manual decimal example')

177 @hypothesis.example( (empty)

178 BigEndianNumberTest([1, 2, 3, 4, 5, 6], 100, 10203040506)

179 ).via('manual decimal example in different base')

180 @hypothesis.example(BigEndianNumberTest([0, 0, 1, 4, 9, 7], 10, 1497)).via( (empty)

181 'manual example with leading zeroes'

182 )

183 @hypothesis.example( (empty)

184 BigEndianNumberTest([1, 0, 0, 1, 0, 0, 0, 0], 2, 144)

185 ).via('manual binary example')

186 @hypothesis.example(BigEndianNumberTest([1, 7, 5, 5], 8, 0o1755)).via( (empty)

187 'manual octal example'

188 )

189 def test_200_big_endian_number( (empty)

190 self, test_case: BigEndianNumberTest

191 ) -> None:

192 """Conversion to big endian numbers in any base works.

193

194 See [`sequin.Sequin.generate`][] for where this is used.

195

196 """

197 sequence, base, expected = test_case 1 ctx 1f

198 assert ( 1 ctx 1f

199 sequin.Sequin._big_endian_number(sequence, base=base)

200 ) == expected

201

202 @Parametrize.BIG_ENDIAN_NUMBER_EXCEPTIONS (empty)

203 def test_300_big_endian_number_exceptions( (empty)

204 self,

205 exc_type: type[Exception],

206 exc_pattern: str,

207 sequence: list[int],

208 base: int,

209 ) -> None:

210 """Nonsensical conversion of numbers in a given base raises.

211

212 See [`sequin.Sequin.generate`][] for where this is used.

213

214 """

215 with pytest.raises(exc_type, match=exc_pattern): 1 ctx 1k

216 sequin.Sequin._big_endian_number(sequence, base=base) 1 ctx 1k

217

218

219 class TestSequin: (empty)

220 """Test the `Sequin` class."""

221

222 class ConstructorTestCase(NamedTuple): (empty)

223 """A test case for the constructor.

224

225 Attributes:

226 sequence:

227 A sequence of ints, bits, or Latin1 characters.

228 is_bitstring:

229 True if and only if `sequence` denotes bits.

230 expected:

231 The expected bit sequence of the internal entropy pool.

232

233 """

234

235 sequence: Sequence[int] | str (empty)

236 """""" (empty)

237 is_bitstring: bool (empty)

238 """""" (empty)

239 expected: Sequence[int] (empty)

240

241 @strategies.composite (empty)

242 @staticmethod (empty)

243 def strategy( (empty)

244 draw: strategies.DrawFn,

245 *,

246 max_entropy: int | None = None,

247 ) -> TestSequin.ConstructorTestCase:

248 """Return a constructor test case.

249

250 Args:

251 max_entropy:

252 The maximum entropy, in bits. Must be between 0 and

253 256, inclusive.

254

255 Raises:

256 AssertionError:

257 `max_entropy` is invalid.

258

259 """

260 if max_entropy is None: # pragma: no branch 1 ctx 1b

261 max_entropy = 256 1 ctx 1b

262 assert max_entropy in range(257) 1 ctx 1b

263 is_bytecount = max_entropy % 8 == 0 1 ctx 1b

264 is_bitstring = ( 1 ctx 1b

265 draw(strategies.randoms()).choice([False, True])

266 if is_bytecount

267 else True

268 )

269 sequence: Sequence[int] | str

270 expected: Sequence[int]

271 if is_bitstring: 1 ctx 1b

272 sequence = draw( 1 ctx 1b

273 strategies.lists(

274 strategies.integers(min_value=0, max_value=1),

275 max_size=max_entropy,

276 )

277 )

278 expected = sequence 1 ctx 1b

279 else:

280 bytecount = max_entropy // 8 1 ctx 1b

281 raw_sequence = draw(strategies.binary(max_size=bytecount)) 1 ctx 1b

282 sequence_format = draw(strategies.randoms()).choice([ 1 ctx 1b

283 'bytes',

284 'ints',

285 'text',

286 ])

287 if sequence_format == 'bytes': 1 ctx 1b

288 sequence = raw_sequence 1 ctx 1b

289 elif sequence_format == 'ints': 289 ↛ 292line 289 didn't jump to line 292 because the condition on line 289 was always true 1 ctx 1b

290 sequence = list(raw_sequence) 1 ctx 1b

291 else:

292 sequence = raw_sequence.decode('latin1')

293 bytestring = ( 1 ctx 1b

294 sequence.encode('latin1')

295 if isinstance(sequence, str)

296 else bytes(sequence)

297 )

298 expected = [] 1 ctx 1b

299 for byte in bytestring: 1 ctx 1b

300 expected.extend(bits(byte, byte_width=1)) 1 ctx 1b

301 return TestSequin.ConstructorTestCase( 1 ctx 1b

302 sequence, is_bitstring, expected

303 )

304

305 @hypothesis.given(test_case=ConstructorTestCase.strategy()) (empty)

306 @hypothesis.example( 2 ctx 1ab

307 ConstructorTestCase([1, 0, 0, 1, 0, 1], True, [1, 0, 0, 1, 0, 1])

308 ).via('manual example bitstring')

309 @hypothesis.example( (empty)

310 ConstructorTestCase(

311 [1, 0, 0, 1, 0, 1],

312 False,

313 bitseq('000000010000000000000000000000010000000000000001'),

314 )

315 ).via('manual example bitstring as byte string')

316 @hypothesis.example( (empty)

317 ConstructorTestCase(b'OK', False, bitseq('0100111101001011'))

318 ).via('manual example true byte string')

319 @hypothesis.example( (empty)

320 ConstructorTestCase('OK', False, bitseq('0100111101001011'))

321 ).via('manual example latin1 text')

322 def test_200_constructor( (empty)

323 self,

324 test_case: ConstructorTestCase,

325 ) -> None:

326 """The constructor handles both bit and integer sequences."""

327 sequence, is_bitstring, expected = test_case 1 ctx 1b

328 seq = sequin.Sequin(sequence, is_bitstring=is_bitstring) 1 ctx 1b

329 assert seq.bases == {2: collections.deque(expected)} 1 ctx 1b

330

331 class GenerationSequence(NamedTuple): (empty)

332 """A sequence of generation results.

333

334 Attributes:

335 bit_sequence:

336 The input bit sequence.

337 steps:

338 A sequence of generation steps. Each step details

339 a requested number base, and the respective result (a

340 number, or [`sequin.SequinExhaustedError`][]).

341

342 """

343

344 bit_sequence: Sequence[int] (empty)

345 """""" (empty)

346 steps: Sequence[tuple[int, int | type[sequin.SequinExhaustedError]]] (empty)

347 """""" (empty)

348

349 @strategies.composite (empty)

350 @staticmethod (empty)

351 def strategy(draw: strategies.DrawFn) -> TestSequin.GenerationSequence: (empty)

352 """Return a generation sequence."""

353 # Signal that there is only one value.

354 draw(strategies.just(None)) 2 ctx 1hg

355 return TestSequin.GenerationSequence( 2 ctx 1hg

356 bitseq('110101011111001'),

357 [

358 (1, 0),

359 (5, 3),

360 (5, 3),

361 (5, 1),

362 (5, sequin.SequinExhaustedError),

363 (1, sequin.SequinExhaustedError),

364 ],

365 )

366

367 @hypothesis.example( (empty)

368 GenerationSequence(

369 bitseq('110101011111001'),

370 [

371 (1, 0),

372 (5, 3),

373 (5, 3),

374 (5, 1),

375 (5, sequin.SequinExhaustedError),

376 (1, sequin.SequinExhaustedError),

377 ],

378 )

379 ).via('manual, pre-hypothesis parametrization value')

380 @hypothesis.given(sequence=GenerationSequence.strategy()) (empty)

381 def test_201_generating(self, sequence: GenerationSequence) -> None: (empty)

382 """The sequin generates deterministic sequences."""

383 seq = sequin.Sequin(sequence.bit_sequence, is_bitstring=True) 1 ctx 1h

384 for i, (num, result) in enumerate(sequence.steps, start=1): 1 ctx 1h

385 if isinstance(result, int): 1 ctx 1h

386 assert seq.generate(num) == result, ( 1 ctx 1h

387 f'Failed to generate {result: d} in step {i}'

388 )

389 else:

390 # Can't use pytest.raises here, because the assertion error

391 # message is not customizable and we would lose information

392 # about which step we're executing.

393 with contextlib.suppress(sequin.SequinExhaustedError): 1 ctx 1h

394 result2 = seq.generate(num) 1 ctx 1h

395 pytest.fail( 1 ctx 1h

396 f'Expected to be exhausted in step {i}, '

397 f'but generated {result2: d} instead'

398 )

399

400 def test_201a_generating_errors(self) -> None: (empty)

401 """The sequin errors deterministically when generating sequences."""

402 seq = sequin.Sequin( 1 ctx 1j

403 [1, 1, 0, 1, 0, 1, 0, 1, 1, 1, 1, 1, 0, 0, 1], is_bitstring=True

404 )

405 with pytest.raises(ValueError, match='invalid target range'): 1 ctx 1j

406 seq.generate(0) 1 ctx 1j

407

408 @hypothesis.example( (empty)

409 GenerationSequence(

410 bitseq('110101011111001'),

411 [

412 (1, 0),

413 (5, 3),

414 (5, 3),

415 (5, 1),

416 (5, sequin.SequinExhaustedError),

417 (1, sequin.SequinExhaustedError),

418 ],

419 )

420 ).via('manual, pre-hypothesis parametrization value')

421 @hypothesis.given(sequence=GenerationSequence.strategy()) (empty)

422 def test_210_internal_generating( (empty)

423 self, sequence: GenerationSequence

424 ) -> None:

425 """The sequin internals generate deterministic sequences."""

426 seq = sequin.Sequin(sequence.bit_sequence, is_bitstring=True) 1 ctx 1g

427 for i, (num, result) in enumerate(sequence.steps, start=1): 1 ctx 1g

428 if num == 1: 1 ctx 1g

429 assert seq._generate_inner(num) == 0, ( 1 ctx 1g

430 f'Failed to generate {result: d} in step {i}'

431 )

432 elif isinstance(result, int): 1 ctx 1g

433 assert seq._generate_inner(num) == result, ( 1 ctx 1g

434 f'Failed to generate {result: d} in step {i}'

435 )

436 else:

437 result2 = seq._generate_inner(num) 1 ctx 1g

438 assert result2 == num, ( 1 ctx 1g

439 f'Expected to be exhausted in step {i}, '

440 f'but generated {result2: d} instead'

441 )

442

443 def test_210a_internal_generating_errors(self) -> None: (empty)

444 """The sequin generation internals error deterministically."""

445 seq = sequin.Sequin( 1 ctx 1i

446 [1, 1, 0, 1, 0, 1, 0, 1, 1, 1, 1, 1, 0, 0, 1], is_bitstring=True

447 )

448 with pytest.raises(ValueError, match='invalid target range'): 1 ctx 1i

449 seq._generate_inner(0) 1 ctx 1i

450 with pytest.raises(ValueError, match='invalid base:'): 1 ctx 1i

451 seq._generate_inner(16, base=1) 1 ctx 1i

452

453 class ShiftSequence(NamedTuple): (empty)

454 """A sequence of bit sequence shift operations.

455

456 Attributes:

457 bit_sequence:

458 The input bit sequence.

459 steps:

460 A sequence of shift steps. Each step details

461 a requested shift size, the respective result, and the

462 bit sequence status afterward.

463

464 """

465

466 bit_sequence: Sequence[int] (empty)

467 """""" (empty)

468 steps: Sequence[tuple[int, Sequence[int], Sequence[int]]] (empty)

469 """""" (empty)

470

471 @strategies.composite (empty)

472 @staticmethod (empty)

473 def strategy(draw: strategies.DrawFn) -> TestSequin.ShiftSequence: (empty)

474 """Return a generation sequence."""

475 no_op_counts_strategy = strategies.lists( 1 ctx 1d

476 strategies.integers(min_value=0, max_value=0),

477 min_size=3,

478 max_size=3,

479 )

480 true_counts_strategy = strategies.lists( 1 ctx 1d

481 strategies.integers(min_value=1, max_value=5),

482 min_size=3,

483 max_size=10,

484 ).map(sorted)

485 bits_strategy = strategies.integers(min_value=0, max_value=1) 1 ctx 1d

486 counts = draw( 1 ctx 1d

487 strategies.builds(

488 operator.add,

489 no_op_counts_strategy,

490 true_counts_strategy,

491 ).flatmap(strategies.permutations)

492 )

493 bit_sequence: list[int] = [] 1 ctx 1d

494 steps: list[tuple[int, Sequence[int], list[int]]] = [] 1 ctx 1d

495 for i, count in enumerate(counts): 1 ctx 1d

496 shift_result = draw( 1 ctx 1d

497 strategies.lists(

498 bits_strategy, min_size=count, max_size=count

499 )

500 )

501 for step in steps[:i]: 1 ctx 1d

502 step[2].extend(shift_result) 1 ctx 1d

503 bit_sequence.extend(shift_result) 1 ctx 1d

504 steps.append((count, shift_result, [])) 1 ctx 1d

505 return TestSequin.ShiftSequence(bit_sequence, steps) 1 ctx 1d

506

507 @hypothesis.given(sequence=ShiftSequence.strategy()) (empty)

508 @hypothesis.example( 2 ctx 1ad

509 ShiftSequence(

510 bitseq('1010010001'),

511 [

512 (3, bitseq('101'), bitseq('0010001')),

513 (3, bitseq('001'), bitseq('0001')),

514 (5, bitseq(''), bitseq('0001')),

515 (4, bitseq('0001'), bitseq('')),

516 ],

517 )

518 )

519 def test_211_shifting(self, sequence: ShiftSequence) -> None: (empty)

520 """The sequin manages the pool of remaining entropy for each base.

521

522 Specifically, the sequin implements all-or-nothing fixed-length

523 draws from the entropy pool.

524

525 """

526 seq = sequin.Sequin(sequence.bit_sequence, is_bitstring=True) 1 ctx 1d

527 assert seq.bases == {2: collections.deque(sequence.bit_sequence)} 1 ctx 1d

528 for i, (count, result, remaining) in enumerate( 1 ctx 1d

529 sequence.steps, start=1

530 ):

531 actual_result = seq._all_or_nothing_shift(count) 1 ctx 1d

532 assert actual_result == tuple(result), ( 1 ctx 1d

533 f'At step {i}, the shifting result differs'

534 )

535 if remaining: 1 ctx 1d

536 assert seq.bases[2] == collections.deque(remaining), ( 1 ctx 1d

537 f'After step {i}, the remaining bit sequence differs'

538 )

539 else:

540 assert 2 not in seq.bases, ( 1 ctx 1d

541 f'After step {i}, the bit sequence is not exhausted yet'

542 )

543

544 @Parametrize.INVALID_SEQUIN_INPUTS (empty)

545 def test_300_constructor_exceptions( (empty)

546 self,

547 sequence: list[int] | str,

548 is_bitstring: bool,

549 exc_type: type[Exception],

550 exc_pattern: str,

551 ) -> None:

552 """The sequin raises on invalid bit and integer sequences."""

553 with pytest.raises(exc_type, match=exc_pattern): 1 ctx 1l

554 sequin.Sequin(sequence, is_bitstring=is_bitstring) 1 ctx 1l