Skip to content

Commit 0803d88

Browse files
authored
change XTC/TRR tests for lock file presence (MDAnalysis#5423)
* fix MDAnalysis#5382 * change XTC/TRR tests for now persistent lock file - filelock >= 3.29.5 will now never remove the lockfile (avoids a race condition on POSIX): changed the correspond test (xfail for older versions of fielock) * filelock also apparently changed behavior on Windows: it used to leave the lock file around but now it seems to be removing it (based on the windows runners): - changed the test to account for this new behavior * docs updates for offsets - updated doc string XDRBaseReader._load_offsets to indicate that we use filelock - added more docs to XDR page (more details on offsets) - add filelock docs to intersphinx mapping * cleaned up lockfile context managers (follow their best-practice docs) * keep long string in sphinx conf.py - exempt from black - exempt from flake8 - shortened other offending lines * update CHANGELOG
1 parent 830e2af commit 0803d88

5 files changed

Lines changed: 143 additions & 40 deletions

File tree

package/CHANGELOG

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ The rules for this file:
2323
* 2.11.0
2424

2525
Fixes
26+
* Fix FileLock tests for XTC and TRR: lock file is no longer removed (#5382)
2627
* InterRDF now correctly returns bins in parallel (PR #5344)
2728
* `Merge()` no longer raises a TypeError on Universes that have a `cmaps`
2829
attribute; cmaps are now combined like the other connection attributes

package/MDAnalysis/coordinates/XDR.py

Lines changed: 102 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,42 @@
3232
MDAnalysis.coordinates.XTC: Read and write GROMACS XTC trajectory files.
3333
MDAnalysis.coordinates.TRR: Read and write GROMACS TRR trajectory files.
3434
MDAnalysis.lib.formats.libmdaxdr: Low level xdr format reader
35+
36+
37+
XDR reader class
38+
----------------
39+
40+
The :class:`XDRBaseReader` contains common functionality for the TRR and XTC
41+
reader for GROMACS files, which are implemented in the
42+
:mod:`MDAnalysis.lib.formats.libmdaxdr` module.
43+
44+
Both formats have in common that they do not allow
45+
native random frame access. Therefore, we first scan the whole trajectory to
46+
build an index of frames in the file ("offsets") as a look-up for seeking to
47+
frames. This process is initially slow so we save the offsets to a hidden file
48+
next to the trajectory (if possible) and then read the offset file when the
49+
trajectory is opened the next time, as described under :ref:`Offsets<offsets-label>`.
50+
51+
.. autoclass:: XDRBaseReader
52+
:members:
53+
:inherited-members:
54+
:private-members:
55+
56+
57+
Functions
58+
---------
59+
60+
.. autofunction:: offsets_filename
61+
62+
.. autofunction:: read_numpy_offsets
63+
3564
"""
3665

3766
import errno
3867
import numpy as np
3968
from os.path import getctime, getsize, isfile, split, join
4069
import warnings
41-
from filelock import FileLock
70+
import filelock
4271

4372
from . import base
4473
from ..lib.mdamath import triclinic_box
@@ -95,27 +124,30 @@ class XDRBaseReader(base.ReaderBase):
95124
"""Base class for libmdaxdr file formats xtc and trr
96125
97126
This class handles integration of XDR based formats into MDAnalysis. The
98-
XTC and TRR classes only implement `_write_next_frame` and
99-
`_frame_to_ts`.
127+
XTC and TRR classes only implement :meth:`_write_next_frame` and
128+
:meth:`_frame_to_ts`.
100129
101130
.. _offsets-label:
102131
103132
Notes
104133
-----
105134
XDR based readers store persistent offsets on disk. The offsets are used to
106135
enable access to random frames efficiently. These offsets will be generated
107-
automatically the first time the trajectory is opened. Generally offsets
108-
are stored in hidden `*_offsets.npz` files. Afterwards opening the same
136+
automatically the first time the trajectory is opened. Generally offsets
137+
are stored in hidden ``*_offsets.npz`` files. Afterwards opening the same
109138
file again is fast. It sometimes can happen that the stored offsets get out
110139
off sync with the trajectory they refer to. For this the offsets also store
111140
the number of atoms, size of the file and last modification time. If any of
112-
them change the offsets are recalculated. Writing of the offset file can
113-
fail when the directory where the trajectory file resides is not writable
114-
or if the disk is full. In this case a warning message will be shown but
141+
them change the offsets are recalculated. Writing of the offset file can
142+
fail when the directory where the trajectory file resides is not writable
143+
or if the disk is full. In this case a warning message will be shown but
115144
the offsets will nevertheless be used during the lifetime of the trajectory
116-
Reader. However, the next time the trajectory is opened, the offsets will
145+
Reader. However, the next time the trajectory is opened, the offsets will
117146
have to be rebuilt again.
118147
148+
See :meth:`_load_offsets` for further details.
149+
150+
119151
.. versionchanged:: 1.0.0
120152
XDR offsets read from trajectory if offsets file read-in fails
121153
.. versionchanged:: 2.0.0
@@ -124,6 +156,7 @@ class XDRBaseReader(base.ReaderBase):
124156
Use a direct read into ts attributes
125157
.. versionchanged:: 2.9.0
126158
Changed fasteners.InterProcessLock() to filelock.FileLock
159+
127160
"""
128161

129162
@store_init_arguments
@@ -204,15 +237,52 @@ def close(self):
204237
self._xdr.close()
205238

206239
def _load_offsets(self):
207-
"""load frame offsets from file, reread them from the trajectory if that
208-
fails. To prevent the competition of generating the same offset file
209-
from multiple processes, an `InterProcessLock` is used."""
240+
"""load frame offsets from file or recalculate if necessary
241+
242+
Frame offsets are cached in an offsets file, which is stored as a
243+
hidden file in the same directory as the trajectory. If the file does
244+
not exist we generate the offsets and store them.
245+
246+
If the data in the offset file are outdated (older than the trajectory
247+
file or different number of frames from the trajectory or different
248+
file size) then the offset file is also regenerated.
249+
250+
.. Note::
251+
252+
Generating offsets can take minutes for large trajectories because
253+
the whole file must be scanned. During this time, code appears to
254+
hang.
255+
256+
You can force regenerating offsets with the `refresh_offsets` keyword
257+
argument for :class:`~MDAnalysis.core.universe.Universe`, for
258+
example,::
259+
260+
u = mda.Universe(TOPOLOGY, XTC, refresh_offsets=True)
261+
262+
To prevent the competition of generating the same offset file from
263+
multiple processes, a :attr:`filelock.FileLock` is used, which is
264+
implemented via a lock file (in the same directory as the offset file
265+
and ending in ".lock"). This lock file is *not* automatically deleted
266+
because doing so could lead to race conditions.
267+
268+
Once this method completes, the
269+
:attr:`~MDAnalysis.lib.formats.libmdaxdr.XTCFile.offsets` attribute of
270+
the underlying reader contains current offsets for the trajectory.
271+
272+
273+
.. SeeAlso::
274+
- :func:`offsets_filename`
275+
- :meth:`_read_offsets`
276+
- :func:`read_numpy_offsets`
277+
278+
"""
210279
fname = offsets_filename(self.filename)
211280
lock_name = offsets_filename(self.filename, ending="lock")
212281

213282
# check if the location of the lock is writable.
283+
lock = filelock.FileLock(lock_name)
214284
try:
215-
with FileLock(lock_name) as filelock:
285+
with lock:
216286
pass
217287
except OSError as e:
218288
if isinstance(e, PermissionError) or e.errno == errno.EROFS:
@@ -225,7 +295,7 @@ def _load_offsets(self):
225295
else:
226296
raise
227297

228-
with FileLock(lock_name) as filelock:
298+
with lock:
229299
if not isfile(fname):
230300
self._read_offsets(store=True)
231301
return
@@ -267,7 +337,24 @@ def _load_offsets(self):
267337
self._xdr.set_offsets(data["offsets"])
268338

269339
def _read_offsets(self, store=False):
270-
"""read frame offsets from trajectory"""
340+
"""read frame offsets from trajectory
341+
342+
Scan the trajectory for frames and build an index that relates frame
343+
number to the position in the file, thus enabling direct seeking to
344+
specific frames. The trajectory scan can take minutes for large
345+
trajectories.
346+
347+
Parameters
348+
----------
349+
store : bool
350+
Save the frame index ("offsets") to a file with name generated from
351+
the trajectory name (:attr:`filename`) with function
352+
:func:`offsets_filename`. The offsets file also contains, ctime,
353+
file size, number of frames, and number of atoms of the trajectory.
354+
The file format is a compressed numpy array (:func:`numpy.savez`).
355+
356+
If saving the file fails for any reasons, only a warning is issued.
357+
"""
271358
fname = offsets_filename(self.filename)
272359
offsets = self._xdr.offsets
273360
if store:

package/doc/sphinx/source/conf.py

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
# MDAnalysis documentation build configuration file, created by
44
# sphinx-quickstart on Mon Sep 27 09:39:55 2010.
55
#
6-
# This file is execfile()d with the current directory set to its containing dir.
6+
# This file is execfile()d with the current directory set to its containing
7+
# dir.
78
#
89
# Note that not all possible configuration values are present in this
910
# autogenerated file.
@@ -29,13 +30,13 @@
2930
# make sure sphinx always uses the current branch
3031
sys.path.insert(0, os.path.abspath("../../.."))
3132

32-
# -- General configuration -----------------------------------------------------
33+
# -- General configuration ----------------------------------------------------
3334

3435
# If your documentation needs a minimal Sphinx version, state it here.
3536
# needs_sphinx = '1.0'
3637

37-
# Add any Sphinx extension module names here, as strings. They can be extensions
38-
# coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
38+
# Add any Sphinx extension module names here, as strings. They can be
39+
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
3940
extensions = [
4041
"sphinx.ext.autodoc",
4142
"sphinx.ext.intersphinx",
@@ -70,7 +71,8 @@ class KeyStyle(UnsrtStyle):
7071
register_plugin("pybtex.style.labels", "keylabel", KeyLabelStyle)
7172
register_plugin("pybtex.style.formatting", "MDA", KeyStyle)
7273

73-
mathjax_path = "https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.0/MathJax.js?config=TeX-AMS-MML_HTMLorMML"
74+
75+
mathjax_path = "https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.0/MathJax.js?config=TeX-AMS-MML_HTMLorMML" # noqa: E501; fmt: skip
7476

7577
# for sitemap with https://github.com/jdillard/sphinx-sitemap
7678
# This sitemap is correct both for the development and release docs, which
@@ -96,8 +98,8 @@ class KeyStyle(UnsrtStyle):
9698

9799
# General information about the project.
98100
# (take the list from AUTHORS)
99-
# Ordering: (1) Naveen (2) Elizabeth, then all contributors in alphabetical order
100-
# (last) Oliver
101+
# Ordering: (1) Naveen (2) Elizabeth, then all contributors in alphabetical
102+
# order (last) Oliver
101103
author_list = mda.__authors__
102104
authors = ", ".join(author_list[:-1]) + ", and " + author_list[-1]
103105
project = "MDAnalysis"
@@ -130,7 +132,8 @@ class KeyStyle(UnsrtStyle):
130132
# directories to ignore when looking for source files.
131133
exclude_patterns = ["_build"]
132134

133-
# The reST default role (used for this markup: `text`) to use for all documents.
135+
# The reST default role (used for this markup: `text`) to use for all
136+
# documents.
134137
# default_role = None
135138

136139
# If true, '()' will be appended to :func: etc. cross-reference text.
@@ -156,7 +159,7 @@ class KeyStyle(UnsrtStyle):
156159
# to prevent including of member entries in toctree
157160
toc_object_entries = False
158161

159-
# -- Options for HTML output ---------------------------------------------------
162+
# -- Options for HTML output --------------------------------------------------
160163

161164
# The theme to use for HTML and HTML Help pages. See the documentation for
162165
# a list of builtin themes.
@@ -246,7 +249,7 @@ class KeyStyle(UnsrtStyle):
246249
htmlhelp_basename = "MDAnalysisdoc"
247250

248251

249-
# -- Options for LaTeX output --------------------------------------------------
252+
# -- Options for LaTeX output -------------------------------------------------
250253

251254
# The paper size ('letter' or 'a4').
252255
# latex_paper_size = 'letter'
@@ -255,7 +258,8 @@ class KeyStyle(UnsrtStyle):
255258
# latex_font_size = '10pt'
256259

257260
# Grouping the document tree into LaTeX files. List of tuples
258-
# (source start file, target name, title, author, documentclass [howto/manual]).
261+
# (source start file, target name, title, author, documentclass
262+
# [howto/manual]).
259263
latex_documents = [
260264
("MDAnalysis.tex", "MDAnalysis Documentation", authors, "manual"),
261265
]
@@ -284,14 +288,14 @@ class KeyStyle(UnsrtStyle):
284288
# latex_domain_indices = True
285289

286290

287-
# -- Options for manual page output --------------------------------------------
291+
# -- Options for manual page output -------------------------------------------
288292

289293
# One entry per manual page. List of tuples
290294
# (source start file, name, description, authors, manual section).
291295
man_pages = [("mdanalysis", "MDAnalysis Documentation", [authors], 1)]
292296

293297

294-
# -- Options for Epub output ---------------------------------------------------
298+
# -- Options for Epub output --------------------------------------------------
295299

296300
# Bibliographic Dublin Core info.
297301
epub_title = "MDAnalysis"
@@ -352,4 +356,5 @@ class KeyStyle(UnsrtStyle):
352356
"imdclient": ("https://imdclient.readthedocs.io/en/stable/", None),
353357
"pooch": ("https://www.fatiando.org/pooch/latest/", None),
354358
"requests": ("https://requests.readthedocs.io/en/latest/", None),
359+
"filelock": ("https://py-filelock.readthedocs.io/en/latest/", None),
355360
}
Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1 @@
11
.. automodule:: MDAnalysis.coordinates.XDR
2-
:members:
3-
:inherited-members:

testsuite/MDAnalysisTests/coordinates/test_xdr.py

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,13 @@
2222
#
2323
import pytest
2424
from unittest.mock import patch
25+
from packaging.version import Version
2526

2627
import re
2728
import os
2829
import shutil
2930
import sys
30-
from filelock import FileLock
31+
import filelock
3132
from pathlib import Path
3233

3334
import numpy as np
@@ -1028,7 +1029,7 @@ def test_persistent_offsets_readonly(self, tmpdir, trajectory):
10281029
ref_offset = trajectory._xdr.offsets
10291030
# Mock filelock acquire to raise an error
10301031
with patch.object(
1031-
FileLock, "acquire", side_effect=PermissionError
1032+
filelock.FileLock, "acquire", side_effect=PermissionError
10321033
): # Simulate failure
10331034
with pytest.warns(UserWarning, match="Cannot write lock"):
10341035
reader = self._reader(filename)
@@ -1048,21 +1049,32 @@ def test_persistent_offsets_readonly(self, tmpdir, trajectory):
10481049
False,
10491050
)
10501051

1052+
@pytest.mark.xfail(
1053+
Version(filelock.__version__) < Version("3.29.5"),
1054+
reason="unsecure version of filelock",
1055+
)
10511056
def test_offset_lock_created(self):
10521057
lock_file_path = XDR.offsets_filename(self.filename, ending="lock")
10531058

1054-
with FileLock(lock_file_path) as lock:
1059+
with filelock.FileLock(lock_file_path) as lock:
10551060
# Lock acquired in context manager, so lock file should exist
10561061
assert lock.is_locked
10571062
assert os.path.exists(lock_file_path)
10581063

1059-
# Explicitly release lock, file should be deleted on UNIX
1060-
lock.release()
1061-
assert not lock.is_locked
1062-
if not sys.platform.startswith("win"):
1063-
# As of filelock>=3.21.0, filelock explicitly deletes lockfile
1064-
# upon release on UNIX. filelock does not do that on windows.
1065-
assert not os.path.exists(lock_file_path)
1064+
# released lock
1065+
assert not lock.is_locked
1066+
1067+
# validate the expected behavior of how filelock handles lock files for
1068+
# released locks:
1069+
if sys.platform.startswith("win"):
1070+
# on Windows, the lockfile remained (filelock ~3.21.0) but recent
1071+
# versions (at least 3.29.7) appear to remove the lockfile
1072+
assert not os.path.exists(lock_file_path)
1073+
else:
1074+
# for filelock>=3.21.0,<3.29.5, the lockfile was deleted on POSIX,
1075+
# but this can lead to race conditions. Secure versions of filelock
1076+
# keep the lockfile (see GH tox-dev/filelock#574)
1077+
assert os.path.exists(lock_file_path)
10661078

10671079

10681080
class TestXTCReader_offsets(_GromacsReader_offsets):

0 commit comments

Comments
 (0)