Coverage for app/venv/lib/python3.14/site-packages/weblate/trans/management/commands/import_project.py: 0%
177 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
4from __future__ import annotations
6import os
7import re
8import tempfile
9from typing import TYPE_CHECKING
11from django.conf import settings
12from django.core.management.base import CommandError
14from weblate.formats.models import FILE_FORMATS
15from weblate.lang.models import Language
16from weblate.logger import LOGGER
17from weblate.trans.discovery import ComponentDiscovery
18from weblate.trans.models import Component, Project
19from weblate.trans.util import is_repo_link
20from weblate.utils.files import remove_tree
21from weblate.utils.management.base import BaseCommand
22from weblate.vcs.base import RepositoryError
23from weblate.vcs.models import VCS_REGISTRY
25if TYPE_CHECKING:
26 from django.core.management.base import CommandParser
29class Command(BaseCommand):
30 """Command for mass importing of repositories into Weblate."""
32 help = "imports projects with more components"
34 def add_arguments(self, parser: CommandParser) -> None:
35 super().add_arguments(parser)
36 parser.add_argument(
37 "--name-template",
38 default="{{ component }}",
39 help="Template string, transforming the file mask match to a project name",
40 )
41 parser.add_argument(
42 "--base-file-template",
43 default="",
44 help=(
45 "Template string, transforming the file mask "
46 "match to a monolingual base filename"
47 ),
48 )
49 parser.add_argument(
50 "--new-base-template",
51 default="",
52 help=(
53 "Template string, transforming the file mask "
54 "match to a base filename for new translations"
55 ),
56 )
57 parser.add_argument(
58 "--file-format",
59 default="po",
60 help="File format type, defaults to Gettext PO",
61 )
62 parser.add_argument(
63 "--language-regex",
64 default="^[^.]+$",
65 help=(
66 "Language filter regular expression to be used for created components"
67 ),
68 )
69 parser.add_argument(
70 "--license", default="", help="License of imported components"
71 )
72 parser.add_argument(
73 "--vcs", default=settings.DEFAULT_VCS, help="Version control system to use"
74 )
75 parser.add_argument(
76 "--push-url", default="", help="Set push URL for the project"
77 )
78 parser.add_argument(
79 "--push-url-same",
80 action="store_true",
81 default=False,
82 help="Set push URL for the project to same as pull",
83 )
84 parser.add_argument(
85 "--disable-push-on-commit",
86 action="store_false",
87 default=settings.DEFAULT_PUSH_ON_COMMIT,
88 dest="push_on_commit",
89 help="Disable push on commit for created components",
90 )
91 parser.add_argument(
92 "--push-on-commit",
93 action="store_true",
94 default=settings.DEFAULT_PUSH_ON_COMMIT,
95 dest="push_on_commit",
96 help="Enable push on commit for created components",
97 )
98 parser.add_argument(
99 "--main-component",
100 default=None,
101 help=(
102 "Define which component will be used as main - including full"
103 " VCS repository"
104 ),
105 )
106 parser.add_argument(
107 "--source-language",
108 default=settings.DEFAULT_LANGUAGE,
109 help="Source language code",
110 )
111 parser.add_argument("project", help="Existing project slug")
112 parser.add_argument("repo", help="VCS repository URL")
113 parser.add_argument("branch", help="VCS repository branch")
114 parser.add_argument("filemask", help="File mask")
116 def __init__(self, *args, **kwargs) -> None:
117 super().__init__(*args, **kwargs)
118 self.filemask: str
119 self.file_format: str
120 self.language_regex: str
121 self.license: str
122 self.main_component: str | None = None
123 self.name_template: str
124 self.source_language: str
125 self.base_file_template: str
126 self.new_base_template: str
127 self.vcs: str
128 self.push_url: str
129 self.discovery: ComponentDiscovery | None = None
130 self.logger = LOGGER
131 self.push_on_commit = True
133 def checkout_tmp(self, project, repo, branch):
134 """Checkout project to temporary location."""
135 # Create temporary working dir
136 workdir = tempfile.mkdtemp(dir=project.full_path)
137 # Make the temporary directory readable by others
138 os.chmod(workdir, 0o755) # noqa: S103, nosec
140 # Initialize git repository
141 self.logger.info("Cloning git repository...")
142 try:
143 gitrepo = VCS_REGISTRY[self.vcs].clone(repo, workdir, branch)
144 except RepositoryError as error:
145 msg = f"Failed clone: {error}"
146 raise CommandError(msg) from error
147 self.logger.info("Updating working copy in git repository...")
148 with gitrepo.lock:
149 gitrepo.configure_branch(branch)
151 return workdir
153 def parse_options(self, repo, options) -> None:
154 """Parse parameters."""
155 self.filemask = options["filemask"]
156 self.vcs = options["vcs"]
157 if options["push_url_same"]:
158 self.push_url = repo
159 else:
160 self.push_url = options["push_url"]
161 self.file_format = options["file_format"]
162 self.language_regex = options["language_regex"]
163 self.main_component = options["main_component"]
164 self.name_template = options["name_template"]
165 self.source_language = Language.objects.get(code=options["source_language"])
166 if "%s" in self.name_template:
167 self.name_template = self.name_template.replace("%s", "{{ component }}")
168 self.license = options["license"]
169 self.push_on_commit = options["push_on_commit"]
170 self.base_file_template = options["base_file_template"]
171 self.new_base_template = options["new_base_template"]
172 if "%s" in self.base_file_template:
173 self.base_file_template = self.base_file_template.replace(
174 "%s", "{{ component }}"
175 )
177 # Is file format supported?
178 if self.file_format not in FILE_FORMATS:
179 msg = "Invalid file format: {}".format(options["file_format"])
180 raise CommandError(msg)
182 # Is vcs supported?
183 if self.vcs not in VCS_REGISTRY:
184 msg = "Invalid vcs: {}".format(options["vcs"])
185 raise CommandError(msg)
187 # Do we have correct mask?
188 # - if there is **, then it's simple mask (it's invalid in regexp)
189 # - validate regexp otherwise
190 if "**" in self.filemask and "*" in self.filemask.replace("**", ""):
191 match = re.escape(self.filemask)
192 match = match.replace(r"\*\*", "(?P<component>[[WILDCARD]])", 1)
193 match = match.replace(r"\*\*", "(?P=component)")
194 match = match.replace(r"\*", "(?P<language>[[WILDCARD]])", 1)
195 match = match.replace(r"\*", "(?P=language)")
196 match = match.replace("[[WILDCARD]]", "[^/]*")
197 self.filemask = match
198 else:
199 try:
200 compiled = re.compile(self.filemask)
201 except re.error as error:
202 msg = f"Could not compile regular expression {self.filemask!r}: {error}"
203 raise CommandError(msg) from error
204 if (
205 "component" not in compiled.groupindex
206 or "language" not in compiled.groupindex
207 ):
208 msg = (
209 "Component regular expression lacks named group "
210 '"component" and/or "language"'
211 )
212 raise CommandError(msg)
214 def handle(self, *args, **options) -> None:
215 """Automatic import of project."""
216 # Read params
217 repo = options["repo"]
218 branch = options["branch"]
219 self.parse_options(repo, options)
221 # Try to get project
222 try:
223 project = Project.objects.get(slug=options["project"])
224 except Project.DoesNotExist as error:
225 msg = 'Project "{}" not found, please create it first!'.format(
226 options["project"]
227 )
228 raise CommandError(msg) from error
230 # Get or create main component
231 if is_repo_link(repo):
232 try:
233 component = Component.objects.get_linked(repo)
234 # Avoid operating on link
235 if component.is_repo_link:
236 component = component.linked_component
237 except Component.DoesNotExist as error:
238 msg = f"Component {repo!r} not found, please create it first!"
239 raise CommandError(msg) from error
240 else:
241 component = self.import_initial(project, repo, branch)
243 discovery = self.get_discovery(component)
244 discovery.perform()
246 def get_discovery(self, component, path=None):
247 """Return discovery object after doing basic sanity check."""
248 if self.discovery is not None:
249 self.discovery.component = component
250 else:
251 self.discovery = ComponentDiscovery(
252 component,
253 match=self.filemask,
254 name_template=self.name_template,
255 language_regex=self.language_regex,
256 base_file_template=self.base_file_template,
257 new_base_template=self.new_base_template,
258 file_format=self.file_format,
259 path=path,
260 )
261 self.logger.info(
262 "Found %d matching files", len(self.discovery.matched_files)
263 )
265 if not self.discovery.matched_files:
266 msg = "Your mask did not match any files!"
267 raise CommandError(msg)
269 self.logger.info(
270 "Found %d components", len(self.discovery.matched_components)
271 )
272 langs = set()
273 for match in self.discovery.matched_components.values():
274 langs.update(match["languages"])
275 self.logger.info("Found %d languages", len(langs))
277 # Do some basic sanity check on languages
278 if not Language.objects.filter(code__in=langs).exists():
279 msg = (
280 "None of matched languages exists, maybe you have "
281 "mixed * and ** in the mask?"
282 )
283 raise CommandError(msg)
284 return self.discovery
286 def import_initial(self, project, repo, branch):
287 """Import the first repository of a project."""
288 # Checkout git to temporary dir
289 workdir = self.checkout_tmp(project, repo, branch)
290 # Create fake discovery without existing component
291 discovery = self.get_discovery(None, workdir)
293 components = project.component_set.all()
295 component = None
297 # Create first component (this one will get full git repo)
298 if self.main_component:
299 match = None
300 for match in discovery.matched_components.values():
301 if match["slug"] == self.main_component:
302 break
303 if match is None or match["slug"] != self.main_component:
304 msg = "Specified --main-component was not found in matches!"
305 raise CommandError(msg)
306 else:
307 # Try if one is already there
308 for match in discovery.matched_components.values():
309 try:
310 component = components.get(repo=repo, filemask=match["mask"])
311 except Component.DoesNotExist:
312 continue
313 # Pick random
314 if component is None:
315 match = next(iter(discovery.matched_components.values()))
317 try:
318 if component is None:
319 component = components.get(slug=match["slug"])
320 self.logger.warning(
321 "Component %s already exists, skipping and using it "
322 "as a main component",
323 match["slug"],
324 )
325 remove_tree(workdir)
326 except Component.DoesNotExist:
327 self.logger.info("Creating component %s as main one", match["slug"])
329 # Rename gitrepository to new name
330 os.rename(workdir, os.path.join(project.full_path, match["slug"]))
332 # Create new component
333 component = discovery.create_component(
334 None,
335 match,
336 existing_slugs=set(),
337 existing_names=set(),
338 project=project,
339 source_language=self.source_language,
340 repo=repo,
341 branch=branch,
342 vcs=self.vcs,
343 push_on_commit=self.push_on_commit,
344 license=self.license,
345 )
347 return component