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