voxkit.config

This module provides access to configurable info about the application.

Configurations

  • App config: application metadata and provenance (app_config).
  • Pipeline config: pipeline steps and UI wiring (pipeline_config).
  • Startup config: launch-time constants and defaults (startup_config).
  • Logging config: rotating file logger setup (logging_config).

Only app_config and pipeline_config are dynamic post-build; they are loaded from YAML files under the active profile and can change without a rebuild. startup_config and logging_config are baked in at build time.

Profile System

Configs are organized into profiles under config/profiles//. The active profile is specified in config/profile.txt.

Use get_active_profile() to check which profile is active. Use get_profile_config_path() to get the path to the active profile's directory.

 1"""This module provides access to configurable info about the application.
 2
 3Configurations
 4--------------
 5- App config: application metadata and provenance (``app_config``).
 6- Pipeline config: pipeline steps and UI wiring (``pipeline_config``).
 7- Startup config: launch-time constants and defaults (``startup_config``).
 8- Logging config: rotating file logger setup (``logging_config``).
 9
10Only ``app_config`` and ``pipeline_config`` are dynamic post-build; they are
11loaded from YAML files under the active profile and can change without a
12rebuild. ``startup_config`` and ``logging_config`` are baked in at build time.
13
14Profile System
15--------------
16Configs are organized into profiles under config/profiles/<name>/.
17The active profile is specified in config/profile.txt.
18
19Use get_active_profile() to check which profile is active.
20Use get_profile_config_path() to get the path to the active profile's directory.
21"""
22
23from voxkit.config.app_config import (
24    AppConfig,
25    get_active_profile,
26    get_app_config,
27    get_config_root,
28    get_profile_config_path,
29    resolve_config_file,
30)
31from voxkit.config.constants import DEFAULT_HELP_URL
32from voxkit.config.logging_config import (
33    LOG_FILE,
34    reset_logging,
35    setup_logging,
36)
37from voxkit.config.pipeline_config import (
38    PipelineConfig,
39    PipelineStep,
40    UIConfig,
41    get_pipeline_config,
42)
43from voxkit.config.startup_config import (
44    STARTUP_SCRIPT,
45    AppName,
46    Defaults,
47    Dimensions,
48    Mode,
49)
50
51__all__ = [
52    # Profile system
53    "get_active_profile",
54    "get_config_root",
55    "get_profile_config_path",
56    "resolve_config_file",
57    # App config
58    "AppConfig",
59    "get_app_config",
60    # Pipeline config
61    "PipelineConfig",
62    "PipelineStep",
63    "UIConfig",
64    "get_pipeline_config",
65    # Startup config
66    "DEFAULT_HELP_URL",
67    "AppName",
68    "Dimensions",
69    "Defaults",
70    "Mode",
71    "STARTUP_SCRIPT",
72    # Logging config
73    "LOG_FILE",
74    "setup_logging",
75    "reset_logging",
76]
def get_active_profile() -> str:
40def get_active_profile() -> str:
41    """Get the active configuration profile name.
42
43    Reads from config/profile.txt. Falls back to 'default' if file doesn't exist.
44
45    Returns:
46        Profile name string
47    """
48    config_root = get_config_root()
49    profile_file = config_root / "profile.txt"
50
51    if profile_file.exists():
52        return profile_file.read_text().strip()
53    return "default"

Get the active configuration profile name.

Reads from config/profile.txt. Falls back to 'default' if file doesn't exist.

Returns: Profile name string

def get_config_root() -> pathlib.Path:
21def get_config_root() -> Path:
22    """Get the path to the config root directory.
23
24    Returns the correct config path whether running from source or as a
25    PyInstaller bundle.
26
27    Returns:
28        Path to the config directory
29    """
30    # Check if running as PyInstaller bundle
31    if getattr(sys, "_MEIPASS", None):
32        # Running as bundled executable
33        # mypy: ignore attr-defined on _MEIPASS - it's dynamically added by PyInstaller
34        return Path(getattr(sys, "_MEIPASS")) / "config"
35    else:
36        # Running from source - get project root (3 levels up from this file)
37        return Path(__file__).parent.parent.parent.parent / "config"

Get the path to the config root directory.

Returns the correct config path whether running from source or as a PyInstaller bundle.

Returns: Path to the config directory

def get_profile_config_path() -> pathlib.Path:
56def get_profile_config_path() -> Path:
57    """Get the path to the active profile's config directory.
58
59    Returns:
60        Path to the active profile directory (e.g., config/profiles/default/)
61    """
62    config_root = get_config_root()
63    profile = get_active_profile()
64    profile_path = config_root / "profiles" / profile
65
66    # Fall back to legacy location if profile doesn't exist
67    if not profile_path.exists():
68        return config_root
69
70    return profile_path

Get the path to the active profile's config directory.

Returns: Path to the active profile directory (e.g., config/profiles/default/)

def resolve_config_file(filename: str) -> pathlib.Path:
 73def resolve_config_file(filename: str) -> Path:
 74    """Resolve a config file path with fallback to default profile.
 75
 76    Looks for the file in the active profile first, then falls back to
 77    the default profile if not found. This allows profiles to only
 78    override the files they need to change.
 79
 80    Args:
 81        filename: The config file name (e.g., "app_info.yaml")
 82
 83    Returns:
 84        Path to the config file (from active profile or default)
 85
 86    Raises:
 87        FileNotFoundError: If file not found in active or default profile
 88    """
 89    config_root = get_config_root()
 90    profile = get_active_profile()
 91
 92    # Try active profile first
 93    active_path = config_root / "profiles" / profile / filename
 94    if active_path.exists():
 95        return active_path
 96
 97    # Fall back to default profile
 98    default_path = config_root / "profiles" / "default" / filename
 99    if default_path.exists():
100        return default_path
101
102    # Throw error if not found in either location
103    raise FileNotFoundError(
104        f"Config file '{filename}' not found in profile '{profile}' or default profile"
105    )

Resolve a config file path with fallback to default profile.

Looks for the file in the active profile first, then falls back to the default profile if not found. This allows profiles to only override the files they need to change.

Args: filename: The config file name (e.g., "app_info.yaml")

Returns: Path to the config file (from active profile or default)

Raises: FileNotFoundError: If file not found in active or default profile

@dataclass
class AppConfig:
108@dataclass
109class AppConfig:
110    """Application configuration data class."""
111
112    app_name: str
113    version: str
114    description: str
115    introduction: str
116    help_url: str | None = None
117    feedback_email: str | None = None
118    release_date: Optional[str] = None
119    release_notes: Optional[str] = None
120    log_max_bytes: int = 5 * 1024 * 1024
121    log_backup_count: int = 3
122
123    @classmethod
124    def from_yaml(cls, config_path: Path) -> "AppConfig":
125        """Load application configuration from YAML file.
126
127        Args:
128            config_path: Path to the app_info.yaml file
129
130        Returns:
131            AppConfig instance with loaded configuration
132
133        Raises:
134            FileNotFoundError: If config file doesn't exist
135            yaml.YAMLError: If YAML parsing fails
136        """
137        if not config_path.exists():
138            raise FileNotFoundError(f"App config file not found: {config_path}")
139
140        with open(config_path, "r", encoding="utf-8") as f:
141            data = yaml.safe_load(f) or {}
142        contact_info = data.get("contact_info", {})
143
144        # Version is sourced from config/VERSION (single source of truth),
145        # not from per-profile YAML.
146        version_file = get_config_root() / "VERSION"
147        version = version_file.read_text(encoding="utf-8").strip()
148
149        return cls(
150            app_name=data.get("app_name", "VoxKit"),
151            version=version,
152            description=data.get("description", ""),
153            introduction=data.get("introduction", ""),
154            help_url=data.get("help_url", DEFAULT_HELP_URL),
155            feedback_email=data.get("feedback_email") or contact_info.get("email_support"),
156            release_date=data.get("release_date"),
157            release_notes=data.get("release_notes"),
158            log_max_bytes=int(data.get("log_max_bytes", 5 * 1024 * 1024)),
159            log_backup_count=int(data.get("log_backup_count", 3)),
160        )
161
162    @classmethod
163    def load_default(cls) -> "AppConfig":
164        """Load the application configuration from the active profile.
165
166        Loads from config/profiles/<active_profile>/app_info.yaml.
167        Falls back to default profile or config root if not found.
168
169        Returns:
170            AppConfig instance
171        """
172        config_path = resolve_config_file("app_info.yaml")
173        return cls.from_yaml(config_path)

Application configuration data class.

AppConfig( app_name: str, version: str, description: str, introduction: str, help_url: str | None = None, feedback_email: str | None = None, release_date: Optional[str] = None, release_notes: Optional[str] = None, log_max_bytes: int = 5242880, log_backup_count: int = 3)
app_name: str
version: str
description: str
introduction: str
help_url: str | None = None
feedback_email: str | None = None
release_date: Optional[str] = None
release_notes: Optional[str] = None
log_max_bytes: int = 5242880
log_backup_count: int = 3
@classmethod
def from_yaml(cls, config_path: pathlib.Path) -> AppConfig:
123    @classmethod
124    def from_yaml(cls, config_path: Path) -> "AppConfig":
125        """Load application configuration from YAML file.
126
127        Args:
128            config_path: Path to the app_info.yaml file
129
130        Returns:
131            AppConfig instance with loaded configuration
132
133        Raises:
134            FileNotFoundError: If config file doesn't exist
135            yaml.YAMLError: If YAML parsing fails
136        """
137        if not config_path.exists():
138            raise FileNotFoundError(f"App config file not found: {config_path}")
139
140        with open(config_path, "r", encoding="utf-8") as f:
141            data = yaml.safe_load(f) or {}
142        contact_info = data.get("contact_info", {})
143
144        # Version is sourced from config/VERSION (single source of truth),
145        # not from per-profile YAML.
146        version_file = get_config_root() / "VERSION"
147        version = version_file.read_text(encoding="utf-8").strip()
148
149        return cls(
150            app_name=data.get("app_name", "VoxKit"),
151            version=version,
152            description=data.get("description", ""),
153            introduction=data.get("introduction", ""),
154            help_url=data.get("help_url", DEFAULT_HELP_URL),
155            feedback_email=data.get("feedback_email") or contact_info.get("email_support"),
156            release_date=data.get("release_date"),
157            release_notes=data.get("release_notes"),
158            log_max_bytes=int(data.get("log_max_bytes", 5 * 1024 * 1024)),
159            log_backup_count=int(data.get("log_backup_count", 3)),
160        )

Load application configuration from YAML file.

Args: config_path: Path to the app_info.yaml file

Returns: AppConfig instance with loaded configuration

Raises: FileNotFoundError: If config file doesn't exist yaml.YAMLError: If YAML parsing fails

@classmethod
def load_default(cls) -> AppConfig:
162    @classmethod
163    def load_default(cls) -> "AppConfig":
164        """Load the application configuration from the active profile.
165
166        Loads from config/profiles/<active_profile>/app_info.yaml.
167        Falls back to default profile or config root if not found.
168
169        Returns:
170            AppConfig instance
171        """
172        config_path = resolve_config_file("app_info.yaml")
173        return cls.from_yaml(config_path)

Load the application configuration from the active profile.

Loads from config/profiles//app_info.yaml. Falls back to default profile or config root if not found.

Returns: AppConfig instance

def get_app_config() -> AppConfig:
176def get_app_config() -> AppConfig:
177    """Get the application configuration.
178
179    Convenience function to load the default configuration.
180
181    Returns:
182        AppConfig instance
183    """
184    return AppConfig.load_default()

Get the application configuration.

Convenience function to load the default configuration.

Returns: AppConfig instance

@dataclass
class PipelineConfig:
 90@dataclass
 91class PipelineConfig:
 92    """Pipeline configuration data class."""
 93
 94    steps: List[PipelineStep]
 95    ui_config: UIConfig
 96
 97    @property
 98    def enabled_steps(self) -> List[PipelineStep]:
 99        """Get only the enabled pipeline steps.
100
101        Returns:
102            List of enabled PipelineStep instances
103        """
104        return [step for step in self.steps if step.enabled]
105
106    @classmethod
107    def from_yaml(cls, config_path: Path) -> "PipelineConfig":
108        """Load pipeline configuration from YAML file.
109
110        Args:
111            config_path: Path to the pipeline_definitions.yaml file
112
113        Returns:
114            PipelineConfig instance with loaded configuration
115
116        Raises:
117            FileNotFoundError: If config file doesn't exist
118            yaml.YAMLError: If YAML parsing fails
119        """
120        if not config_path.exists():
121            raise FileNotFoundError(f"Pipeline config file not found: {config_path}")
122
123        with open(config_path, "r", encoding="utf-8") as f:
124            data = yaml.safe_load(f)
125
126        # Parse pipeline steps
127        steps = []
128        for step_data in data.get("pipeline", []):
129            steps.append(PipelineStep.from_dict(step_data))
130
131        # Parse UI config
132        ui_config = UIConfig.from_dict(data.get("ui"))
133
134        return cls(steps=steps, ui_config=ui_config)
135
136    @classmethod
137    def load_default(cls) -> "PipelineConfig":
138        """Load the pipeline configuration from the active profile.
139
140        Loads from config/profiles/<active_profile>/pipeline_definitions.yaml.
141        Falls back to default profile or config root if not found.
142
143        Returns:
144            PipelineConfig instance
145        """
146        config_path = resolve_config_file("pipeline_definitions.yaml")
147        return cls.from_yaml(config_path)

Pipeline configuration data class.

PipelineConfig( steps: List[PipelineStep], ui_config: UIConfig)
steps: List[PipelineStep]
ui_config: UIConfig
enabled_steps: List[PipelineStep]
 97    @property
 98    def enabled_steps(self) -> List[PipelineStep]:
 99        """Get only the enabled pipeline steps.
100
101        Returns:
102            List of enabled PipelineStep instances
103        """
104        return [step for step in self.steps if step.enabled]

Get only the enabled pipeline steps.

Returns: List of enabled PipelineStep instances

@classmethod
def from_yaml( cls, config_path: pathlib.Path) -> PipelineConfig:
106    @classmethod
107    def from_yaml(cls, config_path: Path) -> "PipelineConfig":
108        """Load pipeline configuration from YAML file.
109
110        Args:
111            config_path: Path to the pipeline_definitions.yaml file
112
113        Returns:
114            PipelineConfig instance with loaded configuration
115
116        Raises:
117            FileNotFoundError: If config file doesn't exist
118            yaml.YAMLError: If YAML parsing fails
119        """
120        if not config_path.exists():
121            raise FileNotFoundError(f"Pipeline config file not found: {config_path}")
122
123        with open(config_path, "r", encoding="utf-8") as f:
124            data = yaml.safe_load(f)
125
126        # Parse pipeline steps
127        steps = []
128        for step_data in data.get("pipeline", []):
129            steps.append(PipelineStep.from_dict(step_data))
130
131        # Parse UI config
132        ui_config = UIConfig.from_dict(data.get("ui"))
133
134        return cls(steps=steps, ui_config=ui_config)

Load pipeline configuration from YAML file.

Args: config_path: Path to the pipeline_definitions.yaml file

Returns: PipelineConfig instance with loaded configuration

Raises: FileNotFoundError: If config file doesn't exist yaml.YAMLError: If YAML parsing fails

@classmethod
def load_default(cls) -> PipelineConfig:
136    @classmethod
137    def load_default(cls) -> "PipelineConfig":
138        """Load the pipeline configuration from the active profile.
139
140        Loads from config/profiles/<active_profile>/pipeline_definitions.yaml.
141        Falls back to default profile or config root if not found.
142
143        Returns:
144            PipelineConfig instance
145        """
146        config_path = resolve_config_file("pipeline_definitions.yaml")
147        return cls.from_yaml(config_path)

Load the pipeline configuration from the active profile.

Loads from config/profiles//pipeline_definitions.yaml. Falls back to default profile or config root if not found.

Returns: PipelineConfig instance

@dataclass
class PipelineStep:
20@dataclass
21class PipelineStep:
22    """Represents a single step in the pipeline."""
23
24    id: str
25    label: str
26    stacker_class: str
27    enabled: bool = True
28    collapsible_sections: Optional[Dict[str, str]] = None  # {header: content} pairs
29    markdown_content: Optional[str] = None  # For MarkdownStacker
30
31    @classmethod
32    def from_dict(cls, data: Dict[str, Any]) -> "PipelineStep":
33        """Create a PipelineStep from a dictionary.
34
35        Args:
36            data: Dictionary containing step configuration
37
38        Returns:
39            PipelineStep instance
40        """
41        # Handle both old format (description/info) and new format (collapsible_sections)
42        collapsible_sections = data.get("collapsible_sections")
43
44        # Backwards compatibility: convert old description/info fields to collapsible_sections
45        if collapsible_sections is None and ("description" in data or "info" in data):
46            collapsible_sections = {}
47            if "description" in data and data["description"]:
48                collapsible_sections["Step Instructions"] = data["description"]
49            if "info" in data and data["info"]:
50                collapsible_sections["Additional Info"] = data["info"]
51
52        return cls(
53            id=data["id"],
54            label=data["label"],
55            stacker_class=data["stacker_class"],
56            enabled=data.get("enabled", True),
57            collapsible_sections=collapsible_sections,
58            markdown_content=data.get("markdown_content"),
59        )

Represents a single step in the pipeline.

PipelineStep( id: str, label: str, stacker_class: str, enabled: bool = True, collapsible_sections: Optional[Dict[str, str]] = None, markdown_content: Optional[str] = None)
id: str
label: str
stacker_class: str
enabled: bool = True
collapsible_sections: Optional[Dict[str, str]] = None
markdown_content: Optional[str] = None
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> PipelineStep:
31    @classmethod
32    def from_dict(cls, data: Dict[str, Any]) -> "PipelineStep":
33        """Create a PipelineStep from a dictionary.
34
35        Args:
36            data: Dictionary containing step configuration
37
38        Returns:
39            PipelineStep instance
40        """
41        # Handle both old format (description/info) and new format (collapsible_sections)
42        collapsible_sections = data.get("collapsible_sections")
43
44        # Backwards compatibility: convert old description/info fields to collapsible_sections
45        if collapsible_sections is None and ("description" in data or "info" in data):
46            collapsible_sections = {}
47            if "description" in data and data["description"]:
48                collapsible_sections["Step Instructions"] = data["description"]
49            if "info" in data and data["info"]:
50                collapsible_sections["Additional Info"] = data["info"]
51
52        return cls(
53            id=data["id"],
54            label=data["label"],
55            stacker_class=data["stacker_class"],
56            enabled=data.get("enabled", True),
57            collapsible_sections=collapsible_sections,
58            markdown_content=data.get("markdown_content"),
59        )

Create a PipelineStep from a dictionary.

Args: data: Dictionary containing step configuration

Returns: PipelineStep instance

@dataclass
class UIConfig:
62@dataclass
63class UIConfig:
64    """UI-related configuration."""
65
66    menu_max_width: int = 500
67    animation_duration: int = 300
68    content_spacing: int = 20
69
70    @classmethod
71    def from_dict(cls, data: Optional[Dict[str, Any]]) -> "UIConfig":
72        """Create a UIConfig from a dictionary.
73
74        Args:
75            data: Dictionary containing UI configuration or None
76
77        Returns:
78            UIConfig instance with values from dict or defaults
79        """
80        if data is None:
81            return cls()
82
83        return cls(
84            menu_max_width=data.get("menu_max_width", 500),
85            animation_duration=data.get("animation_duration", 300),
86            content_spacing=data.get("content_spacing", 20),
87        )

UI-related configuration.

UIConfig( menu_max_width: int = 500, animation_duration: int = 300, content_spacing: int = 20)
menu_max_width: int = 500
animation_duration: int = 300
content_spacing: int = 20
@classmethod
def from_dict( cls, data: Optional[Dict[str, Any]]) -> UIConfig:
70    @classmethod
71    def from_dict(cls, data: Optional[Dict[str, Any]]) -> "UIConfig":
72        """Create a UIConfig from a dictionary.
73
74        Args:
75            data: Dictionary containing UI configuration or None
76
77        Returns:
78            UIConfig instance with values from dict or defaults
79        """
80        if data is None:
81            return cls()
82
83        return cls(
84            menu_max_width=data.get("menu_max_width", 500),
85            animation_duration=data.get("animation_duration", 300),
86            content_spacing=data.get("content_spacing", 20),
87        )

Create a UIConfig from a dictionary.

Args: data: Dictionary containing UI configuration or None

Returns: UIConfig instance with values from dict or defaults

def get_pipeline_config() -> PipelineConfig:
150def get_pipeline_config() -> PipelineConfig:
151    """Get the pipeline configuration.
152
153    Convenience function to load the default configuration.
154
155    Returns:
156        PipelineConfig instance
157    """
158    return PipelineConfig.load_default()

Get the pipeline configuration.

Convenience function to load the default configuration.

Returns: PipelineConfig instance

DEFAULT_HELP_URL = 'https://voxkit-web.vercel.app/help'
AppName = 'VoxKit'
Dimensions = {'min_width': 200, 'min_height': 800, 'max_width': 500, 'max_height': None}
Defaults = {'mode': 'W2TGENGINE', 'output_path': '/path/to/output', 'audio_path': '/path/to/audio', 'textgrid_path': '/path/to/textgrids', 'num_epochs': 10}
Mode = typing.Literal['MFAENGINE', 'W2TGENGINE']
def STARTUP_SCRIPT():
 29def startup_routine():
 30    """Example startup routine to be executed on first launch."""
 31    log.info("Initializing VoxKit...")
 32    time.sleep(1)  # Simulate initialization
 33
 34    storage_root = get_storage_root()
 35    log.info("Storage root: %s", storage_root)
 36
 37    log.info("Creating required directories...")
 38    (storage_root / "computed-likelihoods").mkdir(parents=True, exist_ok=True)
 39    (storage_root / "custom-likelihoods").mkdir(parents=True, exist_ok=True)
 40    time.sleep(1)  # Simulate directory setup
 41
 42    # Download MFA models
 43    log.info("Downloading MFA models...")
 44    mfa_models = [
 45        "acoustic-english_us_arpa-v3.0.0/english_us_arpa.zip",
 46        "acoustic-spanish_mfa-v3.3.0/spanish_mfa.zip",
 47    ]
 48    mfa_models_path = storage_root / "MFAENGINE" / MODELS_ROOT
 49    mfa_models_path.mkdir(parents=True, exist_ok=True)
 50    for model in mfa_models:
 51        success, metadata = models.create_model(
 52            "MFAENGINE", model.split("/")[1].replace(".zip", "")
 53        )
 54        if not success:
 55            log.error("Failed to create model metadata for %s: %s", model, metadata)
 56            continue
 57        assert not isinstance(metadata, str)
 58        model_dest = metadata.get("model_path")
 59        if not model_dest:
 60            log.error("Model path not found in metadata for %s", model)
 61            continue
 62
 63        # Remove last part of path and relace with .zip
 64        output_file = model_dest.parent / model.split("/")[1]
 65
 66        try:
 67            download_acoustic_model(model, str(output_file))
 68            # Update metadata to reflect downloaded file
 69            success, message = models.update_model_metadata(
 70                "MFAENGINE", metadata["id"], {"model_path": str(output_file)}
 71            )
 72
 73            if not success:
 74                log.error("Failed to update model metadata for %s: %s", model, message)
 75
 76            log.info("MFA model %s downloaded to %s", model, output_file)
 77        except Exception:
 78            log.exception("Failed to download MFA model %s", model)
 79
 80    # Download W2TG model from HuggingFace
 81    log.info("Downloading W2TG model from HuggingFace...")
 82    # Create folder for W2TG model
 83    w2tg_path = storage_root / "W2TGENGINE" / MODELS_ROOT
 84    w2tg_path.mkdir(parents=True, exist_ok=True)
 85    success, metadata = models.create_model("W2TGENGINE", "default")
 86    if not success:
 87        log.error("Failed to create W2TG model metadata: %s", metadata)
 88        return
 89    assert not isinstance(metadata, str)
 90    model_dest = metadata.get("model_path")
 91    if not model_dest:
 92        log.error("Model path not found in W2TG metadata")
 93        return
 94    result = download_and_copy_huggingface_model(
 95        model_path="pkadambi/Wav2TextGrid",
 96        destination=str(model_dest),
 97    )
 98    if result:
 99        log.info("W2TG model downloaded to %s", result)
100    else:
101        log.error("Failed to download W2TG model")
102
103    try:
104        import nltk
105
106        nltk.download("averaged_perceptron_tagger_eng")
107
108    except Exception:
109        log.exception("Failed to download NLTK resources")
110
111    log.info("Initialization complete")

Example startup routine to be executed on first launch.

LOG_FILE = PosixPath('/Users/runner/.voxkit/logs/voxkit.log')
def setup_logging( max_bytes: int = 5242880, backup_count: int = 3, log_file: Optional[pathlib.Path] = None) -> logging.handlers.RotatingFileHandler:
27def setup_logging(
28    max_bytes: int = DEFAULT_MAX_BYTES,
29    backup_count: int = DEFAULT_BACKUP_COUNT,
30    log_file: Optional[Path] = None,
31) -> RotatingFileHandler:
32    """Configure the root logger with a rotating file handler.
33
34    Idempotent — calling more than once has no effect beyond the first call.
35
36    Args:
37        max_bytes: Max size in bytes before rotation.
38        backup_count: Number of rotated files to retain.
39        log_file: Override the log file path (primarily for tests).
40
41    Returns:
42        The installed RotatingFileHandler.
43    """
44    global _configured
45
46    target = log_file or LOG_FILE
47    target.parent.mkdir(parents=True, exist_ok=True)
48
49    root = logging.getLogger()
50    debug_enabled = os.environ.get("VOXKIT_DEBUG") == "1"
51    root.setLevel(logging.DEBUG if debug_enabled else logging.INFO)
52
53    if _configured:
54        for handler in root.handlers:
55            if isinstance(handler, RotatingFileHandler):
56                return handler
57
58    handler = RotatingFileHandler(
59        target,
60        maxBytes=max_bytes,
61        backupCount=backup_count,
62        encoding="utf-8",
63    )
64    handler.setFormatter(logging.Formatter(_LOG_FORMAT, datefmt=_DATE_FORMAT))
65    handler.setLevel(logging.DEBUG if debug_enabled else logging.INFO)
66    root.addHandler(handler)
67
68    _configured = True
69    logging.getLogger(__name__).info(
70        "Logging initialized (debug=%s, file=%s)", debug_enabled, target
71    )
72    return handler

Configure the root logger with a rotating file handler.

Idempotent — calling more than once has no effect beyond the first call.

Args: max_bytes: Max size in bytes before rotation. backup_count: Number of rotated files to retain. log_file: Override the log file path (primarily for tests).

Returns: The installed RotatingFileHandler.

def reset_logging() -> None:
75def reset_logging() -> None:
76    """Remove handlers installed by :func:`setup_logging`. Test helper."""
77    global _configured
78    root = logging.getLogger()
79    for handler in list(root.handlers):
80        root.removeHandler(handler)
81        handler.close()
82    _configured = False

Remove handlers installed by setup_logging(). Test helper.