Skip to content

Commit

Permalink
Merge remote-tracking branch 'origin/main' into item-models
Browse files Browse the repository at this point in the history
  • Loading branch information
object-Object committed Feb 13, 2025
2 parents 5fa5102 + 34aff08 commit 4f548f7
Show file tree
Hide file tree
Showing 14 changed files with 205 additions and 25 deletions.
8 changes: 6 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ on:
tags: 'v[0-9]+![0-9]+.[0-9]+.[0-9]+*'
pull_request:
branches: "*"
schedule:
# run every Saturday at 11 EST / 12 EDT
# because new Pydantic versions keep breaking things :(
- cron: 0 16 * * 6

permissions:
contents: read
Expand Down Expand Up @@ -44,7 +48,7 @@ jobs:
run: xvfb-run --auto-servernum hexdoc ci build

- name: Upload package artifact
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: python-build
path: dist
Expand Down Expand Up @@ -138,7 +142,7 @@ jobs:
- uses: actions/checkout@v4

- name: Download package artifact
uses: actions/download-artifact@v3
uses: actions/download-artifact@v4
with:
name: python-build
path: dist
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
* Pyright: `1.1.389`
* Pillow: `11.0.0`

## `1!0.1.0a21`

### Fixed

* Fix broken environment variable loading by adding a dependency exclusion for Pydantic Settings v2.6.0 (see [pydantic/pydantic-settings#445](https://github.com/pydantic/pydantic-settings/issues/445)).

## `1!0.1.0a20`

### Added
Expand Down
22 changes: 22 additions & 0 deletions noxfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,28 @@ def dummy_setup(session: nox.Session):
"type": "dummy:example",
"example_value": "insert funny message here",
},
{
"type": "patchouli:spotlight",
"text": "spotlight!",
"item": "minecraft:stone",
},
{
"type": "patchouli:spotlight",
"title": "title!",
"text": "spotlight with title!",
"item": "minecraft:stone",
},
{
"type": "patchouli:spotlight",
"text": "spotlight with anchor!",
"item": "minecraft:stone",
"anchor": "spotlight",
},
{
"type": "patchouli:spotlight",
"text": "spotlight with named item!",
"item": """minecraft:stone{display:{Name:'{"text":"dirt?","color":"white"}'}}""",
},
],
},
"entries/patchistuff.json": {
Expand Down
4 changes: 3 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,13 @@ dependencies = [
"moderngl[headless]~=5.10",
"moderngl-window~=2.4",
"more_itertools~=10.1",
"nbtlib==1.12.1",
"networkx~=3.2",
"ordered-set~=4.1",
"packaging~=23.2",
"pluggy~=1.3",
"pydantic_settings~=2.0",
# !=2.6.0 - https://github.com/pydantic/pydantic-settings/issues/445
"pydantic_settings~=2.0,!=2.6.0",
"pydantic>=2.7.1,<3,!=2.9.0",
"pygithub~=2.1",
"pyjson5~=1.6",
Expand Down
3 changes: 2 additions & 1 deletion src/hexdoc/_templates/pages/patchouli/spotlight.html.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
{% import "macros/images.html.jinja" as Images with context -%}

{% block inner_body %}
<h4 class="spotlight-title page-header">{{ page.item.name }}</h4>
<div class="spotlight">
{{ Images.load_texture("hexdoc:textures/gui/spotlight.png", "Spotlight inventory slot") }}
{{ Images.item(page.item) }}
Expand All @@ -13,3 +12,5 @@
{% block redirect_image -%}
{{ Images.url(page.item) }}
{%- endblock redirect_image %}

{% block title_attrs %} class="spotlight-title page-header"{% endblock title_attrs %}
55 changes: 55 additions & 0 deletions src/hexdoc/core/resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,22 @@

from __future__ import annotations

import json
import logging
import re
from fnmatch import fnmatch
from pathlib import Path
from typing import Annotated, Any, ClassVar, Literal, Self, TypeVar

from nbtlib import (
Compound,
Path as NBTPath,
parse_nbt, # pyright: ignore[reportUnknownVariableType]
)
from pydantic import (
BeforeValidator,
ConfigDict,
JsonValue,
TypeAdapter,
field_validator,
model_serializer,
Expand All @@ -21,6 +28,7 @@
from pydantic.config import JsonDict
from pydantic.dataclasses import dataclass
from pydantic.functional_validators import ModelWrapValidatorHandler
from pydantic.json_schema import SkipJsonSchema
from typing_extensions import override

from hexdoc.model import DEFAULT_CONFIG
Expand Down Expand Up @@ -86,6 +94,7 @@ def resloc_json_schema_extra(
config=DEFAULT_CONFIG
| ConfigDict(
json_schema_extra=resloc_json_schema_extra,
arbitrary_types_allowed=True,
),
)
class BaseResourceLocation:
Expand Down Expand Up @@ -257,9 +266,40 @@ class ItemStack(BaseResourceLocation, regex=_make_regex(count=True, nbt=True)):
count: int | None = None
nbt: str | None = None

_data: SkipJsonSchema[Compound | None] = None

def __init_subclass__(cls, **kwargs: Any):
super().__init_subclass__(regex=cls._from_str_regex, **kwargs)

def __post_init__(self):
object.__setattr__(self, "_data", _parse_nbt(self.nbt))

@property
def data(self):
return self._data

def get_name(self) -> str | None:
if self.data is None:
return None

component_json = self.data.get(NBTPath("display.Name")) # pyright: ignore[reportUnknownVariableType, reportUnknownMemberType]
if not isinstance(component_json, str):
return None

try:
component: JsonValue = json.loads(component_json)
except ValueError:
return None

if not isinstance(component, dict):
return None

name = component.get("text")
if not isinstance(name, str):
return None

return name

@override
def i18n_key(self, root: str = "item") -> str:
return super().i18n_key(root)
Expand Down Expand Up @@ -302,3 +342,18 @@ def _add_hashtag_to_tag(value: Any):
AssumeTag = Annotated[_T, BeforeValidator(_add_hashtag_to_tag)]
"""Validator that adds `#` to the start of strings, and sets `ResourceLocation.is_tag`
to `True`."""


def _parse_nbt(nbt: str | None) -> Compound | None:
if nbt is None:
return None

try:
result = parse_nbt(nbt)
except ValueError as e:
raise ValueError(f"Failed to parse sNBT literal '{nbt}': {e}") from e

if not isinstance(result, Compound):
raise ValueError(f"Expected Compound, got {type(result)}: {result}")

return result
2 changes: 1 addition & 1 deletion src/hexdoc/core/resource_dir.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ def _json_schema_extra(schema: dict[str, Any]):
path: RelativePath
"""A path relative to `hexdoc.toml`."""

archive: bool = Field(default=None, validate_default=False)
archive: bool = Field(default=None, validate_default=False) # type: ignore
"""If true, treat this path as a zip archive (eg. a mod's `.jar` file).
If `path` ends with `.jar` or `.zip`, defaults to `True`.
Expand Down
6 changes: 5 additions & 1 deletion src/hexdoc/graphics/validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from yarl import URL

from hexdoc.core import I18n, ItemStack, LocalizedStr, Properties, ResourceLocation
from hexdoc.core.i18n import LocalizedItem
from hexdoc.model import (
InlineItemModel,
InlineModel,
Expand Down Expand Up @@ -176,7 +177,10 @@ def load_id(cls, item: ItemStack, context: dict[str, Any]) -> Any:

@override
def _get_name(self, info: ValidationInfo):
return I18n.of(info).localize_item(self.id)
# TODO: i'm not sure if this is really the right place to put this
if (name := self.item.get_name()) is not None:
return LocalizedItem.with_value(name)
return I18n.of(info).localize_item(self.item)


class CyclingImage(HexdocImage, template_id="hexdoc:cycling"):
Expand Down
9 changes: 7 additions & 2 deletions src/hexdoc/patchouli/page/pages.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from collections import defaultdict
from typing import Self

from pydantic import ValidationInfo, field_validator, model_validator
from pydantic import Field, ValidationInfo, field_validator, model_validator

from hexdoc.core import Entity, LocalizedStr, ResourceLocation
from hexdoc.graphics import ImageField, ItemImage, TextureImage
Expand Down Expand Up @@ -163,6 +163,11 @@ class StonecuttingPage(
pass


class SpotlightPage(PageWithTitle, type="patchouli:spotlight"):
class SpotlightPage(PageWithText, type="patchouli:spotlight"):
title_field: LocalizedStr | None = Field(default=None, alias="title")
item: ImageField[ItemImage]
link_recipe: bool = False

@property
def title(self) -> LocalizedStr | None:
return self.title_field or self.item.name
4 changes: 2 additions & 2 deletions src/hexdoc_modonomicon/book.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ class Modonomicon(HexdocModel):

tooltip: LocalizedStr | None = None
generate_book_item: bool = True
model: ResourceLocation | None = Field("modonomicon:modonomicon_purple")
model: ResourceLocation | None = Field("modonomicon:modonomicon_purple") # type: ignore
custom_book_item: ResourceLocation | None = None
creative_tab: str = "misc"
default_title_color: int = 0
Expand All @@ -33,7 +33,7 @@ class Modonomicon(HexdocModel):
search_button_x_offset: int = 0
search_button_y_offset: int = 0
read_all_button_y_offset: int = 0
turn_page_sound: ResourceLocation = Field("minecraft:turn_page")
turn_page_sound: ResourceLocation = Field("minecraft:turn_page") # type: ignore

@model_validator(mode="after")
def _validate_constraints(self):
Expand Down
64 changes: 57 additions & 7 deletions test/core/test_resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,50 +23,100 @@ def test_resourcelocation(s: str, expected: ResourceLocation, str_prefix: str):
assert str(actual) == str_prefix + s


item_stacks: list[tuple[str, ItemStack, str]] = [
item_stacks: list[tuple[str, ItemStack, str, str | None]] = [
(
"stone",
ItemStack("minecraft", "stone", None, None),
"minecraft:",
None,
),
(
"hexcasting:patchouli_book",
ItemStack("hexcasting", "patchouli_book", None, None),
"",
None,
),
(
"minecraft:stone#64",
ItemStack("minecraft", "stone", 64, None),
"",
None,
),
(
"minecraft:diamond_pickaxe{display:{Lore:['A really cool pickaxe']}",
"minecraft:diamond_pickaxe{display:{Lore:['A really cool pickaxe']}}",
ItemStack(
"minecraft",
"diamond_pickaxe",
None,
"{display:{Lore:['A really cool pickaxe']}",
"{display:{Lore:['A really cool pickaxe']}}",
),
"",
None,
),
(
"minecraft:diamond_pickaxe#64{display:{Lore:['A really cool pickaxe']}",
"minecraft:diamond_pickaxe#64{display:{Lore:['A really cool pickaxe']}}",
ItemStack(
"minecraft",
"diamond_pickaxe",
64,
"{display:{Lore:['A really cool pickaxe']}",
"{display:{Lore:['A really cool pickaxe']}}",
),
"",
None,
),
(
"""minecraft:diamond_pickaxe{display:{Name:'{"text": "foo"}'}}""",
ItemStack(
"minecraft",
"diamond_pickaxe",
None,
"""{display:{Name:'{"text": "foo"}'}}""",
),
"",
"foo",
),
(
"""minecraft:diamond_pickaxe{display:{Name:'{"text": "foo}'}}""",
ItemStack(
"minecraft",
"diamond_pickaxe",
None,
"""{display:{Name:'{"text": "foo}'}}""",
),
"",
None,
),
(
"""minecraft:diamond_pickaxe{displayy:{Name:'{"text": "foo"}'}}""",
ItemStack(
"minecraft",
"diamond_pickaxe",
None,
"""{displayy:{Name:'{"text": "foo"}'}}""",
),
"",
None,
),
(
"""minecraft:diamond_pickaxe{display:{Namee:'{"text": "foo"}'}}""",
ItemStack(
"minecraft",
"diamond_pickaxe",
None,
"""{display:{Namee:'{"text": "foo"}'}}""",
),
"",
None,
),
]


@pytest.mark.parametrize("s,expected,str_prefix", item_stacks)
def test_itemstack(s: str, expected: ItemStack, str_prefix: str):
@pytest.mark.parametrize("s,expected,str_prefix,name", item_stacks)
def test_itemstack(s: str, expected: ItemStack, str_prefix: str, name: str | None):
actual = ItemStack.from_str(s)
assert actual == expected
assert str(actual) == str_prefix + s
assert actual.get_name() == name


@pytest.mark.parametrize(
Expand Down
Loading

0 comments on commit 4f548f7

Please sign in to comment.