diff --git a/docs/src/conf.py b/docs/src/conf.py index 292b1e57a..0183a4389 100755 --- a/docs/src/conf.py +++ b/docs/src/conf.py @@ -6,6 +6,7 @@ import sys from datetime import date from textwrap import dedent +from typing import Type import fmu.dataio import fmu.dataio.dataio @@ -51,10 +52,26 @@ def filter(self, record: logging.LogRecord) -> bool: ] -def _myst_substitutions() -> dict[str, SchemaBase]: +def _myst_substitutions() -> dict[str, Type[SchemaBase]]: + """A list of substitutions usable in the documentation. + + Giving + + subs[f"{s.__name__}"] = s + + where `s` is a class allows you to access class values from within MyST markdown + documentation like: + + FmuResultsSchema.VERSION + + Any arbitrary key-value pairs from the code are possible to template into the + documentation in this way. + """ subs = {} for s in schemas: - s.literalinclude = dedent(f""" + # For templating an include of the JSON Schema + s.literalinclude = dedent( # type: ignore + f""" ```{{eval-rst}} .. toggle:: @@ -62,9 +79,15 @@ def _myst_substitutions() -> dict[str, SchemaBase]: :language: json ``` - """) + """ + ) if hasattr(s, "CONTRACTUAL"): - s.contractual = "\n".join([f"- `{item}`" for item in s.CONTRACTUAL]) + s.contractual = "\n".join( # type: ignore + [f"- `{item}`" for item in s.CONTRACTUAL] + ) + + # TODO: Create the changelog template. + subs[f"{s.__name__}"] = s return subs diff --git a/src/fmu/dataio/_models/_changelog_base.py b/src/fmu/dataio/_models/_changelog_base.py new file mode 100644 index 000000000..f03f6761d --- /dev/null +++ b/src/fmu/dataio/_models/_changelog_base.py @@ -0,0 +1,42 @@ +"""Contains the model used to represent a schema's changelog.""" + +from __future__ import annotations + +from typing import Iterator, List, Optional + +from pydantic import BaseModel, RootModel + +from fmu.dataio.types import VersionStr + + +class FieldChanges(BaseModel): + """Contains a list of changes relevant to a particular field in the schema.""" + + field: str + """The field within the schema being changed, i.e. `data.standard_result`.""" + + changes: List[str] + """A list of changes that occurred to this field. + + These changes will be rendered as bullet points. In most cases there is just one + change.""" + + +class ChangeLogEntry(BaseModel): + """Contains all changes related to a particular schema version number.""" + + version: VersionStr + """The version of the schema.""" + + description: Optional[str] = None + """An optional description of the overall schema changes.""" + + field_changes: List[FieldChanges] + """A list of fields that changed and descriptions capturing those changes.""" + + +class ChangeLog(RootModel[List[ChangeLogEntry]]): + """Represents the changelog for all versions of a schema.""" + + def __iter__(self) -> Iterator[ChangeLogEntry]: # type: ignore + return iter(self.root) diff --git a/src/fmu/dataio/_models/_schema_base.py b/src/fmu/dataio/_models/_schema_base.py index 1c147f6f2..96dc2f430 100644 --- a/src/fmu/dataio/_models/_schema_base.py +++ b/src/fmu/dataio/_models/_schema_base.py @@ -18,6 +18,8 @@ from fmu.dataio.types import VersionStr +from ._changelog_base import ChangeLog + T = TypeVar("T", Dict, List, object) @@ -121,6 +123,11 @@ class SchemaBase(ABC): """ + CHANGELOG: ChangeLog + """Contains a list of changes that have occurred to the schema. + + These are used to generate documentation.""" + @classmethod def __init_subclass__(cls, **kwargs: dict[str, Any]) -> None: """This achieves Pydantic-like validation without being Pydantic. @@ -130,16 +137,27 @@ def __init_subclass__(cls, **kwargs: dict[str, Any]) -> None: implemented. It also doesn't like the default generator. """ super().__init_subclass__(**kwargs) - for attr in ("VERSION", "FILENAME", "PATH"): + cls._validate_class_vars_set() + cls._validate_path() + cls._validate_version() + cls._validate_changelog() + + @classmethod + def _validate_class_vars_set(cls) -> None: + for attr in ("VERSION", "FILENAME", "PATH", "CHANGELOG"): if not hasattr(cls, attr): raise TypeError(f"Subclass {cls.__name__} must define '{attr}'") + @classmethod + def _validate_path(cls) -> None: if not cls.PATH.parts[0].startswith(str(FmuSchemas.PATH)): raise ValueError( f"PATH must start with `FmuSchemas.PATH`: {FmuSchemas.PATH}. " f"Got {cls.PATH}" ) + @classmethod + def _validate_version(cls) -> None: try: class PydanticVersionValidator(BaseModel): @@ -151,6 +169,13 @@ class PydanticVersionValidator(BaseModel): except ValidationError as e: raise TypeError(f"Invalid VERSION format for '{cls.__name__}': {e}") from e + @classmethod + def _validate_changelog(cls) -> None: + if all(entry.version != cls.VERSION for entry in cls.CHANGELOG): + raise ValueError( + f"No changelog entry exists for '{cls.__name__}' version {cls.VERSION}" + ) + @classmethod def dev_url(cls) -> str: """Returns the url to the schema on the Radix dev environment.""" diff --git a/src/fmu/dataio/_models/fmu_results/_changelog.py b/src/fmu/dataio/_models/fmu_results/_changelog.py new file mode 100644 index 000000000..b027897c7 --- /dev/null +++ b/src/fmu/dataio/_models/fmu_results/_changelog.py @@ -0,0 +1,23 @@ +"""Contains the changelog for fmu_results.json.""" + +from fmu.dataio._models._changelog_base import ChangeLog, ChangeLogEntry, FieldChanges + +CHANGELOG = ChangeLog( + [ + ChangeLogEntry( + version="0.9.0", + description="This is the first new version of the schema 🎉", + field_changes=[ + FieldChanges( + field="data.product", + changes=["This field has been renamed to `data.standard_result`"], + ) + ], + ), + ChangeLogEntry( + version="0.8.0", + description="This is the initial schema.", + field_changes=[], + ), + ] +) diff --git a/src/fmu/dataio/_models/fmu_results/fmu_results.py b/src/fmu/dataio/_models/fmu_results/fmu_results.py index 4e63578f0..13aab5d6b 100644 --- a/src/fmu/dataio/_models/fmu_results/fmu_results.py +++ b/src/fmu/dataio/_models/fmu_results/fmu_results.py @@ -13,6 +13,7 @@ ) from typing_extensions import Annotated +from fmu.dataio._models._changelog_base import ChangeLog from fmu.dataio._models._schema_base import ( FmuSchemas, GenerateJsonSchemaBase, @@ -20,6 +21,7 @@ ) from fmu.dataio.types import VersionStr +from ._changelog import CHANGELOG from .data import AnyData from .enums import FMUClass from .fields import ( @@ -47,6 +49,7 @@ class FmuResultsSchema(SchemaBase): VERSION: VersionStr = "0.9.0" FILENAME: str = "fmu_results.json" PATH: Path = FmuSchemas.PATH / VERSION / FILENAME + CHANGELOG: ChangeLog = CHANGELOG SOURCE: str = "fmu" CONTRACTUAL: Final[list[str]] = [ diff --git a/src/fmu/dataio/_models/standard_results/inplace_volumes.py b/src/fmu/dataio/_models/standard_results/inplace_volumes.py index 33ad304c7..70f4bf8f3 100644 --- a/src/fmu/dataio/_models/standard_results/inplace_volumes.py +++ b/src/fmu/dataio/_models/standard_results/inplace_volumes.py @@ -5,6 +5,10 @@ from pydantic import BaseModel, Field, RootModel +from fmu.dataio._models._changelog_base import ( + ChangeLog, + ChangeLogEntry, +) from fmu.dataio._models._schema_base import FmuSchemas, SchemaBase from fmu.dataio.export._enums import InplaceVolumes from fmu.dataio.types import VersionStr @@ -88,6 +92,16 @@ class InplaceVolumesSchema(SchemaBase): PATH: Path = FmuSchemas.PATH / "file_formats" / VERSION / FILENAME """The local and URL path of this schema.""" + CHANGELOG: ChangeLog = ChangeLog( + [ + ChangeLogEntry( + version="0.1.0", + description="This is the initial schema.", + field_changes=[], + ), + ] + ) + @classmethod def dump(cls) -> dict[str, Any]: return InplaceVolumesResult.model_json_schema( diff --git a/tests/test_schema/test_schemabase.py b/tests/test_schema/test_schemabase.py index 7679c8524..048612f08 100644 --- a/tests/test_schema/test_schemabase.py +++ b/tests/test_schema/test_schemabase.py @@ -4,6 +4,7 @@ import pytest +from fmu.dataio._models._changelog_base import ChangeLog, ChangeLogEntry from fmu.dataio._models._schema_base import FmuSchemas, SchemaBase @@ -14,27 +15,50 @@ def test_schemabase_validates_class_vars() -> None: class A(SchemaBase): VERSION: str = "0.8.0" FILENAME: str = "fmu_results.json" + CHANGELOG: ChangeLog = ChangeLog( + [ChangeLogEntry(version="0.8.0", field_changes=[])] + ) with pytest.raises(TypeError, match="Subclass B must define 'FILENAME'"): class B(SchemaBase): VERSION: str = "0.8.0" PATH: Path = FmuSchemas.PATH / "test" + CHANGELOG: ChangeLog = ChangeLog( + [ChangeLogEntry(version="0.8.0", field_changes=[])] + ) with pytest.raises(TypeError, match="Subclass C must define 'VERSION'"): class C(SchemaBase): FILENAME: str = "fmu_results.json" PATH: Path = FmuSchemas.PATH / "test" + CHANGELOG: ChangeLog = ChangeLog( + [ChangeLogEntry(version="0.8.0", field_changes=[])] + ) + with pytest.raises(TypeError, match="Subclass D must define 'CHANGELOG'"): -def test_schemabase_validates_verion_string_form() -> None: + class D(SchemaBase): + VERSION: str = "0.8.0" + FILENAME: str = "fmu_results.json" + PATH: Path = FmuSchemas.PATH / "test" + + +def test_schemabase_validates_version_string_form() -> None: + """Tests that the VERSION given to the schema raises if not of a valid form. + + Changelog versions are valid and untest similarly because they are already validated + through a Pydantic model that checks the same.""" with pytest.raises(TypeError, match="Invalid VERSION format for 'MajorMinor'"): class MajorMinor(SchemaBase): VERSION = "12.3" FILENAME: str = "fmu_results.json" PATH: Path = FmuSchemas.PATH / "test" + CHANGELOG: ChangeLog = ChangeLog( + [ChangeLogEntry(version="12.3.1", field_changes=[])] + ) with pytest.raises(TypeError, match="Invalid VERSION format for 'Alphanumeric'"): @@ -42,6 +66,9 @@ class Alphanumeric(SchemaBase): VERSION = "1.3.a" FILENAME: str = "fmu_results.json" PATH: Path = FmuSchemas.PATH / "test" + CHANGELOG: ChangeLog = ChangeLog( + [ChangeLogEntry(version="1.3.0", field_changes=[])] + ) with pytest.raises(TypeError, match="Invalid VERSION format for 'LeadingZero'"): @@ -49,6 +76,9 @@ class LeadingZero(SchemaBase): VERSION = "01.3.0" FILENAME: str = "fmu_results.json" PATH: Path = FmuSchemas.PATH / "test" + CHANGELOG: ChangeLog = ChangeLog( + [ChangeLogEntry(version="1.3.0", field_changes=[])] + ) def test_schemabase_requires_path_starting_with_fmuschemas_path() -> None: @@ -62,3 +92,21 @@ class A(SchemaBase): VERSION: str = "0.8.0" FILENAME: str = "fmu_results.json" PATH: Path = Path("test") + CHANGELOG: ChangeLog = ChangeLog( + [ChangeLogEntry(version="0.8.0", field_changes=[])] + ) + + +def test_schemabase_validates_a_version_has_a_changelog_entry() -> None: + """Tests that a version change has a corresponding changelog entry.""" + with pytest.raises( + ValueError, match="No changelog entry exists for 'A' version 0.9.0" + ): + + class A(SchemaBase): + VERSION: str = "0.9.0" + FILENAME: str = "fmu_results.json" + PATH: Path = FmuSchemas.PATH / "test" + CHANGELOG: ChangeLog = ChangeLog( + [ChangeLogEntry(version="0.8.0", field_changes=[])] + )