Skip to content

Commit 05ef977

Browse files
committed
feat(tap): add new generation methods
- triangular(low, high, mode): triangular distribution - exponential(lambd): exponential distribution - weighted_choice(seq, weights): weighted random selection - random_uuid(): UUID v4 generator - random_token(length, encoding): hex/base64 tokens - random_password(length, charset, flags): secure passwords
1 parent cba2b46 commit 05ef977

1 file changed

Lines changed: 257 additions & 0 deletions

File tree

src/trueentropy/tap.py

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,263 @@ def gauss(self, mu: float = 0.0, sigma: float = 1.0) -> float:
339339
# Scale and shift to desired mean and standard deviation
340340
return mu + sigma * z0
341341

342+
def triangular(
343+
self, low: float = 0.0, high: float = 1.0, mode: float | None = None
344+
) -> float:
345+
"""
346+
Generate a random float from the triangular distribution.
347+
348+
The triangular distribution is a continuous probability distribution
349+
with a lower limit, upper limit, and mode (peak).
350+
351+
Args:
352+
low: Lower limit (default: 0.0)
353+
high: Upper limit (default: 1.0)
354+
mode: Peak of the distribution. If None, defaults to midpoint.
355+
356+
Returns:
357+
Random float from the triangular distribution
358+
359+
Raises:
360+
ValueError: If low > high or mode is outside [low, high]
361+
"""
362+
import math
363+
364+
if low > high:
365+
raise ValueError(f"triangular: low ({low}) must be <= high ({high})")
366+
367+
if mode is None:
368+
mode = (low + high) / 2.0
369+
370+
if not (low <= mode <= high):
371+
raise ValueError(
372+
f"triangular: mode ({mode}) must be in [{low}, {high}]"
373+
)
374+
375+
# Handle degenerate case
376+
if low == high:
377+
return low
378+
379+
u = self.random()
380+
c = (mode - low) / (high - low)
381+
382+
if u < c:
383+
return low + math.sqrt(u * (high - low) * (mode - low))
384+
else:
385+
return high - math.sqrt((1 - u) * (high - low) * (high - mode))
386+
387+
def exponential(self, lambd: float = 1.0) -> float:
388+
"""
389+
Generate a random float from the exponential distribution.
390+
391+
The exponential distribution describes time between events in a
392+
Poisson process. It's commonly used to model waiting times.
393+
394+
Args:
395+
lambd: Rate parameter (1/mean). Must be positive.
396+
Note: Named 'lambd' to avoid conflict with Python keyword.
397+
398+
Returns:
399+
Random float from Exp(lambda)
400+
401+
Raises:
402+
ValueError: If lambd <= 0
403+
"""
404+
import math
405+
406+
if lambd <= 0:
407+
raise ValueError(f"exponential: lambd ({lambd}) must be positive")
408+
409+
# Inverse transform sampling
410+
# If U ~ Uniform(0,1), then -ln(U)/lambda ~ Exp(lambda)
411+
u = self.random()
412+
while u == 0: # Avoid log(0)
413+
u = self.random()
414+
415+
return -math.log(u) / lambd
416+
417+
def weighted_choice(self, seq: Sequence[T], weights: Sequence[float]) -> T:
418+
"""
419+
Return a random element from a sequence with weighted probabilities.
420+
421+
Elements with higher weights are more likely to be selected.
422+
423+
Args:
424+
seq: A non-empty sequence
425+
weights: Weights for each element (must be same length as seq)
426+
427+
Returns:
428+
A randomly selected element
429+
430+
Raises:
431+
ValueError: If seq and weights have different lengths
432+
ValueError: If any weight is negative
433+
ValueError: If all weights are zero
434+
IndexError: If the sequence is empty
435+
436+
Example:
437+
>>> tap.weighted_choice(['rare', 'common', 'common'], [1, 10, 10])
438+
'common' # Most likely
439+
"""
440+
if not seq:
441+
raise IndexError("Cannot choose from an empty sequence")
442+
443+
if len(seq) != len(weights):
444+
raise ValueError(
445+
f"weighted_choice: seq length ({len(seq)}) != "
446+
f"weights length ({len(weights)})"
447+
)
448+
449+
if any(w < 0 for w in weights):
450+
raise ValueError("weighted_choice: weights must be non-negative")
451+
452+
total = sum(weights)
453+
if total == 0:
454+
raise ValueError("weighted_choice: at least one weight must be > 0")
455+
456+
# Generate a random threshold in [0, total)
457+
threshold = self.random() * total
458+
459+
# Find which "bucket" the threshold falls into
460+
cumulative = 0.0
461+
for i, weight in enumerate(weights):
462+
cumulative += weight
463+
if threshold < cumulative:
464+
return seq[i]
465+
466+
# Fallback (shouldn't happen, but handles floating point edge cases)
467+
return seq[-1]
468+
469+
def random_uuid(self) -> str:
470+
"""
471+
Generate a random UUID (version 4).
472+
473+
UUID v4 uses random numbers for all significant bits,
474+
making it ideal for unique identifiers.
475+
476+
Returns:
477+
A UUID string in the format 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'
478+
where x is a random hex digit and y is one of 8, 9, a, or b.
479+
480+
Example:
481+
>>> tap.random_uuid()
482+
'f47ac10b-58cc-4372-a567-0e02b2c3d479'
483+
"""
484+
# Generate 16 random bytes
485+
raw = self.randbytes(16)
486+
raw_list = list(raw)
487+
488+
# Set version to 4 (0100 in high nibble of byte 6)
489+
raw_list[6] = (raw_list[6] & 0x0F) | 0x40
490+
491+
# Set variant to RFC 4122 (10xx in high bits of byte 8)
492+
raw_list[8] = (raw_list[8] & 0x3F) | 0x80
493+
494+
# Convert to hex string
495+
hex_str = bytes(raw_list).hex()
496+
497+
# Format as UUID: 8-4-4-4-12
498+
return (
499+
f"{hex_str[0:8]}-{hex_str[8:12]}-{hex_str[12:16]}-"
500+
f"{hex_str[16:20]}-{hex_str[20:32]}"
501+
)
502+
503+
def random_token(self, length: int = 32, encoding: str = "hex") -> str:
504+
"""
505+
Generate a random token string.
506+
507+
Useful for API keys, session tokens, CSRF tokens, etc.
508+
509+
Args:
510+
length: Number of random bytes to use (default: 32)
511+
encoding: Output encoding - 'hex' or 'base64' (default: 'hex')
512+
513+
Returns:
514+
A random token string
515+
516+
Raises:
517+
ValueError: If length <= 0 or encoding is invalid
518+
519+
Example:
520+
>>> tap.random_token(16, 'hex')
521+
'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6'
522+
>>> tap.random_token(16, 'base64')
523+
'obLNGb5hFbJQ9Q4='
524+
"""
525+
if length <= 0:
526+
raise ValueError(f"random_token: length ({length}) must be positive")
527+
528+
raw = self.randbytes(length)
529+
530+
if encoding == "hex":
531+
return raw.hex()
532+
elif encoding == "base64":
533+
import base64
534+
return base64.urlsafe_b64encode(raw).decode("ascii")
535+
else:
536+
raise ValueError(
537+
f"random_token: encoding must be 'hex' or 'base64', "
538+
f"got '{encoding}'"
539+
)
540+
541+
def random_password(
542+
self,
543+
length: int = 16,
544+
charset: str | None = None,
545+
include_uppercase: bool = True,
546+
include_lowercase: bool = True,
547+
include_digits: bool = True,
548+
include_symbols: bool = True
549+
) -> str:
550+
"""
551+
Generate a secure random password.
552+
553+
Args:
554+
length: Password length (default: 16)
555+
charset: Custom character set. If provided, overrides include_* flags.
556+
include_uppercase: Include A-Z (default: True)
557+
include_lowercase: Include a-z (default: True)
558+
include_digits: Include 0-9 (default: True)
559+
include_symbols: Include !@#$%^&*()_+-= (default: True)
560+
561+
Returns:
562+
A random password string
563+
564+
Raises:
565+
ValueError: If length <= 0 or no characters available
566+
567+
Example:
568+
>>> tap.random_password(12)
569+
'Kx9#mP2$nL7@'
570+
>>> tap.random_password(8, charset='abc123')
571+
'2ab1c3a1'
572+
"""
573+
if length <= 0:
574+
raise ValueError(f"random_password: length ({length}) must be positive")
575+
576+
if charset is not None:
577+
if not charset:
578+
raise ValueError("random_password: charset cannot be empty")
579+
chars = charset
580+
else:
581+
chars = ""
582+
if include_uppercase:
583+
chars += "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
584+
if include_lowercase:
585+
chars += "abcdefghijklmnopqrstuvwxyz"
586+
if include_digits:
587+
chars += "0123456789"
588+
if include_symbols:
589+
chars += "!@#$%^&*()_+-="
590+
591+
if not chars:
592+
raise ValueError(
593+
"random_password: at least one character type must be included"
594+
)
595+
596+
# Generate password by choosing random characters
597+
return "".join(self.choice(chars) for _ in range(length))
598+
342599
# -------------------------------------------------------------------------
343600
# String Representation
344601
# -------------------------------------------------------------------------

0 commit comments

Comments
 (0)