Coverage for app/venv/lib/python3.14/site-packages/weblate/formats/txt.py: 37%
164 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 07:15 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 07:15 +0000
1# Copyright © Michal Čihař <michal@weblate.org>
2#
3# SPDX-License-Identifier: GPL-3.0-or-later
5"""Plain text file formats."""
7from __future__ import annotations
9import os
10from glob import glob
11from itertools import chain
12from typing import TYPE_CHECKING, Any, BinaryIO, NoReturn
14from django.utils.functional import cached_property
15from django.utils.translation import gettext, gettext_lazy
17from weblate.formats.base import (
18 BaseItem,
19 BaseStore,
20 TranslationFormat,
21 TranslationUnit,
22)
23from weblate.utils.errors import report_error
25if TYPE_CHECKING: 25 ↛ 26line 25 didn't jump to line 26 because the condition on line 25 was never true
26 from collections.abc import Callable
29class MultiparserError(Exception):
30 def __init__(self, filename, original) -> None:
31 super().__init__()
32 self.filename = filename
33 self.original = original
35 def __str__(self) -> str:
36 return f"{self.filename}: {self.original}"
39class TextItem(BaseItem):
40 """Actual text unit object."""
42 def __init__(self, filename, line, text, flags: str | None = None) -> None:
43 self.filename = filename
44 self.line = line
45 self.text = text
46 self.flags = flags
48 @cached_property
49 def location(self) -> str:
50 return f"{self.filename}:{self.line}"
52 def getid(self):
53 return self.location
56class TextParser:
57 """Simple text parser returning all content as single unit."""
59 def __init__(self, storefile, filename=None, flags: str | None = None) -> None:
60 with open(storefile) as handle:
61 content = handle.read()
62 if filename:
63 self.filename = filename
64 else:
65 self.filename = os.path.basename(storefile)
66 self.units = [TextItem(self.filename, 1, content.strip(), flags)]
69class TextSerializer:
70 def __init__(self, filename, units) -> None:
71 self.units = [unit for unit in units if unit.filename == filename]
73 def __call__(self, handle):
74 for unit in self.units:
75 handle.write(unit.text.encode())
76 handle.write(b"\n")
79class MultiParser(BaseStore):
80 filenames: tuple[tuple[str, str], ...] = ()
81 units: list[TextItem]
83 def __init__(self, storefile) -> None:
84 if not isinstance(storefile, str):
85 msg = "Needs string as a storefile!"
86 raise TypeError(msg)
88 if not os.path.isdir(storefile):
89 raise ValueError(gettext("Should be a directory with metadata files!"))
91 self.base = storefile
92 self.parsers = self.load_parser()
93 self.units = list(
94 chain.from_iterable(parser.units for parser in self.parsers.values())
95 )
97 def file_key(self, filename):
98 return filename
100 def load_parser(self):
101 result = {}
102 for name, flags in self.filenames:
103 filename = self.get_filename(name)
104 for match in sorted(glob(filename), key=self.file_key):
105 # Needed to allow overlapping globs, more specific first
106 if match in result:
107 continue
108 try:
109 result[match] = TextParser(
110 match, os.path.relpath(match, self.base), flags
111 )
112 except Exception as error:
113 raise MultiparserError(match, error) from error
114 return result
116 def get_filename(self, name):
117 return os.path.join(self.base, name)
120class AppStoreParser(MultiParser):
121 filenames = (
122 ("title.txt", "max-length:30"),
123 ("name.txt", "max-length:30"),
124 ("short[_-]description.txt", "max-length:80"),
125 ("summary.txt", "max-length:80"),
126 ("full[_-]description.txt", "max-length:4000"),
127 ("subtitle.txt", "max-length:80"),
128 ("description.txt", "max-length:4000"),
129 ("keywords.txt", "max-length:100"),
130 ("video.txt", "max-length:256, url"),
131 ("marketing_url.txt", "max-length:256, url"),
132 ("privacy_url.txt", "max-length:256, url"),
133 ("support_url.txt", "max-length:256, url"),
134 ("antifeatures/*.txt", "max-length:500"),
135 ("changelogs/*.txt", "max-length:500"),
136 ("*.txt", ""),
137 )
139 def file_key(self, filename):
140 parts = filename.rsplit("changelogs/", 1)
141 if len(parts) == 2:
142 try:
143 return "-{}".format(int(parts[1].split(".")[0]))
144 except ValueError:
145 pass
146 return filename
149class TextUnit(TranslationUnit):
150 template: TextItem | None
151 unit: TextItem
152 mainunit: TextItem
154 @cached_property
155 def locations(self):
156 """Return comma separated list of locations."""
157 return self.mainunit.location
159 @cached_property
160 def source(self):
161 """Return source string from a ttkit unit."""
162 if self.template is not None:
163 return self.template.text
164 return self.unit.text
166 @cached_property
167 def target(self):
168 """Return target string from a ttkit unit."""
169 if self.unit is None:
170 return ""
171 return self.unit.text
173 @cached_property
174 def context(self):
175 """Return context of message."""
176 return self.mainunit.location
178 @cached_property
179 def flags(self):
180 """Return flags from unit."""
181 flags = super().flags
182 if self.mainunit.flags:
183 flags.merge(self.mainunit.flags)
184 return flags
186 def set_target(self, target: str | list[str]) -> None:
187 """Set translation unit target."""
188 self._invalidate_target()
189 self.unit.text = target
191 def set_state(self, state) -> None:
192 """Set fuzzy /approved flag on translated unit."""
193 return
196class AppStoreFormat(TranslationFormat):
197 # Translators: File format name
198 name = gettext_lazy("App store metadata files")
199 format_id = "appstore"
200 can_add_unit = False
201 can_delete_unit = True
202 monolingual = True
203 unit_class = TextUnit
204 simple_filename = False
205 language_format = "googleplay"
206 create_style = "directory"
207 store: AppStoreParser
209 def load(
210 self,
211 storefile: str | BinaryIO,
212 template_store: TranslationFormat | None,
213 ) -> AppStoreParser:
214 return AppStoreParser(storefile)
216 def create_unit(
217 self,
218 key: str,
219 source: str | list[str],
220 target: str | list[str] | None = None,
221 ) -> NoReturn:
222 msg = "Create not supported"
223 raise ValueError(msg)
225 @classmethod
226 def create_new_file(
227 cls,
228 filename: str,
229 language: str, # noqa: ARG003
230 base: str, # noqa: ARG003
231 callback: Callable | None = None, # noqa: ARG003
232 file_format_params: dict[str, Any] | None = None, # noqa: ARG003
233 ) -> None:
234 """Handle creation of new translation file."""
235 os.makedirs(filename)
237 def add_unit(self, unit: TextUnit) -> None: # type: ignore[override]
238 """Add new unit to underlying store."""
239 self.store.units.append(unit.unit)
241 def save(self) -> None:
242 """Save underlying store to disk."""
243 for unit in self.store.units:
244 filename = self.store.get_filename(unit.filename)
245 if not unit.text:
246 if os.path.exists(filename):
247 os.unlink(filename)
248 continue
249 self.save_atomic(
250 filename,
251 TextSerializer(unit.filename, self.store.units),
252 )
254 def get_filenames(self):
255 return [self.store.get_filename(unit.filename) for unit in self.store.units]
257 @classmethod
258 def get_class(cls) -> None:
259 return None
261 @classmethod
262 def is_valid_base_for_new(
263 cls,
264 base: str,
265 monolingual: bool, # noqa: ARG003
266 errors: list[Exception] | None = None,
267 fast: bool = False,
268 file_format_params: dict[str, Any] | None = None, # noqa: ARG003
269 ) -> bool:
270 """Check whether base is valid."""
271 if not base:
272 return True
273 try:
274 if not fast:
275 AppStoreParser(base)
276 except Exception as exception:
277 if errors is not None:
278 errors.append(exception)
279 report_error("File-parsing error")
280 return False
281 return True
283 def delete_unit(self, ttkit_unit) -> str | None:
284 filename = self.store.get_filename(ttkit_unit.filename)
285 os.unlink(filename)
286 return filename