Update docgen to 0.11, mostly

This commit is contained in:
object-Object 2023-08-06 19:27:52 -04:00
parent b91b37b70a
commit 3e2afd13a3
29 changed files with 299 additions and 186 deletions

View file

@ -1,8 +1,9 @@
name: Build the Python doc-gen
name: Build the web book
on:
push:
branches: [main]
workflow_dispatch:
jobs:
build_docs:
@ -11,19 +12,30 @@ jobs:
contents: write
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
with:
python-version: "3.11"
- name: Install docgen
run: pip install ./doc
- name: Generate file
run: doc/collate_data.py Common/src/main/resources hexcasting thehexbook doc/template.html index.html.uncommitted
run: hexdoc doc/properties.toml -o index.html.uncommitted
- name: Check out gh-pages
uses: actions/checkout@v3
with:
clean: false
ref: gh-pages
- name: Overwrite file and commmit
run: |
mv index.html.uncommitted index.html
git config user.name "Documentation Generation Bot"
git config user.name "HexDoc Bot"
git config user.email "noreply@github.com"
git add index.html
git diff-index --quiet HEAD || git commit -m "Update docs at index.html from $GITHUB_REF"
git diff-index --quiet HEAD || git commit -m "Update web book from $GITHUB_REF"
- name: Upload changes
run: git push

4
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,4 @@
{
"python.analysis.exclude": ["**"],
"python.analysis.indexing": false,
}

View file

@ -2,6 +2,5 @@
"name": "hexcasting.category.greatwork",
"description": "hexcasting.category.greatwork.desc",
"icon": "minecraft:music_disc_11",
"sortnum": 3,
"entry_color": "54398a"
"sortnum": 3
}

View file

@ -16,7 +16,7 @@
},
{
"type": "patchouli:crafting",
"recipe": "hexcasting:empty_directrix",
"recipe": "hexcasting:directrix/empty",
"text": "hexcasting.page.directrix.empty_directrix"
},
{

View file

@ -1,7 +1,7 @@
{
"name": "hexcasting.entry.fanciful_staves",
"category": "hexcasting:greatwork",
"icon": "hexcasting:mindsplice_staff",
"icon": "hexcasting:staff/mindsplice",
"sortnum": 6,
"advancement": "hexcasting:enlightenment",
"entry_color": "54398a",
@ -12,8 +12,8 @@
},
{
"type": "patchouli:crafting",
"recipe": "hexcasting:quenched_staff",
"recipe2": "hexcasting:mindsplice_staff"
"recipe": "hexcasting:staff/quenched",
"recipe2": "hexcasting:staff/mindsplice"
}
]
}

View file

@ -24,7 +24,7 @@
},
{
"type": "patchouli:crafting",
"recipe": "hexcasting:empty_impetus",
"recipe": "hexcasting:impetus/empty",
"text": "hexcasting.page.impetus.empty_impetus"
},
{

View file

@ -15,8 +15,7 @@
},
{
"type": "patchouli:crafting",
"recipe": "hexcasting:edified_planks",
"recipe2": "hexcasting:edified_wood"
"recipe": "hexcasting:edified_planks"
},
{
"type": "patchouli:crafting",

View file

@ -38,8 +38,8 @@
"hexcasting:dye_colorizer_red",
"hexcasting:dye_colorizer_white",
"hexcasting:dye_colorizer_yellow",
"hexcasting:empty_directrix",
"hexcasting:empty_impetus",
"hexcasting:directrix/empty",
"hexcasting:impetus/empty",
"hexcasting:focus",
"hexcasting:jeweler_hammer",
"hexcasting:lens",

View file

@ -9,5 +9,7 @@
"path": "../.."
}
],
"settings": {}
"settings": {
"python.languageServer": "Pylance",
}
}

View file

@ -4,25 +4,21 @@ modid = "hexcasting"
book_name = "thehexbook"
is_0_black = false
recipe_dirs = [
"{fabric.generated}/data/{modid}/recipes",
"{forge.generated}/data/{modid}/recipes",
]
default_recipe_dir = 0
# NOTE: _Raw means "don't apply variable interpolation to this value"
pattern_regex = {_Raw='HexPattern\.fromAngles\("([qweasd]+)", HexDir\.(\w+)\),\s*modLoc\("([^"]+)"\)([^;]*true\);)?'}
pattern_regex = {_Raw='make\(\s*"(?P<name>[a-zA-Z0-9_\/]+)",\s*(?:new )?(?:ActionRegistryEntry|OperationAction)\(\s*HexPattern\.fromAngles\(\s*"(?P<signature>[aqweds]+)",\s*HexDir.(?P<startdir>\w+)\)'}
template = "main.html.jinja"
template_dirs = []
template_packages = []
spoilers = [
spoilered_advancements = [
"hexcasting:opened_eyes",
"hexcasting:y_u_no_cast_angy",
"hexcasting:enlightenment",
"hexcasting:lore/*",
]
blacklist = []
entry_id_blacklist = []
[template_args]
title = "Hex Book"
@ -33,42 +29,56 @@ icon_href = "icon.png"
is_bleeding_edge = true
show_landing_text = true
[base_asset_urls]
hexcasting = "https://raw.githubusercontent.com/gamma-delta/HexMod/main/Common/src/main/resources"
[i18n]
default_lang = "en_us"
filename = "{default_lang}.json"
[i18n.extra]
"item.minecraft.amethyst_shard" = "Amethyst Shard"
"item.minecraft.budding_amethyst" = "Budding Amethyst"
"block.hexcasting.slate" = "Blank Slate"
filename = "{default_lang}.flatten.json5"
[i18n.extra]
"item.minecraft.amethyst_shard" = "Amethyst Shard"
"item.minecraft.budding_amethyst" = "Budding Amethyst"
"block.hexcasting.slate" = "Blank Slate"
[i18n.keys]
use = "Right Click"
sneak = "Left Shift"
jump = "Space"
# platforms
[common]
src = "../Common/src"
# NOTE: {...} is variable interpolation from the current table
package = "{src}/main/java/at/petrak/hexcasting"
resources = "{src}/main/resources"
generated = "{src}/generated/resources"
pattern_stubs = [
"{package}/common/casting/RegisterPatterns.java",
"{package}/interop/pehkui/PehkuiInterop.java",
]
[[common.pattern_stubs]]
path = "{^package}/common/lib/hex/HexActions.java"
regex = "{^^pattern_regex}"
[fabric]
src = "../Fabric/src"
package = "{src}/main/java/at/petrak/hexcasting/fabric"
resources = "{src}/main/resources"
generated = "{src}/generated/resources"
pattern_stubs = [
"{package}/interop/gravity/GravityApiInterop.java",
]
recipes = "{generated}/data/{^modid}/recipes"
tags = "{generated}/data/{^modid}/tags"
[[fabric.pattern_stubs]]
path = "{^package}/FabricHexInitializer.kt"
regex = "{^^pattern_regex}"
[forge]
src = "../Forge/src"
package = "{src}/main/java/at/petrak/hexcasting/forge"
resources = "{src}/main/resources"
generated = "{src}/generated/resources"
recipes = "{generated}/data/{^modid}/recipes"
tags = "{generated}/data/{^modid}/tags"

View file

@ -14,9 +14,9 @@ readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"typing_extensions~=4.7.0",
"typed-argument-parser~=1.8.0",
"pydantic~=2.1.1",
"Jinja2~=3.1.2",
"pyjson5~=1.6.3",
]
[project.optional-dependencies]
@ -47,6 +47,9 @@ hexdoc-hexcasting = "hexdoc.hexcasting.hex_recipes"
hexdoc-minecraft = "hexdoc.minecraft.recipe.ingredients"
hexdoc-hexcasting = "hexdoc.hexcasting.hex_recipes"
[project.entry-points."hexdoc.BrainsweepeeIngredient"]
hexdoc-hexcasting = "hexdoc.hexcasting.hex_recipes"
[tool.hatch.build]
packages = ["src/hexdoc"]

View file

@ -6,7 +6,7 @@
{{ category.description|hexdoc_block }}
{% endcall %}
{% for entry in category.entries if entry.id not in props.blacklist +%}
{% for entry in category.entries if entry.id not in props.entry_id_blacklist +%}
{% include "entry.html.jinja" %}
{% endfor +%}
</section>

View file

@ -1,8 +1,9 @@
from pathlib import Path
import logging
from typing import Any, Generic, TypeVar
from hexdoc.patchouli import AnyBookContext, Book, BookContext
from hexdoc.utils import AnyContext, Properties, ResourceLocation
from hexdoc.utils.properties import PatternStubProps
from .pattern import Direction, PatternInfo
@ -25,6 +26,7 @@ class HexBookType(
# load patterns
patterns = dict[ResourceLocation, PatternInfo]()
signatures = dict[str, PatternInfo]() # just for duplicate checking
for stub in props.pattern_stubs:
# for each stub, load all the patterns in the file
for pattern in cls.load_patterns(stub, props):
@ -35,9 +37,12 @@ class HexBookType(
raise ValueError(
f"Duplicate pattern {pattern.id}\n{pattern}\n{duplicate}"
)
patterns[pattern.id] = pattern
signatures[pattern.signature] = pattern
logging.getLogger(__name__).debug(f"Patterns: {patterns.keys()}")
# build new context
return data, {
**context,
@ -45,16 +50,16 @@ class HexBookType(
}
@classmethod
def load_patterns(cls, path: Path, props: Properties):
def load_patterns(cls, stub: PatternStubProps, props: Properties):
# TODO: add Gradle task to generate json with this data. this is dumb and fragile.
stub_text = path.read_text("utf-8")
for match in props.pattern_regex.finditer(stub_text):
signature, startdir, name, is_per_world = match.groups()
stub_text = stub.path.read_text("utf-8")
for match in stub.regex.finditer(stub_text):
groups = match.groupdict()
yield PatternInfo(
startdir=Direction[startdir],
signature=signature,
is_per_world=bool(is_per_world),
id=ResourceLocation(props.modid, name),
startdir=Direction[groups["startdir"]],
signature=groups["signature"],
# is_per_world=bool(is_per_world), # FIXME: idfk how to do this now
id=ResourceLocation(props.modid, groups["name"]),
)

View file

@ -6,21 +6,39 @@ from hexdoc.minecraft.recipe import (
MinecraftItemIdIngredient,
MinecraftItemTagIngredient,
)
from hexdoc.utils import HexDocModel, ResourceLocation
from hexdoc.utils import HexDocModel, ResourceLocation, TypeTaggedUnion
from hexdoc.utils.model import AnyContext
from .hex_book import HexContext
# ingredients
class VillagerIngredient(HexDocModel[HexContext]): # lol, lmao
class BrainsweepeeIngredient(
TypeTaggedUnion[AnyContext],
group="hexdoc.BrainsweepeeIngredient",
type=None,
):
pass
# lol, lmao
class VillagerIngredient(BrainsweepeeIngredient[HexContext], type="villager"):
minLevel: int
profession: ResourceLocation | None = None
biome: ResourceLocation | None = None
class EntityTypeIngredient(BrainsweepeeIngredient[HexContext], type="entity_type"):
entityType: ResourceLocation
class EntityTagIngredient(BrainsweepeeIngredient[HexContext], type="entity_tag"):
tag: ResourceLocation
class BlockStateIngredient(HexDocModel[HexContext]):
# TODO: StateIngredient should also be a TypeTaggedUnion, probably
# TODO: tagged union
type: Literal["block"]
block: ResourceLocation
@ -53,5 +71,6 @@ class BlockState(HexDocModel[HexContext]):
class BrainsweepRecipe(Recipe[HexContext], type="hexcasting:brainsweep"):
blockIn: BlockStateIngredient
villagerIn: VillagerIngredient
cost: int
entityIn: BrainsweepeeIngredient[HexContext]
result: BlockState

View file

@ -1,7 +1,8 @@
# because Tap.add_argument isn't typed, for some reason
# pyright: reportUnknownMemberType=false
import logging
from argparse import ArgumentParser
from dataclasses import dataclass
from pathlib import Path
from typing import Self, Sequence
from jinja2 import (
ChoiceLoader,
@ -11,35 +12,53 @@ from jinja2 import (
StrictUndefined,
)
# from jinja2.sandbox import SandboxedEnvironment
from tap import Tap
from hexdoc.hexcasting import HexBook
from hexdoc.utils import Properties
from .jinja_extensions import IncludeRawExtension, hexdoc_block, hexdoc_wrap
# TODO: enable
# from jinja2.sandbox import SandboxedEnvironment
def strip_empty_lines(text: str) -> str:
return "\n".join(s for s in text.splitlines() if s.strip())
# CLI arguments
class Args(Tap):
@dataclass
class Args:
"""example: main.py properties.toml -o out.html"""
properties_file: Path
output_file: Path | None
verbose: bool
def configure(self):
self.add_argument("properties_file")
self.add_argument("-o", "--output_file", required=False)
@classmethod
def parse_args(cls, args: Sequence[str] | None = None) -> Self:
parser = ArgumentParser()
parser.add_argument("properties_file", type=Path)
parser.add_argument("--output_file", "-o", type=Path)
parser.add_argument("--verbose", "-v", action="store_true")
return cls(**vars(parser.parse_args(args)))
def main(args: Args | None = None) -> None:
# allow passing Args for test cases, but parse by default
if args is None:
args = Args().parse_args()
args = Args.parse_args()
logging.basicConfig(
style="{",
format="[{levelname}][{name}] {message}",
)
logger = logging.getLogger(__name__)
if args.verbose:
logging.getLogger().setLevel(logging.DEBUG)
logger.debug("Log level set to DEBUG")
# load the properties and book
props = Properties.load(args.properties_file)
@ -60,7 +79,7 @@ def main(args: Args | None = None) -> None:
autoescape=True,
extensions=[IncludeRawExtension],
)
env.filters |= dict( # for some reason, pylance doesn't like the {} here
env.filters |= dict( # pyright: ignore[reportUnknownMemberType]
hexdoc_block=hexdoc_block,
hexdoc_wrap=hexdoc_wrap,
)

View file

@ -16,7 +16,7 @@ from hexdoc.utils import (
Properties,
ResourceLocation,
)
from hexdoc.utils.deserialize import isinstance_or_raise, load_json_dict
from hexdoc.utils.deserialize import isinstance_or_raise, load_and_flatten_json_dict
class I18nContext(TypedDict):
@ -53,7 +53,6 @@ class LocalizedStr(HexDocModel[I18nContext]):
if not isinstance(value, str):
return handler(value)
# this is nasty, but we need to use cast to get type checking for context
context = cast(I18nContext, info.context)
return cls._localize(context["i18n"], value)
@ -113,24 +112,16 @@ class I18n:
# TODO: load ALL of the i18n files, return dict[str, _Lookup] | None
# or maybe dict[(str, str), LocalizedStr]
# we could also use that to ensure all i18n files have the same set of keys
lang_dir = props.resources_dir / "assets" / props.modid / "lang"
path = lang_dir / props.i18n.filename
raw_lookup = load_json_dict(path) | (props.i18n.extra or {})
path = props.assets_dir / "lang" / props.i18n.filename
raw_lookup = load_and_flatten_json_dict(path) | props.i18n.extra
# validate and insert
self.lookup = {}
for key, raw_value in raw_lookup.items():
assert isinstance_or_raise(raw_value, str)
self.lookup[key] = LocalizedStr(
key=key,
value=raw_value.replace("%%", "%"),
)
self.lookup = {
key: LocalizedStr(key=key, value=value.replace("%%", "%"))
for key, value in raw_lookup.items()
}
def localize(
self,
*keys: str,
default: str | None = None,
) -> LocalizedStr:
def localize(self, *keys: str, default: str | None = None) -> LocalizedStr:
"""Looks up the given string in the lang table if i18n is enabled. Otherwise,
returns the original key.
@ -170,8 +161,8 @@ class I18n:
# prefer the book-specific translation if it exists
# TODO: should this use op_id.namespace anywhere?
return self.localize(
f"hexcasting.spell.book.{op_id}",
f"hexcasting.spell.{op_id}",
f"hexcasting.action.book.{op_id}",
f"hexcasting.action.{op_id}",
)
def localize_item(self, item: ItemStack | str) -> LocalizedItem:

View file

@ -1,3 +1,4 @@
import logging
from typing import Any, Self, cast
from pydantic import ValidationInfo, model_validator
@ -10,6 +11,7 @@ class Recipe(TypeTaggedUnion[AnyPropsContext], group="hexdoc.Recipe", type=None)
id: ResourceLocation
group: str | None = None
category: str | None = None
@model_validator(mode="before")
def _pre_root(
@ -30,15 +32,17 @@ class Recipe(TypeTaggedUnion[AnyPropsContext], group="hexdoc.Recipe", type=None)
# load the recipe
context = cast(AnyPropsContext, info.context)
for recipe_dir in context["props"].recipe_dirs:
# TODO: should this use id.namespace somewhere?
path = recipe_dir / f"{id.path}.json"
if recipe_dir == context["props"].default_recipe_dir:
# only load from one file
values = load_json_dict(path) | {"id": id}
elif not path.exists():
# this is to ensure the recipe at least exists on all platforms
# because we've had issues with that before (eg. Hexal's Mote Nexus)
raise ValueError(f"Recipe {id} missing from path {path}")
return values
# TODO: this is ugly and not super great for eg. hexbound
forge_path = context["props"].forge.recipes / f"{id.path}.json"
fabric_path = context["props"].fabric.recipes / f"{id.path}.json"
# this is to ensure the recipe at least exists on all platforms
# because we've had issues with that before (eg. Hexal's Mote Nexus)
if not forge_path.exists():
raise ValueError(f"Recipe {id} missing from path {forge_path}")
logging.getLogger(__name__).debug(
f"Load {cls}\n id: {id}\n path: {fabric_path}"
)
return load_json_dict(fabric_path) | {"id": id}

View file

@ -16,9 +16,10 @@ class CraftingShapedRecipe(
Recipe[Any],
type="minecraft:crafting_shaped",
):
pattern: list[str]
key: dict[str, ItemIngredientOrList[Any]]
pattern: list[str]
result: ItemResult
show_notification: bool
class CraftingShapelessRecipe(

View file

@ -40,6 +40,7 @@ class Book(Generic[AnyContext, AnyBookContext], HexDocModel[AnyBookContext]):
# required
name: LocalizedStr
landing_text: FormatTree
use_resource_pack: Literal[True]
# optional
book_texture: ResourceLocation = ResLoc("patchouli", "textures/gui/book_brown.png")
@ -60,7 +61,7 @@ class Book(Generic[AnyContext, AnyBookContext], HexDocModel[AnyBookContext]):
show_progress: bool = True
version: str | int = 0
subtitle: LocalizedStr | None = None
creative_tab: str = "misc" # TODO: this was changed in 1.19.3+, and again in 1.20
creative_tab: str | None = None
advancements_tab: str | None = None
dont_generate_book: bool = False
custom_book_item: ItemStack | None = None
@ -70,14 +71,11 @@ class Book(Generic[AnyContext, AnyBookContext], HexDocModel[AnyBookContext]):
macros: dict[str, str] = Field(default_factory=dict)
pause_game: bool = False
text_overflow_mode: Literal["overflow", "resize", "truncate"] | None = None
extend: ResourceLocation | None = None
allow_extensions: bool = True
@classmethod
def prepare(cls, props: Properties) -> tuple[dict[str, Any], BookContext]:
# read the raw dict from the json file
path = props.book_dir / "book.json"
data = load_json_dict(path)
data = load_json_dict(props.book_path)
# set up the deserialization context object
assert isinstance_or_raise(data["i18n"], bool)

View file

@ -52,5 +52,8 @@ class Entry(BookFileModel[BookContext, BookContext], Sortable):
if not context or self.advancement is None:
return self
self.is_spoiler = self.advancement in context["props"].spoilers
self.is_spoiler = any(
self.advancement.match(spoiler)
for spoiler in context["props"].spoilered_advancements
)
return self

View file

@ -14,7 +14,7 @@ from pydantic.functional_validators import ModelWrapValidatorHandler
from hexdoc.minecraft import LocalizedStr
from hexdoc.minecraft.i18n import I18nContext
from hexdoc.utils import DEFAULT_CONFIG, HexDocModel, PropsContext
from hexdoc.utils import DEFAULT_CONFIG, HexDocModel, Properties, PropsContext
from hexdoc.utils.types import TryGetEnum
from .html import HTMLElement, HTMLStream
@ -61,11 +61,6 @@ _COLORS = {
"f": "fff",
}
_KEYS = {
"use": "Right Click",
"sneak": "Left Shift",
}
# Higgledy piggledy
# Old fuck Alwinfy said,
@ -116,7 +111,7 @@ class Style(ABC, HexDocModel[Any], frozen=True):
type: CommandStyleType | FunctionStyleType | SpecialStyleType
@staticmethod
def parse(style_str: str, is_0_black: bool) -> Style | _CloseTag | str:
def parse(style_str: str, props: Properties) -> Style | _CloseTag | str:
# direct text replacements
if style_str in _REPLACEMENTS:
return _REPLACEMENTS[style_str]
@ -130,7 +125,7 @@ class Style(ABC, HexDocModel[Any], frozen=True):
return CommandStyle(type=style_type)
# reset color, but only if 0 is considered reset instead of black
if not is_0_black and style_str == "0":
if not props.is_0_black and style_str == "0":
return _CloseTag(type=SpecialStyleType.color)
# preset colors
@ -146,9 +141,8 @@ class Style(ABC, HexDocModel[Any], frozen=True):
name, value = style_str.split(":", 1)
# keys
if name == "k":
if value in _KEYS:
return _KEYS[value]
if name == "k" and (key := props.i18n.keys.get(value)):
return key
# all the other functions
if style_type := FunctionStyleType.get(name):
@ -265,7 +259,7 @@ class FormatTree:
children: list[FormatTree | str] # this can't be Self, it breaks Pydantic
@classmethod
def format(cls, string: str, macros: dict[str, str], is_0_black: bool) -> Self:
def format(cls, string: str, macros: dict[str, str], props: Properties) -> Self:
# resolve macros
# TODO: use ahocorasick? this feels inefficient
old_string = None
@ -286,7 +280,7 @@ class FormatTree:
text_since_prev_style.append(leading_text)
last_end = match.end()
match Style.parse(match[1], is_0_black):
match Style.parse(match[1], props):
case str(replacement):
# str means "use this instead of the original value"
text_since_prev_style.append(replacement)
@ -347,4 +341,4 @@ class FormatTree:
if isinstance(value, str):
value = context["i18n"].localize(value)
return cls.format(value.value, context["macros"], context["props"].is_0_black)
return cls.format(value.value, context["macros"], context["props"])

View file

@ -1,6 +1,5 @@
__all__ = [
"HexDocModel",
"FrozenHexDocModel",
"HexDocFileModel",
"InternallyTaggedUnion",
"Color",
@ -19,13 +18,7 @@ __all__ = [
"TypeTaggedUnion",
]
from .model import (
DEFAULT_CONFIG,
AnyContext,
FrozenHexDocModel,
HexDocFileModel,
HexDocModel,
)
from .model import DEFAULT_CONFIG, AnyContext, HexDocFileModel, HexDocModel
from .properties import AnyPropsContext, Properties, PropsContext
from .resource import Entity, ItemStack, ResLoc, ResourceLocation
from .tagged_union import (

View file

@ -1,8 +1,11 @@
import json
import re
from pathlib import Path
from typing import Any, TypeGuard, TypeVar, get_origin
import pyjson5
_T = TypeVar("_T")
_T_cov = TypeVar("_T_cov", covariant=True)
_DEFAULT_MESSAGE = "Expected any of {expected}, got {actual}: {value}"
@ -39,6 +42,50 @@ JSONValue = JSONDict | list["JSONValue"] | str | int | float | bool | None
def load_json_dict(path: Path) -> JSONDict:
data: JSONValue = json.loads(path.read_text("utf-8"))
data = pyjson5.decode(path.read_text("utf-8"))
assert isinstance_or_raise(data, dict)
return data
# implement pkpcpbp's flattening in python
# https://github.com/gamma-delta/PKPCPBP/blob/786194a590f/src/main/java/at/petrak/pkpcpbp/filters/JsonUtil.java
def load_and_flatten_json_dict(path: Path) -> dict[str, str]:
# load file, replace `\<LF> foobar` with `\<LF>foobar`
json_str = re.sub(r"\\\n\s*", "\\\n", path.read_text("utf-8"))
# decode json5 and flatten
data = pyjson5.decode(json_str)
assert isinstance_or_raise(data, JSONDict)
return _flatten_inner(data, "")
def _flatten_inner(obj: JSONDict, prefix: str) -> dict[str, str]:
out: dict[str, str] = {}
for key_stub, value in obj.items():
if not prefix:
key = key_stub
elif not key_stub:
key = prefix
elif prefix[-1] in ":_-/":
key = prefix + key_stub
else:
key = f"{prefix}.{key_stub}"
match value:
case dict():
_update_disallow_duplicates(out, _flatten_inner(value, key))
case str():
_update_disallow_duplicates(out, {key: value})
case _:
raise TypeError(value)
return out
def _update_disallow_duplicates(base: dict[str, _T_cov], new: dict[str, _T_cov]):
for key, value in new.items():
if key in base:
raise ValueError(f"Duplicate key {key}\nold=`{base[key]}`\nnew=`{value}`")
base[key] = value

View file

@ -1,7 +1,9 @@
import logging
from pathlib import Path
from typing import TYPE_CHECKING, Any, Generic, Self, TypeVar, dataclass_transform
from pydantic import BaseModel, ConfigDict
from pydantic.config import ConfigDict
from typing_extensions import TypedDict
from .deserialize import load_json_dict
@ -47,14 +49,14 @@ class HexDocModel(Generic[AnyContext], BaseModel):
...
@dataclass_transform(frozen_default=True)
class FrozenHexDocModel(Generic[AnyContext], HexDocModel[AnyContext]):
model_config = DEFAULT_CONFIG | {"frozen": True}
@dataclass_transform()
class HexDocFileModel(HexDocModel[AnyContext]):
@classmethod
def load(cls, path: Path, context: AnyContext) -> Self:
logging.getLogger(__name__).debug(f"Load {cls}\n path: {path}")
data = load_json_dict(path) | {"__path": path}
return cls.model_validate(data, context=context)
try:
return cls.model_validate(data, context=context)
except Exception as e:
e.add_note(f"File: {path}")
raise

View file

@ -4,18 +4,12 @@ import re
from pathlib import Path
from typing import Annotated, Any, Self, TypeVar
from pydantic import (
AfterValidator,
Field,
FieldValidationInfo,
HttpUrl,
field_validator,
)
from pydantic import AfterValidator, Field, HttpUrl
from typing_extensions import TypedDict
from .model import HexDocModel
from .resource import ResourceLocation
from .toml_placeholders import load_toml
from .toml_placeholders import load_toml_with_placeholders
NoTrailingSlashHttpUrl = Annotated[
str,
@ -24,38 +18,43 @@ NoTrailingSlashHttpUrl = Annotated[
]
class PlatformProps(HexDocModel[Any]):
resources: Path
generated: Path
class PatternStubProps(HexDocModel[Any], extra="ignore"):
path: Path
regex: re.Pattern[str]
class XplatProps(HexDocModel[Any], extra="ignore"):
src: Path
package: Path
pattern_stubs: list[Path] | None = None
pattern_stubs: list[PatternStubProps] | None = None
resources: Path
class I18nProps(HexDocModel[Any]):
class PlatformProps(XplatProps):
recipes: Path
tags: Path
class I18nProps(HexDocModel[Any], extra="ignore"):
default_lang: str
filename: str
extra: dict[str, str] | None = None
extra: dict[str, str] = Field(default_factory=dict)
keys: dict[str, str] = Field(default_factory=dict)
class Properties(HexDocModel[Any]):
class Properties(HexDocModel[Any], extra="ignore"):
modid: str
book_name: str
is_0_black: bool
"""If true, the style `$(0)` changes the text color to black; otherwise it resets
the text color to the default."""
recipe_dirs: list[Path]
default_recipe_dir_index_: int = Field(alias="default_recipe_dir")
pattern_regex: re.Pattern[str]
template: str
template_dirs: list[Path]
template_packages: list[tuple[str, Path]]
spoilers: set[ResourceLocation]
blacklist: set[ResourceLocation]
spoilered_advancements: set[ResourceLocation]
entry_id_blacklist: set[ResourceLocation]
template_args: dict[str, Any]
@ -64,13 +63,13 @@ class Properties(HexDocModel[Any]):
i18n: I18nProps
common: PlatformProps
common: XplatProps
fabric: PlatformProps # TODO: some way to make these optional for addons
forge: PlatformProps
@classmethod
def load(cls, path: Path) -> Self:
return cls.model_validate(load_toml(path))
return cls.model_validate(load_toml_with_placeholders(path))
@property
def resources_dir(self):
@ -81,30 +80,37 @@ class Properties(HexDocModel[Any]):
return self.i18n.default_lang
@property
def book_dir(self) -> Path:
"""eg. `resources/data/hexcasting/patchouli_books/thehexbook`"""
def book_path(self) -> Path:
"""eg. `resources/data/hexcasting/patchouli_books/thehexbook/book.json`"""
return (
self.resources_dir
/ "data"
/ self.modid
/ "patchouli_books"
/ self.book_name
/ "book.json"
)
@property
def assets_dir(self) -> Path:
"""eg. `resources/assets/hexcasting`"""
return self.resources_dir / "assets" / self.modid
@property
def book_assets_dir(self) -> Path:
"""eg. `resources/assets/hexcasting/patchouli_books/thehexbook`"""
return self.assets_dir / "patchouli_books" / self.book_name
@property
def categories_dir(self) -> Path:
return self.book_dir / self.lang / "categories"
return self.book_assets_dir / self.lang / "categories"
@property
def entries_dir(self) -> Path:
return self.book_dir / self.lang / "entries"
return self.book_assets_dir / self.lang / "entries"
@property
def default_recipe_dir(self) -> Path:
return self.recipe_dirs[self.default_recipe_dir_index_]
@property
def platforms(self) -> list[PlatformProps]:
def platforms(self) -> list[XplatProps]:
platforms = [self.common]
if self.fabric:
platforms.append(self.fabric)
@ -113,7 +119,7 @@ class Properties(HexDocModel[Any]):
return platforms
@property
def pattern_stubs(self) -> list[Path]:
def pattern_stubs(self):
return [
stub
for platform in self.platforms
@ -125,15 +131,6 @@ class Properties(HexDocModel[Any]):
base_url = self.base_asset_urls[asset.namespace]
return f"{base_url}/{path}/{asset.full_path}"
@field_validator("default_recipe_dir_index_")
def _check_default_recipe_dir(cls, value: int, info: FieldValidationInfo) -> int:
num_dirs = len(info.data["recipe_dirs"])
if value >= num_dirs:
raise ValueError(
f"default_recipe_dir must be a valid index of recipe_dirs (expected <={num_dirs - 1}, got {value})"
)
return value
class PropsContext(TypedDict):
props: Properties

View file

@ -5,6 +5,7 @@
# basically, just leave it here
import re
from fnmatch import fnmatch
from pathlib import Path
from typing import Any, ClassVar, Self
@ -16,7 +17,7 @@ from .model import DEFAULT_CONFIG
def _make_regex(count: bool = False, nbt: bool = False) -> re.Pattern[str]:
pattern = r"(?:(?P<namespace>[0-9a-z_\-.]+):)?(?P<path>[0-9a-z_\-./]+)"
pattern = r"(?:(?P<namespace>[0-9a-z_\-.]+):)?(?P<path>[0-9a-z_\-./*]+)"
if count:
pattern += r"(?:#(?P<count>[0-9]+))?"
if nbt:
@ -58,7 +59,11 @@ class BaseResourceLocation:
def _default_namespace(cls, value: str | None) -> str:
if value is None:
return "minecraft"
return value
return value.lower()
@field_validator("path")
def _lower_path(cls, value: str):
return value.lower()
@model_serializer
def _ser_model(self) -> str:
@ -85,6 +90,9 @@ class ResourceLocation(BaseResourceLocation, regex=_make_regex()):
def href(self) -> str:
return f"#{self.path}"
def match(self, pattern: Self) -> bool:
return fnmatch(str(self), str(pattern))
# pure unadulterated laziness
ResLoc = ResourceLocation
@ -101,7 +109,8 @@ class ItemStack(BaseResourceLocation, regex=_make_regex(count=True, nbt=True)):
nbt: str | None = None
def i18n_key(self, root: str = "item") -> str:
return f"{root}.{self.namespace}.{self.path}"
# TODO: is this how i18n works?????
return f"{root}.{self.namespace}.{self.path.replace('/', '.')}"
def __repr__(self) -> str:
s = super().__repr__()

View file

@ -114,7 +114,7 @@ def _fill_placeholders(
_handle_child(data, stack, expanded, key, child, table.__setitem__)
def load_toml(path: Path) -> TOMLDict:
def load_toml_with_placeholders(path: Path) -> TOMLDict:
data = tomllib.loads(path.read_text("utf-8"))
fill_placeholders(data)
return data

View file

@ -7,14 +7,11 @@ from syrupy.assertion import SnapshotAssertion
from hexdoc.hexdoc import Args, main
_RUN = ["hexdoc"]
_ARGV = ["properties.toml", "-o"]
def test_file(tmp_path: Path, snapshot: SnapshotAssertion):
# generate output docs html file and assert it hasn't changed vs. the snapshot
out_path = tmp_path / "out.html"
main(Args().parse_args(_ARGV + [out_path.as_posix()]))
main(Args.parse_args(["properties.toml", "-o", out_path.as_posix()]))
assert out_path.read_text("utf-8") == snapshot
@ -22,7 +19,7 @@ def test_cmd(tmp_path: Path, snapshot: SnapshotAssertion):
# as above, but running the command we actually want to be using
out_path = tmp_path / "out.html"
subprocess.run(
_RUN + _ARGV + [out_path.as_posix()],
["hexdoc", "properties.toml", "-o", out_path.as_posix()],
stdout=sys.stdout,
stderr=sys.stderr,
)
@ -30,5 +27,5 @@ def test_cmd(tmp_path: Path, snapshot: SnapshotAssertion):
def test_stdout(capsys: pytest.CaptureFixture[str], snapshot: SnapshotAssertion):
main(Args().parse_args(["properties.toml"]))
main(Args.parse_args(["properties.toml"]))
assert capsys.readouterr() == snapshot

View file

@ -1,4 +1,7 @@
# pyright: reportPrivateUsage=false
from argparse import Namespace
from typing import cast
from hexdoc.patchouli.text import DEFAULT_MACROS, FormatTree
from hexdoc.patchouli.text.formatting import (
CommandStyle,
@ -7,14 +10,16 @@ from hexdoc.patchouli.text.formatting import (
ParagraphStyle,
SpecialStyleType,
)
from hexdoc.utils.properties import Properties
def test_format_string():
# arrange
test_str = "Write the given iota to my $(l:patterns/readwrite#hexcasting:write/local)$(#490)local$().$(br)The $(l:patterns/readwrite#hexcasting:write/local)$(#490)local$() is a lot like a $(l:items/focus)$(#b0b)Focus$(). It's cleared when I stop casting a Hex, starts with $(l:casting/influences)$(#490)Null$() in it, and is preserved between casts of $(l:patterns/meta#hexcasting:for_each)$(#fc77be)Thoth's Gambit$(). "
mock_props = Namespace(is_0_black=False, i18n=Namespace(keys={}))
# act
tree = FormatTree.format(test_str, DEFAULT_MACROS, is_0_black=False)
tree = FormatTree.format(test_str, DEFAULT_MACROS, cast(Properties, mock_props))
# assert
# TODO: possibly make this less lazy