Skip to content

Commit e0d1018

Browse files
authored
Improve .po IO (#1068)
* read_po: note interface also supports iterable-of-strings, not a filelike * write_po: refactor into generate_po
1 parent 40e60a1 commit e0d1018

File tree

2 files changed

+81
-49
lines changed

2 files changed

+81
-49
lines changed

babel/messages/pofile.py

Lines changed: 72 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -291,7 +291,7 @@ def _process_comment(self, line) -> None:
291291
# These are called user comments
292292
self.user_comments.append(line[1:].strip())
293293

294-
def parse(self, fileobj: IO[AnyStr]) -> None:
294+
def parse(self, fileobj: IO[AnyStr] | Iterable[AnyStr]) -> None:
295295
"""
296296
Reads from the file-like object `fileobj` and adds any po file
297297
units found in it to the `Catalog` supplied to the constructor.
@@ -329,15 +329,15 @@ def _invalid_pofile(self, line, lineno, msg) -> None:
329329

330330

331331
def read_po(
332-
fileobj: IO[AnyStr],
332+
fileobj: IO[AnyStr] | Iterable[AnyStr],
333333
locale: str | Locale | None = None,
334334
domain: str | None = None,
335335
ignore_obsolete: bool = False,
336336
charset: str | None = None,
337337
abort_invalid: bool = False,
338338
) -> Catalog:
339339
"""Read messages from a ``gettext`` PO (portable object) file from the given
340-
file-like object and return a `Catalog`.
340+
file-like object (or an iterable of lines) and return a `Catalog`.
341341
342342
>>> from datetime import datetime
343343
>>> from io import StringIO
@@ -373,7 +373,7 @@ def read_po(
373373
.. versionadded:: 1.0
374374
Added support for explicit charset argument.
375375
376-
:param fileobj: the file-like object to read the PO file from
376+
:param fileobj: the file-like object (or iterable of lines) to read the PO file from
377377
:param locale: the locale identifier or `Locale` object, or `None`
378378
if the catalog is not bound to a locale (which basically
379379
means it's a template)
@@ -529,45 +529,69 @@ def write_po(
529529
updating the catalog
530530
:param include_lineno: include line number in the location comment
531531
"""
532-
def _normalize(key, prefix=''):
533-
return normalize(key, prefix=prefix, width=width)
534-
535-
def _write(text):
536-
if isinstance(text, str):
537-
text = text.encode(catalog.charset, 'backslashreplace')
538-
fileobj.write(text)
539-
540-
def _write_comment(comment, prefix=''):
541-
# xgettext always wraps comments even if --no-wrap is passed;
542-
# provide the same behaviour
543-
_width = width if width and width > 0 else 76
544-
for line in wraptext(comment, _width):
545-
_write(f"#{prefix} {line.strip()}\n")
546-
547-
def _write_message(message, prefix=''):
532+
533+
sort_by = None
534+
if sort_output:
535+
sort_by = "message"
536+
elif sort_by_file:
537+
sort_by = "location"
538+
539+
for line in generate_po(
540+
catalog,
541+
ignore_obsolete=ignore_obsolete,
542+
include_lineno=include_lineno,
543+
include_previous=include_previous,
544+
no_location=no_location,
545+
omit_header=omit_header,
546+
sort_by=sort_by,
547+
width=width,
548+
):
549+
if isinstance(line, str):
550+
line = line.encode(catalog.charset, 'backslashreplace')
551+
fileobj.write(line)
552+
553+
554+
def generate_po(
555+
catalog: Catalog,
556+
*,
557+
ignore_obsolete: bool = False,
558+
include_lineno: bool = True,
559+
include_previous: bool = False,
560+
no_location: bool = False,
561+
omit_header: bool = False,
562+
sort_by: Literal["message", "location"] | None = None,
563+
width: int = 76,
564+
) -> Iterable[str]:
565+
r"""Yield text strings representing a ``gettext`` PO (portable object) file.
566+
567+
See `write_po()` for a more detailed description.
568+
"""
569+
# xgettext always wraps comments even if --no-wrap is passed;
570+
# provide the same behaviour
571+
comment_width = width if width and width > 0 else 76
572+
573+
def _format_comment(comment, prefix=''):
574+
for line in wraptext(comment, comment_width):
575+
yield f"#{prefix} {line.strip()}\n"
576+
577+
def _format_message(message, prefix=''):
548578
if isinstance(message.id, (list, tuple)):
549579
if message.context:
550-
_write(f"{prefix}msgctxt {_normalize(message.context, prefix)}\n")
551-
_write(f"{prefix}msgid {_normalize(message.id[0], prefix)}\n")
552-
_write(f"{prefix}msgid_plural {_normalize(message.id[1], prefix)}\n")
580+
yield f"{prefix}msgctxt {normalize(message.context, prefix=prefix, width=width)}\n"
581+
yield f"{prefix}msgid {normalize(message.id[0], prefix=prefix, width=width)}\n"
582+
yield f"{prefix}msgid_plural {normalize(message.id[1], prefix=prefix, width=width)}\n"
553583

554584
for idx in range(catalog.num_plurals):
555585
try:
556586
string = message.string[idx]
557587
except IndexError:
558588
string = ''
559-
_write(f"{prefix}msgstr[{idx:d}] {_normalize(string, prefix)}\n")
589+
yield f"{prefix}msgstr[{idx:d}] {normalize(string, prefix=prefix, width=width)}\n"
560590
else:
561591
if message.context:
562-
_write(f"{prefix}msgctxt {_normalize(message.context, prefix)}\n")
563-
_write(f"{prefix}msgid {_normalize(message.id, prefix)}\n")
564-
_write(f"{prefix}msgstr {_normalize(message.string or '', prefix)}\n")
565-
566-
sort_by = None
567-
if sort_output:
568-
sort_by = "message"
569-
elif sort_by_file:
570-
sort_by = "location"
592+
yield f"{prefix}msgctxt {normalize(message.context, prefix=prefix, width=width)}\n"
593+
yield f"{prefix}msgid {normalize(message.id, prefix=prefix, width=width)}\n"
594+
yield f"{prefix}msgstr {normalize(message.string or '', prefix=prefix, width=width)}\n"
571595

572596
for message in _sort_messages(catalog, sort_by=sort_by):
573597
if not message.id: # This is the header "message"
@@ -580,12 +604,12 @@ def _write_message(message, prefix=''):
580604
lines += wraptext(line, width=width,
581605
subsequent_indent='# ')
582606
comment_header = '\n'.join(lines)
583-
_write(f"{comment_header}\n")
607+
yield f"{comment_header}\n"
584608

585609
for comment in message.user_comments:
586-
_write_comment(comment)
610+
yield from _format_comment(comment)
587611
for comment in message.auto_comments:
588-
_write_comment(comment, prefix='.')
612+
yield from _format_comment(comment, prefix='.')
589613

590614
if not no_location:
591615
locs = []
@@ -606,35 +630,34 @@ def _write_message(message, prefix=''):
606630
location = f"{location}:{lineno:d}"
607631
if location not in locs:
608632
locs.append(location)
609-
_write_comment(' '.join(locs), prefix=':')
633+
yield from _format_comment(' '.join(locs), prefix=':')
610634
if message.flags:
611-
_write(f"#{', '.join(['', *sorted(message.flags)])}\n")
635+
yield f"#{', '.join(['', *sorted(message.flags)])}\n"
612636

613637
if message.previous_id and include_previous:
614-
_write_comment(
615-
f'msgid {_normalize(message.previous_id[0])}',
638+
yield from _format_comment(
639+
f'msgid {normalize(message.previous_id[0], width=width)}',
616640
prefix='|',
617641
)
618642
if len(message.previous_id) > 1:
619-
_write_comment('msgid_plural %s' % _normalize(
620-
message.previous_id[1],
621-
), prefix='|')
643+
norm_previous_id = normalize(message.previous_id[1], width=width)
644+
yield from _format_comment(f'msgid_plural {norm_previous_id}', prefix='|')
622645

623-
_write_message(message)
624-
_write('\n')
646+
yield from _format_message(message)
647+
yield '\n'
625648

626649
if not ignore_obsolete:
627650
for message in _sort_messages(
628651
catalog.obsolete.values(),
629652
sort_by=sort_by,
630653
):
631654
for comment in message.user_comments:
632-
_write_comment(comment)
633-
_write_message(message, prefix='#~ ')
634-
_write('\n')
655+
yield from _format_comment(comment)
656+
yield from _format_message(message, prefix='#~ ')
657+
yield '\n'
635658

636659

637-
def _sort_messages(messages: Iterable[Message], sort_by: Literal["message", "location"]) -> list[Message]:
660+
def _sort_messages(messages: Iterable[Message], sort_by: Literal["message", "location"] | None) -> list[Message]:
638661
"""
639662
Sort the given message iterable by the given criteria.
640663

tests/messages/test_pofile.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -884,3 +884,12 @@ def test_unknown_language_write():
884884
buf = BytesIO()
885885
pofile.write_po(buf, catalog)
886886
assert 'sr_SP' in buf.getvalue().decode()
887+
888+
889+
def test_iterable_of_strings():
890+
"""
891+
Test we can parse from an iterable of strings.
892+
"""
893+
catalog = pofile.read_po(['msgid "foo"', b'msgstr "Voh"'], locale="en_US")
894+
assert catalog.locale == Locale("en", "US")
895+
assert catalog.get("foo").string == "Voh"

0 commit comments

Comments
 (0)