Refactor gauge trait to match description of it

This commit is contained in:
Griatch 2020-04-18 21:48:37 +02:00
parent c41fd0a33b
commit 7c12e4d362
4 changed files with 641 additions and 249 deletions

View file

@ -104,6 +104,8 @@ class TraitHandlerTest(_TraitHandlerBase):
self.traithandler.foo = "bar" self.traithandler.foo = "bar"
with self.assertRaises(traits.TraitException): with self.assertRaises(traits.TraitException):
self.traithandler["foo"] = "bar" self.traithandler["foo"] = "bar"
with self.assertRaises(traits.TraitException):
self.traithandler.test1 = "foo"
def test_getting(self): def test_getting(self):
"Test we are getting data from the dbstore" "Test we are getting data from the dbstore"
@ -294,74 +296,377 @@ class TraitTest(_TraitHandlerBase):
class TestTraitNumeric(_TraitHandlerBase): class TestTraitNumeric(_TraitHandlerBase):
"""
Test the numeric base class
"""
def test_trait__numeric(self): def setUp(self):
super().setUp()
self.traithandler.add(
"test1",
name="Test1",
trait_type='numeric',
base=1,
extra_val1="xvalue1",
extra_val2="xvalue2"
)
self.trait1 = self.traithandler.get("test1")
def _get_actuals(self):
"""Get trait actuals for comparisons"""
return self.trait1.actual, self.trait2.actual
def test_init(self):
self.assertEqual(
self.trait1._data,
{"name": "Test1",
"trait_type": "numeric",
"base": 1,
"extra_val1": "xvalue1",
"extra_val2": "xvalue2"
}
)
def test_set_wrong_type(self):
self.trait1.base = "foo"
self.assertEqual(self.trait1.base, 1)
def test_actual(self):
self.trait1.base = 10
self.assertEqual(self.trait1.actual, 10)
class TestTraitStatic(_TraitHandlerBase):
"""
Test for static Traits
"""
def setUp(self):
super().setUp()
self.traithandler.add(
"test1",
name="Test1",
trait_type='static',
base=1,
mod=2,
extra_val1="xvalue1",
extra_val2="xvalue2"
)
self.trait1 = self.traithandler.get("test1")
def _get_values(self):
return self.trait1.base, self.trait1.mod, self.trait1.actual
def test_init(self):
self.assertEqual(
self._get_dbstore("test1"),
{"name": "Test1",
"trait_type": 'static',
"base": 1,
"mod": 2,
"extra_val1": "xvalue1",
"extra_val2": "xvalue2"
}
)
def test_actual(self):
"""Actual is base + mod"""
self.assertEqual(self._get_values(), (1, 2, 3))
self.trait1.base += 4
self.assertEqual(self._get_values(), (5, 2, 7))
self.trait1.mod -= 1
self.assertEqual(self._get_values(), (5, 1, 6))
def test_delete(self):
"""Deleting resets to default."""
del self.trait1.base
self.assertEqual(self._get_values(), (0, 2, 2))
del self.trait1.mod
self.assertEqual(self._get_values(), (0, 0, 0))
class TestTraitCounter(_TraitHandlerBase):
"""
Test for counter- Traits
"""
def setUp(self):
super().setUp()
self.traithandler.add(
"test1",
name="Test1",
trait_type='counter',
base=1,
mod=2,
min=-10,
max=10,
extra_val1="xvalue1",
extra_val2="xvalue2"
)
self.trait1 = self.traithandler.get("test1")
def _get_values(self):
return self.trait1.base, self.trait1.mod, self.trait1.actual
def test_init(self):
self.assertEqual(
self._get_dbstore("test1"),
{"name": "Test1",
"trait_type": 'counter',
"base": 1,
"mod": 2,
"min": -10,
"max": 10,
"extra_val1": "xvalue1",
"extra_val2": "xvalue2"
}
)
def test_actual(self):
"""Actual is current + mod, where current defaults to base"""
self.assertEqual(self._get_values(), (1, 2, 3))
self.trait1.base += 4
self.assertEqual(self._get_values(), (5, 2, 7))
self.trait1.mod -= 1
self.assertEqual(self._get_values(), (5, 1, 6))
def test_boundaries__minmax(self):
"""Test range"""
# should not exceed min/max values
self.trait1.base += 20
self.assertEqual(self._get_values(), (10, 2, 10))
self.trait1.base = 100
self.assertEqual(self._get_values(), (10, 2, 10))
self.trait1.base -= 40
self.assertEqual(self._get_values(), (-10, 2, -8))
self.trait1.base = -100
self.assertEqual(self._get_values(), (-10, 2, -8))
def test_boundaries__bigmod(self):
"""add a big mod"""
self.trait1.base = 5
self.trait1.mod = 100
self.assertEqual(self._get_values(), (5, 100, 10))
self.trait1.mod = -100
self.assertEqual(self._get_values(), (5, -100, -10))
def test_boundaries__change_boundaries(self):
"""Change boundaries after base/mod change"""
self.trait1.base = 5
self.trait1.mod = -100
self.trait1.min = -20
self.assertEqual(self._get_values(), (5, -100, -20))
self.trait1.mod = 100
self.trait1.max = 20
self.assertEqual(self._get_values(), (5, 100, 20))
def test_boundaries__base_literal(self):
"""Use the "base" literal makes the max become base+mod"""
self.trait1.base = 5
self.trait1.mod = 100
self.trait1.max = "base"
self.assertEqual(self._get_values(), (5, 100, 105))
def test_boundaries__disable(self):
"""Disable and re-enable boundaries"""
self.trait1.base = 5
self.trait1.mod = 100
del self.trait1.max
self.assertEqual(self.trait1.max, None)
del self.trait1.min
self.assertEqual(self.trait1.min, None)
self.trait1.base = 100
self.assertEqual(self._get_values(), (100, 100, 200))
self.trait1.base = -10
self.assertEqual(self._get_values(), (-10, 100, 90))
# re-activate boundaries
self.trait1.max = 15
self.trait1.min = 10
self.assertEqual(self._get_values(), (-10, 100, 15))
def test_boundaries__inverse(self):
"""Set inverse boundaries - limited by base"""
self.trait1.base = -10
self.trait1.mod = 100
self.trait1.min = 20 # will be set to base
self.assertEqual(self.trait1.min, -10)
self.trait1.max = -20
self.assertEqual(self.trait1.max, -10)
self.assertEqual(self._get_values(), (-10, 100, -10))
def test_current(self):
"""Modifying current value"""
self.trait1.current = 5
self.assertEqual(self._get_values(), (1, 2, 7))
self.trait1.current = 10
self.assertEqual(self._get_values(), (1, 2, 10))
self.trait1.current = 12
self.assertEqual(self._get_values(), (1, 2, 10))
def test_delete(self):
"""Deleting resets to default."""
del self.trait1.base
self.assertEqual(self._get_values(), (0, 2, 2))
del self.trait1.mod
self.assertEqual(self._get_values(), (0, 0, 0))
del self.trait1.min
del self.trait1.max
self.assertEqual(self.trait1.max, None)
self.assertEqual(self.trait1.min, None)
class TestTraitGauge(TestTraitCounter):
def setUp(self):
super().setUp()
self.traithandler.add( self.traithandler.add(
"test2", "test2",
name="Test2", name="Test1",
trait_type='numeric', trait_type='gauge',
) base=1,
self.assertEqual( mod=2,
self._get_dbstore("test2"), min=-10,
{"name": "Test2", max=10,
"trait_type": 'numeric', extra_val1="xvalue1",
"base": 0, extra_val2="xvalue2"
}
) )
self.trait1 = self.traithandler.get("test2")
def test_boundaries__change_boundaries(self):
"""Change boundaries after base/mod change"""
self.trait1.base = 5
self.trait1.mod = -100
self.trait1.min = -20
# from pudb import debugger;debugger.Debugger().set_trace()
self.assertEqual(self._get_values(), (5, -100, -20))
self.trait1.mod = 100
self.trait1.max = 20
self.assertEqual(self._get_values(), (5, 100, 20))
def test_boundaries__disable(self):
"""Disable and re-enable boundaries"""
self.trait1.base = 5
self.trait1.mod = 100
del self.trait1.max
self.assertEqual(self.trait1.max, None)
del self.trait1.min
self.assertEqual(self.trait1.min, None)
self.trait1.base = 100
# this won't change since current is not changed
self.assertEqual(self._get_values(), (100, 100, 10))
self.trait1.current = 150
self.assertEqual(self._get_values(), (100, 100, 150))
self.trait1.base = -10
self.assertEqual(self._get_values(), (-10, 100, 150))
# re-activate boundaries
self.trait1.max = 15
self.trait1.min = 10
self.assertEqual(self._get_values(), (-10, 100, 15))
def test_boundaries__inverse(self):
"""Set inverse boundaries - limited by base"""
self.trait1.base = -10
self.trait1.mod = 100
self.trait1.min = 20 # will be set to base
self.assertEqual(self.trait1.min, -10)
self.trait1.max = -20 # this is <base so ok
self.assertEqual(self.trait1.max, -20)
self.assertEqual(self._get_values(), (-10, 100, -10))
def test_current(self):
"""For a gauge, mod applies to base and not to current."""
self.trait1.current = 5
self.assertEqual(self._get_values(), (1, 2, 5))
self.trait1.current = 14
self.assertEqual(self._get_values(), (1, 2, 10))
self.trait1.current = -14
self.assertEqual(self._get_values(), (1, 2, -10))
class TestNumericTraitOperators(TestCase):
"""Test case for numeric magic method implementations."""
def setUp(self):
# direct instantiation for testing only; use TraitHandler in production
self.st = traits.NumericTrait({
'name': 'Strength',
'trait_type': 'numeric',
'base': 8,
})
self.at = traits.NumericTrait({
'name': 'Attack',
'trait_type': 'numeric',
'base': 4,
})
def test_trait__static(self): def tearDown(self):
self.traithandler.add( self.st, self.at = None, None
"test3",
name="Test3",
trait_type='static'
)
self.assertEqual(
self._get_dbstore("test3"),
{"name": "Test3",
"trait_type": 'static',
"base": 0,
"mod": 0,
}
)
def test_trait__counter(self): def test_pos_shortcut(self):
self.traithandler.add( """overridden unary + operator returns `actual` property"""
"test4", self.assertIn(type(+self.st), (float, int))
name="Test4", self.assertEqual(+self.st, self.st.actual)
trait_type='counter' self.assertEqual(+self.st, 8)
)
self.assertEqual( def test_add_traits(self):
self._get_dbstore("test4"), """test addition of `Trait` objects"""
{"name": "Test4", # two Trait objects
"trait_type": 'counter', self.assertEqual(self.st + self.at, 12)
"base": 0, # Trait and numeric
"mod": 0, self.assertEqual(self.st + 1, 9)
"current": 0, self.assertEqual(1 + self.st, 9)
"max_value": None,
"min_value": None, def test_sub_traits(self):
} """test subtraction of `Trait` objects"""
) # two Trait objects
self.assertEqual(self.st - self.at, 4)
# Trait and numeric
self.assertEqual(self.st - 1, 7)
self.assertEqual(10 - self.st, 2)
def test_mul_traits(self):
"""test multiplication of `Trait` objects"""
# between two Traits
self.assertEqual(self.st * self.at, 32)
# between Trait and numeric
self.assertEqual(self.at * 4, 16)
self.assertEqual(4 * self.at, 16)
def test_floordiv(self):
"""test floor division of `Trait` objects"""
# between two Traits
self.assertEqual(self.st // self.at, 2)
# between Trait and numeric
self.assertEqual(self.st // 2, 4)
self.assertEqual(18 // self.st, 2)
def test_comparisons_traits(self):
"""test equality comparison between `Trait` objects"""
self.assertNotEqual(self.st, self.at)
self.assertLess(self.at, self.st)
self.assertLessEqual(self.at, self.st)
self.assertGreater(self.st, self.at)
self.assertGreaterEqual(self.st, self.at)
def test_comparisons_numeric(self):
"""equality comparisons between `Trait` and numeric"""
self.assertEqual(self.st, 8)
self.assertEqual(8, self.st)
self.assertNotEqual(self.st, 0)
self.assertNotEqual(0, self.st)
self.assertLess(self.st, 10)
self.assertLess(0, self.st)
self.assertLessEqual(self.st, 8)
self.assertLessEqual(8, self.st)
self.assertLessEqual(self.st, 10)
self.assertLessEqual(0, self.st)
self.assertGreater(self.st, 0)
self.assertGreater(10, self.st)
self.assertGreaterEqual(self.st, 8)
self.assertGreaterEqual(8, self.st)
self.assertGreaterEqual(self.st, 0)
self.assertGreaterEqual(10, self.st)
def test_trait__gauge(self):
self.traithandler.add(
"test5",
name="Test5",
trait_type='gauge'
)
self.assertEqual(
self._get_dbstore("test5"),
{"name": "Test5",
"trait_type": 'gauge',
"base": 0,
"mod": 0,
"current": 0,
"max_value": None,
"min_value": None,
}
)
# #
# #
@ -451,93 +756,6 @@ class TestTraitNumeric(_TraitHandlerBase):
# with self.assertRaises(KeyError): # with self.assertRaises(KeyError):
# x = self.trait['preloaded'] # x = self.trait['preloaded']
# #
# class TraitOperatorsTestCase(TestCase):
# """Test case for numeric magic method implementations."""
# def setUp(self):
# # direct instantiation for testing only; use TraitHandler in production
# self.st = Trait({
# 'name': 'Strength',
# 'type': 'static',
# 'base': 8,
# })
# self.at = Trait({
# 'name': 'Attack',
# 'type': 'static',
# 'base': 4,
# })
#
# def tearDown(self):
# self.st, self.at = None, None
#
# def test_pos_shortcut(self):
# """overridden unary + operator returns `actual` property"""
# self.assertIn(type(+self.st), (float, int))
# self.assertEqual(+self.st, self.st.actual)
# self.assertEqual(+self.st, 8)
#
# def test_add_traits(self):
# """test addition of `Trait` objects"""
# # two Trait objects
# self.assertEqual(self.st + self.at, 12)
# # Trait and numeric
# self.assertEqual(self.st + 1, 9)
# self.assertEqual(1 + self.st, 9)
#
# def test_sub_traits(self):
# """test subtraction of `Trait` objects"""
# # two Trait objects
# self.assertEqual(self.st - self.at, 4)
# # Trait and numeric
# self.assertEqual(self.st - 1, 7)
# self.assertEqual(10 - self.st, 2)
#
# def test_mul_traits(self):
# """test multiplication of `Trait` objects"""
# # between two Traits
# self.assertEqual(self.st * self.at, 32)
# # between Trait and numeric
# self.assertEqual(self.at * 4, 16)
# self.assertEqual(4 * self.at, 16)
#
# def test_floordiv(self):
# """test floor division of `Trait` objects"""
# # between two Traits
# self.assertEqual(self.st // self.at, 2)
# # between Trait and numeric
# self.assertEqual(self.st // 2, 4)
# self.assertEqual(18 // self.st, 2)
#
# def test_comparisons_traits(self):
# """test equality comparison between `Trait` objects"""
# self.assertNotEqual(self.st, self.at)
# self.assertLess(self.at, self.st)
# self.assertLessEqual(self.at, self.st)
# self.assertGreater(self.st, self.at)
# self.assertGreaterEqual(self.st, self.at)
# # make st.actual = at.actual by modding at
# self.at.mod = 4
# self.assertEqual(self.st, self.at)
# self.assertGreaterEqual(self.st, self.at)
# self.assertLessEqual(self.st, self.at)
#
# def test_comparisons_numeric(self):
# """equality comparisons between `Trait` and numeric"""
# self.assertEqual(self.st, 8)
# self.assertEqual(8, self.st)
# self.assertNotEqual(self.st, 0)
# self.assertNotEqual(0, self.st)
# self.assertLess(self.st, 10)
# self.assertLess(0, self.st)
# self.assertLessEqual(self.st, 8)
# self.assertLessEqual(8, self.st)
# self.assertLessEqual(self.st, 10)
# self.assertLessEqual(0, self.st)
# self.assertGreater(self.st, 0)
# self.assertGreater(10, self.st)
# self.assertGreaterEqual(self.st, 8)
# self.assertGreaterEqual(8, self.st)
# self.assertGreaterEqual(self.st, 0)
# self.assertGreaterEqual(10, self.st)
# #
# #
# class CounterTraitTestCase(TestCase): # class CounterTraitTestCase(TestCase):

View file

@ -241,9 +241,11 @@ from django.conf import settings
from functools import total_ordering from functools import total_ordering
from evennia.utils.dbserialize import _SaverDict from evennia.utils.dbserialize import _SaverDict
from evennia.utils import logger from evennia.utils import logger
from evennia.utils.utils import inherits_from, class_from_module, list_to_string from evennia.utils.utils import (
inherits_from, class_from_module, list_to_string, percent)
# Available Trait classes.
# This way the user can easily supply their own. Each # This way the user can easily supply their own. Each
# class should have a class-property `trait_type` to # class should have a class-property `trait_type` to
# identify the Trait class. The default ones are "static", # identify the Trait class. The default ones are "static",
@ -346,33 +348,57 @@ class TraitHandler:
"""Return number of Traits registered with the handler""" """Return number of Traits registered with the handler"""
return len(self.trait_data) return len(self.trait_data)
def __setattr__(self, key, value): def __setattr__(self, trait_key, value):
"""Returns error message if trait objects are assigned directly.""" """
if key in ("trait_data", "_cache"): Returns error message if trait objects are assigned directly.
_SA(self, key, value)
Args:
trait_key (str): The Trait-key, like "hp".
value (any): Data to store.
"""
if trait_key in ("trait_data", "_cache"):
_SA(self, trait_key, value)
else: else:
trait_cls = self._get_trait_class(trait_key=trait_key)
valid_keys = list_to_string(list(trait_cls.data_keys.keys()), endsep="or")
raise TraitException( raise TraitException(
"Trait object not settable directly. Assign to one of " "Trait object not settable directly. "
f"`{key}.base`, `{key}.mod`, or `{key}.current` instead." f"Assign to {trait_key}.{valid_keys}."
) )
def __setitem__(self, key, value): def __setitem__(self, trait_key, value):
"""Returns error message if trait objects are assigned directly.""" """Returns error message if trait objects are assigned directly."""
return self.__setattr__(key, value) return self.__setattr__(trait_key, value)
def __getattr__(self, key): def __getattr__(self, trait_key):
"""Returns Trait instances accessed as attributes.""" """Returns Trait instances accessed as attributes."""
return self.get(key) return self.get(trait_key)
def __getitem__(self, key): def __getitem__(self, trait_key):
"""Returns `Trait` instances accessed as dict keys.""" """Returns `Trait` instances accessed as dict keys."""
return self.get(key) return self.get(trait_key)
def __repr__(self): def __repr__(self):
return "TraitHandler ({num} Trait(s) stored): {keys}".format( return "TraitHandler ({num} Trait(s) stored): {keys}".format(
num=len(self), keys=", ".join(self.all) num=len(self), keys=", ".join(self.all)
) )
def _get_trait_class(self, trait_type=None, trait_key=None):
"""
Helper to retrieve Trait class based on type (like "static")
or trait-key (like "hp").
"""
if not trait_type and trait_key:
try:
trait_type = self.trait_data[trait_key]["trait_type"]
except KeyError:
raise TraitException(f"Trait class for Trait {trait_key} could not be found.")
try:
return _TRAIT_CLASSES[trait_type]
except KeyError:
raise TraitException(f"Trait class for {trait_type} could not be found.")
@property @property
def all(self): def all(self):
""" """
@ -384,38 +410,35 @@ class TraitHandler:
""" """
return list(self.trait_data.keys()) return list(self.trait_data.keys())
def get(self, key): def get(self, trait_key):
""" """
Args: Args:
key (str): key from the traits dict containing config data. trait_key (str): key from the traits dict containing config data.
Returns: Returns:
(`Trait` or `None`): named Trait class or None if trait key (`Trait` or `None`): named Trait class or None if trait key
is not found in traits collection. is not found in traits collection.
""" """
trait = self._cache.get(key) trait = self._cache.get(trait_key)
if trait is None and key in self.trait_data: if trait is None and trait_key in self.trait_data:
trait_type = self.trait_data[key]["trait_type"] trait_type = self.trait_data[trait_key]["trait_type"]
try: trait_cls = self._get_trait_class(trait_type)
trait_cls = _TRAIT_CLASSES[trait_type] trait = self._cache[trait_key] = trait_cls(_GA(self, "trait_data")[trait_key])
except KeyError:
raise TraitException("Trait class for {trait_type} could not be found.")
trait = self._cache[key] = trait_cls(_GA(self, "trait_data")[key])
return trait return trait
def add(self, key, name=None, trait_type=DEFAULT_TRAIT_TYPE, force=True, **trait_properties): def add(self, trait_key, name=None, trait_type=DEFAULT_TRAIT_TYPE, force=True, **trait_properties):
""" """
Create a new Trait and add it to the handler. Create a new Trait and add it to the handler.
Args: Args:
key (str): This is the name of the property that will be made trait_key (str): This is the name of the property that will be made
available on this handler (example 'hp'). available on this handler (example 'hp').
name (str, optional): Name of the Trait, like "Health". If name (str, optional): Name of the Trait, like "Health". If
not given, will use `key` starting with a capital letter. not given, will use `trait_key` starting with a capital letter.
trait_type (str, optional): One of 'static', 'counter' or 'gauge'. trait_type (str, optional): One of 'static', 'counter' or 'gauge'.
force_add (bool): If set, create a new Trait even if a Trait with force_add (bool): If set, create a new Trait even if a Trait with
the same `key` already exists. the same `trait_key` already exists.
trait_properties (dict): These will all be use to initialize trait_properties (dict): These will all be use to initialize
the new trait. See the `properties` class variable on each the new trait. See the `properties` class variable on each
Trait class to see which are required. Trait class to see which are required.
@ -428,46 +451,46 @@ class TraitHandler:
""" """
# from evennia import set_trace;set_trace() # from evennia import set_trace;set_trace()
if key in self.trait_data: if trait_key in self.trait_data:
if force: if force:
self.remove(key) self.remove(trait_key)
else: else:
raise TraitException(f"Trait '{key}' already exists.") raise TraitException(f"Trait '{trait_key}' already exists.")
trait_class = _TRAIT_CLASSES.get(trait_type) trait_class = _TRAIT_CLASSES.get(trait_type)
if not trait_class: if not trait_class:
raise TraitException(f"Trait-type '{trait_type}' is invalid.") raise TraitException(f"Trait-type '{trait_type}' is invalid.")
trait_properties["name"] = key.title() if not name else name trait_properties["name"] = trait_key.title() if not name else name
trait_properties["trait_type"] = trait_type trait_properties["trait_type"] = trait_type
# this will raise exception if input is insufficient # this will raise exception if input is insufficient
trait_properties = trait_class.validate_input(trait_properties) trait_properties = trait_class.validate_input(trait_properties)
self.trait_data[key] = trait_properties self.trait_data[trait_key] = trait_properties
def remove(self, key): def remove(self, trait_key):
""" """
Remove a Trait from the handler's parent object. Remove a Trait from the handler's parent object.
Args: Args:
key (str): The name of the trait to remove. trait_key (str): The name of the trait to remove.
""" """
if key not in self.trait_data: if trait_key not in self.trait_data:
raise TraitException(f"Trait '{key}' not found.") raise TraitException(f"Trait '{trait_key}' not found.")
if key in self._cache: if trait_key in self._cache:
del self._cache[key] del self._cache[trait_key]
del self.trait_data[key] del self.trait_data[trait_key]
def clear(self): def clear(self):
""" """
Remove all Traits from the handler's parent object. Remove all Traits from the handler's parent object.
""" """
for key in self.all: for trait_key in self.all:
self.remove(key) self.remove(trait_key)
# Parent Trait class # Parent Trait class
@ -482,17 +505,18 @@ class Trait:
Note: Note:
See module docstring for configuration details. See module docstring for configuration details.
value
""" """
# this is the name used to refer to this trait when adding # this is the name used to refer to this trait when adding
# a new trait in the TraitHandler # a new trait in the TraitHandler
trait_type = "trait" trait_type = "trait"
# Keys required when creating a Trait of this type. This is a dict # Property kwargs settable when creating a Trait of this type. This is a
# of key: default. If a key must be given, use traits.TraitKeyRequired # dict of key: default. To indicate a mandatory kwarg and raise an error if
# as its value - this means the key must be explicitly set or # not given, set the default value to the `traits.MandatoryTraitKey` class.
# the trait will not be able to be created. # Apart from the keys given here, "name" and "trait_type" will also always
# Apart from the keys given here, "name" and "trait_type" will also # have to be a apart of the data.
# always have to be a apart of the data.
data_keys = {"value": None} data_keys = {"value": None}
# enable to set/retrieve other arbitrary properties on the Trait # enable to set/retrieve other arbitrary properties on the Trait
@ -698,7 +722,11 @@ class NumericTrait(Trait):
""" """
Base trait for all Traits based on numbers. This implements Base trait for all Traits based on numbers. This implements
number-comparisons, limits etc. It works on the 'base' property since this number-comparisons, limits etc. It works on the 'base' property since this
makes more sense for child classes. makes more sense for child classes. For this base class, the .actual
property is just an alias of .base.
actual = base
""" """
@ -803,7 +831,7 @@ class NumericTrait(Trait):
@property @property
def actual(self): def actual(self):
"The actual value of the trait" "The actual value of the trait"
return self.base_mod_base() return self.base
@property @property
def base(self): def base(self):
@ -815,14 +843,21 @@ class NumericTrait(Trait):
""" """
return self._data["base"] return self._data["base"]
@base.setter
def base(self, value):
"""Base must be a numerical value."""
if type(value) in (int, float):
self._data["base"] = value
# Implementation of the respective Trait types # Implementation of the respective Trait types
class StaticTrait(NumericTrait): class StaticTrait(NumericTrait):
""" """
Static Trait. This has a modification value. Static Trait. This has a modification value.
actual = base + mod
""" """
trait_type = "static" trait_type = "static"
@ -858,18 +893,28 @@ class CounterTrait(NumericTrait):
Counter Trait. Counter Trait.
This includes modifications and min/max limits as well as the notion of a This includes modifications and min/max limits as well as the notion of a
current value. The value can also be reset to the base value. current value. The value can also be reset to the base value.
min/unset base max/unset
|-----------------------|----------X-------------------|
actual
= current
+ mod
- actual = current + mod, starts at base
- if min or max is None, there is no upper/lower bound (default)
- if max is set to "base", max will be set as base changes.
""" """
trait_type = "counter" trait_type = "counter"
# current starts equal to base.
data_keys = { data_keys = {
"base": 0, "base": 0,
"mod": 0, "mod": 0,
"current": 0, "min": None,
"min_value": None, "max": None,
"max_value": None,
} }
# Helpers # Helpers
@ -886,7 +931,7 @@ class CounterTrait(NumericTrait):
"""Ensures that incoming value falls within trait's range.""" """Ensures that incoming value falls within trait's range."""
if self.min is not None and value <= self.min: if self.min is not None and value <= self.min:
return self.min return self.min
if self._data["max_value"] == "base" and value >= self.mod + self.base: if self._data["max"] == "base" and value >= self.mod + self.base:
return self.mod + self.base return self.mod + self.base
if self.max is not None and value >= self.max: if self.max is not None and value >= self.max:
return self.max return self.max
@ -894,42 +939,39 @@ class CounterTrait(NumericTrait):
# properties # properties
@property
def actual(self):
"The actual value of the Trait"
return self._mod_current()
@property @property
def base(self): def base(self):
return self._data["base"] return self._data["base"]
@base.setter @base.setter
def base(self, amount): def base(self, amount):
if self._data.get("max_value", None) == "base": if self._data.get("max", None) == "base":
self._data["base"] = amount self._data["base"] = amount
if type(amount) in (int, float): if type(amount) in (int, float):
self._data["base"] = self._enforce_bounds(amount) self._data["base"] = self._enforce_bounds(amount)
@property @property
def min(self): def min(self):
return self._data["min_value"] return self._data["min"]
@min.setter @min.setter
def min(self, amount): def min(self, value):
if amount is None: if value is None:
self._data["min_value"] = amount self._data["min"] = value
elif type(amount) in (int, float): elif type(value) in (int, float):
self._data["min_value"] = amount if amount < self.base else self.base if self.max is not None:
value = min(self.max, value)
self._data["min"] = value if value < self.base else self.base
@property @property
def max(self): def max(self):
if self._data["max_value"] == "base": if self._data["max"] == "base":
return self._mod_base() return self._mod_base()
return self._data["max_value"] return self._data["max"]
@max.setter @max.setter
def max(self, value): def max(self, value):
"""The maximum value of the `Trait`. """The maximum value of the trait.
Note: Note:
This property may be set to the string literal 'base'. This property may be set to the string literal 'base'.
@ -937,20 +979,27 @@ class CounterTrait(NumericTrait):
`mod`+`base` properties. `mod`+`base` properties.
""" """
if value == "base" or value is None: if value == "base" or value is None:
self._data["max_value"] = value self._data["max"] = value
elif type(value) in (int, float): elif type(value) in (int, float):
self._data["max_value"] = value if value > self.base else self.base if self.min is not None:
value = max(self.min, value)
self._data["max"] = value if value > self.base else self.base
@property @property
def current(self): def current(self):
"""The `current` value of the `Trait`.""" """The `current` value of the `Trait`."""
return self._data.get("current", self.base) return self._enforce_bounds(self._data.get("current", self.base))
@current.setter @current.setter
def current(self, value): def current(self, value):
if type(value) in (int, float): if type(value) in (int, float):
self._data["current"] = self._enforce_bounds(value) self._data["current"] = self._enforce_bounds(value)
@property
def actual(self):
"The actual value of the Trait"
return self._mod_current()
def reset_mod(self): def reset_mod(self):
"""Clears any mod value to 0.""" """Clears any mod value to 0."""
self.mod = 0 self.mod = 0
@ -959,76 +1008,141 @@ class CounterTrait(NumericTrait):
"""Resets `current` property equal to `base` value.""" """Resets `current` property equal to `base` value."""
self.current = self.base self.current = self.base
def percent(self): def percent(self, formatting="{:3.1f}%"):
"""Returns the value formatted as a percentage.""" """
if self.max: Return the current value as a percentage.
return "{:3.1f}%".format(self.current * 100.0 / self.max)
elif self.base != 0: Args:
return "{:3.1f}%".format(self.current * 100.0 / self._mod_base()) formatting (str, optional): Should contain a
# if we get to this point, it's may be a divide by zero situation format-tag which will receive the value. If
return "100.0%" this is set to None, the raw float will be
returned.
Returns:
float or str: Depending of if a `formatting` string
is supplied or not.
"""
return percent(self.current, self.min, self.max, formatting=formatting)
class GaugeTrait(CounterTrait): class GaugeTrait(CounterTrait):
""" """
Gauge Trait. Gauge Trait.
This emulates a gauge-meter that can be reset. This emulates a gauge-meter that empties from a base+mod value.
min/0 max=base + mod
|-----------------------X---------------------------|
actual
= current
- min defaults to 0
- max value is always base + mad
- .max is an alias of .base
- actual = current and varies from min to max.
""" """
trait_type = "gauge" trait_type = "gauge"
# same as Counter, here for easy reference # same as Counter, here for easy reference
# current starts out equal to base
data_keys = { data_keys = {
"base": 0, "base": 0,
"mod": 0, "mod": 0,
"current": 0, "min": None,
"min_value": None,
"max_value": None,
} }
def _mod_base(self):
"""Calculate adding base and modifications"""
return self._enforce_bounds(self.mod + self.base)
def _mod_current(self):
"""Calculate the current value"""
return self._enforce_bounds(self.current)
def _enforce_bounds(self, value):
"""Ensures that incoming value falls within trait's range."""
if self.min is not None and value <= self.min:
return self.min
return min(self.mod + self.base, value)
def __str__(self): def __str__(self):
status = "{actual:4} / {base:4}".format(actual=self.actual, base=self.base) status = "{actual:4} / {base:4}".format(actual=self.actual, base=self.base)
return "{name:12} {status} ({mod:+3})".format(name=self.name, status=status, mod=self.mod) return "{name:12} {status} ({mod:+3})".format(name=self.name, status=status, mod=self.mod)
@property @property
def actual(self): def base(self):
"The actual value of the trait" return self._data["base"]
return self.current
@base.setter
def base(self, value):
if type(value) in (int, float):
self._data["base"] = self._enforce_bounds(value)
@property @property
def mod(self): def mod(self):
"""The trait's modifier."""
return self._data["mod"] return self._data["mod"]
@mod.setter @mod.setter
def mod(self, amount): def mod(self, amount):
if type(amount) in (int, float): if type(amount) in (int, float):
self._data["mod"] = amount self._data["mod"] = amount
delta = amount - self._data["mod"]
if delta >= 0: @property
# apply increases to current def min(self):
self.current = self._enforce_bounds(self.current + delta) return self._data["min"]
else:
# but not decreases, unless current goes out of range @min.setter
self.current = self._enforce_bounds(self.current) def min(self, value):
if value is None:
self._data["min"] = self.data_keys['min']
elif type(value) in (int, float):
self._data["min"] = min(self.value, self.base + self.mod)
@property
def max(self):
"The max is always base + mod."
return self.base + self.mod
@max.setter
def max(self, value):
raise TraitException("The .max property is not settable "
"on GaugeTraits. Set .base instead.")
@property @property
def current(self): def current(self):
"""The `current` value of the `Trait`.""" """The `current` value of the gauge."""
return self._data.get("current", self._mod_base()) return self._enforce_bounds(self._data.get("current", self._mod_base()))
@current.setter @current.setter
def current(self, value): def current(self, value):
if type(value) in (int, float): if type(value) in (int, float):
self._data["current"] = self._enforce_bounds(value) self._data["current"] = self._enforce_bounds(value)
def fill_gauge(self): @property
"""Adds the `mod`+`base` to the `current` value. def actual(self):
"The actual value of the trait"
return self.current
Note: def percent(self, formatting="{:3.1f}%"):
Will honor the upper bound if set. """
Return the current value as a percentage.
Args:
formatting (str, optional): Should contain a
format-tag which will receive the value. If
this is set to None, the raw float will be
returned.
Returns:
float or str: Depending of if a `formatting` string
is supplied or not.
"""
return percent(self.current, self.min, self.max, formatting=formatting)
def fill_gauge(self):
"""
Fills the gauge to its maximum allowed by base + mod
""" """
self.current = self._enforce_bounds(self.current + self._mod_base()) self.current = self.base + self.mod

View file

@ -312,3 +312,21 @@ class TestFormatGrid(TestCase):
self.assertEqual(len(rows), 8) self.assertEqual(len(rows), 8)
for element in elements: for element in elements:
self.assertTrue(element in "\n".join(rows), f"element {element} is missing.") self.assertTrue(element in "\n".join(rows), f"element {element} is missing.")
class TestPercent(TestCase):
"""
Test the utils.percentage function.
"""
def test_ok_input(self):
result = utils.percentage(3, 0, 10)
self.assertEqual(result, "30.0%")
result = utils.percentage(2.5, 5, 10, formatting=None)
self.assertEqual(result, 50.0)
# min==max we set to 100%
self.assertEqual(utils.percentage(4, 5, 5), "100.0%")
def test_bad_input(self):
self.assertRaises(RuntimeError):
utils.percentage(3, 10, 1)

View file

@ -1687,6 +1687,48 @@ def format_table(table, extra_space=1):
return ftable return ftable
def percent(self, value, minval, maxval, formatting="{:3.1f}%"):
"""
Get a value in an interval as a percentage of its position
in that interval. This also understands negative numbers.
Args:
value (number): This should be a value minval<=value<=maxval.
minval (number): Smallest value in interval.
maxval (number): Biggest value in interval.
formatted (str, optional): This is a string that should
accept one formatting tag. This will receive the
current value as a percentage. If None, the
raw float will be returned instead.
Returns:
str or float: The formatted value or the raw percentage
as a float.
Raises:
RuntimeError: If min/max does not make sense.
Notes:
We handle the case of minval==maxval because we may see this case and
don't want to raise exceptions unnecessarily. In that case we return
100%.
"""
if minval > maxval:
raise RuntimeError("The minimum value must be <= the max value.")
# constrain value to interval
value = min(max(minval, value), maxval)
# these should both be >0
dpart = value - minval
dfull = maxval - minval
try:
result = (dpart / dfull) * 100.0
except ZeroDivisionError:
# this means minval == maxval
result = 100.0
if not isinstance(formatting, str):
return result
return formatting.format(result)
import functools # noqa import functools # noqa