Skip to content

Commit ae01baa

Browse files
Add option to load hexdoc patterns from a JSON file instead of scraping them with regex (#911)
2 parents 977ccba + bf0faf1 commit ae01baa

File tree

3 files changed

+103
-52
lines changed

3 files changed

+103
-52
lines changed

doc/src/hexdoc_hexcasting/metadata.py

Lines changed: 100 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import logging
22
import re
3+
from abc import ABC, abstractmethod
34
from pathlib import Path
5+
from typing import Any, Iterable, Literal
46

57
from hexdoc.core import IsVersion, ModResourceLoader, Properties, ResourceLocation
68
from hexdoc.minecraft import Tag
79
from hexdoc.model import HexdocModel, StripHiddenModel, ValidationContextModel
810
from hexdoc.utils import TRACE, RelativePath
9-
from pydantic import Field
11+
from pydantic import Field, TypeAdapter
12+
from typing_extensions import override
1013

1114
from .utils.pattern import Direction, PatternInfo
1215

@@ -23,16 +26,101 @@ def path(cls, modid: str) -> Path:
2326
return Path(f"{modid}.patterns.hexdoc.json")
2427

2528

26-
class PatternStubProps(StripHiddenModel):
29+
class BasePatternStubProps(StripHiddenModel, ABC):
30+
type: Any
2731
path: RelativePath
28-
regex: re.Pattern[str]
29-
per_world_value: str | None = "true"
3032
required: bool = True
3133
"""If `True` (the default), raise an error if no patterns were loaded from here."""
3234

35+
def load_patterns(
36+
self,
37+
props: Properties,
38+
per_world_tag: Tag | None,
39+
) -> list[PatternInfo]:
40+
logger.debug(f"Load {self.type} pattern stub from {self.path}")
41+
42+
patterns = list[PatternInfo]()
43+
44+
try:
45+
for pattern in self._iter_patterns(props):
46+
if per_world_tag is not None:
47+
pattern.is_per_world = pattern.id in per_world_tag.values
48+
patterns.append(pattern)
49+
except Exception as e:
50+
# hack: notes don't seem to be working on pydantic exceptions :/
51+
logger.error(f"Failed to load {self.type} pattern stub from {self.path}.")
52+
raise e
53+
54+
pretty_path = self.path.resolve().relative_to(Path.cwd())
55+
56+
if self.required and not patterns:
57+
raise ValueError(self._no_patterns_error.format(path=pretty_path))
58+
59+
logger.info(f"Loaded {len(patterns)} patterns from {pretty_path}")
60+
return patterns
61+
62+
@abstractmethod
63+
def _iter_patterns(self, props: Properties) -> Iterable[PatternInfo]:
64+
"""Loads and iterates over the patterns from this stub.
65+
66+
Note: the `is_per_world` value returned by this function should be **ignored**
67+
in 0.11+, since that information can be found in the per world tag.
68+
"""
69+
70+
@property
71+
def _no_patterns_error(self) -> str:
72+
return "No patterns found in {path}, but required is True"
73+
74+
75+
class RegexPatternStubProps(BasePatternStubProps):
76+
"""Fetches pattern info by scraping source code with regex."""
77+
78+
type: Literal["regex"] = "regex"
79+
regex: re.Pattern[str]
80+
per_world_value: str | None = "true"
81+
82+
@override
83+
def _iter_patterns(self, props: Properties) -> Iterable[PatternInfo]:
84+
stub_text = self.path.read_text("utf-8")
85+
86+
for match in self.regex.finditer(stub_text):
87+
groups = match.groupdict()
88+
89+
if ":" in groups["name"]:
90+
id = ResourceLocation.from_str(groups["name"])
91+
else:
92+
id = props.mod_loc(groups["name"])
93+
94+
yield PatternInfo(
95+
id=id,
96+
startdir=Direction[groups["startdir"]],
97+
signature=groups["signature"],
98+
is_per_world=groups.get("is_per_world") == self.per_world_value,
99+
)
100+
101+
@property
102+
@override
103+
def _no_patterns_error(self):
104+
return super()._no_patterns_error + " (check the pattern regex)"
105+
106+
107+
class JsonPatternStubProps(BasePatternStubProps):
108+
"""Fetches pattern info from a JSON file."""
109+
110+
type: Literal["json"]
111+
112+
@override
113+
def _iter_patterns(self, props: Properties) -> Iterable[PatternInfo]:
114+
data = self.path.read_bytes()
115+
return TypeAdapter(list[PatternInfo]).validate_json(data)
116+
117+
118+
PatternStubProps = RegexPatternStubProps | JsonPatternStubProps
119+
33120

34121
class HexProperties(StripHiddenModel):
35-
pattern_stubs: list[PatternStubProps]
122+
pattern_stubs: list[PatternStubProps] = Field(default_factory=list)
123+
allow_duplicates: bool = False
36124

37125

38126
# conthext, perhaps
@@ -84,7 +172,7 @@ def _add_patterns_0_11(
84172

85173
# for each stub, load all the patterns in the file
86174
for stub in self.hex_props.pattern_stubs:
87-
for pattern in self._load_stub_patterns(loader.props, stub, per_world):
175+
for pattern in stub.load_patterns(loader.props, per_world):
88176
self._add_pattern(pattern, signatures)
89177

90178
def _add_patterns_0_10(
@@ -93,7 +181,7 @@ def _add_patterns_0_10(
93181
props: Properties,
94182
):
95183
for stub in self.hex_props.pattern_stubs:
96-
for pattern in self._load_stub_patterns(props, stub, None):
184+
for pattern in stub.load_patterns(props, None):
97185
self._add_pattern(pattern, signatures)
98186

99187
def _add_pattern(self, pattern: PatternInfo, signatures: dict[str, PatternInfo]):
@@ -103,47 +191,11 @@ def _add_pattern(self, pattern: PatternInfo, signatures: dict[str, PatternInfo])
103191
if duplicate := (
104192
self.patterns.get(pattern.id) or signatures.get(pattern.signature)
105193
):
106-
raise ValueError(f"Duplicate pattern {pattern.id}\n{pattern}\n{duplicate}")
194+
message = f"pattern {pattern.id}\n{pattern}\n{duplicate}"
195+
if self.hex_props.allow_duplicates:
196+
logger.warning("Ignoring duplicate " + message)
197+
return
198+
raise ValueError("Duplicate" + message)
107199

108200
self.patterns[pattern.id] = pattern
109201
signatures[pattern.signature] = pattern
110-
111-
def _load_stub_patterns(
112-
self,
113-
props: Properties,
114-
stub: PatternStubProps,
115-
per_world_tag: Tag | None,
116-
):
117-
# TODO: add Gradle task to generate json with this data. this is dumb and fragile.
118-
logger.debug(f"Load pattern stub from {stub.path}")
119-
stub_text = stub.path.read_text("utf-8")
120-
121-
patterns = list[PatternInfo]()
122-
123-
for match in stub.regex.finditer(stub_text):
124-
groups = match.groupdict()
125-
id = props.mod_loc(groups["name"])
126-
127-
if per_world_tag is not None:
128-
is_per_world = id in per_world_tag.values
129-
else:
130-
is_per_world = groups.get("is_per_world") == stub.per_world_value
131-
132-
patterns.append(
133-
PatternInfo(
134-
id=id,
135-
startdir=Direction[groups["startdir"]],
136-
signature=groups["signature"],
137-
is_per_world=is_per_world,
138-
)
139-
)
140-
141-
pretty_path = stub.path.resolve().relative_to(Path.cwd())
142-
143-
if stub.required and not patterns:
144-
raise ValueError(
145-
f"No patterns found in {pretty_path} (check the pattern regex)"
146-
)
147-
148-
logger.info(f"Loaded {len(patterns)} patterns from {pretty_path}")
149-
return patterns

doc/src/hexdoc_hexcasting/utils/pattern.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
from enum import Enum
22
from typing import Annotated, Any
33

4-
from pydantic import BeforeValidator, PlainSerializer
5-
64
from hexdoc.core import ResourceLocation
75
from hexdoc.model import HexdocModel
6+
from pydantic import BeforeValidator, PlainSerializer
87

98

109
class Direction(Enum):
@@ -49,7 +48,7 @@ class RawPatternInfo(BasePatternInfo):
4948
r: int | None = None
5049

5150

52-
class PatternInfo(BasePatternInfo):
51+
class PatternInfo(BasePatternInfo, extra="allow"):
5352
"""Pattern info used and exported by hexdoc for lookups."""
5453

5554
id: ResourceLocation

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ enableExperimentalFeatures = true
9898
# mostly we use strict mode
9999
# but pyright doesn't allow decreasing error severity in strict mode
100100
# so we need to manually specify all of the strict mode overrides so we can do that :/
101-
typeCheckingMode = "basic"
101+
typeCheckingMode = "standard"
102102

103103
strictDictionaryInference = true
104104
strictListInference = true

0 commit comments

Comments
 (0)