chemql-python 0.1.0__tar.gz → 0.2.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.
Files changed (24) hide show
  1. {chemql_python-0.1.0/src/chemql_python.egg-info → chemql_python-0.2.1}/PKG-INFO +14 -2
  2. {chemql_python-0.1.0 → chemql_python-0.2.1}/README.md +13 -1
  3. {chemql_python-0.1.0 → chemql_python-0.2.1}/pyproject.toml +1 -1
  4. {chemql_python-0.1.0 → chemql_python-0.2.1}/src/chemql/__init__.py +184 -4
  5. {chemql_python-0.1.0 → chemql_python-0.2.1/src/chemql_python.egg-info}/PKG-INFO +14 -2
  6. {chemql_python-0.1.0 → chemql_python-0.2.1}/tests/test_public_api.py +2 -1
  7. {chemql_python-0.1.0 → chemql_python-0.2.1}/tests/test_reactions.py +39 -0
  8. {chemql_python-0.1.0 → chemql_python-0.2.1}/LICENSE +0 -0
  9. {chemql_python-0.1.0 → chemql_python-0.2.1}/setup.cfg +0 -0
  10. {chemql_python-0.1.0 → chemql_python-0.2.1}/src/chemql/__main__.py +0 -0
  11. {chemql_python-0.1.0 → chemql_python-0.2.1}/src/chemql/data/BondEnergies.json +0 -0
  12. {chemql_python-0.1.0 → chemql_python-0.2.1}/src/chemql/data/MoleculeBonds.json +0 -0
  13. {chemql_python-0.1.0 → chemql_python-0.2.1}/src/chemql/data/Molecules.json +0 -0
  14. {chemql_python-0.1.0 → chemql_python-0.2.1}/src/chemql/data/PeriodicTableJSON.json +0 -0
  15. {chemql_python-0.1.0 → chemql_python-0.2.1}/src/chemql/data/Reactions.json +0 -0
  16. {chemql_python-0.1.0 → chemql_python-0.2.1}/src/chemql/data/THIRD_PARTY_LICENSES/PeriodicTableJSON.txt +0 -0
  17. {chemql_python-0.1.0 → chemql_python-0.2.1}/src/chemql/data/THIRD_PARTY_LICENSES/RHEA.txt +0 -0
  18. {chemql_python-0.1.0 → chemql_python-0.2.1}/src/chemql/html/periodic_table.html +0 -0
  19. {chemql_python-0.1.0 → chemql_python-0.2.1}/src/chemql_python.egg-info/SOURCES.txt +0 -0
  20. {chemql_python-0.1.0 → chemql_python-0.2.1}/src/chemql_python.egg-info/dependency_links.txt +0 -0
  21. {chemql_python-0.1.0 → chemql_python-0.2.1}/src/chemql_python.egg-info/entry_points.txt +0 -0
  22. {chemql_python-0.1.0 → chemql_python-0.2.1}/src/chemql_python.egg-info/requires.txt +0 -0
  23. {chemql_python-0.1.0 → chemql_python-0.2.1}/src/chemql_python.egg-info/top_level.txt +0 -0
  24. {chemql_python-0.1.0 → chemql_python-0.2.1}/tests/test_external_molecule_lookup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chemql-python
3
- Version: 0.1.0
3
+ Version: 0.2.1
4
4
  Summary: A chemistry-focused query language and REPL
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
@@ -12,6 +12,8 @@ Dynamic: license-file
12
12
 
13
13
  # ChemQL
14
14
 
15
+ ![ChemQL logo](logo.png)
16
+
15
17
  ChemQL is a chemistry-focused query language and REPL for exploring element, molecule, and reaction datasets. It lets you search the built-in chemistry data, filter results with expressions, set reaction conditions, and combine queries with small Python snippets when needed.
16
18
 
17
19
  ## What ChemQL does
@@ -80,9 +82,11 @@ The Python package is `chemql`. Its query engine, chemistry types, and bundled
80
82
  datasets can be imported directly:
81
83
 
82
84
  ```python
83
- from chemql import execute_query_text
85
+ from chemql import balance_stoichiometry, execute_query_text
84
86
 
85
87
  result = execute_query_text('search elements name = "Hydrogen"')
88
+ reaction = balance_stoichiometry(["O2", "H2"], ["H2O"], reversible=True)
89
+ print(reaction)
86
90
  ```
87
91
 
88
92
  ## Core query syntax
@@ -159,10 +163,18 @@ add catalyst "Vanadium oxide"
159
163
  remove catalyst "Vanadium oxide"
160
164
  conditions
161
165
  react N2 3H2
166
+ balance O2 + H2 -> H2O
167
+ balance O2 + H2 <-> H2O
162
168
  ```
163
169
 
164
170
  Temperature accepts `C`, `F`, or `K`; pressure accepts `Pa`, `N/m^2`, `Nm^-2`, `bar`, or `atm`. The keywords `standard` and `room` are also accepted.
165
171
 
172
+ `balance` accepts chemical formulas on each side, separated by `+`. It returns
173
+ a `Reaction` with the smallest positive integer stoichiometric coefficients.
174
+ The arrow determines reaction direction: `->` is irreversible and `<->` is
175
+ reversible. `//` begins a comment, as it does elsewhere in ChemQL. The function
176
+ API is `balance_stoichiometry(reactants, products, reversible=None)`.
177
+
166
178
  ## Operators
167
179
 
168
180
  ChemQL supports these comparison operators:
@@ -1,5 +1,7 @@
1
1
  # ChemQL
2
2
 
3
+ ![ChemQL logo](logo.png)
4
+
3
5
  ChemQL is a chemistry-focused query language and REPL for exploring element, molecule, and reaction datasets. It lets you search the built-in chemistry data, filter results with expressions, set reaction conditions, and combine queries with small Python snippets when needed.
4
6
 
5
7
  ## What ChemQL does
@@ -68,9 +70,11 @@ The Python package is `chemql`. Its query engine, chemistry types, and bundled
68
70
  datasets can be imported directly:
69
71
 
70
72
  ```python
71
- from chemql import execute_query_text
73
+ from chemql import balance_stoichiometry, execute_query_text
72
74
 
73
75
  result = execute_query_text('search elements name = "Hydrogen"')
76
+ reaction = balance_stoichiometry(["O2", "H2"], ["H2O"], reversible=True)
77
+ print(reaction)
74
78
  ```
75
79
 
76
80
  ## Core query syntax
@@ -147,10 +151,18 @@ add catalyst "Vanadium oxide"
147
151
  remove catalyst "Vanadium oxide"
148
152
  conditions
149
153
  react N2 3H2
154
+ balance O2 + H2 -> H2O
155
+ balance O2 + H2 <-> H2O
150
156
  ```
151
157
 
152
158
  Temperature accepts `C`, `F`, or `K`; pressure accepts `Pa`, `N/m^2`, `Nm^-2`, `bar`, or `atm`. The keywords `standard` and `room` are also accepted.
153
159
 
160
+ `balance` accepts chemical formulas on each side, separated by `+`. It returns
161
+ a `Reaction` with the smallest positive integer stoichiometric coefficients.
162
+ The arrow determines reaction direction: `->` is irreversible and `<->` is
163
+ reversible. `//` begins a comment, as it does elsewhere in ChemQL. The function
164
+ API is `balance_stoichiometry(reactants, products, reversible=None)`.
165
+
154
166
  ## Operators
155
167
 
156
168
  ChemQL supports these comparison operators:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "chemql-python"
7
- version = "0.1.0"
7
+ version = "0.2.1"
8
8
  description = "A chemistry-focused query language and REPL"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -13,6 +13,9 @@ from prompt_toolkit.completion import Completer, Completion
13
13
  import urllib.parse
14
14
  import urllib.request
15
15
  import urllib.error
16
+ from fractions import Fraction
17
+ from functools import reduce
18
+ from math import gcd
16
19
 
17
20
 
18
21
  __all__ = [
@@ -22,6 +25,7 @@ __all__ = [
22
25
  "Reaction",
23
26
  "ReturnTable",
24
27
  "Unknown",
28
+ "balance_stoichiometry",
25
29
  "execute_query",
26
30
  "execute_query_text",
27
31
  ]
@@ -918,7 +922,170 @@ reactionKeys = ("id", "name", "equation", "reaction_type", "reversible", "reacta
918
922
  del elements
919
923
  del reactions
920
924
  del molecules
921
- del MOLECULES_BY_FORMULA
925
+
926
+
927
+ def _formula_counts(formula):
928
+ if not isinstance(formula, str) or not formula:
929
+ raise ValueError("Chemical formulas must be non-empty strings")
930
+ symbols = {element.symbol for element in ELEMENTS}
931
+
932
+ def parse_group(index, nested=False):
933
+ counts = {}
934
+ while index < len(formula):
935
+ char = formula[index]
936
+ if char == ")":
937
+ if not nested:
938
+ raise ValueError(f"Unmatched closing parenthesis in formula `{formula}`")
939
+ return counts, index
940
+ if char == "(":
941
+ group, index = parse_group(index + 1, True)
942
+ if index >= len(formula) or formula[index] != ")":
943
+ raise ValueError(f"Unclosed parenthesis in formula `{formula}`")
944
+ index += 1
945
+ match = re.match(r"\d+", formula[index:])
946
+ multiplier = int(match.group()) if match else 1
947
+ if multiplier <= 0:
948
+ raise ValueError(f"Invalid atom count in formula `{formula}`")
949
+ if match:
950
+ index += len(match.group())
951
+ for symbol, amount in group.items():
952
+ counts[symbol] = counts.get(symbol, 0) + amount * multiplier
953
+ continue
954
+ if not char.isupper():
955
+ raise ValueError(f"Invalid chemical formula `{formula}`")
956
+ end = index + 1
957
+ if end < len(formula) and formula[end].islower():
958
+ end += 1
959
+ symbol = formula[index:end]
960
+ if symbol not in symbols:
961
+ raise ValueError(f"Unknown element `{symbol}` in formula `{formula}`")
962
+ index = end
963
+ match = re.match(r"\d+", formula[index:])
964
+ amount = int(match.group()) if match else 1
965
+ if amount <= 0:
966
+ raise ValueError(f"Invalid atom count in formula `{formula}`")
967
+ if match:
968
+ index += len(match.group())
969
+ counts[symbol] = counts.get(symbol, 0) + amount
970
+ if nested:
971
+ raise ValueError(f"Unclosed parenthesis in formula `{formula}`")
972
+ return counts, index
973
+
974
+ counts, end = parse_group(0)
975
+ if end != len(formula) or not counts:
976
+ raise ValueError(f"Invalid chemical formula `{formula}`")
977
+ return counts
978
+
979
+
980
+ def _stoichiometric_species(side):
981
+ if isinstance(side, str):
982
+ species = [item.strip() for item in side.split("+")]
983
+ else:
984
+ species = [str(item).strip() for item in side]
985
+ if not species or any(not item for item in species):
986
+ raise ValueError("Both sides of a reaction must contain molecules")
987
+ return species
988
+
989
+
990
+ def balance_stoichiometry(reactants, products, reversible=None):
991
+ """Balance a reaction and return it as a :class:`Reaction`."""
992
+ reactant_formulas = _stoichiometric_species(reactants)
993
+ product_formulas = _stoichiometric_species(products)
994
+ formulas = reactant_formulas + product_formulas
995
+ formulas_by_element = [_formula_counts(formula) for formula in formulas]
996
+ elements = list(dict.fromkeys(
997
+ element for counts in formulas_by_element for element in counts
998
+ ))
999
+ matrix = [
1000
+ [
1001
+ Fraction(counts.get(element, 0) * (1 if index < len(reactant_formulas) else -1))
1002
+ for index, counts in enumerate(formulas_by_element)
1003
+ ]
1004
+ for element in elements
1005
+ ]
1006
+
1007
+ pivot_columns = []
1008
+ pivot_row = 0
1009
+ for column in range(len(formulas)):
1010
+ selected = next(
1011
+ (row for row in range(pivot_row, len(matrix)) if matrix[row][column]),
1012
+ None,
1013
+ )
1014
+ if selected is None:
1015
+ continue
1016
+ matrix[pivot_row], matrix[selected] = matrix[selected], matrix[pivot_row]
1017
+ divisor = matrix[pivot_row][column]
1018
+ matrix[pivot_row] = [value / divisor for value in matrix[pivot_row]]
1019
+ for row in range(len(matrix)):
1020
+ if row == pivot_row or not matrix[row][column]:
1021
+ continue
1022
+ factor = matrix[row][column]
1023
+ matrix[row] = [
1024
+ value - factor * pivot
1025
+ for value, pivot in zip(matrix[row], matrix[pivot_row])
1026
+ ]
1027
+ pivot_columns.append(column)
1028
+ pivot_row += 1
1029
+ if pivot_row == len(matrix):
1030
+ break
1031
+
1032
+ free_columns = [
1033
+ column for column in range(len(formulas)) if column not in pivot_columns
1034
+ ]
1035
+ if len(free_columns) != 1:
1036
+ raise ValueError("Reaction does not have a unique stoichiometric balance")
1037
+ coefficients = [Fraction(0) for _ in formulas]
1038
+ free_column = free_columns[0]
1039
+ coefficients[free_column] = Fraction(1)
1040
+ for row, column in enumerate(pivot_columns):
1041
+ coefficients[column] = -matrix[row][free_column]
1042
+ if all(value < 0 for value in coefficients):
1043
+ coefficients = [-value for value in coefficients]
1044
+ if any(value <= 0 for value in coefficients):
1045
+ raise ValueError("Reaction cannot be balanced with positive coefficients")
1046
+
1047
+ denominator = 1
1048
+ for coefficient in coefficients:
1049
+ denominator = denominator * coefficient.denominator // gcd(
1050
+ denominator, coefficient.denominator
1051
+ )
1052
+ integer_coefficients = [
1053
+ int(coefficient * denominator) for coefficient in coefficients
1054
+ ]
1055
+ common_divisor = reduce(gcd, integer_coefficients)
1056
+ integer_coefficients = [value // common_divisor for value in integer_coefficients]
1057
+ participants = [
1058
+ {
1059
+ "molecule": formula,
1060
+ "stoichiometric_coefficient": coefficient,
1061
+ }
1062
+ for formula, coefficient in zip(formulas, integer_coefficients)
1063
+ ]
1064
+ split_at = len(reactant_formulas)
1065
+ return Reaction(
1066
+ "BALANCED",
1067
+ "Balanced reaction",
1068
+ "balanced",
1069
+ reversible,
1070
+ participants[:split_at],
1071
+ participants[split_at:],
1072
+ {},
1073
+ "",
1074
+ )
1075
+
1076
+
1077
+ def _execute_balance_command(text):
1078
+ text = text.split("//", 1)[0].rstrip()
1079
+ match = re.fullmatch(
1080
+ r"\s*balance\s+(.+?)\s*(<->|->)\s*(.+?)\s*",
1081
+ text,
1082
+ re.IGNORECASE,
1083
+ )
1084
+ if not match:
1085
+ raise ValueError("Usage: balance <reactants> ->|<-> <products>")
1086
+ reactants, arrow, products = match.groups()
1087
+ reversible = arrow == "<->"
1088
+ return balance_stoichiometry(reactants, products, reversible)
922
1089
 
923
1090
 
924
1091
 
@@ -2134,6 +2301,14 @@ def execute(arguments, query_text=None, positions=None):
2134
2301
  if not arguments:
2135
2302
  return
2136
2303
 
2304
+ if arguments[0].lower() == "balance":
2305
+ try:
2306
+ return _execute_balance_command(
2307
+ query_text if query_text is not None else shlex.join(arguments)
2308
+ )
2309
+ except ValueError as exc:
2310
+ return format_error(str(exc), query_text)
2311
+
2137
2312
  if arguments[0].lower() in {"help", "?"}:
2138
2313
  return HELP
2139
2314
 
@@ -2562,11 +2737,15 @@ HELP = """\033[1;36mChemql - Python Chemistry Query Language\033[0m
2562
2737
  set catalysts
2563
2738
  conditions
2564
2739
  react N2 3H2
2740
+ balance O2 + H2 -> H2O
2741
+ balance O2 + H2 <-> H2O
2565
2742
 
2566
2743
  Temperature accepts C, F, or K. Pressure accepts Pa, N/m^2, Nm^-2, or bar.
2567
2744
  `standard` means 0 C and 100000 Pa; `room` means 25 C and 101325 Pa.
2568
2745
  Unset values appear as `Not Set` in `conditions`.
2569
2746
  Reaction matching allows 5 K temperature and 5% pressure tolerance.
2747
+ `balance` returns a Reaction with the smallest positive integer coefficients.
2748
+ The `->` arrow is irreversible; `<->` is reversible. `//` starts a comment.
2570
2749
 
2571
2750
  \033[1;33mRESULTS\033[0m
2572
2751
  \033[1mfirst\033[0m
@@ -2718,7 +2897,7 @@ class ChemqlLexer(Lexer):
2718
2897
  first_word = words[0] if words else ""
2719
2898
 
2720
2899
  # search / source / list / return / clear / exit
2721
- if first_word in {"search", "source", "list", "clear", "exit", "findel", "findmol", "findre", "set", "add", "remove", "react", "conditions", "view", "help", "dump"}:
2900
+ if first_word in {"search", "source", "list", "clear", "exit", "findel", "findmol", "findre", "set", "add", "remove", "react", "balance", "conditions", "view", "help", "dump"}:
2722
2901
  command_start = line.find(first_word)
2723
2902
  command_end = command_start + len(first_word)
2724
2903
 
@@ -2857,6 +3036,7 @@ class ChemqlLexer(Lexer):
2857
3036
  "add",
2858
3037
  "remove",
2859
3038
  "react",
3039
+ "balance",
2860
3040
  "conditions",
2861
3041
  "view",
2862
3042
  "help",
@@ -2936,7 +3116,7 @@ class ChemqlCompleter(Completer):
2936
3116
  commands = (
2937
3117
  "search", "source", "list", "clear", "return", "exit",
2938
3118
  "findel", "findmol", "findre", "set", "add", "remove",
2939
- "react", "conditions",
3119
+ "react", "balance", "conditions",
2940
3120
  )
2941
3121
 
2942
3122
  if not command_words:
@@ -3019,7 +3199,7 @@ def main():
3019
3199
  "-v",
3020
3200
  "--version",
3021
3201
  action="version",
3022
- version="0.1.0",
3202
+ version="0.2.1",
3023
3203
  )
3024
3204
 
3025
3205
  args = parser.parse_args()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chemql-python
3
- Version: 0.1.0
3
+ Version: 0.2.1
4
4
  Summary: A chemistry-focused query language and REPL
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
@@ -12,6 +12,8 @@ Dynamic: license-file
12
12
 
13
13
  # ChemQL
14
14
 
15
+ ![ChemQL logo](logo.png)
16
+
15
17
  ChemQL is a chemistry-focused query language and REPL for exploring element, molecule, and reaction datasets. It lets you search the built-in chemistry data, filter results with expressions, set reaction conditions, and combine queries with small Python snippets when needed.
16
18
 
17
19
  ## What ChemQL does
@@ -80,9 +82,11 @@ The Python package is `chemql`. Its query engine, chemistry types, and bundled
80
82
  datasets can be imported directly:
81
83
 
82
84
  ```python
83
- from chemql import execute_query_text
85
+ from chemql import balance_stoichiometry, execute_query_text
84
86
 
85
87
  result = execute_query_text('search elements name = "Hydrogen"')
88
+ reaction = balance_stoichiometry(["O2", "H2"], ["H2O"], reversible=True)
89
+ print(reaction)
86
90
  ```
87
91
 
88
92
  ## Core query syntax
@@ -159,10 +163,18 @@ add catalyst "Vanadium oxide"
159
163
  remove catalyst "Vanadium oxide"
160
164
  conditions
161
165
  react N2 3H2
166
+ balance O2 + H2 -> H2O
167
+ balance O2 + H2 <-> H2O
162
168
  ```
163
169
 
164
170
  Temperature accepts `C`, `F`, or `K`; pressure accepts `Pa`, `N/m^2`, `Nm^-2`, `bar`, or `atm`. The keywords `standard` and `room` are also accepted.
165
171
 
172
+ `balance` accepts chemical formulas on each side, separated by `+`. It returns
173
+ a `Reaction` with the smallest positive integer stoichiometric coefficients.
174
+ The arrow determines reaction direction: `->` is irreversible and `<->` is
175
+ reversible. `//` begins a comment, as it does elsewhere in ChemQL. The function
176
+ API is `balance_stoichiometry(reactants, products, reversible=None)`.
177
+
166
178
  ## Operators
167
179
 
168
180
  ChemQL supports these comparison operators:
@@ -10,6 +10,7 @@ def test_all_exports_only_public_query_api_and_result_types():
10
10
  "Reaction",
11
11
  "ReturnTable",
12
12
  "Unknown",
13
+ "balance_stoichiometry",
13
14
  "execute_query",
14
15
  "execute_query_text",
15
16
  }
@@ -24,4 +25,4 @@ def test_cli_version_option_prints_version(monkeypatch, capsys, option):
24
25
  chemql.main()
25
26
 
26
27
  assert exception.value.code == 0
27
- assert capsys.readouterr().out == "0.1.0\n"
28
+ assert capsys.readouterr().out == "0.2.1\n"
@@ -2,6 +2,45 @@ import chemql
2
2
  from prompt_toolkit.document import Document
3
3
 
4
4
 
5
+ def test_balance_stoichiometry_returns_minimal_reaction_coefficients():
6
+ reaction = chemql.balance_stoichiometry(["O2", "H2"], ["H2O"], True)
7
+
8
+ assert isinstance(reaction, chemql.Reaction)
9
+ assert [part["stoichiometric_coefficient"] for part in reaction.reactants] == [1, 2]
10
+ assert [part["stoichiometric_coefficient"] for part in reaction.products] == [2]
11
+ assert reaction.reversible is True
12
+
13
+ grouped = chemql.balance_stoichiometry(
14
+ ["Ca(OH)2", "H3PO4"],
15
+ ["Ca3(PO4)2", "H2O"],
16
+ )
17
+ assert [
18
+ part["stoichiometric_coefficient"]
19
+ for part in grouped.reactants + grouped.products
20
+ ] == [3, 2, 1, 6]
21
+
22
+
23
+ def test_balance_syntax_infers_direction_from_arrow_and_honors_comments(capsys):
24
+ chemql.process_lines(["balance O2 + H2 -> H2O //reversible"])
25
+ assert "1 O₂ + 2 H₂ → 2 H₂O" in capsys.readouterr().out
26
+
27
+ reaction = chemql.execute_query_text(
28
+ "balance O2 + H2 <-> H2O //irreversible"
29
+ )
30
+ assert isinstance(reaction, chemql.Reaction)
31
+ assert reaction.reversible is True
32
+ assert "1 O₂ + 2 H₂ ⇌ 2 H₂O" in str(reaction)
33
+
34
+
35
+ def test_balance_stoichiometry_rejects_invalid_formula():
36
+ try:
37
+ chemql.balance_stoichiometry(["Xx2"], ["H2"])
38
+ except ValueError as exc:
39
+ assert "Unknown element" in str(exc)
40
+ else:
41
+ raise AssertionError("Unknown elements must be rejected")
42
+
43
+
5
44
  def test_findre_searches_reactions_by_id_and_projects_attributes():
6
45
  result = chemql.execute([
7
46
  "findre", "RHEA:10000", "return", "id", "name", "source"
File without changes
File without changes