Source code for typytypy.core

"""Core functionality for TypyTypy.

This module defines the 'PrintingPress' class, which implements the library's main
character-by-character text rendering engine. It provides configurable timing control,
word-level profiles, and case-sensitivity options, serving as the beating heart of
TypyTypy.

Classes:
    PrintingPress: Main character-by-character text printer class.

Examples:
    Basic usage:
        >>> import typytypy
        ...
        >>> printer = typytypy.PrintingPress()
        ...
        >>> printer.type_out("Hello, World!")

    With timing profiles:
        >>> printer.create_profile("highlight", 0.1, 0.05, "IMPORTANT")
        ...
        >>> printer.type_out("This is IMPORTANT information!")

Metadata:
    Author: KitscherEins,
    Version: 0.1.0,
    License: Apache-2.0
"""

import random
import sys
import time
from typing import TypedDict

from .exceptions import (
    ConfigurationError,
    InvalidTimingError,
    ProfileError,
)

# Constants for common validation ranges
TIMING_MIN_VALUE = 0.0
TIMING_MAX_VALUE = 10.0


class _ProfileData(TypedDict):
    """Type definition for timing profile data structure.

    Attributes:
        words (set[str]): Set of words that trigger custom timing.
        base_delay (float): Minimum delay per character, in seconds, for words in a
                            profile.
        delay_range (float): Random delay range, in seconds, for words in a profile.
    """

    words: set[str]
    base_delay: float
    delay_range: float


class _CaseSensitivityAnalysis(TypedDict):
    """Type definition for case-sensitivity switch analysis results.

    Attributes:
        current_mode (str): The current case-sensitivity mode in use.
        target_mode (str): The desired case-sensitivity mode to switch to.
        switch_safe (bool): Whether switching to the target mode is safe.
        collision_count (int): Total number of detected name collisions.
        collisions (dict[str, list[tuple[str, str]]]): Mapping of entities with name
                                                       collisions, where each list
                                                       contains tuples of conflicting
                                                       names.
        affected_profiles (set[str]): Set of profiles impacted by the collisions.
        recommendations (list[str]): List of recommended actions to resolve or avoid
                                     collisions.
    """

    current_mode: str
    target_mode: str
    switch_safe: bool
    collision_count: int
    collisions: dict[str, list[tuple[str, str]]]
    affected_profiles: set[str]
    recommendations: list[str]


[docs] class PrintingPress: """Character-by-character text printer engine with custom timing profile support. This class prints text to stdout character-by-character, simulating real-time typing. It supports configurable timing parameters and custom timing profiles that override default timings for specific words or phrases. Attributes: base_delay (float): Default minimum delay per character, in seconds. delay_range (float): Default random delay range, in seconds. profiles (dict[str, _ProfileData]): Mapping of profile names to profile data. word_to_profile (dict[str, str]): Mapping of normalized words to their assigned profile name. normalized_to_original (dict[str, str]): Mapping of normalized words to their original form for case-sensitivity restoration. case_sensitive_setting (bool): True if profile word matching is case-sensitive. Examples: Basic initialization: >>> printer = PrintingPress() ... >>> printer.type_out("Hello, World!") Custom timing: >>> printer = PrintingPress(base_delay=0.1, delay_range=0.2) ... >>> printer.type_out("Slow typing...") Timing profiles: >>> printer.create_profile("fast", 0.01, 0.005, "URGENT") ... >>> printer.type_out("This is URGENT!") """ # Default timing constants for different use cases DEFAULT_BASE_DELAY = 0.015 DEFAULT_DELAY_RANGE = 0.042 EMPHASIS_BASE_DELAY = 0.0314 EMPHASIS_DELAY_RANGE = 0.0985 def __init__( self, base_delay: float | None = None, delay_range: float | None = None ) -> None: """Initialize a 'PrintingPress' instance with timing configuration (optional). Args: base_delay (float | None): Minimum delay per character, in seconds. Defaults to 'DEFAULT_BASE_DELAY' if None. delay_range (float | None): Random delay range, in seconds. Defaults to 'DEFAULT_DELAY_RANGE' if None. Raises: InvalidTimingError: If invalid timing values are provided. Examples: Default timing: >>> printer = PrintingPress() Custom timing: >>> printer = PrintingPress(base_delay=0.05, delay_range=0.02) """ # Validate and set timing parameters if base_delay is not None: self._validate_timing(base_delay, "base_delay") if delay_range is not None: self._validate_timing(delay_range, "delay_range") self.base_delay = base_delay or self.DEFAULT_BASE_DELAY self.delay_range = delay_range or self.DEFAULT_DELAY_RANGE # Timing profiles management with precise typing self.profiles: dict[str, _ProfileData] = {} # Word-to-profile mapping for efficient lookup self.word_to_profile: dict[str, str] = {} # Reverse mapping for efficient auto-transfer self.normalized_to_original: dict[str, str] = {} # Configuration settings (default: sensitive) self.case_sensitive_setting = True def _validate_timing(self, value: float, param_name: str) -> None: """Validate a timing parameter value against configured limits. This method ensures that timing values fall within the globally defined minimum and maximum limits. For INTERNAL USE. Args: value (float): Timing value to validate, in seconds. param_name (str): Name of the parameter being validated (used in error messages). Raises: InvalidTimingError: If the value is less than 'TIMING_MIN_VALUE' or greater than 'TIMING_MAX_VALUE'. """ if value < TIMING_MIN_VALUE: raise InvalidTimingError( f"{param_name} must be non-negative", parameter_name=param_name, parameter_value=value, valid_range=(TIMING_MIN_VALUE, TIMING_MAX_VALUE), ) if value > TIMING_MAX_VALUE: raise InvalidTimingError( f"{param_name} exceeds reasonable maximum ({TIMING_MAX_VALUE}s)", parameter_name=param_name, parameter_value=value, valid_range=(TIMING_MIN_VALUE, TIMING_MAX_VALUE), ) def _normalize_word(self, word: str) -> str: """Normalize a word for internal profile lookups. Converts the word to lowercase if the printer's case-sensitivity setting is set to False; otherwise leaves it unchanged. This ensures consistent key matching in timing profile mappings. For INTERNAL USE. Args: word (str): The word to normalize. Returns: str: The normalized word (lowercase if case-insensitive, unchanged if case-sensitive). """ return word if self.case_sensitive_setting else word.lower()
[docs] def create_profile( self, profile_name: str, base_delay: float, delay_range: float, words: str | list[str] | None = None, ) -> bool: """Create a new custom timing profile with the specified parameters. A custom timing profile defines a base_delay and delay_range that override the printer's defaults for a specific set of words. If initial words are provided, they are added to the profile immediately after creation. Args: profile_name (str): Unique name for the custom timing profile. If a profile with the same name already exists, creation fails. base_delay (float): Minimum delay per character, in seconds, for words in this profile. delay_range (float): Random delay range, in seconds, for words in this profile. words (str | list[str] | None): Initial word(s) to add to the profile (optional). Can be a single word string, list of words, or None. Returns: bool: True if the profile was created successfully, False if a profile with the given name already exists. Raises: InvalidTimingError: If invalid timing values are provided. Examples: Create empty profile: >>> printer.create_profile("emotions", 0.08, 0.12) True Create profile with initial word: >>> printer.create_profile("calm", 0.1, 0.015, "holistic") True Create profile with multiple initial words: >>> printer.create_profile("fast", 0.01, 0.005, ["CLEAR", "UNDERSTAND"]) True """ # Validate timing parameters self._validate_timing(base_delay, "base_delay") self._validate_timing(delay_range, "delay_range") if profile_name in self.profiles: return False self.profiles[profile_name] = { "base_delay": base_delay, "delay_range": delay_range, "words": set(), } # Add initial words if provided if words is not None: self.add_words_to_profile(profile_name, words) return True
def _modify_profile( self, profile_name: str, words: str | list[str], action: str ) -> int: """Modify the word list of a custom timing profile. Depending on the specified action, this method adds words to or removes words from the given profile. When adding, it validates against case-sensitive or case-insensitive duplicates based on the active setting, and automatically transfers a word from its existing profile if needed. When removing, it cleans up associated lookup mappings. For INTERNAL USE. Args: profile_name (str): Name of the custom timing profile to modify. words (str | list[str]): Word(s) to process (single string or list). action (str): Either "add" or "remove". Returns: int: The number of words successfully processed. Raises: ProfileError: If the profile does not exist, if the action is invalid, or if adding words would violate duplicate rules under the current case-sensitivity configuration. """ if profile_name not in self.profiles: raise ProfileError( f"Profile '{profile_name}' does not exist", profile_name=profile_name, operation=action, ) if action not in ["add", "remove"]: raise ProfileError( "Action must be 'add' or 'remove'", profile_name=profile_name, operation=action, ) # Convert to list but keep original forms if isinstance(words, str): word_list = [words] else: word_list = words # Check for duplicates within input if action == "add": duplicates = self._detect_input_duplicates(word_list) if duplicates: duplicate_type = ( "case-sensitive" if self.case_sensitive_setting else "case-insensitive" ) raise ProfileError( f"Input contains {duplicate_type} duplicates: {duplicates}", profile_name=profile_name, operation=action, ) processed_count = 0 if action == "add": for original_word in word_list: normalized_word = self._normalize_word(original_word) # Track if auto-transfer will happen (for validation) auto_transfer_occurs = normalized_word in self.word_to_profile # Auto-transfer: remove from previous profile if exists if auto_transfer_occurs: previous_profile = self.word_to_profile[normalized_word] old_original_word = self.normalized_to_original[normalized_word] # Remove from previous profile self.profiles[previous_profile]["words"].discard(old_original_word) # Add to new profile with both mappings self.profiles[profile_name]["words"].add( original_word # Store original ) self.word_to_profile[normalized_word] = ( profile_name # Normalized → profile ) self.normalized_to_original[normalized_word] = ( original_word # Normalized → original ) # Validate auto-transfer if it occurred if auto_transfer_occurs: validation_success, validation_message = ( self._validate_auto_transfer(original_word, profile_name) ) if not validation_success: raise ProfileError( ( f"Auto-transfer validation failed for word " f"'{original_word}': {validation_message}" ), profile_name=profile_name, operation=action, ) processed_count += 1 else: # remove for original_word in word_list: normalized_word = self._normalize_word(original_word) if original_word in self.profiles[profile_name]["words"]: # Remove from profile and clean up mappings self.profiles[profile_name]["words"].remove(original_word) if normalized_word in self.word_to_profile: del self.word_to_profile[normalized_word] if normalized_word in self.normalized_to_original: del self.normalized_to_original[normalized_word] processed_count += 1 return processed_count def _detect_input_duplicates(self, word_list: list[str]) -> list[str]: """Detect duplicate words in a given list according to case-sensitivity setting. This method checks for duplicates in the input list using the instance's 'case_sensitive_setting'. In case-sensitive mode, duplicates must match exactly. In case-insensitive mode, duplicates are detected by their lowercase normalized form. For INTERNAL USE. Args: word_list (list[str]): List of original words strings to check for duplicates. Returns: list[str]: A list of duplicates found. In case-sensitive mode, the duplicates are returned in their original form. In case-insensitive mode, duplicates are returned in normalized (lowercase) form. """ seen_normalized = set() seen_original = set() duplicates = [] for original_word in word_list: normalized_word = self._normalize_word(original_word) if self.case_sensitive_setting: # Case-sensitive: check for exact original duplicates if original_word in seen_original: if original_word not in duplicates: duplicates.append(original_word) else: seen_original.add(original_word) else: # Case-insensitive: check for normalized duplicates if normalized_word in seen_normalized: if normalized_word not in duplicates: duplicates.append(normalized_word) else: seen_normalized.add(normalized_word) return duplicates def _validate_auto_transfer( self, original_word: str, target_profile: str ) -> tuple[bool, str]: """Verify that an auto-transfer operation for a word completed successfully. This method, used after adding a word that may have been moved automatically from a different profile, checks that: 1. The original word exists in the target profile. 2. The word's normalized form (case-lowered if case-insensitive) correctly maps to the target profile in 'word_to_profile'. 3. The original word does not remain in any other profile's word set. For INTERNAL USE. Args: original_word (str): The word in its original form that was added. target_profile (str): Name of the profile the word should now be in. Returns: tuple[bool, str]: A '(success, error_message)' tuple where: - 'success' is True if all the validation checks passed, False otherwise - 'error_message' is a description of the first validation failure encountered, or a success confirmation string if valid. """ normalized_word = self._normalize_word(original_word) # Check if word is properly mapped to target profile if normalized_word not in self.word_to_profile: return ( False, ( f"Normalized word '{normalized_word}' not found " "in word_to_profile mapping" ), ) if self.word_to_profile[normalized_word] != target_profile: actual_profile = self.word_to_profile[normalized_word] return ( False, f"Word mapped to '{actual_profile}' instead of '{target_profile}'", ) # Check if original word exists in target profile if original_word not in self.profiles[target_profile]["words"]: return ( False, ( f"Original word '{original_word}' not found in " f"target profile '{target_profile}'" ), ) # Check that word doesn't exist in any other profile for profile_name, profile_data in self.profiles.items(): if ( profile_name != target_profile and original_word in profile_data["words"] ): return ( False, ( f"Word '{original_word}' still exists in old profile " f"'{profile_name}'" ), ) return True, "Auto-transfer validation successful"
[docs] def add_words_to_profile(self, profile_name: str, words: str | list[str]) -> int: """Add word(s) to an existing custom timing profile. Words are validated according to the active case-sensitivity setting (default=True), and duplicates (based on those rules) are not allowed. If a word already exists in another profile, it will be automatically transferred to the target profile. Args: profile_name (str): Name of the existing custom timing profile to add words to. words (str | list[str]): Word(s) to add (single string or list). Matching is case-sensitive or case-insensitive based on the instance's case-sensitivity setting. Returns: int: The number of words successfully added to the profile. Raises: ProfileError: If the profile does not exist or if adding the words would violate duplicate rules under the current case-sensitivity setting. Examples: Add a single word: >>> count = printer.add_words_to_profile("emotions", "happy") >>> print(count) # 1 Add multiple words: >>> count = printer.add_words_to_profile("emotions", ["happy", "sad"]) >>> print(count) # 2 """ return self._modify_profile(profile_name, words, "add")
[docs] def remove_words_from_profile( self, profile_name: str, words: str | list[str] ) -> int: """Remove word(s) from an existing custom timing profile. Any specified words that are found in the target profile are removed, and their entries are cleaned from the lookup mappings. Words that are not present in the profile are ignored. Args: profile_name (str): Name of the existing custom timing profile to remove words from. words (str | list[str]): Word(s) to remove (single string or list). Matching is case-sensitive or case-insensitive based on the instance's case-sensitivity setting. Returns: int: The number of words successfully removed from the profile. Raises: ProfileError: If the profile does not exist. Examples: Remove a single word: >>> count = printer.remove_words_from_profile("emotions", "guilty") >>> print(count) # 1 Remove multiple words: >>> count = printer.remove_words_from_profile("emotions", ... ["angry", "guilty"]) >>> print(count) # 2 if both existed """ return self._modify_profile(profile_name, words, "remove")
[docs] def delete_profile(self, profile_name: str) -> bool: """Delete an entire custom timing profile and remove all associated mappings. Permanently removes the specified profile from the printer instance. It also cleans up all associated words from the 'word_to_profile' and 'normalized_to_original' lookup mappings using the current case-sensitivity rules. Args: profile_name (str): Name of the custom timing profile to delete. Returns: bool: True if the profile existed and was deleted successfully, False if no such profile exists. Examples: >>> success = printer.delete_profile("profile_omega") >>> print(success) # True if profile existed, False otherwise """ if profile_name not in self.profiles: return False # Clean up both mappings using normalized keys original_words_to_remove = list(self.profiles[profile_name]["words"]) for original_word in original_words_to_remove: normalized_word = self._normalize_word(original_word) # Remove from both mappings using normalized key if normalized_word in self.word_to_profile: del self.word_to_profile[normalized_word] if normalized_word in self.normalized_to_original: del self.normalized_to_original[normalized_word] # Delete the profile del self.profiles[profile_name] return True
[docs] def list_profiles(self) -> list[str]: """Return the names of all custom timing profiles in this printer instance. Profiles are listed in the order they were added to the instance (insertion order). Result reflects profiles stored in memory for the current instance. Returns: list[str]: A list of custom timing profile names, in insertion order. Examples: >>> profiles = printer.list_profiles() >>> print(profiles) # ['emotions', 'technical', 'emphasis'] """ return list(self.profiles.keys())
[docs] def get_profile_info( self, profile_name: str ) -> dict[str, float | int | list[str]] | None: """Return detailed information about a custom timing profile. If the profile exists, returns its configuration and current words. If no matching profile is found, returns None. Args: profile_name (str): Name of the custom timing profile. Returns: dict[str, float | int | list[str]] | None: A dictionary containing the following keys, or None if the profile does not exist: - base_delay (float): Minimum delay per character, in seconds, for words in this profile. - delay_range (float): Random delay range, in seconds, for words in this profile. - word_count (int): Number of words currently assigned to the profile. - words (list[str]): All words in the profile, in their original form. Examples: Retrieve and inspect a profile: >>> info = printer.get_profile_info("emotions") >>> print(info) # Full profile record >>> print(info['word_count']) # Number of words in profile """ if profile_name not in self.profiles: return None profile_data = self.profiles[profile_name] # Create a new dict return { "base_delay": profile_data["base_delay"], "delay_range": profile_data["delay_range"], "word_count": len(profile_data["words"]), "words": list(profile_data["words"]), # Convert set to list for readability }
[docs] def update_profile_timing( self, profile_name: str, base_delay: float, delay_range: float ) -> bool: """Update the timing parameters for an existing custom timing profile. Replaces the profile's current 'base_delay' and 'delay_range' values with the specified ones, after validating that both are within the allowed limits. Args: profile_name (str): Name of the custom timing profile to update. base_delay (float): New minimum delay per character, in seconds. delay_range (float): New random delay range, in seconds. Returns: bool: True if the profile exists and was updated successfully, False if no profile with the given name exists. Raises: InvalidTimingError: If invalid timing values are provided. Examples: Update an existing profile's timings: >>> success = printer.update_profile_timing("emotions", 0.1, 0.05) >>> print(success) # True if profile exists """ if profile_name not in self.profiles: return False # Validate timing parameters self._validate_timing(base_delay, "base_delay") self._validate_timing(delay_range, "delay_range") self.profiles[profile_name]["base_delay"] = base_delay self.profiles[profile_name]["delay_range"] = delay_range return True
[docs] def set_profile_case_sensitivity(self, sensitive: bool) -> None: """Configure the case-sensitivity setting for profile word matching. Enables switching the instance's word matching mode between case-sensitive and case-insensitive. Before applying a change, it analyzes all existing profiles to detect whether the new mode would cause conflicting normalized word mappings (e.g., "Apple" and "apple" would collide in case-insensitive mode). If a switch is deemed safe, all internal lookup mappings are rebuilt to match the new case-sensitivity rules. If the target mode is the same as the current mode, no changes are made. Args: sensitive (bool): True to enable case-sensitive matching, False to enable case-insensitive matching. Raises: ConfigurationError: If 'sensitive' is not a boolean, or if switching to the new mode would cause collisions in normalized word mappings. Examples: Safe switch to case-insensitive mode: >>> printer.set_profile_case_sensitivity(False) # No conflicts Unsafe switch with conflicts: >>> printer.set_profile_case_sensitivity(False) Traceback (most recent call last): ... ConfigurationError: Cannot switch to case-insensitive mode: 1 word collision(s) detected... """ if not isinstance(sensitive, bool): raise ConfigurationError( "Case-sensitivity setting must be a boolean value", setting_name="case_sensitive", setting_value=sensitive, expected_type=bool, ) if self.case_sensitive_setting == sensitive: # No change needed return # Analyze the proposed switch analysis = self._analyze_case_sensitivity_switch(sensitive) if not analysis["switch_safe"]: # Build detailed error message collision_details = [] for normalized_word, word_profile_pairs in analysis["collisions"].items(): pairs_str = ", ".join( [f"'{word}' (in {profile})" for word, profile in word_profile_pairs] ) collision_details.append(f"'{normalized_word}' ← {pairs_str}") error_message = ( "Cannot switch to case-insensitive mode: " f"{analysis['collision_count']} " f"word collision(s) detected. The following words would create " f"conflicting mappings: {'; '.join(collision_details)}." f"\nPlease resolve these conflicts by removing or renaming words " f"before switching." ) raise ConfigurationError( error_message, setting_name="case_sensitive", setting_value=sensitive, expected_type=bool, ) # Safe to switch - perform the switch by rebuilding mappings correctly self.case_sensitive_setting = sensitive self._rebuild_mappings_after_sensitivity_change()
def _analyze_case_sensitivity_switch( self, target_sensitivity: bool ) -> _CaseSensitivityAnalysis: """Analyze the impact of changing the case-sensitivity mode. This method simulates switching the instance's case-sensitivity setting to the specified target mode without actually applying it. It checks for word collisions that would occur in the new mode. If switching from case-sensitive to case-insensitive, this method groups words by their lowercase form to detect collisions (e.g., "Apple" and "apple" would conflict). If collisions are found, the report marks the switch as unsafe and lists affected words and profiles, for taking action. For INTERNAL USE. Args: target_sensitivity (bool): The proposed case-sensitivity mode. True for case-sensitive, False for case-insensitive. Returns: _CaseSensitivityAnalysis: TypedDict structured analysis results including collision details and recommendations. """ current_sensitivity = self.case_sensitive_setting analysis: _CaseSensitivityAnalysis = { "current_mode": ( "case-sensitive" if current_sensitivity else "case-insensitive" ), "target_mode": ( "case-sensitive" if target_sensitivity else "case-insensitive" ), "switch_safe": True, "collision_count": 0, "collisions": {}, "affected_profiles": set(), "recommendations": [], } if current_sensitivity and not target_sensitivity: # Switching from case-sensitive to case-insensitive - check for collisions collisions = self._detect_case_sensitivity_collisions() if collisions: analysis["switch_safe"] = False analysis["collision_count"] = len(collisions) analysis["collisions"] = collisions # Collect affected profiles for word_profile_pairs in collisions.values(): for _, profile_name in word_profile_pairs: analysis["affected_profiles"].add(profile_name) analysis["recommendations"] = [ "Remove or rename conflicting words before switching to " "case-insensitive mode", f"Found {len(collisions)} normalized words that would create " "conflicts", "Affected profiles: " f"{', '.join(sorted(analysis['affected_profiles']))}", ] elif not current_sensitivity and target_sensitivity: # Switching from case-insensitive to case-sensitive - should be safe analysis["recommendations"] = [ "Switch should be safe - no conflicts expected " "when going to case-sensitive mode" ] else: # No actual switch needed analysis["recommendations"] = [ f"Already in {analysis['current_mode']} mode - no switch needed" ] return analysis def _detect_case_sensitivity_collisions(self) -> dict[str, list[tuple[str, str]]]: """Identify word collisions that would occur in case-insensitive mode. This method scans all profiles to detect words that would map to the same normalized lowercase form if the printer were switched from case-sensitive to case-insensitive mode. If the instance is already in case-insensitive mode, or if no such conflicts exist, an empty dictionary is returned. For INTERNAL USE. Returns: dict[str, list[tuple[str, str]]]: A mapping of each conflicting normalized lowercase word to the list of (original_word, profile_name) tuples that would collide in case-insensitive mode. Empty if no collisions. """ if not self.case_sensitive_setting: # Already case-insensitive, no collisions possible return {} # Group all original words by their case-insensitive normalized form normalized_groups: dict[str, list[tuple[str, str]]] = {} for profile_name, profile_data in self.profiles.items(): for original_word in profile_data["words"]: # Get what the normalized form would be in case-insensitive mode case_insensitive_normalized = original_word.lower() if case_insensitive_normalized not in normalized_groups: normalized_groups[case_insensitive_normalized] = [] normalized_groups[case_insensitive_normalized].append( (original_word, profile_name) ) # Filter to only return groups with collisions (more than one entry) collisions = { normalized: word_profile_pairs for normalized, word_profile_pairs in normalized_groups.items() if len(word_profile_pairs) > 1 } return collisions def _rebuild_mappings_after_sensitivity_change(self) -> None: """Rebuild internal word lookup mappings after a case-sensitivity change. This method clears and repopulates the 'word_to_profile' and 'normalized_to_original' dictionaries for all existing profiles to match the new case-sensitivity setting. The original words within each profile's word list set are preserved. This method is only called after a successful case-sensitivity switch validation, so collisions are not possible during the rebuild. The operation is idempotent and does not alter any profile word sets. For INTERNAL USE. Returns: None. """ # Clear lookup mappings but preserve original words in profiles self.word_to_profile.clear() self.normalized_to_original.clear() # Rebuild mappings with new case-sensitivity for profile_name, profile_data in self.profiles.items(): for original_word in profile_data["words"]: normalized_word = self._normalize_word(original_word) # These should not conflict - already validated before switching self.word_to_profile[normalized_word] = profile_name self.normalized_to_original[normalized_word] = original_word def _get_word_timing( self, word: str, fallback_base_delay: float, fallback_delay_range: float ) -> tuple[float, float]: """Retrieve the timing parameters for a given word, checking timing profiles. This method determines the 'base_delay' and 'delay_range' values to use when printing the specified word. The lookup process is: 1. Normalize the word according to the current case-sensitivity setting. 2. If the normalized word exists in a timing profile, return that profile's configured timing values. 3. If the word is not in any profile, return the provided fallback values (which are typically either override values passed to the calling method or the instance's default timings). For INTERNAL USE. Args: word (str): The word to retrieve timing for. fallback_base_delay (float | None): Delay, in seconds, to use if the word is not in a profile. Should already be resolved to either an override or the instance default by the caller. fallback_delay_range (float | None): Delay range, in seconds, to use if the word is not in a profile. Should already be resolved to either an override or the instance default by the caller. Returns: tuple[float, float]: A '(base_delay, delay_range)' pair, in seconds, to apply when printing the word. """ normalized_word = self._normalize_word(word) if normalized_word in self.word_to_profile: # Word is in a profile - use profile timing profile_name = self.word_to_profile[normalized_word] profile_info = self.profiles[profile_name] return (profile_info["base_delay"], profile_info["delay_range"]) # Word not in profile - use instance fallback timing base_delay = fallback_base_delay delay_range = fallback_delay_range return (base_delay, delay_range)
[docs] def type_out( self, text: str, base_delay: float | None = None, delay_range: float | None = None, ) -> None: """Print text to stdout one character at a time, simulating real-time typing. Renders the given text in real time, inserting calculated delays between printed characters. Words in custom timing profiles use their configured timing, while other words and whitespace use the provided or the instance's default timing parameters. Stdout is flushed after printing each character for immediate display. All original formatting (spacing, punctuation, and line breaks) is preserved. Args: text (str): The text to print. base_delay (float | None): Override (optional) for the instance minimum delay per character, in seconds, for non-profile words. delay_range (float | None): Override (optional) for the instance random delay range, in seconds, for non-profile words. Returns: None. Raises: InvalidTimingError: If invalid timing values are provided. Examples: Basic usage: >>> printer.type_out("Hello, World!") With per-call timing overrides: >>> printer.type_out("Fast message", base_delay=0.01, delay_range=0.005) With timing profiles: >>> printer.create_profile("emphasis", 0.1, 0.05, "IMPORTANT") ... >>> printer.type_out("This is IMPORTANT information!") """ # Validate override parameters if provided if base_delay is not None: self._validate_timing(base_delay, "base_delay") if delay_range is not None: self._validate_timing(delay_range, "delay_range") # Fallback timing for non-profile words and whitespace fallback_base_delay = base_delay if base_delay is not None else self.base_delay fallback_delay_range = ( delay_range if delay_range is not None else self.delay_range ) current_word = "" for char in text: if char.isspace(): # Process accumulated word if any if current_word: word_base_delay, word_delay_range = self._get_word_timing( current_word, fallback_base_delay, fallback_delay_range ) # Print the accumulated word per its timing for word_char in current_word: sys.stdout.write(word_char) sys.stdout.flush() time.sleep( word_base_delay + random.uniform(0, word_delay_range) ) current_word = "" # Reset word accumulator # Print whitespace character per fallback timing sys.stdout.write(char) sys.stdout.flush() time.sleep( fallback_base_delay + random.uniform(0, fallback_delay_range) ) else: # Accumulate non-whitespace characters into current word current_word += char # Handle final word if text doesn't end with whitespace if current_word: word_base_delay, word_delay_range = self._get_word_timing( current_word, fallback_base_delay, fallback_delay_range ) for word_char in current_word: sys.stdout.write(word_char) sys.stdout.flush() time.sleep(word_base_delay + random.uniform(0, word_delay_range))
[docs] def set_timing(self, base_delay: float, delay_range: float) -> None: """Update the default timing parameters for this printer instance. Permanently changes the instance's base delay and delay range values for all subsequent calls to 'type_out()', unless per-call overrides are provided. Both values are validated against the globally defined minimum and maximum timing limits. Args: base_delay (float): New minimum delay per character, in seconds. delay_range (float): New random delay range, in seconds. Raises: InvalidTimingError: If invalid timing values are provided. Examples: Set slower default typing speed: >>> printer_one.set_timing(0.05, 0.02) Set faster default typing speed: >>> printer_two.set_timing(0.005, 0.01) """ self._validate_timing(base_delay, "base_delay") self._validate_timing(delay_range, "delay_range") self.base_delay = base_delay self.delay_range = delay_range
def _print_default(self, text: str) -> None: """Print text using the class's default timing preset. This method calls 'type_out()' with the predefined 'DEFAULT_BASE_DELAY' and 'DEFAULT_DELAY_RANGE' values for a standard typing speed. It behaves identically to calling 'type_out()' directly with those constants. Args: text (str): The text to print. Examples: >>> printer.print_default("Normal speed text") """ self.type_out(text, self.DEFAULT_BASE_DELAY, self.DEFAULT_DELAY_RANGE) def _print_emphasis(self, text: str) -> None: """Print text using the class's emphasis timing preset. This method calls 'type_out()' with the predefined 'EMPHASIS_BASE_DELAY' and 'EMPHASIS_DELAY_RANGE' values for a slower, dramatic effect typing speed. It behaves identically to calling 'type_out()' directly with those constants. Args: text (str): The text to print. Examples: >>> printer.print_emphasis("This text has dramatic timing...") """ self.type_out(text, self.EMPHASIS_BASE_DELAY, self.EMPHASIS_DELAY_RANGE)
def _main() -> None: """Run a live demonstration of the TypyTypy library. This internal function creates a 'PrintingPress' instance and prints the module's Kitschbotschaft using different timing presets: 1. Default timing preset ('_print_default') 2. Emphasis timing preset ('_print_emphasis') """ # Initialize printer with default settings printer = PrintingPress() # Define demonstration texts text_before = """\nThank you for using KitschCode's TypyTypy. """ quote = """\n „Hear me! For I am such and such a person. Above all, do not mistake me for someone else." — Friedrich Wilhelm Nietzsche """ text_after = """\nBleiben Sie inspiriert. \nIn gratitude, KitscherEins\n """ # Demonstrate different printing modes printer._print_default(text_before) printer._print_emphasis(quote) # Use emphasis timing printer._print_default(text_after) # Run the demo if this file is executed as a script def _init() -> None: """Initialize when executed as main module.""" if __name__ == "__main__": _main() _init()