From ce9cfe8d380d2f4364646b7f214a20d1500d6e55 Mon Sep 17 00:00:00 2001 From: d-w-moore Date: Sun, 8 Feb 2026 02:20:41 -0500 Subject: [PATCH 1/5] [_795,SQUASH] allow options to be accessed as attrs of metadata obj Co-authored-by: Kory Draughn --- README.md | 18 +++++++++++++++++- irods/manager/metadata_manager.py | 15 ++++++++++----- irods/meta.py | 12 ++++++++++++ irods/test/meta_test.py | 16 ++++++++-------- 4 files changed, 47 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 702541dc..2bd1e663 100644 --- a/README.md +++ b/README.md @@ -936,7 +936,7 @@ Disabling AVU reloads from the iRODS server With the default setting of `reload = True`, an `iRODSMetaCollection` will proactively read all current AVUs back from the iRODS server after any -metadata write done by the client. This helps methods such as `items()` +metadata write done by the client. This helps methods such as `keys()` and `items()` to return an up-to-date result. Setting `reload = False` can, however, greatly increase code efficiency if for example a lot of AVUs must be added or deleted at once without reading any back again. @@ -952,6 +952,22 @@ current_metadata = obj.metadata().items() print(f"{current_metadata = }") ``` +By way of explanation, please note that calls of the form +`obj.metadata([opt1=value1[,opt2=value2...]])` will always +produce new `iRODSMetaCollection` objects - which nevertheless share the same +session object as the original, as the copy is shallow in most respects. +This avoids always mutating the current instance and thus prevents any need to +implement context manager semantics when temporarily altering options such +as `reload` and `admin`. + +Additionally note that the call `obj.metadata()` without option parameters +always syncs the AVU list within the resulting `iRODSMetaCollection` object to +what is currently in the catalog, because the original object is unmutated with +respect to all options (meaning `obj.metadata.reload` is always `True`) -- that +is, absent any low-level meddling within reserved fields by the application. +Thus, `obj.metadata().items()` will always agree with the in-catalog AVU list +whereas `obj.metadata.items()` might not. + Subclassing `iRODSMeta` --------------------- The keyword option `iRODSMeta_type` can be used to set up any `iRODSMeta` diff --git a/irods/manager/metadata_manager.py b/irods/manager/metadata_manager.py index c09a6ab6..eeb70aa1 100644 --- a/irods/manager/metadata_manager.py +++ b/irods/manager/metadata_manager.py @@ -27,14 +27,19 @@ class InvalidAtomicAVURequest(Exception): pass +# This was necessarily made separate from the MetadataManager definition +# in order to avoid infinite recursion in iRODSMetaCollection.__getattr__ +_MetadataManager_opts_initializer = { + 'admin':False, + 'timestamps':False, + 'iRODSMeta_type':iRODSMeta, + 'reload':True +} + class MetadataManager(Manager): def __init__(self, *_): - self._opts = { - 'admin':False, - 'timestamps':False, - 'iRODSMeta_type':iRODSMeta - } + self._opts = _MetadataManager_opts_initializer.copy() super().__init__(*_) @property diff --git a/irods/meta.py b/irods/meta.py index 8ca94ae1..9fea2d7f 100644 --- a/irods/meta.py +++ b/irods/meta.py @@ -131,6 +131,18 @@ def __init__(self, operation, avu, **kw): class iRODSMetaCollection: + def __getattr__(self, name): + from irods.manager.metadata_manager import _MetadataManager_opts_initializer + # Separating _MetadataManager_opts_initializer from the MetadataManager class + # prevents # the possibility of arbitrary access by copy.copy() to parts of + # our object's state before they have been initialized, as it is known to do + # by calling hasattr on the "__setstate__" attribute. The result of such + # unfettered access is infinite recursion. See: + # https://nedbatchelder.com/blog/201010/surprising_getattr_recursion + if name in _MetadataManager_opts_initializer: + return self._manager._opts[name] + raise AttributeError + def __call__(self, **opts): """ Optional parameters in **opts are: diff --git a/irods/test/meta_test.py b/irods/test/meta_test.py index cd1c3abd..1ce9eb97 100644 --- a/irods/test/meta_test.py +++ b/irods/test/meta_test.py @@ -820,24 +820,24 @@ def test_binary_avu_fields__issue_707(self): def test_cascading_changes_of_metadata_manager_options__issue_709(self): d = None - def get_option(metacoll, key): - return metacoll._manager._opts[key] +# def get_option(metacoll, key): +# return metacoll._manager._opts[key] try: d = self.sess.data_objects.create(f'{self.coll.path}/issue_709_test_1') m = d.metadata - self.assertEqual(get_option(m, 'admin'), False) + self.assertEqual(m.admin, False) m2 = m(admin=True) - self.assertEqual(get_option(m2, 'timestamps'), False) - self.assertEqual(get_option(m2, 'admin'), True) + self.assertEqual(m2.timestamps, False) + self.assertEqual(m2.admin, True) m3 = m2(timestamps=True) - self.assertEqual(get_option(m3, 'timestamps'), True) - self.assertEqual(get_option(m3, 'admin'), True) + self.assertEqual(m3.timestamps, True) + self.assertEqual(m3.admin, True) self.assertEqual(m3._manager.get_api_keywords().get(kw.ADMIN_KW), "") m4 = m3(admin=False) - self.assertEqual(get_option(m4, 'admin'), False) + self.assertEqual(m4.admin, False) self.assertEqual(m4._manager.get_api_keywords().get(kw.ADMIN_KW), None) finally: if d: From b4461accfe5f03ccabee6a91453785d17ddb75f1 Mon Sep 17 00:00:00 2001 From: d-w-moore Date: Wed, 18 Feb 2026 04:09:11 -0500 Subject: [PATCH 2/5] numerous ruff revisions (squashed) --- irods/manager/metadata_manager.py | 8 ++---- irods/meta.py | 46 +++++++++++++++++++++++++++++-- irods/test/meta_test.py | 14 ++++++++-- 3 files changed, 57 insertions(+), 11 deletions(-) diff --git a/irods/manager/metadata_manager.py b/irods/manager/metadata_manager.py index eeb70aa1..322ab0f8 100644 --- a/irods/manager/metadata_manager.py +++ b/irods/manager/metadata_manager.py @@ -29,12 +29,8 @@ class InvalidAtomicAVURequest(Exception): # This was necessarily made separate from the MetadataManager definition # in order to avoid infinite recursion in iRODSMetaCollection.__getattr__ -_MetadataManager_opts_initializer = { - 'admin':False, - 'timestamps':False, - 'iRODSMeta_type':iRODSMeta, - 'reload':True -} +_MetadataManager_opts_initializer = {'admin': False, 'timestamps': False, 'iRODSMeta_type': iRODSMeta, 'reload': True} + class MetadataManager(Manager): diff --git a/irods/meta.py b/irods/meta.py index 9fea2d7f..e5e858fb 100644 --- a/irods/meta.py +++ b/irods/meta.py @@ -131,16 +131,58 @@ def __init__(self, operation, avu, **kw): class iRODSMetaCollection: + def __setattr__(self, name, value): + """ + Protect the virtual, read-only attributes such as 'admin', 'timestamps', etc., + from being written or created as concrete attributes, which would interfere with + __getattr__'s intended operation for these cases. + + Args: + name: the name of the attribute to be written. + value: the value to be written to the attribute. + + Raises: + AttributeError: on any attempt to write to these special attributes. + """ + from irods.manager.metadata_manager import _MetadataManager_opts_initializer + + if name in _MetadataManager_opts_initializer: + msg = ( + f"""The "{name}" attribute is a special one, settable only via a """ + f"""call on the object. For example: admin_view = data_obj.metadata({name}=)""" + ) + raise AttributeError(msg) + + super().__setattr__(name, value) + def __getattr__(self, name): + """ + Expose certain settable flags (e.g. "admin", "timestamps") as virtual, read-only + "attributes." The names of these special attributes appear as the keys of the + _MetadataManager_opts_initializer dictionary. + + Args: + name: the name of the attribute to be fetched. + + Returns: + the value of the named attribute. + + Raises: + AttributeError: because this is the protocol for deferring to __getattr__'s + default behavior for the case in which none of the special attribute keys are + a match for 'name'. + """ from irods.manager.metadata_manager import _MetadataManager_opts_initializer + # Separating _MetadataManager_opts_initializer from the MetadataManager class - # prevents # the possibility of arbitrary access by copy.copy() to parts of + # prevents the possibility of arbitrary access by copy.copy() to parts of # our object's state before they have been initialized, as it is known to do # by calling hasattr on the "__setstate__" attribute. The result of such # unfettered access is infinite recursion. See: # https://nedbatchelder.com/blog/201010/surprising_getattr_recursion + if name in _MetadataManager_opts_initializer: - return self._manager._opts[name] + return self._manager._opts[name] # noqa: SLF001 raise AttributeError def __call__(self, **opts): diff --git a/irods/test/meta_test.py b/irods/test/meta_test.py index 1ce9eb97..5a9e03fc 100644 --- a/irods/test/meta_test.py +++ b/irods/test/meta_test.py @@ -21,6 +21,7 @@ iRODSMeta, ) from irods.models import Collection, CollectionMeta, DataObject, ModelBase, Resource +from irods.path import iRODSPath from irods.session import iRODSSession from irods.test import helpers @@ -820,8 +821,6 @@ def test_binary_avu_fields__issue_707(self): def test_cascading_changes_of_metadata_manager_options__issue_709(self): d = None -# def get_option(metacoll, key): -# return metacoll._manager._opts[key] try: d = self.sess.data_objects.create(f'{self.coll.path}/issue_709_test_1') m = d.metadata @@ -863,7 +862,16 @@ def test_reload_can_be_deactivated__issue_768(self): self.assertIn(item_1, items_reloaded) self.assertIn(item_2, items_reloaded) + def test_prevention_of_attribute_creation__issue_795(self): + data_path = iRODSPath(self.coll_path, helpers.unique_name(datetime.datetime.now())) # noqa: DTZ005 + data = self.sess.data_objects.create(data_path) + with self.assertRaises(AttributeError): + # This should cause an error since "admin" is considered as a read-only attribute; whereas + # data.metadata(admin = True) generates a cloned object but for the one change to "admin". + data.metadata.admin = True + + if __name__ == "__main__": # let the tests find the parent irods lib sys.path.insert(0, os.path.abspath("../..")) - unittest.main() + From 1c7223d90a01c66b26b7e04f743aef4260d565d7 Mon Sep 17 00:00:00 2001 From: d-w-moore Date: Fri, 27 Feb 2026 03:10:35 -0500 Subject: [PATCH 3/5] summary lines --- irods/meta.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/irods/meta.py b/irods/meta.py index e5e858fb..8bf33022 100644 --- a/irods/meta.py +++ b/irods/meta.py @@ -133,6 +133,8 @@ def __init__(self, operation, avu, **kw): class iRODSMetaCollection: def __setattr__(self, name, value): """ + Override __setattr__. + Protect the virtual, read-only attributes such as 'admin', 'timestamps', etc., from being written or created as concrete attributes, which would interfere with __getattr__'s intended operation for these cases. @@ -157,6 +159,8 @@ def __setattr__(self, name, value): def __getattr__(self, name): """ + Override __getattr__. + Expose certain settable flags (e.g. "admin", "timestamps") as virtual, read-only "attributes." The names of these special attributes appear as the keys of the _MetadataManager_opts_initializer dictionary. From 5398476a0d5b3994afc5760208db1b4584d9ffb9 Mon Sep 17 00:00:00 2001 From: d-w-moore Date: Fri, 27 Feb 2026 14:56:14 -0500 Subject: [PATCH 4/5] undo deletion --- irods/test/meta_test.py | 1 + 1 file changed, 1 insertion(+) diff --git a/irods/test/meta_test.py b/irods/test/meta_test.py index 5a9e03fc..7fa027f4 100644 --- a/irods/test/meta_test.py +++ b/irods/test/meta_test.py @@ -874,4 +874,5 @@ def test_prevention_of_attribute_creation__issue_795(self): if __name__ == "__main__": # let the tests find the parent irods lib sys.path.insert(0, os.path.abspath("../..")) + unittest.main() From bb5d2fb9ebf9d3b92a1b51ca996fbabfa5a2e862 Mon Sep 17 00:00:00 2001 From: d-w-moore Date: Fri, 27 Feb 2026 14:58:04 -0500 Subject: [PATCH 5/5] space --- README.md | 4 ++-- irods/test/meta_test.py | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 2bd1e663..eaae0a68 100644 --- a/README.md +++ b/README.md @@ -952,7 +952,7 @@ current_metadata = obj.metadata().items() print(f"{current_metadata = }") ``` -By way of explanation, please note that calls of the form +By way of explanation, please note that calls of the form `obj.metadata([opt1=value1[,opt2=value2...]])` will always produce new `iRODSMetaCollection` objects - which nevertheless share the same session object as the original, as the copy is shallow in most respects. @@ -960,7 +960,7 @@ This avoids always mutating the current instance and thus prevents any need to implement context manager semantics when temporarily altering options such as `reload` and `admin`. -Additionally note that the call `obj.metadata()` without option parameters +Additionally note that the call `obj.metadata()` without option parameters always syncs the AVU list within the resulting `iRODSMetaCollection` object to what is currently in the catalog, because the original object is unmutated with respect to all options (meaning `obj.metadata.reload` is always `True`) -- that diff --git a/irods/test/meta_test.py b/irods/test/meta_test.py index 7fa027f4..528cde96 100644 --- a/irods/test/meta_test.py +++ b/irods/test/meta_test.py @@ -875,4 +875,3 @@ def test_prevention_of_attribute_creation__issue_795(self): # let the tests find the parent irods lib sys.path.insert(0, os.path.abspath("../..")) unittest.main() -