Skip to content
Draft
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
1 change: 1 addition & 0 deletions changes/23.breaking.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The `load_common_data` and `load_version` functions now returns custom pydantic objects instead of `dict`. This means support for `__getitem__` based accessing of the data (e.g. `load_common_data()["legacy"]["blocks"]`) will no longer work, in favor of attribute-based accessing (e.g. `load_common_data().legacy.blocks`). Additionally, to make sure we follow python's best practices, any camelCase attributes were converted into snake_case.
64 changes: 54 additions & 10 deletions minebase/__init__.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
from __future__ import annotations

import json
from enum import Enum
from pathlib import Path
from typing import Any, cast
from typing import Any, Literal, cast, overload

from minebase.types._base import MinecraftValidationContext
from minebase.types.common_data import CommonData
from minebase.types.data_paths import DataPaths
from minebase.types.mcdata import BedrockMinecraftData, PcMinecraftData

DATA_SUBMODULE_PATH = Path(__file__).parent / "data"
DATA_PATH = DATA_SUBMODULE_PATH / "data"
Expand Down Expand Up @@ -35,13 +40,15 @@ def _load_data_paths() -> DataPaths:
raise ValueError(f"minecraft-data submodule didn't contain data paths manifest (missing {file})")

with file.open("rb") as fp:
return cast("DataPaths", json.load(fp))
data = cast("DataPaths", json.load(fp))

return DataPaths.model_validate(data)


def _load_version_manifest(version: str, edition: Edition = Edition.PC) -> "dict[str, str]":
def _load_version_manifest(version: str, edition: Edition = Edition.PC) -> dict[str, str]:
"""Load the data paths manifest for given version (if it exists)."""
manifest = _load_data_paths()
edition_info = manifest[edition.value]
edition_info = manifest.pc if edition is Edition.PC else manifest.bedrock
try:
return edition_info[version]
except KeyError as exc:
Expand All @@ -50,12 +57,44 @@ def _load_version_manifest(version: str, edition: Edition = Edition.PC) -> "dict

def supported_versions(edition: Edition = Edition.PC) -> list[str]:
"""Get a list of all supported minecraft versions."""
# We prefer versions from common data, as they're in a list, guaranteed to be
# ordered as they were released
data = load_common_data(edition)
versions = data.versions

# This is just for a sanity check
manifest = _load_data_paths()
edition_info = manifest[edition.value]
return list(edition_info.keys())
edition_info = getattr(manifest, edition.value)
manifest_versions = set(edition_info.keys())

# These versions are present in the manifest, but aren't in the common data versions.
# I have no idea why, they're perfectly loadable. We can't just naively insert them
# as we want the versions list to be ordered. For now, as a hack, we remove these to
# pass the check below, trying to load these would work, but they won't be listed as
# supported from this function.
# https://github.com/PrismarineJS/minecraft-data/issues/1064
manifest_versions.remove("1.16.5")
manifest_versions.remove("1.21")
manifest_versions.remove("1.21.6")

if set(versions) != set(manifest_versions) or len(versions) != len(manifest_versions):
raise ValueError(
f"Data integrity error: common versions don't match manifest versions: "
f"{versions=} != {manifest_versions=}",
)

return versions


def load_version(version: str, edition: Edition = Edition.PC) -> dict[str, Any]:
@overload
def load_version(version: str, edition: Literal[Edition.PC] = Edition.PC) -> PcMinecraftData: ...


@overload
def load_version(version: str, edition: Literal[Edition.BEDROCK]) -> BedrockMinecraftData: ...


def load_version(version: str, edition: Edition = Edition.PC) -> PcMinecraftData | BedrockMinecraftData:
"""Load minecraft-data for given `version` and `edition`."""
_validate_data()
version_data = _load_version_manifest(version, edition)
Expand All @@ -76,10 +115,15 @@ def load_version(version: str, edition: Edition = Edition.PC) -> dict[str, Any]:
with file.open("rb") as fp:
data[field] = json.load(fp)

return data
validation_context = MinecraftValidationContext(version=version, edition=edition, versions=supported_versions())

if edition is Edition.PC:
return PcMinecraftData.model_validate(data, context=validation_context)

return BedrockMinecraftData.model_validate(data, context=validation_context)


def load_common_data(edition: Edition = Edition.PC) -> dict[str, Any]:
def load_common_data(edition: Edition = Edition.PC) -> CommonData:
"""Load the common data from minecraft-data for given `edition`."""
_validate_data()
common_dir = DATA_PATH / edition.value / "common"
Expand All @@ -94,4 +138,4 @@ def load_common_data(edition: Edition = Edition.PC) -> dict[str, Any]:
with file.open("rb") as fp:
data[file.stem] = json.load(fp)

return data
return CommonData.model_validate(data)
44 changes: 44 additions & 0 deletions minebase/types/_base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
from __future__ import annotations

from typing import TYPE_CHECKING, TypedDict

from pydantic import BaseModel, ConfigDict
from pydantic.alias_generators import to_camel

if TYPE_CHECKING:
from minebase import Edition

__all__ = ["MinecraftDataModel", "_merge_base_config"]


class MinecraftValidationContext(TypedDict):
"""Context information used during pydantic validation."""

edition: Edition
version: str
versions: list[str]


class MinecraftDataModel(BaseModel):
"""Base type for a pydantic based class holding Minecraft-Data.

This type is reserved for internal use, and it is not a guaranteed base class
for all minecraft-data models. It is a helper class that includes pre-configured
model config for automatic field conversion from camelCase to snakeCase and to
prevent unexpected extra attributes or class population without using the camelCase
aliases.
"""

model_config = ConfigDict(
alias_generator=to_camel,
populate_by_name=False, # only allow population by alias names
from_attributes=True,
extra="forbid",
)


def _merge_base_config(conf: ConfigDict) -> ConfigDict:
"""A function to override specific keys in the pydantic config of the `MinecraftDataModel`."""
new = MinecraftDataModel.model_config.copy()
new.update(conf)
return new
34 changes: 34 additions & 0 deletions minebase/types/attributes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
from __future__ import annotations

from typing import final

from pydantic import model_validator

from minebase.types._base import MinecraftDataModel


@final
class MinecraftAttributeData(MinecraftDataModel):
"""Minecraft-Data for an attribute.

Attributes:
name: The name of this attribute
resource: The Mojang name of an attribute (usually generic.[name] or minecraft:generic.[name]
min: The minimum value of an attribute
max: The maximum value of an attribute
default: The default value of an attribute
"""

name: str
resource: str
default: float
min: float
max: float

@model_validator(mode="after")
def valid_default(self) -> MinecraftAttributeData:
"""Enforce that the default value is within the expected min-max bounds."""
if self.min <= self.default <= self.max:
return self

raise ValueError("The default value is outside of the min-max bounds")
89 changes: 89 additions & 0 deletions minebase/types/biomes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
from __future__ import annotations

from typing import Literal, final

from pydantic import Field, model_validator

from minebase.types._base import MinecraftDataModel


@final
class BiomeClimateData(MinecraftDataModel):
"""Minecraft-Data about the climate in a Biome.

This controls the ideal parameter ranges for Minecraft's multi-noise biome generation system.

Attributes:
temperature: Controls hot/cold climate preference (-1.0 to 1.0)
humidity: Controls dry/wet climate preference (-1.0 to 1.0)
altitude: Controls low/high terrain preference (affects hills/valleys)
weirdness: Controls terrain "strangeness" (also known as "ridges", -1.0 to 1.0)
offset: Fine-tuning parameter for biome selection priority/weight
"""

temperature: float = Field(ge=-1, le=1)
humidity: float = Field(ge=-1, le=1)
altitude: Literal[0] # not sure what the constraints here should be, minecraft-data only uses 0
weirdness: float = Field(ge=-1, le=1)
offset: float


@final
class BiomeData(MinecraftDataModel):
"""Minecraft-Data about a Biome.

Attributes:
id: The unique identifier for a biome
name: The name of a biome
category: Category to which this biome belongs to (e.g. "forest", "ocean", ...)
temperature: The base temperature in a biome.
precipitation: The type of precipitation (none, rain or snow) [before 1.19.4]
has_precipitation: True if a biome has any precipitation (rain or snow) [1.19.4+]
dimension: The dimension of a biome: overworld, nether or end (or the_end on bedrock)
display_name: The display name of a biome
color: The color in a biome
rainfall: How much rain there is in a biome [before 1.19.4]
depth: Depth corresponds approximately to the terrain height.
climates: Climate data for the biome
name_legacy: Legacy name of the biome used in older versions.
parent: The name of the parent biome
child: ID of a variant biome
"""

id: int
name: str
category: str
temperature: float = Field(ge=-1, le=2)
precipitation: Literal["none", "rain", "snow"] | None = None
# For some reason, this field actually uses snake_case, not camelCase
has_precipitation: bool | None = Field(alias="has_precipitation", default=None)
dimension: Literal["overworld", "nether", "end", "the_end"]
display_name: str
color: int
rainfall: float | None = Field(ge=0, le=1, default=None)
depth: float | None = Field(default=None)
climates: list[BiomeClimateData] | None = Field(min_length=1, default=None)
name_legacy: str | None = Field(alias="name_legacy", default=None) # also uses snake_case for some reason
parent: str | None = None
child: int | None = Field(ge=0, default=None)

@model_validator(mode="before")
@classmethod
def rename_has_percipitation(cls, data: dict[str, object]) -> dict[str, object]:
"""Rename the typo field has_percipitation to has_precipitation.

This is a mistake in the minecraft-data dataset which is only present for a single
minecraft version (bedrock 1.21.60), this function renames it back to standardize
our data models.

This will get addressed with: https://github.com/PrismarineJS/minecraft-data/issues/1048
after which this method can be removed.
"""
if "has_percipitation" not in data:
return data

if "has_precipitation" in data:
raise ValueError("Found biome with both has_percipitation and has_precipitation fields")

data["has_precipitation"] = data.pop("has_percipitation")
return data
Loading
Loading