ExtractCommandExecutor._handle_ignore_patterns()   A
last analyzed

Complexity

Conditions 3

Size

Total Lines 16
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 11
dl 0
loc 16
rs 9.85
c 0
b 0
f 0
cc 3
nop 2
1
import argparse
2
import sys
3
4
if sys.version_info >= (3, 9):
5
  from collections.abc import Iterable
6
7
  List = list
8
  Set  = set
9
10
else:
11
  from typing import Iterable
12
  from typing import List
13
  from typing import Set
14
15
if sys.version_info >= (3, 8):
16
  from typing import Literal
17
  MODE = Literal["w", "a"]
18
else:
19
  MODE = str
20
21
from pathlib import Path
22
from typing import Optional
23
24
from .command_base import BaseCommand
25
from .command_base import BaseCommandExecutor
26
27
from .gettext_tools import extract_translations
28
from .gettext_tools import extract_unique_messages
29
from .gettext_tools import merge_new_and_existing_translations
30
from .gettext_tools import remove_obsolete_translations
31
from .gettext_tools import strip_translations_header
32
from .gettext_tools import validate_gettext_tools_exist
33
34
from .paths import ensure_dir_exists
35
from .paths import find_source_files_paths
36
from .paths import get_names_of_immediate_subdirectories
37
from .paths import make_po_file_path
38
from .paths import make_pot_file_path
39
40
from .text import flatten_comma_separated_values
41
from .text import stringify_path
42
43
from .utils import print_err
44
from .utils import print_out
45
from .utils import show_usage_error_and_halt
46
47
from . import defaults
48
49
50
class ExtractCommandExecutor(BaseCommandExecutor):
51
52
  def __init__(self, args=argparse.Namespace) -> None:
53
    self._domain = args.domain
54
    self._validate_domain(self._domain)
55
56
    self._process_all_locales = args.all
57
58
    self._locales_dir_path = self._handle_locales_dir_path(args.output_dir)
59
    self._validate_locales_dir_path(self._locales_dir_path)
60
61
    self._pot_file_path = make_pot_file_path(self._locales_dir_path, self._domain)
62
63
    self._locales = self._handle_locales(
64
      locales=args.locale,
65
      process_all=self._process_all_locales,
66
      locales_dir_path=self._locales_dir_path,
67
    )
68
    self._validate_locales(self._locales)
69
70
    self._keywords = self._handle_keywords(
71
      keywords=args.keyword,
72
      no_defaults=args.no_default_keywords,
73
    )
74
    self._extensions = self._handle_extensions(args.extensions)
75
    self._follow_links = args.follow_links
76
    self._ignore_patterns = self._handle_ignore_patterns(
77
      ignore_patterns=args.ignore_patterns,
78
      no_defaults=args.no_default_ignore_patterns,
79
    )
80
    self._no_wrap = args.no_wrap
81
    self._no_location = args.no_location
82
    self._no_obsolete = args.no_obsolete
83
    self._keep_pot = args.keep_pot
84
    self._xgettext_extra_args = flatten_comma_separated_values(args.xgettext_extra_args)
85
    self._msguniq_extra_args = flatten_comma_separated_values(args.msguniq_extra_args)
86
    self._msgmerge_extra_args = flatten_comma_separated_values(args.msgmerge_extra_args)
87
    self._msgattrib_extra_args = flatten_comma_separated_values(args.msgattrib_extra_args)
88
    self._verbose = args.verbose
89
90
  @staticmethod
91
  def _handle_keywords(
92
    keywords: Optional[Iterable[str]]=None,
93
    no_defaults: bool=False,
94
  ) -> List[str]:
95
96
    keywords = (
97
      list(keywords)
98
      if keywords is not None
99
      else []
100
    )
101
102
    if not no_defaults:
103
      keywords.extend(defaults.DEFAULT_KEYWORDS)
104
105
    return list(sorted(set(keywords)))
106
107
  @staticmethod
108
  def _handle_extensions(
109
    extensions: Optional[Iterable[str]]=None,
110
    ignored: Optional[Iterable[str]]=None,
111
  ) -> Set[str]:
112
113
    extensions = (
114
      list(extensions)
115
      if extensions is not None
116
      else []
117
    )
118
119
    ignored = (
120
      set(ignored)
121
      if ignored is not None
122
      else set()
123
    )
124
125
    ext_list = []
126
127
    for ext in extensions:
128
      ext_list.extend(ext.replace(" ", "").split(","))
129
130
    for i, ext in enumerate(ext_list):
131
      if not ext.startswith("."):
132
        ext_list[i] = ".%s" % ext_list[i]
133
134
    ext_list.append(".py")
135
136
    return {
137
      x
138
      for x in ext_list
139
      if x.strip(".") not in ignored
140
    }
141
142
  @staticmethod
143
  def _handle_ignore_patterns(
144
    ignore_patterns: Optional[Iterable[str]]=None,
145
    no_defaults: bool=False,
146
  ) -> List[str]:
147
148
    ignore_patterns = (
149
      list(ignore_patterns)
150
      if ignore_patterns is not None
151
      else []
152
    )
153
154
    if not no_defaults:
155
      ignore_patterns.extend(defaults.DEFAULT_IGNORE_PATTERNS)
156
157
    return list(sorted(set(ignore_patterns)))
158
159
  @staticmethod
160
  def _validate_domain(domain: Optional[str]) -> None:
161
    if not domain:
162
      print_err(f"invalid domain value: '{domain}'")
163
      show_usage_error_and_halt()
164
165
  @staticmethod
166
  def _handle_locales_dir_path(path: str) -> Path:
167
    return Path(path).absolute()
168
169
  @staticmethod
170
  def _validate_locales_dir_path(path: Path) -> None:
171
    if path.exists() and not path.is_dir():
172
      print_err(
173
        f"locales dir already exists but it is not a directory "
174
        f"(path={stringify_path(path)})"
175
      )
176
      show_usage_error_and_halt()
177
178
  @staticmethod
179
  def _handle_locales(
180
    locales: Optional[List[str]],
181
    process_all: bool,
182
    locales_dir_path: Path,
183
  ) -> List[str]:
184
185
    if locales:
186
      return flatten_comma_separated_values(locales)
187
    elif process_all:
188
      return get_names_of_immediate_subdirectories(locales_dir_path)
189
    else:
190
      return []
191
192
  @staticmethod
193
  def _validate_locales(locales: List[str]) -> None:
194
    if not locales:
195
      print_err(
196
        "specify at least 1 locale or specify processing of all existing locales"
197
      )
198
      show_usage_error_and_halt()
199
200
  def __call__(self) -> None:
201
    validate_gettext_tools_exist()
202
203
    if self._verbose:
204
      self._print_input_args(
205
        domain=self._domain,
206
        locales_dir_path=stringify_path(self._locales_dir_path),
207
        process_all_locales=self._process_all_locales,
208
        locales=self._locales,
209
        keywords=self._keywords,
210
        extensions=self._extensions,
211
        follow_links=self._follow_links,
212
        ignore_patterns=self._ignore_patterns,
213
        no_wrap=self._no_wrap,
214
        no_location=self._no_location,
215
        no_obsolete=self._no_obsolete,
216
        keep_pot=self._keep_pot,
217
        xgettext_extra_args=self._xgettext_extra_args,
218
        msguniq_extra_args=self._msguniq_extra_args,
219
        msgmerge_extra_args=self._msgmerge_extra_args,
220
        msgattrib_extra_args=self._msgattrib_extra_args,
221
        verbose=self._verbose,
222
      )
223
224
    ensure_dir_exists(self._locales_dir_path)
225
226
    try:
227
      self._make_pot_file()
228
      self._ensure_no_duplicates_in_pot_file()
229
      self._make_all_po_files()
230
    finally:
231
      if not self._keep_pot:
232
        self._maybe_remove_pot_file()
233
234
  def _make_pot_file(self) -> None:
235
    if self._verbose:
236
      print_out("making '.pot' file")
237
238
    self._maybe_remove_pot_file()
239
240
    sources_root_dir_path = Path(".")  # explicitly use relative path
241
242
    for file_path in find_source_files_paths(
243
      root_dir_path=sources_root_dir_path,
244
      ignore_patterns=self._ignore_patterns,
245
      extensions=self._extensions,
246
      follow_links=self._follow_links,
247
      verbose=self._verbose,
248
    ):
249
      self._process_source_file(file_path)
250
251
  def _process_source_file(self, source_file_path: Path) -> None:
252
    if self._verbose:
253
      print_out(f"processing source '{stringify_path(source_file_path.absolute())}'")
254
255
    content = extract_translations(
256
      source_file_path=source_file_path,
257
      domain=self._domain,
258
      keywords=self._keywords,
259
      no_wrap=self._no_wrap,
260
      no_location=self._no_location,
261
      xgettext_extra_args=self._xgettext_extra_args,
262
    )
263
264
    if content:
265
      if self._pot_file_path.exists():
266
        content = strip_translations_header(content)
267
      else:
268
        content = content.replace("charset=CHARSET", "charset=UTF-8")
269
270
      self._write_translations_file(
271
        file_path=self._pot_file_path,
272
        content=content,
273
        mode="a",
274
      )
275
276
  def _ensure_no_duplicates_in_pot_file(self) -> None:
277
    unique_messages = self._extract_unique_messages()
278
279
    self._write_translations_file(
280
      file_path=self._pot_file_path,
281
      content=unique_messages,
282
      mode="w",
283
    )
284
285
  def _extract_unique_messages(self) -> str:
286
    if self._verbose:
287
      print_out("extracting unique messages from '.pot' file")
288
289
    return extract_unique_messages(
290
      pot_file_path=self._pot_file_path,
291
      no_wrap=self._no_wrap,
292
      no_location=self._no_location,
293
      msguniq_extra_args=self._msguniq_extra_args,
294
    )
295
296
  def _make_all_po_files(self) -> None:
297
    if self._verbose:
298
      print_out("making '.po' files")
299
300
    for locale in self._locales:
301
      self._make_po_file_for_locale(locale=locale)
302
303
  def _make_po_file_for_locale(self, locale: str) -> None:
304
    if self._verbose:
305
      print_out(f"processing locale '{locale}'")
306
307
    po_file_path = make_po_file_path(
308
      locales_dir_path=self._locales_dir_path,
309
      locale=locale,
310
      domain=self._domain,
311
    )
312
313
    ensure_dir_exists(po_file_path.parent)
314
315
    if po_file_path.exists():
316
      content = self._merge_new_and_existing_translations(po_file_path)
317
    else:
318
      content = self._pot_file_path.read_text(encoding="utf-8")
319
320
    self._write_translations_file(
321
      file_path=po_file_path,
322
      content=content,
323
      mode="w",
324
    )
325
326
    if self._no_obsolete:
327
      self._remove_obsolete_translations(po_file_path)
328
329
  def _merge_new_and_existing_translations(self, po_file_path: Path) -> str:
330
    if self._verbose:
331
      print_out("merging existing and new messages")
332
333
    return merge_new_and_existing_translations(
334
      po_file_path=po_file_path,
335
      pot_file_path=self._pot_file_path,
336
      no_wrap=self._no_wrap,
337
      no_location=self._no_location,
338
      msgmerge_extra_args=self._msgmerge_extra_args,
339
    )
340
341
  def _remove_obsolete_translations(self, file_path: Path) -> None:
342
    if self._verbose:
343
      print_out("removing obsolete translations")
344
345
    remove_obsolete_translations(
346
      po_file_path=file_path,
347
      no_wrap=self._no_wrap,
348
      no_location=self._no_location,
349
      msgattrib_extra_args=self._msgattrib_extra_args,
350
    )
351
352
  def _write_translations_file(
353
    self,
354
    file_path: Path,
355
    content=str,
356
    mode=MODE,
357
  ) -> None:
358
359
    if self._verbose:
360
      print_out(f"writing to '{stringify_path(file_path)}' file")
361
362
    # Force newlines to '\n' to work around
363
    # https://savannah.gnu.org/bugs/index.php?52395
364
    with file_path.open(mode, encoding="utf-8", newline="\n") as f:
365
      f.write(content)
366
367
  def _maybe_remove_pot_file(self) -> None:
368
    if self._pot_file_path.exists():
369
      if self._verbose:
370
        print_out("removing '.pot' file")
371
372
      self._pot_file_path.unlink()
373
374
375
class ExtractCommand(BaseCommand):
376
  name = "extract"
377
  aliases = ["x", ]
378
  executor_class = ExtractCommandExecutor
379
380
  @classmethod
381
  def make_parser(cls, factory=argparse.ArgumentParser) -> argparse.ArgumentParser:
382
    description = "extract translatable strings from sources into '.po' files"
383
    parser = factory(
384
      prog=cls.name,
385
      description=description,
386
      add_help=True,
387
      help=description,
388
      formatter_class=argparse.ArgumentDefaultsHelpFormatter,
389
    )
390
    parser.add_argument(
391
      "-d", "--domain",
392
      dest="domain",
393
      default=defaults.DEFAULT_DOMAIN,
394
      help="domain of message files",
395
    )
396
    parser.add_argument(
397
      "-l", "--locale",
398
      dest="locale",
399
      action="append",
400
      help=(
401
        "create or update '.po' message files for the given locale(s), "
402
        "ex: 'en_US'; can be specified multiple times"
403
      ),
404
    )
405
    parser.add_argument(
406
      "-a", "--all",
407
      dest="all",
408
      action="store_true",
409
      default=False,
410
      help="update all '.po' message files for all existing locales",
411
    )
412
    parser.add_argument(
413
      "-o", "--output-dir",
414
      dest="output_dir",
415
      default=defaults.DEFAULT_LOCALE_DIR_NAME,
416
      help="path to the directory where locales will be stored, a.k.a. 'locale dir'",
417
    )
418
    parser.add_argument(
419
      "-k", "--keyword",
420
      action="append",
421
      dest="keyword",
422
      help="extra keyword to look for, ex: 'L_'; can be specified multiple times",
423
    )
424
    parser.add_argument(
425
      "--no-default-keywords",
426
      action="store_true",
427
      dest="no_default_keywords",
428
      default=False,
429
      help=(
430
        "do not use default keywords as {{{:}}}".format(
431
          ", ".join(map(repr, defaults.DEFAULT_KEYWORDS))
432
        )
433
      ),
434
    )
435
    parser.add_argument(
436
      "-e", "--extension",
437
      dest="extensions",
438
      action="append",
439
      help=(
440
        "extra file extension(s) to scan in addition to '.py'; separate multiple "
441
        "values with commas or specify the parameter multiple times"
442
      ),
443
    )
444
    parser.add_argument(
445
      "-s", "--links",
446
      action="store_true",
447
      dest="follow_links",
448
      default=False,
449
      help=(
450
        "follow links to files and directories when scanning sources for "
451
        "translation strings"
452
      ),
453
    )
454
    parser.add_argument(
455
      "-i", "--ignore",
456
      action="append",
457
      dest="ignore_patterns",
458
      metavar="PATTERN",
459
      help=(
460
        "extra glob-style patterns for ignoring files or directories; "
461
        "can be specified multiple times"
462
      ),
463
    )
464
    parser.add_argument(
465
      "--no-default-ignore",
466
      action="store_true",
467
      dest="no_default_ignore_patterns",
468
      default=False,
469
      help=(
470
        "do not ignore the common glob-style patterns as {{{:}}}".format(
471
          ", ".join(map(repr, defaults.DEFAULT_IGNORE_PATTERNS))
472
        )
473
      ),
474
    )
475
    parser.add_argument(
476
      "--no-wrap",
477
      action="store_true",
478
      dest="no_wrap",
479
      default=False,
480
      help="do not break long message lines into several lines",
481
    )
482
    parser.add_argument(
483
      "--no-location",
484
      action="store_true",
485
      dest="no_location",
486
      default=False,
487
      help="do not write location lines, ex: '#: filename:lineno'",
488
    )
489
    parser.add_argument(
490
      "--no-obsolete",
491
      action="store_true",
492
      dest="no_obsolete",
493
      default=False,
494
      help="remove obsolete message strings",
495
    )
496
    parser.add_argument(
497
      "--keep-pot",
498
      action="store_true",
499
      dest="keep_pot",
500
      default=False,
501
      help="keep '.pot' file after creating '.po' files (useful for debugging)",
502
    )
503
    parser.add_argument(
504
      "--xgettext-extra-args",
505
      action="append",
506
      dest="xgettext_extra_args",
507
      help=(
508
        "extra arguments for 'xgettext' utility; "
509
        "can be comma-separated or specified multiple times"
510
      ),
511
    )
512
    parser.add_argument(
513
      "--msguniq-extra-args",
514
      action="append",
515
      dest="msguniq_extra_args",
516
      help=(
517
        "extra arguments for 'msguniq' utility; "
518
        "can be comma-separated or specified multiple times"
519
      ),
520
    )
521
    parser.add_argument(
522
      "--msgmerge-extra-args",
523
      action="append",
524
      dest="msgmerge_extra_args",
525
      help=(
526
        "extra arguments for 'msgmerge' utility; "
527
        "can be comma-separated or specified multiple times"
528
      ),
529
    )
530
    parser.add_argument(
531
      "--msgattrib-extra-args",
532
      action="append",
533
      dest="msgattrib_extra_args",
534
      help=(
535
        "extra arguments for 'msgattrib' utility; "
536
        "can be comma-separated or specified multiple times"
537
      ),
538
    )
539
    parser.add_argument(
540
      "-v", "--verbose",
541
      action="store_true",
542
      dest="verbose",
543
      default=False,
544
      help="use verbose output",
545
    )
546
    return parser
547