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"
with self.assertRaises(traits.TraitException):
self.traithandler["foo"] = "bar"
with self.assertRaises(traits.TraitException):
self.traithandler.test1 = "foo"
def test_getting(self):
"Test we are getting data from the dbstore"
@ -294,74 +296,377 @@ class TraitTest(_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(
"test2",
name="Test2",
trait_type='numeric',
)
self.assertEqual(
self._get_dbstore("test2"),
{"name": "Test2",
"trait_type": 'numeric',
"base": 0,
}
name="Test1",
trait_type='gauge',
base=1,
mod=2,
min=-10,
max=10,
extra_val1="xvalue1",
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):
self.traithandler.add(
"test3",
name="Test3",
trait_type='static'
)
self.assertEqual(
self._get_dbstore("test3"),
{"name": "Test3",
"trait_type": 'static',
"base": 0,
"mod": 0,
}
)
def tearDown(self):
self.st, self.at = None, None
def test_trait__counter(self):
self.traithandler.add(
"test4",
name="Test4",
trait_type='counter'
)
self.assertEqual(
self._get_dbstore("test4"),
{"name": "Test4",
"trait_type": 'counter',
"base": 0,
"mod": 0,
"current": 0,
"max_value": None,
"min_value": 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)
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):
# 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):

View file

@ -241,9 +241,11 @@ from django.conf import settings
from functools import total_ordering
from evennia.utils.dbserialize import _SaverDict
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
# class should have a class-property `trait_type` to
# identify the Trait class. The default ones are "static",
@ -346,33 +348,57 @@ class TraitHandler:
"""Return number of Traits registered with the handler"""
return len(self.trait_data)
def __setattr__(self, key, value):
"""Returns error message if trait objects are assigned directly."""
if key in ("trait_data", "_cache"):
_SA(self, key, value)
def __setattr__(self, trait_key, value):
"""
Returns error message if trait objects are assigned directly.
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:
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(
"Trait object not settable directly. Assign to one of "
f"`{key}.base`, `{key}.mod`, or `{key}.current` instead."
"Trait object not settable directly. "
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."""
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."""
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."""
return self.get(key)
return self.get(trait_key)
def __repr__(self):
return "TraitHandler ({num} Trait(s) stored): {keys}".format(
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
def all(self):
"""
@ -384,38 +410,35 @@ class TraitHandler:
"""
return list(self.trait_data.keys())
def get(self, key):
def get(self, trait_key):
"""
Args:
key (str): key from the traits dict containing config data.
trait_key (str): key from the traits dict containing config data.
Returns:
(`Trait` or `None`): named Trait class or None if trait key
is not found in traits collection.
"""
trait = self._cache.get(key)
if trait is None and key in self.trait_data:
trait_type = self.trait_data[key]["trait_type"]
try:
trait_cls = _TRAIT_CLASSES[trait_type]
except KeyError:
raise TraitException("Trait class for {trait_type} could not be found.")
trait = self._cache[key] = trait_cls(_GA(self, "trait_data")[key])
trait = self._cache.get(trait_key)
if trait is None and trait_key in self.trait_data:
trait_type = self.trait_data[trait_key]["trait_type"]
trait_cls = self._get_trait_class(trait_type)
trait = self._cache[trait_key] = trait_cls(_GA(self, "trait_data")[trait_key])
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.
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').
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'.
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
the new trait. See the `properties` class variable on each
Trait class to see which are required.
@ -428,46 +451,46 @@ class TraitHandler:
"""
# from evennia import set_trace;set_trace()
if key in self.trait_data:
if trait_key in self.trait_data:
if force:
self.remove(key)
self.remove(trait_key)
else:
raise TraitException(f"Trait '{key}' already exists.")
raise TraitException(f"Trait '{trait_key}' already exists.")
trait_class = _TRAIT_CLASSES.get(trait_type)
if not trait_class:
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
# this will raise exception if input is insufficient
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.
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:
raise TraitException(f"Trait '{key}' not found.")
if trait_key not in self.trait_data:
raise TraitException(f"Trait '{trait_key}' not found.")
if key in self._cache:
del self._cache[key]
del self.trait_data[key]
if trait_key in self._cache:
del self._cache[trait_key]
del self.trait_data[trait_key]
def clear(self):
"""
Remove all Traits from the handler's parent object.
"""
for key in self.all:
self.remove(key)
for trait_key in self.all:
self.remove(trait_key)
# Parent Trait class
@ -482,17 +505,18 @@ class Trait:
Note:
See module docstring for configuration details.
value
"""
# this is the name used to refer to this trait when adding
# a new trait in the TraitHandler
trait_type = "trait"
# Keys required when creating a Trait of this type. This is a dict
# of key: default. If a key must be given, use traits.TraitKeyRequired
# as its value - this means the key must be explicitly set or
# the trait will not be able to be created.
# Apart from the keys given here, "name" and "trait_type" will also
# always have to be a apart of the data.
# Property kwargs settable when creating a Trait of this type. This is a
# dict of key: default. To indicate a mandatory kwarg and raise an error if
# not given, set the default value to the `traits.MandatoryTraitKey` class.
# Apart from the keys given here, "name" and "trait_type" will also always
# have to be a apart of the data.
data_keys = {"value": None}
# 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
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
def actual(self):
"The actual value of the trait"
return self.base_mod_base()
return self.base
@property
def base(self):
@ -815,14 +843,21 @@ class NumericTrait(Trait):
"""
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
class StaticTrait(NumericTrait):
"""
Static Trait. This has a modification value.
actual = base + mod
"""
trait_type = "static"
@ -858,18 +893,28 @@ class CounterTrait(NumericTrait):
Counter Trait.
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"
# current starts equal to base.
data_keys = {
"base": 0,
"mod": 0,
"current": 0,
"min_value": None,
"max_value": None,
"min": None,
"max": None,
}
# Helpers
@ -886,7 +931,7 @@ class CounterTrait(NumericTrait):
"""Ensures that incoming value falls within trait's range."""
if self.min is not None and value <= 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
if self.max is not None and value >= self.max:
return self.max
@ -894,42 +939,39 @@ class CounterTrait(NumericTrait):
# properties
@property
def actual(self):
"The actual value of the Trait"
return self._mod_current()
@property
def base(self):
return self._data["base"]
@base.setter
def base(self, amount):
if self._data.get("max_value", None) == "base":
self._data["base"] = amount
if self._data.get("max", None) == "base":
self._data["base"] = amount
if type(amount) in (int, float):
self._data["base"] = self._enforce_bounds(amount)
@property
def min(self):
return self._data["min_value"]
return self._data["min"]
@min.setter
def min(self, amount):
if amount is None:
self._data["min_value"] = amount
elif type(amount) in (int, float):
self._data["min_value"] = amount if amount < self.base else self.base
def min(self, value):
if value is None:
self._data["min"] = value
elif type(value) in (int, float):
if self.max is not None:
value = min(self.max, value)
self._data["min"] = value if value < self.base else self.base
@property
def max(self):
if self._data["max_value"] == "base":
if self._data["max"] == "base":
return self._mod_base()
return self._data["max_value"]
return self._data["max"]
@max.setter
def max(self, value):
"""The maximum value of the `Trait`.
"""The maximum value of the trait.
Note:
This property may be set to the string literal 'base'.
@ -937,20 +979,27 @@ class CounterTrait(NumericTrait):
`mod`+`base` properties.
"""
if value == "base" or value is None:
self._data["max_value"] = value
self._data["max"] = value
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
def current(self):
"""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
def current(self, value):
if type(value) in (int, float):
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):
"""Clears any mod value to 0."""
self.mod = 0
@ -959,76 +1008,141 @@ class CounterTrait(NumericTrait):
"""Resets `current` property equal to `base` value."""
self.current = self.base
def percent(self):
"""Returns the value formatted as a percentage."""
if self.max:
return "{:3.1f}%".format(self.current * 100.0 / self.max)
elif self.base != 0:
return "{:3.1f}%".format(self.current * 100.0 / self._mod_base())
# if we get to this point, it's may be a divide by zero situation
return "100.0%"
def percent(self, formatting="{:3.1f}%"):
"""
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)
class GaugeTrait(CounterTrait):
"""
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"
# same as Counter, here for easy reference
# current starts out equal to base
data_keys = {
"base": 0,
"mod": 0,
"current": 0,
"min_value": None,
"max_value": None,
"min": 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):
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)
@property
def actual(self):
"The actual value of the trait"
return self.current
def base(self):
return self._data["base"]
@base.setter
def base(self, value):
if type(value) in (int, float):
self._data["base"] = self._enforce_bounds(value)
@property
def mod(self):
"""The trait's modifier."""
return self._data["mod"]
@mod.setter
def mod(self, amount):
if type(amount) in (int, float):
self._data["mod"] = amount
delta = amount - self._data["mod"]
if delta >= 0:
# apply increases to current
self.current = self._enforce_bounds(self.current + delta)
else:
# but not decreases, unless current goes out of range
self.current = self._enforce_bounds(self.current)
@property
def min(self):
return self._data["min"]
@min.setter
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
def current(self):
"""The `current` value of the `Trait`."""
return self._data.get("current", self._mod_base())
"""The `current` value of the gauge."""
return self._enforce_bounds(self._data.get("current", self._mod_base()))
@current.setter
def current(self, value):
if type(value) in (int, float):
self._data["current"] = self._enforce_bounds(value)
def fill_gauge(self):
"""Adds the `mod`+`base` to the `current` value.
@property
def actual(self):
"The actual value of the trait"
return self.current
Note:
Will honor the upper bound if set.
def percent(self, formatting="{:3.1f}%"):
"""
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)
for element in elements:
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
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