Coverage for app/venv/lib/python3.14/site-packages/weblate/trans/discovery.py: 13%

200 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 

4from __future__ import annotations 

5 

6import os 

7import re 

8from itertools import chain 

9from typing import TYPE_CHECKING 

10 

11from django.core.exceptions import ValidationError 

12from django.utils.functional import cached_property 

13from django.utils.text import slugify 

14from django.utils.translation import gettext 

15 

16from weblate.formats.models import FILE_FORMATS 

17from weblate.logger import LOGGER 

18from weblate.trans.defines import COMPONENT_NAME_LENGTH 

19from weblate.trans.models import Component 

20from weblate.trans.tasks import create_component 

21from weblate.trans.util import path_separator 

22from weblate.utils.render import render_template 

23 

24if TYPE_CHECKING: 24 ↛ 25line 24 didn't jump to line 25 because the condition on line 24 was never true

25 from weblate.formats.base import TranslationFormat 

26 

27# Attributes to copy from main component 

28COPY_ATTRIBUTES = ( 

29 "project", 

30 "vcs", 

31 "license", 

32 "agreement", 

33 "source_language", 

34 "report_source_bugs", 

35 "allow_translation_propagation", 

36 "enable_suggestions", 

37 "suggestion_voting", 

38 "suggestion_autoaccept", 

39 "check_flags", 

40 "new_lang", 

41 "language_code_style", 

42 "commit_message", 

43 "add_message", 

44 "delete_message", 

45 "merge_message", 

46 "addon_message", 

47 "pull_message", 

48 "push_on_commit", 

49 "commit_pending_age", 

50 "edit_template", 

51 "manage_units", 

52 "variant_regex", 

53 "category_id", 

54 "key_filter", 

55 "secondary_language", 

56) 

57 

58 

59class ComponentDiscovery: 

60 def __init__( 

61 self, 

62 component, 

63 *, 

64 match: str, 

65 name_template: str, 

66 file_format: str, 

67 language_regex: str = "^[^.]+$", 

68 base_file_template: str = "", 

69 new_base_template: str = "", 

70 intermediate_template: str = "", 

71 path=None, 

72 copy_addons=True, 

73 ) -> None: 

74 self.component = component 

75 if path is None: 

76 self.path = self.component.full_path 

77 else: 

78 self.path = path 

79 self.path_match = self.compile_match(match) 

80 self.name_template = name_template 

81 self.base_file_template = base_file_template 

82 self.new_base_template = new_base_template 

83 self.intermediate_template = intermediate_template 

84 self.language_re = language_regex 

85 self.language_match = re.compile(language_regex) 

86 self.file_format = file_format 

87 self.copy_addons = copy_addons 

88 

89 @cached_property 

90 def file_format_cls(self) -> type[TranslationFormat]: 

91 return FILE_FORMATS[self.file_format] 

92 

93 @staticmethod 

94 def extract_kwargs(params): 

95 """Extract kwargs for discovery from wider dict.""" 

96 attrs = ( 

97 "match", 

98 "name_template", 

99 "language_regex", 

100 "base_file_template", 

101 "new_base_template", 

102 "intermediate_template", 

103 "file_format", 

104 "copy_addons", 

105 ) 

106 return {k: v for k, v in params.items() if k in attrs} 

107 

108 def compile_match(self, match): 

109 parts = match.split("(?P=language)") 

110 offset = 1 

111 while len(parts) > 1: 

112 parts[0:2] = [f"{parts[0]}(?P<_language_{offset}>(?P=language)){parts[1]}"] 

113 offset += 1 

114 return re.compile(f"^{parts[0]}$") 

115 

116 @cached_property 

117 def matches(self): 

118 """Return matched files together with match groups and mask.""" 

119 result = [] 

120 base = os.path.realpath(self.path) 

121 for root, dirnames, filenames in os.walk(self.path, followlinks=True): 

122 for filename in chain(filenames, dirnames): 

123 fullname = os.path.join(root, filename) 

124 

125 # Skip files outside our root 

126 if not os.path.realpath(fullname).startswith(base): 

127 continue 

128 

129 # Calculate relative path 

130 path = path_separator(os.path.relpath(fullname, self.path)) 

131 

132 # Check match against our regexp 

133 matches = self.path_match.match(path) 

134 if not matches: 

135 continue 

136 

137 # Check language regexp 

138 language_part = matches.group("language") 

139 if language_part is None or not self.language_match.match( 

140 language_part 

141 ): 

142 continue 

143 

144 # Calculate file mask for match 

145 replacements = [(matches.start("language"), matches.end("language"))] 

146 replacements.extend( 

147 (matches.start(group), matches.end(group)) 

148 for group in matches.groupdict() 

149 if group.startswith("_language_") 

150 ) 

151 maskparts = [] 

152 maskpath = path 

153 for start, end in sorted(replacements, reverse=True): 

154 maskparts.append(maskpath[end:]) 

155 maskpath = maskpath[:start] 

156 maskparts.append(maskpath) 

157 

158 mask = "*".join(reversed(maskparts)) 

159 

160 result.append((path, matches.groupdict(), mask)) 

161 

162 return result 

163 

164 @cached_property 

165 def matched_files(self): 

166 """Return list of matched files.""" 

167 return [x[0] for x in self.matches] 

168 

169 @cached_property 

170 def matched_components(self): 

171 """Return list of matched components.""" 

172 result = {} 

173 for path, groups, mask in self.matches: 

174 if mask not in result: 

175 name = render_template(self.name_template, **groups) 

176 result[mask] = { 

177 "files": {path}, 

178 "languages": {groups["language"]}, 

179 "files_langs": {(path, groups["language"])}, 

180 "base_file": render_template(self.base_file_template, **groups), 

181 "new_base": render_template(self.new_base_template, **groups), 

182 "intermediate": render_template( 

183 self.intermediate_template, **groups 

184 ), 

185 "mask": mask, 

186 "name": name, 

187 "slug": slugify(name), 

188 } 

189 else: 

190 result[mask]["files"].add(path) 

191 result[mask]["languages"].add(groups["language"]) 

192 result[mask]["files_langs"].add((path, groups["language"])) 

193 return result 

194 

195 def log(self, *args) -> None: 

196 if self.component: 

197 self.component.log_info(*args) 

198 else: 

199 LOGGER.info(*args) 

200 

201 def create_component( 

202 self, 

203 main: Component | None, 

204 match, 

205 *, 

206 background: bool = False, 

207 preview: bool = False, 

208 existing_slugs: set[str], 

209 existing_names: set[str], 

210 **kwargs, 

211 ): 

212 max_length = COMPONENT_NAME_LENGTH 

213 

214 def get_val(key, extra=0): 

215 result = match[key] 

216 if len(result) > max_length - extra: 

217 result = result[: max_length - extra] 

218 return result 

219 

220 # Get name and slug 

221 name = get_val("name") or "Component" 

222 slug = get_val("slug") or "component" 

223 

224 # Copy attributes from main component 

225 for key in COPY_ATTRIBUTES: 

226 if key not in kwargs and main is not None: 

227 kwargs[key] = getattr(main, key) 

228 

229 # Disable template editing if not supported by format 

230 if not self.file_format_cls.can_edit_base: 

231 kwargs["edit_template"] = False 

232 

233 # Fill in repository 

234 if "repo" not in kwargs: 

235 if main is None: 

236 raise ValueError 

237 kwargs["repo"] = main.get_repo_link_url() 

238 

239 # Deal with duplicate name or slug in the same (or none) category 

240 if slug.lower() in existing_slugs or name.lower() in existing_names: 

241 base_name = get_val("name", 4) 

242 base_slug = get_val("slug", 4) 

243 

244 for i in range(1, 1000): 

245 name = f"{base_name} {i}" 

246 slug = f"{base_slug}-{i}" 

247 

248 if slug.lower() in existing_slugs or name.lower() in existing_names: 

249 continue 

250 break 

251 existing_slugs.add(slug.lower()) 

252 existing_names.add(name.lower()) 

253 

254 # Fill in remaining attributes 

255 kwargs.update( 

256 { 

257 "name": name, 

258 "slug": slug, 

259 "template": match["base_file"], 

260 "filemask": match["mask"], 

261 "new_base": match["new_base"], 

262 "intermediate": match["intermediate"], 

263 "file_format": self.file_format, 

264 "language_regex": self.language_re, 

265 "copy_from": main.pk if main else None, 

266 "copy_addons": self.copy_addons, 

267 } 

268 ) 

269 

270 # Create non-saved object for validation 

271 component_kwargs = kwargs.copy() 

272 component_kwargs.pop("copy_from") 

273 component_kwargs.pop("copy_addons") 

274 # main can be None as well 

275 component = Component(**component_kwargs, linked_component=main) 

276 

277 # Special handling for new_lang 

278 try: 

279 component.clean_new_lang() 

280 except ValidationError as error: 

281 component.new_lang = kwargs["new_lang"] = "none" 

282 self.log("Disabling adding new languages for %s because of %s", name, error) 

283 

284 # This might raise an exception 

285 component.full_clean() 

286 

287 if preview: 

288 return component 

289 

290 self.log("Creating component %s", name) 

291 

292 # Can't pass objects, pass only IDs 

293 kwargs["project"] = kwargs["project"].pk 

294 kwargs["source_language"] = kwargs["source_language"].pk 

295 if background: 

296 create_component.delay(**kwargs, in_task=True) 

297 return None 

298 return create_component(**kwargs) 

299 

300 def cleanup(self, main, processed, preview=False): 

301 deleted = [] 

302 for component in main.linked_children.exclude(pk__in=processed): 

303 if component.has_template(): 

304 # Valid template? 

305 if os.path.exists(component.get_template_filename()): 

306 continue 

307 elif component.new_base: 

308 # Valid new base? 

309 if os.path.exists(component.get_new_base_filename()): 

310 continue 

311 elif component.get_mask_matches(): 

312 continue 

313 

314 # Delete as needed files seem to be missing 

315 deleted.append((None, component)) 

316 if not preview: 

317 component.delete() 

318 

319 return deleted 

320 

321 def get_skip_reason(self, match): 

322 # Skip matches to main component 

323 if match["mask"] == self.component.filemask: 

324 return gettext("File mask matches the main component.") 

325 

326 for param in ("base_file", "new_base", "intermediate"): 

327 name = match[param] 

328 if not name: 

329 continue 

330 fullname = os.path.join(self.component.full_path, name) 

331 if not os.path.exists(fullname): 

332 return gettext("{filename} ({parameter}) does not exist.").format( 

333 filename=name, 

334 parameter=param, 

335 ) 

336 

337 return None 

338 

339 def perform(self, preview=False, remove=False, background=False): 

340 created = [] 

341 matched = [] 

342 deleted = [] 

343 skipped = [] 

344 processed = set() 

345 

346 main = self.component 

347 category_components = main.project.component_set.filter(category=main.category) 

348 existing_children: dict[str, Component] = { 

349 component.filemask: component for component in main.linked_children.all() 

350 } 

351 existing_slugs: set[str] = { 

352 slug.lower() for slug in category_components.values_list("slug", flat=True) 

353 } 

354 existing_names: set[str] = { 

355 name.lower() for name in category_components.values_list("name", flat=True) 

356 } 

357 

358 for match in self.matched_components.values(): 

359 # Skip invalid matches 

360 reason = self.get_skip_reason(match) 

361 if reason is not None: 

362 skipped.append((match, reason)) 

363 continue 

364 

365 try: 

366 found = existing_children[match["mask"]] 

367 except KeyError: 

368 # Create new component 

369 try: 

370 component = self.create_component( 

371 main, 

372 match, 

373 background=background, 

374 preview=preview, 

375 existing_slugs=existing_slugs, 

376 existing_names=existing_names, 

377 ) 

378 except ValidationError as error: 

379 skipped.append((match, str(error))) 

380 else: 

381 # Correctly created 

382 if component is not None and component.id: 

383 processed.add(component.id) 

384 created.append((match, component)) 

385 existing_children[match["mask"]] = component 

386 else: 

387 # Component already exists 

388 matched.append((match, found)) 

389 processed.add(found.id) 

390 

391 if remove: 

392 deleted = self.cleanup(main, processed, preview) 

393 

394 return created, matched, deleted, skipped