x-formula-utils 0.1.1__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.
@@ -0,0 +1,63 @@
1
+ Metadata-Version: 2.4
2
+ Name: x-formula-utils
3
+ Version: 0.1.1
4
+ Summary: Resolve Hill formulas into canonical search keys and display-ready metadata.
5
+ Author: zyh, x
6
+ License: Proprietary
7
+ Project-URL: Homepage, https://pypi.org/project/x-formula-utils/
8
+ Keywords: chemistry,formula,Hill formula,pymatgen
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Science/Research
11
+ Classifier: License :: Other/Proprietary License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: Scientific/Engineering :: Chemistry
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ Requires-Dist: pymatgen==2025.10.7
21
+ Provides-Extra: dev
22
+ Requires-Dist: pytest>=7; extra == "dev"
23
+
24
+ # x-formula-utils
25
+
26
+ Resolve Hill formulas into canonical search keys and display-ready metadata.
27
+
28
+ ## Environment setup
29
+
30
+ Python 3.10 or newer is required. Create and activate a virtual environment before installing the package:
31
+
32
+ ```bash
33
+ python -m venv .venv
34
+
35
+ # Windows PowerShell
36
+ .venv\Scripts\Activate.ps1
37
+
38
+ # macOS/Linux
39
+ source .venv/bin/activate
40
+ ```
41
+
42
+ ### Conda
43
+
44
+ ```bash
45
+ conda create -n x-formula-utils python=3.12
46
+ conda activate x-formula-utils
47
+ ```
48
+
49
+ ## Install
50
+
51
+ ```bash
52
+ python -m pip install --upgrade pip
53
+ pip install x-formula-utils
54
+
55
+ # Upgrade to the latest release
56
+ pip install --upgrade x-formula-utils
57
+ ```
58
+
59
+ `x-formula-utils` runtime dependencies: `pymatgen`.
60
+
61
+ ## Usage
62
+
63
+ Refer to the package API documentation for available imports and examples.
@@ -0,0 +1,40 @@
1
+ # x-formula-utils
2
+
3
+ Resolve Hill formulas into canonical search keys and display-ready metadata.
4
+
5
+ ## Environment setup
6
+
7
+ Python 3.10 or newer is required. Create and activate a virtual environment before installing the package:
8
+
9
+ ```bash
10
+ python -m venv .venv
11
+
12
+ # Windows PowerShell
13
+ .venv\Scripts\Activate.ps1
14
+
15
+ # macOS/Linux
16
+ source .venv/bin/activate
17
+ ```
18
+
19
+ ### Conda
20
+
21
+ ```bash
22
+ conda create -n x-formula-utils python=3.12
23
+ conda activate x-formula-utils
24
+ ```
25
+
26
+ ## Install
27
+
28
+ ```bash
29
+ python -m pip install --upgrade pip
30
+ pip install x-formula-utils
31
+
32
+ # Upgrade to the latest release
33
+ pip install --upgrade x-formula-utils
34
+ ```
35
+
36
+ `x-formula-utils` runtime dependencies: `pymatgen`.
37
+
38
+ ## Usage
39
+
40
+ Refer to the package API documentation for available imports and examples.
@@ -0,0 +1,46 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "x-formula-utils"
7
+ version = "0.1.1"
8
+ description = "Resolve Hill formulas into canonical search keys and display-ready metadata."
9
+ readme = "README.MD"
10
+ requires-python = ">=3.10"
11
+ license = {text = "Proprietary"}
12
+ authors = [
13
+ {name = "zyh"},
14
+ {name = "x"},
15
+ ]
16
+ keywords = ["chemistry", "formula", "Hill formula", "pymatgen"]
17
+ classifiers = [
18
+ "Development Status :: 3 - Alpha",
19
+ "Intended Audience :: Science/Research",
20
+ "License :: Other/Proprietary License",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3 :: Only",
23
+ "Programming Language :: Python :: 3.10",
24
+ "Programming Language :: Python :: 3.11",
25
+ "Programming Language :: Python :: 3.12",
26
+ "Topic :: Scientific/Engineering :: Chemistry",
27
+ ]
28
+ dependencies = [
29
+ "pymatgen==2025.10.7",
30
+ ]
31
+
32
+ [project.optional-dependencies]
33
+ dev = ["pytest>=7"]
34
+
35
+ [project.urls]
36
+ Homepage = "https://pypi.org/project/x-formula-utils/"
37
+
38
+ [project.scripts]
39
+ x-formula = "x_formula_utils.cli:main"
40
+
41
+ [tool.setuptools.packages.find]
42
+ where = ["src"]
43
+
44
+ [tool.pytest.ini_options]
45
+ testpaths = ["tests"]
46
+ addopts = "-q"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,23 @@
1
+ """Public API for :mod:`x_formula_utils`.
2
+
3
+ The molecular-formula optimization logic was provided by zyh. x only
4
+ packages and exposes that logic as an installable Python distribution.
5
+ """
6
+
7
+ from .resolver import (
8
+ FormulaMetadata,
9
+ formula_to_html,
10
+ parse_formula,
11
+ resolve_formula_metadata,
12
+ resolve_hill_formula_metadata,
13
+ )
14
+
15
+ __all__ = [
16
+ "FormulaMetadata",
17
+ "formula_to_html",
18
+ "parse_formula",
19
+ "resolve_formula_metadata",
20
+ "resolve_hill_formula_metadata",
21
+ ]
22
+
23
+ __version__ = "0.1.0"
@@ -0,0 +1,30 @@
1
+ """Command-line entry point for x-formula-utils."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ from typing import Sequence
8
+
9
+ from .resolver import resolve_hill_formula_metadata
10
+
11
+
12
+ def main(argv: Sequence[str] | None = None) -> None:
13
+ """Print formula metadata as UTF-8 JSON."""
14
+
15
+ parser = argparse.ArgumentParser(
16
+ description="Resolve a Hill formula into search and display metadata."
17
+ )
18
+ parser.add_argument("hill_formula", help="For example: CuSO4 or H10CuO9S")
19
+ parser.add_argument(
20
+ "--no-overrides",
21
+ action="store_true",
22
+ help="Disable built-in display override and special-carbon rules.",
23
+ )
24
+ parser.add_argument("--pretty", action="store_true", help="Indent JSON output.")
25
+ args = parser.parse_args(argv)
26
+ result = resolve_hill_formula_metadata(
27
+ args.hill_formula,
28
+ use_overrides=not args.no_overrides,
29
+ )
30
+ print(json.dumps(result, ensure_ascii=False, indent=2 if args.pretty else None))
@@ -0,0 +1,316 @@
1
+ """Hill-formula parsing and display normalization.
2
+
3
+ Original molecular-formula optimization logic: zyh.
4
+ Packaging, public API, and distribution integration: x.
5
+
6
+ ``formula_key`` is deliberately different from Hill ordering: it sorts element
7
+ symbols alphabetically, includes every atom count (including ``1``), and is
8
+ therefore suitable for equality checks, deduplication, and exact search.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ from collections import Counter
15
+ from typing import Mapping, TypedDict
16
+
17
+
18
+ class FormulaMetadata(TypedDict):
19
+ """Stable metadata returned by :func:`resolve_hill_formula_metadata`."""
20
+
21
+ formula_key: str
22
+ formula_key_dict: dict[str, int]
23
+ human_formula: str
24
+ display_formula: str
25
+
26
+
27
+ _TOKEN_RE = re.compile(r"([A-Z][a-z]?|\d+|\(|\)|\[|\])")
28
+ _DOT_RE = re.compile(r"[.·]")
29
+ _SUBSCRIPT_TRANSLATION = str.maketrans("₀₁₂₃₄₅₆₇₈₉", "0123456789")
30
+
31
+ # Display-oriented rules from the original formula optimization code. They are
32
+ # intentionally presentation rules, not identity rules; formula_key remains the
33
+ # canonical identifier for retrieval.
34
+ _GROUP_REWRITES: tuple[tuple[str, str], ...] = (
35
+ ("H4N", "NH4"),
36
+ ("H2O4P", "H2PO4"),
37
+ ("HO4P", "HPO4"),
38
+ ("O4S", "SO4"),
39
+ ("O3S", "SO3"),
40
+ ("O3N", "NO3"),
41
+ ("O2N", "NO2"),
42
+ ("O4Cl", "ClO4"),
43
+ )
44
+
45
+ # This compact, embedded table keeps common human-facing inorganic notation
46
+ # deterministic while avoiding a runtime data-file dependency. Applications may
47
+ # extend or replace it through the ``display_overrides`` argument.
48
+ _DEFAULT_DISPLAY_OVERRIDES: Mapping[str, str] = {
49
+ "Al:1|H:3|O:3": "Al(OH)3",
50
+ "Ca:1|H:2|O:2": "Ca(OH)2",
51
+ "Ca:3|O:8|P:2": "Ca3(PO4)2",
52
+ "Cu:1|O:4|S:1": "CuSO4",
53
+ "Fe:2|O:12|S:3": "Fe2(SO4)3",
54
+ "H:4|N:2|O:3": "NH4NO3",
55
+ "H:8|N:2|O:4|S:1": "(NH4)2SO4",
56
+ "Cr:2|K:2|O:7": "K2Cr2O7",
57
+ "H:2|Mg:1|O:2": "Mg(OH)2",
58
+ "Na:2|O:4|S:1": "Na2SO4",
59
+ "Na:2|O:3|S:2": "Na2S2O3",
60
+ "Na:3|O:4|P:1": "Na3PO4",
61
+ }
62
+
63
+ # Carbon salt handling retained from the original algorithm. These are the
64
+ # cases where alphabetical element ordering is especially poor for display.
65
+ _SPECIAL_CARBON_ANIONS: Mapping[str, str] = {
66
+ "C:2|O:4": "C2O4",
67
+ "C:2|H:1|O:4": "HC2O4",
68
+ "C:6|H:5|O:7": "C6H5O7",
69
+ "C:6|H:6|O:7": "HC6H5O7",
70
+ "C:6|H:7|O:7": "H2C6H5O7",
71
+ }
72
+
73
+
74
+ def parse_formula(formula: str) -> dict[str, int]:
75
+ """Parse a formula into atom counts.
76
+
77
+ Parentheses, square brackets, hydrate separators (``.``/``·``), leading
78
+ hydrate multipliers, whitespace, and Unicode numeric subscripts are
79
+ supported. Charges and isotope labels are intentionally outside the
80
+ supported Hill-formula input format and raise :class:`ValueError`.
81
+ """
82
+
83
+ if not isinstance(formula, str):
84
+ raise TypeError("formula must be a string")
85
+
86
+ normalized = formula.translate(_SUBSCRIPT_TRANSLATION).replace(" ", "")
87
+ if not normalized:
88
+ raise ValueError("formula is empty")
89
+
90
+ result: Counter[str] = Counter()
91
+ for part in _DOT_RE.split(normalized):
92
+ if not part:
93
+ raise ValueError(f"empty hydrate fragment in formula: {formula!r}")
94
+ multiplier, fragment = _split_leading_multiplier(part)
95
+ if not fragment:
96
+ raise ValueError(f"missing formula after multiplier in: {formula!r}")
97
+ for element, count in _parse_fragment(fragment).items():
98
+ result[element] += count * multiplier
99
+
100
+ return {element: int(result[element]) for element in sorted(result)}
101
+
102
+
103
+ def _split_leading_multiplier(part: str) -> tuple[int, str]:
104
+ match = re.match(r"^(\d+)(.*)$", part)
105
+ if match is None:
106
+ return 1, part
107
+ multiplier = int(match.group(1))
108
+ if multiplier <= 0:
109
+ raise ValueError("formula multipliers must be positive")
110
+ return multiplier, match.group(2)
111
+
112
+
113
+ def _parse_fragment(fragment: str) -> Counter[str]:
114
+ tokens = _TOKEN_RE.findall(fragment)
115
+ if "".join(tokens) != fragment:
116
+ raise ValueError(f"unsupported token sequence in formula fragment: {fragment!r}")
117
+
118
+ opening = {"(": ")", "[": "]"}
119
+ stack: list[tuple[str | None, Counter[str]]] = [(None, Counter())]
120
+ index = 0
121
+ while index < len(tokens):
122
+ token = tokens[index]
123
+ if token in opening:
124
+ stack.append((opening[token], Counter()))
125
+ index += 1
126
+ continue
127
+ if token in {")", "]"}:
128
+ if len(stack) == 1 or stack[-1][0] != token:
129
+ raise ValueError(f"unmatched {token!r} in formula fragment: {fragment!r}")
130
+ _, group = stack.pop()
131
+ index += 1
132
+ multiplier = 1
133
+ if index < len(tokens) and tokens[index].isdigit():
134
+ multiplier = _positive_count(tokens[index])
135
+ index += 1
136
+ for element, count in group.items():
137
+ stack[-1][1][element] += count * multiplier
138
+ continue
139
+ if token.isdigit():
140
+ raise ValueError(f"unexpected standalone multiplier in: {fragment!r}")
141
+
142
+ count = 1
143
+ index += 1
144
+ if index < len(tokens) and tokens[index].isdigit():
145
+ count = _positive_count(tokens[index])
146
+ index += 1
147
+ stack[-1][1][token] += count
148
+
149
+ if len(stack) != 1:
150
+ raise ValueError(f"unmatched opening bracket in formula fragment: {fragment!r}")
151
+ return stack[0][1]
152
+
153
+
154
+ def _positive_count(value: str) -> int:
155
+ count = int(value)
156
+ if count <= 0:
157
+ raise ValueError("element counts must be positive")
158
+ return count
159
+
160
+
161
+ def _counts_to_formula_key(counts: Mapping[str, int]) -> str:
162
+ return "|".join(f"{element}:{int(counts[element])}" for element in sorted(counts))
163
+
164
+
165
+ def _counts_to_formula(counts: Mapping[str, int]) -> str:
166
+ pieces: list[str] = []
167
+ for element in sorted(counts):
168
+ count = int(counts[element])
169
+ if count:
170
+ pieces.append(element)
171
+ if count != 1:
172
+ pieces.append(str(count))
173
+ return "".join(pieces)
174
+
175
+
176
+ def _recover_special_carbon_salt_formula(counts: Mapping[str, int]) -> str | None:
177
+ if "C" not in counts or "O" not in counts:
178
+ return None
179
+ anion_counts = {key: value for key, value in counts.items() if key in {"C", "H", "O"}}
180
+ anion = _SPECIAL_CARBON_ANIONS.get(_counts_to_formula_key(anion_counts))
181
+ if anion is None:
182
+ return None
183
+ cations = {key: value for key, value in counts.items() if key not in {"C", "H", "O"}}
184
+ return _counts_to_formula(cations) + anion if cations else None
185
+
186
+
187
+ def _should_recover_human_formula(counts: Mapping[str, int]) -> bool:
188
+ """Follow the original guard that avoids reformating ordinary organics."""
189
+
190
+ if "C" not in counts:
191
+ return True
192
+ if _recover_special_carbon_salt_formula(counts) is not None:
193
+ return True
194
+ return _counts_to_formula_key(counts) in {"C:1|Si:1", "C:2|Ca:1"}
195
+
196
+
197
+ def _recover_with_pymatgen(formula: str) -> str:
198
+ """Return pymatgen's reduced, human-readable formula.
199
+
200
+ Importing lazily keeps basic parsing utilities usable in minimal build
201
+ environments. A normal ``pip install x-formula-utils`` installs pymatgen
202
+ automatically through the package metadata.
203
+ """
204
+
205
+ try:
206
+ from pymatgen.core import Composition
207
+ except ImportError as exc: # pragma: no cover - exercised by installation
208
+ raise RuntimeError(
209
+ "pymatgen is required to resolve display metadata. "
210
+ "Install with: pip install x-formula-utils"
211
+ ) from exc
212
+
213
+ try:
214
+ human_formula, _factor = Composition(formula).get_reduced_formula_and_factor()
215
+ except Exception as exc:
216
+ raise ValueError(f"pymatgen could not recover formula {formula!r}") from exc
217
+ return str(human_formula)
218
+
219
+
220
+ def _apply_group_rewrites(formula: str) -> str:
221
+ rewritten = formula
222
+ for source, target in _GROUP_REWRITES:
223
+ rewritten = rewritten.replace(source, target)
224
+ return rewritten
225
+
226
+
227
+ def formula_to_html(formula: str) -> str:
228
+ """Convert formula counts to HTML subscripts.
229
+
230
+ A leading count after a hydrate separator remains a coefficient, e.g.
231
+ ``CuSO4·5H2O`` becomes ``CuSO<sub>4</sub>·5H<sub>2</sub>O``.
232
+ """
233
+
234
+ if "<sub>" in formula:
235
+ return formula
236
+
237
+ def subscript_digits(text: str) -> str:
238
+ return re.sub(r"\d+", lambda match: f"<sub>{match.group()}</sub>", text)
239
+
240
+ parts = re.split(r"([.·])", formula)
241
+ output: list[str] = []
242
+ for index, part in enumerate(parts):
243
+ if part in {".", "·"}:
244
+ output.append(part)
245
+ elif index > 0 and parts[index - 1] in {".", "·"}:
246
+ match = re.match(r"^(\d+)(.*)$", part)
247
+ output.append(match.group(1) + subscript_digits(match.group(2)) if match else subscript_digits(part))
248
+ else:
249
+ output.append(subscript_digits(part))
250
+ return "".join(output)
251
+
252
+
253
+ def resolve_hill_formula_metadata(
254
+ hill_formula: str,
255
+ *,
256
+ use_overrides: bool = True,
257
+ display_overrides: Mapping[str, str] | None = None,
258
+ ) -> FormulaMetadata:
259
+ """Resolve a Hill formula into canonical search and display metadata.
260
+
261
+ Parameters
262
+ ----------
263
+ hill_formula:
264
+ Formula to parse, such as ``"CuSO4"`` or ``"H10CuO9S"``.
265
+ use_overrides:
266
+ Apply the built-in common-formula display table and carbonate rules.
267
+ display_overrides:
268
+ Optional formula-key-to-display-formula mapping. These entries take
269
+ precedence over the built-in table and allow applications to add rules
270
+ without changing package files.
271
+
272
+ Raises
273
+ ------
274
+ ValueError
275
+ If the formula is syntactically invalid or pymatgen cannot recover it.
276
+ RuntimeError
277
+ If the package dependency pymatgen is unavailable.
278
+ """
279
+
280
+ counts = parse_formula(hill_formula)
281
+ formula_key = _counts_to_formula_key(counts)
282
+
283
+ special_formula = _recover_special_carbon_salt_formula(counts) if use_overrides else None
284
+ if special_formula is not None:
285
+ human_formula = special_formula
286
+ elif _should_recover_human_formula(counts):
287
+ human_formula = _recover_with_pymatgen(hill_formula)
288
+ else:
289
+ human_formula = hill_formula
290
+
291
+ overrides: dict[str, str] = dict(_DEFAULT_DISPLAY_OVERRIDES) if use_overrides else {}
292
+ if display_overrides:
293
+ overrides.update(display_overrides)
294
+
295
+ display_plain = overrides.get(formula_key, special_formula or _apply_group_rewrites(human_formula))
296
+ return {
297
+ "formula_key": formula_key,
298
+ "formula_key_dict": dict(counts),
299
+ "human_formula": human_formula,
300
+ "display_formula": formula_to_html(display_plain),
301
+ }
302
+
303
+
304
+ def resolve_formula_metadata(
305
+ formula: str,
306
+ *,
307
+ use_overrides: bool = True,
308
+ display_overrides: Mapping[str, str] | None = None,
309
+ ) -> FormulaMetadata:
310
+ """Alias of :func:`resolve_hill_formula_metadata` for concise use."""
311
+
312
+ return resolve_hill_formula_metadata(
313
+ formula,
314
+ use_overrides=use_overrides,
315
+ display_overrides=display_overrides,
316
+ )
@@ -0,0 +1,63 @@
1
+ Metadata-Version: 2.4
2
+ Name: x-formula-utils
3
+ Version: 0.1.1
4
+ Summary: Resolve Hill formulas into canonical search keys and display-ready metadata.
5
+ Author: zyh, x
6
+ License: Proprietary
7
+ Project-URL: Homepage, https://pypi.org/project/x-formula-utils/
8
+ Keywords: chemistry,formula,Hill formula,pymatgen
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Science/Research
11
+ Classifier: License :: Other/Proprietary License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: Scientific/Engineering :: Chemistry
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ Requires-Dist: pymatgen==2025.10.7
21
+ Provides-Extra: dev
22
+ Requires-Dist: pytest>=7; extra == "dev"
23
+
24
+ # x-formula-utils
25
+
26
+ Resolve Hill formulas into canonical search keys and display-ready metadata.
27
+
28
+ ## Environment setup
29
+
30
+ Python 3.10 or newer is required. Create and activate a virtual environment before installing the package:
31
+
32
+ ```bash
33
+ python -m venv .venv
34
+
35
+ # Windows PowerShell
36
+ .venv\Scripts\Activate.ps1
37
+
38
+ # macOS/Linux
39
+ source .venv/bin/activate
40
+ ```
41
+
42
+ ### Conda
43
+
44
+ ```bash
45
+ conda create -n x-formula-utils python=3.12
46
+ conda activate x-formula-utils
47
+ ```
48
+
49
+ ## Install
50
+
51
+ ```bash
52
+ python -m pip install --upgrade pip
53
+ pip install x-formula-utils
54
+
55
+ # Upgrade to the latest release
56
+ pip install --upgrade x-formula-utils
57
+ ```
58
+
59
+ `x-formula-utils` runtime dependencies: `pymatgen`.
60
+
61
+ ## Usage
62
+
63
+ Refer to the package API documentation for available imports and examples.
@@ -0,0 +1,12 @@
1
+ README.MD
2
+ README.md
3
+ pyproject.toml
4
+ src/x_formula_utils/__init__.py
5
+ src/x_formula_utils/cli.py
6
+ src/x_formula_utils/resolver.py
7
+ src/x_formula_utils.egg-info/PKG-INFO
8
+ src/x_formula_utils.egg-info/SOURCES.txt
9
+ src/x_formula_utils.egg-info/dependency_links.txt
10
+ src/x_formula_utils.egg-info/entry_points.txt
11
+ src/x_formula_utils.egg-info/requires.txt
12
+ src/x_formula_utils.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ x-formula = x_formula_utils.cli:main
@@ -0,0 +1,4 @@
1
+ pymatgen==2025.10.7
2
+
3
+ [dev]
4
+ pytest>=7
@@ -0,0 +1 @@
1
+ x_formula_utils