diff --git a/src/ansys/dpf/core/changelog.py b/src/ansys/dpf/core/changelog.py new file mode 100644 index 00000000000..ab64e7c745d --- /dev/null +++ b/src/ansys/dpf/core/changelog.py @@ -0,0 +1,207 @@ +# Copyright (C) 2020 - 2025 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +"""Provides classes for changelogs.""" + +from __future__ import annotations + +from packaging.version import Version + +import ansys.dpf.core as dpf +from ansys.dpf.core.server_types import AnyServerType + + +class Changelog: + """Changelog of an operator. + + Requires DPF 11.0 (2026 R1) or above. + + Parameters + ---------- + gdc: + An optional GenericDataContainer to initialize the changelog with. + server: + The server to create the changelog on. Defaults to the current global server. + """ + + def __init__(self, gdc: dpf.GenericDataContainer = None, server: AnyServerType = None): + if gdc is None: + gdc = dpf.GenericDataContainer(server=server) + versions_sf = dpf.StringField(server=server) + versions_sf.append(data=["0.0.0"], scopingid=1) + changes_sf = dpf.StringField(server=server) + changes_sf.append(data=["Initial version."], scopingid=1) + gdc.set_property(property_name="versions", prop=versions_sf) + gdc.set_property(property_name="changes", prop=changes_sf) + gdc.set_property(property_name="class", prop="Changelog") + self.gdc = gdc + self._server = server + + def append(self, version: Version, changes: str): + """Append a version and associated changes description to the changelog.""" + versions_sf: dpf.StringField = self.gdc.get_property( + property_name="versions", output_type=dpf.StringField + ) + new_id = versions_sf.scoping.size + 1 + versions_sf.append(data=[str(version)], scopingid=new_id) + changes_sf: dpf.StringField = self.gdc.get_property( + property_name="changes", output_type=dpf.StringField + ) + changes_sf.append(data=[changes], scopingid=new_id) + + def patch_bump(self, changes: str) -> Changelog: + """Bump the patch of the current version with associated changes description. + + Parameters + ---------- + changes: + Description of the changes associated to the patch bump. + + Returns + ------- + changelog: + Returns the current changelog to allow for chaining calls to bumps. + """ + current_version = self.last_version + new_version = Version( + f"{current_version.major}.{current_version.minor}.{current_version.micro+1}" + ) + self.append(version=new_version, changes=changes) + return self + + def minor_bump(self, changes: str) -> Changelog: + """Bump the minor of the current version with associated changes description. + + Parameters + ---------- + changes: + Description of the changes associated to the minor bump. + + Returns + ------- + changelog: + Returns the current changelog to allow for chaining calls to bumps. + """ + current_version = self.last_version + new_version = Version(f"{current_version.major}.{current_version.minor+1}.0") + self.append(version=new_version, changes=changes) + return self + + def major_bump(self, changes: str) -> Changelog: + """Bump the major of the current version with associated changes description. + + Parameters + ---------- + changes: + Description of the changes associated to the major bump. + + Returns + ------- + changelog: + Returns the current changelog to allow for chaining calls to bumps. + """ + current_version = self.last_version + new_version = Version(f"{current_version.major+1}.0.0") + self.append(version=new_version, changes=changes) + return self + + def expect_version(self, version: Version | str) -> Changelog: + """Check the current latest version of the changelog. + + Useful when chaining version bumps to check the resulting version is as expected. + Adds readability to the specification of the operator. + + Parameters + ---------- + version: + Expected current latest version of the changelog. + + Returns + ------- + changelog: + Returns the current changelog to allow for chaining calls to bumps. + """ + if isinstance(version, str): + version = Version(version) + if self.last_version != version: + raise ValueError( + f"Last version in the changelog ({self.last_version}) does not match expected version ({version})." + ) + return self + + @property + def last_version(self) -> Version: + """Highest version in the changelog. + + Returns + ------- + version: + Highest version in the changelog. + """ + return self.versions[-1] + + @property + def versions(self) -> [Version]: + """List of all versions for which the changelog stores descriptions.""" + versions_sf: dpf.StringField = self.gdc.get_property( + property_name="versions", output_type=dpf.StringField + ) + return [Version(version) for version in versions_sf.data_as_list] + + def __getitem__(self, item: Version | int) -> str | [Version, str]: + """Return item at the given index or changes description for the given version.""" + if isinstance(item, int): + if item > len(self) - 1: + raise IndexError(f"Index {item} out of range for changelog of size {len(self)}.") + return self.versions[item], self.changes_for_version(self.versions[item]) + return self.changes_for_version(item) + + def __len__(self): + """Return the number of items in the changelog.""" + return len(self.versions) + + def __contains__(self, item: Version): + """Check if version is in the changelog.""" + return item in self.versions + + def changes_for_version(self, version: Version) -> str: + """Return changes description for a specific version in the changelog.""" + versions_sf: dpf.StringField = self.gdc.get_property( + property_name="versions", output_type=dpf.StringField + ) + changes_sf: dpf.StringField = self.gdc.get_property( + property_name="changes", output_type=dpf.StringField + ) + versions_list = versions_sf.data_as_list + for i, x in enumerate(versions_sf.scoping.ids): + if Version(versions_list[i]) == version: + return changes_sf.get_entity_data_by_id(x)[0] + raise ValueError(f"Changelog has no version '{version}'.") + + def __str__(self): + """Create string representation of the changelog.""" + string = "Changelog:\n" + string += "Version Changes\n" + string += "------- -------\n" + for version in self.versions: + string += f"{str(version): <15}" + self[version].replace("\n", f"\n{'': >15}") + "\n" + return string diff --git a/src/ansys/dpf/core/custom_operator.py b/src/ansys/dpf/core/custom_operator.py index 3b6f9e3dc02..6142c9da0d2 100644 --- a/src/ansys/dpf/core/custom_operator.py +++ b/src/ansys/dpf/core/custom_operator.py @@ -37,6 +37,7 @@ import zipfile import numpy +from packaging.version import Version from ansys.dpf import core as dpf from ansys.dpf.core import ( @@ -55,6 +56,8 @@ external_operator_api, functions_registry, ) +from ansys.dpf.core.changelog import Changelog +from ansys.dpf.core.check_version import version_requires from ansys.dpf.gate import capi, dpf_vector, integral_types, object_handler @@ -400,3 +403,39 @@ def name(self) -> str: This name can then be used to instantiate the Operator. """ pass + + @property + @version_requires("11.0") + def changelog(self) -> Changelog: + """Return the changelog of this operator. + + Requires DPF 11.0 (2026 R1) or above. + + Returns + ------- + changelog: + Changelog of the operator. + """ + from ansys.dpf.core.operators.utility.operator_changelog import operator_changelog + + return Changelog(operator_changelog(operator_name=self.name).eval()) + + @changelog.setter + @version_requires("11.0") + def changelog(self, changelog: Changelog): + """Set the changelog of this operator. + + Requires DPF 11.0 (2026 R1) or above. + + """ + self.specification.set_changelog(changelog) + + @property + @version_requires("11.0") + def version(self) -> Version: + """Return the current version of the operator based on its changelog. + + Requires DPF 11.0 (2026 R1) or above. + + """ + return self.changelog.last_version diff --git a/src/ansys/dpf/core/dpf_operator.py b/src/ansys/dpf/core/dpf_operator.py index 5ccc4192445..b621db50d6c 100644 --- a/src/ansys/dpf/core/dpf_operator.py +++ b/src/ansys/dpf/core/dpf_operator.py @@ -29,8 +29,10 @@ import warnings import numpy +from packaging.version import Version from ansys.dpf.core import server as server_module +from ansys.dpf.core.changelog import Changelog from ansys.dpf.core.check_version import ( server_meet_version, server_meet_version_and_raise, @@ -931,6 +933,35 @@ def specification(self): else: return Specification(operator_name=self.name, server=self._server) + @property + @version_requires("11.0") + def changelog(self) -> Changelog: + """Return the changelog of this operator. + + Requires DPF 11.0 (2026 R1) or above. + + Returns + ------- + changelog: + Changelog of the operator. + """ + from ansys.dpf.core.operators.utility.operator_changelog import operator_changelog + + return Changelog( + gdc=operator_changelog(operator_name=self.name, server=self._server).eval(), + server=self._server, + ) + + @property + @version_requires("11.0") + def version(self) -> Version: + """Return the current version of the operator. + + Requires DPF 11.0 (2026 R1) or above. + + """ + return self.changelog.last_version + def __truediv__(self, inpt): """ Perform division with another operator or a scalar. diff --git a/src/ansys/dpf/core/operator_specification.py b/src/ansys/dpf/core/operator_specification.py index 1bbe5aaaad6..1eab43bde28 100644 --- a/src/ansys/dpf/core/operator_specification.py +++ b/src/ansys/dpf/core/operator_specification.py @@ -32,6 +32,7 @@ from typing import Union from ansys.dpf.core import common, mapping_types, server as server_module +from ansys.dpf.core.changelog import Changelog from ansys.dpf.core.check_version import server_meet_version, version_requires from ansys.dpf.gate import ( integral_types, @@ -497,6 +498,15 @@ def config_specification(self) -> ConfigSpecification: ) return self._config_specification + @version_requires("11.0") + def set_changelog(self, changelog: Changelog): + """Set the changelog for this operator specification. + + Requires DPF 11.0 (2026 R1) or above. + + """ + self._api.operator_specification_set_changelog(self, changelog.gdc) + class CustomConfigOptionSpec(ConfigOptionSpec): """Custom documentation of a configuration option available for a given operator.""" diff --git a/src/ansys/dpf/gate/operator_specification_grpcapi.py b/src/ansys/dpf/gate/operator_specification_grpcapi.py index e21a8fa2d87..23f84b8eaba 100644 --- a/src/ansys/dpf/gate/operator_specification_grpcapi.py +++ b/src/ansys/dpf/gate/operator_specification_grpcapi.py @@ -169,3 +169,7 @@ def operator_specification_get_config_printable_default_value(specification, num def operator_specification_get_config_description(specification, numOption): option = specification._internal_obj.config_spec.config_options_spec[numOption] return option.document + + @staticmethod + def operator_specification_set_changelog(specification, changelog): + specification._internal_obj.changelog = changelog diff --git a/tests/conftest.py b/tests/conftest.py index 634396aee91..f241ee23aa4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -329,6 +329,9 @@ def return_ds(server=None): return return_ds +SERVERS_VERSION_GREATER_THAN_OR_EQUAL_TO_11_0 = meets_version( + get_server_version(core._global_server()), "11.0" +) SERVERS_VERSION_GREATER_THAN_OR_EQUAL_TO_10_0 = meets_version( get_server_version(core._global_server()), "10.0" ) diff --git a/tests/test_changelog.py b/tests/test_changelog.py new file mode 100644 index 00000000000..e59c87d8540 --- /dev/null +++ b/tests/test_changelog.py @@ -0,0 +1,75 @@ +# Copyright (C) 2020 - 2025 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +import pytest + +from ansys.dpf.core.changelog import Changelog +import conftest + + +@conftest.raises_for_servers_version_under("11.0") +def test_changelog_new(server_type): + from packaging.version import Version + + changelog = Changelog(server=server_type) + assert changelog.last_version == Version("0.0.0") + assert changelog[changelog.last_version] == "Initial version." + + +@conftest.raises_for_servers_version_under("11.0") +def test_changelog_updates(server_type): + from packaging.version import Version + + changelog = Changelog(server=server_type) + changelog.patch_bump("Patch 1").minor_bump("Minor bump 1").patch_bump("Patch 2").major_bump( + "Major bump" + ).minor_bump("Minor bump").patch_bump("Patch \nbump") + with pytest.raises(ValueError): + changelog.expect_version(Version("0.0.0")) + changelog.expect_version(Version("1.1.1")) + assert changelog[changelog.last_version] == "Patch \nbump" + changelog.patch_bump("Patch 2") + assert ( + str(changelog) + == """Changelog: +Version Changes +------- ------- +0.0.0 Initial version. +0.0.1 Patch 1 +0.1.0 Minor bump 1 +0.1.1 Patch 2 +1.0.0 Major bump +1.1.0 Minor bump +1.1.1 Patch + bump +1.1.2 Patch 2 +""" + ) + assert len(changelog) == 8 + assert changelog[0] == (Version("0.0.0"), "Initial version.") + assert changelog[-1] == (Version("1.1.2"), "Patch 2") + for i, v in enumerate(changelog): + if i == 5: + assert v == (Version("1.1.0"), "Minor bump") + with pytest.raises(IndexError): + _ = changelog[8] + assert Version("0.0.0") in changelog + assert Version("1.5.2") not in changelog diff --git a/tests/test_operator.py b/tests/test_operator.py index aa254744144..1a6b28d0884 100644 --- a/tests/test_operator.py +++ b/tests/test_operator.py @@ -1533,3 +1533,18 @@ def test_operator_find_outputs_corresponding_pins_any(server_type): f1 = ops.utility.forward() f2 = ops.utility.forward() f2.inputs.any.connect(f1.outputs.any) + + +@conftest.raises_for_servers_version_under("11.0") +def test_operator_changelog(server_type): + from packaging.version import Version + + changelog = dpf.core.operators.math.add(server=server_type).changelog + assert changelog[Version("0.0.0")] == "New" + + +@conftest.raises_for_servers_version_under("11.0") +def test_operator_version(server_type): + from packaging.version import Version + + assert isinstance(dpf.core.operators.math.add(server=server_type).version, Version) diff --git a/tests/test_python_plugins.py b/tests/test_python_plugins.py index a13892612f4..3abfe1a33c2 100644 --- a/tests/test_python_plugins.py +++ b/tests/test_python_plugins.py @@ -28,6 +28,7 @@ import pytest from ansys.dpf import core as dpf +from ansys.dpf.core.changelog import Changelog from ansys.dpf.core.custom_operator import update_virtual_environment_for_custom_operators from ansys.dpf.core.errors import DPFServerException from ansys.dpf.core.operator_specification import ( @@ -40,6 +41,7 @@ from conftest import ( SERVERS_VERSION_GREATER_THAN_OR_EQUAL_TO_4_0, SERVERS_VERSION_GREATER_THAN_OR_EQUAL_TO_7_0, + SERVERS_VERSION_GREATER_THAN_OR_EQUAL_TO_11_0, ) if not SERVERS_VERSION_GREATER_THAN_OR_EQUAL_TO_4_0: @@ -408,3 +410,25 @@ def test_custom_op_with_spec(server_type_remote_process, testfiles_dir): outf = op.outputs.field() expected = np.ones((3, 3), dtype=np.float64) + 4.0 assert np.allclose(outf.data, expected) + + +@pytest.mark.skipif( + not SERVERS_VERSION_GREATER_THAN_OR_EQUAL_TO_11_0, reason="Available for servers >=11.0" +) +def test_custom_op_changelog(server_type_remote_process, testfiles_dir): + from packaging.version import Version + + dpf.load_library( + dpf.path_utilities.to_server_os( + Path(testfiles_dir) / "pythonPlugins", server_type_remote_process + ), + "py_operator_with_changelog", + "load_operators", + server=server_type_remote_process, + ) + op = dpf.Operator("custom_add_to_field", server=server_type_remote_process) + changelog = op.changelog + assert isinstance(changelog, Changelog) + assert changelog.last_version == Version("1.0.0") + assert changelog[Version("1.0.0")] == "Major bump" + assert op.version == Version("1.0.0") diff --git a/tests/testfiles/pythonPlugins/operator_with_changelog.py b/tests/testfiles/pythonPlugins/operator_with_changelog.py new file mode 100644 index 00000000000..4e50d55a7a2 --- /dev/null +++ b/tests/testfiles/pythonPlugins/operator_with_changelog.py @@ -0,0 +1,63 @@ +# Copyright (C) 2020 - 2025 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from ansys.dpf.core import Field +from ansys.dpf.core.changelog import Changelog +from ansys.dpf.core.custom_operator import CustomOperatorBase, record_operator +from ansys.dpf.core.operator_specification import ( + CustomSpecification, + PinSpecification, + SpecificationProperties, +) + + +class AddFloatToFieldData(CustomOperatorBase): + def run(self): + field = self.get_input(0, Field) + to_add = self.get_input(1, float) + data = field.data + data += to_add + self.set_output(0, field) + self.set_succeeded() + + @property + def specification(self): + spec = CustomSpecification() + spec.description = "Add a custom value to all the data of an input Field" + spec.inputs = { + 0: PinSpecification("field", [Field], "Field on which float value is added."), + 1: PinSpecification("to_add", [float], "Data to add."), + } + spec.outputs = { + 0: PinSpecification("field", [Field], "Field on which the float value is added.") + } + spec.properties = SpecificationProperties("custom add to field", "math") + spec.set_changelog(changelog=Changelog().major_bump("Major bump").expect_version("1.0.0")) + return spec + + @property + def name(self): + return "custom_add_to_field" + + +def load_operators(*args): + record_operator(AddFloatToFieldData, *args)