From c3af7c0d103e18f7d4afd8448f29d0baaf9660ad Mon Sep 17 00:00:00 2001 From: Jorge Moraleda Date: Wed, 7 Oct 2026 15:28:01 +0800 Subject: [PATCH] Add option to follow symbolic links when extracting from a directory Add a `follow_links` parameter to `extract_from_dir` and a `--follow-links` option to `pybabel extract`, passed through to `os.walk(followlinks=...)`. Symbolic links are not followed by default. Fixes #1211 Co-Authored-By: Claude Sonnet 5.5 --- babel/messages/extract.py | 5 ++++- babel/messages/frontend.py | 5 +++++ docs/cmdline.rst | 1 + tests/messages/frontend/test_frontend.py | 6 ++++++ tests/messages/test_extract.py | 20 ++++++++++++++++++++ 5 files changed, 36 insertions(+), 1 deletion(-) diff --git a/babel/messages/extract.py b/babel/messages/extract.py index 6fad84304..7c3417d0d 100644 --- a/babel/messages/extract.py +++ b/babel/messages/extract.py @@ -160,6 +160,7 @@ def extract_from_dir( callback: Callable[[str, str, dict[str, Any]], object] | None = None, strip_comment_tags: bool = False, directory_filter: Callable[[str], bool] | None = None, + follow_links: bool = False, ) -> Generator[_FileExtractionResult, None, None]: """Extract messages from any source files found in the given directory. @@ -228,6 +229,8 @@ def extract_from_dir( :param directory_filter: a callback to determine whether a directory should be recursed into. Receives the full directory path; should return True if the directory is valid. + :param follow_links: whether to follow symbolic links to directories. Links + that form a cycle are followed indefinitely. :see: `pathmatch` """ if dirname is None: @@ -244,7 +247,7 @@ def extract_from_dir( root_dir=dirname, ) - for root, dirnames, filenames in os.walk(dirname): + for root, dirnames, filenames in os.walk(dirname, followlinks=follow_links): dirnames[:] = [ subdir for subdir in dirnames if directory_filter(os.path.join(root, subdir)) ] diff --git a/babel/messages/frontend.py b/babel/messages/frontend.py index 89826e834..e307acc63 100644 --- a/babel/messages/frontend.py +++ b/babel/messages/frontend.py @@ -355,6 +355,8 @@ class ExtractMessages(CommandMixin): 'header comment for the catalog'), ('last-translator=', None, 'set the name and email of the last translator in output'), + ('follow-links', None, + 'follow symbolic links when traversing directories'), ] # fmt: skip boolean_options = [ 'no-default-keywords', @@ -364,6 +366,7 @@ class ExtractMessages(CommandMixin): 'sort-output', 'sort-by-file', 'strip-comments', + 'follow-links', ] as_args = 'input-paths' multiple_value_options = ( @@ -403,6 +406,7 @@ def initialize_options(self): self.version = None self.add_comments = None self.strip_comments = False + self.follow_links = False self.include_lineno = True self.ignore_dirs = None self.header_comment = None @@ -532,6 +536,7 @@ def run(self): directory_filter=self.directory_filter, keywords=self.keywords, strip_comment_tags=self.strip_comments, + follow_links=self.follow_links, ) for filename, lineno, message, comments, context in extracted: if os.path.isfile(path): diff --git a/docs/cmdline.rst b/docs/cmdline.rst index 672bbfe70..13fbf92ed 100644 --- a/docs/cmdline.rst +++ b/docs/cmdline.rst @@ -131,6 +131,7 @@ a collection of source files:: (default ".* ._") --header-comment=HEADER_COMMENT header comment for the catalog + --follow-links follow symbolic links when traversing directories The meaning of ``--keyword`` values is as follows: diff --git a/tests/messages/frontend/test_frontend.py b/tests/messages/frontend/test_frontend.py index c8b8d17d5..65a1556b5 100644 --- a/tests/messages/frontend/test_frontend.py +++ b/tests/messages/frontend/test_frontend.py @@ -317,6 +317,12 @@ def test_extract_cli_knows_dash_s(): assert cmdinst.strip_comments +def test_extract_cli_knows_follow_links(): + cmdinst = configure_cli_command("extract --follow-links -o foo babel") + assert isinstance(cmdinst, ExtractMessages) + assert cmdinst.follow_links + + def test_extract_cli_knows_dash_dash_last_dash_translator(): cmdinst = configure_cli_command('extract --last-translator "FULL NAME EMAIL@ADDRESS" -o foo babel') assert isinstance(cmdinst, ExtractMessages) diff --git a/tests/messages/test_extract.py b/tests/messages/test_extract.py index 1a834d1b7..dd6260d62 100644 --- a/tests/messages/test_extract.py +++ b/tests/messages/test_extract.py @@ -10,6 +10,7 @@ # individuals. For the exact contribution history, see the revision # history and logs, available at https://github.com/python-babel/babel/commits/master/. +import os import sys from io import BytesIO, StringIO @@ -174,3 +175,22 @@ def test_issue_1195_2(): 'NOTE: This should still be considered, even if', 'the text is far away', ] + + +@pytest.mark.skipif(not hasattr(os, 'symlink'), reason='requires symlink support') +@pytest.mark.parametrize('follow_links', [False, True]) +def test_extract_from_dir_follow_links(tmp_path, follow_links): + target = tmp_path / 'target' + target.mkdir() + (target / 'linked.py').write_text('_("linked")') + project = tmp_path / 'project' + project.mkdir() + (project / 'plain.py').write_text('_("plain")') + try: + (project / 'link').symlink_to(target, target_is_directory=True) + except OSError: + pytest.skip('cannot create symlinks') + + results = extract.extract_from_dir(project, follow_links=follow_links) + messages = {message for _, _, message, _, _ in results} + assert messages == ({'plain', 'linked'} if follow_links else {'plain'})