mdinterface 1.5.3__tar.gz → 1.5.4__tar.gz

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 (96) hide show
  1. {mdinterface-1.5.3/mdinterface.egg-info → mdinterface-1.5.4}/PKG-INFO +1 -1
  2. {mdinterface-1.5.3 → mdinterface-1.5.4}/mdinterface/__init__.py +2 -2
  3. {mdinterface-1.5.3 → mdinterface-1.5.4}/mdinterface/core/topology.py +3 -2
  4. {mdinterface-1.5.3 → mdinterface-1.5.4}/mdinterface/io/lammpswriter.py +45 -13
  5. {mdinterface-1.5.3 → mdinterface-1.5.4}/mdinterface/simulationbox.py +9 -3
  6. {mdinterface-1.5.3 → mdinterface-1.5.4/mdinterface.egg-info}/PKG-INFO +1 -1
  7. {mdinterface-1.5.3 → mdinterface-1.5.4}/mdinterface.egg-info/SOURCES.txt +2 -0
  8. {mdinterface-1.5.3 → mdinterface-1.5.4}/mdinterface.egg-info/top_level.txt +1 -0
  9. mdinterface-1.5.4/scripts/extract_changelog_summary.py +78 -0
  10. mdinterface-1.5.4/tests/test_changelog_summary.py +91 -0
  11. {mdinterface-1.5.3 → mdinterface-1.5.4}/LICENSE +0 -0
  12. {mdinterface-1.5.3 → mdinterface-1.5.4}/MANIFEST.in +0 -0
  13. {mdinterface-1.5.3 → mdinterface-1.5.4}/README.md +0 -0
  14. {mdinterface-1.5.3 → mdinterface-1.5.4}/assets/mdinterface.png +0 -0
  15. {mdinterface-1.5.3 → mdinterface-1.5.4}/docs/api/database.md +0 -0
  16. {mdinterface-1.5.3 → mdinterface-1.5.4}/docs/api/externals.md +0 -0
  17. {mdinterface-1.5.3 → mdinterface-1.5.4}/docs/api/io.md +0 -0
  18. {mdinterface-1.5.3 → mdinterface-1.5.4}/docs/api/polymer.md +0 -0
  19. {mdinterface-1.5.3 → mdinterface-1.5.4}/docs/api/simcell.md +0 -0
  20. {mdinterface-1.5.3 → mdinterface-1.5.4}/docs/api/specie.md +0 -0
  21. {mdinterface-1.5.3 → mdinterface-1.5.4}/docs/assets/mdinterface.png +0 -0
  22. {mdinterface-1.5.3 → mdinterface-1.5.4}/docs/guide/database.md +0 -0
  23. {mdinterface-1.5.3 → mdinterface-1.5.4}/docs/guide/logging.md +0 -0
  24. {mdinterface-1.5.3 → mdinterface-1.5.4}/docs/guide/polymer.md +0 -0
  25. {mdinterface-1.5.3 → mdinterface-1.5.4}/docs/guide/simcell.md +0 -0
  26. {mdinterface-1.5.3 → mdinterface-1.5.4}/docs/guide/specie.md +0 -0
  27. {mdinterface-1.5.3 → mdinterface-1.5.4}/docs/index.md +0 -0
  28. {mdinterface-1.5.3 → mdinterface-1.5.4}/docs/installation.md +0 -0
  29. {mdinterface-1.5.3 → mdinterface-1.5.4}/docs/quickstart.md +0 -0
  30. {mdinterface-1.5.3 → mdinterface-1.5.4}/docs/requirements.txt +0 -0
  31. {mdinterface-1.5.3 → mdinterface-1.5.4}/examples/electrode_interface.py +0 -0
  32. {mdinterface-1.5.3 → mdinterface-1.5.4}/examples/legacy/make_POSCAR.py +0 -0
  33. {mdinterface-1.5.3 → mdinterface-1.5.4}/examples/legacy/make_box.py +0 -0
  34. {mdinterface-1.5.3 → mdinterface-1.5.4}/examples/legacy/make_polymer.py +0 -0
  35. {mdinterface-1.5.3 → mdinterface-1.5.4}/examples/legacy/make_solvent_box.py +0 -0
  36. {mdinterface-1.5.3 → mdinterface-1.5.4}/examples/legacy/make_specie_ligpargen_resp.py +0 -0
  37. {mdinterface-1.5.3 → mdinterface-1.5.4}/examples/multilayer.py +0 -0
  38. {mdinterface-1.5.3 → mdinterface-1.5.4}/examples/multisolvent_box.py +0 -0
  39. {mdinterface-1.5.3 → mdinterface-1.5.4}/examples/polymer/polymer_piperion.py +0 -0
  40. {mdinterface-1.5.3 → mdinterface-1.5.4}/examples/sandwich_from_traj.py +0 -0
  41. {mdinterface-1.5.3 → mdinterface-1.5.4}/examples/solvent_box.py +0 -0
  42. {mdinterface-1.5.3 → mdinterface-1.5.4}/mdinterface/build/__init__.py +0 -0
  43. {mdinterface-1.5.3 → mdinterface-1.5.4}/mdinterface/build/box.py +0 -0
  44. {mdinterface-1.5.3 → mdinterface-1.5.4}/mdinterface/build/builder.py +0 -0
  45. {mdinterface-1.5.3 → mdinterface-1.5.4}/mdinterface/build/continuum2sim.py +0 -0
  46. {mdinterface-1.5.3 → mdinterface-1.5.4}/mdinterface/build/polymerize.py +0 -0
  47. {mdinterface-1.5.3 → mdinterface-1.5.4}/mdinterface/build/snippets.py +0 -0
  48. {mdinterface-1.5.3 → mdinterface-1.5.4}/mdinterface/build/solvent.py +0 -0
  49. {mdinterface-1.5.3 → mdinterface-1.5.4}/mdinterface/config.py +0 -0
  50. {mdinterface-1.5.3 → mdinterface-1.5.4}/mdinterface/core/__init__.py +0 -0
  51. {mdinterface-1.5.3 → mdinterface-1.5.4}/mdinterface/core/polymer.py +0 -0
  52. {mdinterface-1.5.3 → mdinterface-1.5.4}/mdinterface/core/specie.py +0 -0
  53. {mdinterface-1.5.3 → mdinterface-1.5.4}/mdinterface/database/__init__.py +0 -0
  54. {mdinterface-1.5.3 → mdinterface-1.5.4}/mdinterface/database/graphene.py +0 -0
  55. {mdinterface-1.5.3 → mdinterface-1.5.4}/mdinterface/database/ions.py +0 -0
  56. {mdinterface-1.5.3 → mdinterface-1.5.4}/mdinterface/database/metals.py +0 -0
  57. {mdinterface-1.5.3 → mdinterface-1.5.4}/mdinterface/database/molecules.py +0 -0
  58. {mdinterface-1.5.3 → mdinterface-1.5.4}/mdinterface/database/nobles.py +0 -0
  59. {mdinterface-1.5.3 → mdinterface-1.5.4}/mdinterface/externals/__init__.py +0 -0
  60. {mdinterface-1.5.3 → mdinterface-1.5.4}/mdinterface/externals/aimd.py +0 -0
  61. {mdinterface-1.5.3 → mdinterface-1.5.4}/mdinterface/externals/ase.py +0 -0
  62. {mdinterface-1.5.3 → mdinterface-1.5.4}/mdinterface/externals/ligpargen.py +0 -0
  63. {mdinterface-1.5.3 → mdinterface-1.5.4}/mdinterface/externals/obabel.py +0 -0
  64. {mdinterface-1.5.3 → mdinterface-1.5.4}/mdinterface/externals/optimization.py +0 -0
  65. {mdinterface-1.5.3 → mdinterface-1.5.4}/mdinterface/externals/pyscf.py +0 -0
  66. {mdinterface-1.5.3 → mdinterface-1.5.4}/mdinterface/io/__init__.py +0 -0
  67. {mdinterface-1.5.3 → mdinterface-1.5.4}/mdinterface/io/gromacswriter.py +0 -0
  68. {mdinterface-1.5.3 → mdinterface-1.5.4}/mdinterface/io/packmol.py +0 -0
  69. {mdinterface-1.5.3 → mdinterface-1.5.4}/mdinterface/io/read.py +0 -0
  70. {mdinterface-1.5.3 → mdinterface-1.5.4}/mdinterface/read/cp2ktraj.py +0 -0
  71. {mdinterface-1.5.3 → mdinterface-1.5.4}/mdinterface/read/lammpstraj.py +0 -0
  72. {mdinterface-1.5.3 → mdinterface-1.5.4}/mdinterface/read/read.py +0 -0
  73. {mdinterface-1.5.3 → mdinterface-1.5.4}/mdinterface/read/trajectory.py +0 -0
  74. {mdinterface-1.5.3 → mdinterface-1.5.4}/mdinterface/read/xyztraj.py +0 -0
  75. {mdinterface-1.5.3 → mdinterface-1.5.4}/mdinterface/utils/__init__.py +0 -0
  76. {mdinterface-1.5.3 → mdinterface-1.5.4}/mdinterface/utils/auxiliary.py +0 -0
  77. {mdinterface-1.5.3 → mdinterface-1.5.4}/mdinterface/utils/draw.py +0 -0
  78. {mdinterface-1.5.3 → mdinterface-1.5.4}/mdinterface/utils/graphs.py +0 -0
  79. {mdinterface-1.5.3 → mdinterface-1.5.4}/mdinterface/utils/logger.py +0 -0
  80. {mdinterface-1.5.3 → mdinterface-1.5.4}/mdinterface/utils/map.py +0 -0
  81. {mdinterface-1.5.3 → mdinterface-1.5.4}/mdinterface/utils/poisson.py +0 -0
  82. {mdinterface-1.5.3 → mdinterface-1.5.4}/mdinterface/utils/rings.py +0 -0
  83. {mdinterface-1.5.3 → mdinterface-1.5.4}/mdinterface/utils/units.py +0 -0
  84. {mdinterface-1.5.3 → mdinterface-1.5.4}/mdinterface.egg-info/dependency_links.txt +0 -0
  85. {mdinterface-1.5.3 → mdinterface-1.5.4}/mdinterface.egg-info/requires.txt +0 -0
  86. {mdinterface-1.5.3 → mdinterface-1.5.4}/pyproject.toml +0 -0
  87. {mdinterface-1.5.3 → mdinterface-1.5.4}/requirements.txt +0 -0
  88. {mdinterface-1.5.3 → mdinterface-1.5.4}/setup.cfg +0 -0
  89. {mdinterface-1.5.3 → mdinterface-1.5.4}/setup.py +0 -0
  90. {mdinterface-1.5.3 → mdinterface-1.5.4}/tests/__init__.py +0 -0
  91. {mdinterface-1.5.3 → mdinterface-1.5.4}/tests/test_auxiliary.py +0 -0
  92. {mdinterface-1.5.3 → mdinterface-1.5.4}/tests/test_builder.py +0 -0
  93. {mdinterface-1.5.3 → mdinterface-1.5.4}/tests/test_database.py +0 -0
  94. {mdinterface-1.5.3 → mdinterface-1.5.4}/tests/test_solvent.py +0 -0
  95. {mdinterface-1.5.3 → mdinterface-1.5.4}/tests/test_specie.py +0 -0
  96. {mdinterface-1.5.3 → mdinterface-1.5.4}/tests/test_topology.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mdinterface
3
- Version: 1.5.3
3
+ Version: 1.5.4
4
4
  Summary: Build Interface Systems for Molecular Dynamics Simulations
5
5
  Author-email: Fabrice Roncoroni <fabrice.roncoroni@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -6,8 +6,8 @@ Initially developed to construct electrolyte/electrode interfaces, it is also we
6
6
 
7
7
  """
8
8
 
9
- __version__ = '1.5.3'
10
- __date__ = '09 Jul. 2026'
9
+ __version__ = '1.5.4'
10
+ __date__ = '12 Aug. 2026'
11
11
  __author__ = 'Fabrice Roncoroni'
12
12
  __all__ = ['SimulationBox', 'SimCell', 'BoxBuilder', "Specie", "Polymer"]
13
13
 
@@ -249,13 +249,14 @@ class Dihedral(Topology):
249
249
  return (self.values == other.values and
250
250
  (self.symbols == other.symbols or self.symbols == other.symbols[::-1]))
251
251
 
252
- def write(self, fout):
252
+ def write(self, fout, idx=None):
253
253
  atype = "{}-{}-{}-{}".format(*self.symbols)
254
254
  if self._values[-1] is not None:
255
255
  value = "{:>7.4f} {:>7.4f} {:>7.4f} {:>7.4f} {:>7.4f}".format(*self.values)
256
256
  else:
257
257
  value = "{:>7.4f} {:>7.4f} {:>7.4f} {:>7.4f}".format(*self.values[:-1])
258
- fout.write("{:>5} {} # {:<8} | {}\n".format(self.id, value, atype, self.resname))
258
+ fout.write("{:>5} {} # {:<8} | {}\n".format(
259
+ idx if idx is not None else self.id, value, atype, self.resname))
259
260
 
260
261
  def __bool__(self):
261
262
  return any([bool(val) for val in self.values])
@@ -25,6 +25,16 @@ btype_sections = {'bond':'Bonds', 'angle':'Angles',
25
25
  'dihedral':'Dihedrals', 'improper':'Impropers'}
26
26
 
27
27
 
28
+ def _renumber_bonded_types(raw_types):
29
+ """
30
+ Map raw per-item bond/angle/dihedral/improper type ids -- which may be
31
+ sparse or non-contiguous (e.g. when a Specie template is reused across
32
+ several layers) -- to a contiguous 1..N range ordered by numeric value.
33
+ """
34
+ unique_sorted = sorted({int(t) for t in raw_types})
35
+ return {t: i + 1 for i, t in enumerate(unique_sorted)}
36
+
37
+
28
38
  class DATAWriter(base.WriterBase):
29
39
  """Write out the current time step as a LAMMPS DATA file.
30
40
 
@@ -161,9 +171,14 @@ class DATAWriter(base.WriterBase):
161
171
  self.f.write('\n')
162
172
  self.f.write('{}\n'.format(btype_sections[bonds.btype]))
163
173
  self.f.write('\n')
174
+ try:
175
+ remap = _renumber_bonded_types(bond.type for bond in bonds)
176
+ except (TypeError, ValueError):
177
+ remap = None
164
178
  for bond, i in zip(bonds, range(1, len(bonds)+1)):
165
179
  try:
166
- self.f.write('{:d} {:d} '.format(i, int(bond.type))+\
180
+ btype_id = remap[int(bond.type)] if remap is not None else int(bond.type)
181
+ self.f.write('{:d} {:d} '.format(i, btype_id)+\
167
182
  ' '.join((bond.atoms.indices + 1).astype(str))+'\n')
168
183
  except TypeError:
169
184
  errmsg = (f"LAMMPS DATAWriter: Trying to write bond, but bond "
@@ -337,18 +352,20 @@ def write_lammps_coefficients(
337
352
  # Write Pair Coefficients
338
353
  fout.write("Pair Coeffs\n\n")
339
354
 
340
- idx = 1
355
+ present_types = list(np.unique(system.atoms.types))
341
356
  atom_types = sorted_attributes.get("atoms", [])
357
+ written = set()
342
358
  for atom in atom_types:
343
- if atom.extended_label not in np.unique(system.atoms.types):
359
+ if atom.extended_label not in present_types or atom.extended_label in written:
344
360
  continue
361
+ written.add(atom.extended_label)
362
+ idx = present_types.index(atom.extended_label) + 1
345
363
 
346
364
  eps = atom.eps if atom.eps is not None else 0
347
365
  sig = atom.sig if atom.sig is not None else 0
348
366
 
349
367
  fout.write("{:>5} {:>12.8f} {:>12.8f} # {}\n".format(
350
368
  idx, eps, sig, atom.extended_label))
351
- idx += 1
352
369
 
353
370
  # Write Bond Coefficients
354
371
  bond_types = sorted_attributes.get("bonds", [])
@@ -356,9 +373,13 @@ def write_lammps_coefficients(
356
373
  fout.write("\n")
357
374
  fout.write("Bond Coeffs\n\n")
358
375
 
376
+ bond_remap = _renumber_bonded_types(system.bonds.types()) if len(system.bonds) else {}
377
+ written_bonds = set()
359
378
  for bond in bond_types:
360
- if bond.id not in np.array(system.bonds.types(), dtype=int):
379
+ if int(bond.id) not in bond_remap or bond.id in written_bonds:
361
380
  continue
381
+ written_bonds.add(bond.id)
382
+ idx = bond_remap[int(bond.id)]
362
383
 
363
384
  kr = bond.kr if bond.kr is not None else 0
364
385
  r0 = bond.r0 if bond.r0 is not None else 0
@@ -366,7 +387,7 @@ def write_lammps_coefficients(
366
387
  btype = "{}-{}".format(*bond.symbols)
367
388
 
368
389
  fout.write("{:>5} {:>10.6f} {:>10.6f} # {:<5} | {}\n".format(
369
- bond.id, kr, r0, btype, bond.resname))
390
+ idx, kr, r0, btype, bond.resname))
370
391
 
371
392
  # Write Angle Coefficients
372
393
  angle_types = sorted_attributes.get("angles", [])
@@ -374,9 +395,13 @@ def write_lammps_coefficients(
374
395
  fout.write("\n")
375
396
  fout.write("Angle Coeffs\n\n")
376
397
 
398
+ angle_remap = _renumber_bonded_types(system.angles.types()) if len(system.angles) else {}
399
+ written_angles = set()
377
400
  for angle in angle_types:
378
- if angle.id not in np.array(system.angles.types(), dtype=int):
401
+ if int(angle.id) not in angle_remap or angle.id in written_angles:
379
402
  continue
403
+ written_angles.add(angle.id)
404
+ idx = angle_remap[int(angle.id)]
380
405
 
381
406
  kr = angle.kr if angle.kr is not None else 0
382
407
  theta0 = angle.theta0 if angle.theta0 is not None else 0
@@ -384,7 +409,7 @@ def write_lammps_coefficients(
384
409
  atype = "{}-{}-{}".format(*angle.symbols)
385
410
 
386
411
  fout.write("{:>5} {:>10.6f} {:>10.6f} # {:<8} | {}\n".format(
387
- angle.id, kr, theta0, atype, angle.resname))
412
+ idx, kr, theta0, atype, angle.resname))
388
413
 
389
414
  # Write Dihedral Coefficients
390
415
  dihedral_types = sorted_attributes.get("dihedrals", [])
@@ -392,26 +417,33 @@ def write_lammps_coefficients(
392
417
  fout.write("\n")
393
418
  fout.write("Dihedral Coeffs\n\n")
394
419
 
420
+ dihedral_remap = _renumber_bonded_types(system.dihedrals.types()) if len(system.dihedrals) else {}
421
+ written_dihedrals = set()
395
422
  for dihedral in dihedral_types:
396
- if dihedral.id not in np.array(system.dihedrals.types(), dtype=int):
423
+ if int(dihedral.id) not in dihedral_remap or dihedral.id in written_dihedrals:
397
424
  continue
425
+ written_dihedrals.add(dihedral.id)
398
426
 
399
- dihedral.write(fout)
427
+ dihedral.write(fout, idx=dihedral_remap[int(dihedral.id)])
400
428
 
401
429
  # Write Improper Coefficients
402
430
  improper_types = sorted_attributes.get("impropers", [])
403
431
  if improper_types and system.impropers.types():
404
432
  fout.write("\n")
405
433
  fout.write("Improper Coeffs\n\n")
406
-
434
+
435
+ improper_remap = _renumber_bonded_types(system.impropers.types()) if len(system.impropers) else {}
436
+ written_impropers = set()
407
437
  for improper in improper_types:
408
- if improper.id not in np.array(system.impropers.types(), dtype=int):
438
+ if int(improper.id) not in improper_remap or improper.id in written_impropers:
409
439
  continue
440
+ written_impropers.add(improper.id)
441
+ idx = improper_remap[int(improper.id)]
410
442
 
411
443
  atype = "{}-{}-{}-{}".format(*improper.symbols)
412
444
  value = "{:>7.4f} {:>2d} {:>2d}".format(*improper.values)
413
445
 
414
- fout.write("{:>5} {} # {:<2} | {}\n".format(improper.id, value, atype, improper.resname))
446
+ fout.write("{:>5} {} # {:<2} | {}\n".format(idx, value, atype, improper.resname))
415
447
 
416
448
  fout.write("\n")
417
449
 
@@ -22,7 +22,6 @@ import numpy as np
22
22
 
23
23
  import shutil
24
24
  import warnings
25
- warnings.filterwarnings('ignore')
26
25
 
27
26
  #%%
28
27
 
@@ -56,7 +55,14 @@ class SimulationBox():
56
55
  def __init__(self, solvent: Optional[Any] = None, solute: Optional[Union[Any, List[Any]]] = None,
57
56
  interface: Optional[Any] = None, enderface: Optional[Any] = None,
58
57
  miderface: Optional[Any] = None) -> None:
59
-
58
+
59
+ warnings.warn(
60
+ "SimulationBox is deprecated and will be removed in a future version. "
61
+ "Use SimCell instead.",
62
+ DeprecationWarning,
63
+ stacklevel=2,
64
+ )
65
+
60
66
  # start species
61
67
  self._setup_species(solvent, solute, interface, enderface, miderface)
62
68
 
@@ -367,7 +373,7 @@ class SimulationBox():
367
373
  ion_pos = layer["ion_pos"]
368
374
 
369
375
  # make solvent box
370
- solvent = make_solvent_box(self.species, self.solvent, self._solute,
376
+ solvent = make_solvent_box(self.species, self._solvent, self._solute,
371
377
  [xsize, ysize, zsize], solv_rho,
372
378
  nions, concentration, conmodel, ion_pos, nsolvent)
373
379
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mdinterface
3
- Version: 1.5.3
3
+ Version: 1.5.4
4
4
  Summary: Build Interface Systems for Molecular Dynamics Simulations
5
5
  Author-email: Fabrice Roncoroni <fabrice.roncoroni@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -83,9 +83,11 @@ mdinterface/utils/map.py
83
83
  mdinterface/utils/poisson.py
84
84
  mdinterface/utils/rings.py
85
85
  mdinterface/utils/units.py
86
+ scripts/extract_changelog_summary.py
86
87
  tests/__init__.py
87
88
  tests/test_auxiliary.py
88
89
  tests/test_builder.py
90
+ tests/test_changelog_summary.py
89
91
  tests/test_database.py
90
92
  tests/test_solvent.py
91
93
  tests/test_specie.py
@@ -3,4 +3,5 @@ dist
3
3
  docs
4
4
  examples
5
5
  mdinterface
6
+ scripts
6
7
  tests
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env python3
2
+ """Extract a version's summary paragraph from CHANGELOG.md.
3
+
4
+ Usage: python scripts/extract_changelog_summary.py VERSION
5
+
6
+ Prints the summary paragraph to stdout and exits 0 on success. Exits 1 with
7
+ an error on stderr if the version's changelog header, or its summary
8
+ paragraph, is missing.
9
+ """
10
+ import sys
11
+ from pathlib import Path
12
+
13
+
14
+ def extract_summary(changelog_text: str, version: str) -> str:
15
+ """Return the summary paragraph for `## [version]` in changelog_text.
16
+
17
+ The summary is every non-blank line immediately following the header
18
+ (after skipping any blank lines right after it), stopping at the next
19
+ blank line or the next '## '/'### ' header — i.e. exactly one paragraph.
20
+ """
21
+ header = f"## [{version}]"
22
+ lines = changelog_text.splitlines()
23
+
24
+ header_idx = None
25
+ for i, line in enumerate(lines):
26
+ if line.startswith(header):
27
+ header_idx = i
28
+ break
29
+
30
+ if header_idx is None:
31
+ raise ValueError(
32
+ f"No changelog header found for version {version!r} "
33
+ f"(expected a line starting with {header!r})"
34
+ )
35
+
36
+ i = header_idx + 1
37
+ while i < len(lines) and lines[i].strip() == "":
38
+ i += 1
39
+
40
+ summary_lines = []
41
+ while i < len(lines):
42
+ line = lines[i]
43
+ if line.strip() == "" or line.startswith("## ") or line.startswith("### "):
44
+ break
45
+ summary_lines.append(line)
46
+ i += 1
47
+
48
+ summary = "\n".join(summary_lines).strip()
49
+ if not summary:
50
+ raise ValueError(
51
+ f"No summary paragraph found under {header!r} in CHANGELOG.md - "
52
+ "add one before the first '### ' group"
53
+ )
54
+
55
+ return summary
56
+
57
+
58
+ def main(argv):
59
+ if len(argv) != 2:
60
+ print("Usage: extract_changelog_summary.py VERSION", file=sys.stderr)
61
+ return 1
62
+
63
+ version = argv[1]
64
+ changelog_path = Path(__file__).resolve().parent.parent / "CHANGELOG.md"
65
+ changelog_text = changelog_path.read_text()
66
+
67
+ try:
68
+ summary = extract_summary(changelog_text, version)
69
+ except ValueError as exc:
70
+ print(f"ERROR: {exc}", file=sys.stderr)
71
+ return 1
72
+
73
+ print(summary)
74
+ return 0
75
+
76
+
77
+ if __name__ == "__main__":
78
+ sys.exit(main(sys.argv))
@@ -0,0 +1,91 @@
1
+ import importlib.util
2
+ from pathlib import Path
3
+
4
+ import pytest
5
+
6
+ _SCRIPT_PATH = Path(__file__).resolve().parent.parent / "scripts" / "extract_changelog_summary.py"
7
+ _spec = importlib.util.spec_from_file_location("extract_changelog_summary", _SCRIPT_PATH)
8
+ _module = importlib.util.module_from_spec(_spec)
9
+ _spec.loader.exec_module(_module)
10
+ extract_summary = _module.extract_summary
11
+
12
+
13
+ def test_extract_summary_found():
14
+ changelog = """# Changelog
15
+
16
+ ## [Unreleased]
17
+
18
+ ---
19
+
20
+ ## [1.5.4] - 2026-08-11
21
+
22
+ Fixes a silent LAMMPS output corruption bug.
23
+
24
+ ### Fixed
25
+ - Something
26
+ """
27
+ assert extract_summary(changelog, "1.5.4") == "Fixes a silent LAMMPS output corruption bug."
28
+
29
+
30
+ def test_extract_summary_multiline():
31
+ changelog = """## [1.5.4] - 2026-08-11
32
+
33
+ Fixes a silent LAMMPS output corruption bug affecting systems that reuse
34
+ the same Specie template across multiple layers.
35
+
36
+ ### Fixed
37
+ - Something
38
+ """
39
+ expected = (
40
+ "Fixes a silent LAMMPS output corruption bug affecting systems that reuse\n"
41
+ "the same Specie template across multiple layers."
42
+ )
43
+ assert extract_summary(changelog, "1.5.4") == expected
44
+
45
+
46
+ def test_missing_version_header_raises():
47
+ changelog = """## [1.5.3] - 2026-07-09
48
+
49
+ Some summary.
50
+
51
+ ### Fixed
52
+ - Something
53
+ """
54
+ with pytest.raises(ValueError, match="No changelog header found"):
55
+ extract_summary(changelog, "1.5.4")
56
+
57
+
58
+ def test_missing_summary_paragraph_raises():
59
+ changelog = """## [1.5.4] - 2026-08-11
60
+
61
+ ### Fixed
62
+ - Something
63
+ """
64
+ with pytest.raises(ValueError, match="No summary paragraph found"):
65
+ extract_summary(changelog, "1.5.4")
66
+
67
+
68
+ def test_second_paragraph_not_included():
69
+ changelog = """## [1.5.4] - 2026-08-11
70
+
71
+ First paragraph only.
72
+
73
+ Second paragraph should not be included.
74
+
75
+ ### Fixed
76
+ - Something
77
+ """
78
+ assert extract_summary(changelog, "1.5.4") == "First paragraph only."
79
+
80
+
81
+ def test_header_prefix_does_not_match_longer_version():
82
+ # "## [1.5.4]" must not match when looking for "1.5" or "1.5.40"
83
+ changelog = """## [1.5.40] - 2026-08-11
84
+
85
+ Wrong version's summary.
86
+
87
+ ### Fixed
88
+ - Something
89
+ """
90
+ with pytest.raises(ValueError, match="No changelog header found"):
91
+ extract_summary(changelog, "1.5.4")
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes