diff --git a/.gitignore b/.gitignore
index 120928f..6a1275a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -31,6 +31,7 @@ nosetests.xml
 .settings
 .project
 .pydevproject
+.idea
 
 # Vim
 *.s[a-w][a-z]
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 0aab573..c92c602 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -1,6 +1,135 @@
 Changelog
 =========
 
+v1.1.2 (2022-03-15)
+-------------------
+- [#3][#345] Allow tests to pass and accommodate older Python [Daniel Moore]
+- [#352] Fix the infinite loop issue when sock.recv() returns an empty buffer [Kaivan Kamali]
+- [#345] Fix connection destructor issue [Kaivan Kamali]
+- [#351] replace 704 api constant with AUTH_RESPONSE_AN [Daniel Moore]
+- [#350] password input to AUTH_RESPONSE_AN should be string [Daniel Moore]
+- [#315] skip cleanup() if session.pool is None [Daniel Moore]
+- [#290] only anonymous user can log in without password [Daniel Moore]
+- [#43][#328] reasonable indentation [Daniel Moore]
+- [#328] allow user to change own password [Daniel Moore]
+- [#343][#21] document testing and S3 setup [Daniel Moore]
+- [#343] allow parallel (multi-1247) data transfer to/from S3 [Daniel Moore]
+- [#332] capitalize -C,-R object type abbreviations [Daniel Moore]
+- [#349] normalize() argument not necessarily absolute [Daniel Moore]
+- [#323] remove trailing slashes in collection names [Daniel Moore]
+
+v1.1.1 (2022-01-31)
+-------------------
+- [#338] clarify Python RE Plugin limitations [Daniel Moore]
+- [#339] correction to README regarding RULE_ENGINE_ERROR [Daniel Moore]
+- [#336] rule files can now be submitted from a memory file object [Daniel Moore]
+
+v1.1.0 (2022-01-20)
+-------------------
+- [#334] add SECURE_XML to parser selection [Daniel Moore]
+- [#279] allow long tokens via PamAuthRequest [Daniel Moore]
+- [#190] session_cleanup is optional after rule execution. [Daniel Moore]
+- [#288] Rule execute method can target an instance by name [Daniel Moore]
+- [#314] allow null parameter on INPUT line of a rule file [Daniel Moore]
+- [#318] correction for unicode name queries in Python 2 [Daniel Moore]
+- [#170] fixes for Python2 / ElementTree compatibility [Daniel Moore]
+- [#170] Fix exception handling QuasiXML parser [Sietse Snel]
+- [#170] Parse current iRODS XML protocol [Chris Smeele]
+- [#306] test setting/resetting inheritance [Daniel Moore]
+- [#297] deal with CHECK_VERIFICATION_RESULTS for checksums [Daniel Moore]
+- [irods/irods#5933] PRC ticket API now working with ADMIN_KW [Daniel Moore]
+- [#292] Correct tickets section in README [Daniel Moore]
+- [#290] allow skipping of password file in anonymous user case [Daniel Moore]
+- [irods/irods#5954] interpret timestamps as UTC instead of local time [Daniel Moore]
+- [#294] allow data object get() to work with tickets enabled [Daniel Moore]
+- [#303] Expose additional iRODS collection information in the Collection object. [Ruben Garcia]
+- [#143] Use unittest-xml-reporting package, move to extra [Michael R. Crusoe]
+- [#299] Added GenQuery support for tickets. [Kory Draughn]
+- [#285] adds tests for irods/irods#5548 and irods/irods#5848 [Daniel Moore]
+- [#281] honor the irods_ssl_verify_server setting. [Daniel Moore]
+- [#287] allow passing RError stack through CHKSUM library call [Daniel Moore]
+- [#282] add NO_COMPUTE keyword [Daniel Moore]
+
+v1.0.0 (2021-06-03)
+-------------------
+- [#274] calculate common vault dir for unicode query tests [Daniel Moore]
+- [#269] better session cleanup [Daniel Moore]
+
+v0.9.0 (2021-05-14)
+-------------------
+- [#269] cleanup() is now automatic with session destruct [Daniel Moore]
+- [#235] multithreaded parallel transfer for PUT and GET [Daniel Moore]
+- [#232] do not arbitrarily pick first replica for DEST RESC [Daniel Moore]
+- [#233] add null handler for irods package root [Daniel Moore]
+- [#246] implementation of checksum for data object manager [Daniel Moore]
+- [#270] speed up tests [Daniel Moore]
+- [#260] [irods/irods#5520] XML protocol will use BinBytesBuf in 4.2.9 [Daniel Moore]
+- [#221] prepare test suite for CI [Daniel Moore]
+- [#267] add RuleExec model for genquery [Daniel Moore]
+- [#263] update documentation for connection_timeout [Terrell Russell]
+- [#261] add temporary password support [Paul van Schayck]
+- [#257] better SSL examples [Terrell Russell]
+- [#255] make results of atomic metadata operations visible [Daniel Moore]
+- [#250] add exception for SYS_INVALID_INPUT_PARAM [Daniel Moore]
+
+v0.8.6 (2021-01-22)
+-------------------
+- [#244] added capability to add/remove atomic metadata [Daniel Moore]
+- [#226] Document creation of users [Ruben Garcia]
+- [#230] Add force option to data_object_manager create [Ruben Garcia]
+- [#239] to keep the tests passing [Daniel Moore]
+- [#239] add iRODSUser.info attribute [Pierre Gay]
+- [#239] add iRODSUser.comment attribute [Pierre Gay]
+- [#241] [irods/irods_capability_automated_ingest#136] fix redundant disconnect [Daniel Moore]
+- [#227] [#228] enable ICAT entries for zones and foreign-zone users [Daniel Moore]
+
+v0.8.5 (2020-11-10)
+-------------------
+- [#220] Use connection create time to determine stale connections [Kaivan Kamali]
+
+v0.8.4 (2020-10-19)
+-------------------
+- [#221] fix tests which were failing in Py3.4 and 3.7 [Daniel Moore]
+- [#220] Replace stale connections pulled from idle pools [Kaivan Kamali]
+- [#3] tests failing on Python3 unicode defaults [Daniel Moore]
+- [#214] store/load rules as utf-8 in files [Daniel Moore]
+- [#211] set and report application name to server [Daniel Moore]
+- [#156] skip ssh/pam login tests if user doesn't exist [Daniel Moore]
+- [#209] pam/ssl/env auth tests imported from test harness [Daniel Moore]
+- [#209] store hashed PAM pw [Daniel Moore]
+- [#205] Disallow PAM plaintext passwords as strong default [Daniel Moore]
+- [#156] fix the PAM authentication with env json file. [Patrice Linel]
+- [#207] add raw-acl permissions getter [Daniel Moore]
+
+v0.8.3 (2020-06-05)
+-------------------
+- [#3] remove order sensitivity in test_user_dn [Daniel Moore]
+- [#5] clarify unlink specific replica example [Terrell Russell]
+- [irods/irods#4796] add data object copy tests [Daniel Moore]
+- [#5] Additional sections and examples in README [Daniel Moore]
+- [#187] Allow query on metadata create and modify times [Daniel Moore]
+- [#135] fix queries for multiple AVUs of same name [Daniel Moore]
+- [#135] Allow multiple criteria based on column name [Daniel Moore]
+- [#180] add the "in" genquery operator [Daniel Moore]
+- [#183] fix key error when tables from order_by() not in query() [Daniel Moore]
+- [#5] fix ssl example in README.rst [Terrell Russell]
+
+v0.8.2 (2019-11-13)
+-------------------
+- [#8] Add PAM Authentication handling (still needs tests) [Mattia D'Antonio]
+- [#5] Remove commented-out import [Alan King]
+- [#5] Add .idea directory to .gitignore [Jonathan Landrum]
+- [#150] Fix specific query argument labeling [Chris Klimowski]
+- [#148] DataObjectManager.put() can return the new data_object [Jonathan Landrum]
+- [#124] Convert strings going to irods to Unicode [Alan King]
+- [#161] Allow dynamic I/O for rule from file [Mathijs Koymans]
+- [#162] Include resc_hier in replica information [Brett Hartley]
+- [#165] Fix CAT_STATEMENT_TABLE_FULL by auto closing queries [Chris Smeele]
+- [#166] Test freeing statements in unfinished query [Daniel Moore]
+- [#167] Add metadata for user and usergroup objects [Erwin van Wieringen]
+- [#175] Add metadata property for instances of iRODSResource [Daniel Moore]
+- [#163] add keywords to query objects [Daniel Moore]
+
 v0.8.1 (2018-09-27)
 -------------------
 - [#140] Remove randomization from password test [Alan King]
diff --git a/Dockerfile.prc_test.centos b/Dockerfile.prc_test.centos
new file mode 100644
index 0000000..debed6d
--- /dev/null
+++ b/Dockerfile.prc_test.centos
@@ -0,0 +1,29 @@
+ARG  os_image
+FROM ${os_image}
+ARG  log_output_dir=/tmp
+ENV  LOG_OUTPUT_DIR="$log_output_dir"
+ARG  py_N
+ENV  PY_N "$py_N"
+
+RUN  yum install -y epel-release
+RUN  yum install -y git nmap-ncat sudo
+RUN  yum install -y python${py_N} python${py_N}-pip
+RUN  useradd -md /home/user -s /bin/bash user
+RUN  echo "user ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers
+WORKDIR /home/user
+COPY ./ ./repo/
+RUN chown -R user repo/
+USER user
+RUN  pip${py_N} install --user --upgrade pip==20.3.4 # - limit pip version for C7 system python2.7
+RUN  cd repo && python${py_N} -m pip install --user '.[tests]'
+RUN  python${py_N} repo/docker_build/iinit.py \
+        host irods-provider \
+        port 1247     \
+        user rods     \
+        zone tempZone \
+        password rods
+SHELL ["/bin/bash","-c"]
+CMD  echo "Waiting on iRODS server... " ; \
+     python${PY_N} repo/docker_build/recv_oneshot -h irods-provider -p 8888 -t 360 && \
+     sudo groupadd -o -g $(stat -c%g /irods_shared) irods && sudo usermod -aG irods user && \
+     newgrp irods < repo/run_python_tests.sh
diff --git a/Dockerfile.prc_test.ubuntu b/Dockerfile.prc_test.ubuntu
new file mode 100644
index 0000000..e8c958a
--- /dev/null
+++ b/Dockerfile.prc_test.ubuntu
@@ -0,0 +1,36 @@
+ARG  os_image
+FROM ${os_image}
+ARG  log_output_dir=/tmp
+ENV  LOG_OUTPUT_DIR="$log_output_dir"
+ARG  py_N
+ENV  PY_N "$py_N"
+
+RUN  apt update
+RUN  apt install -y git netcat-openbsd sudo
+RUN  apt install -y python${py_N} python${py_N}-pip
+RUN  useradd -md /home/user -s /bin/bash user
+RUN  echo "user ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers
+WORKDIR /home/user
+COPY ./ ./repo/
+RUN chown -R user repo/
+USER user
+RUN  pip${py_N} install --user --upgrade pip==20.3.4  # -- version specified for Ub16
+RUN  cd repo && python${py_N} -m pip install --user '.[tests]'
+RUN  python${py_N} repo/docker_build/iinit.py \
+        host irods-provider \
+        port 1247     \
+        user rods     \
+        zone tempZone \
+        password rods
+
+SHELL ["/bin/bash","-c"]
+
+# -- At runtime: --
+#  1. wait for provider to run.
+#  2. give user group permissions to access shared irods directories
+#  3. run python tests as the new group
+
+CMD  echo "Waiting on iRODS server... " ; \
+     python${PY_N} repo/docker_build/recv_oneshot -h irods-provider -p 8888 -t 360 && \
+     sudo groupadd -o -g $(stat -c%g /irods_shared) irods && sudo usermod -aG irods user && \
+     newgrp irods < repo/run_python_tests.sh
diff --git a/MANIFEST.in b/MANIFEST.in
index 3c469e1..7d5f943 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1 +1 @@
-include AUTHORS CHANGELOG.rst LICENSE.txt README.rst irods/test/README.rst irods/test/unicode_sampler.xml
\ No newline at end of file
+include AUTHORS CHANGELOG.rst LICENSE.txt README.rst irods/test/README.rst irods/test/unicode_sampler.xml irods/test/test-data/*.json
\ No newline at end of file
diff --git a/README.rst b/README.rst
index 4c2698c..4ae168e 100644
--- a/README.rst
+++ b/README.rst
@@ -2,46 +2,47 @@
 Python iRODS Client (PRC)
 =========================
 
-`iRODS <https://www.irods.org>`_ is an open source distributed data management system. This is a client API implemented in python.
+`iRODS <https://www.irods.org>`_ is an open source distributed data management system. This is a client API implemented in Python.
 
 Currently supported:
 
-- Establish a connection to iRODS, authenticate
-- Implement basic Gen Queries (select columns and filtering)
-- Support more advanced Gen Queries with limits, offsets, and aggregations
+- Python 2.7, 3.4 or newer
+- Establish a connection to iRODS
+- Authenticate via password, GSI, PAM
+- iRODS connection over SSL
+- Implement basic GenQueries (select columns and filtering)
+- Support more advanced GenQueries with limits, offsets, and aggregations
 - Query the collections and data objects within a collection
 - Execute direct SQL queries
 - Execute iRODS rules
 - Support read, write, and seek operations for files
-- PUT/GET data objects
-- Create data objects
-- Delete data objects
+- Parallel PUT/GET data objects
 - Create collections
+- Rename collections
 - Delete collections
+- Create data objects
 - Rename data objects
-- Rename collections
+- Checksum data objects
+- Delete data objects
 - Register files and directories
 - Query metadata for collections and data objects
 - Add, edit, remove metadata
 - Replicate data objects to different resource servers
 - Connection pool management
-- Implement gen query result sets as lazy queries
+- Implement GenQuery result sets as lazy queries
 - Return empty result sets when CAT_NO_ROWS_FOUND is raised
 - Manage permissions
 - Manage users and groups
 - Manage resources
-- GSI authentication
 - Unicode strings
 - Ticket based access
-- iRODS connection over SSL
-- Python 2.7, 3.4 or newer
 
 
 Installing
 ----------
 
 PRC requires Python 2.7 or 3.4+.
-To install with pip::
+Canonically, to install with pip::
 
  pip install python-irodsclient
 
@@ -49,7 +50,6 @@ or::
 
  pip install git+https://github.com/irods/python-irodsclient.git[@branch|@commit|@tag]
 
-
 Uninstalling
 ------------
 
@@ -57,21 +57,39 @@ Uninstalling
 
  pip uninstall python-irodsclient
 
+Hazard: Outdated Python
+--------------------------
+With older versions of Python (as of this writing, the aforementioned 2.7 and 3.4), we
+can take preparatory steps toward securing workable versions of pip and virtualenv by
+using these commands::
 
-Establishing a connection
--------------------------
+    $ pip install --upgrade --user pip'<21.0'
+    $ python -m pip install --user virtualenv
+
+We are then ready to use any of the following commands relevant to and required for the
+installation::
 
-Using environment files in ``~/.irods/``:
+    $ python -m virtualenv ... 
+    $ python -m pip install ...
+
+
+Establishing a (secure) connection
+----------------------------------
+
+Using environment files (including any SSL settings) in ``~/.irods/``:
 
 >>> import os
+>>> import ssl
 >>> from irods.session import iRODSSession
 >>> try:
 ...     env_file = os.environ['IRODS_ENVIRONMENT_FILE']
 ... except KeyError:
 ...     env_file = os.path.expanduser('~/.irods/irods_environment.json')
 ...
->>> with iRODSSession(irods_env_file=env_file) as session:
-...     pass
+>>> ssl_context = ssl.create_default_context(purpose=ssl.Purpose.SERVER_AUTH, cafile=None, capath=None, cadata=None)
+>>> ssl_settings = {'ssl_context': ssl_context}
+>>> with iRODSSession(irods_env_file=env_file, **ssl_settings) as session:
+...     # workload
 ...
 >>>
 
@@ -79,7 +97,7 @@ Passing iRODS credentials as keyword arguments:
 
 >>> from irods.session import iRODSSession
 >>> with iRODSSession(host='localhost', port=1247, user='bob', password='1234', zone='tempZone') as session:
-...     pass
+...     # workload
 ...
 >>>
 
@@ -88,12 +106,76 @@ If you're an administrator acting on behalf of another user:
 >>> from irods.session import iRODSSession
 >>> with iRODSSession(host='localhost', port=1247, user='rods', password='1234', zone='tempZone',
            client_user='bob', client_zone='possibly_another_zone') as session:
-...     pass
+...     # workload
 ...
 >>>
 
 If no ``client_zone`` is provided, the ``zone`` parameter is used in its place.
 
+A pure Python SSL session (without a local `env_file`) requires a few more things defined:
+
+>>> import ssl
+>>> from irods.session import iRODSSession 
+>>> ssl_context = ssl.create_default_context(purpose=ssl.Purpose.SERVER_AUTH, cafile='CERTNAME.crt', capath=None, cadata=None)
+>>> ssl_settings = {'client_server_negotiation': 'request_server_negotiation',
+...                'client_server_policy': 'CS_NEG_REQUIRE',
+...                'encryption_algorithm': 'AES-256-CBC',
+...                'encryption_key_size': 32,
+...                'encryption_num_hash_rounds': 16,
+...                'encryption_salt_size': 8,                        
+...                'ssl_context': ssl_context}
+>>>
+>>> with iRODSSession(host='HOSTNAME_DEFINED_IN_CAFILE_ABOVE', port=1247, user='bob', password='1234', zone='tempZone', **ssl_settings) as session:
+...	# workload
+>>>
+
+
+Maintaining a connection
+------------------------
+
+The default library timeout for a connection to an iRODS Server is 120 seconds.
+
+This can be overridden by changing the session `connection_timeout` immediately after creation of the session object:
+
+>>> session.connection_timeout = 300
+
+This will set the timeout to five minutes for any associated connections.
+
+Session objects and cleanup
+---------------------------
+
+When iRODSSession objects are kept as state in an application, spurious SYS_HEADER_READ_LEN_ERR errors
+can sometimes be seen in the connected iRODS server's log file. This is frequently seen at program exit
+because socket connections are terminated without having been closed out by the session object's 
+cleanup() method.
+
+Starting with PRC Release 0.9.0, code has been included in the session object's __del__ method to call
+cleanup(), properly closing out network connections.  However, __del__ cannot be relied to run under all
+circumstances (Python2 being more problematic), so an alternative may be to call session.cleanup() on
+any session variable which might not be used again.
+
+
+Simple PUTs and GETs
+--------------------
+
+We can use the just-created session object to put files to (or get them from) iRODS.
+
+>>> logical_path = "/{0.zone}/home/{0.username}/{1}".format(session,"myfile.dat")
+>>> session.data_objects.put( "myfile.dat", logical_path)
+>>> session.data_objects.get( logical_path, "/tmp/myfile.dat.copy" )
+
+Note that local file paths may be relative, but iRODS data objects must always be referred to by
+their absolute paths.  This is in contrast to the ``iput`` and ``iget`` icommands, which keep
+track of the current working collection (as modified by ``icd``) for the unix shell.
+
+
+Parallel Transfer
+-----------------
+
+Starting with release 0.9.0, data object transfers using put() and get() will spawn a number
+of threads in order to optimize performance for iRODS server versions 4.2.9+ and file sizes
+larger than a default threshold value of 32 Megabytes.
+
 
 Working with collections
 ------------------------
@@ -165,6 +247,27 @@ Put an existing file as a new data object:
 56789
 
 
+Specifying paths
+----------------
+
+Path strings for collection and data objects are usually expected to be absolute in most contexts in the PRC. They
+must also be normalized to a form including single slashes separating path elements and no slashes at the string's end.
+If there is any doubt that a path string fulfills this requirement, the wrapper class :code:`irods.path.iRODSPath`
+(a subclass of :code:`str`) may be used to normalize it::
+
+    if not session.collections.exists( iRODSPath( potentially_unnormalized_path )): #....
+
+The wrapper serves also as a path joiner; thus::
+
+    iRODSPath( zone, "home", user )
+
+may replace::
+
+    "/".join(["", zone, "home", user])
+
+:code:`iRODSPath` is available beginning with PRC release :code:`v1.1.2`.
+
+
 Reading and writing files
 -------------------------
 
@@ -181,29 +284,351 @@ foo
 bar
 
 
+Computing and Retrieving Checksums
+----------------------------------
+
+Each data object may be associated with a checksum by calling chksum() on the object in question.  Various
+behaviors can be elicited by passing in combinations of keywords (for a description of which, please consult the
+`header documentation <https://github.com/irods/irods/blob/4-2-stable/lib/api/include/dataObjChksum.h>`_ .)
+
+As with most other iRODS APIs, it is straightforward to specify keywords by adding them to an option dictionary:
+
+>>> data_object_1.chksum()  # - computes the checksum if already in the catalog, otherwise computes and stores it
+...                         #   (ie. default behavior with no keywords passed in.)
+>>> from irods.manager.data_object_manager import Server_Checksum_Warning
+>>> import irods.keywords as kw
+>>> opts = { kw.VERIFY_CHKSUM_KW:'' }
+>>> try:
+...     data_object_2.chksum( **opts )  # - Uses verification option. (Does not auto-vivify a checksum field).
+...     # or:
+...     opts[ kw.NO_COMPUTE_KW ] = ''
+...     data_object_2.chksum( **opts )  # - Uses both verification and no-compute options. (Like ichksum -K --no-compute)
+... except Server_Checksum_Warning:
+...     print('some checksums are missing or wrong')
+
+Additionally, if a freshly created irods.message.RErrorStack instance is given, information can be returned and read by
+the client:
+
+>>> r_err_stk = RErrorStack()
+>>> warn = None
+>>> try:  # Here, data_obj has one replica, not yet checksummed.
+...     data_obj.chksum( r_error = r_err_stk , **{kw.VERIFY_CHKSUM_KW:''} )
+... except Server_Checksum_Warning as exc:
+...     warn = exc
+>>> print(r_err_stk)
+[RError<message = u'WARNING: No checksum available for replica [0].', status = -862000 CAT_NO_CHECKSUM_FOR_REPLICA>]
+
+
 Working with metadata
 ---------------------
 
+To enumerate AVU's on an object. With no metadata attached, the result is an empty list:
+
+
+>>> from irods.meta import iRODSMeta
 >>> obj = session.data_objects.get("/tempZone/home/rods/test1")
 >>> print(obj.metadata.items())
 []
 
+
+We then add some metadata.
+Just as with the icommand equivalent "imeta add ...", we can add multiple AVU's with the same name field:
+
+
 >>> obj.metadata.add('key1', 'value1', 'units1')
 >>> obj.metadata.add('key1', 'value2')
 >>> obj.metadata.add('key2', 'value3')
+>>> obj.metadata.add('key2', 'value4')
+>>> print(obj.metadata.items())
+[<iRODSMeta 13182 key1 value1 units1>, <iRODSMeta 13185 key2 value4 None>,
+<iRODSMeta 13183 key1 value2 None>, <iRODSMeta 13184 key2 value3 None>]
+
+
+We can also use Python's item indexing syntax to perform the equivalent of an "imeta set ...", e.g. overwriting
+all AVU's with a name field of "key2" in a single update:
+
+
+>>> new_meta = iRODSMeta('key2','value5','units2')
+>>> obj.metadata[new_meta.name] = new_meta
 >>> print(obj.metadata.items())
-[<iRODSMeta (key1, value1, units1, 10014)>, <iRODSMeta (key2, value3, None, 10017)>,
-<iRODSMeta (key1, value2, None, 10020)>]
+[<iRODSMeta 13182 key1 value1 units1>, <iRODSMeta 13183 key1 value2 None>,
+ <iRODSMeta 13186 key2 value5 units2>]
+
+
+Now, with only one AVU on the object with a name of "key2", *get_one* is assured of not throwing an exception:
 
->>> print(obj.metadata.get_all('key1'))
-[<iRODSMeta (key1, value1, units1, 10014)>, <iRODSMeta (key1, value2, None, 10020)>]
 
 >>> print(obj.metadata.get_one('key2'))
-<iRODSMeta (key2, value3, None, 10017)>
+<iRODSMeta 13186 key2 value5 units2>
+
+
+However, the same is not true of "key1":
+
+
+>>> print(obj.metadata.get_one('key1'))
+Traceback (most recent call last):
+  File "<stdin>", line 1, in <module>
+  File "/[...]/python-irodsclient/irods/meta.py", line 41, in get_one
+    raise KeyError
+KeyError
+
+
+Finally, to remove a specific AVU from an object:
+
 
 >>> obj.metadata.remove('key1', 'value1', 'units1')
 >>> print(obj.metadata.items())
-[<iRODSMeta (key2, value3, None, 10017)>, <iRODSMeta (key1, value2, None, 10020)>]
+[<iRODSMeta 13186 key2 value5 units2>, <iRODSMeta 13183 key1 value2 None>]
+
+
+Alternately, this form of the remove() method can also be useful:
+
+
+>>> for avu in obj.metadata.items():
+...    obj.metadata.remove(avu)
+>>> print(obj.metadata.items())
+[]
+
+
+If we intended on deleting the data object anyway, we could have just done this instead:
+
+
+>>> obj.unlink(force=True)
+
+
+But notice that the force option is important, since a data object in the trash may still have AVU's attached.
+
+At the end of a long session of AVU add/manipulate/delete operations, one should make sure to delete all unused
+AVU's. We can in fact use any *\*Meta* data model in the queries below, since unattached AVU's are not aware
+of the (type of) catalog object they once annotated:
+
+
+>>> from irods.models import (DataObjectMeta, ResourceMeta)
+>>> len(list( session.query(ResourceMeta) ))
+4
+>>> from irods.test.helpers import remove_unused_metadata
+>>> remove_unused_metadata(session)
+>>> len(list( session.query(ResourceMeta) ))
+0
+
+
+Atomic operations on metadata
+-----------------------------
+
+With release 4.2.8 of iRODS, the atomic metadata API was introduced to allow a group of metadata add and remove
+operations to be performed transactionally, within a single call to the server.  This capability can be leveraged in
+version 0.8.6 of the PRC.
+
+So, for example, if 'obj' is a handle to an object in the iRODS catalog (whether a data object, collection, user or
+storage resource), we can send an arbitrary number of AVUOperation instances to be executed together as one indivisible
+operation on that object:
+
+>>> from irods.meta import iRODSMeta, AVUOperation
+>>> obj.metadata.apply_atomic_operations( AVUOperation(operation='remove', avu=iRODSMeta('a1','v1','these_units')),
+...                                       AVUOperation(operation='add', avu=iRODSMeta('a2','v2','those_units')),
+...                                       AVUOperation(operation='remove', avu=iRODSMeta('a3','v3')) # , ...
+... )
+
+The list of operations will applied in the order given, so that a "remove" followed by an "add" of the same AVU
+is, in effect, a metadata "set" operation.  Also note that a "remove" operation will be ignored if the AVU value given
+does not exist on the target object at that point in the sequence of operations.
+
+We can also source from a pre-built list of AVUOperations using Python's `f(*args_list)` syntax. For example, this
+function uses the atomic metadata API to very quickly remove all AVUs from an object:
+
+>>> def remove_all_avus( Object ):
+...     avus_on_Object = Object.metadata.items()
+...     Object.metadata.apply_atomic_operations( *[AVUOperation(operation='remove', avu=i) for i in avus_on_Object] )
+
+
+Special Characters
+------------------
+
+Of course, it is fine to put Unicode characters into your collection and data object names.  However, certain
+non-printable ASCII characters, and the backquote character as well, have historically presented problems -
+especially for clients using iRODS's human readable XML protocol.  Consider this small, only slighly contrived,
+application:
+::
+
+    from irods.test.helpers import make_session
+
+    def create_notes( session, obj_name, content = u'' ):
+        get_home_coll = lambda ses: "/{0.zone}/home/{0.username}".format(ses)
+        path = get_home_coll(session) + "/" + obj_name
+        with session.data_objects.open(path,"a") as f:
+            f.seek(0, 2) # SEEK_END
+            f.write(content.encode('utf8'))
+        return session.data_objects.get(path)
+
+    with make_session() as session:
+
+        # Example 1 : exception thrown when name has non-printable character
+        try:
+            create_notes( session, "lucky\033.dat", content = u'test' )
+        except:
+            pass
+
+        # Example 2 (Ref. issue: irods/irods #4132, fixed for 4.2.9 release of iRODS)
+        print(
+            create_notes( session, "Alice`s diary").name  # note diff (' != `) in printed name
+        )
+
+
+This creates two data objects, but with less than optimal success.  The first example object
+is created but receives no content because an exception is thrown trying to query its name after
+creation.   In the second example, for iRODS 4.2.8 and before, a deficiency in packStruct XML protocol causes
+the backtick to be read back as an apostrophe, which could create problems manipulating or deleting the object later.
+
+As of PRC v1.1.0, we can mitigate both problems by switching in the QUASI_XML parser for the default one:
+::
+
+    from irods.message import (XML_Parser_Type, ET)
+    ET( XML_Parser.QUASI_XML, session.server_version )
+
+Two dedicated environment variables may also be used to customize the Python client's XML parsing behavior via the
+setting of global defaults during start-up.
+
+For example, we can set the default parser to QUASI_XML, optimized for use with version 4.2.8 of the iRODS server,
+in the following manner:
+::
+
+    Bash-Shell> export PYTHON_IRODSCLIENT_DEFAULT_XML=QUASI_XML PYTHON_IRODSCLIENT_QUASI_XML_SERVER_VERSION=4,2,8
+
+Other alternatives for PYTHON_IRODSCLIENT_DEFAULT_XML are "STANDARD_XML" and "SECURE_XML".  These two latter options
+denote use of the xml.etree and defusedxml modules, respectively.
+
+Only the choice of "QUASI_XML" is affected by the specification of a particular server version.
+
+Finally, note that these global defaults, once set, may be overridden on a per-thread basis using
+:code:`ET(parser_type, server_version)`.  We can also revert the current thread's XML parser back to the
+global default by calling :code:`ET(None)`.
+
+
+Rule Execution
+--------------
+
+A simple example of how to execute an iRODS rule from the Python client is as follows.  Suppose we have a rule file
+:code:`native1.r` which contains a rule in native iRODS Rule Language::
+
+  main() {
+      writeLine("*stream",
+                *X ++ " squared is " ++ str(double(*X)^2) )
+  }
+
+  INPUT *X="3", *stream="serverLog"
+  OUTPUT null
+
+The following Python client code will run the rule and produce the appropriate output in the
+irods server log::
+
+  r = irods.rule.Rule( session, rule_file = 'native1.r')
+  r.execute()
+
+With release v1.1.1, not only can we target a specific rule engine instance by name (which is useful when
+more than one is present), but we can also use a file-like object for the :code:`rule_file` parameter::
+
+  Rule( session, rule_file = io.StringIO(u'''mainRule() { anotherRule(*x); writeLine('stdout',*x) }\n'''
+                                         u'''anotherRule(*OUT) {*OUT='hello world!'}\n\n'''
+                                         u'''OUTPUT ruleExecOut\n'''),
+        instance_name = 'irods_rule_engine_plugin-irods_rule_language-instance' )
+
+Incidentally, if we wanted to change the :code:`native1.r` rule code print to stdout also, we could set the
+:code:`INPUT` parameter, :code:`*stream`, using the Rule constructor's :code:`params` keyword argument.
+Similarly, we can change the :code:`OUTPUT` parameter from :code:`null` to :code:`ruleExecOut`, to accommodate
+the output stream, via the :code:`output` argument::
+
+  r = irods.rule.Rule( session, rule_file = 'native1.r',
+             instance_name = 'irods_rule_engine_plugin-irods_rule_language-instance',
+             params={'*stream':'"stdout"'} , output = 'ruleExecOut' )
+  output = r.execute( )
+  if output and len(output.MsParam_PI):
+      buf = output.MsParam_PI[0].inOutStruct.stdoutBuf.buf
+      if buf: print(buf.rstrip(b'\0').decode('utf8'))
+
+(Changing the input value to be squared in this example is left as an exercise for the reader!)
+
+To deal with errors resulting from rule execution failure, two approaches can be taken. Suppose we
+have defined this in the :code:`/etc/irods/core.re` rule-base::
+
+  rule_that_fails_with_error_code(*x) {
+    *y = (if (*x!="") then int(*x) else 0)
+  # if (SOME_PROCEDURE_GOES_WRONG) {
+      if (*y < 0) { failmsg(*y,"-- my error message --"); }  #-> throws an error code of int(*x) in REPF
+      else { fail(); }                                       #-> throws FAIL_ACTION_ENCOUNTERED_ERR in REPF
+  # }
+  }
+
+We can run the rule thus:
+
+>>> Rule( session, body='rule_that_fails_with_error_code(""), instance_name = 'irods_rule_engine_plugin-irods_rule_language-instance',
+...     ).execute( r_error = (r_errs:= irods.message.RErrorStack()) )
+
+Where we've used the Python 3.8 "walrus operator" for brevity.  The error will automatically be caught and translated to a
+returned-error stack::
+
+  >>> pprint.pprint([vars(r) for r in r_errs])
+  [{'raw_msg_': 'DEBUG: fail action encountered\n'
+                'line 14, col 15, rule base core\n'
+                '        else { fail(); }\n'
+                '               ^\n'
+                '\n',
+    'status_': -1220000}]
+
+Note, if a stringized negative integer is given , ie. as a special fail code to be thrown within the rule,
+we must add this code into a special parameter to have this automatically caught as well:
+
+>>> Rule( session, body='rule_that_fails_with_error_code("-2")',instance_name = 'irods_rule_engine_plugin-irods_rule_language-instance'
+...     ).execute( acceptable_errors = ( FAIL_ACTION_ENCOUNTERED_ERR, -2),
+...                r_error = (r_errs := irods.message.RErrorStack()) )
+
+Because the rule is written to emit a custom error message via failmsg in this case, the resulting r_error stack will now include that
+custom error message as a substring::
+
+  >>> pprint.pprint([vars(r) for r in r_errs])
+  [{'raw_msg_': 'DEBUG: -- my error message --\n'
+                'line 21, col 20, rule base core\n'
+                '      if (*y < 0) { failmsg(*y,"-- my error message --"); }  '
+                '#-> throws an error code of int(*x) in REPF\n'
+                '                    ^\n'
+                '\n',
+    'status_': -1220000}]
+
+Alternatively, or in combination with the automatic catching of errors, we may also catch errors as exceptions on the client
+side.  For example, if the Python rule engine is configured, and the following rule is placed in :code:`/etc/irods/core.py`::
+
+  def python_rule(rule_args, callback, rei):
+  #   if some operation fails():
+          raise RuntimeError
+
+we can trap the error thus::
+
+  try:
+      Rule( session, body = 'python_rule', instance_name = 'irods_rule_engine_plugin-python-instance' ).execute()
+  except irods.exception.RULE_ENGINE_ERROR:
+      print('Rule execution failed!')
+      exit(1)
+  print('Rule execution succeeded!')
+
+As fail actions from native rules are not thrown by default (refer to the help text for :code:`Rule.execute`), if we
+anticipate these and prefer to catch them as exceptions, we can do it this way::
+
+  try:
+      Rule( session, body = 'python_rule', instance_name = 'irods_rule_engine_plugin-python-instance'
+           ).execute( acceptable_errors = () )
+  except (irods.exception.RULE_ENGINE_ERROR,
+          irods.exception.FAIL_ACTION_ENCOUNTERED_ERR) as e:
+      print('Rule execution failed!')
+      exit(1)
+  print('Rule execution succeeded!')
+
+Finally,  keep in mind that rule code submitted through an :code:`irods.rule.Rule` object is processed by the
+exec_rule_text function in the targeted plugin instance.  This may be a limitation for plugins not equipped to
+handle rule code in this way.  In a sort of middle-ground case, the iRODS Python Rule Engine Plugin is not
+currently able to handle simple rule calls and the manipulation of iRODS core primitives (like simple parameter
+passing and variable expansion') as flexibly as the iRODS Rule Language.
+
+Also, core.py rules may not be run directly (as is also true with :code:`irule`) by other than a rodsadmin user
+pending the resolution of `this issue <https://github.com/irods/irods_rule_engine_plugin_python/issues/105>`_.
 
 
 General queries
@@ -235,6 +660,31 @@ General queries
 /tempZone/home/rods/manager/user_manager.py id=212669 size=5509
 /tempZone/home/rods/manager/user_manager.pyc id=212658 size=5233
 
+Query using other models:
+
+>>> from irods.column import Criterion
+>>> from irods.models import DataObject, DataObjectMeta, Collection, CollectionMeta
+>>> from irods.session import iRODSSession
+>>> import os
+>>> env_file = os.path.expanduser('~/.irods/irods_environment.json')
+>>> with iRODSSession(irods_env_file=env_file) as session:
+...    # by metadata
+...    # equivalent to 'imeta qu -C type like Project'
+...    results = session.query(Collection, CollectionMeta).filter( \
+...        Criterion('=', CollectionMeta.name, 'type')).filter( \
+...        Criterion('like', CollectionMeta.value, '%Project%'))
+...    for r in results:
+...        print(r[Collection.name], r[CollectionMeta.name], r[CollectionMeta.value], r[CollectionMeta.units])
+...
+('/tempZone/home/rods', 'type', 'Project', None)
+
+Beginning with version 0.8.3 of PRC, the 'in' genquery operator is also available:
+
+>>> from irods.models import Resource
+>>> from irods.column import In
+>>> [ resc[Resource.id]for resc in session.query(Resource).filter(In(Resource.name, ['thisResc','thatResc'])) ]
+[10037,10038]
+
 Query with aggregation(min, max, sum, avg, count):
 
 >>> with iRODSSession(irods_env_file=env_file) as session:
@@ -294,6 +744,7 @@ user_manager.py 212669
 __init__.py 212670
 __init__.pyc 212671
 
+
 Recherché queries
 -----------------
 
@@ -318,7 +769,425 @@ not reside in the trash.
 >>> pprint( list( chained_results ) )
 
 
+Instantiating iRODS objects from query results
+----------------------------------------------
+The General query works well for getting information out of the ICAT if all we're interested in is
+information representable with
+primitive types (ie. object names, paths, and ID's, as strings or integers). But Python's object orientation also
+allows us to create object references to mirror the persistent entities (instances of *Collection*, *DataObject*, *User*, or *Resource*, etc.)
+inhabiting the ICAT.
+
+**Background:**
+Certain iRODS object types can be instantiated easily using the session object's custom type managers,
+particularly if some parameter (often just the name or path) of the object is already known:
+
+>>> type(session.users)
+<class 'irods.manager.user_manager.UserManager'>
+>>> u = session.users.get('rods')
+>>> u.id
+10003
+
+Type managers are good for specific operations, including object creation and removal::
+
+>>> session.collections.create('/tempZone/home/rods/subColln')
+>>> session.collections.remove('/tempZone/home/rods/subColln')
+>>> session.data_objects.create('/tempZone/home/rods/dataObj')
+>>> session.data_objects.unlink('/tempZone/home/rods/dataObj')
+
+When we retrieve a reference to an existing collection using *get* :
+
+>>> c = session.collections.get('/tempZone/home/rods')
+>>> c
+<iRODSCollection 10011 rods>
+
+
+we have, in that variable *c*, a reference to an iRODS *Collection* object whose properties provide
+useful information:
+
+>>> [ x for x in dir(c) if not x.startswith('__') ]
+['_meta', 'data_objects', 'id', 'manager', 'metadata', 'move', 'name', 'path', 'remove', 'subcollections', 'unregister', 'walk']
+>>> c.name
+'rods'
+>>> c.path
+'/tempZone/home/rods'
+>>> c.data_objects
+[<iRODSDataObject 10019 test1>]
+>>> c.metadata.items()
+[ <... list of AVU's attached to Collection c ... > ]
+
+or whose methods can do useful things:
+
+>>> for sub_coll in c.walk(): print('---'); pprint( sub_coll )
+[ ...< series of Python data structures giving the complete tree structure below collection 'c'> ...]
+
+This approach of finding objects by name, or via their relations with other objects (ie "contained by", or in the case of metadata, "attached to"),
+is helpful if we know something about the location or identity of what we're searching for, but we don't always
+have that kind of a-priori knowledge.
+
+So, although we can (as seen in the last example) walk an *iRODSCollection* recursively to discover all subordinate
+collections and their data objects, this approach will not always be best
+for a given type of application or data discovery, especially in more advanced
+use cases.
+
+**A Different Approach:**
+For the PRC to be sufficiently powerful for general use, we'll often need at least:
+
+* general queries, and
+* the capabilities afforded by the PRC's object-relational mapping.
+
+Suppose, for example, we wish to enumerate all collections in the iRODS catalog.
+
+Again, the object managers are the answer, but they are now invoked using a different scheme:
+
+>>> from irods.collection import iRODSCollection; from irods.models import Collection
+>>> all_collns = [ iRODSCollection(session.collections,result) for result in session.query(Collection) ]
+
+From there, we have the ability to do useful work, or filtering based on the results of the enumeration.
+And, because *all_collns* is an iterable of true objects, we can either use Python's list comprehensions or
+execute more catalog queries to achieve further aims.
+
+Note that, for similar system-wide queries of Data Objects (which, as it happens, are inextricably joined to their
+parent Collection objects), a bit more finesse is required.  Let us query, for example, to find all data
+objects in a particular zone with an AVU that matches the following condition::
+
+   META_DATA_ATTR_NAME = "irods::alert_time" and META_DATA_ATTR_VALUE like '+0%'
+   
+   
+>>> import irods.keywords
+>>> from irods.data_object import iRODSDataObject
+>>> from irods.models import DataObjectMeta, DataObject
+>>> from irods.column import Like
+>>> q = session.query(DataObject).filter( DataObjectMeta.name == 'irods::alert_time',
+                                          Like(DataObjectMeta.value, '+0%') )
+>>> zone_hint = "" # --> add a zone name in quotes to search another zone
+>>> if zone_hint: q = q.add_keyword( irods.keywords.ZONE_KW, zone_hint )
+>>> for res in q:
+...      colln_id = res [DataObject.collection_id]
+...      collObject = get_collection( colln_id, session, zone = zone_hint)
+...      dataObject = iRODSDataObject( session.data_objects, parent = collObject, results=[res])
+...      print( '{coll}/{data}'.format (coll = collObject.path, data = dataObject.name))
+
+
+In the above loop we have used a helper function, *get_collection*, to minimize the number of hits to the object
+catalog. Otherwise, me might find within a typical application  that some Collection objects are being queried at
+a high rate of redundancy. *get_collection* can be implemented thusly:
+
+.. code:: Python
+
+    import collections  # of the Pythonic, not iRODS, kind
+    def makehash():
+        # see https://stackoverflow.com/questions/651794/whats-the-best-way-to-initialize-a-dict-of-dicts-in-python
+        return collections.defaultdict(makehash)
+    from irods.collection import iRODSCollection
+    from irods.models import Collection
+    def get_collection (Id, session, zone=None, memo = makehash()):
+        if not zone: zone = ""
+        c_obj = memo[session][zone].get(Id)
+        if c_obj is None:
+            q = session.query(Collection).filter(Collection.id==Id)
+            if zone != '': q = q.add_keyword( irods.keywords.ZONE_KW, zone )
+            c_id =  q.one()
+            c_obj = iRODSCollection(session, result = c_id)
+            memo[session][zone][Id] = c_obj
+        return c_obj
+
+
+Once instantiated, of course, any *iRODSDataObject*'s data to which we have access permissions is available via its open() method.
+
+As stated, this type of object discovery requires some extra study and effort, but the ability to search arbitrary iRODS zones
+(to which we are federated and have the user permissions) is powerful indeed.
+
+
+Tickets
+-------
+
+The :code:`irods.ticket.Ticket` class lets us issue "tickets" which grant limited
+permissions for other users to access our own data objects (or collections of
+data objects).   As with the iticket client, the access may be either "read"
+or "write".  The recipient of the ticket could be a rodsuser, or even an
+anonymous user.
+
+Below is a demonstration of how to generate a new ticket for access to a
+logical path - in this case, say a collection containing 1 or more data objects.
+(We assume the creation of the granting_session and receiving_session for the users
+respectively for the users providing and consuming the ticket access.)
+
+The user who wishes to provide an access may execute the following:
+
+>>> from irods.ticket import Ticket
+>>> new_ticket = Ticket (granting_session)
+>>> The_Ticket_String = new_ticket.issue('read', 
+...     '/zone/home/my/collection_with_data_objects_for/somebody').string
+
+at which point that ticket's unique string may be given to other users, who can then apply the
+ticket to any existing session object in order to gain access to the intended object(s):
+
+>>> from irods.models import Collection, DataObject
+>>> ses = receiving_session
+>>> Ticket(ses, The_Ticket_String).supply()
+>>> c_result = ses.query(Collection).one()
+>>> c = iRODSCollection( ses.collections, c_result)
+>>> for dobj in (c.data_objects):
+...     ses.data_objects.get( dobj.path, '/tmp/' + dobj.name ) # download objects
+
+In this case, however, modification will not be allowed because the ticket is for read only:
+
+>>> c.data_objects[0].open('w').write(  # raises
+...     b'new content')                 #  CAT_NO_ACCESS_PERMISSION
+
+In another example, we could generate a ticket that explicitly allows 'write' access on a
+specific data object, thus granting other users the permissions to modify as well as read it:
+
+>>> ses = iRODSSession( user = 'anonymous', password = '', host = 'localhost',
+                        port = 1247, zone = 'tempZone')
+>>> Ticket(ses, write_data_ticket_string ).supply()
+>>> d_result = ses.query(DataObject.name,Collection.name).one()
+>>> d_path = ( d_result[Collection.name] + '/' +
+...            d_result[DataObject.name] )
+>>> old_content = ses.data_objects.open(d_path,'r').read()
+>>> with tempfile.NamedTemporaryFile() as f:
+...     f.write(b'blah'); f.flush()
+...     ses.data_objects.put(f.name,d_path)
+
+As with iticket, we may set a time limit on the availability of a ticket, either as a
+timestamp or in seconds since the epoch:
+
+>>> t=Ticket(ses); s = t.string
+vIOQ6qzrWWPO9X7
+>>> t.issue('read','/some/path')
+>>> t.modify('expiry','2021-04-01.12:34:56')  # timestamp assumed as UTC
+
+To check the results of the above, we could invoke this icommand elsewhere in a shell prompt:
+
+:code:`iticket ls vIOQ6qzrWWPO9X7`
+
+and the server should report back the same expiration timestamp.
+
+And, if we are the issuer of a ticket, we may also query, filter on, and
+extract information based on a ticket's attributes and catalog relations:
+
+>>> from irods.models import TicketQuery
+>>> delay = lambda secs: int( time.time() + secs + 1)
+>>> Ticket(ses).issue('read','/path/to/data_object').modify(
+                      'expiry',delay(7*24*3600))             # lasts 1 week
+>>> Q = ses.query (TicketQuery.Ticket, TicketQuery.DataObject).filter(
+...                                                            TicketQuery.DataObject.name == 'data_object')
+>>> print ([ _[TicketQuery.Ticket.expiry_ts] for _ in Q ])
+['1636757427']
+
+
+Tracking and manipulating replicas of Data objects
+--------------------------------------------------
+
+Putting together the techniques we've seen so far, it's not hard to write functions
+that achieve useful, common goals. Suppose that for all data objects containing replicas on
+a given named resource (the "source") we want those replicas "moved" to a second, or
+"destination" resource.  We can achieve it with a function such as the one below. It
+achieves the move via a replication of the data objects found to the destination
+resource , followed by a trimming of each replica from the source.  We assume for our current
+purposed that all replicas are "good", ie have a status of "1" ::
+
+  from irods.resource import iRODSResource
+  from irods.collection import iRODSCollection
+  from irods.data_object import iRODSDataObject
+  from irods.models import Resource,Collection,DataObject
+  def repl_and_trim (srcRescName, dstRescName = '', verbose = False):
+      objects_trimmed = 0
+      q = session.query(Resource).filter(Resource.name == srcRescName)
+      srcResc = iRODSResource( session.resources, q.one())
+      # loop over data objects found on srcResc
+      for q_row in session.query(Collection,DataObject) \
+                          .filter(DataObject.resc_id == srcResc.id):
+          collection =  iRODSCollection (session.collections, result = q_row)
+          data_object = iRODSDataObject (session.data_objects, parent = collection, results = (q_row,))
+          objects_trimmed += 1
+          if verbose :
+              import pprint
+              print( '--------', data_object.name, '--------')
+              pprint.pprint( [vars(r) for r in data_object.replicas if
+                              r.resource_name == srcRescName] )
+          if dstRescName:
+              objects_trimmed += 1
+              data_object.replicate(dstRescName)
+              for replica_number in [r.number for r in data_object.replicas]:
+                  options = { kw.DATA_REPL_KW: replica_number }
+                  data_object.unlink( **options )
+      return objects_trimmed
+
+
+Listing Users and Groups ; calculating Group Membership
+-------------------------------------------------------
+
+iRODS tracks groups and users using two tables, R_USER_MAIN and R_USER_GROUP.
+Under this database schema, all "user groups" are also users:
+
+>>> from irods.models import User, UserGroup
+>>> from pprint import pprint
+>>> pprint(list( [ (x[User.id], x[User.name]) for x in session.query(User) ] ))
+[(10048, 'alice'),
+ (10001, 'rodsadmin'),
+ (13187, 'bobby'),
+ (10045, 'collab'),
+ (10003, 'rods'),
+ (13193, 'empty'),
+ (10002, 'public')]
+
+But it's also worth noting that the User.type field will be 'rodsgroup' for any
+user ID that iRODS internally recognizes as a "Group":
+
+>>> groups = session.query(User).filter( User.type == 'rodsgroup' )
+
+>>> [x[User.name] for x in groups]
+['collab', 'public', 'rodsadmin', 'empty']
+
+Since we can instantiate iRODSUserGroup and iRODSUser objects directly from the rows of
+a general query on the corresponding tables,  it is also straightforward to trace out
+the groups' memberships:
+
+>>> from irods.user import iRODSUser, iRODSUserGroup
+>>> grp_usr_mapping = [ (iRODSUserGroup ( session.user_groups, result), iRODSUser (session.users, result)) \
+...                     for result in session.query(UserGroup,User) ]
+>>> pprint( [ (x,y) for x,y in grp_usr_mapping if x.id != y.id ] )
+[(<iRODSUserGroup 10045 collab>, <iRODSUser 10048 alice rodsuser tempZone>),
+ (<iRODSUserGroup 10001 rodsadmin>, <iRODSUser 10003 rods rodsadmin tempZone>),
+ (<iRODSUserGroup 10002 public>, <iRODSUser 10003 rods rodsadmin tempZone>),
+ (<iRODSUserGroup 10002 public>, <iRODSUser 10048 alice rodsuser tempZone>),
+ (<iRODSUserGroup 10045 collab>, <iRODSUser 13187 bobby rodsuser tempZone>),
+ (<iRODSUserGroup 10002 public>, <iRODSUser 13187 bobby rodsuser tempZone>)]
+
+(Note that in general queries, fields cannot be compared to each other, only to literal constants; thus
+the '!=' comparison in the Python list comprehension.)
+
+From the above, we can see that the group 'collab' (with user ID 10045) contains users 'bobby'(13187) and
+'alice'(10048) but not 'rods'(10003), as the tuple (10045,10003) is not listed. Group 'rodsadmin'(10001)
+contains user 'rods'(10003) but no other users; and group 'public'(10002) by default contains all canonical
+users (those whose User.type is 'rodsadmin' or 'rodsuser'). The empty group ('empty') has no users as
+members, so it doesn't show up in our final list.
+
+
+Getting and setting permissions
+-------------------------------
+
+We can find the ID's of all the collections writable (ie having "modify" ACL) by, but not owned by,
+alice (or even alice#otherZone):
+
+>>> from irods.models import Collection,CollectionAccess,CollectionUser,User
+>>> from irods.column import Like
+>>> q = session.query (Collection,CollectionAccess).filter(
+...                                 CollectionUser.name == 'alice',  # User.zone == 'otherZone', # zone optional
+...                                 Like(CollectionAccess.name, 'modify%') ) #defaults to current zone
+
+If we then want to downgrade those permissions to read-only, we can do the following:
+
+>>> from irods.access import iRODSAccess
+>>> for c in q:
+...     session.permissions.set( iRODSAccess('read', c[Collection.name], 'alice', # 'otherZone' # zone optional
+...     ))
+
+We can also query on access type using its numeric value, which will seem more natural to some:
+
+>>> OWN = 1200; MODIFY = 1120 ; READ = 1050
+>>> from irods.models import DataAccess, DataObject, User
+>>> data_objects_writable = list(session.query(DataObject,DataAccess,User)).filter(User.name=='alice',  DataAccess.type >= MODIFY)
+
+
+Managing users
+--------------
+
+You can create a user in the current zone (with an optional auth_str):
+
+>>> session.users.create('user', 'rodsuser', 'MyZone', auth_str)
+
+If you want to create a user in a federated zone, use:
+
+>>> session.users.create('user', 'rodsuser', 'OtherZone', auth_str)
+
+
 And more...
 -----------
 
-Additional code samples are available in the `test directory <https://github.com/irods/python-irodsclient/tree/master/irods/test>`_
+Additional code samples are available in the `test directory <https://github.com/irods/python-irodsclient/tree/main/irods/test>`_
+
+
+=======
+Testing
+=======
+
+Setting up and running tests
+----------------------------
+
+The Python iRODS Client comes with its own suite of tests.  Some amount of setting up may be necessary first:
+
+1. Use :code:`iinit` to specify the iRODS client environment.
+   For best results, point the client at a server running on the local host.
+
+2. Install the python-irodsclient along with the :code:`unittest unittest_xml_reporting` module or the older :code:`xmlrunner` equivalent.
+
+   - for PRC versions 1.1.1 and later:
+
+     *  :code:`pip install ./path-to-python-irodsclient-repo[tests]`  (when using a local Git repo); or,
+     *  :code:`pip install python-irodsclient[tests]'>=1.1.1'`  (when installing directly from PyPI).
+
+   - earlier releases (<= 1.1.0) will install the outdated :code:`xmlrunner` module automatically
+
+3. Follow further instructions in the `test directory <https://github.com/irods/python-irodsclient/tree/main/irods/test>`_
+
+
+Testing S3 parallel transfer
+----------------------------
+
+System requirements::
+
+- Ubuntu 18 user with Docker installed.
+- Local instance of iRODS server running.
+- Logged in sudo privileges.
+
+Run a MinIO service::
+
+  $ docker run -d -p 9000:9000 -p 9001:9001 minio/minio server /data --console-address ":9001"
+
+Set up a bucket :code:`s3://irods` under MinIO::
+
+  $ pip install awscli
+
+  $ aws configure
+  AWS Access Key ID [None]: minioadmin
+  AWS Secret Access Key [None]: minioadmin
+  Default region name [None]:
+  Default output format [None]:
+
+  $ aws --endpoint-url http://127.0.0.1:9000 s3 mb s3://irods
+
+Set up s3 credentials for the iRODS s3 storage resource::
+
+  $ sudo su - irods -c "/bin/echo -e 'minioadmin\nminioadmin' >/var/lib/irods/s3-credentials"
+  $ sudo chown 600 /var/lib/irods/s3-credentials
+
+Create the s3 storage resource::
+
+  $ sudo apt install irods-resource-plugin-s3
+
+As the 'irods' service account user::
+
+  $ iadmin mkresc s3resc s3 $(hostname):/irods/ \
+    "S3_DEFAULT_HOSTNAME=localhost:9000;"\
+    "S3_AUTH_FILE=/var/lib/irods/s3-credentials;"\
+    "S3_REGIONNAME=us-east-1;"\
+    "S3_RETRY_COUNT=1;"\
+    "S3_WAIT_TIME_SEC=3;"\
+    "S3_PROTO=HTTP;"\
+    "ARCHIVE_NAMING_POLICY=consistent;"\
+    "HOST_MODE=cacheless_attached"
+
+  $ dd if=/dev/urandom of=largefile count=40k bs=1k # create 40-megabyte test file
+
+  $ pip install 'python-irodsclient>=1.1.2'
+
+  $ python -c"from irods.test.helpers import make_session
+              import irods.keywords as kw
+              with make_session() as sess:
+                  sess.data_objects.put( 'largefile',
+                                         '/tempZone/home/rods/largeFile1',
+                                         **{kw.DEST_RESC_NAME_KW:'s3resc'} )
+                  sess.data_objects.get( '/tempZone/home/rods/largeFile1',
+                                         '/tmp/largefile')"
diff --git a/debian/changelog b/debian/changelog
index 398adc5..b772a96 100644
--- a/debian/changelog
+++ b/debian/changelog
@@ -1,4 +1,4 @@
-python-irodsclient (0.8.1-3) UNRELEASED; urgency=medium
+python-irodsclient (1.1.2-1) UNRELEASED; urgency=medium
 
   [ Ondřej Nový ]
   * d/control: Update Maintainer field with new Debian Python Team
@@ -14,8 +14,9 @@ python-irodsclient (0.8.1-3) UNRELEASED; urgency=medium
   * Update standards version to 4.5.0, no changes needed.
   * Bump debhelper from old 12 to 13.
   * Update standards version to 4.5.1, no changes needed.
+  * New upstream release.
 
- -- Ondřej Nový <onovy@debian.org>  Thu, 24 Sep 2020 08:44:23 +0200
+ -- Ondřej Nový <onovy@debian.org>  Sun, 20 Mar 2022 12:51:49 -0000
 
 python-irodsclient (0.8.1-2) unstable; urgency=medium
 
diff --git a/debian/patches/no_xmlrunner b/debian/patches/no_xmlrunner
index df14cc8..ab4d050 100644
--- a/debian/patches/no_xmlrunner
+++ b/debian/patches/no_xmlrunner
@@ -1,8 +1,10 @@
 Author: Michael R. Crusoe <michael.crusoe@gmail.com>
 Description: xmlrunner is not packaged for Debian, so just run plainly
+Index: python-irodsclient/irods/test/runner.py
+===================================================================
 --- python-irodsclient.orig/irods/test/runner.py
 +++ python-irodsclient/irods/test/runner.py
-@@ -9,8 +9,7 @@
+@@ -9,8 +9,7 @@ NOTE: "If a test package name (directory
  from __future__ import absolute_import
  import os
  import sys
@@ -12,7 +14,7 @@ Description: xmlrunner is not packaged for Debian, so just run plainly
  import logging
  
  logger = logging.getLogger()
-@@ -30,8 +29,7 @@
+@@ -30,8 +29,7 @@ if __name__ == "__main__":
      suite = TestSuite(loader.discover(start_dir='.', pattern='*_test.py',
                                        top_level_dir="."))
  
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..b2b482c
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,40 @@
+version: '3'
+services:
+
+  icat:
+    image: postgres:10
+    environment:
+      - POSTGRES_HOST_AUTH_METHOD=md5
+      - POSTGRES_PASSWORD=pg_password
+
+  irods-provider:
+    environment:
+      - PYTHON_RULE_ENGINE_INSTALLED=${python_rule_engine_installed}
+    hostname: irods-provider
+    build:
+      context: docker_build
+      dockerfile: Dockerfile.provider
+    volumes:
+      - "${irods_pkg_dir}:/irods_packages:ro"
+      - ./irods_shared:/irods_shared:rw
+    depends_on:
+      - icat
+    networks:
+      default:
+        aliases:
+          - irods-provider
+
+  client-runner:
+    env_file: client-runner.env
+    environment:
+      - PYTHON_RULE_ENGINE_INSTALLED=${python_rule_engine_installed}
+    volumes:
+      - ./irods_shared:/irods_shared:rw
+    build:
+      context: .
+      dockerfile: Dockerfile.prc_test.${client_os_generic}
+      args:
+        os_image: "$client_os_image"
+        py_N: "$python_version"
+    depends_on:
+      - irods-provider
diff --git a/docker_build/Dockerfile.provider b/docker_build/Dockerfile.provider
new file mode 100644
index 0000000..3c97ecc
--- /dev/null
+++ b/docker_build/Dockerfile.provider
@@ -0,0 +1,43 @@
+FROM ubuntu:18.04
+
+ARG irods_pkg_dir
+
+RUN apt update
+RUN apt install -y wget sudo lsb-release apt-transport-https gnupg2 postgresql-client
+RUN wget -qO - https://packages.irods.org/irods-signing-key.asc | sudo apt-key add -
+RUN echo "deb [arch=amd64] https://packages.irods.org/apt/ $(lsb_release -sc) main" | sudo tee /etc/apt/sources.list.d/renci-irods.list
+RUN apt update
+
+SHELL [ "/bin/bash","-c" ]
+
+COPY ICAT.sql /tmp
+COPY pgpass root/.pgpass
+RUN chmod 600 root/.pgpass
+
+RUN apt install -y rsyslog gawk
+RUN apt install -y jq
+ADD build_deps_list wait_on_condition send_oneshot setup_python_rule_engine /tmp/
+
+# At Runtime: 1. Install apt dependencies for the iRODS package files given.
+#             2. Install the package files.
+#             3. Wait on database container.
+#             4. Configure iRODS provider and make sure it is running.
+#             5. Open a server port, informing the client to start tests now that iRODS is up.
+#             6. Configure shared folder for tests that need to register data objects.
+#                (We opt out if /irods_shared does not exist, ie is omitted in the docker-compose.yml).
+#             7. Wait forever.
+
+CMD apt install -y $(/tmp/build_deps_list /irods_packages/irods*{serv,dev,icommand,runtime,database-*postgres}*.deb) && \
+    dpkg -i /irods_packages/irods*{serv,dev,icommand,runtime,database-*postgres}*.deb && \
+    /tmp/wait_on_condition -i 5 -n 12 "psql -h icat -U postgres -c '\\l' >/dev/null" && \
+      psql -h icat -U postgres -f /tmp/ICAT.sql && \
+    sed 's/localhost/icat/' < /var/lib/irods/packaging/localhost_setup_postgres.input \
+        | python /var/lib/irods/scripts/setup_irods.py && \
+    { [ "${PYTHON_RULE_ENGINE_INSTALLED}" = '' ] || { apt install -y irods-rule-engine-plugin-python && /tmp/setup_python_rule_engine; } } && \
+    { pgrep -u irods irodsServer >/dev/null || su irods -c '~/irodsctl start'; \
+      env PORT=8888 /tmp/send_oneshot "iRODS is running..." & } && \
+    { [ ! -d /irods_shared ] || { mkdir -p /irods_shared/reg_resc && mkdir -p /irods_shared/tmp && \
+                                  chown -R irods.irods /irods_shared && chmod g+ws /irods_shared/tmp && \
+                                  chmod 777 /irods_shared/reg_resc ; } } && \
+    echo $'*********\n' $'*********\n' $'*********\n' $'*********\n' $'*********\n' IRODS IS UP  && \
+    tail -f /dev/null
diff --git a/docker_build/ICAT.sql b/docker_build/ICAT.sql
new file mode 100644
index 0000000..abb706a
--- /dev/null
+++ b/docker_build/ICAT.sql
@@ -0,0 +1,3 @@
+CREATE USER irods WITH PASSWORD 'testpassword';
+CREATE DATABASE "ICAT";
+GRANT ALL PRIVILEGES ON DATABASE "ICAT" TO irods;
diff --git a/docker_build/build_deps_list b/docker_build/build_deps_list
new file mode 100755
index 0000000..7bf3798
--- /dev/null
+++ b/docker_build/build_deps_list
@@ -0,0 +1,22 @@
+#!/bin/bash
+
+build_deps_list()
+{
+    local -A pkglist
+    local pkg
+    while [ $# -gt 0 ]
+    do
+        while read f
+        do
+            if [[ ! $f =~ \(.*\)\s*$ ]]; then  # todo: include version-specific ?
+               pkglist["$f"]=""
+            fi
+        done < <(dpkg -I "$1"|grep -i '^ *depends:'|tr ',:' \\n | tail -n +2)
+        shift
+    done
+    for pkg in "${!pkglist[@]}"  # package list de-duped by associative array
+    do
+        echo "$pkg"
+    done
+}
+build_deps_list "$@"
diff --git a/docker_build/iinit.py b/docker_build/iinit.py
new file mode 100644
index 0000000..81365d8
--- /dev/null
+++ b/docker_build/iinit.py
@@ -0,0 +1,44 @@
+from getpass import getpass
+from irods.password_obfuscation import encode
+import json
+import os
+import sys
+from os import chmod
+from os.path import expanduser,exists,join
+from getopt import getopt
+
+
+home_env_path = expanduser('~/.irods')
+env_file_path = join(home_env_path,'irods_environment.json')
+auth_file_path = join(home_env_path,'.irodsA')
+
+
+def do_iinit(host, port, user, zone, password):
+    if not exists(home_env_path):
+        os.makedirs(home_env_path)
+    else:
+        raise RuntimeError('~/.irods already exists')
+
+    with open(env_file_path,'w') as env_file:
+        json.dump ( { "irods_host": host,
+                      "irods_port": int(port),
+                      "irods_user_name": user,
+                      "irods_zone_name": zone  }, env_file, indent=4)
+    with open(auth_file_path,'w') as auth_file:
+        auth_file.write(encode(password))
+    chmod (auth_file_path,0o600)
+
+
+def get_kv_pairs_from_cmdline(*args):
+    arglist = list(args)
+    while arglist:
+       k = arglist.pop(0)
+       v = arglist.pop(0)
+       yield k,v
+
+
+if __name__ == '__main__':
+    import sys
+    args = sys.argv[1:]
+    dct = {k:v for k,v in get_kv_pairs_from_cmdline(*args)}
+    do_iinit(**dct)
diff --git a/docker_build/pgpass b/docker_build/pgpass
new file mode 100644
index 0000000..55a6bdf
--- /dev/null
+++ b/docker_build/pgpass
@@ -0,0 +1 @@
+icat:5432:postgres:postgres:pg_password
diff --git a/docker_build/recv_oneshot b/docker_build/recv_oneshot
new file mode 100755
index 0000000..47e2bdd
--- /dev/null
+++ b/docker_build/recv_oneshot
@@ -0,0 +1,35 @@
+#!/usr/bin/env python
+from __future__ import print_function
+import sys, os, time
+from socket import *
+import getopt
+
+def try_connect(host,port):
+    try:
+        s=socket(AF_INET,SOCK_STREAM)
+        s.connect((host,port))
+        return s
+    except:
+        s.close()
+        return None
+
+# Options:
+#
+# -t timeout
+# -h host
+# -p port
+
+t = now = time.time()
+opts = dict(getopt.getopt(sys.argv[1:],'t:h:p:')[0])
+
+host = opts['-h']
+port = int(opts['-p'])
+timeout = float(opts['-t'])
+
+while time.time() < now + timeout:
+      time.sleep(1)
+      s = try_connect(host, port)
+      if s:
+          print(s.recv(32767).decode('utf-8'),end='')
+          exit(0)
+exit(1)
diff --git a/docker_build/send_oneshot b/docker_build/send_oneshot
new file mode 100755
index 0000000..b265af1
--- /dev/null
+++ b/docker_build/send_oneshot
@@ -0,0 +1,6 @@
+#!/usr/bin/gawk -f
+BEGIN {
+  SERVER = "/inet/tcp/"ENVIRON["PORT"]"/0/0"
+  print ARGV[1] " - " strftime() |& SERVER
+  close(SERVER)
+}
diff --git a/docker_build/setup_python_rule_engine b/docker_build/setup_python_rule_engine
new file mode 100755
index 0000000..eaa5611
--- /dev/null
+++ b/docker_build/setup_python_rule_engine
@@ -0,0 +1,48 @@
+#!/bin/bash
+
+jq_process_in_place() {
+    local filename=$1
+    shift
+    local basenm=$(basename "$filename")
+    local tempname=/tmp/.$$.$basenm
+
+    jq "$@" <"$filename" >"$tempname" && \
+    cp "$tempname" "$filename"
+    STATUS=$?
+    rm -f "$tempname"
+    [ $STATUS = 0 ] || echo "**** jq process error" >&2
+}
+
+jq_process_in_place /etc/irods/server_config.json \
+   '.plugin_configuration.rule_engines[1:1]=[ { "instance_name": "irods_rule_engine_plugin-python-instance",
+                                                "plugin_name": "irods_rule_engine_plugin-python",
+                                                "plugin_specific_configuration": {}
+                                              }
+                                            ]'
+
+echo '
+defined_in_both {
+    writeLine("stdout", "native rule")
+}
+
+generic_failing_rule {
+    fail
+}
+
+failing_with_message {
+    failmsg(-2, "error with code of minus 2")
+}
+
+' >> /etc/irods/core.re
+
+echo '
+def defined_in_both(rule_args,callback,rei):
+    callback.writeLine("stdout", "python rule")
+
+def generic_failing_rule(*_):
+    raise RuntimeError
+
+def failing_with_message_py(rule_args,callback,rei):
+    callback.failing_with_message()
+
+' > /etc/irods/core.py
diff --git a/docker_build/wait_on_condition b/docker_build/wait_on_condition
new file mode 100755
index 0000000..ce2c29b
--- /dev/null
+++ b/docker_build/wait_on_condition
@@ -0,0 +1,34 @@
+#!/bin/bash
+
+# wait for a program to run with 0 return status
+
+interval=3; ntimes=20; verbose=""
+
+usage() {
+  echo "$0 [options] <command args...>"
+  printf "\t options are: -i <sleep interval_secs> (default %d)\n" $interval
+  printf "\t              -n <integer_number_of_tries> (default %d)\n" $ntimes
+  printf "\t              -v : for verbose reporting\n"
+  exit 1
+} >&2
+
+while [[ "$1" = -* ]] ; do
+    case $1 in
+	-i) shift; interval=$1; shift ;;
+	-n) shift; ntimes=$1; shift ;;
+	-v) verbose=1 ; shift;;
+	 *) usage;;
+    esac
+done
+[ $# -eq 0 ] && usage
+
+n=1
+while : ; do
+	eval "$@"
+	STATUS=$?
+	[ -n "$verbose" ] && echo "$n:" 'STATUS =' $STATUS `date`
+	[ $((++n)) -gt $ntimes -o $STATUS -eq 0 ] && break
+        sleep $interval
+done
+
+exit $STATUS
diff --git a/irods/__init__.py b/irods/__init__.py
index 4f43c19..d88d0d4 100644
--- a/irods/__init__.py
+++ b/irods/__init__.py
@@ -1,5 +1,32 @@
 from .version import __version__
 
+import logging
+logger = logging.getLogger(__name__)
+logger.addHandler(logging.NullHandler())
+gHandler = None
+
+def client_logging(flag=True,handler=None):
+    """
+    Example of use:
+
+    import irods
+    # Enable / Disable general client logging
+    irods.client_logging(True[,handler]) -> handler
+    #    (handler is a StreamHandler to stderr by default)
+    irods.client_logging(False)  # - disable irods client logging
+    """
+    global gHandler
+    if flag:
+        if handler is not None:
+            if gHandler: logger.removeHandler(gHandler)
+            if not handler: handler = logging.StreamHandler()
+            gHandler = handler
+            logger.addHandler(handler)
+    else:
+        if gHandler: logger.removeHandler(gHandler)
+        gHandler = None
+    return gHandler
+
 # Magic Numbers
 MAX_PASSWORD_LENGTH = 50
 MAX_SQL_ATTR = 50
@@ -10,8 +37,16 @@ CHALLENGE_LEN = 64
 MAX_SQL_ROWS = 256
 DEFAULT_CONNECTION_TIMEOUT = 120
 
-# Other variables
 AUTH_SCHEME_KEY = 'a_scheme'
+AUTH_USER_KEY = 'a_user'
+AUTH_PWD_KEY = 'a_pw'
+AUTH_TTL_KEY = 'a_ttl'
+
+NATIVE_AUTH_SCHEME = 'native'
+
 GSI_AUTH_PLUGIN = 'GSI'
 GSI_AUTH_SCHEME = GSI_AUTH_PLUGIN.lower()
 GSI_OID = "1.3.6.1.4.1.3536.1.1"  # taken from http://j.mp/2hDeczm
+
+PAM_AUTH_PLUGIN = 'PAM'
+PAM_AUTH_SCHEME = PAM_AUTH_PLUGIN.lower()
diff --git a/irods/api_number.py b/irods/api_number.py
index a221d4a..91bb432 100644
--- a/irods/api_number.py
+++ b/irods/api_number.py
@@ -176,4 +176,7 @@ api_number = {
     # 1100 - 1200 - SSL API calls
     "SSL_START_AN": 1100,
     "SSL_END_AN": 1101,
+    "ATOMIC_APPLY_METADATA_OPERATIONS_APN": 20002,
+    "GET_FILE_DESCRIPTOR_INFO_APN": 20000,
+    "REPLICA_CLOSE_APN": 20004
 }
diff --git a/irods/collection.py b/irods/collection.py
index c750f29..2f6accf 100644
--- a/irods/collection.py
+++ b/irods/collection.py
@@ -6,17 +6,39 @@ from irods.models import Collection, DataObject
 from irods.data_object import iRODSDataObject, irods_basename
 from irods.meta import iRODSMetaCollection
 
+def _first_char( *Strings ):
+    for s in Strings:
+        if s: return s[0]
+    return ''
 
 class iRODSCollection(object):
 
+    class AbsolutePathRequired(Exception):
+        """Exception raised by iRODSCollection.normalize_path.
+
+        AbsolutePathRequired is raised by normalize_path( *paths ) when the leading path element
+        does not start with '/'.  The exception will not be raised, however, if enforce_absolute = False
+        is passed to normalize_path as a keyword option.
+        """
+        pass
+
     def __init__(self, manager, result=None):
         self.manager = manager
         if result:
             self.id = result[Collection.id]
             self.path = result[Collection.name]
             self.name = irods_basename(result[Collection.name])
+            self.create_time = result[Collection.create_time]
+            self.modify_time = result[Collection.modify_time]
+            self._inheritance = result[Collection.inheritance]
+            self.owner_name = result[Collection.owner_name]
+            self.owner_zone = result[Collection.owner_zone]
         self._meta = None
 
+    @property
+    def inheritance(self):
+        return bool(self._inheritance) and self._inheritance != "0"
+
     @property
     def metadata(self):
         if not self._meta:
@@ -69,5 +91,21 @@ class iRODSCollection(object):
         if not topdown:
             yield (self, self.subcollections, self.data_objects)
 
+    @staticmethod
+    def normalize_path(*paths, **kw_):
+        """Normalize a path or list of paths.
+
+        We use the iRODSPath class to eliminate extra slashes in,
+        and (if more than one parameter is given) concatenate, paths.
+        If the keyword argument `enforce_absolute' is set True, this
+        function requires the first character of path(s) passed in
+        should be '/'.
+        """
+        import irods.path
+        absolute = kw_.get('enforce_absolute',False)
+        if absolute and _first_char(*paths) != '/':
+            raise iRODSCollection.AbsolutePathRequired
+        return irods.path.iRODSPath(*paths, absolute = absolute)
+
     def __repr__(self):
-        return "<iRODSCollection {id} {name}>".format(id=self.id, name=self.name.encode('utf-8'))
+        return "<iRODSCollection {id} {name}>".format(id = self.id, name = self.name.encode('utf-8'))
diff --git a/irods/column.py b/irods/column.py
index f4f644f..cfed46b 100644
--- a/irods/column.py
+++ b/irods/column.py
@@ -1,4 +1,5 @@
 from __future__ import absolute_import
+import six
 from datetime import datetime
 from calendar import timegm
 
@@ -38,6 +39,20 @@ class Criterion(object):
     def value(self):
         return self.query_key.column_type.to_irods(self._value)
 
+class In(Criterion):
+
+    def __init__(self, query_key, value):
+        super(In, self).__init__('in', query_key, value)
+
+    @property
+    def value(self):
+        v = "("
+        comma = ""
+        for element in self._value:
+            v += "{}'{}'".format(comma,element)
+            comma = ","
+        v += ")"
+        return v
 
 class Like(Criterion):
 
@@ -113,6 +128,12 @@ class String(ColumnType):
 
     @staticmethod
     def to_irods(data):
+        try:
+            # Convert to Unicode string (aka decode)
+            data = six.text_type(data, 'utf-8', 'replace')
+        except TypeError:
+            # Some strings are already Unicode so they do not need decoding
+            pass
         return u"'{}'".format(data)
 
 
diff --git a/irods/connection.py b/irods/connection.py
index 0a26eab..6782c31 100644
--- a/irods/connection.py
+++ b/irods/connection.py
@@ -4,19 +4,33 @@ import logging
 import struct
 import hashlib
 import six
-import struct
 import os
 import ssl
+import datetime
+import irods.password_obfuscation as obf
+from irods import MAX_NAME_LEN
+from ast import literal_eval as safe_eval
 
 
 from irods.message import (
-    iRODSMessage, StartupPack, AuthResponse, AuthChallenge,
+    iRODSMessage, StartupPack, AuthResponse, AuthChallenge, AuthPluginOut,
     OpenedDataObjRequest, FileSeekResponse, StringStringMap, VersionResponse,
-    GSIAuthMessage, ClientServerNegotiation, Error)
-from irods.exception import get_exception_by_code, NetworkException
+    PluginAuthMessage, ClientServerNegotiation, Error, GetTempPasswordOut)
+from irods.exception import (get_exception_by_code, NetworkException, nominal_code)
+from irods.message import (PamAuthRequest, PamAuthRequestOut)
+
+
+ALLOW_PAM_LONG_TOKENS = True      # True to fix [#279]
+# Message to be logged when the connection
+# destructor is called. Used in a unit test
+DESTRUCTOR_MSG = "connection __del__() called"
+
 from irods import (
     MAX_PASSWORD_LENGTH, RESPONSE_LEN,
-    AUTH_SCHEME_KEY, GSI_AUTH_PLUGIN, GSI_AUTH_SCHEME, GSI_OID)
+    AUTH_SCHEME_KEY, AUTH_USER_KEY, AUTH_PWD_KEY, AUTH_TTL_KEY,
+    NATIVE_AUTH_SCHEME,
+    GSI_AUTH_PLUGIN, GSI_AUTH_SCHEME, GSI_OID,
+    PAM_AUTH_SCHEME)
 from irods.client_server_negotiation import (
     perform_negotiation,
     validate_policy,
@@ -29,9 +43,12 @@ from irods.api_number import api_number
 
 logger = logging.getLogger(__name__)
 
+class PlainTextPAMPasswordError(Exception): pass
 
 class Connection(object):
 
+    DISALLOWING_PAM_PLAINTEXT = True
+
     def __init__(self, pool, account):
 
         self.pool = pool
@@ -39,28 +56,34 @@ class Connection(object):
         self.account = account
         self._client_signature = None
         self._server_version = self._connect()
+        self._disconnected = False
 
         scheme = self.account.authentication_scheme
 
-        if scheme == 'native':
+        if scheme == NATIVE_AUTH_SCHEME:
             self._login_native()
-        elif scheme == 'gsi':
+        elif scheme == GSI_AUTH_SCHEME:
             self.client_ctx = None
             self._login_gsi()
+        elif scheme == PAM_AUTH_SCHEME:
+            self._login_pam()
         else:
             raise ValueError("Unknown authentication scheme %s" % scheme)
+        self.create_time = datetime.datetime.now()
+        self.last_used_time = self.create_time
 
     @property
     def server_version(self):
-        return tuple(int(x) for x in self._server_version.relVersion.replace('rods', '').split('.'))
-
+        detected = tuple(int(x) for x in self._server_version.relVersion.replace('rods', '').split('.'))
+        return (safe_eval(os.environ.get('IRODS_SERVER_VERSION','()'))
+                or detected)
     @property
     def client_signature(self):
         return self._client_signature
 
     def __del__(self):
-        if self.socket:
-            self.disconnect()
+        self.disconnect()
+        logger.debug(DESTRUCTOR_MSG)
 
     def send(self, message):
         string = message.pack()
@@ -77,37 +100,36 @@ class Connection(object):
             self.release(True)
             raise NetworkException("Unable to send message")
 
-    def recv(self):
+    def recv(self, into_buffer = None
+                 , return_message = ()
+                 , acceptable_errors = ()):
+        acceptable_codes = set(nominal_code(e) for e in acceptable_errors)
         try:
-            msg = iRODSMessage.recv(self.socket)
-        except socket.error:
+            if into_buffer is None:
+                msg = iRODSMessage.recv(self.socket)
+            else:
+                msg = iRODSMessage.recv_into(self.socket, into_buffer)
+        except (socket.error, socket.timeout) as e:
+            # If _recv_message_in_len() fails in recv() or recv_into(),
+            # it will throw a socket.error exception. The exception is
+            # caught here, a critical message is logged, and is wrapped
+            # in a NetworkException with a more user friendly message
+            logger.critical(e)
             logger.error("Could not receive server response")
             self.release(True)
             raise NetworkException("Could not receive server response")
+        if isinstance(return_message,list): return_message[:] = [msg]
         if msg.int_info < 0:
             try:
                 err_msg = iRODSMessage(msg=msg.error).get_main_message(Error).RErrMsg_PI[0].msg
             except TypeError:
-                raise get_exception_by_code(msg.int_info)
-            raise get_exception_by_code(msg.int_info, err_msg)
+                err_msg = None
+            if nominal_code(msg.int_info) not in acceptable_codes:
+                raise get_exception_by_code(msg.int_info, err_msg)
         return msg
 
-    def recv_into(self, buffer):
-        try:
-            msg = iRODSMessage.recv_into(self.socket, buffer)
-        except socket.error:
-            logger.error("Could not receive server response")
-            self.release(True)
-            raise NetworkException("Could not receive server response")
-
-        if msg.int_info < 0:
-            try:
-                err_msg = iRODSMessage(msg=msg.error).get_main_message(Error).RErrMsg_PI[0].msg
-            except TypeError:
-                raise get_exception_by_code(msg.int_info)
-            raise get_exception_by_code(msg.int_info, err_msg)
-
-        return msg
+    def recv_into(self, buffer, **options):
+        return self.recv( into_buffer = buffer, **options )
 
     def __enter__(self):
         return self
@@ -147,7 +169,13 @@ class Connection(object):
             context = self.account.ssl_context
         except AttributeError:
             CA_file = getattr(self.account, 'ssl_ca_certificate_file', None)
+            verify_server_mode = getattr(self.account,'ssl_verify_server', 'hostname')
+            if verify_server_mode == 'none':
+                CA_file = None
             context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH, cafile=CA_file)
+            if CA_file is None:
+                context.check_hostname = False
+                context.verify_mode = 0  # VERIFY_NONE
 
         # Wrap socket with context
         wrapped_socket = context.wrap_socket(self.socket, server_hostname=host)
@@ -183,15 +211,18 @@ class Connection(object):
 
         try:
             s = socket.create_connection(address, timeout)
+            self._disconnected = False
         except socket.error:
             raise NetworkException(
                 "Could not connect to specified host and port: " +
                 "{}:{}".format(*address))
 
         self.socket = s
+
         main_message = StartupPack(
             (self.account.proxy_user, self.account.proxy_zone),
-            (self.account.client_user, self.account.client_zone)
+            (self.account.client_user, self.account.client_zone),
+            self.pool.application_name
         )
 
         # No client-server negotiation
@@ -249,16 +280,24 @@ class Connection(object):
         return version_msg.get_main_message(VersionResponse)
 
     def disconnect(self):
-        disconnect_msg = iRODSMessage(msg_type='RODS_DISCONNECT')
-        self.send(disconnect_msg)
-        try:
-            # SSL shutdown handshake
-            self.socket = self.socket.unwrap()
-        except AttributeError:
-            pass
-        self.socket.shutdown(socket.SHUT_RDWR)
-        self.socket.close()
-        self.socket = None
+        # Moved the conditions to call disconnect() inside the function.
+        # Added a new criteria for calling disconnect(); Only call
+        # disconnect() if fileno is not -1 (fileno -1 indicates the socket
+        # is already closed). This makes it safe to call disconnect multiple
+        # times on the same connection. The first call cleans up the resources
+        # and next calls are no-ops
+        if self.socket and getattr(self, "_disconnected", False) == False and self.socket.fileno() != -1:
+            disconnect_msg = iRODSMessage(msg_type='RODS_DISCONNECT')
+            self.send(disconnect_msg)
+            try:
+                # SSL shutdown handshake
+                self.socket = self.socket.unwrap()
+            except AttributeError:
+                pass
+            self.socket.shutdown(socket.SHUT_RDWR)
+            self.socket.close()
+            self.socket = None
+            self._disconnected = True
 
     def recvall(self, n):
         # Helper function to recv n bytes or return None if EOF is hit
@@ -334,9 +373,10 @@ class Connection(object):
     def gsi_client_auth_request(self):
 
         # Request for authentication with GSI on current user
-        message_body = GSIAuthMessage(
+
+        message_body = PluginAuthMessage(
             auth_scheme_=GSI_AUTH_PLUGIN,
-            context_='a_user=%s' % self.account.client_user
+            context_='%s=%s' % (AUTH_USER_KEY, self.account.client_user)
         )
         # GSI = 1201
 # https://github.com/irods/irods/blob/master/lib/api/include/apiNumber.h#L158
@@ -362,7 +402,7 @@ class Connection(object):
             username=self.account.proxy_user + '#' + self.account.proxy_zone
         )
         gsi_request = iRODSMessage(
-            msg_type='RODS_API_REQ', int_info=704, msg=gsi_msg)
+            msg_type='RODS_API_REQ', int_info=api_number['AUTH_RESPONSE_AN'], msg=gsi_msg)
         self.send(gsi_request)
         self.recv()
         # auth_response = self.recv()
@@ -381,6 +421,61 @@ class Connection(object):
 
         logger.info("GSI authorization validated")
 
+    def _login_pam(self):
+
+        time_to_live_in_seconds = 60
+
+        ctx_user = '%s=%s' % (AUTH_USER_KEY, self.account.client_user)
+        ctx_pwd = '%s=%s' % (AUTH_PWD_KEY, self.account.password)
+        ctx_ttl = '%s=%s' % (AUTH_TTL_KEY, str(time_to_live_in_seconds))
+
+        ctx = ";".join([ctx_user, ctx_pwd, ctx_ttl])
+
+        if type(self.socket) is socket.socket:
+            if getattr(self,'DISALLOWING_PAM_PLAINTEXT',True):
+                raise PlainTextPAMPasswordError
+
+        Pam_Long_Tokens = (ALLOW_PAM_LONG_TOKENS and (len(ctx) >= MAX_NAME_LEN))
+
+        if Pam_Long_Tokens:
+
+            message_body = PamAuthRequest(
+                pamUser=self.account.client_user,
+                pamPassword=self.account.password,
+                timeToLive=time_to_live_in_seconds)
+        else:
+
+            message_body = PluginAuthMessage(
+                auth_scheme_ = PAM_AUTH_SCHEME,
+                context_ = ctx)
+
+        auth_req = iRODSMessage(
+            msg_type='RODS_API_REQ',
+            msg=message_body,
+            int_info=(725 if Pam_Long_Tokens else 1201)
+        )
+
+        self.send(auth_req)
+        # Getting the new password
+        output_message = self.recv()
+
+        Pam_Response_Class = (PamAuthRequestOut if Pam_Long_Tokens
+                         else AuthPluginOut)
+
+        auth_out = output_message.get_main_message( Pam_Response_Class )
+
+        self.disconnect()
+        self._connect()
+
+        if hasattr(self.account,'store_pw'):
+            drop = self.account.store_pw
+            if type(drop) is list:
+                drop[:] = [ auth_out.result_ ]
+
+        self._login_native(password=auth_out.result_)
+
+        logger.info("PAM authorization validated")
+
     def read_file(self, desc, size=-1, buffer=None):
         if size < 0:
             size = len(buffer)
@@ -408,7 +503,11 @@ class Connection(object):
 
         return response.bs
 
-    def _login_native(self):
+    def _login_native(self, password=None):
+
+        # Default case, PAM login will send a new password
+        if password is None:
+            password = self.account.password or ''
 
         # authenticate
         auth_req = iRODSMessage(msg_type='RODS_API_REQ', int_info=703)
@@ -430,11 +529,11 @@ class Connection(object):
         if six.PY3:
             challenge = challenge.strip()
             padded_pwd = struct.pack(
-                "%ds" % MAX_PASSWORD_LENGTH, self.account.password.encode(
+                "%ds" % MAX_PASSWORD_LENGTH, password.encode(
                     'utf-8').strip())
         else:
             padded_pwd = struct.pack(
-                "%ds" % MAX_PASSWORD_LENGTH, self.account.password)
+                "%ds" % MAX_PASSWORD_LENGTH, password)
 
         m = hashlib.md5()
         m.update(challenge)
@@ -450,7 +549,7 @@ class Connection(object):
         pwd_msg = AuthResponse(
             response=encoded_pwd, username=self.account.proxy_user)
         pwd_request = iRODSMessage(
-            msg_type='RODS_API_REQ', int_info=704, msg=pwd_msg)
+            msg_type='RODS_API_REQ', int_info=api_number['AUTH_RESPONSE_AN'], msg=pwd_msg)
         self.send(pwd_request)
         self.recv()
 
@@ -504,3 +603,16 @@ class Connection(object):
 
         self.send(message)
         self.recv()
+
+    def temp_password(self):
+        request = iRODSMessage("RODS_API_REQ", msg=None,
+                               int_info=api_number['GET_TEMP_PASSWORD_AN'])
+
+        # Send and receive request
+        self.send(request)
+        response = self.recv()
+        logger.debug(response.int_info)
+
+        # Convert and return answer
+        msg = response.get_main_message(GetTempPasswordOut)
+        return obf.create_temp_password(msg.stringToHashWith, self.account.password)
diff --git a/irods/data_object.py b/irods/data_object.py
index 7895bdd..71308b9 100644
--- a/irods/data_object.py
+++ b/irods/data_object.py
@@ -3,13 +3,18 @@ import io
 import sys
 import logging
 import six
+import os
+import ast
 
 from irods.models import DataObject
 from irods.meta import iRODSMetaCollection
 import irods.keywords as kw
+from irods.api_number import api_number
+from irods.message import (JSON_Message, iRODSMessage)
 
 logger = logging.getLogger(__name__)
 
+IRODS_SERVER_WITH_CLOSE_REPLICA_API = (4,2,9)
 
 def chunks(f, chunksize=io.DEFAULT_BUFFER_SIZE):
     return iter(lambda: f.read(chunksize), b'')
@@ -23,11 +28,12 @@ def irods_basename(path):
 
 class iRODSReplica(object):
 
-    def __init__(self, number, status, resource_name, path, **kwargs):
+    def __init__(self, number, status, resource_name, path, resc_hier, **kwargs):
         self.number = number
         self.status = status
         self.resource_name = resource_name
         self.path = path
+        self.resc_hier = resc_hier
         for key, value in kwargs.items():
             setattr(self, key, value)
 
@@ -61,11 +67,15 @@ class iRODSDataObject(object):
                 r[DataObject.replica_status],
                 r[DataObject.resource_name],
                 r[DataObject.path],
+                r[DataObject.resc_hier],
                 checksum=r[DataObject.checksum],
                 size=r[DataObject.size]
             ) for r in replicas]
         self._meta = None
 
+
+
+
     def __repr__(self):
         return "<iRODSDataObject {id} {name}>".format(**vars(self))
 
@@ -76,11 +86,19 @@ class iRODSDataObject(object):
                 self.manager.sess.metadata, DataObject, self.path)
         return self._meta
 
-    def open(self, mode='r', **options):
-        if kw.DEST_RESC_NAME_KW not in options:
-            options[kw.DEST_RESC_NAME_KW] = self.replicas[0].resource_name
+    def open(self, mode='r', finalize_on_close = True, **options):
+        return self.manager.open(self.path, mode, finalize_on_close = finalize_on_close, **options)
 
-        return self.manager.open(self.path, mode, **options)
+    def chksum(self, **options):
+        """
+        See: https://github.com/irods/irods/blob/4-2-stable/lib/api/include/dataObjChksum.h
+        for a list of applicable irods.keywords options.
+
+        NB options dict may also include a default-constructed RErrorStack object under the key r_error.
+        If passed, this object can receive a list of warnings, one for each existing replica lacking a
+        checksum.  (Relevant only in combination with VERIFY_CHKSUM_KW).
+        """
+        return self.manager.chksum(self.path, **options)
 
     def unlink(self, force=False, **options):
         self.manager.unlink(self.path, force, **options)
@@ -99,13 +117,61 @@ class iRODSDataObject(object):
 
 class iRODSDataObjectFileRaw(io.RawIOBase):
 
-    def __init__(self, conn, descriptor, **options):
+    """The raw object supporting file-like operations (read/write/seek) for the
+       iRODSDataObject."""
+
+    def __init__(self, conn, descriptor, finalize_on_close = True, **options):
+        """
+        Constructor needs a connection and an iRODS data object descriptor. If the
+        finalize_on_close flag evaluates False, close() will invoke the REPLICA_CLOSE
+        API instead of closing and finalizing the object (useful for parallel
+        transfers using multiple threads).
+        """
+        super(iRODSDataObjectFileRaw,self).__init__()
         self.conn = conn
         self.desc = descriptor
         self.options = options
+        self.finalize_on_close = finalize_on_close
+
+    def replica_access_info(self):
+        message_body = JSON_Message( {'fd': self.desc},
+                                     server_version = self.conn.server_version )
+        message = iRODSMessage('RODS_API_REQ', msg = message_body,
+                               int_info=api_number['GET_FILE_DESCRIPTOR_INFO_APN'])
+        self.conn.send(message)
+        result = None
+        try:
+            result = self.conn.recv()
+        except Exception as e:
+            logger.warning('''Couldn't receive or process response to GET_FILE_DESCRIPTOR_INFO_APN -- '''
+                           '''caught: %r''',e)
+            raise
+        dobj_info = result.get_json_encoded_struct()
+        replica_token = dobj_info.get("replica_token","")
+        resc_hier = ( dobj_info.get("data_object_info") or {} ).get("resource_hierarchy","")
+        return (replica_token, resc_hier)
+
+    def _close_replica(self):
+        server_version = ast.literal_eval(os.environ.get('IRODS_VERSION_OVERRIDE', '()' ))
+        if (server_version or self.conn.server_version) < IRODS_SERVER_WITH_CLOSE_REPLICA_API: return False
+        message_body = JSON_Message( { "fd": self.desc,
+                                       "send_notification": False,
+                                       "update_size": False,
+                                       "update_status": False,
+                                       "compute_checksum": False },
+                                     server_version = self.conn.server_version )
+        self.conn.send( iRODSMessage('RODS_API_REQ', msg = message_body,
+                                     int_info=api_number['REPLICA_CLOSE_APN']) )
+        try:
+            self.conn.recv().int_info
+        except Exception:
+            logger.warning ('** ERROR on closing replica **')
+            raise
+        return True
 
     def close(self):
-        self.conn.close_file(self.desc, **self.options)
+        if self.finalize_on_close or not self._close_replica():
+            self.conn.close_file(self.desc, **self.options)
         self.conn.release()
         super(iRODSDataObjectFileRaw, self).close()
         return None
diff --git a/irods/exception.py b/irods/exception.py
index a1976a1..51a7385 100644
--- a/irods/exception.py
+++ b/irods/exception.py
@@ -4,6 +4,9 @@
 
 from __future__ import absolute_import
 import six
+import numbers
+
+
 class PycommandsException(Exception):
     pass
 
@@ -24,6 +27,10 @@ class CollectionDoesNotExist(DoesNotExist):
     pass
 
 
+class ZoneDoesNotExist(DoesNotExist):
+    pass
+
+
 class UserDoesNotExist(DoesNotExist):
     pass
 
@@ -64,8 +71,47 @@ class iRODSException(six.with_metaclass(iRODSExceptionMeta, Exception)):
     pass
 
 
-def get_exception_by_code(code, message=None):
-    return iRODSExceptionMeta.codes[code](message)
+def nominal_code( the_code, THRESHOLD = 1000 ):
+    nominal = []
+    c = rounded_code( the_code , nominal_int_repo = nominal )
+    negated = -abs(nominal[0])
+    return c if (negated <= -abs(THRESHOLD)) else negated  # produce a negative for nonzero integer input
+
+def rounded_code( the_code , nominal_int_repo = () ):
+    nom_err = None
+    try:
+        if isinstance(the_code,type) and \
+           issubclass(the_code, iRODSException): the_code = getattr( the_code, 'code', the_code )
+        if isinstance(the_code,str):
+            nom_err = globals()[the_code].code
+            return nom_err
+        elif isinstance(the_code,numbers.Integral):
+            nom_err = the_code
+            return 1000 * ((-abs(the_code) - 1) // 1000 + 1)
+        else:
+            message = 'Supplied code {the_code!r} must be integer or string'.format(**locals())
+            raise RuntimeError(message)
+    finally:
+        if nom_err is not None and isinstance(nominal_int_repo,list):
+            nominal_int_repo[:] = [nom_err]
+
+
+def get_exception_class_by_code(code, name_only=False):
+    rounded = rounded_code (code)  # rounded up to -1000 if code <= -1000
+    cls = iRODSExceptionMeta.codes.get( rounded )
+    return cls if not name_only \
+      else (cls.__name__ if cls is not None else 'Unknown_iRODS_error')
+
+
+def get_exception_by_code(code, message = None):
+    exc_class = iRODSExceptionMeta.codes[ rounded_code( code ) ]
+    exc_instance = exc_class( message )
+    exc_instance.code = code
+    return exc_instance
+
+
+class UnknowniRODSError(iRODSException):
+    code = 0  # covers rounded_code (errcode) if 0 > errcode > -1000
 
 
 class SystemException(iRODSException):
@@ -504,6 +550,10 @@ class SYS_RESC_QUOTA_EXCEEDED(SystemException):
     code = -110000
 
 
+class SYS_INVALID_INPUT_PARAM(SystemException):
+    code = -130000
+
+
 class UserInputException(iRODSException):
     pass
 
@@ -696,6 +746,18 @@ class NO_LOCAL_FILE_RSYNC_IN_MSI(UserInputException):
     code = -356000
 
 
+class OBJ_PATH_DOES_NOT_EXIST(iRODSException):
+    code = -358000
+
+
+class LOCKED_DATA_OBJECT_ACCESS(SystemException):
+    code = -406000
+
+
+class CHECK_VERIFICATION_RESULTS(SystemException):
+    code = -407000
+
+
 class FileDriverException(iRODSException):
     pass
 
@@ -995,6 +1057,8 @@ class CAT_NO_ROWS_FOUND(CatalogLibraryException):
 class CATALOG_ALREADY_HAS_ITEM_BY_THAT_NAME(CatalogLibraryException):
     code = -809000
 
+class CAT_NO_CHECKSUM_FOR_REPLICA (CatalogLibraryException):
+    code = -862000
 
 class CAT_INVALID_RESOURCE_TYPE(CatalogLibraryException):
     code = -810000
@@ -1131,6 +1195,9 @@ class CAT_TABLE_ACCESS_DENIED(CatalogLibraryException):
 class CAT_UNKNOWN_SPECIFIC_QUERY(CatalogLibraryException):
     code = -853000
 
+class CAT_STATEMENT_TABLE_FULL(CatalogLibraryException):
+    code = -860000
+
 
 class RDSException(iRODSException):
     pass
@@ -1176,6 +1243,18 @@ class RDA_NAME_NOT_FOUND(RDSException):
     code = -889000
 
 
+class TicketException(CatalogLibraryException):
+    pass
+
+
+class CAT_TICKET_INVALID(TicketException):
+    code = -890000
+
+
+class CAT_TICKET_EXPIRED(TicketException):
+    code = -891000
+
+
 class MiscException(iRODSException):
     pass
 
@@ -1492,6 +1571,10 @@ class DATAACCESSINX_EMPTY_IN_STRUCT_ERR(RuleEngineException):
     code = -1016000
 
 
+class RE_TYPE_ERROR(RuleEngineException):
+    code = -1230000
+
+
 class NO_RULE_FOUND_ERR(RuleEngineException):
     code = -1017000
 
@@ -1860,6 +1943,10 @@ class MSI_OPERATION_NOT_ALLOWED(RuleEngineException):
     code = -1110000
 
 
+class RULE_ENGINE_ERROR(RuleEngineException):
+    code = -1828000
+
+
 class PHPException(iRODSException):
     pass
 
@@ -1874,3 +1961,22 @@ class PHP_REQUEST_STARTUP_ERR(PHPException):
 
 class PHP_OPEN_SCRIPT_FILE_ERR(PHPException):
     code = -1602000
+
+class PAMException(iRODSException):
+    pass
+
+
+class PAM_AUTH_NOT_BUILT_INTO_CLIENT(PAMException):
+    code = -991000
+
+
+class PAM_AUTH_NOT_BUILT_INTO_SERVER(PAMException):
+    code = -992000
+
+
+class PAM_AUTH_PASSWORD_FAILED(PAMException):
+    code = -993000
+
+
+class PAM_AUTH_PASSWORD_INVALID_TTL(PAMException):
+    code = -994000
diff --git a/irods/keywords.py b/irods/keywords.py
index 6880bfe..23af305 100644
--- a/irods/keywords.py
+++ b/irods/keywords.py
@@ -8,6 +8,7 @@ FORCE_FLAG_KW = "forceFlag"    # force update #
 CLI_IN_SVR_FIREWALL_KW = "cliInSvrFirewall"  # cli behind same firewall #
 REG_CHKSUM_KW = "regChksum"    # register checksum #
 VERIFY_CHKSUM_KW = "verifyChksum"   # verify checksum #
+NO_COMPUTE_KW = "no_compute"
 VERIFY_BY_SIZE_KW = "verifyBySize"  # verify by size - used by irsync #
 OBJ_PATH_KW = "objPath"   # logical path of the object #
 RESC_NAME_KW = "rescName"   # resource name #
@@ -210,6 +211,7 @@ AGE_KW = "age"  # age of the file for itrim #
 # =-=-=-=-=-=-=-
 # irods general keywords definitions
 RESC_HIER_STR_KW = "resc_hier"
+REPLICA_TOKEN_KW = "replicaToken"
 DEST_RESC_HIER_STR_KW = "dest_resc_hier"
 IN_PDMO_KW = "in_pdmo"
 STAGE_OBJ_KW = "stage_object"
diff --git a/irods/manager/__init__.py b/irods/manager/__init__.py
index 9ad1dcf..09c184c 100644
--- a/irods/manager/__init__.py
+++ b/irods/manager/__init__.py
@@ -1,4 +1,15 @@
 class Manager(object):
 
+    __server_version = ()
+
+    @property
+    def server_version(self):
+        if not self.__server_version:
+            p = self.sess.pool
+            if p is None : raise RuntimeError ("session not configured")
+            conn = getattr(p,"_conn",None) or p.get_connection()
+            if conn: self.__server_version = conn.server_version
+        return tuple( self.__server_version )
+
     def __init__(self, sess):
         self.sess = sess
diff --git a/irods/manager/access_manager.py b/irods/manager/access_manager.py
index 8276e5d..0634769 100644
--- a/irods/manager/access_manager.py
+++ b/irods/manager/access_manager.py
@@ -4,20 +4,38 @@ from os.path import basename, dirname
 from irods.manager import Manager
 from irods.api_number import api_number
 from irods.message import ModAclRequest, iRODSMessage
-from irods.data_object import iRODSDataObject
+from irods.data_object import ( iRODSDataObject, irods_dirname, irods_basename )
 from irods.collection import iRODSCollection
-from irods.models import (
-    DataObject, Collection, User, DataAccess, CollectionAccess, CollectionUser)
+from irods.models import ( DataObject, Collection, User, CollectionUser,
+                           DataAccess, CollectionAccess )
 from irods.access import iRODSAccess
+from irods.column import In
+from irods.user import iRODSUser
 
+import six
 import logging
 
 logger = logging.getLogger(__name__)
 
+def users_by_ids(session,ids=()):
+    try:
+        ids=list(iter(ids))
+    except TypeError:
+        if type(ids) in (str,) + six.integer_types: ids=int(ids)
+        else: raise
+    cond = () if not ids \
+           else (In(User.id,list(map(int,ids))),) if len(ids)>1 \
+           else (User.id == int(ids[0]),)
+    return [ iRODSUser(session.users,i)
+             for i in session.query(User.id,User.name,User.type,User.zone).filter(*cond) ]
 
 class AccessManager(Manager):
 
-    def get(self, target):
+    def get(self, target, report_raw_acls = False, **kw):
+
+        if report_raw_acls:
+            return self.__get_raw(target, **kw)  # prefer a behavior consistent  with 'ils -A`
+
         # different query whether target is an object or a collection
         if type(target) == iRODSDataObject:
             access_type = DataAccess
@@ -45,14 +63,69 @@ class AccessManager(Manager):
             user_zone=row[user_type.zone]
         ) for row in results]
 
+    def coll_access_query(self,path):
+        return self.sess.query(Collection, CollectionAccess).filter(Collection.name == path)
+
+    def data_access_query(self,path):
+        cn = irods_dirname(path)
+        dn = irods_basename(path)
+        return self.sess.query(DataObject, DataAccess).filter( Collection.name == cn, DataObject.name == dn )
+
+    def __get_raw(self, target, **kw):
+
+        ### sample usage: ###
+        #
+        #  user_id_list = []  # simply to store the user id's from the discovered ACL's
+        #  session.permissions.get( data_or_coll_target, report_raw_acls = True,
+        #                                                acl_users = user_id_list,
+        #                                                acl_users_transform = lambda u: u.id)
+        #
+        # -> returns list of iRODSAccess objects mapping one-to-one with ACL's stored in the catalog
+
+        users_out = kw.pop( 'acl_users', None )
+        T = kw.pop( 'acl_users_transform', lambda value : value )
+
+        # different choice of query based on whether target is an object or a collection
+        if isinstance(target, iRODSDataObject):
+            access_column = DataAccess
+            query_func    = self.data_access_query
+
+        elif isinstance(target, iRODSCollection):
+            access_column = CollectionAccess
+            query_func    = self.coll_access_query
+        else:
+            raise TypeError
+
+        rows  = [ r for r in query_func(target.path) ]
+        userids = set( r[access_column.user_id] for r in rows )
+
+        user_lookup = { j.id:j for j in users_by_ids(self.sess, userids) }
+
+        if isinstance(users_out, dict):     users_out.update (user_lookup)
+        elif isinstance (users_out, list):  users_out += [T(v) for v in user_lookup.values()]
+        elif isinstance (users_out, set):   users_out |= set(T(v) for v in user_lookup.values())
+        elif users_out is None: pass
+        else:                   raise TypeError
+
+        acls = [ iRODSAccess ( r[access_column.name],
+                               target.path,
+                               user_lookup[r[access_column.user_id]].name,
+                               user_lookup[r[access_column.user_id]].zone  ) for r in rows ]
+        return acls
+
+
     def set(self, acl, recursive=False, admin=False):
         prefix = 'admin:' if admin else ''
 
+        userName_=acl.user_name
+        zone_=acl.user_zone
+        if acl.access_name.endswith('inherit'): zone_ = userName_ = ''
+
         message_body = ModAclRequest(
             recursiveFlag=int(recursive),
             accessLevel='{prefix}{access_name}'.format(prefix=prefix, **vars(acl)),
-            userName=acl.user_name,
-            zone=acl.user_zone,
+            userName=userName_,
+            zone=zone_,
             path=acl.path
         )
         request = iRODSMessage("RODS_API_REQ", msg=message_body,
diff --git a/irods/manager/collection_manager.py b/irods/manager/collection_manager.py
index 4007426..2b3f0ef 100644
--- a/irods/manager/collection_manager.py
+++ b/irods/manager/collection_manager.py
@@ -1,5 +1,5 @@
 from __future__ import absolute_import
-from irods.models import Collection
+from irods.models import Collection, DataObject
 from irods.manager import Manager
 from irods.message import iRODSMessage, CollectionRequest, FileOpenRequest, ObjCopyRequest, StringStringMap
 from irods.exception import CollectionDoesNotExist, NoResultFound
@@ -12,15 +12,25 @@ import irods.keywords as kw
 class CollectionManager(Manager):
 
     def get(self, path):
-        query = self.sess.query(Collection).filter(Collection.name == path)
-        try:
-            result = query.one()
-        except NoResultFound:
-            raise CollectionDoesNotExist()
-        return iRODSCollection(self, result)
+        path = iRODSCollection.normalize_path( path )
+        filters = [Collection.name == path]
+        # if a ticket is supplied for this session, try both without and with DataObject join
+        repeats = (True,False) if hasattr(self.sess,'ticket__') \
+             else (False,)
+        for rep in repeats:
+            query = self.sess.query(Collection).filter(*filters)
+            try:
+                result = query.one()
+            except NoResultFound:
+                if rep:
+                    filters += [DataObject.id != 0]
+                    continue
+                raise CollectionDoesNotExist()
+            return iRODSCollection(self, result)
 
 
     def create(self, path, recurse=True, **options):
+        path = iRODSCollection.normalize_path( path )
         if recurse:
             options[kw.RECURSIVE_OPR__KW] = ''
        
diff --git a/irods/manager/data_object_manager.py b/irods/manager/data_object_manager.py
index 513b439..09ecea1 100644
--- a/irods/manager/data_object_manager.py
+++ b/irods/manager/data_object_manager.py
@@ -1,15 +1,39 @@
 from __future__ import absolute_import
 import os
 import io
-from irods.models import DataObject
+from irods.models import DataObject, Collection
 from irods.manager import Manager
 from irods.message import (
-    iRODSMessage, FileOpenRequest, ObjCopyRequest, StringStringMap, DataObjInfo, ModDataObjMeta)
+    iRODSMessage, FileOpenRequest, ObjCopyRequest, StringStringMap, DataObjInfo, ModDataObjMeta,
+    DataObjChksumRequest, DataObjChksumResponse, RErrorStack)
 import irods.exception as ex
 from irods.api_number import api_number
+from irods.collection import iRODSCollection
 from irods.data_object import (
     iRODSDataObject, iRODSDataObjectFileRaw, chunks, irods_dirname, irods_basename)
 import irods.keywords as kw
+import irods.parallel as parallel
+from irods.parallel import deferred_call
+import six
+import ast
+import json
+import logging
+
+
+MAXIMUM_SINGLE_THREADED_TRANSFER_SIZE = 32 * ( 1024 ** 2)
+
+DEFAULT_NUMBER_OF_THREADS = 0   # Defaults for reasonable number of threads -- optimized to be
+                                # performant but allow no more worker threads than available CPUs.
+
+DEFAULT_QUEUE_DEPTH = 32
+
+
+class Server_Checksum_Warning(Exception):
+    """Error from iRODS server indicating some replica checksums are missing or incorrect."""
+    def __init__(self,json_response):
+        """Initialize the exception object with a checksum field from the server response message."""
+        super(Server_Checksum_Warning,self).__init__()
+        self.response = json.loads(json_response)
 
 
 class DataObjectManager(Manager):
@@ -26,57 +50,199 @@ class DataObjectManager(Manager):
     O_EXCL = 128
     O_TRUNC = 512
 
-    def _download(self, obj, local_path, **options):
+
+    def should_parallelize_transfer( self,
+                                     num_threads = 0,
+                                     obj_sz = 1+MAXIMUM_SINGLE_THREADED_TRANSFER_SIZE,
+                                     server_version_hint = (),
+                                     measured_obj_size = ()  ## output variable. If a list is provided, it shall
+                                                              # be truncated to contain one value, the size of the
+                                                              # seekable object (if one is provided for `obj_sz').
+        ):
+
+        # Allow an environment variable to override the detection of the server version.
+        # Example: $ export IRODS_VERSION_OVERRIDE="4,2,9" ;  python -m irods.parallel ...
+        # ---
+        # Delete the following line on resolution of https://github.com/irods/irods/issues/5932 :
+        if getattr(self.sess,'ticket__',None) is not None: return False
+        server_version = ( ast.literal_eval(os.environ.get('IRODS_VERSION_OVERRIDE', '()' )) or server_version_hint or 
+                           self.server_version )
+        if num_threads == 1 or ( server_version < parallel.MINIMUM_SERVER_VERSION ):
+            return False
+        if getattr(obj_sz,'seek',None) :
+            pos = obj_sz.tell()
+            size = obj_sz.seek(0,os.SEEK_END)
+            if not isinstance(size,six.integer_types):
+                size = obj_sz.tell()
+            obj_sz.seek(pos,os.SEEK_SET)
+            if isinstance(measured_obj_size,list): measured_obj_size[:] = [size]
+        else:
+            size = obj_sz
+            assert (size > -1)
+        return size > MAXIMUM_SINGLE_THREADED_TRANSFER_SIZE
+
+
+    def _download(self, obj, local_path, num_threads, **options):
+        """Transfer the contents of a data object to a local file.
+
+        Called from get() when a local path is named.
+        """
         if os.path.isdir(local_path):
-            file = os.path.join(local_path, irods_basename(obj))
+            local_file = os.path.join(local_path, irods_basename(obj))
         else:
-            file = local_path
+            local_file = local_path
 
-        # Check for force flag if file exists
-        if os.path.exists(file) and kw.FORCE_FLAG_KW not in options:
+        # Check for force flag if local_file exists
+        if os.path.exists(local_file) and kw.FORCE_FLAG_KW not in options:
             raise ex.OVERWRITE_WITHOUT_FORCE_FLAG
 
-        with open(file, 'wb') as f, self.open(obj, 'r', **options) as o:
-            for chunk in chunks(o, self.READ_BUFFER_SIZE):
-                f.write(chunk)
+        with open(local_file, 'wb') as f, self.open(obj, 'r', **options) as o:
 
+            if self.should_parallelize_transfer (num_threads, o):
+                f.close()
+                if not self.parallel_get( (obj,o), local_path, num_threads = num_threads,
+                                          target_resource_name = options.get(kw.RESC_NAME_KW,'')):
+                    raise RuntimeError("parallel get failed")
+            else:
+                for chunk in chunks(o, self.READ_BUFFER_SIZE):
+                    f.write(chunk)
 
-    def get(self, path, file=None, **options):
+
+    def get(self, path, local_path = None, num_threads = DEFAULT_NUMBER_OF_THREADS, **options):
+        """
+        Get a reference to the data object at the specified `path'.
+
+        Only download the object if the local_path is a string (specifying
+        a path in the local filesystem to use as a destination file).
+        """
         parent = self.sess.collections.get(irods_dirname(path))
 
         # TODO: optimize
-        if file:
-            self._download(path, file, **options)
+        if local_path:
+            self._download(path, local_path, num_threads = num_threads, **options)
 
         query = self.sess.query(DataObject)\
             .filter(DataObject.name == irods_basename(path))\
-            .filter(DataObject.collection_id == parent.id)
+            .filter(DataObject.collection_id == parent.id)\
+            .add_keyword(kw.ZONE_KW, path.split('/')[1])
+
+        if hasattr(self.sess,'ticket__'):
+            query = query.filter(Collection.id != 0) # a no-op, but necessary because CAT_SQL_ERR results if the ticket
+                                                     # is for a DataObject and we don't explicitly join to Collection
+
         results = query.all() # get up to max_rows replicas
         if len(results) <= 0:
             raise ex.DataObjectDoesNotExist()
         return iRODSDataObject(self, parent, results)
 
 
-    def put(self, file, irods_path, **options):
-        if irods_path.endswith('/'):
-            obj = irods_path + os.path.basename(file)
+    def put(self, local_path, irods_path, return_data_object = False, num_threads = DEFAULT_NUMBER_OF_THREADS, **options):
+
+        if self.sess.collections.exists(irods_path):
+            obj = iRODSCollection.normalize_path(irods_path, os.path.basename(local_path))
         else:
             obj = irods_path
 
-        # Set operation type to trigger acPostProcForPut
-        if kw.OPR_TYPE_KW not in options:
-            options[kw.OPR_TYPE_KW] = 1 # PUT_OPR
-
-        with open(file, 'rb') as f, self.open(obj, 'w', **options) as o:
-            for chunk in chunks(f, self.WRITE_BUFFER_SIZE):
-                o.write(chunk)
-
+        with open(local_path, 'rb') as f:
+            sizelist = []
+            if self.should_parallelize_transfer (num_threads, f, measured_obj_size = sizelist):
+                o = deferred_call( self.open, (obj, 'w'), options)
+                f.close()
+                if not self.parallel_put( local_path, (obj,o), total_bytes = sizelist[0], num_threads = num_threads,
+                                          target_resource_name = options.get(kw.RESC_NAME_KW,'') or
+                                                                 options.get(kw.DEST_RESC_NAME_KW,''),
+                                          open_options = options ):
+                    raise RuntimeError("parallel put failed")
+            else:
+                with self.open(obj, 'w', **options) as o:
+                    # Set operation type to trigger acPostProcForPut
+                    if kw.OPR_TYPE_KW not in options:
+                        options[kw.OPR_TYPE_KW] = 1 # PUT_OPR
+                    for chunk in chunks(f, self.WRITE_BUFFER_SIZE):
+                        o.write(chunk)
         if kw.ALL_KW in options:
             options[kw.UPDATE_REPL_KW] = ''
             self.replicate(obj, **options)
 
+        if return_data_object:
+            return self.get(obj)
 
-    def create(self, path, resource=None, **options):
+
+    def chksum(self, path, **options):
+        """
+        See: https://github.com/irods/irods/blob/4-2-stable/lib/api/include/dataObjChksum.h
+        for a list of applicable irods.keywords options.
+        """
+        r_error_stack = options.pop('r_error',None)
+        message_body = DataObjChksumRequest(path, **options)
+        message = iRODSMessage('RODS_API_REQ', msg=message_body,
+                               int_info=api_number['DATA_OBJ_CHKSUM_AN'])
+        checksum = ""
+        msg_retn = []
+        with self.sess.pool.get_connection() as conn:
+            conn.send(message)
+            try:
+                response = conn.recv(return_message = msg_retn)
+            except ex.CHECK_VERIFICATION_RESULTS as exc:
+                # We'll get a response in the client to help qualify or elaborate on the error thrown.
+                if msg_retn: response = msg_retn[0]
+                logging.warning("Exception checksumming data object %r - %r",path,exc)
+            if 'response' in locals():
+                try:
+                    results = response.get_main_message(DataObjChksumResponse, r_error = r_error_stack)
+                    checksum = results.myStr.strip()
+                    if checksum[0] in ( '[','{' ):  # in iRODS 4.2.11 and later, myStr is in JSON format.
+                        exc = Server_Checksum_Warning( checksum )
+                        if not r_error_stack:
+                            r_error_stack.fill(exc.response)
+                        raise exc
+                except iRODSMessage.ResponseNotParseable:
+                    # response.msg is None when VERIFY_CHKSUM_KW is used
+                    pass
+        return checksum
+
+
+    def parallel_get(self,
+                     data_or_path_ ,
+                     file_ ,
+                     async_ = False,
+                     num_threads = 0,
+                     target_resource_name = '',
+                     progressQueue = False):
+        """Call into the irods.parallel library for multi-1247 GET.
+
+        Called from a session.data_objects.get(...) (via the _download method) on
+        the condition that the data object is determined to be of appropriate size
+        for parallel download.
+
+        """
+        return parallel.io_main( self.sess, data_or_path_, parallel.Oper.GET | (parallel.Oper.NONBLOCKING if async_ else 0), file_,
+                                 num_threads = num_threads, target_resource_name = target_resource_name,
+                                 queueLength = (DEFAULT_QUEUE_DEPTH if progressQueue else 0))
+
+    def parallel_put(self,
+                     file_ ,
+                     data_or_path_ ,
+                     async_ = False,
+                     total_bytes = -1,
+                     num_threads = 0,
+                     target_resource_name = '',
+                     open_options = {},
+                     progressQueue = False):
+        """Call into the irods.parallel library for multi-1247 PUT.
+
+        Called from a session.data_objects.put(...) on the condition that the
+        data object is determined to be of appropriate size for parallel upload.
+
+        """
+        return parallel.io_main( self.sess, data_or_path_, parallel.Oper.PUT | (parallel.Oper.NONBLOCKING if async_ else 0), file_,
+                                 num_threads = num_threads, total_bytes = total_bytes,  target_resource_name = target_resource_name,
+                                 open_options = open_options,
+                                 queueLength = (DEFAULT_QUEUE_DEPTH if progressQueue else 0)
+                               )
+
+
+    def create(self, path, resource=None, force=False, **options):
         options[kw.DATA_TYPE_KW] = 'generic'
 
         if resource:
@@ -88,6 +254,9 @@ class DataObjectManager(Manager):
             except AttributeError:
                 pass
 
+        if force:
+            options[kw.FORCE_FLAG_KW] = ''
+
         message_body = FileOpenRequest(
             objPath=path,
             createMode=0o644,
@@ -110,21 +279,27 @@ class DataObjectManager(Manager):
         return self.get(path)
 
 
-    def open(self, path, mode, **options):
+    def open_with_FileRaw(self, *arg, **kw_options):
+        holder = []
+        handle = self.open(*arg,_raw_fd_holder=holder,**kw_options)
+        return (handle, holder[-1])
+
+    def open(self, path, mode, create = True, finalize_on_close = True, **options):
+        _raw_fd_holder =  options.get('_raw_fd_holder',[])
         if kw.DEST_RESC_NAME_KW not in options:
             # Use client-side default resource if available
             try:
                 options[kw.DEST_RESC_NAME_KW] = self.sess.default_resource
             except AttributeError:
                 pass
-
+        createFlag = self.O_CREAT if create else 0
         flags, seek_to_end = {
             'r': (self.O_RDONLY, False),
             'r+': (self.O_RDWR, False),
-            'w': (self.O_WRONLY | self.O_CREAT | self.O_TRUNC, False),
-            'w+': (self.O_RDWR | self.O_CREAT | self.O_TRUNC, False),
-            'a': (self.O_WRONLY | self.O_CREAT, True),
-            'a+': (self.O_RDWR | self.O_CREAT, True),
+            'w': (self.O_WRONLY | createFlag | self.O_TRUNC, False),
+            'w+': (self.O_RDWR | createFlag | self.O_TRUNC, False),
+            'a': (self.O_WRONLY | createFlag, True),
+            'a+': (self.O_RDWR | createFlag, True),
         }[mode]
         # TODO: Use seek_to_end
 
@@ -150,7 +325,9 @@ class DataObjectManager(Manager):
         conn.send(message)
         desc = conn.recv().int_info
 
-        return io.BufferedRandom(iRODSDataObjectFileRaw(conn, desc, **options))
+        raw = iRODSDataObjectFileRaw(conn, desc, finalize_on_close = finalize_on_close, **options)
+        (_raw_fd_holder).append(raw)
+        return io.BufferedRandom(raw)
 
 
     def unlink(self, path, force=False, **options):
diff --git a/irods/manager/metadata_manager.py b/irods/manager/metadata_manager.py
index 338e5d6..80444a9 100644
--- a/irods/manager/metadata_manager.py
+++ b/irods/manager/metadata_manager.py
@@ -1,34 +1,48 @@
+from __future__ import print_function
 from __future__ import absolute_import
 import logging
 from os.path import dirname, basename
 
 from irods.manager import Manager
-from irods.message import MetadataRequest, iRODSMessage
+from irods.message import MetadataRequest, iRODSMessage, JSON_Message
 from irods.api_number import api_number
 from irods.models import (DataObject, Collection, Resource,
                           User, DataObjectMeta, CollectionMeta, ResourceMeta, UserMeta)
-from irods.meta import iRODSMeta
+from irods.meta import iRODSMeta, AVUOperation
+
 
 logger = logging.getLogger(__name__)
 
 
+class InvalidAtomicAVURequest(Exception): pass
+
+
 class MetadataManager(Manager):
 
     @staticmethod
     def _model_class_to_resource_type(model_cls):
         return {
             DataObject: 'd',
-            Collection: 'c',
-            Resource: 'r',
+            Collection: 'C',
+            Resource: 'R',
             User: 'u',
         }[model_cls]
 
+    @staticmethod
+    def _model_class_to_resource_description(model_cls):
+        return {
+            DataObject: 'data_object',
+            Collection: 'collection',
+            Resource: 'resource',
+            User: 'user',
+        }[model_cls]
+
     def get(self, model_cls, path):
         resource_type = self._model_class_to_resource_type(model_cls)
         model = {
             'd': DataObjectMeta,
-            'c': CollectionMeta,
-            'r': ResourceMeta,
+            'C': CollectionMeta,
+            'R': ResourceMeta,
             'u': UserMeta
         }[resource_type]
         conditions = {
@@ -36,8 +50,8 @@ class MetadataManager(Manager):
                 Collection.name == dirname(path),
                 DataObject.name == basename(path)
             ],
-            'c': [Collection.name == path],
-            'r': [Resource.name == path],
+            'C': [Collection.name == path],
+            'R': [Resource.name == path],
             'u': [User.name == path]
         }[resource_type]
         results = self.sess.query(model.id, model.name, model.value, model.units)\
@@ -121,3 +135,33 @@ class MetadataManager(Manager):
             conn.send(request)
             response = conn.recv()
         logger.debug(response.int_info)
+
+    @staticmethod
+    def _avu_operation_to_dict( op ):
+        opJSON = { "operation": op.operation,
+                   "attribute": op.avu.name,
+                   "value": op.avu.value
+        }
+        if op.avu.units not in ("",None):
+            opJSON["units"] = op.avu.units
+        return opJSON
+
+    def apply_atomic_operations(self, model_cls, path, *avu_ops):
+        if not all(isinstance(op,AVUOperation) for op in avu_ops):
+            raise InvalidAtomicAVURequest("avu_ops must contain 1 or more AVUOperations")
+        request = {
+            "entity_name": path,
+            "entity_type": self._model_class_to_resource_description(model_cls),
+            "operations" : [self._avu_operation_to_dict(op) for op in avu_ops]
+        }
+        self._call_atomic_metadata_api(request)
+
+    def _call_atomic_metadata_api(self, request_text):
+        with self.sess.pool.get_connection() as conn:
+            request_msg = iRODSMessage("RODS_API_REQ",  JSON_Message( request_text, conn.server_version ),
+                                       int_info=api_number['ATOMIC_APPLY_METADATA_OPERATIONS_APN'])
+            conn.send( request_msg )
+            response = conn.recv()
+        response_msg = response.get_json_encoded_struct()
+        logger.debug("in atomic_metadata, server responded with: %r",response_msg)
+
diff --git a/irods/manager/user_manager.py b/irods/manager/user_manager.py
index d312858..a8d2f8e 100644
--- a/irods/manager/user_manager.py
+++ b/irods/manager/user_manager.py
@@ -1,10 +1,11 @@
 from __future__ import absolute_import
 import logging
+import os
 
 from irods.models import User, UserGroup
 from irods.manager import Manager
-from irods.message import GeneralAdminRequest, iRODSMessage
-from irods.exception import UserDoesNotExist, UserGroupDoesNotExist, NoResultFound
+from irods.message import UserAdminRequest, GeneralAdminRequest, iRODSMessage, GetTempPasswordForOtherRequest, GetTempPasswordForOtherOut
+from irods.exception import UserDoesNotExist, UserGroupDoesNotExist, NoResultFound, CAT_SQL_ERR
 from irods.api_number import api_number
 from irods.user import iRODSUser, iRODSUserGroup
 import irods.password_obfuscation as obf
@@ -30,7 +31,8 @@ class UserManager(Manager):
         message_body = GeneralAdminRequest(
             "add",
             "user",
-            user_name,
+            user_name if not user_zone or user_zone == self.sess.zone \
+                      else "{}#{}".format(user_name,user_zone),
             user_type,
             user_zone,
             auth_str
@@ -57,6 +59,94 @@ class UserManager(Manager):
             response = conn.recv()
         logger.debug(response.int_info)
 
+    def temp_password_for_user(self, user_name):
+        with self.sess.pool.get_connection() as conn:
+            message_body = GetTempPasswordForOtherRequest(
+                targetUser=user_name,
+                unused=None
+            )
+            request = iRODSMessage("RODS_API_REQ", msg=message_body,
+                                   int_info=api_number['GET_TEMP_PASSWORD_FOR_OTHER_AN'])
+
+            # Send request
+            conn.send(request)
+
+            # Receive answer
+            try:
+                response = conn.recv()
+                logger.debug(response.int_info)
+            except CAT_SQL_ERR:
+                raise UserDoesNotExist()
+
+            # Convert and return answer
+            msg = response.get_main_message(GetTempPasswordForOtherOut)
+            return obf.create_temp_password(msg.stringToHashWith, conn.account.password)
+
+
+    class EnvStoredPasswordNotEdited(RuntimeError):
+
+        """
+        Error thrown by a password change attempt if a login password encoded in the
+        irods environment could not be updated.
+
+        This error will be seen when `modify_irods_authentication_file' is set True and the
+        authentication scheme in effect for the session is other than iRODS native,
+        using a password loaded from the client environment.
+        """
+
+        pass
+
+    @staticmethod
+    def abspath_exists(path):
+        return (isinstance(path,str) and
+                os.path.isabs(path) and
+                os.path.exists(path))
+
+    def modify_password(self, old_value, new_value, modify_irods_authentication_file = False):
+
+        """
+        Change the password for the current user (in the manner of `ipasswd').
+
+        Parameters:
+            old_value - the currently valid (old) password
+            new_value - the desired (new) password
+            modify_irods_authentication_file - Can be False, True, or a string.  If a string, it should indicate
+                                  the absolute path of an IRODS_AUTHENTICATION_FILE to be altered.
+        """
+        with self.sess.pool.get_connection() as conn:
+
+            hash_new_value = obf.obfuscate_new_password(new_value, old_value, conn.client_signature)
+
+            message_body = UserAdminRequest(
+                "userpw",
+                self.sess.username,
+                "password",
+                hash_new_value
+            )
+            request = iRODSMessage("RODS_API_REQ", msg=message_body,
+                                   int_info=api_number['USER_ADMIN_AN'])
+
+            conn.send(request)
+            response = conn.recv()
+            if modify_irods_authentication_file:
+                auth_file = self.sess.auth_file
+                if not auth_file or isinstance(modify_irods_authentication_file, str):
+                    auth_file = (modify_irods_authentication_file if self.abspath_exists(modify_irods_authentication_file) else '')
+                if not auth_file:
+                    message = "Session not loaded from an environment file."
+                    raise UserManager.EnvStoredPasswordNotEdited(message)
+                else:
+                    with open(auth_file) as f:
+                        stored_pw = obf.decode(f.read())
+                    if stored_pw != old_value:
+                        message = "Not changing contents of '{}' - "\
+                                  "stored password is non-native or false match to old password".format(auth_file)
+                        raise UserManager.EnvStoredPasswordNotEdited(message)
+                    with open(auth_file,'w') as f:
+                        f.write(obf.encode(new_value))
+
+        logger.debug(response.int_info)
+
     def modify(self, user_name, option, new_value, user_zone=""):
 
         # must append zone to username for this API call
diff --git a/irods/manager/zone_manager.py b/irods/manager/zone_manager.py
new file mode 100644
index 0000000..f6416c2
--- /dev/null
+++ b/irods/manager/zone_manager.py
@@ -0,0 +1,50 @@
+from __future__ import absolute_import
+import logging
+
+from irods.models import Zone
+from irods.zone import iRODSZone
+from irods.manager import Manager
+from irods.message import GeneralAdminRequest, iRODSMessage
+from irods.api_number import api_number
+from irods.exception import ZoneDoesNotExist, NoResultFound
+
+logger = logging.getLogger(__name__)
+
+class ZoneManager(Manager):
+
+    def get(self, zone_name):
+        query = self.sess.query(Zone).filter(Zone.name == zone_name)
+
+        try:
+            result = query.one()
+        except NoResultFound:
+            raise ZoneDoesNotExist()
+        return iRODSZone(self, result)
+
+    def create(self, zone_name, zone_type):
+        message_body = GeneralAdminRequest(
+            "add",
+            "zone",
+            zone_name,
+            zone_type,
+        )
+        request = iRODSMessage("RODS_API_REQ", msg=message_body,
+                               int_info=api_number['GENERAL_ADMIN_AN'])
+        with self.sess.pool.get_connection() as conn:
+            conn.send(request)
+            response = conn.recv()
+        logger.debug(response.int_info)
+        return self.get(zone_name)
+
+    def remove(self, zone_name):
+        message_body = GeneralAdminRequest(
+            "rm",
+            "zone",
+            zone_name
+        )
+        request = iRODSMessage("RODS_API_REQ", msg=message_body,
+                               int_info=api_number['GENERAL_ADMIN_AN'])
+        with self.sess.pool.get_connection() as conn:
+            conn.send(request)
+            response = conn.recv()
+        logger.debug(response.int_info)
diff --git a/irods/message/__init__.py b/irods/message/__init__.py
index 6e187b9..44ffcc4 100644
--- a/irods/message/__init__.py
+++ b/irods/message/__init__.py
@@ -1,12 +1,144 @@
+"""Define objects related to communication with iRODS server API endpoints."""
+
+import sys
 import struct
 import logging
 import socket
-import xml.etree.ElementTree as ET
+import json
+from six.moves import builtins
+import irods.exception as ex
+import xml.etree.ElementTree as ET_xml
+import defusedxml.ElementTree as ET_secure_xml
+from . import quasixml as ET_quasi_xml
+from collections import namedtuple
+import os
+import fcntl
+import ast
+import threading
 from irods.message.message import Message
 from irods.message.property import (BinaryProperty, StringProperty,
                                     IntegerProperty, LongProperty, ArrayProperty,
                                     SubmessageProperty)
 
+_TUPLE_LIKE_TYPES = (tuple, list)
+
+def _qxml_server_version( var ):
+    val = os.environ.get( var, '()' )
+    vsn = (val and ast.literal_eval( val ))
+    if not isinstance( vsn, _TUPLE_LIKE_TYPES ): return None
+    return tuple( vsn )
+
+if sys.version_info >= (3,):
+    import enum
+    class XML_Parser_Type(enum.Enum):
+        _invalid = 0
+        STANDARD_XML = 1
+        QUASI_XML = 2
+        SECURE_XML = 3
+else:
+    class MyIntEnum(int):
+        """An integer enum class suited to the purpose. A shim until we get rid of Python2."""
+        def __init__(self,i):
+            """Initialize based on an integer or another instance."""
+            super(MyIntEnum,self).__init__()
+            try:self.i = i._value()
+            except AttributeError:
+                self.i = i
+        def _value(self): return self.i
+        @builtins.property
+        def value(self): return self._value()
+
+    class XML_Parser_Type(MyIntEnum):
+        """An enum specifying which XML parser is active."""
+        pass
+    XML_Parser_Type.STANDARD_XML = XML_Parser_Type (1)
+    XML_Parser_Type.QUASI_XML = XML_Parser_Type (2)
+    XML_Parser_Type.SECURE_XML = XML_Parser_Type (3)
+
+# We maintain values on a per-thread basis of:
+#   - the server version with which we're communicating
+#   - which of the choices of parser (STANDARD_XML or QUASI_XML) we're using
+
+_thrlocal = threading.local()
+
+# The packStruct message parser defaults to STANDARD_XML but we can override it by setting the
+# environment variable PYTHON_IRODSCLIENT_DEFAULT_XML to either 'SECURE_XML' or 'QUASI_XML'.
+# If QUASI_XML is selected, the environment variable PYTHON_IRODSCLIENT_QUASI_XML_SERVER_VERSION
+# may also be set to a tuple "X,Y,Z" to inform the client of the connected iRODS server version.
+# If we set a value for the version, it can be either:
+#    * 4,2,8 to work with that server version and older ones which incorrectly encoded back-ticks as '&apos;'
+#    * an empty tuple "()" or something >= 4,2,9 to work with newer servers to allow a flexible character
+#      set within iRODS protocol.
+
+class BadXMLSpec(RuntimeError): pass
+
+_Quasi_Xml_Server_Version = _qxml_server_version('PYTHON_IRODSCLIENT_QUASI_XML_SERVER_VERSION')
+if _Quasi_Xml_Server_Version is None:  # unspecified in environment yields empty tuple ()
+    raise BadXMLSpec('Must properly specify a server version to use QUASI_XML')
+
+_XML_strings = { k:v for k,v in vars(XML_Parser_Type).items() if k.endswith('_XML')}
+
+
+_default_XML = os.environ.get('PYTHON_IRODSCLIENT_DEFAULT_XML','')
+if not _default_XML:
+    _default_XML = XML_Parser_Type.STANDARD_XML
+else:
+    try:
+        _default_XML = _XML_strings[_default_XML]
+    except KeyError:
+        raise BadXMLSpec('XML parser type not recognized')
+
+
+def current_XML_parser(get_module = False):
+    d = getattr(_thrlocal,'xml_type',_default_XML)
+    return d if not get_module else _XML_parsers[d]
+
+def default_XML_parser(get_module = False):
+    d = _default_XML
+    return d if not get_module else _XML_parsers[d]
+
+_XML_parsers = {
+    XML_Parser_Type.STANDARD_XML : ET_xml,
+    XML_Parser_Type.QUASI_XML : ET_quasi_xml,
+    XML_Parser_Type.SECURE_XML : ET_secure_xml
+}
+
+
+def XML_entities_active():
+    Server = getattr(_thrlocal,'irods_server_version',_Quasi_Xml_Server_Version)
+    return [ ('&', '&amp;'), # note: order matters. & must be encoded first.
+             ('<', '&lt;'),
+             ('>', '&gt;'),
+             ('"', '&quot;'),
+             ("'" if not(Server) or Server >= (4,2,9) else '`',
+               '&apos;') # https://github.com/irods/irods/issues/4132
+            ]
+
+
+# ET() [no-args form] will just fetch the current thread's XML parser settings
+
+def ET(xml_type = 0, server_version = None):
+    """
+    Return the module used to parse XML from iRODS protocol messages text.
+
+    May also be used to specify the following attributes of the currently running thread:
+
+    `xml_type', if given, should be 1 for STANDARD_XML or 2 for QUASI_XML.
+      * QUASI_XML is custom parser designed to be more compatible with the use of
+        non-printable characters in object names.
+      * STANDARD_XML uses the standard module, xml.etree.ElementTree.
+
+    `server_version', if given, should be a list or tuple specifying the version of the connected iRODS server.
+
+    """
+    if xml_type is not 0:
+        _thrlocal.xml_type = default_XML_parser() if xml_type in (None, XML_Parser_Type(0)) \
+                        else XML_Parser_Type(xml_type)
+    if isinstance(server_version, _TUPLE_LIKE_TYPES):
+        _thrlocal.irods_server_version = tuple(server_version)  #  A default server version for Quasi-XML parsing is set (from the environment) and
+    return _XML_parsers[current_XML_parser()]                   #  applies to all threads in which ET() has not been called to update the value.
+
+
 logger = logging.getLogger(__name__)
 
 IRODS_VERSION = (4, 3, 0, 'd')
@@ -19,9 +151,24 @@ except NameError:
     UNICODE = str
 
 
+
+# Necessary for older python (<3.7):
+_socket_is_blocking = (lambda self: 0 == fcntl.fcntl(self.fileno(), fcntl.F_GETFL) & os.O_NONBLOCK)
+
 def _recv_message_in_len(sock, size):
     size_left = size
     retbuf = None
+
+    # Get socket properties for debug and exception messages.
+    host, port = sock.getpeername()
+    is_blocking = _socket_is_blocking(sock)
+    timeout = sock.gettimeout()
+
+    logger.debug('host: %s',host)
+    logger.debug('port: %d',port)
+    logger.debug('is_blocking: %s',is_blocking)
+    logger.debug('timeout: %s',timeout)
+
     while size_left > 0:
         try:
             buf = sock.recv(size_left, socket.MSG_WAITALL)
@@ -32,11 +179,27 @@ def _recv_message_in_len(sock, size):
             if getattr(e, 'winerror', 0) != 10045:
                 raise
             buf = sock.recv(size_left)
+
+        # This prevents an infinite loop. If the call to recv()
+        # returns an empty buffer, break out of the loop.
+        if len(buf) == 0:
+            break
         size_left -= len(buf)
         if retbuf is None:
             retbuf = buf
         else:
             retbuf += buf
+
+    # This method is supposed to read and return 'size'
+    # bytes from the socket. If it reads no bytes (retbuf
+    # will be None), or if it reads less number of bytes
+    # than 'size', throw a socket.error exception
+    if retbuf is None or len(retbuf) != size:
+        retbuf_size = len(retbuf) if retbuf is not None else 0
+        msg = 'Read {} bytes from the socket (host {}, port {}) instead of expected {} bytes'.format(
+               retbuf_size, host, port, size)
+        raise socket.error(msg)
+
     return retbuf
 
 
@@ -58,9 +221,28 @@ def _recv_message_into(sock, buffer, size):
         index += rsize
     return mv[:index]
 
+#------------------------------------
+
+class BinBytesBuf(Message):
+    _name = 'BinBytesBuf_PI'
+    buflen = IntegerProperty()
+    buf = BinaryProperty()
+
+class JSON_Binary_Response(BinBytesBuf):
+    pass
 
 class iRODSMessage(object):
 
+    class ResponseNotParseable(Exception):
+
+        """
+        Raised by get_main_message(ResponseClass) to indicate a server response
+        wraps a msg string that is the `None' object rather than an XML String.
+        (Not raised for the ResponseClass is irods.message.Error; see source of
+        get_main_message for further detail.)
+        """
+        pass
+
     def __init__(self, msg_type=b'', msg=None, error=b'', bs=b'', int_info=0):
         self.msg_type = msg_type
         self.msg = msg
@@ -68,6 +250,15 @@ class iRODSMessage(object):
         self.bs = bs
         self.int_info = int_info
 
+    def get_json_encoded_struct (self):
+        Xml = ET().fromstring(self.msg.replace(b'\0',b''))
+        json_str = Xml.find('buf').text
+        if Xml.tag == 'BinBytesBuf_PI':
+            mybin = JSON_Binary_Response()
+            mybin.unpack(Xml)
+            json_str = mybin.buf.replace(b'\0',b'').decode()
+        return json.loads( json_str )
+
     @staticmethod
     def recv(sock):
         # rsp_header_size = sock.recv(4, socket.MSG_WAITALL)
@@ -76,7 +267,7 @@ class iRODSMessage(object):
         # rsp_header = sock.recv(rsp_header_size, socket.MSG_WAITALL)
         rsp_header = _recv_message_in_len(sock, rsp_header_size)
 
-        xml_root = ET.fromstring(rsp_header)
+        xml_root = ET().fromstring(rsp_header)
         msg_type = xml_root.find('type').text
         msg_len = int(xml_root.find('msgLen').text)
         err_len = int(xml_root.find('errorLen').text)
@@ -103,7 +294,7 @@ class iRODSMessage(object):
         rsp_header_size = struct.unpack(">i", rsp_header_size)[0]
         rsp_header = _recv_message_in_len(sock, rsp_header_size)
 
-        xml_root = ET.fromstring(rsp_header)
+        xml_root = ET().fromstring(rsp_header)
         msg_type = xml_root.find('type').text
         msg_len = int(xml_root.find('msgLen').text)
         err_len = int(xml_root.find('errorLen').text)
@@ -165,10 +356,19 @@ class iRODSMessage(object):
         return packed_header + main_msg + self.error + self.bs
 
 
-    def get_main_message(self, cls):
+    def get_main_message(self, cls, r_error = None):
         msg = cls()
-        logger.debug(self.msg)
-        msg.unpack(ET.fromstring(self.msg))
+        logger.debug('Attempt to parse server response [%r] as class [%r].',self.msg,cls)
+        if self.error and isinstance(r_error, RErrorStack):
+            r_error.fill( iRODSMessage(msg=self.error).get_main_message(Error) )
+        if self.msg is None:
+            if cls is not Error:
+                # - For dedicated API response classes being built from server response, allow catching
+                #   of the exception.  However, let iRODS errors such as CAT_NO_ROWS_FOUND to filter
+                #   through as usual for express reporting by instances of irods.connection.Connection .
+                message = "Server response was {self.msg} while parsing as [{cls}]".format(**locals())
+                raise self.ResponseNotParseable( message )
+        msg.unpack(ET().fromstring(self.msg))
         return msg
 
 
@@ -188,7 +388,7 @@ class ClientServerNegotiation(Message):
 class StartupPack(Message):
     _name = 'StartupPack_PI'
 
-    def __init__(self, proxy_user, client_user):
+    def __init__(self, proxy_user, client_user, application_name = ''):
         super(StartupPack, self).__init__()
         if proxy_user and client_user:
             self.irodsProt = 1
@@ -197,7 +397,7 @@ class StartupPack(Message):
             self.clientUser, self.clientRcatZone = client_user
             self.relVersion = "rods{}.{}.{}".format(*IRODS_VERSION)
             self.apiVersion = "{3}".format(*IRODS_VERSION)
-            self.option = ""
+            self.option = application_name
 
     irodsProt = IntegerProperty()
     reconnFlag = IntegerProperty()
@@ -223,21 +423,91 @@ class AuthChallenge(Message):
     _name = 'authRequestOut_PI'
     challenge = BinaryProperty(64)
 
+
+class AuthPluginOut(Message):
+    _name = 'authPlugReqOut_PI'
+    result_ = StringProperty()
+    # result_ = BinaryProperty(16)
+
+
+# The following PamAuthRequest* classes correspond to older, less generic
+# PAM auth api in iRODS, but one which allowed longer password tokens.
+# They are contributed by Rick van de Hoef at Utrecht Univ, c. June 2021:
+
+class PamAuthRequest(Message):
+    _name = 'pamAuthRequestInp_PI'
+    pamUser = StringProperty()
+    pamPassword = StringProperty()
+    timeToLive = IntegerProperty()
+
+class PamAuthRequestOut(Message):
+    _name = 'pamAuthRequestOut_PI'
+    irodsPamPassword = StringProperty()
+    @builtins.property
+    def result_(self): return self.irodsPamPassword
+
+
+
 # define InxIvalPair_PI "int iiLen; int *inx(iiLen); int *ivalue(iiLen);"
 
+class JSON_Binary_Request(BinBytesBuf):
 
-class BinBytesBuf(Message):
-    _name = 'BinBytesBuf_PI'
+    """A message body whose payload is BinBytesBuf containing JSON."""
+
+    def __init__(self,msg_struct):
+        """Initialize with a Python data structure that will be converted to JSON."""
+        super(JSON_Binary_Request,self).__init__()
+        string = json.dumps(msg_struct)
+        self.buf = string
+        self.buflen = len(string)
+
+class BytesBuf(Message):
+
+    """A generic structure carrying text content"""
+
+    _name = 'BytesBuf_PI'
     buflen = IntegerProperty()
-    buf = BinaryProperty()
+    buf = StringProperty()
+    def __init__(self,string,*v,**kw):
+        super(BytesBuf,self).__init__(*v,**kw)
+        self.buf = string
+        self.buflen = len(self.buf)
+
+class JSON_XMLFramed_Request(BytesBuf):
 
+    """A message body whose payload is a BytesBuf containing JSON."""
+    def __init__(self, msg_struct):
+        """Initialize with a Python data structure that will be converted to JSON."""
+        s = json.dumps(msg_struct)
+        super(JSON_XMLFramed_Request,self).__init__(s)
 
-class GSIAuthMessage(Message):
+def JSON_Message( msg_struct , server_version = () ):
+    cls = JSON_XMLFramed_Request if server_version < (4,2,9) \
+          else JSON_Binary_Request
+    return cls(msg_struct)
+
+
+class PluginAuthMessage(Message):
     _name = 'authPlugReqInp_PI'
     auth_scheme_ = StringProperty()
     context_ = StringProperty()
 
 
+class _OrderedMultiMapping :
+    def keys(self):
+        return self._keys
+    def values(self):
+        return self._values
+    def __len__(self):
+        return len(self._keys)
+    def __init__(self, list_of_keyval_tuples ):
+        self._keys = []
+        self._values = []
+        for k,v in list_of_keyval_tuples:
+            self._keys.append(k)
+            self._values.append(v)
+
+
 class IntegerIntegerMap(Message):
     _name = 'InxIvalPair_PI'
 
@@ -341,6 +611,22 @@ class FileOpenRequest(Message):
     oprType = IntegerProperty()
     KeyValPair_PI = SubmessageProperty(StringStringMap)
 
+class DataObjChksumRequest(FileOpenRequest):
+    """Report and/or generate a data object's checksum."""
+
+    def __init__(self,path,**chksumOptions):
+        """Construct the request using the path of a data object."""
+        super(DataObjChksumRequest,self).__init__()
+        for attr,prop in vars(FileOpenRequest).items():
+            if isinstance(prop, (IntegerProperty,LongProperty)):
+                setattr(self, attr, 0)
+        self.objPath = path
+        self.KeyValPair_PI = StringStringMap(chksumOptions)
+
+class DataObjChksumResponse(Message):
+    name = 'Str_PI'
+    myStr = StringProperty()
+
 # define OpenedDataObjInp_PI "int l1descInx; int len; int whence; int
 # oprType; double offset; double bytesWritten; struct KeyValPair_PI;"
 
@@ -431,14 +717,14 @@ class VersionResponse(Message):
     cookie = IntegerProperty()
 
 
-# define generalAdminInp_PI "str *arg0; str *arg1; str *arg2; str *arg3;
-# str *arg4; str *arg5; str *arg6; str *arg7;  str *arg8;  str *arg9;"
+class _admin_request_base(Message):
 
-class GeneralAdminRequest(Message):
-    _name = 'generalAdminInp_PI'
+    _name = None
 
     def __init__(self, *args):
-        super(GeneralAdminRequest, self).__init__()
+        if self.__class__._name is None:
+            raise NotImplementedError
+        super(_admin_request_base, self).__init__()
         for i in range(10):
             if i < len(args) and args[i]:
                 setattr(self, 'arg{0}'.format(i), args[i])
@@ -457,25 +743,72 @@ class GeneralAdminRequest(Message):
     arg9 = StringProperty()
 
 
+# define generalAdminInp_PI "str *arg0; str *arg1; str *arg2; str *arg3;
+# str *arg4; str *arg5; str *arg6; str *arg7;  str *arg8;  str *arg9;"
+
+class GeneralAdminRequest(_admin_request_base):
+    _name = 'generalAdminInp_PI'
+
+
+# define userAdminInp_PI "str *arg0; str *arg1; str *arg2; str *arg3;
+# str *arg4; str *arg5; str *arg6; str *arg7;  str *arg8;  str *arg9;"
+
+class UserAdminRequest(_admin_request_base):
+    _name = 'userAdminInp_PI'
+
+
+class GetTempPasswordForOtherRequest(Message):
+    _name = 'getTempPasswordForOtherInp_PI'
+    targetUser = StringProperty()
+    unused = StringProperty()
+
+
+class GetTempPasswordForOtherOut(Message):
+    _name = 'getTempPasswordForOtherOut_PI'
+    stringToHashWith = StringProperty()
+
+
+class GetTempPasswordOut(Message):
+    _name = 'getTempPasswordOut_PI'
+    stringToHashWith = StringProperty()
+
+
+#in iRODS <= 4.2.10:
 #define ticketAdminInp_PI "str *arg1; str *arg2; str *arg3; str *arg4; str *arg5; str *arg6;"
 
-class TicketAdminRequest(Message):
-    _name = 'ticketAdminInp_PI'
+#in iRODS <= 4.2.11:
+#define ticketAdminInp_PI "str *arg1; str *arg2; str *arg3; str *arg4; str *arg5; str *arg6; struct KeyValPair_PI;"
 
-    def __init__(self, *args):
-        super(TicketAdminRequest, self).__init__()
-        for i in range(6):
-            if i < len(args) and args[i]:
-                setattr(self, 'arg{0}'.format(i+1), str(args[i]))
-            else:
-                setattr(self, 'arg{0}'.format(i+1), "")
+def TicketAdminRequest(session):
 
-    arg1 = StringProperty()
-    arg2 = StringProperty()
-    arg3 = StringProperty()
-    arg4 = StringProperty()
-    arg5 = StringProperty()
-    arg6 = StringProperty()
+    # class is different depending on server version
+
+    SERVER_REQUIRES_KEYVAL_PAIRS = (session.server_version >= (4,2,11))
+
+    class TicketAdminRequest_(Message):
+        _name = 'ticketAdminInp_PI'
+
+        def __init__(self, *args,**ticketOpts):
+            super(TicketAdminRequest_, self).__init__()
+            for i in range(6):
+                if i < len(args) and args[i]:
+                    setattr(self, 'arg{0}'.format(i+1), str(args[i]))
+                else:
+                    setattr(self, 'arg{0}'.format(i+1), "")
+            if SERVER_REQUIRES_KEYVAL_PAIRS:
+                self.KeyValPair_PI = StringStringMap(ticketOpts)
+
+        arg1 = StringProperty()
+        arg2 = StringProperty()
+        arg3 = StringProperty()
+        arg4 = StringProperty()
+        arg5 = StringProperty()
+        arg6 = StringProperty()
+
+        if SERVER_REQUIRES_KEYVAL_PAIRS:
+            KeyValPair_PI = SubmessageProperty(StringStringMap)
+
+    return TicketAdminRequest_
 
 
 #define specificQueryInp_PI "str *sql; str *arg1; str *arg2; str *arg3; str *arg4; str *arg5; str *arg6; str *arg7; str *arg8; str *arg9; str *arg10; int maxRows; int continueInx; int rowOffset; int options; struct KeyValPair_PI;"
@@ -654,6 +987,85 @@ class ModDataObjMeta(Message):
     dataObjInfo = SubmessageProperty(DataObjInfo)
     regParam = SubmessageProperty(StringStringMap)
 
+
+#  -- A tuple-descended class which facilitates filling in a
+#     quasi-RError stack from a JSON formatted list.
+
+_Server_Status_Message = namedtuple('server_status_msg',('msg','status'))
+
+
+class RErrorStack(list):
+
+    """A list of returned RErrors."""
+
+    def __init__(self,Err = None):
+        """Initialize from the `errors' member of an API return message."""
+        super(RErrorStack,self).__init__() # 'list' class initialization
+        self.fill(Err)
+
+    def fill(self,Err = None):
+
+        # first, we try to parse from a JSON list, as this is how message and status return the Data.chksum call.
+        if isinstance (Err, (tuple,list)):
+            self[:] = [ RError( _Server_Status_Message( msg = elem["message"],
+                                                        status = elem["error_code"] )
+                               ) for elem in Err
+                       ]
+            return
+
+        # next, we try to parse from a a response message - eg. as returned by the Rule.execute API call when a rule fails.
+        if Err is not None:
+            self[:] = [ RError(Err.RErrMsg_PI[i]) for i in range(Err.count) ]
+
+
+class RError(object):
+
+    """One of a list of RError messages potentially returned to the client
+       from an iRODS API call.  """
+
+    Encoding = 'utf-8'
+
+    def __init__(self,entry):
+        """Initialize from one member of the RErrMsg_PI array."""
+        super(RError,self).__init__()
+        self.raw_msg_ = entry.msg
+        self.status_ = entry.status
+
+
+    @builtins.property
+    def message(self): #return self.raw_msg_.decode(self.Encoding)
+        msg_ = self.raw_msg_
+        if type(msg_) is UNICODE:
+            return msg_
+        elif type(msg_) is bytes:
+            return msg_.decode(self.Encoding)
+        else:
+            raise RuntimeError('bad msg type in',msg_)
+
+    @builtins.property
+    def status(self): return int(self.status_)
+
+
+    @builtins.property
+    def status_str(self):
+        """Retrieve the IRODS error identifier."""
+        return ex.get_exception_class_by_code( self.status, name_only=True )
+
+
+    def __str__(self):
+        """Retrieve the error message text."""
+        return self.message
+
+    def __int__(self):
+        """Retrieve integer error code."""
+        return self.status
+
+    def __repr__(self):
+        """Show both the message and iRODS error type (both integer and human-readable)."""
+        return "{self.__class__.__name__}"\
+               "<message = {self.message!r}, status = {self.status} {self.status_str}>".format(**locals())
+
+
 #define RErrMsg_PI "int status; str msg[ERR_MSG_LEN];"
 
 class ErrorMessage(Message):
diff --git a/irods/message/quasixml.py b/irods/message/quasixml.py
new file mode 100644
index 0000000..eeba949
--- /dev/null
+++ b/irods/message/quasixml.py
@@ -0,0 +1,175 @@
+# A parser for the iRODS XML-like protocol.
+# The interface aims to be compatible with xml.etree.ElementTree,
+# at least for the features used by python-irodsclient.
+
+class Element():
+    """
+    Represents <name>body</name>.
+
+    (Where `body' is either a string or a list of sub-elements.)
+    """
+
+    @property
+    def tag(self): return self.name
+
+    def __init__(self, name, body):
+        """Initialize with the tag's name and the body (i.e. content)."""
+        if body == []:
+            # Empty element.
+            self.text = None
+        elif type(body) is not list:
+            # String element: decode body.
+            body = decode_entities(body)
+            self.text = body
+
+        self.name = name
+        self.body = body
+
+    def find(self, name):
+        """Get first matching child element by name."""
+        for x in self.findall(name):
+            return x
+
+    def findall(self, name):
+        """Get matching child elements by name."""
+        return list(self.findall_(name))
+
+    def findall_(self, name):
+        """Get matching child elements by name (generator variant)."""
+        return (el for el in self.body if el.name == name)
+
+    # For debugging convenience:
+    def __str__(self):
+        if type(self.body) is list:
+            return '<{}>{}</{}>'.format(self.name, ''.join(map(str, self.body)), self.name)
+        else:
+            return '<{}>{}</{}>'.format(self.name, encode_entities(self.body), self.name)
+
+    def __repr__(self):
+        return '{}({})'.format(self.name, repr(self.body))
+
+
+class Token(object):
+    """A utility class for parsing XML."""
+    def __init__(self, s):
+        """Create a `Token' object from `s', the text comprising the parsed token."""
+        self.text = s
+    def __repr__(self):
+        return str(type(self).__name__) + '(' + self.text.decode('utf-8') + ')'
+    def __str__(self):
+        return repr(self)
+
+class TokenTagOpen(Token):
+    """An opening tag (<foo>)"""
+class TokenTagClose(Token):
+    """An closing tag (</foo>)"""
+class TokenCData(Token):
+    """Textual element body"""
+
+class QuasiXmlParseError(Exception):
+    """Indicates parse failure of XML protocol data."""
+
+def tokenize(s):
+    """Parse an XML-ish string into a list of tokens."""
+    tokens = []
+
+    # Consume input until empty.
+    while True:
+        nextclose = s.find(b'</')
+        nextopen  = s.find(b'<')
+        if nextopen < nextclose or nextopen == -1:
+            # Either we have no tags left, or we are in a non-cdata element body: strip whitespace.
+            s = s.lstrip()
+
+        if len(s) == 0:
+            return tokens
+
+            # Closing tag?
+        elif s.startswith(b'</'):
+            try:
+                name, s = s[2:].split(b'>', 1)
+            except Exception:
+                raise QuasiXmlParseError('protocol error: unterminated close tag')
+            tokens.append(TokenTagClose(name))
+            s = s.lstrip() # consume space after closing tag
+
+            # Opening tag?
+        elif s.startswith(b'<'):
+            try:
+                name, s = s[1:].split(b'>', 1)
+            except Exception:
+                raise QuasiXmlParseError('protocol error: unterminated open tag')
+            tokens.append(TokenTagOpen(name))
+
+        else:
+            # capture cdata till next tag.
+            try:
+                cdata, s = s.split(b'<', 1)
+            except Exception:
+                raise QuasiXmlParseError('protocol error: unterminated cdata')
+            s = b'<' + s
+            tokens.append(TokenCData(cdata))
+
+def fromtokens(tokens):
+    """Parse XML-ish tokens into an Element."""
+
+    def parse_elem(tokens):
+        """Parse some tokens into one Element, and return unconsumed tokens."""
+        topen, tokens = tokens[0], tokens[1:]
+        if type(topen) is not TokenTagOpen:
+            raise QuasiXmlParseError('protocol error: data does not start with open tag')
+
+        children = []
+        cdata    = None
+
+        while len(tokens) > 0:
+            t, tokens = tokens[0], tokens[1:]
+            if type(t) is TokenTagOpen:
+                # Slurp a sub-element.
+                el, tokens = parse_elem([t] + tokens)
+                children.append(el)
+                # Continue with non-consumed tokens.
+            elif type(t) == TokenTagClose:
+                if t.text != topen.text:
+                    raise QuasiXmlParseError('protocol error: close tag <{}> does not match opening tag <{}>'.format(t.text, topen.text))
+                elif cdata is not None and len(children):
+                    raise QuasiXmlParseError('protocol error: mixed cdata and child elements')
+                return Element(topen.text.decode('utf-8'), cdata.decode('utf-8') if cdata is not None else children), tokens
+            else:
+                cdata = t.text
+
+    elem, rest = parse_elem(tokens)
+    if rest != []:
+        raise QuasiXmlParseError('protocol error: trailing data')
+
+    return elem
+
+
+try:
+    unicode         # Python 2
+except NameError:
+    unicode = str
+
+
+def fromstring(s):
+    if type(s) is unicode:
+        s = s.encode('utf-8')
+    if type(s) is not bytes:
+        raise TypeError('expected a bytes-object, got {}'.format(type(s).__name__))
+
+    return fromtokens(tokenize(s))
+
+
+def encode_entities(s):
+    from . import XML_entities_active
+    for k, v in XML_entities_active():
+        s = s.replace(k, v)
+    return s
+
+def decode_entities(s):
+    from . import XML_entities_active
+    rev = list(XML_entities_active())
+    rev.reverse() # (make sure &amp; is decoded last)
+    for k, v in rev:
+        s = s.replace(v, k)
+    return s
diff --git a/irods/meta.py b/irods/meta.py
index 4137ac0..ad16eb1 100644
--- a/irods/meta.py
+++ b/irods/meta.py
@@ -1,3 +1,5 @@
+
+
 class iRODSMeta(object):
 
     def __init__(self, name, value, units=None, avu_id=None):
@@ -10,6 +12,56 @@ class iRODSMeta(object):
         return "<iRODSMeta {avu_id} {name} {value} {units}>".format(**vars(self))
 
 
+class BadAVUOperationKeyword(Exception): pass
+
+class BadAVUOperationValue(Exception): pass
+
+
+class AVUOperation(dict):
+
+    @property
+    def operation(self):
+        return self['operation']
+
+    @operation.setter
+    def operation(self,Oper):
+        self._check_operation(Oper)
+        self['operation'] = Oper
+
+    @property
+    def avu(self):
+        return self['avu']
+
+    @avu.setter
+    def avu(self,newAVU):
+        self._check_avu(newAVU)
+        self['avu'] = newAVU
+
+    def _check_avu(self,avu_param):
+        if not isinstance(avu_param, iRODSMeta):
+            error_msg = "Nonconforming avu {!r} of type {}; must be an iRODSMeta." \
+                        "".format(avu_param,type(avu_param).__name__)
+            raise BadAVUOperationValue(error_msg)
+
+    def _check_operation(self,operation):
+        if operation not in ('add','remove'):
+            error_msg = "Nonconforming operation {!r}; must be 'add' or 'remove'.".format(operation)
+            raise BadAVUOperationValue(error_msg)
+
+    def __init__(self, operation, avu, **kw):
+        """Constructor:
+           AVUOperation( operation = opstr,  # where opstr is "add" or "remove"
+                         avu = metadata )    # where metadata is an irods.meta.iRODSMeta instance
+        """
+        super(AVUOperation,self).__init__()
+        self._check_operation (operation)
+        self._check_avu (avu)
+        if kw:
+            raise BadAVUOperationKeyword('''Nonconforming keyword (s) {}.'''.format(list(kw.keys())))
+        for atr in ('operation','avu'):
+            setattr(self,atr,locals()[atr])
+
+
 class iRODSMetaCollection(object):
 
     def __init__(self, manager, model_cls, path):
@@ -47,6 +99,10 @@ class iRODSMetaCollection(object):
                 "Must specify an iRODSMeta object or key, value, units)")
         return args[0] if len(args) == 1 else iRODSMeta(*args)
 
+    def apply_atomic_operations(self, *avu_ops):
+        self._manager.apply_atomic_operations(self._model_cls, self._path, *avu_ops)
+        self._reset_metadata()
+
     def add(self, *args):
         """
         Add as iRODSMeta to a key
diff --git a/irods/models.py b/irods/models.py
index 08c53e7..542791e 100644
--- a/irods/models.py
+++ b/irods/models.py
@@ -19,9 +19,30 @@ class Model(six.with_metaclass(ModelBase, object)):
     pass
 
 
+class RuleExec(Model):
+    id = Column(Integer, 'RULE_EXEC_ID', 1000)
+    name = Column(String, 'RULE_EXEC_NAME', 1001)
+    rei_file_path = Column(String,'RULE_EXEC_REI_FILE_PATH', 1002)
+    user_name = Column(String, 'RULE_EXEC_USER_NAME', 1003)
+    time = Column(DateTime,'RULE_EXEC_TIME',    1005)
+    last_exe_time = Column(DateTime,'RULE_EXEC_LAST_EXE_TIME', 1010)
+    frequency = Column(String,'RULE_EXEC_FREQUENCY', 1006)
+    priority = Column(String, 'RULE_EXEC_PRIORITY', 1007)
+
+#   # If needed in 4.2.9, we can update the Query class to dynamically
+#   #  attach this field based on server version:
+#   context = Column(String, 'RULE_EXEC_CONTEXT', 1012)
+
+#   # These are either unused or usually absent:
+#   exec_status = Column(String,'RULE_EXEC_STATUS', 1011)
+#   address = Column(String,'RULE_EXEC_ADDRESS', 1004)
+#   notification_addr = Column('RULE_EXEC_NOTIFICATION_ADDR', 1009)
+
+
 class Zone(Model):
     id = Column(Integer, 'ZONE_ID', 101)
     name = Column(String, 'ZONE_NAME', 102)
+    type = Column(String, 'ZONE_TYPE', 103)
 
 
 class User(Model):
@@ -112,6 +133,8 @@ class DataObjectMeta(Model):
     name = Column(String, 'COL_META_DATA_ATTR_NAME', 600)
     value = Column(String, 'COL_META_DATA_ATTR_VALUE', 601)
     units = Column(String, 'COL_META_DATA_ATTR_UNITS', 602)
+    create_time = Column(DateTime, 'COL_META_DATA_CREATE_TIME', 604)
+    modify_time = Column(DateTime, 'COL_META_DATA_MODIFY_TIME', 605)
 
 
 class CollectionMeta(Model):
@@ -119,6 +142,9 @@ class CollectionMeta(Model):
     name = Column(String, 'COL_META_COLL_ATTR_NAME', 610)
     value = Column(String, 'COL_META_COLL_ATTR_VALUE', 611)
     units = Column(String, 'COL_META_COLL_ATTR_UNITS', 612)
+    create_time = Column(DateTime, 'COL_META_COLL_CREATE_TIME', 614)
+    modify_time = Column(DateTime, 'COL_META_COLL_MODIFY_TIME', 615)
+
 
 
 class ResourceMeta(Model):
@@ -126,6 +152,9 @@ class ResourceMeta(Model):
     name = Column(String, 'COL_META_RESC_ATTR_NAME', 630)
     value = Column(String, 'COL_META_RESC_ATTR_VALUE', 631)
     units = Column(String, 'COL_META_RESC_ATTR_UNITS', 632)
+    create_time = Column(DateTime, 'COL_META_RESC_CREATE_TIME', 634)
+    modify_time = Column(DateTime, 'COL_META_RESC_MODIFY_TIME', 635)
+
 
 
 class UserMeta(Model):
@@ -133,6 +162,9 @@ class UserMeta(Model):
     name = Column(String, 'COL_META_USER_ATTR_NAME', 640)
     value = Column(String, 'COL_META_USER_ATTR_VALUE', 641)
     units = Column(String, 'COL_META_USER_ATTR_UNITS', 642)
+    create_time = Column(DateTime, 'COL_META_USER_CREATE_TIME', 644)
+    modify_time = Column(DateTime, 'COL_META_USER_MODIFY_TIME', 645)
+
 
 
 class DataAccess(Model):
@@ -162,3 +194,77 @@ class SpecificQueryResult(Model):
 class Keywords(Model):
     data_type = Keyword(String, 'dataType')
     chksum = Keyword(String, 'chksum')
+
+
+class TicketQuery:
+    """Various model classes for querying attributes of iRODS tickets.
+
+    Namespacing these model classes under the TicketQuery parent class allows
+    a simple import (not conflicting with irods.ticket.Ticket) and a usage
+    that reflects ICAT table structure:
+
+        from irods.models import TicketQuery
+        # ...
+        for row in session.query( TicketQuery.Ticket )\
+                          .filter( TicketQuery.Owner.name == 'alice' ):
+            print( row [TicketQuery.Ticket.string] )
+
+    (For more examples, see irods/test/ticket_test.py)
+
+    """
+    class Ticket(Model):
+        """For queries of R_TICKET_MAIN."""
+        id = Column(Integer, 'TICKET_ID', 2200)
+        string = Column(String, 'TICKET_STRING', 2201)
+        type = Column(String, 'TICKET_TYPE', 2202)
+        user_id = Column(Integer, 'TICKET_USER_ID', 2203)
+        object_id = Column(Integer, 'TICKET_OBJECT_ID', 2204)
+        object_type = Column(String, 'TICKET_OBJECT_TYPE', 2205)
+        uses_limit = Column(Integer, 'TICKET_USES_LIMIT', 2206)
+        uses_count = Column(Integer, 'TICKET_USES_COUNT', 2207)
+        expiry_ts = Column(String, 'TICKET_EXPIRY_TS', 2208)
+        write_file_count = Column(Integer, 'TICKET_WRITE_FILE_COUNT', 2211)
+        write_file_limit = Column(Integer, 'TICKET_WRITE_FILE_LIMIT', 2212)
+        write_byte_count = Column(Integer, 'TICKET_WRITE_BYTE_COUNT', 2213)
+        write_byte_limit = Column(Integer, 'TICKET_WRITE_BYTE_LIMIT', 2214)
+## For now, use of these columns raises CAT_SQL_ERR in both PRC and iquest: (irods/irods#5929)
+#       create_time = Column(String, 'TICKET_CREATE_TIME', 2209)
+#       modify_time = Column(String, 'TICKET_MODIFY_TIME', 2210)
+
+    class DataObject(Model):
+        """For queries of R_DATA_MAIN when joining to R_TICKET_MAIN.
+
+        The ticket(s) in question should be for a data object; otherwise
+        CAT_SQL_ERR is thrown.
+
+        """
+        name = Column(String, 'TICKET_DATA_NAME', 2226)
+        coll = Column(String, 'TICKET_DATA_COLL_NAME', 2227)
+
+    class Collection(Model):
+        """For queries of R_COLL_MAIN when joining to R_TICKET_MAIN.
+
+        The returned ticket(s) will be limited to those issued for collections.
+
+        """
+        name = Column(String, 'TICKET_COLL_NAME', 2228)
+
+    class Owner(Model):
+        """For queries concerning R_TICKET_USER_MAIN."""
+        name = Column(String, 'TICKET_OWNER_NAME', 2229)
+        zone = Column(String, 'TICKET_OWNER_ZONE', 2230)
+
+    class AllowedHosts(Model):
+        """For queries concerning R_TICKET_ALLOWED_HOSTS."""
+        ticket_id = Column(String, 'COL_TICKET_ALLOWED_HOST_TICKET_ID', 2220)
+        host = Column(String, 'COL_TICKET_ALLOWED_HOST', 2221)
+
+    class AllowedUsers(Model):
+        """For queries concerning R_TICKET_ALLOWED_USERS."""
+        ticket_id = Column(String, 'COL_TICKET_ALLOWED_USER_TICKET_ID', 2222)
+        user_name = Column(String, 'COL_TICKET_ALLOWED_USER', 2223)
+
+    class AllowedGroups(Model):
+        """For queries concerning R_TICKET_ALLOWED_GROUPS."""
+        ticket_id = Column(String, 'COL_TICKET_ALLOWED_GROUP_TICKET_ID', 2224)
+        group_name = Column(String, 'COL_TICKET_ALLOWED_GROUP', 2225)
diff --git a/irods/parallel.py b/irods/parallel.py
new file mode 100644
index 0000000..5af4c8a
--- /dev/null
+++ b/irods/parallel.py
@@ -0,0 +1,580 @@
+#!/usr/bin/env python
+from __future__ import print_function
+
+import os
+import ssl
+import time
+import sys
+import logging
+import contextlib
+import concurrent.futures
+import threading
+import multiprocessing
+import six
+
+from irods.data_object import iRODSDataObject
+from irods.exception import DataObjectDoesNotExist
+import irods.keywords as kw
+from six.moves.queue import Queue,Full,Empty
+
+
+logger = logging.getLogger( __name__ )
+_nullh  = logging.NullHandler()
+logger.addHandler( _nullh )
+
+
+MINIMUM_SERVER_VERSION = (4,2,9)
+
+
+class deferred_call(object):
+
+    """
+    A callable object that stores a function to be called later, along
+    with its parameters.
+    """
+
+    def __init__(self, function, args, keywords):
+        """Initialize the object with a function and its call parameters."""
+        self.function = function
+        self.args = args
+        self.keywords = keywords
+
+    def __setitem__(self, key, val):
+        """Allow changing a keyword option for the deferred function call."""
+        self.keywords[key] = val
+
+    def __call__(self):
+        """Call the stored function, using the arguments and keywords also stored
+        in the instance."""
+        return self.function(*self.args, **self.keywords)
+
+
+try:
+    from threading import Barrier   # Use 'Barrier' class if included (as in Python >= 3.2) ...
+except ImportError:                 # ... but otherwise, use this ad hoc:
+    # Based on https://stackoverflow.com/questions/26622745/implementing-barrier-in-python2-7 :
+    class Barrier(object):
+        def __init__(self, n):
+            """Initialize a Barrier to wait on n threads."""
+            self.n = n
+            self.count = 0
+            self.mutex = threading.Semaphore(1)
+            self.barrier = threading.Semaphore(0)
+        def wait(self):
+            """Per-thread wait function.
+
+            As in Python3.2 threading, returns 0 <= wait_serial_int < n
+            """
+            self.mutex.acquire()
+            self.count += 1
+            count = self.count
+            self.mutex.release()
+            if count == self.n: self.barrier.release()
+            self.barrier.acquire()
+            self.barrier.release()
+            return count - 1
+
+@contextlib.contextmanager
+def enableLogging(handlerType,args,level_ = logging.INFO):
+    """Context manager for temporarily enabling a logger. For debug or test.
+
+    Usage Example -
+    with irods.parallel.enableLogging(logging.FileHandler,('/tmp/logfile.txt',)):
+        # parallel put/get code here
+    """
+    h = None
+    saveLevel = logger.level
+    try:
+        logger.setLevel(level_)
+        h = handlerType(*args)
+        h.setLevel( level_ )
+        logger.addHandler(h)
+        yield
+    finally:
+        logger.setLevel(saveLevel)
+        if h in logger.handlers:
+            logger.removeHandler(h)
+
+
+RECOMMENDED_NUM_THREADS_PER_TRANSFER = 3
+
+verboseConnection = False
+
+class BadCallbackTarget(TypeError): pass
+
+class AsyncNotify (object):
+
+    """A type returned when the PUT or GET operation passed includes NONBLOCKING.
+       If enabled, the callback function (or callable object) will be triggered
+       when all parts of the parallel transfer are complete.  It should accept
+       exactly one argument, the irods.parallel.AsyncNotify instance that
+       is calling it.
+    """
+
+    def set_transfer_done_callback( self, callback ):
+        if callback is not None:
+            if not callable(callback):
+                raise BadCallbackTarget( '"callback" must be a callable accepting at least 1 argument' )
+        self.done_callback = callback
+
+    def __init__(self, futuresList, callback = None, progress_Queue = None, total = None, keep_ = ()):
+        """AsyncNotify initialization (used internally to the io.parallel library).
+           The casual user will only be concerned with the callback parameter, called when all threads
+           of the parallel PUT or GET have been terminated and the data object closed.
+        """
+        self._futures = set(futuresList)
+        self._futures_done = dict()
+        self.keep = dict(keep_)
+        self._lock = threading.Lock()
+        self.set_transfer_done_callback (callback)
+        self.__done = False
+        if self._futures:
+            for future in self._futures: future.add_done_callback( self )
+        else:
+            self.__invoke_done_callback()
+
+        self.progress = [0, 0]
+        if (progress_Queue) and (total is not None):
+            self.progress[1] = total
+            def _progress(Q,this):  # - thread to update progress indicator
+                while this.progress[0] < this.progress[1]:
+                    i = None
+                    try:
+                        i = Q.get(timeout=0.1)
+                    except Empty:
+                        pass
+                    if i is not None:
+                        if isinstance(i,six.integer_types) and i >= 0: this.progress[0] += i
+                        else: break
+            self._progress_fn = _progress
+            self._progress_thread = threading.Thread( target = self._progress_fn, args = (progress_Queue, self))
+            self._progress_thread.start()
+
+    @staticmethod
+    def asciiBar( lst, memo = [1] ):
+        memo[0] += 1
+        spinner = "|/-\\"[memo[0]%4]
+        percent = "%5.1f%%"%(lst[0]*100.0/lst[1])
+        mbytes = "%9.1f MB / %9.1f MB"%(lst[0]/1e6,lst[1]/1e6)
+        if lst[1] != 0:
+            s = "  {spinner} {percent} [ {mbytes} ] "
+        else:
+            s = "  {spinner} "
+        return s.format(**locals())
+
+    def wait_until_transfer_done (self, timeout=float('inf'), progressBar = False):
+        carriageReturn = '\r'
+        begin = t = time.time()
+        end = begin + timeout
+        while not self.__done:
+            time.sleep(min(0.1, max(0.0, end - t)))
+            t = time.time()
+            if t >= end: break
+            if progressBar:
+                print ('  ' + self.asciiBar( self.progress ) + carriageReturn, end='', file=sys.stderr)
+                sys.stderr.flush()
+        return self.__done
+
+    def __call__(self,future): # Our instance is called by each future (individual file part) when done.
+                               # When all futures are done, we invoke the configured callback.
+        with self._lock:
+            self._futures_done[future] = future.result()
+            if len(self._futures) == len(self._futures_done): self.__invoke_done_callback()
+
+    def __invoke_done_callback(self):
+        try:
+            if callable(self.done_callback): self.done_callback(self)
+        finally:
+            self.keep.pop('mgr',None)
+            self.__done = True
+        self.set_transfer_done_callback(None)
+
+    @property
+    def futures(self): return list(self._futures)
+
+    @property
+    def futures_done(self): return dict(self._futures_done)
+
+
+class Oper(object):
+
+    """A custom enum-type class with utility methods.
+
+    It makes some logic clearer, including succinct calculation of file and data
+    object open() modes based on whether the operation is a PUT or GET and whether
+    we are doing the "initial" open of the file or object.
+    """
+
+    GET = 0
+    PUT = 1
+    NONBLOCKING = 2
+
+    def __int__(self):
+        """Return the stored flags as an integer bitmask. """
+        return self._opr
+
+    def __init__(self, rhs):
+        """Initialize with a bit mask of flags ie. whether Operation PUT or GET, 
+        and whether NONBLOCKING."""
+        self._opr = int(rhs)
+
+    def isPut(self): return 0 != (self._opr & self.PUT)
+    def isGet(self): return not self.isPut()
+    def isNonBlocking(self): return 0 != (self._opr & self.NONBLOCKING)
+
+    def data_object_mode(self, initial_open = False):
+        if self.isPut():
+            return 'w' if initial_open else 'a'
+        else:
+            return 'r'
+
+    def disk_file_mode(self, initial_open = False, binary = True):
+        if self.isPut():
+            mode = 'r'
+        else:
+            mode = 'w' if initial_open else 'r+'
+        return ((mode + 'b') if binary else mode)
+
+
+def _io_send_bytes_progress (queueObject, item):
+    try:
+        queueObject.put(item)
+        return True
+    except Full:
+        return False
+
+COPY_BUF_SIZE = (1024 ** 2) * 4
+
+def _copy_part( src, dst, length, queueObject, debug_info, mgr):
+    """
+    The work-horse for performing the copy between file and data object.
+
+    It also helps determine whether there has been a large enough increment of
+    bytes to inform the progress bar of a need to update.
+    """
+    bytecount = 0
+    accum = 0
+    while True and bytecount < length:
+        buf = src.read(min(COPY_BUF_SIZE, length - bytecount))
+        buf_len = len(buf)
+        if 0 == buf_len: break
+        dst.write(buf)
+        bytecount += buf_len
+        accum += buf_len
+        if queueObject and accum and _io_send_bytes_progress(queueObject,accum): accum = 0
+        if verboseConnection:
+            print ("("+debug_info+")",end='',file=sys.stderr)
+            sys.stderr.flush()
+
+    # In a put or get, exactly one of (src,dst) is a file. Find which and close that one first.
+    (file_,obj_) = (src,dst) if dst in mgr else (dst,src)
+    file_.close()
+    mgr.remove_io( obj_ ) # 1. closes obj if it is not the mgr's initial descriptor
+                          # 2. blocks at barrier until all transfer threads are done copying
+                          # 3. closes with finalize if obj is mgr's initial descriptor
+    return bytecount
+
+
+class _Multipart_close_manager:
+    """An object used to ensure that the initial transfer thread is also the last one to
+    call the close method on its `Io' object.  The caller is responsible for setting up the
+    conditions that the initial thread's close() is the one performing the catalog update.
+
+    All non-initial transfer threads just call close() as soon as they are done transferring
+    the byte range for which they are responsible, whereas we block the initial thread
+    using a threading Barrier until we know all other threads have called close().
+
+    """
+    def __init__(self, initial_io_, exit_barrier_):
+        self.exit_barrier = exit_barrier_
+        self.initial_io = initial_io_
+        self.__lock = threading.Lock()
+        self.aux = []
+
+    def __contains__(self,Io):
+        with self.__lock:
+            return Io is self.initial_io or \
+                   Io in self.aux
+
+    # `add_io' - add an i/o object to be managed
+    # note: `remove_io' should only be called for managed i/o objects
+
+    def add_io(self,Io):
+        with self.__lock:
+            if Io is not self.initial_io:
+                self.aux.append(Io)
+
+    # `remove_io' is for closing a channel of parallel i/o and allowing the
+    # data object to flush write operations (if any) in a timely fashion.  It also
+    # synchronizes all of the parallel threads just before exit, so that we know
+    # exactly when to perform a finalizing close on the data object
+
+    def remove_io(self,Io):
+        is_initial = True
+        with self.__lock:
+            if Io is not self.initial_io:
+                Io.close()
+                self.aux.remove(Io)
+                is_initial = False
+        self.exit_barrier.wait()
+        if is_initial: self.finalize()
+
+    def finalize(self):
+        self.initial_io.close()
+
+
+def _io_part (objHandle, range_, file_, opr_, mgr_, thread_debug_id = '', queueObject = None ):
+    """
+    Runs in a separate thread to manage the transfer of a range of bytes within the data object.
+
+    The particular range is defined by the end of the range_ parameter, which should be of type
+    (Py2) xrange or (Py3) range.
+    """
+    if 0 == len(range_): return 0
+    Operation = Oper(opr_)
+    (offset,length) = (range_[0], len(range_))
+    objHandle.seek(offset)
+    file_.seek(offset)
+    if thread_debug_id == '':  # for more succinct thread identifiers while debugging.
+        thread_debug_id = str(threading.currentThread().ident)
+    return ( _copy_part (file_, objHandle, length, queueObject, thread_debug_id, mgr_) if Operation.isPut()
+        else _copy_part (objHandle, file_, length, queueObject, thread_debug_id, mgr_) )
+
+
+def _io_multipart_threaded(operation_ , dataObj_and_IO, replica_token, hier_str, session, fname,
+                           total_size, num_threads, **extra_options):
+    """Called by _io_main.
+
+    Carve up (0,total_size) range into `num_threads` parts and initiate a transfer thread for each one.
+    """
+    (Data_object, Io) = dataObj_and_IO
+    Operation = Oper( operation_ )
+
+    def bytes_range_for_thread( i, num_threads, total_bytes,  chunk ):
+        begin_offs = i * chunk
+        if i + 1 < num_threads:
+            end_offs = (i + 1) * chunk
+        else:
+            end_offs = total_bytes
+        return six.moves.range(begin_offs, end_offs)
+
+    bytes_per_thread = total_size // num_threads
+
+    ranges = [bytes_range_for_thread(i, num_threads, total_size, bytes_per_thread) for i in range(num_threads)]
+
+    logger.info("num_threads = %s ; bytes_per_thread = %s", num_threads, bytes_per_thread)
+
+    _queueLength = extra_options.get('_queueLength',0)
+    if _queueLength > 0:
+        queueObject = Queue(_queueLength)
+    else:
+        queueObject = None
+
+    futures = []
+    executor = concurrent.futures.ThreadPoolExecutor(max_workers = num_threads)
+    num_threads = min(num_threads, len(ranges))
+    mgr = _Multipart_close_manager(Io, Barrier(num_threads))
+    counter = 1
+    gen_file_handle = lambda: open(fname, Operation.disk_file_mode(initial_open = (counter == 1)))
+    File = gen_file_handle()
+    for byte_range in ranges:
+        if Io is None:
+            Io = session.data_objects.open( Data_object.path, Operation.data_object_mode(initial_open = False),
+                                            create = False, finalize_on_close = False,
+                                            **{ kw.NUM_THREADS_KW: str(num_threads),
+                                                kw.DATA_SIZE_KW: str(total_size),
+                                                kw.RESC_HIER_STR_KW: hier_str,
+                                                kw.REPLICA_TOKEN_KW: replica_token })
+        mgr.add_io( Io )
+        if File is None: File = gen_file_handle()
+        futures.append(executor.submit( _io_part, Io, byte_range, File, Operation, mgr, str(counter), queueObject))
+        counter += 1
+        Io = File = None
+
+    if Operation.isNonBlocking():
+        if _queueLength:
+            return futures, queueObject, mgr
+        else:
+            return futures
+    else:
+        bytecounts = [ f.result() for f in futures ]
+        return sum(bytecounts), total_size
+
+
+
+def io_main( session, Data, opr_, fname, R='', **kwopt):
+    """
+    The entry point for parallel transfers (multithreaded PUT and GET operations).
+
+    Here, we do the following:
+    * instantiate the data object, if this has not already been done.
+    * determine replica information and the appropriate number of threads.
+    * call the multithread manager to initiate multiple data transfer threads
+
+    """
+    total_bytes = kwopt.pop('total_bytes', -1)
+    Operation = Oper(opr_)
+    d_path = None
+    Io = None
+
+    if isinstance(Data,tuple):
+        (Data, Io) = Data[:2]
+
+    if isinstance (Data, six.string_types):
+        d_path = Data
+        try:
+            Data = session.data_objects.get( Data )
+            d_path = Data.path
+        except DataObjectDoesNotExist:
+            if Operation.isGet(): raise
+
+    R_via_libcall = kwopt.pop( 'target_resource_name', '')
+    if R_via_libcall:
+        R = R_via_libcall
+
+    num_threads = kwopt.get( 'num_threads', None)
+    if num_threads is None: num_threads = int(kwopt.get('N','0'))
+    if num_threads < 1:
+        num_threads = RECOMMENDED_NUM_THREADS_PER_TRANSFER
+    num_threads = max(1, min(multiprocessing.cpu_count(), num_threads))
+
+    open_options = {}
+    if Operation.isPut():
+        if R:
+            open_options [kw.RESC_NAME_KW] = R
+            open_options [kw.DEST_RESC_NAME_KW] = R
+        open_options[kw.NUM_THREADS_KW] = str(num_threads)
+        open_options[kw.DATA_SIZE_KW] = str(total_bytes)
+
+    if (not Io):
+        (Io, rawfile) = session.data_objects.open_with_FileRaw( (d_path or Data.path),
+                                                                Operation.data_object_mode(initial_open = True),
+                                                                finalize_on_close = True, **open_options )
+    else:
+        if type(Io) is deferred_call:
+            Io[kw.NUM_THREADS_KW] = str(num_threads)
+            Io[kw.DATA_SIZE_KW] =  str(total_bytes)
+            Io = Io()
+        rawfile = Io.raw
+
+    # At this point, the data object's existence in the catalog is guaranteed,
+    # whether the Operation is a GET or PUT.
+
+    if not isinstance(Data,iRODSDataObject):
+        Data = session.data_objects.get(d_path)
+
+    # Determine total number of bytes for transfer.
+
+    if Operation.isGet():
+        total_bytes = Io.seek(0,os.SEEK_END)
+        Io.seek(0,os.SEEK_SET)
+    else: # isPut
+        if total_bytes < 0:
+            with open(fname, 'rb') as f:
+                f.seek(0,os.SEEK_END)
+                total_bytes = f.tell()
+
+    # Get necessary info and initiate threaded transfers.
+
+    (replica_token , resc_hier) = rawfile.replica_access_info()
+
+    queueLength = kwopt.get('queueLength',0)
+    retval = _io_multipart_threaded (Operation, (Data, Io), replica_token, resc_hier, session, fname, total_bytes,
+                                     num_threads = num_threads,
+                                     _queueLength = queueLength)
+
+    # SessionObject.data_objects.parallel_{put,get} will return:
+    #   - immediately with an AsyncNotify instance, if Oper.NONBLOCKING flag is used.
+    #   - upon completion with a boolean completion status, otherwise.
+
+    if Operation.isNonBlocking():
+
+        if queueLength > 0:
+            (futures, chunk_notify_queue, mgr) = retval
+        else:
+            futures = retval
+            chunk_notify_queue = total_bytes = None
+
+        return AsyncNotify( futures,                              # individual futures, one per transfer thread
+                            progress_Queue = chunk_notify_queue,  # for notifying the progress indicator thread
+                            total = total_bytes,                  # total number of bytes for parallel transfer
+                            keep_ = {'mgr': mgr}  )   # an open raw i/o object needing to be persisted, if any
+    else:
+        (_bytes_transferred, _bytes_total) = retval
+        return (_bytes_transferred == _bytes_total)
+
+if __name__ == '__main__':
+
+    import getopt
+    import atexit
+    from irods.session import iRODSSession
+
+    def setupLoggingWithDateTimeHeader(name,level = logging.DEBUG):
+        if _nullh in logger.handlers:
+            logger.removeHandler(_nullh)
+            if name:
+                handler = logging.FileHandler(name)
+            else:
+                handler = logging.StreamHandler()
+            handler.setFormatter(logging.Formatter('%(asctime)-15s - %(message)s'))
+        logger.addHandler(handler)
+        logger.setLevel( level )
+
+    try:
+        env_file = os.environ['IRODS_ENVIRONMENT_FILE']
+    except KeyError:
+        env_file = os.path.expanduser('~/.irods/irods_environment.json')
+    ssl_context = ssl.create_default_context(purpose=ssl.Purpose.SERVER_AUTH, cafile=None, capath=None, cadata=None)
+    ssl_settings = {'ssl_context': ssl_context}
+    sess = iRODSSession(irods_env_file=env_file, **ssl_settings)
+    atexit.register(lambda : sess.cleanup())
+
+    opt,arg = getopt.getopt( sys.argv[1:], 'vL:l:aR:N:')
+
+    opts = dict(opt)
+
+    logFilename = opts.pop('-L',None)  # '' for console, non-empty for filesystem destination
+    logLevel = (logging.INFO if logFilename is None else logging.DEBUG)
+    logFilename = logFilename or opts.pop('-l',None)
+
+    if logFilename is not None:
+        setupLoggingWithDateTimeHeader(logFilename, logLevel)
+
+    verboseConnection = (opts.pop('-v',None) is not None)
+
+    async_xfer = opts.pop('-a',None)
+
+    kwarg = { k.lstrip('-'):v for k,v in opts.items() }
+
+    arg[1] = Oper.PUT if arg[1].lower() in ('w','put','a') \
+                      else Oper.GET
+    if async_xfer is not None:
+        arg[1] |= Oper.NONBLOCKING
+
+    ret = io_main(sess, *arg, **kwarg) # arg[0] = data object or path
+                                       # arg[1] = operation: or'd flags : [PUT|GET] NONBLOCKING
+                                       # arg[2] = file path on local filesystem
+                                       # kwarg['queueLength'] sets progress-queue length (0 if no progress indication needed)
+                                       # kwarg options 'N' (num threads) and 'R' (target resource name) are via command-line
+                                       # kwarg['num_threads'] (overrides 'N' when called as a library)
+                                       # kwarg['target_resource_name'] (overrides 'R' when called as a library)
+    if isinstance( ret, AsyncNotify ):
+        print('waiting on completion...',file=sys.stderr)
+        ret.set_transfer_done_callback(lambda r: print('Async transfer done for:',r,file=sys.stderr))
+        done = ret.wait_until_transfer_done (timeout=10.0)  # - or do other useful work here
+        if done:
+            bytes_transferred = sum(ret.futures_done.values())
+            print ('Asynch transfer complete. Total bytes transferred:', bytes_transferred,file=sys.stderr)
+        else:
+            print ('Asynch transfer was not completed before timeout expired.',file=sys.stderr)
+    else:
+        print('Synchronous transfer {}'.format('succeeded' if ret else 'failed'),file=sys.stderr)
+
+# Note : This module requires concurrent.futures, included in Python3.x.
+#        On Python2.7, this dependency must be installed using 'pip install futures'.
+# Demonstration :
+#
+# $ dd if=/dev/urandom bs=1k count=150000 of=$HOME/puttest
+# $ time python -m irods.parallel -R demoResc -N 3 `ipwd`/test.dat put $HOME/puttest  # add -v,-a for verbose, asynch
+# $ time python -m irods.parallel -R demoResc -N 3 `ipwd`/test.dat get $HOME/gettest  # add -v,-a for verbose, asynch
+# $ diff puttest gettest
diff --git a/irods/password_obfuscation.py b/irods/password_obfuscation.py
index a6f3a0e..ef38550 100644
--- a/irods/password_obfuscation.py
+++ b/irods/password_obfuscation.py
@@ -275,3 +275,11 @@ def obfuscate_new_password(new, old, signature):
         new = new + padding[:lcopy]
 
     return scramble_v2(new, old, signature)
+
+
+def create_temp_password(temp_hash, source_password):
+    password = (temp_hash + source_password).ljust(100, chr(0))
+    password_md5 = hashlib.md5(password.encode('utf-8'))
+
+    # Return hexdigest
+    return password_md5.hexdigest()
diff --git a/irods/path/__init__.py b/irods/path/__init__.py
new file mode 100644
index 0000000..00b94ef
--- /dev/null
+++ b/irods/path/__init__.py
@@ -0,0 +1,72 @@
+"""A module providing tools for path normalization and manipulation."""
+
+__all__ = ['iRODSPath']
+
+import re
+import logging
+import os
+
+_multiple_slash = re.compile('/+')
+
+class iRODSPath(str):
+    """A subclass of the Python string that normalizes iRODS logical paths."""
+
+    def __new__(cls, *elem_list, **kw):
+        """
+        Initializes our immutable string object with a normalized form.
+        An instance of iRODSPath is also a `str'.
+
+        Keywords may include only 'absolute'. The default is True, forcing a slash as
+        the leading character of the resulting string.
+
+        Variadic parameters are the path elements, strings which may name individual
+        collections or sub-hierarchies (internally slash-separated).  These are then
+        joined using the path separator:
+
+            data_path = iRODSPath( 'myZone', 'home/user', './dir', 'mydata')
+            # => '/myZone/home/user/dir/mydata'
+
+        In the resulting object, any trailing and redundant path separators are removed,
+        as is the "trivial" path element ('.'), so this will work:
+
+            c = iRODSPath('/tempZone//home/./',username + '/')
+            session.collections.get( c )
+
+        If the `absolute' keyword hint is set to False, leading '..' elements are not
+        suppressed (since only for absolute paths is "/.." equivalent to "/"), and the
+        leading slash requirement will not be imposed on the resulting string.
+        Note also that a leading slash in the first argument will be preserved regardless
+        of the `absolute' hint, but subsequent arguments will act as relative paths
+        regardless of leading slashes. So this will do the "right thing":
+
+            my_dir = str(iRODSPath('dir1'))                     # => "/dir1"
+            my_rel = ""+iRODSPath('dir2', absolute=False)       # => "dir2"
+            my_abs = iRODSPath('/Z/home/user', my_dir, my_rel)  # => "/Z/home/user/dir1/dir2"
+
+        Finally, because iRODSPath has `str` as a base class, this is also possible:
+
+            iRODSPath('//zone/home/public/this', iRODSPath('../that',absolute=False))
+            # => "/zone/home/public/that"
+        """
+        absolute_ = kw.pop('absolute',True)
+        if kw:
+            logging.warning("These iRODSPath options have no effect: %r",kw.items())
+        normalized = cls.resolve_irods_path(*elem_list,**{"absolute":absolute_})
+        obj = str.__new__(cls,normalized)
+        return obj
+
+
+    @staticmethod
+    def resolve_irods_path(*path_elems, **kw):
+
+        abs_ = kw['absolute']
+
+        # Since we mean this operation to be purely a concatenation, we must strip
+        # '/' from all but first path component or os.path, or os.path.join
+        # will disregard all path elements preceding an absolute path specification.
+
+        while path_elems and not path_elems[0]:
+            path_elems = path_elems[1:]          # allow no leading empties preempting leading slash
+        elems = list(path_elems[:1]) + [elem.lstrip("/") for elem in path_elems[1:]]
+        retv = os.path.normpath(os.path.join(('/' if abs_ else ''), *elems))
+        return retv if not retv.startswith('//') else retv[1:] # Grrr...: https://bugs.python.org/issue26329
diff --git a/irods/pool.py b/irods/pool.py
index 4488ba4..37e1734 100644
--- a/irods/pool.py
+++ b/irods/pool.py
@@ -1,38 +1,100 @@
 from __future__ import absolute_import
+import datetime
 import logging
 import threading
+import os
 
 from irods import DEFAULT_CONNECTION_TIMEOUT
 from irods.connection import Connection
 
 logger = logging.getLogger(__name__)
 
+def attribute_from_return_value(attrname):
+    def deco(method):
+        def method_(self,*s,**kw):
+            ret = method(self,*s,**kw)
+            setattr(self,attrname,ret)
+            return ret
+        return method_
+    return deco
+
+DEFAULT_APPLICATION_NAME = 'python-irodsclient'
 
 class Pool(object):
 
-    def __init__(self, account):
+    def __init__(self, account, application_name='', connection_refresh_time=-1):
+        '''
+        Pool( account , application_name='' )
+        Create an iRODS connection pool; 'account' is an irods.account.iRODSAccount instance and
+        'application_name' specifies the application name as it should appear in an 'ips' listing.
+        '''
+
+        self._thread_local = threading.local()
         self.account = account
         self._lock = threading.RLock()
         self.active = set()
         self.idle = set()
         self.connection_timeout = DEFAULT_CONNECTION_TIMEOUT
+        self.application_name = ( os.environ.get('spOption','') or
+                                  application_name or
+                                  DEFAULT_APPLICATION_NAME )
+
+        if connection_refresh_time > 0:
+            self.refresh_connection = True
+            self.connection_refresh_time = connection_refresh_time
+        else:
+            self.refresh_connection = False
+            self.connection_refresh_time = None
+
+    @property
+    def _conn(self): return getattr( self._thread_local, "_conn", None)
+
+    @_conn.setter
+    def _conn(self, conn_): setattr( self._thread_local, "_conn", conn_)
 
+    @attribute_from_return_value("_conn")
     def get_connection(self):
         with self._lock:
             try:
                 conn = self.idle.pop()
+
+                curr_time = datetime.datetime.now()
+                # If 'refresh_connection' flag is True and the connection was
+                # created more than 'connection_refresh_time' seconds ago,
+                # release the connection (as its stale) and create a new one
+                if self.refresh_connection and (curr_time - conn.create_time).total_seconds() > self.connection_refresh_time:
+                    logger.debug('Connection with id {} was created more than {} seconds ago. Releasing the connection and creating a new one.'.format(id(conn), self.connection_refresh_time))
+                    # Since calling disconnect() repeatedly is safe, we call disconnect()
+                    # here explicitly, instead of relying on the garbage collector to clean
+                    # up the object and call disconnect(). This makes the behavior of the
+                    # code more predictable as we are not relying on when garbage collector is called
+                    conn.disconnect()
+                    conn = Connection(self, self.account)
+                    logger.debug("Created new connection with id: {}".format(id(conn)))
             except KeyError:
                 conn = Connection(self, self.account)
+                logger.debug("No connection found in idle set. Created a new connection with id: {}".format(id(conn)))
+
             self.active.add(conn)
+            logger.debug("Adding connection with id {} to active set".format(id(conn)))
+
         logger.debug('num active: {}'.format(len(self.active)))
+        logger.debug('num idle: {}'.format(len(self.idle)))
         return conn
 
     def release_connection(self, conn, destroy=False):
         with self._lock:
             if conn in self.active:
                 self.active.remove(conn)
+                logger.debug("Removed connection with id: {} from active set".format(id(conn)))
                 if not destroy:
+                    # If 'refresh_connection' flag is True, update connection's 'last_used_time'
+                    if self.refresh_connection:
+                        conn.last_used_time = datetime.datetime.now()
                     self.idle.add(conn)
+                    logger.debug("Added connection with id: {} to idle set".format(id(conn)))
             elif conn in self.idle and destroy:
+                logger.debug("Destroyed connection with id: {}".format(id(conn)))
                 self.idle.remove(conn)
+        logger.debug('num active: {}'.format(len(self.active)))
         logger.debug('num idle: {}'.format(len(self.idle)))
diff --git a/irods/query.py b/irods/query.py
index 58fbb77..0d9f7f4 100644
--- a/irods/query.py
+++ b/irods/query.py
@@ -5,7 +5,7 @@ from irods import MAX_SQL_ROWS
 from irods.models import Model
 from irods.column import Column, Keyword
 from irods.message import (
-    IntegerIntegerMap, IntegerStringMap, StringStringMap,
+    IntegerIntegerMap, IntegerStringMap, StringStringMap, _OrderedMultiMapping,
     GenQueryRequest, GenQueryResponse, empty_gen_query_out,
     iRODSMessage, SpecificQueryRequest, GeneralAdminRequest)
 from irods.api_number import api_number
@@ -36,6 +36,7 @@ class Query(object):
         self._limit = -1
         self._offset = 0
         self._continue_index = 0
+        self._keywords = {}
 
         for arg in args:
             if isinstance(arg, type) and issubclass(arg, Model):
@@ -54,6 +55,12 @@ class Query(object):
         new_q._limit = self._limit
         new_q._offset = self._offset
         new_q._continue_index = self._continue_index
+        new_q._keywords = self._keywords
+        return new_q
+
+    def add_keyword(self, keyword, value = ''):
+        new_q = self._clone()
+        new_q._keywords[keyword] = value
         return new_q
 
     def filter(self, *criteria):
@@ -63,7 +70,7 @@ class Query(object):
 
     def order_by(self, column, order='asc'):
         new_q = self._clone()
-        del new_q.columns[column]
+        new_q.columns.pop(column,None)
         if order == 'asc':
             new_q.columns[column] = query_number['ORDER_BY']
         elif order == 'desc':
@@ -124,7 +131,7 @@ class Query(object):
     # todo store criterion for columns and criterion for keywords in seaparate
     # lists
     def _conds_message(self):
-        dct = dict([
+        dct = _OrderedMultiMapping([
             (criterion.query_key.icat_id, criterion.op + ' ' + criterion.value)
             for criterion in self.criteria
             if isinstance(criterion.query_key, Column)
@@ -138,6 +145,8 @@ class Query(object):
             for criterion in self.criteria
             if isinstance(criterion.query_key, Keyword)
         ])
+        for key in self._keywords:
+            dct[ key ] = self._keywords[key]
         return StringStringMap(dct)
 
     def _message(self):
@@ -184,15 +193,20 @@ class Query(object):
 
     def get_batches(self):
         result_set = self.execute()
-        yield result_set
 
-        while result_set.continue_index > 0:
-            try:
-                result_set = self.continue_index(
-                    result_set.continue_index).execute()
-                yield result_set
-            except CAT_NO_ROWS_FOUND:
-                break
+        try:
+            yield result_set
+
+            while result_set.continue_index > 0:
+                try:
+                    result_set = self.continue_index(
+                        result_set.continue_index).execute()
+                    yield result_set
+                except CAT_NO_ROWS_FOUND:
+                    break
+        except GeneratorExit:
+            if result_set.continue_index > 0:
+                self.continue_index(result_set.continue_index).close()
 
     def get_results(self):
         for result_set in self.get_batches():
@@ -204,6 +218,8 @@ class Query(object):
 
     def one(self):
         results = self.execute()
+        if results.continue_index > 0:
+            self.continue_index(results.continue_index).close()
         if not len(results):
             raise NoResultFound()
         if len(results) > 1:
@@ -213,6 +229,8 @@ class Query(object):
     def first(self):
         query = self.limit(1)
         results = query.execute()
+        if results.continue_index > 0:
+            query.continue_index(results.continue_index).close()
         if not len(results):
             return None
         else:
@@ -279,7 +297,7 @@ class SpecificQuery(object):
             conditions = StringStringMap({})
 
         sql_args = {}
-        for i, arg in enumerate(self._args[:10]):
+        for i, arg in enumerate(self._args[:10], start=1):
             sql_args['arg{}'.format(i)] = arg
 
         message_body = SpecificQueryRequest(sql=target,
diff --git a/irods/resource.py b/irods/resource.py
index 7ddd368..c87a7a7 100644
--- a/irods/resource.py
+++ b/irods/resource.py
@@ -1,5 +1,6 @@
 from __future__ import absolute_import
 from irods.models import Resource
+from irods.meta import iRODSMetaCollection
 import six
 
 
@@ -37,6 +38,12 @@ class iRODSResource(object):
 
         self._meta = None
 
+    @property
+    def metadata(self):
+        if not self._meta:
+            self._meta = iRODSMetaCollection(
+                self.manager.sess.metadata, Resource, self.name)
+        return self._meta
 
     @property
     def context_fields(self):
diff --git a/irods/results.py b/irods/results.py
index aabd222..37c0136 100644
--- a/irods/results.py
+++ b/irods/results.py
@@ -3,6 +3,13 @@ from prettytable import PrettyTable
 
 from irods.models import ModelBase
 from six.moves import range
+from six import PY3
+
+
+try:
+    unicode         # Python 2
+except NameError:
+    unicode = str
 
 
 class ResultSet(object):
@@ -41,8 +48,13 @@ class ResultSet(object):
         except (TypeError, ValueError):
             return (col, value)
 
+    _str_encode = staticmethod(lambda x:x.encode('utf-8') if type(x) is unicode else x)
+
+    _get_column_values = ( lambda self,index: [(col, col.value[index]) for col in self.cols]
+           ) if PY3 else ( lambda self,index: [(col, self._str_encode(col.value[index])) for col in self.cols] )
+
     def _format_row(self, index):
-        values = [(col, col.value[index]) for col in self.cols]
+        values = self._get_column_values(index)
         return dict([self._format_attribute(col.attriInx, value) for col, value in values])
 
     def __getitem__(self, index):
diff --git a/irods/rule.py b/irods/rule.py
index 4cd26ad..0ba3e7a 100644
--- a/irods/rule.py
+++ b/irods/rule.py
@@ -1,33 +1,111 @@
 from __future__ import absolute_import
 from irods.message import iRODSMessage, StringStringMap, RodsHostAddress, STR_PI, MsParam, MsParamArray, RuleExecutionRequest
 from irods.api_number import api_number
+import irods.exception as ex
+from io import open as io_open
+from irods.message import Message, StringProperty
+import six
+
+class RemoveRuleMessage(Message):
+    #define RULE_EXEC_DEL_INP_PI "str ruleExecId[NAME_LEN];"
+    _name = 'RULE_EXEC_DEL_INP_PI'
+    ruleExecId = StringProperty()
+    def __init__(self,id_):
+        super(RemoveRuleMessage,self).__init__()
+        self.ruleExecId = str(id_)
 
 class Rule(object):
-    def __init__(self, session, rule_file=None, body='', params=None, output=''):
+    def __init__(self, session, rule_file=None, body='', params=None, output='', instance_name = None, irods_3_literal_style = False):
+        """
+        Initialize a rule object.
+
+        Arguments:
+        Use one of:
+          * rule_file : the name of an existing file containint "rule script" style code. In the context of
+            the native iRODS Rule Language, this is a file ending in '.r' and containing iRODS rules.
+            Optionally, this parameter can be a file-like object containing the rule script text.
+          * body: the text of block of rule code (possibly including rule calls) to be run as if it were
+            the body of a rule, e.g. the part between the braces of a rule definition in the iRODS rule language.
+        * instance_name: the name of the rule engine instance in the context of which to run the rule(s).
+        * output may be set to 'ruleExecOut' if console output is expected on stderr or stdout streams.
+        * params are key/value pairs to be sent into a rule_file.
+        * irods_3_literal_style: affects the format of the @external directive. Use `True' for iRODS 3.x.
+
+        """
         self.session = session
 
+        self.params = {}
+        self.output = ''
+
         if rule_file:
             self.load(rule_file)
         else:
-            self.body = '@external\n' + body
-            if params is None:
-                self.params = {}
+            self.body = '@external\n' + body if irods_3_literal_style \
+                   else '@external rule { ' + body + ' }'
+
+        # overwrite params and output if received arguments
+        if isinstance( params , dict ):
+            if self.params:
+                self.params.update( params )
             else:
                 self.params = params
+
+        if output != '':
             self.output = output
 
-    def load(self, rule_file):
-        self.params = {}
-        self.output = ''
+        self.instance_name = instance_name
+
+    def remove_by_id(self,*ids):
+        with self.session.pool.get_connection() as conn:
+            for id_ in ids:
+                request = iRODSMessage("RODS_API_REQ", msg=RemoveRuleMessage(id_),
+                                       int_info=api_number['RULE_EXEC_DEL_AN'])
+                conn.send(request)
+                response = conn.recv()
+                if response.int_info != 0:
+                    raise RuntimeError("Error removing rule {id_}".format(**locals()))
+
+    def load(self, rule_file, encoding = 'utf-8'):
+        """Load rule code with rule-file (*.r) semantics.
+
+        A "main" rule is defined first; name does not matter. Other rules may follow, which will be
+        callable from the first rule.  Any rules defined in active rule-bases within the server are
+        also callable.
+
+        The `rule_file' parameter is a filename or file-like object.  We give it either:
+           - a string holding the path to a rule-file in the local filesystem, or
+           - an in-memory object (eg. io.StringIO or io.BytesIO) whose content is that of a rule-file.
+
+        This addresses a regression in v1.1.0; see issue #336.  In v1.1.1+, if rule code is passed in literally via
+        the `body' parameter of the Rule constructor, it is interpreted as if it were the body of a rule, and
+        therefore it may not contain internal rule definitions.  However, if rule code is submitted as the content
+        of a file or file-like object referred to by the `rule_file' parameter of the Rule constructor, will be
+        interpreted as .r-file content.  Therefore, it must contain a main rule definition first, followed
+        possibly by others which will be callable from the main rule as if they were part of the core rule-base.
+
+        """
         self.body = '@external\n'
 
-        # parse rule file
-        with open(rule_file) as f:
+
+        with (io_open(rule_file, encoding = encoding) if isinstance(rule_file,six.string_types) else rule_file
+        ) as f:
+
+            # parse rule file line-by-line
             for line in f:
+
+                # convert input line to Unicode if necessary
+                if isinstance(line, bytes):
+                    line = line.decode(encoding)
+
                 # parse input line
                 if line.strip().lower().startswith('input'):
+
                     input_header, input_line = line.split(None, 1)
 
+                    if input_line.strip().lower() == 'null':
+                        self.params = {}
+                        continue
+
                     # sanity check
                     if input_header.lower() != 'input':
                         raise ValueError
@@ -53,25 +131,36 @@ class Rule(object):
                     self.body += line
 
 
-    def execute(self):
-        # rule input
-        param_array = []
-        for label, value in self.params.items():
-            inOutStruct = STR_PI(myStr=value)
-            param_array.append(MsParam(label=label, type='STR_PI', inOutStruct=inOutStruct))
+    def execute(self, session_cleanup = True,
+                      acceptable_errors = (ex.FAIL_ACTION_ENCOUNTERED_ERR,),
+                      r_error = None,
+                      return_message = ()):
+        try:
+            # rule input
+            param_array = []
+            for label, value in self.params.items():
+                inOutStruct = STR_PI(myStr=value)
+                param_array.append(MsParam(label=label, type='STR_PI', inOutStruct=inOutStruct))
+
+            inpParamArray = MsParamArray(paramLen=len(param_array), oprType=0, MsParam_PI=param_array)
+
+            # rule body
+            addr = RodsHostAddress(hostAddr='', rodsZone='', port=0, dummyInt=0)
+            condInput = StringStringMap( {} if self.instance_name is None
+                                            else {'instance_name':self.instance_name} )
+            message_body = RuleExecutionRequest(myRule=self.body, addr=addr, condInput=condInput, outParamDesc=self.output, inpParamArray=inpParamArray)
+
+            request = iRODSMessage("RODS_API_REQ", msg=message_body, int_info=api_number['EXEC_MY_RULE_AN'])
+
+            with self.session.pool.get_connection() as conn:
+                conn.send(request)
+                response = conn.recv(acceptable_errors = acceptable_errors, return_message = return_message)
+                try:
+                    out_param_array = response.get_main_message(MsParamArray, r_error = r_error)
+                except iRODSMessage.ResponseNotParseable:
+                    return MsParamArray() # Ergo, no useful return value - but the RError stack will be accessible
+        finally:
+            if session_cleanup:
+                self.session.cleanup()
 
-        inpParamArray = MsParamArray(paramLen=len(param_array), oprType=0, MsParam_PI=param_array)
-
-        # rule body
-        addr = RodsHostAddress(hostAddr='', rodsZone='', port=0, dummyInt=0)
-        condInput = StringStringMap({})
-        message_body = RuleExecutionRequest(myRule=self.body, addr=addr, condInput=condInput, outParamDesc=self.output, inpParamArray=inpParamArray)
-
-        request = iRODSMessage("RODS_API_REQ", msg=message_body, int_info=api_number['EXEC_MY_RULE_AN'])
-
-        with self.session.pool.get_connection() as conn:
-            conn.send(request)
-            response = conn.recv()
-            out_param_array = response.get_main_message(MsParamArray)
-            self.session.cleanup()
         return out_param_array
diff --git a/irods/session.py b/irods/session.py
index 1c7514b..ce42406 100644
--- a/irods/session.py
+++ b/irods/session.py
@@ -1,6 +1,8 @@
 from __future__ import absolute_import
 import os
 import json
+import errno
+import logging
 from irods.query import Query
 from irods.pool import Pool
 from irods.account import iRODSAccount
@@ -10,18 +12,34 @@ from irods.manager.metadata_manager import MetadataManager
 from irods.manager.access_manager import AccessManager
 from irods.manager.user_manager import UserManager, UserGroupManager
 from irods.manager.resource_manager import ResourceManager
+from irods.manager.zone_manager import ZoneManager
 from irods.exception import NetworkException
 from irods.password_obfuscation import decode
+from irods import NATIVE_AUTH_SCHEME, PAM_AUTH_SCHEME
 
+logger = logging.getLogger(__name__)
+
+class NonAnonymousLoginWithoutPassword(RuntimeError): pass
 
 class iRODSSession(object):
 
+    @property
+    def env_file (self):
+        return self._env_file
+
+    @property
+    def auth_file (self):
+        return self._auth_file
+
     def __init__(self, configure=True, **kwargs):
         self.pool = None
         self.numThreads = 0
-
+        self._env_file = ''
+        self._auth_file = ''
+        self.do_configure = (kwargs if configure else {})
+        self.__configured = None
         if configure:
-            self.configure(**kwargs)
+            self.__configured = self.configure(**kwargs)
 
         self.collections = CollectionManager(self)
         self.data_objects = DataObjectManager(self)
@@ -30,6 +48,7 @@ class iRODSSession(object):
         self.users = UserManager(self)
         self.user_groups = UserGroupManager(self)
         self.resources = ResourceManager(self)
+        self.zones = ZoneManager(self)
 
     def __enter__(self):
         return self
@@ -37,6 +56,13 @@ class iRODSSession(object):
     def __exit__(self, exc_type, exc_value, traceback):
         self.cleanup()
 
+    def __del__(self):
+        self.do_configure = {}
+        # If self.pool has been fully initialized (ie. no exception was
+        #   raised during __init__), then try to clean up.
+        if self.pool is not None:
+            self.cleanup()
+
     def cleanup(self):
         for conn in self.pool.active | self.pool.idle:
             try:
@@ -44,8 +70,11 @@ class iRODSSession(object):
             except NetworkException:
                 pass
             conn.release(True)
+        if self.do_configure: 
+            self.__configured = self.configure(**self.do_configure)
 
     def _configure_account(self, **kwargs):
+
         try:
             env_file = kwargs['irods_env_file']
 
@@ -62,7 +91,7 @@ class iRODSSession(object):
             return iRODSAccount(**kwargs)
 
         # Get credentials from irods environment file
-        creds = self.get_irods_env(env_file)
+        creds = self.get_irods_env(env_file, session_ = self)
 
         # Update with new keywords arguments only
         creds.update((key, value) for key, value in kwargs.items() if key not in creds)
@@ -74,7 +103,13 @@ class iRODSSession(object):
             # default
             auth_scheme = 'native'
 
-        if auth_scheme != 'native':
+        if auth_scheme.lower() == PAM_AUTH_SCHEME:
+            if 'password' in creds:
+                return iRODSAccount(**creds)
+            else:
+                # password will be from irodsA file therefore use native login
+                creds['irods_authentication_scheme'] = NATIVE_AUTH_SCHEME
+        elif auth_scheme != 'native':
             return iRODSAccount(**creds)
 
         # Native auth, try to unscramble password
@@ -83,14 +118,24 @@ class iRODSSession(object):
         except KeyError:
             pass
 
-        creds['password'] = self.get_irods_password(**creds)
+        missing_file_path = []
+        error_args = []
+        pw = creds['password'] = self.get_irods_password(session_ = self, file_path_if_not_found = missing_file_path, **creds)
+        if not pw and creds.get('irods_user_name') != 'anonymous':
+            if missing_file_path:
+                error_args += ["Authentication file not found at {!r}".format(missing_file_path[0])]
+            raise NonAnonymousLoginWithoutPassword(*error_args)
 
         return iRODSAccount(**creds)
 
-
     def configure(self, **kwargs):
-        account = self._configure_account(**kwargs)
-        self.pool = Pool(account)
+        account = self.__configured
+        if not account:
+            account = self._configure_account(**kwargs)
+        connection_refresh_time = self.get_connection_refresh_time(**kwargs)
+        logger.debug("In iRODSSession's configure(). connection_refresh_time set to {}".format(connection_refresh_time))
+        self.pool = Pool(account, application_name=kwargs.pop('application_name',''), connection_refresh_time=connection_refresh_time)
+        return account
 
     def query(self, *args):
         return Query(self, *args)
@@ -122,6 +167,15 @@ class iRODSSession(object):
             conn.release()
             return version
 
+    @property
+    def pam_pw_negotiated(self):
+            self.pool.account.store_pw = []
+            conn = self.pool.get_connection()
+            pw = getattr(self.pool.account,'store_pw',[])
+            delattr( self.pool.account, 'store_pw')
+            conn.release()
+            return pw
+
     @property
     def default_resource(self):
         return self.pool.account.default_resource
@@ -146,12 +200,20 @@ class iRODSSession(object):
             return os.path.expanduser('~/.irods/.irodsA')
 
     @staticmethod
-    def get_irods_env(env_file):
-        with open(env_file, 'rt') as f:
-            return json.load(f)
+    def get_irods_env(env_file, session_ = None):
+        try:
+            with open(env_file, 'rt') as f:
+                j = json.load(f)
+                if session_ is not None:
+                    session_._env_file = env_file
+                return j
+        except IOError:
+            logger.debug("Could not open file {}".format(env_file))
+            return {}
 
     @staticmethod
-    def get_irods_password(**kwargs):
+    def get_irods_password(session_ = None, file_path_if_not_found = (), **kwargs):
+        path_memo  = []
         try:
             irods_auth_file = kwargs['irods_authentication_file']
         except KeyError:
@@ -162,5 +224,41 @@ class iRODSSession(object):
         except KeyError:
             uid = None
 
-        with open(irods_auth_file, 'r') as f:
-            return decode(f.read().rstrip('\n'), uid)
+        _retval = ''
+
+        try:
+            with open(irods_auth_file, 'r') as f:
+                _retval = decode(f.read().rstrip('\n'), uid)
+                return _retval
+        except IOError as exc:
+            if exc.errno != errno.ENOENT:
+                raise  # Auth file exists but can't be read
+            path_memo = [ irods_auth_file ]
+            return ''                           # No auth file (as with anonymous user)
+        finally:
+            if isinstance(file_path_if_not_found, list) and path_memo:
+                file_path_if_not_found[:] = path_memo
+            if session_ is not None and _retval:
+                session_._auth_file = irods_auth_file
+
+    def get_connection_refresh_time(self, **kwargs):
+        connection_refresh_time = -1
+        
+        connection_refresh_time = int(kwargs.get('refresh_time', -1))
+        if connection_refresh_time != -1:
+            return connection_refresh_time
+
+        try:
+            env_file = kwargs['irods_env_file']
+        except KeyError:
+            return connection_refresh_time
+
+        if env_file is not None:
+            env_file_map = self.get_irods_env(env_file)
+            connection_refresh_time = int(env_file_map.get('irods_connection_refresh_time', -1))
+            if connection_refresh_time < 1:
+                # Negative values are not allowed.
+                logger.debug('connection_refresh_time in {} file has value of {}. Only values greater than 1 are allowed.'.format(env_file, connection_refresh_time))
+                connection_refresh_time = -1
+
+        return connection_refresh_time
diff --git a/irods/test/access_test.py b/irods/test/access_test.py
index 0d1c39f..77c636b 100644
--- a/irods/test/access_test.py
+++ b/irods/test/access_test.py
@@ -4,7 +4,12 @@ import os
 import sys
 import unittest
 from irods.access import iRODSAccess
+from irods.user import iRODSUser
+from irods.session import iRODSSession
+from irods.models import User,Collection,DataObject
+from irods.collection import iRODSCollection
 import irods.test.helpers as helpers
+from irods.column import In, Like
 
 
 class TestAccess(unittest.TestCase):
@@ -22,6 +27,7 @@ class TestAccess(unittest.TestCase):
         self.coll.remove(recurse=True, force=True)
         self.sess.cleanup()
 
+
     def test_list_acl(self):
         # test args
         collection = self.coll_path
@@ -57,6 +63,100 @@ class TestAccess(unittest.TestCase):
         # remove object
         self.sess.data_objects.unlink(path)
 
+
+    def test_set_inherit_acl(self):
+
+        acl1 = iRODSAccess('inherit', self.coll_path)
+        self.sess.permissions.set(acl1)
+        c = self.sess.collections.get(self.coll_path)
+        self.assertTrue(c.inheritance)
+
+        acl2 = iRODSAccess('noinherit', self.coll_path)
+        self.sess.permissions.set(acl2)
+        c = self.sess.collections.get(self.coll_path)
+        self.assertFalse(c.inheritance)
+
+    def test_set_inherit_and_test_sub_objects (self):
+        DEPTH = 3
+        OBJ_PER_LVL = 1
+        deepcoll = user = None
+        test_coll_path = self.coll_path + "/test"
+        try:
+            deepcoll = helpers.make_deep_collection(self.sess, test_coll_path, object_content = 'arbitrary',
+                                                    depth=DEPTH, objects_per_level=OBJ_PER_LVL)
+            user = self.sess.users.create('bob','rodsuser')
+            user.modify ('password','bpass')
+
+            acl_inherit = iRODSAccess('inherit', deepcoll.path)
+            acl_read = iRODSAccess('read', deepcoll.path, 'bob')
+
+            self.sess.permissions.set(acl_read)
+            self.sess.permissions.set(acl_inherit)
+
+            # create one new object and one new collection *after* ACL's are applied
+            new_object_path = test_coll_path + "/my_data_obj"
+            with self.sess.data_objects.open( new_object_path ,'w') as f: f.write(b'some_content')
+
+            new_collection_path = test_coll_path + "/my_colln_obj"
+            new_collection = self.sess.collections.create( new_collection_path )
+
+            coll_IDs = [c[Collection.id] for c in
+                            self.sess.query(Collection.id).filter(Like(Collection.name , deepcoll.path + "%"))]
+
+            D_rods = list(self.sess.query(Collection.name,DataObject.name).filter(
+                                                                          In(DataObject.collection_id, coll_IDs )))
+
+            self.assertEqual (len(D_rods), OBJ_PER_LVL*DEPTH+1) # counts the 'older' objects plus one new object
+
+            with iRODSSession (port=self.sess.port, zone=self.sess.zone, host=self.sess.host,
+                               user='bob', password='bpass') as bob:
+
+                D = list(bob.query(Collection.name,DataObject.name).filter(
+                                                                    In(DataObject.collection_id, coll_IDs )))
+
+                # - bob should only see the new data object, but none existing before ACLs were applied
+
+                self.assertEqual( len(D), 1 )
+                D_names = [_[Collection.name] + "/" + _[DataObject.name] for _ in D]
+                self.assertEqual( D[0][DataObject.name], 'my_data_obj' )
+
+                # - bob should be able to read the new data object
+
+                with bob.data_objects.get(D_names[0]).open('r') as f:
+                    self.assertGreater( len(f.read()), 0)
+
+                C = list(bob.query(Collection).filter( In(Collection.id, coll_IDs )))
+                self.assertEqual( len(C), 2 ) # query should return only the top-level and newly created collections
+                self.assertEqual( sorted([c[Collection.name] for c in C]),
+                                  sorted([new_collection.path, deepcoll.path]) )
+        finally:
+            if user: user.remove()
+            if deepcoll: deepcoll.remove(force = True, recurse = True)
+
+    def test_set_inherit_acl_depth_test(self):
+        DEPTH = 3  # But test is valid for any DEPTH > 1
+        for recursionTruth in (True, False):
+            deepcoll = None
+            try:
+                test_coll_path = self.coll_path + "/test"
+                deepcoll = helpers.make_deep_collection(self.sess, test_coll_path, depth=DEPTH, objects_per_level=2)
+                acl1 = iRODSAccess('inherit', deepcoll.path)
+                self.sess.permissions.set( acl1, recursive = recursionTruth )
+                test_subcolls = set( iRODSCollection(self.sess.collections,_)
+                                for _ in self.sess.query(Collection).filter(Like(Collection.name, deepcoll.path + "/%")) )
+
+                # assert top level collection affected
+                test_coll = self.sess.collections.get(test_coll_path)
+                self.assertTrue( test_coll.inheritance )
+                #
+                # assert lower level collections affected only for case when recursive = True
+                subcoll_truths = [ (_.inheritance == recursionTruth) for _ in test_subcolls ]
+                self.assertEqual( len(subcoll_truths), DEPTH-1 )
+                self.assertTrue( all(subcoll_truths) )
+            finally:
+                if deepcoll: deepcoll.remove(force = True, recurse = True)
+
+
     def test_set_data_acl(self):
         # test args
         collection = self.coll_path
@@ -114,6 +214,48 @@ class TestAccess(unittest.TestCase):
         acl1 = iRODSAccess('own', coll.path, user.name, user.zone)
         self.sess.permissions.set(acl1)
 
+    mapping = dict( [ (i,i) for i in ('modify object', 'read object', 'own') ] +
+                    [ ('write','modify object') , ('read', 'read object') ]
+                  )
+
+    @classmethod
+    def perms_lists_symm_diff ( cls, a_iter, b_iter ):
+        fields = lambda perm: (cls.mapping[perm.access_name], perm.user_name, perm.user_zone)
+        A = set (map(fields,a_iter))
+        B = set (map(fields,b_iter))
+        return (A-B) | (B-A)
+
+    def test_raw_acls__207(self):
+        data = helpers.make_object(self.sess,"/".join((self.coll_path,"test_obj")))
+        eg = eu = fg = fu = None
+        try:
+            eg = self.sess.user_groups.create ('egrp')
+            eu = self.sess.users.create ('edith','rodsuser')
+            eg.addmember(eu.name,eu.zone)
+            fg = self.sess.user_groups.create ('fgrp')
+            fu = self.sess.users.create ('frank','rodsuser')
+            fg.addmember(fu.name,fu.zone)
+            my_ownership = set([('own', self.sess.username, self.sess.zone)])
+            #--collection--
+            perms1data = [ iRODSAccess ('write',self.coll_path, eg.name, self.sess.zone),
+                           iRODSAccess ('read', self.coll_path, fu.name, self.sess.zone)
+                         ]
+            for perm in perms1data: self.sess.permissions.set ( perm )
+            p1 = self.sess.permissions.get ( self.coll, report_raw_acls = True)
+            self.assertEqual(self.perms_lists_symm_diff( perms1data, p1 ), my_ownership)
+            #--data object--
+            perms2data = [ iRODSAccess ('write',data.path, fg.name, self.sess.zone),
+                           iRODSAccess ('read', data.path, eu.name, self.sess.zone)
+                         ]
+            for perm in perms2data: self.sess.permissions.set ( perm )
+            p2 = self.sess.permissions.get ( data, report_raw_acls = True)
+            self.assertEqual(self.perms_lists_symm_diff( perms2data, p2 ), my_ownership)
+        finally:
+            ids_for_delete = [ u.id for u in (fu,fg,eu,eg) if u is not None ]
+            for u in [ iRODSUser(self.sess.users,row)
+                       for row in self.sess.query(User).filter(In(User.id, ids_for_delete)) ]:
+                u.remove()
+
 
 if __name__ == '__main__':
     # let the tests find the parent irods lib
diff --git a/irods/test/admin_test.py b/irods/test/admin_test.py
index b341cb1..6f57508 100644
--- a/irods/test/admin_test.py
+++ b/irods/test/admin_test.py
@@ -8,6 +8,7 @@ from irods.exception import UserDoesNotExist, ResourceDoesNotExist
 from irods.session import iRODSSession
 from irods.resource import iRODSResource
 import irods.test.helpers as helpers
+import irods.keywords as kw
 
 
 class TestAdmin(unittest.TestCase):
@@ -153,30 +154,33 @@ class TestAdmin(unittest.TestCase):
         session.resources.add_child(comp.name, ufs1.name, 'archive')
         session.resources.add_child(comp.name, ufs2.name, 'cache')
 
-        # create object on compound resource
-        obj = session.data_objects.create(obj_path, comp.name)
+        obj = None
 
-        # write to object
-        with obj.open('w+') as obj_desc:
-            obj_desc.write(dummy_str)
+        try:
+            # create object on compound resource
+            obj = session.data_objects.create(obj_path, resource = comp.name)
 
-        # refresh object
-        obj = session.data_objects.get(obj_path)
+            # write to object
+            with obj.open('w+',**{kw.DEST_RESC_NAME_KW:comp.name}) as obj_desc:
+                obj_desc.write(dummy_str)
 
-        # check that we have 2 replicas
-        self.assertEqual(len(obj.replicas), 2)
+            # refresh object
+            obj = session.data_objects.get(obj_path)
 
-        # remove object
-        obj.unlink(force=True)
+            # check that we have 2 replicas
+            self.assertEqual(len(obj.replicas), 2)
+        finally:
+            # remove object
+            if obj: obj.unlink(force=True)
 
-        # remove children from compound resource
-        session.resources.remove_child(comp.name, ufs1.name)
-        session.resources.remove_child(comp.name, ufs2.name)
+            # remove children from compound resource
+            session.resources.remove_child(comp.name, ufs1.name)
+            session.resources.remove_child(comp.name, ufs2.name)
 
-        # remove resources
-        ufs1.remove()
-        ufs2.remove()
-        comp.remove()
+            # remove resources
+            ufs1.remove()
+            ufs2.remove()
+            comp.remove()
 
 
     def test_get_resource_children(self):
@@ -263,6 +267,9 @@ class TestAdmin(unittest.TestCase):
 
 
     def test_make_ufs_resource(self):
+        RESC_PATH_BASE = helpers.irods_shared_tmp_dir()
+        if not(RESC_PATH_BASE) and not helpers.irods_session_host_local (self.sess):
+            self.skipTest('for non-local server with shared tmp dir missing')
         # test data
         resc_name = 'temporary_test_resource'
         if self.sess.server_version < (4, 0, 0):
@@ -304,7 +311,9 @@ class TestAdmin(unittest.TestCase):
         obj = self.sess.data_objects.create(obj_path, resc_name)
 
         # write something to the file
-        with obj.open('w+') as obj_desc:
+        # (can omit use of DEST_RESC_NAME_KW on resolution of
+        #  https://github.com/irods/irods/issues/5548 )
+        with obj.open('w+', **{kw.DEST_RESC_NAME_KW: resc_name} ) as obj_desc:
             obj_desc.write(dummy_str)
 
         # refresh object (size has changed)
@@ -352,6 +361,46 @@ class TestAdmin(unittest.TestCase):
             self.sess.users.get(self.new_user_name)
 
 
+    def test_set_user_comment(self):
+        # make a new user
+        self.sess.users.create(self.new_user_name, self.new_user_type)
+
+        # modify user comment
+        new_comment = '''comment-abc123!"#$%&'()*+,-./:;<=>?@[\]^_{|}~Z''' # omitting backtick due to #170
+        self.sess.users.modify(self.new_user_name, 'comment', new_comment)
+
+        # check comment was modified
+        new_user = self.sess.users.get(self.new_user_name)
+        self.assertEqual(new_user.comment, new_comment)
+
+        # delete new user
+        self.sess.users.remove(self.new_user_name)
+
+        # user should be gone
+        with self.assertRaises(UserDoesNotExist):
+            self.sess.users.get(self.new_user_name)
+
+
+    def test_set_user_info(self):
+        # make a new user
+        self.sess.users.create(self.new_user_name, self.new_user_type)
+
+        # modify user info
+        new_info = '''info-abc123!"#$%&'()*+,-./:;<=>?@[\]^_{|}~Z''' # omitting backtick due to #170
+        self.sess.users.modify(self.new_user_name, 'info', new_info)
+
+        # check info was modified
+        new_user = self.sess.users.get(self.new_user_name)
+        self.assertEqual(new_user.info, new_info)
+
+        # delete new user
+        self.sess.users.remove(self.new_user_name)
+
+        # user should be gone
+        with self.assertRaises(UserDoesNotExist):
+            self.sess.users.get(self.new_user_name)
+
+
 if __name__ == '__main__':
     # let the tests find the parent irods lib
     sys.path.insert(0, os.path.abspath('../..'))
diff --git a/irods/test/collection_test.py b/irods/test/collection_test.py
index fc811dd..e64b388 100644
--- a/irods/test/collection_test.py
+++ b/irods/test/collection_test.py
@@ -5,12 +5,15 @@ import sys
 import socket
 import shutil
 import unittest
+import time
 from irods.meta import iRODSMetaCollection
 from irods.exception import CollectionDoesNotExist
 from irods.models import Collection, DataObject
 import irods.test.helpers as helpers
 import irods.keywords as kw
 from six.moves import range
+from irods.test.helpers import my_function_name, unique_name
+from irods.collection import iRODSCollection
 
 
 class TestCollection(unittest.TestCase):
@@ -33,6 +36,13 @@ class TestCollection(unittest.TestCase):
         coll = self.sess.collections.get(self.test_coll_path)
         self.assertEqual(self.test_coll_path, coll.path)
 
+    def test_irods_collection_information(self):
+        coll = self.sess.collections.get(self.test_coll_path)
+        self.assertIsNotNone(coll.create_time)
+        self.assertIsNotNone(coll.modify_time)
+        self.assertFalse(coll.inheritance)
+        self.assertIsNotNone(coll.owner_name)
+        self.assertIsNotNone(coll.owner_zone)
 
     def test_append_to_collection(self):
         """ Append a new file to the collection"""
@@ -241,13 +251,15 @@ class TestCollection(unittest.TestCase):
 
 
     def test_register_collection(self):
-        if self.sess.host not in ('localhost', socket.gethostname()):
+        tmp_dir = helpers.irods_shared_tmp_dir()
+        loc_server = self.sess.host in ('localhost', socket.gethostname())
+        if not(tmp_dir) and not(loc_server):
             self.skipTest('Requires access to server-side file(s)')
 
         # test vars
         file_count = 10
         dir_name = 'register_test_dir'
-        dir_path = os.path.join('/tmp', dir_name)
+        dir_path = os.path.join((tmp_dir or '/tmp'), dir_name)
         coll_path = '{}/{}'.format(self.test_coll.path, dir_name)
 
         # make test dir
@@ -272,13 +284,15 @@ class TestCollection(unittest.TestCase):
 
 
     def test_register_collection_with_checksums(self):
-        if self.sess.host not in ('localhost', socket.gethostname()):
+        tmp_dir = helpers.irods_shared_tmp_dir()
+        loc_server = self.sess.host in ('localhost', socket.gethostname())
+        if not(tmp_dir) and not(loc_server):
             self.skipTest('Requires access to server-side file(s)')
 
         # test vars
         file_count = 10
-        dir_name = 'register_test_dir'
-        dir_path = os.path.join('/tmp', dir_name)
+        dir_name = 'register_test_dir_with_chksums'
+        dir_path = os.path.join((tmp_dir or '/tmp'), dir_name)
         coll_path = '{}/{}'.format(self.test_coll.path, dir_name)
 
         # make test dir
@@ -311,6 +325,62 @@ class TestCollection(unittest.TestCase):
         shutil.rmtree(dir_path)
 
 
+    def test_collection_with_trailing_slash__323(self):
+        Home = helpers.home_collection(self.sess)
+        subcoll, dataobj = [unique_name(my_function_name(),time.time()) for x in range(2)]
+        subcoll_fullpath = "{}/{}".format(Home,subcoll)
+        subcoll_unnormalized = subcoll_fullpath + "/"
+        try:
+            # Test create and exists with trailing slashes.
+            self.sess.collections.create(subcoll_unnormalized)
+            c1 = self.sess.collections.get(subcoll_unnormalized)
+            c2 = self.sess.collections.get(subcoll_fullpath)
+            self.assertEqual(c1.id, c2.id)
+            self.assertTrue(self.sess.collections.exists(subcoll_unnormalized))
+
+            # Test data put to unnormalized collection name.
+            with open(dataobj, "wb") as f: f.write(b'hello')
+            self.sess.data_objects.put(dataobj, subcoll_unnormalized)
+            self.assertEqual(
+                self.sess.query(DataObject).filter(DataObject.name == dataobj).one()[DataObject.collection_id]
+               ,c1.id
+            )
+        finally:
+            if self.sess.collections.exists(subcoll_fullpath):
+                self.sess.collections.remove(subcoll_fullpath, recurse = True, force = True)
+            if os.path.exists(dataobj):
+                os.unlink(dataobj)
+
+
+    def test_concatenation__323(self):
+        coll = iRODSCollection.normalize_path('/zone/','/home/','/dan//','subdir///')
+        self.assertEqual(coll, '/zone/home/dan/subdir')
+
+    def test_object_paths_with_dot_and_dotdot__323(self):
+
+        normalize = iRODSCollection.normalize_path
+        session = self.sess
+        home = helpers.home_collection( session )
+
+        # Test requirement for collection names to be absolute
+        with self.assertRaises(iRODSCollection.AbsolutePathRequired):
+            normalize('../public', enforce_absolute = True)
+
+        # Test '.' and double slashes
+        public_home = normalize(home,'..//public/.//')
+        self.assertEqual(public_home, '/{sess.zone}/home/public'.format(sess = session))
+
+        # Test that '..' cancels last nontrival path element
+        subpath = normalize(home,'./collA/coll2/./../collB')
+        self.assertEqual(subpath, home + "/collA/collB")
+
+        # Test multiple '..'
+        home1 = normalize('/zone','holmes','public/../..','home','user')
+        self.assertEqual(home1, '/zone/home/user')
+        home2 = normalize('/zone','holmes','..','home','public','..','user')
+        self.assertEqual(home2, '/zone/home/user')
+
+
 if __name__ == "__main__":
     # let the tests find the parent irods lib
     sys.path.insert(0, os.path.abspath('../..'))
diff --git a/irods/test/connection_test.py b/irods/test/connection_test.py
index ef605c8..203a2c1 100644
--- a/irods/test/connection_test.py
+++ b/irods/test/connection_test.py
@@ -24,6 +24,9 @@ class TestConnections(unittest.TestCase):
     def test_connection_destructor(self):
         conn = self.sess.pool.get_connection()
         conn.__del__()
+        # These asserts confirm that disconnect() in connection destructor is called
+        self.assertIsNone(conn.socket)
+        self.assertTrue(conn._disconnected)
         conn.release(destroy=True)
 
     def test_failed_connection(self):
@@ -38,12 +41,17 @@ class TestConnections(unittest.TestCase):
         # set port back
         self.sess.pool.account.port = saved_port
 
-    def test_send_failure(self):
+    def test_1_multiple_disconnect(self):
         with self.sess.pool.get_connection() as conn:
-            # try to close connection twice, 2nd one should fail
+            # disconnect() may now be called multiple times without error.
+            # (Note, here it is called implicitly upon exiting the with-block.)
             conn.disconnect()
-            with self.assertRaises(NetworkException):
-                conn.disconnect()
+
+    def test_2_multiple_disconnect(self):
+        conn = self.sess.pool.get_connection()
+        # disconnect() may now be called multiple times without error.
+        conn.disconnect()
+        conn.disconnect()
 
     def test_reply_failure(self):
         with self.sess.pool.get_connection() as conn:
diff --git a/irods/test/data_obj_test.py b/irods/test/data_obj_test.py
index 0fc1d0e..6033d28 100644
--- a/irods/test/data_obj_test.py
+++ b/irods/test/data_obj_test.py
@@ -9,24 +9,54 @@ import base64
 import random
 import string
 import unittest
+import contextlib  # check if redundant
+import logging
+import io
+import re
+import time
+import concurrent.futures
+import xml.etree.ElementTree
+
 from irods.models import Collection, DataObject
-from irods.session import iRODSSession
 import irods.exception as ex
 from irods.column import Criterion
 from irods.data_object import chunks
 import irods.test.helpers as helpers
 import irods.keywords as kw
+from irods.manager import data_object_manager
+from irods.message import RErrorStack
+from irods.message import ( ET, XML_Parser_Type, default_XML_parser, current_XML_parser )
 from datetime import datetime
+from tempfile import NamedTemporaryFile
+from irods.test.helpers import (unique_name, my_function_name)
+import irods.parallel
+from irods.manager.data_object_manager import Server_Checksum_Warning
+
+
+def make_ufs_resc_in_tmpdir(session, base_name, allow_local = False):
+    tmpdir = helpers.irods_shared_tmp_dir()
+    if not tmpdir and allow_local:
+        tmpdir = os.getenv('TMPDIR') or '/tmp'
+    if not tmpdir:
+        raise RuntimeError("Must have filesystem path shareable with server.")
+    full_phys_dir = os.path.join(tmpdir,base_name)
+    if not os.path.exists(full_phys_dir): os.mkdir(full_phys_dir)
+    session.resources.create(base_name,'unixfilesystem',session.host,full_phys_dir)
+    return full_phys_dir
+
 
 class TestDataObjOps(unittest.TestCase):
 
-    def setUp(self):
-        self.sess = helpers.make_session()
 
+    from irods.test.helpers import (create_simple_resc)
+
+    def setUp(self):
         # Create test collection
+        self.sess = helpers.make_session()
         self.coll_path = '/{}/home/{}/test_dir'.format(self.sess.zone, self.sess.username)
         self.coll = helpers.make_collection(self.sess, self.coll_path)
-
+        with self.sess.pool.get_connection() as conn:
+            self.SERVER_VERSION = conn.server_version
 
     def tearDown(self):
         '''Remove test data and close connections
@@ -34,6 +64,212 @@ class TestDataObjOps(unittest.TestCase):
         self.coll.remove(recurse=True, force=True)
         self.sess.cleanup()
 
+    @staticmethod
+    def In_Memory_Stream():
+        return io.BytesIO() if sys.version_info < (3,) else io.StringIO()
+
+
+    @contextlib.contextmanager
+    def create_resc_hierarchy (self, Root, Leaf = None):
+        if not Leaf:
+            Leaf = 'simple_leaf_resc_' + unique_name (my_function_name(), datetime.now())
+            y_value = (Root,Leaf)
+        else:
+            y_value = ';'.join([Root,Leaf])
+        self.sess.resources.create(Leaf,'unixfilesystem',
+                               host = self.sess.host,
+                               path='/tmp/' + Leaf)
+        self.sess.resources.create(Root,'passthru')
+        self.sess.resources.add_child(Root,Leaf)
+        try:
+            yield  y_value
+        finally:
+            self.sess.resources.remove_child(Root,Leaf)
+            self.sess.resources.remove(Leaf)
+            self.sess.resources.remove(Root)
+
+    def test_data_write_stales_other_repls__ref_irods_5548(self):
+        test_data = 'irods_5548_testfile'
+        test_coll = '/{0.zone}/home/{0.username}'.format(self.sess)
+        test_path = test_coll + "/" + test_data
+        demoResc = self.sess.resources.get('demoResc').name
+        self.sess.data_objects.open(test_path, 'w',**{kw.DEST_RESC_NAME_KW: demoResc}).write(b'random dater')
+
+        with self.create_simple_resc() as newResc:
+            try:
+                with self.sess.data_objects.open(test_path, 'a', **{kw.DEST_RESC_NAME_KW: newResc}) as d:
+                    d.seek(0,2)
+                    d.write(b'z')
+                data = self.sess.data_objects.get(test_path)
+                statuses = { repl.resource_name: repl.status for repl in data.replicas }
+                self.assertEqual( '0', statuses[demoResc] )
+                self.assertEqual( '1', statuses[newResc] )
+            finally:
+                self.cleanup_data_object(test_path)
+
+
+    def cleanup_data_object(self,data_logical_path):
+        try:
+            self.sess.data_objects.get(data_logical_path).unlink(force = True)
+        except ex.DataObjectDoesNotExist:
+            pass
+
+
+    def write_and_check_replica_on_parallel_connections( self, data_object_path, root_resc, caller_func, required_num_replicas = 1, seconds_to_wait_for_replicas = 10):
+        """Helper function for testing irods/irods#5548 and irods/irods#5848.
+
+        Writes the  string "books\n" to a replica, but not as a single write operation.
+        It is done piecewise on two independent connections, essentially simulating parallel "put".
+        Then we assert the file contents and dispose of the data object."""
+
+        try:
+            self.sess.data_objects.create(data_object_path, resource = root_resc)
+            for _ in range( seconds_to_wait_for_replicas ):
+                if required_num_replicas <= len( self.sess.data_objects.get(data_object_path).replicas ): break
+                time.sleep(1)
+            else:
+                raise RuntimeError("Did not see %d replicas" % required_num_replicas)
+            fd1 = self.sess.data_objects.open(data_object_path, 'w', **{kw.DEST_RESC_NAME_KW: root_resc} )
+            (replica_token, hier_str) = fd1.raw.replica_access_info()
+            fd2 = self.sess.data_objects.open(data_object_path, 'a', finalize_on_close = False, **{kw.RESC_HIER_STR_KW: hier_str,
+                                                                                                   kw.REPLICA_TOKEN_KW: replica_token})
+            fd2.seek(4) ; fd2.write(b's\n')
+            fd1.write(b'book')
+            fd2.close()
+            fd1.close()
+            with self.sess.data_objects.open(data_object_path, 'r', **{kw.DEST_RESC_NAME_KW: root_resc} ) as f:
+                self.assertEqual(f.read(), b'books\n')
+        except Exception as e:
+            logging.debug('Exception %r in [%s], called from [%s]', e, my_function_name(), caller_func)
+            raise
+        finally:
+            if 'fd2' in locals() and not fd2.closed: fd2.close()
+            if 'fd1' in locals() and not fd1.closed: fd1.close()
+            self.cleanup_data_object( data_object_path )
+
+
+    def test_parallel_conns_to_repl_with_cousin__irods_5848(self):
+        """Cousins = resource nodes not sharing any common parent nodes."""
+        data_path = '/{0.zone}/home/{0.username}/cousin_resc_5848.dat'.format(self.sess)
+
+        #
+        # -- Create replicas of a data object under two different root resources and test parallel write: --
+
+        with self.create_simple_resc() as newResc:
+
+            # - create empty data object on demoResc
+            self.sess.data_objects.open(data_path, 'w',**{kw.DEST_RESC_NAME_KW: 'demoResc'})
+
+            # - replicate data object to newResc
+            self.sess.data_objects.get(data_path).replicate(newResc)
+
+            # - test whether a write to the replica on newResc functions correctly.
+            self.write_and_check_replica_on_parallel_connections( data_path, newResc, my_function_name(), required_num_replicas = 2)
+
+
+    def test_parallel_conns_with_replResc__irods_5848(self):
+        session = self.sess
+        replication_resource = None
+        ufs_resources = []
+        replication_resource = self.sess.resources.create('repl_resc_1_5848', 'replication')
+        number_of_replicas = 2
+        # -- Create replicas of a data object by opening it on a replication resource; then, test parallel write --
+        try:
+            # Build up the replication resource with `number_of_replicas' being the # of children
+            for i in range(number_of_replicas):
+                resource_name = unique_name(my_function_name(),i)
+                resource_type = 'unixfilesystem'
+                resource_host = session.host
+                resource_path = '/tmp/' + resource_name
+                ufs_resources.append(session.resources.create(
+                    resource_name, resource_type, resource_host, resource_path))
+                session.resources.add_child(replication_resource.name, resource_name)
+            data_path = '/{0.zone}/home/{0.username}/Replicated_5848.dat'.format(self.sess)
+
+            # -- Perform the check of writing by a single replica (which is unspecified, but one of the `number_of_replicas`
+            #    will be selected by voting)
+
+            self.write_and_check_replica_on_parallel_connections (data_path, replication_resource.name, my_function_name(), required_num_replicas = 2)
+        finally:
+            for resource in ufs_resources:
+                session.resources.remove_child(replication_resource.name, resource.name)
+                resource.remove()
+            if replication_resource:
+                replication_resource.remove()
+
+    def test_put_get_parallel_autoswitch_A__235(self):
+        if not self.sess.data_objects.should_parallelize_transfer(server_version_hint = self.SERVER_VERSION):
+            self.skipTest('Skip unless detected server version is 4.2.9')
+        if getattr(data_object_manager,'DEFAULT_NUMBER_OF_THREADS',None) in (1, None):
+            self.skipTest('Data object manager not configured for parallel puts and gets')
+        Root  = 'pt235'
+        Leaf  = 'resc235'
+        files_to_delete = []
+        # This test does the following:
+        #  - set up a small resource hierarchy and generate a file large enough to trigger parallel transfer
+        #  - `put' the file to iRODS, then `get' it back, comparing the resulting two disk files and making
+        #    sure that the parallel routines were invoked to do both transfers
+
+        with self.create_resc_hierarchy(Root) as (Root_ , Leaf):
+            self.assertEqual(Root , Root_)
+            self.assertIsInstance( Leaf, str)
+            datafile = NamedTemporaryFile (prefix='getfromhier_235_',delete=True)
+            datafile.write( os.urandom( data_object_manager.MAXIMUM_SINGLE_THREADED_TRANSFER_SIZE + 1 ))
+            datafile.flush()
+            base_name = os.path.basename(datafile.name)
+            data_obj_name = '/{0.zone}/home/{0.username}/{1}'.format(self.sess, base_name)
+            options = { kw.DEST_RESC_NAME_KW:Root,
+                        kw.RESC_NAME_KW:Root }
+
+            PUT_LOG = self.In_Memory_Stream()
+            GET_LOG = self.In_Memory_Stream()
+            NumThreadsRegex = re.compile('^num_threads\s*=\s*(\d+)',re.MULTILINE)
+
+            try:
+                with irods.parallel.enableLogging( logging.StreamHandler, (PUT_LOG,), level_=logging.INFO):
+                    self.sess.data_objects.put(datafile.name, data_obj_name, num_threads = 0, **options)  # - PUT
+                    match = NumThreadsRegex.search (PUT_LOG.getvalue())
+                    self.assertTrue (match is not None and int(match.group(1)) >= 1) # - PARALLEL code path taken?
+
+                with irods.parallel.enableLogging( logging.StreamHandler, (GET_LOG,), level_=logging.INFO):
+                    self.sess.data_objects.get(data_obj_name, datafile.name+".get", num_threads = 0, **options) # - GET
+                    match = NumThreadsRegex.search (GET_LOG.getvalue())
+                    self.assertTrue (match is not None and int(match.group(1)) >= 1) # - PARALLEL code path taken?
+
+                files_to_delete += [datafile.name + ".get"]
+
+                with open(datafile.name, "rb") as f1, open(datafile.name + ".get", "rb") as f2:
+                    self.assertEqual ( f1.read(), f2.read() )
+
+                q = self.sess.query (DataObject.name,DataObject.resc_hier).filter( DataObject.name == base_name,
+                                                                                   DataObject.resource_name == Leaf)
+                replicas = list(q)
+                self.assertEqual( len(replicas), 1 )
+                self.assertEqual( replicas[0][DataObject.resc_hier] , ';'.join([Root,Leaf]) )
+
+            finally:
+                self.sess.data_objects.unlink( data_obj_name, force = True)
+                for n in files_to_delete: os.unlink(n)
+
+    def test_open_existing_dataobj_in_resource_hierarchy__232(self):
+        Root  = 'pt1'
+        Leaf  = 'resc1'
+        with self.create_resc_hierarchy(Root,Leaf) as hier_str:
+            obj = None
+            try:
+                datafile = NamedTemporaryFile (prefix='getfromhier_232_',delete=True)
+                datafile.write(b'abc\n')
+                datafile.flush()
+                fname = datafile.name
+                bname = os.path.basename(fname)
+                LOGICAL = self.coll_path + '/' + bname
+                self.sess.data_objects.put(fname,LOGICAL, **{kw.DEST_RESC_NAME_KW:Root})
+                self.assertEqual([bname], [res[DataObject.name] for res in
+                                           self.sess.query(DataObject.name).filter(DataObject.resc_hier == hier_str)])
+                obj = self.sess.data_objects.get(LOGICAL)
+                obj.open('a') # prior to #232 fix, raises DIRECT_CHILD_ACCESS
+            finally:
+                if obj: obj.unlink(force=True)
 
     def make_new_server_config_json(self, server_config_filename):
         # load server_config.json to inject a new rule base
@@ -55,6 +291,111 @@ class TestDataObjOps(unittest.TestCase):
                 sha256.update(chunk)
         return sha256.hexdigest()
 
+    def test_routine_verify_chksum_operation( self ):
+
+        if self.sess.server_version < (4, 2, 11):
+            self.skipTest('iRODS servers < 4.2.11 do not raise a checksum warning')
+
+        dobj_path =  '/{0.zone}/home/{0.username}/verify_chksum.dat'.format(self.sess)
+        self.sess.data_objects.create(dobj_path)
+        try:
+            with self.sess.data_objects.open(dobj_path,'w') as f:
+                f.write(b'abcd')
+            checksum = self.sess.data_objects.chksum(dobj_path)
+            self.assertGreater(len(checksum),0)
+            r_err_stk = RErrorStack()
+            warning = None
+            try:
+                self.sess.data_objects.chksum(dobj_path, **{'r_error': r_err_stk, kw.VERIFY_CHKSUM_KW:''})
+            except Server_Checksum_Warning as exc_:
+                warning = exc_
+            # There's one replica and it has a checksum, so expect no errors or hints from error stack.
+            self.assertIsNone(warning)
+            self.assertEqual(0, len(r_err_stk))
+        finally:
+            self.sess.data_objects.unlink(dobj_path, force = True)
+
+    def test_verify_chksum__282_287( self ):
+
+        if self.sess.server_version < (4, 2, 11):
+            self.skipTest('iRODS servers < 4.2.11 do not raise a checksum warning')
+
+        with self.create_simple_resc() as R, self.create_simple_resc() as R2, NamedTemporaryFile(mode = 'wb') as f:
+            f.write(b'abcxyz\n')
+            f.flush()
+            coll_path = '/{0.zone}/home/{0.username}' .format(self.sess)
+            dobj_path = coll_path + '/' + os.path.basename(f.name)
+            Data = self.sess.data_objects
+            r_err_stk = RErrorStack()
+            try:
+                demoR = self.sess.resources.get('demoResc').name  # Assert presence of demoResc and
+                Data.put( f.name, dobj_path )                     # Establish three replicas of data object.
+                Data.replicate( dobj_path, resource = R)
+                Data.replicate( dobj_path, resource = R2)
+                my_object = Data.get(dobj_path)
+
+                my_object.chksum( **{kw.RESC_NAME_KW:demoR} )  # Make sure demoResc has the only checksummed replica of the three.
+                my_object = Data.get(dobj_path)                # Refresh replica list to get checksum(s).
+
+                Baseline_repls_without_checksum = set( r.number for r in my_object.replicas if not r.checksum )
+
+                warn_exception = None
+                try:
+                    my_object.chksum( r_error = r_err_stk, **{kw.VERIFY_CHKSUM_KW:''} )   # Verify checksums without auto-vivify.
+                except Server_Checksum_Warning as warn:
+                    warn_exception = warn
+
+                self.assertIsNotNone(warn_exception, msg = "Expected exception of type [Server_Checksum_Warning] was not received.")
+
+                # -- Make sure integer codes are properly reflected for checksum warnings.
+                self.assertEqual (2, len([e for e in r_err_stk if e.status_ == ex.rounded_code('CAT_NO_CHECKSUM_FOR_REPLICA')]))
+
+                NO_CHECKSUM_MESSAGE_PATTERN = re.compile( 'No\s+Checksum\s+Available.+\s+Replica\s\[(\d+)\]', re.IGNORECASE)
+
+                Reported_repls_without_checksum = set( int(match.group(1)) for match in [ NO_CHECKSUM_MESSAGE_PATTERN.search(e.raw_msg_)
+                                                                                          for e in r_err_stk ]
+                                                       if match is not None )
+
+                # Ensure that VERIFY_CHKSUM_KW reported all replicas lacking a checksum
+                self.assertEqual (Reported_repls_without_checksum,
+                                  Baseline_repls_without_checksum)
+            finally:
+                if Data.exists (dobj_path):
+                    Data.unlink (dobj_path, force = True)
+
+
+    def test_compute_chksum( self ):
+
+        with self.create_simple_resc() as R, NamedTemporaryFile(mode = 'wb') as f:
+            coll_path = '/{0.zone}/home/{0.username}' .format(self.sess)
+            dobj_path = coll_path + '/' + os.path.basename(f.name)
+            Data = self.sess.data_objects
+            try:
+                f.write(b'some content bytes ...\n')
+                f.flush()
+                Data.put( f.name, dobj_path )
+
+                # get original checksum and resource name
+                my_object = Data.get(dobj_path)
+                orig_resc = my_object.replicas[0].resource_name
+                chk1 = my_object.chksum()
+
+                # repl to new resource and iput to that new replica
+                Data.replicate( dobj_path, resource = R)
+                f.write(b'...added bytes\n')
+                f.flush()
+                Data.put( f.name, dobj_path, **{kw.DEST_RESC_NAME_KW: R,
+                                                kw.FORCE_FLAG_KW: '1'})
+                # compare checksums
+                my_object = Data.get(dobj_path)
+                chk2 = my_object.chksum( **{kw.RESC_NAME_KW : R} )
+                chk1b = my_object.chksum( **{kw.RESC_NAME_KW : orig_resc} )
+                self.assertEqual (chk1, chk1b)
+                self.assertNotEqual (chk1, chk2)
+
+            finally:
+                if Data.exists (dobj_path): Data.unlink (dobj_path, force = True)
+
 
     def test_obj_exists(self):
         obj_name = 'this_object_will_exist_once_made'
@@ -70,6 +411,20 @@ class TestDataObjOps(unittest.TestCase):
         self.assertFalse(self.sess.data_objects.exists(does_not_exist_path))
 
 
+    def test_create_from_invalid_path__250(self):
+        possible_exceptions = { ex.CAT_UNKNOWN_COLLECTION:  (lambda serv_vsn : serv_vsn >= (4,2,9)),
+                                ex.SYS_INVALID_INPUT_PARAM: (lambda serv_vsn : serv_vsn <= (4,2,8))
+                              }
+        raisedExc = None
+        try:
+            self.sess.data_objects.create('t')
+        except Exception as exc:
+            raisedExc = exc
+        server_version_cond = possible_exceptions.get(type(raisedExc))
+        self.assertTrue(server_version_cond is not None)
+        self.assertTrue(server_version_cond(self.sess.server_version))
+
+
     def test_rename_obj(self):
         # test args
         collection = self.coll_path
@@ -99,7 +454,7 @@ class TestDataObjOps(unittest.TestCase):
         self.assertEqual(obj.id, saved_id)
 
         # remove object
-        self.sess.data_objects.unlink(new_path)
+        self.sess.data_objects.unlink(new_path, force = True)
 
 
     def test_move_obj_to_coll(self):
@@ -131,6 +486,37 @@ class TestDataObjOps(unittest.TestCase):
         # remove new collection
         new_coll.remove(recurse=True, force=True)
 
+    def test_copy_existing_obj_to_relative_dest_fails_irods4796(self):
+        if self.sess.server_version <= (4, 2, 7):
+            self.skipTest('iRODS servers <= 4.2.7 will give nondescriptive error')
+        obj_name = 'this_object_will_exist_once_made'
+        exists_path = '{}/{}'.format(self.coll_path, obj_name)
+        helpers.make_object(self.sess, exists_path)
+        self.assertTrue(self.sess.data_objects.exists(exists_path))
+        non_existing_zone = 'this_zone_absent'
+        relative_dst_path = '{non_existing_zone}/{obj_name}'.format(**locals())
+        options = {}
+        with self.assertRaises(ex.USER_INPUT_PATH_ERR):
+            self.sess.data_objects.copy(exists_path, relative_dst_path, **options)
+
+    def test_copy_from_nonexistent_absolute_data_obj_path_fails_irods4796(self):
+        if self.sess.server_version <= (4, 2, 7):
+            self.skipTest('iRODS servers <= 4.2.7 will hang the client')
+        non_existing_zone = 'this_zone_absent'
+        src_path = '/{non_existing_zone}/non_existing.src'.format(**locals())
+        dst_path = '/{non_existing_zone}/non_existing.dst'.format(**locals())
+        options = {}
+        with self.assertRaises(ex.USER_INPUT_PATH_ERR):
+            self.sess.data_objects.copy(src_path, dst_path, **options)
+
+    def test_copy_from_relative_path_fails_irods4796(self):
+        if self.sess.server_version <= (4, 2, 7):
+            self.skipTest('iRODS servers <= 4.2.7 will hang the client')
+        src_path = 'non_existing.src'
+        dst_path = 'non_existing.dst'
+        options = {}
+        with self.assertRaises(ex.USER_INPUT_PATH_ERR):
+            self.sess.data_objects.copy(src_path, dst_path, **options)
 
     def test_copy_obj_to_obj(self):
         # test args
@@ -292,7 +678,7 @@ class TestDataObjOps(unittest.TestCase):
                 obj_path = "{collection}/{filename}".format(**locals())
                 contents = 'blah' * 100
                 checksum = base64.b64encode(
-                    hashlib.sha256(contents).digest()).decode()
+                    hashlib.sha256(contents.encode()).digest()).decode()
 
                 # make object in test collection
                 options = {kw.OPR_TYPE_KW: 1}   # PUT_OPR
@@ -353,7 +739,8 @@ class TestDataObjOps(unittest.TestCase):
                 # make pseudo-random test file
                 filename = 'test_put_file_trigger_pep.txt'
                 test_file = os.path.join('/tmp', filename)
-                contents = ''.join(random.choice(string.printable) for _ in range(1024))
+                contents = ''.join(random.choice(string.printable) for _ in range(1024)).encode()
+                contents = contents[:1024]
                 with open(test_file, 'wb') as f:
                     f.write(contents)
 
@@ -464,7 +851,6 @@ class TestDataObjOps(unittest.TestCase):
         # delete second resource
         self.sess.resources.remove(resc_name)
 
-
     def test_replica_number(self):
         if self.sess.server_version < (4, 0, 0):
             self.skipTest('For iRODS 4+')
@@ -482,7 +868,7 @@ class TestDataObjOps(unittest.TestCase):
         # make ufs resources
         ufs_resources = []
         for i in range(number_of_replicas):
-            resource_name = 'ufs{}'.format(i)
+            resource_name = unique_name(my_function_name(),i)
             resource_type = 'unixfilesystem'
             resource_host = session.host
             resource_path = '/tmp/' + resource_name
@@ -568,7 +954,7 @@ class TestDataObjOps(unittest.TestCase):
         # make ufs resources and replicate object
         ufs_resources = []
         for i in range(number_of_replicas):
-            resource_name = 'ufs{}'.format(i)
+            resource_name = unique_name(my_function_name(),i)
             resource_type = 'unixfilesystem'
             resource_host = session.host
             resource_path = '/tmp/{}'.format(resource_name)
@@ -606,6 +992,7 @@ class TestDataObjOps(unittest.TestCase):
         for resource in ufs_resources:
             resource.remove()
 
+
     def test_get_replica_size(self):
         session = self.sess
 
@@ -627,7 +1014,7 @@ class TestDataObjOps(unittest.TestCase):
         # make ufs resources
         ufs_resources = []
         for i in range(2):
-            resource_name = 'ufs{}'.format(i)
+            resource_name = unique_name(my_function_name(),i)
             resource_type = 'unixfilesystem'
             resource_host = session.host
             resource_path = '/tmp/{}'.format(resource_name)
@@ -666,6 +1053,7 @@ class TestDataObjOps(unittest.TestCase):
         for resource in ufs_resources:
             resource.remove()
 
+
     def test_obj_put_get(self):
         # Can't do one step open/create with older servers
         if self.sess.server_version <= (4, 1, 4):
@@ -825,6 +1213,46 @@ class TestDataObjOps(unittest.TestCase):
         os.remove(new_env_file)
 
 
+    def test_obj_put_and_return_data_object(self):
+        # Can't do one step open/create with older servers
+        if self.sess.server_version <= (4, 1, 4):
+            self.skipTest('For iRODS 4.1.5 and newer')
+
+        # make another UFS resource
+        session = self.sess
+        resource_name = 'ufs'
+        resource_type = 'unixfilesystem'
+        resource_host = session.host
+        resource_path = '/tmp/' + resource_name
+        session.resources.create(resource_name, resource_type, resource_host, resource_path)
+
+        # set default resource to new UFS resource
+        session.default_resource = resource_name
+
+        # make a local file with random text content
+        content = ''.join(random.choice(string.printable) for _ in range(1024))
+        filename = 'testfile.txt'
+        file_path = os.path.join('/tmp', filename)
+        with open(file_path, 'w') as f:
+            f.write(content)
+
+        # put file
+        collection = self.coll_path
+        obj_path = '{collection}/{filename}'.format(**locals())
+
+        new_file = session.data_objects.put(file_path, obj_path, return_data_object=True)
+
+        # get object and confirm resource
+        obj = session.data_objects.get(obj_path)
+        self.assertEqual(new_file.replicas[0].resource_name, obj.replicas[0].resource_name)
+
+        # cleanup
+        os.remove(file_path)
+        obj.unlink(force=True)
+        session.resources.remove(resource_name)
+
+
+
     def test_force_get(self):
         # Can't do one step open/create with older servers
         if self.sess.server_version <= (4, 1, 4):
@@ -856,82 +1284,133 @@ class TestDataObjOps(unittest.TestCase):
         os.remove(test_file)
 
 
-    def test_register(self):
+    def test_modDataObjMeta(self):
+        test_dir = helpers.irods_shared_tmp_dir()
         # skip if server is remote
-        if self.sess.host not in ('localhost', socket.gethostname()):
+        loc_server = self.sess.host in ('localhost', socket.gethostname())
+        if not(test_dir) and not (loc_server):
             self.skipTest('Requires access to server-side file(s)')
 
         # test vars
-        test_dir = '/tmp'
+        resc_name = 'testDataObjMetaResc'
         filename = 'register_test_file'
-        test_file = os.path.join(test_dir, filename)
         collection = self.coll.path
         obj_path = '{collection}/{filename}'.format(**locals())
+        test_path = make_ufs_resc_in_tmpdir(self.sess, resc_name, allow_local = loc_server)
+        test_file = os.path.join(test_path, filename)
 
         # make random 4K binary file
         with open(test_file, 'wb') as f:
             f.write(os.urandom(1024 * 4))
 
         # register file in test collection
-        self.sess.data_objects.register(test_file, obj_path)
+        self.sess.data_objects.register(test_file, obj_path, **{kw.RESC_NAME_KW:resc_name})
 
-        # confirm object presence
-        obj = self.sess.data_objects.get(obj_path)
+        qu = self.sess.query(Collection.id).filter(Collection.name == collection)
+        for res in qu:
+            collection_id = res[Collection.id]
 
-        # in a real use case we would likely
-        # want to leave the physical file on disk
-        obj.unregister()
+        qu = self.sess.query(DataObject.size, DataObject.modify_time).filter(DataObject.name == filename, DataObject.collection_id == collection_id)
+        for res in qu:
+            self.assertEqual(int(res[DataObject.size]), 1024 * 4)
+        self.sess.data_objects.modDataObjMeta({"objPath" : obj_path}, {"dataSize":1024, "dataModify":4096})
+
+        qu = self.sess.query(DataObject.size, DataObject.modify_time).filter(DataObject.name == filename, DataObject.collection_id == collection_id)
+        for res in qu:
+            self.assertEqual(int(res[DataObject.size]), 1024)
+            self.assertEqual(res[DataObject.modify_time], datetime.utcfromtimestamp(4096))
+
+        # leave physical file on disk
+        self.sess.data_objects.unregister(obj_path)
 
         # delete file
         os.remove(test_file)
 
 
-    def test_register_with_checksum(self):
-        # skip if server is remote
-        if self.sess.host not in ('localhost', socket.gethostname()):
-            self.skipTest('Requires access to server-side file(s)')
+    def test_get_data_objects(self):
+        # Can't do one step open/create with older servers
+        if self.sess.server_version <= (4, 1, 4):
+            self.skipTest('For iRODS 4.1.5 and newer')
 
         # test vars
         test_dir = '/tmp'
-        filename = 'register_test_file'
+        filename = 'get_data_objects_test_file'
         test_file = os.path.join(test_dir, filename)
         collection = self.coll.path
-        obj_path = '{collection}/{filename}'.format(**locals())
 
-        # make random 4K binary file
+        # make random 16byte binary file
+        original_size = 16
         with open(test_file, 'wb') as f:
-            f.write(os.urandom(1024 * 4))
+            f.write(os.urandom(original_size))
 
-        # register file in test collection
-        options = {kw.VERIFY_CHKSUM_KW: ''}
-        self.sess.data_objects.register(test_file, obj_path, **options)
+        # make ufs resources
+        ufs_resources = []
+        for i in range(2):
+            resource_name = unique_name(my_function_name(),i)
+            resource_type = 'unixfilesystem'
+            resource_host = self.sess.host
+            resource_path = '/tmp/{}'.format(resource_name)
+            ufs_resources.append(self.sess.resources.create(
+                resource_name, resource_type, resource_host, resource_path))
 
-        # confirm object presence and verify checksum
+
+        # make passthru resource and add ufs1 as a child
+        passthru_resource = self.sess.resources.create('pt', 'passthru')
+        self.sess.resources.add_child(passthru_resource.name, ufs_resources[1].name)
+
+        # put file in test collection and replicate
+        obj_path = '{collection}/{filename}'.format(**locals())
+        options = {kw.DEST_RESC_NAME_KW: ufs_resources[0].name}
+        self.sess.data_objects.put(test_file, '{collection}/'.format(**locals()), **options)
+        self.sess.data_objects.replicate(obj_path, passthru_resource.name)
+
+        # ensure that replica info is populated
         obj = self.sess.data_objects.get(obj_path)
+        for i in ["number","status","resource_name","path","resc_hier"]:
+            self.assertIsNotNone(obj.replicas[0].__getattribute__(i))
+            self.assertIsNotNone(obj.replicas[1].__getattribute__(i))
 
-        # don't use obj.path (aka logical path)
-        phys_path = obj.replicas[0].path
-        digest = helpers.compute_sha256_digest(phys_path)
-        self.assertEqual(obj.checksum, "sha2:{}".format(digest))
+        # ensure replica info is sensible
+        for i in range(2):
+            self.assertEqual(obj.replicas[i].number, i)
+            self.assertEqual(obj.replicas[i].status, '1')
+            self.assertEqual(obj.replicas[i].path.split('/')[-1], filename)
+            self.assertEqual(obj.replicas[i].resc_hier.split(';')[-1], ufs_resources[i].name)
 
-        # leave physical file on disk
-        obj.unregister()
+        self.assertEqual(obj.replicas[0].resource_name, ufs_resources[0].name)
+        if self.sess.server_version < (4, 2, 0):
+            self.assertEqual(obj.replicas[i].resource_name, passthru_resource.name)
+        else:
+            self.assertEqual(obj.replicas[i].resource_name, ufs_resources[1].name)
+        self.assertEqual(obj.replicas[1].resc_hier.split(';')[0], passthru_resource.name)
 
+        # remove object
+        obj.unlink(force=True)
         # delete file
         os.remove(test_file)
 
-    def test_modDataObjMeta(self):
-        # skip if server is remote
-        if self.sess.host not in ('localhost', socket.gethostname()):
-            self.skipTest('Requires access to server-side file(s)')
+        # remove resources
+        self.sess.resources.remove_child(passthru_resource.name, ufs_resources[1].name)
+        passthru_resource.remove()
+        for resource in ufs_resources:
+            resource.remove()
+
+
+    def test_register(self):
+        test_dir = helpers.irods_shared_tmp_dir()
+        loc_server = self.sess.host in ('localhost', socket.gethostname())
+        if not(test_dir) and not(loc_server):
+            self.skipTest('data_obj register requires server has access to local or shared files')
 
         # test vars
-        test_dir = '/tmp'
+        resc_name = "testRegisterOpResc"
         filename = 'register_test_file'
-        test_file = os.path.join(test_dir, filename)
         collection = self.coll.path
         obj_path = '{collection}/{filename}'.format(**locals())
 
+        test_path = make_ufs_resc_in_tmpdir(self.sess,resc_name, allow_local = loc_server)
+        test_file = os.path.join(test_path, filename)
+
         # make random 4K binary file
         with open(test_file, 'wb') as f:
             f.write(os.urandom(1024 * 4))
@@ -939,59 +1418,136 @@ class TestDataObjOps(unittest.TestCase):
         # register file in test collection
         self.sess.data_objects.register(test_file, obj_path)
 
-        qu = self.sess.query(Collection.id).filter(Collection.name == collection)
-        for res in qu:
-            collection_id = res[Collection.id]
-
-        qu = self.sess.query(DataObject.size, DataObject.modify_time).filter(DataObject.name == filename, DataObject.collection_id == collection_id)
-        for res in qu:
-            self.assertEqual(int(res[DataObject.size]), 1024 * 4)
-        self.sess.data_objects.modDataObjMeta({"objPath" : obj_path}, {"dataSize":1024, "dataModify":4096})
-
-        qu = self.sess.query(DataObject.size, DataObject.modify_time).filter(DataObject.name == filename, DataObject.collection_id == collection_id)
-        for res in qu:
-            self.assertEqual(int(res[DataObject.size]), 1024)
-            self.assertEqual(res[DataObject.modify_time], datetime.utcfromtimestamp(4096))
+        # confirm object presence
+        obj = self.sess.data_objects.get(obj_path)
 
-        # leave physical file on disk
-        self.sess.data_objects.unregister(obj_path)
+        # in a real use case we would likely
+        # want to leave the physical file on disk
+        obj.unregister()
 
         # delete file
         os.remove(test_file)
 
-    def test_register_with_xml_special_chars(self):
-        # skip if server is remote
-        if self.sess.host not in ('localhost', socket.gethostname()):
-            self.skipTest('Requires access to server-side file(s)')
+
+    def test_register_with_checksum(self):
+        test_dir = helpers.irods_shared_tmp_dir()
+        loc_server = self.sess.host in ('localhost', socket.gethostname())
+        if not(test_dir) and not(loc_server):
+            self.skipTest('data_obj register requires server has access to local or shared files')
 
         # test vars
-        test_dir = '/tmp'
-        filename = '''aaa'"<&test&>"'_file'''
-        test_file = os.path.join(test_dir, filename)
+        resc_name= 'regWithChksumResc'
+        filename = 'register_test_file'
         collection = self.coll.path
         obj_path = '{collection}/{filename}'.format(**locals())
 
+        test_path = make_ufs_resc_in_tmpdir(self.sess, resc_name, allow_local = loc_server)
+        test_file = os.path.join(test_path, filename)
+
         # make random 4K binary file
         with open(test_file, 'wb') as f:
             f.write(os.urandom(1024 * 4))
 
         # register file in test collection
-        print('registering [' + obj_path + ']')
-        self.sess.data_objects.register(test_file, obj_path)
+        options = {kw.VERIFY_CHKSUM_KW: '', kw.RESC_NAME_KW: resc_name}
+        self.sess.data_objects.register(test_file, obj_path, **options)
 
-        # confirm object presence
-        print('getting [' + obj_path + ']')
+        # confirm object presence and verify checksum
         obj = self.sess.data_objects.get(obj_path)
 
-        # in a real use case we would likely
-        # want to leave the physical file on disk
-        print('unregistering [' + obj.path + ']')
+        # don't use obj.path (aka logical path)
+        phys_path = obj.replicas[0].path
+        digest = helpers.compute_sha256_digest(phys_path)
+        self.assertEqual(obj.checksum, "sha2:{}".format(digest))
+
+        # leave physical file on disk
         obj.unregister()
 
         # delete file
         os.remove(test_file)
 
 
+    def test_object_names_with_nonprintable_chars (self):
+        if  (4,2,8) < self.sess.server_version < (4,2,11):
+            self.skipTest('4.2.9 and 4.2.10 are known to fail as apostrophes in object names are problematic')
+        test_dir = helpers.irods_shared_tmp_dir()
+        loc_server = self.sess.host in ('localhost', socket.gethostname())
+        if not(test_dir) and not(loc_server):
+            self.skipTest('data_obj register requires server has access to local or shared files')
+        temp_names = []
+        vault = ''
+        try:
+            resc_name = 'regWithNonPrintableNamesResc'
+            vault = make_ufs_resc_in_tmpdir(self.sess, resc_name, allow_local = loc_server)
+            def enter_file_into_irods( session, filename, **kw_opt ):
+                ET( XML_Parser_Type.QUASI_XML, session.server_version)
+                basename = os.path.basename(filename)
+                logical_path = '/{0.zone}/home/{0.username}/{basename}'.format(session,**locals())
+                bound_method = getattr(session.data_objects, kw_opt['method'])
+                bound_method( os.path.abspath(filename), logical_path, **kw_opt['options'] )
+                d = session.data_objects.get(logical_path)
+                Path_Good = (d.path == logical_path)
+                session.data_objects.unlink( logical_path, force = True )
+                session.cleanup()
+                return Path_Good
+            futr = []
+            threadpool = concurrent.futures.ThreadPoolExecutor()
+            fname = re.sub( r'[/]', '',
+                            ''.join(map(chr,range(1,128))) )
+            for opts in [
+                    {'method':'put',     'options':{}},
+                    {'method':'register','options':{kw.RESC_NAME_KW: resc_name}, 'dir':(test_dir or None)}
+                ]:
+                with NamedTemporaryFile(prefix=opts["method"]+"_"+fname, dir=opts.get("dir"), delete=False) as f:
+                    f.write(b'hello')
+                    temp_names += [f.name]
+                ses = helpers.make_session()
+                futr.append( threadpool.submit( enter_file_into_irods, ses, f.name, **opts ))
+            results = [ f.result() for f in futr ]
+            self.assertEqual (results, [True, True])
+        finally:
+            for name in temp_names:
+                if os.path.exists(name):
+                    os.unlink(name)
+            if vault:
+                self.sess.resources.remove( resc_name )
+        self.assertIs( default_XML_parser(), current_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())
+        if not(test_dir) and not(loc_server):
+            self.skipTest('data_obj register requires server has access to local or shared files')
+
+        # test vars
+        resc_name = 'regWithXmlSpecialCharsResc'
+        collection = self.coll.path
+        filename = '''aaa'"<&test&>"'_file'''
+        test_path = make_ufs_resc_in_tmpdir(self.sess, resc_name, allow_local = loc_server)
+        try:
+            test_file = os.path.join(test_path, filename)
+            obj_path = '{collection}/{filename}'.format(**locals())
+
+            # make random 4K binary file
+            with open(test_file, 'wb') as f:
+                f.write(os.urandom(1024 * 4))
+
+            # register file in test collection
+            self.sess.data_objects.register(test_file, obj_path, **{kw.RESC_NAME_KW: resc_name})
+
+            # confirm object presence
+            obj = self.sess.data_objects.get(obj_path)
+
+        finally:
+            # in a real use case we would likely
+            # want to leave the physical file on disk
+            obj.unregister()
+            # delete file
+            os.remove(test_file)
+            # delete resource
+            self.sess.resources.get(resc_name).remove()
+
+
 if __name__ == '__main__':
     # let the tests find the parent irods lib
     sys.path.insert(0, os.path.abspath('../..'))
diff --git a/irods/test/extended_test.py b/irods/test/extended_test.py
index 9a81dd4..884a4f1 100644
--- a/irods/test/extended_test.py
+++ b/irods/test/extended_test.py
@@ -1,4 +1,5 @@
 #! /usr/bin/env python
+from __future__ import print_function
 from __future__ import absolute_import
 import os
 import sys
@@ -9,21 +10,32 @@ import irods.test.helpers as helpers
 
 class TestContinueQuery(unittest.TestCase):
 
+    @classmethod
+    def setUpClass(cls):
+        # once only (before all tests), set up large collection
+        print ("Creating a large collection...", file = sys.stderr)
+        with helpers.make_session() as sess:
+            # Create test collection
+            cls.coll_path = '/{}/home/{}/test_dir'.format(sess.zone, sess.username)
+            cls.obj_count = 2500
+            cls.coll = helpers.make_test_collection( sess, cls.coll_path, cls.obj_count)
+
     def setUp(self):
+        # open the session (per-test)
         self.sess = helpers.make_session()
 
-        # Create test collection
-        self.coll_path = '/{}/home/{}/test_dir'.format(self.sess.zone, self.sess.username)
-        self.obj_count = 2500
-        self.coll = helpers.make_test_collection(
-            self.sess, self.coll_path, self.obj_count)
-
     def tearDown(self):
-        '''Remove test data and close connections
-        '''
-        self.coll.remove(recurse=True, force=True)
+        # close the session (per-test)
         self.sess.cleanup()
 
+    @classmethod
+    def tearDownClass(cls):
+        """Remove test data."""
+        # once only (after all tests), delete large collection
+        print ("Deleting the large collection...", file = sys.stderr)
+        with helpers.make_session() as sess:
+            sess.collections.remove(cls.coll_path, recurse=True, force=True)
+
     def test_walk_large_collection(self):
         for current_coll, subcolls, objects in self.coll.walk():
             # check number of objects
diff --git a/irods/test/force_create.py b/irods/test/force_create.py
new file mode 100644
index 0000000..5fd0a85
--- /dev/null
+++ b/irods/test/force_create.py
@@ -0,0 +1,52 @@
+#! /usr/bin/env python
+from __future__ import absolute_import
+import os
+import sys
+import unittest
+
+from irods.exception import OVERWRITE_WITHOUT_FORCE_FLAG
+import irods.test.helpers as helpers
+
+class TestForceCreate(unittest.TestCase):
+
+    def setUp(self):
+        self.sess = helpers.make_session()
+
+    def tearDown(self):
+        """Close connections."""
+        self.sess.cleanup()
+
+    # This test should pass whether or not federation is configured:
+    def test_force_create(self):
+        if self.sess.server_version > (4, 2, 8):
+            self.skipTest('force flag unneeded for create in iRODS > 4.2.8')
+        session = self.sess
+        FILE = '/{session.zone}/home/{session.username}/a.txt'.format(**locals())
+        try:
+            session.data_objects.unlink(FILE)
+        except:
+            pass
+        error = None
+        try:
+            session.data_objects.create(FILE)
+            session.data_objects.create(FILE)
+        except OVERWRITE_WITHOUT_FORCE_FLAG:
+            error = "OVERWRITE_WITHOUT_FORCE_FLAG"
+        self.assertEqual (error, "OVERWRITE_WITHOUT_FORCE_FLAG")
+        error = None
+        try:
+            session.data_objects.create(FILE, force=True)
+        except:
+            error = "Error creating with force"
+        self.assertEqual (error, None)
+        try:
+            session.data_objects.unlink(FILE)
+        except:
+            error = "Error cleaning up"
+        self.assertEqual (error, None)
+
+
+if __name__ == '__main__':
+    # let the tests find the parent irods lib
+    sys.path.insert(0, os.path.abspath('../..'))
+    unittest.main()
diff --git a/irods/test/helpers.py b/irods/test/helpers.py
index 76d9204..99bde9b 100644
--- a/irods/test/helpers.py
+++ b/irods/test/helpers.py
@@ -7,15 +7,90 @@ import shutil
 import hashlib
 import base64
 import math
+import socket
+import inspect
+import threading
+import random
+import datetime
+import json
 from pwd import getpwnam
 from irods.session import iRODSSession
 from irods.message import iRODSMessage
+from irods.password_obfuscation import encode
 from six.moves import range
 
 
+def my_function_name():
+    """Returns the name of the calling function or method"""
+    return inspect.getframeinfo(inspect.currentframe().f_back).function
+
+
+_thrlocal = threading.local()
+
+def unique_name(*seed_tuple):
+    '''For deterministic pseudo-random identifiers based on function/method name
+       to prevent e.g.  ICAT collisions within and between tests.  Example use:
+
+           def f(session):
+             seq_num = 1
+             a_name = unique_name( my_function_name(), seq_num # [, *optional_further_args]
+                                  )
+             seq_num += 1
+             session.resources.create( a_name, 'unixfilesystem', session.host, '/tmp/' + a_name )
+    '''
+    if not getattr(_thrlocal,"rand_gen",None) : _thrlocal.rand_gen = random.Random()
+    _thrlocal.rand_gen.seed(seed_tuple)
+    return '%016X' % _thrlocal.rand_gen.randint(0,(1<<64)-1)
+
+
+IRODS_SHARED_DIR = os.path.join( os.path.sep, 'irods_shared' )
+IRODS_SHARED_TMP_DIR = os.path.join(IRODS_SHARED_DIR,'tmp')
+IRODS_SHARED_REG_RESC_VAULT = os.path.join(IRODS_SHARED_DIR,'reg_resc')
+
+IRODS_REG_RESC = 'MyRegResc'
+
+def irods_shared_tmp_dir():
+    pth = IRODS_SHARED_TMP_DIR
+    can_write = False
+    if os.path.exists(pth):
+        try:     tempfile.NamedTemporaryFile(dir = pth)
+        except:  pass
+        else:    can_write = True 
+    return pth if can_write else ''
+
+def irods_shared_reg_resc_vault() :
+    vault = IRODS_SHARED_REG_RESC_VAULT
+    if os.path.exists(vault):
+        return vault
+    else:
+        return None
+
+def get_register_resource(session):
+    vault_path = irods_shared_reg_resc_vault()
+    Reg_Resc_Name = ''
+    if vault_path:
+        session.resources.create(IRODS_REG_RESC, 'unixfilesystem', session.host, vault_path)
+        Reg_Resc_Name = IRODS_REG_RESC
+    return Reg_Resc_Name
+
+
+def make_environment_and_auth_files( dir_, **params ):
+    if not os.path.exists(dir_): os.mkdir(dir_)
+    def recast(k):
+        return 'irods_' + k + ('_name' if k in ('user','zone') else '')
+    config = os.path.join(dir_,'irods_environment.json')
+    with open(config,'w') as f1:
+        json.dump({recast(k):v for k,v in params.items() if k != 'password'},f1,indent=4)
+    auth = os.path.join(dir_,'.irodsA')
+    with open(auth,'w') as f2:
+        f2.write(encode(params['password']))
+    os.chmod(auth,0o600)
+    return (config, auth)
+
+
 def make_session(**kwargs):
     try:
-        env_file = kwargs['irods_env_file']
+        env_file = kwargs.pop('irods_env_file')
     except KeyError:
         try:
             env_file = os.environ['IRODS_ENVIRONMENT_FILE']
@@ -28,7 +103,11 @@ def make_session(**kwargs):
     except KeyError:
         uid = None
 
-    return iRODSSession(irods_authentication_uid=uid, irods_env_file=env_file)
+    return iRODSSession( irods_authentication_uid = uid, irods_env_file = env_file, **kwargs )
+
+
+def home_collection(session):
+    return "/{0.zone}/home/{0.username}".format(session)
 
 
 def make_object(session, path, content=None, **options):
@@ -37,10 +116,14 @@ def make_object(session, path, content=None, **options):
 
     content = iRODSMessage.encode_unicode(content)
 
-    # 2 step open-create necessary for iRODS 4.1.4 or older
-    obj = session.data_objects.create(path)
-    with obj.open('w', **options) as obj_desc:
-        obj_desc.write(content)
+    if session.server_version <= (4,1,4):
+        # 2 step open-create necessary for iRODS 4.1.4 or older
+        obj = session.data_objects.create(path)
+        with obj.open('w', **options) as obj_desc:
+            obj_desc.write(content)
+    else:
+        with session.data_objects.open(path, 'w', **options) as obj_desc:
+            obj_desc.write(content)
 
     # refresh object after write
     return session.data_objects.get(path)
@@ -109,6 +192,38 @@ def make_flat_test_dir(dir_path, file_count=10, file_size=1024):
         with open(file_path, 'wb') as f:
             f.write(os.urandom(file_size))
 
+@contextlib.contextmanager
+def create_simple_resc (self, rescName = None):
+    if not rescName: 
+        rescName =  'simple_resc_' + unique_name (my_function_name() + '_simple_resc', datetime.datetime.now())
+    created = False
+    try:
+        self.sess.resources.create(rescName,
+                                   'unixfilesystem',
+                                   host = self.sess.host,
+                                   path = '/tmp/' + rescName)
+        created = True
+        yield rescName
+    finally:
+        if created:
+            self.sess.resources.remove(rescName)
+
+@contextlib.contextmanager
+def create_simple_resc_hierarchy (self, Root, Leaf):
+    d = tempfile.mkdtemp()
+    self.sess.resources.create(Leaf,'unixfilesystem',
+                           host = self.sess.host,
+                           path=d)
+    self.sess.resources.create(Root,'passthru')
+    self.sess.resources.add_child(Root,Leaf)
+    try:
+        yield ';'.join([Root,Leaf])
+    finally:
+        self.sess.resources.remove_child(Root,Leaf)
+        self.sess.resources.remove(Leaf)
+        self.sess.resources.remove(Root)
+        shutil.rmtree(d)
+
 
 def chunks(f, chunksize=io.DEFAULT_BUFFER_SIZE):
     return iter(lambda: f.read(chunksize), b'')
@@ -124,6 +239,17 @@ def compute_sha256_digest(file_path):
     return base64.b64encode(hasher.digest()).decode()
 
 
+def remove_unused_metadata(session):
+    from irods.message import GeneralAdminRequest
+    from irods.api_number import api_number
+    message_body = GeneralAdminRequest( 'rm', 'unusedAVUs', '','','','')
+    req = iRODSMessage("RODS_API_REQ", msg = message_body,int_info=api_number['GENERAL_ADMIN_AN'])
+    with session.pool.get_connection() as conn:
+        conn.send(req)
+        response=conn.recv()
+        if (response.int_info != 0): raise RuntimeError("Error removing unused AVUs")
+
+
 @contextlib.contextmanager
 def file_backed_up(filename):
     with tempfile.NamedTemporaryFile(prefix=os.path.basename(filename)) as f:
@@ -132,3 +258,8 @@ def file_backed_up(filename):
             yield filename
         finally:
             shutil.copyfile(f.name, filename)
+
+
+def irods_session_host_local (sess):
+    return socket.gethostbyname(sess.host) == \
+           socket.gethostbyname(socket.gethostname())
diff --git a/irods/test/login_auth_test.py b/irods/test/login_auth_test.py
new file mode 100644
index 0000000..9e9930f
--- /dev/null
+++ b/irods/test/login_auth_test.py
@@ -0,0 +1,510 @@
+#! /usr/bin/env python
+from __future__ import print_function
+from __future__ import absolute_import
+import os
+import sys
+import tempfile
+import unittest
+import textwrap
+import json
+import shutil
+import ssl
+import irods.test.helpers as helpers
+from irods.connection import Connection
+from irods.session import iRODSSession, NonAnonymousLoginWithoutPassword
+from irods.rule import Rule
+from irods.models import User
+from socket import gethostname
+from irods.password_obfuscation import (encode as pw_encode)
+from irods.connection import PlainTextPAMPasswordError
+from irods.access import iRODSAccess
+import irods.exception as ex
+import contextlib
+import socket
+from re import compile as regex
+import gc
+import six
+
+try:
+    from re import _pattern_type as regex_type
+except ImportError:
+    from re import Pattern as regex_type  # Python 3.7+
+
+
+def json_file_update(fname,keys_to_delete=(),**kw):
+    with open(fname,'r') as f:
+        j = json.load(f)
+    j.update(**kw)
+    for k in keys_to_delete:
+        if k in j: del j [k]
+        elif isinstance(k,regex_type):
+            jk = [i for i in j.keys() if k.search(i)]
+            for ky in jk: del j[ky]
+    with open(fname,'w') as out:
+        json.dump(j, out, indent=4)
+
+def env_dir_fullpath(authtype):  return os.path.join( os.environ['HOME'] , '.irods.' + authtype)
+def json_env_fullpath(authtype):  return os.path.join( env_dir_fullpath(authtype), 'irods_environment.json')
+def secrets_fullpath(authtype):   return os.path.join( env_dir_fullpath(authtype), '.irodsA')
+
+SERVER_ENV_PATH = os.path.expanduser('~irods/.irods/irods_environment.json')
+
+SERVER_ENV_SSL_SETTINGS = {
+    "irods_ssl_certificate_chain_file": "/etc/irods/ssl/irods.crt",
+    "irods_ssl_certificate_key_file": "/etc/irods/ssl/irods.key",
+    "irods_ssl_dh_params_file": "/etc/irods/ssl/dhparams.pem",
+    "irods_ssl_ca_certificate_file": "/etc/irods/ssl/irods.crt",
+    "irods_ssl_verify_server": "cert"
+}
+
+def update_service_account_for_SSL():
+    json_file_update( SERVER_ENV_PATH, **SERVER_ENV_SSL_SETTINGS )
+
+CLIENT_OPTIONS_FOR_SSL = {
+    "irods_client_server_policy": "CS_NEG_REQUIRE",
+    "irods_client_server_negotiation": "request_server_negotiation",
+    "irods_ssl_ca_certificate_file": "/etc/irods/ssl/irods.crt",
+    "irods_ssl_verify_server": "cert",
+    "irods_encryption_key_size": 16,
+    "irods_encryption_salt_size": 8,
+    "irods_encryption_num_hash_rounds": 16,
+    "irods_encryption_algorithm": "AES-256-CBC"
+}
+
+
+def client_env_from_server_env(user_name, auth_scheme=""):
+    cli_env = {}
+    with open(SERVER_ENV_PATH) as f:
+        srv_env = json.load(f)
+        for k in [ "irods_host", "irods_zone_name", "irods_port"  ]:
+            cli_env [k] = srv_env[k]
+    cli_env["irods_user_name"] = user_name
+    if auth_scheme:
+        cli_env["irods_authentication_scheme"] = auth_scheme
+    return cli_env
+
+@contextlib.contextmanager
+def pam_password_in_plaintext(allow=True):
+    saved = bool(Connection.DISALLOWING_PAM_PLAINTEXT)
+    try:
+        Connection.DISALLOWING_PAM_PLAINTEXT = not(allow)
+        yield
+    finally:
+        Connection.DISALLOWING_PAM_PLAINTEXT = saved
+
+
+class TestLogins(unittest.TestCase):
+    '''
+    Ideally, these tests should move into CI, but that would require the server
+    (currently a different node than the client) to have SSL certs created and
+    enabled.
+
+    Until then, we require these tests to be run manually on a server node,
+    with:
+
+        python -m unittest "irods.test.login_auth_test[.XX[.YY]]'
+
+    Additionally:
+
+      1. The PAM/SSL tests under the TestLogins class should be run on a
+         single-node iRODS system, by the service account user. This ensures
+         the /etc/irods directory is local and writable.
+
+      2. ./setupssl.py (sets up SSL keys etc. in /etc/irods/ssl) should be run
+         first to create (or overwrite, if appropriate) the /etc/irods/ssl directory
+         and its contents.
+
+      3. Must add & override configuration entries in /var/lib/irods/irods_environment
+         Per https://slides.com/irods/ugm2018-ssl-and-pam-configuration#/3/7
+
+      4. Create rodsuser alissa and corresponding unix user with the appropriate
+         passwords as below.
+    '''
+
+    test_rods_user = 'alissa'
+
+    user_auth_envs = {
+        '.irods.pam': {
+            'USER':     test_rods_user,
+            'PASSWORD': 'test123', # UNIX pw
+            'AUTH':     'pam'
+        },
+        '.irods.native': {
+            'USER':     test_rods_user,
+            'PASSWORD': 'apass',   # iRODS pw
+            'AUTH':     'native'
+        }
+    }
+
+    env_save = {}
+
+    @contextlib.contextmanager
+    def setenv(self,var,newvalue):
+        try:
+            self.env_save[var] = os.environ.get(var,None)
+            os.environ[var] = newvalue
+            yield newvalue
+        finally:
+            oldvalue = self.env_save[var]
+            if oldvalue is None:
+                del os.environ[var]
+            else:
+                os.environ[var]=oldvalue
+
+    @classmethod
+    def create_env_dirs(cls):
+        dirs = {}
+        retval = []
+        # -- create environment configurations and secrets
+        with pam_password_in_plaintext():
+            for dirname,lookup in cls.user_auth_envs.items():
+                if lookup['AUTH'] == 'pam':
+                    ses = iRODSSession( host=gethostname(),
+                                        user=lookup['USER'],
+                                        zone='tempZone',
+                                        authentication_scheme=lookup['AUTH'],
+                                        password=lookup['PASSWORD'],
+                                        port= 1247 )
+                    try:
+                        pam_hashes = ses.pam_pw_negotiated
+                    except AttributeError:
+                        pam_hashes = []
+                    if not pam_hashes: print('Warning ** PAM pw couldnt be generated' ); break
+                    scrambled_pw = pw_encode( pam_hashes[0] )
+               #elif lookup['AUTH'] == 'XXXXXX': # TODO: insert other authentication schemes here
+                elif lookup['AUTH'] in ('native', '',None):
+                    scrambled_pw = pw_encode( lookup['PASSWORD'] )
+                cl_env = client_env_from_server_env(cls.test_rods_user)
+                if lookup.get('AUTH',None) is not None:     # - specify auth scheme only if given
+                    cl_env['irods_authentication_scheme'] = lookup['AUTH']
+                dirbase = os.path.join(os.environ['HOME'],dirname)
+                dirs[dirbase] = { 'secrets':scrambled_pw , 'client_environment':cl_env }
+
+        # -- create the environment directories and write into them the configurations just created
+        for absdir in dirs.keys():
+            shutil.rmtree(absdir,ignore_errors=True)
+            os.mkdir(absdir)
+            with open(os.path.join(absdir,'irods_environment.json'),'w') as envfile:
+                envfile.write('{}')
+            json_file_update(envfile.name, **dirs[absdir]['client_environment'])
+            with open(os.path.join(absdir,'.irodsA'),'w') as secrets_file:
+                secrets_file.write(dirs[absdir]['secrets'])
+            os.chmod(secrets_file.name,0o600)
+
+        retval = dirs.keys()
+        return retval
+
+
+    @staticmethod
+    def get_server_ssl_negotiation( session ):
+
+        rule_body = textwrap.dedent('''
+                                    test { *out=""; acPreConnect(*out);
+                                               writeLine("stdout", "*out");
+                                         }
+                                    ''')
+        myrule = Rule(session, body=rule_body, params={}, output='ruleExecOut')
+        out_array = myrule.execute()
+        buf = out_array.MsParam_PI[0].inOutStruct.stdoutBuf.buf.decode('utf-8')
+        eol_offset = buf.find('\n')
+        return  buf[:eol_offset]  if  eol_offset >= 0  else None
+
+    @classmethod
+    def setUpClass(cls):
+        cls.admin = helpers.make_session()
+        if cls.test_rods_user in (row[User.name] for row in cls.admin.query(User.name)):
+            cls.server_ssl_setting = cls.get_server_ssl_negotiation( cls.admin )
+            cls.envdirs = cls.create_env_dirs()
+            if not cls.envdirs:
+                raise RuntimeError('Could not create one or more client environments')
+
+    @classmethod
+    def tearDownClass(cls):
+        for envdir in getattr(cls, 'envdirs', []):
+            shutil.rmtree(envdir, ignore_errors=True)
+        cls.admin.cleanup()
+
+    def setUp(self):
+        if not getattr(self, 'envdirs', []):
+            self.skipTest('The test_rods_user "{}" does not exist'.format(self.test_rods_user))
+        if os.environ['HOME'] != '/var/lib/irods':
+            self.skipTest('Must be run as irods')
+        super(TestLogins,self).setUp()
+
+    def tearDown(self):
+        super(TestLogins,self).tearDown()
+
+    def validate_session(self, session, verbose=False, **options):
+        
+        # - try to get the home collection
+        home_coll =  '/{0.zone}/home/{0.username}'.format(session)
+        self.assertTrue(session.collections.get(home_coll).path == home_coll)
+        if verbose: print(home_coll)
+        # - check user is as expected
+        self.assertEqual( session.username, self.test_rods_user )
+        # - check socket type (normal vs SSL) against whether ssl requested
+        use_ssl = options.pop('ssl',None)
+        if use_ssl is not None:
+            my_connect = [s for s in (session.pool.active|session.pool.idle)] [0]
+            self.assertEqual( bool( use_ssl ), my_connect.socket.__class__ is ssl.SSLSocket )
+
+
+#   def test_demo(self): self.demo()
+
+#   def demo(self): # for future reference - skipping based on CS_NEG_DONT_CARE setting
+#       if self.server_ssl_setting == 'CS_NEG_DONT_CARE':
+#           self.skipTest('skipping  b/c setting is DONT_CARE')
+#       self.assertTrue (False)
+
+
+    def tst0(self, ssl_opt, auth_opt, env_opt ):
+        auth_opt_explicit = 'native' if auth_opt=='' else  auth_opt
+        verbosity=False
+        #verbosity='' # -- debug - sanity check by printing out options applied
+        out = {'':''}
+        if env_opt:
+            with self.setenv('IRODS_ENVIRONMENT_FILE', json_env_fullpath(auth_opt_explicit)) as env_file,\
+                 self.setenv('IRODS_AUTHENTICATION_FILE', secrets_fullpath(auth_opt_explicit)):
+                cli_env_extras = {} if not(ssl_opt) else dict( CLIENT_OPTIONS_FOR_SSL )
+                if auth_opt:
+                    cli_env_extras.update( irods_authentication_scheme = auth_opt )
+                    remove=[]
+                else:
+                    remove=[regex('authentication_')]
+                with helpers.file_backed_up(env_file):
+                    json_file_update( env_file, keys_to_delete=remove, **cli_env_extras )
+                    session = iRODSSession(irods_env_file=env_file)
+                    with open(env_file) as f:
+                        out =  json.load(f)
+                    self.validate_session( session, verbose = verbosity, ssl = ssl_opt )
+                    session.cleanup()
+            out['ARGS']='no'
+        else:
+            session_options = {}
+            if auth_opt:
+                session_options.update (authentication_scheme = auth_opt)
+            if ssl_opt:
+                SSL_cert = CLIENT_OPTIONS_FOR_SSL["irods_ssl_ca_certificate_file"]
+                session_options.update(
+                    ssl_context = ssl.create_default_context ( purpose = ssl.Purpose.SERVER_AUTH,
+                                                               capath = None,
+                                                               cadata = None,
+                                                               cafile = SSL_cert),
+                    **CLIENT_OPTIONS_FOR_SSL )
+            lookup = self.user_auth_envs ['.irods.'+('native' if not(auth_opt) else auth_opt)]
+            session = iRODSSession ( host=gethostname(),
+                                     user=lookup['USER'],
+                                     zone='tempZone',
+                                     password=lookup['PASSWORD'],
+                                     port= 1247,
+                                     **session_options )
+            out = session_options
+            self.validate_session( session, verbose = verbosity, ssl = ssl_opt )
+            session.cleanup()
+            out['ARGS']='yes'
+
+        if verbosity == '':
+            print ('--- ssl:',ssl_opt,'/ auth:',repr(auth_opt),'/ env:',env_opt)
+            print ('--- > ',json.dumps({k:v for k,v in out.items() if k != 'ssl_context'},indent=4))
+            print ('---')
+
+    # == test defaulting to 'native'
+
+    def test_01(self):
+        self.tst0 ( ssl_opt = True , auth_opt = '' , env_opt = False )
+    def test_02(self):
+        self.tst0 ( ssl_opt = False, auth_opt = '' , env_opt = False )
+    def test_03(self):
+        self.tst0 ( ssl_opt = True , auth_opt = '' , env_opt = True )
+    def test_04(self):
+        self.tst0 ( ssl_opt = False, auth_opt = '' , env_opt = True  )
+
+    # == test explicit scheme 'native'
+
+    def test_1(self):
+        self.tst0 ( ssl_opt = True , auth_opt = 'native' , env_opt = False )
+
+    def test_2(self):
+        self.tst0 ( ssl_opt = False, auth_opt = 'native' , env_opt = False )
+
+    def test_3(self):
+        self.tst0 ( ssl_opt = True , auth_opt = 'native' , env_opt = True )
+
+    def test_4(self):
+        self.tst0 ( ssl_opt = False, auth_opt = 'native' , env_opt = True  )
+
+    # == test explicit scheme 'pam'
+
+    def test_5(self):
+        self.tst0 ( ssl_opt = True,  auth_opt = 'pam'    , env_opt = False )
+
+    def test_6(self):
+        try:
+            self.tst0 ( ssl_opt = False, auth_opt = 'pam'    , env_opt = False )
+        except PlainTextPAMPasswordError:
+            pass
+        else:
+            # -- no exception raised
+            self.fail("PlainTextPAMPasswordError should have been raised")
+
+    def test_7(self):
+        self.tst0 ( ssl_opt = True , auth_opt = 'pam'    , env_opt = True  )
+
+    def test_8(self):
+        self.tst0 ( ssl_opt = False, auth_opt = 'pam'    , env_opt = True  )
+
+class TestAnonymousUser(unittest.TestCase):
+
+    def setUp(self):
+        admin = self.admin = helpers.make_session()
+
+        user = self.user = admin.users.create('anonymous', 'rodsuser', admin.zone)
+        self.home = '/{admin.zone}/home/{user.name}'.format(**locals())
+
+        admin.collections.create(self.home)
+        acl = iRODSAccess('own', self.home, user.name)
+        admin.permissions.set(acl)
+
+        self.env_file = os.path.expanduser('~/.irods.anon/irods_environment.json')
+        self.env_dir = ( os.path.dirname(self.env_file))
+        self.auth_file = os.path.expanduser('~/.irods.anon/.irodsA')
+        os.mkdir( os.path.dirname(self.env_file))
+        json.dump( { "irods_host": admin.host,
+                     "irods_port": admin.port,
+                     "irods_user_name": user.name,
+                     "irods_zone_name": admin.zone }, open(self.env_file,'w'), indent=4 )
+
+    def tearDown(self):
+        self.admin.collections.remove(self.home, recurse = True, force = True)
+        self.admin.users.remove(self.user.name)
+        shutil.rmtree (self.env_dir, ignore_errors = True)
+
+    def test_login_from_environment(self):
+        orig_env = os.environ.copy()
+        try:
+            os.environ["IRODS_ENVIRONMENT_FILE"] = self.env_file
+            os.environ["IRODS_AUTHENTICATION_FILE"] = self.auth_file
+            ses = helpers.make_session()
+            ses.collections.get(self.home)
+        finally:
+            os.environ.clear()
+            os.environ.update( orig_env )
+
+class TestMiscellaneous(unittest.TestCase):
+
+    def test_nonanonymous_login_without_auth_file_fails__290(self):
+        ses = self.admin
+        if ses.users.get( ses.username ).type != 'rodsadmin':
+            self.skipTest( 'Only a rodsadmin may run this test.')
+        try:
+            ENV_DIR = tempfile.mkdtemp()
+            ses.users.create('bob', 'rodsuser')
+            ses.users.modify('bob', 'password', 'bpass')
+            d = dict(password = 'bpass', user = 'bob', host = ses.host, port = ses.port, zone = ses.zone)
+            (bob_env, bob_auth) = helpers.make_environment_and_auth_files(ENV_DIR, **d)
+            login_options = { 'irods_env_file': bob_env, 'irods_authentication_file': bob_auth }
+            with helpers.make_session(**login_options) as s:
+                s.users.get('bob')
+            os.unlink(bob_auth)
+            # -- Check that we raise an appropriate exception pointing to the missing auth file path --
+            with self.assertRaisesRegexp(NonAnonymousLoginWithoutPassword, bob_auth):
+                with helpers.make_session(**login_options) as s:
+                    s.users.get('bob')
+        finally:
+            try:
+                shutil.rmtree(ENV_DIR,ignore_errors=True)
+                ses.users.get('bob').remove()
+            except ex.UserDoesNotExist:
+                pass
+
+
+    def setUp(self):
+        admin = self.admin = helpers.make_session()
+        if admin.users.get(admin.username).type != 'rodsadmin':
+            self.skipTest('need admin privilege')
+        admin.users.create('alice','rodsuser')
+
+    def tearDown(self):
+        self.admin.users.remove('alice')
+        self.admin.cleanup()
+
+    @unittest.skipUnless(six.PY3, "Skipping in Python2 because it doesn't reliably do cyclic GC.")
+    def test_destruct_session_with_no_pool_315(self):
+
+        destruct_flag = [False]
+
+        class mySess( iRODSSession ):
+            def __del__(self):
+                self.pool = None
+                super(mySess,self).__del__()  # call parent destructor(s) - will raise
+                                              # an error before the #315 fix
+                destruct_flag[:] = [True]
+
+        admin = self.admin
+        admin.users.modify('alice','password','apass')
+
+        my_sess = mySess( user = 'alice',
+                          password = 'apass',
+                          host = admin.host,
+                          port = admin.port,
+                          zone = admin.zone)
+        my_sess.cleanup()
+        del my_sess
+        gc.collect()
+        self.assertEqual( destruct_flag, [True] )
+
+    def test_non_anon_native_login_omitting_password_fails_1__290(self):
+        # rodsuser with password unset
+        with self.assertRaises(ex.CAT_INVALID_USER):
+            self._non_anon_native_login_omitting_password_fails_N__290()
+
+    def test_non_anon_native_login_omitting_password_fails_2__290(self):
+        # rodsuser with a password set
+        self.admin.users.modify('alice','password','apass')
+        with self.assertRaises(ex.CAT_INVALID_AUTHENTICATION):
+            self._non_anon_native_login_omitting_password_fails_N__290()
+
+    def _non_anon_native_login_omitting_password_fails_N__290(self):
+        admin = self.admin
+        with iRODSSession(zone = admin.zone, port = admin.port, host = admin.host, user = 'alice') as alice:
+            alice.collections.get(helpers.home_collection(alice))
+
+class TestWithSSL(unittest.TestCase):
+    '''
+    The tests within this class should be run by an account other than the
+    service account.  Otherwise there is risk of corrupting the server setup.
+    '''
+
+    def setUp(self):
+        if os.path.expanduser('~') == '/var/lib/irods':
+            self.skipTest('TestWithSSL may not be run by user irods')
+        if not os.path.exists('/etc/irods/ssl'):
+            self.skipTest('Running setupssl.py as irods user is prerequisite for this test.')
+        with helpers.make_session() as session:
+            if not session.host in ('localhost', socket.gethostname()):
+                self.skipTest('Test must be run co-resident with server')
+
+
+    def test_ssl_with_server_verify_set_to_none_281(self):
+        env_file = os.path.expanduser('~/.irods/irods_environment.json')
+        with helpers.file_backed_up(env_file):
+            with open(env_file) as env_file_handle:
+                env = json.load( env_file_handle )
+            env.update({ "irods_client_server_negotiation": "request_server_negotiation",
+                         "irods_client_server_policy": "CS_NEG_REQUIRE",
+                         "irods_ssl_ca_certificate_file": "/path/to/some/file.crt",  # does not need to exist
+                         "irods_ssl_verify_server": "none",
+                         "irods_encryption_key_size": 32,
+                         "irods_encryption_salt_size": 8,
+                         "irods_encryption_num_hash_rounds": 16,
+                         "irods_encryption_algorithm": "AES-256-CBC" })
+            with open(env_file,'w') as f:
+                json.dump(env,f)
+            with helpers.make_session() as session:
+                session.collections.get('/{session.zone}/home/{session.username}'.format(**locals()))
+
+
+if __name__ == '__main__':
+    # let the tests find the parent irods lib
+    sys.path.insert(0, os.path.abspath('../..'))
+    unittest.main()
diff --git a/irods/test/message_test.py b/irods/test/message_test.py
index 8050df9..62acf74 100644
--- a/irods/test/message_test.py
+++ b/irods/test/message_test.py
@@ -8,7 +8,8 @@ import unittest
 if __name__ == '__main__':
     sys.path.insert(0, os.path.abspath('../..'))
 
-from xml.etree import ElementTree as ET
+from irods.message import ET
+
 # from base64 import b64encode, b64decode
 from irods.message import (StartupPack, AuthResponse, IntegerIntegerMap,
                            IntegerStringMap, StringStringMap, GenQueryRequest,
@@ -44,7 +45,7 @@ class TestMessages(unittest.TestCase):
         self.assertEqual(xml_str, expected)
 
         sup2 = StartupPack(('rods', 'tempZone'), ('rods', 'tempZone'))
-        sup2.unpack(ET.fromstring(expected))
+        sup2.unpack(ET().fromstring(expected))
         self.assertEqual(sup2.irodsProt, 2)
         self.assertEqual(sup2.reconnFlag, 3)
         self.assertEqual(sup2.proxyUser, "rods")
@@ -66,7 +67,7 @@ class TestMessages(unittest.TestCase):
         self.assertEqual(ar.pack(), expected)
 
         ar2 = AuthResponse()
-        ar2.unpack(ET.fromstring(expected))
+        ar2.unpack(ET().fromstring(expected))
         self.assertEqual(ar2.response, b"hello")
         self.assertEqual(ar2.username, "rods")
 
@@ -85,7 +86,7 @@ class TestMessages(unittest.TestCase):
         self.assertEqual(iip.pack(), expected)
 
         iip2 = IntegerIntegerMap()
-        iip2.unpack(ET.fromstring(expected))
+        iip2.unpack(ET().fromstring(expected))
         self.assertEqual(iip2.iiLen, 2)
         self.assertEqual(iip2.inx, [4, 5])
         self.assertEqual(iip2.ivalue, [1, 2])
@@ -105,7 +106,7 @@ class TestMessages(unittest.TestCase):
         self.assertEqual(kvp.pack(), expected)
 
         kvp2 = StringStringMap()
-        kvp2.unpack(ET.fromstring(expected))
+        kvp2.unpack(ET().fromstring(expected))
         self.assertEqual(kvp2.ssLen, 2)
         self.assertEqual(kvp2.keyWord, ["one", "two"])
         self.assertEqual(kvp2.svalue, ["three", "four"])
@@ -140,7 +141,7 @@ class TestMessages(unittest.TestCase):
         self.assertEqual(gq.pack(), expected)
 
         gq2 = GenQueryRequest()
-        gq2.unpack(ET.fromstring(expected))
+        gq2.unpack(ET().fromstring(expected))
         self.assertEqual(gq2.maxRows, 4)
         self.assertEqual(gq2.continueInx, 3)
         self.assertEqual(gq2.partialStartIndex, 2)
@@ -170,7 +171,7 @@ class TestMessages(unittest.TestCase):
         self.assertEqual(sr.pack(), expected)
 
         sr2 = GenQueryResponseColumn()
-        sr2.unpack(ET.fromstring(expected))
+        sr2.unpack(ET().fromstring(expected))
         self.assertEqual(sr2.attriInx, 504)
         self.assertEqual(sr2.reslen, 64)
         self.assertEqual(sr2.value, ["one", "two"])
@@ -193,7 +194,7 @@ class TestMessages(unittest.TestCase):
         self.assertEqual(gqo.pack(), expected)
 
         gqo2 = GenQueryResponse()
-        gqo2.unpack(ET.fromstring(expected))
+        gqo2.unpack(ET().fromstring(expected))
 
         self.assertEqual(gqo2.rowCnt, 2)
         self.assertEqual(gqo2.pack(), expected)
diff --git a/irods/test/meta_test.py b/irods/test/meta_test.py
index 49fd24f..293cae1 100644
--- a/irods/test/meta_test.py
+++ b/irods/test/meta_test.py
@@ -4,10 +4,12 @@ from __future__ import absolute_import
 import os
 import sys
 import unittest
-from irods.meta import iRODSMeta
-from irods.models import DataObject, Collection
+from irods.meta import (iRODSMeta, AVUOperation, BadAVUOperationValue, BadAVUOperationKeyword)
+from irods.manager.metadata_manager import InvalidAtomicAVURequest
+from irods.models import (DataObject, Collection, Resource)
 import irods.test.helpers as helpers
 from six.moves import range
+from six import PY3
 
 
 class TestMeta(unittest.TestCase):
@@ -19,7 +21,6 @@ class TestMeta(unittest.TestCase):
 
     def setUp(self):
         self.sess = helpers.make_session()
-
         # test data
         self.coll_path = '/{}/home/{}/test_dir'.format(self.sess.zone, self.sess.username)
         self.obj_name = 'test1'
@@ -29,13 +30,91 @@ class TestMeta(unittest.TestCase):
         self.coll = self.sess.collections.create(self.coll_path)
         self.obj = self.sess.data_objects.create(self.obj_path)
 
-
     def tearDown(self):
         '''Remove test data and close connections
         '''
         self.coll.remove(recurse=True, force=True)
+        helpers.remove_unused_metadata(self.sess)
         self.sess.cleanup()
 
+    from irods.test.helpers import create_simple_resc_hierarchy
+
+    def test_atomic_metadata_operations_244(self):
+        user = self.sess.users.get("rods")
+        group = self.sess.user_groups.get("public")
+        m = ( "attr_244","value","units")
+
+        with self.assertRaises(BadAVUOperationValue):
+            AVUOperation(operation="add", avu=m)
+
+        with self.assertRaises(BadAVUOperationValue):
+            AVUOperation(operation="not_add_or_remove", avu=iRODSMeta(*m))
+
+        with self.assertRaises(BadAVUOperationKeyword):
+            AVUOperation(operation="add", avu=iRODSMeta(*m), extra_keyword=None)
+
+
+        with self.assertRaises(InvalidAtomicAVURequest):
+            user.metadata.apply_atomic_operations( tuple() )
+
+        user.metadata.apply_atomic_operations()   # no AVUs applied - no-op without error
+
+        for n,obj in enumerate((group, user, self.coll, self.obj)):
+            avus = [ iRODSMeta('some_attribute',str(i),'some_units') for i in range(n*100,(n+1)*100) ]
+            obj.metadata.apply_atomic_operations(*[AVUOperation(operation="add", avu=avu_) for avu_ in avus])
+            obj.metadata.apply_atomic_operations(*[AVUOperation(operation="remove", avu=avu_) for avu_ in avus])
+
+
+    def test_atomic_metadata_operation_for_resource_244(self):
+        (root,leaf)=('ptX','rescX')
+        with self.create_simple_resc_hierarchy(root,leaf):
+            root_resc = self.sess.resources.get(root)   # resource objects
+            leaf_resc = self.sess.resources.get(leaf)
+            root_tuple = ('role','root','new units #1')    # AVU tuples to apply
+            leaf_tuple = ('role','leaf','new units #2')
+            root_resc.metadata.add( *root_tuple[:2] ) # first apply without units ...
+            leaf_resc.metadata.add( *leaf_tuple[:2] )
+            for resc,resc_tuple in ((root_resc, root_tuple), (leaf_resc, leaf_tuple)):
+                resc.metadata.apply_atomic_operations(  # metadata set operation (remove + add) to add units
+                    AVUOperation(operation="remove", avu=iRODSMeta(*resc_tuple[:2])),
+                    AVUOperation(operation="add", avu=iRODSMeta(*resc_tuple[:3]))
+                )
+                resc_meta = self.sess.metadata.get(Resource, resc.name)
+                avus_to_tuples = lambda avu_list: sorted([(i.name,i.value,i.units) for i in avu_list])
+                self.assertEqual(avus_to_tuples(resc_meta), avus_to_tuples([iRODSMeta(*resc_tuple)]))
+
+
+    def test_atomic_metadata_operation_for_data_object_244(self):
+        AVUs_Equal = lambda avu1,avu2,fn=(lambda x:x): fn(avu1)==fn(avu2)
+        AVU_As_Tuple = lambda avu,length=3:(avu.name,avu.value,avu.units)[:length]
+        AVU_Units_String = lambda avu:"" if not avu.units else avu.units
+        m = iRODSMeta( "attr_244","value","units")
+        self.obj.metadata.add(m)
+        meta = self.sess.metadata.get(DataObject, self.obj_path)
+        self.assertEqual(len(meta), 1)
+        self.assertTrue(AVUs_Equal(m,meta[0],AVU_As_Tuple))
+        self.obj.metadata.apply_atomic_operations(                                  # remove original AVU and replace
+           AVUOperation(operation="remove",avu=m),                                  #   with two altered versions
+           AVUOperation(operation="add",avu=iRODSMeta(m.name,m.value,"units_244")), # (one of them without units) ...
+           AVUOperation(operation="add",avu=iRODSMeta(m.name,m.value))
+        )
+        meta = self.sess.metadata.get(DataObject, self.obj_path)   # ... check integrity of change
+        self.assertEqual(sorted([AVU_Units_String(i) for i in meta]), ["","units_244"])
+
+    def test_atomic_metadata_operations_255(self):
+        my_resc = self.sess.resources.create('dummyResc','passthru')
+        avus = [iRODSMeta('a','b','c'), iRODSMeta('d','e','f')]
+        objects = [ self.sess.users.get("rods"), self.sess.user_groups.get("public"), my_resc,
+                    self.sess.collections.get(self.coll_path), self.sess.data_objects.get(self.obj_path)  ]
+        try:
+            for obj in objects:
+                self.assertEqual(len(obj.metadata.items()), 0)
+                for n,item in enumerate(avus):
+                    obj.metadata.apply_atomic_operations(AVUOperation(operation='add',avu=item))
+                    self.assertEqual(len(obj.metadata.items()), n+1)
+        finally:
+            for obj in objects: obj.metadata.remove_all()
+            my_resc.remove()
 
     def test_get_obj_meta(self):
         # get object metadata
@@ -44,6 +123,19 @@ class TestMeta(unittest.TestCase):
         # there should be no metadata at this point
         assert len(meta) == 0
 
+    def test_resc_meta(self):
+        rescname = 'demoResc'
+        self.sess.resources.get(rescname).metadata.remove_all()
+        self.sess.metadata.set(Resource, rescname, iRODSMeta('zero','marginal','cost'))
+        self.sess.metadata.add(Resource, rescname, iRODSMeta('zero','marginal'))
+        self.sess.metadata.set(Resource, rescname, iRODSMeta('for','ever','after'))
+        meta = self.sess.resources.get(rescname).metadata
+        self.assertTrue( len(meta) == 3 )
+        resource = self.sess.resources.get(rescname)
+        all_AVUs= resource.metadata.items()
+        for avu in all_AVUs:
+            resource.metadata.remove(avu)
+        self.assertTrue(0 == len(self.sess.resources.get(rescname).metadata))
 
     def test_add_obj_meta(self):
         # add metadata to test object
@@ -74,7 +166,8 @@ class TestMeta(unittest.TestCase):
         assert meta[1].units == self.unit1
 
         assert meta[2].name == attribute
-        assert meta[2].value == value
+        testValue = (value if PY3 else value.encode('utf8'))
+        assert meta[2].value == testValue
 
 
     def test_add_obj_meta_empty(self):
diff --git a/irods/test/pool_test.py b/irods/test/pool_test.py
index 0f38ff8..27e373a 100644
--- a/irods/test/pool_test.py
+++ b/irods/test/pool_test.py
@@ -1,15 +1,56 @@
 #! /usr/bin/env python
 from __future__ import absolute_import
+import datetime
+import gc
+import logging
 import os
+import re
 import sys
+import tempfile
+import time
+import json
 import unittest
+import socket
 import irods.test.helpers as helpers
+from irods.connection import DESTRUCTOR_MSG
+
+#  Regular expression to match common synonyms for localhost.
+#
+
+LOCALHOST_REGEX = re.compile(r"""^(127(\.\d+){1,3}|[0:]+1|(.*-)?localhost(\.\w+)?)$""",re.IGNORECASE)
+USE_ONLY_LOCALHOST = False
 
 
 class TestPool(unittest.TestCase):
 
+    config_extension = ".json"
+    test_extension = ""
+    preferred_parameters = {}
+
+    @classmethod
+    def setUpClass(cls):              # generate test env files using connect data from ~/.irods environment
+        if USE_ONLY_LOCALHOST: return
+        Nonlocal_Ext = ".test"
+        with helpers.make_session() as session:
+            cls.preferred_parameters = { 'irods_host':session.host,
+                                         'irods_port':session.port,
+                                         'irods_user_name':session.username,
+                                         'irods_zone_name':session.zone }
+            test_configs_dir = os.path.join(irods_test_path(),"test-data")
+            for config in [os.path.join(test_configs_dir,f) for f in os.listdir(test_configs_dir)
+                           if f.endswith(cls.config_extension)]:
+                with open(config,"r") as in_, open(config + Nonlocal_Ext,"w") as out_:
+                    cf = json.load(in_)
+                    cf.update(cls.preferred_parameters)
+                    json.dump(cf, out_,indent=4)
+            cls.test_extension = Nonlocal_Ext
+
+
     def setUp(self):
-        self.sess = helpers.make_session()
+        self.sess = helpers.make_session(
+            irods_env_file=os.path.join(irods_test_path(),"test-data","irods_environment.json" + self.test_extension))
+        if USE_ONLY_LOCALHOST and not LOCALHOST_REGEX.match (self.sess.host):
+            self.skipTest('for non-local server')
 
     def tearDown(self):
         '''Close connections
@@ -17,7 +58,7 @@ class TestPool(unittest.TestCase):
         self.sess.cleanup()
 
     def test_release_connection(self):
-        with self.sess.pool.get_connection() as conn:
+        with self.sess.pool.get_connection():
             self.assertEqual(1, len(self.sess.pool.active))
             self.assertEqual(0, len(self.sess.pool.idle))
 
@@ -34,7 +75,7 @@ class TestPool(unittest.TestCase):
         self.assertEqual(0, len(self.sess.pool.idle))
 
     def test_destroy_idle(self):
-        with self.sess.pool.get_connection() as conn:
+        with self.sess.pool.get_connection():
             self.assertEqual(1, len(self.sess.pool.active))
             self.assertEqual(0, len(self.sess.pool.idle))
 
@@ -58,6 +99,252 @@ class TestPool(unittest.TestCase):
         self.assertEqual(0, len(self.sess.pool.active))
         self.assertEqual(0, len(self.sess.pool.idle))
 
+    def test_connection_create_time(self):
+        # Get a connection and record its object ID and create_time
+        # Release the connection (goes from active to idle queue)
+        # Again, get a connection. Should get the same connection back.
+        # I.e., the object IDs should match. However, the new connection
+        # should have a more recent 'last_used_time'
+        conn_obj_id_1 = None
+        conn_obj_id_2 = None
+        create_time_1 = None
+        create_time_2 = None
+        last_used_time_1 = None
+        last_used_time_2 = None
+
+        with self.sess.pool.get_connection() as conn:
+            conn_obj_id_1 = id(conn)
+            curr_time = datetime.datetime.now()
+            create_time_1 = conn.create_time
+            last_used_time_1 = conn.last_used_time
+            self.assertTrue(curr_time >= create_time_1)
+            self.assertTrue(curr_time >= last_used_time_1)
+            self.assertEqual(1, len(self.sess.pool.active))
+            self.assertEqual(0, len(self.sess.pool.idle))
+
+            self.sess.pool.release_connection(conn)
+            self.assertEqual(0, len(self.sess.pool.active))
+            self.assertEqual(1, len(self.sess.pool.idle))
+
+        with self.sess.pool.get_connection() as conn:
+            conn_obj_id_2 = id(conn)
+            curr_time = datetime.datetime.now()
+            create_time_2 = conn.create_time
+            last_used_time_2 = conn.last_used_time
+            self.assertEqual(conn_obj_id_1, conn_obj_id_2)
+            self.assertTrue(curr_time >= create_time_2)
+            self.assertTrue(curr_time >= last_used_time_2)
+            self.assertTrue(last_used_time_2 >= last_used_time_1)
+            self.assertEqual(1, len(self.sess.pool.active))
+            self.assertEqual(0, len(self.sess.pool.idle))
+
+            self.sess.pool.release_connection(conn)
+            self.assertEqual(0, len(self.sess.pool.active))
+            self.assertEqual(1, len(self.sess.pool.idle))
+
+            self.sess.pool.release_connection(conn, True)
+            self.assertEqual(0, len(self.sess.pool.active))
+            self.assertEqual(0, len(self.sess.pool.idle))
+
+    def test_refresh_connection(self):
+        # Set 'irods_connection_refresh_time' to '3' (in seconds) in
+        # ~/.irods/irods_environment.json file. This means any connection
+        # that was created more than 3 seconds ago will be dropped and
+        # a new connection is created/returned. This is to avoid
+        # issue with idle connections that are dropped.
+        conn_obj_id_1 = None
+        conn_obj_id_2 = None
+        create_time_1 = None
+        create_time_2 = None
+        last_used_time_1 = None
+        last_used_time_2 = None
+
+        with self.sess.pool.get_connection() as conn:
+            conn_obj_id_1 = id(conn)
+            curr_time = datetime.datetime.now()
+            create_time_1 = conn.create_time
+            last_used_time_1 = conn.last_used_time
+            self.assertTrue(curr_time >= create_time_1)
+            self.assertTrue(curr_time >= last_used_time_1)
+            self.assertEqual(1, len(self.sess.pool.active))
+            self.assertEqual(0, len(self.sess.pool.idle))
+
+            self.sess.pool.release_connection(conn)
+            self.assertEqual(0, len(self.sess.pool.active))
+            self.assertEqual(1, len(self.sess.pool.idle))
+
+        # Wait more than 'irods_connection_refresh_time' seconds,
+        # which is set to 3. Connection object should have a different
+        # object ID (as a new connection is created)
+        time.sleep(5)
+
+        with self.sess.pool.get_connection() as conn:
+            conn_obj_id_2 = id(conn)
+            curr_time = datetime.datetime.now()
+            create_time_2 = conn.create_time
+            last_used_time_2 = conn.last_used_time
+            self.assertTrue(curr_time >= create_time_2)
+            self.assertTrue(curr_time >= last_used_time_2)
+            self.assertNotEqual(conn_obj_id_1, conn_obj_id_2)
+            self.assertTrue(create_time_2 > create_time_1)
+            self.assertEqual(1, len(self.sess.pool.active))
+            self.assertEqual(0, len(self.sess.pool.idle))
+
+            self.sess.pool.release_connection(conn, True)
+            self.assertEqual(0, len(self.sess.pool.active))
+            self.assertEqual(0, len(self.sess.pool.idle))
+
+    def test_no_refresh_connection(self):
+        # Set 'irods_connection_refresh_time' to '3' (in seconds) in
+        # ~/.irods/irods_environment.json file. This means any connection
+        # created more than 3 seconds ago will be dropped and
+        # a new connection is created/returned. This is to avoid
+        # issue with idle connections that are dropped.
+        conn_obj_id_1 = None
+        conn_obj_id_2 = None
+        create_time_1 = None
+        create_time_2 = None
+        last_used_time_1 = None
+        last_used_time_2 = None
+
+        with self.sess.pool.get_connection() as conn:
+            conn_obj_id_1 = id(conn)
+            curr_time = datetime.datetime.now()
+            create_time_1 = conn.create_time
+            last_used_time_1 = conn.last_used_time
+            self.assertTrue(curr_time >= create_time_1)
+            self.assertTrue(curr_time >= last_used_time_1)
+            self.assertEqual(1, len(self.sess.pool.active))
+            self.assertEqual(0, len(self.sess.pool.idle))
+
+            self.sess.pool.release_connection(conn)
+            self.assertEqual(0, len(self.sess.pool.active))
+            self.assertEqual(1, len(self.sess.pool.idle))
+
+        # Wait less than 'irods_connection_refresh_time' seconds,
+        # which is set to 3. Connection object should have the same
+        # object ID (as idle time is less than 'irods_connection_refresh_time')
+        time.sleep(1)
+
+        with self.sess.pool.get_connection() as conn:
+            conn_obj_id_2 = id(conn)
+            curr_time = datetime.datetime.now()
+            create_time_2 = conn.create_time
+            last_used_time_2 = conn.last_used_time
+            self.assertTrue(curr_time >= create_time_2)
+            self.assertTrue(curr_time >= last_used_time_2)
+            self.assertEqual(conn_obj_id_1, conn_obj_id_2)
+            self.assertTrue(create_time_2 >= create_time_1)
+            self.assertEqual(1, len(self.sess.pool.active))
+            self.assertEqual(0, len(self.sess.pool.idle))
+
+            self.sess.pool.release_connection(conn, True)
+            self.assertEqual(0, len(self.sess.pool.active))
+            self.assertEqual(0, len(self.sess.pool.idle))
+
+    # Test to confirm the connection destructor log message is actually
+    # logged to file, to confirm the destructor is called
+    def test_connection_destructor_called(self):
+
+        if self.sess.host != socket.gethostname() and not LOCALHOST_REGEX.match (self.sess.host):
+            self.skipTest('local test only - client dot does not like the extra logging')
+
+        # Set 'irods_connection_refresh_time' to '3' (in seconds) in
+        # ~/.irods/irods_environment.json file. This means any connection
+        # that was created more than 3 seconds ago will be dropped and
+        # a new connection is created/returned. This is to avoid
+        # issue with idle connections that are dropped.
+        conn_obj_id_1 = None
+        conn_obj_id_2 = None
+        create_time_1 = None
+        create_time_2 = None
+        last_used_time_1 = None
+        last_used_time_2 = None
+
+        try:
+
+            # Create a temporary log file
+            my_log_file = tempfile.NamedTemporaryFile()
+
+            logging.getLogger('irods.connection').setLevel(logging.DEBUG)
+            file_handler = logging.FileHandler(my_log_file.name, mode='a')
+            file_handler.setLevel(logging.DEBUG)
+            logging.getLogger('irods.connection').addHandler(file_handler)
+
+            with self.sess.pool.get_connection() as conn:
+                conn_obj_id_1 = id(conn)
+                curr_time = datetime.datetime.now()
+                create_time_1 = conn.create_time
+                last_used_time_1 = conn.last_used_time
+                self.assertTrue(curr_time >= create_time_1)
+                self.assertTrue(curr_time >= last_used_time_1)
+                self.assertEqual(1, len(self.sess.pool.active))
+                self.assertEqual(0, len(self.sess.pool.idle))
+
+                self.sess.pool.release_connection(conn)
+                self.assertEqual(0, len(self.sess.pool.active))
+                self.assertEqual(1, len(self.sess.pool.idle))
+
+            # Wait more than 'irods_connection_refresh_time' seconds,
+            # which is set to 3. Connection object should have a different
+            # object ID (as a new connection is created)
+            time.sleep(5)
+
+            # Call garbage collector, so the unreferenced conn object is garbage collected
+            gc.collect()
+
+            with self.sess.pool.get_connection() as conn:
+                conn_obj_id_2 = id(conn)
+                curr_time = datetime.datetime.now()
+                create_time_2 = conn.create_time
+                last_used_time_2 = conn.last_used_time
+                self.assertTrue(curr_time >= create_time_2)
+                self.assertTrue(curr_time >= last_used_time_2)
+                self.assertNotEqual(conn_obj_id_1, conn_obj_id_2)
+                self.assertTrue(create_time_2 > create_time_1)
+                self.assertEqual(1, len(self.sess.pool.active))
+                self.assertEqual(0, len(self.sess.pool.idle))
+
+                self.sess.pool.release_connection(conn, True)
+                self.assertEqual(0, len(self.sess.pool.active))
+                self.assertEqual(0, len(self.sess.pool.idle))
+
+            # Assert that connection destructor called
+            with open(my_log_file.name, 'r') as fh:
+                lines = fh.read().splitlines()
+                self.assertTrue(DESTRUCTOR_MSG in lines)
+        finally:
+            # Remove irods.connection's file_handler that was added just for this test
+            logging.getLogger('irods.connection').removeHandler(file_handler)
+
+    def test_get_connection_refresh_time_no_env_file_input_param(self):
+        connection_refresh_time = self.sess.get_connection_refresh_time(first_name="Magic", last_name="Johnson")
+        self.assertEqual(connection_refresh_time, -1)
+
+    def test_get_connection_refresh_time_none_existant_env_file(self):
+        connection_refresh_time = self.sess.get_connection_refresh_time(
+            irods_env_file=os.path.join(irods_test_path(),"test-data","irods_environment_non_existant.json" + self.test_extension))
+        self.assertEqual(connection_refresh_time, -1)
+
+    def test_get_connection_refresh_time_no_connection_refresh_field(self):
+        connection_refresh_time = self.sess.get_connection_refresh_time(
+            irods_env_file=os.path.join(irods_test_path(),"test-data","irods_environment_no_refresh_field.json" + self.test_extension))
+        self.assertEqual(connection_refresh_time, -1)
+
+    def test_get_connection_refresh_time_negative_connection_refresh_field(self):
+        connection_refresh_time = self.sess.get_connection_refresh_time(
+            irods_env_file=os.path.join(irods_test_path(),"test-data","irods_environment_negative_refresh_field.json" + self.test_extension))
+        self.assertEqual(connection_refresh_time, -1)
+
+    def test_get_connection_refresh_time(self):
+        default_path = os.path.join (irods_test_path(),"test-data","irods_environment.json" + self.test_extension)
+        connection_refresh_time = self.sess.get_connection_refresh_time(irods_env_file=default_path)
+        self.assertEqual(connection_refresh_time, 3)
+
+
+def irods_test_path():
+    return os.path.dirname(__file__)
+
 
 if __name__ == '__main__':
     # let the tests find the parent irods lib
diff --git a/irods/test/query_test.py b/irods/test/query_test.py
index 6f85097..1cb2626 100644
--- a/irods/test/query_test.py
+++ b/irods/test/query_test.py
@@ -1,19 +1,67 @@
 #! /usr/bin/env python
+# -*- coding: utf-8 -*-
+from __future__ import print_function
 from __future__ import absolute_import
 import os
+import six
 import sys
+import tempfile
 import unittest
+import time
+import uuid
 from datetime import datetime
-from irods.models import User, Collection, DataObject, Resource
+from irods.models import (User, UserMeta,
+                          Resource, ResourceMeta,
+                          Collection, CollectionMeta,
+                          DataObject, DataObjectMeta,
+                          RuleExec)
+
+from tempfile import NamedTemporaryFile
 from irods.exception import MultipleResultsFound, CAT_UNKNOWN_SPECIFIC_QUERY, CAT_INVALID_ARGUMENT
 from irods.query import SpecificQuery
-from irods.column import Like, Between
+from irods.column import Like, Between, In
+from irods.meta import iRODSMeta
+from irods.rule import Rule
 from irods import MAX_SQL_ROWS
+from irods.test.helpers import irods_shared_reg_resc_vault
 import irods.test.helpers as helpers
+from six.moves import range as py3_range
+import irods.keywords as kw
+
+IRODS_STATEMENT_TABLE_SIZE = 50
+
+
+def rows_returned(query):
+    return len( list(query) )
 
 
 class TestQuery(unittest.TestCase):
 
+    Iterate_to_exhaust_statement_table = range(IRODS_STATEMENT_TABLE_SIZE + 1)
+
+    More_than_one_batch = 2*MAX_SQL_ROWS # may need to increase if PRC default page
+                                         #   size is increased beyond 500
+
+    register_resc = ''
+
+    @classmethod
+    def setUpClass(cls):
+        with helpers.make_session() as sess:
+            resource_name = helpers.get_register_resource(sess)
+            if resource_name:
+                cls.register_resc = resource_name
+
+    @classmethod
+    def tearDownClass(cls):
+        with helpers.make_session() as sess:
+            try:
+                if cls.register_resc:
+                    sess.resources.get(cls.register_resc).remove()
+            except Exception as e:
+                print( "Could not remove resc {!r} due to: {} ".format(cls.register_resc,e),
+                 file=sys.stderr)
+
+
     def setUp(self):
         self.sess = helpers.make_session()
 
@@ -26,14 +74,12 @@ class TestQuery(unittest.TestCase):
         self.coll = self.sess.collections.create(self.coll_path)
         self.obj = self.sess.data_objects.create(self.obj_path)
 
-
     def tearDown(self):
         '''Remove test data and close connections
         '''
         self.coll.remove(recurse=True, force=True)
         self.sess.cleanup()
 
-
     def test_collections_query(self):
         # collection query test
         result = self.sess.query(Collection.id, Collection.name).all()
@@ -145,6 +191,20 @@ class TestQuery(unittest.TestCase):
             results = self.sess.query(User.name).order_by(
                 User.name, order='moo').all()
 
+    def test_query_order_by_col_not_in_result__183(self):
+        test_collection_size = 8
+        test_collection_path = '/{0}/home/{1}/testcoln_for_col_not_in_result'.format(self.sess.zone, self.sess.username)
+        c1 = c2 = None
+        try:
+            c1 = helpers.make_test_collection( self.sess, test_collection_path+"1", obj_count=test_collection_size)
+            c2 = helpers.make_test_collection( self.sess, test_collection_path+"2", obj_count=test_collection_size)
+            d12 = [ sorted([d.id for d in c.data_objects]) for c in sorted((c1,c2),key=lambda c:c.id) ]
+            query = self.sess.query(DataObject).filter(Like(Collection.name, test_collection_path+"_")).order_by(Collection.id)
+            q12 = list(map(lambda res:res[DataObject.id], query))
+            self.assertTrue(d12[0] + d12[1] == sorted( q12[:test_collection_size] ) + sorted( q12[test_collection_size:]))
+        finally:
+            if c1: c1.remove(recurse=True,force=True)
+            if c2: c2.remove(recurse=True,force=True)
 
     def test_query_with_like_condition(self):
         '''Equivalent to:
@@ -154,7 +214,6 @@ class TestQuery(unittest.TestCase):
         query = self.sess.query(Resource).filter(Like(Resource.name, 'dem%'))
         self.assertIn('demoResc', [row[Resource.name] for row in query])
 
-
     def test_query_with_between_condition(self):
         '''Equivalent to:
         iquest "select RESC_NAME, COLL_NAME, DATA_NAME where DATA_MODIFY_TIME between '01451606400' '...'"
@@ -171,6 +230,316 @@ class TestQuery(unittest.TestCase):
             res_str = '{} {}/{}'.format(result[Resource.name], result[Collection.name], result[DataObject.name])
             self.assertIn(session.zone, res_str)
 
+    def test_query_with_in_condition(self):
+        collection = self.coll_path
+        filename = 'test_query_id_in_list.txt'
+        file_path = '{collection}/{filename}'.format(**locals())
+        obj1 = helpers.make_object(self.sess, file_path+'-1')
+        obj2 = helpers.make_object(self.sess, file_path+'-2')
+        ids = [x.id for x in (obj1,obj2)]
+        for number in range(3):  # slice for empty(:0), first(:1) or both(:2)
+            search_tuple = (ids[:number] if number >= 1 else [0] + ids[:number])
+            q = self.sess.query(DataObject.name).filter(In( DataObject.id, search_tuple ))
+            self.assertEqual (number, rows_returned(q))
+
+    def test_simultaneous_multiple_AVU_joins(self):
+        objects = []
+        decoys = []
+        try:
+            collection = self.coll_path
+            filename = 'test_multiple_AVU_joins'
+            file_path = '{collection}/{filename}'.format(**locals())
+            for x in range(3,9):
+                obj = helpers.make_object(self.sess, file_path+'-{}'.format(x))  # with metadata
+                objects.append(obj)
+                obj.metadata.add('A_meta','1{}'.format(x))
+                obj.metadata.add('B_meta','2{}'.format(x))
+                decoys.append(helpers.make_object(self.sess, file_path+'-dummy{}'.format(x)))   # without metadata
+            self.assertTrue( len(objects) > 0 )
+
+            # -- test simple repeat of same column --
+            q = self.sess.query(DataObject,DataObjectMeta).\
+                                            filter(DataObjectMeta.name == 'A_meta', DataObjectMeta.value <  '20').\
+                                            filter(DataObjectMeta.name == 'B_meta', DataObjectMeta.value >= '20')
+            self.assertTrue( rows_returned(q) == len(objects) )
+
+            # -- test no-stomp of previous filter --
+            self.assertTrue( ('B_meta','28') in [ (x.name,x.value) for x in objects[-1].metadata.items() ] )
+            q = self.sess.query(DataObject,DataObjectMeta).\
+                                            filter(DataObjectMeta.name == 'B_meta').filter(DataObjectMeta.value < '28').\
+                                            filter(DataObjectMeta.name == 'B_meta').filter(Like(DataObjectMeta.value, '2_'))
+            self.assertTrue( rows_returned(q) == len(objects)-1 )
+
+            # -- test multiple AVU's by same attribute name --
+            objects[-1].metadata.add('B_meta','29')
+            q = self.sess.query(DataObject,DataObjectMeta).\
+                                            filter(DataObjectMeta.name == 'B_meta').filter(DataObjectMeta.value == '28').\
+                                            filter(DataObjectMeta.name == 'B_meta').filter(DataObjectMeta.value == '29')
+            self.assertTrue(rows_returned(q) == 1)
+        finally:
+            for x in (objects + decoys):
+                x.unlink(force=True)
+            helpers.remove_unused_metadata( self.sess )
+
+    def test_query_on_AVU_times(self):
+        test_collection_path = '/{zone}/home/{user}/test_collection'.format( zone = self.sess.zone, user = self.sess.username)
+        testColl = helpers.make_test_collection(self.sess, test_collection_path, obj_count = 1)
+        testData =  testColl.data_objects[0]
+        testResc =  self.sess.resources.get('demoResc')
+        testUser =  self.sess.users.get(self.sess.username)
+        objects =    { 'r': testResc, 'u': testUser, 'c':testColl, 'd':testData }
+        object_IDs = { sfx:obj.id for sfx,obj in objects.items() }
+        tables =  { 'r': (Resource, ResourceMeta),
+                    'u': (User, UserMeta),
+                    'd': (DataObject, DataObjectMeta),
+                    'c': (Collection, CollectionMeta)  }
+        try:
+            str_number_incr = lambda str_numbers : str(1+max([0]+[int(n) if n.isdigit() else 0 for n in str_numbers]))
+            AVU_unique_incr = lambda obj,suffix='' : ( 'a_'+suffix,
+                                                       'v_'+suffix,
+                                                       str_number_incr(avu.units for avu in obj.metadata.items()) )
+            before = datetime.utcnow()
+            time.sleep(1.5)
+            for suffix,obj in objects.items(): obj.metadata.add( *AVU_unique_incr(obj,suffix) )
+            after = datetime.utcnow()
+            for suffix, tblpair in tables.items():
+                self.sess.query( *tblpair ).filter(tblpair[1].modify_time <= after )\
+                                           .filter(tblpair[1].modify_time > before )\
+                                           .filter(tblpair[0].id == object_IDs[suffix] ).one()
+                self.sess.query( *tblpair ).filter(tblpair[1].create_time <= after )\
+                                           .filter(tblpair[1].create_time > before )\
+                                           .filter(tblpair[0].id == object_IDs[suffix] ).one()
+        finally:
+            for obj in objects.values():
+                for avu in obj.metadata.items(): obj.metadata.remove(avu)
+            testColl.remove(recurse=True,force=True)
+            helpers.remove_unused_metadata( self.sess )
+
+
+    def test_multiple_criteria_on_one_column_name(self):
+        collection = self.coll_path
+        filename = 'test_multiple_AVU_joins'
+        file_path = '{collection}/{filename}'.format(**locals())
+        objects = []
+        nobj = 0
+        for x in range(3,9):
+            nobj += 2
+            obj1 = helpers.make_object(self.sess, file_path+'-{}'.format(x))
+            obj2 = helpers.make_object(self.sess, file_path+'-dummy{}'.format(x))
+            objects.extend([obj1,obj2])
+        self.assertTrue( nobj > 0 and len(objects) == nobj )
+        q = self.sess.query(Collection,DataObject)
+        dummy_test = [d for d in q if d[DataObject.name][-1:] != '8'
+                                  and d[DataObject.name][-7:-1] == '-dummy' ]
+        self.assertTrue( len(dummy_test) > 0 )
+        q = q. filter(Like(DataObject.name, '%-dummy_')).\
+               filter(Collection.name == collection) .\
+               filter(DataObject.name != (filename + '-dummy8'))
+        results = [r[DataObject.name] for r in q]
+        self.assertTrue(len(results) == len(dummy_test))
+
+
+    def common_dir_or_vault_info(self):
+        register_opts= {}
+        dir_ = None
+        if self.register_resc:
+            dir_ = irods_shared_reg_resc_vault()
+            register_opts[ kw.RESC_NAME_KW ] = self.register_resc
+        if not(dir_) and helpers.irods_session_host_local (self.sess):
+            dir_ = tempfile.gettempdir()
+        if not dir_:
+            return ()
+        else:
+            return (dir_ , register_opts)
+
+
+    @unittest.skipIf(six.PY3, 'Test is for python2 only')
+    def test_query_for_data_object_with_utf8_name_python2(self):
+        reg_info = self.common_dir_or_vault_info()
+        if not reg_info:
+            self.skipTest('server is non-localhost and no common path exists for object registration')
+        (dir_,resc_option) = reg_info
+        filename_prefix = '_prefix_ǠǡǢǣǤǥǦǧǨǩǪǫǬǭǮǯǰDZDzdzǴǵǶǷǸ'
+        self.assertEqual(self.FILENAME_PREFIX.encode('utf-8'), filename_prefix)
+        _,test_file = tempfile.mkstemp(dir=dir_,prefix=filename_prefix)
+        obj_path = os.path.join(self.coll.path, os.path.basename(test_file))
+        results = None
+        try:
+            self.sess.data_objects.register(test_file, obj_path, **resc_option)
+            results = self.sess.query(DataObject, Collection.name).filter(DataObject.path == test_file).first()
+            result_logical_path = os.path.join(results[Collection.name], results[DataObject.name])
+            result_physical_path = results[DataObject.path]
+            self.assertEqual(result_logical_path, obj_path)
+            self.assertEqual(result_physical_path, test_file)
+        finally:
+            if results: self.sess.data_objects.unregister(obj_path)
+            os.remove(test_file)
+
+    # view/change this line in text editors under own risk:
+    FILENAME_PREFIX = u'_prefix_ǠǡǢǣǤǥǦǧǨǩǪǫǬǭǮǯǰDZDzdzǴǵǶǷǸ' 
+
+    @unittest.skipIf(six.PY2, 'Test is for python3 only')
+    def test_query_for_data_object_with_utf8_name_python3(self):
+        reg_info = self.common_dir_or_vault_info()
+        if not reg_info:
+            self.skipTest('server is non-localhost and no common path exists for object registration')
+        (dir_,resc_option) = reg_info
+        def python34_unicode_mkstemp( prefix, dir = None, open_mode = 0o777 ):
+            file_path = os.path.join ((dir or os.environ.get('TMPDIR') or '/tmp'), prefix+'-'+str(uuid.uuid1()))
+            encoded_file_path = file_path.encode('utf-8')
+            return os.open(encoded_file_path,os.O_CREAT|os.O_RDWR,mode=open_mode), encoded_file_path
+        fd = None
+        filename_prefix = u'_prefix_'\
+            u'\u01e0\u01e1\u01e2\u01e3\u01e4\u01e5\u01e6\u01e7\u01e8\u01e9\u01ea\u01eb\u01ec\u01ed\u01ee\u01ef'\
+            u'\u01f0\u01f1\u01f2\u01f3\u01f4\u01f5\u01f6\u01f7\u01f8'  # make more visible/changeable in VIM
+        self.assertEqual(self.FILENAME_PREFIX, filename_prefix)
+        (fd,encoded_test_file) = tempfile.mkstemp(dir = dir_.encode('utf-8'),prefix=filename_prefix.encode('utf-8')) \
+            if sys.version_info >= (3,5) \
+            else python34_unicode_mkstemp(dir = dir_, prefix = filename_prefix)
+        self.assertTrue(os.path.exists(encoded_test_file))
+        test_file = encoded_test_file.decode('utf-8')
+        obj_path = os.path.join(self.coll.path, os.path.basename(test_file))
+        results = None
+        try:
+            self.sess.data_objects.register(test_file, obj_path, **resc_option)
+            results = list(self.sess.query(DataObject, Collection.name).filter(DataObject.path == test_file))
+            if results:
+                results = results[0]
+                result_logical_path = os.path.join(results[Collection.name], results[DataObject.name])
+                result_physical_path = results[DataObject.path]
+                self.assertEqual(result_logical_path, obj_path)
+                self.assertEqual(result_physical_path, test_file)
+        finally:
+            if results: self.sess.data_objects.unregister(obj_path)
+            if fd is not None: os.close(fd)
+            os.remove(encoded_test_file)
+
+    class Issue_166_context:
+        '''
+        For [irods/python-irodsclient#166] related tests
+        '''
+
+        def __init__(self, session, coll_path='test_collection_issue_166', num_objects=8, num_avus_per_object=0):
+            self.session = session
+            if '/' not in coll_path:
+                coll_path = '/{}/home/{}/{}'.format(self.session.zone, self.session.username, coll_path)
+            self.coll_path = coll_path
+            self.num_objects = num_objects
+            self.test_collection = None
+            self.nAVUs = num_avus_per_object
+
+        def __enter__(self): # - prepare for context block ("with" statement)
+
+            self.test_collection = helpers.make_test_collection( self.session, self.coll_path, obj_count=self.num_objects)
+            q_params = (Collection.name, DataObject.name)
+
+            if self.nAVUs > 0:
+
+                # - set the AVUs on the collection's objects:
+                for data_obj_path in map(lambda d:d[Collection.name]+"/"+d[DataObject.name],
+                                         self.session.query(*q_params).filter(Collection.name == self.test_collection.path)):
+                    data_obj = self.session.data_objects.get(data_obj_path)
+                    for key in (str(x) for x in py3_range(self.nAVUs)):
+                        data_obj.metadata[key] = iRODSMeta(key, "1")
+
+                # - in subsequent test searches, match on each AVU of every data object in the collection:
+                q_params += (DataObjectMeta.name,)
+
+            # - The "with" statement receives, as context variable, a zero-arg function to build the query
+            return lambda : self.session.query( *q_params ).filter( Collection.name == self.test_collection.path)
+
+        def __exit__(self,*_): # - clean up after context block
+
+            if self.test_collection is not None:
+                self.test_collection.remove(recurse=True, force=True)
+
+            if self.nAVUs > 0 and self.num_objects > 0:
+                helpers.remove_unused_metadata(self.session)            # delete unused AVU's
+
+    def test_query_first__166(self):
+
+        with self.Issue_166_context(self.sess) as buildQuery:
+            for dummy_i in self.Iterate_to_exhaust_statement_table:
+                buildQuery().first()
+
+    def test_query_one__166(self):
+
+        with self.Issue_166_context(self.sess, num_objects = self.More_than_one_batch) as buildQuery:
+
+            for dummy_i in self.Iterate_to_exhaust_statement_table:
+                query = buildQuery()
+                try:
+                    query.one()
+                except MultipleResultsFound:
+                    pass # irrelevant result
+
+    def test_query_one_iter__166(self):
+
+        with self.Issue_166_context(self.sess, num_objects = self.More_than_one_batch) as buildQuery:
+
+            for dummy_i in self.Iterate_to_exhaust_statement_table:
+
+                for dummy_row in buildQuery():
+                    break # single iteration
+
+    def test_paging_get_batches_and_check_paging__166(self):
+
+        with self.Issue_166_context( self.sess, num_objects = 1,
+                                     num_avus_per_object = 2 * self.More_than_one_batch) as buildQuery:
+
+            pages = [b for b in buildQuery().get_batches()]
+            self.assertTrue(len(pages) > 2 and len(pages[0]) < self.More_than_one_batch)
+
+            to_compare = []
+
+            for _ in self.Iterate_to_exhaust_statement_table:
+
+                for batch in buildQuery().get_batches():
+                    to_compare.append(batch)
+                    if len(to_compare) == 2: break  #leave query unfinished, but save two pages to compare
+
+                # - To make sure paging was done, we ensure that this "key" tuple (collName/dataName , metadataKey)
+                #   is not repeated between first two pages:
+
+                Compare_Key = lambda d: ( d[Collection.name] + "/" + d[DataObject.name],
+                                          d[DataObjectMeta.name] )
+                Set0 = { Compare_Key(dct) for dct in to_compare[0] }
+                Set1 = { Compare_Key(dct) for dct in to_compare[1] }
+                self.assertTrue(len(Set0 & Set1) == 0) # assert intersection is null set
+
+    def test_paging_get_results__166(self):
+
+        with self.Issue_166_context( self.sess, num_objects = self.More_than_one_batch) as buildQuery:
+            batch_size = 0
+            for result_set in buildQuery().get_batches():
+                batch_size = len(result_set)
+                break
+
+            self.assertTrue(0 < batch_size < self.More_than_one_batch)
+
+            for dummy_iter in self.Iterate_to_exhaust_statement_table:
+                iters = 0
+                for dummy_row in buildQuery().get_results():
+                    iters += 1
+                    if iters == batch_size - 1:
+                        break # leave iteration unfinished
+
+    def test_rules_query__267(self):
+        unique = "Testing prc #267: queryable rule objects"
+        with NamedTemporaryFile(mode='w') as rfile:
+            rfile.write("""f() {{ delay('<EF>1m</EF>') {{ writeLine('serverLog','{unique}') }} }}\n"""
+                        """OUTPUT null\n""".format(**locals()))
+            rfile.flush()
+            ## create a delayed rule we can query against
+            myrule = Rule(self.sess, rule_file = rfile.name)
+            myrule.execute()
+        qu = self.sess.query(RuleExec.id).filter( Like(RuleExec.frequency,'%1m%'),
+                                                  Like(RuleExec.name, '%{unique}%'.format(**locals())) )
+        results = [row for row in qu]
+        self.assertEqual(1, len(results))
+        if results:
+            Rule(self.sess).remove_by_id( results[0][RuleExec.id] )
+
 
 class TestSpecificQuery(unittest.TestCase):
 
@@ -261,7 +630,6 @@ class TestSpecificQuery(unittest.TestCase):
         # remove query
         query.remove()
 
-
     def test_list_specific_queries(self):
         query = SpecificQuery(self.session, alias='ls')
 
@@ -270,7 +638,15 @@ class TestSpecificQuery(unittest.TestCase):
             self.assertIn('SELECT', result[1].upper())  # query string
 
 
-    def test_list_specific_queries_with_wrong_alias(self):
+    def test_list_specific_queries_with_arguments(self):
+        query = SpecificQuery(self.session, alias='lsl', args=['%OFFSET%'])
+
+        for result in query:
+            self.assertIsNotNone(result[0])             # query alias
+            self.assertIn('SELECT', result[1].upper())  # query string
+
+
+    def test_list_specific_queries_with_unknown_alias(self):
         query = SpecificQuery(self.session, alias='foo')
 
         with self.assertRaises(CAT_UNKNOWN_SPECIFIC_QUERY):
@@ -278,6 +654,7 @@ class TestSpecificQuery(unittest.TestCase):
             next(res)
 
 
+
 if __name__ == '__main__':
     # let the tests find the parent irods lib
     sys.path.insert(0, os.path.abspath('../..'))
diff --git a/irods/test/rule_test.py b/irods/test/rule_test.py
index dfcc624..134cb3d 100644
--- a/irods/test/rule_test.py
+++ b/irods/test/rule_test.py
@@ -7,9 +7,16 @@ import time
 import textwrap
 import unittest
 from irods.models import DataObject
+from irods.exception import (FAIL_ACTION_ENCOUNTERED_ERR, RULE_ENGINE_ERROR, UnknowniRODSError)
 import irods.test.helpers as helpers
 from irods.rule import Rule
 import six
+from io import open as io_open
+import io
+
+
+RE_Plugins_installed_run_condition_args = ( os.environ.get('PYTHON_RULE_ENGINE_INSTALLED','*').lower()[:1]=='y',
+                                           'Test depends on server having Python-REP installed beyond the default options' )
 
 
 class TestRule(unittest.TestCase):
@@ -75,6 +82,155 @@ class TestRule(unittest.TestCase):
         # remove rule file
         os.remove(rule_file_path)
 
+    def test_set_metadata_288(self):
+
+        session = self.sess
+
+        # rule body
+        rule_body = textwrap.dedent('''\
+                                    *attribute.*attr_name = "*attr_value"
+                                    msiAssociateKeyValuePairsToObj(*attribute, *object, "-d")
+                                    # (: -- just a comment -- :)  writeLine("serverLog","*value")
+                                    ''')
+
+        input_params = { '*value': "3334" , "*object": '/tempZone/home/rods/runner.py' ,
+                                          '*attr_name':'XX',
+                                          '*attr_value':'YY'
+        }
+
+        output = 'ruleExecOut'
+
+        myrule = Rule(session, body=rule_body, params=input_params, output=output)
+        myrule.execute()
+
+
+    # test catching fail-type actions initiated directly in the instance being called.
+    #
+    @unittest.skipUnless (*RE_Plugins_installed_run_condition_args)
+    def test_with_fail_in_targeted_rule_engines(self):
+        self._failing_in_targeted_rule_engines(rule_to_call = "generic_failing_rule")
+
+
+    # test catching rule fail actions initiated using the native 'failmsg' microservice.
+    #
+    @unittest.skipUnless (*RE_Plugins_installed_run_condition_args)
+    def test_with_using_native_fail_msvc(self):
+        error_dict = \
+        self._failing_in_targeted_rule_engines(rule_to_call = [('irods_rule_engine_plugin-python-instance','failing_with_message_py'),
+                                                               ('irods_rule_engine_plugin-irods_rule_language-instance','failing_with_message')])
+        for v in error_dict.values():
+            self.assertIn('code of minus 2', v[1].args[0])
+
+    # helper for the previous two tests.
+    #
+    def _failing_in_targeted_rule_engines(self, rule_to_call = None):
+        session = self.sess
+        if isinstance(rule_to_call,(list,tuple)):
+            rule_dict = dict(rule_to_call)
+        else:
+            rule_dict = {}
+
+        rule_instances_list = ( 'irods_rule_engine_plugin-irods_rule_language-instance',
+                                'irods_rule_engine_plugin-python-instance' )
+        err_hash = {}
+
+        for i in rule_instances_list:
+
+            if rule_dict:
+                rule_to_call = rule_dict[i]
+
+            rule = Rule( session, body = rule_to_call,
+                         instance_name = i )
+            try:
+                rule.execute( acceptable_errors = (-1,) )
+            except UnknowniRODSError as e:
+                err_hash[i] = ('rule exec failed! - misc - ',(e)) # 2-tuple = failure
+            except RULE_ENGINE_ERROR as e:
+                err_hash[i] = ('rule exec failed! - python - ',(e)) # 2-tuple = failure
+            except FAIL_ACTION_ENCOUNTERED_ERR as e:
+                err_hash[i] = ('rule exec failed! - native - ',(e))
+            else:
+                err_hash[i] = ('rule exec succeeded!',) # 1-tuple = success
+
+        self.assertEqual( len(err_hash), len(rule_instances_list) )
+        self.assertEqual( len(err_hash), len([val for val in err_hash.values() if val[0].startswith('rule exec failed')]) )
+        return err_hash
+
+
+    @unittest.skipUnless (*RE_Plugins_installed_run_condition_args)
+    def test_targeting_Python_instance_when_rule_multiply_defined(self):
+        self._with_X_instance_when_rule_multiply_defined(
+            instance_name  = 'irods_rule_engine_plugin-python-instance',
+            test_condition = lambda bstring: b'python' in bstring
+            )
+
+    @unittest.skipUnless (*RE_Plugins_installed_run_condition_args)
+    def test_targeting_Native_instance_when_rule_multiply_defined(self):
+        self._with_X_instance_when_rule_multiply_defined(
+            instance_name  = 'irods_rule_engine_plugin-irods_rule_language-instance',
+            test_condition = lambda bstring: b'native' in bstring
+            )
+
+    @unittest.skipUnless (*RE_Plugins_installed_run_condition_args)
+    def test_targeting_Unspecified_instance_when_rule_multiply_defined(self):
+        self._with_X_instance_when_rule_multiply_defined(
+            test_condition = lambda bstring: b'native' in bstring and b'python' in bstring
+            )
+
+    def _with_X_instance_when_rule_multiply_defined(self,**kw):
+        session = self.sess
+        rule = Rule( session, body = 'defined_in_both',
+                     output = 'ruleExecOut',
+                     **{key:val for key,val in kw.items() if key == 'instance_name'}
+                   )
+        output  = rule.execute()
+        buf = output.MsParam_PI[0].inOutStruct.stdoutBuf.buf
+        self.assertTrue(kw['test_condition'](buf.rstrip(b'\0').rstrip()))
+
+
+    def test_specifying_rule_instance(self):
+
+        self._with_writeline_to_stream(
+                stream_name = 'stdout',
+                rule_engine_instance = "irods_rule_engine_plugin-irods_rule_language-instance" )
+
+
+    def _with_writeline_to_stream(self, stream_name = "serverLog",
+                                          output_string = 'test-writeline-to-stream',
+                                          alternate_input_params = (),
+                                          rule_engine_instance = ""):
+
+        session = self.sess
+
+        # rule body
+        rule_body = textwrap.dedent('''\
+                                    writeLine("{stream_name}","*value")
+                                    '''.format(**locals()))
+
+        input_params = { '*value': output_string }
+        input_params.update( alternate_input_params )
+
+        output_param = 'ruleExecOut'
+
+        extra_options = {}
+
+        if rule_engine_instance:
+            extra_options [ 'instance_name' ] = rule_engine_instance
+
+        myrule = Rule(session, body=rule_body, params=input_params, output=output_param, **extra_options)
+        output = myrule.execute()
+
+        buf = None
+        if stream_name == 'stdout':
+            buf = output.MsParam_PI[0].inOutStruct.stdoutBuf.buf
+        elif stream_name == 'stderr':
+            buf = output.MsParam_PI[0].inOutStruct.stderrBuf.buf
+
+        if buf is not None:
+            buf = buf.decode('utf-8')
+            self.assertEqual (output_string, buf.rstrip('\0').rstrip())
+
+
     def test_add_metadata_from_rule(self):
         '''
         Runs a rule whose body and input parameters are created in our script.
@@ -114,7 +270,7 @@ class TestRule(unittest.TestCase):
         output = 'ruleExecOut'
 
         # run test rule
-        myrule = Rule(session, body=rule_body,
+        myrule = Rule(session, body=rule_body, irods_3_literal_style = True,
                       params=input_params, output=output)
         myrule.execute()
 
@@ -126,6 +282,7 @@ class TestRule(unittest.TestCase):
         # remove test object
         obj.unlink(force=True)
 
+
     def test_retrieve_std_streams_from_rule(self):
         '''
         Tests running a rule from a client-side .r file.
@@ -157,11 +314,8 @@ class TestRule(unittest.TestCase):
                                 INPUT *some_string="{some_string}",*some_other_string="{some_other_string}",*err_string="{err_string}"
                                 OUTPUT ruleExecOut'''.format(**locals()))
 
-        with open(rule_file_path, "w") as rule_file:
-            if six.PY2:
-                rule_file.write(rule.encode('utf-8'))
-            else:
-                rule_file.write(rule)
+        with io_open(rule_file_path, "w", encoding='utf-8') as rule_file:
+            rule_file.write(rule)
 
         # run test rule
         myrule = Rule(session, rule_file_path)
@@ -188,6 +342,64 @@ class TestRule(unittest.TestCase):
         os.remove(rule_file_path)
 
 
+    @staticmethod
+    def lines_from_stdout_buf(output):
+        buf = ""
+        if output and len(output.MsParam_PI):
+            buf = output.MsParam_PI[0].inOutStruct.stdoutBuf.buf
+            if buf:
+                buf = buf.rstrip(b'\0').decode('utf8')
+        return buf.splitlines()
+
+
+    def test_rulefile_in_file_like_object_1__336(self):
+
+        rule_file_contents = textwrap.dedent(u"""\
+        hw {
+                helloWorld(*message);
+                writeLine("stdout", "Message is: [*message] ...");
+        }
+        helloWorld(*OUT)
+        {
+          *OUT = "Hello world!"
+        }
+        """)
+        r = Rule(self.sess, rule_file = io.StringIO( rule_file_contents ),
+                            output = 'ruleExecOut', instance_name='irods_rule_engine_plugin-irods_rule_language-instance')
+        output = r.execute()
+        lines = self.lines_from_stdout_buf(output)
+        self.assertRegexpMatches (lines[0], '.*\[Hello world!\]')
+
+
+    def test_rulefile_in_file_like_object_2__336(self):
+
+        rule_file_contents = textwrap.dedent("""\
+        main {
+          other_rule()
+          writeLine("stdout","["++type(*msg2)++"][*msg2]");
+        }
+        other_rule {
+          writeLine("stdout","["++type(*msg1)++"][*msg1]");
+        }
+
+        INPUT *msg1="",*msg2=""
+        OUTPUT ruleExecOut
+        """)
+
+        r = Rule(self.sess, rule_file = io.BytesIO( rule_file_contents.encode('utf-8') ))
+        output = r.execute()
+        lines = self.lines_from_stdout_buf(output)
+        self.assertRegexpMatches (lines[0], '\[STRING\]\[\]')
+        self.assertRegexpMatches (lines[1], '\[STRING\]\[\]')
+
+        r = Rule(self.sess, rule_file = io.BytesIO( rule_file_contents.encode('utf-8') )
+                          , params = {'*msg1':5, '*msg2':'"A String"'})
+        output = r.execute()
+        lines = self.lines_from_stdout_buf(output)
+        self.assertRegexpMatches (lines[0], '\[INTEGER\]\[5\]')
+        self.assertRegexpMatches (lines[1], '\[STRING\]\[A String\]')
+
+
 if __name__ == '__main__':
     # let the tests find the parent irods lib
     sys.path.insert(0, os.path.abspath('../..'))
diff --git a/irods/test/setupssl.py b/irods/test/setupssl.py
new file mode 100755
index 0000000..aab6bd1
--- /dev/null
+++ b/irods/test/setupssl.py
@@ -0,0 +1,50 @@
+#!/usr/bin/env python
+
+from __future__ import print_function
+import os
+import sys
+import socket
+import posix
+import shutil
+from subprocess import (Popen, PIPE)
+
+IRODS_SSL_DIR = '/etc/irods/ssl'
+
+def create_ssl_dir():
+    save_cwd = os.getcwd()
+    silent_run =  { 'shell': True, 'stderr' : PIPE, 'stdout' : PIPE }
+    try:
+        if not (os.path.exists(IRODS_SSL_DIR)):
+            os.mkdir(IRODS_SSL_DIR)
+        os.chdir(IRODS_SSL_DIR)
+        Popen("openssl genrsa -out irods.key 2048",**silent_run).communicate()
+        with open("/dev/null","wb") as dev_null:
+            p = Popen("openssl req -new -x509 -key irods.key -out irods.crt -days 365 <<EOF{_sep_}"
+                      "US{_sep_}North Carolina{_sep_}Chapel Hill{_sep_}UNC{_sep_}RENCI{_sep_}"
+                      "{host}{_sep_}anon@mail.com{_sep_}EOF\n""".format(
+                host = socket.gethostname(), _sep_="\n"),shell=True, stdout=dev_null, stderr=dev_null)
+            p.wait()
+            if 0 == p.returncode:
+                Popen('openssl dhparam -2 -out dhparams.pem',**silent_run).communicate()
+        return os.listdir(".")
+    finally:
+        os.chdir(save_cwd)
+
+def test(opts,args=()):
+    if args: print ('warning: non-option args are ignored',file=sys.stderr)
+    affirm = 'n' if os.path.exists(IRODS_SSL_DIR) else 'y'
+    if not [v for k,v in opts if k == '-f'] and affirm == 'n' and posix.isatty(sys.stdin.fileno()):
+        try:
+            input_ = raw_input
+        except NameError:
+            input_ = input
+        affirm = input_("This will overwrite directory '{}'. Proceed(Y/N)? ".format(IRODS_SSL_DIR))
+    if affirm[:1].lower() == 'y':
+        shutil.rmtree(IRODS_SSL_DIR,ignore_errors=True)
+        print("Generating new '{}'. This may take a while.".format(IRODS_SSL_DIR), file=sys.stderr)
+        ssl_dir_files = create_ssl_dir()
+        print('ssl_dir_files=', ssl_dir_files)
+    
+if __name__ == '__main__':
+    import getopt
+    test(*getopt.getopt(sys.argv[1:],'f')) # f = force
diff --git a/irods/test/temp_password_test.py b/irods/test/temp_password_test.py
new file mode 100644
index 0000000..9f18fbd
--- /dev/null
+++ b/irods/test/temp_password_test.py
@@ -0,0 +1,86 @@
+#! /usr/bin/env python
+from __future__ import absolute_import
+import os
+import sys
+import unittest
+from irods.exception import UserDoesNotExist
+from irods.session import iRODSSession
+import irods.test.helpers as helpers
+
+
+class TestTempPassword(unittest.TestCase):
+    """ Suite of tests for setting and getting temporary passwords as rodsadmin or rodsuser
+    """
+    admin = None
+    new_user = 'bobby'
+    password = 'foobar'
+
+    @classmethod
+    def setUpClass(cls):
+        cls.admin = helpers.make_session()
+
+    @classmethod
+    def tearDownClass(cls):
+        cls.admin.cleanup()
+
+    def test_temp_password(self):
+        # Make a new user
+        self.admin.users.create(self.new_user, 'rodsuser')
+        self.admin.users.modify(self.new_user, 'password', self.password)
+
+        # Login as the new test user, to retrieve a temporary password
+        with iRODSSession(host=self.admin.host,
+                          port=self.admin.port,
+                          user=self.new_user,
+                          password=self.password,
+                          zone=self.admin.zone) as session:
+            # Obtain the temporary password
+            conn = session.pool.get_connection()
+            temp_password = conn.temp_password()
+
+        # Open a new session with the temporary password
+        with iRODSSession(host=self.admin.host,
+                          port=self.admin.port,
+                          user=self.new_user,
+                          password=temp_password,
+                          zone=self.admin.zone) as session:
+
+            # do something that connects to the server
+            session.users.get(self.admin.username)
+
+        # delete new user
+        self.admin.users.remove(self.new_user)
+
+        # user should be gone
+        with self.assertRaises(UserDoesNotExist):
+            self.admin.users.get(self.new_user)
+
+    def test_set_temp_password(self):
+        # make a new user
+        temp_user = self.admin.users.create(self.new_user, 'rodsuser')
+
+        # obtain a temporary password as rodsadmin for another user
+        temp_password = temp_user.temp_password()
+
+        # open a session as the new user
+        with iRODSSession(host=self.admin.host,
+                          port=self.admin.port,
+                          user=self.new_user,
+                          password=temp_password,
+                          zone=self.admin.zone) as session:
+
+            # do something that connects to the server
+            session.users.get(self.new_user)
+
+        # delete new user
+        self.admin.users.remove(self.new_user)
+
+        # user should be gone
+        with self.assertRaises(UserDoesNotExist):
+            self.admin.users.get(self.new_user)
+
+
+if __name__ == '__main__':
+    # let the tests find the parent irods lib
+    sys.path.insert(0, os.path.abspath('../..'))
+    unittest.main()
diff --git a/irods/test/test-data/irods_environment.json b/irods/test/test-data/irods_environment.json
new file mode 100644
index 0000000..2bf0fe8
--- /dev/null
+++ b/irods/test/test-data/irods_environment.json
@@ -0,0 +1,7 @@
+{
+    "irods_host": "127.0.0.1",
+    "irods_port": "1247",
+    "irods_user_name": "rods",
+    "irods_zone_name": "tempZone",
+    "irods_connection_refresh_time": "3"
+}
diff --git a/irods/test/test-data/irods_environment_negative_refresh_field.json b/irods/test/test-data/irods_environment_negative_refresh_field.json
new file mode 100644
index 0000000..29803f3
--- /dev/null
+++ b/irods/test/test-data/irods_environment_negative_refresh_field.json
@@ -0,0 +1,7 @@
+{
+    "irods_host": "127.0.0.1",
+    "irods_port": "1247",
+    "irods_user_name": "rods",
+    "irods_zone_name": "tempZone",
+    "irods_connection_refresh_time": "-3"
+}
diff --git a/irods/test/test-data/irods_environment_no_refresh_field.json b/irods/test/test-data/irods_environment_no_refresh_field.json
new file mode 100644
index 0000000..9856098
--- /dev/null
+++ b/irods/test/test-data/irods_environment_no_refresh_field.json
@@ -0,0 +1,6 @@
+{
+    "irods_host": "127.0.0.1",
+    "irods_port": "1247",
+    "irods_user_name": "rods",
+    "irods_zone_name": "tempZone"
+}
diff --git a/irods/test/ticket_test.py b/irods/test/ticket_test.py
new file mode 100644
index 0000000..798a3a6
--- /dev/null
+++ b/irods/test/ticket_test.py
@@ -0,0 +1,364 @@
+#! /usr/bin/env python
+from __future__ import print_function
+from __future__ import absolute_import
+import os
+import sys
+import unittest
+import time
+import calendar
+
+import irods.test.helpers as helpers
+import tempfile
+from irods.session import iRODSSession
+import irods.exception as ex
+import irods.keywords as kw
+from irods.ticket import Ticket
+from irods.models import (TicketQuery,DataObject,Collection)
+
+
+# As with most of the modules in this test suite, session objects created via
+# make_session() are implicitly agents of a rodsadmin unless otherwise indicated.
+# Counterexamples within this module shall be obvious as they are instantiated by
+# the login() method, and always tied to one of the traditional rodsuser names
+# widely used in iRODS test suites, ie. 'alice' or 'bob'.
+
+
+def gmtime_to_timestamp (gmt_struct):
+    return "{0.tm_year:04d}-{0.tm_mon:02d}-{0.tm_mday:02d}."\
+           "{0.tm_hour:02d}:{0.tm_min:02d}:{0.tm_sec:02d}".format(gmt_struct)
+
+
+def delete_my_tickets(session):
+    my_userid = session.users.get( session.username ).id
+    my_tickets = session.query(TicketQuery.Ticket).filter(TicketQuery.Ticket.user_id ==  my_userid)
+    for res in my_tickets:
+        Ticket(session, result = res).delete()
+
+
+class TestRodsUserTicketOps(unittest.TestCase):
+
+    def login(self,user):
+        return iRODSSession (port=self.port,zone=self.zone,host=self.host,
+                user=user.name,password=self.users[user.name])
+
+    @staticmethod
+    def irods_homedir(sess, path_only = False):
+        path = '/{0.zone}/home/{0.username}'.format(sess)
+        if path_only:
+            return path
+        return sess.collections.get(path)
+
+    @staticmethod
+    def list_objects (sess):
+        return [ '{}/{}'.format(o[Collection.name],o[DataObject.name]) for o in
+            sess.query(Collection.name,DataObject.name) ]
+
+    users = {
+            'alice':'apass',
+            'bob':'bpass'
+            }
+
+    def setUp(self):
+
+        self.alice = self.bob = None
+
+        with helpers.make_session() as ses:
+            u = ses.users.get(ses.username)
+            if u.type != 'rodsadmin':
+                self.skipTest('''Test runnable only by rodsadmin.''')
+            self.host = ses.host
+            self.port = ses.port
+            self.zone = ses.zone
+            for newuser,passwd in self.users.items():
+                u = ses.users.create( newuser, 'rodsuser')
+                setattr(self,newuser,u)
+                u.modify('password', passwd)
+
+    def tearDown(self):
+        with helpers.make_session() as ses:
+            for u in self.users:
+                ses.users.remove(u)
+
+
+    def test_admin_keyword_for_tickets (self):
+
+        N_TICKETS = 3
+
+        # Create some tickets as alice.
+
+        with self.login(self.alice) as alice:
+            alice_home_path = self.irods_homedir(alice, path_only = True)
+            ticket_strings = [ Ticket(alice).issue('read', alice_home_path).string for _ in range(N_TICKETS) ]
+
+        # As rodsadmin, use the ADMIN_KW flag to delete alice's tickets.
+
+        with helpers.make_session() as ses:
+            alices_tickets = [t[TicketQuery.Ticket.string] for t in ses.query(TicketQuery.Ticket).filter(TicketQuery.Owner.name == 'alice')]
+            self.assertEqual(len(alices_tickets),N_TICKETS)
+            for s in alices_tickets:
+                Ticket( ses, s ).delete(**{kw.ADMIN_KW:''})
+            alices_tickets = [t[TicketQuery.Ticket.string] for t in ses.query(TicketQuery.Ticket).filter(TicketQuery.Owner.name == 'alice')]
+            self.assertEqual(len(alices_tickets),0)
+
+
+    def test_ticket_expiry (self):
+        with helpers.make_session() as ses:
+            t1 = t2 = dobj = None
+            try:
+                gm_now = time.gmtime()
+                gm_later = time.gmtime( calendar.timegm( gm_now ) + 10 )
+                home = self.irods_homedir(ses)
+                dobj = helpers.make_object(ses, home.path+'/dummy', content='abcxyz')
+
+                later_ts = gmtime_to_timestamp (gm_later)
+                later_epoch = calendar.timegm (gm_later)
+
+                t1 = Ticket(ses)
+                t2 = Ticket(ses)
+
+                tickets = [ _.issue('read',dobj.path).string for _ in (t1,
+                                                                       t2,) ]
+                t1.modify('expire',later_ts)    # - Specify expiry with the human readable timestamp.
+                t2.modify('expire',later_epoch) # - Specify expiry formatted as epoch seconds.
+
+                # Check normal access succeeds prior to expiration
+                for ticket_string in tickets:
+                    with self.login(self.alice) as alice:
+                        Ticket(alice, ticket_string).supply()
+                        alice.data_objects.get(dobj.path)
+
+                # Check that both time formats have effected the same expiry time (The catalog returns epoch secs.)
+                timestamps = []
+                for ticket_string in tickets:
+                    t = ses.query(TicketQuery.Ticket).filter(TicketQuery.Ticket.string == ticket_string).one()
+                    timestamps.append( t [TicketQuery.Ticket.expiry_ts] )
+                self.assertEqual (len(timestamps),2)
+                self.assertEqual (timestamps[0],timestamps[1])
+
+                # Wait for tickets to expire.
+                epoch = int(time.time())
+                while epoch <= later_epoch:
+                    time.sleep(later_epoch - epoch + 1)
+                    epoch = int(time.time())
+
+                Expected_Exception = ex.CAT_TICKET_EXPIRED if ses.server_version >= (4,2,9) \
+                        else ex.SYS_FILE_DESC_OUT_OF_RANGE
+
+                # Check tickets no longer allow access.
+                for ticket_string in tickets:
+                    with self.login(self.alice) as alice, tempfile.NamedTemporaryFile() as f:
+                        Ticket(alice, ticket_string).supply()
+                        with self.assertRaises( Expected_Exception ):
+                            alice.data_objects.get(dobj.path,f.name, **{kw.FORCE_FLAG_KW:''})
+
+            finally:
+                if t1: t1.delete()
+                if t2: t2.delete()
+                if dobj: dobj.unlink(force=True)
+
+
+    def test_object_read_and_write_tickets(self):
+        if self.alice is None or self.bob is None:
+            self.skipTest("A rodsuser (alice and/or bob) could not be created.")
+        t=None
+        data_objs=[]
+        tmpfiles=[]
+        try:
+            # Create ticket for read access to alice's home collection.
+            alice = self.login(self.alice)
+            home = self.irods_homedir(alice)
+
+            # Create 'R' and 'W' in alice's home collection.
+            data_objs = [helpers.make_object(alice,home.path+"/"+name,content='abcxyz') for name in ('R','W')]
+            tickets = {
+                'R': Ticket(alice).issue('read',  home.path + "/R").string,
+                'W': Ticket(alice).issue('write', home.path + "/W").string
+            }
+            # Test only write ticket allows upload.
+            with self.login(self.bob) as bob:
+                rw_names={}
+                for name in  ('R','W'):
+                    Ticket( bob, tickets[name] ).supply()
+                    with tempfile.NamedTemporaryFile (delete=False) as tmpf:
+                        tmpfiles += [tmpf]
+                        rw_names[name] = tmpf.name
+                        tmpf.write(b'hello')
+                    if name=='W':
+                        bob.data_objects.put(tmpf.name,home.path+"/"+name)
+                    else:
+                        try:
+                            bob.data_objects.put(tmpf.name,home.path+"/"+name)
+                        except ex.CAT_NO_ACCESS_PERMISSION:
+                            pass
+                        else:
+                            raise AssertionError("A read ticket allowed a data object write operation to happen without error.")
+
+            # Test upload was successful, by getting and confirming contents.
+
+            with self.login(self.bob) as bob:  # This check must be in a new session or we get CollectionDoesNotExist. - Possibly a new issue [ ]
+                for name in  ('R','W'):
+                    Ticket( bob, tickets[name] ).supply()
+                    bob.data_objects.get(home.path+"/"+name,rw_names[ name ],**{kw.FORCE_FLAG_KW:''})
+                    with open(rw_names[ name ],'r') as tmpread:
+                        self.assertEqual(tmpread.read(),
+                                         'abcxyz' if name == 'R' else 'hello')
+        finally:
+            if t: t.delete()
+            for d in data_objs:
+                d.unlink(force=True)
+            for file_ in tmpfiles: os.unlink( file_.name )
+            alice.cleanup()
+
+
+    def test_coll_read_ticket_between_rodsusers(self):
+        t=None
+        data_objs=[]
+        tmpfiles=[]
+        try:
+            # Create ticket for read access to alice's home collection.
+            alice = self.login(self.alice)
+            tc = Ticket(alice)
+            home = self.irods_homedir(alice)
+            tc.issue('read', home.path)
+
+            # Create 'x' and 'y' in alice's home collection
+            data_objs = [helpers.make_object(alice,home.path+"/"+name,content='abcxyz') for name in ('x','y')]
+
+            with self.login(self.bob) as bob:
+                ts = Ticket( bob, tc.string )
+                ts.supply()
+                # Check collection access ticket allows bob to list both subobjects
+                self.assertEqual(len(self.list_objects(bob)),2)
+                # and that we can get (and read) them properly.
+                for name in ('x','y'):
+                    with tempfile.NamedTemporaryFile (delete=False) as tmpf:
+                        tmpfiles += [tmpf]
+                    bob.data_objects.get(home.path+"/"+name,tmpf.name,**{kw.FORCE_FLAG_KW:''})
+                    with open(tmpf.name,'r') as tmpread:
+                        self.assertEqual(tmpread.read(),'abcxyz')
+
+            td = Ticket(alice)
+            td.issue('read', home.path+"/x")
+
+            with self.login(self.bob) as bob:
+                ts = Ticket( bob, td.string )
+                ts.supply()
+
+                # Check data access ticket allows bob to list only one data object
+                self.assertEqual(len(self.list_objects(bob)),1)
+
+                # ... and fetch that object (verifying content)
+                with tempfile.NamedTemporaryFile (delete=False) as tmpf:
+                    tmpfiles += [tmpf]
+                bob.data_objects.get(home.path+"/x",tmpf.name,**{kw.FORCE_FLAG_KW:''})
+                with open(tmpf.name,'r') as tmpread:
+                    self.assertEqual(tmpread.read(),'abcxyz')
+
+                # ... but not fetch the other data object owned by alice.
+                with self.assertRaises(ex.DataObjectDoesNotExist):
+                    bob.data_objects.get(home.path+"/y")
+        finally:
+            if t: t.delete()
+            for d in data_objs:
+                d.unlink(force=True)
+            for file_ in tmpfiles: os.unlink( file_.name )
+            alice.cleanup()
+
+
+class TestTicketOps(unittest.TestCase):
+
+    def setUp(self):
+        """Create objects for test"""
+        self.sess = helpers.make_session()
+        user = self.sess.users.get(self.sess.username)
+        if user.type != 'rodsadmin':
+            self.skipTest('''Test runnable only by rodsadmin.''')
+
+        admin = self.sess
+        delete_my_tickets( admin )
+
+        # Create test collection
+
+        self.coll_path = '/{}/home/{}/ticket_test_dir'.format(admin.zone, admin.username)
+        self.coll = helpers.make_collection(admin, self.coll_path)
+
+        # Create anonymous test user
+        self.user = admin.users.create('anonymous','rodsuser')
+        self.rodsuser_params = { 'host':admin.host,
+                                 'port':admin.port,
+                                 'user': 'anonymous',
+                                 'password':'',
+                                 'zone':admin.zone }
+
+        # make new data object in the test collection with some initialized content
+
+        self.INITIALIZED_DATA = b'1'*16
+        self.data_path = '{self.coll_path}/ticketed_data'.format(**locals())
+        helpers.make_object (admin, self.data_path, content = self.INITIALIZED_DATA)
+
+        self.MODIFIED_DATA = b'2'*16
+
+        # make new tickets for the various combinations
+
+        self.tickets = {'coll':{},'data':{}}
+        for obj_type in ('coll','data'):
+            for access in ('read','write'):
+                ticket = Ticket(admin)
+                self.tickets [obj_type] [access] = ticket.string
+                ticket.issue( access , getattr(self, obj_type + '_path'))
+
+
+    def tearDown(self):
+        """Clean up tickets , collections and data objects used for test."""
+        admin = self.sess
+        delete_my_tickets( admin )
+        if getattr(self,'coll',None):
+            self.coll.remove(recurse=True, force=True)
+        if getattr(self,'user',None):
+            self.user.remove()
+        admin.cleanup()
+
+
+    def _ticket_read_helper( self, obj_type, download = False ):
+        with iRODSSession( ** self.rodsuser_params ) as user_sess:
+            temp_file = []
+            if download: temp_file += [tempfile.mktemp()]
+            try:
+                Ticket(user_sess, self.tickets[obj_type]['read']).supply()
+                data = user_sess.data_objects.get(self.data_path,*temp_file)
+                self.assertEqual (data.open('r').read(), self.INITIALIZED_DATA)
+                if temp_file:
+                    with open(temp_file[0],'rb') as local_file:
+                        self.assertEqual (local_file.read(), self.INITIALIZED_DATA)
+            finally:
+                if temp_file and os.path.exists(temp_file[0]):
+                    os.unlink(temp_file[0])
+
+
+    def test_data_ticket_read(self): self._ticket_read_helper( obj_type = 'data' )
+
+    def test_coll_ticket_read(self): self._ticket_read_helper( obj_type = 'coll' )
+
+    def test_data_ticket_read_with_download(self): self._ticket_read_helper( obj_type = 'data', download = True )
+
+    def test_coll_ticket_read_with_download(self): self._ticket_read_helper( obj_type = 'coll', download = True )
+
+
+    def _ticket_write_helper( self, obj_type ):
+        with iRODSSession( ** self.rodsuser_params ) as user_sess:
+            Ticket(user_sess, self.tickets[obj_type]['write']).supply()
+            data = user_sess.data_objects.get(self.data_path)
+            with data.open('w') as obj:
+                obj.write(self.MODIFIED_DATA)
+            self.assertEqual (data.open('r').read(), self.MODIFIED_DATA)
+
+
+    def test_data_ticket_write(self): self._ticket_write_helper( obj_type = 'data' )
+
+    def test_coll_ticket_write(self): self._ticket_write_helper( obj_type = 'coll' )
+
+
+if __name__ == '__main__':
+    # let the tests find the parent irods lib
+    sys.path.insert(0, os.path.abspath('../..'))
+    unittest.main()
diff --git a/irods/test/unicode_test.py b/irods/test/unicode_test.py
index 9ee95fc..b6cc643 100644
--- a/irods/test/unicode_test.py
+++ b/irods/test/unicode_test.py
@@ -6,8 +6,10 @@ import sys
 import unittest
 from irods.models import Collection, DataObject
 import xml.etree.ElementTree as ET
+from irods.message import (ET as ET_set, XML_Parser_Type, current_XML_parser, default_XML_parser)
 import logging
 import irods.test.helpers as helpers
+from six import PY3
 
 logger = logging.getLogger(__name__)
 
@@ -70,21 +72,43 @@ class TestUnicodeNames(unittest.TestCase):
         self.coll.remove(recurse=True, force=True)
         self.sess.cleanup()
 
+    def test_object_name_containing_unicode__318(self):
+        dataname = u"réprouvé"
+        homepath = helpers.home_collection( self.sess )
+        try:
+            ET_set( XML_Parser_Type.QUASI_XML, self.sess.server_version )
+            path = homepath + "/" + dataname
+            self.sess.data_objects.create( path )
+        finally:
+            ET_set( None )
+            self.sess.data_objects.unlink (path, force = True)
+
+        # assert successful switch back to global default
+        self.assertIs( current_XML_parser(), default_XML_parser() )
+
     def test_files(self):
         # Query for all files in test collection
         query = self.sess.query(DataObject.name, Collection.name).filter(
             Collection.name == self.coll_path)
 
+        # Python2 compatibility note:  In keeping with the principle of least surprise, we now ensure
+        # queries return values of 'str' type in Python2.  When and if these quantities have a possibility
+        # of representing unicode quantities, they can then go through a decode stage.
+
+        encode_unless_PY3 = (lambda x:x) if PY3 else (lambda x:x.encode('utf8'))
+        decode_unless_PY3 = (lambda x:x) if PY3 else (lambda x:x.decode('utf8'))
+
         for result in query:
             # check that we got back one of our original names
-            assert result[DataObject.name] in self.names
+            assert result[DataObject.name] in ( [encode_unless_PY3(n) for n in self.names] )
 
             # fyi
-            logger.info(
-                u"{0}/{1}".format(result[Collection.name], result[DataObject.name]))
+            logger.info( u"{0}/{1}".format( decode_unless_PY3(result[Collection.name]),
+                                            decode_unless_PY3(result[DataObject.name]) )
+                       )
 
             # remove from set
-            self.names.remove(result[DataObject.name])
+            self.names.remove(decode_unless_PY3(result[DataObject.name]))
 
         # make sure we got all of them
         self.assertEqual(0, len(self.names))
diff --git a/irods/test/user_group_test.py b/irods/test/user_group_test.py
index ff40c83..e074f4b 100644
--- a/irods/test/user_group_test.py
+++ b/irods/test/user_group_test.py
@@ -3,7 +3,13 @@ from __future__ import absolute_import
 import os
 import sys
 import unittest
-from irods.exception import UserGroupDoesNotExist
+import tempfile
+import shutil
+from irods.exception import UserGroupDoesNotExist, UserDoesNotExist
+from irods.meta import iRODSMetaCollection, iRODSMeta
+from irods.models import User, UserGroup, UserMeta
+from irods.session import iRODSSession
+import irods.exception as ex
 import irods.test.helpers as helpers
 from six.moves import range
 
@@ -18,6 +24,95 @@ class TestUserGroup(unittest.TestCase):
         '''
         self.sess.cleanup()
 
+    def test_modify_password__328(self):
+        ses = self.sess
+        if ses.users.get( ses.username ).type != 'rodsadmin':
+            self.skipTest( 'Only a rodsadmin may run this test.')
+
+        OLDPASS = 'apass'
+        NEWPASS = 'newpass'
+        try:
+            ses.users.create('alice', 'rodsuser')
+            ses.users.modify('alice', 'password', OLDPASS)
+
+            with iRODSSession(user='alice', password=OLDPASS, host=ses.host, port=ses.port, zone=ses.zone) as alice:
+                me = alice.users.get(alice.username)
+                me.modify_password(OLDPASS, NEWPASS)
+
+            with iRODSSession(user='alice', password=NEWPASS, host=ses.host, port=ses.port, zone=ses.zone) as alice:
+                home = helpers.home_collection( alice )
+                alice.collections.get( home ) # Non-trivial operation to test success!
+        finally:
+            try:
+                ses.users.get('alice').remove()
+            except UserDoesNotExist:
+                pass
+
+    @staticmethod
+    def do_something(session):
+        return session.username in [i[User.name] for i in session.query(User)]
+
+    def test_modify_password_with_changing_auth_file__328(self):
+        ses = self.sess
+        if ses.users.get( ses.username ).type != 'rodsadmin':
+            self.skipTest( 'Only a rodsadmin may run this test.')
+        OLDPASS = 'apass'
+        def generator(p = OLDPASS):
+            n = 1
+            old_pw = p
+            while True:
+                pw = p + str(n)
+                yield old_pw, pw
+                n += 1; old_pw = pw
+        password_generator = generator()
+        ENV_DIR = tempfile.mkdtemp()
+        d = dict(password = OLDPASS, user = 'alice', host = ses.host, port = ses.port, zone = ses.zone)
+        (alice_env, alice_auth) = helpers.make_environment_and_auth_files(ENV_DIR, **d)
+        try:
+            ses.users.create('alice', 'rodsuser')
+            ses.users.modify('alice', 'password', OLDPASS)
+            for modify_option, sess_factory in [ (alice_auth, lambda: iRODSSession(**d)),
+                                                 (True,
+                                                 lambda: helpers.make_session(irods_env_file = alice_env,
+                                                                              irods_authentication_file = alice_auth)) ]:
+                OLDPASS,NEWPASS=next(password_generator)
+                with sess_factory() as alice_ses:
+                    alice = alice_ses.users.get(alice_ses.username)
+                    alice.modify_password(OLDPASS, NEWPASS, modify_irods_authentication_file = modify_option)
+            d['password'] = NEWPASS
+            with iRODSSession(**d) as session:
+                self.do_something(session)           # can we still do stuff with the final value of the password?
+        finally:
+            shutil.rmtree(ENV_DIR)
+            ses.users.remove('alice')
+
+    def test_modify_password_with_incorrect_old_value__328(self):
+        ses = self.sess
+        if ses.users.get( ses.username ).type != 'rodsadmin':
+            self.skipTest( 'Only a rodsadmin may run this test.')
+        OLDPASS = 'apass'
+        NEWPASS = 'newpass'
+        ENV_DIR = tempfile.mkdtemp()
+        try:
+            ses.users.create('alice', 'rodsuser')
+            ses.users.modify('alice', 'password', OLDPASS)
+            d = dict(password = OLDPASS, user = 'alice', host = ses.host, port = ses.port, zone = ses.zone)
+            (alice_env, alice_auth) = helpers.make_environment_and_auth_files(ENV_DIR, **d)
+            session_factories = [
+                       (lambda: iRODSSession(**d)),
+                       (lambda: helpers.make_session( irods_env_file = alice_env, irods_authentication_file = alice_auth)),
+            ]
+            for factory in session_factories:
+                with factory() as alice_ses:
+                    alice = alice_ses.users.get(alice_ses.username)
+                    with self.assertRaises( ex.CAT_PASSWORD_ENCODING_ERROR ):
+                        alice.modify_password(OLDPASS + ".", NEWPASS)
+            with iRODSSession(**d) as alice_ses:
+                self.do_something(alice_ses)
+        finally:
+            shutil.rmtree(ENV_DIR)
+            ses.users.remove('alice')
+
     def test_create_group(self):
         group_name = "test_group"
 
@@ -85,7 +180,6 @@ class TestUserGroup(unittest.TestCase):
         with self.assertRaises(UserGroupDoesNotExist):
             self.sess.user_groups.get(group_name)
 
-
     def test_user_dn(self):
         # https://github.com/irods/irods/issues/3620
         if self.sess.server_version == (4, 2, 1):
@@ -106,17 +200,141 @@ class TestUserGroup(unittest.TestCase):
 
         # add other dn
         user.modify('addAuth', user_DNs[1])
-        self.assertEqual(user.dn, user_DNs)
+        self.assertEqual( sorted(user.dn), sorted(user_DNs) )
 
         # remove first dn
         user.modify('rmAuth', user_DNs[0])
 
         # confirm removal
-        self.assertEqual(user.dn, user_DNs[1:])
+        self.assertEqual(sorted(user.dn), sorted(user_DNs[1:]))
 
         # delete user
         user.remove()
 
+    def test_group_metadata(self):
+        group_name = "test_group"
+
+        # group should not be already present
+        with self.assertRaises(UserGroupDoesNotExist):
+            self.sess.user_groups.get(group_name)
+
+        group = None
+
+        try:
+            # create group
+            group = self.sess.user_groups.create(group_name)
+
+            # add metadata to group
+            triple = ['key', 'value', 'unit']
+            group.metadata[triple[0]] = iRODSMeta(*triple)
+
+            result =  self.sess.query(UserMeta, UserGroup).filter(UserGroup.name == group_name,
+                                                                  UserMeta.name == 'key').one()
+
+            self.assertTrue([result[k] for k in (UserMeta.name, UserMeta.value, UserMeta.units)] == triple)
+
+        finally:
+            if group:
+                group.remove()
+                helpers.remove_unused_metadata(self.sess)
+
+    def test_user_metadata(self):
+        user_name = "testuser"
+        user = None
+
+        try:
+            user = self.sess.users.create(user_name, 'rodsuser')
+
+            # metadata collection is the right type?
+            self.assertIsInstance(user.metadata, iRODSMetaCollection)
+
+            # add three AVUs, two having the same key
+            user.metadata['key0'] = iRODSMeta('key0', 'value', 'units')
+            sorted_triples = sorted( [ ['key1', 'value0', 'units0'],
+                                       ['key1', 'value1', 'units1']  ] )
+            for m in sorted_triples:
+                user.metadata.add(iRODSMeta(*m))
+
+            # general query gives the right results?
+            result_0 =  self.sess.query(UserMeta, User)\
+                         .filter( User.name == user_name, UserMeta.name == 'key0').one()
+
+            self.assertTrue( [result_0[k] for k in (UserMeta.name, UserMeta.value, UserMeta.units)]
+                              == ['key0', 'value', 'units'] )
+
+            results_1 =  self.sess.query(UserMeta, User)\
+                         .filter(User.name == user_name, UserMeta.name == 'key1')
+
+            retrieved_triples = [ [ res[k] for k in (UserMeta.name, UserMeta.value, UserMeta.units) ]
+                                  for res in results_1
+                                ]
+
+            self.assertTrue( sorted_triples == sorted(retrieved_triples))
+
+        finally:
+            if user:
+                user.remove()
+                helpers.remove_unused_metadata(self.sess)
+
+    def test_get_user_metadata(self):
+        user_name = "testuser"
+        user = None
+
+        try:
+            # create user
+            user = self.sess.users.create(user_name, 'rodsuser')
+            meta = user.metadata.get_all('key')
+
+            # There should be no metadata
+            self.assertEqual(len(meta), 0)
+        finally:
+            if user: user.remove()
+
+    def test_add_user_metadata(self):
+        user_name = "testuser"
+        user = None
+
+        try:
+            # create user
+            user = self.sess.users.create(user_name, 'rodsuser')
+
+            user.metadata.add('key0', 'value0')
+            user.metadata.add('key1', 'value1', 'unit1')
+            user.metadata.add('key2', 'value2a', 'unit2')
+            user.metadata.add('key2', 'value2b', 'unit2')
+
+            meta0 = user.metadata.get_all('key0')
+            self.assertEqual(len(meta0),1)
+            self.assertEqual(meta0[0].name, 'key0')
+            self.assertEqual(meta0[0].value, 'value0')
+
+            meta1 = user.metadata.get_all('key1')
+            self.assertEqual(len(meta1),1)
+            self.assertEqual(meta1[0].name, 'key1')
+            self.assertEqual(meta1[0].value, 'value1')
+            self.assertEqual(meta1[0].units, 'unit1')
+
+            meta2 = sorted(user.metadata.get_all('key2'), key = lambda AVU : AVU.value)
+            self.assertEqual(len(meta2),2)
+            self.assertEqual(meta2[0].name, 'key2')
+            self.assertEqual(meta2[0].value, 'value2a')
+            self.assertEqual(meta2[0].units, 'unit2')
+            self.assertEqual(meta2[1].name, 'key2')
+            self.assertEqual(meta2[1].value, 'value2b')
+            self.assertEqual(meta2[1].units, 'unit2')
+
+            user.metadata.remove('key1', 'value1', 'unit1')
+            metadata = user.metadata.items()
+            self.assertEqual(len(metadata), 3)
+
+            user.metadata.remove('key2', 'value2a', 'unit2')
+            metadata = user.metadata.items()
+            self.assertEqual(len(metadata), 2)
+
+        finally:
+            if user:
+                user.remove()
+                helpers.remove_unused_metadata(self.sess)
 
 if __name__ == '__main__':
     # let the tests find the parent irods lib
diff --git a/irods/test/zone_test.py b/irods/test/zone_test.py
new file mode 100644
index 0000000..de9baf4
--- /dev/null
+++ b/irods/test/zone_test.py
@@ -0,0 +1,52 @@
+#! /usr/bin/env python
+from __future__ import absolute_import
+import os
+import sys
+import unittest
+
+from irods.models import User,Collection
+from irods.access import iRODSAccess
+from irods.collection import iRODSCollection
+from irods.exception import CollectionDoesNotExist
+import irods.test.helpers as helpers
+
+class TestRemoteZone(unittest.TestCase):
+
+    def setUp(self):
+        self.sess = helpers.make_session()
+
+    def tearDown(self):
+        """Close connections."""
+        self.sess.cleanup()
+
+    # This test should pass whether or not federation is configured:
+    def test_create_other_zone_user_227_228(self):
+        usercolls = []
+        session = self.sess
+        A_ZONE_NAME = 'otherZone'
+        A_ZONE_USER = 'alice'
+        try:
+            zoneB =  session.zones.create(A_ZONE_NAME,'remote')
+            zBuser = session.users.create(A_ZONE_USER,'rodsuser', A_ZONE_NAME, '')
+            usercolls = [ iRODSCollection(session.collections, result) for result in
+                          session.query(Collection).filter(Collection.owner_name == zBuser.name and 
+                                                     Collection.owner_zone == zBuser.zone) ]
+            self.assertEqual ([(u[User.name],u[User.zone]) for u in session.query(User).filter(User.zone == A_ZONE_NAME)],
+                              [(A_ZONE_USER,A_ZONE_NAME)])
+            zBuser.remove()
+            zoneB.remove()
+        finally:
+            for p in usercolls:
+                try:
+                    session.collections.get( p.path )
+                except CollectionDoesNotExist:
+                    continue
+                perm = iRODSAccess( 'own', p.path, session.username, session.zone)
+                session.permissions.set( perm, admin=True)
+                p.remove(force=True)
+
+
+if __name__ == '__main__':
+    # let the tests find the parent irods lib
+    sys.path.insert(0, os.path.abspath('../..'))
+    unittest.main()
diff --git a/irods/ticket.py b/irods/ticket.py
index 297a47c..ec1309b 100644
--- a/irods/ticket.py
+++ b/irods/ticket.py
@@ -1,19 +1,43 @@
 from __future__ import absolute_import
-import random
-import string
+
 from irods.api_number import api_number
-from irods.message import (
-    iRODSMessage, TicketAdminRequest)
+from irods.message import iRODSMessage, TicketAdminRequest
+from irods.models import TicketQuery
 
+import random
+import string
 import logging
+import datetime
+import calendar
+
 
 logger = logging.getLogger(__name__)
 
 
+def get_epoch_seconds (utc_timestamp):
+    epoch = None
+    try:
+        epoch = int(utc_timestamp)
+    except ValueError:
+        pass
+    if epoch is not None:
+        return epoch
+    HUMAN_READABLE_DATE = '%Y-%m-%d.%H:%M:%S'
+    try:
+        x = datetime.datetime.strptime(utc_timestamp,HUMAN_READABLE_DATE)
+        return calendar.timegm( x.timetuple() )
+    except ValueError:
+        raise # final try at conversion, so a failure is an error
+
+
 class Ticket(object):
-    def __init__(self, session, ticket=None):
+    def __init__(self, session,  ticket = '', result = None, allow_punctuation = False):
         self._session = session
-        self._ticket = ticket if ticket else self.generate()
+        try:
+            if result is not None: ticket = result[TicketQuery.Ticket.string]
+        except TypeError:
+            raise RuntimeError( "If specified, 'result' parameter must be a TicketQuery.Ticket search result")
+        self._ticket = ticket if ticket else self._generate(allow_punctuation = allow_punctuation)
 
     @property
     def session(self):
@@ -21,26 +45,53 @@ class Ticket(object):
 
     @property
     def ticket(self):
+        """Return the unique string associated with the ticket object."""
         return self._ticket
 
+    # Provide 'string' property such that self.string is a synonym for self.ticket
+    string = ticket
 
-    def generate(self, length=15):
-        return ''.join(random.SystemRandom().choice(string.ascii_letters + string.digits + string.punctuation) for _ in range(length))
-
+    def _generate(self, length=15, allow_punctuation = False):
+        source_characters = string.ascii_letters + string.digits
+        if allow_punctuation:
+            source_characters += string.punctuation
+        return ''.join(random.SystemRandom().choice(source_characters) for _ in range(length))
 
-    def supply(self):
-        message_body = TicketAdminRequest("session", self.ticket)
+    def _api_request(self,cmd_string,*args, **opts):
+        message_body = TicketAdminRequest(self.session)(cmd_string, self.ticket, *args, **opts)
         message = iRODSMessage("RODS_API_REQ", msg=message_body, int_info=api_number['TICKET_ADMIN_AN'])
 
         with self.session.pool.get_connection() as conn:
             conn.send(message)
             response = conn.recv()
+        return self
 
+    def issue(self,permission,target,**opt): return self._api_request("create",permission,target,**opt)
 
-    def issue(self, permission, target):
-        message_body = TicketAdminRequest("create", self.ticket, permission, target)
-        message = iRODSMessage("RODS_API_REQ", msg=message_body, int_info=api_number['TICKET_ADMIN_AN'])
+    create = issue
 
-        with self.session.pool.get_connection() as conn:
-            conn.send(message)
-            response = conn.recv()
+    def modify(self,*args,**opt):
+        arglist = list(args)
+        if arglist[0].lower().startswith('expir'):
+            arglist[1] = str(get_epoch_seconds(utc_timestamp = arglist[1]))
+        return self._api_request("mod",*arglist,**opt)
+
+    def supply(self,**opt):
+        object_ = self._api_request("session",**opt)
+        self.session.ticket__ = self._ticket
+        return object_
+
+    def delete(self,**opt):
+        """
+        Delete the iRODS ticket.
+
+        This applies to a Ticket object on which issue() has been called or, as the case may
+        be, to a Ticket initialized with a ticket string already existing in the object catalog.
+        The deleted object is returned, but may not be used further except for local purposes
+        such as extracting the string.  E.g.
+
+            for t in tickets:
+                print(t.delete().string, "being deleted")
+
+        """
+        return self._api_request("delete",**opt)
diff --git a/irods/user.py b/irods/user.py
index 4e61471..2cbf901 100644
--- a/irods/user.py
+++ b/irods/user.py
@@ -1,7 +1,9 @@
 from __future__ import absolute_import
 from irods.models import User, UserGroup, UserAuth
+from irods.meta import iRODSMetaCollection
 from irods.exception import NoResultFound
 
+_Not_Defined = ()
 
 class iRODSUser(object):
 
@@ -12,13 +14,41 @@ class iRODSUser(object):
             self.name = result[User.name]
             self.type = result[User.type]
             self.zone = result[User.zone]
+            self._comment = result.get(User.comment, _Not_Defined)  # these not needed in results for object ident,
+            self._info = result.get(User.info, _Not_Defined)        # so we fetch lazily via a property
         self._meta = None
 
+    @property
+    def comment(self):
+        if self._comment == _Not_Defined:
+            query = self.manager.sess.query(User.id,User.comment).filter(User.id == self.id)
+            self._comment = query.one()[User.comment]
+        return self._comment
+
+    @property
+    def info(self):
+        if self._info == _Not_Defined:
+            query = self.manager.sess.query(User.id,User.info).filter(User.id == self.id)
+            self._info = query.one()[User.info]
+        return self._info
+
     @property
     def dn(self):
         query = self.manager.sess.query(UserAuth.user_dn).filter(UserAuth.user_id == self.id)
         return [res[UserAuth.user_dn] for res in query]
 
+    @property
+    def metadata(self):
+        if not self._meta:
+            self._meta = iRODSMetaCollection(
+                 self.manager.sess.metadata, User, self.name)
+        return self._meta
+
+    def modify_password(self, old_value, new_value, modify_irods_authentication_file = False):
+        self.manager.modify_password(old_value,
+                                     new_value,
+                                     modify_irods_authentication_file = modify_irods_authentication_file)
+
     def modify(self, *args, **kwargs):
         self.manager.modify(self.name, *args, **kwargs)
 
@@ -28,6 +58,9 @@ class iRODSUser(object):
     def remove(self):
         self.manager.remove(self.name, self.zone)
 
+    def temp_password(self):
+        return self.manager.temp_password_for_user(self.name)
+
 
 class iRODSUserGroup(object):
 
@@ -48,6 +81,13 @@ class iRODSUserGroup(object):
     def members(self):
         return self.manager.getmembers(self.name)
 
+    @property
+    def metadata(self):
+        if not self._meta:
+            self._meta = iRODSMetaCollection(
+                 self.manager.sess.metadata, User, self.name)
+        return self._meta
+
     def addmember(self, user_name, user_zone=""):
         self.manager.addmember(self.name, user_name, user_zone)
 
diff --git a/irods/version.py b/irods/version.py
index ef72cc0..7b344ec 100644
--- a/irods/version.py
+++ b/irods/version.py
@@ -1 +1 @@
-__version__ = '0.8.1'
+__version__ = '1.1.2'
diff --git a/irods/zone.py b/irods/zone.py
new file mode 100644
index 0000000..3943f9a
--- /dev/null
+++ b/irods/zone.py
@@ -0,0 +1,21 @@
+from __future__ import absolute_import
+from irods.models import Zone
+
+
+class iRODSZone(object):
+
+    def __init__(self, manager, result=None):
+        """Construct an iRODSZone object."""
+        self.manager = manager
+        if result:
+            self.id = result[Zone.id]
+            self.name = result[Zone.name]
+            self.type = result[Zone.type]
+
+    def remove(self):
+        self.manager.remove(self.name)
+
+    def __repr__(self):
+        """Render a user-friendly string representation for the iRODSZone object."""
+        return "<iRODSZone {id} {name} {type}>".format(**vars(self))
+
diff --git a/irods_consortium_continuous_integration_test_module.py b/irods_consortium_continuous_integration_test_module.py
new file mode 100644
index 0000000..b511f9d
--- /dev/null
+++ b/irods_consortium_continuous_integration_test_module.py
@@ -0,0 +1,26 @@
+import json
+import sys
+
+def run (CI):
+
+    final_config = CI.store_config(
+        {
+            "yaml_substitutions": {       # -> written to ".env"
+                "python_version" : "3",
+                "client_os_generic": "ubuntu",
+                "client_os_image": "ubuntu:18.04",
+                "python_rule_engine_installed": "y"
+            },
+            "container_environments": {
+                "client-runner" : {       # -> written to "client-runner.env"
+                    "TESTS_TO_RUN": ""    # run test subset, e.g. "irods.test.data_obj_test"
+                }
+
+            }
+        }
+    )
+
+    print ('----------\nconfig after CI modify pass\n----------',file=sys.stderr)
+    print(json.dumps(final_config,indent=4),file=sys.stderr)
+
+    return CI.run_and_wait_on_client_exit ()
diff --git a/run_python_tests.sh b/run_python_tests.sh
new file mode 100644
index 0000000..5ec2207
--- /dev/null
+++ b/run_python_tests.sh
@@ -0,0 +1,13 @@
+#!/bin/bash
+
+set -o pipefail
+cd repo/irods/test 
+
+export PYTHONUNBUFFERED="Y"
+
+if [ -z "${TESTS_TO_RUN}" ] ; then
+    python"${PY_N}" runner.py 2>&1 | tee "${LOG_OUTPUT_DIR}"/prc_test_logs.txt
+else 
+    python"${PY_N}" -m unittest -v ${TESTS_TO_RUN} 2>&1 | tee "${LOG_OUTPUT_DIR}"/prc_test_logs.txt
+fi
+
diff --git a/setup.py b/setup.py
index e735453..d280ced 100644
--- a/setup.py
+++ b/setup.py
@@ -21,6 +21,7 @@ setup(name='python-irodsclient',
       author_email='support@irods.org',
       description='A python API for iRODS',
       long_description=long_description,
+      long_description_content_type='text/x-rst',
       license='BSD',
       url='https://github.com/irods/python-irodsclient',
       keywords='irods',
@@ -38,6 +39,12 @@ setup(name='python-irodsclient',
       install_requires=[
                         'six>=1.10.0',
                         'PrettyTable>=0.7.2',
-                        'xmlrunner>=1.7.7'
-                        ]
+                        'defusedxml',
+                        # - the new syntax:
+                        #'futures; python_version == "2.7"'
+                        ],
+      # - the old syntax:
+      extras_require={ ':python_version == "2.7"': ['futures'],
+                       'tests': ['unittest-xml-reporting']  # for xmlrunner
+                     }
       )