Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,27 @@ Changelog

:docs:`dev` for latest.

.. _`sec:cl070`:

v0.7.0 / 2026-03-27
===================

:docs:`v0.7.0` for current. :docs:`v0.5.2` for QCSchema v1.

Breaking Changes
----------------
* :pr:`47` Models -- All models now serialize to containing ``schema_name`` field.
``ManyBodyProperties`` also includes schema_name, but not all the Nones.

Enhancements
------------
* :pr:`47` Schema -- Compatibility with https://github.com/MolSSI/QCElemental/pull/393
(QCElemental 0.50.0rc4) that replaces early QCElemental routines with native
Pydantic serialization.
* :pr:`47` Models -- Add ``QCManyBody.models.v1.ManyBodyResultProperties.convert_v()``
and ``QCManyBody.models.v2.ManyBodyProperties.convert_v()`` to interconvert.


.. _`sec:cl061`:

v0.6.1 / 2026-03-11
Expand Down
5 changes: 2 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,13 @@ dependencies = [
"numpy",
"pydantic >=2.11; python_version <'3.14'",
"pydantic >=2.12; python_version >='3.14'",
"qcelemental==0.50.0rc3",
#"qcelemental>=0.50.0rc3,<0.70.0",
"qcelemental>=0.50.0rc3,<0.70.0",
]

[project.optional-dependencies]
standard = [
# needed for continuous hi-lvl interface: `mbres = ManyBodyComputer.from_manybodyinput(mbin)`
"qcengine>=0.50.0rc1,<0.70.0", # TODO rc2
"qcengine>=0.50.0rc2,<0.70.0",
]
tests = [
"pytest",
Expand Down
22 changes: 22 additions & 0 deletions qcmanybody/models/v1/manybody_output_pydv1.py
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,28 @@ def _qcvars_translator(cls, reverse: bool = False) -> Dict[str, str]:
return {v: k for k, v in qcvars_to_mbprop.items()}


def _mbprop_convert_v(
self, target_version: int, /
) -> Union["qcmanybody.models.v1.ManyBodyResultProperties", "qcmanybody.models.v2.ManyBodyProperties"]:
"""Convert to instance of particular QCSchema version."""
from qcelemental.models.v1.basemodels import check_convertible_version

import qcmanybody as qcmb

if check_convertible_version(target_version, error="ManyBodyResultProperties") == "self":
return self

dself = self.model_dump()
if target_version == 2:
self_vN = qcmb.models.v2.ManyBodyProperties(**dself)
else:
assert False, target_version

return self_vN


ManyBodyResultProperties.to_qcvariables = classmethod(_qcvars_translator)
ManyBodyResultProperties.convert_v = _mbprop_convert_v


# ==== Results ================================================================
Expand Down Expand Up @@ -460,6 +481,7 @@ def convert_v(

dself["molecule"] = self.input_data.molecule.convert_v(target_version)

dself["properties"] = self.properties.convert_v(target_version)
dself["cluster_properties"] = dself.pop("component_properties")
dself["cluster_results"] = {
k: atres.convert_v(target_version) for k, atres in self.component_results.items()
Expand Down
72 changes: 55 additions & 17 deletions qcmanybody/models/v2/many_body.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,24 @@
except ImportError:
from pydantic import FieldValidationInfo as ValidationInfo

from pydantic import Field, create_model, field_validator, model_validator
from pydantic import (
ConfigDict,
Field,
SerializerFunctionWrapHandler,
create_model,
field_validator,
model_serializer,
model_validator,
)
from qcelemental.models.v2 import ( # Array,
AtomicProperties,
AtomicProtocols,
AtomicResult,
AtomicSpecification,
DriverEnum,
Model,
Molecule,
ProtoModel,
Provenance,
)
from qcelemental.models.v2.basemodels import ExtendedConfigDict, ProtoModel, check_convertible_version
from qcelemental.models.v2.basemodels import ProtoModel, check_convertible_version
from qcelemental.models.v2.types import Array # return to above once qcel corrected

from ...utils import provenance_stamp
Expand Down Expand Up @@ -79,8 +84,6 @@ class ManyBodyProtocols(ProtoModel):
ClusterResultsProtocolEnum.none, description=str(ClusterResultsProtocolEnum.__doc__)
)

model_config = ExtendedConfigDict(force_skip_defaults=True)

def convert_v(
self, target_version: int, /
) -> Union["qcmanybody.models.v1.ManyBodyProtocols", "qcmanybody.models.v2.ManyBodyProtocols"]:
Expand All @@ -92,6 +95,7 @@ def convert_v(

dself = self.model_dump()
if target_version == 1:
dself.pop("schema_name")
# serialization is compact, so use model to assure value
dself.pop("cluster_results", None)
dself["component_results"] = self.cluster_results.value
Expand Down Expand Up @@ -274,9 +278,11 @@ def convert_v(
dself["keywords"].pop("schema_name")
try:
dself["specification"].pop("schema_name")
dself["specification"]["specification"]["protocols"].pop("schema_name")
except KeyError:
for spec in dself["specification"].values():
spec.pop("schema_name")
spec["protocols"].pop("schema_name")
Comment on lines 279 to +285
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In ManyBodySpecification.convert_v, the try branch assumes dself["specification"] has a schema_name key and then indexes dself["specification"]["specification"]["protocols"], but specification is typed/validated as a dict of AtomicSpecifications, so this branch looks unreachable and the ...["specification"]["protocols"] path appears inconsistent with the structure used in the except branch (spec["protocols"]). Simplifying this to always iterate for spec in dself["specification"].values() (and dropping the nested ...["specification"]["specification"]...) would make the conversion logic clearer and avoid a latent crash if the try branch ever becomes reachable.

Suggested change
try:
dself["specification"].pop("schema_name")
dself["specification"]["specification"]["protocols"].pop("schema_name")
except KeyError:
for spec in dself["specification"].values():
spec.pop("schema_name")
spec["protocols"].pop("schema_name")
# strip schema_name from each atomic specification and its protocols
for spec in dself["specification"].values():
spec.pop("schema_name", None)
if "protocols" in spec:
spec["protocols"].pop("schema_name", None)

Copilot uses AI. Check for mistakes.

dself.pop("program") # not in v1
dself["protocols"] = self.protocols.convert_v(target_version)
Expand Down Expand Up @@ -639,26 +645,33 @@ def _validate_arb_max_nbody_fieldnames(cls, values):
return values


class ProtoModelSkipDefaults(ProtoModel):

# fields filtered in model_validator
model_config = ExtendedConfigDict(serialize_skip_defaults=True, force_skip_defaults=True, extra="allow")
class ProtoModelAllowExtra(ProtoModel):
model_config = ConfigDict(extra="allow")


if TYPE_CHECKING:
ManyBodyProperties = ProtoModelSkipDefaults
ManyBodyPropertiesBase = ProtoModelAllowExtra
else:
# if/else suppresses a warning about using a dynamically generated class as Field type in ManyBodyResults
# * deprecated but works: root_validator(skip_on_failure=True)(_validate_arb_max_nbody_fieldnames)
ManyBodyProperties = create_model(
"ManyBodyProperties",
# __doc__=manybodyproperties_doc, # needs later pydantic
__base__=ProtoModelSkipDefaults,
ManyBodyPropertiesBase = create_model(
"ManyBodyPropertiesBase",
__doc__=manybodyproperties_doc,
__base__=ProtoModelAllowExtra,
__validators__={"validator1": model_validator(mode="before")(_validate_arb_max_nbody_fieldnames)},
**mbprop,
)


class ManyBodyProperties(ManyBodyPropertiesBase):
@model_serializer(mode="wrap")
def _remove_none(self, handler: SerializerFunctionWrapHandler) -> Dict[str, Any]:
# Removes fields with a value of None from the serialized output.
# Leaves e.g., schema_name, which is different from v1 model.
serialized = handler(self)
return {k: v for k, v in serialized.items() if v is not None}


def _qcvars_translator(cls, reverse: bool = False) -> Dict[str, str]:
"""Form translation map between many-body results QCSchema and Psi4/QCDB terminologies.

Expand Down Expand Up @@ -687,7 +700,28 @@ def _qcvars_translator(cls, reverse: bool = False) -> Dict[str, str]:
return {v: k for k, v in qcvars_to_mbprop.items()}


def _mbprop_convert_v(
self, target_version: int, /
) -> Union["qcmanybody.models.v1.ManyBodyResultProperties", "qcmanybody.models.v2.ManyBodyProperties"]:
"""Convert to instance of particular QCSchema version."""
import qcmanybody as qcmb

if check_convertible_version(target_version, error="ManyBodyProperties") == "self":
return self

dself = self.model_dump()
if target_version == 1:
dself.pop("schema_name", None)

self_vN = qcmb.models.v1.ManyBodyResultProperties(**dself)
else:
assert False, target_version

return self_vN


ManyBodyProperties.to_qcvariables = classmethod(_qcvars_translator)
ManyBodyProperties.convert_v = _mbprop_convert_v


# ==== Results ================================================================
Expand Down Expand Up @@ -770,7 +804,11 @@ def convert_v(
dself.pop("native_files")
dself.pop("molecule")

dself["component_properties"] = dself.pop("cluster_properties")
dself["properties"] = self.properties.convert_v(target_version)
dself["component_properties"] = {
k: atprop.convert_v(target_version) for k, atprop in self.cluster_properties.items()
}
dself.pop("cluster_properties")
dself["component_results"] = {
k: atres.convert_v(target_version) for k, atres in self.cluster_results.items()
}
Expand Down
11 changes: 9 additions & 2 deletions qcmanybody/tests/test_schema_keywords.py
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,7 @@ def test_mbe_sie(mbe_data, kws, ans, schema_versions):
"calcinfo_nfrrrrrrrrrrr": 3,
}, "Field names not allowed"),
])
def test_mbproperties_expansion(kws, ans, schema_versions):
def test_mbproperties_expansion(kws, ans, schema_versions, request):
_qcmb, ManyBodyComputer, _qcel = schema_versions

if isinstance(ans, str):
Expand All @@ -326,4 +326,11 @@ def test_mbproperties_expansion(kws, ans, schema_versions):
# official leave this as dict(), not model_dump(), to ensure remains operational
with warnings.catch_warnings():
warnings.simplefilter("ignore")
assert len(input_model.dict()) == ans
assert len(input_model.dict(exclude_unset=True)) == ans

if "v2" in request.node.name:
assert len(input_model.model_dump()) == ans + 1, list(input_model.model_dump().keys())
assert sorted(input_model.model_dump().keys()) == sorted(list(kws.keys()) + ["schema_name"])
else:
assert len(input_model.model_dump()) == ans, list(input_model.model_dump().keys())
assert sorted(input_model.model_dump().keys()) == sorted(kws.keys())
Loading