polyphys 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- polyphys/__init__.py +7 -0
- polyphys/__version__.py +22 -0
- polyphys/analyze/__init__.py +51 -0
- polyphys/analyze/clusters.py +1738 -0
- polyphys/analyze/correlations.py +995 -0
- polyphys/analyze/density.py +1138 -0
- polyphys/analyze/measurer.py +1338 -0
- polyphys/analyze/pipeline.py +1868 -0
- polyphys/analyze/stats.py +810 -0
- polyphys/analyze/sumrule.py +1159 -0
- polyphys/manage/__init__.py +11 -0
- polyphys/manage/organizer.py +2188 -0
- polyphys/manage/parser.py +1264 -0
- polyphys/manage/types.py +171 -0
- polyphys/manage/utils.py +777 -0
- polyphys/probe/__init__.py +25 -0
- polyphys/probe/binders.py +711 -0
- polyphys/probe/chain_size.py +583 -0
- polyphys/probe/core.py +437 -0
- polyphys/probe/histograms.py +863 -0
- polyphys/probe/pairs.py +555 -0
- polyphys/simulate/__init__.py +48 -0
- polyphys/simulate/build.py +1231 -0
- polyphys/simulate/core.py +237 -0
- polyphys/simulate/data.py +977 -0
- polyphys/simulate/dump.py +752 -0
- polyphys/simulate/log.py +788 -0
- polyphys/spec.py +1359 -0
- polyphys/tests/analyze/test_clusters.py +1058 -0
- polyphys/tests/analyze/test_correlations.py +708 -0
- polyphys/tests/analyze/test_density.py +813 -0
- polyphys/tests/analyze/test_measurer.py +785 -0
- polyphys/tests/analyze/test_pipeline.py +1881 -0
- polyphys/tests/analyze/test_stats.py +732 -0
- polyphys/tests/analyze/test_sumrule.py +917 -0
- polyphys/tests/conftest.py +753 -0
- polyphys/tests/data/lammps_build/make_starts.py +70 -0
- polyphys/tests/manage/test_organizer.py +1495 -0
- polyphys/tests/manage/test_parser.py +1222 -0
- polyphys/tests/manage/test_utils.py +498 -0
- polyphys/tests/probe/conftest.py +407 -0
- polyphys/tests/probe/test_binders.py +686 -0
- polyphys/tests/probe/test_chain_size.py +557 -0
- polyphys/tests/probe/test_core.py +391 -0
- polyphys/tests/probe/test_histograms.py +779 -0
- polyphys/tests/probe/test_pairs.py +475 -0
- polyphys/tests/simulate/test_build.py +1042 -0
- polyphys/tests/simulate/test_core.py +106 -0
- polyphys/tests/simulate/test_data.py +858 -0
- polyphys/tests/simulate/test_dump.py +487 -0
- polyphys/tests/simulate/test_log.py +667 -0
- polyphys/tests/strategies.py +120 -0
- polyphys/tests/synthetic.py +410 -0
- polyphys/tests/test_conftest.py +414 -0
- polyphys/tests/test_docs.py +82 -0
- polyphys/tests/test_packaging.py +97 -0
- polyphys/tests/test_spec.py +772 -0
- polyphys/tests/viz/conftest.py +32 -0
- polyphys/tests/viz/test_colors.py +113 -0
- polyphys/tests/viz/test_helpers.py +292 -0
- polyphys/viz/__init__.py +15 -0
- polyphys/viz/colors.py +204 -0
- polyphys/viz/helpers.py +549 -0
- polyphys-1.0.0.dist-info/METADATA +339 -0
- polyphys-1.0.0.dist-info/RECORD +69 -0
- polyphys-1.0.0.dist-info/WHEEL +5 -0
- polyphys-1.0.0.dist-info/licenses/AUTHORS.rst +13 -0
- polyphys-1.0.0.dist-info/licenses/LICENSE +22 -0
- polyphys-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
"""Tests for the shared fixtures and the ``private_data`` and ``lammps``
|
|
2
|
+
markers in ``polyphys/tests/conftest.py``.
|
|
3
|
+
|
|
4
|
+
Expected file counts and indices come from the arguments each test passes.
|
|
5
|
+
Expected file contents come from the formula documented in
|
|
6
|
+
``lineage_tree_value``. Expected LAMMPS section layouts come from the LAMMPS
|
|
7
|
+
manual's read_data and dump pages: ``atom-ID atom-type x y z`` for
|
|
8
|
+
``atomic``, ``atom-ID molecule-ID atom-type x y z`` for ``bond`` and
|
|
9
|
+
``angle``, and one bond and one angle per consecutive pair and triple of a
|
|
10
|
+
linear chain. The marker tests run a nested pytest session with
|
|
11
|
+
``pytester``; the ``lammps`` ones name the Python interpreter as the
|
|
12
|
+
executable, since any executable file stands in for LAMMPS there.
|
|
13
|
+
"""
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
import pandas as pd
|
|
19
|
+
import pytest
|
|
20
|
+
|
|
21
|
+
from polyphys.tests import synthetic
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _sections(text: str) -> dict[str, list[str]]:
|
|
25
|
+
"""Split a LAMMPS data file into its header and its named sections."""
|
|
26
|
+
blocks = [b.strip().splitlines() for b in text.split("\n\n")]
|
|
27
|
+
sections = {"header": blocks[1]}
|
|
28
|
+
for title, body in zip(blocks[2:], blocks[3:]):
|
|
29
|
+
if len(title) == 1 and not title[0][0].isdigit():
|
|
30
|
+
sections[title[0].split("#")[0].strip()] = body
|
|
31
|
+
return sections
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _header_counts(header: list[str]) -> dict[str, int]:
|
|
35
|
+
"""Map each header keyword, such as 'bonds', to its count."""
|
|
36
|
+
return {line.split(maxsplit=1)[1]: int(line.split()[0])
|
|
37
|
+
for line in header}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _dump_frames(text: str) -> list[list[str]]:
|
|
41
|
+
"""Split a LAMMPS dump into frames, each a list of its lines."""
|
|
42
|
+
frames = text.split("ITEM: TIMESTEP\n")[1:]
|
|
43
|
+
return [frame.strip().splitlines() for frame in frames]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@pytest.fixture(params=synthetic.__all__)
|
|
47
|
+
def parser_class(request: pytest.FixtureRequest) -> type:
|
|
48
|
+
"""Each synthetic parser class, one per test run."""
|
|
49
|
+
return getattr(synthetic, request.param)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@pytest.fixture
|
|
53
|
+
def private_data_project(pytester: pytest.Pytester) -> pytest.Pytester:
|
|
54
|
+
"""A nested pytest project with this conftest and one private_data test."""
|
|
55
|
+
pytester.makeconftest(Path(__file__).with_name("conftest.py").read_text())
|
|
56
|
+
pytester.makepyfile(
|
|
57
|
+
test_private="""
|
|
58
|
+
import pytest
|
|
59
|
+
|
|
60
|
+
@pytest.mark.private_data
|
|
61
|
+
def test_reads_data(private_data_dir):
|
|
62
|
+
assert private_data_dir.is_dir()
|
|
63
|
+
"""
|
|
64
|
+
)
|
|
65
|
+
return pytester
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@pytest.fixture
|
|
69
|
+
def lammps_project(pytester: pytest.Pytester) -> pytest.Pytester:
|
|
70
|
+
"""A nested pytest project whose conftest imports this conftest (so the
|
|
71
|
+
nested session runs the real module's hooks and fixtures, in process),
|
|
72
|
+
with one lammps test and one unmarked test that takes the
|
|
73
|
+
``lammps_executable`` fixture."""
|
|
74
|
+
pytester.makeconftest(
|
|
75
|
+
"from polyphys.tests.conftest import * # noqa: F401,F403\n")
|
|
76
|
+
pytester.makepyfile(
|
|
77
|
+
test_lammps="""
|
|
78
|
+
from pathlib import Path
|
|
79
|
+
|
|
80
|
+
import pytest
|
|
81
|
+
|
|
82
|
+
@pytest.mark.lammps
|
|
83
|
+
def test_runs_lammps(lammps_executable):
|
|
84
|
+
assert Path(lammps_executable).exists()
|
|
85
|
+
|
|
86
|
+
def test_fixture_without_marker(lammps_executable):
|
|
87
|
+
assert isinstance(lammps_executable, str)
|
|
88
|
+
"""
|
|
89
|
+
)
|
|
90
|
+
return pytester
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_make_lineage_tree_writes_one_file_per_segment(make_lineage_tree):
|
|
94
|
+
"""A segment tree holds n_ensembles * n_wholes * n_segments files."""
|
|
95
|
+
tree = make_lineage_tree(
|
|
96
|
+
synthetic.ChainCyl, n_ensembles=2, n_wholes=3, n_segments=2
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
assert len(tree.paths) == 12
|
|
100
|
+
assert all(path.is_file() for path in tree.paths)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_make_lineage_tree_names_parse_without_missing_keywords(
|
|
104
|
+
make_lineage_tree, capsys, parser_class
|
|
105
|
+
):
|
|
106
|
+
"""Every segment filename yields every keyword of the parser's grammar."""
|
|
107
|
+
tree = make_lineage_tree(parser_class)
|
|
108
|
+
|
|
109
|
+
parsed = [parser_class(path, "segment", tree.group, ispath=True)
|
|
110
|
+
for path in tree.paths]
|
|
111
|
+
|
|
112
|
+
assert len(parsed) == len(tree.paths)
|
|
113
|
+
# The parsers report a missing keyword on stdout rather than raising.
|
|
114
|
+
assert capsys.readouterr().out == ""
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_make_lineage_tree_groups_into_wholes_and_ensembles(
|
|
118
|
+
make_lineage_tree, parser_class
|
|
119
|
+
):
|
|
120
|
+
"""Segments group into one whole per replicate, one ensemble per state."""
|
|
121
|
+
tree = make_lineage_tree(
|
|
122
|
+
parser_class, n_ensembles=2, n_wholes=3, n_segments=2
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
parsed = [parser_class(path, "segment", tree.group, ispath=True)
|
|
126
|
+
for path in tree.paths]
|
|
127
|
+
|
|
128
|
+
assert len({p.whole for p in parsed}) == 6
|
|
129
|
+
assert len({p.ensemble_long for p in parsed}) == 2
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_make_lineage_tree_index_is_read_only(make_lineage_tree):
|
|
133
|
+
"""A tree's file index cannot be changed after the tree is written."""
|
|
134
|
+
tree = make_lineage_tree(synthetic.ChainCyl, n_ensembles=1)
|
|
135
|
+
|
|
136
|
+
with pytest.raises(TypeError, match="does not support item assignment"):
|
|
137
|
+
tree.index[tree.paths[0]] = (9, 9, 9)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_make_lineage_tree_numbers_wholes_and_segments_from_one(
|
|
141
|
+
make_lineage_tree,
|
|
142
|
+
):
|
|
143
|
+
"""The ens and j values in the names count wholes and segments from 1."""
|
|
144
|
+
tree = make_lineage_tree(
|
|
145
|
+
synthetic.ChainCyl, n_ensembles=1, n_wholes=3, n_segments=2
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
parsed = [synthetic.ChainCyl(path, "segment", tree.group, ispath=True)
|
|
149
|
+
for path in tree.paths]
|
|
150
|
+
|
|
151
|
+
assert [(p.ensemble_id, p.segment_id) for p in parsed] == [
|
|
152
|
+
(1, 1), (1, 2), (2, 1), (2, 2), (3, 1), (3, 2)
|
|
153
|
+
]
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@pytest.mark.parametrize(
|
|
157
|
+
("lineage", "ext", "load", "key", "expected"),
|
|
158
|
+
[
|
|
159
|
+
pytest.param("segment", ".npy", np.load, (2, 3, 1),
|
|
160
|
+
[2310.0, 2311.0, 2312.0], id="segment-npy"),
|
|
161
|
+
pytest.param("segment", ".csv",
|
|
162
|
+
lambda p: pd.read_csv(p)["gyrTMon"].to_numpy(),
|
|
163
|
+
(2, 3, 1), [2310.0, 2311.0, 2312.0], id="segment-csv"),
|
|
164
|
+
pytest.param("whole", ".npy", np.load, (2, 3, 0),
|
|
165
|
+
[2300.0, 2301.0, 2302.0], id="whole-npy"),
|
|
166
|
+
],
|
|
167
|
+
)
|
|
168
|
+
def test_make_lineage_tree_file_holds_documented_values(
|
|
169
|
+
make_lineage_tree, lineage, ext, load, key, expected
|
|
170
|
+
):
|
|
171
|
+
"""A file holds 1000*ensemble + 100*whole + 10*segment + row."""
|
|
172
|
+
tree = make_lineage_tree(
|
|
173
|
+
synthetic.ChainCyl, n_ensembles=2, n_wholes=3, lineage=lineage,
|
|
174
|
+
ext=ext, prop="gyrTMon", n_rows=3,
|
|
175
|
+
)
|
|
176
|
+
path = next(p for p, index in tree.index.items() if index == key)
|
|
177
|
+
|
|
178
|
+
values = load(path)
|
|
179
|
+
|
|
180
|
+
# Integers below 2**53 are exact in float64 and in the CSV text.
|
|
181
|
+
np.testing.assert_array_equal(values, expected)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@pytest.mark.parametrize(
|
|
185
|
+
("atom_style", "expected"),
|
|
186
|
+
[
|
|
187
|
+
pytest.param("atomic", {"atoms": 4, "bonds": 0, "angles": 0},
|
|
188
|
+
id="atomic"),
|
|
189
|
+
pytest.param("bond", {"atoms": 4, "bonds": 3, "angles": 0},
|
|
190
|
+
id="bond"),
|
|
191
|
+
pytest.param("angle", {"atoms": 4, "bonds": 3, "angles": 2},
|
|
192
|
+
id="angle"),
|
|
193
|
+
],
|
|
194
|
+
)
|
|
195
|
+
def test_make_lammps_data_header_counts_match_sections(
|
|
196
|
+
make_lammps_data, atom_style, expected
|
|
197
|
+
):
|
|
198
|
+
"""A 4-atom chain's header counts equal its section line counts."""
|
|
199
|
+
path = make_lammps_data(atom_style=atom_style)
|
|
200
|
+
|
|
201
|
+
sections = _sections(path.read_text())
|
|
202
|
+
|
|
203
|
+
counts = _header_counts(sections["header"])
|
|
204
|
+
assert {key: counts.get(key, 0) for key in expected} == expected
|
|
205
|
+
assert {
|
|
206
|
+
"atoms": len(sections["Atoms"]),
|
|
207
|
+
"bonds": len(sections.get("Bonds", [])),
|
|
208
|
+
"angles": len(sections.get("Angles", [])),
|
|
209
|
+
} == expected
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@pytest.mark.parametrize(
|
|
213
|
+
("atom_style", "n_columns"),
|
|
214
|
+
[
|
|
215
|
+
pytest.param("atomic", 5, id="atomic"),
|
|
216
|
+
pytest.param("bond", 6, id="bond"),
|
|
217
|
+
pytest.param("angle", 6, id="angle"),
|
|
218
|
+
],
|
|
219
|
+
)
|
|
220
|
+
def test_make_lammps_data_atoms_columns_follow_style(
|
|
221
|
+
make_lammps_data, atom_style, n_columns
|
|
222
|
+
):
|
|
223
|
+
"""Atoms lines have the column count LAMMPS reads for the atom style."""
|
|
224
|
+
path = make_lammps_data(atom_style=atom_style)
|
|
225
|
+
|
|
226
|
+
atoms = _sections(path.read_text())["Atoms"]
|
|
227
|
+
|
|
228
|
+
assert {len(line.split()) for line in atoms} == {n_columns}
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def test_make_lammps_dump_round_trips_positions(make_lammps_dump):
|
|
232
|
+
"""Positions read back from the dump match those written."""
|
|
233
|
+
positions = np.linspace(0.0, 9.5, 36).reshape(3, 4, 3)
|
|
234
|
+
path = make_lammps_dump(positions=positions, timesteps=[0, 500, 1000])
|
|
235
|
+
|
|
236
|
+
frames = _dump_frames(path.read_text())
|
|
237
|
+
|
|
238
|
+
assert [frame[0] for frame in frames] == ["0", "500", "1000"]
|
|
239
|
+
assert [int(frame[2]) for frame in frames] == [4, 4, 4]
|
|
240
|
+
assert {frame[7] for frame in frames} == {"ITEM: ATOMS id type x y z"}
|
|
241
|
+
read = np.array([[line.split()[2:] for line in frame[8:]]
|
|
242
|
+
for frame in frames], dtype=float)
|
|
243
|
+
# Six decimals are written, so a value can be off by half of 1e-6.
|
|
244
|
+
np.testing.assert_allclose(read, positions, rtol=0, atol=5e-7)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
@pytest.mark.parametrize(
|
|
248
|
+
"boundary",
|
|
249
|
+
[pytest.param("pp pp pp", id="periodic"),
|
|
250
|
+
pytest.param("ff ff pp", id="cylinder")],
|
|
251
|
+
)
|
|
252
|
+
def test_make_lammps_dump_writes_boundary_flags(make_lammps_dump, boundary):
|
|
253
|
+
"""Every BOX BOUNDS line carries the requested boundary flags."""
|
|
254
|
+
path = make_lammps_dump(boundary=boundary)
|
|
255
|
+
|
|
256
|
+
frames = _dump_frames(path.read_text())
|
|
257
|
+
|
|
258
|
+
assert {frame[3] for frame in frames} == {
|
|
259
|
+
f"ITEM: BOX BOUNDS {boundary}"
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def test_private_data_skips_when_data_dir_unset(
|
|
264
|
+
private_data_project, monkeypatch
|
|
265
|
+
):
|
|
266
|
+
"""A private_data test is skipped when POLYPHYS_DATA_DIR is unset."""
|
|
267
|
+
monkeypatch.delenv("POLYPHYS_DATA_DIR", raising=False)
|
|
268
|
+
|
|
269
|
+
result = private_data_project.runpytest_subprocess()
|
|
270
|
+
|
|
271
|
+
result.assert_outcomes(skipped=1)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def test_private_data_runs_when_data_dir_exists(
|
|
275
|
+
private_data_project, monkeypatch, tmp_path
|
|
276
|
+
):
|
|
277
|
+
"""A private_data test runs when POLYPHYS_DATA_DIR names a directory."""
|
|
278
|
+
monkeypatch.setenv("POLYPHYS_DATA_DIR", str(tmp_path))
|
|
279
|
+
|
|
280
|
+
result = private_data_project.runpytest_subprocess()
|
|
281
|
+
|
|
282
|
+
result.assert_outcomes(passed=1)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def test_private_data_errors_when_data_dir_missing(
|
|
286
|
+
private_data_project, monkeypatch, tmp_path
|
|
287
|
+
):
|
|
288
|
+
"""A private_data test errors when POLYPHYS_DATA_DIR is no directory."""
|
|
289
|
+
monkeypatch.setenv("POLYPHYS_DATA_DIR", str(tmp_path / "missing"))
|
|
290
|
+
|
|
291
|
+
result = private_data_project.runpytest_subprocess()
|
|
292
|
+
|
|
293
|
+
result.assert_outcomes(errors=1)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def test_lammps_skips_without_an_executable(lammps_project, monkeypatch):
|
|
297
|
+
"""A lammps test, and the fixture used without the marker, are skipped
|
|
298
|
+
when LMP is unset and lmp_serial is not on PATH."""
|
|
299
|
+
monkeypatch.delenv("LMP", raising=False)
|
|
300
|
+
monkeypatch.setenv("PATH", "")
|
|
301
|
+
|
|
302
|
+
result = lammps_project.runpytest()
|
|
303
|
+
|
|
304
|
+
result.assert_outcomes(skipped=2)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def test_lammps_runs_with_the_executable_lmp_names(
|
|
308
|
+
lammps_project, monkeypatch
|
|
309
|
+
):
|
|
310
|
+
"""A lammps test runs, and the fixture returns the path, when LMP names
|
|
311
|
+
an executable."""
|
|
312
|
+
monkeypatch.setenv("LMP", sys.executable)
|
|
313
|
+
|
|
314
|
+
result = lammps_project.runpytest()
|
|
315
|
+
|
|
316
|
+
result.assert_outcomes(passed=2)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def test_lammps_errors_when_lmp_is_not_an_executable(
|
|
320
|
+
lammps_project, monkeypatch, tmp_path
|
|
321
|
+
):
|
|
322
|
+
"""A lammps test errors in setup when LMP names a missing file; the
|
|
323
|
+
fixture alone returns the name, since the marker does the checking."""
|
|
324
|
+
monkeypatch.setenv("LMP", str(tmp_path / "missing"))
|
|
325
|
+
|
|
326
|
+
result = lammps_project.runpytest()
|
|
327
|
+
|
|
328
|
+
result.assert_outcomes(errors=1, passed=1)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def test_strict_markers_rejects_misspelled_private_data(pytester):
|
|
332
|
+
"""A misspelled private_data marker fails collection."""
|
|
333
|
+
pytester.makeconftest(Path(__file__).with_name("conftest.py").read_text())
|
|
334
|
+
pytester.makepyfile(
|
|
335
|
+
test_typo="""
|
|
336
|
+
import pytest
|
|
337
|
+
|
|
338
|
+
@pytest.mark.private_dta
|
|
339
|
+
def test_reads_data():
|
|
340
|
+
pass
|
|
341
|
+
"""
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
result = pytester.runpytest_subprocess("--strict-markers")
|
|
345
|
+
|
|
346
|
+
result.assert_outcomes(errors=1)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
@pytest.mark.parametrize(
|
|
350
|
+
("kwargs", "match"),
|
|
351
|
+
[
|
|
352
|
+
pytest.param({"n_wholes": 0}, "'n_wholes' must be from 1 to 9",
|
|
353
|
+
id="size-below-range"),
|
|
354
|
+
pytest.param({"n_rows": 11}, "'n_rows' must be from 1 to 10",
|
|
355
|
+
id="rows-above-range"),
|
|
356
|
+
pytest.param({"lineage": "ensemble"},
|
|
357
|
+
"'lineage' must be 'segment' or 'whole'",
|
|
358
|
+
id="unknown-lineage"),
|
|
359
|
+
pytest.param({"ext": ".txt"}, "'ext' must be '.npy' or '.csv'",
|
|
360
|
+
id="unknown-ext"),
|
|
361
|
+
pytest.param({"attrs": {"ens": 1}}, "'attrs' cannot set",
|
|
362
|
+
id="reserved-keyword"),
|
|
363
|
+
pytest.param({"vary": "xyz"},
|
|
364
|
+
"not keywords of ChainCyl whole names",
|
|
365
|
+
id="unknown-keyword"),
|
|
366
|
+
],
|
|
367
|
+
)
|
|
368
|
+
def test_make_lineage_tree_rejects_invalid_arguments(
|
|
369
|
+
make_lineage_tree, kwargs, match
|
|
370
|
+
):
|
|
371
|
+
"""A lineage tree with an invalid argument raises before writing."""
|
|
372
|
+
with pytest.raises(ValueError, match=match):
|
|
373
|
+
make_lineage_tree(synthetic.ChainCyl, **kwargs)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
@pytest.mark.parametrize(
|
|
377
|
+
("kwargs", "match"),
|
|
378
|
+
[
|
|
379
|
+
pytest.param({"atom_style": "full"}, "'atom_style' must be one of",
|
|
380
|
+
id="unknown-style"),
|
|
381
|
+
pytest.param({"positions": np.zeros((4, 2))},
|
|
382
|
+
r"'positions' must have shape \(n_atoms, 3\)",
|
|
383
|
+
id="positions-not-3d"),
|
|
384
|
+
],
|
|
385
|
+
)
|
|
386
|
+
def test_make_lammps_data_rejects_invalid_arguments(
|
|
387
|
+
make_lammps_data, kwargs, match
|
|
388
|
+
):
|
|
389
|
+
"""A data file with an unknown style or bad positions raises."""
|
|
390
|
+
with pytest.raises(ValueError, match=match):
|
|
391
|
+
make_lammps_data(**kwargs)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
@pytest.mark.parametrize(
|
|
395
|
+
("kwargs", "match"),
|
|
396
|
+
[
|
|
397
|
+
pytest.param({"positions": np.zeros((4, 3))},
|
|
398
|
+
r"'positions' must have shape \(n_frames, n_atoms, 3\)",
|
|
399
|
+
id="positions-one-frame-2d"),
|
|
400
|
+
pytest.param({"timesteps": [0]}, "'timesteps' has 1 entries for 3",
|
|
401
|
+
id="timesteps-too-few"),
|
|
402
|
+
pytest.param({"boundary": "pp pp"}, "'boundary' must be three flags",
|
|
403
|
+
id="boundary-two-flags"),
|
|
404
|
+
pytest.param({"boundary": "px pp pp"},
|
|
405
|
+
"'boundary' must be three flags",
|
|
406
|
+
id="boundary-unknown-letter"),
|
|
407
|
+
],
|
|
408
|
+
)
|
|
409
|
+
def test_make_lammps_dump_rejects_invalid_arguments(
|
|
410
|
+
make_lammps_dump, kwargs, match
|
|
411
|
+
):
|
|
412
|
+
"""A dump with bad positions, timesteps, or boundary flags raises."""
|
|
413
|
+
with pytest.raises(ValueError, match=match):
|
|
414
|
+
make_lammps_dump(**kwargs)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for the examples of the documentation pages in ``docs/source/``.
|
|
3
|
+
|
|
4
|
+
pytest collects doctests from ``polyphys``, ``README.md``, and ``projects``
|
|
5
|
+
only, so the ``>>>`` examples of a documentation page run here, one test
|
|
6
|
+
per page, through :func:`doctest.testfile`. The pages compute their expected
|
|
7
|
+
values by hand in the text beside each example.
|
|
8
|
+
"""
|
|
9
|
+
import doctest
|
|
10
|
+
import os
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import textwrap
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import pytest
|
|
17
|
+
|
|
18
|
+
import polyphys
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@pytest.fixture
|
|
22
|
+
def docs_source() -> Path:
|
|
23
|
+
"""The ``docs/source/`` directory of the checkout under test."""
|
|
24
|
+
return Path(__file__).resolve().parents[2] / "docs" / "source"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@pytest.mark.parametrize("page", [
|
|
28
|
+
pytest.param("quickstart.md", id="quickstart"),
|
|
29
|
+
pytest.param("lineage.md", id="lineage"),
|
|
30
|
+
pytest.param("studies.md", id="studies"),
|
|
31
|
+
pytest.param("usage.md", id="usage"),
|
|
32
|
+
pytest.param("viz.md", id="viz"),
|
|
33
|
+
])
|
|
34
|
+
def test_docs_page_examples_run(docs_source: Path, page: str) -> None:
|
|
35
|
+
"""Every example of the page gives the output the page shows."""
|
|
36
|
+
path = docs_source / page
|
|
37
|
+
|
|
38
|
+
result = doctest.testfile(str(path), module_relative=False,
|
|
39
|
+
encoding="utf-8", verbose=False)
|
|
40
|
+
|
|
41
|
+
assert result.attempted > 0, f"{page} has no examples"
|
|
42
|
+
assert result.failed == 0, f"{result.failed} example(s) of {page} failed"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_mocked_modules_import_with_their_annotations_evaluated():
|
|
46
|
+
"""The probe and viz modules import, and every annotation in them
|
|
47
|
+
evaluates, with MDAnalysis and matplotlib replaced by the mocks of the
|
|
48
|
+
docs build."""
|
|
49
|
+
pytest.importorskip("sphinx.ext.autodoc.mock")
|
|
50
|
+
script = textwrap.dedent("""\
|
|
51
|
+
import importlib
|
|
52
|
+
import inspect
|
|
53
|
+
import pkgutil
|
|
54
|
+
|
|
55
|
+
from sphinx.ext.autodoc.mock import mock
|
|
56
|
+
|
|
57
|
+
with mock(['MDAnalysis', 'matplotlib']):
|
|
58
|
+
for package in ('polyphys.probe', 'polyphys.viz'):
|
|
59
|
+
names = [package] + [
|
|
60
|
+
f'{package}.{info.name}' for info in pkgutil.iter_modules(
|
|
61
|
+
importlib.import_module(package).__path__)]
|
|
62
|
+
for name in names:
|
|
63
|
+
module = importlib.import_module(name)
|
|
64
|
+
for obj in vars(module).values():
|
|
65
|
+
if getattr(obj, '__module__', None) != name:
|
|
66
|
+
continue
|
|
67
|
+
members = [obj] + (list(vars(obj).values())
|
|
68
|
+
if inspect.isclass(obj) else [])
|
|
69
|
+
for member in members:
|
|
70
|
+
if inspect.isfunction(member) or inspect.isclass(
|
|
71
|
+
member):
|
|
72
|
+
inspect.get_annotations(member)
|
|
73
|
+
""")
|
|
74
|
+
# The child imports this checkout's package.
|
|
75
|
+
env = dict(os.environ,
|
|
76
|
+
PYTHONPATH=str(Path(polyphys.__file__).parents[1]))
|
|
77
|
+
|
|
78
|
+
completed = subprocess.run(
|
|
79
|
+
[sys.executable, '-c', script], env=env, capture_output=True,
|
|
80
|
+
text=True, timeout=120, check=False)
|
|
81
|
+
|
|
82
|
+
assert completed.returncode == 0, completed.stderr
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for :mod:`polyphys` distribution metadata.
|
|
3
|
+
|
|
4
|
+
These tests guard the packaging contract declared in ``pyproject.toml``
|
|
5
|
+
against defects that are invisible to the rest of the test suite because
|
|
6
|
+
they only surface in an *installed* copy of the package: an entry point
|
|
7
|
+
naming a module that does not exist, or a malformed version string. They
|
|
8
|
+
also guard the public API that ``docs/source/stability.md`` defines: the
|
|
9
|
+
``__all__`` of every module on the API page.
|
|
10
|
+
"""
|
|
11
|
+
import importlib
|
|
12
|
+
import re
|
|
13
|
+
from importlib.metadata import PackageNotFoundError, distribution
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import pytest
|
|
17
|
+
|
|
18
|
+
from polyphys.__version__ import __version__
|
|
19
|
+
|
|
20
|
+
#: ``MAJOR.MINOR.PATCH`` with an optional pre-release/build suffix.
|
|
21
|
+
SEMVER_RE = re.compile(
|
|
22
|
+
r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)"
|
|
23
|
+
r"(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
#: The API page, whose autosummary lists the public modules.
|
|
28
|
+
API_PAGE = Path(__file__).resolve().parents[2] / "docs" / "source" / "api.rst"
|
|
29
|
+
|
|
30
|
+
#: The extra each optional subpackage needs, by module prefix.
|
|
31
|
+
EXTRAS = {"polyphys.probe": "MDAnalysis", "polyphys.viz.helpers": "matplotlib"}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _api_modules() -> list[str]:
|
|
35
|
+
"""Return the modules the API page lists, or none without the page."""
|
|
36
|
+
if not API_PAGE.exists(): # pragma: no cover - depends on the checkout
|
|
37
|
+
return []
|
|
38
|
+
return re.findall(r"^ (polyphys(?:\.\w+)+)$", API_PAGE.read_text(),
|
|
39
|
+
flags=re.MULTILINE)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _polyphys_distribution():
|
|
43
|
+
"""
|
|
44
|
+
Return the installed ``polyphys`` distribution, or skip the test.
|
|
45
|
+
|
|
46
|
+
Skipping keeps the suite runnable from a bare source checkout, where
|
|
47
|
+
no distribution metadata exists to inspect.
|
|
48
|
+
"""
|
|
49
|
+
try:
|
|
50
|
+
return distribution("polyphys")
|
|
51
|
+
except PackageNotFoundError: # pragma: no cover - depends on install
|
|
52
|
+
pytest.skip("polyphys is not installed; no metadata to inspect")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_version_is_valid_semver() -> None:
|
|
56
|
+
"""``__version__`` must be a valid semantic version string.
|
|
57
|
+
|
|
58
|
+
Guards the release process: ``pyproject.toml`` reads this attribute
|
|
59
|
+
dynamically, so a malformed value produces a broken distribution.
|
|
60
|
+
"""
|
|
61
|
+
assert SEMVER_RE.match(__version__), (
|
|
62
|
+
f"__version__ = {__version__!r} is not valid semantic versioning"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_declared_console_scripts_are_importable() -> None:
|
|
67
|
+
"""Every declared console script must resolve to a real callable.
|
|
68
|
+
|
|
69
|
+
Regression test: the distribution previously declared
|
|
70
|
+
``polyphys = polyphys.cli:main`` while ``polyphys/cli.py`` did not
|
|
71
|
+
exist, so every install shipped a command that raised
|
|
72
|
+
``ModuleNotFoundError`` when run.
|
|
73
|
+
"""
|
|
74
|
+
dist = _polyphys_distribution()
|
|
75
|
+
scripts = [
|
|
76
|
+
ep for ep in dist.entry_points if ep.group == "console_scripts"
|
|
77
|
+
]
|
|
78
|
+
for entry_point in scripts:
|
|
79
|
+
target = entry_point.load()
|
|
80
|
+
assert callable(target), (
|
|
81
|
+
f"console script {entry_point.name!r} points at "
|
|
82
|
+
f"{entry_point.value!r}, which is not callable"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@pytest.mark.parametrize("module_name", _api_modules())
|
|
87
|
+
def test_api_module_declares_resolvable_all(module_name: str) -> None:
|
|
88
|
+
"""Every module on the API page declares ``__all__``, and each name in
|
|
89
|
+
it is an attribute of the module."""
|
|
90
|
+
for prefix, extra in EXTRAS.items():
|
|
91
|
+
if module_name.startswith(prefix):
|
|
92
|
+
pytest.importorskip(extra)
|
|
93
|
+
module = importlib.import_module(module_name)
|
|
94
|
+
|
|
95
|
+
assert hasattr(module, "__all__"), f"{module_name} has no __all__"
|
|
96
|
+
missing = [name for name in module.__all__ if not hasattr(module, name)]
|
|
97
|
+
assert missing == []
|