├── .gitignore ├── .travis.yml ├── AUTHORS.rst ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.rst ├── HISTORY.rst ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.rst ├── docs ├── Makefile ├── authors.rst ├── conf.py ├── contributing.rst ├── goals.rst ├── granola.rst ├── history.rst ├── index.rst ├── installation.rst ├── migratingfromtower.rst ├── puente_logo.jpg └── release.rst ├── puente ├── __init__.py ├── commands.py ├── ext.py ├── extract.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ ├── extract.py │ │ └── merge.py ├── settings.py └── utils.py ├── puente_logo.jpg ├── pytest.ini ├── requirements-dev.txt ├── setup.cfg ├── setup.py ├── test_project_django_jinja ├── manage.py └── test_project │ ├── __init__.py │ ├── settings.py │ ├── urls.py │ └── wsgi.py ├── tests ├── __init__.py ├── test_ext.py ├── test_extract.py ├── test_merge.py └── test_utils.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # Packages 4 | *.egg 5 | *.egg-info 6 | dist 7 | build 8 | eggs 9 | sdist 10 | .installed.cfg 11 | 12 | # My emacs backup files 13 | *~ 14 | 15 | # Installer logs 16 | pip-log.txt 17 | 18 | # Unit test / coverage reports 19 | .coverage 20 | .tox 21 | .cache 22 | 23 | # Translations 24 | locale/ 25 | *.mo 26 | 27 | # Test project sqlite3 databases 28 | *.sqlite3 29 | 30 | # Sphinx 31 | docs/_build 32 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | sudo: false 3 | matrix: 4 | include: 5 | - python: "2.7" 6 | env: TOXENV=py27-django1.8 7 | - python: "3.4" 8 | env: TOXENV=py34-django1.8 9 | - python: "2.7" 10 | env: TOXENV=py27-django1.9 11 | - python: "3.5" 12 | env: TOXENV=py35-django1.9 13 | - python: "2.7" 14 | env: TOXENV=py27-django1.10 15 | - python: "3.5" 16 | env: TOXENV=py35-django1.10 17 | - python: "2.7" 18 | env: TOXENV=py27-django1.11 19 | - python: "3.6" 20 | env: TOXENV=py36-django1.11 21 | - python: "3.5" 22 | env: TOXENV=py35-django2.0 23 | - python: "3.5" 24 | env: TOXENV=py35-django2.1 25 | - python: "3.6" 26 | env: TOXENV=py36-django2.2 27 | dist: xenial # For SQLite 3.8.3 or later 28 | - python: "3.7" 29 | env: TOXENV=py37-djangomaster 30 | dist: xenial # For Python 3.7 31 | allow_failures: 32 | - env: TOXENV=py37-djangomaster 33 | install: 34 | - pip install tox 35 | script: 36 | - tox 37 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | Credits 3 | ======= 4 | 5 | Development Lead 6 | ================ 7 | 8 | * Will Kahn-Greene 9 | 10 | 11 | Contributors 12 | ============ 13 | 14 | * Rob Hudson 15 | * Mike Cooper 16 | 17 | 18 | Contributors to Tower 19 | ===================== 20 | 21 | * Dan Poirier 22 | * Dave Dash 23 | * Fred Wenzel 24 | * James Socol 25 | * Jeff Balogh 26 | * Mathieu Agopian 27 | * Michael Kelly 28 | * Michael Ortali 29 | * Paul McLanahan 30 | * Ryan Freebern 31 | * Staś Małolepszy 32 | * Thijs Triemstra 33 | * Wil Clouser 34 | * Will Kahn-Greene 35 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Community Participation Guidelines 2 | 3 | This repository is governed by Mozilla's code of conduct and etiquette guidelines. 4 | For more details, please read the 5 | [Mozilla Community Participation Guidelines](https://www.mozilla.org/about/governance/policies/participation/). 6 | 7 | ## How to Report 8 | 9 | For more information on how to report violations of the Community Participation 10 | Guidelines, please read our 11 | '[How to Report](https://www.mozilla.org/about/governance/policies/participation/reporting/)' 12 | page. 13 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Contributing 3 | ============ 4 | 5 | Contributions are welcome, and they are greatly appreciated! Every 6 | little bit helps, and credit will always be given. 7 | 8 | You can contribute in many ways: 9 | 10 | 11 | Types of Contributions 12 | ====================== 13 | 14 | Report Bugs 15 | ----------- 16 | 17 | Report bugs at https://github.com/mozilla/puente/issues . 18 | 19 | If you are reporting a bug, please include: 20 | 21 | * Your operating system name and version. 22 | * Any details about your local setup that might be helpful in 23 | troubleshooting. 24 | * Detailed steps to reproduce the bug. 25 | 26 | 27 | Fix Bugs 28 | -------- 29 | 30 | Look through the GitHub issues for bugs. Anything tagged with "bug" 31 | is open to whoever wants to implement it. 32 | 33 | 34 | Implement Features 35 | ------------------ 36 | 37 | Look through the GitHub issues for features. Anything tagged with "feature" 38 | is open to whoever wants to implement it. 39 | 40 | 41 | Write Documentation 42 | ------------------- 43 | 44 | Puente could always use more documentation, whether as part of the 45 | official docs, in docstrings, or even on the web in blog posts, 46 | articles, and such. 47 | 48 | 49 | Submit Feedback 50 | --------------- 51 | 52 | The best way to send feedback is to file an issue at https://github.com/mozilla/puente/issues. 53 | 54 | If you are proposing a feature: 55 | 56 | * Explain in detail how it would work. 57 | * Keep the scope as narrow as possible, to make it easier to 58 | implement. 59 | * Remember that this is a volunteer-driven project, and that contributions 60 | are welcome :) 61 | 62 | 63 | Get Started! 64 | ------------ 65 | 66 | Ready to contribute? Here's how to set up `puente` for 67 | local development. 68 | 69 | 1. Fork the `puente` repo on GitHub. 70 | 2. Clone your fork locally:: 71 | 72 | $ git clone git@github.com:your_name_here/puente.git 73 | 74 | 3. Install your local copy into a virtualenv. Assuming you have 75 | virtualenvwrapper installed, this is how you set up your fork for 76 | local development:: 77 | 78 | $ mkvirtualenv puente 79 | $ cd puente/ 80 | $ pip install -r requirements-dev.txt 81 | 82 | 4. Create a branch for local development:: 83 | 84 | $ git checkout -b name-of-your-bugfix-or-feature 85 | 86 | Now you can make your changes locally. 87 | 88 | 5. When you're done making changes, check that your changes pass 89 | flake8 and the tests, including testing other Python versions with 90 | tox:: 91 | 92 | $ flake8 puente tests 93 | $ python setup.py test 94 | $ tox 95 | 96 | To get flake8 and tox, just pip install them into your virtualenv. 97 | 98 | 6. Commit your changes and push your branch to GitHub:: 99 | 100 | $ git add . 101 | $ git commit -m "Your detailed description of your changes." 102 | $ git push origin name-of-your-bugfix-or-feature 103 | 104 | 7. Submit a pull request through the GitHub website. 105 | 106 | 107 | Pull Request Guidelines 108 | ======================= 109 | 110 | Before you submit a pull request, check that it meets these guidelines: 111 | 112 | 1. The pull request should include tests. 113 | 2. If the pull request adds functionality, the docs should be updated. Put 114 | your new functionality into a function with a docstring, and add the 115 | feature to the list in README.rst. 116 | 3. The pull request should work for Python 2.7 and 3.3. Check 117 | https://travis-ci.org/mozilla/puente/pull_requests 118 | and make sure that the tests pass for all supported Python versions. 119 | 120 | 121 | Tests 122 | ===== 123 | 124 | We use py.test and tox for tests. 125 | 126 | To run tests:: 127 | 128 | $ py.test 129 | 130 | To run tests in all environments:: 131 | 132 | $ tox 133 | -------------------------------------------------------------------------------- /HISTORY.rst: -------------------------------------------------------------------------------- 1 | .. :changelog: 2 | 3 | ======= 4 | History 5 | ======= 6 | 7 | 1.0.0 (May 11th, 2022) 8 | ====================== 9 | 10 | Backwards incompatible changes: 11 | 12 | * Dropped support for Python < 3.7. 13 | * Dropped support for Django < 3.2. 14 | * Ended the project. Please switch to something else. 15 | 16 | Changes: 17 | 18 | * Added support for Django 3.2. 19 | * Added support for Python 3.7, 3.8, 3.9, and 3.10. 20 | * Fixed issues with recent Jinja2 releases. 21 | * Switched to semver! 22 | 23 | 24 | 0.5 (March 3rd, 2017) 25 | ===================== 26 | 27 | * Drop support for Django 1.7 and Jingo 28 | * Add support for Python 3.5 and 3.6 29 | * Add support for Django 1.9, 1.10 and 1.11b1 (#59) (Thank you, Thor K. H!) 30 | 31 | 32 | 0.4.1 (December 10th, 2015) 33 | =========================== 34 | 35 | * Add all the Django keywords for extraction (#53) 36 | 37 | 38 | 0.4 (November 20th, 2015) 39 | ========================= 40 | 41 | * Implement pgettext and npgettext (#45) 42 | * Remove undocumented STANDALONE_DOMAINS setting and fix extract/merge code (#44) 43 | * Add ngettext tests 44 | * Rework gettext code, clarify documentation and add tests (#42) 45 | * Project infrastructure fixes 46 | 47 | 48 | 0.3 (November 5th, 2015) 49 | ======================== 50 | 51 | * add "Translators:" to the translator prefix list (#34) 52 | * make ``puente.ext.i18n`` be an alias for ``puente.ext.PuenteI18nExtension`` 53 | * fix the gettext alias to be moar korrect (#35) 54 | * fix the jingo-related docs in regards to extensions (#35) 55 | * lots of changes to the Migrating from Tower document 56 | * fleshed out ``test_project_jingo`` so we can use it for development 57 | * fixed merge to handle ``LANGUAGES`` setting correctly 58 | * first pass on Python 3.4 support (pretty sure it works) (#15) 59 | * logo (#37) 60 | 61 | 62 | 0.2 (October 30th, 2015) 63 | ======================== 64 | 65 | * fix requirements 66 | * remove mention of elasticutils in release process 67 | * fix meta information regarding python 3--we don't support that, yet 68 | 69 | 70 | 0.1 (October 30th, 2015) 71 | ======================== 72 | 73 | Initial writing. Everything has changed! 74 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2010-2015, Mozilla Foundation 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | * Neither the name of the Mozilla Corporation nor the names of its contributors 15 | may be used to endorse or promote products derived from this software without 16 | specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.jpg 2 | include *.rst 3 | include Makefile 4 | include pytest.ini 5 | include tox.ini 6 | include requirements-dev.txt 7 | include LICENSE 8 | include CODE_OF_CONDUCT.md 9 | 10 | recursive-include docs *.py 11 | recursive-include docs *.rst 12 | recursive-include docs *.jpg 13 | recursive-include docs Makefile 14 | recursive-include test_project_django_jinja *.py 15 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: clean-pyc clean-build docs 2 | 3 | help: 4 | @echo "clean-build - remove build artifacts" 5 | @echo "clean-pyc - remove Python file artifacts" 6 | @echo "lint - check style with flake8" 7 | @echo "test - run tests quickly with the default Python" 8 | @echo "testall - run tests on every Python version with tox" 9 | @echo "docs - generate Sphinx HTML documentation, including API docs" 10 | 11 | clean: clean-build clean-pyc clean-docs 12 | 13 | clean-build: 14 | rm -rf build/ 15 | rm -rf dist/ 16 | rm -rf *.egg-info 17 | 18 | clean-pyc: 19 | find . -name '*.pyc' -exec rm -f {} + 20 | find . -name '*.pyo' -exec rm -f {} + 21 | find . -name '*~' -exec rm -f {} + 22 | 23 | clean-docs: 24 | rm -rf docs/_build/ 25 | 26 | lint: 27 | black --target-version=py37 --line-length=88 setup.py puente tests 28 | flake8 puente tests 29 | 30 | test: 31 | py.test 32 | 33 | test-all: 34 | tox 35 | 36 | docs: 37 | rm -f docs/*puente.rst 38 | $(MAKE) -C docs clean 39 | $(MAKE) -C docs html 40 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ====== 2 | puente 3 | ====== 4 | 5 | **Note (2022-05-11): This project is no longer maintained.** 6 | 7 | .. image:: puente_logo.jpg 8 | 9 | Puente is a Python library that handles l10n things for Django projects 10 | using Jinja2 templates. 11 | 12 | * extract command to extract strings from your project and shove them into a 13 | ``.pot`` file 14 | * merge command that merges new strings from a ``.pot`` file into locale ``.po`` 15 | files 16 | * code to collapse whitespace for Jinja2's trans block 17 | * add pgettext and npgettext to template environment and they correctly 18 | escape things and work the same way as Jinja2's newstyle gettext 19 | * configured using Django settings 20 | * solid documentation 21 | * solid tests 22 | 23 | This is derived from `Tower `_, but heavily 24 | changed. 25 | 26 | This project is lightly maintained, and the goal is to phase it out, replacing 27 | it with 28 | `standard Django `_ 29 | for most cases, and 30 | `Babel `_ for more complex cases. For more 31 | information, see the issues and the 32 | `current status of phasing Puente out `_. 33 | 34 | 35 | :Code: https://github.com/mozilla/puente/ 36 | :Issues: No longer maintained. 37 | :License: BSD 3-clause; See LICENSE 38 | :Contributors: See AUTHORS.rst 39 | :Documentation: https://puente.readthedocs.io/ 40 | 41 | 42 | Install 43 | ======= 44 | 45 | From PyPI 46 | --------- 47 | 48 | Run:: 49 | 50 | $ pip install puente 51 | 52 | 53 | For hacking 54 | ----------- 55 | 56 | Run:: 57 | 58 | # Clone the repository 59 | $ git clone https://github.com/mozilla/puente 60 | 61 | # Create a virtualenvironment 62 | ... 63 | 64 | # Install Puente and dev requirements 65 | $ pip install -r requirements-dev.txt 66 | 67 | 68 | Usage 69 | ===== 70 | 71 | See `documentation ` for configuration and usage. 72 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | 49 | clean: 50 | rm -rf $(BUILDDIR)/* 51 | 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | dirhtml: 58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 61 | 62 | singlehtml: 63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 64 | @echo 65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 66 | 67 | pickle: 68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 69 | @echo 70 | @echo "Build finished; now you can process the pickle files." 71 | 72 | json: 73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 74 | @echo 75 | @echo "Build finished; now you can process the JSON files." 76 | 77 | htmlhelp: 78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 79 | @echo 80 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 81 | ".hhp project file in $(BUILDDIR)/htmlhelp." 82 | 83 | qthelp: 84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 85 | @echo 86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/complexity.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/complexity.qhc" 91 | 92 | devhelp: 93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 94 | @echo 95 | @echo "Build finished." 96 | @echo "To view the help file:" 97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/complexity" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/complexity" 99 | @echo "# devhelp" 100 | 101 | epub: 102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 103 | @echo 104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 105 | 106 | latex: 107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 108 | @echo 109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 111 | "(use \`make latexpdf' here to do that automatically)." 112 | 113 | latexpdf: 114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 115 | @echo "Running LaTeX files through pdflatex..." 116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 118 | 119 | latexpdfja: 120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 121 | @echo "Running LaTeX files through platex and dvipdfmx..." 122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 124 | 125 | text: 126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 127 | @echo 128 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 129 | 130 | man: 131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 132 | @echo 133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 134 | 135 | texinfo: 136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 137 | @echo 138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 139 | @echo "Run \`make' in that directory to run these through makeinfo" \ 140 | "(use \`make info' here to do that automatically)." 141 | 142 | info: 143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 144 | @echo "Running Texinfo files through makeinfo..." 145 | make -C $(BUILDDIR)/texinfo info 146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 147 | 148 | gettext: 149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 150 | @echo 151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 152 | 153 | changes: 154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 155 | @echo 156 | @echo "The overview file is in $(BUILDDIR)/changes." 157 | 158 | linkcheck: 159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 160 | @echo 161 | @echo "Link check complete; look for any errors in the above output " \ 162 | "or in $(BUILDDIR)/linkcheck/output.txt." 163 | 164 | doctest: 165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 166 | @echo "Testing of doctests in the sources finished, look at the " \ 167 | "results in $(BUILDDIR)/doctest/output.txt." 168 | 169 | xml: 170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 171 | @echo 172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 173 | 174 | pseudoxml: 175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 176 | @echo 177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 178 | -------------------------------------------------------------------------------- /docs/authors.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../AUTHORS.rst 2 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # 4 | # complexity documentation build configuration file, created by 5 | # sphinx-quickstart on Tue Jul 9 22:26:36 2013. 6 | # 7 | # This file is execfile()d with the current directory set to its containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | import sys, os 16 | 17 | # If extensions (or modules to document with autodoc) are in another directory, 18 | # add these directories to sys.path here. If the directory is relative to the 19 | # documentation root, use os.path.abspath to make it absolute, like shown here. 20 | #sys.path.insert(0, os.path.abspath('.')) 21 | 22 | # Get the project root dir, which is the parent dir of this 23 | cwd = os.getcwd() 24 | project_root = os.path.dirname(cwd) 25 | 26 | # Insert the project root dir as the first element in the PYTHONPATH. 27 | # This lets us ensure that the source package is imported, and that its 28 | # version is used. 29 | sys.path.insert(0, project_root) 30 | 31 | import puente 32 | 33 | # -- General configuration ----------------------------------------------------- 34 | 35 | # If your documentation needs a minimal Sphinx version, state it here. 36 | #needs_sphinx = '1.0' 37 | 38 | # Add any Sphinx extension module names here, as strings. They can be extensions 39 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 40 | extensions = ['sphinx.ext.autodoc', 'sphinx.ext.viewcode'] 41 | 42 | # Add any paths that contain templates here, relative to this directory. 43 | templates_path = ['_templates'] 44 | 45 | # The suffix of source filenames. 46 | source_suffix = '.rst' 47 | 48 | # The encoding of source files. 49 | #source_encoding = 'utf-8-sig' 50 | 51 | # The master toctree document. 52 | master_doc = 'index' 53 | 54 | # General information about the project. 55 | project = u'' 56 | copyright = u'2015, Mozilla Foundation' 57 | 58 | # The version info for the project you're documenting, acts as replacement for 59 | # |version| and |release|, also used in various other places throughout the 60 | # built documents. 61 | # 62 | # The short X.Y version. 63 | version = puente.__version__ 64 | # The full version, including alpha/beta/rc tags. 65 | release = puente.__version__ 66 | 67 | # The language for content autogenerated by Sphinx. Refer to documentation 68 | # for a list of supported languages. 69 | #language = None 70 | 71 | # There are two options for replacing |today|: either, you set today to some 72 | # non-false value, then it is used: 73 | #today = '' 74 | # Else, today_fmt is used as the format for a strftime call. 75 | #today_fmt = '%B %d, %Y' 76 | 77 | # List of patterns, relative to source directory, that match files and 78 | # directories to ignore when looking for source files. 79 | exclude_patterns = ['_build'] 80 | 81 | # The reST default role (used for this markup: `text`) to use for all documents. 82 | #default_role = None 83 | 84 | # If true, '()' will be appended to :func: etc. cross-reference text. 85 | #add_function_parentheses = True 86 | 87 | # If true, the current module name will be prepended to all description 88 | # unit titles (such as .. function::). 89 | #add_module_names = True 90 | 91 | # If true, sectionauthor and moduleauthor directives will be shown in the 92 | # output. They are ignored by default. 93 | #show_authors = False 94 | 95 | # The name of the Pygments (syntax highlighting) style to use. 96 | pygments_style = 'sphinx' 97 | 98 | # A list of ignored prefixes for module index sorting. 99 | #modindex_common_prefix = [] 100 | 101 | # If true, keep warnings as "system message" paragraphs in the built documents. 102 | #keep_warnings = False 103 | 104 | 105 | # -- Options for HTML output --------------------------------------------------- 106 | 107 | # The theme to use for HTML and HTML Help pages. See the documentation for 108 | # a list of builtin themes. 109 | html_theme = 'alabaster' 110 | 111 | # Theme options are theme-specific and customize the look and feel of a theme 112 | # further. For a list of options available for each theme, see the 113 | # documentation. 114 | #html_theme_options = {} 115 | 116 | # Add any paths that contain custom themes here, relative to this directory. 117 | #html_theme_path = [] 118 | 119 | # The name for this set of Sphinx documents. If None, it defaults to 120 | # " v documentation". 121 | #html_title = None 122 | 123 | # A shorter title for the navigation bar. Default is the same as html_title. 124 | #html_short_title = None 125 | 126 | # The name of an image file (relative to this directory) to place at the top 127 | # of the sidebar. 128 | #html_logo = None 129 | 130 | # The name of an image file (within the static path) to use as favicon of the 131 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 132 | # pixels large. 133 | #html_favicon = None 134 | 135 | # Add any paths that contain custom static files (such as style sheets) here, 136 | # relative to this directory. They are copied after the builtin static files, 137 | # so a file named "default.css" will overwrite the builtin "default.css". 138 | html_static_path = ['_static'] 139 | 140 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 141 | # using the given strftime format. 142 | #html_last_updated_fmt = '%b %d, %Y' 143 | 144 | # If true, SmartyPants will be used to convert quotes and dashes to 145 | # typographically correct entities. 146 | #html_use_smartypants = True 147 | 148 | # Custom sidebar templates, maps document names to template names. 149 | html_sidebars = { 150 | '**': [ 151 | 'about.html', 152 | 'navigation.html', 153 | 'relations.html', 154 | 'searchbox.html', 155 | 'donate.html', 156 | ] 157 | } 158 | 159 | # Additional templates that should be rendered to pages, maps page names to 160 | # template names. 161 | #html_additional_pages = {} 162 | 163 | # If false, no module index is generated. 164 | #html_domain_indices = True 165 | 166 | # If false, no index is generated. 167 | #html_use_index = True 168 | 169 | # If true, the index is split into individual pages for each letter. 170 | #html_split_index = False 171 | 172 | # If true, links to the reST sources are added to the pages. 173 | #html_show_sourcelink = True 174 | 175 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 176 | #html_show_sphinx = True 177 | 178 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 179 | #html_show_copyright = True 180 | 181 | # If true, an OpenSearch description file will be output, and all pages will 182 | # contain a tag referring to it. The value of this option must be the 183 | # base URL from which the finished HTML is served. 184 | #html_use_opensearch = '' 185 | 186 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 187 | #html_file_suffix = None 188 | 189 | # Output file base name for HTML help builder. 190 | htmlhelp_basename = 'puentedoc' 191 | 192 | 193 | # -- Options for LaTeX output -------------------------------------------------- 194 | 195 | latex_elements = { 196 | # The paper size ('letterpaper' or 'a4paper'). 197 | #'papersize': 'letterpaper', 198 | 199 | # The font size ('10pt', '11pt' or '12pt'). 200 | #'pointsize': '10pt', 201 | 202 | # Additional stuff for the LaTeX preamble. 203 | #'preamble': '', 204 | } 205 | 206 | # Grouping the document tree into LaTeX files. List of tuples 207 | # (source start file, target name, title, author, documentclass [howto/manual]). 208 | latex_documents = [ 209 | ('index', 'puente.tex', u' Documentation', 210 | u'Will Kahn-Greene', 'manual'), 211 | ] 212 | 213 | # The name of an image file (relative to this directory) to place at the top of 214 | # the title page. 215 | #latex_logo = None 216 | 217 | # For "manual" documents, if this is true, then toplevel headings are parts, 218 | # not chapters. 219 | #latex_use_parts = False 220 | 221 | # If true, show page references after internal links. 222 | #latex_show_pagerefs = False 223 | 224 | # If true, show URL addresses after external links. 225 | #latex_show_urls = False 226 | 227 | # Documents to append as an appendix to all manuals. 228 | #latex_appendices = [] 229 | 230 | # If false, no module index is generated. 231 | #latex_domain_indices = True 232 | 233 | 234 | # -- Options for manual page output -------------------------------------------- 235 | 236 | # One entry per manual page. List of tuples 237 | # (source start file, name, description, authors, manual section). 238 | man_pages = [ 239 | ('index', 'puente', u' Documentation', 240 | [u'Will Kahn-Greene'], 1) 241 | ] 242 | 243 | # If true, show URL addresses after external links. 244 | #man_show_urls = False 245 | 246 | 247 | # -- Options for Texinfo output ------------------------------------------------ 248 | 249 | # Grouping the document tree into Texinfo files. List of tuples 250 | # (source start file, target name, title, author, 251 | # dir menu entry, description, category) 252 | texinfo_documents = [ 253 | ('index', 'puente', u' Documentation', 254 | u'Will Kahn-Greene', 'puente', 'One line description of project.', 255 | 'Miscellaneous'), 256 | ] 257 | 258 | # Documents to append as an appendix to all manuals. 259 | #texinfo_appendices = [] 260 | 261 | # If false, no module index is generated. 262 | #texinfo_domain_indices = True 263 | 264 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 265 | #texinfo_show_urls = 'footnote' 266 | 267 | # If true, do not generate a @detailmenu in the "Top" node's menu. 268 | #texinfo_no_detailmenu = False 269 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CONTRIBUTING.rst 2 | -------------------------------------------------------------------------------- /docs/goals.rst: -------------------------------------------------------------------------------- 1 | ================== 2 | About this project 3 | ================== 4 | 5 | Why Puente? 6 | =========== 7 | 8 | Puente is a derivative of `Tower `_, but with 9 | a slightly different scope and a different purpose. Our goals are as follows: 10 | 11 | 1. Ease translation issues. 12 | 2. Correctly support Jinja2 templates. 13 | 3. Be as close to vanilla Django as possible. 14 | 15 | 16 | Ideally, we want to move closer to vanilla Django over time. The best-case 17 | scenario is that no one is using Puente in a couple of years. To get there, we 18 | need to adjust Puente so that it's closer to vanilla Django and also fix things 19 | upstream to meet the needs we have that aren't currently being met. 20 | 21 | So, why start a new project instead of continue Tower? Tower is pretty tied to 22 | Jingo and it does a few things I don't want to do anymore. I think this is 23 | enough of a difference that if I took over Tower and then marched it into my 24 | ideal future, users of Tower would get annoyed and I'd hit a lot of friction. 25 | With a library like this where it's pretty small in scope, it seemed way easier 26 | to break with the past, take the interesting parts and start anew. 27 | 28 | 29 | Why not just use Babel? 30 | ======================= 31 | 32 | Puente does three nice things: 33 | 34 | 1. makes it easy to migrate from Tower to something you can use with Django 1.8 35 | 2. collapses whitespace in Jinja2 trans blocks 36 | 3. adds pgettext and npgettext to template environment that work like Jinja2's 37 | newstyle gettext 38 | 4. pulls bits from Django settings to configure extraction (e.g. Jinja2 39 | extensions) 40 | 41 | If you don't care about any of those things, go use Babel's pybabel command and 42 | Jinja2's i18n extension--don't use Puente. 43 | 44 | 45 | What's different between Tower and Puente? 46 | ========================================== 47 | 48 | 1. Tower defaults to ``messages`` and ``javascript``, but Puente defaults to 49 | ``django`` and ``djangojs``. 50 | 51 | Django's ``makemessages`` command supports ``django`` and ``djangojs`` 52 | domains which create ``django.po(t)`` and ``djangojs.po(t)`` files so that's 53 | what we support with Puente, too. 54 | 55 | As far as I can tell, this won't be a problem for most situations. However, 56 | there is one situation where this makes things difficult. If you had parts of 57 | the site that **need** to be fully translated and other parts that need to be 58 | translated, but if it's not translated, it's not a big deal, then this makes 59 | that difficult. 60 | 61 | That's on the list of things to mull over how to deal with. 62 | 63 | 2. Tower collapses whitespace for all extracted strings, but Puente only 64 | collapses whitespace for Jinja2 trans blocks. 65 | 66 | Django has a ``blocktrans`` tag which since Django 1.7 has had a ``trimmed`` 67 | flag indicating that whitespace should be collapsed. Jinja2 has no such 68 | thing, but I wrote up an issue for it: 69 | 70 | https://github.com/mitsuhiko/jinja2/issues/504 71 | 72 | If that got implemented, then we could drop the tweaks we do to the ``trans`` 73 | block and use vanilla Jinja2 trans block. 74 | 75 | I think it's important to collapse whitespace in trans blocks because they're 76 | the most susceptible to msgid changes merely because of adjustments to 77 | indentation of the HTML template. That stinks because translators have to go 78 | through and fix all the translations. 79 | 80 | 3. Tower had a bunch of code to support msgctxt in extraction and gettext calls, 81 | but Puente relies on Django's pgettext functions and Babel's msgctxt support 82 | and that works super... except in Jinja2 templates. Puente adds pgettext and 83 | npgettext to the template environment and they work just like Jinja2's 84 | newstyle gettext. 85 | 86 | 4. Tower had its own gettext and ngettext that marked output as safe, but Puente 87 | drops that because it's unneeded if you're using Jinja2's newstyle gettext 88 | and autoescape enabled. 89 | 90 | 5. Tower used translate-toolkit to build the ``.pot`` file, but Puente uses 91 | Babel for putting together the ``.pot`` file. Thus we don't need 92 | translate-toolkit anymore. 93 | 94 | 6. Tower required Jingo, but Puente supports Jingo, django-jinja and other 95 | Jinja2 template environments. 96 | 97 | 7. Tower only supports Django 1.7 and lower versions and Puente only supports 98 | Django 1.7+. 99 | 100 | 8. Tower supports Python 2.6 and 2.7, but Puente supports 2.7. Hopefully Python 101 | 3 in the near future. 102 | 103 | 9. Tower has most of the code in ``__init__.py``, but Puente tries to be easier 104 | to use so you can import it without problems. 105 | 106 | 10. Tower uses nose for tests, but Puente uses py.test. 107 | 108 | This is purely because I stopped using nose on my projects. Generally, I find 109 | py.test easier to set up and use these days. I don't want to change this 110 | unless there's a compelling reason. Generally, if you were maintaining the 111 | project, I'd encourage you to use whichever test framework works best for 112 | you. 113 | 114 | 115 | Current status of phasing Puente out 116 | ==================================== 117 | 118 | The best future for Puente is that it gets phased out because it's not needed 119 | anymore. 120 | 121 | We need to do the following before we can end Puente: 122 | 123 | 1. DONE!: Jinja2 needs to collapse whitespace in the trans tag 124 | 125 | https://github.com/mitsuhiko/jinja2/issues/504 126 | 127 | 2. IN PROGRESS: Jinja2 needs to support pgettext/npgettext in templates. 128 | 129 | https://github.com/mitsuhiko/jinja2/issues/441 130 | 131 | 3. DONE!: django-babel needs to support "trimmed" in django templates. 132 | 133 | https://github.com/python-babel/django-babel/issues/20 134 | 135 | 4. Puente's extract command should work more like Babel's pybabel extract 136 | command. 137 | 138 | The way forward is to phase Puente out for pybabel. In order to make that 139 | work well, we should mimic pybabel's extract command more closely. 140 | 141 | This should probably be broken up into more steps as we discover differences. 142 | 143 | 5. Ditch Puente's merge for pybabel's update? 144 | 145 | 6. Need a nice way to use Django settings for pybabel configuration. For 146 | example, I'd rather not have to define the list of Jinja2 extensions to use 147 | in two places. 148 | 149 | 7. Is there anything else? 150 | -------------------------------------------------------------------------------- /docs/granola.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | Granola 3 | ======= 4 | 5 | This is a "I feel like granola right now" recipe that makes a decent 6 | granola without much fuss. 7 | 8 | Tools: 9 | 10 | 1. non-stick 10" frying pan 11 | 2. rubber spatula 12 | 13 | 14 | Ingredients: 15 | 16 | 1. 4 tablespoons of a fat (olive oil, butter, coconut oil, ...) 17 | 18 | These all have different flavors. Any of them will do. I like coconut oil 19 | since it gives a light coconut taste and coconut oil is pretty good for you 20 | as a fat. 21 | 22 | 2. 4 teaspoons of a sweetener (maple syrup, agave nectar, honey, ...) 23 | 24 | Any of these would do. You might be able to use a solid sweetener like brown 25 | sugar. Molasses might be interesting. 26 | 27 | I live in Massachusetts, USA so maple syrup is easy to acquire and it gives 28 | it a darker flavor that I like. 29 | 30 | 3. 1 cup of rolled oats 31 | 32 | It could be cut oats or whatever. The styling doesn't matter so much. 33 | 34 | 4. 1/2 cup of something something 35 | 36 | I like putting in hemp hearts and cashews that I break up while watching the 37 | oats brown. I also put in some cut dates. Other nice things: sesame seeds, 38 | sunflower seeds, chia seeds, shredded coconut, almond pieces, ... 39 | 40 | 41 | Instructions: 42 | 43 | 1. Put the **frying pan** on the burner and turn it on high. 44 | 45 | 2. Put the **fat** and the **sweetener** in the pan, let them melt and combine. 46 | I move it around a bit with the **spatula** so it doesn't burn. 47 | 48 | This takes about a minute. 49 | 50 | 3. Dump the **oats** in and mix it all up so that the oats absorb the fat and 51 | sweetener. 52 | 53 | Then try to flatten out the oats so they can brown evenly. 54 | 55 | This takes about a minute. 56 | 57 | 4. Turn the burner down low and let the oats brown evenly. Stir periodically. 58 | 59 | Takes about eight to ten minutes depending. They should be noticeably 60 | browner, but don't burn them. 61 | 62 | 5. When they're brown, then dump them out on a cookie sheet, spread them out 63 | and let them cool. 64 | 65 | It's a small amount and the cookie sheet will dissipate the heat, so this 66 | takes 67 | 68 | 6. Dump the mixture and the **something something** into a bowl and stir it up. 69 | 70 | 7. Done! 71 | -------------------------------------------------------------------------------- /docs/history.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../HISTORY.rst 2 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst 2 | 3 | 4 | User's Guide 5 | ============ 6 | 7 | .. toctree:: 8 | :maxdepth: 2 9 | 10 | installation 11 | migratingfromtower 12 | authors 13 | history 14 | granola 15 | 16 | 17 | Maintainer's Guide 18 | ================== 19 | 20 | .. toctree:: 21 | :maxdepth: 2 22 | 23 | contributing 24 | goals 25 | release 26 | 27 | 28 | Indices and tables 29 | ================== 30 | 31 | * :ref:`genindex` 32 | * :ref:`modindex` 33 | * :ref:`search` 34 | 35 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | =============== 2 | Install and use 3 | =============== 4 | 5 | Install 6 | ======= 7 | 8 | Activate your virtual environment, then do: 9 | 10 | .. code-block:: bash 11 | 12 | $ pip install puente 13 | 14 | 15 | **Optional:** If you want to extract strings from Django templates, you will 16 | also need to install django-babel which has an extractor for Django templates: 17 | 18 | .. code-block:: bash 19 | 20 | $ pip install django-babel 21 | 22 | 23 | Configure 24 | ========= 25 | 26 | In ``settings.py`` add Puente to ``INSTALLED_APPS``: 27 | 28 | .. code-block:: python 29 | 30 | INSTALLED_APPS = [ 31 | # ... 32 | 'puente', 33 | # ... 34 | ] 35 | 36 | 37 | In ``settings.py`` add ``puente.ext.i18n`` as an extension in your Jinja2 38 | template environment configuration. For example, if you were using django-jinja, 39 | then it might look like this: 40 | 41 | .. code-block:: python 42 | 43 | TEMPLATES = [ 44 | { 45 | 'BACKEND': 'django_jinja.backend.Jinja2', 46 | # ... 47 | 'OPTIONS': { 48 | # ... 49 | 'autoescape': True, 50 | 'extensions': [ 51 | # ... 52 | 'puente.ext.i18n', 53 | # ... 54 | ], 55 | }, 56 | } 57 | ] 58 | 59 | 60 | Puente configuration goes in the ``PUENTE`` setting in your Django settings 61 | file. Here's a minimal example: 62 | 63 | .. code-block:: python 64 | 65 | PUENTE = { 66 | 'BASE_DIR': BASE_DIR, 67 | 'DOMAIN_METHODS': { 68 | 'django': [ 69 | ('**.py', 'python'), 70 | ('fjord/**/jinja2/**.html', 'jinja2'), 71 | ('fjord/**/templates/**.html', 'django'), 72 | ], 73 | 'djangojs': [ 74 | ('**.js', 'javascript'), 75 | ] 76 | } 77 | } 78 | 79 | 80 | This sets up string extraction for Jinja2 templates using the Jinja2 extractor, 81 | Python files using the Python extractor, and Django templates using the Django 82 | extractor [#]_ and puts all those strings in ``django.pot`` files. 83 | 84 | .. [#] You need to install django-babel for the Django extractor for it to be 85 | available. 86 | 87 | Note that ``BASE_DIR`` is the path to the project root. It's in the 88 | ``settings.py`` file that is generated when you create a new Django project. 89 | 90 | 91 | .. py:data:: BASE_DIR 92 | 93 | :type: String 94 | :default: None 95 | :required: Yes 96 | 97 | 98 | This is the absolute path to the root directory which has ``locale/`` in it. 99 | In most cases, it's probably fine to set it to ``BASE_DIR`` which is in the 100 | ``settings.py`` file that Django generates when you create a new project. 101 | 102 | For example:: 103 | 104 | /home/willkg/ 105 | - fjord/ <-- BASE_DIR 106 | - .git/ 107 | - locale/ 108 | - fjord/ 109 | - code!!! 110 | - manage.py 111 | 112 | 113 | .. py:data:: DOMAIN_METHODS 114 | 115 | :type: Dict of string to list of (string, string) tuples 116 | :default: None 117 | :required: Yes 118 | 119 | 120 | Dict of domain name to list of (file matcher, extractor) tuples. 121 | 122 | A domain name here is the name that's used to name the ``.pot`` and ``.po`` 123 | files. For example, if the domain was "django", then the resulting files 124 | would be ``django.pot`` and ``django.po``. 125 | 126 | The file matcher uses ``*`` and ``**`` glob patterns. 127 | 128 | The only valid domains are ``django`` and ``djangojs``. 129 | 130 | Valid extractors include: 131 | 132 | * ``python`` for Python files (Babel) 133 | * ``javascript`` for Javascript files (Babel) 134 | * ``ignore`` for files to ignore to alleviate difficulties in file matching 135 | (Babel) 136 | * ``jinja2`` for Jinja2 templates (Jinja2) 137 | * ``django`` for django templates (django-babel) [#]_ 138 | 139 | .. [#] You need to install django-babel for the Django extractor for it to be 140 | available. 141 | 142 | You can use extractors provided by other libraries, too. You can also write 143 | your own extractors and use a dotted path to the extraction function. 144 | 145 | Example of ``DOMAIN_METHODS``: 146 | 147 | .. code-block:: python 148 | 149 | PUENTE = { 150 | 'DOMAIN_METHODS': { 151 | 'django': [ 152 | ('fjord/**/jinja2/**.html', 'jinja2'), 153 | ('**.py', 'python') 154 | ('fjord/**/templates/**.html', 'django'), 155 | ], 156 | 'djangojs': [ 157 | ('**.js', 'javascript'), 158 | ] 159 | } 160 | } 161 | 162 | .. Note:: 163 | 164 | The syntax is an inclusion-style syntax where you specify some group of 165 | files to use some extractor. 166 | 167 | In some cases, this is very inconvenient because you might need to say 168 | something like "use this extractor with all the files with this glob 169 | pattern except this one....". 170 | 171 | To exclude files, you create a rule higher up in the list and use the 172 | ``ignore`` extractor. 173 | 174 | For example, to use jinja2 for all files in a directory except 175 | ones named ``whaleshark.html``, you'd do something like this: 176 | 177 | .. code-block:: python 178 | 179 | PUENTE = { 180 | 'DOMAIN_METHODS': { 181 | 'django': [ 182 | ('fjord/**/jinja2/whaleshark.html', 'ignore'), 183 | ('fjord/**/jinja2/**.html', 'jinja2') 184 | ] 185 | } 186 | } 187 | 188 | The example is pretty contrived, but hopefully that helps. 189 | 190 | .. py:data:: KEYWORDS 191 | 192 | :type: Dict of keyword to Babel magic 193 | :default: Common gettext indicators 194 | :required: No 195 | 196 | Babel has keywords: 197 | 198 | https://github.com/python-babel/babel/blob/5116c167/babel/messages/extract.py#L31 199 | 200 | Puente adds ``'_lazy': None`` to that. 201 | 202 | Babel uses the keywords to know what strings to extract and how to extract 203 | them. 204 | 205 | There's a ``puente.utils.generate_keywords`` function to make it easier to 206 | get all the defaults plus the ones you want: 207 | 208 | .. code-block:: python 209 | 210 | from puente.utils import generate_keywords 211 | 212 | PUENTE = { 213 | 'KEYWORDS': generate_keywords({'foo': None}) 214 | } 215 | 216 | 217 | .. py:data:: COMMENT_TAGS 218 | 219 | :type: List of strings 220 | :default: ``['Translators:', 'L10n:', 'L10N:', 'l10n:', 'l10N:']`` 221 | :required: No 222 | 223 | The list of prefixes that denote a comment tag intended for the translator. 224 | 225 | For example, if you had code like this: 226 | 227 | .. code-block:: python 228 | 229 | # l10n: This is a menu name. 230 | menu_name = _('File') 231 | 232 | 233 | Then the comment will get extracted as a translator comment. 234 | 235 | .. Note:: 236 | 237 | Django project uses "Translators:", so if you use that, you're closer 238 | to vanilla Django. 239 | 240 | 241 | .. py:data:: JINJA2_CONFIG 242 | 243 | :type: Dict 244 | :default: Complicated... 245 | :required: Possibly 246 | 247 | This has the options to pass to ``babel_extract``. 248 | 249 | http://jinja.pocoo.org/docs/dev/integration/#babel-integration 250 | 251 | **Setting it yourself** 252 | 253 | Generally, you can add syntax-related options that'd you'd pass in to 254 | build a new Jinja2 Environment: 255 | 256 | http://jinja.pocoo.org/docs/dev/api/#jinja2.Environment 257 | 258 | Additionally, in Jinja2 2.7, they added a ``silent`` option which dictates 259 | whether the parser fails silently when parsing Jinja2 templates. This 260 | commonly happens in two scenarios: 261 | 262 | 1. The list of extensions passed isn't the complete list. 263 | 2. The HTML file isn't a Jinja2 template. 264 | 265 | For debugging purposes, you definitely want ``silent=False``. 266 | 267 | Example of ``JINJA2_CONFIG``: 268 | 269 | .. code-block:: python 270 | 271 | PUENTE = { 272 | 'JINJA2_CONFIG`: { 273 | 'extensions': [ 274 | 'jinja2.ext.do', 275 | 'jinja2.ext.loopcontrols', 276 | 'django_jinja.builtins.extensions.CsrfExtension', 277 | 'django_jinja.builtins.extensions.StaticFilesExtension', 278 | 'django_jinja.builtins.extensions.DjangoFiltersExtension', 279 | 'puente.ext.i18n', 280 | ] 281 | } 282 | } 283 | 284 | **Having Puente figure it out for you** 285 | 286 | If you're using Jingo or django-jinja, then Puente will try to extract the 287 | list of extensions from the relevant settings. If that works for you, then 288 | you don't need to set this. 289 | 290 | If Puente is figuring it out, it will automatically add silent=False. 291 | 292 | For example, if you're using django-jinja with these settings: 293 | 294 | .. code-block:: python 295 | 296 | TEMPLATES = [ 297 | { 298 | 'BACKEND': 'django_jinja.backend.Jinja2', 299 | # ... 300 | 'OPTIONS': { 301 | # ... 302 | 'extensions': [ 303 | # ... 304 | 'puente.ext.i18n', 305 | # ... 306 | ], 307 | } 308 | } 309 | ] 310 | 311 | Then Puente will build something like this: 312 | 313 | .. code-block:: python 314 | 315 | PUENTE = { 316 | # ... 317 | 'JINJA_CONFIG': { 318 | 'extensions': [ 319 | # ... 320 | 'puente.ext.i18n', 321 | # ... 322 | ], 323 | 'silent': 'False' 324 | } 325 | } 326 | 327 | 328 | .. py:data:: PROJECT 329 | 330 | :type: String 331 | :default: "PROJECT" 332 | :required: No 333 | 334 | The name of this project. This goes in the ``.pot`` and ``.po`` files and 335 | could help translators know which project this file that they're translating 336 | belongs to. 337 | 338 | .. py:data:: VERSION 339 | 340 | :type: String 341 | :default: "1.0" 342 | :required: No 343 | 344 | The version of this project. This goes in the ``.pot`` and ``.po`` files and 345 | could help translators know which version of the project this file that 346 | they're translating belongs to. 347 | 348 | .. py:data:: MSGID_BUGS_ADDRESS 349 | 350 | :type: String 351 | :default: "" 352 | :required: No 353 | 354 | The email address or url to send bugs related to msgids to. Without this, it's 355 | hard for a translator to know how to report issues back. If they have this, 356 | then reporting issues is much easier. 357 | 358 | You want good strings, so this is a good thing to set. 359 | 360 | For example: 361 | 362 | .. code-block:: python 363 | 364 | PUENTE = { 365 | # ... 366 | 'MSGID_BUGS_ADDRESS': 'https://bugzilla.mozilla.org/enter_bug.cgi?project=Input' 367 | } 368 | 369 | 370 | Templates 371 | ========= 372 | 373 | We hope you're using Jinja2's newstyle gettext and ``autoescape = True``. If 374 | that's the case, then these docs will help: 375 | 376 | * `Jinja2 template i18n docs `_ 377 | * `Jinja2 template newstyle docs `_ 378 | 379 | Further, Puente adds support for ``pgettext`` and ``npgettext`` in templates:: 380 | 381 | {{ pgettext("some context", "message string") }} 382 | {{ npgettext("some context", "singular message", "plural message", 5) }} 383 | 384 | 385 | FIXME: Expand on this and talk about escaping and ``|safe``. 386 | 387 | 388 | Extract and merge usage 389 | ======================= 390 | 391 | Message extraction 392 | ------------------ 393 | 394 | After you've configured Puente, you can extract messages like this: 395 | 396 | .. code-block:: bash 397 | 398 | $ ./manage.py extract 399 | 400 | 401 | This will extract all the strings specified by the ``DOMAIN_METHODS`` 402 | setting and put them into a ``.pot`` file. 403 | 404 | 405 | Message merge 406 | ------------- 407 | 408 | After you've extracted messages, you'll want to merge new messages into 409 | new or existing locale-specific ``.po`` files. You can merge messages 410 | like this: 411 | 412 | .. code-block:: bash 413 | 414 | $ ./manage.py merge 415 | -------------------------------------------------------------------------------- /docs/migratingfromtower.rst: -------------------------------------------------------------------------------- 1 | ==================== 2 | Migrating from Tower 3 | ==================== 4 | 5 | Tower and Puente have some differences. If you're using Tower, then you'll need 6 | to do something like the following to switch to Puente. 7 | 8 | 1. Upgrade to Python 2.7. 9 | 10 | Puente doesn't support Python 2.6, so you're going to have to upgrade. 11 | 12 | 2. Upgrade to Django 1.7. 13 | 14 | 3. If you're using Jingo, switch to 0.7.1. 15 | 16 | 4. Switch from ``messages.po(t)`` to ``django.po(t)``. 17 | 18 | Tower let you have many domains and defaulted to ``messages.po(t)``. 19 | 20 | Puente is moving closer to vanilla Django. Django uses ``django.po(t)`` and 21 | ``djangojs.po(t)``, so Puente does, too. 22 | 23 | * If you just have ``messages.po(t)`` and ``javascript.po(t)``, then rename 24 | your ``message.po(t)`` to ``django.po(t)`` and ``javascript.po(t)`` to 25 | ``djangojs.po(t)`` and sync with your translation system (e.g. Verbatim, 26 | Pontoon, etc). 27 | 28 | * If you have a bunch of domains and you can squash them all into 29 | ``django.po(t)`` and ``djangojs.po(t)``, then do that. 30 | 31 | * If you have a bunch of domains and can't squash them into ``django.po(t)`` 32 | and ``djangojs.po(t)``, then we should talk--open up an issue. 33 | 34 | 5. Sync your strings 35 | 36 | Using Tower, extract, merge and sync strings with Verbatim. That way you 37 | know exactly what changed for the next few steps. 38 | 39 | 6. Stop using Tower's gettext. 40 | 41 | Switch instances of this: 42 | 43 | .. code-block:: python 44 | 45 | from tower import ugettext as _ 46 | 47 | 48 | to this: 49 | 50 | .. code-block:: python 51 | 52 | from django.utils.translation import ugettext as _ 53 | 54 | 55 | You'll encounter two possible issues here: 56 | 57 | 1. If you have consecutive sequences of whitespace in your gettext 58 | strings, then the msgids will change. 59 | 60 | For example: 61 | 62 | .. code-block:: python 63 | 64 | from tower import ugettext as _ 65 | _('knock knock. who is there?') 66 | 67 | 68 | Tower collapses whitespace in all gettext strings, so that turns 69 | into ``"knock knock. who is there?"``. 70 | 71 | When you switch that to Django's ugettext, then you're using Tower 72 | for extraction, but Django's ugettext to look up the translation. 73 | Because Django's ugettext doesn't collapse whitespace, the msgid 74 | being used to look up the translation will have all the whitespace 75 | in it which won't match the msgid in the ``.po`` file and thus 76 | even though the strings are translated, they won't show up as translated 77 | on the site. 78 | 79 | You'll need to fix that in the string so the resulting code should 80 | look like this: 81 | 82 | .. code-block:: python 83 | 84 | from django.utils.translation import ugettext as _ 85 | _('knock knock. who is there?') 86 | 87 | 88 | That way the msgid generated during extraction is the same as 89 | the one that's generated when rendering and that's the same as the 90 | string the translator translated, so everything should work super. 91 | 92 | Definitely check the msgids after doing this to make sure they're 93 | the same and fix any issues you see. 94 | 95 | 2. Tower's gettext and ngettext supported msgctxt as a separate 96 | argument. 97 | 98 | For example: 99 | 100 | .. code-block:: python 101 | 102 | from tower import ugettext as _ 103 | _('Orange', 'joke response') 104 | _('Orange', context='joke response') 105 | 106 | 107 | You'll need to switch these to the Django pgettext calls: 108 | 109 | .. code-block:: python 110 | 111 | from django import pgettext 112 | pgettext('joke response', 'Orange') 113 | 114 | 115 | [#]_ 116 | 117 | Note that the arguments are reversed! 118 | 119 | https://docs.djangoproject.com/en/1.8/ref/utils/#django.utils.translation.pgettext 120 | 121 | If your test suite covers all code paths that have gettext calls, 122 | then you can run your test suite and it should error out because 123 | Tower's gettext and ngettext had an extra argument that Django's 124 | do not. 125 | 126 | .. [#] Orange who? Orange you glad this example was here to lighten 127 | the mood? 128 | 129 | 130 | At the end of this step, you do not want to be using Tower's gettext at 131 | all and none of your msgids should have changed. 132 | 133 | You can tell whether msgids have changed by running: 134 | 135 | .. code-block:: bash 136 | 137 | ./manage.py extract 138 | 139 | 140 | And diffing the results. 141 | 142 | 7. Make sure ``autoescape=True`` in your Jinja2 environment. 143 | 144 | In your Django settings, ``JINJA_CONFIG`` should have this: 145 | 146 | .. code-block:: python 147 | 148 | JINJA_CONFIG = { 149 | 'autoescape': True, 150 | # ... 151 | } 152 | 153 | 154 | .. Warning:: 155 | 156 | Switching autoescape may cause strings with HTML in them to change. If 157 | that happens, you can use the ``|safe`` filter to undo the escaping. 158 | 159 | 8. Switch from Tower to Puente. 160 | 161 | Puente works with Django 1.7 and Jingo 0.7.1. It also works with Django 1.8+ 162 | and django-jinja. It probably works with other Django Jinja2 template 163 | environments. If not, let us know. 164 | 165 | 1. Remove Tower 166 | 167 | 2. Add Puente 168 | 169 | 3. Make the configuration changes 170 | 171 | Tower configuration probably looks something like this: 172 | 173 | .. code-block:: python 174 | 175 | # in settings.py 176 | DOMAIN_METHODS = { 177 | 'django': [ 178 | ('fjord/**.py', 'tower.tools.extract_tower_python'), 179 | ('fjord/**.html', 'tower.tools.extract_tower_template'), 180 | ], 181 | 'djangojs': [ 182 | ('**.js', 'javascript') 183 | ] 184 | } 185 | 186 | 187 | The equivalent Puente configuration is something like this: 188 | 189 | .. code-block:: python 190 | 191 | # in settings.py 192 | PUENTE = { 193 | 'BASE_DIR': BASE_DIR, 194 | 'DOMAIN_METHODS': { 195 | 'django': [ 196 | ('fjord/**.py', 'python'), 197 | ('fjord/**.html', 'jinja2'), 198 | ], 199 | 'djangojs': [ 200 | ('**.js', 'javascript') 201 | ] 202 | } 203 | } 204 | 205 | 206 | If you have a more complex Tower configuration than that, hop on 207 | ``#puente`` on ``irc.mozilla.org`` and we'll work it out. 208 | 209 | 4. Add the code to install ugettext/ungettext into the Jinja environment. 210 | 211 | Jingo installs gettext/ngettext functions that don't do anything. You 212 | will need to install Django's gettext/ngettext functions into the 213 | environment. 214 | 215 | Calling this during webapp bootstrap will fix that: 216 | 217 | .. code-block:: python 218 | 219 | def install_jinja_translations(): 220 | """Install gettext functions into Jingo's Jinja2 environment""" 221 | from django.utils import translation 222 | 223 | import jingo 224 | jingo.env.install_gettext_translations(translation, newstyle=True) 225 | 226 | 227 | .. Warning:: 228 | 229 | Note that Tower does **NOT** use Jinja2's newstyle gettext. In this 230 | step, you need to switch to the newstyle gettext since the combination 231 | of newstyle gettext and autoescape=True will give you the correct 232 | output for gettext functions. 233 | 234 | 5. When you push the update, make sure to nix your Jinja2 template cache. 235 | -------------------------------------------------------------------------------- /docs/puente_logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/puente/d2e24cb1c374648313644b7a6d3263ca53b02e5a/docs/puente_logo.jpg -------------------------------------------------------------------------------- /docs/release.rst: -------------------------------------------------------------------------------- 1 | =============== 2 | Release process 3 | =============== 4 | 5 | 1. Checkout master tip. 6 | 7 | 2. Update version numbers in ``puente/__init__.py``. 8 | 9 | 1. Set ``__version__`` to something like ``0.4``. 10 | 2. Set ``__releasedate__`` to something like ``20120731``. 11 | 12 | 3. Update ``AUTHORS.rst``, ``HISTORY.rst``, ``MANIFEST.in``. 13 | 14 | Make sure to set the date for the release in ``HISTORY.rst``. 15 | 16 | Update requirements in ``setup.py``. 17 | 18 | 4. Verify correctness. 19 | 20 | 1. Run tests. 21 | 2. Build docs. 22 | 3. Verify everything works. 23 | 24 | 5. Tag the release:: 25 | 26 | $ git tag -a v0.1 27 | 28 | Copy the details from ``HISTORY.rst`` into the tag comment. 29 | 30 | 6. Push everything:: 31 | 32 | $ git push --tags official master 33 | 34 | 7. Update PyPI:: 35 | 36 | $ make clean 37 | $ python setup.py sdist bdist_wheel 38 | $ twine upload dist/* 39 | 40 | 8. Update topic in ``#puente``, blog post, twitter, etc. 41 | -------------------------------------------------------------------------------- /puente/__init__.py: -------------------------------------------------------------------------------- 1 | __author__ = "Will Kahn-Greene" 2 | __email__ = "willkg@mozilla.com" 3 | 4 | # yyyymmdd 5 | __releasedate__ = "20220511" 6 | # x.y or x.y.dev 7 | __version__ = "1.0.0" 8 | -------------------------------------------------------------------------------- /puente/commands.py: -------------------------------------------------------------------------------- 1 | import os 2 | from subprocess import PIPE, Popen, call 3 | from tempfile import TemporaryFile 4 | 5 | from babel.messages.catalog import Catalog 6 | from babel.messages.extract import extract_from_dir 7 | from babel.messages.pofile import write_po 8 | from django.conf import settings 9 | from django.core.management.base import CommandError 10 | 11 | from puente.utils import monkeypatch_i18n 12 | 13 | 14 | def generate_options_map(): 15 | """Generate an ``options_map` to pass to ``extract_from_dir`` 16 | 17 | This is the options_map that's used to generate a Jinja2 environment. We 18 | want to generate and environment for extraction that's the same as the 19 | environment we use for rendering. 20 | 21 | This allows developers to explicitly set a ``JINJA2_CONFIG`` in settings. 22 | If that's not there, then this will pull the relevant bits from the first 23 | Jinja2 backend listed in ``TEMPLATES``. 24 | 25 | """ 26 | try: 27 | return settings.PUENTE["JINJA2_CONFIG"] 28 | except KeyError: 29 | pass 30 | 31 | # If using Django 1.8+, we can skim the TEMPLATES for a backend that we 32 | # know about and extract the settings from that. 33 | for tmpl_config in getattr(settings, "TEMPLATES", []): 34 | try: 35 | backend = tmpl_config["BACKEND"] 36 | except KeyError: 37 | continue 38 | 39 | if backend == "django_jinja.backend.Jinja2": 40 | extensions = tmpl_config.get("OPTIONS", {}).get("extensions", []) 41 | return { 42 | "**.*": { 43 | "extensions": ",".join(extensions), 44 | "silent": "False", 45 | } 46 | } 47 | 48 | # If this is Django 1.7 and Jingo, try to grab extensions from 49 | # JINJA_CONFIG. 50 | if getattr(settings, "JINJA_CONFIG"): 51 | jinja_config = settings.JINJA_CONFIG 52 | if callable(jinja_config): 53 | jinja_config = jinja_config() 54 | return { 55 | "**.*": { 56 | "extensions": ",".join(jinja_config["extensions"]), 57 | "silent": "False", 58 | } 59 | } 60 | 61 | raise CommandError( 62 | "No valid jinja2 config found in settings. See configuration " "documentation." 63 | ) 64 | 65 | 66 | def extract_command( 67 | outputdir, 68 | domain_methods, 69 | text_domain, 70 | keywords, 71 | comment_tags, 72 | base_dir, 73 | project, 74 | version, 75 | msgid_bugs_address, 76 | ): 77 | """Extracts strings into .pot files 78 | 79 | :arg domain: domains to generate strings for or 'all' for all domains 80 | :arg outputdir: output dir for .pot files; usually 81 | locale/templates/LC_MESSAGES/ 82 | :arg domain_methods: DOMAIN_METHODS setting 83 | :arg text_domain: TEXT_DOMAIN settings 84 | :arg keywords: KEYWORDS setting 85 | :arg comment_tags: COMMENT_TAGS setting 86 | :arg base_dir: BASE_DIR setting 87 | :arg project: PROJECT setting 88 | :arg version: VERSION setting 89 | :arg msgid_bugs_address: MSGID_BUGS_ADDRESS setting 90 | 91 | """ 92 | # Must monkeypatch first to fix i18n extensions stomping issues! 93 | monkeypatch_i18n() 94 | 95 | # Create the outputdir if it doesn't exist 96 | outputdir = os.path.abspath(outputdir) 97 | if not os.path.isdir(outputdir): 98 | print("Creating output dir %s ..." % outputdir) 99 | os.makedirs(outputdir) 100 | 101 | domains = domain_methods.keys() 102 | 103 | def callback(filename, method, options): 104 | if method != "ignore": 105 | print(" %s" % filename) 106 | 107 | # Extract string for each domain 108 | for domain in domains: 109 | print("Extracting all strings in domain %s..." % domain) 110 | 111 | methods = domain_methods[domain] 112 | 113 | catalog = Catalog( 114 | header_comment="", 115 | project=project, 116 | version=version, 117 | msgid_bugs_address=msgid_bugs_address, 118 | charset="utf-8", 119 | ) 120 | extracted = extract_from_dir( 121 | base_dir, 122 | method_map=methods, 123 | options_map=generate_options_map(), 124 | keywords=keywords, 125 | comment_tags=comment_tags, 126 | callback=callback, 127 | ) 128 | 129 | for filename, lineno, msg, cmts, ctxt in extracted: 130 | catalog.add( 131 | msg, None, [(filename, lineno)], auto_comments=cmts, context=ctxt 132 | ) 133 | 134 | with open(os.path.join(outputdir, "%s.pot" % domain), "wb") as fp: 135 | write_po(fp, catalog, width=80) 136 | 137 | print("Done") 138 | 139 | 140 | def merge_command(create, backup, base_dir, domain_methods, languages): 141 | """ 142 | :arg create: whether or not to create directories if they don't 143 | exist 144 | :arg backup: whether or not to create backup .po files 145 | :arg base_dir: BASE_DIR setting 146 | :arg domain_methods: DOMAIN_METHODS setting 147 | :arg languages: LANGUAGES setting 148 | 149 | """ 150 | locale_dir = os.path.join(base_dir, "locale") 151 | 152 | # Verify existence of msginit and msgmerge 153 | if not call(["which", "msginit"], stdout=PIPE) == 0: 154 | raise CommandError("You do not have gettext installed.") 155 | 156 | if not call(["which", "msgmerge"], stdout=PIPE) == 0: 157 | raise CommandError("You do not have gettext installed.") 158 | 159 | if languages and isinstance(languages[0], (tuple, list)): 160 | # Django's LANGUAGES setting takes a value like: 161 | # 162 | # LANGUAGES = ( 163 | # ('de', _('German')), 164 | # ('en', _('English')), 165 | # ) 166 | # 167 | # but we only want the language codes, so we pull the first 168 | # part from all the tuples. 169 | languages = [lang[0] for lang in languages] 170 | 171 | if create: 172 | for lang in languages: 173 | d = os.path.join(locale_dir, lang.replace("-", "_"), "LC_MESSAGES") 174 | if not os.path.exists(d): 175 | os.makedirs(d) 176 | 177 | domains = domain_methods.keys() 178 | for domain in domains: 179 | print("Merging %s strings to each locale..." % domain) 180 | domain_pot = os.path.join( 181 | locale_dir, "templates", "LC_MESSAGES", "%s.pot" % domain 182 | ) 183 | if not os.path.isfile(domain_pot): 184 | raise CommandError("Can not find %s.pot" % domain) 185 | 186 | for locale in os.listdir(locale_dir): 187 | if ( 188 | not os.path.isdir(os.path.join(locale_dir, locale)) 189 | or locale.startswith(".") 190 | or locale == "templates" 191 | ): 192 | continue 193 | 194 | domain_po = os.path.join( 195 | locale_dir, locale, "LC_MESSAGES", "%s.po" % domain 196 | ) 197 | 198 | if not os.path.isfile(domain_po): 199 | print(" Can not find (%s). Creating..." % domain_po) 200 | p1 = Popen( 201 | [ 202 | "msginit", 203 | "--no-translator", 204 | "--locale=%s" % locale, 205 | "--input=%s" % domain_pot, 206 | "--output-file=%s" % domain_po, 207 | "--width=200", 208 | ] 209 | ) 210 | p1.communicate() 211 | 212 | print("Merging %s.po for %s" % (domain, locale)) 213 | with open(domain_pot) as domain_pot_file: 214 | if locale == "en_US": 215 | # Create an English translation catalog, then merge 216 | with TemporaryFile("w+t") as enmerged: 217 | p2 = Popen( 218 | ["msgen", "-"], stdin=domain_pot_file, stdout=enmerged 219 | ) 220 | p2.communicate() 221 | _msgmerge(domain_po, enmerged, backup) 222 | else: 223 | _msgmerge(domain_po, domain_pot_file, backup) 224 | 225 | print("Domain %s finished" % domain) 226 | 227 | print("All finished") 228 | 229 | 230 | def _msgmerge(po_path, pot_file, backup): 231 | """Merge an existing .po file with new translations. 232 | 233 | :arg po_path: path to the .po file 234 | :arg pot_file: a file-like object for the related templates 235 | :arg backup: whether or not to create backup .po files 236 | """ 237 | pot_file.seek(0) 238 | command = [ 239 | "msgmerge", 240 | "--update", 241 | "--width=200", 242 | "--backup=%s" % ("simple" if backup else "off"), 243 | po_path, 244 | "-", 245 | ] 246 | p3 = Popen(command, stdin=pot_file) 247 | p3.communicate() 248 | -------------------------------------------------------------------------------- /puente/ext.py: -------------------------------------------------------------------------------- 1 | from django.utils.translation import ( 2 | pgettext as pgettext_real, 3 | npgettext as npgettext_real, 4 | ) 5 | 6 | from jinja2.ext import InternationalizationExtension 7 | from jinja2.utils import pass_context 8 | from markupsafe import Markup 9 | 10 | from puente.utils import collapse_whitespace 11 | 12 | 13 | @pass_context 14 | def pgettext(__context, context, message, **variables): 15 | rv = pgettext_real(context, message) 16 | if __context.eval_ctx.autoescape: 17 | rv = Markup(rv) 18 | return rv % variables 19 | 20 | 21 | @pass_context 22 | def npgettext(__context, context, singular, plural, number, **variables): 23 | variables.setdefault("num", number) 24 | rv = npgettext_real(context, singular, plural, number) 25 | if __context.eval_ctx.autoescape: 26 | rv = Markup(rv) 27 | return rv % variables 28 | 29 | 30 | class PuenteI18nExtension(InternationalizationExtension): 31 | """Provides whitespace collapsing trans behavior 32 | 33 | Extends Jinja2's ``InternationalizationExtension`` to override 34 | ``_parse_block`` to collapse consecutive whitespace characters in 35 | trans atags so msgids don't change when we cahnge indentation in 36 | Jinja2 templates. 37 | 38 | """ 39 | 40 | def __init__(self, environment): 41 | super(PuenteI18nExtension, self).__init__(environment) 42 | environment.globals["pgettext"] = pgettext 43 | environment.globals["npgettext"] = npgettext 44 | 45 | def _parse_block(self, parser, allow_pluralize): 46 | parse_block = InternationalizationExtension._parse_block 47 | ref, buffer = parse_block(self, parser, allow_pluralize) 48 | return ref, collapse_whitespace(buffer) 49 | 50 | 51 | i18n = PuenteI18nExtension 52 | -------------------------------------------------------------------------------- /puente/extract.py: -------------------------------------------------------------------------------- 1 | from jinja2.ext import babel_extract 2 | 3 | from puente.utils import monkeypatch_i18n 4 | 5 | 6 | def extract_from_jinja2(*args, **kwargs): 7 | """Just like Jinja2's Babel extractor, but fixes the i18n issue 8 | 9 | The Jinja2 Babel extractor appends the i18n extension to the list of 10 | extensions before extracting. Since Puente has its own i18n extension, this 11 | creates problems. So we monkeypatch and then call Jinja2's Babel extractor. 12 | 13 | .. Note:: 14 | 15 | You only need to use this if you're using Babel's pybabel extract. If 16 | you're using Puente's extract command, then it does this already and you 17 | can use the Jinja2 Babel extractor. 18 | 19 | """ 20 | # Must monkeypatch first to fix InternationalizationExtension 21 | # stomping issues! See docstring for details. 22 | monkeypatch_i18n() 23 | 24 | return babel_extract(*args, **kwargs) 25 | -------------------------------------------------------------------------------- /puente/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/puente/d2e24cb1c374648313644b7a6d3263ca53b02e5a/puente/management/__init__.py -------------------------------------------------------------------------------- /puente/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/puente/d2e24cb1c374648313644b7a6d3263ca53b02e5a/puente/management/commands/__init__.py -------------------------------------------------------------------------------- /puente/management/commands/extract.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.core.management.base import BaseCommand 4 | 5 | from puente.commands import extract_command 6 | from puente.settings import get_setting 7 | 8 | 9 | class Command(BaseCommand): 10 | help = "Extracts strings for translation." 11 | 12 | def add_arguments(self, parser): 13 | parser.add_argument( 14 | "--output-dir", 15 | "-o", 16 | default=os.path.join( 17 | get_setting("BASE_DIR"), "locale", "templates", "LC_MESSAGES" 18 | ), 19 | dest="outputdir", 20 | help=( 21 | "The directory where extracted files will be placed. " 22 | "(Default: %%default)" 23 | ), 24 | ), 25 | 26 | requires_system_checks = False 27 | 28 | def handle(self, *args, **options): 29 | return extract_command( 30 | # Command line arguments 31 | outputdir=options.get("outputdir"), 32 | # From settings.py 33 | domain_methods=get_setting("DOMAIN_METHODS"), 34 | text_domain=get_setting("TEXT_DOMAIN"), 35 | keywords=get_setting("KEYWORDS"), 36 | comment_tags=get_setting("COMMENT_TAGS"), 37 | base_dir=get_setting("BASE_DIR"), 38 | project=get_setting("PROJECT"), 39 | version=get_setting("VERSION"), 40 | msgid_bugs_address=get_setting("MSGID_BUGS_ADDRESS"), 41 | ) 42 | -------------------------------------------------------------------------------- /puente/management/commands/merge.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.core.management.base import BaseCommand 3 | 4 | from puente.commands import merge_command 5 | from puente.settings import get_setting 6 | 7 | 8 | class Command(BaseCommand): 9 | """Updates all locales' PO files by merging them with the POT files. 10 | 11 | The command looks for POT files in locale/templates/LC_MESSAGES, 12 | which is where software like Verbatim looks for them as well. 13 | 14 | For a given POT file, if a corresponding PO file doesn't exist for 15 | a locale, the command will initialize it with `msginit`. This 16 | guarantees that the newly created PO file has proper gettext 17 | metadata headers. 18 | 19 | """ 20 | 21 | def add_arguments(self, parser): 22 | parser.add_argument( 23 | "-c", 24 | "--create", 25 | action="store_true", 26 | dest="create", 27 | default=False, 28 | help="Create locale subdirectories", 29 | ), 30 | parser.add_argument( 31 | "-b", 32 | "--backup", 33 | action="store_true", 34 | dest="backup", 35 | default=False, 36 | help="Create backup files of .po files", 37 | ), 38 | 39 | def handle(self, *args, **options): 40 | return merge_command( 41 | create=options.get("create"), 42 | backup=options.get("backup"), 43 | base_dir=get_setting("BASE_DIR"), 44 | domain_methods=get_setting("DOMAIN_METHODS"), 45 | languages=getattr(settings, "LANGUAGES", []), 46 | ) 47 | 48 | 49 | Command.help = Command.__doc__ 50 | -------------------------------------------------------------------------------- /puente/settings.py: -------------------------------------------------------------------------------- 1 | from puente.utils import generate_keywords 2 | 3 | 4 | # Name for .pot file--should be 'django' 5 | TEXT_DOMAIN = "django" 6 | 7 | # Keywords indicating gettext calls 8 | KEYWORDS = generate_keywords() 9 | 10 | # Prefixes that indicate a comment tag intended for localizers 11 | COMMENT_TAGS = ["L10n:", "L10N:", "l10n:", "l10N:", "Translators:"] 12 | 13 | # Tells the extract script what files to look for L10n in and what 14 | # function handles the extraction. 15 | # Map of domain to list of (match, extractor) 16 | DOMAIN_METHODS = None 17 | 18 | # The basedir of this project to extract strings from 19 | BASE_DIR = None 20 | 21 | # If you set this, we'll use it. Otherwise we assume you're using django-jinja 22 | # and we'll pick up the settings from the first template handler specified. 23 | JINJA2_CONFIG = None 24 | 25 | # The name of the project. 26 | PROJECT = "PROJECT" 27 | 28 | # The version of the project. 29 | VERSION = "1.0" 30 | 31 | # Email address or url for reporting msgid-related bugs to. 32 | MSGID_BUGS_ADDRESS = "" 33 | 34 | 35 | def get_setting(key): 36 | from django.conf import settings 37 | 38 | return settings.PUENTE.get(key, globals()[key]) 39 | -------------------------------------------------------------------------------- /puente/utils.py: -------------------------------------------------------------------------------- 1 | from babel.messages.extract import DEFAULT_KEYWORDS as BABEL_KEYWORDS 2 | 3 | 4 | def monkeypatch_i18n(): 5 | """Alleviates problems with extraction for trans blocks 6 | 7 | Jinja2 has a ``babel_extract`` function which sets up a Jinja2 8 | environment to parse Jinja2 templates to extract strings for 9 | translation. That's awesome! Yay! However, when it goes to 10 | set up the environment, it checks to see if the environment 11 | has InternationalizationExtension in it and if not, adds it. 12 | 13 | https://github.com/mitsuhiko/jinja2/blob/2.8/jinja2/ext.py#L587 14 | 15 | That stomps on our PuenteI18nExtension so trans blocks don't get 16 | whitespace collapsed and we end up with msgids that are different 17 | between extraction and rendering. Argh! 18 | 19 | Two possible ways to deal with this: 20 | 21 | 1. Rename our block from "trans" to something else like 22 | "blocktrans" or "transam". 23 | 24 | This means everyone has to make sweeping changes to their 25 | templates plus we adjust gettext, too, so now we're talking 26 | about two different extensions. 27 | 28 | 2. Have people include both InternationalizationExtension 29 | before PuenteI18nExtension even though it gets stomped on. 30 | 31 | This will look wrong in settings and someone will want to 32 | "fix" it thus breaking extractino subtly, so I'm loathe to 33 | force everyone to do this. 34 | 35 | 3. Stomp on the InternationalizationExtension variable in 36 | ``jinja2.ext`` just before message extraction. 37 | 38 | This is easy and hopefully the underlying issue will go away 39 | soon. 40 | 41 | 42 | For now, we're going to do number 3. Why? Because I'm hoping 43 | Jinja2 will fix the trans tag so it collapses whitespace if 44 | you tell it to. Then we don't have to do what we're doing and 45 | all these problems go away. 46 | 47 | We can remove this monkeypatch when one of the following is true: 48 | 49 | 1. we remove our whitespace collapsing code because Jinja2 trans 50 | tag supports whitespace collapsing 51 | 2. Jinja2's ``babel_extract`` stops adding 52 | InternationalizationExtension to the environment if it's 53 | not there 54 | 55 | """ 56 | import jinja2.ext 57 | from puente.ext import PuenteI18nExtension 58 | 59 | jinja2.ext.InternationalizationExtension = PuenteI18nExtension 60 | jinja2.ext.i18n = PuenteI18nExtension 61 | 62 | 63 | def generate_keywords(additional_keywords=None): 64 | """Generates gettext keywords list 65 | 66 | :arg additional_keywords: dict of keyword -> value 67 | 68 | :returns: dict of keyword -> values for Babel extraction 69 | 70 | Here's what Babel has for DEFAULT_KEYWORDS:: 71 | 72 | DEFAULT_KEYWORDS = { 73 | '_': None, 74 | 'gettext': None, 75 | 'ngettext': (1, 2), 76 | 'ugettext': None, 77 | 'ungettext': (1, 2), 78 | 'dgettext': (2,), 79 | 'dngettext': (2, 3), 80 | 'N_': None, 81 | 'pgettext': ((1, 'c'), 2) 82 | } 83 | 84 | If you wanted to add a new one ``_frank`` that was like 85 | gettext, then you'd do this:: 86 | 87 | generate_keywords({'_frank': None}) 88 | 89 | If you wanted to add a new one ``upgettext`` that was like 90 | gettext, then you'd do this:: 91 | 92 | generate_keywords({'upgettext': ((1, 'c'), 2)}) 93 | 94 | """ 95 | # Shallow copy 96 | keywords = dict(BABEL_KEYWORDS) 97 | 98 | keywords.update( 99 | { 100 | "_lazy": None, 101 | "gettext_lazy": None, 102 | "ugettext_lazy": None, 103 | "gettext_noop": None, 104 | "ugettext_noop": None, 105 | "ngettext_lazy": (1, 2), 106 | "ungettext_lazy": (1, 2), 107 | "npgettext": ((1, "c"), 2, 3), 108 | "pgettext_lazy": ((1, "c"), 2), 109 | "npgettext_lazy": ((1, "c"), 2, 3), 110 | } 111 | ) 112 | 113 | # Add specified keywords 114 | if additional_keywords: 115 | for key, val in additional_keywords.items(): 116 | keywords[key] = val 117 | return keywords 118 | 119 | 120 | def collapse_whitespace(message): 121 | """Collapses consecutive whitespace into a single space""" 122 | return " ".join( 123 | map(lambda s: s.strip(), filter(None, message.strip().splitlines())) 124 | ) 125 | -------------------------------------------------------------------------------- /puente_logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/puente/d2e24cb1c374648313644b7a6d3263ca53b02e5a/puente_logo.jpg -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | django_find_project = false 3 | addopts = -rsxX --reuse-db --tb=native 4 | testpaths = tests 5 | python_files=test*.py 6 | 7 | python_paths = test_project_django_jinja/ 8 | DJANGO_SETTINGS_MODULE=test_project.settings 9 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | # These are test/development requirements. 2 | -e . 3 | 4 | # just to make development easier and test using test_project/ 5 | django-jinja 6 | 7 | # for tests 8 | pytest 9 | pytest-pythonpath 10 | pytest-django 11 | tox 12 | 13 | # to build docs 14 | Sphinx 15 | 16 | # to do releases 17 | twine 18 | check-manifest 19 | wheel 20 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal=1 3 | 4 | [flake8] 5 | ignore = 6 | # E203: Whitespace before ':'; doesn't work with black 7 | E203, 8 | # E501: line too long 9 | E501, 10 | # W503: line break before operator; this doesn't work with black 11 | W503 12 | exclude = 13 | .git/, 14 | __pycache__, 15 | docs/, 16 | build/, 17 | dist/ 18 | max-line-length = 88 19 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | import re 5 | from setuptools import setup, find_packages 6 | 7 | 8 | readme = open("README.rst").read() 9 | history = open("HISTORY.rst").read().replace(".. :changelog:", "") 10 | 11 | 12 | def get_version(): 13 | VERSIONFILE = os.path.join("puente", "__init__.py") 14 | VSRE = r"""^__version__ = [""]([^""]*)[""]""" 15 | version_file = open(VERSIONFILE, "rt").read() 16 | return re.search(VSRE, version_file, re.M).group(1) 17 | 18 | 19 | setup( 20 | name="puente", 21 | version=get_version(), 22 | description="Strings extraction and other tools -- UNMAINTAINED", 23 | long_description=readme + "\n\n" + history, 24 | author="Will Kahn-Greene", 25 | author_email="willkg@mozilla.com", 26 | url="https://github.com/mozilla/puente", 27 | packages=find_packages(), 28 | include_package_data=True, 29 | install_requires=[ 30 | "Babel>=2.1.1", 31 | "Jinja2>=2.7", 32 | "markupsafe", 33 | "Django>=3.2.0,<4.0.0", 34 | ], 35 | license="BSD", 36 | zip_safe=True, 37 | keywords="", 38 | classifiers=[ 39 | "Development Status :: 2 - Pre-Alpha", 40 | "Framework :: Django", 41 | "Intended Audience :: Developers", 42 | "License :: OSI Approved :: BSD License", 43 | "Natural Language :: English", 44 | "Programming Language :: Python :: 3.7", 45 | "Programming Language :: Python :: 3.8", 46 | "Programming Language :: Python :: 3.9", 47 | "Programming Language :: Python :: 3.10", 48 | ], 49 | test_suite="tests", 50 | entry_points=""" 51 | [babel.extractors] 52 | puente_jinja2 = puente.extract:extract_from_jinja2 53 | """, 54 | ) 55 | -------------------------------------------------------------------------------- /test_project_django_jinja/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_project.settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /test_project_django_jinja/test_project/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/puente/d2e24cb1c374648313644b7a6d3263ca53b02e5a/test_project_django_jinja/test_project/__init__.py -------------------------------------------------------------------------------- /test_project_django_jinja/test_project/settings.py: -------------------------------------------------------------------------------- 1 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 2 | import os 3 | 4 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 5 | 6 | 7 | SECRET_KEY = 'test secret key' 8 | DEBUG = True 9 | ALLOWED_HOSTS = [] 10 | 11 | # Application definition 12 | 13 | INSTALLED_APPS = ( 14 | 'django.contrib.admin', 15 | 'django.contrib.auth', 16 | 'django.contrib.contenttypes', 17 | 'django.contrib.sessions', 18 | 'django.contrib.messages', 19 | 'django.contrib.staticfiles', 20 | 'django_jinja', 21 | 'puente' 22 | ) 23 | 24 | ROOT_URLCONF = 'test_project.urls' 25 | 26 | _CONTEXT_PROCESSORS = [ 27 | 'django.template.context_processors.debug', 28 | 'django.template.context_processors.request', 29 | 'django.contrib.auth.context_processors.auth', 30 | 'django.contrib.messages.context_processors.messages', 31 | ] 32 | 33 | TEMPLATES = [ 34 | { 35 | 'BACKEND': 'django_jinja.backend.Jinja2', 36 | 'DIRS': [], 37 | 'APP_DIRS': True, 38 | 'OPTIONS': { 39 | # Use jinja2/ for jinja templates 40 | 'app_dirname': 'jinja2', 41 | # Don't figure out which template loader to use based on 42 | # file extension 43 | 'match_extension': '', 44 | 'autoescape': True, 45 | 'newstyle_gettext': True, 46 | 'context_processors': _CONTEXT_PROCESSORS, 47 | 'undefined': 'jinja2.Undefined', 48 | 'extensions': [ 49 | 'jinja2.ext.do', 50 | 'jinja2.ext.loopcontrols', 51 | 'django_jinja.builtins.extensions.CsrfExtension', 52 | 'django_jinja.builtins.extensions.StaticFilesExtension', 53 | 'django_jinja.builtins.extensions.DjangoFiltersExtension', 54 | 'puente.ext.i18n', 55 | ] 56 | } 57 | }, 58 | { 59 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 60 | 'DIRS': [], 61 | 'APP_DIRS': True, 62 | 'OPTIONS': { 63 | 'context_processors': _CONTEXT_PROCESSORS, 64 | }, 65 | }, 66 | ] 67 | 68 | WSGI_APPLICATION = 'test_project.wsgi.application' 69 | 70 | 71 | # Database 72 | # https://docs.djangoproject.com/en/1.8/ref/settings/#databases 73 | 74 | DATABASES = { 75 | 'default': { 76 | 'ENGINE': 'django.db.backends.sqlite3', 77 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 78 | } 79 | } 80 | 81 | 82 | # Internationalization 83 | # https://docs.djangoproject.com/en/1.8/topics/i18n/ 84 | 85 | LANGUAGE_CODE = 'en-us' 86 | TIME_ZONE = 'UTC' 87 | USE_I18N = True 88 | USE_L10N = True 89 | USE_TZ = True 90 | 91 | PUENTE = { 92 | 'BASE_DIR': BASE_DIR, 93 | 'DOMAIN_METHODS': { 94 | 'django': [ 95 | ('jinja2/*.html', 'jinja2'), 96 | ('*.py', 'python'), 97 | ] 98 | } 99 | } 100 | 101 | # Static files (CSS, JavaScript, Images) 102 | # https://docs.djangoproject.com/en/1.8/howto/static-files/ 103 | 104 | STATIC_URL = '/static/' 105 | -------------------------------------------------------------------------------- /test_project_django_jinja/test_project/urls.py: -------------------------------------------------------------------------------- 1 | """test_project URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/1.8/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: url(r'^$', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Add an import: from blog import urls as blog_urls 14 | 2. Add a URL to urlpatterns: url(r'^blog/', include(blog_urls)) 15 | """ 16 | from django.conf.urls import include, url 17 | from django.contrib import admin 18 | 19 | urlpatterns = [ 20 | url(r'^admin/', include(admin.site.urls)), 21 | ] 22 | -------------------------------------------------------------------------------- /test_project_django_jinja/test_project/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for test_project project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.8/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_project.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/puente/d2e24cb1c374648313644b7a6d3263ca53b02e5a/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_ext.py: -------------------------------------------------------------------------------- 1 | from django.utils import translation 2 | 3 | from jinja2 import DictLoader, Environment 4 | 5 | 6 | def build_environment(template): 7 | """Create environment with newstyle gettext""" 8 | env = Environment( 9 | autoescape=True, 10 | loader=DictLoader({"tmpl.html": template}), 11 | extensions=["puente.ext.i18n"], 12 | ) 13 | env.install_gettext_translations(translation, newstyle=True) 14 | return env 15 | 16 | 17 | def render(template, *args, **kwargs): 18 | """Renders template in environment and returns text""" 19 | env = build_environment(template) 20 | return env.from_string(template).render(*args, **kwargs) 21 | 22 | 23 | class TestPuenteI18nExtension: 24 | def test_gettext(self): 25 | """gettext works""" 26 | tmpl = '{{ _("blue puentes rule!") }}' 27 | assert render(tmpl) == "blue puentes rule!" 28 | 29 | def test_gettext_is_safe(self): 30 | """html in gettext is safe""" 31 | tmpl = '{{ _("blue puentes rule!") }}' 32 | assert render(tmpl) == "blue puentes rule!" 33 | 34 | def test_gettext_variable_values_notsafe(self): 35 | """interpolated html is not safe""" 36 | tmpl = '{{ _("%(foo)s", foo="bar") }}' 37 | assert render(tmpl) == "<i>bar</i>" 38 | 39 | def test_gettext_variable_values_autoescape_false(self): 40 | """interpolated html is not escaped if autoescape is False""" 41 | tmpl = '{% autoescape False %}{{ _("%(foo)s", foo="bar") }}{% endautoescape %}' 42 | assert render(tmpl) == "bar" 43 | 44 | def test_gettext_variable_values_marked_safe_are_safe(self): 45 | """interpolated html marked as safe are safe""" 46 | tmpl = '{{ _("%(foo)s", foo="bar"|safe) }}' 47 | assert render(tmpl) == "bar" 48 | 49 | def test_gettext_format_notsafe(self): 50 | """format html is not safe""" 51 | tmpl = '{{ _("{0}").format("bar") }}' 52 | assert render(tmpl) == "<i>bar</i>" 53 | 54 | def test_gettext_format_autoescape_false(self): 55 | """format html not escaped if autoescape is False""" 56 | tmpl = '{% autoescape False %}{{ _("{0}").format("bar") }}{% endautoescape %}' 57 | assert render(tmpl) == "bar" 58 | 59 | def test_ngettext(self): 60 | """ngettext works""" 61 | tmpl = '{{ ngettext("one thing", "multiple things", 1) }}' 62 | assert render(tmpl) == "one thing" 63 | tmpl = '{{ ngettext("one thing", "multiple things", 2) }}' 64 | assert render(tmpl) == "multiple things" 65 | 66 | def test_ngettext_is_safe(self): 67 | """ngettext is safe""" 68 | tmpl = '{{ ngettext("one thing", "multiple things", 1) }}' 69 | assert render(tmpl) == "one thing" 70 | tmpl = '{{ ngettext("one thing", "multiple things", 2) }}' 71 | assert render(tmpl) == "multiple things" 72 | 73 | def test_ngettext_variable_num(self): 74 | """ngettext has an implicit num variable""" 75 | tmpl = '{{ ngettext("%(num)s foo", "%(num)s foos", 1) }}' 76 | assert render(tmpl) == "1 foo" 77 | tmpl = '{{ ngettext("%(num)s foo", "%(num)s foos", 2) }}' 78 | assert render(tmpl) == "2 foos" 79 | 80 | def test_ngettext_variable_values_notsafe(self): 81 | """ngettext variable values are not safe""" 82 | tmpl = '{{ ngettext("one %(foo)s", "multiple %(foo)s", 1, foo="bar") }}' 83 | assert render(tmpl) == "one <i>bar</i>" 84 | tmpl = '{{ ngettext("one %(foo)s", "multiple %(foo)s", 2, foo="bar") }}' 85 | assert render(tmpl) == "multiple <i>bar</i>" 86 | 87 | def test_ngettext_variable_value_marked_safe_is_safe(self): 88 | tmpl = '{{ ngettext("one %(foo)s", "multiple %(foo)s", 1, foo="bar"|safe) }}' 89 | assert render(tmpl) == "one bar" 90 | tmpl = '{{ ngettext("one %(foo)s", "multiple %(foo)s", 2, foo="bar"|safe) }}' 91 | assert render(tmpl) == "multiple bar" 92 | 93 | def test_ngettext_variable_values_autoescape_false(self): 94 | tmpl = ( 95 | "{% autoescape False %}" 96 | '{{ ngettext("one %(foo)s", "multiple %(foo)s", 1, foo="bar") }}' 97 | "{% endautoescape %}" 98 | ) 99 | assert render(tmpl) == "one bar" 100 | tmpl = ( 101 | "{% autoescape False %}" 102 | '{{ ngettext("one %(foo)s", "multiple %(foo)s", 2, foo="bar") }}' 103 | "{% endautoescape %}" 104 | ) 105 | assert render(tmpl) == "multiple bar" 106 | 107 | def test_pgettext(self): 108 | tmpl = '{{ pgettext("context", "message") }}' 109 | assert render(tmpl) == "message" 110 | 111 | def test_pgettext_is_safe(self): 112 | tmpl = '{{ pgettext("context", "foo") }}' 113 | assert render(tmpl) == "foo" 114 | 115 | def test_pgettext_variable_value_notsafe(self): 116 | tmpl = '{{ pgettext("context", "%(foo)s", foo="bar") }}' 117 | assert render(tmpl) == "<i>bar</i>" 118 | 119 | def test_pgettext_variable_value_marked_safe_is_safe(self): 120 | tmpl = '{{ pgettext("context", "%(foo)s", foo="bar"|safe) }}' 121 | assert render(tmpl) == "bar" 122 | 123 | def test_pgettext_variable_values_autoescape_false(self): 124 | tmpl = ( 125 | "{% autoescape False %}" 126 | '{{ pgettext("context", "%(foo)s", foo="bar") }}' 127 | "{% endautoescape %}" 128 | ) 129 | assert render(tmpl) == "bar" 130 | 131 | def test_npgettext(self): 132 | tmpl = '{{ npgettext("context", "sing", "plur", 1) }}' 133 | assert render(tmpl) == "sing" 134 | tmpl = '{{ npgettext("context", "sing", "plur", 2) }}' 135 | assert render(tmpl) == "plur" 136 | 137 | def test_npgettext_is_safe(self): 138 | tmpl = '{{ npgettext("context", "sing", "plur", 1) }}' 139 | assert render(tmpl) == "sing" 140 | tmpl = '{{ npgettext("context", "sing", "plur", 2) }}' 141 | assert render(tmpl) == "plur" 142 | 143 | def test_npgettext_variable_num(self): 144 | tmpl = '{{ npgettext("context", "sing %(num)s", "plur %(num)s", 1) }}' 145 | assert render(tmpl) == "sing 1" 146 | tmpl = '{{ npgettext("context", "sing %(num)s", "plur %(num)s", 2) }}' 147 | assert render(tmpl) == "plur 2" 148 | 149 | def test_npgettext_variable_values_notsafe(self): 150 | tmpl = '{{ npgettext("context", "sing %(foo)s", "plur %(foo)s", 1, foo="bar") }}' 151 | assert render(tmpl) == "sing <i>bar</i>" 152 | tmpl = '{{ npgettext("context", "sing %(foo)s", "plur %(foo)s", 2, foo="bar") }}' 153 | assert render(tmpl) == "plur <i>bar</i>" 154 | 155 | def test_npgettext_variable_value_marked_safe_is_safe(self): 156 | tmpl = '{{ npgettext("context", "sing %(foo)s", "plur %(foo)s", 1, foo="bar"|safe) }}' 157 | assert render(tmpl) == "sing bar" 158 | tmpl = '{{ npgettext("context", "sing %(foo)s", "plur %(foo)s", 2, foo="bar"|safe) }}' 159 | assert render(tmpl) == "plur bar" 160 | 161 | def test_npgettext_variable_values_autoescape_false(self): 162 | tmpl = ( 163 | "{% autoescape False %}" 164 | '{{ npgettext("context", "sing %(foo)s", "plur %(foo)s", 1, foo="bar") }}' 165 | "{% endautoescape %}" 166 | ) 167 | assert render(tmpl) == "sing bar" 168 | tmpl = ( 169 | "{% autoescape False %}" 170 | '{{ npgettext("context", "sing %(foo)s", "plur %(foo)s", 2, foo="bar") }}' 171 | "{% endautoescape %}" 172 | ) 173 | assert render(tmpl) == "plur bar" 174 | 175 | def test_trans(self): 176 | tmpl = "
{% trans %}puente rules!{% endtrans %}
" 177 | assert render(tmpl) == "
puente rules!
" 178 | 179 | def test_trans_whitespace(self): 180 | tmpl = ( 181 | "
\n" 182 | " {% trans %}\n" 183 | " Puente\n" 184 | " rules!\n" 185 | " {% endtrans %}\n" 186 | "
\n" 187 | ) 188 | assert render(tmpl) == ("
\n" " Puente rules!\n" "
") 189 | 190 | def test_trans_plural(self): 191 | tmpl = ( 192 | "
\n" 193 | " {% trans count %}\n" 194 | " {{ count }} puente rules!\n" 195 | " {% pluralize %}\n" 196 | " {{ count }} puentes rule!\n" 197 | " {% endtrans %}\n" 198 | "
" 199 | ) 200 | assert render(tmpl, count=1) == ("
\n" " 1 puente rules!\n" "
") 201 | assert render(tmpl, count=2) == ("
\n" " 2 puentes rule!\n" "
") 202 | 203 | def test_trans_interpolation(self): 204 | tmpl = ( 205 | '{% trans tag="bar" %}\n' 206 | " this is a tag {{ tag }}\n" 207 | "{% endtrans %}" 208 | ) 209 | assert render(tmpl) == "this is a tag <i>bar</i>" 210 | 211 | def test_trans_interpolation_with_autoescape_off(self): 212 | tmpl = ( 213 | "{% autoescape False %}\n" 214 | ' {% trans tag="bar" %}\n' 215 | " this is a tag {{ tag }}\n" 216 | " {% endtrans %}\n" 217 | "{% endautoescape %}" 218 | ) 219 | assert render(tmpl).strip() == "this is a tag bar" 220 | 221 | def test_trans_interpolation_and_safe(self): 222 | tmpl = ( 223 | '{% trans tag="bar"|safe %}\n' 224 | " this is a tag {{ tag }}\n" 225 | "{% endtrans %}" 226 | ) 227 | assert render(tmpl) == "this is a tag bar" 228 | 229 | def test_trans_interpolation_and_safe_with_autoescape_off(self): 230 | tmpl = ( 231 | "{% autoescape False %}\n" 232 | ' {% trans tag="bar"|safe %}\n' 233 | " this is a tag {{ tag }}\n" 234 | " {% endtrans %}\n" 235 | "{% endautoescape %}" 236 | ) 237 | assert render(tmpl).strip() == "this is a tag bar" 238 | -------------------------------------------------------------------------------- /tests/test_extract.py: -------------------------------------------------------------------------------- 1 | import os 2 | from textwrap import dedent 3 | 4 | from django.core import management 5 | from django.test import TestCase 6 | 7 | from puente.commands import extract_command 8 | from puente import settings as puente_settings 9 | 10 | 11 | class TestManageExtract(TestCase): 12 | def test_help(self): 13 | try: 14 | management.call_command("extract", "--help") 15 | except SystemExit: 16 | # Calling --help causes it to call sys.exit(0) which 17 | # will otherwise exit. 18 | pass 19 | 20 | 21 | def nix_header(pot_file): 22 | """Nix the POT file header since it changes and we don't care""" 23 | return pot_file[pot_file.find("\n\n") + 2 :] 24 | 25 | 26 | class TestExtractCommand: 27 | def test_basic_extraction(self, tmpdir): 28 | # Create files to extract from 29 | tmpdir.join("foo.py").write( 30 | dedent( 31 | """\ 32 | _('python string') 33 | """ 34 | ) 35 | ) 36 | tmpdir.join("foo.html").write( 37 | dedent( 38 | """\ 39 | 40 | {{ _('html string') }} 41 | {% trans %} 42 | html trans 43 | block 44 | {% endtrans %} 45 | 46 | """ 47 | ) 48 | ) 49 | 50 | # Extract 51 | extract_command( 52 | outputdir=str(tmpdir), 53 | domain_methods={ 54 | "django": [ 55 | ("*.py", "python"), 56 | ("*.html", "jinja2"), 57 | ] 58 | }, 59 | text_domain=puente_settings.TEXT_DOMAIN, 60 | keywords=puente_settings.KEYWORDS, 61 | comment_tags=puente_settings.COMMENT_TAGS, 62 | base_dir=str(tmpdir), 63 | project=puente_settings.PROJECT, 64 | version=puente_settings.VERSION, 65 | msgid_bugs_address=puente_settings.MSGID_BUGS_ADDRESS, 66 | ) 67 | 68 | # Verify contents 69 | assert os.path.exists(str(tmpdir.join("django.pot"))) 70 | pot_file = nix_header(tmpdir.join("django.pot").read()) 71 | assert pot_file == dedent( 72 | """\ 73 | #: foo.html:2 74 | msgid "html string" 75 | msgstr "" 76 | 77 | #: foo.html:3 78 | msgid "html trans block" 79 | msgstr "" 80 | 81 | #: foo.py:1 82 | msgid "python string" 83 | msgstr "" 84 | 85 | """ 86 | ) 87 | 88 | def test_header(self, tmpdir): 89 | # Extract 90 | extract_command( 91 | outputdir=str(tmpdir), 92 | domain_methods={ 93 | "django": [ 94 | ("*.py", "python"), 95 | ("*.html", "jinja2"), 96 | ] 97 | }, 98 | text_domain=puente_settings.TEXT_DOMAIN, 99 | keywords=puente_settings.KEYWORDS, 100 | comment_tags=puente_settings.COMMENT_TAGS, 101 | base_dir=str(tmpdir), 102 | project="Fjord", 103 | version="2000", 104 | msgid_bugs_address="https://bugzilla.mozilla.org/", 105 | ) 106 | 107 | # Verify contents 108 | assert os.path.exists(str(tmpdir.join("django.pot"))) 109 | pot_file = tmpdir.join("django.pot").read().splitlines() 110 | 111 | def get_value(key, pot_file): 112 | """Returns the value for a given key in a potfile header 113 | 114 | A line looks like this:: 115 | 116 | "Project-Id-Version: PROJECT VERSION\n" 117 | 118 | Given the key "Project-Id-Version" we want "PROJECT VERSION". 119 | 120 | """ 121 | 122 | line = [line for line in pot_file if line.startswith('"' + key)][0] 123 | return line.split(" ", 1)[1][:-3] # slash, n, double-quote 124 | 125 | assert get_value("Project-Id-Version", pot_file) == "Fjord 2000" 126 | assert ( 127 | get_value("Report-Msgid-Bugs-To", pot_file) 128 | == "https://bugzilla.mozilla.org/" 129 | ) 130 | 131 | def test_whitespace_collapsing(self, tmpdir): 132 | # We collapse whitespace in Jinja2 trans tags and that's it. 133 | tmpdir.join("foo.py").write( 134 | dedent( 135 | """\ 136 | _(" gettext1 test ") 137 | """ 138 | ) 139 | ) 140 | tmpdir.join("foo.html").write( 141 | dedent( 142 | """\ 143 | {{ _(" gettext2 test ") }} 144 | """ 145 | ) 146 | ) 147 | tmpdir.join("foo2.html").write( 148 | dedent( 149 | """\ 150 | {% trans %} 151 | trans 152 | tag 153 | test 154 | {% endtrans %} 155 | """ 156 | ) 157 | ) 158 | 159 | # Extract 160 | extract_command( 161 | outputdir=str(tmpdir), 162 | domain_methods={ 163 | "django": [ 164 | ("*.py", "python"), 165 | ("*.html", "jinja2"), 166 | ] 167 | }, 168 | text_domain=puente_settings.TEXT_DOMAIN, 169 | keywords=puente_settings.KEYWORDS, 170 | comment_tags=puente_settings.COMMENT_TAGS, 171 | base_dir=str(tmpdir), 172 | project=puente_settings.PROJECT, 173 | version=puente_settings.VERSION, 174 | msgid_bugs_address=puente_settings.MSGID_BUGS_ADDRESS, 175 | ) 176 | 177 | # Verify contents 178 | assert os.path.exists(str(tmpdir.join("django.pot"))) 179 | pot_file = nix_header(tmpdir.join("django.pot").read()) 180 | assert pot_file == dedent( 181 | """\ 182 | #: foo.html:1 183 | msgid " gettext2 test " 184 | msgstr "" 185 | 186 | #: foo.py:1 187 | msgid " gettext1 test " 188 | msgstr "" 189 | 190 | #: foo2.html:1 191 | msgid "trans tag test" 192 | msgstr "" 193 | 194 | """ 195 | ) 196 | 197 | def test_context(self, tmpdir): 198 | # Test context 199 | tmpdir.join("foo.py").write( 200 | dedent( 201 | """\ 202 | pgettext("context", "string") 203 | """ 204 | ) 205 | ) 206 | tmpdir.join("foo.html").write( 207 | dedent( 208 | """\ 209 | {{ _(" gettext2 test ", "context") }} 210 | """ 211 | ) 212 | ) 213 | 214 | # Extract 215 | extract_command( 216 | outputdir=str(tmpdir), 217 | domain_methods={ 218 | "django": [ 219 | ("*.py", "python"), 220 | ("*.html", "jinja2"), 221 | ] 222 | }, 223 | text_domain=puente_settings.TEXT_DOMAIN, 224 | keywords=puente_settings.KEYWORDS, 225 | comment_tags=puente_settings.COMMENT_TAGS, 226 | base_dir=str(tmpdir), 227 | project=puente_settings.PROJECT, 228 | version=puente_settings.VERSION, 229 | msgid_bugs_address=puente_settings.MSGID_BUGS_ADDRESS, 230 | ) 231 | 232 | # Verify contents 233 | assert os.path.exists(str(tmpdir.join("django.pot"))) 234 | pot_file = nix_header(tmpdir.join("django.pot").read()) 235 | assert pot_file == dedent( 236 | """\ 237 | #: foo.html:1 238 | msgid " gettext2 test " 239 | msgstr "" 240 | 241 | #: foo.py:1 242 | msgctxt "context" 243 | msgid "string" 244 | msgstr "" 245 | 246 | """ 247 | ) 248 | 249 | def test_plurals(self, tmpdir): 250 | # Test ngettext 251 | tmpdir.join("foo.py").write( 252 | dedent( 253 | """\ 254 | ngettext('%(num)s thing', '%(num)s things', num) 255 | """ 256 | ) 257 | ) 258 | tmpdir.join("foo.html").write( 259 | dedent( 260 | """\ 261 | {{ ngettext('html %(num)s thing', 'html %(num)s things', num) }} 262 | {% trans num=num %} 263 | There is {{ num }} thing. 264 | {% pluralize %} 265 | There are {{ num }} things. 266 | {% endtrans %} 267 | """ 268 | ) 269 | ) 270 | 271 | # Extract 272 | extract_command( 273 | outputdir=str(tmpdir), 274 | domain_methods={ 275 | "django": [ 276 | ("*.py", "python"), 277 | ("*.html", "jinja2"), 278 | ] 279 | }, 280 | text_domain=puente_settings.TEXT_DOMAIN, 281 | keywords=puente_settings.KEYWORDS, 282 | comment_tags=puente_settings.COMMENT_TAGS, 283 | base_dir=str(tmpdir), 284 | project=puente_settings.PROJECT, 285 | version=puente_settings.VERSION, 286 | msgid_bugs_address=puente_settings.MSGID_BUGS_ADDRESS, 287 | ) 288 | 289 | # Verify contents 290 | assert os.path.exists(str(tmpdir.join("django.pot"))) 291 | pot_file = nix_header(tmpdir.join("django.pot").read()) 292 | assert pot_file == dedent( 293 | """\ 294 | #: foo.html:1 295 | #, python-format 296 | msgid "html %(num)s thing" 297 | msgid_plural "html %(num)s things" 298 | msgstr[0] "" 299 | msgstr[1] "" 300 | 301 | #: foo.html:2 302 | #, python-format 303 | msgid "There is %(num)s thing." 304 | msgid_plural "There are %(num)s things." 305 | msgstr[0] "" 306 | msgstr[1] "" 307 | 308 | #: foo.py:1 309 | #, python-format 310 | msgid "%(num)s thing" 311 | msgid_plural "%(num)s things" 312 | msgstr[0] "" 313 | msgstr[1] "" 314 | 315 | """ 316 | ) 317 | 318 | def test_django_pgettext_keywords(self, tmpdir): 319 | # Test context 320 | tmpdir.join("foo.py").write( 321 | dedent( 322 | """\ 323 | pgettext("context1", "string1") 324 | pgettext_lazy("context2", "string2") 325 | npgettext("context3", "string3", "plural3", 5) 326 | npgettext_lazy("context4", "string4", "plural4", 5) 327 | """ 328 | ) 329 | ) 330 | 331 | # Extract 332 | extract_command( 333 | outputdir=str(tmpdir), 334 | domain_methods={ 335 | "django": [ 336 | ("*.py", "python"), 337 | ] 338 | }, 339 | text_domain=puente_settings.TEXT_DOMAIN, 340 | keywords=puente_settings.KEYWORDS, 341 | comment_tags=puente_settings.COMMENT_TAGS, 342 | base_dir=str(tmpdir), 343 | project=puente_settings.PROJECT, 344 | version=puente_settings.VERSION, 345 | msgid_bugs_address=puente_settings.MSGID_BUGS_ADDRESS, 346 | ) 347 | 348 | # Verify contents 349 | assert os.path.exists(str(tmpdir.join("django.pot"))) 350 | pot_file = nix_header(tmpdir.join("django.pot").read()) 351 | assert pot_file == dedent( 352 | """\ 353 | #: foo.py:1 354 | msgctxt "context1" 355 | msgid "string1" 356 | msgstr "" 357 | 358 | #: foo.py:2 359 | msgctxt "context2" 360 | msgid "string2" 361 | msgstr "" 362 | 363 | #: foo.py:3 364 | msgctxt "context3" 365 | msgid "string3" 366 | msgid_plural "plural3" 367 | msgstr[0] "" 368 | msgstr[1] "" 369 | 370 | #: foo.py:4 371 | msgctxt "context4" 372 | msgid "string4" 373 | msgid_plural "plural4" 374 | msgstr[0] "" 375 | msgstr[1] "" 376 | 377 | """ 378 | ) 379 | -------------------------------------------------------------------------------- /tests/test_merge.py: -------------------------------------------------------------------------------- 1 | import os 2 | from textwrap import dedent 3 | 4 | import pytest 5 | 6 | from django.core import management 7 | from django.core.management import CommandError 8 | from django.test import TestCase 9 | 10 | from puente.commands import merge_command 11 | 12 | 13 | class TestManageMerge(TestCase): 14 | def test_help(self): 15 | try: 16 | management.call_command("merge", "--help") 17 | except SystemExit: 18 | # Calling --help causes it to call sys.exit(0) which 19 | # will otherwise exit. 20 | pass 21 | 22 | 23 | def build_filesystem(basedir, files): 24 | for path, contents in files.items(): 25 | path = os.path.abspath(os.path.join(basedir, path)) 26 | dirname = os.path.dirname(path) 27 | if not os.path.exists(dirname): 28 | os.makedirs(dirname) 29 | with open(path, "w") as fp: 30 | fp.write(contents) 31 | 32 | 33 | class TestMergecommand: 34 | def test_basic(self, tmpdir): 35 | locale_dir = tmpdir.join("locale") 36 | build_filesystem( 37 | str(locale_dir), 38 | { 39 | "templates/LC_MESSAGES/django.pot": dedent( 40 | """\ 41 | #, fuzzy 42 | msgid "" 43 | msgstr "" 44 | "Project-Id-Version: PACKAGE VERSION\\n" 45 | "Report-Msgid-Bugs-To: \\n" 46 | "POT-Creation-Date: 2015-10-28 16:18+0000\\n" 47 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\\n" 48 | "Last-Translator: FULL NAME \\n" 49 | "Language-Team: LANGUAGE \\n" 50 | "MIME-Version: 1.0\\n" 51 | "Content-Type: text/plain; charset=UTF-8\\n" 52 | "Content-Transfer-Encoding: 8bit\\n" 53 | "X-Generator: Translate Toolkit 1.13.0\\n" 54 | 55 | #: foo.html:2 56 | msgid "html string" 57 | msgstr "" 58 | 59 | #: foo.html:3 60 | msgid "html trans block" 61 | msgstr "" 62 | 63 | #: foo.py:1 64 | msgid "python string" 65 | msgstr "" 66 | """ 67 | ), 68 | }, 69 | ) 70 | 71 | merge_command( 72 | create=True, 73 | backup=True, 74 | base_dir=str(tmpdir), 75 | domain_methods={ 76 | "django": [ 77 | ("*.py", "python"), 78 | ("*.html", "jinja2"), 79 | ] 80 | }, 81 | languages=["de", "en-US", "fr"], 82 | ) 83 | 84 | assert locale_dir.join("de", "LC_MESSAGES", "django.po").exists() 85 | assert locale_dir.join("en_US", "LC_MESSAGES", "django.po").exists() 86 | assert locale_dir.join("fr", "LC_MESSAGES", "django.po").exists() 87 | 88 | def test_missing_pot_file(self, tmpdir): 89 | with pytest.raises(CommandError): 90 | merge_command( 91 | create=True, 92 | backup=True, 93 | base_dir=str(tmpdir), 94 | domain_methods={ 95 | "django": [ 96 | ("*.py", "python"), 97 | ("*.html", "jinja2"), 98 | ] 99 | }, 100 | languages=["de", "en-US", "fr"], 101 | ) 102 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from puente.utils import collapse_whitespace, generate_keywords 2 | 3 | 4 | class TestGenerateKeywords: 5 | def test_basic(self): 6 | # Get the keywords and then do a spot check for the keywords that are 7 | # important that we're likely to be using. We don't do a full 8 | # comparison because Babel can change and we don't want our tests 9 | # breaking if breaking isn't helpful. 10 | kwds = generate_keywords() 11 | assert kwds["_"] is None 12 | assert kwds["_lazy"] is None 13 | assert kwds["gettext"] is None 14 | assert kwds["ngettext"] == (1, 2) 15 | assert kwds["pgettext"] == ((1, "c"), 2) 16 | 17 | def test_with_args(self): 18 | kwds = generate_keywords( 19 | { 20 | # Add a new one 21 | "foo": None, 22 | # Override an existing one 23 | "_": (1, 2), 24 | } 25 | ) 26 | 27 | assert kwds["foo"] is None 28 | assert kwds["_"] == (1, 2) 29 | 30 | 31 | def test_collapse_whitespace(): 32 | data = [ 33 | ("", ""), 34 | (" ", ""), 35 | (" \n\t\r\n ", ""), 36 | ("foo\n\nbar", "foo bar"), 37 | ("foo", "foo"), 38 | (" foo ", "foo"), 39 | (" foo\n bar", "foo bar"), 40 | ] 41 | for text, expected in data: 42 | assert collapse_whitespace(text) == expected 43 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | skip_missing_interpreters = true 3 | envlist = 4 | py{37,38,39,310} 5 | 6 | [testenv] 7 | setenv = 8 | PYTHONWARNINGS=default 9 | PYTHONPATH = {toxinidir}:{toxinidir}/puente 10 | 11 | basepython = 12 | py37: python3.7 13 | py38: python3.8 14 | py39: python3.9 15 | py310: python3.10 16 | 17 | deps = 18 | pytest 19 | pytest-pythonpath 20 | pytest-django 21 | django-jinja 22 | Django==3.2.13 23 | 24 | commands = py.test 25 | --------------------------------------------------------------------------------