Skip to main content

Foundational base classes/protocols used to manage state and provide clear errors

Project description

whatamithinking-protolib

Foundational base classes and protocols for managing lifecycle state in Python objects, with clear, descriptive errors and support for both synchronous and asynchronous patterns.


Installation

pip install whatamithinking-protolib

Requires Python 3.12+

whatamithinking-aiotools is installed automatically as a dependency (required by AsyncLockable).


Overview

protolib provides a consistent pattern for objects with managed lifecycle states. Each module follows the same design:

  • An enum describing the valid states.
  • Exception classes that describe exactly what went wrong and why.
  • Guard decorators (ensure_*) that protect methods from being called in an invalid state.
  • State-transition decorators (opener, connector, etc.) that handle state changes automatically.
  • Abstract base classes in both sync (Openable, Connectable, ...) and async (AsyncOpenable, AsyncConnectable, ...) variants.
import whatamithinking.protolib as protolib

Modules

Openable

Manages an open/close lifecycle, suitable for resources like file handles, serial ports, or network sockets.

States — OpenStateType

State Description
CLOSED Initial/default state. The object is not in use.
OPENING Transitioning to open.
OPEN The object is open and operational.
CLOSING Transitioning to closed.
ERROR An error occurred during open or close.

Exceptions

Exception Raised when...
OpenStateError Base class for all open-state errors.
NotOpenError A method requires OPEN state but the object is not open.
NotClosedError open() was called but the object is not currently CLOSED.
ClosedError A method was called while the object is CLOSED.

Guard Decorators

These decorators check state before executing the method and raise an exception if the check fails.

Decorator Raises if...
@ensure_open Object is not in OPEN state → NotOpenError
@ensure_closed Object is not in CLOSED state → NotClosedError
@ensure_not_closed Object is in CLOSED state → ClosedError

Standalone check functions (check_open, check_closed, check_not_closed) are also available for use without decorators.

State-Transition Decorators

Decorator Usage
@opener Apply to open(). Handles OPENINGOPEN (or ERROR) transitions. No-op if already OPEN. Requires object to be CLOSED first.
@closer Apply to close(). Handles CLOSINGCLOSED (or ERROR) transitions. No-op if already CLOSED.

Base Classes

Openable — Synchronous. Implement open() and close(). Supports use as a context manager (with).

AsyncOpenable — Asynchronous. Implement open() and close() as coroutines. Supports use as an async context manager (async with).

State Condition Variable

Openable exposes _open_state_changed: threading.Condition. It is acquired and notify_all() is called every time _set_open_state() runs, so other threads can reliably wait for a state transition:

with obj._open_state_changed:
    obj._open_state_changed.wait_for(lambda: obj.open_state == protolib.OpenStateType.OPEN)

If the object is also a Lockable, the condition shares self.lock (threading.RLock) as its underlying lock.

Example

from typing import Optional
import whatamithinking.protolib as protolib


class MyResource(protolib.Openable):
    @protolib.opener
    def open(self, timeout: Optional[float] = None) -> None:
        # allocate resources here
        ...

    @protolib.closer
    def close(self, timeout: Optional[float] = None) -> None:
        # release resources here
        ...

    @protolib.ensure_open
    def read(self) -> bytes:
        # only runs when object is OPEN
        ...


# Using as a context manager
with MyResource() as r:
    data = r.read()

# Or manually
r = MyResource()
r.open()
try:
    data = r.read()
finally:
    r.close()

Connectable

Manages a connect/disconnect lifecycle with support for polling-based keepalive, suitable for network clients, database connections, etc.

States — ConnectionStateType

State Description
DISCONNECTED Initial/default state.
CONNECTING Transitioning to connected.
CONNECTED The object has an active connection.
DISCONNECTING Transitioning to disconnected.
ERROR A connection or disconnection error occurred.

Exceptions

Exception Raised when...
ConnectionError Base class for all connection-related errors.
NotConnectedError A method requires CONNECTED state but is not connected.
NotDisconnectedError connect() was called but the object is not DISCONNECTED.
NotBadConnectionError A method requires DISCONNECTED or ERROR state but neither applies.
DisconnectedError A method was called while the object is DISCONNECTED.

Guard Decorators

Decorator Raises if...
@ensure_connected Object is not CONNECTEDNotConnectedError
@ensure_not_disconnected Object is DISCONNECTEDDisconnectedError
@ensure_bad_connection Object is not in DISCONNECTED or ERRORNotBadConnectionError

Standalone check functions (check_connected, check_not_disconnected, check_bad_connection) are also available.

State-Transition Decorators

Decorator Usage
@connector Apply to connect(). Handles CONNECTINGCONNECTED (or ERROR) transitions. No-op if already CONNECTED. Requires DISCONNECTED state.
@disconnector Apply to disconnect(). Handles DISCONNECTINGDISCONNECTED (or ERROR) transitions. No-op if already DISCONNECTED.
@poller Apply to poll(). Updates state to CONNECTED or ERROR based on the method's return value.

Base Classes

Connectable — Synchronous. Implement connect(), disconnect(), and poll(). Supports use as a context manager. Includes a keepalive() method for smart polling.

AsyncConnectable — Asynchronous. Same interface with coroutines. Supports use as an async context manager.

State Condition Variable

Connectable exposes _connection_state_changed: threading.Condition. It is acquired and notify_all() is called every time _set_connection_state() runs, so other threads can reliably wait for a state transition:

with obj._connection_state_changed:
    obj._connection_state_changed.wait_for(lambda: obj.connection_state == protolib.ConnectionStateType.CONNECTED)

If the object is also a Lockable, the condition shares self.lock (threading.RLock) as its underlying lock.

Keepalive

Both Connectable and AsyncConnectable include a keepalive() method. Set connection_keepalive_timeout (a datetime.timedelta) on the class, then call keepalive() periodically. It will only call poll() if the connection has not been used recently — avoiding redundant I/O.

import datetime

class MyClient(protolib.Connectable):
    connection_keepalive_timeout = datetime.timedelta(seconds=30)

    @protolib.connector
    def connect(self, timeout=None): ...

    @protolib.disconnector
    def disconnect(self, timeout=None): ...

    @protolib.poller
    def poll(self, timeout=None) -> bool:
        # return True if connected, False otherwise
        return self._ping()

Example

import whatamithinking.protolib as protolib


class DatabaseClient(protolib.Connectable):
    @protolib.connector
    def connect(self, timeout=None) -> None:
        # establish connection
        ...

    @protolib.disconnector
    def disconnect(self, timeout=None) -> None:
        # teardown connection
        ...

    @protolib.poller
    def poll(self, timeout=None) -> bool:
        # return True if still connected
        return self._ping()

    @protolib.ensure_connected
    def query(self, sql: str):
        ...


with DatabaseClient() as db:
    results = db.query("SELECT 1")

Enableable

Manages an enable/disable lifecycle, suitable for toggling features or subsystems on and off.

States — EnabledStateType

State Description
DISABLED Initial/default state.
ENABLING Transitioning to enabled.
ENABLED The object is enabled and operational.
DISABLING Transitioning to disabled.
ERROR An error occurred during enable or disable.

Exceptions

Exception Raised when...
EnableableError Base class for all enable-state errors.
NotEnabledError A method requires ENABLED state but the object is not enabled.
NotDisabledError enable() was called but the object is not currently DISABLED.
NotBadEnableError A method requires DISABLED or ERROR state but neither applies.
DisabledError A method was called while the object is DISABLED.

Guard Decorators

Decorator Raises if...
@ensure_enabled Object is not ENABLEDNotEnabledError
@ensure_not_disabled Object is DISABLEDDisabledError
@ensure_bad_enable Object is not in DISABLED or ERRORNotBadEnableError

Standalone check functions (check_enabled, check_not_disabled, check_bad_enable) are also available.

State-Transition Decorators

Decorator Usage
@enabler Apply to enable(). Handles ENABLINGENABLED (or ERROR) transitions. No-op if already ENABLED. Requires DISABLED state.
@disabler Apply to disable(). Handles DISABLINGDISABLED (or ERROR) transitions. No-op if already DISABLED.

Base Classes

Enableable — Synchronous. Implement enable() and disable().

AsyncEnableable — Asynchronous. Implement enable() and disable() as coroutines.

State Condition Variable

Enableable exposes _enabled_state_changed: threading.Condition. It is acquired and notify_all() is called every time _set_enabled_state() runs, so other threads can reliably wait for a state transition:

with obj._enabled_state_changed:
    obj._enabled_state_changed.wait_for(lambda: obj.enabled_state == protolib.EnabledStateType.ENABLED)

If the object is also a Lockable, the condition shares self.lock (threading.RLock) as its underlying lock.

Example

import whatamithinking.protolib as protolib


class Subsystem(protolib.Enableable):
    @protolib.enabler
    def enable(self, timeout=None) -> None:
        # start up subsystem — state advances DISABLED → ENABLING → ENABLED (or ERROR)
        ...

    @protolib.disabler
    def disable(self, timeout=None) -> None:
        # shut down subsystem — state advances ENABLED → DISABLING → DISABLED (or ERROR)
        ...

    @protolib.ensure_enabled
    def run(self) -> None:
        # only runs when ENABLED
        ...

    @protolib.ensure_not_disabled
    def status(self) -> str:
        # runs in any state except DISABLED
        ...


# Wait for enabled from another thread
subsystem = Subsystem()
with subsystem._enabled_state_changed:
    subsystem._enabled_state_changed.wait_for(
        lambda: subsystem.enabled_state == protolib.EnabledStateType.ENABLED
    )

Lockable

Provides thread-safe (or async-safe) locking for objects using a simple method decorator.

Decorator

Decorator Description
@locked Acquires the object's lock before executing the method and releases it when done. Works with sync, async, generator, and async generator methods.

Base Classes

Lockable — Synchronous. Uses a threading.RLock (reentrant). Pass a custom lock to __init__, or one is created automatically.

AsyncLockable — Asynchronous. Uses an aiotools.RLock (reentrant). Pass a custom lock to __init__, or one is created automatically.

Example

import whatamithinking.protolib as protolib


class SafeCounter(protolib.Lockable):
    def __init__(self):
        super().__init__()
        self._count = 0

    @protolib.locked
    def increment(self):
        self._count += 1

    @protolib.locked
    def value(self) -> int:
        return self._count

Logable

Provides a structured logging interface built on Python's standard logging module.

LogLevelType

An enum mirroring standard log levels: DEBUG, INFO, WARNING, ERROR, CRITICAL.

Base Class — Logable

Pass a log_name to __init__ to configure a named logger with a NullHandler (following best practices for libraries). Logging output is controlled by the consumer of the library.

Method Description
_log(level, msg, **kwargs) Log a message at the given level with any extra context.
_log_extra() Override to return a dict of additional metadata included in every log record for this object.

At ERROR level or above, exc_info is automatically captured.

Example

import whatamithinking.protolib as protolib


class MyService(protolib.Logable):
    def __init__(self):
        super().__init__(log_name="myservice")

    def do_work(self):
        self._log(protolib.LogLevelType.INFO, "Starting work")
        try:
            ...
        except Exception:
            self._log(protolib.LogLevelType.ERROR, "Work failed")
            raise

Combining Mixins

All base classes are implemented with cooperative multiple inheritance (super().__init__(**kwargs)), so they can be freely combined:

import whatamithinking.protolib as protolib


class MyDevice(protolib.Openable, protolib.Lockable, protolib.Logable):
    def __init__(self):
        super().__init__(log_name="mydevice")

    @protolib.opener
    def open(self, timeout=None) -> None:
        self._log(protolib.LogLevelType.INFO, "Opening device")
        ...

    @protolib.closer
    def close(self, timeout=None) -> None:
        self._log(protolib.LogLevelType.INFO, "Closing device")
        ...

    @protolib.locked
    @protolib.ensure_open
    def read(self) -> bytes:
        ...

leaf_method

The @leaf_method utility ensures that state-transition decorators (like @opener, @connector, @enabler) only execute their wrapping logic at the leaf (most-derived) class in an inheritance chain. This means intermediate super().open() calls in a subclass hierarchy won't trigger redundant state transitions — only the outermost call does.


License

MIT — see LICENSE.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

whatamithinking_protolib-1.1.0.tar.gz (19.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

whatamithinking_protolib-1.1.0-py3-none-any.whl (17.5 kB view details)

Uploaded Python 3

File details

Details for the file whatamithinking_protolib-1.1.0.tar.gz.

File metadata

  • Download URL: whatamithinking_protolib-1.1.0.tar.gz
  • Upload date:
  • Size: 19.4 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.13.9

File hashes

Hashes for whatamithinking_protolib-1.1.0.tar.gz
Algorithm Hash digest
SHA256 eb6be77894ed1db586121128754f228bc8fa18d038ec97a9bc0785c88a2d0a5c
MD5 491b3e5578369f5b93cfb31d4d24d8ab
BLAKE2b-256 044e000d53317861d4141b58397d44b9c771e3dca80ce36aa01734b075f0d14b

See more details on using hashes here.

File details

Details for the file whatamithinking_protolib-1.1.0-py3-none-any.whl.

File metadata

File hashes

Hashes for whatamithinking_protolib-1.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 4dc21882aa8c60e773f2adf94d05999f2dc7309cbe3535eaa19cbb36eceaa1ce
MD5 23860f90efae10fafeed229879b76b88
BLAKE2b-256 f6af141d58d42a9d54fdc72418622cf8cac48b110aa9e946fee9c359c527a0b1

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page