diff --git a/README.md b/README.md index 4edd3a39..1e6dcdab 100644 --- a/README.md +++ b/README.md @@ -732,7 +732,17 @@ QUASI_XML parser for the default one: ``` from irods.message import (XML_Parser_Type, ET) - ET( XML_Parser_Type.QUASI_XML, session.server_version ) + ET( XML_Parser_Type.QUASI_XML, + server_version = session.server_version + ) +``` + +The server_version parameter can be used independently, if desired, to change the +current thread's choice of entities during QUASI_XML transactions with the server. +(This is only a concern when interacting with servers before iRODS 4.2.9.) + +``` + ET(server_version = (4,2,8)) ``` Two dedicated environment variables may also be used to customize the @@ -755,8 +765,20 @@ particular server version. Finally, note that these global defaults, once set, may be overridden on a per-thread basis using `ET(parser_type, server_version)`. -We can also revert the current thread's XML parser back to the global -default by calling `ET(None)`. + +The current thread's XML parser can always be reverted to the global default by the +explicit use of `ET(None)`. However, when frequently switching back and forth between +parsers, it may be more convenient to use the `xml_mode` context manager: + +``` + # ... Interactions with the server now use the default XML parser. + + from irods.helpers import xml_mode + with xml_mode('QUASI_XML'): + # ... Interactions with the server, in the current thread, temporarily use QUASI_XML + + # ... We have now returned to using the default XML parser. +``` Rule Execution -------------- diff --git a/irods/helpers/__init__.py b/irods/helpers/__init__.py new file mode 100644 index 00000000..8ff805ff --- /dev/null +++ b/irods/helpers/__init__.py @@ -0,0 +1,37 @@ +import contextlib +import re +from ..test.helpers import (home_collection, + make_session as make_test_session) +from irods.message import (ET, XML_Parser_Type) + +__all__ = ['make_session', 'home_collection', 'xml_mode'] + + +def make_session(test_server_version = False, **kwargs): + return make_test_session(test_server_version = test_server_version, **kwargs) + +make_session.__doc__ = re.sub(r'(test_server_version\s*)=\s*\w+',r'\1 = False',make_test_session.__doc__) + + +@contextlib.contextmanager +def xml_mode(s): + """In a with-block, this context manager can temporarily change the client's choice of XML parser. + + Example usages: + with("QUASI_XML"): + # ... + with(XML_Parser_Type.QUASI_XML): + # ...""" + + try: + if isinstance(s,str): + ET(getattr(XML_Parser_Type,s)) # e.g. xml_mode("QUASI_XML") + elif isinstance(s,XML_Parser_Type): + ET(s) # e.g. xml_mode(XML_Parser_Type.QUASI_XML) + else: + msg = "xml_mode argument must be a string (e.g. 'QUASI_XML') or an XML_Parser_Type enum." + raise ValueError(msg) + yield + finally: + ET(None) + diff --git a/irods/message/__init__.py b/irods/message/__init__.py index 3761e4a6..ee28a219 100644 --- a/irods/message/__init__.py +++ b/irods/message/__init__.py @@ -67,7 +67,9 @@ def __repr__(self): XML_Parser_Type.__members__ = {k:v for k,v in XML_Parser_Type.__dict__.items() if isinstance(v,XML_Parser_Type)} - PARSER_TYPE_STRINGS = {v:k for k,v in XML_Parser_Type.__members__.items() if v.value != 0} +# This creates a mapping from the "valid" (nonzero) XML_Parser_Type enums -- those which represent the actual parser +# choices -- to their corresponding names as strings (e.g. XML_Parser_Type.STANDARD_XML is mapped to 'STANDARD_XML'): +PARSER_TYPE_STRINGS = {v:k for k,v in XML_Parser_Type.__members__.items() if v.value != 0} # We maintain values on a per-thread basis of: # - the server version with which we're communicating @@ -111,6 +113,9 @@ def default_XML_parser(get_module = False): d = _default_XML return d if not get_module else _XML_parsers[d] +def string_for_XML_parser(parser_enum): + return PARSER_TYPE_STRINGS[parser_enum] + _XML_parsers = { XML_Parser_Type.STANDARD_XML : ET_xml, XML_Parser_Type.QUASI_XML : ET_quasi_xml, diff --git a/irods/test/data_obj_test.py b/irods/test/data_obj_test.py index 05cb3254..67f1ffec 100644 --- a/irods/test/data_obj_test.py +++ b/irods/test/data_obj_test.py @@ -1860,6 +1860,57 @@ def test_access_through_resc_hierarchy__243(self): finally: s.resources.remove('parent') + @unittest.skipIf(set(os.environ.keys()) & {'PYTHON_IRODSCLIENT_CONFIG__CONNECTIONS__XML_PARSER_DEFAULT', + 'PYTHON_IRODSCLIENT_CONFIGURATION_PATH', 'PYTHON_IRODSCLIENT_DEFAULT_XML'}, + "skipping due to possible overwriting of test-apropos settings by a configuration file or environment setting") + def test_temporary_xml_mode_change_with_operation_as_proof__issue_586(self): + from irods.helpers import (xml_mode, home_collection) + sess = irods.test.helpers.make_session() + hc = home_collection(sess) + odd_name = '{hc}/\1'.format(**locals()) + + # Currently 'STANDARD_XML' is the default, and 'QUASI_XML' is a convenient alternative to use when + # object names are used which contain special characters (e.g. '\1') hostile to standard XML parsers. + default_xml_parser = 'STANDARD_XML' + + from irods.message import (current_XML_parser, string_for_XML_parser) + active_xml_parser_for_thread = lambda : string_for_XML_parser(current_XML_parser()) + + self.assertEqual(active_xml_parser_for_thread(), default_xml_parser) + + with xml_mode('QUASI_XML'): + sess.data_objects.create(odd_name) + + # Test that the xml parser setting isn't permanently changed + self.assertEqual(active_xml_parser_for_thread(), default_xml_parser) + + try: + if default_xml_parser == 'STANDARD_XML': + with self.assertRaises(xml.etree.ElementTree.ParseError): + sess.collections.get(hc).data_objects + finally: + with xml_mode('QUASI_XML'): + sess.data_objects.unlink(odd_name, force = True) + + def test_temporary_xml_mode_changes_have_desired_thread_limited_effect__issue_586(self): + from irods.message import (current_XML_parser, string_for_XML_parser) + active_xml_parser_for_thread = lambda : string_for_XML_parser(current_XML_parser()) + from concurrent.futures import ThreadPoolExecutor + from irods.helpers import xml_mode + original_xml_parser = active_xml_parser_for_thread() + other_xml_parser = list({'STANDARD_XML', 'QUASI_XML', 'SECURE_XML'} - {original_xml_parser})[0] + + self.assertNotEqual(other_xml_parser, original_xml_parser) + + with xml_mode(other_xml_parser): + # Test that this thread is the only one affected, and that in it we get 'QUASI_XML' when we call + # current_XML_parser(), i.e. the function used internally by ET() to retrieve the current parser module. + self.assertEqual(other_xml_parser, active_xml_parser_for_thread()) + self.assertEqual(original_xml_parser, ThreadPoolExecutor(max_workers = 1).submit(active_xml_parser_for_thread).result()) + + self.assertEqual(active_xml_parser_for_thread(), original_xml_parser) + + def test_register_with_xml_special_chars(self): test_dir = helpers.irods_shared_tmp_dir() loc_server = self.sess.host in ('localhost', socket.gethostname()) diff --git a/irods/test/helpers.py b/irods/test/helpers.py index 4ea2ef35..61ec4962 100644 --- a/irods/test/helpers.py +++ b/irods/test/helpers.py @@ -151,6 +151,20 @@ def recast(k): # Create a connection for test, based on ~/.irods environment by default. def make_session(test_server_version = True, **kwargs): + """Connect to an iRODS server as determined by any client environment + file present at a standard location, and by any keyword arguments given. + + Arguments: + + test_server_version: Of type bool; in the `irods.test.helpers` version of this + function, defaults to True. A True value causes + *iRODS_Server_Too_Recent* to be raised if the server + connected to is more recent than the current Python iRODS + client's advertised level of compatibility. + + **kwargs: Keyword arguments. Fed directly to the iRODSSession + constructor. """ + try: env_file = kwargs.pop('irods_env_file') except KeyError: @@ -159,7 +173,6 @@ def make_session(test_server_version = True, **kwargs): except KeyError: env_file = os.path.expanduser('~/.irods/irods_environment.json') session = iRODSSession( irods_env_file = env_file, **kwargs ) - if test_server_version: connected_version = session.server_version[:3] advertised_version = IRODS_VERSION[:3] @@ -172,6 +185,7 @@ def make_session(test_server_version = True, **kwargs): def home_collection(session): + """Return a string value for the given session's home collection.""" return "/{0.zone}/home/{0.username}".format(session)