diff --git a/package/CHANGELOG b/package/CHANGELOG index b59703816b5..fbebf313971 100644 --- a/package/CHANGELOG +++ b/package/CHANGELOG @@ -10,11 +10,15 @@ The rules for this file: use tabs but use spaces for formatting ------------------------------------------------------------------------------ -??/??/15 tyler.je.reddy, richardjgowers, alejob, orbeckst, dotsdl +??/??/15 tyler.je.reddy, richardjgowers, alejob, orbeckst, dotsdl, + manuel.nuno.melo * 0.11.0 Enhancements + * AtomGroups can now be pickled/unpickled (Issue #293) + * Universes can have a __del__ method (not actually added) without + leaking (Issue #297) * Added reading of DL_Poly format CONFIG and HISTORY files (Issue #298) These can both act as both Topology and Coordinate information * Timestep objects now have __eq__ method @@ -26,6 +30,12 @@ The rules for this file: * ProgressMeter now outputs every *interval* number of ``update`` calls Changes + * A ProtoReader class intermediate between IObase and Reader was added so + specific Readers can be subclassed without __del__ (the ChainReader and + SingleFrameReader), thus preventing memleaks (Issue #312). + * Atoms (and all container classes thereof) are now bound to Universes + only via weakrefs. If Universes are not explicitly kept in scope Atoms + will become orphaned. * Timestep can now only init using an integer argument (which represents the number of atoms) * Added from_timestep and from_coordinates construction methods @@ -40,6 +50,7 @@ The rules for this file: * __iter__ removed from many Readers (now use base.Reader implementation) Fixes + * ChainReaders no longer leak (Issue #312) * analysis.hbonds.HydrogenBondAnalysis performs a sanity check for static selections (Issue #296) * Fixed TRZWriter failing when passed a non TRZTimestep (Issue #302) diff --git a/package/MDAnalysis/__init__.py b/package/MDAnalysis/__init__.py index 41bb39c45ac..b505ba98d15 100644 --- a/package/MDAnalysis/__init__.py +++ b/package/MDAnalysis/__init__.py @@ -159,7 +159,6 @@ import logging - # see the advice on logging and libraries in # http://docs.python.org/library/logging.html?#configuring-logging-for-a-library class NullHandler(logging.Handler): @@ -246,3 +245,7 @@ class StreamWarning(Warning): from coordinates.core import writer as Writer collection = Timeseries.TimeseriesCollection() +import weakref +_anchor_universes = weakref.WeakSet() +_named_anchor_universes = weakref.WeakSet() +del weakref diff --git a/package/MDAnalysis/coordinates/__init__.py b/package/MDAnalysis/coordinates/__init__.py index 2706551c5a8..1a58a422ef6 100644 --- a/package/MDAnalysis/coordinates/__init__.py +++ b/package/MDAnalysis/coordinates/__init__.py @@ -367,8 +367,6 @@ ``close()`` close the file and cease I/O - ``__del__()`` - ensure that the trajectory is closed ``next()`` advance to next time step or raise :exc:`IOError` when moving past the last frame @@ -379,6 +377,12 @@ ``__exit__()`` exit method of a `Context Manager`_, should call ``close()``. +.. Note:: + a ``__del__()`` method should also be present to ensure that the + trajectory is properly closed. However, certain types of Reader can ignore + this requirement. These include the :class:`SingleFrameReader` (file reading + is done within a context manager and needs no closing by hand) and the :class:`ChainReader` + (it is a collection of Readers, each already with its own ``__del__`` method). **Optional methods** diff --git a/package/MDAnalysis/coordinates/base.py b/package/MDAnalysis/coordinates/base.py index e60e946aec1..6ad0c9739d0 100644 --- a/package/MDAnalysis/coordinates/base.py +++ b/package/MDAnalysis/coordinates/base.py @@ -602,13 +602,19 @@ def __exit__(self, exc_type, exc_val, exc_tb): return False # do not suppress exceptions -class Reader(IObase): - """Base class for trajectory readers. +class ProtoReader(IObase): + """Base class for Readers, without a :meth:`__del__` method. + + Extends :class:`IObase` with most attributes and methods of a generic Reader, + with the exception of a :meth:`__del__` method. It should be used as base for Readers + that do not need :meth:`__del__`, especially since having even an empty :meth:`__del__` + might lead to memory leaks. See the :ref:`Trajectory API` definition in :mod:`MDAnalysis.coordinates.__init__` for the required attributes and methods. + .. versionadded:: 0.11.0 + .. SeeAlso:: :class:`Reader` """ - #: The appropriate Timestep class, e.g. #: :class:`MDAnalysis.coordinates.xdrfile.XTC.Timestep` for XTC. _Timestep = Timestep @@ -686,9 +692,6 @@ def _read_next_timestep(self, ts=None): # return ts raise NotImplementedError("BUG: Override _read_next_timestep() in the trajectory reader!") - def __del__(self): - self.close() - def __iter__(self): self._reopen() while True: @@ -802,8 +805,24 @@ def __repr__(self): return "< %s %r with %d frames of %d atoms (%d fixed) >" % \ (self.__class__.__name__, self.filename, self.numframes, self.numatoms, self.fixed) +class Reader(ProtoReader): + """Base class for trajectory readers that extends :class:`ProtoReader` with a :meth:`__del__` method. -class ChainReader(Reader): + New Readers should subclass :class:`Reader` and properly implement a :meth:`close` + method, to ensure proper release of resources (mainly file handles). Readers that + are inherently safe in this regard should subclass :class:`ProtoReader` instead. + + See the :ref:`Trajectory API` definition in + :mod:`MDAnalysis.coordinates.__init__` for the required attributes and methods. + .. SeeAlso:: :class:`ProtoReader` + .. versionchanged:: 0.11.0 + Most of the base Reader class definitions were offloaded to :class:`ProtoReader` + so as to allow the subclassing of Readers without a :meth:`__del__` method. + """ + def __del__(self): + self.close() + +class ChainReader(ProtoReader): """Reader that concatenates multiple trajectories on the fly. **Known issues** @@ -1160,7 +1179,7 @@ def has_valid_coordinates(self, criteria, x): # def write_next_timestep(self, ts=None) -class SingleFrameReader(Reader): +class SingleFrameReader(ProtoReader): """Base class for Readers that only have one frame. .. versionadded:: 0.10.0 @@ -1207,11 +1226,9 @@ def _read_frame(self, frame): def read_next_timestep(self): raise IOError(self._err.format(self.__class__.__name__)) - + def close(self): # all single frame readers should use context managers to access - # self.filename + # self.filename. Explicitly setting it to the null action in case + # the IObase.close method is ever changed from that. pass - - def __del__(self): - self.close() diff --git a/package/MDAnalysis/core/AtomGroup.py b/package/MDAnalysis/core/AtomGroup.py index 2496e5708b8..fb487ab5a52 100644 --- a/package/MDAnalysis/core/AtomGroup.py +++ b/package/MDAnalysis/core/AtomGroup.py @@ -407,6 +407,7 @@ import copy import logging import os.path +import weakref # Local imports import MDAnalysis @@ -433,9 +434,16 @@ class Atom(object): are included (and thus it is not possible to add attributes "on the fly"; they have to be included in the class definition). + An :class:`Atom` is bound to a particular :class:`Universe`, but + via a weak reference only. This means that the :class:`Atom`, and + any :class:`AtomGroup` it is in, are only relevant while the + originating :class:`Universe` is in scope. + .. versionchanged 0.9.0 Added fragment managed property. Changed bonds angles torsions impropers to be a managed property + .. versionchanged 0.11.0 + Changed references to :class:`Universe` to be weak. """ __slots__ = ( @@ -462,7 +470,12 @@ def __init__(self, number, name, type, resname, resid, segid, mass, charge, self.radius = radius self.bfactor = bfactor self.serial = serial - self._universe = universe + # Beware: Atoms hold only weakrefs to the universe, enforced + # throught the Atom.universe setter. + if universe is None: + self._universe = None + else: + self.universe = universe def __repr__(self): return ("= universe_natoms: + raise ValueError("Trying to unpickle an inconsistent AtomGroup") + lookup_set = MDAnalysis._anchor_universes if anchor_name is None else MDAnalysis._named_anchor_universes + for test_universe in lookup_set: + if test_universe._matches_unpickling(*state[1:]): + self.__init__(test_universe.atoms[indices]._atoms) + return + raise RuntimeError(("Couldn't find a suitable Universe to unpickle AtomGroup " + "onto. (needed a universe with {}{} atoms, topology filename: {}, and " + "trajectory filename: {}").format( + "anchor_name: {}, ".format(anchor_name) if anchor_name is not None else "", + *state[2:])) def numberOfAtoms(self): """Total number of atoms in the group""" @@ -3216,6 +3271,10 @@ class Universe(object): Changed .bonds attribute to be a :class:`~MDAnalysis.topology.core.TopologyGroup` Added .angles and .torsions attribute as :class:`~MDAnalysis.topology.core.TopologyGroup` Added fragments to Universe cache + .. versionchanged:: 0.11.0 + :meth:`make_anchor`, :meth:`remove_anchor`, :attr:`is_anchor`, and + :attr:`anchor_name` were added to support the pickling/unpickling of + :class:`AtomGroup`. """ def __init__(self, *args, **kwargs): @@ -3264,6 +3323,16 @@ def __init__(self, *args, **kwargs): *vdwradii* For use with *guess_bonds*. Supply a dict giving a vdwradii for each atom type which are used in guessing bonds. + *is_anchor* + When unpickling instances of :class:`MDAnalysis.core.AtomGroup.AtomGroup` + existing Universes are searched for one where to anchor those atoms. Set + to ``False`` to prevent this Universe from being considered. [``True``] + *anchor_name* + Setting to other than ``None`` will cause :class:`MDAnalysis.core.AtomGroup.AtomGroup` + instances pickled from the Universe to only unpickle if a compatible + Universe with matching *anchor_name* is found. *is_anchor* will be ignored in + this case but will still be honored when unpickling :class:`MDAnalysis.core.AtomGroup.AtomGroup` + instances pickled with *anchor_name*==``None``. [``None``] This routine tries to do the right thing: @@ -3291,7 +3360,11 @@ def __init__(self, *args, **kwargs): Added ``'guess_bonds'`` keyword to cause topology to be guessed on Universe creation. Deprecated ``'bonds'`` keyword, use ``'guess_bonds'`` instead. + .. versionchanged:: 0.11.0 + Added the *is_anchor* and *anchor_name* keywords for finer behavior + control when unpickling instances of :class:`MDAnalysis.core.AtomGroup.AtomGroup`. """ + from ..topology.core import get_parser_for, guess_format from ..topology.base import TopologyReader @@ -3373,6 +3446,11 @@ def __init__(self, *args, **kwargs): if kwargs.get('guess_bonds', False): self.atoms.guess_bonds(vdwradii=kwargs.get('vdwradii',None)) + # For control of AtomGroup unpickling + if kwargs.get('is_anchor', True): + self.make_anchor() + self.anchor_name = kwargs.get('anchor_name') + def _clear_caches(self, *args): """Clear cache for all *args*. @@ -3431,7 +3509,6 @@ def _build_segments(self): # create memory problems? self.segments = self.atoms.segments self.residues = self.atoms.residues - self.universe = self # for Writer.write(universe), see Issue 49 def _init_top(self, cat, Top): """Initiate a generic form of topology. @@ -3582,6 +3659,13 @@ def update(self, other): return frags + @property + def universe(self): + # for Writer.write(universe), see Issue 49 + # Encapsulation in an accessor prevents the Universe from having to keep a reference to itself, + # which might be undesirable if it has a __del__ method. It is also cleaner than a weakref. + return self + @property @cached('fragments') def fragments(self): @@ -4096,10 +4180,54 @@ def trajectory(self, value): del self.__trajectory # guarantees that files are closed (?) self.__trajectory = value - # NOTE: DO NOT ADD A __del__() method: it somehow keeps the Universe - # alive during unit tests and the unit tests run out of memory! - #### def __del__(self): <------ do not add this! [orbeckst] + def make_anchor(self): + """Add this Universe to the list where anchors are searched for when unpickling + :class:`MDAnalysis.core.AtomGroup.AtomGroup` instances. Silently proceeds if it + is already on the list.""" + MDAnalysis._anchor_universes.add(self) + + def remove_anchor(self): + """Remove this Universe from the list where anchors are searched for when unpickling + :class:`MDAnalysis.core.AtomGroup.AtomGroup` instances. Silently proceeds if it + is already not on the list.""" + MDAnalysis._anchor_universes.discard(self) + + @property + def is_anchor(self): + """Whether this Universe will be checked for anchoring when unpickling + :class:`MDAnalysis.core.AtomGroup.AtomGroup` instances""" + return self in MDAnalysis._anchor_universes + + @property + def anchor_name(self): + return self._anchor_name + + @anchor_name.setter + def anchor_name(self, name): + """Setting this attribute to anything other than ``None`` causes this Universe to + be added to the list where named anchors are searched for when unpickling + :class:`MDAnalysis.core.AtomGroup.AtomGroup` instances (silently proceeding if + it already is on the list). Setting to ``None`` causes the removal from said list.""" + self._anchor_name = name + if name is None: + MDAnalysis._named_anchor_universes.discard(self) + else: + MDAnalysis._named_anchor_universes.add(self) + + def _matches_unpickling(self, anchor_name, natoms, fname, trajname): + if anchor_name is None or anchor_name == self.anchor_name: + try: + return len(self.atoms)==natoms and self.filename==fname and self.trajectory.filenames==trajname + except AttributeError: # Only ChainReaders have filenames (plural) + return len(self.atoms)==natoms and self.filename==fname and self.trajectory.filename==trajname + else: + return False + # A __del__ method can be added to the Universe, but bear in mind that for + # that to work objects under Universe that hold backreferences to it can + # only do so using weakrefs. (Issue #297) + #def __del__(self): + # pass def asUniverse(*args, **kwargs): """Return a universe from the input arguments. diff --git a/testsuite/MDAnalysisTests/__init__.py b/testsuite/MDAnalysisTests/__init__.py index cee697298b4..0985a7b026f 100644 --- a/testsuite/MDAnalysisTests/__init__.py +++ b/testsuite/MDAnalysisTests/__init__.py @@ -104,17 +104,36 @@ __version__ = "0.11.0-dev" # keep in sync with RELEASE in setup.py try: - from numpy.testing import Tester + from numpy.testing import Tester, assert_equal test = Tester().test except ImportError: raise ImportError("""numpy>=1.3 is required to run the test suite. Please install it first. """ """(For example, try "easy_install 'numpy>=1.3'").""") +try: + from numpy.testing import assert_ +except ImportError: + # missing in numpy 1.2 but needed here: + # copied code from numpy.testing 1.5 + def assert_(val, msg=''): + """ + Assert that works in release mode. + + The Python built-in ``assert`` does not work when executing code in + optimized mode (the ``-O`` flag) - no byte-code is generated for it. + + For documentation on usage, refer to the Python documentation. + + (Code taken from numpy.testing 1.4) + """ + if not val: + raise AssertionError(msg) + try: import nose except ImportError: - raise ImportError("""nose is requires to run the test suite. Please install it first. """ + raise ImportError("""nose is required to run the test suite. Please install it first. """ """(For example, try "easy_install nose").""") try: @@ -171,3 +190,4 @@ def executable_not_found_runtime(*args): ... """ return lambda: executable_not_found(*args) + diff --git a/testsuite/MDAnalysisTests/test_atomgroup.py b/testsuite/MDAnalysisTests/test_atomgroup.py index 5f284ca599a..f415c84d94e 100644 --- a/testsuite/MDAnalysisTests/test_atomgroup.py +++ b/testsuite/MDAnalysisTests/test_atomgroup.py @@ -25,6 +25,7 @@ import numpy from numpy.testing import * +from . import assert_ from numpy import array, float32, rad2deg from nose.plugins.attrib import attr @@ -34,24 +35,6 @@ from MDAnalysisTests import knownfailure -try: - from numpy.testing import assert_ -except ImportError: - # missing in numpy 1.2 but needed here: - # copied code from numpy.testing 1.5 - def assert_(val, msg=''): - """ - Assert that works in release mode. - - The Python built-in ``assert`` does not work when executing code in - optimized mode (the ``-O`` flag) - no byte-code is generated for it. - - For documentation on usage, refer to the Python documentation. - - """ - if not val: - raise AssertionError(msg) - class TestAtom(TestCase): """Tests of Atom.""" @@ -771,17 +754,6 @@ def test_set_segid(self): assert_equal(u.atoms.segids(), ["CORE", "NMP", "CORE", "LID", "CORE"], err_msg="failed to change segids = {0}".format(u.atoms.segids())) - def test_pickle_raises_NotImplementedError(self): - import cPickle - ag = self.universe.selectAtoms("bynum 12:42 and name H*") - assert_raises(NotImplementedError, cPickle.dumps, ag, protocol=cPickle.HIGHEST_PROTOCOL) - - def test_unpickle_raises_NotImplementedError(self): - ag = self.universe.atoms[:3] - def ag_setstate(ag): - return ag.__setstate__('a') - assert_raises(NotImplementedError, ag_setstate, ag) - def test_wronglen_set(self): """Give the setter function a list of wrong length""" assert_raises(ValueError, self.ag.set_mass, [0.1, 0.2]) @@ -1287,11 +1259,10 @@ def test_partial_timestep(self): def test_empty_AtomGroup(): - """Test that a empty AtomGroup can be constructed (Issue 12)""" + """Test that an empty AtomGroup can be constructed (Issue 12)""" ag = MDAnalysis.core.AtomGroup.AtomGroup([]) assert_equal(len(ag), 0) - class _WriteAtoms(TestCase): """Set up the standard AdK system in implicit solvent.""" ext = None # override to test various output writers @@ -1487,7 +1458,6 @@ def test_set_dimensions(self): u.dimensions = numpy.array([10, 11, 12, 90, 90, 90]) assert_allclose(u.dimensions, box) - class TestPBCFlag(TestCase): def setUp(self): self.prec = 3 diff --git a/testsuite/MDAnalysisTests/test_libxdrfile2.py b/testsuite/MDAnalysisTests/test_libxdrfile2.py index f8a921cb8a6..dd3ec165f41 100644 --- a/testsuite/MDAnalysisTests/test_libxdrfile2.py +++ b/testsuite/MDAnalysisTests/test_libxdrfile2.py @@ -20,25 +20,7 @@ import MDAnalysis.coordinates.xdrfile.libxdrfile2 as xdr # FIXES: test_xdropen: error because assert_ not found in numpy < 1.3 -# maybe move this into separate module together with -# from numpy.testing import * ? -try: - from numpy.testing import assert_ -except ImportError: - def assert_(val, msg=''): - """ - Assert that works in release mode. - - The Python built-in ``assert`` does not work when executing code in - optimized mode (the ``-O`` flag) - no byte-code is generated for it. - - For documentation on usage, refer to the Python documentation. - - (Code taken from numpy.testing 1.4) - """ - if not val: - raise AssertionError(msg) - +from . import assert_ class TestLib(TestCase): def test_constants(self): diff --git a/testsuite/MDAnalysisTests/test_modelling.py b/testsuite/MDAnalysisTests/test_modelling.py index 5eddafd31c6..b8559b86338 100644 --- a/testsuite/MDAnalysisTests/test_modelling.py +++ b/testsuite/MDAnalysisTests/test_modelling.py @@ -25,6 +25,7 @@ import numpy from numpy.testing import * +from . import assert_ from numpy import array, float32, rad2deg from nose.plugins.attrib import attr @@ -32,24 +33,6 @@ import tempfile import itertools -try: - from numpy.testing import assert_ -except ImportError: - # missing in numpy 1.2 but needed here: - # copied code from numpy.testing 1.5 - def assert_(val, msg=''): - """ - Assert that works in release mode. - - The Python built-in ``assert`` does not work when executing code in - optimized mode (the ``-O`` flag) - no byte-code is generated for it. - - For documentation on usage, refer to the Python documentation. - - """ - if not val: - raise AssertionError(msg) - from MDAnalysis import Universe, Merge from MDAnalysis.analysis.align import alignto diff --git a/testsuite/MDAnalysisTests/test_persistence.py b/testsuite/MDAnalysisTests/test_persistence.py new file mode 100644 index 00000000000..58d2140ae2f --- /dev/null +++ b/testsuite/MDAnalysisTests/test_persistence.py @@ -0,0 +1,110 @@ +# -*- Mode: python; tab-width: 4; indent-tabs-mode:nil; coding:utf-8 -*- +# vim: tabstop=4 expandtab shiftwidth=4 softtabstop=4 fileencoding=utf-8 +# +# MDAnalysis --- http://www.MDAnalysis.org +# Copyright (c) 2006-2015 Naveen Michaud-Agrawal, Elizabeth J. Denning, Oliver Beckstein +# and contributors (see AUTHORS for the full list) +# +# Released under the GNU Public Licence, v2 or any higher version +# +# Please cite your use of MDAnalysis in published work: +# +# N. Michaud-Agrawal, E. J. Denning, T. B. Woolf, and O. Beckstein. +# MDAnalysis: A Toolkit for the Analysis of Molecular Dynamics Simulations. +# J. Comput. Chem. 32 (2011), 2319--2327, doi:10.1002/jcc.21787 +# + +import MDAnalysis +from MDAnalysis.tests.datafiles import PSF, DCD, PDB_small +import MDAnalysis.core.AtomGroup +from MDAnalysis.core.AtomGroup import AtomGroup + +import numpy +from numpy.testing import * +from . import assert_ + +import cPickle + +class TestAtomGroupPickle(TestCase): + + def setUp(self): + """Set up hopefully unique universes.""" + # _n marks named universes/atomgroups/pickled strings + self.universe = MDAnalysis.Universe(PDB_small, PDB_small, PDB_small) + self.universe_n = MDAnalysis.Universe(PDB_small, PDB_small, PDB_small, is_anchor=False, anchor_name="test1") + self.ag = self.universe.atoms[:20] # prototypical AtomGroup + self.ag_n = self.universe_n.atoms[:10] + self.pickle_str = cPickle.dumps(self.ag, protocol=cPickle.HIGHEST_PROTOCOL) + self.pickle_str_n = cPickle.dumps(self.ag_n, protocol=cPickle.HIGHEST_PROTOCOL) + + def tearDown(self): + del self.universe + del self.universe_n + del self.ag + del self.ag_n + + def test_unpickle(self): + """Test that an AtomGroup can be unpickled (Issue 293)""" + newag = cPickle.loads(self.pickle_str) + # Can unpickle + assert_array_equal(self.ag.indices(), newag.indices()) + assert_(newag.universe is self.universe, "Unpickled AtomGroup on wrong Universe.") + + def test_unpickle_named(self): + """Test that an AtomGroup can be unpickled (Issue 293)""" + newag = cPickle.loads(self.pickle_str_n) + # Can unpickle + assert_array_equal(self.ag_n.indices(), newag.indices()) + assert_(newag.universe is self.universe_n, "Unpickled AtomGroup on wrong Universe.") + + def test_unpickle_noanchor(self): + # Shouldn't unpickle if the universe is removed from the anchors + self.universe.remove_anchor() + # In the complex (parallel) testing environment there's the risk of other compatible Universes being available + # for anchoring even after this one is expressly removed. + assert_raises(RuntimeError, cPickle.loads, self.pickle_str) + # If this fails to raise an exception either:" + # 1-the anchoring Universe failed to remove_anchor or" + # 2-another Universe with the same characteristics was created for testing and is being used as anchor." + + def test_unpickle_reanchor(self): + # universe is removed from the anchors + self.universe.remove_anchor() + # now it goes back into the anchor list again + self.universe.make_anchor() + newag = cPickle.loads(self.pickle_str) + assert_array_equal(self.ag.indices(), newag.indices()) + assert_(newag.universe is self.universe, "Unpickled AtomGroup on wrong Universe.") + + def test_unpickle_reanchor_other(self): + # universe is removed from the anchors + self.universe.remove_anchor() + # and universe_n goes into the anchor list + self.universe_n.make_anchor() + newag = cPickle.loads(self.pickle_str) + assert_array_equal(self.ag.indices(), newag.indices()) + assert_(newag.universe is self.universe_n, "Unpickled AtomGroup on wrong Universe.") + + def test_unpickle_wrongname(self): + # we change the universe's anchor_name + self.universe_n.anchor_name = "test2" + # shouldn't unpickle if no name matches, even if there's a compatible + # universe in the unnamed anchor list. + assert_raises(RuntimeError, cPickle.loads, self.pickle_str_n) + + def test_unpickle_rename(self): + # we change universe_n's anchor_name + self.universe_n.anchor_name = "test2" + # and make universe a named anchor + self.universe.anchor_name = "test1" + newag = cPickle.loads(self.pickle_str_n) + assert_array_equal(self.ag_n.indices(), newag.indices()) + assert_(newag.universe is self.universe, "Unpickled AtomGroup on wrong Universe.") + +def test_pickle_unpickle_empty(): + """Test that an empty AtomGroup can be pickled/unpickled (Issue 293)""" + ag = AtomGroup([]) + pickle_str = cPickle.dumps(ag, protocol=cPickle.HIGHEST_PROTOCOL) + newag = cPickle.loads(pickle_str) + assert_equal(len(newag), 0) +