Skip to content

Commit 2ecc3a3

Browse files
d-w-moorealanking
authored andcommitted
[#709] preserve options when chaining object calls on obj.metadata.
(These calls are used to alter option values such as admin, timestamps, etc.) The problem in a nutshell: if d_input is the iRODSMetaCollection object referenced by obj.metadata, then d_output = d_input(opt1 = val1)(opt2 = val2) should not yield an object with opt1 returned to its default value. With the present fix, we can now expect the d_output object to be the same as if we'd computed it using the expression: d_input(opt1=val1, opt2=val2).
1 parent 17010c9 commit 2ecc3a3

4 files changed

Lines changed: 235 additions & 40 deletions

File tree

README.md

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -931,6 +931,66 @@ of create and modify timestamps for every AVU returned from the server:
931931
datetime.datetime(2022, 9, 19, 15, 26, 7)
932932
```
933933

934+
Disabling AVU reloads from the iRODS server
935+
-------------------------------------------
936+
937+
With the default setting of `reload = True`, an `iRODSMetaCollection` will
938+
proactively read all current AVUs back from the iRODS server after any
939+
metadata write done by the client. This helps methods such as `items()`
940+
to return an up-to-date result. Setting `reload = False` can, however, greatly
941+
increase code efficiency if for example a lot of AVUs must be added or deleted
942+
at once without reading any back again.
943+
944+
```py
945+
# Make a metadata view in which AVUs are not reloaded, for quick update:
946+
non_current_metadata_view = obj.metadata(reload = False)
947+
for i in range(10):
948+
non_current_metadata_view.add("my_key", "my_value_"+str(i))
949+
950+
# Force reload of AVUs and display:
951+
current_metadata = obj.metadata().items()
952+
print(f"{current_metadata = }")
953+
```
954+
955+
Subclassing `iRODSMeta`
956+
---------------------
957+
The keyword option `iRODSMeta_type` can be used to set up any `iRODSMeta`
958+
subclass as the translator between native iRODS metadata APIs
959+
and the way in which the AVUs thus conveyed should be represented to the
960+
client.
961+
962+
An example is the `irods.meta.iRODSBinOrStringMeta` class which uses the
963+
`base64` module to "hide" arbitrary bytestrings within the `value` and
964+
`units` attributes of an iRODS metadata AVU:
965+
966+
```py
967+
from irods.meta import iRODSBinOrStringMeta as MyMeta
968+
d = session.data_objects.get('/path/to/object')
969+
unencodable_octets = '\u1000'.encode('utf8')[:-1]
970+
971+
# Use our custom client-metadata type to store arbitrary octet strings.
972+
meta_view = d.metadata(iRODSMeta_type = MyMeta)
973+
meta_view.set(m1 := MyMeta('mybinary', unencodable_octets, b'\x02'))
974+
975+
# Show that traditional AVU's can exist alongside the custom kind.
976+
irods.client_configuration.connections.xml_parser_default = 'QUASI_XML'
977+
meta_view.set(m2 := MyMeta('mytext', '\1', '\2'))
978+
979+
try:
980+
# These two lines are equivalent.
981+
assert {m1,m2} <= (all_avus := set(meta_view.items()))
982+
assert {tuple(m1),tuple(m2)} <= all_avus
983+
finally:
984+
del meta_view['mytext'], meta_view['mybinary']
985+
```
986+
987+
Whereas the content of native iRODS AVUs must obey some valid text encoding as
988+
determined by the resident iRODS catalog, the above is a possible alternative - albeit
989+
one semantically bound to the local application that defines the needed
990+
translations. Still, this can be a valid usage for users who need a guarantee
991+
that any given octet string they might generate can be placed into metadata without
992+
violating standard text encodings.
993+
934994
Atomic operations on metadata
935995
-----------------------------
936996

irods/manager/metadata_manager.py

Lines changed: 37 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -29,23 +29,45 @@ class InvalidAtomicAVURequest(Exception):
2929

3030
class MetadataManager(Manager):
3131

32+
def __init__(self, *_):
33+
self._opts = {
34+
'admin':False,
35+
'timestamps':False,
36+
'iRODSMeta_type':iRODSMeta
37+
}
38+
super().__init__(*_)
39+
3240
@property
3341
def use_timestamps(self):
34-
return getattr(self, "_use_ts", False)
42+
return self._opts['timestamps']
3543

3644
__kw : Dict[str, Any] = {} # default (empty) keywords
3745

46+
3847
def _updated_keywords(self, opts):
3948
kw_ = self.__kw.copy()
4049
kw_.update(opts)
4150
return kw_
4251

43-
def __call__(self, admin=False, timestamps=False, **irods_kw_opt):
44-
if admin:
45-
irods_kw_opt.update([(kw.ADMIN_KW, "")])
52+
def get_api_keywords(self):
53+
return self.__kw.copy()
54+
55+
def __call__(self, **flags):
56+
# Make a new shallow copy of the manager object, but update options from parameter list.
4657
new_self = copy.copy(self)
47-
new_self._use_ts = timestamps
48-
new_self.__kw = irods_kw_opt
58+
new_self._opts = copy.copy(self._opts)
59+
60+
# Update the flags that do bookkeeping in the returned(new) manager object.
61+
new_self._opts.update(
62+
(key, val) for key, val in flags.items() if val is not None
63+
)
64+
65+
# Update the ADMIN_KW flag in the returned(new) object.
66+
if new_self._opts.get('admin'):
67+
self.__kw[kw.ADMIN_KW] = ""
68+
else:
69+
self.__kw.pop(kw.ADMIN_KW, None)
70+
4971
return new_self
5072

5173
@staticmethod
@@ -67,6 +89,9 @@ def _model_class_to_resource_description(model_cls):
6789
}[model_cls]
6890

6991
def get(self, model_cls, path):
92+
if not path:
93+
# Short circuit. This should be of the same type as the object returned at the function's end.
94+
return []
7095
resource_type = self._model_class_to_resource_type(model_cls)
7196
model = {
7297
"d": DataObjectMeta,
@@ -96,9 +121,9 @@ def meta_opts(row):
96121
return opts
97122

98123
return [
99-
iRODSMeta(
100-
row[model.name], row[model.value], row[model.units], **meta_opts(row)
101-
)
124+
self._opts['iRODSMeta_type'](None, None, None)._from_column_triple(
125+
row[model.name], row[model.value], row[model.units],
126+
**meta_opts(row))
102127
for row in results
103128
]
104129

@@ -109,9 +134,7 @@ def add(self, model_cls, path, meta, **opts):
109134
"add",
110135
"-" + resource_type,
111136
path,
112-
meta.name,
113-
meta.value,
114-
meta.units,
137+
*meta._to_column_triple(),
115138
**self._updated_keywords(opts)
116139
)
117140
request = iRODSMessage(
@@ -128,9 +151,7 @@ def remove(self, model_cls, path, meta, **opts):
128151
"rm",
129152
"-" + resource_type,
130153
path,
131-
meta.name,
132-
meta.value,
133-
meta.units,
154+
*meta._to_column_triple(),
134155
**self._updated_keywords(opts)
135156
)
136157
request = iRODSMessage(
@@ -167,9 +188,7 @@ def set(self, model_cls, path, meta, **opts):
167188
"set",
168189
"-" + resource_type,
169190
path,
170-
meta.name,
171-
meta.value,
172-
meta.units,
191+
*meta._to_column_triple(),
173192
**self._updated_keywords(opts)
174193
)
175194
request = iRODSMessage(

irods/meta.py

Lines changed: 65 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,45 @@
1+
import base64
2+
import copy
3+
4+
15
class iRODSMeta:
26

7+
def _to_column_triple(self):
8+
return (self.name ,self.forward_translate(self.value)) + (
9+
('',) if not self.units else (self.forward_translate(self.units),)
10+
)
11+
12+
def _from_column_triple(self, name, value, units, **kw):
13+
self.__low_level_init(
14+
name,
15+
self.reverse_translate(value),
16+
units=None if not units else self.reverse_translate(units),
17+
**kw
18+
)
19+
return self
20+
21+
reverse_translate = forward_translate = staticmethod(lambda _: _)
22+
23+
INIT_KW_ARGS = ['units', 'avu_id', 'create_time', 'modify_time']
24+
325
def __init__(
4-
self, name, value, units=None, avu_id=None, create_time=None, modify_time=None
26+
self, name, value, /, units=None, *, avu_id=None, create_time=None, modify_time=None,
527
):
6-
self.avu_id = avu_id
28+
# Defer initialization for iRODSMeta(attribute,value,...) if neither attribute nor value is True under
29+
# a 'bool' transformation. In so doing we streamline initialization for iRODSMeta (and any subclasses)
30+
# for alternatively populating via _from_column_triple(...).
31+
# This is the pathway for allowing user-defined encodings of the iRODSMeta (byte-)string AVU components.
32+
if name or value:
33+
# Note: calling locals() inside the dict comprehension would not access variables in this frame.
34+
local_vars = locals()
35+
kw = {name: local_vars.get(name) for name in self.INIT_KW_ARGS}
36+
self.__low_level_init(name, value, **kw)
37+
38+
def __low_level_init(self, name, value, **kw):
739
self.name = name
840
self.value = value
9-
self.units = units
10-
self.create_time = create_time
11-
self.modify_time = modify_time
41+
for attr in self.INIT_KW_ARGS:
42+
setattr(self, attr, kw.get(attr))
1243

1344
def __eq__(self, other):
1445
return tuple(self) == tuple(other)
@@ -20,7 +51,22 @@ def __iter__(self):
2051
yield self.units
2152

2253
def __repr__(self):
23-
return "<iRODSMeta {avu_id} {name} {value} {units}>".format(**vars(self))
54+
return f"<{self.__class__.__name__} {self.avu_id} {self.name} {self.value} {self.units}>"
55+
56+
def __hash__(self):
57+
return hash(tuple(self))
58+
59+
60+
class iRODSBinOrStringMeta(iRODSMeta):
61+
@staticmethod
62+
def reverse_translate(value):
63+
"""Translate an AVU field from its iRODS object-database form into the client representation of that field."""
64+
return value if value[0] != '\\' else base64.decodebytes(value[1:].encode('utf8'))
65+
66+
@staticmethod
67+
def forward_translate(value):
68+
"""Translate an AVU field from the form it takes in the client, into an iRODS object-database compatible form."""
69+
return b'\\' + base64.encodebytes(value).strip() if isinstance(value,(bytes,bytearray)) else value
2470

2571

2672
class BadAVUOperationKeyword(Exception):
@@ -84,14 +130,16 @@ def __init__(self, operation, avu, **kw):
84130
setattr(self, atr, locals()[atr])
85131

86132

87-
import copy
88-
89-
90133
class iRODSMetaCollection:
134+
def __call__(self, **opts):
135+
"""
136+
Optional parameters in **opts are:
91137
92-
def __call__(self, admin=False, timestamps=False, **opts):
138+
admin (default: False): apply ADMIN_KW to future metadata operations.
139+
timestamps (default: False): attach (ctime,mtime) timestamp attributes to AVUs received from iRODS.
140+
"""
93141
x = copy.copy(self)
94-
x._manager = (x._manager)(admin, timestamps, **opts)
142+
x._manager = (x._manager)(**opts)
95143
x._reset_metadata()
96144
return x
97145

@@ -102,7 +150,11 @@ def __init__(self, manager, model_cls, path):
102150
self._reset_metadata()
103151

104152
def _reset_metadata(self):
105-
self._meta = self._manager.get(self._model_cls, self._path)
153+
m = self._manager
154+
if not hasattr(self, "_meta"):
155+
self._meta = m.get(None, "")
156+
if m._opts.setdefault('reload', True):
157+
self._meta = m.get(self._model_cls, self._path)
106158

107159
def get_all(self, key):
108160
"""
@@ -129,7 +181,7 @@ def get_one(self, key):
129181
def _get_meta(self, *args):
130182
if not len(args):
131183
raise ValueError("Must specify an iRODSMeta object or key, value, units)")
132-
return args[0] if len(args) == 1 else iRODSMeta(*args)
184+
return args[0] if len(args) == 1 else self._manager._opts['iRODSMeta_type'](*args)
133185

134186
def apply_atomic_operations(self, *avu_ops):
135187
self._manager.apply_atomic_operations(self._model_cls, self._path, *avu_ops)

0 commit comments

Comments
 (0)