"""
Helper functions to support function and class actions.
"""
import numbers
import warnings
import numpy as np
import pandas as pd
from typing import Any
from stats_misc.constants import (
CLASS_NAME,
)
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
[docs]
class ManagedProperty(object):
"""
A generic property factory defining setters and getters, with optional
type validation.
Parameters
----------
name : `str`
The name of the setters and getters
types: `Type`, default `NoneType`
Either a single type, or a tuple of types to test against.
Methods
-------
enable_setter()
Enables the setter for the property, allowing attribute assignment.
disable_setter()
Disables the setter for the property, making the property read-only.
set_with_setter(instance, value)
Enables the setter, sets the property value, and then disables
the setter, ensuring controlled updates.
Returns
-------
property
A property object with getter and setter.
"""
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
def __init__(self, name: str, types: tuple[type] | type | None = None) -> None:
"""
Initialize the ManagedProperty.
"""
self.name = name
self.types = types
self._setter_enabled = True
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
def __get__(self, instance: object, owner: type) -> Any:
"""Getter for the property."""
if instance is None:
return self
return instance.__dict__.get(self.name)
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
def __set__(self, instance: object, value: Any) -> None:
"""Setter for the property."""
if not self._setter_enabled:
raise AttributeError(f"The property '{self.name}' is read-only.")
if self.types and not isinstance(value, self.types):
raise ValueError(
f"Expected any of {self.types}, got {type(value)} "
f"for property '{self.name}'."
)
instance.__dict__[self.name] = value
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
[docs]
def enable_setter(self) -> None:
"""Enable the setter for the property."""
self._setter_enabled = True
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
[docs]
def disable_setter(self) -> None:
"""Disable the setter for the property."""
self._setter_enabled = False
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
[docs]
def set_with_setter(self, instance: object, value: Any) -> None:
"""
Enable the setter, set the property value, and then disable the setter.
Parameters
----------
instance : `object`
The instance on which the property is being set.
value : `Any`
The value to assign to the property.
"""
try:
self.enable_setter()
setattr(instance, self.name, value)
finally:
self.disable_setter()
# @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
[docs]
class Results(object):
"""
A general results class.
Parameters
----------
set_args : `list` of `str`
Names of the attributes to set on the instance.
**kwargs : `Any`
Values for each name in ``set_args``.
Raises
------
AttributeError
If an unrecognised keyword is provided.
Warns
-----
UserWarning
For each name in ``set_args`` not supplied in ``kwargs``.
"""
# /////////////////////////////////////////////////////////////////////////
# Initiation the class
# NOTE include * to force all named arguments to be named (no positional)
# args when calling innit.
def __init__(self, *, set_args: list[str], **kwargs: Any) -> None:
"""
Initialise a `Results` instance.
"""
SET_ARGS = '_setargs'
setattr(self,SET_ARGS, set_args)
# now set values
for k in kwargs.keys():
if k not in getattr(self, SET_ARGS):
raise AttributeError("unrecognised argument '{0}'".format(k))
# Loops over `SET_ARGS`, assigns the kwargs content to name `s`.
# if argument is missing in kwargs, print a warning.
for s in getattr(self, SET_ARGS):
try:
setattr(self, s, kwargs[s])
except KeyError:
warnings.warn("argument '{0}' is set to 'None'".format(s))
setattr(self, s, None)
# /////////////////////////////////////////////////////////////////////////
def __str__(self) -> str:
"""Return a human-readable string representation."""
# assigns a back up name if clas_name is not provided
CLASS_NAME_ = getattr(self, CLASS_NAME, type(self).__name__)
return f"A `{CLASS_NAME_}` results class."
# /////////////////////////////////////////////////////////////////////////
def __repr__(self) -> str:
"""Return an unambiguous string representation."""
CLASS_NAME_ = getattr(self, CLASS_NAME, type(self).__name__)
args = getattr(self, '_setargs')
parts = []
# join the keys and values
for arg in args:
# skip
if arg == CLASS_NAME:
continue
# format value
value = getattr(self, arg, None)
if isinstance(value, float):
formatted = f"{value:.3f}"
# check for confidnece intervals
elif (
isinstance(value, (list, tuple)) and\
all(isinstance(v, numbers.Real) for v in value) and\
len(value) == 2
):
formatted = f"[{value[0]:.3f}, {value[1]:.3f}]"
if isinstance(value, tuple):
formatted = f"({value[0]:.3f}, {value[1]:.3f})"
# check for array like objects
elif isinstance(value, (list, tuple, np.ndarray, pd.Series)):
formatted = self._repr_summary(value)
else:
formatted = repr(value)
parts.append(f" {arg}={formatted}")
# return a pretty string
body = "\n".join(parts)
return f"{CLASS_NAME_}\n{body}\n"
# /////////////////////////////////////////////////////////////////////////
def _repr_summary(
self,
value: np.ndarray | list | tuple | pd.Series,
max_items: int = 6,
precision: int = 3,
) -> str:
"""
Format an array-like value as a short summary string.
Parameters
----------
value : `np.ndarray`, `list`, `tuple`, or `pd.Series`
The array-like value to format.
max_items : `int`, default 6
Maximum number of elements to display before truncating.
precision : `int`, default 3
Decimal precision for floating-point values.
Returns
-------
str
A compact string representation of ``value``.
"""
if isinstance(value, np.ndarray):
array_str = np.array2string(
value,
precision=precision,
threshold=max_items,
edgeitems=3,
suppress_small=True
)
# Indent continuation lines
indent_str = ' ' * 2
lines = array_str.splitlines()
if len(lines) > 1:
indented_array = (f"\n{indent_str} ").join(lines)
else:
indented_array = lines[0]
return (
f"array({indented_array}, shape={value.shape}, "
f"dtype={value.dtype})"
)
elif isinstance(value, (list, tuple)):
sample = value[:max_items]
suffix = ", ..." if len(value) > max_items else ""
items = ', '.join(repr(v) for v in sample)
return f"{type(value).__name__}([{items}{suffix}])"
elif isinstance(value, pd.Series):
sample = value.iloc[:max_items].tolist()
suffix = ", ..." if len(value) > max_items else ""
return (f"Series([{', '.join(repr(v) for v in sample)}{suffix}], "
f"name={value.name})")
return repr(value)