|
| 1 | +""" |
| 2 | +Sphinx extension for including code files with translated comments. |
| 3 | +""" |
| 4 | +import os |
| 5 | +from pathlib import Path |
| 6 | +from docutils import nodes |
| 7 | +from docutils.parsers.rst import directives |
| 8 | +from sphinx.directives.code import LiteralInclude, container_wrapper |
| 9 | +from sphinx.util import logging as sphinx_logging |
| 10 | +from sphinx.util.nodes import set_source_info |
| 11 | + |
| 12 | +logger = sphinx_logging.getLogger(__name__) |
| 13 | + |
| 14 | +class TranslatedNode(nodes.literal_block): |
| 15 | + """Custom node that can be pickled.""" |
| 16 | + def astext(self): |
| 17 | + return self.rawsource |
| 18 | + |
| 19 | +class TranslatedLiteralInclude(LiteralInclude): |
| 20 | + """A LiteralInclude directive that supports translated comments.""" |
| 21 | + |
| 22 | + option_spec = LiteralInclude.option_spec.copy() |
| 23 | + option_spec['language'] = directives.unchanged |
| 24 | + |
| 25 | + def __init__(self, *args, **kwargs): |
| 26 | + super().__init__(*args, **kwargs) |
| 27 | + self.translations = {} |
| 28 | + |
| 29 | + def load_translations(self, source_file: Path, language: str) -> dict: |
| 30 | + """Load translations for the given file and language. |
| 31 | + |
| 32 | + Args: |
| 33 | + source_file: Path to the source file |
| 34 | + language: Target language code |
| 35 | + |
| 36 | + Returns: |
| 37 | + Dictionary of original comments to translated comments |
| 38 | + """ |
| 39 | + try: |
| 40 | + project_root = Path(__file__).parent.parent.parent |
| 41 | + rel_path = Path(source_file).relative_to(project_root) |
| 42 | + |
| 43 | + # Get path relative to examples directory |
| 44 | + try: |
| 45 | + examples_index = rel_path.parts.index('examples') |
| 46 | + rel_to_examples = Path(*rel_path.parts[examples_index + 1:]) |
| 47 | + except ValueError: |
| 48 | + logger.error(f"Source file not in examples directory: {rel_path}") |
| 49 | + return {} |
| 50 | + |
| 51 | + trans_path = project_root / 'examples' / 'translations' / language / f"{rel_to_examples}.comments" |
| 52 | + |
| 53 | + if not trans_path.exists(): |
| 54 | + logger.warning(f"No translation file found at: {trans_path}") |
| 55 | + return {} |
| 56 | + |
| 57 | + translations = {} |
| 58 | + for line in trans_path.read_text(encoding='utf-8').splitlines(): |
| 59 | + line = line.strip() |
| 60 | + if line and not line.startswith('#'): |
| 61 | + try: |
| 62 | + orig, trans = line.split('=', 1) |
| 63 | + translations[orig.strip()] = trans.strip() |
| 64 | + except ValueError: |
| 65 | + logger.warning(f"Invalid translation line: {line}") |
| 66 | + |
| 67 | + logger.info(f"Loaded {len(translations)} translations") |
| 68 | + return translations |
| 69 | + |
| 70 | + except Exception as e: |
| 71 | + logger.error(f"Error loading translations: {e}") |
| 72 | + return {} |
| 73 | + |
| 74 | + def translate_content(self, content: str, language: str) -> str: |
| 75 | + """Translate comments in the content using loaded translations.""" |
| 76 | + if not language or language == 'en': |
| 77 | + return content |
| 78 | + |
| 79 | + result = [] |
| 80 | + translations_used = 0 |
| 81 | + |
| 82 | + for line in content.splitlines(): |
| 83 | + stripped = line.strip() |
| 84 | + if stripped.startswith('#'): |
| 85 | + comment_text = stripped[1:].strip() |
| 86 | + if comment_text in self.translations: |
| 87 | + indent = line[:len(line) - len(stripped)] |
| 88 | + result.append(f"{indent}# {self.translations[comment_text]}") |
| 89 | + translations_used += 1 |
| 90 | + else: |
| 91 | + result.append(line) |
| 92 | + else: |
| 93 | + result.append(line) |
| 94 | + |
| 95 | + logger.info(f"Applied {translations_used} translations") |
| 96 | + return '\n'.join(result) |
| 97 | + |
| 98 | + def run(self): |
| 99 | + env = self.state.document.settings.env |
| 100 | + language = (getattr(env.config, 'language', None) or 'en')[:2].lower() |
| 101 | + |
| 102 | + # Get absolute path of source file |
| 103 | + source_file = Path(env.srcdir) / self.arguments[0] |
| 104 | + if '..' in str(source_file): |
| 105 | + project_root = Path(__file__).parent.parent.parent |
| 106 | + parts = Path(self.arguments[0]).parts |
| 107 | + up_count = sum(1 for part in parts if part == '..') |
| 108 | + source_file = project_root.joinpath(*parts[up_count:]) |
| 109 | + |
| 110 | + source_file = source_file.resolve() |
| 111 | + logger.info(f"Processing file: {source_file}") |
| 112 | + |
| 113 | + # Load translations for non-English languages |
| 114 | + if language != 'en': |
| 115 | + self.translations = self.load_translations(source_file, language) |
| 116 | + |
| 117 | + # Get original content and process nodes |
| 118 | + document = super().run() |
| 119 | + if not self.translations: |
| 120 | + return document |
| 121 | + |
| 122 | + result = [] |
| 123 | + for node in document: |
| 124 | + if not isinstance(node, nodes.literal_block): |
| 125 | + result.append(node) |
| 126 | + continue |
| 127 | + |
| 128 | + translated_content = self.translate_content(node.rawsource, language) |
| 129 | + new_node = TranslatedNode( |
| 130 | + translated_content, |
| 131 | + translated_content, |
| 132 | + source=node.source, |
| 133 | + line=node.line |
| 134 | + ) |
| 135 | + |
| 136 | + # Copy node attributes |
| 137 | + new_node['language'] = node.get('language', 'python') |
| 138 | + new_node['highlight_args'] = node.get('highlight_args', {}) |
| 139 | + new_node['linenos'] = node.get('linenos', False) |
| 140 | + new_node['classes'] = node.get('classes', []) |
| 141 | + |
| 142 | + if 'caption' in node: |
| 143 | + new_node['caption'] = node['caption'] |
| 144 | + |
| 145 | + set_source_info(self, new_node) |
| 146 | + |
| 147 | + # Apply container wrapper if needed |
| 148 | + caption = node.get('caption', '') or self.options.get('caption', '') |
| 149 | + if caption or node.get('linenos', False): |
| 150 | + new_node = container_wrapper(self, new_node, caption) |
| 151 | + |
| 152 | + result.append(new_node) |
| 153 | + |
| 154 | + return result |
| 155 | + |
| 156 | +def setup(app): |
| 157 | + app.add_directive('translated-literalinclude', TranslatedLiteralInclude) |
| 158 | + app.add_node(TranslatedNode) |
| 159 | + return { |
| 160 | + 'version': '0.1', |
| 161 | + 'parallel_read_safe': True, |
| 162 | + 'parallel_write_safe': True, |
| 163 | + } |
0 commit comments