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 +3 -0
- lingva/extract.py +492 -0
- lingva/extractors/__init__.py +178 -0
- lingva/extractors/babel.py +69 -0
- lingva/extractors/compat.py +20 -0
- lingva/extractors/python.py +402 -0
- lingva/extractors/xml.py +438 -0
- lingva/extractors/zcml.py +65 -0
- lingva/polint.py +59 -0
- lingva-5.0.7.dist-info/METADATA +381 -0
- lingva-5.0.7.dist-info/RECORD +15 -0
- lingva-5.0.7.dist-info/WHEEL +5 -0
- lingva-5.0.7.dist-info/entry_points.txt +10 -0
- lingva-5.0.7.dist-info/licenses/LICENSE +23 -0
- lingva-5.0.7.dist-info/top_level.txt +1 -0
lingva/__init__.py
ADDED
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
|