helmkit 0.7.5__tar.gz → 0.7.7__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.
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: helmkit
3
- Version: 0.7.5
3
+ Version: 0.7.7
4
4
  Summary: Parse HELM strings into RDKit molecules
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.11
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "helmkit"
3
- version = "0.7.5"
3
+ version = "0.7.7"
4
4
  description = "Parse HELM strings into RDKit molecules"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -47,6 +47,11 @@ ignore = ["I001", "N999"]
47
47
  [tool.ruff.format]
48
48
  skip-magic-trailing-comma = true
49
49
 
50
+ [tool.ty.rules]
51
+ all = "error"
52
+ unsound-return-statement = "ignore"
53
+ unsound-assignment = "ignore"
54
+
50
55
  [tool.ty.src]
51
56
  include = ["src"]
52
57
  exclude = ["tests"]
@@ -0,0 +1,6 @@
1
+ from .molecule import load_in_parallel
2
+ from .molecule import load_monomer_library
3
+ from .molecule import MAX_RGROUPS
4
+ from .molecule import Molecule
5
+
6
+ __all__ = ["MAX_RGROUPS", "Molecule", "load_in_parallel", "load_monomer_library"]
@@ -4,9 +4,13 @@ import re
4
4
  import warnings
5
5
  from collections import defaultdict
6
6
  from collections.abc import Callable
7
+ from collections.abc import Iterable
7
8
  from collections.abc import Sequence
8
9
  from functools import lru_cache
9
10
  from importlib.resources import files
11
+ from typing import assert_never
12
+ from typing import cast
13
+ from typing import Literal
10
14
  from typing import overload
11
15
  from typing import TypedDict
12
16
  from typing import TypeVar
@@ -15,16 +19,13 @@ from rdkit import Chem
15
19
  from rdkit import rdBase
16
20
 
17
21
 
18
- class SequenceConstants:
19
- max_rgroups: int = 4
22
+ MAX_RGROUPS = 4
20
23
 
21
24
 
22
25
  def get_molecule_property(
23
26
  molecule: Chem.Mol, property_name: str, default: str | None = None
24
27
  ) -> str | None:
25
- return (
26
- molecule.GetProp(property_name) if molecule.HasProp(property_name) else default
27
- )
28
+ return molecule.GetProp(property_name, default=default)
28
29
 
29
30
 
30
31
  T = TypeVar("T")
@@ -69,14 +70,14 @@ def infer_attachment_points(
69
70
  continue
70
71
 
71
72
  atom = molecule.GetAtomWithIdx(r_idx)
73
+ bonds: tuple[Chem.Bond, ...] = atom.GetBonds()
72
74
 
73
- for bond in atom.GetBonds():
75
+ for bond in bonds:
74
76
  other_idx = bond.GetOtherAtomIdx(r_idx)
75
77
  attachment_points.append(other_idx)
76
78
  break
77
79
  else:
78
- attachment_points.append(None)
79
- warnings.warn(
80
+ raise ValueError(
80
81
  f"R-group atom {r_idx} has no bonds to determine attachment point"
81
82
  )
82
83
 
@@ -103,12 +104,13 @@ def load_monomer_library(library_path: str | None = None) -> MonomerLibrary:
103
104
  monomers_dict: MonomerLibrary = defaultdict(dict)
104
105
  supplier = Chem.SDMolSupplier(library_path, removeHs=False)
105
106
 
106
- for mol in supplier:
107
+ for mol in cast(Iterable[Chem.Mol | None], supplier):
107
108
  if mol is None:
108
109
  continue
109
110
 
110
111
  symbol = get_molecule_property(mol, "symbol")
111
112
  if not symbol:
113
+ warnings.warn("Monomer without a symbol property will be skipped")
112
114
  continue
113
115
 
114
116
  m_type = get_molecule_property(mol, "m_type", "")
@@ -122,9 +124,9 @@ def load_monomer_library(library_path: str | None = None) -> MonomerLibrary:
122
124
  rgroup_idx = parse_comma_separated_property(mol, "m_RgroupIdx", int)
123
125
  attachment_point_idx = infer_attachment_points(mol, rgroup_idx)
124
126
 
125
- abbr = get_molecule_property(mol, "m_abbr", "")
126
- if not abbr:
127
- continue
127
+ # m_abbr is only used for display, so fall back to the symbol when a
128
+ # library does not provide it instead of dropping the monomer.
129
+ abbr = get_molecule_property(mol, "m_abbr") or symbol
128
130
 
129
131
  monomers_dict[m_type][symbol] = {
130
132
  "m_romol": mol,
@@ -183,13 +185,13 @@ def _create_missing_monomer(monomer_name: str, m_type: str = "aa") -> MonomerDat
183
185
  r_group_idx = [idx for _, idx in sorted_r]
184
186
  mol = Chem.RenumberAtoms(mol, main_atoms + r_group_idx)
185
187
 
186
- rgroup_idx_full: list[int | None] = [None] * SequenceConstants.max_rgroups
188
+ rgroup_idx_full: list[int | None] = [None] * MAX_RGROUPS
187
189
  for i, (r_num, _) in enumerate(sorted_r):
188
- if 1 <= r_num <= SequenceConstants.max_rgroups:
190
+ if 1 <= r_num <= MAX_RGROUPS:
189
191
  rgroup_idx_full[r_num - 1] = len(main_atoms) + i
190
192
 
191
193
  attachment_points = infer_attachment_points(mol, rgroup_idx_full)
192
- rgroup_vals: list[str | None] = [None] * SequenceConstants.max_rgroups
194
+ rgroup_vals: list[str | None] = [None] * MAX_RGROUPS
193
195
 
194
196
  if m_type == "aa" and "_R1" not in monomer_name:
195
197
  matches = {
@@ -295,10 +297,10 @@ class Molecule:
295
297
  def _split_helm_sections(
296
298
  self, helm: str
297
299
  ) -> tuple[list[str], list[str], list[str], str, str]:
298
- parts = self._dollar_outside_brackets.split(helm, 4)
300
+ parts: list[str] = self._dollar_outside_brackets.split(helm, 4)
299
301
  parts.extend([""] * (5 - len(parts)))
300
302
 
301
- polymers = (
303
+ polymers: list[str] = (
302
304
  self._pipe_outside_brackets.split(parts[0])
303
305
  if "|" in parts[0]
304
306
  else [parts[0]]
@@ -333,17 +335,18 @@ class Molecule:
333
335
 
334
336
  return result
335
337
 
336
- def _extract_chain_id(self, chain_str: str) -> tuple[str, str]:
338
+ @staticmethod
339
+ def _extract_polymer_type(chain_str: str) -> Literal["PEPTIDE", "RNA", "CHEM"]:
337
340
  """Extract chain ID and return (chain_id, polymer_type)."""
338
341
  match = re.fullmatch(r"([A-Z]+)(\d+)", chain_str)
339
342
  if not match:
340
343
  raise ValueError(f"Invalid chain format: {chain_str}")
341
344
 
342
- polymer_type = match.group(1)
345
+ polymer_type: str = match.group(1)
343
346
  if polymer_type not in {"PEPTIDE", "RNA", "CHEM"}:
344
347
  raise ValueError(f"Unsupported polymer type: {polymer_type}")
345
348
 
346
- return chain_str, polymer_type
349
+ return polymer_type
347
350
 
348
351
  def _process_monomer(
349
352
  self, monomer_name: str, chain_id: str, residue_idx: int, polymer_type: str
@@ -354,7 +357,7 @@ class Molecule:
354
357
  if monomer_name.startswith("[") and monomer_name.endswith("]")
355
358
  else monomer_name
356
359
  )
357
- if monomer_name == "":
360
+ if not monomer_name:
358
361
  raise ValueError(f"Monomer {residue_idx + 1} has no name. Check HELM.")
359
362
 
360
363
  # Check for (a,[b]) pattern
@@ -427,8 +430,8 @@ class Molecule:
427
430
  warnings.warn(f"No sequence in polymer: {chain}")
428
431
  continue
429
432
 
430
- id_chain = chain[: match.start()]
431
- chain_id, polymer_type = self._extract_chain_id(id_chain)
433
+ chain_id = chain[: match.start()]
434
+ polymer_type = self._extract_polymer_type(chain_id)
432
435
 
433
436
  if chain_id in self.chain_offset:
434
437
  raise ValueError(f"Duplicate chain ID: {chain_id}")
@@ -446,8 +449,6 @@ class Molecule:
446
449
  monomer = self._process_monomer(
447
450
  monomer_name, chain_id, residue_idx, polymer_type
448
451
  )
449
- if not monomer:
450
- continue
451
452
 
452
453
  self.monomers.append(monomer)
453
454
  self.residue_reps[chain_id].append(monomer_idx)
@@ -551,9 +552,12 @@ class Molecule:
551
552
  self.monomers.append(monomer)
552
553
  self.residue_reps[chain_id].append(monomer_idx)
553
554
  monomer_idx += 1
555
+ else:
556
+ assert_never(polymer_type)
554
557
 
558
+ @staticmethod
555
559
  def _parse_connection(
556
- self, connection_str: str
560
+ connection_str: str,
557
561
  ) -> tuple[str, int, int, str, int, int] | None:
558
562
  """Parse a single connection string."""
559
563
  parts = connection_str.split(",")
@@ -660,9 +664,9 @@ class Molecule:
660
664
 
661
665
  rgroups = monomer["m_Rgroups"]
662
666
  rgroup_idx = monomer["m_RgroupIdx"]
663
- for i in range(min(len(rgroups), SequenceConstants.max_rgroups)):
667
+ for i in range(min(len(rgroups), MAX_RGROUPS)):
664
668
  if rgroups[i] is not None:
665
- self._replace_rgroup(self._mol, 0, rgroup_idx[i], rgroups[i])
669
+ self._replace_rgroup(0, rgroup_idx[i], rgroups[i])
666
670
 
667
671
  current_offset = self._mol.GetNumAtoms()
668
672
  self.offset = [0, current_offset]
@@ -672,11 +676,9 @@ class Molecule:
672
676
 
673
677
  rgroups = monomer["m_Rgroups"]
674
678
  rgroup_idx = monomer["m_RgroupIdx"]
675
- for i in range(min(len(rgroups), SequenceConstants.max_rgroups)):
679
+ for i in range(min(len(rgroups), MAX_RGROUPS)):
676
680
  if rgroups[i] is not None:
677
- self._replace_rgroup(
678
- self._mol, current_offset, rgroup_idx[i], rgroups[i]
679
- )
681
+ self._replace_rgroup(current_offset, rgroup_idx[i], rgroups[i])
680
682
 
681
683
  atom_count = monomer["m_romol"].GetNumAtoms()
682
684
  current_offset += atom_count
@@ -695,10 +697,9 @@ class Molecule:
695
697
  absolute_atom1_idx, absolute_atom2_idx, Chem.BondType.SINGLE
696
698
  )
697
699
 
698
- def _replace_rgroup(
699
- self, rdkit_mol: Chem.RWMol, atom_offset: int, atom_idx: int, atom_type: str
700
- ) -> None:
700
+ def _replace_rgroup(self, atom_offset: int, atom_idx: int, atom_type: str) -> None:
701
701
  """Replace an R-group with the appropriate atom type."""
702
+ rdkit_mol = self.mol
702
703
  absolute_idx = atom_offset + atom_idx
703
704
 
704
705
  if atom_type == "OH":