lingva 5.0.7__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
lingva/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from importlib.metadata import version
2
+
3
+ __version__ = version("lingva")
lingva/extract.py ADDED
@@ -0,0 +1,492 @@
1
+ import os
2
+ import re
3
+ import sys
4
+ import tempfile
5
+ from collections import OrderedDict
6
+ from configparser import ConfigParser as SafeConfigParser
7
+ from datetime import datetime
8
+ from operator import attrgetter
9
+
10
+ import click
11
+ import polib
12
+
13
+ from lingva import __version__
14
+ from lingva.extractors import EXTENSIONS, EXTRACTORS, get_extractor, register_extractors
15
+ from lingva.extractors.babel import register_babel_plugins
16
+
17
+
18
+ def po_timestamp():
19
+ now = datetime.now().astimezone()
20
+ return f"{now:%Y-%m-%d %H:%M%z}"
21
+
22
+
23
+ def _same_text(a, b):
24
+ a = re.sub(r"\s+", " ", a)
25
+ b = re.sub(r"\s+", " ", b)
26
+ return a == b
27
+
28
+
29
+ class POEntry(polib.POEntry):
30
+ def __init__(self, *a, **kw):
31
+ polib.POEntry.__init__(self, *a, **kw)
32
+ self._comments = []
33
+ self._tcomments = []
34
+
35
+ @property
36
+ def comment(self):
37
+ return "\n".join(self._comments)
38
+
39
+ @comment.setter
40
+ def comment(self, value):
41
+ pass
42
+
43
+ @property
44
+ def tcomment(self):
45
+ return "\n".join(self._tcomments)
46
+
47
+ @tcomment.setter
48
+ def tcomment(self, value):
49
+ pass
50
+
51
+ def __eq__(self, other):
52
+ r = super().__eq__(other)
53
+ if not r:
54
+ return False
55
+ return _same_text(other.comment, self.comment) and _same_text(
56
+ other.tcomment, self.tcomment
57
+ )
58
+
59
+ def update(self, message, add_occurrences=True):
60
+ if add_occurrences:
61
+ self.occurrences.append((message.location[0], str(message.location[1])))
62
+ self.flags.extend(f for f in message.flags if f not in self.flags)
63
+ if message.comment not in self._comments:
64
+ self._comments.append(message.comment)
65
+ if message.tcomment not in self._tcomments:
66
+ self._tcomments.append(message.tcomment)
67
+
68
+
69
+ class POFile(polib.POFile):
70
+ copyright_holder = None
71
+ package_name = None
72
+
73
+ def metadata_as_entry(self):
74
+ entry = polib.POFile.metadata_as_entry(self)
75
+ year = datetime.now().year
76
+ header = ["SOME DESCRIPTIVE TITLE"]
77
+ if self.copyright_holder:
78
+ header.append(f"Copyright (C) {year} {self.copyright_holder}")
79
+ header.append(
80
+ f"This file is distributed under the same license as the {self.package_name} package."
81
+ )
82
+ header.append(f"FIRST AUTHOR <EMAIL@ADDRESS>, {year}.")
83
+ entry.tcomment = "\n".join(header)
84
+ return entry
85
+
86
+
87
+ def no_duplicates(iterator):
88
+ seen = set()
89
+ for item in iterator:
90
+ if item in seen:
91
+ continue
92
+ seen.add(item)
93
+ yield item
94
+
95
+
96
+ def list_files(files_from, sources):
97
+ if files_from:
98
+ for filename in files_from:
99
+ if filename.startswith("#") or not filename.strip():
100
+ continue
101
+ yield filename.rstrip()
102
+ for file in sources:
103
+ if os.path.isfile(file):
104
+ yield file
105
+ elif os.path.isdir(file):
106
+ for dirpath, dirnames, filenames in os.walk(file):
107
+ for file in filenames:
108
+ if get_extractor(file) is not None:
109
+ yield os.path.join(dirpath, file)
110
+ else:
111
+ click.echo(f"Invalid file type for {file}", err=True)
112
+ sys.exit(1)
113
+
114
+
115
+ def find_file(filename, search_path=None):
116
+ """Return the filename for a given file, checking search paths."""
117
+ for path in (os.path.curdir, *(search_path or ())):
118
+ filename = os.path.join(path, filename)
119
+ if os.path.isfile(filename):
120
+ return filename
121
+ return None
122
+
123
+
124
+ def strip_linenumbers(entry):
125
+ seen = set()
126
+ occurrences = []
127
+ for location, line in entry.occurrences:
128
+ if location in seen:
129
+ continue
130
+ occurrences.append((location, ""))
131
+ seen.add(location)
132
+ entry.occurrences = occurrences
133
+
134
+
135
+ def create_catalog(width, copyright_holder, package_name, package_version, msgid_bugs_address):
136
+ catalog = POFile(wrapwidth=width)
137
+ catalog.copyright_holder = copyright_holder
138
+ catalog.package_name = package_name
139
+ catalog.metadata_is_fuzzy = True
140
+ catalog.metadata = OrderedDict()
141
+ catalog.metadata["Project-Id-Version"] = " ".join(
142
+ filter(None, [package_name, package_version])
143
+ )
144
+ if msgid_bugs_address:
145
+ catalog.metadata["Report-Msgid-Bugs-To"] = msgid_bugs_address
146
+ po_time = po_timestamp()
147
+ catalog.metadata["POT-Creation-Date"] = po_time
148
+ catalog.metadata["PO-Revision-Date"] = po_time
149
+ catalog.metadata["Last-Translator"] = "FULL NAME <EMAIL@ADDRESS>"
150
+ catalog.metadata["Language-Team"] = "LANGUAGE <LL@li.org>"
151
+ catalog.metadata["Language"] = "LANGUAGE"
152
+ catalog.metadata["MIME-Version"] = "1.0"
153
+ catalog.metadata["Content-Type"] = "text/plain; charset=UTF-8"
154
+ catalog.metadata["Content-Transfer-Encoding"] = "8bit"
155
+ catalog.metadata["Generated-By"] = f"Lingva {__version__}"
156
+ return catalog
157
+
158
+
159
+ def _register_extension(extension, extractor):
160
+ if extractor not in EXTRACTORS:
161
+ click.echo(
162
+ f"Unknown extractor {extractor}. Check --list-extractors for available options",
163
+ err=True,
164
+ )
165
+ sys.exit(1)
166
+ EXTENSIONS[extension] = extractor
167
+
168
+
169
+ def read_config(cfg_file):
170
+ config = SafeConfigParser()
171
+ config.read_file(cfg_file)
172
+ for section in config.sections():
173
+ if section == "extensions":
174
+ for extension, extractor in config.items(section):
175
+ _register_extension(extension, extractor)
176
+ elif section.startswith("extractor:"):
177
+ extractor = section[10:]
178
+ if extractor not in EXTRACTORS:
179
+ click.echo(
180
+ f"Unknown extractor {extractor}. "
181
+ "Check --list-extractors for available options",
182
+ err=True,
183
+ )
184
+ sys.exit(1)
185
+ extractor_config = dict(config.items(section))
186
+ EXTRACTORS[extractor].update_config(**extractor_config)
187
+ elif section.startswith("extension"):
188
+ click.echo(
189
+ f'Use of {section} section is obsolete. Please use the "extensions" section.',
190
+ err=True,
191
+ )
192
+ extension = section[10:]
193
+ plugin = config.get(section, "plugin")
194
+ if not plugin:
195
+ click.echo(f"No plugin defined for extension {extension}", err=True)
196
+ _register_extension(extension, plugin)
197
+
198
+
199
+ def _summarise(catalog):
200
+ summary = {}
201
+ for entry in catalog:
202
+ if entry.obsolete:
203
+ continue
204
+ summary[(entry.msgid, entry.msgctxt)] = entry
205
+ return summary
206
+
207
+
208
+ def identical(a, b):
209
+ """Check if two catalogs are identical, ignoring metadata."""
210
+ a = _summarise(a)
211
+ b = _summarise(b)
212
+ return a == b
213
+
214
+
215
+ def _location_sort_key(msg):
216
+ locations = [(fn, int(line)) for (fn, line) in msg.occurrences]
217
+ locations.sort() # Sort so first occurence is always used.
218
+ return locations
219
+
220
+
221
+ class ExtractorOptions:
222
+ def __init__(self, comment_tag, domain, keywords):
223
+ self.comment_tag = comment_tag
224
+ self.domain = domain
225
+ self.keywords = keywords
226
+
227
+
228
+ def extract(
229
+ cfg_file=None,
230
+ files_from=None,
231
+ directory=None,
232
+ sources=None,
233
+ list_extractors=None,
234
+ quiet=False,
235
+ output="messages.pot",
236
+ location=True,
237
+ linenumbers=True,
238
+ width=79,
239
+ sort_order=None,
240
+ allow_empty=False,
241
+ domain=None,
242
+ keywords=None,
243
+ comment_tag=None,
244
+ copyright_holder=None,
245
+ package_name="PACKAGE",
246
+ package_version="1.0",
247
+ msgid_bugs_address=None,
248
+ ):
249
+ """Extract translatable strings."""
250
+ register_extractors()
251
+ register_babel_plugins()
252
+
253
+ if comment_tag is None:
254
+ comment_tag = True
255
+ if list_extractors:
256
+ for extractor in sorted(EXTRACTORS):
257
+ click.echo(f"{extractor:<17} {EXTRACTORS[extractor].__doc__ or ''}")
258
+ return
259
+
260
+ if cfg_file:
261
+ read_config(cfg_file)
262
+ else:
263
+ user_home = os.path.expanduser("~")
264
+ global_config = os.path.join(user_home, ".config", "lingva")
265
+ if os.path.exists(global_config):
266
+ read_config(open(global_config))
267
+
268
+ catalog = create_catalog(
269
+ width, copyright_holder, package_name, package_version, msgid_bugs_address
270
+ )
271
+
272
+ scanned = 0
273
+ if directory and not isinstance(directory, list):
274
+ directory = list(directory)
275
+ for filename in no_duplicates(list_files(files_from, sources)):
276
+ real_filename = find_file(filename, directory)
277
+ if real_filename is None:
278
+ click.echo(f"Can not find file {filename}", err=True)
279
+ sys.exit(1)
280
+ extractor = get_extractor(real_filename)
281
+ if extractor is None:
282
+ click.echo(f"No extractor available for file {filename}", err=True)
283
+ sys.exit(1)
284
+
285
+ extractor_options = ExtractorOptions(
286
+ comment_tag=comment_tag,
287
+ domain=domain,
288
+ keywords=keywords,
289
+ )
290
+ for message in extractor(real_filename, extractor_options):
291
+ entry = catalog.find(message.msgid, msgctxt=message.msgctxt)
292
+ if entry is None:
293
+ entry = POEntry(msgctxt=message.msgctxt, msgid=message.msgid)
294
+ if message.msgid_plural:
295
+ entry.msgid_plural = message.msgid_plural
296
+ entry.msgstr_plural[0] = ""
297
+ entry.msgstr_plural[1] = ""
298
+ catalog.append(entry)
299
+ entry.update(message, add_occurrences=location)
300
+ scanned += 1
301
+ if not scanned:
302
+ click.echo("No files scanned, aborting", err=True)
303
+ sys.exit(1)
304
+ if not catalog and not allow_empty:
305
+ click.echo("No translatable strings found, aborting", err=True)
306
+ sys.exit(2)
307
+
308
+ if sort_order == "msgid":
309
+ catalog.sort(key=attrgetter("msgid"))
310
+ elif sort_order == "location":
311
+ catalog.sort(key=_location_sort_key)
312
+
313
+ if not linenumbers:
314
+ for entry in catalog:
315
+ strip_linenumbers(entry)
316
+
317
+ if os.path.exists(output):
318
+ old_catalog: POFile | None = None
319
+ try:
320
+ old_catalog = polib.pofile(output)
321
+ except (OSError, UnicodeDecodeError):
322
+ pass
323
+ if old_catalog is not None and identical(catalog, old_catalog):
324
+ if not quiet:
325
+ click.echo(f"No changes found - not replacing {output}")
326
+ return
327
+ os.unlink(output)
328
+ fd, tmpfile = tempfile.mkstemp(dir=os.path.dirname(output), text=True)
329
+ with open(fd, "w", encoding=catalog.encoding) as f:
330
+ f.write(catalog.__unicode__())
331
+ os.rename(tmpfile, output)
332
+
333
+
334
+ @click.command()
335
+ @click.option(
336
+ "-c",
337
+ "--config",
338
+ "cfg_file",
339
+ metavar="CONFIG",
340
+ help="Read configuration from CONFIG file",
341
+ type=click.File(),
342
+ )
343
+ # Input options
344
+ @click.option(
345
+ "-f",
346
+ "--files-from",
347
+ metavar="FILE",
348
+ type=click.File(),
349
+ help="Get list of files to process from FILE",
350
+ )
351
+ @click.option(
352
+ "-D",
353
+ "--directory",
354
+ metavar="DIRECTORY",
355
+ type=click.Path(exists=True, file_okay=False, dir_okay=True),
356
+ multiple=True,
357
+ help="Add DIRECTORY to list of paths to check for input files",
358
+ )
359
+ @click.argument("sources", nargs=-1, type=click.Path(exists=True))
360
+ @click.option("--list-extractors", is_flag=True, help="List all known extraction plugins")
361
+ @click.option(
362
+ "-q",
363
+ "--quiet",
364
+ "quiet",
365
+ default=False,
366
+ is_flag=True,
367
+ help="Show error messages only",
368
+ )
369
+ # Output options
370
+ @click.option(
371
+ "-o",
372
+ "--output",
373
+ metavar="FILE",
374
+ type=click.Path(exists=False, dir_okay=False, writable=True),
375
+ default="messages.pot",
376
+ help="Filename for generated POT file",
377
+ )
378
+ @click.option(
379
+ "--add-location/--no-location",
380
+ "location",
381
+ default=True,
382
+ help="Include location information",
383
+ )
384
+ @click.option(
385
+ "--linenumbers/--no-linenumbers",
386
+ default=True,
387
+ help="Include line numbers in location information",
388
+ )
389
+ @click.option("-w", "--width", metavar="NUMBER", default=79, help="Output width")
390
+ @click.option(
391
+ "-s",
392
+ "--sort-output",
393
+ "sort_order", # babel compatibility
394
+ flag_value="msgid",
395
+ help="Order messages by their msgid",
396
+ )
397
+ @click.option(
398
+ "-F",
399
+ "--sort-by-file",
400
+ "sort_order",
401
+ flag_value="location",
402
+ help="Order messages by file location",
403
+ )
404
+ @click.option(
405
+ "--allow-empty/--no-allow-empty",
406
+ "allow_empty",
407
+ default=False,
408
+ help="Allow output file with no msg entries",
409
+ )
410
+ # Extraction configuration
411
+ @click.option("-d", "--domain", help="Domain to extract")
412
+ @click.option(
413
+ "-k",
414
+ "--keyword",
415
+ "keywords",
416
+ metavar="WORD",
417
+ multiple=True,
418
+ help="Look for WORD as additional keyword",
419
+ )
420
+ @click.option(
421
+ "-C",
422
+ "--add-comments",
423
+ "comment_tag",
424
+ metavar="TAG",
425
+ help="Add comments prefixed by TAG to messages, or all if no tag is given",
426
+ )
427
+ # POT metadata
428
+ @click.option(
429
+ "--copyright-holder",
430
+ metavar="STRING",
431
+ help="Specifies the copyright holder for the texts",
432
+ )
433
+ @click.option(
434
+ "--package-name",
435
+ metavar="NAME",
436
+ default="PACKAGE",
437
+ help="Package name to use in the generated POT file",
438
+ )
439
+ @click.option(
440
+ "--package-version",
441
+ metavar="Version",
442
+ default="1.0",
443
+ help="Package version to use in the generated POT file",
444
+ )
445
+ @click.option("--msgid-bugs-address", metavar="EMAIL", help="Email address bugs should be send to")
446
+ def main(
447
+ cfg_file,
448
+ files_from,
449
+ directory,
450
+ sources,
451
+ list_extractors,
452
+ quiet,
453
+ output,
454
+ location,
455
+ linenumbers,
456
+ width,
457
+ sort_order,
458
+ allow_empty,
459
+ domain,
460
+ keywords,
461
+ comment_tag,
462
+ copyright_holder,
463
+ package_name,
464
+ package_version,
465
+ msgid_bugs_address,
466
+ ):
467
+ """Main entrypoint."""
468
+ extract(
469
+ cfg_file,
470
+ files_from,
471
+ directory,
472
+ sources,
473
+ list_extractors,
474
+ quiet,
475
+ output,
476
+ location,
477
+ linenumbers,
478
+ width,
479
+ sort_order,
480
+ allow_empty,
481
+ domain,
482
+ keywords,
483
+ comment_tag,
484
+ copyright_holder,
485
+ package_name,
486
+ package_version,
487
+ msgid_bugs_address,
488
+ )
489
+
490
+
491
+ if __name__ == "__main__":
492
+ main()
@@ -0,0 +1,178 @@
1
+ import abc
2
+ import collections
3
+ import os
4
+ import re
5
+ import sys
6
+ from importlib.metadata import entry_points
7
+
8
+ from .compat import add_metaclass
9
+
10
+ Message = collections.namedtuple(
11
+ "Message", "msgctxt msgid msgid_plural flags comment tcomment location"
12
+ )
13
+
14
+ EXTRACTORS = {}
15
+ EXTENSIONS = {}
16
+
17
+
18
+ def get_extractor(filename):
19
+ ext = os.path.splitext(filename)[1]
20
+ try:
21
+ return EXTRACTORS[EXTENSIONS[ext]]
22
+ except KeyError:
23
+ return None
24
+
25
+
26
+ # Based on http://www.cplusplus.com/reference/cstdio/printf/
27
+ # Note that we skip the space-flag in this list, since this creates too
28
+ # many false positives.
29
+ _C_FORMAT = re.compile(
30
+ r"""
31
+ %
32
+ [+#0-]? # flags
33
+ (\d+|\*)? # width
34
+ (\.(\d+|\*))? # precision
35
+ (hh?|ll?|j|z|t|L)? # length
36
+ [diuoxXfFeEgGaAcspn%] # specifier
37
+ """,
38
+ re.VERBOSE,
39
+ )
40
+
41
+
42
+ def check_c_format(buf, flags):
43
+ if "no-c-format" in flags or "c-format" in flags:
44
+ return
45
+ formats = list(re.finditer("%(?!%)", buf))
46
+ if formats and all(_C_FORMAT.match(buf[m.start() :]) is not None for m in formats):
47
+ flags.append("c-format")
48
+
49
+
50
+ # Based on http://docs.python.org/2/library/string.html#format-string-syntax
51
+ _PYTHON_FORMAT = re.compile(
52
+ r"""
53
+ \{
54
+ (([_A-Za-z](\w*)(\.[_a-z]\w*|\[\d+\])?)|\w+)? # fieldname
55
+ (![rs])? # conversion
56
+ (:\.?[<>=^]?[+ -]?\w*,?(\.\w+)?[bcdeEfFgGnosxX%]?)? # format_spec
57
+ \}
58
+ """,
59
+ re.VERBOSE,
60
+ )
61
+
62
+
63
+ def check_python_format(buf, flags):
64
+ if "no-python-format" in flags or "python-format" in flags:
65
+ return
66
+ if _PYTHON_FORMAT.search(buf) is not None:
67
+ flags.append("python-format")
68
+
69
+
70
+ def check_comment_flags(comment):
71
+ flags = re.match("\\[\\s*(.*?)\\s*\\]\\s*(.*)", comment)
72
+ if flags is not None:
73
+ return (re.split("\\s*,\\s*", flags.group(1)), flags.group(2))
74
+ else:
75
+ return [], comment
76
+
77
+
78
+ class Keyword:
79
+ msgctxt_param = None
80
+ domain_param = None
81
+ comment = ""
82
+ required_arguments = None
83
+
84
+ _comment_arg = re.compile(r'^"(.*)"$')
85
+
86
+ def __init__(
87
+ self,
88
+ function,
89
+ msgid_param=1,
90
+ msgid_plural_param=None,
91
+ domain_param=None,
92
+ msgctxt_param=None,
93
+ ):
94
+ self.function = function
95
+ self.msgid_param = msgid_param
96
+ self.msgid_plural_param = msgid_plural_param
97
+ self.domain_param = domain_param
98
+ self.msgctxt_param = msgctxt_param
99
+
100
+ @classmethod
101
+ def from_spec(cls, spec):
102
+ if ":" not in spec:
103
+ return cls(spec)
104
+ try:
105
+ function, args = spec.split(":", 1)
106
+ kw = cls(function)
107
+ seen_msgid_param = False
108
+ while args:
109
+ if cls._comment_arg.match(args) is not None:
110
+ kw.comment = args[1:-1]
111
+ break
112
+ param, args = args.split(",", 1) if "," in args else (args, "")
113
+ if param.endswith("c"):
114
+ kw.msgctxt_param = int(param[:-1])
115
+ elif param.endswith("d"):
116
+ kw.domain_param = int(param[:-1])
117
+ elif param.endswith("t"):
118
+ kw.required_arguments = int(param[:-1])
119
+ elif not seen_msgid_param:
120
+ kw.msgid_param = int(param)
121
+ seen_msgid_param = True
122
+ else:
123
+ kw.msgid_plural_param = int(param)
124
+ except SyntaxError:
125
+ raise ValueError(f"Invalid keyword spec: {spec}")
126
+ return kw
127
+
128
+
129
+ def update_keywords(keywords, specs):
130
+ for spec in specs:
131
+ if not spec:
132
+ keywords.clear()
133
+ try:
134
+ kw = Keyword.from_spec(spec)
135
+ except ValueError as e:
136
+ print(e, file=sys.stderr)
137
+ sys.exit(1)
138
+ keywords[kw.function] = kw
139
+
140
+
141
+ @add_metaclass(abc.ABCMeta)
142
+ class Extractor:
143
+ default_config = {}
144
+
145
+ def __init__(self, config=None):
146
+ self.config = self.default_config.copy()
147
+ if config:
148
+ self.config.update(config)
149
+
150
+ def update_config(self, **kw):
151
+ self.config.update(kw)
152
+
153
+ @abc.abstractproperty
154
+ def extensions(self):
155
+ raise NotImplementedError()
156
+
157
+ @abc.abstractmethod
158
+ def __call__(self, filename, options, fileobj=None, lineno=0):
159
+ raise NotImplementedError()
160
+
161
+
162
+ def register_extractors():
163
+ try:
164
+ extractor_entry_points = entry_points(group="lingva.extractors")
165
+ except TypeError: # <= Python 3.9
166
+ extractor_entry_points = entry_points()["lingva.extractors"]
167
+
168
+ for entry_point in extractor_entry_points:
169
+ try:
170
+ extractor = entry_point.load()
171
+ except ModuleNotFoundError:
172
+ extractor = None
173
+ if extractor:
174
+ if not issubclass(extractor, Extractor):
175
+ raise ValueError("Registered extractor must derive from ``Extractor``")
176
+ EXTRACTORS[entry_point.name] = extractor()
177
+ for extension in extractor.extensions:
178
+ EXTENSIONS[extension] = entry_point.name