Skip to content

Commit b0a523f

Browse files
d-w-mooretrel
authored andcommitted
[#471][#472] allow save, load, and autoload of configuration
1 parent 2ba0b68 commit b0a523f

7 files changed

Lines changed: 339 additions & 7 deletions

File tree

README.rst

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,37 @@ This may be useful for Python programs in which frequent flushing of write updat
314314
with descriptors on such objects possibly being held open for indeterminately long lifetimes -- yet the eventual
315315
application of those updates prior to the teardown of the Python interpreter is required.
316316

317+
The current value of the setting is global in scope (ie applies to all sessions, whenever created) and is always
318+
consulted for the creation of any data object handle to govern that handle's cleanup behavior.
319+
320+
Python iRODS Client Settings File
321+
---------------------------------
322+
323+
As of v1.1.9, Python iRODS client configuration can be saved in, and loaded from, a settings file.
324+
325+
If the settings file exists, each of its lines contains (a) a dotted name identifying a particular configuration setting
326+
to be assigned within the PRC, potentially changing its runtime behavior; and (b) the specific value, in Python "repr"-style
327+
format, that should be assigned into it.
328+
329+
An example follows:
330+
331+
data_objects.auto_close True
332+
333+
New dotted names may be created following the example of the one valid example created thus far,
334+
code:`data_objects.auto_close`, initialized in :code:`irods/client_configuration/__init__.py`. Each such name should correspond
335+
to a globally set value which the PRC routinely checks when performing the affected library function.
336+
337+
The use of a settings file can be indicated, and the path to that file determined, by setting the environment variable:
338+
:code:`PYTHON_IRODSCLIENT_CONFIGURATION_PATH`. If this variable is present but empty, this denotes use of a default settings
339+
file path of :code:~/.python-irodsclient`; if the variable's value is of nonzero length, the value should be an absolute path
340+
to the settings file whose use is desired. Also, if the variable is set, auto-load of settings will be performed, meaning
341+
that the act of importing :code:`irods` or any of its submodules will cause the automatic loading the settings from the
342+
settings file, assuming it exists. (Failure to find the file at the indicated path will be logged as a warning.)
343+
344+
Settings can also be saved and loaded manually using the save() and load() functions in the :code:`irods.client_configuration`
345+
module. Each of these functions accepts an optional :code:`file` parameter which, if set to a non-empty string, will override
346+
the settings file path currently "in force" (i.e., the CONFIG_DEFAULT_PATH, as optionally overridden by the environment variable
347+
PYTHON_IRODSCLIENT_CONFIGURATION_PATH).
317348

318349
Computing and Retrieving Checksums
319350
----------------------------------

irods/__init__.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
from .version import __version__
22

33
import logging
4+
import os
5+
6+
# This has no effect if basicConfig() was previously called.
7+
logging.basicConfig()
8+
49
logger = logging.getLogger(__name__)
510
logger.addHandler(logging.NullHandler())
611
gHandler = None
@@ -50,3 +55,18 @@ def client_logging(flag=True,handler=None):
5055

5156
PAM_AUTH_PLUGIN = 'PAM'
5257
PAM_AUTH_SCHEME = PAM_AUTH_PLUGIN.lower()
58+
59+
DEFAULT_CONFIG_PATH = os.path.expanduser('~/.python_irodsclient')
60+
settings_path_environment_variable = 'PYTHON_IRODSCLIENT_CONFIGURATION_PATH'
61+
62+
def get_settings_path():
63+
env_var = os.environ.get(settings_path_environment_variable)
64+
return DEFAULT_CONFIG_PATH if not env_var else env_var
65+
66+
from . import client_configuration
67+
68+
client_configuration.preserve_defaults()
69+
70+
# If the settings path variable is not set in the environment, a value of None is passed,
71+
# and thus no settings file is auto-loaded.
72+
client_configuration.autoload(_file_to_load = os.environ.get(settings_path_environment_variable))

irods/client_configuration/__init__.py

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,30 @@
33
import copy
44
import io
55
import logging
6+
import os
67
import re
78
import sys
89
import types
910

11+
# Duplicate here for convenience
12+
from .. import DEFAULT_CONFIG_PATH
13+
1014
logger = logging.Logger(__name__)
1115

1216
class iRODSConfiguration(object):
1317
__slots__ = ()
1418

1519
def getter(category, setting):
20+
"""A programmatic way of allowing the current value of the specified setting to be
21+
given indirectly (through an extra call indirection) as the default value of a parameter.
22+
23+
Returns a lambda that, when called, will yield the setting's value. In the closure of
24+
that lambda, the Python builtin function globals() is used to access (in a read-only
25+
capacity) the namespace dict of the irods.client_configuration module.
26+
27+
See the irods.manager.data_object_manager.DataObjectManager.open(...) method signature
28+
for a usage example.
29+
"""
1630
return lambda:getattr(globals()[category], setting)
1731

1832
# #############################################################################
@@ -48,3 +62,162 @@ def __init__(self):
4862
# listed in the __slots__ member of the category class.
4963

5064
data_objects = DataObjects()
65+
66+
def _var_items(root):
67+
if isinstance(root,types.ModuleType):
68+
return [(i,v) for i,v in vars(root).items()
69+
if isinstance(v,iRODSConfiguration)]
70+
if isinstance(root,iRODSConfiguration):
71+
return [(i, getattr(root,i)) for i in root.__slots__]
72+
return []
73+
74+
def save(root = None, string='', file = ''):
75+
"""Save the current configuration.
76+
77+
When called simply as save(), this function simply writes all client settings into
78+
a configuration file.
79+
80+
The 'root' and 'string' parameters are not likely to be overridden when called from an
81+
application. They should usually only vary from the defaults when save() recurses into itself.
82+
However, for due explanation's sake: 'root' specifies at which subtree node to start writing,
83+
None denoting the top level; and 'string' specifies a prefix for the dotted prefix name,
84+
which should be empty for an invocation that references the settings' top level namespace.
85+
Both of these defaults are in effect when calling save() without explicit parameters.
86+
87+
The configuration file path will normally be the value of DEFAULT_CONFIG_PATH,
88+
but this can be overridden by supplying a non-empty string in the 'file' parameter.
89+
"""
90+
_file = None
91+
auto_close_settings = False
92+
try:
93+
if not file:
94+
from .. import get_settings_path
95+
file = get_settings_path()
96+
if isinstance(file,str):
97+
_file = open(file,'w')
98+
auto_close_settings = True
99+
else:
100+
_file = file # assume file-like object if not a string
101+
if root is None:
102+
root = sys.modules[__name__]
103+
for k,v in _var_items(root):
104+
dotted_string = string + ("." if string else "") + k
105+
if isinstance(v,iRODSConfiguration):
106+
save(root = v, string = dotted_string, file = _file)
107+
else:
108+
print(dotted_string, repr(v), sep='\t\t', file = _file)
109+
return file
110+
finally:
111+
if _file and auto_close_settings:
112+
_file.close()
113+
114+
def _load_config_line(root, setting, value):
115+
arr = [_.strip() for _ in setting.split('.')]
116+
# Compute the object referred to by the dotted name.
117+
attr = ''
118+
for i in filter(None,arr):
119+
if attr:
120+
root = getattr(root,attr)
121+
attr = i
122+
# Assign into the current setting of the dotted name (effectively <root>.<attr>)
123+
# using the loaded value.
124+
if attr:
125+
return setattr(root, attr, ast.literal_eval(value))
126+
error_message = 'Bad setting: root = {root!r}, setting = {setting!r}, value = {value!r}'.format(**locals())
127+
raise RuntimeError (error_message)
128+
129+
# The following regular expression is used to match a configuration file line of the form:
130+
# ---------------------------------------------------------------
131+
# <optional whitespace>
132+
# key: <dotted-name specification>
133+
# <whitespace of length 1 or more>
134+
# value: <A Python value which can be given to ast.literal_eval(); e.g. 5, True, or 'some_string'>
135+
# <optional whitespace>
136+
137+
_key_value_pattern = re.compile(r'\s*(?P<key>\w+(\.\w+)+)\s+(?P<value>\S.*?)\s*$')
138+
139+
class _ConfigLoadError:
140+
"""
141+
Exceptions that subclass this type can be thrown by the load() function if
142+
their classes are listed in the failure_modes parameter of that function.
143+
"""
144+
145+
class NoConfigError(Exception, _ConfigLoadError): pass
146+
class BadConfigError(Exception, _ConfigLoadError): pass
147+
148+
def load(root = None, file = '', failure_modes = (), logging_level = logging.WARNING):
149+
"""Load the current configuration.
150+
151+
An example of a valid line in a configuration file is this:
152+
153+
data_objects.auto_close True
154+
155+
When this function is called without parameters, it reads all client settings from
156+
a configuration file (the path given by DEFAULT_CONFIG_PATH, since file = '' in such
157+
an invocation) and assigns the repr()-style Python value given into the dotted-string
158+
configuration entry given.
159+
160+
The 'file' parameter, when set to a non-empty string, provides an override for
161+
the config-file path default.
162+
163+
As with save(), 'root' refers to the starting location in the settings tree, with
164+
a value of None denoting the top tree node (ie the namespace containing *all* settings).
165+
There are as yet no imagined use-cases for an application developer to pass in an
166+
explicit 'root' override.
167+
168+
'failure_modes' is an iterable containing desired exception types to be thrown if,
169+
for example, the configuration file is missing (NoConfigError) or contains an improperly
170+
formatted line (BadConfigError).
171+
172+
'logging_level' governs the internally logged messages and can be used to e.g. quiet the
173+
call's logging output.
174+
"""
175+
def _existing_config(path):
176+
if os.path.isfile(path):
177+
return open(path,'r')
178+
message = 'Config file not available at %r' % (path,)
179+
logging.getLogger(__name__).log(logging_level, message)
180+
if NoConfigError in failure_modes:
181+
raise NoConfigError(message)
182+
return io.StringIO()
183+
184+
_file = None
185+
try:
186+
if not file:
187+
from .. import get_settings_path
188+
file = get_settings_path()
189+
190+
_file = _existing_config(file)
191+
192+
if root is None:
193+
root = sys.modules[__name__]
194+
195+
for line_number, line in enumerate(_file.readlines()):
196+
line = line.strip()
197+
match = _key_value_pattern.match(line)
198+
if not match:
199+
if line != '':
200+
# Log only the invalid lines that contain non-whitespace characters.
201+
message = 'Invalid configuration format at line %d: %r' % (line_number+1, line)
202+
logging.getLogger(__name__).log(logging_level, message)
203+
if BadConfigError in failure_modes:
204+
raise BadConfigError(message)
205+
continue
206+
_load_config_line(root, match.group('key'), match.group('value'))
207+
finally:
208+
if _file:
209+
_file.close()
210+
211+
default_config_dict = {}
212+
213+
def preserve_defaults():
214+
default_config_dict.update((k,copy.deepcopy(v)) for k,v in globals().items() if isinstance(v,iRODSConfiguration))
215+
216+
def autoload(_file_to_load):
217+
if _file_to_load is not None:
218+
load(file = _file_to_load)
219+
220+
def new_default_config():
221+
module = types.ModuleType('_')
222+
module.__dict__.update(default_config_dict)
223+
return module

irods/connection.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
from ast import literal_eval as safe_eval
1313
import re
1414

15-
1615
PAM_PW_ESC_PATTERN = re.compile(r'([@=&;])')
1716

1817

irods/test/data_obj_test.py

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,12 @@ def is_localhost_synonym(name):
4141
import irods.test.helpers as helpers
4242
import irods.test.modules as test_modules
4343
import irods.keywords as kw
44+
import irods.client_configuration as config
4445
from irods.manager import data_object_manager
4546
from irods.message import RErrorStack
4647
from irods.message import ( ET, XML_Parser_Type, default_XML_parser, current_XML_parser )
4748
from datetime import datetime
48-
from tempfile import NamedTemporaryFile, mktemp
49+
from tempfile import NamedTemporaryFile, gettempdir
4950
from irods.test.helpers import (unique_name, my_function_name)
5051
from irods.ticket import Ticket
5152
import irods.parallel
@@ -1911,6 +1912,52 @@ def test_data_objects_auto_close_on_function_exit__issue_456(self):
19111912
data_object_path, expected_content = test_module.test(return_locals = ('name','expected_content'))
19121913
self._auto_close_test(data_object_path, expected_content)
19131914

1915+
@unittest.skipIf(helpers.configuration_file_exists(),"test would overwrite pre-existing configuration.")
1916+
def test_settings_save_and_autoload__issue_471(self):
1917+
import irods.test.modules.test_saving_and_loading_of_settings__issue_471 as test_module
1918+
truth = int(time.time())
1919+
test_output = test_module.test(truth)
1920+
self.assertEqual(test_output, str(truth))
1921+
1922+
def test_settings_load_and_save_471(self):
1923+
from irods import settings_path_environment_variable, get_settings_path, DEFAULT_CONFIG_PATH
1924+
settings_path = get_settings_path()
1925+
with helpers.file_backed_up(settings_path, require_that_file_exists = False):
1926+
1927+
RANDOM_VALUE=int(time.time())
1928+
config.data_objects.auto_close = RANDOM_VALUE
1929+
1930+
# Create empty settings file.
1931+
with open(settings_path,'w'):
1932+
pass
1933+
1934+
# For "silent" loading.
1935+
load_logging_options = {'logging_level':logging.DEBUG}
1936+
1937+
config.load(**load_logging_options)
1938+
1939+
# Load from empty settings should change nothing.
1940+
self.assertTrue(config.data_objects.auto_close, RANDOM_VALUE)
1941+
1942+
os.unlink(settings_path)
1943+
config.load(**load_logging_options)
1944+
# Load from nonexistent settings file should change nothing.
1945+
self.assertTrue(config.data_objects.auto_close, RANDOM_VALUE)
1946+
1947+
with helpers.environment_variable_backed_up(settings_path_environment_variable):
1948+
os.environ.pop(settings_path_environment_variable,None)
1949+
tmp_path = os.path.join(gettempdir(),'.prc')
1950+
for i, test_path in enumerate([None, '', tmp_path]):
1951+
if test_path is not None:
1952+
os.environ[settings_path_environment_variable] = test_path
1953+
# Check that load and save work as expected.
1954+
config.data_objects.auto_close = RANDOM_VALUE - i - 1
1955+
saved_path = config.save()
1956+
# File path should be as expected.
1957+
self.assertEqual(saved_path, (DEFAULT_CONFIG_PATH if not test_path else test_path))
1958+
config.data_objects.auto_close = RANDOM_VALUE
1959+
config.load(**load_logging_options)
1960+
self.assertTrue(config.data_objects.auto_close, RANDOM_VALUE - i - 1)
19141961

19151962
if __name__ == '__main__':
19161963
# let the tests find the parent irods lib

0 commit comments

Comments
 (0)