Skip to content

Commit

Permalink
DOC: Introduce schema changelog templating
Browse files Browse the repository at this point in the history
  • Loading branch information
mferrera committed Mar 7, 2025
1 parent 7a48f69 commit 3e9cb12
Show file tree
Hide file tree
Showing 7 changed files with 184 additions and 6 deletions.
31 changes: 27 additions & 4 deletions docs/src/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -51,20 +52,42 @@ 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::
.. literalinclude:: ../../../{s.PATH}
: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

Expand Down
42 changes: 42 additions & 0 deletions src/fmu/dataio/_models/_changelog_base.py
Original file line number Diff line number Diff line change
@@ -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)
27 changes: 26 additions & 1 deletion src/fmu/dataio/_models/_schema_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@

from fmu.dataio.types import VersionStr

from ._changelog_base import ChangeLog

T = TypeVar("T", Dict, List, object)


Expand Down Expand Up @@ -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.
Expand All @@ -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):
Expand All @@ -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."""
Expand Down
23 changes: 23 additions & 0 deletions src/fmu/dataio/_models/fmu_results/_changelog.py
Original file line number Diff line number Diff line change
@@ -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=[],
),
]
)
3 changes: 3 additions & 0 deletions src/fmu/dataio/_models/fmu_results/fmu_results.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,15 @@
)
from typing_extensions import Annotated

from fmu.dataio._models._changelog_base import ChangeLog
from fmu.dataio._models._schema_base import (
FmuSchemas,
GenerateJsonSchemaBase,
SchemaBase,
)
from fmu.dataio.types import VersionStr

from ._changelog import CHANGELOG
from .data import AnyData
from .enums import FMUClass
from .fields import (
Expand Down Expand Up @@ -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]] = [
Expand Down
14 changes: 14 additions & 0 deletions src/fmu/dataio/_models/standard_results/inplace_volumes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
50 changes: 49 additions & 1 deletion tests/test_schema/test_schemabase.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import pytest

from fmu.dataio._models._changelog_base import ChangeLog, ChangeLogEntry
from fmu.dataio._models._schema_base import FmuSchemas, SchemaBase


Expand All @@ -14,41 +15,70 @@ 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'"):

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'"):

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:
Expand All @@ -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=[])]
)

0 comments on commit 3e9cb12

Please sign in to comment.