├── .github └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── README.rst ├── _config.yml ├── pyproject.toml ├── pytest_translations ├── __init__.py ├── config.py ├── mo_files.py ├── po_files.py ├── po_spelling.py └── utils.py ├── setup.cfg └── test_translations.py /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | lint: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v3 16 | - name: install poetry 17 | run: | 18 | pip install pipx && pipx install poetry~=1.6 19 | 20 | - name: Set up Python 21 | uses: actions/setup-python@v4 22 | with: 23 | python-version: "3.11" 24 | 25 | - name: install python dependencies 26 | run: | 27 | poetry install --no-interaction 28 | 29 | - name: run linters via pre-commit 30 | run: | 31 | poetry run pre-commit run --all --show-diff-on-failure --color=always 32 | 33 | tests: 34 | runs-on: ubuntu-22.04 35 | strategy: 36 | matrix: 37 | python-version: ["3.8", "3.9", "3.10", "3.11"] 38 | 39 | steps: 40 | - uses: actions/checkout@v2 41 | 42 | - name: Set up Python 43 | uses: actions/setup-python@v2 44 | with: 45 | python-version: ${{ matrix.python-version }} 46 | 47 | - name: Install system package dependencies 48 | run: | 49 | sudo apt-get install -y python3-enchant aspell-en aspell-de 50 | 51 | - name: install poetry 52 | run: | 53 | pip install pipx && pipx install poetry~=1.6 54 | 55 | - name: install python dependencies 56 | run: | 57 | poetry install --no-interaction 58 | 59 | - name: running tests 60 | run: poetry run coverage run --source="pytest_translations" -m "pytest" 61 | 62 | - name: uploading coverage 63 | run: poetry run codecov 64 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: [released] 6 | 7 | jobs: 8 | package_release: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | 13 | - name: Set up Python 14 | uses: actions/setup-python@v2 15 | with: 16 | python-version: 3.x 17 | 18 | - name: Install Python dependencies 19 | run: python -m pip install -U pip setuptools wheel twine 20 | 21 | - name: install poetry and poetry-dynamic-versioning 22 | run: python -m pip install -U poetry poetry-dynamic-versioning 23 | 24 | - name: building package 25 | run: poetry build 26 | 27 | - name: Upload packages 28 | run: "python -m twine upload dist/*" 29 | env: 30 | TWINE_USERNAME: __token__ 31 | TWINE_PASSWORD: ${{ secrets.pypi_token }} 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Python 2 | # Byte-compiled / optimized / DLL files 3 | __pycache__/ 4 | *.py[cod] 5 | *$py.class 6 | 7 | # C extensions 8 | *.so 9 | 10 | poetry.lock 11 | 12 | # Distribution / packaging 13 | .Python 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | wheels/ 26 | pip-wheel-metadata/ 27 | share/python-wheels/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | MANIFEST 32 | 33 | # PyInstaller 34 | # Usually these files are written by a python script from a template 35 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 36 | *.manifest 37 | *.spec 38 | 39 | # Installer logs 40 | pip-log.txt 41 | pip-delete-this-directory.txt 42 | 43 | # Unit test / coverage reports 44 | htmlcov/ 45 | .tox/ 46 | .nox/ 47 | .coverage 48 | .coverage.* 49 | .cache 50 | nosetests.xml 51 | coverage.xml 52 | *.cover 53 | *.py,cover 54 | .hypothesis/ 55 | .pytest_cache/ 56 | pytestdebug.log 57 | 58 | # Translations 59 | *.mo 60 | *.pot 61 | 62 | # Django stuff: 63 | *.log 64 | local_settings.py 65 | db.sqlite3 66 | db.sqlite3-journal 67 | 68 | # Flask stuff: 69 | instance/ 70 | .webassets-cache 71 | 72 | # Scrapy stuff: 73 | .scrapy 74 | 75 | # Sphinx documentation 76 | docs/_build/ 77 | doc/_build/ 78 | 79 | # PyBuilder 80 | target/ 81 | 82 | # Jupyter Notebook 83 | .ipynb_checkpoints 84 | 85 | # IPython 86 | profile_default/ 87 | ipython_config.py 88 | 89 | # pyenv 90 | .python-version 91 | 92 | # pipenv 93 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 94 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 95 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 96 | # install all needed dependencies. 97 | #Pipfile.lock 98 | 99 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 100 | __pypackages__/ 101 | 102 | # Celery stuff 103 | celerybeat-schedule 104 | celerybeat.pid 105 | 106 | # SageMath parsed files 107 | *.sage.py 108 | 109 | # Environments 110 | .env 111 | .venv 112 | env/ 113 | venv/ 114 | ENV/ 115 | env.bak/ 116 | venv.bak/ 117 | pythonenv* 118 | 119 | # Spyder project settings 120 | .spyderproject 121 | .spyproject 122 | 123 | # Rope project settings 124 | .ropeproject 125 | 126 | # mkdocs documentation 127 | /site 128 | 129 | # mypy 130 | .mypy_cache/ 131 | .dmypy.json 132 | dmypy.json 133 | 134 | # Pyre type checker 135 | .pyre/ 136 | 137 | # pytype static type analyzer 138 | .pytype/ 139 | 140 | # profiling data 141 | .prof 142 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.0.1 4 | hooks: 5 | - id: trailing-whitespace 6 | - id: end-of-file-fixer 7 | - id: debug-statements 8 | - id: check-added-large-files 9 | - repo: local 10 | hooks: 11 | - id: flake8 12 | name: flake8 13 | entry: flake8 14 | language: system 15 | types: 16 | - python 17 | - id: isort 18 | name: isort 19 | entry: isort 20 | language: system 21 | types: 22 | - python 23 | - id: black 24 | name: black 25 | entry: black 26 | language: system 27 | types: 28 | - python 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2017 Thermondo GmbH 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | |version| |ci| |coverage| |license| 2 | 3 | PyTest Translations 4 | =================== 5 | 6 | A py.test plugin to check ``gettext`` ``po`` & ``mo`` files. 7 | 8 | Test check for: 9 | 10 | - Spelling (using enchant & aspell) 11 | - Consistency of ``mo`` files 12 | - Obsolete translations 13 | - Fuzzy translations 14 | 15 | Installation 16 | ------------ 17 | 18 | Install the PyPi package. 19 | 20 | .. code:: bash 21 | 22 | pip install pytest-translations 23 | 24 | The spell checking requires enchant and aspell including the correct 25 | dictionary. 26 | 27 | On Linux simply install: 28 | 29 | .. code:: bash 30 | 31 | sudo apt-get install python3-enchant python-enchant aspell-{en|de|CHOSE YOUR LANGUAGE CODES} 32 | 33 | To set up travis-ci simply add the apt packages to your travis-ci config 34 | YAML: 35 | 36 | .. code:: yaml 37 | 38 | addons: 39 | apt: 40 | packages: 41 | - python-enchant 42 | - python3-enchant 43 | - aspell-en 44 | - aspell-de 45 | 46 | On Mac you can use brew to install: 47 | 48 | .. code:: bash 49 | 50 | brew install aspell 51 | brew install enchant 52 | 53 | Usage 54 | ----- 55 | 56 | To execute the translation tests simply run 57 | 58 | .. code:: bash 59 | 60 | py.test --translations 61 | 62 | Every file ending in ``.mo`` and ``.po`` will be discovered and tested, 63 | starting from the command line arguments. 64 | 65 | You also can execute only the translation-tests by using: 66 | 67 | .. code:: bash 68 | 69 | py.test -m translations --translations 70 | 71 | Private Word Lists 72 | ~~~~~~~~~~~~~~~~~~ 73 | 74 | You will almost certainly use words that are not included in the default 75 | dictionaries. That is why you can add your own word list that you want 76 | to add to the dictionary. 77 | 78 | You may do so by adding a plain text file where each line is a word. 79 | Words beginning with a capital letter are case sensitive where lower case words 80 | are insensitive. 81 | 82 | There can be one file for each language contained in a single folder. 83 | The files should be named like the proper language code. 84 | 85 | For example: 86 | 87 | .. code:: bash 88 | 89 | . 90 | └── .spelling 91 | ├── de 92 | ├── en_GB 93 | └── en_US 94 | 95 | What’s left to do is to set an environment variable to point to right 96 | directory. 97 | 98 | For example: 99 | 100 | .. code:: bash 101 | 102 | export PYTEST_TRANSLATIONS_PRIVATE_WORD_LIST=path/to/my/.spelling 103 | 104 | .. |version| image:: https://img.shields.io/pypi/v/pytest-translations.svg 105 | :target: https://pypi.python.org/pypi/pytest-translations/ 106 | .. |ci| image:: https://github.com/Thermondo/pytest-translations/actions/workflows/ci.yml/badge.svg 107 | :target: https://github.com/Thermondo/pytest-translations/actions/workflows/ci.yml 108 | .. |coverage| image:: https://codecov.io/gh/Thermondo/pytest-translations/branch/master/graph/badge.svg 109 | :target: https://codecov.io/gh/Thermondo/pytest-translations 110 | .. |license| image:: https://img.shields.io/badge/license-APL_2-blue.svg 111 | :target: LICENSE 112 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-cayman 2 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "pytest-translations" 3 | # just a dummy version 4 | # will be overwritten from the git tag, 5 | # see https://github.com/mtkennerly/poetry-dynamic-versioning 6 | version = "0.0.0" 7 | description = "Test your translation files." 8 | authors = ["thermondo "] 9 | readme = "README.rst" 10 | repository = "https://github.com/Thermondo/pytest-translations" 11 | license = "Apache-2.0" 12 | classifiers = [ 13 | "Development Status :: 5 - Production/Stable", 14 | "Environment :: Web Environment", 15 | "Intended Audience :: Developers", 16 | "License :: OSI Approved :: Apache Software License", 17 | "Operating System :: OS Independent", 18 | "Natural Language :: English", 19 | "Programming Language :: Python :: 3", 20 | ] 21 | packages = [{ include = "pytest_translations" }] 22 | 23 | [tool.poetry.dependencies] 24 | python = ">=3.8,<4.0" 25 | polib = ">=1.0.5" 26 | pyenchant = ">=1.6.0" 27 | pytest = ">=7" 28 | 29 | [tool.poetry.group.dev.dependencies] 30 | codecov = "~2" 31 | pyenchant = "~3" 32 | black = "*" 33 | flake8 = "*" 34 | isort = "*" 35 | pre-commit = "*" 36 | 37 | [tool.poetry.plugins] 38 | pytest11 = { pytest_translations = "pytest_translations" } 39 | 40 | [tool.poetry-dynamic-versioning] 41 | enable = true 42 | pattern = "^(?P\\d+(\\.\\d+)*)" # tag without `v` prefix 43 | 44 | [tool.isort] 45 | profile = "black" 46 | 47 | [tool.black] 48 | line-length = 88 49 | 50 | [build-system] 51 | requires = ["poetry-core>=1.0.0", "poetry-dynamic-versioning"] 52 | build-backend = "poetry.core.masonry.api" 53 | -------------------------------------------------------------------------------- /pytest_translations/__init__.py: -------------------------------------------------------------------------------- 1 | """A py.test plugin to check ``gettext`` ``po`` & ``mo`` files.""" 2 | 3 | 4 | def pytest_addoption(parser): 5 | group = parser.getgroup("general") 6 | group.addoption( 7 | "--translations", 8 | action="store_true", 9 | help="perform some checks on .mo and .po files", 10 | ) 11 | 12 | 13 | def pytest_configure(config): 14 | config.addinivalue_line("markers", "translations: translation tests") 15 | 16 | 17 | def pytest_collect_file(file_path, parent): 18 | from .mo_files import MoFile 19 | from .po_files import PoFile 20 | 21 | config = parent.config 22 | if config.option.translations: 23 | if file_path.suffix == ".mo": 24 | return MoFile.from_parent( 25 | path=file_path, 26 | parent=parent, 27 | ) 28 | elif file_path.suffix == ".po": 29 | return PoFile.from_parent(path=file_path, parent=parent) 30 | -------------------------------------------------------------------------------- /pytest_translations/config.py: -------------------------------------------------------------------------------- 1 | MARKER_NAME = "translations" 2 | -------------------------------------------------------------------------------- /pytest_translations/mo_files.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import tempfile 4 | 5 | from pytest import File, Item 6 | 7 | from pytest_translations.config import MARKER_NAME 8 | from pytest_translations.utils import ( 9 | TranslationException, 10 | msgfmt, 11 | open_mo_file, 12 | open_po_file, 13 | ) 14 | 15 | 16 | class MoFile(File): 17 | def __init__(self, **kwargs): 18 | super().__init__(**kwargs) 19 | if hasattr(self, "add_marker"): 20 | self.add_marker(MARKER_NAME) 21 | else: 22 | self.keywords[MARKER_NAME] = True 23 | 24 | def collect(self): 25 | yield MoItem.from_parent( 26 | name=self.name, 27 | parent=self, 28 | ) 29 | 30 | 31 | class MoItem(Item): 32 | def __init__(self, name, parent): 33 | super().__init__(name, parent) 34 | self.add_marker(MARKER_NAME) 35 | 36 | def runtest(self): 37 | po_path, _ = os.path.splitext(str(self.path)) 38 | po_path += ".po" 39 | 40 | if not os.path.exists(po_path): 41 | raise TranslationException("corresponding .po file does not exist", []) 42 | 43 | po_file = open_po_file(po_path) 44 | 45 | temp_dir = tempfile.mkdtemp() 46 | 47 | try: 48 | test_file = os.path.join(temp_dir, "test.mo") 49 | po_file.save_as_mofile(test_file) 50 | 51 | original_parsed = open_mo_file(self.path) 52 | test_parsed = open_mo_file(test_file) 53 | 54 | if len(original_parsed) != len(test_parsed): 55 | raise TranslationException("mo-file length does not match po.", []) 56 | 57 | diff = [] 58 | 59 | for lhs, rhs in zip(original_parsed, test_parsed): 60 | if not ( 61 | lhs.msgid == rhs.msgid 62 | and lhs.msgid_plural == rhs.msgid_plural 63 | and lhs.msgstr == rhs.msgstr 64 | and lhs.msgstr_plural == rhs.msgstr_plural 65 | ): 66 | diff.append((lhs, rhs)) 67 | 68 | if diff: 69 | raise TranslationException( 70 | "mo-file content does not match po.", 71 | diff, 72 | ) 73 | 74 | finally: 75 | shutil.rmtree(temp_dir) 76 | 77 | def repr_failure(self, excinfo): 78 | if isinstance(excinfo.value, TranslationException): 79 | msg, diff = excinfo.value.args 80 | 81 | msg += "\n" + "\n".join( 82 | "{0} -> {1}".format( 83 | msgfmt(lhs), 84 | msgfmt(rhs), 85 | ) 86 | for lhs, rhs in diff 87 | ) 88 | 89 | return msg 90 | 91 | else: 92 | return super().repr_failure(excinfo) 93 | 94 | def reportinfo(self): 95 | return (self.path, -1, "mo-test") 96 | -------------------------------------------------------------------------------- /pytest_translations/po_files.py: -------------------------------------------------------------------------------- 1 | from pytest import File, Item 2 | 3 | from pytest_translations.config import MARKER_NAME 4 | from pytest_translations.po_spelling import PoSpellCheckingItem 5 | from pytest_translations.utils import TranslationException, msgfmt, open_po_file 6 | 7 | 8 | class PoFile(File): 9 | def __init__(self, **kwargs): 10 | super().__init__(**kwargs) 11 | 12 | if hasattr(self, "add_marker"): 13 | self.add_marker(MARKER_NAME) 14 | else: 15 | self.keywords[MARKER_NAME] = True 16 | 17 | def collect(self): 18 | yield PoUntranslatedItem.from_parent( 19 | name=self.name, 20 | parent=self, 21 | ) 22 | yield PoFuzzyItem.from_parent( 23 | name=self.name, 24 | parent=self, 25 | ) 26 | yield PoObsoleteItem.from_parent( 27 | name=self.name, 28 | parent=self, 29 | ) 30 | try: 31 | parsed = open_po_file(self.path) 32 | except TranslationException: 33 | # if the PO file is invalid, we can't do spellchecking. 34 | # the other tests will fail then, but this here is the collection phase. 35 | pass 36 | else: 37 | language = parsed.metadata.get("Language", "") 38 | for line in parsed.translated_entries(): 39 | yield PoSpellCheckingItem.from_parent( 40 | line=line, 41 | language=language, 42 | name=self.name, 43 | parent=self, 44 | ) 45 | 46 | 47 | class PoBaseItem(Item): 48 | def __init__(self, name, parent): 49 | super().__init__(name, parent) 50 | self.add_marker(MARKER_NAME) 51 | 52 | def repr_failure(self, excinfo): 53 | if isinstance(excinfo.value, TranslationException): 54 | msg, wrong = excinfo.value.args 55 | 56 | msg += "\n{0}".format(self.path) 57 | 58 | msg += "\n" + "\n".join(msgfmt(i) for i in wrong) 59 | 60 | return msg 61 | 62 | else: 63 | return super().repr_failure(excinfo) 64 | 65 | 66 | class PoUntranslatedItem(PoBaseItem): 67 | def runtest(self): 68 | parsed = open_po_file(self.path) 69 | 70 | untranslated = parsed.untranslated_entries() 71 | 72 | if not untranslated: 73 | return 74 | 75 | raise TranslationException( 76 | "found untranslated entries in file.", 77 | untranslated, 78 | ) 79 | 80 | def reportinfo(self): 81 | return (self.path, -1, "po-untranslated") 82 | 83 | 84 | class PoObsoleteItem(PoBaseItem): 85 | def runtest(self): 86 | parsed = open_po_file(self.path) 87 | 88 | obsolete = parsed.obsolete_entries() 89 | 90 | if not obsolete: 91 | return 92 | 93 | raise TranslationException( 94 | "found obsolete entries in file.", 95 | obsolete, 96 | ) 97 | 98 | def reportinfo(self): 99 | return (self.path, -1, "po-obsolete") 100 | 101 | 102 | class PoFuzzyItem(PoBaseItem): 103 | def runtest(self): 104 | parsed = open_po_file(self.path) 105 | 106 | fuzzy = parsed.fuzzy_entries() 107 | 108 | if not fuzzy: 109 | return 110 | 111 | raise TranslationException( 112 | "found fuzzy entries in file.", 113 | fuzzy, 114 | ) 115 | 116 | def reportinfo(self): 117 | return (self.path, -1, "po-fuzzy") 118 | -------------------------------------------------------------------------------- /pytest_translations/po_spelling.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | 4 | from pytest import Item, skip 5 | 6 | from pytest_translations.config import MARKER_NAME 7 | from pytest_translations.utils import TranslationException 8 | 9 | try: 10 | import enchant 11 | from enchant.tokenize import EmailFilter, HTMLChunker, URLFilter, get_tokenizer 12 | except ImportError: 13 | enchant = None 14 | 15 | 16 | def is_enchant_installed(): 17 | return not (enchant is None) 18 | 19 | 20 | if enchant: 21 | supported_languages = [name.split("_")[0] for name, _ in enchant.list_dicts()] 22 | else: 23 | supported_languages = [] 24 | 25 | 26 | def word_lists(): 27 | PWL = os.getenv("PYTEST_TRANSLATIONS_PRIVATE_WORD_LIST") 28 | 29 | if enchant and PWL and os.path.exists(PWL): 30 | return { 31 | fn: os.path.join(PWL, fn) for fn in os.listdir(PWL) if not os.path.isdir(fn) 32 | } 33 | 34 | else: 35 | return {} 36 | 37 | 38 | class PoSpellCheckingItem(Item): 39 | def __init__(self, line, language, name, parent): 40 | self.line = line 41 | self.language = language 42 | 43 | wl = word_lists() 44 | 45 | private_word_list = wl.get(language, None) 46 | 47 | if enchant and language in supported_languages: 48 | self.lang_dict = enchant.DictWithPWL(language, pwl=private_word_list) 49 | else: 50 | self.lang_dict = None 51 | 52 | super().__init__(name, parent) 53 | self.add_marker(MARKER_NAME) 54 | 55 | def runtest(self): 56 | if not enchant: 57 | skip("enchant is not installed") 58 | 59 | if not self.language: 60 | skip("no language defined in PO file") 61 | 62 | if self.language not in supported_languages: 63 | skip("aspell dictionary for language {} not found.".format(self.language)) 64 | 65 | text = self.line.msgstr 66 | 67 | # remove python format placeholders, old and new, 68 | # where it's a problem. 69 | # 1. replace everything between curly braces 70 | text = re.sub("{.*?}", "", text) 71 | # 2. remove everything between %( and ) 72 | text = re.sub(r"\%\(.*?\)", "", text) 73 | # 3. remove ­ html entity 74 | text = text.replace("­", "") 75 | 76 | tokenizer = get_tokenizer( 77 | chunkers=[ 78 | HTMLChunker, 79 | ], 80 | filters=( 81 | EmailFilter, 82 | URLFilter, 83 | ), 84 | ) 85 | tokens = tokenizer(text) 86 | 87 | errors = [t for t, _ in tokens if not self.lang_dict.check(t)] 88 | 89 | if errors: 90 | raise TranslationException( 91 | "Spell checking failed: {}".format(", ".join(errors)), 92 | self.line.msgstr, 93 | [(t, self.lang_dict.suggest(t)) for t in errors], 94 | ) 95 | 96 | def repr_failure(self, excinfo): 97 | if isinstance(excinfo.value, TranslationException): 98 | msg, line, wrong = excinfo.value.args 99 | lines = [msg, str(self.path), 'msgstr "%s"' % line] 100 | for i in wrong: 101 | lines.append("%s: %s" % i) 102 | 103 | return "\n".join(lines) 104 | 105 | else: 106 | return super().repr_failure(excinfo) 107 | 108 | def reportinfo(self): 109 | return (self.path, -1, "po-spelling") 110 | -------------------------------------------------------------------------------- /pytest_translations/utils.py: -------------------------------------------------------------------------------- 1 | def msgfmt(i): 2 | return '"{0}": "{1}"'.format( 3 | i.msgid, 4 | i.msgstr, 5 | ) 6 | 7 | 8 | def open_po_file(fn): 9 | import polib 10 | 11 | try: 12 | return polib.pofile(str(fn)) 13 | except Exception as e: 14 | raise TranslationException( 15 | str(e), 16 | [], 17 | ) 18 | 19 | 20 | def open_mo_file(fn): 21 | import polib 22 | 23 | try: 24 | return polib.mofile(str(fn)) 25 | except Exception as e: 26 | raise TranslationException( 27 | str(e), 28 | [], 29 | ) 30 | 31 | 32 | class TranslationException(Exception): 33 | pass 34 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [tool:pytest] 2 | norecursedirs=venv env .eggs 3 | addopts = --tb=short 4 | filterwarnings = 5 | error 6 | 7 | [flake8] 8 | max-line-length = 88 9 | extend-ignore = E203 10 | -------------------------------------------------------------------------------- /test_translations.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | import os 3 | 4 | import polib 5 | import pytest 6 | 7 | pytest_plugins = ("pytester",) 8 | 9 | 10 | class TestMo(object): 11 | @pytest.fixture 12 | def pomo(self, testdir): 13 | pofile = testdir.makefile( 14 | ".po", 15 | """ 16 | #: asdf.py:111 17 | msgid "car" 18 | msgstr "Auto" 19 | """, 20 | ) 21 | 22 | p, ext = os.path.splitext(str(pofile)) 23 | mofile = p + ".mo" 24 | 25 | polib.pofile(str(pofile)).save_as_mofile(mofile) 26 | 27 | return str(pofile), str(mofile) 28 | 29 | def test_broken_file(self, testdir): 30 | testdir.makefile( 31 | ".po", 32 | """ 33 | #: asdf.py:111 34 | msgid "car" 35 | msgstr "Auto" 36 | """, 37 | ) 38 | testdir.makefile( 39 | ".mo", 40 | """ 41 | asdflkaj sdlkfaj 42 | """, 43 | ) 44 | 45 | result = testdir.runpytest("--translations") 46 | assert result.ret == 1 47 | result.stdout.fnmatch_lines( 48 | [ 49 | "*collected 5*", 50 | "*Invalid mo file*", 51 | "*1 failed*", 52 | ] 53 | ) 54 | 55 | def test_ok(self, testdir, pomo): 56 | result = testdir.runpytest("--translations", "-vvv") 57 | assert result.ret == 0 58 | result.stdout.fnmatch_lines( 59 | [ 60 | "*collected 5*", 61 | "*4 passed*", 62 | ] 63 | ) 64 | 65 | def test_missing_po(self, testdir, pomo): 66 | po, mo = pomo 67 | os.unlink(po) 68 | 69 | result = testdir.runpytest("--translations", "-vvv") 70 | assert result.ret == 1 71 | result.stdout.fnmatch_lines( 72 | [ 73 | "*collected 1*", 74 | "*corresponding .po file does not exist*", 75 | "*1 failed*", 76 | ] 77 | ) 78 | 79 | def test_entry_mismatch(self, testdir, pomo): 80 | po, mo = pomo 81 | os.unlink(po) 82 | 83 | testdir.makefile( 84 | ".po", 85 | """ 86 | msgid "" 87 | msgstr "" 88 | "Language: de\n" 89 | 90 | #: asdf.py:111 91 | msgid "bike" 92 | msgstr "Fahrrad" 93 | """, 94 | ) 95 | 96 | result = testdir.runpytest("--translations", "-vvv") 97 | assert result.ret == 1 98 | result.stdout.fnmatch_lines( 99 | [ 100 | "*collected 5*", 101 | "*mo*file content does not match po*", 102 | "*bike*Fahrrad*", 103 | ] 104 | ) 105 | 106 | 107 | class TestPo(object): 108 | def test_uses_argument(self, testdir): 109 | testdir.makefile( 110 | ".po", 111 | """ 112 | #: asdf.py:111 113 | msgid "car" 114 | msgstr "Auto" 115 | """, 116 | ) 117 | result = testdir.runpytest() 118 | result.stdout.fnmatch_lines( 119 | [ 120 | "*collected 0*", 121 | ] 122 | ) 123 | 124 | def test_broken_file(self, testdir): 125 | testdir.makefile( 126 | ".po", 127 | """ 128 | asdflkaj sdlkfaj 129 | """, 130 | ) 131 | result = testdir.runpytest("--translations", "-vvv") 132 | assert result.ret == 1 133 | result.stdout.fnmatch_lines( 134 | [ 135 | "*Syntax error*", 136 | ] 137 | ) 138 | 139 | def test_valid(self, testdir): 140 | testdir.makefile( 141 | ".po", 142 | """ 143 | #: asdf.py:111 144 | msgid "car" 145 | msgstr "Auto" 146 | """, 147 | ) 148 | result = testdir.runpytest("--translations", "-vvv") 149 | assert result.ret == 0 150 | result.stdout.fnmatch_lines( 151 | [ 152 | "*collected 4*", 153 | "*3 passed*", 154 | ] 155 | ) 156 | 157 | def test_missing_translation(self, testdir): 158 | testdir.makefile( 159 | ".po", 160 | """ 161 | #: asdf.py:111 162 | msgid "car" 163 | msgstr "" 164 | """, 165 | ) 166 | result = testdir.runpytest("--translations", "-vvv") 167 | assert result.ret == 1 168 | result.stdout.fnmatch_lines( 169 | [ 170 | "*collected 3*", 171 | "*1 failed*2 passed*", 172 | ] 173 | ) 174 | 175 | def test_fuzzy(self, testdir): 176 | testdir.makefile( 177 | ".po", 178 | """ 179 | #: asdf.py:111 180 | #, fuzzy 181 | msgid "car" 182 | msgstr "Auto" 183 | """, 184 | ) 185 | result = testdir.runpytest("--translations", "-vvv") 186 | assert result.ret == 1 187 | result.stdout.fnmatch_lines( 188 | [ 189 | "*collected 3*", 190 | "*fuzzy*", 191 | "*1 failed*2 passed*", 192 | ] 193 | ) 194 | 195 | def test_obsolete(self, testdir): 196 | testdir.makefile( 197 | ".po", 198 | """ 199 | #: asdf.py:111 200 | #~ msgid "car" 201 | #~ msgstr "Auto" 202 | """, 203 | ) 204 | result = testdir.runpytest("--translations", "-vvv") 205 | assert result.ret == 1 206 | result.stdout.fnmatch_lines( 207 | [ 208 | "*collected 3*", 209 | "*obsolete*", 210 | "*1 failed*2 passed*", 211 | ] 212 | ) 213 | 214 | def test_all(self, testdir): 215 | testdir.makefile( 216 | ".po", 217 | """ 218 | #: asdf.py:111 219 | msgid "car2" 220 | msgstr "" 221 | 222 | #: asdf.py:111 223 | #, fuzzy 224 | msgid "car1" 225 | msgstr "Auto1" 226 | 227 | #: asdf.py:111 228 | #~ msgid "car" 229 | #~ msgstr "Auto" 230 | """, 231 | ) 232 | result = testdir.runpytest("--translations", "-vvv") 233 | assert result.ret == 1 234 | result.stdout.fnmatch_lines( 235 | [ 236 | "*collected 3*", 237 | "*untranslated*", 238 | "*fuzzy*", 239 | "*obsolete*", 240 | "*3 failed*", 241 | ] 242 | ) 243 | 244 | 245 | class TestPoSpellcheck(object): 246 | def test_broken_file(self, testdir): 247 | testdir.makefile( 248 | ".po", 249 | """ 250 | asdflkjasdf laskdjfasdf 251 | """, 252 | ) 253 | result = testdir.runpytest("--translations", "-vvv", "-r", "s") 254 | result.stdout.fnmatch_lines( 255 | [ 256 | "*collected 3*", 257 | ] 258 | ) 259 | 260 | def test_language_missing_in_po(self, testdir): 261 | testdir.makefile( 262 | ".po", 263 | """ 264 | #: asdf.py:111 265 | msgid "meeting" 266 | msgstr "meeeting" 267 | """, 268 | ) 269 | result = testdir.runpytest("--translations", "-vvv", "-r", "s") 270 | result.stdout.fnmatch_lines( 271 | [ 272 | "*collected 4*", 273 | "SKIPPED * no language defined in PO file", 274 | ] 275 | ) 276 | 277 | def test_language_catalog_missing(self, testdir): 278 | testdir.makefile( 279 | ".po", 280 | """ 281 | msgid "" 282 | msgstr "" 283 | "Language: hr\\n" 284 | 285 | #: asdf.py:111 286 | msgid "meeting" 287 | msgstr "meeeting" 288 | """, 289 | ) 290 | result = testdir.runpytest("--translations", "-vvv", "-r", "s") 291 | result.stdout.fnmatch_lines( 292 | [ 293 | "*collected 4*", 294 | "SKIPPED * aspell dictionary for language hr not found*", 295 | ] 296 | ) 297 | 298 | def test_python_format_placeholders(self, testdir): 299 | testdir.makefile( 300 | ".po", 301 | """ 302 | msgid "" 303 | msgstr "" 304 | "Language: de\\n" 305 | 306 | #: asdf.py:111 307 | msgid "meeting" 308 | msgstr "Langer Text %(salkdjfalsdjf)s {kaksjsalkas} Verabredung" 309 | """, 310 | ) 311 | result = testdir.runpytest("--translations", "-vvv", "-r", "s") 312 | result.stdout.fnmatch_lines( 313 | [ 314 | "*collected 4*", 315 | "*4 passed*", 316 | ] 317 | ) 318 | 319 | def test_shy_entity(self, testdir): 320 | testdir.makefile( 321 | ".po", 322 | """ 323 | msgid "" 324 | msgstr "" 325 | "Language: de\\n" 326 | 327 | #: asdf.py:111 328 | msgid "meeting" 329 | msgstr "Ver­ab­redung" 330 | """, 331 | ) 332 | result = testdir.runpytest("--translations", "-vvv", "-r", "s") 333 | result.stdout.fnmatch_lines( 334 | [ 335 | "*collected 4*", 336 | "*4 passed*", 337 | ] 338 | ) 339 | 340 | def test_pass(self, testdir): 341 | testdir.makefile( 342 | ".po", 343 | """ 344 | msgid "" 345 | msgstr "" 346 | "Language: de\\n" 347 | 348 | #: asdf.py:111 349 | msgid "meeting" 350 | msgstr "Verabredung" 351 | """, 352 | ) 353 | result = testdir.runpytest("--translations", "-vvv", "-r", "s") 354 | result.stdout.fnmatch_lines( 355 | [ 356 | "*collected 4*", 357 | "*4 passed*", 358 | ] 359 | ) 360 | 361 | def test_fail(self, testdir): 362 | testdir.makefile( 363 | ".po", 364 | """ 365 | msgid "" 366 | msgstr "" 367 | "Language: de\\n" 368 | 369 | #: asdf.py:111 370 | msgid "meeting" 371 | msgstr "meeeting" 372 | """, 373 | ) 374 | result = testdir.runpytest("--translations", "-vvv", "-r", "s") 375 | result.stdout.fnmatch_lines( 376 | [ 377 | "*collected 4*", 378 | "*Spell checking failed:*", 379 | '*msgstr "meeeting"*', 380 | "*1 failed*", 381 | ] 382 | ) 383 | 384 | def test_wordlist(self, testdir, monkeypatch): 385 | testdir.makefile( 386 | ".po", 387 | """ 388 | msgid "" 389 | msgstr "" 390 | "Language: de\\n" 391 | 392 | #: asdf.py:111 393 | msgid "meeting" 394 | msgstr "meeeting" 395 | """, 396 | ) 397 | 398 | test_dir = str(testdir.tmpdir.dirpath()) 399 | wordlist_de = os.path.join(test_dir, "de") 400 | assert not os.path.exists(wordlist_de) 401 | 402 | with open(wordlist_de, "w+") as wl: 403 | wl.write("meeeting\n") 404 | 405 | monkeypatch.setenv("PYTEST_TRANSLATIONS_PRIVATE_WORD_LIST", test_dir) 406 | 407 | assert os.path.exists(wordlist_de) 408 | 409 | result = testdir.runpytest("--translations", "-vvv") 410 | result.stdout.fnmatch_lines( 411 | [ 412 | "*collected 4*", 413 | "*4 passed*", 414 | ] 415 | ) 416 | --------------------------------------------------------------------------------