diff --git a/docs/changelog.rst b/docs/changelog.rst index 3da28c88..1148f544 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -26,13 +26,18 @@ Changelog Breaking Changes ++++++++++++++++ * The very old model names `ResultInput`, `Result`, `ResultProperties`, `Optimization` deprecated in 2019 are now only available through `qcelelemental.models.v1` +* ``models.v2`` do not support AutoDoc. The AutoDoc routines have been left at pydantic v1 syntax. Use autodoc-pydantic for Sphinx instead. New Features ++++++++++++ * Downstream code should ``from qcelemental.models.v1 import Molecule, AtomicResult`` etc. to assure medium-term availability of existing models. +* New pydantic v2 models available as ``from qcelemental.models.v2 import Molecule, AtomicResult`` etc. Enhancements ++++++++++++ +* The ``models.v2`` have had their `schema_version` bumped for ``BasisSet``, ``AtomicInput``, ``OptimizationInput`` (implicit for ``AtomicResult`` and ``OptimizationResult``), ``TorsionDriveInput`` , and ``TorsionDriveResult``. +* The ``models.v2`` ``AtomicResultProperties`` has been given a ``schema_name`` and ``schema_version`` (2) for the first time. +* Note that ``models.v2`` ``QCInputSpecification`` and ``OptimizationSpecification`` have *not* had schema_version bumped. Bug Fixes +++++++++ diff --git a/qcelemental/models/__init__.py b/qcelemental/models/__init__.py index e7e2ac4b..829a3482 100644 --- a/qcelemental/models/__init__.py +++ b/qcelemental/models/__init__.py @@ -1 +1,10 @@ +try: + import pydantic +except ImportError: # pragma: no cover + raise ImportError( + "Python module pydantic not found. Solve by installing it: " + "`conda install pydantic -c conda-forge` or `pip install pydantic`" + ) + +from . import v1, v2 from .v1 import * diff --git a/qcelemental/models/v1/__init__.py b/qcelemental/models/v1/__init__.py index c17f2cdc..e61f458b 100644 --- a/qcelemental/models/v1/__init__.py +++ b/qcelemental/models/v1/__init__.py @@ -1,11 +1,3 @@ -try: - import pydantic -except ImportError: # pragma: no cover - raise ImportError( - "Python module pydantic not found. Solve by installing it: " - "`conda install pydantic -c conda-forge` or `pip install pydantic`" - ) - from . import types from .align import AlignmentMill from .basemodels import AutodocBaseSettings # remove when QCFractal merges `next` diff --git a/qcelemental/models/v1/align.py b/qcelemental/models/v1/align.py index ca09504f..2a6c0a23 100644 --- a/qcelemental/models/v1/align.py +++ b/qcelemental/models/v1/align.py @@ -1,11 +1,7 @@ from typing import Optional import numpy as np - -try: - from pydantic.v1 import Field, validator -except ImportError: # Will also trap ModuleNotFoundError - from pydantic import Field, validator +from pydantic.v1 import Field, validator from ...util import blockwise_contract, blockwise_expand from .basemodels import ProtoModel diff --git a/qcelemental/models/v1/basemodels.py b/qcelemental/models/v1/basemodels.py index 2fecef26..229b1588 100644 --- a/qcelemental/models/v1/basemodels.py +++ b/qcelemental/models/v1/basemodels.py @@ -3,13 +3,8 @@ from typing import Any, Dict, Optional, Set, Union import numpy as np - -try: - from pydantic.v1 import BaseSettings # remove when QCFractal merges `next` - from pydantic.v1 import BaseModel -except ImportError: # Will also trap ModuleNotFoundError - from pydantic import BaseSettings # remove when QCFractal merges `next` - from pydantic import BaseModel +from pydantic.v1 import BaseSettings # remove when QCFractal merges `next` +from pydantic.v1 import BaseModel from qcelemental.util import deserialize, serialize from qcelemental.util.autodocs import AutoPydanticDocGenerator # remove when QCFractal merges `next` diff --git a/qcelemental/models/v1/basis.py b/qcelemental/models/v1/basis.py index 2a4b2c88..c7d1c4b8 100644 --- a/qcelemental/models/v1/basis.py +++ b/qcelemental/models/v1/basis.py @@ -1,10 +1,7 @@ from enum import Enum from typing import Dict, List, Optional -try: - from pydantic.v1 import ConstrainedInt, Field, constr, validator -except ImportError: # Will also trap ModuleNotFoundError - from pydantic import ConstrainedInt, Field, constr, validator +from pydantic.v1 import ConstrainedInt, Field, constr, validator from ...exceptions import ValidationError from .basemodels import ProtoModel, qcschema_draft diff --git a/qcelemental/models/v1/common_models.py b/qcelemental/models/v1/common_models.py index f848449d..7f822798 100644 --- a/qcelemental/models/v1/common_models.py +++ b/qcelemental/models/v1/common_models.py @@ -2,20 +2,13 @@ from typing import TYPE_CHECKING, Any, Dict, Optional, Union import numpy as np - -try: - from pydantic.v1 import Field -except ImportError: # Will also trap ModuleNotFoundError - from pydantic import Field +from pydantic.v1 import Field from .basemodels import ProtoModel, qcschema_draft from .basis import BasisSet if TYPE_CHECKING: - try: - from pydantic.v1.typing import ReprArgs - except ImportError: # Will also trap ModuleNotFoundError - from pydantic.typing import ReprArgs + from pydantic.v1.typing import ReprArgs # Encoders, to be deprecated diff --git a/qcelemental/models/v1/molecule.py b/qcelemental/models/v1/molecule.py index d2261f63..e533b832 100644 --- a/qcelemental/models/v1/molecule.py +++ b/qcelemental/models/v1/molecule.py @@ -10,11 +10,7 @@ from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Tuple, Union, cast import numpy as np - -try: - from pydantic.v1 import ConstrainedFloat, ConstrainedInt, Field, constr, validator -except ImportError: # Will also trap ModuleNotFoundError - from pydantic import ConstrainedFloat, ConstrainedInt, Field, constr, validator +from pydantic.v1 import ConstrainedFloat, ConstrainedInt, Field, constr, validator # molparse imports separated b/c https://github.com/python/mypy/issues/7203 from ...molparse.from_arrays import from_arrays @@ -31,10 +27,7 @@ from .types import Array if TYPE_CHECKING: - try: - from pydantic.v1.typing import ReprArgs - except ImportError: # Will also trap ModuleNotFoundError - from pydantic.typing import ReprArgs + from pydantic.v1.typing import ReprArgs # Rounding quantities for hashing GEOMETRY_NOISE = 8 diff --git a/qcelemental/models/v1/procedures.py b/qcelemental/models/v1/procedures.py index 90f3c7cf..5a0ce95b 100644 --- a/qcelemental/models/v1/procedures.py +++ b/qcelemental/models/v1/procedures.py @@ -1,10 +1,7 @@ from enum import Enum from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple -try: - from pydantic.v1 import Field, conlist, constr, validator -except ImportError: # Will also trap ModuleNotFoundError - from pydantic import Field, conlist, constr, validator +from pydantic.v1 import Field, conlist, constr, validator from ...util import provenance_stamp from .basemodels import ProtoModel @@ -23,10 +20,7 @@ from .results import AtomicResult if TYPE_CHECKING: - try: - from pydantic.v1.typing import ReprArgs - except ImportError: # Will also trap ModuleNotFoundError - from pydantic.typing import ReprArgs + from pydantic.v1.typing import ReprArgs class TrajectoryProtocolEnum(str, Enum): diff --git a/qcelemental/models/v1/results.py b/qcelemental/models/v1/results.py index 44140729..ede7197a 100644 --- a/qcelemental/models/v1/results.py +++ b/qcelemental/models/v1/results.py @@ -3,11 +3,7 @@ from typing import TYPE_CHECKING, Any, Dict, Optional, Set, Union import numpy as np - -try: - from pydantic.v1 import Field, constr, validator -except ImportError: # Will also trap ModuleNotFoundError - from pydantic import Field, constr, validator +from pydantic.v1 import Field, constr, validator from ...util import provenance_stamp from .basemodels import ProtoModel, qcschema_draft @@ -17,10 +13,7 @@ from .types import Array if TYPE_CHECKING: - try: - from pydantic.v1.typing import ReprArgs - except ImportError: # Will also trap ModuleNotFoundError - from pydantic.typing import ReprArgs + from pydantic.v1.typing import ReprArgs class AtomicResultProperties(ProtoModel): diff --git a/qcelemental/models/v2/__init__.py b/qcelemental/models/v2/__init__.py new file mode 100644 index 00000000..509cda9d --- /dev/null +++ b/qcelemental/models/v2/__init__.py @@ -0,0 +1,19 @@ +from . import types +from .align import AlignmentMill +from .basemodels import ProtoModel +from .basis import BasisSet +from .common_models import ComputeError, DriverEnum, FailedOperation, Provenance +from .molecule import Molecule +from .procedures import OptimizationInput, OptimizationResult +from .results import AtomicInput, AtomicResult, AtomicResultProperties + + +def qcschema_models(): + return [ + AtomicInput, + AtomicResult, + AtomicResultProperties, + BasisSet, + Molecule, + Provenance, + ] diff --git a/qcelemental/models/v2/basemodels.py b/qcelemental/models/v2/basemodels.py index c82109f3..cd88d7a3 100644 --- a/qcelemental/models/v2/basemodels.py +++ b/qcelemental/models/v2/basemodels.py @@ -5,10 +5,8 @@ import numpy as np from pydantic import BaseModel, ConfigDict, model_serializer -from pydantic_settings import BaseSettings # remove when QCFractal merges `next` from qcelemental.util import deserialize, serialize -from qcelemental.util.autodocs import AutoPydanticDocGenerator # remove when QCFractal merges `next` def _repr(self) -> str: @@ -279,10 +277,4 @@ def _merge_config_with(cls, *args, **kwargs): return ExtendedConfigDict(**output_dict) -# remove when QCFractal merges `next` -class AutodocBaseSettings(BaseSettings): - def __init_subclass__(cls) -> None: - cls.__doc__ = AutoPydanticDocGenerator(cls, always_apply=True) - - qcschema_draft = "http://json-schema.org/draft-04/schema#" diff --git a/qcelemental/models/v2/basis.py b/qcelemental/models/v2/basis.py index ca9ad843..54ff278f 100644 --- a/qcelemental/models/v2/basis.py +++ b/qcelemental/models/v2/basis.py @@ -172,7 +172,7 @@ class BasisSet(ProtoModel): description=f"The QCSchema specification to which this model conforms. Explicitly fixed as qcschema_basis.", ) schema_version: int = Field( # type: ignore - 1, + 2, description="The version number of :attr:`~qcelemental.models.BasisSet.schema_name` " "to which this model conforms.", ) diff --git a/qcelemental/models/v2/procedures.py b/qcelemental/models/v2/procedures.py index 2b7ecb86..5761bb63 100644 --- a/qcelemental/models/v2/procedures.py +++ b/qcelemental/models/v2/procedures.py @@ -70,7 +70,7 @@ class OptimizationInput(ProtoModel): schema_name: constr( # type: ignore strip_whitespace=True, pattern=qcschema_optimization_input_default ) = qcschema_optimization_input_default - schema_version: int = 1 + schema_version: int = 2 keywords: Dict[str, Any] = Field({}, description="The optimization specific keywords to be used.") extras: Dict[str, Any] = Field({}, description="Extra fields that are not part of the schema.") @@ -205,7 +205,7 @@ class TorsionDriveInput(ProtoModel): schema_name: constr( strip_whitespace=True, pattern=qcschema_torsion_drive_input_default ) = qcschema_torsion_drive_input_default # type: ignore - schema_version: int = 1 + schema_version: int = 2 keywords: TDKeywords = Field(..., description="The torsion drive specific keywords to be used.") extras: Dict[str, Any] = Field({}, description="Extra fields that are not part of the schema.") @@ -239,7 +239,7 @@ class TorsionDriveResult(TorsionDriveInput): schema_name: constr( strip_whitespace=True, pattern=qcschema_torsion_drive_output_default ) = qcschema_torsion_drive_output_default # type: ignore - schema_version: int = 1 + schema_version: int = 2 final_energies: Dict[str, float] = Field( ..., description="The final energy at each angle of the TorsionDrive scan." @@ -261,18 +261,3 @@ class TorsionDriveResult(TorsionDriveInput): ) error: Optional[ComputeError] = Field(None, description=str(ComputeError.__doc__)) provenance: Provenance = Field(..., description=str(Provenance.__doc__)) - - -def Optimization(*args, **kwargs): - """QC Optimization Results Schema. - - .. deprecated:: 0.12 - Use :py:func:`qcelemental.models.OptimizationResult` instead. - - """ - from warnings import warn - - warn( - "Optimization has been renamed to OptimizationResult and will be removed as soon as v0.13.0", DeprecationWarning - ) - return OptimizationResult(*args, **kwargs) diff --git a/qcelemental/models/v2/results.py b/qcelemental/models/v2/results.py index ea0b6fcf..265c7a05 100644 --- a/qcelemental/models/v2/results.py +++ b/qcelemental/models/v2/results.py @@ -1,6 +1,6 @@ from enum import Enum from functools import partial -from typing import TYPE_CHECKING, Any, Dict, Optional, Set, Union +from typing import TYPE_CHECKING, Any, Dict, Literal, Optional, Set, Union import numpy as np from pydantic import Field, constr, field_validator @@ -26,6 +26,17 @@ class AtomicResultProperties(ProtoModel): * nmo: number of molecular orbitals = :attr:`~qcelemental.models.AtomicResultProperties.calcinfo_nmo` """ + schema_name: Literal["qcschema_atomicproperties"] = Field( + "qcschema_atomicproperties", + description=( + f"The QCSchema specification this model conforms to. Explicitly fixed as qcschema_atomicproperties." + ), + ) + schema_version: int = Field( + 2, + description="The version number of :attr:`~qcelemental.models.AtomicResultProperties.schema_name` to which this model conforms.", + ) + # Calcinfo calcinfo_nbasis: Optional[int] = Field(None, description="The number of basis functions for the computation.") calcinfo_nmo: Optional[int] = Field(None, description="The number of molecular orbitals for the computation.") @@ -642,7 +653,7 @@ class AtomicInput(ProtoModel): ), ) schema_version: int = Field( - 1, + 2, description="The version number of :attr:`~qcelemental.models.AtomicInput.schema_name` to which this model conforms.", ) @@ -831,69 +842,3 @@ def _native_file_protocol(cls, value, info): for rk in return_keep: ret[rk] = files.get(rk, None) return ret - - -class ResultProperties(AtomicResultProperties): - """QC Result Properties Schema. - - .. deprecated:: 0.12 - Use :py:func:`qcelemental.models.AtomicResultProperties` instead. - - """ - - def __init__(self, *args, **kwargs): - from warnings import warn - - warn( - "ResultProperties has been renamed to AtomicResultProperties and will be removed as soon as v0.13.0", - DeprecationWarning, - ) - super().__init__(*args, **kwargs) - - -class ResultProtocols(AtomicResultProtocols): - """QC Result Protocols Schema. - - .. deprecated:: 0.12 - Use :py:func:`qcelemental.models.AtomicResultProtocols` instead. - - """ - - def __init__(self, *args, **kwargs): - from warnings import warn - - warn( - "ResultProtocols has been renamed to AtomicResultProtocols and will be removed as soon as v0.13.0", - DeprecationWarning, - ) - super().__init__(*args, **kwargs) - - -class ResultInput(AtomicInput): - """QC Input Schema. - - .. deprecated:: 0.12 - Use :py:func:`qcelemental.models.AtomicInput` instead. - - """ - - def __init__(self, *args, **kwargs): - from warnings import warn - - warn("ResultInput has been renamed to AtomicInput and will be removed as soon as v0.13.0", DeprecationWarning) - super().__init__(*args, **kwargs) - - -class Result(AtomicResult): - """QC Result Schema. - - .. deprecated:: 0.12 - Use :py:func:`qcelemental.models.AtomicResult` instead. - - """ - - def __init__(self, *args, **kwargs): - from warnings import warn - - warn("Result has been renamed to AtomicResult and will be removed as soon as v0.13.0", DeprecationWarning) - super().__init__(*args, **kwargs) diff --git a/qcelemental/tests/addons.py b/qcelemental/tests/addons.py index a590faf4..f54151fc 100644 --- a/qcelemental/tests/addons.py +++ b/qcelemental/tests/addons.py @@ -62,12 +62,13 @@ def xfail_on_pubchem_busy(): def drop_qcsk(instance, tnm: str, schema_name: str = None): - if isinstance(instance, qcelemental.models.ProtoModel) and schema_name is None: + is_model = isinstance(instance, (qcelemental.models.v1.ProtoModel, qcelemental.models.v2.ProtoModel)) + if is_model and schema_name is None: schema_name = type(instance).__name__ drop = (_data_path / schema_name / tnm).with_suffix(".json") with open(drop, "w") as fp: - if isinstance(instance, qcelemental.models.ProtoModel): + if is_model: # fp.write(instance.json(exclude_unset=True, exclude_none=True)) # works but file is one-line instance = json.loads(instance.json(exclude_unset=True, exclude_none=True)) elif isinstance(instance, dict): @@ -83,7 +84,7 @@ def Molecule(request): if request.param == "v1": return qcelemental.models.v1.Molecule elif request.param == "v2": - return qcelemental.models.v1.Molecule # TODO v2 + return qcelemental.models.v2.Molecule else: return qcelemental.models.Molecule @@ -93,6 +94,6 @@ def schema_versions(request): if request.param == "v1": return qcelemental.models.v1 elif request.param == "v2": - return qcelemental.models.v1 # TODO v2 + return qcelemental.models.v2 else: return qcelemental.models diff --git a/qcelemental/tests/test_model_results.py b/qcelemental/tests/test_model_results.py index f089917d..395d9ce2 100644 --- a/qcelemental/tests/test_model_results.py +++ b/qcelemental/tests/test_model_results.py @@ -1,4 +1,5 @@ import numpy as np +import pydantic import pytest import qcelemental as qcel @@ -549,40 +550,63 @@ def test_result_derivatives_array(request, schema_versions): @pytest.mark.parametrize( - "smodel", ["molecule", "atomicresultproperties", "atomicinput", "atomicresult", "optimizationresult"] + "smodel", ["molecule", "atomicresultproperties", "atomicinput", "atomicresult", "optimizationresult", "basisset"] ) -def test_model_dictable(result_data_fixture, optimization_data_fixture, smodel, schema_versions): - Molecule = schema_versions.Molecule - AtomicResultProperties = schema_versions.AtomicResultProperties - AtomicInput = schema_versions.AtomicInput - AtomicResult = schema_versions.AtomicResult - OptimizationResult = schema_versions.OptimizationResult +def test_model_dictable(result_data_fixture, optimization_data_fixture, smodel, schema_versions, request): + qcsk_ver = "v2" if ("v2" in request.node.name) else "v1" if smodel == "molecule": - model = Molecule + model = schema_versions.Molecule data = result_data_fixture["molecule"].dict() + sver = (2, 2) # TODO , 3) elif smodel == "atomicresultproperties": - model = AtomicResultProperties + model = schema_versions.AtomicResultProperties data = {"scf_one_electron_energy": "-5.0", "scf_dipole_moment": [1, 2, 3], "ccsd_dipole_moment": None} + sver = (None, 2) elif smodel == "atomicinput": - model = AtomicInput + model = schema_versions.AtomicInput data = {k: result_data_fixture[k] for k in ["molecule", "model", "driver"]} + sver = (1, 2) elif smodel == "atomicresult": - model = AtomicResult + model = schema_versions.AtomicResult data = result_data_fixture + sver = (1, 2) elif smodel == "optimizationresult": - model = OptimizationResult + model = schema_versions.OptimizationResult data = optimization_data_fixture + sver = (1, 2) + + elif smodel == "basisset": + model = schema_versions.basis.BasisSet + data = {"name": "custom", "center_data": center_data, "atom_map": ["bs_sto3g_o", "bs_sto3g_h", "bs_sto3g_h"]} + sver = (1, 2) + + def ver_tests(qcsk_ver): + if qcsk_ver == "v1": + if sver[0] is not None: + assert instance.schema_version == sver[0] + assert isinstance(instance, pydantic.v1.BaseModel) + elif qcsk_ver == "v2": + if sver[1] is not None: + assert instance.schema_version == sver[1] + assert isinstance(instance, pydantic.BaseModel) instance = model(**data) - assert model(**instance.dict()) + ver_tests(qcsk_ver) + instance = model(**instance.dict()) + assert instance + ver_tests(qcsk_ver) + +def test_result_model_deprecations(result_data_fixture, optimization_data_fixture, request): + if "v1" not in request.node.name: + # schema_versions coming from fixtures despite not being explicitly present + pytest.skip("Deprecations from 2019 only available from qcel.models.v1") -def test_result_model_deprecations(result_data_fixture, optimization_data_fixture): with pytest.warns(DeprecationWarning): qcel.models.v1.ResultProperties(scf_one_electron_energy="-5.0") diff --git a/qcelemental/tests/test_molutil.py b/qcelemental/tests/test_molutil.py index b5b0b4f7..3cd78f5b 100644 --- a/qcelemental/tests/test_molutil.py +++ b/qcelemental/tests/test_molutil.py @@ -2,12 +2,7 @@ import pprint import numpy as np - -try: - import pydantic.v1 as pydantic -except ImportError: # Will also trap ModuleNotFoundError - import pydantic - +import pydantic import pytest import qcelemental as qcel @@ -166,7 +161,7 @@ def test_error_nat_b787(Molecule): def test_mill_shift_error(schema_versions): AlignmentMill = schema_versions.AlignmentMill - with pytest.raises(pydantic.ValidationError) as e: + with pytest.raises((pydantic.v1.ValidationError, pydantic.ValidationError)) as e: AlignmentMill(shift=[0, 1]) assert "Shift must be castable to shape" in str(e.value) @@ -175,7 +170,7 @@ def test_mill_shift_error(schema_versions): def test_mill_rot_error(schema_versions): AlignmentMill = schema_versions.AlignmentMill - with pytest.raises(pydantic.ValidationError) as e: + with pytest.raises((pydantic.v1.ValidationError, pydantic.ValidationError)) as e: AlignmentMill(rotation=[0, 1, 3]) assert "Rotation must be castable to shape" in str(e.value) diff --git a/qcelemental/util/autodocs.py b/qcelemental/util/autodocs.py index b6b64232..ac57b50d 100644 --- a/qcelemental/util/autodocs.py +++ b/qcelemental/util/autodocs.py @@ -3,10 +3,11 @@ from textwrap import dedent, indent from typing import Any -try: - from pydantic.v1 import BaseModel, BaseSettings -except ImportError: # Will also trap ModuleNotFoundError - from pydantic import BaseModel, BaseSettings +from pydantic.v1 import BaseModel, BaseSettings + +# home-grown AutoDoc has been replaced autodoc-pydantic for Sphinx in QCElemental and QCEngine. +# pre-next QCFractal was the last known user. Leaving this in pydantic v1 for now until removed entirely. + __all__ = ["auto_gen_docs_on_demand", "get_base_docs", "AutoPydanticDocGenerator"]