Skip to content
Open
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
3ae7cbd
Add definitions for MessageConcatenation and MessageMerge classes in …
soft-suroleb Dec 9, 2024
246671a
Implement basic logic for concatenating catalogs
soft-suroleb Dec 9, 2024
9a217e2
Add options: unique, less-than, more-than, no-wrap, and width
soft-suroleb Dec 9, 2024
a1bf8d4
Implement basic msgmerge logic for working with a compendium
soft-suroleb Dec 9, 2024
a5458fb
Write tests for msgcat
soft-suroleb Dec 9, 2024
6020107
Write tests for msgmerge
soft-suroleb Dec 9, 2024
cb71c93
Add options update, backup, and c_overwrite for a different compendiu…
soft-suroleb Dec 9, 2024
99ac987
Add test for msgmerge compendium overwrite mode with no comments
soft-suroleb Dec 9, 2024
7228cea
Refactor test for msgmerge with compendium-overwrite option
soft-suroleb Dec 9, 2024
2dababc
Create a catalog without fuzzy by default, remove add-location
soft-suroleb Dec 9, 2024
888cdd0
Add tests for using msgcat with plural message forms
soft-suroleb Dec 9, 2024
5202291
Rename msgmerge to merge and msgcat to concat
soft-suroleb Dec 9, 2024
efe2502
Add discription to all options
soft-suroleb Dec 9, 2024
133c8db
Merge branch 'master' into master
soft-suroleb Mar 23, 2025
4cbe604
Ability to specify multiple compendiums
soft-suroleb Mar 2, 2025
8568e90
Marking conflicting messages
soft-suroleb Mar 2, 2025
3f37414
Fix PR issues
soft-suroleb Mar 23, 2025
1817bce
Add info about pybable concat and pybabel merge into docs
soft-suroleb Apr 7, 2025
dd44348
Add usage documentation for pybabel concat and merge commands
soft-suroleb Apr 21, 2025
0a6388d
Fix PR issues
soft-suroleb Apr 27, 2025
5828c13
Add '_conflicts' getter in catalog
soft-suroleb Apr 27, 2025
bbba96e
Rework tests from unittest to pytest
soft-suroleb Apr 27, 2025
01f3793
Merge branch 'master' into master
soft-suroleb Apr 27, 2025
1871bcf
Merge branch 'master' into master
soft-suroleb Jul 6, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
317 changes: 317 additions & 0 deletions babel/messages/frontend.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import sys
import tempfile
import warnings
from collections import OrderedDict, defaultdict
from configparser import RawConfigParser
from io import StringIO
from typing import BinaryIO, Iterable, Literal
Expand Down Expand Up @@ -852,6 +853,318 @@ def run(self):
return


class ConcatenationCatalog(CommandMixin):
description = 'concatenates and merges the specified PO files'
user_options = [
('input-files', None, 'input files'),
('files-from=', 'f', 'get list of input files from FILE'),
('directory=', 'D', 'add DIRECTORY to list for input files search'
'If input file is -, standard input is read.'),
('output-file=', 'o', 'write output to specified file'),
('less-than=', '<', 'print messages with less than this many'
'definitions, defaults to infinite if not set'),
('more-than=', '>', 'print messages with more than this many'
'definitions, defaults to 0 if not set'),
('unique', 'u', 'shorthand for --less-than=2, requests'
'that only unique messages be printed'),
('properties-input', 'P', 'input files are in Java .properties syntax'),
('stringtable-input', None, 'input files are in NeXTstep/GNUstep .strings syntax'),
('to-code=','t', 'encoding for output'),
('use-first', None, 'use first available translation for each'
'message, don\'t merge several translations'),
('lang=', None, 'set 'Language' field in the header entry'),
('color=', None, 'use colors and other text attributes always'),
('style=', None, 'specify CSS style rule file for --color'),
('no-escape', 'e', 'do not use C escapes in output (default)'),
('escape', 'E', 'use C escapes in output, no extended chars'),
('force-po', None, 'write PO file even if empty'),
('indent', 'i', 'write the .po file using indented style'),
('no-location', None, 'do not write \'#: filename:line\' lines'),
('strict', None, 'write out strict Uniforum conforming .po file'),
('properties-output', None, 'write out a Java .properties file'),
('stringtable-output', None, 'write out a NeXTstep/GNUstep .strings file'),
('width=', 'w', 'set output page width'),
('no-wrap', None, 'do not break long message lines, longer than'
'the output page width, into several lines'),
('sort-output', 's', 'generate sorted output'),
('sort-by-file', 'F', 'sort output by file location'),
]

as_args='input-files'

boolean_options = [
'unique',
'properties-input',
'stringtable-input',
'no-escape',
'escape',
'force-po',
'indent',
'no-location',
'strict',
'properties-output',
'stringtable-output',
'no-wrap',
'sort-output',
'sort-by-file',
]

option_choices = {
'color': ('always', 'never', 'auto', 'html'),
}

def initialize_options(self):
self.input_files = None #
self.files_from = None
self.directory = None
self.output_file = None #
self.less_than = None #
self.more_than = 0 #
self.unique = False #
self.properties_input = None
self.stringtable_input = None
self.to_code = None
# the first translation is always used temporarily
self.use_first = True #~
self.lang = None
self.color = None
self.style = None
self.no_escape = None
self.escape = None
self.force_po = None
self.indent = None
self.no_location = None #
self.strict = None
self.properties_output = None
self.stringtable_output = None
self.width = None #
self.no_wrap = None #
self.sort_output = False #
self.sort_by_file = False #

def finalize_options(self):
if not self.input_files:
raise OptionError('you must specify the input files')
if not self.output_file:
raise OptionError('you must specify the output file')
Comment on lines +895 to +896
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this not output to stdout?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you want the result to be output to stdout or what? It's just that each command in frontend has a similar exception for the output_file

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I meant that it would probably be useful for this command to be able to output to stdout (if no specific output file is set).

msgcat supports this:

The results are written to standard output if no output file is specified or if it is ‘-’.


if self.no_wrap and self.width:
raise OptionError("'--no-wrap' and '--width' are mutually exclusive")
if not self.no_wrap and not self.width:
self.width = 76
elif self.width is not None:
self.width = int(self.width)

if self.more_than is None:
self.more_than = 0
else:
self.more_than = int(self.more_than)
if self.less_than is not None:
self.less_than = int(self.less_than)
if self.unique:
self.less_than = 2
Comment on lines +911 to +912
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess unique, more_than and less_than are all mutually exclusive?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not quite so. More_than and less_than are like bounds. Unique means less_than=2 but boolean

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mean... does it make sense to be able to set more_than and less_than along with unique?


def _prepare(self):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems to do a bit more than just preparation... maybe a better name?

self.message_count = defaultdict(int)

for filename in self.input_files:
with open(filename, 'r') as pofile:
template = read_po(pofile)
for message in template:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should probably also filter out messages with a nullish id.

self.message_count[message.id] += 1

def run(self):
catalog = Catalog(fuzzy=False)
self._prepare()

for filename in self.input_files:
with open(filename, 'r') as pofile:
template = read_po(pofile)
if catalog.locale is None:
catalog.locale = template.locale

for message in template:
if not message.id:
continue

if message.id in catalog and catalog[message.id].string != message.string and not self.use_first:
raise NotImplementedError()

message_count = self.message_count[message.id]
if message_count > self.more_than and (self.less_than is None or message_count < self.less_than):
catalog[message.id] = message

catalog.fuzzy = any(message.fuzzy for message in catalog)
with open(self.output_file, 'wb') as outfile:
write_po(
outfile,
catalog,
width=self.width,
sort_by_file=self.sort_by_file,
sort_output=self.sort_output,
no_location=self.no_location,
)


class MergeCatalog(CommandMixin):
description='combines two Uniforum-style PO files into one'
user_options=[
('input-files', None, 'def.po ref.pot'),
('directory=', 'D', 'add DIRECTORY to list for input files search'),
('compendium=', 'C', 'additional library of message translations, may be specified more than once'),
('compendium-overwrite', '', 'overwrite mode of compendium'),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This help text doesn't really help much... 😄

('no-compendium-comment', '', ''),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this have a help text?

('update', 'U', 'pdate def.po, do nothing if def.po already up to date'),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
('update', 'U', 'pdate def.po, do nothing if def.po already up to date'),
('update', 'U', 'update def.po, do nothing if def.po already up to date'),

('output-file=', 'o', 'write output to specified file, the results are written'
'to standard output if no output file is specified'),
('backup', None, 'make a backup of def.po'),
('suffix=', None, 'override the usual backup suffix'),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What "usual backup suffix"? 🤔

('multi-domain', 'm', 'apply ref.pot to each of the domains in def.po'),
('for-msgfmt', None, 'produce output for 'msgfmt', not for a translator'),
('no-fuzzy-matching', 'N', 'do not use fuzzy matching'),
('previous', None, 'keep previous msgids of translated messages'),
('properties-input', 'P', 'input files are in Java .properties syntax'),
('stringtable-input', None, 'input files are in NeXTstep/GNUstep .strings syntax'),
('lang=', None, 'set 'Language' field in the header entry'),
('color=', None, 'use colors and other text attributes always'),
('style=', None, 'specify CSS style rule file for --color'),
('no-escape', 'e', 'do not use C escapes in output (default)'),
('escape', 'E', 'use C escapes in output, no extended chars'),
('force-po', None, 'write PO file even if empty'),
('indent', 'i', 'indented output style'),
('no-location', None, 'suppress \'#: filename:line\' lines'),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's use the same text as in the other tools' no-location option:

Suggested change
('no-location', None, 'suppress \'#: filename:line\' lines'),
('no-location', None, 'do not include location comments with filename and line number'),

('strict', None, 'strict Uniforum output style'),
('properties-output', None, 'write out a Java .properties file'),
('stringtable-output', None, 'write out a NeXTstep/GNUstep .strings file'),
('width=', 'w', 'set output page width'),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's use the same text as in the other tools' width option:

Suggested change
('width=', 'w', 'set output page width'),
('width=', 'w', 'set output line width (default 76)'),

('no-wrap', None, 'do not break long message lines, longer'
'than the output page width, into several lines'),
('sort-output', 's', 'generate sorted output'),
('sort-by-file', 'F', 'sort output by file location'),
]

as_args='input-files'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
as_args='input-files'
as_args = 'input-files'


boolean_options = [
'update',
'multi-domain',
'for-msgfmt',
'no-fuzzy-matching',
'previous'
'properties-input',
'stringtable-input',
'no-escape',
'escape',
'force-po',
'indent',
'no-location',
'strict',
'properties-output',
'stringtable-output',
'no-wrap',
'sort-output',
'sort-by-file',
'compendium-overwrite',
'backup',
'no-compendium-comment',
]

option_choices = {
'color': ('always', 'never', 'auto', 'html'),
}

def initialize_options(self):
self.input_files = None #
self.directory = None

self.compendium = None #~
self.compendium_overwrite = False #
self.no_compendium_comment = None #

self.update = None #
self.output_file = None #
self.backup = False #
self.suffix = '~' #
self.multi_domain = None
self.for_msgfmt = None
self.no_fuzzy_matching = None #
self.previous = None
self.properties_input = None
self.stringtable_input = None
self.lang = None
self.color = None
self.style = None
self.no_escape = None
self.escape = None
self.force_po = None
self.indent = None
self.no_location = None #
self.strict = None
self.properties_output = None
self.stringtable_output = None
self.width = None #
self.no_wrap = None #
self.sort_output = False #
self.sort_by_file = False #

def finalize_options(self):
if not self.input_files or len(self.input_files) != 2:
raise OptionError('must be two po files')
if not self.output_file and not self.update:
raise OptionError('you must specify the output file or update existing')

if self.no_wrap and self.width:
raise OptionError("'--no-wrap' and '--width' are mutually exclusive")
if not self.no_wrap and not self.width:
self.width = 76
elif self.width is not None:
self.width = int(self.width)

def run(self):
def_file, ref_file = self.input_files

if self.update and self.backup:
shutil.copy(def_file, def_file + self.suffix)

with open(def_file, 'r') as pofile:
catalog = read_po(pofile)
with open(ref_file, 'r') as pofile:
ref_catalog = read_po(pofile)
catalog.update(
ref_catalog,
no_fuzzy_matching=self.no_fuzzy_matching
)

if self.compendium:
with open(self.compendium, 'r') as pofile:
compendium_catalog = read_po(pofile)

for message in compendium_catalog:
current = catalog[message.id]
if message.id in catalog and (not current.string or current.fuzzy or self.compendium_overwrite):
Comment on lines +1058 to +1059
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a good place to use the walrus operator – the unlearned reader doesn't know that catalog.__getitem__ can return Nones, so it looks weird that you'd first grab a thing from a dict-like object, and only then check if it is in there.

Suggested change
current = catalog[message.id]
if message.id in catalog and (not current.string or current.fuzzy or self.compendium_overwrite):
if (current := catalog.get(message.id)) and (not current.string or current.fuzzy or self.compendium_overwrite):

if self.compendium_overwrite and not current.fuzzy and current.string:
catalog.obsolete[message.id] = current.clone()

current.string = message.string
if current.fuzzy:
current.flags.remove('fuzzy')

if not self.no_compendium_comment:
current.auto_comments.append(self.compendium)

catalog.fuzzy = any(message.fuzzy for message in catalog)
output_path = def_file if self.update else self.output_file
with open(output_path, 'wb') as outfile:
write_po(
outfile,
catalog,
no_location=self.no_location,
width=self.width,
sort_by_file=self.sort_by_file,
sort_output=self.sort_output,
)


class CommandLineInterface:
"""Command-line interface.

Expand All @@ -866,13 +1179,17 @@ class CommandLineInterface:
'extract': 'extract messages from source files and generate a POT file',
'init': 'create new message catalogs from a POT file',
'update': 'update existing message catalogs from a POT file',
'concat': 'concatenates and merges the specified PO files',
'merge': 'combines two Uniforum-style PO files into one',
}

command_classes = {
'compile': CompileCatalog,
'extract': ExtractMessages,
'init': InitCatalog,
'update': UpdateCatalog,
'concat': ConcatenationCatalog,
'merge': MergeCatalog,
}

log = None # Replaced on instance level
Expand Down
Loading