Skip to content

Commit 9554c9f

Browse files
d-w-moorekorydraughn
authored andcommitted
[#746] choice of sort functions now available for data replicas
The iRODSDataObject has thus far sorted its replicas list by ascending replica order. replica_sort_function is a new keyword option recognized by both <session>.data_objects.get() and the iRODSDataObject constructor, and either can be given an alternate value (REPLICA_FITNESS_SORT_KEY_FN) or even a user-defined sort key if desired in order to alter replica order to a way that conforms more to user requirements.
1 parent c19dd38 commit 9554c9f

4 files changed

Lines changed: 234 additions & 81 deletions

File tree

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -453,6 +453,18 @@ ensure the lifetime of the updatable instance extends beyond the time needed for
453453

454454
See `irods/test/data_obj_test.py` for examples of these and other subtleties of progress bar usage.
455455

456+
Replica access and sorting
457+
--------------------------
458+
459+
The `replicas` member of an instance of `iRODSDataObject` allows a view into the various replicas that exist under a
460+
given logical path. For an example of this, jump forward to [working with data objects](#working-with-data-objects-files).
461+
462+
Note that a `replica_sort_function` option exists in the `iRODSDataObject` constructor and in
463+
`<session_object>.data_objects.get`. If `replica_sort_function` is not specified, the replicas
464+
list contained in the returned object will be sorted according to replica number
465+
(REPLICA_NUMBER_SORT_KEY_FN) in PRC < v4 or by general fitness to be considered a
466+
good representation of that object (REPLICA_FITNESS_SORT_KEY_FN) in PRC >= v4. For those definitions, see `irods/data_object.py`.
467+
456468
Working with collections (directories)
457469
--------------------------------------
458470

irods/data_object.py

Lines changed: 66 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,24 @@
1+
"""
2+
Interface for iRODS data objects.
3+
4+
Provides high level abstraction and POSIX-like facilities (create, open,
5+
read/write) allowing clients to manipulate data objects very much as if they
6+
were local files.
7+
"""
8+
9+
import ast
10+
import enum
111
import io
2-
import sys
312
import logging
413
import os
5-
import ast
14+
import sys
15+
from datetime import datetime, timezone
616

7-
from irods.models import DataObject
8-
from irods.meta import iRODSMetaCollection
917
import irods.keywords as kw
1018
from irods.api_number import api_number
1119
from irods.message import JSON_Message, iRODSMessage
20+
from irods.meta import iRODSMetaCollection
21+
from irods.models import DataObject
1222

1323
logger = logging.getLogger(__name__)
1424

@@ -41,11 +51,57 @@ def __repr__(self):
4151
return "<{}.{} {}>".format(self.__class__.__module__, self.__class__.__name__, self.resource_name)
4252

4353

54+
class _repl_status(enum.Enum): # noqa: N801
55+
STALE_REPLICA, GOOD_REPLICA, INTERMEDIATE_REPLICA, READ_LOCKED, WRITE_LOCKED = range(5)
56+
57+
58+
# An ordering of the various replica status values, by descending fitness for use/interface
59+
_REPL_STATUSES = tuple(
60+
getattr(_repl_status, ident).value
61+
for ident in (
62+
"GOOD_REPLICA",
63+
"STALE_REPLICA",
64+
"INTERMEDIATE_REPLICA",
65+
"READ_LOCKED",
66+
"WRITE_LOCKED",
67+
)
68+
)
69+
70+
# An appropriate reference datetime value for gauging replica age as part of
71+
# the default sort key in PRC4 and onward.
72+
_REFERENCE_DATETIME = datetime.fromtimestamp(0, timezone.utc)
73+
74+
# ruff: noqa: D103 off
75+
76+
# Key functions to dictate how replica row results will be sorted within an iRODSDataObject.
77+
78+
79+
def REPLICA_NUMBER_SORT_KEY_FN(row): # noqa: N802
80+
return row[DataObject.replica_number]
81+
82+
83+
def REPLICA_FITNESS_SORT_KEY_FN(row): # noqa: N802
84+
repl_status = int(row[DataObject.replica_status])
85+
86+
repl_status_rank = _REPL_STATUSES.index(repl_status) if _REPL_STATUSES.count(repl_status) else sys.maxsize
87+
88+
return (repl_status_rank, _REFERENCE_DATETIME - row[DataObject.modify_time])
89+
90+
91+
# ruff: noqa: D103 on
92+
93+
_DEFAULT_SORT_KEY_FN = REPLICA_NUMBER_SORT_KEY_FN
94+
95+
4496
class iRODSDataObject:
45-
def __init__(self, manager, parent=None, results=None):
97+
# iRODSDataObject's constructor is not usually directly accessed by iRODS client applications. See the main README.
98+
# ruff: noqa: D107 off
99+
100+
def __init__(self, manager, parent=None, results=None, replica_sort_function=None):
46101
self.manager = manager
47102
if parent and results:
48103
self.collection = parent
104+
results = sorted(results, key=(replica_sort_function or _DEFAULT_SORT_KEY_FN))
49105
for attr, value in DataObject.__dict__.items():
50106
if not attr.startswith("_"):
51107
try:
@@ -54,9 +110,8 @@ def __init__(self, manager, parent=None, results=None):
54110
# backward compatibility with older schema versions
55111
pass
56112
self.path = self.collection.path + "/" + self.name
57-
replicas = sorted(results, key=lambda r: r[DataObject.replica_number])
58113

59-
# The status quo before iRODS 5
114+
# Copy pre-iRODS 5 fields
60115

61116
replica_args = [
62117
(
@@ -75,18 +130,20 @@ def __init__(self, manager, parent=None, results=None):
75130
modify_time=r[DataObject.modify_time],
76131
),
77132
)
78-
for r in replicas
133+
for r in results
79134
]
80135

81136
# Adjust for adding access_time in the iRODS 5 case.
82137

83138
if self.manager.sess.server_version >= (5,):
84-
for n, r in enumerate(replicas):
139+
for n, r in enumerate(results):
85140
replica_args[n][1]['access_time'] = r[DataObject.access_time]
86141
self.replicas = [iRODSReplica(*a, **k) for a, k in replica_args]
87142

88143
self._meta = None
89144

145+
# ruff: noqa: D107 off
146+
90147
def __repr__(self):
91148
return f"<iRODSDataObject {self.id} {self.name}>"
92149

irods/manager/data_object_manager.py

Lines changed: 83 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -6,37 +6,36 @@
66
import os
77
import weakref
88
from typing import Any, List, Type
9-
from irods.models import DataObject, Collection
10-
from irods.manager import Manager
11-
from irods.manager._internal import _api_impl, _logical_path
12-
from irods.message import (
13-
iRODSMessage,
14-
FileOpenRequest,
15-
ObjCopyRequest,
16-
StringStringMap,
17-
DataObjInfo_for_session,
18-
ModDataObjMeta_for_session,
19-
DataObjChksumRequest,
20-
DataObjChksumResponse,
21-
RErrorStack,
22-
STR_PI,
23-
)
9+
10+
import irods.client_configuration as client_config
2411
import irods.exception as ex
12+
import irods.keywords as kw
13+
from irods import parallel
2514
from irods.api_number import api_number
2615
from irods.collection import iRODSCollection
2716
from irods.data_object import (
28-
iRODSDataObject,
29-
iRODSDataObjectFileRaw,
3017
chunks,
31-
irods_dirname,
3218
irods_basename,
19+
irods_dirname,
20+
iRODSDataObject,
21+
iRODSDataObjectFileRaw,
3322
)
34-
import irods.client_configuration as client_config
35-
import irods.keywords as kw
36-
import irods.parallel as parallel
23+
from irods.manager import Manager
24+
from irods.manager._internal import _api_impl, _logical_path
25+
from irods.message import (
26+
STR_PI,
27+
DataObjChksumRequest,
28+
DataObjChksumResponse,
29+
DataObjInfo_for_session,
30+
FileOpenRequest,
31+
ModDataObjMeta_for_session,
32+
ObjCopyRequest,
33+
StringStringMap,
34+
iRODSMessage,
35+
)
36+
from irods.models import Collection, DataObject
3737
from irods.parallel import deferred_call
3838

39-
4039
logger = logging.getLogger(__name__)
4140

4241
_update_types: List[Type] = []
@@ -218,27 +217,29 @@ def should_parallelize_transfer(
218217
if size is not None and isinstance(open_options, dict):
219218
open_options[kw.DATA_SIZE_KW] = size
220219

221-
def _download(self, obj, local_path, num_threads, updatables=(), **options):
220+
def _download(self, obj_path, local_path, num_threads, updatables=(), **options):
222221
"""Transfer the contents of a data object to a local file.
223222
224223
Called from get() when a local path is named.
225224
"""
226-
if os.path.isdir(local_path):
227-
local_file = os.path.join(local_path, irods_basename(obj))
228-
else:
229-
local_file = local_path
225+
226+
local_file = (
227+
os.path.join(local_path, irods_basename(obj_path)) # noqa: PTH118
228+
if os.path.isdir(local_path) # noqa: PTH112
229+
else local_path
230+
)
230231

231232
# Check for force flag if local_file exists
232233
if os.path.exists(local_file) and kw.FORCE_FLAG_KW not in options:
233234
raise ex.OVERWRITE_WITHOUT_FORCE_FLAG
234235

235236
data_open_returned_values_ = {}
236-
with self.open(obj, "r", returned_values=data_open_returned_values_, **options) as o:
237+
with self.open(obj_path, "r", returned_values=data_open_returned_values_, **options) as o:
237238
if self.should_parallelize_transfer(num_threads, o, open_options=options.items()):
238239
error = RuntimeError("parallel get failed")
239240
try:
240241
if not self.parallel_get(
241-
(obj, o),
242+
(obj_path, o),
242243
local_file,
243244
num_threads=num_threads,
244245
target_resource_name=options.get(kw.RESC_NAME_KW, ""),
@@ -256,12 +257,46 @@ def _download(self, obj, local_path, num_threads, updatables=(), **options):
256257
f.write(chunk)
257258
do_progress_updates(updatables, len(chunk))
258259

259-
def get(self, path, local_path=None, num_threads=DEFAULT_NUMBER_OF_THREADS, updatables=(), **options):
260+
def get(
261+
self,
262+
path,
263+
local_path=None,
264+
num_threads=DEFAULT_NUMBER_OF_THREADS,
265+
updatables=(),
266+
replica_sort_function=None,
267+
**options,
268+
):
260269
"""
261-
Get a reference to the data object at the specified `path'.
262-
263-
Only download the object if the local_path is a string (specifying
264-
a path in the local filesystem to use as a destination file).
270+
Create an iRODSDataObject instance representing the data object at the specified path.
271+
272+
If local_path is not None, it names a local file to which the content of the
273+
data object will be downloaded.
274+
275+
Args:
276+
path: an absolute logical path where the data object may be found.
277+
local_path: a filename within the local filesystem, the target for download ("GET") of the data
278+
object if one is requested. Directory components in the path must already exist.
279+
num_threads: in the case of a parallel data transfer (for large files), specifies the number of
280+
transfer control threads to be spawned. If not specified, this number will be DEFAULT_NUMBER_OF_THREADS,
281+
i.e. a reasonable small integer value.
282+
updatables: a tuple or list in which each element is a tqdm-like progress bar object, or the equivalent:
283+
an instance on which func.update(n) can be called with n being the number of bytes successfully
284+
copied in the data transfer increment just completed. If not a tuple or list, this argument
285+
is interpreted as a single updatable. The parameter defaults to (), meaning no such objects will be
286+
slated for update.
287+
replica_sort_function: a sort key function dictating the order of replica query results in 'self.replicas'.
288+
If not specified, a default value of None will cause irods.data_objects._DEFAULT_SORT_KEY_FN to be
289+
selected to determine the sort order.
290+
**options: a combination of possible iRODS keyword options to be relayed to the data object open() call.
291+
For a download request, FORCE_FLAG_KW may be used to ensure any pre-existing file at the 'local_path'
292+
will be overwritten.
293+
294+
Returns:
295+
an iRODSDataObject representing the object at the given path.
296+
297+
Raises:
298+
DataObjectDoesNotExist: if the specified path does not exist, or exists as a collection rather than a data
299+
object.
265300
"""
266301
parent = self.sess.collections.get(irods_dirname(path))
267302

@@ -284,7 +319,7 @@ def get(self, path, local_path=None, num_threads=DEFAULT_NUMBER_OF_THREADS, upda
284319
results = query.all() # get up to max_rows replicas
285320
if len(results) <= 0:
286321
raise ex.DataObjectDoesNotExist()
287-
return iRODSDataObject(self, parent, results)
322+
return iRODSDataObject(self, parent, results, replica_sort_function=replica_sort_function)
288323

289324
@staticmethod
290325
def _resolve_force_put_option(options, default_setting=None, true_value=""):
@@ -317,23 +352,25 @@ def put(
317352
self._resolve_force_put_option(options, default_setting=client_config.data_objects.force_put_by_default)
318353

319354
if self.sess.collections.exists(irods_path):
320-
obj = iRODSCollection.normalize_path(irods_path, os.path.basename(local_path))
355+
obj_path = iRODSCollection.normalize_path(irods_path, os.path.basename(local_path)) # noqa: PTH119
321356
else:
322-
obj = irods_path
323-
if kw.FORCE_FLAG_KW not in options and self.exists(obj):
357+
obj_path = irods_path
358+
if kw.FORCE_FLAG_KW not in options and self.exists(obj_path):
324359
raise ex.OVERWRITE_WITHOUT_FORCE_FLAG
325360
options.pop(kw.FORCE_FLAG_KW, None)
326361

362+
replica_sort_function = options.pop('replica_sort_function', None)
363+
327364
with open(local_path, "rb") as f:
328365
sizelist = []
329366
if self.should_parallelize_transfer(num_threads, f, measured_obj_size=sizelist, open_options=options):
330-
o = deferred_call(self.open, (obj, "w"), options)
367+
o = deferred_call(self.open, (obj_path, "w"), options)
331368
f.close()
332369
error = RuntimeError("parallel put failed")
333370
try:
334371
if not self.parallel_put(
335372
local_path,
336-
(obj, o),
373+
(obj_path, o),
337374
total_bytes=sizelist[0],
338375
num_threads=num_threads,
339376
target_resource_name=options.get(kw.RESC_NAME_KW, "") or options.get(kw.DEST_RESC_NAME_KW, ""),
@@ -346,7 +383,7 @@ def put(
346383
except BaseException as e:
347384
raise error from e
348385
else:
349-
with self.open(obj, "w", **options) as o:
386+
with self.open(obj_path, "w", **options) as o:
350387
# Set operation type to trigger acPostProcForPut
351388
if kw.OPR_TYPE_KW not in options:
352389
options[kw.OPR_TYPE_KW] = 1 # PUT_OPR
@@ -360,10 +397,11 @@ def put(
360397
# Requested to register checksum without verifying, but source replica has a checksum. This can result
361398
# in multiple replicas being marked good with different checksums, which is an inconsistency.
362399
del repl_options[kw.REG_CHKSUM_KW]
363-
self.replicate(obj, **repl_options)
400+
self.replicate(obj_path, **repl_options)
364401

365402
if return_data_object:
366-
return self.get(obj)
403+
return self.get(obj_path, replica_sort_function=replica_sort_function)
404+
return None
367405

368406
def chksum(self, path, **options):
369407
"""
@@ -480,6 +518,7 @@ def create(
480518
raise ex.DataObjectExistsAtLogicalPath
481519

482520
options = {**options, kw.DATA_TYPE_KW: "generic"}
521+
replica_sort_function = options.pop('replica_sort_function', None)
483522

484523
if resource:
485524
options[kw.DEST_RESC_NAME_KW] = resource
@@ -508,7 +547,7 @@ def create(
508547
desc = response.int_info
509548
conn.close_file(desc)
510549

511-
return self.get(path)
550+
return self.get(path, replica_sort_function=replica_sort_function)
512551

513552
def open_with_FileRaw(self, *arg, **kw_options):
514553
holder = []

0 commit comments

Comments
 (0)