Skip to content

Commit b840c91

Browse files
committed
refactor(tap): extract BaseTap abstract class for tap implementations
1 parent a194673 commit b840c91

1 file changed

Lines changed: 176 additions & 143 deletions

File tree

src/trueentropy/tap.py

Lines changed: 176 additions & 143 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from __future__ import annotations
2424

2525
import struct
26+
from abc import ABC, abstractmethod
2627
from collections.abc import MutableSequence, Sequence
2728
from typing import Any, TypeVar
2829

@@ -32,163 +33,36 @@
3233
T = TypeVar("T")
3334

3435

35-
class EntropyTap:
36+
class BaseTap(ABC):
3637
"""
37-
Extracts and formats random values from an entropy pool.
38-
39-
The tap is responsible for converting raw entropy bytes into
40-
various formats like floats, integers, and booleans. It ensures
41-
that all generated values are uniformly distributed.
38+
Abstract base class for entropy taps.
4239
43-
Example:
44-
>>> pool = EntropyPool()
45-
>>> tap = EntropyTap(pool)
46-
>>> value = tap.random()
47-
>>> print(f"Random: {value}")
40+
Provides common utility methods and higher-level distributions
41+
dependent on the core random primitives.
4842
"""
4943

50-
# -------------------------------------------------------------------------
51-
# Initialization
52-
# -------------------------------------------------------------------------
53-
54-
def __init__(self, pool: EntropyPool) -> None:
55-
"""
56-
Initialize the tap with an entropy pool.
57-
58-
Args:
59-
pool: The EntropyPool instance to extract entropy from
60-
"""
61-
self._pool = pool
62-
63-
# -------------------------------------------------------------------------
64-
# Random Value Generation
65-
# -------------------------------------------------------------------------
66-
44+
@abstractmethod
6745
def random(self) -> float:
68-
"""
69-
Generate a random float in the range [0.0, 1.0).
70-
71-
Uses 64 bits of entropy to generate a uniformly distributed
72-
floating-point number. The result is always less than 1.0.
73-
74-
Returns:
75-
A float value where 0.0 <= value < 1.0
76-
77-
How it works:
78-
1. Extract 8 bytes (64 bits) from the pool
79-
2. Interpret as unsigned 64-bit integer
80-
3. Divide by 2^64 to get value in [0, 1)
81-
"""
82-
# Extract 8 bytes of entropy
83-
raw_bytes = self._pool.extract(8)
84-
85-
# Unpack as unsigned 64-bit integer (big-endian)
86-
# We use big-endian for consistency across platforms
87-
value = struct.unpack("!Q", raw_bytes)[0]
88-
89-
# Convert to float in range [0.0, 1.0)
90-
# 2^64 = 18446744073709551616
91-
return value / 18446744073709551616.0
46+
"""Generate a random float in the range [0.0, 1.0)."""
47+
pass
9248

49+
@abstractmethod
9350
def randint(self, a: int, b: int) -> int:
94-
"""
95-
Generate a random integer N such that a <= N <= b.
96-
97-
Uses rejection sampling to ensure uniform distribution.
98-
This avoids modulo bias that would occur with simple modulo.
99-
100-
Args:
101-
a: Lower bound (inclusive)
102-
b: Upper bound (inclusive)
103-
104-
Returns:
105-
Random integer in [a, b]
106-
107-
Raises:
108-
ValueError: If a > b
109-
110-
How it works:
111-
1. Calculate the range size (b - a + 1)
112-
2. Find the smallest number of bits needed to represent range
113-
3. Generate random bits and check if value < range
114-
4. If not, reject and try again (rejection sampling)
115-
5. This ensures perfectly uniform distribution
116-
"""
117-
if a > b:
118-
raise ValueError(f"randint: a ({a}) must be <= b ({b})")
51+
"""Generate a random integer N such that a <= N <= b."""
52+
pass
11953

120-
if a == b:
121-
return a # Only one possible value
122-
123-
# Calculate range size
124-
range_size = b - a + 1
125-
126-
# Find number of bits needed to represent range_size
127-
# We need ceil(log2(range_size)) bits
128-
bits_needed = (range_size - 1).bit_length()
129-
bytes_needed = (bits_needed + 7) // 8 # Round up to bytes
130-
131-
# Mask to extract only the bits we need
132-
# e.g., for range_size=100, bits_needed=7, mask=0x7F (127)
133-
mask = (1 << bits_needed) - 1
134-
135-
# Rejection sampling loop
136-
# We keep generating random values until we get one in range
137-
# Expected number of iterations is < 2 on average
138-
while True:
139-
# Extract random bytes
140-
raw_bytes = self._pool.extract(bytes_needed)
141-
142-
# Pad to 8 bytes for unpacking (big-endian)
143-
padded = raw_bytes.rjust(8, b"\x00")
144-
145-
# Unpack as unsigned 64-bit integer
146-
value = struct.unpack("!Q", padded)[0]
147-
148-
# Apply mask to get only needed bits
149-
value = value & mask
150-
151-
# Check if value is in valid range
152-
if value < range_size:
153-
return a + value
54+
@abstractmethod
55+
def randbytes(self, n: int) -> bytes:
56+
"""Generate n random bytes."""
57+
pass
15458

15559
def randbool(self) -> bool:
15660
"""
15761
Generate a random boolean (True or False).
15862
159-
Each value has exactly 50% probability - a fair coin flip.
160-
161-
Returns:
162-
True or False with equal probability
163-
164-
How it works:
165-
1. Extract 1 byte from the pool
166-
2. Check the least significant bit
167-
3. Return True if bit is 1, False if 0
63+
Default implementation uses random(). Can be overridden for efficiency.
16864
"""
169-
# Extract 1 byte of entropy
170-
raw_byte = self._pool.extract(1)
171-
172-
# Check least significant bit
173-
return (raw_byte[0] & 1) == 1
174-
175-
def randbytes(self, n: int) -> bytes:
176-
"""
177-
Generate n random bytes.
178-
179-
Args:
180-
n: Number of bytes to generate (must be positive)
181-
182-
Returns:
183-
A bytes object of length n
184-
185-
Raises:
186-
ValueError: If n is not positive
187-
"""
188-
if n <= 0:
189-
raise ValueError(f"randbytes: n ({n}) must be positive")
190-
191-
return self._pool.extract(n)
65+
return self.random() < 0.5
19266

19367
def choice(self, seq: Sequence[T]) -> T:
19468
"""
@@ -586,6 +460,165 @@ def random_password(
586460
# Generate password by choosing random characters
587461
return "".join(self.choice(chars) for _ in range(length))
588462

463+
464+
class EntropyTap(BaseTap):
465+
"""
466+
Extracts and formats random values from an entropy pool.
467+
468+
The tap is responsible for converting raw entropy bytes into
469+
various formats like floats, integers, and booleans. It ensures
470+
that all generated values are uniformly distributed.
471+
472+
Example:
473+
>>> pool = EntropyPool()
474+
>>> tap = EntropyTap(pool)
475+
>>> value = tap.random()
476+
>>> print(f"Random: {value}")
477+
"""
478+
479+
# -------------------------------------------------------------------------
480+
# Initialization
481+
# -------------------------------------------------------------------------
482+
483+
def __init__(self, pool: EntropyPool) -> None:
484+
"""
485+
Initialize the tap with an entropy pool.
486+
487+
Args:
488+
pool: The EntropyPool instance to extract entropy from
489+
"""
490+
self._pool = pool
491+
492+
# -------------------------------------------------------------------------
493+
# Random Value Generation
494+
# -------------------------------------------------------------------------
495+
496+
def random(self) -> float:
497+
"""
498+
Generate a random float in the range [0.0, 1.0).
499+
500+
Uses 64 bits of entropy to generate a uniformly distributed
501+
floating-point number. The result is always less than 1.0.
502+
503+
Returns:
504+
A float value where 0.0 <= value < 1.0
505+
506+
How it works:
507+
1. Extract 8 bytes (64 bits) from the pool
508+
2. Interpret as unsigned 64-bit integer
509+
3. Divide by 2^64 to get value in [0, 1)
510+
"""
511+
# Extract 8 bytes of entropy
512+
raw_bytes = self._pool.extract(8)
513+
514+
# Unpack as unsigned 64-bit integer (big-endian)
515+
# We use big-endian for consistency across platforms
516+
value = struct.unpack("!Q", raw_bytes)[0]
517+
518+
# Convert to float in range [0.0, 1.0)
519+
# 2^64 = 18446744073709551616
520+
return value / 18446744073709551616.0
521+
522+
def randint(self, a: int, b: int) -> int:
523+
"""
524+
Generate a random integer N such that a <= N <= b.
525+
526+
Uses rejection sampling to ensure uniform distribution.
527+
This avoids modulo bias that would occur with simple modulo.
528+
529+
Args:
530+
a: Lower bound (inclusive)
531+
b: Upper bound (inclusive)
532+
533+
Returns:
534+
Random integer in [a, b]
535+
536+
Raises:
537+
ValueError: If a > b
538+
539+
How it works:
540+
1. Calculate the range size (b - a + 1)
541+
2. Find the smallest number of bits needed to represent range
542+
3. Generate random bits and check if value < range
543+
4. If not, reject and try again (rejection sampling)
544+
5. This ensures perfectly uniform distribution
545+
"""
546+
if a > b:
547+
raise ValueError(f"randint: a ({a}) must be <= b ({b})")
548+
549+
if a == b:
550+
return a # Only one possible value
551+
552+
# Calculate range size
553+
range_size = b - a + 1
554+
555+
# Find number of bits needed to represent range_size
556+
# We need ceil(log2(range_size)) bits
557+
bits_needed = (range_size - 1).bit_length()
558+
bytes_needed = (bits_needed + 7) // 8 # Round up to bytes
559+
560+
# Mask to extract only the bits we need
561+
# e.g., for range_size=100, bits_needed=7, mask=0x7F (127)
562+
mask = (1 << bits_needed) - 1
563+
564+
# Rejection sampling loop
565+
# We keep generating random values until we get one in range
566+
# Expected number of iterations is < 2 on average
567+
while True:
568+
# Extract random bytes
569+
raw_bytes = self._pool.extract(bytes_needed)
570+
571+
# Pad to 8 bytes for unpacking (big-endian)
572+
padded = raw_bytes.rjust(8, b"\x00")
573+
574+
# Unpack as unsigned 64-bit integer
575+
value = struct.unpack("!Q", padded)[0]
576+
577+
# Apply mask to get only needed bits
578+
value = value & mask
579+
580+
# Check if value is in valid range
581+
if value < range_size:
582+
return a + value
583+
584+
def randbool(self) -> bool:
585+
"""
586+
Generate a random boolean (True or False).
587+
588+
Each value has exactly 50% probability - a fair coin flip.
589+
590+
Returns:
591+
True or False with equal probability
592+
593+
How it works:
594+
1. Extract 1 byte from the pool
595+
2. Check the least significant bit
596+
3. Return True if bit is 1, False if 0
597+
"""
598+
# Extract 1 byte of entropy
599+
raw_byte = self._pool.extract(1)
600+
601+
# Check least significant bit
602+
return (raw_byte[0] & 1) == 1
603+
604+
def randbytes(self, n: int) -> bytes:
605+
"""
606+
Generate n random bytes.
607+
608+
Args:
609+
n: Number of bytes to generate (must be positive)
610+
611+
Returns:
612+
A bytes object of length n
613+
614+
Raises:
615+
ValueError: If n is not positive
616+
"""
617+
if n <= 0:
618+
raise ValueError(f"randbytes: n ({n}) must be positive")
619+
620+
return self._pool.extract(n)
621+
589622
# -------------------------------------------------------------------------
590623
# String Representation
591624
# -------------------------------------------------------------------------

0 commit comments

Comments
 (0)