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.
Files changed (69) hide show
  1. polyphys/__init__.py +7 -0
  2. polyphys/__version__.py +22 -0
  3. polyphys/analyze/__init__.py +51 -0
  4. polyphys/analyze/clusters.py +1738 -0
  5. polyphys/analyze/correlations.py +995 -0
  6. polyphys/analyze/density.py +1138 -0
  7. polyphys/analyze/measurer.py +1338 -0
  8. polyphys/analyze/pipeline.py +1868 -0
  9. polyphys/analyze/stats.py +810 -0
  10. polyphys/analyze/sumrule.py +1159 -0
  11. polyphys/manage/__init__.py +11 -0
  12. polyphys/manage/organizer.py +2188 -0
  13. polyphys/manage/parser.py +1264 -0
  14. polyphys/manage/types.py +171 -0
  15. polyphys/manage/utils.py +777 -0
  16. polyphys/probe/__init__.py +25 -0
  17. polyphys/probe/binders.py +711 -0
  18. polyphys/probe/chain_size.py +583 -0
  19. polyphys/probe/core.py +437 -0
  20. polyphys/probe/histograms.py +863 -0
  21. polyphys/probe/pairs.py +555 -0
  22. polyphys/simulate/__init__.py +48 -0
  23. polyphys/simulate/build.py +1231 -0
  24. polyphys/simulate/core.py +237 -0
  25. polyphys/simulate/data.py +977 -0
  26. polyphys/simulate/dump.py +752 -0
  27. polyphys/simulate/log.py +788 -0
  28. polyphys/spec.py +1359 -0
  29. polyphys/tests/analyze/test_clusters.py +1058 -0
  30. polyphys/tests/analyze/test_correlations.py +708 -0
  31. polyphys/tests/analyze/test_density.py +813 -0
  32. polyphys/tests/analyze/test_measurer.py +785 -0
  33. polyphys/tests/analyze/test_pipeline.py +1881 -0
  34. polyphys/tests/analyze/test_stats.py +732 -0
  35. polyphys/tests/analyze/test_sumrule.py +917 -0
  36. polyphys/tests/conftest.py +753 -0
  37. polyphys/tests/data/lammps_build/make_starts.py +70 -0
  38. polyphys/tests/manage/test_organizer.py +1495 -0
  39. polyphys/tests/manage/test_parser.py +1222 -0
  40. polyphys/tests/manage/test_utils.py +498 -0
  41. polyphys/tests/probe/conftest.py +407 -0
  42. polyphys/tests/probe/test_binders.py +686 -0
  43. polyphys/tests/probe/test_chain_size.py +557 -0
  44. polyphys/tests/probe/test_core.py +391 -0
  45. polyphys/tests/probe/test_histograms.py +779 -0
  46. polyphys/tests/probe/test_pairs.py +475 -0
  47. polyphys/tests/simulate/test_build.py +1042 -0
  48. polyphys/tests/simulate/test_core.py +106 -0
  49. polyphys/tests/simulate/test_data.py +858 -0
  50. polyphys/tests/simulate/test_dump.py +487 -0
  51. polyphys/tests/simulate/test_log.py +667 -0
  52. polyphys/tests/strategies.py +120 -0
  53. polyphys/tests/synthetic.py +410 -0
  54. polyphys/tests/test_conftest.py +414 -0
  55. polyphys/tests/test_docs.py +82 -0
  56. polyphys/tests/test_packaging.py +97 -0
  57. polyphys/tests/test_spec.py +772 -0
  58. polyphys/tests/viz/conftest.py +32 -0
  59. polyphys/tests/viz/test_colors.py +113 -0
  60. polyphys/tests/viz/test_helpers.py +292 -0
  61. polyphys/viz/__init__.py +15 -0
  62. polyphys/viz/colors.py +204 -0
  63. polyphys/viz/helpers.py +549 -0
  64. polyphys-1.0.0.dist-info/METADATA +339 -0
  65. polyphys-1.0.0.dist-info/RECORD +69 -0
  66. polyphys-1.0.0.dist-info/WHEEL +5 -0
  67. polyphys-1.0.0.dist-info/licenses/AUTHORS.rst +13 -0
  68. polyphys-1.0.0.dist-info/licenses/LICENSE +22 -0
  69. 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 == []