# Copyright Iris contributors
#
# This file is part of Iris and is released under the BSD license.
# See LICENSE in the root of the repository for full licensing details.
"""Provides the infrastructure to support lenient client/service behaviour."""
from collections.abc import Iterable
from contextlib import contextmanager
from copy import deepcopy
from functools import wraps
from inspect import getmodule
import threading
__all__ = [
"LENIENT",
"Lenient",
]
#: Default _Lenient services global activation state.
_LENIENT_ENABLE_DEFAULT = True
#: Default Lenient maths feature state.
_LENIENT_MATHS_DEFAULT = True
#: Protected _Lenient internal non-client, non-service keys.
_LENIENT_PROTECTED = ("active", "enable")
def _lenient_client(*dargs, services=None):
"""Allow a client function/method to declare at runtime.
Decorator that allows a client function/method to declare at runtime that
it is executing and requires lenient behaviour from a prior registered
lenient service function/method.
This decorator supports being called with no arguments e.g::
@_lenient_client()
def func():
pass
This is equivalent to using it as a simple naked decorator e.g::
@_lenient_client
def func()
pass
Alternatively, this decorator supports the lenient client explicitly
declaring the lenient services that it wishes to use e.g::
@_lenient_client(services=(service1, service2, ...)
def func():
pass
Parameters
----------
dargs : tuple of callable
A tuple containing the callable lenient client function/method to be
wrapped by the decorator. This is automatically populated by Python
through the decorator interface. No argument requires to be manually
provided.
services : callable or str or iterable of callable/str, optional
Zero or more function/methods, or equivalent fully qualified string names, of
lenient service function/methods.
Returns
-------
Closure wrapped function/method.
"""
ndargs = len(dargs)
if ndargs:
assert (
ndargs == 1
), f"Invalid lenient client arguments, expecting 1 got {ndargs}."
assert callable(
dargs[0]
), "Invalid lenient client argument, expecting a callable."
assert not (
ndargs and services
), "Invalid lenient client, got both arguments and keyword arguments."
if ndargs:
# The decorator has been used as a simple naked decorator.
(func,) = dargs
@wraps(func)
def lenient_client_inner_naked(*args, **kwargs):
"""Closure wrapper function.
Closure wrapper function to register the wrapped function/method
as active at runtime before executing it.
"""
with _LENIENT.context(active=_qualname(func)):
result = func(*args, **kwargs)
return result
result = lenient_client_inner_naked
else:
# The decorator has been called with None, zero or more explicit lenient services.
if services is None:
services = ()
if isinstance(services, str) or not isinstance(services, Iterable):
services = (services,)
def lenient_client_outer(func):
@wraps(func)
def lenient_client_inner(*args, **kwargs):
"""Closure wrapper function.
Closure wrapper function to register the wrapped function/method
as active at runtime before executing it.
"""
with _LENIENT.context(*services, active=_qualname(func)):
result = func(*args, **kwargs)
return result
return lenient_client_inner
result = lenient_client_outer
return result
def _lenient_service(*dargs):
"""Implement the lenient service protocol.
Decorator that allows a function/method to declare that it supports lenient
behaviour as a service.
Registration is at Python interpreter parse time.
The decorator supports being called with no arguments e.g::
@_lenient_service()
def func():
pass
This is equivalent to using it as a simple naked decorator e.g::
@_lenient_service
def func():
pass
Parameters
----------
dargs : tuple of callable
A tuple containing the callable lenient service function/method to be
wrapped by the decorator. This is automatically populated by Python
through the decorator interface. No argument requires to be manually
provided.
Returns
-------
Closure wrapped function/method.
"""
ndargs = len(dargs)
if ndargs:
assert (
ndargs == 1
), f"Invalid lenient service arguments, expecting 1 got {ndargs}."
assert callable(
dargs[0]
), "Invalid lenient service argument, expecting a callable."
if ndargs:
# The decorator has been used as a simple naked decorator.
# Thus the (single) argument is a function to be wrapped.
# We just register the argument function as a lenient service, and
# return it unchanged
(func,) = dargs
_LENIENT.register_service(func)
# This decorator registers 'func': the func itself is unchanged.
result = func
else:
# The decorator has been called with no arguments.
# Return a decorator, to apply to 'func' immediately following.
def lenient_service_outer(func):
_LENIENT.register_service(func)
# Decorator registers 'func', but func itself is unchanged.
return func
result = lenient_service_outer
return result
def _qualname(func):
"""Return the fully qualified function/method string name.
Parameters
----------
func : callable
Callable function/method. Non-callable arguments are simply
passed through.
Notes
-----
.. note::
Inherited methods will be qualified with the base class that
defines the method.
"""
result = func
if callable(func):
module = getmodule(func)
result = f"{module.__name__}.{func.__qualname__}"
return result
[docs]
class Lenient(threading.local):
def __init__(self, **kwargs):
"""Container for managing the run-time lenient features and options.
Parameters
----------
**kwargs : dict, optional
Mapping of lenient key/value options to enable/disable. Note that,
only the lenient "maths" options is available, which controls
lenient/strict cube arithmetic.
Examples
--------
::
Lenient(maths=False)
Note that, the values of these options are thread-specific.
"""
# Configure the initial default lenient state.
self._init()
if not kwargs:
# If not specified, set the default behaviour of the maths lenient feature.
kwargs = dict(maths=_LENIENT_MATHS_DEFAULT)
# Configure the provided (or default) lenient features.
for feature, state in kwargs.items():
self[feature] = state
def __contains__(self, key):
return key in self.__dict__
def __getitem__(self, key):
if key not in self.__dict__:
cls = self.__class__.__name__
emsg = f"Invalid {cls!r} option, got {key!r}."
raise KeyError(emsg)
return self.__dict__[key]
def __repr__(self):
cls = self.__class__.__name__
msg = f"{cls}(maths={self.__dict__['maths']!r})"
return msg
def __setitem__(self, key, value):
cls = self.__class__.__name__
if key not in self.__dict__:
emsg = f"Invalid {cls!r} option, got {key!r}."
raise KeyError(emsg)
if not isinstance(value, bool):
emsg = f"Invalid {cls!r} option {key!r} value, got {value!r}."
raise ValueError(emsg)
self.__dict__[key] = value
# Toggle the (private) lenient behaviour.
_LENIENT.enable = value
def _init(self):
"""Configure the initial default lenient state."""
# This is the only public supported lenient feature i.e., cube arithmetic
self.__dict__["maths"] = None
[docs]
@contextmanager
def context(self, **kwargs):
"""Context manager supporting temporary modification of lenient state.
Return a context manager which allows temporary modification of the
lenient option state within the scope of the context manager.
On entry to the context manager, all provided keyword arguments are
applied. On exit from the context manager, the previous lenient
option state is restored.
For example::
with iris.common.Lenient.context(maths=False):
pass
"""
def configure_state(state):
for feature, value in state.items():
self[feature] = value
# Save the original state.
original_state = deepcopy(self.__dict__)
# Configure the provided lenient features.
configure_state(kwargs)
try:
yield
finally:
# Restore the original state.
self.__dict__.clear()
self._init()
configure_state(original_state)
###############################################################################
class _Lenient(threading.local):
def __init__(self, *args, **kwargs):
"""Container for managing the run-time lenient services and client options.
A container for managing the run-time lenient services and client
options for pre-defined functions/methods.
Parameters
----------
*args : callable or str or iterable of callable/str
A function/method or fully qualified string name of the function/method
acting as a lenient service.
**kwargs : dict of callable/str or iterable of callable/str, optional
Mapping of lenient client function/method, or fully qualified string name
of the function/method, to one or more lenient service
function/methods or fully qualified string name of function/methods.
Examples
--------
::
_Lenient(service1, service2, client1=service1, client2=(service1, service2))
Note that, the values of these options are thread-specific.
"""
# The executing lenient client at runtime.
self.__dict__["active"] = None
# The global lenient services state activation switch.
self.__dict__["enable"] = _LENIENT_ENABLE_DEFAULT
for service in args:
self.register_service(service)
for client, services in kwargs.items():
self.register_client(client, services)
def __call__(self, func):
"""Determine whether it is valid for the function/method to provide a lenient service.
Determine whether it is valid for the function/method to provide a
lenient service at runtime to the actively executing lenient client.
Parameters
----------
func : callable or str
A function/method or fully qualified string name of the function/method.
Returns
-------
bool
"""
result = False
if self.__dict__["enable"]:
service = _qualname(func)
if service in self and self.__dict__[service]:
active = self.__dict__["active"]
if active is not None and active in self:
services = self.__dict__[active]
if isinstance(services, str) or not isinstance(services, Iterable):
services = (services,)
result = service in services
return result
def __contains__(self, name):
name = _qualname(name)
return name in self.__dict__
def __getattr__(self, name):
if name not in self.__dict__:
cls = self.__class__.__name__
emsg = f"Invalid {cls!r} option, got {name!r}."
raise AttributeError(emsg)
return self.__dict__[name]
def __getitem__(self, name):
name = _qualname(name)
if name not in self.__dict__:
cls = self.__class__.__name__
emsg = f"Invalid {cls!r} option, got {name!r}."
raise KeyError(emsg)
return self.__dict__[name]
def __repr__(self):
cls = self.__class__.__name__
width = len(cls) + 1
kwargs = [
"{}={!r}".format(name, self.__dict__[name])
for name in sorted(self.__dict__.keys())
]
joiner = ",\n{}".format(" " * width)
return "{}({})".format(cls, joiner.join(kwargs))
def __setitem__(self, name, value):
name = _qualname(name)
cls = self.__class__.__name__
if name not in self.__dict__:
emsg = f"Invalid {cls!r} option, got {name!r}."
raise KeyError(emsg)
if name == "active":
value = _qualname(value)
if not isinstance(value, str) and value is not None:
emsg = f"Invalid {cls!r} option {name!r}, expected a registered {cls!r} client, got {value!r}."
raise ValueError(emsg)
self.__dict__[name] = value
elif name == "enable":
self.enable = value
else:
if isinstance(value, str) or callable(value):
value = (value,)
if isinstance(value, Iterable):
value = tuple([_qualname(item) for item in value])
self.__dict__[name] = value
@contextmanager
def context(self, *args, **kwargs):
"""Context manager supporting temporary modification of lenient state.
Return a context manager which allows temporary modification of
the lenient option state for the active thread.
On entry to the context manager, all provided keyword arguments are
applied. On exit from the context manager, the previous lenient option
state is restored.
For example::
with iris._LENIENT.context(example_lenient_flag=False):
# ... code that expects some non-lenient behaviour
.. note::
iris._LENIENT.example_lenient_flag does not exist and is
provided only as an example.
"""
def update_client(client, services):
if client in self.__dict__:
existing_services = self.__dict__[client]
else:
existing_services = ()
self.__dict__[client] = tuple(set(existing_services + services))
# Save the original state.
original_state = deepcopy(self.__dict__)
# Temporarily update the state with the kwargs first.
for name, value in kwargs.items():
self[name] = value
# Get the active client.
active = self.__dict__["active"]
if args:
# Update the client with the provided services.
new_services = tuple([_qualname(arg) for arg in args])
if active is None:
# Ensure not to use "context" as the ephemeral name
# of the context manager runtime "active" lenient client,
# as this causes a namespace clash with this method
# i.e., _Lenient.context, via _Lenient.__getattr__
active = "__context"
self.__dict__["active"] = active
self.__dict__[active] = new_services
else:
# Append provided services to any pre-existing services of the active client.
update_client(active, new_services)
else:
# Append previous ephemeral services (for non-specific client) to the active client.
if (
active is not None
and active != "__context"
and "__context" in self.__dict__
):
new_services = self.__dict__["__context"]
update_client(active, new_services)
try:
yield
finally:
# Restore the original state.
self.__dict__.clear()
self.__dict__.update(original_state)
@property
def enable(self):
"""Return the activation state of the lenient services."""
return self.__dict__["enable"]
@enable.setter
def enable(self, state):
"""Set the activate state of the lenient services.
Setting the state to `False` disables all lenient services, and
setting the state to `True` enables all lenient services.
Parameters
----------
state : bool
Activate state for lenient services.
"""
if not isinstance(state, bool):
cls = self.__class__.__name__
emsg = f"Invalid {cls!r} option 'enable', expected a {type(True)!r}, got {state!r}."
raise ValueError(emsg)
self.__dict__["enable"] = state
def register_client(self, func, services, append=False):
"""Add the lenient client to service mapping.
Add the provided mapping of lenient client function/method to
required lenient service function/methods.
Parameters
----------
func : callable or str
A client function/method or fully qualified string name of the
client function/method.
services : callable or str or iterable of callable/str
One or more service function/methods or fully qualified string names
of the required service function/method.
append : bool, default=False
If True, append the lenient services to any pre-registered lenient
services for the provided lenient client. Default is False.
"""
func = _qualname(func)
cls = self.__class__.__name__
if func in _LENIENT_PROTECTED:
emsg = (
f"Cannot register {cls!r} client. "
f"Please rename your client to be something other than {func!r}."
)
raise ValueError(emsg)
if isinstance(services, str) or not isinstance(services, Iterable):
services = (services,)
if not len(services):
emsg = f"Require at least one {cls!r} client service."
raise ValueError(emsg)
services = tuple([_qualname(service) for service in services])
if append:
# The original provided service order is not significant. There is
# no requirement to preserve it, so it's safe to sort.
existing = self.__dict__[func] if func in self else ()
services = tuple(sorted(set(existing) | set(services)))
self.__dict__[func] = services
def register_service(self, func):
"""Add the provided function/method as providing a lenient service and activate it.
Parameters
----------
func : callable or str
A service function/method or fully qualified string name of the
service function/method.
"""
func = _qualname(func)
if func in _LENIENT_PROTECTED:
cls = self.__class__.__name__
emsg = (
f"Cannot register {cls!r} service. "
f"Please rename your service to be something other than {func!r}."
)
raise ValueError(emsg)
self.__dict__[func] = True
def unregister_client(self, func):
"""Remove the provided function/method as a lenient client using lenient services.
Parameters
----------
func : callable or str
A function/method of fully qualified string name of the function/method.
"""
func = _qualname(func)
cls = self.__class__.__name__
if func in _LENIENT_PROTECTED:
emsg = f"Cannot unregister {cls!r} client, as {func!r} is a protected {cls!r} option."
raise ValueError(emsg)
if func in self.__dict__:
value = self.__dict__[func]
if isinstance(value, bool):
emsg = f"Cannot unregister {cls!r} client, as {func!r} is not a valid {cls!r} client."
raise ValueError(emsg)
del self.__dict__[func]
else:
emsg = f"Cannot unregister unknown {cls!r} client {func!r}."
raise ValueError(emsg)
def unregister_service(self, func):
"""Remove the provided function/method as providing a lenient service.
Parameters
----------
func : callable or str
A function/method or fully qualified string name of the function/method.
"""
func = _qualname(func)
cls = self.__class__.__name__
if func in _LENIENT_PROTECTED:
emsg = f"Cannot unregister {cls!r} service, as {func!r} is a protected {cls!r} option."
raise ValueError(emsg)
if func in self.__dict__:
value = self.__dict__[func]
if not isinstance(value, bool):
emsg = f"Cannot unregister {cls!r} service, as {func!r} is not a valid {cls!r} service."
raise ValueError(emsg)
del self.__dict__[func]
else:
emsg = f"Cannot unregister unknown {cls!r} service {func!r}."
raise ValueError(emsg)
#: (Private) Instance that manages all Iris run-time lenient client and service options.
_LENIENT = _Lenient()
#: (Public) Instance that manages all Iris run-time lenient features.
LENIENT = Lenient()