PK! qpygenstrings/__init__.py__version__ = '0.1.0' PK!5  pygenstrings/cli.pyfrom __future__ import annotations import fnmatch import os from dataclasses import dataclass from pathlib import Path from typing import * import click import toml from pygenstrings.genstrings import ( generate_strings, read_strings, merge_strings, PathFilter, ) class ConfigError(Exception): pass T = TypeVar("T") @dataclass class Box(Generic[T]): """ https://github.com/python/mypy/issues/5485 workaround """ inner: T def unbox(self) -> T: return self.inner @dataclass class Config: sources: List[Path] destination: Path languages: List[str] path_filter: Box[PathFilter] @classmethod def merge( cls, sources: List[str], destination: Optional[str], languages: List[str], exclude: List[str], config: Optional[Dict[str, Any]], ) -> Config: if config: if not sources: sources = config.get("sources", []) if not destination: destination = config.get("destination", None) if not languages: languages = config.get("languages", []) if not exclude: exclude = config.get("exclude", []) if not sources: raise ConfigError("Must define at least one source") source_paths = [Path(src) for src in sources] if not all(src.exists() for src in source_paths): raise ConfigError("Not all sources exist") if destination is None: dest_path = Path.cwd() else: dest_path = Path(destination) if not dest_path.exists(): raise ConfigError("Destination does not exist") if not dest_path.is_dir(): raise ConfigError("Destination is not a directory") if not languages: raise ConfigError("Must specify at least one language") if exclude: path_filter = make_filter(exclude) else: path_filter = null_filter return cls( sources=source_paths, destination=dest_path, languages=languages, path_filter=Box(path_filter), ) def null_filter(entry: os.DirEntry[str]) -> bool: return True def make_filter(exclude: List[str]) -> PathFilter: def fltr(entry: os.DirEntry[str]) -> bool: for pat in exclude: if fnmatch.fnmatch(entry.path, f"*{pat}"): return False return True return fltr def find_translations(path: Path, langs: Iterable[str]) -> Iterable[Path]: for lang in langs: directory = path / f"{lang}.lproj" if not directory.exists(): directory.mkdir() yield directory / "Localizable.strings" def read_config(config_file: Optional[TextIO]) -> Optional[Dict[str, Any]]: if config_file: return toml.load(config_file).get("pygenstrings", {}) path = Path.cwd() / "pygenstrings.toml" if path.exists() and path.is_file(): with path.open("r") as fobj: return toml.load(fobj).get("pygenstrings", {}) path = Path.cwd() / "pyproject.toml" if path.exists() and path.is_file(): with path.open("r") as fobj: tools = toml.load(fobj).get("tool", {}) assert isinstance(tools, dict) return tools.get("pygenstrings", {}) return None @click.command() @click.option( "-s", "--src", type=click.Path(dir_okay=True, file_okay=False, exists=True), multiple=True, ) @click.option( "-d", "--dst", type=click.Path(dir_okay=True, file_okay=False, exists=True) ) @click.option("-l", "--lang", multiple=True) @click.option("-e", "--exclude", multiple=True) @click.option("-c", "--config-file", type=click.File(mode="r")) def main( src: List[str], dst: Optional[str], lang: List[str], exclude: List[str], config_file: Optional[TextIO], ) -> None: config = Config.merge( sources=src, destination=dst, languages=lang, exclude=exclude, config=read_config(config_file), ) strings = generate_strings(config.sources, config.path_filter.unbox()) click.echo(f"Found {len(strings.strings)} strings to translate") for path, language in zip( find_translations(config.destination, config.languages), config.languages ): translation = read_strings(path) result = merge_strings(strings, translation) with path.open("w", encoding="utf-8") as fobj: fobj.write(result.to_source()) click.echo(f"Wrote {language}") click.echo("Done") PK!8\Nr pygenstrings/genstrings.pyfrom __future__ import annotations import os from dataclasses import dataclass from pathlib import Path from subprocess import check_call from tempfile import TemporaryDirectory from typing import * import chardet from .parser import parse if TYPE_CHECKING: DirEntry = os.DirEntry[str] else: DirEntry = os.DirEntry PathFilter = Callable[[DirEntry], bool] @dataclass(frozen=True, eq=True) class LocalizableString: string: str comment: str @dataclass class LocalizableStrings: strings: Dict[str, LocalizableString] @classmethod def null(cls) -> LocalizableStrings: return cls({}) @classmethod def from_source(cls, source: str) -> LocalizableStrings: return cls( { key: LocalizableString(string=value, comment=comment) for key, value, comment in parse(source) } ) def to_source(self) -> str: lines = [] for key, localized_string in sorted(self.strings.items()): if localized_string.comment: lines.append(f"/* {localized_string.comment} */") lines.append(f"{key} = {localized_string.string};") lines.append("") return "\n".join(lines) def read_file(path: Path) -> Union[str, None]: if not path.exists(): return None with path.open("rb") as fobj: data = fobj.read() candidates = ["utf-8"] encoding = chardet.detect(data)["encoding"] if encoding is not None: candidates.append(encoding) candidates.append("utf-16-le") for candidate in candidates: try: s: str = data.decode(candidate) return s except UnicodeDecodeError: continue return None def read_strings(path: Path) -> LocalizableStrings: source = read_file(path) if source is None: return LocalizableStrings.null() return LocalizableStrings.from_source(source) def scan_tree(path: Path, path_filter: PathFilter) -> Iterable[str]: for entry in os.scandir(path): if not path_filter(entry): continue if entry.is_file() and entry.name.endswith((".m", ".mm", ".swift")): yield entry.path elif entry.is_dir(): yield from scan_tree(Path(entry.path), path_filter) def generate_strings( sources: List[Path], path_filter: PathFilter ) -> LocalizableStrings: with TemporaryDirectory() as workspace: for src in sources: for entry in scan_tree(src, path_filter): check_call( ["genstrings", "-a", "-littleEndian", "-o", workspace, entry] ) return read_strings(Path(workspace) / "Localizable.strings") def merge_strings( strings: LocalizableStrings, translations: LocalizableStrings ) -> LocalizableStrings: return LocalizableStrings( { key: translations.strings.get(key, value) for key, value in strings.strings.items() } ) PK!Vpygenstrings/parser.pyimport tokenize from typing import * T = TypeVar("T") def pairwise(itr: Iterable[T], first: T) -> Iterable[Tuple[T, T]]: memo = first for item in itr: yield memo, item memo = item def get_comment(line: str) -> str: if line.startswith("/*") and line.endswith("*/"): return line[2:-2].strip() else: return "" class LineParseError(Exception): pass def parse_line(line: str) -> Tuple[str, str]: lines = iter([line.encode("utf-8")]) def rdln(size: int = -1) -> bytes: try: return next(lines) except StopIteration: return b"" tokens = list(tokenize.tokenize(rdln)) if len(tokens) != 7: raise LineParseError(f"Wrong number of tokens: {len(tokens)}, {tokens}") encoding, key, eq, value, semi, newline, endmarker = tokens if encoding.type != tokenize.ENCODING: raise LineParseError("No encoding token") if key.type != tokenize.STRING: raise LineParseError("Key not string") if eq.type != tokenize.OP: raise LineParseError("= not op") if value.type != tokenize.STRING: raise LineParseError("Value not string") if semi.type != tokenize.OP: raise LineParseError("; not op") if newline.type != tokenize.NEWLINE: raise LineParseError("Expected newline") if endmarker.type != tokenize.ENDMARKER: raise LineParseError("Expected endmarker") if eq.string != "=": raise LineParseError("= not =") if semi.string != ";": raise LineParseError("; not ;") return key.string, value.string def parse(source: str) -> Iterable[Tuple[str, str, str]]: lines = map(str.strip, source.splitlines(keepends=False)) for prev, line in pairwise(lines, ""): comment = get_comment(prev) try: key, value = parse_line(line) except LineParseError: continue yield key, value, comment PK!H$..6,pygenstrings-19.3.dist-info/entry_points.txtN+I/N.,()*LO+.)K/E%dZ&fqqPK!Hu)GTU!pygenstrings-19.3.dist-info/WHEEL HM K-*ϳR03rOK-J,/R(O-)$qzd&Y)r$UV&UrPK!HR$pygenstrings-19.3.dist-info/METADATAN0 y AeHhnMv.ISTh<=΄ .bk=j d!JZmu +/NDGko,2 Uh#АqpFMNW1Hql+OSŵ?Wc8?HFA"vֲrJuȖ[Y|"g23!$shmcߎ,oikRPm:^wCK"8ΚoPK!H\y$y"pygenstrings-19.3.dist-info/RECORD}ѹ@἟ld`AYFhL84KY>LiMnVXt{$ +5]RVvU.G^>XtH@y)uLe z2;~#dlo(GƭRAI"}~efFhegāY)Fz*61 x&O~3~ $# IGC"0O8GW-9z 6&GQWEG =ȴ! GU:|艮RwVeZK1fP9ŔLD[ww$JV&JЇ>ȦETǘ(YxIєr"p(z@"}`]N^ڞFQoPK! qpygenstrings/__init__.pyPK!5  Lpygenstrings/cli.pyPK!8\Nr pygenstrings/genstrings.pyPK!Vpygenstrings/parser.pyPK!H$..6,T&pygenstrings-19.3.dist-info/entry_points.txtPK!Hu)GTU!&pygenstrings-19.3.dist-info/WHEELPK!HR$_'pygenstrings-19.3.dist-info/METADATAPK!H\y$y"(pygenstrings-19.3.dist-info/RECORDPK^x*