hexdoc.minecraft
102class I18n(ValidationContextModel): 103 """Handles localization of strings.""" 104 105 lookup: dict[str, LocalizedStr] 106 lang: str 107 default_i18n: I18n | None 108 enabled: bool 109 lang_props: LangProps 110 111 @classmethod 112 def list_all(cls, loader: ModResourceLoader): 113 # don't list languages which this particular mod doesn't support 114 # eg. if Hex has translations for ru_ru but an addon doesn't 115 return set( 116 id.path 117 for resource_dir, id, _ in cls._load_lang_resources(loader) 118 if not resource_dir.external 119 ) 120 121 @classmethod 122 def load_all(cls, loader: ModResourceLoader, enabled: bool): 123 # lang -> (key -> value) 124 lookups = defaultdict[str, dict[str, LocalizedStr]](dict) 125 internal_langs = set[str]() 126 127 for resource_dir, lang_id, data in cls._load_lang_resources(loader): 128 lang = lang_id.path 129 if not langcodes.tag_is_valid(lang): 130 modid = resource_dir.modid or lang_id.namespace 131 raise ValueError( 132 textwrap.dedent(f"""\ 133 Attempted to load invalid lang (provided by {modid}): {lang} 134 Resource dir: {resource_dir.path} 135 """).rstrip() 136 ) 137 138 lookups[lang] |= cls.parse_lookup(data) 139 if not resource_dir.external: 140 internal_langs.add(lang) 141 142 default_lang = loader.props.default_lang 143 default_lookup = lookups[default_lang] 144 default_i18n = cls( 145 lookup=default_lookup, 146 lang=default_lang, 147 default_i18n=None, 148 enabled=enabled, 149 lang_props=loader.props.lang[default_lang], 150 ) 151 152 return {default_lang: default_i18n} | { 153 lang: cls( 154 lookup=lookup, 155 lang=lang, 156 default_i18n=default_i18n, 157 enabled=enabled, 158 lang_props=loader.props.lang[lang], 159 ) 160 for lang, lookup in lookups.items() 161 if lang in internal_langs and lang != default_lang 162 } 163 164 @classmethod 165 def load( 166 cls, 167 loader: ModResourceLoader, 168 enabled: bool, 169 lang: str, 170 ) -> Self: 171 if not langcodes.tag_is_valid(lang): 172 raise ValueError(f"Invalid lang: {lang}") 173 174 lookup = dict[str, LocalizedStr]() 175 is_internal = False 176 177 for resource_dir, _, data in cls._load_lang_resources(loader, lang): 178 lookup |= cls.parse_lookup(data) 179 if not resource_dir.external: 180 is_internal = True 181 182 if enabled and not is_internal: 183 raise FileNotFoundError( 184 f"Lang {lang} exists, but {loader.props.modid} does not support it" 185 ) 186 187 default_lang = loader.props.default_lang 188 default_i18n = None 189 if lang != default_lang: 190 default_i18n = cls.load(loader, enabled, default_lang) 191 192 return cls( 193 lookup=lookup, 194 lang=lang, 195 default_i18n=default_i18n, 196 enabled=enabled, 197 lang_props=loader.props.lang[lang], 198 ) 199 200 @classmethod 201 def parse_lookup(cls, raw_lookup: dict[str, str]) -> dict[str, LocalizedStr]: 202 return { 203 key: LocalizedStr(key=key, value=value.replace("%%", "%")) 204 for key, value in raw_lookup.items() 205 } 206 207 @classmethod 208 def _load_lang_resources(cls, loader: ModResourceLoader, lang: str = "*"): 209 return loader.load_resources( 210 "assets", 211 namespace="*", 212 folder="lang", 213 glob=[ 214 f"{lang}.json", 215 f"{lang}.json5", 216 f"{lang}.flatten.json", 217 f"{lang}.flatten.json5", 218 ], 219 decode=decode_and_flatten_json_dict, 220 export=cls._export, 221 ) 222 223 @classmethod 224 def _export(cls, new: dict[str, str], current: dict[str, str] | None): 225 return json.dumps((current or {}) | new) 226 227 @model_validator(mode="after") 228 def _warn_if_disabled(self): 229 if not self.enabled: 230 logger.info( 231 f"I18n is disabled for {self.lang}. Warnings about missing translations" 232 + " will only be logged in verbose mode." 233 ) 234 elif self.lang_props.quiet: 235 logger.info( 236 f"Quiet mode is enabled for {self.lang}. Warnings about missing" 237 + " translations will only be logged in verbose mode." 238 ) 239 return self 240 241 @property 242 def is_default(self): 243 return self.default_i18n is None 244 245 def localize( 246 self, 247 *keys: str, 248 default: str | None = None, 249 silent: bool = False, 250 ) -> LocalizedStr: 251 """Looks up the given string in the lang table if i18n is enabled. Otherwise, 252 returns the original key. 253 254 If multiple keys are provided, returns the value of the first key which exists. 255 That is, subsequent keys are treated as fallbacks for the first. 256 257 Raises ValueError if i18n is enabled and default is None but the key has no 258 corresponding localized value. 259 """ 260 261 for key in keys: 262 if key in self.lookup: 263 return self.lookup[key] 264 265 if silent or not self.enabled or self.lang_props.quiet: 266 log_level = logging.DEBUG 267 else: 268 log_level = logging.WARNING 269 270 log_keys = set(keys) - self._logged_missing_keys 271 if log_keys: 272 self._logged_missing_keys.update(log_keys) 273 match list(log_keys): 274 case [key]: 275 message = f"key {key}" 276 case _: 277 message = "keys " + ", ".join(log_keys) 278 logger.log(log_level, f"No translation in {self.lang} for {message}") 279 280 if default is not None: 281 return LocalizedStr.skip_i18n(default) 282 283 if self.default_i18n: 284 return self.default_i18n.localize(*keys, default=default, silent=silent) 285 286 return LocalizedStr.skip_i18n(keys[0]) 287 288 def localize_pattern( 289 self, 290 op_id: ResourceLocation, 291 silent: bool = False, 292 ) -> LocalizedStr: 293 """Localizes the given pattern id (internal name, eg. brainsweep). 294 295 Raises ValueError if i18n is enabled but the key has no localization. 296 """ 297 key_group = ValueIfVersion(">=1.20", "action", "spell")() 298 299 # prefer the book-specific translation if it exists 300 return self.localize( 301 f"hexcasting.{key_group}.book.{op_id}", 302 f"hexcasting.{key_group}.{op_id}", 303 silent=silent, 304 ) 305 306 def localize_item( 307 self, 308 item: str | ResourceLocation | ItemStack, 309 silent: bool = False, 310 ) -> LocalizedItem: 311 """Localizes the given item resource name. 312 313 Raises ValueError if i18n is enabled but the key has no localization. 314 """ 315 match item: 316 case str(): 317 item = ItemStack.from_str(item) 318 case ResourceLocation(namespace=namespace, path=path): 319 item = ItemStack(namespace=namespace, path=path) 320 case _: 321 pass 322 323 localized = self.localize( 324 item.i18n_key(), 325 item.i18n_key("block"), 326 silent=silent, 327 ) 328 return LocalizedItem(key=localized.key, value=localized.value) 329 330 def localize_entity(self, entity: ResourceLocation, type: str | None = None): 331 if type: 332 entity = type / entity 333 return self.localize(entity.i18n_key("entity")) 334 335 def localize_key(self, key: str, silent: bool = False) -> LocalizedStr: 336 if not key.startswith("key."): 337 key = "key." + key 338 return self.localize(key, silent=silent) 339 340 def localize_item_tag(self, tag: ResourceLocation, silent: bool = False): 341 localized = self.localize( 342 f"tag.{tag.namespace}.{tag.path}", 343 f"tag.item.{tag.namespace}.{tag.path}", 344 f"tag.block.{tag.namespace}.{tag.path}", 345 default=self.fallback_tag_name(tag), 346 silent=silent, 347 ) 348 return LocalizedStr(key=localized.key, value=f"Tag: {localized.value}") 349 350 def fallback_tag_name(self, tag: ResourceLocation): 351 """Generates a more-or-less reasonable fallback name for a tag. 352 353 For example: 354 * `forge:ores` -> Ores 355 * `c:saplings/almond` -> Almond Saplings 356 * `c:tea_ingredients/gloopy/weak` -> Tea Ingredients, Gloopy, Weak 357 """ 358 359 if tag.path.count("/") == 1: 360 before, after = tag.path.split("/") 361 return f"{after} {before}".title() 362 363 return tag.path.replace("_", " ").replace("/", ", ").title() 364 365 def localize_texture(self, texture_id: ResourceLocation, silent: bool = False): 366 path = texture_id.path.removeprefix("textures/").removesuffix(".png") 367 root, rest = path.split("/", 1) 368 369 # TODO: refactor / extensibilify 370 if root == "mob_effect": 371 root = "effect" 372 373 return self.localize(f"{root}.{texture_id.namespace}.{rest}", silent=silent) 374 375 def localize_lang(self, silent: bool = False): 376 name = self.localize("language.name", default="", silent=silent) 377 region = self.localize("language.region", silent=silent) 378 # don't allow language names to fall back to English (United States) 379 if name.value == "": 380 return self.lang 381 return f"{name} ({region})" 382 383 @model_validator(mode="after") 384 def _init_logger_cache(self): 385 self._logged_missing_keys = set[str]() 386 return self
Handles localization of strings.
111 @classmethod 112 def list_all(cls, loader: ModResourceLoader): 113 # don't list languages which this particular mod doesn't support 114 # eg. if Hex has translations for ru_ru but an addon doesn't 115 return set( 116 id.path 117 for resource_dir, id, _ in cls._load_lang_resources(loader) 118 if not resource_dir.external 119 )
121 @classmethod 122 def load_all(cls, loader: ModResourceLoader, enabled: bool): 123 # lang -> (key -> value) 124 lookups = defaultdict[str, dict[str, LocalizedStr]](dict) 125 internal_langs = set[str]() 126 127 for resource_dir, lang_id, data in cls._load_lang_resources(loader): 128 lang = lang_id.path 129 if not langcodes.tag_is_valid(lang): 130 modid = resource_dir.modid or lang_id.namespace 131 raise ValueError( 132 textwrap.dedent(f"""\ 133 Attempted to load invalid lang (provided by {modid}): {lang} 134 Resource dir: {resource_dir.path} 135 """).rstrip() 136 ) 137 138 lookups[lang] |= cls.parse_lookup(data) 139 if not resource_dir.external: 140 internal_langs.add(lang) 141 142 default_lang = loader.props.default_lang 143 default_lookup = lookups[default_lang] 144 default_i18n = cls( 145 lookup=default_lookup, 146 lang=default_lang, 147 default_i18n=None, 148 enabled=enabled, 149 lang_props=loader.props.lang[default_lang], 150 ) 151 152 return {default_lang: default_i18n} | { 153 lang: cls( 154 lookup=lookup, 155 lang=lang, 156 default_i18n=default_i18n, 157 enabled=enabled, 158 lang_props=loader.props.lang[lang], 159 ) 160 for lang, lookup in lookups.items() 161 if lang in internal_langs and lang != default_lang 162 }
164 @classmethod 165 def load( 166 cls, 167 loader: ModResourceLoader, 168 enabled: bool, 169 lang: str, 170 ) -> Self: 171 if not langcodes.tag_is_valid(lang): 172 raise ValueError(f"Invalid lang: {lang}") 173 174 lookup = dict[str, LocalizedStr]() 175 is_internal = False 176 177 for resource_dir, _, data in cls._load_lang_resources(loader, lang): 178 lookup |= cls.parse_lookup(data) 179 if not resource_dir.external: 180 is_internal = True 181 182 if enabled and not is_internal: 183 raise FileNotFoundError( 184 f"Lang {lang} exists, but {loader.props.modid} does not support it" 185 ) 186 187 default_lang = loader.props.default_lang 188 default_i18n = None 189 if lang != default_lang: 190 default_i18n = cls.load(loader, enabled, default_lang) 191 192 return cls( 193 lookup=lookup, 194 lang=lang, 195 default_i18n=default_i18n, 196 enabled=enabled, 197 lang_props=loader.props.lang[lang], 198 )
245 def localize( 246 self, 247 *keys: str, 248 default: str | None = None, 249 silent: bool = False, 250 ) -> LocalizedStr: 251 """Looks up the given string in the lang table if i18n is enabled. Otherwise, 252 returns the original key. 253 254 If multiple keys are provided, returns the value of the first key which exists. 255 That is, subsequent keys are treated as fallbacks for the first. 256 257 Raises ValueError if i18n is enabled and default is None but the key has no 258 corresponding localized value. 259 """ 260 261 for key in keys: 262 if key in self.lookup: 263 return self.lookup[key] 264 265 if silent or not self.enabled or self.lang_props.quiet: 266 log_level = logging.DEBUG 267 else: 268 log_level = logging.WARNING 269 270 log_keys = set(keys) - self._logged_missing_keys 271 if log_keys: 272 self._logged_missing_keys.update(log_keys) 273 match list(log_keys): 274 case [key]: 275 message = f"key {key}" 276 case _: 277 message = "keys " + ", ".join(log_keys) 278 logger.log(log_level, f"No translation in {self.lang} for {message}") 279 280 if default is not None: 281 return LocalizedStr.skip_i18n(default) 282 283 if self.default_i18n: 284 return self.default_i18n.localize(*keys, default=default, silent=silent) 285 286 return LocalizedStr.skip_i18n(keys[0])
Looks up the given string in the lang table if i18n is enabled. Otherwise, returns the original key.
If multiple keys are provided, returns the value of the first key which exists. That is, subsequent keys are treated as fallbacks for the first.
Raises ValueError if i18n is enabled and default is None but the key has no corresponding localized value.
288 def localize_pattern( 289 self, 290 op_id: ResourceLocation, 291 silent: bool = False, 292 ) -> LocalizedStr: 293 """Localizes the given pattern id (internal name, eg. brainsweep). 294 295 Raises ValueError if i18n is enabled but the key has no localization. 296 """ 297 key_group = ValueIfVersion(">=1.20", "action", "spell")() 298 299 # prefer the book-specific translation if it exists 300 return self.localize( 301 f"hexcasting.{key_group}.book.{op_id}", 302 f"hexcasting.{key_group}.{op_id}", 303 silent=silent, 304 )
Localizes the given pattern id (internal name, eg. brainsweep).
Raises ValueError if i18n is enabled but the key has no localization.
306 def localize_item( 307 self, 308 item: str | ResourceLocation | ItemStack, 309 silent: bool = False, 310 ) -> LocalizedItem: 311 """Localizes the given item resource name. 312 313 Raises ValueError if i18n is enabled but the key has no localization. 314 """ 315 match item: 316 case str(): 317 item = ItemStack.from_str(item) 318 case ResourceLocation(namespace=namespace, path=path): 319 item = ItemStack(namespace=namespace, path=path) 320 case _: 321 pass 322 323 localized = self.localize( 324 item.i18n_key(), 325 item.i18n_key("block"), 326 silent=silent, 327 ) 328 return LocalizedItem(key=localized.key, value=localized.value)
Localizes the given item resource name.
Raises ValueError if i18n is enabled but the key has no localization.
340 def localize_item_tag(self, tag: ResourceLocation, silent: bool = False): 341 localized = self.localize( 342 f"tag.{tag.namespace}.{tag.path}", 343 f"tag.item.{tag.namespace}.{tag.path}", 344 f"tag.block.{tag.namespace}.{tag.path}", 345 default=self.fallback_tag_name(tag), 346 silent=silent, 347 ) 348 return LocalizedStr(key=localized.key, value=f"Tag: {localized.value}")
350 def fallback_tag_name(self, tag: ResourceLocation): 351 """Generates a more-or-less reasonable fallback name for a tag. 352 353 For example: 354 * `forge:ores` -> Ores 355 * `c:saplings/almond` -> Almond Saplings 356 * `c:tea_ingredients/gloopy/weak` -> Tea Ingredients, Gloopy, Weak 357 """ 358 359 if tag.path.count("/") == 1: 360 before, after = tag.path.split("/") 361 return f"{after} {before}".title() 362 363 return tag.path.replace("_", " ").replace("/", ", ").title()
Generates a more-or-less reasonable fallback name for a tag.
For example:
forge:ores-> Oresc:saplings/almond-> Almond Saplingsc:tea_ingredients/gloopy/weak-> Tea Ingredients, Gloopy, Weak
365 def localize_texture(self, texture_id: ResourceLocation, silent: bool = False): 366 path = texture_id.path.removeprefix("textures/").removesuffix(".png") 367 root, rest = path.split("/", 1) 368 369 # TODO: refactor / extensibilify 370 if root == "mob_effect": 371 root = "effect" 372 373 return self.localize(f"{root}.{texture_id.namespace}.{rest}", silent=silent)
375 def localize_lang(self, silent: bool = False): 376 name = self.localize("language.name", default="", silent=silent) 377 region = self.localize("language.region", silent=silent) 378 # don't allow language names to fall back to English (United States) 379 if name.value == "": 380 return self.lang 381 return f"{name} ({region})"
96class LocalizedItem(LocalizedStr, frozen=True): 97 @classmethod 98 def _localize(cls, i18n: I18n, key: str) -> Self: 99 return cls.model_validate(i18n.localize_item(key))
Represents a string which has been localized.
30@total_ordering 31class LocalizedStr(HexdocModel, frozen=True): 32 """Represents a string which has been localized.""" 33 34 model_config = DEFAULT_CONFIG | json_schema_extra_config(type_str, inherited) 35 36 key: str 37 value: str 38 39 @classmethod 40 def skip_i18n(cls, key: str) -> Self: 41 """Returns an instance of this class with `value = key`.""" 42 return cls(key=key, value=key) 43 44 @classmethod 45 def with_value(cls, value: str) -> Self: 46 """Returns an instance of this class with an empty key.""" 47 return cls(key="", value=value) 48 49 @model_validator(mode="wrap") 50 @classmethod 51 def _check_localize( 52 cls, 53 value: str | Any, 54 handler: ModelWrapValidatorHandler[Self], 55 info: ValidationInfo, 56 ) -> Self: 57 # NOTE: if we need LocalizedStr to work as a dict key, add another check which 58 # returns cls.skip_i18n(value) if info.context is falsy 59 if not isinstance(value, str): 60 return handler(value) 61 62 i18n = I18n.of(info) 63 return cls._localize(i18n, value) 64 65 @classmethod 66 def _localize(cls, i18n: I18n, key: str) -> Self: 67 return cls.model_validate(i18n.localize(key)) 68 69 def map(self, fn: Callable[[str], str]) -> Self: 70 """Returns a copy of this object with `new.value = fn(old.value)`.""" 71 return self.model_copy(update={"value": fn(self.value)}) 72 73 def __repr__(self) -> str: 74 return self.value 75 76 def __str__(self) -> str: 77 return self.value 78 79 def __eq__(self, other: Self | str | Any): 80 match other: 81 case LocalizedStr(): 82 return self.value == other.value 83 case str(): 84 return self.value == other 85 case _: 86 return super().__eq__(other) 87 88 def __lt__(self, other: Self | str): 89 match other: 90 case LocalizedStr(): 91 return self.value < other.value 92 case str(): 93 return self.value < other
Represents a string which has been localized.
39 @classmethod 40 def skip_i18n(cls, key: str) -> Self: 41 """Returns an instance of this class with `value = key`.""" 42 return cls(key=key, value=key)
Returns an instance of this class with value = key.
41class Tag(HexdocModel): 42 GASLIGHTING_ITEMS: ClassVar = TagLoader("hexdoc", "items", "gaslighting") 43 """Item/block ids that gaslight you. This tag isn't real, it's all in your head. 44 45 File: `hexdoc/tags/items/gaslighting.json` 46 """ 47 SPOILERED_ADVANCEMENTS: ClassVar = TagLoader("hexdoc", "advancements", "spoilered") 48 """Advancements for entries that should be blurred in the web book. 49 50 File: `hexdoc/tags/advancements/spoilered.json` 51 """ 52 53 registry: str = Field(exclude=True) 54 values: PydanticOrderedSet[TagValue] 55 replace: bool = False 56 57 @classmethod 58 def load( 59 cls, 60 registry: str, 61 id: ResourceLocation, 62 loader: ModResourceLoader, 63 ) -> Self: 64 values = PydanticOrderedSet[TagValue]() 65 replace = False 66 67 for _, _, tag in loader.load_resources( 68 "data", 69 folder=f"tags/{registry}", 70 id=id, 71 decode=lambda raw_data: Tag._convert( 72 registry=registry, 73 raw_data=raw_data, 74 ), 75 export=cls._export, 76 ): 77 if tag.replace: 78 values.clear() 79 for value in tag._load_values(loader): 80 values.add(value) 81 82 return cls(registry=registry, values=values, replace=replace) 83 84 @classmethod 85 def _convert(cls, *, registry: str, raw_data: str) -> Self: 86 data = decode_json_dict(raw_data) 87 return cls.model_validate(data | {"registry": registry}) 88 89 @property 90 def value_ids(self) -> Iterator[ResourceLocation]: 91 for value in self.values: 92 match value: 93 case ResourceLocation(): 94 yield value 95 case OptionalTagValue(id=id): 96 yield id 97 98 @property 99 def value_ids_set(self): 100 return set(self.value_ids) 101 102 def __ror__(self, other: set[ResourceLocation]): 103 new = set(other) 104 new |= self.value_ids_set 105 return new 106 107 def __contains__(self, x: Any) -> bool: 108 if isinstance(x, BaseResourceLocation): 109 return x.id in self.value_ids_set 110 return NotImplemented 111 112 def _export(self: Tag, current: Tag | None): 113 if self.replace or current is None: 114 tag = self 115 else: 116 tag = self.model_copy( 117 update={"values": current.values | self.values}, 118 ) 119 return tag.model_dump_json(by_alias=True) 120 121 def _load_values(self, loader: ModResourceLoader) -> Iterator[TagValue]: 122 for value in self.values: 123 match value: 124 case ( 125 (ResourceLocation() as child_id) 126 | OptionalTagValue(id=child_id) 127 ) if child_id.is_tag: 128 try: 129 child = Tag.load(self.registry, child_id, loader) 130 yield from child._load_values(loader) 131 except FileNotFoundError: 132 yield value 133 case _: 134 yield value
Base class for all Pydantic models in hexdoc.
Sets the default model config, and overrides __init__ to allow using the
init_context context manager to set validation context for constructors.
Item/block ids that gaslight you. This tag isn't real, it's all in your head.
File: hexdoc/tags/items/gaslighting.json
Advancements for entries that should be blurred in the web book.
File: hexdoc/tags/advancements/spoilered.json
57 @classmethod 58 def load( 59 cls, 60 registry: str, 61 id: ResourceLocation, 62 loader: ModResourceLoader, 63 ) -> Self: 64 values = PydanticOrderedSet[TagValue]() 65 replace = False 66 67 for _, _, tag in loader.load_resources( 68 "data", 69 folder=f"tags/{registry}", 70 id=id, 71 decode=lambda raw_data: Tag._convert( 72 registry=registry, 73 raw_data=raw_data, 74 ), 75 export=cls._export, 76 ): 77 if tag.replace: 78 values.clear() 79 for value in tag._load_values(loader): 80 values.add(value) 81 82 return cls(registry=registry, values=values, replace=replace)