# Copyright Mozilla Foundation # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from __future__ import annotations from dataclasses import dataclass, field from logging import getLogger from typing import Any, TypeVar, Union from moz.l10n.model import ( CatchallKey, Entry, Expression, Id, Message, Pattern, PatternMessage, Resource, Section, SelectMessage, VariableRef, ) from moz.l10n.paths.config import L10nConfigPaths from moz.l10n.paths.discover import L10nDiscoverPaths from moz.l10n.resource import parse_resource log = getLogger(__name__) M = TypeVar("M", bound=Union[Message, str]) @dataclass class MigrationContext: paths: L10nConfigPaths | L10nDiscoverPaths ref_path: str locale: str parse_options: dict[str, Any] target_id: Id = () _prev_ids: list[Id] = field(default_factory=list) _resources: dict[str, Resource[Message] | None] = field(default_factory=dict) def get_resource(self, ref_path: str) -> Resource[Message] | None: if ref_path in self._resources: return self._resources[ref_path] tgt_path, _ = self.paths.target(ref_path, locale=self.locale) try: res = parse_resource(tgt_path, **self.parse_options) except OSError: log.debug(f"Resource not available: {tgt_path}") res = None except Exception: log.debug(f"Parse error: {tgt_path}", exc_info=True) res = None self._resources[ref_path] = res return res def pretty_id(self, id: tuple[str, ...]) -> str: return f"{'.'.join(id)} in {self.ref_path} for locale {self.locale}" def _update(self, id: Id | str) -> None: if self.target_id: self._prev_ids.append(self.target_id) self.target_id = (id,) if isinstance(id, str) else id def __str__(self) -> str: return self.pretty_id(self.target_id) def get_entry(res: Resource[M], *id: str) -> Entry[M] | None: """ Get an entry matching `id` from `res`. If not found return None """ for section in res.sections: if section.id: sid_len = len(section.id) if section.id != id[:sid_len]: continue eid = id[sid_len:] else: eid = id entry = next( (e for e in section.entries if isinstance(e, Entry) and e.id == eid), None, ) if entry is not None: return entry return None def get_pattern( res: Resource[Message], *id: str, property: str | None = None, default: Pattern | None = None, variant: tuple[str | CatchallKey, ...] | str | None = None, ) -> Pattern: """ Get a pattern matching `id`, `property`, and `variant` from `res`. If `property` is not set, a pattern from the `id` entry's `.value` is returned. Otherwise, a pattern from its `.properties[property]` is returned. If the message for `id` and `property` is a SelectMessage, either the pattern matching `variant` is returned, or (if not found) the fallback pattern is returned. If `default` is a Pattern, it is returned if no matching pattern is found. Otherwise, a StopIteration exception is raised. """ entry = get_entry(res, *id) if entry is None: if default is not None: return default raise StopIteration if property is None: msg = entry.value elif property in entry.properties: msg = entry.properties[property] else: raise StopIteration if isinstance(msg, PatternMessage): return msg.pattern elif isinstance(msg, SelectMessage): if isinstance(variant, str): variant = (variant,) if variant in msg.variants: return msg.variants[variant] return next( pattern for keys, pattern in msg.variants.items() if all(isinstance(key, CatchallKey) for key in keys) ) raise ValueError(f"Value of {id} entry is not a Message") def insert_entry_after( res: Resource[M], entry: Entry[M], *src_ids: tuple[str, ...] | str, ) -> None: """ Insert `entry` in `res` after the last entry with an `.id` matching one of `src_ids`, or if none such are found, at the end of the last section matching any of `ids`. Note that `entry.id` is expected to include its section id, which will be dropped before insertion. If no suitable insertion position is found, raises StopIteration. """ id_set = {id if isinstance(id, tuple) else (id,) for id in src_ids} last_section: Section[M] | None = None for section in reversed(res.sections): if section.id: sid_len = len(section.id) eid_set = {id[sid_len:] for id in id_set if section.id == id[:sid_len]} if not eid_set: continue else: sid_len = 0 eid_set = id_set for e_rev_idx, e in enumerate(reversed(section.entries)): if isinstance(e, Entry) and e.id in eid_set: if sid_len: entry.id = entry.id[sid_len:] section.entries.insert(len(section.entries) - e_rev_idx, entry) return last_section = section if last_section is None: raise StopIteration else: if last_section.id: entry.id = entry.id[len(last_section.id) :] last_section.entries.append(entry) def plural_message( var_name: str, *, zero: Pattern | None = None, one: Pattern | None = None, two: Pattern | None = None, few: Pattern | None = None, many: Pattern | None = None, other: Pattern, ) -> SelectMessage: """ Construct a SelectMessage using `var_name` to determine the plural category, with the given variants. By convention, use the following as `var_name`: - `"n"` for Gettext plurals - `"quantity"` for Android strings. """ raw_variants: list[tuple[str | CatchallKey, Pattern | None]] = [ ("zero", zero), ("one", one), ("two", two), ("few", few), ("many", many), (CatchallKey("other"), other), ] return SelectMessage( declarations={var_name: Expression(VariableRef(var_name), "number")}, selectors=(VariableRef(var_name),), variants={ (key,): pattern for key, pattern in raw_variants if pattern is not None }, )