vimol 0.1.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.
vimol/molecule.py ADDED
@@ -0,0 +1,159 @@
1
+ """The core molecular data model.
2
+
3
+ A :class:`Molecule` stores atoms as parallel numpy arrays (positions, colors,
4
+ radii) plus a bond list. Keeping everything as numpy arrays lets the renderer
5
+ operate on the whole structure vectorized.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from typing import List, Optional, Sequence, Set, Tuple
11
+
12
+ import numpy as np
13
+
14
+ from . import elements
15
+
16
+
17
+ @dataclass
18
+ class VectorField:
19
+ """A set of per-atom vectors (e.g. dipoles, forces) drawn as arrows.
20
+
21
+ ``vectors`` is atom-aligned with :attr:`Molecule.positions` -- an atom
22
+ with a near-zero vector simply has no arrow drawn. Color is the only
23
+ thing distinguishing one field from another (this layer has no notion
24
+ of what the vectors represent); magnitudes are never normalized, so
25
+ arrow length is ``|vector| * scale``.
26
+ """
27
+ vectors: np.ndarray # (N, 3) angstrom, atom-aligned
28
+ color: Tuple[float, float, float] = (0.15, 0.95, 0.85)
29
+ scale: float = 1.0 # arrow length = |vector| * scale
30
+ radius: float = 0.05 # shaft radius, angstrom
31
+ head_scale: float = 2.5 # head base radius = radius * head_scale
32
+ head_length_frac: float = 0.25 # fraction of arrow length that's the head
33
+
34
+
35
+ @dataclass
36
+ class Molecule:
37
+ """A set of atoms and the bonds between them.
38
+
39
+ Attributes
40
+ ----------
41
+ symbols: list of element symbols, length N
42
+ positions: (N, 3) float array, angstrom
43
+ bonds: list of (i, j, order) tuples, i < j
44
+ name: free-form label
45
+ """
46
+
47
+ symbols: List[str] = field(default_factory=list)
48
+ positions: np.ndarray = field(default_factory=lambda: np.zeros((0, 3), np.float64))
49
+ bonds: List[Tuple[int, int, int]] = field(default_factory=list)
50
+ name: str = ""
51
+ vector_fields: List[VectorField] = field(default_factory=list)
52
+ # Explicit bonds (e.g. drawn by option-drag) that survive re-perception
53
+ # even beyond the auto distance threshold; (i, j, order) with i < j, like
54
+ # `bonds`. `editor._reperceive` unions these into `bonds` on every rebuild.
55
+ manual_bonds: List[Tuple[int, int, int]] = field(default_factory=list)
56
+ # Indices of atoms created by editing since load (or since the last
57
+ # `editor.cleanup` accepted the relaxed geometry). `editor.py` marks every
58
+ # atom it creates; `editor.cleanup` reads this to weight its relaxation
59
+ # and clears it once the geometry is "accepted".
60
+ new_atoms: Set[int] = field(default_factory=set)
61
+
62
+ # -- construction -----------------------------------------------------
63
+ def add_atom(self, symbol: str, x: float, y: float, z: float) -> int:
64
+ idx = len(self.symbols)
65
+ self.symbols.append(elements.normalize_symbol(symbol))
66
+ self.positions = np.vstack([self.positions, [x, y, z]]) if len(self.positions) else np.array([[x, y, z]], float)
67
+ return idx
68
+
69
+ def add_bond(self, i: int, j: int, order: int = 1) -> None:
70
+ if i > j:
71
+ i, j = j, i
72
+ self.bonds.append((i, j, order))
73
+
74
+ def add_vector_field(self, vectors, color=(0.15, 0.95, 0.85), scale: float = 1.0,
75
+ radius: float = 0.05, head_scale: float = 2.5,
76
+ head_length_frac: float = 0.25) -> VectorField:
77
+ """Attach a set of per-atom vectors (e.g. dipoles) to be drawn as arrows."""
78
+ vectors = np.asarray(vectors, np.float64)
79
+ if vectors.shape != (self.n_atoms, 3):
80
+ raise ValueError(
81
+ f"vectors must have shape ({self.n_atoms}, 3), got {vectors.shape}")
82
+ vf = VectorField(vectors=vectors, color=tuple(color), scale=scale, radius=radius,
83
+ head_scale=head_scale, head_length_frac=head_length_frac)
84
+ self.vector_fields.append(vf)
85
+ return vf
86
+
87
+ # -- derived quantities ----------------------------------------------
88
+ @property
89
+ def n_atoms(self) -> int:
90
+ return len(self.symbols)
91
+
92
+ def centroid(self) -> np.ndarray:
93
+ if self.n_atoms == 0:
94
+ return np.zeros(3)
95
+ return self.positions.mean(axis=0)
96
+
97
+ def radius_of_gyration_extent(self) -> float:
98
+ """A robust size estimate: max distance of any atom from the centroid."""
99
+ if self.n_atoms == 0:
100
+ return 1.0
101
+ d = np.linalg.norm(self.positions - self.centroid(), axis=1)
102
+ return float(max(d.max(), 1e-3))
103
+
104
+ def vector_extent(self) -> float:
105
+ """Max centroid-to-arrow-tip distance across all vector fields, 0 if none.
106
+
107
+ Mirrors :meth:`radius_of_gyration_extent` so the camera's auto-fit can
108
+ pad for long arrows the same way it pads for atom radii.
109
+ """
110
+ if not self.vector_fields or self.n_atoms == 0:
111
+ return 0.0
112
+ c = self.centroid()
113
+ best = 0.0
114
+ for vf in self.vector_fields:
115
+ # A field can go stale if atoms are added after it was attached;
116
+ # skip it here exactly as build_arrow_geometry does (rather than
117
+ # letting the (N+1,3)+(N,3) broadcast raise mid-render).
118
+ if np.asarray(vf.vectors).shape != (self.n_atoms, 3):
119
+ continue
120
+ tips = self.positions + vf.vectors * vf.scale
121
+ # pad by the cone-head base radius so a fat arrowhead at the edge
122
+ # of the scene isn't clipped by auto-fit.
123
+ d = np.linalg.norm(tips - c, axis=1) + vf.radius * vf.head_scale
124
+ best = max(best, float(d.max()))
125
+ return best
126
+
127
+ def element_colors(self) -> np.ndarray:
128
+ return np.array([elements.element_color(s) for s in self.symbols], np.float64)
129
+
130
+ def covalent_radii(self) -> np.ndarray:
131
+ return np.array([elements.covalent_radius(s) for s in self.symbols], np.float64)
132
+
133
+ def vdw_radii(self) -> np.ndarray:
134
+ return np.array([elements.vdw_radius(s) for s in self.symbols], np.float64)
135
+
136
+ def atomic_numbers(self) -> np.ndarray:
137
+ return np.array([elements.symbol_to_z(s) for s in self.symbols], np.int32)
138
+
139
+ def recenter(self) -> "Molecule":
140
+ """Translate so the centroid sits at the origin (in place)."""
141
+ if self.n_atoms:
142
+ self.positions = self.positions - self.centroid()
143
+ return self
144
+
145
+ def formula(self) -> str:
146
+ """Hill-system molecular formula, e.g. 'C8H10N4O2'."""
147
+ from collections import Counter
148
+ c = Counter(self.symbols)
149
+ parts = []
150
+ for el in ("C", "H"):
151
+ if c.get(el):
152
+ parts.append(el + (str(c[el]) if c[el] > 1 else ""))
153
+ del c[el]
154
+ for el in sorted(c):
155
+ parts.append(el + (str(c[el]) if c[el] > 1 else ""))
156
+ return "".join(parts)
157
+
158
+ def __repr__(self) -> str:
159
+ return f"<Molecule {self.name!r} atoms={self.n_atoms} bonds={len(self.bonds)} formula={self.formula()}>"
@@ -0,0 +1,88 @@
1
+ """Molecular file-format parsers and a format-dispatching loader."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ from typing import List
6
+
7
+ from ..molecule import Molecule
8
+ from . import xyz as _xyz
9
+ from . import pdb as _pdb
10
+ from . import mol as _mol
11
+
12
+ _EXT_PARSERS = {
13
+ ".xyz": _xyz.parse,
14
+ ".pdb": _pdb.parse,
15
+ ".ent": _pdb.parse,
16
+ ".mol": _mol.parse,
17
+ ".sdf": _mol.parse,
18
+ ".mdl": _mol.parse,
19
+ }
20
+
21
+ SUPPORTED_EXTENSIONS = tuple(sorted(_EXT_PARSERS))
22
+
23
+
24
+ def load(path: str, fmt: str | None = None) -> Molecule:
25
+ """Load a single molecule from a file, dispatching on extension (or *fmt*)."""
26
+ mols = load_all(path, fmt=fmt)
27
+ if not mols:
28
+ raise ValueError(f"No molecules found in {path!r}")
29
+ return mols[0]
30
+
31
+
32
+ def load_all(path: str, fmt: str | None = None) -> List[Molecule]:
33
+ """Load every model/frame from a file (e.g. multi-record SDF, NMR PDB)."""
34
+ if fmt is None:
35
+ ext = os.path.splitext(path)[1].lower()
36
+ else:
37
+ ext = fmt if fmt.startswith(".") else "." + fmt.lower()
38
+ parser = _EXT_PARSERS.get(ext)
39
+ if parser is None:
40
+ raise ValueError(
41
+ f"Unsupported format {ext!r}. Supported: {', '.join(SUPPORTED_EXTENSIONS)}"
42
+ )
43
+ with open(path, "r", errors="replace") as fh:
44
+ text = fh.read()
45
+ mols = parser(text)
46
+ if isinstance(mols, Molecule):
47
+ mols = [mols]
48
+ for m in mols:
49
+ if not m.name:
50
+ m.name = os.path.splitext(os.path.basename(path))[0]
51
+ return mols
52
+
53
+
54
+ _EXT_WRITERS = {
55
+ ".xyz": _xyz.write,
56
+ }
57
+
58
+ SAVE_EXTENSIONS = tuple(sorted(_EXT_WRITERS))
59
+
60
+
61
+ def save(mol: Molecule, path: str, fmt: str | None = None) -> None:
62
+ """Write a molecule to *path*, dispatching on extension (or *fmt*).
63
+
64
+ Only formats in :data:`SAVE_EXTENSIONS` can be written (currently XYZ); a
65
+ path with any other extension raises :class:`ValueError`.
66
+ """
67
+ if fmt is None:
68
+ ext = os.path.splitext(path)[1].lower()
69
+ else:
70
+ ext = fmt if fmt.startswith(".") else "." + fmt.lower()
71
+ writer = _EXT_WRITERS.get(ext)
72
+ if writer is None:
73
+ raise ValueError(
74
+ f"Cannot write {ext!r}. Writable formats: {', '.join(SAVE_EXTENSIONS)}"
75
+ )
76
+ writer(mol, path)
77
+
78
+
79
+ def loads(text: str, fmt: str) -> Molecule:
80
+ """Parse a molecule from an in-memory string given an explicit format."""
81
+ ext = fmt if fmt.startswith(".") else "." + fmt.lower()
82
+ parser = _EXT_PARSERS.get(ext)
83
+ if parser is None:
84
+ raise ValueError(f"Unsupported format {fmt!r}")
85
+ mols = parser(text)
86
+ if isinstance(mols, Molecule):
87
+ return mols
88
+ return mols[0]
vimol/parsers/mol.py ADDED
@@ -0,0 +1,57 @@
1
+ """MDL MOL (V2000) and SDF parser (SDF = one or more MOL records)."""
2
+ from __future__ import annotations
3
+
4
+ from typing import List
5
+
6
+ import numpy as np
7
+
8
+ from ..molecule import Molecule
9
+ from .. import elements
10
+
11
+
12
+ def _parse_one(block: str) -> Molecule | None:
13
+ lines = block.splitlines()
14
+ if len(lines) < 4:
15
+ return None
16
+ name = lines[0].strip()
17
+ counts = lines[3]
18
+ try:
19
+ n_atoms = int(counts[0:3])
20
+ n_bonds = int(counts[3:6])
21
+ except ValueError:
22
+ return None
23
+ symbols: List[str] = []
24
+ coords: List[list] = []
25
+ for k in range(n_atoms):
26
+ row = lines[4 + k]
27
+ x = float(row[0:10])
28
+ y = float(row[10:20])
29
+ z = float(row[20:30])
30
+ sym = row[31:34].strip()
31
+ symbols.append(elements.normalize_symbol(sym))
32
+ coords.append([x, y, z])
33
+ bonds: List[tuple] = []
34
+ for k in range(n_bonds):
35
+ row = lines[4 + n_atoms + k]
36
+ a = int(row[0:3]) - 1
37
+ b = int(row[3:6]) - 1
38
+ order = int(row[6:9]) if row[6:9].strip() else 1
39
+ lo, hi = (a, b) if a < b else (b, a)
40
+ bonds.append((lo, hi, order))
41
+ return Molecule(
42
+ symbols=symbols,
43
+ positions=np.array(coords, float) if coords else np.zeros((0, 3)),
44
+ bonds=bonds,
45
+ name=name[:60],
46
+ )
47
+
48
+
49
+ def parse(text: str) -> List[Molecule]:
50
+ mols: List[Molecule] = []
51
+ for block in text.split("$$$$"):
52
+ if not block.strip():
53
+ continue
54
+ m = _parse_one(block)
55
+ if m is not None and m.n_atoms:
56
+ mols.append(m)
57
+ return mols
vimol/parsers/pdb.py ADDED
@@ -0,0 +1,97 @@
1
+ """Minimal but practical PDB parser.
2
+
3
+ Handles ATOM/HETATM records with fixed-column layout, CONECT bonds, TER, and
4
+ MODEL/ENDMDL multi-model files. Element is taken from columns 77-78 when
5
+ present, otherwise guessed from the atom name.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from typing import List
10
+
11
+ import numpy as np
12
+
13
+ from ..molecule import Molecule
14
+ from .. import elements
15
+
16
+
17
+ def _guess_element(atom_name: str, col_element: str) -> str:
18
+ col_element = col_element.strip()
19
+ if col_element:
20
+ return elements.normalize_symbol(col_element)
21
+ name = atom_name.strip()
22
+ # PDB atom names: element is usually the leading alphabetic chunk; names
23
+ # starting in column 13 with a digit are hydrogens/remoteness-indexed.
24
+ if not name:
25
+ return "X"
26
+ if name[0].isdigit():
27
+ name = name[1:]
28
+ # Two-letter elements only for the known set to avoid e.g. 'CA' (calcium)
29
+ # vs 'CA' (alpha carbon) confusion -> default to single letter for protein.
30
+ letters = "".join(c for c in name if c.isalpha())
31
+ if not letters:
32
+ return "X"
33
+ return elements.normalize_symbol(letters[0])
34
+
35
+
36
+ def parse(text: str) -> List[Molecule]:
37
+ models: List[Molecule] = []
38
+ cur = Molecule()
39
+ serial_to_index: dict = {}
40
+ have_atoms = False
41
+
42
+ def flush():
43
+ nonlocal cur, serial_to_index, have_atoms
44
+ if have_atoms:
45
+ models.append(cur)
46
+ cur = Molecule()
47
+ serial_to_index = {}
48
+ have_atoms = False
49
+
50
+ for line in text.splitlines():
51
+ rec = line[:6].strip()
52
+ if rec in ("ATOM", "HETATM"):
53
+ try:
54
+ serial = int(line[6:11])
55
+ except ValueError:
56
+ serial = len(cur.symbols)
57
+ atom_name = line[12:16]
58
+ x = float(line[30:38])
59
+ y = float(line[38:46])
60
+ z = float(line[46:54])
61
+ col_el = line[76:78] if len(line) >= 78 else ""
62
+ el = _guess_element(atom_name, col_el)
63
+ idx = len(cur.symbols)
64
+ cur.symbols.append(el)
65
+ serial_to_index[serial] = idx
66
+ cur.positions = (
67
+ np.vstack([cur.positions, [x, y, z]]) if len(cur.positions) else np.array([[x, y, z]], float)
68
+ )
69
+ have_atoms = True
70
+ elif rec == "CONECT":
71
+ try:
72
+ a = serial_to_index.get(int(line[6:11]))
73
+ except ValueError:
74
+ continue
75
+ if a is None:
76
+ continue
77
+ for c in (11, 16, 21, 26):
78
+ frag = line[c:c + 5].strip()
79
+ if not frag:
80
+ continue
81
+ try:
82
+ b = serial_to_index.get(int(frag))
83
+ except ValueError:
84
+ continue
85
+ if b is not None and a != b:
86
+ lo, hi = (a, b) if a < b else (b, a)
87
+ if (lo, hi, 1) not in cur.bonds:
88
+ cur.bonds.append((lo, hi, 1))
89
+ elif rec == "ENDMDL":
90
+ flush()
91
+ elif rec == "END":
92
+ flush()
93
+
94
+ flush()
95
+ if not models:
96
+ models.append(cur)
97
+ return models
vimol/parsers/xyz.py ADDED
@@ -0,0 +1,68 @@
1
+ """XYZ / extended-XYZ parser (supports multi-frame trajectories)."""
2
+ from __future__ import annotations
3
+
4
+ from typing import List
5
+
6
+ import numpy as np
7
+
8
+ from ..molecule import Molecule
9
+ from .. import elements
10
+
11
+
12
+ def parse(text: str) -> List[Molecule]:
13
+ lines = text.splitlines()
14
+ mols: List[Molecule] = []
15
+ i = 0
16
+ n = len(lines)
17
+ while i < n:
18
+ # Skip blank lines between frames.
19
+ while i < n and not lines[i].strip():
20
+ i += 1
21
+ if i >= n:
22
+ break
23
+ try:
24
+ count = int(lines[i].strip().split()[0])
25
+ except (ValueError, IndexError):
26
+ break
27
+ comment = lines[i + 1] if i + 1 < n else ""
28
+ symbols: List[str] = []
29
+ coords: List[list] = []
30
+ for k in range(count):
31
+ row = i + 2 + k
32
+ if row >= n:
33
+ break
34
+ parts = lines[row].split()
35
+ if len(parts) < 4:
36
+ continue
37
+ sym = parts[0]
38
+ # Extended XYZ may use atomic numbers instead of symbols.
39
+ if sym.isdigit():
40
+ sym = elements.SYMBOLS[int(sym)] if 0 < int(sym) < len(elements.SYMBOLS) else "X"
41
+ symbols.append(elements.normalize_symbol(sym))
42
+ coords.append([float(parts[1]), float(parts[2]), float(parts[3])])
43
+ mol = Molecule(
44
+ symbols=symbols,
45
+ positions=np.array(coords, float) if coords else np.zeros((0, 3)),
46
+ name=comment.strip()[:60],
47
+ )
48
+ mols.append(mol)
49
+ i += 2 + count
50
+ return mols
51
+
52
+
53
+ def dumps(mol: Molecule) -> str:
54
+ """Serialize a molecule to XYZ text (single frame).
55
+
56
+ The comment line carries the molecule name. Coordinates are written with
57
+ six decimals, which round-trips through :func:`parse` without drift.
58
+ """
59
+ lines = [str(mol.n_atoms), mol.name or ""]
60
+ for sym, p in zip(mol.symbols, mol.positions):
61
+ lines.append(f"{sym:<2s} {p[0]:>14.6f} {p[1]:>14.6f} {p[2]:>14.6f}")
62
+ return "\n".join(lines) + "\n"
63
+
64
+
65
+ def write(mol: Molecule, path: str) -> None:
66
+ """Write a molecule to *path* as XYZ."""
67
+ with open(path, "w") as fh:
68
+ fh.write(dumps(mol))
@@ -0,0 +1,97 @@
1
+ """Standard 18-column periodic-table layout, derived from vimol.elements.
2
+
3
+ Grid coordinates here drive the interactive element picker in the viewer:
4
+ cursor navigation, click hit-testing, and rendering all key off :data:`GRID`.
5
+ The layout follows the common convention of pulling the lanthanides and
6
+ actinides out of the main table into two extra rows below, leaving a
7
+ non-selectable placeholder cell in the gap each leaves behind.
8
+
9
+ Element placement is computed from atomic-number ranges against
10
+ :data:`vimol.elements.SYMBOLS` rather than typed out by hand, so it can't
11
+ drift out of sync with that table.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass
16
+ from typing import Dict, List, Optional, Tuple
17
+
18
+ from . import elements
19
+
20
+ N_COLS = 18
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class Cell:
25
+ row: int
26
+ col: int
27
+ symbol: Optional[str] # None for a non-selectable placeholder
28
+ z: Optional[int] = None
29
+ label: Optional[str] = None # display text; defaults to the symbol
30
+ note: Optional[str] = None # shown in the info line for a placeholder
31
+ jump_row: Optional[int] = None # row Enter/click jumps to, for a placeholder
32
+
33
+ @property
34
+ def text(self) -> str:
35
+ if self.label is not None:
36
+ return self.label
37
+ return self.symbol or ""
38
+
39
+
40
+ def _add_row(rows: List[List[Optional[Cell]]], row_idx: int, placements) -> None:
41
+ """placements: (col, z) for an element, or (col, None, label, note, jump_row)
42
+ for a non-selectable placeholder."""
43
+ cells: List[Optional[Cell]] = [None] * N_COLS
44
+ for item in placements:
45
+ if len(item) == 2:
46
+ col, z = item
47
+ cells[col] = Cell(row_idx, col, elements.SYMBOLS[z], z)
48
+ else:
49
+ col, _, label, note, jump_row = item
50
+ cells[col] = Cell(row_idx, col, None, None, label, note, jump_row)
51
+ rows.append(cells)
52
+
53
+
54
+ def _build() -> List[List[Optional[Cell]]]:
55
+ rows: List[List[Optional[Cell]]] = []
56
+ # Period 1: H(1) .. He(2)
57
+ _add_row(rows, 0, [(0, 1), (17, 2)])
58
+ # Period 2: Li(3) Be(4) ... B(5)..Ne(10)
59
+ _add_row(rows, 1, [(0, 3), (1, 4)] + [(12 + i, 5 + i) for i in range(6)])
60
+ # Period 3: Na(11) Mg(12) ... Al(13)..Ar(18)
61
+ _add_row(rows, 2, [(0, 11), (1, 12)] + [(12 + i, 13 + i) for i in range(6)])
62
+ # Period 4: K(19)..Kr(36), 18 consecutive
63
+ _add_row(rows, 3, [(c, 19 + c) for c in range(18)])
64
+ # Period 5: Rb(37)..Xe(54), 18 consecutive
65
+ _add_row(rows, 4, [(c, 37 + c) for c in range(18)])
66
+ # Period 6: Cs(55) Ba(56) [gap: La-Lu below] Hf(72)..Rn(86)
67
+ _add_row(rows, 5, [(0, 55), (1, 56), (2, None, "**", "Lanthanides — see the row below", 7)]
68
+ + [(3 + c, 72 + c) for c in range(15)])
69
+ # Period 7: Fr(87) Ra(88) [gap: Ac-Lr below] Rf(104)..Og(118)
70
+ _add_row(rows, 6, [(0, 87), (1, 88), (2, None, "**", "Actinides — see the row below", 8)]
71
+ + [(3 + c, 104 + c) for c in range(15)])
72
+ # Lanthanides: La(57)..Lu(71), indented under the gap above
73
+ _add_row(rows, 7, [(3 + c, 57 + c) for c in range(15)])
74
+ # Actinides: Ac(89)..Lr(103)
75
+ _add_row(rows, 8, [(3 + c, 89 + c) for c in range(15)])
76
+ return rows
77
+
78
+
79
+ GRID: List[List[Optional[Cell]]] = _build()
80
+
81
+ POSITIONS: Dict[str, Tuple[int, int]] = {
82
+ cell.symbol: (r, c)
83
+ for r, row in enumerate(GRID)
84
+ for c, cell in enumerate(row)
85
+ if cell is not None and cell.symbol is not None
86
+ }
87
+
88
+
89
+ def cell_at(row: int, col: int) -> Optional[Cell]:
90
+ if 0 <= row < len(GRID) and 0 <= col < N_COLS:
91
+ return GRID[row][col]
92
+ return None
93
+
94
+
95
+ def position_of(symbol: str) -> Tuple[int, int]:
96
+ """Grid (row, col) for *symbol*, defaulting to Carbon if not placed."""
97
+ return POSITIONS.get(elements.normalize_symbol(symbol), POSITIONS["C"])
vimol/py.typed ADDED
File without changes