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

1# Copyright © Michal Čihař <michal@weblate.org> 

2# 

3# SPDX-License-Identifier: GPL-3.0-or-later 

4 

5"""Plain text file formats.""" 

6 

7from __future__ import annotations 

8 

9import os 

10from glob import glob 

11from itertools import chain 

12from typing import TYPE_CHECKING, Any, BinaryIO, NoReturn 

13 

14from django.utils.functional import cached_property 

15from django.utils.translation import gettext, gettext_lazy 

16 

17from weblate.formats.base import ( 

18 BaseItem, 

19 BaseStore, 

20 TranslationFormat, 

21 TranslationUnit, 

22) 

23from weblate.utils.errors import report_error 

24 

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 

27 

28 

29class MultiparserError(Exception): 

30 def __init__(self, filename, original) -> None: 

31 super().__init__() 

32 self.filename = filename 

33 self.original = original 

34 

35 def __str__(self) -> str: 

36 return f"{self.filename}: {self.original}" 

37 

38 

39class TextItem(BaseItem): 

40 """Actual text unit object.""" 

41 

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 

47 

48 @cached_property 

49 def location(self) -> str: 

50 return f"{self.filename}:{self.line}" 

51 

52 def getid(self): 

53 return self.location 

54 

55 

56class TextParser: 

57 """Simple text parser returning all content as single unit.""" 

58 

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)] 

67 

68 

69class TextSerializer: 

70 def __init__(self, filename, units) -> None: 

71 self.units = [unit for unit in units if unit.filename == filename] 

72 

73 def __call__(self, handle): 

74 for unit in self.units: 

75 handle.write(unit.text.encode()) 

76 handle.write(b"\n") 

77 

78 

79class MultiParser(BaseStore): 

80 filenames: tuple[tuple[str, str], ...] = () 

81 units: list[TextItem] 

82 

83 def __init__(self, storefile) -> None: 

84 if not isinstance(storefile, str): 

85 msg = "Needs string as a storefile!" 

86 raise TypeError(msg) 

87 

88 if not os.path.isdir(storefile): 

89 raise ValueError(gettext("Should be a directory with metadata files!")) 

90 

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 ) 

96 

97 def file_key(self, filename): 

98 return filename 

99 

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 

115 

116 def get_filename(self, name): 

117 return os.path.join(self.base, name) 

118 

119 

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 ) 

138 

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 

147 

148 

149class TextUnit(TranslationUnit): 

150 template: TextItem | None 

151 unit: TextItem 

152 mainunit: TextItem 

153 

154 @cached_property 

155 def locations(self): 

156 """Return comma separated list of locations.""" 

157 return self.mainunit.location 

158 

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 

165 

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 

172 

173 @cached_property 

174 def context(self): 

175 """Return context of message.""" 

176 return self.mainunit.location 

177 

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 

185 

186 def set_target(self, target: str | list[str]) -> None: 

187 """Set translation unit target.""" 

188 self._invalidate_target() 

189 self.unit.text = target 

190 

191 def set_state(self, state) -> None: 

192 """Set fuzzy /approved flag on translated unit.""" 

193 return 

194 

195 

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 

208 

209 def load( 

210 self, 

211 storefile: str | BinaryIO, 

212 template_store: TranslationFormat | None, 

213 ) -> AppStoreParser: 

214 return AppStoreParser(storefile) 

215 

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) 

224 

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) 

236 

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) 

240 

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 ) 

253 

254 def get_filenames(self): 

255 return [self.store.get_filename(unit.filename) for unit in self.store.units] 

256 

257 @classmethod 

258 def get_class(cls) -> None: 

259 return None 

260 

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 

282 

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