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