Skip to content

Commit 03d243b

Browse files
committed
frozen RenderedSubstituentName dataclass
1 parent ade7a0c commit 03d243b

11 files changed

Lines changed: 209 additions & 63 deletions

src/openclatura/assembly_parts.py

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,15 +27,32 @@ class NameTokenBinding:
2727
right_context: str = ""
2828

2929

30-
class RenderedSubstituentName(str):
31-
"""A rendered substituent carrying construction-time boundary metadata."""
30+
@dataclass(frozen=True)
31+
class RenderedSubstituentName:
32+
"""Rendered text plus construction-time parenthesis-boundary metadata."""
33+
34+
text: str
35+
outer_parentheses_optional: bool = False
36+
37+
def __str__(self) -> str:
38+
return self.text
39+
40+
41+
RenderedSubstituentText = str | RenderedSubstituentName
42+
43+
44+
def split_rendered_substituent_name(name: RenderedSubstituentText) -> tuple[str, bool]:
45+
"""Return plain text and explicit boundary metadata for a rendered name."""
46+
47+
if isinstance(name, RenderedSubstituentName):
48+
return name.text, name.outer_parentheses_optional
49+
return name, False
50+
3251

33-
outer_parentheses_optional: bool
52+
def rendered_substituent_text(name: RenderedSubstituentText) -> str:
53+
"""Return plain text when boundary metadata is no longer needed."""
3454

35-
def __new__(cls, value: str, *, outer_parentheses_optional: bool = False):
36-
rendered = super().__new__(cls, value)
37-
rendered.outer_parentheses_optional = outer_parentheses_optional
38-
return rendered
55+
return name.text if isinstance(name, RenderedSubstituentName) else name
3956

4057

4158
@dataclass
@@ -50,6 +67,7 @@ class SubstituentItem:
5067
nested_decisions: list[dict] = field(default_factory=list)
5168
substituent_tree: dict | None = None
5269
spiro: SpiroAssembly | None = None
70+
outer_parentheses_optional: bool = False
5371

5472

5573
@dataclass

src/openclatura/assembly_prefixes.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import re
44

55
from .assembly_charge import inferred_ionic_retained_parent, single_charged_replacement_locants
6-
from .assembly_parts import AssemblyParts, RenderedSubstituentName, SubstituentItem
6+
from .assembly_parts import AssemblyParts, SubstituentItem
77
from .assembly_utils import is_fully_enclosed, needs_hyphen, parse_locant
88
from .formatting import is_complex_prefix
99
from .nomenclature import RULES
@@ -148,6 +148,7 @@ def format_substituent_prefixes(parts: AssemblyParts, spiro_subs) -> str:
148148
prefix_parts = []
149149
for name in sorted(grouped.keys(), key=substituent_sort_key):
150150
items = grouped[name]
151+
outer_parentheses_optional = all(item.outer_parentheses_optional for item in items)
151152
locs = sorted([loc for item in items for loc in item.locants], key=parse_locant)
152153
attachments_per_group = 2 if ("diyl" in name and "ylidene" not in name) else 1
153154
count_raw = len(locs) if locs else len(items)
@@ -156,7 +157,14 @@ def format_substituent_prefixes(parts: AssemblyParts, spiro_subs) -> str:
156157
mult = (multipliers.complex_(count) if is_complex else multipliers.basic(count)) if count > 1 else ""
157158
loc_str = substituent_locant_string(parts, locs, len(grouped), spiro_subs)
158159

159-
name_to_use = _omit_optional_outer_parentheses(parts, name, count, loc_str, len(grouped))
160+
name_to_use = _omit_optional_outer_parentheses(
161+
parts,
162+
name,
163+
count,
164+
loc_str,
165+
len(grouped),
166+
outer_parentheses_optional=outer_parentheses_optional,
167+
)
160168
if is_complex and not is_fully_enclosed(name_to_use):
161169
if count > 1 or loc_str:
162170
name_to_use = f"({name_to_use})"
@@ -177,6 +185,8 @@ def _omit_optional_outer_parentheses(
177185
count: int,
178186
locant_text: str,
179187
grouped_count: int,
188+
*,
189+
outer_parentheses_optional: bool,
180190
) -> str:
181191
"""Unwrap a directly rendered fragment when its parent boundary is clear."""
182192

@@ -185,8 +195,7 @@ def _omit_optional_outer_parentheses(
185195
or count != 1
186196
or locant_text
187197
or grouped_count != 1
188-
or not isinstance(name, RenderedSubstituentName)
189-
or not name.outer_parentheses_optional
198+
or not outer_parentheses_optional
190199
or not is_fully_enclosed(name)
191200
):
192201
return name

src/openclatura/component_modifiers.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Data-driven component modifiers attached after parent numbering."""
22

3-
from .assembly_parts import AssemblyParts, NameTokenBinding, SubstituentItem
3+
from .assembly_parts import AssemblyParts, NameTokenBinding, SubstituentItem, split_rendered_substituent_name
44
from .formatting import strip_outer_parentheses
55
from .group_atom_roles import ester_or_peroxy_single_oxygen
66
from .locants import parse_locant
@@ -119,10 +119,12 @@ def add_component_n_substituents(
119119
bond_ids_within(mol, {single_n, n_sub}),
120120
)
121121
if _use_hydrazone_suffix_modifier(parts, principal_key):
122+
branch_text, outer_parentheses_optional = split_rendered_substituent_name(branch_name)
122123
parts.principal_suffix_modifiers.append(
123124
SubstituentItem(
124-
branch_name,
125+
branch_text,
125126
[],
127+
outer_parentheses_optional=outer_parentheses_optional,
126128
atom_ids=branch_atoms,
127129
bond_ids=bond_ids_within(mol, branch_atoms | {single_n}),
128130
charge_atom_ids=_charged_atoms(mol, branch_atoms),

src/openclatura/component_namer.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from collections.abc import Callable
44

5-
from .assembly_parts import AssemblyParts, NameAtomBinding, SubstituentItem
5+
from .assembly_parts import AssemblyParts, NameAtomBinding, SubstituentItem, split_rendered_substituent_name
66
from .chains import find_all_carbon_paths, find_ring_systems, get_cyclic_atoms
77
from .component_group_rules import (
88
exclude_nonparent_group_atoms,
@@ -132,13 +132,15 @@ def collect_component_branch_substituents(
132132
)
133133
branch_trace = []
134134
branch_tree = None
135+
branch_name, outer_parentheses_optional = split_rendered_substituent_name(branch_name)
135136
if branch_name:
136137
branch_exclude = sub_exclude | main_set
137138
branch_atoms = subgraph_component(mol, n_idx, branch_exclude)
138139
subst_mapping.setdefault(c_idx, []).append(
139140
SubstituentItem(
140141
name=branch_name,
141142
locants=[],
143+
outer_parentheses_optional=outer_parentheses_optional,
142144
atom_ids=branch_atoms,
143145
bond_ids=bond_ids_within(mol, branch_atoms | {c_idx}),
144146
charge_atom_ids=_charged_atoms(mol, branch_atoms),
@@ -183,6 +185,7 @@ def add_component_substituents(
183185
item.emitted_tokens,
184186
substituent_tree=item.substituent_tree,
185187
spiro=item.spiro,
188+
outer_parentheses_optional=item.outer_parentheses_optional,
186189
)
187190

188191

src/openclatura/formatting.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import re
44

5+
from .assembly_parts import RenderedSubstituentName, rendered_substituent_text
56
from .namer_config import ALKYL_OXY_PREFIXES
67
from .rules import multipliers, stems
78

@@ -22,9 +23,10 @@ def is_fully_enclosed(s: str) -> bool:
2223
return depth == 0
2324

2425

25-
def strip_outer_parentheses(name: str) -> str:
26+
def strip_outer_parentheses(name: str | RenderedSubstituentName) -> str:
2627
"""Remove one balanced outer parenthesis pair from a fragment."""
2728

29+
name = rendered_substituent_text(name)
2830
if name.startswith("(") and name.endswith(")"):
2931
return name[1:-1]
3032
return name

src/openclatura/functional_prefixes.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from collections.abc import Callable
66
from dataclasses import dataclass
77

8-
from .assembly_parts import NameTokenBinding, SubstituentItem
8+
from .assembly_parts import NameTokenBinding, SubstituentItem, rendered_substituent_text
99
from .formatting import format_counted_prefixes, is_complex_prefix, oxy_prefix_from_branch, strip_outer_parentheses
1010
from .group_atom_roles import amide_nitrogen, ester_or_peroxy_single_oxygen
1111
from .molecule import Molecule
@@ -45,6 +45,7 @@ def ester_prefix_from_group(
4545
branch_name = branch_namer(mol, r_group_c, sub_exclude | {single_o}, upstream_atom=single_o)
4646
if not branch_name:
4747
return ""
48+
branch_name = rendered_substituent_text(branch_name)
4849
return f"({oxy_prefix_from_branch(branch_name)}{suffix_text})"
4950

5051

@@ -62,7 +63,10 @@ def amide_prefix_from_group(
6263
return ""
6364
if not n_subs:
6465
return base
65-
sub_names = [branch_namer(mol, x, sub_exclude | {single_n}, upstream_atom=single_n) for x in n_subs]
66+
sub_names = [
67+
rendered_substituent_text(branch_namer(mol, x, sub_exclude | {single_n}, upstream_atom=single_n))
68+
for x in n_subs
69+
]
6670
return f"({format_counted_prefixes(sub_names)}{base})"
6771

6872

@@ -87,7 +91,14 @@ def iminium_prefix_handler(context: PrefixContext, group: PerceivedGroup) -> str
8791
if not n_subs:
8892
return "iminio"
8993
sub_names = [
90-
context.branch_namer(context.mol, n_sub, context.sub_exclude | {iminium_n}, upstream_atom=iminium_n)
94+
rendered_substituent_text(
95+
context.branch_namer(
96+
context.mol,
97+
n_sub,
98+
context.sub_exclude | {iminium_n},
99+
upstream_atom=iminium_n,
100+
)
101+
)
91102
for n_sub in n_subs
92103
]
93104
return f"({format_counted_prefixes(sub_names)}iminio)"

src/openclatura/heteroatom_subgraphs.py

Lines changed: 35 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Heteroatom-starting recursive substituent naming."""
22

3+
from .assembly_parts import split_rendered_substituent_name
34
from .formatting import (
45
count_names,
56
format_counted_prefixes,
@@ -26,6 +27,25 @@
2627
from .rules import multipliers
2728

2829

30+
def _branch_name_text(
31+
branch_namer: RecursiveSubgraphNamer,
32+
mol: Molecule,
33+
start_idx: int,
34+
exclude_atoms: set[int],
35+
upstream_atom: int,
36+
) -> str:
37+
"""Render a nested branch as text, consuming its boundary metadata."""
38+
39+
rendered = branch_namer(
40+
mol,
41+
start_idx,
42+
exclude_atoms,
43+
upstream_atom=upstream_atom,
44+
)
45+
name, _ = split_rendered_substituent_name(rendered)
46+
return name
47+
48+
2949
def upstream_bond_order(mol: Molecule, start_idx: int, upstream_atom: int | None) -> int:
3050
if upstream_atom is None:
3151
return 0
@@ -152,7 +172,7 @@ def name_branch_or_none(
152172
) -> str:
153173
if branch_idx is None:
154174
return ""
155-
return strip_outer_parentheses(branch_namer(mol, branch_idx, exclude_atoms, upstream_atom))
175+
return strip_outer_parentheses(_branch_name_text(branch_namer, mol, branch_idx, exclude_atoms, upstream_atom))
156176

157177

158178
def name_carbonyl_like_fragment(
@@ -331,7 +351,7 @@ def name_oxygen_subgraph(
331351
branch = name_branch_or_none(mol, branch_idx, exclude_atoms | {start_idx, nxt}, nxt, branch_namer)
332352
return f"({branch}peroxy)" if branch else "hydroperoxy"
333353

334-
branch = branch_namer(mol, nxt, exclude_atoms | {start_idx}, start_idx)
354+
branch = _branch_name_text(branch_namer, mol, nxt, exclude_atoms | {start_idx}, start_idx)
335355
if branch:
336356
return oxy_prefix_from_branch(branch)
337357
return "hydroxy"
@@ -406,7 +426,7 @@ def name_nitrogen_subgraph(
406426
if sulfur_imide:
407427
branches.append(sulfur_imide)
408428
else:
409-
branch = branch_namer(mol, nxt, exclude_atoms | {start_idx}, start_idx)
429+
branch = _branch_name_text(branch_namer, mol, nxt, exclude_atoms | {start_idx}, start_idx)
410430
if branch:
411431
branches.append(branch)
412432

@@ -446,7 +466,7 @@ def _sulfur_imide_branch_name(
446466
cyclic_name = _cyclic_sulfur_imide_ligand_name(mol, sulfur, nitrogen, next_atoms, s_oxygens)
447467
if cyclic_name:
448468
return cyclic_name
449-
branches = [br for nxt in next_atoms if (br := branch_namer(mol, nxt, local_exclude, sulfur))]
469+
branches = [br for nxt in next_atoms if (br := _branch_name_text(branch_namer, mol, nxt, local_exclude, sulfur))]
450470
branches.extend(["oxo"] * len(s_oxygens))
451471
if not branches:
452472
return "sulfanylidene"
@@ -568,7 +588,7 @@ def name_sulfur_subgraph(
568588
br
569589
for nxt in next_atoms
570590
if (
571-
br := branch_namer(mol, nxt, exclude_atoms | {start_idx} | set(s_oxygens) | set(s_nitrogens), start_idx)
591+
br := _branch_name_text(branch_namer, mol, nxt, exclude_atoms | {start_idx} | set(s_oxygens) | set(s_nitrogens), start_idx)
572592
)
573593
]
574594
branches.extend(["oxo"] * len(s_oxygens))
@@ -589,7 +609,7 @@ def name_sulfur_subgraph(
589609
branches = [
590610
br
591611
for nxt in next_atoms
592-
if (br := branch_namer(mol, nxt, exclude_atoms | {start_idx} | set(s_oxygens), start_idx))
612+
if (br := _branch_name_text(branch_namer, mol, nxt, exclude_atoms | {start_idx} | set(s_oxygens), start_idx))
593613
]
594614
branches.extend(["oxo"] * len(s_oxygens))
595615
return format_lambda_substituent(
@@ -603,7 +623,7 @@ def name_sulfur_subgraph(
603623
branches = [
604624
br
605625
for nxt in next_atoms
606-
if (br := branch_namer(mol, nxt, exclude_atoms | {start_idx} | set(s_nitrogens), start_idx))
626+
if (br := _branch_name_text(branch_namer, mol, nxt, exclude_atoms | {start_idx} | set(s_nitrogens), start_idx))
607627
]
608628
if len(s_nitrogens) == 1:
609629
branches = ["imino", *branches]
@@ -619,7 +639,7 @@ def name_sulfur_subgraph(
619639
return "thioxo" if is_double else f"{stereo_prefix_text}{unsubstituted_prefix('S') or 'sulfanyl'}"
620640

621641
if len(next_atoms) == 1:
622-
branch = branch_namer(mol, next_atoms[0], exclude_atoms | {start_idx}, start_idx)
642+
branch = _branch_name_text(branch_namer, mol, next_atoms[0], exclude_atoms | {start_idx}, start_idx)
623643
if not is_double and mol.atoms[start_idx].charge > 0:
624644
return f"({stereo_prefix_text}{branch}sulfaniumyl)" if branch else f"{stereo_prefix_text}sulfaniumyl"
625645
if branch in SIMPLE_SULFANYL_PREFIXES:
@@ -628,7 +648,7 @@ def name_sulfur_subgraph(
628648
return format_element_substituent(stereo_prefix_text, branch, "sulfanyl", is_double=is_double)
629649
return f"{stereo_prefix_text}{'sulfanylidene' if is_double else 'sulfanyl'}"
630650

631-
branches = [br for nxt in next_atoms if (br := branch_namer(mol, nxt, exclude_atoms | {start_idx}, start_idx))]
651+
branches = [br for nxt in next_atoms if (br := _branch_name_text(branch_namer, mol, nxt, exclude_atoms | {start_idx}, start_idx))]
632652
return format_lambda_substituent(
633653
mol, start_idx, branches, stereo_prefix_text, "sulfanylidene" if is_double else "sulfanyl"
634654
)
@@ -659,7 +679,7 @@ def name_chalcogen_subgraph(
659679
branches = [
660680
br
661681
for nxt in next_atoms
662-
if (br := branch_namer(mol, nxt, exclude_atoms | {start_idx} | set(oxo_ligands), start_idx))
682+
if (br := _branch_name_text(branch_namer, mol, nxt, exclude_atoms | {start_idx} | set(oxo_ligands), start_idx))
663683
]
664684
branches.extend(["oxo"] * len(oxo_ligands))
665685
return format_lambda_substituent(
@@ -676,7 +696,7 @@ def name_chalcogen_subgraph(
676696
else f"{stereo_prefix_text}{unsubstituted_prefix(mol.atoms[start_idx].symbol) or element_suffix}"
677697
)
678698
if len(next_atoms) == 1:
679-
branch = branch_namer(mol, next_atoms[0], exclude_atoms | {start_idx}, start_idx)
699+
branch = _branch_name_text(branch_namer, mol, next_atoms[0], exclude_atoms | {start_idx}, start_idx)
680700
if branch and substituent_bonding_number(mol, start_idx) != mol.atoms[start_idx].element.standard_valence:
681701
return format_lambda_substituent(
682702
mol, start_idx, [branch], stereo_prefix_text, element_suffix + ("idene" if is_double else "")
@@ -686,7 +706,7 @@ def name_chalcogen_subgraph(
686706
if branch:
687707
return format_element_substituent(stereo_prefix_text, branch, element_suffix, is_double=is_double)
688708
return f"{stereo_prefix_text}{element_suffix + ('idene' if is_double else '')}"
689-
branches = [br for nxt in next_atoms if (br := branch_namer(mol, nxt, exclude_atoms | {start_idx}, start_idx))]
709+
branches = [br for nxt in next_atoms if (br := _branch_name_text(branch_namer, mol, nxt, exclude_atoms | {start_idx}, start_idx))]
690710
return format_lambda_substituent(
691711
mol, start_idx, branches, stereo_prefix_text, element_suffix + ("idene" if is_double else "")
692712
)
@@ -714,7 +734,7 @@ def name_phosphorus_subgraph(
714734
branches = [
715735
br
716736
for nxt in next_atoms
717-
if (br := branch_namer(mol, nxt, exclude_atoms | {start_idx} | set(p_oxygens), start_idx))
737+
if (br := _branch_name_text(branch_namer, mol, nxt, exclude_atoms | {start_idx} | set(p_oxygens), start_idx))
718738
]
719739
return f"({stereo_prefix_text}{format_counted_prefixes(branches)}{suffix})"
720740

@@ -734,7 +754,7 @@ def name_group_13_14_subgraph(
734754
next_atoms = subgraph_neighbors(mol, start_idx, exclude_atoms, upstream_atom)
735755
if not next_atoms:
736756
return suffix
737-
branches = [br for nxt in next_atoms if (br := branch_namer(mol, nxt, exclude_atoms | {start_idx}, start_idx))]
757+
branches = [br for nxt in next_atoms if (br := _branch_name_text(branch_namer, mol, nxt, exclude_atoms | {start_idx}, start_idx))]
738758
return f"({format_counted_prefixes(branches)}{suffix})"
739759

740760

@@ -749,7 +769,7 @@ def name_halogen_subgraph(
749769
next_atoms = subgraph_neighbors(mol, start_idx, exclude_atoms, upstream_atom)
750770
if not next_atoms:
751771
return HALOGEN_PREFIXES[symbol]
752-
branches = [br for nxt in next_atoms if (br := branch_namer(mol, nxt, exclude_atoms | {start_idx}, start_idx))]
772+
branches = [br for nxt in next_atoms if (br := _branch_name_text(branch_namer, mol, nxt, exclude_atoms | {start_idx}, start_idx))]
753773
valence = sum(mol.get_bond(start_idx, n).order for n in mol.get_neighbors(start_idx))
754774
return f"({format_counted_prefixes(branches)}lambda^{valence}-{HALOGEN_LAMBDA_SUFFIXES[symbol]})"
755775

0 commit comments

Comments
 (0)