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

Stateable

Stateable is the common base class for all sync stateful mixins (Openable, Connectable, Enableable). It owns a single _state_changed: threading.Condition that every mixin on the same concrete object shares.

Why a shared condition?

Python's threading.Condition has no equivalent of select() — there is no way to block until any one of several conditions is notified. If each mixin kept its own condition, code that needs to wait for a composite state change (e.g. "connected AND open") would be forced to poll. By sharing one condition across all state dimensions, a single wait_for call wakes on any state mutation, regardless of which mixin triggered it:

with obj._state_changed:
    obj._state_changed.wait_for(
        lambda: (
            obj.connection_state == protolib.ConnectionStateType.CONNECTED
            and obj.open_state == protolib.OpenStateType.OPEN
        )
    )

Every _set_*_state helper in Connectable, Openable, and Enableable acquires _state_changed, mutates its own state variable, and calls notify_all() — so all waiters are woken on every transition.

Lock integration

When the concrete class also inherits from Lockable, the condition is backed by self.lock (threading.RLock), consolidating all locking onto a single primitive.


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 inherits _state_changed: threading.Condition from Stateable. 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._state_changed:
    obj._state_changed.wait_for(lambda: obj.open_state == protolib.OpenStateType.OPEN)

Because the condition is shared with all other Stateable mixins on the same object, a single wait_for call will also be woken by connection or enable state changes — no per-dimension polling needed. See Stateable for details.

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 inherits _state_changed: threading.Condition from Stateable. 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._state_changed:
    obj._state_changed.wait_for(lambda: obj.connection_state == protolib.ConnectionStateType.CONNECTED)

Because the condition is shared with all other Stateable mixins on the same object, a single wait_for call will also be woken by open or enable state changes — no per-dimension polling needed. See Stateable for details.

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 inherits _state_changed: threading.Condition from Stateable. 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._state_changed:
    obj._state_changed.wait_for(lambda: obj.enabled_state == protolib.EnabledStateType.ENABLED)

Because the condition is shared with all other Stateable mixins on the same object, a single wait_for call will also be woken by connection or open state changes — no per-dimension polling needed. See Stateable for details.

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._state_changed:
    subsystem._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. When mixing multiple stateful classes (Openable, Connectable, Enableable), their shared Stateable base is initialised exactly once by the MRO, producing a single _state_changed condition covering all state dimensions on the object:

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.1.tar.gz (21.2 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.1-py3-none-any.whl (19.6 kB view details)

Uploaded Python 3

File details

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

File metadata

  • Download URL: whatamithinking_protolib-1.1.1.tar.gz
  • Upload date:
  • Size: 21.2 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.1.tar.gz
Algorithm Hash digest
SHA256 be3d7c5b3dcd11a7a47ca8e8e750a3368303c80a4b936c2d6d3176ecf99a4cf5
MD5 1e1fd2540af25654558d2099c705af29
BLAKE2b-256 36a7bcbf3ebd7433dc26e727c2f509a738b18989ed63c0e2802197cae8fe635a

See more details on using hashes here.

File details

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

File metadata

File hashes

Hashes for whatamithinking_protolib-1.1.1-py3-none-any.whl
Algorithm Hash digest
SHA256 88623720550c4ff4abf9ed705cfcd6e0e83dedf92d30a4f963971e6283d4b9b1
MD5 7f5fa911fb3804565e85ab362cf9144d
BLAKE2b-256 75fb900ce20489839ff937908c05931bdea34274733348733d3fb87c3ba17bf3

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