Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit f2332b3

Browse files
d-w-moorealanking
authored andcommitted
[irods#614] ordered cleanup function execution in stages.
1 parent 13dc9da commit f2332b3

File tree

5 files changed

+265
-3
lines changed

5 files changed

+265
-3
lines changed

README.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -928,6 +928,45 @@ parsers, it may be more convenient to use the `xml_mode` context manager:
928928
# ... We have now returned to using the default XML parser.
929929
```
930930

931+
Application Cleanup
932+
-------------------
933+
934+
Using the `irods.at_client_exit` module, we may register user-defined functions to be executed at or around the
935+
time when the Python iRODS Client is engaged in object teardown (also called "cleanup") operations.
936+
This is analogous to Python's [atexit module](https://docs.python.org/3/library/atexit.html#module-atexit),
937+
except that here we have the extra resolution to specify that a function or callable object be expressly before,
938+
or expressly after, aforementioned object teardown stage:
939+
940+
```python
941+
from irods import at_client_exit
942+
at_client_exit.register_for_execution_after_prc_cleanup(lambda: print("PRC cleanup has completed."))
943+
at_client_exit.register_for_execution_before_prc_cleanup(lambda: print("PRC cleanup is about to start."))
944+
```
945+
946+
A function normally cannot be registered multiple times to run in the same stage, but we may overcome this limitation
947+
(and, optionally, arguments set for the invocation) by wrapping the same function into two different callable objects:
948+
949+
```python
950+
def print_exit_message(n):
951+
print(f"Called just after PRC cleanup - iteration {n}")
952+
953+
for n_iter in (1,2):
954+
at_client_exit.register_for_execution_after_prc_cleanup(
955+
at_client_exit.unique_function_invocation(print_exit_message, tup_args = (n_iter,))
956+
)
957+
```
958+
959+
The output of the above, upon script exit, will be:
960+
961+
```
962+
Called just after PRC cleanup - iteration 2
963+
Called just after PRC cleanup - iteration 1
964+
```
965+
966+
which may be reversed from the order that one might expect. This is because -- similarly as with Python atexit module, and
967+
consistently with the teardown of higher abstractions before lower ones -- functions _registered_ later within a given cleanup
968+
stage will actually be _executed_ sooner (i.e. in "LIFO" order).
969+
931970
Rule Execution
932971
--------------
933972

irods/at_client_exit.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import atexit
2+
import enum
3+
import logging
4+
import sys
5+
import threading
6+
7+
logger = logging.getLogger(__name__)
8+
9+
class LibraryCleanupStage(enum.Enum):
10+
# Integer value determines relative order of execution among the stages.
11+
BEFORE = 10
12+
DURING = 20
13+
AFTER = 30
14+
def __lt__(self, other):
15+
return self.value < other.value
16+
17+
def NOTIFY_VIA_ATTRIBUTE(func, stage):
18+
func.at_exit_stage = stage
19+
20+
initialization_lock = threading.Lock()
21+
initialized = False
22+
_stage_notify = {}
23+
24+
def _register(stage_for_execution, function, stage_notify_function = None):
25+
with initialization_lock:
26+
global initialized
27+
if not initialized:
28+
initialized = True
29+
atexit.register(_call_cleanup_functions)
30+
array = _cleanup_functions[stage_for_execution]
31+
if function not in array:
32+
array.append(function)
33+
_stage_notify[function] = stage_notify_function
34+
35+
def _reset_cleanup_functions():
36+
global _cleanup_functions
37+
_cleanup_functions = {LibraryCleanupStage.BEFORE:[],
38+
LibraryCleanupStage.DURING:[],
39+
LibraryCleanupStage.AFTER:[]
40+
}
41+
42+
_reset_cleanup_functions()
43+
44+
def _call_cleanup_functions():
45+
ordered_funclists = sorted((pri,fl) for (pri,fl) in _cleanup_functions.items())
46+
try:
47+
# Run each cleanup stage.
48+
for stage,function_list in ordered_funclists:
49+
# Within each cleanup stage, run all registered functions last-in, first-out. (Per atexit conventions.)
50+
for function in reversed(function_list):
51+
# Ensure we execute all cleanup functions regardless of some of them raising exceptions.
52+
try:
53+
notify = _stage_notify.get(function)
54+
if notify:
55+
notify(function, stage)
56+
function()
57+
except Exception as exc:
58+
logger.warning("%r raised from %s", exc, function)
59+
finally:
60+
_reset_cleanup_functions()
61+
62+
# The primary interface: 'before' and 'after' are the execution order relative to the DURING stage,
63+
# in which the Python client cleans up its own session objects and runs the optional data object auto-close facility.
64+
65+
def register_for_execution_before_prc_cleanup(function, **kw_args):
66+
"""Register a function to be called by the Python iRODS Client (PRC) at exit time.
67+
It will be called without arguments, and before PRC begins its own cleanup tasks."""
68+
return _register(LibraryCleanupStage.BEFORE, function, **kw_args)
69+
70+
def register_for_execution_after_prc_cleanup(function, **kw_args):
71+
"""Register a function to be called by the Python iRODS Client (PRC) at exit time.
72+
It will be called without arguments, and after PRC is done with its own cleanup tasks."""
73+
return _register(LibraryCleanupStage.AFTER, function, **kw_args)
74+
75+
class unique_function_invocation:
76+
"""Wrap a function object to provide a unique handle for registration of a given
77+
function multiple times, possibly with different arguments.
78+
"""
79+
def __init__(self, func, tup_args = (), kw_args = ()):
80+
self.tup_args = tup_args
81+
self.kw_args = dict(kw_args)
82+
self.func = func
83+
self.at_exit_stage = None
84+
85+
def __call__(self):
86+
return (self.func)(*self.tup_args, **self.kw_args)
87+
88+
def get_stage(n = 2):
89+
"""A utility function that can be called from within the highest call frame (use a higher
90+
"n" for subordinate frames) of a user-defined cleanup function to get the currently active
91+
cleanup stage, defined by a LibraryCleanupStage enum value. The user-defined function must have been
92+
wrapped by an instance of class unique_function_invocation, and that wrapper object
93+
registered using the keyword argument: stage_notify_function = NOTIFY_VIA_ATTRIBUTE."""
94+
caller = sys._getframe(n).f_locals.get('self')
95+
if isinstance(caller, unique_function_invocation):
96+
return caller.at_exit_stage

irods/session.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from irods.exception import (NetworkException, NotImplementedInIRODSServer)
2525
from irods.password_obfuscation import decode
2626
from irods import NATIVE_AUTH_SCHEME, PAM_AUTH_SCHEMES
27+
from . import at_client_exit
2728
from . import (DEFAULT_CONNECTION_TIMEOUT, MAXIMUM_CONNECTION_TIMEOUT)
2829

2930
_fds = None
@@ -32,14 +33,19 @@
3233
_sessions_lock = threading.Lock()
3334

3435
def _cleanup_remaining_sessions():
35-
for fd in list(_fds.keys()):
36+
for fd in list((_fds or {}).keys()):
3637
if not fd.closed:
3738
fd.close()
3839
# remove refs to session objects no longer needed
3940
fd._iRODS_session = None
40-
for ses in _sessions.copy():
41+
for ses in (_sessions or []).copy():
4142
ses.cleanup() # internally modifies _sessions
4243

44+
with _sessions_lock:
45+
at_client_exit._register(
46+
at_client_exit.LibraryCleanupStage.DURING,
47+
_cleanup_remaining_sessions)
48+
4349
def _weakly_reference(ses):
4450
global _sessions, _fds
4551
try:
@@ -49,7 +55,6 @@ def _weakly_reference(ses):
4955
if do_register:
5056
_sessions = weakref.WeakKeyDictionary()
5157
_fds = weakref.WeakKeyDictionary()
52-
atexit.register(_cleanup_remaining_sessions)
5358
finally:
5459
_sessions[ses] = None
5560

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import os
2+
import re
3+
import subprocess
4+
import sys
5+
import textwrap
6+
import unittest
7+
8+
import irods.at_client_exit
9+
import irods.test.modules as test_modules
10+
11+
class TestCleanupFunctions(unittest.TestCase):
12+
13+
def test_execution_of_client_exit_functions_at_proper_time__issue_614(self):
14+
helper_script = os.path.join(test_modules.__path__[0], 'test_client_exit_functions.py')
15+
16+
# Note: The enum.Enum subclass's __members__ is an ordered dictionary, i.e. key order is preserved:
17+
# https://docs.python.org/3.6/library/enum.html#iteration
18+
# This is essential for the test to pass, since:
19+
# - We assume that key order to be preserved in the rendering of the arguments list presented to the test helper script.
20+
# - That argument order determines the below-asserted order of execution, at script exit, of the functions registered therein.
21+
22+
args = [""] + list(irods.at_client_exit.LibraryCleanupStage.__members__)
23+
24+
p = subprocess.Popen([sys.executable, helper_script, *args], stdout=subprocess.PIPE)
25+
script_output = p.communicate()[0].decode().strip()
26+
from irods.test.modules.test_client_exit_functions import projected_output_from_innate_list_order
27+
self.assertEqual(
28+
projected_output_from_innate_list_order(args),
29+
script_output
30+
)
31+
32+
def test_that_client_exit_functions_execute_in_LIFO_order_and_despite_uncaught_exceptions__issue_614(self):
33+
process = subprocess.Popen([sys.executable, '-c',
34+
textwrap.dedent("""
35+
import logging
36+
logging.basicConfig()
37+
from irods.at_client_exit import (_register, LibraryCleanupStage, register_for_execution_before_prc_cleanup, register_for_execution_after_prc_cleanup)
38+
gen_lambda = lambda expressions: (lambda:[print(eval(x)) for x in expressions])
39+
40+
register_for_execution_after_prc_cleanup(gen_lambda(['"after1"','1/0']))
41+
register_for_execution_after_prc_cleanup(gen_lambda(['"after2"','1/0']))
42+
43+
_register(LibraryCleanupStage.DURING, gen_lambda(['"during"','1/0']))
44+
45+
register_for_execution_before_prc_cleanup(gen_lambda(['"before"','1/0']))
46+
""")], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
47+
48+
stdout_content, stderr_content = process.communicate()
49+
self.assertEqual(len(list(re.finditer(br'ZeroDivisionError.*\n',stderr_content))), 4)
50+
self.assertEqual(b','.join([m.group() for m in re.finditer(br'(\w+)',stdout_content)]),
51+
b'before,during,after2,after1')
52+
53+
def test_notifying_client_exit_functions_of_stage_in_which_they_are_called__issue_614(self):
54+
process = subprocess.Popen([sys.executable, '-c',
55+
textwrap.dedent("""
56+
from irods.at_client_exit import (unique_function_invocation,
57+
get_stage, LibraryCleanupStage, NOTIFY_VIA_ATTRIBUTE,
58+
register_for_execution_before_prc_cleanup,
59+
register_for_execution_after_prc_cleanup)
60+
def before(): print(f'before:{get_stage().name}')
61+
def after(): print(f'after:{get_stage().name}')
62+
register_for_execution_before_prc_cleanup(unique_function_invocation(before), stage_notify_function = NOTIFY_VIA_ATTRIBUTE)
63+
register_for_execution_after_prc_cleanup(unique_function_invocation(after), stage_notify_function = NOTIFY_VIA_ATTRIBUTE)
64+
""")], stdout=subprocess.PIPE)
65+
stdout_content, _ = process.communicate()
66+
stdout_lines = stdout_content.split(b"\n")
67+
self.assertIn(b"before:BEFORE", stdout_lines)
68+
self.assertIn(b"after:AFTER", stdout_lines)
69+
70+
if __name__ == '__main__':
71+
# let the tests find the parent irods lib
72+
sys.path.insert(0, os.path.abspath('../..'))
73+
unittest.main()
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# Used in test of:
2+
# irods.test.cleanup_functions_test.TestCleanupFunctions.test_proper_execution_of_client_exit_functions__issue_614
3+
4+
import irods.at_client_exit
5+
import sys
6+
7+
def get_stage_object_and_value_from_name(stage_name):
8+
try:
9+
obj = getattr(irods.at_client_exit.LibraryCleanupStage, stage_name)
10+
return obj, obj.value
11+
except AttributeError:
12+
return None, stage_name
13+
14+
def object_printer(name, stream = sys.stdout):
15+
(obj, _) = get_stage_object_and_value_from_name(stage_name = name)
16+
return lambda: print(f'[{name if not obj else obj.value}]', end='', file = stream)
17+
18+
def projected_output_from_innate_list_order(name_list):
19+
import io
20+
in_memory_stream = io.StringIO()
21+
for name in name_list:
22+
func = object_printer(name, stream = in_memory_stream)
23+
func()
24+
return in_memory_stream.getvalue()
25+
26+
# When run as :
27+
# $ test_client_exit_functions.py "misc_string" "STAGE_NAME_1" "STAGE_NAME_2" ...
28+
# this script transforms the argv vector into an output of the form:
29+
# [misc_string][<STAGE_NAME_1.value>][<STAGE_NAME_2.value>]...
30+
# where the value attribute is the STAGE_NAME_x's assigned enum value.
31+
# The square brackets are literal.
32+
# As indicated, any string (including the empty string) that does not name a
33+
# particular stage name is left untransformed.
34+
35+
if __name__ == '__main__':
36+
37+
function_info = [[*get_stage_object_and_value_from_name(name),object_printer(name)] for name in reversed(sys.argv[1:])]
38+
39+
# For each argument to the script that represents a cleanup stage: register a function to print the stage's value, to be run during that stage.
40+
for key in function_info.copy():
41+
(stage,name,func) = key
42+
if not stage:
43+
continue
44+
irods.at_client_exit._register(stage, func)
45+
function_info.remove(key)
46+
47+
# Immediately execute any leftover functions, since the test expects the corresponding output to precede that of their counterparts run during cleanup.
48+
for stage, name, func in function_info:
49+
func()

0 commit comments

Comments
 (0)