From 46e6477e1e266aca3c2fabcfa8b7567e97a39103 Mon Sep 17 00:00:00 2001 From: Nelson Moore Date: Wed, 25 Sep 2024 12:27:43 -0400 Subject: [PATCH] fix(entity.get_attr_dict()): falsey attr vals - fix get_attr_dict() method so that it properly returns simple entity attributes with 'falsey' values - refactor get_attr_dict() to use Entity's - add tests for get_attr_dict() to test_001entity.py - formatting --- python/pyproject.toml | 4 +- python/src/bento_meta/entity.py | 185 +++++++++++++++----------------- python/tests/test_001entity.py | 130 ++++++++++++++-------- 3 files changed, 174 insertions(+), 145 deletions(-) diff --git a/python/pyproject.toml b/python/pyproject.toml index 5fd505a..b493c97 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "bento-meta" -version = "0.2.7" +version = "0.2.8" description = "Python drivers for Bento Metamodel Database" authors = [ { name="Mark A. Jensen", email = "mark.jensen@nih.gov"}, @@ -20,7 +20,7 @@ classifiers = [ [tool.poetry] name = "bento-meta" -version = "0.2.7" +version = "0.2.8" description = "Python drivers for Bento Metamodel Database" authors = [ "Mark A. Jensen ", diff --git a/python/src/bento_meta/entity.py b/python/src/bento_meta/entity.py index c8d4e4e..ce11b9c 100644 --- a/python/src/bento_meta/entity.py +++ b/python/src/bento_meta/entity.py @@ -2,25 +2,25 @@ bento_meta.entity ================= -This module contains -* `Entity`, the base class for metamodel objects, -* the `CollValue` class to manage collection-valued attributes, and +This module contains +* `Entity`, the base class for metamodel objects, +* the `CollValue` class to manage collection-valued attributes, and * the `ArgError` exception. """ -from collections import UserDict -# from pdb import set_trace +from __future__ import annotations + +from collections import UserDict from warnings import warn class ArgError(Exception): - """Exception for method argument errors""" + """Exception for method argument errors.""" - pass - -class Entity(object): - """Base class for all metamodel objects. +class Entity: + """ + Base class for all metamodel objects. Entity contains all the magic for metamodel objects such as `bento_meta.objects.Node` and 'bento_meta.object.Edge`. It will rarely @@ -79,14 +79,16 @@ class Entity(object): versioning_on = False def __init__(self, init=None): - """Entity constructor. Always called by subclasses. + """ + Entity constructor. Always called by subclasses. + .. py:function:: Node(init) :param dict init: A dict of attribute names and values. Undeclared attributes are ignored. :param neo4j.graph.Node init: a `neo4j.graph.Node` object to be stored as a model object. :param `bento_meta.Entity` init: an Entity (of matching subclass). Used to duplicate another model object. """ if not set(type(self).attspec.values()) <= set( - ["simple", "object", "collection"] + ["simple", "object", "collection"], ): raise ArgError("unknown attribute type in attspec") @@ -126,8 +128,10 @@ def mapspec(cls): @classmethod def versioning(cls, on=None): - """Get or set whether versioning is applied to object manipulations - :param boolean on: True, apply versioning. False, do not. + """ + Get or set whether versioning is applied to object manipulations. + + :param boolean on: True, apply versioning. False, do not. """ if on is None: return cls.versioning_on @@ -136,9 +140,12 @@ def versioning(cls, on=None): @classmethod def set_version_count(cls, ct): - """Set the integer version counter. This will usually be accessed via a - `bento_meta.Model` instance - :param int ct: Set version counter to this value + """ + Set the integer version counter. + + This will usually be accessed via a `bento_meta.Model` instance. + + :param int ct: Set version counter to this value """ if not isinstance(ct, int) or ct < 0: raise ArgError("arg must be a positive integer") @@ -154,21 +161,24 @@ def default(cls, propname): # @classmethod def get_by_id(self, id): - """Get an object from the db with the id attribute (not the Neo4j id). Returns a new object. + """ + Get an object from the db with the id attribute (not the Neo4j id). + + Returns a new object. :param string id: value of id for desired object """ if self.object_map: - print(" > now in entity.get_by_id where self is {}".format(self)) - print(" > and class is {}".format(self.__class__)) + print(f" > now in entity.get_by_id where self is {self}") + print(f" > and class is {self.__class__}") return self.object_map.get_by_id(self, id) else: print(" _NO_ cls.object_map detected") - pass @property def dirty(self): - """Flag whether this instance has been changed since retrieval from - the database + """ + Flag whether this instance has been changed since retrieval from the database. + Set to -1, ensure that the next time an attribute is accessed, the instance will retrieve itself from the database. """ @@ -193,7 +203,8 @@ def object_map(self): @property def belongs(self): - """Dict that stores information on the owners (referents) of this instance + """ + Dict that stores information on the owners (referents) of this instance in the model """ return self.pvt["belongs"] @@ -222,23 +233,18 @@ def set_with_node(self, init): def set_with_entity(self, ent): if not isinstance(self, type(ent)): raise ArgError( - "class mismatch: I am a {slf}, but arg is a {ent}".format( - slf=type(self).__name__, ent=type(ent).__name__ - ) + f"class mismatch: I am a {type(self).__name__}, but arg is a {type(ent).__name__}", ) for k in type(self).attspec: atts = type(self).attspec[k] if k == "_next" or k == "_prev": break - if atts == "simple": - setattr(self, k, getattr(ent, k)) - elif atts == "object": + if atts == "simple" or atts == "object": setattr(self, k, getattr(ent, k)) elif atts == "collection": setattr(self, k, CollValue(getattr(ent, k), owner=self, owner_key=k)) - pass else: - raise RuntimeError("unknown attribute type '{atts}'".format(atts=atts)) + raise RuntimeError(f"unknown attribute type '{atts}'") for okey in ent.belongs: self.belongs[okey] = ent.belongs[okey] self.neoid = ent.neoid @@ -265,9 +271,7 @@ def __getattr__(self, name): return self.__dict__[name] else: raise AttributeError( - "get: attribute '{name}' neither private nor declared for subclass {cls}".format( - name=name, cls=type(self).__name__ - ) + f"get: attribute '{name}' neither private nor declared for subclass {type(self).__name__}", ) def __setattr__(self, name, value): @@ -284,9 +288,7 @@ def __setattr__(self, name, value): self._set_declared_attr(name, value) else: raise AttributeError( - "set: attribute '{name}' neither private nor declared for subclass {cls}".format( - name=name, cls=type(self).__name__ - ) + f"set: attribute '{name}' neither private nor declared for subclass {type(self).__name__}", ) def version_me(setattr_func): @@ -361,7 +363,7 @@ def _set_declared_attr(self, name, value): d[getattr(v, type(v).mapspec()["key"])] = v value = CollValue(d, owner=self, owner_key=name) else: - raise RuntimeError("unknown attspec value '{}'".format(atts)) + raise RuntimeError(f"unknown attspec value '{atts}'") self.dirty = 1 self.__dict__[name] = value @@ -377,45 +379,42 @@ def _check_value(self, att, value): spec = type(self).attspec[att] try: if spec == "simple": - if not ( - isinstance(value, int) - or isinstance(value, str) - or isinstance(value, float) - or isinstance(value, bool) - or value is None + if ( + not (isinstance(value, (bool, float, int, str))) + and value is not None ): raise ArgError( - "value for key '{att}' is not a simple scalar".format(att=att) + f"value for key '{att}' is not a simple scalar", ) elif spec == "object": if not (isinstance(value, Entity) or value is None): raise ArgError( - "value for key '{att}' is not an Entity subclass".format( - att=att - ) + f"value for key '{att}' is not an Entity subclass", ) elif spec == "collection": if not (isinstance(value, (dict, list, CollValue))): raise AttributeError( - "value for key '{att}' is not a dict,list, or CollValue".format( - att=att - ) + f"value for key '{att}' is not a dict,list, or CollValue", ) else: raise ArgError( - "unknown attribute type '{type}' for attribute '{att}' in attspec".format( - type=spec, att=att - ) + f"unknown attribute type '{spec}' for attribute '{att}' in attspec", ) except Exception: raise def dup(self): - """Duplicate the object, but not too deeply. Mainly for use of the versioning machinery.""" + """ + Duplicate the object, but not too deeply. + + Mainly for use of the versioning machinery. + """ return type(self)(self) def delete(self): - """Delete self from the database. + """ + Delete self from the database. + If versioning is active, this will 'deprecate' the entity, but not actually remove it from the db """ if self.versioning_on and self.versioned: @@ -423,9 +422,7 @@ def delete(self): self._to = type(self).version_count else: warn( - "delete - current version count {vct} is <= entity's _to attribute".format( - vct=type(self).version_count - ) + f"delete - current version count {type(self).version_count} is <= entity's _to attribute", ) else: # unlink from other entities @@ -438,7 +435,9 @@ def delete(self): setattr(owner, att[0], None) def dget(self, refresh=False): - """Update self from the database + """ + Update self from the database. + :param boolean refresh: if True, force a retrieval from db; if False, retrieve from cache; don't disrupt changes already made @@ -449,7 +448,9 @@ def dget(self, refresh=False): pass def dput(self): - """Put self to the database. + """ + Put self to the database. + This will set the `neoid` property if not yet set. """ if type(self).object_map: @@ -466,7 +467,7 @@ def rm(self, force): @classmethod def attr_doc(cls): - """Create a docstring for declared attributes on class as configured""" + """Create a docstring for declared attributes on class as configured.""" def str_for_obj(thing): if isinstance(thing, set): @@ -479,20 +480,18 @@ def str_for_obj(thing): first += " Posesses the following attributes:" else: first += " Posesses all :class:`Entity` attributes, plus the following:" - doc = """\ -.. py:class:: {cls} + doc = f"""\ +.. py:class:: {cls.__name__} -{desc} +{first} -""".format( - desc=first, cls=cls.__name__ - ) +""" for att in [x for x in cls.attspec_ if cls.attspec[x] == "simple"]: doc += """\ .. py:attribute:: {att} :type: simple """.format( - att=cls.__name__.lower() + "." + att + att=cls.__name__.lower() + "." + att, ) for att in [x for x in cls.attspec_ if cls.attspec[x] == "object"]: doc += """\ @@ -514,30 +513,26 @@ def str_for_obj(thing): return doc def get_label(self) -> str: - """returns type of entity as label""" + """Return type of entity as label.""" return self.mapspec_["label"] - def get_attr_dict(self): + def get_attr_dict(self) -> dict[str, str]: """ - Returns given entity's set attributes as a dictionary. + Return simple attributes set for Entity as a dict. - Dictionary of attributes used as the parameters - of methods with the write_txn decorator. + Attr values are converted to strings. Doesn't include attrs with None values. """ - attr_dict = {} - for key, val in vars(self).items(): - if ( - val - and val is not None - and key != "pvt" - and isinstance(val, (str, int, float, complex, bool)) - ): - attr_dict[key] = str(val) - return attr_dict + return { + k: str(getattr(self, k)) + for k in self.attspec + if self.attspec[k] == "simple" and getattr(self, k) is not None + } class CollValue(UserDict): - """A UserDict for housing Entity collection attributes. + """ + A UserDict for housing Entity collection attributes. + This class contains a hook for recording the Entity that owns the value that is being set. The value is marked as belonging to the *containing object*, not this collection object. @@ -556,12 +551,12 @@ def __init__(self, init=None, *, owner, owner_key): @property def owner(self): - """The entity instance of which this collection is an attribute""" + """The entity instance of which this collection is an attribute.""" return self.__dict__["__owner"] @property def owner_key(self): - """The attribute name of this collection on the `owner`""" + """The attribute name of this collection on the `owner`.""" return self.__dict__["__owner_key"] def version_me(setitem_func): @@ -571,7 +566,6 @@ def _version_set_collvalue_item(self, name, value): if not self.owner.versioned: return setitem_func(self, name, value) elif (Entity.version_count > self.owner._from) and (self.owner._to is None): - pass # dup becomes the "old" object and self the "new": dup = self.owner.dup() dup._to = Entity.version_count @@ -595,9 +589,9 @@ def _version_set_collvalue_item(self, name, value): owner = self.owner.belongs[okey] (oid, *att) = okey if len(att) == 2: - getattr(owner, att[0])[ - att[1] - ] = self.owner # this dups the owning entity if nec + getattr(owner, att[0])[att[1]] = ( + self.owner + ) # this dups the owning entity if nec else: setattr(owner, att[0], self.owner) if owner._prev: @@ -613,9 +607,7 @@ def _version_set_collvalue_item(self, name, value): def __setitem__(self, name, value): if not isinstance(value, Entity): raise ArgError( - "a collection-valued attribute can only accept Entity members, not '{tipe}'s".format( - tipe=type(value) - ) + f"a collection-valued attribute can only accept Entity members, not '{type(value)}'s", ) if name in self: oldval = self.data.get(name) @@ -634,7 +626,7 @@ def __setitem__(self, name, value): def __getitem__(self, name): if name not in self.data: - return + return None if self.data[name].dirty < 0: self.data[name].dget() return self.data[name] @@ -642,4 +634,3 @@ def __getitem__(self, name): def __delitem__(self, name): self[name] == None # trigger __setitem__ super().__delitem__(name) - return diff --git a/python/tests/test_001entity.py b/python/tests/test_001entity.py index fca16e1..b4f48a5 100644 --- a/python/tests/test_001entity.py +++ b/python/tests/test_001entity.py @@ -1,51 +1,89 @@ -import re import sys -sys.path.insert(0,'.') -sys.path.insert(0,'..') + +sys.path.insert(0, ".") +sys.path.insert(0, "..") + import pytest -from pdb import set_trace +from bento_meta.entity import ArgError, CollValue, Entity -from bento_meta.entity import Entity, CollValue, ArgError class TestEntity(Entity): - attspec = {"a":"simple","b":"object","c":"collection"} - mapspec_ = {"label":"test","relationship":{"b":{ "rel":":has_object>", "end_cls":"entity"},"c":{ "rel":":has_object>", "end_cls":"entity"}},"property":{"a":"a"}} - def __init__(self,init=None): - super().__init__(init) - -def test_create_entity(): - assert Entity() - -def test_entity_attrs(): - assert TestEntity.attspec == {"a":"simple","b":"object","c":"collection"} - ent = TestEntity() - val = TestEntity() - ent.a = 1 - ent.b = val - assert ent.a==1 - assert ent.b==val - with pytest.raises(AttributeError, match="attribute 'frelb' neither"): - ent.frelb = 42 - with pytest.raises(AttributeError,match="attribute 'frelb' neither"): - f = ent.frelb - with pytest.raises(ArgError,match=".*is not an Entity subclass"): - ent.b={"plain":"dict"} - -def test_entity_init(): - val = TestEntity({"a":10}) - col = {} - good = {"a":1,"b":val,"c":col,"d":"ignored"} - bad = {"a":val,"b":val,"c":col,"d":"ignored"} - ent = TestEntity(init=good) - with pytest.raises(ArgError,match=".*is not a simple scalar"): - TestEntity(init=bad) - -def test_entity_belongs(): - e = TestEntity() - ee = TestEntity() - cc = CollValue({},owner=e,owner_key='c') - e.c=cc - cc['k']=ee - assert e.c == cc - assert e.c['k'] == ee - + attspec = {"a": "simple", "b": "object", "c": "collection"} + mapspec_ = { + "label": "test", + "relationship": { + "b": {"rel": ":has_object>", "end_cls": "entity"}, + "c": {"rel": ":has_object>", "end_cls": "entity"}, + }, + "property": {"a": "a"}, + } + + def __init__(self, init=None) -> None: + super().__init__(init) + + +def test_create_entity() -> None: + assert Entity() + + +def test_entity_attrs() -> None: + assert TestEntity.attspec == {"a": "simple", "b": "object", "c": "collection"} + ent = TestEntity() + val = TestEntity() + ent.a = 1 + ent.b = val + assert ent.a == 1 + assert ent.b == val + with pytest.raises(AttributeError, match="attribute 'frelb' neither"): + ent.frelb = 42 + with pytest.raises(AttributeError, match="attribute 'frelb' neither"): + f = ent.frelb + with pytest.raises(ArgError, match=".*is not an Entity subclass"): + ent.b = {"plain": "dict"} + + +def test_entity_init() -> None: + val = TestEntity({"a": 10}) + col = {} + good = {"a": 1, "b": val, "c": col, "d": "ignored"} + bad = {"a": val, "b": val, "c": col, "d": "ignored"} + ent = TestEntity(init=good) + with pytest.raises(ArgError, match=".*is not a simple scalar"): + TestEntity(init=bad) + + +def test_entity_belongs() -> None: + e = TestEntity() + ee = TestEntity() + cc = CollValue({}, owner=e, owner_key="c") + e.c = cc + cc["k"] = ee + assert e.c == cc + assert e.c["k"] == ee + + +def test_get_attr_dict() -> None: + """Test get_attr_dict method for different attribute types.""" + ent = TestEntity() + ent.a = True + assert ent.get_attr_dict() == {"a": "True"} + ent.a = False + assert ent.get_attr_dict() == {"a": "False"} + ent.a = 42 + assert ent.get_attr_dict() == {"a": "42"} + ent.a = 0 + assert ent.get_attr_dict() == {"a": "0"} + ent.a = 4.2 + assert ent.get_attr_dict() == {"a": "4.2"} + ent.a = 0.0 + assert ent.get_attr_dict() == {"a": "0.0"} + ent.a = "abc" + assert ent.get_attr_dict() == {"a": "abc"} + ent.a = "" + assert ent.get_attr_dict() == {"a": ""} + ent.a = None + assert ent.get_attr_dict() == {} + ent.b = TestEntity() + assert ent.get_attr_dict() == {} + ent.c = CollValue({}, owner=ent, owner_key="c") + assert ent.get_attr_dict() == {}