Repository: MolSSI/QCElemental

Issue:
Molecule representation should include charge and multiplicity
**Is your feature request related to a problem? Please describe.**
<!-- A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] -->
Currently printing a molecule to a string does not include the charge or multiplicity information

**Describe the solution you'd like**
<!--A clear and concise description of what you want to happen. -->
Instead of printing `Molecule(name="C", formula="C", hash="...")` printing `Molecule(name="C+", formula="2^C+", hash="...")` would be preferred. Singlet multiplicity could be omitted from printout

**Describe alternatives you've considered**
<!--A clear and concise description of any alternative solutions or features you've considered. -->
Include multiplicity and charge in the string representation.

**Additional context**
<!--Add any other context or screenshots about the feature request here. -->

Hints from issue comments:
@loriab: actually will be fixed by #340 

Solution patch:
diff --git a/.github/workflows/CI.yaml b/.github/workflows/CI.yaml
index f6dad23..aa949f3 100644
--- a/.github/workflows/CI.yaml
+++ b/.github/workflows/CI.yaml
@@ -72,6 +72,8 @@ jobs:
         name: Set up Python
         with:
           python-version: "3.10"
+      - name: Setup Graphviz
+        uses: ts-graphviz/setup-graphviz@v2
       - name: Install poetry
         run: pip install poetry
       # Force pydantic 1.0 by modifying poetry dep "pydantic" string with in-place sed (see above for details)
diff --git a/docs/changelog.rst b/docs/changelog.rst
index b574e7f..201fded 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -19,6 +19,29 @@ Changelog
 .. Misc.
 .. +++++
 
+- (:pr:`340`, :issue:`330`) Add molecular charge and multiplicity to Molecule repr formula,
+  so neutral singlet unchanged but radical cation has '2^formula+'.
+
+
+0.29.0 / 2024-MM-DD (Unreleased)
+--------------------------------
+
+Breaking Changes
+++++++++++++++++
+
+New Features
+++++++++++++
+
+Enhancements
+++++++++++++
+
+Bug Fixes
++++++++++
+
+Misc.
++++++
+- (:pr:`342`) Update some docs settings and requirements for newer tools.
+
 
 0.28.0 / 2024-06-21
 -------------------
diff --git a/docs/conf.py b/docs/conf.py
index d420f73..9633b9a 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -88,7 +88,7 @@ master_doc = "index"
 #
 # This is also used if you do content translation via gettext catalogs.
 # Usually you set "language" from the command line for these cases.
-language = None
+language = "en"
 
 # List of patterns, relative to source directory, that match files and
 # directories to ignore when looking for source files.
@@ -115,7 +115,7 @@ html_theme = "sphinx_rtd_theme"
 # Add any paths that contain custom static files (such as style sheets) here,
 # relative to this directory. They are copied after the builtin static files,
 # so a file named "default.css" will overwrite the builtin "default.css".
-html_static_path = ["_static"]
+html_static_path = []  # "_static"]
 
 # Custom sidebar templates, must be a dictionary that maps document names
 # to template names.
@@ -187,8 +187,8 @@ texinfo_documents = [
 # -- Extension configuration -------------------------------------------------
 
 extlinks = {
-    "issue": ("https://github.com/MolSSI/QCElemental/issues/%s", "GH#"),
-    "pr": ("https://github.com/MolSSI/QCElemental/pull/%s", "GH#"),
+    "issue": ("https://github.com/MolSSI/QCElemental/issues/%s", "GH#%s"),
+    "pr": ("https://github.com/MolSSI/QCElemental/pull/%s", "GH#%s"),
 }
 
 
@@ -200,8 +200,9 @@ intersphinx_mapping = {
     "numpy": ("https://numpy.org/doc/stable/", None),
     "scipy": ("https://docs.scipy.org/doc/scipy/", None),
     "matplotlib": ("https://matplotlib.org/stable/", None),
-    "qcengine": ("http://docs.qcarchive.molssi.org/projects/QCEngine/en/latest/", None),
-    "qcfractal": ("http://docs.qcarchive.molssi.org/projects/QCFractal/en/latest/", None),
+    "qcengine": ("https://molssi.github.io/QCEngine/", None),
+    "qcfractal": ("https://molssi.github.io/QCFractal/", None),
+    "nglview": ("https://nglviewer.org/nglview/release/v2.7.7", None),
 }
 
 # -- Options for todo extension ----------------------------------------------
diff --git a/pyproject.toml b/pyproject.toml
index 0b23eb8..6073bed 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -31,6 +31,10 @@ numpy = [
     { version = ">=1.26.1,<2.0", python = ">=3.9,<3.10" },
     { version = ">=1.26.1", python = ">=3.9,<3.13" },
 ]
+packaging = [
+    { version = ">=24.0", python = ">=3.7,<3.8" },
+    { version = ">=24.1", python = ">=3.8" },
+]
 # qcel is compatible with most any numpy, v1 or v2, but numpy v2 only works with pint >=0.24, which is only available for py >=3.10
 python = "^3.7"
 pint = [
@@ -72,6 +76,7 @@ sphinx-rtd-theme = "^1.2.0"
 autodoc-pydantic = "^1.8.0"
 sphinx-automodapi = "^0.15.0"
 sphinx-autodoc-typehints = "^1.22"
+graphviz = "^0.20.0"  # insufficient on pypi as also need `dot`. python-graphviz sufficient in conda.
 
 [tool.black]
 line-length = 120
diff --git a/qcelemental/models/basemodels.py b/qcelemental/models/basemodels.py
index 2fecef2..15504b5 100644
--- a/qcelemental/models/basemodels.py
+++ b/qcelemental/models/basemodels.py
@@ -20,6 +20,8 @@ def _repr(self) -> str:
 
 
 class ProtoModel(BaseModel):
+    """QCSchema extension of pydantic.BaseModel."""
+
     class Config:
         allow_mutation: bool = False
         extra: str = "forbid"
@@ -198,6 +200,8 @@ class ProtoModel(BaseModel):
 
 # remove when QCFractal merges `next`
 class AutodocBaseSettings(BaseSettings):
+    """Old class for pydantic docstring before autodoc-pydantic came about."""
+
     def __init_subclass__(cls) -> None:
         cls.__doc__ = AutoPydanticDocGenerator(cls, always_apply=True)
 
diff --git a/qcelemental/models/molecule.py b/qcelemental/models/molecule.py
index ac278a0..afd8877 100644
--- a/qcelemental/models/molecule.py
+++ b/qcelemental/models/molecule.py
@@ -16,6 +16,12 @@ try:
 except ImportError:  # Will also trap ModuleNotFoundError
     from pydantic import ConstrainedFloat, ConstrainedInt, Field, constr, validator
 
+try:
+    import nglview
+except ModuleNotFoundError:
+    # import is purely for forward reference for docs-build. import is not required except for Molecule.show()
+    pass
+
 # molparse imports separated b/c https://github.com/python/mypy/issues/7203
 from ..molparse.from_arrays import from_arrays
 from ..molparse.from_schema import from_schema
@@ -305,7 +311,7 @@ class Molecule(ProtoModel):
         serialize_skip_defaults = True
         repr_style = lambda self: [
             ("name", self.name),
-            ("formula", self.get_molecular_formula()),
+            ("formula", self.get_molecular_formula(chgmult=True)),
             ("hash", self.get_hash()[:7]),
         ]
         fields = {
@@ -795,7 +801,7 @@ class Molecule(ProtoModel):
         m.update(concat.encode("utf-8"))
         return m.hexdigest()
 
-    def get_molecular_formula(self, order: str = "alphabetical") -> str:
+    def get_molecular_formula(self, order: str = "alphabetical", chgmult: bool = False) -> str:
         r"""
         Returns the molecular formula for a molecule.
 
@@ -803,6 +809,8 @@ class Molecule(ProtoModel):
         ----------
         order: str, optional
             Sorting order of the formula. Valid choices are "alphabetical" and "hill".
+        chgmult
+            If not neutral singlet, return formula as {mult}^{formula}{chg}.
 
         Returns
         -------
@@ -829,11 +837,47 @@ class Molecule(ProtoModel):
         >>> hcl.get_molecular_formula()
         ClH
 
+        >>> two_pentanol_radcat = qcelemental.models.Molecule('''
+        ... 1 2
+        ... C         -4.43914        1.67538       -0.14135
+        ... C         -2.91385        1.70652       -0.10603
+        ... H         -4.82523        2.67391       -0.43607
+        ... H         -4.84330        1.41950        0.86129
+        ... H         -4.79340        0.92520       -0.88015
+        ... H         -2.59305        2.48187        0.62264
+        ... H         -2.53750        1.98573       -1.11429
+        ... C         -2.34173        0.34025        0.29616
+        ... H         -2.72306        0.06156        1.30365
+        ... C         -0.80326        0.34498        0.31454
+        ... H         -2.68994       -0.42103       -0.43686
+        ... O         -0.32958        1.26295        1.26740
+        ... H         -0.42012        0.59993       -0.70288
+        ... C         -0.26341       -1.04173        0.66218
+        ... H         -0.61130       -1.35318        1.67053
+        ... H          0.84725       -1.02539        0.65807
+        ... H         -0.60666       -1.78872       -0.08521
+        ... H         -0.13614        2.11102        0.78881
+        ... ''')
+        >>> two_pentanol_radcat.get_molecular_formula(chgmult=True)
+        2^C5H12O+
+
         """
 
         from ..molutil import molecular_formula_from_symbols
 
-        return molecular_formula_from_symbols(symbols=self.symbols, order=order)
+        formula = molecular_formula_from_symbols(symbols=self.symbols, order=order)
+
+        c, m = self.molecular_charge, self.molecular_multiplicity
+        if not chgmult or (c == 0.0 and m == 1):
+            return formula
+
+        if m > 1:
+            formula = f"{m}^{formula}"
+        if c < 0.0:
+            formula += abs(int(c)) * "-"
+        elif c > 0.0:
+            formula += int(c) * "+"
+        return formula
 
     ### Constructors
 
@@ -1059,7 +1103,11 @@ class Molecule(ProtoModel):
         return new_geometry
 
     def __repr_args__(self) -> "ReprArgs":
-        return [("name", self.name), ("formula", self.get_molecular_formula()), ("hash", self.get_hash()[:7])]
+        return [
+            ("name", self.name),
+            ("formula", self.get_molecular_formula(chgmult=True)),
+            ("hash", self.get_hash()[:7]),
+        ]
 
     def _ipython_display_(self, **kwargs) -> None:
         try:
diff --git a/qcelemental/models/procedures.py b/qcelemental/models/procedures.py
index 5aa7807..8f6daf5 100644
--- a/qcelemental/models/procedures.py
+++ b/qcelemental/models/procedures.py
@@ -1,6 +1,12 @@
 from enum import Enum
 from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
 
+try:
+    from typing import Literal
+except ImportError:
+    # remove when minimum py38
+    from typing_extensions import Literal
+
 try:
     from pydantic.v1 import Field, conlist, constr, validator
 except ImportError:  # Will also trap ModuleNotFoundError
@@ -58,7 +64,7 @@ class QCInputSpecification(ProtoModel):
     A compute description for energy, gradient, and Hessian computations used in a geometry optimization.
     """
 
-    schema_name: constr(strip_whitespace=True, regex=qcschema_input_default) = qcschema_input_default  # type: ignore
+    schema_name: Literal["qcschema_input"] = Field(qcschema_input_default)
     schema_version: int = 1
 
     driver: DriverEnum = Field(DriverEnum.gradient, description=str(DriverEnum.__doc__))
@@ -72,6 +78,8 @@ class QCInputSpecification(ProtoModel):
 
 
 class OptimizationInput(ProtoModel):
+    """QCSchema input directive for geometry optimization."""
+
     id: Optional[str] = None
     hash_index: Optional[str] = None
     schema_name: constr(  # type: ignore
@@ -96,6 +104,8 @@ class OptimizationInput(ProtoModel):
 
 
 class OptimizationResult(OptimizationInput):
+    """QCSchema results model for geometry optimization."""
+
     schema_name: constr(  # type: ignore
         strip_whitespace=True, regex=qcschema_optimization_output_default
     ) = qcschema_optimization_output_default
diff --git a/qcelemental/models/results.py b/qcelemental/models/results.py
index 301e173..0fd53f2 100644
--- a/qcelemental/models/results.py
+++ b/qcelemental/models/results.py
@@ -2,6 +2,12 @@ from enum import Enum
 from functools import partial
 from typing import TYPE_CHECKING, Any, Dict, Optional, Set, Union
 
+try:
+    from typing import Literal
+except ImportError:
+    # remove when minimum py38
+    from typing_extensions import Literal
+
 import numpy as np
 
 try:
@@ -568,7 +574,7 @@ class AtomicInput(ProtoModel):
     r"""The MolSSI Quantum Chemistry Schema"""
 
     id: Optional[str] = Field(None, description="The optional ID for the computation.")
-    schema_name: constr(strip_whitespace=True, regex="^(qc_?schema_input)$") = Field(  # type: ignore
+    schema_name: constr(strip_whitespace=True, regex=r"^(qc\_?schema_input)$") = Field(  # type: ignore
         qcschema_input_default,
         description=(
             f"The QCSchema specification this model conforms to. Explicitly fixed as {qcschema_input_default}."
@@ -609,7 +615,7 @@ class AtomicInput(ProtoModel):
 class AtomicResult(AtomicInput):
     r"""Results from a CMS program execution."""
 
-    schema_name: constr(strip_whitespace=True, regex="^(qc_?schema_output)$") = Field(  # type: ignore
+    schema_name: Literal["qcschema_output"] = Field(
         qcschema_output_default,
         description=(
             f"The QCSchema specification this model conforms to. Explicitly fixed as {qcschema_output_default}."
diff --git a/qcelemental/util/importing.py b/qcelemental/util/importing.py
index 7cad8cd..55e97d2 100644
--- a/qcelemental/util/importing.py
+++ b/qcelemental/util/importing.py
@@ -1,7 +1,10 @@
 import os
 import shutil
 import sys
-from typing import List, Union
+from typing import TYPE_CHECKING, List, Union
+
+if TYPE_CHECKING:
+    from packaging.version import Version
 
 
 def which_import(
@@ -127,20 +130,17 @@ def which(
         return ans
 
 
-def safe_version(*args, **kwargs) -> str:
+def safe_version(version) -> str:
     """
     Package resources is a very slow load
     """
-    import pkg_resources
-
-    version = pkg_resources.safe_version(*args, **kwargs)
-    return version
+    return str(parse_version(version))
 
 
-def parse_version(*args, **kwargs):
+def parse_version(version) -> "Version":
     """
     Package resources is a very slow load
     """
-    import pkg_resources
+    from packaging.version import parse
 
-    return pkg_resources.parse_version(*args, **kwargs)
+    return parse(version)


Test patch:
diff --git a/qcelemental/tests/test_importing.py b/qcelemental/tests/test_importing.py
index 2caed79..eac3e59 100644
--- a/qcelemental/tests/test_importing.py
+++ b/qcelemental/tests/test_importing.py
@@ -147,3 +147,13 @@ def test_which_f_raisemsg():
         qcel.util.which("evills", raise_error=True, raise_msg="Install `evills`.")
 
     assert str(e.value).endswith("Command 'evills' not found in envvar PATH. Install `evills`.")
+
+
+def test_parse_version():
+    v = qcel.util.parse_version("5.3.1")
+    assert str(v) == "5.3.1"
+
+
+def test_safe_version():
+    v = qcel.util.safe_version("5.3.1")
+    assert v == "5.3.1"
diff --git a/qcelemental/tests/test_molecule.py b/qcelemental/tests/test_molecule.py
index 1d7b82a..faf5bd8 100644
--- a/qcelemental/tests/test_molecule.py
+++ b/qcelemental/tests/test_molecule.py
@@ -141,6 +141,51 @@ def test_molecule_compare():
     assert water_molecule != water_molecule3
 
 
+def test_molecule_repr_chgmult():
+    wat1 = water_molecule.copy()
+    assert "formula='H2O'," in wat1.__repr__(), "charge/mult wrongly present in Molecule repr"
+
+    wat2 = water_dimer_minima.dict()
+    wat2["fragment_charges"] = [1, 0]
+    for field in ["molecular_charge", "molecular_multiplicity", "fragment_multiplicities", "validated"]:
+        wat2.pop(field)
+    wat2 = Molecule(**wat2)
+    assert "formula='2^H4O2+'," in wat2.__repr__(), "charge/mult missing from Molecule repr"
+
+    two_pentanol_radcat = Molecule.from_data(
+        """
+        1 2
+        C         -4.43914        1.67538       -0.14135
+        C         -2.91385        1.70652       -0.10603
+        H         -4.82523        2.67391       -0.43607
+        H         -4.84330        1.41950        0.86129
+        H         -4.79340        0.92520       -0.88015
+        H         -2.59305        2.48187        0.62264
+        H         -2.53750        1.98573       -1.11429
+        C         -2.34173        0.34025        0.29616
+        H         -2.72306        0.06156        1.30365
+        C         -0.80326        0.34498        0.31454
+        H         -2.68994       -0.42103       -0.43686
+        O         -0.32958        1.26295        1.26740
+        H         -0.42012        0.59993       -0.70288
+        C         -0.26341       -1.04173        0.66218
+        H         -0.61130       -1.35318        1.67053
+        H          0.84725       -1.02539        0.65807
+        H         -0.60666       -1.78872       -0.08521
+        H         -0.13614        2.11102        0.78881
+    """
+    )
+    assert "formula='2^C5H12O+'," in two_pentanol_radcat.__repr__(), "charge/mult missing from Molecule repr"
+
+    Oanion = Molecule.from_data(
+        """
+        -2 1
+        O 0 0 0
+    """
+    )
+    assert "formula='O--'," in Oanion.__repr__(), "charge/mult missing from Molecule repr"
+
+
 def test_water_minima_data():
     # Give it a name
     mol_dict = water_dimer_minima.dict()