Skip to content

API Reference

CheapSettings

cheap_settings.CheapSettings

Base class for simple, environment-variable-driven configuration.

Subclass this and define your settings as typed class attributes:

class MySettings(CheapSettings):
    host: str = "localhost"
    port: int = 8080
    debug: bool = False
Environment variables will override the defaults

HOST=example.com PORT=3000 DEBUG=true python myapp.py

Supports all basic Python types plus datetime, date, time, Decimal, UUID, Path, Optional and Union types. Complex types (list, dict) are parsed from JSON strings.

Source code in src/cheap_settings/cheap_settings.py
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
class CheapSettings(metaclass=MetaCheapSettings):
    """Base class for simple, environment-variable-driven configuration.

    Subclass this and define your settings as typed class attributes:

        class MySettings(CheapSettings):
            host: str = "localhost"
            port: int = 8080
            debug: bool = False

    Environment variables will override the defaults:
        HOST=example.com PORT=3000 DEBUG=true python myapp.py

    Supports all basic Python types plus datetime, date, time, Decimal, UUID,
    Path, Optional and Union types. Complex types (list, dict) are parsed from JSON strings.
    """

    def __getattribute__(self, name):
        """Allow instances to access class-level settings."""
        # First try regular instance attribute access
        try:
            return object.__getattribute__(self, name)
        except AttributeError:
            # Fall back to class-level attribute access
            # This allows instances to access settings defined at the class level
            return getattr(type(self), name)

    def __reduce__(self):
        """Enable pickling by returning class and state information."""
        # Try to return the class directly if it can be imported
        try:
            module = importlib.import_module(self.__class__.__module__)
            if hasattr(module, self.__class__.__name__):
                cls = getattr(module, self.__class__.__name__)
                return cls, (), self.__getstate__()
        except (ImportError, AttributeError):
            pass

        # Fallback to reconstruction
        return (
            _reconstruct_settings_instance,
            (self.__class__.__module__, self.__class__.__name__),
            self.__getstate__(),
        )

    def __getstate__(self):
        """Get the state for pickling - returns a dict of all settings."""
        # For CheapSettings, we don't actually have instance state
        # All settings are class-level, so we just return an empty dict
        return {}

    def __setstate__(self, state):
        """Restore state when unpickling."""
        # Nothing to restore for CheapSettings instances
        # All settings are accessed from the class level
        pass

    @classmethod
    def to_static(cls) -> object:
        """Create a static snapshot of current settings as a regular class.
        The returned class is a regular Python class without any dynamic behavior.

        Returns:
             object: a new class with all settings resolved to their current values.

        This is useful for:
        - Performance-critical code where attribute access overhead matters
        - Situations where you want to freeze settings at a point in time
        - Working around edge cases with the dynamic metaclass behavior

        Example:
            >>> class MySettings(CheapSettings):
            ...     host: str = "localhost"
            ...     port: int = 8080
            >>> StaticSettings = MySettings.to_static()
            >>> StaticSettings.host  # Just a regular class attribute
            'localhost'
        """
        # Collect all settings and their current resolved values
        attrs = {}

        # Get all settings from the class (including inherited ones)
        for name in dir(cls):
            # Skip private attributes and methods
            if name.startswith("_"):
                continue

            # Get the attribute value
            try:
                value = getattr(cls, name)
            except AttributeError:
                continue

            # Skip methods and other callables
            if callable(value):
                continue

            # Add the resolved value to our static class
            attrs[name] = value

        # Create a new regular class with the resolved values
        static_class = type(f"Static{cls.__name__}", (), attrs)

        # Copy the module for better repr and debugging
        static_class.__module__ = cls.__module__

        return static_class

    @classmethod
    def from_env(cls) -> object:
        """Create a static snapshot with only values from environment variables.

        Returns a regular Python class containing only the settings that are
        explicitly set in environment variables, ignoring all defaults.

        Returns:
            object: A new class with only environment-sourced settings.

        This is useful for:
        - Debugging which settings are coming from the environment
        - Creating minimal configuration objects
        - Validating environment-only deployments

        Example:
            >>> os.environ['HOST'] = 'example.com'
            >>> EnvOnly = MySettings.from_env()
            >>> EnvOnly.host  # 'example.com'
            >>> hasattr(EnvOnly, 'port')  # False (not in env)
        """
        attrs = {}
        config_instance = object.__getattribute__(cls, "__config_instance")
        annotations = getattr(config_instance, "__annotations__", {})

        # Only include attributes that have environment variables set
        for name, type_hint in annotations.items():
            env_name = name.upper()
            if env_name in os.environ:
                # Use the same logic as __getattribute__ to get the converted value
                try:
                    value = getattr(cls, name)
                    attrs[name] = value
                except (AttributeError, ValueError):
                    # Skip if we can't get or convert the value
                    pass

        # Create a simple class with just the env values
        env_class = type(f"{cls.__name__}FromEnv", (), attrs)
        return env_class

    @classmethod
    def set_raise_on_uninitialized(cls, value: bool = True):
        """Set whether to raise an error when accessing uninitialized settings.

        Args:
            value: If True, accessing uninitialized settings raises AttributeError.
                   If False, accessing uninitialized settings returns None (default).

        Example:
            >>> class MySettings(CheapSettings):
            ...     required_setting: str  # No default
            >>> MySettings.set_raise_on_uninitialized(True)
            >>> MySettings.required_setting  # Raises AttributeError
        """
        config_instance = object.__getattribute__(cls, "__config_instance")
        object.__setattr__(
            config_instance, "_MetaCheapSettings__raise_on_uninitialized_setting", value
        )

    @classmethod
    def set_config_from_command_line(cls, arg_parser=None, args=None):
        """Creates command line arguments (as flags) that correspond to the settings, & parses them, setting the
        config values based on them. Settings overridden by command line arguments take precedence over any
        default variables, & over environment variables. Currently, settings of `dict` & `list` types are ignored,
        & no command line arguments are added for them. It can optionally take an instance of argparse.ArgumentParser
        that can be used to pre-configure your own command line arguments. The optional `args` parameter allows
        passing specific arguments for testing (if None, uses sys.argv). Returns the parsed arguments (an
        instance of argparse.Namespace). Can raise various exceptions."""

        # Create a second config instance specifically for command line arguments. This adds a new class attribute
        # called `__cli_config_instance`, & adds any command line arguments that correspond to the attributes of
        # `__config_instance`. It also copies any type annotations for the copied attributes.
        try:
            config_instance = object.__getattribute__(cls, "__config_instance")
        except AttributeError:
            raise AttributeError("Config instance has not been set.")

        if config_instance is None:
            raise AttributeError("Config instance has not been set.")

        cli_config_instance = MetaCheapSettings.ConfigInstance()
        parsed_args = parse_command_line_arguments(
            config_instance, cli_config_instance, arg_parser, args
        )
        # Use type.__setattr__ to set attribute on the class
        type.__setattr__(cls, "__cli_config_instance", cli_config_instance)

        return parsed_args

__getattribute__(name)

Allow instances to access class-level settings.

Source code in src/cheap_settings/cheap_settings.py
466
467
468
469
470
471
472
473
474
def __getattribute__(self, name):
    """Allow instances to access class-level settings."""
    # First try regular instance attribute access
    try:
        return object.__getattribute__(self, name)
    except AttributeError:
        # Fall back to class-level attribute access
        # This allows instances to access settings defined at the class level
        return getattr(type(self), name)

__getstate__()

Get the state for pickling - returns a dict of all settings.

Source code in src/cheap_settings/cheap_settings.py
494
495
496
497
498
def __getstate__(self):
    """Get the state for pickling - returns a dict of all settings."""
    # For CheapSettings, we don't actually have instance state
    # All settings are class-level, so we just return an empty dict
    return {}

__reduce__()

Enable pickling by returning class and state information.

Source code in src/cheap_settings/cheap_settings.py
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
def __reduce__(self):
    """Enable pickling by returning class and state information."""
    # Try to return the class directly if it can be imported
    try:
        module = importlib.import_module(self.__class__.__module__)
        if hasattr(module, self.__class__.__name__):
            cls = getattr(module, self.__class__.__name__)
            return cls, (), self.__getstate__()
    except (ImportError, AttributeError):
        pass

    # Fallback to reconstruction
    return (
        _reconstruct_settings_instance,
        (self.__class__.__module__, self.__class__.__name__),
        self.__getstate__(),
    )

__setstate__(state)

Restore state when unpickling.

Source code in src/cheap_settings/cheap_settings.py
500
501
502
503
504
def __setstate__(self, state):
    """Restore state when unpickling."""
    # Nothing to restore for CheapSettings instances
    # All settings are accessed from the class level
    pass

from_env() classmethod

Create a static snapshot with only values from environment variables.

Returns a regular Python class containing only the settings that are explicitly set in environment variables, ignoring all defaults.

Returns:

Name Type Description
object object

A new class with only environment-sourced settings.

This is useful for: - Debugging which settings are coming from the environment - Creating minimal configuration objects - Validating environment-only deployments

Example

os.environ['HOST'] = 'example.com' EnvOnly = MySettings.from_env() EnvOnly.host # 'example.com' hasattr(EnvOnly, 'port') # False (not in env)

Source code in src/cheap_settings/cheap_settings.py
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
@classmethod
def from_env(cls) -> object:
    """Create a static snapshot with only values from environment variables.

    Returns a regular Python class containing only the settings that are
    explicitly set in environment variables, ignoring all defaults.

    Returns:
        object: A new class with only environment-sourced settings.

    This is useful for:
    - Debugging which settings are coming from the environment
    - Creating minimal configuration objects
    - Validating environment-only deployments

    Example:
        >>> os.environ['HOST'] = 'example.com'
        >>> EnvOnly = MySettings.from_env()
        >>> EnvOnly.host  # 'example.com'
        >>> hasattr(EnvOnly, 'port')  # False (not in env)
    """
    attrs = {}
    config_instance = object.__getattribute__(cls, "__config_instance")
    annotations = getattr(config_instance, "__annotations__", {})

    # Only include attributes that have environment variables set
    for name, type_hint in annotations.items():
        env_name = name.upper()
        if env_name in os.environ:
            # Use the same logic as __getattribute__ to get the converted value
            try:
                value = getattr(cls, name)
                attrs[name] = value
            except (AttributeError, ValueError):
                # Skip if we can't get or convert the value
                pass

    # Create a simple class with just the env values
    env_class = type(f"{cls.__name__}FromEnv", (), attrs)
    return env_class

set_config_from_command_line(arg_parser=None, args=None) classmethod

Creates command line arguments (as flags) that correspond to the settings, & parses them, setting the config values based on them. Settings overridden by command line arguments take precedence over any default variables, & over environment variables. Currently, settings of dict & list types are ignored, & no command line arguments are added for them. It can optionally take an instance of argparse.ArgumentParser that can be used to pre-configure your own command line arguments. The optional args parameter allows passing specific arguments for testing (if None, uses sys.argv). Returns the parsed arguments (an instance of argparse.Namespace). Can raise various exceptions.

Source code in src/cheap_settings/cheap_settings.py
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
@classmethod
def set_config_from_command_line(cls, arg_parser=None, args=None):
    """Creates command line arguments (as flags) that correspond to the settings, & parses them, setting the
    config values based on them. Settings overridden by command line arguments take precedence over any
    default variables, & over environment variables. Currently, settings of `dict` & `list` types are ignored,
    & no command line arguments are added for them. It can optionally take an instance of argparse.ArgumentParser
    that can be used to pre-configure your own command line arguments. The optional `args` parameter allows
    passing specific arguments for testing (if None, uses sys.argv). Returns the parsed arguments (an
    instance of argparse.Namespace). Can raise various exceptions."""

    # Create a second config instance specifically for command line arguments. This adds a new class attribute
    # called `__cli_config_instance`, & adds any command line arguments that correspond to the attributes of
    # `__config_instance`. It also copies any type annotations for the copied attributes.
    try:
        config_instance = object.__getattribute__(cls, "__config_instance")
    except AttributeError:
        raise AttributeError("Config instance has not been set.")

    if config_instance is None:
        raise AttributeError("Config instance has not been set.")

    cli_config_instance = MetaCheapSettings.ConfigInstance()
    parsed_args = parse_command_line_arguments(
        config_instance, cli_config_instance, arg_parser, args
    )
    # Use type.__setattr__ to set attribute on the class
    type.__setattr__(cls, "__cli_config_instance", cli_config_instance)

    return parsed_args

set_raise_on_uninitialized(value=True) classmethod

Set whether to raise an error when accessing uninitialized settings.

Parameters:

Name Type Description Default
value bool

If True, accessing uninitialized settings raises AttributeError. If False, accessing uninitialized settings returns None (default).

True
Example

class MySettings(CheapSettings): ... required_setting: str # No default MySettings.set_raise_on_uninitialized(True) MySettings.required_setting # Raises AttributeError

Source code in src/cheap_settings/cheap_settings.py
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
@classmethod
def set_raise_on_uninitialized(cls, value: bool = True):
    """Set whether to raise an error when accessing uninitialized settings.

    Args:
        value: If True, accessing uninitialized settings raises AttributeError.
               If False, accessing uninitialized settings returns None (default).

    Example:
        >>> class MySettings(CheapSettings):
        ...     required_setting: str  # No default
        >>> MySettings.set_raise_on_uninitialized(True)
        >>> MySettings.required_setting  # Raises AttributeError
    """
    config_instance = object.__getattribute__(cls, "__config_instance")
    object.__setattr__(
        config_instance, "_MetaCheapSettings__raise_on_uninitialized_setting", value
    )

to_static() classmethod

Create a static snapshot of current settings as a regular class. The returned class is a regular Python class without any dynamic behavior.

Returns:

Name Type Description
object Type[Any]

a new class with all settings resolved to their current values.

This is useful for: - Performance-critical code where attribute access overhead matters - Situations where you want to freeze settings at a point in time - Working around edge cases with the dynamic metaclass behavior

Example

class MySettings(CheapSettings): ... host: str = "localhost" ... port: int = 8080 StaticSettings = MySettings.to_static() StaticSettings.host # Just a regular class attribute 'localhost'

Source code in src/cheap_settings/cheap_settings.py
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
@classmethod
def to_static(cls) -> object:
    """Create a static snapshot of current settings as a regular class.
    The returned class is a regular Python class without any dynamic behavior.

    Returns:
         object: a new class with all settings resolved to their current values.

    This is useful for:
    - Performance-critical code where attribute access overhead matters
    - Situations where you want to freeze settings at a point in time
    - Working around edge cases with the dynamic metaclass behavior

    Example:
        >>> class MySettings(CheapSettings):
        ...     host: str = "localhost"
        ...     port: int = 8080
        >>> StaticSettings = MySettings.to_static()
        >>> StaticSettings.host  # Just a regular class attribute
        'localhost'
    """
    # Collect all settings and their current resolved values
    attrs = {}

    # Get all settings from the class (including inherited ones)
    for name in dir(cls):
        # Skip private attributes and methods
        if name.startswith("_"):
            continue

        # Get the attribute value
        try:
            value = getattr(cls, name)
        except AttributeError:
            continue

        # Skip methods and other callables
        if callable(value):
            continue

        # Add the resolved value to our static class
        attrs[name] = value

    # Create a new regular class with the resolved values
    static_class = type(f"Static{cls.__name__}", (), attrs)

    # Copy the module for better repr and debugging
    static_class.__module__ = cls.__module__

    return static_class

Reserved Names

The attribute name __cheap_settings__ is reserved for future configuration of cheap-settings behavior. Do not use this name for your settings.

# ❌ Don't do this
class MySettings(CheapSettings):
    __cheap_settings__: dict = {}  # Reserved!

# ✅ Do this instead
class MySettings(CheapSettings):
    my_settings: dict = {}

Type Support

cheap-settings automatically converts environment variable strings to the appropriate Python types based on type annotations.

Supported Types

Type Example Environment Variable Notes
str "hello" VALUE="hello" No conversion needed
int 42 VALUE="42" Converted with int()
float 3.14 VALUE="3.14" Converted with float()
bool True VALUE="true" Accepts: true/false, yes/no, on/off, 1/0 (case-insensitive)
pathlib.Path Path("/etc") VALUE="/etc" Converted with Path()
datetime datetime(2024, 12, 25, 15, 30) VALUE="2024-12-25T15:30:00" ISO format (fromisoformat)
date date(2024, 12, 25) VALUE="2024-12-25" ISO format (YYYY-MM-DD)
time time(15, 30, 45) VALUE="15:30:45" ISO format (HH:MM:SS)
Decimal Decimal("99.99") VALUE="99.99" Preserves precision
UUID UUID("...") VALUE="550e8400-..." With/without hyphens
list [1, 2, 3] VALUE='[1, 2, 3]' Parsed as JSON
dict {"key": "value"} VALUE='{"key": "value"}' Parsed as JSON
Custom types Any type with from_string() VALUE="custom format" Calls Type.from_string(value)
Optional[T] None or T VALUE="none" or valid T Special "none" string sets to None
Union[T, U] T or U Valid for either type Tries each type in order

Extended Type Examples

Date and Time Types

from datetime import datetime, date, time
from cheap_settings import CheapSettings

class TimeSettings(CheapSettings):
    created_at: datetime = datetime(2024, 1, 1)
    expiry_date: date = date(2024, 12, 31)
    backup_time: time = time(3, 0, 0)

# Environment variables:
# CREATED_AT="2024-12-25T15:30:45.123456"  # With microseconds
# CREATED_AT="2024-12-25T15:30:45+05:30"   # With timezone
# EXPIRY_DATE="2025-01-01"
# BACKUP_TIME="02:30:00"

Decimal for Financial Precision

from decimal import Decimal

class PricingSettings(CheapSettings):
    product_price: Decimal = Decimal("99.99")
    tax_rate: Decimal = Decimal("0.0825")  # 8.25%

# Preserves exact decimal precision
# PRODUCT_PRICE="149.99"
# TAX_RATE="0.0925"

# Calculate with precision
tax = PricingSettings.product_price * PricingSettings.tax_rate

UUID for Unique Identifiers

from uuid import UUID

class ServiceSettings(CheapSettings):
    instance_id: UUID = UUID("00000000-0000-0000-0000-000000000000")

# Accepts multiple formats:
# INSTANCE_ID="550e8400-e29b-41d4-a716-446655440000"  # Standard
# INSTANCE_ID="550e8400e29b41d4a716446655440000"      # No hyphens
# INSTANCE_ID="{550e8400-e29b-41d4-a716-446655440000}" # With braces

Custom Types with from_string()

Any custom type that implements a from_string() class method will work automatically:

class Temperature:
    def __init__(self, celsius: float):
        self.celsius = celsius

    @classmethod
    def from_string(cls, value: str) -> 'Temperature':
        if value.endswith('F'):
            # Convert Fahrenheit to Celsius
            fahrenheit = float(value[:-1])
            celsius = (fahrenheit - 32) * 5/9
            return cls(celsius)
        else:
            # Assume Celsius
            return cls(float(value))

class Settings(CheapSettings):
    room_temp: Temperature = Temperature(20.0)

# Environment variables work automatically:
# ROOM_TEMP="72F"  # Converts to Temperature(22.2)
# ROOM_TEMP="25"   # Converts to Temperature(25.0)

Environment Variable Naming

Environment variables are the uppercase version of the attribute name:

class Settings(CheapSettings):
    database_url: str = "localhost"     # DATABASE_URL
    api_timeout: int = 30               # API_TIMEOUT
    enable_cache: bool = False          # ENABLE_CACHE

Command Line Arguments

Command line arguments are the lowercase, hyphenated version of the attribute name:

class Settings(CheapSettings):
    database_url: str = "localhost"     # --database-url
    api_timeout: int = 30               # --api-timeout
    enable_cache: bool = False          # --enable-cache / --no-enable-cache

Boolean Command Line Behavior

Boolean handling differs based on whether the type is Optional:

  • Non-Optional booleans (bool = False/True) create both positive and negative flags: python class Settings(CheapSettings): debug: bool = False # Creates both --debug and --no-debug secure: bool = True # Creates both --secure and --no-secure This allows you to override environment variables in either direction: bash # Environment: DEBUG=true, SECURE=false python app.py --no-debug --secure # Override both values

  • Optional booleans (Optional[bool]) accept explicit values: python class Settings(CheapSettings): debug: Optional[bool] = None # --debug true/false/1/0/yes/no

Both approaches allow overriding environment variables, but non-Optional booleans provide a cleaner flag-based interface.

Inheritance

Settings classes support inheritance. Child classes inherit all settings from parent classes and can override them:

class BaseSettings(CheapSettings):
    timeout: int = 30

class WebSettings(BaseSettings):
    timeout: int = 60  # Override parent
    port: int = 8080   # Add new setting

Error Handling

  • Type conversion errors: If an environment variable can't be converted to the expected type, a ValueError is raised with details
  • JSON parsing errors: For list and dict types, JSON parsing errors include helpful messages
  • Missing attributes: Accessing undefined settings raises AttributeError

Performance

For performance-critical code where attribute access overhead matters, use to_static() to create a snapshot with no dynamic behavior:

Settings = MyDynamicSettings.to_static()
# Now Settings.value is just a regular class attribute

Environment-Only Settings

The from_env() method returns a class containing only settings that are explicitly set in environment variables:

EnvOnly = MySettings.from_env()
# EnvOnly only has attributes for settings with environment variables

This is useful for debugging deployments or validating environment configuration.

Settings Without Initializers

You can define settings with type annotations but no default values:

class MySettings(CheapSettings):
    required_api_key: str  # No default value
    optional_timeout: Optional[int]  # No default, Optional type

By default, accessing uninitialized settings returns None:

assert MySettings.required_api_key is None
assert MySettings.optional_timeout is None

Environment variables work normally with uninitialized settings:

os.environ["REQUIRED_API_KEY"] = "secret123"
assert MySettings.required_api_key == "secret123"

For stricter validation, use set_raise_on_uninitialized(True) to make accessing uninitialized settings raise AttributeError:

MySettings.set_raise_on_uninitialized(True)
MySettings.required_api_key  # Raises AttributeError if not in environment

Note: settings with Optional types (or unions with None), never raise when uninitialized. They always return None even if you set_raise_on_uninitialized(True).

Working with Credentials and .env Files

For sensitive settings like API keys or passwords, use Optional types with no default:

class Settings(CheapSettings):
    api_key: Optional[str] = None
    db_password: Optional[str] = None

Since cheap-settings reads environment variables dynamically, it works seamlessly with python-dotenv:

from dotenv import load_dotenv
from cheap_settings import CheapSettings

load_dotenv()  # Load .env file into environment
# Settings will automatically pick up the values